refactor examples

This commit is contained in:
Jérôme Bousquié
2026-09-25 10:19:24 +02:00
parent ab3f056dbb
commit 35aeb769a8
37 changed files with 3430 additions and 457 deletions
+121 -195
View File
@@ -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<Geometry>` 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 | ## Forces
|------|-------|
| 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 |
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 ## Quickstart
### 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`):
```rust ```rust
use wsg_lib::prelude::*;
use wsg_lib::app::AppBuilder; use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError; 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) { fn setup(&mut self, app: &mut wsg_lib::App) {
app.renderer_mut().set_unlit(true); // 2D flat (optional)
app.scene app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap(); .unwrap();
let geometry = Geometry::new(vec![ app.scene
[-0.5, 0.5, 0.0], .create_material("mat", "standard", None)
[ 0.5, 0.5, 0.0], .unwrap();
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0], // Un cube lit par Phong, posé au-dessus d'un plan
]) app.scene
.with_normals(vec![[0.0, 0.0, 1.0]; 4]) .create_mesh("cube", cube(1.0), Some("mat"))
.with_colors(vec![ .unwrap();
[1.0, 0.0, 0.0, 1.0], app.scene
[0.0, 1.0, 0.0, 1.0], .add_entity("my_cube", "cube")
[0.0, 0.0, 1.0, 1.0], .unwrap();
[1.0, 1.0, 0.0, 1.0],
]) app.scene
.with_indices(vec![0, 1, 2, 0, 2, 3]); .create_mesh("ground", plane(10.0, 10.0, 1, 1), Some("mat"))
app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material .unwrap();
app.scene.add_entity("quad", "quad_mesh").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] fn main() -> Result<(), WsgError> {
async fn main() -> Result<(), WsgError> { let mut app = AppBuilder::new()
let app = AppBuilder::new().title("WSG Simple").build().await?; .title("Ma scène WSG")
app.run(MonQuad) .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<Geometry>` 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<Geometry>`, `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<Geometry>` 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 ```toml
[dependencies] [dependencies]
wsg-lib = { path = "/path/to/wsg/lib" } wsg-lib = { path = "../lib" }
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder) pollster = { version = "1", features = ["macro"] }
winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton)
``` ```
| Action | Command | ```sh
|--------|---------| cargo run --example demo # le showcase complet (6 primitives, 3 lumières, ombres, HDR)
| 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` |
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 ## 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): ## Exemples
- [Quickstart](docs/user/quickstart.md) — first window, first object, in ~30 lines
- [Meshes](docs/user/meshes.md) · [Materials & textures](docs/user/materials.md) · [Lights](docs/user/lights.md)
- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md)
**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture): | Exemple | Ce qu'il montre |
- [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. | `demo` | Le showcase : 6 primitives, 3 lumières, ombres, HDR, LOD, caméra orbitale |
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 **Target** — material batching. | `cube` | MVP 3D : un cube lit par Phong, texture checkerboard |
- [ARCHI_ARENES](docs/tech/ARCHI_ARENES.md) — 🎯 **Target/deferred** — slotmap generational handles; String IDs are used today. | `simple` | Minimal : un quad coloré en mode unlit (2D) |
- [FRAME_LOOP](docs/tech/FRAME_LOOP.md) — frame lifetime and resource persistence. ✅ **Current** — implemented. | `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.) # Minimal : juste le cube
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.)* wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] }
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`.)* # Avec import OBJ
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. wsg-lib = { path = "../lib", features = ["import-obj"] }
6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors. ```
7. ✅ **CPU geometry storage (Step 8)** — `Mesh` retains a shared `Arc<Geometry>` (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.) | Feature | Active |
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.) | `prim-cube`, `prim-plane`, `prim-sphere`, `prim-cylinder`, `prim-cone`, `prim-torus` | Primitives |
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.) | `all-prims` (default) | Les 6 primitives |
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.) | `import-obj` | Parser Wavefront OBJ |
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.) | `import-gltf` | glTF (stub) |
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.) ## Build
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.) ```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)*
+212
View File
@@ -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) |
+326 -82
View File
@@ -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 : ## Objectif
- `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`
## 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/ Scene render → HDR texture (Rgba16Float, full res)
├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox │
├── prelude.rs # glob re-exports (types quotidiens) ├─[bloom actif?]─→ 1. Threshold (half res) : extrait les pixels > threshold
├── core/ │ 2. Blur H (half res) : Gaussian 9 taps
│ ├── mod.rs # + geometry, transform, frustum, lod │ 3. Blur V (half res) : Gaussian 9 taps
│ ├── geometry.rs # ← déplacé de math/ │ 4. Composite (full res) : HDR += bloom × intensity
│ ├── transform.rs # ← déplacé de math/ │
│ ├── frustum.rs # ← déplacé de math/ ▼
│ ├── lod.rs # ← déplacé de math/ TM pass → surface
│ ├── 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/
``` ```
## Features (Cargo.toml) Quand bloom est désactivé : `Scene → HDR → TM → surface` (comme aujourd'hui, zéro overhead).
| Feature | Default | Fournit | **4 passes fullscreen** supplémentaires (seulement si HDR + bloom actifs).
|---------|---------|---------|
| `prim-cube` | ✅ (via all-prims) | `cube(size)` | ---
| `prim-plane` | ✅ | `plane(w, d, sx, sz)` |
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` | ## Composants
| `prim-cylinder` | ✅ | `cylinder(…)` |
| `prim-cone` | ✅ | `cone(…)` | ### `BloomConfig` (pub, dans `core/bloom.rs`)
| `prim-torus` | ✅ | `torus(…)` |
| `all-prims` | ✅ (default) | les 6 ci-dessus | ```rust
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` | pub struct BloomConfig {
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) | /// 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<f32> 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<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var pos: vec2<f32>;
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<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = vec2<f32>(pos.x * 0.5 + 0.5, 0.5 - pos.y * 0.5);
return out;
}
struct ThresholdUniforms {
threshold: f32,
knee: f32,
pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> tmu: ThresholdUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
@group(0) @binding(3) var<atomic u32> pad; // placeholder — not needed, use texture_storage
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let color = textureSample(src_tex, src_sampler, in.uv).rgb;
let lum = dot(color, vec3<f32>(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<f32>(color * contrib, 1.0);
}
```
### `bloom_blur.wgsl`
```wgsl
// Même VsOut / vs_main que threshold (fullscreen triangle)
struct BlurUniforms {
direction: vec2<f32>, // texel offset: (1/w, 0) or (0, 1/h)
radius: f32,
pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> bu: BlurUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
const W: array<f32, 5> = array<f32, 5>(
0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162
);
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
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<f32>(sum, 1.0);
}
```
### `bloom_composite.wgsl`
```wgsl
// Même VsOut / vs_main
struct CompositeUniforms {
intensity: f32,
pad: vec3<f32>,
};
@group(0) @binding(0) var<uniform> cu: CompositeUniforms;
@group(0) @binding(1) var hdr_tex: texture_2d<f32>;
@group(0) @binding(2) var hdr_sampler: sampler;
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@group(0) @binding(4) var bloom_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb;
let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb;
return vec4<f32>(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<BloomPipeline>`, `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écisions
| # | Décision | | # | Décision | Justification |
|---|----------| |---|----------|---------------|
| D1 | Un seul crate `wsg-lib` — pas de crate séparée | | D1 | 4 passes (threshold + blur H + blur V + composite) | Bonne qualité/performances. Un seul niveau de mip suffit pour un bloom "soft" |
| D2 | Feature par famille de primitives | | 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 | Feature par format d'import | | D3 | Soft-knee threshold (pas un cutoff dur) | `soft/(soft+knee)` donne une transition douce, pas d'aliasing au seuil |
| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` | | 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 | `Geometry::new()` / `Scene::add_mesh()` restent en core | | 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 | Module `wsg::mesh` au même niveau que `core`, `app` | | D6 | `BloomConfig` avec 3 champs (threshold, intensity, radius) | Minimum utile. Pas de multi-mip, pas de directional bloom pour MVP |
| D7 | `primitives/` un fichier par famille | | D7 | Sampler `Linear` + `ClampToEdge` pour le blur | Les bords ne doivent pas sampler hors-texture (artefacts noirs) |
| D8 | `import/` un fichier par format | | 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 | Import retourne `Result<_, MeshImportError>` | | D9 | Uniform threshold : 16 bytes (threshold + knee + 2 pad) | Aligned 16, simple |
| D10 | `default = ["all-prims"]` | | D10 | Uniform blur : 16 bytes (direction vec2 + radius + pad) | Aligned 16 |
| D11 | `all-prims` = les 6 primitives | | D11 | Uniform composite : 16 bytes (intensity + 3 pad) | Aligned 16 |
| D12 | `math` disparaît — types re-exportés par `core` / top-level |
---
## 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<BloomPipeline>`, `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 ## Tests
- 107 unit tests (dont 7 tests OBJ parser) | Test | Vérifie |
- 4 WGSL validation |------|---------|
- 5 doctests | `bloom_config_default` | threshold=1.0, intensity=0.8, radius=4.0 |
- **Total : 116 tests, 0 failures** | `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) ✅ ## Critères d'acceptation
- `cargo check --no-default-features --features "prim-cube"` ✅
- `cargo check --features "import-obj,import-gltf"` ✅ - [ ] `cargo test` passe (tous tests existants + nouveaux)
- `cargo check --examples --features "import-obj"` ✅ - [ ] `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
+4 -3
View File
@@ -63,9 +63,9 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
| # | Item | Impact visuel | Effort | Statut | | # | Item | Impact visuel | Effort | Statut |
|---|------|:---:|:---:|:---:| |---|------|:---:|:---:|:---:|
| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ⬜ | | 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ |
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ⬜ | | 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ⬜ | | 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ |
| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ | | 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ |
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ | | 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é | ⬜ | | 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.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.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.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 | ⬜ |
--- ---
+93
View File
@@ -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).
+2 -2
View File
@@ -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). matrices into the frame buffer (aspect recomputed from the window size).
```rust ```rust
use wsg_lib::resources::Camera; use wsg_lib::camera::Camera;
use glam::Vec3; use glam::Vec3;
app.scene.set_camera(Camera::new( app.scene.set_camera(Camera::new(
@@ -37,7 +37,7 @@ app.scene.set_camera(Camera::new(
bounded to `[0.1, 100]`), `target` (target point). bounded to `[0.1, 100]`), `target` (target point).
```rust ```rust
use wsg_lib::resources::CameraController; use wsg_lib::camera::CameraController;
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles) ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
+107
View File
@@ -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é.
+245 -17
View File
@@ -1,23 +1,251 @@
# Examples # Exemples WSG
Each `.rs` file in this directory is a **standalone example** auto-discovered by Cargo Chaque exemple est autonome et illustre **un effet ou une fonctionnalité** spécifique
(`cargo build -p wsg-lib --examples`). To run an example: de la bibliothèque. Tous utilisent l'API déclarative (`AppBuilder` + `AppHandler`).
```bash ## Lancer un exemple
cargo run -p wsg-lib --example <name>
```sh
cargo run -p wsg-lib --example <nom>
``` ```
| Example | Command | Description | | Exemple | Effet démontré |
|---------|---------|-------------| |---------|---------------|
| `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). | | `demo` | Showcase complet (tous les effets combinés) |
| `simple` | `cargo run -p wsg-lib --example simple` | Flat unlit quad (minimal declarative workflow, `AppBuilder` + auto scene). | | `bloom` | Post-process bloom (glow autour des zones brillantes) |
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. | | `hdr` | HDR + Tone Mapping (ACES) + contrôle d'exposition |
| `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). | | `emissive` | Matériaux émissifs (intensités croissantes 0 → 4.0) |
| `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` | Shadow mapping (ombre portée directionnelle) |
| `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. | | `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). ## `demo` — Showcase complet
- 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. 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.
+217
View File
@@ -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(),
})
}
+170
View File
@@ -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,
})
}
+38 -1
View File
@@ -20,6 +20,10 @@
//! `AppBuilder::with_hdr(ToneMapper::Aces)`. The main pass renders to an offscreen //! `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 //! `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. //! 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 //! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with: //! concise and French where helpful. Run with:
@@ -31,10 +35,12 @@ use winit::event::MouseButton;
use winit::keyboard::KeyCode; use winit::keyboard::KeyCode;
use wsg_lib::AppHandler; use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder; use wsg_lib::app::AppBuilder;
use wsg_lib::core::BloomConfig;
use wsg_lib::core::ToneMapper; use wsg_lib::core::ToneMapper;
use wsg_lib::core::Transform; use wsg_lib::core::Transform;
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere}; 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; use wsg_lib::utils::WsgError;
/// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`. /// 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("cone_e", "cone_mesh", app, 4);
place("torus_e", "torus_mesh", app, 5); 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. // 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. // 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(); 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()); 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 ---- // ---- Slow rotation of the primitives so lighting/shadow read clearly ----
self.angle += 0.008; self.angle += 0.008;
let base = *app let base = *app
@@ -281,6 +317,7 @@ async fn main() -> Result<(), WsgError> {
.title("WSG Demo") .title("WSG Demo")
.with_culling(true) .with_culling(true)
.with_hdr(ToneMapper::Aces) .with_hdr(ToneMapper::Aces)
.with_bloom(BloomConfig::default())
.build() .build()
.await?; .await?;
app.run(Demo { app.run(Demo {
+197
View File
@@ -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,
})
}
+170
View File
@@ -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,
})
}
+1 -1
View File
@@ -63,7 +63,7 @@ impl ApplicationHandler for App {
// Flat 2D rendering: `standard` in unlit mode (the frame+object bind groups are set by // 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). // 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); renderer.set_unlit(true);
// 3. Material: uses renderer.device() and renderer.format() // 3. Material: uses renderer.device() and renderer.format()
+201
View File
@@ -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,
})
}
+2 -1
View File
@@ -16,7 +16,8 @@
//! //!
//! Run with: `cargo run -p wsg-lib --example shadow_test` //! Run with: `cargo run -p wsg-lib --example shadow_test`
use glam::Vec3; use glam::Vec3;
use wsg_lib::resources::{Camera, Geometry}; use wsg_lib::camera::Camera;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError; use wsg_lib::utils::WsgError;
/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one /// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one
+75 -3
View File
@@ -23,7 +23,8 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities. //! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler; 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::scene::Scene;
use crate::utils::WsgError; use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; 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); /// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead);
/// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface. /// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface.
pub(crate) hdr: Option<ToneMapper>, pub(crate) hdr: Option<ToneMapper>,
/// Bloom post-process (Étape 23). `None` = no bloom (default, zero overhead).
/// Only active when HDR is also enabled.
pub(crate) bloom_config: Option<BloomConfig>,
/// 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. /// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle. /// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
@@ -128,6 +135,8 @@ impl App {
culling: self.culling, culling: self.culling,
shadow_config: self.shadow_config.clone(), shadow_config: self.shadow_config.clone(),
hdr: self.hdr, hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
handler, handler,
app: None, app: None,
}; };
@@ -147,7 +156,40 @@ impl App {
pub fn render_scene(&self, view: &wgpu::TextureView) { pub fn render_scene(&self, view: &wgpu::TextureView) {
let size = self.window().inner_size(); let size = self.window().inner_size();
let aspect = size.width as f32 / size.height.max(1) as f32; 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). /// 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 /// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
/// the offscreen HDR texture + tone mapping pass. /// the offscreen HDR texture + tone mapping pass.
hdr: Option<ToneMapper>, hdr: Option<ToneMapper>,
/// Bloom post-process (Étape 23). `None` = no bloom (default); `Some(c)` activates
/// the 4-pass bloom when HDR is also enabled.
bloom_config: Option<BloomConfig>,
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
exposure: f32,
} }
impl AppBuilder { impl AppBuilder {
@@ -205,6 +252,8 @@ impl AppBuilder {
culling: false, culling: false,
shadow_config: ShadowConfig::default(), shadow_config: ShadowConfig::default(),
hdr: None, hdr: None,
bloom_config: None,
exposure: 1.0,
} }
} }
/// Sets the window title to display in the OS taskbar/window decorations. /// Sets the window title to display in the OS taskbar/window decorations.
@@ -242,6 +291,21 @@ impl AppBuilder {
self.hdr = Some(tonemapper); self.hdr = Some(tonemapper);
self 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 /// 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 /// 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. /// 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, culling: self.culling,
shadow_config: self.shadow_config, shadow_config: self.shadow_config,
hdr: self.hdr, hdr: self.hdr,
bloom_config: self.bloom_config,
exposure: self.exposure,
event_loop: Some(event_loop), event_loop: Some(event_loop),
context: None, context: None,
renderer: None, renderer: None,
@@ -282,6 +348,10 @@ struct AppRunner<H: AppHandler> {
shadow_config: ShadowConfig, shadow_config: ShadowConfig,
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`. /// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
hdr: Option<ToneMapper>, hdr: Option<ToneMapper>,
/// Bloom config (Étape 23); passed to `Renderer::new` in `resumed`. Only active with HDR.
bloom_config: Option<BloomConfig>,
/// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use.
exposure: f32,
/// The user-provided game logic. /// The user-provided game logic.
handler: H, handler: H,
/// The fully-built App facade, populated on the first `resumed` event. /// The fully-built App facade, populated on the first `resumed` event.
@@ -315,7 +385,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.expect("surface configuration failed"); .expect("surface configuration failed");
let device = Arc::new(context.device.clone()); let device = Arc::new(context.device.clone());
let renderer = 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). // Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling); renderer.set_culling(self.culling);
@@ -340,6 +410,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
culling: self.culling, culling: self.culling,
shadow_config: self.shadow_config.clone(), shadow_config: self.shadow_config.clone(),
hdr: self.hdr, hdr: self.hdr,
bloom_config: self.bloom_config.clone(),
exposure: self.exposure,
event_loop: None, event_loop: None,
context: Some(context), context: Some(context),
renderer: Some(renderer), renderer: Some(renderer),
@@ -111,7 +111,7 @@ pub const PITCH_LIMIT: f32 = 1.45; // ~83°
/// decoupled from `Camera`'s own position/target/up representation. /// decoupled from `Camera`'s own position/target/up representation.
/// ///
/// ``` /// ```
/// # use wsg_lib::resources::{Camera, CameraController}; /// # use wsg_lib::camera::{Camera, CameraController};
/// # use glam::Vec3; /// # use glam::Vec3;
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y); /// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
/// let mut ctrl = CameraController::from_camera(&cam); /// let mut ctrl = CameraController::from_camera(&cam);
+793
View File
@@ -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);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
//! //!
//! View-projection frustum representation and plane extraction, for frustum culling (Phase 3, //! 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 //! 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 //! 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 //! 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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, /// 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. /// 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. /// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
#[test] #[test]
fn demo_camera_sees_all_primitives() { fn demo_camera_sees_all_primitives() {
use crate::resources::CameraController; use crate::camera::CameraController;
let mut ctrl = CameraController::default(); let mut ctrl = CameraController::default();
ctrl.yaw = 0.6; ctrl.yaw = 0.6;
ctrl.pitch = 0.35; ctrl.pitch = 0.35;
+2 -2
View File
@@ -9,24 +9,24 @@
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`. //! - `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). //! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
pub mod bloom;
pub mod context; pub mod context;
pub mod frame; pub mod frame;
pub mod frustum; pub mod frustum;
pub mod geometry; pub mod geometry;
pub mod hdr; pub mod hdr;
pub mod input;
pub mod lod; pub mod lod;
pub mod renderer; pub mod renderer;
pub mod shadow; pub mod shadow;
pub mod transform; pub mod transform;
// Re-exports // Re-exports
pub use bloom::BloomConfig;
pub use context::Context; pub use context::Context;
pub use frame::Frame; pub use frame::Frame;
pub use frustum::Frustum; pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError}; pub use geometry::{BBox, Geometry, GeometryError};
pub use hdr::ToneMapper; pub use hdr::ToneMapper;
pub use input::InputState;
pub use lod::{lod_level, projected_radius_px}; pub use lod::{lod_level, projected_radius_px};
pub use renderer::Renderer; pub use renderer::Renderer;
pub use shadow::ShadowConfig; pub use shadow::ShadowConfig;
+109 -19
View File
@@ -19,7 +19,9 @@
//! texture state changes happen once per distinct material, not once per entity. //! 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. //! - **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::core::Context;
use crate::lights::{Lights, MAX_LIGHTS};
use crate::core::Frame; use crate::core::Frame;
use crate::core::Frustum; use crate::core::Frustum;
use crate::core::lod::{lod_level, projected_radius_px}; use crate::core::lod::{lod_level, projected_radius_px};
@@ -29,13 +31,14 @@ use crate::pipeline::{
}; };
use crate::resources::uniform::{ use crate::resources::uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE, 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, SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot,
}; };
use crate::resources::{ use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, CullUniforms, FrameUniforms, Material, Mesh, ObjectUniform, ShadowUniform,
}; };
use crate::scene::Scene; use crate::scene::Scene;
use crate::core::bloom::{BloomConfig, BloomPipeline};
use crate::core::hdr::ToneMapper; use crate::core::hdr::ToneMapper;
use crate::utils::conf::{ use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER, 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`. /// 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). /// When `None`, the main pass renders directly to the surface (LDR, zero overhead).
hdr: Option<HdrPipeline>, hdr: Option<HdrPipeline>,
/// 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<BloomPipeline>,
/// 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. /// 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). /// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve).
pipeline: wgpu::RenderPipeline, pipeline: wgpu::RenderPipeline,
/// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport). /// 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, 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). /// Bind group layout for the TM pass (reused on resize to recreate the bind group).
layout: wgpu::BindGroupLayout, layout: wgpu::BindGroupLayout,
/// Sampler for the HDR texture (linear, clamp). /// Sampler for the HDR texture (linear, clamp).
sampler: wgpu::Sampler, sampler: wgpu::Sampler,
/// Viewport width in pixels (for the TM uniform's pad.xy).
width: u32,
/// Viewport height in pixels.
height: u32,
} }
impl Renderer { impl Renderer {
@@ -187,6 +200,7 @@ impl Renderer {
height: u32, height: u32,
shadow_config: &super::shadow::ShadowConfig, shadow_config: &super::shadow::ShadowConfig,
hdr: Option<ToneMapper>, hdr: Option<ToneMapper>,
bloom_config: Option<BloomConfig>,
) -> Self { ) -> Self {
let queue: wgpu::Queue = context.queue.clone(); let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone(); let device: wgpu::Device = context.device.clone();
@@ -223,6 +237,7 @@ impl Renderer {
}); });
let identity_object = ObjectUniform { let identity_object = ObjectUniform {
model: glam::Mat4::IDENTITY, model: glam::Mat4::IDENTITY,
emissive: glam::Vec4::ZERO,
}; };
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object)); queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -425,9 +440,11 @@ impl Renderer {
label: Some("GPU world matrices"), label: Some("GPU world matrices"),
size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE, size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map. // 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 usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_SRC, | wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor { let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -575,12 +592,27 @@ impl Renderer {
viewport_height: height, viewport_height: height,
shadow_config: shadow_config.clone(), shadow_config: shadow_config.clone(),
hdr: None, 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 // 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. // `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
renderer.write_default_frame_uniforms(); renderer.write_default_frame_uniforms();
// Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled. // É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)); 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 renderer
} }
@@ -623,10 +655,24 @@ impl Renderer {
// Étape 20: recreate the HDR texture + bind group at the new size (D10). // Étape 20: recreate the HDR texture + bind group at the new size (D10).
if let Some(hdr) = &mut self.hdr { if let Some(hdr) = &mut self.hdr {
let (tex, view) = create_hdr_texture(&self.device, width, height); 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.texture = tex;
hdr.view = view; hdr.view = view;
hdr.bind_group = bg; 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 // camera must look along the light's **travel direction** (light → scene), i.e. the negation
// of the surface→light vector for directional lights. // of the surface→light vector for directional lights.
let dir = match light.light_type() { 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.x,
-light.position_dir.y, -light.position_dir.y,
-light.position_dir.z, -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) 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 r = self.shadow_config.scene_radius;
let target = Vec3::from(self.shadow_config.scene_center); 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. /// 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 /// 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. /// 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). // 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags).
self.write_frame_uniforms( self.write_frame_uniforms(
scene.camera(), scene.camera(),
@@ -1007,9 +1053,42 @@ impl Renderer {
} }
} }
// 8. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR // 8. Étape 22 (6.1): write the current exposure into the TM uniform buffer (per-frame,
// texture, applies exposure + tone mapping curve, and writes to the surface. // so live adjustments via keyboard take effect immediately).
// Only runs when HDR is active; the surface is the color target (no depth needed). // 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 { if let Some(hdr) = &self.hdr {
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("tone mapping pass"), label: Some("tone mapping pass"),
@@ -1321,6 +1400,12 @@ impl Renderer {
self.lod_enabled.set(enabled); 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 /// 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 /// 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 /// 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) (texture, view)
} }
/// Creates the tone mapping bind group: HDR texture (binding 0) + sampler (binding 1) + uniform (binding 2). /// Creates the tone mapping bind group + uniform buffer: HDR texture (binding 0) + sampler (binding 1)
/// The uniform contains exposure (1.0) and viewport size (pad.xy). /// + 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( fn create_hdr_bind_group(
device: &wgpu::Device, device: &wgpu::Device,
layout: &wgpu::BindGroupLayout, layout: &wgpu::BindGroupLayout,
@@ -1533,7 +1619,7 @@ fn create_hdr_bind_group(
texture: &wgpu::Texture, texture: &wgpu::Texture,
width: u32, width: u32,
height: u32, height: u32,
) -> wgpu::BindGroup { ) -> (wgpu::BindGroup, wgpu::Buffer) {
// Write the uniform: exposure = 1.0, pad.xy = viewport size. // Write the uniform: exposure = 1.0, pad.xy = viewport size.
// WGSL uniform layout: f32 at offset 0 (4B), vec3<f32> at offset 16 (16B, aligned to 16). // WGSL uniform layout: f32 at offset 0 (4B), vec3<f32> at offset 16 (16B, aligned to 16).
// Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0]. // 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 { let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("tm uniform"), label: Some("tm uniform"),
size: 32, size: 32,
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: true, mapped_at_creation: true,
}); });
{ {
@@ -1555,7 +1641,7 @@ fn create_hdr_bind_group(
drop(w); drop(w);
uniform_buffer.unmap(); uniform_buffer.unmap();
} }
device.create_bind_group(&wgpu::BindGroupDescriptor { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("tm bind group"), label: Some("tm bind group"),
layout, layout,
entries: &[ 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. /// 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. // 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 { HdrPipeline {
texture, texture,
view, view,
pipeline, pipeline,
bind_group, bind_group,
uniform_buffer,
layout, layout,
sampler, sampler,
width,
height,
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@
//! ## Query examples (in `AppHandler::update`) //! ## Query examples (in `AppHandler::update`)
//! ``` //! ```
//! # use winit::keyboard::{KeyCode, PhysicalKey}; //! # 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_held(KeyCode::KeyW) { /* move forward */ }
//! if input.key_pressed(KeyCode::Space) { /* jump */ } //! if input.key_pressed(KeyCode::Space) { /* jump */ }
//! let (dx, dy) = input.mouse_delta(); //! let (dx, dy) = input.mouse_delta();
+4
View File
@@ -29,8 +29,11 @@
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod app; pub mod app;
pub mod camera;
pub mod core; pub mod core;
pub mod handler; pub mod handler;
pub mod input;
pub mod lights;
pub mod mesh; pub mod mesh;
pub mod pipeline; pub mod pipeline;
pub mod prelude; pub mod prelude;
@@ -48,6 +51,7 @@ pub use crate::handler::AppHandler;
/// Re-export of the shadow mapping configuration for convenient top-level access. /// Re-export of the shadow mapping configuration for convenient top-level access.
/// Users tune shadow quality via `AppBuilder::with_shadow_config`. /// Users tune shadow quality via `AppBuilder::with_shadow_config`.
pub use crate::core::BloomConfig;
pub use crate::core::ShadowConfig; pub use crate::core::ShadowConfig;
/// Re-export of the tone mapping curve selector for convenient top-level access. /// Re-export of the tone mapping curve selector for convenient top-level access.
@@ -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 //! Defines the scene's global light list — directional, point and spot lights — and the
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each //! GPU-upload types (`Light`, `LightType`, `MAX_LIGHTS`).
//! 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).
//! //!
//! ## Rangement (no type flag) //! ## Rangement (no type flag)
//! Directional lights occupy indices `0..num_directional`; point lights occupy //! 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 //! [`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`. //! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
use crate::resources::uniform::{Light, MAX_LIGHTS};
use glam::{Vec3, Vec4}; 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 /// 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` /// (last). Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene`
/// API. /// API.
@@ -32,13 +32,11 @@ pub struct Lights {
} }
impl Lights { impl Lights {
/// Default = one white directional light along +Z (from surface toward light), no point or /// Default = one white directional light along +Z.
/// spot lights. This reproduces the historical single-light look when combined with a white
/// ambient.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
directional: vec![Light { 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, color: Vec4::ONE,
radius: Vec4::ZERO, radius: Vec4::ZERO,
dir_angle: 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 { pub fn len(&self) -> usize {
self.directional.len() + self.point.len() + self.spot.len() self.directional.len() + self.point.len() + self.spot.len()
} }
@@ -58,9 +56,7 @@ impl Lights {
self.len() == 0 self.len() == 0
} }
/// Returns the light at a **packed-array index** (directionals first, then point lights, then /// Returns the light at a **packed-array index**.
/// 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).
pub fn get(&self, index: usize) -> Option<&Light> { pub fn get(&self, index: usize) -> Option<&Light> {
let n_dir = self.directional.len(); let n_dir = self.directional.len();
if index < n_dir { if index < n_dir {
@@ -74,10 +70,7 @@ impl Lights {
self.spot.get(index - n_point) self.spot.get(index - n_point)
} }
/// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then /// Packs the lights into the GPU frame array.
/// 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).
pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) { pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) {
let empty = Light { let empty = Light {
position_dir: Vec4::ZERO, position_dir: Vec4::ZERO,
@@ -102,14 +95,12 @@ impl Lights {
} }
impl Default for Lights { impl Default for Lights {
/// `Lights::new()` — one white directional light along +Z (non-regression default).
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and /// Builds a directional [`Light`].
/// an intensity multiplier. Used by `Scene::add_directional_light`.
pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light { pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
Light { Light {
position_dir: dir.extend(0.0), 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 /// Builds a point [`Light`].
/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`.
pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light { pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light {
Light { Light {
position_dir: pos.extend(0.0), 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 /// Builds a spot [`Light`].
/// 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`.
pub fn spot_light( pub fn spot_light(
pos: Vec3, pos: Vec3,
dir: Vec3, dir: Vec3,
@@ -164,7 +152,7 @@ mod tests {
#[test] #[test]
fn into_frame_array_packs_directional_point_then_spot() { fn into_frame_array_packs_directional_point_then_spot() {
let mut lights = Lights::new(); // 1 directional let mut lights = Lights::new();
lights lights
.point .point
.push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0)); .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_dir, 1);
assert_eq!(n_point, 1); assert_eq!(n_point, 1);
assert_eq!(n_spot, 1); assert_eq!(n_spot, 1);
// Directional first, point second, spot third.
assert_eq!(array[0].color, Vec4::ONE); 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[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)); 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_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); assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6);
} }
@@ -194,28 +180,18 @@ mod tests {
assert!(MAX_LIGHTS >= 1); 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] #[test]
fn spot_cone_axis_alignment_is_positive() { 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 light_pos = Vec3::new(0.0, 0.0, 3.0);
let surface_point = Vec3::ZERO; let surface_point = Vec3::ZERO;
let cone_axis = (surface_point - light_pos).normalize(); // (0,0,-1) let cone_axis = (surface_point - light_pos).normalize();
let l = (light_pos - surface_point).normalize();
// Shader math: l points surface -> light; the cone test uses -l (light -> point). let to_point = -l;
let l = (light_pos - surface_point).normalize(); // (0,0,1)
let to_point = -l; // (0,0,-1)
let cone = to_point.dot(cone_axis); let cone = to_point.dot(cone_axis);
assert!( assert!(
(cone - 1.0).abs() < 1e-6, (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); assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
} }
} }
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
label: Some("object_uniform_layout"), label: Some("object_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry { entries: &[wgpu::BindGroupLayoutEntry {
binding: 0, binding: 0,
visibility: wgpu::ShaderStages::VERTEX, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform, ty: wgpu::BufferBindingType::Uniform,
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written // Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
+11 -1
View File
@@ -15,7 +15,17 @@
// Core types // Core types
pub use crate::core::geometry::{BBox, Geometry}; pub use crate::core::geometry::{BBox, Geometry};
pub use crate::core::transform::Transform; 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) // App / handler (already at crate root, re-exported here for convenience)
pub use crate::app::AppBuilder; pub use crate::app::AppBuilder;
+5
View File
@@ -19,6 +19,7 @@ use std::sync::Arc;
/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline and an optional /// 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 /// 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. /// using the same shader. Owns its texture bind group (group 2), built at construction.
#[derive(Clone)]
pub struct Material { pub struct Material {
/// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache. /// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache.
pub shader_id: String, 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 /// 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)`. /// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`.
pub texture_bind_group: wgpu::BindGroup, 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 { impl Material {
@@ -69,6 +73,7 @@ impl Material {
pipeline, pipeline,
texture, texture,
texture_bind_group, texture_bind_group,
emissive: [0.0, 0.0, 0.0, 0.0],
} }
} }
} }
+10 -20
View File
@@ -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 //! Defines the core GPU data types that flow through the rendering pipeline: **Mesh** (GPU geometry
//! vertex data, source of truth — re-exported here from `math` for convenience), **Vertex** (interleaved //! container with vertex/index buffers), **Material** (appearance descriptor pairing shader ID with
//! CPU-side per-attribute tuple, the GPU upload contract), **Mesh** (GPU geometry container with vertex/index //! a compiled RenderPipeline), **Texture** (GPU image + sampler), and **Uniform** (Pod structs for
//! buffers), and **Material** (appearance descriptor pairing shader ID with a compiled RenderPipeline). //! uniform buffer uploads).
//! These are immutable after creation and consumed by Renderer for draw calls.
//! //!
//! ## Interaction with Other Modules //! Camera, Lights and Input are now top-level modules (`wsg::camera`, `wsg::lights`, `wsg::input`).
//! - `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.
pub mod camera;
pub mod lights;
pub mod material; pub mod material;
pub mod mesh; pub mod mesh;
pub mod texture; pub mod texture;
@@ -21,19 +14,16 @@ pub mod uniform;
pub mod vertex; pub mod vertex;
// Re-exports // Re-exports
pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights;
pub use material::Material; pub use material::Material;
pub use mesh::{LodMode, Mesh, PackError}; pub use mesh::{LodMode, Mesh, PackError};
pub use texture::{Texture, TextureError}; pub use texture::{Texture, TextureError};
pub use uniform::{ pub use uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot, 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, FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, LodRow, LodTable,
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform,
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot, TRANSFORM_SLOT_SIZE, TransformSlot,
}; };
pub use vertex::Vertex; pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes // Convenience re-export of Geometry (Step 8, D2)
// from `wsg_lib::resources::Geometry` without importing `math` separately.
pub use crate::core::Geometry; pub use crate::core::Geometry;
+21 -43
View File
@@ -27,55 +27,33 @@ pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64
/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation). /// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation).
pub const MAX_LIGHTS: usize = 8; pub const MAX_LIGHTS: usize = 8;
/// A single light, stored in the per-frame uniform array. One struct serves all three types; the /// A single light, stored in the per-frame uniform array (64 bytes, std140).
/// *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`).
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, PartialEq)] #[derive(Clone, Copy, Pod, Zeroable, PartialEq)]
pub struct Light { pub struct Light {
/// xyz = direction from surface toward the light (directional) or world position (point/spot); /// xyz = direction (directional) or position (point/spot); w = 0.
/// w = 0.
pub position_dir: Vec4, pub position_dir: Vec4,
/// rgb = color; a = intensity (multiplier). /// rgb = color; a = intensity.
pub color: Vec4, pub color: Vec4,
/// x = attenuation radius (point/spot lights); 0 for directional. /// x = attenuation radius.
pub radius: Vec4, pub radius: Vec4,
/// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle. /// xyz = cone axis; w = cos half-angle (spot only).
/// Zero for directional and point lights.
pub dir_angle: Vec4, pub dir_angle: Vec4,
} }
/// The runtime-disambiguated type of a [`Light`] (Step 14, D6). Not stored in the struct (the array /// The runtime-disambiguated type of a [].
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
/// which must reject point lights (cubemap shadows are out of scope).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LightType { pub enum LightType {
/// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the /// Directional light (infinitely distant).
/// light, `radius.x` = 0, `dir_angle` = 0.
Directional, Directional,
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation /// Point (omnidirectional).
/// radius, `dir_angle` = 0.
Point, Point,
/// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in /// Spot (cone).
/// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle.
Spot, Spot,
} }
impl Light { impl Light {
/// Classifies the light for CPU-side logic. Query order is significant because a spot light /// Classifies the light for CPU-side logic.
/// 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`].
pub fn light_type(&self) -> LightType { pub fn light_type(&self) -> LightType {
if self.dir_angle.w > 0.0 { if self.dir_angle.w > 0.0 {
LightType::Spot LightType::Spot
@@ -87,14 +65,8 @@ impl Light {
} }
} }
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data + options.
/// /// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data.
/// 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).
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)] #[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms { 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)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)] #[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct ObjectUniform { pub struct ObjectUniform {
/// Model matrix (object → world space). Offset 0. /// Model matrix (object → world space). Offset 0.
pub model: Mat4, 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 /// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
@@ -517,9 +493,11 @@ mod tests {
#[test] #[test]
fn object_uniform_layout_matches_wgsl() { fn object_uniform_layout_matches_wgsl() {
assert_eq!(size_of::<ObjectUniform>(), 64); // Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive).
assert_eq!(size_of::<ObjectUniform>(), 80);
assert_eq!(align_of::<ObjectUniform>(), 16); assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0); assert_eq!(offset_of!(ObjectUniform, model), 0);
assert_eq!(offset_of!(ObjectUniform, emissive), 64);
} }
#[test] #[test]
+28 -13
View File
@@ -17,7 +17,7 @@
use crate::core::{Geometry, Transform}; use crate::core::{Geometry, Transform};
use crate::pipeline::PipelineCache; 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 crate::scene::Entity;
use glam::Vec3; use glam::Vec3;
use std::cell::RefCell; 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 /// 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 { pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera &mut self.camera
} }
@@ -468,15 +468,15 @@ impl Scene {
color: [f32; 3], color: [f32; 3],
intensity: f32, intensity: f32,
) -> Result<(), String> { ) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS { if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!( return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.", "Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS crate::lights::MAX_LIGHTS
)); ));
} }
self.lights self.lights
.directional .directional
.push(crate::resources::lights::directional_light( .push(crate::lights::directional_light(
dir, color, intensity, dir, color, intensity,
)); ));
Ok(()) Ok(())
@@ -492,15 +492,15 @@ impl Scene {
intensity: f32, intensity: f32,
radius: f32, radius: f32,
) -> Result<(), String> { ) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS { if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!( return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.", "Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS crate::lights::MAX_LIGHTS
)); ));
} }
self.lights self.lights
.point .point
.push(crate::resources::lights::point_light( .push(crate::lights::point_light(
pos, color, intensity, radius, pos, color, intensity, radius,
)); ));
Ok(()) Ok(())
@@ -520,13 +520,13 @@ impl Scene {
radius: f32, radius: f32,
half_angle: f32, half_angle: f32,
) -> Result<(), String> { ) -> Result<(), String> {
if self.lights.len() >= crate::resources::MAX_LIGHTS { if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!( return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.", "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, pos, dir, color, intensity, radius, half_angle,
)); ));
Ok(()) Ok(())
@@ -603,6 +603,21 @@ impl Scene {
Ok(id.to_string()) 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. /// 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 /// 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). /// material_id is needed here (DRAFT Step 7.3).
@@ -857,12 +872,12 @@ mod tests {
scene scene
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0) .add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
.unwrap(); .unwrap();
while scene.lights().len() < crate::resources::MAX_LIGHTS { while scene.lights().len() < crate::lights::MAX_LIGHTS {
scene scene
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0) .add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
.unwrap(); .unwrap();
} }
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS); assert_eq!(scene.lights().len(), crate::lights::MAX_LIGHTS);
assert!( assert!(
scene scene
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5) .add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
+56
View File
@@ -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<f32>,
@location(0) uv: vec2<f32>,
};
// 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<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct BlurUniforms {
direction: vec2<f32>,
radius: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> bu: BlurUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
const W: array<f32, 5> = array<f32, 5>(
0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162
);
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
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<f32>(sum, 1.0);
}
+47
View File
@@ -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<f32>,
@location(0) uv: vec2<f32>,
};
// 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<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct CompositeUniforms {
intensity: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> cu: CompositeUniforms;
@group(0) @binding(1) var hdr_tex: texture_2d<f32>;
@group(0) @binding(2) var hdr_sampler: sampler;
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@group(0) @binding(4) var bloom_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let hdr = textureSample(hdr_tex, hdr_sampler, in.uv).rgb;
let bloom = textureSample(bloom_tex, bloom_sampler, in.uv).rgb;
return vec4<f32>(hdr + bloom * cu.intensity, 1.0);
}
+50
View File
@@ -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<f32>,
@location(0) uv: vec2<f32>,
};
// 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<f32>(-1.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, 1.0);
}
case 1u {
out.pos = vec4<f32>(3.0, -1.0, 0.0, 1.0);
out.uv = vec2<f32>(2.0, 1.0);
}
default {
out.pos = vec4<f32>(-1.0, 3.0, 0.0, 1.0);
out.uv = vec2<f32>(0.0, -1.0);
}
}
return out;
}
struct ThresholdUniforms {
threshold: f32,
knee: f32,
pad: vec4<f32>,
};
@group(0) @binding(0) var<uniform> tmu: ThresholdUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_sampler: sampler;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let color = textureSample(src_tex, src_sampler, in.uv).rgb;
let lum = dot(color, vec3<f32>(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<f32>(color * contrib, 1.0);
}
+9 -4
View File
@@ -96,7 +96,8 @@ struct FrameUniforms {
}; };
struct ObjectUniform { struct ObjectUniform {
model: mat4x4<f32>, model: mat4x4<f32>, // 64 bytes (offset 0)
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
}; };
@group(0) @binding(0) var<uniform> frame: FrameUniforms; @group(0) @binding(0) var<uniform> frame: FrameUniforms;
@@ -147,9 +148,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let texel = textureSample(diffuse_texture, texture_sampler, in.uv); let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb; 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) { if (frame.options.x != 0u) {
return vec4<f32>(base, in.color.a); let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
return vec4<f32>(base + emissive_contrib, in.color.a);
} }
let n = normalize(in.normal); let n = normalize(in.normal);
@@ -203,7 +205,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
} }
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n); let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
return vec4<f32>(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<f32>(lit + emissive_contrib, in.color.a);
} }
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position // Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
+13 -1
View File
@@ -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. /// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled.
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl"); 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 /// 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 /// 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. /// 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 /// 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 /// so upper layers can address the shadow light safely, Step 14 D7). Also used as the no-caster
/// sentinel for `FrameUniforms.shadow_light_index`. /// 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. /// Default application title displayed in the OS taskbar/window decorations.
pub const APP_DEFAULT_TITLE: &str = "WSG App"; pub const APP_DEFAULT_TITLE: &str = "WSG App";
+66
View File
@@ -116,3 +116,69 @@ fn tonemap_shader_is_valid_wgsl() {
"the three entry points are expected" "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"]);
}