Design for raising the renderer from geometrically-correct-but-flat to Gargantua-reference fidelity. Four user-selected traits: - HDR color + ACES tone mapping (Rgba16Float offscreen, Narkowicz fit) - Multi-pass HDR bloom (bright-pass → down-pyramid → up-pyramid → composite) - Domain-warped FBM disk texture (replaces two-sine noise) - Anti-aliasing via render_scale + gaussian-speck stars All quality options configurable via a dedicated egui Quality panel, with tiered web defaults (Low bloom, no MSAA/star-AA) vs desktop (High). BloomQuality::Off falls back to the original LDR path for WebGPU browsers without float-filterable support. Geodesic integrator (deriv/rk45_step/is_captured_rk45) untouched — the physics.rs mirror contract is unaffected. No new tests.
14 KiB
Cinematic Rendering — Design Spec
Date: 2026-07-14 Phase: 3 (visual fidelity), builds on Phase 2 (Kerr) Status: approved design, pending implementation plan
Goal
Raise the renderer from "geometrically correct but flat" (current screenshot) to the visual fidelity of the Gargantua reference image (Figure 1). Four target traits, all user-selected:
- HDR color + tone mapping — white-hot core → deep orange, strong Doppler left/right asymmetry.
- Bloom post-processing — cinematic glow around the bright disk.
- Smoke/turbulence disk texture — feathered, flowing gas instead of flat color blocks.
- Anti-aliasing + round stars — smooth edges, sharp circular stars instead of jagged pixel blobs.
All quality options must be configurable via a dedicated egui panel, with tiered web defaults (conservative on web, full on desktop).
Non-goals (explicit scope boundaries)
- Volumetric disk thickness — disk stays a zero-thickness plane. Volume ray-marching is a separate future project.
- Exact Kerr Hamiltonian — keep the leading-pole (Lense-Thirring) approximation. Phase 3 physics is out of scope.
- Retrograde / tilted spin axis — out of scope.
- MSAA on the fullscreen quad — documented as decorative (the quad is a single fragment covering the screen; MSAA samples geometry edges, not shader internals). Retained as a visible option for future geometry; the real AA is
render_scale+star_aa.
Architecture: the new render pipeline
The current pipeline is 2 passes: OffscreenQuad → Upscale. The new pipeline is 5 stages chained through Image targets:
[1] Offscreen (HDR) black_hole.wgsl
Rgba16Float @ render_scale
geodesic + disk + stars, linear HDR output (no tone-map yet)
↓
[2] Bright-pass brightpass.wgsl (new material)
Rgba16Float @ render_scale/2
extracts luminance > threshold, soft-knee, pre-blur
↓
[3] Downsample pyramid blur.wgsl (down mode) × N passes
Rgba16Float @ render_scale/2, /4, /8 (N = BloomQuality::levels)
each level samples previous with 13-tap weighted kernel
↓
[4] Upsample pyramid blur.wgsl (up mode) × N passes
back up to render_scale/2, additive blend with lerp factor
↓
[5] Composite + Tone-map composite.wgsl (replaces upscale.wgsl)
@ window res, Bgra8UnormSrgb
final = ACES(scene_hdr + bloom * bloom_strength) * exposure
Key format decision
The offscreen target switches from Bgra8UnormSrgb to Rgba16Float. This is mandatory — bloom needs over-bright disk values (>1.0) that 8-bit srgb would clamp away. The final LDR conversion is deferred to ACES tone-mapping in stage [5].
Why a down-then-up pyramid instead of a single blur
Multi-pass downsampling gives isotropic wide-frequency bloom (13 taps around the source ≈ a Gaussian). A single bilinear tap gives a soft halo, not the broad cinematic glow of the reference. The number of levels is configurable (1–3) so web can run 1 (soft halo) and desktop 3 (full pyramid).
Image targets (created in the plugin)
| Target | Format | Scale | Written by | Read by |
|---|---|---|---|---|
offscreen_hdr |
Rgba16Float | render_scale | BH shader | brightpass |
bloom_0 |
Rgba16Float | render_scale/2 | brightpass | blur down[0] |
bloom_1 |
Rgba16Float | render_scale/4 | blur down[0] | blur down[1] / up[1] |
bloom_2 |
Rgba16Float | render_scale/8 | blur down[1] | blur up[1] |
| (window) | Bgra8UnormSrgb | 1.0 | composite | — |
Pyramid depth = number of bloom textures = BloomQuality::levels (Off=0, Low=1, Medium=2, High=3). With L levels there are L−1 down passes and L−1 up passes. For High (3 textures: bloom_0/1/2): down passes 0→1 and 1→2 (2 instances) + up passes 2→1 and 1→0 (2 instances) = 4 BlurMaterial instances total. Off = no bloom targets/materials at all (falls back to a plain LDR upscale path, no float textures).
Shader changes (black_hole.wgsl)
Two changes, both localized to disk_color and the output path. The geodesic integrator (deriv, rk45_step, the loop at lines 320–390) is untouched.
Disk noise: domain-warped FBM
Replace the two-sine noise (current disk_color lines 148–150) with:
fn value_noise3(p: vec3<f32>) -> f32 // smoothed 3D value noise (hash → smoothstep interp)
fn fbm3(p: vec3<f32>, octaves: u32) -> f32 // FBM layered sum of value_noise3
fn disk_noise(pos: vec3<f32>, t: f32) -> f32 // domain warp
let warp = fbm3(pos * 0.8 + t * 0.1, 3)
let n = fbm3(pos * 2.0 + warp * 1.5 + t * 0.3, 4)
return n
disk_color uses disk_noise in place of the sines. The Keplerian shear (rot = time*speed / r^1.5) is retained as the time term — it drives inner-faster-than-outer rotation, which is correct physics. Domain warping produces the feathered/smoky structure the user flagged as missing from the reference.
Cost: current disk_color ≈ 2 sin calls; FBM+warp ≈ 50 ALU ops/hit (7× (3 hash + 3 interp)). A ray hits the disk at most ~2 times, so this is acceptable.
Linear HDR output
With a Rgba16Float target, disk color is no longer implicitly clamped to [0,1]. The white-hot inner disk radiates at 3–5×, the bright Doppler channel reaches 5–8×. disk_brightness now legitimately exceeds 1.0 and acts as a scale. Stars stay low-intensity (below bloom threshold); background space stays black. Only the disk (and skybox when its intensity is high) drives bloom.
New uniform fields (added to BlackHoleUniforms)
bloom_threshold: f32(bright-pass cutoff, default 1.0)bloom_strength: f32(composite blend, default 0.8)tone_map_exposure: f32(ACES pre-multiplier, default 1.0)
These are part of the existing BlackHoleUniforms struct (binding 0), mirrored from BlackHoleParams each frame. They are not physics fields and do not affect the physics.rs mirror.
New shaders
brightpass.wgsl (stage [2])
@fragment
fn fragment(in) -> vec4<f32> {
let hdr = textureSample(source, samp, in.uv).rgb;
let lum = dot(hdr, vec3(0.2126, 0.7152, 0.0722));
// soft knee, not hard threshold
let soft = max(lum - threshold, 0.0) / (lum + 0.0001);
let contribution = hdr * soft;
return vec4(contribution, 1.0);
}
Hard thresholds create hard bloom edges. The soft knee (lum - t)/(lum + ε) gives a smooth roll-off — fully bright passes through, near-threshold tapers to zero. Sampling at half-res cell centers gives a free 2×2 box downsample via bilinear.
blur.wgsl (stages [3] and [4])
One shader, two modes via a uniform u_mode (0=down, 1=up):
struct BlurUniform { mode: u32, texel_size: vec2<f32>, blend: f32, _pad: f32 };
// 13-tap weighted kernel (Gaussian approximation from HDR bloom literature)
// Down: sample previous-larger-level → write to smaller level
// Up: sample next-smaller-level → additive-blend with `blend` factor
texel_size drives the tap stride. One shader, instantiated once per pass (L−1 down + L−1 up for L bloom textures).
composite.wgsl (stage [5], replaces upscale.wgsl)
@fragment
fn fragment(in) -> vec4<f32> {
let hdr = textureSample(scene, scene_samp, in.uv).rgb; // full-res HDR
let bloom = textureSample(bloom, bloom_samp, in.uv).rgb; // top of pyramid
let combined = hdr + bloom * bloom_strength;
let mapped = aces(combined * exposure); // Narkowicz fit, 5 ops
return vec4(clamp(mapped, 0.0, 1.0), 1.0); // → Bgra8UnormSrgb
}
ACES Narkowicz fit: (x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), clamped to [0,1]. 5 ops/pixel, negligible at 4K.
Rust-side changes
New materials (render/material.rs)
Three new Material2d structs mirroring the UpscaleMaterial pattern:
BrightPassMaterial { source: Handle<Image>, threshold: f32 }— uniform at binding 0, texture at 1+2BlurMaterial { source: Handle<Image>, mode: u32, texel_size: Vec2, blend: f32 }— one material instance per pyramid pass. With L bloom textures there are L−1 down passes and L−1 up passes. For High (3 textures): 2 down + 2 up = 4 instances. (BevyMaterial2dbinds uniforms once per material, not per entity, so each pass needs its own material asset.)CompositeMaterial { scene: Handle<Image>, bloom: Handle<Image>, bloom_strength: f32, exposure: f32 }— replacesUpscaleMaterial
UpscaleMaterial and upscale.wgsl are removed.
New params + BloomQuality enum (params.rs)
pub enum BloomQuality { Off, Low, Medium, High } // 0 / 1 / 2 / 3 levels
pub struct BlackHoleParams {
// ... existing fields unchanged ...
// Quality (new section)
pub bloom_quality: BloomQuality,
pub bloom_threshold: f32, // 1.0
pub bloom_strength: f32, // 0.8
pub exposure: f32, // 1.0
pub msaa: u32, // 1 (Off) — 1/2/4
pub star_aa: bool, // anti-aliased star rendering
}
Defaults differ via cfg!(target_arch = "wasm32"):
| Param | Desktop | Web |
|---|---|---|
bloom_quality |
High (3) | Low (1) |
msaa |
4 | 1 |
star_aa |
true | false |
msaa toggles the offscreen camera between Msaa::Off / Msaa::Sample2 / Msaa::Sample4. star_aa toggles a shader branch in black_hole.wgsl.
Plugin rewiring (render/plugin.rs)
spawn_fullscreen_quad now also spawns:
bloom_0/1/2Images (sized by thebloom_qualityupper bound)- 4
BlurMaterialinstances + quads (2 down + 2 up for High; fewer for lower quality) - 1
BrightPassMaterialquad - 1
CompositeMaterialquad (replaces the Upscale quad)
Camera order: offscreen=-3, brightpass=-2, blur chain=-1, composite=0.
resize_offscreen must rebuild bloom pyramid dimensions on resize (all targets share the scale). When bloom_quality changes at runtime via the UI, a one-shot pyramid rebuild is triggered (same logic as the resize path, but driven by a param-change flag rather than a WindowResized message).
Nudgable marker component
nudge_camera (the Bevy 0.19 #24448 workaround) currently targets Or<(With<OffscreenCamera>, With<UpscaleCamera>)>. It must extend to all new cameras or they freeze too. Introduce a marker component Nudgable; all render cameras carry it; nudge_camera queries With<Nudgable>. (The component is zero-sized and carry-only; no logic.)
Anti-aliasing + round stars (black_hole.wgsl)
MSAA caveat (documented): MSAA on a fullscreen quad is decorative — the quad is a single fragment covering the screen, so MSAA's geometry-edge sampling does nothing for shader-internal aliasing. Retained as a visible option (useful if real geometry is added later) but honestly labeled. The real AA is:
render_scale (existing, renamed "Resolution scale" in the Quality panel) — at 1.0 the offscreen is full-res and the bilinear upscale becomes identity, edges as sharp as possible. Already a lever; just surfaced in the Quality panel.
star_color rewrite (gaussian speck): the current smoothstep(0.5, 0.0, d) with scale=80 produces blocky rectangles at low resolution. Replace with a gaussian speck per cell:
- One star per cell (existing hash decides presence).
- Distance to cell star-center,
smoothstep(radius, 0.0, dist)whereradius ≈ 0.15cell. - Naturally produces a 2–3 pixel anti-aliased disk instead of a rectangle.
star_aaflag switches between the old fast path and the gaussian speck path.
Quality panel (ui.rs)
A new top-level CollapsingHeader::new("Quality") (separate from the existing param-category sections), containing:
- Bloom quality dropdown (Off/Low/Medium/High)
- Bloom threshold slider (0.0–3.0)
- Bloom strength slider (0.0–2.0)
- Exposure slider (0.5–3.0)
- Resolution scale slider (0.25–1.0, moved from Renderer)
- MSAA dropdown (1/2/4) with a "limited effect on fullscreen shader" note label
- Star AA checkbox
- Per-option perf-hint labels
This satisfies "all quality options configurable, quality in its own panel."
Gotcha reconciliation (AGENTS.md)
nudge_camera— extended to all bloom cameras via theNudgablemarker. Without this, the new cameras freeze and bloom silently stops updating.- bevy_egui context — the Quality panel lives in the same
ui_system, already inEguiPrimaryContextPass. No change. - Storage buffer / RetryNextUpdate — only the BH material uses a storage buffer; unchanged. Bloom materials are plain texture binds, no risk.
Testing
The CPU mirror contract (deriv / rk45_step / is_captured_rk45 in physics.rs ↔ shader) is untouched — none of the changes touch the geodesic integrator. Existing tests (b < b_crit captured, b > b_crit escapes, spin=0 degeneracy, spin>0 capture) pass unchanged. No new tests: per AGENTS.md, "The GPU shader is not unit-tested."
cargo test is run first to establish a green baseline before any change.
Risk: Rgba16Float on WebGPU
Rgba16Float as a filterable render target requires WebGPU's float-filterable feature. On desktop this is standard. On web, most modern browsers support rgba16float, but if unsupported, the trunk build fails at context creation — surfacing the existing WebGPU fallback message rather than a blank canvas.
Mitigation:
- Web defaults to
BloomQuality::Low(1 bloom level), minimizing float textures. BloomQuality::Offfalls back to the original LDR upscale path (no float targets, no bloom materials). A web user on an unsupported browser can disable bloom and run the LDR path with zero float-texture requirement.
Implementation order (per-commit blocks)
Each block compiles standalone and is visually checkable via cargo run --release.
- Run
cargo test— green baseline (no code change). - Offscreen format →
Rgba16Float; adjust BH shader output (remove implicit clamp). Commit. - FBM + domain-warp noise in
disk_color. Commit. star_colorgaussian speck +star_aaflag. Commit.BrightPassMaterial+brightpass.wgsl+ camera. Commit.BlurMaterial+blur.wgsl+ pyramid spawn. Commit.CompositeMaterial+composite.wgsl(ACES); removeUpscaleMaterial. Commit.BloomQualityparam +Nudgablemarker + Quality panel. Commit.- Web defaults + bloom-off fallback path. Commit.