From 6d30151be1a2db81d6bb6551e4e09dae7d16a8e0 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 13:48:20 +0900 Subject: [PATCH] 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 --- crates/voltex_renderer/src/bloom.rs | 2 +- crates/voltex_renderer/src/bloom_shader.wgsl | 112 ++++++++++++++++++ .../voltex_renderer/src/tonemap_shader.wgsl | 68 +++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 crates/voltex_renderer/src/bloom_shader.wgsl create mode 100644 crates/voltex_renderer/src/tonemap_shader.wgsl diff --git a/crates/voltex_renderer/src/bloom.rs b/crates/voltex_renderer/src/bloom.rs index 3ef3643..740ecac 100644 --- a/crates/voltex_renderer/src/bloom.rs +++ b/crates/voltex_renderer/src/bloom.rs @@ -38,7 +38,7 @@ impl BloomResources { pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { 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 { label: Some("Bloom Uniform Buffer"), size: std::mem::size_of::() as u64, diff --git a/crates/voltex_renderer/src/bloom_shader.wgsl b/crates/voltex_renderer/src/bloom_shader.wgsl new file mode 100644 index 0000000..e7147b7 --- /dev/null +++ b/crates/voltex_renderer/src/bloom_shader.wgsl @@ -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; +@group(0) @binding(1) var s_input: sampler; + +struct BloomUniform { + threshold: f32, + soft_threshold: f32, + _padding: vec2, +}; + +@group(0) @binding(2) var bloom: BloomUniform; + +// ── Vertex stage ────────────────────────────────────────────────────────────── + +struct VertexInput { + @location(0) position: vec2, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(v: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(v.position, 0.0, 1.0); + out.uv = vec2(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 { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +/// Soft-knee bright-pass: returns the colour with sub-threshold values attenuated. +fn bright_extract(color: vec3, threshold: f32, soft: f32) -> vec3 { + 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 { + let uv = in.uv; + let texel = 1.0 / vec2(textureDimensions(t_input)); + + let center = textureSample(t_input, s_input, uv).rgb; + let tl = textureSample(t_input, s_input, uv + vec2(-1.0, -1.0) * texel).rgb; + let tr = textureSample(t_input, s_input, uv + vec2( 1.0, -1.0) * texel).rgb; + let bl = textureSample(t_input, s_input, uv + vec2(-1.0, 1.0) * texel).rgb; + let br = textureSample(t_input, s_input, uv + vec2( 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(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 { + let uv = in.uv; + let texel = 1.0 / vec2(textureDimensions(t_input)); + + let tl = textureSample(t_input, s_input, uv + vec2(-1.0, -1.0) * texel).rgb; + let tc = textureSample(t_input, s_input, uv + vec2( 0.0, -1.0) * texel).rgb; + let tr = textureSample(t_input, s_input, uv + vec2( 1.0, -1.0) * texel).rgb; + let ml = textureSample(t_input, s_input, uv + vec2(-1.0, 0.0) * texel).rgb; + let mc = textureSample(t_input, s_input, uv).rgb; + let mr = textureSample(t_input, s_input, uv + vec2( 1.0, 0.0) * texel).rgb; + let bl = textureSample(t_input, s_input, uv + vec2(-1.0, 1.0) * texel).rgb; + let bc = textureSample(t_input, s_input, uv + vec2( 0.0, 1.0) * texel).rgb; + let br = textureSample(t_input, s_input, uv + vec2( 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(color, 1.0); +} diff --git a/crates/voltex_renderer/src/tonemap_shader.wgsl b/crates/voltex_renderer/src/tonemap_shader.wgsl new file mode 100644 index 0000000..99837cf --- /dev/null +++ b/crates/voltex_renderer/src/tonemap_shader.wgsl @@ -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; +@group(0) @binding(1) var t_bloom: texture_2d; +@group(0) @binding(2) var s_sampler: sampler; + +struct TonemapUniform { + bloom_intensity: f32, + exposure: f32, + _padding: vec2, +}; + +@group(0) @binding(3) var tonemap: TonemapUniform; + +// ── Vertex stage ────────────────────────────────────────────────────────────── + +struct VertexInput { + @location(0) position: vec2, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(v: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(v.position, 0.0, 1.0); + out.uv = vec2(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) -> vec3 { + let num = x * (2.51 * x + vec3(0.03)); + let den = x * (2.43 * x + vec3(0.59)) + vec3(0.14); + return clamp(num / den, vec3(0.0), vec3(1.0)); +} + +// ── Fragment stage ──────────────────────────────────────────────────────────── + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + 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(1.0 / 2.2)); + + return vec4(color, 1.0); +}