Skip to content

Commit e825a25

Browse files
committed
planetarium dome rendering in 2D
1 parent 2511855 commit e825a25

5 files changed

Lines changed: 310 additions & 25 deletions

File tree

input.example.toml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,52 @@
854854
# @default: the global box diagonal (the whole box fits from any angle)
855855
ortho_height = ""
856856

857+
# Fulldome fisheye ("planetarium dome master"). 2D only; a circular image is
858+
# centered in the frame's inscribed circle with the corners left as the
859+
# background (the dome master's black border). Set `width == height` (e.g.
860+
# 4096) for a square master. When enabled, the axes and the outside colorbar
861+
# strip are suppressed so the PNG stays exactly width x height. Seamless
862+
# across MPI domains (the pixel->world map is a shared, deterministic function
863+
# and the tiles stay disjoint). Ignored (with a warning) for 3D.
864+
# @note: CARTESIAN -- the flat plane is warped radially into the disk; use
865+
# `fov`/`radius`/`center`/`projection` below.
866+
# @note: SPHERICAL / GR Kerr-Schild -- the meridional slice is ALREADY a
867+
# disk, so dome mode only mirrors it to a full disk (see `mirror`
868+
# above; keep it true) and fits it to the inscribed circle. The
869+
# `fov`/`radius`/`center`/`projection` keys are ignored (the native
870+
# (X, Z) meridional map is used, with image radius proportional to
871+
# the physical radius r, r=0 at the disk center).
872+
[output.render.dome]
873+
# Build the fisheye dome master instead of the plain slice
874+
# @type: bool
875+
# @default: false
876+
enable = ""
877+
# (Cartesian only) Full dome field of view in degrees (image radius maps
878+
# linearly to the dome zenith angle: the rim is at fov/2)
879+
# @type: float [> 0.0, <= 180.0]
880+
# @default: 180.0 # a full hemisphere
881+
fov = ""
882+
# (Cartesian only) World radius of the circular cutout mapped onto the dome
883+
# @type: float [> 0.0]
884+
# @default: half the shorter domain side (the largest centered disk that
885+
# fits inside the box)
886+
radius = ""
887+
# (Cartesian only) World-space center of the cutout
888+
# @type: array<float> [size 2]
889+
# @default: the domain center
890+
center = ""
891+
# (Cartesian only) How the dome zenith angle maps to a world radius on the
892+
# flat slice
893+
# @type: string
894+
# @enum: "equidistant" (r proportional to angle; the fulldome image
895+
# standard -- a straight radial scaling of the cutout)
896+
# "gnomonic" (r ~ tan(angle); the slice as a flat "ceiling"
897+
# tangent to the dome -- straight sim lines stay straight)
898+
# "stereographic" (r ~ tan(angle/2); conformal, preserves shapes)
899+
# "orthographic" (r ~ sin(angle); the slice as seen face-on)
900+
# @default: "equidistant"
901+
projection = ""
902+
857903
# One scene per scalar field -> one PNG stream. Repeat the table for each.
858904
[[output.render.scenes]]
859905
# Scalar field to render (a volume render needs a scalar, so vectors are

