From 5c6c20fa00dec9ca5445b4e402426385a966852c Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 14 Jul 2026 18:24:36 +0800 Subject: [PATCH] docs: spec cinematic rendering (HDR bloom + FBM disk + ACES + AA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-blackhole-cinematic-rendering-design.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md diff --git a/docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md b/docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md new file mode 100644 index 0000000..d9b63dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md @@ -0,0 +1,262 @@ +# 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 (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 // smoothed 3D value noise (hash → smoothstep interp) +fn fbm3(p: vec3, octaves: u32) -> f32 // FBM layered sum of value_noise3 +fn disk_noise(pos: vec3, 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]) + +```wgsl +@fragment +fn fragment(in) -> vec4 { + 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): + +```wgsl +struct BlurUniform { mode: u32, texel_size: vec2, 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) + +```wgsl +@fragment +fn fragment(in) -> vec4 { + 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, threshold: f32 }` — uniform at binding 0, texture at 1+2 +- `BlurMaterial { source: Handle, 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. (Bevy `Material2d` binds uniforms once per material, not per entity, so each pass needs its own material asset.) +- `CompositeMaterial { scene: Handle, bloom: Handle, bloom_strength: f32, exposure: f32 }` — replaces `UpscaleMaterial` + +`UpscaleMaterial` and `upscale.wgsl` are removed. + +### New params + BloomQuality enum (params.rs) + +```rust +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, With)>`. 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`. (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 2–3 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.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) + +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.