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 |
|
||||
|------|-------|
|
||||
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
|
||||
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) |
|
||||
| `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) |
|
||||
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Step 15, Phase 3) — `render_scene` + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL `select` argument order — and verified by GPU readback, D14). User doc: [gpu-driven.md](docs/user/gpu-driven.md) · spec: [ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
|
||||
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader |
|
||||
## Forces
|
||||
|
||||
Note: `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is the **single** shader the library ships — flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`). See the `cube` example (3D, lit) and the `simple` example (2D, unlit).
|
||||
| Force | Détail |
|
||||
|-------|--------|
|
||||
| **Zéro wgpu dans votre code** | L'API déclarative (`AppBuilder` + `AppHandler`) encapsule tout |
|
||||
| **Opt-in = zéro coût** | Un effet non activé n'alloue rien, n'exécute rien |
|
||||
| **Features Cargo** | Ne compilez que les primitives/import dont vous avez besoin |
|
||||
| **Un seul shader** | Le `standard` shader (Phong) couvre 90 % des cas ; mode unlit pour la 2D |
|
||||
| **GPU-driven** | Le CPU envoie des transforms, le GPU fait le reste (matrices, culling, draws) |
|
||||
|
||||
## What it does
|
||||
|
||||
### Declarative workflow (recommended)
|
||||
|
||||
Register your scene once in `setup()`, then let `App` handle the window lifecycle, events,
|
||||
input and frame presentation — **without importing wgpu or winit**. This is the workflow of
|
||||
the `simple`, `cube`, `demo`, `shadow_test` and `spot_test` examples (excerpt below is `simple`):
|
||||
## Quickstart
|
||||
|
||||
```rust
|
||||
use wsg_lib::prelude::*;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
use wsg_lib::AppHandler;
|
||||
|
||||
struct MonQuad;
|
||||
struct MaScene;
|
||||
|
||||
impl AppHandler for MonQuad {
|
||||
impl AppHandler for MaScene {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.renderer_mut().set_unlit(true); // 2D flat (optional)
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0, 1.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material
|
||||
app.scene.add_entity("quad", "quad_mesh").unwrap();
|
||||
app.scene
|
||||
.create_material("mat", "standard", None)
|
||||
.unwrap();
|
||||
|
||||
// Un cube lit par Phong, posé au-dessus d'un plan
|
||||
app.scene
|
||||
.create_mesh("cube", cube(1.0), Some("mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_entity("my_cube", "cube")
|
||||
.unwrap();
|
||||
|
||||
app.scene
|
||||
.create_mesh("ground", plane(10.0, 10.0, 1, 1), Some("mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_entity("floor", "ground")
|
||||
.unwrap();
|
||||
}
|
||||
// `update(&mut self, app)` — your per-frame logic (empty default).
|
||||
// `render(&mut self, app, frame)` — default: `app.render_scene(frame.view())`,
|
||||
// the whole scene is drawn automatically in one pass per frame.
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
||||
app.run(MonQuad)
|
||||
fn main() -> Result<(), WsgError> {
|
||||
let mut app = AppBuilder::new()
|
||||
.title("Ma scène WSG")
|
||||
.with_hdr(ToneMapper::Aces) // optionnel : HDR + tone mapping
|
||||
.build()?;
|
||||
app.run(MaScene);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
> API note: `Scene` methods currently return `Result<_, String>` — typed-error unification is
|
||||
> on the roadmap. `Scene::create_mesh(id, geometry, material)` takes a CPU `Geometry` (source of
|
||||
> truth, retained as `Arc<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
|
||||
[dependencies]
|
||||
wsg-lib = { path = "/path/to/wsg/lib" }
|
||||
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder)
|
||||
winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton)
|
||||
wsg-lib = { path = "../lib" }
|
||||
pollster = { version = "1", features = ["macro"] }
|
||||
```
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| Build everything | `cargo build --workspace` |
|
||||
| Run the showcase (primitives, lights, shadows, orbital camera) | `cargo run -p wsg-lib --example demo` |
|
||||
| Run the 3D MVP example | `cargo run -p wsg-lib --example cube` |
|
||||
| Run the minimal example | `cargo run -p wsg-lib --example simple` |
|
||||
| Run the shadow / spot light showcases | `cargo run -p wsg-lib --example shadow_test` / `cargo run -p wsg-lib --example spot_test` |
|
||||
| Run the advanced (manual) example | `cargo run -p wsg-lib --example manual` |
|
||||
| Check everything (incl. examples) | `cargo check --all-targets` |
|
||||
```sh
|
||||
cargo run --example demo # le showcase complet (6 primitives, 3 lumières, ombres, HDR)
|
||||
```
|
||||
|
||||
The `demo` example is the showcase: one of each primitive, procedural textures, three lights, a shadow-casting light and a live orbital camera. `simple` is the minimal declarative app (a colored quad, unlit); `cube` is the 3D MVP (a rotating Phong-lit, textured cube); `shadow_test` and `spot_test` isolate the shadow and spot-light systems; `manual` is the reference for the low-level workflow. All of them except `manual` use the declarative path and draw a scene **without importing wgpu**.
|
||||
## Fonctionnalités
|
||||
|
||||
| Catégorie | Ce qui est disponible |
|
||||
|-----------|----------------------|
|
||||
| **Géométrie** | 6 primitives procédurales + import OBJ + `Geometry` custom |
|
||||
| **Rendu** | Phong (lit), unlit (2D flat), HDR + tone mapping (ACES/Reinhard) |
|
||||
| **Lumières** | Directional, point, spot (8 max) + ambient |
|
||||
| **Ombres** | Shadow mapping (directional/spot), slope-scaled bias, PCF |
|
||||
| **LOD** | Décimation quadric auto, hystérésis, 1 buffer multi-niveaux |
|
||||
| **GPU-driven** | Compute pass (matrices + culling) → indirect draws |
|
||||
| **Caméra** | Orbitale (drag/zoom/reset) + presets (front/side/top) |
|
||||
| **Input** | Clavier (pressed/held/released), souris (delta, scroll, boutons) |
|
||||
| **Textures** | RGBA8 (de bytes, de fichier, placeholder blanc) |
|
||||
|
||||
## Documentation
|
||||
|
||||
Three layers (user docs and API reference in **English**; technical docs in **French**):
|
||||
| Où | Quoi |
|
||||
|----|------|
|
||||
| [docs/user/](docs/user/README.md) | **Guide utilisateur** (EN) — comment utiliser l'API, pas à pas |
|
||||
| [docs/tech/](docs/tech/ARCHI_APP.md) | **Architecture interne** (FR) — décisions, specs, cibles |
|
||||
| [docs/ROADMAP.md](docs/ROADMAP.md) | Feuille de route (phases 1-5 ✅, phase 6 en cours) |
|
||||
| [docs/PLAN.md](docs/PLAN.md) | Livre de recette (historique des étapes) |
|
||||
| `cargo doc -p wsg-lib --no-deps` | **Référence API** (rustdoc, 100 % couvert) |
|
||||
|
||||
**User documentation — [docs/user](docs/user/README.md)** (how to use the API, no wgpu knowledge needed):
|
||||
- [Quickstart](docs/user/quickstart.md) — first window, first object, in ~30 lines
|
||||
- [Meshes](docs/user/meshes.md) · [Materials & textures](docs/user/materials.md) · [Lights](docs/user/lights.md)
|
||||
- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md)
|
||||
## Exemples
|
||||
|
||||
**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture):
|
||||
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ **Current** — facade (`App`/`AppHandler`) and GPU-driven two-pass pipeline (implemented in Phase 3, 2026-09-22, with the documented deviations); only the future double-buffering notes remain target.
|
||||
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. ✅ **Current** — implemented in ROADMAP Phase 3 (2026-09-22, Étape 17, decisions D1–D14); deviations from the original spec are noted in the document.
|
||||
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 **Target** — material batching.
|
||||
- [ARCHI_ARENES](docs/tech/ARCHI_ARENES.md) — 🎯 **Target/deferred** — slotmap generational handles; String IDs are used today.
|
||||
- [FRAME_LOOP](docs/tech/FRAME_LOOP.md) — frame lifetime and resource persistence. ✅ **Current** — implemented.
|
||||
| Exemple | Ce qu'il montre |
|
||||
|---------|----------------|
|
||||
| `demo` | Le showcase : 6 primitives, 3 lumières, ombres, HDR, LOD, caméra orbitale |
|
||||
| `cube` | MVP 3D : un cube lit par Phong, texture checkerboard |
|
||||
| `simple` | Minimal : un quad coloré en mode unlit (2D) |
|
||||
| `shadow_test` | Ombres portées isolées |
|
||||
| `spot_test` | Spotlight isolé |
|
||||
| `import` | Import de fichier OBJ (feature `import-obj`) |
|
||||
| `manual` | Workflow low-level (Context/Renderer/PipelineCache, sans App) |
|
||||
|
||||
**API reference** — full rustdoc: `cargo doc -p wsg-lib --no-deps` (every public type is documented).
|
||||
## Features Cargo
|
||||
|
||||
## Roadmap
|
||||
```toml
|
||||
# Default : toutes les primitives
|
||||
wsg-lib = { path = "../lib" }
|
||||
|
||||
1. ✅ **Scene auto-rendering** — `App::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.)
|
||||
2. ✅ **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, then indirect draws (see ARCHI_CPU_GPU). *(Done 2026-09-22 — see item 17. Deviation from the original spec: one indirect draw **per slot** rather than a single fused draw, D1 — see ARCHI_CPU_GPU.)*
|
||||
3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering.
|
||||
4. ✅ **Real 3D pipeline (MVP reached)** — MVP uniforms + camera support in the vertex shader. *(Engine plumbing done 2026-09-16; Step 5, 2026-09-17: `standard` wired into the `cube` example — a unit cube lit (Phong) and spinning, rendered automatically by `App::render_scene`. Removal of `basic`: flat 2D = unlit variant of `standard` via `Renderer::set_unlit`.)*
|
||||
5. **Typed resource handles** — keep String IDs for the MVP (current design, source of truth in `Scene`); slotmap-based generational handles (`ARCHI_ARENES.md`) are deferred to a later performance pass.
|
||||
6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors.
|
||||
7. ✅ **CPU geometry storage (Step 8)** — `Mesh` retains a shared `Arc<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.)
|
||||
9. ✅ **Window resize (Step 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.)
|
||||
10. ✅ **Multi-lighting (Step 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.)
|
||||
11. ✅ **Spot lights (Step 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.)
|
||||
12. ✅ **Shadows — shadow mapping (Step 14, Phase 4.2, optional)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.)
|
||||
13. ✅ **Procedural primitive meshes (Step 15.A)** — `math::primitives` provides drop-in `Geometry` generators (`cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) with positions + per-face/smooth normals + UVs + indices. Re-exported at `math::*`. The `cube` and `spot_test` examples now reuse `math::cube(1.0)` (the `cube_geometry` helper was factored away; `shadow_test` keeps its generic `box_geometry`). (Done 2026-09-20; 6 unit tests.)
|
||||
14. ✅ **Unified input (Step 15.B)** — `core::input::InputState` gives cross-frame **pressed/held/released** semantics for keyboard (physical `KeyCode`) and mouse (buttons, position, per-frame delta, wheel scroll), rotated by `begin_frame`/`end_frame` around `AppHandler::update`. `App` exposes it as a public `input` field, fed from winit `WindowEvent`s and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.)
|
||||
15. ✅ **Orbital camera + final demo (Step 15.C)** — `resources::CameraController` (yaw/pitch/distance/target, `apply_to` writes into a `Camera`, drag-orbit + wheel-zoom + clamps) drives the new `demo` example: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom / `R` reset / `1`/`2`/`3` view presets. Run with `cargo run -p wsg-lib --example demo`. (Done 2026-09-20; runtime-verified headless.)
|
||||
16. ✅ **User documentation (Step 16, Phase 5)** — `docs/user/` (quickstart, meshes, materials, lights, shadows, camera & input, examples) written in English and cross-linked to each other, to the tech docs and to rustdoc; tech docs interlinked with their stale status banners refreshed; this README re-anchored (declarative workflow = recommended, manual = advanced, `demo` = showcase, pollster 1.x). (Done 2026-07-19.)
|
||||
17. ✅ **GPU-driven rendering (Step 15, Phase 3.1/3.2/3.3)** — world matrices and indirect draw args move from CPU to GPU. `shaders/gpu_driven.wgsl` (two compute entry points, `compute_matrices` + `cull`, one module, explicit 3-group layout) runs before the render passes over a fixed 256-slot table (the world-matrix buffer is bound to the `uniform` object slot; WebGPU caps a `uniform` binding at 64 KB and a `uniform` offset at 256 B, so each matrix slot is padded to 256 B and 256 × 256 B = 64 KB is the max); `render_scene` and the shadow pass become **100 % indirect** (one indirect draw per active slot, culled/inactive slots are no-ops), and the per-entity CPU draw loop is gone. New `math::Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) + `BBox` on `Geometry`; `TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms` Pod mirrors of the WGSL structs. Frustum **culling is off by default** (non-regression) and opt-in via `AppBuilder::with_culling(true)` / `Renderer::set_culling(bool)`; the `demo` enables it. The object bind-group layout is now dynamic so every entity shares one GPU matrix buffer via per-slot offsets. (Done 2026-09-22; WGSL + frustum + scene-slot tests, 57 lib / 3 WGSL / 3 doctests all green. **Culling fix 2026-09-22**: the WGSL `select` arguments had been written HLSL-style, silently zeroing the draw count of every *visible* entity — a black window; fixed and verified by GPU readback, see D14 in ARCHI_CPU_GPU.md.)
|
||||
# Minimal : juste le cube
|
||||
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] }
|
||||
|
||||
# Avec import OBJ
|
||||
wsg-lib = { path = "../lib", features = ["import-obj"] }
|
||||
```
|
||||
|
||||
| Feature | Active |
|
||||
|---------|--------|
|
||||
| `prim-cube`, `prim-plane`, `prim-sphere`, `prim-cylinder`, `prim-cone`, `prim-torus` | Primitives |
|
||||
| `all-prims` (default) | Les 6 primitives |
|
||||
| `import-obj` | Parser Wavefront OBJ |
|
||||
| `import-gltf` | glTF (stub) |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo build --workspace # tout
|
||||
cargo test --workspace # 116 tests
|
||||
cargo check --all-targets # vérification rapide
|
||||
cargo run -p wsg-lib --example demo # lancer le showcase
|
||||
```
|
||||
|
||||
## Projet
|
||||
|
||||
- **Langage** : Rust 2024
|
||||
- **Dépendances** : wgpu 30, winit 0.30, glam (math)
|
||||
- **Pas publié sur crates.io** (dépendance par path)
|
||||
- **Status** : MVP complet (phases 1-5 ✅), post-MVP en cours (phase 6)
|
||||
|
||||
---
|
||||
|
||||
*Documentation détaillée (architecture, status, API reference, workflow manuel) : [README_DETAILS.md](README_DETAILS.md)*
|
||||
|
||||
@@ -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 :
|
||||
- `math/` supprimé — types (`Geometry`, `Transform`, `BBox`, `Frustum`, LOD) déplacés vers `core/`
|
||||
- `primitives.rs` (monolith) → `mesh/primitives/` (6 fichiers, un par famille)
|
||||
- Nouveau module `wsg::mesh` : point d'entrée unique pour les sources de géométrie
|
||||
- Features par primitive (`prim-cube`, `prim-sphere`, …) — zéro coût si désactivées
|
||||
- Parser OBJ intégré (zéro dep externe), wrapper glTF en stub
|
||||
- `prelude.rs` pour un glob import confortable
|
||||
- Re-exports top-level : `Geometry`, `Transform`, `BBox`
|
||||
## Objectif
|
||||
|
||||
## Structure finale
|
||||
Ajouter un effet **bloom** : les zones très brillantes de la scène (emissive > 1.0, spéculaires,
|
||||
overbright lighting) diffusent une lueur vers les zones voisines. C'est l'effet "glow" qui rend
|
||||
les néons et les sources de lumière visuellement impactants.
|
||||
|
||||
Le bloom est un **post-process** qui opère sur la texture HDR, entre le rendu de la scène et le
|
||||
tone mapping. Il est **opt-in** (`AppBuilder::with_bloom(...)`) et n'a **zéro coût** quand
|
||||
désactivé (aucune texture/pipeline allouée).
|
||||
|
||||
---
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
lib/src/
|
||||
├── lib.rs # + pub mod mesh, pub mod prelude, re-exports Geometry/Transform/BBox
|
||||
├── prelude.rs # glob re-exports (types quotidiens)
|
||||
├── core/
|
||||
│ ├── mod.rs # + geometry, transform, frustum, lod
|
||||
│ ├── geometry.rs # ← déplacé de math/
|
||||
│ ├── transform.rs # ← déplacé de math/
|
||||
│ ├── frustum.rs # ← déplacé de math/
|
||||
│ ├── lod.rs # ← déplacé de math/
|
||||
│ ├── renderer.rs
|
||||
│ ├── shadow.rs
|
||||
│ ├── hdr.rs
|
||||
│ ├── context.rs
|
||||
│ ├── frame.rs
|
||||
│ └── input.rs
|
||||
├── mesh/
|
||||
│ ├── mod.rs # re-exports flat (cube, plane, sphere, …, load_obj, …)
|
||||
│ ├── primitives/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── cube.rs
|
||||
│ │ ├── plane.rs
|
||||
│ │ ├── sphere.rs # uv_sphere + icosphere
|
||||
│ │ ├── cylinder.rs
|
||||
│ │ ├── cone.rs
|
||||
│ │ └── torus.rs
|
||||
│ └── import/
|
||||
│ ├── mod.rs # MeshImportError
|
||||
│ ├── obj.rs # parser OBJ (zéro dep)
|
||||
│ └── gltf.rs # stub (wrapper gltf crate à implémenter)
|
||||
├── app.rs
|
||||
├── handler.rs
|
||||
├── pipeline/
|
||||
├── resources/
|
||||
├── scene/
|
||||
└── utils/
|
||||
Scene render → HDR texture (Rgba16Float, full res)
|
||||
│
|
||||
├─[bloom actif?]─→ 1. Threshold (half res) : extrait les pixels > threshold
|
||||
│ 2. Blur H (half res) : Gaussian 9 taps
|
||||
│ 3. Blur V (half res) : Gaussian 9 taps
|
||||
│ 4. Composite (full res) : HDR += bloom × intensity
|
||||
│
|
||||
▼
|
||||
TM pass → surface
|
||||
```
|
||||
|
||||
## Features (Cargo.toml)
|
||||
Quand bloom est désactivé : `Scene → HDR → TM → surface` (comme aujourd'hui, zéro overhead).
|
||||
|
||||
| Feature | Default | Fournit |
|
||||
|---------|---------|---------|
|
||||
| `prim-cube` | ✅ (via all-prims) | `cube(size)` |
|
||||
| `prim-plane` | ✅ | `plane(w, d, sx, sz)` |
|
||||
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` |
|
||||
| `prim-cylinder` | ✅ | `cylinder(…)` |
|
||||
| `prim-cone` | ✅ | `cone(…)` |
|
||||
| `prim-torus` | ✅ | `torus(…)` |
|
||||
| `all-prims` | ✅ (default) | les 6 ci-dessus |
|
||||
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` |
|
||||
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) |
|
||||
**4 passes fullscreen** supplémentaires (seulement si HDR + bloom actifs).
|
||||
|
||||
---
|
||||
|
||||
## Composants
|
||||
|
||||
### `BloomConfig` (pub, dans `core/bloom.rs`)
|
||||
|
||||
```rust
|
||||
pub struct BloomConfig {
|
||||
/// Seuil de luminance (en unités HDR linéaires). Au-dessus → contribue au bloom.
|
||||
/// Défaut : 1.0 (seul ce qui dépasse 1.0 "bloom" — les emissives > 1.0, les spéculaires).
|
||||
pub threshold: f32,
|
||||
/// Intensité du bloom (multiplicateur sur le résultat du blur). Défaut : 0.8.
|
||||
pub intensity: f32,
|
||||
/// Rayon du blur en pixels (à la résolution half-res). Défaut : 4.0.
|
||||
pub radius: f32,
|
||||
}
|
||||
impl Default for BloomConfig { /* threshold=1.0, intensity=0.8, radius=4.0 */ }
|
||||
```
|
||||
|
||||
### `BloomPipeline` (interne, dans `core/bloom.rs`)
|
||||
|
||||
```rust
|
||||
struct BloomPipeline {
|
||||
/// Texture half-res pour le bloom (Rgba16Float).
|
||||
bright_texture: wgpu::Texture,
|
||||
bright_view: wgpu::TextureView,
|
||||
/// Texture half-res pour le blur ping-pong (2nd buffer).
|
||||
blur_texture: wgpu::Texture,
|
||||
blur_view: wgpu::TextureView,
|
||||
/// Sampler linear pour le blur.
|
||||
sampler: wgpu::Sampler,
|
||||
/// Pipeline threshold (fullscreen → half-res).
|
||||
threshold_pipeline: wgpu::RenderPipeline,
|
||||
/// Pipeline blur (fullscreen half-res, direction via uniform).
|
||||
blur_pipeline: wgpu::RenderPipeline,
|
||||
/// Pipeline composite (full-res: HDR += bloom).
|
||||
composite_pipeline: wgpu::RenderPipeline,
|
||||
/// Bind groups pré-alloués.
|
||||
threshold_bg: wgpu::BindGroup,
|
||||
blur_bg_a: wgpu::BindGroup, // reads bright, writes blur
|
||||
blur_bg_b: wgpu::BindGroup, // reads blur, writes bright (ping-pong)
|
||||
composite_bg: wgpu::BindGroup, // reads HDR + bright
|
||||
/// Uniform buffer pour le blur (direction + radius).
|
||||
blur_uniform: wgpu::Buffer,
|
||||
/// Uniform buffer pour le threshold (threshold value).
|
||||
threshold_uniform: wgpu::Buffer,
|
||||
/// Half-res dimensions.
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
```
|
||||
|
||||
### Shaders (3 fichiers WGSL)
|
||||
|
||||
#### `bloom_threshold.wgsl`
|
||||
- Vertex : fullscreen triangle
|
||||
- Fragment : lit la texture HDR (full res), calcule la luminance, sort `color × smoothstep(threshold, threshold+knee, lum)` ou `max(color - threshold, 0)` si `lum > threshold`, sinon `0`
|
||||
- Écrit dans la texture half-res
|
||||
|
||||
#### `bloom_blur.wgsl`
|
||||
- Vertex : fullscreen triangle (à la résolution half-res)
|
||||
- Fragment : 9-tap Gaussian séparable. L'offset est `texel_size × radius × i` dans la direction donnée par l'uniform.
|
||||
- Uniform : `vec2<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écision |
|
||||
|---|----------|
|
||||
| D1 | Un seul crate `wsg-lib` — pas de crate séparée |
|
||||
| D2 | Feature par famille de primitives |
|
||||
| D3 | Feature par format d'import |
|
||||
| D4 | Pas de trait `MeshSource` — fonctions qui retournent `Geometry` |
|
||||
| D5 | `Geometry::new()` / `Scene::add_mesh()` restent en core |
|
||||
| D6 | Module `wsg::mesh` au même niveau que `core`, `app` |
|
||||
| D7 | `primitives/` un fichier par famille |
|
||||
| D8 | `import/` un fichier par format |
|
||||
| D9 | Import retourne `Result<_, MeshImportError>` |
|
||||
| D10 | `default = ["all-prims"]` |
|
||||
| D11 | `all-prims` = les 6 primitives |
|
||||
| D12 | `math` disparaît — types re-exportés par `core` / top-level |
|
||||
| # | Décision | Justification |
|
||||
|---|----------|---------------|
|
||||
| D1 | 4 passes (threshold + blur H + blur V + composite) | Bonne qualité/performances. Un seul niveau de mip suffit pour un bloom "soft" |
|
||||
| D2 | Résolution half-res pour le bloom | Standard. Le blur à half-res est 4× moins coûteux et le résultat upscalé par le sampler linear est lisse |
|
||||
| D3 | Soft-knee threshold (pas un cutoff dur) | `soft/(soft+knee)` donne une transition douce, pas d'aliasing au seuil |
|
||||
| D4 | Composite via ping-pong full-res (3ème texture) | Évite le conflit read/write sur la même texture dans une même pass |
|
||||
| D5 | Bloom seulement si HDR actif | Le bloom opère en espace linéaire HDR. Sans HDR, les valeurs sont déjà clampées [0,1] → pas de "bright" à extraire |
|
||||
| D6 | `BloomConfig` avec 3 champs (threshold, intensity, radius) | Minimum utile. Pas de multi-mip, pas de directional bloom pour MVP |
|
||||
| D7 | Sampler `Linear` + `ClampToEdge` pour le blur | Les bords ne doivent pas sampler hors-texture (artefacts noirs) |
|
||||
| D8 | Le TM pass lit la texture composite (si bloom) ou la HDR (si pas bloom) | Le TM est agnostique de la source — il lit juste une texture full-res Rgba16Float |
|
||||
| D9 | Uniform threshold : 16 bytes (threshold + knee + 2 pad) | Aligned 16, simple |
|
||||
| D10 | Uniform blur : 16 bytes (direction vec2 + radius + pad) | Aligned 16 |
|
||||
| D11 | Uniform composite : 16 bytes (intensity + 3 pad) | Aligned 16 |
|
||||
|
||||
---
|
||||
|
||||
## Fichiers modifiés / créés
|
||||
|
||||
| Fichier | Changement |
|
||||
|---------|-----------|
|
||||
| `lib/src/core/bloom.rs` | **Nouveau** : `BloomConfig`, `BloomPipeline`, allocation + bind groups |
|
||||
| `lib/src/core/renderer.rs` | + `bloom: Option<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
|
||||
|
||||
- 107 unit tests (dont 7 tests OBJ parser)
|
||||
- 4 WGSL validation
|
||||
- 5 doctests
|
||||
- **Total : 116 tests, 0 failures**
|
||||
| Test | Vérifie |
|
||||
|------|---------|
|
||||
| `bloom_config_default` | threshold=1.0, intensity=0.8, radius=4.0 |
|
||||
| `bloom_requires_hdr` | `with_bloom` sans `with_hdr` → warning, bloom inactif |
|
||||
| `bloom_pipeline_allocates_half_res` | dimensions = (w/2, h/2) |
|
||||
| `bloom_zero_intensity_is_noop` | intensity=0 → composite = HDR (pas de changement) |
|
||||
| WGSL threshold | compile avec naga |
|
||||
| WGSL blur | compile avec naga |
|
||||
| WGSL composite | compile avec naga |
|
||||
|
||||
## Build vérifié
|
||||
---
|
||||
|
||||
- `cargo check` (default = all-prims) ✅
|
||||
- `cargo check --no-default-features --features "prim-cube"` ✅
|
||||
- `cargo check --features "import-obj,import-gltf"` ✅
|
||||
- `cargo check --examples --features "import-obj"` ✅
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] `cargo test` passe (tous tests existants + nouveaux)
|
||||
- [ ] `cargo run --example demo` : le glow sphere produit un halo visible
|
||||
- [ ] Sans bloom : rendu identique à avant (zéro régression)
|
||||
- [ ] Sans HDR + avec bloom : pas de crash (bloom ignoré, warning)
|
||||
- [ ] Resize : le bloom continue de fonctionner
|
||||
- [ ] 0 warnings
|
||||
|
||||
+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 |
|
||||
|---|------|:---:|:---:|:---:|
|
||||
| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ⬜ |
|
||||
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ⬜ |
|
||||
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||
| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ |
|
||||
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
|
||||
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ |
|
||||
| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ |
|
||||
| 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ |
|
||||
@@ -79,6 +79,7 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
|
||||
| 6.9 | API update géométrie par entité (per-frame, sans rebuild complet) | ⬜ |
|
||||
| 6.10 | Double-buffering des buffers Transform/Matrix (désync CPU/GPU) | ⬜ |
|
||||
| 6.11 | **Module `mesh`** : primitives en features optionnelles + import (OBJ/gltf) — `math/` supprimé | ✅ |
|
||||
| 6.12 | **Module `texture`** : génération procédurale (checkerboard, gradient, noise) + formats compressés (KTX2, basis) en features optionnelles | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::Camera;
|
||||
use wsg_lib::camera::Camera;
|
||||
use glam::Vec3;
|
||||
|
||||
app.scene.set_camera(Camera::new(
|
||||
@@ -37,7 +37,7 @@ app.scene.set_camera(Camera::new(
|
||||
bounded to `[0.1, 100]`), `target` (target point).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::CameraController;
|
||||
use wsg_lib::camera::CameraController;
|
||||
|
||||
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
|
||||
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
|
||||
|
||||
@@ -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
|
||||
(`cargo build -p wsg-lib --examples`). To run an example:
|
||||
Chaque exemple est autonome et illustre **un effet ou une fonctionnalité** spécifique
|
||||
de la bibliothèque. Tous utilisent l'API déclarative (`AppBuilder` + `AppHandler`).
|
||||
|
||||
```bash
|
||||
cargo run -p wsg-lib --example <name>
|
||||
## Lancer un exemple
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example <nom>
|
||||
```
|
||||
|
||||
| Example | Command | Description |
|
||||
|---------|---------|-------------|
|
||||
| `demo` | `cargo run -p wsg-lib --example demo` | **Showcase**: one of each primitive, procedural textures, directional + point + spot lights, a shadow-casting light, and a live orbital camera (drag / wheel zoom / `R` reset / `1`-`3` presets). |
|
||||
| `simple` | `cargo run -p wsg-lib --example simple` | Flat unlit quad (minimal declarative workflow, `AppBuilder` + auto scene). |
|
||||
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. |
|
||||
| `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). |
|
||||
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Spot-light isolation: only one spot is on (near-zero ambient), cube rotates on two axes so the oriented beam is clearly visible. |
|
||||
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping: one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. |
|
||||
| Exemple | Effet démontré |
|
||||
|---------|---------------|
|
||||
| `demo` | Showcase complet (tous les effets combinés) |
|
||||
| `bloom` | Post-process bloom (glow autour des zones brillantes) |
|
||||
| `hdr` | HDR + Tone Mapping (ACES) + contrôle d'exposition |
|
||||
| `emissive` | Matériaux émissifs (intensités croissantes 0 → 4.0) |
|
||||
| `shadow` | Shadow mapping (ombre portée directionnelle) |
|
||||
| `culling` | Culling GPU-driven (grille 20×20, objets hors frustum ignorés) |
|
||||
| `manual` | Workflow bas niveau (Context + Renderer + PipelineCache) |
|
||||
| `import` | Import de fichier OBJ (non graphique, stdout) |
|
||||
|
||||
## Conventions
|
||||
---
|
||||
|
||||
- Examples are **self-contained**: no assets loaded from disk (procedural textures, hardcoded geometry).
|
||||
- They use the declarative workflow (`AppBuilder` + `Scene`) except `manual`, which bypasses the `App` facade.
|
||||
- When adding a new example: create a `.rs` file in this directory, document it here, and reference it in the root README if appropriate.
|
||||
## `demo` — Showcase complet
|
||||
|
||||
Combine **tous** les effets : primitives LOD, textures procédurales, lumières
|
||||
(directional + point + spot), ombres, HDR/ACES, exposition, émissif, bloom, culling.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example demo
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
| `1` / `2` / `3` | Presets : face / côté / dessus |
|
||||
| `+` / `-` | Exposition ×1.3 / ÷1.3 |
|
||||
| `0` | Reset exposition |
|
||||
|
||||
---
|
||||
|
||||
## `bloom` — Post-process Bloom
|
||||
|
||||
Deux sphères émissives (orange intensité 2.0, bleue intensité 3.0) produisent un
|
||||
halo visible. Le cube et le sol servent de référence (non-émissifs).
|
||||
|
||||
Le bloom est un pipeline 4 passes GPU : threshold → blur H → blur V → composite.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example bloom
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
| `+` / `-` | **Bloom threshold** +0.1 / −0.1 |
|
||||
| `[` / `]` | **Bloom intensity** +0.1 / −0.1 |
|
||||
| `I` / `O` | **Bloom radius** +0.5 / −0.5 |
|
||||
| `E` / `Q` | Exposition ×1.3 / ÷1.3 |
|
||||
| `0` | Reset exposition |
|
||||
|
||||
### Ce qu'on voit
|
||||
|
||||
- **threshold bas** (0.0) : tout l'image "bloom" (effet très diffus).
|
||||
- **threshold élevé** (2.0+) : seules les sphères émissives brillantes produisent du glow.
|
||||
- **intensity 0.0** : pas de glow visible (même si le threshold extrait des pixels).
|
||||
- **radius grand** (10+) : le glow s'étend sur une grande zone.
|
||||
|
||||
---
|
||||
|
||||
## `hdr` — HDR + Tone Mapping
|
||||
|
||||
Démontre le rendu HDR avec la courbe ACES Filmic. Trois objets :
|
||||
|
||||
- **Cube** : éclairage normal (aucun émissif) — référence LDR.
|
||||
- **Sphère brillante** (émissif 3.0) : sans HDR, elle serait clampée à blanc.
|
||||
Avec ACES, les highlights "roulent" doucement vers le blanc (rolloff).
|
||||
- **Sphère sombre** (émissif 0.3) : reste sombre même à haute exposition.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example hdr
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
| `E` | **Exposition ×1.3** (plus clair) |
|
||||
| `Q` | **Exposition ÷1.3** (plus sombre) |
|
||||
| `0` | Reset exposition à 1.0 |
|
||||
|
||||
### Ce qu'on voit
|
||||
|
||||
- À exposition 1.0 : la sphère brillante est blanche mais avec des détails (rolloff ACES).
|
||||
- À exposition haute (E×E×E) : la scène s'éclaircit, la sphère brillante reste blanche
|
||||
(saturée), mais le cube gagne en détail.
|
||||
- À exposition basse (Q×Q) : tout s'assombrit, la sphère brillante devient orangée
|
||||
(les valeurs HDR > 1.0 sont compressées).
|
||||
|
||||
> **Note** : le tone mapper est compilé dans le pipeline au build. Pour comparer
|
||||
> ACES vs Reinhard, modifier `ToneMapper::Aces` → `ToneMapper::Reinhard` dans le source.
|
||||
|
||||
---
|
||||
|
||||
## `emissive` — Matériaux Émissifs
|
||||
|
||||
Cinq sphères alignées avec des intensités émissives croissantes :
|
||||
|
||||
| Sphere | Couleur | Intensité | Effet |
|
||||
|--------|---------|-----------|-------|
|
||||
| 1 | Gris | 0.0 | Aucune glow (référence) |
|
||||
| 2 | Orange | 0.5 | Légère lueur |
|
||||
| 3 | Jaune | 1.0 | Lueur visible |
|
||||
| 4 | Vert | 2.0 | Glow HDR (au-delà de 1.0) |
|
||||
| 5 | Bleu | 4.0 | Glow intense (saturation) |
|
||||
|
||||
Avec HDR, les intensités > 1.0 produisent un vrai "glow" (les valeurs dépassent
|
||||
[0,1] en espace linéaire). Sans HDR, elles seraient clampées à blanc.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example emissive
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
| `E` / `Q` | Exposition ×1.3 / ÷1.3 |
|
||||
| `0` | Reset exposition |
|
||||
| `C` | **Cycler le multiplicateur d'émissif** (1× → 2× → 0.5× → ...) |
|
||||
|
||||
### Ce qu'on voit
|
||||
|
||||
- La sphère 1 (intensité 0) est simplement éclairée par la lumière directionnelle.
|
||||
- Les sphères 2-5 brillent de leur propre lumière, indépendamment de l'éclairage.
|
||||
- `C` double ou réduit toutes les intensités en même temps (pour voir l'effet HDR).
|
||||
|
||||
---
|
||||
|
||||
## `shadow` — Shadow Mapping
|
||||
|
||||
Quatre objets (cube, sphère, cône, cylindre) sur un sol, éclairés par une lumière
|
||||
directionnelle qui projette des ombres. La qualité des ombres est contrôlée par
|
||||
`ShadowConfig` (taille de la shadow map, biais anti-acne).
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example shadow
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra |
|
||||
| Molette | Zoom |
|
||||
| `R` | Reset caméra |
|
||||
| `1` | Vue de face |
|
||||
| `2` | Vue de côté |
|
||||
| `3` | **Vue de dessus** (voir la forme des ombres clairement) |
|
||||
| `L` | Changer la direction de la lumière (3 presets) |
|
||||
|
||||
### Ce qu'on voit
|
||||
|
||||
- Le cube tourne lentement → son ombre bouge sur le sol.
|
||||
- La sphère a une transition ombre/lumière douce (terminateur lisse).
|
||||
- Le cône produit une ombre triangulaire distincte.
|
||||
- En vue de dessus (`3`), on voit la forme exacte des ombres projetées.
|
||||
- La taille de la shadow map (1024 par défaut) détermine la résolution :
|
||||
modifier `SHADOW_MAP_SIZE` en haut du fichier pour tester 256 (pixelisé) ou 2048 (net).
|
||||
|
||||
---
|
||||
|
||||
## `culling` — GPU Frustum Culling
|
||||
|
||||
Une grille de **15×15 = 225 cubes** est placée sur un grand sol. Le culling
|
||||
GPU-driven (compute shader) détermine quels cubes sont visibles dans le frustum
|
||||
de la caméra et zéro leurs draw args indirects — **zéro coût CPU**.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example culling
|
||||
```
|
||||
|
||||
### Touches
|
||||
|
||||
| Touche | Action |
|
||||
|--------|--------|
|
||||
| Glisser (LMB) | Orbiter la caméra (regarder autour) |
|
||||
| Molette | Zoom in/out |
|
||||
| `R` | Reset (vue de dessus) |
|
||||
| `1` | Vue de face (les cubes derrière sont culled) |
|
||||
| `2` | Vue de côté |
|
||||
| `3` | **Vue de dessus** (voir toute la grille) |
|
||||
|
||||
### Ce qu'on voit
|
||||
|
||||
- En vue de dessus (`3`) : toute la grille 20×20 est visible.
|
||||
- Orbiter à 90° : les cubes derrière la caméra **ne sont pas dessinés** (culled).
|
||||
- Zoomer très près : seuls les cubes proches du plan de near sont rendus.
|
||||
- Les cubes tournent lentement (phases décalées) → le culling est dynamique
|
||||
(un cube peut entrer/sortir du frustum au cours d'une frame).
|
||||
|
||||
> **Note** : le culling est activé via `AppBuilder::with_culling(true)`. Le modifier
|
||||
> à `false` dans le source désactive le culling (tous les 400 cubes sont toujours
|
||||
> dessinés, même hors écran).
|
||||
|
||||
---
|
||||
|
||||
## `manual` — Workflow bas niveau
|
||||
|
||||
Démontre l'API **sans** la façade `App` : utilisation directe de `Context`,
|
||||
`Renderer`, `PipelineCache`, `Mesh`, `Material`. Rend un quad coloré (unlit).
|
||||
|
||||
Utile pour comprendre ce que la façade `App` encapsule.
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example manual
|
||||
```
|
||||
|
||||
Pas de touches — rendu statique (quad unlit, 4 couleurs).
|
||||
|
||||
---
|
||||
|
||||
## `import` — Import de fichier OBJ
|
||||
|
||||
Exemple **non graphique** : parse un fichier `.obj` et affiche les statistiques
|
||||
(nombre de sommets, normales, UVs, indices, bounding box) sur stdout.
|
||||
|
||||
```sh
|
||||
# Avec un fichier :
|
||||
cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj
|
||||
|
||||
# Sans argument (triangle de démonstration) :
|
||||
cargo run -p wsg-lib --example import --features import-obj
|
||||
```
|
||||
|
||||
Pas de touches — s'exécute et quitte.
|
||||
|
||||
@@ -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
|
||||
//! `Rgba16Float` texture, then a fullscreen TM pass compresses it to [0,1] and writes
|
||||
//! to the sRGB surface — highlights are softly rolled off instead of clipping to white.
|
||||
//! * **Exposure** (Étape 22, 6.1): keys `+` / `-` adjust the tone mapping exposure live
|
||||
//! (×1.3 / ÷1.3 per press), `0` resets to 1.0.
|
||||
//! * **Emissive** (Étape 22, 6.2): a small glowing orange sphere sits at the center
|
||||
//! (emissive intensity 2.0 → HDR glow, visible even in shadow).
|
||||
//!
|
||||
//! Doc (this header) follows the English convention used for examples; internal comments stay
|
||||
//! concise and French where helpful. Run with:
|
||||
@@ -31,10 +35,12 @@ use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::core::BloomConfig;
|
||||
use wsg_lib::core::ToneMapper;
|
||||
use wsg_lib::core::Transform;
|
||||
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
|
||||
use wsg_lib::resources::{CameraController, Texture};
|
||||
use wsg_lib::camera::CameraController;
|
||||
use wsg_lib::resources::Texture;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`.
|
||||
@@ -169,6 +175,24 @@ impl AppHandler for Demo {
|
||||
place("cone_e", "cone_mesh", app, 4);
|
||||
place("torus_e", "torus_mesh", app, 5);
|
||||
|
||||
// 4b. Étape 22 (6.2): emissive demo — a small glowing sphere at the center.
|
||||
// The material has emissive = [1.0, 0.3, 0.05, 2.0] (orange, intensity 2.0 = HDR glow).
|
||||
// IMPORTANT: set emissive BEFORE create_mesh (the mesh captures the Arc at creation).
|
||||
app.scene
|
||||
.add_material_texture("glow_mat", "standard", "checker_texture")
|
||||
.unwrap();
|
||||
app.scene
|
||||
.set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0])
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("glow_mesh", icosphere(0.3, 3), Some("glow_mat"))
|
||||
.unwrap();
|
||||
let mut glow_tf = Transform::identity();
|
||||
glow_tf.translation = Vec3::new(0.0, 0.5, 0.0);
|
||||
app.scene
|
||||
.add_entity_with_transform("glow_e", "glow_mesh", glow_tf)
|
||||
.unwrap();
|
||||
|
||||
// 5. Lights: a shadow-casting directional + a warm point + a green spot.
|
||||
// Start from the default list (directional +Z) so we keep it and add the rest.
|
||||
let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize();
|
||||
@@ -239,6 +263,18 @@ impl AppHandler for Demo {
|
||||
}
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
|
||||
// ---- Étape 22 (6.1): exposure control ----
|
||||
// `+` / `-`: multiply/divide by 1.3 (visible step). `0`: reset to 1.0.
|
||||
if app.input.key_pressed(KeyCode::Equal) {
|
||||
app.set_exposure(app.exposure() * 1.3);
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Minus) {
|
||||
app.set_exposure(app.exposure() / 1.3);
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit0) {
|
||||
app.set_exposure(1.0);
|
||||
}
|
||||
|
||||
// ---- Slow rotation of the primitives so lighting/shadow read clearly ----
|
||||
self.angle += 0.008;
|
||||
let base = *app
|
||||
@@ -281,6 +317,7 @@ async fn main() -> Result<(), WsgError> {
|
||||
.title("WSG Demo")
|
||||
.with_culling(true)
|
||||
.with_hdr(ToneMapper::Aces)
|
||||
.with_bloom(BloomConfig::default())
|
||||
.build()
|
||||
.await?;
|
||||
app.run(Demo {
|
||||
|
||||
@@ -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
|
||||
// draw_entity, the default frame matrix is the identity → NDC positions unchanged).
|
||||
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None);
|
||||
let mut renderer = Renderer::new(&context, format, 800, 600, &ShadowConfig::default(), None, None);
|
||||
renderer.set_unlit(true);
|
||||
|
||||
// 3. Material: uses renderer.device() and renderer.format()
|
||||
|
||||
@@ -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`
|
||||
use glam::Vec3;
|
||||
use wsg_lib::resources::{Camera, Geometry};
|
||||
use wsg_lib::camera::Camera;
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one
|
||||
|
||||
+75
-3
@@ -23,7 +23,8 @@
|
||||
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
||||
|
||||
use crate::AppHandler;
|
||||
use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
|
||||
use crate::core::{BloomConfig, Context, Renderer, ShadowConfig, ToneMapper};
|
||||
use crate::input::InputState;
|
||||
use crate::scene::Scene;
|
||||
use crate::utils::WsgError;
|
||||
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
||||
@@ -63,6 +64,12 @@ pub struct App {
|
||||
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default, zero overhead);
|
||||
/// `Some(t)` = render to Rgba16Float offscreen + tone mapping pass to the surface.
|
||||
pub(crate) hdr: Option<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.
|
||||
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
|
||||
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
|
||||
@@ -128,6 +135,8 @@ impl App {
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config.clone(),
|
||||
hdr: self.hdr,
|
||||
bloom_config: self.bloom_config.clone(),
|
||||
exposure: self.exposure,
|
||||
handler,
|
||||
app: None,
|
||||
};
|
||||
@@ -147,7 +156,40 @@ impl App {
|
||||
pub fn render_scene(&self, view: &wgpu::TextureView) {
|
||||
let size = self.window().inner_size();
|
||||
let aspect = size.width as f32 / size.height.max(1) as f32;
|
||||
self.renderer().render_scene(view, &self.scene, aspect);
|
||||
self.renderer().render_scene(view, &self.scene, aspect, self.exposure);
|
||||
}
|
||||
|
||||
/// Sets the exposure multiplier (Étape 22, 6.1). Clamped to [0.01, 10.0].
|
||||
/// Takes effect on the next frame's tone mapping pass.
|
||||
pub fn set_exposure(&mut self, value: f32) {
|
||||
self.exposure = value.clamp(0.01, 10.0);
|
||||
}
|
||||
|
||||
/// Returns the current exposure multiplier.
|
||||
pub fn exposure(&self) -> f32 {
|
||||
self.exposure
|
||||
}
|
||||
|
||||
/// Returns `true` if bloom is active (Étape 23). Requires HDR to be enabled.
|
||||
pub fn bloom_enabled(&self) -> bool {
|
||||
self.bloom_config.is_some() && self.hdr.is_some()
|
||||
}
|
||||
|
||||
/// Returns the current bloom configuration (Étape 23). `None` if bloom is not enabled.
|
||||
pub fn bloom_config(&self) -> Option<&BloomConfig> {
|
||||
self.bloom_config.as_ref()
|
||||
}
|
||||
|
||||
/// Updates the bloom configuration at runtime (Étape 23).
|
||||
/// Takes effect on the next frame (uniforms are re-written each frame).
|
||||
/// No-op if bloom is not enabled.
|
||||
pub fn set_bloom_config(&mut self, config: BloomConfig) {
|
||||
if self.bloom_config.is_some() {
|
||||
self.bloom_config = Some(config.clone());
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.set_bloom_config(&config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
|
||||
@@ -192,6 +234,11 @@ pub struct AppBuilder {
|
||||
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
|
||||
/// the offscreen HDR texture + tone mapping pass.
|
||||
hdr: Option<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 {
|
||||
@@ -205,6 +252,8 @@ impl AppBuilder {
|
||||
culling: false,
|
||||
shadow_config: ShadowConfig::default(),
|
||||
hdr: None,
|
||||
bloom_config: None,
|
||||
exposure: 1.0,
|
||||
}
|
||||
}
|
||||
/// Sets the window title to display in the OS taskbar/window decorations.
|
||||
@@ -242,6 +291,21 @@ impl AppBuilder {
|
||||
self.hdr = Some(tonemapper);
|
||||
self
|
||||
}
|
||||
/// Enables the bloom post-process (Étape 23). Bright areas (above `config.threshold` in
|
||||
/// linear HDR units) are blurred and added back to the image, creating a glow effect.
|
||||
/// **Requires HDR** (`with_hdr`): without it, the bloom is silently ignored with a warning.
|
||||
pub fn with_bloom(mut self, config: BloomConfig) -> Self {
|
||||
if self.hdr.is_none() {
|
||||
eprintln!("[wsg] Warning: with_bloom() requires with_hdr() — bloom ignored.");
|
||||
}
|
||||
self.bloom_config = Some(config);
|
||||
self
|
||||
}
|
||||
/// Sets the initial exposure multiplier (Étape 22, 6.1). Default 1.0.
|
||||
pub fn with_exposure(mut self, exposure: f32) -> Self {
|
||||
self.exposure = exposure;
|
||||
self
|
||||
}
|
||||
/// Builds the configured `App` instance: creates the event loop and stores the window
|
||||
/// configuration. The GPU context, window and renderer are created later, when the event loop
|
||||
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
|
||||
@@ -258,6 +322,8 @@ impl AppBuilder {
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config,
|
||||
hdr: self.hdr,
|
||||
bloom_config: self.bloom_config,
|
||||
exposure: self.exposure,
|
||||
event_loop: Some(event_loop),
|
||||
context: None,
|
||||
renderer: None,
|
||||
@@ -282,6 +348,10 @@ struct AppRunner<H: AppHandler> {
|
||||
shadow_config: ShadowConfig,
|
||||
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
|
||||
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.
|
||||
handler: H,
|
||||
/// 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");
|
||||
let device = Arc::new(context.device.clone());
|
||||
let renderer =
|
||||
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr);
|
||||
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone());
|
||||
// Step 15, D8: apply the culling flag (off by default — non-regression).
|
||||
renderer.set_culling(self.culling);
|
||||
|
||||
@@ -340,6 +410,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config.clone(),
|
||||
hdr: self.hdr,
|
||||
bloom_config: self.bloom_config.clone(),
|
||||
exposure: self.exposure,
|
||||
event_loop: None,
|
||||
context: Some(context),
|
||||
renderer: Some(renderer),
|
||||
|
||||
@@ -111,7 +111,7 @@ pub const PITCH_LIMIT: f32 = 1.45; // ~83°
|
||||
/// decoupled from `Camera`'s own position/target/up representation.
|
||||
///
|
||||
/// ```
|
||||
/// # use wsg_lib::resources::{Camera, CameraController};
|
||||
/// # use wsg_lib::camera::{Camera, CameraController};
|
||||
/// # use glam::Vec3;
|
||||
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
|
||||
/// let mut ctrl = CameraController::from_camera(&cam);
|
||||
@@ -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,
|
||||
//! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space
|
||||
//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]).
|
||||
//! z range (the `directx` projection produced by [`crate::camera::Camera::projection_matrix`]).
|
||||
//!
|
||||
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
|
||||
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
|
||||
@@ -80,7 +80,7 @@ impl Frustum {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resources::camera::Camera;
|
||||
use crate::camera::Camera;
|
||||
|
||||
/// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov,
|
||||
/// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer.
|
||||
@@ -149,7 +149,7 @@ mod tests {
|
||||
/// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
|
||||
#[test]
|
||||
fn demo_camera_sees_all_primitives() {
|
||||
use crate::resources::CameraController;
|
||||
use crate::camera::CameraController;
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.yaw = 0.6;
|
||||
ctrl.pitch = 0.35;
|
||||
|
||||
+2
-2
@@ -9,24 +9,24 @@
|
||||
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`.
|
||||
//! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
|
||||
|
||||
pub mod bloom;
|
||||
pub mod context;
|
||||
pub mod frame;
|
||||
pub mod frustum;
|
||||
pub mod geometry;
|
||||
pub mod hdr;
|
||||
pub mod input;
|
||||
pub mod lod;
|
||||
pub mod renderer;
|
||||
pub mod shadow;
|
||||
pub mod transform;
|
||||
|
||||
// Re-exports
|
||||
pub use bloom::BloomConfig;
|
||||
pub use context::Context;
|
||||
pub use frame::Frame;
|
||||
pub use frustum::Frustum;
|
||||
pub use geometry::{BBox, Geometry, GeometryError};
|
||||
pub use hdr::ToneMapper;
|
||||
pub use input::InputState;
|
||||
pub use lod::{lod_level, projected_radius_px};
|
||||
pub use renderer::Renderer;
|
||||
pub use shadow::ShadowConfig;
|
||||
|
||||
+109
-19
@@ -19,7 +19,9 @@
|
||||
//! texture state changes happen once per distinct material, not once per entity.
|
||||
//! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths.
|
||||
|
||||
use crate::camera::Camera;
|
||||
use crate::core::Context;
|
||||
use crate::lights::{Lights, MAX_LIGHTS};
|
||||
use crate::core::Frame;
|
||||
use crate::core::Frustum;
|
||||
use crate::core::lod::{lod_level, projected_radius_px};
|
||||
@@ -29,13 +31,14 @@ use crate::pipeline::{
|
||||
};
|
||||
use crate::resources::uniform::{
|
||||
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE,
|
||||
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE,
|
||||
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE,
|
||||
SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot,
|
||||
};
|
||||
use crate::resources::{
|
||||
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
|
||||
CullUniforms, FrameUniforms, Material, Mesh, ObjectUniform, ShadowUniform,
|
||||
};
|
||||
use crate::scene::Scene;
|
||||
use crate::core::bloom::{BloomConfig, BloomPipeline};
|
||||
use crate::core::hdr::ToneMapper;
|
||||
use crate::utils::conf::{
|
||||
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER,
|
||||
@@ -151,6 +154,11 @@ pub struct Renderer {
|
||||
/// HDR pipeline (Étape 20). Present only when HDR is enabled via `AppBuilder::with_hdr`.
|
||||
/// When `None`, the main pass renders directly to the surface (LDR, zero overhead).
|
||||
hdr: Option<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.
|
||||
@@ -163,12 +171,17 @@ struct HdrPipeline {
|
||||
/// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve).
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport).
|
||||
/// The uniform buffer is owned by the bind group (freed when the bind group is replaced).
|
||||
bind_group: wgpu::BindGroup,
|
||||
/// TM uniform buffer (32 bytes: exposure + viewport). Re-written each frame for live exposure.
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
/// Bind group layout for the TM pass (reused on resize to recreate the bind group).
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Sampler for the HDR texture (linear, clamp).
|
||||
sampler: wgpu::Sampler,
|
||||
/// Viewport width in pixels (for the TM uniform's pad.xy).
|
||||
width: u32,
|
||||
/// Viewport height in pixels.
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -187,6 +200,7 @@ impl Renderer {
|
||||
height: u32,
|
||||
shadow_config: &super::shadow::ShadowConfig,
|
||||
hdr: Option<ToneMapper>,
|
||||
bloom_config: Option<BloomConfig>,
|
||||
) -> Self {
|
||||
let queue: wgpu::Queue = context.queue.clone();
|
||||
let device: wgpu::Device = context.device.clone();
|
||||
@@ -223,6 +237,7 @@ impl Renderer {
|
||||
});
|
||||
let identity_object = ObjectUniform {
|
||||
model: glam::Mat4::IDENTITY,
|
||||
emissive: glam::Vec4::ZERO,
|
||||
};
|
||||
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
|
||||
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -425,9 +440,11 @@ impl Renderer {
|
||||
label: Some("GPU world matrices"),
|
||||
size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE,
|
||||
// COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map.
|
||||
// COPY_DST: lets the CPU write emissive values into the slot padding (Étape 22).
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::UNIFORM
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
| wgpu::BufferUsages::COPY_SRC
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -575,12 +592,27 @@ impl Renderer {
|
||||
viewport_height: height,
|
||||
shadow_config: shadow_config.clone(),
|
||||
hdr: None,
|
||||
bloom: None,
|
||||
bloom_config: bloom_config.clone().unwrap_or_default(),
|
||||
};
|
||||
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
|
||||
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
|
||||
renderer.write_default_frame_uniforms();
|
||||
// Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled.
|
||||
renderer.hdr = hdr.map(|tm| create_hdr_pipeline(&renderer.device, &renderer.queue, width, height, tm, format));
|
||||
// Étape 23: allocate the bloom pipeline when both HDR and bloom are active.
|
||||
if bloom_config.is_some() {
|
||||
if let Some(hdr) = &mut renderer.hdr {
|
||||
let bloom = BloomPipeline::new(&renderer.device, width, height, &hdr.view);
|
||||
// Recreate the TM bind group to read from the bloom composite texture.
|
||||
let (bg, _buf) = create_hdr_bind_group(
|
||||
&renderer.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height,
|
||||
);
|
||||
hdr.bind_group = bg;
|
||||
renderer.bloom = Some(bloom);
|
||||
renderer.bloom_config = bloom_config.clone().unwrap();
|
||||
}
|
||||
}
|
||||
renderer
|
||||
}
|
||||
|
||||
@@ -623,10 +655,24 @@ impl Renderer {
|
||||
// Étape 20: recreate the HDR texture + bind group at the new size (D10).
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
let (tex, view) = create_hdr_texture(&self.device, width, height);
|
||||
let bg = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height);
|
||||
let (bg, buf) = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height);
|
||||
hdr.texture = tex;
|
||||
hdr.view = view;
|
||||
hdr.bind_group = bg;
|
||||
hdr.uniform_buffer = buf;
|
||||
hdr.width = width;
|
||||
hdr.height = height;
|
||||
}
|
||||
// Étape 23: resize bloom textures + re-point TM bind group at the composite.
|
||||
if self.bloom.is_some() {
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
let bloom = self.bloom.as_mut().unwrap();
|
||||
bloom.resize(&self.device, width, height, &hdr.view);
|
||||
let (bg, _buf) = create_hdr_bind_group(
|
||||
&self.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height,
|
||||
);
|
||||
hdr.bind_group = bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,15 +763,15 @@ impl Renderer {
|
||||
// camera must look along the light's **travel direction** (light → scene), i.e. the negation
|
||||
// of the surface→light vector for directional lights.
|
||||
let dir = match light.light_type() {
|
||||
crate::resources::LightType::Directional => Vec3::new(
|
||||
crate::lights::LightType::Directional => Vec3::new(
|
||||
-light.position_dir.x,
|
||||
-light.position_dir.y,
|
||||
-light.position_dir.z,
|
||||
),
|
||||
crate::resources::LightType::Spot => {
|
||||
crate::lights::LightType::Spot => {
|
||||
Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z)
|
||||
}
|
||||
crate::resources::LightType::Point => return None,
|
||||
crate::lights::LightType::Point => return None,
|
||||
};
|
||||
let r = self.shadow_config.scene_radius;
|
||||
let target = Vec3::from(self.shadow_config.scene_center);
|
||||
@@ -807,7 +853,7 @@ impl Renderer {
|
||||
/// draw). This removes the CPU-side per-entity loop from the render hot path.
|
||||
/// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are
|
||||
/// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection.
|
||||
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
|
||||
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32, exposure: f32) {
|
||||
// 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags).
|
||||
self.write_frame_uniforms(
|
||||
scene.camera(),
|
||||
@@ -1007,9 +1053,42 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
|
||||
// texture, applies exposure + tone mapping curve, and writes to the surface.
|
||||
// Only runs when HDR is active; the surface is the color target (no depth needed).
|
||||
// 8. Étape 22 (6.1): write the current exposure into the TM uniform buffer (per-frame,
|
||||
// so live adjustments via keyboard take effect immediately).
|
||||
// 8b. Étape 22 (6.2): write each active slot's emissive into the matrix buffer padding
|
||||
// (bytes 64-79). The compute pass only overwrites bytes 0-63 (the matrix), so the
|
||||
// emissive persists. This must happen before the encoder submit (CPU→GPU copy).
|
||||
if let Some(hdr) = &self.hdr {
|
||||
let uniform_data = [
|
||||
exposure, 0.0, 0.0, 0.0,
|
||||
hdr.width as f32, hdr.height as f32, 0.0, 0.0,
|
||||
];
|
||||
self.queue.write_buffer(&hdr.uniform_buffer, 0, bytemuck::cast_slice(&uniform_data));
|
||||
}
|
||||
// Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79).
|
||||
// The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists.
|
||||
for slot in scene.iter_slot_draws().filter(|s| s.active) {
|
||||
let mat = slot
|
||||
.mesh
|
||||
.material()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| scene.default_material());
|
||||
if mat.emissive != [0.0; 4] {
|
||||
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64;
|
||||
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive));
|
||||
}
|
||||
}
|
||||
|
||||
// 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite).
|
||||
// Only runs when both HDR and bloom are active. The composite texture becomes
|
||||
// the input to the TM pass (the TM bind group was re-pointed at construction).
|
||||
if let Some(bloom) = &self.bloom {
|
||||
bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config);
|
||||
}
|
||||
|
||||
// 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR
|
||||
// texture (or the bloom composite when bloom is active), applies exposure + tone
|
||||
// mapping curve, and writes to the surface.
|
||||
if let Some(hdr) = &self.hdr {
|
||||
let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("tone mapping pass"),
|
||||
@@ -1321,6 +1400,12 @@ impl Renderer {
|
||||
self.lod_enabled.set(enabled);
|
||||
}
|
||||
|
||||
/// Updates the bloom configuration at runtime (Étape 23).
|
||||
/// Takes effect on the next frame (uniforms are re-written each frame in `record_passes`).
|
||||
pub fn set_bloom_config(&mut self, config: &BloomConfig) {
|
||||
self.bloom_config = config.clone();
|
||||
}
|
||||
|
||||
/// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the
|
||||
/// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox
|
||||
/// center + max half-extent × max scale component, rotated by the entity's quaternion) — is
|
||||
@@ -1524,8 +1609,9 @@ fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
/// Creates the tone mapping bind group: HDR texture (binding 0) + sampler (binding 1) + uniform (binding 2).
|
||||
/// The uniform contains exposure (1.0) and viewport size (pad.xy).
|
||||
/// Creates the tone mapping bind group + uniform buffer: HDR texture (binding 0) + sampler (binding 1)
|
||||
/// + uniform (binding 2). The uniform contains exposure (1.0) and viewport size (pad.xy).
|
||||
/// Returns both the bind group and the uniform buffer (so the exposure can be re-written per frame).
|
||||
fn create_hdr_bind_group(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
@@ -1533,7 +1619,7 @@ fn create_hdr_bind_group(
|
||||
texture: &wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> wgpu::BindGroup {
|
||||
) -> (wgpu::BindGroup, wgpu::Buffer) {
|
||||
// Write the uniform: exposure = 1.0, pad.xy = viewport size.
|
||||
// WGSL uniform layout: f32 at offset 0 (4B), vec3<f32> at offset 16 (16B, aligned to 16).
|
||||
// Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0].
|
||||
@@ -1546,7 +1632,7 @@ fn create_hdr_bind_group(
|
||||
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("tm uniform"),
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: true,
|
||||
});
|
||||
{
|
||||
@@ -1555,7 +1641,7 @@ fn create_hdr_bind_group(
|
||||
drop(w);
|
||||
uniform_buffer.unmap();
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("tm bind group"),
|
||||
layout,
|
||||
entries: &[
|
||||
@@ -1576,7 +1662,8 @@ fn create_hdr_bind_group(
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
(bind_group, uniform_buffer)
|
||||
}
|
||||
|
||||
/// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group.
|
||||
@@ -1669,15 +1756,18 @@ fn create_hdr_pipeline(
|
||||
});
|
||||
|
||||
// 5. Bind group with the initial texture + viewport size.
|
||||
let bind_group = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height);
|
||||
let (bind_group, uniform_buffer) = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height);
|
||||
|
||||
HdrPipeline {
|
||||
texture,
|
||||
view,
|
||||
pipeline,
|
||||
bind_group,
|
||||
uniform_buffer,
|
||||
layout,
|
||||
sampler,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! ## Query examples (in `AppHandler::update`)
|
||||
//! ```
|
||||
//! # use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
//! # fn demo(input: &wsg_lib::core::input::InputState) {
|
||||
//! # fn demo(input: &wsg_lib::input::InputState) {
|
||||
//! if input.key_held(KeyCode::KeyW) { /* move forward */ }
|
||||
//! if input.key_pressed(KeyCode::Space) { /* jump */ }
|
||||
//! let (dx, dy) = input.mouse_delta();
|
||||
@@ -29,8 +29,11 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod app;
|
||||
pub mod camera;
|
||||
pub mod core;
|
||||
pub mod handler;
|
||||
pub mod input;
|
||||
pub mod lights;
|
||||
pub mod mesh;
|
||||
pub mod pipeline;
|
||||
pub mod prelude;
|
||||
@@ -48,6 +51,7 @@ pub use crate::handler::AppHandler;
|
||||
|
||||
/// Re-export of the shadow mapping configuration for convenient top-level access.
|
||||
/// Users tune shadow quality via `AppBuilder::with_shadow_config`.
|
||||
pub use crate::core::BloomConfig;
|
||||
pub use crate::core::ShadowConfig;
|
||||
|
||||
/// Re-export of the tone mapping curve selector for convenient top-level access.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13)
|
||||
//! # Lights — Global Light List + Light Types
|
||||
//!
|
||||
//! Holds the scene's global light list — directional, point and spot lights — in a CPU-side
|
||||
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each
|
||||
//! frame by `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is
|
||||
//! lit by the same list (per-material lights are out of scope, a later performance/feature step).
|
||||
//! Defines the scene's global light list — directional, point and spot lights — and the
|
||||
//! GPU-upload types (`Light`, `LightType`, `MAX_LIGHTS`).
|
||||
//!
|
||||
//! ## Rangement (no type flag)
|
||||
//! Directional lights occupy indices `0..num_directional`; point lights occupy
|
||||
@@ -15,9 +13,11 @@
|
||||
//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white
|
||||
//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
|
||||
|
||||
use crate::resources::uniform::{Light, MAX_LIGHTS};
|
||||
use glam::{Vec3, Vec4};
|
||||
|
||||
/// Re-exported from `crate::resources::uniform` (where `Pod` is derived for the uniform buffer).
|
||||
pub use crate::resources::uniform::{Light, LightType, MAX_LIGHTS};
|
||||
|
||||
/// The scene's global light list: directional lights (first), point lights (middle), spot lights
|
||||
/// (last). Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene`
|
||||
/// API.
|
||||
@@ -32,13 +32,11 @@ pub struct Lights {
|
||||
}
|
||||
|
||||
impl Lights {
|
||||
/// Default = one white directional light along +Z (from surface toward light), no point or
|
||||
/// spot lights. This reproduces the historical single-light look when combined with a white
|
||||
/// ambient.
|
||||
/// Default = one white directional light along +Z.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
directional: vec![Light {
|
||||
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z
|
||||
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0),
|
||||
color: Vec4::ONE,
|
||||
radius: Vec4::ZERO,
|
||||
dir_angle: Vec4::ZERO,
|
||||
@@ -48,7 +46,7 @@ impl Lights {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of lights (directional + point + spot).
|
||||
/// Total number of lights.
|
||||
pub fn len(&self) -> usize {
|
||||
self.directional.len() + self.point.len() + self.spot.len()
|
||||
}
|
||||
@@ -58,9 +56,7 @@ impl Lights {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Returns the light at a **packed-array index** (directionals first, then point lights, then
|
||||
/// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to
|
||||
/// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Step 14 D7).
|
||||
/// Returns the light at a **packed-array index**.
|
||||
pub fn get(&self, index: usize) -> Option<&Light> {
|
||||
let n_dir = self.directional.len();
|
||||
if index < n_dir {
|
||||
@@ -74,10 +70,7 @@ impl Lights {
|
||||
self.spot.get(index - n_point)
|
||||
}
|
||||
|
||||
/// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then
|
||||
/// point lights, then spot lights. The tail is zero-filled. Returns
|
||||
/// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS`
|
||||
/// (the `Scene` API validates capacity).
|
||||
/// Packs the lights into the GPU frame array.
|
||||
pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) {
|
||||
let empty = Light {
|
||||
position_dir: Vec4::ZERO,
|
||||
@@ -102,14 +95,12 @@ impl Lights {
|
||||
}
|
||||
|
||||
impl Default for Lights {
|
||||
/// `Lights::new()` — one white directional light along +Z (non-regression default).
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and
|
||||
/// an intensity multiplier. Used by `Scene::add_directional_light`.
|
||||
/// Builds a directional [`Light`].
|
||||
pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
|
||||
Light {
|
||||
position_dir: dir.extend(0.0),
|
||||
@@ -119,8 +110,7 @@ pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a point [`Light`] from a world position, a color, an intensity multiplier and an
|
||||
/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`.
|
||||
/// Builds a point [`Light`].
|
||||
pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light {
|
||||
Light {
|
||||
position_dir: pos.extend(0.0),
|
||||
@@ -130,9 +120,7 @@ pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> L
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a spot [`Light`] from a world position, a cone axis (from the light toward the scene), a
|
||||
/// color, an intensity multiplier, an attenuation radius and a half-angle in radians. Used by
|
||||
/// `Scene::add_spot_light`. The half-angle is stored as its cosine in `dir_angle.w`.
|
||||
/// Builds a spot [`Light`].
|
||||
pub fn spot_light(
|
||||
pos: Vec3,
|
||||
dir: Vec3,
|
||||
@@ -164,7 +152,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn into_frame_array_packs_directional_point_then_spot() {
|
||||
let mut lights = Lights::new(); // 1 directional
|
||||
let mut lights = Lights::new();
|
||||
lights
|
||||
.point
|
||||
.push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0));
|
||||
@@ -180,11 +168,9 @@ mod tests {
|
||||
assert_eq!(n_dir, 1);
|
||||
assert_eq!(n_point, 1);
|
||||
assert_eq!(n_spot, 1);
|
||||
// Directional first, point second, spot third.
|
||||
assert_eq!(array[0].color, Vec4::ONE);
|
||||
assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0));
|
||||
assert_eq!(array[2].color, Vec4::new(0.0, 1.0, 0.0, 1.0));
|
||||
// Spot stores the cone axis (normalized) and the half-angle cosine.
|
||||
assert_eq!(array[2].dir_angle.truncate(), Vec3::new(-1.0, 0.0, 0.0));
|
||||
assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6);
|
||||
}
|
||||
@@ -194,28 +180,18 @@ mod tests {
|
||||
assert!(MAX_LIGHTS >= 1);
|
||||
}
|
||||
|
||||
/// Locks the spot sign convention used by the shader: for a surface point that lies on the
|
||||
/// cone axis, the alignment between the "light -> point" direction (`-l`, where `l` points
|
||||
/// from the surface toward the light) and the stored cone axis (`dir_angle.xyz`, from the
|
||||
/// light toward the scene) must be **+1** (full cone), not −1. A regression to the wrong sign
|
||||
/// would make every spot light contribute zero (black cube). Mirrors the WGSL spot loop.
|
||||
#[test]
|
||||
fn spot_cone_axis_alignment_is_positive() {
|
||||
// Spot at (0,0,3), cone axis pointing toward the origin (light -> scene).
|
||||
let light_pos = Vec3::new(0.0, 0.0, 3.0);
|
||||
let surface_point = Vec3::ZERO;
|
||||
let cone_axis = (surface_point - light_pos).normalize(); // (0,0,-1)
|
||||
|
||||
// Shader math: l points surface -> light; the cone test uses -l (light -> point).
|
||||
let l = (light_pos - surface_point).normalize(); // (0,0,1)
|
||||
let to_point = -l; // (0,0,-1)
|
||||
let cone_axis = (surface_point - light_pos).normalize();
|
||||
let l = (light_pos - surface_point).normalize();
|
||||
let to_point = -l;
|
||||
let cone = to_point.dot(cone_axis);
|
||||
|
||||
assert!(
|
||||
(cone - 1.0).abs() < 1e-6,
|
||||
"on-axis point must align with the cone axis (got {cone}); if it is ~-1 the spot sign is wrong"
|
||||
"on-axis point must align with the cone axis (got {cone})"
|
||||
);
|
||||
// Sanity: the buggy expression (dot of l with the axis) would be ~ -1.
|
||||
assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
|
||||
label: Some("object_uniform_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
|
||||
|
||||
+11
-1
@@ -15,7 +15,17 @@
|
||||
// Core types
|
||||
pub use crate::core::geometry::{BBox, Geometry};
|
||||
pub use crate::core::transform::Transform;
|
||||
pub use crate::core::{ShadowConfig, ToneMapper};
|
||||
pub use crate::core::{BloomConfig, ShadowConfig, ToneMapper};
|
||||
pub use crate::resources::Material;
|
||||
|
||||
// Camera
|
||||
pub use crate::camera::{Camera, CameraController};
|
||||
|
||||
// Lights
|
||||
pub use crate::lights::{directional_light, point_light, spot_light, Light, LightType, Lights};
|
||||
|
||||
// Input
|
||||
pub use crate::input::InputState;
|
||||
|
||||
// App / handler (already at crate root, re-exported here for convenience)
|
||||
pub use crate::app::AppBuilder;
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::sync::Arc;
|
||||
/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline and an optional
|
||||
/// diffuse texture. Does not own the pipeline; holds an Arc for zero-copy sharing across objects
|
||||
/// using the same shader. Owns its texture bind group (group 2), built at construction.
|
||||
#[derive(Clone)]
|
||||
pub struct Material {
|
||||
/// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache.
|
||||
pub shader_id: String,
|
||||
@@ -29,6 +30,9 @@ pub struct Material {
|
||||
/// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in
|
||||
/// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`.
|
||||
pub texture_bind_group: wgpu::BindGroup,
|
||||
/// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]`
|
||||
/// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect.
|
||||
pub emissive: [f32; 4],
|
||||
}
|
||||
|
||||
impl Material {
|
||||
@@ -69,6 +73,7 @@ impl Material {
|
||||
pipeline,
|
||||
texture,
|
||||
texture_bind_group,
|
||||
emissive: [0.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
//! vertex data, source of truth — re-exported here from `math` for convenience), **Vertex** (interleaved
|
||||
//! CPU-side per-attribute tuple, the GPU upload contract), **Mesh** (GPU geometry container with vertex/index
|
||||
//! buffers), and **Material** (appearance descriptor pairing shader ID with a compiled RenderPipeline).
|
||||
//! These are immutable after creation and consumed by Renderer for draw calls.
|
||||
//! Defines the core GPU data types that flow through the rendering pipeline: **Mesh** (GPU geometry
|
||||
//! container with vertex/index buffers), **Material** (appearance descriptor pairing shader ID with
|
||||
//! a compiled RenderPipeline), **Texture** (GPU image + sampler), and **Uniform** (Pod structs for
|
||||
//! uniform buffer uploads).
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `pipeline_cache::build_pipeline()` reads Vertex field offsets to construct the vertex buffer layout.
|
||||
//! - `mesh::from_geometry()` derives `Vertex` arrays from a `Geometry` and uploads them into GPU vertex
|
||||
//! buffers via DeviceExt::create_buffer_init().
|
||||
//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization.
|
||||
//! Camera, Lights and Input are now top-level modules (`wsg::camera`, `wsg::lights`, `wsg::input`).
|
||||
|
||||
pub mod camera;
|
||||
pub mod lights;
|
||||
pub mod material;
|
||||
pub mod mesh;
|
||||
pub mod texture;
|
||||
@@ -21,19 +14,16 @@ pub mod uniform;
|
||||
pub mod vertex;
|
||||
|
||||
// Re-exports
|
||||
pub use camera::{Camera, CameraController, PITCH_LIMIT};
|
||||
pub use lights::Lights;
|
||||
pub use material::Material;
|
||||
pub use mesh::{LodMode, Mesh, PackError};
|
||||
pub use texture::{Texture, TextureError};
|
||||
pub use uniform::{
|
||||
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
|
||||
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow,
|
||||
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform,
|
||||
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot,
|
||||
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, LodRow, LodTable,
|
||||
MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform,
|
||||
TRANSFORM_SLOT_SIZE, TransformSlot,
|
||||
};
|
||||
pub use vertex::Vertex;
|
||||
|
||||
// Convenience re-export of `math::Geometry` (Step 8, D2) so examples can build meshes
|
||||
// from `wsg_lib::resources::Geometry` without importing `math` separately.
|
||||
// Convenience re-export of Geometry (Step 8, D2)
|
||||
pub use crate::core::Geometry;
|
||||
|
||||
@@ -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).
|
||||
pub const MAX_LIGHTS: usize = 8;
|
||||
|
||||
/// A single light, stored in the per-frame uniform array. One struct serves all three types; the
|
||||
/// *position in the array* disambiguates:
|
||||
/// - indices `0..num_directional` are **directional** (`position_dir.xyz` = direction
|
||||
/// **from the surface toward the light**);
|
||||
/// - indices `num_directional..num_directional + num_point` are **point**
|
||||
/// (`position_dir.xyz` = world position);
|
||||
/// - indices `num_directional + num_point..` are **spot** (`position_dir.xyz` = world position,
|
||||
/// `dir_angle.xyz` = cone axis **from the light toward the scene**, `dir_angle.w` = cos of the
|
||||
/// half-angle).
|
||||
/// No type flag in the struct.
|
||||
///
|
||||
/// 4 × Vec4 = 64 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`).
|
||||
/// A single light, stored in the per-frame uniform array (64 bytes, std140).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, PartialEq)]
|
||||
pub struct Light {
|
||||
/// xyz = direction from surface toward the light (directional) or world position (point/spot);
|
||||
/// w = 0.
|
||||
/// xyz = direction (directional) or position (point/spot); w = 0.
|
||||
pub position_dir: Vec4,
|
||||
/// rgb = color; a = intensity (multiplier).
|
||||
/// rgb = color; a = intensity.
|
||||
pub color: Vec4,
|
||||
/// x = attenuation radius (point/spot lights); 0 for directional.
|
||||
/// x = attenuation radius.
|
||||
pub radius: Vec4,
|
||||
/// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle.
|
||||
/// Zero for directional and point lights.
|
||||
/// xyz = cone axis; w = cos half-angle (spot only).
|
||||
pub dir_angle: Vec4,
|
||||
}
|
||||
|
||||
/// The runtime-disambiguated type of a [`Light`] (Step 14, D6). Not stored in the struct (the array
|
||||
/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection,
|
||||
/// which must reject point lights (cubemap shadows are out of scope).
|
||||
/// The runtime-disambiguated type of a [].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LightType {
|
||||
/// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the
|
||||
/// light, `radius.x` = 0, `dir_angle` = 0.
|
||||
/// Directional light (infinitely distant).
|
||||
Directional,
|
||||
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation
|
||||
/// radius, `dir_angle` = 0.
|
||||
/// Point (omnidirectional).
|
||||
Point,
|
||||
/// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in
|
||||
/// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle.
|
||||
/// Spot (cone).
|
||||
Spot,
|
||||
}
|
||||
|
||||
impl Light {
|
||||
/// Classifies the light for CPU-side logic. Query order is significant because a spot light
|
||||
/// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a
|
||||
/// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is
|
||||
/// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or
|
||||
/// [`LightType::Spot`].
|
||||
/// Classifies the light for CPU-side logic.
|
||||
pub fn light_type(&self) -> LightType {
|
||||
if self.dir_angle.w > 0.0 {
|
||||
LightType::Spot
|
||||
@@ -87,14 +65,8 @@ impl Light {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data + options.
|
||||
///
|
||||
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
|
||||
/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
|
||||
/// light selection, the light view-projection matrix + shadow parameters, then options — total
|
||||
/// **784 bytes** (Step 14, DRAFT 3.1), 16-byte aligned, `Pod` for direct `bytes_of` upload. The
|
||||
/// bind-group layout uses `min_binding_size: None`, so extending this struct is transparent
|
||||
/// (no relayout).
|
||||
|
||||
/// Per-frame GPU uniforms: camera matrices + ambient + global light list + shadow data.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct FrameUniforms {
|
||||
@@ -158,14 +130,18 @@ impl Default for FrameUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-object GPU uniforms: the entity's world-space model matrix.
|
||||
/// Per-object GPU uniforms: the entity's world-space model matrix + emissive color.
|
||||
///
|
||||
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
|
||||
/// Mirrors the WGSL `ObjectUniform` struct. 80 bytes, `Pod`.
|
||||
/// In the GPU-driven path, the emissive lives in the `MatSlot` padding (bytes 64-79),
|
||||
/// pre-filled by the CPU at slot creation and never overwritten by the compute pass.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct ObjectUniform {
|
||||
/// Model matrix (object → world space). Offset 0.
|
||||
pub model: Mat4,
|
||||
/// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression).
|
||||
pub emissive: Vec4,
|
||||
}
|
||||
|
||||
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
|
||||
@@ -517,9 +493,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn object_uniform_layout_matches_wgsl() {
|
||||
assert_eq!(size_of::<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!(offset_of!(ObjectUniform, model), 0);
|
||||
assert_eq!(offset_of!(ObjectUniform, emissive), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+28
-13
@@ -17,7 +17,7 @@
|
||||
|
||||
use crate::core::{Geometry, Transform};
|
||||
use crate::pipeline::PipelineCache;
|
||||
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
|
||||
use crate::camera::Camera; use crate::lights::Lights; use crate::resources::{BBoxSlot, Material, Mesh, Texture, TransformSlot};
|
||||
use crate::scene::Entity;
|
||||
use glam::Vec3;
|
||||
use std::cell::RefCell;
|
||||
@@ -452,7 +452,7 @@ impl Scene {
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the scene's active camera, for in-place per-frame edits
|
||||
/// (e.g. [`CameraController::apply_to`](crate::resources::CameraController) during `update`).
|
||||
/// (e.g. [`CameraController::apply_to`](crate::camera::CameraController) during `update`).
|
||||
pub fn camera_mut(&mut self) -> &mut Camera {
|
||||
&mut self.camera
|
||||
}
|
||||
@@ -468,15 +468,15 @@ impl Scene {
|
||||
color: [f32; 3],
|
||||
intensity: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
if self.lights.len() >= crate::lights::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
crate::lights::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights
|
||||
.directional
|
||||
.push(crate::resources::lights::directional_light(
|
||||
.push(crate::lights::directional_light(
|
||||
dir, color, intensity,
|
||||
));
|
||||
Ok(())
|
||||
@@ -492,15 +492,15 @@ impl Scene {
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
if self.lights.len() >= crate::lights::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
crate::lights::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights
|
||||
.point
|
||||
.push(crate::resources::lights::point_light(
|
||||
.push(crate::lights::point_light(
|
||||
pos, color, intensity, radius,
|
||||
));
|
||||
Ok(())
|
||||
@@ -520,13 +520,13 @@ impl Scene {
|
||||
radius: f32,
|
||||
half_angle: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
if self.lights.len() >= crate::lights::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
crate::lights::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights.spot.push(crate::resources::lights::spot_light(
|
||||
self.lights.spot.push(crate::lights::spot_light(
|
||||
pos, dir, color, intensity, radius, half_angle,
|
||||
));
|
||||
Ok(())
|
||||
@@ -603,6 +603,21 @@ impl Scene {
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Sets the emissive color on a registered material (Étape 22, 6.2).
|
||||
/// Uses `Arc::get_mut` — only works if the material has a single reference (i.e., no mesh
|
||||
/// has captured it yet). Call BEFORE `create_mesh` to pre-set the emissive.
|
||||
/// Returns Err if the material doesn't exist or has multiple references.
|
||||
pub fn set_material_emissive(&mut self, id: &str, emissive: [f32; 4]) -> Result<(), String> {
|
||||
let mat = self
|
||||
.materials
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("Material '{}' not found.", id))?;
|
||||
let inner = Arc::get_mut(mat)
|
||||
.ok_or_else(|| format!("Material '{}' has multiple references; cannot modify in place.", id))?;
|
||||
inner.emissive = emissive;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Associates an entity label with a mesh for rendering iteration, using an identity transform.
|
||||
/// The appearance (Material) is read from the Mesh itself (or the Scene's default), so no
|
||||
/// material_id is needed here (DRAFT Step 7.3).
|
||||
@@ -857,12 +872,12 @@ mod tests {
|
||||
scene
|
||||
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
|
||||
.unwrap();
|
||||
while scene.lights().len() < crate::resources::MAX_LIGHTS {
|
||||
while scene.lights().len() < crate::lights::MAX_LIGHTS {
|
||||
scene
|
||||
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS);
|
||||
assert_eq!(scene.lights().len(), crate::lights::MAX_LIGHTS);
|
||||
assert!(
|
||||
scene
|
||||
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
@@ -147,9 +148,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
|
||||
let base = texel.rgb * in.color.rgb;
|
||||
|
||||
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex telle quelle.
|
||||
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
|
||||
if (frame.options.x != 0u) {
|
||||
return vec4<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);
|
||||
@@ -203,7 +205,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+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.
|
||||
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.wgsl");
|
||||
|
||||
/// The bloom threshold pass shader (Étape 23). Extracts pixels above a luminance threshold
|
||||
/// from the full-res HDR texture into a half-res bright texture. Soft-knee falloff.
|
||||
pub const BLOOM_THRESHOLD_SHADER: &str = include_str!("../shaders/bloom_threshold.wgsl");
|
||||
|
||||
/// The bloom blur pass shader (Étape 23). Separable 9-tap Gaussian, direction via uniform.
|
||||
/// Ping-ponged between two half-res textures (H pass then V pass).
|
||||
pub const BLOOM_BLUR_SHADER: &str = include_str!("../shaders/bloom_blur.wgsl");
|
||||
|
||||
/// The bloom composite pass shader (Étape 23). Adds the blurred bloom (half-res, upsampled)
|
||||
/// to the full-res HDR texture, scaled by intensity. Writes to a full-res composite texture.
|
||||
pub const BLOOM_COMPOSITE_SHADER: &str = include_str!("../shaders/bloom_composite.wgsl");
|
||||
|
||||
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
|
||||
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
|
||||
/// rewrites only the transform slots and the cull uniforms.
|
||||
@@ -102,7 +114,7 @@ pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0];
|
||||
/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout
|
||||
/// so upper layers can address the shadow light safely, Step 14 D7). Also used as the no-caster
|
||||
/// sentinel for `FrameUniforms.shadow_light_index`.
|
||||
pub use crate::resources::uniform::MAX_LIGHTS;
|
||||
pub use crate::lights::MAX_LIGHTS;
|
||||
|
||||
/// Default application title displayed in the OS taskbar/window decorations.
|
||||
pub const APP_DEFAULT_TITLE: &str = "WSG App";
|
||||
|
||||
@@ -116,3 +116,69 @@ fn tonemap_shader_is_valid_wgsl() {
|
||||
"the three entry points are expected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the `bloom_threshold.wgsl` shader (Étape 23) via naga.
|
||||
#[test]
|
||||
fn bloom_threshold_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/bloom_threshold.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("bloom_threshold.wgsl: parsing error: {e:?}"));
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("bloom_threshold.wgsl: validation failed: {e:?}"));
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the `bloom_blur.wgsl` shader (Étape 23) via naga.
|
||||
#[test]
|
||||
fn bloom_blur_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/bloom_blur.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("bloom_blur.wgsl: parsing error: {e:?}"));
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("bloom_blur.wgsl: validation failed: {e:?}"));
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the `bloom_composite.wgsl` shader (Étape 23) via naga.
|
||||
#[test]
|
||||
fn bloom_composite_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/bloom_composite.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("bloom_composite.wgsl: parsing error: {e:?}"));
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("bloom_composite.wgsl: validation failed: {e:?}"));
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(entry_names, vec!["fs_main", "vs_main"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user