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 ssgi;
|
||||||
pub mod rt_accel;
|
pub mod rt_accel;
|
||||||
pub mod rt_shadow;
|
pub mod rt_shadow;
|
||||||
|
pub mod hdr;
|
||||||
|
pub mod bloom;
|
||||||
|
pub mod tonemap;
|
||||||
|
|
||||||
pub use gpu::{GpuContext, DEPTH_FORMAT};
|
pub use gpu::{GpuContext, DEPTH_FORMAT};
|
||||||
pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT};
|
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 ssgi::{SsgiResources, SsgiUniform, SSGI_OUTPUT_FORMAT};
|
||||||
pub use rt_accel::{RtAccel, RtInstance, BlasMeshData, mat4_to_tlas_transform};
|
pub use rt_accel::{RtAccel, RtInstance, BlasMeshData, mat4_to_tlas_transform};
|
||||||
pub use rt_shadow::{RtShadowResources, RtShadowUniform, RT_SHADOW_FORMAT};
|
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