singularity-rs/docs/superpowers/plans/2026-07-14-blackhole-cinematic-rendering.md
xfy d32ea10aec docs: implementation plan for cinematic rendering
10-task plan (Tasks 0-9) for the HDR bloom + FBM disk + ACES + AA
pipeline. Each task is a standalone commit: Rgba16Float offscreen →
FBM disk → gaussian-speck stars → brightpass → blur pyramid →
composite+ACES → BloomQuality enum → Quality panel → verification.

Conventions established upfront (camera orders, RenderLayers, the
QuadScaleFactor/CompositeQuad/Nudgable markers) so each task references
them rather than re-deriving. The naga var<private> array fix for the
blur kernel is baked into the shader from the start.
2026-07-14 23:24:14 +08:00

60 KiB
Raw Permalink Blame History

Cinematic Rendering Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Raise the renderer from "geometrically correct but flat" to Gargantua-reference fidelity: HDR color + ACES tone mapping, multi-pass HDR bloom, domain-warped FBM disk texture, and anti-aliased round stars — all configurable via a dedicated Quality panel with tiered web defaults.

Architecture: A 5-stage HDR pipeline replaces the 2-pass offscreen→upscale path: Offscreen (Rgba16Float) → BrightPass → Down-pyramid → Up-pyramid → Composite+ACES. The geodesic integrator is untouched; all changes are in compositing/shading.

Tech Stack: Bevy 0.19, WGSL, egui 0.41. No new crate dependencies.

Spec: docs/superpowers/specs/2026-07-14-blackhole-cinematic-rendering-design.md


Conventions used throughout this plan

Camera order is i32; lower renders first. The full pipeline order, set once and referenced by all tasks:

Camera order Renders into
offscreen -20 offscreen_hdr
brightpass -19 bloom_0
blur down01 -18 bloom_1
blur down12 -17 bloom_2
blur up21 -16 bloom_1
blur up10 -15 bloom_final
composite 0 window

RenderLayers isolate each camera's draw to its own quad: layer 0 = offscreen, 1 = composite, 2 = brightpass, 3 = down01, 4 = down12, 5 = up21, 6 = up10.

QuadScaleFactor(f32, f32) — a component on every quad storing the fraction of the offscreen resolution that the quad's target fills. Used by resize_offscreen to rescale each quad independently:

  • offscreen quad: (1.0, 1.0)
  • brightpass (writes bloom_0 at half-res): (0.5, 0.5)
  • blur down01 (writes bloom_1 at quarter): (0.25, 0.25)
  • blur down12 (writes bloom_2 at eighth): (0.125, 0.125)
  • blur up21 (writes bloom_1 at quarter): (0.25, 0.25)
  • blur up10 (writes bloom_final at half): (0.5, 0.5)
  • composite quad: rescaled against the WINDOW (not offscreen), so it carries a CompositeQuad marker instead and is handled separately in resize.

File Structure

Created:

  • assets/shaders/brightpass.wgsl — stage [2]: extracts luminance > threshold with soft knee
  • assets/shaders/blur.wgsl — stages [3]/[4]: one shader, down/up modes via uniform
  • assets/shaders/composite.wgsl — stage [5]: ACES tone-map + bloom blend, replaces upscale.wgsl

Modified:

  • assets/shaders/black_hole.wgsl — FBM disk noise, gaussian-speck stars, star_aa branch, new uniform fields read
  • src/render/material.rs — add BrightPassMaterial, BlurMaterial, CompositeMaterial; add new uniform fields to BlackHoleUniforms; remove UpscaleMaterial
  • src/render/plugin.rs — spawn bloom targets/materials/cameras; Nudgable + QuadScaleFactor + CompositeQuad markers; generalize nudge_camera; bloom quality rebuild on param change
  • src/params.rs — add BloomQuality enum + quality fields to BlackHoleParams; tiered defaults
  • src/ui.rs — new Quality panel section

Deleted:

  • assets/shaders/upscale.wgsl

Task 0: Establish green baseline

Files: none

  • Step 1: Run the existing test suite

Run: cargo test Expected: all physics tests pass (b_crit capture/escape, spin=0 degeneracy, spin>0 capture).

  • Step 2: Confirm the desktop build runs

Run: cargo build --release Expected: compiles without error.

  • Step 3: No commit — verification gate only.

Task 1: Offscreen format → Rgba16Float + camera order + markers

Files:

  • Modify: src/render/plugin.rs (offscreen texture format, offscreen camera order, add Nudgable/QuadScaleFactor/CompositeQuad markers to offscreen + upscale cameras/quads, generalize nudge_camera)

This switches the offscreen render target to float, sets up the camera order for the bloom pipeline, and introduces the marker components all later tasks depend on. No bloom yet — the upscale path still works.

  • Step 1: Define the new marker components

In src/render/plugin.rs, after the UpscaleQuad component definition (around line 34), add:

/// Marker for any camera that must be nudged each frame (Bevy 0.19 #24448
/// workaround). All render cameras carry this.
#[derive(Component)]
pub struct Nudgable;

/// Stores the fraction of the offscreen resolution that this quad's target
/// fills. Used by resize_offscreen to rescale each quad independently.
/// (1.0, 1.0) = full offscreen res; (0.5, 0.5) = half; etc.
#[derive(Component)]
pub struct QuadScaleFactor(pub f32, pub f32);

/// Marks the composite quad (renders to the window, not an offscreen Image).
/// resize_offscreen rescales it against the window, not the offscreen res.
#[derive(Component)]
pub struct CompositeQuad;
  • Step 2: Change the offscreen texture format in spawn_fullscreen_quad

In src/render/plugin.rs, find the Image::new_target_texture call in spawn_fullscreen_quad (around line 84) and change the format:

    let offscreen = images.add(Image::new_target_texture(
        w,
        h,
        TextureFormat::Rgba16Float,  // was Bgra8UnormSrgb — HDR for bloom headroom
        None,
    ));
  • Step 3: Add QuadScaleFactor to the offscreen quad

In spawn_fullscreen_quad, in the offscreen quad spawn (the FullscreenQuad commands.spawn(...), around line 111), add QuadScaleFactor(1.0, 1.0) to the tuple:

        Transform::default().with_scale(Vec3::new(half_w, half_h, 1.0)),
        FullscreenQuad,
        QuadScaleFactor(1.0, 1.0),
        RenderLayers::layer(0),
  • Step 4: Set the offscreen camera order to -20 and add Nudgable

In spawn_fullscreen_quad, in the offscreen camera spawn (the commands.spawn((Camera2d, Camera { order: -1, ... })) around line 119), change order: -1 to order: -20 and add Nudgable,:

        Camera {
            order: -20,
            clear_color: ClearColorConfig::Custom(Color::srgb(0.1, 0.1, 0.1)),
            ..default()
        },
        RenderTarget::Image(offscreen.clone().into()),
        Msaa::Off,
        OffscreenCamera,
        Nudgable,
        RenderLayers::layer(0),
  • Step 5: Tag the upscale quad + camera with CompositeQuad + Nudgable

In spawn_fullscreen_quad, in the upscale quad spawn (around line 133), add CompositeQuad,:

        Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)),
        UpscaleQuad,
        CompositeQuad,
        RenderLayers::layer(1),

In the upscale camera spawn (around line 142), add Nudgable,:

    commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, RenderLayers::layer(1)));
  • Step 6: Generalize nudge_camera to use the Nudgable marker

In src/render/plugin.rs, replace the nudge_camera function signature and query:

