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

263 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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])
```wgsl
@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):
```wgsl
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)
```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)
```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<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.