refactor examples
This commit is contained in:
+75
-3
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user