fn nudge_camera(
    time: Res<Time>,
    mut camera: Query<&mut Transform, With<Nudgable>>,
) {
    let nudge = (time.elapsed_secs() * 5.0).sin() * 1e-3;
    for mut t in &mut camera {
        t.translation.x = nudge;
    }
}
  • Step 7: Update resize_offscreen to use QuadScaleFactor

In src/render/plugin.rs, replace the quads ParamSet in resize_offscreen and the rescale loops. The new version rescales offscreen+bloom quads by their QuadScaleFactor against the offscreen resolution, and the composite quad against the window. Replace the existing mut quads: ParamSet<( ... )> parameter and the two for loops at the end with:

    mut quads: ParamSet<(
        // p0: offscreen + bloom quads — rescaled against offscreen resolution.
        Query<(&mut Transform, &QuadScaleFactor), Without<CompositeQuad>>,
        // p1: composite quad — rescaled against window resolution.
        Query<&mut Transform, With<CompositeQuad>>,
    )>,

and replace the final two for loops with:

    // Rescale offscreen + bloom quads against the offscreen resolution.
    for (mut t, f) in &mut quads.p0() {
        t.scale = Vec3::new(w as f32 * f.0 / 2.0, h as f32 * f.1 / 2.0, 1.0);
    }
    // Rescale the composite quad against the window resolution.
    for mut t in &mut quads.p1() {
        t.scale = Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0);
    }

Also change the offscreen image resize format from Bgra8UnormSrgb to Rgba16Float in resize_offscreen (around line 170):

    let img = Image::new_target_texture(w, h, TextureFormat::Rgba16Float, None);
  • Step 8: Build and visually verify

Run: cargo run --release Expected: the black hole renders, possibly slightly brighter/blown-out in the disk core (over-bright values now reach the window unclamped). ACES is added in Task 6. No crash; the nudge still works (all cameras carry Nudgable).

  • Step 9: Commit
git add src/render/plugin.rs
git commit -m "render: offscreen → Rgba16Float + Nudgable/QuadScaleFactor/CompositeQuad markers

Switch the offscreen target from Bgra8UnormSrgb to Rgba16Float for HDR
headroom (disk brightness can exceed 1.0 without clamping). Set offscreen
camera order to -20 (the bloom pipeline, added next, needs cameras ordered
so offscreen renders first). Introduce three marker components:

- Nudgable: all render cameras carry it; nudge_camera now queries this
  instead of a hardcoded Or<(OffscreenCamera, UpscaleCamera)>.
- QuadScaleFactor(fx, fy): fraction of offscreen res each quad fills;
  resize_offscreen rescales each quad independently via this.
- CompositeQuad: the final quad renders to the window (rescaled against
  window res, not offscreen).

The upscale path still works (textureSample reads float textures);
ACES tone-mapping is added in a later commit."

Task 2: Domain-warped FBM disk texture

Files:

  • Modify: assets/shaders/black_hole.wgsl:143-171 (disk_color) and add hash33/value_noise3/fbm3/disk_noise helpers before disk_color

Replace the two-sine noise with domain-warped FBM for the feathered/smoky disk texture. The geodesic integrator is untouched.

  • Step 1: Add FBM helper functions before disk_color

In assets/shaders/black_hole.wgsl, insert these functions immediately before fn disk_color (before line 143):

// --- disk noise (domain-warped FBM) ---
fn hash33(p: vec3<f32>) -> vec3<f32> {
    let q = vec3<f32>(
        dot(p, vec3<f32>(127.1, 311.7, 74.7)),
        dot(p, vec3<f32>(269.5, 183.3, 246.1)),
        dot(p, vec3<f32>(113.5, 271.9, 124.6)),
    );
    return fract(sin(q) * 43758.5453);
}

fn value_noise3(p: vec3<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let n000 = dot(hash33(i + vec3<f32>(0.0, 0.0, 0.0)) - 0.5, vec3<f32>(1.0));
    let n100 = dot(hash33(i + vec3<f32>(1.0, 0.0, 0.0)) - 0.5, vec3<f32>(1.0));
    let n010 = dot(hash33(i + vec3<f32>(0.0, 1.0, 0.0)) - 0.5, vec3<f32>(1.0));
    let n110 = dot(hash33(i + vec3<f32>(1.0, 1.0, 0.0)) - 0.5, vec3<f32>(1.0));
    let n001 = dot(hash33(i + vec3<f32>(0.0, 0.0, 1.0)) - 0.5, vec3<f32>(1.0));
    let n101 = dot(hash33(i + vec3<f32>(1.0, 0.0, 1.0)) - 0.5, vec3<f32>(1.0));
    let n011 = dot(hash33(i + vec3<f32>(0.0, 1.0, 1.0)) - 0.5, vec3<f32>(1.0));
    let n111 = dot(hash33(i + vec3<f32>(1.0, 1.0, 1.0)) - 0.5, vec3<f32>(1.0));
    let nx00 = mix(n000, n100, u.x);
    let nx10 = mix(n010, n110, u.x);
    let nx01 = mix(n001, n101, u.x);
    let nx11 = mix(n011, n111, u.x);
    let nxy0 = mix(nx00, nx10, u.y);
    let nxy1 = mix(nx01, nx11, u.y);
    return mix(nxy0, nxy1, u.z) + 0.5;
}

fn fbm3(p: vec3<f32>, octaves: u32) -> f32 {
    var sum = 0.0;
    var amp = 0.5;
    var freq = 1.0;
    for (var i: u32 = 0u; i < octaves; i = i + 1u) {
        sum = sum + amp * value_noise3(p * freq);
        freq = freq * 2.0;
        amp = amp * 0.5;
    }
    return sum;
}

fn disk_noise(pos: vec3<f32>, t: f32) -> f32 {
    let warp = fbm3(pos * 0.8 + vec3<f32>(0.0, 0.0, t * 0.1), 3u);
    let n = fbm3(pos * 2.0 + warp * 1.5 + vec3<f32>(0.0, 0.0, t * 0.3), 4u);
    return n;
}
  • Step 2: Replace the noise computation in disk_color

In assets/shaders/black_hole.wgsl, in disk_color, replace these four lines (the rot, n, n2, noise computation around lines 147-150):

    let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
    let n = sin(phi * 8.0 + rot) * 0.5 + 0.5;
    let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5;
    let noise = mix(n, n2, 0.4);

with:

    let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
    // Domain-warped FBM for feathered/smoky gas texture. The Keplerian shear
    // (rot ∝ 1/r^1.5) is folded into the noise flow term so inner radii flow
    // faster than outer — correct differential rotation.
    let noise = disk_noise(vec3<f32>(r * 0.5, phi * 0.3, rot), uniforms.time);
  • Step 3: Build and visually verify

Run: cargo run --release Expected: the disk shows feathered, turbulent structure instead of banded sine waves. The brightness gradient (white-hot inner → orange outer) and Doppler asymmetry are preserved. If the noise looks too flat or too busy, the 0.5/0.3/2.0 constants in disk_noise/disk_color are the tuning knobs.

  • Step 4: Commit
git add assets/shaders/black_hole.wgsl
git commit -m "shader: domain-warped FBM disk texture replaces two-sine noise

The disk's noise was two sin() terms (phi*8, phi*23) producing geometric
bands. Replace with domain-warped FBM: a 3-octave value-noise field warps
the sampling coordinate of a 4-octave FBM, giving the feathered/smoky gas
texture of the Gargantua reference. The Keplerian shear (rot ∝ 1/r^1.5)
is retained as the flow time term so inner radii rotate faster."

