feat(renderer): add bloom and tonemap shaders

- bloom_shader.wgsl: fullscreen vertex + fs_downsample (5-tap box + bright extract) + fs_upsample (9-tap tent)
- tonemap_shader.wgsl: fullscreen vertex + fs_main (HDR+bloom combine, exposure, ACES, gamma 1/2.2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:48:20 +09:00
parent 6c938999e4
commit 6d30151be1
3 changed files with 181 additions and 1 deletions

View File

@@ -38,7 +38,7 @@ impl BloomResources {
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let mip_views = create_mip_views(device, width, height); let mip_views = create_mip_views(device, width, height);
let uniform = BloomUniform::default(); let _uniform = BloomUniform::default();
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Bloom Uniform Buffer"), label: Some("Bloom Uniform Buffer"),
size: std::mem::size_of::<BloomUniform>() as u64, size: std::mem::size_of::<BloomUniform>() as u64,

View File

@@ -0,0 +1,112 @@
// Bloom pass shader.
// Two fragment entry points:
// fs_downsample — 5-tap box filter with optional bright extraction
// fs_upsample — 9-tap tent filter for the upsample chain
// ── Group 0 ───────────────────────────────────────────────────────────────────
@group(0) @binding(0) var t_input: texture_2d<f32>;
@group(0) @binding(1) var s_input: sampler;
struct BloomUniform {
threshold: f32,
soft_threshold: f32,
_padding: vec2<f32>,
};
@group(0) @binding(2) var<uniform> bloom: BloomUniform;
// ── Vertex stage ──────────────────────────────────────────────────────────────
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(v: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(v.position, 0.0, 1.0);
out.uv = vec2<f32>(v.position.x * 0.5 + 0.5, 1.0 - (v.position.y * 0.5 + 0.5));
return out;
}
// ── Shared helpers ────────────────────────────────────────────────────────────
fn luminance(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
}
/// Soft-knee bright-pass: returns the colour with sub-threshold values attenuated.
fn bright_extract(color: vec3<f32>, threshold: f32, soft: f32) -> vec3<f32> {
let lum = luminance(color);
let knee = threshold * soft;
// Quadratic soft knee
let ramp = clamp(lum - threshold + knee, 0.0, 2.0 * knee);
let weight = select(
select(0.0, 1.0, lum >= threshold),
ramp * ramp / (4.0 * knee + 0.00001),
lum >= threshold - knee && lum < threshold,
);
// When lum >= threshold we pass through, otherwise fade via weight
if lum >= threshold {
return color;
}
return color * (weight / max(lum, 0.00001));
}
// ── Downsample pass ───────────────────────────────────────────────────────────
/// 5-sample box filter: centre × 4 + 4 diagonal neighbours × 1, divided by 8.
/// When bloom.threshold > 0 a bright-pass is applied after filtering.
@fragment
fn fs_downsample(in: VertexOutput) -> @location(0) vec4<f32> {
let uv = in.uv;
let texel = 1.0 / vec2<f32>(textureDimensions(t_input));
let center = textureSample(t_input, s_input, uv).rgb;
let tl = textureSample(t_input, s_input, uv + vec2<f32>(-1.0, -1.0) * texel).rgb;
let tr = textureSample(t_input, s_input, uv + vec2<f32>( 1.0, -1.0) * texel).rgb;
let bl = textureSample(t_input, s_input, uv + vec2<f32>(-1.0, 1.0) * texel).rgb;
let br = textureSample(t_input, s_input, uv + vec2<f32>( 1.0, 1.0) * texel).rgb;
var color = (center * 4.0 + tl + tr + bl + br) / 8.0;
// Bright extraction on the first downsample level (threshold guard)
if bloom.threshold > 0.0 {
color = bright_extract(color, bloom.threshold, bloom.soft_threshold);
}
return vec4<f32>(color, 1.0);
}
// ── Upsample pass ─────────────────────────────────────────────────────────────
/// 9-tap tent filter (3×3):
/// corners × 1, axis-aligned edges × 2, centre × 4 → /16
@fragment
fn fs_upsample(in: VertexOutput) -> @location(0) vec4<f32> {
let uv = in.uv;
let texel = 1.0 / vec2<f32>(textureDimensions(t_input));
let tl = textureSample(t_input, s_input, uv + vec2<f32>(-1.0, -1.0) * texel).rgb;
let tc = textureSample(t_input, s_input, uv + vec2<f32>( 0.0, -1.0) * texel).rgb;
let tr = textureSample(t_input, s_input, uv + vec2<f32>( 1.0, -1.0) * texel).rgb;
let ml = textureSample(t_input, s_input, uv + vec2<f32>(-1.0, 0.0) * texel).rgb;
let mc = textureSample(t_input, s_input, uv).rgb;
let mr = textureSample(t_input, s_input, uv + vec2<f32>( 1.0, 0.0) * texel).rgb;
let bl = textureSample(t_input, s_input, uv + vec2<f32>(-1.0, 1.0) * texel).rgb;
let bc = textureSample(t_input, s_input, uv + vec2<f32>( 0.0, 1.0) * texel).rgb;
let br = textureSample(t_input, s_input, uv + vec2<f32>( 1.0, 1.0) * texel).rgb;
// Tent weights: corners×1 + edges×2 + centre×4 → sum=16
let color = (tl + tr + bl + br
+ (tc + ml + mr + bc) * 2.0
+ mc * 4.0) / 16.0;
return vec4<f32>(color, 1.0);
}

View File

@@ -0,0 +1,68 @@
// Tonemap pass shader.
// Combines HDR scene colour + bloom, applies exposure and ACES tonemapping,
// then converts to gamma-corrected sRGB for the swapchain.
// ── Group 0 ───────────────────────────────────────────────────────────────────
@group(0) @binding(0) var t_hdr: texture_2d<f32>;
@group(0) @binding(1) var t_bloom: texture_2d<f32>;
@group(0) @binding(2) var s_sampler: sampler;
struct TonemapUniform {
bloom_intensity: f32,
exposure: f32,
_padding: vec2<f32>,
};
@group(0) @binding(3) var<uniform> tonemap: TonemapUniform;
// ── Vertex stage ──────────────────────────────────────────────────────────────
struct VertexInput {
@location(0) position: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(v: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(v.position, 0.0, 1.0);
out.uv = vec2<f32>(v.position.x * 0.5 + 0.5, 1.0 - (v.position.y * 0.5 + 0.5));
return out;
}
// ── ACES tonemap ──────────────────────────────────────────────────────────────
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
let num = x * (2.51 * x + vec3<f32>(0.03));
let den = x * (2.43 * x + vec3<f32>(0.59)) + vec3<f32>(0.14);
return clamp(num / den, vec3<f32>(0.0), vec3<f32>(1.0));
}
// ── Fragment stage ────────────────────────────────────────────────────────────
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let uv = in.uv;
let hdr = textureSample(t_hdr, s_sampler, uv).rgb;
let bloom = textureSample(t_bloom, s_sampler, uv).rgb;
// Combine HDR + bloom
var color = hdr + bloom * tonemap.bloom_intensity;
// Apply exposure
color = color * tonemap.exposure;
// ACES tonemapping
color = aces_tonemap(color);
// Gamma correction (linear → sRGB, γ = 2.2)
color = pow(color, vec3<f32>(1.0 / 2.2));
return vec4<f32>(color, 1.0);
}