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.

Note: this task gates Off vs full-On (3-level pyramid always runs when
enabled). Partial pyramid for Low/Medium is deferred to the runtime
rebuild in Task 8.
This commit is contained in:
xfy 2026-07-15 00:22:39 +08:00
parent a7490c0c2f
commit c06f1543db
2 changed files with 177 additions and 129 deletions

View File

@ -1,5 +1,26 @@
use bevy::prelude::*; use bevy::prelude::*;
/// 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,
}
}
}
/// All tunable black-hole parameters. Edited by the egui panel (Task 17), /// All tunable black-hole parameters. Edited by the egui panel (Task 17),
/// mirrored into BlackHoleUniforms each frame (Task 7). /// mirrored into BlackHoleUniforms each frame (Task 7).
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
@ -32,6 +53,7 @@ pub struct BlackHoleParams {
pub bloom_threshold: f32, pub bloom_threshold: f32,
pub bloom_strength: f32, pub bloom_strength: f32,
pub exposure: f32, pub exposure: f32,
pub bloom_quality: BloomQuality,
} }
impl Default for BlackHoleParams { impl Default for BlackHoleParams {
@ -57,6 +79,7 @@ impl Default for BlackHoleParams {
bloom_threshold: 1.0, bloom_threshold: 1.0,
bloom_strength: 0.8, bloom_strength: 0.8,
exposure: 1.0, exposure: 1.0,
bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High },
} }
} }
} }

View File

