Compare commits
59 Commits
26a3cda6f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3f056dbb | |||
| 805babe53d | |||
| 004761252b | |||
| 40fac63590 | |||
| eac266dd86 | |||
| a3a7ff4a6b | |||
| f15e920109 | |||
| 531c43a457 | |||
| 3a424afe8c | |||
| 3dd372410f | |||
| ef13913464 | |||
| 5ae978da23 | |||
| 4bfd712496 | |||
| 5a92daf7ab | |||
| eeb471f37c | |||
| b41f7e259e | |||
| 4da89c7178 | |||
| 84ceffc755 | |||
| b2db12e637 | |||
| ab13fa725e | |||
| bfe68f4393 | |||
| 9a51ff7602 | |||
| 67bd7af095 | |||
| 39167ee05f | |||
| c2cbd7fadb | |||
| 8779af067f | |||
| a2bd25ecb2 | |||
| cc26080ed0 | |||
| 2582b6f571 | |||
| 73085de537 | |||
| e8ff364d0d | |||
| d518948deb | |||
| 49ecdea249 | |||
| 1258668e4f | |||
| b0aafefed9 | |||
| f4df63a136 | |||
| acf819737d | |||
| 23568e8820 | |||
| 440f2dffa3 | |||
| 3de84aa4dc | |||
| 7a5d627221 | |||
| dfa7403260 | |||
| fca5d728ad | |||
| 17989b177f | |||
| 9ad47e8790 | |||
| 4a94ad4ac1 | |||
| 6f6b72ae8d | |||
| 757d20f746 | |||
| 7a48f2517c | |||
| 84d7176c71 | |||
| eb5f6aa827 | |||
| 62b5cac11b | |||
| df546a4ac9 | |||
| 4be9737065 | |||
| d2dd1967cd | |||
| 0a85aff17b | |||
| 43e8bfb40a | |||
| f10e249898 | |||
| c1e07b42b4 |
@@ -43,6 +43,8 @@ WGPU doesn't have a native "Context" object — this type groups them together f
|
||||
- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading.
|
||||
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
|
||||
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
|
||||
- **WGSL `select` argument order** (cost us a day): `select(reject, accept, cond)` returns the **second** arg when `cond` is true — the reverse of HLSL's `select(trueVal, falseVal, cond)`. In `shaders/gpu_driven.wgsl` the cull pass must stay `select(0u, u32(flags.z), visible)` (visible ⇒ full count, culled ⇒ 0). Swapped args silently zero the counts of every visible entity → black window. See the GOTCHA comment at the top of that shader.
|
||||
- **LOD UV blending: never fold integer-tile jumps, freeze seam twins instead** (cost us a day, 2026-09-23): a UV *seam* is two copies of the same 3-D point on integer-apart UVs (u=0/u=1 columns) — it is NOT a mesh edge, so the decimation must record the weld's refused pairs and **freeze** those twins (any edge touching one is excluded from the PQ). A co-facial edge spanning a whole tile (cone apex v=1 ↔ base v=0) is a *legit* chart span — the chart is bilinear, so the UVs **blend linearly** (fold the integer jump to zero and the apex UV smears down the cone side). And the attribute-aware weld refuses a Δ of *exactly* 0.5 (ambiguous: seam at its widest vs legit half-tile jump — the cone's u=1 column vs the cap-disc chart sits exactly there). See the comments in `geometry.rs` (`welded`, `Collapse::collapse_edge`) and the cone/seam regression tests.
|
||||
|
||||
<!-- lean-ctx -->
|
||||
## lean-ctx
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# WSG - WGPU Simple Graphics Library
|
||||
|
||||
## Project Type
|
||||
Rust workspace (2024 edition) wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations.
|
||||
|
||||
## Workspace Structure
|
||||
```
|
||||
Cargo.toml # workspace root — no dependencies here
|
||||
lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30
|
||||
examples/Cargo.toml # depends on wsg-lib via path reference
|
||||
lib/lib.rs # lib entry point
|
||||
lib/context.rs # Context type (aggregates wgpu objects: Instance, Surface, Adapter, Device, Queue)
|
||||
lib/renderer.rs # renderer implementation
|
||||
examples/src/main.rs # example binary
|
||||
```
|
||||
|
||||
**Key convention**: `wsg-lib` is referenced from `examples/` via relative path (`path = "../lib"`). Do not publish this to crates.io as-is — it uses a local path dependency.
|
||||
|
||||
## Essential Commands
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| Build everything | `cargo build --workspace` |
|
||||
| Run examples | `cargo run -p examples` |
|
||||
| Test | `cargo test --workspace` |
|
||||
| Check | `cargo check --workspace` |
|
||||
| Format | `cargo fmt --all` |
|
||||
|
||||
No custom scripts or linting tooling beyond standard Cargo conventions.
|
||||
|
||||
## Architecture Overview
|
||||
The library's purpose is to abstract the five core wgpu objects into a single **Context**:
|
||||
|
||||
- **Instance** — GPU backend selection (Vulkan/Metal/DX12)
|
||||
- **Surface** — window rendering surface (via winit)
|
||||
- **Adapter** — physical/logical GPU device
|
||||
- **Device** — buffer/texture/pipeline creation
|
||||
- **Queue** — command submission
|
||||
|
||||
WGPU doesn't have a native "Context" object — this type groups them together for a simpler user API. See README.md for the French documentation of each component.
|
||||
|
||||
## Gotchas
|
||||
- Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`).
|
||||
- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading.
|
||||
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
|
||||
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
|
||||
Generated
+136
@@ -18,6 +18,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -181,6 +187,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
@@ -307,6 +319,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -387,12 +408,32 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -488,6 +529,9 @@ name = "glam"
|
||||
version = "0.33.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
@@ -562,6 +606,21 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -750,6 +809,36 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.0"
|
||||
@@ -1234,6 +1323,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1313,6 +1415,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -1517,6 +1625,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -2429,6 +2543,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glam",
|
||||
"image",
|
||||
"pollster",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu",
|
||||
@@ -2517,3 +2632,24 @@ dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -2,25 +2,83 @@
|
||||
|
||||
WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single `Context`, adds small building blocks (`Mesh`, `Material`, `PipelineCache`, `Frame`), and exposes the low-level primitives for advanced users.
|
||||
|
||||
> **Status: unstable development version.** The manual workflow below is fully working, and the high-level "declarative" workflow (automatic `App` scene rendering) works for flat/NDC drawing. The GPU-driven two-pass pipeline described in the architecture docs is **not implemented yet** — see [Status](#status) and [Roadmap](#roadmap).
|
||||
> **Status: unstable development version.** The **declarative workflow** (`AppBuilder` + `App` + `AppHandler`) is the **recommended** path and is fully working: scene auto-rendering (`App::render_scene`), 3D Phong lighting, textures, shadows, camera and unified input — the `demo` example is the showcase. The **manual workflow** (`Context`/`Renderer`/`PipelineCache`) coexists for fine-grained control. 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).
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
|------|-------|
|
||||
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working |
|
||||
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
|
||||
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) |
|
||||
| `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) |
|
||||
| GPU-driven two-pass pipeline (Compute → indirect draw) | 📋 Roadmap — spec in [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
|
||||
| 3D transforms (MVP uniforms, camera in the pipeline) | 📋 Roadmap — the bundled shader draws positions straight to NDC today |
|
||||
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Step 15, Phase 3) — `render_scene` + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL `select` argument order — and verified by GPU readback, D14). User doc: [gpu-driven.md](docs/user/gpu-driven.md) · spec: [ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
|
||||
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader |
|
||||
|
||||
Note: the bundled `basic_shader.wgsl` treats vertex positions as already in NDC space, so what you can see today is flat, untransformed drawing (e.g. a colored quad) — not a 3D scene.
|
||||
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).
|
||||
|
||||
## What it does
|
||||
|
||||
### Manual workflow (working — recommended today)
|
||||
### Declarative workflow (recommended)
|
||||
|
||||
Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself. This is the only workflow that renders pixels today (same code as the `manual` example):
|
||||
Register your scene once in `setup()`, then let `App` handle the window lifecycle, events,
|
||||
input and frame presentation — **without importing wgpu or winit**. This is the workflow of
|
||||
the `simple`, `cube`, `demo`, `shadow_test` and `spot_test` examples (excerpt below is `simple`):
|
||||
|
||||
```rust
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
use wsg_lib::AppHandler;
|
||||
|
||||
struct MonQuad;
|
||||
|
||||
impl AppHandler for MonQuad {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.renderer_mut().set_unlit(true); // 2D flat (optional)
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0, 1.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
app.scene.create_mesh("quad_mesh", geometry, None).unwrap(); // None = default material
|
||||
app.scene.add_entity("quad", "quad_mesh").unwrap();
|
||||
}
|
||||
// `update(&mut self, app)` — your per-frame logic (empty default).
|
||||
// `render(&mut self, app, frame)` — default: `app.render_scene(frame.view())`,
|
||||
// the whole scene is drawn automatically in one pass per frame.
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
||||
app.run(MonQuad)
|
||||
}
|
||||
```
|
||||
|
||||
> API note: `Scene` methods currently return `Result<_, String>` — typed-error unification is
|
||||
> on the roadmap. `Scene::create_mesh(id, geometry, material)` takes a CPU `Geometry` (source of
|
||||
> truth, retained as `Arc<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;
|
||||
@@ -28,7 +86,7 @@ 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::{Material, Mesh, Vertex};
|
||||
use wsg_lib::resources::{Geometry, Material, Mesh};
|
||||
use wsg_lib::utils;
|
||||
|
||||
fn main() {
|
||||
@@ -39,20 +97,30 @@ fn main() {
|
||||
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)
|
||||
let renderer = Renderer::new(&context, format);
|
||||
// `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("basic", utils::BASIC_SHADER_PATH).unwrap();
|
||||
cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap();
|
||||
|
||||
// Material + mesh
|
||||
let material = Material::new(renderer.format(), "basic", &mut cache);
|
||||
let vertices: [Vertex; 4] = [
|
||||
Vertex { position: [-0.5, 0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], color: [1.0, 0.0, 0.0, 1.0] },
|
||||
Vertex { position: [ 0.5, 0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [1.0, 0.0], color: [0.0, 1.0, 0.0, 1.0] },
|
||||
Vertex { position: [ 0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [1.0, 1.0], color: [0.0, 0.0, 1.0, 1.0] },
|
||||
Vertex { position: [-0.5, -0.5, 0.0], normal: [0.0, 0.0, 1.0], uv: [0.0, 1.0], color: [1.0, 1.0, 0.0, 1.0] },
|
||||
];
|
||||
let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
|
||||
let mesh = Mesh::new(renderer.device(), &vertices, Some(&indices));
|
||||
// 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| {
|
||||
@@ -71,61 +139,34 @@ fn main() {
|
||||
}
|
||||
```
|
||||
|
||||
### Declarative workflow (work in progress)
|
||||
|
||||
The intended API: register your scene's resources and entities once, then let `App` handle the window lifecycle, event processing and frame presentation. Users implement the `AppHandler` trait to inject per-frame logic:
|
||||
|
||||
```rust
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::{App, AppHandler};
|
||||
|
||||
struct MyGame;
|
||||
|
||||
impl AppHandler for MyGame {
|
||||
// update() has an empty default — implement it to mutate scene state each frame.
|
||||
// render(app, frame) has a default that draws the whole scene automatically via
|
||||
// app.render_scene(frame.view()). You don't need to implement it for the common case.
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), wsg_lib::utils::WsgError> {
|
||||
let app = AppBuilder::new().build().await?;
|
||||
|
||||
// Register your scene once (string IDs), then App renders it automatically each frame:
|
||||
// app.cache.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)?;
|
||||
// app.scene.add_mesh("quad", Arc::new(mesh))?;
|
||||
// app.scene.add_material("mat", Arc::new(Material::new(app.renderer.format(), "basic", &mut app.cache)))?;
|
||||
// app.scene.add_entity("my_quad", "quad", "mat")?;
|
||||
|
||||
app.run(MyGame)
|
||||
}
|
||||
```
|
||||
|
||||
> API note: `Scene::add_mesh` / `add_material` / `add_entity` and `PipelineCache::register_shader`
|
||||
> currently return `Result<_, String>` — typed error unification is on the roadmap.
|
||||
|
||||
## 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`, `Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (types only, not yet used by the pipeline).
|
||||
- **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 planned target architecture — a GPU-driven two-pass pipeline (Compute Pass: world matrices + frustum culling → Indirect Draw Buffer, then a single `draw_indexed_indirect` per frame) — is specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) but is **not implemented yet**.
|
||||
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 `update()` / `render()` callbacks | ✅ (`Frame::view()` exposed; default `render` draws the scene) |
|
||||
| AppHandler | Trait | User-defined `setup()` / `update()` / `render()` callbacks | ✅ (default `render` draws the scene via `App::render_scene`) |
|
||||
| Scene | Struct | String-ID registry: meshes, materials, entities | ✅ (registry auto-rendered by the facade) |
|
||||
| Context | Struct | GPU hardware lifecycle (Instance, Surface, Adapter, Device, Queue) | ✅ |
|
||||
| Renderer | Struct | Binds Material + Mesh into a RenderPass, submits | ✅ (`render_scene` batches one pass/frame) |
|
||||
| PipelineCache | Struct | Shader → compiled RenderPipeline cache | ✅ |
|
||||
| Material | Struct | Shader ID → RenderPipeline | ✅ |
|
||||
| Mesh / Vertex | Struct | GPU geometry container / CPU-side vertex tuple | ✅ |
|
||||
| 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 | 📋 Types only, not in the pipeline |
|
||||
| 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
|
||||
|
||||
@@ -134,32 +175,56 @@ WSG is **not published on crates.io** — depend on it by path:
|
||||
```toml
|
||||
[dependencies]
|
||||
wsg-lib = { path = "/path/to/wsg/lib" }
|
||||
pollster = "0.4" # only if you use the async AppBuilder
|
||||
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (async AppBuilder)
|
||||
winit = "0.30" # only if your code mentions winit types (KeyCode, MouseButton)
|
||||
```
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| Build everything | `cargo build --workspace` |
|
||||
| Run the working example | `cargo run -p wsg-lib --example manual` |
|
||||
| Run the showcase (primitives, lights, shadows, orbital camera) | `cargo run -p wsg-lib --example demo` |
|
||||
| Run the 3D MVP example | `cargo run -p wsg-lib --example cube` |
|
||||
| Run the minimal example | `cargo run -p wsg-lib --example simple` |
|
||||
| Run the shadow / spot light showcases | `cargo run -p wsg-lib --example shadow_test` / `cargo run -p wsg-lib --example spot_test` |
|
||||
| Run the advanced (manual) example | `cargo run -p wsg-lib --example manual` |
|
||||
| Check everything (incl. examples) | `cargo check --all-targets` |
|
||||
|
||||
The `manual` example is the reference for the low-level workflow. The `simple` example (App facade) registers a colored quad and renders it automatically through the declarative path — it draws a scene without importing wgpu.
|
||||
The `demo` example is the showcase: one of each primitive, procedural textures, three lights, a shadow-casting light and a live orbital camera. `simple` is the minimal declarative app (a colored quad, unlit); `cube` is the 3D MVP (a rotating Phong-lit, textured cube); `shadow_test` and `spot_test` isolate the shadow and spot-light systems; `manual` is the reference for the low-level workflow. All of them except `manual` use the declarative path and draw a scene **without importing wgpu**.
|
||||
|
||||
## Documentation
|
||||
|
||||
The architecture docs live in `docs/tech/` and are written in **French**. Each document states whether it describes the **current** (implemented) state or the **target** (planned, not yet implemented) architecture:
|
||||
Three layers (user docs and API reference in **English**; technical docs in **French**):
|
||||
|
||||
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet.
|
||||
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. 🎯 **Target** — GPU-driven pipeline, ROADMAP Phase 3.
|
||||
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. 🎯 **Target** — model for the future scene auto-render.
|
||||
**User documentation — [docs/user](docs/user/README.md)** (how to use the API, no wgpu knowledge needed):
|
||||
- [Quickstart](docs/user/quickstart.md) — first window, first object, in ~30 lines
|
||||
- [Meshes](docs/user/meshes.md) · [Materials & textures](docs/user/materials.md) · [Lights](docs/user/lights.md)
|
||||
- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md)
|
||||
|
||||
**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture):
|
||||
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ **Current** — facade (`App`/`AppHandler`) 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.
|
||||
|
||||
**API reference** — full rustdoc: `cargo doc -p wsg-lib --no-deps` (every public type is documented).
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. ✅ **Scene auto-rendering** — `App::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.)
|
||||
2. **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, single `draw_indexed_indirect` (see ARCHI_CPU_GPU).
|
||||
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 uniforms + camera support in the vertex shader.
|
||||
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.)
|
||||
|
||||
+86
-119
@@ -1,132 +1,99 @@
|
||||
# DRAFT — Plan d'implémentation : « 3D + éclairage Phong »
|
||||
# Étape 21 — Module `mesh` : primitives optionnelles + import
|
||||
|
||||
> **Usage.** Ce fichier (dans `docs/`) sert de brouillon pour le plan détaillé de l'étape en cours.
|
||||
> **Son contenu est effacé au début de chaque nouvelle étape.** La source de vérité de l'état est
|
||||
> le code + README.md ; les autres docs `docs/*` restent stables.
|
||||
>
|
||||
> **Étape.** 3D + éclairage Phong (ROADMAP 1.3 + 1.5). Objectif MVP : **un mesh 3D éclairé à l'écran**,
|
||||
> rendu automatiquement par la boucle `App` (Scene auto-render déjà en place).
|
||||
>
|
||||
> **État de départ vérifié.**
|
||||
> - Rendu automatique fonctionnel mais **plat** : `basic_shader.wgsl` pose les positions telles quelles
|
||||
> (`vec4(position, 1.0)`), aucune matrice, aucune uniform, aucun éclairage.
|
||||
> - `Renderer::render_scene` parcourt `iter_entities()` en une passe (`&self`, `&Scene`), sans transform.
|
||||
> - `Scene::entities` : `HashMap<String, (mesh_id, material_id)>` — pas de `Transform` par entité.
|
||||
> - `Camera` (`resources/camera.rs`) : **fichier orphelin, non exporté** (absent de `resources/mod.rs`) ;
|
||||
> `Transform`/`Geometry` exportés via `math`.
|
||||
> - `PipelineCache::build_pipeline` : `bind_group_layouts: &[]`, `immediate_size: 0` — aucun binding.
|
||||
> - Défaut latente : `basic_shader.wgsl` déclare `@location(1) uv`, `(2) color` alors que le
|
||||
> `VertexBufferLayout` réel expose `(1) normal`, `(2) uv`, `(3) color`.
|
||||
**Statut : ✅ TERMINÉE**
|
||||
|
||||
---
|
||||
## Résumé
|
||||
|
||||
## Étape 1 — Fondations data : Transform + Camera exposées
|
||||
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`
|
||||
|
||||
**But** : donner à chaque entité un `Transform` et rendre `Camera` utilisable via l'API publique, **sans**
|
||||
toucher au rendu (pure façade de données, validable par compilation).
|
||||
## Structure finale
|
||||
|
||||
- [X] 1.1 **Exporter `Camera`** : dans `lib/src/resources/mod.rs`, ajouter
|
||||
`pub mod camera;` et `pub use camera::Camera;` (aujourd'hui fichier orphelin non compilé). *(fait — 2026-09-16)*
|
||||
- [X] 1.2 **Type `Entity` + transform** : nouvelle struct
|
||||
`Entity { mesh_id: String, material_id: String, transform: Transform }` (module `scene` ou `resources`).
|
||||
Remplacer `Scene::entities: HashMap<String, (String, String)>` par
|
||||
`HashMap<String, Entity>`. Sérialiser `iter_entities()` pour rendre le `&Transform`.
|
||||
*(fait — `lib/src/scene/entity.rs`)*
|
||||
- [X] 1.3 **Compat API** : garder `add_entity(label, mesh_id, material_id)` (transform identité par défaut)
|
||||
+ ajouter `add_entity_with_transform(label, mesh_id, material_id, transform)`.
|
||||
Ajouter `entity_transform(label) -> Option<&Transform>` et `set_entity_transform(label, transform)`.
|
||||
*(fait — 2026-09-16)*
|
||||
- [X] **Validation** : `cargo check --workspace` 0 warning ; `cargo doc --no-deps` 0 warning ; les exemples
|
||||
`simple`/`manual` compilent inchangés (défaut : identité ⇒ même rendu). *(fait — 0 warning. Au passage,
|
||||
`camera.rs` étant désormais compilée, les fonctions glam dépréciées `look_at_rh`/`perspective_rh_gl` ont été
|
||||
migrées vers `glam::camera::rh::view::look_at_mat4` / `glam::camera::rh::proj::opengl::perspective`.)*
|
||||
```
|
||||
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/
|
||||
```
|
||||
|
||||
## Étape 2 — Shader Phong `standard_shader.wgsl`
|
||||
## Features (Cargo.toml)
|
||||
|
||||
**But** : produire un rendu 3D éclairé via un nouveau shader, sans encore le brancher.
|
||||
| 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) |
|
||||
|
||||
- [X] 2.1 **Créer `lib/src/shaders/standard_shader.wgsl`** avec le **contrat vertex correct** :
|
||||
`@location(0) position : vec3`, `(1) normal : vec3`, `(2) uv : vec2`, `(3) color : vec4`.
|
||||
- `@group(0) @binding(0)` : `FrameUniforms { view: mat4, proj: mat4, cam_pos: vec4, light_dir: vec4, light_color: vec4, options: vec4<u32> }` (options.x = unlit flag)
|
||||
- `@group(1) @binding(0)` : `ObjectUniform { model: mat4 }`
|
||||
- `vs_main` : `clip_position = proj * view * model * vec4(position,1)` ; passe `normal`/`color` en espace monde.
|
||||
- `fs_main` : éclairage hémisphérique (ambient) + diffuse directionnel (max(dot(N,L),0)), sortie `vec4(color*light, 1)`.
|
||||
- **Mode unlit** : `options.x != 0` neutralise la directionnelle → couleur plate. Ainsi « 2D » = `standard`
|
||||
non-éclairé, **cas particulier de la 3D** (décision actée). *(fait — 2026-09-16)*
|
||||
- [X] 2.2 **Constantes** : ajouter `STANDARD_SHADER_PATH = "assets/shaders/standard_shader.wgsl"` et
|
||||
`STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl")` dans `lib/src/utils/conf.rs`.
|
||||
*(fait — 2026-09-16)*
|
||||
- [ ] 2.3 **Migrer `basic` vers le mode unlit de `standard`** (défaut latente réglée) : plus de pipeline au
|
||||
**layout vide séparé**. Le rendu plat = `standard` non-éclairé (identité/ortho + ambiance) sous le **même
|
||||
layout uniformisé**. Le fallback embarqué (`BASIC_SHADER`) devient la variante unlit de `standard`.
|
||||
*(bloqué : dépend des bind groups du `PipelineCache`, infra de l'Étape 3)*
|
||||
- [X] **Validation** : shader validé hors-ligne via un **nouveau test permanent** `lib/tests/wgsl_validate.rs`
|
||||
(naga via `wgpu::naga`, aucune nouvelle dépendance) ; corrigé au passage le cast `mat4x4 -> mat3x3` non
|
||||
supporté (construction de la sous-matrice 3×3 explicite). `cargo test` + `cargo check --workspace --examples`
|
||||
0 warning ; `cargo doc --no-deps` OK ; `cargo fmt` propre. Le shader n'est pas encore compilé par un pipeline
|
||||
(Étape 3). *(fait — 2026-09-16)*
|
||||
## Décisions
|
||||
|
||||
## Étape 3 — Infrastructure uniforms dans le `PipelineCache`
|
||||
| # | 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 |
|
||||
|
||||
**But** : permettre aux pipelines de recevoir des uniforms (bind groups) au lieu de `bind_group_layouts: &[]`.
|
||||
## Tests
|
||||
|
||||
- [ ] 3.1 **Types bytemuck `Pod`** (nouveau `lib/src/resources/uniform.rs`, ou `math/uniform.rs`) :
|
||||
- `#[repr(C)] #[derive(Pod, Zeroable, Copy, Clone)] FrameUniforms` (voir 2.1)
|
||||
- `#[repr(C)] #[derive(...)] ObjectUniform { model: Mat4 }`
|
||||
- (alignement 16 octets : utiliser `Vec4`/tableaux pour éviter le padding). Exporter via le `mod.rs` concerné.
|
||||
- [ ] 3.2 **Bind group layouts** : dans `build_pipeline`, créer 2 `BindGroupLayout`
|
||||
(frame @0 + object @1, chacun avec un buffer uniform `Vertex`/`Fragment`/`Vertex|Fragment` selon usage) et les
|
||||
passer dans `PipelineLayoutDescriptor.bind_group_layouts`. `immediate_size` reste 0 (pas de `var<immediate>`).
|
||||
- [ ] 3.3 **Acté : un seul layout pour tous** (option A). `build_pipeline` attache **toujours** les 2 bind groups
|
||||
(frame @0 + object @1). Plus de famille `basic` au layout vide : tout matériau partage le même layout
|
||||
uniformisé. `manual`/quad plat migrent (Étape 5).
|
||||
- [ ] **Validation** : `cargo check` 0 warning ; `cargo doc` 0 warning (types documentés, `missing_docs` actif).
|
||||
- 107 unit tests (dont 7 tests OBJ parser)
|
||||
- 4 WGSL validation
|
||||
- 5 doctests
|
||||
- **Total : 116 tests, 0 failures**
|
||||
|
||||
## Étape 4 — Rendu 3D dans le `Renderer`
|
||||
## Build vérifié
|
||||
|
||||
**But** : `render_scene` applique matrices + éclairage par entité.
|
||||
|
||||
- [ ] 4.1 **Buffers frame partagés** : créer le `wgpu::Buffer` `FrameUniforms` + `BindGroup(0)` dans
|
||||
`Renderer::new` (ou à la 1re frame). Écrire chaque frame : view/proj (caméra active) + lumière.
|
||||
- [ ] 4.2 **Buffers object par entité** : `Renderer` maintient un cache
|
||||
`RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>` clefé par label d'entité (créé à la 1re rencontre),
|
||||
car `render_scene(&self, &Scene)` est immuable. Chaque frame : écrire `ObjectUniform.world = entity.transform.to_matrix()` + `set_bind_group(1, ...)`.
|
||||
- [ ] 4.3 **Caméra active** : ajouter `scene.set_active_camera(Camera)` / `scene.active_camera() -> Option<&Camera>`.
|
||||
Calcul du `proj` avec l'aspect de la fenêtre (`window.inner_size()` accessible via `App.window`).
|
||||
- [ ] 4.4 **`draw_entity` étendu** : `set_bind_group(0, frame_bg)` + `set_bind_group(1, object_bg)` avant le draw,
|
||||
pour **tout** matériau (layout unique). Le chemin bas-niveau `Renderer::render` pose aussi les 2 bind groups
|
||||
(frame partagé + object du mesh appelant).
|
||||
- [ ] **Validation** : `cargo check` 0 warning ; exécution `simple` (sans panique, boucle active) ;
|
||||
`manual` non-régressif (chemin bas-niveau).
|
||||
|
||||
## Étape 5 — Exemple 3D (cube éclairé)
|
||||
|
||||
**But** : démontrer l'objectif MVP à l'écran et **migrer** les exemples sur le pipeline unifié.
|
||||
|
||||
- [ ] 5.1 **Nouvel exemple `lib/examples/cube.rs`** : cube unitaire (positions + normales), matériau
|
||||
`standard` éclairé, `Transform` non-identique, camera + lumière directionnelle, rotation dans `AppHandler::update`.
|
||||
Toujours via `AppBuilder`/scène automatique, **sans importer wgpu** (comme `simple`).
|
||||
- [ ] 5.2 **Migrer `simple.rs`** (quad plat → `standard` **unlit**, transform identité) et **`manual.rs`** (bas niveau
|
||||
→ bind groups frame+object posés, unlit). `basic` disparaît comme famille séparée.
|
||||
- [ ] **Validation** : compile + tourne sans panique ; rotation/éclairage visibles (à confirmer sur GPU/fenêtre).
|
||||
|
||||
## Étape 6 — Validation globale & docs
|
||||
|
||||
- [ ] 6.1 `cargo check --workspace` 0 warning ; `cargo doc --no-deps` 0 warning ; `cargo fmt --all`.
|
||||
- [ ] 6.2 Cas limites (comme à l'étape précédente) : scène vide, mesh non indexé, mesh 0-vertex.
|
||||
- [ ] 6.3 Mettre à jour `README.md` (statut 3D) + `docs/PLAN.md`/`docs/ROADMAP.md` (cases 1.3/1.5 actées).
|
||||
- [ ] 6.4 Commits conventionnels (`feat:`, `docs:`), diffs ciblés.
|
||||
|
||||
---
|
||||
|
||||
## Décisions actées (verrouillées avant l'implémentation)
|
||||
|
||||
| Décision | Option proposée | Justification |
|
||||
|----------|-----------------|---------------|
|
||||
| Schéma uniforms | **Acté : 2 bind groups** — frame partagé (@0) + object par entité (@1) | Étendu, portable sur tous backends (Metal/DX12/Vulkan) ; `var<immediate>` neuf, limites de taille et hazard d'écriture par objet ; 2 binds/draw seulement, trivialement « pipeline bind-less » plus tard |
|
||||
| Emplacement types uniforms | **Acté : `resources/uniform.rs`** (`FrameUniforms`, `ObjectUniform`, types `Pod` bytemuck) | Couche de données GPU (avec Camera/Mesh/Material/Vertex) ; préserve `math/` pur (sans bytemuck ni couplage wgpu) |
|
||||
| Cache object buffer | `RefCell<HashMap<label, (Buffer, BindGroup)>>` dans `Renderer` | `render_scene(&self)` immuable ; MVP petit nombre d'entités |
|
||||
| Transform dans l'entité | `Entity { mesh_id, material_id, transform }` + `add_entity_with_transform` | `add_entity` garde sa signature (transform identité) |
|
||||
| Layout pipeline | **Acté : un seul layout pour tous** (frame @0 + object @1) ; `basic` unlit = variante de `standard` | 2D = cas particulier 3D (décision utilisateur) ; supprime la fourchette à deux layouts pour toujours |
|
||||
| Exemple démo | Nouvel exemple `cube.rs` (éclairé) ; `simple.rs` et `manual.rs` **migrés** vers le pipeline unifié (unlit) | Démontre le 3D sans dédoubler ; cohérent avec « un seul layout pour tous » |
|
||||
| Correction `basic_shader.wgsl` | **Supprimer** `basic` comme pipeline séparé ; le quad plat devient `standard` unlit | 2D ⊂ 3D : pas de famille de pipeline dédiée |
|
||||
- `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"` ✅
|
||||
|
||||
+25
-30
@@ -11,11 +11,19 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
||||
|
||||
Ce plan définit les étapes prioritaires pour finaliser l'architecture actuelle. L'objectif est de rendre l'API intuitive pour l'utilisateur standard tout en conservant la puissance de contrôle pour l'utilisateur avancé.
|
||||
|
||||
> **Statut réel (à jour au 2026-09-16).** Ce plan couvre la phase de *consolidation* passée ; la source
|
||||
> de vérité sur l'état actuel est **README.md** et le code. Depuis la révision du 2026-09-14, l'étape
|
||||
> **« Scene auto-render »** a été réalisée : le rendu de la `Scene` est **automatisé** en une seule
|
||||
> passe groupée via `App::render_scene(frame.view())` (appelée par défaut dans `AppHandler::render`),
|
||||
> et `simple.rs` (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement.
|
||||
> **Statut réel (à jour au 2026-09-18).** La phase de *consolidation* (Phases 1 à 3 de ce plan) est
|
||||
> **terminée** ; la source de vérité sur l'état actuel est **README.md** et le code. Depuis la révision
|
||||
> du 2026-09-14, le rendu de la `Scene` est automatisé en une passe groupée
|
||||
> (`App::render_scene(frame.view())`, appelée par défaut dans `AppHandler::render`) et `simple.rs`
|
||||
> (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement. Les étapes suivantes
|
||||
> ont ensuite : posé l'infrastructure 3D (bind groups uniformes frame+object partagés, caméra active,
|
||||
> matrices monde par entité — Étapes 3+4, 2026-09-16) ; atteint le **MVP 3D Phong** (Étape 5,
|
||||
> 2026-09-17 : l'exemple `cube` ; le 2D plat = variante **unlit** de `standard` via
|
||||
> `Renderer::set_unlit`) ; rattaché le `PipelineCache` à la `Scene` et fait référencer son `Material`
|
||||
> par chaque `Mesh` (Étape 7) ; donné à `Mesh` une source de vérité **CPU partagée**
|
||||
> (`geometry: Arc<Geometry>`, Étape 8) ; activé un **depth buffer** sur toutes les passes (Étape 9) ;
|
||||
> et ajouté les **textures diffuses** (Étape 10, 2026-09-18 : `resources::Texture` + bind group @2 +
|
||||
> `Material.texture`).
|
||||
|
||||
## Phase 1 : Finalisation et Nettoyage de l'Existant (Priorité Absolue)
|
||||
|
||||
@@ -45,7 +53,7 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
|
||||
### Intégration de la Scene
|
||||
|
||||
- [X] Formaliser la structure `Scene` : un conteneur qui liste les Entities.
|
||||
- [ ] Associer le `PipelineCache` à la Scene pour que la gestion des matériaux soit entièrement portée par la scène (actuellement le cache est porté par `App`, indépendant de la Scene — le rendu de la scène est, lui, déjà automatisé depuis 2026-09-16).
|
||||
- [X] Associer le `PipelineCache` à la Scene pour que la gestion des matériaux soit entièrement portée par la scène (actuellement le cache est porté par `App`, indépendant de la Scene — le rendu de la scène est, lui, déjà automatisé depuis 2026-09-16). *(fait — 2026-09-17, DRAFT Étape 7 : `Scene::init_gpu` détient device+format+`PipelineCache` ; `App` n'a plus de champ `cache`)*
|
||||
- [X] Implémenter la logique de rendu de la scène : `App::render_scene(view)` parcourt la scène,
|
||||
récupère les matériaux et soumet tous les draw calls en **une seule passe groupée**
|
||||
(`Renderer::render_scene`), appelée automatiquement chaque frame par l'implémentation par défaut
|
||||
@@ -54,8 +62,8 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
|
||||
|
||||
### Gestion des Matériaux et Shaders
|
||||
|
||||
- [ ] S'assurer que chaque Mesh possède une référence vers un Material (à l'heure actuelle le lien est porté par l'entité `(mesh_id, material_id)` de la Scene, pas par le Mesh lui-même).
|
||||
- [ ] Implémenter le comportement par défaut : si aucun matériau n'est assigné, le moteur injecte automatiquement le `basic_shader` (non implémenté).
|
||||
- [X] S'assurer que chaque Mesh possède une référence vers un Material (à l'heure actuelle le lien est porté par l'entité `(mesh_id, material_id)` de la Scene, pas par le Mesh lui-même). *(fait — 2026-09-17, DRAFT Étape 7 : `Mesh.material: Option<Arc<Material>>` ; `Entity { mesh_id, transform }`, plus de `material_id`)*
|
||||
- [X] Implémenter le comportement par défaut : si aucun matériau n'est assigné, le moteur injecte automatiquement le `standard_shader` (variante unlit). *(fait — 2026-09-17, DRAFT Étape 7.3.5 : `Scene::default_material()` injecte `standard` ; le flat reste piloté par `Renderer::set_unlit`)*
|
||||
|
||||
## Phase 3 : Documentation et Interface (API "User-Friendly")
|
||||
|
||||
@@ -72,9 +80,12 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
|
||||
|
||||
Une fois les phases 1 à 3 validées, nous pourrons introduire :
|
||||
|
||||
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache.
|
||||
- [ ] **Textures** : Intégration d'un module de chargement d'images et de BindGroups.
|
||||
- [ ] **Caméras** : Gestion des matrices de projection/vue dans la Scene.
|
||||
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache *(planifié — ROADMAP 4.2, Éclairage avancé)*.
|
||||
- [X] **Textures** : Intégration d'un module de chargement d'images et de BindGroups *(fait 2026-09-18,
|
||||
Étape 10, ROADMAP 4.1 : `resources::Texture`, bind group @2, `Material.texture`)*.
|
||||
- [X] **Caméras** : Gestion des matrices de projection/vue dans la Scene *(fait 2026-09-16, Étape 4.3 —
|
||||
`Scene::set_camera`/`camera()` porte une caméra active ; `render_scene` écrit view/proj/cam_pos réels
|
||||
dans le buffer frame chaque frame, aspect calculé depuis la fenêtre)*.
|
||||
|
||||
## Check-list de Vérification pour le LLM d'Assistance
|
||||
|
||||
@@ -83,22 +94,6 @@ Une fois les phases 1 à 3 validées, nous pourrons introduire :
|
||||
exposée via `Frame::view()`, `render()` dessine la scène automatiquement en une passe via
|
||||
`App::render_scene(frame.view())`, la présentation est faite par `App::run`)
|
||||
- [X] Les modules sont-ils bien exposés via `lib.rs` ?
|
||||
- [X] `pollster` est-il uniquement en dev-dependencies ? — **obsolète** : depuis la migration
|
||||
winit 0.30 (2026-09-16), `pollster` est en `dependencies` de la lib (le `block_on` d'init GPU
|
||||
est désormais appelé dans le code de la lib, `app.rs`, cf. note pour mémoire ci-dessous).
|
||||
|
||||
## Note pour mémoire : couplage au runtime async (pollster)
|
||||
|
||||
Depuis la migration winit 0.30, la lib embarque un runtime async pour l'init GPU. Le point de
|
||||
couplage actuel est **unique** : `pollster::block_on(Context::new(...))` dans `lib/src/app.rs`
|
||||
(`resumed()`), plus le macro `#[pollster::main]` dans les exemples (crates séparées, hors lib).
|
||||
|
||||
À ce stade (un seul appel), **on ne crée volontairement PAS d'abstraction** : ce serait du
|
||||
sur-engineering pour un seul point d'appel. Mais si la lib acquiert d'autres appels async
|
||||
(chargements / uploads GPU, etc.), il faudra isoler le runtime derrière un **module-pivot unique**
|
||||
(`lib/src/exec.rs`, une fonction `block_on`), seul fichier à modifier pour basculer de pollster
|
||||
vers tokio/futures-executor — le reste du code appelant `crate::exec::block_on(...)`.
|
||||
|
||||
Rappel : pollster et tokio sont des runtimes indépendants qui coexistent sans conflit dans un
|
||||
même binaire ; la seule contre-indication est de faire un `pollster::block_on` **à l'intérieur**
|
||||
d'un contexte async tokio (blocage imbriqué / deadlock possible).
|
||||
- [X] `pollster` est-il isolé de l'utilisateur final ? — résolu : depuis winit 0.30 (2026-09-16),
|
||||
`pollster` est en `dependencies` de la lib ; le `block_on` de l'init GPU est appelé une seule fois
|
||||
dans `lib/src/app.rs` (`resumed()`). Les exemples compilent sans le connaître (crates séparées).
|
||||
|
||||
+67
-141
@@ -1,164 +1,90 @@
|
||||
---
|
||||
type: Roadmap
|
||||
title: WSG Engine Development Roadmap
|
||||
description: Development roadmap for the WSG engine from prototype to full-featured 3D rendering engine
|
||||
tags: [roadmap, development, planning, wsg-lib, 3d-rendering]
|
||||
status: stable
|
||||
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
||||
---
|
||||
# ROADMAP — WSG
|
||||
|
||||
# Roadmap WSG — Prototype → Moteur Complet
|
||||
**Vision** : une lib Rust de dessin 3D simple, fondée sur `wgpu`, où l'API utilisateur est
|
||||
déclarative (graph scène + traits) et où le rendu est **100 % GPU-driven** (indirect draws).
|
||||
|
||||
> Basé sur l'architecture existante (ARCHI_APP, ARCHI_ARENES, ARCHI_CPU_GPU, ARCHI_RENDU).
|
||||
> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite.
|
||||
>
|
||||
> **Point de départ (état réel au 2026-09-16 — la source de vérité est README.md).**
|
||||
> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à
|
||||
> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) :
|
||||
> - Workflow manuel (`Context` + `Renderer` + `PipelineCache`) : ✅ fonctionnel (exemple `manual`).
|
||||
> - Façade `App` / `AppBuilder` / `AppHandler` : ✅ **Scene auto-render** (2026-09-16) — la vue de frame
|
||||
> est exposée (`Frame::view()`), `render()` dessine la scène en une passe groupée
|
||||
> (`App::render_scene`) et la présentation est automatique dans `App::run` (exemple `simple`).
|
||||
> - `Scene` avec identifiants **String** (décision prise — voir tableau Notes de Décision) : 🚧 enregistrement seul.
|
||||
> - `Camera` / `Transform` et `glam` : types et mathématiques présents (`math/`, `resources/camera.rs`), non branchés au pipeline.
|
||||
Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT détaillé
|
||||
(`DRAFT.md`, remplacé à chaque étape) et sa doc livrée (`docs/tech/`, `docs/user/`).
|
||||
|
||||
> **Étape suivante (prochaine itération) — « 3D + éclairage Phong » (ROADMAP 1.3 + 1.5).**
|
||||
> Le rendu automatique est aujourd'hui **plat** : le `basic_shader.wgsl` interprète les positions comme
|
||||
> déjà en NDC, sans matrice monde/vue/projection ni lumière. L'étape suivante rend la scène réellement
|
||||
> 3D et éclairée : créer `standard_shader.wgsl` (Phong : matrice `projection * view * world` + lumière
|
||||
> directionnelle), ajouter les uniform buffers (frame : view/proj/light ; par mesh : world matrix dérivée
|
||||
> du `Transform`) et les brancher dans `Renderer::render_scene` et `Material`, puis exposer `Camera`/
|
||||
> `Transform` à la `Scene` (caméra active) et ajouter un mesh de test (cube) à l'exemple. Objectif MVP :
|
||||
> **un mesh 3D éclairé à l'écran**.
|
||||
> **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné
|
||||
> **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie
|
||||
> (tout reste désactivable, les chemins existants ne changent pas).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1️⃣ — Prototype MVP : Un Mesh 3D éclairé à l'écran
|
||||
## Phase 1 — Fondations ✅
|
||||
|
||||
**Objectif** : Afficher un cube (ou autre mesh) 3D avec un éclairage Phong basique.
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 1.1 | Contexte GPU (Instance, Surface, Adapter, Device, Queue) + boucle winit | ✅ |
|
||||
| 1.2 | Buffers & Pipeline (vertex buffer, pipeline compilé, fullscreen) | ✅ |
|
||||
| 1.3 | Geometry (struct `Geometry`, buffers GPU, topologie, `PrimitiveTopology`) | ✅ |
|
||||
|
||||
### 1.1 Dépendances & Mathématiques
|
||||
- [x] `glam = "0.33"` ajouté (`lib/Cargo.toml`) — déjà présent, utilisé par `math/transform.rs` et `resources/camera.rs`
|
||||
- [x] `slotmap` **retiré** — décision prise : **String IDs pour le MVP** ; slotmap reporté à l'étape "handles typés" (voir Notes de Décision)
|
||||
- [ ] Créer module `math/` (ou `transform.rs`) :
|
||||
- [ ] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }`
|
||||
- [ ] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale
|
||||
- [ ] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs
|
||||
- [ ] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)`
|
||||
## Phase 2 — Scène & Transforms ✅
|
||||
|
||||
### 1.2 Geometry & Mesh
|
||||
- [ ] Créer struct `Geometry` (math/geometry.rs) :
|
||||
- [ ] `positions: Vec<[f32; 3]>` (obligatoire)
|
||||
- [ ] `indices: Option<Vec<u16>>` (optionnel)
|
||||
- [ ] `normals: Option<Vec<[f32; 3]>>` (pour Phong)
|
||||
- [ ] Refactorer `Mesh` pour contenir :
|
||||
- [ ] `geometry: Arc<Geometry>`
|
||||
- [ ] `vertex_buffer: wgpu::Buffer`
|
||||
- [ ] `index_buffer: Option<wgpu::Buffer>`
|
||||
- [ ] `transform: Transform` (état CPU)
|
||||
- [ ] Ajouter un mesh de test (cube unitaire) en exemple
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 2.1 | Primitives procédurales (`cube`, `plane`, `sphere`, `cylinder`, `cone`, `torus`) | ✅ |
|
||||
| 2.2 | Transforms (struct `Transform`, composition translation × rotation × scale) | ✅ |
|
||||
| 2.3 | Entités & scène (struct `Entity`, `Scene`, `TransformStore`, graph entité→mesh) | ✅ |
|
||||
| 2.4 | Camera (struct `Camera`, matrices view + perspective, `CameraController` orbital) | ✅ |
|
||||
|
||||
### 1.3 Shader Phong Minimal
|
||||
- [ ] Créer `standard_shader.wgsl` :
|
||||
- [ ] Vertex shader : projection * view * world * position
|
||||
- [ ] Fragment shader : éclairage hémisphérique + diffuse avec une lumière directionnelle
|
||||
- [ ] Uniforms : `view_matrix`, `proj_matrix`, `world_matrix`, `light_dir`, `light_color`
|
||||
- [ ] Mettre à jour `Material` pour supporter les uniforms du shader Phong
|
||||
## Phase 3 — GPU-driven (cœur de la vision) ✅
|
||||
|
||||
### 1.4 Scene avec identifiants (MVP : String IDs)
|
||||
- [x] `Scene` implémentée avec **String IDs** (`HashMap<String, Arc<Mesh>>`, `...Material`, entités) — état actuel validé ; décision : rester en String IDs pour le MVP
|
||||
- [x] Méthodes : `add_mesh()`, `get_mesh()`, `add_material()`, `add_entity()`, `iter_entities()`, `remove_entity()`
|
||||
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 3.1 | Buffers par entité (Transform + Matrix, uniform par slot) | ✅ |
|
||||
| 3.2 | Compute matrices (compute shader : transform → world matrix) | ✅ |
|
||||
| 3.3 | Indirect draws (`draw_args` GPU, `draw_indirect` / `draw_indexed_indirect`) | ✅ |
|
||||
| 3.4 | Culling GPU (bounding sphere → frustum test → indirect args zéro) | ✅ |
|
||||
|
||||
### 1.5 Rendu du Prototype
|
||||
- [ ] Uniform buffer pour la frame : `view_matrix`, `proj_matrix`, `light_dir`
|
||||
- [ ] Uniform buffer par mesh : `world_matrix` (calculée sur CPU pour le MVP)
|
||||
- [ ] `Renderer::render()` itère sur les meshes de la Scene et dessine chacun
|
||||
- [ ] Exemple fonctionnel : un cube éclairé tourne à l'écran
|
||||
## Phase 4 — Rendu avancé ✅
|
||||
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 4.1 | Textures & matériaux (struct `Texture`, `Material`, bind groups, shader standard) | ✅ |
|
||||
| 4.2 | Lighting & ombres (directional + point + spot + ambient, shadow mapping PCF) | ✅ |
|
||||
| 4.3 | Batching & LOD (batching par matériau, LOD quadric edge collapse + hystérésis) | ✅ |
|
||||
| 4.4 | **HDR + Tone Mapping** (offscreen `Rgba16Float` + fullscreen TM pass ACES/Reinhard) | ✅ |
|
||||
|
||||
## Phase 5 — Qualité & polish ✅
|
||||
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 5.1 | Exemples (7 examples : hello_triangle → demo) | ✅ |
|
||||
| 5.2 | Documentation (tech/ + user/ + rustdoc 100 %) | ✅ |
|
||||
| 5.3 | Tests & robustesse (99 unit + 4 WGSL validation + 3 doctests) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2️⃣ — Système de Ressources complet
|
||||
## Phase 6 — Post-MVP ⬜
|
||||
|
||||
**Objectif** : Étoffer la Scene avec tous les types de ressources.
|
||||
> Au-delà du scope initial. Chaque item est opt-in et indépendant.
|
||||
|
||||
### 2.1 Arènes complètes
|
||||
- [ ] `SlotMap<MaterialId, Material>`
|
||||
- [ ] `SlotMap<TextureId, Texture>` (struct de base)
|
||||
- [ ] `SlotMap<LightId, Light>` (struct de base)
|
||||
- [ ] `SlotMap<EntityId, Entity>` pour les entités de la scène
|
||||
| # | 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.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é | ⬜ |
|
||||
| 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ |
|
||||
|
||||
### 2.2 Entités & Hiérarchie
|
||||
- [ ] Struct `Entity { mesh_id: Option<MeshId>, material_id: Option<MaterialId>, transform: Transform }`
|
||||
- [ ] `Scene::add_entity()` → retourne `EntityId`
|
||||
- [ ] `Scene::iter_entities()` → pour le render loop
|
||||
### Cibles techniques (refactoring)
|
||||
|
||||
### 2.3 Camera dans la Scene
|
||||
- [ ] Intégrer `Camera` comme ressource de la Scene
|
||||
- [ ] Permettre plusieurs caméras (actuelle/inactive)
|
||||
- [ ] Exposer API : `scene.set_active_camera(camera_id)`
|
||||
| # | Item | Statut |
|
||||
|---|------|:------:|
|
||||
| 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ |
|
||||
| 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é | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3️⃣ — GPU-Driven Rendering
|
||||
## Liens
|
||||
|
||||
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md).
|
||||
|
||||
### 3.1 Compute Shader
|
||||
- [ ] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes
|
||||
- [ ] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales
|
||||
- [ ] Compute shader : calcul des World Matrices pour tous les meshes
|
||||
|
||||
### 3.2 Frustum Culling GPU
|
||||
- [ ] Ajouter `BBox` dans `Geometry` (center + extents)
|
||||
- [ ] Buffer `BoundingBoxBuffer` (CPU → GPU, statique)
|
||||
- [ ] Compute shader : culling basé sur la frustum de caméra
|
||||
- [ ] Buffer `IndirectDrawBuffer` rempli par le GPU
|
||||
|
||||
### 3.3 Rendu Indirect
|
||||
- [ ] `draw_indexed_indirect()` au lieu de draw calls individuels
|
||||
- [ ] Un seul command draw pour tous les objets visibles
|
||||
|
||||
---
|
||||
|
||||
## Phase 4️⃣ — Fonctionnalités Avancées
|
||||
|
||||
**Objectif** : Qualité visuelle et performances.
|
||||
|
||||
### 4.1 Textures
|
||||
- [ ] Struct `Texture` avec chargement d'image
|
||||
- [ ] Ajouter `uvs: Option<Vec<[f32; 2]>>` dans `Geometry`
|
||||
- [ ] BindGroup pour les textures dans le shader
|
||||
- [ ] `Material` supporte une texture diffuse
|
||||
|
||||
### 4.2 Éclairage avancé
|
||||
- [ ] Support multi-lumières (directionnelles, ponctuelles)
|
||||
- [ ] Lumières hémisphériques
|
||||
- [ ] Shadows (optionnel)
|
||||
|
||||
### 4.3 Optimisations
|
||||
- [ ] Batching par Material (réduction des state changes GPU)
|
||||
- [ ] Level of Detail (LOD)
|
||||
- [ ] HDR + Tone Mapping (optionnel)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5️⃣ — Documentation & Polish
|
||||
|
||||
- [ ] Exemple complet : mesh texturé, éclairé, avec caméra orbitale
|
||||
- [ ] Documentation API (`docs/ARCHI_SCENE.md`)
|
||||
- [ ] Tests unitaires : `Geometry`, `Scene`, `Transform`
|
||||
- [ ] README mis à jour avec les nouvelles fonctionnalités
|
||||
|
||||
---
|
||||
|
||||
## Notes de Décision
|
||||
|
||||
| Décision | Raison |
|
||||
|----------|--------|
|
||||
| **Normals dès Phase 1** | Nécessaires pour le shader Phong ; sans elles, pas d'éclairage |
|
||||
| **UVs en Phase 4** | Inutiles avant les textures ; on garde `Geometry` simple au départ |
|
||||
| **BBox en Phase 3** | Utile uniquement pour le frustum culling GPU |
|
||||
| **World Matrix CPU → MVP, GPU → Phase 3** | Le MVP est plus simple avec un uniform par mesh ; la migration GPU-driven est progressive |
|
||||
| **String IDs pour le MVP, slotmap reporté** | Le code et le README utilisent des String IDs (simples, sûrs, figés avant la boucle de rendu) ; `ARCHI_ARENES.md` reste la cible "handles typés" pour plus tard. La dépendance `slotmap` a été retirée tant qu'elle est inutilisée |
|
||||
| **Present mode FIFO figé pour l'instant** | Le swapchain utilise `PresentMode::Fifo` avec `desired_maximum_frame_latency: 2` (double buffering vsync) — défaut sûr : pas de tearing, énergie minimale, zéro artefact. On **gèle ce choix** ; `Mailbox` (triple buffering) pourra être exposé en option et `Immediate` restera réservé à l'offscreen, **on s'occupera du present mode le moment venu** (quand le pipeline GPU-driven arrivera, Phase 3) — ce n'est pas bloquant pour les étapes 1-2 |
|
||||
- **Prochaine étape** : [DRAFT.md](DRAFT.md) (détail de l'étape en cours, remplacée à chaque itération)
|
||||
- **Architecture** : [docs/tech/](tech/ARCHI_APP.md)
|
||||
- **Utilisation** : [docs/user/](user/README.md)
|
||||
- **Livre de recette** : [docs/PLAN.md](PLAN.md)
|
||||
|
||||
+28
-15
@@ -15,14 +15,20 @@ stale_after: 2027-01-31
|
||||
|
||||
wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architecture à deux niveaux : une façade de haut niveau pour la productivité et un accès bas niveau pour un contrôle total.
|
||||
|
||||
> **État du document : CIBLE (architecture visée, en grande partie non implémentée).**
|
||||
> Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes
|
||||
> (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect)
|
||||
> et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est
|
||||
> la trajectoire de ROADMAP.md (et README étape 2-3). L'état **réel actuel** est dans README.md :
|
||||
> workflow manuel uniquement, `Renderer` dessine un objet par soumission, shader en NDC sans MVP.
|
||||
> La §3 (`App`/`AppHandler`) correspond à l'état actuel, à une nuance près : `render()` ne peut pas
|
||||
> encore dessiner la scène (l'acquisition/présentation de frame fonctionne, pas le rendu de la scène).
|
||||
> **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
|
||||
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (Étape 17, 2026-09-22, décisions
|
||||
> D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
|
||||
> `setup` (déclaration de la scène) → par frame `update` (mutation) →
|
||||
> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
|
||||
> un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif).
|
||||
> Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières,
|
||||
> ombres, caméra orbitale, culling GPU activé). Le workflow **manuel** (exemple `manual`) coexiste
|
||||
> pour le contrôle fin.
|
||||
> Les sections §1, §4B, §5 et §6 décrivent le pipeline GPU-driven **tel qu'implémenté**, avec les
|
||||
> écarts documentés (cf. `ARCHI_CPU_GPU.md`) : un draw indirect par slot (D1), table fixe de 256 slots
|
||||
> (D12), culling par sphère conservative (D5), single buffer (D4), et le piège de l'ordre des
|
||||
> arguments de `select` en WGSL (D14, bug « fenêtre noire » corrigé le 2026-09-22). La section
|
||||
> « Notes pour l'implémentation future » (double buffering) reste **CIBLE**.
|
||||
|
||||
## 1. Philosophie et Principes
|
||||
|
||||
@@ -85,7 +91,7 @@ pub trait AppHandler {
|
||||
|
||||
- **Shaders** : Chargés avant la renderloop.
|
||||
- **PipelineCache** : Enregistre les shaders.
|
||||
- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise `basic_shader` par défaut.
|
||||
- **Matériaux** : Créés avec un shader associé. Un mesh sans matériau explicite utilise le matériau par défaut de la scène (`standard`).
|
||||
- **Scene** : Assemblage des objets. L'utilisateur peuple la scène via `app.scene`.
|
||||
|
||||
### B. Boucle de Rendu — Pipeline GPU-Driven
|
||||
@@ -104,8 +110,8 @@ Le moteur gère la renderloop interne via un pipeline à **deux passes séquenti
|
||||
|
||||
1. **Update** (`AppHandler::update`) — L'utilisateur modifie la scène (transformations, entités). Ces changements sont synchronisés vers le GPU via un **single buffer** Transform avant la passe de calcul.
|
||||
> La synchronisation est assurée par le pipeline wgpu : `queue.submit()` après le compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun double buffering n'est nécessaire tant que la latence maximale de la surface (via `desired_maximum_frame_latency`) est ≥ 3.
|
||||
2. **Compute Pass** — Un compute shader lit les Transform bruts, calcule les World Matrices finales, effectue le Frustum Culling par AABB, et remplit l'Indirect Draw Buffer avec les identifiants des objets visibles.
|
||||
3. **Render Pass** — Le CPU émet une unique commande `draw_indexed_indirect`. Le GPU pioche dans l'Indirect Draw Buffer et dessine uniquement les objets visibles, sans intervention du CPU.
|
||||
2. **Compute Pass** — Deux entry points compute séquentiels (`compute_matrices` puis `cull`, un seul module WGSL) lisent les Transform bruts, calculent les World Matrices finales, effectuent le Frustum Culling par **sphère conservative** (D5), et remplissent l'Indirect Draw Buffer avec les **comptes** de draw des objets visibles (0 si cullé/inactif).
|
||||
3. **Render Pass** — Le CPU émet **un draw indirect par slot** (écart D1 — la cible initiale prévoyait une commande unique fusionnée). Le GPU pioche les comptes dans l'Indirect Draw Buffer et dessine uniquement les objets non cullés et actifs, sans intervention du CPU.
|
||||
4. **Présentation** — La surface est présentée à l'écran.
|
||||
|
||||
L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis `begin_render_pass`) garantit l'exécution séquentielle. Les barrières de mémoire entre passes sont insérées automatiquement par le pilote.
|
||||
@@ -114,10 +120,11 @@ L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis
|
||||
|
||||
| Buffer | Rôle | Type wGPU | Direction du flux |
|
||||
|--------|------|-----------|-------------------|
|
||||
| Transform Buffer | Positions/rotations/échelles brutes | Storage Buffer | CPU → GPU |
|
||||
| Matrix Buffer | World Matrices finales calculées | Storage Buffer | GPU (Calculé) → GPU (Lu par Render) |
|
||||
| Bounding Box Buffer | AABB de chaque mesh pour culling | Storage Buffer | CPU → GPU (Statique) |
|
||||
| Indirect Draw Buffer | Liste dynamique des objets à dessiner | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
|
||||
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
|
||||
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform | GPU (Calculé) → GPU (Lu par Render) |
|
||||
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
|
||||
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
|
||||
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
|
||||
|
||||
> **Synchronisation single buffer** : Les buffers Transform et Matrix utilisent un **single buffer** en phase initiale. Le CPU écrit dans le buffer pendant `update()`, puis le compute shader lit les données au frame suivant via `queue.submit()` qui garantit la séquence d'exécution. Cette approche fonctionne correctement tant que la surface a une latence maximale ≥ 2 frames (configuré via `desired_maximum_frame_latency`). Le double buffering sera ajouté uniquement si des artefacts visuels apparaissent à haute fréquence (typiquement > 90 fps sur machines rapides).
|
||||
|
||||
@@ -155,3 +162,9 @@ Single buffer (phase initiale) : Update écrit, Compute lit au frame suivant —
|
||||
- **Synchronisation** : Toujours appeler `begin_compute_pass` avant `begin_render_pass` sur le même `CommandEncoder`. Les barrières entre passes sont automatiques — ne jamais insérer de barrière manuelle sauf besoin critique.
|
||||
- **Synchronisation single buffer (phase initiale)** : La séquence `queue.submit()` après chaque compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun conflit de lecture/écriture n'est possible tant que `desired_maximum_frame_latency` ≥ 3.
|
||||
- **Double Buffering (future migration)** : Sera implémenté sur les buffers Transform et Matrix seulement, pas sur BoundingBox ni Indirect Draw. Le switch se résume à : dupliquer ces deux buffers, ajouter une méthode `swap()` appelée dans `AboutToWait`, modifier les bind groups pour pointer vers l'index courant. Pas besoin de refonte architecturale.
|
||||
|
||||
## Liens
|
||||
|
||||
- [ARCHI_RENDU](ARCHI_RENDU.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
|
||||
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
- Référence API : `cargo doc -p wsg-lib --no-deps`
|
||||
|
||||
@@ -220,7 +220,7 @@ impl ResourceManager {
|
||||
6. Suppression Dynamique : Bien que possible, la suppression de ressources pendant la boucle de rendu doit être faite avec prudence. Assurez-vous que les entités ou objets qui référençaient cette ressource soient informés ou nettoyés pour éviter d'utiliser des Handles invalides. La suppression est souvent mieux gérée en fin de frame ou via un système de "marquage pour suppression" suivi d'un nettoyage différé.
|
||||
7. Futur : SecondaryMaps : slotmap permet d'utiliser des SecondaryMap pour associer dynamiquement des données à des ressources existantes sans modifier leur structure principale. Par exemple, `SecondaryMap<MeshId, Transform>` pourrait stocker les transformations actuelles de chaque maillage. Cela peut être utile pour le rendu ou pour des systèmes de physique/transformation indépendants.
|
||||
|
||||
> **Note sur les Transforms côté GPU** : `SecondaryMap<MeshId, Transform>` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec<Transform>`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms.
|
||||
> **Note sur les Transforms côté GPU** : `SecondaryMap<MeshId, Transform>` est une suggestion d'approche générale. Si le modèle le plus performant pour votre cas d'usage est plutôt un vecteur/plat de Transforms (`Vec<Transform>`) alimentant un Storage Buffer CPU → GPU (comme décrit dans [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md)), alors c'est cette approche qu'il faut adopter. Comme toutes les ressources sont créées avant le début de la boucle de rendu, vous pouvez décider à ce moment-là du meilleur modèle de stockage — en fonction du volume de meshes et de la fréquence de mise à jour des transforms.
|
||||
|
||||
# Avantages de cette Approche
|
||||
|
||||
@@ -230,3 +230,9 @@ impl ResourceManager {
|
||||
* Conformité avec Rust : Respecte les principes de propriété et de sécurité mémoire de Rust sans recourir à Rc<RefCell<T>> ou d'autres constructions potentiellement coûteuses ou moins sûres pour la gestion partagée des ressources.
|
||||
* Typage Fort : Les types MeshId, MaterialId, etc., empêchent les erreurs de compilation liées au mélange de Handles de types différents.
|
||||
* Extensibilité : L'écosystème slotmap (SecondaryMap) offre des perspectives pour des architectures plus complexes à l'avenir.
|
||||
|
||||
## Liens
|
||||
|
||||
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [FRAME_LOOP](FRAME_LOOP.md)
|
||||
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
- Référence API : `cargo doc -p wsg-lib --no-deps`
|
||||
|
||||
+71
-18
@@ -7,7 +7,7 @@ actor: person/jerome
|
||||
sources: []
|
||||
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
||||
verified: true
|
||||
status: target
|
||||
status: current
|
||||
stale_after: 2027-01-31
|
||||
---
|
||||
|
||||
@@ -16,13 +16,47 @@ Bonnes Pratiques & Guide d'Implémentation
|
||||
|
||||
Ce document sert de spécification technique et de trame d'implémentation pour l'architecture de rendu 3D pilotée par le GPU (GPU-Driven Rendering) utilisant wgpu. L'objectif est de déléguer un maximum de charges de calcul au GPU pour soulager le CPU et maximiser les performances de parallélisme.
|
||||
|
||||
> **État du document : CIBLE (spécification du pipeline GPU-driven, non implémenté).**
|
||||
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étapes 17–19, validé 2026-09-22).**
|
||||
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
|
||||
> et les buffers persistants en VRAM décrits ici correspondent à la **Phase 3 du ROADMAP** et aux
|
||||
> README étapes 2-3. **Aucun de ces mécanismes n'existe encore dans le code.** Aujourd'hui le rendu est
|
||||
> piloté par le CPU, **objet par objet** (une soumission par mesh, voir README.md et l'exemple `manual`).
|
||||
> Considérez ce document comme la spécification de référence pour l'implémentation future du pipeline
|
||||
> GPU-driven, pas comme une description de l'état actuel.
|
||||
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
|
||||
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
|
||||
> buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
|
||||
> capacité fixe de 256 slots).
|
||||
> Ce document est la **référence durable** de la conception : le draft d'origine de l'Étape 17
|
||||
> (décisions D1–D14, layouts, plan de validation) a été vidé de `docs/DRAFT.md` après validation
|
||||
> et vit dans le git (`git show 3a424af:docs/DRAFT.md`) ; l'essentiel en est repris ci-dessous.
|
||||
> **Écarts documentés** (numérotation du draft d'origine) : (D1) un draw indirect **par slot** plutôt qu'une
|
||||
> commande unique fusionnée ; (D12) 256 slots, slot matrice padded à 256 o (plafond `uniform` WebGPU) ;
|
||||
> (D5) culling par **sphère** conservative dérivée de l'AABB locale du mesh, pas par l'AABB transformée
|
||||
> exacte ; (D4) single buffer, pas de double-buffering.
|
||||
> **Piège connu (2026-09-22, D14)** : l'ordre des arguments de `select` en WGSL est l'inverse de la
|
||||
> convention HLSL — l'avoir inversé a produit un bug « fenêtre noire » (entités visibles remises à 0),
|
||||
> corrigé et vérifié par readback GPU. Documenté en tête de `gpu_driven.wgsl` et dans `AGENTS.md`.
|
||||
> **Batching par material (Étape 18, 2026-09-22)** : la passe principale émet désormais les draws
|
||||
> groupés par `Material` (1 `set_pipeline` + 1 bind group @2 par matériau distinct, pas par entité ;
|
||||
> le pass d'ombre — un seul pipeline — est inchangé). Réordonnancement sûr car tous les pipelines
|
||||
> sont opaques (`BlendState::REPLACE`) ; les no-ops cullés restent émis dans leur groupe.
|
||||
> Détail : `docs/user/gpu-driven.md` § « Batching by material ».
|
||||
> **LOD (Étape 19, 2026-09-23)** : le pass `cull` remplit désormais les arguments indirects à partir
|
||||
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
|
||||
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
|
||||
> le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont
|
||||
> générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
|
||||
> arêtes au coût quadrique minimal sont repliées en premier ; soudure **consciente des attributs**
|
||||
> — un doublon ne fusionne que si UV strictement < ½ tuile par coordonnée (un Δ = ½ exact est
|
||||
> ambigu : fente à sa plus large vs saut légitime) ET normales proches (dot > 0.9) ; les paires
|
||||
> refusées à UV écart d'entier sont **enregistrées** (jumeaux de fente) — ; un mesh sans couture
|
||||
> reste fermé (pas de trous, pas de « books »), et sur un mesh couturé les jumeaux de fente sont
|
||||
> **gelés** (toute arête qui y touche est exclue de la file — la fente zéro-largeur reste fermée à
|
||||
> tous les niveaux) ; UVs/couleurs/normales **blendés linéairement** au repli (le chart est
|
||||
> bilinéaire → exact au nouveau point — jamais de fold de tuile, qui figeait l'UV d'un sommet sur
|
||||
> les vertices de base), normales **héritées** de la source (jamais recalculées — l'éclairage reste
|
||||
> identique au niveau 0 quelle que soit l'orientation source) ; rebase u16) et **empilés dans les
|
||||
> buffers vertex/index du mesh** (offsets en
|
||||
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 :
|
||||
> 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu
|
||||
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
|
||||
> § « Level of Detail ».
|
||||
|
||||
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
|
||||
|
||||
@@ -31,7 +65,8 @@ Pour éviter les goulets d'étranglement dus aux allers-retours sur le bus PCIe,
|
||||
Côté CPU (Source de Vérité)
|
||||
- Ce qu'il conserve : Les données logiques et les transformations brutes des objets (ex: Vec<Transform> contenant la position, la rotation, et l'échelle).
|
||||
- Ce qu'il fait : Il gère la logique de jeu, l'IA, le réseau et les interactions globales.
|
||||
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires.
|
||||
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires (le culling frustum reste 100 % GPU).
|
||||
- Ce qu'il fait en plus (LOD, Étape 19) : le **choix du niveau de détail** par entité — O(N) projections de sphères en pixels + hystérésis, coût négligeable. C'est l'unique décision de visibilité/détail conservée côté CPU : elle dépend de la taille écran (un choix artistique), pas de la géométrie, et l'hystérésis a besoin de l'état de la frame précédente.
|
||||
|
||||
Côté GPU (Exécutant Autonome)
|
||||
- Ce qu'il calcule : Les World Matrices, le Frustum Culling, et la génération des listes de dessin indirectes.
|
||||
@@ -50,13 +85,13 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
|
||||
```
|
||||
|
||||
Étape par étape :
|
||||
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
|
||||
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution), **et le niveau LOD de chaque slot** (1 u32/slot, Étape 19). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
|
||||
- Pass de Calcul (Compute Pass) :
|
||||
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
|
||||
- Frustum Culling GPU : Le même compute shader (ou un compute pass dédié) compare la Bounding Box (AABB) de chaque objet avec les plans de la caméra (matrice de projection/vue).
|
||||
- Remplissage du Buffer Indirect : Si l'objet est visible, son identifiant est injecté dans un buffer de commandes de dessin indirect (Indirect Draw Buffer).
|
||||
- Frustum Culling GPU : Un compute pass dédié (`cull`) compare la **sphère bounding** de chaque objet (D5 — conservative, dérivée de l'AABB locale du mesh et de l'échelle de l'entité) avec les 6 plans du frustum de la caméra.
|
||||
- Remplissage du Buffer Indirect : le pass `cull` lit le **niveau LOD** du slot, en choisit la ligne dans la table LOD du mesh (`LodTable` : 4 lignes d'offsets/comptes en unités d'élément) et écrit les arguments dans le `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op). Le niveau 0 porte les comptes du mesh complet, donc LOD désactivé ≡ pré-LOD bit-à-bit.
|
||||
- Pass de Rendu (Render Pass) :
|
||||
- Le CPU émet une unique commande globale : draw_indexed_indirect.
|
||||
- Le CPU émet **un draw indirect par slot** (écart D1 — la spécification initiale prévoyait une commande unique fusionnée) ; les slots à compte 0 (cullés/inactifs/vides) sont des no-ops.
|
||||
- Le GPU pioche directement dans le buffer préparé par le compute pass et dessine uniquement les objets visibles, sans intervention du CPU.
|
||||
|
||||
3. Stratégie de Synchronisation
|
||||
@@ -66,9 +101,27 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
|
||||
|
||||
4. Synthèse des Structures de Données en VRAM
|
||||
|
||||
Pour implémenter cette architecture, prévoyez l'utilisation des buffers wGPU suivants :
|
||||
Nom du Buffer,Rôle,Type wGPU,Direction du flux
|
||||
Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer,CPU → GPU
|
||||
Matrix Buffer,Stocke les World Matrices finales calculées.,Storage Buffer,GPU (Calculé) → GPU (Lu par le Render)
|
||||
Bounding Box Buffer,Stocke les AABB de chaque mesh pour le culling.,Storage Buffer,CPU → GPU (Statique)
|
||||
Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render)
|
||||
L'implémentation utilise les buffers wGPU suivants (tous créés par le `Renderer` à l'initialisation, capacité fixe de 256 slots) :
|
||||
|
||||
| Buffer | Rôle | Type wGPU | Direction du flux |
|
||||
|--------|------|-----------|-------------------|
|
||||
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
|
||||
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform Buffer | GPU (Calculé) → GPU (Lu par le Render) |
|
||||
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
|
||||
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
|
||||
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
|
||||
| Lod Levels | Niveau LOD par slot choisi par le CPU (4 o/slot) | Storage Buffer (read-only) | CPU → GPU (chaque frame) |
|
||||
| Lod Tables | Table par mesh : `count` + 4 lignes de 16 o (offset/compte d'éléments) (80 o/mesh) | Storage Buffer (read-only) | CPU → GPU (quand l'ensemble des meshes change) |
|
||||
|
||||
**Buffers de géométrie LOD (Étape 19)** : les niveaux d'un mesh sont **empilés** — un seul buffer vertex et un
|
||||
seul buffer index par mesh, contenant les niveaux concaténés (L0, L1, …). Les lignes de la table LOD portent
|
||||
les offsets en **unités d'élément** (premier vertex / premier index), car les arguments `drawIndirect*` de
|
||||
WebGPU s'expriment en éléments, et le buffer est lié en entier à l'offset 0. Conséquences : un mesh LOD ne
|
||||
peux dépasser 65 535 sommets au total (indices u16) et 4 niveaux (`MAX_LOD_LEVELS`) ; le mélange indexé/non-indexé
|
||||
dans un même mesh est supporté (la commande de draw par slot suit le niveau choisi par le CPU).
|
||||
|
||||
## Liens
|
||||
|
||||
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_ARENES](ARCHI_ARENES.md) · [FRAME_LOOP](FRAME_LOOP.md)
|
||||
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
- Référence API : `cargo doc -p wsg-lib --no-deps`
|
||||
|
||||
@@ -15,13 +15,15 @@ stale_after: 2027-01-31
|
||||
|
||||
Ce document définit la stratégie de gestion de la mutabilité et des données du moteur wsg_lib, conçue pour maximiser la performance et garantir la sécurité mémoire via Rust.
|
||||
|
||||
> **État du document : CIBLE (modèle de mutabilité pour le futur rendu automatisé).**
|
||||
> Le cycle update/render strict, l'itération **automatique** des entités et `renderer.render_scene()`
|
||||
> décrits ici ne sont **pas implémentés** : c'est l'**étape 1 du Roadmap README** (scene auto-rendering).
|
||||
> Aujourd'hui `App::run` acquiert/présente la frame mais `render()` ne peut pas encore dessiner la scène,
|
||||
> et le `Renderer` ne dessine qu'un objet par soumission, à la main (exemple `manual`). La terminologie
|
||||
> `MeshId`/`MaterialId` (handles typés) est celle de la **cible** ; l'état actuel utilise des **String IDs**
|
||||
> dans `Scene`. La dichotomie update/render reste toutefois le modèle de référence retenu pour la suite.
|
||||
> **État du document : ACTUEL pour la dichotomie update/render (implémentée) ; CIBLE pour le batching.**
|
||||
> Le cycle strict est en place : `AppHandler::update` (mutation libre de la scène) tourne avant
|
||||
> `AppHandler::render`, dont l'implémentation par défaut appelle `app.render_scene(frame.view())` —
|
||||
> le moteur itère automatiquement les entités et les dessine en **une passe groupée** par frame
|
||||
> (rendu automatisé livré le 2026-09-16 ; la passe d'ombre est ajoutée en tête quand un caster est
|
||||
> actif). Le workflow **manuel** (`Renderer::render` objet par objet, exemple `manual`) coexiste
|
||||
> pour le contrôle fin. Reste en **cible** : le **tri/batching par matériau** (ROADMAP 4.3) et les
|
||||
> **handles typés** `MeshId`/`MaterialId` (voir [ARCHI_ARENES](ARCHI_ARENES.md)) — l'état actuel
|
||||
> utilise des **String IDs** dans `Scene`.
|
||||
|
||||
## 1. La Dichotomie Update / Render
|
||||
|
||||
@@ -65,4 +67,14 @@ Bien que cette architecture facilite la gestion de la mémoire, des règles stri
|
||||
|
||||
> "Si vous devez changer la position d'un objet ou son matériau, faites-le dans `update()`. Si vous avez besoin d'afficher un élément de debug ou un rendu spécial, faites-le dans `render()`, mais traitez les objets de la scène comme des données en lecture seule."
|
||||
|
||||
Cette structure permet au projet d'être extrêmement scalable. L'ajout futur de fonctionnalités (Lumières, Textures, Caméras) ne nécessitera que d'ajouter de nouveaux conteneurs dans la Scene et de mettre à jour le système de tri dans `Renderer::render_scene()` (méthode à créer — cible de l'étape 1 du Roadmap README).
|
||||
Cette structure permet au projet d'être extrêmement scalable. L'ajout des fonctionnalités Lumières,
|
||||
Textures et Caméras (livrées — voir [ROADMAP](../ROADMAP.md)) a effectivement consisté à ajouter des
|
||||
conteneurs dans la Scene (`lights`, `textures`, `camera`) et à les consommer dans
|
||||
`Renderer::render_scene()` (existant — il écrit les uniformes de frame chaque frame). Il restera à
|
||||
y ajouter le **système de tri par matériau** (batching, ROADMAP 4.3) quand il sera justifié.
|
||||
|
||||
## Liens
|
||||
|
||||
- [ARCHI_APP](ARCHI_APP.md) · [FRAME_LOOP](FRAME_LOOP.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
|
||||
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
- Référence API : `cargo doc -p wsg-lib --no-deps`
|
||||
|
||||
+29
-9
@@ -14,12 +14,25 @@ stale_after: 2027-01-31
|
||||
# La Boucle de Rendu (Frame Loop)
|
||||
|
||||
> **État du document : ACTUEL (implémenté).** Ce document décrit la frame lifetime telle qu'elle est
|
||||
> réellement implémentée. Il concerne le rendu **CPU-piloté actuel** (objet par objet, exemple `manual`).
|
||||
> Le pipeline GPU-driven de l'état **visé** est décrit dans ARCHI_APP.md / ARCHI_CPU_GPU.md (cible).
|
||||
> réellement implémentée. **Deux flux coexistent** : le flux **facade `App`** (rendu automatique de la
|
||||
> scène, `App::render_scene` — le workflow recommandé, exemples `simple`/`cube`/`demo`) et le flux
|
||||
> **manuel** (`Context`/`Renderer`/`Frame` pilotés à la main — exemple `manual`, un objet par soumission).
|
||||
> Le pipeline **GPU-driven** (compute pass + draw indirect) de l'état **visé** est décrit dans
|
||||
> [ARCHI_APP](ARCHI_APP.md) / [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) (cible).
|
||||
|
||||
Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime. Deux flux coexistent, tous deux basés sur `Frame` :
|
||||
Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lifetime, basé sur `Frame` :
|
||||
|
||||
**Flux `Frame` (utilisé par `App::run` et l'exemple `manual`) :**
|
||||
**Flux facade `App` (recommandé — `App::run` + `AppHandler`) :**
|
||||
- **`Context::get_next_frame()`** : acquiert la surface texture et crée sa `TextureView` (dans `Frame`).
|
||||
- **`AppHandler::render` (défaut) → `App::render_scene(view)`** : le moteur itère les entités de la
|
||||
scène et les dessine en **une passe groupée** (un `CommandEncoder` + une soumission par frame ;
|
||||
passe d'ombre en tête si un caster est actif).
|
||||
- **`Renderer::present(frame)`** : présente l'image à l'écran.
|
||||
- Chaque frame, avant `update`, le moteur appelle `device.poll()` (les callbacks asynchrones wgpu —
|
||||
`on_submitted_work_done`, `map_async` — ne se déclenchent que lors d'un poll), et la fenêtre
|
||||
redimensionnée est gérée par `App::resize` (surface + depth texture recréées ensemble).
|
||||
|
||||
**Flux `manual` (exemple `manual` — un objet par soumission) :**
|
||||
- **`Context::get_next_frame()`** (ou `Frame::try_new(&context.surface)`) : acquiert la surface texture et crée sa `TextureView` (dans `Frame`).
|
||||
- **`Renderer::render(&view, &mesh, &material)`** : crée un `CommandEncoder`, écrit les ordres de dessin dans la `TextureView`, puis soumet à la file (`queue`).
|
||||
- **`Renderer::present(frame)`** : présente l'image à l'écran.
|
||||
@@ -28,6 +41,12 @@ Pour afficher quelque chose, nous suivons un cycle immuable appelé la Frame Lif
|
||||
- **`Context::begin_frame()`** : acquiert la surface et renvoie la `wgpu::SurfaceTexture` (sans vue).
|
||||
- **`Context::end_frame(surface_texture)`** : soumet et présente cette texture.
|
||||
|
||||
## Liens
|
||||
|
||||
- [ARCHI_APP](ARCHI_APP.md) · [ARCHI_RENDU](ARCHI_RENDU.md) · [ARCHI_CPU_GPU](ARCHI_CPU_GPU.md) · [ARCHI_ARENES](ARCHI_ARENES.md)
|
||||
- Documentation utilisateur : [docs/user](../user/README.md) · [README racine](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
- Référence API : `cargo doc -p wsg-lib --no-deps`
|
||||
|
||||
---
|
||||
|
||||
## Pourquoi cette séparation est vitale
|
||||
@@ -49,10 +68,11 @@ Avec notre nouvelle architecture "Atelier", la distinction est devenue encore pl
|
||||
| CommandEncoder | Par-Frame | Ton "carnet de notes" temporaire pour les ordres du GPU. |
|
||||
| TextureView | Par-Frame | Fenêtre temporaire sur la texture active du swapchain. |
|
||||
|
||||
> **Ressources GPU persistantes (single buffer) — CIBLE, non implémenté** : À l'état **visé**, les
|
||||
> buffers Transform et Matrix vivent en VRAM avec un single buffer en phase initiale (le CPU écrit
|
||||
> pendant `update()`, le compute shader lit au frame suivant, séquencé par `queue.submit()`), puis un
|
||||
> double buffering si des artefacts apparaissent à haute fréquence. **Aucune de ces ressources n'existe
|
||||
> encore dans le code** — c'est la cible GPU-driven (ROADMAP Phase 3 / ARCHI_CPU_GPU).
|
||||
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-09-22)** : les buffers
|
||||
> Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
|
||||
> capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
|
||||
> **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
|
||||
> (l'ordre est garanti par l'encoder, pas par `queue.submit()` inter-frames). Le double buffering
|
||||
> reste la **cible** si des artefacts apparaissent à haute fréquence (voir ARCHI_CPU_GPU / ARCHI_APP).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# User documentation — WSG
|
||||
|
||||
**Usage** documentation for the `wsg-lib` crate: how to build a 3D rendering application
|
||||
without touching wgpu directly. It targets a developer with basic Rust knowledge; no prior
|
||||
GPU graphics background is required.
|
||||
|
||||
> **Not to be confused**: these pages explain *how to use* the API. The **technical**
|
||||
> documentation (internal architecture, design decisions, future targets) lives in
|
||||
> [../tech/](../tech/ARCHI_APP.md), and the exhaustive API reference is generated by rustdoc
|
||||
> (`cargo doc -p wsg-lib --no-deps`).
|
||||
|
||||
## Where to start
|
||||
|
||||
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
|
||||
2. Then, at your own pace, depending on what you need:
|
||||
|
||||
| Page | Topic |
|
||||
|-------|-------|
|
||||
| [Meshes](meshes.md) | Geometries: procedural primitives, custom `Geometry`, entities and `Transform` |
|
||||
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
|
||||
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
|
||||
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
|
||||
| [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated |
|
||||
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
|
||||
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
|
||||
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
|
||||
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
|
||||
|
||||
The pages are cross-linked: each page ends with a link to the next one.
|
||||
|
||||
## Design principle: opt-in = zero cost
|
||||
|
||||
WSG follows a strict rule: **a feature you don't enable costs nothing at runtime**.
|
||||
|
||||
| Feature | How to enable | If NOT enabled |
|
||||
|---------|--------------|----------------|
|
||||
| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map allocated, no depth pass, no PCF sampling |
|
||||
| HDR + Tone mapping | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass, direct-to-surface render |
|
||||
| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect draw buffers |
|
||||
| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh, no decimation, no hysteresis |
|
||||
| Primitives | Cargo feature `prim-*` (default: all) | Not compiled at all |
|
||||
| File import | Cargo feature `import-*` | Not compiled at all |
|
||||
|
||||
The distinction matters:
|
||||
- **Runtime opt-in** (shadows, HDR, culling, LOD): the code is compiled into your binary
|
||||
but is **completely inert** if you never call the activation method. No GPU resources are
|
||||
allocated, no passes execute, no per-frame overhead. The cost of the code being in the
|
||||
binary is a few KB — negligible.
|
||||
- **Compile-time opt-in** (primitives, import): the code is **not compiled at all** unless
|
||||
you opt in via Cargo features. This matters when you want to minimize compile time or
|
||||
binary size for a minimal build.
|
||||
|
||||
You can mix both: build with `--no-default-features --features "prim-cube"` for a minimal
|
||||
binary, then enable shadows/HDR at runtime only for the scenes that need them.
|
||||
|
||||
## Links
|
||||
|
||||
- Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
|
||||
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) · [DRAFT](../DRAFT.md)
|
||||
- Full API reference: `cargo doc -p wsg-lib --no-deps`
|
||||
@@ -0,0 +1,127 @@
|
||||
# Camera & input
|
||||
|
||||
Two bricks drive the viewpoint: the scene's **active `Camera`** (view/projection matrices
|
||||
built every frame) and the unified **`InputState`** (keyboard/mouse, cross-frame
|
||||
semantics). The orbital **`CameraController`** bridges the two.
|
||||
|
||||
## 1. The active camera
|
||||
|
||||
The scene holds a single camera, read by the engine every frame to write the view/projection
|
||||
matrices into the frame buffer (aspect recomputed from the window size).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::Camera;
|
||||
use glam::Vec3;
|
||||
|
||||
app.scene.set_camera(Camera::new(
|
||||
Vec3::new(3.0, 2.0, 3.0), // eye position
|
||||
Vec3::ZERO, // target point
|
||||
Vec3::Y, // "up" vector
|
||||
));
|
||||
```
|
||||
|
||||
- **Default**: position `(0, 0, 3)`, looking at the origin, 45° vertical fov, near 0.1,
|
||||
far 100 — frames a unit cube with no tuning.
|
||||
- `Camera::with_perspective(fov, near, far)` adjusts the projection (fov in radians).
|
||||
- Read: `app.scene.camera()`; direct mutation: `app.scene.camera_mut()`.
|
||||
- The `up` field matters: the orbital camera forces it to `+Y` (level horizon).
|
||||
|
||||
> The matrices use the **WebGPU** convention (NDC depth `[0,1]`) — do not replace
|
||||
> `projection_matrix` with an OpenGL `[-1,1]` projection, the near part of the frustum would
|
||||
> be clipped.
|
||||
|
||||
## 2. The orbital controller
|
||||
|
||||
`CameraController` represents the viewpoint in spherical coordinates around a target:
|
||||
`yaw` (azimuth around +Y), `pitch` (elevation, bounded to ±~83°), `distance` (radius,
|
||||
bounded to `[0.1, 100]`), `target` (target point).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::CameraController;
|
||||
|
||||
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
|
||||
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
|
||||
ctrl.zoom(scroll_y); // wheel: zoom (positive scroll = move closer)
|
||||
ctrl.reset(); // back to the default framing
|
||||
ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active camera (do this EVERY frame)
|
||||
```
|
||||
|
||||
`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera
|
||||
(useful to start the orbit from a manual framing).
|
||||
|
||||
Two public fields tune the feel of the camera (defaults in parentheses):
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|-------|---------|---------|
|
||||
| `orbit_sensitivity` | radians of yaw per pixel of mouse delta | `0.005` (~110° per full window width) |
|
||||
| `zoom_factor` | multiplicative distance change per wheel notch (`distance *= factor^scroll`) | `0.9` (10% per notch) |
|
||||
|
||||
The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from
|
||||
`app.input`) is in [`demo.rs`](../../lib/examples/demo.rs), `update()` section.
|
||||
|
||||
## 3. The unified input state
|
||||
|
||||
`app.input` (public field of `App`) is fed by winit events and **rotated** automatically
|
||||
every frame (`begin_frame`/`end_frame` around your `update`). Three semantics per control:
|
||||
|
||||
| Semantics | Methods | Meaning |
|
||||
|------------|----------|---------|
|
||||
| **pressed** | `key_pressed(code)`, `mouse_button_pressed(btn)` | true **only** on the frame the key/button was just pressed |
|
||||
| **held** | `key_held(code)`, `mouse_button_held(btn)` | true while the key/button stays down |
|
||||
| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame |
|
||||
|
||||
Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the
|
||||
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel, in **line/notch units** —
|
||||
`PixelDelta` events are normalized by /32 so one physical wheel notch ≈ 1.0 on every backend).
|
||||
|
||||
> **Button-gated orbit**: `mouse_delta()` returns movement *whenever* the mouse moves. For a
|
||||
> classic arc-rotate camera, apply it only while a button is held — that is what the `demo` does:
|
||||
> `if app.input.mouse_button_held(MouseButton::Left) { self.camera.orbit(dx, dy); }`.
|
||||
> Free-movement orbit (no button) is also possible, just drop the condition.
|
||||
|
||||
`KeyCode` values are winit's physical codes (`winit::keyboard::KeyCode`); mouse buttons are
|
||||
`winit::event::MouseButton`. The library does not re-export them: if your code mentions
|
||||
them, add `winit = "0.30"` to your own dependencies (as the examples do). Input-less
|
||||
applications (like `simple`/`cube`) don't need winit: `app.input` remains usable, only
|
||||
`KeyCode` comparisons require the import.
|
||||
|
||||
```rust
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Orbit (left-drag gated) + zoom driven by the mouse (excerpts from demo):
|
||||
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);
|
||||
|
||||
// R: reset — key_pressed fires once, not on key-repeat.
|
||||
if app.input.key_pressed(KeyCode::KeyR) {
|
||||
self.camera.yaw = 0.6;
|
||||
self.camera.pitch = 0.35;
|
||||
self.camera.distance = 6.5;
|
||||
}
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
}
|
||||
```
|
||||
|
||||
> **Gamepad**: the API is reserved (`InputState` will pass through `DeviceEvent`s) but not
|
||||
> implemented yet — deferred, see [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## 4. Common recipes
|
||||
|
||||
| Need | Recipe |
|
||||
|--------|--------|
|
||||
| Standard orbital camera | `CameraController` + `mouse_delta`/`scroll_delta` (snippet above) |
|
||||
| FPS camera (WASD) | `key_held(KeyCode::KeyW)` in `update` → move `camera.position`/`target`; override `render()` if needed |
|
||||
| Changing the orbit target | `ctrl.target = subject_position;` (following an object) |
|
||||
| View presets | `key_pressed(Digit1/2/3)` → write yaw/pitch/distance (from the `demo`) |
|
||||
| Tuning the camera speed | `ctrl.orbit_sensitivity = 0.003;` (slower orbit), `ctrl.zoom_factor = 0.95;` (gentler zoom) |
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Examples
|
||||
|
||||
Seven examples live in [`lib/examples/`](../../lib/examples/) and all launch with
|
||||
`cargo run -p wsg-lib --example <name>`. They are **self-contained**: no assets on disk
|
||||
(procedural textures, hard-coded geometries).
|
||||
|
||||
| Example | Command | What it shows | Corresponding page |
|
||||
|---------|----------|---------------|--------------------|
|
||||
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) |
|
||||
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) |
|
||||
| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (left-drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) |
|
||||
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) |
|
||||
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) |
|
||||
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade (winit 0.30 `ApplicationHandler`) | below |
|
||||
|
||||
## The `manual` workflow (advanced)
|
||||
|
||||
When the `App` facade doesn't fit (fine-grained loop control, integration into an existing
|
||||
framework, experimentation), you bypass `App` and drive directly:
|
||||
|
||||
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/`Device`/
|
||||
`Queue`, `configure()` for the swapchain, `get_next_frame()`.
|
||||
- `Renderer` (*Executor* layer): `render(view, mesh, material)` = one object per submission;
|
||||
`present(frame)`.
|
||||
- `PipelineCache`: `register_shader(id, path)` then `Material::new(format, id, &mut cache)`.
|
||||
|
||||
The window and GPU are created in winit 0.30's `resumed()` callback (`run_app` +
|
||||
`ApplicationHandler`), as in `app.rs`. The reference file is
|
||||
[`manual.rs`](../../lib/examples/manual.rs); the two-layer architecture is detailed in
|
||||
[ARCHI_APP](../tech/ARCHI_APP.md) and [FRAME_LOOP](../tech/FRAME_LOOP.md).
|
||||
|
||||
> **Tip**: start with the declarative workflow. The manual workflow doesn't render more
|
||||
> pixels — it gives more control over command encoding.
|
||||
|
||||
## Adding your own example
|
||||
|
||||
Repo conventions (see `lib/examples/README.md`):
|
||||
|
||||
1. Create `lib/examples/my_example.rs` (Cargo discovers it automatically).
|
||||
2. Keep it **self-contained**: procedural textures, hard-coded geometries, no external assets.
|
||||
3. Use the declarative workflow (`AppBuilder` + `Scene`) when possible.
|
||||
4. Document the example in `lib/examples/README.md` (and here, `docs/user/examples.md`).
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Quickstart](quickstart.md) · [Camera & input](camera-input.md)
|
||||
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||
@@ -0,0 +1,245 @@
|
||||
# GPU-driven rendering
|
||||
|
||||
WSG's scene rendering is **GPU-driven**: the per-entity world matrices and the indirect draw
|
||||
arguments are computed on the GPU each frame, so the CPU no longer loops over entities to issue
|
||||
draw calls. This page explains what that means for you, how to opt into **frustum culling**, and how the
|
||||
**Level of Detail (LOD)** system works.
|
||||
|
||||
## What runs on the GPU
|
||||
|
||||
Each frame, before the render passes, two compute passes run over a fixed-capacity slot table
|
||||
(256 entities, allocated once):
|
||||
|
||||
1. **`compute_matrices`** derives each entity's world matrix from its transform
|
||||
(translation / rotation / scale). The result feeds the render pipelines as the per-entity
|
||||
model matrix.
|
||||
2. **`cull`** decides per-entity visibility and fills the **indirect draw arguments** (the
|
||||
vertex/index count, zeroed when the entity is culled or inactive). With LOD on (the default), the
|
||||
count it writes comes from the mesh's **LOD table** at the level the CPU chose for the slot this
|
||||
frame — see [Level of Detail](#level-of-detail-lod).
|
||||
|
||||
The main and shadow render passes are then **100 % indirect**: each active slot issues one
|
||||
indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero
|
||||
count, so its draw is a no-op. The CPU only rewrites the transform slots and the cull uniforms
|
||||
each frame — it never iterates the entities to issue draws.
|
||||
|
||||
You do not need to do anything special to get this: `render_scene` is GPU-driven by default.
|
||||
|
||||
## Batching by material
|
||||
|
||||
The main render pass batches the draws by material: all entities sharing the same material are
|
||||
drawn back to back, so the GPU pipeline and the material's texture bind group are switched **once
|
||||
per distinct material**, not once per entity (the per-draw work — matrix offset, vertex/index
|
||||
buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change
|
||||
the rendered image and there is nothing to configure.
|
||||
|
||||
> **Constraint:** the batching reorders the draws, which is safe here because every pipeline in
|
||||
> the engine is **opaque** (`BlendState::REPLACE`, no alpha blending) — the depth buffer resolves
|
||||
> the draw order. If transparent materials are ever added, the transparent draws must be isolated
|
||||
> (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque
|
||||
> draws.
|
||||
|
||||
## Frustum culling (opt-in)
|
||||
|
||||
Culling is **off by default**. The culling pass still runs, but with culling disabled it marks
|
||||
every active entity visible — so the rendered image is **identical** to a CPU-culled scene.
|
||||
This protects you from a culling bug (an object that should be visible vanishing) becoming a
|
||||
silent correctness issue.
|
||||
|
||||
To enable culling, build your `App` with `.with_culling(true)`:
|
||||
|
||||
```rust
|
||||
let app = AppBuilder::new()
|
||||
.title("My app")
|
||||
.with_culling(true) // skip entities whose bounding sphere leaves the frustum
|
||||
.build()
|
||||
.await?;
|
||||
```
|
||||
|
||||
Or toggle it at runtime on the renderer:
|
||||
|
||||
```rust
|
||||
app.renderer().set_culling(true); // enable
|
||||
app.renderer().set_culling(false); // disable again
|
||||
```
|
||||
|
||||
## How culling works
|
||||
|
||||
When culling is on, each entity's **local-axis-aligned bounding box** (computed once from its
|
||||
geometry, `Geometry::bbox()`) is treated as a **bounding sphere**:
|
||||
|
||||
- **center** = the box center, transformed by the entity's world transform (rotation +
|
||||
translation; scale is folded into the radius),
|
||||
- **radius** = the box's circumradius scaled by the entity's largest scale component.
|
||||
|
||||
The sphere is tested against the six camera frustum planes. If it is **fully outside** (beyond
|
||||
a plane by more than its radius), the entity is culled; otherwise it is drawn.
|
||||
|
||||
The sphere is a **conservative** approximation of the box: it can draw an object that is partly
|
||||
out of view (false negative), but it will **never cull an object that is actually visible**
|
||||
(false positive). For tight culling you would need per-mesh sphere fitting or per-face tests,
|
||||
which are out of scope for v1.
|
||||
|
||||
## Level of Detail (LOD)
|
||||
|
||||
LOD is **on by default**: distant entities automatically draw a coarser version of their mesh, so
|
||||
the GPU stops spending fillrate and vertex work on detail the eye cannot see. It is a quality
|
||||
feature with a performance payoff — unlike culling, it is safe to leave on because the
|
||||
worst case (a level chosen too fine) is exactly what you would have drawn anyway.
|
||||
|
||||
### How it works
|
||||
|
||||
LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity decision kept on the
|
||||
CPU):
|
||||
|
||||
1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated
|
||||
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
|
||||
`Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
|
||||
cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the
|
||||
neighbours — no new face, so a **seam-free mesh stays closed** (no holes, no non-manifold
|
||||
"books") and a boundary collapse removes one face; duplicate corners are welded **aware of
|
||||
their attributes** (relative position tolerance 1e-6, merged only when the UVs are strictly
|
||||
less than half a tile apart on both coordinates — an offset of exactly ½ is ambiguous: a wrap
|
||||
seam at its widest or a legitimate half-tile jump — and the normals within ~25°; a seam or a
|
||||
hard edge therefore stays a separate corner, and the weld *records* the integer-apart pairs it
|
||||
refused); on a mesh with a UV seam those **seam twins are frozen** — every edge touching one
|
||||
is excluded from the collapse queue — so the zero-width slit stays closed at every level, and
|
||||
the rim protection (no boundary collapse while any interior edge remains) keeps the surface
|
||||
geometrically complete; a survivor **moved** by a collapse gets its UV/color/normal
|
||||
**blended linearly** between the collapsed endpoints (same λ as its new position — the chart
|
||||
is bilinear, so the blend is the exact chart value at the new point: texture and shading stay
|
||||
attached to the surface and coarsen smoothly across levels, and a seam is never crossed because
|
||||
its twins are frozen, not because a blend is rejected; normals are **inherited from the
|
||||
source, never recomputed**, so the lighting is identical to level 0 whatever the source's
|
||||
winding), and the
|
||||
levels are **packed into the mesh's single
|
||||
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
|
||||
2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
|
||||
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
|
||||
finer is immediate, going coarser only below 80 % of the bound (a 20 % dead band) — which is what
|
||||
prevents flicker when an entity hovers around a threshold. Default thresholds: 48 px and 12 px
|
||||
(bigger than 48 px → full detail; smaller than 12 px → coarsest).
|
||||
3. **Per frame (GPU).** The `cull` pass reads the slot's level, looks up the matching row of the
|
||||
mesh's LOD table (element-unit offsets + counts), and writes the indirect draw arguments from it.
|
||||
|
||||
### Using it
|
||||
|
||||
```rust
|
||||
// One level (the default): create_mesh is unchanged.
|
||||
let id = scene.create_mesh("hero", &geometry, &material)?;
|
||||
|
||||
// Auto-generated levels 1..3 (decimated at half, quarter, eighth the triangle count).
|
||||
let id = scene.create_mesh_with_lod("hero", &geometry, &material, 4)?;
|
||||
|
||||
// Or supply your own levels (same attributes, same indexed-ness as level 0).
|
||||
scene.add_mesh_lod("hero", 1, &my_coarse_geometry)?;
|
||||
```
|
||||
|
||||
Toggle at runtime (off = every slot forced to level 0 = byte-identical rendering to the pre-LOD
|
||||
engine — the level-0 rows carry the full-mesh counts, so nothing else changes):
|
||||
|
||||
```rust
|
||||
app.renderer().set_lod_enabled(false);
|
||||
```
|
||||
|
||||
### Constraint: packed LOD buffers
|
||||
|
||||
A level is **not a separate buffer**: the mesh's levels are concatenated into its one vertex buffer
|
||||
and one index buffer, and the per-mesh LOD table stores each level's offsets/counts. Two
|
||||
consequences:
|
||||
|
||||
- **u16 indices** → the *sum* of all levels must stay under 65 535 vertices (the scene rejects a
|
||||
level set that would not fit, with a clear error);
|
||||
- **at most 4 levels** per mesh (`MAX_LOD_LEVELS`, also the size of the GPU table row).
|
||||
|
||||
Indexed-ness: levels supplied through `add_mesh_lod` must match level 0's indexed-ness (validated).
|
||||
Auto-generated levels from a **non-indexed** level 0 are indexed anyway (decimation rebuilds with
|
||||
indices), and the packed buffer supports that mix — the per-slot draw command follows the level the
|
||||
CPU chose (the shadow pass always uses the level-0 command, so casters stay at full detail).
|
||||
|
||||
### How to verify LOD with the debug dump
|
||||
|
||||
The debug dump (below) prints, per frame: the per-slot **levels** and each mesh's **LOD table**
|
||||
(rows = `vertex_offset / vertex_count / index_offset / index_count`, element units). The clean test
|
||||
is to **zoom the camera out**: the entities' perceived size drops below the thresholds, the levels
|
||||
step up (0 → 1 → 2), and the indirect argument counts shrink to the corresponding rows — e.g. the
|
||||
demo's 3 840-index sphere drops to 1 824, then 912 — while the levels stay **stable frame to frame**
|
||||
(hysteresis holding). Verified 2026-09-23: at the demo's default distance every entity sits at
|
||||
level 0 with full counts; zoomed to 4.6×, all multi-level meshes select level 1 with exactly their
|
||||
L1 rows, stable across frames.
|
||||
|
||||
## Debugging the GPU path
|
||||
|
||||
If something looks wrong — a missing object, a black window — the GPU-side slot tables can be
|
||||
read back and printed. The `Renderer` ships a debug helper (intentionally **not** part of the
|
||||
documented API):
|
||||
|
||||
```rust
|
||||
app.renderer().debug_dump(8); // prints the first 8 GPU slots to stderr
|
||||
```
|
||||
|
||||
It dumps exactly what the GPU sees: the transform slots, the derived world matrices, the
|
||||
indirect draw arguments, the mesh bounding boxes, the cull uniforms, the per-slot **LOD levels**
|
||||
and the per-mesh **LOD tables**. A slot whose vertex count reads `0` was zeroed by the cull pass
|
||||
(culled, inactive, or beyond `num_slots`); a full count means the entity is drawn — and with LOD
|
||||
on, the *row* the count comes from tells you the selected level (see above). In the `demo` example the dump is opt-in via an environment
|
||||
variable, so the showcase stays silent by default:
|
||||
|
||||
```sh
|
||||
WSG_DEBUG_DUMP=120 cargo run -p wsg-lib --example demo
|
||||
```
|
||||
|
||||
`WSG_DEBUG_DUMP=N` dumps for the first *N* frames. The demo stays **silent** when the variable is
|
||||
unset; a set-but-non-numeric value (e.g. `WSG_DEBUG_DUMP=on`) gives 3 frames.
|
||||
|
||||
**How to verify culling is actually working** (a correct culling pass is invisible — culled
|
||||
objects were off-screen anyway — so the proof is in the counts, not the image):
|
||||
|
||||
1. Launch the demo with `WSG_DEBUG_DUMP=120` (the demo has culling **on** and an orbiting
|
||||
camera — drag the mouse to orbit).
|
||||
2. Note first that orbiting/zooming this camera **cannot cull the entity ring**: the camera
|
||||
always looks at the origin, so each entity's angular offset from the view axis is bounded
|
||||
by `atan(ring radius / camera distance)` = `atan(1.7/6.1)` ≈ 15.5°, under the ~22° vertical
|
||||
half-FOV. The seven demo entities therefore keep their **full** counts (cube `36`, sphere
|
||||
`3840`, …) in every orientation — that is the expected and correct behaviour (verified
|
||||
2026-09-22: 600-frame camera sweep, GPU cull verdicts matched an independent CPU sphere
|
||||
test on all 6000 entity frames, zero flips on the ring).
|
||||
3. To see the counts actually flip to **`0`**, you need an entity well **off the target axis**
|
||||
— e.g. one placed far away so it ends up behind the near plane. Its count then toggles
|
||||
`0` ↔ full as the camera orbits, while the on-axis entities stay full. (This off-axis test
|
||||
is the one that verified the cull path end-to-end, positive and negative.)
|
||||
4. Optional A/B: temporarily build with `.with_culling(false)` and repeat — with culling off,
|
||||
every entity keeps its full count in **every** orientation (the off-axis one included).
|
||||
|
||||
This readback is the reference truth when a shader bug is suspected: it shows both the computed
|
||||
counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how
|
||||
the 2026-09-22 « black window » bug — an inverted WGSL `select` argument order — was diagnosed
|
||||
and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Culling is all-or-nothing per entity.** There is no partial (per-triangle) culling.
|
||||
- **The sphere is a coarse bound** for elongated meshes (a long thin box gets a large sphere).
|
||||
If your scene is dominated by such shapes, culling may bring little gain.
|
||||
- **Capacity is 256 entities per render pass.** Beyond that, extra entities are not drawn.
|
||||
This is the largest a single-buffer design can address under WebGPU's two `uniform` rules: a
|
||||
single `uniform` binding is capped at 64 KB, *and* a `uniform` offset must be a multiple of 256 B.
|
||||
A 64-byte matrix can never be individually addressable by a `uniform` offset, so each matrix
|
||||
slot is padded to 256 B — and 256 slots × 256 B = 64 KB is the maximum. It is amply generous
|
||||
for a simple scene (the demo has 7).
|
||||
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
|
||||
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
|
||||
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
|
||||
levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse
|
||||
+ attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the
|
||||
per-frame cost is one sphere projection per entity on the CPU.
|
||||
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
|
||||
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
|
||||
coarser. `set_lod_enabled(false)` is the escape hatch.
|
||||
|
||||
Culling is a **performance** feature, not a visual one: with it off you get the same image with
|
||||
the indirect-draw machinery still active.
|
||||
|
||||
---
|
||||
|
||||
Next: [Examples](examples.md) · Back to [User documentation index](README.md)
|
||||
@@ -0,0 +1,77 @@
|
||||
# HDR & Tone Mapping
|
||||
|
||||
> **Étape 20** — Opt-in HDR rendering with tone mapping.
|
||||
|
||||
## What it does
|
||||
|
||||
By default, the WSG renderer draws directly to the window's sRGB surface. Color values
|
||||
above 1.0 are **clipped** (saturated to white) — you lose all information in bright areas.
|
||||
|
||||
When HDR is enabled, the pipeline becomes:
|
||||
|
||||
```
|
||||
Main pass → offscreen Rgba16Float texture (unbounded float)
|
||||
TM pass → fullscreen triangle samples HDR texture, applies curve, writes to sRGB surface
|
||||
```
|
||||
|
||||
The tone mapping **compresses** the [0, ∞) range to [0, 1] with a perceptual curve,
|
||||
so bright areas are smoothly rolled off instead of clipping.
|
||||
|
||||
## Enabling HDR
|
||||
|
||||
```rust
|
||||
use wsg_lib::core::ToneMapper;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
|
||||
let app = AppBuilder::new()
|
||||
.title("My HDR App")
|
||||
.with_hdr(ToneMapper::Aces) // ← enables HDR
|
||||
.build()
|
||||
.await?;
|
||||
```
|
||||
|
||||
Without `.with_hdr(...)`, the renderer operates in LDR mode (direct to surface, zero overhead).
|
||||
|
||||
## Tone mapping curves
|
||||
|
||||
| Variant | Curve | Use case |
|
||||
|---------|-------|----------|
|
||||
| `ToneMapper::Aces` | ACES Filmic (Narkowicz 2015) | Cinematic look, soft highlight rolloff, good contrast |
|
||||
| `ToneMapper::Reinhard` | `x / (1 + x)` | Simple, flat; less contrast but computationally trivial |
|
||||
|
||||
The curve is **compiled into the pipeline** at construction time (one WGSL entry point
|
||||
per variant) — there is no runtime branching cost.
|
||||
|
||||
## Cost
|
||||
|
||||
| HDR state | Extra per-frame cost |
|
||||
|-----------|---------------------|
|
||||
| Disabled (default) | **Zero** — no texture, no pass, no pipeline |
|
||||
| Enabled | +1 fullscreen render pass (triangle, 3 verts) + 1 offscreen texture (same size as window) |
|
||||
|
||||
The extra pass is negligible on any GPU (a few hundred microseconds). The offscreen
|
||||
texture costs ~12 bytes/pixel of VRAM (RGBA16F = 8 bytes/px + the surface's own buffer).
|
||||
|
||||
## How it works (technical)
|
||||
|
||||
- **Offscreen texture**: `Rgba16Float`, same size as the window. Created in `Renderer::new`,
|
||||
recreated on resize.
|
||||
- **Main pass**: the color attachment targets the HDR texture instead of the surface.
|
||||
The `standard_shader.wgsl` fragment output (linear float, unbounded) is stored as-is.
|
||||
- **TM pass**: a fullscreen triangle (3 vertices, no vertex buffer) samples the HDR texture,
|
||||
multiplies by exposure (currently fixed at 1.0), applies the tone curve, and writes to
|
||||
the sRGB surface. The hardware performs the linear→sRGB gamma conversion automatically
|
||||
(the surface format is `Rgba8UnormSrgb`).
|
||||
- **No double gamma**: the shader outputs linear [0,1]; the sRGB surface encoding is
|
||||
handled by the rasterizer.
|
||||
|
||||
## Exposure
|
||||
|
||||
Currently fixed at 1.0 (no user control yet). A future step will expose an
|
||||
`exposure` field in a `HdrConfig` struct for live adjustment.
|
||||
|
||||
## See also
|
||||
|
||||
- [Shadows](shadows.md) — the other opt-in visual feature
|
||||
- [GPU-driven rendering](gpu-driven.md) — the compute pipeline that feeds the main pass
|
||||
- [Examples](examples.md) — the `demo` example enables HDR by default
|
||||
@@ -0,0 +1,84 @@
|
||||
# Lights
|
||||
|
||||
Lights are **scene-global**: a single list is packed into the frame uniforms every frame, and
|
||||
**all** entities receive their lighting (per-material lights are out of the current scope).
|
||||
|
||||
## Model
|
||||
|
||||
- Bounded capacity: **`MAX_LIGHTS = 8`** lights in total (directional + point + spot
|
||||
combined). Adding beyond that returns an error.
|
||||
- **Default**: one white directional light along **+Z** (from the surface point toward the
|
||||
light) + white ambient. This default exactly reproduces the historical single-light
|
||||
rendering — your scene "just works" with no configuration.
|
||||
- Ambient (`set_ambient`) is a global hemispherical term, independent of the lights.
|
||||
|
||||
## Adding lights
|
||||
|
||||
```rust
|
||||
use glam::Vec3;
|
||||
|
||||
// Directional: `dir` points FROM the surface point TOWARD the light.
|
||||
app.scene
|
||||
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)
|
||||
.unwrap();
|
||||
|
||||
// Point: world position, tint, intensity, attenuation radius (linear down to 0).
|
||||
app.scene
|
||||
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
|
||||
.unwrap();
|
||||
|
||||
// Spot: position, cone axis (FROM the light TOWARD the scene), tint, intensity, radius,
|
||||
// half-angle in radians (penumbra smoothed at the edge).
|
||||
app.scene.add_spot_light(
|
||||
Vec3::new(-2.5, 2.2, 1.0), // position
|
||||
Vec3::new(2.5, -2.2, -1.0).normalize(), // axis, toward the scene
|
||||
[0.3, 1.0, 0.5], // green tint
|
||||
1.4, 8.0, 0.45, // intensity, radius, half-angle (~26°)
|
||||
).unwrap();
|
||||
```
|
||||
|
||||
These three calls are the ones in the [`demo`](../../lib/examples/demo.rs) example;
|
||||
[`cube.rs`](../../lib/examples/cube.rs) shows a point + a spot on top of the default
|
||||
directional, and [`spot_test.rs`](../../lib/examples/spot_test.rs) isolates a single spot
|
||||
(ambient nearly zero).
|
||||
|
||||
Global settings:
|
||||
|
||||
| Method | Effect |
|
||||
|---------|--------|
|
||||
| `set_ambient([r, g, b])` | hemispherical ambient color (default white) |
|
||||
| `clear_lights()` | empties the list — only ambient will light the scene (useful for a flat look without switching to unlit) |
|
||||
| `set_lights(Lights)` | replaces the whole list (batch reset) |
|
||||
| `lights()` | reads the current list |
|
||||
|
||||
## ⚠️ Packed indices (important for shadows)
|
||||
|
||||
Lights are stacked in the GPU array **by type, in order**:
|
||||
|
||||
```
|
||||
index 0 .. n_dir-1 : directional
|
||||
index n_dir .. +n_point-1 : point
|
||||
index … .. +n_spot-1 : spot
|
||||
```
|
||||
|
||||
Two consequences:
|
||||
|
||||
1. **Index 0 is the default +Z directional** (the one `Lights::new()` pre-loads),
|
||||
not your first added light. This is a classic pitfall — see
|
||||
[Shadows](shadows.md).
|
||||
2. If you want **your** light to be the only one (and thus at index 0), clear the list
|
||||
first: `app.scene.clear_lights();` then `add_*_light(…)` (this is the technique in
|
||||
[`shadow_test.rs`](../../lib/examples/shadow_test.rs)).
|
||||
|
||||
## Intensities and tints
|
||||
|
||||
- `color` is an RGB in `[0..1]`; `intensity` is an unbounded multiplier.
|
||||
- Local lights (point/spot) attenuate **linearly** — intensity drops to zero at `radius`.
|
||||
Beyond the radius, the light contributes nothing.
|
||||
- The `standard` shader accumulates ambient + all lights (no mutual occlusion between
|
||||
lights; the spot cone culling happens at the fragment).
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Shadows](shadows.md) · [Materials & textures](materials.md)
|
||||
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Materials & textures
|
||||
|
||||
A **`Material`** describes a mesh's appearance: it references a shader (by id) and
|
||||
optionally a **diffuse texture**. Several materials pointing at the same shader share the
|
||||
same compiled GPU pipeline (the `PipelineCache` held by the scene).
|
||||
|
||||
The engine ships a single shader: **`standard`** — multi-light Phong lighting (see
|
||||
[Lights](lights.md)), with an **unlit** mode for flat rendering.
|
||||
|
||||
## 1. Registering the shader
|
||||
|
||||
```rust
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
```
|
||||
|
||||
> **Note**: `STANDARD_SHADER_PATH` points to an optional file on disk; if it is missing
|
||||
> (the normal case for the embedded library), loading falls back to the shader **embedded at
|
||||
> compile time** (`include_str!`, byte-identical). The fallback message you may see is
|
||||
> therefore **expected and harmless**.
|
||||
|
||||
For a custom shader: register your `.wgsl` file path under an id of your choice (it must
|
||||
expose the same bind groups as `standard` — frame @0, object @1, texture @2, shadow @3 — see
|
||||
[ARCHI_RENDU](../tech/ARCHI_RENDU.md) and the
|
||||
[`shaders/standard_shader.wgsl`](../../lib/src/shaders/standard_shader.wgsl) file).
|
||||
|
||||
## 2. Creating materials
|
||||
|
||||
```rust
|
||||
// Textureless material: the color comes from per-vertex colors (or white by default).
|
||||
app.scene.add_material_shader("mat", "standard").unwrap();
|
||||
|
||||
// Textured material: the texture must first be registered in the scene (below).
|
||||
app.scene.add_material_texture("mat_textured", "standard", "my_texture").unwrap();
|
||||
```
|
||||
|
||||
Binding a material to a mesh happens at mesh creation (see [Meshes](meshes.md)):
|
||||
|
||||
```rust
|
||||
app.scene.create_mesh("cube_mesh", cube(1.0), Some("mat_textured")).unwrap();
|
||||
```
|
||||
|
||||
A mesh created with `material = None` is rendered with the scene's **default material**
|
||||
(`standard`, built once then cached) — that is the behavior of the
|
||||
[`simple`](../../lib/examples/simple.rs) example.
|
||||
|
||||
## 3. Diffuse textures
|
||||
|
||||
`Texture` is a GPU image in `Rgba8UnormSrgb` (linear sampler, repeat addressing).
|
||||
Four constructors:
|
||||
|
||||
| Constructor | Usage |
|
||||
|--------------|-------|
|
||||
| `Texture::from_rgba8(device, queue, w, h, rgba, label)` | raw RGBA8 bytes (procedural) |
|
||||
| `Texture::from_bytes(device, queue, label, bytes)` | encoded data (PNG/JPEG… via the `image` crate) |
|
||||
| `Texture::from_file(device, queue, label, path)` | image file on disk |
|
||||
| `Texture::white_placeholder(device, queue)` | 1×1 white — used internally when a material has no texture |
|
||||
|
||||
You get `device`/`queue` in `setup()` via `app.context()`:
|
||||
|
||||
```rust
|
||||
let (device, queue) = {
|
||||
let ctx = app.context();
|
||||
(ctx.device.clone(), ctx.queue.clone())
|
||||
};
|
||||
let texture = Texture::from_rgba8(&device, &queue, 8, 8, &my_rgba, "checker").unwrap();
|
||||
app.scene.add_texture("checker_texture", texture).unwrap();
|
||||
app.scene.add_material_texture("ground_mat", "standard", "checker_texture").unwrap();
|
||||
```
|
||||
|
||||
The exact snippet (8×8 checkerboard + stripes generation) is in
|
||||
[`demo.rs`](../../lib/examples/demo.rs) and [`cube.rs`](../../lib/examples/cube.rs).
|
||||
|
||||
Two conditions for a texture to show up:
|
||||
1. the material is created via `add_material_texture` (otherwise the 1×1 white placeholder
|
||||
is bound — no visual effect, no regression);
|
||||
2. the `Geometry` carries **UVs** (`.with_uvs(…)`). Without UVs, sampling is constant.
|
||||
The procedural primitives (`uv_sphere`, `cube`, …) already provide them.
|
||||
|
||||
## 4. Unlit mode (flat / 2D rendering)
|
||||
|
||||
"Flat" rendering (vertex colors as-is, no lighting) is a **renderer switch**, not a material:
|
||||
|
||||
```rust
|
||||
app.renderer_mut().set_unlit(true); // in setup()
|
||||
```
|
||||
|
||||
This is the mode of the `simple` example (2D quad). In this mode the scene's lights are
|
||||
ignored; per-vertex colors (or white) are rendered directly. 2D is a special case of 3D:
|
||||
the single `standard` pipeline serves both.
|
||||
|
||||
> `clear_lights()` (see [Lights](lights.md)) gives a similar result but keeps the lit
|
||||
> pipeline: only ambient stays active. Use it when you want to "turn off the lights" without
|
||||
> switching to unlit.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Meshes](meshes.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
|
||||
@@ -0,0 +1,108 @@
|
||||
# Module `mesh` — Sources de géométrie
|
||||
|
||||
Le module `wsg::mesh` est le point d'entrée unique pour **d'où vient la géométrie** :
|
||||
générateurs procéduraux ou import de fichiers.
|
||||
|
||||
## Primitives procédurales
|
||||
|
||||
Chaque famille de primitives est derrière une **feature** — vous ne compilez que ce dont vous avez besoin.
|
||||
|
||||
| Feature | Fonction | Description |
|
||||
|---------|----------|-------------|
|
||||
| `prim-cube` | `cube(size)` | Cube centré, 24 sommets, normales par face |
|
||||
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Plan horizontal XZ (normale +Y), subdivisé |
|
||||
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Sphère lat/long, normales lisses |
|
||||
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphère (subdiv icosahedron) |
|
||||
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylindre (côté + caps), normales analytiques |
|
||||
| `prim-cone` | `cone(r, h, sectors)` | Cône (apex + base fermée) |
|
||||
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Tore, normales lisses |
|
||||
|
||||
### Features par défaut
|
||||
|
||||
```toml
|
||||
# Cargo.toml de votre projet
|
||||
[dependencies]
|
||||
wsg-lib = { path = "../lib" }
|
||||
# Default: toutes les primitives activées (all-prims)
|
||||
```
|
||||
|
||||
```toml
|
||||
# Ne compiler que le cube et la sphère :
|
||||
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube", "prim-sphere"] }
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```rust
|
||||
use wsg_lib::prelude::*;
|
||||
|
||||
let cube = cube(2.0);
|
||||
let sphere = uv_sphere(1.0, 32, 16);
|
||||
let ico = icosphere(1.0, 2);
|
||||
|
||||
// Tous retournent un Geometry (positions + normals + UVs + indices)
|
||||
assert_eq!(cube.positions.len(), 24);
|
||||
```
|
||||
|
||||
## Import de fichiers
|
||||
|
||||
| Feature | Fonction | Format |
|
||||
|---------|----------|--------|
|
||||
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
|
||||
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
|
||||
|
||||
### Parser OBJ
|
||||
|
||||
Supporte : `v`, `vn`, `vt`, `f` (3-4 sommets, triangulation en éventail).
|
||||
Si le fichier n'a pas de normales, elles sont **calculées** (pondération par aire).
|
||||
|
||||
```rust
|
||||
use wsg_lib::mesh::{load_obj, parse_obj};
|
||||
|
||||
// Depuis un fichier
|
||||
let geom = load_obj("model.obj")?;
|
||||
|
||||
// Depuis une string
|
||||
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
|
||||
```
|
||||
|
||||
### Erreurs
|
||||
|
||||
```rust
|
||||
use wsg_lib::mesh::import::MeshImportError;
|
||||
|
||||
match load_obj("missing.obj") {
|
||||
Ok(geom) => { /* … */ }
|
||||
Err(MeshImportError::Io(e)) => eprintln!("fichier inaccessible: {e}"),
|
||||
Err(MeshImportError::Parse(e)) => eprintln!("syntaxe invalide: {e}"),
|
||||
Err(MeshImportError::Unsupported(e)) => eprintln!("feature non supportée: {e}"),
|
||||
}
|
||||
```
|
||||
|
||||
## De `Geometry` à la scène
|
||||
|
||||
Le module `mesh` produit des `Geometry` (données CPU). Pour les rendre,
|
||||
passez par `Scene::create_mesh` qui les transfère en GPU :
|
||||
|
||||
```rust
|
||||
use wsg_lib::prelude::*;
|
||||
use wsg_lib::mesh::cube;
|
||||
|
||||
// Dans AppHandler::setup :
|
||||
let geom = cube(1.0);
|
||||
app.scene.create_mesh("my_mesh", geom, Some("my_mat"))?;
|
||||
app.scene.add_entity("my_entity", "my_mesh")?;
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```sh
|
||||
cargo run -p wsg-lib --example import --features import-obj -- model.obj
|
||||
```
|
||||
|
||||
## Convention
|
||||
|
||||
- **Y-up**, origine centrée (sauf `plane` : plan XZ à y=0)
|
||||
- Normales **sortantes**
|
||||
- UVs dans [0,1]²
|
||||
- Winding **CCW** (face avant)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Meshes: geometries, entities and transforms
|
||||
|
||||
A displayed object in WSG goes through three levels:
|
||||
|
||||
```
|
||||
Geometry (CPU, source of truth) ──► Mesh (GPU: vertex/index buffers) ──► Entity (placement in the scene)
|
||||
```
|
||||
|
||||
- **`Geometry`**: raw CPU-side data — positions + optional normals/UVs/colors/indices.
|
||||
- **`Mesh`**: GPU container (buffers uploaded once). It **retains** its `Arc<Geometry>` on the
|
||||
CPU side, along with its material.
|
||||
- **`Entity`**: a `mesh + Transform` association. This is the unit the engine draws. The same
|
||||
`Mesh` can be shared by several entities (each with its own `Transform`).
|
||||
|
||||
## 1. Procedural primitives (the shortest path)
|
||||
|
||||
The `math::primitives` module provides ready-to-use `Geometry` generators
|
||||
(positions + normals + UVs + indices):
|
||||
|
||||
| Function | Parameters | Result |
|
||||
|----------|-----------|--------|
|
||||
| `cube(size)` | side length | origin-centered cube, per-face normals |
|
||||
| `plane(width, depth, seg_x, seg_z)` | dimensions + subdivisions | horizontal plane (Y-up), UVs |
|
||||
| `uv_sphere(radius, sectors, stacks)` | radius + resolution | UV sphere (seam visible) |
|
||||
| `icosphere(radius, subdivisions)` | radius + subdivisions | smooth sphere (normalized, seam-free) |
|
||||
| `cylinder(radius, height, sectors)` | radius, height, resolution | centered cylinder |
|
||||
| `cone(radius, height, sectors)` | radius, height, resolution | cone (base at the bottom when translated in Y) |
|
||||
| `torus(major, minor, major_segments, minor_segments)` | radii + resolution | torus |
|
||||
|
||||
```rust
|
||||
use wsg_lib::math::{cube, icosphere, torus};
|
||||
|
||||
app.scene.create_mesh("cube_mesh", cube(0.8), Some("solid_mat")).unwrap();
|
||||
app.scene.create_mesh("sphere_mesh", icosphere(0.5, 2), Some("solid_mat")).unwrap();
|
||||
```
|
||||
|
||||
## 2. Custom `Geometry` (your own mesh)
|
||||
|
||||
`Geometry` is a builder: positions are mandatory, everything else is optional
|
||||
(sensible defaults are applied at upload — e.g. normal `[0,0,1]`, white color).
|
||||
|
||||
```rust
|
||||
use wsg_lib::resources::Geometry;
|
||||
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4]) // required for lighting (Phong)
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0, 1.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]); // triangulation (without indices: triangle list)
|
||||
```
|
||||
|
||||
Other attributes: `.with_uvs(vec![[u, v], …])` (required for textures — see
|
||||
[Materials & textures](materials.md)). `geometry.validate()` checks the arrays for
|
||||
consistency (aligned lengths, indices in range) before upload.
|
||||
|
||||
> **Indices**: `Vec<u16>` — a custom mesh must therefore stay under 65,536 vertices. The
|
||||
> engine's primitives respect this limit.
|
||||
|
||||
## 3. Registering in the scene
|
||||
|
||||
```rust
|
||||
// The mesh is built (GPU buffers) and bound to its material in one call.
|
||||
// `material = None`: the scene will use its default material (`standard`) at render time.
|
||||
app.scene.create_mesh("cube_mesh", geometry, Some("cube_material"))?;
|
||||
|
||||
// The entity references the mesh by its id (String IDs).
|
||||
app.scene.add_entity("cube", "cube_mesh")?;
|
||||
// …or with an explicit placement:
|
||||
app.scene.add_entity_with_transform("cube", "cube_mesh", transform)?;
|
||||
```
|
||||
|
||||
All these methods return `Result<_, String>` (unifying the typed errors is on the
|
||||
horizon — see [ROADMAP](../ROADMAP.md)).
|
||||
|
||||
## 4. Moving / animating: the `Transform`
|
||||
|
||||
Placement lives on the **entity** (not on the mesh): `Transform { translation: Vec3,
|
||||
rotation: Quat, scale: Vec3 }`, converted to a world matrix by the engine every frame.
|
||||
|
||||
The snippet below is the animation from the [`cube`](../../lib/examples/cube.rs) example:
|
||||
|
||||
```rust
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
self.angle += 0.02;
|
||||
let mut tf = *app.scene.entity_transform("cube").expect("entity present");
|
||||
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3);
|
||||
app.scene.set_entity_transform("cube", tf);
|
||||
}
|
||||
```
|
||||
|
||||
Other entity operations: `entity_transform(label)` (read), `remove_entity(label)` (hides
|
||||
without freeing resources), `entity_count()`.
|
||||
|
||||
> **Rotation order**: `Quat` does not commute — `rot_y * rot_x` is not `rot_x * rot_y`.
|
||||
> The order above (Y then X) gives a readable "top spinning" motion.
|
||||
|
||||
## 5. Mesh sharing
|
||||
|
||||
Create **one** mesh per geometry and as many entities as occurrences:
|
||||
|
||||
```rust
|
||||
app.scene.create_mesh("rock_mesh", icosphere(0.3, 1), Some("rock_mat")).unwrap();
|
||||
for i in 0..10 {
|
||||
let label = format!("rock_{i}");
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = Vec3::new(i as f32 * 0.8, 0.15, 0.0);
|
||||
app.scene.add_entity_with_transform(&label, "rock_mesh", tf).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
The GPU buffers are uploaded only once; only the world matrices differ.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Quickstart](quickstart.md) · [Materials & textures](materials.md) · [Lights](lights.md)
|
||||
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,131 @@
|
||||
# Quickstart
|
||||
|
||||
Goal: a window showing an object, with the render loop handled by the library. You will only
|
||||
write three things: a struct implementing `AppHandler`, your scene declaration in `setup()`,
|
||||
and your `main()`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A recent Rust toolchain (the library is **edition 2024** — run `rustup update` if needed).
|
||||
- A windowing environment (X11/Wayland on Linux, or native macOS/Windows).
|
||||
- WSG is **not published on crates.io**: it is consumed by file path.
|
||||
|
||||
## 1. Dependencies
|
||||
|
||||
In your application's `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
wsg-lib = { path = "/path/to/wsg/lib" }
|
||||
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (AppBuilder is async)
|
||||
```
|
||||
|
||||
## 2. The minimal application
|
||||
|
||||
This snippet is the [`simple`](../../lib/examples/simple.rs) example from the repo, almost
|
||||
verbatim: a flat two-tone quad, rendered automatically every frame.
|
||||
|
||||
```rust
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
use wsg_lib::AppHandler;
|
||||
|
||||
struct MyQuad;
|
||||
|
||||
impl AppHandler for MyQuad {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
// Flat 2D: the `standard` shader in unlit mode returns the vertex color as-is.
|
||||
app.renderer_mut().set_unlit(true);
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
let geometry = Geometry::new(vec![
|
||||
[-0.5, 0.5, 0.0],
|
||||
[ 0.5, 0.5, 0.0],
|
||||
[ 0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0], // red
|
||||
[0.0, 1.0, 0.0, 1.0], // green
|
||||
[0.0, 0.0, 1.0, 1.0], // blue
|
||||
[1.0, 1.0, 0.0, 1.0], // yellow
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
|
||||
// `None`: the scene injects its default material (`standard`) at render time.
|
||||
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
|
||||
app.scene.add_entity("quad", "quad_mesh").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
||||
app.run(MyQuad)
|
||||
}
|
||||
```
|
||||
|
||||
Note: **no `wgpu` or `winit` imports** — the `App` facade encapsulates them entirely.
|
||||
|
||||
## 3. What the library does for you
|
||||
|
||||
The full lifecycle, as driven by `App::run` (technical details in
|
||||
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
|
||||
|
||||
```
|
||||
AppBuilder::build() creates the event loop
|
||||
│
|
||||
App::run(handler) starts the loop
|
||||
│
|
||||
resumed (winit) window + GPU (Instance/Surface/Adapter/Device/Queue) + Renderer
|
||||
│
|
||||
handler.setup(&mut app) ← you declare the scene here (once, GPU ready)
|
||||
│
|
||||
▼ per frame, in a loop:
|
||||
input.begin_frame() current frame's keyboard/mouse state
|
||||
handler.update(&mut app) ← your logic (motion, input, …)
|
||||
input.end_frame()
|
||||
handler.render(app, frame) ← default: app.render_scene(frame.view())
|
||||
│ (the whole scene is drawn automatically, one pass per frame)
|
||||
└─ present → next frame
|
||||
```
|
||||
|
||||
So you implement:
|
||||
|
||||
| Hook | When | Role | Default |
|
||||
|------|-------|------|---------|
|
||||
| `setup(&mut self, app)` | once, GPU ready | declare shaders, materials, textures, meshes, entities, lights, camera | empty |
|
||||
| `update(&mut self, app)` | every frame, before render | animate: transforms, input, lights… | empty |
|
||||
| `render(&mut self, app, frame)` | every frame, after update | **default**: draws the whole scene; override for custom rendering | `app.render_scene(frame.view())` |
|
||||
|
||||
Golden rule: **mutate the scene in `update()`** (and `setup()`), only read it in `render()`
|
||||
(model detailed in [ARCHI_RENDU](../tech/ARCHI_RENDU.md)).
|
||||
|
||||
## 4. Running it
|
||||
|
||||
From the WSG repo root (the examples live in `lib/examples/`):
|
||||
|
||||
| Command | What you see |
|
||||
|----------|--------------|
|
||||
| `cargo run -p wsg-lib --example simple` | the quad above (flat 2D, unlit) |
|
||||
| `cargo run -p wsg-lib --example cube` | a textured, lit, spinning cube (3D) |
|
||||
| `cargo run -p wsg-lib --example demo` | the full showcase: 6 primitives + lights + shadows + orbital camera |
|
||||
|
||||
For your own application: create a crate, add the §1 dependency, paste the §2 code into
|
||||
`src/main.rs`, and `cargo run`.
|
||||
|
||||
## 5. Where to go next
|
||||
|
||||
- Want a 3D object? → [Meshes](meshes.md)
|
||||
- Want to change the look / add a texture? → [Materials & textures](materials.md)
|
||||
- Want lights? → [Lights](lights.md)
|
||||
- Want to see everything at once? → the `demo` example ([Examples](examples.md))
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Meshes](meshes.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Shadows (shadow mapping)
|
||||
|
||||
Shadows are **off by default** and are enabled by designating **a single** casting light:
|
||||
|
||||
```rust
|
||||
app.scene.set_shadow_caster(Some(index)); // packed index — see the pitfall below
|
||||
app.scene.set_shadow_caster(None); // shadows off (default)
|
||||
```
|
||||
|
||||
Only a **directional or spot** light can cast shadows. A **point** light index disables the
|
||||
shadow pass (cubemap shadows are out of scope).
|
||||
|
||||
## ⚠️ The packed-index pitfall
|
||||
|
||||
`set_shadow_caster` takes the light's index **in the packed array** (directionals first,
|
||||
then point, then spot — recalled in [Lights](lights.md)).
|
||||
|
||||
**Index 0 is the default +Z directional** pre-loaded by `Lights::new()`, not necessarily
|
||||
your light. Symptom of a wrong index: the shadow camera looks in an unexpected direction and
|
||||
misaligned objects occlude each other (blackened objects, ghost shadows).
|
||||
|
||||
Two ways to avoid it:
|
||||
|
||||
1. **Clear the list before adding yours** — your light becomes index 0:
|
||||
|
||||
```rust
|
||||
app.scene.clear_lights(); // removes the default +Z
|
||||
app.scene.add_directional_light(dir, [1.0, 0.98, 0.92], 1.6).unwrap();
|
||||
app.scene.set_shadow_caster(Some(0)); // now it really is YOUR light
|
||||
```
|
||||
|
||||
This is the technique in [`shadow_test.rs`](../../lib/examples/shadow_test.rs).
|
||||
|
||||
2. **Count the indices** — if you keep the default light and add yours, it lands at index 1:
|
||||
|
||||
```rust
|
||||
app.scene.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5).unwrap(); // → index 1
|
||||
app.scene.set_shadow_caster(Some(1)); // this is the demo's warm light that casts
|
||||
```
|
||||
|
||||
This is the technique in [`demo.rs`](../../lib/examples/demo.rs).
|
||||
|
||||
## How it works (to understand the limits)
|
||||
|
||||
Each frame, if a caster is active, the engine runs **two passes** (technical details in
|
||||
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
|
||||
|
||||
1. **Shadow pass**: the scene is rendered as seen *from the light* (depth-only
|
||||
`shadow_shader.wgsl` shader) into a 1024² `Depth32Float` shadow map (size configurable
|
||||
via `SHADOW_MAP_SIZE`), with a depth bias (slope-scaled + constant) to avoid shadow acne.
|
||||
2. **Color pass**: the `standard` fragment shader re-projects each fragment into light space
|
||||
and compares its depth against the map via a **3×3 PCF** (softened shadow edges).
|
||||
|
||||
Things to know:
|
||||
|
||||
- **Directional light**: the shadow frustum is orthographic, centered on the scene center
|
||||
(`SHADOW_SCENE_CENTER`, radius `SHADOW_SCENE_RADIUS = 5.0` by default). Objects **far from
|
||||
the origin** may fall outside the frustum and stop casting.
|
||||
- **Spot light**: the light's cone naturally bounds the shadow.
|
||||
- Only one light casts at a time (no multi-light shadows).
|
||||
- Shadows only affect meshes rendered by `standard` in lit mode — a renderer in unlit mode
|
||||
(see [Materials & textures](materials.md)) receives none.
|
||||
|
||||
## Tuning shadow rendering
|
||||
|
||||
The constants `SHADOW_MAP_SIZE`, `SHADOW_DEPTH_BIAS`, `SHADOW_SCENE_RADIUS`,
|
||||
`SHADOW_SCENE_CENTER` are exposed in `wsg_lib::utils` (defaults: 1024, 0.006, 5.0, origin).
|
||||
|
||||
Tuning tips:
|
||||
|
||||
- **Speckled shadow edges (acne)**: raise the bias.
|
||||
- **Peter-panning** (shadow detached from the object): lower the bias.
|
||||
- **Shadow clipped at the scene edge**: raise the frustum radius (directional).
|
||||
- **Shadows too blurry, want them crisper**: raise the map size.
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
|
||||
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
|
||||
+20
-1
@@ -6,10 +6,29 @@ edition = "2024"
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["all-prims"]
|
||||
|
||||
# Primitives procédurales (zéro dep externe)
|
||||
prim-cube = []
|
||||
prim-plane = []
|
||||
prim-sphere = []
|
||||
prim-cylinder = []
|
||||
prim-cone = []
|
||||
prim-torus = []
|
||||
all-prims = ["prim-cube", "prim-plane", "prim-sphere", "prim-cylinder", "prim-cone", "prim-torus"]
|
||||
|
||||
# Import de fichiers
|
||||
import-obj = []
|
||||
import-gltf = []
|
||||
|
||||
[dependencies]
|
||||
wgpu = "30.0.0" # Vérifiez la version la plus récente
|
||||
winit = "0.30.13" # For window management — pinned to match examples
|
||||
thiserror = "2"
|
||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||
glam = "0.33"
|
||||
glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs)
|
||||
pollster = { version="1.0.1", features = ["macro"] }
|
||||
# Étape 10 (Textures, DRAFT D3) : décodage d'images (PNG/JPEG) pour charger des textures diffuses.
|
||||
# default-features = false pour n'emporter que les codecs utiles (plus petit arbre de compilation).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Examples
|
||||
|
||||
Each `.rs` file in this directory is a **standalone example** auto-discovered by Cargo
|
||||
(`cargo build -p wsg-lib --examples`). To run an example:
|
||||
|
||||
```bash
|
||||
cargo run -p wsg-lib --example <name>
|
||||
```
|
||||
|
||||
| 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. |
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,111 @@
|
||||
//! A lit unit cube that rotates, **textured** with a procedural checkerboard via the diffuse path
|
||||
//! (bind group `@group(2)`).
|
||||
//!
|
||||
//! A 3D mesh with Phong lighting on screen — the library's 3D showcase.
|
||||
//! Follows the declarative workflow (like `simple`): `AppBuilder` + automatic scene, **no wgpu import**.
|
||||
//! The scene owns its `PipelineCache`: go through `register_shader` +
|
||||
//! `add_material_shader`/`add_material_texture` + `create_mesh` + `add_entity`. The mesh
|
||||
//! is declared from a **`Geometry`** (positions, normals, indices). A texture is
|
||||
//! registered by id (`add_texture`) and a textured material bound to it (`add_material_texture`);
|
||||
//! the texture is generated *procedurally* (RGBA 8×8 checkerboard) to stay self-contained, no on-disk asset.
|
||||
//! The default active camera (`Scene::default`, position (0,0,3), fov 45°) frames the cube, and
|
||||
//! `AppHandler::update` rotates the entity via `set_entity_transform` each frame.
|
||||
use glam::{Quat, Vec3};
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::mesh::cube;
|
||||
use wsg_lib::resources::Texture;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Demo handler: rotates the textured cube in `update`.
|
||||
struct Cube {
|
||||
/// Cumulative rotation angle (radians), incremented each frame.
|
||||
angle: f32,
|
||||
}
|
||||
|
||||
/// Generates a *procedural* RGBA 8×8 checkerboard (white/brick), no on-disk asset, to texture the
|
||||
/// cube. Returned as a raw RGBA8 `Vec<u8>`, loadable via `Texture::from_rgba8`.
|
||||
fn checkerboard_rgba() -> Vec<u8> {
|
||||
const SIZE: u32 = 8;
|
||||
let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let even = (x + y) % 2 == 0;
|
||||
let (r, g, b) = if even { (255, 255, 255) } else { (190, 40, 40) };
|
||||
rgba.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
impl AppHandler for Cube {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
// Phong shader `standard` (carries the frame + object + texture bind groups).
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
// Builds the checkerboard texture with the Context's device/queue (via `app.context()`), then
|
||||
// registers it in the scene by id; a textured material is then bound to that id.
|
||||
let (device, queue) = {
|
||||
let ctx = app.context();
|
||||
(ctx.device.clone(), ctx.queue.clone())
|
||||
};
|
||||
let texture =
|
||||
Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap();
|
||||
app.scene.add_texture("checker_texture", texture).unwrap();
|
||||
app.scene
|
||||
.add_material_texture("cube_material", "standard", "checker_texture")
|
||||
.unwrap();
|
||||
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(1.0), Some("cube_material"))
|
||||
.unwrap();
|
||||
app.scene.add_entity("cube", "cube_mesh").unwrap();
|
||||
|
||||
// In addition to the default directional light (+Z), a warm **point** light
|
||||
// is added in front of the cube. Its halo (linear attenuation over the
|
||||
// radius) is visible on the near face of the cube, on top of the directional lighting.
|
||||
app.scene
|
||||
.add_point_light(
|
||||
Vec3::new(1.0, 0.5, 1.5), // world position, in front/right of the cube
|
||||
[1.0, 0.7, 0.3], // warm tint
|
||||
1.0, // intensity
|
||||
3.0, // attenuation radius
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A green **spot** light aimed at the cube from the left.
|
||||
// The cone (half-angle ~20°) projects a directed beam onto the cube's faces, with a
|
||||
// smoothed penumbra at the edge and linear attenuation over the radius.
|
||||
app.scene
|
||||
.add_spot_light(
|
||||
Vec3::new(-2.0, 1.0, 1.5), // world position, left/above/behind the camera
|
||||
Vec3::new(2.0, -1.0, -1.5).normalize(), // cone axis, toward the cube (origin)
|
||||
[0.3, 1.0, 0.4], // green tint
|
||||
1.2, // intensity
|
||||
4.0, // attenuation radius
|
||||
0.35, // half-angle (~20°) in radians
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Cumulative cube rotation (double axis for a more readable motion).
|
||||
self.angle += 0.02;
|
||||
let base = *app
|
||||
.scene
|
||||
.entity_transform("cube")
|
||||
.expect("cube entity present");
|
||||
let mut transform = base;
|
||||
transform.rotation =
|
||||
Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.3);
|
||||
app.scene.set_entity_transform("cube", transform);
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Cube").build().await?;
|
||||
app.run(Cube { angle: 0.0 })
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! **WSG `demo`** — the final showcase example.
|
||||
//!
|
||||
//! Combines everything built throughout the library into one declarative scene:
|
||||
//!
|
||||
//! * a **ground plane** plus one of each procedural primitive from `math::primitives`
|
||||
//! (`cube`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) placed around it,
|
||||
//! * a **procedural texture** per mesh (checker / stripe grids, no assets on disk),
|
||||
//! * the **standard** Phong material wired to those textures,
|
||||
//! * an **orbital camera** driven live by the unified input state:
|
||||
//! hold the **left mouse button** and drag to orbit (yaw/pitch), the wheel zooms (distance),
|
||||
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
|
||||
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
|
||||
//! so the shadow of the cube and the colored light halos are all visible,
|
||||
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly,
|
||||
//! * **LOD** (Step 19): the rounded primitives are created with three levels each
|
||||
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's
|
||||
//! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and
|
||||
//! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen.
|
||||
//! * **HDR + Tone Mapping** (Étape 20): the demo enables ACES Filmic tone mapping via
|
||||
//! `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.
|
||||
//!
|
||||
//! Doc (this header) follows the English convention used for examples; internal comments stay
|
||||
//! concise and French where helpful. Run with:
|
||||
//!
|
||||
//! `cargo run -p wsg-lib --example demo`
|
||||
|
||||
use glam::{Quat, Vec3};
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
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::utils::WsgError;
|
||||
|
||||
/// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`.
|
||||
fn checkerboard_rgba() -> Vec<u8> {
|
||||
const SIZE: u32 = 8;
|
||||
let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let even = (x + y) % 2 == 0;
|
||||
let (r, g, b) = if even { (235, 235, 228) } else { (150, 90, 70) };
|
||||
rgba.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
/// Generates a vertical stripe texture (blue / cyan), useful to make rotation visible on rounded
|
||||
/// bodies (sphere / cylinder) via the UV seams.
|
||||
fn stripes_rgba() -> Vec<u8> {
|
||||
const W: u32 = 32;
|
||||
const H: u32 = 16;
|
||||
let mut rgba = Vec::with_capacity((W * H * 4) as usize);
|
||||
for _y in 0..H {
|
||||
for x in 0..W {
|
||||
let band = (x / 4) % 2 == 0;
|
||||
let (r, g, b) = if band { (40, 90, 190) } else { (120, 210, 235) };
|
||||
rgba.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
/// Demo handler: holds the orbital controller plus a slow rotation angle.
|
||||
struct Demo {
|
||||
camera: CameraController,
|
||||
angle: f32,
|
||||
/// Phase 3 black-window investigation: number of debug_dump calls already made.
|
||||
dbg: u32,
|
||||
}
|
||||
|
||||
/// Horizontal radius at which the primitives sit around the origin.
|
||||
const ORBIT_RADIUS: f32 = 1.7;
|
||||
/// Vertical offset so the meshes stand on the ground plane (y = 0).
|
||||
const STAND_HEIGHT: f32 = 0.5;
|
||||
|
||||
/// Lays out one primitive (already scaled/positioned) at an angle around the origin.
|
||||
fn place(label: &str, mesh: &str, app: &mut wsg_lib::App, index: usize) {
|
||||
let a = index as f32 / 6.0 * std::f32::consts::TAU;
|
||||
let mut tf = Transform::identity();
|
||||
tf.translation = Vec3::new(a.cos() * ORBIT_RADIUS, STAND_HEIGHT, a.sin() * ORBIT_RADIUS);
|
||||
tf.rotation = Quat::from_rotation_y(a); // face the center
|
||||
app.scene
|
||||
.add_entity_with_transform(label, mesh, tf)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
impl AppHandler for Demo {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
// 1. Shader + material base.
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
let (device, queue) = {
|
||||
let ctx = app.context();
|
||||
(ctx.device.clone(), ctx.queue.clone())
|
||||
};
|
||||
|
||||
// 2. Procedural textures, one material per pattern.
|
||||
let checker =
|
||||
Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap();
|
||||
app.scene.add_texture("checker_texture", checker).unwrap();
|
||||
app.scene
|
||||
.add_material_texture("ground_mat", "standard", "checker_texture")
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_material_texture("solid_mat", "standard", "checker_texture")
|
||||
.unwrap();
|
||||
|
||||
let stripes =
|
||||
Texture::from_rgba8(&device, &queue, 32, 16, &stripes_rgba(), "stripes").unwrap();
|
||||
app.scene.add_texture("stripes_texture", stripes).unwrap();
|
||||
app.scene
|
||||
.add_material_texture("stripes_mat", "standard", "stripes_texture")
|
||||
.unwrap();
|
||||
|
||||
// 3. Ground plane (large, thin, textured).
|
||||
app.scene
|
||||
.create_mesh("ground_mesh", plane(9.0, 9.0, 1, 1), Some("ground_mat"))
|
||||
.unwrap();
|
||||
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||
|
||||
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
|
||||
// The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the
|
||||
// rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh,
|
||||
// levels 1.. are auto-generated by quadric edge collapse at halving targets (D10), all
|
||||
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
|
||||
// switches levels on the fly (asymmetric hysteresis, D4).
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(0.8), Some("solid_mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh_with_lod(
|
||||
"sphere_mesh",
|
||||
uv_sphere(0.55, 32, 20),
|
||||
Some("stripes_mat"),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh_with_lod("ico_mesh", icosphere(0.5, 2), Some("solid_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh_with_lod("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh_with_lod("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh_with_lod(
|
||||
"torus_mesh",
|
||||
torus(0.42, 0.16, 24, 16),
|
||||
Some("solid_mat"),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
place("cube_e", "cube_mesh", app, 0);
|
||||
place("sphere_e", "sphere_mesh", app, 1);
|
||||
place("ico_e", "ico_mesh", app, 2);
|
||||
place("cyl_e", "cyl_mesh", app, 3);
|
||||
place("cone_e", "cone_mesh", app, 4);
|
||||
place("torus_e", "torus_mesh", app, 5);
|
||||
|
||||
// 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();
|
||||
app.scene
|
||||
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_spot_light(
|
||||
Vec3::new(-2.5, 2.2, 1.0),
|
||||
Vec3::new(2.5, -2.2, -1.0).normalize(),
|
||||
[0.3, 1.0, 0.5],
|
||||
1.4,
|
||||
8.0,
|
||||
0.45,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The warm directional light above casts shadows. It is packed at index 1: index 0 is
|
||||
// the default +Z directional light pre-loaded by `Lights::new()` (kept here for the
|
||||
// base lighting), so the demo's own light is the SECOND one in the packed array.
|
||||
app.scene.set_shadow_caster(Some(1));
|
||||
app.scene.set_ambient([0.14, 0.14, 0.16]);
|
||||
|
||||
// 6. Active camera, driven by the orbital controller (position, distance, preset target).
|
||||
self.camera.yaw = 0.6;
|
||||
self.camera.pitch = 0.35;
|
||||
self.camera.distance = 6.5;
|
||||
self.camera.target = Vec3::ZERO;
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
}
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// ---- Orbital camera from unified input ----
|
||||
// Classic arc-rotate: orbit ONLY while the left button is held (drag); the wheel zooms
|
||||
// without any button. Sensitivities use the library defaults (0.005 rad/px orbit, 0.9x
|
||||
// per wheel notch); tune them via `camera.orbit_sensitivity` / `camera.zoom_factor`.
|
||||
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);
|
||||
|
||||
// R: reset the view. Keys 1/2/3: front / side / top presets.
|
||||
if app.input.key_pressed(KeyCode::KeyR) {
|
||||
// Keep the target but restore a pleasing default framing.
|
||||
self.camera.yaw = 0.6;
|
||||
self.camera.pitch = 0.35;
|
||||
self.camera.distance = 6.5;
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit1) {
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 0.25;
|
||||
self.camera.distance = 6.5;
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit2) {
|
||||
self.camera.yaw = std::f32::consts::FRAC_PI_2;
|
||||
self.camera.pitch = 0.15;
|
||||
self.camera.distance = 6.5;
|
||||
}
|
||||
if app.input.key_pressed(KeyCode::Digit3) {
|
||||
self.camera.yaw = 0.0;
|
||||
self.camera.pitch = 1.25;
|
||||
self.camera.distance = 8.0;
|
||||
}
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
|
||||
// ---- Slow rotation of the primitives so lighting/shadow read clearly ----
|
||||
self.angle += 0.008;
|
||||
let base = *app
|
||||
.scene
|
||||
.entity_transform("cube_e")
|
||||
.expect("cube entity present");
|
||||
let mut tf = base;
|
||||
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4);
|
||||
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());
|
||||
// Opt-in GPU readback (black-window investigation tooling): WSG_DEBUG_DUMP=N dumps the
|
||||
// first 8 slots of the transform/matrix/draw-args/bbox buffers for N frames (unset = silent,
|
||||
// non-numeric value = 3 frames). Note: orbiting/zooming this camera
|
||||
// can never cull the entity ring — the camera always looks at the origin, so each
|
||||
// entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under
|
||||
// the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all
|
||||
// 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far
|
||||
// off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md.
|
||||
// Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3.
|
||||
let frames = match std::env::var("WSG_DEBUG_DUMP") {
|
||||
Ok(v) => v.parse::<u32>().ok().filter(|&n| n > 0).unwrap_or(3),
|
||||
Err(_) => 0,
|
||||
};
|
||||
if self.dbg < frames {
|
||||
self.dbg += 1;
|
||||
app.renderer().debug_dump(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere.
|
||||
// HDR + ACES tone mapping (Étape 20): renders to an offscreen Rgba16Float texture, then
|
||||
// tone-maps to the sRGB surface. Without `.with_hdr(...)`, the demo would be LDR direct.
|
||||
let app = AppBuilder::new()
|
||||
.title("WSG Demo")
|
||||
.with_culling(true)
|
||||
.with_hdr(ToneMapper::Aces)
|
||||
.build()
|
||||
.await?;
|
||||
app.run(Demo {
|
||||
camera: CameraController::default(),
|
||||
angle: 0.0,
|
||||
dbg: 0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! # Example: File Import (OBJ)
|
||||
//!
|
||||
//! Demonstrates loading a Wavefront OBJ file with `wsg_lib::mesh::load_obj`.
|
||||
//! Parses the file and prints geometry statistics.
|
||||
//!
|
||||
//! ## Build & Run
|
||||
//! ```sh
|
||||
//! cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj
|
||||
//! ```
|
||||
//!
|
||||
//! Without a file argument, parses a built-in sample triangle.
|
||||
|
||||
use wsg_lib::mesh::import::parse_obj;
|
||||
use wsg_lib::mesh::load_obj;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let content = if args.len() > 1 {
|
||||
let path = &args[1];
|
||||
eprintln!("Loading: {path}");
|
||||
match load_obj(path) {
|
||||
Ok(geom) => {
|
||||
print_stats(&geom);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("No file argument — parsing a built-in sample.");
|
||||
eprintln!("Usage: import <model.obj>");
|
||||
// Built-in sample: a simple triangle with UVs and normals
|
||||
"v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.5 1.0 0.0\nvn 0 0 1\nvt 0.0 0.0\nvt 1.0 0.0\nvt 0.5 1.0\nf 1/1/1 2/2/1 3/3/1\n"
|
||||
};
|
||||
|
||||
let geom = parse_obj(content).expect("sample should parse");
|
||||
print_stats(&geom);
|
||||
}
|
||||
|
||||
fn print_stats(geom: &wsg_lib::Geometry) {
|
||||
println!("\n=== Geometry Statistics ===");
|
||||
println!(" Vertices: {}", geom.positions.len());
|
||||
if let Some(n) = &geom.normals {
|
||||
println!(" Normals: {}", n.len());
|
||||
}
|
||||
if let Some(uv) = &geom.uvs {
|
||||
println!(" UVs: {}", uv.len());
|
||||
}
|
||||
if let Some(idx) = &geom.indices {
|
||||
println!(" Indices: {} ({} triangles)", idx.len(), idx.len() / 3);
|
||||
}
|
||||
if let Err(e) = geom.validate() {
|
||||
println!(" Validation FAILED: {e}");
|
||||
} else {
|
||||
println!(" Validation: OK");
|
||||
}
|
||||
// Bounding box
|
||||
if let Some(bbox) = geom.bbox() {
|
||||
println!(" BBox min: {:?}", bbox.min);
|
||||
println!(" BBox max: {:?}", bbox.max);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
+52
-65
@@ -1,7 +1,8 @@
|
||||
//! Workflow bas-niveau : utilisation directe du `Context`, `Renderer`, `PipelineCache`, `Mesh` et
|
||||
//! `Material`, contournant la façade `App`. Rendu d'un quad plat éclairé via la boucle winit 0.30
|
||||
//! (`EventLoop::run_app` + `ApplicationHandler`). La fenêtre et le GPU sont créés dans `resumed()`,
|
||||
//! comme l'exigent winit 0.30 et la migration faite dans `app.rs`.
|
||||
//! Low-level workflow: direct use of `Context`, `Renderer`, `PipelineCache`, `Mesh` and
|
||||
//! `Material`, bypassing the `App` facade. Renders a flat quad (shader `standard` **unlit**) via the
|
||||
//! winit 0.30 loop (`EventLoop::run_app` + `ApplicationHandler`). The window and the GPU are created
|
||||
//! in `resumed()` (winit 0.30 only exposes the display after resume). The mesh is built via
|
||||
//! `Mesh::from_geometry(device, Arc<Geometry>, None)` from a `Geometry` (positions + colors per vertex).
|
||||
use std::sync::Arc;
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::LogicalSize;
|
||||
@@ -10,32 +11,30 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::window::{Window, WindowAttributes};
|
||||
use wsg_lib::core::Context;
|
||||
use wsg_lib::core::Frame;
|
||||
use wsg_lib::core::Renderer;
|
||||
use wsg_lib::core::{Renderer, ShadowConfig};
|
||||
use wsg_lib::pipeline::PipelineCache;
|
||||
use wsg_lib::resources::Material;
|
||||
use wsg_lib::resources::Mesh;
|
||||
use wsg_lib::resources::Vertex;
|
||||
use wsg_lib::resources::{Geometry, Material, Mesh};
|
||||
use wsg_lib::utils;
|
||||
|
||||
/// Application bas-niveau : détient les objets GPU + window, tous créés dans `resumed`.
|
||||
/// Low-level application: holds the GPU objects + window, all created in `resumed`.
|
||||
struct App {
|
||||
/// Fenêtre système, partagée via Arc (comme dans app.rs).
|
||||
/// System window, shared via Arc (as in app.rs).
|
||||
window: Option<Arc<Window>>,
|
||||
/// Contexte GPU (Instance, Surface, Adapter, Device, Queue).
|
||||
/// GPU context (Instance, Surface, Adapter, Device, Queue).
|
||||
context: Option<Context>,
|
||||
/// Couche d'exécution qui soumet les draw calls.
|
||||
/// Execution layer that submits draw calls.
|
||||
renderer: Option<Renderer>,
|
||||
/// Cache de shaders/pipelines.
|
||||
/// Shader/pipeline cache.
|
||||
cache: Option<PipelineCache>,
|
||||
/// Matériau (pipeline) du quad.
|
||||
/// Quad material (pipeline).
|
||||
material: Option<Material>,
|
||||
/// Mesh du quad (sommets + indices).
|
||||
/// Quad mesh (vertices + indices).
|
||||
mesh: Option<Mesh>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
/// Crée la fenêtre puis le GPU, et construit le mesh/matériau. Exécuté une fois au démarrage.
|
||||
/// Redondant `resumed` pour créer à nouveau ? double protection par `self.context.is_some()`.
|
||||
/// Creates the window then the GPU, and builds the mesh/material. Runs once at startup.
|
||||
/// Redundant `resumed` creating again? Double protection via `self.context.is_some()`.
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.context.is_some() {
|
||||
return;
|
||||
@@ -48,55 +47,46 @@ impl ApplicationHandler for App {
|
||||
let window = Arc::new(event_loop.create_window(attrs).unwrap());
|
||||
|
||||
// 1. Initialisation
|
||||
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
|
||||
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
|
||||
|
||||
// Configuration de la surface et récupération du format
|
||||
// Surface configuration and format retrieval
|
||||
let format = context
|
||||
.configure(&context.adapter, 800, 600)
|
||||
.expect("Échec configuration");
|
||||
.expect("configuration failed");
|
||||
|
||||
// 2. Initialisation du Renderer (Il récupère tout ce dont il a besoin)
|
||||
// 2. Renderer initialization (it retrieves everything it needs)
|
||||
let device = Arc::new(context.device.clone());
|
||||
let mut cache = PipelineCache::new(device);
|
||||
let mut cache = PipelineCache::new(device, context.queue.clone());
|
||||
cache
|
||||
.register_shader("basic", utils::BASIC_SHADER_PATH)
|
||||
.register_shader("standard", utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
let renderer = Renderer::new(&context, format);
|
||||
// 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);
|
||||
renderer.set_unlit(true);
|
||||
|
||||
// 3. Material : On utilise renderer.device() et renderer.format()
|
||||
let material = Material::new(renderer.format(), "basic", &mut cache);
|
||||
// 3. Material: uses renderer.device() and renderer.format()
|
||||
let material = Material::new(renderer.format(), "standard", &mut cache);
|
||||
|
||||
// Mesh : On utilise le device du renderer
|
||||
let vertices = [
|
||||
// Position (x,y,z) | Normale (x,y,z) | UV (u,v) | Couleur (r,g,b,a)
|
||||
Vertex {
|
||||
position: [-0.5, 0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.0, 0.0],
|
||||
color: [1.0, 0.0, 0.0, 1.0],
|
||||
}, // Haut-Gauche (Rouge)
|
||||
Vertex {
|
||||
position: [0.5, 0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [1.0, 0.0],
|
||||
color: [0.0, 1.0, 0.0, 1.0],
|
||||
}, // Haut-Droite (Vert)
|
||||
Vertex {
|
||||
position: [0.5, -0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [1.0, 1.0],
|
||||
color: [0.0, 0.0, 1.0, 1.0],
|
||||
}, // Bas-Droite (Bleu)
|
||||
Vertex {
|
||||
position: [-0.5, -0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.0, 1.0],
|
||||
color: [1.0, 1.0, 0.0, 1.0],
|
||||
}, // Bas-Gauche (Jaune)
|
||||
];
|
||||
let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
|
||||
let mesh = Mesh::new(renderer.device(), &vertices, Some(&indices));
|
||||
// Mesh: uses the renderer's device. The mesh is built from a `Geometry`
|
||||
// (positions + colors per vertex) via `Mesh::from_geometry` — the mesh also keeps the
|
||||
// `Arc<Geometry>` on the CPU side.
|
||||
let geometry = Geometry::new(vec![
|
||||
// Position (x,y,z) | Color (r,g,b,a) — normals/UVs default via to_vertices
|
||||
[-0.5, 0.5, 0.0],
|
||||
[0.5, 0.5, 0.0],
|
||||
[0.5, -0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0], // top-left (red)
|
||||
[0.0, 1.0, 0.0, 1.0], // top-right (green)
|
||||
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
|
||||
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
|
||||
|
||||
self.window = Some(window);
|
||||
self.context = Some(context);
|
||||
@@ -106,14 +96,14 @@ impl ApplicationHandler for App {
|
||||
self.mesh = Some(mesh);
|
||||
}
|
||||
|
||||
/// À chaque frame, demande un redessin pour un rendu continu (animation).
|
||||
/// Each frame, requests a redraw for continuous rendering (animation).
|
||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch des événements de fenêtre : RedrawRequested rend puis présente, CloseRequested quitte.
|
||||
/// Window event dispatch: RedrawRequested renders then presents, CloseRequested exits.
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
@@ -126,16 +116,16 @@ impl ApplicationHandler for App {
|
||||
(&self.context, &self.renderer, &self.mesh, &self.material)
|
||||
{
|
||||
if let Some(frame) = Frame::try_new(&context.surface) {
|
||||
// 1. Rendu (plus d'arguments device/queue inutiles)
|
||||
// 1. Render (no more useless device/queue arguments)
|
||||
renderer.render(frame.view(), mesh, material);
|
||||
|
||||
// 2. Présentation
|
||||
// 2. Present
|
||||
renderer.present(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
winit::event::WindowEvent::CloseRequested => {
|
||||
event_loop.exit(); // C'est ici que tu demandes à la boucle de s'arrêter
|
||||
event_loop.exit(); // this is where you ask the loop to stop
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
@@ -143,10 +133,7 @@ impl ApplicationHandler for App {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"Répertoire courant : {:?}",
|
||||
std::env::current_dir().unwrap()
|
||||
);
|
||||
println!("Current directory: {:?}", std::env::current_dir().unwrap());
|
||||
let event_loop = EventLoop::new().unwrap();
|
||||
let mut app = App {
|
||||
window: None,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Dedicated test for **shadow mapping**.
|
||||
//!
|
||||
//! A single **directional** light is configured as the shadow caster
|
||||
//! (`Scene::set_shadow_caster(Some(0))`). The cube sits on a large thin ground
|
||||
//! slab, so its silhouette is projected as a crisp PCF-softened shadow. With a
|
||||
//! small ambient term the shadow is clearly visible and the light/shadow
|
||||
//! directions are easy to read:
|
||||
//!
|
||||
//! 1. the **blocker** (cube) casts a directional shadow that stretches along
|
||||
//! the ground opposite the light direction. The light sits at the camera's
|
||||
//! front-right and low-ish, so its shadow runs clearly across the ground to
|
||||
//! the left of the cube and is easy to see,
|
||||
//! 2. the shadow edge is **softened** by 3×3 PCF (no hard jagged border),
|
||||
//! 3. the lit faces are bright while the shadowed ground stays near-ambient,
|
||||
//! proving the depth comparison is applied per-pixel.
|
||||
//!
|
||||
//! Run with: `cargo run -p wsg-lib --example shadow_test`
|
||||
use glam::Vec3;
|
||||
use wsg_lib::resources::{Camera, Geometry};
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one
|
||||
/// shadow-casting directional light.
|
||||
struct ShadowTest;
|
||||
|
||||
/// Axis-aligned box geometry (24 vertices / 36 indices, per-face normals + uvs).
|
||||
fn box_geometry(hx: f32, hy: f32, hz: f32) -> Geometry {
|
||||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||||
(
|
||||
[0.0, 0.0, 1.0],
|
||||
[[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]],
|
||||
), // +Z
|
||||
(
|
||||
[0.0, 0.0, -1.0],
|
||||
[
|
||||
[hx, -hy, -hz],
|
||||
[-hx, -hy, -hz],
|
||||
[-hx, hy, -hz],
|
||||
[hx, hy, -hz],
|
||||
],
|
||||
), // -Z
|
||||
(
|
||||
[1.0, 0.0, 0.0],
|
||||
[[hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]],
|
||||
), // +X
|
||||
(
|
||||
[-1.0, 0.0, 0.0],
|
||||
[
|
||||
[-hx, -hy, hz],
|
||||
[-hx, hy, hz],
|
||||
[-hx, hy, -hz],
|
||||
[-hx, -hy, -hz],
|
||||
],
|
||||
), // -X
|
||||
(
|
||||
[0.0, 1.0, 0.0],
|
||||
[[-hx, hy, -hz], [hx, hy, -hz], [hx, hy, hz], [-hx, hy, hz]],
|
||||
), // +Y
|
||||
(
|
||||
[0.0, -1.0, 0.0],
|
||||
[
|
||||
[-hx, -hy, hz],
|
||||
[hx, -hy, hz],
|
||||
[hx, -hy, -hz],
|
||||
[-hx, -hy, -hz],
|
||||
],
|
||||
), // -Y
|
||||
];
|
||||
|
||||
let mut positions = Vec::with_capacity(24);
|
||||
let mut normals = Vec::with_capacity(24);
|
||||
let mut uvs = Vec::with_capacity(24);
|
||||
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
for (normal, corners) in faces {
|
||||
for (i, corner) in corners.iter().enumerate() {
|
||||
positions.push(*corner);
|
||||
normals.push(normal);
|
||||
uvs.push(quad_uvs[i]);
|
||||
}
|
||||
}
|
||||
let mut indices = Vec::with_capacity(36);
|
||||
for face in 0..6u16 {
|
||||
let b = face * 4;
|
||||
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
|
||||
}
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
impl wsg_lib::AppHandler for ShadowTest {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
app.scene.add_material_shader("mat", "standard").unwrap();
|
||||
|
||||
// Ground slab (thin, wide) lying with its top at y = 0.
|
||||
app.scene
|
||||
.create_mesh("ground_mesh", box_geometry(5.0, 0.05, 5.0), Some("mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.add_entity_with_transform(
|
||||
"ground",
|
||||
"ground_mesh",
|
||||
wsg_lib::core::Transform::identity(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Blocker cube centred at the origin, standing on the ground (bottom at y = 0).
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", box_geometry(0.5, 0.5, 0.5), Some("mat"))
|
||||
.unwrap();
|
||||
let mut cube_tf = wsg_lib::core::Transform::identity();
|
||||
cube_tf.translation = Vec3::new(0.0, 0.5, 0.0);
|
||||
app.scene
|
||||
.add_entity_with_transform("cube", "cube_mesh", cube_tf)
|
||||
.unwrap();
|
||||
|
||||
// One directional light only: replace the default list.
|
||||
app.scene.clear_lights();
|
||||
// Direction "from surface toward the light": the light sits up and to the +x side
|
||||
// (the camera's right), at a lowish elevation. Its shadow is then cast toward -x,
|
||||
// running clearly across the ground to the left of the cube. A steeper or more
|
||||
// frontal light would push the shadow tight against the cube's base or behind it,
|
||||
// where it is occluded by the cube from this elevated front-right view.
|
||||
let toward_light = Vec3::new(1.0, 0.5, 0.0).normalize();
|
||||
app.scene
|
||||
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.6)
|
||||
.unwrap();
|
||||
|
||||
// Make this directional light (packed index 0) the shadow caster.
|
||||
app.scene.set_shadow_caster(Some(0));
|
||||
|
||||
// Small ambient so the shadowed side of the ground stays readable.
|
||||
app.scene.set_ambient([0.12, 0.12, 0.14]);
|
||||
|
||||
// Slightly elevated view so both the cube and its ground shadow are framed.
|
||||
app.scene
|
||||
.set_camera(Camera::new(Vec3::new(3.4, 2.6, 3.4), Vec3::ZERO, Vec3::Y));
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = wsg_lib::app::AppBuilder::new()
|
||||
.title("WSG Shadow Test")
|
||||
.build()
|
||||
.await?;
|
||||
app.run(ShadowTest)
|
||||
}
|
||||
+35
-52
@@ -1,65 +1,48 @@
|
||||
//! Workflow déclaratif minimal, sans manipulation WGPU explicite dans ce fichier.
|
||||
//! `AppBuilder` crée l'event loop puis `App::run` ouvre la fenêtre, construit le `Context`/`Renderer`
|
||||
//! et fait tourner la boucle update → render → present. Depuis la migration winit 0.30, le GPU n'existe
|
||||
//! qu'après `resumed` : c'est pourquoi l'enregistrement shader + la création mesh/matériau/entité vivent
|
||||
//! dans le hook `AppHandler::setup`, appelé une fois le contexte prêt. La scène se rend automatiquement :
|
||||
//! la méthode `render()` par défaut appelle `app.render_scene(frame.view())`.
|
||||
use std::sync::Arc;
|
||||
//! Minimal declarative workflow, no explicit WGPU handling in this file.
|
||||
//! `AppBuilder` creates the event loop, then `App::run` opens the window, builds the `Context`/`Renderer`
|
||||
//! and drives the update → render → present loop. In winit 0.30 the GPU only exists
|
||||
//! after `resumed`: that is why shader registration + mesh/material/entity creation live in
|
||||
//! the `AppHandler::setup` hook, called once the context is ready. The PipelineCache
|
||||
//! lives in the scene (`Scene::init_gpu`, called in `resumed`): go through `register_shader` +
|
||||
//! `add_material_shader` + `create_mesh` + `add_entity`, the material being bound to the mesh. The mesh
|
||||
//! is declared from a **`Geometry`**: per-vertex positions + colors
|
||||
//! for the unlit quad. The scene renders automatically: the default `render()` method calls
|
||||
//! `app.render_scene(frame.view())`.
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::resources::{Material, Mesh, Vertex};
|
||||
use wsg_lib::resources::Geometry;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
struct MonQuad;
|
||||
|
||||
impl AppHandler for MonQuad {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
let format = app.renderer().format();
|
||||
|
||||
// Enregistrement du shader, création du matériau et du mesh du quad (sans importer wgpu).
|
||||
app.cache()
|
||||
.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)
|
||||
.unwrap();
|
||||
let vertices = [
|
||||
Vertex {
|
||||
position: [-0.5, 0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.0, 0.0],
|
||||
color: [1.0, 0.0, 0.0, 1.0],
|
||||
}, // Haut-Gauche (Rouge)
|
||||
Vertex {
|
||||
position: [0.5, 0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [1.0, 0.0],
|
||||
color: [0.0, 1.0, 0.0, 1.0],
|
||||
}, // Haut-Droite (Vert)
|
||||
Vertex {
|
||||
position: [0.5, -0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [1.0, 1.0],
|
||||
color: [0.0, 0.0, 1.0, 1.0],
|
||||
}, // Bas-Droite (Bleu)
|
||||
Vertex {
|
||||
position: [-0.5, -0.5, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.0, 1.0],
|
||||
color: [1.0, 1.0, 0.0, 1.0],
|
||||
}, // Bas-Gauche (Jaune)
|
||||
];
|
||||
let indices: [u16; 6] = [0, 1, 2, 0, 2, 3];
|
||||
|
||||
let mesh = Arc::new(Mesh::new(
|
||||
app.renderer().device(),
|
||||
&vertices,
|
||||
Some(&indices),
|
||||
));
|
||||
let material = Arc::new(Material::new(format, "basic", app.cache()));
|
||||
|
||||
app.scene.add_mesh("quad_mesh", mesh).unwrap();
|
||||
app.scene.add_material("basic_material", material).unwrap();
|
||||
// Flat 2D example: the `standard` shader in **unlit** mode (options.x = 1) returns the vertex
|
||||
// color as-is. Flat 2D is thus a special case of 3D — a single pipeline for all.
|
||||
app.renderer_mut().set_unlit(true);
|
||||
app.scene
|
||||
.add_entity("quad", "quad_mesh", "basic_material")
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
|
||||
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_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||
.with_colors(vec![
|
||||
[1.0, 0.0, 0.0, 1.0], // top-left (red)
|
||||
[0.0, 1.0, 0.0, 1.0], // top-right (green)
|
||||
[0.0, 0.0, 1.0, 1.0], // bottom-right (blue)
|
||||
[1.0, 1.0, 0.0, 1.0], // bottom-left (yellow)
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||
|
||||
// Default material: `None` lets the Scene inject its `standard` at render time
|
||||
// (`Scene::default_material`) — this exercises the default path.
|
||||
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
|
||||
app.scene.add_entity("quad", "quad_mesh").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Test dedicated to **spot lights**.
|
||||
//!
|
||||
//! In this example, **only** a spot light is on (the default directional light is
|
||||
//! removed via `clear_lights()`) and the ambient is deliberately **very low**. The cube therefore
|
||||
//! appears nearly black except where the spot's cone reaches it: you clearly see
|
||||
//!
|
||||
//! 1. a **directed beam** (not an omni halo like the point light),
|
||||
//! 2. a **smoothed edge** (penumbra) at the cone's limit,
|
||||
//! 3. the lighting that **follows the cube** as it rotates (the cone is fixed in world space).
|
||||
//!
|
||||
//! Run with: `cargo run -p wsg-lib --example spot_test`
|
||||
use glam::{Quat, Vec3};
|
||||
use wsg_lib::AppHandler;
|
||||
use wsg_lib::app::AppBuilder;
|
||||
use wsg_lib::mesh::cube;
|
||||
use wsg_lib::utils::WsgError;
|
||||
|
||||
/// Test handler: cube rotating slowly on two axes, lit **only** by a spot.
|
||||
struct SpotTest {
|
||||
angle_x: f32,
|
||||
angle_y: f32,
|
||||
}
|
||||
|
||||
impl AppHandler for SpotTest {
|
||||
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||
app.scene
|
||||
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||
.unwrap();
|
||||
app.scene.add_material_shader("mat", "standard").unwrap();
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(1.0), Some("mat"))
|
||||
.unwrap();
|
||||
app.scene.add_entity("cube", "cube_mesh").unwrap();
|
||||
|
||||
// Remove the default directional light to isolate the spot.
|
||||
app.scene.clear_lights();
|
||||
// Near-zero ambient: the cube is black outside the beam, the cone stands out.
|
||||
app.scene.set_ambient([0.03, 0.03, 0.03]);
|
||||
|
||||
// The spot is above/behind the camera, aimed at the origin (the cube).
|
||||
// World position (0, 2, 3), cone axis toward (0,0,0).
|
||||
let spot_pos = Vec3::new(0.0, 2.0, 3.0);
|
||||
let spot_dir = (Vec3::ZERO - spot_pos).normalize(); // points at the cube
|
||||
app.scene
|
||||
.add_spot_light(
|
||||
spot_pos,
|
||||
spot_dir,
|
||||
[1.0, 0.9, 0.6], // warm tint
|
||||
2.0, // intensity
|
||||
10.0, // attenuation radius (wide, the cube is at ~3.6)
|
||||
0.45, // half-angle (~26°) — wide enough to cover the cube
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Slow rotation on two axes (X and Y): the cone is fixed in world space,
|
||||
// so a fixed region of the cube stays lit while the cube rotates.
|
||||
// The two axes let you see the beam's effect on the 6 faces without
|
||||
// a favored orientation (a Y rotation alone would leave the +Y/-Y faces fixed).
|
||||
self.angle_x += 0.007;
|
||||
self.angle_y += 0.011;
|
||||
let base = *app
|
||||
.scene
|
||||
.entity_transform("cube")
|
||||
.expect("cube entity present");
|
||||
let mut transform = base;
|
||||
// Y * X composition: the X axis rotates in the frame already oriented by Y,
|
||||
// which gives a precession motion (all vertices pass in front of
|
||||
// the cone in turn).
|
||||
transform.rotation =
|
||||
Quat::from_rotation_y(self.angle_y) * Quat::from_rotation_x(self.angle_x);
|
||||
app.scene.set_entity_transform("cube", transform);
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Spot Test").build().await?;
|
||||
app.run(SpotTest {
|
||||
angle_x: 0.0,
|
||||
angle_y: 0.0,
|
||||
})
|
||||
}
|
||||
+7
-5
@@ -2,14 +2,16 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into seven modules:
|
||||
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules (plus the `shaders/` asset directory):
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration |
|
||||
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor) |
|
||||
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration (incl. the GPU-driven compute passes + opt-in frustum culling); also `InputState` (unified keyboard/mouse input, Step 15.B) |
|
||||
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box, multi-level LOD via packed vertex/index buffers), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`/`LodRow`/`LodTable`) |
|
||||
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
|
||||
| **scene** | Scene — resource depot and entity graph for declarative rendering setup |
|
||||
| **shaders** | Embedded WGSL sources (`standard`, `shadow`, `gpu_driven`) loaded via the `include_str!` fallback in `utils::conf` |
|
||||
| **scene** | Scene — resource depot and slot-based entity graph for declarative rendering setup (Step 17) |
|
||||
| **math** | Transform, Geometry (per-attribute mesh data + AABB, quadric edge collapse `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) |
|
||||
| **utils** | Configuration constants and WsgError type |
|
||||
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
|
||||
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
|
||||
@@ -18,7 +20,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
|
||||
|
||||
The library supports two workflows:
|
||||
|
||||
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/ARCHI_APP.md).
|
||||
- **Declarative (recommended)**: Use `Scene` to register resources and associate entities before the render loop begins. This is the "App facade" pattern described in [ARCHI_APP](../../docs/tech/ARCHI_APP.md).
|
||||
- **Manual**: Manipulate Context, Renderer, and PipelineCache directly for fine-grained control.
|
||||
|
||||
## Dependency Flow
|
||||
|
||||
+170
-30
@@ -8,8 +8,9 @@
|
||||
//! ## Interaction with Other Modules
|
||||
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
|
||||
//! - **core::renderer**: Delegates draw call execution to Renderer per frame.
|
||||
//! - **pipeline::pipeline_cache**: Holds PipelineCache instance for shader/pipeline management.
|
||||
//! - **scene::scene**: Exposes Scene as mutable field so users can register resources and entities.
|
||||
//! Since Step 7 the `PipelineCache` lives *inside* the Scene (via `Scene::init_gpu`), owned and
|
||||
//! used for material building there.
|
||||
//! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
|
||||
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
|
||||
//!
|
||||
@@ -22,8 +23,7 @@
|
||||
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
||||
|
||||
use crate::AppHandler;
|
||||
use crate::core::{Context, Renderer};
|
||||
use crate::pipeline::PipelineCache;
|
||||
use crate::core::{Context, InputState, Renderer, ShadowConfig, ToneMapper};
|
||||
use crate::scene::Scene;
|
||||
use crate::utils::WsgError;
|
||||
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
||||
@@ -38,19 +38,31 @@ use winit::window::{Window, WindowAttributes};
|
||||
/// Encapsulates all five WGPU objects (Instance, Surface, Adapter, Device, Queue) plus the render loop.
|
||||
/// Users create an App via AppBuilder, then run it with their implementation of AppHandler.
|
||||
///
|
||||
/// The GPU-facing fields (`context`, `renderer`, `window`, `cache`) are created lazily when the
|
||||
/// application is resumed (see `AppRunner`); they are only populated after `App::run` has started.
|
||||
/// Access them through the `context()`, `renderer()`, `window()` and `cache()` accessors, which is
|
||||
/// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
|
||||
/// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
|
||||
/// is not a field here: since Step 7 it lives in the `Scene`'s pipeline context (via `Scene::init_gpu`).
|
||||
/// Access GPU resources through the `context()`, `renderer()` and `window()` accessors, which are
|
||||
/// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
|
||||
pub struct App {
|
||||
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
|
||||
pub scene: Scene,
|
||||
/// Unified input state (keyboard/mouse/scroll, DRAFT Step 15). Fed by the winit window events
|
||||
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
|
||||
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
|
||||
pub input: InputState,
|
||||
/// Window title, read by the runner when the window is created in `resumed`.
|
||||
pub(crate) title: String,
|
||||
/// Window width, read by the runner when the window is created in `resumed`.
|
||||
pub(crate) width: u32,
|
||||
/// Window height, read by the runner when the window is created in `resumed`.
|
||||
pub(crate) height: u32,
|
||||
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
|
||||
pub(crate) culling: bool,
|
||||
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
|
||||
pub(crate) shadow_config: ShadowConfig,
|
||||
/// 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>,
|
||||
/// 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.
|
||||
@@ -59,8 +71,6 @@ pub struct App {
|
||||
renderer: Option<Renderer>,
|
||||
/// The OS-level window backing this application. Shared via Arc for multi-owner access.
|
||||
window: Option<Arc<Window>>,
|
||||
/// Shader compilation cache — manages RenderPipelines keyed by shader_id.
|
||||
cache: Option<PipelineCache>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -72,6 +82,16 @@ impl App {
|
||||
.expect("renderer not initialized yet — call app.run(handler) first")
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the GPU renderer.
|
||||
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
|
||||
/// Callers can configure the renderer here, e.g. `app.renderer_mut().set_unlit(true)` in `setup`
|
||||
/// to select flat 2D rendering (DRAFT Step 5).
|
||||
pub fn renderer_mut(&mut self) -> &mut Renderer {
|
||||
self.renderer
|
||||
.as_mut()
|
||||
.expect("renderer not initialized yet — call app.run(handler) first")
|
||||
}
|
||||
|
||||
/// Returns a reference to the GPU hardware context.
|
||||
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
|
||||
pub fn context(&self) -> &Context {
|
||||
@@ -80,14 +100,6 @@ impl App {
|
||||
.expect("context not initialized yet — call app.run(handler) first")
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the shader compilation cache.
|
||||
/// Panics if called before `App::run` has created the cache (i.e. before `resumed` fires).
|
||||
pub fn cache(&mut self) -> &mut PipelineCache {
|
||||
self.cache
|
||||
.as_mut()
|
||||
.expect("cache not initialized yet — call app.run(handler) first")
|
||||
}
|
||||
|
||||
/// Returns a reference to the window backing this application.
|
||||
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
|
||||
pub fn window(&self) -> &Window {
|
||||
@@ -107,12 +119,15 @@ impl App {
|
||||
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
|
||||
/// 6) on CloseRequested: exit the event loop.
|
||||
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
|
||||
// On extrait l'event_loop de manière sûre grâce au Option
|
||||
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris
|
||||
// Extract the event_loop safely via Option
|
||||
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
|
||||
let mut runner = AppRunner {
|
||||
title: self.title.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config.clone(),
|
||||
hdr: self.hdr,
|
||||
handler,
|
||||
app: None,
|
||||
};
|
||||
@@ -125,8 +140,39 @@ impl App {
|
||||
/// Called automatically each frame by the default `AppHandler::render`, or manually by users
|
||||
/// who override `render` to control drawing themselves.
|
||||
/// Inputs: view — the frame's texture view acting as the color attachment target.
|
||||
///
|
||||
/// The viewport aspect ratio (needed for the active camera's perspective projection, Step 4.3)
|
||||
/// is derived here from the window's current inner size, so the `Renderer` stays independent of
|
||||
/// the windowing backend.
|
||||
pub fn render_scene(&self, view: &wgpu::TextureView) {
|
||||
self.renderer().render_scene(view, &self.scene);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
|
||||
/// Reconfigures the surface via `Context::configure` (which returns the chosen format) and
|
||||
/// recreates the depth texture via `Renderer::resize_depth` so the color and depth attachments
|
||||
/// stay the same size. If the surface format changes (rare, deterministic per window), the
|
||||
/// Scene's GPU context is re-initialized to the new format; otherwise the swap alone suffices.
|
||||
/// Inputs: width/height — the new surface dimensions in pixels.
|
||||
/// Returns Ok(()) on success or a `WsgError` if the surface cannot be reconfigured.
|
||||
pub fn resize(&mut self, width: u32, height: u32) -> Result<(), WsgError> {
|
||||
let context = self.context.as_ref().ok_or(WsgError::SurfaceIncompatible)?;
|
||||
let old_format = self.renderer().format();
|
||||
let new_format = context.configure(&context.adapter, width, height)?;
|
||||
self.renderer_mut().resize_depth(width, height);
|
||||
self.renderer_mut().set_format(new_format);
|
||||
if new_format != old_format && self.hdr.is_none() {
|
||||
// Surface format changed (rare): re-wire the Scene's GPU context so its
|
||||
// PipelineCache/pipelines match the new surface format.
|
||||
// Étape 20: when HDR is active, the Scene uses Rgba16Float regardless of the
|
||||
// surface format, so no re-init is needed on surface format change.
|
||||
let device = std::sync::Arc::new(self.renderer_mut().device().clone());
|
||||
self.scene
|
||||
.init_gpu(device, self.context().queue.clone(), new_format);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +185,13 @@ pub struct AppBuilder {
|
||||
width: u32,
|
||||
/// Window height in pixels.
|
||||
height: u32,
|
||||
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
|
||||
culling: bool,
|
||||
/// Shadow mapping configuration (map size, biases, frustum). Defaults to sensible values.
|
||||
shadow_config: ShadowConfig,
|
||||
/// HDR / tone mapping (Étape 20). `None` = LDR direct (default); `Some(t)` activates
|
||||
/// the offscreen HDR texture + tone mapping pass.
|
||||
hdr: Option<ToneMapper>,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
@@ -149,6 +202,9 @@ impl AppBuilder {
|
||||
title: APP_DEFAULT_TITLE.to_string(),
|
||||
width: APP_DEFAULT_WIDTH,
|
||||
height: APP_DEFAULT_HEIGHT,
|
||||
culling: false,
|
||||
shadow_config: ShadowConfig::default(),
|
||||
hdr: None,
|
||||
}
|
||||
}
|
||||
/// Sets the window title to display in the OS taskbar/window decorations.
|
||||
@@ -164,6 +220,28 @@ impl AppBuilder {
|
||||
self.height = height;
|
||||
self
|
||||
}
|
||||
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
|
||||
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
|
||||
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
|
||||
/// every active entity visible, so the rendered image is identical to culling-off.
|
||||
pub fn with_culling(mut self, enabled: bool) -> Self {
|
||||
self.culling = enabled;
|
||||
self
|
||||
}
|
||||
/// Sets the shadow mapping configuration (map size, depth/slope bias, ortho frustum).
|
||||
/// Defaults to `ShadowConfig::default()` (1024² map, bias 0.002, slope 0.004, radius 5.0).
|
||||
pub fn with_shadow_config(mut self, config: ShadowConfig) -> Self {
|
||||
self.shadow_config = config;
|
||||
self
|
||||
}
|
||||
/// Enables HDR rendering with the given tone mapping curve (Étape 20). The main pass
|
||||
/// renders into an offscreen `Rgba16Float` texture, then a fullscreen tone mapping pass
|
||||
/// compresses the result to [0,1] and writes it to the sRGB surface. Without this call,
|
||||
/// the renderer draws directly to the surface (LDR, zero overhead).
|
||||
pub fn with_hdr(mut self, tonemapper: ToneMapper) -> Self {
|
||||
self.hdr = Some(tonemapper);
|
||||
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.
|
||||
@@ -173,14 +251,17 @@ impl AppBuilder {
|
||||
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
|
||||
Ok(App {
|
||||
scene: Scene::new(),
|
||||
input: InputState::new(),
|
||||
title: self.title,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config,
|
||||
hdr: self.hdr,
|
||||
event_loop: Some(event_loop),
|
||||
context: None,
|
||||
renderer: None,
|
||||
window: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -195,6 +276,12 @@ struct AppRunner<H: AppHandler> {
|
||||
width: u32,
|
||||
/// Window height in pixels, applied when the window is created in `resumed`.
|
||||
height: u32,
|
||||
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
|
||||
culling: bool,
|
||||
/// Shadow mapping configuration; passed to `Renderer::new` in `resumed`.
|
||||
shadow_config: ShadowConfig,
|
||||
/// HDR / tone mapping (Étape 20); passed to `Renderer::new` in `resumed`.
|
||||
hdr: Option<ToneMapper>,
|
||||
/// The user-provided game logic.
|
||||
handler: H,
|
||||
/// The fully-built App facade, populated on the first `resumed` event.
|
||||
@@ -221,27 +308,44 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
.expect("failed to create window"),
|
||||
);
|
||||
|
||||
// Initialization GPU (bloquant, simplifié au max)
|
||||
let context = pollster::block_on(Context::new(window.clone())).expect("Échec init GPU");
|
||||
// GPU initialization (blocking, kept as simple as possible)
|
||||
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
|
||||
let format = context
|
||||
.configure(&context.adapter, self.width, self.height)
|
||||
.expect("Échec configuration surface");
|
||||
.expect("surface configuration failed");
|
||||
let device = Arc::new(context.device.clone());
|
||||
let cache = PipelineCache::new(device);
|
||||
let renderer = Renderer::new(&context, format);
|
||||
let renderer =
|
||||
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr);
|
||||
// Step 15, D8: apply the culling flag (off by default — non-regression).
|
||||
renderer.set_culling(self.culling);
|
||||
|
||||
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
|
||||
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
|
||||
// Étape 20: when HDR is active, the main pass targets Rgba16Float (not the surface format),
|
||||
// so the Scene's pipelines must be compiled for that format.
|
||||
let main_format = if self.hdr.is_some() {
|
||||
wgpu::TextureFormat::Rgba16Float
|
||||
} else {
|
||||
format
|
||||
};
|
||||
let mut scene = Scene::new();
|
||||
scene.init_gpu(device, context.queue.clone(), main_format);
|
||||
|
||||
let mut app = App {
|
||||
scene: Scene::new(),
|
||||
scene,
|
||||
input: InputState::new(),
|
||||
title: self.title.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
shadow_config: self.shadow_config.clone(),
|
||||
hdr: self.hdr,
|
||||
event_loop: None,
|
||||
context: Some(context),
|
||||
renderer: Some(renderer),
|
||||
window: Some(window),
|
||||
cache: Some(cache),
|
||||
};
|
||||
// On laisse l'utilisateur enregistrer shaders/meshes/matériaux/entités une fois le GPU prêt.
|
||||
// Let the user register shaders/meshes/materials/entities once the GPU is ready.
|
||||
self.handler.setup(&mut app);
|
||||
self.app = Some(app);
|
||||
}
|
||||
@@ -252,7 +356,23 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
let Some(app) = self.app.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Poll the device each frame: wgpu only fires async callbacks (queue.on_submitted_work_done,
|
||||
// buffer/texture map_async) when the device is polled, and the event loop never does it on
|
||||
// our behalf. `Wait` with no timeout = block until the most recent submission completes
|
||||
// (i.e. once per frame on a live GPU, which is what we want for the windowed loop).
|
||||
// A failed poll (e.g. a device-lost error) is logged, not fatal: the next frame's poll
|
||||
// will retry, and wgpu surfaces the loss through the device's error handler anyway.
|
||||
if let Err(e) = app.context().device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
}) {
|
||||
eprintln!("WSG: device.poll() failed ({e:?})");
|
||||
}
|
||||
// Step 15 (input): start the input frame (rotate pressed/released + reset deltas),
|
||||
// run the user logic, then close (clear the transient states).
|
||||
app.input.begin_frame();
|
||||
self.handler.update(app);
|
||||
app.input.end_frame();
|
||||
app.window().request_redraw();
|
||||
}
|
||||
|
||||
@@ -267,14 +387,34 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
let Some(app) = self.app.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
|
||||
app.input.handle_window_event(&event);
|
||||
match event {
|
||||
WindowEvent::Resized(size) => {
|
||||
// Guard (D3): minimizing the window sends Resized(0x0); never reconfigure at 0.
|
||||
let w = size.width as u32;
|
||||
let h = size.height as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
// Step 11: reconfigure surface + depth to the new size, then re-render.
|
||||
if let Err(e) = app.resize(w, h) {
|
||||
eprintln!("WSG: resize error ({e:?})");
|
||||
}
|
||||
app.window().request_redraw();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
// Guard (D6): do not render on a zero-sized surface (minimized window).
|
||||
let size = app.window().inner_size();
|
||||
if size.width == 0 || size.height == 0 {
|
||||
return;
|
||||
}
|
||||
// Rendering logic
|
||||
let frame = app.context().get_next_frame();
|
||||
|
||||
// On appelle le render() de l'utilisateur (reçoit la frame courante)
|
||||
// Call the user's render() (receives the current frame)
|
||||
self.handler.render(app, &frame);
|
||||
// On présente automatiquement
|
||||
// Present automatically
|
||||
app.renderer().present(frame);
|
||||
}
|
||||
WindowEvent::CloseRequested => {
|
||||
|
||||
@@ -9,6 +9,7 @@ The `core` module contains two architectural layers that drive rendering:
|
||||
| **context** | **Manager layer** — owns GPU hardware resource lifecycle (Instance, Surface, Adapter, Device, Queue). Initializes GPU at startup; orchestrates frame-by-frame rendering via begin_frame() / end_frame(). Does not own rendering logic. |
|
||||
| **renderer** | **Executor layer** — owns Device/Queue references after initialization from Context. Orchestrates draw calls by binding Material pipelines and Mesh vertex data into a RenderPass. Does not own raw hardware resources externally or RenderPipelines/shaders. |
|
||||
| **frame** | Per-frame RAII wrapper around the surface texture and its TextureView. Exists only for the duration of a single rendering pass. |
|
||||
| **input** | `InputState` (Step 15.B) — unified cross-frame keyboard/mouse state (pressed/held/released, mouse delta, wheel scroll). Rotated by `begin_frame`/`end_frame` around `AppHandler::update`; exposed by `App` as a public `input` field. |
|
||||
|
||||
## Interaction with Other Modules
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
//! - **error**: returns WsgError variants from all fallible methods.
|
||||
//!
|
||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
||||
//! - **Phase de Déclaration**: Context is created once at application startup before the render loop begins.
|
||||
//! - **Declaration Phase**: Context is created once at application startup before the render loop begins.
|
||||
//! This follows the declarative workflow where all GPU state is configured upfront.
|
||||
//! - **Injection Async**: Context::new() is async because the runtime must be injected at creation time.
|
||||
//! - **Accès Bas-Niveau**: Advanced users can bypass the Scene facade and manipulate Context directly
|
||||
//! - **Low-Level Access**: Advanced users can bypass the Scene facade and manipulate Context directly
|
||||
//! through App.renderer(), App.context(), etc., for fine-grained control over wgpu handles.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! Frame::try_new() returns `Option<Self>` for graceful recovery.
|
||||
//!
|
||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
||||
//! - **Phase d'Exécution**: Frame is acquired at the start of each render loop iteration and released after rendering.
|
||||
//! - **Execution Phase**: Frame is acquired at the start of each render loop iteration and released after rendering.
|
||||
//! - **Ergonomie**: Users interact with frames through Renderer::render() + Renderer::present(), not directly with wgpu handles.
|
||||
|
||||
/// A per-frame RAII wrapper around the surface texture and its `TextureView`.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! # Frustum Module
|
||||
//!
|
||||
//! 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`]).
|
||||
//!
|
||||
//! 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
|
||||
//! view-projection matrix `M` (world to clip space), whose NDC conventions are x, y in `[-1, 1]` and
|
||||
//! z in `[0, 1]`:
|
||||
//!
|
||||
//! | plane | clip-space inequality | row combination |
|
||||
//! |--------|-----------------------|-----------------|
|
||||
//! | left | cx + cw >= 0 | w + x |
|
||||
//! | right | -cx + cw >= 0 | w - x |
|
||||
//! | bottom | cy + cw >= 0 | w + y |
|
||||
//! | top | -cy + cw >= 0 | w - y |
|
||||
//! | near | cz >= 0 | z |
|
||||
//! | far | -cz + cw >= 0 | w - z |
|
||||
//!
|
||||
//! (For the `[0, 1]` z range the near plane is the z row alone — `cz >= 0` — whereas the classic
|
||||
//! `[-1, 1]` Gribb-Hartmann uses `w + z`. The far plane `w - z` is the same in both.)
|
||||
//! Each plane is normalized to a unit normal so the signed-distance test is scale-invariant.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
/// A view-projection frustum represented by its six bounding planes.
|
||||
///
|
||||
/// Each plane is a `[f32; 4]` `(normal, d)`: a world point `p` is inside when
|
||||
/// `dot(p, normal) + d >= 0`. Built from a view-projection matrix via
|
||||
/// [`Frustum::from_view_proj`] and uploaded to the GPU culling compute shader (Phase 3).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Frustum {
|
||||
/// The six frustum planes, order: `[left, right, bottom, top, near, far]`.
|
||||
pub planes: [[f32; 4]; 6],
|
||||
}
|
||||
|
||||
impl Frustum {
|
||||
/// Extracts the six frustum planes from a view-projection matrix (Gribb-Hartmann, adapted to
|
||||
/// WebGPU's `[0, 1]` clip-space z). Inputs: m — the `projection * view` matrix (world to clip
|
||||
/// space). Returns the frustum with unit-length plane normals.
|
||||
pub fn from_view_proj(m: &Mat4) -> Self {
|
||||
// glam stores Mat4 by column; transpose so `.x_axis`/`.y_axis`/... are the rows of M,
|
||||
// i.e. the clip-space basis vectors the Gribb-Hartmann method combines.
|
||||
let mt = m.transpose();
|
||||
let r0 = mt.x_axis; // row 0 of M -> clip x
|
||||
let r1 = mt.y_axis; // row 1 of M -> clip y
|
||||
let r2 = mt.z_axis; // row 2 of M -> clip z
|
||||
let r3 = mt.w_axis; // row 3 of M -> clip w
|
||||
let raw: [Vec4; 6] = [
|
||||
r3 + r0, // left
|
||||
r3 - r0, // right
|
||||
r3 + r1, // bottom
|
||||
r3 - r1, // top
|
||||
r2, // near
|
||||
r3 - r2, // far
|
||||
];
|
||||
let planes = raw.map(|p| {
|
||||
let n = Vec3::new(p.x, p.y, p.z);
|
||||
let len = n.length();
|
||||
if len > 1e-8 {
|
||||
let nn = n / len;
|
||||
[nn.x, nn.y, nn.z, p.w / len]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
}
|
||||
});
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Tests whether a world-space point lies inside the frustum (inside every plane).
|
||||
/// Inputs: p — a world-space point. Returns true if it satisfies all six plane inequalities.
|
||||
pub fn contains_point(&self, p: Vec3) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane[0] * p.x + plane[1] * p.y + plane[2] * p.z + plane[3] >= 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resources::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.
|
||||
fn vp(d: f32) -> Mat4 {
|
||||
let cam = Camera::new(Vec3::new(0.0, 0.0, d), Vec3::ZERO, Vec3::Y).with_perspective(
|
||||
45.0_f32.to_radians(),
|
||||
0.1,
|
||||
100.0,
|
||||
);
|
||||
cam.projection_matrix(1.0) * cam.view_matrix()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_inside_when_camera_looks_at_it() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
assert!(
|
||||
fr.contains_point(Vec3::new(0.0, 0.0, 0.0)),
|
||||
"the look-at target must be inside the frustum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_camera_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// Camera at z = 10 looks toward -z; a point at z = 50 is behind it.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(0.0, 0.0, 50.0)),
|
||||
"a point behind the camera must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_to_the_side_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// Far off to the side, well outside the 45-degree field of view.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(1000.0, 0.0, 0.0)),
|
||||
"a point far to the side must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beyond_far_plane_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// z = -500 is 510 units in front of the camera (at z = 10), beyond far = 100.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(0.0, 0.0, -500.0)),
|
||||
"a point beyond the far plane must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes_are_unit_length() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
for plane in fr.planes {
|
||||
let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
||||
assert!(
|
||||
(len - 1.0).abs() < 1e-3,
|
||||
"plane normal must be unit length, got {len}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reproduces the `demo` example's exact camera + entity layout and confirms the GPU cull
|
||||
/// pass would NOT cull any of them (they sit at radius 1.7 around the origin, in front of the
|
||||
/// 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;
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.yaw = 0.6;
|
||||
ctrl.pitch = 0.35;
|
||||
ctrl.distance = 6.5;
|
||||
ctrl.target = Vec3::ZERO;
|
||||
let mut cam = Camera::default();
|
||||
ctrl.apply_to(&mut cam);
|
||||
let vp = cam.projection_matrix(1.0) * cam.view_matrix();
|
||||
let fr = Frustum::from_view_proj(&vp);
|
||||
// Ground plane center (origin).
|
||||
assert!(
|
||||
fr.contains_point(Vec3::ZERO),
|
||||
"origin (ground center) must be inside"
|
||||
);
|
||||
// The six primitives, placed by demo::place at radius 1.7, y = 0.5.
|
||||
for i in 0..6 {
|
||||
let a = i as f32 / 6.0 * std::f32::consts::TAU;
|
||||
let p = Vec3::new(a.cos() * 1.7, 0.5, a.sin() * 1.7);
|
||||
assert!(
|
||||
fr.contains_point(p),
|
||||
"primitive {i} at {} must be inside the frustum",
|
||||
p
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
//! # HDR / Tone Mapping Configuration (Étape 20)
|
||||
//!
|
||||
//! Defines the `ToneMapper` enum (selects the tone mapping curve) and provides the
|
||||
//! configuration passed to the `Renderer` when HDR is enabled. The HDR pipeline
|
||||
//! (offscreen `Rgba16Float` texture + fullscreen tone mapping pass) is **opt-in**:
|
||||
//! without it, the renderer draws directly to the sRGB surface (zero overhead).
|
||||
|
||||
/// Selects the tone mapping curve applied by the HDR pass.
|
||||
///
|
||||
/// The choice is compiled into the pipeline at construction time (one entry point per
|
||||
/// variant) — there is no runtime branching cost.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToneMapper {
|
||||
/// ACES Filmic (Narkowicz 2015 approximation). Cinematic contrast, used in AAA
|
||||
/// games and film pipelines. Softly compresses highlights while preserving
|
||||
/// midtone contrast.
|
||||
Aces,
|
||||
/// Reinhard: `x / (1 + x)`. Simple, flat response. Less contrast than ACES but
|
||||
/// computationally trivial.
|
||||
Reinhard,
|
||||
}
|
||||
|
||||
impl ToneMapper {
|
||||
/// Returns the WGSL entry point name for this tone mapper variant.
|
||||
pub(crate) fn entry_point(&self) -> &'static str {
|
||||
match self {
|
||||
ToneMapper::Aces => "fs_aces",
|
||||
ToneMapper::Reinhard => "fs_reinhard",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label (for debug output).
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ToneMapper::Aces => "ACES",
|
||||
ToneMapper::Reinhard => "Reinhard",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToneMapper {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! # Input Module — Unified Input State (Step 15, ROADMAP 2.3)
|
||||
//!
|
||||
//! **Unified** input state (keyboard / mouse / wheel) with cross-frame
|
||||
//! **pressed / held / released** semantics, fed by **winit** events (`WindowEvent`), on the **CPU
|
||||
//! (Rust)** side — WGSL (the GPU shader language) has no I/O. This module is embodied in `App::input`
|
||||
//! and driven by the loop: `begin_frame()` before `AppHandler::update`, `end_frame()` after.
|
||||
//!
|
||||
//! ## Conventions
|
||||
//! - **Keyboard**: identified by `KeyCode` (physical key, independent of the AZERTY/QWERTY layout:
|
||||
//! the Z key on AZERTY is `KeyCode::KeyW`). `pressed`/`released` are valid for a single frame,
|
||||
//! `held` stays true as long as the key is held down.
|
||||
//! - **Mouse**: absolute position (pixels), per-frame delta (derived from `CursorMoved`, i.e. relative
|
||||
//! movement — suitable for a drag-orbit camera), buttons
|
||||
//! `pressed`/`held`/`released`, wheel (`scroll`), `y > 0` = wheel upward.
|
||||
//! - **Gamepad**: reserved for a future minimal v1 (DRAFT D7); the API is ready to accept
|
||||
//! a `GamepadState` without breaking existing code (the final example only needs keyboard + mouse).
|
||||
//!
|
||||
//! ## Query examples (in `AppHandler::update`)
|
||||
//! ```
|
||||
//! # use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
//! # fn demo(input: &wsg_lib::core::input::InputState) {
|
||||
//! if input.key_held(KeyCode::KeyW) { /* move forward */ }
|
||||
//! if input.key_pressed(KeyCode::Space) { /* jump */ }
|
||||
//! let (dx, dy) = input.mouse_delta();
|
||||
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbit */ }
|
||||
//! let (_, zoom) = input.scroll_delta();
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use std::collections::HashSet;
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
|
||||
/// Unified input state, aggregate of the keyboard, mouse and wheel groups. It is **refreshed every
|
||||
/// frame** by `App` via `begin_frame`/`end_frame`, and read by the user in
|
||||
/// `AppHandler::update` via `app.input`.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct InputState {
|
||||
// ---- Keyboard ----
|
||||
/// Physically held-down keys as of now (persists across frames).
|
||||
held: HashSet<KeyCode>,
|
||||
/// Keys pressed during the current frame (valid for a single frame).
|
||||
pressed: HashSet<KeyCode>,
|
||||
/// Keys released during the current frame (valid for a single frame).
|
||||
released: HashSet<KeyCode>,
|
||||
/// `pressed` accumulator between two `begin_frame` calls (consumed on rotation).
|
||||
frame_pressed: HashSet<KeyCode>,
|
||||
/// `released` accumulator between two `begin_frame` calls.
|
||||
frame_released: HashSet<KeyCode>,
|
||||
|
||||
// ---- Mouse ----
|
||||
/// Absolute cursor position in pixels (last received).
|
||||
mouse_position: (f32, f32),
|
||||
/// Previous absolute position, to derive the `CursorMoved` delta.
|
||||
last_mouse_position: Option<(f32, f32)>,
|
||||
/// Frame accumulator for the relative movement (events between two `begin_frame` calls),
|
||||
/// rotated into `mouse_delta` at the next `begin_frame` (same pattern as the keyboard).
|
||||
frame_mouse_delta: (f32, f32),
|
||||
/// Cumulative relative movement during the current frame (queryable in `update`).
|
||||
mouse_delta: (f32, f32),
|
||||
/// Buttons currently held down.
|
||||
held_buttons: HashSet<MouseButton>,
|
||||
/// Buttons pressed during the current frame.
|
||||
pressed_buttons: HashSet<MouseButton>,
|
||||
/// Buttons released during the current frame.
|
||||
released_buttons: HashSet<MouseButton>,
|
||||
/// Button accumulators between two `begin_frame` calls.
|
||||
frame_pressed_buttons: HashSet<MouseButton>,
|
||||
frame_released_buttons: HashSet<MouseButton>,
|
||||
|
||||
// ---- Wheel ----
|
||||
/// Frame accumulator for the scroll (x, y) (events between two `begin_frame` calls),
|
||||
/// rotated into `scroll` at the next `begin_frame`.
|
||||
frame_scroll: (f32, f32),
|
||||
/// Cumulative scroll during the current frame (x, y) (queryable in `update`).
|
||||
scroll: (f32, f32),
|
||||
// ---- Gamepad (reserved) ----
|
||||
// (DRAFT D7: optional minimal v1, deferred — the API will extend without breakage.)
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Creates a fresh `InputState` (all states empty). Equivalent to `Default`.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Consumes a winit window event and updates the internal state (accumulators). Irrelevant
|
||||
/// events are ignored. Rotation into the queryable sets (`pressed`/`released`) happens at the
|
||||
/// next `begin_frame`.
|
||||
pub fn handle_window_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event: ke, .. } => {
|
||||
let PhysicalKey::Code(code) = ke.physical_key else {
|
||||
return; // non-character keys (e.g. system keys) ignored
|
||||
};
|
||||
self.key_input(code, ke.state);
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => self.mouse_button(*button, *state),
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor_move(position.x as f32, position.y as f32);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } => match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => self.wheel(*x, *y),
|
||||
// PixelDelta (most Wayland compositors) reports raw pixels — one wheel notch is
|
||||
// typically ~32 px, so normalize to line (notch) units to keep `scroll_delta()`
|
||||
// in the same scale as LineDelta backends (X11). Without this, `zoom()` would
|
||||
// apply `factor^100` per notch and snap to the clamp in a single wheel step.
|
||||
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32 / 32.0, p.y as f32 / 32.0),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a raw keyboard event (physical key + state), called by
|
||||
/// [`InputState::handle_window_event`]. Split out so it can be tested without building a `KeyEvent`.
|
||||
fn key_input(&mut self, code: KeyCode, state: ElementState) {
|
||||
match state {
|
||||
ElementState::Pressed => {
|
||||
self.held.insert(code);
|
||||
self.frame_pressed.insert(code);
|
||||
}
|
||||
ElementState::Released => {
|
||||
self.held.remove(&code);
|
||||
self.frame_released.insert(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a raw mouse-button event, called by [`InputState::handle_window_event`].
|
||||
fn mouse_button(&mut self, button: MouseButton, state: ElementState) {
|
||||
match state {
|
||||
ElementState::Pressed => {
|
||||
self.held_buttons.insert(button);
|
||||
self.frame_pressed_buttons.insert(button);
|
||||
}
|
||||
ElementState::Released => {
|
||||
self.held_buttons.remove(&button);
|
||||
self.frame_released_buttons.insert(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the cursor position and accumulates the relative movement in the frame buffer. Called
|
||||
/// by [`InputState::handle_window_event`]; the frame buffer is rotated into the queryable
|
||||
/// `mouse_delta` at the next [`InputState::begin_frame`].
|
||||
fn cursor_move(&mut self, x: f32, y: f32) {
|
||||
if let Some((px, py)) = self.last_mouse_position {
|
||||
self.frame_mouse_delta.0 += x - px;
|
||||
self.frame_mouse_delta.1 += y - py;
|
||||
}
|
||||
self.last_mouse_position = Some((x, y));
|
||||
self.mouse_position = (x, y);
|
||||
}
|
||||
|
||||
/// Accumulates the wheel scroll in the frame buffer (in **line/notch units** — `handle_window_event`
|
||||
/// normalizes `PixelDelta` by /32 before calling this). The frame buffer is rotated into the
|
||||
/// queryable `scroll` at the next [`InputState::begin_frame`].
|
||||
fn wheel(&mut self, dx: f32, dy: f32) {
|
||||
self.frame_scroll.0 += dx;
|
||||
self.frame_scroll.1 += dy;
|
||||
}
|
||||
|
||||
/// Starts a new input frame: **rotates** all the event accumulators (keyboard pressed/released,
|
||||
/// buttons, mouse delta and wheel — accumulated between two `begin_frame` calls) into the
|
||||
/// queryable state. Call this **before** `AppHandler::update`.
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.pressed = std::mem::take(&mut self.frame_pressed);
|
||||
self.released = std::mem::take(&mut self.frame_released);
|
||||
self.pressed_buttons = std::mem::take(&mut self.frame_pressed_buttons);
|
||||
self.released_buttons = std::mem::take(&mut self.frame_released_buttons);
|
||||
self.mouse_delta = self.frame_mouse_delta;
|
||||
self.frame_mouse_delta = (0.0, 0.0);
|
||||
self.scroll = self.frame_scroll;
|
||||
self.frame_scroll = (0.0, 0.0);
|
||||
}
|
||||
|
||||
/// Ends a frame: clears the transient state consumed by `update` (`pressed`/`released`, button
|
||||
/// sets, queryable mouse delta and wheel). The `held` states and the cursor position are kept.
|
||||
/// Call this **after** `AppHandler::update` (or `render`).
|
||||
pub fn end_frame(&mut self) {
|
||||
self.pressed.clear();
|
||||
self.released.clear();
|
||||
self.pressed_buttons.clear();
|
||||
self.released_buttons.clear();
|
||||
self.mouse_delta = (0.0, 0.0);
|
||||
self.scroll = (0.0, 0.0);
|
||||
}
|
||||
|
||||
// ---- Keyboard queries ----
|
||||
|
||||
/// True if `code` was **pressed** during the current frame (a single frame only).
|
||||
pub fn key_pressed(&self, code: KeyCode) -> bool {
|
||||
self.pressed.contains(&code)
|
||||
}
|
||||
/// True if `code` is **held** down (persists across frames).
|
||||
pub fn key_held(&self, code: KeyCode) -> bool {
|
||||
self.held.contains(&code)
|
||||
}
|
||||
/// True if `code` was **released** during the current frame (a single frame only).
|
||||
pub fn key_released(&self, code: KeyCode) -> bool {
|
||||
self.released.contains(&code)
|
||||
}
|
||||
|
||||
// ---- Mouse queries ----
|
||||
|
||||
/// Absolute cursor position in pixels (last position received).
|
||||
pub fn mouse_position(&self) -> (f32, f32) {
|
||||
self.mouse_position
|
||||
}
|
||||
/// Cumulative relative mouse movement during the current frame.
|
||||
pub fn mouse_delta(&self) -> (f32, f32) {
|
||||
self.mouse_delta
|
||||
}
|
||||
/// Cumulative wheel scroll during the current frame, in **line (notch) units**
|
||||
/// (`(dx, dy)`, `dy > 0` = wheel up). `PixelDelta` events are normalized by /32 so the scale
|
||||
/// is backend-independent (one physical wheel notch ≈ 1.0).
|
||||
pub fn scroll_delta(&self) -> (f32, f32) {
|
||||
self.scroll
|
||||
}
|
||||
/// True if `button` was **pressed** during the current frame (a single frame only).
|
||||
pub fn mouse_button_pressed(&self, button: MouseButton) -> bool {
|
||||
self.pressed_buttons.contains(&button)
|
||||
}
|
||||
/// True if `button` is **held** down (persists across frames).
|
||||
pub fn mouse_button_held(&self, button: MouseButton) -> bool {
|
||||
self.held_buttons.contains(&button)
|
||||
}
|
||||
/// True if `button` was **released** during the current frame (a single frame only).
|
||||
pub fn mouse_button_released(&self, button: MouseButton) -> bool {
|
||||
self.released_buttons.contains(&button)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[test]
|
||||
fn keyboard_pressed_held_released_lifecycle() {
|
||||
let mut input = InputState::new();
|
||||
input.key_input(KeyCode::KeyW, ElementState::Pressed);
|
||||
input.begin_frame();
|
||||
assert!(input.key_pressed(KeyCode::KeyW));
|
||||
assert!(input.key_held(KeyCode::KeyW));
|
||||
assert!(!input.key_released(KeyCode::KeyW));
|
||||
input.end_frame();
|
||||
|
||||
// Next frame without a new event: no longer "pressed", still "held".
|
||||
input.begin_frame();
|
||||
assert!(!input.key_pressed(KeyCode::KeyW));
|
||||
assert!(input.key_held(KeyCode::KeyW));
|
||||
input.end_frame();
|
||||
|
||||
// Release.
|
||||
input.key_input(KeyCode::KeyW, ElementState::Released);
|
||||
input.begin_frame();
|
||||
assert!(input.key_released(KeyCode::KeyW));
|
||||
assert!(!input.key_held(KeyCode::KeyW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_buttons_lifecycle() {
|
||||
let mut input = InputState::new();
|
||||
input.mouse_button(MouseButton::Left, ElementState::Pressed);
|
||||
input.begin_frame();
|
||||
assert!(input.mouse_button_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_button_held(MouseButton::Left));
|
||||
input.end_frame();
|
||||
input.begin_frame();
|
||||
assert!(!input.mouse_button_pressed(MouseButton::Left));
|
||||
assert!(input.mouse_button_held(MouseButton::Left));
|
||||
input.mouse_button(MouseButton::Left, ElementState::Released);
|
||||
input.end_frame();
|
||||
input.begin_frame();
|
||||
assert!(input.mouse_button_released(MouseButton::Left));
|
||||
assert!(!input.mouse_button_held(MouseButton::Left));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_delta_and_position_accumulate() {
|
||||
// Real winit order: events arrive BETWEEN two frames, then `begin_frame` rotates the
|
||||
// accumulator into the queryable delta (a `begin_frame` before the events would not lose
|
||||
// them, the queryable copy is separate from the frame buffer).
|
||||
let mut input = InputState::new();
|
||||
input.begin_frame(); // frame 1 starts (empty)
|
||||
input.cursor_move(10.0, 20.0);
|
||||
input.cursor_move(30.0, 40.0);
|
||||
input.end_frame();
|
||||
|
||||
// Frame 2 starts: the movement accumulated during frame 1 is rotated into the queryable
|
||||
// delta and read by `update`.
|
||||
input.begin_frame();
|
||||
assert_eq!(input.mouse_delta(), (20.0, 20.0));
|
||||
assert_eq!(input.mouse_position(), (30.0, 40.0));
|
||||
input.end_frame();
|
||||
|
||||
// Frame 3 without new events: the queryable delta is back to zero, the position persists.
|
||||
input.begin_frame();
|
||||
assert_eq!(input.mouse_delta(), (0.0, 0.0));
|
||||
assert_eq!(input.mouse_position(), (30.0, 40.0));
|
||||
|
||||
// Frame 4: a small movement accumulates and is rotated in again.
|
||||
input.cursor_move(31.0, 42.0);
|
||||
input.begin_frame();
|
||||
assert_eq!(input.mouse_delta(), (1.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_accumulates_per_frame() {
|
||||
// Real winit order: wheel events accumulate between frames, `begin_frame` rotates them.
|
||||
let mut input = InputState::new();
|
||||
input.wheel(1.0, 2.0);
|
||||
input.wheel(0.5, -1.0);
|
||||
input.begin_frame();
|
||||
assert_eq!(input.scroll_delta(), (1.5, 1.0));
|
||||
input.end_frame();
|
||||
|
||||
// Next frame without new scroll: the queryable delta is zero.
|
||||
input.begin_frame();
|
||||
assert_eq!(input.scroll_delta(), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_empty() {
|
||||
let input = InputState::default();
|
||||
assert!(!input.key_held(KeyCode::KeyW));
|
||||
assert!(!input.mouse_button_held(MouseButton::Left));
|
||||
assert_eq!(input.mouse_delta(), (0.0, 0.0));
|
||||
assert_eq!(input.scroll_delta(), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! # LOD — Per-frame Level Selection (pure, testable without a GPU)
|
||||
//!
|
||||
//! The pure functions behind the LOD feature (Step 19, D1/D4/D8). Each frame the **CPU**
|
||||
//! decides which detail level every entity draws; these functions do that math:
|
||||
//!
|
||||
//! - [`projected_radius_px`]: the entity's *perceived size* — its bounding-sphere radius in
|
||||
//! screen pixels (the **same sphere** the GPU frustum culling uses, D8);
|
||||
//! - [`lod_level`]: the level decision with **asymmetric hysteresis** (D4) — the core
|
||||
//! anti-flicker mechanism.
|
||||
//!
|
||||
//! Both are pure (no GPU, no state beyond the caller-supplied `last` level) → unit-testable.
|
||||
//! The Renderer calls them per slot each frame and uploads the resulting levels to the GPU,
|
||||
//! which only maps level → draw args (the packed-buffer offsets live in the per-mesh LOD
|
||||
//! table — see `resources::uniform::LodTable`).
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
/// Projected radius (in **pixels**) of a bounding sphere, given the camera's view/projection.
|
||||
///
|
||||
/// The sphere center (world space) is transformed into view space; a sphere at depth `d` with
|
||||
/// radius `r` subtends `r / d` in view space, which the projection's vertical scale
|
||||
/// (`proj.y.y = 1 / tan(fov / 2)`) maps to NDC — multiplied by `height_px / 2` (half the
|
||||
/// viewport height in pixels) gives pixels.
|
||||
///
|
||||
/// A sphere whose center is inside/behind the near plane (`depth <= 1e-4`) returns
|
||||
/// `f32::INFINITY` — the entity dominates the screen, so the finest level (0) is chosen.
|
||||
pub fn projected_radius_px(
|
||||
center_world: Vec3,
|
||||
radius: f32,
|
||||
view: Mat4,
|
||||
proj: Mat4,
|
||||
height_px: f32,
|
||||
) -> f32 {
|
||||
let v = view * Vec4::new(center_world.x, center_world.y, center_world.z, 1.0);
|
||||
let depth = -v.z; // view space: the camera looks along -Z (glam `look_at_mat4`)
|
||||
if depth <= 1e-4 {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
(radius / depth) * proj.y_axis.y * (height_px * 0.5)
|
||||
}
|
||||
|
||||
/// Level decision with **asymmetric hysteresis** (Step 19, D4).
|
||||
///
|
||||
/// `thresholds` is a **descending** pixel radius: `thresholds[k]` is the radius *above which*
|
||||
/// level k+1 is required (i.e. level k is sufficient up to that bound; level 0 has no bound).
|
||||
/// Levels beyond the threshold count share the last bound (clamped) — e.g. with `[48, 12]`
|
||||
/// only the first three levels are distinct.
|
||||
///
|
||||
/// Hysteresis (dead band):
|
||||
/// - to a **finer** level: immediate, as soon as `radius_px` exceeds the current level's bound;
|
||||
/// - to a **coarser** level: only if `radius_px <= bound(k) * 0.8` (20 % dead band), stepped
|
||||
/// incrementally (each intermediate bound × 0.8 must hold).
|
||||
///
|
||||
/// The "detail loss" pop (going coarser) is therefore delayed; the "detail regain" pop (going
|
||||
/// finer) is immediate — standard engine practice. `f32::INFINITY` (object at the camera)
|
||||
/// always returns 0. The result is always within `0..=max_level`.
|
||||
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32 {
|
||||
if radius_px.is_infinite() || max_level == 0 || thresholds.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Bound for level k+1: the k-th threshold, clamped for levels beyond the threshold count.
|
||||
let bound = |k: u32| thresholds[(k as usize).min(thresholds.len() - 1)];
|
||||
let last = (last as usize).min(max_level as usize) as u32;
|
||||
|
||||
// Target without hysteresis: the coarsest level whose bound is still satisfied.
|
||||
let mut target = 0u32;
|
||||
let mut k = 0u32;
|
||||
while k < max_level {
|
||||
if radius_px <= bound(k) {
|
||||
target = k + 1;
|
||||
k += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if target <= last {
|
||||
// Finer or equal: immediate (no dead band on the way to more detail).
|
||||
target
|
||||
} else {
|
||||
// Coarser: 20 % dead band per step, incremental.
|
||||
let mut lvl = last;
|
||||
while lvl < target {
|
||||
if radius_px <= bound(lvl) * 0.8 {
|
||||
lvl += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
lvl
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::Mat4;
|
||||
use glam::Vec3;
|
||||
|
||||
/// A camera at `(0, 0, dist)` looking at the origin, up `+Y`, with vertical `fov`.
|
||||
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
|
||||
let view =
|
||||
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
|
||||
let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0);
|
||||
(view, proj)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// projected_radius_px
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn projected_radius_analytic() {
|
||||
// Sphere of radius 1 at the origin; camera 5 units away; fov = 90°
|
||||
// (proj vertical scale = 1/tan(45°) = 1); viewport 1000 px tall.
|
||||
// Expected: (1 / 5) * 1 * 500 = 100 px.
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::ZERO, 1.0, view, proj, 1000.0);
|
||||
assert!((r - 100.0).abs() < 1e-3, "expected 100 px, got {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_scale_invariance() {
|
||||
// 10x bigger object 10x further away → same projected radius (similarity).
|
||||
let (view1, proj1) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let (view2, proj2) = camera(50.0, std::f32::consts::PI / 2.0);
|
||||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, view1, proj1, 1000.0);
|
||||
let r2 = projected_radius_px(Vec3::ZERO, 10.0, view2, proj2, 1000.0);
|
||||
assert!((r1 - r2).abs() < 1e-2, "expected equal, got {r1} vs {r2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_at_camera_is_infinite() {
|
||||
// Center at the camera position → depth 0 → INFINITY (finest level).
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 5.0), 1.0, view, proj, 1000.0);
|
||||
assert!(r.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_behind_camera_is_infinite() {
|
||||
// Center behind the camera → negative depth → INFINITY.
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 20.0), 1.0, view, proj, 1000.0);
|
||||
assert!(r.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_narrower_fov_larger_pixels() {
|
||||
// Narrower FOV (zoomed in) → LARGER vertical projection scale (1/tan(fov/2)) →
|
||||
// more pixels for the same sphere at the same distance.
|
||||
let fov_narrow = std::f32::consts::PI / 3.0; // 60°
|
||||
let fov_wide = std::f32::consts::PI / 2.0; // 90°
|
||||
let (v1, p1) = camera(5.0, fov_narrow);
|
||||
let (v2, p2) = camera(5.0, fov_wide);
|
||||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, v1, p1, 1000.0);
|
||||
let r2 = projected_radius_px(Vec3::ZERO, 1.0, v2, p2, 1000.0);
|
||||
assert!(
|
||||
r1 > r2,
|
||||
"narrower FOV should give more pixels: {r1} vs {r2}"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// lod_level
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn lod_level_simple_thresholds() {
|
||||
let t = [48.0f32, 12.0];
|
||||
// r > 48 → level 0 (too big for any coarser level).
|
||||
assert_eq!(lod_level(100.0, 0, 2, &t), 0);
|
||||
assert_eq!(lod_level(52.0, 0, 2, &t), 0);
|
||||
// 48 >= r > 38.4 (0.8·48): target is L1, but the dead band holds it at L0.
|
||||
assert_eq!(lod_level(44.0, 0, 2, &t), 0);
|
||||
// r <= 38.4 → L1.
|
||||
assert_eq!(lod_level(38.4, 0, 2, &t), 1);
|
||||
assert_eq!(lod_level(30.0, 0, 2, &t), 1);
|
||||
// 12 > r > 9.6 (0.8·12): target L2, dead band holds at L1.
|
||||
assert_eq!(lod_level(10.0, 0, 2, &t), 1);
|
||||
// r <= 9.6 → L2 (both steps pass the band).
|
||||
assert_eq!(lod_level(9.6, 0, 2, &t), 2);
|
||||
assert_eq!(lod_level(9.0, 0, 2, &t), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_finer_is_immediate() {
|
||||
let t = [48.0f32, 12.0];
|
||||
// Already coarse (L2); radius grows past 48 → immediately back to L0.
|
||||
assert_eq!(lod_level(100.0, 2, 2, &t), 0);
|
||||
// L2, radius between the bounds → immediately to L1.
|
||||
assert_eq!(lod_level(30.0, 2, 2, &t), 1);
|
||||
// L1, radius past 48 → immediately to L0.
|
||||
assert_eq!(lod_level(52.0, 1, 2, &t), 0);
|
||||
// L1, radius below 12 → target L2 but dead band (10 > 9.6) holds at L1.
|
||||
assert_eq!(lod_level(10.0, 1, 2, &t), 1);
|
||||
// L1, radius below 9.6 → L2.
|
||||
assert_eq!(lod_level(9.0, 1, 2, &t), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_oscillation_is_stable() {
|
||||
// Anti-flicker (D4): a radius oscillating ±10 % around threshold 48 (43.2..52.8)
|
||||
// must not make the level flip back and forth.
|
||||
let t = [48.0f32];
|
||||
let mut level = 0u32;
|
||||
for _ in 0..100 {
|
||||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||||
level = lod_level(r, level, 2, &t);
|
||||
}
|
||||
}
|
||||
// Whatever level it settled on, it must not have changed on the last pass.
|
||||
let before = level;
|
||||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||||
level = lod_level(r, level, 2, &t);
|
||||
}
|
||||
assert_eq!(before, level, "level flickered around the threshold");
|
||||
// From L0 the oscillation never leaves L0 (coarser needs r ≤ 38.4).
|
||||
assert_eq!(lod_level(43.2, 0, 2, &t), 0);
|
||||
assert_eq!(lod_level(52.8, 0, 2, &t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_clamped_thresholds_for_extra_levels() {
|
||||
// 4 levels but only 2 thresholds: levels 2 and 3 share the last bound (12).
|
||||
let t = [48.0f32, 12.0];
|
||||
// r = 9 passes both bands (38.4, 9.6) AND the clamped third bound (0.8·12) → L3.
|
||||
assert_eq!(lod_level(9.0, 0, 3, &t), 3);
|
||||
// r = 10 passes the first two targets but the clamped band holds at L2.
|
||||
assert_eq!(lod_level(10.0, 0, 3, &t), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_infinite_returns_zero() {
|
||||
let t = [48.0f32, 12.0];
|
||||
assert_eq!(lod_level(f32::INFINITY, 2, 2, &t), 0);
|
||||
assert_eq!(lod_level(f32::INFINITY, 0, 2, &t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_degenerate_inputs() {
|
||||
let t = [48.0f32];
|
||||
assert_eq!(lod_level(1.0, 5, 0, &t), 0); // max_level 0
|
||||
assert_eq!(lod_level(1.0, 0, 2, &[]), 0); // no thresholds
|
||||
// Stale `last` beyond max_level is clamped, not a panic.
|
||||
assert_eq!(lod_level(100.0, 9, 2, &t), 0);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,23 @@
|
||||
|
||||
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 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;
|
||||
pub use transform::Transform;
|
||||
|
||||
+1590
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
//! Shadow mapping configuration.
|
||||
//!
|
||||
//! Users of the WSG library can tune shadow quality/behavior without modifying the library
|
||||
//! source. All fields have sensible defaults (see [`ShadowConfig::default`]); pass a custom
|
||||
//! config via [`AppBuilder::with_shadow_config`](crate::app::AppBuilder::with_shadow_config).
|
||||
|
||||
use crate::utils::conf::{
|
||||
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
|
||||
SHADOW_SLOPE_BIAS,
|
||||
};
|
||||
|
||||
/// Configuration for the shadow mapping system.
|
||||
///
|
||||
/// Controls the shadow map resolution, depth bias (anti-acne), and the orthographic frustum
|
||||
/// that frames the scene from the shadow-casting light's point of view.
|
||||
///
|
||||
/// # Usage
|
||||
/// ```ignore
|
||||
/// use wsg_lib::core::ShadowConfig;
|
||||
///
|
||||
/// let app = AppBuilder::new()
|
||||
/// .with_shadow_config(ShadowConfig {
|
||||
/// map_size: 2048, // higher resolution → sharper shadows
|
||||
/// depth_bias: 0.002, // constant bias (NDC depth units)
|
||||
/// slope_bias: 0.006, // slope-scaled bias coefficient
|
||||
/// scene_center: [0.0, 0.0, 0.0], // where to center the ortho frustum
|
||||
/// scene_radius: 8.0, // half-extent of the ortho frustum (world units)
|
||||
/// ..Default::default()
|
||||
/// })
|
||||
/// .build()
|
||||
/// .await?;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShadowConfig {
|
||||
/// Shadow map resolution in pixels per side (square map). Higher = sharper shadows,
|
||||
/// more VRAM. Defaults to 1024.
|
||||
pub map_size: u32,
|
||||
/// Constant depth bias subtracted from the reference depth before the shadow comparison.
|
||||
/// This is the *minimum* bias; the slope-scaled term adds more for grazing angles.
|
||||
/// Defaults to 0.002.
|
||||
pub depth_bias: f32,
|
||||
/// Slope-scaled bias coefficient. The effective bias is
|
||||
/// `max(depth_bias, slope_bias * (1.0 - |dot(N, L)|))` — it grows as the surface normal
|
||||
/// becomes perpendicular to the light direction, where shadow acne is worst.
|
||||
/// Defaults to 0.004.
|
||||
pub slope_bias: f32,
|
||||
/// World-space center of the orthographic shadow frustum. The frustum is oriented along
|
||||
/// the shadow light's direction and centered on this point. Defaults to `[0.0, 0.0, 0.0]`.
|
||||
pub scene_center: [f32; 3],
|
||||
/// Half-extent (world units) of the orthographic shadow frustum. Must be large enough to
|
||||
/// encompass all shadow-casting and receiving geometry. Defaults to 5.0.
|
||||
pub scene_radius: f32,
|
||||
}
|
||||
|
||||
impl Default for ShadowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map_size: SHADOW_MAP_SIZE,
|
||||
depth_bias: SHADOW_DEPTH_BIAS,
|
||||
slope_bias: SHADOW_SLOPE_BIAS,
|
||||
scene_center: SHADOW_SCENE_CENTER,
|
||||
scene_radius: SHADOW_SCENE_RADIUS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! # Transform Module
|
||||
//!
|
||||
//! Defines the `Transform` struct for representing object transformations in 3D space,
|
||||
//! including translation, rotation, and scale. Also provides functionality to convert
|
||||
//! the transform into a 4x4 matrix for use in shaders.
|
||||
//!
|
||||
//! ## Usage
|
||||
//! - Used by `Scene` entities to define their position in the world
|
||||
//! - Converted to `Mat4` for MVP matrix calculations in the renderer
|
||||
//!
|
||||
//! ## Related Types
|
||||
//! - `Transform`: Core struct for position/rotation/scale
|
||||
//! - `to_matrix()`: Converts transform to a 4x4 matrix
|
||||
|
||||
use glam::{Mat4, Quat, Vec3};
|
||||
|
||||
/// Represents a 3D transformation with translation, rotation, and scale.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Transform {
|
||||
/// Translation vector in 3D space
|
||||
pub translation: Vec3,
|
||||
/// Rotation as a quaternion
|
||||
pub rotation: Quat,
|
||||
/// Scale factors along X, Y, Z axes
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// Creates a new identity transform.
|
||||
pub fn identity() -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the transform into a 4x4 transformation matrix.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Mat4` representing the transformation matrix
|
||||
pub fn to_matrix(&self) -> Mat4 {
|
||||
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vec_close(a: Vec3, b: Vec3) -> bool {
|
||||
(a - b).length() < 1e-5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_transform_is_identity_matrix() {
|
||||
assert_eq!(Transform::identity().to_matrix(), Mat4::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_only() {
|
||||
let t = Transform {
|
||||
translation: Vec3::new(1.0, 2.0, 3.0),
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
assert_eq!(
|
||||
t.to_matrix(),
|
||||
Mat4::from_translation(Vec3::new(1.0, 2.0, 3.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_only() {
|
||||
let t = Transform {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::new(2.0, 3.0, 4.0),
|
||||
};
|
||||
assert_eq!(t.to_matrix(), Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_turn_around_y() {
|
||||
let t = Transform {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::from_axis_angle(Vec3::Y, std::f32::consts::FRAC_PI_2),
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
let v = t.to_matrix().transform_point3(Vec3::X);
|
||||
assert!(vec_close(v, Vec3::new(0.0, 0.0, -1.0)), "got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_trs_moves_a_point() {
|
||||
let t = Transform {
|
||||
translation: Vec3::new(10.0, 0.0, 0.0),
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::new(2.0, 2.0, 2.0),
|
||||
};
|
||||
let v = t.to_matrix().transform_point3(Vec3::new(1.0, 0.0, 0.0));
|
||||
assert!(vec_close(v, Vec3::new(12.0, 0.0, 0.0)), "got {v}");
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -26,8 +26,7 @@ use crate::core::Frame;
|
||||
pub trait AppHandler {
|
||||
/// Called once by `App::run`, right after the window/GPU context are created (winit `resumed`).
|
||||
/// Use it to register shaders, build Meshes/Materials, and populate `app.scene` before the loop
|
||||
/// starts. This replaces the pre-`run` setup that was possible before the winit 0.30 migration.
|
||||
/// Default implementation does nothing.
|
||||
/// starts. Default implementation does nothing.
|
||||
/// Inputs: app — mutable reference to the fully-initialized App facade.
|
||||
fn setup(&mut self, _app: &mut App) {}
|
||||
/// Called once per frame before rendering begins. Used for physics updates, input processing,
|
||||
|
||||
+24
-6
@@ -1,9 +1,9 @@
|
||||
//! # WSG Library Crate Root
|
||||
//!
|
||||
//! The top-level entry point for the wsg-lib crate. Exposes seven public modules organized by architectural responsibility:
|
||||
//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor layers),
|
||||
//! **resources** (data types), **pipeline** (shader compilation cache), **scene** (resource graph and entity management),
|
||||
//! and **utils** (configuration and error handling).
|
||||
//! The top-level entry point for the wsg-lib crate. Exposes eight public modules organized by architectural responsibility:
|
||||
//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor + geometry types),
|
||||
//! **mesh** (geometry sources: primitives + import), **resources** (data types), **pipeline** (shader compilation cache),
|
||||
//! **scene** (resource graph and entity management), **prelude** (glob re-exports), and **utils** (configuration and error handling).
|
||||
//!
|
||||
//! ## Module Interaction Map
|
||||
//! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`.
|
||||
@@ -22,7 +22,7 @@
|
||||
//! ```ignore
|
||||
//! use wsg_lib::core::{Context, Renderer};
|
||||
//! use wsg_lib::resources::{Mesh, Material, Vertex};
|
||||
//! use wsg_lib::utils::BASIC_SHADER;
|
||||
//! use wsg_lib::utils::STANDARD_SHADER;
|
||||
//! ```
|
||||
|
||||
// Warn if a public API item has no rustdoc comment, keeping API coverage at 100%.
|
||||
@@ -31,8 +31,9 @@
|
||||
pub mod app;
|
||||
pub mod core;
|
||||
pub mod handler;
|
||||
pub mod math;
|
||||
pub mod mesh;
|
||||
pub mod pipeline;
|
||||
pub mod prelude;
|
||||
pub mod resources;
|
||||
pub mod scene;
|
||||
pub mod utils;
|
||||
@@ -44,3 +45,20 @@ pub use crate::app::App;
|
||||
/// Re-export of the user-defined game logic interface for convenient top-level access.
|
||||
/// Users implement this trait to define update/render callbacks injected into the render loop.
|
||||
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::ShadowConfig;
|
||||
|
||||
/// Re-export of the tone mapping curve selector for convenient top-level access.
|
||||
/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`.
|
||||
pub use crate::core::ToneMapper;
|
||||
|
||||
/// Re-export of the geometry data type (positions, normals, UVs, indices).
|
||||
pub use crate::core::Geometry;
|
||||
|
||||
/// Re-export of the per-entity transform (position + rotation + scale).
|
||||
pub use crate::core::Transform;
|
||||
|
||||
/// Re-export of the axis-aligned bounding box.
|
||||
pub use crate::core::BBox;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! # Geometry Module
|
||||
//!
|
||||
//! Defines the `Geometry` struct for storing vertex data of 3D meshes.
|
||||
//! This module handles the core geometric representation used by meshes.
|
||||
//!
|
||||
//! ## Usage
|
||||
//! - Stores vertex attributes (positions, normals, UVs)
|
||||
//! - Used by `Mesh` to define its vertex data
|
||||
//! - Passed to shaders for rendering
|
||||
//!
|
||||
//! ## Related Types
|
||||
//! - `Geometry`: Main struct for vertex data storage
|
||||
//! - Fields: positions, normals, uvs, indices
|
||||
|
||||
/// Represents the geometric data of a 3D mesh.
|
||||
///
|
||||
/// This struct stores the core vertex attributes that define a mesh's shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Geometry {
|
||||
/// Vertex positions as an array of 3D coordinates
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Optional vertex normals for lighting calculations
|
||||
pub normals: Option<Vec<[f32; 3]>>,
|
||||
/// Optional texture coordinates for UV mapping
|
||||
pub uvs: Option<Vec<[f32; 2]>>,
|
||||
/// Optional indices for indexed rendering
|
||||
pub indices: Option<Vec<u16>>,
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//! # Math Module — Geometric and Transformation Utilities
|
||||
//!
|
||||
//! Provides core mathematical types and utilities for 3D graphics operations, including:
|
||||
//! - `Transform` for object positioning, rotation, and scaling
|
||||
//! - `Camera` for view and projection matrix calculations
|
||||
//! - `Geometry` for mesh vertex data representation
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `scene::Scene` uses `Transform` to manage entity positions
|
||||
//! - `renderer::Renderer` uses `Transform` and `Camera` to compute matrices for shaders
|
||||
//! - `resources::Mesh` stores vertex data in `Geometry` format
|
||||
//!
|
||||
//! ## Files
|
||||
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
|
||||
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
|
||||
//! - `camera.rs`: Defines the `Camera` struct and view/projection matrix calculations
|
||||
|
||||
pub mod geometry;
|
||||
pub mod transform;
|
||||
|
||||
// Re-exports
|
||||
pub use geometry::Geometry;
|
||||
pub use transform::Transform;
|
||||
@@ -1,45 +0,0 @@
|
||||
//! # Transform Module
|
||||
//!
|
||||
//! Defines the `Transform` struct for representing object transformations in 3D space,
|
||||
//! including translation, rotation, and scale. Also provides functionality to convert
|
||||
//! the transform into a 4x4 matrix for use in shaders.
|
||||
//!
|
||||
//! ## Usage
|
||||
//! - Used by `Scene` entities to define their position in the world
|
||||
//! - Converted to `Mat4` for MVP matrix calculations in the renderer
|
||||
//!
|
||||
//! ## Related Types
|
||||
//! - `Transform`: Core struct for position/rotation/scale
|
||||
//! - `to_matrix()`: Converts transform to a 4x4 matrix
|
||||
|
||||
use glam::{Mat4, Quat, Vec3};
|
||||
|
||||
/// Represents a 3D transformation with translation, rotation, and scale.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Transform {
|
||||
/// Translation vector in 3D space
|
||||
pub translation: Vec3,
|
||||
/// Rotation as a quaternion
|
||||
pub rotation: Quat,
|
||||
/// Scale factors along X, Y, Z axes
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// Creates a new identity transform.
|
||||
pub fn identity() -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the transform into a 4x4 transformation matrix.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Mat4` representing the transformation matrix
|
||||
pub fn to_matrix(&self) -> Mat4 {
|
||||
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! glTF 2.0 / GLB loader.
|
||||
//!
|
||||
//! **Status: stub** — the full implementation requires the `gltf` crate and will
|
||||
//! be added in a follow-up. For now, this module compiles (behind `feature = "import-gltf"`)
|
||||
//! and returns a clear error.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use crate::mesh::import::MeshImportError;
|
||||
use std::path::Path;
|
||||
|
||||
/// Loads a glTF 2.0 (.gltf JSON) or GLB (.glb binary) file.
|
||||
///
|
||||
/// # Errors
|
||||
/// Always returns [`MeshImportError::Unsupported`] for now (implementation pending).
|
||||
pub fn load_gltf(path: impl AsRef<Path>) -> Result<Vec<Geometry>, MeshImportError> {
|
||||
let _ = path;
|
||||
Err(MeshImportError::Unsupported(
|
||||
"glTF import is not yet implemented (pending gltf crate wrapper)".into(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! File import loaders — each behind a feature flag.
|
||||
//!
|
||||
//! | Feature | Function | Format |
|
||||
//!|---------|----------|--------|
|
||||
//!| `import-obj` | `load_obj(path)` | Wavefront OBJ |
|
||||
//!| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB |
|
||||
|
||||
#[cfg(feature = "import-obj")]
|
||||
pub mod obj;
|
||||
#[cfg(feature = "import-gltf")]
|
||||
#[path = "gltf.rs"]
|
||||
pub mod gltf_loader;
|
||||
|
||||
#[cfg(feature = "import-obj")]
|
||||
pub use obj::{load_obj, parse_obj};
|
||||
#[cfg(feature = "import-gltf")]
|
||||
pub use gltf_loader::load_gltf;
|
||||
|
||||
/// Error type for mesh file import.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum MeshImportError {
|
||||
/// The file could not be read (I/O error).
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The file content is malformed or cannot be parsed.
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
/// The file uses features not supported by this loader.
|
||||
#[error("unsupported format: {0}")]
|
||||
Unsupported(String),
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Wavefront OBJ parser — minimal, dependency-free.
|
||||
//!
|
||||
//! Supports: `v` (position), `vn` (normal), `vt` (UV), `f` (face, 3-4 verts).
|
||||
//! Quads are split into triangles via fan triangulation.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use crate::mesh::import::MeshImportError;
|
||||
use std::path::Path;
|
||||
|
||||
/// Parses a Wavefront OBJ file and returns a single [`Geometry`].
|
||||
///
|
||||
/// Supported directives: `v`, `vn`, `vt`, `f` (3 or 4 vertices per face).
|
||||
/// Vertex references in `f` use 1-based indices.
|
||||
/// If no `vn` lines are present, normals are computed (area-weighted face normals).
|
||||
/// If no `vt` lines are present, UVs are omitted.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`MeshImportError::Io`] if the file cannot be read,
|
||||
/// or [`MeshImportError::Parse`] on malformed input.
|
||||
pub fn load_obj(path: impl AsRef<Path>) -> Result<Geometry, MeshImportError> {
|
||||
let content = std::fs::read_to_string(path).map_err(MeshImportError::Io)?;
|
||||
parse_obj(&content)
|
||||
}
|
||||
|
||||
/// Parses OBJ content from a string. See [`load_obj`] for supported features.
|
||||
pub fn parse_obj(content: &str) -> Result<Geometry, MeshImportError> {
|
||||
let mut positions: Vec<[f32; 3]> = Vec::new();
|
||||
let mut file_normals: Vec<[f32; 3]> = Vec::new();
|
||||
let mut file_uvs: Vec<[f32; 2]> = Vec::new();
|
||||
|
||||
// Unique vertex table: (pos_idx, opt_uv_idx, opt_norm_idx)
|
||||
let mut vert_table: Vec<(usize, Option<usize>, Option<usize>)> = Vec::new();
|
||||
let mut indices: Vec<u16> = Vec::new();
|
||||
|
||||
for (line_num, raw) in content.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
match parts[0] {
|
||||
"v" => {
|
||||
if parts.len() < 4 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 'v' needs 3+ components",
|
||||
line_num + 1
|
||||
)));
|
||||
}
|
||||
let x = parts[1].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'v' x = '{}'", line_num + 1, parts[1]))
|
||||
})?;
|
||||
let y = parts[2].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'v' y = '{}'", line_num + 1, parts[2]))
|
||||
})?;
|
||||
let z = parts[3].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'v' z = '{}'", line_num + 1, parts[3]))
|
||||
})?;
|
||||
positions.push([x, y, z]);
|
||||
}
|
||||
"vn" => {
|
||||
if parts.len() < 4 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 'vn' needs 3+ components",
|
||||
line_num + 1
|
||||
)));
|
||||
}
|
||||
let x = parts[1].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'vn' x", line_num + 1))
|
||||
})?;
|
||||
let y = parts[2].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'vn' y", line_num + 1))
|
||||
})?;
|
||||
let z = parts[3].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'vn' z", line_num + 1))
|
||||
})?;
|
||||
file_normals.push([x, y, z]);
|
||||
}
|
||||
"vt" => {
|
||||
if parts.len() < 3 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 'vt' needs 2+ components",
|
||||
line_num + 1
|
||||
)));
|
||||
}
|
||||
let u = parts[1].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'vt' u", line_num + 1))
|
||||
})?;
|
||||
let v = parts[2].parse::<f32>().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad 'vt' v", line_num + 1))
|
||||
})?;
|
||||
file_uvs.push([u, v]);
|
||||
}
|
||||
"f" => {
|
||||
if parts.len() < 4 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 'f' needs 3+ vertices, got {}",
|
||||
line_num + 1,
|
||||
parts.len() - 1
|
||||
)));
|
||||
}
|
||||
// Parse vertex references: "idx" or "idx/uv" or "idx/uv/norm"
|
||||
let face_verts: Vec<(usize, Option<usize>, Option<usize>)> = parts[1..]
|
||||
.iter()
|
||||
.map(|tok| {
|
||||
let mut fields = tok.split('/');
|
||||
let idx_str = fields.next().unwrap_or("0");
|
||||
let uv_str = fields.next();
|
||||
let norm_str = fields.next();
|
||||
|
||||
let idx: usize = idx_str.parse().map_err(|_| {
|
||||
MeshImportError::Parse(format!(
|
||||
"line {}: bad face vertex index '{}'",
|
||||
line_num + 1,
|
||||
tok
|
||||
))
|
||||
})?;
|
||||
if idx == 0 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 0-based index not allowed in face",
|
||||
line_num + 1
|
||||
)));
|
||||
}
|
||||
let uv_idx = parse_opt_idx(uv_str, line_num, "uv")?;
|
||||
let norm_idx = parse_opt_idx(norm_str, line_num, "norm")?;
|
||||
Ok((idx - 1, uv_idx, norm_idx))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
// Map to unique vertex indices (dedup by pos+uv+norm tuple)
|
||||
let mapped: Vec<u16> = face_verts
|
||||
.iter()
|
||||
.map(|&(pi, uvi, ni)| {
|
||||
// Check if this combo already exists
|
||||
if let Some(pos) = vert_table.iter().position(|&(ep, eu, en)| {
|
||||
ep == pi && eu == uvi && en == ni
|
||||
}) {
|
||||
pos as u16
|
||||
} else {
|
||||
vert_table.push((pi, uvi, ni));
|
||||
(vert_table.len() - 1) as u16
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fan triangulation
|
||||
if mapped.len() == 3 {
|
||||
indices.extend_from_slice(&mapped);
|
||||
} else if mapped.len() > 3 {
|
||||
for i in 1..mapped.len() - 1 {
|
||||
indices.extend_from_slice(&[mapped[0], mapped[i], mapped[i + 1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore unknown directives
|
||||
}
|
||||
}
|
||||
|
||||
if positions.is_empty() {
|
||||
return Err(MeshImportError::Parse("no vertices found".into()));
|
||||
}
|
||||
if vert_table.is_empty() {
|
||||
return Err(MeshImportError::Parse("no faces found".into()));
|
||||
}
|
||||
|
||||
// Build output vertex arrays from the table
|
||||
let mut out_positions = Vec::with_capacity(vert_table.len());
|
||||
let mut out_normals = Vec::with_capacity(vert_table.len());
|
||||
let mut out_uvs = Vec::with_capacity(vert_table.len());
|
||||
let mut has_any_uv = false;
|
||||
|
||||
for &(pi, uvi, ni) in &vert_table {
|
||||
out_positions.push(positions[pi]);
|
||||
if let Some(ni) = ni {
|
||||
out_normals.push(file_normals[ni]);
|
||||
} else {
|
||||
out_normals.push([0.0, 0.0, 0.0]);
|
||||
}
|
||||
if let Some(uvi) = uvi {
|
||||
out_uvs.push(file_uvs[uvi]);
|
||||
has_any_uv = true;
|
||||
} else {
|
||||
out_uvs.push([0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute normals if file had none
|
||||
if file_normals.is_empty() {
|
||||
compute_normals(&out_positions, &indices, &mut out_normals);
|
||||
}
|
||||
|
||||
let mut geo = Geometry::new(out_positions)
|
||||
.with_normals(out_normals)
|
||||
.with_indices(indices);
|
||||
if has_any_uv {
|
||||
geo = geo.with_uvs(out_uvs);
|
||||
}
|
||||
|
||||
geo.validate()
|
||||
.map_err(|e| MeshImportError::Parse(format!("validation failed: {e}")))?;
|
||||
Ok(geo)
|
||||
}
|
||||
|
||||
fn parse_opt_idx(
|
||||
field: Option<&str>,
|
||||
line_num: usize,
|
||||
what: &str,
|
||||
) -> Result<Option<usize>, MeshImportError> {
|
||||
match field {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) => {
|
||||
let idx: usize = s.parse().map_err(|_| {
|
||||
MeshImportError::Parse(format!("line {}: bad {what} index '{s}'", line_num + 1))
|
||||
})?;
|
||||
if idx == 0 {
|
||||
return Err(MeshImportError::Parse(format!(
|
||||
"line {}: 0-based {what} index",
|
||||
line_num + 1
|
||||
)));
|
||||
}
|
||||
Ok(Some(idx - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes area-weighted vertex normals from triangle faces.
|
||||
fn compute_normals(positions: &[[f32; 3]], indices: &[u16], normals: &mut [[f32; 3]]) {
|
||||
use glam::Vec3;
|
||||
for n in normals.iter_mut() {
|
||||
*n = [0.0, 0.0, 0.0];
|
||||
}
|
||||
for tri in indices.chunks(3) {
|
||||
if tri.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
let pa = Vec3::from_array(positions[a]);
|
||||
let pb = Vec3::from_array(positions[b]);
|
||||
let pc = Vec3::from_array(positions[c]);
|
||||
let fn_ = (pb - pa).cross(pc - pa);
|
||||
for idx in [a, b, c] {
|
||||
let n = &mut normals[idx];
|
||||
n[0] += fn_.x;
|
||||
n[1] += fn_.y;
|
||||
n[2] += fn_.z;
|
||||
}
|
||||
}
|
||||
for n in normals.iter_mut() {
|
||||
let v = Vec3::from_array(*n);
|
||||
*n = v.normalize().to_array();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_triangle() {
|
||||
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
|
||||
let geo = parse_obj(content).unwrap();
|
||||
assert_eq!(geo.positions.len(), 3);
|
||||
assert_eq!(geo.indices.as_ref().unwrap().len(), 3);
|
||||
geo.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_quad_splits_to_two_tris() {
|
||||
let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n";
|
||||
let geo = parse_obj(content).unwrap();
|
||||
assert_eq!(geo.positions.len(), 4);
|
||||
assert_eq!(geo.indices.as_ref().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_normals_and_uvs() {
|
||||
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nvt 0 0\nvt 1 0\nvt 0 1\nf 1/1/1 2/2/1 3/3/1\n";
|
||||
let geo = parse_obj(content).unwrap();
|
||||
assert!(geo.normals.is_some());
|
||||
assert!(geo.uvs.is_some());
|
||||
let n = geo.normals.as_ref().unwrap();
|
||||
assert_eq!(n[0], [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_normals_computes_them() {
|
||||
let content = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
|
||||
let geo = parse_obj(content).unwrap();
|
||||
let n = geo.normals.as_ref().unwrap();
|
||||
assert!((n[0][2] - 1.0).abs() < 1e-4, "expected +Z normal, got {:?}", n[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_fails() {
|
||||
assert!(parse_obj("").is_err());
|
||||
assert!(parse_obj("# just a comment\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_malformed_fails() {
|
||||
assert!(parse_obj("v 1 2\nf 1 2 3\n").is_err());
|
||||
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shared_vertex_dedup() {
|
||||
let content = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 3 4\n";
|
||||
let geo = parse_obj(content).unwrap();
|
||||
assert_eq!(geo.positions.len(), 4);
|
||||
assert_eq!(geo.indices.as_ref().unwrap().len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! # Mesh module — geometry sources for WSG
|
||||
//!
|
||||
//! This module is the single entry point for **where geometry data comes from**:
|
||||
//!
|
||||
//! - **`primitives`** — procedural generators (cube, sphere, torus, …), each behind a
|
||||
//! feature flag so you only compile what you need.
|
||||
//! - **`import`** — file loaders (OBJ, glTF), each behind a feature flag.
|
||||
//!
|
||||
//! All sources produce a [`Geometry`] (CPU-side vertex data: positions, normals, UVs,
|
||||
//! indices). Turning that into a GPU renderable is the job of [`crate::scene::Scene::add_mesh`].
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use wsg_lib::mesh::cube;
|
||||
//!
|
||||
//! // Procedural (feature "prim-cube")
|
||||
//! let geom = cube(2.0);
|
||||
//! assert_eq!(geom.positions.len(), 24);
|
||||
//! ```
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! | Feature | Provides |
|
||||
//! |---------|----------|
|
||||
//! | `prim-cube` | `cube(size)` |
|
||||
//! | `prim-sphere` | `uv_sphere(…)`, `icosphere(…)` |
|
||||
//! | `prim-cylinder` | `cylinder(…)` |
|
||||
//! | `prim-cone` | `cone(…)` |
|
||||
//! | `prim-torus` | `torus(…)` |
|
||||
//! | `prim-plane` | `plane(…)` |
|
||||
//! | `all-prims` | all of the above |
|
||||
//! | `import-obj` | `load_obj(path)` |
|
||||
//! | `import-gltf` | `load_gltf(path)` |
|
||||
|
||||
pub mod primitives;
|
||||
|
||||
#[cfg(feature = "import-obj")]
|
||||
pub mod import;
|
||||
|
||||
// Flat re-exports at the `wsg::mesh` level for convenience.
|
||||
#[cfg(feature = "prim-cube")]
|
||||
pub use primitives::cube;
|
||||
#[cfg(feature = "prim-plane")]
|
||||
pub use primitives::plane;
|
||||
#[cfg(feature = "prim-sphere")]
|
||||
pub use primitives::{icosphere, uv_sphere};
|
||||
#[cfg(feature = "prim-cylinder")]
|
||||
pub use primitives::cylinder;
|
||||
#[cfg(feature = "prim-cone")]
|
||||
pub use primitives::cone;
|
||||
#[cfg(feature = "prim-torus")]
|
||||
pub use primitives::torus;
|
||||
|
||||
#[cfg(feature = "import-obj")]
|
||||
pub use import::load_obj;
|
||||
#[cfg(feature = "import-gltf")]
|
||||
pub use import::load_gltf;
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Cone primitive — side (apex + base ring) + base cap.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Generates a cone of radius `radius` and height `height` (apex at +h/2, base at -h/2), closed by a
|
||||
/// base, with `sectors` segments. Analytical side normals (tilted outward);
|
||||
/// base normal −Y.
|
||||
pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
|
||||
let si = sectors.max(3);
|
||||
let h = height * 0.5;
|
||||
let (mut positions, mut normals, mut uvs, mut indices) = (
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 2]>::new(),
|
||||
Vec::<u16>::new(),
|
||||
);
|
||||
|
||||
// Side elements: apex + base ring.
|
||||
let apex = 0u16;
|
||||
positions.push([0.0, h, 0.0]);
|
||||
normals.push([0.0, 1.0, 0.0]); // shared apex; normal close to +Y by default
|
||||
uvs.push([0.5, 1.0]);
|
||||
let base_start = 1u16;
|
||||
for s in 0..=si {
|
||||
let u = s as f32 / si as f32;
|
||||
let theta = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
positions.push([radius * cos_t, -h, radius * sin_t]);
|
||||
// Side normal: normalize(h·cosθ, r, h·sinθ).
|
||||
let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize();
|
||||
normals.push(n.to_array());
|
||||
uvs.push([u, 0.0]);
|
||||
}
|
||||
for s in 0..si {
|
||||
indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]);
|
||||
}
|
||||
|
||||
// Closed base (circle at -h/2, normal -Y).
|
||||
let center = positions.len() as u16;
|
||||
positions.push([0.0, -h, 0.0]);
|
||||
normals.push([0.0, -1.0, 0.0]);
|
||||
uvs.push([0.5, 0.5]);
|
||||
let ring = positions.len() as u16;
|
||||
for s in 0..=si {
|
||||
let u = s as f32 / si as f32;
|
||||
let theta = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
positions.push([radius * cos_t, -h, radius * sin_t]);
|
||||
normals.push([0.0, -1.0, 0.0]);
|
||||
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
|
||||
}
|
||||
for s in 0..si {
|
||||
let r = ring + s as u16;
|
||||
indices.extend_from_slice(&[center, r, r + 1]);
|
||||
}
|
||||
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len());
|
||||
assert_eq!(uvs.len(), positions.len());
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3);
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cone_validate() {
|
||||
assert_valid(&cone(0.5, 1.0, 16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Cube primitive — 24 vertices (4 per face) + 36 indices.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
|
||||
/// Generates a cube centered at the origin, edge `size`, with one normal and UVs per face.
|
||||
/// 24 vertices (4 per face) + 36 indices.
|
||||
pub fn cube(size: f32) -> Geometry {
|
||||
let s = size * 0.5;
|
||||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||||
(
|
||||
[0.0, 0.0, 1.0],
|
||||
[[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]],
|
||||
),
|
||||
(
|
||||
[0.0, 0.0, -1.0],
|
||||
[[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]],
|
||||
),
|
||||
(
|
||||
[1.0, 0.0, 0.0],
|
||||
[[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]],
|
||||
),
|
||||
(
|
||||
[-1.0, 0.0, 0.0],
|
||||
[[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]],
|
||||
),
|
||||
(
|
||||
[0.0, 1.0, 0.0],
|
||||
[[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]],
|
||||
),
|
||||
(
|
||||
[0.0, -1.0, 0.0],
|
||||
[[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]],
|
||||
),
|
||||
];
|
||||
|
||||
let mut positions = Vec::with_capacity(24);
|
||||
let mut normals = Vec::with_capacity(24);
|
||||
let mut uvs = Vec::with_capacity(24);
|
||||
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
for (normal, corners) in faces {
|
||||
for (i, corner) in corners.iter().enumerate() {
|
||||
positions.push(*corner);
|
||||
normals.push(normal);
|
||||
uvs.push(quad_uvs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut indices = Vec::with_capacity(36);
|
||||
for face in 0..6u16 {
|
||||
let b = face * 4;
|
||||
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
|
||||
}
|
||||
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::Vec3;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len(), "normals/positions count");
|
||||
assert_eq!(uvs.len(), positions.len(), "uvs/positions count");
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3, "unit normal, got {len}");
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len(), "index {i} in bounds");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_counts() {
|
||||
let g = cube(1.0);
|
||||
assert_eq!(g.positions.len(), 24);
|
||||
assert_eq!(g.indices.as_ref().unwrap().len(), 36);
|
||||
assert_valid(&g);
|
||||
let g2 = cube(2.0);
|
||||
assert_eq!(
|
||||
g2.positions,
|
||||
g.positions
|
||||
.iter()
|
||||
.map(|p| [p[0] * 2.0, p[1] * 2.0, p[2] * 2.0])
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Cylinder primitive — side + top/bottom caps.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Generates a cylinder of radius `radius` and height `height` (along Y, centered), with
|
||||
/// `sectors` segments. Parts: side (smooth radial normals), top cap (+Y), bottom base (−Y).
|
||||
pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
|
||||
let si = sectors.max(3);
|
||||
let h = height * 0.5;
|
||||
let (mut positions, mut normals, mut uvs, mut indices) = (
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 2]>::new(),
|
||||
Vec::<u16>::new(),
|
||||
);
|
||||
|
||||
// Side: radial columns × 2 rows (bottom/top).
|
||||
let side_base = 0u16;
|
||||
for row in 0..=1 {
|
||||
let y = if row == 0 { -h } else { h };
|
||||
for s in 0..=si {
|
||||
let u = s as f32 / si as f32;
|
||||
let theta = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
let radial = Vec3::new(cos_t, 0.0, sin_t);
|
||||
positions.push((radial * radius + Vec3::new(0.0, y, 0.0)).to_array());
|
||||
normals.push(radial.to_array());
|
||||
uvs.push([u, row as f32]);
|
||||
}
|
||||
}
|
||||
for s in 0..si {
|
||||
let a = side_base + s as u16;
|
||||
let b = a + 1;
|
||||
let c = side_base + (si as u16) + 1 + s as u16;
|
||||
let d = c + 1;
|
||||
indices.extend_from_slice(&[a, c, b, b, c, d]);
|
||||
}
|
||||
|
||||
// Caps: center + ring at each end.
|
||||
for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] {
|
||||
let center = positions.len() as u16;
|
||||
positions.push([0.0, y, 0.0]);
|
||||
normals.push(normal);
|
||||
uvs.push([0.5, 0.5]);
|
||||
let ring_start = positions.len() as u16;
|
||||
for s in 0..=si {
|
||||
let u = s as f32 / si as f32;
|
||||
let theta = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
positions.push([radius * cos_t, y, radius * sin_t]);
|
||||
normals.push(normal);
|
||||
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
|
||||
}
|
||||
for s in 0..si {
|
||||
let a = ring_start + s as u16;
|
||||
indices.extend_from_slice(&[center, a + 1, a]);
|
||||
}
|
||||
}
|
||||
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len());
|
||||
assert_eq!(uvs.len(), positions.len());
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3);
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_validate() {
|
||||
assert_valid(&cylinder(0.5, 1.0, 16));
|
||||
let c = cylinder(0.5, 1.0, 8);
|
||||
assert!(c.positions.iter().all(|p| p[1].abs() <= 0.5 + 1e-5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Procedural mesh generators — each behind a feature flag.
|
||||
//!
|
||||
//! Enable features in `Cargo.toml`:
|
||||
//! ```toml
|
||||
//! wsg = { features = ["prim-cube", "prim-sphere"] }
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "prim-cube")]
|
||||
pub mod cube;
|
||||
#[cfg(feature = "prim-plane")]
|
||||
pub mod plane;
|
||||
#[cfg(feature = "prim-sphere")]
|
||||
pub mod sphere;
|
||||
#[cfg(feature = "prim-cylinder")]
|
||||
pub mod cylinder;
|
||||
#[cfg(feature = "prim-cone")]
|
||||
pub mod cone;
|
||||
#[cfg(feature = "prim-torus")]
|
||||
pub mod torus;
|
||||
|
||||
// Flat re-exports: `use wsg::mesh::primitives::cube` or `use wsg::mesh::cube`
|
||||
#[cfg(feature = "prim-cube")]
|
||||
pub use cube::cube;
|
||||
#[cfg(feature = "prim-plane")]
|
||||
pub use plane::plane;
|
||||
#[cfg(feature = "prim-sphere")]
|
||||
pub use sphere::{icosphere, uv_sphere};
|
||||
#[cfg(feature = "prim-cylinder")]
|
||||
pub use cylinder::cylinder;
|
||||
#[cfg(feature = "prim-cone")]
|
||||
pub use cone::cone;
|
||||
#[cfg(feature = "prim-torus")]
|
||||
pub use torus::torus;
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Plane primitive — horizontal plane in XZ with subdivisions.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
|
||||
/// Generates a horizontal plane in the XZ plane (normal +Y), centered at (0, 0, 0), with
|
||||
/// `width` × `depth` dimensions, subdivided into `seg_x` × `seg_z` cells.
|
||||
pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry {
|
||||
let sx = seg_x.max(1);
|
||||
let sz = seg_z.max(1);
|
||||
let (mut positions, mut normals, mut uvs, mut indices) = (
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 2]>::new(),
|
||||
Vec::<u16>::new(),
|
||||
);
|
||||
for z in 0..=sz {
|
||||
let vz = z as f32 / sz as f32;
|
||||
for x in 0..=sx {
|
||||
let vx = x as f32 / sx as f32;
|
||||
positions.push([(vx - 0.5) * width, 0.0, (vz - 0.5) * depth]);
|
||||
normals.push([0.0, 1.0, 0.0]);
|
||||
uvs.push([vx, vz]);
|
||||
}
|
||||
}
|
||||
for z in 0..sz {
|
||||
for x in 0..sx {
|
||||
let a = z * (sx + 1) + x;
|
||||
let b = a + 1;
|
||||
let c = (z + 1) * (sx + 1) + x;
|
||||
let d = c + 1;
|
||||
indices
|
||||
.extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]);
|
||||
}
|
||||
}
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::Vec3;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len());
|
||||
assert_eq!(uvs.len(), positions.len());
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3);
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_counts() {
|
||||
let g = plane(2.0, 3.0, 1, 1);
|
||||
assert_eq!(g.positions.len(), 4);
|
||||
assert_eq!(g.indices.as_ref().unwrap().len(), 6);
|
||||
assert_valid(&g);
|
||||
assert!(g.positions.iter().all(|p| p[1] == 0.0));
|
||||
let g2 = plane(2.0, 3.0, 4, 5);
|
||||
assert_eq!(g2.positions.len(), (4 + 1) * (5 + 1));
|
||||
assert_valid(&g2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Sphere primitives — UV sphere (lat/long) + icosphere (subdivided icosahedron).
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use glam::Vec3;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generates a UV (latitude/longitude) sphere of radius `radius`, with `sectors` segments around
|
||||
/// and `stacks` vertical rings. Smooth normals = normalized position; spherical UVs.
|
||||
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry {
|
||||
let si = sectors.max(3);
|
||||
let st = stacks.max(3);
|
||||
let (mut positions, mut normals, mut uvs, mut indices) = (
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 2]>::new(),
|
||||
Vec::<u16>::new(),
|
||||
);
|
||||
for stack in 0..=st {
|
||||
let v = stack as f32 / st as f32;
|
||||
let phi = v * std::f32::consts::PI;
|
||||
for sector in 0..=si {
|
||||
let u = sector as f32 / si as f32;
|
||||
let theta = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_p, cos_p) = phi.sin_cos();
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
let pos = Vec3::new(
|
||||
radius * sin_p * cos_t,
|
||||
radius * cos_p,
|
||||
radius * sin_p * sin_t,
|
||||
);
|
||||
positions.push(pos.to_array());
|
||||
normals.push(pos.normalize().to_array());
|
||||
uvs.push([u, v]);
|
||||
}
|
||||
}
|
||||
for stack in 0..st {
|
||||
for sector in 0..si {
|
||||
let k1 = stack * (si + 1) + sector;
|
||||
let k2 = k1 + si + 1;
|
||||
let (k1, k2) = (k1 as u16, k2 as u16);
|
||||
indices.extend_from_slice(&[k1, k2, k1 + 1, k1 + 1, k2, k2 + 1]);
|
||||
}
|
||||
}
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
/// Generates an icosphere (subdivided icosahedron) of radius `radius`.
|
||||
/// `subdivisions = 0` gives an icosahedron (12 verts / 20 faces); each subdivision refines ×4.
|
||||
pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
|
||||
let t = (1.0 + 5.0_f32.sqrt()) * 0.5;
|
||||
let mut positions: Vec<Vec3> = [
|
||||
[-1.0, t, 0.0], [1.0, t, 0.0], [-1.0, -t, 0.0], [1.0, -t, 0.0],
|
||||
[0.0, -1.0, t], [0.0, 1.0, t], [0.0, -1.0, -t], [0.0, 1.0, -t],
|
||||
[t, 0.0, -1.0], [t, 0.0, 1.0], [-t, 0.0, -1.0], [-t, 0.0, 1.0],
|
||||
]
|
||||
.iter()
|
||||
.map(|v| Vec3::from_array(*v).normalize())
|
||||
.collect();
|
||||
|
||||
let mut faces: Vec<[u32; 3]> = [
|
||||
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
||||
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
||||
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
||||
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for _ in 0..subdivisions {
|
||||
let mut midpoint = HashMap::new();
|
||||
let old_faces = std::mem::take(&mut faces);
|
||||
for [a, b, c] in old_faces {
|
||||
let ab = subdiv_midpoint(&mut positions, &mut midpoint, a, b);
|
||||
let bc = subdiv_midpoint(&mut positions, &mut midpoint, b, c);
|
||||
let ca = subdiv_midpoint(&mut positions, &mut midpoint, c, a);
|
||||
faces.push([a, ab, ca]);
|
||||
faces.push([ab, b, bc]);
|
||||
faces.push([ca, bc, c]);
|
||||
faces.push([ab, bc, ca]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut normals = Vec::with_capacity(positions.len());
|
||||
let mut uvs = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let dir = p.normalize();
|
||||
normals.push(dir.to_array());
|
||||
uvs.push(spherical_uv(dir));
|
||||
}
|
||||
let scaled: Vec<[f32; 3]> = positions.iter().map(|p| (*p * radius).to_array()).collect();
|
||||
|
||||
let mut indices = Vec::with_capacity(faces.len() * 3);
|
||||
for [a, b, c] in &faces {
|
||||
indices.extend_from_slice(&[*a as u16, *b as u16, *c as u16]);
|
||||
}
|
||||
Geometry::new(scaled)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
fn subdiv_midpoint(
|
||||
positions: &mut Vec<Vec3>,
|
||||
cache: &mut HashMap<(u32, u32), u32>,
|
||||
a: u32,
|
||||
b: u32,
|
||||
) -> u32 {
|
||||
let key = if a < b { (a, b) } else { (b, a) };
|
||||
if let Some(&i) = cache.get(&key) {
|
||||
return i;
|
||||
}
|
||||
let mid = (positions[a as usize] + positions[b as usize]).normalize();
|
||||
positions.push(mid);
|
||||
let i = (positions.len() - 1) as u32;
|
||||
cache.insert(key, i);
|
||||
i
|
||||
}
|
||||
|
||||
fn spherical_uv(dir: Vec3) -> [f32; 2] {
|
||||
let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI));
|
||||
let v = 0.5 - (dir.y.asin() / std::f32::consts::PI);
|
||||
[u, v]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len());
|
||||
assert_eq!(uvs.len(), positions.len());
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3);
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sphere_counts_and_normals() {
|
||||
let g = uv_sphere(1.0, 12, 8);
|
||||
assert_eq!(g.positions.len(), (12 + 1) * (8 + 1));
|
||||
assert_valid(&g);
|
||||
for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) {
|
||||
let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length();
|
||||
assert!(diff < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icosphere_grows_with_subdivision() {
|
||||
let base = icosphere(1.0, 0);
|
||||
assert_eq!(base.positions.len(), 12);
|
||||
assert_eq!(base.indices.as_ref().unwrap().len(), 60);
|
||||
assert_valid(&base);
|
||||
let once = icosphere(1.0, 1);
|
||||
assert!(once.positions.len() > base.positions.len());
|
||||
assert_valid(&once);
|
||||
for (p, n) in once.positions.iter().zip(once.normals.as_ref().unwrap()) {
|
||||
let r = Vec3::from_array(*p).length();
|
||||
assert!((r - 1.0).abs() < 1e-3);
|
||||
let diff = (Vec3::from_array(*p).normalize() - Vec3::from_array(*n)).length();
|
||||
assert!(diff < 1e-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Torus primitive — tube around a ring.
|
||||
|
||||
use crate::core::geometry::Geometry;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Generates a torus with major radius `major`, minor radius `minor`, with `major_segments`
|
||||
/// segments around the ring and `minor_segments` around the tube cross-section.
|
||||
pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry {
|
||||
let mj = major_segments.max(3);
|
||||
let mn = minor_segments.max(3);
|
||||
let (mut positions, mut normals, mut uvs, mut indices) = (
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 3]>::new(),
|
||||
Vec::<[f32; 2]>::new(),
|
||||
Vec::<u16>::new(),
|
||||
);
|
||||
for i in 0..=mj {
|
||||
let u = i as f32 / mj as f32;
|
||||
let ua = u * 2.0 * std::f32::consts::PI;
|
||||
let (sin_u, cos_u) = ua.sin_cos();
|
||||
for j in 0..=mn {
|
||||
let v = j as f32 / mn as f32;
|
||||
let va = v * 2.0 * std::f32::consts::PI;
|
||||
let (sin_v, cos_v) = va.sin_cos();
|
||||
let ring = Vec3::new(
|
||||
(major + minor * cos_v) * cos_u,
|
||||
minor * sin_v,
|
||||
(major + minor * cos_v) * sin_u,
|
||||
);
|
||||
positions.push(ring.to_array());
|
||||
let n = Vec3::new(cos_v * cos_u, sin_v, cos_v * sin_u).normalize();
|
||||
normals.push(n.to_array());
|
||||
uvs.push([u, v]);
|
||||
}
|
||||
}
|
||||
for i in 0..mj {
|
||||
for j in 0..mn {
|
||||
let a = i * (mn + 1) + j;
|
||||
let b = a + 1;
|
||||
let c = a + mn + 1;
|
||||
let d = c + 1;
|
||||
indices
|
||||
.extend_from_slice(&[a as u16, b as u16, c as u16, b as u16, d as u16, c as u16]);
|
||||
}
|
||||
}
|
||||
Geometry::new(positions)
|
||||
.with_normals(normals)
|
||||
.with_uvs(uvs)
|
||||
.with_indices(indices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_valid(geo: &Geometry) {
|
||||
geo.validate().expect("generated geometry must validate");
|
||||
let positions = &geo.positions;
|
||||
let normals = geo.normals.as_ref().expect("normals present");
|
||||
let uvs = geo.uvs.as_ref().expect("uvs present");
|
||||
let indices = geo.indices.as_ref().expect("indices present");
|
||||
assert_eq!(normals.len(), positions.len());
|
||||
assert_eq!(uvs.len(), positions.len());
|
||||
for n in normals {
|
||||
let len = Vec3::from_array(*n).length();
|
||||
assert!((len - 1.0).abs() < 1e-3);
|
||||
}
|
||||
for &i in indices {
|
||||
assert!((i as usize) < positions.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torus_validate() {
|
||||
let g = torus(1.0, 0.25, 24, 12);
|
||||
assert_valid(&g);
|
||||
assert_eq!(g.positions.len(), (24 + 1) * (12 + 1));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ The `pipeline` module contains the shader compilation cache that avoids duplicat
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| **pipeline_cache** | PipelineCache — maps (shader_id, format) keys to compiled RenderPipelines. Loads WGSL from disk or falls back to embedded BASIC_SHADER constant. Creates pipelines on-demand via build_pipeline(). |
|
||||
| **pipeline_cache** | PipelineCache — maps (shader_id, format) keys to compiled RenderPipelines. Loads WGSL from disk or falls back to embedded STANDARD_SHADER constant. Creates pipelines on-demand via build_pipeline(). |
|
||||
|
||||
## Interaction with Other Modules
|
||||
|
||||
- **utils::conf**: Provides BASIC_SHADER_PATH (disk path) and BASIC_SHADER (embedded fallback).
|
||||
- **utils::conf**: Provides STANDARD_SHADER_PATH (disk path) and STANDARD_SHADER (embedded fallback).
|
||||
- **resources::vertex**: Vertex struct field offsets define the CPU-side layout that build_pipeline() uses as the vertex buffer contract.
|
||||
- **resources::material**: Material::new() calls get_or_create() during construction to obtain a shared RenderPipeline.
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `material` calls `get_or_create()` during its own construction to obtain a shared RenderPipeline.
|
||||
//! - `conf::BASIC_SHADER` provides fallback WGSL source when an external file is not found.
|
||||
//! - `conf::STANDARD_SHADER` provides fallback WGSL source when an external file is not found.
|
||||
//! - `vertex::Vertex` defines the CPU-side layout that `build_pipeline()` uses as the vertex buffer contract.
|
||||
|
||||
pub mod pipeline_cache;
|
||||
// Re-exports
|
||||
pub use pipeline_cache::PipelineCache;
|
||||
pub use pipeline_cache::{
|
||||
DEPTH_FORMAT, PipelineCache, build_shadow_pipeline, create_shadow_map_bind_group_layout,
|
||||
create_shadow_uniform_layout, create_texture_bind_group_layout,
|
||||
create_uniform_bind_group_layouts, vertex_buffer_layout,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - **Material** calls `get_or_create()` during its own construction to obtain a shared RenderPipeline.
|
||||
//! - **conf::BASIC_SHADER** provides fallback WGSL source when an external file is not found.
|
||||
//! - **conf::STANDARD_SHADER** provides fallback WGSL source when an external file is not found.
|
||||
//! - **vertex::Vertex** defines the CPU-side layout that `build_pipeline` uses as the vertex buffer contract.
|
||||
//!
|
||||
//! ## Technical Points
|
||||
@@ -15,12 +15,179 @@
|
||||
//! - wgpu 30 requires `compilation_options` in VertexState/FragmentState and `depth_slice` in color attachments.
|
||||
//! - **Batching**: Multiple Materials with the same shader_id share one pipeline, enabling material-level batching in Renderer.
|
||||
|
||||
use crate::resources::Vertex;
|
||||
use crate::utils::BASIC_SHADER;
|
||||
use crate::resources::{Texture, Vertex};
|
||||
use crate::utils::STANDARD_SHADER;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Creates the two bind group layouts shared by **every** pipeline (Step 3 — decision ratified
|
||||
/// "a single layout for all"). Both buffers are `Uniform` and 16-byte aligned. Matching CPU
|
||||
/// types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
|
||||
/// Returns `[frame_layout, object_layout]` in renderer binding order.
|
||||
///
|
||||
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages. Static
|
||||
/// (one shared `FrameUniforms` buffer per frame, no dynamic offset).
|
||||
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only. **Dynamic**
|
||||
/// (Phase 3, D12): the offset selects a 64-byte slice of the single GPU-written matrix buffer,
|
||||
/// so every entity shares one buffer. The low-level `render` path passes offset 0.
|
||||
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
|
||||
[
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("frame_uniform_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
}),
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("object_uniform_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
|
||||
// matrix buffer (one 64-byte slice per slot), instead of a per-entity buffer.
|
||||
// The low-level `render` path passes offset 0 (its identity object buffer).
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Creates the texture bind group layout (group 2) shared by every pipeline (Step 10, DRAFT D1).
|
||||
/// Binds the diffuse texture + its sampler in the **fragment** stage only. Added to every pipeline
|
||||
/// layout alongside the frame (@0) + object (@1) uniform groups, so « un seul layout pour tous »
|
||||
/// (Step 3) is preserved: a texture-less `Material` binds the white 1×1 placeholder instead.
|
||||
///
|
||||
/// - `binding 0`: sampler (filtering, linear/repeat — D3).
|
||||
/// - `binding 1`: `texture_2d<f32>` diffuse.
|
||||
pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("texture_bind_group_layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Step 14,
|
||||
/// DRAFT D1/D5). Binds a **comparison** sampler + a depth texture so the fragment can run a PCF
|
||||
/// `textureSampleCompare` against the shadow map. Added to every pipeline layout alongside groups
|
||||
/// 0–2, keeping « un seul layout pour tous » — shadows are simply a no-op when disabled.
|
||||
///
|
||||
/// - `binding 0`: `sampler_comparison` (compare fn drives the shadow test, D5).
|
||||
/// - `binding 1`: `texture_depth_2d` (the shadow map).
|
||||
pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("shadow_map_bind_group_layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the **shadow uniform** bind group layout (group 0 of the depth-only shadow pipeline,
|
||||
/// Step 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the
|
||||
/// **vertex** stage only (the shadow shader transforms vertices into light-clip space).
|
||||
pub fn create_shadow_uniform_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("shadow_uniform_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// The shared GPU `Vertex`-buffer layout used by **every** pipeline that renders mesh geometry
|
||||
/// (both the main `build_pipeline` and the depth-only shadow pipeline). The array stride equals
|
||||
/// `size_of::<Vertex>()` so it matches the mesh vertex buffers exactly; the four attributes are
|
||||
/// declared position (loc 0), normal (1), uv (2), color (3).
|
||||
pub fn vertex_buffer_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // position
|
||||
wgpu::VertexAttribute {
|
||||
offset: 12,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // normal
|
||||
wgpu::VertexAttribute {
|
||||
offset: 24,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
}, // uv
|
||||
wgpu::VertexAttribute {
|
||||
offset: 32,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
}, // color
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Depth texture format shared by the whole library (Step 9, D1 decision of 2026-09-18).
|
||||
///
|
||||
/// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the
|
||||
/// `DepthStencilState` of every pipeline (`build_pipeline`). Keeping them on the same constant
|
||||
/// guarantees by construction that the pipeline depth format always matches the texture format
|
||||
/// (wgpu validation error otherwise). `Depth32Float` = maximum precision (exact comparison),
|
||||
/// with clear `1.0` (maximum depth far away), `depth_compare: Less`, write enabled.
|
||||
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// Shader pipeline cache: maps (shader_id, format) keys to compiled RenderPipelines.
|
||||
/// Ensures each unique shader+format combination is compiled at most once; subsequent requests return cached instances.
|
||||
pub struct PipelineCache {
|
||||
@@ -29,22 +196,68 @@ pub struct PipelineCache {
|
||||
pipelines: HashMap<String, Arc<wgpu::RenderPipeline>>,
|
||||
/// Maps shader IDs to file paths on disk for WGSL loading in `load_shader()`.
|
||||
shader_paths: HashMap<String, String>,
|
||||
/// Shared bind group layout for the texture group (`@group(2)`), used by every pipeline and by
|
||||
/// every Material's texture bind group (Step 10, DRAFT D1: "a single layout for all").
|
||||
texture_bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// White 1×1 placeholder texture bound by materials that have no diffuse texture (DRAFT D1/D2).
|
||||
/// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look.
|
||||
placeholder: Arc<Texture>,
|
||||
}
|
||||
|
||||
impl PipelineCache {
|
||||
/// Creates an empty pipeline cache with no pre-loaded shaders or pipelines.
|
||||
/// Inputs: device (owned Arc reference to wgpu Device, required for creating ShaderModules and RenderPipelines).
|
||||
/// Returns a new PipelineCache ready for shader registration via register_shader().
|
||||
/// Creates an empty pipeline cache with no pre-loaded shaders or pipelines, plus the shared
|
||||
/// texture bind group layout (group 2) and the white placeholder texture (Step 10).
|
||||
/// Inputs: device (owned Arc reference to wgpu Device), queue (used once to upload the white
|
||||
/// placeholder). Returns a new PipelineCache ready for shader registration via register_shader().
|
||||
/// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first.
|
||||
pub fn new(device: Arc<wgpu::Device>) -> Self {
|
||||
pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue) -> Self {
|
||||
let placeholder = Texture::white_placeholder(&device, &queue).arc();
|
||||
let texture_bind_group_layout = create_texture_bind_group_layout(&device);
|
||||
Self {
|
||||
device,
|
||||
pipelines: HashMap::new(),
|
||||
// Maps shader IDs to file paths on disk for WGSL loading in load_shader().
|
||||
// When a path exists, it reads from it; otherwise falls back to BASIC_SHADER constant.
|
||||
// When a path exists, it reads from it; otherwise falls back to STANDARD_SHADER constant.
|
||||
shader_paths: HashMap::new(),
|
||||
texture_bind_group_layout,
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the shared white placeholder texture, bound by `Material`s without a diffuse texture.
|
||||
/// Called by `Material` construction (through [`PipelineCache::texture_bind_group`]) and by
|
||||
/// `Scene::get_texture` fallbacks. Step 10 (DRAFT D1/D2).
|
||||
pub fn placeholder(&self) -> &Arc<Texture> {
|
||||
&self.placeholder
|
||||
}
|
||||
|
||||
/// Returns a reference to the shared group-2 bind group layout (sampler + texture), used by
|
||||
/// every Material to build its texture bind group. Step 10 (DRAFT D1).
|
||||
pub fn texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
|
||||
&self.texture_bind_group_layout
|
||||
}
|
||||
|
||||
/// Builds a group-2 bind group for a Material from its diffuse texture (or the white placeholder
|
||||
/// when `texture` is `None`). Centralizes the sampler+texture binding so `Material` never touches
|
||||
/// wgpu directly (Step 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None`
|
||||
/// for a texture-less material (binds the placeholder). Returns the group-2 bind group.
|
||||
pub fn texture_bind_group(&self, texture: Option<Arc<Texture>>) -> wgpu::BindGroup {
|
||||
let tex = texture.unwrap_or_else(|| self.placeholder.clone());
|
||||
self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture bind group"),
|
||||
layout: &self.texture_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Sampler(&tex.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&tex.view),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/// Registers an external WGSL shader file path associated with a given ID.
|
||||
/// Inputs: id (unique key for this shader), path (filesystem path to .wgsl file).
|
||||
/// Returns Ok(id) on success or Err(String) if the ID is already registered. Called during scene setup to register custom shaders.
|
||||
@@ -101,13 +314,13 @@ impl PipelineCache {
|
||||
pipeline_arc
|
||||
}
|
||||
|
||||
/// Loads a WGSL shader module: reads from disk first, falls back to the embedded BASIC_SHADER constant.
|
||||
/// Loads a WGSL shader module: reads from disk first, falls back to the embedded STANDARD_SHADER constant.
|
||||
/// Inputs: device (GPU command source for shader compilation), path (file path or shader_id string).
|
||||
/// Returns a compiled wgpu::ShaderModule. Called internally by `get_or_create()` when compiling a new pipeline.
|
||||
fn load_shader(&self, device: &wgpu::Device, path: &str) -> wgpu::ShaderModule {
|
||||
let source = std::fs::read_to_string(path).unwrap_or_else(|_| {
|
||||
println!("Shader not found: {}, falling back to default", path);
|
||||
BASIC_SHADER.to_string()
|
||||
STANDARD_SHADER.to_string()
|
||||
});
|
||||
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -129,39 +342,26 @@ impl PipelineCache {
|
||||
) -> wgpu::RenderPipeline {
|
||||
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
|
||||
// Must match Vertex struct field offsets exactly.
|
||||
let vertex_buffer_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // position
|
||||
wgpu::VertexAttribute {
|
||||
offset: 12,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x3,
|
||||
}, // normal
|
||||
wgpu::VertexAttribute {
|
||||
offset: 24,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
}, // uv
|
||||
wgpu::VertexAttribute {
|
||||
offset: 32,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
}, // color
|
||||
],
|
||||
};
|
||||
let vertex_buffer_layout = vertex_buffer_layout();
|
||||
|
||||
// Pipeline layout — defines bind group bindings (empty here; no uniform buffers used).
|
||||
// wgpu 30: `immediate_size` replaces `push_constant_ranges`.
|
||||
// Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
|
||||
// bind group (@2, Step 10 DRAFT D1) AND the shadow-map bind group (@3, Step 14 D5) are
|
||||
// attached to EVERY pipeline (Step 3, decision ratified "a single layout for all"), even
|
||||
// if a given shader does not read them.
|
||||
// `immediate_size` stays 0 (no var<immediate> used).
|
||||
let uniform_layouts = create_uniform_bind_group_layouts(device);
|
||||
let texture_layout = create_texture_bind_group_layout(device);
|
||||
let shadow_layout = create_shadow_map_bind_group_layout(device);
|
||||
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = vec![
|
||||
Some(&uniform_layouts[0]), // frame @0
|
||||
Some(&uniform_layouts[1]), // object @1
|
||||
Some(&texture_layout), // texture @2
|
||||
Some(&shadow_layout), // shadow map @3
|
||||
];
|
||||
let render_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("render_pipeline_layout"),
|
||||
bind_group_layouts: &[],
|
||||
bind_group_layouts: &layout_refs,
|
||||
immediate_size: 0, // no var<immediate> used
|
||||
});
|
||||
|
||||
@@ -188,7 +388,17 @@ impl PipelineCache {
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
// Step 9 (DRAFT 9.3): depth test enabled on EVERY pipeline. The format must match
|
||||
// the depth attachment (DEPTH_FORMAT) — guaranteed by the shared D1 constant.
|
||||
// depth_write_enabled + depth_compare are Options in wgpu 30: Some(true) → the depth
|
||||
// is written; Some(Less) → the fragment is kept if its z is closer.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
// multiview → replaced by multiview_mask (NonZeroU32) and cache fields in wgpu 30.
|
||||
multiview_mask: None,
|
||||
@@ -203,3 +413,66 @@ impl PipelineCache {
|
||||
self.pipelines.get(shader_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the **depth-only shadow pipeline** (Step 14, D4): a vertex-only pipeline (no fragment
|
||||
/// stage) that transforms each mesh vertex into the shadow-casting light's clip space, writing only
|
||||
/// depth. Its layout is [`shadow_uniform_layout`] (group 0: light `view_proj`) + [`object_layout`]
|
||||
/// (group 1: per-entity model matrix — the SAME layout/bind groups the main renderer already caches
|
||||
/// per entity, so the shadow pass reuses them directly).
|
||||
///
|
||||
/// `depth_stencil` writes depth with a slope-scaled bias (D5) to suppress acne on surfaces nearly
|
||||
/// parallel to the light. The vertex buffer layout is the shared [`vertex_buffer_layout`], so the
|
||||
/// same mesh vertex/index buffers are reused.
|
||||
///
|
||||
/// Inputs: device (GPU), object_layout (the shared per-object bind group layout, group 1).
|
||||
/// Returns the compiled shadow pipeline, ready to render into a depth attachment.
|
||||
pub fn build_shadow_pipeline(
|
||||
device: &wgpu::Device,
|
||||
object_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
// Vertex-only shader: this pipeline sets `fragment: None`, so only the depth is produced.
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("shadow_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::utils::SHADOW_SHADER.into()),
|
||||
});
|
||||
|
||||
let shadow_uniform_layout = create_shadow_uniform_layout(device);
|
||||
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> =
|
||||
vec![Some(&shadow_uniform_layout), Some(object_layout)];
|
||||
let shadow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("shadow_pipeline_layout"),
|
||||
bind_group_layouts: &layout_refs,
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shadow Pipeline"),
|
||||
layout: Some(&shadow_pipeline_layout),
|
||||
// wgpu 30: vertex state requires `compilation_options`.
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(vertex_buffer_layout())],
|
||||
},
|
||||
// Depth-only: no fragment state (no color output, no color target).
|
||||
fragment: None,
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
// Step 14 (D5): slope-scaled depth bias against acne — surfaces nearly parallel to
|
||||
// the light are pushed back slightly in the shadow map so they do not self-shadow.
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 2,
|
||||
slope_scale: 2.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! # WSG Prelude
|
||||
//!
|
||||
//! Re-exports the most commonly used types in a single glob import:
|
||||
//!
|
||||
//! ```rust
|
||||
//! use wsg_lib::prelude::*;
|
||||
//!
|
||||
//! let geom = cube(2.0);
|
||||
//! assert_eq!(geom.positions.len(), 24);
|
||||
//! let tf = Transform::identity();
|
||||
//! ```
|
||||
//!
|
||||
//! This avoids long import paths for the types you touch every day.
|
||||
|
||||
// Core types
|
||||
pub use crate::core::geometry::{BBox, Geometry};
|
||||
pub use crate::core::transform::Transform;
|
||||
pub use crate::core::{ShadowConfig, ToneMapper};
|
||||
|
||||
// App / handler (already at crate root, re-exported here for convenience)
|
||||
pub use crate::app::AppBuilder;
|
||||
pub use crate::handler::AppHandler;
|
||||
|
||||
// Primitives (available when the corresponding feature is enabled)
|
||||
#[cfg(feature = "prim-cube")]
|
||||
pub use crate::mesh::cube;
|
||||
#[cfg(feature = "prim-sphere")]
|
||||
pub use crate::mesh::{icosphere, uv_sphere};
|
||||
#[cfg(feature = "prim-cylinder")]
|
||||
pub use crate::mesh::cylinder;
|
||||
#[cfg(feature = "prim-cone")]
|
||||
pub use crate::mesh::cone;
|
||||
#[cfg(feature = "prim-torus")]
|
||||
pub use crate::mesh::torus;
|
||||
#[cfg(feature = "prim-plane")]
|
||||
pub use crate::mesh::plane;
|
||||
|
||||
// Import (available when the corresponding feature is enabled)
|
||||
#[cfg(feature = "import-obj")]
|
||||
pub use crate::mesh::load_obj;
|
||||
#[cfg(feature = "import-gltf")]
|
||||
pub use crate::mesh::load_gltf;
|
||||
|
||||
// Import error type
|
||||
#[cfg(any(feature = "import-obj", feature = "import-gltf"))]
|
||||
pub use crate::mesh::import::MeshImportError;
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The `resources` module defines three immutable data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
|
||||
The `resources` module defines the data types that flow through the rendering pipeline. These are created once during scene initialization and consumed by Renderer for draw calls every frame.
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| **vertex** | Vertex struct — CPU-side per-attribute tuple (position [f32;3], normal [f32;3], uv [f32;2], color [f32;4]). Must match PipelineCache::build_pipeline() vertex buffer layout byte-for-byte. |
|
||||
| **mesh** | Mesh struct — persistent GPU geometry container with vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::new() which uploads data from CPU to GPU buffers. |
|
||||
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. |
|
||||
| **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc<Geometry>` (Step 8), vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::from_geometry() which derives Vertex arrays from the Geometry and uploads them to GPU buffers. |
|
||||
| **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. Optionally holds a diffuse `Texture` (Step 10) plus its texture bind group. |
|
||||
| **texture** | Texture struct *(Step 10)* — GPU 2D image (device, view, sampler) in `Rgba8UnormSrgb`. Constructors: `from_rgba8` (raw bytes), `from_bytes` (encoded, via the `image` crate: png/jpeg/...), `from_file`, and `white_placeholder` (1x1 white used when no texture is attached). Sampler is linear-filtered with repeat addressing. |
|
||||
| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 704 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (64 B, 4 × vec4, directional/point/spot) and `MAX_LIGHTS` (Phase 4.2, Steps 12–13). |
|
||||
| **lights** | `Lights` — the scene's CPU-side global light list (directional + point + spot) and its `into_frame_array` packing (Phase 4.2, Steps 12–13). |
|
||||
| **camera** | `Camera` (position/target/up + fov/near/far, `with_perspective`, `view_matrix`/`projection_matrix`) and `CameraController` (Step 15.C — orbital: yaw/pitch/distance/target, `orbit`/`zoom`/`reset`/`apply_to`). |
|
||||
|
||||
## Interaction with Other Modules
|
||||
|
||||
- **pipeline**: build_pipeline() reads Vertex field offsets to construct VertexBufferLayout attributes array.
|
||||
- **scene**: Scene stores Arc<Mesh> and Arc<Material> instances keyed by identifier strings.
|
||||
- **utils**: Mesh creation uses BASIC_SHADER fallback when external shader files are missing.
|
||||
- **utils**: PipelineCache uses the embedded STANDARD_SHADER fallback when external shader files are missing.
|
||||
|
||||
+259
-11
@@ -15,9 +15,18 @@
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
/// Default vertical field of view in radians (45°).
|
||||
pub const DEFAULT_FOV: f32 = 45.0_f32.to_radians();
|
||||
/// Near clipping plane distance used by the default perspective projection.
|
||||
pub const DEFAULT_NEAR: f32 = 0.1;
|
||||
/// Far clipping plane distance used by the default perspective projection.
|
||||
pub const DEFAULT_FAR: f32 = 100.0;
|
||||
|
||||
/// Represents a 3D camera for viewing the scene.
|
||||
///
|
||||
/// The camera defines the viewpoint and projection settings for rendering.
|
||||
/// The camera defines the viewpoint (position/target/up), the projection parameters (fov, near, far)
|
||||
/// and can produce the view and projection matrices uploaded each frame to the `FrameUniforms` buffer
|
||||
/// (Step 4.3). Use `Scene::set_camera` to install it as the scene's active camera.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Camera {
|
||||
/// Position of the camera in world space
|
||||
@@ -26,37 +35,276 @@ pub struct Camera {
|
||||
pub target: Vec3,
|
||||
/// Up vector defining the camera's orientation
|
||||
pub up: Vec3,
|
||||
/// Vertical field of view in radians (used by the perspective projection).
|
||||
pub fov: f32,
|
||||
/// Near clipping plane distance (used by the perspective projection).
|
||||
pub near: f32,
|
||||
/// Far clipping plane distance (used by the perspective projection).
|
||||
pub far: f32,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
/// Default camera: positioned at (0, 0, 3) looking at the origin with a 45° vertical fov,
|
||||
/// near 0.1 and far 100. Good enough to frame a unit-cube scene out of the box.
|
||||
fn default() -> Self {
|
||||
Self::new(Vec3::new(0.0, 0.0, 3.0), Vec3::ZERO, Vec3::Y)
|
||||
}
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Creates a new camera with specified position, target, and up vector.
|
||||
/// Creates a new perspective camera with the default fov/near/far.
|
||||
/// Inputs: position (world-space eye point), target (world-space look-at point), up (view up vector).
|
||||
/// Adjust the projection via [`Camera::with_perspective`] if the defaults don't fit.
|
||||
pub fn new(position: Vec3, target: Vec3, up: Vec3) -> Self {
|
||||
Self {
|
||||
position,
|
||||
target,
|
||||
up,
|
||||
fov: DEFAULT_FOV,
|
||||
near: DEFAULT_NEAR,
|
||||
far: DEFAULT_FAR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the perspective projection parameters and returns the camera for chaining.
|
||||
/// Inputs: fov (vertical field of view in radians), near (near plane), far (far plane).
|
||||
pub fn with_perspective(mut self, fov: f32, near: f32, far: f32) -> Self {
|
||||
self.fov = fov;
|
||||
self.near = near;
|
||||
self.far = far;
|
||||
self
|
||||
}
|
||||
|
||||
/// Computes the view matrix for this camera.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Mat4` representing the view transformation matrix
|
||||
/// A `Mat4` representing the view transformation matrix (world → view space)
|
||||
pub fn view_matrix(&self) -> Mat4 {
|
||||
glam::camera::rh::view::look_at_mat4(self.position, self.target, self.up)
|
||||
}
|
||||
|
||||
/// Computes the projection matrix for this camera.
|
||||
/// Computes the perspective projection matrix for this camera using its stored fov/near/far.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `fov`: Field of view in radians
|
||||
/// - `aspect`: Aspect ratio of the viewport
|
||||
/// - `near`: Near clipping plane distance
|
||||
/// - `far`: Far clipping plane distance
|
||||
/// - `aspect`: Aspect ratio of the viewport (width / height)
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Mat4` representing the projection transformation matrix
|
||||
pub fn projection_matrix(&self, fov: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
|
||||
glam::camera::rh::proj::opengl::perspective(fov, aspect, near, far)
|
||||
/// A `Mat4` representing the projection transformation matrix (view → clip space)
|
||||
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
|
||||
// WebGPU expects NDC clip depth in [0,1]; glam's `opengl` module remaps to [-1,1], which
|
||||
// would clip roughly the front half of the frustum in wgpu. The `directx` (WebGPU) module
|
||||
// produces Y-up right-handed projections with depth already in [0,1], matching the shadow
|
||||
// projections and the depth wgpu writes.
|
||||
glam::camera::rh::proj::directx::perspective(self.fov, aspect, self.near, self.far)
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical pitch clamp (radians) applied by [`CameraController`] so the camera never flips over the
|
||||
/// poles. Kept a little under ±90°.
|
||||
pub const PITCH_LIMIT: f32 = 1.45; // ~83°
|
||||
|
||||
/// Orbital camera controller (Step 15, sub-step 15.C).
|
||||
///
|
||||
/// Represents the viewpoint spherically around a `target`: `yaw` (rotation around the world-up axis),
|
||||
/// `pitch` (elevation above/below the horizontal), `distance` (radius) and the look-at `target`.
|
||||
/// [`CameraController::apply_to`] writes these into a [`Camera`] each frame, so the controller stays
|
||||
/// decoupled from `Camera`'s own position/target/up representation.
|
||||
///
|
||||
/// ```
|
||||
/// # use wsg_lib::resources::{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);
|
||||
/// ctrl.orbit(0.1, -0.05); // drag: yaw/pitch
|
||||
/// ctrl.zoom(-1.0); // wheel: distance
|
||||
/// let mut cam2 = cam;
|
||||
/// ctrl.apply_to(&mut cam2); // write back into the active camera
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CameraController {
|
||||
/// Rotation around the world-up (+Y) axis, in radians.
|
||||
pub yaw: f32,
|
||||
/// Elevation angle above (+) / below (-) the horizontal, in radians, clamped to ±[`PITCH_LIMIT`].
|
||||
pub pitch: f32,
|
||||
/// Distance from the camera position to the `target` (orbit radius).
|
||||
pub distance: f32,
|
||||
/// World-space point the camera looks at and orbits around.
|
||||
pub target: Vec3,
|
||||
/// Orbit sensitivity (radians of yaw per pixel of mouse delta); see [`DEFAULT_ORBIT_SENSITIVITY`].
|
||||
pub orbit_sensitivity: f32,
|
||||
/// Multiplicative zoom factor applied per unit of vertical scroll; see [`DEFAULT_ZOOM_FACTOR`].
|
||||
pub zoom_factor: f32,
|
||||
}
|
||||
|
||||
impl Default for CameraController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
distance: 3.0,
|
||||
target: Vec3::ZERO,
|
||||
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
|
||||
zoom_factor: DEFAULT_ZOOM_FACTOR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensitivity of the orbit drag (radians of yaw per pixel of horizontal mouse delta).
|
||||
/// 0.005 gives ~110° per full window width — a comfortable default; raise it for smaller viewports.
|
||||
pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
|
||||
/// Multiplicative zoom factor applied per unit of vertical scroll (one wheel notch ≈ 1 unit after
|
||||
/// `InputState` normalization). 0.9 → 10% distance change per notch.
|
||||
pub const DEFAULT_ZOOM_FACTOR: f32 = 0.9;
|
||||
|
||||
impl CameraController {
|
||||
/// Builds a controller that reproduces an existing camera's framing by extracting yaw/pitch/
|
||||
/// distance from `position - target` in spherical coordinates.
|
||||
pub fn from_camera(camera: &Camera) -> Self {
|
||||
let offset = camera.position - camera.target;
|
||||
let distance = offset.length().max(f32::EPSILON);
|
||||
// Y-up convention: pitch = asin(y / r), yaw measured from +Z toward +X.
|
||||
let pitch = offset
|
||||
.y
|
||||
.atan2((offset.x * offset.x + offset.z * offset.z).sqrt());
|
||||
let yaw = offset.x.atan2(offset.z);
|
||||
Self {
|
||||
yaw,
|
||||
pitch: pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT),
|
||||
distance,
|
||||
target: camera.target,
|
||||
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
|
||||
zoom_factor: DEFAULT_ZOOM_FACTOR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the world-space eye position from the current yaw/pitch/distance around `target`.
|
||||
pub fn position(&self) -> Vec3 {
|
||||
let cp = self.pitch.cos();
|
||||
let dir = Vec3::new(cp * self.yaw.sin(), self.pitch.sin(), cp * self.yaw.cos());
|
||||
self.target + dir * self.distance
|
||||
}
|
||||
|
||||
/// Writes the current framing into a [`Camera`]: sets its `position` (spherical away from
|
||||
/// `target`), its look-at `target`, and forces `up` to world +Y so the horizon stays level.
|
||||
pub fn apply_to(&self, camera: &mut Camera) {
|
||||
camera.position = self.position();
|
||||
camera.target = self.target;
|
||||
camera.up = Vec3::Y;
|
||||
}
|
||||
|
||||
/// Applies an orbit drag (mouse delta in pixels): `dx` rotates yaw, `dy` rotates pitch
|
||||
/// (inverted so dragging up tilts the view up). Pitch is clamped to ±[`PITCH_LIMIT`]. The
|
||||
/// rotation speed is scaled by `self.orbit_sensitivity`.
|
||||
pub fn orbit(&mut self, dx: f32, dy: f32) {
|
||||
self.yaw -= dx * self.orbit_sensitivity;
|
||||
self.pitch = (self.pitch + dy * self.orbit_sensitivity).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// Zooms in/out by an exponential factor on the vertical wheel scroll (`scroll_y`, in wheel
|
||||
/// notches): positive scroll zooms in (distance shrinks). Clamped to a sane `[0.1, 100]` range.
|
||||
/// The per-notch factor is `self.zoom_factor`.
|
||||
pub fn zoom(&mut self, scroll_y: f32) {
|
||||
if scroll_y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let factor = self.zoom_factor.powf(scroll_y);
|
||||
self.distance = (self.distance * factor).clamp(0.1, 100.0);
|
||||
}
|
||||
|
||||
/// Resets the controller to its default framing (origin target, `distance` 3, level view).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_positions_level_front() {
|
||||
let ctrl = CameraController::default();
|
||||
let p = ctrl.position();
|
||||
assert!((p - Vec3::new(0.0, 0.0, 3.0)).length() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbit_changes_yaw_and_clamps_pitch() {
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.orbit(100.0, 0.0); // yaw rotation
|
||||
let p1 = ctrl.position();
|
||||
assert!((p1.x.abs()) > 0.1, "yaw should swing around +Y");
|
||||
assert!(ctrl.pitch == 0.0);
|
||||
// Pitch clamped to ±PITCH_LIMIT even with a huge drag.
|
||||
ctrl.orbit(0.0, 1_000.0);
|
||||
assert!((ctrl.pitch - PITCH_LIMIT).abs() < 1e-5);
|
||||
ctrl.orbit(0.0, -2_000.0);
|
||||
assert!((ctrl.pitch + PITCH_LIMIT).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_inout_clamped() {
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.zoom(1.0);
|
||||
assert!(ctrl.distance < 3.0, "positive scroll zooms in");
|
||||
ctrl.zoom(-10.0);
|
||||
assert!(ctrl.distance > 3.0);
|
||||
ctrl.zoom(10_000.0);
|
||||
assert!(ctrl.distance >= 0.1 - 1e-5);
|
||||
ctrl.zoom(-10_000.0);
|
||||
assert!(ctrl.distance <= 100.0 + 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_from_camera_reproduces_framing() {
|
||||
let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::new(1.0, 1.0, 0.0), Vec3::Y);
|
||||
let ctrl = CameraController::from_camera(&cam);
|
||||
let mut back = cam.clone();
|
||||
ctrl.apply_to(&mut back);
|
||||
// Target preserved; position matches up to float error for a non-pole framing.
|
||||
assert!((back.target - cam.target).length() < 1e-4);
|
||||
assert!((back.position - cam.position).length() < 1e-2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_is_configurable() {
|
||||
let mut slow = CameraController::default();
|
||||
let mut fast = CameraController::default();
|
||||
slow.orbit_sensitivity = 0.001; // one fifth of the default
|
||||
fast.orbit_sensitivity = 0.02; // four times the default
|
||||
slow.orbit(100.0, 0.0);
|
||||
fast.orbit(100.0, 0.0);
|
||||
assert!(
|
||||
(slow.yaw - fast.yaw).abs() > 1.0,
|
||||
"faster sensitivity must rotate more"
|
||||
);
|
||||
// Zoom factor: a gentler factor moves the distance less for the same scroll.
|
||||
let mut gentle = CameraController::default();
|
||||
gentle.zoom_factor = 0.99;
|
||||
let mut aggressive = CameraController::default();
|
||||
aggressive.zoom_factor = 0.8;
|
||||
gentle.zoom(3.0);
|
||||
aggressive.zoom(3.0);
|
||||
assert!(gentle.distance > aggressive.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restores_defaults() {
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.orbit(100.0, 50.0);
|
||||
ctrl.zoom(3.0);
|
||||
assert!(ctrl.yaw != 0.0);
|
||||
ctrl.reset();
|
||||
assert!((ctrl.yaw).abs() < 1e-6);
|
||||
assert!(ctrl.distance == 3.0);
|
||||
assert!(ctrl.target == Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_enforces_world_up() {
|
||||
let ctrl = CameraController::default();
|
||||
let mut cam = Camera::new(Vec3::ZERO, Vec3::ZERO, Vec3::X); // odd up
|
||||
ctrl.apply_to(&mut cam);
|
||||
assert!(cam.up == Vec3::Y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! # Lights Module — CPU-side Global Light List (Phase 4.2, Steps 12–13)
|
||||
//!
|
||||
//! Holds the scene's global light list — directional, point and spot lights — in a CPU-side
|
||||
//! [`Lights`] group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each
|
||||
//! frame by `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is
|
||||
//! lit by the same list (per-material lights are out of scope, a later performance/feature step).
|
||||
//!
|
||||
//! ## Rangement (no type flag)
|
||||
//! Directional lights occupy indices `0..num_directional`; point lights occupy
|
||||
//! `num_directional..num_directional + num_point`; spot lights occupy
|
||||
//! `num_directional + num_point..`. The index alone disambiguates the type in the fragment shader,
|
||||
//! so no type field is stored in [`Light`].
|
||||
//!
|
||||
//! ## Non-regression
|
||||
//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white
|
||||
//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`.
|
||||
|
||||
use crate::resources::uniform::{Light, MAX_LIGHTS};
|
||||
use glam::{Vec3, Vec4};
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Lights {
|
||||
/// Directional lights (indices `0..len` in the frame array).
|
||||
pub directional: Vec<Light>,
|
||||
/// Point lights (indices `num_directional..` in the frame array).
|
||||
pub point: Vec<Light>,
|
||||
/// Spot lights (indices `num_directional + num_point..` in the frame array).
|
||||
pub spot: Vec<Light>,
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
color: Vec4::ONE,
|
||||
radius: Vec4::ZERO,
|
||||
dir_angle: Vec4::ZERO,
|
||||
}],
|
||||
point: Vec::new(),
|
||||
spot: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of lights (directional + point + spot).
|
||||
pub fn len(&self) -> usize {
|
||||
self.directional.len() + self.point.len() + self.spot.len()
|
||||
}
|
||||
|
||||
/// `true` when there are no lights at all.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
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).
|
||||
pub fn get(&self, index: usize) -> Option<&Light> {
|
||||
let n_dir = self.directional.len();
|
||||
if index < n_dir {
|
||||
return self.directional.get(index);
|
||||
}
|
||||
let index = index - n_dir;
|
||||
let n_point = self.point.len();
|
||||
if index < n_point {
|
||||
return self.point.get(index);
|
||||
}
|
||||
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).
|
||||
pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) {
|
||||
let empty = Light {
|
||||
position_dir: Vec4::ZERO,
|
||||
color: Vec4::ZERO,
|
||||
radius: Vec4::ZERO,
|
||||
dir_angle: Vec4::ZERO,
|
||||
};
|
||||
let mut array = [empty; MAX_LIGHTS];
|
||||
for (i, l) in self.directional.iter().enumerate() {
|
||||
array[i] = *l;
|
||||
}
|
||||
let n_dir = self.directional.len();
|
||||
for (i, l) in self.point.iter().enumerate() {
|
||||
array[n_dir + i] = *l;
|
||||
}
|
||||
let n_point = self.point.len();
|
||||
for (i, l) in self.spot.iter().enumerate() {
|
||||
array[n_dir + n_point + i] = *l;
|
||||
}
|
||||
(array, n_dir as u32, n_point as u32, self.spot.len() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
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`.
|
||||
pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light {
|
||||
Light {
|
||||
position_dir: dir.extend(0.0),
|
||||
color: Vec4::new(color[0], color[1], color[2], intensity),
|
||||
radius: Vec4::ZERO,
|
||||
dir_angle: Vec4::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light {
|
||||
Light {
|
||||
position_dir: pos.extend(0.0),
|
||||
color: Vec4::new(color[0], color[1], color[2], intensity),
|
||||
radius: Vec4::new(radius, 0.0, 0.0, 0.0),
|
||||
dir_angle: Vec4::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub fn spot_light(
|
||||
pos: Vec3,
|
||||
dir: Vec3,
|
||||
color: [f32; 3],
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
half_angle: f32,
|
||||
) -> Light {
|
||||
Light {
|
||||
position_dir: pos.extend(0.0),
|
||||
color: Vec4::new(color[0], color[1], color[2], intensity),
|
||||
radius: Vec4::new(radius, 0.0, 0.0, 0.0),
|
||||
dir_angle: dir.normalize().extend(half_angle.cos()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_has_one_directional() {
|
||||
let lights = Lights::new();
|
||||
assert_eq!(lights.directional.len(), 1);
|
||||
assert_eq!(lights.point.len(), 0);
|
||||
assert_eq!(lights.spot.len(), 0);
|
||||
assert_eq!(lights.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_frame_array_packs_directional_point_then_spot() {
|
||||
let mut lights = Lights::new(); // 1 directional
|
||||
lights
|
||||
.point
|
||||
.push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0));
|
||||
lights.spot.push(spot_light(
|
||||
Vec3::new(2.0, 0.0, 0.0),
|
||||
Vec3::new(-1.0, 0.0, 0.0),
|
||||
[0.0, 1.0, 0.0],
|
||||
1.0,
|
||||
3.0,
|
||||
0.3,
|
||||
));
|
||||
let (array, n_dir, n_point, n_spot) = lights.into_frame_array();
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_bounded_by_max_lights() {
|
||||
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 = 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"
|
||||
);
|
||||
// Sanity: the buggy expression (dot of l with the axis) would be ~ -1.
|
||||
assert!((l.dot(cone_axis) + 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,74 @@
|
||||
//! # Material Module — Appearance Descriptor (shader_id → RenderPipeline)
|
||||
//! # Material Module — Appearance Descriptor (shader_id → RenderPipeline + diffuse texture)
|
||||
//!
|
||||
//! Defines `Material`, a lightweight appearance descriptor that pairs a shader identifier with
|
||||
//! a shared RenderPipeline. Materials are created via PipelineCache to ensure pipeline reuse—
|
||||
//! a shared RenderPipeline and, since Step 10 (DRAFT D4), an optional diffuse `Texture` plus the
|
||||
//! matching group-2 bind group. Materials are created via PipelineCache to ensure pipeline reuse—
|
||||
//! multiple materials referencing the same shader_id point to the identical compiled GPU pipeline.
|
||||
//!
|
||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
||||
//! - **Identifiants**: Each Material is registered in Scene by string identifier, enabling dynamic access
|
||||
//! during the render loop without borrow checker issues. The shader_id serves as the `Handle<T>` key.
|
||||
//! - **Phase de Déclaration**: Materials are instantiated once in the declarative phase before the render loop begins.
|
||||
//! - **Declaration Phase**: Materials are instantiated once in the declarative phase before the render loop begins.
|
||||
//! - **Texture (Step 10, D4)**: the appearance lives on the Material. A texture-less Material binds
|
||||
//! the shared white placeholder (DRAFT D1/D2), so every pipeline layout (`@group(2)`) is satisfied.
|
||||
|
||||
use crate::pipeline::PipelineCache;
|
||||
use crate::resources::Texture;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Lightweight appearance descriptor: links a shader ID to a shared RenderPipeline.
|
||||
/// Does not own the pipeline; holds an Arc for zero-copy sharing across objects using the same shader.
|
||||
/// 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.
|
||||
pub struct Material {
|
||||
/// Unique shader identifier used to look up or create a compiled RenderPipeline in PipelineCache.
|
||||
pub shader_id: String,
|
||||
/// Shared reference to the compiled GPU render pipeline. Multiple Materials can share one through Arc cloning.
|
||||
pub pipeline: Arc<wgpu::RenderPipeline>,
|
||||
/// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2).
|
||||
pub texture: Option<Arc<Texture>>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// Creates a new Material by requesting the cache to provide (or create) its RenderPipeline.
|
||||
/// Creates a new Material by requesting the cache to provide (or create) its RenderPipeline, and
|
||||
/// building a group-2 texture bind group that binds the **white placeholder** (no diffuse texture).
|
||||
/// Inputs: format (surface texture format required for fragment output), shader_id (unique key into PipelineCache),
|
||||
/// cache (mutable reference for potential insertion of new pipelines).
|
||||
/// Returns a Material holding the Arc-wrapped pipeline. Called at scene initialization time only.
|
||||
pub fn new(format: wgpu::TextureFormat, shader_id: &str, cache: &mut PipelineCache) -> Self {
|
||||
// Request pipeline from cache — returns cached instance if already exists, creates new otherwise
|
||||
Self::build(format, shader_id, None, cache)
|
||||
}
|
||||
|
||||
/// Creates a Material with a diffuse texture: compiles/retrieves the pipeline and builds a
|
||||
/// group-2 texture bind group that samples `texture` (DRAFT D4). Inputs: format, shader_id,
|
||||
/// texture (the diffuse texture to sample), cache. Returns the texturized Material.
|
||||
pub fn new_with_texture(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
texture: Arc<Texture>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
Self::build(format, shader_id, Some(texture), cache)
|
||||
}
|
||||
|
||||
/// Shared construction: requests the pipeline from the cache, then builds the group-2 texture
|
||||
/// bind group from `texture` (or the cache placeholder when `None`). The bind group is created
|
||||
/// right here so it exactly matches the shared layout, keeping « un seul layout pour tous » (D1).
|
||||
fn build(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
texture: Option<Arc<Texture>>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self {
|
||||
let pipeline = cache.get_or_create(format, shader_id);
|
||||
let texture_bind_group = cache.texture_bind_group(texture.clone());
|
||||
Self {
|
||||
shader_id: shader_id.to_string(),
|
||||
pipeline,
|
||||
texture,
|
||||
texture_bind_group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+346
-33
@@ -1,61 +1,374 @@
|
||||
//! # Mesh Module — Persistent GPU Geometry Container
|
||||
//!
|
||||
//! Defines `Mesh`, a persistent GPU geometry container. Mesh data is uploaded to the GPU once at creation time
|
||||
//! and remains valid across all frames until dropped. It holds no rendering knowledge—only raw geometric data.
|
||||
//! and remains valid across all frames until dropped. Since Step 8 (DRAFT Step 8.3), a Mesh also retains the
|
||||
//! CPU geometry it was built from (`geometry: Arc<Geometry>`), giving meshes a shared, readable source of truth
|
||||
//! for phases such as bounding-box culling and UV access. Since Step 7, a Mesh may also hold a reference to the
|
||||
//! `Material` that draws it — the appearance lives on the Mesh rather than on the `Entity`.
|
||||
//!
|
||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
||||
//! - **Identifiants**: Each Mesh is registered in Scene by string identifier, enabling dynamic access
|
||||
//! during the render loop without borrow checker issues. The identifier serves as the `Handle<T>` key.
|
||||
//! - **Phase de Déclaration**: Meshes are instantiated once in the declarative phase before the render loop begins.
|
||||
//! - **Declaration Phase**: Meshes are instantiated once in the declarative phase before the render loop begins.
|
||||
//! - **Performance**: Multiple entities can reference the same Mesh, reducing memory footprint for repeated geometry.
|
||||
//! - **CPU+GPU retention (DRAFT Step 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
|
||||
//! The GPU buffers are uploaded once at creation; the `Arc<Geometry>` is kept for CPU-side computations
|
||||
//! without re-uploading per frame.
|
||||
//! - **LOD packing (Step 19, D7)**: a multi-level mesh packs ALL its levels into **one** vertex buffer and
|
||||
//! **one** index buffer (level k lives at a byte offset), because WebGPU forbids dynamic offsets on
|
||||
//! vertex/index bindings — only the draw ARGS move per frame. The per-level offsets live in the
|
||||
//! `LodRow`s (uploaded to the GPU LOD table); the shadow/main passes always bind level 0.
|
||||
//!
|
||||
//! ## Construction (DRAFT Step 8, D4; Step 19, D6/D7)
|
||||
//! The single canonical constructor is [`Mesh::from_geometry_lod`] (levels + mode); [`Mesh::from_geometry`]
|
||||
//! is its one-level convenience wrapper. The former `Mesh::new`/`Mesh::with_material`
|
||||
//! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and
|
||||
//! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`.
|
||||
|
||||
use crate::resources::vertex::Vertex;
|
||||
use crate::core::Geometry;
|
||||
use crate::resources::Material;
|
||||
use crate::resources::Vertex;
|
||||
use crate::resources::uniform::LodRow;
|
||||
use std::sync::Arc;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// Persistent GPU geometry: vertex positions, optional indices, and draw call counters.
|
||||
/// Created once via `Mesh::new()` during scene setup; referenced by Renderer for every frame.
|
||||
/// How a mesh's LOD levels were produced (Step 19, D6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LodMode {
|
||||
/// No LOD: a single level (the plain `create_mesh` path).
|
||||
#[default]
|
||||
Off,
|
||||
/// Levels 1.. were auto-generated by quadric edge collapse (`Geometry::decimated`, D10).
|
||||
Auto,
|
||||
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
|
||||
Explicit,
|
||||
}
|
||||
|
||||
/// Persistent GPU geometry: vertex positions, optional indices, draw call counters, and the retained
|
||||
/// CPU `Geometry` (Step 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
|
||||
/// Renderer for every frame. A Mesh optionally references the `Material` used to render it (`Option<Arc<Material>>`).
|
||||
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Step 7.3.5).
|
||||
///
|
||||
/// Since Step 19 a Mesh may carry several LOD levels: they are packed into the single vertex/index
|
||||
/// buffers (D7) and described by `lod_rows` (uploaded per mesh as a [`crate::resources::uniform::LodTable`]).
|
||||
pub struct Mesh {
|
||||
/// GPU buffer containing vertex attribute data (position, UV, color).
|
||||
/// Shared CPU geometry of level 0 (the full mesh, Step 8, D5). Retained for CPU-side computation
|
||||
/// (bounding boxes, UV access, normal queries) and shared across meshes with identical geometry.
|
||||
geometry: Arc<Geometry>,
|
||||
/// GPU buffer containing the packed vertex data of ALL levels (one `Vertex` per position, levels
|
||||
/// concatenated in level order; level 0 first).
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
/// Optional GPU buffer for indexed drawing. Present when the mesh uses index-based rendering instead of simple vertex iteration.
|
||||
/// Optional GPU buffer with the packed indices of all indexed levels (rebased onto the packed
|
||||
/// vertex layout). `None` when no level is indexed.
|
||||
pub index_buffer: Option<wgpu::Buffer>,
|
||||
/// Number of vertices in the mesh. Used as `0..num_vertices` for non-indexed draws.
|
||||
/// Number of vertices of level 0. Used as `0..num_vertices` for non-indexed draws.
|
||||
pub num_vertices: u32,
|
||||
/// Number of indices in the index buffer. Used as `0..num_indices` for indexed draws.
|
||||
/// Number of indices of level 0. Used as `0..num_indices` for indexed draws.
|
||||
pub num_indices: u32,
|
||||
/// The Material used to render this mesh. `None` until assigned; the Renderer falls back to the
|
||||
/// Scene's default material when absent (DRAFT Step 7.3.5).
|
||||
material: Option<Arc<Material>>,
|
||||
/// How the levels were produced (Step 19, D6).
|
||||
lod_mode: LodMode,
|
||||
/// The CPU geometry of every level, level 0 first (all retained; `geometry` is `lod_levels[0]`).
|
||||
lod_levels: Vec<Arc<Geometry>>,
|
||||
/// The packed-buffer offsets per level (mirrors the uploaded per-mesh LOD table).
|
||||
lod_rows: Vec<LodRow>,
|
||||
}
|
||||
|
||||
/// Error returned by [`pack_levels`] when the packed vertex total exceeds the u16 index range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PackError {
|
||||
/// Total packed vertices (all levels) — must be < 65536 for u16 rebased indices.
|
||||
pub total_vertices: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PackError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"packed LOD vertex total {} exceeds the 65535 u16 index limit",
|
||||
self.total_vertices
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PackError {}
|
||||
|
||||
/// Pure packing of LOD levels (Step 19, D7) — no GPU, unit-testable.
|
||||
///
|
||||
/// Concatenates every level's interleaved vertices into one flat slice (level 0 first) and rebases
|
||||
/// every indexed level's indices onto the packed vertex layout. Returns the packed vertices, the
|
||||
/// packed indices (`None` when no level is indexed), and one [`LodRow`] per level carrying its
|
||||
/// **element** offsets + counts. Offsets are in ELEMENT units (vertex indices / index elements),
|
||||
/// not bytes: the GPU cull pass copies them straight into the WebGPU indirect draw args, whose
|
||||
/// `first_vertex`/`first_index` fields are element indices — the packed buffers are bound in full
|
||||
/// (offset 0) and only the args' first_* fields move per level.
|
||||
///
|
||||
/// Fails with [`PackError`] when the **total** packed vertex count reaches 65536 (u16 rebased
|
||||
/// indices cannot address it). Callers (the Scene APIs) validate the total beforehand and report
|
||||
/// the error to the user; a single-level mesh can never fail (its indices are already u16).
|
||||
pub(crate) fn pack_levels(
|
||||
levels: &[Arc<Geometry>],
|
||||
) -> Result<(Vec<Vertex>, Option<Vec<u16>>, Vec<LodRow>), PackError> {
|
||||
let total: u32 = levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(PackError {
|
||||
total_vertices: total,
|
||||
});
|
||||
}
|
||||
|
||||
let mut vertices: Vec<Vertex> = Vec::new();
|
||||
let mut indices: Vec<u16> = Vec::new();
|
||||
let mut any_indexed = false;
|
||||
let mut rows: Vec<LodRow> = Vec::with_capacity(levels.len());
|
||||
|
||||
let mut vertex_base = 0u32; // element (vertex index) base of the level in the packed buffer
|
||||
let mut index_base = 0u32; // element (index element) base of the level in the packed buffer
|
||||
for level in levels {
|
||||
let level_vertices = level.to_vertices();
|
||||
// Element units (NOT bytes): the row's offsets feed the WebGPU indirect draw args
|
||||
// (first_vertex = vertex index, first_index = index element) — see the doc above.
|
||||
let level_vertex_offset = vertex_base;
|
||||
let level_vertex_count = level_vertices.len() as u32;
|
||||
|
||||
let (level_index_offset, level_index_count) = match level.indices() {
|
||||
Some(data) => {
|
||||
any_indexed = true;
|
||||
for &idx in data {
|
||||
// Safe: the total-vertex check above guarantees no u16 overflow.
|
||||
indices.push(idx as u32 as u16 + vertex_base as u16);
|
||||
}
|
||||
(index_base, data.len() as u32)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
rows.push(LodRow::new(
|
||||
level_vertex_offset,
|
||||
level_vertex_count,
|
||||
level_index_offset,
|
||||
level_index_count,
|
||||
));
|
||||
vertex_base += level_vertex_count;
|
||||
index_base += level_index_count;
|
||||
vertices.extend(level_vertices);
|
||||
}
|
||||
|
||||
Ok((vertices, any_indexed.then_some(indices), rows))
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Creates a new Mesh by uploading vertex and optional index data to GPU buffers.
|
||||
/// Inputs: device (GPU command source for buffer creation), vertices (CPU-side vertex array to upload),
|
||||
/// indices (optional CPU-side index array for indexed drawing).
|
||||
/// Returns a Mesh with two GPU buffers ready for rendering. Called at scene initialization time only.
|
||||
/// Internal steps: 1) create_buffer_init for vertex data →
|
||||
/// 2) if indices provided: create_buffer_init for index data and set num_indices = len →
|
||||
/// else: set index_buffer = None and num_indices = 0.
|
||||
pub fn new(device: &wgpu::Device, vertices: &[Vertex], indices: Option<&[u16]>) -> Self {
|
||||
/// Canonical constructor (Step 8, D4, now D6/D7): builds the packed GPU buffers from a list of
|
||||
/// LOD levels (level 0 = the full mesh, always present).
|
||||
///
|
||||
/// All levels are packed into ONE vertex buffer and ONE index buffer (D7 — WebGPU forbids dynamic
|
||||
/// offsets on vertex/index bindings; only the draw args move). `num_vertices`/`num_indices`
|
||||
/// describe **level 0** (the shadow and main passes always bind level 0); the per-level draw
|
||||
/// arguments are emitted by the GPU cull pass from the uploaded LOD table.
|
||||
pub fn from_geometry_lod(
|
||||
device: &wgpu::Device,
|
||||
levels: Vec<Arc<Geometry>>,
|
||||
material: Option<Arc<Material>>,
|
||||
mode: LodMode,
|
||||
) -> Self {
|
||||
assert!(!levels.is_empty(), "a mesh needs at least level 0");
|
||||
// Packing can only fail when the packed vertex total reaches 65536; the Scene APIs
|
||||
// validate that beforehand. A single level (from_geometry) can never fail.
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&levels).expect("packed LOD vertex total exceeds the u16 limit");
|
||||
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Vertex Buffer"),
|
||||
contents: bytemuck::cast_slice(vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
label: Some("Mesh Vertex Buffer (packed LOD)"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
|
||||
// Create optional index buffer and count indices if provided
|
||||
let (index_buffer, num_indices) = if let Some(data) = indices {
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Index Buffer"),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
contents: bytemuck::cast_slice(data),
|
||||
});
|
||||
(Some(buffer), data.len() as u32)
|
||||
} else {
|
||||
(None, 0)
|
||||
};
|
||||
let index_buffer = indices.map(|data| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Index Buffer (packed LOD)"),
|
||||
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_SRC,
|
||||
contents: bytemuck::cast_slice(&data),
|
||||
})
|
||||
});
|
||||
|
||||
let l0 = &levels[0];
|
||||
Self {
|
||||
geometry: Arc::clone(l0),
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
num_vertices: vertices.len() as u32,
|
||||
num_indices,
|
||||
num_vertices: l0.positions.len() as u32,
|
||||
num_indices: l0.indices().map(|i| i.len() as u32).unwrap_or(0),
|
||||
material,
|
||||
lod_mode: mode,
|
||||
lod_levels: levels,
|
||||
lod_rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-level convenience constructor (Step 8, D4): builds GPU buffers from a shared CPU
|
||||
/// `Geometry` (no LOD — `LodMode::Off`, `lod_rows` has a single row).
|
||||
pub fn from_geometry(
|
||||
device: &wgpu::Device,
|
||||
geometry: Arc<Geometry>,
|
||||
material: Option<Arc<Material>>,
|
||||
) -> Self {
|
||||
Self::from_geometry_lod(device, vec![geometry], material, LodMode::Off)
|
||||
}
|
||||
|
||||
/// Returns a reference to the level-0 CPU geometry this mesh was built from (Step 8, D5).
|
||||
/// Read-only accessor for CPU-side queries (bounding boxes, UVs, normals).
|
||||
pub fn geometry(&self) -> &Arc<Geometry> {
|
||||
&self.geometry
|
||||
}
|
||||
|
||||
/// Returns a reference to the Material attached to this mesh, if any.
|
||||
/// When `None`, the Renderer falls back to the Scene's default material at draw time.
|
||||
pub fn material(&self) -> Option<&Arc<Material>> {
|
||||
self.material.as_ref()
|
||||
}
|
||||
|
||||
/// Attaches (or replaces) the Material used to render this mesh.
|
||||
/// Called by `Scene::create_mesh` during scene setup or by advanced users linking geometry manually.
|
||||
pub fn set_material(&mut self, material: Arc<Material>) {
|
||||
self.material = Some(material);
|
||||
}
|
||||
|
||||
/// How this mesh's LOD levels were produced (Step 19, D6).
|
||||
pub fn lod_mode(&self) -> LodMode {
|
||||
self.lod_mode
|
||||
}
|
||||
|
||||
/// Number of LOD levels (1 for a plain mesh).
|
||||
pub fn num_lod_levels(&self) -> usize {
|
||||
self.lod_rows.len()
|
||||
}
|
||||
|
||||
/// The per-level packed-buffer rows (level 0 first).
|
||||
pub fn lod_rows(&self) -> &[LodRow] {
|
||||
&self.lod_rows
|
||||
}
|
||||
|
||||
/// The CPU geometry of level k (0 = full mesh). Used by `Scene::add_mesh_lod` when
|
||||
/// reconstructing a mesh with a modified level list.
|
||||
pub fn lod_levels_arc(&self, k: usize) -> Arc<Geometry> {
|
||||
Arc::clone(&self.lod_levels[k])
|
||||
}
|
||||
|
||||
/// The per-mesh GPU LOD table (uploaded once by the Renderer; read by the GPU cull pass).
|
||||
pub fn lod_table(&self) -> crate::resources::uniform::LodTable {
|
||||
crate::resources::uniform::LodTable::from_rows(&self.lod_rows)
|
||||
}
|
||||
|
||||
/// Whether **level 0** is indexed (the shadow/main passes always bind level 0).
|
||||
pub fn l0_indexed(&self) -> bool {
|
||||
self.lod_rows
|
||||
.first()
|
||||
.map(|r| r.index_count > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesh::primitives;
|
||||
|
||||
#[test]
|
||||
fn pack_levels_offsets_and_rebasing() {
|
||||
// Two levels: L0 = icosahedron (12 verts / 60 indices), L1 = decimated to 10 (welded).
|
||||
let l0 = Arc::new(primitives::icosphere(1.0, 0));
|
||||
let l1 = Arc::new(l0.decimated(10));
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&[Arc::clone(&l0), Arc::clone(&l1)]).expect("small mesh packs");
|
||||
|
||||
// Packed vertices = L0 + L1 concatenated.
|
||||
assert_eq!(vertices.len(), l0.positions.len() + l1.positions.len());
|
||||
// Packed indices = 60 + L1's (rebased by L0's vertex count).
|
||||
let packed_indices = indices.expect("both levels indexed");
|
||||
assert_eq!(packed_indices.len(), 60 + l1.indices().unwrap().len());
|
||||
// L0 indices unchanged (rebase base 0); L1 indices rebased by 12.
|
||||
for (i, idx) in l0.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed_indices[i], *idx);
|
||||
}
|
||||
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed_indices[60 + j], *idx + 12);
|
||||
}
|
||||
|
||||
// Rows carry the ELEMENT offsets (vertex indices / index elements, not bytes).
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].vertex_offset, 0);
|
||||
assert_eq!(rows[0].vertex_count, 12);
|
||||
assert_eq!(rows[0].index_offset, 0);
|
||||
assert_eq!(rows[0].index_count, 60);
|
||||
assert_eq!(rows[1].vertex_offset, 12);
|
||||
assert_eq!(rows[1].vertex_count, l1.positions.len() as u32);
|
||||
assert_eq!(rows[1].index_offset, 60);
|
||||
assert_eq!(rows[1].index_count, l1.indices().unwrap().len() as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_mixed_indexedness() {
|
||||
// Non-indexed L0 (6 verts) + indexed L1 (welded) — the Auto-mode case from DRAFT D7.
|
||||
let l0 = Arc::new(
|
||||
Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, 2.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, -2.0, 0.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 3, 4, 5]),
|
||||
);
|
||||
// Force L0 non-indexed: strip the indices.
|
||||
let l0_nonidx = Arc::new(Geometry::new(l0.positions.clone()));
|
||||
let l1 = Arc::new(l0.decimated(1)); // welded + indexed
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&[Arc::clone(&l0_nonidx), Arc::clone(&l1)]).expect("small mesh packs");
|
||||
assert_eq!(vertices.len(), 6 + l1.positions.len());
|
||||
let packed = indices.expect("an indexed level exists");
|
||||
// L0 contributes no indices; L1's are rebased by 6.
|
||||
assert_eq!(packed.len(), l1.indices().unwrap().len());
|
||||
assert_eq!(rows[0].index_count, 0, "non-indexed L0 row");
|
||||
assert_eq!(rows[1].index_offset, 0, "L1 is the first indexed level");
|
||||
assert_eq!(rows[1].vertex_offset, 6, "element units, not bytes");
|
||||
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed[j], *idx + 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_all_non_indexed() {
|
||||
let l0 = Arc::new(Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]));
|
||||
let (vertices, indices, rows) = pack_levels(&[l0.clone()]).expect("small mesh packs");
|
||||
assert_eq!(vertices.len(), 3);
|
||||
assert!(indices.is_none());
|
||||
assert_eq!(rows[0].index_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_table_from_rows() {
|
||||
let l0 = Arc::new(primitives::icosphere(1.0, 0));
|
||||
let l1 = Arc::new(l0.decimated(10));
|
||||
let (_, _, rows) = pack_levels(&[l0, l1]).expect("small mesh packs");
|
||||
let table = crate::resources::uniform::LodTable::from_rows(&rows);
|
||||
assert_eq!(table.count, 2);
|
||||
assert_eq!(table.rows[0].index_count, 60);
|
||||
assert_eq!(table.rows[1].vertex_count, rows[1].vertex_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_rejects_u16_overflow() {
|
||||
// A level with 70k vertices (indexed) cannot be packed with u16 rebased indices.
|
||||
let big = Arc::new(
|
||||
Geometry::new(vec![[0.0, 0.0, 0.0]; 70_000]).with_indices(vec![0u16; 70_000 / 3 * 3]),
|
||||
);
|
||||
let err = pack_levels(&[big]).unwrap_err();
|
||||
assert_eq!(err.total_vertices, 70_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
//! # Resources Module — Data Types
|
||||
//!
|
||||
//! Defines the three core data types that flow through the rendering pipeline: **Vertex** (CPU-side per-attribute
|
||||
//! tuple), **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 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.
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `pipeline_cache::build_pipeline()` reads Vertex field offsets to construct the vertex buffer layout.
|
||||
//! - `mesh::new()` uploads Vertex arrays from CPU memory into GPU vertex buffers via DeviceExt::create_buffer_init().
|
||||
//! - `mesh::from_geometry()` derives `Vertex` arrays from a `Geometry` and uploads them into GPU vertex
|
||||
//! buffers via DeviceExt::create_buffer_init().
|
||||
//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization.
|
||||
|
||||
pub mod camera;
|
||||
pub mod lights;
|
||||
pub mod material;
|
||||
pub mod mesh;
|
||||
pub mod texture;
|
||||
pub mod uniform;
|
||||
pub mod vertex;
|
||||
|
||||
// Re-exports
|
||||
pub use camera::Camera;
|
||||
pub use camera::{Camera, CameraController, PITCH_LIMIT};
|
||||
pub use lights::Lights;
|
||||
pub use material::Material;
|
||||
pub use mesh::Mesh;
|
||||
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,
|
||||
};
|
||||
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.
|
||||
pub use crate::core::Geometry;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! # Texture Module — GPU Diffuse Texture (device + view + sampler)
|
||||
//!
|
||||
//! Defines `Texture`, the GPU representation of a diffuse image: the backing `wgpu::Texture`,
|
||||
//! its `TextureView` (for sampling in the shader) and its `Sampler` (filtering/address mode).
|
||||
//! Added in Step 10 (DRAFT D3) to texturize the standard shader via bind group `@group(2)`.
|
||||
//!
|
||||
//! ## Architecture Notes (per DRAFT Step 10, D3/D4)
|
||||
//! - **Format**: `Rgba8UnormSrgb` (espace sRGB, correct pour une couleur diffuse).
|
||||
//! - **Usage**: `TEXTURE_BINDING | COPY_DST` (sampled in the fragment, filled by CPU upload).
|
||||
//! - **Mipmaps**: single level (`mip_level_count: 1` — YAGNI, no mipmap generation at this step).
|
||||
//! - **Sampler**: `Linear` + `Repeat` (smooth filtering, classic UV coordinates).
|
||||
//! - **Placeholder**: a white 1×1 texture (multiplicative-identity texel) serves the texture-less
|
||||
//! `Material` — see `white_placeholder`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// GPU diffuse texture format (DRAFT D3): sRGB 8-bit RGBA. Matches the color space expected for
|
||||
/// sampled diffuse albedo and the fragment bind group layout (`texture_2d<f32>`).
|
||||
pub const TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
|
||||
/// Errors produced while decoding an image into a `Texture`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TextureError {
|
||||
/// The image could not be decoded by the `image` crate (corrupt/unsupported file).
|
||||
#[error("failed to decode image: {0}")]
|
||||
Decode(#[from] image::ImageError),
|
||||
/// The pixel buffer chunk (DRW) is empty — nothing to upload.
|
||||
#[error("no pixel data provided to build the texture")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// GPU diffuse texture: backing texture, sampling view and sampler. Immutable after creation,
|
||||
/// shared (behind `Arc`) by `Material`s via the Scene resource depot (Step 10, D4).
|
||||
pub struct Texture {
|
||||
/// Backing GPU image, kept alive for the whole lifetime of the texture.
|
||||
_texture: wgpu::Texture,
|
||||
/// Sampling view of the backing image, bound into the group-2 bind group.
|
||||
pub view: wgpu::TextureView,
|
||||
/// Sampler (filtering + address mode) used to sample the texture in the shader.
|
||||
pub sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
/// The core constructor: uploads raw RGBA8 pixels into a `Rgba8UnormSrgb` 2D texture.
|
||||
/// This is the primitive used by `from_bytes`/`from_file` (after decoding) and by
|
||||
/// `white_placeholder`. Inputs: device (GPU), queue (for `write_texture`), width (px),
|
||||
/// height (px), rgba (raw 4-bytes-per-pixel data, `width * height * 4` long), label (debug).
|
||||
/// Returns the texture, or `Err(TextureError::Empty)` if `rgba` is empty.
|
||||
pub fn from_rgba8(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: &[u8],
|
||||
label: &str,
|
||||
) -> Result<Self, TextureError> {
|
||||
if rgba.is_empty() {
|
||||
return Err(TextureError::Empty);
|
||||
}
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1, // YAGNI: no mipmaps at this step (DRAFT D3)
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: TEXTURE_FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
rgba,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4 * width),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
address_mode_w: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Ok(Self {
|
||||
_texture: texture,
|
||||
view,
|
||||
sampler,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes an encoded image (PNG/JPEG via the `image` crate) from a byte slice and uploads it.
|
||||
/// Inputs: device (GPU), queue (write target), label (debug), bytes (encoded image data).
|
||||
/// Returns the decoded+uploaded texture, or `Err(TextureError)` on decode/empty failure.
|
||||
pub fn from_bytes(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Self, TextureError> {
|
||||
let img = image::load_from_memory(bytes)?;
|
||||
// Normalize to RGBA8 (downsamples Luma8/Rgb8 to RGBA8, as Rgba8UnormSrgb requires).
|
||||
let rgba = img.to_rgba8();
|
||||
Self::from_rgba8(device, queue, rgba.width(), rgba.height(), &rgba, label)
|
||||
}
|
||||
|
||||
/// Reads a file from `path` and uploads its pixels via [`Texture::from_bytes`].
|
||||
/// Inputs: device (GPU), queue (write target), label (debug), path (image file on disk).
|
||||
/// Returns the texture, or `Err(TextureError)` if the file cannot be read/decoded.
|
||||
pub fn from_file(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
path: &str,
|
||||
) -> Result<Self, TextureError> {
|
||||
let bytes =
|
||||
std::fs::read(path).map_err(|e| TextureError::Decode(image::ImageError::IoError(e)))?;
|
||||
Self::from_bytes(device, queue, label, &bytes)
|
||||
}
|
||||
|
||||
/// Builds the white 1×1 placeholder used by `Material`s without a texture (DRAFT D1/D2).
|
||||
/// A white texel is the multiplicative identity: sampling it leaves the vertex color
|
||||
/// unchanged, so a texture-less material renders exactly as before (no breakage in
|
||||
/// unlit vertex-colored geometry). Inputs: device, queue. Returns the 1×1 white texture.
|
||||
pub fn white_placeholder(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
||||
Self::from_rgba8(
|
||||
device,
|
||||
queue,
|
||||
1,
|
||||
1,
|
||||
&[255, 255, 255, 255],
|
||||
"default white texture",
|
||||
)
|
||||
.expect("1×1 white placeholder must not be empty")
|
||||
}
|
||||
|
||||
/// Shared convenience wrapper so `Arc<Texture>` can be created ergonomically by callers.
|
||||
pub(crate) fn arc(self) -> Arc<Texture> {
|
||||
Arc::new(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! # Uniform Module — GPU Buffer Data Types
|
||||
//!
|
||||
//! Defines the CPU-side `Pod` (plain old data) structs that are uploaded to GPU uniform buffers.
|
||||
//! Their memory layout must match **exactly** the WGSL uniforms declared in `standard_shader.wgsl`
|
||||
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding.
|
||||
//!
|
||||
//! Two bind groups are shared by every pipeline (single-layout decision, Step 3):
|
||||
//! - `@group(0) @binding(0)`: `FrameUniforms` (per-frame: camera + lights + shadow) → 784 bytes
|
||||
//! - `@group(1) @binding(0)`: `ObjectUniform` (per-entity model matrix) → 64 bytes
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `pipeline_cache::build_pipeline()` creates the two bind group layouts matching these types.
|
||||
//! - `Renderer` allocates the buffers and `BindGroup`s from these types and writes them each frame.
|
||||
//! - `standard_shader.wgsl` consumes them (layout identical to these structs).
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use glam::{Mat4, Vec4};
|
||||
|
||||
/// Byte size of the per-frame uniform buffer (`FrameUniforms`).
|
||||
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
|
||||
/// Byte size of the per-object uniform buffer (`ObjectUniform`).
|
||||
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
|
||||
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Step 14).
|
||||
pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64;
|
||||
|
||||
/// Maximum number of lights stored in the per-frame uniform buffer.
|
||||
/// 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`).
|
||||
#[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.
|
||||
pub position_dir: Vec4,
|
||||
/// rgb = color; a = intensity (multiplier).
|
||||
pub color: Vec4,
|
||||
/// x = attenuation radius (point/spot lights); 0 for directional.
|
||||
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.
|
||||
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).
|
||||
#[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,
|
||||
/// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation
|
||||
/// radius, `dir_angle` = 0.
|
||||
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,
|
||||
}
|
||||
|
||||
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`].
|
||||
pub fn light_type(&self) -> LightType {
|
||||
if self.dir_angle.w > 0.0 {
|
||||
LightType::Spot
|
||||
} else if self.radius.x > 0.0 {
|
||||
LightType::Point
|
||||
} else {
|
||||
LightType::Directional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct FrameUniforms {
|
||||
/// Camera view matrix (world → view space). Offset 0.
|
||||
pub view: Mat4,
|
||||
/// Camera projection matrix (view → clip space). Offset 64.
|
||||
pub proj: Mat4,
|
||||
/// Camera world position (`.xyz` used). Offset 128.
|
||||
pub cam_pos: Vec4,
|
||||
/// Ambient hemisphere color (`.rgb` used). Offset 144.
|
||||
pub ambient: Vec4,
|
||||
/// Global light list: directionals, then point, then spot. Offset 160.
|
||||
pub lights: [Light; MAX_LIGHTS],
|
||||
/// Number of active directional lights (indices `0..num_directional`).
|
||||
/// Offset 160 + 64·MAX_LIGHTS.
|
||||
pub num_directional: u32,
|
||||
/// Number of active point lights (indices after the directionals).
|
||||
pub num_point: u32,
|
||||
/// Number of active spot lights (indices after the point lights).
|
||||
pub num_spot: u32,
|
||||
/// Index (in the packed frame array) of the single shadow-casting light (DRAFT Step 14, D1).
|
||||
/// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12.
|
||||
pub shadow_light_index: u32,
|
||||
/// View-projection matrix of the shadow-casting light (world → light clip space), used to
|
||||
/// reproject fragments into the shadow map (DRAFT Step 14, D3). Offset 176 + 64·MAX_LIGHTS.
|
||||
pub light_view_proj: Mat4,
|
||||
/// Shadow sampling parameters (DRAFT Step 14, D5). `x` = shadow map size in pixels (for
|
||||
/// texel-space PCF offsets), `y` = depth bias, `z`/`w` reserved. Offset 240 + 64·MAX_LIGHTS.
|
||||
pub shadow_params: Vec4,
|
||||
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting);
|
||||
/// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside
|
||||
/// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS.
|
||||
pub options: [u32; 4],
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
/// Sensible defaults: identity camera, white ambient, a single white directional light along
|
||||
/// +Z (from surface toward light), *lit* mode — reproduces the pre-multi-light look exactly.
|
||||
/// No point or spot lights.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
view: Mat4::IDENTITY,
|
||||
proj: Mat4::IDENTITY,
|
||||
cam_pos: Vec4::ZERO,
|
||||
ambient: Vec4::ONE,
|
||||
lights: [Light {
|
||||
position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z
|
||||
color: Vec4::ONE,
|
||||
radius: Vec4::ZERO,
|
||||
dir_angle: Vec4::ZERO,
|
||||
}; MAX_LIGHTS],
|
||||
num_directional: 1,
|
||||
num_point: 0,
|
||||
num_spot: 0,
|
||||
// Shadows off by default (Step 14, D7 — non-regression): sentinel = MAX_LIGHTS.
|
||||
shadow_light_index: MAX_LIGHTS as u32,
|
||||
light_view_proj: Mat4::IDENTITY,
|
||||
shadow_params: Vec4::ZERO,
|
||||
options: [0, 0, 0, 0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-object GPU uniforms: the entity's world-space model matrix.
|
||||
///
|
||||
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct ObjectUniform {
|
||||
/// Model matrix (object → world space). Offset 0.
|
||||
pub model: Mat4,
|
||||
}
|
||||
|
||||
/// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
|
||||
/// view-projection matrix. Mirrors the WGSL `ShadowUniform` struct in `shadow_shader.wgsl`.
|
||||
/// 64 bytes, `Pod`, bound as group 0 of the shadow pipeline.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct ShadowUniform {
|
||||
/// Light view-projection matrix (world → light clip space). Offset 0.
|
||||
pub view_proj: Mat4,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 3 — GPU-driven entity slot buffers (Step 15)
|
||||
// ============================================================================
|
||||
//
|
||||
// CPU-side `Pod` mirrors of the GPU buffer element types declared in `gpu_driven.wgsl` (see that
|
||||
// file's "GPU buffer layouts" note). Their byte layout must match the WGSL structs **exactly** —
|
||||
// including the 16-byte alignment of `vec3` (std140), which is why `vec3` fields below carry an
|
||||
// explicit `_pad` so the Rust offsets line up with the WGSL ones.
|
||||
//
|
||||
// All buffers are fixed-capacity (`MAX_ENTITIES`), allocated once. Per frame the CPU rewrites only
|
||||
// the transform slots + cull uniforms; the matrices and indirect draw args are written by the GPU.
|
||||
|
||||
/// Byte size of one GPU entity transform slot (`TransformSlot`).
|
||||
pub const TRANSFORM_SLOT_SIZE: u64 = std::mem::size_of::<TransformSlot>() as u64;
|
||||
/// Byte size of one GPU world-matrix slot (`MatSlot`).
|
||||
pub const MAT_SLOT_SIZE: u64 = std::mem::size_of::<MatSlot>() as u64;
|
||||
/// Byte size of one GPU local-space bounding box (`BBoxSlot`).
|
||||
pub const BBOX_SLOT_SIZE: u64 = std::mem::size_of::<BBoxSlot>() as u64;
|
||||
/// Byte size of one GPU indirect draw-args slot (`DrawSlot`).
|
||||
pub const DRAW_SLOT_SIZE: u64 = std::mem::size_of::<DrawSlot>() as u64;
|
||||
/// Byte size of the GPU cull/uniform block (`CullUniforms`).
|
||||
pub const CULL_UNIFORMS_SIZE: u64 = std::mem::size_of::<CullUniforms>() as u64;
|
||||
|
||||
/// A packed entity transform slot (64 bytes) — the single CPU→GPU source of truth for world
|
||||
/// matrices (Step 15, D13). Mirrors the WGSL `TransformSlot`.
|
||||
///
|
||||
/// Layout (std140, 16-byte aligned): translation (vec3 @0) + pad, flags (vec4 @16), rotation
|
||||
/// (vec4 @32), scale (vec3 @48) + pad → 64 bytes.
|
||||
///
|
||||
/// `flags` packing: x = mesh index (stable index into the mesh list), y = active (0/1),
|
||||
/// z = draw count (vertex or index count for this mesh), w = has_index (0/1).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct TransformSlot {
|
||||
/// World translation (xyz). Offset 0.
|
||||
pub translation: [f32; 3],
|
||||
_pad0: [f32; 1],
|
||||
/// Packed flags: x = mesh index, y = active, z = draw count, w = has_index. Offset 16.
|
||||
pub flags: [f32; 4],
|
||||
/// Rotation quaternion (x, y, z, w). Offset 32.
|
||||
pub rotation: [f32; 4],
|
||||
/// Non-uniform scale (xyz). Offset 48.
|
||||
pub scale: [f32; 3],
|
||||
_pad1: [f32; 1],
|
||||
}
|
||||
|
||||
impl TransformSlot {
|
||||
/// Builds an active transform slot from a CPU [`crate::core::Transform`] + the mesh's draw
|
||||
/// metadata. `mesh_index` / `draw_count` are packed into `flags`; `active` is 1.
|
||||
pub fn from_transform(
|
||||
t: &crate::core::Transform,
|
||||
mesh_index: u32,
|
||||
draw_count: u32,
|
||||
has_index: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
translation: t.translation.to_array(),
|
||||
_pad0: [0.0; 1],
|
||||
flags: [
|
||||
mesh_index as f32,
|
||||
1.0,
|
||||
draw_count as f32,
|
||||
if has_index { 1.0 } else { 0.0 },
|
||||
],
|
||||
rotation: [t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w],
|
||||
scale: t.scale.to_array(),
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an inactive (tombstone) slot: `active` = 0; the GPU writes identity and skips the draw.
|
||||
pub fn inactive() -> Self {
|
||||
Self {
|
||||
translation: [0.0; 3],
|
||||
_pad0: [0.0; 1],
|
||||
flags: [0.0, 0.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
scale: [0.0; 3],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable index of the entity's mesh (from `flags.x`).
|
||||
pub fn mesh_index(&self) -> u32 {
|
||||
self.flags[0] as u32
|
||||
}
|
||||
|
||||
/// Draw count (vertex or index count) packed in `flags.z`.
|
||||
pub fn draw_count(&self) -> u32 {
|
||||
self.flags[2] as u32
|
||||
}
|
||||
|
||||
/// Whether the entity's mesh is indexed (from `flags.w`).
|
||||
pub fn has_index(&self) -> bool {
|
||||
self.flags[3] >= 0.5
|
||||
}
|
||||
|
||||
/// Whether the slot is active (from `flags.y`).
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.flags[1] >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// A 4x4 world matrix (64 B) followed by 192 B of padding = **256 B** total. The padding is
|
||||
/// REQUIRED: the render pipelines read this slot through the `uniform` object group with a
|
||||
/// per-slot dynamic offset, and WebGPU demands that offset be a multiple of
|
||||
/// `min_uniform_buffer_offset_alignment` (256 B). A bare 64-byte matrix can never be individually
|
||||
/// addressable via a uniform offset, so each slot is padded to a 256-byte boundary (capacity is
|
||||
/// capped at 256 = 64 KB / 256 B). Derived on the GPU by `compute_matrices` (Step 15). Mirrors the
|
||||
/// WGSL `MatSlot`. (No `Default`: the matrices are GPU-written, so the CPU never constructs a
|
||||
/// `MatSlot` — this type exists only to fix the buffer's slot size. `pad` is `[f32; 48]`, beyond
|
||||
/// the `N ≤ 32` bound of the array `Default` impl, so `Default` cannot be derived.)
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct MatSlot {
|
||||
/// The world matrix (object → world), column-major. Offset 0.
|
||||
pub m: Mat4,
|
||||
/// 192 bytes of padding (alignment only, never read). Offset 64.
|
||||
pub pad: [f32; 48],
|
||||
}
|
||||
|
||||
/// A local-space axis-aligned bounding box (32 bytes), uploaded once per mesh (Step 15).
|
||||
/// Mirrors the WGSL `BBoxSlot` (min vec3 @0 + pad, max vec3 @16 + pad).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct BBoxSlot {
|
||||
/// Bounding box minimum corner (xyz). Offset 0.
|
||||
pub min: [f32; 3],
|
||||
_pad0: [f32; 1],
|
||||
/// Bounding box maximum corner (xyz). Offset 16.
|
||||
pub max: [f32; 3],
|
||||
_pad1: [f32; 1],
|
||||
}
|
||||
|
||||
impl BBoxSlot {
|
||||
/// Builds a slot from a CPU [`crate::core::BBox`] (padding zeroed).
|
||||
pub fn from_bbox(b: &crate::core::BBox) -> Self {
|
||||
Self {
|
||||
min: b.min,
|
||||
max: b.max,
|
||||
_pad0: [0.0; 1],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate (all-zero) box, used as the placeholder for a mesh with no bounding box.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
min: [0.0; 3],
|
||||
max: [0.0; 3],
|
||||
_pad0: [0.0; 1],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indirect draw arguments for one entity (80 bytes = five u32 vec4s). The shader writes only `.a`;
|
||||
/// the rest stays zero (the constant instance count of 1 lives in `.a.y`). Mirrors the WGSL `DrawSlot`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct DrawSlot {
|
||||
/// `.a` = (count, instance_count, first_vertex/first_index, base_vertex); `.b..e` = 0.
|
||||
pub a: [u32; 4],
|
||||
/// Reserved (first_instance for the indexed block). Kept zero.
|
||||
pub b: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub c: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub d: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub e: [u32; 4],
|
||||
}
|
||||
|
||||
/// Per-frame GPU cull/uniform block (112 bytes), rewritten by the CPU each frame (Step 15).
|
||||
/// Mirrors the WGSL `CullUniforms`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct CullUniforms {
|
||||
/// Six frustum planes, unit (normal, d); inside the frustum iff dot(p, normal) + d >= 0.
|
||||
pub planes: [[f32; 4]; 6],
|
||||
/// Number of live entity slots (slots at/ beyond this are no-ops).
|
||||
pub num_slots: u32,
|
||||
/// 0 = culling disabled (every active entity draws), 1 = enabled (sphere test).
|
||||
pub culling: u32,
|
||||
_pad: [u32; 2],
|
||||
}
|
||||
|
||||
impl CullUniforms {
|
||||
/// Builds the cull block from six frustum planes (each a unit `[normal; d]` `[f32; 4]`) + the
|
||||
/// control flags. `num_slots` = number of live entity slots.
|
||||
pub fn new(planes: [[f32; 4]; 6], num_slots: u32, culling: bool) -> Self {
|
||||
Self {
|
||||
planes,
|
||||
num_slots,
|
||||
culling: if culling { 1 } else { 0 },
|
||||
_pad: [0; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the cull block directly from a [`crate::core::Frustum`] (its six unit planes).
|
||||
pub fn from_frustum(f: &crate::core::Frustum, num_slots: u32, culling: bool) -> Self {
|
||||
Self::new(f.planes, num_slots, culling)
|
||||
}
|
||||
}
|
||||
|
||||
/// Size in bytes of one [`LodRow`] (16 B = 4 u32), matching WGSL `LodRow`.
|
||||
pub const LOD_ROW_SIZE: u64 = 16;
|
||||
|
||||
/// Size in bytes of one [`LodTable`] (80 B = 5 × 16 B), matching WGSL `LodTable`.
|
||||
pub const LOD_TABLE_SIZE: u64 = 80;
|
||||
|
||||
/// One LOD level of a mesh's **packed** vertex/index buffers (Step 19, D7) — the per-level
|
||||
/// draw offsets the GPU cull pass needs to emit the level's indirect draw args.
|
||||
/// Mirrors the WGSL `LodRow` (16 bytes: `vec4<u32>`).
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
|
||||
pub struct LodRow {
|
||||
/// **Element** offset of this level's vertices within the mesh's packed vertex buffer
|
||||
/// (a vertex index, not a byte offset — the WebGPU `drawIndirectNonIndexed` first_vertex
|
||||
/// is a vertex index; it goes straight into the indirect args' `first_vertex`).
|
||||
pub vertex_offset: u32,
|
||||
/// Number of vertices of this level.
|
||||
pub vertex_count: u32,
|
||||
/// **Element** offset of this level's indices within the mesh's packed index buffer
|
||||
/// (an index element, not a byte offset — the `drawIndirectIndexed` first_index is an
|
||||
/// index element; 0 when the level is non-indexed or no earlier level has indices).
|
||||
pub index_offset: u32,
|
||||
/// Number of indices of this level (0 when non-indexed); the draw count.
|
||||
pub index_count: u32,
|
||||
}
|
||||
|
||||
impl LodRow {
|
||||
/// Builds a row from the packed-buffer offsets the packer computed.
|
||||
/// `index_offset`/`index_count` are 0 for a non-indexed level.
|
||||
pub fn new(vertex_offset: u32, vertex_count: u32, index_offset: u32, index_count: u32) -> Self {
|
||||
Self {
|
||||
vertex_offset,
|
||||
vertex_count,
|
||||
index_offset,
|
||||
index_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-mesh LOD table (Step 19, D7): one [`LodRow`] per level, uploaded once per mesh and
|
||||
/// read by the GPU cull pass to map a CPU-decided level to indirect draw args. Level 0
|
||||
/// **always** exists and is byte-exact with the full mesh. Mirrors the WGSL `LodTable`
|
||||
/// (80 bytes = count @0 + 4 × 16-byte rows @16..80, each row a `vec4<u32>`).
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
|
||||
pub struct LodTable {
|
||||
/// Number of valid levels (1 = no LOD, single level).
|
||||
pub count: u32,
|
||||
/// Padding so the rows start at byte 16 (WGSL `LodTable`: `count : u32` @0, a 12-byte pad,
|
||||
/// then the `array<LodRow, 4>` @16 — the WGSL pad is an `array<u32, 3>` (alignment 4, NOT
|
||||
/// `vec3<u32>` which would align to 16 and grow the struct to 96 bytes).
|
||||
pub _pad: [u32; 3],
|
||||
/// One 16-byte row per level (`LOD_ROW_SIZE`-spaced), zeroed beyond `count`.
|
||||
pub rows: [LodRow; crate::utils::conf::MAX_LOD_LEVELS as usize],
|
||||
}
|
||||
|
||||
impl LodTable {
|
||||
/// Builds a table from up to `MAX_LOD_LEVELS` rows (row 0 = level 0 = full mesh).
|
||||
pub fn from_rows(rows: &[LodRow]) -> Self {
|
||||
let mut table = Self::default();
|
||||
table._pad = [0; 3];
|
||||
table.count = rows.len() as u32;
|
||||
for (i, row) in rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(crate::utils::conf::MAX_LOD_LEVELS as usize)
|
||||
{
|
||||
table.rows[i] = *row;
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::mem::offset_of;
|
||||
use std::mem::{align_of, size_of};
|
||||
|
||||
#[test]
|
||||
fn frame_uniforms_layout_matches_wgsl() {
|
||||
// The offsets below must match the offset table in standard_shader.wgsl.
|
||||
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
|
||||
// light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
|
||||
// Total = 160 + 64·8 + 16 + 112 = 784 bytes.
|
||||
assert_eq!(size_of::<FrameUniforms>(), 784);
|
||||
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
|
||||
assert_eq!(align_of::<FrameUniforms>(), 16);
|
||||
|
||||
let f = FrameUniforms::default();
|
||||
assert_eq!(offset_of!(FrameUniforms, view), 0);
|
||||
assert_eq!(offset_of!(FrameUniforms, proj), 64);
|
||||
assert_eq!(offset_of!(FrameUniforms, cam_pos), 128);
|
||||
assert_eq!(offset_of!(FrameUniforms, ambient), 144);
|
||||
assert_eq!(offset_of!(FrameUniforms, lights), 160);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, num_directional),
|
||||
160 + 64 * MAX_LIGHTS
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, num_point),
|
||||
160 + 64 * MAX_LIGHTS + 4
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, num_spot),
|
||||
160 + 64 * MAX_LIGHTS + 8
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, shadow_light_index),
|
||||
160 + 64 * MAX_LIGHTS + 12
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, light_view_proj),
|
||||
160 + 64 * MAX_LIGHTS + 16
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, shadow_params),
|
||||
160 + 64 * MAX_LIGHTS + 80
|
||||
);
|
||||
assert_eq!(
|
||||
offset_of!(FrameUniforms, options),
|
||||
160 + 64 * MAX_LIGHTS + 96
|
||||
);
|
||||
// Default is lit mode (unlit flag cleared), one directional light, no point/spot lights,
|
||||
// shadows off (sentinel = MAX_LIGHTS).
|
||||
assert_eq!(f.options[0], 0);
|
||||
assert_eq!(f.num_directional, 1);
|
||||
assert_eq!(f.num_point, 0);
|
||||
assert_eq!(f.num_spot, 0);
|
||||
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_uniform_layout_matches_wgsl() {
|
||||
assert_eq!(size_of::<ObjectUniform>(), 64);
|
||||
assert_eq!(align_of::<ObjectUniform>(), 16);
|
||||
assert_eq!(offset_of!(ObjectUniform, model), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_slot_layouts_match_wgsl() {
|
||||
// TransformSlot: translation @0 (vec3+pad), flags @16, rotation @32, scale @48 (vec3+pad) -> 64.
|
||||
assert_eq!(size_of::<TransformSlot>(), 64);
|
||||
assert_eq!(offset_of!(TransformSlot, translation), 0);
|
||||
assert_eq!(offset_of!(TransformSlot, flags), 16);
|
||||
assert_eq!(offset_of!(TransformSlot, rotation), 32);
|
||||
assert_eq!(offset_of!(TransformSlot, scale), 48);
|
||||
|
||||
// MatSlot: one 4x4 column-major matrix (64 B) + 192 B padding -> 256 (padded to the
|
||||
// uniform offset alignment; see the struct doc). m @0, pad @64.
|
||||
assert_eq!(size_of::<MatSlot>(), 256);
|
||||
assert_eq!(align_of::<MatSlot>(), 16);
|
||||
assert_eq!(offset_of!(MatSlot, m), 0);
|
||||
assert_eq!(offset_of!(MatSlot, pad), 64);
|
||||
|
||||
// BBoxSlot: min @0 (vec3+pad), max @16 (vec3+pad) -> 32.
|
||||
assert_eq!(size_of::<BBoxSlot>(), 32);
|
||||
assert_eq!(offset_of!(BBoxSlot, min), 0);
|
||||
assert_eq!(offset_of!(BBoxSlot, max), 16);
|
||||
|
||||
// DrawSlot: five u32 vec4s -> 80 (a multiple of both 16 and 20, per the WebGPU indirect rule).
|
||||
assert_eq!(size_of::<DrawSlot>(), 80);
|
||||
|
||||
// CullUniforms: 6 planes (96) + num_slots @96 + culling @100 + pad -> 112.
|
||||
assert_eq!(size_of::<CullUniforms>(), 112);
|
||||
assert_eq!(offset_of!(CullUniforms, planes), 0);
|
||||
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
|
||||
assert_eq!(offset_of!(CullUniforms, culling), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_layouts_match_wgsl() {
|
||||
// LodRow: four u32 -> 16 (a vec4<u32> in WGSL).
|
||||
assert_eq!(size_of::<LodRow>(), 16);
|
||||
assert_eq!(LOD_ROW_SIZE, 16);
|
||||
assert_eq!(offset_of!(LodRow, vertex_offset), 0);
|
||||
assert_eq!(offset_of!(LodRow, vertex_count), 4);
|
||||
assert_eq!(offset_of!(LodRow, index_offset), 8);
|
||||
assert_eq!(offset_of!(LodRow, index_count), 12);
|
||||
|
||||
// LodTable: count @0 + 4 rows @16 -> 80.
|
||||
assert_eq!(size_of::<LodTable>(), 80);
|
||||
assert_eq!(LOD_TABLE_SIZE, 80);
|
||||
assert_eq!(offset_of!(LodTable, count), 0);
|
||||
assert_eq!(offset_of!(LodTable, rows), 16);
|
||||
|
||||
// from_rows: count + rows filled, rest zeroed.
|
||||
let table = LodTable::from_rows(&[LodRow {
|
||||
vertex_offset: 0,
|
||||
vertex_count: 100,
|
||||
index_offset: 0,
|
||||
index_count: 300,
|
||||
}]);
|
||||
assert_eq!(table.count, 1);
|
||||
assert_eq!(table.rows[0].vertex_count, 100);
|
||||
assert_eq!(table.rows[1], LodRow::default());
|
||||
|
||||
// MAX_LOD_LEVELS rows fit exactly.
|
||||
let table = LodTable::from_rows(&vec![
|
||||
LodRow::default();
|
||||
crate::utils::conf::MAX_LOD_LEVELS as usize
|
||||
]);
|
||||
assert_eq!(table.count, crate::utils::conf::MAX_LOD_LEVELS);
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,4 @@ The `scene` module defines Scene, the declarative layer of the WSG architecture.
|
||||
|
||||
## Architecture Note
|
||||
|
||||
Per [ARCHI_APP](../../docs/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
|
||||
Per [ARCHI_APP](../../../docs/tech/ARCHI_APP.md), Scene is one half of the "App" facade pattern. It enables a declarative workflow where all resources are declared before the render loop begins, while keeping the freedom to build the engine "brick by brick" through direct Context/PipelineCache/Renderer manipulation.
|
||||
|
||||
+9
-20
@@ -1,18 +1,19 @@
|
||||
//! # Entity Module
|
||||
//!
|
||||
//! Defines `Entity`, the renderable association between a Mesh and a Material together with its
|
||||
//! own world-space `Transform`. Each entry of `Scene::entities` is an `Entity`: it references the
|
||||
//! resource by identifier while carrying the per-entity placement data.
|
||||
//! Defines `Entity`, the renderable association between a Mesh and its own world-space `Transform`.
|
||||
//! Since Step 7 (DRAFT Step 7.3) the appearance (Material) lives **on the Mesh**, so an `Entity` only
|
||||
//! references the mesh by identifier and carries the per-entity placement. Each entry of `Scene::entities`
|
||||
//! is an `Entity`.
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `scene::Scene` stores entities in a `HashMap<String, Entity>` keyed by label.
|
||||
//! - `math::Transform` provides the placement (translation / rotation / scale) converted to a
|
||||
//! matrix during rendering.
|
||||
//! - `resources::{Mesh, Material}` are the referenced render resources, resolved by `Scene`.
|
||||
//! - `resources::Mesh` is the referenced render resource, resolved by `Scene`; its Material is read by the Renderer.
|
||||
|
||||
use crate::math::Transform;
|
||||
use crate::core::Transform;
|
||||
|
||||
/// A renderable entity: a mesh + material pair with its own world-space transform.
|
||||
/// A renderable entity: a mesh (with its own material) and a world-space transform.
|
||||
///
|
||||
/// Entities are created through [`crate::scene::Scene::add_entity`] (identity transform) or
|
||||
/// [`crate::scene::Scene::add_entity_with_transform`]. Fields are exposed via accessors.
|
||||
@@ -20,23 +21,16 @@ use crate::math::Transform;
|
||||
pub struct Entity {
|
||||
/// Identifier of the referenced Mesh resource.
|
||||
mesh_id: String,
|
||||
/// Identifier of the referenced Material resource.
|
||||
material_id: String,
|
||||
/// World-space placement of this entity.
|
||||
transform: Transform,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
/// Creates a new entity associating a mesh and a material under the given transform.
|
||||
/// Creates a new entity referencing a mesh under the given transform.
|
||||
/// Called internally by `Scene::add_entity*` after resource existence is validated.
|
||||
pub fn new(
|
||||
mesh_id: impl Into<String>,
|
||||
material_id: impl Into<String>,
|
||||
transform: Transform,
|
||||
) -> Self {
|
||||
pub fn new(mesh_id: impl Into<String>, transform: Transform) -> Self {
|
||||
Self {
|
||||
mesh_id: mesh_id.into(),
|
||||
material_id: material_id.into(),
|
||||
transform,
|
||||
}
|
||||
}
|
||||
@@ -46,11 +40,6 @@ impl Entity {
|
||||
&self.mesh_id
|
||||
}
|
||||
|
||||
/// Returns the identifier of the referenced Material resource.
|
||||
pub fn material_id(&self) -> &str {
|
||||
&self.material_id
|
||||
}
|
||||
|
||||
/// Returns a reference to this entity's world-space transform.
|
||||
pub fn transform(&self) -> &Transform {
|
||||
&self.transform
|
||||
|
||||
@@ -21,4 +21,4 @@ pub mod scene;
|
||||
|
||||
// Re-export
|
||||
pub use entity::Entity;
|
||||
pub use scene::Scene;
|
||||
pub use scene::{Scene, SlotDraw};
|
||||
|
||||
+817
-39
@@ -4,41 +4,582 @@
|
||||
//! the render loop starts, then associate entities via labels. At runtime, Scene provides immutable access to these resources without exposing raw wgpu handles.
|
||||
//!
|
||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
||||
//! - **La Recette**: Scene is central to the "App" facade workflow. In the Phase de Déclaration, users call add_mesh(), add_material(), and add_entity()
|
||||
//! to build the resource depot. During Phase d'Exécution, Renderer iterates Scene entities for rendering.
|
||||
//! - **The Recipe**: Scene is central to the "App" facade workflow. In the Declaration Phase, users call add_mesh(), add_material(), and add_entity()
|
||||
//! to build the resource depot. During the Execution Phase, Renderer iterates Scene entities for rendering.
|
||||
//! - **Identifiants**: All resource registration uses string identifiers (`Handle<T>`/String pattern), guaranteeing memory safety
|
||||
//! and avoiding borrow checker issues during dynamic updates.
|
||||
//! - **Ergonomie**: Users interact only with entity-level operations (add/remove/get) rather than wgpu buffers/pipelines directly.
|
||||
//!
|
||||
//! ## Step 7 — Pipeline context owned by the Scene (DRAFT Step 7.1)
|
||||
//! Since Step 7 the Scene owns the GPU-facing material pipeline context (`SceneGpu`: device + format + `PipelineCache`)
|
||||
//! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject
|
||||
//! a default material for meshes that carry none (`default_material`).
|
||||
|
||||
use crate::math::Transform;
|
||||
use crate::resources::{Material, Mesh};
|
||||
use crate::core::{Geometry, Transform};
|
||||
use crate::pipeline::PipelineCache;
|
||||
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
|
||||
use crate::scene::Entity;
|
||||
use glam::Vec3;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// GPU-facing context the Scene needs to build materials and meshes by itself. Held in
|
||||
/// `Scene.gpu` and populated once by `Scene::init_gpu` after the `Context`/`Renderer` exist
|
||||
/// (during `AppRunner::resumed`, before `AppHandler::setup`). The `cache` is interior-mutable
|
||||
/// (`RefCell`) so a default material can be built lazily from an immutable `&Scene` at render time.
|
||||
struct SceneGpu {
|
||||
/// Shared GPU device used to create mesh buffers and compile pipelines.
|
||||
device: Arc<wgpu::Device>,
|
||||
/// Surface texture output format, required to build fragment pipelines.
|
||||
format: wgpu::TextureFormat,
|
||||
/// Shader compilation cache: compiles/caches RenderPipelines keyed by shader_id + format.
|
||||
cache: RefCell<PipelineCache>,
|
||||
}
|
||||
|
||||
/// A stable, append-only slot for an entity in the GPU-driven slot buffers (Phase 3, Step 15).
|
||||
/// Slots are **never freed**: removing an entity leaves a tombstone (its label drops out of the
|
||||
/// `entities` map) so that slot indices stay stable across frames and the fixed-capacity GPU buffers
|
||||
/// can be indexed by a constant slot index. The transform itself is read from the `entities` map
|
||||
/// (by `label`) at pack time; the slot only carries the mesh identity + draw metadata, which are
|
||||
/// stable once the entity is (re-)added.
|
||||
#[derive(Clone)]
|
||||
struct EntitySlot {
|
||||
/// Entity label (key into the `entities` map; the transform is read from there each frame).
|
||||
label: String,
|
||||
/// Stable index of the entity's mesh (position in `mesh_order`) — indexes the GPU bbox buffer.
|
||||
mesh_index: u32,
|
||||
/// Draw count for the entity's mesh (vertex count, or index count when indexed) → packed into
|
||||
/// the transform slot's `flags.z`.
|
||||
draw_count: u32,
|
||||
/// Whether the entity's mesh is indexed → packed into the transform slot's `flags.w`.
|
||||
has_index: bool,
|
||||
}
|
||||
|
||||
/// Per-slot draw descriptor for the GPU-driven render loop (Phase 3). Carries everything the
|
||||
/// renderer needs to issue one indirect draw: the slot index (→ indirect-args + matrix buffer
|
||||
/// offset), whether the slot is active (tombstones are skipped on the CPU), the mesh, and whether
|
||||
/// it is indexed. The world matrix is **not** carried here — it is derived on the GPU (Step 15.5)
|
||||
/// and read from the matrix buffer by the render pipeline.
|
||||
#[derive(Clone)]
|
||||
pub struct SlotDraw {
|
||||
/// Stable slot index (offset into the indirect-args and matrix buffers, in slot units).
|
||||
pub slot_index: usize,
|
||||
/// Whether the slot is active (false = tombstone; the CPU skips it, the GPU zeros its draw args).
|
||||
pub active: bool,
|
||||
/// The entity's mesh (vertex/index buffers + material).
|
||||
pub mesh: Arc<Mesh>,
|
||||
/// Whether the mesh is indexed (`draw_indexed_indirect` vs `draw_indirect`).
|
||||
pub has_index: bool,
|
||||
}
|
||||
|
||||
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
|
||||
/// and maps entity labels to their associated `Entity` (mesh + material + transform) for rendering iteration.
|
||||
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
|
||||
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
|
||||
/// Created once during application setup; entities are added before the render loop starts.
|
||||
pub struct Scene {
|
||||
/// Map of mesh identifiers to owned `Arc<Mesh>` instances. Populated via `add_mesh()`.
|
||||
meshes: HashMap<String, Arc<Mesh>>,
|
||||
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
|
||||
materials: HashMap<String, Arc<Material>>,
|
||||
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Step 10, D4).
|
||||
/// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id.
|
||||
textures: HashMap<String, Arc<Texture>>,
|
||||
/// Map of entity labels to `Entity` associations. Populated via `add_entity()` / `add_entity_with_transform()`.
|
||||
entities: HashMap<String, Entity>,
|
||||
/// Active camera used for rendering. Read each frame by `Renderer::render_scene` to compute the
|
||||
/// view/projection matrices written into the frame uniform buffer. Replaced via `set_camera()`.
|
||||
camera: Camera,
|
||||
/// Owned pipeline context (device + format + cache), `None` until `init_gpu` is called.
|
||||
gpu: Option<SceneGpu>,
|
||||
/// Lazily-built default `standard` material, cached so `default_material` costs O(1) after the
|
||||
/// first call. Interior-mutable so it can be filled from an immutable `&Scene` (used by the Renderer).
|
||||
default_material: RefCell<Option<Arc<Material>>>,
|
||||
/// Global light list (directional + point), uploaded into the frame uniforms each frame
|
||||
/// (Phase 4.2, Step 12). Default = one white directional light along +Z (non-regression).
|
||||
lights: Lights,
|
||||
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
|
||||
ambient: [f32; 3],
|
||||
/// Optional shadow-casting light index (DRAFT Step 14, D1): the index (in the packed frame
|
||||
/// array: directionals, then points, then spots) of the single light that casts a shadow.
|
||||
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
|
||||
/// to compute the light `view_proj` and enable shadow sampling.
|
||||
shadow_caster: Option<usize>,
|
||||
/// Stable, append-only entity slots for the GPU-driven buffers (Phase 3). Grows only; removed
|
||||
/// entities leave tombstones so slot indices stay stable.
|
||||
entity_slots: Vec<EntitySlot>,
|
||||
/// Map of entity label to slot index (O(1) lookup so a re-added label reuses its slot).
|
||||
slot_of_label: HashMap<String, usize>,
|
||||
/// Ordered mesh identifiers (index = the stable `mesh_index` used by slots and the bbox buffer).
|
||||
mesh_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Creates an empty scene with no registered resources or entities.
|
||||
/// Called at application startup before any resource registration.
|
||||
/// Creates an empty scene with no registered resources or entities and a default camera
|
||||
/// (`Camera::default()`: position (0,0,3), looking at origin, 45° perspective).
|
||||
/// Called at application startup before any resource registration. The GPU pipeline context is
|
||||
/// empty (`gpu: None`) until `init_gpu` is called once the `Context`/`Renderer` exist.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
meshes: HashMap::new(),
|
||||
materials: HashMap::new(),
|
||||
textures: HashMap::new(),
|
||||
entities: HashMap::new(),
|
||||
camera: Camera::default(),
|
||||
gpu: None,
|
||||
default_material: RefCell::new(None),
|
||||
lights: Lights::new(),
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
shadow_caster: None,
|
||||
entity_slots: Vec::new(),
|
||||
slot_of_label: HashMap::new(),
|
||||
mesh_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the GPU-facing pipeline context (device + queue + format + `PipelineCache`) to this
|
||||
/// Scene, enabling it to build materials and meshes itself. Called once during `AppRunner::resumed`,
|
||||
/// just after the `Context`/`Renderer` are created and **before** `AppHandler::setup`, so setup
|
||||
/// can register shaders/materials/textures/meshes/entities using `self`. Returns `&mut self` for chaining.
|
||||
/// Inputs: device — shared GPU device (Arc clone); queue — GPU command queue (used to build the
|
||||
/// geometry); format — surface texture output format.
|
||||
pub fn init_gpu(
|
||||
&mut self,
|
||||
device: Arc<wgpu::Device>,
|
||||
queue: wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> &mut Self {
|
||||
let cache = PipelineCache::new(device.clone(), queue.clone());
|
||||
self.gpu = Some(SceneGpu {
|
||||
device,
|
||||
format,
|
||||
cache: RefCell::new(cache),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the owned GPU pipeline context, panicking with a clear message if it has not been
|
||||
/// initialized yet. Called internally whenever the Scene builds materials/meshes.
|
||||
fn gpu(&self) -> &SceneGpu {
|
||||
self.gpu
|
||||
.as_ref()
|
||||
.expect("scene pipeline not initialized yet — call Scene::init_gpu once after the GPU context is ready")
|
||||
}
|
||||
|
||||
/// Returns the Scene's shared GPU device (used to create mesh buffers and compile pipelines).
|
||||
/// Panics if the Scene's pipeline context has not been initialized (i.e. outside `resumed`).
|
||||
pub fn device(&self) -> &wgpu::Device {
|
||||
self.gpu().device.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the surface texture output format used to build fragment pipelines.
|
||||
/// Panics if the Scene's pipeline context has not been initialized.
|
||||
pub fn format(&self) -> wgpu::TextureFormat {
|
||||
self.gpu().format
|
||||
}
|
||||
|
||||
/// Registers an external WGSL shader file path under a shader id in the Scene's pipeline cache.
|
||||
/// Sugar for `cache().register_shader(id, path)` so callers never touch the cache directly.
|
||||
/// Returns Ok(id) or Err(String) if the id is already registered.
|
||||
pub fn register_shader(&mut self, id: &str, path: &str) -> Result<String, String> {
|
||||
self.gpu().cache.borrow_mut().register_shader(id, path)
|
||||
}
|
||||
|
||||
/// Builds and registers a Material from a shader id, using the Scene's pipeline context
|
||||
/// (format + cache). This is the declarative way to declare an appearance without touching
|
||||
/// `Material::new` or the `PipelineCache` directly. Returns Ok(id) or Err(String) if the id exists.
|
||||
pub fn add_material_shader(&mut self, id: &str, shader_id: &str) -> Result<String, String> {
|
||||
if self.materials.contains_key(id) {
|
||||
return Err(format!("Material ID '{}' already exists.", id));
|
||||
}
|
||||
let mut cache = self.gpu().cache.borrow_mut();
|
||||
let material = Arc::new(Material::new(self.gpu().format, shader_id, &mut cache));
|
||||
drop(cache);
|
||||
self.materials.insert(id.to_string(), material);
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so
|
||||
/// materials can reference it declaratively (Step 10, D4). The texture is wrapped in `Arc` for
|
||||
/// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists.
|
||||
/// Inputs: id (unique identifier), texture (GPU diffuse texture to register).
|
||||
pub fn add_texture(&mut self, id: &str, texture: Texture) -> Result<String, String> {
|
||||
if self.textures.contains_key(id) {
|
||||
return Err(format!("Texture ID '{}' already exists.", id));
|
||||
}
|
||||
self.textures.insert(id.to_string(), Arc::new(texture));
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Retrieves a registered diffuse texture by its identifier, if present. Called by the user to
|
||||
/// read back a texture (or by internals when resolving material↔texture links). Step 10 (D4).
|
||||
pub fn get_texture(&self, id: &str) -> Option<&Arc<Texture>> {
|
||||
self.textures.get(id)
|
||||
}
|
||||
|
||||
/// Builds and registers a Material from a shader id **and** a diffuse texture registered via
|
||||
/// [`Scene::add_texture`]. The material samples `texture_id` (Step 10, D4). Returns Ok(id) or
|
||||
/// Err(String) if the material id exists or the texture id does not. Inputs: id (material id to
|
||||
/// register), shader_id (pipeline key), texture_id (existing texture id in this Scene).
|
||||
pub fn add_material_texture(
|
||||
&mut self,
|
||||
id: &str,
|
||||
shader_id: &str,
|
||||
texture_id: &str,
|
||||
) -> Result<String, String> {
|
||||
if self.materials.contains_key(id) {
|
||||
return Err(format!("Material ID '{}' already exists.", id));
|
||||
}
|
||||
let texture = self
|
||||
.textures
|
||||
.get(texture_id)
|
||||
.ok_or_else(|| format!("Texture '{}' does not exist.", texture_id))?
|
||||
.clone();
|
||||
let mut cache = self.gpu().cache.borrow_mut();
|
||||
let material = Arc::new(Material::new_with_texture(
|
||||
self.gpu().format,
|
||||
shader_id,
|
||||
texture,
|
||||
&mut cache,
|
||||
));
|
||||
drop(cache);
|
||||
self.materials.insert(id.to_string(), material);
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Builds, (optionally) links to a Material, and registers a Mesh in one declarative call.
|
||||
/// Since Step 8 the mesh is declared from a CPU `Geometry` (DRAFT Step 8, D4) instead of raw
|
||||
/// `&[Vertex]`. This builds the shared `Arc<Geometry>` and creates the GPU buffers via
|
||||
/// `Mesh::from_geometry(device, arc, ...)`, then — if `material` is `Some(name)` — resolves that
|
||||
/// material id and attaches it to the mesh (`Mesh::set_material`). When `material` is `None`, the
|
||||
/// mesh carries no material and the Scene's `default_material` is used at draw time.
|
||||
/// Returns Ok(id) or Err(String) if the id exists or the named material does not.
|
||||
pub fn create_mesh(
|
||||
&mut self,
|
||||
id: &str,
|
||||
geometry: Geometry,
|
||||
material: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if self.meshes.contains_key(id) {
|
||||
return Err(format!("Mesh ID '{}' already exists.", id));
|
||||
}
|
||||
let mut mesh = Mesh::from_geometry(self.device(), Arc::new(geometry), None);
|
||||
if let Some(name) = material {
|
||||
let mat = self
|
||||
.materials
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
|
||||
.clone();
|
||||
mesh.set_material(mat);
|
||||
}
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
|
||||
/// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are
|
||||
/// auto-generated by quadric edge collapse (`Geometry::generate_lod_levels`, D10) at halving
|
||||
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
|
||||
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
|
||||
///
|
||||
/// Inputs: id (unique mesh id), geometry (level 0 — the full mesh), material (optional
|
||||
/// material id, same rule as [`create_mesh`]), levels (2..=MAX_LOD_LEVELS).
|
||||
/// Returns `Err` if the id exists, the material is unknown, `levels` is out of range, or the
|
||||
/// packed vertex total exceeds the u16 index limit (65535).
|
||||
pub fn create_mesh_with_lod(
|
||||
&mut self,
|
||||
id: &str,
|
||||
geometry: Geometry,
|
||||
material: Option<&str>,
|
||||
levels: u8,
|
||||
) -> Result<String, String> {
|
||||
use crate::utils::conf::MAX_LOD_LEVELS;
|
||||
if self.meshes.contains_key(id) {
|
||||
return Err(format!("Mesh ID '{}' already exists.", id));
|
||||
}
|
||||
if levels < 2 || u32::from(levels) > MAX_LOD_LEVELS {
|
||||
return Err(format!(
|
||||
"LOD levels must be in 2..={MAX_LOD_LEVELS} (got {levels})."
|
||||
));
|
||||
}
|
||||
let lod_levels: Vec<Arc<Geometry>> = geometry
|
||||
.generate_lod_levels(levels)
|
||||
.into_iter()
|
||||
.map(Arc::new)
|
||||
.collect();
|
||||
let total: u32 = lod_levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(format!(
|
||||
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit; use fewer levels or a smaller mesh."
|
||||
));
|
||||
}
|
||||
let mut mesh = Mesh::from_geometry_lod(
|
||||
self.device(),
|
||||
lod_levels,
|
||||
None,
|
||||
crate::resources::LodMode::Auto,
|
||||
);
|
||||
if let Some(name) = material {
|
||||
let mat = self
|
||||
.materials
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
|
||||
.clone();
|
||||
mesh.set_material(mat);
|
||||
}
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Adds (or replaces) an **explicitly provided** LOD level on an existing mesh (Step 19, D6).
|
||||
/// The level is packed into the mesh's buffers alongside the others (D7) and the per-mesh LOD
|
||||
/// table is updated (it is re-uploaded every frame, so the change takes effect next frame).
|
||||
///
|
||||
/// Inputs: id (existing mesh id), level (index ≥ 1; must be ≤ the current level count —
|
||||
/// append at the end or replace in place), geometry (the level's geometry).
|
||||
/// Validation: the level must validate, carry the **same attribute set and indexedness** as
|
||||
/// level 0, keep the packed vertex total under 65536, and stay within `MAX_LOD_LEVELS`.
|
||||
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String> {
|
||||
use crate::utils::conf::MAX_LOD_LEVELS;
|
||||
let current = self
|
||||
.meshes
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Mesh '{}' does not exist.", id))?;
|
||||
let levels_count = current.num_lod_levels();
|
||||
if level < 1 || u32::from(level) > MAX_LOD_LEVELS {
|
||||
return Err(format!(
|
||||
"LOD level must be in 1..={MAX_LOD_LEVELS} (got {level})."
|
||||
));
|
||||
}
|
||||
if level as usize > levels_count {
|
||||
return Err(format!(
|
||||
"Mesh '{}' has {levels_count} level(s); level {level} does not exist and the next free level is {levels_count}.",
|
||||
id
|
||||
));
|
||||
}
|
||||
let l0 = current.geometry();
|
||||
let attr = |g: &Geometry| (g.normals.is_some(), g.uvs.is_some(), g.colors.is_some());
|
||||
if attr(&geometry) != attr(l0) {
|
||||
return Err(format!(
|
||||
"LOD level {level} of mesh '{}' must have the same attribute set (normals/UVs/colors) as level 0.",
|
||||
id
|
||||
));
|
||||
}
|
||||
if geometry.indices().is_some() != l0.indices().is_some() {
|
||||
return Err(format!(
|
||||
"LOD level {level} of mesh '{}' must have the same indexedness as level 0.",
|
||||
id
|
||||
));
|
||||
}
|
||||
geometry
|
||||
.validate()
|
||||
.map_err(|e| format!("LOD level {level} of mesh '{}': {e}", id))?;
|
||||
|
||||
let mut new_levels: Vec<Arc<Geometry>> = (0..levels_count)
|
||||
.map(|i| current.lod_levels_arc(i))
|
||||
.collect();
|
||||
if level as usize == levels_count {
|
||||
if levels_count >= MAX_LOD_LEVELS as usize {
|
||||
return Err(format!(
|
||||
"Mesh '{}' already has the maximum of {MAX_LOD_LEVELS} LOD levels.",
|
||||
id
|
||||
));
|
||||
}
|
||||
new_levels.push(Arc::new(geometry)); // append the next level (L_k at vec index k)
|
||||
} else {
|
||||
new_levels[level as usize] = Arc::new(geometry); // replace L_k in place (vec index k)
|
||||
}
|
||||
let total: u32 = new_levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(format!(
|
||||
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit."
|
||||
));
|
||||
}
|
||||
|
||||
let material = current.material().cloned();
|
||||
let mesh = Mesh::from_geometry_lod(
|
||||
self.device(),
|
||||
new_levels,
|
||||
material,
|
||||
crate::resources::LodMode::Explicit,
|
||||
);
|
||||
// Entities reference the mesh by (stable) index, not by Arc — swapping the Arc is safe.
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The per-mesh LOD tables in `mesh_order` order (one 80-byte table per mesh, level 0 first)
|
||||
/// — the payload of the GPU `lod_tables` buffer, uploaded every frame (Step 19, D7).
|
||||
pub fn mesh_lod_tables(&self) -> Vec<crate::resources::LodTable> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.map(|name| self.meshes[name].lod_table())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
|
||||
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
|
||||
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
|
||||
/// orthogonal `Renderer::set_unlit` flag (DRAFT Step 7.3.5).
|
||||
pub fn default_material(&self) -> Arc<Material> {
|
||||
if let Some(m) = self.default_material.borrow().as_ref() {
|
||||
return m.clone();
|
||||
}
|
||||
let material = Arc::new(Material::new(
|
||||
self.gpu().format,
|
||||
"standard",
|
||||
&mut self.gpu().cache.borrow_mut(),
|
||||
));
|
||||
*self.default_material.borrow_mut() = Some(material.clone());
|
||||
material
|
||||
}
|
||||
|
||||
/// Replaces the scene's active camera. The new camera is used from the next frame onward by
|
||||
/// `Renderer::render_scene` to build the view/projection matrices and the camera position.
|
||||
/// Inputs: camera — the new camera configuration. Call during setup or `AppHandler::update`
|
||||
/// to move/re-orient the view (e.g. orbit or FPS controls).
|
||||
pub fn set_camera(&mut self, camera: Camera) {
|
||||
self.camera = camera;
|
||||
}
|
||||
|
||||
/// Returns a reference to the scene's active camera.
|
||||
/// Called by users to read the current camera (e.g. to move it based on input) and internally by
|
||||
/// `Renderer::render_scene` to upload its matrices.
|
||||
pub fn camera(&self) -> &Camera {
|
||||
&self.camera
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
pub fn camera_mut(&mut self) -> &mut Camera {
|
||||
&mut self.camera
|
||||
}
|
||||
|
||||
/// Adds a directional light (direction **from the surface toward the light**, color, intensity).
|
||||
/// Lights are global to the scene and uploaded into the frame uniforms each frame (Phase 4.2,
|
||||
/// Step 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no
|
||||
/// dynamic UBO allocation). Inputs: dir (direction toward the light source), color (rgb),
|
||||
/// intensity (multiplier).
|
||||
pub fn add_directional_light(
|
||||
&mut self,
|
||||
dir: Vec3,
|
||||
color: [f32; 3],
|
||||
intensity: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights
|
||||
.directional
|
||||
.push(crate::resources::lights::directional_light(
|
||||
dir, color, intensity,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a point light (world position, color, intensity, attenuation radius).
|
||||
/// Returns `Err` if the scene would exceed `MAX_LIGHTS`. Inputs: pos (world position of the
|
||||
/// light), color (rgb), intensity (multiplier), radius (linear falloff to zero at this distance).
|
||||
pub fn add_point_light(
|
||||
&mut self,
|
||||
pos: Vec3,
|
||||
color: [f32; 3],
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights
|
||||
.point
|
||||
.push(crate::resources::lights::point_light(
|
||||
pos, color, intensity, radius,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a spot light (world position, cone axis from the light toward the scene, color,
|
||||
/// intensity, attenuation radius, half-angle in radians). Returns `Err` if the scene would
|
||||
/// exceed `MAX_LIGHTS`. Inputs: pos (world position of the light), dir (cone axis, from the
|
||||
/// light toward the scene), color (rgb), intensity (multiplier), radius (linear falloff to
|
||||
/// zero at this distance), half_angle (cone half-angle in radians).
|
||||
pub fn add_spot_light(
|
||||
&mut self,
|
||||
pos: Vec3,
|
||||
dir: Vec3,
|
||||
color: [f32; 3],
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
half_angle: f32,
|
||||
) -> Result<(), String> {
|
||||
if self.lights.len() >= crate::resources::MAX_LIGHTS {
|
||||
return Err(format!(
|
||||
"Cannot add another light: MAX_LIGHTS ({}) reached.",
|
||||
crate::resources::MAX_LIGHTS
|
||||
));
|
||||
}
|
||||
self.lights.spot.push(crate::resources::lights::spot_light(
|
||||
pos, dir, color, intensity, radius, half_angle,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the scene's global light list. The `Scene` keeps ownership; the list is uploaded
|
||||
/// into the frame uniforms each frame. Used to reset or bulk-configure lighting.
|
||||
pub fn set_lights(&mut self, lights: Lights) {
|
||||
self.lights = lights;
|
||||
}
|
||||
|
||||
/// Returns a reference to the scene's global light list (directional + point).
|
||||
/// Read by `Renderer::render_scene` each frame to upload the light array.
|
||||
pub fn lights(&self) -> &Lights {
|
||||
&self.lights
|
||||
}
|
||||
|
||||
/// Selects the single shadow-casting light by **its index in the packed frame array**
|
||||
/// (directionals first, then point lights, then spots — same order as
|
||||
/// `Lights::into_frame_array`). `None` disables shadows (default, non-regression, Step 14 D7).
|
||||
/// The light must be **directional or spot**; a point light index disables the shadow pass
|
||||
/// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or
|
||||
/// `None` to turn shadows off.
|
||||
pub fn set_shadow_caster(&mut self, index: Option<usize>) {
|
||||
self.shadow_caster = index;
|
||||
}
|
||||
|
||||
/// Returns the index of the scene's shadow-casting light (`None` = shadows off).
|
||||
/// Read by `Renderer::render_scene` each frame to decide whether to run the shadow pass.
|
||||
pub fn shadow_caster(&self) -> Option<usize> {
|
||||
self.shadow_caster
|
||||
}
|
||||
|
||||
/// Removes all lights (directional, point and spot). The fragment shader then contributes
|
||||
/// only the ambient term. Useful for flat look without toggling `unlit`.
|
||||
pub fn clear_lights(&mut self) {
|
||||
self.lights = Lights {
|
||||
directional: Vec::new(),
|
||||
point: Vec::new(),
|
||||
spot: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Sets the ambient hemisphere color (rgb). Default is white.
|
||||
pub fn set_ambient(&mut self, color: [f32; 3]) {
|
||||
self.ambient = color;
|
||||
}
|
||||
|
||||
/// Returns the scene's ambient hemisphere color (rgb).
|
||||
pub fn ambient(&self) -> [f32; 3] {
|
||||
self.ambient
|
||||
}
|
||||
|
||||
/// Registers a Mesh in the scene under a unique identifier.
|
||||
/// Inputs: id (unique key), mesh (Arc-wrapped Mesh instance). Returns Ok(id) on success or Err(String) if already exists.
|
||||
/// Called during scene initialization when building the resource depot.
|
||||
@@ -47,6 +588,7 @@ impl Scene {
|
||||
return Err(format!("Mesh ID '{}' already exists.", id));
|
||||
}
|
||||
self.meshes.insert(id.to_string(), mesh);
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
@@ -61,44 +603,67 @@ impl Scene {
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Associates an entity label with a mesh and material pair for rendering iteration, using an identity transform.
|
||||
/// Inputs: label (entity identifier string), mesh_id (key into meshes map), material_id (key into materials map).
|
||||
/// Returns Ok(label) on success or Err(String) if either referenced resource does not exist.
|
||||
/// 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).
|
||||
/// Inputs: label (entity identifier string), mesh_id (key into meshes map).
|
||||
/// Returns Ok(label) on success or Err(String) if the referenced mesh does not exist.
|
||||
/// Called during scene initialization to build the renderable entity graph.
|
||||
/// Internal steps: 1) validate mesh_id exists → 2) validate material_id exists →
|
||||
/// 3) insert an `Entity` with identity transform into the entities HashMap.
|
||||
pub fn add_entity(
|
||||
&mut self,
|
||||
label: &str,
|
||||
mesh_id: &str,
|
||||
material_id: &str,
|
||||
) -> Result<String, String> {
|
||||
self.add_entity_with_transform(label, mesh_id, material_id, Transform::identity())
|
||||
/// Internal steps: 1) validate mesh_id exists → 2) insert an `Entity` with identity transform.
|
||||
pub fn add_entity(&mut self, label: &str, mesh_id: &str) -> Result<String, String> {
|
||||
self.add_entity_with_transform(label, mesh_id, Transform::identity())
|
||||
}
|
||||
|
||||
/// Associates an entity label with a mesh and material pair together with an explicit world-space transform.
|
||||
/// Inputs: label (entity identifier string), mesh_id (key into meshes map), material_id (key into materials map),
|
||||
/// transform (world-space placement). Returns Ok(label) on success or Err(String) if either referenced resource does not exist.
|
||||
/// Called during scene initialization to build the renderable entity graph.
|
||||
/// Internal steps: 1) validate mesh_id exists → 2) validate material_id exists →
|
||||
/// 3) insert the `Entity` into the entities HashMap.
|
||||
/// Associates an entity label with a mesh together with an explicit world-space transform.
|
||||
/// Inputs: label (entity identifier string), mesh_id (key into meshes map),
|
||||
/// transform (world-space placement). Returns Ok(label) on success or Err(String) if the
|
||||
/// referenced mesh does not exist. Called during scene initialization to build the renderable entity graph.
|
||||
/// Internal steps: 1) validate mesh_id exists → 2) insert the `Entity` into the entities HashMap.
|
||||
pub fn add_entity_with_transform(
|
||||
&mut self,
|
||||
label: &str,
|
||||
mesh_id: &str,
|
||||
material_id: &str,
|
||||
transform: Transform,
|
||||
) -> Result<String, String> {
|
||||
if !self.meshes.contains_key(mesh_id) {
|
||||
return Err(format!("Mesh '{}' does not exist.", mesh_id));
|
||||
}
|
||||
if !self.materials.contains_key(material_id) {
|
||||
return Err(format!("Material '{}' does not exist.", material_id));
|
||||
}
|
||||
self.entities.insert(
|
||||
label.to_string(),
|
||||
Entity::new(mesh_id, material_id, transform),
|
||||
);
|
||||
self.entities
|
||||
.insert(label.to_string(), Entity::new(mesh_id, transform));
|
||||
// Keep the stable slot in sync (Phase 3): a re-added label reuses its slot (stable index);
|
||||
// a new label appends a slot. The mesh index + draw metadata are read from the mesh.
|
||||
let mesh_index = self
|
||||
.mesh_order
|
||||
.iter()
|
||||
.position(|id| id == mesh_id)
|
||||
.expect("mesh validated above") as u32;
|
||||
let mesh = &self.meshes[mesh_id];
|
||||
let has_index = mesh.index_buffer.is_some();
|
||||
let draw_count = if has_index {
|
||||
mesh.num_indices
|
||||
} else {
|
||||
mesh.num_vertices
|
||||
};
|
||||
let slot_index = match self.slot_of_label.get(label) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = self.entity_slots.len();
|
||||
self.slot_of_label.insert(label.to_string(), i);
|
||||
self.entity_slots.push(EntitySlot {
|
||||
label: label.to_string(),
|
||||
mesh_index: 0,
|
||||
draw_count: 0,
|
||||
has_index: false,
|
||||
});
|
||||
i
|
||||
}
|
||||
};
|
||||
self.entity_slots[slot_index] = EntitySlot {
|
||||
label: label.to_string(),
|
||||
mesh_index,
|
||||
draw_count,
|
||||
has_index,
|
||||
};
|
||||
Ok(label.to_string())
|
||||
}
|
||||
|
||||
@@ -114,15 +679,14 @@ impl Scene {
|
||||
self.materials.get(id)
|
||||
}
|
||||
|
||||
/// Iterates all entity associations, yielding (label, mesh_ref, material_ref, transform_ref) tuples.
|
||||
/// Iterates all entity associations, yielding (label, mesh_ref, transform_ref) tuples.
|
||||
/// The Material is **not** yielded here: since Step 7 it is resolved from the Mesh
|
||||
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Step 7.3.4).
|
||||
/// Called by the orchestrator during each render pass to draw every entity in order.
|
||||
pub fn iter_entities(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Arc<Material>, &Transform)> + '_ {
|
||||
pub fn iter_entities(&self) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Transform)> + '_ {
|
||||
self.entities.iter().map(|(label, entity)| {
|
||||
let mesh = self.meshes.get(entity.mesh_id()).unwrap(); // safe: add_entity validates existence
|
||||
let mat = self.materials.get(entity.material_id()).unwrap(); // same invariant
|
||||
(label.as_str(), mesh, mat, entity.transform())
|
||||
(label.as_str(), mesh, entity.transform())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,4 +721,218 @@ impl Scene {
|
||||
pub fn entity_count(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Phase 3 — GPU-driven entity slot accessors (Step 15)
|
||||
// ========================================================================
|
||||
// These expose the stable slot system to the Renderer: the packed transform slots (uploaded
|
||||
// to the GPU each frame), the per-slot draw descriptors (for the indirect render loop), the
|
||||
// per-mesh bounding boxes (uploaded once), and the slot/mesh counts. The world matrices are
|
||||
// derived on the GPU; these methods only feed the CPU→GPU inputs and the draw-loop metadata.
|
||||
|
||||
/// Packs the stable entity slots into GPU [`TransformSlot`]s (one per slot; tombstones →
|
||||
/// inactive). The renderer uploads this to the transform buffer each frame (Phase 3, Step 15).
|
||||
pub fn packed_transform_slots(&self) -> Vec<TransformSlot> {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.map(|slot| match self.entities.get(&slot.label) {
|
||||
Some(entity) => TransformSlot::from_transform(
|
||||
entity.transform(),
|
||||
slot.mesh_index,
|
||||
slot.draw_count,
|
||||
slot.has_index,
|
||||
),
|
||||
None => TransformSlot::inactive(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterates the stable entity slots as per-slot draw descriptors (Phase 3). Each item carries
|
||||
/// the slot index (→ indirect-args/matrix buffer offset), whether the slot is active (tombstones
|
||||
/// are skipped on the CPU), the mesh, and whether it is indexed. Used by the indirect render loop.
|
||||
pub fn iter_slot_draws(&self) -> impl Iterator<Item = SlotDraw> + '_ {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, slot)| SlotDraw {
|
||||
slot_index: i,
|
||||
active: self.entities.contains_key(&slot.label),
|
||||
mesh: self.meshes[&self.mesh_order[slot.mesh_index as usize]].clone(),
|
||||
has_index: slot.has_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// The local-space bounding boxes for all registered meshes, in `mesh_index` order (one per
|
||||
/// mesh). Uploaded once to the GPU bbox buffer (Phase 3). Meshes without a bounding box get a
|
||||
/// degenerate (all-zero) box, which the cull pass treats as a zero-radius sphere.
|
||||
pub fn mesh_bboxes(&self) -> Vec<BBoxSlot> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.map(|id| {
|
||||
self.meshes[id]
|
||||
.geometry()
|
||||
.bbox()
|
||||
.map(|b| BBoxSlot::from_bbox(&b))
|
||||
.unwrap_or_else(BBoxSlot::empty)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable index of `mesh_id` in the ordered mesh list (its slot in the GPU bbox buffer), if
|
||||
/// the mesh is registered.
|
||||
pub fn mesh_index_of(&self, mesh_id: &str) -> Option<u32> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.position(|id| id == mesh_id)
|
||||
.map(|p| p as u32)
|
||||
}
|
||||
|
||||
/// The registered mesh at a stable `mesh_index` (panics if the index is out of range; in
|
||||
/// practice it is always valid, being derived from `mesh_order` positions).
|
||||
pub fn mesh_by_index(&self, index: u32) -> &Arc<Mesh> {
|
||||
&self.meshes[&self.mesh_order[index as usize]]
|
||||
}
|
||||
|
||||
/// Number of entity slots (tombstones included) — the `num_slots` written to the cull uniforms
|
||||
/// (slots at/beyond this are no-ops on the GPU).
|
||||
pub fn num_slots(&self) -> usize {
|
||||
self.entity_slots.len()
|
||||
}
|
||||
|
||||
/// Number of *active* entity slots (tombstones excluded) — the live entity count.
|
||||
pub fn num_active_slots(&self) -> usize {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.filter(|s| self.entities.contains_key(&s.label))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_scene_is_empty() {
|
||||
let scene = Scene::new();
|
||||
assert_eq!(scene.entity_count(), 0);
|
||||
assert_eq!(scene.iter_entities().count(), 0);
|
||||
assert!(scene.get_mesh("nope").is_none());
|
||||
assert!(scene.get_material("nope").is_none());
|
||||
assert_eq!(scene.shadow_caster(), None);
|
||||
assert_eq!(scene.ambient(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_camera_looks_at_origin_from_plus_z() {
|
||||
let scene = Scene::new();
|
||||
let cam = scene.camera();
|
||||
assert_eq!(cam.position, Vec3::new(0.0, 0.0, 3.0));
|
||||
assert_eq!(cam.target, Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_set_and_get_roundtrip() {
|
||||
let mut scene = Scene::new();
|
||||
let cam = Camera::new(Vec3::new(5.0, 5.0, 5.0), Vec3::ZERO, Vec3::Y)
|
||||
.with_perspective(1.0, 0.5, 50.0);
|
||||
scene.set_camera(cam.clone());
|
||||
assert_eq!(scene.camera().position, Vec3::new(5.0, 5.0, 5.0));
|
||||
scene.camera_mut().target = Vec3::new(1.0, 0.0, 0.0);
|
||||
assert_eq!(scene.camera().target, Vec3::new(1.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_light_list_has_one_directional() {
|
||||
let scene = Scene::new();
|
||||
assert_eq!(scene.lights().len(), 1);
|
||||
assert_eq!(scene.lights().directional.len(), 1);
|
||||
assert_eq!(scene.lights().point.len(), 0);
|
||||
assert_eq!(scene.lights().spot.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_lights_until_capacity_then_rejected() {
|
||||
let mut scene = Scene::new(); // starts with the default directional (1 light)
|
||||
scene
|
||||
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
|
||||
.unwrap();
|
||||
while scene.lights().len() < crate::resources::MAX_LIGHTS {
|
||||
scene
|
||||
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(scene.lights().len(), crate::resources::MAX_LIGHTS);
|
||||
assert!(
|
||||
scene
|
||||
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_lights_keeps_ambient() {
|
||||
let mut scene = Scene::new();
|
||||
scene.set_ambient([0.5, 0.2, 0.1]);
|
||||
scene.clear_lights();
|
||||
assert!(scene.lights().is_empty());
|
||||
assert_eq!(scene.ambient(), [0.5, 0.2, 0.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_caster_set_and_get() {
|
||||
let mut scene = Scene::new();
|
||||
scene.set_shadow_caster(Some(0));
|
||||
assert_eq!(scene.shadow_caster(), Some(0));
|
||||
scene.set_shadow_caster(None);
|
||||
assert_eq!(scene.shadow_caster(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_entity_rejects_unknown_mesh() {
|
||||
let mut scene = Scene::new();
|
||||
assert!(scene.add_entity("e1", "missing_mesh").is_err());
|
||||
assert!(!scene.set_entity_transform("missing", Transform::identity()));
|
||||
assert!(!scene.remove_entity("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_driven_slot_bookkeeping_is_empty_when_no_entities() {
|
||||
// The GPU-driven slot system starts empty; the full slot/mesh interplay requires a
|
||||
// wgpu device (to build meshes) and is validated by the examples.
|
||||
let scene = Scene::new();
|
||||
assert_eq!(scene.num_slots(), 0);
|
||||
assert_eq!(scene.num_active_slots(), 0);
|
||||
assert!(scene.packed_transform_slots().is_empty());
|
||||
assert!(scene.mesh_bboxes().is_empty());
|
||||
assert!(scene.iter_slot_draws().next().is_none());
|
||||
assert!(scene.mesh_index_of("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_slot_roundtrips_transform_and_is_inactive_when_tombstoned() {
|
||||
// Exercises the CPU→GPU packing (GPU-independent): an active slot packs the transform +
|
||||
// mesh index + draw count + index flag; a tombstoned slot packs to `inactive()`.
|
||||
let t = Transform {
|
||||
translation: glam::Vec3::new(1.0, 2.0, 3.0),
|
||||
..Transform::identity()
|
||||
};
|
||||
let slot = TransformSlot::from_transform(&t, 7, 36, true);
|
||||
assert_eq!(slot.mesh_index(), 7, "mesh index packed in flags.x");
|
||||
assert!(slot.is_active(), "active = 1");
|
||||
assert_eq!(slot.draw_count(), 36, "draw count packed in flags.z");
|
||||
assert!(slot.has_index(), "indexed flag packed in flags.w");
|
||||
assert!(
|
||||
slot.translation
|
||||
.iter()
|
||||
.zip([1.0, 2.0, 3.0].iter())
|
||||
.all(|(a, b)| (a - b).abs() < 1e-5)
|
||||
);
|
||||
|
||||
let inactive = TransformSlot::inactive();
|
||||
assert!(!inactive.is_active(), "inactive active-flag = 0");
|
||||
assert_eq!(inactive.mesh_index(), 0);
|
||||
assert_eq!(inactive.draw_count(), 0);
|
||||
assert!(!inactive.has_index());
|
||||
}
|
||||
}
|
||||
|
||||
+24
-33
@@ -2,41 +2,25 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Contains WGSL shader source files used by the PipelineCache module. These are loaded at runtime from disk when referenced by their registered ID in PipelineCache.register_shader(). If a file is missing, PipelineCache falls back to the embedded BASIC_SHADER constant defined in utils::conf.
|
||||
Contains WGSL shader source files used by the PipelineCache module. These are loaded at runtime from
|
||||
disk when referenced by their registered ID in PipelineCache.register_shader(). If a file is missing,
|
||||
PipelineCache falls back to the embedded STANDARD_SHADER constant defined in utils::conf.
|
||||
|
||||
The directory contains a **single shader**: `standard_shader.wgsl` (Phong). Flat 2D rendering is
|
||||
the **unlit variant** of `standard`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| **basic_shader.wgsl** | Legacy flat/unlit vertex/fragment shader pair (vs_main / fs_main) with position, uv, and color attributes. Scheduled to be replaced by the unlit variant of `standard_shader.wgsl` (DRAFT Étape 2.3 / 5). |
|
||||
| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + directional diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1)). |
|
||||
|
||||
## Shader Contract (basic_shader.wgsl)
|
||||
|
||||
The WGSL shader defines:
|
||||
|
||||
- `@vertex fn vs_main(model: VertexInput) -> VertexOutput` — vertex entry point
|
||||
- `@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32>` — fragment entry point writing RGBA output
|
||||
|
||||
### Vertex Input Layout
|
||||
|
||||
| Location | Attribute | Type | Offset (bytes) |
|
||||
|----------|-----------|------|----------------|
|
||||
| 0 | position | vec3<f32> | 0 |
|
||||
| 1 | uv | vec2<f32> | 12 |
|
||||
| 2 | color | vec3<f32> | 24 |
|
||||
|
||||
**Note**: This shader uses a 39-byte vertex stride (3+2+3 floats). It does NOT include normal data or alpha channel interpolation — it outputs fully opaque geometry with per-vertex color passthrough. This differs from the full `Vertex` struct layout (56 bytes with normal + alpha) defined in resources::Vertex; if a full shader matching the Vertex struct is needed, extend this shader accordingly.
|
||||
|
||||
> **Statut** : ce shader n'est plus un pipeline séparé ; il est destiné à disparaître au profit de la variante
|
||||
> unlit de `standard_shader.wgsl` (DRAFT Étape 2.3 / Étape 5, « un seul layout pour tous »).
|
||||
| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + multi-light (directional + point + spot) diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1) + texture @group(2)). |
|
||||
|
||||
## Shader Contract (standard_shader.wgsl)
|
||||
|
||||
`standard_shader.wgsl` est le shader unifié (Phong) de WSG. Il corrige le défaut latente de `basic`
|
||||
(contrat vertex incomplet) et expose les deux bind groups partagés par tout matériau.
|
||||
`standard_shader.wgsl` is WSG's unified (Phong) shader. It exposes the two bind groups shared
|
||||
by every material (Step 3: a single layout for all).
|
||||
|
||||
### Vertex Input Layout (56-byte stride — correspond au `resources::Vertex`)
|
||||
### Vertex Input Layout (56-byte stride — matches `resources::Vertex`)
|
||||
|
||||
| Location | Attribute | Type | Offset (bytes) |
|
||||
|----------|-----------|------|----------------|
|
||||
@@ -47,14 +31,21 @@ The WGSL shader defines:
|
||||
|
||||
### Uniforms (bind groups)
|
||||
|
||||
| Group / Binding | Struct | Contenu |
|
||||
| Group / Binding | Struct | Content |
|
||||
|-----------------|--------|---------|
|
||||
| `@group(0) @binding(0)` | `FrameUniforms` (192 B) | `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options` (.x = unlit flag) |
|
||||
| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (matrice modèle de l'entité) |
|
||||
| `@group(0) @binding(0)` | `FrameUniforms` (704 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `num_spot`, `options` (.x = unlit flag) |
|
||||
| `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (the entity's model matrix) |
|
||||
|
||||
`light_dir` pointe de la surface vers la lumière ; le fragment shader l'inverse pour le terme N·L.
|
||||
`FrameUniforms` carries a **global light list** (Steps 12–13, Phase 4.2): `lights[0..num_directional]`
|
||||
are **directional** lights (`position_dir.xyz` = direction from the surface toward the light),
|
||||
`lights[num_directional..num_directional + num_point]` are **point** lights
|
||||
(`position_dir.xyz` = world position, `radius.x` = linear attenuation radius), and
|
||||
`lights[num_directional + num_point..]` are **spot** lights (`position_dir.xyz` = world position,
|
||||
`dir_angle.xyz` = light cone axis toward the scene, `dir_angle.w` = cos of the half-angle).
|
||||
The index disambiguates the type — no flag. `ambient` is the color of the hemispherical ambient term.
|
||||
|
||||
### Mode unlit
|
||||
### Unlit mode
|
||||
|
||||
Un flag `options.x != 0` neutralise la directionnelle et renvoie la couleur du vertex telle quelle
|
||||
(couleur plate). Ainsi le rendu 2D plat est un **cas particulier** de la 3D éclairée.
|
||||
The `options.x != 0` flag disables **all lights** and returns the vertex color as-is
|
||||
(flat color). On the API side, `Renderer::set_unlit(true)` (or `app.renderer_mut().set_unlit(true)`)
|
||||
sets this flag in the frame uniforms. Flat 2D rendering is thus a **special case** of lit 3D.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//! # Basic Shader Module
|
||||
//!
|
||||
//! Default vertex/fragment shader pair used by PipelineCache when no external .wgsl file is found.
|
||||
//! This shader implements a simple unlit rendering path: passes through position and color attributes
|
||||
//! from VertexInput to fragment output, producing flat-colored geometry without lighting calculations.
|
||||
//!
|
||||
//! ## Shader Contract
|
||||
//! Must define entry points matching PipelineCache::build_pipeline():
|
||||
//! - @vertex fn vs_main(model: VertexInput) -> VertexOutput
|
||||
//! - model.position → @location(0), vec3<f32>, offset 0 bytes in vertex buffer
|
||||
//! - model.uv → @location(1), vec2<f32>, offset 12 bytes in vertex buffer
|
||||
//! - model.color → @location(2), vec3<f32>, offset 24 bytes in vertex buffer
|
||||
//! - @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32>
|
||||
//! - Writes RGBA output where alpha is hardcoded to 1.0 (fully opaque).
|
||||
//!
|
||||
//! ## Technical Notes
|
||||
//! - No normal or UV interpolation — this is an unlit shader that directly outputs the per-vertex color.
|
||||
//! - The clip_position is computed as vec4<f32>(position, 1.0), assuming position is already in NDC space.
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) color: vec3<f32>,
|
||||
};
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) color: vec3<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(model: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
out.clip_position = vec4<f32>(model.position, 1.0);
|
||||
out.color = model.color; // On transmet la couleur au fragment shader
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(in.color, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// # GPU-driven rendering compute shader (Phase 3, Step 15)
|
||||
//
|
||||
// Two compute entry points run sequentially in a single command encoder, before the render passes:
|
||||
// 1. `compute_matrices` derives each entity's world matrix on the GPU from its transform slot.
|
||||
// 2. `cull` decides per-entity visibility (bounding sphere vs frustum) and fills the indirect
|
||||
// draw arguments (the vertex/index count, zeroed when the entity is culled or inactive).
|
||||
//
|
||||
// The main and shadow render passes are then 100% indirect: they read the draw slots (zero count
|
||||
// = no-op) instead of a CPU-side per-entity loop.
|
||||
//
|
||||
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see ARCHI_CPU_GPU.md D12) and are
|
||||
// allocated once. Each frame the CPU rewrites the transform slots, cull uniforms, LOD levels
|
||||
// and LOD tables; everything else is GPU-driven.
|
||||
//
|
||||
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
|
||||
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B),
|
||||
// LodRow (16B), LodTable (80B).
|
||||
//
|
||||
// Step 19 (LOD): the CPU decides each entity's level (screen-space size + hysteresis, D8);
|
||||
// the cull pass maps it to the packed level's draw args through the per-mesh LOD table
|
||||
// (binding 4) and per-slot level array (binding 3). With LOD disabled the CPU writes level 0
|
||||
// everywhere and the args are byte-identical to the pre-LOD behavior.
|
||||
//
|
||||
// GOTCHA — WGSL `select` argument order: `select(reject, accept, cond)` returns the SECOND
|
||||
// argument when `cond` is true and the FIRST when false (the reverse of HLSL's
|
||||
// `select(trueVal, falseVal, cond)`). The original cull pass wrote
|
||||
// `select(u32(t.flags.z), 0u, visible)`, which zeroed the count of every VISIBLE entity (and
|
||||
// would have drawn the culled ones) — the source of the black-window bug.
|
||||
|
||||
// 64 bytes: packed TRS, the single source of truth for world matrices.
|
||||
struct TransformSlot {
|
||||
translation : vec3f,
|
||||
flags : vec4f, // x = mesh index, y = active, z = draw count, w = has_index
|
||||
rotation : vec4f,
|
||||
scale : vec3f,
|
||||
};
|
||||
|
||||
// 256 bytes: a 64-byte world matrix followed by 192 bytes of padding. The padding is REQUIRED —
|
||||
// the render pipelines read this slot through the `uniform` object group with a per-slot dynamic
|
||||
// offset, and WebGPU demands that offset be a multiple of `min_uniform_buffer_offset_alignment`
|
||||
// (256 bytes). A bare 64-byte matrix can never be individually addressable via a uniform offset,
|
||||
// so each slot is padded to a 256-byte boundary (capacity is capped at 256 = 64 KB / 256 B).
|
||||
struct MatSlot {
|
||||
m : mat4x4f,
|
||||
pad : array<vec4f, 12>, // 192 bytes, alignment only — never read
|
||||
};
|
||||
|
||||
// 32 bytes: local-space axis-aligned bounding box (uploaded once per mesh).
|
||||
struct BBoxSlot {
|
||||
min : vec3f,
|
||||
max : vec3f,
|
||||
};
|
||||
|
||||
// 80 bytes: indirect draw arguments for one entity (a 4-u32 non-indexed / 5-u32 indexed block).
|
||||
// Only `.a` is written by the shader; the rest stays zero (instance count is the constant 1 in `.a.y`).
|
||||
struct DrawSlot {
|
||||
a : vec4u,
|
||||
b : vec4u,
|
||||
c : vec4u,
|
||||
d : vec4u,
|
||||
e : vec4u,
|
||||
};
|
||||
|
||||
// 112 bytes: frustum planes + control flags, rewritten by the CPU each frame.
|
||||
struct CullUniforms {
|
||||
planes : array<vec4f, 6>, // unit (normal, d); inside iff dot(p, normal) + d >= 0
|
||||
num_slots : u32, // number of live entity slots
|
||||
culling : u32, // 0 = culling disabled, 1 = enabled
|
||||
_pad : vec2u,
|
||||
};
|
||||
|
||||
// 16 bytes: one LOD level's draw offsets (Step 19, D7). ELEMENT units, not bytes: x is a
|
||||
// vertex index (drawIndirectNonIndexed first_vertex) and z an index element (drawIndirectIndexed
|
||||
// first_index) — the packed vertex/index buffers are bound in full (offset 0), only these
|
||||
// first_* values move per level. w = 0 marks a non-indexed level.
|
||||
struct LodRow {
|
||||
o : vec4u, // x = first_vertex, y = vertex_count, z = first_index, w = index_count
|
||||
};
|
||||
|
||||
// 80 bytes: the per-mesh LOD table (Step 19, D7). count @0, 12-byte pad (an array<u32,3> —
|
||||
// NOT vec3u, which would align to 16 and grow the struct to 96 B), rows @16..80.
|
||||
struct LodTable {
|
||||
count : u32, // number of valid levels (1 = no LOD)
|
||||
_pad : array<u32, 3>,
|
||||
rows : array<LodRow, 4>, // MAX_LOD_LEVELS = 4; zeroed beyond count
|
||||
};
|
||||
|
||||
// ---- Bind groups (Step 15.5) ----
|
||||
// Group 0 (transforms) is shared by both entry points; group 1 (matrices) by `compute_matrices`;
|
||||
// group 2 (cull uniforms + bboxes + draw args) by `cull`. Each pipeline infers the subset it uses.
|
||||
@group(0) @binding(0) var<storage, read> transforms : array<TransformSlot>;
|
||||
@group(1) @binding(0) var<storage, read_write> matrices : array<MatSlot>;
|
||||
@group(2) @binding(0) var<uniform> cull_u : CullUniforms;
|
||||
@group(2) @binding(1) var<storage, read> bboxes : array<BBoxSlot>;
|
||||
@group(2) @binding(2) var<storage, read_write> draw_args : array<DrawSlot>;
|
||||
// Step 19: per-slot CPU-decided LOD level (one u32 per entity slot) and the per-mesh LOD tables
|
||||
// (one 80-byte LodTable per mesh, mesh_order order — same indexing as `bboxes`).
|
||||
@group(2) @binding(3) var<storage, read> lod_levels : array<u32>;
|
||||
@group(2) @binding(4) var<storage, read> lod_tables : array<LodTable>;
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
// Builds a rotation mat4x4f from a quaternion (x, y, z, w) in column-major form.
|
||||
fn quat_to_mat4(q : vec4f) -> mat4x4f {
|
||||
let x = q.x;
|
||||
let y = q.y;
|
||||
let z = q.z;
|
||||
let w = q.w;
|
||||
return mat4x4f(
|
||||
vec4f(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y), 0.0),
|
||||
vec4f(2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x), 0.0),
|
||||
vec4f(2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y), 0.0),
|
||||
vec4f(0.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// Rotates a local-space vector by a quaternion (via the rotation matrix).
|
||||
fn rotate_by_quat(v : vec3f, q : vec4f) -> vec3f {
|
||||
let m = quat_to_mat4(q);
|
||||
return (m * vec4f(v, 0.0)).xyz;
|
||||
}
|
||||
|
||||
// World matrix = T * R * S, column-major (matches the CPU `Transform::to_matrix`, D13).
|
||||
// Returns just the 4x4 matrix; the caller stores it in `matrices[i].m` (the slot's 192-byte pad
|
||||
// is left at its zero-initialised value).
|
||||
fn world_matrix(t : TransformSlot) -> mat4x4f {
|
||||
let r = quat_to_mat4(t.rotation);
|
||||
return mat4x4f(
|
||||
r[0] * t.scale.x,
|
||||
r[1] * t.scale.y,
|
||||
r[2] * t.scale.z,
|
||||
vec4f(t.translation.x, t.translation.y, t.translation.z, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// The identity world matrix (used for inactive slots so any stale read is harmless).
|
||||
fn identity_mat() -> mat4x4f {
|
||||
return mat4x4f(
|
||||
vec4f(1.0, 0.0, 0.0, 0.0),
|
||||
vec4f(0.0, 1.0, 0.0, 0.0),
|
||||
vec4f(0.0, 0.0, 1.0, 0.0),
|
||||
vec4f(0.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// Fills a draw slot with a non-zero (visible) count of `count`, or zero (culled / inactive).
|
||||
// The count lands in `.a.x`; `.a.y` (instance count) is the constant 1; the rest stays zero.
|
||||
// (A level-0 draw produced by `write_level_args` is byte-identical to this: first_* = 0.)
|
||||
fn set_draw_count(i : u32, count : u32) {
|
||||
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
|
||||
}
|
||||
|
||||
// Fills the slot's indirect args with the draw command of LOD level `row` (Step 19, D7):
|
||||
// indexed levels use the 5-field layout (index_count, instances, first_index, base_vertex,
|
||||
// base_instance) and non-indexed levels the 4-field layout (vertex_count, instances,
|
||||
// first_vertex, base_instance). The element-unit offsets in the row become the first_* fields.
|
||||
fn write_level_args(i : u32, row : LodRow) {
|
||||
if (row.o.w > 0u) {
|
||||
draw_args[i].a = vec4u(row.o.w, 1u, row.o.z, 0u);
|
||||
} else {
|
||||
draw_args[i].a = vec4u(row.o.y, 1u, row.o.x, 0u);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 1: derive world matrices (Step 15.5) ----
|
||||
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
|
||||
@compute
|
||||
@workgroup_size(64)
|
||||
fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
|
||||
let i = gid.x;
|
||||
let t = transforms[i];
|
||||
if (t.flags.y < 0.5) {
|
||||
matrices[i].m = identity_mat();
|
||||
} else {
|
||||
matrices[i].m = world_matrix(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 2: LOD level → draw args + cull (Steps 15.6 + 19) ----
|
||||
// Dispatched for MAX_ENTITIES. Slots at or beyond `num_slots` (and inactive slots) are zeroed so
|
||||
// the indirect render passes skip them. For visible slots the CPU-decided LOD level (binding 3)
|
||||
// is mapped through the mesh's LOD table (binding 4) to the level's draw command; culling
|
||||
// (when enabled) zeroes it against the frustum planes.
|
||||
@compute
|
||||
@workgroup_size(64)
|
||||
fn cull(@builtin(global_invocation_id) gid : vec3u) {
|
||||
let i = gid.x;
|
||||
let t = transforms[i];
|
||||
|
||||
// Beyond the live slots: zero the count so the indirect draw is a no-op.
|
||||
if (i >= cull_u.num_slots) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inactive slot (tombstone): no draw.
|
||||
if (t.flags.y < 0.5) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// LOD (Step 19, D8): the CPU decides the level per slot (projected size + hysteresis);
|
||||
// the GPU only maps it to the packed level's draw args. Clamped to the mesh's level count
|
||||
// (the CPU clamps too — defense in depth against a stale level after an add_mesh_lod).
|
||||
let table = lod_tables[u32(t.flags.x)];
|
||||
var level = lod_levels[i];
|
||||
if (level >= table.count) {
|
||||
level = table.count - 1u;
|
||||
}
|
||||
let row = table.rows[level].o;
|
||||
|
||||
// Culling: test the entity's world bounding sphere against the frustum planes.
|
||||
var visible = true;
|
||||
if (cull_u.culling == 1u) {
|
||||
let b = bboxes[u32(t.flags.x)];
|
||||
let center_local = (b.min + b.max) * 0.5;
|
||||
// World center = translation + rotation * local center (no scale; the radius carries it).
|
||||
let center_world = t.translation + rotate_by_quat(center_local, t.rotation);
|
||||
let half_extents = (b.max - b.min) * 0.5;
|
||||
let radius = length(half_extents) * max(t.scale.x, max(t.scale.y, t.scale.z));
|
||||
|
||||
for (var p = 0u; p < 6u; p = p + 1u) {
|
||||
let plane = cull_u.planes[p];
|
||||
let dist = dot(plane.xyz, center_world) + plane.w;
|
||||
if (dist < -radius) {
|
||||
visible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// Visible: the chosen level's draw command (level 0 is byte-identical to the pre-LOD args).
|
||||
write_level_args(i, LodRow(row));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! # Shadow Shader (Étape 14, Phase 4.2 — depth-only pass)
|
||||
//!
|
||||
//! Minimal vertex shader used for the **shadow map pass** (DRAFT Étape 14, D4). It transforms each
|
||||
//! vertex into the light's clip space and lets the depth write happen — no fragment stage, no color
|
||||
//! output, no lighting : the rasterizer only records the depth (D2).
|
||||
//!
|
||||
//! Only the `position` attribute (location 0) is consumed, so this pipeline needs no normal/uv/color
|
||||
//! buffers and is as cheap as possible.
|
||||
//!
|
||||
//! ## Uniform Contract (this pipeline's own layout — independent of the main pipeline)
|
||||
//! - `@group(0) @binding(0)` : `ShadowUniform` — the light's `view_proj` matrix (world → light clip).
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` — the entity's per-entity model matrix (shared with
|
||||
//! the main pipeline, so the Renderer reuses its per-entity object bind groups).
|
||||
//!
|
||||
//! The light VP is passed as a group-0 uniform rather than reusing the camera `FrameUniforms`
|
||||
//! because the shadow pass is rendered from the light's point of view, not the camera's.
|
||||
|
||||
struct ShadowUniform {
|
||||
view_proj: mat4x4<f32>,
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
model: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> shadow: ShadowUniform;
|
||||
@group(1) @binding(0) var<uniform> object: ObjectUniform;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>,
|
||||
};
|
||||
|
||||
// Output carries only the clip position; any attribute interpolated without a fragment stage is
|
||||
// still fine (it is simply discarded). Keeping just the position minimizes the vertex output size.
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(input: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let world = object.model * vec4<f32>(input.position, 1.0);
|
||||
out.clip_position = shadow.view_proj * world;
|
||||
return out;
|
||||
}
|
||||
@@ -1,27 +1,49 @@
|
||||
//! # Standard Shader Module (Phong)
|
||||
//! # Standard Shader Module (Phong + diffuse texture)
|
||||
//!
|
||||
//! Default lit shading pipeline for WSG. Implements an ambient + directional-diffuse
|
||||
//! (Phong-style) lighting model with an explicit "unlit" mode so that flat 2D rendering
|
||||
//! is a special case of the 3D path (see DRAFT décision actée : « 2D ⊂ 3D »).
|
||||
//! Since Étape 10 (DRAFT D2) the fragment can also sample a diffuse texture whose texel
|
||||
//! modulates the vertex color (`texel.rgb * in.color.rgb`).
|
||||
//!
|
||||
//! ## Uniform Contract
|
||||
//! Two bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [192 bytes]
|
||||
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
|
||||
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes]
|
||||
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
|
||||
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
|
||||
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
|
||||
//! - `@group(3) @binding(0)` : `shadow_sampler` (sampler_comparison) (Étape 14)
|
||||
//! - `@group(3) @binding(1)` : `shadow_map` (texture_depth_2d) (Étape 14)
|
||||
//!
|
||||
//! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) :
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |--------|--------------|-----------|-----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | light_dir | vec4<f32> | Light direction (see below) |
|
||||
//! | 160 | light_color | vec4<f32> | Light color (.rgb) |
|
||||
//! | 176 | options | vec4<u32> | x = unlit flag (1 => flat color) |
|
||||
//! | 192 | total | | |
|
||||
//! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |-----------------------|-------------------|---------------|----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
|
||||
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
|
||||
//! | 160 + 64·MAX_LIGHTS | num_directional | u32 | # directional (indices 0..n) |
|
||||
//! | | num_point | u32 | # point (indices n..) |
|
||||
//! | | num_spot | u32 | # spot (indices after point) |
|
||||
//! | | shadow_light_index| u32 | packed index of shadow light |
|
||||
//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4<f32> | world → light clip space (D3) |
|
||||
//! | | shadow_params | vec4<f32> | .x = map size, .y = depth bias |
|
||||
//! | | options | vec4<u32> | .x = unlit ; .y = shadows on |
|
||||
//!
|
||||
//! `light_dir` convention : vector pointing **from the surface toward the light**.
|
||||
//! The fragment shader negates it to obtain the light direction for the N·L term.
|
||||
//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × vec4). Directional lights occupy
|
||||
//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
|
||||
//! light**); point lights occupy `lights[num_directional..num_directional + num_point]`
|
||||
//! (`position_dir.xyz` = world position, `radius.x` = linear attenuation radius); spot lights
|
||||
//! occupy `lights[num_directional + num_point..]` (`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 — the index disambiguates (Étapes 12–13).
|
||||
//!
|
||||
//! ## Texturing (Étape 10, D2)
|
||||
//! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the
|
||||
//! white 1×1 placeholder (texel = `[1,1,1]`), which is the multiplicative identity: `texel * color`
|
||||
//! leaves the vertex color unchanged, exactly reproducing the pre-Étape-10 look in both lit and
|
||||
//! unlit modes. A real texture tints/multiplies the vertex color.
|
||||
//!
|
||||
//! ## Vertex Input Layout (matches the full `resources::Vertex` struct, 56-byte stride)
|
||||
//! | Location | Attribute | Type | Offset (bytes) |
|
||||
@@ -33,7 +55,7 @@
|
||||
//!
|
||||
//! ## Entry Points
|
||||
//! - `@vertex vs_main` : world = model * position ; clip = proj * view * world.
|
||||
//! - `@fragment fs_main` : ambient (hemispheric) + directional diffuse, or flat color when unlit.
|
||||
//! - `@fragment fs_main` : base = texel * vertex color; × (ambient + diffuse) when lit, or base when unlit.
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec3<f32>,
|
||||
@@ -42,13 +64,35 @@ struct VertexInput {
|
||||
@location(3) color: vec4<f32>,
|
||||
};
|
||||
|
||||
// Étape 12 (Phase 4.2) : maximum number of lights in the per-frame array. Must match
|
||||
// `wsg_lib::resources::MAX_LIGHTS`.
|
||||
const MAX_LIGHTS: u32 = 8u;
|
||||
|
||||
// A single light (64 bytes = 4 × vec4). Directional: `position_dir.xyz` = direction from the
|
||||
// surface toward the light. Point: `position_dir.xyz` = world position, `radius.x` = linear
|
||||
// attenuation radius. 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. The array index
|
||||
// disambiguates the type (no flag stored).
|
||||
struct Light {
|
||||
position_dir: vec4<f32>,
|
||||
color: vec4<f32>, // rgb = color; a = intensity
|
||||
radius: vec4<f32>, // x = point/spot attenuation radius
|
||||
dir_angle: vec4<f32>, // spot: xyz = cone axis, w = cos(half-angle)
|
||||
};
|
||||
|
||||
struct FrameUniforms {
|
||||
view: mat4x4<f32>,
|
||||
proj: mat4x4<f32>,
|
||||
cam_pos: vec4<f32>,
|
||||
light_dir: vec4<f32>,
|
||||
light_color: vec4<f32>,
|
||||
options: vec4<u32>, // .x : unlit flag (1 = flat color, no directional lighting)
|
||||
ambient: vec4<f32>, // .rgb = ambient hemisphere color
|
||||
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
|
||||
num_directional: u32,
|
||||
num_point: u32,
|
||||
num_spot: u32,
|
||||
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
|
||||
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
|
||||
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
|
||||
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
@@ -57,12 +101,21 @@ struct ObjectUniform {
|
||||
|
||||
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
|
||||
@group(1) @binding(0) var<uniform> object: ObjectUniform;
|
||||
// Étape 10 (DRAFT D1) : groupe texture — sampler (0) + texture diffuse (1). Un matériau sans
|
||||
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
|
||||
@group(2) @binding(0) var texture_sampler: sampler;
|
||||
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
|
||||
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1).
|
||||
// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
|
||||
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
|
||||
@group(3) @binding(1) var shadow_map: texture_depth_2d;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) world_pos: vec3<f32>,
|
||||
@location(1) normal: vec3<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -81,29 +134,127 @@ fn vs_main(input: VertexInput) -> VertexOutput {
|
||||
object.model[2].xyz,
|
||||
);
|
||||
out.normal = normal_matrix * input.normal;
|
||||
out.uv = input.uv;
|
||||
out.color = input.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Flat (unlit) mode : pas d'éclairage, couleur du vertex telle quelle.
|
||||
// Étape 10 (D2) : échantillonnage inconditionnel. Le texel module la couleur du vertex
|
||||
// (base = texel * color). Avec le placeholder blanc (texel = 1), base == vertex color :
|
||||
// aucune régression pour les matériaux sans texture, en lit comme en unlit.
|
||||
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.
|
||||
if (frame.options.x != 0u) {
|
||||
return in.color;
|
||||
return vec4<f32>(base, in.color.a);
|
||||
}
|
||||
|
||||
let n = normalize(in.normal);
|
||||
// light_dir pointe de la surface vers la lumière ; on inverse pour le terme N·L.
|
||||
let l = normalize(-frame.light_dir.xyz);
|
||||
let ndotl = max(dot(n, l), 0.0);
|
||||
|
||||
// Ambient hémisphérique : dépend de la composante verticale de la normale.
|
||||
// Ambient hémisphérique : dépend de la composante verticale de la normale (couleur venue
|
||||
// de frame.ambient, Étape 12 — était codée en dur via la couleur de la lumière avant).
|
||||
let sky = max(n.y, 0.0);
|
||||
let ambient = frame.light_color.rgb * (0.3 + 0.4 * sky);
|
||||
let ambient = frame.ambient.rgb * (0.3 + 0.4 * sky);
|
||||
|
||||
// Diffuse directionnel classique.
|
||||
let diffuse = frame.light_color.rgb * ndotl;
|
||||
var diffuse = vec3<f32>(0.0);
|
||||
|
||||
let lit = in.color.rgb * (ambient + diffuse);
|
||||
// Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface
|
||||
// vers la lumière, donc on l'utilise tel quel pour le terme N·L (dot(n, direction vers la
|
||||
// lumière) > 0 = face éclairée).
|
||||
for (var i = 0u; i < frame.num_directional; i++) {
|
||||
let l = normalize(frame.lights[i].position_dir.xyz);
|
||||
let ndotl = max(dot(n, l), 0.0);
|
||||
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl;
|
||||
}
|
||||
|
||||
// Lumières ponctuelles (indices num_directional..num_directional + num_point). Atténuation
|
||||
// linéaire dans le rayon (zéro au-delà).
|
||||
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
|
||||
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
|
||||
let dist = length(to_light);
|
||||
let l = to_light / max(dist, 1e-4);
|
||||
let ndotl = max(dot(n, l), 0.0);
|
||||
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
|
||||
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff;
|
||||
}
|
||||
|
||||
// Lumières spot (indices num_directional + num_point..num_directional + num_point +
|
||||
// num_spot). Cône orienté : on teste l'alignement de la direction **de la lumière vers le
|
||||
// point** de la surface (-l, car l pointe de la surface vers la lumière) avec l'axe du cône
|
||||
// (dir_angle.xyz, de la lumière vers la scène). Pénombre lissée entre le demi-angle intérieur
|
||||
// (dir_angle.w) et un liseré extérieur (demi-angle − 0.1 rad), plus atténuation linéaire.
|
||||
let spot_base = frame.num_directional + frame.num_point;
|
||||
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
|
||||
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
|
||||
let dist = length(to_light);
|
||||
let l = to_light / max(dist, 1e-4); // surface -> lumière
|
||||
let ndotl = max(dot(n, l), 0.0);
|
||||
// direction lumière -> point de la surface = -l ; alignée avec l'axe du cône (dir_angle.xyz).
|
||||
let to_point = -l;
|
||||
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
|
||||
let cos_inner = frame.lights[i].dir_angle.w;
|
||||
let cos_outer = cos_inner - 0.1;
|
||||
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
|
||||
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
|
||||
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
|
||||
}
|
||||
|
||||
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
|
||||
return vec4<f32>(lit, in.color.a);
|
||||
}
|
||||
|
||||
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
|
||||
// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages
|
||||
// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (LessEqual). Returns
|
||||
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow.
|
||||
//
|
||||
// Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by
|
||||
// `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the
|
||||
// surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents
|
||||
// the large black patches that a constant bias alone cannot suppress on large flat surfaces.
|
||||
fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
|
||||
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit.
|
||||
if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
|
||||
return 1.0;
|
||||
}
|
||||
let light_clip = frame.light_view_proj * vec4<f32>(world_pos, 1.0);
|
||||
// Perspective divide then map NDC [-1,1] → UV [0,1]. Orthographic depth is linear in the map.
|
||||
let shadow_ndc = light_clip.xyz / max(light_clip.w, 1e-6);
|
||||
var shadow_uv = shadow_ndc.xy * 0.5 + 0.5;
|
||||
shadow_uv = vec2<f32>(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates
|
||||
// The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam
|
||||
// directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed.
|
||||
let current_depth = shadow_ndc.z;
|
||||
let texel = 1.0 / max(frame.shadow_params.x, 1.0);
|
||||
|
||||
// Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light).
|
||||
// Direction from surface toward the shadow-casting light:
|
||||
// directional → position_dir.xyz (already the surface→light direction)
|
||||
// spot → normalize(light_position - world_pos)
|
||||
let sl_idx = frame.shadow_light_index;
|
||||
let sl = frame.lights[sl_idx];
|
||||
let is_dir = (sl_idx < frame.num_directional);
|
||||
var light_dir: vec3<f32>;
|
||||
if (is_dir) {
|
||||
light_dir = normalize(sl.position_dir.xyz);
|
||||
} else {
|
||||
light_dir = normalize(sl.position_dir.xyz - world_pos);
|
||||
}
|
||||
// The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular.
|
||||
let slope = 1.0 - abs(dot(normalize(normal), light_dir));
|
||||
let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope);
|
||||
|
||||
// 3×3 PCF : average of the comparison results around the fragment's texel.
|
||||
var lit_count = 0.0;
|
||||
for (var ox = -1i; ox <= 1; ox++) {
|
||||
for (var oy = -1i; oy <= 1; oy++) {
|
||||
let offset = vec2<f32>(f32(ox), f32(oy)) * texel;
|
||||
lit_count += textureSampleCompare(
|
||||
shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias);
|
||||
}
|
||||
}
|
||||
return lit_count / 9.0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Tone mapping fullscreen pass shader (Étape 20).
|
||||
//
|
||||
// Renders a fullscreen triangle (no vertex buffer — position derived from vertex_index)
|
||||
// that samples the HDR texture, applies exposure + tone mapping curve, and writes the
|
||||
// result to the sRGB surface. The hardware handles the linear→sRGB gamma conversion
|
||||
// automatically (the surface is Rgba8UnormSrgb).
|
||||
//
|
||||
// Two fragment entry points: `fs_aces` (ACES Filmic, Narkowicz 2015) and `fs_reinhard`
|
||||
// (simple Reinhard). The pipeline is compiled with the appropriate entry point at
|
||||
// construction time.
|
||||
|
||||
struct TmUniforms {
|
||||
exposure: f32,
|
||||
pad: vec3<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var u_hdr_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var u_hdr_sampler: sampler;
|
||||
@group(0) @binding(2) var<uniform> u_params: TmUniforms;
|
||||
|
||||
// Fullscreen triangle vertex shader: generates three vertices covering the entire
|
||||
// NDC viewport. The triangle is (-1,-1), (3,-1), (-1,3) — the fourth NDC corner (1,1)
|
||||
// is outside the triangle and gets clipped away; the visible portion exactly covers [-1,1]².
|
||||
// The fragment shader derives UVs from the built-in position (window coords).
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
|
||||
switch vid {
|
||||
case 0u {
|
||||
return vec4<f32>(-1.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
case 1u {
|
||||
return vec4<f32>(3.0, -1.0, 0.0, 1.0);
|
||||
}
|
||||
default {
|
||||
return vec4<f32>(-1.0, 3.0, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACES Filmic tone curve (Narkowicz 2015) ---
|
||||
fn aces(x: f32) -> f32 {
|
||||
let a = 2.51;
|
||||
let b = 0.03;
|
||||
let c = 2.43;
|
||||
let d = 0.59;
|
||||
let e = 0.14;
|
||||
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
|
||||
}
|
||||
|
||||
// --- Reinhard tone curve ---
|
||||
fn reinhard(x: f32) -> f32 {
|
||||
return clamp(x / (1.0 + x), 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Convert window-space position to texture UVs [0,1]².
|
||||
// @builtin(position) in a fragment shader is in window coordinates (pixels, top-left origin).
|
||||
// We need the draw size to normalize; pass it via a uniform or use the known viewport.
|
||||
// Here we use a simpler trick: the NDC position is available via the vertex interpolation,
|
||||
// but since we only output @builtin(position), we derive UVs in the fragment from
|
||||
// @builtin(position) / viewport. The viewport is the full window, so we normalize by
|
||||
// the known draw size.
|
||||
//
|
||||
// Actually, the simplest correct approach: since the triangle covers the full viewport,
|
||||
// we can use `@builtin(position)` (in pixels) and normalize by the viewport size.
|
||||
// But we don't have the viewport size as a binding here...
|
||||
//
|
||||
// Alternative: use a second vertex output for UVs. Since tuple returns aren't supported
|
||||
// in this naga version, we use a different trick — the UVs are linearly interpolated
|
||||
// from the vertex positions. We compute them as (ndc + 1) / 2 in the vertex shader
|
||||
// and pass them through an @location. But we can only have one return value...
|
||||
//
|
||||
// Simplest fix: just use @builtin(position) in the fragment and divide by the
|
||||
// viewport size (stored in the uniform).
|
||||
|
||||
// We add viewport size to the uniform (reusing the _pad field).
|
||||
// _pad.xy = viewport size in pixels (width, height).
|
||||
// _pad.z = unused, _pad.w = unused.
|
||||
|
||||
@fragment
|
||||
fn fs_aces(
|
||||
@builtin(position) frag_pos: vec4<f32>,
|
||||
) -> @location(0) vec4<f32> {
|
||||
let uv = frag_pos.xy / u_params.pad.xy;
|
||||
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
|
||||
return vec4<f32>(
|
||||
aces(color.r),
|
||||
aces(color.g),
|
||||
aces(color.b),
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_reinhard(
|
||||
@builtin(position) frag_pos: vec4<f32>,
|
||||
) -> @location(0) vec4<f32> {
|
||||
let uv = frag_pos.xy / u_params.pad.xy;
|
||||
let color = textureSample(u_hdr_texture, u_hdr_sampler, uv).rgb * u_params.exposure;
|
||||
return vec4<f32>(
|
||||
reinhard(color.r),
|
||||
reinhard(color.g),
|
||||
reinhard(color.b),
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ The `utils` module defines two leaf concepts that other modules consume but have
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| **conf** | Shared constants for shader paths (BASIC_SHADER_PATH) and embedded WGSL source code (BASIC_SHADER). Centralized here so all submodules import from one place instead of duplicating literal strings. Enables PipelineCache to fall back to an embedded default shader when the file-based one is missing. |
|
||||
| **conf** | Shared constants for shader paths (STANDARD_SHADER_PATH) and embedded WGSL source code (STANDARD_SHADER). Centralized here so all submodules import from one place instead of duplicating literal strings. Enables PipelineCache to fall back to an embedded default shader when the file-based one is missing. |
|
||||
| **error** | WsgError enum — application-level error type mapping specific wgpu failure modes to user-friendly messages via thiserror. Every variant maps a GPU initialization or rendering failure to a recoverable or fatal outcome. |
|
||||
|
||||
## Interaction with Other Modules
|
||||
|
||||
- **pipeline::pipeline_cache**: load_shader() reads BASIC_SHADER_PATH from disk; falls back to BASIC_SHADER if unreadable.
|
||||
- **pipeline::pipeline_cache**: load_shader() reads STANDARD_SHADER_PATH from disk; falls back to STANDARD_SHADER if unreadable.
|
||||
- **core::context**: Returns WsgError variants from all fallible methods (new, configure, begin_frame).
|
||||
- **core::renderer**: Does not use errors directly — panics on invalid state rather than returning Result.
|
||||
|
||||
+89
-13
@@ -6,28 +6,104 @@
|
||||
//! Also provides application defaults for window title, width, and height used by AppBuilder.
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - **pipeline_cache::load_shader()** reads `BASIC_SHADER_PATH` from disk; if unreadable, falls back to `BASIC_SHADER`.
|
||||
//! - **pipeline_cache::load_shader()** reads `STANDARD_SHADER_PATH` from disk; if unreadable, falls back to `STANDARD_SHADER`.
|
||||
//! - Both constants are compile-time values (`include_str!`) ensuring the fallback shader is always available even without external files.
|
||||
//! - **app::AppBuilder** reads APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH, and APP_DEFAULT_HEIGHT for default window configuration.
|
||||
|
||||
/// Path to the default WGSL shader file on disk (runtime). Used by PipelineCache::load_shader() for file-based loading.
|
||||
pub const BASIC_SHADER_PATH: &str = "assets/shaders/basic_shader.wgsl";
|
||||
|
||||
/// The basic WGSL shader source code, embedded at compile time via `include_str!`.
|
||||
/// Serves as a fallback when `BASIC_SHADER_PATH` cannot be read at runtime.
|
||||
pub const BASIC_SHADER: &str = include_str!("../shaders/basic_shader.wgsl");
|
||||
|
||||
/// Path to the standard (Phong) WGSL shader file on disk (runtime). Used by PipelineCache::load_shader()
|
||||
/// once standardized (Étape 3) : this shader carries the full uniform contract (frame + object bind groups)
|
||||
/// and supports an unlit mode so flat 2D rendering is a special case of the 3D lit path.
|
||||
/// for file-based loading. This is the unified pipeline shader (a single layout for all):
|
||||
/// it carries the full uniform contract (frame + object bind groups) and supports an unlit mode so flat
|
||||
/// 2D rendering is a special case of the 3D lit path.
|
||||
///
|
||||
/// NOTE: the shipped `assets/shaders/*.wgsl` files are OPTIONAL — when they are absent (library consumed
|
||||
/// from a checkout without the assets directory, or from a published crate), `PipelineCache::load_shader`
|
||||
/// falls back to the embedded `STANDARD_SHADER` source, which is byte-identical. The fallback is therefore
|
||||
/// expected and harmless, not an error condition.
|
||||
pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl";
|
||||
|
||||
/// The standard (Phong) WGSL shader source code, embedded at compile time via `include_str!`.
|
||||
/// Not yet compiled by any pipeline (Étape 2 : shader seul, non branché). Becomes the unified
|
||||
/// pipeline shader once the uniform infrastructure exists (Étape 3). The unlit variant is the
|
||||
/// replacement for the flat `basic` family.
|
||||
/// Serves as the fallback when `STANDARD_SHADER_PATH` cannot be read at runtime. Because every
|
||||
/// pipeline uses the unified layout (frame @0 + object @1), this is the only shader the library ships.
|
||||
pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl");
|
||||
|
||||
/// Path to the depth-only **shadow** WGSL shader on disk (Step 14, D4). Kept only for API
|
||||
/// compatibility — the shadow pass always compiles the embedded `SHADOW_SHADER` directly
|
||||
/// (it is internal to the library, no external file is ever read).
|
||||
pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
|
||||
|
||||
/// The depth-only shadow WGSL shader source, embedded at compile time via `include_str!`
|
||||
/// (Step 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
|
||||
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
|
||||
|
||||
/// The GPU-driven rendering compute shader source (Phase 3, Step 15), embedded at compile time.
|
||||
/// It carries two compute entry points — `compute_matrices` (derive world matrices) and `cull`
|
||||
/// (per-entity visibility + indirect draw args) — compiled directly by the renderer (internal to
|
||||
/// the library; no external file is read).
|
||||
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
|
||||
|
||||
/// The tone mapping fullscreen pass shader source (Étape 20), embedded at compile time.
|
||||
/// Carries one vertex entry point (`vs_main`, fullscreen triangle) and two fragment entry
|
||||
/// points (`fs_aces`, `fs_reinhard`). Compiled directly by the renderer when HDR is enabled.
|
||||
pub const TONEMAP_SHADER: &str = include_str!("../shaders/tonemap.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.
|
||||
///
|
||||
/// **Why 256 (and why the matrix slot is padded to 256 B):** the matrix buffer is bound to the
|
||||
/// render pipeline's `uniform` object slot (group 1), which imposes TWO limits:
|
||||
/// (1) a single uniform binding is capped at `max_uniform_buffer_binding_size` (64 KB on most
|
||||
/// backends), and (2) a uniform offset must be a multiple of `min_uniform_buffer_offset_alignment`
|
||||
/// (256 B). A 64-byte matrix can therefore never be individually addressable via a per-slot
|
||||
/// dynamic offset, so each matrix slot is padded to 256 B (see `MatSlot`); with 256-byte slots,
|
||||
/// 256 slots × 256 B is exactly 64 KB — the largest the single-buffer / dynamic-offset design
|
||||
/// can address. (The transform / bbox / draw-args buffers are `storage` bindings with a 128 MB
|
||||
/// limit and no 256-B offset rule, so they keep their natural 64 / 32 / 80 B slot sizes.)
|
||||
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
|
||||
pub const MAX_ENTITIES: u32 = 256;
|
||||
|
||||
/// Maximum number of LOD levels per mesh (Étape 19, D8). The per-mesh LOD table
|
||||
/// (`LodTable`, 80 bytes) carries one 16-byte row per level — 4 rows + the count header.
|
||||
pub const MAX_LOD_LEVELS: u32 = 4;
|
||||
|
||||
/// Default LOD thresholds in **pixels** of projected bounding-sphere radius (Étape 19, D4/D8):
|
||||
/// `thresholds[k]` is the radius *above which* level k+1 is required (descending). With these
|
||||
/// values: `r > 48` → L0, `12 < r ≤ 48` → L1, `r ≤ 12` → L2+ (clamped). Constants in v1
|
||||
/// (per-scene/mesh configurability is a follow-up); the hysteresis dead band (×0.8 to go
|
||||
/// coarser) lives in `math::lod::lod_level`.
|
||||
pub const LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0];
|
||||
|
||||
/// Workgroup size of the GPU-driven compute shaders (matches the `@workgroup_size` in
|
||||
/// `gpu_driven.wgsl`). The compute dispatch is `MAX_ENTITIES / WORKGROUP_SIZE` workgroups.
|
||||
pub const GPU_WORKGROUP_SIZE: u32 = 64;
|
||||
|
||||
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
|
||||
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
|
||||
pub const SHADOW_MAP_SIZE: u32 = 1024;
|
||||
|
||||
/// Default shadow constant bias (Step 14, D5) subtracted from the reference depth before the
|
||||
/// comparison, to suppress acne without killing contact shadows. This is the minimum bias;
|
||||
/// the slope-scaled term (SHADOW_SLOPE_BIAS) adds more for surfaces at grazing angles.
|
||||
pub const SHADOW_DEPTH_BIAS: f32 = 0.002;
|
||||
|
||||
/// Slope-scaled bias coefficient (Étape 14 fix, 2026-09-24). The effective bias is
|
||||
/// `max(SHADOW_DEPTH_BIAS, SHADOW_SLOPE_BIAS * (1.0 - |dot(N, L)|))` — it grows as the surface
|
||||
/// normal becomes perpendicular to the light direction, where shadow acne is worst. A value of
|
||||
/// 0.004 works well for a 1024² map with a 10-unit ortho frustum; tune per scene scale.
|
||||
pub const SHADOW_SLOPE_BIAS: f32 = 0.006;
|
||||
|
||||
/// Default half-extent (world units) of the orthographic shadow frustum around the scene center
|
||||
/// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples.
|
||||
pub const SHADOW_SCENE_RADIUS: f32 = 5.0;
|
||||
|
||||
/// Default world-space scene center used to place the shadow light for the examples (D3).
|
||||
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;
|
||||
|
||||
/// Default application title displayed in the OS taskbar/window decorations.
|
||||
pub const APP_DEFAULT_TITLE: &str = "WSG App";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Both are consumed by other modules but have no internal dependencies on them.
|
||||
//!
|
||||
//! ## Interaction with Other Modules
|
||||
//! - `pipeline_cache` loads shaders from disk using conf::BASIC_SHADER_PATH; falls back to BASIC_SHADER.
|
||||
//! - `pipeline_cache` loads shaders from disk using conf::STANDARD_SHADER_PATH; falls back to STANDARD_SHADER.
|
||||
//! - `context` returns WsgError variants from all fallible methods (new, configure, begin_frame).
|
||||
//! - `renderer` does not use errors directly (panics on invalid state rather than returning Result).
|
||||
|
||||
@@ -13,6 +13,8 @@ pub mod conf;
|
||||
pub mod error;
|
||||
|
||||
// Re-exports
|
||||
pub use conf::BASIC_SHADER;
|
||||
pub use conf::BASIC_SHADER_PATH;
|
||||
pub use conf::{
|
||||
SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH,
|
||||
STANDARD_SHADER, STANDARD_SHADER_PATH,
|
||||
};
|
||||
pub use error::WsgError;
|
||||
|
||||
+99
-11
@@ -1,21 +1,21 @@
|
||||
//! # Validation WGSL (naga)
|
||||
//!
|
||||
//! Le shader `standard_shader.wgsl` n'est pas encore chargé par un `RenderPipeline` (voir Étapes 3–5) :
|
||||
//! cette validation hors-ligne via `wgpu::naga` est donc la **seule** garantie de sa validité tant qu'il
|
||||
//! n'est pas branché. Elle protège contre les régressions futures (ré-édition du shader, changement de
|
||||
//! layout) sans nécessiter de contexte GPU.
|
||||
//! The `standard_shader.wgsl` shader is not yet loaded by a `RenderPipeline` (see Steps 3–5):
|
||||
//! this offline validation via `wgpu::naga` is therefore the **only** guarantee of its validity until
|
||||
//! it is wired in. It protects against future regressions (shader re-edits, layout changes)
|
||||
//! without requiring a GPU context.
|
||||
//!
|
||||
//! Aucune nouvelle dépendance n'est introduite : `wgpu` ré-exporte `naga`, déjà dépendance de `wsg-lib`.
|
||||
//! No new dependency is introduced: `wgpu` re-exports `naga`, already a `wsg-lib` dependency.
|
||||
|
||||
use wgpu::naga;
|
||||
|
||||
/// Parse et valide complètement le shader embarqué `standard_shader.wgsl` via naga.
|
||||
/// Un échec ici signifie que le shader serait rejeté par `Device::create_shader_module` à l'Étape 3.
|
||||
/// Parses and fully validates the embedded `standard_shader.wgsl` shader via naga.
|
||||
/// A failure here means the shader would be rejected by `Device::create_shader_module` at Step 3.
|
||||
#[test]
|
||||
fn standard_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/standard_shader.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("standard_shader.wgsl : erreur de parsing : {e:?}"));
|
||||
.unwrap_or_else(|e| panic!("standard_shader.wgsl: parsing error: {e:?}"));
|
||||
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
@@ -23,8 +23,96 @@ fn standard_shader_is_valid_wgsl() {
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("standard_shader.wgsl : échec de validation : {e:?}"));
|
||||
.unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}"));
|
||||
|
||||
// Contrat : exactement les deux entrées vs_main / fs_main attendues.
|
||||
assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus");
|
||||
// Contract: exactly the two expected entry points vs_main / fs_main.
|
||||
assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected");
|
||||
}
|
||||
|
||||
/// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.
|
||||
/// The "shadow" pipeline is wired directly by `build_shadow_pipeline` (bypassing the
|
||||
/// PipelineCache), so this offline validation is the guarantee of its validity. The contract
|
||||
/// expects a single entry point (`vs_main` — pipeline with no fragment stage).
|
||||
#[test]
|
||||
fn shadow_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/shadow_shader.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("shadow_shader.wgsl: 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!("shadow_shader.wgsl: validation failed: {e:?}"));
|
||||
|
||||
let entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(entry_names, vec!["vs_main"], "only vs_main expected");
|
||||
}
|
||||
|
||||
/// Parses and fully validates the embedded `gpu_driven.wgsl` compute shader (Phase 3, Step 15)
|
||||
/// via naga. The renderer compiles it directly into two `ComputePipeline`s (one per entry point),
|
||||
/// so this offline validation is the guarantee of its validity. The contract expects exactly the
|
||||
/// two compute entry points: `compute_matrices` and `cull`.
|
||||
#[test]
|
||||
fn gpu_driven_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/gpu_driven.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("gpu_driven.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!("gpu_driven.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!["compute_matrices", "cull"],
|
||||
"the two compute entry points are expected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses and fully validates the embedded `tonemap.wgsl` shader (Étape 20) via naga.
|
||||
/// The renderer compiles it into one `RenderPipeline` (vertex `vs_main` + one of the two
|
||||
/// fragment entry points `fs_aces` / `fs_reinhard`), so this offline validation is the
|
||||
/// guarantee of its validity. The contract expects three entry points.
|
||||
#[test]
|
||||
fn tonemap_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/tonemap.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("tonemap.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!("tonemap.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_aces", "fs_reinhard", "vs_main"],
|
||||
"the three entry points are expected"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user