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.
This commit is contained in:
xfy 2026-07-14 18:24:36 +08:00
parent e7c45804a4
commit 5c6c20fa00

View File

@ -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 (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.