feat(renderer): add HDR target, Bloom resources, and ACES tonemap

- Add hdr.rs with HdrTarget (Rgba16Float render target) and HDR_FORMAT constant
- Add bloom.rs with BloomResources (5-level mip chain), BloomUniform, and mip_sizes()
- Add tonemap.rs with TonemapUniform and CPU-side aces_tonemap() for testing
- Export all new types from lib.rs
- 33 tests passing (26 existing + 3 bloom + 4 tonemap)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:47:19 +09:00
parent ba610f48dc
commit 6c938999e4
4 changed files with 265 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
use bytemuck::{Pod, Zeroable};
use crate::hdr::HDR_FORMAT;
/// Number of bloom mip levels (downsample + upsample chain).
pub const BLOOM_MIP_COUNT: usize = 5;
/// Uniform buffer for the bloom pass.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct BloomUniform {
/// Luminance threshold above which a pixel contributes to bloom.
pub threshold: f32,
/// Soft knee for the threshold.
pub soft_threshold: f32,
pub _padding: [f32; 2],
}
impl Default for BloomUniform {
fn default() -> Self {
Self {
threshold: 1.0,
soft_threshold: 0.5,
_padding: [0.0; 2],
}
}
}
/// GPU resources for the bloom pass (mip chain + uniform buffer).
pub struct BloomResources {
/// One `TextureView` per mip level (5 levels).
pub mip_views: Vec<wgpu::TextureView>,
pub uniform_buffer: wgpu::Buffer,
/// Blend intensity applied during the tonemap pass.
pub intensity: f32,
}
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_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Bloom Uniform Buffer"),
size: std::mem::size_of::<BloomUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Initialize with defaults via a write at construction time would require a queue;
// callers may write the buffer themselves. The buffer is left zero-initialised here.
Self {
mip_views,
uniform_buffer,
intensity: 0.5,
}
}
/// Recreate the mip-chain textures when the window is resized.
pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
self.mip_views = create_mip_views(device, width, height);
}
}
// ── Public helpers ────────────────────────────────────────────────────────────
/// Return the (width, height) of each bloom mip level.
///
/// Level 0 = width/2 × height/2; each subsequent level halves again.
/// Sizes are clamped to a minimum of 1.
pub fn mip_sizes(width: u32, height: u32) -> Vec<(u32, u32)> {
let mut sizes = Vec::with_capacity(BLOOM_MIP_COUNT);
let mut w = (width / 2).max(1);
let mut h = (height / 2).max(1);
for _ in 0..BLOOM_MIP_COUNT {
sizes.push((w, h));
w = (w / 2).max(1);
h = (h / 2).max(1);
}
sizes
}
// ── Internal helpers ──────────────────────────────────────────────────────────
fn create_mip_views(device: &wgpu::Device, width: u32, height: u32) -> Vec<wgpu::TextureView> {
let sizes = mip_sizes(width, height);
sizes
.into_iter()
.enumerate()
.map(|(i, (w, h))| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("Bloom Mip {} Texture", i)),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
texture.create_view(&wgpu::TextureViewDescriptor::default())
})
.collect()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mip_sizes_1920_1080() {
let sizes = mip_sizes(1920, 1080);
assert_eq!(sizes.len(), BLOOM_MIP_COUNT);
assert_eq!(sizes[0], (960, 540));
assert_eq!(sizes[1], (480, 270));
assert_eq!(sizes[2], (240, 135));
assert_eq!(sizes[3], (120, 67));
assert_eq!(sizes[4], (60, 33));
}
#[test]
fn mip_sizes_64_64() {
let sizes = mip_sizes(64, 64);
assert_eq!(sizes.len(), BLOOM_MIP_COUNT);
assert_eq!(sizes[0], (32, 32));
assert_eq!(sizes[1], (16, 16));
assert_eq!(sizes[2], (8, 8));
assert_eq!(sizes[3], (4, 4));
assert_eq!(sizes[4], (2, 2));
}
#[test]
fn bloom_uniform_default() {
let u = BloomUniform::default();
assert!((u.threshold - 1.0).abs() < f32::EPSILON);
assert!((u.soft_threshold - 0.5).abs() < f32::EPSILON);
assert_eq!(u._padding, [0.0f32; 2]);
}
}