Task 3: Gaussian-speck stars + star_aa flag + bloom/exposure uniforms

Files:

  • Modify: assets/shaders/black_hole.wgsl (BlackHoleUniforms struct + star_color)
  • Modify: src/render/material.rs (BlackHoleUniforms fields + defaults)
  • Modify: src/render/plugin.rs (mirror_params)
  • Modify: src/params.rs (add fields + defaults)

Replace blocky smoothstep stars with a gaussian speck (round, anti-aliased). Add the star_aa toggle and the bloom/exposure uniform fields (used by later tasks, with safe defaults).

  • Step 1: Add uniform fields to the WGSL BlackHoleUniforms struct

In assets/shaders/black_hole.wgsl, in the BlackHoleUniforms struct, replace the final two lines:

    spin: f32,
    _pad5: f32,
};

with:

    spin: f32,
    star_aa: u32,
    bloom_threshold: f32,
    bloom_strength: f32,
    exposure: f32,
    _pad5: f32,
};
  • Step 2: Add the matching fields to the Rust BlackHoleUniforms struct

In src/render/material.rs, in the BlackHoleUniforms struct, replace:

    pub spin: f32,       // Phase 2: dimensionless Kerr spin χ = a/M ∈ [0,1].
    pub _pad5: f32,
}

with:

    pub spin: f32,       // Phase 2: dimensionless Kerr spin χ = a/M ∈ [0,1].
    pub star_aa: u32,
    pub bloom_threshold: f32,
    pub bloom_strength: f32,
    pub exposure: f32,
    pub _pad5: f32,
}
  • Step 3: Set defaults for the new fields

In src/render/material.rs, in impl Default for BlackHoleUniforms, replace:

            spin: 0.0,
            _pad5: 0.0,
        }
    }
}

with:

            spin: 0.0,
            star_aa: 1,
            bloom_threshold: 1.0,
            bloom_strength: 0.8,
            exposure: 1.0,
            _pad5: 0.0,
        }
    }
}
  • Step 4: Add the fields to BlackHoleParams

In src/params.rs, in the BlackHoleParams struct, after pub spin: f32,, add:

    // Quality (Phase 3: cinematic rendering)
    pub star_aa: bool,
    pub bloom_threshold: f32,
    pub bloom_strength: f32,
    pub exposure: f32,
  • Step 5: Set defaults in BlackHoleParams::default

In src/params.rs, in impl Default for BlackHoleParams, replace:

            spin: 0.0,
        }
    }
}

with:

            spin: 0.0,
            star_aa: if cfg!(target_arch = "wasm32") { false } else { true },
            bloom_threshold: 1.0,
            bloom_strength: 0.8,
            exposure: 1.0,
        }
    }
}
  • Step 6: Mirror the new fields in mirror_params

In src/render/plugin.rs, in mirror_params, after the u.spin = params.spin; line, add:

        u.star_aa = params.star_aa as u32;
        u.bloom_threshold = params.bloom_threshold;
        u.bloom_strength = params.bloom_strength;
        u.exposure = params.exposure;
  • Step 7: Replace star_color with the gaussian-speck version

In assets/shaders/black_hole.wgsl, replace the entire star_color function (lines 83-97):

fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
    let scale = 80.0;
    let cell = floor(dir * scale);
    let h = hash13(cell);
    let threshold = 0.985;
    if (h > threshold) {
        let b = (h - threshold) / (1.0 - threshold);
        let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
        let f = abs(dir * scale - cell);
        let d = max(f.x, max(f.y, f.z));
        let falloff = smoothstep(0.5, 0.0, d);
        return col * b * falloff * 3.0 * intensity;
    }
    return vec3<f32>(0.0);
}

with:

fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
    let scale = 80.0;
    let p = dir * scale;
    let cell = floor(p);
    let h = hash13(cell);
    let threshold = 0.985;
    if (h > threshold) {
        let b = (h - threshold) / (1.0 - threshold);
        let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
        if (uniforms.star_aa != 0u) {
            // Gaussian speck: distance to cell center, soft radial falloff.
            // Produces a round 2-3 pixel anti-aliased disk instead of a square.
            let center = cell + vec3<f32>(0.5);
            let dist = length(p - center);
            let radius = 0.25 + b * 0.4;
            let falloff = exp(-dist * dist / (radius * radius));
            return col * b * falloff * 4.0 * intensity;
        } else {
            // Original fast path: square-cell smoothstep (blocky but cheap).
            let f = abs(p - cell);
            let d = max(f.x, max(f.y, f.z));
            let falloff = smoothstep(0.5, 0.0, d);
            return col * b * falloff * 3.0 * intensity;
        }
    }
    return vec3<f32>(0.0);
}
  • Step 8: Build and visually verify

Run: cargo run --release Expected: stars are round points instead of rectangular pixel blobs (desktop, star_aa defaults true). The starfield looks sharper.

  • Step 9: Commit
git add assets/shaders/black_hole.wgsl src/render/material.rs src/render/plugin.rs src/params.rs
git commit -m "shader: gaussian-speck stars + star_aa toggle + bloom/exposure uniforms

star_color's smoothstep(0.5,0.0,d) with scale=80 produced blocky rectangles
at low render_scale. Replace with a gaussian speck: distance to cell center
with exp(-d²/r²) falloff, producing a round 2-3 pixel anti-aliased disk.
star_aa uniform toggles between the gaussian path (desktop default) and the
old square-cell fast path (web default).

Also adds bloom_threshold/bloom_strength/exposure uniform fields to
BlackHoleUniforms (used by later bloom tasks) with safe defaults."

Task 4: BrightPass material + shader + camera

Files:

  • Create: assets/shaders/brightpass.wgsl
  • Modify: src/render/material.rs (add BrightPassMaterial)
  • Modify: src/render/plugin.rs (add target/quad/camera marker components, register plugin, spawn brightpass)

Adds stage [2]: extracts luminance > threshold from the HDR offscreen into a half-res float texture.

  • Step 1: Create the brightpass shader

Create assets/shaders/brightpass.wgsl:

#import bevy_sprite::mesh2d_vertex_output::VertexOutput

struct BrightPassUniform {
    threshold: f32,
    _pad0: f32,
    _pad1: f32,
    _pad2: f32,
};

@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: BrightPassUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var samp: sampler;

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let hdr = textureSample(tex, samp, in.uv).rgb;
    let lum = dot(hdr, vec3<f32>(0.2126, 0.7152, 0.0722));
    // Soft knee: smooth roll-off instead of a hard threshold cut.
    // Fully-bright passes through; near-threshold tapers to zero.
    let soft = max(lum - u.threshold, 0.0) / (lum + 0.0001);
    let contribution = hdr * soft;
    return vec4<f32>(contribution, 1.0);
}
  • Step 2: Add BrightPassMaterial to src/render/material.rs

At the end of src/render/material.rs, add:

/// Extracts luminance above a threshold from the HDR offscreen into a
/// half-res float texture (bloom stage [2]). Soft-knee, not hard cut.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct BrightPassMaterial {
    #[uniform(0)]
    pub threshold: f32,
    #[texture(1)]
    #[sampler(2)]
    pub source: Handle<Image>,
}

impl Material2d for BrightPassMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/brightpass.wgsl".into()
    }
}
  • Step 3: Add bloom target/quad/camera marker components

In src/render/plugin.rs, after the CompositeQuad component (added in Task 1 Step 1), add:

/// The half-res Image the bright-pass writes into (bloom stage [2]).
#[derive(Component)]
pub struct BloomTarget0(pub Handle<Image>);

