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:
145
crates/voltex_renderer/src/bloom.rs
Normal file
145
crates/voltex_renderer/src/bloom.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
43
crates/voltex_renderer/src/hdr.rs
Normal file
43
crates/voltex_renderer/src/hdr.rs
Normal 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())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
71
crates/voltex_renderer/src/tonemap.rs
Normal file
71
crates/voltex_renderer/src/tonemap.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user