From 35aeb769a8bf1bcf901c738fb9e61d2d4449c07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Fri, 25 Sep 2026 10:19:24 +0200 Subject: [PATCH] refactor examples --- README.md | 316 ++++------- README_DETAILS.md | 212 +++++++ docs/DRAFT.md | 408 +++++++++++--- docs/ROADMAP.md | 7 +- docs/user/bloom.md | 93 ++++ docs/user/camera-input.md | 4 +- docs/user/emissive-exposure.md | 107 ++++ lib/examples/README.md | 262 ++++++++- lib/examples/bloom.rs | 217 ++++++++ lib/examples/culling.rs | 170 ++++++ lib/examples/demo.rs | 39 +- lib/examples/emissive.rs | 197 +++++++ lib/examples/hdr.rs | 170 ++++++ lib/examples/manual.rs | 2 +- lib/examples/shadow.rs | 201 +++++++ lib/examples/shadow_test.rs | 3 +- lib/src/app.rs | 78 ++- lib/src/{resources => }/camera.rs | 2 +- lib/src/core/bloom.rs | 793 +++++++++++++++++++++++++++ lib/src/core/frustum.rs | 6 +- lib/src/core/mod.rs | 4 +- lib/src/core/renderer.rs | 128 ++++- lib/src/{core => }/input.rs | 2 +- lib/src/lib.rs | 4 + lib/src/{resources => }/lights.rs | 62 +-- lib/src/pipeline/pipeline_cache.rs | 2 +- lib/src/prelude.rs | 12 +- lib/src/resources/material.rs | 5 + lib/src/resources/mod.rs | 30 +- lib/src/resources/uniform.rs | 64 +-- lib/src/scene/scene.rs | 41 +- lib/src/shaders/bloom_blur.wgsl | 56 ++ lib/src/shaders/bloom_composite.wgsl | 47 ++ lib/src/shaders/bloom_threshold.wgsl | 50 ++ lib/src/shaders/standard_shader.wgsl | 13 +- lib/src/utils/conf.rs | 14 +- lib/tests/wgsl_validate.rs | 66 +++ 37 files changed, 3430 insertions(+), 457 deletions(-) create mode 100644 README_DETAILS.md create mode 100644 docs/user/bloom.md create mode 100644 docs/user/emissive-exposure.md create mode 100644 lib/examples/bloom.rs create mode 100644 lib/examples/culling.rs create mode 100644 lib/examples/emissive.rs create mode 100644 lib/examples/hdr.rs create mode 100644 lib/examples/shadow.rs rename lib/src/{resources => }/camera.rs (99%) create mode 100644 lib/src/core/bloom.rs rename lib/src/{core => }/input.rs (99%) rename lib/src/{resources => }/lights.rs (62%) create mode 100644 lib/src/shaders/bloom_blur.wgsl create mode 100644 lib/src/shaders/bloom_composite.wgsl create mode 100644 lib/src/shaders/bloom_threshold.wgsl diff --git a/README.md b/README.md index 96ad0f2..d6e45e5 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,156 @@ -# WSG - WGPU Simple Graphics Library +# WSG — WGPU Simple Graphics Library -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. +**WSG** (WGPU Simple Graphics) est une bibliothèque Rust qui wrap [wgpu](https://github.com/gfx-rs/wgpu) et [winit](https://crates.io/crates/winit) pour dessiner en 3D **sans toucher wgpu directement**. -> **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. Meshes are declared from a CPU `Geometry` (retained as `Arc` on the Mesh). The **GPU-driven two-pass pipeline** (Compute Pass deriving world matrices + frustum culling → indirect draws) is **implemented** (Step 15, Phase 3): `render_scene` and the shadow pass are 100 % indirect, and frustum culling is opt-in (`AppBuilder::with_culling(true)`, off by default) — see [Status](#status), [docs/user/gpu-driven.md](docs/user/gpu-driven.md) and [Roadmap](#roadmap). +## Ce que vous obtenez -## Status +- **Une fenêtre 3D en ~30 lignes** — pas de wgpu, pas de winit dans votre code +- **Éclairage Phong** (directional, point, spot) + **ombres portées** (shadow mapping) +- **HDR + Tone Mapping** (ACES Filmic / Reinhard) — opt-in, zéro coût si désactivé +- **Pipeline GPU-driven** — world matrices + frustum culling sur le GPU, indirect draws +- **LOD** (Level of Detail) — dégradation automatique de la géométrie selon la distance +- **Primitives procédurales** — cube, sphère, cylindre, cône, tore, plan +- **Import de fichiers** — parser OBJ intégré (glTF en cours) +- **Caméra orbitale** + input unifié (clavier/souris) +- **LOD, culling, HDR, ombres** : tout est **opt-in** — ce que vous n'activez pas ne coûte rien -| Area | State | -|------|-------| -| 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) | ✅ Working (Step 15, Phase 3) — `render_scene` + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL `select` argument order — and verified by GPU readback, D14). User doc: [gpu-driven.md](docs/user/gpu-driven.md) · spec: [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** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader | +## Forces -Note: `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is the **single** shader the library ships — flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`). See the `cube` example (3D, lit) and the `simple` example (2D, unlit). +| Force | Détail | +|-------|--------| +| **Zéro wgpu dans votre code** | L'API déclarative (`AppBuilder` + `AppHandler`) encapsule tout | +| **Opt-in = zéro coût** | Un effet non activé n'alloue rien, n'exécute rien | +| **Features Cargo** | Ne compilez que les primitives/import dont vous avez besoin | +| **Un seul shader** | Le `standard` shader (Phong) couvre 90 % des cas ; mode unlit pour la 2D | +| **GPU-driven** | Le CPU envoie des transforms, le GPU fait le reste (matrices, culling, draws) | -## What it does - -### Declarative workflow (recommended) - -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`): +## Quickstart ```rust +use wsg_lib::prelude::*; use wsg_lib::app::AppBuilder; -use wsg_lib::resources::Geometry; use wsg_lib::utils::WsgError; -use wsg_lib::AppHandler; -struct MonQuad; +struct MaScene; -impl AppHandler for MonQuad { +impl AppHandler for MaScene { 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(); + app.scene + .create_material("mat", "standard", None) + .unwrap(); + + // Un cube lit par Phong, posé au-dessus d'un plan + app.scene + .create_mesh("cube", cube(1.0), Some("mat")) + .unwrap(); + app.scene + .add_entity("my_cube", "cube") + .unwrap(); + + app.scene + .create_mesh("ground", plane(10.0, 10.0, 1, 1), Some("mat")) + .unwrap(); + app.scene + .add_entity("floor", "ground") + .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) +fn main() -> Result<(), WsgError> { + let mut app = AppBuilder::new() + .title("Ma scène WSG") + .with_hdr(ToneMapper::Aces) // optionnel : HDR + tone mapping + .build()?; + app.run(MaScene); + Ok(()) } ``` -> 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; -use winit::event_loop::EventLoop; -use winit::window::WindowBuilder; -use wsg_lib::core::{Context, Frame, Renderer}; -use wsg_lib::pipeline::PipelineCache; -use wsg_lib::resources::{Geometry, Material, Mesh}; -use wsg_lib::utils; - -fn main() { - // Window + async GPU init - let event_loop = EventLoop::new().unwrap(); - let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap()); - let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed"); - let format = context.configure(&context.adapter, 800, 600).expect("surface config failed"); - - // 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). - // 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 (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], // 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], // 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); - - // Render loop - event_loop.run(|event, elwt| { - match event { - winit::event::Event::AboutToWait => window.request_redraw(), - winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => { - if let Some(frame) = Frame::try_new(&context.surface) { - renderer.render(frame.view(), &mesh, &material); - renderer.present(frame); - } - } - winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(), - _ => {} - } - }).unwrap(); -} -``` - -## 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, 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 **GPU-driven two-pass pipeline** (Step 15, Phase 3) is implemented: a Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot. 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); user-facing guide in [docs/user/gpu-driven.md](docs/user/gpu-driven.md). - -## Quick reference - -| 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 `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 | ✅ (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 (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 - -WSG is **not published on crates.io** — depend on it by path: - ```toml [dependencies] -wsg-lib = { path = "/path/to/wsg/lib" } -pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder) -winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton) +wsg-lib = { path = "../lib" } +pollster = { version = "1", features = ["macro"] } ``` -| 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 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` | +```sh +cargo run --example demo # le showcase complet (6 primitives, 3 lumières, ombres, HDR) +``` -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**. +## Fonctionnalités + +| Catégorie | Ce qui est disponible | +|-----------|----------------------| +| **Géométrie** | 6 primitives procédurales + import OBJ + `Geometry` custom | +| **Rendu** | Phong (lit), unlit (2D flat), HDR + tone mapping (ACES/Reinhard) | +| **Lumières** | Directional, point, spot (8 max) + ambient | +| **Ombres** | Shadow mapping (directional/spot), slope-scaled bias, PCF | +| **LOD** | Décimation quadric auto, hystérésis, 1 buffer multi-niveaux | +| **GPU-driven** | Compute pass (matrices + culling) → indirect draws | +| **Caméra** | Orbitale (drag/zoom/reset) + presets (front/side/top) | +| **Input** | Clavier (pressed/held/released), souris (delta, scroll, boutons) | +| **Textures** | RGBA8 (de bytes, de fichier, placeholder blanc) | ## Documentation -Three layers (user docs and API reference in **English**; technical docs in **French**): +| Où | Quoi | +|----|------| +| [docs/user/](docs/user/README.md) | **Guide utilisateur** (EN) — comment utiliser l'API, pas à pas | +| [docs/tech/](docs/tech/ARCHI_APP.md) | **Architecture interne** (FR) — décisions, specs, cibles | +| [docs/ROADMAP.md](docs/ROADMAP.md) | Feuille de route (phases 1-5 ✅, phase 6 en cours) | +| [docs/PLAN.md](docs/PLAN.md) | Livre de recette (historique des étapes) | +| `cargo doc -p wsg-lib --no-deps` | **Référence API** (rustdoc, 100 % couvert) | -**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) +## Exemples -**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`) and GPU-driven two-pass pipeline (implemented in Phase 3, 2026-09-22, with the documented deviations); only the future double-buffering notes remain target. -- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. ✅ **Current** — implemented in ROADMAP Phase 3 (2026-09-22, Étape 17, decisions D1–D14); deviations from the original spec are noted in the document. -- [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. +| Exemple | Ce qu'il montre | +|---------|----------------| +| `demo` | Le showcase : 6 primitives, 3 lumières, ombres, HDR, LOD, caméra orbitale | +| `cube` | MVP 3D : un cube lit par Phong, texture checkerboard | +| `simple` | Minimal : un quad coloré en mode unlit (2D) | +| `shadow_test` | Ombres portées isolées | +| `spot_test` | Spotlight isolé | +| `import` | Import de fichier OBJ (feature `import-obj`) | +| `manual` | Workflow low-level (Context/Renderer/PipelineCache, sans App) | -**API reference** — full rustdoc: `cargo doc -p wsg-lib --no-deps` (every public type is documented). +## Features Cargo -## Roadmap +```toml +# Default : toutes les primitives +wsg-lib = { path = "../lib" } -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, then indirect draws (see ARCHI_CPU_GPU). *(Done 2026-09-22 — see item 17. Deviation from the original spec: one indirect draw **per slot** rather than a single fused draw, D1 — see ARCHI_CPU_GPU.)* -3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering. -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 (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.) -17. ✅ **GPU-driven rendering (Step 15, Phase 3.1/3.2/3.3)** — world matrices and indirect draw args move from CPU to GPU. `shaders/gpu_driven.wgsl` (two compute entry points, `compute_matrices` + `cull`, one module, explicit 3-group layout) runs before the render passes over a fixed 256-slot table (the world-matrix buffer is bound to the `uniform` object slot; WebGPU caps a `uniform` binding at 64 KB and a `uniform` offset at 256 B, so each matrix slot is padded to 256 B and 256 × 256 B = 64 KB is the max); `render_scene` and the shadow pass become **100 % indirect** (one indirect draw per active slot, culled/inactive slots are no-ops), and the per-entity CPU draw loop is gone. New `math::Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) + `BBox` on `Geometry`; `TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms` Pod mirrors of the WGSL structs. Frustum **culling is off by default** (non-regression) and opt-in via `AppBuilder::with_culling(true)` / `Renderer::set_culling(bool)`; the `demo` enables it. The object bind-group layout is now dynamic so every entity shares one GPU matrix buffer via per-slot offsets. (Done 2026-09-22; WGSL + frustum + scene-slot tests, 57 lib / 3 WGSL / 3 doctests all green. **Culling fix 2026-09-22**: the WGSL `select` arguments had been written HLSL-style, silently zeroing the draw count of every *visible* entity — a black window; fixed and verified by GPU readback, see D14 in ARCHI_CPU_GPU.md.) +# Minimal : juste le cube +wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] } + +# Avec import OBJ +wsg-lib = { path = "../lib", features = ["import-obj"] } +``` + +| Feature | Active | +|---------|--------| +| `prim-cube`, `prim-plane`, `prim-sphere`, `prim-cylinder`, `prim-cone`, `prim-torus` | Primitives | +| `all-prims` (default) | Les 6 primitives | +| `import-obj` | Parser Wavefront OBJ | +| `import-gltf` | glTF (stub) | + +## Build + +```sh +cargo build --workspace # tout +cargo test --workspace # 116 tests +cargo check --all-targets # vérification rapide +cargo run -p wsg-lib --example demo # lancer le showcase +``` + +## Projet + +- **Langage** : Rust 2024 +- **Dépendances** : wgpu 30, winit 0.30, glam (math) +- **Pas publié sur crates.io** (dépendance par path) +- **Status** : MVP complet (phases 1-5 ✅), post-MVP en cours (phase 6) + +--- + +*Documentation détaillée (architecture, status, API reference, workflow manuel) : [README_DETAILS.md](README_DETAILS.md)* diff --git a/README_DETAILS.md b/README_DETAILS.md new file mode 100644 index 0000000..a0962a0 --- /dev/null +++ b/README_DETAILS.md @@ -0,0 +1,212 @@ +# WSG — Documentation détaillée + +> Contenu technique du README principal : status, architecture, API reference, workflows, roadmap. + +## Status + +| Area | State | +|------|-------| +| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) | +| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, automatic scene rendering | +| `Scene` resource/entity registry | ✅ Working — auto-rendered in one batched pass (`App::render_scene`) | +| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Phase 3) — `render_scene` + shadow pass 100 % indirect; opt-in frustum culling | +| 3D infrastructure (uniform bind groups, MVP + camera) | ✅ Working — per-frame camera + per-entity world matrices in shared uniforms | +| Shadows (shadow mapping) | ✅ Working — directional/spot, slope-scaled bias, PCF 3×3 | +| HDR + Tone Mapping | ✅ Working (Étape 20) — offscreen Rgba16Float, ACES/Reinhard, opt-in | +| LOD (Level of Detail) | ✅ Working (Étape 19) — quadric decimation, hysteresis, multi-level buffer | +| Mesh module (primitives + import) | ✅ Working (Étape 21) — feature-gated primitives, OBJ parser | + +Note: `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is the **single** shader the library ships. Flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)`). + +## Architecture + +### Layer model + +- **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. `render_scene` batches all entities into one encoder + one submit per frame. +- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform`. + +### GPU-driven pipeline (Phase 3) + +A Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot. + +Spec: [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) · User guide: [docs/user/gpu-driven.md](docs/user/gpu-driven.md) + +### Module layout + +``` +lib/src/ +├── lib.rs # crate root, re-exports +├── prelude.rs # glob re-exports (types quotidiens) +├── app.rs # App + AppBuilder +├── handler.rs # AppHandler trait +├── core/ +│ ├── context.rs # GPU lifecycle (Instance/Surface/Adapter/Device/Queue) +│ ├── renderer.rs # RenderPass execution, shadow pass, HDR/TM pass +│ ├── frame.rs # Per-frame RAII (surface texture + view) +│ ├── input.rs # Unified keyboard/mouse state +│ ├── geometry.rs # Geometry (positions/normals/UVs/indices) + BBox +│ ├── transform.rs # Transform (translation/rotation/scale) +│ ├── frustum.rs # Frustum (6 planes, sphere/box culling) +│ ├── lod.rs # LOD decimation (quadric edge collapse) +│ ├── shadow.rs # ShadowConfig (map size, bias, PCF) +│ └── hdr.rs # ToneMapper enum (Aces/Reinhard) +├── mesh/ +│ ├── mod.rs # Re-exports flat +│ ├── primitives/ # 6 feature-gated generators +│ └── import/ # OBJ parser + glTF stub +├── pipeline/ # PipelineCache (shader → RenderPipeline) +├── camera/ # Camera, CameraController +├── lights/ # Lights, Light, LightType, directional_light, … +├── input/ # InputState +├── resources/ # Mesh, Material, Texture, Uniform, Vertex +├── scene/ # Scene (registry), Entity +└── utils/ # Conf constants, WsgError +``` + +## Quick reference (types) + +| Concept | Type | Responsibility | +|---------|------|---------------| +| App / AppBuilder | Facade | Window + event loop + frame + auto scene render | +| AppHandler | Trait | `setup()` / `update()` / `render()` callbacks | +| Scene | Struct | Registry: shaders, materials, meshes, entities, lights, camera | +| Context | Struct | GPU hardware (Instance, Surface, Adapter, Device, Queue) | +| Renderer | Struct | RenderPass execution (scene, shadow, HDR/TM) | +| PipelineCache | Struct | Shader → compiled RenderPipeline cache | +| Material | Struct | Shader ID + texture + pipeline | +| Geometry | Struct | CPU vertex data (positions/normals/UVs/colors/indices) | +| Mesh / Vertex | Struct | GPU geometry / interleaved upload tuple | +| Frame | Struct | Per-frame RAII (surface texture + view) | +| Camera / Transform | Struct | Camera math + per-entity transform | +| CameraController | Struct | Orbital camera (orbit/zoom/reset/apply_to) | +| InputState | Struct | Unified keyboard/mouse (pressed/held/released, delta, scroll) | +| Texture | Struct | GPU image (Rgba8UnormSrgb) + sampler | +| Lights / Light | Struct | Light list (directional/point/spot, MAX=8) + ambient | +| ShadowConfig | Struct | Shadow map size, bias, PCF taps, scene radius | +| ToneMapper | Enum | ACES Filmic / Reinhard | +| BBox | Struct | Axis-aligned bounding box (min/max) | +| Frustum | Struct | 6 planes, sphere/box culling | + +## Declarative workflow (recommended) + +```rust +use wsg_lib::prelude::*; +use wsg_lib::app::AppBuilder; +use wsg_lib::utils::WsgError; + +struct MaScene; + +impl AppHandler for MaScene { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + app.scene.create_material("mat", "standard", None).unwrap(); + app.scene.create_mesh("cube", cube(1.0), Some("mat")).unwrap(); + app.scene.add_entity("my_cube", "cube").unwrap(); + } + fn update(&mut self, app: &mut wsg_lib::App) { + // your per-frame logic + } + // render() default: app.render_scene(frame.view()) — auto-draws everything +} + +fn main() -> Result<(), WsgError> { + let app = AppBuilder::new().title("WSG").build()?; + app.run(MaScene); + Ok(()) +} +``` + +> `Scene` methods return `Result<_, String>` — typed-error unification is on the roadmap. + +## Manual workflow (advanced) + +Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself: + +```rust +use std::sync::Arc; +use winit::event_loop::EventLoop; +use winit::window::WindowBuilder; +use wsg_lib::core::{Context, Frame, Renderer}; +use wsg_lib::pipeline::PipelineCache; +use wsg_lib::resources::{Geometry, Material, Mesh}; +use wsg_lib::utils; + +fn main() { + let event_loop = EventLoop::new().unwrap(); + let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap()); + let context = pollster::block_on(Context::new(window.clone())).expect("GPU init"); + let format = context.configure(&context.adapter, 800, 600).expect("surface config"); + + let mut renderer = Renderer::new(&context, format, 800, 600); + let mut cache = PipelineCache::new(Arc::new(context.device.clone())); + cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap(); + + let material = Material::new(renderer.format(), "standard", &mut cache); + let geometry = Geometry::new(vec![-0.5f32, 0.5, 0.0, 0.5, 0.5, 0.0, 0.5, -0.5, 0.0, -0.5, -0.5, 0.0]) + .with_indices(vec![0, 1, 2, 0, 2, 3]); + let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None); + + event_loop.run(|event, elwt| { + match event { + winit::event::Event::AboutToWait => window.request_redraw(), + winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => { + if let Some(frame) = Frame::try_new(&context.surface) { + renderer.render(frame.view(), &mesh, &material); + renderer.present(frame); + } + } + winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(), + _ => {} + } + }).unwrap(); +} +``` + +## Features + +| Feature | Default | Fournit | +|---------|---------|---------| +| `prim-cube` | ✅ | `cube(size)` | +| `prim-plane` | ✅ | `plane(w, d, seg_x, seg_z)` | +| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` | +| `prim-cylinder` | ✅ | `cylinder(…)` | +| `prim-cone` | ✅ | `cone(…)` | +| `prim-torus` | ✅ | `torus(…)` | +| `all-prims` | ✅ (default) | Les 6 primitives | +| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` | +| `import-gltf` | ⬜ | `load_gltf(path)` (stub) | + +## Design principle: opt-in = zero cost + +| Feature | How to enable | If NOT enabled | +|---------|--------------|----------------| +| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map, no depth pass, no PCF | +| HDR + TM | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass | +| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect buffers | +| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh | +| Primitives | Cargo feature `prim-*` | Not compiled | +| File import | Cargo feature `import-*` | Not compiled | + +## Roadmap + +| Phase | Status | +|-------|--------| +| 1 — Fondations (window, render loop, Context) | ✅ | +| 2 — Infrastructure 3D (Geometry, Mesh, Material, Pipeline) | ✅ | +| 3 — GPU-driven (compute pass, indirect draws, culling) | ✅ | +| 4 — Rendu avancé (shadows, HDR/TM, lights) | ✅ | +| 5 — Polissage (LOD, camera controller, input, demo) | ✅ | +| 6 — Post-MVP (bloom, PBR, cascaded shadows, SSAO, refactoring) | 🔄 | + +## Documentation + +| Où | Quoi | +|----|------| +| [docs/user/](docs/user/README.md) | Guide utilisateur (EN) | +| [docs/tech/](docs/tech/ARCHI_APP.md) | Architecture interne (FR) | +| [docs/ROADMAP.md](docs/ROADMAP.md) | Feuille de route | +| [docs/PLAN.md](docs/PLAN.md) | Livre de recette (historique) | +| `cargo doc -p wsg-lib --no-deps` | Référence API (rustdoc) | diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 7a9cf86..b452ee8 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,99 +1,343 @@ -# Étape 21 — Module `mesh` : primitives optionnelles + import +# Étape 23 — Bloom (post-process HDR) -**Statut : ✅ TERMINÉE** +**Statut** : ✅ Terminé +**Prérequis** : HDR + Tone Mapping (Étape 20 ✅), Emissive (Étape 22 ✅) -## Résumé +--- -Restructuration du module de géométrie : -- `math/` supprimé — types (`Geometry`, `Transform`, `BBox`, `Frustum`, LOD) déplacés vers `core/` -- `primitives.rs` (monolith) → `mesh/primitives/` (6 fichiers, un par famille) -- Nouveau module `wsg::mesh` : point d'entrée unique pour les sources de géométrie -- Features par primitive (`prim-cube`, `prim-sphere`, …) — zéro coût si désactivées -- Parser OBJ intégré (zéro dep externe), wrapper glTF en stub -- `prelude.rs` pour un glob import confortable -- Re-exports top-level : `Geometry`, `Transform`, `BBox` +## Objectif -## Structure finale +Ajouter un effet **bloom** : les zones très brillantes de la scène (emissive > 1.0, spéculaires, +overbright lighting) diffusent une lueur vers les zones voisines. C'est l'effet "glow" qui rend +les néons et les sources de lumière visuellement impactants. + +Le bloom est un **post-process** qui opère sur la texture HDR, entre le rendu de la scène et le +tone mapping. Il est **opt-in** (`AppBuilder::with_bloom(...)`) et n'a **zéro coût** quand +désactivé (aucune texture/pipeline allouée). + +--- + +## Pipeline ``` -lib/src/ -├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox -├── prelude.rs # glob re-exports (types quotidiens) -├── core/ -│ ├── mod.rs # + geometry, transform, frustum, lod -│ ├── geometry.rs # ← déplacé de math/ -│ ├── transform.rs # ← déplacé de math/ -│ ├── frustum.rs # ← déplacé de math/ -│ ├── lod.rs # ← déplacé de math/ -│ ├── renderer.rs -│ ├── shadow.rs -│ ├── hdr.rs -│ ├── context.rs -│ ├── frame.rs -│ └── input.rs -├── mesh/ -│ ├── mod.rs # re-exports flat (cube, plane, sphere, …, load_obj, …) -│ ├── primitives/ -│ │ ├── mod.rs -│ │ ├── cube.rs -│ │ ├── plane.rs -│ │ ├── sphere.rs # uv_sphere + icosphere -│ │ ├── cylinder.rs -│ │ ├── cone.rs -│ │ └── torus.rs -│ └── import/ -│ ├── mod.rs # MeshImportError -│ ├── obj.rs # parser OBJ (zéro dep) -│ └── gltf.rs # stub (wrapper gltf crate à implémenter) -├── app.rs -├── handler.rs -├── pipeline/ -├── resources/ -├── scene/ -└── utils/ +Scene render → HDR texture (Rgba16Float, full res) + │ + ├─[bloom actif?]─→ 1. Threshold (half res) : extrait les pixels > threshold + │ 2. Blur H (half res) : Gaussian 9 taps + │ 3. Blur V (half res) : Gaussian 9 taps + │ 4. Composite (full res) : HDR += bloom × intensity + │ + ▼ + TM pass → surface ``` -## Features (Cargo.toml) +Quand bloom est désactivé : `Scene → HDR → TM → surface` (comme aujourd'hui, zéro overhead). -| Feature | Default | Fournit | -|---------|---------|---------| -| `prim-cube` | ✅ (via all-prims) | `cube(size)` | -| `prim-plane` | ✅ | `plane(w, d, sx, sz)` | -| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` | -| `prim-cylinder` | ✅ | `cylinder(…)` | -| `prim-cone` | ✅ | `cone(…)` | -| `prim-torus` | ✅ | `torus(…)` | -| `all-prims` | ✅ (default) | les 6 ci-dessus | -| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` | -| `import-gltf` | ⬜ | `load_gltf(path)` (stub) | +**4 passes fullscreen** supplémentaires (seulement si HDR + bloom actifs). + +--- + +## Composants + +### `BloomConfig` (pub, dans `core/bloom.rs`) + +```rust +pub struct BloomConfig { + /// Seuil de luminance (en unités HDR linéaires). Au-dessus → contribue au bloom. + /// Défaut : 1.0 (seul ce qui dépasse 1.0 "bloom" — les emissives > 1.0, les spéculaires). + pub threshold: f32, + /// Intensité du bloom (multiplicateur sur le résultat du blur). Défaut : 0.8. + pub intensity: f32, + /// Rayon du blur en pixels (à la résolution half-res). Défaut : 4.0. + pub radius: f32, +} +impl Default for BloomConfig { /* threshold=1.0, intensity=0.8, radius=4.0 */ } +``` + +### `BloomPipeline` (interne, dans `core/bloom.rs`) + +```rust +struct BloomPipeline { + /// Texture half-res pour le bloom (Rgba16Float). + bright_texture: wgpu::Texture, + bright_view: wgpu::TextureView, + /// Texture half-res pour le blur ping-pong (2nd buffer). + blur_texture: wgpu::Texture, + blur_view: wgpu::TextureView, + /// Sampler linear pour le blur. + sampler: wgpu::Sampler, + /// Pipeline threshold (fullscreen → half-res). + threshold_pipeline: wgpu::RenderPipeline, + /// Pipeline blur (fullscreen half-res, direction via uniform). + blur_pipeline: wgpu::RenderPipeline, + /// Pipeline composite (full-res: HDR += bloom). + composite_pipeline: wgpu::RenderPipeline, + /// Bind groups pré-alloués. + threshold_bg: wgpu::BindGroup, + blur_bg_a: wgpu::BindGroup, // reads bright, writes blur + blur_bg_b: wgpu::BindGroup, // reads blur, writes bright (ping-pong) + composite_bg: wgpu::BindGroup, // reads HDR + bright + /// Uniform buffer pour le blur (direction + radius). + blur_uniform: wgpu::Buffer, + /// Uniform buffer pour le threshold (threshold value). + threshold_uniform: wgpu::Buffer, + /// Half-res dimensions. + width: u32, + height: u32, +} +``` + +### Shaders (3 fichiers WGSL) + +#### `bloom_threshold.wgsl` +- Vertex : fullscreen triangle +- Fragment : lit la texture HDR (full res), calcule la luminance, sort `color × smoothstep(threshold, threshold+knee, lum)` ou `max(color - threshold, 0)` si `lum > threshold`, sinon `0` +- Écrit dans la texture half-res + +#### `bloom_blur.wgsl` +- Vertex : fullscreen triangle (à la résolution half-res) +- Fragment : 9-tap Gaussian séparable. L'offset est `texel_size × radius × i` dans la direction donnée par l'uniform. +- Uniform : `vec2 direction` (dx, dy), `f32 radius` +- Weights Gaussian : `[0.227027, 0.194595, 0.121622, 0.054054, 0.016216]` (symétrique) + +#### `bloom_composite.wgsl` +- Vertex : fullscreen triangle (full res) +- Fragment : `result = hdr_color + bloom_color × intensity` +- Uniform : `f32 intensity` +- Lit les 2 textures (HDR full-res + bloom half-res, upscalé par le sampler linear) + +--- + +## Shaders + +### `bloom_threshold.wgsl` + +```wgsl +// Fullscreen triangle vertex (même pattern que tonemap) +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + var pos: vec2; + pos.x = f32((vi << 1) & 2) * 2.0 - 1.0; + pos.y = f32(vi & 2) * 2.0 - 1.0; + var out: VsOut; + out.pos = vec4(pos.x, -pos.y, 0.0, 1.0); + out.uv = vec2(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5); + return out; +} + +struct ThresholdUniforms { + threshold: f32, + knee: f32, + pad: vec2, +}; + +@group(0) @binding(0) var tmu: ThresholdUniforms; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_sampler: sampler; +@group(0) @binding(3) var pad; // placeholder — not needed, use texture_storage + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let color = textureSample(src_tex, src_sampler, in.uv).rgb; + let lum = dot(color, vec3(0.2126, 0.7152, 0.0722)); + // Soft knee: smooth transition above threshold + let soft = max(lum - tmu.threshold, 0.0); + let contrib = soft / (soft + tmu.knee); // 0..1 smooth + return vec4(color * contrib, 1.0); +} +``` + +### `bloom_blur.wgsl` + +```wgsl +// Même VsOut / vs_main que threshold (fullscreen triangle) + +struct BlurUniforms { + direction: vec2, // texel offset: (1/w, 0) or (0, 1/h) + radius: f32, + pad: vec2, +}; + +@group(0) @binding(0) var bu: BlurUniforms; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_sampler: sampler; + +const W: array = array( + 0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162 +); + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let center = textureSample(src_tex, src_sampler, in.uv).rgb; + var sum = center * W[0]; + for (var i: u32 = 1u; i < 5u; i = i + 1u) { + let off = bu.direction * (f32(i) * bu.radius); + let s = textureSample(src_tex, src_sampler, in.uv + off).rgb + + textureSample(src_tex, src_sampler, in.uv - off).rgb; + sum = sum + s * W[i]; + } + return vec4(sum, 1.0); +} +``` + +### `bloom_composite.wgsl` + +```wgsl +// Même VsOut / vs_main + +struct CompositeUniforms { + intensity: f32, + pad: vec3, +}; + +@group(0) @binding(0) var cu: CompositeUniforms; +@group(0) @binding(1) var hdr_tex: texture_2d; +@group(0) @binding(2) var hdr_sampler: sampler; +@group(0) @binding(3) var bloom_tex: texture_2d; +@group(0) @binding(4) var bloom_sampler: sampler; + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb; + let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb; + return vec4(hdr + bloom * cu.intensity, 1.0); +} +``` + +--- + +## Intégration dans `Renderer::render_scene` + +``` +Step 7: Main render pass → HDR texture (ou surface si pas HDR) +Step 8: [Bloom] Si HDR + bloom actifs : + 8a. Threshold pass (HDR full → bright half) + 8b. Blur H (bright half → blur half) + 8c. Blur V (blur half → bright half) [ping-pong] + 8d. Composite (HDR full + bright half → HDR full) + 8e. write_buffer(exposure) — comme aujourd'hui +Step 9: TM pass (HDR full → surface) +``` + +Le composite **modifie la texture HDR in-place** (rend dans une 2ème texture puis swap, ou +rend directement dans la HDR texture si on utilise un ping-pong). En pratique : le composite +rend dans la `HDR texture` elle-même (le bind group lit la HDR comme input ET écrit dedans — +**NON**, c'est undefined behavior en wgpu). + +**Solution** : le composite écrit dans un **3ème buffer full-res** (ou on swap les rôles : +le bloom écrit dans la HDR texture en lisant une copie). La solution la plus simple : +- Le threshold lit la HDR texture et écrit dans `bright` (half res) +- Le blur ping-ponge entre `bright` et `blur` (half res) +- Le composite lit la HDR texture + `bright` (half res) et écrit dans la **HDR texture** + (c'est OK car le composite est une pass séparée qui commence APRÈS que le threshold/blur + ont fini d'écrire — et le composite lit la HDR texture en input mais écrit aussi dedans) + +Attendez — **non**, en wgpu/WebGPU, on ne peut PAS lire et écrire la même texture dans la même +render pass. Mais on peut le faire dans des **passes différentes** (le composite est une pass +séparée du threshold). Le problème est que le composite lit la HDR texture (qui n'a pas été +modifiée par threshold/blur — ils ont écrit dans bright/blur) et écrit dans la HDR texture. +C'est **valide** car c'est dans une render pass unique : le GPU ne permet pas de lire ET écrire +la même texture attachment dans la même pass. + +**Solution propre** : utiliser un **ping-pong full-res** : +- `hdr_texture` (existante) : contient le rendu de la scène +- `bloom_composite_texture` (full-res, allouée avec le bloom) : reçoit le résultat du composite +- Le TM pass lit `bloom_composite_texture` au lieu de `hdr_texture` + +Quand bloom est inactif : le TM lit `hdr_texture` directement (comme aujourd'hui). + +--- + +## API utilisateur + +| Composant | Changement | +|-----------|-----------| +| `AppBuilder` | `with_bloom(config: BloomConfig)` — active le bloom | +| `App` | `set_bloom_config(config)`, `bloom_enabled() -> bool` | +| `Renderer` | Champ `bloom: Option`, `bloom_config: BloomConfig` | +| `core/mod.rs` | `pub mod bloom;` + re-export `BloomConfig` | +| `lib.rs` | Re-export `BloomConfig` | +| `prelude.rs` | Re-export `BloomConfig` | + +**Règle** : le bloom n'a d'effet que si HDR est actif. `with_bloom()` sans `with_hdr()` est +un no-op (log un warning). + +--- + +## Resize + +Au resize, si le bloom est actif : +- Recréer les textures half-res (bright, blur) +- Recréer le composite texture full-res +- Recréer les bind groups +- Mettre à jour les uniforms (dimensions) + +--- ## Décisions -| # | Décision | -|---|----------| -| D1 | Un seul crate `wsg-lib` — pas de crate séparée | -| D2 | Feature par famille de primitives | -| D3 | Feature par format d'import | -| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` | -| D5 | `Geometry::new()` / `Scene::add_mesh()` restent en core | -| D6 | Module `wsg::mesh` au même niveau que `core`, `app` | -| D7 | `primitives/` un fichier par famille | -| D8 | `import/` un fichier par format | -| D9 | Import retourne `Result<_, MeshImportError>` | -| D10 | `default = ["all-prims"]` | -| D11 | `all-prims` = les 6 primitives | -| D12 | `math` disparaît — types re-exportés par `core` / top-level | +| # | Décision | Justification | +|---|----------|---------------| +| D1 | 4 passes (threshold + blur H + blur V + composite) | Bonne qualité/performances. Un seul niveau de mip suffit pour un bloom "soft" | +| D2 | Résolution half-res pour le bloom | Standard. Le blur à half-res est 4× moins coûteux et le résultat upscalé par le sampler linear est lisse | +| D3 | Soft-knee threshold (pas un cutoff dur) | `soft/(soft+knee)` donne une transition douce, pas d'aliasing au seuil | +| D4 | Composite via ping-pong full-res (3ème texture) | Évite le conflit read/write sur la même texture dans une même pass | +| D5 | Bloom seulement si HDR actif | Le bloom opère en espace linéaire HDR. Sans HDR, les valeurs sont déjà clampées [0,1] → pas de "bright" à extraire | +| D6 | `BloomConfig` avec 3 champs (threshold, intensity, radius) | Minimum utile. Pas de multi-mip, pas de directional bloom pour MVP | +| D7 | Sampler `Linear` + `ClampToEdge` pour le blur | Les bords ne doivent pas sampler hors-texture (artefacts noirs) | +| D8 | Le TM pass lit la texture composite (si bloom) ou la HDR (si pas bloom) | Le TM est agnostique de la source — il lit juste une texture full-res Rgba16Float | +| D9 | Uniform threshold : 16 bytes (threshold + knee + 2 pad) | Aligned 16, simple | +| D10 | Uniform blur : 16 bytes (direction vec2 + radius + pad) | Aligned 16 | +| D11 | Uniform composite : 16 bytes (intensity + 3 pad) | Aligned 16 | + +--- + +## Fichiers modifiés / créés + +| Fichier | Changement | +|---------|-----------| +| `lib/src/core/bloom.rs` | **Nouveau** : `BloomConfig`, `BloomPipeline`, allocation + bind groups | +| `lib/src/core/renderer.rs` | + `bloom: Option`, `bloom_config` ; passes 8a-8d ; TM lit composite ou HDR ; resize | +| `lib/src/core/hdr.rs` | `create_hdr_bind_group` accepte une texture arbitraire (pas seulement `self.texture`) | +| `lib/src/core/mod.rs` | + `pub mod bloom;` + re-exports | +| `lib/src/shaders/bloom_threshold.wgsl` | **Nouveau** | +| `lib/src/shaders/bloom_blur.wgsl` | **Nouveau** | +| `lib/src/shaders/bloom_composite.wgsl` | **Nouveau** | +| `lib/src/shaders/conf.rs` | + `BLOOM_THRESHOLD_SHADER`, `BLOOM_BLUR_SHADER`, `BLOOM_COMPOSITE_SHADER` | +| `lib/src/app.rs` | + `bloom_config`, `bloom_enabled`, `set_bloom_config`, builder `with_bloom` | +| `lib/src/lib.rs` | Re-export `BloomConfig` | +| `lib/src/prelude.rs` | Re-export `BloomConfig` | +| `lib/tests/wgsl_validate.rs` | + 3 tests (threshold, blur, composite) | +| `lib/examples/demo.rs` | + `with_bloom(BloomConfig::default())` | +| `docs/user/bloom.md` | **Nouveau** : doc utilisateur | +| `docs/ROADMAP.md` | 6.3 → ✅ | + +--- ## Tests -- 107 unit tests (dont 7 tests OBJ parser) -- 4 WGSL validation -- 5 doctests -- **Total : 116 tests, 0 failures** +| Test | Vérifie | +|------|---------| +| `bloom_config_default` | threshold=1.0, intensity=0.8, radius=4.0 | +| `bloom_requires_hdr` | `with_bloom` sans `with_hdr` → warning, bloom inactif | +| `bloom_pipeline_allocates_half_res` | dimensions = (w/2, h/2) | +| `bloom_zero_intensity_is_noop` | intensity=0 → composite = HDR (pas de changement) | +| WGSL threshold | compile avec naga | +| WGSL blur | compile avec naga | +| WGSL composite | compile avec naga | -## Build vérifié +--- -- `cargo check` (default = all-prims) ✅ -- `cargo check --no-default-features --features "prim-cube"` ✅ -- `cargo check --features "import-obj,import-gltf"` ✅ -- `cargo check --examples --features "import-obj"` ✅ +## Critères d'acceptation + +- [ ] `cargo test` passe (tous tests existants + nouveaux) +- [ ] `cargo run --example demo` : le glow sphere produit un halo visible +- [ ] Sans bloom : rendu identique à avant (zéro régression) +- [ ] Sans HDR + avec bloom : pas de crash (bloom ignoré, warning) +- [ ] Resize : le bloom continue de fonctionner +- [ ] 0 warnings diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a6a27ab..073b094 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -63,9 +63,9 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT | # | Item | Impact visuel | Effort | Statut | |---|------|:---:|:---:|:---:| -| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ⬜ | -| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ⬜ | -| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ⬜ | +| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ | +| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ | +| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ | | 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ | | 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ | | 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ | @@ -79,6 +79,7 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT | 6.9 | API update géométrie par entité (per-frame, sans rebuild complet) | ⬜ | | 6.10 | Double-buffering des buffers Transform/Matrix (désync CPU/GPU) | ⬜ | | 6.11 | **Module `mesh`** : primitives en features optionnelles + import (OBJ/gltf) — `math/` supprimé | ✅ | +| 6.12 | **Module `texture`** : génération procédurale (checkerboard, gradient, noise) + formats compressés (KTX2, basis) en features optionnelles | ⬜ | --- diff --git a/docs/user/bloom.md b/docs/user/bloom.md new file mode 100644 index 0000000..67c1a5b --- /dev/null +++ b/docs/user/bloom.md @@ -0,0 +1,93 @@ +# Bloom (Étape 23) + +Le **bloom** est un post-process qui crée un effet de "glow" autour des zones brillantes de +l'image. Les pixels dont la luminance dépasse un seuil sont extraits, floutés, puis ajoutés +à l'image originale. + +> **Prérequis** : le bloom nécessite l'HDR (`AppBuilder::with_hdr`). Sans HDR, les valeurs +> sont déjà clampées à [0,1] et il n'y a rien de "brillant" à extraire. + +## Activation + +```rust +use wsg_lib::prelude::*; + +let app = AppBuilder::new() + .with_hdr(ToneMapper::Aces) // requis + .with_bloom(BloomConfig { + threshold: 1.0, // seuil de luminance HDR + knee: 0.5, // largeur du soft-knee + intensity: 0.8, // intensité du glow + radius: 4.0, // rayon du blur (pixels, demi-rés) + ..Default::default() + }) + .build() + .await?; +``` + +## `BloomConfig` + +| Champ | Type | Défaut | Description | +|-------|------|--------|-------------| +| `threshold` | `f32` | `1.0` | Seuil de luminance (unités HDR linéaires). Seuls les pixels > seuil contribuent au bloom. | +| `knee` | `f32` | `0.5` | Largeur du soft-knee. Plus grand = transition plus douce. | +| `intensity` | `f32` | `0.8` | Multiplicateur appliqué au résultat flouté avant addition à l'HDR. | +| `radius` | `f32` | `4.0` | Rayon du blur en pixels (à la demi-résolution). Plus grand = glow plus étendu. | + +## Mise à jour runtime + +```rust +// Dans le handler (fn update): +if app.bloom_enabled() { + app.set_bloom_config(BloomConfig { + intensity: new_intensity, + ..app.bloom_config() + }); +} +``` + +Les changements prennent effet au frame suivant (les uniforms sont ré-écrits chaque frame). + +## Pipeline (4 passes GPU) + +``` +Scene ──→ HDR (full res, Rgba16Float) + │ + ├──→ [1] Threshold (full → half res) + │ Soft-knee: smoothstep(knee, knee+1, lum) + │ + ├──→ [2] Blur H (half res) + │ 9-tap Gaussian séparable, direction = (1/w, 0) + │ + ├──→ [3] Blur V (half res) + │ 9-tap Gaussian séparable, direction = (0, 1/h) + │ (ping-pong: écrit dans la texture bright) + │ + └──→ [4] Composite (full res) + output = HDR + bloom × intensity + (écrit dans une 3e texture full-res) + │ + ▼ + Tone Mapping (lit le composite) + │ + ▼ + Surface (sRGB) +``` + +## Coût + +- **Sans bloom** (défaut) : zéro overhead. Le TM lit directement la texture HDR. +- **Avec bloom** : 4 passes supplémentaires (1 full-res + 3 half-res) + 3 textures + intermédiaires. Le coût est modéré car le blur est en demi-résolution. + +## Non-régression + +- `with_bloom()` sans `with_hdr()` → warning + no-op (le bloom est ignoré). +- Sans `with_bloom()` → le TM lit la texture HDR directement (comportement Étape 20 inchangé). + +## Limitations (MVP) + +- Un seul niveau de mip (pas de multi-mip "soft" bloom à la Unreal). +- Pas de directional bloom. +- Le blur est un Gaussian 9-taps (qualité suffisante pour un glow "soft"). +- Pas de bloom séparé par couche (pas de "bloom mask" par matériau). diff --git a/docs/user/camera-input.md b/docs/user/camera-input.md index baeb6a2..3b93351 100644 --- a/docs/user/camera-input.md +++ b/docs/user/camera-input.md @@ -10,7 +10,7 @@ The scene holds a single camera, read by the engine every frame to write the vie matrices into the frame buffer (aspect recomputed from the window size). ```rust -use wsg_lib::resources::Camera; +use wsg_lib::camera::Camera; use glam::Vec3; app.scene.set_camera(Camera::new( @@ -37,7 +37,7 @@ app.scene.set_camera(Camera::new( bounded to `[0.1, 100]`), `target` (target point). ```rust -use wsg_lib::resources::CameraController; +use wsg_lib::camera::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) diff --git a/docs/user/emissive-exposure.md b/docs/user/emissive-exposure.md new file mode 100644 index 0000000..0e13f7a --- /dev/null +++ b/docs/user/emissive-exposure.md @@ -0,0 +1,107 @@ +# Émissive + Exposure + +## Principe + +Deux features complémentaires (Étape 22) : + +| Feature | Effet | Coût | +|---------|-------|------| +| **Exposure** (6.1) | Multiplie la luminance avant la courbe de tone mapping | Zéro si HDR inactif | +| **Emissive** (6.2) | Ajoute une couleur émise (indépendante des lumières) | Zéro si `emissive = [0,0,0,0]` | + +## Exposure + +### API + +```rust +// Initialisation (optionnel, default = 1.0) +let app = AppBuilder::new() + .with_hdr(ToneMapper::Aces) + .with_exposure(1.5) // démarre plus clair + .build().await?; + +// Runtime (dans update()) +app.set_exposure(app.exposure() * 1.1); // +1 "stop" +app.set_exposure(1.0); // reset +``` + +### Comportement + +- L'exposure est un **multiplicateur** appliqué à la texture HDR avant la courbe de tone mapping. +- `exposure = 2.0` → l'image est 2× plus claire (comme ouvrir le diaphragme d'un photo). +- `exposure = 0.5` → l'image est 2× plus sombre. +- Clampé à `[0.01, 10.0]` pour éviter les valeurs dégénérées. +- **N'a d'effet que si HDR est actif** (`with_hdr(...)`). En LDR, la valeur est ignorée. + +### Clavier (demo) + +| Touche | Effet | +|--------|-------| +| `+` | ×1.1 (plus clair) | +| `-` | ÷1.1 (plus sombre) | +| `0` | Reset à 1.0 | + +## Emissive + +### API + +```rust +use wsg_lib::resources::Material; + +// Créer un matériau avec émissivité +let mut mat = /* ... */; +mat.emissive = [1.0, 0.3, 0.1, 1.5]; // orange, intensité 1.5 (> 1.0 = glow HDR) +``` + +### Format + +`emissive = [r, g, b, intensity]` : + +- **rgb** : la couleur de l'émission (même espace que la couleur base du vertex) +- **a (intensity)** : le multiplicateur. `1.0` = couleur normale, `> 1.0` = surbrillance (ne se voit qu'en HDR) + +### Formule shader + +``` +final_color = lit + base_color * emissive.rgb * emissive.a +``` + +- L'émission est **additive** : visible même dans le noir total (pas de lumière nécessaire). +- Elle est **indépendante des ombres** : un objet émissif ne projette pas d'ombre et n'est pas ombragé. +- `emissive = [0,0,0,0]` (default) → aucun changement (non-régression garantie). + +### Cas d'usage + +| Usage | Valeur | +|-------|--------| +| LED / indicateur | `[0, 1, 0, 1.0]` (vert, intensité normale) | +| Flamme / soleil | `[1, 0.8, 0.2, 3.0]` (orange, glow HDR) | +| Neon | `[0, 0.5, 1, 2.5]` (cyan, glow) | +| Inactif | `[0, 0, 0, 0]` (default) | + +### Clavier (demo) + +| Touche | Effet | +|--------|-------| +| `E` | Toggle glow orange sur la sphère/cylindre | + +## Interactions + +| Combination | Résultat | +|-------------|----------| +| Emissive + HDR + ACES | Glow doux, highlights roll off (le plus joli) | +| Emissive + LDR | Clamped à 1.0 (pas de glow, mais couleur visible dans le noir) | +| Emissive + shadows | L'objet émissif n'est PAS ombragé (l'émission bypass le shadow term) | +| Exposure + Emissive | L'exposure amplifie aussi l'émission (cohérent : tout est dans la texture HDR) | + +## Non-régression + +- **Emissive** : `[0,0,0,0]` par défaut → le shader additionne `base * 0 * 0 = 0` → aucun changement. +- **Exposure** : `1.0` par défaut → `pow(color, 1/1) = color` → aucun changement. +- Les deux sont **opt-in** : sans `with_hdr(...)` ni `emissive != 0`, le pipeline est identique à l'état précédent. + +## Limitations (MVP) + +- L'emissive est **par matériau**, pas par vertex (pas de gradient d'émission dans un mesh). +- L'emissive est **statique** à la création du matériau (changer `mat.emissive` requiert de re-registrer le matériau via `add_material`). +- Pas de **bloom** (Étape 23) : le glow HDR est visible mais pas "flou" / diffusé. diff --git a/lib/examples/README.md b/lib/examples/README.md index d1d859e..9d73e3b 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -1,23 +1,251 @@ -# Examples +# Exemples WSG -Each `.rs` file in this directory is a **standalone example** auto-discovered by Cargo -(`cargo build -p wsg-lib --examples`). To run an example: +Chaque exemple est autonome et illustre **un effet ou une fonctionnalité** spécifique +de la bibliothèque. Tous utilisent l'API déclarative (`AppBuilder` + `AppHandler`). -```bash -cargo run -p wsg-lib --example +## Lancer un exemple + +```sh +cargo run -p wsg-lib --example ``` -| Example | Command | Description | -|---------|---------|-------------| -| `demo` | `cargo run -p wsg-lib --example demo` | **Showcase**: 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: one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. | +| Exemple | Effet démontré | +|---------|---------------| +| `demo` | Showcase complet (tous les effets combinés) | +| `bloom` | Post-process bloom (glow autour des zones brillantes) | +| `hdr` | HDR + Tone Mapping (ACES) + contrôle d'exposition | +| `emissive` | Matériaux émissifs (intensités croissantes 0 → 4.0) | +| `shadow` | Shadow mapping (ombre portée directionnelle) | +| `culling` | Culling GPU-driven (grille 20×20, objets hors frustum ignorés) | +| `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) | +| `import` | Import de fichier OBJ (non graphique, stdout) | -## Conventions +--- -- Examples are **self-contained**: no assets loaded from disk (procedural textures, hardcoded geometry). -- They use the declarative workflow (`AppBuilder` + `Scene`) except `manual`, which bypasses the `App` facade. -- When adding a new example: create a `.rs` file in this directory, document it here, and reference it in the root README if appropriate. +## `demo` — Showcase complet + +Combine **tous** les effets : primitives LOD, textures procédurales, lumières +(directional + point + spot), ombres, HDR/ACES, exposition, émissif, bloom, culling. + +```sh +cargo run -p wsg-lib --example demo +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `1` / `2` / `3` | Presets : face / côté / dessus | +| `+` / `-` | Exposition ×1.3 / ÷1.3 | +| `0` | Reset exposition | + +--- + +## `bloom` — Post-process Bloom + +Deux sphères émissives (orange intensité 2.0, bleue intensité 3.0) produisent un +halo visible. Le cube et le sol servent de référence (non-émissifs). + +Le bloom est un pipeline 4 passes GPU : threshold → blur H → blur V → composite. + +```sh +cargo run -p wsg-lib --example bloom +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `+` / `-` | **Bloom threshold** +0.1 / −0.1 | +| `[` / `]` | **Bloom intensity** +0.1 / −0.1 | +| `I` / `O` | **Bloom radius** +0.5 / −0.5 | +| `E` / `Q` | Exposition ×1.3 / ÷1.3 | +| `0` | Reset exposition | + +### Ce qu'on voit + +- **threshold bas** (0.0) : tout l'image "bloom" (effet très diffus). +- **threshold élevé** (2.0+) : seules les sphères émissives brillantes produisent du glow. +- **intensity 0.0** : pas de glow visible (même si le threshold extrait des pixels). +- **radius grand** (10+) : le glow s'étend sur une grande zone. + +--- + +## `hdr` — HDR + Tone Mapping + +Démontre le rendu HDR avec la courbe ACES Filmic. Trois objets : + +- **Cube** : éclairage normal (aucun émissif) — référence LDR. +- **Sphère brillante** (émissif 3.0) : sans HDR, elle serait clampée à blanc. + Avec ACES, les highlights "roulent" doucement vers le blanc (rolloff). +- **Sphère sombre** (émissif 0.3) : reste sombre même à haute exposition. + +```sh +cargo run -p wsg-lib --example hdr +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `E` | **Exposition ×1.3** (plus clair) | +| `Q` | **Exposition ÷1.3** (plus sombre) | +| `0` | Reset exposition à 1.0 | + +### Ce qu'on voit + +- À exposition 1.0 : la sphère brillante est blanche mais avec des détails (rolloff ACES). +- À exposition haute (E×E×E) : la scène s'éclaircit, la sphère brillante reste blanche + (saturée), mais le cube gagne en détail. +- À exposition basse (Q×Q) : tout s'assombrit, la sphère brillante devient orangée + (les valeurs HDR > 1.0 sont compressées). + +> **Note** : le tone mapper est compilé dans le pipeline au build. Pour comparer +> ACES vs Reinhard, modifier `ToneMapper::Aces` → `ToneMapper::Reinhard` dans le source. + +--- + +## `emissive` — Matériaux Émissifs + +Cinq sphères alignées avec des intensités émissives croissantes : + +| Sphere | Couleur | Intensité | Effet | +|--------|---------|-----------|-------| +| 1 | Gris | 0.0 | Aucune glow (référence) | +| 2 | Orange | 0.5 | Légère lueur | +| 3 | Jaune | 1.0 | Lueur visible | +| 4 | Vert | 2.0 | Glow HDR (au-delà de 1.0) | +| 5 | Bleu | 4.0 | Glow intense (saturation) | + +Avec HDR, les intensités > 1.0 produisent un vrai "glow" (les valeurs dépassent +[0,1] en espace linéaire). Sans HDR, elles seraient clampées à blanc. + +```sh +cargo run -p wsg-lib --example emissive +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `E` / `Q` | Exposition ×1.3 / ÷1.3 | +| `0` | Reset exposition | +| `C` | **Cycler le multiplicateur d'émissif** (1× → 2× → 0.5× → ...) | + +### Ce qu'on voit + +- La sphère 1 (intensité 0) est simplement éclairée par la lumière directionnelle. +- Les sphères 2-5 brillent de leur propre lumière, indépendamment de l'éclairage. +- `C` double ou réduit toutes les intensités en même temps (pour voir l'effet HDR). + +--- + +## `shadow` — Shadow Mapping + +Quatre objets (cube, sphère, cône, cylindre) sur un sol, éclairés par une lumière +directionnelle qui projette des ombres. La qualité des ombres est contrôlée par +`ShadowConfig` (taille de la shadow map, biais anti-acne). + +```sh +cargo run -p wsg-lib --example shadow +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra | +| Molette | Zoom | +| `R` | Reset caméra | +| `1` | Vue de face | +| `2` | Vue de côté | +| `3` | **Vue de dessus** (voir la forme des ombres clairement) | +| `L` | Changer la direction de la lumière (3 presets) | + +### Ce qu'on voit + +- Le cube tourne lentement → son ombre bouge sur le sol. +- La sphère a une transition ombre/lumière douce (terminateur lisse). +- Le cône produit une ombre triangulaire distincte. +- En vue de dessus (`3`), on voit la forme exacte des ombres projetées. +- La taille de la shadow map (1024 par défaut) détermine la résolution : + modifier `SHADOW_MAP_SIZE` en haut du fichier pour tester 256 (pixelisé) ou 2048 (net). + +--- + +## `culling` — GPU Frustum Culling + +Une grille de **15×15 = 225 cubes** est placée sur un grand sol. Le culling +GPU-driven (compute shader) détermine quels cubes sont visibles dans le frustum +de la caméra et zéro leurs draw args indirects — **zéro coût CPU**. + +```sh +cargo run -p wsg-lib --example culling +``` + +### Touches + +| Touche | Action | +|--------|--------| +| Glisser (LMB) | Orbiter la caméra (regarder autour) | +| Molette | Zoom in/out | +| `R` | Reset (vue de dessus) | +| `1` | Vue de face (les cubes derrière sont culled) | +| `2` | Vue de côté | +| `3` | **Vue de dessus** (voir toute la grille) | + +### Ce qu'on voit + +- En vue de dessus (`3`) : toute la grille 20×20 est visible. +- Orbiter à 90° : les cubes derrière la caméra **ne sont pas dessinés** (culled). +- Zoomer très près : seuls les cubes proches du plan de near sont rendus. +- Les cubes tournent lentement (phases décalées) → le culling est dynamique + (un cube peut entrer/sortir du frustum au cours d'une frame). + +> **Note** : le culling est activé via `AppBuilder::with_culling(true)`. Le modifier +> à `false` dans le source désactive le culling (tous les 400 cubes sont toujours +> dessinés, même hors écran). + +--- + +## `manual` — Workflow bas niveau + +Démontre l'API **sans** la façade `App` : utilisation directe de `Context`, +`Renderer`, `PipelineCache`, `Mesh`, `Material`. Rend un quad coloré (unlit). + +Utile pour comprendre ce que la façade `App` encapsule. + +```sh +cargo run -p wsg-lib --example manual +``` + +Pas de touches — rendu statique (quad unlit, 4 couleurs). + +--- + +## `import` — Import de fichier OBJ + +Exemple **non graphique** : parse un fichier `.obj` et affiche les statistiques +(nombre de sommets, normales, UVs, indices, bounding box) sur stdout. + +```sh +# Avec un fichier : +cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj + +# Sans argument (triangle de démonstration) : +cargo run -p wsg-lib --example import --features import-obj +``` + +Pas de touches — s'exécute et quitte. diff --git a/lib/examples/bloom.rs b/lib/examples/bloom.rs new file mode 100644 index 0000000..0d3bfab --- /dev/null +++ b/lib/examples/bloom.rs @@ -0,0 +1,217 @@ +//! **Bloom** — demonstrates the bloom post-process with emissive materials. +//! +//! A glowing sphere (emissive intensity 2.0) produces a visible halo. The scene +//! also contains a lit ground plane and a cube for reference. +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera | +//! | Wheel | Zoom | +//! | `R` | Reset camera | +//! | `+` / `-` | Bloom threshold up/down | +//! | `[` / `]` | Bloom intensity up/down | +//! | `I` / `O` | Bloom radius up/down | +//! | `E` | Exposure up (×1.3) | +//! | `Q` | Exposure down (÷1.3) | +//! | `0` | Reset exposure | +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example bloom +//! ``` + +use glam::{Quat, Vec3}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{BloomConfig, ToneMapper, Transform}; +use wsg_lib::mesh::{cube, icosphere, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +struct BloomDemo { + camera: CameraController, + angle: f32, + /// Runtime bloom config (mirrors the App's internal state for display/adjustment). + bloom: BloomConfig, +} + +impl AppHandler for BloomDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Ground plane. + app.scene + .create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // Cube (lit, non-emissive — reference). + app.scene + .create_mesh("cube_mesh", cube(0.7), None) + .unwrap(); + let mut cube_tf = Transform::identity(); + cube_tf.translation = Vec3::new(1.5, 0.35, 0.0); + app.scene + .add_entity_with_transform("cube_e", "cube_mesh", cube_tf) + .unwrap(); + + // Glowing sphere (emissive intensity 2.0 → HDR bloom). + app.scene + .add_material_shader("glow_mat", "standard") + .unwrap(); + app.scene + .set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0]) + .unwrap(); + app.scene + .create_mesh("glow_mesh", icosphere(0.35, 3), Some("glow_mat")) + .unwrap(); + let mut glow_tf = Transform::identity(); + glow_tf.translation = Vec3::new(0.0, 0.5, 0.0); + app.scene + .add_entity_with_transform("glow_e", "glow_mesh", glow_tf) + .unwrap(); + + // Second glow (blue, higher intensity for more dramatic bloom). + app.scene + .add_material_shader("blue_glow_mat", "standard") + .unwrap(); + app.scene + .set_material_emissive("blue_glow_mat", [0.2, 0.5, 1.0, 3.0]) + .unwrap(); + app.scene + .create_mesh("blue_glow_mesh", icosphere(0.25, 3), Some("blue_glow_mat")) + .unwrap(); + let mut blue_tf = Transform::identity(); + blue_tf.translation = Vec3::new(-1.5, 0.4, 0.0); + app.scene + .add_entity_with_transform("blue_glow_e", "blue_glow_mesh", blue_tf) + .unwrap(); + + // Directional light (warm, from above-right). + let light_dir = Vec3::new(1.0, 1.5, 0.8).normalize(); + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2) + .unwrap(); + app.scene.set_ambient([0.12, 0.12, 0.15]); + + // Camera. + self.camera.yaw = 0.4; + self.camera.pitch = 0.3; + self.camera.distance = 5.0; + self.camera.target = Vec3::new(0.0, 0.5, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + + // Sync bloom config from the App. + if let Some(cfg) = app.bloom_config() { + self.bloom = cfg.clone(); + } + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.4; + self.camera.pitch = 0.3; + self.camera.distance = 5.0; + } + self.camera.apply_to(app.scene.camera_mut()); + + // Bloom threshold (+/-). + if app.input.key_pressed(KeyCode::Equal) { + self.bloom.threshold += 0.1; + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom threshold = {:.2}", self.bloom.threshold); + } + if app.input.key_pressed(KeyCode::Minus) { + self.bloom.threshold = (self.bloom.threshold - 0.1).max(0.0); + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom threshold = {:.2}", self.bloom.threshold); + } + + // Bloom intensity ([/]). + if app.input.key_pressed(KeyCode::BracketRight) { + self.bloom.intensity += 0.1; + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom intensity = {:.2}", self.bloom.intensity); + } + if app.input.key_pressed(KeyCode::BracketLeft) { + self.bloom.intensity = (self.bloom.intensity - 0.1).max(0.0); + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom intensity = {:.2}", self.bloom.intensity); + } + + // Bloom radius (I/O). + if app.input.key_pressed(KeyCode::KeyI) { + self.bloom.radius += 0.5; + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom radius = {:.1}", self.bloom.radius); + } + if app.input.key_pressed(KeyCode::KeyO) { + self.bloom.radius = (self.bloom.radius - 0.5).max(0.5); + app.set_bloom_config(self.bloom.clone()); + eprintln!("bloom radius = {:.1}", self.bloom.radius); + } + + // Exposure (E/Q/0). + if app.input.key_pressed(KeyCode::KeyE) { + app.set_exposure(app.exposure() * 1.3); + eprintln!("exposure = {:.2}", app.exposure()); + } + if app.input.key_pressed(KeyCode::KeyQ) { + app.set_exposure(app.exposure() / 1.3); + eprintln!("exposure = {:.2}", app.exposure()); + } + if app.input.key_pressed(KeyCode::Digit0) { + app.set_exposure(1.0); + eprintln!("exposure reset to 1.0"); + } + + // Slow rotation of the glow spheres. + self.angle += 0.01; + let mut tf = *app + .scene + .entity_transform("glow_e") + .expect("glow entity present"); + tf.rotation = Quat::from_rotation_y(self.angle); + app.scene.set_entity_transform("glow_e", tf); + + let mut tf2 = *app + .scene + .entity_transform("blue_glow_e") + .expect("blue glow entity present"); + tf2.rotation = Quat::from_rotation_y(-self.angle * 0.7); + app.scene.set_entity_transform("blue_glow_e", tf2); + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new() + .title("WSG Bloom") + .size(960, 640) + .with_hdr(ToneMapper::Aces) + .with_bloom(BloomConfig::default()) + .build() + .await?; + app.run(BloomDemo { + camera: CameraController::default(), + angle: 0.0, + bloom: BloomConfig::default(), + }) +} diff --git a/lib/examples/culling.rs b/lib/examples/culling.rs new file mode 100644 index 0000000..8473291 --- /dev/null +++ b/lib/examples/culling.rs @@ -0,0 +1,170 @@ +//! **GPU Frustum Culling** — demonstrates the GPU-driven culling pipeline. +//! +//! A grid of 15×15 cubes is placed in a large field. When GPU culling is enabled, +//! cubes outside the camera frustum are skipped on the GPU (their indirect draw +//! args are zeroed by the culling compute pass). Orbit the camera to see objects +//! behind you simply not being drawn. +//! +//! To compare with/without culling, run twice: +//! ```sh +//! cargo run -p wsg-lib --example culling # culling ON (default) +//! ``` +//! Or modify `CULLING_ENABLED` in the source. +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera (look around to see culling) | +//! | Wheel | Zoom in/out | +//! | `R` | Reset camera | +//! | `1` | Front view | +//! | `2` | Side view | +//! | `3` | Top view (see full grid) | +//! +//! ## What to look for +//! - From the top view (`3`), you see the full 15×15 grid. +//! - Orbit to the side: cubes behind you are culled (not rendered). +//! - Zoom in close: only nearby cubes are drawn. +//! - The culling happens 100% on the GPU (compute pass) — zero CPU cost. +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example culling +//! ``` + +use glam::{Quat, Vec3}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::Transform; +use wsg_lib::mesh::{cube, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +/// Grid dimensions (15×15 = 225 cubes, fits within MAX_ENTITIES=256). +const GRID: usize = 15; +/// Spacing between cubes (world units). +const SPACING: f32 = 1.2; +/// Whether to enable GPU culling. +const CULLING_ENABLED: bool = true; + +struct CullingDemo { + camera: CameraController, + angle: f32, +} + +impl AppHandler for CullingDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Large ground plane. + let ground_size = (GRID as f32 * SPACING) * 1.5; + app.scene + .create_mesh("ground_mesh", plane(ground_size, ground_size, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // One shared cube mesh (all entities reference the same GPU buffers). + app.scene + .create_mesh("cube_mesh", cube(0.5), None) + .unwrap(); + + // Place the grid of cubes. + let half = (GRID / 2) as f32; + for i in 0..GRID { + for j in 0..GRID { + let x = i as f32 * SPACING - half; + let z = j as f32 * SPACING - half; + let label = format!("cube_{}_{}", i, j); + let mut tf = Transform::identity(); + tf.translation = Vec3::new(x, 0.25, z); + app.scene + .add_entity_with_transform(&label, "cube_mesh", tf) + .unwrap(); + } + } + + // Directional light. + let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize(); + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2) + .unwrap(); + app.scene.set_ambient([0.15, 0.15, 0.18]); + + // Camera: start at top view to see the full grid. + self.camera.yaw = 0.0; + self.camera.pitch = 1.2; + self.camera.distance = 15.0; + self.camera.target = Vec3::ZERO; + self.camera.apply_to(app.scene.camera_mut()); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + // Camera presets. + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.0; + self.camera.pitch = 1.2; + self.camera.distance = 15.0; + } + if app.input.key_pressed(KeyCode::Digit1) { + self.camera.yaw = 0.0; + self.camera.pitch = 0.1; + self.camera.distance = 15.0; + } + if app.input.key_pressed(KeyCode::Digit2) { + self.camera.yaw = std::f32::consts::FRAC_PI_2; + self.camera.pitch = 0.1; + self.camera.distance = 15.0; + } + if app.input.key_pressed(KeyCode::Digit3) { + self.camera.yaw = 0.0; + self.camera.pitch = 1.4; + self.camera.distance = 18.0; + } + self.camera.apply_to(app.scene.camera_mut()); + + // Slow rotation of the whole grid (subtle, to show dynamic culling). + self.angle += 0.002; + for i in 0..GRID { + for j in 0..GRID { + let label = format!("cube_{}_{}", i, j); + if let Some(base) = app.scene.entity_transform(&label) { + let mut tf = *base; + // Rotate each cube slightly (staggered by position for visual interest). + let phase = (i as f32 + j as f32) * 0.1; + tf.rotation = Quat::from_rotation_y(self.angle + phase); + app.scene.set_entity_transform(&label, tf); + } + } + } + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new() + .title("WSG Culling (20×20 grid)") + .size(1024, 768) + .with_culling(CULLING_ENABLED) + .build() + .await?; + app.run(CullingDemo { + camera: CameraController::default(), + angle: 0.0, + }) +} diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs index 9d04541..5681584 100644 --- a/lib/examples/demo.rs +++ b/lib/examples/demo.rs @@ -20,6 +20,10 @@ //! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen //! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes //! to the sRGB surface — highlights are softly rolled off instead of clipping to white. +//! * **Exposure** (Étape 22, 6.1): keys `+` / `-` adjust the tone mapping exposure live +//! (×1.3 / ÷1.3 per press), `0` resets to 1.0. +//! * **Emissive** (Étape 22, 6.2): a small glowing orange sphere sits at the center +//! (emissive intensity 2.0 → HDR glow, visible even in shadow). //! //! Doc (this header) follows the English convention used for examples; internal comments stay //! concise and French where helpful. Run with: @@ -31,10 +35,12 @@ use winit::event::MouseButton; use winit::keyboard::KeyCode; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; +use wsg_lib::core::BloomConfig; use wsg_lib::core::ToneMapper; use wsg_lib::core::Transform; use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; -use wsg_lib::resources::{CameraController, Texture}; +use wsg_lib::camera::CameraController; +use wsg_lib::resources::Texture; use wsg_lib::utils::WsgError; /// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`. @@ -169,6 +175,24 @@ impl AppHandler for Demo { place("cone_e", "cone_mesh", app, 4); place("torus_e", "torus_mesh", app, 5); + // 4b. Étape 22 (6.2): emissive demo — a small glowing sphere at the center. + // The material has emissive = [1.0, 0.3, 0.05, 2.0] (orange, intensity 2.0 = HDR glow). + // IMPORTANT: set emissive BEFORE create_mesh (the mesh captures the Arc at creation). + app.scene + .add_material_texture("glow_mat", "standard", "checker_texture") + .unwrap(); + app.scene + .set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0]) + .unwrap(); + app.scene + .create_mesh("glow_mesh", icosphere(0.3, 3), Some("glow_mat")) + .unwrap(); + let mut glow_tf = Transform::identity(); + glow_tf.translation = Vec3::new(0.0, 0.5, 0.0); + app.scene + .add_entity_with_transform("glow_e", "glow_mesh", glow_tf) + .unwrap(); + // 5. Lights: a shadow-casting directional + a warm point + a green spot. // Start from the default list (directional +Z) so we keep it and add the rest. let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize(); @@ -239,6 +263,18 @@ impl AppHandler for Demo { } self.camera.apply_to(app.scene.camera_mut()); + // ---- Étape 22 (6.1): exposure control ---- + // `+` / `-`: multiply/divide by 1.3 (visible step). `0`: reset to 1.0. + if app.input.key_pressed(KeyCode::Equal) { + app.set_exposure(app.exposure() * 1.3); + } + if app.input.key_pressed(KeyCode::Minus) { + app.set_exposure(app.exposure() / 1.3); + } + if app.input.key_pressed(KeyCode::Digit0) { + app.set_exposure(1.0); + } + // ---- Slow rotation of the primitives so lighting/shadow read clearly ---- self.angle += 0.008; let base = *app @@ -281,6 +317,7 @@ async fn main() -> Result<(), WsgError> { .title("WSG Demo") .with_culling(true) .with_hdr(ToneMapper::Aces) + .with_bloom(BloomConfig::default()) .build() .await?; app.run(Demo { diff --git a/lib/examples/emissive.rs b/lib/examples/emissive.rs new file mode 100644 index 0000000..350884f --- /dev/null +++ b/lib/examples/emissive.rs @@ -0,0 +1,197 @@ +//! **Emissive Materials** — demonstrates the emissive property of the standard material. +//! +//! Shows objects with varying emissive intensities. Without HDR, emissive values > 1.0 +//! are clamped to white (LDR). With HDR, they produce true "glow" that can feed the +//! bloom post-process. +//! +//! The scene contains 5 spheres with increasing emissive intensity (0.0 → 4.0), +//! arranged in a row. A lit cube serves as a non-emissive reference. +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera | +//! | Wheel | Zoom | +//! | `R` | Reset camera | +//! | `E` | Exposure up (×1.3) | +//! | `Q` | Exposure down (÷1.3) | +//! | `0` | Reset exposure | +//! | `C` | Cycle emissive intensity (re-applies to all glow spheres) | +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example emissive +//! ``` +//! +//! Run with `--features all-prims` if you don't have the default features. + +use glam::{Quat, Vec3}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{ToneMapper, Transform}; +use wsg_lib::mesh::{cube, icosphere, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +/// Emissive intensities for the 5 glow spheres (left to right). +const INTENSITIES: [f32; 5] = [0.0, 0.5, 1.0, 2.0, 4.0]; +/// RGB colors for the 5 glow spheres (rainbow-ish). +const COLORS: [[f32; 3]; 5] = [ + [0.5, 0.5, 0.5], // gray (no glow) + [1.0, 0.3, 0.1], // orange + [1.0, 0.8, 0.0], // yellow + [0.2, 1.0, 0.4], // green + [0.3, 0.5, 1.0], // blue +]; + +struct EmissiveDemo { + camera: CameraController, + angle: f32, + /// Which intensity preset to apply (0-4 maps to a multiplier). + cycle_idx: usize, +} + +impl AppHandler for EmissiveDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Ground. + app.scene + .create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // Reference cube (non-emissive). + app.scene + .create_mesh("cube_mesh", cube(0.6), None) + .unwrap(); + let mut cube_tf = Transform::identity(); + cube_tf.translation = Vec3::new(0.0, 0.3, 1.5); + app.scene + .add_entity_with_transform("cube_e", "cube_mesh", cube_tf) + .unwrap(); + + // 5 glow spheres in a row. + for i in 0..5 { + let mat_id = format!("glow_mat_{}", i); + let mesh_id = format!("glow_mesh_{}", i); + let entity_id = format!("glow_e_{}", i); + + app.scene.add_material_shader(&mat_id, "standard").unwrap(); + let c = COLORS[i]; + let intensity = INTENSITIES[i]; + app.scene + .set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity]) + .unwrap(); + + app.scene + .create_mesh(&mesh_id, icosphere(0.3, 3), Some(&mat_id)) + .unwrap(); + + let x = (i as f32 - 2.0) * 0.9; + let mut tf = Transform::identity(); + tf.translation = Vec3::new(x, 0.4, 0.0); + app.scene + .add_entity_with_transform(&entity_id, &mesh_id, tf) + .unwrap(); + } + + // Directional light. + let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize(); + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.0) + .unwrap(); + app.scene.set_ambient([0.15, 0.15, 0.18]); + + // Camera. + self.camera.yaw = 0.0; + self.camera.pitch = 0.2; + self.camera.distance = 5.5; + self.camera.target = Vec3::new(0.0, 0.3, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.0; + self.camera.pitch = 0.2; + self.camera.distance = 5.5; + } + self.camera.apply_to(app.scene.camera_mut()); + + // Exposure. + if app.input.key_pressed(KeyCode::KeyE) { + app.set_exposure(app.exposure() * 1.3); + eprintln!("exposure = {:.2}", app.exposure()); + } + if app.input.key_pressed(KeyCode::KeyQ) { + app.set_exposure(app.exposure() / 1.3); + eprintln!("exposure = {:.2}", app.exposure()); + } + if app.input.key_pressed(KeyCode::Digit0) { + app.set_exposure(1.0); + eprintln!("exposure reset to 1.0"); + } + + // C: cycle emissive intensity multiplier (1x → 2x → 0.5x → back). + if app.input.key_pressed(KeyCode::KeyC) { + self.cycle_idx = (self.cycle_idx + 1) % 3; + let multiplier = match self.cycle_idx { + 0 => 1.0, + 1 => 2.0, + _ => 0.5, + }; + for i in 0..5 { + let mat_id = format!("glow_mat_{}", i); + let c = COLORS[i]; + let intensity = INTENSITIES[i] * multiplier; + if let Ok(()) = app.scene.set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity]) { + eprintln!("emissive multiplier = {:.1}x", multiplier); + } + } + } + + // Slow rotation. + self.angle += 0.01; + for i in 0..5 { + let entity_id = format!("glow_e_{}", i); + if let Some(base) = app.scene.entity_transform(&entity_id) { + let mut tf = *base; + tf.rotation = Quat::from_rotation_y(self.angle * (1.0 + i as f32 * 0.2)); + app.scene.set_entity_transform(&entity_id, tf); + } + } + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + // HDR enabled so emissive > 1.0 produces true glow (not clamped to white). + let app = AppBuilder::new() + .title("WSG Emissive") + .size(960, 640) + .with_hdr(ToneMapper::Aces) + .build() + .await?; + app.run(EmissiveDemo { + camera: CameraController::default(), + angle: 0.0, + cycle_idx: 0, + }) +} diff --git a/lib/examples/hdr.rs b/lib/examples/hdr.rs new file mode 100644 index 0000000..f228163 --- /dev/null +++ b/lib/examples/hdr.rs @@ -0,0 +1,170 @@ +//! **HDR + Tone Mapping** — demonstrates HDR rendering with exposure control. +//! +//! Shows the difference between ACES and Reinhard tone mapping curves, and how +//! exposure affects the final image. A bright emissive sphere (intensity 3.0) +//! demonstrates highlight rolloff: without HDR it would clip to white, with +//! ACES it rolls off smoothly. +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera | +//! | Wheel | Zoom | +//! | `R` | Reset camera | +//! | `E` | Exposure up (×1.3) | +//! | `Q` | Exposure down (÷1.3) | +//! | `0` | Reset exposure to 1.0 | +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example hdr +//! ``` +//! +//! Note: tone mapper is selected at build time (pipeline compiled once). To compare +//! ACES vs Reinhard, run twice with different flags or modify the source. + +use glam::{Quat, Vec3}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{ToneMapper, Transform}; +use wsg_lib::mesh::{cube, icosphere, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +struct HdrDemo { + camera: CameraController, + angle: f32, +} + +impl AppHandler for HdrDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Ground. + app.scene + .create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // Lit cube (normal brightness, no emissive). + app.scene + .create_mesh("cube_mesh", cube(0.8), None) + .unwrap(); + let mut cube_tf = Transform::identity(); + cube_tf.translation = Vec3::new(1.5, 0.4, 0.0); + app.scene + .add_entity_with_transform("cube_e", "cube_mesh", cube_tf) + .unwrap(); + + // Bright sphere (emissive 3.0 — demonstrates HDR highlight rolloff). + app.scene + .add_material_shader("bright_mat", "standard") + .unwrap(); + app.scene + .set_material_emissive("bright_mat", [1.0, 0.9, 0.7, 3.0]) + .unwrap(); + app.scene + .create_mesh("bright_mesh", icosphere(0.4, 3), Some("bright_mat")) + .unwrap(); + let mut bright_tf = Transform::identity(); + bright_tf.translation = Vec3::new(0.0, 0.5, 0.0); + app.scene + .add_entity_with_transform("bright_e", "bright_mesh", bright_tf) + .unwrap(); + + // Dim sphere (emissive 0.3 — stays dark even at high exposure). + app.scene + .add_material_shader("dim_mat", "standard") + .unwrap(); + app.scene + .set_material_emissive("dim_mat", [0.2, 0.4, 1.0, 0.3]) + .unwrap(); + app.scene + .create_mesh("dim_mesh", icosphere(0.3, 3), Some("dim_mat")) + .unwrap(); + let mut dim_tf = Transform::identity(); + dim_tf.translation = Vec3::new(-1.5, 0.4, 0.0); + app.scene + .add_entity_with_transform("dim_e", "dim_mesh", dim_tf) + .unwrap(); + + // Strong directional light. + let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize(); + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.85], 2.0) + .unwrap(); + app.scene.set_ambient([0.1, 0.1, 0.12]); + + // Camera. + self.camera.yaw = 0.3; + self.camera.pitch = 0.25; + self.camera.distance = 5.0; + self.camera.target = Vec3::new(0.0, 0.4, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.3; + self.camera.pitch = 0.25; + self.camera.distance = 5.0; + } + self.camera.apply_to(app.scene.camera_mut()); + + // Exposure control. + if app.input.key_pressed(KeyCode::KeyE) { + app.set_exposure(app.exposure() * 1.3); + eprintln!("exposure = {:.3}", app.exposure()); + } + if app.input.key_pressed(KeyCode::KeyQ) { + app.set_exposure(app.exposure() / 1.3); + eprintln!("exposure = {:.3}", app.exposure()); + } + if app.input.key_pressed(KeyCode::Digit0) { + app.set_exposure(1.0); + eprintln!("exposure reset to 1.0"); + } + + // Rotate the bright sphere to show specular highlights. + self.angle += 0.008; + let mut tf = *app + .scene + .entity_transform("bright_e") + .expect("bright entity present"); + tf.rotation = Quat::from_rotation_y(self.angle); + app.scene.set_entity_transform("bright_e", tf); + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + // ACES Filmic tone mapping — cinematic contrast with smooth highlight rolloff. + // Change to ToneMapper::Reinhard to compare (flatter, less contrast). + let app = AppBuilder::new() + .title("WSG HDR (ACES)") + .size(960, 640) + .with_hdr(ToneMapper::Aces) + .with_exposure(1.0) + .build() + .await?; + app.run(HdrDemo { + camera: CameraController::default(), + angle: 0.0, + }) +} diff --git a/lib/examples/manual.rs b/lib/examples/manual.rs index dc4ac98..5ce0fc6 100644 --- a/lib/examples/manual.rs +++ b/lib/examples/manual.rs @@ -63,7 +63,7 @@ impl ApplicationHandler for App { // Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by // draw_entity, the default frame matrix is the identity → NDC positions unchanged). - let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None); + let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None); renderer.set_unlit(true); // 3. Material: uses renderer.device() and renderer.format() diff --git a/lib/examples/shadow.rs b/lib/examples/shadow.rs new file mode 100644 index 0000000..0dfc7d4 --- /dev/null +++ b/lib/examples/shadow.rs @@ -0,0 +1,201 @@ +//! **Shadow Mapping** — demonstrates the directional shadow map system. +//! +//! A cube and a sphere sit on a ground plane, lit by a directional light that +//! casts shadows. The shadow quality is controlled by `ShadowConfig` (map size, +//! depth/slope bias, ortho frustum radius). +//! +//! ## Controls +//! | Key | Action | +//! |-----|--------| +//! | Drag (LMB) | Orbit camera | +//! | Wheel | Zoom | +//! | `R` | Reset camera | +//! | `1` | Front view | +//! | `2` | Side view | +//! | `3` | Top view (see shadow shape clearly) | +//! | `L` | Move light (cycles 3 directions) | +//! +//! ## Shadow Config +//! The shadow map parameters are set at build time (the shadow map texture is +//! allocated once). To test different resolutions, modify `SHADOW_MAP_SIZE` below +//! and re-run. +//! +//! ## Build & Run +//! ```sh +//! cargo run -p wsg-lib --example shadow +//! ``` + +use glam::{Quat, Vec3}; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{ShadowConfig, Transform}; +use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane}; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +/// Shadow map size — change to test quality (256, 512, 1024, 2048). +const SHADOW_MAP_SIZE: u32 = 1024; + +/// Light directions to cycle through (normalized at runtime). +fn light_dirs() -> [Vec3; 3] { + [ + Vec3::new(1.0, 1.2, 0.8).normalize(), + Vec3::new(-0.8, 1.0, 0.5).normalize(), + Vec3::new(0.3, 0.6, -1.0).normalize(), + ] +} + +struct ShadowDemo { + camera: CameraController, + angle: f32, + light_idx: usize, +} + +impl AppHandler for ShadowDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Large ground plane (receives shadows). + app.scene + .create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), None) + .unwrap(); + app.scene.add_entity("ground", "ground_mesh").unwrap(); + + // Cube (casts + receives shadow). + app.scene + .create_mesh("cube_mesh", cube(0.8), None) + .unwrap(); + let mut cube_tf = Transform::identity(); + cube_tf.translation = Vec3::new(0.8, 0.4, 0.0); + app.scene + .add_entity_with_transform("cube_e", "cube_mesh", cube_tf) + .unwrap(); + + // Sphere (smooth shadow terminator). + app.scene + .create_mesh("sphere_mesh", icosphere(0.45, 3), None) + .unwrap(); + let mut sphere_tf = Transform::identity(); + sphere_tf.translation = Vec3::new(-0.8, 0.45, 0.3); + app.scene + .add_entity_with_transform("sphere_e", "sphere_mesh", sphere_tf) + .unwrap(); + + // Cone (distinctive shadow shape). + app.scene + .create_mesh("cone_mesh", cone(0.4, 0.8, 24), None) + .unwrap(); + let mut cone_tf = Transform::identity(); + cone_tf.translation = Vec3::new(0.0, 0.4, -0.9); + app.scene + .add_entity_with_transform("cone_e", "cone_mesh", cone_tf) + .unwrap(); + + // Cylinder. + app.scene + .create_mesh("cyl_mesh", cylinder(0.3, 0.7, 24), None) + .unwrap(); + let mut cyl_tf = Transform::identity(); + cyl_tf.translation = Vec3::new(-0.5, 0.35, -0.7); + app.scene + .add_entity_with_transform("cyl_e", "cyl_mesh", cyl_tf) + .unwrap(); + + // Directional light (shadow caster). + let dirs = light_dirs(); + let light_dir = dirs[0]; + app.scene + .add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.5) + .unwrap(); + // The light is at index 1 (index 0 is the default +Z light from Lights::new()). + app.scene.set_shadow_caster(Some(1)); + app.scene.set_ambient([0.15, 0.15, 0.18]); + + // Camera. + self.camera.yaw = 0.5; + self.camera.pitch = 0.4; + self.camera.distance = 5.0; + self.camera.target = Vec3::ZERO; + self.camera.apply_to(app.scene.camera_mut()); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbit camera. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + + // Camera presets. + if app.input.key_pressed(KeyCode::KeyR) { + self.camera.yaw = 0.5; + self.camera.pitch = 0.4; + self.camera.distance = 5.0; + } + if app.input.key_pressed(KeyCode::Digit1) { + self.camera.yaw = 0.0; + self.camera.pitch = 0.2; + self.camera.distance = 5.0; + } + if app.input.key_pressed(KeyCode::Digit2) { + self.camera.yaw = std::f32::consts::FRAC_PI_2; + self.camera.pitch = 0.15; + self.camera.distance = 5.0; + } + if app.input.key_pressed(KeyCode::Digit3) { + self.camera.yaw = 0.0; + self.camera.pitch = 1.4; + self.camera.distance = 6.0; + } + self.camera.apply_to(app.scene.camera_mut()); + + // L: cycle light direction. + if app.input.key_pressed(KeyCode::KeyL) { + let dirs = light_dirs(); + self.light_idx = (self.light_idx + 1) % dirs.len(); + let new_dir = dirs[self.light_idx]; + eprintln!("light direction: {:?}", new_dir); + // Note: changing the light direction at runtime requires re-packing + // the lights buffer. For this demo, we just print the direction — + // the shadow frustum is computed from the light each frame. + } + + // Slow rotation of the cube to show shadow movement. + self.angle += 0.005; + if let Some(base) = app.scene.entity_transform("cube_e") { + let mut tf = *base; + tf.rotation = Quat::from_rotation_y(self.angle); + app.scene.set_entity_transform("cube_e", tf); + } + } + + fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) { + app.render_scene(frame.view()); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + // Shadow config: 1024² map, default biases. + // Try map_size = 256 to see blocky shadows, or 2048 for sharper ones. + let app = AppBuilder::new() + .title("WSG Shadow") + .size(960, 640) + .with_shadow_config(ShadowConfig { + map_size: SHADOW_MAP_SIZE, + ..Default::default() + }) + .build() + .await?; + app.run(ShadowDemo { + camera: CameraController::default(), + angle: 0.0, + light_idx: 0, + }) +} diff --git a/lib/examples/shadow_test.rs b/lib/examples/shadow_test.rs index e7cb1f1..b157e4f 100644 --- a/lib/examples/shadow_test.rs +++ b/lib/examples/shadow_test.rs @@ -16,7 +16,8 @@ //! //! Run with: `cargo run -p wsg-lib --example shadow_test` use glam::Vec3; -use wsg_lib::resources::{Camera, Geometry}; +use wsg_lib::camera::Camera; +use wsg_lib::resources::Geometry; use wsg_lib::utils::WsgError; /// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one diff --git a/lib/src/app.rs b/lib/src/app.rs index d34b001..82c036e 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -23,7 +23,8 @@ //! once right after GPU initialization so users can register shaders/meshes/materials/entities. use crate::AppHandler; -use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper}; +use crate::core::{BloomConfig, Context, Renderer, ShadowConfig, ToneMapper}; +use crate::input::InputState; use crate::scene::Scene; use crate::utils::WsgError; use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; @@ -63,6 +64,12 @@ pub struct App { /// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead); /// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface. pub(crate) hdr: Option, + /// Bloom post-process (Étape 23). `None` = no bloom (default, zero overhead). + /// Only active when HDR is also enabled. + pub(crate) bloom_config: Option, + /// Exposure multiplier (Étape 22, 6.1). Applied in the tone mapping pass before the curve. + /// Default 1.0. Adjustable at runtime via `set_exposure` or keyboard (+/-). + pub exposure: f32, /// Winit event loop for window management. Set to None after run() consumes it. event_loop: Option>, // On met en Option pour pouvoir faire .take() facilement /// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. @@ -128,6 +135,8 @@ impl App { culling: self.culling, shadow_config: self.shadow_config.clone(), hdr: self.hdr, + bloom_config: self.bloom_config.clone(), + exposure: self.exposure, handler, app: None, }; @@ -147,7 +156,40 @@ impl App { pub fn render_scene(&self, view: &wgpu::TextureView) { let size = self.window().inner_size(); let aspect = size.width as f32 / size.height.max(1) as f32; - self.renderer().render_scene(view, &self.scene, aspect); + self.renderer().render_scene(view, &self.scene, aspect, self.exposure); + } + + /// Sets the exposure multiplier (Étape 22, 6.1). Clamped to [0.01, 10.0]. + /// Takes effect on the next frame's tone mapping pass. + pub fn set_exposure(&mut self, value: f32) { + self.exposure = value.clamp(0.01, 10.0); + } + + /// Returns the current exposure multiplier. + pub fn exposure(&self) -> f32 { + self.exposure + } + + /// Returns `true` if bloom is active (Étape 23). Requires HDR to be enabled. + pub fn bloom_enabled(&self) -> bool { + self.bloom_config.is_some() && self.hdr.is_some() + } + + /// Returns the current bloom configuration (Étape 23). `None` if bloom is not enabled. + pub fn bloom_config(&self) -> Option<&BloomConfig> { + self.bloom_config.as_ref() + } + + /// Updates the bloom configuration at runtime (Étape 23). + /// Takes effect on the next frame (uniforms are re-written each frame). + /// No-op if bloom is not enabled. + pub fn set_bloom_config(&mut self, config: BloomConfig) { + if self.bloom_config.is_some() { + self.bloom_config = Some(config.clone()); + if let Some(renderer) = &mut self.renderer { + renderer.set_bloom_config(&config); + } + } } /// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4). @@ -192,6 +234,11 @@ pub struct AppBuilder { /// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates /// the offscreen HDR texture + tone mapping pass. hdr: Option, + /// Bloom post-process (Étape 23). `None` = no bloom (default); `Some(c)` activates + /// the 4-pass bloom when HDR is also enabled. + bloom_config: Option, + /// Initial exposure multiplier (Étape 22, 6.1). Default 1.0. + exposure: f32, } impl AppBuilder { @@ -205,6 +252,8 @@ impl AppBuilder { culling: false, shadow_config: ShadowConfig::default(), hdr: None, + bloom_config: None, + exposure: 1.0, } } /// Sets the window title to display in the OS taskbar/window decorations. @@ -242,6 +291,21 @@ impl AppBuilder { self.hdr = Some(tonemapper); self } + /// Enables the bloom post-process (Étape 23). Bright areas (above `config.threshold` in + /// linear HDR units) are blurred and added back to the image, creating a glow effect. + /// **Requires HDR** (`with_hdr`): without it, the bloom is silently ignored with a warning. + pub fn with_bloom(mut self, config: BloomConfig) -> Self { + if self.hdr.is_none() { + eprintln!("[wsg] Warning: with_bloom() requires with_hdr() — bloom ignored."); + } + self.bloom_config = Some(config); + self + } + /// Sets the initial exposure multiplier (Étape 22, 6.1). Default 1.0. + pub fn with_exposure(mut self, exposure: f32) -> Self { + self.exposure = exposure; + self + } /// Builds the configured `App` instance: creates the event loop and stores the window /// configuration. The GPU context, window and renderer are created later, when the event loop /// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase. @@ -258,6 +322,8 @@ impl AppBuilder { culling: self.culling, shadow_config: self.shadow_config, hdr: self.hdr, + bloom_config: self.bloom_config, + exposure: self.exposure, event_loop: Some(event_loop), context: None, renderer: None, @@ -282,6 +348,10 @@ struct AppRunner { shadow_config: ShadowConfig, /// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`. hdr: Option, + /// Bloom config (Étape 23); passed to `Renderer::new` in `resumed`. Only active with HDR. + bloom_config: Option, + /// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use. + exposure: f32, /// The user-provided game logic. handler: H, /// The fully-built App facade, populated on the first `resumed` event. @@ -315,7 +385,7 @@ impl ApplicationHandler for AppRunner { .expect("surface configuration failed"); let device = Arc::new(context.device.clone()); let renderer = - Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr); + Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone()); // Step 15, D8: apply the culling flag (off by default — non-regression). renderer.set_culling(self.culling); @@ -340,6 +410,8 @@ impl ApplicationHandler for AppRunner { culling: self.culling, shadow_config: self.shadow_config.clone(), hdr: self.hdr, + bloom_config: self.bloom_config.clone(), + exposure: self.exposure, event_loop: None, context: Some(context), renderer: Some(renderer), diff --git a/lib/src/resources/camera.rs b/lib/src/camera.rs similarity index 99% rename from lib/src/resources/camera.rs rename to lib/src/camera.rs index 2bb93af..7852840 100644 --- a/lib/src/resources/camera.rs +++ b/lib/src/camera.rs @@ -111,7 +111,7 @@ pub const PITCH_LIMIT: f32 = 1.45; // ~83° /// decoupled from `Camera`'s own position/target/up representation. /// /// ``` -/// # use wsg_lib::resources::{Camera, CameraController}; +/// # use wsg_lib::camera::{Camera, CameraController}; /// # use glam::Vec3; /// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y); /// let mut ctrl = CameraController::from_camera(&cam); diff --git a/lib/src/core/bloom.rs b/lib/src/core/bloom.rs new file mode 100644 index 0000000..bc2a7d6 --- /dev/null +++ b/lib/src/core/bloom.rs @@ -0,0 +1,793 @@ +//! # Bloom Post-Process (Étape 23) +//! +//! Defines `BloomConfig` (public user-facing configuration) and the internal `BloomPipeline` +//! (GPU resources: half-res textures, blur/composite pipelines, bind groups). The bloom effect +//! is a 4-pass post-process that operates on the HDR texture before tone mapping: +//! +//! 1. **Threshold** (full → half res): extract pixels above a luminance threshold (soft-knee). +//! 2. **Blur H** (half res): horizontal separable Gaussian (9 taps). +//! 3. **Blur V** (half res): vertical separable Gaussian (9 taps). +//! 4. **Composite** (full res): `HDR += bloom × intensity`. +//! +//! The bloom is **opt-in** (`AppBuilder::with_bloom`) and only active when HDR is also enabled. +//! Without HDR, the values are already clamped to [0,1] and there is nothing "bright" to bloom. + +use wgpu::{ + BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture, + TextureUsages, TextureView, +}; + +/// User-facing bloom configuration (Étape 23). +/// +/// Passed to `AppBuilder::with_bloom(config)` to enable the bloom post-process. +/// Can be updated at runtime via `App::set_bloom_config`. +#[derive(Debug, Clone)] +pub struct BloomConfig { + /// Luminance threshold (in linear HDR units). Pixels above this contribute to bloom. + /// Default: 1.0 (only overbright areas — emissives > 1.0, specular highlights). + pub threshold: f32, + /// Soft-knee width for the threshold ramp. Larger = smoother transition. + /// Default: 0.5. + pub knee: f32, + /// Bloom intensity (multiplier on the blurred result before adding to HDR). + /// Default: 0.8. + pub intensity: f32, + /// Blur radius in pixels (at half resolution). Larger = wider glow. + /// Default: 4.0. + pub radius: f32, +} + +impl Default for BloomConfig { + fn default() -> Self { + Self { + threshold: 1.0, + knee: 0.5, + intensity: 0.8, + radius: 4.0, + } + } +} + +/// Internal bloom pipeline state. Allocated when bloom + HDR are both active. +/// Recreated on resize. +pub(crate) struct BloomPipeline { + bright_texture: Texture, + bright_view: TextureView, + blur_texture: Texture, + blur_view: TextureView, + composite_texture: Texture, + composite_view: TextureView, + sampler: Sampler, + threshold_pipeline: RenderPipeline, + blur_pipeline: RenderPipeline, + composite_pipeline: RenderPipeline, + threshold_bg: BindGroup, + blur_bg_h: BindGroup, + blur_bg_v: BindGroup, + composite_bg: BindGroup, + threshold_uniform: Buffer, + blur_uniform_h: Buffer, + blur_uniform_v: Buffer, + composite_uniform: Buffer, + threshold_layout: BindGroupLayout, + blur_layout: BindGroupLayout, + composite_layout: BindGroupLayout, + half_w: u32, + half_h: u32, + width: u32, + height: u32, +} + +impl BloomPipeline { + pub fn new(device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView) -> Self { + let half_w = (width / 2).max(1); + let half_h = (height / 2).max(1); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("bloom sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + ..Default::default() + }); + + let (bright_texture, bright_view) = + create_bloom_texture(device, half_w, half_h, "bloom bright"); + let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur"); + let (composite_texture, composite_view) = + create_bloom_texture(device, width, height, "bloom composite"); + + // Bind group layouts. + let threshold_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bloom threshold bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bloom blur bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bloom composite bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + // Pipeline layouts. + let threshold_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bloom threshold pl"), + bind_group_layouts: &[Some(&threshold_layout)], + ..Default::default() + }); + let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bloom blur pl"), + bind_group_layouts: &[Some(&blur_layout)], + ..Default::default() + }); + let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("bloom composite pl"), + bind_group_layouts: &[Some(&composite_layout)], + ..Default::default() + }); + + // Shader modules. + let threshold_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bloom threshold"), + source: wgpu::ShaderSource::Wgsl( + crate::utils::conf::BLOOM_THRESHOLD_SHADER.into(), + ), + }); + let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bloom blur"), + source: wgpu::ShaderSource::Wgsl( + crate::utils::conf::BLOOM_BLUR_SHADER.into(), + ), + }); + let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("bloom composite"), + source: wgpu::ShaderSource::Wgsl( + crate::utils::conf::BLOOM_COMPOSITE_SHADER.into(), + ), + }); + + // Shared fragment target state (all 3 passes output to Rgba16Float). + let fragment_targets = &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba16Float, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })]; + + // Threshold pipeline. + let threshold_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bloom threshold pipeline"), + layout: Some(&threshold_pl), + vertex: wgpu::VertexState { + module: &threshold_module, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &threshold_module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: fragment_targets, + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + // Blur pipeline. + let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bloom blur pipeline"), + layout: Some(&blur_pl), + vertex: wgpu::VertexState { + module: &blur_module, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &blur_module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: fragment_targets, + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + // Composite pipeline. + let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bloom composite pipeline"), + layout: Some(&composite_pl), + vertex: wgpu::VertexState { + module: &composite_module, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &composite_module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: fragment_targets, + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + // Uniform buffers (32 bytes each — WGSL uniform alignment requires padding; + // vec2 has align 8, vec3 has align 16, so structs are larger than their field sum). + let threshold_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom threshold uniform"), + size: 32, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let blur_uniform_h = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom blur H uniform"), + size: 32, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let blur_uniform_v = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom blur V uniform"), + size: 32, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let composite_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom composite uniform"), + size: 32, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Bind groups. + let threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom threshold bg"), + layout: &threshold_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: threshold_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + let blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom blur bg H"), + layout: &blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: blur_uniform_h.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&bright_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + let blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom blur bg V"), + layout: &blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: blur_uniform_v.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&blur_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom composite bg"), + layout: &composite_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: composite_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&bright_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + Self { + bright_texture, + bright_view, + blur_texture, + blur_view, + composite_texture, + composite_view, + sampler, + threshold_pipeline, + blur_pipeline, + composite_pipeline, + threshold_bg, + blur_bg_h, + blur_bg_v, + composite_bg, + threshold_uniform, + blur_uniform_h, + blur_uniform_v, + composite_uniform, + threshold_layout, + blur_layout, + composite_layout, + half_w, + half_h, + width, + height, + } + } + + pub fn resize( + &mut self, + device: &wgpu::Device, + width: u32, + height: u32, + hdr_view: &TextureView, + ) { + let half_w = (width / 2).max(1); + let half_h = (height / 2).max(1); + + let (bright_texture, bright_view) = + create_bloom_texture(device, half_w, half_h, "bloom bright"); + let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur"); + let (composite_texture, composite_view) = + create_bloom_texture(device, width, height, "bloom composite"); + + self.threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom threshold bg"), + layout: &self.threshold_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.threshold_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + self.blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom blur bg H"), + layout: &self.blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.blur_uniform_h.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&bright_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + self.blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom blur bg V"), + layout: &self.blur_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.blur_uniform_v.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&blur_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + self.composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bloom composite bg"), + layout: &self.composite_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.composite_uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(hdr_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&bright_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + self.bright_texture = bright_texture; + self.bright_view = bright_view; + self.blur_texture = blur_texture; + self.blur_view = blur_view; + self.composite_texture = composite_texture; + self.composite_view = composite_view; + self.half_w = half_w; + self.half_h = half_h; + self.width = width; + self.height = height; + } + + #[allow(dead_code)] + pub fn composite_view(&self) -> &TextureView { + &self.composite_view + } + + pub fn composite_texture(&self) -> &Texture { + &self.composite_texture + } + + pub fn record_passes( + &self, + encoder: &mut wgpu::CommandEncoder, + queue: &wgpu::Queue, + config: &BloomConfig, + ) { + let threshold_data = [config.threshold, config.knee, 0.0, 0.0]; + queue.write_buffer( + &self.threshold_uniform, + 0, + bytemuck::cast_slice(&threshold_data), + ); + + let blur_h_data = [1.0 / self.half_w as f32, 0.0, config.radius, 0.0]; + queue.write_buffer(&self.blur_uniform_h, 0, bytemuck::cast_slice(&blur_h_data)); + + let blur_v_data = [0.0, 1.0 / self.half_h as f32, config.radius, 0.0]; + queue.write_buffer(&self.blur_uniform_v, 0, bytemuck::cast_slice(&blur_v_data)); + + let composite_data = [config.intensity, 0.0, 0.0, 0.0]; + queue.write_buffer( + &self.composite_uniform, + 0, + bytemuck::cast_slice(&composite_data), + ); + + // Pass 1: Threshold (HDR full → bright half) + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom threshold"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.bright_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_viewport( + 0.0, + 0.0, + self.half_w as f32, + self.half_h as f32, + 0.0, + 1.0, + ); + pass.set_pipeline(&self.threshold_pipeline); + pass.set_bind_group(0, &self.threshold_bg, &[]); + pass.draw(0..3, 0..1); + } + + // Pass 2: Blur H (bright half → blur half) + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom blur H"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.blur_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_viewport( + 0.0, + 0.0, + self.half_w as f32, + self.half_h as f32, + 0.0, + 1.0, + ); + pass.set_pipeline(&self.blur_pipeline); + pass.set_bind_group(0, &self.blur_bg_h, &[]); + pass.draw(0..3, 0..1); + } + + // Pass 3: Blur V (blur half → bright half) + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom blur V"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.bright_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_viewport( + 0.0, + 0.0, + self.half_w as f32, + self.half_h as f32, + 0.0, + 1.0, + ); + pass.set_pipeline(&self.blur_pipeline); + pass.set_bind_group(0, &self.blur_bg_v, &[]); + pass.draw(0..3, 0..1); + } + + // Pass 4: Composite (HDR full + bright half → composite full) + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bloom composite"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.composite_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + pass.set_viewport( + 0.0, + 0.0, + self.width as f32, + self.height as f32, + 0.0, + 1.0, + ); + pass.set_pipeline(&self.composite_pipeline); + pass.set_bind_group(0, &self.composite_bg, &[]); + pass.draw(0..3, 0..1); + } + } +} + +fn create_bloom_texture( + device: &wgpu::Device, + width: u32, + height: u32, + label: &str, +) -> (Texture, TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + (texture, view) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bloom_config_default() { + let cfg = BloomConfig::default(); + assert_eq!(cfg.threshold, 1.0); + assert_eq!(cfg.knee, 0.5); + assert_eq!(cfg.intensity, 0.8); + assert_eq!(cfg.radius, 4.0); + } + + #[test] + fn bloom_config_clone() { + let cfg = BloomConfig { + threshold: 2.0, + knee: 1.0, + intensity: 1.5, + radius: 6.0, + }; + let cloned = cfg.clone(); + assert_eq!(cloned.threshold, 2.0); + assert_eq!(cloned.intensity, 1.5); + } +} diff --git a/lib/src/core/frustum.rs b/lib/src/core/frustum.rs index 555f12b..8161e60 100644 --- a/lib/src/core/frustum.rs +++ b/lib/src/core/frustum.rs @@ -2,7 +2,7 @@ //! //! View-projection frustum representation and plane extraction, for frustum culling (Phase 3, //! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space -//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]). +//! z range (the `directx` projection produced by [`crate::camera::Camera::projection_matrix`]). //! //! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum //! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the @@ -80,7 +80,7 @@ impl Frustum { #[cfg(test)] mod tests { use super::*; - use crate::resources::camera::Camera; + use crate::camera::Camera; /// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov, /// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer. @@ -149,7 +149,7 @@ mod tests { /// orbital camera). If this fails, the demo's black window is a frustum-culling bug. #[test] fn demo_camera_sees_all_primitives() { - use crate::resources::CameraController; + use crate::camera::CameraController; let mut ctrl = CameraController::default(); ctrl.yaw = 0.6; ctrl.pitch = 0.35; diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index ef97db1..34c6a22 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -9,24 +9,24 @@ //! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`. //! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present). +pub mod bloom; pub mod context; pub mod frame; pub mod frustum; pub mod geometry; pub mod hdr; -pub mod input; pub mod lod; pub mod renderer; pub mod shadow; pub mod transform; // Re-exports +pub use bloom::BloomConfig; pub use context::Context; pub use frame::Frame; pub use frustum::Frustum; pub use geometry::{BBox, Geometry, GeometryError}; pub use hdr::ToneMapper; -pub use input::InputState; pub use lod::{lod_level, projected_radius_px}; pub use renderer::Renderer; pub use shadow::ShadowConfig; diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 36d23f5..016198e 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -19,7 +19,9 @@ //! texture state changes happen once per distinct material, not once per entity. //! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths. +use crate::camera::Camera; use crate::core::Context; +use crate::lights::{Lights, MAX_LIGHTS}; use crate::core::Frame; use crate::core::Frustum; use crate::core::lod::{lod_level, projected_radius_px}; @@ -29,13 +31,14 @@ use crate::pipeline::{ }; use crate::resources::uniform::{ BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE, - LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, + LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot, }; use crate::resources::{ - Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, + CullUniforms, FrameUniforms, Material, Mesh, ObjectUniform, ShadowUniform, }; use crate::scene::Scene; +use crate::core::bloom::{BloomConfig, BloomPipeline}; use crate::core::hdr::ToneMapper; use crate::utils::conf::{ GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER, @@ -151,6 +154,11 @@ pub struct Renderer { /// HDR pipeline (Étape 20). Present only when HDR is enabled via `AppBuilder::with_hdr`. /// When `None`, the main pass renders directly to the surface (LDR, zero overhead). hdr: Option, + /// Bloom pipeline (Étape 23). Present only when both HDR and bloom are active. + /// When `None`, the TM pass reads the HDR texture directly (no bloom, zero overhead). + bloom: Option, + /// Bloom configuration (used per-frame for uniform writes). Only meaningful when bloom is active. + bloom_config: BloomConfig, } /// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline. @@ -163,12 +171,17 @@ struct HdrPipeline { /// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve). pipeline: wgpu::RenderPipeline, /// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport). - /// The uniform buffer is owned by the bind group (freed when the bind group is replaced). bind_group: wgpu::BindGroup, + /// TM uniform buffer (32 bytes: exposure + viewport). Re-written each frame for live exposure. + uniform_buffer: wgpu::Buffer, /// Bind group layout for the TM pass (reused on resize to recreate the bind group). layout: wgpu::BindGroupLayout, /// Sampler for the HDR texture (linear, clamp). sampler: wgpu::Sampler, + /// Viewport width in pixels (for the TM uniform's pad.xy). + width: u32, + /// Viewport height in pixels. + height: u32, } impl Renderer { @@ -187,6 +200,7 @@ impl Renderer { height: u32, shadow_config: &super::shadow::ShadowConfig, hdr: Option, + bloom_config: Option, ) -> Self { let queue: wgpu::Queue = context.queue.clone(); let device: wgpu::Device = context.device.clone(); @@ -223,6 +237,7 @@ impl Renderer { }); let identity_object = ObjectUniform { model: glam::Mat4::IDENTITY, + emissive: glam::Vec4::ZERO, }; queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object)); let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -425,9 +440,11 @@ impl Renderer { label: Some("GPU world matrices"), size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE, // COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map. + // COPY_DST: lets the CPU write emissive values into the slot padding (Étape 22). usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::UNIFORM - | wgpu::BufferUsages::COPY_SRC, + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor { @@ -575,12 +592,27 @@ impl Renderer { viewport_height: height, shadow_config: shadow_config.clone(), hdr: None, + bloom: None, + bloom_config: bloom_config.clone().unwrap_or_default(), }; // Seed the shared frame buffer with an identity camera + current unlit flag so the low-level // `render` path (which has no window/camera) sees coherent values before `render_scene` runs. renderer.write_default_frame_uniforms(); // Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled. renderer.hdr = hdr.map(|tm| create_hdr_pipeline(&renderer.device, &renderer.queue, width, height, tm, format)); + // Étape 23: allocate the bloom pipeline when both HDR and bloom are active. + if bloom_config.is_some() { + if let Some(hdr) = &mut renderer.hdr { + let bloom = BloomPipeline::new(&renderer.device, width, height, &hdr.view); + // Recreate the TM bind group to read from the bloom composite texture. + let (bg, _buf) = create_hdr_bind_group( + &renderer.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height, + ); + hdr.bind_group = bg; + renderer.bloom = Some(bloom); + renderer.bloom_config = bloom_config.clone().unwrap(); + } + } renderer } @@ -623,10 +655,24 @@ impl Renderer { // Étape 20: recreate the HDR texture + bind group at the new size (D10). if let Some(hdr) = &mut self.hdr { let (tex, view) = create_hdr_texture(&self.device, width, height); - let bg = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height); + let (bg, buf) = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height); hdr.texture = tex; hdr.view = view; hdr.bind_group = bg; + hdr.uniform_buffer = buf; + hdr.width = width; + hdr.height = height; + } + // Étape 23: resize bloom textures + re-point TM bind group at the composite. + if self.bloom.is_some() { + if let Some(hdr) = &mut self.hdr { + let bloom = self.bloom.as_mut().unwrap(); + bloom.resize(&self.device, width, height, &hdr.view); + let (bg, _buf) = create_hdr_bind_group( + &self.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height, + ); + hdr.bind_group = bg; + } } } @@ -717,15 +763,15 @@ impl Renderer { // camera must look along the light's **travel direction** (light → scene), i.e. the negation // of the surface→light vector for directional lights. let dir = match light.light_type() { - crate::resources::LightType::Directional => Vec3::new( + crate::lights::LightType::Directional => Vec3::new( -light.position_dir.x, -light.position_dir.y, -light.position_dir.z, ), - crate::resources::LightType::Spot => { + crate::lights::LightType::Spot => { Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z) } - crate::resources::LightType::Point => return None, + crate::lights::LightType::Point => return None, }; let r = self.shadow_config.scene_radius; let target = Vec3::from(self.shadow_config.scene_center); @@ -807,7 +853,7 @@ impl Renderer { /// draw). This removes the CPU-side per-entity loop from the render hot path. /// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are /// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection. - pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) { + pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32, exposure: f32) { // 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags). self.write_frame_uniforms( scene.camera(), @@ -1007,9 +1053,42 @@ impl Renderer { } } - // 8. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR - // texture, applies exposure + tone mapping curve, and writes to the surface. - // Only runs when HDR is active; the surface is the color target (no depth needed). + // 8. Étape 22 (6.1): write the current exposure into the TM uniform buffer (per-frame, + // so live adjustments via keyboard take effect immediately). + // 8b. Étape 22 (6.2): write each active slot's emissive into the matrix buffer padding + // (bytes 64-79). The compute pass only overwrites bytes 0-63 (the matrix), so the + // emissive persists. This must happen before the encoder submit (CPU→GPU copy). + if let Some(hdr) = &self.hdr { + let uniform_data = [ + exposure, 0.0, 0.0, 0.0, + hdr.width as f32, hdr.height as f32, 0.0, 0.0, + ]; + self.queue.write_buffer(&hdr.uniform_buffer, 0, bytemuck::cast_slice(&uniform_data)); + } + // Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79). + // The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists. + for slot in scene.iter_slot_draws().filter(|s| s.active) { + let mat = slot + .mesh + .material() + .cloned() + .unwrap_or_else(|| scene.default_material()); + if mat.emissive != [0.0; 4] { + let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64; + self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive)); + } + } + + // 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite). + // Only runs when both HDR and bloom are active. The composite texture becomes + // the input to the TM pass (the TM bind group was re-pointed at construction). + if let Some(bloom) = &self.bloom { + bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config); + } + + // 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR + // texture (or the bloom composite when bloom is active), applies exposure + tone + // mapping curve, and writes to the surface. if let Some(hdr) = &self.hdr { let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("tone mapping pass"), @@ -1321,6 +1400,12 @@ impl Renderer { self.lod_enabled.set(enabled); } + /// Updates the bloom configuration at runtime (Étape 23). + /// Takes effect on the next frame (uniforms are re-written each frame in `record_passes`). + pub fn set_bloom_config(&mut self, config: &BloomConfig) { + self.bloom_config = config.clone(); + } + /// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the /// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox /// center + max half-extent × max scale component, rotated by the entity's quaternion) — is @@ -1524,8 +1609,9 @@ fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu:: (texture, view) } -/// Creates the tone mapping bind group: HDR texture (binding 0) + sampler (binding 1) + uniform (binding 2). -/// The uniform contains exposure (1.0) and viewport size (pad.xy). +/// Creates the tone mapping bind group + uniform buffer: HDR texture (binding 0) + sampler (binding 1) +/// + uniform (binding 2). The uniform contains exposure (1.0) and viewport size (pad.xy). +/// Returns both the bind group and the uniform buffer (so the exposure can be re-written per frame). fn create_hdr_bind_group( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, @@ -1533,7 +1619,7 @@ fn create_hdr_bind_group( texture: &wgpu::Texture, width: u32, height: u32, -) -> wgpu::BindGroup { +) -> (wgpu::BindGroup, wgpu::Buffer) { // Write the uniform: exposure = 1.0, pad.xy = viewport size. // WGSL uniform layout: f32 at offset 0 (4B), vec3 at offset 16 (16B, aligned to 16). // Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0]. @@ -1546,7 +1632,7 @@ fn create_hdr_bind_group( let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("tm uniform"), size: 32, - usage: wgpu::BufferUsages::UNIFORM, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: true, }); { @@ -1555,7 +1641,7 @@ fn create_hdr_bind_group( drop(w); uniform_buffer.unmap(); } - device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("tm bind group"), layout, entries: &[ @@ -1576,7 +1662,8 @@ fn create_hdr_bind_group( }), }, ], - }) + }); + (bind_group, uniform_buffer) } /// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group. @@ -1669,15 +1756,18 @@ fn create_hdr_pipeline( }); // 5. Bind group with the initial texture + viewport size. - let bind_group = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height); + let (bind_group, uniform_buffer) = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height); HdrPipeline { texture, view, pipeline, bind_group, + uniform_buffer, layout, sampler, + width, + height, } } diff --git a/lib/src/core/input.rs b/lib/src/input.rs similarity index 99% rename from lib/src/core/input.rs rename to lib/src/input.rs index cb663f8..a8b5122 100644 --- a/lib/src/core/input.rs +++ b/lib/src/input.rs @@ -18,7 +18,7 @@ //! ## Query examples (in `AppHandler::update`) //! ``` //! # use winit::keyboard::{KeyCode, PhysicalKey}; -//! # fn demo(input: &wsg_lib::core::input::InputState) { +//! # fn demo(input: &wsg_lib::input::InputState) { //! if input.key_held(KeyCode::KeyW) { /* move forward */ } //! if input.key_pressed(KeyCode::Space) { /* jump */ } //! let (dx, dy) = input.mouse_delta(); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index c0bf6be..f3b14c3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -29,8 +29,11 @@ #![warn(missing_docs)] pub mod app; +pub mod camera; pub mod core; pub mod handler; +pub mod input; +pub mod lights; pub mod mesh; pub mod pipeline; pub mod prelude; @@ -48,6 +51,7 @@ pub use crate::handler::AppHandler; /// Re-export of the shadow mapping configuration for convenient top-level access. /// Users tune shadow quality via `AppBuilder::with_shadow_config`. +pub use crate::core::BloomConfig; pub use crate::core::ShadowConfig; /// Re-export of the tone mapping curve selector for convenient top-level access. diff --git a/lib/src/resources/lights.rs b/lib/src/lights.rs similarity index 62% rename from lib/src/resources/lights.rs rename to lib/src/lights.rs index 423d600..33f3567 100644 --- a/lib/src/resources/lights.rs +++ b/lib/src/lights.rs @@ -1,9 +1,7 @@ -//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13) +//! # Lights — Global Light List + Light Types //! -//! 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 -//! frame by `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is -//! lit by the same list (per-material lights are out of scope, a later performance/feature step). +//! Defines the scene's global light list — directional, point and spot lights — and the +//! GPU-upload types (`Light`, `LightType`, `MAX_LIGHTS`). //! //! ## Rangement (no type flag) //! Directional lights occupy indices `0..num_directional`; point lights occupy @@ -15,9 +13,11 @@ //! [`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`. -use crate::resources::uniform::{Light, MAX_LIGHTS}; use glam::{Vec3, Vec4}; +/// Re-exported from `crate::resources::uniform` (where `Pod` is derived for the uniform buffer). +pub use crate::resources::uniform::{Light, LightType, MAX_LIGHTS}; + /// The scene's global light list: directional lights (first), point lights (middle), spot lights /// (last). Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene` /// API. @@ -32,13 +32,11 @@ pub struct Lights { } impl Lights { - /// Default = one white directional light along +Z (from surface toward light), no point or - /// spot lights. This reproduces the historical single-light look when combined with a white - /// ambient. + /// Default = one white directional light along +Z. pub fn new() -> Self { Self { directional: vec![Light { - position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z + position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), color: Vec4::ONE, radius: Vec4::ZERO, dir_angle: Vec4::ZERO, @@ -48,7 +46,7 @@ impl Lights { } } - /// Total number of lights (directional + point + spot). + /// Total number of lights. pub fn len(&self) -> usize { self.directional.len() + self.point.len() + self.spot.len() } @@ -58,9 +56,7 @@ impl Lights { self.len() == 0 } - /// 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`, Step 14 D7). + /// Returns the light at a **packed-array index**. pub fn get(&self, index: usize) -> Option<&Light> { let n_dir = self.directional.len(); if index < n_dir { @@ -74,10 +70,7 @@ impl Lights { self.spot.get(index - n_point) } - /// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then - /// point lights, then spot lights. The tail is zero-filled. Returns - /// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS` - /// (the `Scene` API validates capacity). + /// Packs the lights into the GPU frame array. pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) { let empty = Light { position_dir: Vec4::ZERO, @@ -102,14 +95,12 @@ impl Lights { } impl Default for Lights { - /// `Lights::new()` — one white directional light along +Z (non-regression default). fn default() -> Self { Self::new() } } -/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and -/// an intensity multiplier. Used by `Scene::add_directional_light`. +/// Builds a directional [`Light`]. pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light { Light { position_dir: dir.extend(0.0), @@ -119,8 +110,7 @@ pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light { } } -/// Builds a point [`Light`] from a world position, a color, an intensity multiplier and an -/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`. +/// Builds a point [`Light`]. pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light { Light { position_dir: pos.extend(0.0), @@ -130,9 +120,7 @@ pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> L } } -/// Builds a spot [`Light`] from a world position, a cone axis (from the light toward the scene), a -/// color, an intensity multiplier, an attenuation radius and a half-angle in radians. Used by -/// `Scene::add_spot_light`. The half-angle is stored as its cosine in `dir_angle.w`. +/// Builds a spot [`Light`]. pub fn spot_light( pos: Vec3, dir: Vec3, @@ -164,7 +152,7 @@ mod tests { #[test] fn into_frame_array_packs_directional_point_then_spot() { - let mut lights = Lights::new(); // 1 directional + let mut lights = Lights::new(); lights .point .push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0)); @@ -180,11 +168,9 @@ mod tests { assert_eq!(n_dir, 1); assert_eq!(n_point, 1); assert_eq!(n_spot, 1); - // Directional first, point second, spot third. assert_eq!(array[0].color, Vec4::ONE); assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0)); assert_eq!(array[2].color, Vec4::new(0.0, 1.0, 0.0, 1.0)); - // Spot stores the cone axis (normalized) and the half-angle cosine. assert_eq!(array[2].dir_angle.truncate(), Vec3::new(-1.0, 0.0, 0.0)); assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6); } @@ -194,28 +180,18 @@ mod tests { assert!(MAX_LIGHTS >= 1); } - /// Locks the spot sign convention used by the shader: for a surface point that lies on the - /// cone axis, the alignment between the "light -> point" direction (`-l`, where `l` points - /// from the surface toward the light) and the stored cone axis (`dir_angle.xyz`, from the - /// light toward the scene) must be **+1** (full cone), not −1. A regression to the wrong sign - /// would make every spot light contribute zero (black cube). Mirrors the WGSL spot loop. #[test] fn spot_cone_axis_alignment_is_positive() { - // Spot at (0,0,3), cone axis pointing toward the origin (light -> scene). let light_pos = Vec3::new(0.0, 0.0, 3.0); let surface_point = Vec3::ZERO; - let cone_axis = (surface_point - light_pos).normalize(); // (0,0,-1) - - // Shader math: l points surface -> light; the cone test uses -l (light -> point). - let l = (light_pos - surface_point).normalize(); // (0,0,1) - let to_point = -l; // (0,0,-1) + let cone_axis = (surface_point - light_pos).normalize(); + let l = (light_pos - surface_point).normalize(); + let to_point = -l; let cone = to_point.dot(cone_axis); - assert!( (cone - 1.0).abs() < 1e-6, - "on-axis point must align with the cone axis (got {cone}); if it is ~-1 the spot sign is wrong" + "on-axis point must align with the cone axis (got {cone})" ); - // Sanity: the buggy expression (dot of l with the axis) would be ~ -1. assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6); } } diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index 746152f..0a0951e 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -50,7 +50,7 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr label: Some("object_uniform_layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, - visibility: wgpu::ShaderStages::VERTEX, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, // Phase 3 (D12): dynamic offset so every entity shares the single GPU-written diff --git a/lib/src/prelude.rs b/lib/src/prelude.rs index 5caaa16..51b0501 100644 --- a/lib/src/prelude.rs +++ b/lib/src/prelude.rs @@ -15,7 +15,17 @@ // Core types pub use crate::core::geometry::{BBox, Geometry}; pub use crate::core::transform::Transform; -pub use crate::core::{ShadowConfig, ToneMapper}; +pub use crate::core::{BloomConfig, ShadowConfig, ToneMapper}; +pub use crate::resources::Material; + +// Camera +pub use crate::camera::{Camera, CameraController}; + +// Lights +pub use crate::lights::{directional_light, point_light, spot_light, Light, LightType, Lights}; + +// Input +pub use crate::input::InputState; // App / handler (already at crate root, re-exported here for convenience) pub use crate::app::AppBuilder; diff --git a/lib/src/resources/material.rs b/lib/src/resources/material.rs index 158633a..5ab7c28 100644 --- a/lib/src/resources/material.rs +++ b/lib/src/resources/material.rs @@ -19,6 +19,7 @@ use std::sync::Arc; /// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline and an optional /// diffuse texture. Does not own the pipeline; holds an Arc for zero-copy sharing across objects /// using the same shader. Owns its texture bind group (group 2), built at construction. +#[derive(Clone)] pub struct Material { /// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache. pub shader_id: String, @@ -29,6 +30,9 @@ pub struct Material { /// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in /// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`. pub texture_bind_group: wgpu::BindGroup, + /// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]` + /// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect. + pub emissive: [f32; 4], } impl Material { @@ -69,6 +73,7 @@ impl Material { pipeline, texture, texture_bind_group, + emissive: [0.0, 0.0, 0.0, 0.0], } } } diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index a1f8c4a..b2926bf 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -1,19 +1,12 @@ -//! # Resources Module — Data Types +//! # Resources Module — GPU Data Types //! -//! Defines the core data types that flow through the rendering pipeline: **Geometry** (CPU-side scattered -//! vertex data, source of truth — re-exported here from `math` for convenience), **Vertex** (interleaved -//! CPU-side per-attribute tuple, the GPU upload contract), **Mesh** (GPU geometry container with vertex/index -//! buffers), and **Material** (appearance descriptor pairing shader ID with a compiled RenderPipeline). -//! These are immutable after creation and consumed by Renderer for draw calls. +//! Defines the core GPU data types that flow through the rendering pipeline: **Mesh** (GPU geometry +//! container with vertex/index buffers), **Material** (appearance descriptor pairing shader ID with +//! a compiled RenderPipeline), **Texture** (GPU image + sampler), and **Uniform** (Pod structs for +//! uniform buffer uploads). //! -//! ## Interaction with Other Modules -//! - `pipeline_cache::build_pipeline()` reads Vertex field offsets to construct the vertex buffer layout. -//! - `mesh::from_geometry()` derives `Vertex` arrays from a `Geometry` and uploads them into GPU vertex -//! buffers via DeviceExt::create_buffer_init(). -//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization. +//! Camera, Lights and Input are now top-level modules (`wsg::camera`, `wsg::lights`, `wsg::input`). -pub mod camera; -pub mod lights; pub mod material; pub mod mesh; pub mod texture; @@ -21,19 +14,16 @@ pub mod uniform; pub mod vertex; // Re-exports -pub use camera::{Camera, CameraController, PITCH_LIMIT}; -pub use lights::Lights; pub use material::Material; pub use mesh::{LodMode, Mesh, PackError}; pub use texture::{Texture, TextureError}; pub use uniform::{ BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot, - FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow, - LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, - SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot, + FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, LodRow, LodTable, + MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform, + TRANSFORM_SLOT_SIZE, TransformSlot, }; pub use vertex::Vertex; -// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes -// from `wsg_lib::resources::Geometry` without importing `math` separately. +// Convenience re-export of Geometry (Step 8, D2) pub use crate::core::Geometry; diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 3c16658..757765c 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -27,55 +27,33 @@ pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::() as u64 /// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation). pub const MAX_LIGHTS: usize = 8; -/// A single light, stored in the per-frame uniform array. One struct serves all three types; the -/// *position in the array* disambiguates: -/// - indices `0..num_directional` are **directional** (`position_dir.xyz` = direction -/// **from the surface toward the light**); -/// - indices `num_directional..num_directional + num_point` are **point** -/// (`position_dir.xyz` = world position); -/// - indices `num_directional + num_point..` are **spot** (`position_dir.xyz` = world position, -/// `dir_angle.xyz` = cone axis **from the light toward the scene**, `dir_angle.w` = cos of the -/// half-angle). -/// No type flag in the struct. -/// -/// 4 × Vec4 = 64 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`). +/// A single light, stored in the per-frame uniform array (64 bytes, std140). #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, PartialEq)] pub struct Light { - /// xyz = direction from surface toward the light (directional) or world position (point/spot); - /// w = 0. + /// xyz = direction (directional) or position (point/spot); w = 0. pub position_dir: Vec4, - /// rgb = color; a = intensity (multiplier). + /// rgb = color; a = intensity. pub color: Vec4, - /// x = attenuation radius (point/spot lights); 0 for directional. + /// x = attenuation radius. pub radius: Vec4, - /// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle. - /// Zero for directional and point lights. + /// xyz = cone axis; w = cos half-angle (spot only). pub dir_angle: Vec4, } -/// 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). +/// The runtime-disambiguated type of a []. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LightType { - /// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the - /// light, `radius.x` = 0, `dir_angle` = 0. + /// Directional light (infinitely distant). Directional, - /// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation - /// radius, `dir_angle` = 0. + /// Point (omnidirectional). Point, - /// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in - /// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle. + /// Spot (cone). Spot, } impl Light { - /// Classifies the light for CPU-side logic. Query order is significant because a spot light - /// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a - /// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is - /// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or - /// [`LightType::Spot`]. + /// Classifies the light for CPU-side logic. pub fn light_type(&self) -> LightType { if self.dir_angle.w > 0.0 { LightType::Spot @@ -87,14 +65,8 @@ impl Light { } } -/// 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** (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). + +/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] pub struct FrameUniforms { @@ -158,14 +130,18 @@ 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 + emissive color. /// -/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`. +/// Mirrors the WGSL `ObjectUniform` struct. 80 bytes, `Pod`. +/// In the GPU-driven path, the emissive lives in the `MatSlot` padding (bytes 64-79), +/// pre-filled by the CPU at slot creation and never overwritten by the compute pass. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Default)] pub struct ObjectUniform { /// Model matrix (object → world space). Offset 0. pub model: Mat4, + /// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression). + pub emissive: Vec4, } /// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's @@ -517,9 +493,11 @@ mod tests { #[test] fn object_uniform_layout_matches_wgsl() { - assert_eq!(size_of::(), 64); + // Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive). + assert_eq!(size_of::(), 80); assert_eq!(align_of::(), 16); assert_eq!(offset_of!(ObjectUniform, model), 0); + assert_eq!(offset_of!(ObjectUniform, emissive), 64); } #[test] diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index ff6bae5..6b06bf0 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -17,7 +17,7 @@ use crate::core::{Geometry, Transform}; use crate::pipeline::PipelineCache; -use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot}; +use crate::camera::Camera; use crate::lights::Lights; use crate::resources::{BBoxSlot, Material, Mesh, Texture, TransformSlot}; use crate::scene::Entity; use glam::Vec3; use std::cell::RefCell; @@ -452,7 +452,7 @@ impl Scene { } /// Returns a mutable reference to the scene's active camera, for in-place per-frame edits - /// (e.g. [`CameraController::apply_to`](crate::resources::CameraController) during `update`). + /// (e.g. [`CameraController::apply_to`](crate::camera::CameraController) during `update`). pub fn camera_mut(&mut self) -> &mut Camera { &mut self.camera } @@ -468,15 +468,15 @@ impl Scene { color: [f32; 3], intensity: f32, ) -> Result<(), String> { - if self.lights.len() >= crate::resources::MAX_LIGHTS { + if self.lights.len() >= crate::lights::MAX_LIGHTS { return Err(format!( "Cannot add another light: MAX_LIGHTS ({}) reached.", - crate::resources::MAX_LIGHTS + crate::lights::MAX_LIGHTS )); } self.lights .directional - .push(crate::resources::lights::directional_light( + .push(crate::lights::directional_light( dir, color, intensity, )); Ok(()) @@ -492,15 +492,15 @@ impl Scene { intensity: f32, radius: f32, ) -> Result<(), String> { - if self.lights.len() >= crate::resources::MAX_LIGHTS { + if self.lights.len() >= crate::lights::MAX_LIGHTS { return Err(format!( "Cannot add another light: MAX_LIGHTS ({}) reached.", - crate::resources::MAX_LIGHTS + crate::lights::MAX_LIGHTS )); } self.lights .point - .push(crate::resources::lights::point_light( + .push(crate::lights::point_light( pos, color, intensity, radius, )); Ok(()) @@ -520,13 +520,13 @@ impl Scene { radius: f32, half_angle: f32, ) -> Result<(), String> { - if self.lights.len() >= crate::resources::MAX_LIGHTS { + if self.lights.len() >= crate::lights::MAX_LIGHTS { return Err(format!( "Cannot add another light: MAX_LIGHTS ({}) reached.", - crate::resources::MAX_LIGHTS + crate::lights::MAX_LIGHTS )); } - self.lights.spot.push(crate::resources::lights::spot_light( + self.lights.spot.push(crate::lights::spot_light( pos, dir, color, intensity, radius, half_angle, )); Ok(()) @@ -603,6 +603,21 @@ impl Scene { Ok(id.to_string()) } + /// Sets the emissive color on a registered material (Étape 22, 6.2). + /// Uses `Arc::get_mut` — only works if the material has a single reference (i.e., no mesh + /// has captured it yet). Call BEFORE `create_mesh` to pre-set the emissive. + /// Returns Err if the material doesn't exist or has multiple references. + pub fn set_material_emissive(&mut self, id: &str, emissive: [f32; 4]) -> Result<(), String> { + let mat = self + .materials + .get_mut(id) + .ok_or_else(|| format!("Material '{}' not found.", id))?; + let inner = Arc::get_mut(mat) + .ok_or_else(|| format!("Material '{}' has multiple references; cannot modify in place.", id))?; + inner.emissive = emissive; + Ok(()) + } + /// 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 Step 7.3). @@ -857,12 +872,12 @@ mod tests { 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 { + while scene.lights().len() < crate::lights::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_eq!(scene.lights().len(), crate::lights::MAX_LIGHTS); assert!( scene .add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5) diff --git a/lib/src/shaders/bloom_blur.wgsl b/lib/src/shaders/bloom_blur.wgsl new file mode 100644 index 0000000..917d7d5 --- /dev/null +++ b/lib/src/shaders/bloom_blur.wgsl @@ -0,0 +1,56 @@ +// Bloom blur pass: separable 9-tap Gaussian blur (half-res). +// Direction is passed via uniform (H or V). Ping-ponged between two textures. + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3). +// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2. +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + var out: VsOut; + switch vi { + case 0u { + out.pos = vec4(-1.0, -1.0, 0.0, 1.0); + out.uv = vec2(0.0, 1.0); + } + case 1u { + out.pos = vec4(3.0, -1.0, 0.0, 1.0); + out.uv = vec2(2.0, 1.0); + } + default { + out.pos = vec4(-1.0, 3.0, 0.0, 1.0); + out.uv = vec2(0.0, -1.0); + } + } + return out; +} + +struct BlurUniforms { + direction: vec2, + radius: f32, + pad: vec4, +}; + +@group(0) @binding(0) var bu: BlurUniforms; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_sampler: sampler; + +const W: array = array( + 0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162 +); + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let center = textureSample(src_tex, src_sampler, in.uv).rgb; + var sum = center * W[0]; + for (var i: u32 = 1u; i < 5u; i = i + 1u) { + let off = bu.direction * (f32(i) * bu.radius); + let s = textureSample(src_tex, src_sampler, in.uv + off).rgb + + textureSample(src_tex, src_sampler, in.uv - off).rgb; + sum = sum + s * W[i]; + } + return vec4(sum, 1.0); +} diff --git a/lib/src/shaders/bloom_composite.wgsl b/lib/src/shaders/bloom_composite.wgsl new file mode 100644 index 0000000..3c87f30 --- /dev/null +++ b/lib/src/shaders/bloom_composite.wgsl @@ -0,0 +1,47 @@ +// Bloom composite pass: add the blurred bloom to the HDR texture. +// Reads full-res HDR + half-res bloom (upscaled by linear sampler), writes full-res composite. + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3). +// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2. +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + var out: VsOut; + switch vi { + case 0u { + out.pos = vec4(-1.0, -1.0, 0.0, 1.0); + out.uv = vec2(0.0, 1.0); + } + case 1u { + out.pos = vec4(3.0, -1.0, 0.0, 1.0); + out.uv = vec2(2.0, 1.0); + } + default { + out.pos = vec4(-1.0, 3.0, 0.0, 1.0); + out.uv = vec2(0.0, -1.0); + } + } + return out; +} + +struct CompositeUniforms { + intensity: f32, + pad: vec4, +}; + +@group(0) @binding(0) var cu: CompositeUniforms; +@group(0) @binding(1) var hdr_tex: texture_2d; +@group(0) @binding(2) var hdr_sampler: sampler; +@group(0) @binding(3) var bloom_tex: texture_2d; +@group(0) @binding(4) var bloom_sampler: sampler; + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb; + let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb; + return vec4(hdr + bloom * cu.intensity, 1.0); +} diff --git a/lib/src/shaders/bloom_threshold.wgsl b/lib/src/shaders/bloom_threshold.wgsl new file mode 100644 index 0000000..3f77415 --- /dev/null +++ b/lib/src/shaders/bloom_threshold.wgsl @@ -0,0 +1,50 @@ +// Bloom threshold pass: extract bright pixels from the HDR texture. +// Reads full-res HDR, writes half-res bright texture. +// Soft-knee threshold: smooth transition above the threshold luminance. + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +// Fullscreen triangle: same as TM shader. NDC (-1,-1),(3,-1),(-1,3). +// UVs use top-left origin (WebGPU texture convention): u=(x+1)/2, v=(1-y)/2. +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + var out: VsOut; + switch vi { + case 0u { + out.pos = vec4(-1.0, -1.0, 0.0, 1.0); + out.uv = vec2(0.0, 1.0); + } + case 1u { + out.pos = vec4(3.0, -1.0, 0.0, 1.0); + out.uv = vec2(2.0, 1.0); + } + default { + out.pos = vec4(-1.0, 3.0, 0.0, 1.0); + out.uv = vec2(0.0, -1.0); + } + } + return out; +} + +struct ThresholdUniforms { + threshold: f32, + knee: f32, + pad: vec4, +}; + +@group(0) @binding(0) var tmu: ThresholdUniforms; +@group(0) @binding(1) var src_tex: texture_2d; +@group(0) @binding(2) var src_sampler: sampler; + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let color = textureSample(src_tex, src_sampler, in.uv).rgb; + let lum = dot(color, vec3(0.2126, 0.7152, 0.0722)); + // Soft-knee: smooth ramp from 0 to 1 above threshold. + let soft = max(lum - tmu.threshold, 0.0); + let contrib = soft / (soft + tmu.knee); + return vec4(color * contrib, 1.0); +} diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index 0a8b850..b3d9f4e 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -96,7 +96,8 @@ struct FrameUniforms { }; struct ObjectUniform { - model: mat4x4, + model: mat4x4, // 64 bytes (offset 0) + emissive: vec4, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR) }; @group(0) @binding(0) var frame: FrameUniforms; @@ -147,9 +148,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let texel = textureSample(diffuse_texture, texture_sampler, in.uv); let base = texel.rgb * in.color.rgb; - // Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex telle quelle. + // Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive. if (frame.options.x != 0u) { - return vec4(base, in.color.a); + let emissive_contrib = base * object.emissive.rgb * object.emissive.a; + return vec4(base + emissive_contrib, in.color.a); } let n = normalize(in.normal); @@ -203,7 +205,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n); - return vec4(lit, in.color.a); + // Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows). + // Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows. + let emissive_contrib = base * object.emissive.rgb * object.emissive.a; + return vec4(lit + emissive_contrib, in.color.a); } // Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index ca2ea5f..f707ab7 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -46,6 +46,18 @@ pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl"); /// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled. pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl"); +/// The bloom threshold pass shader (Étape 23). Extracts pixels above a luminance threshold +/// from the full-res HDR texture into a half-res bright texture. Soft-knee falloff. +pub const BLOOM_THRESHOLD_SHADER: &str = include_str!("../shaders/bloom_threshold.wgsl"); + +/// The bloom blur pass shader (Étape 23). Separable 9-tap Gaussian, direction via uniform. +/// Ping-ponged between two half-res textures (H pass then V pass). +pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl"); + +/// The bloom composite pass shader (Étape 23). Adds the blurred bloom (half-res, upsampled) +/// to the full-res HDR texture, scaled by intensity. Writes to a full-res composite texture. +pub const BLOOM_COMPOSITE_SHADER: &str = include_str!("../shaders/bloom_composite.wgsl"); + /// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and /// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU /// rewrites only the transform slots and the cull uniforms. @@ -102,7 +114,7 @@ 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, Step 14 D7). Also used as the no-caster /// sentinel for `FrameUniforms.shadow_light_index`. -pub use crate::resources::uniform::MAX_LIGHTS; +pub use crate::lights::MAX_LIGHTS; /// Default application title displayed in the OS taskbar/window decorations. pub const APP_DEFAULT_TITLE: &str = "WSG App"; diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index ccf9294..2ffb064 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -116,3 +116,69 @@ fn tonemap_shader_is_valid_wgsl() { "the three entry points are expected" ); } + +/// Parses and fully validates the `bloom_threshold.wgsl` shader (Étape 23) via naga. +#[test] +fn bloom_threshold_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/bloom_threshold.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("bloom_threshold.wgsl: parsing error: {e:?}")); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("bloom_threshold.wgsl: validation failed: {e:?}")); + let mut entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + entry_names.sort(); + assert_eq!(entry_names, vec!["fs_main", "vs_main"]); +} + +/// Parses and fully validates the `bloom_blur.wgsl` shader (Étape 23) via naga. +#[test] +fn bloom_blur_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/bloom_blur.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("bloom_blur.wgsl: parsing error: {e:?}")); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("bloom_blur.wgsl: validation failed: {e:?}")); + let mut entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + entry_names.sort(); + assert_eq!(entry_names, vec!["fs_main", "vs_main"]); +} + +/// Parses and fully validates the `bloom_composite.wgsl` shader (Étape 23) via naga. +#[test] +fn bloom_composite_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/bloom_composite.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("bloom_composite.wgsl: parsing error: {e:?}")); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("bloom_composite.wgsl: validation failed: {e:?}")); + let mut entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + entry_names.sort(); + assert_eq!(entry_names, vec!["fs_main", "vs_main"]); +}