refactor examples

This commit is contained in:
Jérôme Bousquié
2026-09-25 10:19:24 +02:00
parent ab3f056dbb
commit 35aeb769a8
37 changed files with 3430 additions and 457 deletions
+75 -3
View File
@@ -23,7 +23,8 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler;
use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
use crate::core::{BloomConfig, Context, Renderer, ShadowConfig, ToneMapper};
use crate::input::InputState;
use crate::scene::Scene;
use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
@@ -63,6 +64,12 @@ pub struct App {
/// 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>,
/// Bloom post-process (Étape 23). `None` = no bloom (default, zero overhead).
/// Only active when HDR is also enabled.
pub(crate) bloom_config: Option<BloomConfig>,
/// Exposure multiplier (Étape 22, 6.1). Applied in the tone mapping pass before the curve.
/// Default 1.0. Adjustable at runtime via `set_exposure` or keyboard (+/-).
pub exposure: f32,
/// 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.
@@ -128,6 +135,8 @@ impl App {
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
handler,
app: None,
};
@@ -147,7 +156,40 @@ impl App {
pub fn render_scene(&self, view: &wgpu::TextureView) {
let size = self.window().inner_size();
let aspect = size.width as f32 / size.height.max(1) as f32;
self.renderer().render_scene(view, &self.scene, aspect);
self.renderer().render_scene(view, &self.scene, aspect, self.exposure);
}
/// Sets the exposure multiplier (Étape 22, 6.1). Clamped to [0.01, 10.0].
/// Takes effect on the next frame's tone mapping pass.
pub fn set_exposure(&mut self, value: f32) {
self.exposure = value.clamp(0.01, 10.0);
}
/// Returns the current exposure multiplier.
pub fn exposure(&self) -> f32 {
self.exposure
}
/// Returns `true` if bloom is active (Étape 23). Requires HDR to be enabled.
pub fn bloom_enabled(&self) -> bool {
self.bloom_config.is_some() && self.hdr.is_some()
}
/// Returns the current bloom configuration (Étape 23). `None` if bloom is not enabled.
pub fn bloom_config(&self) -> Option<&BloomConfig> {
self.bloom_config.as_ref()
}
/// Updates the bloom configuration at runtime (Étape 23).
/// Takes effect on the next frame (uniforms are re-written each frame).
/// No-op if bloom is not enabled.
pub fn set_bloom_config(&mut self, config: BloomConfig) {
if self.bloom_config.is_some() {
self.bloom_config = Some(config.clone());
if let Some(renderer) = &mut self.renderer {
renderer.set_bloom_config(&config);
}
}
}
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
@@ -192,6 +234,11 @@ pub struct AppBuilder {
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
/// the offscreen HDR texture + tone mapping pass.
hdr: Option<ToneMapper>,
/// Bloom post-process (Étape 23). `None` = no bloom (default); `Some(c)` activates
/// the 4-pass bloom when HDR is also enabled.
bloom_config: Option<BloomConfig>,
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
exposure: f32,
}
impl AppBuilder {
@@ -205,6 +252,8 @@ impl AppBuilder {
culling: false,
shadow_config: ShadowConfig::default(),
hdr: None,
bloom_config: None,
exposure: 1.0,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -242,6 +291,21 @@ impl AppBuilder {
self.hdr = Some(tonemapper);
self
}
/// Enables the bloom post-process (Étape 23). Bright areas (above `config.threshold` in
/// linear HDR units) are blurred and added back to the image, creating a glow effect.
/// **Requires HDR** (`with_hdr`): without it, the bloom is silently ignored with a warning.
pub fn with_bloom(mut self, config: BloomConfig) -> Self {
if self.hdr.is_none() {
eprintln!("[wsg] Warning: with_bloom() requires with_hdr() — bloom ignored.");
}
self.bloom_config = Some(config);
self
}
/// Sets the initial exposure multiplier (Étape 22, 6.1). Default 1.0.
pub fn with_exposure(mut self, exposure: f32) -> Self {
self.exposure = exposure;
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.
@@ -258,6 +322,8 @@ impl AppBuilder {
culling: self.culling,
shadow_config: self.shadow_config,
hdr: self.hdr,
bloom_config: self.bloom_config,
exposure: self.exposure,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -282,6 +348,10 @@ struct AppRunner<H: AppHandler> {
shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
hdr: Option<ToneMapper>,
/// Bloom config (Étape 23); passed to `Renderer::new` in `resumed`. Only active with HDR.
bloom_config: Option<BloomConfig>,
/// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use.
exposure: f32,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -315,7 +385,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer =
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr);
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone());
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
@@ -340,6 +410,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
culling: self.culling,
shadow_config: self.shadow_config.clone(),
hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
event_loop: None,
context: Some(context),
renderer: Some(renderer),
@@ -111,7 +111,7 @@ pub const PITCH_LIMIT: f32 = 1.45; // ~83°
/// decoupled from `Camera`'s own position/target/up representation.
///
/// ```
/// # use wsg_lib::resources::{Camera, CameraController};
/// # use wsg_lib::camera::{Camera, CameraController};
/// # use glam::Vec3;
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
/// let mut ctrl = CameraController::from_camera(&cam);
+793
View File
@@ -0,0 +1,793 @@
//! # Bloom Post-Process (Étape 23)
//!
//! Defines `BloomConfig` (public user-facing configuration) and the internal `BloomPipeline`
//! (GPU resources: half-res textures, blur/composite pipelines, bind groups). The bloom effect
//! is a 4-pass post-process that operates on the HDR texture before tone mapping:
//!
//! 1. **Threshold** (full → half res): extract pixels above a luminance threshold (soft-knee).
//! 2. **Blur H** (half res): horizontal separable Gaussian (9 taps).
//! 3. **Blur V** (half res): vertical separable Gaussian (9 taps).
//! 4. **Composite** (full res): `HDR += bloom × intensity`.
//!
//! The bloom is **opt-in** (`AppBuilder::with_bloom`) and only active when HDR is also enabled.
//! Without HDR, the values are already clamped to [0,1] and there is nothing "bright" to bloom.
use wgpu::{
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
TextureUsages, TextureView,
};
/// User-facing bloom configuration (Étape 23).
///
/// Passed to `AppBuilder::with_bloom(config)` to enable the bloom post-process.
/// Can be updated at runtime via `App::set_bloom_config`.
#[derive(Debug, Clone)]
pub struct BloomConfig {
/// Luminance threshold (in linear HDR units). Pixels above this contribute to bloom.
/// Default: 1.0 (only overbright areas — emissives > 1.0, specular highlights).
pub threshold: f32,
/// Soft-knee width for the threshold ramp. Larger = smoother transition.
/// Default: 0.5.
pub knee: f32,
/// Bloom intensity (multiplier on the blurred result before adding to HDR).
/// Default: 0.8.
pub intensity: f32,
/// Blur radius in pixels (at half resolution). Larger = wider glow.
/// Default: 4.0.
pub radius: f32,
}
impl Default for BloomConfig {
fn default() -> Self {
Self {
threshold: 1.0,
knee: 0.5,
intensity: 0.8,
radius: 4.0,
}
}
}
/// Internal bloom pipeline state. Allocated when bloom + HDR are both active.
/// Recreated on resize.
pub(crate) struct BloomPipeline {
bright_texture: Texture,
bright_view: TextureView,
blur_texture: Texture,
blur_view: TextureView,
composite_texture: Texture,
composite_view: TextureView,
sampler: Sampler,
threshold_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
composite_pipeline: RenderPipeline,
threshold_bg: BindGroup,
blur_bg_h: BindGroup,
blur_bg_v: BindGroup,
composite_bg: BindGroup,
threshold_uniform: Buffer,
blur_uniform_h: Buffer,
blur_uniform_v: Buffer,
composite_uniform: Buffer,
threshold_layout: BindGroupLayout,
blur_layout: BindGroupLayout,
composite_layout: BindGroupLayout,
half_w: u32,
half_h: u32,
width: u32,
height: u32,
}
impl BloomPipeline {
pub fn new(device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView) -> Self {
let half_w = (width / 2).max(1);
let half_h = (height / 2).max(1);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("bloom 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()
});
let (bright_texture, bright_view) =
create_bloom_texture(device, half_w, half_h, "bloom bright");
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
let (composite_texture, composite_view) =
create_bloom_texture(device, width, height, "bloom composite");
// Bind group layouts.
let threshold_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom threshold bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
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: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom blur bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
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: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bloom composite bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
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: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
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: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline layouts.
let threshold_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom threshold pl"),
bind_group_layouts: &[Some(&threshold_layout)],
..Default::default()
});
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom blur pl"),
bind_group_layouts: &[Some(&blur_layout)],
..Default::default()
});
let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("bloom composite pl"),
bind_group_layouts: &[Some(&composite_layout)],
..Default::default()
});
// Shader modules.
let threshold_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom threshold"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_THRESHOLD_SHADER.into(),
),
});
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom blur"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_BLUR_SHADER.into(),
),
});
let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("bloom composite"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::BLOOM_COMPOSITE_SHADER.into(),
),
});
// Shared fragment target state (all 3 passes output to Rgba16Float).
let fragment_targets = &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})];
// Threshold pipeline.
let threshold_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom threshold pipeline"),
layout: Some(&threshold_pl),
vertex: wgpu::VertexState {
module: &threshold_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &threshold_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Blur pipeline.
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom blur pipeline"),
layout: Some(&blur_pl),
vertex: wgpu::VertexState {
module: &blur_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blur_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Composite pipeline.
let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("bloom composite pipeline"),
layout: Some(&composite_pl),
vertex: wgpu::VertexState {
module: &composite_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &composite_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: fragment_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Uniform buffers (32 bytes each — WGSL uniform alignment requires padding;
// vec2 has align 8, vec3 has align 16, so structs are larger than their field sum).
let threshold_uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom threshold uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let blur_uniform_h = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom blur H uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let blur_uniform_v = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom blur V uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let composite_uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("bloom composite uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind groups.
let threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom threshold bg"),
layout: &threshold_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: threshold_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg H"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: blur_uniform_h.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg V"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: blur_uniform_v.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&blur_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom composite bg"),
layout: &composite_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: composite_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
Self {
bright_texture,
bright_view,
blur_texture,
blur_view,
composite_texture,
composite_view,
sampler,
threshold_pipeline,
blur_pipeline,
composite_pipeline,
threshold_bg,
blur_bg_h,
blur_bg_v,
composite_bg,
threshold_uniform,
blur_uniform_h,
blur_uniform_v,
composite_uniform,
threshold_layout,
blur_layout,
composite_layout,
half_w,
half_h,
width,
height,
}
}
pub fn resize(
&mut self,
device: &wgpu::Device,
width: u32,
height: u32,
hdr_view: &TextureView,
) {
let half_w = (width / 2).max(1);
let half_h = (height / 2).max(1);
let (bright_texture, bright_view) =
create_bloom_texture(device, half_w, half_h, "bloom bright");
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
let (composite_texture, composite_view) =
create_bloom_texture(device, width, height, "bloom composite");
self.threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom threshold bg"),
layout: &self.threshold_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.threshold_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg H"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.blur_uniform_h.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom blur bg V"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.blur_uniform_v.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&blur_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("bloom composite bg"),
layout: &self.composite_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.composite_uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(hdr_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&bright_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.bright_texture = bright_texture;
self.bright_view = bright_view;
self.blur_texture = blur_texture;
self.blur_view = blur_view;
self.composite_texture = composite_texture;
self.composite_view = composite_view;
self.half_w = half_w;
self.half_h = half_h;
self.width = width;
self.height = height;
}
#[allow(dead_code)]
pub fn composite_view(&self) -> &TextureView {
&self.composite_view
}
pub fn composite_texture(&self) -> &Texture {
&self.composite_texture
}
pub fn record_passes(
&self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
config: &BloomConfig,
) {
let threshold_data = [config.threshold, config.knee, 0.0, 0.0];
queue.write_buffer(
&self.threshold_uniform,
0,
bytemuck::cast_slice(&threshold_data),
);
let blur_h_data = [1.0 / self.half_w as f32, 0.0, config.radius, 0.0];
queue.write_buffer(&self.blur_uniform_h, 0, bytemuck::cast_slice(&blur_h_data));
let blur_v_data = [0.0, 1.0 / self.half_h as f32, config.radius, 0.0];
queue.write_buffer(&self.blur_uniform_v, 0, bytemuck::cast_slice(&blur_v_data));
let composite_data = [config.intensity, 0.0, 0.0, 0.0];
queue.write_buffer(
&self.composite_uniform,
0,
bytemuck::cast_slice(&composite_data),
);
// Pass 1: Threshold (HDR full → bright half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom threshold"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.bright_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()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.threshold_pipeline);
pass.set_bind_group(0, &self.threshold_bg, &[]);
pass.draw(0..3, 0..1);
}
// Pass 2: Blur H (bright half → blur half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom blur H"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.blur_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()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.blur_pipeline);
pass.set_bind_group(0, &self.blur_bg_h, &[]);
pass.draw(0..3, 0..1);
}
// Pass 3: Blur V (blur half → bright half)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom blur V"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.bright_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()
});
pass.set_viewport(
0.0,
0.0,
self.half_w as f32,
self.half_h as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.blur_pipeline);
pass.set_bind_group(0, &self.blur_bg_v, &[]);
pass.draw(0..3, 0..1);
}
// Pass 4: Composite (HDR full + bright half → composite full)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("bloom composite"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.composite_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()
});
pass.set_viewport(
0.0,
0.0,
self.width as f32,
self.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.composite_pipeline);
pass.set_bind_group(0, &self.composite_bg, &[]);
pass.draw(0..3, 0..1);
}
}
}
fn create_bloom_texture(
device: &wgpu::Device,
width: u32,
height: u32,
label: &str,
) -> (Texture, TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
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: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bloom_config_default() {
let cfg = BloomConfig::default();
assert_eq!(cfg.threshold, 1.0);
assert_eq!(cfg.knee, 0.5);
assert_eq!(cfg.intensity, 0.8);
assert_eq!(cfg.radius, 4.0);
}
#[test]
fn bloom_config_clone() {
let cfg = BloomConfig {
threshold: 2.0,
knee: 1.0,
intensity: 1.5,
radius: 6.0,
};
let cloned = cfg.clone();
assert_eq!(cloned.threshold, 2.0);
assert_eq!(cloned.intensity, 1.5);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
//!
//! View-projection frustum representation and plane extraction, for frustum culling (Phase 3,
//! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space
//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]).
//! z range (the `directx` projection produced by [`crate::camera::Camera::projection_matrix`]).
//!
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
@@ -80,7 +80,7 @@ impl Frustum {
#[cfg(test)]
mod tests {
use super::*;
use crate::resources::camera::Camera;
use crate::camera::Camera;
/// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov,
/// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer.
@@ -149,7 +149,7 @@ mod tests {
/// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
#[test]
fn demo_camera_sees_all_primitives() {
use crate::resources::CameraController;
use crate::camera::CameraController;
let mut ctrl = CameraController::default();
ctrl.yaw = 0.6;
ctrl.pitch = 0.35;
+2 -2
View File
@@ -9,24 +9,24 @@
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`.
//! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
pub mod bloom;
pub mod context;
pub mod frame;
pub mod frustum;
pub mod geometry;
pub mod hdr;
pub mod input;
pub mod lod;
pub mod renderer;
pub mod shadow;
pub mod transform;
// Re-exports
pub use bloom::BloomConfig;
pub use context::Context;
pub use frame::Frame;
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use hdr::ToneMapper;
pub use input::InputState;
pub use lod::{lod_level, projected_radius_px};
pub use renderer::Renderer;
pub use shadow::ShadowConfig;
+109 -19
View File
@@ -19,7 +19,9 @@
//! texture state changes happen once per distinct material, not once per entity.
//! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths.
use crate::camera::Camera;
use crate::core::Context;
use crate::lights::{Lights, MAX_LIGHTS};
use crate::core::Frame;
use crate::core::Frustum;
use crate::core::lod::{lod_level, projected_radius_px};
@@ -29,13 +31,14 @@ use crate::pipeline::{
};
use crate::resources::uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE,
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE,
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE,
SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot,
};
use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
CullUniforms, FrameUniforms, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::core::bloom::{BloomConfig, BloomPipeline};
use crate::core::hdr::ToneMapper;
use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER,
@@ -151,6 +154,11 @@ pub struct Renderer {
/// 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>,
/// Bloom pipeline (Étape 23). Present only when both HDR and bloom are active.
/// When `None`, the TM pass reads the HDR texture directly (no bloom, zero overhead).
bloom: Option<BloomPipeline>,
/// Bloom configuration (used per-frame for uniform writes). Only meaningful when bloom is active.
bloom_config: BloomConfig,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
@@ -163,12 +171,17 @@ struct HdrPipeline {
/// 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,
/// TM uniform buffer (32 bytes: exposure + viewport). Re-written each frame for live exposure.
uniform_buffer: wgpu::Buffer,
/// 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,
/// Viewport width in pixels (for the TM uniform's pad.xy).
width: u32,
/// Viewport height in pixels.
height: u32,
}
impl Renderer {
@@ -187,6 +200,7 @@ impl Renderer {
height: u32,
shadow_config: &super::shadow::ShadowConfig,
hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
@@ -223,6 +237,7 @@ impl Renderer {
});
let identity_object = ObjectUniform {
model: glam::Mat4::IDENTITY,
emissive: glam::Vec4::ZERO,
};
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -425,9 +440,11 @@ impl Renderer {
label: Some("GPU world matrices"),
size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map.
// COPY_DST: lets the CPU write emissive values into the slot padding (Étape 22).
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_SRC,
| wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -575,12 +592,27 @@ impl Renderer {
viewport_height: height,
shadow_config: shadow_config.clone(),
hdr: None,
bloom: None,
bloom_config: bloom_config.clone().unwrap_or_default(),
};
// 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));
// Étape 23: allocate the bloom pipeline when both HDR and bloom are active.
if bloom_config.is_some() {
if let Some(hdr) = &mut renderer.hdr {
let bloom = BloomPipeline::new(&renderer.device, width, height, &hdr.view);
// Recreate the TM bind group to read from the bloom composite texture.
let (bg, _buf) = create_hdr_bind_group(
&renderer.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height,
);
hdr.bind_group = bg;
renderer.bloom = Some(bloom);
renderer.bloom_config = bloom_config.clone().unwrap();
}
}
renderer
}
@@ -623,10 +655,24 @@ impl Renderer {
// É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);
let (bg, buf) = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height);
hdr.texture = tex;
hdr.view = view;
hdr.bind_group = bg;
hdr.uniform_buffer = buf;
hdr.width = width;
hdr.height = height;
}
// Étape 23: resize bloom textures + re-point TM bind group at the composite.
if self.bloom.is_some() {
if let Some(hdr) = &mut self.hdr {
let bloom = self.bloom.as_mut().unwrap();
bloom.resize(&self.device, width, height, &hdr.view);
let (bg, _buf) = create_hdr_bind_group(
&self.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height,
);
hdr.bind_group = bg;
}
}
}
@@ -717,15 +763,15 @@ impl Renderer {
// camera must look along the light's **travel direction** (light → scene), i.e. the negation
// of the surface→light vector for directional lights.
let dir = match light.light_type() {
crate::resources::LightType::Directional => Vec3::new(
crate::lights::LightType::Directional => Vec3::new(
-light.position_dir.x,
-light.position_dir.y,
-light.position_dir.z,
),
crate::resources::LightType::Spot => {
crate::lights::LightType::Spot => {
Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z)
}
crate::resources::LightType::Point => return None,
crate::lights::LightType::Point => return None,
};
let r = self.shadow_config.scene_radius;
let target = Vec3::from(self.shadow_config.scene_center);
@@ -807,7 +853,7 @@ impl Renderer {
/// draw). This removes the CPU-side per-entity loop from the render hot path.
/// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are
/// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection.
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32, exposure: f32) {
// 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags).
self.write_frame_uniforms(
scene.camera(),
@@ -1007,9 +1053,42 @@ 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).
// 8. Étape 22 (6.1): write the current exposure into the TM uniform buffer (per-frame,
// so live adjustments via keyboard take effect immediately).
// 8b. Étape 22 (6.2): write each active slot's emissive into the matrix buffer padding
// (bytes 64-79). The compute pass only overwrites bytes 0-63 (the matrix), so the
// emissive persists. This must happen before the encoder submit (CPU→GPU copy).
if let Some(hdr) = &self.hdr {
let uniform_data = [
exposure, 0.0, 0.0, 0.0,
hdr.width as f32, hdr.height as f32, 0.0, 0.0,
];
self.queue.write_buffer(&hdr.uniform_buffer, 0, bytemuck::cast_slice(&uniform_data));
}
// Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79).
// The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists.
for slot in scene.iter_slot_draws().filter(|s| s.active) {
let mat = slot
.mesh
.material()
.cloned()
.unwrap_or_else(|| scene.default_material());
if mat.emissive != [0.0; 4] {
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64;
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive));
}
}
// 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite).
// Only runs when both HDR and bloom are active. The composite texture becomes
// the input to the TM pass (the TM bind group was re-pointed at construction).
if let Some(bloom) = &self.bloom {
bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config);
}
// 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
// texture (or the bloom composite when bloom is active), applies exposure + tone
// mapping curve, and writes to the surface.
if let Some(hdr) = &self.hdr {
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("tone mapping pass"),
@@ -1321,6 +1400,12 @@ impl Renderer {
self.lod_enabled.set(enabled);
}
/// Updates the bloom configuration at runtime (Étape 23).
/// Takes effect on the next frame (uniforms are re-written each frame in `record_passes`).
pub fn set_bloom_config(&mut self, config: &BloomConfig) {
self.bloom_config = config.clone();
}
/// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the
/// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox
/// center + max half-extent × max scale component, rotated by the entity's quaternion) — is
@@ -1524,8 +1609,9 @@ fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::
(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).
/// Creates the tone mapping bind group + uniform buffer: HDR texture (binding 0) + sampler (binding 1)
/// + uniform (binding 2). The uniform contains exposure (1.0) and viewport size (pad.xy).
/// Returns both the bind group and the uniform buffer (so the exposure can be re-written per frame).
fn create_hdr_bind_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
@@ -1533,7 +1619,7 @@ fn create_hdr_bind_group(
texture: &wgpu::Texture,
width: u32,
height: u32,
) -> wgpu::BindGroup {
) -> (wgpu::BindGroup, wgpu::Buffer) {
// 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].
@@ -1546,7 +1632,7 @@ fn create_hdr_bind_group(
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("tm uniform"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: true,
});
{
@@ -1555,7 +1641,7 @@ fn create_hdr_bind_group(
drop(w);
uniform_buffer.unmap();
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("tm bind group"),
layout,
entries: &[
@@ -1576,7 +1662,8 @@ fn create_hdr_bind_group(
}),
},
],
})
});
(bind_group, uniform_buffer)
}
/// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group.
@@ -1669,15 +1756,18 @@ fn create_hdr_pipeline(
});
// 5. Bind group with the initial texture + viewport size.
let bind_group = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height);
let (bind_group, uniform_buffer) = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height);
HdrPipeline {
texture,
view,
pipeline,
bind_group,
uniform_buffer,
layout,
sampler,
width,
height,
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
//! ## Query examples (in `AppHandler::update`)
//! ```
//! # use winit::keyboard::{KeyCode, PhysicalKey};
//! # fn demo(input: &wsg_lib::core::input::InputState) {
//! # fn demo(input: &wsg_lib::input::InputState) {
//! if input.key_held(KeyCode::KeyW) { /* move forward */ }
//! if input.key_pressed(KeyCode::Space) { /* jump */ }
//! let (dx, dy) = input.mouse_delta();
+4
View File
@@ -29,8 +29,11 @@
#![warn(missing_docs)]
pub mod app;
pub mod camera;
pub mod core;
pub mod handler;
pub mod input;
pub mod lights;
pub mod mesh;
pub mod pipeline;
pub mod prelude;
@@ -48,6 +51,7 @@ 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::BloomConfig;
pub use crate::core::ShadowConfig;
/// Re-export of the tone mapping curve selector for convenient top-level access.
@@ -1,9 +1,7 @@
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13)
//! # Lights — Global Light List + Light Types
//!
//! Holds the scene's global light list — directional, point and spot lights — in a CPU-side
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each
//! frame by `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is
//! lit by the same list (per-material lights are out of scope, a later performance/feature step).
//! Defines the scene's global light list — directional, point and spot lights — and the
//! GPU-upload types (`Light`, `LightType`, `MAX_LIGHTS`).
//!
//! ## Rangement (no type flag)
//! Directional lights occupy indices `0..num_directional`; point lights occupy
@@ -15,9 +13,11 @@
//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white
//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
use crate::resources::uniform::{Light, MAX_LIGHTS};
use glam::{Vec3, Vec4};
/// Re-exported from `crate::resources::uniform` (where `Pod` is derived for the uniform buffer).
pub use crate::resources::uniform::{Light, LightType, MAX_LIGHTS};
/// The scene's global light list: directional lights (first), point lights (middle), spot lights
/// (last). Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene`
/// API.
@@ -32,13 +32,11 @@ pub struct Lights {
}
impl Lights {
/// Default = one white directional light along +Z (from surface toward light), no point or
/// spot lights. This reproduces the historical single-light look when combined with a white
/// ambient.
/// Default = one white directional light along +Z.
pub fn new() -> Self {
Self {
directional: vec![Light {
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0),
color: Vec4::ONE,
radius: Vec4::ZERO,
dir_angle: Vec4::ZERO,
@@ -48,7 +46,7 @@ impl Lights {
}
}
/// Total number of lights (directional + point + spot).
/// Total number of lights.
pub fn len(&self) -> usize {
self.directional.len() + self.point.len() + self.spot.len()
}
@@ -58,9 +56,7 @@ impl Lights {
self.len() == 0
}
/// Returns the light at a **packed-array index** (directionals first, then point lights, then
/// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Step 14 D7).
/// Returns the light at a **packed-array index**.
pub fn get(&self, index: usize) -> Option<&Light> {
let n_dir = self.directional.len();
if index < n_dir {
@@ -74,10 +70,7 @@ impl Lights {
self.spot.get(index - n_point)
}
/// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then
/// point lights, then spot lights. The tail is zero-filled. Returns
/// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS`
/// (the `Scene` API validates capacity).
/// Packs the lights into the GPU frame array.
pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) {
let empty = Light {
position_dir: Vec4::ZERO,
@@ -102,14 +95,12 @@ impl Lights {
}
impl Default for Lights {
/// `Lights::new()` — one white directional light along +Z (non-regression default).
fn default() -> Self {
Self::new()
}
}
/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and
/// an intensity multiplier. Used by `Scene::add_directional_light`.
/// Builds a directional [`Light`].
pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
Light {
position_dir: dir.extend(0.0),
@@ -119,8 +110,7 @@ pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
}
}
/// Builds a point [`Light`] from a world position, a color, an intensity multiplier and an
/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`.
/// Builds a point [`Light`].
pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light {
Light {
position_dir: pos.extend(0.0),
@@ -130,9 +120,7 @@ pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> L
}
}
/// Builds a spot [`Light`] from a world position, a cone axis (from the light toward the scene), a
/// color, an intensity multiplier, an attenuation radius and a half-angle in radians. Used by
/// `Scene::add_spot_light`. The half-angle is stored as its cosine in `dir_angle.w`.
/// Builds a spot [`Light`].
pub fn spot_light(
pos: Vec3,
dir: Vec3,
@@ -164,7 +152,7 @@ mod tests {
#[test]
fn into_frame_array_packs_directional_point_then_spot() {
let mut lights = Lights::new(); // 1 directional
let mut lights = Lights::new();
lights
.point
.push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0));
@@ -180,11 +168,9 @@ mod tests {
assert_eq!(n_dir, 1);
assert_eq!(n_point, 1);
assert_eq!(n_spot, 1);
// Directional first, point second, spot third.
assert_eq!(array[0].color, Vec4::ONE);
assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0));
assert_eq!(array[2].color, Vec4::new(0.0, 1.0, 0.0, 1.0));
// Spot stores the cone axis (normalized) and the half-angle cosine.
assert_eq!(array[2].dir_angle.truncate(), Vec3::new(-1.0, 0.0, 0.0));
assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6);
}
@@ -194,28 +180,18 @@ mod tests {
assert!(MAX_LIGHTS >= 1);
}
/// Locks the spot sign convention used by the shader: for a surface point that lies on the
/// cone axis, the alignment between the "light -> point" direction (`-l`, where `l` points
/// from the surface toward the light) and the stored cone axis (`dir_angle.xyz`, from the
/// light toward the scene) must be **+1** (full cone), not −1. A regression to the wrong sign
/// would make every spot light contribute zero (black cube). Mirrors the WGSL spot loop.
#[test]
fn spot_cone_axis_alignment_is_positive() {
// Spot at (0,0,3), cone axis pointing toward the origin (light -> scene).
let light_pos = Vec3::new(0.0, 0.0, 3.0);
let surface_point = Vec3::ZERO;
let cone_axis = (surface_point - light_pos).normalize(); // (0,0,-1)
// Shader math: l points surface -> light; the cone test uses -l (light -> point).
let l = (light_pos - surface_point).normalize(); // (0,0,1)
let to_point = -l; // (0,0,-1)
let cone_axis = (surface_point - light_pos).normalize();
let l = (light_pos - surface_point).normalize();
let to_point = -l;
let cone = to_point.dot(cone_axis);
assert!(
(cone - 1.0).abs() < 1e-6,
"on-axis point must align with the cone axis (got {cone}); if it is ~-1 the spot sign is wrong"
"on-axis point must align with the cone axis (got {cone})"
);
// Sanity: the buggy expression (dot of l with the axis) would be ~ -1.
assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
label: Some("object_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
+11 -1
View File
@@ -15,7 +15,17 @@
// Core types
pub use crate::core::geometry::{BBox, Geometry};
pub use crate::core::transform::Transform;
pub use crate::core::{ShadowConfig, ToneMapper};
pub use crate::core::{BloomConfig, ShadowConfig, ToneMapper};
pub use crate::resources::Material;
// Camera
pub use crate::camera::{Camera, CameraController};
// Lights
pub use crate::lights::{directional_light, point_light, spot_light, Light, LightType, Lights};
// Input
pub use crate::input::InputState;
// App / handler (already at crate root, re-exported here for convenience)
pub use crate::app::AppBuilder;
+5
View File
@@ -19,6 +19,7 @@ use std::sync::Arc;
/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline and an optional
/// diffuse texture. Does not own the pipeline; holds an Arc for zero-copy sharing across objects
/// using the same shader. Owns its texture bind group (group 2), built at construction.
#[derive(Clone)]
pub struct Material {
/// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache.
pub shader_id: String,
@@ -29,6 +30,9 @@ pub struct Material {
/// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in
/// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`.
pub texture_bind_group: wgpu::BindGroup,
/// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]`
/// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect.
pub emissive: [f32; 4],
}
impl Material {
@@ -69,6 +73,7 @@ impl Material {
pipeline,
texture,
texture_bind_group,
emissive: [0.0, 0.0, 0.0, 0.0],
}
}
}
+10 -20
View File
@@ -1,19 +1,12 @@
//! # Resources Module — Data Types
//! # Resources Module — GPU Data Types
//!
//! Defines the core data types that flow through the rendering pipeline: **Geometry** (CPU-side scattered
//! vertex data, source of truth — re-exported here from `math` for convenience), **Vertex** (interleaved
//! CPU-side per-attribute tuple, the GPU upload contract), **Mesh** (GPU geometry container with vertex/index
//! buffers), and **Material** (appearance descriptor pairing shader ID with a compiled RenderPipeline).
//! These are immutable after creation and consumed by Renderer for draw calls.
//! Defines the core GPU data types that flow through the rendering pipeline: **Mesh** (GPU geometry
//! container with vertex/index buffers), **Material** (appearance descriptor pairing shader ID with
//! a compiled RenderPipeline), **Texture** (GPU image + sampler), and **Uniform** (Pod structs for
//! uniform buffer uploads).
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache::build_pipeline()` reads Vertex field offsets to construct the vertex buffer layout.
//! - `mesh::from_geometry()` derives `Vertex` arrays from a `Geometry` and uploads them into GPU vertex
//! buffers via DeviceExt::create_buffer_init().
//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization.
//! Camera, Lights and Input are now top-level modules (`wsg::camera`, `wsg::lights`, `wsg::input`).
pub mod camera;
pub mod lights;
pub mod material;
pub mod mesh;
pub mod texture;
@@ -21,19 +14,16 @@ pub mod uniform;
pub mod vertex;
// Re-exports
pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights;
pub use material::Material;
pub use mesh::{LodMode, Mesh, PackError};
pub use texture::{Texture, TextureError};
pub use uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow,
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform,
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, LodRow, LodTable,
MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform,
TRANSFORM_SLOT_SIZE, TransformSlot,
};
pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes
// from `wsg_lib::resources::Geometry` without importing `math` separately.
// Convenience re-export of Geometry (Step 8, D2)
pub use crate::core::Geometry;
+21 -43
View File
@@ -27,55 +27,33 @@ pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64
/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation).
pub const MAX_LIGHTS: usize = 8;
/// A single light, stored in the per-frame uniform array. One struct serves all three types; the
/// *position in the array* disambiguates:
/// - indices `0..num_directional` are **directional** (`position_dir.xyz` = direction
/// **from the surface toward the light**);
/// - indices `num_directional..num_directional + num_point` are **point**
/// (`position_dir.xyz` = world position);
/// - indices `num_directional + num_point..` are **spot** (`position_dir.xyz` = world position,
/// `dir_angle.xyz` = cone axis **from the light toward the scene**, `dir_angle.w` = cos of the
/// half-angle).
/// No type flag in the struct.
///
/// 4 × Vec4 = 64 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`).
/// A single light, stored in the per-frame uniform array (64 bytes, std140).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, PartialEq)]
pub struct Light {
/// xyz = direction from surface toward the light (directional) or world position (point/spot);
/// w = 0.
/// xyz = direction (directional) or position (point/spot); w = 0.
pub position_dir: Vec4,
/// rgb = color; a = intensity (multiplier).
/// rgb = color; a = intensity.
pub color: Vec4,
/// x = attenuation radius (point/spot lights); 0 for directional.
/// x = attenuation radius.
pub radius: Vec4,
/// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle.
/// Zero for directional and point lights.
/// xyz = cone axis; w = cos half-angle (spot only).
pub dir_angle: Vec4,
}
/// The runtime-disambiguated type of a [`Light`] (Step 14, D6). Not stored in the struct (the array
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
/// which must reject point lights (cubemap shadows are out of scope).
/// The runtime-disambiguated type of a [].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LightType {
/// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the
/// light, `radius.x` = 0, `dir_angle` = 0.
/// Directional light (infinitely distant).
Directional,
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation
/// radius, `dir_angle` = 0.
/// Point (omnidirectional).
Point,
/// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in
/// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle.
/// Spot (cone).
Spot,
}
impl Light {
/// Classifies the light for CPU-side logic. Query order is significant because a spot light
/// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a
/// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is
/// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or
/// [`LightType::Spot`].
/// Classifies the light for CPU-side logic.
pub fn light_type(&self) -> LightType {
if self.dir_angle.w > 0.0 {
LightType::Spot
@@ -87,14 +65,8 @@ impl Light {
}
}
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data + options.
///
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
/// light selection, the light view-projection matrix + shadow parameters, then options — total
/// **784 bytes** (Step 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
/// bind-group layout uses `min_binding_size: None`, so extending this struct is transparent
/// (no relayout).
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms {
@@ -158,14 +130,18 @@ impl Default for FrameUniforms {
}
}
/// Per-object GPU uniforms: the entity's world-space model matrix.
/// Per-object GPU uniforms: the entity's world-space model matrix + emissive color.
///
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
/// Mirrors the WGSL `ObjectUniform` struct. 80 bytes, `Pod`.
/// In the GPU-driven path, the emissive lives in the `MatSlot` padding (bytes 64-79),
/// pre-filled by the CPU at slot creation and never overwritten by the compute pass.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct ObjectUniform {
/// Model matrix (object → world space). Offset 0.
pub model: Mat4,
/// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression).
pub emissive: Vec4,
}
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
@@ -517,9 +493,11 @@ mod tests {
#[test]
fn object_uniform_layout_matches_wgsl() {
assert_eq!(size_of::<ObjectUniform>(), 64);
// Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive).
assert_eq!(size_of::<ObjectUniform>(), 80);
assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0);
assert_eq!(offset_of!(ObjectUniform, emissive), 64);
}
#[test]
+28 -13
View File
@@ -17,7 +17,7 @@
use crate::core::{Geometry, Transform};
use crate::pipeline::PipelineCache;
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
use crate::camera::Camera; use crate::lights::Lights; use crate::resources::{BBoxSlot, Material, Mesh, Texture, TransformSlot};
use crate::scene::Entity;
use glam::Vec3;
use std::cell::RefCell;
@@ -452,7 +452,7 @@ impl Scene {
}
/// Returns a mutable reference to the scene's active camera, for in-place per-frame edits
/// (e.g. [`CameraController::apply_to`](crate::resources::CameraController) during `update`).
/// (e.g. [`CameraController::apply_to`](crate::camera::CameraController) during `update`).
pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
@@ -468,15 +468,15 @@ impl Scene {
color: [f32; 3],
intensity: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
crate::lights::MAX_LIGHTS
));
}
self.lights
.directional
.push(crate::resources::lights::directional_light(
.push(crate::lights::directional_light(
dir, color, intensity,
));
Ok(())
@@ -492,15 +492,15 @@ impl Scene {
intensity: f32,
radius: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
crate::lights::MAX_LIGHTS
));
}
self.lights
.point
.push(crate::resources::lights::point_light(
.push(crate::lights::point_light(
pos, color, intensity, radius,
));
Ok(())
@@ -520,13 +520,13 @@ impl Scene {
radius: f32,
half_angle: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
crate::lights::MAX_LIGHTS
));
}
self.lights.spot.push(crate::resources::lights::spot_light(
self.lights.spot.push(crate::lights::spot_light(
pos, dir, color, intensity, radius, half_angle,
));
Ok(())
@@ -603,6 +603,21 @@ impl Scene {
Ok(id.to_string())
}
/// Sets the emissive color on a registered material (Étape 22, 6.2).
/// Uses `Arc::get_mut` — only works if the material has a single reference (i.e., no mesh
/// has captured it yet). Call BEFORE `create_mesh` to pre-set the emissive.
/// Returns Err if the material doesn't exist or has multiple references.
pub fn set_material_emissive(&mut self, id: &str, emissive: [f32; 4]) -> Result<(), String> {
let mat = self
.materials
.get_mut(id)
.ok_or_else(|| format!("Material '{}' not found.", id))?;
let inner = Arc::get_mut(mat)
.ok_or_else(|| format!("Material '{}' has multiple references; cannot modify in place.", id))?;
inner.emissive = emissive;
Ok(())
}
/// Associates an entity label with a mesh for rendering iteration, using an identity transform.
/// The appearance (Material) is read from the Mesh itself (or the Scene's default), so no
/// material_id is needed here (DRAFT Step 7.3).
@@ -857,12 +872,12 @@ mod tests {
scene
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
.unwrap();
while scene.lights().len() < crate::resources::MAX_LIGHTS {
while scene.lights().len() < crate::lights::MAX_LIGHTS {
scene
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
.unwrap();
}
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS);
assert_eq!(scene.lights().len(), crate::lights::MAX_LIGHTS);
assert!(
scene
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
+56
View File
@@ -0,0 +1,56 @@
// Bloom blur pass: separable 9-tap Gaussian blur (half-res).
// Direction is passed via uniform (H or V). Ping-ponged between two textures.
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3).
// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2.
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var out: VsOut;
switch vi {
case 0u {
out.pos = vec4<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct BlurUniforms {
direction: vec2<f32>,
radius: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> bu: BlurUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
const W: array<f32, 5> = array<f32, 5>(
0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162
);
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let center = textureSample(src_tex, src_sampler, in.uv).rgb;
var sum = center * W[0];
for (var i: u32 = 1u; i < 5u; i = i + 1u) {
let off = bu.direction * (f32(i) * bu.radius);
let s = textureSample(src_tex, src_sampler, in.uv + off).rgb
+ textureSample(src_tex, src_sampler, in.uv - off).rgb;
sum = sum + s * W[i];
}
return vec4<f32>(sum, 1.0);
}
+47
View File
@@ -0,0 +1,47 @@
// Bloom composite pass: add the blurred bloom to the HDR texture.
// Reads full-res HDR + half-res bloom (upscaled by linear sampler), writes full-res composite.
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3).
// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2.
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var out: VsOut;
switch vi {
case 0u {
out.pos = vec4<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct CompositeUniforms {
intensity: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> cu: CompositeUniforms;
@group(0) @binding(1) var hdr_tex: texture_2d<f32>;
@group(0) @binding(2) var hdr_sampler: sampler;
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@group(0) @binding(4) var bloom_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb;
let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb;
return vec4<f32>(hdr + bloom * cu.intensity, 1.0);
}
+50
View File
@@ -0,0 +1,50 @@
// Bloom threshold pass: extract bright pixels from the HDR texture.
// Reads full-res HDR, writes half-res bright texture.
// Soft-knee threshold: smooth transition above the threshold luminance.
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3).
// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2.
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var out: VsOut;
switch vi {
case 0u {
out.pos = vec4<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct ThresholdUniforms {
threshold: f32,
knee: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> tmu: ThresholdUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let color = textureSample(src_tex, src_sampler, in.uv).rgb;
let lum = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
// Soft-knee: smooth ramp from 0 to 1 above threshold.
let soft = max(lum - tmu.threshold, 0.0);
let contrib = soft / (soft + tmu.knee);
return vec4<f32>(color * contrib, 1.0);
}
+9 -4
View File
@@ -96,7 +96,8 @@ struct FrameUniforms {
};
struct ObjectUniform {
model: mat4x4<f32>,
model: mat4x4<f32>, // 64 bytes (offset 0)
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
};
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
@@ -147,9 +148,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb;
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex telle quelle.
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
if (frame.options.x != 0u) {
return vec4<f32>(base, in.color.a);
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(base + emissive_contrib, in.color.a);
}
let n = normalize(in.normal);
@@ -203,7 +205,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
}
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
return vec4<f32>(lit, in.color.a);
// Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows).
// Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows.
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(lit + emissive_contrib, in.color.a);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
+13 -1
View File
@@ -46,6 +46,18 @@ pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
/// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled.
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl");
/// The bloom threshold pass shader (Étape 23). Extracts pixels above a luminance threshold
/// from the full-res HDR texture into a half-res bright texture. Soft-knee falloff.
pub const BLOOM_THRESHOLD_SHADER: &str = include_str!("../shaders/bloom_threshold.wgsl");
/// The bloom blur pass shader (Étape 23). Separable 9-tap Gaussian, direction via uniform.
/// Ping-ponged between two half-res textures (H pass then V pass).
pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl");
/// The bloom composite pass shader (Étape 23). Adds the blurred bloom (half-res, upsampled)
/// to the full-res HDR texture, scaled by intensity. Writes to a full-res composite texture.
pub const BLOOM_COMPOSITE_SHADER: &str = include_str!("../shaders/bloom_composite.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.
@@ -102,7 +114,7 @@ pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0];
/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout
/// so upper layers can address the shadow light safely, Step 14 D7). Also used as the no-caster
/// sentinel for `FrameUniforms.shadow_light_index`.
pub use crate::resources::uniform::MAX_LIGHTS;
pub use crate::lights::MAX_LIGHTS;
/// Default application title displayed in the OS taskbar/window decorations.
pub const APP_DEFAULT_TITLE: &str = "WSG App";