/// Camera + quad markers for the bright-pass (for rebuild queries).
#[derive(Component)]
pub struct BrightPassCamera;
#[derive(Component)]
pub struct BrightPassQuad;

/// Camera + quad markers for the blur passes (for rebuild queries).
#[derive(Component)]
pub struct BlurCamera;
#[derive(Component)]
pub struct BlurQuad;
  • Step 4: Register the BrightPass material plugin

In src/render/plugin.rs, in BlackHolePlugin::build, after the UpscaleMaterial plugin line (around line 44), add:

            .add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default())
  • Step 5: Spawn the brightpass target, quad, and camera

In src/render/plugin.rs, in spawn_fullscreen_quad, after the offscreen camera spawn block (after the OffscreenCamera commands.spawn(...) ending around line 130), insert:

    // --- Bright-pass (bloom stage [2]): half-res float target ---
    let bw = ((w as f32 * 0.5) as u32).max(1);
    let bh = ((h as f32 * 0.5) as u32).max(1);
    let bloom0 = images.add(Image::new_target_texture(
        bw, bh, TextureFormat::Rgba16Float, None,
    ));
    commands.spawn(BloomTarget0(bloom0.clone()));
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(materials.add(crate::render::material::BrightPassMaterial {
            threshold: 1.0,
            source: offscreen.clone(),
        })),
        Transform::default().with_scale(Vec3::new(bw as f32 / 2.0, bh as f32 / 2.0, 1.0)),
        BrightPassQuad,
        QuadScaleFactor(0.5, 0.5),
        RenderLayers::layer(2),
    ));
    commands.spawn((
        Camera2d,
        Camera {
            order: -19,
            clear_color: ClearColorConfig::Custom(Color::srgb(0.0, 0.0, 0.0)),
            ..default()
        },
        RenderTarget::Image(bloom0.clone().into()),
        Msaa::Off,
        BrightPassCamera,
        Nudgable,
        RenderLayers::layer(2),
    ));
  • Step 6: Build and visually verify

Run: cargo run --release Expected: the main image is unchanged (the brightpass runs but its output isn't composited yet — dead-end texture). Confirm no crash, existing render still shows.

  • Step 7: Commit
git add assets/shaders/brightpass.wgsl src/render/material.rs src/render/plugin.rs
git commit -m "render: bright-pass material + camera (bloom stage [2])

Adds BrightPassMaterial + brightpass.wgsl: extracts luminance above a
soft-knee threshold from the HDR offscreen into a half-res Rgba16Float
target (bloom_0). Camera order -19 (after offscreen at -20). The brightpass
output is not yet composited (Task 6); this commit only adds the pass and
confirms it runs without crashing."

Task 5: Blur material + shader + down/up pyramid

Files:

  • Create: assets/shaders/blur.wgsl
  • Modify: src/render/material.rs (add BlurMaterial + BlurUniform)
  • Modify: src/render/plugin.rs (add BloomTarget1/2 + BloomFinalTarget, register plugin, spawn 4 blur passes)

Adds stages [3] and [4]: the downsample then upsample pyramid. For High quality (3 bloom textures): bloom_0 (half, from brightpass) → bloom_1 (quarter) → bloom_2 (eighth), then upsample to bloom_final (half). 2 down passes + 2 up passes = 4 BlurMaterial instances.

  • Step 1: Create the blur shader

Create assets/shaders/blur.wgsl:

#import bevy_sprite::mesh2d_vertex_output::VertexOutput

struct BlurUniform {
    mode: u32,           // 0 = downsample, 1 = upsample
    texel_size: vec2<f32>,
    blend: f32,          // upsample blend factor (ignored for down)
    _pad0: f32,
};

@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: BlurUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var samp: sampler;

// 13-tap weighted kernel (Gaussian approximation for HDR bloom).
// NOTE: naga rejects dynamic indexing of const arrays ("may only be indexed
// by a constant"), so we use var<private> here — it allows runtime indexing.
var<private> KERNEL_OFFSETS: array<vec2<f32>, 13> = array<vec2<f32>, 13>(
    vec2<f32>( 0.0,  0.0),
    vec2<f32>( 1.0,  0.0), vec2<f32>(-1.0,  0.0),
    vec2<f32>( 0.0,  1.0), vec2<f32>( 0.0, -1.0),
    vec2<f32>( 1.0,  1.0), vec2<f32>(-1.0,  1.0),
    vec2<f32>( 1.0, -1.0), vec2<f32>(-1.0, -1.0),
    vec2<f32>( 2.0,  0.0), vec2<f32>(-2.0,  0.0),
    vec2<f32>( 0.0,  2.0), vec2<f32>( 0.0, -2.0),
);
var<private> KERNEL_WEIGHTS: array<f32, 13> = array<f32, 13>(
    0.5,
    0.25, 0.25, 0.25, 0.25,
    0.125, 0.125, 0.125, 0.125,
    0.0625, 0.0625, 0.0625, 0.0625,
);

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let uv = in.uv;
    // Downsample and upsample share the same 13-tap weighted kernel.
    // mode 0 = down (plain weighted average), mode 1 = up (scaled by blend).
    var sum = vec3<f32>(0.0);
    var wsum = 0.0;
    for (var i: i32 = 0; i < 13; i = i + 1) {
        let off = KERNEL_OFFSETS[i] * u.texel_size;
        let c = textureSample(tex, samp, uv + off).rgb;
        sum = sum + c * KERNEL_WEIGHTS[i];
        wsum = wsum + KERNEL_WEIGHTS[i];
    }
    let blurred = sum / wsum;
    if (u.mode == 0u) {
        return vec4<f32>(blurred, 1.0);
    } else {
        return vec4<f32>(blurred * u.blend, 1.0);
    }
}
  • Step 2: Add BlurMaterial + BlurUniform to src/render/material.rs

At the end of src/render/material.rs, add:

/// Uniform for one blur pass (bloom stages [3]/[4]).
#[derive(Clone, ShaderType)]
pub struct BlurUniform {
    pub mode: u32,           // 0 = downsample, 1 = upsample
    pub texel_size: Vec2,
    pub blend: f32,          // upsample blend factor (ignored for down)
    pub _pad0: f32,
}

/// One pass of the bloom down/up pyramid. One material instance per pass
/// (Bevy binds uniforms once per material, not per entity).
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct BlurMaterial {
    #[uniform(0)]
    pub uniform: BlurUniform,
    #[texture(1)]
    #[sampler(2)]
    pub source: Handle<Image>,
}

impl Material2d for BlurMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/blur.wgsl".into()
    }
}
  • Step 3: Add the remaining bloom target components + register the Blur plugin

In src/render/plugin.rs, after the BlurQuad component (Task 4 Step 3), add:

/// Bloom pyramid textures bloom_1, bloom_2 (bloom_0 is BloomTarget0).
#[derive(Component)]
pub struct BloomTarget1(pub Handle<Image>);
#[derive(Component)]
pub struct BloomTarget2(pub Handle<Image>);

/// The final up-sampled bloom texture read by the composite pass.
#[derive(Component)]
pub struct BloomFinalTarget(pub Handle<Image>);

In BlackHolePlugin::build, after the BrightPassMaterial plugin line (Task 4 Step 4), add:

            .add_plugins(Material2dPlugin::<crate::render::material::BlurMaterial>::default())
  • Step 4: Spawn the blur pyramid in spawn_fullscreen_quad

