diff --git a/assets/shaders/composite.wgsl b/assets/shaders/composite.wgsl new file mode 100644 index 0000000..edc43d4 --- /dev/null +++ b/assets/shaders/composite.wgsl @@ -0,0 +1,33 @@ +#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 u: CompositeUniform; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var scene_tex: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var scene_samp: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var bloom_tex: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var bloom_samp: sampler; + +// ACES Narkowicz fit (5 ops, clamped to [0,1]). +fn aces_tonemap(x: vec3) -> vec3 { + 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(0.0), vec3(1.0)); +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + 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(mapped, 1.0); +} diff --git a/assets/shaders/upscale.wgsl b/assets/shaders/upscale.wgsl deleted file mode 100644 index 08cf97b..0000000 --- a/assets/shaders/upscale.wgsl +++ /dev/null @@ -1,10 +0,0 @@ -#import bevy_sprite::mesh2d_vertex_output::VertexOutput - -@group(#{MATERIAL_BIND_GROUP}) @binding(0) var tex: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(1) var samp: sampler; - -@fragment -fn fragment(in: VertexOutput) -> @location(0) vec4 { - // in.uv is [0,1]; sample the offscreen image directly (linear sampler upscales). - return textureSample(tex, samp, in.uv); -} diff --git a/src/render/material.rs b/src/render/material.rs index c29414b..573f5f5 100644 --- a/src/render/material.rs +++ b/src/render/material.rs @@ -123,21 +123,6 @@ impl Material2d for BlackHoleMaterial { } } -/// Samples the sub-resolution offscreen render and blits it fullscreen. -/// Bound to a second Camera2d that draws after the offscreen camera. -#[derive(Asset, TypePath, AsBindGroup, Clone)] -pub struct UpscaleMaterial { - #[texture(0)] - #[sampler(1)] - pub source: Handle, -} - -impl Material2d for UpscaleMaterial { - fn fragment_shader() -> ShaderRef { - "shaders/upscale.wgsl".into() - } -} - /// 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)] @@ -181,3 +166,31 @@ impl Material2d for BlurMaterial { } } +/// 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, + #[texture(3)] + #[sampler(4)] + pub bloom: Handle, +} + +impl Material2d for CompositeMaterial { + fn fragment_shader() -> ShaderRef { + "shaders/composite.wgsl".into() + } +} diff --git a/src/render/plugin.rs b/src/render/plugin.rs index 8afebcd..5316817 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -83,7 +83,7 @@ impl Plugin for BlackHolePlugin { .init_resource::() .init_resource::() .add_plugins(Material2dPlugin::::default()) - .add_plugins(Material2dPlugin::::default()) + .add_plugins(Material2dPlugin::::default()) .add_plugins(Material2dPlugin::::default()) .add_plugins(Material2dPlugin::::default()) .add_plugins(bevy_egui::EguiPlugin::default()) @@ -110,7 +110,7 @@ fn spawn_fullscreen_quad( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, - mut upscale_materials: ResMut>, + mut composite_materials: ResMut>, mut bp_materials: ResMut>, mut blur_materials: ResMut>, mut buffers: ResMut>, @@ -307,12 +307,18 @@ fn spawn_fullscreen_quad( RenderTarget::Image(bloom_final.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(6), )); - // --- Upscale quad (draws offscreen Image to the window) --- + // --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) --- + // bloom_final comes from the blur pyramid (Task 5). + 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(upscale_materials.add(crate::render::material::UpscaleMaterial { - source: offscreen.clone(), - })), + MeshMaterial2d(composite_mat), Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)), UpscaleQuad, CompositeQuad, @@ -328,6 +334,10 @@ fn resize_offscreen( mut images: ResMut>, params: Res, target: Query<&OffscreenTarget>, + bloom0: Query<&BloomTarget0>, + bloom1: Query<&BloomTarget1>, + bloom2: Query<&BloomTarget2>, + bloom_final: Query<&BloomFinalTarget>, window: Query<&Window>, mut resized: MessageReader, // Both queries borrow `&mut Transform`. Bevy's conflict checker does not @@ -354,6 +364,25 @@ fn resize_offscreen( // insert is harmless — it'll succeed on the next WindowResized. let _ = images.insert(handle.0.id(), img); } + // Bloom pyramid (queries return empty when bloom is 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)); + } // 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); @@ -389,6 +418,8 @@ fn mirror_params( time: Res