src/framework/domain/metadomain_render.cpp

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,18 @@ namespace ntt {
912912
const int H = g_renderer.height();
913913
const bool mirror = g_renderer.mirror();
914914

915+
// fulldome fisheye ("dome master"). Cartesian slices are a flat plane, so
916+
// the kernel warps each pixel radially (fisheye). Curvilinear slices
917+
// (spherical / GR Kerr-Schild) are ALREADY a meridional disk, so dome mode
918+
// there is only a framing change: mirror to a full disk (the `mirror`
919+
// default) and fit that disk to the frame's inscribed circle (the pad skip
920+
// below), while the kernel keeps its native (X, Z) meridional map. Reported
921+
// back so the (metric-agnostic) compositor keeps the frame a clean square.
922+
// All ranks take the same branch (M is fixed per run), so it stays seamless
923+
// across tiles.
924+
const out::DomeMap dome = g_renderer.dome();
925+
g_renderer.setDomeActive(dome.enabled);
926+
915927
// global slice-plane world window (shared by all ranks -> seamless),
916928
// taken from the optional render region (== full extent when uncropped).
917929
// gext (the full extent) is kept for the field-line coarse grid below.
@@ -973,15 +985,19 @@ namespace ntt {
973985
}
974986
// spherical slices get a background border so the round outline and its
975987
// R/theta labels are not clipped at the frame edges (Cartesian fills the
976-
// frame and draws its ticks in dedicated margins, so it needs none).
988+
// frame and draws its ticks in dedicated margins, so it needs none). A dome
989+
// master skips it: the disk must reach the frame's inscribed circle (which
990+
// the projector maps to the dome horizon), and it draws no axes.
977991
if constexpr (M::CoordType != Coord::type::Cartesian) {
978-
const real_t pad = static_cast<real_t>(1.12);
979-
const real_t cu = HALF * (umin + umax), hu = HALF * (umax - umin) * pad;
980-
const real_t cv = HALF * (vmin + vmax), hv = HALF * (vmax - vmin) * pad;
981-
umin = cu - hu;
982-
umax = cu + hu;
983-
vmin = cv - hv;
984-
vmax = cv + hv;
992+
if (not dome.enabled) {
993+
const real_t pad = static_cast<real_t>(1.12);
994+
const real_t cu = HALF * (umin + umax), hu = HALF * (umax - umin) * pad;
995+
const real_t cv = HALF * (vmin + vmax), hv = HALF * (vmax - vmin) * pad;
996+
umin = cu - hu;
997+
umax = cu + hu;
998+
vmin = cv - hv;
999+
vmax = cv + hv;
1000+
}
9851001
}
9861002

9871003
// hand the world window + axis names to the (host) axes overlay. Default
@@ -1015,8 +1031,40 @@ namespace ntt {
10151031
// boundary; an arc for spherical, a box for Cartesian)
10161032
const auto le = local_domain->mesh.extent();
10171033
auto toPix = [&](real_t u, real_t v, real_t& px, real_t& py) {
1018-
px = (u - umin) / (umax - umin) * static_cast<real_t>(W) - HALF;
1019-
py = (vmax - v) / (vmax - vmin) * static_cast<real_t>(H) - HALF;
1034+
if (dome.enabled and M::CoordType == Coord::type::Cartesian) {
1035+
// forward fisheye projection (inverse of the kernel's radial map),
1036+
// used to bound this domain's footprint on the dome disk. Cartesian
1037+
// only -- curvilinear dome uses the linear (X, Z) map below, matching
1038+
// the kernel's native meridional projection.
1039+
const real_t cxp = HALF * static_cast<real_t>(W);
1040+
const real_t cyp = HALF * static_cast<real_t>(H);
1041+
const real_t Rpx = HALF * static_cast<real_t>(std::min(W, H));
1042+
const real_t dx = u - dome.cx, dy = v - dome.cy;
1043+
const real_t rw = std::sqrt(dx * dx + dy * dy);
1044+
real_t fr = (dome.R > ZERO) ? (rw / dome.R) : ZERO;
1045+
if (fr > ONE) {
1046+
fr = ONE; // clamp onto the rim (conservative for the bbox)
1047+
}
1048+
real_t theta;
1049+
if (dome.law == out::DomeMap::Gnomonic) {
1050+
theta = std::atan(fr * std::tan(dome.theta_max));
1051+
} else if (dome.law == out::DomeMap::Stereographic) {
1052+
theta = static_cast<real_t>(2) *
1053+
std::atan(fr * std::tan(HALF * dome.theta_max));
1054+
} else if (dome.law == out::DomeMap::Orthographic) {
1055+
theta = std::asin(fr * std::sin(dome.theta_max));
1056+
} else {
1057+
theta = fr * dome.theta_max;
1058+
}
1059+
const real_t rho = (dome.theta_max > ZERO) ? (theta / dome.theta_max)
1060+
: fr;
1061+
const real_t phi = std::atan2(dy, dx);
1062+
px = cxp + rho * Rpx * std::cos(phi) - HALF;
1063+
py = cyp - rho * Rpx * std::sin(phi) - HALF;
1064+
} else {
1065+
px = (u - umin) / (umax - umin) * static_cast<real_t>(W) - HALF;
1066+
py = (vmax - v) / (vmax - vmin) * static_cast<real_t>(H) - HALF;
1067+
}
10201068
};
10211069
real_t minx = static_cast<real_t>(1e30), miny = static_cast<real_t>(1e30);
10221070
real_t maxx = static_cast<real_t>(-1e30), maxy = static_cast<real_t>(-1e30);
@@ -1029,10 +1077,28 @@ namespace ntt {
10291077
maxy = std::max(maxy, py);
10301078
};
10311079
if constexpr (M::CoordType == Coord::type::Cartesian) {
1032-
acc(le[0].first, le[1].first);
1033-
acc(le[0].second, le[1].first);
1034-
acc(le[0].first, le[1].second);
1035-
acc(le[0].second, le[1].second);
1080+
if (dome.enabled) {
1081+
// the fisheye map is nonlinear (and a domain straddling the center
1082+
// wraps around the image center), so bound the footprint by sampling
1083+
// the whole domain-rectangle boundary, not just the 4 corners
1084+
const int NB = 65;
1085+
const real_t x0 = le[0].first, x1 = le[0].second;
1086+
const real_t y0 = le[1].first, y1 = le[1].second;
1087+
for (int k = 0; k < NB; ++k) {
1088+
const real_t t = static_cast<real_t>(k) / static_cast<real_t>(NB - 1);
1089+
const real_t xx = x0 + (x1 - x0) * t;
1090+
const real_t yy = y0 + (y1 - y0) * t;
1091+
acc(xx, y0);
1092+
acc(xx, y1);
1093+
acc(x0, yy);
1094+
acc(x1, yy);
1095+
}
1096+
} else {
1097+
acc(le[0].first, le[1].first);
1098+
acc(le[0].second, le[1].first);
1099+
acc(le[0].first, le[1].second);
1100+
acc(le[0].second, le[1].second);
1101+
}
10361102
} else {
10371103
const int NB = 33;
10381104
const real_t r0 = le[0].first, r1 = le[0].second;
@@ -1213,6 +1279,7 @@ namespace ntt {
12131279
by0,
12141280
bw,
12151281
mirror,
1282+
dome,
12161283
x1lo,
12171284
x1hi,
12181285
x2lo,

src/output/render/renderer.cpp

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,85 @@ namespace out {
157157
}
158158
}
159159

160+
/* ---- fulldome fisheye (planetarium dome master) --------------------- */
161+
// A 2D-only radial ("fisheye") projection of a circular cutout centered on
162+
// the domain, drawn into the frame's inscribed circle (corners kept as the
163+
// background border -> a valid dome master). The 3D dome is a separate
164+
// workstream; warn if asked for here so it does not silently fall back to
165+
// the volume camera.
166+
{
167+
const bool dome_enable = toml::find_or(td, "output", "render", "dome",
168+
"enable", false);
169+
if (dome_enable) {
170+
if (global_extent.size() != 2) {
171+
raise::Warning("output.render.dome is 2D-only for now; ignoring", HERE);
172+
} else {
173+
m_dome.enabled = true;
174+
const real_t fov = toml::find_or<real_t>(td, "output", "render", "dome",
175+
"fov", static_cast<real_t>(180));
176+
m_dome.theta_max = HALF * fov * static_cast<real_t>(constant::PI) /
177+
static_cast<real_t>(180);
178+
const auto proj = toml::find_or<std::string>(td, "output", "render",
179+
"dome", "projection",
180+
"equidistant");
181+
if (proj == "gnomonic") {
182+
m_dome.law = DomeMap::Gnomonic;
183+
} else if (proj == "stereographic") {
184+
m_dome.law = DomeMap::Stereographic;
185+
} else if (proj == "orthographic") {
186+
m_dome.law = DomeMap::Orthographic;
187+
} else {
188+
if (proj != "equidistant") {
189+
raise::Warning("output.render.dome.projection '" + proj +
190+
"' unknown; using 'equidistant'",
191+
HERE);
192+
}
193+
m_dome.law = DomeMap::Equidistant;
194+
}
195+
// the gnomonic (flat-tangent) law diverges as the dome half-FOV -> 90
196+
// deg (a flat plane never reaches the horizon), so cap it below that.
197+
if (m_dome.law == DomeMap::Gnomonic) {
198+
const real_t cap = static_cast<real_t>(89.0 * constant::PI / 180.0);
199+
if (m_dome.theta_max >= cap) {
200+
raise::Warning("output.render.dome: 'gnomonic' needs fov < 180 deg "
201+
"(a flat plane cannot reach the dome horizon); "
202+
"capping the half-FOV at 89 deg",
203+
HERE);
204+
m_dome.theta_max = cap;
205+
}
206+
}
207+
// default center = domain center; default radius = the largest disk
208+
// that fits inside the (rectangular) domain (half the shorter side).
209+
const real_t Lx = global_extent[0].second - global_extent[0].first;
210+
const real_t Ly = global_extent[1].second - global_extent[1].first;
211+
m_dome.cx = HALF * (global_extent[0].first + global_extent[0].second);
212+
m_dome.cy = HALF * (global_extent[1].first + global_extent[1].second);
213+
const auto ctr = toml::find_or<std::vector<real_t>>(
214+
td, "output", "render", "dome", "center", std::vector<real_t> {});
215+
if (ctr.size() == 2) {
216+
m_dome.cx = ctr[0];
217+
m_dome.cy = ctr[1];
218+
} else if (not ctr.empty()) {
219+
raise::Warning("output.render.dome.center must have 2 entries "
220+
"[x, y]; using the domain center",
221+
HERE);
222+
}
223+
const real_t rdef = HALF * std::min(Lx, Ly);
224+
m_dome.R = toml::find_or<real_t>(td, "output", "render", "dome",
225+
"radius", rdef);
226+
if (m_dome.R <= ZERO) {
227+
m_dome.R = rdef;
228+
}
229+
if (m_width != m_height) {
230+
raise::Warning("output.render.dome: width != height; the fisheye "
231+
"disk is centered on the shorter side and the frame "
232+
"is not a square dome master",
233+
HERE);
234+
}
235+
}
236+
}
237+
}
238+
160239
{
161240
const auto al = toml::find_or<std::vector<std::string>>(
162241
td, "output", "render", "axis_labels", std::vector<std::string> {});
@@ -539,9 +618,12 @@ namespace out {
539618
// The polar (curvilinear) overlay annotates inside the data region (the
540619
// disk is centered with background around it), so it needs no margins.
541620
const bool polar = (m_global_extent.size() == 2) and m_slice_polar;
621+
// a fisheye dome master must stay exactly W x H (its inscribed circle is
622+
// the dome), so it takes no axes margins and no outside colorbar strip.
623+
const bool dome = m_dome_active;
542624
int ml = 0, mb = 0;
543-
out::axesMargins(m_axes and not polar, m_height, ml, mb);
544-
const int strip = (m_colorbar and m_colorbar_outside)
625+
out::axesMargins(m_axes and not polar and not dome, m_height, ml, mb);
626+
const int strip = (m_colorbar and m_colorbar_outside and not dome)
545627
? colorbarBlockWidth(m_height)
546628
: 0;
547629
const int CW = ml + m_width + strip;
@@ -551,7 +633,8 @@ namespace out {
551633
// that pads the window with background): its top & right edges in
552634
// data-region pixels. The axes/spine clamp to it and the time label sits
553635
// in the pad above it, so neither includes the empty aspect padding.
554-
const bool cart2d = (m_global_extent.size() == 2) and not polar;
636+
const bool cart2d = (m_global_extent.size() == 2) and not polar and
637+
not dome;
555638
int dbox_top = 0; // data box top edge (px from data top)
556639
int dbox_bot = m_height; // data box bottom edge (px)
557640
int dbox_right = m_width; // data box right edge (px from data left)
@@ -609,7 +692,7 @@ namespace out {
609692
static_cast<std::size_t>(m_width) * 4,
610693
&canvas[(static_cast<std::size_t>(y) * CW + ml) * 4]);
611694
}
612-
if (m_axes) {
695+
if (m_axes and not dome) {
613696
if (m_global_extent.size() == 3) {
614697
out::drawAxes3D(canvas.data(), CW, CH, ml, m_width, m_height,
615698
m_camera_dev, m_region, m_axis_labels,

src/output/render/renderer.h

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,27 @@ namespace out {
168168
TransferFunction tf;
169169
};
170170

171+
/**
172+
* @brief Fulldome fisheye ("planetarium dome master") projection parameters.
173+
* @note When `enabled`, the 2D slice rasterizer ignores the linear world
174+
* window and instead maps each pixel radially: the frame's inscribed circle is
175+
* the dome, a pixel at normalized image radius rho in [0,1] is the dome zenith
176+
* angle theta = rho * theta_max (azimuthal-equidistant image law -- the
177+
* fulldome standard), and `law` picks how theta maps to a world radius r in a
178+
* disk of radius `R` centered at (cx, cy). Pixels outside the inscribed circle
179+
* are left transparent, so the corners are the dome master's black border.
180+
* Cartesian 2D only (see Metadomain::Render); a metric-agnostic POD so the
181+
* (templated) Render can copy it by value into the device kernel.
182+
*/
183+
struct DomeMap {
184+
enum Law { Equidistant = 0, Gnomonic = 1, Stereographic = 2, Orthographic = 3 };
185+
bool enabled { false };
186+
int law { Equidistant };
187+
real_t theta_max { static_cast<real_t>(1.5707963267948966) }; // dome half-FOV (rad)
188+
real_t cx { ZERO }, cy { ZERO }; // world center of the cutout
189+
real_t R { ONE }; // world radius of the cutout
190+
};
191+
171192
/**
172193
* @brief A sparse screen-space sub-image: the bounding box of one domain's
173194
* projected footprint plus its premultiplied RGBA pixels.
@@ -370,6 +391,20 @@ namespace out {
370391
return m_fieldlines;
371392
}
372393

394+
// Fulldome fisheye projection (planetarium dome master). `dome()` carries the
395+
// parsed config + resolved center/radius/FOV; `enabled` there reflects the
396+
// toml + a 2D run. The templated Render only activates it for Cartesian, so
397+
// it reports the per-run truth back via setDomeActive(), which the (metric-
398+
// agnostic) compositeAndWrite reads to emit a clean square frame.
399+
[[nodiscard]]
400+
auto dome() const -> const DomeMap& {
401+
return m_dome;
402+
}
403+
404+
void setDomeActive(bool active) {
405+
m_dome_active = active;
406+
}
407+
373408
private:
374409
bool m_enabled { false };
375410

@@ -431,6 +466,11 @@ namespace out {
431466
std::vector<Scene> m_scenes;
432467
FieldLineConfig m_fieldlines;
433468

469+
// fulldome fisheye config (parsed in init); m_dome_active is set per-run by
470+
// the templated Render (true only for a 2D Cartesian dome).
471+
DomeMap m_dome;
472+
bool m_dome_active { false };
473+
434474
tools::Tracker m_tracker;
435475
path_t m_root;
436476
};

0 commit comments

Comments
 (0)