In src/render/plugin.rs, in spawn_fullscreen_quad, after the brightpass spawn block from Task 4 Step 5, insert:

    // --- Blur pyramid (bloom stages [3]/[4]): bloom_1, bloom_2 + down/up passes ---
    let b1w = ((w as f32 * 0.25) as u32).max(1);
    let b1h = ((h as f32 * 0.25) as u32).max(1);
    let b2w = ((w as f32 * 0.125) as u32).max(1);
    let b2h = ((h as f32 * 0.125) as u32).max(1);
    let bloom1 = images.add(Image::new_target_texture(
        b1w, b1h, TextureFormat::Rgba16Float, None,
    ));
    let bloom2 = images.add(Image::new_target_texture(
        b2w, b2h, TextureFormat::Rgba16Float, None,
    ));
    commands.spawn(BloomTarget1(bloom1.clone()));
    commands.spawn(BloomTarget2(bloom2.clone()));

    // Down pass 0→1: samples bloom0 (half-res), writes bloom1 (quarter-res).
    let down01_texel = Vec2::new(1.0 / bw as f32, 1.0 / bh as f32);
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(materials.add(crate::render::material::BlurMaterial {
            uniform: crate::render::material::BlurUniform {
                mode: 0, texel_size: down01_texel, blend: 0.0, _pad0: 0.0,
            },
            source: bloom0.clone(),
        })),
        Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
        BlurQuad,
        QuadScaleFactor(0.25, 0.25),
        RenderLayers::layer(3),
    ));
    // Down pass 1→2: samples bloom1 (quarter), writes bloom2 (eighth).
    let down12_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(materials.add(crate::render::material::BlurMaterial {
            uniform: crate::render::material::BlurUniform {
                mode: 0, texel_size: down12_texel, blend: 0.0, _pad0: 0.0,
            },
            source: bloom1.clone(),
        })),
        Transform::default().with_scale(Vec3::new(b2w as f32 / 2.0, b2h as f32 / 2.0, 1.0)),
        BlurQuad,
        QuadScaleFactor(0.125, 0.125),
        RenderLayers::layer(4),
    ));
    // Up pass 2→1: samples bloom2 (eighth), writes bloom1 (quarter).
    // The up pass OVERWRITES bloom1 (Camera2d clears before drawing) — that's
    // fine; the composite pass adds the final bloom to the HDR scene.
    let up21_texel = Vec2::new(1.0 / b2w as f32, 1.0 / b2h as f32);
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(materials.add(crate::render::material::BlurMaterial {
            uniform: crate::render::material::BlurUniform {
                mode: 1, texel_size: up21_texel, blend: 0.6, _pad0: 0.0,
            },
            source: bloom2.clone(),
        })),
        Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
        BlurQuad,
        QuadScaleFactor(0.25, 0.25),
        RenderLayers::layer(5),
    ));
    // Up pass 1→0: samples bloom1 (quarter), writes bloom_final (half).
    let bfw = bw;
    let bfh = bh;
    let bloom_final = images.add(Image::new_target_texture(
        bfw, bfh, TextureFormat::Rgba16Float, None,
    ));
    commands.spawn(BloomFinalTarget(bloom_final.clone()));
    let up10_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(materials.add(crate::render::material::BlurMaterial {
            uniform: crate::render::material::BlurUniform {
                mode: 1, texel_size: up10_texel, blend: 0.8, _pad0: 0.0,
            },
            source: bloom1.clone(),
        })),
        Transform::default().with_scale(Vec3::new(bfw as f32 / 2.0, bfh as f32 / 2.0, 1.0)),
        BlurQuad,
        QuadScaleFactor(0.5, 0.5),
        RenderLayers::layer(6),
    ));
    // Cameras: down01=-18, down12=-17, up21=-16, up10=-15 (after brightpass
    // at -19, before composite at 0). Each renders to its target Image.
    commands.spawn((
        Camera2d, Camera { order: -18, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
        RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(3),
    ));
    commands.spawn((
        Camera2d, Camera { order: -17, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
        RenderTarget::Image(bloom2.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(4),
    ));
    commands.spawn((
        Camera2d, Camera { order: -16, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
        RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(5),
    ));
    commands.spawn((
        Camera2d, Camera { order: -15, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
        RenderTarget::Image(bloom_final.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(6),
    ));
  • Step 5: Build and visually verify

Run: cargo run --release Expected: main image unchanged (bloom output not composited yet). Confirm no crash; blur passes run into dead-end textures.

  • Step 6: Commit
git add assets/shaders/blur.wgsl src/render/material.rs src/render/plugin.rs
git commit -m "render: blur pyramid (bloom stages [3]/[4]) — down then up

Adds BlurMaterial + blur.wgsl with a 13-tap weighted Gaussian kernel
(var<private> arrays — naga rejects dynamic indexing of const arrays),
run in downsample then upsample passes. For the 3-level pyramid:
bloom_0 (half, from brightpass) → bloom_1 (quarter) → bloom_2 (eighth),
then upsample to bloom_final (half). Camera orders -18/-17/-16/-15.

The bloom output is not yet composited (Task 6); this commit only wires
the passes and confirms they run in sequence without crashing."

Task 6: Composite material + shader + ACES (replaces upscale)

Files:

  • Create: assets/shaders/composite.wgsl
  • Modify: src/render/material.rs (add CompositeMaterial + CompositeUniform, remove UpscaleMaterial)
  • Modify: src/render/plugin.rs (replace upscale quad/material/camera with composite; register plugin; update mirror_params; update resize_offscreen for bloom targets)
  • Delete: assets/shaders/upscale.wgsl

Adds stage [5]: ACES tone-map + bloom blend, writing to the window. Replaces the old upscale path.

  • Step 1: Create the composite shader

Create assets/shaders/composite.wgsl:

#import bevy_sprite::mesh2d_vertex_output::VertexOutput

struct CompositeUniform {
    bloom_strength: f32,
    exposure: f32,
    _pad0: f32,
    _pad1: f32,
};

@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: CompositeUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var scene_tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var scene_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var bloom_tex: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var bloom_samp: sampler;

// ACES Narkowicz fit (5 ops, clamped to [0,1]).
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
    let a = 2.51;
    let b = 0.03;
    let c = 2.43;
    let d = 0.59;
    let e = 0.14;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let hdr = textureSample(scene_tex, scene_samp, in.uv).rgb;
    let bloom = textureSample(bloom_tex, bloom_samp, in.uv).rgb;
    let combined = hdr + bloom * u.bloom_strength;
    let mapped = aces_tonemap(combined * u.exposure);
    return vec4<f32>(mapped, 1.0);
}
  • Step 2: Add CompositeMaterial + CompositeUniform to src/render/material.rs

At the end of src/render/material.rs, add:

/// Uniform for the composite pass (bloom stage [5]).
#[derive(Clone, ShaderType)]
pub struct CompositeUniform {
    pub bloom_strength: f32,
    pub exposure: f32,
    pub _pad0: f32,
    pub _pad1: f32,
}

/// Final stage: tone-maps the HDR scene + bloom to LDR for the window.
/// Replaces UpscaleMaterial. ACES (Narkowicz) tone mapping.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct CompositeMaterial {
    #[uniform(0)]
    pub uniform: CompositeUniform,
    #[texture(1)]
    #[sampler(2)]
    pub scene: Handle<Image>,
    #[texture(3)]
    #[sampler(4)]
    pub bloom: Handle<Image>,
}

impl Material2d for CompositeMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/composite.wgsl".into()
    }
}
  • Step 3: Remove UpscaleMaterial from src/render/material.rs

Delete the entire UpscaleMaterial struct and its impl Material2d block (the lines from /// Samples the sub-resolution offscreen render... through the end of its impl, around lines 118-131).

  • Step 4: Register the Composite material plugin, remove the Upscale plugin

In src/render/plugin.rs, in BlackHolePlugin::build, replace the line:

            .add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default())

with:

            .add_plugins(Material2dPlugin::<crate::render::material::CompositeMaterial>::default())
  • Step 5: Replace the upscale quad/camera with composite in spawn_fullscreen_quad

First, update the spawn_fullscreen_quad signature — the upscale_materials parameter held UpscaleMaterial; now it must hold CompositeMaterial. Change:

    mut upscale_materials: ResMut<Assets<crate::render::material::UpscaleMaterial>>,

to:

    mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,

Then find the upscale quad + camera spawn block (the --- Upscale quad --- section, around lines 132-142) and replace it with:

    // --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) ---
    // bloom_final comes from the blur pyramid (Task 5 Step 4).
    let composite_mat = composite_materials.add(crate::render::material::CompositeMaterial {
        uniform: crate::render::material::CompositeUniform {
            bloom_strength: 0.8, exposure: 1.0, _pad0: 0.0, _pad1: 0.0,
        },
        scene: offscreen.clone(),
        bloom: bloom_final.clone(),
    });
    commands.spawn((
        Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
        MeshMaterial2d(composite_mat),
        Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)),
        UpscaleQuad,
        CompositeQuad,
        RenderLayers::layer(1),
    ));
    commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, RenderLayers::layer(1)));

