refactor examples
This commit is contained in:
@@ -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)*
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 | ⬜ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user