This commit is contained in:
Jérôme Bousquié
2026-09-25 13:43:59 +02:00
parent 9614156848
commit 8ece89ccba
22 changed files with 1998 additions and 245 deletions
+38 -1
View File
@@ -73,6 +73,10 @@ pub struct App {
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
/// `Some(config)` activates multi-sample anti-aliasing.
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default, zero overhead).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR.
dof: Option<super::core::DoFConfig>,
/// 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.
@@ -141,6 +145,8 @@ impl App {
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
handler,
app: None,
};
@@ -246,6 +252,10 @@ pub struct AppBuilder {
exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
msaa: Option<MsaaConfig>,
/// Fog configuration (Étape 25). `None` = no fog (default).
fog: Option<super::core::FogConfig>,
/// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR.
dof: Option<super::core::DoFConfig>,
}
impl AppBuilder {
@@ -262,6 +272,8 @@ impl AppBuilder {
bloom_config: None,
exposure: 1.0,
msaa: None,
fog: None,
dof: None,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -327,6 +339,23 @@ impl AppBuilder {
}
self
}
/// Enables distance fog (Étape 25). Fades objects into `config.color` based on their
/// distance from the camera. Use `FogConfig::exponential2(color, density)` to mask
/// the edge of the rendered world. Zero cost when not called.
pub fn with_fog(mut self, config: super::core::FogConfig) -> Self {
self.fog = Some(config);
self
}
/// Enables Depth of Field (Étape 26). Blurs pixels based on their distance from the
/// focus plane, creating a cinematic bokeh effect. **Requires HDR** (`with_hdr`):
/// without it, the DoF is silently ignored with a warning. Zero cost when not called.
pub fn with_dof(mut self, config: super::core::DoFConfig) -> Self {
if self.hdr.is_none() {
eprintln!("[wsg] Warning: with_dof() requires with_hdr() — DoF ignored.");
}
self.dof = Some(config);
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.
@@ -346,6 +375,8 @@ impl AppBuilder {
bloom_config: self.bloom_config,
exposure: self.exposure,
msaa: self.msaa,
fog: self.fog,
dof: self.dof,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -376,6 +407,10 @@ struct AppRunner<H: AppHandler> {
exposure: f32,
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
msaa: Option<MsaaConfig>,
/// Fog config (Étape 25); passed to `Renderer::new` in `resumed`.
fog: Option<super::core::FogConfig>,
/// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR.
dof: Option<super::core::DoFConfig>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -409,7 +444,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, self.bloom_config.clone(), self.msaa.clone());
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone(), self.fog.clone(), self.dof.clone());
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
@@ -438,6 +473,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
fog: self.fog.clone(),
dof: self.dof.clone(),
event_loop: None,
context: Some(context),
renderer: Some(renderer),
+570
View File
@@ -0,0 +1,570 @@
//! Depth of Field (DoF) configuration (Étape 26).
//!
//! DoF simulates camera lens behavior: objects at the focus distance are sharp,
//! everything else is progressively blurred. This is a post-process effect that
//! operates on the HDR texture + depth buffer before tone mapping.
//!
//! **Opt-in**: when no `DoFConfig` is set, no DoF textures are allocated and the
//! pipeline cost is zero.
/// Depth of Field configuration.
#[derive(Clone, Copy, Debug)]
pub struct DoFConfig {
/// Focus distance in world units. The image is perfectly sharp at this distance.
pub focus_distance: f32,
/// Blur intensity: 0.0 = no blur, 1.0 = maximum. Scales the CoC calculation.
pub aperture: f32,
/// Maximum blur radius in pixels. Clamps the CoC to prevent excessive blur.
pub max_blur: f32,
}
impl DoFConfig {
/// Creates a custom DoF configuration.
///
/// - `focus_distance`: world distance where the image is sharp
/// - `aperture`: blur intensity (0.0–1.0)
/// - `max_blur`: maximum blur radius in pixels
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self {
Self {
focus_distance,
aperture: aperture.clamp(0.0, 1.0),
max_blur: max_blur.max(0.0),
}
}
/// Cinematic preset: gradual blur building up to 12px at the extremes.
/// Good for cutscenes and character close-ups.
pub fn cinematic(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.3, 12.0)
}
/// Subtle preset: very gentle blur, 8px max radius.
/// Good for gameplay with a hint of depth separation.
pub fn subtle(focus_distance: f32) -> Self {
Self::new(focus_distance, 0.1, 8.0)
}
/// Packs the config into the (fog-style) two vec4 uniform layout.
/// Returns `(dof_a, dof_b)` where:
/// - `dof_a = (focus_distance, aperture, max_blur, near)`
/// - `dof_b = (far, inv_width, inv_height, 0.0)`
///
/// `near` and `far` come from the camera projection. `inv_width`/`inv_height`
/// are the reciprocal texture dimensions.
pub fn pack(
&self,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(self.focus_distance, self.aperture, self.max_blur, near),
glam::Vec4::new(far, inv_width, inv_height, 0.0),
)
}
}
use wgpu::{
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
TextureView,
};
/// Internal DoF pipeline state. Allocated when DoF + HDR are both active.
/// Recreated on resize.
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
// Samplers: non-filtering for CoC (depth), filtering for blur (color + CoC).
coc_sampler: Sampler,
blur_sampler: Sampler,
// Pipelines
coc_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
// Uniform buffer (shared: same values for both passes, 32 bytes)
uniform_buffer: Buffer,
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
// Layouts (kept for resize)
coc_layout: BindGroupLayout,
blur_layout: BindGroupLayout,
// Dimensions
width: u32,
height: u32,
}
impl DoFPipeline {
pub fn new(
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) -> Self {
// Non-filtering sampler for the CoC pass (depth textures require non-filtering).
let coc_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof coc sampler (non-filtering)"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
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()
});
// Filtering sampler for the blur pass (color + CoC textures).
let blur_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("dof blur sampler (filtering)"),
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()
});
// CoC texture: R16Float, full-res.
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
// Output texture: Rgba16Float, full-res.
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
// --- CoC bind group layout (3 bindings) ---
let coc_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof coc 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::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
],
});
// --- Blur bind group layout (4 bindings) ---
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("dof 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::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline layouts.
let coc_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof coc pl"),
bind_group_layouts: &[Some(&coc_layout)],
..Default::default()
});
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("dof blur pl"),
bind_group_layouts: &[Some(&blur_layout)],
..Default::default()
});
// Shader modules.
let coc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof coc"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_COC_SHADER.into(),
),
});
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("dof blur"),
source: wgpu::ShaderSource::Wgsl(
crate::utils::conf::DOF_BLUR_SHADER.into(),
),
});
// CoC pipeline (output: R16Float).
let coc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof coc pipeline"),
layout: Some(&coc_pl),
vertex: wgpu::VertexState {
module: &coc_module,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &coc_module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::R16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Blur pipeline (output: Rgba16Float).
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("dof 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: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
// Uniform buffer (32 bytes: 8 f32s).
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dof uniform"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind groups.
let coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&coc_sampler),
},
],
});
let blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&blur_sampler),
},
],
});
Self {
coc_texture,
coc_view,
output_texture,
output_view,
coc_sampler,
blur_sampler,
coc_pipeline,
blur_pipeline,
uniform_buffer,
coc_bind_group,
blur_bind_group,
coc_layout,
blur_layout,
width,
height,
}
}
/// Writes the DoF uniform buffer with current config values.
pub fn update_uniform(
&self,
queue: &wgpu::Queue,
config: &DoFConfig,
near: f32,
far: f32,
) {
let (a, b) = config.pack(near, far, 1.0 / self.width as f32, 1.0 / self.height as f32);
let data: [f32; 8] = [a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w];
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&data));
}
/// Recreates textures and bind groups on resize.
pub fn resize(
&mut self,
device: &wgpu::Device,
width: u32,
height: u32,
depth_view: &TextureView,
color_view: &TextureView,
) {
self.width = width;
self.height = height;
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof coc"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let coc_view = coc_texture.create_view(&Default::default());
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dof output"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let output_view = output_texture.create_view(&Default::default());
self.coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof coc bg"),
layout: &self.coc_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.coc_sampler),
},
],
});
self.blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("dof blur bg"),
layout: &self.blur_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(color_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&coc_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.blur_sampler),
},
],
});
self.coc_texture = coc_texture;
self.coc_view = coc_view;
self.output_texture = output_texture;
self.output_view = output_view;
}
/// Returns the DoF output texture (for re-pointing the TM bind group).
pub fn output_texture(&self) -> &Texture {
&self.output_texture
}
/// Returns the DoF output view.
pub fn output_view(&self) -> &TextureView {
&self.output_view
}
pub fn coc_view(&self) -> &TextureView {
&self.coc_view
}
pub fn coc_pipeline(&self) -> &RenderPipeline {
&self.coc_pipeline
}
pub fn blur_pipeline(&self) -> &RenderPipeline {
&self.blur_pipeline
}
pub fn coc_bind_group(&self) -> &BindGroup {
&self.coc_bind_group
}
pub fn blur_bind_group(&self) -> &BindGroup {
&self.blur_bind_group
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_new_clamps_aperture() {
let c = DoFConfig::new(5.0, 2.0, 8.0);
assert_eq!(c.aperture, 1.0);
assert_eq!(c.focus_distance, 5.0);
assert_eq!(c.max_blur, 8.0);
}
#[test]
fn config_new_clamps_negative_aperture() {
let c = DoFConfig::new(5.0, -1.0, 8.0);
assert_eq!(c.aperture, 0.0);
}
#[test]
fn cinematic_preset() {
let c = DoFConfig::cinematic(5.0);
assert_eq!(c.focus_distance, 5.0);
assert!((c.aperture - 0.3).abs() < f32::EPSILON);
assert!((c.max_blur - 12.0).abs() < f32::EPSILON);
}
#[test]
fn subtle_preset() {
let c = DoFConfig::subtle(3.0);
assert_eq!(c.focus_distance, 3.0);
assert!((c.aperture - 0.1).abs() < f32::EPSILON);
assert!((c.max_blur - 8.0).abs() < f32::EPSILON);
}
#[test]
fn pack_layout() {
let c = DoFConfig::new(5.0, 0.5, 8.0);
let (a, b) = c.pack(0.1, 100.0, 1.0 / 1920.0, 1.0 / 1080.0);
assert!((a.x - 5.0).abs() < f32::EPSILON);
assert!((a.y - 0.5).abs() < f32::EPSILON);
assert!((a.z - 8.0).abs() < f32::EPSILON);
assert!((a.w - 0.1).abs() < f32::EPSILON);
assert!((b.x - 100.0).abs() < f32::EPSILON);
assert!((b.y - 1.0 / 1920.0).abs() < f32::EPSILON);
assert!((b.z - 1.0 / 1080.0).abs() < f32::EPSILON);
assert_eq!(b.w, 0.0);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! # Fog Module (Étape 25)
//!
//! Distance fog: fades objects into a background color based on their distance
//! from the camera. Primary use case: masking the edge of the rendered world
//! to create the illusion of an infinite scene.
//!
//! Three modes are supported:
//! - **Linear**: hard cutoff between `near` and `far` distances
//! - **Exponential**: gradual falloff `exp(-density * d)`
//! - **Exponential²**: sharper cutoff `exp(-density² * d²)` — best for masking
//!
//! Zero cost when disabled: `fog_enabled = 0` → the shader branch is never taken.
/// Fog attenuation mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FogMode {
/// Linear fade between `near` and `far` distances.
Linear,
/// Exponential falloff: `exp(-density * distance)`.
#[default]
Exponential,
/// Exponential squared: `exp(-density² * distance²)`. Sharper cutoff.
Exponential2,
}
impl FogMode {
/// Numeric value written to the GPU uniform (0 = linear, 1 = exp, 2 = exp²).
pub fn as_f32(self) -> f32 {
match self {
FogMode::Linear => 0.0,
FogMode::Exponential => 1.0,
FogMode::Exponential2 => 2.0,
}
}
}
/// Fog configuration for the scene.
///
/// When not set (no `.with_fog()` call), the renderer writes `fog_enabled = 0`
/// and the shader skips the fog block entirely — zero GPU cost.
#[derive(Clone, Copy, Debug)]
pub struct FogConfig {
/// Attenuation mode (linear / exp / exp²).
pub mode: FogMode,
/// Fog color (RGB, linear space). Should match the sky/clear color for
/// a seamless "infinite world" illusion.
pub color: [f32; 3],
/// Near distance (Linear mode only). Fog starts at this distance.
pub near: f32,
/// Far distance (Linear mode only). Fully fogged at this distance.
pub far: f32,
/// Density (Exponential / Exponential² modes). Higher = thicker fog.
/// Typical range: 0.01 (very thin) to 0.3 (very dense).
pub density: f32,
}
impl FogConfig {
/// Linear fog: fades from `near` to `far` distance.
pub fn linear(color: [f32; 3], near: f32, far: f32) -> Self {
Self {
mode: FogMode::Linear,
color,
near,
far,
density: 0.0,
}
}
/// Exponential fog: `factor = exp(-density * distance)`.
/// Natural-looking fog (forest, lake, atmosphere).
pub fn exponential(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Exponential² fog: `factor = exp(-density² * distance²)`.
/// Gradual start, sharp cutoff — ideal for masking world edges.
pub fn exponential2(color: [f32; 3], density: f32) -> Self {
Self {
mode: FogMode::Exponential2,
color,
near: 0.0,
far: 0.0,
density,
}
}
/// Pack into two `Vec4`s for the GPU uniform buffer.
/// - `a` = (enabled, mode, near, far)
/// - `b` = (density, color_r, color_g, color_b)
pub fn pack(&self, enabled: bool) -> (glam::Vec4, glam::Vec4) {
(
glam::Vec4::new(
if enabled { 1.0 } else { 0.0 },
self.mode.as_f32(),
self.near,
self.far,
),
glam::Vec4::new(
self.density,
self.color[0],
self.color[1],
self.color[2],
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_as_f32() {
assert_eq!(FogMode::Linear.as_f32(), 0.0);
assert_eq!(FogMode::Exponential.as_f32(), 1.0);
assert_eq!(FogMode::Exponential2.as_f32(), 2.0);
}
#[test]
fn linear_pack() {
let cfg = FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0);
let (a, b) = cfg.pack(true);
assert_eq!(a, glam::Vec4::new(1.0, 0.0, 5.0, 50.0));
assert_eq!(b, glam::Vec4::new(0.0, 0.7, 0.8, 0.9));
}
#[test]
fn exp2_pack_disabled() {
let cfg = FogConfig::exponential2([1.0, 1.0, 1.0], 0.1);
let (a, b) = cfg.pack(false);
assert_eq!(a.x, 0.0); // disabled
assert_eq!(a.y, 2.0); // exp² mode
assert_eq!(b.x, 0.1); // density
}
}
+4
View File
@@ -11,6 +11,8 @@
pub mod bloom;
pub mod context;
pub mod dof;
pub mod fog;
pub mod frame;
pub mod frustum;
pub mod geometry;
@@ -24,6 +26,8 @@ pub mod transform;
// Re-exports
pub use bloom::BloomConfig;
pub use context::Context;
pub use dof::DoFConfig;
pub use fog::{FogConfig, FogMode};
pub use frame::Frame;
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
+134 -6
View File
@@ -170,6 +170,12 @@ pub struct Renderer {
msaa_depth_texture: Option<wgpu::Texture>,
/// MSAA depth view used as the main pass depth attachment when MSAA is active.
msaa_depth_view: Option<wgpu::TextureView>,
/// Fog configuration (Étape 25). `None` = fog disabled (zero overhead).
fog: Option<super::fog::FogConfig>,
/// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead).
dof: Option<super::dof::DoFConfig>,
/// DoF pipeline (Étape 26). Present only when DoF + HDR are both active.
dof_pipeline: Option<super::dof::DoFPipeline>,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
@@ -213,6 +219,8 @@ impl Renderer {
hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
msaa_config: Option<MsaaConfig>,
fog: Option<super::fog::FogConfig>,
dof_config: Option<super::dof::DoFConfig>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
@@ -220,7 +228,7 @@ impl Renderer {
// Step 9 (DRAFT 9.1): depth texture + view, allocated once at the initial surface
// size (D3). The isolated helper keeps the Phase 4.4 recreate trivial.
let (depth_texture, depth_view) = create_depth_texture(&device, width, height);
let (depth_texture, depth_view) = create_depth_texture(&device, width, height, dof_config.is_some());
// Shared frame uniforms: identity camera + white directional light, lit mode by default.
// Values become meaningful once an active camera is wired (Step 4.3); for now the default
@@ -606,11 +614,16 @@ impl Renderer {
hdr: None,
bloom: None,
bloom_config: bloom_config.clone().unwrap_or_default(),
msaa_config: msaa_config.clone().unwrap_or_default(),
// When MSAA is not requested (None), store sample_count=1 (disabled).
// Using `Default` here would give 4 and incorrectly trigger MSAA allocation.
msaa_config: msaa_config.unwrap_or(MsaaConfig { sample_count: 1 }),
msaa_color_texture: None,
msaa_color_view: None,
msaa_depth_texture: None,
msaa_depth_view: None,
fog,
dof: dof_config,
dof_pipeline: None,
};
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
@@ -657,6 +670,30 @@ impl Renderer {
renderer.msaa_depth_texture = Some(msaa_depth_tex);
renderer.msaa_depth_view = Some(msaa_depth_view);
}
// Étape 26: allocate the DoF pipeline when DoF + HDR are both active.
if renderer.dof.is_some() {
if let Some(hdr) = &mut renderer.hdr {
// The color source for DoF is the HDR texture (or bloom composite if bloom is active).
let color_tex: &wgpu::Texture = if let Some(bloom) = &renderer.bloom {
bloom.composite_texture()
} else {
&hdr.texture
};
let color_view = color_tex.create_view(&Default::default());
let dof_pipe = super::dof::DoFPipeline::new(
&renderer.device, width, height, &renderer.depth_view, &color_view,
);
// Recreate the TM bind group to read from the DoF output texture.
let (bg, _buf) = create_hdr_bind_group(
&renderer.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
);
hdr.bind_group = bg;
renderer.dof_pipeline = Some(dof_pipe);
} else {
eprintln!("[WSG] DoF requires HDR: call with_hdr() before with_dof(). DoF disabled.");
renderer.dof = None;
}
}
renderer
}
@@ -686,12 +723,26 @@ impl Renderer {
self.write_default_frame_uniforms();
}
/// Sets the fog configuration at runtime (Étape 25). `None` disables fog.
/// Takes effect on the next `render_scene` call.
pub fn set_fog(&mut self, fog: Option<super::fog::FogConfig>) {
self.fog = fog;
}
/// Sets the DoF configuration at runtime (Étape 26). `None` disables DoF.
/// Only effective when DoF was enabled at construction (pipeline already allocated).
pub fn set_dof(&mut self, config: Option<super::dof::DoFConfig>) {
if self.dof_pipeline.is_some() {
self.dof = config;
}
}
/// Recreates the depth texture at a new size, used on window resize (ROADMAP Phase 4.4).
/// The previous depth texture is dropped when its field is replaced — no leak, no double
/// allocation. The helper `create_depth_texture` (Step 9, D3) is reused so the recreate stays
/// trivial. Inputs: width/height — the new surface dimensions in pixels.
pub fn resize_depth(&mut self, width: u32, height: u32) {
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height, self.dof_pipeline.is_some());
self._depth_texture = depth_texture;
self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
@@ -744,6 +795,23 @@ impl Renderer {
self.msaa_depth_texture = Some(msaa_depth_tex);
self.msaa_depth_view = Some(msaa_depth_view);
}
// Étape 26: resize DoF textures + re-point TM bind group at the DoF output.
if self.dof_pipeline.is_some() {
if let Some(hdr) = &mut self.hdr {
let color_tex: &wgpu::Texture = if let Some(bloom) = &self.bloom {
bloom.composite_texture()
} else {
&hdr.texture
};
let color_view = color_tex.create_view(&Default::default());
let dof_pipe = self.dof_pipeline.as_mut().unwrap();
dof_pipe.resize(&self.device, width, height, &self.depth_view, &color_view);
let (bg, _buf) = create_hdr_bind_group(
&self.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height,
);
hdr.bind_group = bg;
}
}
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -801,6 +869,9 @@ impl Renderer {
light_view_proj,
shadow_params,
options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0],
// Étape 25: fog params (disabled by default → fog_a.x = 0).
fog_a: self.fog.as_ref().map(|f| f.pack(true).0).unwrap_or(glam::Vec4::ZERO),
fog_b: self.fog.as_ref().map(|f| f.pack(true).1).unwrap_or(glam::Vec4::ZERO),
};
self.queue
.write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame));
@@ -1168,9 +1239,61 @@ impl Renderer {
bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config);
}
// 8d. Étape 26: DoF passes (CoC → Blur).
// Only runs when DoF + HDR are active and DoF config is set.
// The DoF output texture becomes the input to the TM pass.
if let Some(dof_pipe) = &self.dof_pipeline {
if let Some(dof_cfg) = &self.dof {
// Update the shared uniform buffer.
dof_pipe.update_uniform(&self.queue, dof_cfg, 0.1, 100.0);
// Pass 1: CoC (depth → R16Float radius texture).
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("dof coc pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dof_pipe.coc_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_pipeline(dof_pipe.coc_pipeline());
pass.set_bind_group(0, dof_pipe.coc_bind_group(), &[]);
pass.draw(0..3, 0..1);
}
// Pass 2: Blur (color + CoC → blurred Rgba16Float output).
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("dof blur pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dof_pipe.output_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_pipeline(dof_pipe.blur_pipeline());
pass.set_bind_group(0, dof_pipe.blur_bind_group(), &[]);
pass.draw(0..3, 0..1);
}
}
}
// 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.
// texture (or the bloom composite when bloom is active, or DoF output when DoF 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"),
@@ -1577,7 +1700,12 @@ fn create_depth_texture(
device: &wgpu::Device,
width: u32,
height: u32,
texturable: bool,
) -> (wgpu::Texture, wgpu::TextureView) {
let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
if texturable {
usage |= wgpu::TextureUsages::TEXTURE_BINDING;
}
let depth_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth texture"),
size: wgpu::Extent3d {
@@ -1589,7 +1717,7 @@ fn create_depth_texture(
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
usage,
view_formats: &[],
});
let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
+1
View File
@@ -61,6 +61,7 @@ pub use crate::core::ToneMapper;
/// Re-export of the MSAA configuration for convenient top-level access.
/// Users enable MSAA via `AppBuilder::with_msaa(4)`.
pub use crate::core::MsaaConfig;
pub use crate::core::{DoFConfig, FogConfig, FogMode};
/// Re-export of the geometry data type (positions, normals, UVs, indices).
pub use crate::core::Geometry;
+1 -1
View File
@@ -15,7 +15,7 @@
// Core types
pub use crate::core::geometry::{BBox, Geometry};
pub use crate::core::transform::Transform;
pub use crate::core::{BloomConfig, MsaaConfig, ShadowConfig, ToneMapper};
pub use crate::core::{BloomConfig, DoFConfig, FogConfig, FogMode, MsaaConfig, ShadowConfig, ToneMapper};
pub use crate::resources::Material;
// Camera
+24 -4
View File
@@ -100,6 +100,11 @@ pub struct FrameUniforms {
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
pub options: [u32; 4],
/// Fog params A (Étape 25): x = enabled (0/1), y = mode (0=linear, 1=exp, 2=exp²),
/// z = near (linear), w = far (linear).
pub fog_a: Vec4,
/// Fog params B (Étape 25): x = density (exp/exp²), y/z/w = fog color RGB.
pub fog_b: Vec4,
}
impl Default for FrameUniforms {
@@ -126,6 +131,9 @@ impl Default for FrameUniforms {
light_view_proj: Mat4::IDENTITY,
shadow_params: Vec4::ZERO,
options: [0, 0, 0, 0],
// Fog disabled by default (Étape 25): enabled=0 → shader branch skipped.
fog_a: Vec4::ZERO,
fog_b: Vec4::ZERO,
}
}
}
@@ -443,9 +451,10 @@ mod tests {
// The offsets below must match the offset table in standard_shader.wgsl.
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
assert_eq!(size_of::<FrameUniforms>(), 784);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
// Fog (Étape 25): fog_a (16) + fog_b (16) = 32 bytes.
// Total = 160 + 64·8 + 16 + 112 + 32 = 816 bytes.
assert_eq!(size_of::<FrameUniforms>(), 816);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112 + 32);
assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default();
@@ -482,13 +491,24 @@ mod tests {
offset_of!(FrameUniforms, options),
160 + 64 * MAX_LIGHTS + 96
);
// Étape 25: fog fields at the end (two vec4 = 32 bytes).
assert_eq!(
offset_of!(FrameUniforms, fog_a),
160 + 64 * MAX_LIGHTS + 112
);
assert_eq!(
offset_of!(FrameUniforms, fog_b),
160 + 64 * MAX_LIGHTS + 128
);
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
// shadows off (sentinel = MAX_LIGHTS).
// shadows off (sentinel = MAX_LIGHTS), fog disabled (all zeros).
assert_eq!(f.options[0], 0);
assert_eq!(f.num_directional, 1);
assert_eq!(f.num_point, 0);
assert_eq!(f.num_spot, 0);
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
assert_eq!(f.fog_a, glam::Vec4::ZERO);
assert_eq!(f.fog_b, glam::Vec4::ZERO);
}
#[test]
+74
View File
@@ -0,0 +1,74 @@
// DoF Blur pass (Étape 26)
// Reads the HDR color texture + CoC texture, applies a 12-tap disc blur with
// per-pixel variable radius (from CoC), and writes the blurred result.
// Output: Rgba16Float texture (full-res, HDR).
struct DoFUniform {
focus_distance: f32,
aperture: f32,
max_blur: f32,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
pad: f32,
};
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var color_tex: texture_2d<f32>;
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
@group(0) @binding(3) var s: sampler;
// 12-tap disc pattern (Poisson-disc-like) for natural bokeh.
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
vec2( 0.000, 0.000), // center
vec2( 0.000, 1.000), // top
vec2( 1.000, 0.000), // right
vec2( 0.000, -1.000), // bottom
vec2(-1.000, 0.000), // left
vec2( 0.707, 0.707), // top-right diagonal
vec2( 0.707, -0.707), // bottom-right diagonal
vec2(-0.707, 0.707), // top-left diagonal
vec2(-0.707, -0.707), // bottom-left diagonal
vec2( 0.383, 0.924), // upper ring
vec2(-0.383, 0.924), // upper ring
vec2( 0.383, -0.924), // lower ring
);
// Fullscreen triangle (same as TM): top-left origin.
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
@fragment
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
// UV from fragment pixel position (same pattern as TM shader).
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
let coc = textureSample(coc_tex, s, uv).r;
// Below 0.5px: no visible blur, skip for performance.
if (coc < 0.5) {
return textureSample(color_tex, s, uv);
}
// Variable-radius disc blur.
let texel = vec2(u.inv_width, u.inv_height);
var sum = vec4<f32>(0.0);
for (var i = 0u; i < 12u; i++) {
let offset = TAPS[i] * coc * texel;
sum += textureSample(color_tex, s, uv + offset);
}
return sum / 12.0;
}
+58
View File
@@ -0,0 +1,58 @@
// DoF Circle-of-Confusion pass (Étape 26)
// Reads the scene depth buffer, linearizes it to world distance, and computes
// a per-pixel blur radius (in pixels) based on the DoF parameters.
// Output: R16Float texture (single channel = CoC radius in pixels).
struct DoFUniform {
focus_distance: f32,
aperture: f32,
max_blur: f32,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
pad: f32,
};
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var depth_tex: texture_depth_2d;
@group(0) @binding(2) var s: sampler;
// Fullscreen triangle (same as TM): top-left origin.
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
switch vid {
case 0u {
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
}
case 1u {
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
}
default {
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
}
}
}
@fragment
fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) f32 {
// UV from fragment pixel position (same pattern as TM shader).
let uv = frag_pos.xy * vec2(u.inv_width, u.inv_height);
let ndc_z = textureSample(depth_tex, s, uv);
// Linearize: NDC depth [0,1] → world distance (perspective projection)
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
// CoC in pixels: proportional to |dist - focus_distance|
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
/ max(u.focus_distance, 1e-4);
coc = min(coc, u.max_blur);
// Far plane (depth ≈ 1.0) → no blur (sky/background)
if (ndc_z >= 0.9999) {
coc = 0.0;
}
return coc;
}
+31 -3
View File
@@ -8,7 +8,7 @@
//!
//! ## Uniform Contract
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes]
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow + fog) [816 bytes]
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
@@ -93,6 +93,8 @@ struct FrameUniforms {
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
fog_a: vec4<f32>, // .x=enabled .y=mode .z=near .w=far (Étape 25)
fog_b: vec4<f32>, // .x=density .y/.z/.w=fog color RGB (Étape 25)
};
struct ObjectUniform {
@@ -151,7 +153,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
if (frame.options.x != 0u) {
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(base + emissive_contrib, in.color.a);
let final_rgb = base + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
let n = normalize(in.normal);
@@ -208,7 +211,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// É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);
let final_rgb = lit + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
// Étape 25 : distance fog. Blends the final color toward the fog color based on the
// fragment's distance from the camera. Three modes: linear, exponential, exponential².
// When `fog_a.x == 0` (disabled), returns the input color unchanged — zero cost.
fn apply_fog(color: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
if (frame.fog_a.x < 0.5) {
return color;
}
let dist = length(world_pos - frame.cam_pos.xyz);
var fog_factor: f32;
if (frame.fog_a.y < 0.5) {
// Linear: 1.0 at near, 0.0 at far.
fog_factor = saturate((frame.fog_a.w - dist) / max(frame.fog_a.w - frame.fog_a.z, 1e-4));
} else if (frame.fog_a.y < 1.5) {
// Exponential: exp(-density * distance).
fog_factor = exp(-frame.fog_b.x * dist);
} else {
// Exponential²: exp(-density² * distance²) — sharper cutoff.
let d2 = frame.fog_b.x * frame.fog_b.x;
fog_factor = exp(-d2 * dist * dist);
}
let fog_color = frame.fog_b.yzw;
return mix(color, fog_color, 1.0 - fog_factor);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
+6
View File
@@ -58,6 +58,12 @@ pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl");
/// 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");
/// DoF circle-of-confusion shader (Étape 26).
pub const DOF_COC_SHADER: &str = include_str!("../shaders/dof_coc.wgsl");
/// DoF blur shader (Étape 26).
pub const DOF_BLUR_SHADER: &str = include_str!("../shaders/dof_blur.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.