bloom_final is the Handle<Image> from BloomFinalTarget spawned in Task 5 Step 4 — already in scope (same function).

  • Step 6: Update mirror_params to set composite + brightpass uniforms each frame

In src/render/plugin.rs, in mirror_params, add the composite_materials and brightpass_materials parameters and update loops. Change the signature:

fn mirror_params(
    camera: Res<crate::camera::OrbitCamera>,
    params: Res<crate::params::BlackHoleParams>,
    time: Res<Time>,
    window: Query<&Window>,
    mut materials: ResMut<Assets<BlackHoleMaterial>>,
) {

to:

fn mirror_params(
    camera: Res<crate::camera::OrbitCamera>,
    params: Res<crate::params::BlackHoleParams>,
    time: Res<Time>,
    window: Query<&Window>,
    mut materials: ResMut<Assets<BlackHoleMaterial>>,
    mut brightpass_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
    mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,
) {

After the existing for (_, mat) in materials.iter_mut() loop, add:

    // Update brightpass threshold (live-tunable).
    for (_, mat) in brightpass_materials.iter_mut() {
        mat.threshold = params.bloom_threshold;
    }
    // Update composite material uniforms (bloom strength + exposure live-tunable).
    for (_, mat) in composite_materials.iter_mut() {
        mat.uniform.bloom_strength = params.bloom_strength;
        mat.uniform.exposure = params.exposure;
    }
  • Step 7: Update resize_offscreen to resize bloom targets

In src/render/plugin.rs, in resize_offscreen, add the bloom target queries and resize logic. The signature (built in Task 1 Step 7) already has the quads ParamSet with QuadScaleFactor/CompositeQuad. Add the bloom target queries. Change:

fn resize_offscreen(
    mut images: ResMut<Assets<Image>>,
    params: Res<crate::params::BlackHoleParams>,
    window: Query<&Window>,
    mut resized: MessageReader<bevy::window::WindowResized>,
    offscreen: Query<&OffscreenTarget>,
    mut quads: ParamSet<(
        Query<(&mut Transform, &QuadScaleFactor), Without<CompositeQuad>>,
        Query<&mut Transform, With<CompositeQuad>>,
    )>,
) {

to:

fn resize_offscreen(
    mut images: ResMut<Assets<Image>>,
    params: Res<crate::params::BlackHoleParams>,
    window: Query<&Window>,
    mut resized: MessageReader<bevy::window::WindowResized>,
    offscreen: Query<&OffscreenTarget>,
    bloom0: Query<&BloomTarget0>,
    bloom1: Query<&BloomTarget1>,
    bloom2: Query<&BloomTarget2>,
    bloom_final: Query<&BloomFinalTarget>,
    mut quads: ParamSet<(
        Query<(&mut Transform, &QuadScaleFactor), Without<CompositeQuad>>,
        Query<&mut Transform, With<CompositeQuad>>,
    )>,
) {

Then, after the offscreen image resize block (the if let Ok(handle) = offscreen.single() { ... } block), add:

    // Bloom pyramid (queries return empty when bloom_quality == Off).
    let bw = ((w as f32 * 0.5) as u32).max(1);
    let bh = ((h as f32 * 0.5) as u32).max(1);
    let b1w = ((w as f32 * 0.25) as u32).max(1);
    let b1h = ((h as f32 * 0.25) as u32).max(1);
    let b2w = ((w as f32 * 0.125) as u32).max(1);
    let b2h = ((h as f32 * 0.125) as u32).max(1);
    if let Ok(t) = bloom0.single() {
        let _ = images.insert(t.0.id(), Image::new_target_texture(bw, bh, TextureFormat::Rgba16Float, None));
    }
    if let Ok(t) = bloom1.single() {
        let _ = images.insert(t.0.id(), Image::new_target_texture(b1w, b1h, TextureFormat::Rgba16Float, None));
    }
    if let Ok(t) = bloom2.single() {
        let _ = images.insert(t.0.id(), Image::new_target_texture(b2w, b2h, TextureFormat::Rgba16Float, None));
    }
    if let Ok(t) = bloom_final.single() {
        let _ = images.insert(t.0.id(), Image::new_target_texture(bw, bh, TextureFormat::Rgba16Float, None));
    }
  • Step 8: Delete the old upscale shader

Run: git rm assets/shaders/upscale.wgsl

  • Step 9: Build and visually verify

Run: cargo run --release Expected: bloom is now composited. Bright disk regions glow with a soft halo. ACES compresses over-bright cores to a natural white-orange. The image looks dramatically more cinematic. If bloom is too strong, lower bloom_strength (default 0.8) — UI control comes in Task 8.

  • Step 10: Commit
git add assets/shaders/composite.wgsl src/render/material.rs src/render/plugin.rs
git rm assets/shaders/upscale.wgsl
git commit -m "render: composite + ACES tone-map (bloom stage [5]), replaces upscale

Adds CompositeMaterial + composite.wgsl: combines the HDR scene with the
bloom pyramid output, applies ACES (Narkowicz) tone mapping, and writes
LDR to the window. Removes UpscaleMaterial + upscale.wgsl.

mirror_params now updates composite (bloom_strength, exposure) and
brightpass (threshold) uniforms each frame. resize_offscreen rebuilds
the full bloom pyramid targets on window resize."

Task 7: BloomQuality enum + tiered defaults + bloom-off fallback

Files:

  • Modify: src/params.rs (add BloomQuality enum + bloom_quality field + tiered defaults)
  • Modify: src/render/plugin.rs (conditional spawn of bloom passes based on quality; bloom-off fallback to a 1×1 black bloom texture)

Makes bloom quality configurable and adds web/desktop tiered defaults. Off skips all bloom passes and composites scene-only with ACES.

  • Step 1: Add BloomQuality enum to src/params.rs

At the top of src/params.rs (before BlackHoleParams), add:

/// Bloom pyramid depth (number of bloom textures).
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum BloomQuality {
    Off,     // no bloom, scene-only ACES composite
    Low,     // 1 level: brightpass → composite (soft halo)
    Medium,  // 2 levels: brightpass → 1 down → 1 up → composite
    #[default]
    High,    // 3 levels: brightpass → 2 down → 2 up → composite (full cinematic)
}

impl BloomQuality {
    pub fn levels(self) -> u32 {
        match self {
            BloomQuality::Off => 0,
            BloomQuality::Low => 1,
            BloomQuality::Medium => 2,
            BloomQuality::High => 3,
        }
    }
}
  • Step 2: Add bloom_quality field + tiered default to BlackHoleParams

In src/params.rs, in the BlackHoleParams struct, after pub exposure: f32, (added in Task 3), add:

    pub bloom_quality: BloomQuality,

In impl Default for BlackHoleParams, after exposure: 1.0,, add:

            bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High },
  • Step 3: Gate bloom spawn on bloom_quality in spawn_fullscreen_quad

In src/render/plugin.rs, in spawn_fullscreen_quad, wrap the entire bloom spawn block (brightpass from Task 4 + blur pyramid from Task 5) in a conditional based on params.bloom_quality.levels(). Introduce a bloom_final_handle: Option<Handle<Image>> that is Some when bloom is active, None when Off:

    let levels = params.bloom_quality.levels();
    let mut bloom_final_handle: Option<Handle<Image>> = None;
    if levels > 0 {
        // ... the entire brightpass + blur pyramid spawn block from Tasks 4-5 ...
        // At the end of the block, after bloom_final is created:
        bloom_final_handle = Some(bloom_final.clone());
    }

Then, for the composite material spawn (Task 6 Step 5), use the bloom handle if present, else a 1×1 black fallback texture:

    let bloom_handle = bloom_final_handle.unwrap_or_else(|| {
        // 1x1 black fallback — composite samples black, effectively scene-only ACES.
        images.add(Image::new_fill(
            bevy::render::render_resource::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
            bevy::render::render_resource::TextureDimension::D2,
            &[0, 0, 0, 0],
            TextureFormat::Rgba16Float,
            bevy::render::render_asset::RenderAssetUsages::default(),
        ))
    });
    let composite_mat = composite_materials.add(crate::render::material::CompositeMaterial {
        uniform: crate::render::material::CompositeUniform {
            bloom_strength: 0.8, exposure: 1.0, _pad0: 0.0, _pad1: 0.0,
        },
        scene: offscreen.clone(),
        bloom: bloom_handle,
    });

(Replace the bloom: bloom_final.clone(), line from Task 6 Step 5 with bloom: bloom_handle,.)

Note: params is already a parameter of spawn_fullscreen_quad (params: Res<crate::params::BlackHoleParams>). It's currently only used for render_scale; now it's also read for bloom_quality. The params resource is immutable in spawn_fullscreen_quad (it's Res, not ResMut), which is correct for a read.

  • Step 4: Build and visually verify (Off path)

Temporarily set bloom_quality: BloomQuality::Off in the params default, run cargo run --release. Expected: the image renders with ACES tone mapping but no bloom halo — a clean HDR scene. Restore to the tiered default (if cfg!(wasm32) { Low } else { High }) after confirming.

  • Step 5: Build and visually verify (High path)

Set bloom_quality: BloomQuality::High, run cargo run --release. Expected: full bloom as in Task 6.

  • Step 6: Commit
git add src/params.rs src/render/plugin.rs
git commit -m "params: BloomQuality enum + tiered web/desktop defaults + Off fallback

BloomQuality { Off, Low, Medium, High } controls pyramid depth (0-3).
Web defaults to Low (1 level, minimal float textures), desktop to High
(3 levels, full cinematic). Off skips all bloom passes and composites
scene-only with ACES — the fallback path for WebGPU browsers without
float-filterable support. The composite samples a 1x1 black texture
when bloom is off."

Task 8: Quality egui panel + runtime bloom-quality rebuild

Files:

  • Modify: src/ui.rs (add Quality collapsible section)
  • Modify: src/render/plugin.rs (rebuild bloom pyramid when bloom_quality changes at runtime)

Wires all quality options to the UI and handles runtime pyramid rebuild.

  • Step 1: Add the Quality panel section to src/ui.rs

In src/ui.rs, in ui_system, after the existing Grid CollapsingHeader (the last one, around line 54), add a new section inside the egui::Window::show closure:

                egui::CollapsingHeader::new("Quality")
                    .default_open(true)
                    .show(ui, |ui| {
                        use crate::params::BloomQuality;
                        let mut q = params.bloom_quality;
                        egui::ComboBox::from_label("Bloom quality")
                            .selected_text(format!("{:?}", q))
                            .show_ui(ui, |ui| {
                                ui.selectable_value(&mut q, BloomQuality::Off, "Off");
                                ui.selectable_value(&mut q, BloomQuality::Low, "Low");
                                ui.selectable_value(&mut q, BloomQuality::Medium, "Medium");
                                ui.selectable_value(&mut q, BloomQuality::High, "High");
                            });
                        params.bloom_quality = q;
                        ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).text("Bloom threshold"));
                        ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).text("Bloom strength"));
                        ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure"));
                        ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale"));
                        ui.checkbox(&mut params.star_aa, "Anti-aliased stars");
                        ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
                    });
  • Step 2: Extract bloom spawn into a reusable helper

