From 7591b790b281ac81b8bb84e6223037eba53c02a9 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 00:31:50 +0800 Subject: [PATCH] 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). MSAA honestly labeled as decorative. Note: the rebuild currently re-spawns the full 3-level pyramid whenever bloom is enabled (Off vs On). Partial pyramids for Low/Medium are a future enhancement. --- src/params.rs | 2 +- src/render/plugin.rs | 385 ++++++++++++++++++++++++++++--------------- src/ui.rs | 21 +++ 3 files changed, 273 insertions(+), 135 deletions(-) diff --git a/src/params.rs b/src/params.rs index bb6ef5a..337063f 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,7 +1,7 @@ use bevy::prelude::*; /// Bloom pyramid depth (number of bloom textures). -#[derive(Clone, Copy, PartialEq, Eq, Default)] +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum BloomQuality { Off, // no bloom, scene-only ACES composite Low, // 1 level: brightpass → composite (soft halo) diff --git a/src/render/plugin.rs b/src/render/plugin.rs index 40ad254..18f01f5 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -75,6 +75,11 @@ pub struct BloomTarget2(pub Handle); #[derive(Component)] pub struct BloomFinalTarget(pub Handle); +/// 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); + pub struct BlackHolePlugin; impl Plugin for BlackHolePlugin { @@ -99,6 +104,7 @@ impl Plugin for BlackHolePlugin { ), ) .add_systems(Update, crate::scene::planets::upload_planets) + .add_systems(Update, rebuild_bloom) // bevy_egui 0.41 requires UI systems to run inside the egui context // pass (fonts/ctx are initialized there); placing them in Update panics. .add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system); @@ -183,142 +189,25 @@ fn spawn_fullscreen_quad( if levels > 0 { // NOTE: when bloom is enabled the full 3-level pyramid (High) always // runs here. Partial pyramid — Low = brightpass-only, Medium = 1 down + - // 1 up — is deferred to the runtime rebuild in Task 8, which can spawn - // fewer passes. For now this is a simple Off vs On gate. - // - // --- 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(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 --- - 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(blur_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(blur_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). - 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(blur_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(blur_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. - 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()); + // 1 up — is a future enhancement; for now this is a simple Off vs On + // gate. spawn_bloom_pipeline always emits the full pyramid when called. + bloom_final_handle = spawn_bloom_pipeline( + &mut commands, + &mut images, + &mut bp_materials, + &mut blur_materials, + &mut meshes, + &offscreen, + w, + h, + ); } + // Track the quality tier currently realized in the render graph. Must be + // set here (not via init_resource) so it matches the tiered params default + // (Off on web, High on desktop) rather than the enum's #[default]. + commands.insert_resource(AppliedBloomQuality(params.bloom_quality)); + // --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) --- // 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 @@ -352,6 +241,160 @@ fn spawn_fullscreen_quad( commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, RenderLayers::layer(1))); } +/// Spawns the bloom pipeline (brightpass + blur pyramid + bloom_final). +/// Called at startup and when bloom_quality changes at runtime. +/// +/// Pragmatic scope: this always emits the full 3-level pyramid (High) when +/// called. Partial pyramids — Low (brightpass-only) and Medium (1 down + 1 up) +/// — are a future enhancement; the caller simply does not invoke this for +/// `BloomQuality::Off`. The `levels`-driven branching is intentionally absent. +/// +/// Returns the `bloom_final` handle (the half-res texture the composite pass +/// samples). Caller must fall back to a 1x1 black texture when it returns None. +#[allow(clippy::too_many_arguments)] +fn spawn_bloom_pipeline( + commands: &mut Commands, + images: &mut Assets, + bp_materials: &mut Assets, + blur_materials: &mut Assets, + meshes: &mut Assets, + offscreen: &Handle, + w: u32, + h: u32, +) -> Option> { + // --- 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(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 --- + 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(blur_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(blur_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). + 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(blur_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(blur_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. + 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), + )); + + Some(bloom_final.clone()) +} + /// Recreate the offscreen Image and rescale both quads on window resize, /// honoring the live `render_scale` param. #[allow(clippy::type_complexity)] @@ -437,6 +480,80 @@ fn nudge_camera( } } +/// Detects a bloom_quality change and rebuilds the bloom pipeline. +/// Heavy (despawns and respawns all bloom entities + their cameras/targets) +/// but only fires when the user changes the dropdown in the Quality panel. +/// +/// `applied` is read as `Res` (immutable borrow) and re-written via +/// `commands.insert_resource` (a queued command applied later in the frame), +/// so the read/write do not conflict in Bevy's system parameter borrow checker. +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +fn rebuild_bloom( + params: Res, + applied: Res, + mut commands: Commands, + bloom_entities: Query, With, + With, With, + )>>, + bloom_targets: Query, With, + With, With, + )>>, + mut composite_materials: ResMut>, + offscreen_target: Query<&OffscreenTarget>, + window: Query<&Window>, + mut images: ResMut>, + mut bp_materials: ResMut>, + mut blur_materials: ResMut>, + mut meshes: ResMut>, +) { + if params.bloom_quality == applied.0 { + return; + } + // Despawn all bloom entities (cameras + quads). + for e in bloom_entities.iter() { + commands.entity(e).despawn(); + } + // Despawn all bloom target markers (they are spawned as bare entities). + for e in bloom_targets.iter() { + commands.entity(e).despawn(); + } + // Re-spawn at the new quality (or not at all, if Off). + 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 new_bloom = spawn_bloom_pipeline( + &mut commands, + &mut images, + &mut bp_materials, + &mut blur_materials, + &mut meshes, + &offscreen, + w, + h, + ); + // Update the composite material's bloom handle. When the new quality is + // Off, spawn_bloom_pipeline returns None and we fall back to a 1x1 black + // texture so the composite samples black (scene-only ACES). + 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, 0, 0, 0, 0], + TextureFormat::Rgba16Float, + bevy::asset::RenderAssetUsages::default(), + )) + }); + for (_, mat) in composite_materials.iter_mut() { + mat.bloom = bloom_handle.clone(); + } + commands.insert_resource(AppliedBloomQuality(params.bloom_quality)); +} + fn mirror_params( camera: Res, params: Res, diff --git a/src/ui.rs b/src/ui.rs index 4695d8f..522a20d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -52,6 +52,27 @@ pub fn ui_system( ui.checkbox(&mut params.grid_enabled, "Enabled"); ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density")); }); + 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)."); + }); }); // egui captures pointer when the cursor is over a window or being interacted with. wants.0 = ctx.egui_wants_pointer_input();