singularity-rs/docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md
xfy 5c6c20fa00 docs: spec cinematic rendering (HDR bloom + FBM disk + ACES + AA)
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.
2026-07-14 18:24:36 +08:00

14 KiB
Raw Blame History

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:

  1. HDR color + tone mapping — white-hot core → deep orange, strong Doppler left/right asymmetry.
  2. Bloom post-processing — cinematic glow around the bright disk.
  3. Smoke/turbulence disk texture — feathered, flowing gas instead of flat color blocks.
  4. 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 (13) 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 L1 down passes and L1 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 320390) is untouched.

Disk noise: domain-warped FBM

Replace the two-sine noise (current disk_color lines 148150) 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 35×, the bright Doppler channel reaches 58×. 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 (L1 down + L1 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+2
  • BlurMaterial { source: Handle<Image>, mode: u32, texel_size: Vec2, blend: f32 } — one material instance per pyramid pass. With L bloom textures there are L1 down passes and L1 up passes. For High (3 textures): 2 down + 2 up = 4 instances. (Bevy Material2d binds 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 } — replaces UpscaleMaterial

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/2 Images (sized by the bloom_quality upper bound)
  • 4 BlurMaterial instances + quads (2 down + 2 up for High; fewer for lower quality)
  • 1 BrightPassMaterial quad
  • 1 CompositeMaterial quad (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) where radius ≈ 0.15 cell.
  • Naturally produces a 23 pixel anti-aliased disk instead of a rectangle.
  • star_aa flag 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.03.0)
  • Bloom strength slider (0.02.0)
  • Exposure slider (0.53.0)
  • Resolution scale slider (0.251.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)

  1. nudge_camera — extended to all bloom cameras via the Nudgable marker. Without this, the new cameras freeze and bloom silently stops updating.
  2. bevy_egui context — the Quality panel lives in the same ui_system, already in EguiPrimaryContextPass. No change.
  3. 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::Off falls 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.

  1. Run cargo test — green baseline (no code change).
  2. Offscreen format → Rgba16Float; adjust BH shader output (remove implicit clamp). Commit.
  3. FBM + domain-warp noise in disk_color. Commit.
  4. star_color gaussian speck + star_aa flag. Commit.
  5. BrightPassMaterial + brightpass.wgsl + camera. Commit.
  6. BlurMaterial + blur.wgsl + pyramid spawn. Commit.
  7. CompositeMaterial + composite.wgsl (ACES); remove UpscaleMaterial. Commit.
  8. BloomQuality param + Nudgable marker + Quality panel. Commit.
  9. Web defaults + bloom-off fallback path. Commit.