This commit is contained in:
Jérôme Bousquié
2026-09-25 11:20:20 +02:00
parent 35aeb769a8
commit 9614156848
15 changed files with 822 additions and 333 deletions
+30 -4
View File
@@ -23,7 +23,7 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler;
use crate::core::{BloomConfig, Context, Renderer, ShadowConfig, ToneMapper};
use crate::core::{BloomConfig, Context, MsaaConfig, Renderer, ShadowConfig, ToneMapper};
use crate::input::InputState;
use crate::scene::Scene;
use crate::utils::WsgError;
@@ -70,6 +70,9 @@ pub struct App {
/// 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,
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
/// `Some(config)` activates multi-sample anti-aliasing.
msaa: Option<MsaaConfig>,
/// 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.
@@ -137,6 +140,7 @@ impl App {
hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
handler,
app: None,
};
@@ -211,8 +215,9 @@ impl App {
// Étape 20: when HDR is active, the Scene uses Rgba16Float regardless of the
// surface format, so no re-init is needed on surface format change.
let device = std::sync::Arc::new(self.renderer_mut().device().clone());
let sc = self.renderer_mut().msaa_sample_count();
self.scene
.init_gpu(device, self.context().queue.clone(), new_format);
.init_gpu(device, self.context().queue.clone(), new_format, sc);
}
Ok(())
}
@@ -239,6 +244,8 @@ pub struct AppBuilder {
bloom_config: Option<BloomConfig>,
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
exposure: f32,
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
msaa: Option<MsaaConfig>,
}
impl AppBuilder {
@@ -254,6 +261,7 @@ impl AppBuilder {
hdr: None,
bloom_config: None,
exposure: 1.0,
msaa: None,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -306,6 +314,19 @@ impl AppBuilder {
self.exposure = exposure;
self
}
/// Enables MSAA (Multi-Sample Anti-Aliasing) with the given sample count (Étape 24).
/// The count must be 2, 4, or 8 (validated at build time; invalid values fall back to no MSAA
/// with a warning). When disabled (not set), the renderer uses single-sample (zero overhead).
/// Works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and resolves
/// into the HDR texture before bloom/TM; without HDR, it resolves directly to the swapchain.
pub fn with_msaa(mut self, sample_count: u32) -> Self {
if let Some(reason) = MsaaConfig::validate(sample_count) {
eprintln!("[wsg] Warning: with_msaa({}) — {} — MSAA disabled.", sample_count, reason);
} else {
self.msaa = Some(MsaaConfig { sample_count });
}
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.
@@ -324,6 +345,7 @@ impl AppBuilder {
hdr: self.hdr,
bloom_config: self.bloom_config,
exposure: self.exposure,
msaa: self.msaa,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -352,6 +374,8 @@ struct AppRunner<H: AppHandler> {
bloom_config: Option<BloomConfig>,
/// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use.
exposure: f32,
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
msaa: Option<MsaaConfig>,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -385,7 +409,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());
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone());
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
@@ -399,7 +423,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
format
};
let mut scene = Scene::new();
scene.init_gpu(device, context.queue.clone(), main_format);
let msaa_sc = self.msaa.as_ref().map(|c| c.sample_count).unwrap_or(1);
scene.init_gpu(device, context.queue.clone(), main_format, msaa_sc);
let mut app = App {
scene,
@@ -412,6 +437,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
msaa: self.msaa.clone(),
event_loop: None,
context: Some(context),
renderer: Some(renderer),
+2
View File
@@ -16,6 +16,7 @@ pub mod frustum;
pub mod geometry;
pub mod hdr;
pub mod lod;
pub mod msaa;
pub mod renderer;
pub mod shadow;
pub mod transform;
@@ -28,6 +29,7 @@ pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use hdr::ToneMapper;
pub use lod::{lod_level, projected_radius_px};
pub use msaa::MsaaConfig;
pub use renderer::Renderer;
pub use shadow::ShadowConfig;
pub use transform::Transform;
+65
View File
@@ -0,0 +1,65 @@
//! MSAA (Multi-Sample Anti-Aliasing) configuration (Étape 24, 6.4).
//!
//! When enabled, the main scene pass renders into a multi-sampled texture
//! (N samples per pixel) and wgpu resolves it (averages) into the single-sample
//! target (HDR texture or swapchain). Post-processes (bloom, TM) operate on
//! the resolved single-sample texture — they are unaffected.
//!
//! MSAA is a rasterizer feature: **no new shader** is needed. The cost is
//! in the rasterizer/fill-rate (edges are over-sampled), typically 1.3–1.5×
//! for 4× MSAA.
/// MSAA configuration.
///
/// `sample_count` must be a power of two (2, 4, or 8) and must be supported
/// by the GPU for the target texture format. The default is 4.
///
/// When disabled (not set in the builder), the renderer uses `sample_count = 1`
/// (single sample, no MSAA) and the behavior is identical to pre-MSAA.
#[derive(Clone, Copy, Debug)]
pub struct MsaaConfig {
/// Number of samples per pixel. Must be 2, 4, or 8.
pub sample_count: u32,
}
impl Default for MsaaConfig {
fn default() -> Self {
Self { sample_count: 4 }
}
}
impl MsaaConfig {
/// Validates that `sample_count` is a supported value (2, 4, or 8).
/// Returns `None` if valid, `Some(reason)` if not.
pub fn validate(sample_count: u32) -> Option<&'static str> {
match sample_count {
2 | 4 | 8 => None,
_ => Some("sample_count must be 2, 4, or 8"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_4() {
assert_eq!(MsaaConfig::default().sample_count, 4);
}
#[test]
fn validate_accepts_powers_of_two() {
assert_eq!(MsaaConfig::validate(2), None);
assert_eq!(MsaaConfig::validate(4), None);
assert_eq!(MsaaConfig::validate(8), None);
}
#[test]
fn validate_rejects_invalid() {
assert!(MsaaConfig::validate(1).is_some());
assert!(MsaaConfig::validate(3).is_some());
assert!(MsaaConfig::validate(16).is_some());
assert!(MsaaConfig::validate(0).is_some());
}
}
+122 -8
View File
@@ -40,6 +40,7 @@ use crate::resources::{
use crate::scene::Scene;
use crate::core::bloom::{BloomConfig, BloomPipeline};
use crate::core::hdr::ToneMapper;
use crate::core::msaa::MsaaConfig;
use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER,
};
@@ -159,6 +160,16 @@ pub struct Renderer {
bloom: Option<BloomPipeline>,
/// Bloom configuration (used per-frame for uniform writes). Only meaningful when bloom is active.
bloom_config: BloomConfig,
/// MSAA configuration (Étape 24). `sample_count = 1` means MSAA is disabled (zero overhead).
msaa_config: MsaaConfig,
/// MSAA color texture (N samples). `None` when MSAA is disabled.
msaa_color_texture: Option<wgpu::Texture>,
/// MSAA color view used as the main pass color attachment when MSAA is active.
msaa_color_view: Option<wgpu::TextureView>,
/// MSAA depth texture (N samples). `None` when MSAA is disabled.
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>,
}
/// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline.
@@ -201,6 +212,7 @@ impl Renderer {
shadow_config: &super::shadow::ShadowConfig,
hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
msaa_config: Option<MsaaConfig>,
) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
@@ -594,6 +606,11 @@ impl Renderer {
hdr: None,
bloom: None,
bloom_config: bloom_config.clone().unwrap_or_default(),
msaa_config: msaa_config.clone().unwrap_or_default(),
msaa_color_texture: None,
msaa_color_view: None,
msaa_depth_texture: None,
msaa_depth_view: 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.
@@ -613,6 +630,33 @@ impl Renderer {
renderer.bloom_config = bloom_config.clone().unwrap();
}
}
// Étape 24: allocate MSAA textures when sample_count > 1.
// The MSAA color texture uses the same format as the main target (HDR or surface).
if renderer.msaa_config.sample_count > 1 {
let sc = renderer.msaa_config.sample_count;
let color_format = if renderer.hdr.is_some() {
wgpu::TextureFormat::Rgba16Float
} else {
format
};
let msaa_tex = renderer.device.create_texture(&wgpu::TextureDescriptor {
label: Some("MSAA color texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: sc,
dimension: wgpu::TextureDimension::D2,
format: color_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
let (msaa_depth_tex, msaa_depth_view) =
create_msaa_depth_texture(&renderer.device, width, height, sc);
renderer.msaa_color_texture = Some(msaa_tex);
renderer.msaa_color_view = Some(msaa_view);
renderer.msaa_depth_texture = Some(msaa_depth_tex);
renderer.msaa_depth_view = Some(msaa_depth_view);
}
renderer
}
@@ -674,6 +718,32 @@ impl Renderer {
hdr.bind_group = bg;
}
}
// Étape 24: recreate MSAA textures at the new size.
if self.msaa_config.sample_count > 1 {
let sc = self.msaa_config.sample_count;
let color_format = if self.hdr.is_some() {
wgpu::TextureFormat::Rgba16Float
} else {
self.format
};
let msaa_tex = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("MSAA color texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: sc,
dimension: wgpu::TextureDimension::D2,
format: color_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
let (msaa_depth_tex, msaa_depth_view) =
create_msaa_depth_texture(&self.device, width, height, sc);
self.msaa_color_texture = Some(msaa_tex);
self.msaa_color_view = Some(msaa_view);
self.msaa_depth_texture = Some(msaa_depth_tex);
self.msaa_depth_view = Some(msaa_depth_view);
}
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -963,26 +1033,38 @@ impl Renderer {
// are hoisted out of the slot loop: one per DISTINCT material, not one per entity.
// Étape 20: when HDR is active, the color attachment targets the offscreen HDR texture
// instead of the surface; the TM pass (step 8) then copies it to the surface.
let main_target = match &self.hdr {
Some(h) => &h.view,
None => view,
// Étape 24: when MSAA is active, the color attachment targets the MSAA texture and
// resolves into the single-sample target (HDR or swapchain). The depth is also MSAA.
let (color_view, resolve_target, depth_attach) = if let Some(msaa_view) = &self.msaa_color_view {
// MSAA active: render into MSAA, resolve to single-sample target.
let resolve = match &self.hdr {
Some(h) => Some(h.view.clone()),
None => Some(view.clone()),
};
let depth = self.msaa_depth_view.as_ref().unwrap();
(msaa_view.clone(), resolve, depth)
} else {
// No MSAA: current behavior.
let color = match &self.hdr {
Some(h) => &h.view,
None => view,
};
(color.clone(), None, &self.depth_view)
};
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: main_target,
resolve_target: None,
view: &color_view,
resolve_target: resolve_target.as_ref(),
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
// Step 9 (DRAFT 9.2): same depth attachment as the low-level path, for a
// coherent z-test (D2 — both render passes share the depth_view).
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
view: depth_attach,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
@@ -1406,6 +1488,16 @@ impl Renderer {
self.bloom_config = config.clone();
}
/// Returns `true` if MSAA is active (sample_count > 1). (Étape 24)
pub fn msaa_enabled(&self) -> bool {
self.msaa_config.sample_count > 1
}
/// Returns the current MSAA sample count (1 = disabled). (Étape 24)
pub fn msaa_sample_count(&self) -> u32 {
self.msaa_config.sample_count
}
/// 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
@@ -1504,6 +1596,28 @@ fn create_depth_texture(
(depth_texture, depth_view)
}
/// Creates an MSAA depth texture (N samples) with a view (Étape 24). Used when MSAA is active:
/// the main pass needs a multi-sampled depth buffer matching the MSAA color attachment.
fn create_msaa_depth_texture(
device: &wgpu::Device,
width: u32,
height: u32,
sample_count: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("MSAA depth texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
/// Allocates the shadow-map texture + view backing the depth-only shadow pass's
/// `depth_stencil_attachment` (Step 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single
/// mip, no MSAA. Unlike the screen depth texture this one is flagged **both** `RENDER_ATTACHMENT`
+4
View File
@@ -58,6 +58,10 @@ pub use crate::core::ShadowConfig;
/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`.
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;
/// Re-export of the geometry data type (positions, normals, UVs, indices).
pub use crate::core::Geometry;
+19 -13
View File
@@ -202,6 +202,9 @@ pub struct PipelineCache {
/// White 1×1 placeholder texture bound by materials that have no diffuse texture (DRAFT D1/D2).
/// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look.
placeholder: Arc<Texture>,
/// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's
/// attachment sample count. 1 = no MSAA (default).
sample_count: u32,
}
impl PipelineCache {
@@ -210,7 +213,7 @@ impl PipelineCache {
/// Inputs: device (owned Arc reference to wgpu Device), queue (used once to upload the white
/// placeholder). Returns a new PipelineCache ready for shader registration via register_shader().
/// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first.
pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue) -> Self {
pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue, sample_count: u32) -> Self {
let placeholder = Texture::white_placeholder(&device, &queue).arc();
let texture_bind_group_layout = create_texture_bind_group_layout(&device);
Self {
@@ -221,6 +224,7 @@ impl PipelineCache {
shader_paths: HashMap::new(),
texture_bind_group_layout,
placeholder,
sample_count,
}
}
@@ -305,7 +309,7 @@ impl PipelineCache {
.map(|s| s.as_str())
.unwrap_or(shader_id);
let shader = self.load_shader(&self.device, path);
let pipeline = Self::build_pipeline(&self.device, format, &shader);
let pipeline = self.build_pipeline(format, &shader);
// Step 3: Cache the new pipeline behind Arc and return it
let pipeline_arc = Arc::new(pipeline);
@@ -330,13 +334,11 @@ impl PipelineCache {
}
/// Builds a RenderPipeline from a shader module, device, and surface texture format.
/// Inputs: device (GPU command source), format (output texture format), shader (compiled WGSL module).
/// Inputs: format (output texture format), shader (compiled WGSL module).
/// Uses `self.device` and `self.sample_count` (Étape 24: MSAA-aware compilation).
/// Returns a fully configured RenderPipeline ready for draw calls. Called internally by `get_or_create()`.
/// Internal steps: 1) define VertexBufferLayout from Vertex struct offsets →
/// 2) create PipelineLayout with bind_group_layouts + immediate_size →
/// 3) create RenderPipeline with vertex/fragment states, primitive config, multisample state.
fn build_pipeline(
device: &wgpu::Device,
&self,
format: wgpu::TextureFormat,
shader: &wgpu::ShaderModule,
) -> wgpu::RenderPipeline {
@@ -349,9 +351,9 @@ impl PipelineCache {
// attached to EVERY pipeline (Step 3, decision ratified "a single layout for all"), even
// if a given shader does not read them.
// `immediate_size` stays 0 (no var<immediate> used).
let uniform_layouts = create_uniform_bind_group_layouts(device);
let texture_layout = create_texture_bind_group_layout(device);
let shadow_layout = create_shadow_map_bind_group_layout(device);
let uniform_layouts = create_uniform_bind_group_layouts(&self.device);
let texture_layout = create_texture_bind_group_layout(&self.device);
let shadow_layout = create_shadow_map_bind_group_layout(&self.device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = vec![
Some(&uniform_layouts[0]), // frame @0
Some(&uniform_layouts[1]), // object @1
@@ -359,14 +361,14 @@ impl PipelineCache {
Some(&shadow_layout), // shadow map @3
];
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render_pipeline_layout"),
bind_group_layouts: &layout_refs,
immediate_size: 0, // no var<immediate> used
});
// Create the full RenderPipeline — vertex state + fragment state + primitive configuration.
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Render Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
@@ -399,7 +401,11 @@ impl PipelineCache {
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
// Étape 24: MSAA-aware — the sample count must match the render pass's attachments.
multisample: wgpu::MultisampleState {
count: self.sample_count,
..Default::default()
},
// multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30.
multiview_mask: None,
cache: None,
+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, ShadowConfig, ToneMapper};
pub use crate::core::{BloomConfig, MsaaConfig, ShadowConfig, ToneMapper};
pub use crate::resources::Material;
// Camera
+2 -1
View File
@@ -148,8 +148,9 @@ impl Scene {
device: Arc<wgpu::Device>,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
) -> &mut Self {
let cache = PipelineCache::new(device.clone(), queue.clone());
let cache = PipelineCache::new(device.clone(), queue.clone(), sample_count);
self.gpu = Some(SceneGpu {
device,
format,