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.
This commit is contained in:
xfy 2026-07-15 00:07:09 +08:00
parent 6ccd0a173b
commit ed6639738f
3 changed files with 188 additions and 0 deletions

52
assets/shaders/blur.wgsl Normal file
View File

@ -0,0 +1,52 @@
#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);
}
}

View File

@ -155,3 +155,29 @@ impl Material2d for BrightPassMaterial {
}
}
/// 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()
}
}

View File

@ -65,6 +65,16 @@ pub struct BlurCamera;
#[derive(Component)]
pub struct BlurQuad;
/// 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>);
pub struct BlackHolePlugin;
impl Plugin for BlackHolePlugin {
@ -75,6 +85,7 @@ impl Plugin for BlackHolePlugin {
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::BlurMaterial>::default())
.add_plugins(bevy_egui::EguiPlugin::default())
.add_systems(Startup, spawn_fullscreen_quad)
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
@ -101,6 +112,7 @@ fn spawn_fullscreen_quad(
mut materials: ResMut<Assets<BlackHoleMaterial>>,
mut upscale_materials: ResMut<Assets<crate::render::material::UpscaleMaterial>>,
mut bp_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
mut blur_materials: ResMut<Assets<crate::render::material::BlurMaterial>>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
mut images: ResMut<Assets<Image>>,
window: Query<&Window>,
@ -197,6 +209,104 @@ fn spawn_fullscreen_quad(
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),
));
// --- Upscale quad (draws offscreen Image to the window) ---
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),