@ -177,144 +177,169 @@ fn spawn_fullscreen_quad(
RenderLayers::layer(0), RenderLayers::layer(0),
)); ));
// --- Bright-pass (bloom stage [2]): half-res float target --- let levels = params.bloom_quality.levels();
let bw = ((w as f32 * 0.5) as u32).max(1); let mut bloom_final_handle: Option<Handle<Image>> = None;
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(bp_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),
));
// --- Blur pyramid (bloom stages [3]/[4]): bloom_1, bloom_2 + down/up passes --- if levels > 0 {
let b1w = ((w as f32 * 0.25) as u32).max(1); // NOTE: when bloom is enabled the full 3-level pyramid (High) always
let b1h = ((h as f32 * 0.25) as u32).max(1); // runs here. Partial pyramid — Low = brightpass-only, Medium = 1 down +
let b2w = ((w as f32 * 0.125) as u32).max(1); // 1 up — is deferred to the runtime rebuild in Task 8, which can spawn
let b2h = ((h as f32 * 0.125) as u32).max(1); // fewer passes. For now this is a simple Off vs On gate.
let bloom1 = images.add(Image::new_target_texture( //
b1w, b1h, TextureFormat::Rgba16Float, None, // --- Bright-pass (bloom stage [2]): half-res float target ---
)); let bw = ((w as f32 * 0.5) as u32).max(1);
let bloom2 = images.add(Image::new_target_texture( let bh = ((h as f32 * 0.5) as u32).max(1);
b2w, b2h, TextureFormat::Rgba16Float, None, let bloom0 = images.add(Image::new_target_texture(
)); bw, bh, TextureFormat::Rgba16Float, None,
commands.spawn(BloomTarget1(bloom1.clone())); ));
commands.spawn(BloomTarget2(bloom2.clone())); commands.spawn(BloomTarget0(bloom0.clone()));
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
MeshMaterial2d(bp_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),
));
// Down pass 0→1: samples bloom0 (half-res), writes bloom1 (quarter-res). // --- Blur pyramid (bloom stages [3]/[4]): bloom_1, bloom_2 + down/up passes ---
let down01_texel = Vec2::new(1.0 / bw as f32, 1.0 / bh as f32); let b1w = ((w as f32 * 0.25) as u32).max(1);
commands.spawn(( let b1h = ((h as f32 * 0.25) as u32).max(1);
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), let b2w = ((w as f32 * 0.125) as u32).max(1);
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial { let b2h = ((h as f32 * 0.125) as u32).max(1);
uniform: crate::render::material::BlurUniform { let bloom1 = images.add(Image::new_target_texture(
mode: 0, texel_size: down01_texel, blend: 0.0, _pad0: 0.0, b1w, b1h, TextureFormat::Rgba16Float, None,
}, ));
source: bloom0.clone(), let bloom2 = images.add(Image::new_target_texture(
})), b2w, b2h, TextureFormat::Rgba16Float, None,
Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)), ));
BlurQuad, commands.spawn(BloomTarget1(bloom1.clone()));
QuadScaleFactor(0.25, 0.25), commands.spawn(BloomTarget2(bloom2.clone()));
RenderLayers::layer(3),
)); // Down pass 0→1: samples bloom0 (half-res), writes bloom1 (quarter-res).
// Down pass 1→2: samples bloom1 (quarter), writes bloom2 (eighth). let down01_texel = Vec2::new(1.0 / bw as f32, 1.0 / bh as f32);
let down12_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32); commands.spawn((
commands.spawn(( Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial { uniform: crate::render::material::BlurUniform {
uniform: crate::render::material::BlurUniform { mode: 0, texel_size: down01_texel, blend: 0.0, _pad0: 0.0,
mode: 0, texel_size: down12_texel, blend: 0.0, _pad0: 0.0, },
}, source: bloom0.clone(),
source: bloom1.clone(), })),
})), Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
Transform::default().with_scale(Vec3::new(b2w as f32 / 2.0, b2h as f32 / 2.0, 1.0)), BlurQuad,
BlurQuad, QuadScaleFactor(0.25, 0.25),
QuadScaleFactor(0.125, 0.125), RenderLayers::layer(3),
RenderLayers::layer(4), ));
)); // Down pass 1→2: samples bloom1 (quarter), writes bloom2 (eighth).
// Up pass 2→1: samples bloom2 (eighth), writes bloom1 (quarter). let down12_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
let up21_texel = Vec2::new(1.0 / b2w as f32, 1.0 / b2h as f32); commands.spawn((
commands.spawn(( Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial { uniform: crate::render::material::BlurUniform {
uniform: crate::render::material::BlurUniform { mode: 0, texel_size: down12_texel, blend: 0.0, _pad0: 0.0,
mode: 1, texel_size: up21_texel, blend: 0.6, _pad0: 0.0, },
}, source: bloom1.clone(),
source: bloom2.clone(), })),
})), Transform::default().with_scale(Vec3::new(b2w as f32 / 2.0, b2h as f32 / 2.0, 1.0)),
Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)), BlurQuad,
BlurQuad, QuadScaleFactor(0.125, 0.125),
QuadScaleFactor(0.25, 0.25), RenderLayers::layer(4),
RenderLayers::layer(5), ));
)); // Up pass 2→1: samples bloom2 (eighth), writes bloom1 (quarter).
// Up pass 1→0: samples bloom1 (quarter), writes bloom_final (half). let up21_texel = Vec2::new(1.0 / b2w as f32, 1.0 / b2h as f32);
let bfw = bw; commands.spawn((
let bfh = bh; Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
let bloom_final = images.add(Image::new_target_texture( MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
bfw, bfh, TextureFormat::Rgba16Float, None, uniform: crate::render::material::BlurUniform {
)); mode: 1, texel_size: up21_texel, blend: 0.6, _pad0: 0.0,
commands.spawn(BloomFinalTarget(bloom_final.clone())); },
let up10_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32); source: bloom2.clone(),
commands.spawn(( })),
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial { BlurQuad,
uniform: crate::render::material::BlurUniform { QuadScaleFactor(0.25, 0.25),
mode: 1, texel_size: up10_texel, blend: 0.8, _pad0: 0.0, RenderLayers::layer(5),
}, ));
source: bloom1.clone(), // Up pass 1→0: samples bloom1 (quarter), writes bloom_final (half).
})), let bfw = bw;
Transform::default().with_scale(Vec3::new(bfw as f32 / 2.0, bfh as f32 / 2.0, 1.0)), let bfh = bh;
BlurQuad, let bloom_final = images.add(Image::new_target_texture(
QuadScaleFactor(0.5, 0.5), bfw, bfh, TextureFormat::Rgba16Float, None,
RenderLayers::layer(6), ));
)); commands.spawn(BloomFinalTarget(bloom_final.clone()));
// Cameras: down01=-18, down12=-17, up21=-16, up10=-15. let up10_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
commands.spawn(( commands.spawn((
Camera2d, Camera { order: -18, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() }, Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(3), MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
)); uniform: crate::render::material::BlurUniform {
commands.spawn(( mode: 1, texel_size: up10_texel, blend: 0.8, _pad0: 0.0,
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), source: bloom1.clone(),
)); })),
commands.spawn(( Transform::default().with_scale(Vec3::new(bfw as f32 / 2.0, bfh as f32 / 2.0, 1.0)),
Camera2d, Camera { order: -16, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() }, BlurQuad,
RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(5), QuadScaleFactor(0.5, 0.5),
)); RenderLayers::layer(6),
commands.spawn(( ));
Camera2d, Camera { order: -15, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() }, // Cameras: down01=-18, down12=-17, up21=-16, up10=-15.
RenderTarget::Image(bloom_final.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(6), 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),
));
bloom_final_handle = Some(bloom_final.clone());
}
// --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) --- // --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) ---
// bloom_final comes from the blur pyramid (Task 5). // When bloom is off (BloomQuality::Off), bloom_final_handle is None and we
// fall back to a 1x1 black texture: the composite samples black, yielding a
// scene-only ACES composite (no bloom contribution).
let bloom_handle = bloom_final_handle.unwrap_or_else(|| {
// 1x1 black fallback — composite samples black, effectively scene-only ACES.
// 8 zero bytes = one Rgba16Float pixel (16 bits/channel × 4).
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, 0, 0, 0, 0],
TextureFormat::Rgba16Float,
bevy::asset::RenderAssetUsages::default(),
))
});
let composite_mat = composite_materials.add(crate::render::material::CompositeMaterial { let composite_mat = composite_materials.add(crate::render::material::CompositeMaterial {
uniform: crate::render::material::CompositeUniform { uniform: crate::render::material::CompositeUniform {
bloom_strength: 0.8, exposure: 1.0, _pad0: 0.0, _pad1: 0.0, bloom_strength: 0.8, exposure: 1.0, _pad0: 0.0, _pad1: 0.0,
}, },
scene: offscreen.clone(), scene: offscreen.clone(),
bloom: bloom_final.clone(), bloom: bloom_handle,
}); });
commands.spawn(( commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))), Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),