diff --git a/README.md b/README.md index 52c838c..055ea63 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,83 @@ WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single `Context`, adds small building blocks (`Mesh`, `Material`, `PipelineCache`, `Frame`), and exposes the low-level primitives for advanced users. -> **Status: unstable development version.** The manual workflow below is fully working, the high-level "declarative" workflow (automatic `App` scene rendering) works for flat/NDC drawing, and the **3D MVP is reached** : the `cube` example (Étape 5) renders a rotating, Phong-lit cube through `App::render_scene`. Since Étape 8, meshes are declared from a CPU `Geometry` (retained as `Arc` on the Mesh) instead of raw vertex arrays. The GPU-driven two-pass pipeline described in the architecture docs is **not implemented yet** — see [Status](#status) and [Roadmap](#roadmap). +> **Status: unstable development version.** The **declarative workflow** (`AppBuilder` + `App` + `AppHandler`) is the **recommended** path and is fully working: scene auto-rendering (`App::render_scene`), 3D Phong lighting, textures, shadows, camera and unified input — the `demo` example is the showcase. The **manual workflow** (`Context`/`Renderer`/`PipelineCache`) coexists for fine-grained control. Since Step 8, meshes are declared from a CPU `Geometry` (retained as `Arc` on the Mesh) instead of raw vertex arrays. The GPU-driven two-pass pipeline described in the architecture docs is **not implemented yet** — see [Status](#status) and [Roadmap](#roadmap). ## Status | Area | State | |------|-------| -| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working | +| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) | | `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) | | `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) | | GPU-driven two-pass pipeline (Compute → indirect draw) | 📋 Roadmap — spec in [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) | -| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Étape 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader | +| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader | -Note: the `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is now the **single** shader the library ships. The old `basic_shader.wgsl` was removed as a separate pipeline family (Étape 5) : flat 2D drawing is the unlit variant of `standard` (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`, DRAFT « 2D ⊂ 3D »). See the `cube` example (3D, lit) and the `simple` example (2D, unlit). +Note: the `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is now the **single** shader the library ships. The old `basic_shader.wgsl` was removed as a separate pipeline family (Step 5) : flat 2D drawing is the unlit variant of `standard` (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`, DRAFT: "2D ⊂ 3D"). See the `cube` example (3D, lit) and the `simple` example (2D, unlit). ## What it does -### Manual workflow (working — recommended today) +### Declarative workflow (recommended) -Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself. This is the only workflow that renders pixels today (same code as the `manual` example): +Register your scene once in `setup()`, then let `App` handle the window lifecycle, events, +input and frame presentation — **without importing wgpu or winit**. This is the workflow of +the `simple`, `cube`, `demo`, `shadow_test` and `spot_test` examples (excerpt below is `simple`): + +```rust +use wsg_lib::app::AppBuilder; +use wsg_lib::resources::Geometry; +use wsg_lib::utils::WsgError; +use wsg_lib::AppHandler; + +struct MonQuad; + +impl AppHandler for MonQuad { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.renderer_mut().set_unlit(true); // 2D flat (optional) + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + let geometry = Geometry::new(vec![ + [-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_normals(vec![[0.0, 0.0, 1.0]; 4]) + .with_colors(vec![ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [1.0, 1.0, 0.0, 1.0], + ]) + .with_indices(vec![0, 1, 2, 0, 2, 3]); + app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material + app.scene.add_entity("quad", "quad_mesh").unwrap(); + } + // `update(&mut self, app)` — your per-frame logic (empty default). + // `render(&mut self, app, frame)` — default: `app.render_scene(frame.view())`, + // the whole scene is drawn automatically in one pass per frame. +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new().title("WSG Simple").build().await?; + app.run(MonQuad) +} +``` + +> API note: `Scene` methods currently return `Result<_, String>` — typed-error unification is +> on the roadmap. `Scene::create_mesh(id, geometry, material)` takes a CPU `Geometry` (source of +> truth, retained as `Arc` on the Mesh); `material = None` uses the scene's default +> material. + +The full user documentation (meshes, materials, lights, shadows, camera & input, all examples) +lives in [docs/user](docs/user/README.md). + +### Manual workflow (advanced — fine-grained control) + +Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself (same code +as the `manual` example): ```rust use std::sync::Arc; @@ -40,26 +98,26 @@ fn main() { // Renderer + shader cache (falls back to the embedded shader if the file is missing) // `set_unlit(true)` selects flat 2D rendering (the quad below is drawn in NDC space, unlit). - // Étape 9 : width/height size the depth buffer allocated inside the Renderer. + // Step 9: width/height size the depth buffer allocated inside the Renderer. let mut renderer = Renderer::new(&context, format, 800, 600); renderer.set_unlit(true); let mut cache = PipelineCache::new(Arc::new(context.device.clone())); cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap(); - // Material + mesh (Étape 8 : le mesh est construit depuis une `Geometry` — positions, - // attributs optionnels en builder, défauts blancs via `to_vertices`). + // Material + mesh (Step 8: the mesh is built from a `Geometry` — positions, + // optional attributes via builder, white defaults via `to_vertices`). let material = Material::new(renderer.format(), "standard", &mut cache); 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_colors(vec![ - [1.0, 0.0, 0.0, 1.0], // Rouge - [0.0, 1.0, 0.0, 1.0], // Vert - [0.0, 0.0, 1.0, 1.0], // Bleu - [1.0, 1.0, 0.0, 1.0], // Jaune + [1.0, 0.0, 0.0, 1.0], // red + [0.0, 1.0, 0.0, 1.0], // green + [0.0, 0.0, 1.0, 1.0], // blue + [1.0, 1.0, 0.0, 1.0], // yellow ]) .with_indices(vec![0, 1, 2, 0, 2, 3]); let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None); @@ -81,52 +139,11 @@ fn main() { } ``` -### Declarative workflow (work in progress) - -The intended API: register your scene's resources and entities once, then let `App` handle the window lifecycle, event processing and frame presentation. Users implement the `AppHandler` trait to inject per-frame logic: - -```rust -use wsg_lib::app::AppBuilder; -use wsg_lib::{App, AppHandler}; - -struct MyGame; - -impl AppHandler for MyGame { - // update() has an empty default — implement it to mutate scene state each frame. - // render(app, frame) has a default that draws the whole scene automatically via - // app.render_scene(frame.view()). You don't need to implement it for the common case. -} - -#[pollster::main] -async fn main() -> Result<(), wsg_lib::utils::WsgError> { - let app = AppBuilder::new().build().await?; - - // Register your scene once (string IDs), then App renders it automatically each frame. - // Since Étape 7 the Scene owns the pipeline cache: build materials/meshes through it and - // link the material to the mesh (no material_id on the entity anymore). - // Since Étape 8 meshes are declared from a `Geometry` (positions + optional attributes). - // app.renderer_mut().set_unlit(true); // select flat 2D rendering (optional) - // app.scene.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)?; - // app.scene.add_material_shader("mat", "standard")?; // build via the Scene's cache - // let geometry = wsg_lib::resources::Geometry::new(vec![[-0.5,0.5,0.0],[0.5,0.5,0.0]]) - // .with_colors(vec![[1.0,0.0,0.0,1.0],[0.0,1.0,0.0,1.0]]); - // app.scene.create_mesh("quad", geometry, Some("mat"))?; // mesh links its Material - // app.scene.add_entity("my_quad", "quad")?; - - app.run(MyGame) -} -``` - -> API note: `Scene::register_shader` / `add_material_shader` / `create_mesh` / `add_entity` and -> `PipelineCache::register_shader` currently return `Result<_, String>` — typed error unification -> is on the roadmap. Since Étape 8, `Scene::create_mesh(id, geometry, material)` takes a CPU -> `Geometry` (source of truth, retained on the Mesh) instead of raw `&[Vertex]`. - ## Architecture overview - **Manager layer (`Context`)** — owns the GPU hardware lifecycle (Instance → Surface → Adapter → Device → Queue). Created once at startup; `configure()` sets up the swapchain, `Frame` wraps each frame's surface texture + view. - **Executor layer (`Renderer`)** — binds a `Material` pipeline + `Mesh` buffers into a RenderPass and submits the commands. Rendering a whole `Scene` (`render_scene`) batches all entities into **one encoder + one submit per frame**; the low-level `render` still allocates one per object. -- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (active camera wired to the frame uniforms, Étape 4.3). `Geometry` is the CPU source of truth (positions/normals/UVs/colors), `Mesh` uploads it to GPU buffers and retains the `Arc`, `Vertex` is the interleaved upload contract (Étape 8). +- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (active camera wired to the frame uniforms, Step 4.3). `Geometry` is the CPU source of truth (positions/normals/UVs/colors), `Mesh` uploads it to GPU buffers and retains the `Arc`, `Vertex` is the interleaved upload contract (Step 8). The planned target architecture — a GPU-driven two-pass pipeline (Compute Pass: world matrices + frustum culling → Indirect Draw Buffer, then a single `draw_indexed_indirect` per frame) — is specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) but is **not implemented yet**. @@ -135,16 +152,21 @@ The planned target architecture — a GPU-driven two-pass pipeline (Compute Pass | Concept | Type | Responsibility | Status | |---------|------|---------------|--------| | App / AppBuilder | Facade | Window lifecycle + winit event loop + frame presentation | ✅ (auto scene rendering via `App::render_scene`) | -| AppHandler | Trait | User-defined `update()` / `render()` callbacks | ✅ (`Frame::view()` exposed; default `render` draws the scene) | +| AppHandler | Trait | User-defined `setup()` / `update()` / `render()` callbacks | ✅ (default `render` draws the scene via `App::render_scene`) | | Scene | Struct | String-ID registry: meshes, materials, entities | ✅ (registry auto-rendered by the facade) | | Context | Struct | GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) | ✅ | | Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (`render_scene` batches one pass/frame) | | PipelineCache | Struct | Shader → compiled RenderPipeline cache | ✅ | | Material | Struct | Shader ID → RenderPipeline | ✅ | -| Geometry | Struct | CPU-side scattered vertex data (positions/normals/UVs/colors/indices), source of truth | ✅ (Étape 8 — retained `Arc` on Mesh) | +| Geometry | Struct | CPU-side scattered vertex data (positions/normals/UVs/colors/indices), source of truth | ✅ (Step 8 — retained `Arc` on Mesh) | | Mesh / Vertex | Struct | GPU geometry container / CPU-side interleaved upload tuple | ✅ | | Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ | -| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Étape 4.3) | +| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Step 4.3) | +| Texture | Struct | GPU diffuse image (device + view + sampler, `Rgba8UnormSrgb`) | ✅ (Step 10 — `from_rgba8`/`from_bytes`/`from_file`/`white_placeholder`) | +| Lights / Light | Struct | Scene-wide light list (directional + point + spot, `MAX_LIGHTS = 8`) + ambient | ✅ (Steps 12-13) | +| CameraController | Struct | Orbital camera (yaw/pitch/distance/target; `orbit`/`zoom`/`reset`/`apply_to`) | ✅ (Step 15.C) | +| InputState | Struct | Unified keyboard/mouse state (pressed/held/released, mouse delta, scroll) | ✅ (Step 15.B — `app.input`) | +| math::primitives | Module | Procedural `Geometry` generators (cube, plane, uv_sphere, icosphere, cylinder, cone, torus) | ✅ (Step 15.A) | ## Getting started @@ -153,42 +175,55 @@ WSG is **not published on crates.io** — depend on it by path: ```toml [dependencies] wsg-lib = { path = "/path/to/wsg/lib" } -pollster = "0.4" # only if you use the async AppBuilder +pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder) +winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton) ``` | Action | Command | |--------|---------| | Build everything | `cargo build --workspace` | +| Run the showcase (primitives, lights, shadows, orbital camera) | `cargo run -p wsg-lib --example demo` | | Run the 3D MVP example | `cargo run -p wsg-lib --example cube` | -| Run the working example | `cargo run -p wsg-lib --example manual` | +| Run the minimal example | `cargo run -p wsg-lib --example simple` | +| Run the shadow / spot light showcases | `cargo run -p wsg-lib --example shadow_test` / `cargo run -p wsg-lib --example spot_test` | +| Run the advanced (manual) example | `cargo run -p wsg-lib --example manual` | | Check everything (incl. examples) | `cargo check --all-targets` | -The `manual` example is the reference for the low-level workflow. The `simple` example (App facade) registers a colored quad and renders it automatically through the declarative path — it draws a scene without importing wgpu. The `cube` example (Étape 5) demonstrates the 3D MVP: a rotating Phong-lit cube, also through the declarative path and without importing wgpu. +The `demo` example is the showcase: one of each primitive, procedural textures, three lights, a shadow-casting light and a live orbital camera. `simple` is the minimal declarative app (a colored quad, unlit); `cube` is the 3D MVP (a rotating Phong-lit, textured cube); `shadow_test` and `spot_test` isolate the shadow and spot-light systems; `manual` is the reference for the low-level workflow. All of them except `manual` use the declarative path and draw a scene **without importing wgpu**. ## Documentation -The architecture docs live in `docs/tech/` and are written in **French**. Each document states whether it describes the **current** (implemented) state or the **target** (planned, not yet implemented) architecture: +Three layers (user docs and API reference in **English**; technical docs in **French**): -- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet. +**User documentation — [docs/user](docs/user/README.md)** (how to use the API, no wgpu knowledge needed): +- [Quickstart](docs/user/quickstart.md) — first window, first object, in ~30 lines +- [Meshes](docs/user/meshes.md) · [Materials & textures](docs/user/materials.md) · [Lights](docs/user/lights.md) +- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md) + +**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture): +- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ Current facade (`App`/`AppHandler`) / 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet. - [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. 🎯 **Target** — GPU-driven pipeline, ROADMAP Phase 3. -- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. 🎯 **Target** — model for the future scene auto-render. +- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 **Target** — material batching. - [ARCHI_ARENES](docs/tech/ARCHI_ARENES.md) — 🎯 **Target/deferred** — slotmap generational handles; String IDs are used today. - [FRAME_LOOP](docs/tech/FRAME_LOOP.md) — frame lifetime and resource persistence. ✅ **Current** — implemented. +**API reference** — full rustdoc: `cargo doc -p wsg-lib --no-deps` (every public type is documented). + ## Roadmap 1. ✅ **Scene auto-rendering** — `App::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.) 2. **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, single `draw_indexed_indirect` (see ARCHI_CPU_GPU). 3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering. -4. ✅ **Real 3D pipeline (MVP atteint)** — MVP uniforms + camera support in the vertex shader. *(Engine plumbing done 2026-09-16 ; Étape 5, 2026-09-17 : `standard` branché sur l'exemple `cube` — un cube unitaire éclairé (Phong) qui tourne, rendu automatiquement par `App::render_scene`. Retrait de `basic` : le 2D plat = variante unlit de `standard` via `Renderer::set_unlit`.)* +4. ✅ **Real 3D pipeline (MVP reached)** — MVP uniforms + camera support in the vertex shader. *(Engine plumbing done 2026-09-16; Step 5, 2026-09-17: `standard` wired into the `cube` example — a unit cube lit (Phong) and spinning, rendered automatically by `App::render_scene`. Removal of `basic`: flat 2D = unlit variant of `standard` via `Renderer::set_unlit`.)* 5. **Typed resource handles** — keep String IDs for the MVP (current design, source of truth in `Scene`); slotmap-based generational handles (`ARCHI_ARENES.md`) are deferred to a later performance pass. 6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors. -7. ✅ **CPU geometry storage (Étape 8)** — `Mesh` retains a shared `Arc` (CPU source of truth with colors) alongside its GPU buffers; meshes are declared from a `Geometry` via `Mesh::from_geometry`/`Scene::create_mesh(id, geometry, material)` instead of raw `&[Vertex]` arrays. (Done 2026-09-18; `transform` stays on `Entity` — deviation D3.) -8. ✅ **Diffuse textures (Étape 10, Phase 4.1)** — `resources::Texture` (GPU image: device+view+sampler, `Rgba8UnormSrgb`, loaders `from_rgba8`/`from_bytes`/`from_file`) attached to a `Material` as diffuse texture. The `standard` shader samples it via bind group **@2** (shared layout: sampler+texture); UVs are forwarded as vertex attribute location 2. Without a texture the material uses a shared 1×1 white placeholder so lit and unlit rendering are unchanged (no regression). The `cube` example now uses a procedural checkerboard texture. (Done 2026-09-18.) -9. ✅ **Window resize (Étape 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.) -10. ✅ **Multi-lighting (Étape 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.) -11. ✅ **Spot lights (Étape 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.) -12. ✅ **Shadows — shadow mapping (Étape 14, Phase 4.2, optionnel)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.) -13. ✅ **Procedural primitive meshes (Étape 15.A)** — `math::primitives` provides drop-in `Geometry` generators (`cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) with positions + per-face/smooth normals + UVs + indices. Re-exported at `math::*`. The `cube` and `spot_test` examples now reuse `math::cube(1.0)` (the `cube_geometry` helper was factored away; `shadow_test` keeps its generic `box_geometry`). (Done 2026-09-20; 6 unit tests.) -14. ✅ **Unified input (Étape 15.B)** — `core::input::InputState` gives cross-frame **pressed/held/released** semantics for keyboard (physical `KeyCode`) and mouse (buttons, position, per-frame delta, wheel scroll), rotated by `begin_frame`/`end_frame` around `AppHandler::update`. `App` exposes it as a public `input` field, fed from winit `WindowEvent`s and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.) -15. ✅ **Orbital camera + final demo (Étape 15.C)** — `resources::CameraController` (yaw/pitch/distance/target, `apply_to` writes into a `Camera`, drag-orbit + wheel-zoom + clamps) drives the new `demo` example: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom / `R` reset / `1`/`2`/`3` view presets. Run with `cargo run -p wsg-lib --example demo`. (Done 2026-09-20; runtime-verified headless.) +7. ✅ **CPU geometry storage (Step 8)** — `Mesh` retains a shared `Arc` (CPU source of truth with colors) alongside its GPU buffers; meshes are declared from a `Geometry` via `Mesh::from_geometry`/`Scene::create_mesh(id, geometry, material)` instead of raw `&[Vertex]` arrays. (Done 2026-09-18; `transform` stays on `Entity` — deviation D3.) +8. ✅ **Diffuse textures (Step 10, Phase 4.1)** — `resources::Texture` (GPU image: device+view+sampler, `Rgba8UnormSrgb`, loaders `from_rgba8`/`from_bytes`/`from_file`) attached to a `Material` as diffuse texture. The `standard` shader samples it via bind group **@2** (shared layout: sampler+texture); UVs are forwarded as vertex attribute location 2. Without a texture the material uses a shared 1×1 white placeholder so lit and unlit rendering are unchanged (no regression). The `cube` example now uses a procedural checkerboard texture. (Done 2026-09-18.) +9. ✅ **Window resize (Step 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.) +10. ✅ **Multi-lighting (Step 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.) +11. ✅ **Spot lights (Step 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.) +12. ✅ **Shadows — shadow mapping (Step 14, Phase 4.2, optional)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.) +13. ✅ **Procedural primitive meshes (Step 15.A)** — `math::primitives` provides drop-in `Geometry` generators (`cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) with positions + per-face/smooth normals + UVs + indices. Re-exported at `math::*`. The `cube` and `spot_test` examples now reuse `math::cube(1.0)` (the `cube_geometry` helper was factored away; `shadow_test` keeps its generic `box_geometry`). (Done 2026-09-20; 6 unit tests.) +14. ✅ **Unified input (Step 15.B)** — `core::input::InputState` gives cross-frame **pressed/held/released** semantics for keyboard (physical `KeyCode`) and mouse (buttons, position, per-frame delta, wheel scroll), rotated by `begin_frame`/`end_frame` around `AppHandler::update`. `App` exposes it as a public `input` field, fed from winit `WindowEvent`s and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.) +15. ✅ **Orbital camera + final demo (Step 15.C)** — `resources::CameraController` (yaw/pitch/distance/target, `apply_to` writes into a `Camera`, drag-orbit + wheel-zoom + clamps) drives the new `demo` example: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom / `R` reset / `1`/`2`/`3` view presets. Run with `cargo run -p wsg-lib --example demo`. (Done 2026-09-20; runtime-verified headless.) +16. ✅ **User documentation (Step 16, Phase 5)** — `docs/user/` (quickstart, meshes, materials, lights, shadows, camera & input, examples) written in English and cross-linked to each other, to the tech docs and to rustdoc; tech docs interlinked with their stale status banners refreshed; this README re-anchored (declarative workflow = recommended, manual = advanced, `demo` = showcase, pollster 1.x). (Done 2026-07-19.) diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 1a0e4fe..7184bfb 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,45 +1,12 @@ -# DRAFT — Étape suivante +# Prochaine étape -> 📅 **Étape 15 — TERMINÉE (2026-09-20).** Document vidé conformément à la convention -> (« ce document est vidé à la complétion de chaque étape »). Le plan détaillé (objectifs, -> décisions D1–D9, détail d'implémentation, périmètre) est archivé dans l'historique git du -> présent fichier — `git log -- docs/DRAFT.md`. +> Étape 16 (Phase 5 — Documentation & Polish) **terminée** le 2026-07-19. +> +> Traduction anglaise de toute la documentation (hors `docs/tech/`, DRAFT/PLAN/ROADMAP) **terminée** le 2026-07-19 : +> `docs/user/*`, `README.md`, READMEs de modules, doc/rustdoc de tous les `.rs` (src + examples + tests), +> `Étape`→`Step` global. Vérifications : 50 tests OK, `cargo fmt` clean, aucun lien cassé, 0 accent restant hors zone franche. -## Bilan de l'Étape 15 (archive) - -**Objectif atteint** : un démonstrateur `demo` interactif cumulant les étapes 8-14 — un sol texturé -damier + 6 primitives (cube, sphère UV, icosphère, cylindre, cône, tore) texturées, éclairées -(multi-lumières + spot, Étapes 12-13), projetant des **ombres** (Étape 14), et pilotées par une -**caméra orbitale** au clavier/souris (glisser = orbite, molette = zoom, `R` = reset, `1`/`2`/`3` = -présettes) via un nouveau **module d'input unifié** — le tout dans le workflow déclaratif -`AppBuilder` + `AppHandler`, sans importer wgpu. - -**Trois volets livrés :** -- **15.A — `math::primitives`** : générateurs de `Geometry` prêts à l'emploi (`cube`, `plane`, - `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) + 6 tests. Le `cube_geometry` des exemples - (`cube.rs`, `spot_test.rs`) a été factorisé vers `math::cube(1.0)`. -- **15.B — `core::input`** : `InputState` unifié (clavier `KeyCode` / souris position+delta+molette, - sémantique pressed/held/released) branché dans `App` (`app.input` public, rotation - `begin_frame`/`end_frame` autour de `update`) + 5 tests. Gamepad **reporté** (D7) — champ réservé. -- **15.C — `demo` + caméra orbitale** : `resources::CameraController` (yaw/pitch/distance/target, - `apply_to`) + exemple `demo` (sol + 6 primitives + lumières + ombres + caméra orbitale). - -**Livraison** : ROADMAP 2.2 (primitifs) et 2.3 (input) cochées (gamepad `[~]` partiel) ; README items -13-15 ajoutés ; `cargo fmt --all -- --check` propre ; build workspace + exemples ; 28 tests unitaires -+ doctests au vert ; `demo` vérifié au runtime (headless). - -**Corrections de bugs découvertes en cours de route :** -- *Ombres du `demo`* : `set_shadow_caster(Some(0))` désignait la lumière directionnelle **+Z par - défaut** préchargée par `Lights::new()` (index 0 packé) au lieu de la lumière chaude du demo - (index 1) → la caméra d'ombre regardait −Z et des objets non-alignés s'occluaient mutuellement - (tore/cône noircis). Corrigé en `Some(1)`. -- *Tore noir* : le winding du tore (`primitives::torus`) était inversé (`[a, c, b]`), la face externe - était cullée et seul l'intérieur (normales vers l'extérieur → N·L ≤ 0) restait visible. Corrigé en - `[a, b, c]` + `[b, d, c]` (CCW vu de l'extérieur). -- *`App::run`* : ajout d'un `device.poll()` par frame — sans lui, les callbacks asynchrones wgpu - (`on_submitted_work_done`, `map_async`) ne firent jamais dans la boucle de production. -- *Docs* : `STANDARD_SHADER_PATH` pointait vers un fichier absent (le vrai shader est embarqué via - `include_str!`) ; le fallback est attendu et inoffensif — doc corrigée. - -**Hors périmètre (reporté)** : `set_active_camera` multi-caméras (2.1), `InputUniforms` WGSL -(debug/gizmo), GPU-driven (Phase 3), batching matériau (4.3), LOD, HDR/tone mapping, gamepad complet. +## Prochaines options +- **Phase 3 — GPU-driven rendering** (ROADMAP 3.1/3.2/3.3) : indirect draw, buffers de paramètres GPU, culling GPU. C'est le gros morceau performance qui reste. +- **Phase 4.4 — Performance** : LOD, instancing/multi-instancing, occlusion culling, batching par matériau (4.3). +- Multi-caméras (`scene.set_active_camera`) — restant de la Phase 2.1. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index be740ac..3a52a96 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -102,7 +102,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } ### 2.1 Camera dans la Scene - [x] Intégrer `Camera` comme ressource de la Scene (Étape 4.3 : `Scene::set_camera` / `camera()`, caméra active unique) - [ ] Permettre plusieurs caméras (actuelle/inactive) et une sélection par identifiant (`scene.set_active_camera(camera_id)`) -- [ ] Exposer une caméra orbitale contrôlable (exemple final, Phase 5) +- [x] Exposer une caméra orbitale contrôlable (exemple final, Phase 5) — *(Étape 15.C, 2026-09-20 : `CameraController` orbitale pilotée par l'input unifié, branchée sur l'exemple `demo`)* ### 2.2 Meshes primitifs (bibliothèque procédurale, WSGL) - [x] Module `math::primitives` générant des `Geometry` prêts à l'emploi (positions + normales + UVs + indices) : `cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone` (et `torus` en bonus) — *(Étape 15.A, 2026-09-20 : implémenté, commit `4da89c7`)* @@ -181,10 +181,10 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } ## Phase 5️⃣ — Documentation & Polish -- [ ] Exemple complet : mesh texturé, éclairé, avec caméra orbitale -- [ ] Documentation API (`docs/ARCHI_SCENE.md`) -- [ ] Tests unitaires : `Geometry`, `Scene`, `Transform` -- [ ] README mis à jour avec les nouvelles fonctionnalités +- [x] Exemple complet : mesh texturé, éclairé, avec caméra orbitale — *(Étape 15, 2026-09-20 : exemple `demo` — les 7 primitives, textures procédurales, 3 lumières, ombre portée, caméra orbitale live ; vérifié headless)* +- [x] Documentation API — *(Étape 16, 2026-07-19 : guide utilisateur `docs/user/` en français (8 pages interconnectées) + rustdoc complet sur toute l'API publique ; le `ARCHI_SCENE.md` séparé prévu est remplacé par les pages `docs/user/` + rustdoc — DRAFT Étape 16, décision D3)* +- [x] Tests unitaires : `Geometry`, `Scene`, `Transform` — *(Étape 16 : modules de tests ajoutés à `math/geometry.rs`, `math/transform.rs`, `scene/scene.rs` ; 28 tests unitaires + doctests au total, `cargo test --workspace` vert)* +- [x] README mis à jour avec les nouvelles fonctionnalités — *(Étape 16 : README racine re-ancré — workflow déclaratif = recommandé, manuel = avancé, `demo` = showcase, pollster 1.x ; README de modules `lib/src/**` à jour ; liens tech docs interconnectés)* --- diff --git a/docs/tech/ARCHI_APP.md b/docs/tech/ARCHI_APP.md index e311fc9..3723973 100644 --- a/docs/tech/ARCHI_APP.md +++ b/docs/tech/ARCHI_APP.md @@ -15,14 +15,17 @@ stale_after: 2027-01-31 wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architecture à deux niveaux : une façade de haut niveau pour la productivité et un accès bas niveau pour un contrôle total. -> **État du document : CIBLE (architecture visée, en grande partie non implémentée).** +> **État du document : ACTUEL pour la façade (`App`/`AppHandler`, §3, §4A) ; CIBLE pour la partie +> GPU-driven (§1, §4B, §5, §6).** La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le +> **workflow recommandé** : `setup` (déclaration de la scène) → par frame `update` (mutation) → +> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**, +> un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif). +> Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières, +> ombres, caméra orbitale). Le workflow **manuel** (exemple `manual`) coexiste pour le contrôle fin. > Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes > (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect) > et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est -> la trajectoire de ROADMAP.md (et README étape 2-3). L'état **réel actuel** est dans README.md : -> workflow manuel uniquement, `Renderer` dessine un objet par soumission, shader en NDC sans MVP. -> La §3 (`App`/`AppHandler`) correspond à l'état actuel, à une nuance près : `render()` ne peut pas -> encore dessiner la scène (l'acquisition/présentation de frame fonctionne, pas le rendu de la scène). +> la trajectoire ROADMAP Phase 3. ## 1. Philosophie et Principes @@ -85,7 +88,7 @@ pub trait AppHandler { - **Shaders** : Chargés avant la renderloop. - **PipelineCache** : Enregistre les shaders. -- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise `basic_shader` par défaut. +- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise le matériau par défaut de la scène (`standard`). - **Scene** : Assemblage des objets. L'utilisateur peuple la scène via `app.scene`. ### B. Boucle de Rendu — Pipeline GPU-Driven @@ -155,3 +158,9 @@ Single buffer (phase initiale) : Update écrit, Compute lit au frame suivant — - **Synchronisation** : Toujours appeler `begin_compute_pass` avant `begin_render_pass` sur le même `CommandEncoder`. Les barrières entre passes sont automatiques — ne jamais insérer de barrière manuelle sauf besoin critique. - **Synchronisation single buffer (phase initiale)** : La séquence `queue.submit()` après chaque compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun conflit de lecture/écriture n'est possible tant que `desired_maximum_frame_latency` ≥ 3. - **Double Buffering (future migration)** : Sera implémenté sur les buffers Transform et Matrix seulement, pas sur BoundingBox ni Indirect Draw. Le switch se résume à : dupliquer ces deux buffers, ajouter une méthode `swap()` appelée dans `AboutToWait`, modifier les bind groups pour pointer vers l'index courant. Pas besoin de refonte architecturale. + +## Liens + +- [ARCHI_RENDU](ARCHI_RENDU.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) +- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md) +- Référence API : `cargo doc -p wsg-lib --no-deps` diff --git a/docs/tech/ARCHI_ARENES.md b/docs/tech/ARCHI_ARENES.md index 3816856..ebf8dfa 100644 --- a/docs/tech/ARCHI_ARENES.md +++ b/docs/tech/ARCHI_ARENES.md @@ -220,7 +220,7 @@ impl ResourceManager { 6. Suppression Dynamique : Bien que possible, la suppression de ressources pendant la boucle de rendu doit être faite avec prudence. Assurez-vous que les entités ou objets qui référençaient cette ressource soient informés ou nettoyés pour éviter d'utiliser des Handles invalides. La suppression est souvent mieux gérée en fin de frame ou via un système de "marquage pour suppression" suivi d'un nettoyage différé. 7. Futur : SecondaryMaps : slotmap permet d'utiliser des SecondaryMap pour associer dynamiquement des données à des ressources existantes sans modifier leur structure principale. Par exemple, `SecondaryMap` pourrait stocker les transformations actuelles de chaque maillage. Cela peut être utile pour le rendu ou pour des systèmes de physique/transformation indépendants. -> **Note sur les Transforms côté GPU** : `SecondaryMap` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms. +> **Note sur les Transforms côté GPU** : `SecondaryMap` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms. # Avantages de cette Approche @@ -230,3 +230,9 @@ impl ResourceManager { * Conformité avec Rust : Respecte les principes de propriété et de sécurité mémoire de Rust sans recourir à Rc> ou d'autres constructions potentiellement coûteuses ou moins sûres pour la gestion partagée des ressources. * Typage Fort : Les types MeshId, MaterialId, etc., empêchent les erreurs de compilation liées au mélange de Handles de types différents. * Extensibilité : L'écosystème slotmap (SecondaryMap) offre des perspectives pour des architectures plus complexes à l'avenir. + +## Liens + +- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [FRAME_LOOP](FRAME_LOOP.md) +- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md) +- Référence API : `cargo doc -p wsg-lib --no-deps` diff --git a/docs/tech/ARCHI_CPU_GPU.md b/docs/tech/ARCHI_CPU_GPU.md index 2b30fa3..db06913 100644 --- a/docs/tech/ARCHI_CPU_GPU.md +++ b/docs/tech/ARCHI_CPU_GPU.md @@ -72,3 +72,9 @@ Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer Matrix Buffer,Stocke les World Matrices finales calculées.,Storage Buffer,GPU (Calculé) → GPU (Lu par le Render) Bounding Box Buffer,Stocke les AABB de chaque mesh pour le culling.,Storage Buffer,CPU → GPU (Statique) Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render) + +## Liens + +- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) · [FRAME_LOOP](FRAME_LOOP.md) +- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md) +- Référence API : `cargo doc -p wsg-lib --no-deps` diff --git a/docs/tech/ARCHI_RENDU.md b/docs/tech/ARCHI_RENDU.md index 5d979ee..a697202 100644 --- a/docs/tech/ARCHI_RENDU.md +++ b/docs/tech/ARCHI_RENDU.md @@ -15,13 +15,15 @@ stale_after: 2027-01-31 Ce document définit la stratégie de gestion de la mutabilité et des données du moteur wsg_lib, conçue pour maximiser la performance et garantir la sécurité mémoire via Rust. -> **État du document : CIBLE (modèle de mutabilité pour le futur rendu automatisé).** -> Le cycle update/render strict, l'itération **automatique** des entités et `renderer.render_scene()` -> décrits ici ne sont **pas implémentés** : c'est l'**étape 1 du Roadmap README** (scene auto-rendering). -> Aujourd'hui `App::run` acquiert/présente la frame mais `render()` ne peut pas encore dessiner la scène, -> et le `Renderer` ne dessine qu'un objet par soumission, à la main (exemple `manual`). La terminologie -> `MeshId`/`MaterialId` (handles typés) est celle de la **cible** ; l'état actuel utilise des **String IDs** -> dans `Scene`. La dichotomie update/render reste toutefois le modèle de référence retenu pour la suite. +> **État du document : ACTUEL pour la dichotomie update/render (implémentée) ; CIBLE pour le batching.** +> Le cycle strict est en place : `AppHandler::update` (mutation libre de la scène) tourne avant +> `AppHandler::render`, dont l'implémentation par défaut appelle `app.render_scene(frame.view())` — +> le moteur itère automatiquement les entités et les dessine en **une passe groupée** par frame +> (rendu automatisé livré le 2026-09-16 ; la passe d'ombre est ajoutée en tête quand un caster est +> actif). Le workflow **manuel** (`Renderer::render` objet par objet, exemple `manual`) coexiste +> pour le contrôle fin. Reste en **cible** : le **tri/batching par matériau** (ROADMAP 4.3) et les +> **handles typés** `MeshId`/`MaterialId` (voir [ARCHI_ARENES](ARCHI_ARENES.md)) — l'état actuel +> utilise des **String IDs** dans `Scene`. ## 1. La Dichotomie Update / Render @@ -65,4 +67,14 @@ Bien que cette architecture facilite la gestion de la mémoire, des règles stri > "Si vous devez changer la position d'un objet ou son matériau, faites-le dans `update()`. Si vous avez besoin d'afficher un élément de debug ou un rendu spécial, faites-le dans `render()`, mais traitez les objets de la scène comme des données en lecture seule." -Cette structure permet au projet d'être extrêmement scalable. L'ajout futur de fonctionnalités (Lumières, Textures, Caméras) ne nécessitera que d'ajouter de nouveaux conteneurs dans la Scene et de mettre à jour le système de tri dans `Renderer::render_scene()` (méthode à créer — cible de l'étape 1 du Roadmap README). +Cette structure permet au projet d'être extrêmement scalable. L'ajout des fonctionnalités Lumières, +Textures et Caméras (livrées — voir [ROADMAP](../ROADMAP.md)) a effectivement consisté à ajouter des +conteneurs dans la Scene (`lights`, `textures`, `camera`) et à les consommer dans +`Renderer::render_scene()` (existant — il écrit les uniformes de frame chaque frame). Il restera à +y ajouter le **système de tri par matériau** (batching, ROADMAP 4.3) quand il sera justifié. + +## Liens + +- [ARCHI_APP](ARCHI_APP.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) +- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md) +- Référence API : `cargo doc -p wsg-lib --no-deps` diff --git a/docs/tech/FRAME_LOOP.md b/docs/tech/FRAME_LOOP.md index d0b2400..65aef93 100644 --- a/docs/tech/FRAME_LOOP.md +++ b/docs/tech/FRAME_LOOP.md @@ -14,12 +14,25 @@ stale_after: 2027-01-31 # La Boucle de Rendu (Frame Loop) > **État du document : ACTUEL (implémenté).** Ce document décrit la frame lifetime telle qu'elle est -> réellement implémentée. Il concerne le rendu **CPU-piloté actuel** (objet par objet, exemple `manual`). -> Le pipeline GPU-driven de l'état **visé** est décrit dans ARCHI_APP.md / ARCHI_CPU_GPU.md (cible). +> réellement implémentée. **Deux flux coexistent** : le flux **facade `App`** (rendu automatique de la +> scène, `App::render_scene` — le workflow recommandé, exemples `simple`/`cube`/`demo`) et le flux +> **manuel** (`Context`/`Renderer`/`Frame` pilotés à la main — exemple `manual`, un objet par soumission). +> Le pipeline **GPU-driven** (compute pass + draw indirect) de l'état **visé** est décrit dans +> [ARCHI_APP](ARCHI_APP.md) / [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) (cible). -Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime. Deux flux coexistent, tous deux basés sur `Frame` : +Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime, basé sur `Frame` : -**Flux `Frame` (utilisé par `App::run` et l'exemple `manual`) :** +**Flux facade `App` (recommandé — `App::run` + `AppHandler`) :** +- **`Context::get_next_frame()`** : acquiert la surface texture et crée sa `TextureView` (dans `Frame`). +- **`AppHandler::render` (défaut) → `App::render_scene(view)`** : le moteur itère les entités de la + scène et les dessine en **une passe groupée** (un `CommandEncoder` + une soumission par frame ; + passe d'ombre en tête si un caster est actif). +- **`Renderer::present(frame)`** : présente l'image à l'écran. +- Chaque frame, avant `update`, le moteur appelle `device.poll()` (les callbacks asynchrones wgpu — + `on_submitted_work_done`, `map_async` — ne se déclenchent que lors d'un poll), et la fenêtre + redimensionnée est gérée par `App::resize` (surface + depth texture recréées ensemble). + +**Flux `manual` (exemple `manual` — un objet par soumission) :** - **`Context::get_next_frame()`** (ou `Frame::try_new(&context.surface)`) : acquiert la surface texture et crée sa `TextureView` (dans `Frame`). - **`Renderer::render(&view, &mesh, &material)`** : crée un `CommandEncoder`, écrit les ordres de dessin dans la `TextureView`, puis soumet à la file (`queue`). - **`Renderer::present(frame)`** : présente l'image à l'écran. @@ -28,6 +41,12 @@ Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lif - **`Context::begin_frame()`** : acquiert la surface et renvoie la `wgpu::SurfaceTexture` (sans vue). - **`Context::end_frame(surface_texture)`** : soumet et présente cette texture. +## Liens + +- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) +- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md) +- Référence API : `cargo doc -p wsg-lib --no-deps` + --- ## Pourquoi cette séparation est vitale diff --git a/docs/user/README.md b/docs/user/README.md new file mode 100644 index 0000000..a266875 --- /dev/null +++ b/docs/user/README.md @@ -0,0 +1,32 @@ +# User documentation — WSG + +**Usage** documentation for the `wsg-lib` crate: how to build a 3D rendering application +without touching wgpu directly. It targets a developer with basic Rust knowledge; no prior +GPU graphics background is required. + +> **Not to be confused**: these pages explain *how to use* the API. The **technical** +> documentation (internal architecture, design decisions, future targets) lives in +> [../tech/](../tech/ARCHI_APP.md), and the exhaustive API reference is generated by rustdoc +> (`cargo doc -p wsg-lib --no-deps`). + +## Where to start + +1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines. +2. Then, at your own pace, depending on what you need: + +| Page | Topic | +|-------|-------| +| [Meshes](meshes.md) | Geometries: procedural primitives, custom `Geometry`, entities and `Transform` | +| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures | +| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` | +| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall | +| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state | +| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example | + +The pages are cross-linked: each page ends with a link to the next one. + +## Links + +- Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) +- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) · [DRAFT](../DRAFT.md) +- Full API reference: `cargo doc -p wsg-lib --no-deps` diff --git a/docs/user/camera-input.md b/docs/user/camera-input.md new file mode 100644 index 0000000..a73cea1 --- /dev/null +++ b/docs/user/camera-input.md @@ -0,0 +1,110 @@ +# Camera & input + +Two bricks drive the viewpoint: the scene's **active `Camera`** (view/projection matrices +built every frame) and the unified **`InputState`** (keyboard/mouse, cross-frame +semantics). The orbital **`CameraController`** bridges the two. + +## 1. The active camera + +The scene holds a single camera, read by the engine every frame to write the view/projection +matrices into the frame buffer (aspect recomputed from the window size). + +```rust +use wsg_lib::resources::Camera; +use glam::Vec3; + +app.scene.set_camera(Camera::new( + Vec3::new(3.0, 2.0, 3.0), // eye position + Vec3::ZERO, // target point + Vec3::Y, // "up" vector +)); +``` + +- **Default**: position `(0, 0, 3)`, looking at the origin, 45° vertical fov, near 0.1, + far 100 — frames a unit cube with no tuning. +- `Camera::with_perspective(fov, near, far)` adjusts the projection (fov in radians). +- Read: `app.scene.camera()`; direct mutation: `app.scene.camera_mut()`. +- The `up` field matters: the orbital camera forces it to `+Y` (level horizon). + +> The matrices use the **WebGPU** convention (NDC depth `[0,1]`) — do not replace +> `projection_matrix` with an OpenGL `[-1,1]` projection, the near part of the frustum would +> be clipped. + +## 2. The orbital controller + +`CameraController` represents the viewpoint in spherical coordinates around a target: +`yaw` (azimuth around +Y), `pitch` (elevation, bounded to ±~83°), `distance` (radius, +bounded to `[0.1, 100]`), `target` (target point). + +```rust +use wsg_lib::resources::CameraController; + +let mut ctrl = CameraController::default(); // target at origin, distance 3, front view +ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles) +ctrl.zoom(scroll_y); // wheel: zoom (positive scroll = move closer) +ctrl.reset(); // back to the default framing +ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active camera (do this EVERY frame) +``` + +`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera +(useful to start the orbit from a manual framing). + +The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from +`app.input`) is in [`demo.rs`](../../lib/examples/demo.rs), `update()` section. + +## 3. The unified input state + +`app.input` (public field of `App`) is fed by winit events and **rotated** automatically +every frame (`begin_frame`/`end_frame` around your `update`). Three semantics per control: + +| Semantics | Methods | Meaning | +|------------|----------|---------| +| **pressed** | `key_pressed(code)`, `mouse_button_pressed(btn)` | true **only** on the frame the key/button was just pressed | +| **held** | `key_held(code)`, `mouse_button_held(btn)` | true while the key/button stays down | +| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame | + +Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the +frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel). + +`KeyCode` values are winit's physical codes (`winit::keyboard::KeyCode`); mouse buttons are +`winit::event::MouseButton`. The library does not re-export them: if your code mentions +them, add `winit = "0.30"` to your own dependencies (as the examples do). Input-less +applications (like `simple`/`cube`) don't need winit: `app.input` remains usable, only +`KeyCode` comparisons require the import. + +```rust +use winit::keyboard::KeyCode; + +fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit + zoom driven by the mouse (excerpts from demo): + let (dx, dy) = app.input.mouse_delta(); + self.camera.orbit(dx, dy); + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + // R: reset — key_pressed fires once, not on key-repeat. + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.6; + self.camera.pitch = 0.35; + self.camera.distance = 6.5; + } + self.camera.apply_to(app.scene.camera_mut()); +} +``` + +> **Gamepad**: the API is reserved (`InputState` will pass through `DeviceEvent`s) but not +> implemented yet — deferred, see [ROADMAP](../ROADMAP.md). + +## 4. Common recipes + +| Need | Recipe | +|--------|--------| +| Standard orbital camera | `CameraController` + `mouse_delta`/`scroll_delta` (snippet above) | +| FPS camera (WASD) | `key_held(KeyCode::KeyW)` in `update` → move `camera.position`/`target`; override `render()` if needed | +| Changing the orbit target | `ctrl.target = subject_position;` (following an object) | +| View presets | `key_pressed(Digit1/2/3)` → write yaw/pitch/distance (from the `demo`) | + +## Links + +- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md) +- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md) diff --git a/docs/user/examples.md b/docs/user/examples.md new file mode 100644 index 0000000..b721cf9 --- /dev/null +++ b/docs/user/examples.md @@ -0,0 +1,47 @@ +# Examples + +Seven examples live in [`lib/examples/`](../../lib/examples/) and all launch with +`cargo run -p wsg-lib --example `. They are **self-contained**: no assets on disk +(procedural textures, hard-coded geometries). + +| Example | Command | What it shows | Corresponding page | +|---------|----------|---------------|--------------------| +| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) | +| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) | +| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) | +| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) | +| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) | +| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade (winit 0.30 `ApplicationHandler`) | below | + +## The `manual` workflow (advanced) + +When the `App` facade doesn't fit (fine-grained loop control, integration into an existing +framework, experimentation), you bypass `App` and drive directly: + +- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/`Device`/ + `Queue`, `configure()` for the swapchain, `get_next_frame()`. +- `Renderer` (*Executor* layer): `render(view, mesh, material)` = one object per submission; + `present(frame)`. +- `PipelineCache`: `register_shader(id, path)` then `Material::new(format, id, &mut cache)`. + +The window and GPU are created in winit 0.30's `resumed()` callback (`run_app` + +`ApplicationHandler`), as in `app.rs`. The reference file is +[`manual.rs`](../../lib/examples/manual.rs); the two-layer architecture is detailed in +[ARCHI_APP](../tech/ARCHI_APP.md) and [FRAME_LOOP](../tech/FRAME_LOOP.md). + +> **Tip**: start with the declarative workflow. The manual workflow doesn't render more +> pixels — it gives more control over command encoding. + +## Adding your own example + +Repo conventions (see `lib/examples/README.md`): + +1. Create `lib/examples/my_example.rs` (Cargo discovers it automatically). +2. Keep it **self-contained**: procedural textures, hard-coded geometries, no external assets. +3. Use the declarative workflow (`AppBuilder` + `Scene`) when possible. +4. Document the example in `lib/examples/README.md` (and here, `docs/user/examples.md`). + +## Links + +- [User README](README.md) · [Quickstart](quickstart.md) · [Camera & input](camera-input.md) +- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) diff --git a/docs/user/lights.md b/docs/user/lights.md new file mode 100644 index 0000000..23745ce --- /dev/null +++ b/docs/user/lights.md @@ -0,0 +1,84 @@ +# Lights + +Lights are **scene-global**: a single list is packed into the frame uniforms every frame, and +**all** entities receive their lighting (per-material lights are out of the current scope). + +## Model + +- Bounded capacity: **`MAX_LIGHTS = 8`** lights in total (directional + point + spot + combined). Adding beyond that returns an error. +- **Default**: one white directional light along **+Z** (from the surface point toward the + light) + white ambient. This default exactly reproduces the historical single-light + rendering — your scene "just works" with no configuration. +- Ambient (`set_ambient`) is a global hemispherical term, independent of the lights. + +## Adding lights + +```rust +use glam::Vec3; + +// Directional: `dir` points FROM the surface point TOWARD the light. +app.scene + .add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5) + .unwrap(); + +// Point: world position, tint, intensity, attenuation radius (linear down to 0). +app.scene + .add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0) + .unwrap(); + +// Spot: position, cone axis (FROM the light TOWARD the scene), tint, intensity, radius, +// half-angle in radians (penumbra smoothed at the edge). +app.scene.add_spot_light( + Vec3::new(-2.5, 2.2, 1.0), // position + Vec3::new(2.5, -2.2, -1.0).normalize(), // axis, toward the scene + [0.3, 1.0, 0.5], // green tint + 1.4, 8.0, 0.45, // intensity, radius, half-angle (~26°) +).unwrap(); +``` + +These three calls are the ones in the [`demo`](../../lib/examples/demo.rs) example; +[`cube.rs`](../../lib/examples/cube.rs) shows a point + a spot on top of the default +directional, and [`spot_test.rs`](../../lib/examples/spot_test.rs) isolates a single spot +(ambient nearly zero). + +Global settings: + +| Method | Effect | +|---------|--------| +| `set_ambient([r, g, b])` | hemispherical ambient color (default white) | +| `clear_lights()` | empties the list — only ambient will light the scene (useful for a flat look without switching to unlit) | +| `set_lights(Lights)` | replaces the whole list (batch reset) | +| `lights()` | reads the current list | + +## ⚠️ Packed indices (important for shadows) + +Lights are stacked in the GPU array **by type, in order**: + +``` +index 0 .. n_dir-1 : directional +index n_dir .. +n_point-1 : point +index … .. +n_spot-1 : spot +``` + +Two consequences: + +1. **Index 0 is the default +Z directional** (the one `Lights::new()` pre-loads), + not your first added light. This is a classic pitfall — see + [Shadows](shadows.md). +2. If you want **your** light to be the only one (and thus at index 0), clear the list + first: `app.scene.clear_lights();` then `add_*_light(…)` (this is the technique in + [`shadow_test.rs`](../../lib/examples/shadow_test.rs)). + +## Intensities and tints + +- `color` is an RGB in `[0..1]`; `intensity` is an unbounded multiplier. +- Local lights (point/spot) attenuate **linearly** — intensity drops to zero at `radius`. + Beyond the radius, the light contributes nothing. +- The `standard` shader accumulates ambient + all lights (no mutual occlusion between + lights; the spot cone culling happens at the fragment). + +## Links + +- [User README](README.md) · [Shadows](shadows.md) · [Materials & textures](materials.md) +- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) diff --git a/docs/user/materials.md b/docs/user/materials.md new file mode 100644 index 0000000..86be5e0 --- /dev/null +++ b/docs/user/materials.md @@ -0,0 +1,100 @@ +# Materials & textures + +A **`Material`** describes a mesh's appearance: it references a shader (by id) and +optionally a **diffuse texture**. Several materials pointing at the same shader share the +same compiled GPU pipeline (the `PipelineCache` held by the scene). + +The engine ships a single shader: **`standard`** — multi-light Phong lighting (see +[Lights](lights.md)), with an **unlit** mode for flat rendering. + +## 1. Registering the shader + +```rust +app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); +``` + +> **Note**: `STANDARD_SHADER_PATH` points to an optional file on disk; if it is missing +> (the normal case for the embedded library), loading falls back to the shader **embedded at +> compile time** (`include_str!`, byte-identical). The fallback message you may see is +> therefore **expected and harmless**. + +For a custom shader: register your `.wgsl` file path under an id of your choice (it must +expose the same bind groups as `standard` — frame @0, object @1, texture @2, shadow @3 — see +[ARCHI_RENDU](../tech/ARCHI_RENDU.md) and the +[`shaders/standard_shader.wgsl`](../../lib/src/shaders/standard_shader.wgsl) file). + +## 2. Creating materials + +```rust +// Textureless material: the color comes from per-vertex colors (or white by default). +app.scene.add_material_shader("mat", "standard").unwrap(); + +// Textured material: the texture must first be registered in the scene (below). +app.scene.add_material_texture("mat_textured", "standard", "my_texture").unwrap(); +``` + +Binding a material to a mesh happens at mesh creation (see [Meshes](meshes.md)): + +```rust +app.scene.create_mesh("cube_mesh", cube(1.0), Some("mat_textured")).unwrap(); +``` + +A mesh created with `material = None` is rendered with the scene's **default material** +(`standard`, built once then cached) — that is the behavior of the +[`simple`](../../lib/examples/simple.rs) example. + +## 3. Diffuse textures + +`Texture` is a GPU image in `Rgba8UnormSrgb` (linear sampler, repeat addressing). +Four constructors: + +| Constructor | Usage | +|--------------|-------| +| `Texture::from_rgba8(device, queue, w, h, rgba, label)` | raw RGBA8 bytes (procedural) | +| `Texture::from_bytes(device, queue, label, bytes)` | encoded data (PNG/JPEG… via the `image` crate) | +| `Texture::from_file(device, queue, label, path)` | image file on disk | +| `Texture::white_placeholder(device, queue)` | 1×1 white — used internally when a material has no texture | + +You get `device`/`queue` in `setup()` via `app.context()`: + +```rust +let (device, queue) = { + let ctx = app.context(); + (ctx.device.clone(), ctx.queue.clone()) +}; +let texture = Texture::from_rgba8(&device, &queue, 8, 8, &my_rgba, "checker").unwrap(); +app.scene.add_texture("checker_texture", texture).unwrap(); +app.scene.add_material_texture("ground_mat", "standard", "checker_texture").unwrap(); +``` + +The exact snippet (8×8 checkerboard + stripes generation) is in +[`demo.rs`](../../lib/examples/demo.rs) and [`cube.rs`](../../lib/examples/cube.rs). + +Two conditions for a texture to show up: +1. the material is created via `add_material_texture` (otherwise the 1×1 white placeholder + is bound — no visual effect, no regression); +2. the `Geometry` carries **UVs** (`.with_uvs(…)`). Without UVs, sampling is constant. + The procedural primitives (`uv_sphere`, `cube`, …) already provide them. + +## 4. Unlit mode (flat / 2D rendering) + +"Flat" rendering (vertex colors as-is, no lighting) is a **renderer switch**, not a material: + +```rust +app.renderer_mut().set_unlit(true); // in setup() +``` + +This is the mode of the `simple` example (2D quad). In this mode the scene's lights are +ignored; per-vertex colors (or white) are rendered directly. 2D is a special case of 3D: +the single `standard` pipeline serves both. + +> `clear_lights()` (see [Lights](lights.md)) gives a similar result but keeps the lit +> pipeline: only ambient stays active. Use it when you want to "turn off the lights" without +> switching to unlit. + +## Links + +- [User README](README.md) · [Meshes](meshes.md) · [Lights](lights.md) · [Examples](examples.md) +- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) diff --git a/docs/user/meshes.md b/docs/user/meshes.md new file mode 100644 index 0000000..a52260d --- /dev/null +++ b/docs/user/meshes.md @@ -0,0 +1,125 @@ +# Meshes: geometries, entities and transforms + +A displayed object in WSG goes through three levels: + +``` +Geometry (CPU, source of truth) ──► Mesh (GPU: vertex/index buffers) ──► Entity (placement in the scene) +``` + +- **`Geometry`**: raw CPU-side data — positions + optional normals/UVs/colors/indices. +- **`Mesh`**: GPU container (buffers uploaded once). It **retains** its `Arc` on the + CPU side, along with its material. +- **`Entity`**: a `mesh + Transform` association. This is the unit the engine draws. The same + `Mesh` can be shared by several entities (each with its own `Transform`). + +## 1. Procedural primitives (the shortest path) + +The `math::primitives` module provides ready-to-use `Geometry` generators +(positions + normals + UVs + indices): + +| Function | Parameters | Result | +|----------|-----------|--------| +| `cube(size)` | side length | origin-centered cube, per-face normals | +| `plane(width, depth, seg_x, seg_z)` | dimensions + subdivisions | horizontal plane (Y-up), UVs | +| `uv_sphere(radius, sectors, stacks)` | radius + resolution | UV sphere (seam visible) | +| `icosphere(radius, subdivisions)` | radius + subdivisions | smooth sphere (normalized, seam-free) | +| `cylinder(radius, height, sectors)` | radius, height, resolution | centered cylinder | +| `cone(radius, height, sectors)` | radius, height, resolution | cone (base at the bottom when translated in Y) | +| `torus(major, minor, major_segments, minor_segments)` | radii + resolution | torus | + +```rust +use wsg_lib::math::{cube, icosphere, torus}; + +app.scene.create_mesh("cube_mesh", cube(0.8), Some("solid_mat")).unwrap(); +app.scene.create_mesh("sphere_mesh", icosphere(0.5, 2), Some("solid_mat")).unwrap(); +``` + +## 2. Custom `Geometry` (your own mesh) + +`Geometry` is a builder: positions are mandatory, everything else is optional +(sensible defaults are applied at upload — e.g. normal `[0,0,1]`, white color). + +```rust +use wsg_lib::resources::Geometry; + +let geometry = Geometry::new(vec![ + [-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_normals(vec![[0.0, 0.0, 1.0]; 4]) // required for lighting (Phong) +.with_colors(vec![ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [1.0, 1.0, 0.0, 1.0], +]) +.with_indices(vec![0, 1, 2, 0, 2, 3]); // triangulation (without indices: triangle list) +``` + +Other attributes: `.with_uvs(vec![[u, v], …])` (required for textures — see +[Materials & textures](materials.md)). `geometry.validate()` checks the arrays for +consistency (aligned lengths, indices in range) before upload. + +> **Indices**: `Vec` — a custom mesh must therefore stay under 65,536 vertices. The +> engine's primitives respect this limit. + +## 3. Registering in the scene + +```rust +// The mesh is built (GPU buffers) and bound to its material in one call. +// `material = None`: the scene will use its default material (`standard`) at render time. +app.scene.create_mesh("cube_mesh", geometry, Some("cube_material"))?; + +// The entity references the mesh by its id (String IDs). +app.scene.add_entity("cube", "cube_mesh")?; +// …or with an explicit placement: +app.scene.add_entity_with_transform("cube", "cube_mesh", transform)?; +``` + +All these methods return `Result<_, String>` (unifying the typed errors is on the +horizon — see [ROADMAP](../ROADMAP.md)). + +## 4. Moving / animating: the `Transform` + +Placement lives on the **entity** (not on the mesh): `Transform { translation: Vec3, +rotation: Quat, scale: Vec3 }`, converted to a world matrix by the engine every frame. + +The snippet below is the animation from the [`cube`](../../lib/examples/cube.rs) example: + +```rust +fn update(&mut self, app: &mut wsg_lib::App) { + self.angle += 0.02; + let mut tf = *app.scene.entity_transform("cube").expect("entity present"); + tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3); + app.scene.set_entity_transform("cube", tf); +} +``` + +Other entity operations: `entity_transform(label)` (read), `remove_entity(label)` (hides +without freeing resources), `entity_count()`. + +> **Rotation order**: `Quat` does not commute — `rot_y * rot_x` is not `rot_x * rot_y`. +> The order above (Y then X) gives a readable "top spinning" motion. + +## 5. Mesh sharing + +Create **one** mesh per geometry and as many entities as occurrences: + +```rust +app.scene.create_mesh("rock_mesh", icosphere(0.3, 1), Some("rock_mat")).unwrap(); +for i in 0..10 { + let label = format!("rock_{i}"); + let mut tf = Transform::identity(); + tf.translation = Vec3::new(i as f32 * 0.8, 0.15, 0.0); + app.scene.add_entity_with_transform(&label, "rock_mesh", tf).unwrap(); +} +``` + +The GPU buffers are uploaded only once; only the world matrices differ. + +## Links + +- [User README](README.md) · [Quickstart](quickstart.md) · [Materials & textures](materials.md) · [Lights](lights.md) +- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md) diff --git a/docs/user/quickstart.md b/docs/user/quickstart.md new file mode 100644 index 0000000..da3f369 --- /dev/null +++ b/docs/user/quickstart.md @@ -0,0 +1,131 @@ +# Quickstart + +Goal: a window showing an object, with the render loop handled by the library. You will only +write three things: a struct implementing `AppHandler`, your scene declaration in `setup()`, +and your `main()`. + +## Prerequisites + +- A recent Rust toolchain (the library is **edition 2024** — run `rustup update` if needed). +- A windowing environment (X11/Wayland on Linux, or native macOS/Windows). +- WSG is **not published on crates.io**: it is consumed by file path. + +## 1. Dependencies + +In your application's `Cargo.toml`: + +```toml +[dependencies] +wsg-lib = { path = "/path/to/wsg/lib" } +pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (AppBuilder is async) +``` + +## 2. The minimal application + +This snippet is the [`simple`](../../lib/examples/simple.rs) example from the repo, almost +verbatim: a flat two-tone quad, rendered automatically every frame. + +```rust +use wsg_lib::app::AppBuilder; +use wsg_lib::resources::Geometry; +use wsg_lib::utils::WsgError; +use wsg_lib::AppHandler; + +struct MyQuad; + +impl AppHandler for MyQuad { + fn setup(&mut self, app: &mut wsg_lib::App) { + // Flat 2D: the `standard` shader in unlit mode returns the vertex color as-is. + 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], + [ 0.5, 0.5, 0.0], + [ 0.5, -0.5, 0.0], + [-0.5, -0.5, 0.0], + ]) + .with_normals(vec![[0.0, 0.0, 1.0]; 4]) + .with_colors(vec![ + [1.0, 0.0, 0.0, 1.0], // red + [0.0, 1.0, 0.0, 1.0], // green + [0.0, 0.0, 1.0, 1.0], // blue + [1.0, 1.0, 0.0, 1.0], // yellow + ]) + .with_indices(vec![0, 1, 2, 0, 2, 3]); + + // `None`: the scene injects its default material (`standard`) at render time. + app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); + app.scene.add_entity("quad", "quad_mesh").unwrap(); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new().title("WSG Simple").build().await?; + app.run(MyQuad) +} +``` + +Note: **no `wgpu` or `winit` imports** — the `App` facade encapsulates them entirely. + +## 3. What the library does for you + +The full lifecycle, as driven by `App::run` (technical details in +[FRAME_LOOP](../tech/FRAME_LOOP.md)): + +``` +AppBuilder::build() creates the event loop + │ +App::run(handler) starts the loop + │ +resumed (winit) window + GPU (Instance/Surface/Adapter/Device/Queue) + Renderer + │ +handler.setup(&mut app) ← you declare the scene here (once, GPU ready) + │ + ▼ per frame, in a loop: + input.begin_frame() current frame's keyboard/mouse state +handler.update(&mut app) ← your logic (motion, input, …) + input.end_frame() +handler.render(app, frame) ← default: app.render_scene(frame.view()) + │ (the whole scene is drawn automatically, one pass per frame) + └─ present → next frame +``` + +So you implement: + +| Hook | When | Role | Default | +|------|-------|------|---------| +| `setup(&mut self, app)` | once, GPU ready | declare shaders, materials, textures, meshes, entities, lights, camera | empty | +| `update(&mut self, app)` | every frame, before render | animate: transforms, input, lights… | empty | +| `render(&mut self, app, frame)` | every frame, after update | **default**: draws the whole scene; override for custom rendering | `app.render_scene(frame.view())` | + +Golden rule: **mutate the scene in `update()`** (and `setup()`), only read it in `render()` +(model detailed in [ARCHI_RENDU](../tech/ARCHI_RENDU.md)). + +## 4. Running it + +From the WSG repo root (the examples live in `lib/examples/`): + +| Command | What you see | +|----------|--------------| +| `cargo run -p wsg-lib --example simple` | the quad above (flat 2D, unlit) | +| `cargo run -p wsg-lib --example cube` | a textured, lit, spinning cube (3D) | +| `cargo run -p wsg-lib --example demo` | the full showcase: 6 primitives + lights + shadows + orbital camera | + +For your own application: create a crate, add the §1 dependency, paste the §2 code into +`src/main.rs`, and `cargo run`. + +## 5. Where to go next + +- Want a 3D object? → [Meshes](meshes.md) +- Want to change the look / add a texture? → [Materials & textures](materials.md) +- Want lights? → [Lights](lights.md) +- Want to see everything at once? → the `demo` example ([Examples](examples.md)) + +## Links + +- [User README](README.md) · [Meshes](meshes.md) · [Examples](examples.md) +- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) · [ARCHI_APP](../tech/ARCHI_APP.md) diff --git a/docs/user/shadows.md b/docs/user/shadows.md new file mode 100644 index 0000000..7098744 --- /dev/null +++ b/docs/user/shadows.md @@ -0,0 +1,79 @@ +# Shadows (shadow mapping) + +Shadows are **off by default** and are enabled by designating **a single** casting light: + +```rust +app.scene.set_shadow_caster(Some(index)); // packed index — see the pitfall below +app.scene.set_shadow_caster(None); // shadows off (default) +``` + +Only a **directional or spot** light can cast shadows. A **point** light index disables the +shadow pass (cubemap shadows are out of scope). + +## ⚠️ The packed-index pitfall + +`set_shadow_caster` takes the light's index **in the packed array** (directionals first, +then point, then spot — recalled in [Lights](lights.md)). + +**Index 0 is the default +Z directional** pre-loaded by `Lights::new()`, not necessarily +your light. Symptom of a wrong index: the shadow camera looks in an unexpected direction and +misaligned objects occlude each other (blackened objects, ghost shadows). + +Two ways to avoid it: + +1. **Clear the list before adding yours** — your light becomes index 0: + + ```rust + app.scene.clear_lights(); // removes the default +Z + app.scene.add_directional_light(dir, [1.0, 0.98, 0.92], 1.6).unwrap(); + app.scene.set_shadow_caster(Some(0)); // now it really is YOUR light + ``` + + This is the technique in [`shadow_test.rs`](../../lib/examples/shadow_test.rs). + +2. **Count the indices** — if you keep the default light and add yours, it lands at index 1: + + ```rust + app.scene.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5).unwrap(); // → index 1 + app.scene.set_shadow_caster(Some(1)); // this is the demo's warm light that casts + ``` + + This is the technique in [`demo.rs`](../../lib/examples/demo.rs). + +## How it works (to understand the limits) + +Each frame, if a caster is active, the engine runs **two passes** (technical details in +[FRAME_LOOP](../tech/FRAME_LOOP.md)): + +1. **Shadow pass**: the scene is rendered as seen *from the light* (depth-only + `shadow_shader.wgsl` shader) into a 1024² `Depth32Float` shadow map (size configurable + via `SHADOW_MAP_SIZE`), with a depth bias (slope-scaled + constant) to avoid shadow acne. +2. **Color pass**: the `standard` fragment shader re-projects each fragment into light space + and compares its depth against the map via a **3×3 PCF** (softened shadow edges). + +Things to know: + +- **Directional light**: the shadow frustum is orthographic, centered on the scene center + (`SHADOW_SCENE_CENTER`, radius `SHADOW_SCENE_RADIUS = 5.0` by default). Objects **far from + the origin** may fall outside the frustum and stop casting. +- **Spot light**: the light's cone naturally bounds the shadow. +- Only one light casts at a time (no multi-light shadows). +- Shadows only affect meshes rendered by `standard` in lit mode — a renderer in unlit mode + (see [Materials & textures](materials.md)) receives none. + +## Tuning shadow rendering + +The constants `SHADOW_MAP_SIZE`, `SHADOW_DEPTH_BIAS`, `SHADOW_SCENE_RADIUS`, +`SHADOW_SCENE_CENTER` are exposed in `wsg_lib::utils` (defaults: 1024, 0.006, 5.0, origin). + +Tuning tips: + +- **Speckled shadow edges (acne)**: raise the bias. +- **Peter-panning** (shadow detached from the object): lower the bias. +- **Shadow clipped at the scene edge**: raise the frustum radius (directional). +- **Shadows too blurry, want them crisper**: raise the map size. + +## Links + +- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md) +- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) diff --git a/lib/examples/README.md b/lib/examples/README.md index e9cc76b..2be7b87 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -9,11 +9,12 @@ cargo run -p wsg-lib --example | 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 diff --git a/lib/examples/cube.rs b/lib/examples/cube.rs index 02cb763..f900cfe 100644 --- a/lib/examples/cube.rs +++ b/lib/examples/cube.rs @@ -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` 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`, loadable via `Texture::from_rgba8`. fn checkerboard_rgba() -> Vec { const SIZE: u32 = 8; let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize); @@ -40,13 +40,13 @@ fn checkerboard_rgba() -> Vec { 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 diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs index eb5e6aa..56c66aa 100644 --- a/lib/examples/demo.rs +++ b/lib/examples/demo.rs @@ -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, diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index b65e9c3..fb585a8 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -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, 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, 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>, - /// Contexte GPU (Instance, Surface, Adapter, Device, Queue). + /// GPU context (Instance, Surface, Adapter, Device, Queue). context: Option, - /// Couche d'exécution qui soumet les draw calls. + /// Execution layer that submits draw calls. renderer: Option, - /// Cache de shaders/pipelines. + /// Shader/pipeline cache. cache: Option, - /// Matériau (pipeline) du quad. + /// Quad material (pipeline). material: Option, - /// Mesh du quad (sommets + indices). + /// Quad mesh (vertices + indices). mesh: Option, } 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` 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` 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, diff --git a/lib/examples/shadow_test.rs b/lib/examples/shadow_test.rs index 8c626ba..ed6e439 100644 --- a/lib/examples/shadow_test.rs +++ b/lib/examples/shadow_test.rs @@ -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 diff --git a/lib/examples/simple.rs b/lib/examples/simple.rs index 4c5f266..c3fca7c 100644 --- a/lib/examples/simple.rs +++ b/lib/examples/simple.rs @@ -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(); } diff --git a/lib/examples/spot_test.rs b/lib/examples/spot_test.rs index 79e7ad1..d6d40f8 100644 --- a/lib/examples/spot_test.rs +++ b/lib/examples/spot_test.rs @@ -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); diff --git a/lib/src/README.md b/lib/src/README.md index 231baa5..125f6cb 100644 --- a/lib/src/README.md +++ b/lib/src/README.md @@ -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 diff --git a/lib/src/app.rs b/lib/src/app.rs index 94e7fef..1236144 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -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(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 ApplicationHandler for AppRunner { .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 ApplicationHandler for AppRunner { 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 ApplicationHandler for AppRunner { 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 ApplicationHandler for AppRunner { 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 ApplicationHandler for AppRunner { // 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 => { diff --git a/lib/src/core/README.md b/lib/src/core/README.md index 6f5ecd9..b3750a1 100644 --- a/lib/src/core/README.md +++ b/lib/src/core/README.md @@ -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 diff --git a/lib/src/core/context.rs b/lib/src/core/context.rs index edb41e8..a6d68e6 100644 --- a/lib/src/core/context.rs +++ b/lib/src/core/context.rs @@ -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; diff --git a/lib/src/core/frame.rs b/lib/src/core/frame.rs index fad02ca..4ee5adf 100644 --- a/lib/src/core/frame.rs +++ b/lib/src/core/frame.rs @@ -12,7 +12,7 @@ //! Frame::try_new() returns `Option` 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`. diff --git a/lib/src/core/input.rs b/lib/src/core/input.rs index 4f92868..f15ef64 100644 --- a/lib/src/core/input.rs +++ b/lib/src/core/input.rs @@ -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, - /// Touches enfoncées pendant la frame courante (valides une seule frame). + /// Keys pressed during the current frame (valid for a single frame). pressed: HashSet, - /// 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, - /// Accumulateur de `pressed` entre deux `begin_frame` (consommé à la rotation). + /// `pressed` accumulator between two `begin_frame` calls (consumed on rotation). frame_pressed: HashSet, - /// Accumulateur de `released` entre deux `begin_frame`. + /// `released` accumulator between two `begin_frame` calls. frame_released: HashSet, - // ---- 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, - /// Boutons pressés pendant la frame courante. + /// Buttons pressed during the current frame. pressed_buttons: HashSet, - /// Boutons relâchés pendant la frame courante. + /// Buttons released during the current frame. released_buttons: HashSet, - /// Accumulateurs des boutons entre deux `begin_frame`. + /// Button accumulators between two `begin_frame` calls. frame_pressed_buttons: HashSet, frame_released_buttons: HashSet, - // ---- 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)); diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 796c294..b8755c2 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -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>, /// 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, ) { 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 { diff --git a/lib/src/math/geometry.rs b/lib/src/math/geometry.rs index 14fd489..1b205e0 100644 --- a/lib/src/math/geometry.rs +++ b/lib/src/math/geometry.rs @@ -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][..])); + } +} diff --git a/lib/src/math/mod.rs b/lib/src/math/mod.rs index 32c61bd..4b14be0 100644 --- a/lib/src/math/mod.rs +++ b/lib/src/math/mod.rs @@ -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; diff --git a/lib/src/math/primitives.rs b/lib/src/math/primitives.rs index 4808662..0134d7e 100644 --- a/lib/src/math/primitives.rs +++ b/lib/src/math/primitives.rs @@ -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 = [ [-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, 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::::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::::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}"); diff --git a/lib/src/math/transform.rs b/lib/src/math/transform.rs index bd0eec1..8970068 100644 --- a/lib/src/math/transform.rs +++ b/lib/src/math/transform.rs @@ -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}"); + } +} diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index a78fdbe..655e2f9 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -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` diffuse. +/// - `binding 0`: sampler (filtering, linear/repeat — D3). +/// - `binding 1`: `texture_2d` 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, /// 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, } 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 { &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>) -> 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 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, diff --git a/lib/src/resources/README.md b/lib/src/resources/README.md index 304fd8c..5c9725c 100644 --- a/lib/src/resources/README.md +++ b/lib/src/resources/README.md @@ -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` (É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` (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 diff --git a/lib/src/resources/camera.rs b/lib/src/resources/camera.rs index 95dfe2d..d99c230 100644 --- a/lib/src/resources/camera.rs +++ b/lib/src/resources/camera.rs @@ -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`. diff --git a/lib/src/resources/lights.rs b/lib/src/resources/lights.rs index 3f561a5..423d600 100644 --- a/lib/src/resources/lights.rs +++ b/lib/src/resources/lights.rs @@ -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 { diff --git a/lib/src/resources/material.rs b/lib/src/resources/material.rs index b6cb1ba..158633a 100644 --- a/lib/src/resources/material.rs +++ b/lib/src/resources/material.rs @@ -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` 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; diff --git a/lib/src/resources/mesh.rs b/lib/src/resources/mesh.rs index caec2c5..1663997 100644 --- a/lib/src/resources/mesh.rs +++ b/lib/src/resources/mesh.rs @@ -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`), 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` 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` 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>`). -/// 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, /// 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>, } 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, @@ -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 { &self.geometry diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index 50fe850..52f9b23 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -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; diff --git a/lib/src/resources/texture.rs b/lib/src/resources/texture.rs index 2deafa5..5c62708 100644 --- a/lib/src/resources/texture.rs +++ b/lib/src/resources/texture.rs @@ -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 { 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) } diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index ee481a5..bb53629 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -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::() as u64; /// Byte size of the per-object uniform buffer (`ObjectUniform`). pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::() 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::() 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)] diff --git a/lib/src/scene/README.md b/lib/src/scene/README.md index b8149fe..6950d3f 100644 --- a/lib/src/scene/README.md +++ b/lib/src/scene/README.md @@ -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. diff --git a/lib/src/scene/entity.rs b/lib/src/scene/entity.rs index 680598e..fcf6645 100644 --- a/lib/src/scene/entity.rs +++ b/lib/src/scene/entity.rs @@ -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`. //! diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index e0fe806..116c83d 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -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`/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` instances. Populated via `add_mesh()`. meshes: HashMap>, /// Map of material identifiers to owned `Arc` instances. Populated via `add_material()`. materials: HashMap>, - /// Map of diffuse texture identifiers to owned `Arc` instances (Étape 10, D4). + /// Map of diffuse texture identifiers to owned `Arc` instances (Step 10, D4). /// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id. textures: HashMap>, /// 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>>, /// 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, } 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 { @@ -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> { 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` 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 { 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, &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")); + } +} diff --git a/lib/src/shaders/README.md b/lib/src/shaders/README.md index 4b3104e..5bb52cc 100644 --- a/lib/src/shaders/README.md +++ b/lib/src/shaders/README.md @@ -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. diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index 3b967d4..eb55544 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -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; diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 03f0557..2e16254 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -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"); }