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.
60 KiB
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
CompositeQuadmarker instead and is handled separately in resize.
File Structure
Created:
assets/shaders/brightpass.wgsl— stage [2]: extracts luminance > threshold with soft kneeassets/shaders/blur.wgsl— stages [3]/[4]: one shader, down/up modes via uniformassets/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_aabranch, new uniform fields readsrc/render/material.rs— addBrightPassMaterial,BlurMaterial,CompositeMaterial; add new uniform fields toBlackHoleUniforms; removeUpscaleMaterialsrc/render/plugin.rs— spawn bloom targets/materials/cameras;Nudgable+QuadScaleFactor+CompositeQuadmarkers; generalizenudge_camera; bloom quality rebuild on param changesrc/params.rs— addBloomQualityenum + quality fields toBlackHoleParams; tiered defaultssrc/ui.rs— newQualitypanel 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, addNudgable/QuadScaleFactor/CompositeQuadmarkers to offscreen + upscale cameras/quads, generalizenudge_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
QuadScaleFactorto 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_camerato use theNudgablemarker
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_offscreento useQuadScaleFactor
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 addhash33/value_noise3/fbm3/disk_noisehelpers beforedisk_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(BlackHoleUniformsstruct +star_color) - Modify:
src/render/material.rs(BlackHoleUniformsfields + 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
BlackHoleUniformsstruct
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
BlackHoleUniformsstruct
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_colorwith 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(addBrightPassMaterial) - 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
BrightPassMaterialtosrc/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(addBlurMaterial+BlurUniform) - Modify:
src/render/plugin.rs(addBloomTarget1/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+BlurUniformtosrc/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(addCompositeMaterial+CompositeUniform, removeUpscaleMaterial) - Modify:
src/render/plugin.rs(replace upscale quad/material/camera with composite; register plugin; updatemirror_params; updateresize_offscreenfor 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+CompositeUniformtosrc/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
UpscaleMaterialfromsrc/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_paramsto 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_offscreento 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(addBloomQualityenum +bloom_qualityfield + 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
BloomQualityenum tosrc/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_qualityfield + tiered default toBlackHoleParams
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_qualityinspawn_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(addQualitycollapsible section) - Modify:
src/render/plugin.rs(rebuild bloom pyramid whenbloom_qualitychanges 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
AppliedBloomQualityresource +rebuild_bloomsystem
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:
BloomQualityenum: 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. ✓CompositeQuadmarker: on composite quad, queried in resize + rebuild. ✓Nudgablemarker: 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. ✓