This commit is contained in:
Jérôme Bousquié
2026-09-21 10:07:32 +02:00
parent 4bfd712496
commit 5ae978da23
49 changed files with 1640 additions and 611 deletions
+40 -40
View File
@@ -13,10 +13,10 @@
//! - **material**: provides the RenderPipeline reference via set_pipeline during draw.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Phase d'Exécution**: Renderer executes per-frame render loops. During this phase it iterates Scene entities
//! - **Execution Phase**: Renderer executes per-frame render loops. During this phase it iterates Scene entities
//! and draws each one by binding the appropriate Material+Mesh pair.
//! - **Performance**: Entity sorting within the render loop minimizes pipeline switches (batching par matériau).
//! - **Accès Bas-Niveau**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths.
//! - **Performance**: Entity sorting within the render loop minimizes pipeline switches (batching by material).
//! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths.
use crate::core::Context;
use crate::core::Frame;
@@ -41,7 +41,7 @@ use std::collections::HashMap;
/// plus the surface texture format. Executes WGPU rendering commands by binding Materials and Meshes
/// into RenderPasses during each frame. Does not own raw hardware resources (they are Arc-cloned from Context).
///
/// Since Étape 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the
/// Since Step 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the
/// Renderer owns the matching GPU buffers and `BindGroup`s and binds them around every draw call.
pub struct Renderer {
/// GPU command submission queue — holds an Arc clone from Context; shared with other Context users.
@@ -50,7 +50,7 @@ pub struct Renderer {
device: wgpu::Device,
/// Surface texture output format — stored here so it can be passed to PipelineCache on Material creation.
format: wgpu::TextureFormat,
/// z-buffer texture backing `depth_view` (Étape 9). Held here only to keep the GPU resource
/// z-buffer texture backing `depth_view` (Step 9). Held here only to keep the GPU resource
/// alive for the whole application lifetime (a `TextureView` alone does not guarantee the
/// underlying `Texture` stays valid in wgpu). Not read directly (hence `_` prefix → no
/// `dead_code`); reused when the depth texture is recreated at resize (ROADMAP Phase 4.4).
@@ -73,9 +73,9 @@ pub struct Renderer {
object_cache: RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>,
/// Flat (unlit) rendering flag, exposed via [`Renderer::set_unlit`]. When true, `options.x` of the
/// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D
/// rendering is thus a special case of the 3D lit path (DRAFT Étape 5). Defaults to `false` (lit).
/// rendering is thus a special case of the 3D lit path (DRAFT Step 5). Defaults to `false` (lit).
unlit: bool,
// Étape 14 (DRAFT 3.2) — shadow mapping resources, owned by the Renderer like the depth texture.
// Step 14 (DRAFT 3.2) — shadow mapping resources, owned by the Renderer like the depth texture.
/// Backing GPU shadow-map texture (D2), kept alive for the whole application lifetime. Sized
/// `SHADOW_MAP_SIZE²`, `DEPTH_FORMAT`, used as the shadow pass depth attachment **and** bound
/// for sampling in the main pass (`RENDER_ATTACHMENT | TEXTURE_BINDING`).
@@ -98,7 +98,7 @@ impl Renderer {
/// the surface format, and allocates the shared frame + object uniform buffers and their bind groups.
/// Inputs: context (borrowed reference to Context providing GPU resource handles), format (surface
/// texture format), width (surface width in pixels) and height (surface height in pixels) — the
/// latter two size the depth texture allocated here (Étape 9).
/// latter two size the depth texture allocated here (Step 9).
/// Returns a new Renderer instance sharing the same underlying GPU resources as Context.
/// Called once at application startup during scene setup. The Renderer shares these resources via Arc;
/// Context retains ownership and can continue using them after this call.
@@ -107,12 +107,12 @@ impl Renderer {
let device: wgpu::Device = context.device.clone();
let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device);
// Étape 9 (DRAFT 9.1) : depth texture + view, allouées une seule fois à la taille initiale
// de la surface (D3). Le helper isolé rendra trivial le recreate planifié en Phase 4.4.
// 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);
// Shared frame uniforms: identity camera + white directional light, lit mode by default.
// Values become meaningful once an active camera is wired (Étape 4.3); for now the default
// Values become meaningful once an active camera is wired (Step 4.3); for now the default
// is a coherent scene when a shader actually reads them, and irrelevant to shaders that don't.
let frame_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("frame uniform buffer"),
@@ -149,7 +149,7 @@ impl Renderer {
}],
});
// Étape 14 (DRAFT 3.2) : shadow mapping resources — shadow map texture/view, comparison
// Step 14 (DRAFT 3.2): shadow mapping resources — shadow map texture/view, comparison
// sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the
// depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8).
let (shadow_texture, shadow_view) = create_shadow_map(&device, SHADOW_MAP_SIZE);
@@ -169,7 +169,7 @@ impl Renderer {
// compare function holds for `compare_op(depth_ref, sampled)`, so `LessEqual` is the
// correct choice: `depth_ref (= current_depth - bias) <= stored_depth` → lit. Using
// `GreaterEqual` here inverts the test (shadowed regions render lit, directly-lit
// surfaces self-shadow to black) — the regression seen in the Étape 14 `shadow_test`.
// surfaces self-shadow to black) — the regression seen in the Step 14 `shadow_test`.
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
});
@@ -236,7 +236,7 @@ impl Renderer {
fn write_default_frame_uniforms(&self) {
let frame = FrameUniforms {
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0],
// Étape 14 (D2) : no active shadow caster in the low-level path — sentinel index
// Step 14 (D2): no active shadow caster in the low-level path — sentinel index
// MAX_LIGHTS disables the shadow term in the shader even if options.y were set.
shadow_light_index: MAX_LIGHTS as u32,
..FrameUniforms::default()
@@ -246,9 +246,9 @@ impl Renderer {
}
/// Toggles flat (unlit) rendering. When true, the `standard` shader returns vertex colors as-is
/// (`options.x = 1`), so flat 2D rendering is a special case of the 3D lit path (DRAFT Étape 5 :
/// « 2D ⊂ 3D »). Rewrites the shared frame buffer immediately so the low-level `render` path picks
/// up the change ; the `render_scene` path reads the flag each frame in `write_frame_uniforms`.
/// (`options.x = 1`), so flat 2D rendering is a special case of the 3D lit path (DRAFT Step 5:
/// "2D ⊂ 3D"). Rewrites the shared frame buffer immediately so the low-level `render` path picks
/// up the change; the `render_scene` path reads the flag each frame in `write_frame_uniforms`.
/// Inputs: unlit — true for flat rendering, false (default) for Phong-lit rendering.
pub fn set_unlit(&mut self, unlit: bool) {
self.unlit = unlit;
@@ -257,7 +257,7 @@ impl Renderer {
/// 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` (Étape 9, D3) is reused so the recreate stays
/// 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);
@@ -275,7 +275,7 @@ impl Renderer {
/// Rewrites the shared per-frame uniform buffer from the scene's active camera, its global
/// light list, its ambient color, and the current viewport aspect, then returns the frame bind
/// group wired to that buffer. Called at the start of every `render_scene` so the GPU sees the
/// latest camera matrices, camera position, and lighting (Étape 4.3, Étapes 12–13).
/// latest camera matrices, camera position, and lighting (Step 4.3, Steps 12–13).
///
/// The light array is packed via `Lights::into_frame_array` (directionals first, then point,
/// then spot lights). Inputs: camera (the scene's active camera), lights (the scene's global
@@ -291,7 +291,7 @@ impl Renderer {
shadow_caster: Option<usize>,
) {
let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array();
// Étape 14 (DRAFT 3.2) : derive the shadow light's view_proj and shadow flags (D3).
// Step 14 (DRAFT 3.2): derive the shadow light's view_proj and shadow flags (D3).
let (shadow_light_index, light_view_proj, shadow_params, shadow_on) =
match self.shadow_light_view_proj(lights, shadow_caster) {
Some((index, vp)) => (
@@ -404,8 +404,8 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})],
// Étape 9 (DRAFT 9.2) : depth attachment via la view partagée (D1 : clear 1.0
// = profondeur max au loin en début de frame, puis Store pour la garder).
// Step 9 (DRAFT 9.2): depth attachment via the shared view (D1: clear 1.0
// = max depth far away at frame start, then Store to keep it).
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
@@ -438,7 +438,7 @@ impl Renderer {
/// projection.
///
/// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU
/// receives the active camera's view/projection matrices and position for this frame (Étape 4.3).
/// receives the active camera's view/projection matrices and position for this frame (Step 4.3).
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
self.write_frame_uniforms(
scene.camera(),
@@ -454,7 +454,7 @@ impl Renderer {
label: Some("scene encoder"),
});
// Étape 14 (DRAFT 3.2) : run the depth-only shadow pass first when a light is configured to
// Step 14 (DRAFT 3.2): run the depth-only shadow pass first when a light is configured to
// cast shadows (D4). It populates `shadow_view` on the shared encoder; the main pass below
// then samples it via `shadow_bind_group`. `render_shadow_map` no-ops when shadows are off.
self.render_shadow_map(&mut encoder, scene);
@@ -471,8 +471,8 @@ impl Renderer {
store: wgpu::StoreOp::Store,
},
})],
// Étape 9 (DRAFT 9.2) : même depth attachment que le chemin bas niveau, pour un
// z-test cohérent (D2 — les deux render passes partagent la depth_view).
// 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,
depth_ops: Some(wgpu::Operations {
@@ -484,7 +484,7 @@ impl Renderer {
..Default::default()
});
// Étape 7 (DRAFT 7.3.4) : the Material is resolved from the Mesh itself, falling back
// Step 7 (DRAFT 7.3.4): the Material is resolved from the Mesh itself, falling back
// to the Scene's default material when the mesh carries none.
for (label, mesh, transform) in scene.iter_entities() {
let material = mesh
@@ -506,7 +506,7 @@ impl Renderer {
}
/// Renders every entity of `scene` from the shadow-casting light's point of view into the
/// shadow depth map (Étape 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at
/// shadow depth map (Step 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at
/// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is
/// `None`. The shadow light's `view_proj` is written to `shadow_uniform_buffer`, and the shadow
/// pass writes depth into `shadow_view` (clear 1.0, store). The per-entity model bind groups are
@@ -533,7 +533,7 @@ impl Renderer {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shadow map render pass"),
color_attachments: &[],
// Depth-only : the shadow map is the sole attachment. Clear 1.0 so fragments beyond
// Depth-only: the shadow map is the sole attachment. Clear 1.0 so fragments beyond
// `far` read as "fully distant" and never occlude lit surfaces (D4).
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.shadow_view,
@@ -546,11 +546,11 @@ impl Renderer {
..Default::default()
});
pass.set_pipeline(&self.shadow_pipeline);
// Group 0 : the shadow light view_proj (D4) — the shadow pipeline's only uniform group.
// Group 0: the shadow light view_proj (D4) — the shadow pipeline's only uniform group.
pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]);
for (label, mesh, transform) in scene.iter_entities() {
let object_bind_group = self.object_bind_group_for(label, transform);
// Group 1 : per-entity model. The shadow pipeline has no texture/sampler groups.
// Group 1: per-entity model. The shadow pipeline has no texture/sampler groups.
pass.set_bind_group(1, &object_bind_group, &[]);
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer {
@@ -585,7 +585,7 @@ impl Renderer {
/// Returns the per-entity object bind group for `label`, creating its uniform buffer on first
/// encounter and rewriting the model matrix each call. Since `render_scene(&self)` is immutable,
/// the lazily-populated cache is interior-mutable (`RefCell`). Étape 4.2.
/// the lazily-populated cache is interior-mutable (`RefCell`). Step 4.2.
/// Inputs: label (entity identifier used as cache key), transform (world placement to upload).
/// Returns an owned (cheaply Arc-cloned) reference handle to the object bind group (group 1).
fn object_bind_group_for(&self, label: &str, transform: &Transform) -> wgpu::BindGroup {
@@ -618,7 +618,7 @@ impl Renderer {
}
/// Allocates the depth texture + view backing the render passes' `depth_stencil_attachment`
/// (Étape 9, DRAFT 9.1). Format is the shared `DEPTH_FORMAT` (Depth32Float, D1) so it always
/// (Step 9, DRAFT 9.1). Format is the shared `DEPTH_FORMAT` (Depth32Float, D1) so it always
/// matches every pipeline's `DepthStencilState`. Sized to the surface (width x height), single
/// mip, no MSAA, used strictly as a render target.
///
@@ -650,7 +650,7 @@ fn create_depth_texture(
}
/// Allocates the shadow-map texture + view backing the depth-only shadow pass's
/// `depth_stencil_attachment` (Étape 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single
/// `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`
/// (shadow pass writes depth) **and** `TEXTURE_BINDING` (main pass samples it via the group-3
/// comparison sampler). Allocated once at the default resolution; resizing is deferred (D8).
@@ -678,8 +678,8 @@ fn create_shadow_map(device: &wgpu::Device, size: u32) -> (wgpu::Texture, wgpu::
/// Binds a Material pipeline, the four shared bind groups, and Mesh buffers into an active render
/// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`.
/// The frame (@0), object (@1), texture (@2) and shadow-map (@3) bind groups are **required** by
/// every pipeline layout (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture — Étape 14 :
/// groupe ombre) — they must be bound even if the shader does not read them. Draws indexed geometry
/// every pipeline layout (Step 3: a single layout for all — Step 10: texture group — Step 14:
/// shadow group) — they must be bound even if the shader does not read them. Draws indexed geometry
/// when an index buffer exists, otherwise falls back to a non-indexed draw.
/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline + texture bind
/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/
@@ -700,11 +700,11 @@ fn draw_entity(
pass.set_pipeline(&material.pipeline);
pass.set_bind_group(0, frame_bind_group, &[]);
pass.set_bind_group(1, object_bind_group, &[]);
// Étape 10 (DRAFT 10.4) : groupe texture — le Material possède son bind group (placeholder
// blanc s'il n'a pas de texture, D1/D2). Toujours liable car posé sur toutes les pipelines.
// Step 10 (DRAFT 10.4): texture group — the Material owns its bind group (placeholder
// white if it has no texture, D1/D2). Always bindable since it is attached to every pipeline.
pass.set_bind_group(2, &material.texture_bind_group, &[]);
// Étape 14 : groupe ombre — toujours lié pour rester conforme au layout unifié, que la pipeline
// soit éclairée ou non (le groupe @3 reste requis par toutes les pipelines standards).
// Step 14: shadow group — always bound to stay conformant with the unified layout, whether
// the pipeline is lit or not (group @3 is still required by all standard pipelines).
pass.set_bind_group(3, shadow_bind_group, &[]);
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer {