doc
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user