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
+2 -1
View File
@@ -9,11 +9,12 @@ cargo run -p wsg-lib --example <name>
| Example | Command | Description |
|---------|---------|-------------|
| `demo` | `cargo run -p wsg-lib --example demo` | **Showcase** (Step 15): one of each primitive, procedural textures, directional + point + spot lights, a shadow-casting light, and a live orbital camera (drag / wheel zoom / `R` reset / `1`-`3` presets). |
| `simple` | `cargo run -p wsg-lib --example simple` | Flat unlit quad (minimal declarative workflow, `AppBuilder` + auto scene). |
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. |
| `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). |
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Spot-light isolation: only one spot is on (near-zero ambient), cube rotates on two axes so the oriented beam is clearly visible. |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping (Étape 14): one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping (Step 14): one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. |
## Conventions
+35 -35
View File
@@ -1,15 +1,15 @@
//! Étape 5 — MVP 3D : un cube unitaire éclairé qui tourne ; **Étape 10** — le cube est **texturé**
//! (damier procédural) via le nouveau chemin diffues (bind group `@group(2)`).
//! Step 5 — MVP 3D: a lit unit cube that rotates; **Step 10** — the cube is **textured**
//! (procedural checkerboard) via the new diffuse path (bind group `@group(2)`).
//!
//! Démonstration de l'objectif MVP du ROADMAP 1.3 + 1.5 : un mesh 3D avec éclairage Phong à l'écran.
//! On suit le workflow déclaratif (comme `simple`) : `AppBuilder` + scène automatique, **sans importer
//! wgpu**. Depuis l'Étape 7 la scène possède son `PipelineCache` : on passe par `register_shader` +
//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. Depuis l'Étape 8 le mesh
//! est déclaré à partir d'une **`Geometry`** (positions, normales, indices). Depuis l'Étape 10 (D4) on
//! enregistre une texture par id (`add_texture`) puis on lie un matériau texturé (`add_material_texture`) ;
//! la texture est générée *procéduralement* (damier RGBA 8×8) pour rester autonome, sans asset sur disque.
//! La caméra active par défaut (`Scene::default`, position (0,0,3), fov 45°) cadre le cube, et
//! `AppHandler::update` fait tourner l'entité via `set_entity_transform` chaque frame.
//! Demonstrates the MVP goal of ROADMAP 1.3 + 1.5: a 3D mesh with Phong lighting on screen.
//! Follows the declarative workflow (like `simple`): `AppBuilder` + automatic scene, **no wgpu import**.
//! Since Step 7 the scene owns its `PipelineCache`: go through `register_shader` +
//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. Since Step 8 the mesh
//! is declared from a **`Geometry`** (positions, normals, indices). Since Step 10 (D4) a texture is
//! registered by id (`add_texture`) and a textured material bound to it (`add_material_texture`);
//! the texture is generated *procedurally* (RGBA 8×8 checkerboard) to stay self-contained, no on-disk asset.
//! The default active camera (`Scene::default`, position (0,0,3), fov 45°) frames the cube, and
//! `AppHandler::update` rotates the entity via `set_entity_transform` each frame.
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
@@ -17,14 +17,14 @@ use wsg_lib::math::cube;
use wsg_lib::resources::Texture;
use wsg_lib::utils::WsgError;
/// Handler de démonstration : fait tourner le cube texturé dans `update`.
/// Demo handler: rotates the textured cube in `update`.
struct Cube {
/// Angle de rotation cumulé (radians), incrémenté à chaque frame.
/// Cumulative rotation angle (radians), incremented each frame.
angle: f32,
}
/// Génère un damier RGBA 8×8 (blanc/brique) *procédural*, sans asset sur disque, pour texturer le
/// cube (Étape 10, D3/D4). Renvoyé en `Vec<u8>` brut RGBA8, chargeable via `Texture::from_rgba8`.
/// Generates a *procedural* RGBA 8×8 checkerboard (white/brick), no on-disk asset, to texture the
/// cube (Step 10, D3/D4). Returned as a raw RGBA8 `Vec<u8>`, loadable via `Texture::from_rgba8`.
fn checkerboard_rgba() -> Vec<u8> {
const SIZE: u32 = 8;
let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
@@ -40,13 +40,13 @@ fn checkerboard_rgba() -> Vec<u8> {
impl AppHandler for Cube {
fn setup(&mut self, app: &mut wsg_lib::App) {
// Shader Phong `standard` (porteur des bind groups frame + object + texture, Étape 10).
// Phong shader `standard` (carries the frame + object + texture bind groups, Step 10).
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
// Construit la texture damier avec le device/queue du Context (via `app.context()`), puis
// l'enregistre dans la scène par id ; on lie ensuite un matériau texturé à cette id.
// Builds the checkerboard texture with the Context's device/queue (via `app.context()`), then
// registers it in the scene by id; a textured material is then bound to that id.
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
@@ -63,35 +63,35 @@ impl AppHandler for Cube {
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
// Étape 12 (Phase 4.2) : en plus de la lumière directionnelle par défaut (+Z), on ajoute
// une lumière **ponctuelle** chaude devant le cube. Son halo (atténuation linéaire dans le
// rayon) est visible sur la face proche du cube, en superposition à l'éclairage directionnel.
// Step 12 (Phase 4.2): in addition to the default directional light (+Z), a warm **point** light
// is added in front of the cube. Its halo (linear attenuation over the
// radius) is visible on the near face of the cube, on top of the directional lighting.
app.scene
.add_point_light(
Vec3::new(1.0, 0.5, 1.5), // position monde, devant/droite du cube
[1.0, 0.7, 0.3], // teinte chaude
1.0, // intensité
3.0, // rayon d'atténuation
Vec3::new(1.0, 0.5, 1.5), // world position, in front/right of the cube
[1.0, 0.7, 0.3], // warm tint
1.0, // intensity
3.0, // attenuation radius
)
.unwrap();
// Étape 13 (Phase 4.2) : une lumière **spot** verte pointée vers le cube depuis la gauche.
// Le cône (demi-angle ~20°) projette un faisceau orienté sur les faces du cube, avec une
// pénombre lissée au bord et une atténuation linéaire dans le rayon.
// Step 13 (Phase 4.2): a green **spot** light aimed at the cube from the left.
// The cone (half-angle ~20°) projects a directed beam onto the cube's faces, with a
// smoothed penumbra at the edge and linear attenuation over the radius.
app.scene
.add_spot_light(
Vec3::new(-2.0, 1.0, 1.5), // position monde, à gauche/dessus/derrière-caméra
Vec3::new(2.0, -1.0, -1.5).normalize(), // axe du cône, vers le cube (origine)
[0.3, 1.0, 0.4], // teinte verte
1.2, // intensité
4.0, // rayon d'atténuation
0.35, // demi-angle (~20°) en radians
Vec3::new(-2.0, 1.0, 1.5), // world position, left/above/behind the camera
Vec3::new(2.0, -1.0, -1.5).normalize(), // cone axis, toward the cube (origin)
[0.3, 1.0, 0.4], // green tint
1.2, // intensity
4.0, // attenuation radius
0.35, // half-angle (~20°) in radians
)
.unwrap();
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Rotation cumulée du cube (double axe pour un mouvement plus lisible).
// Cumulative cube rotation (double axis for a more readable motion).
self.angle += 0.02;
let base = *app
.scene
+2 -2
View File
@@ -1,4 +1,4 @@
//! **WSG `demo`** — the final showcase example (Étape 15, sous-volt 15.C).
//! **WSG `demo`** — the final showcase example (Step 15, sous-volt 15.C).
//!
//! Combines everything built throughout the library into one declarative scene:
//!
@@ -6,7 +6,7 @@
//! (`cube`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) placed around it,
//! * a **procedural texture** per mesh (checker / stripe grids, no assets on disk),
//! * the **standard** Phong material wired to those textures,
//! * an **orbital camera** driven live by the unified input state (Étape 15.B):
//! * an **orbital camera** driven live by the unified input state (Step 15.B):
//! moving the mouse orbits (yaw/pitch), the wheel zooms (distance),
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
+36 -39
View File
@@ -1,9 +1,9 @@
//! Workflow bas-niveau : utilisation directe du `Context`, `Renderer`, `PipelineCache`, `Mesh` et
//! `Material`, contournant la façade `App`. Rendu d'un quad plat (shader `standard` **unlit**) via la
//! boucle winit 0.30 (`EventLoop::run_app` + `ApplicationHandler`). La fenêtre et le GPU sont créés
//! dans `resumed()`, comme l'exigent winit 0.30 et la migration faite dans `app.rs`. Depuis l'Étape 8
//! (DRAFT 8.5) le mesh est construit via `Mesh::from_geometry(device, Arc<Geometry>, None)` à partir
//! d'une `Geometry` (positions + couleurs par sommet) au lieu de `Mesh::new(device, &[Vertex], ..)`.
//! Low-level workflow: direct use of `Context`, `Renderer`, `PipelineCache`, `Mesh` and
//! `Material`, bypassing the `App` facade. Renders a flat quad (shader `standard` **unlit**) via the
//! winit 0.30 loop (`EventLoop::run_app` + `ApplicationHandler`). The window and the GPU are created
//! in `resumed()`, as required by winit 0.30 and the migration done in `app.rs`. Since Step 8
//! (DRAFT 8.5) the mesh is built via `Mesh::from_geometry(device, Arc<Geometry>, None)` from
//! a `Geometry` (positions + colors per vertex) instead of `Mesh::new(device, &[Vertex], ..)`.
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
@@ -17,25 +17,25 @@ use wsg_lib::pipeline::PipelineCache;
use wsg_lib::resources::{Geometry, Material, Mesh};
use wsg_lib::utils;
/// Application bas-niveau : détient les objets GPU + window, tous créés dans `resumed`.
/// Low-level application: holds the GPU objects + window, all created in `resumed`.
struct App {
/// Fenêtre système, partagée via Arc (comme dans app.rs).
/// System window, shared via Arc (as in app.rs).
window: Option<Arc<Window>>,
/// Contexte GPU (Instance, Surface, Adapter, Device, Queue).
/// GPU context (Instance, Surface, Adapter, Device, Queue).
context: Option<Context>,
/// Couche d'exécution qui soumet les draw calls.
/// Execution layer that submits draw calls.
renderer: Option<Renderer>,
/// Cache de shaders/pipelines.
/// Shader/pipeline cache.
cache: Option<PipelineCache>,
/// Matériau (pipeline) du quad.
/// Quad material (pipeline).
material: Option<Material>,
/// Mesh du quad (sommets + indices).
/// Quad mesh (vertices + indices).
mesh: Option<Mesh>,
}
impl ApplicationHandler for App {
/// Crée la fenêtre puis le GPU, et construit le mesh/matériau. Exécuté une fois au démarrage.
/// Redondant `resumed` pour créer à nouveau ? double protection par `self.context.is_some()`.
/// Creates the window then the GPU, and builds the mesh/material. Runs once at startup.
/// Redundant `resumed` creating again? Double protection via `self.context.is_some()`.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.context.is_some() {
return;
@@ -48,43 +48,43 @@ impl ApplicationHandler for App {
let window = Arc::new(event_loop.create_window(attrs).unwrap());
// 1. Initialisation
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
// Configuration de la surface et récupération du format
// Surface configuration and format retrieval
let format = context
.configure(&context.adapter, 800, 600)
.expect("Échec configuration");
.expect("configuration failed");
// 2. Initialisation du Renderer (Il récupère tout ce dont il a besoin)
// 2. Renderer initialization (it retrieves everything it needs)
let device = Arc::new(context.device.clone());
let mut cache = PipelineCache::new(device, context.queue.clone());
cache
.register_shader("standard", utils::STANDARD_SHADER_PATH)
.unwrap();
// Rendu 2D plat : `standard` en mode unlit (les bind groups frame+object sont posés par
// draw_entity, la matrice frame par défaut est l'identité → positions NDC inchangées).
// 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);
renderer.set_unlit(true);
// 3. Material : On utilise renderer.device() et renderer.format()
// 3. Material: uses renderer.device() and renderer.format()
let material = Material::new(renderer.format(), "standard", &mut cache);
// Mesh : on utilise le device du renderer. Depuis l'Étape 8 le mesh est construit depuis une
// `Geometry` (positions + couleurs par sommet) via `Mesh::from_geometry` — le mesh garde aussi
// l'`Arc<Geometry>` côté CPU (rétention D5).
// Mesh: uses the renderer's device. Since Step 8 the mesh is built from a
// `Geometry` (positions + colors per vertex) via `Mesh::from_geometry` — the mesh also keeps
// the `Arc<Geometry>` on the CPU side (retention D5).
let geometry = Geometry::new(vec![
// Position (x,y,z) | Couleur (r,g,b,a) — normales/UV par défaut via to_vertices
// Position (x,y,z) | Color (r,g,b,a) — normals/UVs default via to_vertices
[-0.5, 0.5, 0.0],
[0.5, 0.5, 0.0],
[0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // Haut-Gauche (Rouge)
[0.0, 1.0, 0.0, 1.0], // Haut-Droite (Vert)
[0.0, 0.0, 1.0, 1.0], // Bas-Droite (Bleu)
[1.0, 1.0, 0.0, 1.0], // Bas-Gauche (Jaune)
[1.0, 0.0, 0.0, 1.0], // top-left (red)
[0.0, 1.0, 0.0, 1.0], // top-right (green)
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
@@ -97,14 +97,14 @@ impl ApplicationHandler for App {
self.mesh = Some(mesh);
}
/// À chaque frame, demande un redessin pour un rendu continu (animation).
/// Each frame, requests a redraw for continuous rendering (animation).
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
/// Dispatch des événements de fenêtre : RedrawRequested rend puis présente, CloseRequested quitte.
/// Window event dispatch: RedrawRequested renders then presents, CloseRequested exits.
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
@@ -117,16 +117,16 @@ impl ApplicationHandler for App {
(&self.context, &self.renderer, &self.mesh, &self.material)
{
if let Some(frame) = Frame::try_new(&context.surface) {
// 1. Rendu (plus d'arguments device/queue inutiles)
// 1. Render (no more useless device/queue arguments)
renderer.render(frame.view(), mesh, material);
// 2. Présentation
// 2. Present
renderer.present(frame);
}
}
}
winit::event::WindowEvent::CloseRequested => {
event_loop.exit(); // C'est ici que tu demandes à la boucle de s'arrêter
event_loop.exit(); // this is where you ask the loop to stop
}
_ => (),
}
@@ -134,10 +134,7 @@ impl ApplicationHandler for App {
}
fn main() {
println!(
"Répertoire courant : {:?}",
std::env::current_dir().unwrap()
);
println!("Current directory: {:?}", std::env::current_dir().unwrap());
let event_loop = EventLoop::new().unwrap();
let mut app = App {
window: None,
+1 -1
View File
@@ -1,4 +1,4 @@
//! Dedicated test for **shadow mapping** (Étape 14, Phase 4.2).
//! Dedicated test for **shadow mapping** (Step 14, Phase 4.2).
//!
//! A single **directional** light is configured as the shadow caster
//! (`Scene::set_shadow_caster(Some(0))`). The cube sits on a large thin ground
+21 -21
View File
@@ -1,12 +1,12 @@
//! Workflow déclaratif minimal, sans manipulation WGPU explicite dans ce fichier.
//! `AppBuilder` crée l'event loop puis `App::run` ouvre la fenêtre, construit le `Context`/`Renderer`
//! et fait tourner la boucle update → render → present. Depuis la migration winit 0.30, le GPU n'existe
//! qu'après `resumed` : c'est pourquoi l'enregistrement shader + la création mesh/matériau/entité vivent
//! dans le hook `AppHandler::setup`, appelé une fois le contexte prêt. Depuis l'Étape 7 le PipelineCache
//! vit dans la scène (`Scene::init_gpu`, appelé dans `resumed`) : on passe par `register_shader` +
//! `add_material_shader` + `create_mesh` + `add_entity`, le matériau étant lié au mesh. Depuis l'Étape 8
//! (DRAFT 8.4/8.5) le mesh est déclaré à partir d'une **`Geometry`** : positions + couleurs par sommet
//! pour le quad unlit. La scène se rend automatiquement : la méthode `render()` par défaut appelle
//! Minimal declarative workflow, no explicit WGPU handling in this file.
//! `AppBuilder` creates the event loop, then `App::run` opens the window, builds the `Context`/`Renderer`
//! and drives the update → render → present loop. Since the winit 0.30 migration, the GPU only exists
//! after `resumed`: that is why shader registration + mesh/material/entity creation live in
//! the `AppHandler::setup` hook, called once the context is ready. Since Step 7 the PipelineCache
//! lives in the scene (`Scene::init_gpu`, called in `resumed`): go through `register_shader` +
//! `add_material_shader` + `create_mesh` + `add_entity`, the material being bound to the mesh. Since Step 8
//! (DRAFT 8.4/8.5) the mesh is declared from a **`Geometry`**: per-vertex positions + colors
//! for the unlit quad. The scene renders automatically: the default `render()` method calls
//! `app.render_scene(frame.view())`.
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
@@ -17,30 +17,30 @@ struct MonQuad;
impl AppHandler for MonQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
// Exemple 2D plat : le shader `standard` en mode **unlit** (options.x = 1) renvoie la couleur
// du vertex telle quelle. Ainsi le 2D est un cas particulier du 3D — un seul pipeline pour tous.
// Flat 2D example: the `standard` shader in **unlit** mode (options.x = 1) returns the vertex
// color as-is. Flat 2D is thus a special case of 3D — a single pipeline for all.
app.renderer_mut().set_unlit(true);
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0], // Haut-Gauche
[0.5, 0.5, 0.0], // Haut-Droite
[0.5, -0.5, 0.0], // Bas-Droite
[-0.5, -0.5, 0.0], // Bas-Gauche
[-0.5, 0.5, 0.0], // top-left
[0.5, 0.5, 0.0], // top-right
[0.5, -0.5, 0.0], // bottom-right
[-0.5, -0.5, 0.0], // bottom-left
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // Haut-Gauche (Rouge)
[0.0, 1.0, 0.0, 1.0], // Haut-Droite (Vert)
[0.0, 0.0, 1.0, 1.0], // Bas-Droite (Bleu)
[1.0, 1.0, 0.0, 1.0], // Bas-Gauche (Jaune)
[1.0, 0.0, 0.0, 1.0], // top-left (red)
[0.0, 1.0, 0.0, 1.0], // top-right (green)
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
// Material par défaut : `None` laisse la Scene injecter son `standard` au rendu
// (`Scene::default_material`, DRAFT Étape 7.3.5) — on vérifie le chemin par défaut.
// Default material: `None` lets the Scene inject its `standard` at render time
// (`Scene::default_material`, DRAFT Step 7.3.5) — this exercises the default path.
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
+25 -25
View File
@@ -1,21 +1,21 @@
//! Test dédié aux **lumières spot** (Étape 13, Phase 4.2).
//! Test dedicated to **spot lights** (Step 13, Phase 4.2).
//!
//! Dans cet exemple, **seule** une lumière spot est allumée (la directionnelle par défaut est
//! retirée via `clear_lights()`) et l'ambiant est volontairement **très bas**. Le cube apparaît
//! donc quasiment noir sauf là où le cône de la spot l'atteint : on voit clairement
//! In this example, **only** a spot light is on (the default directional light is
//! removed via `clear_lights()`) and the ambient is deliberately **very low**. The cube therefore
//! appears nearly black except where the spot's cone reaches it: you clearly see
//!
//! 1. un **faisceau orienté** (pas un halo omni comme la lumière ponctuelle),
//! 2. un **bord lissé** (pénombre) à la limite du cône,
//! 3. l'éclairage qui **suit le cube** quand il tourne (le cône est fixe dans l'espace monde).
//! 1. a **directed beam** (not an omni halo like the point light),
//! 2. a **smoothed edge** (penumbra) at the cone's limit,
//! 3. the lighting that **follows the cube** as it rotates (the cone is fixed in world space).
//!
//! Lance avec : `cargo run -p wsg-lib --example spot_test`
//! Run with: `cargo run -p wsg-lib --example spot_test`
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::math::cube;
use wsg_lib::utils::WsgError;
/// Handler de test : cube qui tourne lentement sur deux axes, éclairé **uniquement** par une spot.
/// Test handler: cube rotating slowly on two axes, lit **only** by a spot.
struct SpotTest {
angle_x: f32,
angle_y: f32,
@@ -32,32 +32,32 @@ impl AppHandler for SpotTest {
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
// On retire la directionnelle par défaut pour isoler la spot.
// Remove the default directional light to isolate the spot.
app.scene.clear_lights();
// Ambiant quasi nul : le cube est noir hors du faisceau, le cône saute aux yeux.
// Near-zero ambient: the cube is black outside the beam, the cone stands out.
app.scene.set_ambient([0.03, 0.03, 0.03]);
// La spot est au-dessus/derrière-caméra, pointée vers l'origine (le cube).
// Position monde (0, 2, 3), axe du cône vers (0,0,0).
// The spot is above/behind the camera, aimed at the origin (the cube).
// World position (0, 2, 3), cone axis toward (0,0,0).
let spot_pos = Vec3::new(0.0, 2.0, 3.0);
let spot_dir = (Vec3::ZERO - spot_pos).normalize(); // pointe vers le cube
let spot_dir = (Vec3::ZERO - spot_pos).normalize(); // points at the cube
app.scene
.add_spot_light(
spot_pos,
spot_dir,
[1.0, 0.9, 0.6], // teinte chaude
2.0, // intensité
10.0, // rayon d'atténuation (large, le cube est à ~3.6)
0.45, // demi-angle (~26°) — assez large pour couvrir le cube
[1.0, 0.9, 0.6], // warm tint
2.0, // intensity
10.0, // attenuation radius (wide, the cube is at ~3.6)
0.45, // half-angle (~26°) — wide enough to cover the cube
)
.unwrap();
}
fn update(&mut self, app: &mut wsg_lib::App) {
// Rotation lente sur deux axes (X et Y) : le cône est fixe dans l'espace monde,
// on voit donc une région fixe du cube rester éclairée pendant que le cube tourne.
// Les deux axes permettent de voir l'effet du faisceau sur les 6 faces sans
// orientation privilégiée (la rotation Y seule laisserait les faces +Y/-Y fixes).
// Slow rotation on two axes (X and Y): the cone is fixed in world space,
// so a fixed region of the cube stays lit while the cube rotates.
// The two axes let you see the beam's effect on the 6 faces without
// a favored orientation (a Y rotation alone would leave the +Y/-Y faces fixed).
self.angle_x += 0.007;
self.angle_y += 0.011;
let base = *app
@@ -65,9 +65,9 @@ impl AppHandler for SpotTest {
.entity_transform("cube")
.expect("cube entity present");
let mut transform = base;
// Composition Y * X : l'axe X tourne dans le repère déjà orienté en Y,
// ce qui donne un mouvement de précession (tous les sommets passent devant
// le cône à tour de rôle).
// Y * X composition: the X axis rotates in the frame already oriented by Y,
// which gives a precession motion (all vertices pass in front of
// the cone in turn).
transform.rotation =
Quat::from_rotation_y(self.angle_y) * Quat::from_rotation_x(self.angle_x);
app.scene.set_entity_transform("cube", transform);
+5 -4
View File
@@ -2,14 +2,15 @@
## Overview
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into seven modules:
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules:
| Module | Responsibility |
|--------|---------------|
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor) |
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration; also `InputState` (unified keyboard/mouse input, Step 15.B) |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor), Texture, Lights, Camera + CameraController |
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
| **scene** | Scene — resource depot and entity graph for declarative rendering setup |
| **math** | Transform, Geometry (per-attribute mesh data) and `primitives` (procedural mesh generators) |
| **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
@@ -18,7 +19,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
The library supports two workflows:
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/ARCHI_APP.md).
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/tech/ARCHI_APP.md).
- **Manual**: Manipulate Context, Renderer, and PipelineCache directly for fine-grained control.
## Dependency Flow
+22 -22
View File
@@ -9,7 +9,7 @@
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
//! - **core::renderer**: Delegates draw call execution to Renderer per frame.
//! - **scene::scene**: Exposes Scene as mutable field so users can register resources and entities.
//! Since Étape 7 the `PipelineCache` lives *inside* the Scene (via `Scene::init_gpu`), owned and
//! Since Step 7 the `PipelineCache` lives *inside* the Scene (via `Scene::init_gpu`), owned and
//! used for material building there.
//! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
@@ -40,13 +40,13 @@ use winit::window::{Window, WindowAttributes};
///
/// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
/// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
/// is not a field here: since Étape 7 it lives in the `Scene`'s pipeline context (via `Scene::init_gpu`).
/// is not a field here: since Step 7 it lives in the `Scene`'s pipeline context (via `Scene::init_gpu`).
/// Access GPU resources through the `context()`, `renderer()` and `window()` accessors, which are
/// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
pub struct App {
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
pub scene: Scene,
/// Unified input state (keyboard/mouse/scroll, DRAFT Étape 15). Fed by the winit window events
/// Unified input state (keyboard/mouse/scroll, DRAFT Step 15). Fed by the winit window events
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
pub input: InputState,
@@ -78,7 +78,7 @@ impl App {
/// Returns a mutable reference to the GPU renderer.
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
/// Callers can configure the renderer here, e.g. `app.renderer_mut().set_unlit(true)` in `setup`
/// to select flat 2D rendering (DRAFT Étape 5).
/// to select flat 2D rendering (DRAFT Step 5).
pub fn renderer_mut(&mut self) -> &mut Renderer {
self.renderer
.as_mut()
@@ -112,8 +112,8 @@ impl App {
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
/// 6) on CloseRequested: exit the event loop.
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
// On extrait l'event_loop de manière sûre grâce au Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris
// Extract the event_loop safely via Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
let mut runner = AppRunner {
title: self.title.clone(),
width: self.width,
@@ -131,7 +131,7 @@ impl App {
/// who override `render` to control drawing themselves.
/// Inputs: view — the frame's texture view acting as the color attachment target.
///
/// The viewport aspect ratio (needed for the active camera's perspective projection, Étape 4.3)
/// The viewport aspect ratio (needed for the active camera's perspective projection, Step 4.3)
/// is derived here from the window's current inner size, so the `Renderer` stays independent of
/// the windowing backend.
pub fn render_scene(&self, view: &wgpu::TextureView) {
@@ -255,15 +255,15 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.expect("failed to create window"),
);
// Initialization GPU (bloquant, simplifié au max)
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
// GPU initialization (blocking, kept as simple as possible)
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
let format = context
.configure(&context.adapter, self.width, self.height)
.expect("Échec configuration surface");
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer = Renderer::new(&context, format, self.width, self.height);
// Étape 7 (DRAFT 7.1) : the PipelineCache now lives in the Scene. We wire the GPU context
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
let mut scene = Scene::new();
scene.init_gpu(device, context.queue.clone(), format);
@@ -279,7 +279,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
renderer: Some(renderer),
window: Some(window),
};
// On laisse l'utilisateur enregistrer shaders/meshes/matériaux/entités une fois le GPU prêt.
// Let the user register shaders/meshes/materials/entities once the GPU is ready.
self.handler.setup(&mut app);
self.app = Some(app);
}
@@ -300,10 +300,10 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
submission_index: None,
timeout: None,
}) {
eprintln!("WSG : device.poll() a échoué ({e:?})");
eprintln!("WSG: device.poll() failed ({e:?})");
}
// Étape 15 (input) : débute la frame d'input (rotation pressed/released + reset deltas),
// exécute la logique utilisateur, puis clôt (nettoie les états transitoires).
// Step 15 (input): start the input frame (rotate pressed/released + reset deltas),
// run the user logic, then close (clear the transient states).
app.input.begin_frame();
self.handler.update(app);
app.input.end_frame();
@@ -321,24 +321,24 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else {
return;
};
// Étape 15 (input) : alimente l'état unifié depuis les événements winit (clavier/souris/molette).
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
app.input.handle_window_event(&event);
match event {
WindowEvent::Resized(size) => {
// Garde (D3) : minimiser la fenêtre envoie Resized(0x0) ; ne jamais reconfigurer à 0.
// Guard (D3): minimizing the window sends Resized(0x0); never reconfigure at 0.
let w = size.width as u32;
let h = size.height as u32;
if w == 0 || h == 0 {
return;
}
// Étape 11 : reconfigurer surface + depth à la nouvelle taille, puis re-rendre.
// Step 11: reconfigure surface + depth to the new size, then re-render.
if let Err(e) = app.resize(w, h) {
eprintln!("WSG : erreur de resize ({e:?})");
eprintln!("WSG: resize error ({e:?})");
}
app.window().request_redraw();
}
WindowEvent::RedrawRequested => {
// Garde (D6) : ne pas rendre sur une surface de taille nulle (fenêtre minimisée).
// Guard (D6): do not render on a zero-sized surface (minimized window).
let size = app.window().inner_size();
if size.width == 0 || size.height == 0 {
return;
@@ -346,9 +346,9 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
// Rendering logic
let frame = app.context().get_next_frame();
// On appelle le render() de l'utilisateur (reçoit la frame courante)
// Call the user's render() (receives the current frame)
self.handler.render(app, &frame);
// On présente automatiquement
// Present automatically
app.renderer().present(frame);
}
WindowEvent::CloseRequested => {
+1
View File
@@ -9,6 +9,7 @@ The `core` module contains two architectural layers that drive rendering:
| **context** | **Manager layer** — owns GPU hardware resource lifecycle (Instance, Surface, Adapter, Device, Queue). Initializes GPU at startup; orchestrates frame-by-frame rendering via begin_frame() / end_frame(). Does not own rendering logic. |
| **renderer** | **Executor layer** — owns Device/Queue references after initialization from Context. Orchestrates draw calls by binding Material pipelines and Mesh vertex data into a RenderPass. Does not own raw hardware resources externally or RenderPipelines/shaders. |
| **frame** | Per-frame RAII wrapper around the surface texture and its TextureView. Exists only for the duration of a single rendering pass. |
| **input** | `InputState` (Step 15.B) — unified cross-frame keyboard/mouse state (pressed/held/released, mouse delta, wheel scroll). Rotated by `begin_frame`/`end_frame` around `AppHandler::update`; exposed by `App` as a public `input` field. |
## Interaction with Other Modules
+2 -2
View File
@@ -10,10 +10,10 @@
//! - **error**: returns WsgError variants from all fallible methods.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Phase de Déclaration**: Context is created once at application startup before the render loop begins.
//! - **Declaration Phase**: Context is created once at application startup before the render loop begins.
//! This follows the declarative workflow where all GPU state is configured upfront.
//! - **Injection Async**: Context::new() is async because the runtime must be injected at creation time.
//! - **Accès Bas-Niveau**: Advanced users can bypass the Scene facade and manipulate Context directly
//! - **Low-Level Access**: Advanced users can bypass the Scene facade and manipulate Context directly
//! through App.renderer(), App.context(), etc., for fine-grained control over wgpu handles.
use std::sync::Arc;
+1 -1
View File
@@ -12,7 +12,7 @@
//! Frame::try_new() returns `Option<Self>` for graceful recovery.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Phase d'Exécution**: Frame is acquired at the start of each render loop iteration and released after rendering.
//! - **Execution Phase**: Frame is acquired at the start of each render loop iteration and released after rendering.
//! - **Ergonomie**: Users interact with frames through Renderer::render() + Renderer::present(), not directly with wgpu handles.
/// A per-frame RAII wrapper around the surface texture and its `TextureView`.
+67 -67
View File
@@ -1,28 +1,28 @@
//! # Input Module — Unified Input State (Étape 15, ROADMAP 2.3)
//! # Input Module — Unified Input State (Step 15, ROADMAP 2.3)
//!
//! State d'entrée **unifié** (clavier / souris / molette) à la sémantique cross-frame
//! **pressed / held / released**, alimenté par les événements **winit** (`WindowEvent`), côté **CPU
//! (Rust)** — WGSL (langage de shader, GPU) n'a pas d'I/O. Ce module est incarné dans `App::input`
//! et piloté par la boucle : `begin_frame()` avant `AppHandler::update`, `end_frame()` après.
//! **Unified** input state (keyboard / mouse / wheel) with cross-frame
//! **pressed / held / released** semantics, fed by **winit** events (`WindowEvent`), on the **CPU
//! (Rust)** side — WGSL (the GPU shader language) has no I/O. This module is embodied in `App::input`
//! and driven by the loop: `begin_frame()` before `AppHandler::update`, `end_frame()` after.
//!
//! ## Conventions
//! - **Clavier** : identifié par `KeyCode` (physique, indépendant de la disposition AZERTY/QWERTY :
//! la touche Z sur AZERTY est `KeyCode::KeyW`). `pressed`/`released` valent une seule frame,
//! `held` reste vrai tant que la touche est enfoncée.
//! - **Souris** : position absolue (pixels), delta par frame (dérivé des `CursorMoved`, donc des
//! déplacements relatifs valables pour une caméra orbitale en glisser), boutons
//! `pressed`/`held`/`released`, molette (`scroll`), `y > 0` = molette vers le haut.
//! - **Gamepad** : réservé pour une future v1 minimale (DRAFT D7) ; l'API est prête à accueillir
//! un `GamepadState` sans casser l'existant (l'exemple final n'a besoin que du clavier + souris).
//! - **Keyboard**: identified by `KeyCode` (physical key, independent of the AZERTY/QWERTY layout:
//! the Z key on AZERTY is `KeyCode::KeyW`). `pressed`/`released` are valid for a single frame,
//! `held` stays true as long as the key is held down.
//! - **Mouse**: absolute position (pixels), per-frame delta (derived from `CursorMoved`, i.e. relative
//! movement — suitable for a drag-orbit camera), buttons
//! `pressed`/`held`/`released`, wheel (`scroll`), `y > 0` = wheel upward.
//! - **Gamepad**: reserved for a future minimal v1 (DRAFT D7); the API is ready to accept
//! a `GamepadState` without breaking existing code (the final example only needs keyboard + mouse).
//!
//! ## Exemples de requête (dans `AppHandler::update`)
//! ## Query examples (in `AppHandler::update`)
//! ```
//! # use winit::keyboard::{KeyCode, PhysicalKey};
//! # fn demo(input: &wsg_lib::core::input::InputState) {
//! if input.key_held(KeyCode::KeyW) { /* avancer */ }
//! if input.key_pressed(KeyCode::Space) { /* sauter */ }
//! if input.key_held(KeyCode::KeyW) { /* move forward */ }
//! if input.key_pressed(KeyCode::Space) { /* jump */ }
//! let (dx, dy) = input.mouse_delta();
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbiter */ }
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbit */ }
//! let (_, zoom) = input.scroll_delta();
//! # }
//! ```
@@ -31,61 +31,61 @@ use std::collections::HashSet;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// État de saisie unifié, agrégat des groupes clavier, souris et molette. Il est **remis à jour à
/// chaque frame** par `App` via `begin_frame`/`end_frame`, et lu par l'utilisateur dans
/// Unified input state, aggregate of the keyboard, mouse and wheel groups. It is **refreshed every
/// frame** by `App` via `begin_frame`/`end_frame`, and read by the user in
/// `AppHandler::update` via `app.input`.
#[derive(Debug, Default, Clone)]
pub struct InputState {
// ---- Clavier ----
/// Touches physiquement enfoncées au moment présent (persiste entre les frames).
// ---- Keyboard ----
/// Physically held-down keys as of now (persists across frames).
held: HashSet<KeyCode>,
/// Touches enfoncées pendant la frame courante (valides une seule frame).
/// Keys pressed during the current frame (valid for a single frame).
pressed: HashSet<KeyCode>,
/// Touches relâchées pendant la frame courante (valides une seule frame).
/// Keys released during the current frame (valid for a single frame).
released: HashSet<KeyCode>,
/// Accumulateur de `pressed` entre deux `begin_frame` (consommé à la rotation).
/// `pressed` accumulator between two `begin_frame` calls (consumed on rotation).
frame_pressed: HashSet<KeyCode>,
/// Accumulateur de `released` entre deux `begin_frame`.
/// `released` accumulator between two `begin_frame` calls.
frame_released: HashSet<KeyCode>,
// ---- Souris ----
/// Position absolue du curseur en pixels (dernière reçue).
// ---- Mouse ----
/// Absolute cursor position in pixels (last received).
mouse_position: (f32, f32),
/// Position absolue précédente, pour dériver le delta de `CursorMoved`.
/// Previous absolute position, to derive the `CursorMoved` delta.
last_mouse_position: Option<(f32, f32)>,
/// Déplacement relatif cumulé pendant la frame courante.
/// Cumulative relative movement during the current frame.
mouse_delta: (f32, f32),
/// Boutons enfoncés au moment présent.
/// Buttons currently held down.
held_buttons: HashSet<MouseButton>,
/// Boutons pressés pendant la frame courante.
/// Buttons pressed during the current frame.
pressed_buttons: HashSet<MouseButton>,
/// Boutons relâchés pendant la frame courante.
/// Buttons released during the current frame.
released_buttons: HashSet<MouseButton>,
/// Accumulateurs des boutons entre deux `begin_frame`.
/// Button accumulators between two `begin_frame` calls.
frame_pressed_buttons: HashSet<MouseButton>,
frame_released_buttons: HashSet<MouseButton>,
// ---- Molette ----
/// Défilement cumulé pendant la frame courante (x, y).
// ---- Wheel ----
/// Cumulative scroll during the current frame (x, y).
scroll: (f32, f32),
// ---- Gamepad (réservé) ----
// (DRAFT D7 : v1 minimale optionnelle, reportée — l'API s'étendra sans rupture.)
// ---- Gamepad (reserved) ----
// (DRAFT D7: optional minimal v1, deferred — the API will extend without breakage.)
}
impl InputState {
/// Crée un `InputState` vierge (toutes états vides). Équivalent à `Default`.
/// Creates a fresh `InputState` (all states empty). Equivalent to `Default`.
pub fn new() -> Self {
Self::default()
}
/// Consomme un événement de fenêtre winit et met à jour l'état interne (accumulateurs). Les
/// événements non pertinents sont ignorés. La rotation vers les ensembles requêtables
/// (`pressed`/`released`) se fait au prochain `begin_frame`.
/// Consumes a winit window event and updates the internal state (accumulators). Irrelevant
/// events are ignored. Rotation into the queryable sets (`pressed`/`released`) happens at the
/// next `begin_frame`.
pub fn handle_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event: ke, .. } => {
let PhysicalKey::Code(code) = ke.physical_key else {
return; // touches non-figurées (ex. clavier système) ignorées
return; // non-character keys (e.g. system keys) ignored
};
self.key_input(code, ke.state);
}
@@ -101,8 +101,8 @@ impl InputState {
}
}
/// Enregistre un événement clavier brut (touche physique + état), appelé par
/// [`InputState::handle_window_event`]. Séparé pour être testable sans construire un `KeyEvent`.
/// Records a raw keyboard event (physical key + state), called by
/// [`InputState::handle_window_event`]. Split out so it can be tested without building a `KeyEvent`.
fn key_input(&mut self, code: KeyCode, state: ElementState) {
match state {
ElementState::Pressed => {
@@ -116,7 +116,7 @@ impl InputState {
}
}
/// Enregistre un événement bouton de souris brut, appelé par [`InputState::handle_window_event`].
/// Records a raw mouse-button event, called by [`InputState::handle_window_event`].
fn mouse_button(&mut self, button: MouseButton, state: ElementState) {
match state {
ElementState::Pressed => {
@@ -130,7 +130,7 @@ impl InputState {
}
}
/// Met à jour la position du curseur et cumule le déplacement relatif. Appelé par
/// Updates the cursor position and accumulates the relative movement. Called by
/// [`InputState::handle_window_event`].
fn cursor_move(&mut self, x: f32, y: f32) {
if let Some((px, py)) = self.last_mouse_position {
@@ -141,15 +141,15 @@ impl InputState {
self.mouse_position = (x, y);
}
/// Cumule le défilement de la molette. Appelé par [`InputState::handle_window_event`].
/// Accumulates the wheel scroll. Called by [`InputState::handle_window_event`].
fn wheel(&mut self, dx: f32, dy: f32) {
self.scroll.0 += dx;
self.scroll.1 += dy;
}
/// Démarre une nouvelle frame pour l'input : **fait tourner** les accumulateurs d'événements
/// (accumulés entre deux `begin_frame`) vers les ensembles requêtables `pressed`/`released`, et
/// remet à zéro le delta de souris et la molette. À appeler **avant** `AppHandler::update`.
/// Starts a new input frame: **rotates** the event accumulators
/// (accumulated between two `begin_frame` calls) into the queryable sets `pressed`/`released`, and
/// zeroes the mouse delta and the wheel. Call this **before** `AppHandler::update`.
pub fn begin_frame(&mut self) {
self.pressed = std::mem::take(&mut self.frame_pressed);
self.released = std::mem::take(&mut self.frame_released);
@@ -159,9 +159,9 @@ impl InputState {
self.scroll = (0.0, 0.0);
}
/// Clôt une frame : vide les ensembles transitoires `pressed`/`released` (déjà consommés par
/// `update`). Les états `held` et la position sont conservés. À appeler **après**
/// `AppHandler::update` (ou `render`).
/// Ends a frame: clears the transient sets `pressed`/`released` (already consumed by
/// `update`). The `held` states and the position are kept. Call this **after**
/// `AppHandler::update` (or `render`).
pub fn end_frame(&mut self) {
self.pressed.clear();
self.released.clear();
@@ -169,44 +169,44 @@ impl InputState {
self.released_buttons.clear();
}
// ---- Requêtes clavier ----
// ---- Keyboard queries ----
/// Vrai si `code` a été **enfoncée** pendant la frame courante (une seule frame).
/// True if `code` was **pressed** during the current frame (a single frame only).
pub fn key_pressed(&self, code: KeyCode) -> bool {
self.pressed.contains(&code)
}
/// Vrai si `code` est **maintenue** enfoncée (persiste entre les frames).
/// True if `code` is **held** down (persists across frames).
pub fn key_held(&self, code: KeyCode) -> bool {
self.held.contains(&code)
}
/// Vrai si `code` a été **relâchée** pendant la frame courante (une seule frame).
/// True if `code` was **released** during the current frame (a single frame only).
pub fn key_released(&self, code: KeyCode) -> bool {
self.released.contains(&code)
}
// ---- Requêtes souris ----
// ---- Mouse queries ----
/// Position absolue du curseur en pixels (dernière position reçue).
/// Absolute cursor position in pixels (last position received).
pub fn mouse_position(&self) -> (f32, f32) {
self.mouse_position
}
/// Déplacement relatif de la souris cumulé pendant la frame courante.
/// Cumulative relative mouse movement during the current frame.
pub fn mouse_delta(&self) -> (f32, f32) {
self.mouse_delta
}
/// Défilement de la molette cumulé pendant la frame courante (`(dx, dy)`, `dy > 0` = vers le haut).
/// Cumulative wheel scroll during the current frame (`(dx, dy)`, `dy > 0` = upward).
pub fn scroll_delta(&self) -> (f32, f32) {
self.scroll
}
/// Vrai si `button` a été **pressé** pendant la frame courante (une seule frame).
/// True if `button` was **pressed** during the current frame (a single frame only).
pub fn mouse_button_pressed(&self, button: MouseButton) -> bool {
self.pressed_buttons.contains(&button)
}
/// Vrai si `button` est **maintenu** enfoncé (persiste entre les frames).
/// True if `button` is **held** down (persists across frames).
pub fn mouse_button_held(&self, button: MouseButton) -> bool {
self.held_buttons.contains(&button)
}
/// Vrai si `button` a été **relâché** pendant la frame courante (une seule frame).
/// True if `button` was **released** during the current frame (a single frame only).
pub fn mouse_button_released(&self, button: MouseButton) -> bool {
self.released_buttons.contains(&button)
}
@@ -227,13 +227,13 @@ mod tests {
assert!(!input.key_released(KeyCode::KeyW));
input.end_frame();
// Frame suivante sans nouvel événement : plus "pressed", toujours "held".
// Next frame without a new event: no longer "pressed", still "held".
input.begin_frame();
assert!(!input.key_pressed(KeyCode::KeyW));
assert!(input.key_held(KeyCode::KeyW));
input.end_frame();
// Relâchement.
// Release.
input.key_input(KeyCode::KeyW, ElementState::Released);
input.begin_frame();
assert!(input.key_released(KeyCode::KeyW));
@@ -268,7 +268,7 @@ mod tests {
assert_eq!(input.mouse_position(), (30.0, 40.0));
input.end_frame();
// Nouvelle frame : le delta est remis à zéro au début, la position reste.
// New frame: the delta is reset to zero at the start, the position persists.
input.begin_frame();
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
+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 {
+119
View File
@@ -269,3 +269,122 @@ impl Geometry {
Ok(self.to_vertices())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quad() -> Geometry {
Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_uvs(vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
.with_colors(vec![[1.0, 0.0, 0.0, 1.0]; 4])
.with_indices(vec![0, 1, 2, 0, 2, 3])
}
#[test]
fn new_starts_with_no_optional_attributes() {
let geo = Geometry::new(vec![[0.0, 0.0, 0.0]]);
assert!(geo.normals.is_none());
assert!(geo.uvs.is_none());
assert!(geo.colors.is_none());
assert!(geo.indices.is_none());
}
#[test]
fn builder_chain_validates() {
assert!(quad().validate().is_ok());
}
#[test]
fn validate_rejects_empty_positions() {
let geo = Geometry::new(Vec::new());
assert_eq!(geo.validate(), Err(GeometryError::EmptyPositions));
}
#[test]
fn validate_rejects_attribute_count_mismatch() {
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
.with_normals(vec![[0.0, 0.0, 1.0]]);
assert_eq!(
geo.validate(),
Err(GeometryError::NormalCountMismatch {
positions: 2,
normals: 1
})
);
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]).with_uvs(vec![[0.0, 0.0]]);
assert_eq!(
geo.validate(),
Err(GeometryError::UvCountMismatch {
positions: 2,
uvs: 1
})
);
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
.with_colors(vec![[1.0, 1.0, 1.0, 1.0]]);
assert_eq!(
geo.validate(),
Err(GeometryError::ColorCountMismatch {
positions: 2,
colors: 1
})
);
}
#[test]
fn validate_rejects_out_of_bounds_index() {
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]).with_indices(vec![0, 2]);
assert_eq!(
geo.validate(),
Err(GeometryError::IndexOutOfBounds {
index: 2,
vertex_count: 2
})
);
}
#[test]
fn to_vertices_fills_defaults() {
let geo = Geometry::new(vec![[0.5, 0.5, 0.0], [1.5, 0.5, 0.0]]);
let vertices = geo.to_vertices();
assert_eq!(vertices.len(), 2);
assert_eq!(vertices[0].position, [0.5, 0.5, 0.0]);
assert_eq!(vertices[0].normal, [0.0, 0.0, 1.0]);
assert_eq!(vertices[0].uv, [0.0, 0.0]);
assert_eq!(vertices[0].color, [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn to_vertices_copies_provided_attributes() {
let vertices = quad().to_vertices();
assert_eq!(vertices.len(), 4);
assert_eq!(vertices[3].position, [0.0, 1.0, 0.0]);
assert_eq!(vertices[3].normal, [0.0, 0.0, 1.0]);
assert_eq!(vertices[3].uv, [0.0, 1.0]);
assert_eq!(vertices[3].color, [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn try_into_vertices_propagates_validation_errors() {
let geo = Geometry::new(Vec::new());
assert!(matches!(
geo.try_into_vertices(),
Err(GeometryError::EmptyPositions)
));
assert!(quad().try_into_vertices().is_ok());
}
#[test]
fn indices_accessor_returns_the_slice() {
let geo = quad();
assert_eq!(geo.indices(), Some(&[0, 1, 2, 0, 2, 3][..]));
}
}
+3 -3
View File
@@ -2,18 +2,18 @@
//!
//! Provides core mathematical types and utilities for 3D graphics operations, including:
//! - `Transform` for object positioning, rotation, and scaling
//! - `Camera` for view and projection matrix calculations
//! - `Geometry` for mesh vertex data representation
//! - `primitives` for procedural mesh generators (cube, plane, sphere, cylinder, cone, torus)
//!
//! ## Interaction with Other Modules
//! - `scene::Scene` uses `Transform` to manage entity positions
//! - `renderer::Renderer` uses `Transform` and `Camera` to compute matrices for shaders
//! - `renderer::Renderer` uses `Transform` to compute world matrices for shaders
//! - `resources::Mesh` stores vertex data in `Geometry` format
//! - `resources::Camera` (view/projection matrices) lives in the `resources` module
//!
//! ## Files
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
//! - `camera.rs`: Defines the `Camera` struct and view/projection matrix calculations
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
pub mod geometry;
+50 -50
View File
@@ -1,30 +1,30 @@
//! # Primitives Module — Meshes géométriques prêts à l'emploi (Étape 15, ROADMAP 2.2)
//! # Primitives Module — Ready-to-use geometry meshes (Step 15, ROADMAP 2.2)
//!
//! Générateurs de `Geometry` procédurales pour les formes 3D courantes, utilisables
//! directement dans WSGL sans import wgpu : `cube`, `plane`, `uv_sphere`, `icosphere`,
//! `cylinder`, `cone` (et `torus` en bonus).
//! Procedural `Geometry` generators for common 3D shapes, usable directly in WSG
//! without importing wgpu: `cube`, `plane`, `uv_sphere`, `icosphere`,
//! `cylinder`, `cone` (and `torus` as a bonus).
//!
//! ## Conventions
//! - Axe **Y vers le haut**, origine centrée (sauf `plane`, ancré dans le plan XZ autour de 0).
//! - Normales **orientées vers l'extérieur** (pertinentes pour l'éclairage Phong, le culling
//! restant désactivé par défaut).
//! - UVs dans [0,1]², aussi continus que possible ; `uv_sphere`/`icosphere` projettent depuis
//! des coordonnées sphériques.
//! - Chaque générateur renvoie une `Geometry` **complète** (positions + normales + UVs +
//! indices, pas de couleurs → défaut blanc opaque via `Geometry::to_vertices`).
//! - **Y-up** axis, origin-centered (except `plane`, which lies in the XZ plane around 0).
//! - Normals **pointing outward** (meaningful for Phong lighting; culling stays disabled
//! by default).
//! - UVs in [0,1]², as continuous as possible; `uv_sphere`/`icosphere` project from
//! spherical coordinates.
//! - Each generator returns a **complete** `Geometry` (positions + normals + UVs +
//! indices, no colors → opaque white default via `Geometry::to_vertices`).
//!
//! ## Invariant
//! Toute géométrie produite passe `Geometry::validate()` sans erreur (vérifié par les tests).
//! Every produced geometry passes `Geometry::validate()` without error (checked by the tests).
use crate::math::Geometry;
use glam::Vec3;
use std::collections::HashMap;
/// Génére un cube centré à l'origine, d'arête `size`, avec une normale et des UVs par face.
/// 24 sommets (4 par face) + 36 indices. Reproduit exactement le `cube_geometry` historique des
/// exemples (Étape 5/10) pour assurer la non-régression.
/// Generates a cube centered at the origin, edge `size`, with one normal and UVs per face.
/// 24 vertices (4 per face) + 36 indices. Replicates exactly the historical `cube_geometry` of
/// the examples (Step 5/10) to guarantee non-regression.
pub fn cube(size: f32) -> Geometry {
let s = size * 0.5; // demi-arête
let s = size * 0.5; // half edge
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
@@ -76,8 +76,8 @@ pub fn cube(size: f32) -> Geometry {
.with_indices(indices)
}
/// Génére un plan horizontal dans le plan XZ (normale +Y), centré en (0, 0, 0), de dimensions
/// `width` × `depth`, subdivisé en `seg_x` × `seg_z` cellules. UVs étirées sur [0,1]².
/// Generates a horizontal plane in the XZ plane (normal +Y), centered at (0, 0, 0), with
/// `width` × `depth` dimensions, subdivided into `seg_x` × `seg_z` cells. UVs stretched over [0,1]².
pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry {
let sx = seg_x.max(1);
let sz = seg_z.max(1);
@@ -112,8 +112,8 @@ pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry {
.with_indices(indices)
}
/// Génére une sphère UV (latitude/longitude) de rayon `radius`, avec `sectors` segments autour et
/// `stacks` cercles verticaux. Normales lisses = position normalisée ; UVs sphériques.
/// Generates a UV (latitude/longitude) sphere of radius `radius`, with `sectors` segments around
/// and `stacks` vertical rings. Smooth normals = normalized position; spherical UVs.
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry {
let si = sectors.max(3);
let st = stacks.max(3);
@@ -155,13 +155,13 @@ pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry {
.with_indices(indices)
}
/// Génére une icosphère (icosaèdre subdivisé) de rayon `radius`. `subdivisions = 0` donne un
/// icosaèdre (12 sommets / 20 faces / 60 indices) ; chaque subdivision raffine les faces en 4.
/// Normales lisses = direction de la position ; UVs sphériques (une couture est inévitable sans UV
/// Generates an icosphere (subdivided icosahedron) of radius `radius`. `subdivisions = 0` gives
/// an icosahedron (12 vertices / 20 faces / 60 indices); each subdivision refines the faces into 4.
/// Smooth normals = position direction; spherical UVs (a seam is unavoidable without a UV
/// atlas).
pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
let t = (1.0 + 5.0_f32.sqrt()) * 0.5;
// 12 sommets unitaires (icosaèdre canonique).
// 12 unit vertices (canonical icosahedron).
let mut positions: Vec<Vec3> = [
[-1.0, t, 0.0],
[1.0, t, 0.0],
@@ -220,7 +220,7 @@ pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
}
}
// Échelle au rayon + normales (direction unitaire) + UVs sphériques.
// Scale to the radius + normals (unit direction) + spherical UVs.
let mut normals = Vec::with_capacity(positions.len());
let mut uvs = Vec::with_capacity(positions.len());
for p in &positions {
@@ -240,7 +240,7 @@ pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
.with_indices(indices)
}
/// Crée (ou retrouve) le point milieu normalisé entre `a` et `b`, poussé sur la sphère unitaire.
/// Creates (or retrieves) the normalized midpoint between `a` and `b`, pushed onto the unit sphere.
fn subdiv_midpoint(
positions: &mut Vec<Vec3>,
cache: &mut HashMap<(u32, u32), u32>,
@@ -258,16 +258,16 @@ fn subdiv_midpoint(
i
}
/// UV sphérique à partir d'une direction unitaire, dans [0,1]².
/// Spherical UV from a unit direction, in [0,1]².
fn spherical_uv(dir: Vec3) -> [f32; 2] {
let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI));
let v = 0.5 - (dir.y.asin() / std::f32::consts::PI);
[u, v]
}
/// Génére un cylindre de rayon `radius` et hauteur `height` (le long de Y, centré), avec `sectors`
/// segments. Parties : flanc (normales radiales lisses), couvercle supérieur (+Y), base inférieure
/// (-Y). UVs sur le flanc étirées [0,1]², anneaux concentriques fusionnés sur les caps.
/// Generates a cylinder of radius `radius` and height `height` (along Y, centered), with
/// `sectors` segments. Parts: side (smooth radial normals), top cap (+Y), bottom base
/// (-Y). Side UVs stretched over [0,1]², concentric rings merged on the caps.
pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
@@ -278,7 +278,7 @@ pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
Vec::<u16>::new(),
);
// Flanc : colonnes radiales × 2 rangs (bas/haut).
// Side: radial columns × 2 rows (bottom/top).
let side_base = 0u16;
for row in 0..=1 {
let y = if row == 0 { -h } else { h };
@@ -300,7 +300,7 @@ pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
indices.extend_from_slice(&[a, c, b, b, c, d]);
}
// Caps : centre + anneau à chaque extrémité.
// Caps: center + ring at each end.
for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] {
let center = positions.len() as u16;
positions.push([0.0, y, 0.0]);
@@ -327,9 +327,9 @@ pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
.with_indices(indices)
}
/// Génére un cône de rayon `radius` et hauteur `height` (sommet en +h/2, base en -h/2), fermé par une
/// base, avec `sectors` segments. Normales latérales analytiques (inclinées vers l'extérieur) ;
/// normale de la base −Y.
/// Generates a cone of radius `radius` and height `height` (apex at +h/2, base at -h/2), closed by a
/// base, with `sectors` segments. Analytical side normals (tilted outward);
/// base normal −Y.
pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
@@ -340,10 +340,10 @@ pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
Vec::<u16>::new(),
);
// Éléments latéraux : sommet + anneau de base.
// Side elements: apex + base ring.
let apex = 0u16;
positions.push([0.0, h, 0.0]);
normals.push([0.0, 1.0, 0.0]); // sommet partagé ; normal proche +Y par défaut
normals.push([0.0, 1.0, 0.0]); // shared apex; normal close to +Y by default
uvs.push([0.5, 1.0]);
let base_start = 1u16;
for s in 0..=si {
@@ -351,7 +351,7 @@ pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, -h, radius * sin_t]);
// Normale latérale : normalize(h·cosθ, r, h·sinθ).
// Side normal: normalize(h·cosθ, r, h·sinθ).
let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize();
normals.push(n.to_array());
uvs.push([u, 0.0]);
@@ -360,7 +360,7 @@ pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]);
}
// Base fermée (cercle en -h/2, normale -Y).
// Closed base (circle at -h/2, normal -Y).
let center = positions.len() as u16;
positions.push([0.0, -h, 0.0]);
normals.push([0.0, -1.0, 0.0]);
@@ -385,9 +385,9 @@ pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
.with_indices(indices)
}
/// Génére un tore (anneau) de rayon majeur `major` (centre du tube) et rayon mineur `minor`
/// (rayon du tube), subdivisé en `major_segments` × `minor_segments`. Normales lisses (direction du
/// tube) ; UVs [0,1]² (couture le long du méridien et de l'équateur du tube).
/// Generates a torus (ring) with major radius `major` (tube center) and minor radius `minor`
/// (tube radius), subdivided into `major_segments` × `minor_segments`. Smooth normals (tube
/// direction); UVs [0,1]² (seam along the tube meridian and equator).
pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry {
let mj = major_segments.max(3);
let mn = minor_segments.max(3);
@@ -422,13 +422,13 @@ pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -
let b = a + 1;
let c = a + mn + 1;
let d = c + 1;
// Triangles [a, b, c] / [b, d, c] : en face, l'angle u (majeur) croît avec +u et
// l'angle v (mineur) croît avec +v ; cross(tang_u, tang_v) pointe vers l'EXTÉRIEUR
// du tube (= la normale stockée), donc le winding est CCW vu de l'extérieur —
// cohérent avec `front_face: Face::Ccw` (culling des faces arrière).
// L'ordre [a, c, b] d'origine était inversé : la face externe (CCW vu de l'extérieur,
// normale extérieure) était Cullée et seul l'intérieur du tube, dont les normales
// pointent vers l'extérieur, restait visible — le tore apparaissait noir (N·L ≤ 0).
// Triangles [a, b, c] / [b, d, c]: on the surface, angle u (major) grows with +u and
// angle v (minor) grows with +v; cross(tang_u, tang_v) points OUTWARD from
// the tube (= the stored normal), so the winding is CCW seen from outside —
// consistent with `front_face: Face::Ccw` (back-face culling).
// The original [a, c, b] order was inverted: the external face (CCW seen from outside,
// outward normal) was culled and only the inside of the tube, whose normals
// point outward, stayed visible — the torus appeared black (N·L ≤ 0).
indices
.extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]);
}
@@ -493,7 +493,7 @@ mod tests {
let g = uv_sphere(1.0, 12, 8);
assert_eq!(g.positions.len(), (12 + 1) * (8 + 1));
assert_valid(&g);
// Normales pointent vers l'extérieur (position/rayon).
// Normals point outward (position/radius).
for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) {
let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length();
assert!(diff < 1e-4, "normal ~ position/radius, got diff {diff}");
+59
View File
@@ -43,3 +43,62 @@ impl Transform {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vec_close(a: Vec3, b: Vec3) -> bool {
(a - b).length() < 1e-5
}
#[test]
fn identity_transform_is_identity_matrix() {
assert_eq!(Transform::identity().to_matrix(), Mat4::IDENTITY);
}
#[test]
fn translation_only() {
let t = Transform {
translation: Vec3::new(1.0, 2.0, 3.0),
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
};
assert_eq!(
t.to_matrix(),
Mat4::from_translation(Vec3::new(1.0, 2.0, 3.0))
);
}
#[test]
fn scale_only() {
let t = Transform {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::new(2.0, 3.0, 4.0),
};
assert_eq!(t.to_matrix(), Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)));
}
#[test]
fn quarter_turn_around_y() {
let t = Transform {
translation: Vec3::ZERO,
rotation: Quat::from_axis_angle(Vec3::Y, std::f32::consts::FRAC_PI_2),
scale: Vec3::ONE,
};
let v = t.to_matrix().transform_point3(Vec3::X);
assert!(vec_close(v, Vec3::new(0.0, 0.0, -1.0)), "got {v}");
}
#[test]
fn combined_trs_moves_a_point() {
let t = Transform {
translation: Vec3::new(10.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
scale: Vec3::new(2.0, 2.0, 2.0),
};
let v = t.to_matrix().transform_point3(Vec3::new(1.0, 0.0, 0.0));
assert!(vec_close(v, Vec3::new(12.0, 0.0, 0.0)), "got {v}");
}
}
+34 -34
View File
@@ -21,13 +21,13 @@ use crate::utils::STANDARD_SHADER;
use std::collections::HashMap;
use std::sync::Arc;
/// Creates the two bind group layouts shared by **every** pipeline (Étape 3 — décision actée
/// « un seul layout pour tous »). Both buffers are `Uniform`, 16-byte aligned, no dynamic offset.
/// Creates the two bind group layouts shared by **every** pipeline (Step 3 — decision ratified
/// "a single layout for all"). Both buffers are `Uniform`, 16-byte aligned, no dynamic offset.
/// Matching CPU types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
/// Returns `[frame_layout, object_layout]` in renderer binding order.
///
/// - `index 0` : per-frame uniforms (view/proj/light/options), visible in both shader stages.
/// - `index 1` : per-object uniforms (model matrix), visible in the vertex stage only.
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages.
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only.
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
[
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -59,13 +59,13 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
]
}
/// Creates the texture bind group layout (group 2) shared by every pipeline (Étape 10, DRAFT D1).
/// Creates the texture bind group layout (group 2) shared by every pipeline (Step 10, DRAFT D1).
/// Binds the diffuse texture + its sampler in the **fragment** stage only. Added to every pipeline
/// layout alongside the frame (@0) + object (@1) uniform groups, so « un seul layout pour tous »
/// (Étape 3) is preserved: a texture-less `Material` binds the white 1×1 placeholder instead.
/// (Step 3) is preserved: a texture-less `Material` binds the white 1×1 placeholder instead.
///
/// - `binding 0` : sampler (filtering, linear/repeat — D3).
/// - `binding 1` : `texture_2d<f32>` diffuse.
/// - `binding 0`: sampler (filtering, linear/repeat — D3).
/// - `binding 1`: `texture_2d<f32>` diffuse.
pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("texture_bind_group_layout"),
@@ -90,13 +90,13 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou
})
}
/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Étape 14,
/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Step 14,
/// DRAFT D1/D5). Binds a **comparison** sampler + a depth texture so the fragment can run a PCF
/// `textureSampleCompare` against the shadow map. Added to every pipeline layout alongside groups
/// 0–2, keeping « un seul layout pour tous » — shadows are simply a no-op when disabled.
///
/// - `binding 0` : `sampler_comparison` (compare fn drives the shadow test, D5).
/// - `binding 1` : `texture_depth_2d` (the shadow map).
/// - `binding 0`: `sampler_comparison` (compare fn drives the shadow test, D5).
/// - `binding 1`: `texture_depth_2d` (the shadow map).
pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("shadow_map_bind_group_layout"),
@@ -122,7 +122,7 @@ pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindG
}
/// Creates the **shadow uniform** bind group layout (group 0 of the depth-only shadow pipeline,
/// Étape 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the
/// Step 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the
/// **vertex** stage only (the shadow shader transforms vertices into light-clip space).
pub fn create_shadow_uniform_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -173,13 +173,13 @@ pub fn vertex_buffer_layout() -> wgpu::VertexBufferLayout<'static> {
}
}
/// Depth texture format shared by the whole library (Étape 9, décision D1 du 2026-09-18).
/// Depth texture format shared by the whole library (Step 9, D1 decision of 2026-09-18).
///
/// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the
/// `DepthStencilState` of every pipeline (`build_pipeline`). Keeping them on the same constant
/// guarantees by construction that the pipeline depth format always matches the texture format
/// (wgpu validation error otherwise). `Depth32Float` = portée maximale (comparaison précise),
/// avec clear `1.0` (profondeur maximale au loin), `depth_compare: Less`, write enabled.
/// (wgpu validation error otherwise). `Depth32Float` = maximum precision (exact comparison),
/// with clear `1.0` (maximum depth far away), `depth_compare: Less`, write enabled.
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Shader pipeline cache: maps (shader_id, format) keys to compiled RenderPipelines.
@@ -191,16 +191,16 @@ pub struct PipelineCache {
/// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`.
shader_paths: HashMap<String, String>,
/// Shared bind group layout for the texture group (`@group(2)`), used by every pipeline and by
/// every Material's texture bind group (Étape 10, DRAFT D1 : « un seul layout pour tous »).
/// every Material's texture bind group (Step 10, DRAFT D1: "a single layout for all").
texture_bind_group_layout: wgpu::BindGroupLayout,
/// 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-Étape-10 look.
/// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look.
placeholder: Arc<Texture>,
}
impl PipelineCache {
/// Creates an empty pipeline cache with no pre-loaded shaders or pipelines, plus the shared
/// texture bind group layout (group 2) and the white placeholder texture (Étape 10).
/// texture bind group layout (group 2) and the white placeholder texture (Step 10).
/// 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.
@@ -220,20 +220,20 @@ impl PipelineCache {
/// Returns the shared white placeholder texture, bound by `Material`s without a diffuse texture.
/// Called by `Material` construction (through [`PipelineCache::texture_bind_group`]) and by
/// `Scene::get_texture` fallbacks. Étape 10 (DRAFT D1/D2).
/// `Scene::get_texture` fallbacks. Step 10 (DRAFT D1/D2).
pub fn placeholder(&self) -> &Arc<Texture> {
&self.placeholder
}
/// Returns a reference to the shared group-2 bind group layout (sampler + texture), used by
/// every Material to build its texture bind group. Étape 10 (DRAFT D1).
/// every Material to build its texture bind group. Step 10 (DRAFT D1).
pub fn texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.texture_bind_group_layout
}
/// Builds a group-2 bind group for a Material from its diffuse texture (or the white placeholder
/// when `texture` is `None`). Centralizes the sampler+texture binding so `Material` never touches
/// wgpu directly (Étape 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None`
/// wgpu directly (Step 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None`
/// for a texture-less material (binds the placeholder). Returns the group-2 bind group.
pub fn texture_bind_group(&self, texture: Option<Arc<Texture>>) -> wgpu::BindGroup {
let tex = texture.unwrap_or_else(|| self.placeholder.clone());
@@ -339,8 +339,8 @@ impl PipelineCache {
let vertex_buffer_layout = vertex_buffer_layout();
// Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
// bind group (@2, Étape 10 DRAFT D1) AND the shadow-map bind group (@3, Étape 14 D5) are
// attached to EVERY pipeline (Étape 3, décision actée « un seul layout pour tous »), even
// bind group (@2, Step 10 DRAFT D1) AND the shadow-map bind group (@3, Step 14 D5) are
// 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);
@@ -382,10 +382,10 @@ impl PipelineCache {
})],
}),
primitive: wgpu::PrimitiveState::default(),
// Étape 9 (DRAFT 9.3) : depth test activé sur TOUTE pipeline. Le format doit matcher
// l'attachment depth (DEPTH_FORMAT) — c'est garanti par la constante partagée D1.
// depth_write_enabled + depth_compare sont des Option en wgpu 30 : Some(true) → on
// écrit la profondeur ; Some(Less) → le fragment est gardé si son z est plus proche.
// Step 9 (DRAFT 9.3): depth test enabled on EVERY pipeline. The format must match
// the depth attachment (DEPTH_FORMAT) — guaranteed by the shared D1 constant.
// depth_write_enabled + depth_compare are Options in wgpu 30: Some(true) → the depth
// is written; Some(Less) → the fragment is kept if its z is closer.
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
@@ -408,10 +408,10 @@ impl PipelineCache {
}
}
/// Builds the **depth-only shadow pipeline** (Étape 14, D4): a vertex-only pipeline (no fragment
/// Builds the **depth-only shadow pipeline** (Step 14, D4): a vertex-only pipeline (no fragment
/// stage) that transforms each mesh vertex into the shadow-casting light's clip space, writing only
/// depth. Its layout is [`shadow_uniform_layout`] (group 0 : light `view_proj`) + [`object_layout`]
/// (group 1 : per-entity model matrix — the SAME layout/bind groups the main renderer already caches
/// depth. Its layout is [`shadow_uniform_layout`] (group 0: light `view_proj`) + [`object_layout`]
/// (group 1: per-entity model matrix — the SAME layout/bind groups the main renderer already caches
/// per entity, so the shadow pass reuses them directly).
///
/// `depth_stencil` writes depth with a slope-scaled bias (D5) to suppress acne on surfaces nearly
@@ -424,7 +424,7 @@ pub fn build_shadow_pipeline(
device: &wgpu::Device,
object_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
// Vertex-only shader : this pipeline sets `fragment: None`, so only the depth is produced.
// Vertex-only shader: this pipeline sets `fragment: None`, so only the depth is produced.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shadow_shader"),
source: wgpu::ShaderSource::Wgsl(crate::utils::SHADOW_SHADER.into()),
@@ -442,14 +442,14 @@ pub fn build_shadow_pipeline(
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shadow Pipeline"),
layout: Some(&shadow_pipeline_layout),
// wgpu 30 : vertex state requires `compilation_options`.
// wgpu 30: vertex state requires `compilation_options`.
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(vertex_buffer_layout())],
},
// Depth-only : no fragment state (no color output, no color target).
// Depth-only: no fragment state (no color output, no color target).
fragment: None,
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(wgpu::DepthStencilState {
@@ -457,7 +457,7 @@ pub fn build_shadow_pipeline(
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
// Étape 14 (D5) : slope-scaled depth bias against acne — surfaces nearly parallel to
// Step 14 (D5): slope-scaled depth bias against acne — surfaces nearly parallel to
// the light are pushed back slightly in the shadow map so they do not self-shadow.
bias: wgpu::DepthBiasState {
constant: 2,
+7 -6
View File
@@ -2,16 +2,17 @@
## Overview
The `resources` module defines three immutable data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
The `resources` module defines the data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
| File | Responsibility |
|------|---------------|
| **vertex** | Vertex struct — CPU-side per-attribute tuple (position [f32;3], normal [f32;3], uv [f32;2], color [f32;4]). Must match PipelineCache::build_pipeline() vertex buffer layout byte-for-byte. |
| **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc<Geometry>` (Étape 8), vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::from_geometry() which derives Vertex arrays from the Geometry and uploads them to GPU buffers. |
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. Optionally holds a diffuse `Texture` (Étape 10) plus its texture bind group. |
| **texture** | Texture struct *(Étape 10)* — GPU 2D image (device, view, sampler) in `Rgba8UnormSrgb`. Constructors: `from_rgba8` (raw bytes), `from_bytes` (encoded, via the `image` crate: png/jpeg/...), `from_file`, and `white_placeholder` (1x1 white used when no texture is attached). Sampler is linear-filtered with repeat addressing. |
| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 704 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (64 B, 4 × vec4, directional/point/spot) and `MAX_LIGHTS` (Phase 4.2, Étapes 12–13). |
| **lights** | `Lights` — the scene's CPU-side global light list (directional + point + spot) and its `into_frame_array` packing (Phase 4.2, Étapes 12–13). |
| **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc<Geometry>` (Step 8), vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::from_geometry() which derives Vertex arrays from the Geometry and uploads them to GPU buffers. |
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. Optionally holds a diffuse `Texture` (Step 10) plus its texture bind group. |
| **texture** | Texture struct *(Step 10)* — GPU 2D image (device, view, sampler) in `Rgba8UnormSrgb`. Constructors: `from_rgba8` (raw bytes), `from_bytes` (encoded, via the `image` crate: png/jpeg/...), `from_file`, and `white_placeholder` (1x1 white used when no texture is attached). Sampler is linear-filtered with repeat addressing. |
| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 704 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (64 B, 4 × vec4, directional/point/spot) and `MAX_LIGHTS` (Phase 4.2, Steps 12–13). |
| **lights** | `Lights` — the scene's CPU-side global light list (directional + point + spot) and its `into_frame_array` packing (Phase 4.2, Steps 12–13). |
| **camera** | `Camera` (position/target/up + fov/near/far, `with_perspective`, `view_matrix`/`projection_matrix`) and `CameraController` (Step 15.C — orbital: yaw/pitch/distance/target, `orbit`/`zoom`/`reset`/`apply_to`). |
## Interaction with Other Modules
+3 -3
View File
@@ -26,7 +26,7 @@ pub const DEFAULT_FAR: f32 = 100.0;
///
/// The camera defines the viewpoint (position/target/up), the projection parameters (fov, near, far)
/// and can produce the view and projection matrices uploaded each frame to the `FrameUniforms` buffer
/// (Étape 4.3). Use `Scene::set_camera` to install it as the scene's active camera.
/// (Step 4.3). Use `Scene::set_camera` to install it as the scene's active camera.
#[derive(Debug, Clone)]
pub struct Camera {
/// Position of the camera in world space
@@ -44,7 +44,7 @@ pub struct Camera {
}
impl Default for Camera {
/// Default camera : positioned at (0, 0, 3) looking at the origin with a 45° vertical fov,
/// Default camera: positioned at (0, 0, 3) looking at the origin with a 45° vertical fov,
/// near 0.1 and far 100. Good enough to frame a unit-cube scene out of the box.
fn default() -> Self {
Self::new(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y)
@@ -103,7 +103,7 @@ impl Camera {
/// poles. Kept a little under ±90°.
pub const PITCH_LIMIT: f32 = 1.45; // ~83°
/// Orbital camera controller (Étape 15, sous-volt 15.C).
/// Orbital camera controller (Step 15, sub-step 15.C).
///
/// Represents the viewpoint spherically around a `target`: `yaw` (rotation around the world-up axis),
/// `pitch` (elevation above/below the horizontal), `distance` (radius) and the look-at `target`.
+3 -3
View File
@@ -1,4 +1,4 @@
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Étapes 12–13)
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13)
//!
//! Holds the scene's global light list — directional, point and spot lights — in a CPU-side
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each
@@ -11,7 +11,7 @@
//! `num_directional + num_point..`. The index alone disambiguates the type in the fragment shader,
//! so no type field is stored in [`Light`].
//!
//! ## Non-régression
//! ## Non-regression
//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white
//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
@@ -60,7 +60,7 @@ impl Lights {
/// Returns the light at a **packed-array index** (directionals first, then point lights, then
/// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Étape 14 D7).
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Step 14 D7).
pub fn get(&self, index: usize) -> Option<&Light> {
let n_dir = self.directional.len();
if index < n_dir {
+3 -3
View File
@@ -1,15 +1,15 @@
//! # Material Module — Appearance Descriptor (shader_id → RenderPipeline + diffuse texture)
//!
//! Defines `Material`, a lightweight appearance descriptor that pairs a shader identifier with
//! a shared RenderPipeline and, since Étape 10 (DRAFT D4), an optional diffuse `Texture` plus the
//! a shared RenderPipeline and, since Step 10 (DRAFT D4), an optional diffuse `Texture` plus the
//! matching group-2 bind group. Materials are created via PipelineCache to ensure pipeline reuse—
//! multiple materials referencing the same shader_id point to the identical compiled GPU pipeline.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Identifiants**: Each Material is registered in Scene by string identifier, enabling dynamic access
//! during the render loop without borrow checker issues. The shader_id serves as the `Handle<T>` key.
//! - **Phase de Déclaration**: Materials are instantiated once in the declarative phase before the render loop begins.
//! - **Texture (Étape 10, D4)**: the appearance lives on the Material. A texture-less Material binds
//! - **Declaration Phase**: Materials are instantiated once in the declarative phase before the render loop begins.
//! - **Texture (Step 10, D4)**: the appearance lives on the Material. A texture-less Material binds
//! the shared white placeholder (DRAFT D1/D2), so every pipeline layout (`@group(2)`) is satisfied.
use crate::pipeline::PipelineCache;
+13 -13
View File
@@ -1,23 +1,23 @@
//! # Mesh Module — Persistent GPU Geometry Container
//!
//! Defines `Mesh`, a persistent GPU geometry container. Mesh data is uploaded to the GPU once at creation time
//! and remains valid across all frames until dropped. Since Étape 8 (DRAFT Étape 8.3), a Mesh also retains the
//! and remains valid across all frames until dropped. Since Step 8 (DRAFT Step 8.3), a Mesh also retains the
//! CPU geometry it was built from (`geometry: Arc<Geometry>`), giving meshes a shared, readable source of truth
//! for phases such as bounding-box culling and UV access. Since Étape 7, a Mesh may also hold a reference to the
//! for phases such as bounding-box culling and UV access. Since Step 7, a Mesh may also hold a reference to the
//! `Material` that draws it — the appearance lives on the Mesh rather than on the `Entity`.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Identifiants**: Each Mesh is registered in Scene by string identifier, enabling dynamic access
//! during the render loop without borrow checker issues. The identifier serves as the `Handle<T>` key.
//! - **Phase de Déclaration**: Meshes are instantiated once in the declarative phase before the render loop begins.
//! - **Declaration Phase**: Meshes are instantiated once in the declarative phase before the render loop begins.
//! - **Performance**: Multiple entities can reference the same Mesh, reducing memory footprint for repeated geometry.
//! - **Rétention CPU+GPU (DRAFT Étape 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
//! - **CPU+GPU retention (DRAFT Step 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
//! The GPU buffers are uploaded once at creation; the `Arc<Geometry>` is kept for CPU-side computations
//! without re-uploading per frame.
//!
//! ## Construction (DRAFT Étape 8, D4)
//! ## Construction (DRAFT Step 8, D4)
//! The single canonical constructor is [`Mesh::from_geometry`]. The former `Mesh::new`/`Mesh::with_material`
//! (which took raw `&[Vertex]`) were removed in Étape 8: the `Scene` declares meshes from a `Geometry`, and
//! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and
//! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`.
use crate::math::Geometry;
@@ -26,11 +26,11 @@ use std::sync::Arc;
use wgpu::util::DeviceExt;
/// Persistent GPU geometry: vertex positions, optional indices, draw call counters, and the retained
/// CPU `Geometry` (Étape 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
/// CPU `Geometry` (Step 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
/// Renderer for every frame. A Mesh optionally references the `Material` used to render it (`Option<Arc<Material>>`).
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Étape 7.3.5).
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Step 7.3.5).
pub struct Mesh {
/// Shared CPU geometry this mesh was built from (Étape 8, D5). Retained for CPU-side computation
/// Shared CPU geometry this mesh was built from (Step 8, D5). Retained for CPU-side computation
/// (bounding boxes, UV access, normal queries) and shared across meshes with identical geometry.
geometry: Arc<Geometry>,
/// GPU buffer containing vertex attribute data (position, UV, color).
@@ -42,12 +42,12 @@ pub struct Mesh {
/// Number of indices in the index buffer. Used as `0..num_indices` for indexed draws.
pub num_indices: u32,
/// The Material used to render this mesh. `None` until assigned; the Renderer falls back to the
/// Scene's default material when absent (DRAFT Étape 7.3.5).
/// Scene's default material when absent (DRAFT Step 7.3.5).
material: Option<Arc<Material>>,
}
impl Mesh {
/// Canonical constructor (Étape 8, D4): builds GPU buffers from a shared CPU `Geometry`.
/// Canonical constructor (Step 8, D4): builds GPU buffers from a shared CPU `Geometry`.
///
/// Inputs: device (GPU command source for buffer creation), geometry (shared CPU vertex data to
/// upload), material (optional appearance; `None` falls back to the Scene default at draw time).
@@ -57,7 +57,7 @@ impl Mesh {
/// and set `num_indices`, else leave it `None`.
///
/// The provided `geometry` is retained on the mesh (`geometry` accessor) alongside the uploaded GPU
/// buffers, so the CPU data remains readable for later phases without re-uploading each frame (DRAFT Étape 8, D5).
/// buffers, so the CPU data remains readable for later phases without re-uploading each frame (DRAFT Step 8, D5).
pub fn from_geometry(
device: &wgpu::Device,
geometry: Arc<Geometry>,
@@ -91,7 +91,7 @@ impl Mesh {
}
}
/// Returns a reference to the shared CPU geometry this mesh was built from (Étape 8, D5).
/// Returns a reference to the shared CPU geometry this mesh was built from (Step 8, D5).
/// Read-only accessor for CPU-side queries (bounding boxes, UVs, normals).
pub fn geometry(&self) -> &Arc<Geometry> {
&self.geometry
+1 -1
View File
@@ -32,6 +32,6 @@ pub use uniform::{
};
pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes
// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes
// from `wsg_lib::resources::Geometry` without importing `math` separately.
pub use crate::math::Geometry;
+11 -11
View File
@@ -2,15 +2,15 @@
//!
//! Defines `Texture`, the GPU representation of a diffuse image: the backing `wgpu::Texture`,
//! its `TextureView` (for sampling in the shader) and its `Sampler` (filtering/address mode).
//! Added in Étape 10 (DRAFT D3) to texturize the standard shader via bind group `@group(2)`.
//! Added in Step 10 (DRAFT D3) to texturize the standard shader via bind group `@group(2)`.
//!
//! ## Architecture Notes (per DRAFT Étape 10, D3/D4)
//! - **Format** : `Rgba8UnormSrgb` (espace sRGB, correct pour une couleur diffuse).
//! - **Usage** : `TEXTURE_BINDING | COPY_DST` (échantillonnée en fragment, remplie par upload CPU).
//! - **Mipmaps** : objet unique (`mip_level_count: 1` — YAGNI, pas de génération de mipmaps à cette étape).
//! - **Sampler** : `Linear` + `Repeat` (filtrage doux, coordonnées UV classiques).
//! - **Placeholder** : une texture blanche 1×1 (texel identité multiplicative) sert au `Material`
//! sans texture — voir `white_placeholder`.
//! ## Architecture Notes (per DRAFT Step 10, D3/D4)
//! - **Format**: `Rgba8UnormSrgb` (espace sRGB, correct pour une couleur diffuse).
//! - **Usage**: `TEXTURE_BINDING | COPY_DST` (sampled in the fragment, filled by CPU upload).
//! - **Mipmaps**: single level (`mip_level_count: 1` — YAGNI, no mipmap generation at this step).
//! - **Sampler**: `Linear` + `Repeat` (smooth filtering, classic UV coordinates).
//! - **Placeholder**: a white 1×1 texture (multiplicative-identity texel) serves the texture-less
//! `Material` — see `white_placeholder`.
use std::sync::Arc;
@@ -30,7 +30,7 @@ pub enum TextureError {
}
/// GPU diffuse texture: backing texture, sampling view and sampler. Immutable after creation,
/// shared (behind `Arc`) by `Material`s via the Scene resource depot (Étape 10, D4).
/// shared (behind `Arc`) by `Material`s via the Scene resource depot (Step 10, D4).
pub struct Texture {
/// Backing GPU image, kept alive for the whole lifetime of the texture.
_texture: wgpu::Texture,
@@ -64,7 +64,7 @@ impl Texture {
height,
depth_or_array_layers: 1,
},
mip_level_count: 1, // YAGNI : pas de mipmaps à cette étape (DRAFT D3)
mip_level_count: 1, // YAGNI: no mipmaps at this step (DRAFT D3)
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: TEXTURE_FORMAT,
@@ -118,7 +118,7 @@ impl Texture {
bytes: &[u8],
) -> Result<Self, TextureError> {
let img = image::load_from_memory(bytes)?;
// Normalise en RGBA8 (sous-échantillonne Luma8/Rgb8 en RGBA8, comme le veut Rgba8UnormSrgb).
// Normalize to RGBA8 (downsamples Luma8/Rgb8 to RGBA8, as Rgba8UnormSrgb requires).
let rgba = img.to_rgba8();
Self::from_rgba8(device, queue, rgba.width(), rgba.height(), &rgba, label)
}
+14 -14
View File
@@ -4,9 +4,9 @@
//! Their memory layout must match **exactly** the WGSL uniforms declared in `standard_shader.wgsl`
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding.
//!
//! Two bind groups are shared by every pipeline (single-layout decision, Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights + shadow) → 784 bytes
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes
//! Two bind groups are shared by every pipeline (single-layout decision, Step 3):
//! - `@group(0) @binding(0)`: `FrameUniforms` (per-frame: camera + lights + shadow) → 784 bytes
//! - `@group(1) @binding(0)`: `ObjectUniform` (per-entity model matrix) → 64 bytes
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache::build_pipeline()` creates the two bind group layouts matching these types.
@@ -20,7 +20,7 @@ use glam::{Mat4, Vec4};
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
/// Byte size of the per-object uniform buffer (`ObjectUniform`).
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Étape 14).
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Step 14).
pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64;
/// Maximum number of lights stored in the per-frame uniform buffer.
@@ -54,7 +54,7 @@ pub struct Light {
pub dir_angle: Vec4,
}
/// The runtime-disambiguated type of a [`Light`] (Étape 14, D6). Not stored in the struct (the array
/// The runtime-disambiguated type of a [`Light`] (Step 14, D6). Not stored in the struct (the array
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
/// which must reject point lights (cubemap shadows are out of scope).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -87,12 +87,12 @@ impl Light {
}
}
/// Per-frame GPU uniforms : camera matrices + ambient + global light list + shadow data + options.
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data + options.
///
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
/// light selection, the light view-projection matrix + shadow parameters, then options — total
/// **784 bytes** (Étape 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
/// **784 bytes** (Step 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
/// bind-group layout uses `min_binding_size: None`, so extending this struct is transparent
/// (no relayout).
#[repr(C)]
@@ -115,13 +115,13 @@ pub struct FrameUniforms {
pub num_point: u32,
/// Number of active spot lights (indices after the point lights).
pub num_spot: u32,
/// Index (in the packed frame array) of the single shadow-casting light (DRAFT Étape 14, D1).
/// Index (in the packed frame array) of the single shadow-casting light (DRAFT Step 14, D1).
/// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12.
pub shadow_light_index: u32,
/// View-projection matrix of the shadow-casting light (world → light clip space), used to
/// reproject fragments into the shadow map (DRAFT Étape 14, D3). Offset 176 + 64·MAX_LIGHTS.
/// reproject fragments into the shadow map (DRAFT Step 14, D3). Offset 176 + 64·MAX_LIGHTS.
pub light_view_proj: Mat4,
/// Shadow sampling parameters (DRAFT Étape 14, D5). `x` = shadow map size in pixels (for
/// Shadow sampling parameters (DRAFT Step 14, D5). `x` = shadow map size in pixels (for
/// texel-space PCF offsets), `y` = depth bias, `z`/`w` reserved. Offset 240 + 64·MAX_LIGHTS.
pub shadow_params: Vec4,
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting);
@@ -131,7 +131,7 @@ pub struct FrameUniforms {
}
impl Default for FrameUniforms {
/// Sensible defaults : identity camera, white ambient, a single white directional light along
/// Sensible defaults: identity camera, white ambient, a single white directional light along
/// +Z (from surface toward light), *lit* mode — reproduces the pre-multi-light look exactly.
/// No point or spot lights.
fn default() -> Self {
@@ -149,7 +149,7 @@ impl Default for FrameUniforms {
num_directional: 1,
num_point: 0,
num_spot: 0,
// Shadows off by default (Étape 14, D7 — non-régression) : sentinel = MAX_LIGHTS.
// Shadows off by default (Step 14, D7 — non-regression): sentinel = MAX_LIGHTS.
shadow_light_index: MAX_LIGHTS as u32,
light_view_proj: Mat4::IDENTITY,
shadow_params: Vec4::ZERO,
@@ -158,7 +158,7 @@ impl Default for FrameUniforms {
}
}
/// Per-object GPU uniforms : the entity's world-space model matrix.
/// Per-object GPU uniforms: the entity's world-space model matrix.
///
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
#[repr(C)]
@@ -168,7 +168,7 @@ pub struct ObjectUniform {
pub model: Mat4,
}
/// GPU uniforms of the depth-only shadow pass (Étape 14, D4): the shadow-casting light's
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
/// view-projection matrix. Mirrors the WGSL `ShadowUniform` struct in `shadow_shader.wgsl`.
/// 64 bytes, `Pod`, bound as group 0 of the shadow pipeline.
#[repr(C)]
+1 -1
View File
@@ -19,4 +19,4 @@ The `scene` module defines Scene, the declarative layer of the WSG architecture.
## Architecture Note
Per [ARCHI_APP](../../docs/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
Per [ARCHI_APP](../../../docs/tech/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
+1 -1
View File
@@ -1,7 +1,7 @@
//! # Entity Module
//!
//! Defines `Entity`, the renderable association between a Mesh and its own world-space `Transform`.
//! Since Étape 7 (DRAFT Étape 7.3) the appearance (Material) lives **on the Mesh**, so an `Entity` only
//! Since Step 7 (DRAFT Step 7.3) the appearance (Material) lives **on the Mesh**, so an `Entity` only
//! references the mesh by identifier and carries the per-entity placement. Each entry of `Scene::entities`
//! is an `Entity`.
//!
+109 -20
View File
@@ -4,14 +4,14 @@
//! the render loop starts, then associate entities via labels. At runtime, Scene provides immutable access to these resources without exposing raw wgpu handles.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **La Recette**: Scene is central to the "App" facade workflow. In the Phase de Déclaration, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During Phase d'Exécution, Renderer iterates Scene entities for rendering.
//! - **The Recipe**: Scene is central to the "App" facade workflow. In the Declaration Phase, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During the Execution Phase, Renderer iterates Scene entities for rendering.
//! - **Identifiants**: All resource registration uses string identifiers (`Handle<T>`/String pattern), guaranteeing memory safety
//! and avoiding borrow checker issues during dynamic updates.
//! - **Ergonomie**: Users interact only with entity-level operations (add/remove/get) rather than wgpu buffers/pipelines directly.
//!
//! ## Étape 7 — Pipeline context owned by the Scene (DRAFT Étape 7.1)
//! Since Étape 7 the Scene owns the GPU-facing material pipeline context (`SceneGpu` : device + format + `PipelineCache`)
//! ## Step 7 — Pipeline context owned by the Scene (DRAFT Step 7.1)
//! Since Step 7 the Scene owns the GPU-facing material pipeline context (`SceneGpu`: device + format + `PipelineCache`)
//! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject
//! a default material for meshes that carry none (`default_material`).
@@ -39,14 +39,14 @@ struct SceneGpu {
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Étape 4.3).
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
/// Created once during application setup; entities are added before the render loop starts.
pub struct Scene {
/// Map of mesh identifiers to owned `Arc<Mesh>` instances. Populated via `add_mesh()`.
meshes: HashMap<String, Arc<Mesh>>,
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
materials: HashMap<String, Arc<Material>>,
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Étape 10, D4).
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Step 10, D4).
/// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id.
textures: HashMap<String, Arc<Texture>>,
/// Map of entity labels to `Entity` associations. Populated via `add_entity()` / `add_entity_with_transform()`.
@@ -60,20 +60,20 @@ pub struct Scene {
/// first call. Interior-mutable so it can be filled from an immutable `&Scene` (used by the Renderer).
default_material: RefCell<Option<Arc<Material>>>,
/// Global light list (directional + point), uploaded into the frame uniforms each frame
/// (Phase 4.2, Étape 12). Default = one white directional light along +Z (non-regression).
/// (Phase 4.2, Step 12). Default = one white directional light along +Z (non-regression).
lights: Lights,
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
ambient: [f32; 3],
/// Optional shadow-casting light index (DRAFT Étape 14, D1): the index (in the packed frame
/// Optional shadow-casting light index (DRAFT Step 14, D1): the index (in the packed frame
/// array: directionals, then points, then spots) of the single light that casts a shadow.
/// `None` = shadows off (default, non-régression). Read each frame by `Renderer::render_scene`
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
/// to compute the light `view_proj` and enable shadow sampling.
shadow_caster: Option<usize>,
}
impl Scene {
/// Creates an empty scene with no registered resources or entities and a default camera
/// (`Camera::default()` : position (0,0,3), looking at origin, 45° perspective).
/// (`Camera::default()`: position (0,0,3), looking at origin, 45° perspective).
/// Called at application startup before any resource registration. The GPU pipeline context is
/// empty (`gpu: None`) until `init_gpu` is called once the `Context`/`Renderer` exist.
pub fn new() -> Self {
@@ -154,7 +154,7 @@ impl Scene {
}
/// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so
/// materials can reference it declaratively (Étape 10, D4). The texture is wrapped in `Arc` for
/// materials can reference it declaratively (Step 10, D4). The texture is wrapped in `Arc` for
/// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists.
/// Inputs: id (unique identifier), texture (GPU diffuse texture to register).
pub fn add_texture(&mut self, id: &str, texture: Texture) -> Result<String, String> {
@@ -166,13 +166,13 @@ impl Scene {
}
/// Retrieves a registered diffuse texture by its identifier, if present. Called by the user to
/// read back a texture (or by internals when resolving material↔texture links). Étape 10 (D4).
/// read back a texture (or by internals when resolving material↔texture links). Step 10 (D4).
pub fn get_texture(&self, id: &str) -> Option<&Arc<Texture>> {
self.textures.get(id)
}
/// Builds and registers a Material from a shader id **and** a diffuse texture registered via
/// [`Scene::add_texture`]. The material samples `texture_id` (Étape 10, D4). Returns Ok(id) or
/// [`Scene::add_texture`]. The material samples `texture_id` (Step 10, D4). Returns Ok(id) or
/// Err(String) if the material id exists or the texture id does not. Inputs: id (material id to
/// register), shader_id (pipeline key), texture_id (existing texture id in this Scene).
pub fn add_material_texture(
@@ -202,7 +202,7 @@ impl Scene {
}
/// Builds, (optionally) links to a Material, and registers a Mesh in one declarative call.
/// Since Étape 8 the mesh is declared from a CPU `Geometry` (DRAFT Étape 8, D4) instead of raw
/// Since Step 8 the mesh is declared from a CPU `Geometry` (DRAFT Step 8, D4) instead of raw
/// `&[Vertex]`. This builds the shared `Arc<Geometry>` and creates the GPU buffers via
/// `Mesh::from_geometry(device, arc, ...)`, then — if `material` is `Some(name)` — resolves that
/// material id and attaches it to the mesh (`Mesh::set_material`). When `material` is `None`, the
@@ -233,7 +233,7 @@ impl Scene {
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
/// orthogonal `Renderer::set_unlit` flag (DRAFT Étape 7.3.5).
/// orthogonal `Renderer::set_unlit` flag (DRAFT Step 7.3.5).
pub fn default_material(&self) -> Arc<Material> {
if let Some(m) = self.default_material.borrow().as_ref() {
return m.clone();
@@ -270,7 +270,7 @@ impl Scene {
/// Adds a directional light (direction **from the surface toward the light**, color, intensity).
/// Lights are global to the scene and uploaded into the frame uniforms each frame (Phase 4.2,
/// Étape 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no
/// Step 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no
/// dynamic UBO allocation). Inputs: dir (direction toward the light source), color (rgb),
/// intensity (multiplier).
pub fn add_directional_light(
@@ -357,7 +357,7 @@ impl Scene {
/// Selects the single shadow-casting light by **its index in the packed frame array**
/// (directionals first, then point lights, then spots — same order as
/// `Lights::into_frame_array`). `None` disables shadows (default, non-régression, Étape 14 D7).
/// `Lights::into_frame_array`). `None` disables shadows (default, non-regression, Step 14 D7).
/// The light must be **directional or spot**; a point light index disables the shadow pass
/// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or
/// `None` to turn shadows off.
@@ -415,7 +415,7 @@ impl Scene {
/// Associates an entity label with a mesh for rendering iteration, using an identity transform.
/// The appearance (Material) is read from the Mesh itself (or the Scene's default), so no
/// material_id is needed here (DRAFT Étape 7.3).
/// material_id is needed here (DRAFT Step 7.3).
/// Inputs: label (entity identifier string), mesh_id (key into meshes map).
/// Returns Ok(label) on success or Err(String) if the referenced mesh does not exist.
/// Called during scene initialization to build the renderable entity graph.
@@ -456,8 +456,8 @@ impl Scene {
}
/// Iterates all entity associations, yielding (label, mesh_ref, transform_ref) tuples.
/// The Material is **not** yielded here: since Étape 7 it is resolved from the Mesh
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Étape 7.3.4).
/// The Material is **not** yielded here: since Step 7 it is resolved from the Mesh
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Step 7.3.4).
/// Called by the orchestrator during each render pass to draw every entity in order.
pub fn iter_entities(&self) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Transform)> + '_ {
self.entities.iter().map(|(label, entity)| {
@@ -498,3 +498,92 @@ impl Scene {
self.entities.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_scene_is_empty() {
let scene = Scene::new();
assert_eq!(scene.entity_count(), 0);
assert_eq!(scene.iter_entities().count(), 0);
assert!(scene.get_mesh("nope").is_none());
assert!(scene.get_material("nope").is_none());
assert_eq!(scene.shadow_caster(), None);
assert_eq!(scene.ambient(), [1.0, 1.0, 1.0]);
}
#[test]
fn default_camera_looks_at_origin_from_plus_z() {
let scene = Scene::new();
let cam = scene.camera();
assert_eq!(cam.position, Vec3::new(0.0, 0.0, 3.0));
assert_eq!(cam.target, Vec3::ZERO);
}
#[test]
fn camera_set_and_get_roundtrip() {
let mut scene = Scene::new();
let cam = Camera::new(Vec3::new(5.0, 5.0, 5.0), Vec3::ZERO, Vec3::Y)
.with_perspective(1.0, 0.5, 50.0);
scene.set_camera(cam.clone());
assert_eq!(scene.camera().position, Vec3::new(5.0, 5.0, 5.0));
scene.camera_mut().target = Vec3::new(1.0, 0.0, 0.0);
assert_eq!(scene.camera().target, Vec3::new(1.0, 0.0, 0.0));
}
#[test]
fn default_light_list_has_one_directional() {
let scene = Scene::new();
assert_eq!(scene.lights().len(), 1);
assert_eq!(scene.lights().directional.len(), 1);
assert_eq!(scene.lights().point.len(), 0);
assert_eq!(scene.lights().spot.len(), 0);
}
#[test]
fn add_lights_until_capacity_then_rejected() {
let mut scene = Scene::new(); // starts with the default directional (1 light)
scene
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
.unwrap();
while scene.lights().len() < crate::resources::MAX_LIGHTS {
scene
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
.unwrap();
}
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS);
assert!(
scene
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
.is_err()
);
}
#[test]
fn clear_lights_keeps_ambient() {
let mut scene = Scene::new();
scene.set_ambient([0.5, 0.2, 0.1]);
scene.clear_lights();
assert!(scene.lights().is_empty());
assert_eq!(scene.ambient(), [0.5, 0.2, 0.1]);
}
#[test]
fn shadow_caster_set_and_get() {
let mut scene = Scene::new();
scene.set_shadow_caster(Some(0));
assert_eq!(scene.shadow_caster(), Some(0));
scene.set_shadow_caster(None);
assert_eq!(scene.shadow_caster(), None);
}
#[test]
fn add_entity_rejects_unknown_mesh() {
let mut scene = Scene::new();
assert!(scene.add_entity("e1", "missing_mesh").is_err());
assert!(!scene.set_entity_transform("missing", Transform::identity()));
assert!(!scene.remove_entity("missing"));
}
}
+18 -19
View File
@@ -6,9 +6,9 @@ Contains WGSL shader source files used by the PipelineCache module. These are lo
disk when referenced by their registered ID in PipelineCache.register_shader(). If a file is missing,
PipelineCache falls back to the embedded STANDARD_SHADER constant defined in utils::conf.
Depuis l'Étape 5, il n'existe plus qu'**un seul shader** : `standard_shader.wgsl` (Phong). L'ancien
`basic_shader.wgsl` a été supprimé comme pipeline séparé — le rendu 2D plat est désormais la **variante
unlit** de `standard` (décision actée dans le DRAFT : « 2D ⊂ 3D »).
Since Step 5, only **one shader** remains: `standard_shader.wgsl` (Phong). The former
`basic_shader.wgsl` was removed as a separate pipeline — flat 2D rendering is now the **unlit
variant** of `standard` (decision ratified in the DRAFT: "2D ⊂ 3D").
## Files
@@ -18,8 +18,8 @@ unlit** de `standard` (décision actée dans le DRAFT : « 2D ⊂ 3D »).
## Shader Contract (standard_shader.wgsl)
`standard_shader.wgsl` est le shader unifié (Phong) de WSG. Il expose les deux bind groups partagés
par tout matériau (Étape 3 : un seul layout pour tous).
`standard_shader.wgsl` is WSG's unified (Phong) shader. It exposes the two bind groups shared
by every material (Step 3: a single layout for all).
### Vertex Input Layout (56-byte stride — correspond au `resources::Vertex`)
@@ -32,22 +32,21 @@ par tout matériau (Étape 3 : un seul layout pour tous).
### Uniforms (bind groups)
| Group / Binding | Struct | Contenu |
| Group / Binding | Struct | Content |
|-----------------|--------|---------|
| `@group(0) @binding(0)` | `FrameUniforms` (704 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `num_spot`, `options` (.x = unlit flag) |
| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (matrice modèle de l'entité) |
| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (the entity's model matrix) |
`FrameUniforms` porte une **liste de lumières globales** (Étapes 12–13, Phase 4.2) : `lights[0..num_directional]`
sont des lumières **directionnelles** (`position_dir.xyz` = direction de la surface vers la lumière),
`lights[num_directional..num_directional + num_point]` des lumières **ponctuelles**
(`position_dir.xyz` = position monde, `radius.x` = rayon d'atténuation linéaire), et
`lights[num_directional + num_point..]` des lumières **spot** (`position_dir.xyz` = position monde,
`dir_angle.xyz` = axe du cône de la lumière vers la scène, `dir_angle.w` = cos du demi-angle).
L'index disambiguise le type — pas de drapeau. `ambient` est la couleur du terme ambiant hémisphérique.
`FrameUniforms` carries a **global light list** (Steps 12–13, Phase 4.2): `lights[0..num_directional]`
are **directional** lights (`position_dir.xyz` = direction from the surface toward the light),
`lights[num_directional..num_directional + num_point]` are **point** lights
(`position_dir.xyz` = world position, `radius.x` = linear attenuation radius), and
`lights[num_directional + num_point..]` are **spot** lights (`position_dir.xyz` = world position,
`dir_angle.xyz` = light cone axis toward the scene, `dir_angle.w` = cos of the half-angle).
The index disambiguates the type — no flag. `ambient` is the color of the hemispherical ambient term.
### Mode unlit
### Unlit mode
Un flag `options.x != 0` neutralise **toutes les lumières** et renvoie la couleur du vertex telle quelle
(couleur plate). Côté API, `Renderer::set_unlit(true)` (ou `app.renderer_mut().set_unlit(true)`)
positionne ce flag dans les frame uniforms. Ainsi le rendu 2D plat est un **cas particulier** de la 3D
éclairée.
The `options.x != 0` flag disables **all lights** and returns the vertex color as-is
(flat color). On the API side, `Renderer::set_unlit(true)` (or `app.renderer_mut().set_unlit(true)`)
sets this flag in the frame uniforms. Flat 2D rendering is thus a **special case** of lit 3D.
+6 -6
View File
@@ -11,9 +11,9 @@
//! - **app::AppBuilder** reads APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH, and APP_DEFAULT_HEIGHT for default window configuration.
/// Path to the standard (Phong) WGSL shader file on disk (runtime). Used by PipelineCache::load_shader()
/// for file-based loading. This is the unified pipeline shader (Étape 3 : un seul layout pour tous) :
/// for file-based loading. This is the unified pipeline shader (Step 3: a single layout for all):
/// it carries the full uniform contract (frame + object bind groups) and supports an unlit mode so flat
/// 2D rendering is a special case of the 3D lit path. The `basic` family was removed (Étape 5).
/// 2D rendering is a special case of the 3D lit path. The `basic` family was removed (Step 5).
///
/// NOTE: the shipped `assets/shaders/*.wgsl` files are OPTIONAL — when they are absent (library consumed
/// from a checkout without the assets directory, or from a published crate), `PipelineCache::load_shader`
@@ -26,20 +26,20 @@ pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl";
/// pipeline uses the unified layout (frame @0 + object @1), this is the only shader the library ships.
pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl");
/// Path to the depth-only **shadow** WGSL shader on disk (Étape 14, D4). Kept only for API
/// Path to the depth-only **shadow** WGSL shader on disk (Step 14, D4). Kept only for API
/// compatibility — the shadow pass always compiles the embedded `SHADOW_SHADER` directly
/// (it is internal to the library, no external file is ever read).
pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
/// The depth-only shadow WGSL shader source, embedded at compile time via `include_str!`
/// (Étape 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
/// (Step 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
pub const SHADOW_MAP_SIZE: u32 = 1024;
/// Default shadow depth bias (Étape 14, D5) subtracted from the reference depth before the
/// Default shadow depth bias (Step 14, D5) subtracted from the reference depth before the
/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled
/// bias applied on the shadow pipeline itself.
pub const SHADOW_DEPTH_BIAS: f32 = 0.006;
@@ -52,7 +52,7 @@ pub const SHADOW_SCENE_RADIUS: f32 = 5.0;
pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0];
/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout
/// so upper layers can address the shadow light safely, Étape 14 D7). Also used as the no-caster
/// so upper layers can address the shadow light safely, Step 14 D7). Also used as the no-caster
/// sentinel for `FrameUniforms.shadow_light_index`.
pub use crate::resources::uniform::MAX_LIGHTS;
+18 -18
View File
@@ -1,21 +1,21 @@
//! # Validation WGSL (naga)
//!
//! Le shader `standard_shader.wgsl` n'est pas encore chargé par un `RenderPipeline` (voir Étapes 3–5) :
//! cette validation hors-ligne via `wgpu::naga` est donc la **seule** garantie de sa validité tant qu'il
//! n'est pas branché. Elle protège contre les régressions futures (ré-édition du shader, changement de
//! layout) sans nécessiter de contexte GPU.
//! The `standard_shader.wgsl` shader is not yet loaded by a `RenderPipeline` (see Steps 3–5):
//! this offline validation via `wgpu::naga` is therefore the **only** guarantee of its validity until
//! it is wired in. It protects against future regressions (shader re-edits, layout changes)
//! without requiring a GPU context.
//!
//! Aucune nouvelle dépendance n'est introduite : `wgpu` ré-exporte `naga`, déjà dépendance de `wsg-lib`.
//! No new dependency is introduced: `wgpu` re-exports `naga`, already a `wsg-lib` dependency.
use wgpu::naga;
/// Parse et valide complètement le shader embarqué `standard_shader.wgsl` via naga.
/// Un échec ici signifie que le shader serait rejeté par `Device::create_shader_module` à l'Étape 3.
/// Parses and fully validates the embedded `standard_shader.wgsl` shader via naga.
/// A failure here means the shader would be rejected by `Device::create_shader_module` at Step 3.
#[test]
fn standard_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/standard_shader.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("standard_shader.wgsl : erreur de parsing : {e:?}"));
.unwrap_or_else(|e| panic!("standard_shader.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -23,21 +23,21 @@ fn standard_shader_is_valid_wgsl() {
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("standard_shader.wgsl : échec de validation : {e:?}"));
.unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}"));
// Contrat : exactement les deux entrées vs_main / fs_main attendues.
assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus");
// Contract: exactly the two expected entry points vs_main / fs_main.
assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected");
}
/// Parse et valide complètement le shader embarqué `shadow_shader.wgsl` (Étape 14, D4) via naga.
/// Le pipeline « shadow » est câblé directement par `build_shadow_pipeline` (sans passer par le
/// PipelineCache), donc cette validation hors-ligne est la garantie de sa validité. Le contrat
/// n'attend qu'une seule entrée (`vs_main` — pipeline sans fragment stage).
/// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.
/// The "shadow" pipeline is wired directly by `build_shadow_pipeline` (bypassing the
/// PipelineCache), so this offline validation is the guarantee of its validity. The contract
/// expects a single entry point (`vs_main` — pipeline with no fragment stage).
#[test]
fn shadow_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/shadow_shader.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("shadow_shader.wgsl : erreur de parsing : {e:?}"));
.unwrap_or_else(|e| panic!("shadow_shader.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -45,12 +45,12 @@ fn shadow_shader_is_valid_wgsl() {
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("shadow_shader.wgsl : échec de validation : {e:?}"));
.unwrap_or_else(|e| panic!("shadow_shader.wgsl: validation failed: {e:?}"));
let entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
assert_eq!(entry_names, vec!["vs_main"], "seule vs_main attendue");
assert_eq!(entry_names, vec!["vs_main"], "only vs_main expected");
}