In src/render/plugin.rs, extract the bloom spawn block (brightpass + blur pyramid, currently inside the if levels > 0 { ... } in spawn_fullscreen_quad) into a standalone function that both startup and the rebuild system can call:

/// Spawns the bloom pipeline (brightpass + blur pyramid + bloom_final).
/// Called at startup and when bloom_quality changes at runtime.
/// Returns the bloom_final handle (None if levels == 0).
#[allow(clippy::too_many_arguments)]
fn spawn_bloom_pipeline(
    commands: &mut Commands,
    images: &mut Assets<Image>,
    materials: &mut Assets<crate::render::material::BrightPassMaterial>,
    blur_materials: &mut Assets<crate::render::material::BlurMaterial>,
    meshes: &mut Assets<Mesh>,
    offscreen: &Handle<Image>,
    w: u32,
    h: u32,
    levels: u32,
) -> Option<Handle<Image>> {
    if levels == 0 { return None; }
    // ... the brightpass + blur spawn code, moved verbatim from spawn_fullscreen_quad ...
    // Return Some(bloom_final.clone()) at the end.
    // NOTE: when levels < 3, spawn fewer blur passes. For Low (1 level):
    //   only brightpass → bloom_final (no down/up). For Medium (2 levels):
    //   brightpass → 1 down → 1 up. Branch on `levels`.
    todo!("move the bloom spawn code here, branching on `levels`")
}

The levels branching: for levels == 1 (Low), spawn only the brightpass writing directly to bloom_final (skip the blur pyramid). For levels == 2 (Medium), spawn brightpass + one down pass + one up pass. For levels == 3 (High), the full 2-down + 2-up pyramid. The composite reads bloom_final regardless of how many levels produced it.

