From 3d22fd02f6748624c3362c69b4c46a85cc351ac1 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Mon, 29 Jun 2026 23:52:19 -0400 Subject: [PATCH 01/20] initial commit on internal renderer --- src/engines/engine.hpp | 9 +- src/framework/CMakeLists.txt | 1 + src/framework/domain/metadomain.h | 13 + src/framework/domain/metadomain_comm.cpp | 34 +++ src/framework/domain/metadomain_render.cpp | 315 +++++++++++++++++++ src/global/global.h | 1 + src/global/utils/diag.cpp | 4 + src/global/utils/diag.h | 2 + src/global/utils/timer.cpp | 6 +- src/output/CMakeLists.txt | 1 + src/output/render/composite.h | 77 +++++ src/output/render/png.h | 174 +++++++++++ src/output/render/raymarch.hpp | 298 ++++++++++++++++++ src/output/render/renderer.cpp | 334 +++++++++++++++++++++ src/output/render/renderer.h | 171 +++++++++++ src/output/render/transfer_fn.h | 192 ++++++++++++ 16 files changed, 1630 insertions(+), 2 deletions(-) create mode 100644 src/framework/domain/metadomain_render.cpp create mode 100644 src/output/render/composite.h create mode 100644 src/output/render/png.h create mode 100644 src/output/render/raymarch.hpp create mode 100644 src/output/render/renderer.cpp create mode 100644 src/output/render/renderer.h create mode 100644 src/output/render/transfer_fn.h diff --git a/src/engines/engine.hpp b/src/engines/engine.hpp index 161339a9d..95985717d 100644 --- a/src/engines/engine.hpp +++ b/src/engines/engine.hpp @@ -142,6 +142,7 @@ namespace ntt { #if defined(OUTPUT_ENABLED) m_metadomain.InitWriter(&m_adios, m_params); m_metadomain.InitCheckpointWriter(&m_adios, m_params); + m_metadomain.InitRenderer(m_params); #endif logger::Checkpoint("Initializing Engine", HERE); if (not is_resuming) { @@ -255,7 +256,7 @@ namespace ntt { "Injector", "Custom", "LoadBalance", "ParticleSort", "Output", - "Checkpoint" }, + "Render", "Checkpoint" }, []() { Kokkos::fence(); }, @@ -323,6 +324,7 @@ namespace ntt { ++step; auto print_output = false; + auto print_render = false; auto print_checkpoint = false; #if defined(OUTPUT_ENABLED) timers.start("Output"); @@ -368,6 +370,10 @@ namespace ntt { } timers.stop("Output"); + timers.start("Render"); + print_render = m_metadomain.Render(m_params, step, step - 1, time, time - dt); + timers.stop("Render"); + timers.start("Checkpoint"); print_checkpoint = m_metadomain.WriteCheckpoint(m_params, step, @@ -394,6 +400,7 @@ namespace ntt { m_metadomain.l_maxnpart_perspec(), print_prtl_clear, print_output, + print_render, print_checkpoint, m_params.get("diagnostics.colored_stdout")); } diff --git a/src/framework/CMakeLists.txt b/src/framework/CMakeLists.txt index 377c1f98a..a6c5317cf 100644 --- a/src/framework/CMakeLists.txt +++ b/src/framework/CMakeLists.txt @@ -66,6 +66,7 @@ set(SOURCES ${SRC_DIR}/containers/fields.cpp) if(${output}) list(APPEND SOURCES ${SRC_DIR}/domain/metadomain_io.cpp) + list(APPEND SOURCES ${SRC_DIR}/domain/metadomain_render.cpp) list(APPEND SOURCES ${SRC_DIR}/domain/metadomain_chckpt.cpp) list(APPEND SOURCES ${SRC_DIR}/containers/fields_io.cpp) list(APPEND SOURCES ${SRC_DIR}/containers/particles_io.cpp) diff --git a/src/framework/domain/metadomain.h b/src/framework/domain/metadomain.h index f090dade1..7e5d9f420 100644 --- a/src/framework/domain/metadomain.h +++ b/src/framework/domain/metadomain.h @@ -39,6 +39,7 @@ #if defined(OUTPUT_ENABLED) #include "output/checkpoint.h" + #include "output/render/renderer.h" #include "output/writer.h" #include @@ -96,6 +97,9 @@ namespace ntt { void SynchronizeFields(Domain&, CommTags, const cell_range_t& = { 0, 0 }) const; + // Halo-fill of the bckp buffer (neighbor active cells -> local ghosts), + // used by the in-situ renderer for seamless trilinear sampling. + void CommunicateBckp(Domain&, const cell_range_t&) const; #if defined(MPI_ENABLED) && defined(OUTPUT_ENABLED) void CommunicateVectorPotential(unsigned short); #endif @@ -173,6 +177,14 @@ namespace ntt { void ContinueFromCheckpoint(adios2::ADIOS*, const SimulationParams&); void redecomposeFromCheckpoint(const std::vector>&, const std::vector>&); + + /* in-situ volume renderer (see metadomain_render.cpp) ------------------ */ + void InitRenderer(const SimulationParams&); + auto Render(const SimulationParams&, + timestep_t, + timestep_t, + simtime_t, + simtime_t) -> bool; #endif void InitStatsWriter(const SimulationParams&, bool); @@ -305,6 +317,7 @@ namespace ntt { #if defined(OUTPUT_ENABLED) out::Writer g_writer; checkpoint::Writer g_checkpoint_writer; + out::Renderer g_renderer; #endif #if defined(MPI_ENABLED) diff --git a/src/framework/domain/metadomain_comm.cpp b/src/framework/domain/metadomain_comm.cpp index f8c0f60d7..dd18b8e25 100644 --- a/src/framework/domain/metadomain_comm.cpp +++ b/src/framework/domain/metadomain_comm.cpp @@ -652,6 +652,38 @@ namespace ntt { #endif } + template + void Metadomain::CommunicateBckp(Domain& domain, + const cell_range_t& components) const { + // Halo FILL of the bckp buffer: copy each neighbor's active boundary cells + // into this domain's ghost zones (additive=false). This is distinct from + // SynchronizeFields, which sums ghost-deposited values back into active + // cells. The renderer needs the ghost halo populated so trilinear sampling + // near a domain face reads valid neighbor values (C0 across the face). + for (auto& direction : dir::Directions::all) { + const auto [send_params, + recv_params] = GetSendRecvParams(this, domain, direction, false); + const auto [send_indrank, send_slice] = send_params; + const auto [recv_indrank, recv_slice] = recv_params; + const auto [send_ind, send_rank] = send_indrank; + const auto [recv_ind, recv_rank] = recv_indrank; + if (send_rank < 0 and recv_rank < 0) { + continue; + } + comm::CommunicateField(domain.index(), + domain.fields.bckp, + domain.fields.bckp, + send_ind, + recv_ind, + send_rank, + recv_rank, + send_slice, + recv_slice, + components, + false); + } + } + // NOLINTBEGIN(bugprone-macro-parentheses) #define METADOMAIN_COMM(S, M, D) \ template void Metadomain>::CommunicateFields(Domain>&, \ @@ -659,6 +691,8 @@ namespace ntt { template void Metadomain>::SynchronizeFields(Domain>&, \ CommTags, \ const cell_range_t&) const; \ + template void Metadomain>::CommunicateBckp(Domain>&, \ + const cell_range_t&) const; \ template void Metadomain>::CommunicateParticles(Domain>&) const; NTT_FOREACH_SPECIALIZATION(METADOMAIN_COMM) diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp new file mode 100644 index 000000000..4e9cbf696 --- /dev/null +++ b/src/framework/domain/metadomain_render.cpp @@ -0,0 +1,315 @@ +/** + * @file framework/domain/metadomain_render.cpp + * @brief Metadomain driver for the in-situ volume renderer + * @implements + * - ntt::Metadomain::InitRenderer + * - ntt::Metadomain::Render + * @namespaces: + * - ntt:: + * @macros: + * - MPI_ENABLED + * - OUTPUT_ENABLED + * @note + * This is the templated counterpart of the (plain) out::Renderer: it owns the + * per-(engine, metric, dim) field preparation, the device ray-march kernel + * launch, and the device->host copy. It reuses the exact field-prep code paths + * as Metadomain::Write (ComputeMoments / FieldsToPhys), then hands the per-rank + * host image to out::Renderer for the MPI ordered composite and PNG write. + * Active only for 3D Minkowski; a no-op otherwise (the structured-order + * composite assumes an axis-aligned, affine code<->world map). + */ + +#include "enums.h" +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "traits/metric.h" +#include "utils/error.h" +#include "utils/log.h" + +#include "framework/containers/particles.h" +#include "framework/domain/domain.h" +#include "framework/domain/mesh.h" +#include "framework/domain/metadomain.h" +#include "framework/parameters/parameters.h" +#include "framework/specialization_registry.h" +#include "kernels/fields_to_phys.hpp" +#include "kernels/particle_moments.hpp" +#include "output/render/composite.h" +#include "output/render/raymarch.hpp" + +#include +#include + +#include +#include +#include + +namespace ntt { + + namespace { + + // Mirror of Metadomain::Write's ComputeMoments, kept with internal linkage + // so it does not collide with the (identically-named) one in metadomain_io. + template + void renderMoment(const SimulationParams& params, + const Mesh& mesh, + const std::vector>& prtl_species, + ndfield_t& buffer, + idx_t buff_idx) { + std::vector specs; + for (auto& sp : prtl_species) { + if (sp.mass() > 0) { + specs.push_back(sp.index()); + } + } + auto scatter_buff = Kokkos::Experimental::create_scatter_view(buffer); + const auto use_weights = params.get("particles.use_weights"); + const auto ni2 = mesh.n_active(in::x2); + const auto inv_n0 = ONE / params.get("scales.n0"); + const auto smooth_order = params.get( + "output.fields.smoothing.order"); + const auto smooth_method = OutputSmoothingType::from_string( + params.get("output.fields.smoothing.method")); + const std::vector components {}; + for (const auto& sp : specs) { + auto& prtl_spec = prtl_species[sp - 1]; + Kokkos::parallel_for( + "RenderComputeMoments", + prtl_spec.rangeActiveParticles(), + kernel::ParticleMoments_kernel(components, + scatter_buff, + buff_idx, + prtl_spec, + use_weights, + mesh.metric, + mesh.flds_bc(), + ni2, + inv_n0, + smooth_order, + smooth_method)); + } + Kokkos::Experimental::contribute(buffer, scatter_buff); + } + + } // namespace + + template + void Metadomain::InitRenderer(const SimulationParams& params) { + g_renderer.init(params, mesh().extent()); + } + + template + auto Metadomain::Render(const SimulationParams& params, + timestep_t current_step, + timestep_t finished_step, + simtime_t current_time, + simtime_t finished_time) -> bool { + if constexpr (M::Dim == Dim::_3D and M::CoordType == Coord::type::Cartesian) { + // structured-order composite assumes an axis-aligned, affine code<->world + // map; only Cartesian (Minkowski) 3D qualifies. + if (not g_renderer.enabled() or + not g_renderer.shouldRender(finished_step, finished_time)) { + return false; + } + raise::ErrorIf(l_subdomain_indices().size() != 1, + "Renderer supports one subdomain per rank only", + HERE); + auto local_domain = subdomain_ptr(l_subdomain_indices()[0]); + raise::ErrorIf(local_domain->is_placeholder(), + "local_domain is a placeholder", + HERE); + logger::Checkpoint("Rendering output", HERE); + + const auto& cam = g_renderer.camera(); + const int W = g_renderer.width(); + const int H = g_renderer.height(); + + // per-domain world AABB + const auto loc_ext = local_domain->mesh.extent(); + real_t lo[3] = { loc_ext[0].first, loc_ext[1].first, loc_ext[2].first }; + real_t hi[3] = { loc_ext[0].second, loc_ext[1].second, loc_ext[2].second }; + + // fixed global world step (identical on all ranks -> seamless) + const auto glob_ext = mesh().extent(); + real_t gdiag = ZERO; + for (auto d { 0 }; d < 3; ++d) { + const real_t s = glob_ext[d].second - glob_ext[d].first; + gdiag += s * s; + } + gdiag = math::sqrt(gdiag); + const real_t ds = (g_renderer.stepSize() > ZERO) + ? g_renderer.stepSize() + : gdiag / static_cast(g_renderer.samples()); + const int max_steps = 2 * g_renderer.samples() + 16; + + // composite order key (depends on the current decomposition offsets) + const uint64_t order_key = out::compositeOrderKey( + local_domain->offset_ndomains(), + ndomains_per_dim(), + cam.forward); + + auto& bckp = local_domain->fields.bckp; + const int ext0 = static_cast(bckp.extent(0)); + const int ext1 = static_cast(bckp.extent(1)); + const int ext2 = static_cast(bckp.extent(2)); + + const auto metric = local_domain->mesh.metric; + + bool rendered_any = false; + for (const auto& scene : g_renderer.scenes()) { + Kokkos::deep_copy(bckp, ZERO); + + if (scene.field == "N") { + renderMoment(params, + local_domain->mesh, + local_domain->species, + bckp, + 0u); + // sum boundary-crossing particle deposits back into active cells + // (particles in neighbor domains deposit into our ghost zone) + SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); + } else if (scene.field == "Bmag" or scene.field == "Jmag") { + const bool is_current = (scene.field == "Jmag"); + // raw vector components into bckp(:,0..2) + if (is_current) { + Kokkos::deep_copy( + Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, + cell_range_t(0, 3)), + Kokkos::subview(local_domain->fields.cur, Kokkos::ALL, Kokkos::ALL, + Kokkos::ALL, cell_range_t(cur::jx1, cur::jx3 + 1))); + } else { + Kokkos::deep_copy( + Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, + cell_range_t(0, 3)), + Kokkos::subview(local_domain->fields.em, Kokkos::ALL, Kokkos::ALL, + Kokkos::ALL, cell_range_t(em::bx1, em::bx3 + 1))); + } + // interpolate to cell centers + convert to physical basis -> (3,4,5) + PrepareOutputFlags interp = is_current + ? PrepareOutput::InterpToCellCenterFromEdges + : PrepareOutput::InterpToCellCenterFromFaces; + PrepareOutputFlags prepare = (S == SimEngine::SRPIC) + ? PrepareOutput::ConvertToHat + : PrepareOutput::ConvertToPhysCntrv; + list_t comp_from = { 0, 1, 2 }; + list_t comp_to = { 3, 4, 5 }; + Kokkos::parallel_for( + "RenderFieldsToPhys", + local_domain->mesh.rangeActiveCells(), + kernel::FieldsToPhys_kernel(bckp, + bckp, + comp_from, + comp_to, + interp | prepare, + metric)); + // magnitude -> bckp(:,0) + auto bckp_v = bckp; + Kokkos::parallel_for( + "RenderVectorMagnitude", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + const real_t v1 = bckp_v(i1, i2, i3, 3); + const real_t v2 = bckp_v(i1, i2, i3, 4); + const real_t v3 = bckp_v(i1, i2, i3, 5); + bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); + }); + } else if (scene.field == "smooth_xyz") { + // continuous-by-construction regression field: x + y + z + auto bckp_v = bckp; + Kokkos::parallel_for( + "RenderSmoothXYZ", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + coord_t x_Cd { ZERO }, x_Ph { ZERO }; + x_Cd[0] = COORD(i1) + HALF; + x_Cd[1] = COORD(i2) + HALF; + x_Cd[2] = COORD(i3) + HALF; + metric.template convert(x_Cd, x_Ph); + bckp_v(i1, i2, i3, 0) = x_Ph[0] + x_Ph[1] + x_Ph[2]; + }); + } else { + raise::Warning( + "output.render: unknown field '" + scene.field + "', skipping", + HERE); + continue; + } + + // fill the ghost halo with neighbor active values so trilinear + // sampling is C0 across domain faces (this is a halo EXCHANGE, not the + // sum-into-active that SynchronizeFields performs). + CommunicateBckp(*local_domain, { 0, 1 }); + + // ---- launch the ray-march kernel ------------------------------- // + array_t image { "render_img", + static_cast(W) * + static_cast(H) }; + randacc_ndfield_t Fld { bckp }; + Kokkos::parallel_for( + "VolumeRayMarch", + CreateRangePolicy({ 0, 0 }, + { static_cast(W), + static_cast(H) }), + kernel::VolumeRayMarch_kernel(Fld, + 0u, + metric, + cam, + lo, + hi, + ext0, + ext1, + ext2, + W, + H, + ds, + max_steps, + scene.tf.lut, + scene.tf.n_lut, + scene.tf.vmin, + scene.tf.vmax, + scene.tf.log_scale, + g_renderer.earlyAlpha(), + image)); + Kokkos::fence(); + + // device -> host, into a layout-agnostic pixel-major buffer + auto image_h = Kokkos::create_mirror_view(image); + Kokkos::deep_copy(image_h, image); + const std::size_t npix = static_cast(W) * + static_cast(H); + std::vector rgba(npix * 4); + for (std::size_t p = 0; p < npix; ++p) { + rgba[p * 4 + 0] = image_h(p, 0); + rgba[p * 4 + 1] = image_h(p, 1); + rgba[p * 4 + 2] = image_h(p, 2); + rgba[p * 4 + 3] = image_h(p, 3); + } + g_renderer.compositeAndWrite(rgba, order_key, scene.prefix, current_step); + rendered_any = true; + } + return rendered_any; + } else { + (void)params; + (void)current_step; + (void)finished_step; + (void)current_time; + (void)finished_time; + return false; + } + } + + // NOLINTBEGIN(bugprone-macro-parentheses) +#define METADOMAIN_RENDER(S, M, D) \ + template void Metadomain>::InitRenderer(const SimulationParams&); \ + template auto Metadomain>::Render(const SimulationParams&, \ + timestep_t, \ + timestep_t, \ + simtime_t, \ + simtime_t) -> bool; + + NTT_FOREACH_SPECIALIZATION(METADOMAIN_RENDER) + +#undef METADOMAIN_RENDER + // NOLINTEND(bugprone-macro-parentheses) + +} // namespace ntt diff --git a/src/global/global.h b/src/global/global.h index c3fbc5061..dafd8663c 100644 --- a/src/global/global.h +++ b/src/global/global.h @@ -254,6 +254,7 @@ namespace Timer { PrintParticleSort = 1 << 4, PrintCheckpoint = 1 << 5, PrintNormed = 1 << 6, + PrintRender = 1 << 7, Default = PrintNormed | PrintTotal | PrintTitle | AutoConvert, }; } // namespace Timer diff --git a/src/global/utils/diag.cpp b/src/global/utils/diag.cpp index 604a872ae..f22bc8137 100644 --- a/src/global/utils/diag.cpp +++ b/src/global/utils/diag.cpp @@ -90,6 +90,7 @@ namespace diag { const std::vector& species_maxnpart, bool print_prtl_clear, bool print_output, + bool print_render, bool print_checkpoint, bool print_colors) { DiagFlags diag_flags = Diag::Default; @@ -106,6 +107,9 @@ namespace diag { if (print_output) { timer_flags |= Timer::PrintOutput; } + if (print_render) { + timer_flags |= Timer::PrintRender; + } if (print_checkpoint) { timer_flags |= Timer::PrintCheckpoint; } diff --git a/src/global/utils/diag.h b/src/global/utils/diag.h index 18669c1f1..61186aa1c 100644 --- a/src/global/utils/diag.h +++ b/src/global/utils/diag.h @@ -36,6 +36,7 @@ namespace diag { * @param maxnpart (per each species) * @param particlesort (if true, dead particles were removed) * @param output (if true, output was written) + * @param render (if true, a volume render was produced) * @param checkpoint (if true, checkpoint was written) * @param colorful_print (if true, print with colors) */ @@ -52,6 +53,7 @@ namespace diag { bool, bool, bool, + bool, bool); } // namespace diag diff --git a/src/global/utils/timer.cpp b/src/global/utils/timer.cpp index 6f2045be6..85647d899 100644 --- a/src/global/utils/timer.cpp +++ b/src/global/utils/timer.cpp @@ -136,7 +136,10 @@ namespace timer { auto Timers::printAll(TimerFlags flags, npart_t npart, ncells_t ncells) const -> std::string { - const std::vector extras { "ParticleSort", "Output", "Checkpoint" }; + const std::vector extras { "ParticleSort", + "Output", + "Render", + "Checkpoint" }; const auto stats = gather(extras, npart, ncells); if (stats.empty()) { return ""; @@ -262,6 +265,7 @@ namespace timer { // print extra timers for output/checkpoint/particleSort const std::vector extras_f { Timer::PrintParticleSort, Timer::PrintOutput, + Timer::PrintRender, Timer::PrintCheckpoint }; for (auto i { 0u }; i < extras.size(); ++i) { const auto& name = extras[i]; diff --git a/src/output/CMakeLists.txt b/src/output/CMakeLists.txt index 4cf1bf410..f0a490d5e 100644 --- a/src/output/CMakeLists.txt +++ b/src/output/CMakeLists.txt @@ -31,6 +31,7 @@ set(SOURCES ${SRC_DIR}/stats.cpp ${SRC_DIR}/fields.cpp if(${output}) list(APPEND SOURCES ${SRC_DIR}/writer.cpp) list(APPEND SOURCES ${SRC_DIR}/checkpoint.cpp) + list(APPEND SOURCES ${SRC_DIR}/render/renderer.cpp) list(APPEND SOURCES ${SRC_DIR}/utils/writers.cpp) list(APPEND SOURCES ${SRC_DIR}/utils/readers.cpp) list(APPEND SOURCES ${SRC_DIR}/utils/tuning.cpp) diff --git a/src/output/render/composite.h b/src/output/render/composite.h new file mode 100644 index 000000000..39ea31aad --- /dev/null +++ b/src/output/render/composite.h @@ -0,0 +1,77 @@ +/** + * @file output/render/composite.h + * @brief Front-to-back visibility ordering for the structured decomposition + * and the premultiplied "over" compositing operator. + * @implements + * - out::compositeOrderKey + * - out::overComposite + * @namespaces: + * - out:: + * @note + * entity decomposes the global box into a regular Dx x Dy x Dz grid of domains + * (domain index == MPI rank). For a camera viewing the box from outside, the + * correct global front-to-back order is a deterministic per-axis ordering by + * which side of each split plane the camera sits on -- no general depth sort, + * no cyclic overlap. Ordered premultiplied "over" of the non-overlapping, + * correctly-ordered per-domain segments reconstructs the single-image ray + * integral, hence is seamless. + */ + +#ifndef OUTPUT_RENDER_COMPOSITE_H +#define OUTPUT_RENDER_COMPOSITE_H + +#include "global.h" + +#include "utils/numeric.h" + +#include +#include + +namespace out { + + /** + * @brief Total-order sort key placing nearer domains first (front-to-back). + * @param offset integer grid coordinate of the domain (offset_ndomains) + * @param ndoms number of domains per axis (ndomains_per_dim) + * @param forward camera view direction (world == code axes for Minkowski) + * @return a single key; ascending key == front-to-back. Smaller is nearer. + * + * For axis d: if the camera looks toward +d (forward[d] >= 0), the smaller + * grid index is nearer, so key_d = offset_d. Otherwise key_d is reversed. + * The per-axis keys are packed lexicographically (axis 0 most significant). + */ + inline auto compositeOrderKey(const std::vector& offset, + const std::vector& ndoms, + const real_t forward[3]) + -> uint64_t { + uint64_t key = 0; + for (std::size_t d = 0; d < ndoms.size(); ++d) { + const unsigned int Dd = ndoms[d]; + const unsigned int od = offset[d]; + const unsigned int kd = (forward[d] >= ZERO) ? od : (Dd - 1u - od); + key = key * static_cast(Dd) + + static_cast(kd); + } + return key; + } + + /** + * @brief Accumulate one segment into a front-to-back running composite. + * @param acc 4-element premultiplied RGBA accumulator (modified in place) + * @param seg 4-element premultiplied RGBA of the next (further) segment + * + * acc holds everything in front of seg. The "over" operator: + * C_acc += (1 - A_acc) * C_seg ; A_acc += (1 - A_acc) * A_seg + * Associative with identity (0,0,0,0); segments must be supplied front first. + */ + inline void overComposite(real_t acc[4], const real_t seg[4]) { + const real_t one_minus_a = ONE - acc[3]; + acc[0] += one_minus_a * seg[0]; + acc[1] += one_minus_a * seg[1]; + acc[2] += one_minus_a * seg[2]; + acc[3] += one_minus_a * seg[3]; + } + +} // namespace out + +#endif // OUTPUT_RENDER_COMPOSITE_H diff --git a/src/output/render/png.h b/src/output/render/png.h new file mode 100644 index 000000000..3f22cd5f5 --- /dev/null +++ b/src/output/render/png.h @@ -0,0 +1,174 @@ +/** + * @file output/render/png.h + * @brief Self-contained, dependency-free PNG (8-bit RGBA) encoder + * @implements + * - out::write_png + * @namespaces: + * - out:: + * @note + * Header-only. Emits a valid PNG using stored (uncompressed) DEFLATE blocks + * wrapped in a zlib stream, with per-scanline filter type 0 (None). This keeps + * the encoder tiny and provably correct at the cost of compression ratio; the + * resulting files are still orders of magnitude smaller than the full-field + * dumps the renderer is meant to replace. A drop-in stronger encoder (e.g. a + * fixed-Huffman DEFLATE, or vendored stb_image_write) can replace the IDAT + * producer without touching callers. + */ + +#ifndef OUTPUT_RENDER_PNG_H +#define OUTPUT_RENDER_PNG_H + +#include "global.h" + +#include +#include +#include +#include + +namespace out { + + namespace png_hidden { + + inline auto crc32(const uint8_t* data, std::size_t len) -> uint32_t { + static uint32_t table[256]; + static bool ready = false; + if (not ready) { + for (uint32_t n = 0; n < 256; ++n) { + uint32_t c = n; + for (int k = 0; k < 8; ++k) { + c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + } + table[n] = c; + } + ready = true; + } + uint32_t c = 0xFFFFFFFFu; + for (std::size_t i = 0; i < len; ++i) { + c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); + } + return c ^ 0xFFFFFFFFu; + } + + inline auto adler32(const uint8_t* data, std::size_t len) -> uint32_t { + constexpr uint32_t MOD = 65521u; + uint32_t a = 1u, b = 0u; + for (std::size_t i = 0; i < len; ++i) { + a = (a + data[i]) % MOD; + b = (b + a) % MOD; + } + return (b << 16) | a; + } + + inline void put_u32_be(std::vector& v, uint32_t x) { + v.push_back(static_cast((x >> 24) & 0xFFu)); + v.push_back(static_cast((x >> 16) & 0xFFu)); + v.push_back(static_cast((x >> 8) & 0xFFu)); + v.push_back(static_cast(x & 0xFFu)); + } + + inline void write_chunk(std::vector& out, + const char type[4], + const std::vector& data) { + put_u32_be(out, static_cast(data.size())); + std::vector typed_data; + typed_data.reserve(4 + data.size()); + for (int i = 0; i < 4; ++i) { + typed_data.push_back(static_cast(type[i])); + } + typed_data.insert(typed_data.end(), data.begin(), data.end()); + out.insert(out.end(), typed_data.begin(), typed_data.end()); + put_u32_be(out, crc32(typed_data.data(), typed_data.size())); + } + + // zlib stream wrapping `raw` in stored (BTYPE=00) DEFLATE blocks + inline auto zlib_store(const std::vector& raw) -> std::vector { + std::vector z; + z.push_back(0x78); // CMF: CM=8, CINFO=7 + z.push_back(0x01); // FLG: makes (CMF<<8 | FLG) % 31 == 0, no dict, level 0 + std::size_t off = 0; + const std::size_t n = raw.size(); + constexpr std::size_t BLOCK = 65535u; + if (n == 0) { + z.push_back(0x01); // final, stored + z.push_back(0x00); + z.push_back(0x00); + z.push_back(0xFF); + z.push_back(0xFF); + } + while (off < n) { + const std::size_t len = (n - off > BLOCK) ? BLOCK : (n - off); + const bool final = (off + len >= n); + z.push_back(final ? 0x01 : 0x00); + const uint16_t l = static_cast(len); + const uint16_t nl = static_cast(~l); + z.push_back(static_cast(l & 0xFFu)); + z.push_back(static_cast((l >> 8) & 0xFFu)); + z.push_back(static_cast(nl & 0xFFu)); + z.push_back(static_cast((nl >> 8) & 0xFFu)); + z.insert(z.end(), raw.begin() + off, raw.begin() + off + len); + off += len; + } + put_u32_be(z, adler32(raw.data(), raw.size())); + return z; + } + + } // namespace png_hidden + + /** + * @brief Write an 8-bit RGBA buffer to a PNG file. + * @param path output file path + * @param width image width in pixels + * @param height image height in pixels + * @param rgba pointer to width*height*4 bytes, row-major, top-left origin + * @return true on success + */ + inline auto write_png(const path_t& path, + int width, + int height, + const uint8_t* rgba) -> bool { + using namespace png_hidden; + const std::size_t w = static_cast(width); + const std::size_t h = static_cast(height); + // build filtered raw scanlines: each row prefixed with filter byte 0 (None) + std::vector raw; + raw.reserve(h * (1 + w * 4)); + for (std::size_t y = 0; y < h; ++y) { + raw.push_back(0x00); + const uint8_t* row = rgba + y * w * 4; + raw.insert(raw.end(), row, row + w * 4); + } + + std::vector file; + // PNG signature + const uint8_t sig[8] = { 137, 80, 78, 71, 13, 10, 26, 10 }; + file.insert(file.end(), sig, sig + 8); + + // IHDR + std::vector ihdr; + put_u32_be(ihdr, static_cast(width)); + put_u32_be(ihdr, static_cast(height)); + ihdr.push_back(8); // bit depth + ihdr.push_back(6); // color type: RGBA + ihdr.push_back(0); // compression + ihdr.push_back(0); // filter + ihdr.push_back(0); // interlace + write_chunk(file, "IHDR", ihdr); + + // IDAT + write_chunk(file, "IDAT", zlib_store(raw)); + + // IEND + write_chunk(file, "IEND", {}); + + std::ofstream f(path, std::ios::binary); + if (not f.good()) { + return false; + } + f.write(reinterpret_cast(file.data()), + static_cast(file.size())); + return f.good(); + } + +} // namespace out + +#endif // OUTPUT_RENDER_PNG_H diff --git a/src/output/render/raymarch.hpp b/src/output/render/raymarch.hpp new file mode 100644 index 000000000..c2403bc69 --- /dev/null +++ b/src/output/render/raymarch.hpp @@ -0,0 +1,298 @@ +/** + * @file output/render/raymarch.hpp + * @brief Header-only Kokkos volume ray-march kernel (one parallel_for over pixels) + * @implements + * - kernel::VolumeRayMarch_kernel + * @namespaces: + * - kernel:: + * @macros: + * - OUTPUT_ENABLED + * @note + * Pure Kokkos: the only device entities are Views, the (trivially-copyable) + * metric, and the POD camera. Runs in Kokkos::DefaultExecutionSpace, inheriting + * whatever backend entity was built with (HIP / CUDA / SYCL / OpenMP). + * + * Seamlessness: every rank marches at GLOBAL sample positions t_k = k*ds + * measured from the shared camera eye, with a FIXED world-space step `ds` + * identical on all ranks. Each global sample therefore lands in exactly one + * domain (half-open membership via the per-domain slab interval [t_enter, + * t_exit)), so the ordered cross-domain "over" composite reproduces the single + * full-ray integral exactly. Trilinear sampling reads into the 1-cell ghost + * halo entity already exchanges, so the per-rank field is C0-continuous up to + * the shared face. + */ + +#ifndef OUTPUT_RENDER_RAYMARCH_HPP +#define OUTPUT_RENDER_RAYMARCH_HPP + +#include "enums.h" +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "traits/metric.h" + +#include "output/render/renderer.h" + +namespace kernel { + using namespace ntt; + + template + class VolumeRayMarch_kernel { + static constexpr auto D = M::Dim; + + randacc_ndfield_t Fld; + const idx_t comp; + const M metric; + const out::CameraDevice cam; + + // local-domain world AABB and View extents (for index clamping) + const real_t lo0, lo1, lo2, hi0, hi1, hi2; + const int ext0, ext1, ext2; + + const int W, H; + const real_t ds; // fixed world step (global, identical on all ranks) + const int max_steps; // safety cap on the marching loop + + // transfer function + array_t lut; + const int n_lut; + const real_t vmin, vmax; + const bool log_scale; + const real_t early_alpha; + + array_t image; // output, (W*H, 4) premultiplied RGBA + + public: + VolumeRayMarch_kernel(const randacc_ndfield_t& Fld_, + idx_t comp_, + const M& metric_, + const out::CameraDevice& cam_, + const real_t lo[3], + const real_t hi[3], + int ext0_, + int ext1_, + int ext2_, + int W_, + int H_, + real_t ds_, + int max_steps_, + const array_t& lut_, + int n_lut_, + real_t vmin_, + real_t vmax_, + bool log_scale_, + real_t early_alpha_, + const array_t& image_) + : Fld { Fld_ } + , comp { comp_ } + , metric { metric_ } + , cam { cam_ } + , lo0 { lo[0] } + , lo1 { lo[1] } + , lo2 { lo[2] } + , hi0 { hi[0] } + , hi1 { hi[1] } + , hi2 { hi[2] } + , ext0 { ext0_ } + , ext1 { ext1_ } + , ext2 { ext2_ } + , W { W_ } + , H { H_ } + , ds { ds_ } + , max_steps { max_steps_ } + , lut { lut_ } + , n_lut { n_lut_ } + , vmin { vmin_ } + , vmax { vmax_ } + , log_scale { log_scale_ } + , early_alpha { early_alpha_ } + , image { image_ } {} + + // trilinear sample of the prepared scalar at world point p, reading the + // ghost halo for corners just outside the active box. + Inline auto sample(real_t px, real_t py, real_t pz) const -> real_t { + // world -> code (cell-center continuous index = code index - 1/2) + const real_t g0 = metric.template convert<1, Crd::Ph, Crd::Cd>(px) - HALF; + const real_t g1 = metric.template convert<2, Crd::Ph, Crd::Cd>(py) - HALF; + const real_t g2 = metric.template convert<3, Crd::Ph, Crd::Cd>(pz) - HALF; + const real_t f0 = math::floor(g0); + const real_t f1 = math::floor(g1); + const real_t f2 = math::floor(g2); + const real_t t0 = g0 - f0; + const real_t t1 = g1 - f1; + const real_t t2 = g2 - f2; + // base View index of the lower corner (active cell i -> View i + N_GHOSTS) + int b0 = static_cast(f0) + static_cast(N_GHOSTS); + int b1 = static_cast(f1) + static_cast(N_GHOSTS); + int b2 = static_cast(f2) + static_cast(N_GHOSTS); + // clamp so both corners (b, b+1) stay in range [0, ext-1] + b0 = (b0 < 0) ? 0 : ((b0 > ext0 - 2) ? ext0 - 2 : b0); + b1 = (b1 < 0) ? 0 : ((b1 > ext1 - 2) ? ext1 - 2 : b1); + b2 = (b2 < 0) ? 0 : ((b2 > ext2 - 2) ? ext2 - 2 : b2); + const real_t c000 = Fld(b0, b1, b2, comp); + const real_t c100 = Fld(b0 + 1, b1, b2, comp); + const real_t c010 = Fld(b0, b1 + 1, b2, comp); + const real_t c110 = Fld(b0 + 1, b1 + 1, b2, comp); + const real_t c001 = Fld(b0, b1, b2 + 1, comp); + const real_t c101 = Fld(b0 + 1, b1, b2 + 1, comp); + const real_t c011 = Fld(b0, b1 + 1, b2 + 1, comp); + const real_t c111 = Fld(b0 + 1, b1 + 1, b2 + 1, comp); + const real_t c00 = c000 * (ONE - t0) + c100 * t0; + const real_t c10 = c010 * (ONE - t0) + c110 * t0; + const real_t c01 = c001 * (ONE - t0) + c101 * t0; + const real_t c11 = c011 * (ONE - t0) + c111 * t0; + const real_t c0 = c00 * (ONE - t1) + c10 * t1; + const real_t c1 = c01 * (ONE - t1) + c11 * t1; + return c0 * (ONE - t2) + c1 * t2; + } + + Inline void operator()(cellidx_t px, cellidx_t py) const { + const auto pix = static_cast(py) * static_cast(W) + + static_cast(px); + // default transparent + image(pix, 0) = ZERO; + image(pix, 1) = ZERO; + image(pix, 2) = ZERO; + image(pix, 3) = ZERO; + + // ---- ray generation ------------------------------------------------ // + const real_t fx = TWO * (static_cast(px) + HALF) / + static_cast(W) - ONE; + const real_t fy = ONE - TWO * (static_cast(py) + HALF) / + static_cast(H); + real_t ox, oy, oz, dx, dy, dz; + if (cam.orthographic) { + const real_t sx = fx * cam.half_w; + const real_t sy = fy * cam.half_h; + ox = cam.eye[0] + sx * cam.right[0] + sy * cam.up[0]; + oy = cam.eye[1] + sx * cam.right[1] + sy * cam.up[1]; + oz = cam.eye[2] + sx * cam.right[2] + sy * cam.up[2]; + dx = cam.forward[0]; + dy = cam.forward[1]; + dz = cam.forward[2]; + } else { + const real_t nx = fx * cam.aspect * cam.tan_half_fov; + const real_t ny = fy * cam.tan_half_fov; + dx = cam.forward[0] + nx * cam.right[0] + ny * cam.up[0]; + dy = cam.forward[1] + nx * cam.right[1] + ny * cam.up[1]; + dz = cam.forward[2] + nx * cam.right[2] + ny * cam.up[2]; + const real_t inv = ONE / math::sqrt(dx * dx + dy * dy + dz * dz); + dx *= inv; + dy *= inv; + dz *= inv; + ox = cam.eye[0]; + oy = cam.eye[1]; + oz = cam.eye[2]; + } + + // ---- ray-AABB slab test against [lo, hi] --------------------------- // + real_t t_enter = ZERO; + real_t t_exit = static_cast(1e30); + const real_t eps = static_cast(1e-12); + // axis 0 + if (math::abs(dx) < eps) { + if (ox < lo0 or ox > hi0) { + return; + } + } else { + real_t t1 = (lo0 - ox) / dx; + real_t t2 = (hi0 - ox) / dx; + if (t1 > t2) { + const real_t tmp = t1; + t1 = t2; + t2 = tmp; + } + t_enter = (t1 > t_enter) ? t1 : t_enter; + t_exit = (t2 < t_exit) ? t2 : t_exit; + } + // axis 1 + if (math::abs(dy) < eps) { + if (oy < lo1 or oy > hi1) { + return; + } + } else { + real_t t1 = (lo1 - oy) / dy; + real_t t2 = (hi1 - oy) / dy; + if (t1 > t2) { + const real_t tmp = t1; + t1 = t2; + t2 = tmp; + } + t_enter = (t1 > t_enter) ? t1 : t_enter; + t_exit = (t2 < t_exit) ? t2 : t_exit; + } + // axis 2 + if (math::abs(dz) < eps) { + if (oz < lo2 or oz > hi2) { + return; + } + } else { + real_t t1 = (lo2 - oz) / dz; + real_t t2 = (hi2 - oz) / dz; + if (t1 > t2) { + const real_t tmp = t1; + t1 = t2; + t2 = tmp; + } + t_enter = (t1 > t_enter) ? t1 : t_enter; + t_exit = (t2 < t_exit) ? t2 : t_exit; + } + if (t_enter >= t_exit) { + return; + } + + // ---- march at global sample positions t_k = k*ds ------------------- // + const real_t inv_range = (vmax > vmin) ? (ONE / (vmax - vmin)) : ZERO; + const real_t log_vmin = log_scale ? math::log10(vmin) : ZERO; + // first global sample index inside this segment: t_k >= t_enter + const real_t k0 = math::ceil(t_enter / ds); + real_t t = k0 * ds; + real_t acc_r = ZERO, acc_g = ZERO, acc_b = ZERO, acc_a = ZERO; + int steps = 0; + while (t < t_exit and steps < max_steps) { + const real_t s = sample(ox + t * dx, oy + t * dy, oz + t * dz); + // normalize through the transfer function range + real_t u; + if (log_scale) { + u = (s > ZERO) ? (math::log10(s) - log_vmin) * inv_range + : -ONE; + } else { + u = (s - vmin) * inv_range; + } + if (u < ZERO) { + u = ZERO; + } else if (u > ONE) { + u = ONE; + } + int idx = static_cast(u * static_cast(n_lut - 1) + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > n_lut - 1) { + idx = n_lut - 1; + } + const real_t cr = lut(idx, 0); // premultiplied + const real_t cg = lut(idx, 1); + const real_t cb = lut(idx, 2); + const real_t ca = lut(idx, 3); + const real_t w = ONE - acc_a; + acc_r += w * cr; + acc_g += w * cg; + acc_b += w * cb; + acc_a += w * ca; + if (acc_a >= early_alpha) { + break; + } + t += ds; + ++steps; + } + + image(pix, 0) = acc_r; + image(pix, 1) = acc_g; + image(pix, 2) = acc_b; + image(pix, 3) = acc_a; + } + }; + +} // namespace kernel + +#endif // OUTPUT_RENDER_RAYMARCH_HPP diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp new file mode 100644 index 000000000..019fc7eaa --- /dev/null +++ b/src/output/render/renderer.cpp @@ -0,0 +1,334 @@ +#include "output/render/renderer.h" + +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "utils/error.h" +#include "utils/formatting.h" +#include "utils/log.h" +#include "utils/numeric.h" + +#include "output/render/composite.h" +#include "output/render/png.h" +#include "output/render/transfer_fn.h" + +#include + +#if defined(MPI_ENABLED) + #include "arch/mpi_aliases.h" + + #include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace out { + + namespace { + + inline void cross3(const real_t a[3], const real_t b[3], real_t out[3]) { + out[0] = a[1] * b[2] - a[2] * b[1]; + out[1] = a[2] * b[0] - a[0] * b[2]; + out[2] = a[0] * b[1] - a[1] * b[0]; + } + + inline auto norm3(const real_t a[3]) -> real_t { + return std::sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); + } + + inline void normalize3(real_t a[3]) { + const real_t n = norm3(a); + if (n > static_cast(1e-30)) { + a[0] /= n; + a[1] /= n; + a[2] /= n; + } + } + + inline auto quantize(real_t v) -> uint8_t { + const real_t c = (v < ZERO) ? ZERO : ((v > ONE) ? ONE : v); + return static_cast(c * static_cast(255.0) + HALF); + } + + } // namespace + + void Renderer::init(const ntt::SimulationParams& params, + const boundaries_t& global_extent) { + m_enabled = false; + const auto& td = params.data(); + + const bool enable = toml::find_or(td, "output", "render", "enable", false); + if (not enable) { + return; + } + // the renderer is a 3D feature; silently no-op otherwise. + if (global_extent.size() != 3) { + raise::Warning("output.render enabled but simulation is not 3D; " + "the volume renderer will be inactive", + HERE); + return; + } + + m_root = path_t(params.get("simulation.name")); + + m_width = toml::find_or(td, "output", "render", "width", 1024); + m_height = toml::find_or(td, "output", "render", "height", 1024); + m_samples = toml::find_or(td, "output", "render", "samples", 400); + m_step_size = toml::find_or(td, "output", "render", "step_size", ZERO); + m_early_alpha = toml::find_or(td, + "output", + "render", + "early_term_alpha", + static_cast(0.99)); + m_n_lut = toml::find_or(td, "output", "render", "n_lut", 256); + + // cadence: mirror output.* (interval in steps; interval_time in sim time) + const auto interval = toml::find_or(td, + "output", + "render", + "interval", + 0u); + const auto interval_time = toml::find_or(td, + "output", + "render", + "interval_time", + -1.0); + m_tracker.init("render", interval, interval_time); + + /* ---- camera --------------------------------------------------------- */ + real_t center[3], size[3]; + real_t maxext = ZERO; + for (int d = 0; d < 3; ++d) { + center[d] = static_cast(0.5) * + (global_extent[d].first + global_extent[d].second); + size[d] = global_extent[d].second - global_extent[d].first; + maxext = (size[d] > maxext) ? size[d] : maxext; + } + const real_t diag = std::sqrt(size[0] * size[0] + size[1] * size[1] + + size[2] * size[2]); + + const bool ortho = toml::find_or(td, + "output", + "render", + "camera", + "orthographic", + true); + auto pos = toml::find_or>(td, + "output", + "render", + "camera", + "position", + std::vector {}); + auto look = toml::find_or>(td, + "output", + "render", + "camera", + "look_at", + std::vector {}); + auto up = toml::find_or>(td, + "output", + "render", + "camera", + "up", + std::vector {}); + const real_t fov = toml::find_or(td, + "output", + "render", + "camera", + "fov", + static_cast(35.0)); + // default covers the box from any view direction (default camera looks + // down the diagonal), so nothing is clipped without explicit framing. + (void)maxext; + const real_t ortho_height = toml::find_or(td, + "output", + "render", + "camera", + "ortho_height", + diag); + + real_t eye[3], lookat[3], upv[3]; + for (int d = 0; d < 3; ++d) { + // default eye: box center pushed back along (1,1,1) by ~1.7 diagonals + eye[d] = (pos.size() == 3) + ? pos[d] + : center[d] + static_cast(1.7) * diag * + static_cast(0.57735026919); + lookat[d] = (look.size() == 3) ? look[d] : center[d]; + } + upv[0] = (up.size() == 3) ? up[0] : ZERO; + upv[1] = (up.size() == 3) ? up[1] : ZERO; + upv[2] = (up.size() == 3) ? up[2] : ONE; + + real_t forward[3] = { lookat[0] - eye[0], + lookat[1] - eye[1], + lookat[2] - eye[2] }; + normalize3(forward); + real_t right[3]; + cross3(forward, upv, right); + normalize3(right); + real_t up_cam[3]; + cross3(right, forward, up_cam); + + for (int d = 0; d < 3; ++d) { + m_camera_dev.eye[d] = eye[d]; + m_camera_dev.forward[d] = forward[d]; + m_camera_dev.right[d] = right[d]; + m_camera_dev.up[d] = up_cam[d]; + } + m_camera_dev.aspect = static_cast(m_width) / + static_cast(m_height); + m_camera_dev.tan_half_fov = std::tan(static_cast(0.5) * fov * + static_cast(constant::PI) / + static_cast(180.0)); + m_camera_dev.orthographic = ortho; + m_camera_dev.half_h = static_cast(0.5) * ortho_height; + m_camera_dev.half_w = m_camera_dev.half_h * m_camera_dev.aspect; + + /* ---- scenes --------------------------------------------------------- */ + m_scenes.clear(); + const auto scenes_arr = toml::find_or(td, + "output", + "render", + "scenes", + toml::array {}); + for (const auto& sc : scenes_arr) { + Scene scene; + scene.field = toml::find_or(sc, "field", ""); + scene.prefix = toml::find_or(sc, "prefix", scene.field + "_"); + if (scene.field.empty()) { + raise::Warning("output.render scene with no field; skipping", HERE); + continue; + } + scene.tf.vmin = toml::find_or(sc, "min", ZERO); + scene.tf.vmax = toml::find_or(sc, "max", ONE); + scene.tf.log_scale = toml::find_or(sc, "log", false); + scene.tf.n_lut = m_n_lut; + const auto colormap = toml::find_or(sc, "colormap", "viridis"); + // alpha control points: array of [position, alpha] pairs + const auto alpha_raw = toml::find_or>>( + sc, + "alpha", + std::vector> {}); + std::vector> alpha_pts; + for (const auto& p : alpha_raw) { + if (p.size() >= 2) { + alpha_pts.push_back({ p[0], p[1] }); + } + } + scene.tf.lut = buildLUT(colormap, m_n_lut, alpha_pts); + m_scenes.push_back(std::move(scene)); + } + + if (m_scenes.empty()) { + raise::Warning("output.render enabled but no valid scenes; disabling", HERE); + return; + } + + m_enabled = true; + logger::Checkpoint("Volume renderer initialized", HERE); + } + + void Renderer::compositeAndWrite(const std::vector& rgba, + uint64_t order_key, + const std::string& prefix, + timestep_t step) const { + const std::size_t npix = static_cast(m_width) * + static_cast(m_height); + const std::size_t n = npix * 4; + + auto write_image = [&](const std::vector& img) { + // ensure /renders/ exists + const auto dir = m_root / path_t("renders"); + try { + if (not std::filesystem::exists(m_root)) { + std::filesystem::create_directory(m_root); + } + if (not std::filesystem::exists(dir)) { + std::filesystem::create_directory(dir); + } + } catch (const std::exception& e) { + raise::Warning(e.what(), HERE); + } + std::vector bytes(n); + for (std::size_t i = 0; i < n; ++i) { + bytes[i] = quantize(img[i]); + } + const auto fname = dir / fmt::format("%s%08lu.png", + prefix.c_str(), + static_cast(step)); + if (not write_png(fname, m_width, m_height, bytes.data())) { + raise::Warning( + fmt::format("failed to write %s", fname.string().c_str()), + HERE); + } + }; + +#if defined(MPI_ENABLED) + int rank = 0, size = 1; + MPI_Comm_rank(MPI_COMM_WORLD, &rank); + MPI_Comm_size(MPI_COMM_WORLD, &size); + + if (size == 1) { + write_image(rgba); + return; + } + + std::vector recv; + std::vector keys; + if (rank == MPI_ROOT_RANK) { + recv.resize(static_cast(size) * n); + keys.resize(static_cast(size)); + } + const unsigned long long my_key = static_cast(order_key); + + MPI_Gather(rgba.data(), + static_cast(n), + mpi::get_type(), + (rank == MPI_ROOT_RANK) ? recv.data() : nullptr, + static_cast(n), + mpi::get_type(), + MPI_ROOT_RANK, + MPI_COMM_WORLD); + MPI_Gather(&my_key, + 1, + MPI_UNSIGNED_LONG_LONG, + (rank == MPI_ROOT_RANK) ? keys.data() : nullptr, + 1, + MPI_UNSIGNED_LONG_LONG, + MPI_ROOT_RANK, + MPI_COMM_WORLD); + + if (rank != MPI_ROOT_RANK) { + return; + } + + // front-to-back order = ranks sorted by ascending composite key + std::vector order(size); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&](int a, int b) { + return keys[a] < keys[b]; + }); + + std::vector acc(n, ZERO); + for (const int r : order) { + const real_t* seg_base = recv.data() + static_cast(r) * n; + for (std::size_t p = 0; p < npix; ++p) { + overComposite(acc.data() + p * 4, seg_base + p * 4); + } + } + write_image(acc); +#else + (void)order_key; + write_image(rgba); +#endif + } + +} // namespace out diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h new file mode 100644 index 000000000..707307341 --- /dev/null +++ b/src/output/render/renderer.h @@ -0,0 +1,171 @@ +/** + * @file output/render/renderer.h + * @brief In-situ volume renderer: configuration, cadence, host composite & PNG + * @implements + * - out::Renderer + * - out::CameraDevice + * - out::TransferFunction + * - out::Scene + * @cpp: + * - render/renderer.cpp + * @namespaces: + * - out:: + * @macros: + * - MPI_ENABLED + * - OUTPUT_ENABLED + * @note + * The Renderer is intentionally NOT templated on the engine/metric: it owns + * only metric-agnostic, host-side work (config parsing, cadence tracking, the + * MPI ordered composite and PNG encode). The device ray-march kernel and the + * field preparation live in the templated `Metadomain::Render`, mirroring + * the `out::Writer` (plain) / `Metadomain::Write` (templated) split. + */ + +#ifndef OUTPUT_RENDER_RENDERER_H +#define OUTPUT_RENDER_RENDERER_H + +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "utils/tools.h" + +#include "framework/parameters/parameters.h" + +#include +#include +#include + +namespace out { + + /** + * @brief Device-friendly POD camera + precomputed per-pixel ray basis. + * @note Trivially copyable; captured by value into the Kokkos kernel. + */ + struct CameraDevice { + real_t eye[3] { ZERO, ZERO, ZERO }; + real_t right[3] { ONE, ZERO, ZERO }; + real_t up[3] { ZERO, ONE, ZERO }; + real_t forward[3] { ZERO, ZERO, -ONE }; + real_t tan_half_fov { ONE }; + real_t aspect { ONE }; + bool orthographic { true }; + real_t half_w { ONE }; + real_t half_h { ONE }; + }; + + /** + * @brief Per-scene transfer function: premultiplied RGBA device LUT + range. + */ + struct TransferFunction { + array_t lut; // device, (n_lut, 4), premultiplied RGBA + int n_lut { 256 }; + real_t vmin { ZERO }; + real_t vmax { ONE }; + bool log_scale { false }; + }; + + /** + * @brief One rendered scalar field -> one PNG stream. + */ + struct Scene { + std::string field; // "N" | "Bmag" | "Jmag" | "smooth_xyz" + std::string prefix; // PNG filename prefix, e.g. "Bmag_" + TransferFunction tf; + }; + + class Renderer { + public: + Renderer() {} + + ~Renderer() = default; + + Renderer(Renderer&&) = default; + + /** + * @brief Parse `[output.render.*]` and build the camera + per-scene LUTs. + * @param params simulation parameters (raw toml read via params.data()) + * @param global_extent global physical box, for default camera framing + */ + void init(const ntt::SimulationParams& params, + const boundaries_t& global_extent); + + [[nodiscard]] + auto shouldRender(timestep_t step, simtime_t time) -> bool { + return m_enabled and m_tracker.shouldWrite(step, time); + } + + /** + * @brief Composite the per-rank host image across MPI and write the PNG. + * @param rgba host buffer, length width*height*4, premultiplied RGBA, in + * pixel-major / channel-minor order (rgba[pix*4 + ch]) + * @param order_key this rank's front-to-back sort key (see composite.h) + * @param prefix PNG filename prefix + * @param step current timestep (for the filename cycle number) + * @note Only the MPI root rank writes the file. + */ + void compositeAndWrite(const std::vector& rgba, + uint64_t order_key, + const std::string& prefix, + timestep_t step) const; + + /* getters -------------------------------------------------------------- */ + [[nodiscard]] + auto enabled() const -> bool { + return m_enabled; + } + + [[nodiscard]] + auto width() const -> int { + return m_width; + } + + [[nodiscard]] + auto height() const -> int { + return m_height; + } + + [[nodiscard]] + auto samples() const -> int { + return m_samples; + } + + [[nodiscard]] + auto stepSize() const -> real_t { + return m_step_size; + } + + [[nodiscard]] + auto earlyAlpha() const -> real_t { + return m_early_alpha; + } + + [[nodiscard]] + auto camera() const -> const CameraDevice& { + return m_camera_dev; + } + + [[nodiscard]] + auto scenes() const -> const std::vector& { + return m_scenes; + } + + private: + bool m_enabled { false }; + + int m_width { 1024 }; + int m_height { 1024 }; + int m_samples { 400 }; + real_t m_step_size { ZERO }; // world units/step; 0 => derive from samples + real_t m_early_alpha { static_cast(0.99) }; + int m_n_lut { 256 }; + + CameraDevice m_camera_dev; + std::vector m_scenes; + + tools::Tracker m_tracker; + path_t m_root; + }; + +} // namespace out + +#endif // OUTPUT_RENDER_RENDERER_H diff --git a/src/output/render/transfer_fn.h b/src/output/render/transfer_fn.h new file mode 100644 index 000000000..a7d682cb3 --- /dev/null +++ b/src/output/render/transfer_fn.h @@ -0,0 +1,192 @@ +/** + * @file output/render/transfer_fn.h + * @brief Colormap tables and premultiplied RGBA look-up-table builder + * @implements + * - out::buildLUT + * - out::colormapRGB + * @namespaces: + * - out:: + * @note + * Colormaps are stored as a handful of anchor colors and linearly + * interpolated; this is visually indistinguishable from the full 256-entry + * matplotlib tables for volume rendering while keeping the header compact. + * The LUT is built on the host and deep-copied to a device View of shape + * (N_LUT, 4) holding premultiplied RGBA (R=r*a, G=g*a, B=b*a, A=a). + */ + +#ifndef OUTPUT_RENDER_TRANSFER_FN_H +#define OUTPUT_RENDER_TRANSFER_FN_H + +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "utils/numeric.h" + +#include +#include +#include + +namespace out { + + namespace cmap_hidden { + + // anchor colors sampled at uniform positions in [0, 1] + struct Anchors { + const float (*rgb)[3]; + int n; + }; + + inline constexpr float viridis[9][3] = { + { 0.267004f, 0.004874f, 0.329415f }, + { 0.282623f, 0.140926f, 0.457517f }, + { 0.253935f, 0.265254f, 0.529983f }, + { 0.206756f, 0.371758f, 0.553117f }, + { 0.163625f, 0.471133f, 0.558148f }, + { 0.127568f, 0.566949f, 0.550556f }, + { 0.134692f, 0.658636f, 0.517649f }, + { 0.477504f, 0.821444f, 0.318195f }, + { 0.993248f, 0.906157f, 0.143936f }, + }; + + inline constexpr float inferno[9][3] = { + { 0.001462f, 0.000466f, 0.013866f }, + { 0.087411f, 0.044556f, 0.224813f }, + { 0.258234f, 0.038571f, 0.406485f }, + { 0.416331f, 0.090203f, 0.432943f }, + { 0.578304f, 0.148039f, 0.404411f }, + { 0.735683f, 0.215906f, 0.330245f }, + { 0.865006f, 0.316822f, 0.226055f }, + { 0.954506f, 0.468744f, 0.099874f }, + { 0.988362f, 0.998364f, 0.644924f }, + }; + + inline constexpr float plasma[9][3] = { + { 0.050383f, 0.029803f, 0.527975f }, + { 0.287076f, 0.010855f, 0.627295f }, + { 0.417642f, 0.000564f, 0.658390f }, + { 0.562738f, 0.051545f, 0.641509f }, + { 0.692840f, 0.165141f, 0.564522f }, + { 0.798216f, 0.280197f, 0.469538f }, + { 0.881443f, 0.392529f, 0.383229f }, + { 0.949217f, 0.517763f, 0.295662f }, + { 0.940015f, 0.975158f, 0.131326f }, + }; + + // Moreland cool-to-warm diverging + inline constexpr float cool2warm[3][3] = { + { 0.230f, 0.299f, 0.754f }, + { 0.865f, 0.865f, 0.865f }, + { 0.706f, 0.016f, 0.150f }, + }; + + inline constexpr float gray[2][3] = { + { 0.0f, 0.0f, 0.0f }, + { 1.0f, 1.0f, 1.0f }, + }; + + inline auto lookup(const std::string& name) -> Anchors { + if (name == "inferno") { + return { inferno, 9 }; + } else if (name == "plasma") { + return { plasma, 9 }; + } else if (name == "cool2warm" or name == "coolwarm") { + return { cool2warm, 3 }; + } else if (name == "gray" or name == "grey") { + return { gray, 2 }; + } else { + // default / "viridis" + return { viridis, 9 }; + } + } + + } // namespace cmap_hidden + + /** + * @brief Sample a named colormap at u in [0, 1], returning RGB in [0, 1]. + */ + inline void colormapRGB(const std::string& name, + real_t u, + real_t& r, + real_t& g, + real_t& b) { + const auto anchors = cmap_hidden::lookup(name); + if (u <= ZERO) { + r = anchors.rgb[0][0]; + g = anchors.rgb[0][1]; + b = anchors.rgb[0][2]; + return; + } + if (u >= ONE) { + r = anchors.rgb[anchors.n - 1][0]; + g = anchors.rgb[anchors.n - 1][1]; + b = anchors.rgb[anchors.n - 1][2]; + return; + } + const real_t x = u * static_cast(anchors.n - 1); + const int i0 = static_cast(x); + const int i1 = (i0 + 1 < anchors.n) ? (i0 + 1) : i0; + const real_t t = x - static_cast(i0); + r = static_cast(anchors.rgb[i0][0]) * (ONE - t) + + static_cast(anchors.rgb[i1][0]) * t; + g = static_cast(anchors.rgb[i0][1]) * (ONE - t) + + static_cast(anchors.rgb[i1][1]) * t; + b = static_cast(anchors.rgb[i0][2]) * (ONE - t) + + static_cast(anchors.rgb[i1][2]) * t; + } + + /** + * @brief Piecewise-linear opacity from sorted (position, alpha) control points. + */ + inline auto alphaAt(const std::vector>& pts, real_t u) + -> real_t { + if (pts.empty()) { + return u; // sensible default: linear ramp + } + if (u <= pts.front()[0]) { + return pts.front()[1]; + } + if (u >= pts.back()[0]) { + return pts.back()[1]; + } + for (std::size_t i = 0; i + 1 < pts.size(); ++i) { + if (u >= pts[i][0] and u <= pts[i + 1][0]) { + const real_t span = pts[i + 1][0] - pts[i][0]; + const real_t t = (span > ZERO) ? (u - pts[i][0]) / span : ZERO; + return pts[i][1] * (ONE - t) + pts[i + 1][1] * t; + } + } + return pts.back()[1]; + } + + /** + * @brief Build a premultiplied RGBA device LUT from a colormap + alpha points. + * @param colormap name of the colormap + * @param n_lut number of entries + * @param alpha_pts sorted (position, alpha) control points in [0,1]x[0,1] + * @return device View of shape (n_lut, 4), premultiplied RGBA + */ + inline auto buildLUT(const std::string& colormap, + int n_lut, + const std::vector>& alpha_pts) + -> array_t { + array_t lut { "render_lut", static_cast(n_lut) }; + auto lut_h = Kokkos::create_mirror_view(lut); + for (int i = 0; i < n_lut; ++i) { + const real_t u = (n_lut > 1) + ? static_cast(i) / static_cast(n_lut - 1) + : ZERO; + real_t r, g, b; + colormapRGB(colormap, u, r, g, b); + const real_t a = alphaAt(alpha_pts, u); + lut_h(i, 0) = r * a; // premultiplied + lut_h(i, 1) = g * a; + lut_h(i, 2) = b * a; + lut_h(i, 3) = a; + } + Kokkos::deep_copy(lut, lut_h); + return lut; + } + +} // namespace out + +#endif // OUTPUT_RENDER_TRANSFER_FN_H From ca24afe13d19b5a8df2422d07d8233fb8de64449 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 00:01:17 -0400 Subject: [PATCH 02/20] add background to rendered image --- src/output/render/renderer.cpp | 23 +++++++++++++++++++++-- src/output/render/renderer.h | 3 +++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 019fc7eaa..37c01e80e 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -89,6 +89,18 @@ namespace out { static_cast(0.99)); m_n_lut = toml::find_or(td, "output", "render", "n_lut", 256); + // opaque background color (shows through low-alpha pixels); default black + const auto bg = toml::find_or>(td, + "output", + "render", + "background", + std::vector {}); + if (bg.size() == 3) { + m_background[0] = bg[0]; + m_background[1] = bg[1]; + m_background[2] = bg[2]; + } + // cadence: mirror output.* (interval in steps; interval_time in sim time) const auto interval = toml::find_or(td, "output", @@ -257,9 +269,16 @@ namespace out { } catch (const std::exception& e) { raise::Warning(e.what(), HERE); } + // composite the premultiplied image over the opaque background: + // out = src_premult + (1 - src_alpha) * background, alpha = opaque. std::vector bytes(n); - for (std::size_t i = 0; i < n; ++i) { - bytes[i] = quantize(img[i]); + for (std::size_t p = 0; p < npix; ++p) { + const real_t a = img[p * 4 + 3]; + const real_t inv = ONE - a; + bytes[p * 4 + 0] = quantize(img[p * 4 + 0] + inv * m_background[0]); + bytes[p * 4 + 1] = quantize(img[p * 4 + 1] + inv * m_background[1]); + bytes[p * 4 + 2] = quantize(img[p * 4 + 2] + inv * m_background[2]); + bytes[p * 4 + 3] = 255; } const auto fname = dir / fmt::format("%s%08lu.png", prefix.c_str(), diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 707307341..aaae57243 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -158,6 +158,9 @@ namespace out { real_t m_step_size { ZERO }; // world units/step; 0 => derive from samples real_t m_early_alpha { static_cast(0.99) }; int m_n_lut { 256 }; + // opaque background composited under the final image (shows through + // low-alpha pixels); defaults to black. + real_t m_background[3] { ZERO, ZERO, ZERO }; CameraDevice m_camera_dev; std::vector m_scenes; From 614aff4ef9e608f4cc10f2bf9470c41ec3b105a6 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 11:07:02 -0400 Subject: [PATCH 03/20] added colorbar --- src/framework/domain/metadomain_render.cpp | 2 +- src/output/render/colorbar.h | 270 +++++++++++++++++++++ src/output/render/renderer.cpp | 61 ++++- src/output/render/renderer.h | 11 +- 4 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 src/output/render/colorbar.h diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index 4e9cbf696..c83d7f703 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -284,7 +284,7 @@ namespace ntt { rgba[p * 4 + 2] = image_h(p, 2); rgba[p * 4 + 3] = image_h(p, 3); } - g_renderer.compositeAndWrite(rgba, order_key, scene.prefix, current_step); + g_renderer.compositeAndWrite(rgba, order_key, scene, current_step); rendered_any = true; } return rendered_any; diff --git a/src/output/render/colorbar.h b/src/output/render/colorbar.h new file mode 100644 index 000000000..4c311bd90 --- /dev/null +++ b/src/output/render/colorbar.h @@ -0,0 +1,270 @@ +/** + * @file output/render/colorbar.h + * @brief Draw a colorbar (gradient strip + value ticks + label) onto an + * opaque 8-bit RGBA image, using a self-contained 5x7 bitmap font. + * @implements + * - out::drawColorbar + * @namespaces: + * - out:: + * @note + * Header-only, host-only. No font dependency: a compact 5x7 ASCII font (digits, + * sign/exponent symbols, and A-Z) is embedded. Lowercase is mapped to + * uppercase; unknown glyphs render as blank. Drawn on the MPI root rank after + * the final composite, so it only ever touches the output buffer. + */ + +#ifndef OUTPUT_RENDER_COLORBAR_H +#define OUTPUT_RENDER_COLORBAR_H + +#include "global.h" + +#include "utils/numeric.h" + +#include "output/render/transfer_fn.h" + +#include +#include +#include +#include + +namespace out { + + namespace cbar_hidden { + + // 5x7 glyph: 7 rows, low 5 bits per row, bit 4 = leftmost column. + inline auto glyph(char c) -> const uint8_t* { + // map lowercase to uppercase + if (c >= 'a' and c <= 'z') { + c = static_cast(c - 'a' + 'A'); + } + switch (c) { + case '0': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110 }; return g; } + case '1': { static const uint8_t g[7] = { 0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110 }; return g; } + case '2': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111 }; return g; } + case '3': { static const uint8_t g[7] = { 0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110 }; return g; } + case '4': { static const uint8_t g[7] = { 0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010 }; return g; } + case '5': { static const uint8_t g[7] = { 0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110 }; return g; } + case '6': { static const uint8_t g[7] = { 0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110 }; return g; } + case '7': { static const uint8_t g[7] = { 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000 }; return g; } + case '8': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110 }; return g; } + case '9': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100 }; return g; } + case '.': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00110, 0b00110 }; return g; } + case '-': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000 }; return g; } + case '+': { static const uint8_t g[7] = { 0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000 }; return g; } + case '_': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111 }; return g; } + case ':': { static const uint8_t g[7] = { 0b00000, 0b00110, 0b00110, 0b00000, 0b00110, 0b00110, 0b00000 }; return g; } + case '/': { static const uint8_t g[7] = { 0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000 }; return g; } + case 'A': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }; return g; } + case 'B': { static const uint8_t g[7] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110 }; return g; } + case 'C': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110 }; return g; } + case 'D': { static const uint8_t g[7] = { 0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100 }; return g; } + case 'E': { static const uint8_t g[7] = { 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111 }; return g; } + case 'F': { static const uint8_t g[7] = { 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000 }; return g; } + case 'G': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111 }; return g; } + case 'H': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001 }; return g; } + case 'I': { static const uint8_t g[7] = { 0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110 }; return g; } + case 'J': { static const uint8_t g[7] = { 0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100 }; return g; } + case 'K': { static const uint8_t g[7] = { 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001 }; return g; } + case 'L': { static const uint8_t g[7] = { 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111 }; return g; } + case 'M': { static const uint8_t g[7] = { 0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001 }; return g; } + case 'N': { static const uint8_t g[7] = { 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001 }; return g; } + case 'O': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }; return g; } + case 'P': { static const uint8_t g[7] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000 }; return g; } + case 'Q': { static const uint8_t g[7] = { 0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101 }; return g; } + case 'R': { static const uint8_t g[7] = { 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001 }; return g; } + case 'S': { static const uint8_t g[7] = { 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110 }; return g; } + case 'T': { static const uint8_t g[7] = { 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100 }; return g; } + case 'U': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110 }; return g; } + case 'V': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100 }; return g; } + case 'W': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001 }; return g; } + case 'X': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001 }; return g; } + case 'Y': { static const uint8_t g[7] = { 0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100 }; return g; } + case 'Z': { static const uint8_t g[7] = { 0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111 }; return g; } + default: { static const uint8_t g[7] = { 0, 0, 0, 0, 0, 0, 0 }; return g; } // blank + } + } + + inline void setPx(uint8_t* rgba, int W, int H, int x, int y, uint8_t r, + uint8_t g, uint8_t b) { + if (x < 0 or x >= W or y < 0 or y >= H) { + return; + } + const std::size_t i = (static_cast(y) * W + x) * 4; + rgba[i + 0] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = 255; + } + + inline void drawChar(uint8_t* rgba, int W, int H, int x, int y, char c, + int s, uint8_t r, uint8_t g, uint8_t b) { + const uint8_t* gl = glyph(c); + for (int row = 0; row < 7; ++row) { + for (int col = 0; col < 5; ++col) { + if (gl[row] & (1u << (4 - col))) { + for (int dy = 0; dy < s; ++dy) { + for (int dx = 0; dx < s; ++dx) { + setPx(rgba, W, H, x + col * s + dx, y + row * s + dy, r, g, b); + } + } + } + } + } + } + + inline void drawText(uint8_t* rgba, int W, int H, int x, int y, + const std::string& str, int s, uint8_t r, uint8_t g, + uint8_t b) { + int cx = x; + for (const char c : str) { + drawChar(rgba, W, H, cx, y, c, s, r, g, b); + cx += 6 * s; // 5px glyph + 1px spacing + } + } + + inline auto quant(real_t v) -> uint8_t { + const real_t c = (v < ZERO) ? ZERO : ((v > ONE) ? ONE : v); + return static_cast(c * static_cast(255.0) + HALF); + } + + inline auto fmtNum(real_t v) -> std::string { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.3g", static_cast(v)); + return std::string(buf); + } + + inline auto scale(int H) -> int { + return std::max(2, H / 400); + } + + } // namespace cbar_hidden + + /** + * @brief Pixel width of the colorbar block (bar + gap + labels + padding). + * @note Depends only on H, so it can size a canvas margin before drawing. + */ + inline auto colorbarBlockWidth(int H) -> int { + const int s = cbar_hidden::scale(H); + const int char_w = 6 * s; + const int bar_w = std::max(12, H / 50); + const int gap = 3 * s; + const int label_w = 9 * char_w; // room for e.g. "-1.23e+04" + const int pad = 4 * s; + return pad + bar_w + gap + label_w + pad; + } + + /** + * @brief Draw a vertical colorbar onto an opaque RGBA buffer. + * @param rgba width*height*4 bytes, opaque (alpha forced to 255 on drawn px) + * @param W,H image dimensions + * @param colormap name of the colormap to redraw the gradient + * @param vmin,vmax value range mapped onto the bar + * @param log_scale if true, ticks are spaced/labelled logarithmically + * @param label title drawn above the bar (e.g. the field name) + * @param bg background RGB (to auto-pick contrasting text color) + */ + inline void drawColorbar(uint8_t* rgba, + int W, + int H, + const std::string& colormap, + real_t vmin, + real_t vmax, + bool log_scale, + const std::string& label, + const real_t bg[3]) { + using namespace cbar_hidden; + + const int s = scale(H); + const int char_h = 8 * s; + const int bar_w = std::max(12, H / 50); + const int bar_h = H / 2; + const int gap = 3 * s; + const int pad = 4 * s; + const int block_w = colorbarBlockWidth(H); + + // place the bar near the right edge of the (possibly extended) canvas + int bar_x = W - block_w + pad; + if (bar_x < pad) { + bar_x = pad; + } + const int bar_y = (H - bar_h) / 2; + + // contrasting monochrome for text / frame / ticks + const real_t lum = static_cast(0.299) * bg[0] + + static_cast(0.587) * bg[1] + + static_cast(0.114) * bg[2]; + const uint8_t tc = (lum < HALF) ? 255 : 0; + + // gradient strip (top = vmax, bottom = vmin) + for (int j = 0; j < bar_h; ++j) { + const real_t u = (bar_h > 1) + ? ONE - static_cast(j) / + static_cast(bar_h - 1) + : ZERO; + real_t cr, cg, cb; + colormapRGB(colormap, u, cr, cg, cb); + const uint8_t R = quant(cr), G = quant(cg), B = quant(cb); + for (int i = 0; i < bar_w; ++i) { + setPx(rgba, W, H, bar_x + i, bar_y + j, R, G, B); + } + } + + // frame (thickness s) + for (int t = 0; t < s; ++t) { + for (int i = -t; i < bar_w + t; ++i) { + setPx(rgba, W, H, bar_x + i, bar_y - t, tc, tc, tc); + setPx(rgba, W, H, bar_x + i, bar_y + bar_h - 1 + t, tc, tc, tc); + } + for (int j = -t; j < bar_h + t; ++j) { + setPx(rgba, W, H, bar_x - t, bar_y + j, tc, tc, tc); + setPx(rgba, W, H, bar_x + bar_w - 1 + t, bar_y + j, tc, tc, tc); + } + } + + // ticks + labels + const bool can_log = log_scale and vmin > ZERO and vmax > ZERO; + const real_t lvmin = can_log ? math::log10(vmin) : ZERO; + const real_t lvmax = can_log ? math::log10(vmax) : ZERO; + const int nticks = 5; + for (int t = 0; t < nticks; ++t) { + const real_t u = static_cast(t) / + static_cast(nticks - 1); + const real_t val = can_log + ? math::pow(static_cast(10), + lvmin + (lvmax - lvmin) * u) + : (vmin + (vmax - vmin) * u); + const int ty = bar_y + + static_cast((ONE - u) * + static_cast(bar_h - 1)); + // tick line + for (int i = 0; i < gap; ++i) { + for (int w = 0; w < std::max(1, s / 2); ++w) { + setPx(rgba, W, H, bar_x + bar_w + i, ty + w, tc, tc, tc); + } + } + // label, vertically centered on the tick + drawText(rgba, + W, + H, + bar_x + bar_w + gap + 2 * s, + ty - char_h / 2, + fmtNum(val), + s, + tc, + tc, + tc); + } + + // title above the bar, left-aligned to the bar so it stays in the strip + if (not label.empty()) { + int ty = bar_y - char_h - 2 * gap; + if (ty < 0) { + ty = 0; + } + drawText(rgba, W, H, bar_x, ty, label, s, tc, tc, tc); + } + } + +} // namespace out + +#endif // OUTPUT_RENDER_COLORBAR_H diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 37c01e80e..c28fed054 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -8,6 +8,7 @@ #include "utils/log.h" #include "utils/numeric.h" +#include "output/render/colorbar.h" #include "output/render/composite.h" #include "output/render/png.h" #include "output/render/transfer_fn.h" @@ -101,6 +102,13 @@ namespace out { m_background[2] = bg[2]; } + m_colorbar = toml::find_or(td, "output", "render", "colorbar", true); + m_colorbar_outside = toml::find_or(td, + "output", + "render", + "colorbar_outside", + true); + // cadence: mirror output.* (interval in steps; interval_time in sim time) const auto interval = toml::find_or(td, "output", @@ -219,11 +227,13 @@ namespace out { raise::Warning("output.render scene with no field; skipping", HERE); continue; } + scene.label = toml::find_or(sc, "label", scene.field); scene.tf.vmin = toml::find_or(sc, "min", ZERO); scene.tf.vmax = toml::find_or(sc, "max", ONE); scene.tf.log_scale = toml::find_or(sc, "log", false); scene.tf.n_lut = m_n_lut; const auto colormap = toml::find_or(sc, "colormap", "viridis"); + scene.tf.colormap = colormap; // alpha control points: array of [position, alpha] pairs const auto alpha_raw = toml::find_or>>( sc, @@ -250,7 +260,7 @@ namespace out { void Renderer::compositeAndWrite(const std::vector& rgba, uint64_t order_key, - const std::string& prefix, + const Scene& scene, timestep_t step) const { const std::size_t npix = static_cast(m_width) * static_cast(m_height); @@ -281,9 +291,54 @@ namespace out { bytes[p * 4 + 3] = 255; } const auto fname = dir / fmt::format("%s%08lu.png", - prefix.c_str(), + scene.prefix.c_str(), static_cast(step)); - if (not write_png(fname, m_width, m_height, bytes.data())) { + bool ok = true; + if (m_colorbar and m_colorbar_outside) { + // extend the canvas to the right so the colorbar sits in its own margin, + // outside the rendered volume. + const int strip = colorbarBlockWidth(m_height); + const int CW = m_width + strip; + const uint8_t bR = quantize(m_background[0]); + const uint8_t bG = quantize(m_background[1]); + const uint8_t bB = quantize(m_background[2]); + std::vector canvas(static_cast(CW) * m_height * 4); + for (std::size_t i = 0; i < canvas.size(); i += 4) { + canvas[i + 0] = bR; + canvas[i + 1] = bG; + canvas[i + 2] = bB; + canvas[i + 3] = 255; + } + for (int y = 0; y < m_height; ++y) { + std::copy_n(&bytes[static_cast(y) * m_width * 4], + static_cast(m_width) * 4, + &canvas[static_cast(y) * CW * 4]); + } + drawColorbar(canvas.data(), + CW, + m_height, + scene.tf.colormap, + scene.tf.vmin, + scene.tf.vmax, + scene.tf.log_scale, + scene.label, + m_background); + ok = write_png(fname, CW, m_height, canvas.data()); + } else { + if (m_colorbar) { + drawColorbar(bytes.data(), + m_width, + m_height, + scene.tf.colormap, + scene.tf.vmin, + scene.tf.vmax, + scene.tf.log_scale, + scene.label, + m_background); + } + ok = write_png(fname, m_width, m_height, bytes.data()); + } + if (not ok) { raise::Warning( fmt::format("failed to write %s", fname.string().c_str()), HERE); diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index aaae57243..88294183d 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -62,6 +62,7 @@ namespace out { real_t vmin { ZERO }; real_t vmax { ONE }; bool log_scale { false }; + std::string colormap { "viridis" }; // for redrawing the colorbar }; /** @@ -70,6 +71,7 @@ namespace out { struct Scene { std::string field; // "N" | "Bmag" | "Jmag" | "smooth_xyz" std::string prefix; // PNG filename prefix, e.g. "Bmag_" + std::string label; // colorbar title (defaults to field) TransferFunction tf; }; @@ -99,13 +101,13 @@ namespace out { * @param rgba host buffer, length width*height*4, premultiplied RGBA, in * pixel-major / channel-minor order (rgba[pix*4 + ch]) * @param order_key this rank's front-to-back sort key (see composite.h) - * @param prefix PNG filename prefix + * @param scene the scene being written (prefix, colorbar colormap/range/label) * @param step current timestep (for the filename cycle number) * @note Only the MPI root rank writes the file. */ void compositeAndWrite(const std::vector& rgba, uint64_t order_key, - const std::string& prefix, + const Scene& scene, timestep_t step) const; /* getters -------------------------------------------------------------- */ @@ -161,6 +163,11 @@ namespace out { // opaque background composited under the final image (shows through // low-alpha pixels); defaults to black. real_t m_background[3] { ZERO, ZERO, ZERO }; + // draw a colorbar (gradient + value ticks + label) on each PNG + bool m_colorbar { true }; + // draw the colorbar in an extended right margin (outside the render region) + // rather than overlaying it on top of the rendered volume + bool m_colorbar_outside { true }; CameraDevice m_camera_dev; std::vector m_scenes; From df108202d70c468aaefb03b9e18d9cdab8282c1c Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 11:43:36 -0400 Subject: [PATCH 04/20] significant speedup --- src/framework/domain/metadomain_render.cpp | 99 ++++++++------ src/output/render/composite.h | 143 +++++++++++++++++++ src/output/render/raymarch.hpp | 25 +++- src/output/render/renderer.cpp | 151 +++++++++++++++------ src/output/render/renderer.h | 29 ++-- 5 files changed, 347 insertions(+), 100 deletions(-) diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index c83d7f703..013b855a4 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -156,6 +156,11 @@ namespace ntt { const auto metric = local_domain->mesh.metric; + // screen-space bounding box of this domain's footprint (same for all + // scenes); we only ray-march and composite within it. + int bx0 = 0, by0 = 0, bw = 0, bh = 0; + const bool on_screen = out::screenBBox(cam, W, H, lo, hi, bx0, by0, bw, bh); + bool rendered_any = false; for (const auto& scene : g_renderer.scenes()) { Kokkos::deep_copy(bckp, ZERO); @@ -240,51 +245,59 @@ namespace ntt { // sum-into-active that SynchronizeFields performs). CommunicateBckp(*local_domain, { 0, 1 }); - // ---- launch the ray-march kernel ------------------------------- // - array_t image { "render_img", - static_cast(W) * - static_cast(H) }; - randacc_ndfield_t Fld { bckp }; - Kokkos::parallel_for( - "VolumeRayMarch", - CreateRangePolicy({ 0, 0 }, - { static_cast(W), - static_cast(H) }), - kernel::VolumeRayMarch_kernel(Fld, - 0u, - metric, - cam, - lo, - hi, - ext0, - ext1, - ext2, - W, - H, - ds, - max_steps, - scene.tf.lut, - scene.tf.n_lut, - scene.tf.vmin, - scene.tf.vmax, - scene.tf.log_scale, - g_renderer.earlyAlpha(), - image)); - Kokkos::fence(); + // ---- launch the ray-march kernel over the screen bbox ---------- // + out::SubImage sub; + if (on_screen) { + sub.x0 = bx0; + sub.y0 = by0; + sub.w = bw; + sub.h = bh; + const std::size_t bnpix = static_cast(bw) * + static_cast(bh); + array_t image { "render_img", bnpix }; + randacc_ndfield_t Fld { bckp }; + Kokkos::parallel_for( + "VolumeRayMarch", + CreateRangePolicy({ 0, 0 }, + { static_cast(bw), + static_cast(bh) }), + kernel::VolumeRayMarch_kernel(Fld, + 0u, + metric, + cam, + lo, + hi, + ext0, + ext1, + ext2, + W, + H, + bx0, + by0, + bw, + ds, + max_steps, + scene.tf.lut, + scene.tf.n_lut, + scene.tf.vmin, + scene.tf.vmax, + scene.tf.log_scale, + g_renderer.earlyAlpha(), + image)); + Kokkos::fence(); - // device -> host, into a layout-agnostic pixel-major buffer - auto image_h = Kokkos::create_mirror_view(image); - Kokkos::deep_copy(image_h, image); - const std::size_t npix = static_cast(W) * - static_cast(H); - std::vector rgba(npix * 4); - for (std::size_t p = 0; p < npix; ++p) { - rgba[p * 4 + 0] = image_h(p, 0); - rgba[p * 4 + 1] = image_h(p, 1); - rgba[p * 4 + 2] = image_h(p, 2); - rgba[p * 4 + 3] = image_h(p, 3); + // device -> host, into a layout-agnostic pixel-major buffer + auto image_h = Kokkos::create_mirror_view(image); + Kokkos::deep_copy(image_h, image); + sub.rgba.resize(bnpix * 4); + for (std::size_t p = 0; p < bnpix; ++p) { + sub.rgba[p * 4 + 0] = image_h(p, 0); + sub.rgba[p * 4 + 1] = image_h(p, 1); + sub.rgba[p * 4 + 2] = image_h(p, 2); + sub.rgba[p * 4 + 3] = image_h(p, 3); + } } - g_renderer.compositeAndWrite(rgba, order_key, scene, current_step); + g_renderer.compositeAndWrite(sub, order_key, scene, current_step); rendered_any = true; } return rendered_any; diff --git a/src/output/render/composite.h b/src/output/render/composite.h index 39ea31aad..47bcdcc15 100644 --- a/src/output/render/composite.h +++ b/src/output/render/composite.h @@ -24,6 +24,11 @@ #include "utils/numeric.h" +#include "output/render/renderer.h" + +#include +#include +#include #include #include @@ -72,6 +77,144 @@ namespace out { acc[3] += one_minus_a * seg[3]; } + /** + * @brief Project a world point to a (fractional) screen pixel, inverting the + * ray-march kernel's ray generation. + * @return false if the point is behind a perspective camera (no projection) + */ + inline auto projectToScreen(const CameraDevice& cam, + int W, + int H, + const real_t p[3], + real_t& outx, + real_t& outy) -> bool { + const real_t dx = p[0] - cam.eye[0]; + const real_t dy = p[1] - cam.eye[1]; + const real_t dz = p[2] - cam.eye[2]; + const real_t cx = dx * cam.right[0] + dy * cam.right[1] + dz * cam.right[2]; + const real_t cy = dx * cam.up[0] + dy * cam.up[1] + dz * cam.up[2]; + real_t fx, fy; + if (cam.orthographic) { + fx = cx / cam.half_w; + fy = cy / cam.half_h; + } else { + const real_t cz = dx * cam.forward[0] + dy * cam.forward[1] + + dz * cam.forward[2]; + if (cz <= static_cast(1e-6)) { + return false; + } + fx = (cx / cz) / (cam.aspect * cam.tan_half_fov); + fy = (cy / cz) / cam.tan_half_fov; + } + outx = (fx + ONE) * HALF * static_cast(W) - HALF; + outy = (ONE - fy) * HALF * static_cast(H) - HALF; + return true; + } + + /** + * @brief Screen-space bounding box (in pixels) of a world-space AABB. + * @param lo,hi world AABB corners + * @param[out] bx0,by0,bw,bh clamped pixel bbox (top-left + size) + * @return false if the box projects to an empty on-screen region + * @note Falls back to the full frame if any corner is behind the camera. + */ + inline auto screenBBox(const CameraDevice& cam, + int W, + int H, + const real_t lo[3], + const real_t hi[3], + int& bx0, + int& by0, + int& bw, + int& bh) -> bool { + real_t minx = static_cast(1e30), miny = static_cast(1e30); + real_t maxx = static_cast(-1e30), maxy = static_cast(-1e30); + for (int c = 0; c < 8; ++c) { + const real_t p[3] = { (c & 1) ? hi[0] : lo[0], + (c & 2) ? hi[1] : lo[1], + (c & 4) ? hi[2] : lo[2] }; + real_t sx, sy; + if (not projectToScreen(cam, W, H, p, sx, sy)) { + bx0 = 0; + by0 = 0; + bw = W; + bh = H; + return true; // conservative fallback + } + minx = std::min(minx, sx); + maxx = std::max(maxx, sx); + miny = std::min(miny, sy); + maxy = std::max(maxy, sy); + } + const int pad = 2; + int x0 = static_cast(std::floor(minx)) - pad; + int x1 = static_cast(std::ceil(maxx)) + pad; + int y0 = static_cast(std::floor(miny)) - pad; + int y1 = static_cast(std::ceil(maxy)) + pad; + x0 = std::max(0, std::min(W, x0)); + x1 = std::max(0, std::min(W, x1)); + y0 = std::max(0, std::min(H, y0)); + y1 = std::max(0, std::min(H, y1)); + bx0 = x0; + by0 = y0; + bw = x1 - x0; + bh = y1 - y0; + return (bw > 0 and bh > 0); + } + + /** + * @brief Composite two sparse sub-images: `front` OVER `back`. + * @return a sub-image spanning the union of the two bounding boxes + * @note premultiplied "over": out = front + (1 - front.a) * back. Associative, + * so a tree of these reproduces the sequential front-to-back composite. + */ + inline auto overSub(const SubImage& f, const SubImage& b) -> SubImage { + if (f.w == 0 or f.h == 0) { + return b; + } + if (b.w == 0 or b.h == 0) { + return f; + } + const int ux0 = std::min(f.x0, b.x0); + const int uy0 = std::min(f.y0, b.y0); + const int ux1 = std::max(f.x0 + f.w, b.x0 + b.w); + const int uy1 = std::max(f.y0 + f.h, b.y0 + b.h); + SubImage r; + r.x0 = ux0; + r.y0 = uy0; + r.w = ux1 - ux0; + r.h = uy1 - uy0; + r.rgba.assign(static_cast(r.w) * r.h * 4, ZERO); + // place `back` + for (int y = 0; y < b.h; ++y) { + for (int x = 0; x < b.w; ++x) { + const std::size_t ri = (static_cast(b.y0 + y - uy0) * r.w + + (b.x0 + x - ux0)) * + 4; + const std::size_t bi = (static_cast(y) * b.w + x) * 4; + r.rgba[ri + 0] = b.rgba[bi + 0]; + r.rgba[ri + 1] = b.rgba[bi + 1]; + r.rgba[ri + 2] = b.rgba[bi + 2]; + r.rgba[ri + 3] = b.rgba[bi + 3]; + } + } + // `front` OVER the (back-filled) result + for (int y = 0; y < f.h; ++y) { + for (int x = 0; x < f.w; ++x) { + const std::size_t ri = (static_cast(f.y0 + y - uy0) * r.w + + (f.x0 + x - ux0)) * + 4; + const std::size_t fi = (static_cast(y) * f.w + x) * 4; + const real_t inv = ONE - f.rgba[fi + 3]; + r.rgba[ri + 0] = f.rgba[fi + 0] + inv * r.rgba[ri + 0]; + r.rgba[ri + 1] = f.rgba[fi + 1] + inv * r.rgba[ri + 1]; + r.rgba[ri + 2] = f.rgba[fi + 2] + inv * r.rgba[ri + 2]; + r.rgba[ri + 3] = f.rgba[fi + 3] + inv * r.rgba[ri + 3]; + } + } + return r; + } + } // namespace out #endif // OUTPUT_RENDER_COMPOSITE_H diff --git a/src/output/render/raymarch.hpp b/src/output/render/raymarch.hpp index c2403bc69..f10702b47 100644 --- a/src/output/render/raymarch.hpp +++ b/src/output/render/raymarch.hpp @@ -49,7 +49,8 @@ namespace kernel { const real_t lo0, lo1, lo2, hi0, hi1, hi2; const int ext0, ext1, ext2; - const int W, H; + const int W, H; // full frame size (for ray generation / ndc) + const int bx0, by0, bw; // screen-bbox offset and width (output stride) const real_t ds; // fixed world step (global, identical on all ranks) const int max_steps; // safety cap on the marching loop @@ -60,7 +61,7 @@ namespace kernel { const bool log_scale; const real_t early_alpha; - array_t image; // output, (W*H, 4) premultiplied RGBA + array_t image; // output, (bw*bh, 4) premultiplied RGBA public: VolumeRayMarch_kernel(const randacc_ndfield_t& Fld_, @@ -74,6 +75,9 @@ namespace kernel { int ext2_, int W_, int H_, + int bx0_, + int by0_, + int bw_, real_t ds_, int max_steps_, const array_t& lut_, @@ -98,6 +102,9 @@ namespace kernel { , ext2 { ext2_ } , W { W_ } , H { H_ } + , bx0 { bx0_ } + , by0 { by0_ } + , bw { bw_ } , ds { ds_ } , max_steps { max_steps_ } , lut { lut_ } @@ -146,9 +153,13 @@ namespace kernel { return c0 * (ONE - t2) + c1 * t2; } - Inline void operator()(cellidx_t px, cellidx_t py) const { - const auto pix = static_cast(py) * static_cast(W) + - static_cast(px); + Inline void operator()(cellidx_t lpx, cellidx_t lpy) const { + // local bbox index -> output pixel; global pixel -> ray generation + const auto pix = static_cast(lpy) * + static_cast(bw) + + static_cast(lpx); + const int gpx = bx0 + static_cast(lpx); + const int gpy = by0 + static_cast(lpy); // default transparent image(pix, 0) = ZERO; image(pix, 1) = ZERO; @@ -156,9 +167,9 @@ namespace kernel { image(pix, 3) = ZERO; // ---- ray generation ------------------------------------------------ // - const real_t fx = TWO * (static_cast(px) + HALF) / + const real_t fx = TWO * (static_cast(gpx) + HALF) / static_cast(W) - ONE; - const real_t fy = ONE - TWO * (static_cast(py) + HALF) / + const real_t fy = ONE - TWO * (static_cast(gpy) + HALF) / static_cast(H); real_t ox, oy, oz, dx, dy, dz; if (cam.orthographic) { diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index c28fed054..051f991f0 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -258,14 +258,35 @@ namespace out { logger::Checkpoint("Volume renderer initialized", HERE); } - void Renderer::compositeAndWrite(const std::vector& rgba, - uint64_t order_key, - const Scene& scene, - timestep_t step) const { + void Renderer::compositeAndWrite(const SubImage& sub, + uint64_t order_key, + const Scene& scene, + timestep_t step) const { const std::size_t npix = static_cast(m_width) * static_cast(m_height); const std::size_t n = npix * 4; + // expand a sparse sub-image into a full transparent frame (premultiplied) + auto subToFull = [&](const SubImage& s) -> std::vector { + std::vector full(n, ZERO); + for (int y = 0; y < s.h; ++y) { + for (int x = 0; x < s.w; ++x) { + const int fx = s.x0 + x; + const int fy = s.y0 + y; + if (fx < 0 or fx >= m_width or fy < 0 or fy >= m_height) { + continue; + } + const std::size_t fi = (static_cast(fy) * m_width + fx) * 4; + const std::size_t si = (static_cast(y) * s.w + x) * 4; + full[fi + 0] = s.rgba[si + 0]; + full[fi + 1] = s.rgba[si + 1]; + full[fi + 2] = s.rgba[si + 2]; + full[fi + 3] = s.rgba[si + 3]; + } + } + return full; + }; + auto write_image = [&](const std::vector& img) { // ensure /renders/ exists const auto dir = m_root / path_t("renders"); @@ -351,57 +372,103 @@ namespace out { MPI_Comm_size(MPI_COMM_WORLD, &size); if (size == 1) { - write_image(rgba); + write_image(subToFull(sub)); return; } - std::vector recv; - std::vector keys; - if (rank == MPI_ROOT_RANK) { - recv.resize(static_cast(size) * n); - keys.resize(static_cast(size)); - } - const unsigned long long my_key = static_cast(order_key); - - MPI_Gather(rgba.data(), - static_cast(n), - mpi::get_type(), - (rank == MPI_ROOT_RANK) ? recv.data() : nullptr, - static_cast(n), - mpi::get_type(), - MPI_ROOT_RANK, - MPI_COMM_WORLD); - MPI_Gather(&my_key, - 1, - MPI_UNSIGNED_LONG_LONG, - (rank == MPI_ROOT_RANK) ? keys.data() : nullptr, - 1, - MPI_UNSIGNED_LONG_LONG, - MPI_ROOT_RANK, - MPI_COMM_WORLD); - - if (rank != MPI_ROOT_RANK) { - return; - } + constexpr int TAG_HDR = 7301; + constexpr int TAG_DATA = 7302; + + auto sendSub = [&](const SubImage& s, int dest) { + int hdr[4] = { s.x0, s.y0, s.w, s.h }; + MPI_Send(hdr, 4, MPI_INT, dest, TAG_HDR, MPI_COMM_WORLD); + const int cnt = s.w * s.h * 4; + if (cnt > 0) { + MPI_Send(s.rgba.data(), + cnt, + mpi::get_type(), + dest, + TAG_DATA, + MPI_COMM_WORLD); + } + }; + auto recvSub = [&](int src) -> SubImage { + int hdr[4]; + MPI_Recv(hdr, 4, MPI_INT, src, TAG_HDR, MPI_COMM_WORLD, MPI_STATUS_IGNORE); + SubImage s; + s.x0 = hdr[0]; + s.y0 = hdr[1]; + s.w = hdr[2]; + s.h = hdr[3]; + const int cnt = s.w * s.h * 4; + if (cnt > 0) { + s.rgba.resize(static_cast(cnt)); + MPI_Recv(s.rgba.data(), + cnt, + mpi::get_type(), + src, + TAG_DATA, + MPI_COMM_WORLD, + MPI_STATUS_IGNORE); + } + return s; + }; - // front-to-back order = ranks sorted by ascending composite key - std::vector order(size); + // Every rank learns the full key vector (one uint64 each: ~tiny) and + // derives the same global front-to-back order, so no rank needs the others' + // images to agree on the composite order. + const unsigned long long my_key = static_cast(order_key); + std::vector keys(static_cast(size)); + MPI_Allgather(&my_key, + 1, + MPI_UNSIGNED_LONG_LONG, + keys.data(), + 1, + MPI_UNSIGNED_LONG_LONG, + MPI_COMM_WORLD); + std::vector order(size); // order[position] = world rank, front-to-back std::iota(order.begin(), order.end(), 0); std::stable_sort(order.begin(), order.end(), [&](int a, int b) { return keys[a] < keys[b]; }); + std::vector pos(size); // pos[world rank] = front-to-back position + for (int i = 0; i < size; ++i) { + pos[order[i]] = i; + } - std::vector acc(n, ZERO); - for (const int r : order) { - const real_t* seg_base = recv.data() + static_cast(r) * n; - for (std::size_t p = 0; p < npix; ++p) { - overComposite(acc.data() + p * 4, seg_base + p * 4); + // Order-preserving binary tree reduction over positions. At level `s`, the + // front of each pair (lower position) receives the back partner's image and + // composites front OVER back; the back partner sends and drops out. "over" + // is associative, so this reproduces the sequential front-to-back composite + // in O(log nranks) rounds with no single-rank bottleneck. + SubImage cur = sub; + const int P = pos[rank]; + for (int s = 1; s < size; s <<= 1) { + if ((P % (2 * s)) == 0) { + const int pp = P + s; + if (pp < size) { + const SubImage back = recvSub(order[pp]); + cur = overSub(cur, back); // cur is the front + } + } else if ((P % (2 * s)) == s) { + sendSub(cur, order[P - s]); + break; // absorbed into the front partner + } + } + + // The fully composited image now lives at position 0; deliver it to root. + if (rank == order[0]) { + if (rank == MPI_ROOT_RANK) { + write_image(subToFull(cur)); + } else { + sendSub(cur, MPI_ROOT_RANK); } + } else if (rank == MPI_ROOT_RANK) { + write_image(subToFull(recvSub(order[0]))); } - write_image(acc); #else (void)order_key; - write_image(rgba); + write_image(subToFull(sub)); #endif } diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 88294183d..91f3d67dd 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -75,6 +75,19 @@ namespace out { TransferFunction tf; }; + /** + * @brief A sparse screen-space sub-image: the bounding box of one domain's + * projected footprint plus its premultiplied RGBA pixels. + * @note Each domain covers only a small part of the screen, so compositing + * these sparse boxes (not full frames) is what lets the renderer scale to + * thousands of ranks. + */ + struct SubImage { + int x0 { 0 }, y0 { 0 }; // top-left pixel in the full frame + int w { 0 }, h { 0 }; // bbox size in pixels (0 => empty) + std::vector rgba; // w*h*4 premultiplied, pixel-major + }; + class Renderer { public: Renderer() {} @@ -97,18 +110,18 @@ namespace out { } /** - * @brief Composite the per-rank host image across MPI and write the PNG. - * @param rgba host buffer, length width*height*4, premultiplied RGBA, in - * pixel-major / channel-minor order (rgba[pix*4 + ch]) + * @brief Composite the per-rank sparse sub-image across MPI and write PNG. + * @param sub this rank's sparse screen-space sub-image (premultiplied RGBA) * @param order_key this rank's front-to-back sort key (see composite.h) * @param scene the scene being written (prefix, colorbar colormap/range/label) * @param step current timestep (for the filename cycle number) - * @note Only the MPI root rank writes the file. + * @note Uses an order-preserving distributed tree reduce; only the MPI root + * rank assembles the full frame and writes the file. */ - void compositeAndWrite(const std::vector& rgba, - uint64_t order_key, - const Scene& scene, - timestep_t step) const; + void compositeAndWrite(const SubImage& sub, + uint64_t order_key, + const Scene& scene, + timestep_t step) const; /* getters -------------------------------------------------------------- */ [[nodiscard]] From ec55fcf5044d35331af4a29590a94f4b4ed28d35 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 13:35:28 -0400 Subject: [PATCH 05/20] reduce send/recv via uint8 --- src/output/render/renderer.cpp | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 051f991f0..5b66f6471 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -379,17 +379,20 @@ namespace out { constexpr int TAG_HDR = 7301; constexpr int TAG_DATA = 7302; + // Wire format is premultiplied uint8 RGBA (4x less bandwidth than float). + // Compositing stays in float; only the per-message quantization adds error + // (~1 LSB through the log(N)-deep tree), so fidelity is effectively that of + // the final 8-bit PNG. auto sendSub = [&](const SubImage& s, int dest) { int hdr[4] = { s.x0, s.y0, s.w, s.h }; MPI_Send(hdr, 4, MPI_INT, dest, TAG_HDR, MPI_COMM_WORLD); const int cnt = s.w * s.h * 4; if (cnt > 0) { - MPI_Send(s.rgba.data(), - cnt, - mpi::get_type(), - dest, - TAG_DATA, - MPI_COMM_WORLD); + std::vector bytes(static_cast(cnt)); + for (int i = 0; i < cnt; ++i) { + bytes[i] = quantize(s.rgba[i]); + } + MPI_Send(bytes.data(), cnt, MPI_UNSIGNED_CHAR, dest, TAG_DATA, MPI_COMM_WORLD); } }; auto recvSub = [&](int src) -> SubImage { @@ -402,14 +405,19 @@ namespace out { s.h = hdr[3]; const int cnt = s.w * s.h * 4; if (cnt > 0) { - s.rgba.resize(static_cast(cnt)); - MPI_Recv(s.rgba.data(), + std::vector bytes(static_cast(cnt)); + MPI_Recv(bytes.data(), cnt, - mpi::get_type(), + MPI_UNSIGNED_CHAR, src, TAG_DATA, MPI_COMM_WORLD, MPI_STATUS_IGNORE); + s.rgba.resize(static_cast(cnt)); + const real_t inv255 = ONE / static_cast(255); + for (int i = 0; i < cnt; ++i) { + s.rgba[i] = static_cast(bytes[i]) * inv255; + } } return s; }; From 6dd8117fcacd68eed5dee6b0578365a333741ecb Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 13:56:52 -0400 Subject: [PATCH 06/20] added option to define colorbar ticks as parameter --- src/output/render/colorbar.h | 62 ++++++++++++++++++++++++---------- src/output/render/renderer.cpp | 9 +++-- src/output/render/renderer.h | 9 ++--- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/output/render/colorbar.h b/src/output/render/colorbar.h index 4c311bd90..3c208b850 100644 --- a/src/output/render/colorbar.h +++ b/src/output/render/colorbar.h @@ -26,6 +26,8 @@ #include #include #include +#include +#include namespace out { @@ -162,16 +164,19 @@ namespace out { * @param log_scale if true, ticks are spaced/labelled logarithmically * @param label title drawn above the bar (e.g. the field name) * @param bg background RGB (to auto-pick contrasting text color) + * @param ticks explicit tick values to label; if empty, 5 evenly-spaced + * ticks are generated. Values outside [vmin, vmax] are skipped. */ - inline void drawColorbar(uint8_t* rgba, - int W, - int H, - const std::string& colormap, - real_t vmin, - real_t vmax, - bool log_scale, - const std::string& label, - const real_t bg[3]) { + inline void drawColorbar(uint8_t* rgba, + int W, + int H, + const std::string& colormap, + real_t vmin, + real_t vmax, + bool log_scale, + const std::string& label, + const real_t bg[3], + const std::vector& ticks = {}) { using namespace cbar_hidden; const int s = scale(H); @@ -221,18 +226,39 @@ namespace out { } } - // ticks + labels + // ticks + labels: explicit values if given, else 5 evenly-spaced const bool can_log = log_scale and vmin > ZERO and vmax > ZERO; const real_t lvmin = can_log ? math::log10(vmin) : ZERO; const real_t lvmax = can_log ? math::log10(vmax) : ZERO; - const int nticks = 5; - for (int t = 0; t < nticks; ++t) { - const real_t u = static_cast(t) / - static_cast(nticks - 1); - const real_t val = can_log - ? math::pow(static_cast(10), - lvmin + (lvmax - lvmin) * u) - : (vmin + (vmax - vmin) * u); + std::vector> tk; // (u in [0,1], value) + if (ticks.empty()) { + const int nticks = 5; + for (int t = 0; t < nticks; ++t) { + const real_t u = static_cast(t) / + static_cast(nticks - 1); + const real_t val = can_log + ? math::pow(static_cast(10), + lvmin + (lvmax - lvmin) * u) + : (vmin + (vmax - vmin) * u); + tk.emplace_back(u, val); + } + } else { + const real_t span = can_log ? (lvmax - lvmin) : (vmax - vmin); + for (const real_t v : ticks) { + if (can_log and v <= ZERO) { + continue; + } + const real_t u = (span != ZERO) + ? ((can_log ? (math::log10(v) - lvmin) : (v - vmin)) / + span) + : ZERO; + if (u < static_cast(-1e-4) or u > ONE + static_cast(1e-4)) { + continue; // outside the colorbar range + } + tk.emplace_back(std::min(ONE, std::max(ZERO, u)), v); + } + } + for (const auto& [u, val] : tk) { const int ty = bar_y + static_cast((ONE - u) * static_cast(bar_h - 1)); diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 5b66f6471..7c33fc2c5 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -228,6 +228,9 @@ namespace out { continue; } scene.label = toml::find_or(sc, "label", scene.field); + scene.ticks = toml::find_or>(sc, + "colorbar_ticks", + std::vector {}); scene.tf.vmin = toml::find_or(sc, "min", ZERO); scene.tf.vmax = toml::find_or(sc, "max", ONE); scene.tf.log_scale = toml::find_or(sc, "log", false); @@ -343,7 +346,8 @@ namespace out { scene.tf.vmax, scene.tf.log_scale, scene.label, - m_background); + m_background, + scene.ticks); ok = write_png(fname, CW, m_height, canvas.data()); } else { if (m_colorbar) { @@ -355,7 +359,8 @@ namespace out { scene.tf.vmax, scene.tf.log_scale, scene.label, - m_background); + m_background, + scene.ticks); } ok = write_png(fname, m_width, m_height, bytes.data()); } diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 91f3d67dd..911847c47 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -69,10 +69,11 @@ namespace out { * @brief One rendered scalar field -> one PNG stream. */ struct Scene { - std::string field; // "N" | "Bmag" | "Jmag" | "smooth_xyz" - std::string prefix; // PNG filename prefix, e.g. "Bmag_" - std::string label; // colorbar title (defaults to field) - TransferFunction tf; + std::string field; // "N" | "Bmag" | "Jmag" | "smooth_xyz" + std::string prefix; // PNG filename prefix, e.g. "Bmag_" + std::string label; // colorbar title (defaults to field) + std::vector ticks; // explicit colorbar tick values (optional) + TransferFunction tf; }; /** From a84c821065af6547561e6b694de2ddb5efb9d4cf Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 14:05:41 -0400 Subject: [PATCH 07/20] generalize field rendering --- input.example.toml | 152 +++++++++++++++++++++ src/framework/domain/metadomain_render.cpp | 117 ++++++++++------ 2 files changed, 227 insertions(+), 42 deletions(-) diff --git a/input.example.toml b/input.example.toml index 8d6f25117..d92c3c212 100644 --- a/input.example.toml +++ b/input.example.toml @@ -681,6 +681,158 @@ # @default: [] custom = "" + # In-situ volume renderer. Ray-marches scalar fields on the GPU and writes + # PNG images directly to `/renders/` each cadence -- no field data is + # written to storage, and the result is seamless across MPI domain boundaries. + # @note: 3D Cartesian (Minkowski) only; a no-op for other dims/metrics + # @note: One PNG stream per scene (e.g. a density/|B|/|J| triptych) + [output.render] + # Toggle for the volume renderer + # @type: bool + # @default: false + enable = "" + # Number of timesteps between renders + # @type: uint + # @default: 0 + # @note: When `!= 0`, overrides `output.interval` + # @note: When `== 0`, `interval_time` (or `output.interval`) is used + interval = "" + # Physical (code) time interval between renders + # @type: float + # @default: -1.0 + # @note: When `< 0`, the output is controlled by `interval` + interval_time = "" + # Image width in pixels (the rendered region; the PNG is wider if a colorbar + # margin is added, see `colorbar_outside`) + # @type: int [> 0] + # @default: 1024 + width = "" + # Image height in pixels + # @type: int [> 0] + # @default: 1024 + height = "" + # Number of ray-march steps across the global box diagonal + # @type: int [> 0] + # @default: 400 + # @note: The world-space step is `box_diagonal / samples` unless `step_size` + # is set. Higher = better quality, slower. + samples = "" + # Fixed world-space step between ray samples + # @type: float [>= 0.0] + # @default: 0.0 + # @note: 0 derives the step from `samples`. The step is identical on all + # ranks, which is what makes the multi-domain composite seamless. + step_size = "" + # Stop marching a ray once its accumulated opacity reaches this value + # @type: float [0.0 -> 1.0] + # @default: 0.99 + # @note: Pure speed optimization; set to 1.0 to disable early termination + early_term_alpha = "" + # Number of entries in the color/opacity lookup table + # @type: int [> 1] + # @default: 256 + n_lut = "" + # Opaque background RGB (each channel 0..1) shown through transparent/low- + # opacity pixels; also fills the colorbar margin + # @type: array [size 3] + # @default: [0.0, 0.0, 0.0] + background = "" + # Draw a colorbar (gradient + value ticks + label) on each PNG + # @type: bool + # @default: true + colorbar = "" + # Draw the colorbar in an added right margin (the PNG becomes wider by a + # fixed strip) instead of overlaying it on the rendered volume + # @type: bool + # @default: true + colorbar_outside = "" + + # Camera. Defaults frame the whole global box from outside, looking down the + # (1,1,1) diagonal -- the production setup for which the structured composite + # is provably seamless. + [output.render.camera] + # Orthographic (true) or perspective (false) projection + # @type: bool + # @default: true + # @note: Orthographic is recommended; the seamless composite is always + # valid for it. Perspective is only seamless with the eye outside + # the box. + orthographic = "" + # Camera (eye) position in world (physical) coordinates + # @type: array [size 3] + # @default: box center pushed back ~1.7 box-diagonals along (1, 1, 1) + position = "" + # Point the camera looks at, in world coordinates + # @type: array [size 3] + # @default: box center + look_at = "" + # Camera up vector + # @type: array [size 3] + # @default: [0.0, 0.0, 1.0] + up = "" + # Vertical field of view in degrees (perspective only) + # @type: float [> 0.0] + # @default: 35.0 + fov = "" + # Vertical extent of the view in world units (orthographic only) + # @type: float [> 0.0] + # @default: the global box diagonal (the whole box fits from any angle) + ortho_height = "" + + # One scene per scalar field -> one PNG stream. Repeat the table for each. + [[output.render.scenes]] + # Scalar field to render (a volume render needs a scalar, so a vector is + # given as a magnitude or a single component) + # @required + # @type: string + # @enum: "N"; "Emag"/"Bmag"/"Jmag"; "{E,B,J}{1,2,3}" or "{E,B,J}{x,y,z}" + # @note: "N" = number density; "{E,B,J}mag" = vector magnitude |.| + # @note: "B1"/"Bx", "J3"/"Jz", ... = a single (signed) physical component + # @note: a bare vector ("E"/"B"/"J") is not renderable -- choose a + # component or the magnitude + # @note: components are signed; pair a symmetric `min`/`max` with a + # diverging colormap ("cool2warm") to center zero + field = "" + # PNG filename prefix; files are `.png` + # @type: string + # @default: "_" + prefix = "" + # Colorbar title + # @type: string + # @default: `field` + label = "" + # Lower bound of the value range mapped onto the colormap/opacity + # @type: float + # @default: 0.0 + min = "" + # Upper bound of the value range + # @type: float + # @default: 1.0 + max = "" + # Map the value range logarithmically + # @type: bool + # @default: false + # @note: Requires min > 0 and max > 0 + log = "" + # Colormap name + # @type: string + # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray" + # @default: "viridis" + colormap = "" + # Opacity transfer function: [position, opacity] control points, both in + # [0, 1], piecewise-linear in the normalized value + # @type: array> + # @default: linear ramp (opacity = normalized value) + # @note: Keep the low end near 0 so empty regions stay transparent + # @example: [[0.0, 0.0], [0.3, 0.1], [1.0, 0.7]] + alpha = "" + # Explicit value(s) to label on the colorbar + # @type: array + # @default: 5 evenly-spaced ticks between min and max + # @note: Values outside [min, max] are skipped + # @example: [0.0, 0.5, 1.0] + colorbar_ticks = "" + [checkpoint] # Number of timesteps between checkpoints # @type: uint [> 0] diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index 013b855a4..ffdd51ba1 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -41,6 +41,7 @@ #include #include +#include #include #include #include @@ -174,9 +175,53 @@ namespace ntt { // sum boundary-crossing particle deposits back into active cells // (particles in neighbor domains deposit into our ghost zone) SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); - } else if (scene.field == "Bmag" or scene.field == "Jmag") { - const bool is_current = (scene.field == "Jmag"); - // raw vector components into bckp(:,0..2) + } else { + // Vector field as a scalar: "" with base in {E, B, J} + // and selector in {mag, 1/2/3, x/y/z}. A component (e.g. "B1"/"Bx") is + // signed; a magnitude (e.g. "Bmag") is non-negative. + const std::string& f = scene.field; + const char base = f.empty() + ? '?' + : static_cast(std::toupper(f[0])); + bool ok = true; + bool is_current = false; + uint8_t src_base = 0; // first component of the source field + PrepareOutputFlags interp = PrepareOutput::None; + if (base == 'B') { + src_base = em::bx1; + interp = PrepareOutput::InterpToCellCenterFromFaces; + } else if (base == 'E') { + src_base = em::ex1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else if (base == 'J') { + is_current = true; + src_base = cur::jx1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else { + ok = false; + } + // selector: -1 = magnitude, 0/1/2 = a single component + int comp = -2; + const std::string sel = (f.size() > 1) ? f.substr(1) : std::string {}; + if (sel == "mag") { + comp = -1; + } else if (sel == "1" or sel == "x") { + comp = 0; + } else if (sel == "2" or sel == "y") { + comp = 1; + } else if (sel == "3" or sel == "z") { + comp = 2; + } else { + ok = false; + } + if (not ok) { + raise::Warning("output.render: unknown field '" + scene.field + + "' (expected N, {E,B,J}mag, or " + "{E,B,J}{1,2,3}|{x,y,z}); skipping", + HERE); + continue; + } + // raw vector components into bckp(:, 0..2) if (is_current) { Kokkos::deep_copy( Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, @@ -188,17 +233,14 @@ namespace ntt { Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, cell_range_t(0, 3)), Kokkos::subview(local_domain->fields.em, Kokkos::ALL, Kokkos::ALL, - Kokkos::ALL, cell_range_t(em::bx1, em::bx3 + 1))); + Kokkos::ALL, cell_range_t(src_base, src_base + 3))); } // interpolate to cell centers + convert to physical basis -> (3,4,5) - PrepareOutputFlags interp = is_current - ? PrepareOutput::InterpToCellCenterFromEdges - : PrepareOutput::InterpToCellCenterFromFaces; - PrepareOutputFlags prepare = (S == SimEngine::SRPIC) - ? PrepareOutput::ConvertToHat - : PrepareOutput::ConvertToPhysCntrv; - list_t comp_from = { 0, 1, 2 }; - list_t comp_to = { 3, 4, 5 }; + const PrepareOutputFlags prepare = (S == SimEngine::SRPIC) + ? PrepareOutput::ConvertToHat + : PrepareOutput::ConvertToPhysCntrv; + list_t comp_from = { 0, 1, 2 }; + list_t comp_to = { 3, 4, 5 }; Kokkos::parallel_for( "RenderFieldsToPhys", local_domain->mesh.rangeActiveCells(), @@ -208,36 +250,27 @@ namespace ntt { comp_to, interp | prepare, metric)); - // magnitude -> bckp(:,0) - auto bckp_v = bckp; - Kokkos::parallel_for( - "RenderVectorMagnitude", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - const real_t v1 = bckp_v(i1, i2, i3, 3); - const real_t v2 = bckp_v(i1, i2, i3, 4); - const real_t v3 = bckp_v(i1, i2, i3, 5); - bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); - }); - } else if (scene.field == "smooth_xyz") { - // continuous-by-construction regression field: x + y + z - auto bckp_v = bckp; - Kokkos::parallel_for( - "RenderSmoothXYZ", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - coord_t x_Cd { ZERO }, x_Ph { ZERO }; - x_Cd[0] = COORD(i1) + HALF; - x_Cd[1] = COORD(i2) + HALF; - x_Cd[2] = COORD(i3) + HALF; - metric.template convert(x_Cd, x_Ph); - bckp_v(i1, i2, i3, 0) = x_Ph[0] + x_Ph[1] + x_Ph[2]; - }); - } else { - raise::Warning( - "output.render: unknown field '" + scene.field + "', skipping", - HERE); - continue; + // reduce to the scalar to render -> bckp(:, 0) + auto bckp_v = bckp; + const int cc = comp; + if (comp == -1) { + Kokkos::parallel_for( + "RenderVectorMagnitude", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + const real_t v1 = bckp_v(i1, i2, i3, 3); + const real_t v2 = bckp_v(i1, i2, i3, 4); + const real_t v3 = bckp_v(i1, i2, i3, 5); + bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); + }); + } else { + Kokkos::parallel_for( + "RenderVectorComponent", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + bckp_v(i1, i2, i3, 0) = bckp_v(i1, i2, i3, 3 + cc); + }); + } } // fill the ghost halo with neighbor active values so trilinear From 36ce55cb1971a7df24dabd0e8a54c607174f55d6 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 14:20:42 -0400 Subject: [PATCH 08/20] generalized field rendering --- input.example.toml | 16 +- src/framework/domain/metadomain_render.cpp | 172 ++++++++++++++++++--- 2 files changed, 162 insertions(+), 26 deletions(-) diff --git a/input.example.toml b/input.example.toml index d92c3c212..b9c3c070a 100644 --- a/input.example.toml +++ b/input.example.toml @@ -781,15 +781,23 @@ # One scene per scalar field -> one PNG stream. Repeat the table for each. [[output.render.scenes]] - # Scalar field to render (a volume render needs a scalar, so a vector is + # Scalar field to render (a volume render needs a scalar, so vectors are # given as a magnitude or a single component) # @required # @type: string - # @enum: "N"; "Emag"/"Bmag"/"Jmag"; "{E,B,J}{1,2,3}" or "{E,B,J}{x,y,z}" - # @note: "N" = number density; "{E,B,J}mag" = vector magnitude |.| - # @note: "B1"/"Bx", "J3"/"Jz", ... = a single (signed) physical component + # @enum (fields): "{E,B,J}mag"; "{E,B,J}{1,2,3}" or "{E,B,J}{x,y,z}" + # @enum (moments): "N", "Nppc", "Rho", "Charge"; "T{i}{j}"; "V{i}" + # @note: "{E,B,J}mag" = vector magnitude |.|; "B1"/"Bx", "J3"/"Jz", ... = + # a single (signed) physical component # @note: a bare vector ("E"/"B"/"J") is not renderable -- choose a # component or the magnitude + # @note: "N"/"Nppc" = number / per-cell count, "Rho" = mass density, + # "Charge" = charge density + # @note: "T{i}{j}" = one stress-energy component, i,j in {t,x,y,z} or + # {0,1,2,3} (e.g. "Txx", "Ttt", "T0x"); "V{i}" = one bulk-velocity + # component, i in {x,y,z} or {1,2,3} (e.g. "Vx", "V1") + # @note: per-species selection with a "_" suffix on moments, e.g. + # "N_1", "Rho_2", "Txy_1_2", "V1_3"; default = all massive species # @note: components are signed; pair a symmetric `min`/`max` with a # diverging colormap ("cool2warm") to center zero field = "" diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index ffdd51ba1..b429a88a1 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -56,14 +56,24 @@ namespace ntt { void renderMoment(const SimulationParams& params, const Mesh& mesh, const std::vector>& prtl_species, - ndfield_t& buffer, - idx_t buff_idx) { - std::vector specs; - for (auto& sp : prtl_species) { - if (sp.mass() > 0) { - specs.push_back(sp.index()); + const std::vector& species, + const std::vector& components, + ndfield_t& buffer, + idx_t buff_idx) { + std::vector specs = species; + if (specs.empty()) { + // default: accumulate over all massive species + for (auto& sp : prtl_species) { + if (sp.mass() > 0) { + specs.push_back(sp.index()); + } } } + for (const auto& sp : specs) { + raise::ErrorIf((sp > prtl_species.size()) or (sp == 0), + "Invalid species index " + std::to_string(sp), + HERE); + } auto scatter_buff = Kokkos::Experimental::create_scatter_view(buffer); const auto use_weights = params.get("particles.use_weights"); const auto ni2 = mesh.n_active(in::x2); @@ -72,7 +82,6 @@ namespace ntt { "output.fields.smoothing.order"); const auto smooth_method = OutputSmoothingType::from_string( params.get("output.fields.smoothing.method")); - const std::vector components {}; for (const auto& sp : specs) { auto& prtl_spec = prtl_species[sp - 1]; Kokkos::parallel_for( @@ -166,34 +175,153 @@ namespace ntt { for (const auto& scene : g_renderer.scenes()) { Kokkos::deep_copy(bckp, ZERO); - if (scene.field == "N") { - renderMoment(params, - local_domain->mesh, - local_domain->species, - bckp, - 0u); + // Parse an optional trailing per-species suffix "__..."; + // species apply to particle moments only (N, Nppc, Rho, Charge, T, V). + std::string base = scene.field; + std::vector species; + { + const auto us = scene.field.find('_'); + if (us != std::string::npos) { + bool ok_sp = true; + std::size_t start = us + 1; + while (start <= scene.field.size()) { + const auto nx = scene.field.find('_', start); + const auto tok = scene.field.substr( + start, + (nx == std::string::npos) ? std::string::npos : nx - start); + if (tok.empty() or + tok.find_first_not_of("0123456789") != std::string::npos) { + ok_sp = false; + break; + } + species.push_back(static_cast(std::stoi(tok))); + if (nx == std::string::npos) { + break; + } + start = nx + 1; + } + if (ok_sp) { + base = scene.field.substr(0, us); + } else { + species.clear(); // not a species suffix; keep the full name + } + } + } + bool bad_species = false; + for (const auto sp : species) { + if (sp == 0 or sp > local_domain->species.size()) { + bad_species = true; + } + } + if (bad_species) { + raise::Warning("output.render: invalid species in '" + scene.field + + "', skipping", + HERE); + continue; + } + + // axis/index character -> {t,x,y,z} == {0,1,2,3}; -1 if invalid + auto axisIdx = [](char ch) -> int { + switch (ch) { + case 't': + case '0': + return 0; + case 'x': + case '1': + return 1; + case 'y': + case '2': + return 2; + case 'z': + case '3': + return 3; + default: + return -1; + } + }; + + bool handled = false; + if (base == "N" or base == "Nppc" or base == "Rho" or base == "Charge") { + // scalar particle moments + if (base == "N") { + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 0u); + } else if (base == "Nppc") { + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 0u); + } else if (base == "Rho") { + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 0u); + } else { + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 0u); + } // sum boundary-crossing particle deposits back into active cells - // (particles in neighbor domains deposit into our ghost zone) SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); - } else { + handled = true; + } else if (base.size() == 3 and base[0] == 'T') { + // a single stress-energy tensor component "T" + const int i = axisIdx(base[1]); + const int j = axisIdx(base[2]); + if (i >= 0 and j >= 0) { + const std::vector comps { static_cast(i), + static_cast(j) }; + renderMoment(params, local_domain->mesh, + local_domain->species, species, comps, + bckp, 0u); + SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); + handled = true; + } + } else if (base.size() == 2 and base[0] == 'V') { + // a single bulk-velocity component "V" (spatial); normalize by Rho + const int c = axisIdx(base[1]); + if (c >= 1 and c <= 3) { + const std::vector comps { static_cast(c) }; + renderMoment(params, local_domain->mesh, + local_domain->species, species, comps, + bckp, 0u); + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 1u); + SynchronizeFields(*local_domain, Comm::Bckp, { 0, 2 }); + // V_c = (mass-weighted bulk velocity) / Rho + auto bckp_v = bckp; + Kokkos::parallel_for( + "RenderNormalizeV", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + const real_t rho = bckp_v(i1, i2, i3, 1); + bckp_v(i1, i2, i3, 0) = (rho != ZERO) + ? (bckp_v(i1, i2, i3, 0) / rho) + : ZERO; + }); + handled = true; + } + } + + if (not handled) { // Vector field as a scalar: "" with base in {E, B, J} // and selector in {mag, 1/2/3, x/y/z}. A component (e.g. "B1"/"Bx") is // signed; a magnitude (e.g. "Bmag") is non-negative. - const std::string& f = scene.field; - const char base = f.empty() - ? '?' - : static_cast(std::toupper(f[0])); + const std::string& f = base; + const char fbase = f.empty() + ? '?' + : static_cast(std::toupper(f[0])); bool ok = true; bool is_current = false; uint8_t src_base = 0; // first component of the source field PrepareOutputFlags interp = PrepareOutput::None; - if (base == 'B') { + if (fbase == 'B') { src_base = em::bx1; interp = PrepareOutput::InterpToCellCenterFromFaces; - } else if (base == 'E') { + } else if (fbase == 'E') { src_base = em::ex1; interp = PrepareOutput::InterpToCellCenterFromEdges; - } else if (base == 'J') { + } else if (fbase == 'J') { is_current = true; src_base = cur::jx1; interp = PrepareOutput::InterpToCellCenterFromEdges; From 866bf5041d53ab72eb23ad17f7bd0388ea1103aa Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 14:29:21 -0400 Subject: [PATCH 09/20] added Vmag rendering --- input.example.toml | 5 ++-- src/framework/domain/metadomain_render.cpp | 34 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/input.example.toml b/input.example.toml index b9c3c070a..08e13aa97 100644 --- a/input.example.toml +++ b/input.example.toml @@ -786,7 +786,7 @@ # @required # @type: string # @enum (fields): "{E,B,J}mag"; "{E,B,J}{1,2,3}" or "{E,B,J}{x,y,z}" - # @enum (moments): "N", "Nppc", "Rho", "Charge"; "T{i}{j}"; "V{i}" + # @enum (moments): "N", "Nppc", "Rho", "Charge"; "T{i}{j}"; "V{i}"; "Vmag" # @note: "{E,B,J}mag" = vector magnitude |.|; "B1"/"Bx", "J3"/"Jz", ... = # a single (signed) physical component # @note: a bare vector ("E"/"B"/"J") is not renderable -- choose a @@ -795,7 +795,8 @@ # "Charge" = charge density # @note: "T{i}{j}" = one stress-energy component, i,j in {t,x,y,z} or # {0,1,2,3} (e.g. "Txx", "Ttt", "T0x"); "V{i}" = one bulk-velocity - # component, i in {x,y,z} or {1,2,3} (e.g. "Vx", "V1") + # component, i in {x,y,z} or {1,2,3} (e.g. "Vx", "V1"); "Vmag" = + # bulk-velocity magnitude sqrt(V1^2+V2^2+V3^2) # @note: per-species selection with a "_" suffix on moments, e.g. # "N_1", "Rho_2", "Txy_1_2", "V1_3"; default = all massive species # @note: components are signed; pair a symmetric `min`/`max` with a diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index b429a88a1..03fa80dd9 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -276,6 +276,40 @@ namespace ntt { SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); handled = true; } + } else if (base == "Vmag") { + // bulk-velocity magnitude |V| = sqrt(V1^2 + V2^2 + V3^2). Each Vi is + // the mass-weighted flux normalized by Rho, so deposit the three + // spatial components into bckp(0..2) and Rho into bckp(3), sum-sync + // all four, divide, then reduce to the Euclidean norm in bckp(0). + renderMoment(params, local_domain->mesh, + local_domain->species, species, { 1u }, + bckp, 0u); + renderMoment(params, local_domain->mesh, + local_domain->species, species, { 2u }, + bckp, 1u); + renderMoment(params, local_domain->mesh, + local_domain->species, species, { 3u }, + bckp, 2u); + renderMoment(params, local_domain->mesh, + local_domain->species, species, {}, + bckp, 3u); + SynchronizeFields(*local_domain, Comm::Bckp, { 0, 4 }); + auto bckp_v = bckp; + Kokkos::parallel_for( + "RenderNormalizeVmag", + local_domain->mesh.rangeActiveCells(), + Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { + const real_t rho = bckp_v(i1, i2, i3, 3); + if (rho != ZERO) { + const real_t v1 = bckp_v(i1, i2, i3, 0) / rho; + const real_t v2 = bckp_v(i1, i2, i3, 1) / rho; + const real_t v3 = bckp_v(i1, i2, i3, 2) / rho; + bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); + } else { + bckp_v(i1, i2, i3, 0) = ZERO; + } + }); + handled = true; } else if (base.size() == 2 and base[0] == 'V') { // a single bulk-velocity component "V" (spatial); normalize by Rho const int c = axisIdx(base[1]); From afb45f540b5110c7f8cd3c8fdd19f6d0d1e747a5 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 15:42:59 -0400 Subject: [PATCH 10/20] support for 2D rendering --- input.example.toml | 33 +- src/framework/domain/metadomain.h | 11 +- src/framework/domain/metadomain_render.cpp | 772 ++++++++++++++------- src/output/render/reduce.hpp | 178 +++++ src/output/render/renderer.cpp | 24 +- src/output/render/renderer.h | 16 +- src/output/render/slice2d.hpp | 210 ++++++ 7 files changed, 963 insertions(+), 281 deletions(-) create mode 100644 src/output/render/reduce.hpp create mode 100644 src/output/render/slice2d.hpp diff --git a/input.example.toml b/input.example.toml index 08e13aa97..b4d5db216 100644 --- a/input.example.toml +++ b/input.example.toml @@ -681,10 +681,19 @@ # @default: [] custom = "" - # In-situ volume renderer. Ray-marches scalar fields on the GPU and writes - # PNG images directly to `/renders/` each cadence -- no field data is - # written to storage, and the result is seamless across MPI domain boundaries. - # @note: 3D Cartesian (Minkowski) only; a no-op for other dims/metrics + # In-situ renderer. Renders scalar fields on the GPU and writes PNG images + # directly to `/renders/` each cadence -- no field data is written to + # storage, and the result is seamless across MPI domain boundaries. + # @note: two modes, selected automatically by the simulation dimension: + # - 3D Cartesian (Minkowski): volume ray-march (uses `samples`, + # `step_size`, `early_term_alpha`, and the [camera] table) + # - 2D (Minkowski, Spherical/QSpherical, and all GR Kerr-Schild): + # flat slice rasterizer. Cartesian shows the (x, y) plane; spherical + # shows the meridional (r, theta) half-plane mapped to Cartesian + # (X = r sin th, Z = r cos th), optionally mirrored (see `mirror`). + # The `samples`/`step_size`/`early_term_alpha`/[camera] keys are + # ignored in 2D (one opaque sample per pixel). + # @note: 1D (and 3D non-Cartesian, which does not exist) is a no-op # @note: One PNG stream per scene (e.g. a density/|B|/|J| triptych) [output.render] # Toggle for the volume renderer @@ -746,10 +755,17 @@ # @type: bool # @default: true colorbar_outside = "" + # 2D spherical slice only: mirror the meridional half-plane across the + # symmetry axis to render a full disk from one axisymmetric half. No effect + # on Cartesian or 3D rendering. + # @type: bool + # @default: true + mirror = "" - # Camera. Defaults frame the whole global box from outside, looking down the - # (1,1,1) diagonal -- the production setup for which the structured composite - # is provably seamless. + # Camera (3D volume mode only; ignored by the 2D slice rasterizer). Defaults + # frame the whole global box from outside, looking down the (1,1,1) diagonal + # -- the production setup for which the structured composite is provably + # seamless. [output.render.camera] # Orthographic (true) or perspective (false) projection # @type: bool @@ -797,6 +813,9 @@ # {0,1,2,3} (e.g. "Txx", "Ttt", "T0x"); "V{i}" = one bulk-velocity # component, i in {x,y,z} or {1,2,3} (e.g. "Vx", "V1"); "Vmag" = # bulk-velocity magnitude sqrt(V1^2+V2^2+V3^2) + # @note: moments follow the engine: SRPIC = tetrad-basis bulk 3-velocity + # and stress-energy; GRPIC = Eckart-frame 4-velocity (so "Vt"/"V0" + # = u^0 = Gamma/alpha is also valid) and contravariant T # @note: per-species selection with a "_" suffix on moments, e.g. # "N_1", "Rho_2", "Txy_1_2", "V1_3"; default = all massive species # @note: components are signed; pair a symmetric `min`/`max` with a diff --git a/src/framework/domain/metadomain.h b/src/framework/domain/metadomain.h index 7e5d9f420..899213369 100644 --- a/src/framework/domain/metadomain.h +++ b/src/framework/domain/metadomain.h @@ -178,13 +178,22 @@ namespace ntt { void redecomposeFromCheckpoint(const std::vector>&, const std::vector>&); - /* in-situ volume renderer (see metadomain_render.cpp) ------------------ */ + /* in-situ renderer (3D volume ray-march & 2D slice; metadomain_render.cpp) */ void InitRenderer(const SimulationParams&); auto Render(const SimulationParams&, timestep_t, timestep_t, simtime_t, simtime_t) -> bool; + // Prepare the scalar named by a scene's `field` into bckp(:, 0) (active + // cells synced; ghosts not yet halo-filled). Shared by the 2D and 3D render + // paths so the field grammar (moments, T/V components, |E,B,J|, species + // suffix) has a single source of truth. Returns false (and warns) for an + // unknown field or invalid species so the caller can skip the scene. + auto prepareRenderScalar(const SimulationParams&, + Domain&, + const std::string& field_name, + ndfield_t&) const -> bool; #endif void InitStatsWriter(const SimulationParams&, bool); diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index 03fa80dd9..c45ba7f23 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -37,11 +37,15 @@ #include "kernels/particle_moments.hpp" #include "output/render/composite.h" #include "output/render/raymarch.hpp" +#include "output/render/reduce.hpp" +#include "output/render/slice2d.hpp" #include #include +#include #include +#include #include #include #include @@ -102,8 +106,312 @@ namespace ntt { Kokkos::Experimental::contribute(buffer, scatter_buff); } + // Copy 3 contiguous components [from.first, from.first+3) of a source field + // into bckp(:, 0..2). Dimension-generic (the subview arity depends on D). + template + void copyVec3ToBckp(const ndfield_t& src, + const ndfield_t& dst, + const cell_range_t& from) { + const cell_range_t to { 0, 3 }; + if constexpr (D == Dim::_2D) { + Kokkos::deep_copy( + Kokkos::subview(dst, Kokkos::ALL, Kokkos::ALL, to), + Kokkos::subview(src, Kokkos::ALL, Kokkos::ALL, from)); + } else if constexpr (D == Dim::_3D) { + Kokkos::deep_copy( + Kokkos::subview(dst, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, to), + Kokkos::subview(src, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, from)); + } + } + } // namespace + template + auto Metadomain::prepareRenderScalar(const SimulationParams& params, + Domain& domain, + const std::string& field_name, + ndfield_t& bckp) const + -> bool { + // Parse an optional trailing per-species suffix "__..."; + // species apply to particle moments only (N, Nppc, Rho, Charge, T, V). + std::string base = field_name; + std::vector species; + { + const auto us = field_name.find('_'); + if (us != std::string::npos) { + bool ok_sp = true; + std::size_t start = us + 1; + while (start <= field_name.size()) { + const auto nx = field_name.find('_', start); + const auto tok = field_name.substr( + start, + (nx == std::string::npos) ? std::string::npos : nx - start); + if (tok.empty() or + tok.find_first_not_of("0123456789") != std::string::npos) { + ok_sp = false; + break; + } + species.push_back(static_cast(std::stoi(tok))); + if (nx == std::string::npos) { + break; + } + start = nx + 1; + } + if (ok_sp) { + base = field_name.substr(0, us); + } else { + species.clear(); // not a species suffix; keep the full name + } + } + } + bool bad_species = false; + for (const auto sp : species) { + if (sp == 0 or sp > domain.species.size()) { + bad_species = true; + } + } + if (bad_species) { + raise::Warning("output.render: invalid species in '" + field_name + + "', skipping", + HERE); + return false; + } + + // axis/index character -> {t,x,y,z} == {0,1,2,3}; -1 if invalid + auto axisIdx = [](char ch) -> int { + switch (ch) { + case 't': + case '0': + return 0; + case 'x': + case '1': + return 1; + case 'y': + case '2': + return 2; + case 'z': + case '3': + return 3; + default: + return -1; + } + }; + + const auto& mesh = domain.mesh; + const auto metric = mesh.metric; + + if (base == "N" or base == "Nppc" or base == "Rho" or base == "Charge") { + // scalar particle moments + if (base == "N") { + renderMoment(params, mesh, domain.species, species, {}, + bckp, 0u); + } else if (base == "Nppc") { + renderMoment(params, mesh, domain.species, species, + {}, bckp, 0u); + } else if (base == "Rho") { + renderMoment(params, mesh, domain.species, species, + {}, bckp, 0u); + } else { + renderMoment(params, mesh, domain.species, species, + {}, bckp, 0u); + } + // sum boundary-crossing particle deposits back into active cells + SynchronizeFields(domain, Comm::Bckp, { 0, 1 }); + return true; + } else if (base.size() == 3 and base[0] == 'T') { + // a single stress-energy tensor component "T" (same for SR & GR; + // the moment kernel branches on the engine internally) + const int i = axisIdx(base[1]); + const int j = axisIdx(base[2]); + if (i >= 0 and j >= 0) { + const std::vector comps { static_cast(i), + static_cast(j) }; + renderMoment(params, mesh, domain.species, species, + comps, bckp, 0u); + SynchronizeFields(domain, Comm::Bckp, { 0, 1 }); + return true; + } + } else if (base == "Vmag") { + // bulk-velocity magnitude |V| = sqrt(V1^2 + V2^2 + V3^2) + if constexpr (S == SimEngine::GRPIC) { + // GR: Eckart-frame 4-velocity; need all 4 components for the norm + renderMoment(params, mesh, domain.species, species, + { 0u }, bckp, 0u); + renderMoment(params, mesh, domain.species, species, + { 1u }, bckp, 1u); + renderMoment(params, mesh, domain.species, species, + { 2u }, bckp, 2u); + renderMoment(params, mesh, domain.species, species, + { 3u }, bckp, 3u); + SynchronizeFields(domain, Comm::Bckp, { 0, 4 }); + Kokkos::parallel_for( + "RenderNormalize4Vel", + mesh.rangeActiveCells(), + kernel::Normalize4VelocityByNorm_kernel( + bckp, bckp, 0, 1, 2, 3, metric)); + Kokkos::parallel_for( + "RenderTransform4Vel", + mesh.rangeActiveCells(), + kernel::Transform4VelocitySpatialToPhysical_kernel( + bckp, 1, 2, 3, metric)); + // |spatial physical 4-velocity| -> bckp(0) + Kokkos::parallel_for("RenderVmagGR", + mesh.rangeActiveCells(), + kernel::RenderMagnitude3_kernel(bckp, 1, + 2, 3, 0)); + } else { + // SR: mass-weighted bulk 3-velocity, normalized by Rho + renderMoment(params, mesh, domain.species, species, + { 1u }, bckp, 0u); + renderMoment(params, mesh, domain.species, species, + { 2u }, bckp, 1u); + renderMoment(params, mesh, domain.species, species, + { 3u }, bckp, 2u); + renderMoment(params, mesh, domain.species, species, + {}, bckp, 3u); + SynchronizeFields(domain, Comm::Bckp, { 0, 4 }); + Kokkos::parallel_for("RenderVmagSR", + mesh.rangeActiveCells(), + kernel::RenderVmagByRho_kernel(bckp, 0, 1, + 2, 3, 0)); + } + return true; + } else if (base.size() == 2 and base[0] == 'V') { + // a single bulk-velocity component "V" + const int c = axisIdx(base[1]); + if constexpr (S == SimEngine::GRPIC) { + // GR: 4-velocity component (t/0 = u^0 = Gamma/alpha; x,y,z spatial) + if (c >= 0 and c <= 3) { + renderMoment(params, mesh, domain.species, species, + { 0u }, bckp, 0u); + renderMoment(params, mesh, domain.species, species, + { 1u }, bckp, 1u); + renderMoment(params, mesh, domain.species, species, + { 2u }, bckp, 2u); + renderMoment(params, mesh, domain.species, species, + { 3u }, bckp, 3u); + SynchronizeFields(domain, Comm::Bckp, { 0, 4 }); + Kokkos::parallel_for( + "RenderNormalize4Vel", + mesh.rangeActiveCells(), + kernel::Normalize4VelocityByNorm_kernel( + bckp, bckp, 0, 1, 2, 3, metric)); + Kokkos::parallel_for( + "RenderTransform4Vel", + mesh.rangeActiveCells(), + kernel::Transform4VelocitySpatialToPhysical_kernel( + bckp, 1, 2, 3, metric)); + if (c != 0) { + Kokkos::parallel_for( + "RenderPickV", + mesh.rangeActiveCells(), + kernel::RenderPickComp_kernel( + bckp, static_cast(c), 0)); + } + return true; + } + } else { + // SR: spatial bulk velocity (x,y,z), normalized by Rho + if (c >= 1 and c <= 3) { + renderMoment(params, mesh, domain.species, species, + { static_cast(c) }, bckp, 0u); + renderMoment(params, mesh, domain.species, species, + {}, bckp, 1u); + SynchronizeFields(domain, Comm::Bckp, { 0, 2 }); + Kokkos::parallel_for("RenderNormalizeV", + mesh.rangeActiveCells(), + kernel::RenderDivideComp_kernel(bckp, 0, + 1)); + return true; + } + } + } else { + // Vector field as a scalar: "" with base in {E, B, J} + // and selector in {mag, 1/2/3, x/y/z}. A component (e.g. "B1"/"Bx") is + // signed; a magnitude (e.g. "Bmag") is non-negative. + const std::string& f = base; + const char fbase = f.empty() + ? '?' + : static_cast(std::toupper(f[0])); + bool ok = true; + bool is_current = false; + uint8_t src_base = 0; // first component of the source field + PrepareOutputFlags interp = PrepareOutput::None; + if (fbase == 'B') { + src_base = em::bx1; + interp = PrepareOutput::InterpToCellCenterFromFaces; + } else if (fbase == 'E') { + src_base = em::ex1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else if (fbase == 'J') { + is_current = true; + src_base = cur::jx1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else { + ok = false; + } + // selector: -1 = magnitude, 0/1/2 = a single component + int comp = -2; + const std::string sel = (f.size() > 1) ? f.substr(1) : std::string {}; + if (sel == "mag") { + comp = -1; + } else if (sel == "1" or sel == "x") { + comp = 0; + } else if (sel == "2" or sel == "y") { + comp = 1; + } else if (sel == "3" or sel == "z") { + comp = 2; + } else { + ok = false; + } + if (ok) { + // raw vector components into bckp(:, 0..2) + if (is_current) { + copyVec3ToBckp(domain.fields.cur, bckp, + cell_range_t(cur::jx1, cur::jx3 + 1)); + } else { + copyVec3ToBckp(domain.fields.em, bckp, + cell_range_t(src_base, src_base + 3)); + } + // interpolate to cell centers + convert to physical basis -> (3,4,5) + const PrepareOutputFlags prepare = (S == SimEngine::SRPIC) + ? PrepareOutput::ConvertToHat + : PrepareOutput::ConvertToPhysCntrv; + list_t comp_from = { 0, 1, 2 }; + list_t comp_to = { 3, 4, 5 }; + Kokkos::parallel_for( + "RenderFieldsToPhys", + mesh.rangeActiveCells(), + kernel::FieldsToPhys_kernel(bckp, + bckp, + comp_from, + comp_to, + interp | prepare, + metric)); + // reduce to the scalar to render -> bckp(:, 0) + if (comp == -1) { + Kokkos::parallel_for( + "RenderVectorMagnitude", + mesh.rangeActiveCells(), + kernel::RenderMagnitude3_kernel(bckp, 3, 4, 5, 0)); + } else { + Kokkos::parallel_for( + "RenderVectorComponent", + mesh.rangeActiveCells(), + kernel::RenderPickComp_kernel( + bckp, static_cast(3 + comp), 0)); + } + return true; + } + } + + raise::Warning("output.render: unknown field '" + field_name + + "' (expected N/Nppc/Rho/Charge, T{i}{j}, V{i}/Vmag, or " + "{E,B,J}{mag,1,2,3,x,y,z}); skipping", + HERE); + return false; + } + template void Metadomain::InitRenderer(const SimulationParams& params) { g_renderer.init(params, mesh().extent()); @@ -115,7 +423,9 @@ namespace ntt { timestep_t finished_step, simtime_t current_time, simtime_t finished_time) -> bool { + (void)current_time; if constexpr (M::Dim == Dim::_3D and M::CoordType == Coord::type::Cartesian) { + // ---- 3D volume ray-march (Minkowski only) ----------------------- // // structured-order composite assumes an axis-aligned, affine code<->world // map; only Cartesian (Minkowski) 3D qualifies. if (not g_renderer.enabled() or @@ -129,7 +439,7 @@ namespace ntt { raise::ErrorIf(local_domain->is_placeholder(), "local_domain is a placeholder", HERE); - logger::Checkpoint("Rendering output", HERE); + logger::Checkpoint("Rendering output (3D volume)", HERE); const auto& cam = g_renderer.camera(); const int W = g_renderer.width(); @@ -174,273 +484,14 @@ namespace ntt { bool rendered_any = false; for (const auto& scene : g_renderer.scenes()) { Kokkos::deep_copy(bckp, ZERO); - - // Parse an optional trailing per-species suffix "__..."; - // species apply to particle moments only (N, Nppc, Rho, Charge, T, V). - std::string base = scene.field; - std::vector species; - { - const auto us = scene.field.find('_'); - if (us != std::string::npos) { - bool ok_sp = true; - std::size_t start = us + 1; - while (start <= scene.field.size()) { - const auto nx = scene.field.find('_', start); - const auto tok = scene.field.substr( - start, - (nx == std::string::npos) ? std::string::npos : nx - start); - if (tok.empty() or - tok.find_first_not_of("0123456789") != std::string::npos) { - ok_sp = false; - break; - } - species.push_back(static_cast(std::stoi(tok))); - if (nx == std::string::npos) { - break; - } - start = nx + 1; - } - if (ok_sp) { - base = scene.field.substr(0, us); - } else { - species.clear(); // not a species suffix; keep the full name - } - } - } - bool bad_species = false; - for (const auto sp : species) { - if (sp == 0 or sp > local_domain->species.size()) { - bad_species = true; - } - } - if (bad_species) { - raise::Warning("output.render: invalid species in '" + scene.field + - "', skipping", - HERE); + if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { continue; } - - // axis/index character -> {t,x,y,z} == {0,1,2,3}; -1 if invalid - auto axisIdx = [](char ch) -> int { - switch (ch) { - case 't': - case '0': - return 0; - case 'x': - case '1': - return 1; - case 'y': - case '2': - return 2; - case 'z': - case '3': - return 3; - default: - return -1; - } - }; - - bool handled = false; - if (base == "N" or base == "Nppc" or base == "Rho" or base == "Charge") { - // scalar particle moments - if (base == "N") { - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 0u); - } else if (base == "Nppc") { - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 0u); - } else if (base == "Rho") { - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 0u); - } else { - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 0u); - } - // sum boundary-crossing particle deposits back into active cells - SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); - handled = true; - } else if (base.size() == 3 and base[0] == 'T') { - // a single stress-energy tensor component "T" - const int i = axisIdx(base[1]); - const int j = axisIdx(base[2]); - if (i >= 0 and j >= 0) { - const std::vector comps { static_cast(i), - static_cast(j) }; - renderMoment(params, local_domain->mesh, - local_domain->species, species, comps, - bckp, 0u); - SynchronizeFields(*local_domain, Comm::Bckp, { 0, 1 }); - handled = true; - } - } else if (base == "Vmag") { - // bulk-velocity magnitude |V| = sqrt(V1^2 + V2^2 + V3^2). Each Vi is - // the mass-weighted flux normalized by Rho, so deposit the three - // spatial components into bckp(0..2) and Rho into bckp(3), sum-sync - // all four, divide, then reduce to the Euclidean norm in bckp(0). - renderMoment(params, local_domain->mesh, - local_domain->species, species, { 1u }, - bckp, 0u); - renderMoment(params, local_domain->mesh, - local_domain->species, species, { 2u }, - bckp, 1u); - renderMoment(params, local_domain->mesh, - local_domain->species, species, { 3u }, - bckp, 2u); - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 3u); - SynchronizeFields(*local_domain, Comm::Bckp, { 0, 4 }); - auto bckp_v = bckp; - Kokkos::parallel_for( - "RenderNormalizeVmag", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - const real_t rho = bckp_v(i1, i2, i3, 3); - if (rho != ZERO) { - const real_t v1 = bckp_v(i1, i2, i3, 0) / rho; - const real_t v2 = bckp_v(i1, i2, i3, 1) / rho; - const real_t v3 = bckp_v(i1, i2, i3, 2) / rho; - bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); - } else { - bckp_v(i1, i2, i3, 0) = ZERO; - } - }); - handled = true; - } else if (base.size() == 2 and base[0] == 'V') { - // a single bulk-velocity component "V" (spatial); normalize by Rho - const int c = axisIdx(base[1]); - if (c >= 1 and c <= 3) { - const std::vector comps { static_cast(c) }; - renderMoment(params, local_domain->mesh, - local_domain->species, species, comps, - bckp, 0u); - renderMoment(params, local_domain->mesh, - local_domain->species, species, {}, - bckp, 1u); - SynchronizeFields(*local_domain, Comm::Bckp, { 0, 2 }); - // V_c = (mass-weighted bulk velocity) / Rho - auto bckp_v = bckp; - Kokkos::parallel_for( - "RenderNormalizeV", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - const real_t rho = bckp_v(i1, i2, i3, 1); - bckp_v(i1, i2, i3, 0) = (rho != ZERO) - ? (bckp_v(i1, i2, i3, 0) / rho) - : ZERO; - }); - handled = true; - } - } - - if (not handled) { - // Vector field as a scalar: "" with base in {E, B, J} - // and selector in {mag, 1/2/3, x/y/z}. A component (e.g. "B1"/"Bx") is - // signed; a magnitude (e.g. "Bmag") is non-negative. - const std::string& f = base; - const char fbase = f.empty() - ? '?' - : static_cast(std::toupper(f[0])); - bool ok = true; - bool is_current = false; - uint8_t src_base = 0; // first component of the source field - PrepareOutputFlags interp = PrepareOutput::None; - if (fbase == 'B') { - src_base = em::bx1; - interp = PrepareOutput::InterpToCellCenterFromFaces; - } else if (fbase == 'E') { - src_base = em::ex1; - interp = PrepareOutput::InterpToCellCenterFromEdges; - } else if (fbase == 'J') { - is_current = true; - src_base = cur::jx1; - interp = PrepareOutput::InterpToCellCenterFromEdges; - } else { - ok = false; - } - // selector: -1 = magnitude, 0/1/2 = a single component - int comp = -2; - const std::string sel = (f.size() > 1) ? f.substr(1) : std::string {}; - if (sel == "mag") { - comp = -1; - } else if (sel == "1" or sel == "x") { - comp = 0; - } else if (sel == "2" or sel == "y") { - comp = 1; - } else if (sel == "3" or sel == "z") { - comp = 2; - } else { - ok = false; - } - if (not ok) { - raise::Warning("output.render: unknown field '" + scene.field + - "' (expected N, {E,B,J}mag, or " - "{E,B,J}{1,2,3}|{x,y,z}); skipping", - HERE); - continue; - } - // raw vector components into bckp(:, 0..2) - if (is_current) { - Kokkos::deep_copy( - Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, - cell_range_t(0, 3)), - Kokkos::subview(local_domain->fields.cur, Kokkos::ALL, Kokkos::ALL, - Kokkos::ALL, cell_range_t(cur::jx1, cur::jx3 + 1))); - } else { - Kokkos::deep_copy( - Kokkos::subview(bckp, Kokkos::ALL, Kokkos::ALL, Kokkos::ALL, - cell_range_t(0, 3)), - Kokkos::subview(local_domain->fields.em, Kokkos::ALL, Kokkos::ALL, - Kokkos::ALL, cell_range_t(src_base, src_base + 3))); - } - // interpolate to cell centers + convert to physical basis -> (3,4,5) - const PrepareOutputFlags prepare = (S == SimEngine::SRPIC) - ? PrepareOutput::ConvertToHat - : PrepareOutput::ConvertToPhysCntrv; - list_t comp_from = { 0, 1, 2 }; - list_t comp_to = { 3, 4, 5 }; - Kokkos::parallel_for( - "RenderFieldsToPhys", - local_domain->mesh.rangeActiveCells(), - kernel::FieldsToPhys_kernel(bckp, - bckp, - comp_from, - comp_to, - interp | prepare, - metric)); - // reduce to the scalar to render -> bckp(:, 0) - auto bckp_v = bckp; - const int cc = comp; - if (comp == -1) { - Kokkos::parallel_for( - "RenderVectorMagnitude", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - const real_t v1 = bckp_v(i1, i2, i3, 3); - const real_t v2 = bckp_v(i1, i2, i3, 4); - const real_t v3 = bckp_v(i1, i2, i3, 5); - bckp_v(i1, i2, i3, 0) = math::sqrt(v1 * v1 + v2 * v2 + v3 * v3); - }); - } else { - Kokkos::parallel_for( - "RenderVectorComponent", - local_domain->mesh.rangeActiveCells(), - Lambda(cellidx_t i1, cellidx_t i2, cellidx_t i3) { - bckp_v(i1, i2, i3, 0) = bckp_v(i1, i2, i3, 3 + cc); - }); - } - } - // fill the ghost halo with neighbor active values so trilinear - // sampling is C0 across domain faces (this is a halo EXCHANGE, not the + // sampling is C0 across domain faces (a halo EXCHANGE, not the // sum-into-active that SynchronizeFields performs). CommunicateBckp(*local_domain, { 0, 1 }); - // ---- launch the ray-march kernel over the screen bbox ---------- // out::SubImage sub; if (on_screen) { sub.x0 = bx0; @@ -496,11 +547,199 @@ namespace ntt { rendered_any = true; } return rendered_any; + } else if constexpr (M::Dim == Dim::_2D) { + // ---- 2D slice rasterizer (Cartesian or spherical) --------------- // + // A 2D run has no depth to integrate: each pixel is one inverse-mapped + // sample, painted opaque. Domains tile the screen disjointly, so the + // sparse sub-images composite seamlessly regardless of order. + if (not g_renderer.enabled() or + not g_renderer.shouldRender(finished_step, finished_time)) { + return false; + } + raise::ErrorIf(l_subdomain_indices().size() != 1, + "Renderer supports one subdomain per rank only", + HERE); + auto local_domain = subdomain_ptr(l_subdomain_indices()[0]); + raise::ErrorIf(local_domain->is_placeholder(), + "local_domain is a placeholder", + HERE); + logger::Checkpoint("Rendering output (2D slice)", HERE); + + const int W = g_renderer.width(); + const int H = g_renderer.height(); + const bool mirror = g_renderer.mirror(); + + // global slice-plane world window (shared by all ranks -> seamless) + const auto gext = mesh().extent(); + real_t umin, umax, vmin, vmax; + if constexpr (M::CoordType == Coord::type::Cartesian) { + umin = gext[0].first; + umax = gext[0].second; + vmin = gext[1].first; + vmax = gext[1].second; + } else { + // meridional (X = r sin th, Z = r cos th) bounding box of the arc + const real_t rmax = gext[0].second; + const real_t th0 = gext[1].first; + const real_t th1 = gext[1].second; + umax = rmax; + umin = mirror ? -rmax : ZERO; + vmax = rmax * math::cos(th0); + vmin = rmax * math::cos(th1); + } + // expand the window to the image aspect (centered) so geometry is not + // stretched + { + const real_t waspect = (umax - umin) / (vmax - vmin); + const real_t iaspect = static_cast(W) / static_cast(H); + if (iaspect > waspect) { + const real_t cu = HALF * (umin + umax); + const real_t hu = HALF * (vmax - vmin) * iaspect; + umin = cu - hu; + umax = cu + hu; + } else { + const real_t cv = HALF * (vmin + vmax); + const real_t hv = HALF * (umax - umin) / iaspect; + vmin = cv - hv; + vmax = cv + hv; + } + } + + auto& bckp = local_domain->fields.bckp; + const int ext0 = static_cast(bckp.extent(0)); + const int ext1 = static_cast(bckp.extent(1)); + const auto metric = local_domain->mesh.metric; + const int n1 = static_cast(local_domain->mesh.n_active(in::x1)); + const int n2 = static_cast(local_domain->mesh.n_active(in::x2)); + + // screen-space bbox of this domain's footprint (host projection of the + // boundary; an arc for spherical, a box for Cartesian) + const auto le = local_domain->mesh.extent(); + auto toPix = [&](real_t u, real_t v, real_t& px, real_t& py) { + px = (u - umin) / (umax - umin) * static_cast(W) - HALF; + py = (vmax - v) / (vmax - vmin) * static_cast(H) - HALF; + }; + real_t minx = static_cast(1e30), miny = static_cast(1e30); + real_t maxx = static_cast(-1e30), maxy = static_cast(-1e30); + auto acc = [&](real_t u, real_t v) { + real_t px, py; + toPix(u, v, px, py); + minx = std::min(minx, px); + maxx = std::max(maxx, px); + miny = std::min(miny, py); + maxy = std::max(maxy, py); + }; + if constexpr (M::CoordType == Coord::type::Cartesian) { + acc(le[0].first, le[1].first); + acc(le[0].second, le[1].first); + acc(le[0].first, le[1].second); + acc(le[0].second, le[1].second); + } else { + const int NB = 33; + const real_t r0 = le[0].first, r1 = le[0].second; + const real_t a0 = le[1].first, a1 = le[1].second; + for (int k = 0; k < NB; ++k) { + const real_t t = static_cast(k) / static_cast(NB - 1); + const real_t rr = r0 + (r1 - r0) * t; + const real_t aa = a0 + (a1 - a0) * t; + // r-arcs at a0, a1 and theta-rays at r0, r1 + const real_t pts[4][2] = { { r0 * math::sin(aa), r0 * math::cos(aa) }, + { r1 * math::sin(aa), r1 * math::cos(aa) }, + { rr * math::sin(a0), rr * math::cos(a0) }, + { rr * math::sin(a1), rr * math::cos(a1) } }; + for (auto& p : pts) { + acc(p[0], p[1]); + if (mirror) { + acc(-p[0], p[1]); + } + } + } + } + const int pad = 2; + int x0 = static_cast(std::floor(minx)) - pad; + int x1 = static_cast(std::ceil(maxx)) + pad; + int y0 = static_cast(std::floor(miny)) - pad; + int y1 = static_cast(std::ceil(maxy)) + pad; + x0 = std::max(0, std::min(W, x0)); + x1 = std::max(0, std::min(W, x1)); + y0 = std::max(0, std::min(H, y0)); + y1 = std::max(0, std::min(H, y1)); + const int bx0 = x0, by0 = y0, bw = x1 - x0, bh = y1 - y0; + + // disjoint tiling -> any consistent total order composites correctly; + // a lexicographic key over the decomposition offsets is unique per rank. + const real_t fwd2d[3] = { ONE, ONE, ZERO }; + const uint64_t order_key = out::compositeOrderKey( + local_domain->offset_ndomains(), + ndomains_per_dim(), + fwd2d); + + bool rendered_any = false; + for (const auto& scene : g_renderer.scenes()) { + Kokkos::deep_copy(bckp, ZERO); + if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { + continue; + } + CommunicateBckp(*local_domain, { 0, 1 }); + + out::SubImage sub; + if (bw > 0 and bh > 0) { + sub.x0 = bx0; + sub.y0 = by0; + sub.w = bw; + sub.h = bh; + const std::size_t bnpix = static_cast(bw) * + static_cast(bh); + array_t image { "render_img", bnpix }; + randacc_ndfield_t Fld { bckp }; + Kokkos::parallel_for( + "Slice2DRaster", + CreateRangePolicy({ 0, 0 }, + { static_cast(bw), + static_cast(bh) }), + kernel::SliceRaster_kernel(Fld, + 0u, + metric, + umin, + umax, + vmin, + vmax, + W, + H, + bx0, + by0, + bw, + mirror, + n1, + n2, + ext0, + ext1, + scene.tf.lut_opaque, + scene.tf.n_lut, + scene.tf.vmin, + scene.tf.vmax, + scene.tf.log_scale, + image)); + Kokkos::fence(); + + auto image_h = Kokkos::create_mirror_view(image); + Kokkos::deep_copy(image_h, image); + sub.rgba.resize(bnpix * 4); + for (std::size_t p = 0; p < bnpix; ++p) { + sub.rgba[p * 4 + 0] = image_h(p, 0); + sub.rgba[p * 4 + 1] = image_h(p, 1); + sub.rgba[p * 4 + 2] = image_h(p, 2); + sub.rgba[p * 4 + 3] = image_h(p, 3); + } + } + g_renderer.compositeAndWrite(sub, order_key, scene, current_step); + rendered_any = true; + } + return rendered_any; } else { (void)params; (void)current_step; (void)finished_step; - (void)current_time; (void)finished_time; return false; } @@ -513,7 +752,12 @@ namespace ntt { timestep_t, \ timestep_t, \ simtime_t, \ - simtime_t) -> bool; + simtime_t) -> bool; \ + template auto Metadomain>::prepareRenderScalar( \ + const SimulationParams&, \ + Domain>&, \ + const std::string&, \ + ndfield_t::Dim, 6>&) const -> bool; NTT_FOREACH_SPECIALIZATION(METADOMAIN_RENDER) diff --git a/src/output/render/reduce.hpp b/src/output/render/reduce.hpp new file mode 100644 index 000000000..18478fc6b --- /dev/null +++ b/src/output/render/reduce.hpp @@ -0,0 +1,178 @@ +/** + * @file output/render/reduce.hpp + * @brief Small dimension-generic cell reductions used by the in-situ renderer + * @implements + * - kernel::RenderMagnitude3_kernel + * - kernel::RenderPickComp_kernel + * - kernel::RenderDivideComp_kernel + * - kernel::RenderVmagByRho_kernel + * @namespaces: + * - kernel:: + * @macros: + * - OUTPUT_ENABLED + * @note + * Each functor provides 1D/2D/3D operator() overloads so the same object works + * with `mesh.rangeActiveCells()` of any dimension (the range policy selects the + * matching arity). They reduce the prepared (interpolated, synced) `bckp` + * scratch field down to the single scalar component the ray-march / slice + * kernel samples, so the field-grammar dispatch stays dimension-agnostic. + */ + +#ifndef OUTPUT_RENDER_REDUCE_HPP +#define OUTPUT_RENDER_REDUCE_HPP + +#include "global.h" + +#include "arch/kokkos_aliases.h" + +#include + +namespace kernel { + using namespace ntt; + + /** + * @brief F(.., co) = sqrt(F(.., c0)^2 + F(.., c1)^2 + F(.., c2)^2) + */ + template + class RenderMagnitude3_kernel { + ndfield_t F; + const std::uint8_t c0, c1, c2, co; + + public: + RenderMagnitude3_kernel(const ndfield_t& f, + std::uint8_t a, + std::uint8_t b, + std::uint8_t c, + std::uint8_t o) + : F { f } + , c0 { a } + , c1 { b } + , c2 { c } + , co { o } {} + + Inline void operator()(cellidx_t i1) const { + const real_t v0 = F(i1, c0), v1 = F(i1, c1), v2 = F(i1, c2); + F(i1, co) = math::sqrt(v0 * v0 + v1 * v1 + v2 * v2); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2) const { + const real_t v0 = F(i1, i2, c0), v1 = F(i1, i2, c1), v2 = F(i1, i2, c2); + F(i1, i2, co) = math::sqrt(v0 * v0 + v1 * v1 + v2 * v2); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2, cellidx_t i3) const { + const real_t v0 = F(i1, i2, i3, c0), v1 = F(i1, i2, i3, c1), + v2 = F(i1, i2, i3, c2); + F(i1, i2, i3, co) = math::sqrt(v0 * v0 + v1 * v1 + v2 * v2); + } + }; + + /** + * @brief F(.., co) = F(.., ci) (move one component into the render slot) + */ + template + class RenderPickComp_kernel { + ndfield_t F; + const std::uint8_t ci, co; + + public: + RenderPickComp_kernel(const ndfield_t& f, std::uint8_t i, std::uint8_t o) + : F { f } + , ci { i } + , co { o } {} + + Inline void operator()(cellidx_t i1) const { + F(i1, co) = F(i1, ci); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2) const { + F(i1, i2, co) = F(i1, i2, ci); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2, cellidx_t i3) const { + F(i1, i2, i3, co) = F(i1, i2, i3, ci); + } + }; + + /** + * @brief F(.., cnum) = (F(.., cden) != 0) ? F(.., cnum) / F(.., cden) : 0 + */ + template + class RenderDivideComp_kernel { + ndfield_t F; + const std::uint8_t cnum, cden; + + public: + RenderDivideComp_kernel(const ndfield_t& f, + std::uint8_t num, + std::uint8_t den) + : F { f } + , cnum { num } + , cden { den } {} + + Inline void operator()(cellidx_t i1) const { + const real_t d = F(i1, cden); + F(i1, cnum) = (d != ZERO) ? (F(i1, cnum) / d) : ZERO; + } + + Inline void operator()(cellidx_t i1, cellidx_t i2) const { + const real_t d = F(i1, i2, cden); + F(i1, i2, cnum) = (d != ZERO) ? (F(i1, i2, cnum) / d) : ZERO; + } + + Inline void operator()(cellidx_t i1, cellidx_t i2, cellidx_t i3) const { + const real_t d = F(i1, i2, i3, cden); + F(i1, i2, i3, cnum) = (d != ZERO) ? (F(i1, i2, i3, cnum) / d) : ZERO; + } + }; + + /** + * @brief F(.., co) = | (F(c0), F(c1), F(c2)) / F(crho) |, else 0. + * @note SR bulk-speed magnitude: the three mass-weighted flux components are + * each normalized by Rho before the Euclidean norm, in one pass. + */ + template + class RenderVmagByRho_kernel { + ndfield_t F; + const std::uint8_t c0, c1, c2, crho, co; + + public: + RenderVmagByRho_kernel(const ndfield_t& f, + std::uint8_t a, + std::uint8_t b, + std::uint8_t c, + std::uint8_t rho, + std::uint8_t o) + : F { f } + , c0 { a } + , c1 { b } + , c2 { c } + , crho { rho } + , co { o } {} + + Inline auto mag(real_t v0, real_t v1, real_t v2, real_t rho) const -> real_t { + if (rho == ZERO) { + return ZERO; + } + const real_t a = v0 / rho, b = v1 / rho, c = v2 / rho; + return math::sqrt(a * a + b * b + c * c); + } + + Inline void operator()(cellidx_t i1) const { + F(i1, co) = mag(F(i1, c0), F(i1, c1), F(i1, c2), F(i1, crho)); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2) const { + F(i1, i2, co) = mag(F(i1, i2, c0), F(i1, i2, c1), F(i1, i2, c2), + F(i1, i2, crho)); + } + + Inline void operator()(cellidx_t i1, cellidx_t i2, cellidx_t i3) const { + F(i1, i2, i3, co) = mag(F(i1, i2, i3, c0), F(i1, i2, i3, c1), + F(i1, i2, i3, c2), F(i1, i2, i3, crho)); + } + }; + +} // namespace kernel + +#endif // OUTPUT_RENDER_REDUCE_HPP diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 7c33fc2c5..128135895 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -69,10 +69,11 @@ namespace out { if (not enable) { return; } - // the renderer is a 3D feature; silently no-op otherwise. - if (global_extent.size() != 3) { - raise::Warning("output.render enabled but simulation is not 3D; " - "the volume renderer will be inactive", + // 2D (slice rasterizer) and 3D Cartesian (volume ray-march) are supported; + // 1D has nothing to render. + if (global_extent.size() != 2 and global_extent.size() != 3) { + raise::Warning("output.render enabled but simulation is 1D; " + "the renderer will be inactive", HERE); return; } @@ -108,6 +109,8 @@ namespace out { "render", "colorbar_outside", true); + // 2D slice mode (spherical only): mirror the half-plane into a full disk + m_mirror = toml::find_or(td, "output", "render", "mirror", true); // cadence: mirror output.* (interval in steps; interval_time in sim time) const auto interval = toml::find_or(td, @@ -122,10 +125,11 @@ namespace out { -1.0); m_tracker.init("render", interval, interval_time); - /* ---- camera --------------------------------------------------------- */ - real_t center[3], size[3]; + /* ---- camera (used by the 3D volume mode; the 2D slice path frames itself + * and ignores this, so a missing 3rd axis is zero-filled harmlessly) ---- */ + real_t center[3] = { ZERO, ZERO, ZERO }, size[3] = { ZERO, ZERO, ZERO }; real_t maxext = ZERO; - for (int d = 0; d < 3; ++d) { + for (std::size_t d = 0; d < global_extent.size() and d < 3; ++d) { center[d] = static_cast(0.5) * (global_extent[d].first + global_extent[d].second); size[d] = global_extent[d].second - global_extent[d].first; @@ -249,6 +253,10 @@ namespace out { } } scene.tf.lut = buildLUT(colormap, m_n_lut, alpha_pts); + // opaque companion LUT (alpha == 1) for the flat 2D slice rasterizer + scene.tf.lut_opaque = buildLUT(colormap, + m_n_lut, + { { ZERO, ONE }, { ONE, ONE } }); m_scenes.push_back(std::move(scene)); } @@ -258,7 +266,7 @@ namespace out { } m_enabled = true; - logger::Checkpoint("Volume renderer initialized", HERE); + logger::Checkpoint("In-situ renderer initialized", HERE); } void Renderer::compositeAndWrite(const SubImage& sub, diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 911847c47..15b64b624 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -58,6 +58,10 @@ namespace out { */ struct TransferFunction { array_t lut; // device, (n_lut, 4), premultiplied RGBA + // opaque variant (alpha == 1 everywhere, so the premultiplied entries are + // straight RGB) used by the flat 2D slice rasterizer, where a single + // per-pixel sample should paint a solid heatmap rather than fade by opacity. + array_t lut_opaque; int n_lut { 256 }; real_t vmin { ZERO }; real_t vmax { ONE }; @@ -69,7 +73,7 @@ namespace out { * @brief One rendered scalar field -> one PNG stream. */ struct Scene { - std::string field; // "N" | "Bmag" | "Jmag" | "smooth_xyz" + std::string field; // "N" | "Bmag" | "Vmag" | "Txy" | "B1" ... std::string prefix; // PNG filename prefix, e.g. "Bmag_" std::string label; // colorbar title (defaults to field) std::vector ticks; // explicit colorbar tick values (optional) @@ -160,6 +164,13 @@ namespace out { return m_camera_dev; } + // 2D slice mode: mirror a spherical half-plane across the axis into a full + // disk (no effect on Cartesian or 3D rendering). + [[nodiscard]] + auto mirror() const -> bool { + return m_mirror; + } + [[nodiscard]] auto scenes() const -> const std::vector& { return m_scenes; @@ -182,6 +193,9 @@ namespace out { // draw the colorbar in an extended right margin (outside the render region) // rather than overlaying it on top of the rendered volume bool m_colorbar_outside { true }; + // 2D slice mode (spherical): mirror the meridional half-plane across the + // symmetry axis to render a full disk from one axisymmetric half + bool m_mirror { true }; CameraDevice m_camera_dev; std::vector m_scenes; diff --git a/src/output/render/slice2d.hpp b/src/output/render/slice2d.hpp new file mode 100644 index 000000000..335aab0aa --- /dev/null +++ b/src/output/render/slice2d.hpp @@ -0,0 +1,210 @@ +/** + * @file output/render/slice2d.hpp + * @brief Header-only Kokkos 2D slice rasterizer (one parallel_for over pixels) + * @implements + * - kernel::SliceRaster_kernel + * @namespaces: + * - kernel:: + * @macros: + * - OUTPUT_ENABLED + * @note + * The 2D counterpart of the volume ray-march: a 2D simulation has no depth to + * integrate, so each screen pixel is a single inverse-mapped sample of the + * prepared scalar, painted opaque. Two coordinate families are handled at + * compile time via M::CoordType: + * - Cartesian (Minkowski 2D): the screen window IS the (x, y) physical plane; + * the inverse map is the per-axis code conversion. + * - Spherical / Qspherical (2D SR & all 2D GR): the screen window is the + * meridional (X, Z) Cartesian plane; a pixel maps to physical + * r = sqrt(X^2 + Z^2), theta = atan2(|X|, Z), then per-axis code conversion + * (separable once in physical spherical coords). With `mirror`, X<0 is the + * theta-reflected half, yielding a full disk from one axisymmetric half. + * + * Seamlessness: every rank shares the same global screen window, so a pixel's + * world point is identical on all ranks. Each pixel's active-region membership + * (code index in [0, n]) selects exactly one domain in the interior (boundary + * pixels may be claimed by two, but the halo-filled value is continuous there), + * so the disjoint sub-images composite without seams regardless of order. + */ + +#ifndef OUTPUT_RENDER_SLICE2D_HPP +#define OUTPUT_RENDER_SLICE2D_HPP + +#include "enums.h" +#include "global.h" + +#include "arch/kokkos_aliases.h" +#include "traits/metric.h" + +#include "output/render/renderer.h" + +namespace kernel { + using namespace ntt; + + template + class SliceRaster_kernel { + static constexpr auto D = M::Dim; + static_assert(D == Dim::_2D, "SliceRaster_kernel is 2D only"); + + randacc_ndfield_t Fld; + const idx_t comp; + const M metric; + + // global orthographic window in slice-plane world coords (shared by ranks) + const real_t umin, umax, vmin, vmax; + const int W, H; // full frame size (ray generation / ndc) + const int bx0, by0, bw; // screen-bbox offset and width (output stride) + const bool mirror; // spherical: paint the X<0 reflected half too + + // local-domain active cell counts and View extents (membership + clamping) + const real_t n1, n2; + const int ext0, ext1; + + // transfer function (opaque LUT: premultiplied with alpha == 1) + array_t lut; + const int n_lut; + const real_t vlo, vhi; + const bool log_scale; + + array_t image; // output, (bw*bh, 4) premultiplied RGBA + + public: + SliceRaster_kernel(const randacc_ndfield_t& Fld_, + idx_t comp_, + const M& metric_, + real_t umin_, + real_t umax_, + real_t vmin_, + real_t vmax_, + int W_, + int H_, + int bx0_, + int by0_, + int bw_, + bool mirror_, + int n1_, + int n2_, + int ext0_, + int ext1_, + const array_t& lut_, + int n_lut_, + real_t vlo_, + real_t vhi_, + bool log_scale_, + const array_t& image_) + : Fld { Fld_ } + , comp { comp_ } + , metric { metric_ } + , umin { umin_ } + , umax { umax_ } + , vmin { vmin_ } + , vmax { vmax_ } + , W { W_ } + , H { H_ } + , bx0 { bx0_ } + , by0 { by0_ } + , bw { bw_ } + , mirror { mirror_ } + , n1 { static_cast(n1_) } + , n2 { static_cast(n2_) } + , ext0 { ext0_ } + , ext1 { ext1_ } + , lut { lut_ } + , n_lut { n_lut_ } + , vlo { vlo_ } + , vhi { vhi_ } + , log_scale { log_scale_ } + , image { image_ } {} + + // bilinear sample of the prepared scalar at continuous code coords + // (cc1, cc2), reading the ghost halo for corners just outside the box. + Inline auto sample(real_t cc1, real_t cc2) const -> real_t { + const real_t g0 = cc1 - HALF; // cell-center continuous index + const real_t g1 = cc2 - HALF; + const real_t f0 = math::floor(g0); + const real_t f1 = math::floor(g1); + const real_t t0 = g0 - f0; + const real_t t1 = g1 - f1; + int b0 = static_cast(f0) + static_cast(N_GHOSTS); + int b1 = static_cast(f1) + static_cast(N_GHOSTS); + b0 = (b0 < 0) ? 0 : ((b0 > ext0 - 2) ? ext0 - 2 : b0); + b1 = (b1 < 0) ? 0 : ((b1 > ext1 - 2) ? ext1 - 2 : b1); + const real_t c00 = Fld(b0, b1, comp); + const real_t c10 = Fld(b0 + 1, b1, comp); + const real_t c01 = Fld(b0, b1 + 1, comp); + const real_t c11 = Fld(b0 + 1, b1 + 1, comp); + const real_t c0 = c00 * (ONE - t0) + c10 * t0; + const real_t c1 = c01 * (ONE - t0) + c11 * t0; + return c0 * (ONE - t1) + c1 * t1; + } + + Inline void operator()(cellidx_t lpx, cellidx_t lpy) const { + const auto pix = static_cast(lpy) * + static_cast(bw) + + static_cast(lpx); + const int gpx = bx0 + static_cast(lpx); + const int gpy = by0 + static_cast(lpy); + // default transparent + image(pix, 0) = ZERO; + image(pix, 1) = ZERO; + image(pix, 2) = ZERO; + image(pix, 3) = ZERO; + + // pixel center -> slice-plane world coords (v flipped so +v is up) + const real_t u = umin + (static_cast(gpx) + HALF) / + static_cast(W) * (umax - umin); + const real_t v = vmax - (static_cast(gpy) + HALF) / + static_cast(H) * (vmax - vmin); + + // world -> continuous local code coords + real_t cc1, cc2; + if constexpr (M::CoordType == Coord::Cartesian) { + cc1 = metric.template convert<1, Crd::Ph, Crd::Cd>(u); + cc2 = metric.template convert<2, Crd::Ph, Crd::Cd>(v); + } else { + if (not mirror and u < ZERO) { + return; // only the X>=0 meridional half is physical + } + const real_t r = math::sqrt(u * u + v * v); + const real_t th = math::atan2(math::abs(u), v); // in [0, pi] + cc1 = metric.template convert<1, Crd::Ph, Crd::Cd>(r); + cc2 = metric.template convert<2, Crd::Ph, Crd::Cd>(th); + } + // active-region membership (inclusive so interiors gap-free, boundaries + // shared harmlessly); outside -> leave transparent + if (cc1 < ZERO or cc1 > n1 or cc2 < ZERO or cc2 > n2) { + return; + } + + const real_t s = sample(cc1, cc2); + // normalize through the transfer-function range + const real_t inv_range = (vhi > vlo) ? (ONE / (vhi - vlo)) : ZERO; + real_t uu; + if (log_scale) { + const real_t log_vlo = math::log10(vlo); + uu = (s > ZERO) ? (math::log10(s) - log_vlo) * inv_range : -ONE; + } else { + uu = (s - vlo) * inv_range; + } + if (uu < ZERO) { + uu = ZERO; + } else if (uu > ONE) { + uu = ONE; + } + int idx = static_cast(uu * static_cast(n_lut - 1) + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > n_lut - 1) { + idx = n_lut - 1; + } + // opaque LUT: premultiplied with alpha == 1, so this is straight RGB + image(pix, 0) = lut(idx, 0); + image(pix, 1) = lut(idx, 1); + image(pix, 2) = lut(idx, 2); + image(pix, 3) = ONE; + } + }; + +} // namespace kernel + +#endif // OUTPUT_RENDER_SLICE2D_HPP From 02cf11c042d823fbba1f37dae6ef8f8f499d0b45 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 17:24:13 -0400 Subject: [PATCH 11/20] rendering of axis spines, ticks and labels --- input.example.toml | 28 + src/framework/domain/metadomain_render.cpp | 59 ++ src/output/render/axes.h | 711 +++++++++++++++++++++ src/output/render/raymarch.hpp | 112 +++- src/output/render/renderer.cpp | 119 ++-- src/output/render/renderer.h | 80 +++ 6 files changed, 1045 insertions(+), 64 deletions(-) create mode 100644 src/output/render/axes.h diff --git a/input.example.toml b/input.example.toml index b4d5db216..527f5e9de 100644 --- a/input.example.toml +++ b/input.example.toml @@ -761,6 +761,34 @@ # @type: bool # @default: true mirror = "" + # Draw a spine (frame) + axis ticks + labels around the rendered region. + # The PNG gains left/bottom margins (background-filled) for the tick labels + # and axis names, so they never overlap the data. + # @type: bool + # @default: false + # @note: 2D Cartesian = a rectangular frame with linear spatial ticks; + # 2D spherical = polar axes (an "R" radial axis on the symmetry + # axis with R=0 centered, and a "Theta" axis along the curved + # outline / spine); + # 3D = the global box projected to a wireframe with ticks on the + # three silhouette edges (x bottom, y & z on the left) + axes = "" + # Axis names. 3D uses all three; the 2D slice uses the first two. When unset, + # the 2D slice defaults to "x","y" (Cartesian) or "X","Z" (spherical). + # @type: array [size <= 3] + # @default: ["x", "y", "z"] + axis_labels = "" + # Target number of ticks per axis (actual count is rounded to nice values) + # @type: int [>= 2] + # @default: 5 + axis_ticks = "" + # 3D only: target width (pixels) of the box wireframe "spine". The spine is + # drawn inside the ray-march (opaque, depth-occluded by the volume); its + # width is floored by the ray step, so for a crisper thin line raise + # `samples` as well. + # @type: float [> 0.0] + # @default: 2.0 + spine_width = "" # Camera (3D volume mode only; ignored by the 2D slice rasterizer). Defaults # frame the whole global box from outside, looking down the (1,1,1) diagonal diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index c45ba7f23..f9320a094 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -463,6 +463,28 @@ namespace ntt { : gdiag / static_cast(g_renderer.samples()); const int max_steps = 2 * g_renderer.samples() + 16; + // global box + depth-occluded spine (opaque box wireframe rendered inline + // in the march so the volume covers its far edges). The visual width is + // ~spine_width px; the 0.55*ds floor keeps the thin line gap-free at the + // current sampling (raise `samples` for a crisper, thinner line). + real_t glo[3] = { glob_ext[0].first, glob_ext[1].first, + glob_ext[2].first }; + real_t ghi[3] = { glob_ext[0].second, glob_ext[1].second, + glob_ext[2].second }; + const real_t px_w = (cam.half_h * static_cast(2)) / + static_cast(H); + const real_t spine_radius = + g_renderer.axes() + ? math::max(static_cast(0.55) * ds, + HALF * g_renderer.spineWidth() * px_w) + : ZERO; + // contrasting opaque spine color (white on dark bg, black on light) + const real_t bg_lum = static_cast(0.299) * g_renderer.background(0) + + static_cast(0.587) * g_renderer.background(1) + + static_cast(0.114) * g_renderer.background(2); + const real_t sc = (bg_lum < HALF) ? ONE : ZERO; + const real_t spine_rgb[3] = { sc, sc, sc }; + // composite order key (depends on the current decomposition offsets) const uint64_t order_key = out::compositeOrderKey( local_domain->offset_ndomains(), @@ -529,6 +551,10 @@ namespace ntt { scene.tf.vmax, scene.tf.log_scale, g_renderer.earlyAlpha(), + glo, + ghi, + spine_radius, + spine_rgb, image)); Kokkos::fence(); @@ -604,6 +630,39 @@ namespace ntt { vmax = cv + hv; } } + // spherical slices get a background border so the round outline and its + // R/theta labels are not clipped at the frame edges (Cartesian fills the + // frame and draws its ticks in dedicated margins, so it needs none). + if constexpr (M::CoordType != Coord::type::Cartesian) { + const real_t pad = static_cast(1.12); + const real_t cu = HALF * (umin + umax), hu = HALF * (umax - umin) * pad; + const real_t cv = HALF * (vmin + vmax), hv = HALF * (vmax - vmin) * pad; + umin = cu - hu; + umax = cu + hu; + vmin = cv - hv; + vmax = cv + hv; + } + + // hand the world window + axis names to the (host) axes overlay. Default + // names follow the coordinate family unless the toml set axis_labels. + { + const bool sph = (M::CoordType != Coord::type::Cartesian); + const std::string xl = g_renderer.axisLabelsSet() + ? g_renderer.axisLabel(0) + : (sph ? std::string("X") : std::string("x")); + const std::string yl = g_renderer.axisLabelsSet() + ? g_renderer.axisLabel(1) + : (sph ? std::string("Z") : std::string("y")); + g_renderer.setSliceFrame(umin, umax, vmin, vmax, xl, yl); + // curvilinear slices get polar axes (R radial + Theta arc); pass the + // global (r, theta) extent. + if (sph) { + g_renderer.setSlicePolar(true, gext[0].first, gext[0].second, + gext[1].first, gext[1].second, mirror); + } else { + g_renderer.setSlicePolar(false, ZERO, ONE, ZERO, ONE, mirror); + } + } auto& bckp = local_domain->fields.bckp; const int ext0 = static_cast(bckp.extent(0)); diff --git a/src/output/render/axes.h b/src/output/render/axes.h new file mode 100644 index 000000000..9807ec55b --- /dev/null +++ b/src/output/render/axes.h @@ -0,0 +1,711 @@ +/** + * @file output/render/axes.h + * @brief Draw a spine (frame), axis ticks and labels onto the opaque RGBA + * canvas, for both the 2D slice and the 3D volume renders. + * @implements + * - out::axesMargins + * - out::drawAxes2D + * - out::drawAxes3D + * @namespaces: + * - out:: + * @note + * Header-only, host-only, drawn on the MPI root rank after compositing (like the + * colorbar). Reuses the 5x7 bitmap font and helpers from colorbar.h. + * - 2D: the data region maps affinely to a world window [u0,u1]x[v0,v1]; a + * rectangular spine is drawn around it with linear ticks/labels in the + * surrounding margins (so they never overlap the data). + * - 3D: the global box is projected with the ray-march camera into a wireframe + * "spine"; ticks + labels are placed along the three edges emanating from the + * bottom-most projected corner, marks pushed outward from the box centroid. + */ + +#ifndef OUTPUT_RENDER_AXES_H +#define OUTPUT_RENDER_AXES_H + +#include "global.h" + +#include "output/render/colorbar.h" // glyph, scale, fmtNum +#include "output/render/composite.h" // projectToScreen, CameraDevice + +#include +#include +#include +#include +#include +#include + +namespace out { + + namespace axes_hidden { + + // set one opaque pixel, clipped to the full canvas [0,CW)x[0,CH) + inline void px(uint8_t* b, int CW, int CH, int x, int y, uint8_t c) { + if (x < 0 or x >= CW or y < 0 or y >= CH) { + return; + } + const std::size_t i = (static_cast(y) * CW + x) * 4; + b[i + 0] = c; + b[i + 1] = c; + b[i + 2] = c; + b[i + 3] = 255; + } + + inline void thickPx(uint8_t* b, int CW, int CH, int x, int y, int t, uint8_t c) { + for (int dy = -t; dy <= t; ++dy) { + for (int dx = -t; dx <= t; ++dx) { + px(b, CW, CH, x + dx, y + dy, c); + } + } + } + + // Bresenham line, thickness (2t+1) + inline void line(uint8_t* b, int CW, int CH, int x0, int y0, int x1, int y1, + int t, uint8_t c) { + int dx = std::abs(x1 - x0), sx = (x0 < x1) ? 1 : -1; + int dy = -std::abs(y1 - y0), sy = (y0 < y1) ? 1 : -1; + int err = dx + dy; + while (true) { + thickPx(b, CW, CH, x0, y0, t, c); + if (x0 == x1 and y0 == y1) { + break; + } + const int e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } + + inline void text(uint8_t* b, int CW, int CH, int x, int y, + const std::string& str, int s, uint8_t c) { + int cx = x; + for (const char ch : str) { + const uint8_t* gl = cbar_hidden::glyph(ch); + for (int row = 0; row < 7; ++row) { + for (int col = 0; col < 5; ++col) { + if (gl[row] & (1u << (4 - col))) { + for (int dy = 0; dy < s; ++dy) { + for (int dx = 0; dx < s; ++dx) { + px(b, CW, CH, cx + col * s + dx, y + row * s + dy, c); + } + } + } + } + } + cx += 6 * s; + } + } + + // Rotated bitmap text: the baseline advances along unit (ax, ay); (ox, oy) + // is the text-local origin (top-left of the first glyph). Each glyph cell is + // oversampled 2x so rotation leaves no gaps. + inline void textRot(uint8_t* b, int CW, int CH, real_t ox, real_t oy, + const std::string& str, int s, real_t ax, real_t ay, + uint8_t c) { + const real_t dnx = -ay, dny = ax; // glyph "down" (perp. to baseline) + for (std::size_t ci = 0; ci < str.size(); ++ci) { + const uint8_t* gl = cbar_hidden::glyph(str[ci]); + const real_t base = static_cast(ci) * 6 * s; + for (int row = 0; row < 7; ++row) { + for (int col = 0; col < 5; ++col) { + if (not(gl[row] & (1u << (4 - col)))) { + continue; + } + for (int sy = 0; sy < 2 * s; ++sy) { + for (int sx = 0; sx < 2 * s; ++sx) { + const real_t u = base + col * s + static_cast(sx) * HALF; + const real_t v = row * s + static_cast(sy) * HALF; + px(b, CW, CH, + static_cast(std::lround(ox + u * ax + v * dnx)), + static_cast(std::lround(oy + u * ay + v * dny)), c); + } + } + } + } + } + } + + // rotated text centered on (cxp, cyp), baseline along unit (ax, ay) + inline void textRotCentered(uint8_t* b, int CW, int CH, real_t cxp, + real_t cyp, const std::string& str, int s, + real_t ax, real_t ay, uint8_t c) { + const real_t dnx = -ay, dny = ax; + const real_t w = static_cast(str.size()) * 6 * s; + const real_t h = 7 * s; + textRot(b, CW, CH, cxp - HALF * w * ax - HALF * h * dnx, + cyp - HALF * w * ay - HALF * h * dny, str, s, ax, ay, c); + } + + // orient a screen-space edge direction so text reads naturally (rightward + // for near-horizontal edges, upward for near-vertical ones) + inline void readableDir(real_t& ax, real_t& ay) { + const real_t n = std::sqrt(static_cast(ax * ax + ay * ay)); + if (n < static_cast(1e-9)) { + ax = ONE; + ay = ZERO; + return; + } + ax /= n; + ay /= n; + if (std::fabs(static_cast(ax)) >= std::fabs(static_cast(ay))) { + if (ax < ZERO) { + ax = -ax; + ay = -ay; + } + } else if (ay > ZERO) { + ax = -ax; + ay = -ay; + } + } + + // vertical stack of characters (top to bottom), used for the y-axis name + inline void textVert(uint8_t* b, int CW, int CH, int x, int y, + const std::string& str, int s, uint8_t c) { + int cy = y; + for (const char ch : str) { + const std::string one(1, ch); + text(b, CW, CH, x, cy, one, s, c); + cy += 8 * s; + } + } + + inline auto textW(const std::string& str, int s) -> int { + return static_cast(str.size()) * 6 * s; + } + + inline auto contrast(const real_t bg[3]) -> uint8_t { + const real_t lum = static_cast(0.299) * bg[0] + + static_cast(0.587) * bg[1] + + static_cast(0.114) * bg[2]; + return (lum < HALF) ? 255 : 0; + } + + // a "nice" number close to x (1/2/5 x 10^k) + inline auto niceNum(real_t x, bool round) -> real_t { + if (x <= ZERO) { + return ONE; + } + const real_t e = std::floor(std::log10(static_cast(x))); + const real_t f = x / static_cast(std::pow(10.0, e)); + real_t nf; + if (round) { + nf = (f < static_cast(1.5)) + ? ONE + : ((f < static_cast(3)) ? static_cast(2) + : (f < static_cast(7)) + ? static_cast(5) + : static_cast(10)); + } else { + nf = (f <= ONE) ? ONE + : (f <= static_cast(2)) + ? static_cast(2) + : (f <= static_cast(5)) ? static_cast(5) + : static_cast(10); + } + return nf * static_cast(std::pow(10.0, e)); + } + + // a tick at k*pi/D, carried with its reduced fraction k/D == n/d + struct PiTick { + real_t val; + int n, d; + }; + + // format a multiple of pi as "0", "PI", "PI", "PI/", "PI/" + // (the bitmap font has no greek glyph, so "PI" is spelled out) + inline auto fmtPi(int n, int d) -> std::string { + if (n == 0) { + return "0"; + } + std::string s; + if (n < 0) { + s += "-"; + n = -n; + } + if (n != 1) { + s += std::to_string(n); + } + s += "PI"; + if (d != 1) { + s += "/" + std::to_string(d); + } + return s; + } + + // ticks at nice fractions of pi spanning [lo, hi] + inline auto piTicks(real_t lo, real_t hi, int nticks) -> std::vector { + std::vector out; + const real_t PI = static_cast(constant::PI); + const real_t range = hi - lo; + if (not(range > ZERO) or nticks < 2) { + return out; + } + const real_t ideal = static_cast(nticks - 1) * PI / range; + const int Ds[] = { 1, 2, 3, 4, 6, 8, 12, 16, 24 }; + int D = 4; + real_t bestd = static_cast(1e30); + for (const int dd : Ds) { + const real_t df = std::fabs(static_cast(dd) - ideal); + if (df < bestd) { + bestd = df; + D = dd; + } + } + const real_t step = PI / static_cast(D); + const int k0 = static_cast(std::ceil(static_cast(lo / step) - + 1e-6)); + const int k1 = static_cast( + std::floor(static_cast(hi / step) + 1e-6)); + for (int k = k0; k <= k1; ++k) { + int n = k, d = D; + const int g = std::gcd(std::abs(n), d); + if (g > 0) { + n /= g; + d /= g; + } + out.push_back({ static_cast(k) * step, n, d }); + } + return out; + } + + inline auto niceTicks(real_t lo, real_t hi, int n) -> std::vector { + std::vector out; + if (not(hi > lo) or n < 2) { + return out; + } + const real_t step = niceNum((hi - lo) / static_cast(n - 1), true); + if (step <= ZERO) { + return out; + } + const real_t g0 = std::ceil(static_cast(lo / step)) * step; + const real_t eps = static_cast(1e-6) * step; + for (real_t v = g0; v <= hi + static_cast(0.5) * step; v += step) { + if (v >= lo - eps and v <= hi + eps) { + out.push_back((std::fabs(static_cast(v)) < eps) ? ZERO : v); + } + } + return out; + } + + } // namespace axes_hidden + + /** + * @brief Left/bottom margin (pixels) that the axes annotation needs. + * @note Zero when axes are disabled. Depends only on H, so it can size the + * canvas before drawing. + */ + inline void axesMargins(bool axes, int H, int& ml, int& mb) { + if (not axes) { + ml = 0; + mb = 0; + return; + } + const int s = cbar_hidden::scale(H); + const int cw = 6 * s; + const int ch = 8 * s; + const int tl = 5 * s; + const int gap = 2 * s; + ml = tl + gap + 7 * cw + gap + cw + gap; // ticks + numbers + y-axis name + mb = tl + gap + ch + gap + ch + gap; // ticks + numbers + x-axis name + } + + /** + * @brief Draw a 2D spine + ticks + labels around the data region. + * @param rgba canvas (CW*CH*4), opaque + * @param CW,CH canvas dimensions (includes margins) + * @param x0 left pixel of the data region (== left margin width) + * @param W,H data region dimensions + * @param u0,u1,v0,v1 world window mapped onto the data region (+v is up) + * @param xlabel,ylabel axis names + * @param bg background RGB (for contrasting text color) + * @param nticks target number of ticks per axis + */ + inline void drawAxes2D(uint8_t* rgba, + int CW, + int CH, + int x0, + int W, + int H, + real_t u0, + real_t u1, + real_t v0, + real_t v1, + const std::string& xlabel, + const std::string& ylabel, + const real_t bg[3], + int nticks) { + using namespace axes_hidden; + const uint8_t c = contrast(bg); + const int s = cbar_hidden::scale(H); + const int ch = 8 * s; + const int tl = 5 * s; + const int gap = 2 * s; + const int th = std::max(0, s / 2 - 1); // spine half-thickness + + const int xL = x0, xR = x0 + W - 1, yT = 0, yB = H - 1; + // spine + line(rgba, CW, CH, xL, yT, xR, yT, th, c); + line(rgba, CW, CH, xL, yB, xR, yB, th, c); + line(rgba, CW, CH, xL, yT, xL, yB, th, c); + line(rgba, CW, CH, xR, yT, xR, yB, th, c); + + auto X = [&](real_t u) -> int { + return static_cast(std::lround( + static_cast(xL) + + static_cast((u - u0) / (u1 - u0)) * (xR - xL))); + }; + auto Y = [&](real_t v) -> int { + return static_cast(std::lround( + static_cast(yB) - + static_cast((v - v0) / (v1 - v0)) * (yB - yT))); + }; + + // x ticks (bottom): marks + labels below the spine + for (const real_t tv : niceTicks(u0, u1, nticks)) { + const int x = X(tv); + if (x < xL or x > xR) { + continue; + } + line(rgba, CW, CH, x, yB, x, yB + tl, 0, c); + const std::string lab = cbar_hidden::fmtNum(tv); + text(rgba, CW, CH, x - textW(lab, s) / 2, yB + tl + gap, lab, s, c); + } + // y ticks (left): marks + right-aligned labels left of the spine + for (const real_t tv : niceTicks(v0, v1, nticks)) { + const int y = Y(tv); + if (y < yT or y > yB) { + continue; + } + line(rgba, CW, CH, xL, y, xL - tl, y, 0, c); + const std::string lab = cbar_hidden::fmtNum(tv); + text(rgba, CW, CH, xL - tl - gap - textW(lab, s), y - ch / 2, lab, s, c); + } + // axis names + if (not xlabel.empty()) { + text(rgba, CW, CH, xL + W / 2 - textW(xlabel, s) / 2, + yB + tl + gap + ch + gap, xlabel, s, c); + } + if (not ylabel.empty()) { + textVert(rgba, CW, CH, std::max(gap, x0 - tl - gap - 7 * (6 * s) - gap - 6 * s), + (yT + yB) / 2 - 4 * s * static_cast(ylabel.size()) / 2, + ylabel, s, c); + } + } + + /** + * @brief Draw polar (curvilinear) axes for a 2D spherical meridional slice. + * @param x0,W,H data region (the slice maps world (X = r sin th, Z = r cos th) + * onto it via the [u0,u1]x[v0,v1] window, aspect-matched) + * @param rmin,rmax,tmin,tmax global (r, theta) extent + * @param mirror whether the half-plane is mirrored into a full disk + * @param rlabel,tlabel names for the radial / angular axes (e.g. "R","Theta") + * @note Draws (1) a curvilinear spine: the outer & inner arcs plus the two + * radial edges (or full arcs when mirrored); (2) an "R" radial axis along the + * symmetry axis (X=0) with R=0 at the center, increasing outward; (3) a + * "Theta" angular axis with ticks along the outer arc. + */ + inline void drawAxesPolar(uint8_t* rgba, + int CW, + int CH, + int x0, + int W, + int H, + real_t u0, + real_t u1, + real_t v0, + real_t v1, + real_t rmin, + real_t rmax, + real_t tmin, + real_t tmax, + bool mirror, + const std::string& rlabel, + const std::string& tlabel, + const real_t bg[3], + int nticks) { + using namespace axes_hidden; + const uint8_t c = contrast(bg); + const int s = cbar_hidden::scale(H); + const int ch = 8 * s; + const int tl = 5 * s; + const int gap = 2 * s; + + auto WX = [&](real_t X) -> real_t { + return static_cast(x0) + (X - u0) / (u1 - u0) * W - HALF; + }; + auto WZ = [&](real_t Z) -> real_t { + return (v1 - Z) / (v1 - v0) * H - HALF; + }; + auto PX = [&](real_t X, real_t Z, int& qx, int& qy) { + qx = static_cast(std::lround(WX(X))); + qy = static_cast(std::lround(WZ(Z))); + }; + + const int NA = 160; + auto arc = [&](real_t r, real_t sgn) { + int qx, qy; + PX(sgn * r * std::sin(static_cast(tmin)), + r * std::cos(static_cast(tmin)), qx, qy); + for (int i = 1; i <= NA; ++i) { + const real_t th = tmin + (tmax - tmin) * i / NA; + int rx, ry; + PX(sgn * r * std::sin(static_cast(th)), + r * std::cos(static_cast(th)), rx, ry); + line(rgba, CW, CH, qx, qy, rx, ry, 0, c); + qx = rx; + qy = ry; + } + }; + auto ray = [&](real_t th) { + int ax, ay, bx, by; + PX(rmin * std::sin(static_cast(th)), + rmin * std::cos(static_cast(th)), ax, ay); + PX(rmax * std::sin(static_cast(th)), + rmax * std::cos(static_cast(th)), bx, by); + line(rgba, CW, CH, ax, ay, bx, by, 0, c); + }; + + // ---- curvilinear spine -------------------------------------------- // + arc(rmax, ONE); + arc(rmin, ONE); + if (mirror) { + arc(rmax, -ONE); + arc(rmin, -ONE); + } else { + ray(tmin); + ray(tmax); + } + + // ---- R axis: along the symmetry axis (X = 0), zero at the center --- // + const int rmaxlabW = textW(cbar_hidden::fmtNum(rmax), s); + for (const real_t Rv : niceTicks(ZERO, rmax, nticks)) { + for (int sg = 1; sg >= -1; sg -= 2) { + if (sg < 0 and Rv == ZERO) { + continue; // a single tick at the center + } + int ax, ay; + PX(ZERO, static_cast(sg) * Rv, ax, ay); + line(rgba, CW, CH, ax, ay, ax - tl, ay, 0, c); + const std::string lab = cbar_hidden::fmtNum(Rv); + text(rgba, CW, CH, ax - tl - gap - textW(lab, s), ay - ch / 2, lab, s, c); + } + } + if (not rlabel.empty()) { + int ax, ay; + PX(ZERO, ZERO, ax, ay); + const real_t off = tl + gap + rmaxlabW + gap + ch; + textRotCentered(rgba, CW, CH, ax - off, static_cast(ay), rlabel, s, + ZERO, -ONE, c); + } + + // ---- Theta axis: ticks + labels (fractions of pi) along the arc --- // + // widest tick label, so the "Theta" name can clear them all + real_t maxlw = static_cast(ch); + for (const auto& tk : piTicks(tmin, tmax, nticks)) { + maxlw = std::max(maxlw, + static_cast(textW(fmtPi(tk.n, tk.d), s))); + } + for (const auto& tk : piTicks(tmin, tmax, nticks)) { + const real_t ox = std::sin(static_cast(tk.val)); + const real_t oz = std::cos(static_cast(tk.val)); + int px0, py0; + PX(rmax * ox, rmax * oz, px0, py0); + const real_t dxp = ox, dyp = -oz; // outward pixel direction + line(rgba, CW, CH, px0, py0, + static_cast(std::lround(px0 + dxp * tl)), + static_cast(std::lround(py0 + dyp * tl)), 0, c); + const std::string lab = fmtPi(tk.n, tk.d); + // push the (horizontal) label box fully clear of the arc/tick at any + // angle: offset its center by its own support along the outward direction + const real_t inset = HALF * (static_cast(textW(lab, s)) * + std::fabs(static_cast(dxp)) + + static_cast(ch) * + std::fabs(static_cast(dyp))); + const real_t lo = tl + gap + inset; + text(rgba, CW, CH, + static_cast(std::lround(px0 + dxp * lo)) - textW(lab, s) / 2, + static_cast(std::lround(py0 + dyp * lo)) - ch / 2, lab, s, c); + } + if (not tlabel.empty()) { + const real_t tm = HALF * (tmin + tmax); + const real_t ox = std::sin(static_cast(tm)); + const real_t oz = std::cos(static_cast(tm)); + int px0, py0; + PX(rmax * ox, rmax * oz, px0, py0); + real_t adx = std::cos(static_cast(tm)); // arc tangent + real_t ady = std::sin(static_cast(tm)); + readableDir(adx, ady); + // beyond the tick labels (which reach ~tl+gap+maxlw from the arc) + const real_t off = tl + gap + maxlw + gap + ch; + textRotCentered(rgba, CW, CH, px0 + ox * off, py0 - oz * off, tlabel, s, + adx, ady, c); + } + } + + /** + * @brief Draw a 3D bounding-box wireframe spine + ticks + labels. + * @param rgba canvas (CW*CH*4), opaque + * @param CW,CH canvas dimensions + * @param x0 left pixel of the data region (left margin width) + * @param W,H data region dimensions (used for the camera projection) + * @param cam ray-march camera (inverted to project world -> screen) + * @param ext global box extent (3 axes) + * @param lab axis names [x, y, z] + * @param bg background RGB + * @param nticks target number of ticks per axis + */ + inline void drawAxes3D(uint8_t* rgba, + int CW, + int CH, + int x0, + int W, + int H, + const CameraDevice& cam, + const boundaries_t& ext, + const std::string lab[3], + const real_t bg[3], + int nticks) { + using namespace axes_hidden; + if (ext.size() < 3) { + return; + } + const uint8_t c = contrast(bg); + const int s = cbar_hidden::scale(H); + const int ch = 8 * s; + const int tl = 5 * s; + + auto corner = [&](int m, real_t p[3]) { + p[0] = (m & 1) ? ext[0].second : ext[0].first; + p[1] = (m & 2) ? ext[1].second : ext[1].first; + p[2] = (m & 4) ? ext[2].second : ext[2].first; + }; + real_t cx[8], cy[8]; + bool ok[8]; + for (int m = 0; m < 8; ++m) { + real_t p[3]; + corner(m, p); + real_t a, b; + ok[m] = projectToScreen(cam, W, H, p, a, b); + cx[m] = a + static_cast(x0); + cy[m] = b; + } + // box centroid (for outward tick/label direction) + real_t cen[3] = { HALF * (ext[0].first + ext[0].second), + HALF * (ext[1].first + ext[1].second), + HALF * (ext[2].first + ext[2].second) }; + real_t ccx = ZERO, ccy = ZERO; + { + real_t a, b; + projectToScreen(cam, W, H, cen, a, b); + ccx = a + static_cast(x0); + ccy = b; + } + + (void)ok; + // The wireframe "spine" is drawn in the ray-march (depth-occluded), so here + // we only annotate. For each axis, pick one *silhouette* edge (its two + // adjacent faces face opposite ways) and, among the two candidates, the one + // whose screen position matches the convention x=bottom, y & z on the left + // of the default diagonal view. + auto frontFace = [&](int axis, int side) -> bool { + const real_t nrm = (side != 0) ? ONE : -ONE; // outward normal sign + return (nrm * (-cam.forward[axis])) > ZERO; // points toward the camera? + }; + for (int d = 0; d < 3; ++d) { + const int e1 = (d == 0) ? 1 : 0; + const int e2 = (d == 2) ? 1 : 2; + int bs1 = 0, bs2 = 0; + bool found = false; + real_t best = ZERO; + for (int s1 = 0; s1 < 2; ++s1) { + for (int s2 = 0; s2 < 2; ++s2) { + const int m0 = (s1 << e1) | (s2 << e2); + const int m1 = m0 | (1 << d); + const real_t mx = HALF * (cx[m0] + cx[m1]); + const real_t my = HALF * (cy[m0] + cy[m1]); + // screen-position convention: x on the bottom, y & z on the left edges + real_t score = (d == 0) ? my : -mx; + if (frontFace(e1, s1) != frontFace(e2, s2)) { + score += static_cast(1e6); // strongly prefer silhouette edges + } + if (not found or score > best) { + best = score; + bs1 = s1; + bs2 = s2; + found = true; + } + } + } + const int m0 = (bs1 << e1) | (bs2 << e2); + const int m1 = m0 | (1 << d); + real_t o[3]; + corner(m0, o); // perpendicular coords fixed; axis d swept for ticks + const real_t lo = ext[d].first, hi = ext[d].second; + // screen-space perpendicular to the edge, flipped to point outward + real_t ex = cx[m1] - cx[m0], ey = cy[m1] - cy[m0]; + real_t el = std::sqrt(static_cast(ex * ex + ey * ey)); + if (el < ONE) { + el = ONE; + } + ex /= el; + ey /= el; + real_t pxd = -ey, pyd = ex; + { + const real_t mxv = HALF * (cx[m0] + cx[m1]) - ccx; + const real_t myv = HALF * (cy[m0] + cy[m1]) - ccy; + if (pxd * mxv + pyd * myv < ZERO) { + pxd = -pxd; + pyd = -pyd; + } + } + // readable text baseline aligned with the edge direction + real_t adx = ex, ady = ey; + readableDir(adx, ady); + const real_t numOff = static_cast(tl) + 5 * s; // number center + const real_t nameOff = static_cast(tl) + 14 * s; // axis-name center + // ticks + numeric labels (numbers rotated along the edge for x & y; the + // vertical z edge keeps horizontal numbers, which read more easily) + for (const real_t tv : niceTicks(lo, hi, nticks)) { + real_t p[3] = { o[0], o[1], o[2] }; + p[d] = tv; + real_t a, b; + if (not projectToScreen(cam, W, H, p, a, b)) { + continue; + } + a += static_cast(x0); + const int mx = static_cast(std::lround(a + pxd * tl)); + const int my = static_cast(std::lround(b + pyd * tl)); + line(rgba, CW, CH, static_cast(std::lround(a)), + static_cast(std::lround(b)), mx, my, 0, c); + const std::string l2 = cbar_hidden::fmtNum(tv); + if (d == 2) { + const int tx = (pxd < ZERO) ? (mx - textW(l2, s)) : mx; + text(rgba, CW, CH, tx, my - ch / 2, l2, s, c); + } else { + textRotCentered(rgba, CW, CH, a + pxd * numOff, b + pyd * numOff, l2, + s, adx, ady, c); + } + } + // axis name at the MIDDLE of the edge (near the central tick), aligned + // with the edge and pushed further outward than the numbers + if (not lab[d].empty()) { + real_t mid[3] = { o[0], o[1], o[2] }; + mid[d] = HALF * (lo + hi); + real_t a, b; + if (projectToScreen(cam, W, H, mid, a, b)) { + a += static_cast(x0); + textRotCentered(rgba, CW, CH, a + pxd * nameOff, b + pyd * nameOff, + lab[d], s, adx, ady, c); + } + } + } + } + +} // namespace out + +#endif // OUTPUT_RENDER_AXES_H diff --git a/src/output/render/raymarch.hpp b/src/output/render/raymarch.hpp index f10702b47..e0db322f3 100644 --- a/src/output/render/raymarch.hpp +++ b/src/output/render/raymarch.hpp @@ -61,6 +61,12 @@ namespace kernel { const bool log_scale; const real_t early_alpha; + // global box wireframe "spine": opaque (alpha 1) segments composited inline + // during the march, so the volume occludes the far edges. radius <= 0 off. + const real_t glo0, glo1, glo2, ghi0, ghi1, ghi2; + const real_t spine_radius; + const real_t spine_cr, spine_cg, spine_cb; + array_t image; // output, (bw*bh, 4) premultiplied RGBA public: @@ -86,6 +92,10 @@ namespace kernel { real_t vmax_, bool log_scale_, real_t early_alpha_, + const real_t glo[3], + const real_t ghi[3], + real_t spine_radius_, + const real_t spine_rgb[3], const array_t& image_) : Fld { Fld_ } , comp { comp_ } @@ -113,8 +123,54 @@ namespace kernel { , vmax { vmax_ } , log_scale { log_scale_ } , early_alpha { early_alpha_ } + , glo0 { glo[0] } + , glo1 { glo[1] } + , glo2 { glo[2] } + , ghi0 { ghi[0] } + , ghi1 { ghi[1] } + , ghi2 { ghi[2] } + , spine_radius { spine_radius_ } + , spine_cr { spine_rgb[0] } + , spine_cg { spine_rgb[1] } + , spine_cb { spine_rgb[2] } , image { image_ } {} + // distance test: is world point (px,py,pz) within `spine_radius` of any of + // the 12 global-box edges? (nearest parallel edge per axis == nearest + // perpendicular-plane corner) + Inline auto onSpine(real_t px, real_t py, real_t pz) const -> bool { + if (spine_radius <= ZERO) { + return false; + } + const real_t r2 = spine_radius * spine_radius; + const real_t pad = spine_radius; + // edges parallel to x (perp plane = y,z) + if (px >= glo0 - pad and px <= ghi0 + pad) { + const real_t dy = math::min(math::abs(py - glo1), math::abs(py - ghi1)); + const real_t dz = math::min(math::abs(pz - glo2), math::abs(pz - ghi2)); + if (dy * dy + dz * dz < r2) { + return true; + } + } + // edges parallel to y (perp plane = x,z) + if (py >= glo1 - pad and py <= ghi1 + pad) { + const real_t dx = math::min(math::abs(px - glo0), math::abs(px - ghi0)); + const real_t dz = math::min(math::abs(pz - glo2), math::abs(pz - ghi2)); + if (dx * dx + dz * dz < r2) { + return true; + } + } + // edges parallel to z (perp plane = x,y) + if (pz >= glo2 - pad and pz <= ghi2 + pad) { + const real_t dx = math::min(math::abs(px - glo0), math::abs(px - ghi0)); + const real_t dy = math::min(math::abs(py - glo1), math::abs(py - ghi1)); + if (dx * dx + dy * dy < r2) { + return true; + } + } + return false; + } + // trilinear sample of the prepared scalar at world point p, reading the // ghost halo for corners just outside the active box. Inline auto sample(real_t px, real_t py, real_t pz) const -> real_t { @@ -261,31 +317,41 @@ namespace kernel { real_t acc_r = ZERO, acc_g = ZERO, acc_b = ZERO, acc_a = ZERO; int steps = 0; while (t < t_exit and steps < max_steps) { - const real_t s = sample(ox + t * dx, oy + t * dy, oz + t * dz); - // normalize through the transfer function range - real_t u; - if (log_scale) { - u = (s > ZERO) ? (math::log10(s) - log_vmin) * inv_range - : -ONE; + const real_t px = ox + t * dx, py = oy + t * dy, pz = oz + t * dz; + real_t cr, cg, cb, ca; + if (onSpine(px, py, pz)) { + // opaque box edge (premultiplied; alpha == 1 -> color is straight RGB). + // Composited inline so accumulated foreground volume occludes it. + cr = spine_cr; + cg = spine_cg; + cb = spine_cb; + ca = ONE; } else { - u = (s - vmin) * inv_range; - } - if (u < ZERO) { - u = ZERO; - } else if (u > ONE) { - u = ONE; - } - int idx = static_cast(u * static_cast(n_lut - 1) + HALF); - if (idx < 0) { - idx = 0; - } else if (idx > n_lut - 1) { - idx = n_lut - 1; + const real_t s = sample(px, py, pz); + // normalize through the transfer function range + real_t u; + if (log_scale) { + u = (s > ZERO) ? (math::log10(s) - log_vmin) * inv_range : -ONE; + } else { + u = (s - vmin) * inv_range; + } + if (u < ZERO) { + u = ZERO; + } else if (u > ONE) { + u = ONE; + } + int idx = static_cast(u * static_cast(n_lut - 1) + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > n_lut - 1) { + idx = n_lut - 1; + } + cr = lut(idx, 0); // premultiplied + cg = lut(idx, 1); + cb = lut(idx, 2); + ca = lut(idx, 3); } - const real_t cr = lut(idx, 0); // premultiplied - const real_t cg = lut(idx, 1); - const real_t cb = lut(idx, 2); - const real_t ca = lut(idx, 3); - const real_t w = ONE - acc_a; + const real_t w = ONE - acc_a; acc_r += w * cr; acc_g += w * cg; acc_b += w * cb; diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 128135895..ebda438f5 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -8,6 +8,7 @@ #include "utils/log.h" #include "utils/numeric.h" +#include "output/render/axes.h" #include "output/render/colorbar.h" #include "output/render/composite.h" #include "output/render/png.h" @@ -112,6 +113,24 @@ namespace out { // 2D slice mode (spherical only): mirror the half-plane into a full disk m_mirror = toml::find_or(td, "output", "render", "mirror", true); + // axes: spine + ticks + labels around the rendered region + m_axes = toml::find_or(td, "output", "render", "axes", false); + m_axis_nticks = toml::find_or(td, "output", "render", "axis_ticks", 5); + m_spine_width = toml::find_or(td, "output", "render", "spine_width", + static_cast(2)); + m_global_extent = global_extent; + { + const auto al = toml::find_or>( + td, "output", "render", "axis_labels", std::vector {}); + m_axis_labels_set = not al.empty(); + for (std::size_t d = 0; d < al.size() and d < 3; ++d) { + m_axis_labels[d] = al[d]; + } + // default 2D slice names track the labels (overridden per-metric by Render) + m_slice_xlabel = m_axis_labels[0]; + m_slice_ylabel = m_axis_labels[1]; + } + // cadence: mirror output.* (interval in steps; interval_time in sim time) const auto interval = toml::find_or(td, "output", @@ -313,28 +332,50 @@ namespace out { } // composite the premultiplied image over the opaque background: // out = src_premult + (1 - src_alpha) * background, alpha = opaque. - std::vector bytes(n); + std::vector data(n); for (std::size_t p = 0; p < npix; ++p) { const real_t a = img[p * 4 + 3]; const real_t inv = ONE - a; - bytes[p * 4 + 0] = quantize(img[p * 4 + 0] + inv * m_background[0]); - bytes[p * 4 + 1] = quantize(img[p * 4 + 1] + inv * m_background[1]); - bytes[p * 4 + 2] = quantize(img[p * 4 + 2] + inv * m_background[2]); - bytes[p * 4 + 3] = 255; + data[p * 4 + 0] = quantize(img[p * 4 + 0] + inv * m_background[0]); + data[p * 4 + 1] = quantize(img[p * 4 + 1] + inv * m_background[1]); + data[p * 4 + 2] = quantize(img[p * 4 + 2] + inv * m_background[2]); + data[p * 4 + 3] = 255; } const auto fname = dir / fmt::format("%s%08lu.png", scene.prefix.c_str(), static_cast(step)); + + auto drawBar = [&](uint8_t* buf, int bw, int bh) { + if (m_colorbar) { + drawColorbar(buf, bw, bh, scene.tf.colormap, scene.tf.vmin, + scene.tf.vmax, scene.tf.log_scale, scene.label, + m_background, scene.ticks); + } + }; + + // canvas margins: axes (left + bottom) and the colorbar strip (right). + // The data region sits at (ml, 0); margins/strip are background-filled. + // The polar (curvilinear) overlay annotates inside the data region (the + // disk is centered with background around it), so it needs no margins. + const bool polar = (m_global_extent.size() == 2) and m_slice_polar; + int ml = 0, mb = 0; + out::axesMargins(m_axes and not polar, m_height, ml, mb); + const int strip = (m_colorbar and m_colorbar_outside) + ? colorbarBlockWidth(m_height) + : 0; + const int CW = ml + m_width + strip; + const int CH = m_height + mb; + bool ok = true; - if (m_colorbar and m_colorbar_outside) { - // extend the canvas to the right so the colorbar sits in its own margin, - // outside the rendered volume. - const int strip = colorbarBlockWidth(m_height); - const int CW = m_width + strip; - const uint8_t bR = quantize(m_background[0]); - const uint8_t bG = quantize(m_background[1]); - const uint8_t bB = quantize(m_background[2]); - std::vector canvas(static_cast(CW) * m_height * 4); + if (CW == m_width and CH == m_height and not m_axes) { + // no margins, no outside strip, no overlay: colorbar overlays the data + drawBar(data.data(), m_width, m_height); + ok = write_png(fname, m_width, m_height, data.data()); + } else { + const uint8_t bR = quantize(m_background[0]); + const uint8_t bG = quantize(m_background[1]); + const uint8_t bB = quantize(m_background[2]); + std::vector canvas(static_cast(CW) * CH * 4); for (std::size_t i = 0; i < canvas.size(); i += 4) { canvas[i + 0] = bR; canvas[i + 1] = bG; @@ -342,35 +383,31 @@ namespace out { canvas[i + 3] = 255; } for (int y = 0; y < m_height; ++y) { - std::copy_n(&bytes[static_cast(y) * m_width * 4], - static_cast(m_width) * 4, - &canvas[static_cast(y) * CW * 4]); + std::copy_n( + &data[static_cast(y) * m_width * 4], + static_cast(m_width) * 4, + &canvas[(static_cast(y) * CW + ml) * 4]); } - drawColorbar(canvas.data(), - CW, - m_height, - scene.tf.colormap, - scene.tf.vmin, - scene.tf.vmax, - scene.tf.log_scale, - scene.label, - m_background, - scene.ticks); - ok = write_png(fname, CW, m_height, canvas.data()); - } else { - if (m_colorbar) { - drawColorbar(bytes.data(), - m_width, - m_height, - scene.tf.colormap, - scene.tf.vmin, - scene.tf.vmax, - scene.tf.log_scale, - scene.label, - m_background, - scene.ticks); + if (m_axes) { + if (m_global_extent.size() == 3) { + out::drawAxes3D(canvas.data(), CW, CH, ml, m_width, m_height, + m_camera_dev, m_global_extent, m_axis_labels, + m_background, m_axis_nticks); + } else if (polar) { + out::drawAxesPolar(canvas.data(), CW, CH, ml, m_width, m_height, + m_slice_win[0], m_slice_win[1], m_slice_win[2], + m_slice_win[3], m_slice_rmin, m_slice_rmax, + m_slice_tmin, m_slice_tmax, m_slice_pmirror, "R", + "Theta", m_background, m_axis_nticks); + } else { + out::drawAxes2D(canvas.data(), CW, CH, ml, m_width, m_height, + m_slice_win[0], m_slice_win[1], m_slice_win[2], + m_slice_win[3], m_slice_xlabel, m_slice_ylabel, + m_background, m_axis_nticks); + } } - ok = write_png(fname, m_width, m_height, bytes.data()); + drawBar(canvas.data(), CW, CH); + ok = write_png(fname, CW, CH, canvas.data()); } if (not ok) { raise::Warning( diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 15b64b624..15210f61e 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -171,6 +171,68 @@ namespace out { return m_mirror; } + [[nodiscard]] + auto axes() const -> bool { + return m_axes; + } + + [[nodiscard]] + auto background(int i) const -> real_t { + return m_background[(i < 0) ? 0 : ((i > 2) ? 2 : i)]; + } + + // target 3D spine line width in pixels + [[nodiscard]] + auto spineWidth() const -> real_t { + return m_spine_width; + } + + // whether `output.render.axis_labels` was set in the toml (so the 2D path + // honors it instead of substituting per-metric defaults) + [[nodiscard]] + auto axisLabelsSet() const -> bool { + return m_axis_labels_set; + } + + [[nodiscard]] + auto axisLabel(int d) const -> const std::string& { + return m_axis_labels[(d < 0) ? 0 : ((d > 2) ? 2 : d)]; + } + + // Set the world window + axis names the 2D slice path maps onto the image, + // so the (host) axes overlay can label spatial coordinates. Called by the + // templated 2D Render before compositing; the window is constant per run. + void setSliceFrame(real_t u0, + real_t u1, + real_t v0, + real_t v1, + const std::string& xlabel, + const std::string& ylabel) { + m_slice_win[0] = u0; + m_slice_win[1] = u1; + m_slice_win[2] = v0; + m_slice_win[3] = v1; + m_slice_xlabel = xlabel; + m_slice_ylabel = ylabel; + } + + // Mark the 2D slice as curvilinear (spherical) so the axes are drawn polar: + // a radial "R" axis on the symmetry axis + a "Theta" arc, with a curvilinear + // spine. Set per-frame by the templated 2D Render (constant per run). + void setSlicePolar(bool polar, + real_t rmin, + real_t rmax, + real_t tmin, + real_t tmax, + bool mir) { + m_slice_polar = polar; + m_slice_rmin = rmin; + m_slice_rmax = rmax; + m_slice_tmin = tmin; + m_slice_tmax = tmax; + m_slice_pmirror = mir; + } + [[nodiscard]] auto scenes() const -> const std::vector& { return m_scenes; @@ -196,6 +258,24 @@ namespace out { // 2D slice mode (spherical): mirror the meridional half-plane across the // symmetry axis to render a full disk from one axisymmetric half bool m_mirror { true }; + // draw a spine (frame) + axis ticks/labels around the rendered region + bool m_axes { false }; + bool m_axis_labels_set { false }; + int m_axis_nticks { 5 }; + real_t m_spine_width { static_cast(2) }; // 3D spine width (px) + std::string m_axis_labels[3] { "x", "y", "z" }; + // global world box (2 or 3 axes); used to project the 3D axes box and to + // know the render mode (size 2 => 2D slice, size 3 => 3D volume). + boundaries_t m_global_extent; + // 2D slice world window + axis names, set per-frame by the templated Render + real_t m_slice_win[4] { ZERO, ONE, ZERO, ONE }; + std::string m_slice_xlabel { "x" }; + std::string m_slice_ylabel { "y" }; + // 2D curvilinear (spherical) slice: draw polar axes instead of Cartesian + bool m_slice_polar { false }; + real_t m_slice_rmin { ZERO }, m_slice_rmax { ONE }; + real_t m_slice_tmin { ZERO }, m_slice_tmax { ONE }; + bool m_slice_pmirror { false }; CameraDevice m_camera_dev; std::vector m_scenes; From 5a0bffc6984234b2da64e9425663e674a8447921 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 19:12:46 -0400 Subject: [PATCH 12/20] 3D field lines --- input.example.toml | 77 ++++ src/framework/domain/metadomain_render.cpp | 210 +++++++++- src/output/render/fieldlines.h | 457 +++++++++++++++++++++ src/output/render/raymarch.hpp | 137 +++++- src/output/render/renderer.cpp | 51 +++ src/output/render/renderer.h | 64 ++- 6 files changed, 979 insertions(+), 17 deletions(-) create mode 100644 src/output/render/fieldlines.h diff --git a/input.example.toml b/input.example.toml index 527f5e9de..78ff1fc45 100644 --- a/input.example.toml +++ b/input.example.toml @@ -848,6 +848,8 @@ # "N_1", "Rho_2", "Txy_1_2", "V1_3"; default = all massive species # @note: components are signed; pair a symmetric `min`/`max` with a # diverging colormap ("cool2warm") to center zero + # @note: "fieldlines" renders the magnetic field-line tubes on their own + # (no scalar volume sampled); see [output.render.fieldlines] below field = "" # PNG filename prefix; files are `.png` # @type: string @@ -888,6 +890,81 @@ # @note: Values outside [min, max] are skipped # @example: [0.0, 0.5, 1.0] colorbar_ticks = "" + # Overlay the magnetic field-line tubes inside this scene's volume + # @type: bool + # @default: false + # @note: requires the [output.render.fieldlines] table below (3D only). + # A scene with field = "fieldlines" instead renders them alone. + fieldlines = "" + + # Magnetic field-line tubes (3D volume mode only). Traces field lines through + # a coarse, MPI-replicated copy of the field and draws them as solid tubes, + # colored by |field|, composited inside the same ray-march as the volume so + # they are correctly occluded by it. Built once per frame and shared by every + # scene that opts in (per-scene `fieldlines = true`) and by any standalone + # `field = "fieldlines"` scene. The coarsening is what makes the lines cheap + # to trace (no parallel particle advection) and gives a smoothed morphology. + [output.render.fieldlines] + # Build the field-line geometry this run + # @type: bool + # @default: false + # @note: implied true if any scene sets `fieldlines = true` or uses + # `field = "fieldlines"` + enable = "" + # Vector field to trace + # @type: string + # @enum: "B", "E", "J" + # @default: "B" + field = "" + # Field coarsening factor (simulation cells per coarse cell, per axis) + # @type: int [1..16] + # @default: 4 + # @note: larger = smoother "morphology" lines + cheaper replication + # (the coarse field is ~ N_cells / bin^3 x 3 floats, on every rank) + bin = "" + # Seed-lattice spacing in screen pixels (sets line density) + # @type: float [> 0] + # @default: 8 + # @note: capped by `seed_max`; if seed_px asks for more seeds than that, + # the spacing grows to fit and seed_px no longer governs + seed_px = "" + # Hard cap on the seed count (the seed lattice is n^3, 2 lines per seed) + # @type: int [> 0] + # @default: 4096 + # @note: lower this for fewer / more widely spaced lines + seed_max = "" + # Tube radius in screen pixels + # @type: float [> 0] + # @default: 2 + tube_px = "" + # Tube colormap (mapped by |field| along the line) + # @type: string + # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray" + # @default: "inferno" + colormap = "" + # Map the tube color range logarithmically + # @type: bool + # @default: false + # @note: requires min > 0 + log = "" + # Tube color range (lower / upper bound on |field|) + # @type: float + # @default: 0.0 + # @note: when min >= max, the range is auto-set from |field| along the lines + min = "" + max = "" + # RK4 integration step as a fraction of one coarse cell + # @type: float [> 0] + # @default: 0.5 + step_frac = "" + # Per-direction integration-step cap + # @type: int [> 0] + # @default: 4000 + max_steps = "" + # Maximum line length, in global box diagonals (per direction) + # @type: float [> 0] + # @default: 3.0 + max_length = "" [checkpoint] # Number of timesteps between checkpoints diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index f9320a094..da3127f53 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -36,6 +36,7 @@ #include "kernels/fields_to_phys.hpp" #include "kernels/particle_moments.hpp" #include "output/render/composite.h" +#include "output/render/fieldlines.h" #include "output/render/raymarch.hpp" #include "output/render/reduce.hpp" #include "output/render/slice2d.hpp" @@ -43,6 +44,12 @@ #include #include +#if defined(MPI_ENABLED) + #include "arch/mpi_aliases.h" + + #include +#endif + #include #include #include @@ -124,6 +131,120 @@ namespace ntt { } } + // Volume-average this domain's physical-basis vector field (B/E/J) onto a + // GLOBAL coarse grid, then MPI-replicate it so every rank holds the same + // field and can trace identical global field lines locally. `bckp` is used + // as scratch (overwritten). 3D only (the field-line renderer is Cartesian). + template + auto buildCoarseFieldVec(const Mesh& mesh, + const Fields& fields, + ndfield_t& bckp, + char fbase, + const real_t gorigin[3], + const int gnc[3], + const real_t gdx[3]) -> out::CoarseField { + const auto metric = mesh.metric; + // raw vector components -> bckp(0,1,2) + uint8_t src_base = em::bx1; + PrepareOutputFlags interp = PrepareOutput::InterpToCellCenterFromFaces; + bool is_current = false; + if (fbase == 'E') { + src_base = em::ex1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else if (fbase == 'J') { + is_current = true; + src_base = cur::jx1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } + if (is_current) { + copyVec3ToBckp(fields.cur, bckp, + cell_range_t(cur::jx1, cur::jx3 + 1)); + } else { + copyVec3ToBckp(fields.em, bckp, + cell_range_t(src_base, src_base + 3)); + } + // interpolate to cell centers + convert to physical basis -> bckp(3,4,5) + const PrepareOutputFlags prepare = (S == SimEngine::SRPIC) + ? PrepareOutput::ConvertToHat + : PrepareOutput::ConvertToPhysCntrv; + list_t comp_from = { 0, 1, 2 }; + list_t comp_to = { 3, 4, 5 }; + Kokkos::parallel_for( + "RenderFLFieldsToPhys", + mesh.rangeActiveCells(), + kernel::FieldsToPhys_kernel(bckp, bckp, comp_from, comp_to, + interp | prepare, metric)); + Kokkos::fence(); + + // pull the physical components to host and bin into the coarse grid + auto bckp_h = Kokkos::create_mirror_view(bckp); + Kokkos::deep_copy(bckp_h, bckp); + + const std::size_t ncell = static_cast(gnc[0]) * + static_cast(gnc[1]) * + static_cast(gnc[2]); + std::vector sum(ncell * 3, ZERO); + std::vector cnt(ncell, ZERO); + + const auto le = mesh.extent(); + const real_t llo[3] = { le[0].first, le[1].first, le[2].first }; + const real_t lsz[3] = { le[0].second - le[0].first, + le[1].second - le[1].first, + le[2].second - le[2].first }; + const int nl[3] = { static_cast(mesh.n_active(in::x1)), + static_cast(mesh.n_active(in::x2)), + static_cast(mesh.n_active(in::x3)) }; + const int NG = static_cast(N_GHOSTS); + for (int k = 0; k < nl[2]; ++k) { + for (int j = 0; j < nl[1]; ++j) { + for (int i = 0; i < nl[0]; ++i) { + const real_t world[3] = { + llo[0] + (static_cast(i) + HALF) * lsz[0] / nl[0], + llo[1] + (static_cast(j) + HALF) * lsz[1] / nl[1], + llo[2] + (static_cast(k) + HALF) * lsz[2] / nl[2] + }; + int c[3]; + for (int d = 0; d < 3; ++d) { + int cc = static_cast( + std::floor((world[d] - gorigin[d]) / gdx[d])); + cc = (cc < 0) ? 0 : ((cc > gnc[d] - 1) ? gnc[d] - 1 : cc); + c[d] = cc; + } + const std::size_t lin = (static_cast(c[2]) * gnc[1] + + c[1]) * + gnc[0] + + c[0]; + sum[lin * 3 + 0] += bckp_h(i + NG, j + NG, k + NG, 3); + sum[lin * 3 + 1] += bckp_h(i + NG, j + NG, k + NG, 4); + sum[lin * 3 + 2] += bckp_h(i + NG, j + NG, k + NG, 5); + cnt[lin] += ONE; + } + } + } +#if defined(MPI_ENABLED) + MPI_Allreduce(MPI_IN_PLACE, sum.data(), static_cast(ncell * 3), + mpi::get_type(), MPI_SUM, MPI_COMM_WORLD); + MPI_Allreduce(MPI_IN_PLACE, cnt.data(), static_cast(ncell), + mpi::get_type(), MPI_SUM, MPI_COMM_WORLD); +#endif + out::CoarseField cf; + cf.B.assign(ncell * 3, ZERO); + for (int d = 0; d < 3; ++d) { + cf.n[d] = gnc[d]; + cf.origin[d] = gorigin[d]; + cf.dx[d] = gdx[d]; + } + for (std::size_t c = 0; c < ncell; ++c) { + if (cnt[c] > ZERO) { + const real_t inv = ONE / cnt[c]; + cf.B[c * 3 + 0] = sum[c * 3 + 0] * inv; + cf.B[c * 3 + 1] = sum[c * 3 + 1] * inv; + cf.B[c * 3 + 2] = sum[c * 3 + 2] * inv; + } + } + return cf; + } + } // namespace template @@ -503,16 +624,89 @@ namespace ntt { int bx0 = 0, by0 = 0, bw = 0, bh = 0; const bool on_screen = out::screenBBox(cam, W, H, lo, hi, bx0, by0, bw, bh); + // ---- magnetic-field-line tubes (built once, shared by every scene) --- // + // Every rank coarsens + replicates the field, traces the SAME global + // polylines, and keeps only the segments inside its own domain; the + // ordered cross-domain composite stitches them. Built before the scene + // loop so an overlay and a standalone tube scene share one geometry pass. + // NB: all ranks reach this together (cadence is collective), so the + // Allreduce inside buildCoarseFieldVec is safe. + const auto& flc = g_renderer.fieldlines(); + out::TubeSet tubes = out::emptyTubeSet(); + out::TubeSet empty = out::emptyTubeSet(); + bool have_tubes = false; + if (flc.enable) { + const int gN[3] = { static_cast(mesh().n_active(in::x1)), + static_cast(mesh().n_active(in::x2)), + static_cast(mesh().n_active(in::x3)) }; + int gnc[3]; + real_t gorigin[3], gdx[3]; + for (int d = 0; d < 3; ++d) { + gnc[d] = std::max(1, (gN[d] + flc.bin - 1) / flc.bin); + gorigin[d] = glob_ext[d].first; + gdx[d] = (glob_ext[d].second - glob_ext[d].first) / gnc[d]; + } + const char fb = static_cast( + std::toupper(flc.field.empty() ? 'B' : flc.field[0])); + out::CoarseField cf = buildCoarseFieldVec( + local_domain->mesh, local_domain->fields, bckp, fb, gorigin, gnc, gdx); + // seed/tube scale: world units per screen pixel (orthographic frame) + const real_t wpp = (cam.half_h * TWO) / static_cast(H); + real_t vlo, vhi; + auto lines = out::traceFieldLines(cf, flc, wpp, vlo, vhi); + if (flc.vmax > flc.vmin) { // explicit color range overrides auto + vlo = flc.vmin; + vhi = flc.vmax; + } + const real_t tube_world = math::max(flc.tube_px, ONE) * wpp; + const real_t eff_r = math::max(tube_world, + static_cast(0.55) * ds); + std::size_t n_kept = 0; + tubes = out::buildTubeSet(lines, eff_r, flc, vlo, vhi, lo, hi, cf, + n_kept); + have_tubes = true; + logger::Checkpoint("field lines: " + std::to_string(lines.size()) + + " global lines, " + std::to_string(n_kept) + + " local segments", + HERE); + } + bool rendered_any = false; for (const auto& scene : g_renderer.scenes()) { - Kokkos::deep_copy(bckp, ZERO); - if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { + // a `field = "fieldlines"` scene renders the tubes standalone (no + // volume); any other scene may overlay them inside its volume. + const bool fl_only = (scene.field == "fieldlines"); + const bool volume_on = not fl_only; + const bool show_tubes = scene.show_fieldlines and have_tubes; + if (volume_on) { + Kokkos::deep_copy(bckp, ZERO); + if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { + continue; + } + // fill the ghost halo with neighbor active values so trilinear + // sampling is C0 across domain faces (a halo EXCHANGE, not the + // sum-into-active that SynchronizeFields performs). + CommunicateBckp(*local_domain, { 0, 1 }); + } else if (not have_tubes) { + // standalone field-line scene but tracing produced nothing/disabled + raise::Warning("output.render: 'fieldlines' scene but no field-line " + "geometry; skipping", + HERE); continue; } - // fill the ghost halo with neighbor active values so trilinear - // sampling is C0 across domain faces (a halo EXCHANGE, not the - // sum-into-active that SynchronizeFields performs). - CommunicateBckp(*local_domain, { 0, 1 }); + const out::TubeSet& kt = show_tubes ? tubes : empty; + // a standalone tube scene colors its colorbar by |field|, not by the + // (unused) volume transfer function + out::Scene scene_cb = scene; + if (fl_only) { + scene_cb.tf.vmin = tubes.vmin; + scene_cb.tf.vmax = tubes.vmax; + scene_cb.tf.log_scale = tubes.log_scale; + scene_cb.tf.colormap = tubes.colormap; + if (scene_cb.label == "fieldlines") { + scene_cb.label = "|" + flc.field + "|"; + } + } out::SubImage sub; if (on_screen) { @@ -555,6 +749,8 @@ namespace ntt { ghi, spine_radius, spine_rgb, + kt, + volume_on, image)); Kokkos::fence(); @@ -569,7 +765,7 @@ namespace ntt { sub.rgba[p * 4 + 3] = image_h(p, 3); } } - g_renderer.compositeAndWrite(sub, order_key, scene, current_step); + g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step); rendered_any = true; } return rendered_any; diff --git a/src/output/render/fieldlines.h b/src/output/render/fieldlines.h new file mode 100644 index 000000000..b1f24f4db --- /dev/null +++ b/src/output/render/fieldlines.h @@ -0,0 +1,457 @@ +/** + * @file output/render/fieldlines.h + * @brief Host-side magnetic-field-line tracer + bucketed tube-geometry builder + * @implements + * - out::CoarseField + * - out::traceFieldLines + * - out::buildTubeSet + * - out::emptyTubeSet + * @namespaces: + * - out:: + * @macros: + * - OUTPUT_ENABLED + * @note + * Field lines are intrinsically non-local (a streamline wanders across MPI + * domains), which would normally demand parallel particle advection. We sidestep + * that entirely: the (physical-basis) field is volume-averaged onto a COARSE + * grid and MPI-replicated to every rank (see Metadomain::buildFieldLineTubes), + * so every rank traces the SAME global polylines locally and renders only the + * segments overlapping its own domain. The existing ordered cross-domain + * composite then stitches the pieces. Tracing/geometry here is metric-agnostic: + * the only supported 3D render mode is Cartesian (Minkowski), so the coarse grid + * is a plain uniform lattice in world coordinates. + * + * Performance: a ray sample must not test every segment. Segments are bucketed + * into the coarse grid (CSR), and since the tube radius is << one coarse cell, + * a sample only needs the segments registered in its own cell. The kernel + * (raymarch.hpp) walks that short bucket. + */ + +#ifndef OUTPUT_RENDER_FIELDLINES_H +#define OUTPUT_RENDER_FIELDLINES_H + +#include "global.h" + +#include "arch/kokkos_aliases.h" + +#include "output/render/renderer.h" +#include "output/render/transfer_fn.h" + +#include + +#include +#include +#include +#include +#include + +namespace out { + + /** + * @brief A coarse, MPI-replicated copy of the physical-basis vector field. + * @note B is laid out component-fastest: index (c0,c1,c2,comp) lives at + * ((c2*n1 + c1)*n0 + c0)*3 + comp, with c0 the fastest spatial axis. + */ + struct CoarseField { + std::vector B; // n0*n1*n2*3 + int n[3] { 0, 0, 0 }; + real_t origin[3] { ZERO, ZERO, ZERO }; + real_t dx[3] { ONE, ONE, ONE }; + }; + + /** @brief One traced field line: world-space vertices + per-vertex |field|. */ + struct Polyline { + std::vector> pts; + std::vector scal; + }; + + namespace fl_hidden { + + inline auto cellLinear(const CoarseField& cf, int c0, int c1, int c2) + -> std::size_t { + return (static_cast(c2) * cf.n[1] + c1) * cf.n[0] + c0; + } + + // trilinear sample of the coarse field at world point p -> B[3], |B|. + // Clamps to the grid (so a sample just outside a face still returns the edge + // value); membership in the global box is the caller's stop test. + inline auto sampleCoarse(const CoarseField& cf, const real_t p[3], real_t B[3]) + -> real_t { + int i0[3], i1[3]; + real_t fr[3]; + for (int d = 0; d < 3; ++d) { + if (cf.n[d] <= 1) { + i0[d] = 0; + i1[d] = 0; + fr[d] = ZERO; + continue; + } + const real_t g = (p[d] - cf.origin[d]) / cf.dx[d] - HALF; + real_t f = std::floor(g); + real_t t = g - f; + int b = static_cast(f); + if (b < 0) { + b = 0; + t = ZERO; + } else if (b > cf.n[d] - 2) { + b = cf.n[d] - 2; + t = ONE; + } + i0[d] = b; + i1[d] = b + 1; + fr[d] = t; + } + for (int comp = 0; comp < 3; ++comp) { + const real_t c000 = cf.B[cellLinear(cf, i0[0], i0[1], i0[2]) * 3 + comp]; + const real_t c100 = cf.B[cellLinear(cf, i1[0], i0[1], i0[2]) * 3 + comp]; + const real_t c010 = cf.B[cellLinear(cf, i0[0], i1[1], i0[2]) * 3 + comp]; + const real_t c110 = cf.B[cellLinear(cf, i1[0], i1[1], i0[2]) * 3 + comp]; + const real_t c001 = cf.B[cellLinear(cf, i0[0], i0[1], i1[2]) * 3 + comp]; + const real_t c101 = cf.B[cellLinear(cf, i1[0], i0[1], i1[2]) * 3 + comp]; + const real_t c011 = cf.B[cellLinear(cf, i0[0], i1[1], i1[2]) * 3 + comp]; + const real_t c111 = cf.B[cellLinear(cf, i1[0], i1[1], i1[2]) * 3 + comp]; + const real_t c00 = c000 * (ONE - fr[0]) + c100 * fr[0]; + const real_t c10 = c010 * (ONE - fr[0]) + c110 * fr[0]; + const real_t c01 = c001 * (ONE - fr[0]) + c101 * fr[0]; + const real_t c11 = c011 * (ONE - fr[0]) + c111 * fr[0]; + const real_t c0 = c00 * (ONE - fr[1]) + c10 * fr[1]; + const real_t c1 = c01 * (ONE - fr[1]) + c11 * fr[1]; + B[comp] = c0 * (ONE - fr[2]) + c1 * fr[2]; + } + return std::sqrt(B[0] * B[0] + B[1] * B[1] + B[2] * B[2]); + } + + inline auto insideBox(const CoarseField& cf, const real_t p[3]) -> bool { + for (int d = 0; d < 3; ++d) { + const real_t hi = cf.origin[d] + cf.n[d] * cf.dx[d]; + if (p[d] < cf.origin[d] or p[d] > hi) { + return false; + } + } + return true; + } + + } // namespace fl_hidden + + /** + * @brief Trace field lines through the coarse field by bidirectional RK4. + * @param cf coarse, replicated physical field + * @param cfg field-line configuration (seed density, step, caps) + * @param world_per_pixel world units per screen pixel (sets seed/tube scale) + * @param[out] out_vmin,out_vmax auto color range (min/max |field| along lines) + * @return global polylines (identical on every rank) + */ + inline auto traceFieldLines(const CoarseField& cf, + const FieldLineConfig& cfg, + real_t world_per_pixel, + real_t& out_vmin, + real_t& out_vmax) + -> std::vector { + using fl_hidden::insideBox; + using fl_hidden::sampleCoarse; + + std::vector lines; + if (cf.n[0] < 1 or cf.n[1] < 1 or cf.n[2] < 1) { + return lines; + } + + real_t size[3]; + real_t diag2 = ZERO; + real_t min_dx = static_cast(1e30); + for (int d = 0; d < 3; ++d) { + size[d] = cf.n[d] * cf.dx[d]; + diag2 += size[d] * size[d]; + min_dx = std::min(min_dx, cf.dx[d]); + } + const real_t box_diag = std::sqrt(diag2); + const real_t max_len = cfg.max_len_frac * box_diag; + const real_t h = std::max(cfg.step_frac, static_cast(1e-3)) * + min_dx; + const real_t eps = static_cast(1e-20); + + // seed lattice: spacing ~ seed_px screen pixels, grown to respect seed_max + real_t spacing = std::max(cfg.seed_px, ONE) * world_per_pixel; + int ns[3]; + auto countSeeds = [&](real_t s) -> long { + long tot = 1; + for (int d = 0; d < 3; ++d) { + ns[d] = std::max(1, static_cast(std::floor(size[d] / s))); + tot *= ns[d]; + } + return tot; + }; + long n_seed = countSeeds(spacing); + if (n_seed > cfg.seed_max and cfg.seed_max > 0) { + const real_t grow = std::cbrt(static_cast(n_seed) / + static_cast(cfg.seed_max)); + spacing *= grow; + countSeeds(spacing); // recompute ns[] for the grown spacing + } + + // unit-vector field derivative (×dir) used by RK4; false if |B| ~ 0 + auto deriv = [&](const real_t p[3], real_t dir, real_t out[3]) -> bool { + real_t B[3]; + const real_t m = sampleCoarse(cf, p, B); + if (m < eps) { + return false; + } + const real_t inv = dir / m; + out[0] = B[0] * inv; + out[1] = B[1] * inv; + out[2] = B[2] * inv; + return true; + }; + + out_vmin = static_cast(1e30); + out_vmax = static_cast(-1e30); + auto track = [&](real_t m) { + out_vmin = std::min(out_vmin, m); + out_vmax = std::max(out_vmax, m); + }; + + // integrate one direction (dir = +1 forward, -1 backward) from a seed + auto integrate = [&](const real_t seed[3], real_t dir) { + Polyline pl; + real_t p[3] = { seed[0], seed[1], seed[2] }; + real_t B0[3]; + real_t m0 = sampleCoarse(cf, p, B0); + if (m0 < eps) { + return; + } + pl.pts.push_back({ p[0], p[1], p[2] }); + pl.scal.push_back(m0); + track(m0); + real_t len = ZERO; + for (int step = 0; step < cfg.max_steps and len < max_len; ++step) { + real_t k1[3], k2[3], k3[3], k4[3], q[3]; + if (not deriv(p, dir, k1)) { + break; + } + for (int d = 0; d < 3; ++d) { + q[d] = p[d] + HALF * h * k1[d]; + } + if (not deriv(q, dir, k2)) { + break; + } + for (int d = 0; d < 3; ++d) { + q[d] = p[d] + HALF * h * k2[d]; + } + if (not deriv(q, dir, k3)) { + break; + } + for (int d = 0; d < 3; ++d) { + q[d] = p[d] + h * k3[d]; + } + if (not deriv(q, dir, k4)) { + break; + } + for (int d = 0; d < 3; ++d) { + p[d] += (h / static_cast(6)) * + (k1[d] + static_cast(2) * k2[d] + + static_cast(2) * k3[d] + k4[d]); + } + if (not insideBox(cf, p)) { + break; + } + real_t B[3]; + const real_t m = sampleCoarse(cf, p, B); + pl.pts.push_back({ p[0], p[1], p[2] }); + pl.scal.push_back(m); + track(m); + len += h; + } + if (pl.pts.size() >= 2) { + lines.push_back(std::move(pl)); + } + }; + + for (int k = 0; k < ns[2]; ++k) { + for (int j = 0; j < ns[1]; ++j) { + for (int i = 0; i < ns[0]; ++i) { + const real_t seed[3] = { + cf.origin[0] + (static_cast(i) + HALF) * size[0] / ns[0], + cf.origin[1] + (static_cast(j) + HALF) * size[1] / ns[1], + cf.origin[2] + (static_cast(k) + HALF) * size[2] / ns[2] + }; + integrate(seed, ONE); + integrate(seed, -ONE); + } + } + } + if (out_vmin > out_vmax) { // no lines traced + out_vmin = ZERO; + out_vmax = ONE; + } + return lines; + } + + /** + * @brief Bucket the field-line segments overlapping a domain AABB into a CSR + * grid index and pack them into device Views ready for the ray-march kernel. + * @param lines global polylines (every rank passes the same set) + * @param radius world-space tube radius (the ds floor is applied by the caller) + * @param cfg field-line configuration (colormap / log) + * @param vmin,vmax tube color range + * @param lo,hi this domain's world AABB (segments outside it are dropped) + * @param cf coarse grid geometry, reused as the bucket grid + * @param[out] n_kept number of segments kept for this domain (for logging) + */ + inline auto buildTubeSet(const std::vector& lines, + real_t radius, + const FieldLineConfig& cfg, + real_t vmin, + real_t vmax, + const real_t lo[3], + const real_t hi[3], + const CoarseField& cf, + std::size_t& n_kept) -> TubeSet { + TubeSet ts; + ts.radius = radius; + ts.vmin = vmin; + ts.vmax = (vmax > vmin) ? vmax : (vmin + ONE); + ts.log_scale = cfg.log_scale and (vmin > ZERO); + ts.colormap = cfg.colormap; + ts.n_lut = 256; + // Bucket grid: a LOCAL uniform lattice spanning only THIS domain's AABB + // (not the whole box), so cell_start stays O(local cells). A bucket cell is + // ~one coarse cell; the tube radius is far smaller, so a ray sample (always + // inside [lo,hi]) finds its nearest segment in its own bucket cell. + for (int d = 0; d < 3; ++d) { + ts.gdx[d] = (cf.dx[d] > ZERO) ? cf.dx[d] : ONE; + ts.gorigin[d] = lo[d]; + const real_t span = hi[d] - lo[d]; + ts.gnc[d] = std::max(1, static_cast(std::ceil(span / ts.gdx[d]))); + } + auto lin = [&](int c0, int c1, int c2) -> std::size_t { + return (static_cast(c2) * ts.gnc[1] + c1) * ts.gnc[0] + c0; + }; + // opaque LUT: a tube sample paints a solid color (alpha==1) + ts.lut = buildLUT(cfg.colormap, ts.n_lut, { { ZERO, ONE }, { ONE, ONE } }); + + // 1) keep segments whose radius-padded AABB overlaps this domain AABB. + // (We keep the whole segment, not a clipped piece: the kernel only ever + // samples within this domain's slab, so a shared segment shows only in + // the correct domain's depth range -- no double-draw.) + std::vector> kept; + for (const auto& pl : lines) { + for (std::size_t i = 0; i + 1 < pl.pts.size(); ++i) { + const auto& a = pl.pts[i]; + const auto& b = pl.pts[i + 1]; + bool overlap = true; + for (int d = 0; d < 3; ++d) { + const real_t smin = std::min(a[d], b[d]) - radius; + const real_t smax = std::max(a[d], b[d]) + radius; + if (smax < lo[d] or smin > hi[d]) { + overlap = false; + break; + } + } + if (overlap) { + kept.push_back({ a[0], a[1], a[2], b[0], b[1], b[2], pl.scal[i], + pl.scal[i + 1] }); + } + } + } + n_kept = kept.size(); + ts.n_seg = static_cast(kept.size()); + const std::size_t ncell = static_cast(ts.gnc[0]) * ts.gnc[1] * + ts.gnc[2]; + + // 2) CSR bucketing on the coarse grid: count, prefix-sum, scatter. Each + // segment is registered in every cell its radius-padded AABB overlaps. + auto cellOf = [&](real_t x, int d) -> int { + int c = static_cast(std::floor((x - ts.gorigin[d]) / ts.gdx[d])); + if (c < 0) { + c = 0; + } else if (c > ts.gnc[d] - 1) { + c = ts.gnc[d] - 1; + } + return c; + }; + auto cellRange = [&](const std::array& s, int d, int& c0, int& c1) { + const real_t smin = std::min(s[d], s[3 + d]) - radius; + const real_t smax = std::max(s[d], s[3 + d]) + radius; + c0 = cellOf(smin, d); + c1 = cellOf(smax, d); + }; + + std::vector count(ncell + 1, 0); + for (const auto& s : kept) { + int a0, a1, b0, b1, d0, d1; + cellRange(s, 0, a0, a1); + cellRange(s, 1, b0, b1); + cellRange(s, 2, d0, d1); + for (int c2 = d0; c2 <= d1; ++c2) { + for (int c1 = b0; c1 <= b1; ++c1) { + for (int c0 = a0; c0 <= a1; ++c0) { + ++count[lin(c0, c1, c2)]; + } + } + } + } + std::vector start(ncell + 1, 0); + for (std::size_t c = 0; c < ncell; ++c) { + start[c + 1] = start[c] + count[c]; + } + const std::size_t n_insert = static_cast(start[ncell]); + std::vector idx(n_insert, 0); + std::vector cursor(start.begin(), start.end()); // running write head + for (std::size_t si = 0; si < kept.size(); ++si) { + int a0, a1, b0, b1, d0, d1; + cellRange(kept[si], 0, a0, a1); + cellRange(kept[si], 1, b0, b1); + cellRange(kept[si], 2, d0, d1); + for (int c2 = d0; c2 <= d1; ++c2) { + for (int c1 = b0; c1 <= b1; ++c1) { + for (int c0 = a0; c0 <= a1; ++c0) { + const std::size_t cl = lin(c0, c1, c2); + idx[static_cast(cursor[cl]++)] = static_cast(si); + } + } + } + } + + // 3) upload to device + ts.seg = array_t("fl_seg", static_cast(ts.n_seg)); + if (ts.n_seg > 0) { + auto seg_h = Kokkos::create_mirror_view(ts.seg); + for (int s = 0; s < ts.n_seg; ++s) { + for (int c = 0; c < 8; ++c) { + seg_h(s, c) = kept[static_cast(s)][static_cast(c)]; + } + } + Kokkos::deep_copy(ts.seg, seg_h); + } + ts.cell_start = array_t("fl_cell_start", ncell + 1); + { + auto h = Kokkos::create_mirror_view(ts.cell_start); + for (std::size_t c = 0; c <= ncell; ++c) { + h(c) = start[c]; + } + Kokkos::deep_copy(ts.cell_start, h); + } + ts.seg_idx = array_t("fl_seg_idx", std::max(n_insert, 1)); + if (n_insert > 0) { + auto h = Kokkos::create_mirror_view(ts.seg_idx); + for (std::size_t k = 0; k < n_insert; ++k) { + h(k) = idx[k]; + } + Kokkos::deep_copy(ts.seg_idx, h); + } + return ts; + } + + /** @brief A valid but empty tube set (used when a scene shows no field lines). */ + inline auto emptyTubeSet() -> TubeSet { + TubeSet ts; + ts.n_seg = 0; + ts.seg = array_t("fl_seg_empty", 0); + ts.cell_start = array_t("fl_cell_start_empty", 1); + ts.seg_idx = array_t("fl_seg_idx_empty", 1); + ts.lut = buildLUT("inferno", 2, { { ZERO, ONE }, { ONE, ONE } }); + return ts; + } + +} // namespace out + +#endif // OUTPUT_RENDER_FIELDLINES_H diff --git a/src/output/render/raymarch.hpp b/src/output/render/raymarch.hpp index e0db322f3..a326d3f31 100644 --- a/src/output/render/raymarch.hpp +++ b/src/output/render/raymarch.hpp @@ -67,6 +67,24 @@ namespace kernel { const real_t spine_radius; const real_t spine_cr, spine_cg, spine_cb; + // magnetic-field-line tubes: opaque capsules colored by |field|, composited + // inline like the spine. Bucketed into the coarse grid (CSR) so a sample + // tests only the segments in its cell. tn_seg <= 0 disables them. + array_t tseg; // (tn_seg, 8): p0, p1, s0, s1 (world) + array_t tcell_start; // (ncell+1) CSR offsets + array_t tseg_idx; // segment indices grouped by cell + const int tn_seg; + const real_t tube_r2; // squared tube radius + const int tgnc0, tgnc1, tgnc2; + const real_t tg0, tg1, tg2; // bucket-grid origin + const real_t tdx0, tdx1, tdx2; // bucket-grid cell size + array_t tube_lut; + const int tube_n_lut; + const real_t tube_vmin, tube_vmax; + const bool tube_log; + // when false the scalar volume is not sampled (standalone field-line render) + const bool volume_enabled; + array_t image; // output, (bw*bh, 4) premultiplied RGBA public: @@ -96,6 +114,8 @@ namespace kernel { const real_t ghi[3], real_t spine_radius_, const real_t spine_rgb[3], + const out::TubeSet& tubes_, + bool volume_enabled_, const array_t& image_) : Fld { Fld_ } , comp { comp_ } @@ -133,6 +153,26 @@ namespace kernel { , spine_cr { spine_rgb[0] } , spine_cg { spine_rgb[1] } , spine_cb { spine_rgb[2] } + , tseg { tubes_.seg } + , tcell_start { tubes_.cell_start } + , tseg_idx { tubes_.seg_idx } + , tn_seg { tubes_.n_seg } + , tube_r2 { tubes_.radius * tubes_.radius } + , tgnc0 { tubes_.gnc[0] } + , tgnc1 { tubes_.gnc[1] } + , tgnc2 { tubes_.gnc[2] } + , tg0 { tubes_.gorigin[0] } + , tg1 { tubes_.gorigin[1] } + , tg2 { tubes_.gorigin[2] } + , tdx0 { tubes_.gdx[0] } + , tdx1 { tubes_.gdx[1] } + , tdx2 { tubes_.gdx[2] } + , tube_lut { tubes_.lut } + , tube_n_lut { tubes_.n_lut } + , tube_vmin { tubes_.vmin } + , tube_vmax { tubes_.vmax } + , tube_log { tubes_.log_scale } + , volume_enabled { volume_enabled_ } , image { image_ } {} // distance test: is world point (px,py,pz) within `spine_radius` of any of @@ -171,6 +211,48 @@ namespace kernel { return false; } + // is world point within `tube_radius` of any field-line segment? Walks only + // the bucket of the point's coarse cell (radius << one coarse cell, so a + // padded-AABB insertion guarantees the nearest segment is in this cell). + // On a hit, `scalar` is the |field| interpolated to the closest point. + Inline auto inTube(real_t px, real_t py, real_t pz, real_t& scalar) const + -> bool { + if (tn_seg <= 0) { + return false; + } + const int c0 = static_cast(math::floor((px - tg0) / tdx0)); + const int c1 = static_cast(math::floor((py - tg1) / tdx1)); + const int c2 = static_cast(math::floor((pz - tg2) / tdx2)); + if (c0 < 0 or c0 >= tgnc0 or c1 < 0 or c1 >= tgnc1 or c2 < 0 or + c2 >= tgnc2) { + return false; + } + const int lin = (c2 * tgnc1 + c1) * tgnc0 + c0; + const int kb = tcell_start(lin); + const int ke = tcell_start(lin + 1); + real_t best = tube_r2; + bool hit = false; + for (int k = kb; k < ke; ++k) { + const int s = tseg_idx(k); + const real_t ax = tseg(s, 0), ay = tseg(s, 1), az = tseg(s, 2); + const real_t bx = tseg(s, 3), by = tseg(s, 4), bz = tseg(s, 5); + const real_t ex = bx - ax, ey = by - ay, ez = bz - az; + const real_t wx = px - ax, wy = py - ay, wz = pz - az; + const real_t ee = ex * ex + ey * ey + ez * ez; + real_t tt = (ee > ZERO) ? (wx * ex + wy * ey + wz * ez) / ee : ZERO; + tt = (tt < ZERO) ? ZERO : ((tt > ONE) ? ONE : tt); + const real_t cx = ax + tt * ex, cy = ay + tt * ey, cz = az + tt * ez; + const real_t dx = px - cx, dy = py - cy, dz = pz - cz; + const real_t d2 = dx * dx + dy * dy + dz * dz; + if (d2 < best) { + best = d2; + scalar = tseg(s, 6) * (ONE - tt) + tseg(s, 7) * tt; + hit = true; + } + } + return hit; + } + // trilinear sample of the prepared scalar at world point p, reading the // ghost halo for corners just outside the active box. Inline auto sample(real_t px, real_t py, real_t pz) const -> real_t { @@ -311,6 +393,12 @@ namespace kernel { // ---- march at global sample positions t_k = k*ds ------------------- // const real_t inv_range = (vmax > vmin) ? (ONE / (vmax - vmin)) : ZERO; const real_t log_vmin = log_scale ? math::log10(vmin) : ZERO; + const real_t tube_inv_range = (tube_vmax > tube_vmin) + ? (ONE / (tube_vmax - tube_vmin)) + : ZERO; + const real_t tube_log_vmin = (tube_log and tube_vmin > ZERO) + ? math::log10(tube_vmin) + : ZERO; // first global sample index inside this segment: t_k >= t_enter const real_t k0 = math::ceil(t_enter / ds); real_t t = k0 * ds; @@ -318,7 +406,8 @@ namespace kernel { int steps = 0; while (t < t_exit and steps < max_steps) { const real_t px = ox + t * dx, py = oy + t * dy, pz = oz + t * dz; - real_t cr, cg, cb, ca; + real_t cr = ZERO, cg = ZERO, cb = ZERO, ca = ZERO; + real_t tube_s = ZERO; if (onSpine(px, py, pz)) { // opaque box edge (premultiplied; alpha == 1 -> color is straight RGB). // Composited inline so accumulated foreground volume occludes it. @@ -326,7 +415,33 @@ namespace kernel { cg = spine_cg; cb = spine_cb; ca = ONE; - } else { + } else if (inTube(px, py, pz, tube_s)) { + // opaque field-line tube colored by |field| through the tube LUT + real_t u; + if (tube_log) { + u = (tube_s > ZERO) + ? (math::log10(tube_s) - tube_log_vmin) * tube_inv_range + : -ONE; + } else { + u = (tube_s - tube_vmin) * tube_inv_range; + } + if (u < ZERO) { + u = ZERO; + } else if (u > ONE) { + u = ONE; + } + int idx = static_cast(u * static_cast(tube_n_lut - 1) + + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > tube_n_lut - 1) { + idx = tube_n_lut - 1; + } + cr = tube_lut(idx, 0); // opaque LUT -> straight RGB, alpha 1 + cg = tube_lut(idx, 1); + cb = tube_lut(idx, 2); + ca = tube_lut(idx, 3); + } else if (volume_enabled) { const real_t s = sample(px, py, pz); // normalize through the transfer function range real_t u; @@ -351,13 +466,17 @@ namespace kernel { cb = lut(idx, 2); ca = lut(idx, 3); } - const real_t w = ONE - acc_a; - acc_r += w * cr; - acc_g += w * cg; - acc_b += w * cb; - acc_a += w * ca; - if (acc_a >= early_alpha) { - break; + // composite only a non-empty sample (volume-off rays contribute solely + // where they hit a tube or the spine) + if (ca > ZERO) { + const real_t w = ONE - acc_a; + acc_r += w * cr; + acc_g += w * cg; + acc_b += w * cb; + acc_a += w * ca; + if (acc_a >= early_alpha) { + break; + } } t += ds; ++steps; diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index ebda438f5..30ee7c578 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -254,6 +254,10 @@ namespace out { scene.ticks = toml::find_or>(sc, "colorbar_ticks", std::vector {}); + // overlay the B-field-line tubes inside this scene's volume; a dedicated + // `field = "fieldlines"` scene renders the tubes standalone (no volume). + scene.show_fieldlines = toml::find_or(sc, "fieldlines", false) or + (scene.field == "fieldlines"); scene.tf.vmin = toml::find_or(sc, "min", ZERO); scene.tf.vmax = toml::find_or(sc, "max", ONE); scene.tf.log_scale = toml::find_or(sc, "log", false); @@ -284,6 +288,53 @@ namespace out { return; } + /* ---- magnetic-field-line tube overlay ------------------------------- */ + // The tubes are built whenever the [output.render.fieldlines] section asks + // for them OR any scene requests the overlay (so a bare `field = + // "fieldlines"` scene works without a separate enable flag). + bool any_fl = false; + for (const auto& s : m_scenes) { + any_fl = any_fl or s.show_fieldlines; + } + m_fieldlines.enable = toml::find_or(td, "output", "render", + "fieldlines", "enable", false) or + any_fl; + if (m_fieldlines.enable) { + if (m_global_extent.size() != 3) { + raise::Warning("output.render.fieldlines is 3D-only; ignoring", HERE); + m_fieldlines.enable = false; + } else { + auto& fl = m_fieldlines; + fl.field = toml::find_or(td, "output", "render", + "fieldlines", "field", "B"); + fl.bin = toml::find_or(td, "output", "render", "fieldlines", + "bin", 4); + fl.bin = (fl.bin < 1) ? 1 : ((fl.bin > 16) ? 16 : fl.bin); + fl.seed_px = toml::find_or(td, "output", "render", "fieldlines", + "seed_px", static_cast(8)); + fl.tube_px = toml::find_or(td, "output", "render", "fieldlines", + "tube_px", static_cast(2)); + fl.colormap = toml::find_or(td, "output", "render", + "fieldlines", "colormap", + "inferno"); + fl.log_scale = toml::find_or(td, "output", "render", "fieldlines", + "log", false); + fl.vmin = toml::find_or(td, "output", "render", "fieldlines", + "min", ZERO); + fl.vmax = toml::find_or(td, "output", "render", "fieldlines", + "max", ZERO); + fl.step_frac = toml::find_or(td, "output", "render", "fieldlines", + "step_frac", static_cast(0.5)); + fl.max_steps = toml::find_or(td, "output", "render", "fieldlines", + "max_steps", 4000); + fl.max_len_frac = toml::find_or(td, "output", "render", + "fieldlines", "max_length", + static_cast(3)); + fl.seed_max = toml::find_or(td, "output", "render", "fieldlines", + "seed_max", 4096); + } + } + m_enabled = true; logger::Checkpoint("In-situ renderer initialized", HERE); } diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 15210f61e..72f1c170d 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -69,14 +69,70 @@ namespace out { std::string colormap { "viridis" }; // for redrawing the colorbar }; + /** + * @brief Configuration for the magnetic-field-line tube overlay. + * @note The lines are traced once per frame through a coarse, MPI-replicated + * copy of the (physical-basis) field, so every rank produces the same global + * polylines and renders only the segments inside its own domain; the existing + * ordered cross-domain composite then stitches them. The coarsening (`bin`) + * is what makes the replicate-and-trace cheap and avoids parallel particle + * advection. See output/render/fieldlines.h. + */ + struct FieldLineConfig { + bool enable { false }; // build the geometry this run + std::string field { "B" }; // vector field to trace: "B" | "E" | "J" + int bin { 4 }; // coarsening factor (cells/coarse cell), 2..8 + real_t seed_px { 8 }; // seed lattice spacing in screen pixels + real_t tube_px { 2 }; // tube radius in screen pixels + std::string colormap { "inferno" }; + bool log_scale { false }; + real_t vmin { ZERO }; // tube color range; vmin>=vmax => auto |B| + real_t vmax { ZERO }; + real_t step_frac { static_cast(0.5) }; // RK4 step / coarse cell + int max_steps { 4000 }; // per-direction integration cap + real_t max_len_frac { static_cast(3) }; // x global box diagonal + int seed_max { 4096 }; // hard cap on seed count (spacing grows to fit) + }; + + /** + * @brief Device-side field-line geometry handed to the ray-march kernel. + * @note A flat capsule list: each row is (p0xyz, p1xyz, s0, s1) with s the + * per-vertex scalar (|field|) used to color the tube. Opaque (alpha==1) LUT, + * so a tube sample paints a solid color and is composited inline exactly like + * the box spine. Empty (n_seg==0) on ranks no line touches. + */ + struct TubeSet { + array_t seg; // (n_seg, 8): p0, p1, s0, s1 in world coords + int n_seg { 0 }; + real_t radius { ZERO }; // world-space tube radius (ds floor applied) + array_t lut; // premultiplied RGBA, opaque (alpha==1) + int n_lut { 256 }; + real_t vmin { ZERO }, vmax { ONE }; + bool log_scale { false }; + std::string colormap { "inferno" }; // for the standalone colorbar + // uniform-grid bucket index (CSR) so a ray sample tests only the few + // segments in its cell instead of all of them. Bucketing on the coarse + // grid is exact because the tube radius is << one coarse cell; a segment is + // registered in every cell its radius-padded AABB overlaps. + array_t cell_start; // (ncell+1) prefix offsets into seg_idx + array_t seg_idx; // segment indices, grouped by cell + int gnc[3] { 1, 1, 1 }; + real_t gorigin[3] { ZERO, ZERO, ZERO }; + real_t gdx[3] { ONE, ONE, ONE }; + }; + /** * @brief One rendered scalar field -> one PNG stream. + * @note `field == "fieldlines"` is a standalone tube scene: no scalar volume + * is sampled (the field lines render against the background alone). Any other + * field with `show_fieldlines` true overlays the tubes inside its volume. */ struct Scene { - std::string field; // "N" | "Bmag" | "Vmag" | "Txy" | "B1" ... + std::string field; // "N" | "Bmag" | "Vmag" | "Txy" | "B1" | "fieldlines" ... std::string prefix; // PNG filename prefix, e.g. "Bmag_" std::string label; // colorbar title (defaults to field) std::vector ticks; // explicit colorbar tick values (optional) + bool show_fieldlines { false }; // overlay B-field tubes in the volume TransferFunction tf; }; @@ -238,6 +294,11 @@ namespace out { return m_scenes; } + [[nodiscard]] + auto fieldlines() const -> const FieldLineConfig& { + return m_fieldlines; + } + private: bool m_enabled { false }; @@ -279,6 +340,7 @@ namespace out { CameraDevice m_camera_dev; std::vector m_scenes; + FieldLineConfig m_fieldlines; tools::Tracker m_tracker; path_t m_root; From 7f8e819c497092d6e6df91661049181734b50ce9 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 20:25:32 -0400 Subject: [PATCH 13/20] 2D field lines --- input.example.toml | 47 ++- src/framework/domain/metadomain_render.cpp | 212 ++++++++++- src/output/render/fieldlines.h | 403 ++++++++++++++++++++- src/output/render/renderer.cpp | 18 +- src/output/render/renderer.h | 32 ++ src/output/render/slice2d.hpp | 288 +++++++++++++-- 6 files changed, 953 insertions(+), 47 deletions(-) diff --git a/input.example.toml b/input.example.toml index 78ff1fc45..a43ad92cd 100644 --- a/input.example.toml +++ b/input.example.toml @@ -897,13 +897,17 @@ # A scene with field = "fieldlines" instead renders them alone. fieldlines = "" - # Magnetic field-line tubes (3D volume mode only). Traces field lines through - # a coarse, MPI-replicated copy of the field and draws them as solid tubes, - # colored by |field|, composited inside the same ray-march as the volume so - # they are correctly occluded by it. Built once per frame and shared by every - # scene that opts in (per-scene `fieldlines = true`) and by any standalone - # `field = "fieldlines"` scene. The coarsening is what makes the lines cheap - # to trace (no parallel particle advection) and gives a smoothed morphology. + # Magnetic field lines, drawn from a coarse, MPI-replicated copy of the field + # so the geometry is global and seamless across domains (the coarsening is + # what makes this cheap -- no parallel particle advection / flux scan). + # - 3D (Cartesian): traced as solid tubes, colored by |field|, composited + # inside the volume ray-march so the volume correctly occludes them. + # - 2D (Cartesian): iso-contours of the flux function psi (Bx = d psi/dy, + # By = -d psi/dx), i.e. the in-plane field lines, colored by |B|. + # - 2D (spherical / Kerr-Schild): traced meridional streamlines of the + # poloidal (Br, Btheta) field (nt2py style). + # Built once per frame and shared by every scene that opts in (per-scene + # `fieldlines = true`) and by any standalone `field = "fieldlines"` scene. [output.render.fieldlines] # Build the field-line geometry this run # @type: bool @@ -919,29 +923,40 @@ # Field coarsening factor (simulation cells per coarse cell, per axis) # @type: int [1..16] # @default: 4 - # @note: larger = smoother "morphology" lines + cheaper replication - # (the coarse field is ~ N_cells / bin^3 x 3 floats, on every rank) + # @note: larger = smoother "morphology" lines + cheaper replication (the + # coarse field is ~ N_cells / bin^D floats/rank; D = sim dimension) bin = "" - # Seed-lattice spacing in screen pixels (sets line density) + # (3D tubes) Seed-lattice spacing in screen pixels (sets line density) # @type: float [> 0] # @default: 8 # @note: capped by `seed_max`; if seed_px asks for more seeds than that, # the spacing grows to fit and seed_px no longer governs seed_px = "" - # Hard cap on the seed count (the seed lattice is n^3, 2 lines per seed) + # (3D tubes) Hard cap on the seed count (lattice is n^3, 2 lines per seed) # @type: int [> 0] # @default: 4096 # @note: lower this for fewer / more widely spaced lines seed_max = "" - # Tube radius in screen pixels + # (2D contours) Number of evenly-spaced flux-function contour levels + # @type: int [> 0] + # @default: 16 + # @note: evenly-spaced psi levels => line density tracks |B| automatically + levels = "" + # Tube radius (3D) / contour line width (2D), in screen pixels # @type: float [> 0] # @default: 2 tube_px = "" - # Tube colormap (mapped by |field| along the line) + # Colormap for the field lines (mapped by |B| along each line) # @type: string # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray" # @default: "inferno" colormap = "" + # Monochrome override: draw the lines in a single [r,g,b] color (each 0..1) + # instead of the |B| colormap -- reads well as an overlay on another volume + # @type: array [size 3] + # @default: [] (empty => color by |B|) + # @example: [1.0, 1.0, 1.0] # white field lines + color = "" # Map the tube color range logarithmically # @type: bool # @default: false @@ -953,15 +968,15 @@ # @note: when min >= max, the range is auto-set from |field| along the lines min = "" max = "" - # RK4 integration step as a fraction of one coarse cell + # (3D tubes) RK4 integration step as a fraction of one coarse cell # @type: float [> 0] # @default: 0.5 step_frac = "" - # Per-direction integration-step cap + # (3D tubes) Per-direction integration-step cap # @type: int [> 0] # @default: 4000 max_steps = "" - # Maximum line length, in global box diagonals (per direction) + # (3D tubes) Maximum line length, in global box diagonals (per direction) # @type: float [> 0] # @default: 3.0 max_length = "" diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index da3127f53..b6ba21c04 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -245,6 +245,103 @@ namespace ntt { return cf; } + // 2D analogue of buildCoarseFieldVec: volume-average the in-plane physical + // components (Bx, By) onto a coarse global 2D grid and MPI-replicate them, + // so every rank can integrate the SAME flux function for seamless contours. + template + auto buildCoarseField2D(const Mesh& mesh, + const Fields& fields, + ndfield_t& bckp, + char fbase, + const real_t gorigin[2], + const int gnc[2], + const real_t gdx[2]) -> out::CoarseField2D { + const auto metric = mesh.metric; + uint8_t src_base = em::bx1; + PrepareOutputFlags interp = PrepareOutput::InterpToCellCenterFromFaces; + bool is_current = false; + if (fbase == 'E') { + src_base = em::ex1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } else if (fbase == 'J') { + is_current = true; + src_base = cur::jx1; + interp = PrepareOutput::InterpToCellCenterFromEdges; + } + if (is_current) { + copyVec3ToBckp(fields.cur, bckp, + cell_range_t(cur::jx1, cur::jx3 + 1)); + } else { + copyVec3ToBckp(fields.em, bckp, + cell_range_t(src_base, src_base + 3)); + } + const PrepareOutputFlags prepare = (S == SimEngine::SRPIC) + ? PrepareOutput::ConvertToHat + : PrepareOutput::ConvertToPhysCntrv; + list_t comp_from = { 0, 1, 2 }; + list_t comp_to = { 3, 4, 5 }; + Kokkos::parallel_for( + "RenderFL2DFieldsToPhys", + mesh.rangeActiveCells(), + kernel::FieldsToPhys_kernel(bckp, bckp, comp_from, comp_to, + interp | prepare, metric)); + Kokkos::fence(); + + auto bckp_h = Kokkos::create_mirror_view(bckp); + Kokkos::deep_copy(bckp_h, bckp); + + const std::size_t ncell = static_cast(gnc[0]) * gnc[1]; + std::vector sum(ncell * 2, ZERO); + std::vector cnt(ncell, ZERO); + const auto le = mesh.extent(); + const real_t llo[2] = { le[0].first, le[1].first }; + const real_t lsz[2] = { le[0].second - le[0].first, + le[1].second - le[1].first }; + const int nl[2] = { static_cast(mesh.n_active(in::x1)), + static_cast(mesh.n_active(in::x2)) }; + const int NG = static_cast(N_GHOSTS); + for (int j = 0; j < nl[1]; ++j) { + for (int i = 0; i < nl[0]; ++i) { + const real_t world[2] = { + llo[0] + (static_cast(i) + HALF) * lsz[0] / nl[0], + llo[1] + (static_cast(j) + HALF) * lsz[1] / nl[1] + }; + int c[2]; + for (int d = 0; d < 2; ++d) { + int cc = static_cast( + std::floor((world[d] - gorigin[d]) / gdx[d])); + cc = (cc < 0) ? 0 : ((cc > gnc[d] - 1) ? gnc[d] - 1 : cc); + c[d] = cc; + } + const std::size_t lin = static_cast(c[1]) * gnc[0] + c[0]; + sum[lin * 2 + 0] += bckp_h(i + NG, j + NG, 3); // Bx + sum[lin * 2 + 1] += bckp_h(i + NG, j + NG, 4); // By + cnt[lin] += ONE; + } + } +#if defined(MPI_ENABLED) + MPI_Allreduce(MPI_IN_PLACE, sum.data(), static_cast(ncell * 2), + mpi::get_type(), MPI_SUM, MPI_COMM_WORLD); + MPI_Allreduce(MPI_IN_PLACE, cnt.data(), static_cast(ncell), + mpi::get_type(), MPI_SUM, MPI_COMM_WORLD); +#endif + out::CoarseField2D cf; + cf.B.assign(ncell * 2, ZERO); + for (int d = 0; d < 2; ++d) { + cf.n[d] = gnc[d]; + cf.origin[d] = gorigin[d]; + cf.dx[d] = gdx[d]; + } + for (std::size_t c = 0; c < ncell; ++c) { + if (cnt[c] > ZERO) { + const real_t inv = ONE / cnt[c]; + cf.B[c * 2 + 0] = sum[c * 2 + 0] * inv; + cf.B[c * 2 + 1] = sum[c * 2 + 1] * inv; + } + } + return cf; + } + } // namespace template @@ -929,13 +1026,117 @@ namespace ntt { ndomains_per_dim(), fwd2d); + // ---- 2D field lines (built once) ------------------------------------ // + // Cartesian: iso-contours of the flux function psi. Spherical/Kerr: traced + // meridional streamlines (nt2py style). Both come from a coarse, MPI- + // replicated copy of the in-plane field, so the geometry is global and + // seamless across the disjoint tiles. All ranks reach buildCoarseField2D + // together (collective Allreduce); M is fixed per run, so every rank takes + // the same Cartesian/spherical branch. + const auto& flc = g_renderer.fieldlines(); + out::ContourSet contours = out::emptyContourSet(); + out::ContourSet emptyc = out::emptyContourSet(); + out::TubeSet lines2d = out::emptyTubeSet(); + out::TubeSet emptyl = out::emptyTubeSet(); + bool have_fl = false; + real_t fl_vmin = ZERO, fl_vmax = ONE; + std::string fl_colormap = flc.colormap; + if (flc.enable) { + const int gN[2] = { static_cast(mesh().n_active(in::x1)), + static_cast(mesh().n_active(in::x2)) }; + int gnc[2]; + real_t gorigin[2], gdx[2]; + for (int d = 0; d < 2; ++d) { + gnc[d] = std::max(1, (gN[d] + flc.bin - 1) / flc.bin); + gorigin[d] = gext[d].first; + gdx[d] = (gext[d].second - gext[d].first) / gnc[d]; + } + const char fb = static_cast( + std::toupper(flc.field.empty() ? 'B' : flc.field[0])); + // coarse, replicated in-plane field: (Bx,By) for Cartesian, (Br,Bth) for + // spherical (FieldsToPhys writes the physical components in axis order) + out::CoarseField2D cf = buildCoarseField2D( + local_domain->mesh, local_domain->fields, bckp, fb, gorigin, gnc, gdx); + const real_t wpp = (umax - umin) / static_cast(W); + if constexpr (M::CoordType == Coord::type::Cartesian) { + std::vector psi; + real_t pmin, pmax, bmin, bmax; + out::computeFlux2D(cf, psi, pmin, pmax, bmin, bmax); + const real_t line_half = HALF * math::max(flc.tube_px, ONE); + contours = out::buildContourSet(cf, psi, pmin, pmax, bmin, bmax, flc, + line_half, wpp); + fl_vmin = contours.vmin; + fl_vmax = contours.vmax; + fl_colormap = contours.colormap; + logger::Checkpoint("field lines (2D): " + std::to_string(flc.levels) + + " psi contours on a " + std::to_string(gnc[0]) + + "x" + std::to_string(gnc[1]) + " grid", + HERE); + } else { + // traced meridional streamlines through the coarse (r, theta) field + real_t vlo, vhi; + auto poly = out::traceFieldLinesMeridional(cf, flc, wpp, mirror, vlo, + vhi); + if (flc.vmax > flc.vmin) { // explicit |B| range overrides auto + vlo = flc.vmin; + vhi = flc.vmax; + } + const real_t eff_r = math::max(flc.tube_px, ONE) * wpp; + // bucket grid for buildTubeSet: cell ~ a coarse dr (a length), AABB + // spans the (mirrored) meridional disk, z is a single thin slab at 0 + out::CoarseField bucket_cf; + bucket_cf.dx[0] = cf.dx[0]; + bucket_cf.dx[1] = cf.dx[0]; + bucket_cf.dx[2] = cf.dx[0]; + const real_t rmax = gext[0].second; + const real_t lo[3] = { mirror ? -rmax : ZERO, -rmax, -cf.dx[0] }; + const real_t hi[3] = { rmax, rmax, cf.dx[0] }; + std::size_t n_kept = 0; + lines2d = out::buildTubeSet(poly, eff_r, flc, vlo, vhi, lo, hi, + bucket_cf, n_kept); + fl_vmin = lines2d.vmin; + fl_vmax = lines2d.vmax; + fl_colormap = lines2d.colormap; + logger::Checkpoint("field lines (2D meridional): " + + std::to_string(poly.size()) + " lines, " + + std::to_string(n_kept) + " segments", + HERE); + } + have_fl = true; + } + bool rendered_any = false; for (const auto& scene : g_renderer.scenes()) { - Kokkos::deep_copy(bckp, ZERO); - if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { + // standalone `field = "fieldlines"` -> lines only (no heatmap fill); any + // other scene with `fieldlines = true` overlays them on its heatmap. + const bool fl_only = (scene.field == "fieldlines"); + const bool heatmap_on = not fl_only; + const bool show_lines = scene.show_fieldlines and have_fl; + if (heatmap_on) { + Kokkos::deep_copy(bckp, ZERO); + if (not prepareRenderScalar(params, *local_domain, scene.field, bckp)) { + continue; + } + CommunicateBckp(*local_domain, { 0, 1 }); + } else if (not have_fl) { + raise::Warning("output.render: 'fieldlines' scene needs a 2D run with " + "[output.render.fieldlines]; skipping", + HERE); continue; } - CommunicateBckp(*local_domain, { 0, 1 }); + const out::ContourSet& kc = show_lines ? contours : emptyc; + const out::TubeSet& kt = show_lines ? lines2d : emptyl; + // a standalone field-line scene colors its colorbar by |B| + out::Scene scene_cb = scene; + if (fl_only) { + scene_cb.tf.vmin = fl_vmin; + scene_cb.tf.vmax = fl_vmax; + scene_cb.tf.log_scale = false; + scene_cb.tf.colormap = fl_colormap; + if (scene_cb.label == "fieldlines") { + scene_cb.label = "|" + flc.field + "|"; + } + } out::SubImage sub; if (bw > 0 and bh > 0) { @@ -974,6 +1175,9 @@ namespace ntt { scene.tf.vmin, scene.tf.vmax, scene.tf.log_scale, + kc, + kt, + heatmap_on, image)); Kokkos::fence(); @@ -987,7 +1191,7 @@ namespace ntt { sub.rgba[p * 4 + 3] = image_h(p, 3); } } - g_renderer.compositeAndWrite(sub, order_key, scene, current_step); + g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step); rendered_any = true; } return rendered_any; diff --git a/src/output/render/fieldlines.h b/src/output/render/fieldlines.h index b1f24f4db..a6adbdd7c 100644 --- a/src/output/render/fieldlines.h +++ b/src/output/render/fieldlines.h @@ -133,6 +133,30 @@ namespace out { } // namespace fl_hidden + /** @brief A constant-color opaque LUT (monochrome field lines). */ + inline auto buildSolidLUT(real_t r, real_t g, real_t b, int n_lut) + -> array_t { + array_t lut { "fl_solid_lut", static_cast(n_lut) }; + auto h = Kokkos::create_mirror_view(lut); + for (int i = 0; i < n_lut; ++i) { + h(i, 0) = r; // opaque -> premultiplied == straight RGB + h(i, 1) = g; + h(i, 2) = b; + h(i, 3) = ONE; + } + Kokkos::deep_copy(lut, h); + return lut; + } + + /** @brief The field-line LUT: a single color if cfg.color is set, else by |B|. */ + inline auto buildLineLUT(const FieldLineConfig& cfg, int n_lut) + -> array_t { + if (cfg.color.size() == 3) { + return buildSolidLUT(cfg.color[0], cfg.color[1], cfg.color[2], n_lut); + } + return buildLUT(cfg.colormap, n_lut, { { ZERO, ONE }, { ONE, ONE } }); + } + /** * @brief Trace field lines through the coarse field by bidirectional RK4. * @param cf coarse, replicated physical field @@ -325,8 +349,9 @@ namespace out { auto lin = [&](int c0, int c1, int c2) -> std::size_t { return (static_cast(c2) * ts.gnc[1] + c1) * ts.gnc[0] + c0; }; - // opaque LUT: a tube sample paints a solid color (alpha==1) - ts.lut = buildLUT(cfg.colormap, ts.n_lut, { { ZERO, ONE }, { ONE, ONE } }); + // opaque LUT: a tube sample paints a solid color (alpha==1), by |B| or a + // single monochrome color when cfg.color is set + ts.lut = buildLineLUT(cfg, ts.n_lut); // 1) keep segments whose radius-padded AABB overlaps this domain AABB. // (We keep the whole segment, not a clipped piece: the kernel only ever @@ -452,6 +477,380 @@ namespace out { return ts; } + // ====================================================================== // + // 2D field lines == contours of the flux function psi // + // ====================================================================== // + + /** + * @brief A coarse, MPI-replicated copy of the in-plane (Bx, By) field. + * @note Component-fastest: (c0,c1,comp) lives at (c1*n0 + c0)*2 + comp. + */ + struct CoarseField2D { + std::vector B; // n0*n1*2 + int n[2] { 0, 0 }; + real_t origin[2] { ZERO, ZERO }; + real_t dx[2] { ONE, ONE }; + }; + + /** + * @brief Integrate the flux function psi(x,y) from the coarse in-plane field. + * @note psi obeys Bx = d psi/dy, By = -d psi/dx; the trapezoidal cumulative + * integral (one pass along x at j=0, then up each column) is path-consistent + * up to div(B) = 0. Because cf is globally replicated, every rank gets the + * SAME psi -> contour levels are identical everywhere -> seamless lines. + * @param[out] psi (n0*n1) flux function, c0-fastest + * @param[out] psi_min,psi_max flux range (for level spacing) + * @param[out] bmin,bmax |B| range (for contour coloring) + */ + inline void computeFlux2D(const CoarseField2D& cf, + std::vector& psi, + real_t& psi_min, + real_t& psi_max, + real_t& bmin, + real_t& bmax) { + const int nx = cf.n[0], ny = cf.n[1]; + psi.assign(static_cast(nx) * ny, ZERO); + auto B = [&](int i, int j, int c) -> real_t { + return cf.B[(static_cast(j) * nx + i) * 2 + c]; + }; + auto P = [&](int i, int j) -> real_t& { + return psi[static_cast(j) * nx + i]; + }; + // bottom row (j = 0): d psi/dx = -By, trapezoidal in x + for (int i = 1; i < nx; ++i) { + P(i, 0) = P(i - 1, 0) - HALF * (B(i - 1, 0, 1) + B(i, 0, 1)) * cf.dx[0]; + } + // each column: d psi/dy = +Bx, trapezoidal in y + for (int i = 0; i < nx; ++i) { + for (int j = 1; j < ny; ++j) { + P(i, j) = P(i, j - 1) + HALF * (B(i, j - 1, 0) + B(i, j, 0)) * cf.dx[1]; + } + } + psi_min = static_cast(1e30); + psi_max = static_cast(-1e30); + bmin = static_cast(1e30); + bmax = static_cast(-1e30); + for (int j = 0; j < ny; ++j) { + for (int i = 0; i < nx; ++i) { + const real_t p = P(i, j); + psi_min = std::min(psi_min, p); + psi_max = std::max(psi_max, p); + const real_t bx = B(i, j, 0), by = B(i, j, 1); + const real_t b = std::sqrt(bx * bx + by * by); + bmin = std::min(bmin, b); + bmax = std::max(bmax, b); + } + } + if (psi_min > psi_max) { + psi_min = ZERO; + psi_max = ONE; + } + if (bmin > bmax) { + bmin = ZERO; + bmax = ONE; + } + } + + /** + * @brief Pack the flux function + contour parameters into a device ContourSet. + * @param line_half_px half the contour line width, in screen pixels + * @param wpp world units per screen pixel (sets the screen-space line width) + */ + inline auto buildContourSet(const CoarseField2D& cf, + const std::vector& psi, + real_t psi_min, + real_t psi_max, + real_t bmin, + real_t bmax, + const FieldLineConfig& cfg, + real_t line_half_px, + real_t wpp) -> ContourSet { + ContourSet cs; + cs.n0 = cf.n[0]; + cs.n1 = cf.n[1]; + cs.origin0 = cf.origin[0]; + cs.origin1 = cf.origin[1]; + cs.dx0 = cf.dx[0]; + cs.dx1 = cf.dx[1]; + const int nlev = std::max(1, cfg.levels); + cs.dlevel = (psi_max > psi_min) + ? (psi_max - psi_min) / static_cast(nlev) + : ONE; + cs.psi_ref = psi_min; + cs.line_half_px = line_half_px; + cs.wpp = wpp; + cs.colormap = cfg.colormap; + cs.n_lut = 256; + real_t vlo = bmin, vhi = bmax; + if (cfg.vmax > cfg.vmin) { // explicit |B| color range overrides auto + vlo = cfg.vmin; + vhi = cfg.vmax; + } + cs.vmin = vlo; + cs.vmax = (vhi > vlo) ? vhi : (vlo + ONE); + cs.lut = buildLineLUT(cfg, cs.n_lut); // by |B| or monochrome (cfg.color) + const std::size_t n = static_cast(cs.n0) * cs.n1; + cs.psi = array_t("fl_psi", std::max(n, 1)); + if (n > 0) { + auto h = Kokkos::create_mirror_view(cs.psi); + for (std::size_t k = 0; k < n; ++k) { + h(k) = psi[k]; + } + Kokkos::deep_copy(cs.psi, h); + } + cs.enabled = true; + return cs; + } + + /** @brief A valid but empty contour set (scene shows no 2D field lines). */ + inline auto emptyContourSet() -> ContourSet { + ContourSet cs; + cs.enabled = false; + cs.psi = array_t("fl_psi_empty", 1); + cs.lut = buildLUT("inferno", 2, { { ZERO, ONE }, { ONE, ONE } }); + return cs; + } + + // ====================================================================== // + // 2D spherical / Kerr-Schild == traced meridional streamlines (nt2py) // + // ====================================================================== // + + namespace fl_hidden { + // bilinear sample of the (Br, Btheta) coarse field at physical (r, theta); + // false if (r, theta) lies outside the grid (the integrator stops there). + inline auto sampleRTh(const CoarseField2D& cf, real_t r, real_t th, real_t B[2]) + -> bool { + const real_t rmin = cf.origin[0], thmin = cf.origin[1]; + const real_t rmax = rmin + cf.n[0] * cf.dx[0]; + const real_t thmax = thmin + cf.n[1] * cf.dx[1]; + const real_t tr = HALF * cf.dx[0], tt = HALF * cf.dx[1]; + if (r < rmin - tr or r > rmax + tr or th < thmin - tt or th > thmax + tt) { + return false; + } + int i0, i1, j0, j1; + real_t a0 = ZERO, a1 = ZERO; + if (cf.n[0] <= 1) { + i0 = 0; + i1 = 0; + } else { + const real_t g = (r - rmin) / cf.dx[0] - HALF; + const real_t f = std::floor(g); + int b = static_cast(f); + a0 = g - f; + if (b < 0) { + b = 0; + a0 = ZERO; + } else if (b > cf.n[0] - 2) { + b = cf.n[0] - 2; + a0 = ONE; + } + i0 = b; + i1 = b + 1; + } + if (cf.n[1] <= 1) { + j0 = 0; + j1 = 0; + } else { + const real_t g = (th - thmin) / cf.dx[1] - HALF; + const real_t f = std::floor(g); + int b = static_cast(f); + a1 = g - f; + if (b < 0) { + b = 0; + a1 = ZERO; + } else if (b > cf.n[1] - 2) { + b = cf.n[1] - 2; + a1 = ONE; + } + j0 = b; + j1 = b + 1; + } + for (int c = 0; c < 2; ++c) { + const real_t c00 = cf.B[(static_cast(j0) * cf.n[0] + i0) * 2 + c]; + const real_t c10 = cf.B[(static_cast(j0) * cf.n[0] + i1) * 2 + c]; + const real_t c01 = cf.B[(static_cast(j1) * cf.n[0] + i0) * 2 + c]; + const real_t c11 = cf.B[(static_cast(j1) * cf.n[0] + i1) * 2 + c]; + const real_t e0 = c00 * (ONE - a0) + c10 * a0; + const real_t e1 = c01 * (ONE - a0) + c11 * a0; + B[c] = e0 * (ONE - a1) + e1 * a1; + } + return true; + } + } // namespace fl_hidden + + /** + * @brief Trace poloidal field lines in the meridional (X, Z) plane (nt2py + * style): integrate (Fx, Fz) = (Br sin th + Bth cos th, Br cos th - Bth sin th) + * by bidirectional RK4 through the coarse (r, theta) field. Polylines are + * returned in meridional world coords (z = 0 so they reuse the 3D tube + * builder); with `mirror` the X<0 half is added as the theta-reflected copy. + * @param cf coarse field: component 0 = Br, 1 = Btheta, grid in (r, theta) + * @param world_per_pixel meridional world units per pixel (seed/length scale) + */ + inline auto traceFieldLinesMeridional(const CoarseField2D& cf, + const FieldLineConfig& cfg, + real_t world_per_pixel, + bool mirror, + real_t& out_vmin, + real_t& out_vmax) + -> std::vector { + using fl_hidden::sampleRTh; + std::vector lines; + if (cf.n[0] < 1 or cf.n[1] < 1) { + return lines; + } + const real_t rmin = cf.origin[0], thmin = cf.origin[1]; + const real_t rmax = rmin + cf.n[0] * cf.dx[0]; + const real_t thmax = thmin + cf.n[1] * cf.dx[1]; + const real_t h = std::max(cfg.step_frac, static_cast(1e-3)) * + cf.dx[0]; // step ~ a coarse dr (a length) + const real_t max_len = cfg.max_len_frac * rmax * static_cast(2); + const real_t eps = static_cast(1e-20); + + auto bmag = [&](real_t X, real_t Z) -> real_t { + const real_t r = std::sqrt(X * X + Z * Z); + const real_t th = std::atan2(std::abs(X), Z); + real_t B[2]; + if (not sampleRTh(cf, r, th, B)) { + return ZERO; + } + return std::sqrt(B[0] * B[0] + B[1] * B[1]); + }; + // unit meridional direction (x dir); false if |F| ~ 0 or outside the grid + auto deriv = [&](const real_t p[2], real_t dir, real_t out[2]) -> bool { + const real_t X = p[0], Z = p[1]; + const real_t r = std::sqrt(X * X + Z * Z); + const real_t th = std::atan2(std::abs(X), Z); + real_t B[2]; + if (not sampleRTh(cf, r, th, B)) { + return false; + } + const real_t st = std::sin(th), ct = std::cos(th); + // (Br, Bth) -> meridional Cartesian; sign of the X-component follows X so + // a line seeded in X>=0 stays in X>=0 (the X<0 half is the mirror image) + const real_t sgn = (X < ZERO) ? -ONE : ONE; + const real_t Fx = sgn * (B[0] * st + B[1] * ct); + const real_t Fz = B[0] * ct - B[1] * st; + const real_t m = std::sqrt(Fx * Fx + Fz * Fz); + if (m < eps) { + return false; + } + const real_t inv = dir / m; + out[0] = Fx * inv; + out[1] = Fz * inv; + return true; + }; + + out_vmin = static_cast(1e30); + out_vmax = static_cast(-1e30); + auto track = [&](real_t m) { + out_vmin = std::min(out_vmin, m); + out_vmax = std::max(out_vmax, m); + }; + auto inDomain = [&](real_t X, real_t Z) -> bool { + const real_t r = std::sqrt(X * X + Z * Z); + const real_t th = std::atan2(std::abs(X), Z); + return (r >= rmin and r <= rmax and th >= thmin and th <= thmax); + }; + + auto integrate = [&](const real_t seed[2], real_t dir) { + Polyline pl; + real_t p[2] = { seed[0], seed[1] }; + real_t m0 = bmag(p[0], p[1]); + if (m0 < eps) { + return; + } + pl.pts.push_back({ p[0], p[1], ZERO }); + pl.scal.push_back(m0); + track(m0); + real_t len = ZERO; + for (int step = 0; step < cfg.max_steps and len < max_len; ++step) { + real_t k1[2], k2[2], k3[2], k4[2], q[2]; + if (not deriv(p, dir, k1)) { + break; + } + q[0] = p[0] + HALF * h * k1[0]; + q[1] = p[1] + HALF * h * k1[1]; + if (not deriv(q, dir, k2)) { + break; + } + q[0] = p[0] + HALF * h * k2[0]; + q[1] = p[1] + HALF * h * k2[1]; + if (not deriv(q, dir, k3)) { + break; + } + q[0] = p[0] + h * k3[0]; + q[1] = p[1] + h * k3[1]; + if (not deriv(q, dir, k4)) { + break; + } + p[0] += (h / static_cast(6)) * + (k1[0] + static_cast(2) * k2[0] + + static_cast(2) * k3[0] + k4[0]); + p[1] += (h / static_cast(6)) * + (k1[1] + static_cast(2) * k2[1] + + static_cast(2) * k3[1] + k4[1]); + if (not inDomain(p[0], p[1])) { + break; + } + const real_t m = bmag(p[0], p[1]); + pl.pts.push_back({ p[0], p[1], ZERO }); + pl.scal.push_back(m); + track(m); + len += h; + } + if (pl.pts.size() >= 2) { + lines.push_back(std::move(pl)); + } + }; + + // seed lattice over the X>=0 meridional half, keeping in-domain seeds + const real_t Xhi = rmax, Zlo = -rmax, Zhi = rmax; + real_t spacing = std::max(cfg.seed_px, ONE) * world_per_pixel; + auto gridCount = [&](real_t s) -> long { + const long nx = std::max(1L, static_cast(std::floor(Xhi / s))); + const long nz = std::max(1L, static_cast(std::floor((Zhi - Zlo) / s))); + return nx * nz; + }; + if (gridCount(spacing) > cfg.seed_max and cfg.seed_max > 0) { + spacing *= std::sqrt(static_cast(gridCount(spacing)) / + static_cast(cfg.seed_max)); + } + const long nx = std::max(1L, static_cast(std::floor(Xhi / spacing))); + const long nz = std::max(1L, + static_cast(std::floor((Zhi - Zlo) / spacing))); + for (long iz = 0; iz < nz; ++iz) { + for (long ix = 0; ix < nx; ++ix) { + const real_t X = (static_cast(ix) + HALF) * Xhi / + static_cast(nx); + const real_t Z = Zlo + (static_cast(iz) + HALF) * (Zhi - Zlo) / + static_cast(nz); + if (not inDomain(X, Z)) { + continue; + } + const real_t seed[2] = { X, Z }; + integrate(seed, ONE); + integrate(seed, -ONE); + } + } + if (out_vmin > out_vmax) { + out_vmin = ZERO; + out_vmax = ONE; + } + // mirror the traced (X>=0) lines into the X<0 half for a full disk + if (mirror) { + const std::size_t n0 = lines.size(); + for (std::size_t i = 0; i < n0; ++i) { + Polyline m = lines[i]; + for (auto& q : m.pts) { + q[0] = -q[0]; + } + lines.push_back(std::move(m)); + } + } + return lines; + } + } // namespace out #endif // OUTPUT_RENDER_FIELDLINES_H diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 30ee7c578..9b32a1e82 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -300,10 +300,12 @@ namespace out { "fieldlines", "enable", false) or any_fl; if (m_fieldlines.enable) { - if (m_global_extent.size() != 3) { - raise::Warning("output.render.fieldlines is 3D-only; ignoring", HERE); + if (m_global_extent.size() != 2 and m_global_extent.size() != 3) { + raise::Warning("output.render.fieldlines needs a 2D or 3D run; ignoring", + HERE); m_fieldlines.enable = false; } else { + // 3D -> traced tubes inside the volume; 2D -> flux-function contours auto& fl = m_fieldlines; fl.field = toml::find_or(td, "output", "render", "fieldlines", "field", "B"); @@ -317,6 +319,16 @@ namespace out { fl.colormap = toml::find_or(td, "output", "render", "fieldlines", "colormap", "inferno"); + // optional monochrome color [r,g,b]; overrides the colormap when set + fl.color = toml::find_or>(td, "output", "render", + "fieldlines", "color", + std::vector {}); + if (not fl.color.empty() and fl.color.size() != 3) { + raise::Warning("output.render.fieldlines.color must have 3 entries " + "[r,g,b]; ignoring", + HERE); + fl.color.clear(); + } fl.log_scale = toml::find_or(td, "output", "render", "fieldlines", "log", false); fl.vmin = toml::find_or(td, "output", "render", "fieldlines", @@ -332,6 +344,8 @@ namespace out { static_cast(3)); fl.seed_max = toml::find_or(td, "output", "render", "fieldlines", "seed_max", 4096); + fl.levels = toml::find_or(td, "output", "render", "fieldlines", + "levels", 16); } } diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 72f1c170d..75e4a45c4 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -85,6 +85,10 @@ namespace out { real_t seed_px { 8 }; // seed lattice spacing in screen pixels real_t tube_px { 2 }; // tube radius in screen pixels std::string colormap { "inferno" }; + // monochrome override: when this holds 3 entries [r,g,b] in [0,1] the lines + // are drawn in that single color instead of the |B| colormap (reads well as + // an overlay on a density/other volume). Empty => color by |B|. + std::vector color {}; bool log_scale { false }; real_t vmin { ZERO }; // tube color range; vmin>=vmax => auto |B| real_t vmax { ZERO }; @@ -92,6 +96,34 @@ namespace out { int max_steps { 4000 }; // per-direction integration cap real_t max_len_frac { static_cast(3) }; // x global box diagonal int seed_max { 4096 }; // hard cap on seed count (spacing grows to fit) + // 2D only: number of evenly-spaced flux-function contour levels (field lines + // in 2D are iso-contours of the out-of-plane vector potential psi) + int levels { 16 }; + }; + + /** + * @brief Device-side 2D field-line geometry: the flux function psi on a coarse + * world grid, contoured per-pixel by the slice rasterizer. + * @note In 2D the in-plane field lines are the iso-contours of the flux + * function psi (Bx = d psi/dy, By = -d psi/dx). psi is integrated on a coarse, + * MPI-replicated copy of the field so the contour levels are global -> the + * lines are seamless across domains. The kernel draws a contour where psi is + * within a (screen-space) line width of a level, colored by |B| = |grad psi|. + */ + struct ContourSet { + array_t psi; // (n0*n1) flux function, c0-fastest + int n0 { 0 }, n1 { 0 }; + real_t origin0 { ZERO }, origin1 { ZERO }; + real_t dx0 { ONE }, dx1 { ONE }; + real_t dlevel { ONE }; // contour spacing in flux units + real_t psi_ref { ZERO }; // reference (zeroth) level + real_t line_half_px { ONE }; // half contour-line width, pixels + real_t wpp { ONE }; // world units per screen pixel + array_t lut; // opaque colormap, by |B| = |grad psi| + int n_lut { 256 }; + real_t vmin { ZERO }, vmax { ONE }; // |B| color range + bool enabled { false }; + std::string colormap { "inferno" }; // for the standalone colorbar }; /** diff --git a/src/output/render/slice2d.hpp b/src/output/render/slice2d.hpp index 335aab0aa..9f798b0b8 100644 --- a/src/output/render/slice2d.hpp +++ b/src/output/render/slice2d.hpp @@ -66,6 +66,36 @@ namespace kernel { const real_t vlo, vhi; const bool log_scale; + // 2D field-line contours: iso-levels of the flux function psi on a coarse + // world grid, colored by |B| = |grad psi|. Cartesian only; drawn where psi + // is within `cline_half_px` (screen px) of a level. `heatmap_on` false -> + // standalone contours (no scalar fill). + array_t cpsi; + const int cn0, cn1; + const real_t corigin0, corigin1, cdx0, cdx1; + const real_t cdlevel, cpsi_ref, cline_half_px, cwpp; + array_t clut; + const int cn_lut; + const real_t cvmin, cvmax; + const bool contour_on; + + // spherical/Kerr field lines: traced meridional streamlines drawn as lines, + // reusing the 3D tube segment-bucket geometry at z == 0 (queried at the + // pixel's (X, Z) world point). Cartesian uses the contours above instead. + array_t lseg; + array_t lcell_start; + array_t lseg_idx; + const int ln_seg; + const real_t line_r2; + const int lgnc0, lgnc1, lgnc2; + const real_t lg0, lg1, lg2, ldx0, ldx1, ldx2; + array_t line_lut; + const int line_n_lut; + const real_t line_vmin, line_vmax; + const bool line_on; + + const bool heatmap_on; + array_t image; // output, (bw*bh, 4) premultiplied RGBA public: @@ -91,6 +121,9 @@ namespace kernel { real_t vlo_, real_t vhi_, bool log_scale_, + const out::ContourSet& contours_, + const out::TubeSet& lines_, + bool heatmap_enabled_, const array_t& image_) : Fld { Fld_ } , comp { comp_ } @@ -114,6 +147,42 @@ namespace kernel { , vlo { vlo_ } , vhi { vhi_ } , log_scale { log_scale_ } + , cpsi { contours_.psi } + , cn0 { contours_.n0 } + , cn1 { contours_.n1 } + , corigin0 { contours_.origin0 } + , corigin1 { contours_.origin1 } + , cdx0 { contours_.dx0 } + , cdx1 { contours_.dx1 } + , cdlevel { contours_.dlevel } + , cpsi_ref { contours_.psi_ref } + , cline_half_px { contours_.line_half_px } + , cwpp { contours_.wpp } + , clut { contours_.lut } + , cn_lut { contours_.n_lut } + , cvmin { contours_.vmin } + , cvmax { contours_.vmax } + , contour_on { contours_.enabled } + , lseg { lines_.seg } + , lcell_start { lines_.cell_start } + , lseg_idx { lines_.seg_idx } + , ln_seg { lines_.n_seg } + , line_r2 { lines_.radius * lines_.radius } + , lgnc0 { lines_.gnc[0] } + , lgnc1 { lines_.gnc[1] } + , lgnc2 { lines_.gnc[2] } + , lg0 { lines_.gorigin[0] } + , lg1 { lines_.gorigin[1] } + , lg2 { lines_.gorigin[2] } + , ldx0 { lines_.gdx[0] } + , ldx1 { lines_.gdx[1] } + , ldx2 { lines_.gdx[2] } + , line_lut { lines_.lut } + , line_n_lut { lines_.n_lut } + , line_vmin { lines_.vmin } + , line_vmax { lines_.vmax } + , line_on { lines_.n_seg > 0 } + , heatmap_on { heatmap_enabled_ } , image { image_ } {} // bilinear sample of the prepared scalar at continuous code coords @@ -138,6 +207,98 @@ namespace kernel { return c0 * (ONE - t1) + c1 * t1; } + // bilinear sample of the coarse flux function psi at world point (x, y) + Inline auto sampleFlux(real_t x, real_t y) const -> real_t { + if (cn0 <= 0 or cn1 <= 0) { + return ZERO; + } + int i0, i1, j0, j1; + real_t t0 = ZERO, t1 = ZERO; + if (cn0 <= 1) { + i0 = 0; + i1 = 0; + } else { + const real_t g0 = (x - corigin0) / cdx0 - HALF; + const real_t f0 = math::floor(g0); + int b0 = static_cast(f0); + t0 = g0 - f0; + if (b0 < 0) { + b0 = 0; + t0 = ZERO; + } else if (b0 > cn0 - 2) { + b0 = cn0 - 2; + t0 = ONE; + } + i0 = b0; + i1 = b0 + 1; + } + if (cn1 <= 1) { + j0 = 0; + j1 = 0; + } else { + const real_t g1 = (y - corigin1) / cdx1 - HALF; + const real_t f1 = math::floor(g1); + int b1 = static_cast(f1); + t1 = g1 - f1; + if (b1 < 0) { + b1 = 0; + t1 = ZERO; + } else if (b1 > cn1 - 2) { + b1 = cn1 - 2; + t1 = ONE; + } + j0 = b1; + j1 = b1 + 1; + } + const real_t c00 = cpsi(j0 * cn0 + i0); + const real_t c10 = cpsi(j0 * cn0 + i1); + const real_t c01 = cpsi(j1 * cn0 + i0); + const real_t c11 = cpsi(j1 * cn0 + i1); + const real_t c0 = c00 * (ONE - t0) + c10 * t0; + const real_t c1 = c01 * (ONE - t0) + c11 * t0; + return c0 * (ONE - t1) + c1 * t1; + } + + // is meridional world point (x, z) within the line width of any traced + // streamline segment? Same bucketed distance-to-segment test as the 3D + // tube kernel, restricted to the z == 0 plane. On a hit, `scalar` is |B|. + Inline auto inLine(real_t x, real_t z, real_t& scalar) const -> bool { + if (ln_seg <= 0) { + return false; + } + const int c0 = static_cast(math::floor((x - lg0) / ldx0)); + const int c1 = static_cast(math::floor((z - lg1) / ldx1)); + const int c2 = static_cast(math::floor((ZERO - lg2) / ldx2)); + if (c0 < 0 or c0 >= lgnc0 or c1 < 0 or c1 >= lgnc1 or c2 < 0 or + c2 >= lgnc2) { + return false; + } + const int lin = (c2 * lgnc1 + c1) * lgnc0 + c0; + const int kb = lcell_start(lin); + const int ke = lcell_start(lin + 1); + real_t best = line_r2; + bool hit = false; + for (int k = kb; k < ke; ++k) { + const int s = lseg_idx(k); + const real_t ax = lseg(s, 0), az = lseg(s, 1); // (X, Z) in slots 0,1 + const real_t bx = lseg(s, 3), bz = lseg(s, 4); + const real_t ex = bx - ax, ez = bz - az; + const real_t wx = x - ax, wz = z - az; + const real_t ee = ex * ex + ez * ez; + real_t tt = (ee > ZERO) ? (wx * ex + wz * ez) / ee : ZERO; + tt = (tt < ZERO) ? ZERO : ((tt > ONE) ? ONE : tt); + const real_t cx = ax + tt * ex, cz = az + tt * ez; + const real_t dx = x - cx, dz = z - cz; + const real_t d2 = dx * dx + dz * dz; + if (d2 < best) { + best = d2; + scalar = lseg(s, 6) * (ONE - tt) + lseg(s, 7) * tt; + hit = true; + } + } + return hit; + } + Inline void operator()(cellidx_t lpx, cellidx_t lpy) const { const auto pix = static_cast(lpy) * static_cast(bw) + @@ -176,32 +337,113 @@ namespace kernel { return; } - const real_t s = sample(cc1, cc2); - // normalize through the transfer-function range - const real_t inv_range = (vhi > vlo) ? (ONE / (vhi - vlo)) : ZERO; - real_t uu; - if (log_scale) { - const real_t log_vlo = math::log10(vlo); - uu = (s > ZERO) ? (math::log10(s) - log_vlo) * inv_range : -ONE; - } else { - uu = (s - vlo) * inv_range; + real_t cr = ZERO, cg = ZERO, cb = ZERO; + bool painted = false; + if (heatmap_on) { + const real_t s = sample(cc1, cc2); + // normalize through the transfer-function range + const real_t inv_range = (vhi > vlo) ? (ONE / (vhi - vlo)) : ZERO; + real_t uu; + if (log_scale) { + const real_t log_vlo = math::log10(vlo); + uu = (s > ZERO) ? (math::log10(s) - log_vlo) * inv_range : -ONE; + } else { + uu = (s - vlo) * inv_range; + } + if (uu < ZERO) { + uu = ZERO; + } else if (uu > ONE) { + uu = ONE; + } + int idx = static_cast(uu * static_cast(n_lut - 1) + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > n_lut - 1) { + idx = n_lut - 1; + } + // opaque LUT: premultiplied with alpha == 1, so this is straight RGB + cr = lut(idx, 0); + cg = lut(idx, 1); + cb = lut(idx, 2); + painted = true; } - if (uu < ZERO) { - uu = ZERO; - } else if (uu > ONE) { - uu = ONE; + // field-line contours: iso-levels of the flux function psi (Cartesian + // only, where (u, v) IS the (x, y) world plane). Drawn over the heatmap + // and colored by |B| = |grad psi|; a screen-space line width keeps the + // contours ~constant thickness regardless of local gradient. + if constexpr (M::CoordType == Coord::Cartesian) { + if (contour_on) { + const real_t psi0 = sampleFlux(u, v); + const real_t pl = sampleFlux(u - cwpp, v); + const real_t pr = sampleFlux(u + cwpp, v); + const real_t pd = sampleFlux(u, v - cwpp); + const real_t pup = sampleFlux(u, v + cwpp); + const real_t gx = (pr - pl) / (TWO * cwpp); + const real_t gy = (pup - pd) / (TWO * cwpp); + const real_t g = math::sqrt(gx * gx + gy * gy); // |B| + const real_t tlev = (cdlevel > ZERO) ? (psi0 - cpsi_ref) / cdlevel + : ZERO; + const real_t nlev = math::floor(tlev + HALF); // nearest level index + const real_t d_world = math::abs(psi0 - (cpsi_ref + nlev * cdlevel)); + const real_t eps = static_cast(1e-30); + const real_t d_screen = (g > eps) ? (d_world / (g * cwpp)) + : static_cast(1e30); + if (d_screen <= cline_half_px) { + const real_t invr = (cvmax > cvmin) ? (ONE / (cvmax - cvmin)) : ZERO; + real_t uu = (g - cvmin) * invr; + if (uu < ZERO) { + uu = ZERO; + } else if (uu > ONE) { + uu = ONE; + } + int idx = static_cast(uu * static_cast(cn_lut - 1) + + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > cn_lut - 1) { + idx = cn_lut - 1; + } + cr = clut(idx, 0); + cg = clut(idx, 1); + cb = clut(idx, 2); + painted = true; + } + } + } else { + // spherical/Kerr: traced meridional streamlines. (u, v) is the (X, Z) + // world point; draw a line where it falls within a segment's width. + if (line_on) { + real_t sB; + if (inLine(u, v, sB)) { + const real_t invr = (line_vmax > line_vmin) + ? (ONE / (line_vmax - line_vmin)) + : ZERO; + real_t uu = (sB - line_vmin) * invr; + if (uu < ZERO) { + uu = ZERO; + } else if (uu > ONE) { + uu = ONE; + } + int idx = static_cast(uu * static_cast(line_n_lut - 1) + + HALF); + if (idx < 0) { + idx = 0; + } else if (idx > line_n_lut - 1) { + idx = line_n_lut - 1; + } + cr = line_lut(idx, 0); + cg = line_lut(idx, 1); + cb = line_lut(idx, 2); + painted = true; + } + } } - int idx = static_cast(uu * static_cast(n_lut - 1) + HALF); - if (idx < 0) { - idx = 0; - } else if (idx > n_lut - 1) { - idx = n_lut - 1; + if (painted) { + image(pix, 0) = cr; + image(pix, 1) = cg; + image(pix, 2) = cb; + image(pix, 3) = ONE; } - // opaque LUT: premultiplied with alpha == 1, so this is straight RGB - image(pix, 0) = lut(idx, 0); - image(pix, 1) = lut(idx, 1); - image(pix, 2) = lut(idx, 2); - image(pix, 3) = ONE; } }; From 4c6632517f1f7dd807ff4c9d4d727781fefebc6d Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Tue, 30 Jun 2026 23:54:06 -0400 Subject: [PATCH 14/20] option to plot time label --- input.example.toml | 6 +++ src/framework/domain/metadomain_render.cpp | 6 ++- src/output/render/colorbar.h | 1 + src/output/render/renderer.cpp | 46 +++++++++++++++++++++- src/output/render/renderer.h | 6 ++- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/input.example.toml b/input.example.toml index a43ad92cd..96b86884c 100644 --- a/input.example.toml +++ b/input.example.toml @@ -761,6 +761,12 @@ # @type: bool # @default: true mirror = "" + # Draw the current simulation time as a label ("T = ", fixed to 2 + # decimals) in the upper-right corner of the render region, in a contrasting + # color, vertically centered between the frame top and the colorbar. + # @type: bool + # @default: false + time_label = "" # Draw a spine (frame) + axis ticks + labels around the rendered region. # The PNG gains left/bottom margins (background-filled) for the tick labels # and axis names, so they never overlap the data. diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index b6ba21c04..2de1acd03 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -862,7 +862,8 @@ namespace ntt { sub.rgba[p * 4 + 3] = image_h(p, 3); } } - g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step); + g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step, + current_time); rendered_any = true; } return rendered_any; @@ -1191,7 +1192,8 @@ namespace ntt { sub.rgba[p * 4 + 3] = image_h(p, 3); } } - g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step); + g_renderer.compositeAndWrite(sub, order_key, scene_cb, current_step, + current_time); rendered_any = true; } return rendered_any; diff --git a/src/output/render/colorbar.h b/src/output/render/colorbar.h index 3c208b850..fbe52cb3b 100644 --- a/src/output/render/colorbar.h +++ b/src/output/render/colorbar.h @@ -53,6 +53,7 @@ namespace out { case '.': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00110, 0b00110 }; return g; } case '-': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000 }; return g; } case '+': { static const uint8_t g[7] = { 0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000 }; return g; } + case '=': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000 }; return g; } case '_': { static const uint8_t g[7] = { 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111 }; return g; } case ':': { static const uint8_t g[7] = { 0b00000, 0b00110, 0b00110, 0b00000, 0b00110, 0b00110, 0b00000 }; return g; } case '/': { static const uint8_t g[7] = { 0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000 }; return g; } diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 9b32a1e82..8a4668ff4 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -113,6 +114,10 @@ namespace out { // 2D slice mode (spherical only): mirror the half-plane into a full disk m_mirror = toml::find_or(td, "output", "render", "mirror", true); + // draw the current simulation time in the upper-right corner + m_time_label = toml::find_or(td, "output", "render", "time_label", + false); + // axes: spine + ticks + labels around the rendered region m_axes = toml::find_or(td, "output", "render", "axes", false); m_axis_nticks = toml::find_or(td, "output", "render", "axis_ticks", 5); @@ -356,7 +361,8 @@ namespace out { void Renderer::compositeAndWrite(const SubImage& sub, uint64_t order_key, const Scene& scene, - timestep_t step) const { + timestep_t step, + simtime_t time) const { const std::size_t npix = static_cast(m_width) * static_cast(m_height); const std::size_t n = npix * 4; @@ -418,6 +424,42 @@ namespace out { } }; + // upper-right corner label of the current simulation time. `data_left` is + // the x-offset of the render region inside the buffer (0 without axes, the + // left margin `ml` with axes), so the label sits in the render region's + // top-right, not over the colorbar strip. + auto drawTimeLabel = [&](uint8_t* buf, int cw, int ch, int data_left) { + if (not m_time_label) { + return; + } + const int s = cbar_hidden::scale(m_height); + char tbuf[48]; + // fixed-point so it reads e.g. "T = 12345.67" (up to 5 integer digits + // and 2 decimals; more integer digits still print, never truncated) + std::snprintf(tbuf, sizeof(tbuf), "T = %.2f", + static_cast(time)); + const std::string str(tbuf); + const int tw = static_cast(str.size()) * 6 * s; + const int pad = 3 * s; + const int tx = data_left + m_width - tw - pad; + // vertically center the label between the image top and the top of the + // colorbar bar. drawColorbar uses bar_h = ch/2, so the bar top is at + // bar_y = (ch - bar_h)/2 = ch/4; center the 7*s-tall glyphs in [0, bar_y]. + const int text_h = 7 * s; + const int bar_h = ch / 2; + const int cbar_top = m_colorbar ? (ch - bar_h) / 2 : (ch / 4); + int ty = (cbar_top - text_h) / 2; + if (ty < pad) { + ty = pad; + } + // contrasting text color (white on a dark background, black on light) + const real_t lum = static_cast(0.299) * m_background[0] + + static_cast(0.587) * m_background[1] + + static_cast(0.114) * m_background[2]; + const uint8_t tc = (lum < HALF) ? 255 : 0; + cbar_hidden::drawText(buf, cw, ch, tx, ty, str, s, tc, tc, tc); + }; + // canvas margins: axes (left + bottom) and the colorbar strip (right). // The data region sits at (ml, 0); margins/strip are background-filled. // The polar (curvilinear) overlay annotates inside the data region (the @@ -435,6 +477,7 @@ namespace out { if (CW == m_width and CH == m_height and not m_axes) { // no margins, no outside strip, no overlay: colorbar overlays the data drawBar(data.data(), m_width, m_height); + drawTimeLabel(data.data(), m_width, m_height, 0); ok = write_png(fname, m_width, m_height, data.data()); } else { const uint8_t bR = quantize(m_background[0]); @@ -472,6 +515,7 @@ namespace out { } } drawBar(canvas.data(), CW, CH); + drawTimeLabel(canvas.data(), CW, CH, ml); ok = write_png(fname, CW, CH, canvas.data()); } if (not ok) { diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index 75e4a45c4..d7aa7b462 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -208,13 +208,15 @@ namespace out { * @param order_key this rank's front-to-back sort key (see composite.h) * @param scene the scene being written (prefix, colorbar colormap/range/label) * @param step current timestep (for the filename cycle number) + * @param time current simulation time (drawn as a corner label if enabled) * @note Uses an order-preserving distributed tree reduce; only the MPI root * rank assembles the full frame and writes the file. */ void compositeAndWrite(const SubImage& sub, uint64_t order_key, const Scene& scene, - timestep_t step) const; + timestep_t step, + simtime_t time) const; /* getters -------------------------------------------------------------- */ [[nodiscard]] @@ -351,6 +353,8 @@ namespace out { // 2D slice mode (spherical): mirror the meridional half-plane across the // symmetry axis to render a full disk from one axisymmetric half bool m_mirror { true }; + // draw the current simulation time as a label in the upper-right corner + bool m_time_label { false }; // draw a spine (frame) + axis ticks/labels around the rendered region bool m_axes { false }; bool m_axis_labels_set { false }; From 95f08f2b180605de641bbde35f44e57c99bb9879 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Wed, 1 Jul 2026 10:27:41 -0400 Subject: [PATCH 15/20] added option to provide axis limits to rendering --- input.example.toml | 12 +++ src/framework/domain/metadomain_render.cpp | 101 +++++++++++++++------ src/output/render/renderer.cpp | 42 ++++++++- src/output/render/renderer.h | 33 +++++++ src/output/render/slice2d.hpp | 28 +++++- 5 files changed, 182 insertions(+), 34 deletions(-) diff --git a/input.example.toml b/input.example.toml index 96b86884c..ce0b917bf 100644 --- a/input.example.toml +++ b/input.example.toml @@ -720,6 +720,18 @@ # @type: int [> 0] # @default: 1024 height = "" + # Axis-aligned render region [lo, hi] in physical/world coords, per axis + # (x1/x2/x3). Any axis left unset spans the full domain. Clamped to the box. + # @type: array [size 2] + # @default: [] (full extent) + # @note: 3D -> the volume is depth-clipped to this box, the wireframe/axes + # frame it, and the default camera zooms to it; 2D -> the slice + # window is framed to it. For a spherical 2D slice, x1_lim crops the + # radius r and x2_lim crops the polar angle theta. + # @example: x1_lim = [-64.0, 64.0] + x1_lim = "" + x2_lim = "" + x3_lim = "" # Number of ray-march steps across the global box diagonal # @type: int [> 0] # @default: 400 diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index 2de1acd03..679077fb3 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -663,16 +663,32 @@ namespace ntt { const int W = g_renderer.width(); const int H = g_renderer.height(); - // per-domain world AABB + // optional axis-aligned render region (== full extent when uncropped) + const real_t rlo[3] = { g_renderer.regionLo(0), g_renderer.regionLo(1), + g_renderer.regionLo(2) }; + const real_t rhi[3] = { g_renderer.regionHi(0), g_renderer.regionHi(1), + g_renderer.regionHi(2) }; + // per-domain world AABB, clipped to the region const auto loc_ext = local_domain->mesh.extent(); - real_t lo[3] = { loc_ext[0].first, loc_ext[1].first, loc_ext[2].first }; - real_t hi[3] = { loc_ext[0].second, loc_ext[1].second, loc_ext[2].second }; + real_t lo[3] = { math::max(loc_ext[0].first, rlo[0]), + math::max(loc_ext[1].first, rlo[1]), + math::max(loc_ext[2].first, rlo[2]) }; + real_t hi[3] = { math::min(loc_ext[0].second, rhi[0]), + math::min(loc_ext[1].second, rhi[1]), + math::min(loc_ext[2].second, rhi[2]) }; + // does this domain intersect the region? if not, render nothing (but still + // join the collective composite / field-line reduce below). + const bool in_region = (lo[0] < hi[0]) and (lo[1] < hi[1]) and + (lo[2] < hi[2]); - // fixed global world step (identical on all ranks -> seamless) + // global extent (drives the field-line coarse grid, which spans the full + // field regardless of the crop) const auto glob_ext = mesh().extent(); - real_t gdiag = ZERO; + // fixed world step, identical on all ranks -> seamless. Sized to the region + // diagonal so `samples` spans the (possibly cropped) view. + real_t gdiag = ZERO; for (auto d { 0 }; d < 3; ++d) { - const real_t s = glob_ext[d].second - glob_ext[d].first; + const real_t s = rhi[d] - rlo[d]; gdiag += s * s; } gdiag = math::sqrt(gdiag); @@ -681,14 +697,12 @@ namespace ntt { : gdiag / static_cast(g_renderer.samples()); const int max_steps = 2 * g_renderer.samples() + 16; - // global box + depth-occluded spine (opaque box wireframe rendered inline + // region box + depth-occluded spine (opaque box wireframe rendered inline // in the march so the volume covers its far edges). The visual width is // ~spine_width px; the 0.55*ds floor keeps the thin line gap-free at the // current sampling (raise `samples` for a crisper, thinner line). - real_t glo[3] = { glob_ext[0].first, glob_ext[1].first, - glob_ext[2].first }; - real_t ghi[3] = { glob_ext[0].second, glob_ext[1].second, - glob_ext[2].second }; + real_t glo[3] = { rlo[0], rlo[1], rlo[2] }; + real_t ghi[3] = { rhi[0], rhi[1], rhi[2] }; const real_t px_w = (cam.half_h * static_cast(2)) / static_cast(H); const real_t spine_radius = @@ -719,7 +733,8 @@ namespace ntt { // screen-space bounding box of this domain's footprint (same for all // scenes); we only ray-march and composite within it. int bx0 = 0, by0 = 0, bw = 0, bh = 0; - const bool on_screen = out::screenBBox(cam, W, H, lo, hi, bx0, by0, bw, bh); + const bool on_screen = in_region and + out::screenBBox(cam, W, H, lo, hi, bx0, by0, bw, bh); // ---- magnetic-field-line tubes (built once, shared by every scene) --- // // Every rank coarsens + replicates the field, traces the SAME global @@ -889,23 +904,47 @@ namespace ntt { const int H = g_renderer.height(); const bool mirror = g_renderer.mirror(); - // global slice-plane world window (shared by all ranks -> seamless) - const auto gext = mesh().extent(); - real_t umin, umax, vmin, vmax; + // global slice-plane world window (shared by all ranks -> seamless), + // taken from the optional render region (== full extent when uncropped). + // gext (the full extent) is kept for the field-line coarse grid below. + const auto gext = mesh().extent(); + const real_t x1lo = g_renderer.regionLo(0), x1hi = g_renderer.regionHi(0); + const real_t x2lo = g_renderer.regionLo(1), x2hi = g_renderer.regionHi(1); + real_t umin, umax, vmin, vmax; if constexpr (M::CoordType == Coord::type::Cartesian) { - umin = gext[0].first; - umax = gext[0].second; - vmin = gext[1].first; - vmax = gext[1].second; + umin = x1lo; + umax = x1hi; + vmin = x2lo; + vmax = x2hi; } else { - // meridional (X = r sin th, Z = r cos th) bounding box of the arc - const real_t rmax = gext[0].second; - const real_t th0 = gext[1].first; - const real_t th1 = gext[1].second; - umax = rmax; - umin = mirror ? -rmax : ZERO; - vmax = rmax * math::cos(th0); - vmin = rmax * math::cos(th1); + // meridional (X = r sin th, Z = r cos th) bounding box of the cropped + // annular wedge r in [x1lo, x1hi], theta in [x2lo, x2hi]. Sample the + // boundary (arcs + rays) so the bbox is correct for any theta range. + umin = static_cast(1e30); + umax = static_cast(-1e30); + vmin = static_cast(1e30); + vmax = static_cast(-1e30); + const int NB = 65; + auto accXZ = [&](real_t r, real_t th) { + const real_t X = r * math::sin(th), Z = r * math::cos(th); + umin = std::min(umin, X); + umax = std::max(umax, X); + vmin = std::min(vmin, Z); + vmax = std::max(vmax, Z); + if (mirror) { + umin = std::min(umin, -X); + umax = std::max(umax, -X); + } + }; + for (int k = 0; k < NB; ++k) { + const real_t t = static_cast(k) / static_cast(NB - 1); + const real_t th = x2lo + (x2hi - x2lo) * t; + const real_t rr = x1lo + (x1hi - x1lo) * t; + accXZ(x1lo, th); + accXZ(x1hi, th); + accXZ(rr, x2lo); + accXZ(rr, x2hi); + } } // expand the window to the image aspect (centered) so geometry is not // stretched @@ -951,8 +990,7 @@ namespace ntt { // curvilinear slices get polar axes (R radial + Theta arc); pass the // global (r, theta) extent. if (sph) { - g_renderer.setSlicePolar(true, gext[0].first, gext[0].second, - gext[1].first, gext[1].second, mirror); + g_renderer.setSlicePolar(true, x1lo, x1hi, x2lo, x2hi, mirror); } else { g_renderer.setSlicePolar(false, ZERO, ONE, ZERO, ONE, mirror); } @@ -1167,6 +1205,11 @@ namespace ntt { by0, bw, mirror, + x1lo, + x1hi, + x2lo, + x2hi, + g_renderer.hasRegion(), n1, n2, ext0, diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 8a4668ff4..7d38598ca 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -124,6 +124,39 @@ namespace out { m_spine_width = toml::find_or(td, "output", "render", "spine_width", static_cast(2)); m_global_extent = global_extent; + + // optional axis-aligned render region (physical coords). Unset axes default + // to the full extent; user limits are clamped to the box (nothing to render + // outside it). x{1,2,3}_lim -> axes {0,1,2} (r/theta for spherical 2D). + m_region = global_extent; + m_has_region = false; + { + const char* keys[3] = { "x1_lim", "x2_lim", "x3_lim" }; + for (std::size_t d = 0; d < global_extent.size() and d < 3; ++d) { + const auto lim = toml::find_or>( + td, "output", "render", keys[d], std::vector {}); + if (lim.empty()) { + continue; + } + if (lim.size() != 2 or lim[1] <= lim[0]) { + raise::Warning("output.render." + std::string(keys[d]) + + " must be [lo, hi] with hi > lo; ignoring", + HERE); + continue; + } + const real_t lo = std::max(lim[0], global_extent[d].first); + const real_t hi = std::min(lim[1], global_extent[d].second); + if (hi > lo) { + m_region[d] = { lo, hi }; + m_has_region = true; + } else { + raise::Warning("output.render." + std::string(keys[d]) + + " does not overlap the domain; ignoring", + HERE); + } + } + } + { const auto al = toml::find_or>( td, "output", "render", "axis_labels", std::vector {}); @@ -151,12 +184,13 @@ namespace out { /* ---- camera (used by the 3D volume mode; the 2D slice path frames itself * and ignores this, so a missing 3rd axis is zero-filled harmlessly) ---- */ + // frame the camera on the render region (== the full extent when uncropped) real_t center[3] = { ZERO, ZERO, ZERO }, size[3] = { ZERO, ZERO, ZERO }; real_t maxext = ZERO; - for (std::size_t d = 0; d < global_extent.size() and d < 3; ++d) { + for (std::size_t d = 0; d < m_region.size() and d < 3; ++d) { center[d] = static_cast(0.5) * - (global_extent[d].first + global_extent[d].second); - size[d] = global_extent[d].second - global_extent[d].first; + (m_region[d].first + m_region[d].second); + size[d] = m_region[d].second - m_region[d].first; maxext = (size[d] > maxext) ? size[d] : maxext; } const real_t diag = std::sqrt(size[0] * size[0] + size[1] * size[1] + @@ -499,7 +533,7 @@ namespace out { if (m_axes) { if (m_global_extent.size() == 3) { out::drawAxes3D(canvas.data(), CW, CH, ml, m_width, m_height, - m_camera_dev, m_global_extent, m_axis_labels, + m_camera_dev, m_region, m_axis_labels, m_background, m_axis_nticks); } else if (polar) { out::drawAxesPolar(canvas.data(), CW, CH, ml, m_width, m_height, diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index d7aa7b462..c71edc541 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -254,6 +254,34 @@ namespace out { return m_camera_dev; } + // Optional axis-aligned render region in physical/world coords. Always + // resolved (unset axes default to the full global extent), so the driver can + // use these unconditionally. `hasRegion()` reports whether any axis was + // overridden (e.g. to know a crop is active). `d` in {0,1,2} == {x1,x2,x3}. + [[nodiscard]] + auto hasRegion() const -> bool { + return m_has_region; + } + + [[nodiscard]] + auto regionLo(int d) const -> real_t { + const int k = (d < 0) ? 0 : ((d > 2) ? 2 : d); + return (static_cast(k) < m_region.size()) ? m_region[k].first + : ZERO; + } + + [[nodiscard]] + auto regionHi(int d) const -> real_t { + const int k = (d < 0) ? 0 : ((d > 2) ? 2 : d); + return (static_cast(k) < m_region.size()) ? m_region[k].second + : ZERO; + } + + [[nodiscard]] + auto region() const -> const boundaries_t& { + return m_region; + } + // 2D slice mode: mirror a spherical half-plane across the axis into a full // disk (no effect on Cartesian or 3D rendering). [[nodiscard]] @@ -364,6 +392,11 @@ namespace out { // global world box (2 or 3 axes); used to project the 3D axes box and to // know the render mode (size 2 => 2D slice, size 3 => 3D volume). boundaries_t m_global_extent; + // resolved render region [lo, hi] per axis (== global extent unless the + // user set x{1,2,3}_lim); the volume is clipped / the slice window is framed + // to this, and the default camera frames it. + boundaries_t m_region; + bool m_has_region { false }; // 2D slice world window + axis names, set per-frame by the templated Render real_t m_slice_win[4] { ZERO, ONE, ZERO, ONE }; std::string m_slice_xlabel { "x" }; diff --git a/src/output/render/slice2d.hpp b/src/output/render/slice2d.hpp index 9f798b0b8..7936cb3a1 100644 --- a/src/output/render/slice2d.hpp +++ b/src/output/render/slice2d.hpp @@ -56,6 +56,12 @@ namespace kernel { const int bx0, by0, bw; // screen-bbox offset and width (output stride) const bool mirror; // spherical: paint the X<0 reflected half too + // optional physical render-region clip: a pixel is drawn only if its + // coordinate is inside [rx1lo,rx1hi] x [rx2lo,rx2hi] (x1,x2 == x,y for + // Cartesian; r,theta for spherical). Off => the whole domain is drawn. + const real_t rx1lo, rx1hi, rx2lo, rx2hi; + const bool region_clip; + // local-domain active cell counts and View extents (membership + clamping) const real_t n1, n2; const int ext0, ext1; @@ -112,6 +118,11 @@ namespace kernel { int by0_, int bw_, bool mirror_, + real_t rx1lo_, + real_t rx1hi_, + real_t rx2lo_, + real_t rx2hi_, + bool region_clip_, int n1_, int n2_, int ext0_, @@ -138,6 +149,11 @@ namespace kernel { , by0 { by0_ } , bw { bw_ } , mirror { mirror_ } + , rx1lo { rx1lo_ } + , rx1hi { rx1hi_ } + , rx2lo { rx2lo_ } + , rx2hi { rx2hi_ } + , region_clip { region_clip_ } , n1 { static_cast(n1_) } , n2 { static_cast(n2_) } , ext0 { ext0_ } @@ -317,9 +333,15 @@ namespace kernel { const real_t v = vmax - (static_cast(gpy) + HALF) / static_cast(H) * (vmax - vmin); - // world -> continuous local code coords + // world -> continuous local code coords, with an optional physical + // render-region clip (so a crop hides domain data outside the region, not + // just reframes the view) real_t cc1, cc2; if constexpr (M::CoordType == Coord::Cartesian) { + if (region_clip and + (u < rx1lo or u > rx1hi or v < rx2lo or v > rx2hi)) { + return; + } cc1 = metric.template convert<1, Crd::Ph, Crd::Cd>(u); cc2 = metric.template convert<2, Crd::Ph, Crd::Cd>(v); } else { @@ -328,6 +350,10 @@ namespace kernel { } const real_t r = math::sqrt(u * u + v * v); const real_t th = math::atan2(math::abs(u), v); // in [0, pi] + if (region_clip and + (r < rx1lo or r > rx1hi or th < rx2lo or th > rx2hi)) { + return; + } cc1 = metric.template convert<1, Crd::Ph, Crd::Cd>(r); cc2 = metric.template convert<2, Crd::Ph, Crd::Cd>(th); } From 856d66ca7df0683d7ae688a0e47262422fee257f Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Wed, 1 Jul 2026 11:50:36 -0400 Subject: [PATCH 16/20] added moving camera to track e.g. shock fronts --- input.example.toml | 13 +++++++ src/framework/domain/metadomain_render.cpp | 8 ++++ src/output/render/renderer.cpp | 44 ++++++++++++++++++++++ src/output/render/renderer.h | 22 ++++++++++- 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/input.example.toml b/input.example.toml index ce0b917bf..08b43370c 100644 --- a/input.example.toml +++ b/input.example.toml @@ -732,6 +732,19 @@ x1_lim = "" x2_lim = "" x3_lim = "" + # Moving view: translate the render region (and, in 3D, the camera) at this + # velocity in world-units-per-sim-time, to keep a propagating feature (e.g. a + # shock) in frame. Pair with x{1,2,3}_lim (the moving window). 2D uses the + # first two components; the motion starts at `camera_start_time`. + # @type: array [size 2 or 3] + # @default: [] (static view) + # @example: camera_velocity = [0.9, 0.0] # pan along +x1 at 0.9 c + camera_velocity = "" + # Sim time at which the view starts moving (static before it, e.g. to let an + # initial ramp-up finish) + # @type: float + # @default: 0.0 + camera_start_time = "" # Number of ray-march steps across the global box diagonal # @type: int [> 0] # @default: 400 diff --git a/src/framework/domain/metadomain_render.cpp b/src/framework/domain/metadomain_render.cpp index 679077fb3..23535acd9 100644 --- a/src/framework/domain/metadomain_render.cpp +++ b/src/framework/domain/metadomain_render.cpp @@ -659,6 +659,10 @@ namespace ntt { HERE); logger::Checkpoint("Rendering output (3D volume)", HERE); + // advance the moving view (region + camera) to this frame's time before + // reading camera()/region(); collective (same time on all ranks). + g_renderer.updateForTime(current_time); + const auto& cam = g_renderer.camera(); const int W = g_renderer.width(); const int H = g_renderer.height(); @@ -900,6 +904,10 @@ namespace ntt { HERE); logger::Checkpoint("Rendering output (2D slice)", HERE); + // advance the moving view (region window) to this frame's time before + // reading region(); collective (same time on all ranks). + g_renderer.updateForTime(current_time); + const int W = g_renderer.width(); const int H = g_renderer.height(); const bool mirror = g_renderer.mirror(); diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 7d38598ca..003069f06 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -274,6 +274,30 @@ namespace out { m_camera_dev.half_h = static_cast(0.5) * ortho_height; m_camera_dev.half_w = m_camera_dev.half_h * m_camera_dev.aspect; + /* ---- moving view (pan the region/camera to track a feature) --------- */ + // remember the static region + camera eye; updateForTime() translates them. + m_region_base = m_region; + for (int d = 0; d < 3; ++d) { + m_eye_base[d] = m_camera_dev.eye[d]; + } + { + const auto vel = toml::find_or>( + td, "output", "render", "camera_velocity", std::vector {}); + for (std::size_t d = 0; d < vel.size() and d < 3; ++d) { + m_cam_vel[d] = vel[d]; + } + m_cam_moving = (m_cam_vel[0] != ZERO) or (m_cam_vel[1] != ZERO) or + (m_cam_vel[2] != ZERO); + m_cam_t0 = toml::find_or(td, "output", "render", + "camera_start_time", 0.0); + if (m_cam_moving and not m_has_region and global_extent.size() == 2) { + raise::Warning("output.render.camera_velocity set without x{1,2}_lim: " + "the 2D window will pan off the domain. Set a region to " + "track a feature within it.", + HERE); + } + } + /* ---- scenes --------------------------------------------------------- */ m_scenes.clear(); const auto scenes_arr = toml::find_or(td, @@ -392,6 +416,26 @@ namespace out { logger::Checkpoint("In-situ renderer initialized", HERE); } + void Renderer::updateForTime(simtime_t time) { + if (not m_cam_moving) { + return; + } + const real_t dt = static_cast( + (time > m_cam_t0) ? (time - m_cam_t0) : static_cast(0)); + const real_t shift[3] = { m_cam_vel[0] * dt, m_cam_vel[1] * dt, + m_cam_vel[2] * dt }; + // translate the render region (its width is preserved) + for (std::size_t d = 0; d < m_region.size() and d < 3; ++d) { + m_region[d] = { m_region_base[d].first + shift[d], + m_region_base[d].second + shift[d] }; + } + // translate the 3D camera by the same shift -- a pure pan: forward/right/up + // and the ortho height are unchanged, so only the eye moves. + for (int d = 0; d < 3; ++d) { + m_camera_dev.eye[d] = m_eye_base[d] + shift[d]; + } + } + void Renderer::compositeAndWrite(const SubImage& sub, uint64_t order_key, const Scene& scene, diff --git a/src/output/render/renderer.h b/src/output/render/renderer.h index c71edc541..5bbbb4257 100644 --- a/src/output/render/renderer.h +++ b/src/output/render/renderer.h @@ -202,6 +202,15 @@ namespace out { return m_enabled and m_tracker.shouldWrite(step, time); } + /** + * @brief Advance the moving view to `time`: translate the render region (and + * the 3D camera) by `camera_velocity * max(0, time - camera_start_time)`. + * @note A no-op unless `camera_velocity` was set. Call once per frame, before + * reading region()/camera(). All ranks pass the same time, so the + * shifted view is identical everywhere (the composite stays seamless). + */ + void updateForTime(simtime_t time); + /** * @brief Composite the per-rank sparse sub-image across MPI and write PNG. * @param sub this rank's sparse screen-space sub-image (premultiplied RGBA) @@ -394,9 +403,20 @@ namespace out { boundaries_t m_global_extent; // resolved render region [lo, hi] per axis (== global extent unless the // user set x{1,2,3}_lim); the volume is clipped / the slice window is framed - // to this, and the default camera frames it. + // to this, and the default camera frames it. `m_region` is the CURRENT region + // (shifted by the moving view below); `m_region_base` is the static toml one. boundaries_t m_region; + boundaries_t m_region_base; bool m_has_region { false }; + + // moving view: after `m_cam_t0`, the render region and the 3D camera + // translate at `m_cam_vel` (world units per unit sim-time) to keep a + // propagating feature (e.g. a shock) in frame. `m_eye_base` is the static + // camera eye. See updateForTime(). + real_t m_cam_vel[3] { ZERO, ZERO, ZERO }; + simtime_t m_cam_t0 { 0 }; + bool m_cam_moving { false }; + real_t m_eye_base[3] { ZERO, ZERO, ZERO }; // 2D slice world window + axis names, set per-frame by the templated Render real_t m_slice_win[4] { ZERO, ONE, ZERO, ONE }; std::string m_slice_xlabel { "x" }; From cc78232a91e8f7446979dc297c90f27a25919da9 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Wed, 1 Jul 2026 12:31:02 -0400 Subject: [PATCH 17/20] style updates --- src/output/render/axes.h | 57 ++++++++++++++--------- src/output/render/colorbar.h | 26 +++++++---- src/output/render/renderer.cpp | 85 +++++++++++++++++++++++++--------- 3 files changed, 114 insertions(+), 54 deletions(-) diff --git a/src/output/render/axes.h b/src/output/render/axes.h index 9807ec55b..96904b009 100644 --- a/src/output/render/axes.h +++ b/src/output/render/axes.h @@ -335,6 +335,10 @@ namespace out { real_t u1, real_t v0, real_t v1, + real_t du0, + real_t du1, + real_t dv0, + real_t dv1, const std::string& xlabel, const std::string& ylabel, const real_t bg[3], @@ -347,14 +351,12 @@ namespace out { const int gap = 2 * s; const int th = std::max(0, s / 2 - 1); // spine half-thickness + // [u0,u1]x[v0,v1] is the world window mapped onto the full data region; + // [du0,du1]x[dv0,dv1] is the actual data box (the domain/region), a sub-rect + // when the window was aspect-expanded. The spine + ticks clamp to the DATA + // box so the empty aspect pad stays outside the frame. const int xL = x0, xR = x0 + W - 1, yT = 0, yB = H - 1; - // spine - line(rgba, CW, CH, xL, yT, xR, yT, th, c); - line(rgba, CW, CH, xL, yB, xR, yB, th, c); - line(rgba, CW, CH, xL, yT, xL, yB, th, c); - line(rgba, CW, CH, xR, yT, xR, yB, th, c); - - auto X = [&](real_t u) -> int { + auto X = [&](real_t u) -> int { return static_cast(std::lround( static_cast(xL) + static_cast((u - u0) / (u1 - u0)) * (xR - xL))); @@ -364,35 +366,46 @@ namespace out { static_cast(yB) - static_cast((v - v0) / (v1 - v0)) * (yB - yT))); }; - - // x ticks (bottom): marks + labels below the spine - for (const real_t tv : niceTicks(u0, u1, nticks)) { + auto clampX = [&](int x) { return (x < xL) ? xL : ((x > xR) ? xR : x); }; + auto clampY = [&](int y) { return (y < yT) ? yT : ((y > yB) ? yB : y); }; + const int xLd = clampX(X(du0)), xRd = clampX(X(du1)); + const int yTd = clampY(Y(dv1)), yBd = clampY(Y(dv0)); // dv1 = top + + // spine around the data box + line(rgba, CW, CH, xLd, yTd, xRd, yTd, th, c); + line(rgba, CW, CH, xLd, yBd, xRd, yBd, th, c); + line(rgba, CW, CH, xLd, yTd, xLd, yBd, th, c); + line(rgba, CW, CH, xRd, yTd, xRd, yBd, th, c); + + // x ticks (below the data box): marks + labels + for (const real_t tv : niceTicks(du0, du1, nticks)) { const int x = X(tv); - if (x < xL or x > xR) { + if (x < xLd or x > xRd) { continue; } - line(rgba, CW, CH, x, yB, x, yB + tl, 0, c); + line(rgba, CW, CH, x, yBd, x, yBd + tl, 0, c); const std::string lab = cbar_hidden::fmtNum(tv); - text(rgba, CW, CH, x - textW(lab, s) / 2, yB + tl + gap, lab, s, c); + text(rgba, CW, CH, x - textW(lab, s) / 2, yBd + tl + gap, lab, s, c); } - // y ticks (left): marks + right-aligned labels left of the spine - for (const real_t tv : niceTicks(v0, v1, nticks)) { + // y ticks (left of the data box): marks + right-aligned labels + for (const real_t tv : niceTicks(dv0, dv1, nticks)) { const int y = Y(tv); - if (y < yT or y > yB) { + if (y < yTd or y > yBd) { continue; } - line(rgba, CW, CH, xL, y, xL - tl, y, 0, c); + line(rgba, CW, CH, xLd, y, xLd - tl, y, 0, c); const std::string lab = cbar_hidden::fmtNum(tv); - text(rgba, CW, CH, xL - tl - gap - textW(lab, s), y - ch / 2, lab, s, c); + text(rgba, CW, CH, xLd - tl - gap - textW(lab, s), y - ch / 2, lab, s, c); } // axis names if (not xlabel.empty()) { - text(rgba, CW, CH, xL + W / 2 - textW(xlabel, s) / 2, - yB + tl + gap + ch + gap, xlabel, s, c); + text(rgba, CW, CH, (xLd + xRd) / 2 - textW(xlabel, s) / 2, + yBd + tl + gap + ch + gap, xlabel, s, c); } if (not ylabel.empty()) { - textVert(rgba, CW, CH, std::max(gap, x0 - tl - gap - 7 * (6 * s) - gap - 6 * s), - (yT + yB) / 2 - 4 * s * static_cast(ylabel.size()) / 2, + textVert(rgba, CW, CH, + std::max(gap, xLd - tl - gap - 7 * (6 * s) - gap - 6 * s), + (yTd + yBd) / 2 - 4 * s * static_cast(ylabel.size()) / 2, ylabel, s, c); } } diff --git a/src/output/render/colorbar.h b/src/output/render/colorbar.h index fbe52cb3b..0ff9f085f 100644 --- a/src/output/render/colorbar.h +++ b/src/output/render/colorbar.h @@ -167,6 +167,10 @@ namespace out { * @param bg background RGB (to auto-pick contrasting text color) * @param ticks explicit tick values to label; if empty, 5 evenly-spaced * ticks are generated. Values outside [vmin, vmax] are skipped. + * @param span_top,span_bot vertical pixel span the bar should occupy (e.g. the + * data domain, so the bar is centered on the actual data). When + * span_top < 0 or the span is empty, the bar is half the canvas + * height, centered on the canvas (the default). */ inline void drawColorbar(uint8_t* rgba, int W, @@ -177,23 +181,27 @@ namespace out { bool log_scale, const std::string& label, const real_t bg[3], - const std::vector& ticks = {}) { + const std::vector& ticks = {}, + int span_top = -1, + int span_bot = -1) { using namespace cbar_hidden; - const int s = scale(H); - const int char_h = 8 * s; - const int bar_w = std::max(12, H / 50); - const int bar_h = H / 2; - const int gap = 3 * s; - const int pad = 4 * s; - const int block_w = colorbarBlockWidth(H); + const int s = scale(H); + const int char_h = 8 * s; + const int bar_w = std::max(12, H / 50); + const bool aligned = (span_top >= 0) and (span_bot > span_top); + const int bar_h = aligned ? (span_bot - span_top) : (H / 2); + const int gap = 3 * s; + const int pad = 4 * s; + const int block_w = colorbarBlockWidth(H); // place the bar near the right edge of the (possibly extended) canvas int bar_x = W - block_w + pad; if (bar_x < pad) { bar_x = pad; } - const int bar_y = (H - bar_h) / 2; + // vertical position: aligned to the data span if given, else canvas-centered + const int bar_y = aligned ? span_top : ((H - bar_h) / 2); // contrasting monochrome for text / frame / ticks const real_t lum = static_cast(0.299) * bg[0] + diff --git a/src/output/render/renderer.cpp b/src/output/render/renderer.cpp index 003069f06..54bbd3461 100644 --- a/src/output/render/renderer.cpp +++ b/src/output/render/renderer.cpp @@ -494,19 +494,20 @@ namespace out { scene.prefix.c_str(), static_cast(step)); - auto drawBar = [&](uint8_t* buf, int bw, int bh) { + auto drawBar = [&](uint8_t* buf, int bw, int bh, int span_top, int span_bot) { if (m_colorbar) { drawColorbar(buf, bw, bh, scene.tf.colormap, scene.tf.vmin, scene.tf.vmax, scene.tf.log_scale, scene.label, - m_background, scene.ticks); + m_background, scene.ticks, span_top, span_bot); } }; - // upper-right corner label of the current simulation time. `data_left` is - // the x-offset of the render region inside the buffer (0 without axes, the - // left margin `ml` with axes), so the label sits in the render region's - // top-right, not over the colorbar strip. - auto drawTimeLabel = [&](uint8_t* buf, int cw, int ch, int data_left) { + // Draw the sim-time label, right-aligned to `right_x` and vertically + // centered in the band [0, top_limit] -- i.e. OUTSIDE the plotted data: + // above the colorbar (3D / disk) or, for a 2D slice, in the aspect-pad + // above the data box (so it never sits inside the simulation axes). + auto drawTimeLabel = [&](uint8_t* buf, int cw, int ch, int right_x, + int top_limit) { if (not m_time_label) { return; } @@ -517,16 +518,11 @@ namespace out { std::snprintf(tbuf, sizeof(tbuf), "T = %.2f", static_cast(time)); const std::string str(tbuf); - const int tw = static_cast(str.size()) * 6 * s; - const int pad = 3 * s; - const int tx = data_left + m_width - tw - pad; - // vertically center the label between the image top and the top of the - // colorbar bar. drawColorbar uses bar_h = ch/2, so the bar top is at - // bar_y = (ch - bar_h)/2 = ch/4; center the 7*s-tall glyphs in [0, bar_y]. - const int text_h = 7 * s; - const int bar_h = ch / 2; - const int cbar_top = m_colorbar ? (ch - bar_h) / 2 : (ch / 4); - int ty = (cbar_top - text_h) / 2; + const int tw = static_cast(str.size()) * 6 * s; + const int pad = 3 * s; + const int tx = right_x - tw - pad; + const int text_h = 7 * s; + int ty = (top_limit - text_h) / 2; if (ty < pad) { ty = pad; } @@ -551,11 +547,50 @@ namespace out { const int CW = ml + m_width + strip; const int CH = m_height + mb; + // 2D-Cartesian data box (== the render region, before the aspect-expansion + // that pads the window with background): its top & right edges in + // data-region pixels. The axes/spine clamp to it and the time label sits + // in the pad above it, so neither includes the empty aspect padding. + const bool cart2d = (m_global_extent.size() == 2) and not polar; + int dbox_top = 0; // data box top edge (px from data top) + int dbox_bot = m_height; // data box bottom edge (px) + int dbox_right = m_width; // data box right edge (px from data left) + if (cart2d and m_region.size() >= 2) { + const real_t u0 = m_slice_win[0], u1 = m_slice_win[1]; + const real_t v0 = m_slice_win[2], v1 = m_slice_win[3]; + const real_t du1 = m_region[0].second; // data box right in world (x1) + const real_t dv0 = m_region[1].first; // data box bottom in world (x2) + const real_t dv1 = m_region[1].second; // data box top in world (x2) + if (u1 > u0) { + int r = static_cast(std::lround( + static_cast((du1 - u0) / (u1 - u0)) * (m_width - 1))); + dbox_right = (r < 0) ? 0 : ((r > m_width) ? m_width : r); + } + if (v1 > v0) { + int t = static_cast(std::lround( + static_cast((v1 - dv1) / (v1 - v0)) * (m_height - 1))); + int b = static_cast(std::lround( + static_cast((v1 - dv0) / (v1 - v0)) * (m_height - 1))); + dbox_top = (t < 0) ? 0 : ((t > m_height) ? m_height : t); + dbox_bot = (b < 0) ? 0 : ((b > m_height) ? m_height : b); + } + } + // time-label anchor: for a 2D slice, the top-right of the data box (label + // goes in the pad above it); otherwise the top-right above the colorbar. + const int cbar_top = m_colorbar ? (CH - CH / 2) / 2 : (CH / 4); + const int tl_right = cart2d ? (ml + dbox_right) : (ml + m_width); + const int tl_top = cart2d ? dbox_top : cbar_top; + // colorbar vertical span: aligned to the actual data domain for a 2D slice + // (so it's centered on the data, not the aspect-padded canvas); sentinel + // (-1) elsewhere -> drawColorbar centers it on the canvas as before. + const int cbar_span_top = cart2d ? dbox_top : -1; + const int cbar_span_bot = cart2d ? dbox_bot : -1; + bool ok = true; if (CW == m_width and CH == m_height and not m_axes) { // no margins, no outside strip, no overlay: colorbar overlays the data - drawBar(data.data(), m_width, m_height); - drawTimeLabel(data.data(), m_width, m_height, 0); + drawBar(data.data(), m_width, m_height, cbar_span_top, cbar_span_bot); + drawTimeLabel(data.data(), m_width, m_height, tl_right, tl_top); ok = write_png(fname, m_width, m_height, data.data()); } else { const uint8_t bR = quantize(m_background[0]); @@ -586,14 +621,18 @@ namespace out { m_slice_tmin, m_slice_tmax, m_slice_pmirror, "R", "Theta", m_background, m_axis_nticks); } else { + // data box (== region, un-expanded) so the spine hugs the domain, + // not the aspect-padded window + const real_t du0 = m_region[0].first, du1 = m_region[0].second; + const real_t dv0 = m_region[1].first, dv1 = m_region[1].second; out::drawAxes2D(canvas.data(), CW, CH, ml, m_width, m_height, m_slice_win[0], m_slice_win[1], m_slice_win[2], - m_slice_win[3], m_slice_xlabel, m_slice_ylabel, - m_background, m_axis_nticks); + m_slice_win[3], du0, du1, dv0, dv1, m_slice_xlabel, + m_slice_ylabel, m_background, m_axis_nticks); } } - drawBar(canvas.data(), CW, CH); - drawTimeLabel(canvas.data(), CW, CH, ml); + drawBar(canvas.data(), CW, CH, cbar_span_top, cbar_span_bot); + drawTimeLabel(canvas.data(), CW, CH, tl_right, tl_top); ok = write_png(fname, CW, CH, canvas.data()); } if (not ok) { From 0bf6e745779a129f1ad544a7bf173f25af19ae60 Mon Sep 17 00:00:00 2001 From: Ludwig Boess Date: Wed, 1 Jul 2026 12:58:54 -0400 Subject: [PATCH 18/20] added more colormaps --- input.example.toml | 10 +- src/output/render/transfer_fn.h | 432 ++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 2 deletions(-) diff --git a/input.example.toml b/input.example.toml index 08b43370c..ddb991e87 100644 --- a/input.example.toml +++ b/input.example.toml @@ -905,7 +905,10 @@ log = "" # Colormap name # @type: string - # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray" + # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray", "RdBu_r", and the + # CMasher maps (BSD-3, https://cmasher.readthedocs.io): "dusk", + # "cosmic", "freeze", "apple", "gothic", "sunburst", "voltage", + # "ocean", "fusion", "prinsenvlag" (an optional "cmr." prefix is ok) # @default: "viridis" colormap = "" # Opacity transfer function: [position, opacity] control points, both in @@ -979,7 +982,10 @@ tube_px = "" # Colormap for the field lines (mapped by |B| along each line) # @type: string - # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray" + # @enum: "viridis", "inferno", "plasma", "cool2warm", "gray", "RdBu_r", and the + # CMasher maps (BSD-3, https://cmasher.readthedocs.io): "dusk", + # "cosmic", "freeze", "apple", "gothic", "sunburst", "voltage", + # "ocean", "fusion", "prinsenvlag" (an optional "cmr." prefix is ok) # @default: "inferno" colormap = "" # Monochrome override: draw the lines in a single [r,g,b] color (each 0..1) diff --git a/src/output/render/transfer_fn.h b/src/output/render/transfer_fn.h index a7d682cb3..75759bf1d 100644 --- a/src/output/render/transfer_fn.h +++ b/src/output/render/transfer_fn.h @@ -84,6 +84,414 @@ namespace out { { 1.0f, 1.0f, 1.0f }, }; + // ----------------------------------------------------------------------- + // CMasher scientific colormaps (https://cmasher.readthedocs.io) + // + // The following anchor tables are uniform downsamplings (33 anchors) of the + // published CMasher colormap data, re-implemented from the source at + // https://github.com/1313e/CMasher (src/cmasher/colormaps//_norm.txt). + // At 33 anchors the linear-interpolation error versus the full 256/511-entry + // tables is < 4.5/255 for every map, i.e. visually indistinguishable. + // + // CMasher is distributed under the BSD 3-Clause License: + // Copyright (c) 2019-2021, Ellert van der Velden + // All rights reserved. + // Redistribution and use in source and binary forms, with or without + // modification, are permitted provided that the copyright notice, this list + // of conditions and the BSD-3-Clause disclaimer are retained. The name of the + // copyright holder may not be used to endorse products without permission. + // ----------------------------------------------------------------------- + + // cmasher::dusk (33 anchors sampled from the 256-entry table) + inline constexpr float dusk[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.006238f, 0.007290f, 0.012708f }, + { 0.018622f, 0.026187f, 0.053076f }, + { 0.029900f, 0.055366f, 0.101787f }, + { 0.030149f, 0.086842f, 0.147576f }, + { 0.015542f, 0.120312f, 0.179819f }, + { 0.010319f, 0.152335f, 0.193847f }, + { 0.029326f, 0.181190f, 0.200163f }, + { 0.066363f, 0.207841f, 0.204068f }, + { 0.103996f, 0.233120f, 0.206493f }, + { 0.141503f, 0.257403f, 0.206888f }, + { 0.180382f, 0.280651f, 0.204334f }, + { 0.222140f, 0.302532f, 0.198293f }, + { 0.267566f, 0.322632f, 0.189012f }, + { 0.316579f, 0.340653f, 0.177441f }, + { 0.368580f, 0.356469f, 0.164931f }, + { 0.422824f, 0.370100f, 0.153037f }, + { 0.471588f, 0.380324f, 0.144475f }, + { 0.528296f, 0.390215f, 0.138408f }, + { 0.585639f, 0.398375f, 0.137901f }, + { 0.643321f, 0.404994f, 0.144156f }, + { 0.701148f, 0.410242f, 0.157700f }, + { 0.758937f, 0.414304f, 0.178666f }, + { 0.816278f, 0.417544f, 0.207638f }, + { 0.871717f, 0.421238f, 0.247431f }, + { 0.918409f, 0.431732f, 0.307531f }, + { 0.940305f, 0.464843f, 0.388730f }, + { 0.947103f, 0.510940f, 0.465808f }, + { 0.949229f, 0.559258f, 0.537097f }, + { 0.949161f, 0.607586f, 0.604625f }, + { 0.947863f, 0.655456f, 0.669259f }, + { 0.945994f, 0.702786f, 0.731248f }, + { 0.944208f, 0.749586f, 0.790456f }, + }; + + // cmasher::cosmic (33 anchors sampled from the 256-entry table) + inline constexpr float cosmic[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.010239f, 0.006872f, 0.013172f }, + { 0.038809f, 0.022373f, 0.054399f }, + { 0.076237f, 0.043279f, 0.104947f }, + { 0.112700f, 0.063138f, 0.158384f }, + { 0.148869f, 0.079429f, 0.215952f }, + { 0.185079f, 0.091867f, 0.278775f }, + { 0.221470f, 0.099655f, 0.348020f }, + { 0.257982f, 0.101322f, 0.424930f }, + { 0.294209f, 0.094291f, 0.510709f }, + { 0.328977f, 0.074083f, 0.605931f }, + { 0.359190f, 0.034502f, 0.708287f }, + { 0.377711f, 0.016748f, 0.805820f }, + { 0.375894f, 0.098454f, 0.873588f }, + { 0.355826f, 0.192411f, 0.902268f }, + { 0.326497f, 0.271620f, 0.906235f }, + { 0.293925f, 0.337879f, 0.899030f }, + { 0.265164f, 0.388114f, 0.889366f }, + { 0.233446f, 0.439282f, 0.877747f }, + { 0.203862f, 0.485773f, 0.867189f }, + { 0.176988f, 0.529060f, 0.858475f }, + { 0.153054f, 0.570207f, 0.851855f }, + { 0.131837f, 0.609999f, 0.847280f }, + { 0.112515f, 0.649034f, 0.844504f }, + { 0.093584f, 0.687770f, 0.843135f }, + { 0.072938f, 0.726550f, 0.842663f }, + { 0.048159f, 0.765608f, 0.842480f }, + { 0.021220f, 0.805066f, 0.841902f }, + { 0.009956f, 0.844909f, 0.840192f }, + { 0.039372f, 0.884930f, 0.836603f }, + { 0.115052f, 0.924556f, 0.830460f }, + { 0.218056f, 0.962249f, 0.821634f }, + { 0.371763f, 0.992456f, 0.816521f }, + }; + + // cmasher::freeze (33 anchors sampled from the 256-entry table) + inline constexpr float freeze[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.010910f, 0.009007f, 0.014906f }, + { 0.039247f, 0.030823f, 0.059160f }, + { 0.074148f, 0.060008f, 0.110444f }, + { 0.106606f, 0.087232f, 0.163635f }, + { 0.137212f, 0.112671f, 0.219689f }, + { 0.166145f, 0.136733f, 0.279301f }, + { 0.193344f, 0.159680f, 0.343042f }, + { 0.218514f, 0.181739f, 0.411377f }, + { 0.241052f, 0.203213f, 0.484587f }, + { 0.259859f, 0.224652f, 0.562539f }, + { 0.272953f, 0.247192f, 0.644102f }, + { 0.276779f, 0.273147f, 0.725721f }, + { 0.265827f, 0.306369f, 0.798716f }, + { 0.236083f, 0.349541f, 0.849696f }, + { 0.192331f, 0.398903f, 0.873582f }, + { 0.145422f, 0.448298f, 0.878894f }, + { 0.112009f, 0.489316f, 0.875932f }, + { 0.099232f, 0.533336f, 0.869029f }, + { 0.122966f, 0.574696f, 0.861171f }, + { 0.170084f, 0.613909f, 0.853779f }, + { 0.227234f, 0.651383f, 0.847515f }, + { 0.289300f, 0.687372f, 0.842698f }, + { 0.355048f, 0.721960f, 0.839586f }, + { 0.424505f, 0.755075f, 0.838630f }, + { 0.497701f, 0.786577f, 0.840759f }, + { 0.573685f, 0.816502f, 0.847460f }, + { 0.650307f, 0.845347f, 0.860140f }, + { 0.725396f, 0.873979f, 0.879132f }, + { 0.797886f, 0.903229f, 0.903669f }, + { 0.867683f, 0.933696f, 0.932619f }, + { 0.935038f, 0.965809f, 0.964980f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::apple (33 anchors sampled from the 256-entry table) + inline constexpr float apple[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.018449f, 0.006683f, 0.008999f }, + { 0.069836f, 0.019462f, 0.030130f }, + { 0.124822f, 0.033495f, 0.057048f }, + { 0.180264f, 0.045068f, 0.080127f }, + { 0.236726f, 0.050851f, 0.098639f }, + { 0.294404f, 0.049708f, 0.111860f }, + { 0.353148f, 0.039763f, 0.118281f }, + { 0.412085f, 0.021350f, 0.115115f }, + { 0.467944f, 0.008973f, 0.098099f }, + { 0.512805f, 0.042578f, 0.069239f }, + { 0.544883f, 0.107236f, 0.041311f }, + { 0.569454f, 0.165936f, 0.019988f }, + { 0.589142f, 0.220295f, 0.007127f }, + { 0.604821f, 0.272232f, 0.001856f }, + { 0.616738f, 0.322887f, 0.005791f }, + { 0.624881f, 0.372928f, 0.022422f }, + { 0.628812f, 0.416517f, 0.050591f }, + { 0.629507f, 0.466302f, 0.088693f }, + { 0.625985f, 0.516149f, 0.130045f }, + { 0.618051f, 0.566092f, 0.175071f }, + { 0.605455f, 0.616124f, 0.224400f }, + { 0.587974f, 0.666152f, 0.279195f }, + { 0.566011f, 0.715805f, 0.341631f }, + { 0.543179f, 0.763859f, 0.415364f }, + { 0.534328f, 0.806916f, 0.503615f }, + { 0.564391f, 0.840721f, 0.598311f }, + { 0.628027f, 0.867449f, 0.684639f }, + { 0.703665f, 0.891763f, 0.760351f }, + { 0.781104f, 0.916124f, 0.828098f }, + { 0.857052f, 0.941725f, 0.890058f }, + { 0.930451f, 0.969331f, 0.947434f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::gothic (33 anchors sampled from the 256-entry table) + inline constexpr float gothic[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.009103f, 0.009497f, 0.017125f }, + { 0.031259f, 0.032684f, 0.068646f }, + { 0.061552f, 0.062439f, 0.127729f }, + { 0.091294f, 0.088771f, 0.191076f }, + { 0.121658f, 0.111295f, 0.260048f }, + { 0.154308f, 0.129160f, 0.335784f }, + { 0.191149f, 0.140572f, 0.419137f }, + { 0.234428f, 0.142239f, 0.510129f }, + { 0.286418f, 0.128494f, 0.605972f }, + { 0.347449f, 0.092109f, 0.695963f }, + { 0.411712f, 0.037080f, 0.759733f }, + { 0.471546f, 0.020266f, 0.789424f }, + { 0.526274f, 0.060551f, 0.796780f }, + { 0.578066f, 0.108541f, 0.793004f }, + { 0.628368f, 0.151332f, 0.783641f }, + { 0.677621f, 0.190718f, 0.770763f }, + { 0.719390f, 0.224799f, 0.756764f }, + { 0.763054f, 0.267998f, 0.736645f }, + { 0.790627f, 0.328361f, 0.713928f }, + { 0.791142f, 0.402697f, 0.719584f }, + { 0.787649f, 0.467602f, 0.747951f }, + { 0.784959f, 0.526150f, 0.782754f }, + { 0.783559f, 0.580954f, 0.819616f }, + { 0.783736f, 0.633370f, 0.856997f }, + { 0.785586f, 0.684344f, 0.893939f }, + { 0.789112f, 0.734726f, 0.928646f }, + { 0.795666f, 0.785049f, 0.956164f }, + { 0.813132f, 0.833710f, 0.968235f }, + { 0.848938f, 0.877750f, 0.970393f }, + { 0.895684f, 0.918861f, 0.974579f }, + { 0.947082f, 0.959196f, 0.984058f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::sunburst (33 anchors sampled from the 256-entry table) + inline constexpr float sunburst[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.014374f, 0.007761f, 0.013830f }, + { 0.057181f, 0.023825f, 0.051569f }, + { 0.107503f, 0.043062f, 0.091435f }, + { 0.159109f, 0.059433f, 0.125622f }, + { 0.212012f, 0.071665f, 0.153805f }, + { 0.266014f, 0.080343f, 0.175895f }, + { 0.320933f, 0.085763f, 0.191981f }, + { 0.376633f, 0.087997f, 0.202182f }, + { 0.432991f, 0.086959f, 0.206564f }, + { 0.489851f, 0.082504f, 0.205087f }, + { 0.546970f, 0.074631f, 0.197554f }, + { 0.603921f, 0.064180f, 0.183549f }, + { 0.659892f, 0.055133f, 0.162324f }, + { 0.713225f, 0.059572f, 0.132732f }, + { 0.760503f, 0.093519f, 0.093993f }, + { 0.796917f, 0.154735f, 0.050648f }, + { 0.819328f, 0.216009f, 0.025564f }, + { 0.837747f, 0.284012f, 0.033882f }, + { 0.851299f, 0.348017f, 0.074896f }, + { 0.861316f, 0.408787f, 0.125585f }, + { 0.868446f, 0.467188f, 0.180749f }, + { 0.873123f, 0.523806f, 0.239715f }, + { 0.875769f, 0.578986f, 0.302576f }, + { 0.876914f, 0.632883f, 0.369564f }, + { 0.877316f, 0.685489f, 0.440900f }, + { 0.878101f, 0.736650f, 0.516675f }, + { 0.880900f, 0.786078f, 0.596685f }, + { 0.887890f, 0.833402f, 0.680181f }, + { 0.901543f, 0.878304f, 0.765633f }, + { 0.924079f, 0.920687f, 0.850654f }, + { 0.957291f, 0.960697f, 0.931527f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::voltage (33 anchors sampled from the 256-entry table) + inline constexpr float voltage[33][3] = { + { 0.000000f, 0.000000f, 0.000000f }, + { 0.015829f, 0.007377f, 0.012045f }, + { 0.060346f, 0.022787f, 0.046824f }, + { 0.109165f, 0.041971f, 0.090162f }, + { 0.157514f, 0.059030f, 0.134981f }, + { 0.205921f, 0.071648f, 0.182831f }, + { 0.254512f, 0.079556f, 0.235168f }, + { 0.303074f, 0.082057f, 0.293555f }, + { 0.350914f, 0.078204f, 0.359600f }, + { 0.396550f, 0.067768f, 0.434356f }, + { 0.437427f, 0.055503f, 0.516639f }, + { 0.470517f, 0.059198f, 0.601227f }, + { 0.494085f, 0.093088f, 0.680818f }, + { 0.508421f, 0.145253f, 0.750719f }, + { 0.514803f, 0.203410f, 0.809898f }, + { 0.514558f, 0.262635f, 0.859195f }, + { 0.508787f, 0.321230f, 0.899892f }, + { 0.499930f, 0.371547f, 0.929319f }, + { 0.486263f, 0.427841f, 0.956532f }, + { 0.469915f, 0.482826f, 0.977159f }, + { 0.452770f, 0.536437f, 0.991010f }, + { 0.438275f, 0.588421f, 0.997509f }, + { 0.432385f, 0.638191f, 0.996018f }, + { 0.443151f, 0.684778f, 0.986745f }, + { 0.476274f, 0.727179f, 0.972136f }, + { 0.529358f, 0.765229f, 0.956912f }, + { 0.594140f, 0.799927f, 0.945473f }, + { 0.663403f, 0.832718f, 0.939967f }, + { 0.733296f, 0.864814f, 0.940775f }, + { 0.802260f, 0.897066f, 0.947563f }, + { 0.869795f, 0.930059f, 0.959867f }, + { 0.935781f, 0.964231f, 0.977340f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::ocean (33 anchors sampled from the 256-entry table) + inline constexpr float ocean[33][3] = { + { 0.110363f, 0.001691f, 0.253026f }, + { 0.124516f, 0.043066f, 0.289045f }, + { 0.135665f, 0.084868f, 0.324305f }, + { 0.143805f, 0.121839f, 0.358190f }, + { 0.148976f, 0.156972f, 0.390248f }, + { 0.151327f, 0.191269f, 0.420093f }, + { 0.151211f, 0.225122f, 0.447430f }, + { 0.149271f, 0.258662f, 0.472116f }, + { 0.146471f, 0.291895f, 0.494196f }, + { 0.144043f, 0.324790f, 0.513894f }, + { 0.143346f, 0.357322f, 0.531555f }, + { 0.145639f, 0.389496f, 0.547565f }, + { 0.151848f, 0.421347f, 0.562289f }, + { 0.162417f, 0.452929f, 0.576030f }, + { 0.177344f, 0.484306f, 0.589017f }, + { 0.196368f, 0.515533f, 0.601409f }, + { 0.219188f, 0.546652f, 0.613299f }, + { 0.242129f, 0.573802f, 0.623330f }, + { 0.271793f, 0.604715f, 0.634385f }, + { 0.305461f, 0.635419f, 0.645036f }, + { 0.343819f, 0.665728f, 0.655393f }, + { 0.387893f, 0.695314f, 0.665825f }, + { 0.438718f, 0.723698f, 0.677325f }, + { 0.496053f, 0.750479f, 0.691913f }, + { 0.556972f, 0.775909f, 0.711830f }, + { 0.617772f, 0.800881f, 0.737580f }, + { 0.676718f, 0.826177f, 0.768091f }, + { 0.733666f, 0.852223f, 0.802113f }, + { 0.788975f, 0.879240f, 0.838730f }, + { 0.843051f, 0.907368f, 0.877312f }, + { 0.896212f, 0.936732f, 0.917381f }, + { 0.948620f, 0.967497f, 0.958467f }, + { 1.000000f, 1.000000f, 1.000000f }, + }; + + // cmasher::fusion (diverging; 33 anchors sampled from the 511-entry table) + inline constexpr float fusion[33][3] = { + { 0.152696f, 0.015942f, 0.069889f }, + { 0.243393f, 0.027996f, 0.138374f }, + { 0.339396f, 0.022662f, 0.187330f }, + { 0.434194f, 0.019908f, 0.202137f }, + { 0.518461f, 0.063682f, 0.191551f }, + { 0.591978f, 0.129217f, 0.171843f }, + { 0.656187f, 0.199931f, 0.150076f }, + { 0.710976f, 0.275318f, 0.131287f }, + { 0.754925f, 0.356047f, 0.126115f }, + { 0.784581f, 0.436574f, 0.150892f }, + { 0.803828f, 0.525723f, 0.220961f }, + { 0.815422f, 0.614033f, 0.328856f }, + { 0.828457f, 0.697985f, 0.458793f }, + { 0.850099f, 0.777148f, 0.598141f }, + { 0.884001f, 0.852908f, 0.739531f }, + { 0.932364f, 0.926807f, 0.878147f }, + { 1.000000f, 1.000000f, 1.000000f }, + { 0.882739f, 0.938948f, 0.943839f }, + { 0.759644f, 0.882704f, 0.900553f }, + { 0.630638f, 0.828950f, 0.872602f }, + { 0.499428f, 0.774306f, 0.861660f }, + { 0.381603f, 0.714439f, 0.863772f }, + { 0.301845f, 0.647196f, 0.868704f }, + { 0.272703f, 0.573492f, 0.869040f }, + { 0.281047f, 0.499433f, 0.863193f }, + { 0.307182f, 0.414842f, 0.849495f }, + { 0.335700f, 0.322756f, 0.825580f }, + { 0.356873f, 0.220225f, 0.784504f }, + { 0.360275f, 0.107524f, 0.709606f }, + { 0.327077f, 0.044725f, 0.576747f }, + { 0.256240f, 0.065580f, 0.425468f }, + { 0.175982f, 0.062679f, 0.300240f }, + { 0.095379f, 0.037917f, 0.194868f }, + }; + + // cmasher::prinsenvlag (diverging; 33 anchors sampled from the 511-entry table) + inline constexpr float prinsenvlag[33][3] = { + { 0.666523f, 0.321623f, 0.271748f }, + { 0.715454f, 0.343630f, 0.238454f }, + { 0.759975f, 0.370421f, 0.199167f }, + { 0.798794f, 0.402978f, 0.153663f }, + { 0.830257f, 0.442137f, 0.100784f }, + { 0.852097f, 0.488559f, 0.041408f }, + { 0.861821f, 0.542123f, 0.032681f }, + { 0.860262f, 0.599807f, 0.123019f }, + { 0.854920f, 0.655912f, 0.231638f }, + { 0.852701f, 0.704526f, 0.333943f }, + { 0.855639f, 0.752429f, 0.440786f }, + { 0.864494f, 0.797240f, 0.544831f }, + { 0.879227f, 0.839859f, 0.645897f }, + { 0.899719f, 0.880993f, 0.743689f }, + { 0.925990f, 0.921172f, 0.837642f }, + { 0.958977f, 0.960584f, 0.926102f }, + { 1.000000f, 1.000000f, 1.000000f }, + { 0.926250f, 0.969453f, 0.961500f }, + { 0.846431f, 0.941633f, 0.927771f }, + { 0.760433f, 0.915515f, 0.901576f }, + { 0.669026f, 0.889675f, 0.886082f }, + { 0.578083f, 0.861643f, 0.882953f }, + { 0.498336f, 0.829280f, 0.888643f }, + { 0.435785f, 0.792736f, 0.897592f }, + { 0.392859f, 0.755673f, 0.906500f }, + { 0.363227f, 0.713780f, 0.915753f }, + { 0.350577f, 0.669530f, 0.923924f }, + { 0.355484f, 0.622674f, 0.928631f }, + { 0.377364f, 0.573245f, 0.923046f }, + { 0.410189f, 0.524116f, 0.889701f }, + { 0.432426f, 0.483706f, 0.814621f }, + { 0.433280f, 0.452439f, 0.723666f }, + { 0.421591f, 0.424905f, 0.636181f }, + }; + + // ColorBrewer "RdBu" diverging map, reversed (blue -> white -> red), as + // exposed by matplotlib under the name "RdBu_r". matplotlib builds it by + // linearly interpolating these 11 control points, so the uniform-anchor + // scheme above reproduces it to < 1.6/255. + // Colors from ColorBrewer (https://colorbrewer2.org) by Cynthia A. Brewer, + // Geography, Pennsylvania State University -- Apache License 2.0. + inline constexpr float rdbu_r[11][3] = { + { 0.019608f, 0.188235f, 0.380392f }, + { 0.132026f, 0.403460f, 0.676278f }, + { 0.262745f, 0.576471f, 0.764706f }, + { 0.566474f, 0.768704f, 0.868512f }, + { 0.819608f, 0.898039f, 0.941176f }, + { 0.969089f, 0.966474f, 0.964937f }, + { 0.992157f, 0.858824f, 0.780392f }, + { 0.957555f, 0.651211f, 0.515110f }, + { 0.839216f, 0.376471f, 0.301961f }, + { 0.692272f, 0.092272f, 0.167705f }, + { 0.403922f, 0.000000f, 0.121569f }, + }; + inline auto lookup(const std::string& name) -> Anchors { if (name == "inferno") { return { inferno, 9 }; @@ -93,6 +501,30 @@ namespace out { return { cool2warm, 3 }; } else if (name == "gray" or name == "grey") { return { gray, 2 }; + // CMasher scientific colormaps (accept an optional "cmr." prefix so + // names can be copied straight from the CMasher documentation) + } else if (name == "dusk" or name == "cmr.dusk") { + return { dusk, 33 }; + } else if (name == "cosmic" or name == "cmr.cosmic") { + return { cosmic, 33 }; + } else if (name == "freeze" or name == "cmr.freeze") { + return { freeze, 33 }; + } else if (name == "apple" or name == "cmr.apple") { + return { apple, 33 }; + } else if (name == "gothic" or name == "cmr.gothic") { + return { gothic, 33 }; + } else if (name == "sunburst" or name == "cmr.sunburst") { + return { sunburst, 33 }; + } else if (name == "voltage" or name == "cmr.voltage") { + return { voltage, 33 }; + } else if (name == "ocean" or name == "cmr.ocean") { + return { ocean, 33 }; + } else if (name == "fusion" or name == "cmr.fusion") { + return { fusion, 33 }; + } else if (name == "prinsenvlag" or name == "cmr.prinsenvlag") { + return { prinsenvlag, 33 }; + } else if (name == "RdBu_r" or name == "rdbu_r") { + return { rdbu_r, 11 }; } else { // default / "viridis" return { viridis, 9 }; From 9d74601cfef6d5d9e4e72ca21b2cffb6aa049c35 Mon Sep 17 00:00:00 2001 From: LudwigBoess Date: Sun, 5 Jul 2026 21:35:15 +0000 Subject: [PATCH 19/20] always render axis ticks in the foreground --- src/output/render/axes.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/output/render/axes.h b/src/output/render/axes.h index 96904b009..72da984bc 100644 --- a/src/output/render/axes.h +++ b/src/output/render/axes.h @@ -621,10 +621,12 @@ namespace out { (void)ok; // The wireframe "spine" is drawn in the ray-march (depth-occluded), so here - // we only annotate. For each axis, pick one *silhouette* edge (its two - // adjacent faces face opposite ways) and, among the two candidates, the one - // whose screen position matches the convention x=bottom, y & z on the left - // of the default diagonal view. + // we only annotate. For each axis, pick one *silhouette* edge: its two + // adjacent faces point opposite ways relative to the camera (one toward it, + // one away), so the edge lies on the box OUTLINE and is always in the + // foreground -- never hidden behind the volume. Among the (usually two) + // silhouette candidates take the one nearest the bottom-left of the image, + // the conventional, view-independent place for axis annotation. auto frontFace = [&](int axis, int side) -> bool { const real_t nrm = (side != 0) ? ONE : -ONE; // outward normal sign return (nrm * (-cam.forward[axis])) > ZERO; // points toward the camera? @@ -641,8 +643,10 @@ namespace out { const int m1 = m0 | (1 << d); const real_t mx = HALF * (cx[m0] + cx[m1]); const real_t my = HALF * (cy[m0] + cy[m1]); - // screen-position convention: x on the bottom, y & z on the left edges - real_t score = (d == 0) ? my : -mx; + // prefer the foreground (bottom-left) edge: larger pixel-y is lower, + // smaller pixel-x is further left. Axis-independent, so it follows the + // camera instead of assuming the default diagonal view. + real_t score = my - mx; if (frontFace(e1, s1) != frontFace(e2, s2)) { score += static_cast(1e6); // strongly prefer silhouette edges } From 28f9dab2ff918d2c63491b6fc2f6a558fe0e9f88 Mon Sep 17 00:00:00 2001 From: LudwigBoess Date: Sun, 5 Jul 2026 21:36:07 +0000 Subject: [PATCH 20/20] python script to preview render orientation --- render_preview.py | 812 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 812 insertions(+) create mode 100644 render_preview.py diff --git a/render_preview.py b/render_preview.py new file mode 100644 index 000000000..77891cd21 --- /dev/null +++ b/render_preview.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +""" +render_preview.py -- fast, data-free preview of the entity in-situ renderer's +SCENE GEOMETRY. + +Purpose +------- +Reads a simulation `.toml` and draws the domain box / camera framing / axes / +region crop / field-line seed lattice, WITHOUT any simulation data or +ray-marching. It lets you iterate on camera orientation (e.g. the domain cube of +a 3D turbulence run) and framing without relaunching the simulation. + +It reproduces the SAME camera / projection the C++ renderer uses, so the preview +is trustworthy: the box you see here is the box the renderer will draw. + + IMPORTANT: the camera / projection / region / field-line-lattice math below is + a faithful port of the C++ renderer. If the C++ changes, THIS MUST BE UPDATED + IN SYNC. The controlling C++ sources (verified line-by-line while writing this) + are: + - src/output/render/renderer.cpp + Renderer::init -> toml parse, region resolution, default camera, + ortho/persp basis, ortho_height default = box diag, + eye = center + 1.7*diag*(1,1,1)/sqrt3, fov 35 deg + Renderer::updateForTime -> moving view (pure pan of region + eye) + - src/output/render/composite.h + projectToScreen (~L85-112) -> world -> pixel, ortho & perspective + screenBBox (~L121-163) + - src/output/render/raymarch.hpp + ray generation (~L308-335) -- the inverse of projectToScreen + - src/output/render/axes.h + drawAxes3D / drawAxes2D / drawAxesPolar, niceTicks / niceNum tick style + - src/framework/domain/metadomain_render.cpp + 2D window derivation (Cartesian window vs. spherical meridional wedge, + X = r sin th, Z = r cos th, aspect expansion, mirror), field-line setup + - src/framework/parameters/grid.cpp (~L470-497) + extent parse + theta,phi auto-fill for non-Cartesian metrics + - src/output/render/fieldlines.h + 3D seed lattice: spacing = max(seed_px,1)*wpp, grown by + cbrt(n_seed/seed_max) if over seed_max, ns[d]=floor(size[d]/spacing), + seeds at cell centers. + +Modes +----- + * 3D (Cartesian only -- the renderer's only 3D mode): projects the domain cube + (and region crop, if any) with the exact ray-march camera; optional field- + line SEED lattice scatter (schematic: seeds, not traced lines). + * 2D Cartesian: aspect-expanded slice window + domain/region box + ticks. + * 2D spherical/GR: meridional wedge (arcs at r in {rmin,rmax}, rays at + theta in {tmin,tmax}), mirrored into a full disk if `mirror`. + * 1D: nothing to render (warns). + +Usage +----- + module load python/3.13.0 + python render_preview.py [--out preview.png] [--time T] [--scene N] + +If --out is omitted, saves to /_preview.png. +""" + +import argparse +import math +import os +import sys + +try: + import tomllib # Python 3.11+ +except ModuleNotFoundError: # Python 3.10 and older (e.g. the miniforge3 module) + import tomli as tomllib # same load() API + +import numpy as np + +import matplotlib +matplotlib.use("Agg") # headless cluster: no interactive display +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle + + +# --------------------------------------------------------------------------- # +# toml helpers (mirror toml::find_or: return default if any key is missing) # +# --------------------------------------------------------------------------- # +def find_or(d, default, *keys): + cur = d + for k in keys: + if not isinstance(cur, dict) or k not in cur: + return default + cur = cur[k] + return cur + + +# --------------------------------------------------------------------------- # +# metric / extent handling (grid.cpp ~L416-497) # +# --------------------------------------------------------------------------- # +CARTESIAN_METRICS = {"minkowski"} +# everything else that entity supports is curvilinear (r-first extent): +# spherical, qspherical, kerr_schild, kerr_schild_0, qkerr_schild +def is_cartesian(metric_name): + return metric_name.strip().lower() in CARTESIAN_METRICS + + +def global_extent(td): + """Replicates grid.cpp: parse grid.extent, auto-fill theta/phi for + non-Cartesian metrics. Returns (extent_pairs, dim, cartesian, metric_name). + + extent_pairs is a list of (lo, hi) of length == dim (== len(resolution)). + """ + resolution = find_or(td, None, "grid", "resolution") + if resolution is None: + raise ValueError("grid.resolution missing") + dim = len(resolution) + metric_name = find_or(td, "minkowski", "grid", "metric", "metric") + cart = is_cartesian(metric_name) + + extent = find_or(td, None, "grid", "extent") + if extent is None: + raise ValueError("grid.extent missing") + # deep copy as list of [lo,hi] + ext = [list(pair) for pair in extent] + + # grid.cpp: if extent has more rows than dim, truncate to dim + if len(ext) > dim: + ext = ext[:dim] + + if not cart: + # non-Cartesian: extent gives only the r-range; append theta,(phi). + # (grid.cpp errors if >1 row is supplied for non-cartesian; we just + # keep the r-row and append.) + ext = [ext[0]] + ext.append([0.0, math.pi]) # theta in [0, pi] (2D and 3D) + if dim == 3: + ext.append([0.0, 2.0 * math.pi]) # phi in [0, 2pi] (3D only) + + if len(ext) != dim: + raise ValueError(f"inferred grid.extent has {len(ext)} rows, expected {dim}") + + pairs = [(float(p[0]), float(p[1])) for p in ext] + return pairs, dim, cart, metric_name + + +# --------------------------------------------------------------------------- # +# Camera (renderer.cpp Renderer::init, composite.h projectToScreen) # +# --------------------------------------------------------------------------- # +def _norm3(a): + n = math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) + if n > 1e-30: + return (a[0] / n, a[1] / n, a[2] / n) + return (a[0], a[1], a[2]) + + +def _cross3(a, b): + return (a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0]) + + +def _dot3(a, b): + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +class Camera: + """POD camera identical to out::CameraDevice, built exactly as in + Renderer::init. eye/forward/right/up in world coords; ortho/persp framing.""" + + def __init__(self, td, region, width, height): + # region is a list of (lo,hi); may be < 3 axes (zero-filled), mirroring + # the C++ which sizes center/size over m_region.size() and d<3. + center = [0.0, 0.0, 0.0] + size = [0.0, 0.0, 0.0] + for d in range(min(len(region), 3)): + center[d] = 0.5 * (region[d][0] + region[d][1]) + size[d] = region[d][1] - region[d][0] + diag = math.sqrt(size[0] ** 2 + size[1] ** 2 + size[2] ** 2) + + cam_ortho = find_or(td, True, "output", "render", "camera", "orthographic") + pos = find_or(td, [], "output", "render", "camera", "position") + look = find_or(td, [], "output", "render", "camera", "look_at") + up = find_or(td, [], "output", "render", "camera", "up") + fov = float(find_or(td, 35.0, "output", "render", "camera", "fov")) + # default ortho_height covers the box from any view -> == box diagonal + ortho_height = float( + find_or(td, diag, "output", "render", "camera", "ortho_height")) + + # default eye: box center pushed back along (1,1,1) by ~1.7 diagonals + eye = [0.0, 0.0, 0.0] + lookat = [0.0, 0.0, 0.0] + for d in range(3): + if len(pos) == 3: + eye[d] = float(pos[d]) + else: + eye[d] = center[d] + 1.7 * diag * 0.57735026919 + lookat[d] = float(look[d]) if len(look) == 3 else center[d] + if len(up) == 3: + upv = [float(up[0]), float(up[1]), float(up[2])] + else: + upv = [0.0, 0.0, 1.0] + + forward = _norm3([lookat[0] - eye[0], lookat[1] - eye[1], + lookat[2] - eye[2]]) + right = _norm3(_cross3(forward, upv)) + up_cam = _cross3(right, forward) # already unit (right,forward unit & perp) + + self.eye = list(eye) + self.forward = list(forward) + self.right = list(right) + self.up = list(up_cam) + self.aspect = float(width) / float(height) + self.tan_half_fov = math.tan(0.5 * fov * math.pi / 180.0) + self.orthographic = bool(cam_ortho) + self.half_h = 0.5 * ortho_height + self.half_w = self.half_h * self.aspect + # keep for the summary line + self.ortho_height = ortho_height + self.fov = fov + + def pan(self, shift): + """Moving view: pure pan of the eye (forward/right/up/half_h unchanged), + matching Renderer::updateForTime.""" + for d in range(3): + self.eye[d] += shift[d] + + def project(self, p, W, H): + """projectToScreen: world point -> (px, py) in pixels (py DOWN, + image origin top-left). Returns None if behind a perspective camera.""" + dx = p[0] - self.eye[0] + dy = p[1] - self.eye[1] + dz = p[2] - self.eye[2] + cx = dx * self.right[0] + dy * self.right[1] + dz * self.right[2] + cy = dx * self.up[0] + dy * self.up[1] + dz * self.up[2] + if self.orthographic: + fx = cx / self.half_w + fy = cy / self.half_h + else: + cz = dx * self.forward[0] + dy * self.forward[1] + dz * self.forward[2] + if cz <= 1e-6: + return None + fx = (cx / cz) / (self.aspect * self.tan_half_fov) + fy = (cy / cz) / self.tan_half_fov + px = (fx + 1.0) * 0.5 * W - 0.5 + py = (1.0 - fy) * 0.5 * H - 0.5 + return (px, py) + + +# --------------------------------------------------------------------------- # +# region resolution (renderer.cpp Renderer::init ~L128-158) # +# --------------------------------------------------------------------------- # +def resolve_region(td, ext): + """m_region starts as the global extent; x{d+1}_lim clamps axis d to the box + if it is a valid [lo,hi] with hi>lo that overlaps. Returns (region, has_region).""" + region = [list(p) for p in ext] + has_region = False + keys = ["x1_lim", "x2_lim", "x3_lim"] + for d in range(min(len(ext), 3)): + lim = find_or(td, [], "output", "render", keys[d]) + if not lim: + continue + if len(lim) != 2 or lim[1] <= lim[0]: + print(f" warning: output.render.{keys[d]} must be [lo,hi] with hi>lo; ignoring") + continue + lo = max(float(lim[0]), ext[d][0]) + hi = min(float(lim[1]), ext[d][1]) + if hi > lo: + region[d] = [lo, hi] + has_region = True + else: + print(f" warning: output.render.{keys[d]} does not overlap the domain; ignoring") + return [tuple(p) for p in region], has_region + + +def apply_moving_view(td, region, ext, cam, time, dim): + """Renderer::updateForTime: dt=max(0, t-t0); shift=vel*dt; pan region+eye.""" + vel = find_or(td, [], "output", "render", "camera_velocity") + if not vel: + return region + v = [0.0, 0.0, 0.0] + for d in range(min(len(vel), 3)): + v[d] = float(vel[d]) + if v == [0.0, 0.0, 0.0]: + return region + t0 = float(find_or(td, 0.0, "output", "render", "camera_start_time")) + dt = max(0.0, time - t0) + shift = [v[0] * dt, v[1] * dt, v[2] * dt] + new_region = [] + for d in range(len(region)): + s = shift[d] if d < 3 else 0.0 + new_region.append((region[d][0] + s, region[d][1] + s)) + cam.pan(shift) # cam only meaningful for the 3D mode; harmless otherwise + return new_region + + +# --------------------------------------------------------------------------- # +# "nice" ticks (axes.h niceNum / niceTicks) for annotation # +# --------------------------------------------------------------------------- # +def _nice_num(x, do_round): + if x <= 0.0: + return 1.0 + e = math.floor(math.log10(x)) + f = x / (10.0 ** e) + if do_round: + nf = 1.0 if f < 1.5 else (2.0 if f < 3.0 else (5.0 if f < 7.0 else 10.0)) + else: + nf = 1.0 if f <= 1.0 else (2.0 if f <= 2.0 else (5.0 if f <= 5.0 else 10.0)) + return nf * (10.0 ** e) + + +def nice_ticks(lo, hi, n): + out = [] + if not (hi > lo) or n < 2: + return out + step = _nice_num((hi - lo) / (n - 1), True) + if step <= 0.0: + return out + g0 = math.ceil(lo / step) * step + eps = 1e-6 * step + v = g0 + while v <= hi + 0.5 * step: + if lo - eps <= v <= hi + eps: + out.append(0.0 if abs(v) < eps else v) + v += step + return out + + +# --------------------------------------------------------------------------- # +# 3D field-line SEED lattice (fieldlines.h traceFieldLines seed setup) # +# --------------------------------------------------------------------------- # +def field_line_seeds_3d(td, region, cam, H): + """Reproduce the seed-lattice geometry (NOT the traced lines). + + world_per_pixel wpp = (cam.half_h*2)/H (metadomain_render.cpp) + spacing = max(seed_px,1)*wpp; if n_seed>seed_max: spacing *= cbrt(n_seed/seed_max) + ns[d] = max(1, floor(size[d]/spacing)); seeds at cell centers over the + field-line COARSE grid, which spans the FULL global extent -- BUT here we + seed over the render region's box (== extent when uncropped), matching the + coarse grid origin=extent.first in the uncropped case. We use the region box + the camera frames so the schematic overlays the drawn cube. + """ + fl_enable = find_or(td, False, "output", "render", "fieldlines", "enable") + # any scene may also request the overlay + scenes = find_or(td, [], "output", "render", "scenes") + any_fl = any( + (find_or(sc, False, "fieldlines") or find_or(sc, "", "field") == "fieldlines") + for sc in scenes + ) + if not (fl_enable or any_fl): + return None + + seed_px = float(find_or(td, 8.0, "output", "render", "fieldlines", "seed_px")) + seed_max = int(find_or(td, 4096, "output", "render", "fieldlines", "seed_max")) + + wpp = (cam.half_h * 2.0) / float(H) + size = [region[d][1] - region[d][0] for d in range(3)] + origin = [region[d][0] for d in range(3)] + + spacing = max(seed_px, 1.0) * wpp + + def count_seeds(sp): + ns = [] + tot = 1 + for d in range(3): + n = max(1, int(math.floor(size[d] / sp))) if sp > 0 else 1 + ns.append(n) + tot *= n + return tot, ns + + n_seed, ns = count_seeds(spacing) + if n_seed > seed_max and seed_max > 0: + grow = (float(n_seed) / float(seed_max)) ** (1.0 / 3.0) + spacing *= grow + n_seed, ns = count_seeds(spacing) + + seeds = [] + for k in range(ns[2]): + for j in range(ns[1]): + for i in range(ns[0]): + seeds.append(( + origin[0] + (i + 0.5) * size[0] / ns[0], + origin[1] + (j + 0.5) * size[1] / ns[1], + origin[2] + (k + 0.5) * size[2] / ns[2], + )) + return seeds, ns + + +# --------------------------------------------------------------------------- # +# cube edges: the 12 edges of an axis-aligned box given by (lo,hi) per axis # +# --------------------------------------------------------------------------- # +def cube_corners(box): + """box: list of (lo,hi) for 3 axes. Corner m: bit0->x, bit1->y, bit2->z + (matches the C++ corner() ordering in axes.h / screenBBox).""" + corners = [] + for m in range(8): + corners.append(( + box[0][1] if (m & 1) else box[0][0], + box[1][1] if (m & 2) else box[1][0], + box[2][1] if (m & 4) else box[2][0], + )) + return corners + + +# the 12 edges as (corner_i, corner_j) index pairs +CUBE_EDGES = [ + (0, 1), (2, 3), (4, 5), (6, 7), # x-parallel + (0, 2), (1, 3), (4, 6), (5, 7), # y-parallel + (0, 4), (1, 5), (2, 6), (3, 7), # z-parallel +] + + +# --------------------------------------------------------------------------- # +# 3D axis-annotation edge selection (axes.h drawAxes3D) # +# # +# A box axis has FOUR parallel edges; the ticks/labels go on exactly one. We # +# only ever pick a *silhouette* edge -- one whose two adjacent faces point in # +# opposite directions relative to the camera (one toward it, one away). A # +# silhouette edge always lies on the drawn OUTLINE of the box, so it is in the # +# foreground -- never occluded by the volume -- and its ticks, pushed outward # +# from the projected centroid, land in empty background. Among the (usually # +# two) silhouette candidates we take the one nearest the bottom-left of the # +# image (score = pixel_y - pixel_x), the conventional place for annotation. # +# This is a faithful port of out::drawAxes3D so the preview matches the # +# renderer's choice of which spine carries the axis. # +# --------------------------------------------------------------------------- # +def _front_face(cam, axis, side): + """True if the box face perpendicular to `axis` at `side` (1=high, 0=low) + faces the camera (its outward normal points back toward the eye).""" + nrm = 1.0 if side else -1.0 + return (nrm * (-cam.forward[axis])) > 0.0 + + +def select_axis_edge_3d(cam, d, cx, cy, ccx, ccy): + """Choose the edge parallel to axis d that carries the ticks + label. + + cx,cy are the 8 projected corner pixel coords (cube_corners order); ccx,ccy + the projected box centroid. Returns (m0, m1, pxd, pyd): the edge's two corner + indices (m0 has axis d at its low end) and the unit screen-space OUTWARD push + direction (perpendicular to the edge, pointing away from the centroid).""" + e1 = 1 if d == 0 else 0 # the two perpendicular axes + e2 = 1 if d == 2 else 2 + best = None + for s1 in (0, 1): + for s2 in (0, 1): + m0 = (s1 << e1) | (s2 << e2) + m1 = m0 | (1 << d) + mx = 0.5 * (cx[m0] + cx[m1]) + my = 0.5 * (cy[m0] + cy[m1]) + score = my - mx # prefer the foreground (bottom-left) edge + if _front_face(cam, e1, s1) != _front_face(cam, e2, s2): + score += 1e6 # strongly prefer silhouette (outline) edges + if best is None or score > best[0]: + best = (score, m0, m1) + _, m0, m1 = best + ex, ey = cx[m1] - cx[m0], cy[m1] - cy[m0] + el = math.hypot(ex, ey) or 1.0 + ex, ey = ex / el, ey / el + pxd, pyd = -ey, ex # screen-perpendicular to the edge + mxv = 0.5 * (cx[m0] + cx[m1]) - ccx + myv = 0.5 * (cy[m0] + cy[m1]) - ccy + if pxd * mxv + pyd * myv < 0.0: # flip to point away from the box centroid + pxd, pyd = -pxd, -pyd + return m0, m1, pxd, pyd + + +# --------------------------------------------------------------------------- # +# 3D preview # +# --------------------------------------------------------------------------- # +def draw_3d(td, ext, region, has_region, cam, W, H, out_path, sim_name): + fig, ax = plt.subplots(figsize=(W / 100.0, H / 100.0), dpi=100) + + def project_box(box, color, lw, label, ls="-"): + corners = cube_corners(box) + proj = [cam.project(c, W, H) for c in corners] + first = True + for (a, b) in CUBE_EDGES: + pa, pb = proj[a], proj[b] + if pa is None or pb is None: + continue # edge with a corner behind a perspective camera + ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color=color, lw=lw, ls=ls, + label=(label if first else None), zorder=3) + first = False + + # full extent (light gray) + project_box([ext[0], ext[1], ext[2]], color="0.6", lw=1.2, + label="full extent") + # region crop, if distinct + if has_region: + project_box([region[0], region[1], region[2]], color="tab:blue", + lw=2.0, label="region crop") + + # axes tick labels: for each axis, pick the FOREGROUND (silhouette) edge of + # the framed box and annotate along it, exactly as out::drawAxes3D does, so + # the labels never end up on an edge hidden behind the volume. + axes_on = find_or(td, False, "output", "render", "axes") + nticks = int(find_or(td, 5, "output", "render", "axis_ticks")) + frame_box = region if has_region else ext + axis_names = find_or(td, [], "output", "render", "axis_labels") + default_names = ["x", "y", "z"] + if axes_on: + corners = cube_corners([frame_box[0], frame_box[1], frame_box[2]]) + cx = [0.0] * 8 + cy = [0.0] * 8 + for m in range(8): + pr = cam.project(corners[m], W, H) + cx[m] = pr[0] if pr is not None else 0.0 + cy[m] = pr[1] if pr is not None else 0.0 + cen = [0.5 * (frame_box[d][0] + frame_box[d][1]) for d in range(3)] + prc = cam.project(cen, W, H) + ccx = prc[0] if prc is not None else 0.0 + ccy = prc[1] if prc is not None else 0.0 + + tl = 8.0 # tick-mark length [px] + num_off = tl + 10.0 # numeric-label center offset from the edge [px] + name_off = tl + 30.0 # axis-name center offset from the edge [px] + for d in range(3): + name = axis_names[d] if d < len(axis_names) else default_names[d] + m0, m1, pxd, pyd = select_axis_edge_3d(cam, d, cx, cy, ccx, ccy) + # o = corner(m0): perpendicular coords fixed, axis d swept for ticks + o = list(corners[m0]) + lo_d, hi_d = frame_box[d][0], frame_box[d][1] + for tv in nice_ticks(lo_d, hi_d, nticks): + p = list(o) + p[d] = tv + pr = cam.project(p, W, H) + if pr is None: + continue + a, b = pr + ax.plot([a, a + pxd * tl], [b, b + pyd * tl], + color="0.35", lw=1.0, zorder=4) + ax.annotate(f"{tv:g}", (a + pxd * num_off, b + pyd * num_off), + fontsize=6, color="0.25", ha="center", va="center") + # axis name at the MIDDLE of the chosen edge, pushed further outward + mid = list(o) + mid[d] = 0.5 * (lo_d + hi_d) + pr = cam.project(mid, W, H) + if pr is not None: + a, b = pr + ax.annotate(name, (a + pxd * name_off, b + pyd * name_off), + fontsize=9, color="k", fontweight="bold", + ha="center", va="center") + + # field-line SEED lattice (schematic scatter, NOT traced lines) + fl = field_line_seeds_3d(td, frame_box, cam, H) + if fl is not None: + seeds, ns = fl + pxs, pys = [], [] + for s in seeds: + pr = cam.project(s, W, H) + if pr is not None: + pxs.append(pr[0]) + pys.append(pr[1]) + if pxs: + ax.scatter(pxs, pys, s=8, c="tab:red", marker="o", alpha=0.6, + edgecolors="none", zorder=2, + label=f"field-line seeds (schematic, {ns[0]}x{ns[1]}x{ns[2]})") + + ax.set_xlim(0, W) + ax.set_ylim(H, 0) # inverted y: origin upper-left, matches the PNG + ax.set_aspect("equal") # lock width:height 1:1 in pixel space + ax.set_xlabel("screen x [px]") + ax.set_ylabel("screen y [px]") + proj_kind = "orthographic" if cam.orthographic else f"perspective (fov {cam.fov:g})" + ax.set_title(f"{sim_name}: 3D scene preview ({proj_kind})", fontsize=10) + ax.legend(loc="upper right", fontsize=7, framealpha=0.85) + fig.tight_layout() + fig.savefig(out_path, dpi=100) + plt.close(fig) + + +# --------------------------------------------------------------------------- # +# 2D window derivation (metadomain_render.cpp 2D branch) # +# --------------------------------------------------------------------------- # +def derive_2d_window(td, ext, region, cartesian, mirror, W, H): + """Return (umin,umax,vmin,vmax) -- the aspect-expanded world window mapped + onto the WxH image, exactly as metadomain_render.cpp derives it.""" + x1lo, x1hi = region[0][0], region[0][1] + x2lo, x2hi = region[1][0], region[1][1] + if cartesian: + umin, umax, vmin, vmax = x1lo, x1hi, x2lo, x2hi + else: + # meridional (X = r sin th, Z = r cos th) bbox of the wedge, sampling the + # boundary (arcs at r={x1lo,x1hi}, rays at theta={x2lo,x2hi}). + umin, umax = 1e30, -1e30 + vmin, vmax = 1e30, -1e30 + NB = 65 + + def accXZ(r, th): + nonlocal umin, umax, vmin, vmax + X = r * math.sin(th) + Z = r * math.cos(th) + umin = min(umin, X); umax = max(umax, X) + vmin = min(vmin, Z); vmax = max(vmax, Z) + if mirror: + umin = min(umin, -X); umax = max(umax, -X) + + for k in range(NB): + t = k / (NB - 1) + th = x2lo + (x2hi - x2lo) * t + rr = x1lo + (x1hi - x1lo) * t + accXZ(x1lo, th); accXZ(x1hi, th) + accXZ(rr, x2lo); accXZ(rr, x2hi) + + # expand the window to the image aspect (centered) so geometry isn't stretched + waspect = (umax - umin) / (vmax - vmin) + iaspect = float(W) / float(H) + if iaspect > waspect: + cu = 0.5 * (umin + umax) + hu = 0.5 * (vmax - vmin) * iaspect + umin, umax = cu - hu, cu + hu + else: + cv = 0.5 * (vmin + vmax) + hv = 0.5 * (umax - umin) / iaspect + vmin, vmax = cv - hv, cv + hv + + # spherical slices get a 1.12x background border so the round outline + labels + # are not clipped (Cartesian fills the frame and needs none). + if not cartesian: + pad = 1.12 + cu = 0.5 * (umin + umax); hu = 0.5 * (umax - umin) * pad + cv = 0.5 * (vmin + vmax); hv = 0.5 * (vmax - vmin) * pad + umin, umax = cu - hu, cu + hu + vmin, vmax = cv - hv, cv + hv + + return umin, umax, vmin, vmax + + +def draw_2d_cartesian(td, ext, region, has_region, W, H, out_path, sim_name): + umin, umax, vmin, vmax = derive_2d_window(td, ext, region, True, False, W, H) + fig, ax = plt.subplots(figsize=(W / 100.0, H / 100.0), dpi=100) + + # aspect-expanded slice window (the background-padded frame) + ax.add_patch(Rectangle((umin, vmin), umax - umin, vmax - vmin, + fill=False, ec="0.7", lw=1.0, ls="--", + label="slice window (aspect-expanded)")) + # full domain box + ax.add_patch(Rectangle((ext[0][0], ext[1][0]), + ext[0][1] - ext[0][0], ext[1][1] - ext[1][0], + fill=False, ec="0.4", lw=1.5, label="domain")) + # region crop + if has_region: + ax.add_patch(Rectangle((region[0][0], region[1][0]), + region[0][1] - region[0][0], + region[1][1] - region[1][0], + fill=False, ec="tab:blue", lw=2.0, + label="region crop")) + + # ticks (nice numbers over the data box == region) + axes_on = find_or(td, False, "output", "render", "axes") + nticks = int(find_or(td, 5, "output", "render", "axis_ticks")) + if axes_on: + for tv in nice_ticks(region[0][0], region[0][1], nticks): + ax.axvline(tv, color="0.85", lw=0.5, zorder=0) + for tv in nice_ticks(region[1][0], region[1][1], nticks): + ax.axhline(tv, color="0.85", lw=0.5, zorder=0) + + ax.set_xlim(umin, umax) + ax.set_ylim(vmin, vmax) # +v up (Cartesian slice: y is up in world) + ax.set_aspect("equal") + ax.set_xlabel("x1") + ax.set_ylabel("x2") + ax.set_title(f"{sim_name}: 2D Cartesian slice preview", fontsize=10) + ax.legend(loc="upper right", fontsize=7, framealpha=0.85) + fig.tight_layout() + fig.savefig(out_path, dpi=100) + plt.close(fig) + + +def draw_2d_spherical(td, ext, region, has_region, mirror, W, H, out_path, sim_name): + umin, umax, vmin, vmax = derive_2d_window(td, ext, region, False, mirror, W, H) + # (r, theta) wedge of the render region + rmin, rmax = region[0][0], region[0][1] + tmin, tmax = region[1][0], region[1][1] + + fig, ax = plt.subplots(figsize=(W / 100.0, H / 100.0), dpi=100) + + def wedge_boundary(rmn, rmx, tmn, tmx, sign, color, lw, label=None): + # outer + inner arcs and two rays, in meridional (X=r sin th, Z=r cos th) + th = np.linspace(tmn, tmx, 200) + # outer arc + ax.plot(sign * rmx * np.sin(th), rmx * np.cos(th), color=color, lw=lw, + label=label) + # inner arc + ax.plot(sign * rmn * np.sin(th), rmn * np.cos(th), color=color, lw=lw) + # rays at tmin, tmax + for tt in (tmn, tmx): + ax.plot([sign * rmn * math.sin(tt), sign * rmx * math.sin(tt)], + [rmn * math.cos(tt), rmx * math.cos(tt)], color=color, lw=lw) + + # full extent wedge (light gray) + wedge_boundary(ext[0][0], ext[0][1], ext[1][0], ext[1][1], 1.0, "0.6", 1.2, + label="full extent") + if mirror: + wedge_boundary(ext[0][0], ext[0][1], ext[1][0], ext[1][1], -1.0, "0.6", 1.2) + + # region wedge (colored) if cropped + if has_region: + wedge_boundary(rmin, rmax, tmin, tmax, 1.0, "tab:blue", 2.0, + label="region crop") + if mirror: + wedge_boundary(rmin, rmax, tmin, tmax, -1.0, "tab:blue", 2.0) + + # radial ticks along the symmetry axis (X=0) + axes_on = find_or(td, False, "output", "render", "axes") + nticks = int(find_or(td, 5, "output", "render", "axis_ticks")) + if axes_on: + for Rv in nice_ticks(0.0, ext[0][1], nticks): + ax.plot(0.0, Rv, marker="+", color="0.3", ms=6) + ax.annotate(f"{Rv:g}", (0.0, Rv), fontsize=6, color="0.25", + xytext=(-8, 0), textcoords="offset points", ha="right", + va="center") + + ax.set_xlim(umin, umax) + ax.set_ylim(vmin, vmax) + ax.set_aspect("equal") + ax.set_xlabel("X = r sin(theta)") + ax.set_ylabel("Z = r cos(theta)") + m = "mirrored" if mirror else "half-plane" + ax.set_title(f"{sim_name}: 2D spherical meridional preview ({m})", fontsize=10) + ax.legend(loc="upper right", fontsize=7, framealpha=0.85) + fig.tight_layout() + fig.savefig(out_path, dpi=100) + plt.close(fig) + + +# --------------------------------------------------------------------------- # +# main # +# --------------------------------------------------------------------------- # +def main(): + ap = argparse.ArgumentParser( + description="Data-free preview of the entity in-situ renderer scene geometry.") + ap.add_argument("toml", help="simulation .toml file") + ap.add_argument("--out", default=None, + help="output PNG (default: /_preview.png)") + ap.add_argument("--time", type=float, default=0.0, + help="sim time T for the moving-view pan (default 0)") + ap.add_argument("--scene", type=int, default=None, + help="scene index (accepted for parity; geometry is scene-" + "independent, so it only affects the reported label)") + args = ap.parse_args() + + if not os.path.isfile(args.toml): + print(f"error: no such file: {args.toml}", file=sys.stderr) + return 2 + + with open(args.toml, "rb") as f: + td = tomllib.load(f) + + # renderer enabled? + if not find_or(td, False, "output", "render", "enable"): + print("note: [output.render].enable is false in this toml; previewing anyway.") + + sim_name = find_or(td, "sim", "simulation", "name") + width = int(find_or(td, 1024, "output", "render", "width")) + height = int(find_or(td, 1024, "output", "render", "height")) + mirror = bool(find_or(td, True, "output", "render", "mirror")) + + ext, dim, cartesian, metric_name = global_extent(td) + + # output path + if args.out: + out_path = args.out + else: + toml_dir = os.path.dirname(os.path.abspath(args.toml)) + out_path = os.path.join(toml_dir, f"{sim_name}_preview.png") + + # region + camera (region drives the default framing) + region, has_region = resolve_region(td, ext) + # camera is only meaningful in 3D, but building it is cheap & shares the pan + cam = Camera(td, region, width, height) + region = apply_moving_view(td, region, ext, cam, args.time, dim) + # NB: the C++ pans the eye but keeps forward/right/up/half_h; we already + # panned cam.eye, and the ortho_height / basis are region-independent after + # the initial framing, so cam is now consistent with the panned region. + + # ---- summary line ------------------------------------------------------- + def fmt_pairs(pairs): + return "[" + ", ".join(f"({p[0]:g},{p[1]:g})" for p in pairs) + "]" + + if dim == 3 and cartesian: + mode = "3D box (Cartesian volume)" + elif dim == 3: + mode = "3D (non-Cartesian: unsupported by renderer)" + elif dim == 2 and cartesian: + mode = "2D Cartesian slice" + elif dim == 2: + mode = "2D spherical meridional slice" + else: + mode = "1D (nothing to render)" + + eye_str = f"({cam.eye[0]:g},{cam.eye[1]:g},{cam.eye[2]:g})" + print(f"mode={mode} | metric={metric_name} | eye={eye_str} | " + f"ortho_height={cam.ortho_height:g} | region={fmt_pairs(region)}") + if args.scene is not None: + print(f" (scene index {args.scene} requested; geometry is scene-independent)") + + # ---- dispatch ----------------------------------------------------------- + if dim == 3 and cartesian: + draw_3d(td, ext, region, has_region, cam, width, height, out_path, sim_name) + elif dim == 3: + print("warning: 3D non-Cartesian is not a renderer mode (3D is Cartesian-" + "only); nothing drawn.") + return 1 + elif dim == 2 and cartesian: + draw_2d_cartesian(td, ext, region, has_region, width, height, out_path, + sim_name) + elif dim == 2: + draw_2d_spherical(td, ext, region, has_region, mirror, width, height, + out_path, sim_name) + else: + print("warning: 1D run -- the renderer is inactive; nothing to preview.") + return 1 + + print(f"wrote {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())