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
+32 -1
View File
@@ -16,7 +16,8 @@ cargo run -p wsg-lib --example <nom>
| `hdr` | HDR + Tone Mapping (ACES) + contrôle d'exposition |
| `emissive` | Matériaux émissifs (intensités croissantes 0 → 4.0) |
| `shadow` | Shadow mapping (ombre portée directionnelle) |
| `culling` | Culling GPU-driven (grille 20×20, objets hors frustum ignorés) |
| `culling` | Culling GPU-driven (grille 15×15, objets hors frustum ignorés) |
| `msaa` | MSAA 4× (anti-aliasing multi-échantillons, arêtes lisses) |
| `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) |
| `import` | Import de fichier OBJ (non graphique, stdout) |
@@ -220,6 +221,36 @@ cargo run -p wsg-lib --example culling
---
## `msaa` — MSAA 4× (Anti-aliasing)
Démontre l'anti-aliasing multi-échantillons : les arêtes des objets (cube, sphère)
sont lisses au lieu d'être "en escalier". La scène contient un cube (arêtes nettes),
une sphère (silhouette courbe) et un petit cube près de la caméra (aliasing maximal).
```sh
cargo run -p wsg-lib --example msaa
```
### Touches
| Touche | Action |
|--------|--------|
| Glisser (LMB) | Orbiter la caméra |
| Molette | Zoom |
| `R` | Reset caméra |
| `M` | Afficher le nombre d'échantillons |
### Pour comparer avec/sans MSAA
Supprimer la ligne `.with_msaa(4)` dans le source et recompiler : la scène est
identique, seules les arêtes diffèrent (escaler vs lisse).
> **Note** : MSAA est un réglage de build-time (allocation de textures multi-échantillons).
> Il fonctionne indépendamment de HDR : avec HDR, la texture MSAA est `Rgba16Float`
> et résout dans la texture HDR avant bloom/TM.
---
## `manual` — Workflow bas niveau
Démontre l'API **sans** la façade `App` : utilisation directe de `Context`,
+2 -2
View File
@@ -56,14 +56,14 @@ impl ApplicationHandler for App {
// 2. Renderer initialization (it retrieves everything it needs)
let device = Arc::new(context.device.clone());
let mut cache = PipelineCache::new(device, context.queue.clone());
let mut cache = PipelineCache::new(device, context.queue.clone(), 1);
cache
.register_shader("standard", utils::STANDARD_SHADER_PATH)
.unwrap();
// Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by
// draw_entity, the default frame matrix is the identity → NDC positions unchanged).
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None);
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None, None);
renderer.set_unlit(true);
// 3. Material: uses renderer.device() and renderer.format()
+151
View File
@@ -0,0 +1,151 @@
//! **MSAA (Multi-Sample Anti-Aliasing)** — demonstrates 4× MSAA edge smoothing.
//!
//! Shows how MSAA eliminates the jagged "staircase" artifacts (aliasing) along
//! sharp edges. The scene contains a cube (sharp edges), a sphere (curved surface),
//! and a ground plane — all with high-contrast edges where aliasing is most visible.
//!
//! To compare with/without MSAA: remove the `.with_msaa(4)` line from the builder
//! below and rebuild. The scene and lighting are identical — only the edge
//! smoothness differs.
//!
//! ## Pipeline (MSAA + HDR)
//! ```text
//! Main pass → MSAA texture (4 samples, Rgba16Float)
//! ↓ resolve (average 4 samples → 1)
//! HDR texture (single sample)
//! ↓
//! Tone Mapping → surface
//! ```
//!
//! ## Controls
//! | Key | Action |
//! |-----|--------|
//! | Drag (LMB) | Orbit camera |
//! | Wheel | Zoom |
//! | `R` | Reset camera |
//! | `M` | Toggle MSAA info (shows sample count) |
//!
//! ## Build & Run
//! ```sh
//! cargo run -p wsg-lib --example msaa
//! ```
use glam::Vec3;
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::app::AppBuilder;
use wsg_lib::camera::CameraController;
use wsg_lib::core::{Transform, ToneMapper};
use wsg_lib::mesh::{cube, icosphere, plane};
use wsg_lib::AppHandler;
use wsg_lib::utils::WsgError;
struct MsaaDemo {
camera: CameraController,
show_info: bool,
}
impl AppHandler for MsaaDemo {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Ground plane.
app.scene
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// Cube — sharp edges make aliasing very visible.
app.scene
.create_mesh("cube_mesh", cube(1.0), None)
.unwrap();
let mut cube_tf = Transform::identity();
cube_tf.translation = Vec3::new(1.5, 0.5, 0.0);
app.scene
.add_entity_with_transform("cube", "cube_mesh", cube_tf)
.unwrap();
// Sphere — curved surface, aliasing visible on the silhouette.
app.scene
.create_mesh("sphere_mesh", icosphere(0.6, 3), None)
.unwrap();
let mut sphere_tf = Transform::identity();
sphere_tf.translation = Vec3::new(-1.5, 0.6, 0.0);
app.scene
.add_entity_with_transform("sphere", "sphere_mesh", sphere_tf)
.unwrap();
// Small cube near the camera — very close edges, maximum aliasing.
app.scene
.create_mesh("small_cube_mesh", cube(0.3), None)
.unwrap();
let mut small_tf = Transform::identity();
small_tf.translation = Vec3::new(0.0, 0.15, 1.5);
app.scene
.add_entity_with_transform("small_cube", "small_cube_mesh", small_tf)
.unwrap();
// Directional light (strong, creates high-contrast edges).
let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize();
app.scene
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.5)
.unwrap();
app.scene.set_ambient([0.08, 0.08, 0.1]);
// Camera.
self.camera.yaw = 0.4;
self.camera.pitch = 0.2;
self.camera.distance = 4.0;
self.camera.target = Vec3::new(0.0, 0.4, 0.0);
self.camera.apply_to(app.scene.camera_mut());
// Print MSAA status.
let sc = app.renderer().msaa_sample_count();
eprintln!("[MSAA] sample_count = {} ({})", sc, if sc > 1 { "active" } else { "disabled" });
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit camera.
let (dx, dy) = app.input.mouse_delta();
if app.input.mouse_button_held(MouseButton::Left) {
self.camera.orbit(dx, dy);
}
let (_, sy) = app.input.scroll_delta();
self.camera.zoom(sy);
if app.input.key_pressed(KeyCode::KeyR) {
self.camera.yaw = 0.4;
self.camera.pitch = 0.2;
self.camera.distance = 4.0;
}
self.camera.apply_to(app.scene.camera_mut());
// Toggle info display.
if app.input.key_pressed(KeyCode::KeyM) {
self.show_info = !self.show_info;
let sc = app.renderer().msaa_sample_count();
eprintln!("[MSAA] {}× {}", sc, if sc > 1 { "enabled" } else { "disabled (single sample)" });
}
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new()
.title("WSG MSAA 4×")
.size(960, 640)
.with_msaa(4) // ← Enable 4× MSAA (remove for comparison)
.with_hdr(ToneMapper::Aces) // MSAA works with or without HDR
.build()
.await?;
app.run(MsaaDemo {
camera: CameraController::default(),
show_info: false,
})
}
+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,