View File

@@ -0,0 +1,43 @@
/// Texture format used for HDR render targets.
pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// An HDR render target (Rgba16Float) used as the output of the lighting pass.
pub struct HdrTarget {
pub view: wgpu::TextureView,
pub width: u32,
pub height: u32,
}
impl HdrTarget {
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let view = create_hdr_view(device, width, height);
Self { view, width, height }
}
/// Recreate the HDR texture when the window is resized.
pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
self.view = create_hdr_view(device, width, height);
self.width = width;
self.height = height;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn create_hdr_view(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("HDR Target Texture"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
texture.create_view(&wgpu::TextureViewDescriptor::default())
}

View File

@@ -19,6 +19,9 @@ pub mod deferred_pipeline;
pub mod ssgi;
pub mod rt_accel;
pub mod rt_shadow;
pub mod hdr;
pub mod bloom;
pub mod tonemap;
pub use gpu::{GpuContext, DEPTH_FORMAT};
pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT};
@@ -43,3 +46,6 @@ pub use deferred_pipeline::{
pub use ssgi::{SsgiResources, SsgiUniform, SSGI_OUTPUT_FORMAT};
pub use rt_accel::{RtAccel, RtInstance, BlasMeshData, mat4_to_tlas_transform};
pub use rt_shadow::{RtShadowResources, RtShadowUniform, RT_SHADOW_FORMAT};
pub use hdr::{HdrTarget, HDR_FORMAT};
pub use bloom::{BloomResources, BloomUniform, mip_sizes, BLOOM_MIP_COUNT};
pub use tonemap::{TonemapUniform, aces_tonemap};

View File

@@ -0,0 +1,71 @@
use bytemuck::{Pod, Zeroable};
/// Uniform buffer for the tonemap pass.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct TonemapUniform {
/// Bloom contribution weight.
pub bloom_intensity: f32,
/// Pre-tonemap exposure multiplier.
pub exposure: f32,
pub _padding: [f32; 2],
}
impl Default for TonemapUniform {
fn default() -> Self {
Self {
bloom_intensity: 0.5,
exposure: 1.0,
_padding: [0.0; 2],
}
}
}
/// CPU implementation of the ACES filmic tonemap curve (for testing / CPU-side work).
///
/// Formula: clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0, 1)
pub fn aces_tonemap(x: f32) -> f32 {
let num = x * (2.51 * x + 0.03);
let den = x * (2.43 * x + 0.59) + 0.14;
(num / den).clamp(0.0, 1.0)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aces_zero() {
// aces(0) should be ≈ 0
assert!(aces_tonemap(0.0).abs() < 1e-5, "aces(0) = {}", aces_tonemap(0.0));
}
#[test]
fn aces_one() {
// aces(1) ≈ 0.80 with the standard formula
// clamp((1*(2.51+0.03))/(1*(2.43+0.59)+0.14), 0, 1) = 2.54/3.16 ≈ 0.8038
let v = aces_tonemap(1.0);
assert!(
(v - 0.8038).abs() < 0.001,
"aces(1) = {}, expected ≈ 0.8038",
v
);
}
#[test]
fn aces_large() {
// aces(10) should be very close to 1.0 (saturated)
let v = aces_tonemap(10.0);
assert!(v > 0.999, "aces(10) = {}, expected ≈ 1.0", v);
}
#[test]
fn tonemap_uniform_default() {
let u = TonemapUniform::default();
assert!((u.bloom_intensity - 0.5).abs() < f32::EPSILON);
assert!((u.exposure - 1.0).abs() < f32::EPSILON);
assert_eq!(u._padding, [0.0f32; 2]);
}
}