This commit is contained in:
Jérôme Bousquié
2026-09-24 11:21:35 +02:00
parent 004761252b
commit 805babe53d
18 changed files with 733 additions and 400 deletions
+8
View File
@@ -16,6 +16,10 @@
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's
//! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and
//! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen.
//! * **HDR + Tone Mapping** (Étape 20): the demo enables ACES Filmic tone mapping via
//! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen
//! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes
//! to the sRGB surface — highlights are softly rolled off instead of clipping to white.
//!
//! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with:
@@ -27,6 +31,7 @@ use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::core::ToneMapper;
use wsg_lib::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
use wsg_lib::resources::{CameraController, Texture};
use wsg_lib::utils::WsgError;
@@ -269,9 +274,12 @@ impl AppHandler for Demo {
#[pollster::main]
async fn main() -> Result<(), WsgError> {
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere.
// HDR + ACES tone mapping (Étape 20): renders to an offscreen Rgba16Float texture, then
// tone-maps to the sRGB surface. Without `.with_hdr(...)`, the demo would be LDR direct.
let app = AppBuilder::new()
.title("WSG Demo")
.with_culling(true)
.with_hdr(ToneMapper::Aces)
.build()
.await?;
app.run(Demo {
+2 -2
View File
@@ -11,7 +11,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
use wsg_lib::core::Context;
use wsg_lib::core::Frame;
use wsg_lib::core::Renderer;
use wsg_lib::core::{Renderer, ShadowConfig};
use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils;
@@ -63,7 +63,7 @@ impl ApplicationHandler for App {
// Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by
// draw_entity, the default frame matrix is the identity → NDC positions unchanged).
let mut renderer = Renderer::new(&context, format, 800, 600);
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None);
renderer.set_unlit(true);
// 3. Material: uses renderer.device() and renderer.format()
+52 -6
View File
@@ -23,7 +23,7 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler;
use crate::core::{Context, InputState, Renderer};
use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
use crate::scene::Scene;
use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
@@ -58,6 +58,11 @@ pub struct App {
pub(crate) height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
pub(crate) culling: bool,
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
pub(crate) shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead);
/// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface.
pub(crate) hdr: Option<ToneMapper>,
/// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
@@ -121,6 +126,8 @@ impl App {
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
handler,
app: None,
};
@@ -156,9 +163,11 @@ impl App {
let new_format = context.configure(&context.adapter, width, height)?;
self.renderer_mut().resize_depth(width, height);
self.renderer_mut().set_format(new_format);
if new_format != old_format {
// Surface format changed: re-wire the Scene's GPU context (device + queue + format)
// so its PipelineCache/pipelines match the new surface format.
if new_format != old_format && self.hdr.is_none() {
// Surface format changed (rare): re-wire the Scene's GPU context so its
// PipelineCache/pipelines match the new surface format.
// Étape 20: when HDR is active, the Scene uses Rgba16Float regardless of the
// surface format, so no re-init is needed on surface format change.
let device = std::sync::Arc::new(self.renderer_mut().device().clone());
self.scene
.init_gpu(device, self.context().queue.clone(), new_format);
@@ -178,6 +187,11 @@ pub struct AppBuilder {
height: u32,
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
culling: bool,
/// Shadow mapping configuration (map size, biases, frustum). Defaults to sensible values.
shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
/// the offscreen HDR texture + tone mapping pass.
hdr: Option<ToneMapper>,
}
impl AppBuilder {
@@ -189,6 +203,8 @@ impl AppBuilder {
width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT,
culling: false,
shadow_config: ShadowConfig::default(),
hdr: None,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -212,6 +228,20 @@ impl AppBuilder {
self.culling = enabled;
self
}
/// Sets the shadow mapping configuration (map size, depth/slope bias, ortho frustum).
/// Defaults to `ShadowConfig::default()` (1024² map, bias 0.002, slope 0.004, radius 5.0).
pub fn with_shadow_config(mut self, config: ShadowConfig) -> Self {
self.shadow_config = config;
self
}
/// Enables HDR rendering with the given tone mapping curve (Étape 20). The main pass
/// renders into an offscreen `Rgba16Float` texture, then a fullscreen tone mapping pass
/// compresses the result to [0,1] and writes it to the sRGB surface. Without this call,
/// the renderer draws directly to the surface (LDR, zero overhead).
pub fn with_hdr(mut self, tonemapper: ToneMapper) -> Self {
self.hdr = Some(tonemapper);
self
}
/// Builds the configured `App` instance: creates the event loop and stores the window
/// configuration. The GPU context, window and renderer are created later, when the event loop
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
@@ -226,6 +256,8 @@ impl AppBuilder {
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config,
hdr: self.hdr,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -246,6 +278,10 @@ struct AppRunner<H: AppHandler> {
height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
culling: bool,
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
hdr: Option<ToneMapper>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -278,14 +314,22 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.configure(&context.adapter, self.width, self.height)
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer = Renderer::new(&context, format, self.width, self.height);
let renderer =
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr);
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
// Étape 20: when HDR is active, the main pass targets Rgba16Float (not the surface format),
// so the Scene's pipelines must be compiled for that format.
let main_format = if self.hdr.is_some() {
wgpu::TextureFormat::Rgba16Float
} else {
format
};
let mut scene = Scene::new();
scene.init_gpu(device, context.queue.clone(), format);
scene.init_gpu(device, context.queue.clone(), main_format);
let mut app = App {
scene,
@@ -294,6 +338,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
width: self.width,
height: self.height,
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
event_loop: None,
context: Some(context),
renderer: Some(renderer),
+45
View File
@@ -0,0 +1,45 @@
//! # HDR / Tone Mapping Configuration (Étape 20)
//!
//! Defines the `ToneMapper` enum (selects the tone mapping curve) and provides the
//! configuration passed to the `Renderer` when HDR is enabled. The HDR pipeline
//! (offscreen `Rgba16Float` texture + fullscreen tone mapping pass) is **opt-in**:
//! without it, the renderer draws directly to the sRGB surface (zero overhead).
/// Selects the tone mapping curve applied by the HDR pass.
///
/// The choice is compiled into the pipeline at construction time (one entry point per
/// variant) — there is no runtime branching cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToneMapper {
/// ACES Filmic (Narkowicz 2015 approximation). Cinematic contrast, used in AAA
/// games and film pipelines. Softly compresses highlights while preserving
/// midtone contrast.
Aces,
/// Reinhard: `x / (1 + x)`. Simple, flat response. Less contrast than ACES but
/// computationally trivial.
Reinhard,
}
impl ToneMapper {
/// Returns the WGSL entry point name for this tone mapper variant.
pub(crate) fn entry_point(&self) -> &'static str {
match self {
ToneMapper::Aces => "fs_aces",
ToneMapper::Reinhard => "fs_reinhard",
}
}
/// Human-readable label (for debug output).
pub fn label(&self) -> &'static str {
match self {
ToneMapper::Aces => "ACES",
ToneMapper::Reinhard => "Reinhard",
}
}
}
impl std::fmt::Display for ToneMapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
+4
View File
@@ -11,11 +11,15 @@
pub mod context;
pub mod frame;
pub mod hdr;
pub mod input;
pub mod renderer;
pub mod shadow;
// Re-exports
pub use context::Context;
pub use frame::Frame;
pub use hdr::ToneMapper;
pub use input::InputState;
pub use renderer::Renderer;
pub use shadow::ShadowConfig;
+261 -9
View File
@@ -36,9 +36,9 @@ use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::core::hdr::ToneMapper;
use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS,
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER,
};
use glam::{Mat4, Quat, Vec3, Vec4};
use std::cell::{Cell, RefCell};
@@ -145,6 +145,30 @@ pub struct Renderer {
/// Viewport height in pixels (Step 19, D9): the unit of the LOD projected-size test. Set from
/// the initial surface size in `new` and refreshed by `resize_depth` on window resize.
viewport_height: u32,
/// Shadow mapping configuration (map size, biases, frustum). Set at construction time;
/// `map_size` determines the shadow texture allocation, the rest are used per-frame.
shadow_config: super::shadow::ShadowConfig,
/// HDR pipeline (Étape 20). Present only when HDR is enabled via `AppBuilder::with_hdr`.
/// When `None`, the main pass renders directly to the surface (LDR, zero overhead).
hdr: Option<HdrPipeline>,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
/// Allocated in `Renderer::new` when HDR is active; recreated on resize.
struct HdrPipeline {
/// Offscreen HDR color texture (`Rgba16Float`), sized to the surface.
texture: wgpu::Texture,
/// View of the HDR texture, used as the main pass color attachment.
view: wgpu::TextureView,
/// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve).
pipeline: wgpu::RenderPipeline,
/// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport).
/// The uniform buffer is owned by the bind group (freed when the bind group is replaced).
bind_group: wgpu::BindGroup,
/// Bind group layout for the TM pass (reused on resize to recreate the bind group).
layout: wgpu::BindGroupLayout,
/// Sampler for the HDR texture (linear, clamp).
sampler: wgpu::Sampler,
}
impl Renderer {
@@ -156,7 +180,14 @@ impl Renderer {
/// Returns a new Renderer instance sharing the same underlying GPU resources as Context.
/// Called once at application startup during scene setup. The Renderer shares these resources via Arc;
/// Context retains ownership and can continue using them after this call.
pub fn new(context: &Context, format: wgpu::TextureFormat, width: u32, height: u32) -> Self {
pub fn new(
context: &Context,
format: wgpu::TextureFormat,
width: u32,
height: u32,
shadow_config: &super::shadow::ShadowConfig,
hdr: Option<ToneMapper>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device);
@@ -206,7 +237,7 @@ impl Renderer {
// Step 14 (DRAFT 3.2): shadow mapping resources — shadow map texture/view, comparison
// sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the
// depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8).
let (shadow_texture, shadow_view) = create_shadow_map(&device, SHADOW_MAP_SIZE);
let (shadow_texture, shadow_view) = create_shadow_map(&device, shadow_config.map_size);
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("shadow comparison sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -508,7 +539,7 @@ impl Renderer {
}],
});
let renderer = Self {
let mut renderer = Self {
queue,
device,
format,
@@ -542,10 +573,14 @@ impl Renderer {
lod_enabled: Cell::new(true),
last_lod_levels: RefCell::new(Vec::new()),
viewport_height: height,
shadow_config: shadow_config.clone(),
hdr: None,
};
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
renderer.write_default_frame_uniforms();
// Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled.
renderer.hdr = hdr.map(|tm| create_hdr_pipeline(&renderer.device, &renderer.queue, width, height, tm, format));
renderer
}
@@ -585,6 +620,14 @@ impl Renderer {
self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
self.viewport_height = height;
// Étape 20: recreate the HDR texture + bind group at the new size (D10).
if let Some(hdr) = &mut self.hdr {
let (tex, view) = create_hdr_texture(&self.device, width, height);
let bg = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height);
hdr.texture = tex;
hdr.view = view;
hdr.bind_group = bg;
}
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -619,7 +662,12 @@ impl Renderer {
Some((index, vp)) => (
index as u32,
vp,
Vec4::new(SHADOW_MAP_SIZE as f32, SHADOW_DEPTH_BIAS, 0.0, 0.0),
Vec4::new(
self.shadow_config.map_size as f32,
self.shadow_config.depth_bias,
self.shadow_config.slope_bias,
0.0,
),
1,
),
None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0),
@@ -679,8 +727,8 @@ impl Renderer {
}
crate::resources::LightType::Point => return None,
};
let r = SHADOW_SCENE_RADIUS;
let target = Vec3::from(SHADOW_SCENE_CENTER);
let r = self.shadow_config.scene_radius;
let target = Vec3::from(self.shadow_config.scene_center);
// Eye one scene-radius behind the target along the light path, so distance(target)=r and
// every point in the box has depth within [near=0, far=r].
let eye = target - dir * r;
@@ -867,11 +915,17 @@ impl Renderer {
// The matrix + draw-args are read via per-slot offsets; a culled/inactive slot's args
// are zero, so its draw is a no-op. State changes (pipeline + texture bind group @2)
// are hoisted out of the slot loop: one per DISTINCT material, not one per entity.
// Étape 20: when HDR is active, the color attachment targets the offscreen HDR texture
// instead of the surface; the TM pass (step 8) then copies it to the surface.
let main_target = match &self.hdr {
Some(h) => &h.view,
None => view,
};
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
view: main_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
@@ -952,6 +1006,30 @@ impl Renderer {
}
}
}
// 8. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
// texture, applies exposure + tone mapping curve, and writes to the surface.
// Only runs when HDR is active; the surface is the color target (no depth needed).
if let Some(hdr) = &self.hdr {
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("tone mapping pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
tm_pass.set_pipeline(&hdr.pipeline);
tm_pass.set_bind_group(0, &hdr.bind_group, &[]);
tm_pass.draw(0..3, 0..1);
}
self.queue.submit(std::iter::once(encoder.finish()));
}
@@ -1429,6 +1507,180 @@ fn batch_slots<K: Eq + Hash + Clone>(keys: &[K]) -> Vec<Vec<usize>> {
groups.into_iter().map(|(_, idxs)| idxs).collect()
}
/// Allocates the offscreen HDR color texture (`Rgba16Float`) + view at the given size (Étape 20, D3).
/// Used both at initial allocation and on resize.
fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("hdr texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
(texture, view)
}
/// Creates the tone mapping bind group: HDR texture (binding 0) + sampler (binding 1) + uniform (binding 2).
/// The uniform contains exposure (1.0) and viewport size (pad.xy).
fn create_hdr_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
texture: &wgpu::Texture,
width: u32,
height: u32,
) -> wgpu::BindGroup {
// Write the uniform: exposure = 1.0, pad.xy = viewport size.
// WGSL uniform layout: f32 at offset 0 (4B), vec3<f32> at offset 16 (16B, aligned to 16).
// Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0].
let uniform_data = [
1.0f32, // exposure (offset 0)
0.0, 0.0, 0.0, // padding to align vec3 to offset 16
width as f32, height as f32, 0.0, // pad: vec3<f32> at offset 16
0.0, // trailing pad to 32 bytes
];
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("tm uniform"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM,
mapped_at_creation: true,
});
{
let mut w = uniform_buffer.slice(..).get_mapped_range_mut().expect("mapped buffer");
w.copy_from_slice(bytemuck::cast_slice(&uniform_data));
drop(w);
uniform_buffer.unmap();
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("tm bind group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&texture.create_view(&wgpu::TextureViewDescriptor::default())),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform_buffer,
offset: 0,
size: None,
}),
},
],
})
}
/// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group.
/// The pipeline uses the `TONEMAP_SHADER` with the entry point selected by the `ToneMapper` variant.
fn create_hdr_pipeline(
device: &wgpu::Device,
_queue: &wgpu::Queue,
width: u32,
height: u32,
tonemapper: ToneMapper,
format: wgpu::TextureFormat,
) -> HdrPipeline {
// 1. Offscreen HDR texture + view.
let (texture, view) = create_hdr_texture(device, width, height);
// 2. Sampler (linear, clamp).
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("hdr sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
// 3. Bind group layout: texture (0) + sampler (1) + uniform (2).
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hdr bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
],
});
// 4. Render pipeline: fullscreen triangle (no vertex buffer) + selected TM entry point.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("tonemap shader"),
source: wgpu::ShaderSource::Wgsl(TONEMAP_SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hdr pipeline layout"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("tone mapping pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(tonemapper.entry_point()),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState::from(format))],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// 5. Bind group with the initial texture + viewport size.
let bind_group = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height);
HdrPipeline {
texture,
view,
pipeline,
bind_group,
layout,
sampler,
}
}
#[cfg(test)]
mod tests {
use super::*;
+65
View File
@@ -0,0 +1,65 @@
//! Shadow mapping configuration.
//!
//! Users of the WSG library can tune shadow quality/behavior without modifying the library
//! source. All fields have sensible defaults (see [`ShadowConfig::default`]); pass a custom
//! config via [`AppBuilder::with_shadow_config`](crate::app::AppBuilder::with_shadow_config).
use crate::utils::conf::{
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
SHADOW_SLOPE_BIAS,
};
/// Configuration for the shadow mapping system.
///
/// Controls the shadow map resolution, depth bias (anti-acne), and the orthographic frustum
/// that frames the scene from the shadow-casting light's point of view.
///
/// # Usage
/// ```ignore
/// use wsg_lib::core::ShadowConfig;
///
/// let app = AppBuilder::new()
/// .with_shadow_config(ShadowConfig {
/// map_size: 2048, // higher resolution → sharper shadows
/// depth_bias: 0.002, // constant bias (NDC depth units)
/// slope_bias: 0.006, // slope-scaled bias coefficient
/// scene_center: [0.0, 0.0, 0.0], // where to center the ortho frustum
/// scene_radius: 8.0, // half-extent of the ortho frustum (world units)
/// ..Default::default()
/// })
/// .build()
/// .await?;
/// ```
#[derive(Debug, Clone)]
pub struct ShadowConfig {
/// Shadow map resolution in pixels per side (square map). Higher = sharper shadows,
/// more VRAM. Defaults to 1024.
pub map_size: u32,
/// Constant depth bias subtracted from the reference depth before the shadow comparison.
/// This is the *minimum* bias; the slope-scaled term adds more for grazing angles.
/// Defaults to 0.002.
pub depth_bias: f32,
/// Slope-scaled bias coefficient. The effective bias is
/// `max(depth_bias, slope_bias * (1.0 - |dot(N, L)|))` — it grows as the surface normal
/// becomes perpendicular to the light direction, where shadow acne is worst.
/// Defaults to 0.004.
pub slope_bias: f32,
/// World-space center of the orthographic shadow frustum. The frustum is oriented along
/// the shadow light's direction and centered on this point. Defaults to `[0.0, 0.0, 0.0]`.
pub scene_center: [f32; 3],
/// Half-extent (world units) of the orthographic shadow frustum. Must be large enough to
/// encompass all shadow-casting and receiving geometry. Defaults to 5.0.
pub scene_radius: f32,
}
impl Default for ShadowConfig {
fn default() -> Self {
Self {
map_size: SHADOW_MAP_SIZE,
depth_bias: SHADOW_DEPTH_BIAS,
slope_bias: SHADOW_SLOPE_BIAS,
scene_center: SHADOW_SCENE_CENTER,
scene_radius: SHADOW_SCENE_RADIUS,
}
}
}
+8
View File
@@ -44,3 +44,11 @@ pub use crate::app::App;
/// Re-export of the user-defined game logic interface for convenient top-level access.
/// Users implement this trait to define update/render callbacks injected into the render loop.
pub use crate::handler::AppHandler;
/// Re-export of the shadow mapping configuration for convenient top-level access.
/// Users tune shadow quality via `AppBuilder::with_shadow_config`.
pub use crate::core::ShadowConfig;
/// Re-export of the tone mapping curve selector for convenient top-level access.
/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`.
pub use crate::core::ToneMapper;
+26 -6
View File
@@ -91,7 +91,7 @@ struct FrameUniforms {
num_spot: u32,
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = shadow map size, .y = depth bias
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
};
@@ -202,16 +202,20 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
}
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos);
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
return vec4<f32>(lit, in.color.a);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages
// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (LessEqual). Returns
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow. The reference depth is
// pulled toward the viewer by `frame.shadow_params.y` (bias) to suppress acne.
fn compute_shadow(world_pos: vec3<f32>) -> f32 {
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow.
//
// Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by
// `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the
// surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents
// the large black patches that a constant bias alone cannot suppress on large flat surfaces.
fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit.
if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
return 1.0;
@@ -224,9 +228,25 @@ fn compute_shadow(world_pos: vec3<f32>) -> f32 {
// The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam
// directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed.
let current_depth = shadow_ndc.z;
let bias = frame.shadow_params.y;
let texel = 1.0 / max(frame.shadow_params.x, 1.0);
// Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light).
// Direction from surface toward the shadow-casting light:
// directional → position_dir.xyz (already the surface→light direction)
// spot → normalize(light_position - world_pos)
let sl_idx = frame.shadow_light_index;
let sl = frame.lights[sl_idx];
let is_dir = (sl_idx < frame.num_directional);
var light_dir: vec3<f32>;
if (is_dir) {
light_dir = normalize(sl.position_dir.xyz);
} else {
light_dir = normalize(sl.position_dir.xyz - world_pos);
}
// The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular.
let slope = 1.0 - abs(dot(normalize(normal), light_dir));
let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope);
// 3×3 PCF : average of the comparison results around the fragment's texel.
var lit_count = 0.0;
for (var ox = -1i; ox <= 1; ox++) {
+105
View File
@@ -0,0 +1,105 @@
// Tone mapping fullscreen pass shader (Étape 20).
//
// Renders a fullscreen triangle (no vertex buffer — position derived from vertex_index)
// that samples the HDR texture, applies exposure + tone mapping curve, and writes the
// result to the sRGB surface. The hardware handles the linear→sRGB gamma conversion
// automatically (the surface is Rgba8UnormSrgb).
//
// Two fragment entry points: `fs_aces` (ACES Filmic, Narkowicz 2015) and `fs_reinhard`
// (simple Reinhard). The pipeline is compiled with the appropriate entry point at
// construction time.
struct TmUniforms {
exposure: f32,
pad: vec3<f32>,
}
@group(0) @binding(0) var u_hdr_texture: texture_2d<f32>;
@group(0) @binding(1) var u_hdr_sampler: sampler;
@group(0) @binding(2) var<uniform> u_params: TmUniforms;
// Fullscreen triangle vertex shader: generates three vertices covering the entire
// NDC viewport. The triangle is (-1,-1), (3,-1), (-1,3) — the fourth NDC corner (1,1)
// is outside the triangle and gets clipped away; the visible portion exactly covers [-1,1]².
// The fragment shader derives UVs from the built-in position (window coords).
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
// --- ACES Filmic tone curve (Narkowicz 2015) ---
fn aces(x: f32) -> f32 {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
// --- Reinhard tone curve ---
fn reinhard(x: f32) -> f32 {
return clamp(x / (1.0 + x), 0.0, 1.0);
}
// Convert window-space position to texture UVs [0,1]².
// @builtin(position) in a fragment shader is in window coordinates (pixels, top-left origin).
// We need the draw size to normalize; pass it via a uniform or use the known viewport.
// Here we use a simpler trick: the NDC position is available via the vertex interpolation,
// but since we only output @builtin(position), we derive UVs in the fragment from
// @builtin(position) / viewport. The viewport is the full window, so we normalize by
// the known draw size.
//
// Actually, the simplest correct approach: since the triangle covers the full viewport,
// we can use `@builtin(position)` (in pixels) and normalize by the viewport size.
// But we don't have the viewport size as a binding here...
//
// Alternative: use a second vertex output for UVs. Since tuple returns aren't supported
// in this naga version, we use a different trick — the UVs are linearly interpolated
// from the vertex positions. We compute them as (ndc + 1) / 2 in the vertex shader
// and pass them through an @location. But we can only have one return value...
//
// Simplest fix: just use @builtin(position) in the fragment and divide by the
// viewport size (stored in the uniform).
// We add viewport size to the uniform (reusing the _pad field).
// _pad.xy = viewport size in pixels (width, height).
// _pad.z = unused, _pad.w = unused.
@fragment
fn fs_aces(
@builtin(position) frag_pos: vec4<f32>,
) -> @location(0) vec4<f32> {
let uv = frag_pos.xy / u_params.pad.xy;
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
return vec4<f32>(
aces(color.r),
aces(color.g),
aces(color.b),
1.0,
);
}
@fragment
fn fs_reinhard(
@builtin(position) frag_pos: vec4<f32>,
) -> @location(0) vec4<f32> {
let uv = frag_pos.xy / u_params.pad.xy;
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
return vec4<f32>(
reinhard(color.r),
reinhard(color.g),
reinhard(color.b),
1.0,
);
}
+15 -4
View File
@@ -41,6 +41,11 @@ pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
/// the library; no external file is read).
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
/// The tone mapping fullscreen pass shader source (Étape 20), embedded at compile time.
/// Carries one vertex entry point (`vs_main`, fullscreen triangle) and two fragment entry
/// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled.
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl");
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
/// rewrites only the transform slots and the cull uniforms.
@@ -76,10 +81,16 @@ pub const GPU_WORKGROUP_SIZE: u32 = 64;
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
pub const SHADOW_MAP_SIZE: u32 = 1024;
/// Default shadow depth bias (Step 14, D5) subtracted from the reference depth before the
/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled
/// bias applied on the shadow pipeline itself.
pub const SHADOW_DEPTH_BIAS: f32 = 0.006;
/// Default shadow constant bias (Step 14, D5) subtracted from the reference depth before the
/// comparison, to suppress acne without killing contact shadows. This is the minimum bias;
/// the slope-scaled term (SHADOW_SLOPE_BIAS) adds more for surfaces at grazing angles.
pub const SHADOW_DEPTH_BIAS: f32 = 0.002;
/// Slope-scaled bias coefficient (Étape 14 fix, 2026-09-24). The effective bias is
/// `max(SHADOW_DEPTH_BIAS, SHADOW_SLOPE_BIAS * (1.0 - |dot(N, L)|))` — it grows as the surface
/// normal becomes perpendicular to the light direction, where shadow acne is worst. A value of
/// 0.004 works well for a 1024² map with a 10-unit ortho frustum; tune per scene scale.
pub const SHADOW_SLOPE_BIAS: f32 = 0.006;
/// Default half-extent (world units) of the orthographic shadow frustum around the scene center
/// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples.
+31
View File
@@ -85,3 +85,34 @@ fn gpu_driven_shader_is_valid_wgsl() {
"the two compute entry points are expected"
);
}
/// Parses and fully validates the embedded `tonemap.wgsl` shader (Étape 20) via naga.
/// The renderer compiles it into one `RenderPipeline` (vertex `vs_main` + one of the two
/// fragment entry points `fs_aces` / `fs_reinhard`), so this offline validation is the
/// guarantee of its validity. The contract expects three entry points.
#[test]
fn tonemap_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/tonemap.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("tonemap.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("tonemap.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(
entry_names,
vec!["fs_aces", "fs_reinhard", "vs_main"],
"the three entry points are expected"
);
}