Note: this refactor also requires BlurMaterial assets in the signature (the materials: &mut Assets<BrightPassMaterial> param isn't enough — blur passes need their own). Add blur_materials: &mut Assets<crate::render::material::BlurMaterial> and update the calls. The spawn_fullscreen_quad function currently uses a single materials: ResMut<Assets<BlackHoleMaterial>> for the black-hole material and separate typed asset stores for bloom. Ensure all typed asset stores are passed through.

  • Step 3: Add the AppliedBloomQuality resource + rebuild_bloom system

In src/render/plugin.rs, add:

/// Tracks the bloom quality currently applied to the render pipeline.
/// When it differs from params.bloom_quality, a rebuild is triggered.
#[derive(Resource)]
pub struct AppliedBloomQuality(pub crate::params::BloomQuality);

In BlackHolePlugin::build, init it (set to the params default at startup):

            .init_resource::<AppliedBloomQuality>()

Wait — init_resource uses Default, but BloomQuality defaults to High. The web default is Low. So AppliedBloomQuality must be initialized from params, not via init_resource. Instead, set it in spawn_fullscreen_quad after reading params:

    commands.insert_resource(AppliedBloomQuality(params.bloom_quality));

Add the rebuild system:

/// Detects a bloom_quality change and rebuilds the bloom pipeline.
/// Heavy (despawn + respawn all bloom entities) but only fires when the
/// user changes the dropdown.
fn rebuild_bloom(
    params: Res<crate::params::BlackHoleParams>,
    applied: Res<AppliedBloomQuality>,
    mut commands: Commands,
    // Despawn all bloom entities (cameras, quads, targets).
    bloom_entities: Query<Entity, Or<(
        With<BrightPassCamera>, With<BlurCamera>,
        With<BrightPassQuad>, With<BlurQuad>,
        Or<(With<BloomTarget0>, With<BloomTarget1>, With<BloomTarget2>, With<BloomFinalTarget>)>,
    )>>,
    // Also re-query the composite material to update its bloom handle.
    mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,
    // Need the offscreen handle + window size + asset stores to re-spawn.
    offscreen_target: Query<&OffscreenTarget>,
    window: Query<&Window>,
    mut images: ResMut<Assets<Image>>,
    mut bp_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
    mut blur_materials: ResMut<Assets<crate::render::material::BlurMaterial>>,
    mut meshes: ResMut<Assets<Mesh>>,
) {
    if params.bloom_quality == applied.0 { return; }
    // Despawn all bloom entities.
    for e in bloom_entities.iter() {
        commands.entity(e).despawn();
    }
    // Re-spawn at the new quality.
    let Ok(win) = window.single() else { return; };
    let scale = params.render_scale.clamp(MIN_RENDER_SCALE, 1.0);
    let w = ((win.width() * scale) as u32).max(1);
    let h = ((win.height() * scale) as u32).max(1);
    let Ok(offscreen_t) = offscreen_target.single() else { return; };
    let offscreen = offscreen_t.0.clone();
    let levels = params.bloom_quality.levels();
    let new_bloom = spawn_bloom_pipeline(
        &mut commands, &mut images, &mut bp_materials, &mut blur_materials,
        &mut meshes, &offscreen, w, h, levels,
    );
    // Update the composite material's bloom handle.
    let bloom_handle = new_bloom.unwrap_or_else(|| {
        images.add(Image::new_fill(
            bevy::render::render_resource::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
            bevy::render::render_resource::TextureDimension::D2,
            &[0, 0, 0, 0],
            TextureFormat::Rgba16Float,
            bevy::render::render_asset::RenderAssetUsages::default(),
        ))
    });
    for (_, mat) in composite_materials.iter_mut() {
        mat.bloom = bloom_handle.clone();
    }
    commands.insert_resource(AppliedBloomQuality(params.bloom_quality));
}

Register it in BlackHolePlugin::build:

            .add_systems(Update, rebuild_bloom)

Note: the AppliedBloomQuality resource must NOT be a plain Resource updated via commands.insert_resource inside a system that also reads it as Res — Bevy's change detection requires it to be ResMut to update. Since rebuild_bloom both reads (applied.0) and writes (commands.insert_resource) it, read it as Res and write via commands.insert_resource (which replaces the resource). This is safe — insert_resource doesn't conflict with the Res borrow because it runs at command application time, after the system's direct borrows are released.

Actually — applied: Res<AppliedBloomQuality> and then commands.insert_resource(AppliedBloomQuality(...)) in the same system IS safe in Bevy: the Res is an immutable borrow for reading, and insert_resource is a queued command applied later. Confirmed pattern.

  • Step 4: Build and visually verify

Run: cargo run --release Expected: the Quality panel appears with all controls. Changing bloom quality at runtime rebuilds the pyramid (no crash). Sliders for threshold/strength/exposure take effect immediately. Toggling star_aa switches star rendering. Resolution scale changes sharpness.

  • Step 5: Commit
git add src/ui.rs src/render/plugin.rs
git commit -m "ui: Quality panel + runtime bloom-quality rebuild

Adds a dedicated Quality collapsible section: bloom quality dropdown
(Off/Low/Medium/High), bloom threshold/strength, exposure, resolution
scale, star AA toggle. Changing bloom quality at runtime triggers a
pipeline rebuild (despawn bloom entities + respawn via spawn_bloom_pipeline
helper, branching on levels). MSAA honestly labeled as decorative."

Task 9: Final verification

Files: none (verification only)

  • Step 1: Run the full test suite

Run: cargo test Expected: all physics tests pass unchanged (the integrator wasn't touched).

  • Step 2: Desktop visual verification

Run: cargo run --release Check against the Gargantua reference:

  • Disk has feathered/smoky texture (FBM), not bands.

  • Bright disk regions have a soft cinematic halo (bloom).

  • White-hot core → deep orange gradient (HDR + ACES).

  • Doppler asymmetry: one side brighter than the other.

  • Stars are round points, not rectangles.

  • Quality panel controls all work live.

  • Step 3: Web build check (if trunk is installed)

Run: trunk build --release Expected: builds without error. (Full web visual check requires a WebGPU browser; the build succeeding confirms the float-format code compiles for wasm32.)

  • Step 4: Commit any final tuning

If any tuning constants (FBM scales, bloom strengths, kernel weights) were adjusted during verification, commit them:

git add -A
git commit -m "tweak: final cinematic rendering tuning constants

Adjusted [specific constants] during visual verification against the
Gargantua reference."

Self-Review

1. Spec coverage:

  • HDR color + tone mapping → Task 1 (Rgba16Float) + Task 6 (ACES). ✓
  • Bloom post-processing → Tasks 4, 5, 6, 7. ✓
  • Smoke/turbulence disk texture → Task 2 (FBM). ✓
  • Anti-aliasing + round stars → Task 3 (gaussian speck) + render_scale in Task 8 panel. ✓
  • Configurable quality panel → Task 8. ✓
  • Tiered web defaults → Task 7. ✓
  • Bloom-off fallback → Task 7. ✓
  • Nudgable marker → Task 1. ✓

2. Placeholder scan: Task 8 Step 2 contains a todo!("move the bloom spawn code here...") — this is a genuine refactor instruction (extract existing code into a helper), not a content placeholder. The implementer moves the Task 4/5 spawn code verbatim into spawn_bloom_pipeline and adds the levels branching. No other TBD/TODO.

3. Type consistency:

  • BloomQuality enum: consistent (Off/Low/Medium/High, .levels()) across params.rs, ui.rs, plugin.rs. ✓
  • BlurUniform: mode: u32, texel_size: Vec2, blend: f32, _pad0: f32 — matches shader. ✓
  • CompositeUniform: bloom_strength, exposure, _pad0, _pad1 — matches shader. ✓
  • QuadScaleFactor(f32, f32): consistent across all spawn sites + resize. ✓
  • CompositeQuad marker: on composite quad, queried in resize + rebuild. ✓
  • Nudgable marker: applied to all 7 cameras. ✓
  • Camera orders: -20/-19/-18/-17/-16/-15/0 — consistent across all tasks. ✓
  • RenderLayers: 0-6 — consistent across all tasks. ✓