Compare commits
68 Commits
43e8bfb40a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49aa9e48fd | |||
| 8606aba510 | |||
| e5f3636b42 | |||
| fecfdcd2d2 | |||
| d4c2d93fc5 | |||
| 24fbafc810 | |||
| 7e88390006 | |||
| 83daeb4c7d | |||
| 54a482e354 | |||
| 8ece89ccba | |||
| 9614156848 | |||
| 35aeb769a8 | |||
| 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 |
@@ -5,22 +5,24 @@ Rust workspace (2024 edition) wrapping [wgpu](https://github.com/gfx-rs/wgpu) fo
|
|||||||
|
|
||||||
## Workspace Structure
|
## Workspace Structure
|
||||||
```
|
```
|
||||||
Cargo.toml # workspace root — no dependencies here
|
Cargo.toml # workspace root (members = ["lib"]) — no dependencies here
|
||||||
lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30
|
lib/Cargo.toml # wsg-lib crate: wgpu 30.0.0, winit 0.30 + explicit [[example]] entries
|
||||||
examples/Cargo.toml # depends on wsg-lib via path reference
|
lib/src/ # library source (app, core/, mesh/, pipeline/, resources/, scene/, utils/)
|
||||||
lib/lib.rs # lib entry point
|
lib/examples/ # examples, one subfolder per category (each folder has a README.md):
|
||||||
lib/context.rs # Context type (aggregates wgpu objects: Instance, Surface, Adapter, Device, Queue)
|
│ ├── meshes/ # simple, cube, pbr, import, manual
|
||||||
lib/renderer.rs # renderer implementation
|
│ ├── lights/ # shadow, shadow_test, spot_test, emissive
|
||||||
examples/src/main.rs # example binary
|
│ ├── cameras/ # culling
|
||||||
|
│ └── effects/ # demo, bloom, hdr, msaa, fog, dof
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**Key convention**: examples live in `lib/examples/<category>/` subfolders. Cargo only auto-discovers top-level `examples/*.rs`, so **every example is declared explicitly in `lib/Cargo.toml`** (`[[example]] name = … path = "examples/<cat>/….rs"`). Names are stable: `cargo run -p wsg-lib --example <name>` works as before. Do not publish this to crates.io as-is — it uses local path conventions.
|
||||||
|
|
||||||
## Essential Commands
|
## Essential Commands
|
||||||
| Action | Command |
|
| Action | Command |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| Build everything | `cargo build --workspace` |
|
| Build everything | `cargo build --workspace` |
|
||||||
| Run examples | `cargo run -p examples` |
|
| Run an example | `cargo run -p wsg-lib --example <name>` |
|
||||||
|
| Run a feature-gated example | `cargo run -p wsg-lib --example import --features import-obj` |
|
||||||
| Test | `cargo test --workspace` |
|
| Test | `cargo test --workspace` |
|
||||||
| Check | `cargo check --workspace` |
|
| Check | `cargo check --workspace` |
|
||||||
| Format | `cargo fmt --all` |
|
| Format | `cargo fmt --all` |
|
||||||
@@ -41,8 +43,11 @@ WGPU doesn't have a native "Context" object — this type groups them together f
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
- Rust 2024 edition is used. Ensure your Rust toolchain supports it (`rustup update`).
|
- 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.
|
- 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.
|
- Cargo features gate primitives (`prim-*`, `all-prims` is default) and importers (`import-obj`, `import-gltf`); the `import` example is `required-features = ["import-obj"]`. 138 tests exist (`cargo test --workspace`, incl. particle layout + billboard WGSL validation).
|
||||||
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
|
- 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.
|
||||||
|
- **wgpu 30 API drift** (verified this session): `BufferInitDescriptor` has a `contents: &[u8]` field (not `data`) and `create_buffer_init` comes from the `wgpu::util::DeviceExt` trait (import it, as in `mesh.rs`). `BlendState::ALPHA_BLENDING` is the alpha-blend constant (there is no `ALPHA`); `DepthStencilState` has **no** `Default` impl — write `stencil`/`bias` fields explicitly. `min_binding_size` is `Option<NonZero<u64>>`. bytemuck 1.25: `Zeroable::zeroed()` is not `const` (const traits unstable) — use a const literal for `ZERO`-style constants. For layout-offset tests prefer `std::mem::offset_of!` (stable 1.77, no unsafe).
|
||||||
|
- **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 -->
|
||||||
## 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.
|
|
||||||
@@ -18,6 +18,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -181,6 +187,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -307,6 +319,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -387,12 +408,32 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -565,6 +606,21 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -753,6 +809,36 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "30.0.0"
|
version = "30.0.0"
|
||||||
@@ -1237,6 +1323,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -1316,6 +1415,12 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -1520,6 +1625,12 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd_cesu8"
|
name = "simd_cesu8"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2432,6 +2543,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"glam",
|
"glam",
|
||||||
|
"image",
|
||||||
"pollster",
|
"pollster",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
@@ -2520,3 +2632,24 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,165 +1,177 @@
|
|||||||
# WSG - WGPU Simple Graphics Library
|
# WSG — WGPU Simple Graphics Library
|
||||||
|
|
||||||
WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single `Context`, adds small building blocks (`Mesh`, `Material`, `PipelineCache`, `Frame`), and exposes the low-level primitives for advanced users.
|
**WSG** (WGPU Simple Graphics) is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) to draw 3D **without touching wgpu directly**.
|
||||||
|
|
||||||
> **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).
|
## What you get
|
||||||
|
|
||||||
## Status
|
- **A 3D window in ~30 lines** — no wgpu, no winit in your code
|
||||||
|
- **Phong lighting** (directional, point, spot) + **shadows** (shadow mapping)
|
||||||
|
- **HDR + Tone Mapping** (ACES Filmic / Reinhard) — opt-in, zero cost when disabled
|
||||||
|
- **GPU-driven pipeline** — world matrices + frustum culling on the GPU, indirect draws
|
||||||
|
- **LOD** (Level of Detail) — automatic geometry degradation based on distance
|
||||||
|
- **Procedural primitives** — cube, sphere, cylinder, cone, torus, plane
|
||||||
|
- **File import** — built-in OBJ parser (glTF in progress)
|
||||||
|
- **Orbital camera** + unified input (keyboard/mouse)
|
||||||
|
- **LOD, culling, HDR, shadows**: everything is **opt-in** — what you don't enable costs nothing
|
||||||
|
|
||||||
| Area | State |
|
## Strengths
|
||||||
|------|-------|
|
|
||||||
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working |
|
|
||||||
| `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 infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working at the engine level — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the bundled `basic` shader still ignores them, so visible 3D awaits wiring `standard_shader.wgsl` to an example |
|
|
||||||
|
|
||||||
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. The Phong-lit `standard_shader.wgsl` exists and validates, and the uniform plumbing (bind groups + per-frame camera + per-entity world matrices) is in place, but it is not yet bound to a visible example.
|
| Strength | Detail |
|
||||||
|
|----------|--------|
|
||||||
|
| **Zero wgpu in your code** | The declarative API (`AppBuilder` + `AppHandler`) encapsulates everything |
|
||||||
|
| **Opt-in = zero cost** | A disabled effect allocates nothing, executes nothing |
|
||||||
|
| **Cargo features** | Only compile the primitives/importers you need |
|
||||||
|
| **One shader** | The `standard` shader (Phong) covers 90% of cases; unlit mode for 2D |
|
||||||
|
| **GPU-driven** | CPU sends transforms, GPU does the rest (matrices, culling, draws) |
|
||||||
|
|
||||||
## What it does
|
## Quickstart
|
||||||
|
|
||||||
### Manual workflow (working — recommended today)
|
|
||||||
|
|
||||||
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):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use std::sync::Arc;
|
|
||||||
use winit::event_loop::EventLoop;
|
|
||||||
use winit::window::WindowBuilder;
|
|
||||||
use wsg_lib::core::{Context, Frame, Renderer};
|
|
||||||
use wsg_lib::pipeline::PipelineCache;
|
|
||||||
use wsg_lib::resources::{Material, Mesh, Vertex};
|
|
||||||
use wsg_lib::utils;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Window + async GPU init
|
|
||||||
let event_loop = EventLoop::new().unwrap();
|
|
||||||
let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
|
|
||||||
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
|
|
||||||
let format = context.configure(&context.adapter, 800, 600).expect("surface config failed");
|
|
||||||
|
|
||||||
// Renderer + shader cache (falls back to the embedded shader if the file is missing)
|
|
||||||
let renderer = Renderer::new(&context, format);
|
|
||||||
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
|
|
||||||
cache.register_shader("basic", utils::BASIC_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));
|
|
||||||
|
|
||||||
// Render loop
|
|
||||||
event_loop.run(|event, elwt| {
|
|
||||||
match event {
|
|
||||||
winit::event::Event::AboutToWait => window.request_redraw(),
|
|
||||||
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => {
|
|
||||||
if let Some(frame) = Frame::try_new(&context.surface) {
|
|
||||||
renderer.render(frame.view(), &mesh, &material);
|
|
||||||
renderer.present(frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}).unwrap();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
use wsg_lib::app::AppBuilder;
|
use wsg_lib::app::AppBuilder;
|
||||||
use wsg_lib::{App, AppHandler};
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
struct MyGame;
|
struct MyScene;
|
||||||
|
|
||||||
impl AppHandler for MyGame {
|
impl AppHandler for MyScene {
|
||||||
// update() has an empty default — implement it to mutate scene state each frame.
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
// render(app, frame) has a default that draws the whole scene automatically via
|
app.scene
|
||||||
// app.render_scene(frame.view()). You don't need to implement it for the common case.
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_material("mat", "standard", None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A Phong-lit cube, sitting on a ground plane
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube", cube(1.0), Some("mat"))
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_entity("my_cube", "cube")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground", plane(10.0, 10.0, 1, 1), Some("mat"))
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_entity("floor", "ground")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pollster::main]
|
fn main() -> Result<(), WsgError> {
|
||||||
async fn main() -> Result<(), wsg_lib::utils::WsgError> {
|
let mut app = AppBuilder::new()
|
||||||
let app = AppBuilder::new().build().await?;
|
.title("My WSG scene")
|
||||||
|
.with_hdr(ToneMapper::Aces) // optional: HDR + tone mapping
|
||||||
// Register your scene once (string IDs), then App renders it automatically each frame:
|
.build()?;
|
||||||
// app.cache.register_shader("basic", wsg_lib::utils::BASIC_SHADER_PATH)?;
|
app.run(MyScene);
|
||||||
// app.scene.add_mesh("quad", Arc::new(mesh))?;
|
Ok(())
|
||||||
// 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` (active camera wired to the frame uniforms, Étape 4.3).
|
|
||||||
|
|
||||||
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**.
|
|
||||||
|
|
||||||
## 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) |
|
|
||||||
| 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 | ✅ |
|
|
||||||
| Frame | Struct | Per-frame RAII wrapper (surface texture + view) | ✅ |
|
|
||||||
| Camera / Transform | Struct | Camera & transform math | ✅ Active camera + transform wired to per-frame uniforms (Étape 4.3) |
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
WSG is **not published on crates.io** — depend on it by path:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wsg-lib = { path = "/path/to/wsg/lib" }
|
wsg-lib = { path = "../lib" }
|
||||||
pollster = "0.4" # only if you use the async AppBuilder
|
pollster = { version = "1", features = ["macro"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
| Action | Command |
|
```sh
|
||||||
|--------|---------|
|
cargo run -p wsg-lib --example demo # full showcase (6 primitives, 3 lights, shadows, HDR)
|
||||||
| Build everything | `cargo build --workspace` |
|
```
|
||||||
| Run the working 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.
|
## Features
|
||||||
|
|
||||||
|
| Category | What's available |
|
||||||
|
|----------|-----------------|
|
||||||
|
| **Geometry** | 6 procedural primitives + OBJ import + custom `Geometry` |
|
||||||
|
| **Rendering** | Phong (lit), unlit (2D flat), PBR metallic/roughness, HDR + tone mapping |
|
||||||
|
| **Lights** | Directional, point, spot (8 max) + ambient |
|
||||||
|
| **Shadows** | Shadow mapping (directional/spot), slope-scaled bias, PCF |
|
||||||
|
| **LOD** | Auto quadric decimation, hysteresis, 1 buffer multi-level |
|
||||||
|
| **GPU-driven** | Compute pass (matrices + culling) → indirect draws |
|
||||||
|
| **Post-process** | Bloom, Depth of Field, Fog (3 modes), MSAA 4× |
|
||||||
|
| **Camera** | Orbital (drag/zoom/reset) + presets (front/side/top) |
|
||||||
|
| **Input** | Keyboard (pressed/held/released), mouse (delta, scroll, buttons) |
|
||||||
|
| **Textures** | RGBA8 (from bytes, from file, white placeholder) |
|
||||||
|
|
||||||
## Documentation
|
## 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:
|
| Where | What |
|
||||||
|
|-------|------|
|
||||||
|
| [docs/user/](docs/user/README.md) | **User guide** — how to use the API, step by step |
|
||||||
|
| [docs/tech/](docs/tech/ARCHI_APP.md) | **Internal architecture** — decisions, specs, targets |
|
||||||
|
| [lib/examples/](lib/examples/README.md) | **Examples** — 4 category folders (meshes/lights/cameras/effects), each with a README |
|
||||||
|
| [docs/ROADMAP.md](docs/ROADMAP.md) | Roadmap (phases 1-5 ✅, phase 6 in progress) |
|
||||||
|
| [docs/PLAN.md](docs/PLAN.md) | Recipe book (step history) |
|
||||||
|
| `cargo doc -p wsg-lib --no-deps` | **API reference** (rustdoc, 100% covered) |
|
||||||
|
|
||||||
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet.
|
## Examples
|
||||||
- [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.
|
|
||||||
- [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.
|
|
||||||
|
|
||||||
## Roadmap
|
Sixteen examples in `lib/examples/`, organized into **four category folders** —
|
||||||
|
each folder has its own README (run commands, keys, what to observe):
|
||||||
|
[`lib/examples/README.md`](lib/examples/README.md). All run from the repo root
|
||||||
|
with `cargo run -p wsg-lib --example <name>` (feature-gated ones need
|
||||||
|
`--features`, e.g. `import` → `--features import-obj`).
|
||||||
|
|
||||||
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.)
|
| Folder | Example | What it shows |
|
||||||
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).
|
|--------|---------|---------------|
|
||||||
3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering.
|
| [`meshes/`](lib/examples/meshes/README.md) | `simple` | Minimal declarative workflow (flat unlit quad, ~15 lines) |
|
||||||
4. **Real 3D pipeline** — MVP uniforms + camera support in the vertex shader. *(Engine-side plumbing done 2026-09-16: uniform bind groups, per-frame active camera matrices, per-entity world matrices; visible 3D awaits wiring `standard_shader.wgsl` to an example — Étape 5.)*
|
| | `cube` | Textured, lit, spinning cube (the 3D MVP) |
|
||||||
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.
|
| | `pbr` | PBR metallic/roughness + normal mapping |
|
||||||
6. **Error unification** — replace `Result<_, String>` in `Scene`/`PipelineCache` with typed errors.
|
| | `import` | OBJ file import (feature `import-obj`) |
|
||||||
|
| | `manual` | Low-level workflow (Context/Renderer/PipelineCache, no App) |
|
||||||
|
| [`lights/`](lib/examples/lights/README.md) | `shadow` | Shadow mapping in isolation |
|
||||||
|
| | `shadow_test` | Dedicated shadow test (directional caster + PCF) |
|
||||||
|
| | `spot_test` | Isolated spot light (beam, penumbra) |
|
||||||
|
| | `emissive` | Emissive materials + runtime exposure control |
|
||||||
|
| [`cameras/`](lib/examples/cameras/README.md) | `culling` | GPU-driven frustum culling (15×15 grid) |
|
||||||
|
| [`effects/`](lib/examples/effects/README.md) | `demo` | Full showcase: 6 primitives, 3 lights, shadows, HDR, LOD, orbital camera |
|
||||||
|
| | `bloom` | HDR bloom post-process |
|
||||||
|
| | `hdr` | HDR + tone mapping (ACES/Reinhard) |
|
||||||
|
| | `msaa` | 4× multisample anti-aliasing |
|
||||||
|
| | `fog` | 3 fog modes (linear, exponential, exponential²) |
|
||||||
|
| | `dof` | Depth of field with focus presets |
|
||||||
|
|
||||||
|
## Cargo Features
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Default: all primitives
|
||||||
|
wsg-lib = { path = "../lib" }
|
||||||
|
|
||||||
|
# Minimal: just the cube
|
||||||
|
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube"] }
|
||||||
|
|
||||||
|
# With OBJ import
|
||||||
|
wsg-lib = { path = "../lib", features = ["import-obj"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feature | Enables |
|
||||||
|
|---------|---------|
|
||||||
|
| `prim-cube`, `prim-plane`, `prim-sphere`, `prim-cylinder`, `prim-cone`, `prim-torus` | Primitives |
|
||||||
|
| `all-prims` (default) | All 6 primitives |
|
||||||
|
| `import-obj` | Wavefront OBJ parser |
|
||||||
|
| `import-gltf` | glTF (stub) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --workspace # everything
|
||||||
|
cargo test --workspace # 127 tests
|
||||||
|
cargo check --all-targets # quick check
|
||||||
|
cargo run -p wsg-lib --example demo # run the showcase
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
- **Language**: Rust 2024
|
||||||
|
- **Dependencies**: wgpu 30, winit 0.30, glam (math)
|
||||||
|
- **Not published on crates.io** (path dependency)
|
||||||
|
- **Status**: MVP complete (phases 1-5 ✅), post-MVP in progress (phase 6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Detailed documentation (architecture, status, API reference, manual workflow): [README_DETAILS.md](README_DETAILS.md)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> This project was heavily developed using OpenCode, Pi Code, and JCode AI agents running on local Qwen3-27b_Q4 and DeepSeek V4 Flash Q4 instances. The project organization and architecture are the author's own design.
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# WSG — Detailed Documentation
|
||||||
|
|
||||||
|
> Technical content from the main README: status, architecture, API reference, workflows, roadmap.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Area | State |
|
||||||
|
|------|-------|
|
||||||
|
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
|
||||||
|
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, automatic scene rendering |
|
||||||
|
| `Scene` resource/entity registry | ✅ Working — auto-rendered in one batched pass (`App::render_scene`) |
|
||||||
|
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Phase 3) — `render_scene` + shadow pass 100% indirect; opt-in frustum culling |
|
||||||
|
| 3D infrastructure (uniform bind groups, MVP + camera) | ✅ Working — per-frame camera + per-entity world matrices in shared uniforms |
|
||||||
|
| Shadows (shadow mapping) | ✅ Working — directional/spot, slope-scaled bias, PCF 3×3 |
|
||||||
|
| HDR + Tone Mapping | ✅ Working — offscreen Rgba16Float, ACES/Reinhard, opt-in |
|
||||||
|
| LOD (Level of Detail) | ✅ Working — quadric decimation, hysteresis, multi-level buffer |
|
||||||
|
| Mesh module (primitives + import) | ✅ Working — feature-gated primitives, OBJ parser |
|
||||||
|
| Bloom | ✅ Working — threshold + separable blur + composite, HDR required |
|
||||||
|
| Fog | ✅ Working — 3 modes (linear, exp, exp²), runtime switchable |
|
||||||
|
| MSAA | ✅ Working — 4× multisample, resolve pass |
|
||||||
|
| DoF | ✅ Working — CoC + disc blur, focus presets |
|
||||||
|
| PBR (metallic/roughness + normal maps) | ✅ Working — Cook-Torrance, GGX, IBL, derivative tangent |
|
||||||
|
|
||||||
|
Note: `standard_shader.wgsl` (Phong + PBR, with an explicit **unlit** mode) is the **single** shader the library ships. Flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Layer model
|
||||||
|
|
||||||
|
- **Manager layer (`Context`)** — owns the GPU hardware lifecycle (Instance → Surface → Adapter → Device → Queue). Created once at startup; `configure()` sets up the swapchain, `Frame` wraps each frame's surface texture + view.
|
||||||
|
- **Executor layer (`Renderer`)** — binds a `Material` pipeline + `Mesh` buffers into a RenderPass and submits. `render_scene` batches all entities into one encoder + one submit per frame.
|
||||||
|
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform`.
|
||||||
|
|
||||||
|
### GPU-driven pipeline (Phase 3)
|
||||||
|
|
||||||
|
A Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot.
|
||||||
|
|
||||||
|
Spec: [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) · User guide: [docs/user/cameras/gpu-driven.md](docs/user/cameras/gpu-driven.md)
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/src/
|
||||||
|
├── lib.rs # crate root, re-exports
|
||||||
|
├── prelude.rs # glob re-exports
|
||||||
|
├── app.rs # App + AppBuilder
|
||||||
|
├── handler.rs # AppHandler trait
|
||||||
|
├── camera.rs # Camera, CameraController
|
||||||
|
├── input.rs # InputState
|
||||||
|
├── lights.rs # Lights, Light, LightType, directional_light, …
|
||||||
|
├── core/
|
||||||
|
│ ├── context.rs # GPU lifecycle (Instance/Surface/Adapter/Device/Queue)
|
||||||
|
│ ├── renderer.rs # RenderPass execution, shadow pass, HDR/TM pass
|
||||||
|
│ ├── frame.rs # Per-frame RAII (surface texture + view)
|
||||||
|
│ ├── geometry.rs # Geometry (positions/normals/UVs/indices) + BBox
|
||||||
|
│ ├── transform.rs # Transform (translation/rotation/scale)
|
||||||
|
│ ├── frustum.rs # Frustum (6 planes, sphere/box culling)
|
||||||
|
│ ├── lod.rs # LOD decimation (quadric edge collapse)
|
||||||
|
│ ├── shadow.rs # ShadowConfig (map size, bias, PCF)
|
||||||
|
│ ├── hdr.rs # ToneMapper enum (Aces/Reinhard)
|
||||||
|
│ ├── bloom.rs # BloomConfig + BloomPipeline
|
||||||
|
│ ├── msaa.rs # MsaaConfig
|
||||||
|
│ ├── fog.rs # FogConfig + FogMode
|
||||||
|
│ └── dof.rs # DoFConfig + DoFPipeline
|
||||||
|
├── mesh/
|
||||||
|
│ ├── mod.rs # Re-exports flat
|
||||||
|
│ ├── primitives/ # 6 feature-gated generators
|
||||||
|
│ └── import/ # OBJ parser + glTF stub
|
||||||
|
├── pipeline/ # PipelineCache (shader → RenderPipeline)
|
||||||
|
├── resources/ # Mesh, Material, Texture, Uniform, Vertex
|
||||||
|
├── scene/ # Scene (registry), Entity
|
||||||
|
└── utils/ # Conf constants, WsgError
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick reference (types)
|
||||||
|
|
||||||
|
| Concept | Type | Responsibility |
|
||||||
|
|---------|------|---------------|
|
||||||
|
| App / AppBuilder | Facade | Window + event loop + frame + auto scene render |
|
||||||
|
| AppHandler | Trait | `setup()` / `update()` / `render()` callbacks |
|
||||||
|
| Scene | Struct | Registry: shaders, materials, meshes, entities, lights, camera |
|
||||||
|
| Context | Struct | GPU hardware (Instance, Surface, Adapter, Device, Queue) |
|
||||||
|
| Renderer | Struct | RenderPass execution (scene, shadow, HDR/TM, bloom, DoF) |
|
||||||
|
| PipelineCache | Struct | Shader → compiled RenderPipeline cache |
|
||||||
|
| Material | Struct | Shader ID + texture + pipeline + PBR params |
|
||||||
|
| Geometry | Struct | CPU vertex data (positions/normals/UVs/colors/indices) |
|
||||||
|
| Mesh / Vertex | Struct | GPU geometry / interleaved upload tuple |
|
||||||
|
| Frame | Struct | Per-frame RAII (surface texture + view) |
|
||||||
|
| Camera / Transform | Struct | Camera math + per-entity transform |
|
||||||
|
| CameraController | Struct | Orbital camera (orbit/zoom/reset/apply_to) |
|
||||||
|
| InputState | Struct | Unified keyboard/mouse (pressed/held/released, delta, scroll) |
|
||||||
|
| Texture | Struct | GPU image (Rgba8UnormSrgb) + sampler |
|
||||||
|
| Lights / Light | Struct | Light list (directional/point/spot, MAX=8) + ambient |
|
||||||
|
| ShadowConfig | Struct | Shadow map size, bias, PCF taps, scene radius |
|
||||||
|
| ToneMapper | Enum | ACES Filmic / Reinhard |
|
||||||
|
| BloomConfig | Struct | Threshold, intensity, H/V passes |
|
||||||
|
| MsaaConfig | Struct | Sample count (1 = disabled) |
|
||||||
|
| FogConfig | Struct | Mode, near/far, density, color |
|
||||||
|
| DoFConfig | Struct | Focus distance, aperture, max blur |
|
||||||
|
| BBox | Struct | Axis-aligned bounding box (min/max) |
|
||||||
|
| Frustum | Struct | 6 planes, sphere/box culling |
|
||||||
|
|
||||||
|
## Declarative workflow (recommended)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct MyScene;
|
||||||
|
|
||||||
|
impl AppHandler for MyScene {
|
||||||
|
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", cube(1.0), Some("mat")).unwrap();
|
||||||
|
app.scene.add_entity("my_cube", "cube").unwrap();
|
||||||
|
}
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// your per-frame logic
|
||||||
|
}
|
||||||
|
// render() default: app.render_scene(frame.view()) — auto-draws everything
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new().title("WSG").build().await?;
|
||||||
|
app.run(MyScene);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `Scene` methods return `Result<_, String>` — typed-error unification is on the roadmap.
|
||||||
|
|
||||||
|
## Manual workflow (advanced)
|
||||||
|
|
||||||
|
Bypass the `App` facade and drive `Context`, `Renderer` and `PipelineCache` yourself:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::Arc;
|
||||||
|
use winit::event_loop::EventLoop;
|
||||||
|
use winit::window::WindowBuilder;
|
||||||
|
use wsg_lib::core::{Context, Frame, Renderer};
|
||||||
|
use wsg_lib::pipeline::PipelineCache;
|
||||||
|
use wsg_lib::resources::{Geometry, Material, Mesh};
|
||||||
|
use wsg_lib::utils;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let event_loop = EventLoop::new().unwrap();
|
||||||
|
let window = Arc::new(WindowBuilder::new().build(&event_loop).unwrap());
|
||||||
|
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init");
|
||||||
|
let format = context.configure(&context.adapter, 800, 600).expect("surface config");
|
||||||
|
|
||||||
|
let mut renderer = Renderer::new(&context, format, 800, 600);
|
||||||
|
let mut cache = PipelineCache::new(Arc::new(context.device.clone()));
|
||||||
|
cache.register_shader("standard", utils::STANDARD_SHADER_PATH).unwrap();
|
||||||
|
|
||||||
|
let material = Material::new(renderer.format(), "standard", &mut cache);
|
||||||
|
let geometry = Geometry::new(vec![-0.5f32, 0.5, 0.0, 0.5, 0.5, 0.0, 0.5, -0.5, 0.0, -0.5, -0.5, 0.0])
|
||||||
|
.with_indices(vec![0, 1, 2, 0, 2, 3]);
|
||||||
|
let mesh = Mesh::from_geometry(renderer.device(), Arc::new(geometry), None);
|
||||||
|
|
||||||
|
event_loop.run(|event, elwt| {
|
||||||
|
match event {
|
||||||
|
winit::event::Event::AboutToWait => window.request_redraw(),
|
||||||
|
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::RedrawRequested, .. } => {
|
||||||
|
if let Some(frame) = Frame::try_new(&context.surface) {
|
||||||
|
renderer.render(frame.view(), &mesh, &material);
|
||||||
|
renderer.present(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winit::event::Event::WindowEvent { event: winit::event::WindowEvent::CloseRequested, .. } => elwt.exit(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Default | Provides |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `prim-cube` | ✅ | `cube(size)` |
|
||||||
|
| `prim-plane` | ✅ | `plane(w, d, seg_x, seg_z)` |
|
||||||
|
| `prim-sphere` | ✅ | `uv_sphere(…)`, `icosphere(…)` |
|
||||||
|
| `prim-cylinder` | ✅ | `cylinder(…)` |
|
||||||
|
| `prim-cone` | ✅ | `cone(…)` |
|
||||||
|
| `prim-torus` | ✅ | `torus(…)` |
|
||||||
|
| `all-prims` | ✅ (default) | All 6 primitives |
|
||||||
|
| `import-obj` | ⬜ | `load_obj(path)`, `parse_obj(str)` |
|
||||||
|
| `import-gltf` | ⬜ | `load_gltf(path)` (stub) |
|
||||||
|
|
||||||
|
## Design principle: opt-in = zero cost
|
||||||
|
|
||||||
|
| Feature | How to enable | If NOT enabled |
|
||||||
|
|---------|--------------|----------------|
|
||||||
|
| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map, no depth pass, no PCF |
|
||||||
|
| HDR + TM | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass |
|
||||||
|
| Bloom | `AppBuilder::with_bloom(BloomConfig::…)` | No bloom textures, no passes |
|
||||||
|
| MSAA | `AppBuilder::with_msaa(MsaaConfig { sample_count: 4 })` | Single sample, no resolve |
|
||||||
|
| Fog | `AppBuilder::with_fog(FogConfig::…)` | No fog uniforms |
|
||||||
|
| DoF | `AppBuilder::with_dof(DoFConfig::…)` | No CoC/blur textures |
|
||||||
|
| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect buffers |
|
||||||
|
| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh |
|
||||||
|
| Primitives | Cargo feature `prim-*` | Not compiled |
|
||||||
|
| File import | Cargo feature `import-*` | Not compiled |
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
| Phase | Status |
|
||||||
|
|-------|--------|
|
||||||
|
| 1 — Foundations (window, render loop, Context) | ✅ |
|
||||||
|
| 2 — 3D infrastructure (Geometry, Mesh, Material, Pipeline) | ✅ |
|
||||||
|
| 3 — GPU-driven (compute pass, indirect draws, culling) | ✅ |
|
||||||
|
| 4 — Advanced rendering (shadows, HDR/TM, lights) | ✅ |
|
||||||
|
| 5 — Polish (LOD, camera controller, input, demo) | ✅ |
|
||||||
|
| 6 — Post-MVP (bloom, PBR, fog, DoF, MSAA, cascaded shadows, SSAO) | 🔄 |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Where | What |
|
||||||
|
|-------|------|
|
||||||
|
| [docs/user/](docs/user/README.md) | User guide |
|
||||||
|
| [docs/tech/](docs/tech/ARCHI_APP.md) | Internal architecture |
|
||||||
|
| [docs/ROADMAP.md](docs/ROADMAP.md) | Roadmap |
|
||||||
|
| [docs/PLAN.md](docs/PLAN.md) | Recipe book (step history) |
|
||||||
|
| `cargo doc -p wsg-lib --no-deps` | API reference (rustdoc) |
|
||||||
@@ -1,159 +1,468 @@
|
|||||||
# DRAFT — Plan d'implémentation : « 3D + éclairage Phong »
|
# Étape 28 — Système de Particules : Étape A (Pool)
|
||||||
|
|
||||||
> **Usage.** Ce fichier (dans `docs/`) sert de brouillon pour le plan détaillé de l'étape en cours.
|
> **Objectif** : Créer l'infrastructure GPU du pool de particules (buffers + pipeline render +
|
||||||
> **Son contenu est effacé au début de chaque nouvelle étape.** La source de vérité de l'état est
|
> bind group), **sans driver**. C'est la brique de base sur laquelle les drivers
|
||||||
> le code + README.md ; les autres docs `docs/*` restent stables.
|
> (GPU/CPU/Manual) seront construits.
|
||||||
>
|
> **Référence** : `docs/tech/ARCHI_PARTICULES.md` (§2, §3.2, §6, §7.2, §8.2, §12, §13, §18)
|
||||||
> **É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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Point d'étape — 2026-09-16 (fin de session, reprise sur autre machine)
|
## Contexte
|
||||||
|
|
||||||
**État : infra posée, MVP 3D pas encore atteint.** Dernier commit : `f10e249`
|
La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
|
||||||
(`feat(renderer): active camera wired to frame uniforms (Étape 4.3)`), dépôt propre.
|
On passe maintenant au **système de particules** — une nouvelle catégorie de fonctionnalité
|
||||||
|
(simulation + rendu) qui suit l'architecture Pool ≠ Driver décrite dans `ARCHI_PARTICULES.md`.
|
||||||
|
|
||||||
**Fait et committé (bases pour la reprise)**
|
Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric)
|
||||||
- Étapes **1, 2.1, 2.2, 3, 4** du DRAFT → ✅ (détails cochés ci-dessous).
|
seront repris **après** le système de particules (phase 7).
|
||||||
- `cargo check --workspace --examples` 0 warning, tests Pod + wgsl OK, doc 0 warning, fmt propre.
|
|
||||||
- `standard_shader.wgsl` validé par naga (test permanent) mais **pas encore branché** sur un pipeline d'exemple.
|
|
||||||
- `basic` ignore encore les uniforms → les exemples `simple`/`manual` tournent mais le rendu reste plat.
|
|
||||||
|
|
||||||
**Prochaine session — reprendre à (dans l'ordre)**
|
|
||||||
1. **Étape 5** : créer `lib/examples/cube.rs` (cube unitaire + `standard` éclairé + rotation, via `AppBuilder` sans wgpu), puis migrer `simple`/`manual` (2.3 + 5.2 : `basic` → variante unlit de `standard`).
|
|
||||||
2. **Validation réelle** Étape 4/5 : exécuter les exemples, confirmer rotation/éclairage sur GPU/fenêtre.
|
|
||||||
3. **Étape 6** : cas limites + update `README.md`/`PLAN.md`/`ROADMAP.md` (cases 1.3/1.5/2.3) + commits.
|
|
||||||
|
|
||||||
> Les cases 2.3, Étape 4-validation, Étape 5 et Étape 6 restent **non cochées** ci-dessous = état exact.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Étape 1 — Fondations data : Transform + Camera exposées
|
## Scope de cette étape (A)
|
||||||
|
|
||||||
**But** : donner à chaque entité un `Transform` et rendre `Camera` utilisable via l'API publique, **sans**
|
| Fait | Non fait (étapes suivantes) |
|
||||||
toucher au rendu (pure façade de données, validable par compilation).
|
|------|---------------------------|
|
||||||
|
| Struct `Particle` (**80 B, sans padding** [D15/D19]) | Driver GPU (compute + spawn + compaction) — Étape B |
|
||||||
|
| `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C |
|
||||||
|
| `ParticlePool` (4 buffers + pipeline + bind group) | Driver Manual + handle — Étape D |
|
||||||
|
| Pipeline render (billboard instancé, layout vertex vide) | Intégration Renderer (frame loop, `draw_indirect`) — Étape E |
|
||||||
|
| Vertex shader (quad via vertex_index + `compact_index[ii]` [D17/D19]) | Presets + Example — Étape F |
|
||||||
|
| Fragment shader (texture × color, UV via `uv_rect` [D15]) | Tests WGSL compute — Étape B |
|
||||||
|
| Texture par défaut (disque 16×16) | |
|
||||||
|
| Méthode `Scene::create_particle_pool` (+ champs `SceneGpu`) | |
|
||||||
|
| Pool inactif par défaut (args indirect = 0 → no-op) | |
|
||||||
|
|
||||||
- [X] 1.1 **Exporter `Camera`** : dans `lib/src/resources/mod.rs`, ajouter
|
> **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver =
|
||||||
`pub mod camera;` et `pub use camera::Camera;` (aujourd'hui fichier orphelin non compilé). *(fait — 2026-09-16)*
|
> `indirect_args` à zéro = `drawIndirect` no-op, §12 ARCHI). Le draw sera activé à l'étape E
|
||||||
- [X] 1.2 **Type `Entity` + transform** : nouvelle struct
|
> (intégration Renderer). On peut néanmoins tester le pipeline en forçant des args
|
||||||
`Entity { mesh_id: String, material_id: String, transform: Transform }` (module `scene` ou `resources`).
|
> artificiels dans un test.
|
||||||
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`.)*
|
|
||||||
|
|
||||||
## Étape 2 — Shader Phong `standard_shader.wgsl`
|
|
||||||
|
|
||||||
**But** : produire un rendu 3D éclairé via un nouveau shader, sans encore le brancher.
|
|
||||||
|
|
||||||
- [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)*
|
|
||||||
|
|
||||||
## Étape 3 — Infrastructure uniforms dans le `PipelineCache`
|
|
||||||
|
|
||||||
**But** : permettre aux pipelines de recevoir des uniforms (bind groups) au lieu de `bind_group_layouts: &[]`.
|
|
||||||
|
|
||||||
- [X] 3.1 **Types bytemuck `Pod`** (nouveau `lib/src/resources/uniform.rs`) : `FrameUniforms` (192 B) et
|
|
||||||
`ObjectUniform` (64 B), `#[repr(C)]`, 16-byte alignés, sans padding — offset vérifiés par un test
|
|
||||||
unitaire contre la table du shader. Exports via `resources/mod.rs`. *(fait — 2026-09-16. Au passage,
|
|
||||||
`glam` feature `bytemuck` activé pour que `Mat4`/`Vec4` implémentent `Pod`/`Zeroable`.)*
|
|
||||||
- [X] 3.2 **Bind group layouts** : nouveau `create_uniform_bind_group_layouts(device)` (dans
|
|
||||||
`pipeline_cache.rs`, exporté) → frame @0 (`Uniform`, `Vertex|Fragment`) + object @1 (`Uniform`, `Vertex`).
|
|
||||||
`build_pipeline` les passe dans le `PipelineLayoutDescriptor`. `immediate_size` reste 0.
|
|
||||||
*(fait — 2026-09-16)*
|
|
||||||
- [X] 3.3 **Acté : un seul layout pour tous** (option A). `build_pipeline` attache **toujours** les 2 bind
|
|
||||||
groups (frame @0 + object @1), même si le shader ne les lit pas (validation wgpu : layout╱bind group).
|
|
||||||
*(fait — 2026-09-16)*
|
|
||||||
- [X] **Validation** : `cargo check --workspace --examples` 0 warning ; `cargo doc --no-deps` 0 warning ;
|
|
||||||
`cargo test` (types Pod + wgsl naga) OK ; `cargo fmt` propre. *(fait — 2026-09-16)*
|
|
||||||
|
|
||||||
## Étape 4 — Rendu 3D dans le `Renderer`
|
|
||||||
|
|
||||||
**But** : `render_scene` applique matrices + éclairage par entité.
|
|
||||||
|
|
||||||
- [X] 4.1 **Buffers frame partagés** : le `Renderer::new` crée le `wgpu::Buffer` `FrameUniforms` + `BindGroup(0)`
|
|
||||||
(défaut : caméra identité + lumière blanche + mode lit). *(fait — 2026-09-16)*
|
|
||||||
- [X] 4.2 **Buffers object par entité** : le `Renderer` maintient un cache
|
|
||||||
`RefCell<HashMap<String,(wgpu::Buffer, wgpu::BindGroup)>>` clefé par label d'entité ; chaque frame il
|
|
||||||
écrit `ObjectUniform.world = entity.transform.to_matrix()` (via `object_bind_group_for`). *(fait — 2026-09-16)*
|
|
||||||
- [X] 4.3 **Caméra active** : `Scene` porte une caméra active (`Camera::default()` : position (0,0,3),
|
|
||||||
fov 45°, near 0.1, far 100) via `set_camera()` / `camera()` ; `Camera` enrichie (fov/near/far +
|
|
||||||
`with_perspective` / `projection_matrix(aspect)`). Chaque frame, `Renderer::render_scene` écrit
|
|
||||||
view/proj/cam_pos réels dans le buffer frame via `write_frame_uniforms` ; l'aspect est calculé par
|
|
||||||
`App::render_scene` depuis `window.inner_size()` (le Renderer reste indépendant de la fenêtre).
|
|
||||||
*(fait — 2026-09-16)*
|
|
||||||
- [X] 4.4 **`draw_entity` étendu** : pose `set_bind_group(0, frame_bg)` + `set_bind_group(1, object_bg)` avant le
|
|
||||||
draw (groupes requis par le layout unique) ; le chemin bas-niveau `Renderer::render` pose aussi les 2 bind
|
|
||||||
groups (frame partagé + object identité partagé). *(fait — 2026-09-16)*
|
|
||||||
- [ ] **Validation** : `cargo check` 0 warning ; exécution `simple` (sans panique, boucle active). *(une partie :
|
|
||||||
`simple` reste exécutable car `basic` ignore les uniforms ; le rendu 3D réel attend l'Étape 5 où `standard` est
|
|
||||||
branché sur un exemple)*
|
|
||||||
|
|
||||||
## É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écisions appliquées (rappel de ARCHI_PARTICULES.md §17/§18)
|
||||||
|
|
||||||
| Décision | Option proposée | Justification |
|
| # | Décision | Détail |
|
||||||
|----------|-----------------|---------------|
|
|---|----------|--------|
|
||||||
| 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 |
|
| D4 | **80 bytes/particule, sans padding** [D15/D19] | pos@0, vel@12, life@24, max_life@28, size@32, size_growth@36, angle@40, angular_vel@44, color@48, uv_rect@64. Espace **storage** : vec3/vec4 align 4 → layout Rust = WGSL identique. |
|
||||||
| 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) |
|
| D5 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
|
||||||
| Cache object buffer | `RefCell<HashMap<label, (Buffer, BindGroup)>>` dans `Renderer` | `render_scene(&self)` immuable ; MVP petit nombre d'entités |
|
| D6 | Quad via `@builtin(vertex_index)`, **layout vertex vide** [D17/D19] | Pas de vertex buffer. Le slot arrive par **storage** (`compact_index[ii]`), pas par attribut. |
|
||||||
| Transform dans l'entité | `Entity { mesh_id, material_id, transform }` + `add_entity_with_transform` | `add_entity` garde sa signature (transform identité) |
|
| D9 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
|
||||||
| 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 |
|
| D10 | Depth test oui, depth write non | Transparence correcte |
|
||||||
| 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 » |
|
| D11 | Texture par défaut : disque 16×16 | Si `texture: None` |
|
||||||
| 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 |
|
| D12 | Pool inactif si pas de driver | Zéro compute ; `indirect_args` = 0 → draw no-op |
|
||||||
|
| D15 | UV par particule (`uv_rect`) | `uv = uv_rect.xy + (q + 0.5) * uv_rect.zw` |
|
||||||
|
| D17 | Compaction + indirect draw | Buffers `compact_index` (N × u32) + `indirect_args` (16 B) créés ici ; la compaction elle-même est dans le compute de l'étape B. |
|
||||||
|
| D19 | Audit layout | Un seul struct `Particle` partagé (pas de `ParticleAlive`), 1 u32 par slot dans `compact_index`, args 16 B. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers à créer / modifier
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
└── src/
|
||||||
|
├── shaders/
|
||||||
|
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main (pas de compute)
|
||||||
|
├── utils/
|
||||||
|
│ └── conf.rs # + pub const PARTICLE_BILLBOARD_SHADER (include_str!, pattern existant)
|
||||||
|
├── resources/
|
||||||
|
│ ├── mod.rs # + pub mod particle + re-export
|
||||||
|
│ └── particle.rs # NOUVEAU : struct Particle (80 B, Pod, sans padding)
|
||||||
|
├── core/
|
||||||
|
│ ├── mod.rs # + pub mod particles
|
||||||
|
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
|
||||||
|
├── scene/
|
||||||
|
│ └── scene.rs # + SceneGpu { queue, sample_count }
|
||||||
|
│ # + particle_pools: HashMap<String, Arc<ParticlePool>>
|
||||||
|
│ # + create_particle_pool()
|
||||||
|
└── prelude.rs # + re-exports
|
||||||
|
|
||||||
|
lib/tests/
|
||||||
|
└── wgsl_validate.rs # + tests particle_billboard
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Changement `SceneGpu`** : le struct actuel ne garde que `device`/`format`/`cache`
|
||||||
|
> (le `queue` et le `sample_count` sont reçus par `init_gpu` puis jetés). Le pool a besoin
|
||||||
|
> des deux pour créer son pipeline au moment du `create_particle_pool` → ajouter les deux
|
||||||
|
> champs à `SceneGpu` (stokés au lieu d'être jetés). Aucun autre impact : `init_gpu` garde
|
||||||
|
> sa signature. Le depth format vient de la constante `crate::pipeline::DEPTH_FORMAT`
|
||||||
|
> (pas de champ à ajouter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Détail des implémentations
|
||||||
|
|
||||||
|
### 1. `resources/particle.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
|
/// 80 bytes per particle. Mirror of the WGSL `Particle` struct (ARCHI §2 / §8).
|
||||||
|
/// Layout **sans padding** : espace storage (vec3/vec4 align 4) → Rust = WGSL identique [D19].
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, Pod, Zeroable, Default)]
|
||||||
|
pub struct Particle {
|
||||||
|
pub pos: [f32; 3], // offset 0
|
||||||
|
pub vel: [f32; 3], // offset 12
|
||||||
|
pub life: f32, // offset 24
|
||||||
|
pub max_life: f32, // offset 28
|
||||||
|
pub size: f32, // offset 32
|
||||||
|
pub size_growth: f32, // offset 36
|
||||||
|
pub angle: f32, // offset 40
|
||||||
|
pub angular_vel: f32, // offset 44
|
||||||
|
pub color: [f32; 4], // offset 48
|
||||||
|
pub uv_rect: [f32; 4], // offset 64 — zone UV (ox, oy, sx, sy) [D15]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Particle {
|
||||||
|
/// Taille d'un élément du buffer storage : **80 bytes** (doit rester stable — testé).
|
||||||
|
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64;
|
||||||
|
/// Particule nulle (life = 0 → morte). `Default`.
|
||||||
|
pub const ZERO: Self = Self::default();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests** : `size_of::<Particle>() == 80`, `align_of::<Particle>() == 4`, offsets de chaque
|
||||||
|
champ (0/12/24/28/32/36/40/44/48/64), `ZERO.life == 0.0`.
|
||||||
|
|
||||||
|
### 2. `core/particles.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum BlendingMode {
|
||||||
|
#[default]
|
||||||
|
Alpha,
|
||||||
|
Additive,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParticlePoolConfig {
|
||||||
|
/// Capacité max du pool (slots). Défaut : 1024.
|
||||||
|
pub max_count: u32,
|
||||||
|
/// ID d'une texture dans `scene.textures`. `None` → disque 16×16 par défaut [D11].
|
||||||
|
pub texture: Option<String>,
|
||||||
|
/// Mode de blending figé au pipeline [D9].
|
||||||
|
pub blending: BlendingMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParticlePool {
|
||||||
|
/// État des particules : N × 80 B. STORAGE | COPY_DST. Zéro initialisé (toutes mortes).
|
||||||
|
pub(crate) buffer: wgpu::Buffer,
|
||||||
|
/// Index compact : N × u32, 1 par slot [D17/D19]. STORAGE | COPY_DST.
|
||||||
|
pub(crate) compact_index: wgpu::Buffer,
|
||||||
|
/// Args indirect draw : 16 B (4 × u32) [D17/D19]. STORAGE | COPY_DST. Zéro initialisé.
|
||||||
|
pub(crate) indirect_args: wgpu::Buffer,
|
||||||
|
/// Camera params : 128 B (view + proj). UNIFORM | COPY_DST.
|
||||||
|
/// Possédée par le pool ; écrite par le Renderer à chaque frame (étape E).
|
||||||
|
pub(crate) camera_params: wgpu::Buffer,
|
||||||
|
pub(crate) pipeline: wgpu::RenderPipeline,
|
||||||
|
pub(crate) layout: wgpu::BindGroupLayout,
|
||||||
|
/// Bind group construit une fois à la création (tout est possédé par le pool [D17/D19]).
|
||||||
|
pub(crate) bind_group: wgpu::BindGroup,
|
||||||
|
pub(crate) sampler: wgpu::Sampler,
|
||||||
|
pub max_count: u32,
|
||||||
|
pub blending: BlendingMode,
|
||||||
|
// Driver (étapes B/C/D) :
|
||||||
|
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
|
||||||
|
pub(crate) active: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Construit** par `Scene::create_particle_pool` (device + queue + format + textures).
|
||||||
|
Le pipeline est compilé immédiatement. `ParticleDriver` (trait) est déclaré ici mais
|
||||||
|
ses implémentations arrivent aux étapes B/C/D — le champ `driver` reste `None` à cette étape.
|
||||||
|
|
||||||
|
### 3. `shaders/particle_billboard.wgsl`
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
// Particle billboard shader (vertex + fragment). Étape A — pas de compute.
|
||||||
|
// Layout vertex VIDE : quad généré en shader, slot par storage [D17/D19].
|
||||||
|
|
||||||
|
struct Particle { // 80 B — espace storage, vec3/vec4 align 4, pas de padding [D19]
|
||||||
|
pos: vec3<f32>,
|
||||||
|
vel: vec3<f32>,
|
||||||
|
life: f32,
|
||||||
|
max_life: f32,
|
||||||
|
size: f32,
|
||||||
|
size_growth: f32,
|
||||||
|
angle: f32,
|
||||||
|
angular_vel: f32,
|
||||||
|
color: vec4<f32>,
|
||||||
|
uv_rect: vec4<f32>, // [D15]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CameraParams { // 128 B — préfixe de FrameUniforms (view + proj)
|
||||||
|
view: mat4x4<f32>,
|
||||||
|
proj: mat4x4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VsOut {
|
||||||
|
@builtin(position) clip: vec4<f32>,
|
||||||
|
@location(0) frag_color: vec4<f32>,
|
||||||
|
@location(1) uv: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> camera: CameraParams;
|
||||||
|
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
|
||||||
|
@group(0) @binding(2) var<storage, read> compact_index: array<u32>; // [D17/D19]
|
||||||
|
|
||||||
|
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad — voir GOTCHA topologie.
|
||||||
|
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
|
||||||
|
vec2(-0.5, -0.5),
|
||||||
|
vec2( 0.5, -0.5),
|
||||||
|
vec2( 0.5, 0.5),
|
||||||
|
vec2(-0.5, -0.5),
|
||||||
|
vec2( 0.5, 0.5),
|
||||||
|
vec2(-0.5, 0.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(
|
||||||
|
@builtin(vertex_index) vi: u32,
|
||||||
|
@builtin(instance_index) ii: u32,
|
||||||
|
) -> VsOut {
|
||||||
|
var out: VsOut;
|
||||||
|
|
||||||
|
// Pas d'early-out [D17] : instance_count vient des args indirect (exact = alive).
|
||||||
|
let slot = compact_index[ii];
|
||||||
|
let p = particles[slot];
|
||||||
|
|
||||||
|
let q = QUAD[vi];
|
||||||
|
|
||||||
|
// Rotation 2D dans le plan du billboard
|
||||||
|
let c = cos(p.angle);
|
||||||
|
let s = sin(p.angle);
|
||||||
|
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
|
||||||
|
|
||||||
|
// Axes camera-facing (colonne/ligne de la view matrix)
|
||||||
|
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
|
||||||
|
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
|
||||||
|
|
||||||
|
let world = p.pos + right * rot.x + up * rot.y;
|
||||||
|
out.clip = camera.proj * camera.view * vec4(world, 1.0);
|
||||||
|
out.frag_color = p.color;
|
||||||
|
out.uv = p.uv_rect.xy + (q + vec2(0.5)) * p.uv_rect.zw; // [D15]
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(3) var samp: sampler;
|
||||||
|
@group(0) @binding(4) var tex: texture_2d<f32>;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||||
|
let t = textureSample(tex, samp, in.uv);
|
||||||
|
return in.frag_color * t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Bind group layout (render)
|
||||||
|
|
||||||
|
| Group | Binding | Type | Contenu | Visibility |
|
||||||
|
|-------|---------|------|---------|------------|
|
||||||
|
| 0 | 0 | Uniform RO | `camera_params` (view + proj, 128 B) | VERTEX |
|
||||||
|
| 0 | 1 | Storage RO | `particle_data` | VERTEX |
|
||||||
|
| 0 | 2 | Storage RO | `compact_index` [D17] | VERTEX |
|
||||||
|
| 0 | 3 | Sampler | Sampler | FRAGMENT |
|
||||||
|
| 0 | 4 | Texture | Texture particule | FRAGMENT |
|
||||||
|
|
||||||
|
> Le slot de l'instance arrive par **storage** (`compact_index[ii]`), pas par attribut vertex
|
||||||
|
> (layout vide, pattern TM) [D17/D19]. Le count exact vient des args indirect — pas d'early-out.
|
||||||
|
|
||||||
|
### 5. Pipeline descriptor
|
||||||
|
|
||||||
|
```rust
|
||||||
|
wgpu::RenderPipelineDescriptor {
|
||||||
|
vertex: wgpu::VertexStage {
|
||||||
|
module: shader,
|
||||||
|
entry_point: "vs_main",
|
||||||
|
buffers: &[], // layout VIDE — quad via QUAD[vi], slot via storage binding 2 [D17/D19]
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentStage {
|
||||||
|
module: shader,
|
||||||
|
entry_point: "fs_main",
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
color_states: [wgpu::ColorState {
|
||||||
|
format,
|
||||||
|
alpha_blend: blend_alpha, // selon BlendingMode
|
||||||
|
color_blend: blend_color,
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
}],
|
||||||
|
depth_stencil: Some(wgpu::DepthStencilState {
|
||||||
|
format: DEPTH_FORMAT, // crate::pipeline::DEPTH_FORMAT (Depth32Float)
|
||||||
|
depth_write_enabled: false, // [D10]
|
||||||
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
multisample: wgpu::MultisampleState { count: sample_count, ..Default::default() },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Draw (étape E) : `render_pass.draw_indirect(&pool.indirect_args, 0)` — vertexCount = 6
|
||||||
|
(dans les args), instanceCount = alive [D17].
|
||||||
|
|
||||||
|
### ⚠️ GOTCHA : Topologie du quad billboard
|
||||||
|
|
||||||
|
**Problème** : `draw(4, N)` sans index buffer drawe 4 **vertices** en `TriangleList`,
|
||||||
|
ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad.
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
|
||||||
|
| Option | Pro | Contre |
|
||||||
|
|--------|-----|--------|
|
||||||
|
| A : `draw(6, N)` + 6 sommets (quad = 2 tris, 6 verts) | Pas d'index buffer | 6 vertices au lieu de 4 (2 dupliqués) |
|
||||||
|
| B : Index buffer (6 indices) + `draw_indexed` | 4 vertices seulement | 1 buffer index de plus à gérer |
|
||||||
|
| C : `@builtin(vertex_index)` avec 6 values dans le const | Pas d'index buffer, pas de vertex buffer | Le const a 6 entries au lieu de 4 |
|
||||||
|
|
||||||
|
**Décision : Option C** — 6 entries dans le const QUAD (ci-dessus).
|
||||||
|
Pas de vertex buffer, pas d'index buffer. Cohérent avec le pattern fullscreen triangle
|
||||||
|
du TM/bloom (`draw(3, 1)`). Avec l'indirect draw [D17], les args portent
|
||||||
|
`vertex_count = 6, instance_count = alive`.
|
||||||
|
|
||||||
|
### 6. Texture par défaut (disque 16×16)
|
||||||
|
|
||||||
|
Générée en Rust au build du pool (si `config.texture == None`) [D11] :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn default_disc_texture() -> Vec<u8> {
|
||||||
|
let size = 16;
|
||||||
|
let mut data = vec![0u8; size * size * 4];
|
||||||
|
let center = (size as f32 - 1.0) / 2.0;
|
||||||
|
for y in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
let dx = (x as f32 - center) / center;
|
||||||
|
let dy = (y as f32 - center) / center;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
let alpha = ((1.0 - dist).clamp(0.0, 1.0) * 255.0) as u8;
|
||||||
|
let i = (y * size + x) * 4;
|
||||||
|
data[i] = 255; // R
|
||||||
|
data[i+1] = 255; // G
|
||||||
|
data[i+2] = 255; // B
|
||||||
|
data[i+3] = alpha; // A
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Format `Rgba8UnormSrgb` (convention du lib). Sampler : `MagFilter::Linear`, `AddressMode::ClampToEdge`.
|
||||||
|
|
||||||
|
### 7. `Scene::create_particle_pool`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Scene {
|
||||||
|
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String> {
|
||||||
|
if self.particle_pools.contains_key(id) {
|
||||||
|
return Err(format!("particle pool '{}' already exists", id));
|
||||||
|
}
|
||||||
|
// Résoudre la texture
|
||||||
|
let (texture_view, sampler) = match &config.texture {
|
||||||
|
Some(tex_id) => {
|
||||||
|
let tex = self.textures.get(tex_id)
|
||||||
|
.ok_or_else(|| format!("texture '{}' not found", tex_id))?;
|
||||||
|
(tex.view.clone(), tex.sampler.clone())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Créer la texture disque 16×16 par défaut [D11]
|
||||||
|
self.gpu.create_default_disc_texture()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Construire le pool (buffers + pipeline + bind group)
|
||||||
|
let gpu = self.gpu();
|
||||||
|
let pool = ParticlePool::new(
|
||||||
|
gpu.device.as_ref(),
|
||||||
|
gpu.format,
|
||||||
|
gpu.sample_count,
|
||||||
|
&config,
|
||||||
|
texture_view,
|
||||||
|
sampler,
|
||||||
|
);
|
||||||
|
self.particle_pools.insert(id.to_string(), Arc::new(pool));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Champs ajoutés à `Scene` : `particle_pools: HashMap<String, Arc<ParticlePool>>`
|
||||||
|
(vide par défaut → zéro coût [D12]).
|
||||||
|
|
||||||
|
### 8. Prelude
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Dans prelude.rs :
|
||||||
|
pub use crate::core::particles::{ParticlePoolConfig, BlendingMode};
|
||||||
|
pub use crate::resources::particle::Particle;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blend states
|
||||||
|
|
||||||
|
| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst |
|
||||||
|
|------|--------------|--------------|---------------|---------------|
|
||||||
|
| **Additive** | One | One | One | One |
|
||||||
|
| **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Unit tests (`particles.rs`)
|
||||||
|
|
||||||
|
| Test | Vérifie |
|
||||||
|
|------|---------|
|
||||||
|
| `particle_size_is_80` | `size_of::<Particle>() == 80` [D15/D19] |
|
||||||
|
| `particle_align_is_4` | `align_of::<Particle>() == 4` (storage, pas de padding) [D19] |
|
||||||
|
| `particle_offsets` | Offsets 0/12/24/28/32/36/40/44/48/64 de chaque champ |
|
||||||
|
| `particle_zero_is_dead` | `Particle::ZERO.life == 0.0` |
|
||||||
|
| `pool_config_default_max_count` | Valeur raisonnable (1024) |
|
||||||
|
| `pool_buffers_sizes` | particle_data = N×80, compact_index = N×4, indirect_args = 16, camera_params = 128 |
|
||||||
|
| `default_disc_texture_size` | 16×16×4 bytes |
|
||||||
|
| `default_disc_center_is_opaque` | Center pixel alpha = 255 |
|
||||||
|
| `default_disc_corner_is_transparent` | Corner pixel alpha = 0 |
|
||||||
|
|
||||||
|
### WGSL validation (`wgsl_validate.rs`)
|
||||||
|
|
||||||
|
| Test | Vérifie |
|
||||||
|
|------|---------|
|
||||||
|
| `particle_billboard_compiles` | Naga compile le shader |
|
||||||
|
| `particle_billboard_entry_points` | Contient `vs_main` + `fs_main` |
|
||||||
|
| `particle_billboard_no_compute` | Pas d'entry point compute (cette étape) |
|
||||||
|
| `particle_billboard_layout_empty` | Le pipeline se construit avec `buffers: &[]` (layout vide) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification de non-régression
|
||||||
|
|
||||||
|
- [ ] `cargo check -p wsg-lib --all-targets` → 0 errors, 0 warnings
|
||||||
|
- [ ] `cargo test -p wsg-lib` → tous les tests existants passent (127+)
|
||||||
|
- [ ] Les examples existants (demo, pbr, bloom, etc.) compilent et fonctionnent
|
||||||
|
- [ ] Aucun changement dans `renderer.rs` (le pool n'est pas encore intégré au frame loop)
|
||||||
|
- [ ] `Scene` a un nouveau champ `particle_pools` vide par défaut → zéro coût [D12]
|
||||||
|
- [ ] `SceneGpu` gagne 2 champs (`queue`, `sample_count`) — `init_gpu` inchangé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d'acceptation
|
||||||
|
|
||||||
|
1. ✅ `Particle` compile : **80 bytes**, align 4, Pod, offsets corrects (sans padding)
|
||||||
|
2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL)
|
||||||
|
3. ✅ `ParticlePool::new` crée les 4 buffers + pipeline + bind group sans erreur
|
||||||
|
4. ✅ `indirect_args` initialisée à zéro → un `draw_indirect` forcé est un no-op
|
||||||
|
5. ✅ La texture disque 16×16 est générée correctement
|
||||||
|
6. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec device réel)
|
||||||
|
7. ✅ Le pool est inactif (pas de driver, args = 0) tant qu'aucun driver n'est attaché
|
||||||
|
8. ✅ Zéro warning, tous les tests verts
|
||||||
|
9. ✅ Prelude expose les types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape suivante (B)
|
||||||
|
|
||||||
|
Driver GPU : compute shader `particle_update.wgsl` (intégration + **compaction fused**
|
||||||
|
[§18 D17] : `compact_index` + `indirect_args` écrits par le compute) + `GpuEmitterConfig`
|
||||||
|
(**`color_range` + `uv_rects` + `alpha_scale`** [D18]) + spawn CPU + dispatch +
|
||||||
|
`Scene::attach_gpu_emitter`.
|
||||||
|
|||||||
@@ -11,15 +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é.
|
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
|
> **Statut réel (à jour au 2026-09-18).** La phase de *consolidation* (Phases 1 à 3 de ce plan) est
|
||||||
> de vérité sur l'état actuel est **README.md** et le code. Depuis la révision du 2026-09-14, l'étape
|
> **terminée** ; la source de vérité sur l'état actuel est **README.md** et le code. Depuis la révision
|
||||||
> **« Scene auto-render »** a été réalisée : le rendu de la `Scene` est **automatisé** en une seule
|
> du 2026-09-14, le rendu de la `Scene` est automatisé en une passe groupée
|
||||||
> passe groupée via `App::render_scene(frame.view())` (appelée par défaut dans `AppHandler::render`),
|
> (`App::render_scene(frame.view())`, appelée par défaut dans `AppHandler::render`) et `simple.rs`
|
||||||
> et `simple.rs` (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement.
|
> (API `AppBuilder`, sans `winit`/`wgpu`) déclare un quad rendu automatiquement. Les étapes suivantes
|
||||||
> Le même jour (Étape 3 + 4, 2026-09-16) l'**infrastructure 3D** est en place : bind groups uniformes
|
> ont ensuite : posé l'infrastructure 3D (bind groups uniformes frame+object partagés, caméra active,
|
||||||
> partagés (frame + object), caméra active dans la `Scene` (`Scene::set_camera`/`camera()`) écrite dans
|
> matrices monde par entité — Étapes 3+4, 2026-09-16) ; atteint le **MVP 3D Phong** (Étape 5,
|
||||||
> le buffer frame chaque frame, matrices monde par entité. L'éclairage visible (`standard_shader.wgsl`
|
> 2026-09-17 : l'exemple `cube` ; le 2D plat = variante **unlit** de `standard` via
|
||||||
> branché sur un exemple) reste une étape suivante.
|
> `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)
|
## Phase 1 : Finalisation et Nettoyage de l'Existant (Priorité Absolue)
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
|
|||||||
### Intégration de la Scene
|
### Intégration de la Scene
|
||||||
|
|
||||||
- [X] Formaliser la structure `Scene` : un conteneur qui liste les Entities.
|
- [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,
|
- [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**
|
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
|
(`Renderer::render_scene`), appelée automatiquement chaque frame par l'implémentation par défaut
|
||||||
@@ -58,8 +62,8 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
|
|||||||
|
|
||||||
### Gestion des Matériaux et Shaders
|
### 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).
|
- [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`)*
|
||||||
- [ ] 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] 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")
|
## Phase 3 : Documentation et Interface (API "User-Friendly")
|
||||||
|
|
||||||
@@ -76,8 +80,9 @@ 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 :
|
Une fois les phases 1 à 3 validées, nous pourrons introduire :
|
||||||
|
|
||||||
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache.
|
- [ ] **Système de Lumières** : Ajout de buffers d'uniformes dans le PipelineCache *(planifié — ROADMAP 4.2, Éclairage avancé)*.
|
||||||
- [ ] **Textures** : Intégration d'un module de chargement d'images et de BindGroups.
|
- [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 —
|
- [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
|
`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)*.
|
dans le buffer frame chaque frame, aspect calculé depuis la fenêtre)*.
|
||||||
@@ -89,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
|
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`)
|
`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] Les modules sont-ils bien exposés via `lib.rs` ?
|
||||||
- [X] `pollster` est-il uniquement en dev-dependencies ? — **obsolète** : depuis la migration
|
- [X] `pollster` est-il isolé de l'utilisateur final ? — résolu : depuis winit 0.30 (2026-09-16),
|
||||||
winit 0.30 (2026-09-16), `pollster` est en `dependencies` de la lib (le `block_on` d'init GPU
|
`pollster` est en `dependencies` de la lib ; le `block_on` de l'init GPU est appelé une seule fois
|
||||||
est désormais appelé dans le code de la lib, `app.rs`, cf. note pour mémoire ci-dessous).
|
dans `lib/src/app.rs` (`resumed()`). Les exemples compilent sans le connaître (crates séparées).
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|||||||
@@ -1,168 +1,118 @@
|
|||||||
---
|
# ROADMAP — WSG
|
||||||
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 — 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).
|
Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT détaillé
|
||||||
> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite.
|
(`DRAFT.md`, remplacé à chaque étape) et sa doc livrée (`docs/tech/`, `docs/user/`).
|
||||||
>
|
|
||||||
> **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`),
|
|
||||||
> initialement non branchés au pipeline — **désormais branchés** (caméra active + matrices monde écrites
|
|
||||||
> chaque frame, Étape 4.3, 2026-09-16 ; voir §1.1/1.5 ci-dessous).
|
|
||||||
|
|
||||||
> **Étape suivante (prochaine itération) — « 3D + éclairage Phong » (ROADMAP 1.3 + 1.5).**
|
> **Légende** : ✅ fait · 🔶 partiel · ⬜ à faire · ❌ abandonné
|
||||||
> Le rendu automatique est aujourd'hui **plat** : le `basic_shader.wgsl` interprète les positions comme
|
> **Principe** : chaque étape est **additive et opt-in** — non-régression structurelle garantie
|
||||||
> déjà en NDC, sans matrice monde/vue/projection ni lumière. **Une grande partie de l'infrastructure est
|
> (tout reste désactivable, les chemins existants ne changent pas).
|
||||||
> déjà en place (Étapes 3+4, 2026-09-16)** : le `standard_shader.wgsl` Phong (matrice
|
|
||||||
> `projection * view * world` + lumière directionnelle) existe et valide ; les uniform buffers sont
|
|
||||||
> branchés (frame : view/proj/cam_pos + lumière ; par mesh : `world` dérivé du `Transform`) ; le `Renderer`
|
|
||||||
> écrit chaque frame la caméra active (via `Scene::set_camera`/`camera()`) et la matrice monde de chaque
|
|
||||||
> entité. **Reste à faire** pour un mesh 3D éclairé à l'écran : brancher `standard` sur un exemple et
|
|
||||||
> ajouter un mesh de test (cube). Objectif MVP : **un mesh 3D éclairé à l'écran**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## Phase 2 — Scène & Transforms ✅
|
||||||
- [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)
|
|
||||||
- [x] Module `math/` / `transform.rs`:
|
|
||||||
- [x] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }`
|
|
||||||
- [x] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale
|
|
||||||
- [x] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs — enrichi en Étape 4.3 (fov/near/far + `with_perspective`)
|
|
||||||
- [x] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)` (Étape 4.3 : `projection_matrix(aspect)` utilise fov/near/far stockés)
|
|
||||||
|
|
||||||
### 1.2 Geometry & Mesh
|
| # | Item | Statut |
|
||||||
- [ ] Créer struct `Geometry` (math/geometry.rs) :
|
|---|------|:------:|
|
||||||
- [ ] `positions: Vec<[f32; 3]>` (obligatoire)
|
| 2.1 | Primitives procédurales (`cube`, `plane`, `sphere`, `cylinder`, `cone`, `torus`) | ✅ |
|
||||||
- [ ] `indices: Option<Vec<u16>>` (optionnel)
|
| 2.2 | Transforms (struct `Transform`, composition translation × rotation × scale) | ✅ |
|
||||||
- [ ] `normals: Option<Vec<[f32; 3]>>` (pour Phong)
|
| 2.3 | Entités & scène (struct `Entity`, `Scene`, `TransformStore`, graph entité→mesh) | ✅ |
|
||||||
- [ ] Refactorer `Mesh` pour contenir :
|
| 2.4 | Camera (struct `Camera`, matrices view + perspective, `CameraController` orbital) | ✅ |
|
||||||
- [ ] `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
|
|
||||||
|
|
||||||
### 1.3 Shader Phong Minimal
|
## Phase 3 — GPU-driven (cœur de la vision) ✅
|
||||||
- [x] Créer `standard_shader.wgsl` (Étape 2, 2026-09-16) :
|
|
||||||
- [x] Vertex shader : projection * view * world * position
|
|
||||||
- [x] Fragment shader : éclairage directionnel (+ hémisphérique)
|
|
||||||
- [x] Uniforms : `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options`
|
|
||||||
- [x] Mettre à jour `Material` / pipeline pour supporter les uniforms du shader Phong (bind group layouts frame+object, Étape 3) — `standard` n'est pas encore branché sur un exemple
|
|
||||||
|
|
||||||
### 1.4 Scene avec identifiants (MVP : String IDs)
|
| # | Item | Statut |
|
||||||
- [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()`
|
| 3.1 | Buffers par entité (Transform + Matrix, uniform par slot) | ✅ |
|
||||||
- [x] Caméra active dans la `Scene` : `set_camera()` / `camera()` (Étape 4.3)
|
| 3.2 | Compute matrices (compute shader : transform → world matrix) | ✅ |
|
||||||
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront
|
| 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
|
## Phase 4 — Rendu avancé ✅
|
||||||
- [x] Uniform buffer pour la frame : `view`, `proj`, `cam_pos`, `light_dir` (Étapes 3+4) — écrit chaque frame depuis la caméra active
|
|
||||||
- [x] Uniform buffer par mesh : `world` (calculée sur CPU depuis `transform.to_matrix()`, Étape 4.2)
|
| # | Item | Statut |
|
||||||
- [x] `Renderer::render_scene()` itère sur les entités de la Scene et dessine chacune (liaison bind groups frame+object)
|
|---|------|:------:|
|
||||||
- [ ] Exemple fonctionnel : un cube éclairé tourne à l'écran — **à faire** (Étape 5 : brancher `standard` sur un exemple + mesh cube)
|
| 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 (effets) 🔶
|
||||||
|
|
||||||
**Objectif** : Étoffer la Scene avec tous les types de ressources.
|
> Au-delà du scope initial. Chaque item est opt-in et indépendant.
|
||||||
|
> **Note** : la phase 6 est mise en pause pendant la phase 7 (particules).
|
||||||
|
> Les items restants (6.6, 6.7, 6.14-6.16) seront repris après.
|
||||||
|
|
||||||
### 2.1 Arènes complètes
|
| # | Item | Impact visuel | Effort | Statut |
|
||||||
- [ ] `SlotMap<MaterialId, Material>`
|
|---|------|:---:|:---:|:---:|
|
||||||
- [ ] `SlotMap<TextureId, Texture>` (struct de base)
|
| 6.1 | **Exposure control** (clavier / API live) | ⭐⭐ | Trés faible | ✅ |
|
||||||
- [ ] `SlotMap<LightId, Light>` (struct de base)
|
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
|
||||||
- [ ] `SlotMap<EntityId, Entity>` pour les entités de la scène
|
| 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é | ⬜ |
|
||||||
|
| 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ |
|
||||||
|
| 6.14 | **Area lights** (rectangular area light, BRDF approx — specular + diffuse) | ⭐⭐⭐ | Élevé | ⬜ |
|
||||||
|
| 6.15 | **Textured area lights** (area light avec texture d’émission, e.g. panneaux LED, néons) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||||
|
| 6.16 | **Volumetric lighting** (god rays / light scattering — radial blur ou ray-march 3D) | ⭐⭐⭐⭐ | Élevé | ⬜ |
|
||||||
|
| 6.17 | **Depth of field** (post-process CoC : circle-of-confusion + bokeh blur) | ⭐⭐⭐ | Moyen | ✅ |
|
||||||
|
|
||||||
### 2.2 Entités & Hiérarchie
|
### Cibles techniques (refactoring)
|
||||||
- [ ] Struct `Entity { mesh_id: Option<MeshId>, material_id: Option<MaterialId>, transform: Transform }`
|
|
||||||
- [ ] `Scene::add_entity()` → retourne `EntityId`
|
|
||||||
- [ ] `Scene::iter_entities()` → pour le render loop
|
|
||||||
|
|
||||||
### 2.3 Camera dans la Scene
|
| # | Item | Statut |
|
||||||
- [x] Intégrer `Camera` comme ressource de la Scene (Étape 4.3 : `Scene::set_camera` / `camera()`, caméra active unique)
|
|---|------|:------:|
|
||||||
- [ ] Permettre plusieurs caméras (actuelle/inactive) et une sélection par identifiant (`scene.set_active_camera(camera_id)`)
|
| 6.8 | Handles typés par ressource (slotmap) — `docs/tech/ARCHI_ARENES.md` | ⬜ |
|
||||||
- [ ] Exposer une caméra orbitale contrôlable (exemple final, Phase 5)
|
| 6.9 | API update géométrie par entité (per-frame, sans rebuild complet) | ⬜ |
|
||||||
|
| 6.10 | Double-buffering des buffers Transform/Matrix (désync CPU/GPU) | ⬜ |
|
||||||
|
| 6.11 | **Module `mesh`** : primitives en features optionnelles + import (OBJ/gltf) — `math/` supprimé | ✅ |
|
||||||
|
| 6.12 | **Module `texture`** : génération procédurale (checkerboard, gradient, noise) + formats compressés (KTX2, basis) en features optionnelles | ⬜ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3️⃣ — GPU-Driven Rendering
|
## Phase 7 — Système de Particules 🔄
|
||||||
|
|
||||||
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md).
|
> Nouvelle catégorie : simulation + rendu de particules (VFX).
|
||||||
|
> Architecture : **Pool ≠ Driver** (`docs/tech/ARCHI_PARTICULES.md`).
|
||||||
|
> 3 drivers possibles : GPU (compute), CPU (simulation Rust), Manual (full control).
|
||||||
|
|
||||||
### 3.1 Compute Shader
|
| # | Item | Impact visuel | Effort | Statut |
|
||||||
- [ ] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes
|
|---|------|:---:|:---:|:---:|
|
||||||
- [ ] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales
|
| 7.1 | **Pool** (buffer 64B×N + pipeline billboard + texture + draw) | — | Moyen | ⬜ ← **en cours** |
|
||||||
- [ ] Compute shader : calcul des World Matrices pour tous les meshes
|
| 7.2 | **Driver GPU** (compute update + spawn CPU + GpuEmitterConfig) | ⭐⭐⭐⭐ | Élevé | ⬜ |
|
||||||
|
| 7.3 | **Driver CPU** (simulation Rust + upload + custom_force) | ⭐⭐⭐ | Moyen | ⬜ |
|
||||||
|
| 7.4 | **Driver Manual** (handle direct sur le buffer) | ⭐⭐ | Faible | ⬜ |
|
||||||
|
| 7.5 | **Intégration Renderer** (frame loop, order, multi-pools) | — | Moyen | ⬜ |
|
||||||
|
| 7.6 | **Presets + Example** (fire, smoke, rain, explosion, snow, sparkles) | ⭐⭐⭐⭐ | Moyen | ⬜ |
|
||||||
|
| 7.7 | **Tests** (WGSL validate + layout + pool + drivers) | — | Faible | ⬜ |
|
||||||
|
|
||||||
### 3.2 Frustum Culling GPU
|
> **Après la phase 7** : reprise des items phase 6 restants (6.6 CSM, 6.7 SSAO, 6.14-6.16).
|
||||||
- [ ] 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
|
## Liens
|
||||||
|
|
||||||
**Objectif** : Qualité visuelle et performances.
|
- **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)
|
||||||
### 4.1 Textures
|
- **Utilisation** : [docs/user/](user/README.md)
|
||||||
- [ ] Struct `Texture` avec chargement d'image
|
- **Livre de recette** : [docs/PLAN.md](PLAN.md)
|
||||||
- [ ] 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 |
|
|
||||||
|
|||||||
@@ -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.
|
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).**
|
> **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
|
||||||
> Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes
|
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (Étape 17, 2026-09-22, décisions
|
||||||
> (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect)
|
> D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
|
||||||
> et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est
|
> `setup` (déclaration de la scène) → par frame `update` (mutation) →
|
||||||
> la trajectoire de ROADMAP.md (et README étape 2-3). L'état **réel actuel** est dans README.md :
|
> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
|
||||||
> workflow manuel uniquement, `Renderer` dessine un objet par soumission, shader en NDC sans MVP.
|
> un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif).
|
||||||
> La §3 (`App`/`AppHandler`) correspond à l'état actuel, à une nuance près : `render()` ne peut pas
|
> Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières,
|
||||||
> encore dessiner la scène (l'acquisition/présentation de frame fonctionne, pas le rendu de la scène).
|
> 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
|
## 1. Philosophie et Principes
|
||||||
|
|
||||||
@@ -85,7 +91,7 @@ pub trait AppHandler {
|
|||||||
|
|
||||||
- **Shaders** : Chargés avant la renderloop.
|
- **Shaders** : Chargés avant la renderloop.
|
||||||
- **PipelineCache** : Enregistre les shaders.
|
- **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`.
|
- **Scene** : Assemblage des objets. L'utilisateur peuple la scène via `app.scene`.
|
||||||
|
|
||||||
### B. Boucle de Rendu — Pipeline GPU-Driven
|
### 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.
|
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.
|
> 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.
|
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 une unique commande `draw_indexed_indirect`. Le GPU pioche dans l'Indirect Draw Buffer et dessine uniquement les objets visibles, sans intervention du CPU.
|
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.
|
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.
|
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 |
|
| Buffer | Rôle | Type wGPU | Direction du flux |
|
||||||
|--------|------|-----------|-------------------|
|
|--------|------|-----------|-------------------|
|
||||||
| Transform Buffer | Positions/rotations/échelles brutes | Storage Buffer | CPU → GPU |
|
| 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 | Storage Buffer | GPU (Calculé) → GPU (Lu par Render) |
|
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform | GPU (Calculé) → GPU (Lu par Render) |
|
||||||
| Bounding Box Buffer | AABB de chaque mesh pour culling | Storage Buffer | CPU → GPU (Statique) |
|
| 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 | Liste dynamique des objets à dessiner | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
|
| 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).
|
> **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** : 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.
|
- **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.
|
- **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é.
|
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.
|
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
|
# 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.
|
* 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.
|
* 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.
|
* 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`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ actor: person/jerome
|
|||||||
sources: []
|
sources: []
|
||||||
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
||||||
verified: true
|
verified: true
|
||||||
status: target
|
status: current
|
||||||
stale_after: 2027-01-31
|
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.
|
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
|
> 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
|
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
|
||||||
> README étapes 2-3. **Aucun de ces mécanismes n'existe encore dans le code.** Aujourd'hui le rendu est
|
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
|
||||||
> piloté par le CPU, **objet par objet** (une soumission par mesh, voir README.md et l'exemple `manual`).
|
> buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
|
||||||
> Considérez ce document comme la spécification de référence pour l'implémentation future du pipeline
|
> capacité fixe de 256 slots).
|
||||||
> GPU-driven, pas comme une description de l'état actuel.
|
> 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/cameras/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/cameras/gpu-driven.md`
|
||||||
|
> § « Level of Detail ».
|
||||||
|
|
||||||
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
|
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é)
|
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 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 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)
|
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.
|
- 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 :
|
É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) :
|
- 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.
|
- 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).
|
- 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 : Si l'objet est visible, son identifiant est injecté dans un buffer de commandes de dessin indirect (Indirect Draw Buffer).
|
- 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) :
|
- 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.
|
- 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
|
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
|
4. Synthèse des Structures de Données en VRAM
|
||||||
|
|
||||||
Pour implémenter cette architecture, prévoyez l'utilisation des buffers wGPU suivants :
|
L'implémentation utilise les buffers wGPU suivants (tous créés par le `Renderer` à l'initialisation, capacité fixe de 256 slots) :
|
||||||
Nom du Buffer,Rôle,Type wGPU,Direction du flux
|
|
||||||
Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer,CPU → GPU
|
| Buffer | Rôle | Type wGPU | Direction du flux |
|
||||||
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)
|
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
|
||||||
Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render)
|
| 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.
|
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é).**
|
> **État du document : ACTUEL pour la dichotomie update/render (implémentée) ; CIBLE pour le batching.**
|
||||||
> Le cycle update/render strict, l'itération **automatique** des entités et `renderer.render_scene()`
|
> Le cycle strict est en place : `AppHandler::update` (mutation libre de la scène) tourne avant
|
||||||
> décrits ici ne sont **pas implémentés** : c'est l'**étape 1 du Roadmap README** (scene auto-rendering).
|
> `AppHandler::render`, dont l'implémentation par défaut appelle `app.render_scene(frame.view())` —
|
||||||
> Aujourd'hui `App::run` acquiert/présente la frame mais `render()` ne peut pas encore dessiner la scène,
|
> le moteur itère automatiquement les entités et les dessine en **une passe groupée** par frame
|
||||||
> et le `Renderer` ne dessine qu'un objet par soumission, à la main (exemple `manual`). La terminologie
|
> (rendu automatisé livré le 2026-09-16 ; la passe d'ombre est ajoutée en tête quand un caster est
|
||||||
> `MeshId`/`MaterialId` (handles typés) est celle de la **cible** ; l'état actuel utilise des **String IDs**
|
> actif). Le workflow **manuel** (`Renderer::render` objet par objet, exemple `manual`) coexiste
|
||||||
> dans `Scene`. La dichotomie update/render reste toutefois le modèle de référence retenu pour la suite.
|
> 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
|
## 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."
|
> "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`
|
||||||
|
|||||||
@@ -14,12 +14,25 @@ stale_after: 2027-01-31
|
|||||||
# La Boucle de Rendu (Frame Loop)
|
# La Boucle de Rendu (Frame Loop)
|
||||||
|
|
||||||
> **État du document : ACTUEL (implémenté).** Ce document décrit la frame lifetime telle qu'elle est
|
> **É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`).
|
> réellement implémentée. **Deux flux coexistent** : le flux **facade `App`** (rendu automatique de la
|
||||||
> Le pipeline GPU-driven de l'état **visé** est décrit dans ARCHI_APP.md / ARCHI_CPU_GPU.md (cible).
|
> 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`).
|
- **`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::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.
|
- **`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::begin_frame()`** : acquiert la surface et renvoie la `wgpu::SurfaceTexture` (sans vue).
|
||||||
- **`Context::end_frame(surface_texture)`** : soumet et présente cette texture.
|
- **`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
|
## 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. |
|
| 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. |
|
| 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
|
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-09-22)** : les buffers
|
||||||
> buffers Transform et Matrix vivent en VRAM avec un single buffer en phase initiale (le CPU écrit
|
> Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
|
||||||
> pendant `update()`, le compute shader lit au frame suivant, séquencé par `queue.submit()`), puis un
|
> capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
|
||||||
> double buffering si des artefacts apparaissent à haute fréquence. **Aucune de ces ressources n'existe
|
> **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
|
||||||
> encore dans le code** — c'est la cible GPU-driven (ROADMAP Phase 3 / ARCHI_CPU_GPU).
|
> (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,57 @@
|
|||||||
|
# WSG — User documentation
|
||||||
|
|
||||||
|
WSG (WGPU Simple Graphics) is a 3D graphics engine built on top of
|
||||||
|
[wgpu](https://docs.rs/wgpu) and [winit](https://docs.rs/winit).
|
||||||
|
It deliberately provides **no scene-graph abstraction**: you create resources,
|
||||||
|
place entities, and write the frame loop yourself. The engine handles the rest
|
||||||
|
(GPU context, compilation, command encoding, presentation).
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
|
||||||
|
2. Then, at your pace, pick a **topic folder** (which mirrors the example folders in
|
||||||
|
[`lib/examples/`](../../lib/examples/README.md) — each page is paired with its examples):
|
||||||
|
|
||||||
|
| Folder | Pages |
|
||||||
|
|--------|-------|
|
||||||
|
| [`meshes/`](meshes/README.md) | [Meshes](meshes/meshes.md) — geometries, entities and `Transform` · [Geometry sources](meshes/sources.md) — procedural generators + file import · [Materials & textures](meshes/materials.md) — the `standard` shader, unlit mode, textures |
|
||||||
|
| [`lights/`](lights/README.md) | [Lights](lights/lights.md) — directional/point/spot/ambient, `MAX_LIGHTS` · [Shadows](lights/shadows.md) — shadow mapping · [Emissive + Exposure](lights/emissive-exposure.md) |
|
||||||
|
| [`cameras/`](cameras/README.md) | [Camera & input](cameras/camera-input.md) — orbital controller, unified input · [GPU-driven rendering](cameras/gpu-driven.md) — culling, LOD |
|
||||||
|
| [`effects/`](effects/README.md) | [HDR](effects/hdr.md) · [Bloom](effects/bloom.md) · [MSAA](effects/msaa.md) · [Fog](effects/fog.md) · [DoF](effects/dof.md) |
|
||||||
|
|
||||||
|
Plus [Examples](examples.md) — the 16 examples of the repo in 4 folders, the advanced
|
||||||
|
`manual` workflow, and how to add your own example.
|
||||||
|
|
||||||
|
The pages are cross-linked: each page ends with links to its related pages.
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
- **Explicit over magic**: no scene graph, no ECS, no hidden state machine. What you write
|
||||||
|
is what runs.
|
||||||
|
- **The handler drives the loop**: `AppHandler` is the only required trait (`setup`,
|
||||||
|
`update`, `render` + optional event hook).
|
||||||
|
- **String IDs everywhere**: meshes, materials, textures and entities are referenced by
|
||||||
|
label — no integer handles to manage, errors are readable.
|
||||||
|
- **Safe core, `unsafe` at the edges**: the public API is fully safe; `unsafe` is confined
|
||||||
|
to the raw-pointer interop layer.
|
||||||
|
- **Feature-gated primitives**: every primitive and importer behind a Cargo feature
|
||||||
|
(`prim-cube`, `import-obj`, …) — default is `all-prims` + `import-obj`.
|
||||||
|
|
||||||
|
## Documentation tree
|
||||||
|
|
||||||
|
```
|
||||||
|
README.md this index (the one you are reading)
|
||||||
|
quickstart.md the 30-line path to a window + a cube
|
||||||
|
examples.md the 16 repo examples, the manual workflow, adding your own
|
||||||
|
meshes/ meshes, geometry sources, materials & textures
|
||||||
|
lights/ lights, shadows, emissive + exposure
|
||||||
|
cameras/ camera & input, GPU-driven rendering (culling, LOD)
|
||||||
|
effects/ HDR, bloom, MSAA, fog, DoF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Root README](../../README.md)
|
||||||
|
- Technical docs: [ARCHI_APP](../tech/ARCHI_APP.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) ·
|
||||||
|
[ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
|
||||||
|
- [ROADMAP](../ROADMAP.md)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Cameras — user documentation
|
||||||
|
|
||||||
|
The **viewpoint side**: the active camera, the orbital controller, unified input, and the
|
||||||
|
GPU-driven pipeline (frustum culling, LOD) that the camera drives.
|
||||||
|
|
||||||
|
| Page | Topic |
|
||||||
|
|------|-------|
|
||||||
|
| [Camera & input](camera-input.md) | Active camera, `CameraController` (orbit/zoom/reset), unified keyboard/mouse state, recipes |
|
||||||
|
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling, LOD, debugging |
|
||||||
|
|
||||||
|
Example folder: [`lib/examples/cameras/`](../../../lib/examples/cameras/README.md)
|
||||||
|
(`culling`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
|
||||||
@@ -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::camera::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::camera::CameraController;
|
||||||
|
|
||||||
|
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
|
||||||
|
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
|
||||||
|
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/effects/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/lights.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.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,19 @@
|
|||||||
|
# Effects — user documentation
|
||||||
|
|
||||||
|
The **post-process side**: everything that happens between the main pass and the screen.
|
||||||
|
All effects are opt-in — a feature you don't enable costs nothing (no textures, no passes).
|
||||||
|
|
||||||
|
| Page | Topic |
|
||||||
|
|------|-------|
|
||||||
|
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
|
||||||
|
| [Bloom](bloom.md) | Post-process glow: threshold → blur → composite |
|
||||||
|
| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` |
|
||||||
|
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
|
||||||
|
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus distance, opt-in via `with_dof()` |
|
||||||
|
|
||||||
|
Example folder: [`lib/examples/effects/`](../../../lib/examples/effects/README.md)
|
||||||
|
(`demo`, `bloom`, `hdr`, `msaa`, `fog`, `dof`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Bloom (Step 23)
|
||||||
|
|
||||||
|
**Bloom** is a post-process that creates a "glow" effect around the bright areas of the image.
|
||||||
|
Pixels whose luminance exceeds a threshold are extracted, blurred, then added back to the
|
||||||
|
original image.
|
||||||
|
|
||||||
|
> **Prerequisite**: bloom requires HDR (`AppBuilder::with_hdr`). Without HDR, values are already
|
||||||
|
> clamped to [0,1] and there is nothing "bright" to extract.
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_hdr(ToneMapper::Aces) // required
|
||||||
|
.with_bloom(BloomConfig {
|
||||||
|
threshold: 1.0, // HDR luminance threshold
|
||||||
|
knee: 0.5, // soft-knee width
|
||||||
|
intensity: 0.8, // glow intensity
|
||||||
|
radius: 4.0, // blur radius (pixels, half-res)
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## `BloomConfig`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `threshold` | `f32` | `1.0` | Luminance threshold (linear HDR units). Only pixels above the threshold contribute to the bloom. |
|
||||||
|
| `knee` | `f32` | `0.5` | Soft-knee width. Larger = smoother transition. |
|
||||||
|
| `intensity` | `f32` | `0.8` | Multiplier applied to the blurred result before adding it to the HDR. |
|
||||||
|
| `radius` | `f32` | `4.0` | Blur radius in pixels (at half resolution). Larger = wider glow. |
|
||||||
|
|
||||||
|
## Runtime update
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In the handler (fn update):
|
||||||
|
if app.bloom_enabled() {
|
||||||
|
app.set_bloom_config(BloomConfig {
|
||||||
|
intensity: new_intensity,
|
||||||
|
..app.bloom_config()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes take effect on the next frame (the uniforms are re-written every frame).
|
||||||
|
|
||||||
|
## Pipeline (4 GPU passes)
|
||||||
|
|
||||||
|
```
|
||||||
|
Scene ──→ HDR (full res, Rgba16Float)
|
||||||
|
│
|
||||||
|
├──→ [1] Threshold (full → half res)
|
||||||
|
│ Soft-knee: smoothstep(knee, knee+1, lum)
|
||||||
|
│
|
||||||
|
├──→ [2] Blur H (half res)
|
||||||
|
│ 9-tap separable Gaussian, direction = (1/w, 0)
|
||||||
|
│
|
||||||
|
├──→ [3] Blur V (half res)
|
||||||
|
│ 9-tap separable Gaussian, direction = (0, 1/h)
|
||||||
|
│ (ping-pong: writes into the bright texture)
|
||||||
|
│
|
||||||
|
└──→ [4] Composite (full res)
|
||||||
|
output = HDR + bloom × intensity
|
||||||
|
(writes into a 3rd full-res texture)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Tone Mapping (reads the composite)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Surface (sRGB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
- **Without bloom** (default): zero overhead. The TM reads the HDR texture directly.
|
||||||
|
- **With bloom**: 4 extra passes (1 full-res + 3 half-res) + 3 intermediate textures. The cost
|
||||||
|
is moderate because the blur runs at half resolution.
|
||||||
|
|
||||||
|
## Non-regression
|
||||||
|
|
||||||
|
- `with_bloom()` without `with_hdr()` → warning + no-op (the bloom is ignored).
|
||||||
|
- Without `with_bloom()` → the TM reads the HDR texture directly (the Step 20 behavior is
|
||||||
|
unchanged).
|
||||||
|
|
||||||
|
## Limitations (MVP)
|
||||||
|
|
||||||
|
- A single mip level (no multi-mip "soft" bloom à la Unreal).
|
||||||
|
- No directional bloom.
|
||||||
|
- The blur is a 9-tap Gaussian (good enough for a "soft" glow).
|
||||||
|
- No per-layer bloom (no per-material "bloom mask").
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# DoF (depth of field)
|
||||||
|
|
||||||
|
Depth of field simulates camera-lens behavior: objects at the **focus distance** are sharp,
|
||||||
|
everything else is progressively blurred (the cinematic "bokeh" look). DoF is a post-process
|
||||||
|
that operates on the HDR texture + depth buffer, before tone mapping.
|
||||||
|
|
||||||
|
> **Prerequisite**: like bloom, DoF reads the HDR texture (and the depth buffer for the
|
||||||
|
> focus/blur computation). Enable HDR together with it.
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
DoF is opt-in through the builder. Without it, no DoF textures are allocated and the pipeline
|
||||||
|
cost is **zero**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.with_dof(DoFConfig::cinematic(4.0)) // sharp at 4.0 world units
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## `DoFConfig`
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `focus_distance` | `f32` | World distance where the image is perfectly sharp |
|
||||||
|
| `aperture` | `f32` | Blur intensity (0.0–1.0, clamped). Scales the circle of confusion |
|
||||||
|
| `max_blur` | `f32` | Maximum blur radius in pixels (clamps the CoC) |
|
||||||
|
|
||||||
|
Presets:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DoFConfig::new(focus_distance, aperture, max_blur) // custom
|
||||||
|
DoFConfig::cinematic(focus_distance) // aperture 0.3, max blur 12 px (cutscenes)
|
||||||
|
DoFConfig::subtle(focus_distance) // aperture 0.1, max blur 8 px (gameplay)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime change
|
||||||
|
|
||||||
|
The `dof` example switches focus presets with the keys `1`–`4` (near / mid / far / infinity)
|
||||||
|
and follows the zoom:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example dof
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
- **Without DoF** (default): zero overhead — no textures, no pass.
|
||||||
|
- **With DoF**: 1 extra fullscreen pass + 2 intermediate textures (the bokeh buffer), before
|
||||||
|
tone mapping.
|
||||||
|
|
||||||
|
## Limitations (MVP)
|
||||||
|
|
||||||
|
- A single focus distance per frame (no per-pixel focus / rack-focus over time).
|
||||||
|
- The blur is a fixed-radius Gaussian scaled by the circle of confusion.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [Bloom](bloom.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Distance fog
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Distance fog blends objects toward a predefined color based on their distance to the camera.
|
||||||
|
It is the standard tool for:
|
||||||
|
|
||||||
|
- **Hiding the rendered edge of the world** — the illusion of an infinite world (Skyrim, GTA,
|
||||||
|
Minecraft)
|
||||||
|
- **Adding depth** — a natural atmospheric effect
|
||||||
|
- **Masking transitions** — tile loading, LOD pops
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `.with_fog()`, fog is disabled — **zero GPU cost** (the shader branch is never taken).
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
| Mode | Formula | Use |
|
||||||
|
|------|---------|-----|
|
||||||
|
| `Linear` | `saturate((far - d) / (far - near))` | Sharp cutoff between two distances |
|
||||||
|
| `Exponential` | `exp(-density × d)` | Natural fog (forest, lake) |
|
||||||
|
| `Exponential2` | `exp(-density² × d²)` | Gradual start, sharp cutoff — **ideal for masking** |
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Linear: fade between near and far
|
||||||
|
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
|
||||||
|
|
||||||
|
// Exponential: natural fade
|
||||||
|
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
|
||||||
|
|
||||||
|
// Exponential²: world-edge masking
|
||||||
|
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `mode` | `FogMode` | Linear / Exponential / Exponential2 |
|
||||||
|
| `color` | `[f32; 3]` | Fog color (RGB, linear space) |
|
||||||
|
| `near` | `f32` | Start distance (linear mode only) |
|
||||||
|
| `far` | `f32` | End distance, full fog (linear mode) |
|
||||||
|
| `density` | `f32` | Density (exp / exp² modes). Typical: 0.01–0.3 |
|
||||||
|
|
||||||
|
### Choosing the color
|
||||||
|
|
||||||
|
The fog color **must match the sky/clear color** for a seamless "infinite world" effect. With
|
||||||
|
HDR + ACES, use linear values consistent with the tone mapping.
|
||||||
|
|
||||||
|
### Choosing the density (exp²)
|
||||||
|
|
||||||
|
To mask the edge of the world at a distance `D`:
|
||||||
|
|
||||||
|
```
|
||||||
|
density ≈ 2.0 / D
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- World visible up to 25 units → `density = 0.08`
|
||||||
|
- World visible up to 50 units → `density = 0.04`
|
||||||
|
- World visible up to 100 units → `density = 0.02`
|
||||||
|
|
||||||
|
## Runtime change
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In update():
|
||||||
|
if key_pressed(KeyCode::Digit1) {
|
||||||
|
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
|
||||||
|
}
|
||||||
|
if key_pressed(KeyCode::Digit4) {
|
||||||
|
app.renderer_mut().set_fog(None); // disable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The change takes effect on the next frame.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
```text
|
||||||
|
Main pass (fragment shader)
|
||||||
|
↓
|
||||||
|
Lighting → final_rgb
|
||||||
|
↓
|
||||||
|
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← here
|
||||||
|
↓
|
||||||
|
→ HDR texture / swapchain
|
||||||
|
↓
|
||||||
|
(Bloom) → Tone Mapping → surface
|
||||||
|
```
|
||||||
|
|
||||||
|
Fog is applied **before** tone mapping: HDR values stay unclamped, and the TM applies the
|
||||||
|
ACES/Reinhard curve to the already-fogged result. Result: the fog is perceptually coherent.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
| With | OK? | Note |
|
||||||
|
|------|-----|------|
|
||||||
|
| HDR + TM | ✅ | Fog before TM (recommended) |
|
||||||
|
| Bloom | ✅ | Bloom extracts the bright areas of the post-fog result |
|
||||||
|
| MSAA | ✅ | Independent (rasterizer vs fragment shader) |
|
||||||
|
| GPU culling | ✅ | Independent (culling decides what to draw, fog decides the color) |
|
||||||
|
| Shadows | ✅ | The shadow is computed before the fog |
|
||||||
|
|
||||||
|
## Limitations (v1)
|
||||||
|
|
||||||
|
- **Scene-level only**: a single fog for the whole scene. Per-material fog would require an
|
||||||
|
extra parameter in the per-object bind group.
|
||||||
|
- **Euclidean distance**: no volumetric or directional fog.
|
||||||
|
- **Fixed color**: no color gradient with distance.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
See `lib/examples/effects/fog.rs`: 15 cubes in a row + 5 spheres on an 80×80 plane, with
|
||||||
|
runtime switching between the 3 modes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example fog --features "all-prims"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# HDR & tone mapping
|
||||||
|
|
||||||
|
> **Step 20** — Opt-in HDR rendering with tone mapping.
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
By default, WSG renders **directly to the swapchain** in 8-bit sRGB. This is fine for simple
|
||||||
|
scenes, but the moment you want **bloom** or physically plausible intensities, you hit the
|
||||||
|
ceiling: 8-bit clamps everything to [0,1] before any post-process can run.
|
||||||
|
|
||||||
|
When HDR is enabled, the scene is first rendered into an **offscreen float texture**
|
||||||
|
(`Rgba16Float`, full window resolution), where values are unbounded (no clamping). The
|
||||||
|
**tone mapping** pass then compresses the HDR signal into [0,1] sRGB for the swapchain.
|
||||||
|
|
||||||
|
```
|
||||||
|
Without HDR (default) With HDR (.with_hdr(ToneMapper::Aces))
|
||||||
|
───────────────────── ──────────────────────────────────────
|
||||||
|
Scene ──────────→ Swapchain Scene ──→ HDR texture (Rgba16Float)
|
||||||
|
(8-bit sRGB, clamped) │ (float, unbounded)
|
||||||
|
▼
|
||||||
|
Tone Mapping (ACES / Reinhard)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Swapchain (sRGB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enabling
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_hdr(ToneMapper::Aces) // or ToneMapper::Reinhard
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Without** `with_hdr`: the pipeline is untouched, **zero cost** (no extra texture, no
|
||||||
|
extra pass).
|
||||||
|
- **With** `with_hdr`: one extra full-res texture + one fullscreen pass per frame. Negligible
|
||||||
|
cost on any discrete GPU; moderate on an integrated one (full-res read+write).
|
||||||
|
|
||||||
|
## Tone mappers
|
||||||
|
|
||||||
|
| Curve | Characteristics |
|
||||||
|
|-------|----------------|
|
||||||
|
| `Aces` | **Default.** Filmic look, good highlight roll-off, slightly desaturated in the shadows. The standard for games and engines. |
|
||||||
|
| `Reinhard` | Simple `c / (1 + c)`. Neutral and fast, but highlights "washed out" (the curve saturates quickly). |
|
||||||
|
|
||||||
|
> The `demo` example starts in **LDR** (no HDR): the sphere's emissive intensity of 3.0 is
|
||||||
|
> clamped to 1.0 — it looks "burnt" but no glow. Pressing a key enables HDR+ACES and the
|
||||||
|
> highlight rolls off gracefully.
|
||||||
|
|
||||||
|
## Exposure
|
||||||
|
|
||||||
|
Runtime-adjustable since Step 22: initialize with `AppBuilder::with_exposure(…)`, adjust with
|
||||||
|
`app.set_exposure(…)` (multiplicative, clamped to [0.01, 10.0] — only active when HDR is on).
|
||||||
|
See [Emissive + Exposure](../lights/emissive-exposure.md).
|
||||||
|
|
||||||
|
## Interaction with other features
|
||||||
|
|
||||||
|
| Feature | Behavior under HDR |
|
||||||
|
|---------|-------------------|
|
||||||
|
| **Shadows** | Unchanged — the shadow pass still writes depth; the color pass just targets the HDR texture instead of the swapchain. |
|
||||||
|
| **Fog** | Applied **before** tone mapping (inside the main pass). The fog color must be chosen in linear space, consistent with the tone curve. |
|
||||||
|
| **MSAA** | Compatible — the HDR texture becomes the multisample render target and is resolved before tone mapping. |
|
||||||
|
| **Bloom** | **Requires** HDR. Without it, bloom is ignored (warning). |
|
||||||
|
|
||||||
|
## Cost and non-regression
|
||||||
|
|
||||||
|
- No HDR (default): nothing is allocated, nothing is run. The swapchain is targeted directly.
|
||||||
|
- HDR enabled: one extra `Rgba16Float` texture + one fullscreen pass (tone mapping). If bloom
|
||||||
|
is also enabled, three more half-res textures and a few more passes (see the bloom page).
|
||||||
|
- The `demo` example shows both paths side by side: the LDR start (emissive clamped) and the
|
||||||
|
HDR+ACES mode (highlight roll-off).
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- No **auto-exposure** (histogram-based). Exposure is fixed at build time (the demo hardcodes
|
||||||
|
1.0).
|
||||||
|
- No **DITHERING** on the output: banding may appear in smooth gradients near black (sRGB 8-bit
|
||||||
|
ceiling).
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Shadows](../lights/shadows.md)
|
||||||
|
- [GPU-driven rendering](../cameras/gpu-driven.md)
|
||||||
|
- [Examples](../examples.md)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# MSAA (anti-aliasing)
|
||||||
|
|
||||||
|
MSAA (Multisample Anti-Aliasing) smooths the edges of meshes by sampling each pixel multiple
|
||||||
|
times **at rasterization time** (before the fragment shader).
|
||||||
|
|
||||||
|
## Enabling
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_msaa(4) // 4x MSAA
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Without** `with_msaa`: the swapchain is used as-is (1x, no cost).
|
||||||
|
- **With** `with_msaa(4)`: the swapchain is created with `sample_count = 4`, and a
|
||||||
|
**resolve** pass (multisample → swapchain) runs at the end of each frame.
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
- The **main pass** becomes more expensive (fragment shader run `sample_count` times per pixel
|
||||||
|
on aliased edges — in practice much less, since fully-covered pixels are only processed once).
|
||||||
|
- One extra **fullscreen resolve** per frame (GPU-native, very cheap).
|
||||||
|
- VRAM: the swapchain buffer is multiplied by `sample_count` (4x for 4x MSAA).
|
||||||
|
|
||||||
|
In practice: 4x MSAA is **negligible** on a discrete GPU and perfectly acceptable on an
|
||||||
|
integrated one for scenes of this complexity.
|
||||||
|
|
||||||
|
## MSAA + HDR
|
||||||
|
|
||||||
|
The two compose naturally:
|
||||||
|
|
||||||
|
```
|
||||||
|
Scene ──→ HDR multisample texture (sample_count = N)
|
||||||
|
│
|
||||||
|
▼ resolve
|
||||||
|
HDR texture (single sample)
|
||||||
|
│
|
||||||
|
▼ (bloom?)
|
||||||
|
▼ tone mapping
|
||||||
|
Swapchain (sRGB)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `with_msaa(4)` + `with_hdr(…)`: the offscreen HDR texture becomes multisample and is
|
||||||
|
**resolved** before tone mapping (and before bloom, which operates on the single-sample
|
||||||
|
buffer).
|
||||||
|
- `with_msaa(4)` without HDR: the swapchain itself is multisample, resolved at the end of the
|
||||||
|
frame.
|
||||||
|
|
||||||
|
## MSAA + fog
|
||||||
|
|
||||||
|
Fog is applied **inside** the fragment shader (per sample), so it is inherently MSAA-compatible:
|
||||||
|
each sample computes its own fog factor based on its own depth. No aliasing on the fog
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
## What MSAA does NOT fix
|
||||||
|
|
||||||
|
- **Transparency aliasing** (there is no transparency in the engine — all opaque).
|
||||||
|
- **Texture shimmering** at distance: this is the domain of **anisotropic filtering** (already
|
||||||
|
enabled: `SampleFilter::AnisotropicClamped` + `anisotropy = 4`).
|
||||||
|
- **Temporal flicker** of fine details: this would be the domain of TAA (out of scope).
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- `sample_count` is fixed **at swapchain creation** (not changeable at runtime without
|
||||||
|
recreating the window/surface).
|
||||||
|
- `2` and `4` are the useful values. `8` exists but the cost/quality ratio is bad.
|
||||||
|
- Not all adapters support MSAA on the swapchain — if unsupported, the builder falls back to
|
||||||
|
1x with a warning.
|
||||||
|
|
||||||
|
See `lib/examples/effects/msaa.rs` for a full interactive demo (torus + Icosphere, with
|
||||||
|
zoom/orbit, MSAA 4x by default).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Examples
|
||||||
|
|
||||||
|
16 examples in **4 folders** (mirroring the topic folders of this documentation), covering the
|
||||||
|
full range of the engine — from a 2D quad to GPU-driven rendering.
|
||||||
|
|
||||||
|
All examples are in [`lib/examples/`](../../lib/examples/README.md); each folder has its own
|
||||||
|
README (description + how to run): [`meshes/`](../../lib/examples/meshes/README.md),
|
||||||
|
[`lights/`](../../lib/examples/lights/README.md), [`cameras/`](../../lib/examples/cameras/README.md),
|
||||||
|
[`effects/`](../../lib/examples/effects/README.md).
|
||||||
|
|
||||||
|
| Example | Folder | What it shows | How to run | Corresponding page |
|
||||||
|
|---------|--------|---------------|------------|--------------------|
|
||||||
|
| `simple` | meshes | A 2D quad with vertex colors, unlit mode (~30 lines) | `cargo run -p wsg-lib --example simple` | [Quickstart](quickstart.md), [Materials](meshes/materials.md) |
|
||||||
|
| `cube` | meshes | A rotating cube: point + spot light, `uv_texture.jpg` UV-atlas texture | `cargo run -p wsg-lib --example cube` | [Meshes](meshes/meshes.md), [Materials](meshes/materials.md), [Lights](lights/lights.md) |
|
||||||
|
| `pbr` | meshes | PBR materials (metal/roughness) + real textures: `cave.jpg` albedo, `caveNormal.jpg` normal map, `ground.jpeg` floor | `cargo run -p wsg-lib --example pbr` | [Materials](meshes/materials.md) |
|
||||||
|
| `import` | meshes | Wavefront **OBJ** import (CLI: file path as argument, procedural cube as fallback) | `cargo run -p wsg-lib --example import --features import-obj -- model.obj` | [Geometry sources](meshes/sources.md) |
|
||||||
|
| `manual` | meshes | **Advanced**: the full manual workflow — buffers, pipelines, command encoding, no helpers | `cargo run -p wsg-lib --example manual` | [ARCHI_APP](../tech/ARCHI_APP.md), [FRAME_LOOP](../tech/FRAME_LOOP.md) |
|
||||||
|
| `shadow` | lights | Shadow mapping: the classic pitfall — the packed-index shadow caster | `cargo run -p wsg-lib --example shadow` | [Shadows](lights/shadows.md) |
|
||||||
|
| `shadow_test` | lights | Shadow mapping in isolation (cleared list → your light is index 0) | `cargo run -p wsg-lib --example shadow_test` | [Shadows](lights/shadows.md) |
|
||||||
|
| `spot_test` | lights | A single spotlight (cone + penumbra), ambient nearly zero | `cargo run -p wsg-lib --example spot_test` | [Lights](lights/lights.md) |
|
||||||
|
| `emissive` | lights | Emissive materials + HDR glow, runtime exposure (+/-/0 keys) | `cargo run -p wsg-lib --example emissive` | [Emissive & exposure](lights/emissive-exposure.md) |
|
||||||
|
| `culling` | cameras | **GPU-driven**: world matrices + indirect draws on the GPU, opt-in frustum culling, LOD | `cargo run -p wsg-lib --example culling` | [GPU-driven](cameras/gpu-driven.md) |
|
||||||
|
| `demo` | effects | The full showcase: all features combined (shadows, HDR, bloom, MSAA, fog, lights, orbital camera) | `cargo run -p wsg-lib --example demo` | [All pages](README.md) |
|
||||||
|
| `bloom` | effects | HDR + bloom: threshold → blur → composite | `cargo run -p wsg-lib --example bloom` | [Bloom](effects/bloom.md) |
|
||||||
|
| `hdr` | effects | HDR + tone mapping (ACES / Reinhard), emissive showcase | `cargo run -p wsg-lib --example hdr` | [HDR](effects/hdr.md) |
|
||||||
|
| `msaa` | effects | 4x MSAA anti-aliasing on the swapchain | `cargo run -p wsg-lib --example msaa` | [MSAA](effects/msaa.md) |
|
||||||
|
| `fog` | effects | Distance fog, 3 modes switchable at runtime (linear / exponential / exp²) | `cargo run -p wsg-lib --example fog` | [Fog](effects/fog.md) |
|
||||||
|
| `dof` | effects | Depth of field: Gaussian blur scaled by defocus distance, cinematic bokeh; focus presets 1-4 + continuous zoom | `cargo run -p wsg-lib --example dof` | [DoF](effects/dof.md) |
|
||||||
|
|
||||||
|
> **Texture assets**: the multi-mesh examples use real image files from
|
||||||
|
> [`lib/examples/assets/textures/`](../../lib/examples/assets/textures/) (see the
|
||||||
|
> [Texture assets](../../lib/examples/README.md#texture-assets) section there for the full
|
||||||
|
> table): `uv_texture.jpg` (UV atlas visualization), `ground.jpeg` (tiled floor albedo),
|
||||||
|
> `stonewall.jpg`, and the `cave.jpg` + `caveNormal.jpg` albedo/normal pair. Paths are
|
||||||
|
> resolved against `CARGO_MANIFEST_DIR`, so the examples run from any working directory.
|
||||||
|
|
||||||
|
## The `manual` example: bypassing the helpers
|
||||||
|
|
||||||
|
[`manual.rs`](../../lib/examples/meshes/manual.rs) renders a rotating cube with **no
|
||||||
|
high-level helper at all** — no `Scene`, no `Renderer` convenience API, no `AppHandler`
|
||||||
|
default `render()`. It shows the full pipeline:
|
||||||
|
|
||||||
|
1. **`setup`**: manual creation of vertex/index buffers, bind groups, render/compute
|
||||||
|
pipelines, the swapchain.
|
||||||
|
2. **`render` (overridden)**: manual command encoding per frame (clear, draw, present) —
|
||||||
|
the handler controls **every** `CommandEncoder` operation.
|
||||||
|
3. **Uniforms written by hand** with `queue.write_buffer` (projection, view, model matrices).
|
||||||
|
|
||||||
|
This is the reference for what the high-level API does under the hood, and the starting
|
||||||
|
point for features that don't exist yet in the engine (custom pipelines, post-processes,
|
||||||
|
custom compute). The technical details are in [ARCHI_APP](../tech/ARCHI_APP.md) and
|
||||||
|
[FRAME_LOOP](../tech/FRAME_LOOP.md).
|
||||||
|
|
||||||
|
Rule of thumb: **use `AppHandler` + `Scene` for everything the engine already does, and drop
|
||||||
|
to `manual` style only when you need what it doesn't** — the two styles can be mixed in the
|
||||||
|
same app (e.g. `Scene` for the scene, a manual post-process pass in `render()`).
|
||||||
|
|
||||||
|
## Adding your own example
|
||||||
|
|
||||||
|
1. Create `lib/examples/<folder>/<name>.rs` — pick the folder it belongs to
|
||||||
|
(`meshes` / `lights` / `cameras` / `effects`).
|
||||||
|
2. Declare the `[[example]]` entry in `lib/Cargo.toml` (the folder structure means Cargo
|
||||||
|
no longer auto-discovers examples):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[example]]
|
||||||
|
name = "<name>"
|
||||||
|
path = "examples/<folder>/<name>.rs"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Required features: the base crate has no primitives by default in examples — declare
|
||||||
|
`required-features` if your example uses them (e.g. `required-features = ["prim-cube"]`).
|
||||||
|
4. Register it in the folder's README and in the table above.
|
||||||
|
5. Verify: `cargo build --workspace --examples` + run it.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](README.md) · [Quickstart](quickstart.md)
|
||||||
|
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Lights — user documentation
|
||||||
|
|
||||||
|
The **lighting side** of the scene: the light model, shadows, and emissive materials.
|
||||||
|
|
||||||
|
| Page | Topic |
|
||||||
|
|------|-------|
|
||||||
|
| [Lights](lights.md) | Scene-global lights (directional/point/spot/ambient), `MAX_LIGHTS`, packed indices |
|
||||||
|
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall, tuning |
|
||||||
|
| [Emissive + Exposure](emissive-exposure.md) | Emissive materials (HDR glow) and runtime exposure |
|
||||||
|
|
||||||
|
Example folder: [`lib/examples/lights/`](../../../lib/examples/lights/README.md)
|
||||||
|
(`shadow`, `shadow_test`, `spot_test`, `emissive`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Emissive + Exposure
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Two complementary features (Step 22):
|
||||||
|
|
||||||
|
| Feature | Effect | Cost |
|
||||||
|
|---------|--------|------|
|
||||||
|
| **Exposure** (6.1) | Multiplies luminance before the tone-mapping curve | Zero when HDR is inactive |
|
||||||
|
| **Emissive** (6.2) | Adds an emitted color (independent of the lights) | Zero when `emissive = [0,0,0,0]` |
|
||||||
|
|
||||||
|
## Exposure
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Initialization (optional, default = 1.0)
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.with_exposure(1.5) // start brighter
|
||||||
|
.build().await?;
|
||||||
|
|
||||||
|
// Runtime (in update())
|
||||||
|
app.set_exposure(app.exposure() * 1.1); // +1 "stop"
|
||||||
|
app.set_exposure(1.0); // reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Exposure is a **multiplier** applied to the HDR texture before the tone-mapping curve.
|
||||||
|
- `exposure = 2.0` → the image is 2× brighter (like opening a camera's aperture).
|
||||||
|
- `exposure = 0.5` → the image is 2× darker.
|
||||||
|
- Clamped to `[0.01, 10.0]` to avoid degenerate values.
|
||||||
|
- **Only has an effect when HDR is active** (`with_hdr(...)`). In LDR the value is ignored.
|
||||||
|
|
||||||
|
### Keyboard (demo)
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|-----|--------|
|
||||||
|
| `+` | ×1.1 (brighter) |
|
||||||
|
| `-` | ÷1.1 (darker) |
|
||||||
|
| `0` | Reset to 1.0 |
|
||||||
|
|
||||||
|
## Emissive
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::resources::Material;
|
||||||
|
|
||||||
|
// Create a material with emissivity
|
||||||
|
let mut mat = /* ... */;
|
||||||
|
mat.emissive = [1.0, 0.3, 0.1, 1.5]; // orange, intensity 1.5 (> 1.0 = HDR glow)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
`emissive = [r, g, b, intensity]`:
|
||||||
|
|
||||||
|
- **rgb**: the emission color (same space as the vertex base color)
|
||||||
|
- **a (intensity)**: the multiplier. `1.0` = normal color, `> 1.0` = highlight (only visible in HDR)
|
||||||
|
|
||||||
|
### Shader formula
|
||||||
|
|
||||||
|
```
|
||||||
|
final_color = lit + base_color * emissive.rgb * emissive.a
|
||||||
|
```
|
||||||
|
|
||||||
|
- The emission is **additive**: visible even in total darkness (no light needed).
|
||||||
|
- It is **independent of shadows**: an emissive object casts no shadow and is not shadowed.
|
||||||
|
- `emissive = [0,0,0,0]` (default) → no change (non-regression guaranteed).
|
||||||
|
|
||||||
|
### Use cases
|
||||||
|
|
||||||
|
| Use | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| LED / indicator | `[0, 1, 0, 1.0]` (green, normal intensity) |
|
||||||
|
| Flame / sun | `[1, 0.8, 0.2, 3.0]` (orange, HDR glow) |
|
||||||
|
| Neon | `[0, 0.5, 1, 2.5]` (cyan, glow) |
|
||||||
|
| Inactive | `[0, 0, 0, 0]` (default) |
|
||||||
|
|
||||||
|
### Keyboard (demo)
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|-----|--------|
|
||||||
|
| `E` | Toggle orange glow on the sphere/cylinder |
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
| Combination | Result |
|
||||||
|
|-------------|--------|
|
||||||
|
| Emissive + HDR + ACES | Soft glow, highlights roll off (the nicest) |
|
||||||
|
| Emissive + LDR | Clamped to 1.0 (no glow, but the color is visible in the dark) |
|
||||||
|
| Emissive + shadows | The emissive object is NOT shadowed (emission bypasses the shadow term) |
|
||||||
|
| Exposure + Emissive | Exposure also amplifies the emission (consistent: everything is in the HDR texture) |
|
||||||
|
|
||||||
|
## Non-regression
|
||||||
|
|
||||||
|
- **Emissive**: `[0,0,0,0]` by default → the shader adds `base * 0 * 0 = 0` → no change.
|
||||||
|
- **Exposure**: `1.0` by default → `pow(color, 1/1) = color` → no change.
|
||||||
|
- Both are **opt-in**: without `with_hdr(...)` and `emissive != 0`, the pipeline is identical
|
||||||
|
to the previous state.
|
||||||
|
|
||||||
|
## Limitations (MVP)
|
||||||
|
|
||||||
|
- Emissive is **per material**, not per vertex (no emission gradient within a mesh).
|
||||||
|
- Emissive is **static** at material creation (changing `mat.emissive` requires re-registering
|
||||||
|
the material via `add_material`).
|
||||||
|
- No **bloom** (Step 23): the HDR glow is visible but not "blurred" / spread.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [HDR & tone mapping](../effects/hdr.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -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/effects/demo.rs) example;
|
||||||
|
[`cube.rs`](../../../lib/examples/meshes/cube.rs) shows a point + a spot on top of the default
|
||||||
|
directional, and [`spot_test.rs`](../../../lib/examples/lights/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/lights/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](../meshes/materials.md)
|
||||||
|
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.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/lights/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/effects/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](../meshes/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)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Meshes — user documentation
|
||||||
|
|
||||||
|
The **geometry side** of the scene: where the geometry comes from, how meshes and entities are
|
||||||
|
organized, and how objects look.
|
||||||
|
|
||||||
|
| Page | Topic |
|
||||||
|
|------|-------|
|
||||||
|
| [Meshes](meshes.md) | The three levels `Geometry` → `Mesh` → `Entity`; procedural primitives, custom geometry, `Transform`, mesh sharing |
|
||||||
|
| [Geometry sources](sources.md) | The `wsg::mesh` module: feature-gated procedural generators + file import (OBJ, glTF stub) |
|
||||||
|
| [Materials & textures](materials.md) | The `standard` shader, unlit mode, diffuse textures |
|
||||||
|
|
||||||
|
Example folder: [`lib/examples/meshes/`](../../../lib/examples/meshes/README.md)
|
||||||
|
(`simple`, `cube`, `pbr`, `import`, `manual`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 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/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/meshes/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()` (clone them out of the
|
||||||
|
borrow before touching `app.scene` again — see the pattern in every textured example):
|
||||||
|
|
||||||
|
```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/effects/demo.rs).
|
||||||
|
|
||||||
|
For **file textures** (the pattern used by `cube`, `demo`, `shadow`, `fog`, `dof`,
|
||||||
|
`culling` and `pbr`), load from `assets/textures/` and resolve the path against
|
||||||
|
`CARGO_MANIFEST_DIR` so the example works from any working directory:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
let texture = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
The sampler is `Linear` + `Repeat`, so textures tile automatically when UVs exceed
|
||||||
|
[0,1] (e.g. the 80×80 fog floor tiles `ground.jpeg`).
|
||||||
|
|
||||||
|
> **Normal maps**: `Texture` is always `Rgba8UnormSrgb`, so the GPU sRGB-decodes on
|
||||||
|
> sample. A normal map is *linear* data — pre-encode its channels with the sRGB OETF
|
||||||
|
> before upload so the round-trip is the identity. See `load_normal_map` in
|
||||||
|
> [`pbr.rs`](../../../lib/examples/meshes/pbr.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/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/lights.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.md)
|
||||||
@@ -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/meshes/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/lights.md)
|
||||||
|
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.md)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Geometry sources: procedural generators and file import
|
||||||
|
|
||||||
|
The `wsg::mesh` module is the single entry point for **where the geometry comes from**:
|
||||||
|
procedural generators or file import.
|
||||||
|
|
||||||
|
## Procedural primitives
|
||||||
|
|
||||||
|
Each primitive family is behind a **feature** — you only compile what you need.
|
||||||
|
|
||||||
|
| Feature | Function | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| `prim-cube` | `cube(size)` | Centered cube, 24 vertices, per-face normals |
|
||||||
|
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Horizontal XZ plane (normal +Y), subdivided |
|
||||||
|
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Lat/long sphere, smooth normals |
|
||||||
|
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphere (subdivided icosahedron) |
|
||||||
|
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylinder (side + caps), analytic normals |
|
||||||
|
| `prim-cone` | `cone(r, h, sectors)` | Cone (apex + closed base) |
|
||||||
|
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Torus, smooth normals |
|
||||||
|
|
||||||
|
### Default features
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Your project's Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
wsg-lib = { path = "../lib" }
|
||||||
|
# Default: all primitives enabled (all-prims)
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Only compile the cube and the sphere:
|
||||||
|
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);
|
||||||
|
|
||||||
|
// All return a Geometry (positions + normals + UVs + indices)
|
||||||
|
assert_eq!(cube.positions.len(), 24);
|
||||||
|
```
|
||||||
|
|
||||||
|
## File import
|
||||||
|
|
||||||
|
| Feature | Function | Format |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
|
||||||
|
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
|
||||||
|
|
||||||
|
### OBJ parser
|
||||||
|
|
||||||
|
Supports: `v`, `vn`, `vt`, `f` (3–4 vertices, fan triangulation).
|
||||||
|
If the file has no normals, they are **computed** (area-weighted).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::mesh::{load_obj, parse_obj};
|
||||||
|
|
||||||
|
// From a file
|
||||||
|
let geom = load_obj("model.obj")?;
|
||||||
|
|
||||||
|
// From a string
|
||||||
|
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::mesh::import::MeshImportError;
|
||||||
|
|
||||||
|
match load_obj("missing.obj") {
|
||||||
|
Ok(geom) => { /* … */ }
|
||||||
|
Err(MeshImportError::Io(e)) => eprintln!("file not accessible: {e}"),
|
||||||
|
Err(MeshImportError::Parse(e)) => eprintln!("invalid syntax: {e}"),
|
||||||
|
Err(MeshImportError::Unsupported(e)) => eprintln!("unsupported feature: {e}"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## From `Geometry` to the scene
|
||||||
|
|
||||||
|
The `mesh` module produces `Geometry` (CPU data). To render it, go through
|
||||||
|
`Scene::create_mesh`, which uploads it to the GPU:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
use wsg_lib::mesh::cube;
|
||||||
|
|
||||||
|
// In 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Y-up**, centered on the origin (except `plane`: XZ plane at y=0)
|
||||||
|
- **Outward** normals
|
||||||
|
- UVs in [0,1]²
|
||||||
|
- **CCW** winding (front face)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](../README.md) · [Meshes](meshes.md) · [Materials & textures](materials.md) · [Examples](../examples.md)
|
||||||
|
- [Root README](../../../README.md)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Quickstart
|
||||||
|
|
||||||
|
Get a window with a rotating cube on screen in ~30 lines. The full version with comments is
|
||||||
|
in the [`simple`](../../lib/examples/meshes/simple.rs) example (2D quad, unlit) and
|
||||||
|
[`cube`](../../lib/examples/meshes/cube.rs) (3D cube, lit).
|
||||||
|
|
||||||
|
## 1. Add the dependency
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
wsg-lib = { path = "../lib" }
|
||||||
|
glam = "0.29" # Vec3/Quat — re-exported but you need it in your own code
|
||||||
|
winit = "0.30" # KeyCode/MouseButton for the input (only if you use app.input)
|
||||||
|
```
|
||||||
|
|
||||||
|
> The workspace pins `glam 0.29` and `winit 0.30`; match these versions to avoid
|
||||||
|
> type mismatches.
|
||||||
|
|
||||||
|
## 2. Implement `AppHandler`
|
||||||
|
|
||||||
|
Three mandatory methods (`setup`, `update`, `render`) and an optional event hook.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::prelude::*;
|
||||||
|
|
||||||
|
struct MyHandler {
|
||||||
|
camera: CameraController,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for MyHandler {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { camera: CameraController::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(&mut self, app: &mut App) -> Result<(), String> {
|
||||||
|
// A cube (primitive) + the standard material.
|
||||||
|
app.scene.create_mesh("cube_mesh", cube(1.0), Some("cube_mat"))?;
|
||||||
|
app.scene.add_entity("cube", "cube_mesh")?;
|
||||||
|
|
||||||
|
// A warm directional light + shadows on it.
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)?;
|
||||||
|
app.scene.set_shadow_caster(Some(0));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut App) {
|
||||||
|
// Spin the cube.
|
||||||
|
let mut tf = *app.scene.entity_transform("cube").unwrap();
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.t) * tf.rotation;
|
||||||
|
app.scene.set_entity_transform("cube", tf);
|
||||||
|
self.t += 0.02;
|
||||||
|
|
||||||
|
// Camera: orbit (left-drag), zoom (wheel), reset (R), presets (1/2/3).
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.6;
|
||||||
|
self.camera.pitch = 0.35;
|
||||||
|
self.camera.distance = 6.5;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut App) -> Result<(), String> {
|
||||||
|
// Default implementation: renders the whole scene. Override only for custom passes.
|
||||||
|
app.renderer().render_scene(app.context())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Build the app
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("My WSG app")
|
||||||
|
.size(1024, 768)
|
||||||
|
.with_culling(true) // optional: skip off-screen entities
|
||||||
|
.with_shadows() // optional: enable shadow mapping
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut handler = MyHandler::new();
|
||||||
|
app.run(&mut handler).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AppBuilder` methods you will use early:
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `.title(…)` / `.size(w, h)` | Window |
|
||||||
|
| `.with_vsync(false)` / `.with_frame_limit(n)` | Frame pacing (vsync off + 144 fps cap in the `demo`) |
|
||||||
|
| `.with_culling(true)` | Opt-in frustum culling (see [GPU-driven](cameras/gpu-driven.md)) |
|
||||||
|
| `.with_shadows()` | Opt-in shadow mapping (see [Shadows](lights/shadows.md)) |
|
||||||
|
| `.with_hdr(ToneMapper::Aces)` | Opt-in HDR + tone mapping (see [HDR](effects/hdr.md)) |
|
||||||
|
|
||||||
|
## 4. Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example cube # the reference "hello world" of the engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Controls (in the `cube`/`demo` examples): **left-drag** orbit, **wheel** zoom, **R** reset
|
||||||
|
camera, **1/2/3** view presets, **H** help overlay, **Esc** quit.
|
||||||
|
|
||||||
|
## 5. Where to go next
|
||||||
|
|
||||||
|
- [Meshes](meshes/meshes.md) — entities, transforms, custom geometries
|
||||||
|
- [Materials & textures](meshes/materials.md) — diffuse textures, unlit mode
|
||||||
|
- [Lights](lights/lights.md) — point/spot lights, ambient
|
||||||
|
- [Camera & input](cameras/camera-input.md) — the full input API
|
||||||
|
- [GPU-driven](cameras/gpu-driven.md) — culling, LOD, debugging the GPU path
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [User README](README.md) · [Meshes](meshes/meshes.md) · [Examples](examples.md)
|
||||||
|
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
|
||||||
@@ -6,6 +6,22 @@ edition = "2024"
|
|||||||
[lib]
|
[lib]
|
||||||
path = "src/lib.rs"
|
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]
|
[dependencies]
|
||||||
wgpu = "30.0.0" # Vérifiez la version la plus récente
|
wgpu = "30.0.0" # Vérifiez la version la plus récente
|
||||||
winit = "0.30.13" # For window management — pinned to match examples
|
winit = "0.30.13" # For window management — pinned to match examples
|
||||||
@@ -13,3 +29,81 @@ thiserror = "2"
|
|||||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||||
glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs)
|
glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs)
|
||||||
pollster = { version="1.0.1", features = ["macro"] }
|
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"] }
|
||||||
|
|
||||||
|
# Examples live in per-category subfolders (meshes/, lights/, cameras/, effects/).
|
||||||
|
# Cargo only auto-discovers top-level `examples/*.rs`, so every example is
|
||||||
|
# declared explicitly with its `path`. Names are stable: `cargo run -p wsg-lib
|
||||||
|
# --example <name>` works exactly as before the reorganization.
|
||||||
|
# Each folder has a README.md documenting its examples.
|
||||||
|
|
||||||
|
# --- meshes/ : geometry, materials, import, low-level workflow ---
|
||||||
|
[[example]]
|
||||||
|
name = "simple"
|
||||||
|
path = "examples/meshes/simple.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "cube"
|
||||||
|
path = "examples/meshes/cube.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "pbr"
|
||||||
|
path = "examples/meshes/pbr.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "import"
|
||||||
|
path = "examples/meshes/import.rs"
|
||||||
|
required-features = ["import-obj"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "manual"
|
||||||
|
path = "examples/meshes/manual.rs"
|
||||||
|
|
||||||
|
# --- lights/ : shadow mapping, spot, emissive ---
|
||||||
|
[[example]]
|
||||||
|
name = "shadow"
|
||||||
|
path = "examples/lights/shadow.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "shadow_test"
|
||||||
|
path = "examples/lights/shadow_test.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "spot_test"
|
||||||
|
path = "examples/lights/spot_test.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "emissive"
|
||||||
|
path = "examples/lights/emissive.rs"
|
||||||
|
|
||||||
|
# --- cameras/ : camera-driven rendering (frustum culling) ---
|
||||||
|
[[example]]
|
||||||
|
name = "culling"
|
||||||
|
path = "examples/cameras/culling.rs"
|
||||||
|
|
||||||
|
# --- effects/ : HDR, post-process, the full showcase ---
|
||||||
|
[[example]]
|
||||||
|
name = "demo"
|
||||||
|
path = "examples/effects/demo.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "bloom"
|
||||||
|
path = "examples/effects/bloom.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "hdr"
|
||||||
|
path = "examples/effects/hdr.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "msaa"
|
||||||
|
path = "examples/effects/msaa.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "fog"
|
||||||
|
path = "examples/effects/fog.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "dof"
|
||||||
|
path = "examples/effects/dof.rs"
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# WSG Examples
|
||||||
|
|
||||||
|
The examples are organized into **four category folders**, one per theme. Each
|
||||||
|
folder has its own `README.md` documenting its examples in detail (what they
|
||||||
|
demonstrate, how to run them, keyboard controls, what to observe).
|
||||||
|
|
||||||
|
| Folder | Theme | Examples |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| [meshes/](meshes/README.md) | Geometry, materials, file import, low-level workflow | `simple`, `cube`, `pbr`, `import`, `manual` |
|
||||||
|
| [lights/](lights/README.md) | Light types, shadow mapping, emissive materials | `shadow`, `shadow_test`, `spot_test`, `emissive` |
|
||||||
|
| [cameras/](cameras/README.md) | Camera-driven rendering (frustum culling) | `culling` |
|
||||||
|
| [effects/](effects/README.md) | HDR, tone mapping, post-process, full showcase | `demo`, `bloom`, `hdr`, `msaa`, `fog`, `dof` |
|
||||||
|
|
||||||
|
## Running an example
|
||||||
|
|
||||||
|
Example **names are stable** — from the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples gated behind a Cargo feature need the feature too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example import --features import-obj
|
||||||
|
```
|
||||||
|
|
||||||
|
All examples are **self-contained**: hard-coded geometries, and textures that are
|
||||||
|
either procedural or shipped in [`assets/textures/`](assets/textures/). All use the
|
||||||
|
declarative API (`AppBuilder` + `AppHandler`) except `manual`, which demonstrates
|
||||||
|
the low-level workflow instead.
|
||||||
|
|
||||||
|
## Texture assets
|
||||||
|
|
||||||
|
A few examples (the multi-mesh / multi-effect ones) use real image files from
|
||||||
|
`assets/textures/`, loaded with `Texture::from_file`. The paths are resolved
|
||||||
|
against `CARGO_MANIFEST_DIR` at compile time, so the examples work from **any
|
||||||
|
working directory**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
Texture::from_file(&device, &queue, "label", &format!("{TEXTURES}/ground.jpeg"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Assets used by the examples:
|
||||||
|
|
||||||
|
| Asset | Size | Used by | Role |
|
||||||
|
|-------|------|---------|------|
|
||||||
|
| `uv_texture.jpg` | 437×438 | `cube`, `demo`, `shadow`, `dof`, `culling` | 8×8 UV atlas visualization (labelled cells + corner coordinates) — makes UV mapping and culling decisions explicit |
|
||||||
|
| `ground.jpeg` | 512×512 | `demo`, `pbr`, `shadow`, `fog`, `dof` | Seamless ground albedo, tiled via the `Repeat` sampler |
|
||||||
|
| `stonewall.jpg` | 300×225 | `fog` | Distinctive cube texture — the fog falloff reads clearly on it |
|
||||||
|
| `cave.jpg` + `caveNormal.jpg` | 600×450 | `pbr` | Albedo + normal-map pair for the PBR normal-mapping demo |
|
||||||
|
|
||||||
|
The remaining assets in the folder (`rock.jpg`, `seamlessRoad.jpg`, `stalag.jpg` /
|
||||||
|
`stalagNormal.jpg`, `stars1.jpg`, sprite/heightmap PNGs, …) are available for
|
||||||
|
experiments. Two notes:
|
||||||
|
|
||||||
|
- `Texture` uploads to `Rgba8UnormSrgb` — correct for **albedo** maps (the GPU
|
||||||
|
sRGB-decodes on sample). A **normal map** is linear data, so `pbr` pre-encodes
|
||||||
|
its channels with the sRGB OETF before upload (`load_normal_map`): the GPU
|
||||||
|
decode then restores the original values (EOTF∘OETF = identity).
|
||||||
|
- The texture sampler is `Linear` + `Repeat`, so any texture tiles automatically
|
||||||
|
when UVs exceed [0,1] (the 80×80 fog floor uses this to tile `ground.jpeg`).
|
||||||
|
|
||||||
|
> **Where do the files live?** Examples live in subfolders
|
||||||
|
> (`examples/<folder>/<name>.rs`). Cargo only auto-discovers top-level
|
||||||
|
> `examples/*.rs`, so every example is declared explicitly in
|
||||||
|
> [`lib/Cargo.toml`](../Cargo.toml) with its `path`. This keeps
|
||||||
|
> `--example <name>` working while allowing the folder organization.
|
||||||
|
|
||||||
|
## Suggested learning path
|
||||||
|
|
||||||
|
1. `simple` — the minimal declarative workflow (flat unlit quad, ~15 lines)
|
||||||
|
2. `cube` — the 3D MVP: a textured, lit, spinning cube
|
||||||
|
3. `pbr` — PBR materials and normal mapping
|
||||||
|
4. `spot_test`, `shadow_test` — isolated light and shadow behavior
|
||||||
|
5. `hdr` → `emissive` → `bloom` — the HDR chain, step by step
|
||||||
|
6. `culling` — GPU-driven frustum culling
|
||||||
|
7. `demo` — everything combined
|
||||||
|
8. `manual` — what the `App` facade actually encapsulates
|
||||||
|
|
||||||
|
## Adding your own example
|
||||||
|
|
||||||
|
1. Create `lib/examples/<folder>/my_example.rs` (pick the matching category;
|
||||||
|
add a new folder + README if needed).
|
||||||
|
2. Declare it in `lib/Cargo.toml` (Cargo won't discover it otherwise):
|
||||||
|
```toml
|
||||||
|
[[example]]
|
||||||
|
name = "my_example"
|
||||||
|
path = "examples/<folder>/my_example.rs"
|
||||||
|
```
|
||||||
|
3. Keep it **self-contained**: hard-coded geometries; textures are procedural
|
||||||
|
or come from `assets/textures/` (resolved via `CARGO_MANIFEST_DIR`, see
|
||||||
|
*Texture assets* above).
|
||||||
|
4. Document it in the folder's `README.md` (and in `docs/user/examples.md`).
|
||||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 625 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 314 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
# Cameras & Camera-Driven Rendering
|
||||||
|
|
||||||
|
Examples where the **camera** drives what gets rendered.
|
||||||
|
|
||||||
|
| Example | Run command | What it shows |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| `culling` | `cargo run -p wsg-lib --example culling` | GPU-driven frustum culling: a 15×15 grid of UV-atlas cubes, off-frustum objects skipped |
|
||||||
|
|
||||||
|
> All commands run from the repo root.
|
||||||
|
|
||||||
|
The frustum is defined by the camera's view-projection matrix, so frustum
|
||||||
|
culling is inherently a camera concept: move the camera and the set of drawn
|
||||||
|
objects changes — with **zero CPU cost** (the GPU decides in a compute pass).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `culling` — GPU Frustum Culling
|
||||||
|
|
||||||
|
A grid of **15×15 = 225 cubes** is placed on a large floor. The shared cube
|
||||||
|
mesh is textured with the `uv_texture.jpg` atlas — the colourful labelled
|
||||||
|
cells make it obvious exactly which cubes the GPU draws and which it culls.
|
||||||
|
The GPU-driven culling (compute shader) determines which cubes are visible in
|
||||||
|
the camera frustum and zeros their indirect draw args — **zero CPU cost**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example culling
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera (look around) |
|
||||||
|
| Wheel | Zoom in/out |
|
||||||
|
| `R` | Reset (top view) |
|
||||||
|
| `1` | Front view (cubes behind are culled) |
|
||||||
|
| `2` | Side view |
|
||||||
|
| `3` | **Top view** (see the full grid) |
|
||||||
|
|
||||||
|
### What to observe
|
||||||
|
|
||||||
|
- In top view (`3`): the entire grid is visible.
|
||||||
|
- Orbit to 90°: cubes behind the camera **are not drawn** (culled).
|
||||||
|
- Zoom very close: only cubes near the near plane are rendered.
|
||||||
|
- Cubes rotate slowly (staggered phases) → culling is dynamic (a cube can
|
||||||
|
enter/leave the frustum during a frame).
|
||||||
|
|
||||||
|
> **Note**: culling is enabled via `AppBuilder::with_culling(true)`. Changing
|
||||||
|
> it to `false` in the source disables culling (all cubes are always drawn,
|
||||||
|
> even off-screen).
|
||||||
|
>
|
||||||
|
> The GPU-driven pipeline (compute matrices → culling → indirect draws) is
|
||||||
|
> documented in [`docs/tech/ARCHI_CPU_GPU.md`](../../../docs/tech/ARCHI_CPU_GPU.md)
|
||||||
|
> and [`docs/user/cameras/gpu-driven.md`](../../../docs/user/cameras/gpu-driven.md).
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! **GPU Frustum Culling** — demonstrates the GPU-driven culling pipeline.
|
||||||
|
//!
|
||||||
|
//! A grid of 15×15 cubes is placed in a large field. When GPU culling is enabled,
|
||||||
|
//! cubes outside the camera frustum are skipped on the GPU (their indirect draw
|
||||||
|
//! args are zeroed by the culling compute pass). Orbit the camera to see objects
|
||||||
|
//! behind you simply not being drawn.
|
||||||
|
//!
|
||||||
|
//! To compare with/without culling, run twice:
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example culling # culling ON (default)
|
||||||
|
//! ```
|
||||||
|
//! Or modify `CULLING_ENABLED` in the source.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera (look around to see culling) |
|
||||||
|
//! | Wheel | Zoom in/out |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `1` | Front view |
|
||||||
|
//! | `2` | Side view |
|
||||||
|
//! | `3` | Top view (see full grid) |
|
||||||
|
//!
|
||||||
|
//! ## What to look for
|
||||||
|
//! - From the top view (`3`), you see the full 15×15 grid.
|
||||||
|
//! - Orbit to the side: cubes behind you are culled (not rendered).
|
||||||
|
//! - Zoom in close: only nearby cubes are drawn.
|
||||||
|
//! - The culling happens 100% on the GPU (compute pass) — zero CPU cost.
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example culling
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::Transform;
|
||||||
|
use wsg_lib::mesh::{cube, plane};
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
/// Grid dimensions (15×15 = 225 cubes, fits within MAX_ENTITIES=256).
|
||||||
|
const GRID: usize = 15;
|
||||||
|
/// Spacing between cubes (world units).
|
||||||
|
const SPACING: f32 = 1.2;
|
||||||
|
/// Whether to enable GPU culling.
|
||||||
|
const CULLING_ENABLED: bool = true;
|
||||||
|
|
||||||
|
struct CullingDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
angle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for CullingDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Large ground plane.
|
||||||
|
let ground_size = (GRID as f32 * SPACING) * 1.5;
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(ground_size, ground_size, 1, 1), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// One shared cube mesh (all entities reference the same GPU buffers), textured with
|
||||||
|
// the uv_texture.jpg atlas — the colourful labelled cells make it obvious exactly
|
||||||
|
// which cubes the GPU draws and which it culls.
|
||||||
|
let (device, queue) = {
|
||||||
|
let ctx = app.context();
|
||||||
|
(ctx.device.clone(), ctx.queue.clone())
|
||||||
|
};
|
||||||
|
let uv_tex = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("uv_texture", uv_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("cube_mat", "standard", "uv_texture")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(0.5), Some("cube_mat"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Place the grid of cubes.
|
||||||
|
let half = (GRID / 2) as f32;
|
||||||
|
for i in 0..GRID {
|
||||||
|
for j in 0..GRID {
|
||||||
|
let x = i as f32 * SPACING - half;
|
||||||
|
let z = j as f32 * SPACING - half;
|
||||||
|
let label = format!("cube_{}_{}", i, j);
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(x, 0.25, z);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&label, "cube_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directional light.
|
||||||
|
let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.15, 0.15, 0.18]);
|
||||||
|
|
||||||
|
// Camera: start at top view to see the full grid.
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 1.2;
|
||||||
|
self.camera.distance = 15.0;
|
||||||
|
self.camera.target = Vec3::ZERO;
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
// Camera presets.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 1.2;
|
||||||
|
self.camera.distance = 15.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit1) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.1;
|
||||||
|
self.camera.distance = 15.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit2) {
|
||||||
|
self.camera.yaw = std::f32::consts::FRAC_PI_2;
|
||||||
|
self.camera.pitch = 0.1;
|
||||||
|
self.camera.distance = 15.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit3) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 1.4;
|
||||||
|
self.camera.distance = 18.0;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Slow rotation of the whole grid (subtle, to show dynamic culling).
|
||||||
|
self.angle += 0.002;
|
||||||
|
for i in 0..GRID {
|
||||||
|
for j in 0..GRID {
|
||||||
|
let label = format!("cube_{}_{}", i, j);
|
||||||
|
if let Some(base) = app.scene.entity_transform(&label) {
|
||||||
|
let mut tf = *base;
|
||||||
|
// Rotate each cube slightly (staggered by position for visual interest).
|
||||||
|
let phase = (i as f32 + j as f32) * 0.1;
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.angle + phase);
|
||||||
|
app.scene.set_entity_transform(&label, tf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG Culling (20×20 grid)")
|
||||||
|
.size(1024, 768)
|
||||||
|
.with_culling(CULLING_ENABLED)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(CullingDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Effects: HDR, Post-process & Showcase
|
||||||
|
|
||||||
|
Examples covering **HDR / tone mapping** and **post-process effects**, plus
|
||||||
|
the full showcase that combines everything.
|
||||||
|
|
||||||
|
| Example | Run command | What it shows |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| `demo` | `cargo run -p wsg-lib --example demo` | **Full showcase**: 6 LOD primitives, 3 lights, shadows, HDR/ACES, bloom, culling, orbital camera |
|
||||||
|
| `bloom` | `cargo run -p wsg-lib --example bloom` | Post-process bloom (glow around bright areas) |
|
||||||
|
| `hdr` | `cargo run -p wsg-lib --example hdr` | HDR + tone mapping (ACES) + runtime exposure control |
|
||||||
|
| `msaa` | `cargo run -p wsg-lib --example msaa` | MSAA 4× (multisample anti-aliasing, smooth edges) |
|
||||||
|
| `fog` | `cargo run -p wsg-lib --example fog --features "all-prims"` | Distance fog (3 modes: linear, exp, exp²) |
|
||||||
|
| `dof` | `cargo run -p wsg-lib --example dof --features "all-prims"` | Depth of field (cinematic bokeh, focus presets) |
|
||||||
|
|
||||||
|
> All commands run from the repo root. All effects are **opt-in** — a disabled
|
||||||
|
> effect allocates nothing and executes nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `demo` — Full Showcase
|
||||||
|
|
||||||
|
Combines **all** effects: LOD primitives, file + procedural textures
|
||||||
|
(`ground.jpeg` floor, `uv_texture.jpg` cube, checker/stripe grids), lights
|
||||||
|
(directional + point + spot), shadows, HDR/ACES, exposure, emissive, bloom, culling.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example demo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `1` / `2` / `3` | Presets: front / side / top |
|
||||||
|
| `+` / `-` | Exposure ×1.3 / ÷1.3 |
|
||||||
|
| `0` | Reset exposure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `bloom` — Post-process Bloom
|
||||||
|
|
||||||
|
Two emissive spheres (orange intensity 2.0, blue intensity 3.0) produce a
|
||||||
|
visible halo. The cube and floor serve as reference (non-emissive).
|
||||||
|
|
||||||
|
Bloom is a 4-pass GPU pipeline: threshold → blur H → blur V → composite.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example bloom
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `+` / `-` | **Bloom threshold** +0.1 / −0.1 |
|
||||||
|
| `[` / `]` | **Bloom intensity** +0.1 / −0.1 |
|
||||||
|
| `I` / `O` | **Bloom radius** +0.5 / −0.5 |
|
||||||
|
| `E` / `Q` | Exposure ×1.3 / ÷1.3 |
|
||||||
|
| `0` | Reset exposure |
|
||||||
|
|
||||||
|
### What to observe
|
||||||
|
|
||||||
|
- **Low threshold** (0.0): the entire image "blooms" (very diffuse effect).
|
||||||
|
- **High threshold** (2.0+): only the bright emissive spheres produce glow.
|
||||||
|
- **Intensity 0.0**: no visible glow (even though the threshold extracts pixels).
|
||||||
|
- **Large radius** (10+): the glow spreads over a large area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `hdr` — HDR + Tone Mapping
|
||||||
|
|
||||||
|
Demonstrates HDR rendering with the ACES Filmic curve. Three objects:
|
||||||
|
|
||||||
|
- **Cube**: normal lighting (no emissive) — LDR reference.
|
||||||
|
- **Bright sphere** (emissive 3.0): without HDR, it would be clamped to white.
|
||||||
|
With ACES, highlights "roll off" smoothly toward white.
|
||||||
|
- **Dark sphere** (emissive 0.3): stays dark even at high exposure.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example hdr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `E` | **Exposure ×1.3** (brighter) |
|
||||||
|
| `Q` | **Exposure ÷1.3** (darker) |
|
||||||
|
| `0` | Reset exposure to 1.0 |
|
||||||
|
|
||||||
|
### What to observe
|
||||||
|
|
||||||
|
- At exposure 1.0: the bright sphere is white but with detail (ACES rolloff).
|
||||||
|
- At high exposure (E×E×E): the scene brightens, the bright sphere stays white
|
||||||
|
(saturated), but the cube gains detail.
|
||||||
|
- At low exposure (Q×Q): everything darkens, the bright sphere becomes orange
|
||||||
|
(HDR values > 1.0 are compressed).
|
||||||
|
|
||||||
|
> **Note**: the tone mapper is compiled into the pipeline at build time. To
|
||||||
|
> compare ACES vs Reinhard, change `ToneMapper::Aces` → `ToneMapper::Reinhard`
|
||||||
|
> in the source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `msaa` — MSAA 4× (Anti-aliasing)
|
||||||
|
|
||||||
|
Demonstrates multisample anti-aliasing: object edges (cube, sphere) are smooth
|
||||||
|
instead of "stair-stepped". The scene contains a cube (sharp edges), a sphere
|
||||||
|
(curved silhouette), and a small cube near the camera (maximum aliasing).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example msaa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `M` | Show sample count |
|
||||||
|
|
||||||
|
### To compare with/without MSAA
|
||||||
|
|
||||||
|
Remove the `.with_msaa(4)` line in the source and recompile: the scene is
|
||||||
|
identical, only the edges differ (stair-stepped vs smooth).
|
||||||
|
|
||||||
|
> **Note**: MSAA is a build-time setting (multisample texture allocation). It
|
||||||
|
> works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and
|
||||||
|
> resolves into the HDR texture before bloom/TM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `fog` — Distance Fog
|
||||||
|
|
||||||
|
Demonstrates the 3 fog modes: **linear**, **exponential**, **exponential²**.
|
||||||
|
The scene contains a row of cubes receding into the distance and scattered
|
||||||
|
spheres on a large floor plane. The floor is textured with `ground.jpeg`
|
||||||
|
(tiled across 80×80 units via the `Repeat` sampler) and the cubes with
|
||||||
|
`stonewall.jpg` — the fog falloff reads clearly on the textured surfaces.
|
||||||
|
Fog blends objects toward a background color, creating the illusion of an
|
||||||
|
infinite world.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example fog --features "all-prims"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keys**: `1` = linear, `2` = exp, `3` = exp², `4` = off, `R` = reset.
|
||||||
|
|
||||||
|
> Fog is applied in the main fragment shader (after lighting, before tone
|
||||||
|
> mapping). It uses the Euclidean distance from the fragment to the camera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `dof` — Depth of Field (Cinematic Bokeh)
|
||||||
|
|
||||||
|
Demonstrates depth of field blur: an object at the focus plane stays sharp
|
||||||
|
while foreground and background blur according to their distance from the
|
||||||
|
focus plane. Creates a natural attention effect (cinematic style).
|
||||||
|
|
||||||
|
The scene contains 20 cubes in a row along Z (z=3 to z=-25.5) and 5 spheres to
|
||||||
|
the sides, on a floor plane. The floor is textured with `ground.jpeg` (tiled)
|
||||||
|
and the cubes with the `uv_texture.jpg` atlas — bokeh blur reads much better
|
||||||
|
on textured surfaces. Focus presets at 3 m / 8 m / 15 m.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example dof --features "all-prims"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keys**: `1` = cinematic, `2` = subtle, `3` = focus 3 m, `4` = focus 15 m,
|
||||||
|
`5` = off, `R` = reset.
|
||||||
|
|
||||||
|
> DoF operates in linear HDR (after bloom, before tone mapping). Two passes:
|
||||||
|
> CoC (depth → per-pixel blur radius) then 12-tap disc blur with variable radius.
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! **Bloom** — demonstrates the bloom post-process with emissive materials.
|
||||||
|
//!
|
||||||
|
//! A glowing sphere (emissive intensity 2.0) produces a visible halo. The scene
|
||||||
|
//! also contains a lit ground plane and a cube for reference.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `+` / `-` | Bloom threshold up/down |
|
||||||
|
//! | `[` / `]` | Bloom intensity up/down |
|
||||||
|
//! | `I` / `O` | Bloom radius up/down |
|
||||||
|
//! | `E` | Exposure up (×1.3) |
|
||||||
|
//! | `Q` | Exposure down (÷1.3) |
|
||||||
|
//! | `0` | Reset exposure |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example bloom
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{BloomConfig, ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct BloomDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
angle: f32,
|
||||||
|
/// Runtime bloom config (mirrors the App's internal state for display/adjustment).
|
||||||
|
bloom: BloomConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for BloomDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ground plane.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Cube (lit, non-emissive — reference).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(0.7), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cube_tf = Transform::identity();
|
||||||
|
cube_tf.translation = Vec3::new(1.5, 0.35, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Glowing sphere (emissive intensity 2.0 → HDR bloom).
|
||||||
|
app.scene
|
||||||
|
.add_material_shader("glow_mat", "standard")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0])
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("glow_mesh", icosphere(0.35, 3), Some("glow_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut glow_tf = Transform::identity();
|
||||||
|
glow_tf.translation = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("glow_e", "glow_mesh", glow_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second glow (blue, higher intensity for more dramatic bloom).
|
||||||
|
app.scene
|
||||||
|
.add_material_shader("blue_glow_mat", "standard")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive("blue_glow_mat", [0.2, 0.5, 1.0, 3.0])
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("blue_glow_mesh", icosphere(0.25, 3), Some("blue_glow_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut blue_tf = Transform::identity();
|
||||||
|
blue_tf.translation = Vec3::new(-1.5, 0.4, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("blue_glow_e", "blue_glow_mesh", blue_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Directional light (warm, from above-right).
|
||||||
|
let light_dir = Vec3::new(1.0, 1.5, 0.8).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.2)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.12, 0.12, 0.15]);
|
||||||
|
|
||||||
|
// Camera.
|
||||||
|
self.camera.yaw = 0.4;
|
||||||
|
self.camera.pitch = 0.3;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Sync bloom config from the App.
|
||||||
|
if let Some(cfg) = app.bloom_config() {
|
||||||
|
self.bloom = cfg.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.4;
|
||||||
|
self.camera.pitch = 0.3;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Bloom threshold (+/-).
|
||||||
|
if app.input.key_pressed(KeyCode::Equal) {
|
||||||
|
self.bloom.threshold += 0.1;
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom threshold = {:.2}", self.bloom.threshold);
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Minus) {
|
||||||
|
self.bloom.threshold = (self.bloom.threshold - 0.1).max(0.0);
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom threshold = {:.2}", self.bloom.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloom intensity ([/]).
|
||||||
|
if app.input.key_pressed(KeyCode::BracketRight) {
|
||||||
|
self.bloom.intensity += 0.1;
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom intensity = {:.2}", self.bloom.intensity);
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::BracketLeft) {
|
||||||
|
self.bloom.intensity = (self.bloom.intensity - 0.1).max(0.0);
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom intensity = {:.2}", self.bloom.intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloom radius (I/O).
|
||||||
|
if app.input.key_pressed(KeyCode::KeyI) {
|
||||||
|
self.bloom.radius += 0.5;
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom radius = {:.1}", self.bloom.radius);
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyO) {
|
||||||
|
self.bloom.radius = (self.bloom.radius - 0.5).max(0.5);
|
||||||
|
app.set_bloom_config(self.bloom.clone());
|
||||||
|
eprintln!("bloom radius = {:.1}", self.bloom.radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposure (E/Q/0).
|
||||||
|
if app.input.key_pressed(KeyCode::KeyE) {
|
||||||
|
app.set_exposure(app.exposure() * 1.3);
|
||||||
|
eprintln!("exposure = {:.2}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyQ) {
|
||||||
|
app.set_exposure(app.exposure() / 1.3);
|
||||||
|
eprintln!("exposure = {:.2}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit0) {
|
||||||
|
app.set_exposure(1.0);
|
||||||
|
eprintln!("exposure reset to 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow rotation of the glow spheres.
|
||||||
|
self.angle += 0.01;
|
||||||
|
let mut tf = *app
|
||||||
|
.scene
|
||||||
|
.entity_transform("glow_e")
|
||||||
|
.expect("glow entity present");
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.angle);
|
||||||
|
app.scene.set_entity_transform("glow_e", tf);
|
||||||
|
|
||||||
|
let mut tf2 = *app
|
||||||
|
.scene
|
||||||
|
.entity_transform("blue_glow_e")
|
||||||
|
.expect("blue glow entity present");
|
||||||
|
tf2.rotation = Quat::from_rotation_y(-self.angle * 0.7);
|
||||||
|
app.scene.set_entity_transform("blue_glow_e", tf2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG Bloom")
|
||||||
|
.size(960, 640)
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.with_bloom(BloomConfig::default())
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(BloomDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
bloom: BloomConfig::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! **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,
|
||||||
|
//! * **textures per mesh**: the ground is a **file asset** (`ground.jpeg`) and the cube a
|
||||||
|
//! **UV atlas asset** (`uv_texture.jpg`), the rest stay procedural (checker / stripe grids),
|
||||||
|
//! * 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.
|
||||||
|
//! * **Exposure** (Étape 22, 6.1): keys `+` / `-` adjust the tone mapping exposure live
|
||||||
|
//! (×1.3 / ÷1.3 per press), `0` resets to 1.0.
|
||||||
|
//! * **Emissive** (Étape 22, 6.2): a small glowing orange sphere sits at the center
|
||||||
|
//! (emissive intensity 2.0 → HDR glow, visible even in shadow).
|
||||||
|
//!
|
||||||
|
//! Doc (this header) follows the English convention used for examples; internal comments stay
|
||||||
|
//! concise and French where helpful. Run with:
|
||||||
|
//!
|
||||||
|
//! `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::BloomConfig;
|
||||||
|
use wsg_lib::core::ToneMapper;
|
||||||
|
use wsg_lib::core::Transform;
|
||||||
|
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
/// 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. Textures: two **file assets** (ground + UV atlas) plus two procedural patterns.
|
||||||
|
// File paths are resolved against the crate root (`CARGO_MANIFEST_DIR`) so the
|
||||||
|
// example works from any CWD.
|
||||||
|
let ground_tex = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"ground",
|
||||||
|
&format!("{TEXTURES}/ground.jpeg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("ground_texture", ground_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("ground_mat", "standard", "ground_texture")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let uv_tex = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("uv_texture", uv_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("uv_mat", "standard", "uv_texture")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
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("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("uv_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);
|
||||||
|
|
||||||
|
// 4b. Étape 22 (6.2): emissive demo — a small glowing sphere at the center.
|
||||||
|
// The material has emissive = [1.0, 0.3, 0.05, 2.0] (orange, intensity 2.0 = HDR glow).
|
||||||
|
// IMPORTANT: set emissive BEFORE create_mesh (the mesh captures the Arc at creation).
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("glow_mat", "standard", "checker_texture")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive("glow_mat", [1.0, 0.3, 0.05, 2.0])
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("glow_mesh", icosphere(0.3, 3), Some("glow_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut glow_tf = Transform::identity();
|
||||||
|
glow_tf.translation = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("glow_e", "glow_mesh", glow_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 5. Lights: a shadow-casting directional + a warm point + a green spot.
|
||||||
|
// Start from the default list (directional +Z) so we keep it and add the rest.
|
||||||
|
let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize();
|
||||||
|
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());
|
||||||
|
|
||||||
|
// ---- Étape 22 (6.1): exposure control ----
|
||||||
|
// `+` / `-`: multiply/divide by 1.3 (visible step). `0`: reset to 1.0.
|
||||||
|
if app.input.key_pressed(KeyCode::Equal) {
|
||||||
|
app.set_exposure(app.exposure() * 1.3);
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Minus) {
|
||||||
|
app.set_exposure(app.exposure() / 1.3);
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit0) {
|
||||||
|
app.set_exposure(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Slow rotation of the primitives so lighting/shadow read clearly ----
|
||||||
|
self.angle += 0.008;
|
||||||
|
let base = *app
|
||||||
|
.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/cameras/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)
|
||||||
|
.with_bloom(BloomConfig::default())
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(Demo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
dbg: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! # Depth of Field Example (Étape 26)
|
||||||
|
//!
|
||||||
|
//! Demonstrates cinematic DoF: a row of cubes receding into the distance,
|
||||||
|
//! with the focus plane at a configurable depth. Cubes at the focus distance
|
||||||
|
//! stay sharp; those closer or farther blur proportionally.
|
||||||
|
//!
|
||||||
|
//! ## Pipeline
|
||||||
|
//! DoF operates in linear HDR space **after** bloom and **before** tone mapping:
|
||||||
|
//! 1. CoC pass: reads the depth buffer, linearizes to world distance, computes
|
||||||
|
//! per-pixel blur radius.
|
||||||
|
//! 2. Blur pass: 12-tap disc blur with variable radius (from CoC), producing
|
||||||
|
//! natural circular bokeh.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `1` | Cinematic preset (focus=8m, strong blur) |
|
||||||
|
//! | `2` | Subtle preset (focus=8m, gentle blur) |
|
||||||
|
//! | `3` | Focus at 3m (near cubes sharp, far blurred) |
|
||||||
|
//! | `4` | Focus at 15m (far cubes sharp, near blurred) |
|
||||||
|
//! | `5` | DoF OFF |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example dof --features "all-prims"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::Vec3;
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{DoFConfig, ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
struct DoFDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for DoFDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Textured ground + cubes: bokeh blur reads much better on textured surfaces.
|
||||||
|
// ground.jpeg tiles across the 80×80 floor (Repeat sampler); uv_texture.jpg on the cubes.
|
||||||
|
let (device, queue) = {
|
||||||
|
let ctx = app.context();
|
||||||
|
(ctx.device.clone(), ctx.queue.clone())
|
||||||
|
};
|
||||||
|
let ground_tex =
|
||||||
|
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
|
||||||
|
app.scene.add_texture("ground_texture", ground_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("ground_mat", "standard", "ground_texture")
|
||||||
|
.unwrap();
|
||||||
|
let uv_tex = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("uv_texture", uv_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("cube_mat", "standard", "uv_texture")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ground plane.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), Some("ground_mat"))
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(
|
||||||
|
"ground",
|
||||||
|
"ground_mesh",
|
||||||
|
Transform::identity(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Row of cubes receding along -Z (distance ≈ 2 to 25 from camera at dist=8).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(1.0), Some("cube_mat"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for i in 0..20 {
|
||||||
|
let z = 3.0 - i as f32 * 1.5; // from z=3 (close) to z=-25.5 (far)
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(0.0, 0.5, z);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few spheres scattered to the sides for visual interest.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("sphere_mesh", icosphere(0.7, 3), None)
|
||||||
|
.unwrap();
|
||||||
|
let sphere_positions = [
|
||||||
|
Vec3::new(2.5, 0.7, -2.0),
|
||||||
|
Vec3::new(-3.0, 0.7, -6.0),
|
||||||
|
Vec3::new(3.5, 0.7, -10.0),
|
||||||
|
Vec3::new(-2.0, 0.7, -14.0),
|
||||||
|
Vec3::new(2.0, 0.7, -18.0),
|
||||||
|
];
|
||||||
|
for (i, pos) in sphere_positions.iter().enumerate() {
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = *pos;
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directional light.
|
||||||
|
let light_dir = Vec3::new(-0.4, -1.0, -0.3).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.08, 0.08, 0.1]);
|
||||||
|
|
||||||
|
// Camera — positioned to look down the row of cubes.
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.1;
|
||||||
|
self.camera.distance = 8.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
eprintln!("[DoF] Initial: Cinematic (focus=8m, aperture=0.3, max_blur=12)");
|
||||||
|
eprintln!("[DoF] Keys: 1=cinematic 2=subtle 3=focus 3m 4=focus 15m 5=off R=reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
// DoF presets.
|
||||||
|
if app.input.key_pressed(KeyCode::Digit1) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_dof(Some(DoFConfig::cinematic(8.0)));
|
||||||
|
eprintln!("[DoF] → Cinematic (focus=8m, aperture=0.3, max_blur=12)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit2) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_dof(Some(DoFConfig::subtle(8.0)));
|
||||||
|
eprintln!("[DoF] → Subtle (focus=8m, aperture=0.1, max_blur=8)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit3) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_dof(Some(DoFConfig::new(3.0, 0.3, 12.0)));
|
||||||
|
eprintln!("[DoF] → Focus 3m (near sharp, far blurred)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit4) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_dof(Some(DoFConfig::new(15.0, 0.3, 12.0)));
|
||||||
|
eprintln!("[DoF] → Focus 15m (far sharp, near blurred)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit5) {
|
||||||
|
app.renderer_mut().set_dof(None);
|
||||||
|
eprintln!("[DoF] → OFF");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.1;
|
||||||
|
self.camera.distance = 8.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG — Depth of Field (Étape 26)")
|
||||||
|
.size(1280, 720)
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.with_dof(DoFConfig::cinematic(8.0))
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(DoFDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! # Fog Example (Étape 25)
|
||||||
|
//!
|
||||||
|
//! Demonstrates distance fog: objects fade into the fog color as they recede,
|
||||||
|
//! creating the illusion of an infinite world (Skyrim/GTA pattern).
|
||||||
|
//!
|
||||||
|
//! The scene has a row of cubes receding into the distance and scattered spheres,
|
||||||
|
//! all sitting on a large ground plane. Switch fog modes with number keys to
|
||||||
|
//! compare the three attenuation curves.
|
||||||
|
//!
|
||||||
|
//! ## Pipeline
|
||||||
|
//! Fog is applied in the main pass fragment shader (after lighting, before tone
|
||||||
|
//! mapping). It uses the fragment's world-space distance to the camera and
|
||||||
|
//! blends the final color toward `fog_color`.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `1` | Linear fog (near=5, far=30) |
|
||||||
|
//! | `2` | Exponential fog (density=0.04) |
|
||||||
|
//! | `3` | Exponential² fog (density=0.06) — best for masking |
|
||||||
|
//! | `4` | Fog OFF |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example fog --features "all-prims"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::Vec3;
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{FogConfig, ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
struct FogDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for FogDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Textured ground + cubes: the fog falloff reads much better on textured surfaces.
|
||||||
|
// ground.jpeg tiles across the 80×80 floor (Repeat sampler); stonewall.jpg on the cubes.
|
||||||
|
let (device, queue) = {
|
||||||
|
let ctx = app.context();
|
||||||
|
(ctx.device.clone(), ctx.queue.clone())
|
||||||
|
};
|
||||||
|
let ground_tex =
|
||||||
|
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
|
||||||
|
app.scene.add_texture("ground_texture", ground_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("ground_mat", "standard", "ground_texture")
|
||||||
|
.unwrap();
|
||||||
|
let wall_tex =
|
||||||
|
Texture::from_file(&device, &queue, "wall", &format!("{TEXTURES}/stonewall.jpg")).unwrap();
|
||||||
|
app.scene.add_texture("wall_texture", wall_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("wall_mat", "standard", "wall_texture")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Large ground plane — will fade into fog at distance.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(80.0, 80.0, 1, 1), Some("ground_mat"))
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Row of cubes receding into the distance.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(1.0), Some("wall_mat"))
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..15 {
|
||||||
|
let z = -2.0 - i as f32 * 2.5;
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(0.0, 0.5, z);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&format!("cube_{i}"), "cube_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scattered spheres at various distances.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("sphere_mesh", icosphere(0.8, 3), None)
|
||||||
|
.unwrap();
|
||||||
|
let positions = [
|
||||||
|
Vec3::new(3.0, 0.8, -5.0),
|
||||||
|
Vec3::new(-4.0, 0.8, -10.0),
|
||||||
|
Vec3::new(5.0, 0.8, -15.0),
|
||||||
|
Vec3::new(-3.0, 0.8, -20.0),
|
||||||
|
Vec3::new(0.0, 0.8, -30.0),
|
||||||
|
];
|
||||||
|
for (i, pos) in positions.iter().enumerate() {
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = *pos;
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&format!("sphere_{i}"), "sphere_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directional light.
|
||||||
|
let light_dir = Vec3::new(-0.5, -1.0, -0.3).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.2)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.08, 0.08, 0.1]);
|
||||||
|
|
||||||
|
// Camera — positioned to look down the row of cubes.
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.15;
|
||||||
|
self.camera.distance = 8.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, -8.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Print initial fog status.
|
||||||
|
eprintln!("[Fog] Initial: Exponential² (density=0.06)");
|
||||||
|
eprintln!("[Fog] Keys: 1=linear 2=exp 3=exp² 4=off R=reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
// Fog mode switching.
|
||||||
|
if app.input.key_pressed(KeyCode::Digit1) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_fog(Some(FogConfig::linear([0.7, 0.75, 0.85], 5.0, 30.0)));
|
||||||
|
eprintln!("[Fog] → Linear (near=5, far=30)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit2) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_fog(Some(FogConfig::exponential([0.7, 0.75, 0.85], 0.04)));
|
||||||
|
eprintln!("[Fog] → Exponential (density=0.04)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit3) {
|
||||||
|
app.renderer_mut()
|
||||||
|
.set_fog(Some(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06)));
|
||||||
|
eprintln!("[Fog] → Exponential² (density=0.06)");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit4) {
|
||||||
|
app.renderer_mut().set_fog(None);
|
||||||
|
eprintln!("[Fog] → OFF");
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.15;
|
||||||
|
self.camera.distance = 8.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG — Fog (3 modes)")
|
||||||
|
.size(1024, 640)
|
||||||
|
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(FogDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! **HDR + Tone Mapping** — demonstrates HDR rendering with exposure control.
|
||||||
|
//!
|
||||||
|
//! Shows the difference between ACES and Reinhard tone mapping curves, and how
|
||||||
|
//! exposure affects the final image. A bright emissive sphere (intensity 3.0)
|
||||||
|
//! demonstrates highlight rolloff: without HDR it would clip to white, with
|
||||||
|
//! ACES it rolls off smoothly.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `E` | Exposure up (×1.3) |
|
||||||
|
//! | `Q` | Exposure down (÷1.3) |
|
||||||
|
//! | `0` | Reset exposure to 1.0 |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example hdr
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Note: tone mapper is selected at build time (pipeline compiled once). To compare
|
||||||
|
//! ACES vs Reinhard, run twice with different flags or modify the source.
|
||||||
|
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct HdrDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
angle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for HdrDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ground.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Lit cube (normal brightness, no emissive).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(0.8), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cube_tf = Transform::identity();
|
||||||
|
cube_tf.translation = Vec3::new(1.5, 0.4, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Bright sphere (emissive 3.0 — demonstrates HDR highlight rolloff).
|
||||||
|
app.scene
|
||||||
|
.add_material_shader("bright_mat", "standard")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive("bright_mat", [1.0, 0.9, 0.7, 3.0])
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("bright_mesh", icosphere(0.4, 3), Some("bright_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut bright_tf = Transform::identity();
|
||||||
|
bright_tf.translation = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("bright_e", "bright_mesh", bright_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Dim sphere (emissive 0.3 — stays dark even at high exposure).
|
||||||
|
app.scene
|
||||||
|
.add_material_shader("dim_mat", "standard")
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive("dim_mat", [0.2, 0.4, 1.0, 0.3])
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.create_mesh("dim_mesh", icosphere(0.3, 3), Some("dim_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut dim_tf = Transform::identity();
|
||||||
|
dim_tf.translation = Vec3::new(-1.5, 0.4, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("dim_e", "dim_mesh", dim_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Strong directional light.
|
||||||
|
let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 2.0)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.1, 0.1, 0.12]);
|
||||||
|
|
||||||
|
// Camera.
|
||||||
|
self.camera.yaw = 0.3;
|
||||||
|
self.camera.pitch = 0.25;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.4, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.3;
|
||||||
|
self.camera.pitch = 0.25;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Exposure control.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyE) {
|
||||||
|
app.set_exposure(app.exposure() * 1.3);
|
||||||
|
eprintln!("exposure = {:.3}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyQ) {
|
||||||
|
app.set_exposure(app.exposure() / 1.3);
|
||||||
|
eprintln!("exposure = {:.3}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit0) {
|
||||||
|
app.set_exposure(1.0);
|
||||||
|
eprintln!("exposure reset to 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate the bright sphere to show specular highlights.
|
||||||
|
self.angle += 0.008;
|
||||||
|
let mut tf = *app
|
||||||
|
.scene
|
||||||
|
.entity_transform("bright_e")
|
||||||
|
.expect("bright entity present");
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.angle);
|
||||||
|
app.scene.set_entity_transform("bright_e", tf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
// ACES Filmic tone mapping — cinematic contrast with smooth highlight rolloff.
|
||||||
|
// Change to ToneMapper::Reinhard to compare (flatter, less contrast).
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG HDR (ACES)")
|
||||||
|
.size(960, 640)
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.with_exposure(1.0)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(HdrDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! **MSAA (Multi-Sample Anti-Aliasing)** — demonstrates 4× MSAA edge smoothing.
|
||||||
|
//!
|
||||||
|
//! Shows how MSAA eliminates the jagged "staircase" artifacts (aliasing) along
|
||||||
|
//! sharp edges. The scene contains a cube (sharp edges), a sphere (curved surface),
|
||||||
|
//! and a ground plane — all with high-contrast edges where aliasing is most visible.
|
||||||
|
//!
|
||||||
|
//! To compare with/without MSAA: remove the `.with_msaa(4)` line from the builder
|
||||||
|
//! below and rebuild. The scene and lighting are identical — only the edge
|
||||||
|
//! smoothness differs.
|
||||||
|
//!
|
||||||
|
//! ## Pipeline (MSAA + HDR)
|
||||||
|
//! ```text
|
||||||
|
//! Main pass → MSAA texture (4 samples, Rgba16Float)
|
||||||
|
//! ↓ resolve (average 4 samples → 1)
|
||||||
|
//! HDR texture (single sample)
|
||||||
|
//! ↓
|
||||||
|
//! Tone Mapping → surface
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `M` | Toggle MSAA info (shows sample count) |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example msaa
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::Vec3;
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{Transform, ToneMapper};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct MsaaDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
show_info: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for MsaaDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ground plane.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Cube — sharp edges make aliasing very visible.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(1.0), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cube_tf = Transform::identity();
|
||||||
|
cube_tf.translation = Vec3::new(1.5, 0.5, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cube", "cube_mesh", cube_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Sphere — curved surface, aliasing visible on the silhouette.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("sphere_mesh", icosphere(0.6, 3), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut sphere_tf = Transform::identity();
|
||||||
|
sphere_tf.translation = Vec3::new(-1.5, 0.6, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("sphere", "sphere_mesh", sphere_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Small cube near the camera — very close edges, maximum aliasing.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("small_cube_mesh", cube(0.3), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut small_tf = Transform::identity();
|
||||||
|
small_tf.translation = Vec3::new(0.0, 0.15, 1.5);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("small_cube", "small_cube_mesh", small_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Directional light (strong, creates high-contrast edges).
|
||||||
|
let light_dir = Vec3::new(0.5, 1.0, 0.3).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.85], 1.5)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.08, 0.08, 0.1]);
|
||||||
|
|
||||||
|
// Camera.
|
||||||
|
self.camera.yaw = 0.4;
|
||||||
|
self.camera.pitch = 0.2;
|
||||||
|
self.camera.distance = 4.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.4, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Print MSAA status.
|
||||||
|
let sc = app.renderer().msaa_sample_count();
|
||||||
|
eprintln!("[MSAA] sample_count = {} ({})", sc, if sc > 1 { "active" } else { "disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.4;
|
||||||
|
self.camera.pitch = 0.2;
|
||||||
|
self.camera.distance = 4.0;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Toggle info display.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyM) {
|
||||||
|
self.show_info = !self.show_info;
|
||||||
|
let sc = app.renderer().msaa_sample_count();
|
||||||
|
eprintln!("[MSAA] {}× {}", sc, if sc > 1 { "enabled" } else { "disabled (single sample)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG MSAA 4×")
|
||||||
|
.size(960, 640)
|
||||||
|
.with_msaa(4) // ← Enable 4× MSAA (remove for comparison)
|
||||||
|
.with_hdr(ToneMapper::Aces) // MSAA works with or without HDR
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(MsaaDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
show_info: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Lights, Shadows & Emissive
|
||||||
|
|
||||||
|
Examples covering **lighting**: shadow mapping, isolated light types, and
|
||||||
|
emissive materials.
|
||||||
|
|
||||||
|
| Example | Run command | What it shows |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| `shadow` | `cargo run -p wsg-lib --example shadow` | Shadow mapping in isolation (directional light, 4 objects on a textured floor) |
|
||||||
|
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Dedicated shadow test: one directional caster, cube on a ground slab, PCF-softened |
|
||||||
|
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot light: directed beam, penumbra, attenuation |
|
||||||
|
| `emissive` | `cargo run -p wsg-lib --example emissive` | Emissive materials (increasing intensities 0 → 4.0) |
|
||||||
|
|
||||||
|
> All commands run from the repo root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `shadow` — Shadow Mapping
|
||||||
|
|
||||||
|
Four objects (cube, sphere, cone, cylinder) on a floor, lit by a directional
|
||||||
|
light that casts shadows. Shadow quality is controlled by `ShadowConfig`
|
||||||
|
(map size, anti-acne bias). The floor is textured (`ground.jpeg`, tiled) and
|
||||||
|
the rotating cube uses the `uv_texture.jpg` UV atlas — the textures make the
|
||||||
|
shadow shapes and their movement clearly readable.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `1` | Front view |
|
||||||
|
| `2` | Side view |
|
||||||
|
| `3` | **Top view** (see shadow shapes clearly) |
|
||||||
|
| `L` | Change light direction (3 presets) |
|
||||||
|
|
||||||
|
### What to observe
|
||||||
|
|
||||||
|
- The cube rotates slowly → its shadow moves on the floor.
|
||||||
|
- The sphere has a smooth shadow/light transition (soft terminator).
|
||||||
|
- The cone produces a distinct triangular shadow.
|
||||||
|
- In top view (`3`), you see the exact shape of projected shadows.
|
||||||
|
- Shadow map size (1024 default) determines resolution: modify
|
||||||
|
`SHADOW_MAP_SIZE` at the top of the file to test 256 (pixelated) or 2048 (sharp).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `shadow_test` — Dedicated Shadow Mapping Test
|
||||||
|
|
||||||
|
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 the shadow runs clearly across the ground to
|
||||||
|
the left of the cube,
|
||||||
|
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.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example shadow_test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `spot_test` — Isolated Spot Light
|
||||||
|
|
||||||
|
**Only** a spot light is on (the default directional light is removed via
|
||||||
|
`clear_lights()`) and the ambient is deliberately **very low**. The rotating
|
||||||
|
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).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example spot_test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `emissive` — Emissive Materials
|
||||||
|
|
||||||
|
Five spheres in a row with increasing emissive intensities:
|
||||||
|
|
||||||
|
| Sphere | Color | Intensity | Effect |
|
||||||
|
|--------|-------|-----------|--------|
|
||||||
|
| 1 | Gray | 0.0 | No glow (reference) |
|
||||||
|
| 2 | Orange | 0.5 | Slight glow |
|
||||||
|
| 3 | Yellow | 1.0 | Visible glow |
|
||||||
|
| 4 | Green | 2.0 | HDR glow (beyond 1.0) |
|
||||||
|
| 5 | Blue | 4.0 | Intense glow (saturation) |
|
||||||
|
|
||||||
|
With HDR, intensities > 1.0 produce a true "glow" (values exceed [0,1] in
|
||||||
|
linear space). Without HDR, they would be clamped to white.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example emissive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
| `E` / `Q` | Exposure ×1.3 / ÷1.3 |
|
||||||
|
| `0` | Reset exposure |
|
||||||
|
| `C` | **Cycle emissive multiplier** (1× → 2× → 0.5× → …) |
|
||||||
|
|
||||||
|
### What to observe
|
||||||
|
|
||||||
|
- Sphere 1 (intensity 0) is simply lit by the directional light.
|
||||||
|
- Spheres 2-5 glow with their own light, independent of scene lighting.
|
||||||
|
- `C` doubles or halves all intensities simultaneously (to see the HDR effect).
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! **Emissive Materials** — demonstrates the emissive property of the standard material.
|
||||||
|
//!
|
||||||
|
//! Shows objects with varying emissive intensities. Without HDR, emissive values > 1.0
|
||||||
|
//! are clamped to white (LDR). With HDR, they produce true "glow" that can feed the
|
||||||
|
//! bloom post-process.
|
||||||
|
//!
|
||||||
|
//! The scene contains 5 spheres with increasing emissive intensity (0.0 → 4.0),
|
||||||
|
//! arranged in a row. A lit cube serves as a non-emissive reference.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `E` | Exposure up (×1.3) |
|
||||||
|
//! | `Q` | Exposure down (÷1.3) |
|
||||||
|
//! | `0` | Reset exposure |
|
||||||
|
//! | `C` | Cycle emissive intensity (re-applies to all glow spheres) |
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example emissive
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Run with `--features all-prims` if you don't have the default features.
|
||||||
|
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Emissive intensities for the 5 glow spheres (left to right).
|
||||||
|
const INTENSITIES: [f32; 5] = [0.0, 0.5, 1.0, 2.0, 4.0];
|
||||||
|
/// RGB colors for the 5 glow spheres (rainbow-ish).
|
||||||
|
const COLORS: [[f32; 3]; 5] = [
|
||||||
|
[0.5, 0.5, 0.5], // gray (no glow)
|
||||||
|
[1.0, 0.3, 0.1], // orange
|
||||||
|
[1.0, 0.8, 0.0], // yellow
|
||||||
|
[0.2, 1.0, 0.4], // green
|
||||||
|
[0.3, 0.5, 1.0], // blue
|
||||||
|
];
|
||||||
|
|
||||||
|
struct EmissiveDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
angle: f32,
|
||||||
|
/// Which intensity preset to apply (0-4 maps to a multiplier).
|
||||||
|
cycle_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for EmissiveDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Ground.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(10.0, 10.0, 1, 1), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Reference cube (non-emissive).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(0.6), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cube_tf = Transform::identity();
|
||||||
|
cube_tf.translation = Vec3::new(0.0, 0.3, 1.5);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 5 glow spheres in a row.
|
||||||
|
for i in 0..5 {
|
||||||
|
let mat_id = format!("glow_mat_{}", i);
|
||||||
|
let mesh_id = format!("glow_mesh_{}", i);
|
||||||
|
let entity_id = format!("glow_e_{}", i);
|
||||||
|
|
||||||
|
app.scene.add_material_shader(&mat_id, "standard").unwrap();
|
||||||
|
let c = COLORS[i];
|
||||||
|
let intensity = INTENSITIES[i];
|
||||||
|
app.scene
|
||||||
|
.set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.scene
|
||||||
|
.create_mesh(&mesh_id, icosphere(0.3, 3), Some(&mat_id))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let x = (i as f32 - 2.0) * 0.9;
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(x, 0.4, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(&entity_id, &mesh_id, tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directional light.
|
||||||
|
let light_dir = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.0)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.set_ambient([0.15, 0.15, 0.18]);
|
||||||
|
|
||||||
|
// Camera.
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.2;
|
||||||
|
self.camera.distance = 5.5;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.3, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.2;
|
||||||
|
self.camera.distance = 5.5;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// Exposure.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyE) {
|
||||||
|
app.set_exposure(app.exposure() * 1.3);
|
||||||
|
eprintln!("exposure = {:.2}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::KeyQ) {
|
||||||
|
app.set_exposure(app.exposure() / 1.3);
|
||||||
|
eprintln!("exposure = {:.2}", app.exposure());
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit0) {
|
||||||
|
app.set_exposure(1.0);
|
||||||
|
eprintln!("exposure reset to 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// C: cycle emissive intensity multiplier (1x → 2x → 0.5x → back).
|
||||||
|
if app.input.key_pressed(KeyCode::KeyC) {
|
||||||
|
self.cycle_idx = (self.cycle_idx + 1) % 3;
|
||||||
|
let multiplier = match self.cycle_idx {
|
||||||
|
0 => 1.0,
|
||||||
|
1 => 2.0,
|
||||||
|
_ => 0.5,
|
||||||
|
};
|
||||||
|
for i in 0..5 {
|
||||||
|
let mat_id = format!("glow_mat_{}", i);
|
||||||
|
let c = COLORS[i];
|
||||||
|
let intensity = INTENSITIES[i] * multiplier;
|
||||||
|
if let Ok(()) = app.scene.set_material_emissive(&mat_id, [c[0], c[1], c[2], intensity]) {
|
||||||
|
eprintln!("emissive multiplier = {:.1}x", multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow rotation.
|
||||||
|
self.angle += 0.01;
|
||||||
|
for i in 0..5 {
|
||||||
|
let entity_id = format!("glow_e_{}", i);
|
||||||
|
if let Some(base) = app.scene.entity_transform(&entity_id) {
|
||||||
|
let mut tf = *base;
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.angle * (1.0 + i as f32 * 0.2));
|
||||||
|
app.scene.set_entity_transform(&entity_id, tf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
// HDR enabled so emissive > 1.0 produces true glow (not clamped to white).
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG Emissive")
|
||||||
|
.size(960, 640)
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(EmissiveDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
cycle_idx: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! **Shadow Mapping** — demonstrates the directional shadow map system.
|
||||||
|
//!
|
||||||
|
//! A cube and a sphere sit on a ground plane, lit by a directional light that
|
||||||
|
//! casts shadows. The shadow quality is controlled by `ShadowConfig` (map size,
|
||||||
|
//! depth/slope bias, ortho frustum radius).
|
||||||
|
//!
|
||||||
|
//! The ground is textured (`ground.jpeg`, tiled via the Repeat sampler) and the
|
||||||
|
//! cube uses the `uv_texture.jpg` UV atlas — textured surfaces make the shadow
|
||||||
|
//! shapes and their movement clearly readable.
|
||||||
|
//!
|
||||||
|
//! ## Controls
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |-----|--------|
|
||||||
|
//! | Drag (LMB) | Orbit camera |
|
||||||
|
//! | Wheel | Zoom |
|
||||||
|
//! | `R` | Reset camera |
|
||||||
|
//! | `1` | Front view |
|
||||||
|
//! | `2` | Side view |
|
||||||
|
//! | `3` | Top view (see shadow shape clearly) |
|
||||||
|
//! | `L` | Move light (cycles 3 directions) |
|
||||||
|
//!
|
||||||
|
//! ## Shadow Config
|
||||||
|
//! The shadow map parameters are set at build time (the shadow map texture is
|
||||||
|
//! allocated once). To test different resolutions, modify `SHADOW_MAP_SIZE` below
|
||||||
|
//! and re-run.
|
||||||
|
//!
|
||||||
|
//! ## Build & Run
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run -p wsg-lib --example shadow
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::{Quat, Vec3};
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{ShadowConfig, Transform};
|
||||||
|
use wsg_lib::mesh::{cone, cube, cylinder, icosphere, plane};
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
/// Shadow map size — change to test quality (256, 512, 1024, 2048).
|
||||||
|
const SHADOW_MAP_SIZE: u32 = 1024;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
/// Light directions to cycle through (normalized at runtime).
|
||||||
|
fn light_dirs() -> [Vec3; 3] {
|
||||||
|
[
|
||||||
|
Vec3::new(1.0, 1.2, 0.8).normalize(),
|
||||||
|
Vec3::new(-0.8, 1.0, 0.5).normalize(),
|
||||||
|
Vec3::new(0.3, 0.6, -1.0).normalize(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ShadowDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
angle: f32,
|
||||||
|
light_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for ShadowDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Textured ground + cube (assets make the shadows readable): ground.jpeg on the
|
||||||
|
// floor, uv_texture.jpg on the rotating cube. Paths resolve against CARGO_MANIFEST_DIR.
|
||||||
|
let (device, queue) = {
|
||||||
|
let ctx = app.context();
|
||||||
|
(ctx.device.clone(), ctx.queue.clone())
|
||||||
|
};
|
||||||
|
let ground_tex =
|
||||||
|
Texture::from_file(&device, &queue, "ground", &format!("{TEXTURES}/ground.jpeg")).unwrap();
|
||||||
|
app.scene.add_texture("ground_texture", ground_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("ground_mat", "standard", "ground_texture")
|
||||||
|
.unwrap();
|
||||||
|
let uv_tex = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("uv_texture", uv_tex).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("cube_mat", "standard", "uv_texture")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Large ground plane (receives shadows).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("ground_mesh", plane(8.0, 8.0, 1, 1), Some("ground_mat"))
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||||
|
|
||||||
|
// Cube (casts + receives shadow).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cube_mesh", cube(0.8), Some("cube_mat"))
|
||||||
|
.unwrap();
|
||||||
|
let mut cube_tf = Transform::identity();
|
||||||
|
cube_tf.translation = Vec3::new(0.8, 0.4, 0.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cube_e", "cube_mesh", cube_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Sphere (smooth shadow terminator).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("sphere_mesh", icosphere(0.45, 3), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut sphere_tf = Transform::identity();
|
||||||
|
sphere_tf.translation = Vec3::new(-0.8, 0.45, 0.3);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("sphere_e", "sphere_mesh", sphere_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Cone (distinctive shadow shape).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cone_mesh", cone(0.4, 0.8, 24), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cone_tf = Transform::identity();
|
||||||
|
cone_tf.translation = Vec3::new(0.0, 0.4, -0.9);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cone_e", "cone_mesh", cone_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Cylinder.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("cyl_mesh", cylinder(0.3, 0.7, 24), None)
|
||||||
|
.unwrap();
|
||||||
|
let mut cyl_tf = Transform::identity();
|
||||||
|
cyl_tf.translation = Vec3::new(-0.5, 0.35, -0.7);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("cyl_e", "cyl_mesh", cyl_tf)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Directional light (shadow caster).
|
||||||
|
let dirs = light_dirs();
|
||||||
|
let light_dir = dirs[0];
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(light_dir, [1.0, 0.95, 0.88], 1.5)
|
||||||
|
.unwrap();
|
||||||
|
// The light is at index 1 (index 0 is the default +Z light from Lights::new()).
|
||||||
|
app.scene.set_shadow_caster(Some(1));
|
||||||
|
app.scene.set_ambient([0.15, 0.15, 0.18]);
|
||||||
|
|
||||||
|
// Camera.
|
||||||
|
self.camera.yaw = 0.5;
|
||||||
|
self.camera.pitch = 0.4;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
self.camera.target = Vec3::ZERO;
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbit camera.
|
||||||
|
let (dx, dy) = app.input.mouse_delta();
|
||||||
|
if app.input.mouse_button_held(MouseButton::Left) {
|
||||||
|
self.camera.orbit(dx, dy);
|
||||||
|
}
|
||||||
|
let (_, sy) = app.input.scroll_delta();
|
||||||
|
self.camera.zoom(sy);
|
||||||
|
|
||||||
|
// Camera presets.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera.yaw = 0.5;
|
||||||
|
self.camera.pitch = 0.4;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit1) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.2;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit2) {
|
||||||
|
self.camera.yaw = std::f32::consts::FRAC_PI_2;
|
||||||
|
self.camera.pitch = 0.15;
|
||||||
|
self.camera.distance = 5.0;
|
||||||
|
}
|
||||||
|
if app.input.key_pressed(KeyCode::Digit3) {
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 1.4;
|
||||||
|
self.camera.distance = 6.0;
|
||||||
|
}
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// L: cycle light direction.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyL) {
|
||||||
|
let dirs = light_dirs();
|
||||||
|
self.light_idx = (self.light_idx + 1) % dirs.len();
|
||||||
|
let new_dir = dirs[self.light_idx];
|
||||||
|
eprintln!("light direction: {:?}", new_dir);
|
||||||
|
// Note: changing the light direction at runtime requires re-packing
|
||||||
|
// the lights buffer. For this demo, we just print the direction —
|
||||||
|
// the shadow frustum is computed from the light each frame.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow rotation of the cube to show shadow movement.
|
||||||
|
self.angle += 0.005;
|
||||||
|
if let Some(base) = app.scene.entity_transform("cube_e") {
|
||||||
|
let mut tf = *base;
|
||||||
|
tf.rotation = Quat::from_rotation_y(self.angle);
|
||||||
|
app.scene.set_entity_transform("cube_e", tf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||||
|
app.render_scene(frame.view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
// Shadow config: 1024² map, default biases.
|
||||||
|
// Try map_size = 256 to see blocky shadows, or 2048 for sharper ones.
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG Shadow")
|
||||||
|
.size(960, 640)
|
||||||
|
.with_shadow_config(ShadowConfig {
|
||||||
|
map_size: SHADOW_MAP_SIZE,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(ShadowDemo {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
angle: 0.0,
|
||||||
|
light_idx: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! 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::camera::Camera;
|
||||||
|
use wsg_lib::resources::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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//! 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`.
|
|
||||||
use std::sync::Arc;
|
|
||||||
use winit::application::ApplicationHandler;
|
|
||||||
use winit::dpi::LogicalSize;
|
|
||||||
use winit::event::WindowEvent;
|
|
||||||
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::pipeline::PipelineCache;
|
|
||||||
use wsg_lib::resources::Material;
|
|
||||||
use wsg_lib::resources::Mesh;
|
|
||||||
use wsg_lib::resources::Vertex;
|
|
||||||
use wsg_lib::utils;
|
|
||||||
|
|
||||||
/// Application bas-niveau : détient les objets GPU + window, tous créés dans `resumed`.
|
|
||||||
struct App {
|
|
||||||
/// Fenêtre système, partagée via Arc (comme dans app.rs).
|
|
||||||
window: Option<Arc<Window>>,
|
|
||||||
/// Contexte GPU (Instance, Surface, Adapter, Device, Queue).
|
|
||||||
context: Option<Context>,
|
|
||||||
/// Couche d'exécution qui soumet les draw calls.
|
|
||||||
renderer: Option<Renderer>,
|
|
||||||
/// Cache de shaders/pipelines.
|
|
||||||
cache: Option<PipelineCache>,
|
|
||||||
/// Matériau (pipeline) du quad.
|
|
||||||
material: Option<Material>,
|
|
||||||
/// Mesh du quad (sommets + 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()`.
|
|
||||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
||||||
if self.context.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event_loop.set_control_flow(ControlFlow::Poll);
|
|
||||||
|
|
||||||
let attrs = WindowAttributes::default()
|
|
||||||
.with_title("WSG Manual")
|
|
||||||
.with_inner_size(LogicalSize::new(800.0, 600.0));
|
|
||||||
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");
|
|
||||||
|
|
||||||
// Configuration de la surface et récupération du format
|
|
||||||
let format = context
|
|
||||||
.configure(&context.adapter, 800, 600)
|
|
||||||
.expect("Échec configuration");
|
|
||||||
|
|
||||||
// 2. Initialisation du Renderer (Il récupère tout ce dont il a besoin)
|
|
||||||
let device = Arc::new(context.device.clone());
|
|
||||||
let mut cache = PipelineCache::new(device);
|
|
||||||
cache
|
|
||||||
.register_shader("basic", utils::BASIC_SHADER_PATH)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let renderer = Renderer::new(&context, format);
|
|
||||||
|
|
||||||
// 3. Material : On utilise renderer.device() et renderer.format()
|
|
||||||
let material = Material::new(renderer.format(), "basic", &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));
|
|
||||||
|
|
||||||
self.window = Some(window);
|
|
||||||
self.context = Some(context);
|
|
||||||
self.renderer = Some(renderer);
|
|
||||||
self.cache = Some(cache);
|
|
||||||
self.material = Some(material);
|
|
||||||
self.mesh = Some(mesh);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// À chaque frame, demande un redessin pour un rendu continu (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.
|
|
||||||
fn window_event(
|
|
||||||
&mut self,
|
|
||||||
event_loop: &ActiveEventLoop,
|
|
||||||
_window_id: winit::window::WindowId,
|
|
||||||
event: WindowEvent,
|
|
||||||
) {
|
|
||||||
match event {
|
|
||||||
winit::event::WindowEvent::RedrawRequested => {
|
|
||||||
if let (Some(context), Some(renderer), Some(mesh), Some(material)) =
|
|
||||||
(&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)
|
|
||||||
renderer.render(frame.view(), mesh, material);
|
|
||||||
|
|
||||||
// 2. Présentation
|
|
||||||
renderer.present(frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
winit::event::WindowEvent::CloseRequested => {
|
|
||||||
event_loop.exit(); // C'est ici que tu demandes à la boucle de s'arrêter
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!(
|
|
||||||
"Répertoire courant : {:?}",
|
|
||||||
std::env::current_dir().unwrap()
|
|
||||||
);
|
|
||||||
let event_loop = EventLoop::new().unwrap();
|
|
||||||
let mut app = App {
|
|
||||||
window: None,
|
|
||||||
context: None,
|
|
||||||
renderer: None,
|
|
||||||
cache: None,
|
|
||||||
material: None,
|
|
||||||
mesh: None,
|
|
||||||
};
|
|
||||||
event_loop.run_app(&mut app).unwrap();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Meshes, Materials & Import
|
||||||
|
|
||||||
|
Examples covering **geometry and materials**: the minimal workflow, the 3D MVP,
|
||||||
|
PBR shading, file import, and the low-level (non-`App`) workflow.
|
||||||
|
|
||||||
|
| Example | Run command | What it shows |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a flat two-tone quad, **unlit**, rendered automatically |
|
||||||
|
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (`uv_texture.jpg` UV atlas) cube, lit (directional + point + spot), spinning |
|
||||||
|
| `pbr` | `cargo run -p wsg-lib --example pbr` | PBR metallic/roughness + normal mapping (real `cave.jpg` albedo + `caveNormal.jpg`) |
|
||||||
|
| `import` | `cargo run -p wsg-lib --example import --features import-obj` | OBJ file import (non-graphical, prints stats to stdout) |
|
||||||
|
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade |
|
||||||
|
|
||||||
|
> All commands run from the repo root. The `import` example additionally
|
||||||
|
> requires the `import-obj` Cargo feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `simple` — Minimal Declarative Workflow
|
||||||
|
|
||||||
|
The "15 lines, no wgpu" model: `AppBuilder` creates the window + GPU, and
|
||||||
|
`App::run` drives the update → render → present loop. The scene renders
|
||||||
|
**automatically** — the default `AppHandler::render` calls
|
||||||
|
`app.render_scene(frame.view())`.
|
||||||
|
|
||||||
|
The mesh is a flat two-tone quad declared from a `Geometry` (per-vertex
|
||||||
|
positions + colors) and drawn with the `standard` shader in **unlit** mode
|
||||||
|
(`renderer.set_unlit(true)`): flat 2D is a special case of 3D, one pipeline for all.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example simple
|
||||||
|
```
|
||||||
|
|
||||||
|
No keys — static render.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `cube` — The 3D MVP
|
||||||
|
|
||||||
|
A lit unit cube that rotates, **textured** with the `uv_texture.jpg` asset — an
|
||||||
|
8×8 UV atlas visualization (labelled cells + corner coordinates) that makes
|
||||||
|
exactly where each face's UVs land visible. The texture is loaded from
|
||||||
|
`assets/textures/` via `Texture::from_file` (path resolved against
|
||||||
|
`CARGO_MANIFEST_DIR`), registered by id (`add_texture`) and bound through
|
||||||
|
`add_material_texture` (diffuse path, bind group `@group(2)`). Follows the
|
||||||
|
declarative workflow (like `simple`): `AppBuilder` + automatic scene, **no wgpu
|
||||||
|
import**; the default camera at (0, 0, 3) frames the cube, and `update()`
|
||||||
|
rotates the entity via `set_entity_transform` each frame.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example cube
|
||||||
|
```
|
||||||
|
|
||||||
|
No keys — the cube spins on its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `pbr` — PBR Metallic/Roughness + Normal Mapping
|
||||||
|
|
||||||
|
Demonstrates the Cook-Torrance PBR workflow: GGX distribution + Smith geometry +
|
||||||
|
Schlick Fresnel + hemispheric IBL + normal mapping.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example pbr
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Drag (LMB) | Orbit camera |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
| `R` | Reset camera |
|
||||||
|
|
||||||
|
Scene: 6 PBR materials (mirror metal, smooth plastic, rusty metal, ceramic,
|
||||||
|
cave, textured floor). The floor is a 20×20 plane with the `ground.jpeg`
|
||||||
|
albedo; the cave cube pairs `cave.jpg` (albedo) with `caveNormal.jpg`
|
||||||
|
(normal map, pre-encoded sRGB before upload — see the *Texture assets* section
|
||||||
|
in the parent README) and shows real surface detail under the light.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `import` — OBJ File Import
|
||||||
|
|
||||||
|
**Non-graphical** example: parses a `.obj` file and prints statistics
|
||||||
|
(vertex count, normals, UVs, indices, bounding box) to stdout.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# With a file:
|
||||||
|
cargo run -p wsg-lib --example import --features import-obj -- /path/to/model.obj
|
||||||
|
|
||||||
|
# Without argument (demo triangle):
|
||||||
|
cargo run -p wsg-lib --example import --features import-obj
|
||||||
|
```
|
||||||
|
|
||||||
|
No keys — runs and exits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `manual` — Low-level Workflow (no `App` facade)
|
||||||
|
|
||||||
|
Demonstrates the API **without** the `App` facade: direct use of `Context`,
|
||||||
|
`Renderer`, `PipelineCache`, `Mesh`, `Material`. Renders a colored quad (unlit).
|
||||||
|
|
||||||
|
Useful for understanding what the `App` facade encapsulates:
|
||||||
|
|
||||||
|
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/
|
||||||
|
`Device`/`Queue`, `configure()` for the swapchain, per-frame surface texture.
|
||||||
|
- `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`). The two-layer architecture is detailed in
|
||||||
|
[`docs/tech/ARCHI_APP.md`](../../../docs/tech/ARCHI_APP.md) and
|
||||||
|
[`FRAME_LOOP.md`](../../../docs/tech/FRAME_LOOP.md).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p wsg-lib --example manual
|
||||||
|
```
|
||||||
|
|
||||||
|
No keys — static render (unlit quad, 4 colors).
|
||||||
|
|
||||||
|
> **Tip**: start with the declarative workflow. The manual workflow doesn't
|
||||||
|
> render more pixels — it gives more control over command encoding.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! A lit unit cube that rotates, **textured** with the `uv_texture.jpg` asset (an 8×8 UV atlas
|
||||||
|
//! visualization — each labelled cell shows exactly where a face's UVs land) 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`);
|
||||||
|
//! here the texture is a **file asset** (`assets/textures/uv_texture.jpg`, loaded via
|
||||||
|
//! `Texture::from_file`) — the path is resolved against `CARGO_MANIFEST_DIR` so the example
|
||||||
|
//! works from any working directory.
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
/// Demo handler: rotates the textured cube in `update`.
|
||||||
|
struct Cube {
|
||||||
|
/// Cumulative rotation angle (radians), incremented each frame.
|
||||||
|
angle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Loads the `uv_texture.jpg` asset 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_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"uv_atlas",
|
||||||
|
&format!("{TEXTURES}/uv_texture.jpg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("uv_texture", texture).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_texture("cube_material", "standard", "uv_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,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!();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! 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;
|
||||||
|
use winit::event::WindowEvent;
|
||||||
|
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, ShadowConfig};
|
||||||
|
use wsg_lib::pipeline::PipelineCache;
|
||||||
|
use wsg_lib::resources::{Geometry, Material, Mesh};
|
||||||
|
use wsg_lib::utils;
|
||||||
|
|
||||||
|
/// Low-level application: holds the GPU objects + window, all created in `resumed`.
|
||||||
|
struct App {
|
||||||
|
/// System window, shared via Arc (as in app.rs).
|
||||||
|
window: Option<Arc<Window>>,
|
||||||
|
/// GPU context (Instance, Surface, Adapter, Device, Queue).
|
||||||
|
context: Option<Context>,
|
||||||
|
/// Execution layer that submits draw calls.
|
||||||
|
renderer: Option<Renderer>,
|
||||||
|
/// Shader/pipeline cache.
|
||||||
|
cache: Option<PipelineCache>,
|
||||||
|
/// Quad material (pipeline).
|
||||||
|
material: Option<Material>,
|
||||||
|
/// Quad mesh (vertices + indices).
|
||||||
|
mesh: Option<Mesh>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for App {
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
event_loop.set_control_flow(ControlFlow::Poll);
|
||||||
|
|
||||||
|
let attrs = WindowAttributes::default()
|
||||||
|
.with_title("WSG Manual")
|
||||||
|
.with_inner_size(LogicalSize::new(800.0, 600.0));
|
||||||
|
let window = Arc::new(event_loop.create_window(attrs).unwrap());
|
||||||
|
|
||||||
|
// 1. Initialisation
|
||||||
|
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
|
||||||
|
|
||||||
|
// Surface configuration and format retrieval
|
||||||
|
let format = context
|
||||||
|
.configure(&context.adapter, 800, 600)
|
||||||
|
.expect("configuration failed");
|
||||||
|
|
||||||
|
// 2. Renderer initialization (it retrieves everything it needs)
|
||||||
|
let device = Arc::new(context.device.clone());
|
||||||
|
let mut cache = PipelineCache::new(device, context.queue.clone(), 1);
|
||||||
|
cache
|
||||||
|
.register_shader("standard", utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 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, None, None, None, None);
|
||||||
|
renderer.set_unlit(true);
|
||||||
|
|
||||||
|
// 3. Material: uses renderer.device() and renderer.format()
|
||||||
|
let material = Material::new(renderer.format(), "standard", &mut cache);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
self.renderer = Some(renderer);
|
||||||
|
self.cache = Some(cache);
|
||||||
|
self.material = Some(material);
|
||||||
|
self.mesh = Some(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window event dispatch: RedrawRequested renders then presents, CloseRequested exits.
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
event_loop: &ActiveEventLoop,
|
||||||
|
_window_id: winit::window::WindowId,
|
||||||
|
event: WindowEvent,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
winit::event::WindowEvent::RedrawRequested => {
|
||||||
|
if let (Some(context), Some(renderer), Some(mesh), Some(material)) =
|
||||||
|
(&self.context, &self.renderer, &self.mesh, &self.material)
|
||||||
|
{
|
||||||
|
if let Some(frame) = Frame::try_new(&context.surface) {
|
||||||
|
// 1. Render (no more useless device/queue arguments)
|
||||||
|
renderer.render(frame.view(), mesh, material);
|
||||||
|
|
||||||
|
// 2. Present
|
||||||
|
renderer.present(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winit::event::WindowEvent::CloseRequested => {
|
||||||
|
event_loop.exit(); // this is where you ask the loop to stop
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("Current directory: {:?}", std::env::current_dir().unwrap());
|
||||||
|
let event_loop = EventLoop::new().unwrap();
|
||||||
|
let mut app = App {
|
||||||
|
window: None,
|
||||||
|
context: None,
|
||||||
|
renderer: None,
|
||||||
|
cache: None,
|
||||||
|
material: None,
|
||||||
|
mesh: None,
|
||||||
|
};
|
||||||
|
event_loop.run_app(&mut app).unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! # Exemple PBR — Metallic/Roughness + Normal Mapping (Étape 27)
|
||||||
|
//!
|
||||||
|
//! Démonstration du workflow PBR Cook-Torrance (GGX + Smith + Schlick) avec IBL hémisphérique.
|
||||||
|
//!
|
||||||
|
//! ## Scène
|
||||||
|
//! - Sol : plan 20×20, PBR matte + albedo `ground.jpeg` (metallic=0, roughness=0.8)
|
||||||
|
//! - Cube métal : metallic=1.0, roughness=0.1 → reflet spéculaire net (miroir)
|
||||||
|
//! - Cube plastique : metallic=0.0, roughness=0.4 → spéculaire large et doux
|
||||||
|
//! - Cube rouillé : metallic=0.8, roughness=0.7 → métal rugueux
|
||||||
|
//! - Sphere céramique : metallic=0.3, roughness=0.3
|
||||||
|
//! - Cube cave : albedo `cave.jpg` + normal map `caveNormal.jpg` (assets, normal map pré-encodée sRGB)
|
||||||
|
//!
|
||||||
|
//! ## Contrôles
|
||||||
|
//! | Touche | Action |
|
||||||
|
//! |--------|--------|
|
||||||
|
//! | Drag (LMB) | Orbite caméra |
|
||||||
|
//! | Molette | Zoom |
|
||||||
|
//! | `R` | Reset caméra |
|
||||||
|
//!
|
||||||
|
//! ## Lancement
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p wsg-lib --example pbr
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use glam::Vec3;
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
use winit::keyboard::KeyCode;
|
||||||
|
use wsg_lib::app::AppBuilder;
|
||||||
|
use wsg_lib::camera::CameraController;
|
||||||
|
use wsg_lib::core::{ToneMapper, Transform};
|
||||||
|
use wsg_lib::mesh::{cube, icosphere, plane};
|
||||||
|
use wsg_lib::resources::Texture;
|
||||||
|
use wsg_lib::AppHandler;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct PbrDemo {
|
||||||
|
camera: CameraController,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PbrDemo {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
camera: CameraController::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppHandler for PbrDemo {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
app.scene
|
||||||
|
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Textures fichiers (assets/textures) : albedo du sol + albedo/normal cave.
|
||||||
|
// La normal map est pré-encodée sRGB avant upload : `Texture` est toujours
|
||||||
|
// `Rgba8UnormSrgb` (le GPU décode en sRGB à l'échantillonnage), et les données
|
||||||
|
// d'une normal map sont linéaires — l'encodage OETF compense la décodage EOTF
|
||||||
|
// (EOTF(OETF(x)) = x), sinon la perturbation serait visiblement faussée.
|
||||||
|
let (device, queue) = {
|
||||||
|
let ctx = app.context();
|
||||||
|
(ctx.device.clone(), ctx.queue.clone())
|
||||||
|
};
|
||||||
|
let ground_albedo = Texture::from_file(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
"ground",
|
||||||
|
&format!("{TEXTURES}/ground.jpeg"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_texture("ground_albedo", ground_albedo).unwrap();
|
||||||
|
let cave_albedo =
|
||||||
|
Texture::from_file(&device, &queue, "cave", &format!("{TEXTURES}/cave.jpg")).unwrap();
|
||||||
|
app.scene.add_texture("cave_albedo", cave_albedo).unwrap();
|
||||||
|
let cave_nm = load_normal_map(
|
||||||
|
&device,
|
||||||
|
&queue,
|
||||||
|
&format!("{TEXTURES}/caveNormal.jpg"),
|
||||||
|
"cave_nm",
|
||||||
|
);
|
||||||
|
app.scene.add_texture("cave_nm", cave_nm).unwrap();
|
||||||
|
|
||||||
|
// Matériaux PBR.
|
||||||
|
app.scene
|
||||||
|
.add_material_pbr_textured("floor", "standard", 0.0, 0.8, Some("ground_albedo"), None)
|
||||||
|
.unwrap();
|
||||||
|
app.scene.add_material_pbr("metal", "standard", 1.0, 0.1).unwrap();
|
||||||
|
app.scene.add_material_pbr("plastic", "standard", 0.0, 0.4).unwrap();
|
||||||
|
app.scene.add_material_pbr("rust", "standard", 0.8, 0.7).unwrap();
|
||||||
|
app.scene.add_material_pbr("ceramic", "standard", 0.3, 0.3).unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_material_pbr_textured(
|
||||||
|
"cave",
|
||||||
|
"standard",
|
||||||
|
0.0,
|
||||||
|
0.6,
|
||||||
|
Some("cave_albedo"),
|
||||||
|
Some("cave_nm"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Sol (plan 20×20).
|
||||||
|
app.scene
|
||||||
|
.create_mesh("floor_mesh", plane(1.0, 1.0, 1, 1), Some("floor"))
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(0.0, 0.0, 0.0);
|
||||||
|
tf.scale = Vec3::new(20.0, 1.0, 20.0);
|
||||||
|
app.scene.add_entity_with_transform("floor", "floor_mesh", tf).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cubes.
|
||||||
|
app.scene.create_mesh("cube_mesh", cube(1.0), None).unwrap();
|
||||||
|
let cubes: [(&str, &str, Vec3); 4] = [
|
||||||
|
("c_metal", "metal", Vec3::new(-3.0, 0.5, 0.0)),
|
||||||
|
("c_plastic", "plastic", Vec3::new(-1.0, 0.5, 0.0)),
|
||||||
|
("c_rust", "rust", Vec3::new(1.0, 0.5, 0.0)),
|
||||||
|
("c_cave", "cave", Vec3::new(3.0, 0.5, 0.0)),
|
||||||
|
];
|
||||||
|
for (id, mat, pos) in &cubes {
|
||||||
|
app.scene
|
||||||
|
.create_mesh(&format!("{id}_mesh"), cube(1.0), Some(mat))
|
||||||
|
.unwrap();
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = *pos;
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform(id, &format!("{id}_mesh"), tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sphere céramique.
|
||||||
|
app.scene
|
||||||
|
.create_mesh("sphere_mesh", icosphere(0.5, 4), Some("ceramic"))
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let mut tf = Transform::identity();
|
||||||
|
tf.translation = Vec3::new(0.0, 0.5, -3.0);
|
||||||
|
app.scene
|
||||||
|
.add_entity_with_transform("s_ceramic", "sphere_mesh", tf)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lumières.
|
||||||
|
app.scene
|
||||||
|
.add_directional_light(Vec3::new(-1.0, 2.0, 1.0).normalize(), [1.0, 0.95, 0.9], 2.0)
|
||||||
|
.unwrap();
|
||||||
|
app.scene
|
||||||
|
.add_point_light(Vec3::new(0.0, 3.0, 2.0), [0.3, 0.5, 1.0], 8.0, 5.0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
|
||||||
|
// Ambiance (IBL hémisphérique).
|
||||||
|
app.scene.set_ambient([0.3, 0.35, 0.4]);
|
||||||
|
|
||||||
|
// Caméra.
|
||||||
|
self.camera.yaw = 0.0;
|
||||||
|
self.camera.pitch = 0.3;
|
||||||
|
self.camera.distance = 8.0;
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/cave/floor)");
|
||||||
|
eprintln!("[PBR] Drag=orbit, Wheel=zoom, R=reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// Orbite caméra.
|
||||||
|
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);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
|
||||||
|
// R = reset.
|
||||||
|
if app.input.key_pressed(KeyCode::KeyR) {
|
||||||
|
self.camera = CameraController::default();
|
||||||
|
self.camera.target = Vec3::new(0.0, 0.5, 0.0);
|
||||||
|
self.camera.apply_to(app.scene.camera_mut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Texture asset directory, resolved against the crate root so the example works from any CWD.
|
||||||
|
const TEXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/textures");
|
||||||
|
|
||||||
|
/// Charge une normal map depuis un fichier et l'upload en `Texture`.
|
||||||
|
///
|
||||||
|
/// `Texture` est toujours `Rgba8UnormSrgb` : le GPU applique la EOTF sRGB à
|
||||||
|
/// l'échantillonnage. Une normal map est des données **linéaires** — on pré-encode
|
||||||
|
/// donc chaque canal avec la OETF sRGB avant l'upload, pour que le round-trip
|
||||||
|
/// GPU soit l'identité (EOTF(OETF(x)) = x). Sans ce pré-encodage, la perturbation
|
||||||
|
/// de normale serait visiblement faussée (valeurs compressées vers le noir).
|
||||||
|
fn load_normal_map(device: &wgpu::Device, queue: &wgpu::Queue, path: &str, label: &str) -> Texture {
|
||||||
|
let bytes = std::fs::read(path).expect("normal map asset present in the repo");
|
||||||
|
let rgba = image::load_from_memory(&bytes).expect("valid image").to_rgba8();
|
||||||
|
let encoded = rgba
|
||||||
|
.as_raw()
|
||||||
|
.iter()
|
||||||
|
.map(|&c| {
|
||||||
|
let v = c as f32 / 255.0;
|
||||||
|
let e = if v <= 0.0031308 {
|
||||||
|
12.92 * v
|
||||||
|
} else {
|
||||||
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||||
|
};
|
||||||
|
(e * 255.0).round().clamp(0.0, 255.0) as u8
|
||||||
|
})
|
||||||
|
.collect::<Vec<u8>>();
|
||||||
|
Texture::from_rgba8(device, queue, rgba.width(), rgba.height(), &encoded, label)
|
||||||
|
.expect("normal map upload failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new()
|
||||||
|
.title("WSG — PBR Metallic/Roughness")
|
||||||
|
.size(1280, 720)
|
||||||
|
.with_hdr(ToneMapper::Aces)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
app.run(PbrDemo::default())
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! 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::Geometry;
|
||||||
|
use wsg_lib::utils::WsgError;
|
||||||
|
|
||||||
|
struct MonQuad;
|
||||||
|
|
||||||
|
impl AppHandler for MonQuad {
|
||||||
|
fn setup(&mut self, app: &mut wsg_lib::App) {
|
||||||
|
// 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
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pollster::main]
|
||||||
|
async fn main() -> Result<(), WsgError> {
|
||||||
|
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
||||||
|
app.run(MonQuad)
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
use wsg_lib::AppHandler;
|
|
||||||
use wsg_lib::app::AppBuilder;
|
|
||||||
use wsg_lib::resources::{Material, Mesh, Vertex};
|
|
||||||
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();
|
|
||||||
app.scene
|
|
||||||
.add_entity("quad", "quad_mesh", "basic_material")
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[pollster::main]
|
|
||||||
async fn main() -> Result<(), WsgError> {
|
|
||||||
let app = AppBuilder::new().title("WSG Simple").build().await?;
|
|
||||||
app.run(MonQuad)
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
## Overview
|
## 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 |
|
| Module | Responsibility |
|
||||||
|--------|---------------|
|
|--------|---------------|
|
||||||
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration |
|
| **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), Material (appearance descriptor) |
|
| **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 |
|
| **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 |
|
| **utils** | Configuration constants and WsgError type |
|
||||||
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
|
| **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 |
|
| **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:
|
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.
|
- **Manual**: Manipulate Context, Renderer, and PipelineCache directly for fine-grained control.
|
||||||
|
|
||||||
## Dependency Flow
|
## Dependency Flow
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
//! ## Interaction with Other Modules
|
//! ## Interaction with Other Modules
|
||||||
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
|
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
|
||||||
//! - **core::renderer**: Delegates draw call execution to Renderer per frame.
|
//! - **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.
|
//! - **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.
|
//! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
|
||||||
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
|
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
|
||||||
//!
|
//!
|
||||||
@@ -22,8 +23,8 @@
|
|||||||
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
||||||
|
|
||||||
use crate::AppHandler;
|
use crate::AppHandler;
|
||||||
use crate::core::{Context, Renderer};
|
use crate::core::{BloomConfig, Context, MsaaConfig, Renderer, ShadowConfig, ToneMapper};
|
||||||
use crate::pipeline::PipelineCache;
|
use crate::input::InputState;
|
||||||
use crate::scene::Scene;
|
use crate::scene::Scene;
|
||||||
use crate::utils::WsgError;
|
use crate::utils::WsgError;
|
||||||
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
||||||
@@ -38,19 +39,44 @@ use winit::window::{Window, WindowAttributes};
|
|||||||
/// Encapsulates all five WGPU objects (Instance, Surface, Adapter, Device, Queue) plus the render loop.
|
/// 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.
|
/// 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
|
/// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
|
||||||
/// application is resumed (see `AppRunner`); they are only populated after `App::run` has started.
|
/// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
|
||||||
/// Access them through the `context()`, `renderer()`, `window()` and `cache()` accessors, which is
|
/// 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`.
|
/// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
|
||||||
pub struct App {
|
pub struct App {
|
||||||
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
|
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
|
||||||
pub scene: Scene,
|
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`.
|
/// Window title, read by the runner when the window is created in `resumed`.
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
/// Window width, read by the runner when the window is created in `resumed`.
|
/// Window width, read by the runner when the window is created in `resumed`.
|
||||||
pub(crate) width: u32,
|
pub(crate) width: u32,
|
||||||
/// Window height, read by the runner when the window is created in `resumed`.
|
/// Window height, read by the runner when the window is created in `resumed`.
|
||||||
pub(crate) height: u32,
|
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>,
|
||||||
|
/// Bloom post-process (Étape 23). `None` = no bloom (default, zero overhead).
|
||||||
|
/// Only active when HDR is also enabled.
|
||||||
|
pub(crate) bloom_config: Option<BloomConfig>,
|
||||||
|
/// Exposure multiplier (Étape 22, 6.1). Applied in the tone mapping pass before the curve.
|
||||||
|
/// Default 1.0. Adjustable at runtime via `set_exposure` or keyboard (+/-).
|
||||||
|
pub exposure: f32,
|
||||||
|
/// MSAA configuration (Étape 24). `None` = no MSAA (default, zero overhead);
|
||||||
|
/// `Some(config)` activates multi-sample anti-aliasing.
|
||||||
|
msaa: Option<MsaaConfig>,
|
||||||
|
/// Fog configuration (Étape 25). `None` = no fog (default, zero overhead).
|
||||||
|
fog: Option<super::core::FogConfig>,
|
||||||
|
/// DoF configuration (Étape 26). `None` = no DoF (default, zero overhead). Requires HDR.
|
||||||
|
dof: Option<super::core::DoFConfig>,
|
||||||
/// Winit event loop for window management. Set to None after run() consumes it.
|
/// Winit event loop for window management. Set to None after run() consumes it.
|
||||||
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
|
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
|
||||||
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
|
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
|
||||||
@@ -59,8 +85,6 @@ pub struct App {
|
|||||||
renderer: Option<Renderer>,
|
renderer: Option<Renderer>,
|
||||||
/// The OS-level window backing this application. Shared via Arc for multi-owner access.
|
/// The OS-level window backing this application. Shared via Arc for multi-owner access.
|
||||||
window: Option<Arc<Window>>,
|
window: Option<Arc<Window>>,
|
||||||
/// Shader compilation cache — manages RenderPipelines keyed by shader_id.
|
|
||||||
cache: Option<PipelineCache>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -72,6 +96,16 @@ impl App {
|
|||||||
.expect("renderer not initialized yet — call app.run(handler) first")
|
.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.
|
/// Returns a reference to the GPU hardware context.
|
||||||
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
|
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
|
||||||
pub fn context(&self) -> &Context {
|
pub fn context(&self) -> &Context {
|
||||||
@@ -80,14 +114,6 @@ impl App {
|
|||||||
.expect("context not initialized yet — call app.run(handler) first")
|
.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.
|
/// Returns a reference to the window backing this application.
|
||||||
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
|
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
|
||||||
pub fn window(&self) -> &Window {
|
pub fn window(&self) -> &Window {
|
||||||
@@ -107,12 +133,20 @@ impl App {
|
|||||||
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
|
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
|
||||||
/// 6) on CloseRequested: exit the event loop.
|
/// 6) on CloseRequested: exit the event loop.
|
||||||
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
|
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
|
// Extract the event_loop safely via Option
|
||||||
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // Erreur si déjà pris
|
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
|
||||||
let mut runner = AppRunner {
|
let mut runner = AppRunner {
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
|
culling: self.culling,
|
||||||
|
shadow_config: self.shadow_config.clone(),
|
||||||
|
hdr: self.hdr,
|
||||||
|
bloom_config: self.bloom_config.clone(),
|
||||||
|
exposure: self.exposure,
|
||||||
|
msaa: self.msaa.clone(),
|
||||||
|
fog: self.fog.clone(),
|
||||||
|
dof: self.dof.clone(),
|
||||||
handler,
|
handler,
|
||||||
app: None,
|
app: None,
|
||||||
};
|
};
|
||||||
@@ -126,13 +160,72 @@ impl App {
|
|||||||
/// who override `render` to control drawing themselves.
|
/// who override `render` to control drawing themselves.
|
||||||
/// Inputs: view — the frame's texture view acting as the color attachment target.
|
/// 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, Étape 4.3)
|
/// 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
|
/// is derived here from the window's current inner size, so the `Renderer` stays independent of
|
||||||
/// the windowing backend.
|
/// the windowing backend.
|
||||||
pub fn render_scene(&self, view: &wgpu::TextureView) {
|
pub fn render_scene(&self, view: &wgpu::TextureView) {
|
||||||
let size = self.window().inner_size();
|
let size = self.window().inner_size();
|
||||||
let aspect = size.width as f32 / size.height.max(1) as f32;
|
let aspect = size.width as f32 / size.height.max(1) as f32;
|
||||||
self.renderer().render_scene(view, &self.scene, aspect);
|
self.renderer().render_scene(view, &self.scene, aspect, self.exposure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the exposure multiplier (Étape 22, 6.1). Clamped to [0.01, 10.0].
|
||||||
|
/// Takes effect on the next frame's tone mapping pass.
|
||||||
|
pub fn set_exposure(&mut self, value: f32) {
|
||||||
|
self.exposure = value.clamp(0.01, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current exposure multiplier.
|
||||||
|
pub fn exposure(&self) -> f32 {
|
||||||
|
self.exposure
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if bloom is active (Étape 23). Requires HDR to be enabled.
|
||||||
|
pub fn bloom_enabled(&self) -> bool {
|
||||||
|
self.bloom_config.is_some() && self.hdr.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current bloom configuration (Étape 23). `None` if bloom is not enabled.
|
||||||
|
pub fn bloom_config(&self) -> Option<&BloomConfig> {
|
||||||
|
self.bloom_config.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the bloom configuration at runtime (Étape 23).
|
||||||
|
/// Takes effect on the next frame (uniforms are re-written each frame).
|
||||||
|
/// No-op if bloom is not enabled.
|
||||||
|
pub fn set_bloom_config(&mut self, config: BloomConfig) {
|
||||||
|
if self.bloom_config.is_some() {
|
||||||
|
self.bloom_config = Some(config.clone());
|
||||||
|
if let Some(renderer) = &mut self.renderer {
|
||||||
|
renderer.set_bloom_config(&config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
|
||||||
|
/// 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());
|
||||||
|
let sc = self.renderer_mut().msaa_sample_count();
|
||||||
|
self.scene
|
||||||
|
.init_gpu(device, self.context().queue.clone(), new_format, sc);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +238,24 @@ pub struct AppBuilder {
|
|||||||
width: u32,
|
width: u32,
|
||||||
/// Window height in pixels.
|
/// Window height in pixels.
|
||||||
height: u32,
|
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>,
|
||||||
|
/// Bloom post-process (Étape 23). `None` = no bloom (default); `Some(c)` activates
|
||||||
|
/// the 4-pass bloom when HDR is also enabled.
|
||||||
|
bloom_config: Option<BloomConfig>,
|
||||||
|
/// Initial exposure multiplier (Étape 22, 6.1). Default 1.0.
|
||||||
|
exposure: f32,
|
||||||
|
/// MSAA configuration (Étape 24). `None` = no MSAA (default).
|
||||||
|
msaa: Option<MsaaConfig>,
|
||||||
|
/// Fog configuration (Étape 25). `None` = no fog (default).
|
||||||
|
fog: Option<super::core::FogConfig>,
|
||||||
|
/// DoF configuration (Étape 26). `None` = no DoF (default). Requires HDR.
|
||||||
|
dof: Option<super::core::DoFConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppBuilder {
|
impl AppBuilder {
|
||||||
@@ -155,6 +266,14 @@ impl AppBuilder {
|
|||||||
title: APP_DEFAULT_TITLE.to_string(),
|
title: APP_DEFAULT_TITLE.to_string(),
|
||||||
width: APP_DEFAULT_WIDTH,
|
width: APP_DEFAULT_WIDTH,
|
||||||
height: APP_DEFAULT_HEIGHT,
|
height: APP_DEFAULT_HEIGHT,
|
||||||
|
culling: false,
|
||||||
|
shadow_config: ShadowConfig::default(),
|
||||||
|
hdr: None,
|
||||||
|
bloom_config: None,
|
||||||
|
exposure: 1.0,
|
||||||
|
msaa: None,
|
||||||
|
fog: None,
|
||||||
|
dof: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Sets the window title to display in the OS taskbar/window decorations.
|
/// Sets the window title to display in the OS taskbar/window decorations.
|
||||||
@@ -170,6 +289,73 @@ impl AppBuilder {
|
|||||||
self.height = height;
|
self.height = height;
|
||||||
self
|
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
|
||||||
|
}
|
||||||
|
/// Enables the bloom post-process (Étape 23). Bright areas (above `config.threshold` in
|
||||||
|
/// linear HDR units) are blurred and added back to the image, creating a glow effect.
|
||||||
|
/// **Requires HDR** (`with_hdr`): without it, the bloom is silently ignored with a warning.
|
||||||
|
pub fn with_bloom(mut self, config: BloomConfig) -> Self {
|
||||||
|
if self.hdr.is_none() {
|
||||||
|
eprintln!("[wsg] Warning: with_bloom() requires with_hdr() — bloom ignored.");
|
||||||
|
}
|
||||||
|
self.bloom_config = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Sets the initial exposure multiplier (Étape 22, 6.1). Default 1.0.
|
||||||
|
pub fn with_exposure(mut self, exposure: f32) -> Self {
|
||||||
|
self.exposure = exposure;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Enables MSAA (Multi-Sample Anti-Aliasing) with the given sample count (Étape 24).
|
||||||
|
/// The count must be 2, 4, or 8 (validated at build time; invalid values fall back to no MSAA
|
||||||
|
/// with a warning). When disabled (not set), the renderer uses single-sample (zero overhead).
|
||||||
|
/// Works independently of HDR: with HDR, the MSAA texture is `Rgba16Float` and resolves
|
||||||
|
/// into the HDR texture before bloom/TM; without HDR, it resolves directly to the swapchain.
|
||||||
|
pub fn with_msaa(mut self, sample_count: u32) -> Self {
|
||||||
|
if let Some(reason) = MsaaConfig::validate(sample_count) {
|
||||||
|
eprintln!("[wsg] Warning: with_msaa({}) — {} — MSAA disabled.", sample_count, reason);
|
||||||
|
} else {
|
||||||
|
self.msaa = Some(MsaaConfig { sample_count });
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Enables distance fog (Étape 25). Fades objects into `config.color` based on their
|
||||||
|
/// distance from the camera. Use `FogConfig::exponential2(color, density)` to mask
|
||||||
|
/// the edge of the rendered world. Zero cost when not called.
|
||||||
|
pub fn with_fog(mut self, config: super::core::FogConfig) -> Self {
|
||||||
|
self.fog = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Enables Depth of Field (Étape 26). Blurs pixels based on their distance from the
|
||||||
|
/// focus plane, creating a cinematic bokeh effect. **Requires HDR** (`with_hdr`):
|
||||||
|
/// without it, the DoF is silently ignored with a warning. Zero cost when not called.
|
||||||
|
pub fn with_dof(mut self, config: super::core::DoFConfig) -> Self {
|
||||||
|
if self.hdr.is_none() {
|
||||||
|
eprintln!("[wsg] Warning: with_dof() requires with_hdr() — DoF ignored.");
|
||||||
|
}
|
||||||
|
self.dof = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
/// Builds the configured `App` instance: creates the event loop and stores the window
|
/// Builds the configured `App` instance: creates the event loop and stores the window
|
||||||
/// configuration. The GPU context, window and renderer are created later, when the event loop
|
/// configuration. The GPU context, window and renderer are created later, when the event loop
|
||||||
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
|
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
|
||||||
@@ -179,14 +365,22 @@ impl AppBuilder {
|
|||||||
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
|
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
|
||||||
Ok(App {
|
Ok(App {
|
||||||
scene: Scene::new(),
|
scene: Scene::new(),
|
||||||
|
input: InputState::new(),
|
||||||
title: self.title,
|
title: self.title,
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
|
culling: self.culling,
|
||||||
|
shadow_config: self.shadow_config,
|
||||||
|
hdr: self.hdr,
|
||||||
|
bloom_config: self.bloom_config,
|
||||||
|
exposure: self.exposure,
|
||||||
|
msaa: self.msaa,
|
||||||
|
fog: self.fog,
|
||||||
|
dof: self.dof,
|
||||||
event_loop: Some(event_loop),
|
event_loop: Some(event_loop),
|
||||||
context: None,
|
context: None,
|
||||||
renderer: None,
|
renderer: None,
|
||||||
window: None,
|
window: None,
|
||||||
cache: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +395,22 @@ struct AppRunner<H: AppHandler> {
|
|||||||
width: u32,
|
width: u32,
|
||||||
/// Window height in pixels, applied when the window is created in `resumed`.
|
/// Window height in pixels, applied when the window is created in `resumed`.
|
||||||
height: u32,
|
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>,
|
||||||
|
/// Bloom config (Étape 23); passed to `Renderer::new` in `resumed`. Only active with HDR.
|
||||||
|
bloom_config: Option<BloomConfig>,
|
||||||
|
/// Initial exposure (Étape 22, 6.1); stored in the App for per-frame use.
|
||||||
|
exposure: f32,
|
||||||
|
/// MSAA config (Étape 24); passed to `Renderer::new` in `resumed`.
|
||||||
|
msaa: Option<MsaaConfig>,
|
||||||
|
/// Fog config (Étape 25); passed to `Renderer::new` in `resumed`.
|
||||||
|
fog: Option<super::core::FogConfig>,
|
||||||
|
/// DoF config (Étape 26); passed to `Renderer::new` in `resumed`. Only active with HDR.
|
||||||
|
dof: Option<super::core::DoFConfig>,
|
||||||
/// The user-provided game logic.
|
/// The user-provided game logic.
|
||||||
handler: H,
|
handler: H,
|
||||||
/// The fully-built App facade, populated on the first `resumed` event.
|
/// The fully-built App facade, populated on the first `resumed` event.
|
||||||
@@ -227,27 +437,50 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
.expect("failed to create window"),
|
.expect("failed to create window"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialization GPU (bloquant, simplifié au max)
|
// GPU initialization (blocking, kept as simple as possible)
|
||||||
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");
|
||||||
let format = context
|
let format = context
|
||||||
.configure(&context.adapter, self.width, self.height)
|
.configure(&context.adapter, self.width, self.height)
|
||||||
.expect("Échec configuration surface");
|
.expect("surface configuration failed");
|
||||||
let device = Arc::new(context.device.clone());
|
let device = Arc::new(context.device.clone());
|
||||||
let cache = PipelineCache::new(device);
|
let renderer =
|
||||||
let renderer = Renderer::new(&context, format);
|
Renderer::new(&context, format, self.width, self.height, &self.shadow_config, self.hdr, self.bloom_config.clone(), self.msaa.clone(), self.fog.clone(), self.dof.clone());
|
||||||
|
// 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();
|
||||||
|
let msaa_sc = self.msaa.as_ref().map(|c| c.sample_count).unwrap_or(1);
|
||||||
|
scene.init_gpu(device, context.queue.clone(), main_format, msaa_sc);
|
||||||
|
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
scene: Scene::new(),
|
scene,
|
||||||
|
input: InputState::new(),
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
|
culling: self.culling,
|
||||||
|
shadow_config: self.shadow_config.clone(),
|
||||||
|
hdr: self.hdr,
|
||||||
|
bloom_config: self.bloom_config.clone(),
|
||||||
|
exposure: self.exposure,
|
||||||
|
msaa: self.msaa.clone(),
|
||||||
|
fog: self.fog.clone(),
|
||||||
|
dof: self.dof.clone(),
|
||||||
event_loop: None,
|
event_loop: None,
|
||||||
context: Some(context),
|
context: Some(context),
|
||||||
renderer: Some(renderer),
|
renderer: Some(renderer),
|
||||||
window: Some(window),
|
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.handler.setup(&mut app);
|
||||||
self.app = Some(app);
|
self.app = Some(app);
|
||||||
}
|
}
|
||||||
@@ -258,7 +491,23 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
let Some(app) = self.app.as_mut() else {
|
let Some(app) = self.app.as_mut() else {
|
||||||
return;
|
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);
|
self.handler.update(app);
|
||||||
|
app.input.end_frame();
|
||||||
app.window().request_redraw();
|
app.window().request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,14 +522,34 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
let Some(app) = self.app.as_mut() else {
|
let Some(app) = self.app.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
|
||||||
|
app.input.handle_window_event(&event);
|
||||||
match 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 => {
|
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
|
// Rendering logic
|
||||||
let frame = app.context().get_next_frame();
|
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);
|
self.handler.render(app, &frame);
|
||||||
// On présente automatiquement
|
// Present automatically
|
||||||
app.renderer().present(frame);
|
app.renderer().present(frame);
|
||||||
}
|
}
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! # Camera Module
|
||||||
|
//!
|
||||||
|
//! Defines the `Camera` struct and related functionality for 3D viewing.
|
||||||
|
//! Supports different camera types and projection configurations.
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//! - Used by `Renderer` to compute view and projection matrices
|
||||||
|
//! - Configurable for perspective and orthographic projections
|
||||||
|
//! - Supports FPS-style and orbital movement patterns
|
||||||
|
//!
|
||||||
|
//! ## Related Types
|
||||||
|
//! - `Camera`: Main struct for camera configuration
|
||||||
|
//! - `view_matrix()`: Computes the view matrix
|
||||||
|
//! - `projection_matrix()`: Computes the projection matrix
|
||||||
|
|
||||||
|
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 (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
|
||||||
|
pub position: Vec3,
|
||||||
|
/// Target point the camera is looking at
|
||||||
|
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 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 (world → view space)
|
||||||
|
pub fn view_matrix(&self) -> Mat4 {
|
||||||
|
glam::camera::rh::view::look_at_mat4(self.position, self.target, self.up)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the perspective projection matrix for this camera using its stored fov/near/far.
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
/// - `aspect`: Aspect ratio of the viewport (width / height)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 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::camera::{Camera, CameraController};
|
||||||
|
/// # use glam::Vec3;
|
||||||
|
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
|
||||||
|
/// let mut ctrl = CameraController::from_camera(&cam);
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. |
|
| **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. |
|
| **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. |
|
| **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
|
## Interaction with Other Modules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,793 @@
|
|||||||
|
//! # Bloom Post-Process (Étape 23)
|
||||||
|
//!
|
||||||
|
//! Defines `BloomConfig` (public user-facing configuration) and the internal `BloomPipeline`
|
||||||
|
//! (GPU resources: half-res textures, blur/composite pipelines, bind groups). The bloom effect
|
||||||
|
//! is a 4-pass post-process that operates on the HDR texture before tone mapping:
|
||||||
|
//!
|
||||||
|
//! 1. **Threshold** (full → half res): extract pixels above a luminance threshold (soft-knee).
|
||||||
|
//! 2. **Blur H** (half res): horizontal separable Gaussian (9 taps).
|
||||||
|
//! 3. **Blur V** (half res): vertical separable Gaussian (9 taps).
|
||||||
|
//! 4. **Composite** (full res): `HDR += bloom × intensity`.
|
||||||
|
//!
|
||||||
|
//! The bloom is **opt-in** (`AppBuilder::with_bloom`) and only active when HDR is also enabled.
|
||||||
|
//! Without HDR, the values are already clamped to [0,1] and there is nothing "bright" to bloom.
|
||||||
|
|
||||||
|
use wgpu::{
|
||||||
|
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
|
||||||
|
TextureUsages, TextureView,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// User-facing bloom configuration (Étape 23).
|
||||||
|
///
|
||||||
|
/// Passed to `AppBuilder::with_bloom(config)` to enable the bloom post-process.
|
||||||
|
/// Can be updated at runtime via `App::set_bloom_config`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BloomConfig {
|
||||||
|
/// Luminance threshold (in linear HDR units). Pixels above this contribute to bloom.
|
||||||
|
/// Default: 1.0 (only overbright areas — emissives > 1.0, specular highlights).
|
||||||
|
pub threshold: f32,
|
||||||
|
/// Soft-knee width for the threshold ramp. Larger = smoother transition.
|
||||||
|
/// Default: 0.5.
|
||||||
|
pub knee: f32,
|
||||||
|
/// Bloom intensity (multiplier on the blurred result before adding to HDR).
|
||||||
|
/// Default: 0.8.
|
||||||
|
pub intensity: f32,
|
||||||
|
/// Blur radius in pixels (at half resolution). Larger = wider glow.
|
||||||
|
/// Default: 4.0.
|
||||||
|
pub radius: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BloomConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
threshold: 1.0,
|
||||||
|
knee: 0.5,
|
||||||
|
intensity: 0.8,
|
||||||
|
radius: 4.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal bloom pipeline state. Allocated when bloom + HDR are both active.
|
||||||
|
/// Recreated on resize.
|
||||||
|
pub(crate) struct BloomPipeline {
|
||||||
|
bright_texture: Texture,
|
||||||
|
bright_view: TextureView,
|
||||||
|
blur_texture: Texture,
|
||||||
|
blur_view: TextureView,
|
||||||
|
composite_texture: Texture,
|
||||||
|
composite_view: TextureView,
|
||||||
|
sampler: Sampler,
|
||||||
|
threshold_pipeline: RenderPipeline,
|
||||||
|
blur_pipeline: RenderPipeline,
|
||||||
|
composite_pipeline: RenderPipeline,
|
||||||
|
threshold_bg: BindGroup,
|
||||||
|
blur_bg_h: BindGroup,
|
||||||
|
blur_bg_v: BindGroup,
|
||||||
|
composite_bg: BindGroup,
|
||||||
|
threshold_uniform: Buffer,
|
||||||
|
blur_uniform_h: Buffer,
|
||||||
|
blur_uniform_v: Buffer,
|
||||||
|
composite_uniform: Buffer,
|
||||||
|
threshold_layout: BindGroupLayout,
|
||||||
|
blur_layout: BindGroupLayout,
|
||||||
|
composite_layout: BindGroupLayout,
|
||||||
|
half_w: u32,
|
||||||
|
half_h: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BloomPipeline {
|
||||||
|
pub fn new(device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView) -> Self {
|
||||||
|
let half_w = (width / 2).max(1);
|
||||||
|
let half_h = (height / 2).max(1);
|
||||||
|
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("bloom sampler"),
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let (bright_texture, bright_view) =
|
||||||
|
create_bloom_texture(device, half_w, half_h, "bloom bright");
|
||||||
|
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
|
||||||
|
let (composite_texture, composite_view) =
|
||||||
|
create_bloom_texture(device, width, height, "bloom composite");
|
||||||
|
|
||||||
|
// Bind group layouts.
|
||||||
|
let threshold_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("bloom threshold bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("bloom blur bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("bloom composite bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 4,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipeline layouts.
|
||||||
|
let threshold_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("bloom threshold pl"),
|
||||||
|
bind_group_layouts: &[Some(&threshold_layout)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("bloom blur pl"),
|
||||||
|
bind_group_layouts: &[Some(&blur_layout)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("bloom composite pl"),
|
||||||
|
bind_group_layouts: &[Some(&composite_layout)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shader modules.
|
||||||
|
let threshold_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("bloom threshold"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
crate::utils::conf::BLOOM_THRESHOLD_SHADER.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("bloom blur"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
crate::utils::conf::BLOOM_BLUR_SHADER.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("bloom composite"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
crate::utils::conf::BLOOM_COMPOSITE_SHADER.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shared fragment target state (all 3 passes output to Rgba16Float).
|
||||||
|
let fragment_targets = &[Some(wgpu::ColorTargetState {
|
||||||
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})];
|
||||||
|
|
||||||
|
// Threshold pipeline.
|
||||||
|
let threshold_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("bloom threshold pipeline"),
|
||||||
|
layout: Some(&threshold_pl),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &threshold_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &threshold_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: fragment_targets,
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: Default::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur pipeline.
|
||||||
|
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("bloom blur pipeline"),
|
||||||
|
layout: Some(&blur_pl),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &blur_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &blur_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: fragment_targets,
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: Default::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composite pipeline.
|
||||||
|
let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("bloom composite pipeline"),
|
||||||
|
layout: Some(&composite_pl),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &composite_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &composite_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: fragment_targets,
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: Default::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uniform buffers (32 bytes each — WGSL uniform alignment requires padding;
|
||||||
|
// vec2 has align 8, vec3 has align 16, so structs are larger than their field sum).
|
||||||
|
let threshold_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("bloom threshold uniform"),
|
||||||
|
size: 32,
|
||||||
|
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let blur_uniform_h = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("bloom blur H uniform"),
|
||||||
|
size: 32,
|
||||||
|
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let blur_uniform_v = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("bloom blur V uniform"),
|
||||||
|
size: 32,
|
||||||
|
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let composite_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("bloom composite uniform"),
|
||||||
|
size: 32,
|
||||||
|
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind groups.
|
||||||
|
let threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom threshold bg"),
|
||||||
|
layout: &threshold_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: threshold_uniform.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(hdr_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom blur bg H"),
|
||||||
|
layout: &blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: blur_uniform_h.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&bright_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom blur bg V"),
|
||||||
|
layout: &blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: blur_uniform_v.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&blur_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom composite bg"),
|
||||||
|
layout: &composite_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: composite_uniform.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(hdr_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&bright_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
bright_texture,
|
||||||
|
bright_view,
|
||||||
|
blur_texture,
|
||||||
|
blur_view,
|
||||||
|
composite_texture,
|
||||||
|
composite_view,
|
||||||
|
sampler,
|
||||||
|
threshold_pipeline,
|
||||||
|
blur_pipeline,
|
||||||
|
composite_pipeline,
|
||||||
|
threshold_bg,
|
||||||
|
blur_bg_h,
|
||||||
|
blur_bg_v,
|
||||||
|
composite_bg,
|
||||||
|
threshold_uniform,
|
||||||
|
blur_uniform_h,
|
||||||
|
blur_uniform_v,
|
||||||
|
composite_uniform,
|
||||||
|
threshold_layout,
|
||||||
|
blur_layout,
|
||||||
|
composite_layout,
|
||||||
|
half_w,
|
||||||
|
half_h,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
hdr_view: &TextureView,
|
||||||
|
) {
|
||||||
|
let half_w = (width / 2).max(1);
|
||||||
|
let half_h = (height / 2).max(1);
|
||||||
|
|
||||||
|
let (bright_texture, bright_view) =
|
||||||
|
create_bloom_texture(device, half_w, half_h, "bloom bright");
|
||||||
|
let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur");
|
||||||
|
let (composite_texture, composite_view) =
|
||||||
|
create_bloom_texture(device, width, height, "bloom composite");
|
||||||
|
|
||||||
|
self.threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom threshold bg"),
|
||||||
|
layout: &self.threshold_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.threshold_uniform.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(hdr_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom blur bg H"),
|
||||||
|
layout: &self.blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.blur_uniform_h.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&bright_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom blur bg V"),
|
||||||
|
layout: &self.blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.blur_uniform_v.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&blur_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("bloom composite bg"),
|
||||||
|
layout: &self.composite_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.composite_uniform.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(hdr_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&bright_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.bright_texture = bright_texture;
|
||||||
|
self.bright_view = bright_view;
|
||||||
|
self.blur_texture = blur_texture;
|
||||||
|
self.blur_view = blur_view;
|
||||||
|
self.composite_texture = composite_texture;
|
||||||
|
self.composite_view = composite_view;
|
||||||
|
self.half_w = half_w;
|
||||||
|
self.half_h = half_h;
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn composite_view(&self) -> &TextureView {
|
||||||
|
&self.composite_view
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn composite_texture(&self) -> &Texture {
|
||||||
|
&self.composite_texture
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_passes(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
config: &BloomConfig,
|
||||||
|
) {
|
||||||
|
let threshold_data = [config.threshold, config.knee, 0.0, 0.0];
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.threshold_uniform,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&threshold_data),
|
||||||
|
);
|
||||||
|
|
||||||
|
let blur_h_data = [1.0 / self.half_w as f32, 0.0, config.radius, 0.0];
|
||||||
|
queue.write_buffer(&self.blur_uniform_h, 0, bytemuck::cast_slice(&blur_h_data));
|
||||||
|
|
||||||
|
let blur_v_data = [0.0, 1.0 / self.half_h as f32, config.radius, 0.0];
|
||||||
|
queue.write_buffer(&self.blur_uniform_v, 0, bytemuck::cast_slice(&blur_v_data));
|
||||||
|
|
||||||
|
let composite_data = [config.intensity, 0.0, 0.0, 0.0];
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.composite_uniform,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&composite_data),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass 1: Threshold (HDR full → bright half)
|
||||||
|
{
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("bloom threshold"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &self.bright_view,
|
||||||
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
pass.set_viewport(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
self.half_w as f32,
|
||||||
|
self.half_h as f32,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&self.threshold_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.threshold_bg, &[]);
|
||||||
|
pass.draw(0..3, 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Blur H (bright half → blur half)
|
||||||
|
{
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("bloom blur H"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &self.blur_view,
|
||||||
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
pass.set_viewport(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
self.half_w as f32,
|
||||||
|
self.half_h as f32,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&self.blur_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.blur_bg_h, &[]);
|
||||||
|
pass.draw(0..3, 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: Blur V (blur half → bright half)
|
||||||
|
{
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("bloom blur V"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &self.bright_view,
|
||||||
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
pass.set_viewport(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
self.half_w as f32,
|
||||||
|
self.half_h as f32,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&self.blur_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.blur_bg_v, &[]);
|
||||||
|
pass.draw(0..3, 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 4: Composite (HDR full + bright half → composite full)
|
||||||
|
{
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("bloom composite"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &self.composite_view,
|
||||||
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
pass.set_viewport(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
self.width as f32,
|
||||||
|
self.height as f32,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&self.composite_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.composite_bg, &[]);
|
||||||
|
pass.draw(0..3, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_bloom_texture(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> (Texture, TextureView) {
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
|
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let view = texture.create_view(&Default::default());
|
||||||
|
(texture, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bloom_config_default() {
|
||||||
|
let cfg = BloomConfig::default();
|
||||||
|
assert_eq!(cfg.threshold, 1.0);
|
||||||
|
assert_eq!(cfg.knee, 0.5);
|
||||||
|
assert_eq!(cfg.intensity, 0.8);
|
||||||
|
assert_eq!(cfg.radius, 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bloom_config_clone() {
|
||||||
|
let cfg = BloomConfig {
|
||||||
|
threshold: 2.0,
|
||||||
|
knee: 1.0,
|
||||||
|
intensity: 1.5,
|
||||||
|
radius: 6.0,
|
||||||
|
};
|
||||||
|
let cloned = cfg.clone();
|
||||||
|
assert_eq!(cloned.threshold, 2.0);
|
||||||
|
assert_eq!(cloned.intensity, 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
//! - **error**: returns WsgError variants from all fallible methods.
|
//! - **error**: returns WsgError variants from all fallible methods.
|
||||||
//!
|
//!
|
||||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
//! ## 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.
|
//! 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.
|
//! - **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.
|
//! through App.renderer(), App.context(), etc., for fine-grained control over wgpu handles.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
//! Depth of Field (DoF) configuration (Étape 26).
|
||||||
|
//!
|
||||||
|
//! DoF simulates camera lens behavior: objects at the focus distance are sharp,
|
||||||
|
//! everything else is progressively blurred. This is a post-process effect that
|
||||||
|
//! operates on the HDR texture + depth buffer before tone mapping.
|
||||||
|
//!
|
||||||
|
//! **Opt-in**: when no `DoFConfig` is set, no DoF textures are allocated and the
|
||||||
|
//! pipeline cost is zero.
|
||||||
|
|
||||||
|
/// Depth of Field configuration.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct DoFConfig {
|
||||||
|
/// Focus distance in world units. The image is perfectly sharp at this distance.
|
||||||
|
pub focus_distance: f32,
|
||||||
|
/// Blur intensity: 0.0 = no blur, 1.0 = maximum. Scales the CoC calculation.
|
||||||
|
pub aperture: f32,
|
||||||
|
/// Maximum blur radius in pixels. Clamps the CoC to prevent excessive blur.
|
||||||
|
pub max_blur: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DoFConfig {
|
||||||
|
/// Creates a custom DoF configuration.
|
||||||
|
///
|
||||||
|
/// - `focus_distance`: world distance where the image is sharp
|
||||||
|
/// - `aperture`: blur intensity (0.0–1.0)
|
||||||
|
/// - `max_blur`: maximum blur radius in pixels
|
||||||
|
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
focus_distance,
|
||||||
|
aperture: aperture.clamp(0.0, 1.0),
|
||||||
|
max_blur: max_blur.max(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cinematic preset: gradual blur building up to 12px at the extremes.
|
||||||
|
/// Good for cutscenes and character close-ups.
|
||||||
|
pub fn cinematic(focus_distance: f32) -> Self {
|
||||||
|
Self::new(focus_distance, 0.3, 12.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtle preset: very gentle blur, 8px max radius.
|
||||||
|
/// Good for gameplay with a hint of depth separation.
|
||||||
|
pub fn subtle(focus_distance: f32) -> Self {
|
||||||
|
Self::new(focus_distance, 0.1, 8.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packs the config into the (fog-style) two vec4 uniform layout.
|
||||||
|
/// Returns `(dof_a, dof_b)` where:
|
||||||
|
/// - `dof_a = (focus_distance, aperture, max_blur, near)`
|
||||||
|
/// - `dof_b = (far, inv_width, inv_height, 0.0)`
|
||||||
|
///
|
||||||
|
/// `near` and `far` come from the camera projection. `inv_width`/`inv_height`
|
||||||
|
/// are the reciprocal texture dimensions.
|
||||||
|
pub fn pack(
|
||||||
|
&self,
|
||||||
|
near: f32,
|
||||||
|
far: f32,
|
||||||
|
inv_width: f32,
|
||||||
|
inv_height: f32,
|
||||||
|
) -> (glam::Vec4, glam::Vec4) {
|
||||||
|
(
|
||||||
|
glam::Vec4::new(self.focus_distance, self.aperture, self.max_blur, near),
|
||||||
|
glam::Vec4::new(far, inv_width, inv_height, 0.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use wgpu::{
|
||||||
|
BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture,
|
||||||
|
TextureView,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Internal DoF pipeline state. Allocated when DoF + HDR are both active.
|
||||||
|
/// Recreated on resize.
|
||||||
|
pub(crate) struct DoFPipeline {
|
||||||
|
// Textures
|
||||||
|
coc_texture: Texture,
|
||||||
|
coc_view: TextureView,
|
||||||
|
output_texture: Texture,
|
||||||
|
output_view: TextureView,
|
||||||
|
|
||||||
|
// Samplers: non-filtering for CoC (depth), filtering for blur (color + CoC).
|
||||||
|
coc_sampler: Sampler,
|
||||||
|
blur_sampler: Sampler,
|
||||||
|
|
||||||
|
// Pipelines
|
||||||
|
coc_pipeline: RenderPipeline,
|
||||||
|
blur_pipeline: RenderPipeline,
|
||||||
|
|
||||||
|
// Uniform buffer (shared: same values for both passes, 32 bytes)
|
||||||
|
uniform_buffer: Buffer,
|
||||||
|
|
||||||
|
// Bind groups
|
||||||
|
coc_bind_group: BindGroup,
|
||||||
|
blur_bind_group: BindGroup,
|
||||||
|
|
||||||
|
// Layouts (kept for resize)
|
||||||
|
coc_layout: BindGroupLayout,
|
||||||
|
blur_layout: BindGroupLayout,
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DoFPipeline {
|
||||||
|
pub fn new(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
depth_view: &TextureView,
|
||||||
|
color_view: &TextureView,
|
||||||
|
) -> Self {
|
||||||
|
// Non-filtering sampler for the CoC pass (depth textures require non-filtering).
|
||||||
|
let coc_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("dof coc sampler (non-filtering)"),
|
||||||
|
mag_filter: wgpu::FilterMode::Nearest,
|
||||||
|
min_filter: wgpu::FilterMode::Nearest,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// Filtering sampler for the blur pass (color + CoC textures).
|
||||||
|
let blur_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("dof blur sampler (filtering)"),
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// CoC texture: R16Float, full-res.
|
||||||
|
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("dof coc"),
|
||||||
|
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R16Float,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let coc_view = coc_texture.create_view(&Default::default());
|
||||||
|
|
||||||
|
// Output texture: Rgba16Float, full-res.
|
||||||
|
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("dof output"),
|
||||||
|
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let output_view = output_texture.create_view(&Default::default());
|
||||||
|
|
||||||
|
// --- CoC bind group layout (3 bindings) ---
|
||||||
|
let coc_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("dof coc bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Depth,
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Blur bind group layout (4 bindings) ---
|
||||||
|
let blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("dof blur bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipeline layouts.
|
||||||
|
let coc_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("dof coc pl"),
|
||||||
|
bind_group_layouts: &[Some(&coc_layout)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("dof blur pl"),
|
||||||
|
bind_group_layouts: &[Some(&blur_layout)],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shader modules.
|
||||||
|
let coc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("dof coc"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
crate::utils::conf::DOF_COC_SHADER.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("dof blur"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
crate::utils::conf::DOF_BLUR_SHADER.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// CoC pipeline (output: R16Float).
|
||||||
|
let coc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("dof coc pipeline"),
|
||||||
|
layout: Some(&coc_pl),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &coc_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &coc_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: wgpu::TextureFormat::R16Float,
|
||||||
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: Default::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur pipeline (output: Rgba16Float).
|
||||||
|
let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("dof blur pipeline"),
|
||||||
|
layout: Some(&blur_pl),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &blur_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &blur_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: Default::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uniform buffer (32 bytes: 8 f32s).
|
||||||
|
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("dof uniform"),
|
||||||
|
size: 32,
|
||||||
|
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind groups.
|
||||||
|
let coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("dof coc bg"),
|
||||||
|
layout: &coc_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(depth_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&coc_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("dof blur bg"),
|
||||||
|
layout: &blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(color_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&coc_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&blur_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
coc_texture,
|
||||||
|
coc_view,
|
||||||
|
output_texture,
|
||||||
|
output_view,
|
||||||
|
coc_sampler,
|
||||||
|
blur_sampler,
|
||||||
|
coc_pipeline,
|
||||||
|
blur_pipeline,
|
||||||
|
uniform_buffer,
|
||||||
|
coc_bind_group,
|
||||||
|
blur_bind_group,
|
||||||
|
coc_layout,
|
||||||
|
blur_layout,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the DoF uniform buffer with current config values.
|
||||||
|
pub fn update_uniform(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
config: &DoFConfig,
|
||||||
|
near: f32,
|
||||||
|
far: f32,
|
||||||
|
) {
|
||||||
|
let (a, b) = config.pack(near, far, 1.0 / self.width as f32, 1.0 / self.height as f32);
|
||||||
|
let data: [f32; 8] = [a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w];
|
||||||
|
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recreates textures and bind groups on resize.
|
||||||
|
pub fn resize(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
depth_view: &TextureView,
|
||||||
|
color_view: &TextureView,
|
||||||
|
) {
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
|
||||||
|
let coc_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("dof coc"),
|
||||||
|
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R16Float,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let coc_view = coc_texture.create_view(&Default::default());
|
||||||
|
|
||||||
|
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("dof output"),
|
||||||
|
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let output_view = output_texture.create_view(&Default::default());
|
||||||
|
|
||||||
|
self.coc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("dof coc bg"),
|
||||||
|
layout: &self.coc_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.uniform_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(depth_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.coc_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.blur_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("dof blur bg"),
|
||||||
|
layout: &self.blur_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: self.uniform_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(color_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&coc_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.blur_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.coc_texture = coc_texture;
|
||||||
|
self.coc_view = coc_view;
|
||||||
|
self.output_texture = output_texture;
|
||||||
|
self.output_view = output_view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the DoF output texture (for re-pointing the TM bind group).
|
||||||
|
pub fn output_texture(&self) -> &Texture {
|
||||||
|
&self.output_texture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the DoF output view.
|
||||||
|
pub fn output_view(&self) -> &TextureView {
|
||||||
|
&self.output_view
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coc_view(&self) -> &TextureView {
|
||||||
|
&self.coc_view
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coc_pipeline(&self) -> &RenderPipeline {
|
||||||
|
&self.coc_pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blur_pipeline(&self) -> &RenderPipeline {
|
||||||
|
&self.blur_pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coc_bind_group(&self) -> &BindGroup {
|
||||||
|
&self.coc_bind_group
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blur_bind_group(&self) -> &BindGroup {
|
||||||
|
&self.blur_bind_group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_new_clamps_aperture() {
|
||||||
|
let c = DoFConfig::new(5.0, 2.0, 8.0);
|
||||||
|
assert_eq!(c.aperture, 1.0);
|
||||||
|
assert_eq!(c.focus_distance, 5.0);
|
||||||
|
assert_eq!(c.max_blur, 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_new_clamps_negative_aperture() {
|
||||||
|
let c = DoFConfig::new(5.0, -1.0, 8.0);
|
||||||
|
assert_eq!(c.aperture, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cinematic_preset() {
|
||||||
|
let c = DoFConfig::cinematic(5.0);
|
||||||
|
assert_eq!(c.focus_distance, 5.0);
|
||||||
|
assert!((c.aperture - 0.3).abs() < f32::EPSILON);
|
||||||
|
assert!((c.max_blur - 12.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtle_preset() {
|
||||||
|
let c = DoFConfig::subtle(3.0);
|
||||||
|
assert_eq!(c.focus_distance, 3.0);
|
||||||
|
assert!((c.aperture - 0.1).abs() < f32::EPSILON);
|
||||||
|
assert!((c.max_blur - 8.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pack_layout() {
|
||||||
|
let c = DoFConfig::new(5.0, 0.5, 8.0);
|
||||||
|
let (a, b) = c.pack(0.1, 100.0, 1.0 / 1920.0, 1.0 / 1080.0);
|
||||||
|
assert!((a.x - 5.0).abs() < f32::EPSILON);
|
||||||
|
assert!((a.y - 0.5).abs() < f32::EPSILON);
|
||||||
|
assert!((a.z - 8.0).abs() < f32::EPSILON);
|
||||||
|
assert!((a.w - 0.1).abs() < f32::EPSILON);
|
||||||
|
assert!((b.x - 100.0).abs() < f32::EPSILON);
|
||||||
|
assert!((b.y - 1.0 / 1920.0).abs() < f32::EPSILON);
|
||||||
|
assert!((b.z - 1.0 / 1080.0).abs() < f32::EPSILON);
|
||||||
|
assert_eq!(b.w, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! # Fog Module (Étape 25)
|
||||||
|
//!
|
||||||
|
//! Distance fog: fades objects into a background color based on their distance
|
||||||
|
//! from the camera. Primary use case: masking the edge of the rendered world
|
||||||
|
//! to create the illusion of an infinite scene.
|
||||||
|
//!
|
||||||
|
//! Three modes are supported:
|
||||||
|
//! - **Linear**: hard cutoff between `near` and `far` distances
|
||||||
|
//! - **Exponential**: gradual falloff `exp(-density * d)`
|
||||||
|
//! - **Exponential²**: sharper cutoff `exp(-density² * d²)` — best for masking
|
||||||
|
//!
|
||||||
|
//! Zero cost when disabled: `fog_enabled = 0` → the shader branch is never taken.
|
||||||
|
|
||||||
|
/// Fog attenuation mode.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum FogMode {
|
||||||
|
/// Linear fade between `near` and `far` distances.
|
||||||
|
Linear,
|
||||||
|
/// Exponential falloff: `exp(-density * distance)`.
|
||||||
|
#[default]
|
||||||
|
Exponential,
|
||||||
|
/// Exponential squared: `exp(-density² * distance²)`. Sharper cutoff.
|
||||||
|
Exponential2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FogMode {
|
||||||
|
/// Numeric value written to the GPU uniform (0 = linear, 1 = exp, 2 = exp²).
|
||||||
|
pub fn as_f32(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
FogMode::Linear => 0.0,
|
||||||
|
FogMode::Exponential => 1.0,
|
||||||
|
FogMode::Exponential2 => 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fog configuration for the scene.
|
||||||
|
///
|
||||||
|
/// When not set (no `.with_fog()` call), the renderer writes `fog_enabled = 0`
|
||||||
|
/// and the shader skips the fog block entirely — zero GPU cost.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct FogConfig {
|
||||||
|
/// Attenuation mode (linear / exp / exp²).
|
||||||
|
pub mode: FogMode,
|
||||||
|
/// Fog color (RGB, linear space). Should match the sky/clear color for
|
||||||
|
/// a seamless "infinite world" illusion.
|
||||||
|
pub color: [f32; 3],
|
||||||
|
/// Near distance (Linear mode only). Fog starts at this distance.
|
||||||
|
pub near: f32,
|
||||||
|
/// Far distance (Linear mode only). Fully fogged at this distance.
|
||||||
|
pub far: f32,
|
||||||
|
/// Density (Exponential / Exponential² modes). Higher = thicker fog.
|
||||||
|
/// Typical range: 0.01 (very thin) to 0.3 (very dense).
|
||||||
|
pub density: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FogConfig {
|
||||||
|
/// Linear fog: fades from `near` to `far` distance.
|
||||||
|
pub fn linear(color: [f32; 3], near: f32, far: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: FogMode::Linear,
|
||||||
|
color,
|
||||||
|
near,
|
||||||
|
far,
|
||||||
|
density: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponential fog: `factor = exp(-density * distance)`.
|
||||||
|
/// Natural-looking fog (forest, lake, atmosphere).
|
||||||
|
pub fn exponential(color: [f32; 3], density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: FogMode::Exponential,
|
||||||
|
color,
|
||||||
|
near: 0.0,
|
||||||
|
far: 0.0,
|
||||||
|
density,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponential² fog: `factor = exp(-density² * distance²)`.
|
||||||
|
/// Gradual start, sharp cutoff — ideal for masking world edges.
|
||||||
|
pub fn exponential2(color: [f32; 3], density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: FogMode::Exponential2,
|
||||||
|
color,
|
||||||
|
near: 0.0,
|
||||||
|
far: 0.0,
|
||||||
|
density,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack into two `Vec4`s for the GPU uniform buffer.
|
||||||
|
/// - `a` = (enabled, mode, near, far)
|
||||||
|
/// - `b` = (density, color_r, color_g, color_b)
|
||||||
|
pub fn pack(&self, enabled: bool) -> (glam::Vec4, glam::Vec4) {
|
||||||
|
(
|
||||||
|
glam::Vec4::new(
|
||||||
|
if enabled { 1.0 } else { 0.0 },
|
||||||
|
self.mode.as_f32(),
|
||||||
|
self.near,
|
||||||
|
self.far,
|
||||||
|
),
|
||||||
|
glam::Vec4::new(
|
||||||
|
self.density,
|
||||||
|
self.color[0],
|
||||||
|
self.color[1],
|
||||||
|
self.color[2],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_as_f32() {
|
||||||
|
assert_eq!(FogMode::Linear.as_f32(), 0.0);
|
||||||
|
assert_eq!(FogMode::Exponential.as_f32(), 1.0);
|
||||||
|
assert_eq!(FogMode::Exponential2.as_f32(), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linear_pack() {
|
||||||
|
let cfg = FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0);
|
||||||
|
let (a, b) = cfg.pack(true);
|
||||||
|
assert_eq!(a, glam::Vec4::new(1.0, 0.0, 5.0, 50.0));
|
||||||
|
assert_eq!(b, glam::Vec4::new(0.0, 0.7, 0.8, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exp2_pack_disabled() {
|
||||||
|
let cfg = FogConfig::exponential2([1.0, 1.0, 1.0], 0.1);
|
||||||
|
let (a, b) = cfg.pack(false);
|
||||||
|
assert_eq!(a.x, 0.0); // disabled
|
||||||
|
assert_eq!(a.y, 2.0); // exp² mode
|
||||||
|
assert_eq!(b.x, 0.1); // density
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! Frame::try_new() returns `Option<Self>` for graceful recovery.
|
//! Frame::try_new() returns `Option<Self>` for graceful recovery.
|
||||||
//!
|
//!
|
||||||
//! ## Architecture Notes (per ARCHI_APP.md)
|
//! ## 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.
|
//! - **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`.
|
/// 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::camera::Camera::projection_matrix`]).
|
||||||
|
//!
|
||||||
|
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
|
||||||
|
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
|
||||||
|
//! 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::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::camera::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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,33 @@
|
|||||||
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`.
|
//! - `renderer` receives Device/Queue references from Context, uses Materials from `resources`.
|
||||||
//! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
|
//! - `frame` is consumed by both Context (begin_frame → end_frame) and Renderer (render → present).
|
||||||
|
|
||||||
|
pub mod bloom;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
|
pub mod dof;
|
||||||
|
pub mod fog;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod frustum;
|
||||||
|
pub mod geometry;
|
||||||
|
pub mod hdr;
|
||||||
|
pub mod lod;
|
||||||
|
pub mod msaa;
|
||||||
|
pub mod particles;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
|
pub mod shadow;
|
||||||
|
pub mod transform;
|
||||||
|
|
||||||
// Re-exports
|
// Re-exports
|
||||||
|
pub use bloom::BloomConfig;
|
||||||
pub use context::Context;
|
pub use context::Context;
|
||||||
|
pub use dof::DoFConfig;
|
||||||
|
pub use fog::{FogConfig, FogMode};
|
||||||
pub use frame::Frame;
|
pub use frame::Frame;
|
||||||
|
pub use frustum::Frustum;
|
||||||
|
pub use geometry::{BBox, Geometry, GeometryError};
|
||||||
|
pub use hdr::ToneMapper;
|
||||||
|
pub use lod::{lod_level, projected_radius_px};
|
||||||
|
pub use msaa::MsaaConfig;
|
||||||
|
pub use particles::{BlendingMode, ParticleDriver, ParticlePool, ParticlePoolConfig};
|
||||||
pub use renderer::Renderer;
|
pub use renderer::Renderer;
|
||||||
|
pub use shadow::ShadowConfig;
|
||||||
|
pub use transform::Transform;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! MSAA (Multi-Sample Anti-Aliasing) configuration (Étape 24, 6.4).
|
||||||
|
//!
|
||||||
|
//! When enabled, the main scene pass renders into a multi-sampled texture
|
||||||
|
//! (N samples per pixel) and wgpu resolves it (averages) into the single-sample
|
||||||
|
//! target (HDR texture or swapchain). Post-processes (bloom, TM) operate on
|
||||||
|
//! the resolved single-sample texture — they are unaffected.
|
||||||
|
//!
|
||||||
|
//! MSAA is a rasterizer feature: **no new shader** is needed. The cost is
|
||||||
|
//! in the rasterizer/fill-rate (edges are over-sampled), typically 1.3–1.5×
|
||||||
|
//! for 4× MSAA.
|
||||||
|
|
||||||
|
/// MSAA configuration.
|
||||||
|
///
|
||||||
|
/// `sample_count` must be a power of two (2, 4, or 8) and must be supported
|
||||||
|
/// by the GPU for the target texture format. The default is 4.
|
||||||
|
///
|
||||||
|
/// When disabled (not set in the builder), the renderer uses `sample_count = 1`
|
||||||
|
/// (single sample, no MSAA) and the behavior is identical to pre-MSAA.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct MsaaConfig {
|
||||||
|
/// Number of samples per pixel. Must be 2, 4, or 8.
|
||||||
|
pub sample_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MsaaConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { sample_count: 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MsaaConfig {
|
||||||
|
/// Validates that `sample_count` is a supported value (2, 4, or 8).
|
||||||
|
/// Returns `None` if valid, `Some(reason)` if not.
|
||||||
|
pub fn validate(sample_count: u32) -> Option<&'static str> {
|
||||||
|
match sample_count {
|
||||||
|
2 | 4 | 8 => None,
|
||||||
|
_ => Some("sample_count must be 2, 4, or 8"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_4() {
|
||||||
|
assert_eq!(MsaaConfig::default().sample_count, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_powers_of_two() {
|
||||||
|
assert_eq!(MsaaConfig::validate(2), None);
|
||||||
|
assert_eq!(MsaaConfig::validate(4), None);
|
||||||
|
assert_eq!(MsaaConfig::validate(8), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_invalid() {
|
||||||
|
assert!(MsaaConfig::validate(1).is_some());
|
||||||
|
assert!(MsaaConfig::validate(3).is_some());
|
||||||
|
assert!(MsaaConfig::validate(16).is_some());
|
||||||
|
assert!(MsaaConfig::validate(0).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
//! # Particles — Pool Infrastructure (Étape 28 A)
|
||||||
|
//!
|
||||||
|
//! Implements the **pool** side of the Pool ≠ Driver architecture
|
||||||
|
//! (ARCHI_PARTICULES §1–§3, §6–§8): the pool owns the GPU resources — the
|
||||||
|
//! per-particle state buffer (80 B/slot, D15/D19), the compaction index buffer
|
||||||
|
//! and the indirect draw args (D17), the per-pool camera-params uniform, the
|
||||||
|
//! billboard render pipeline and its bind group — while *drivers* (GPU/CPU/Manual,
|
||||||
|
//! Étapes B/C/D) provide the simulation logic through the [`ParticleDriver`] trait.
|
||||||
|
//!
|
||||||
|
//! A pool without an attached driver costs nothing at render time: its indirect
|
||||||
|
//! args stay zero, so the draw is a no-op (D12). Pools are created through
|
||||||
|
//! [`Scene::create_particle_pool`](crate::scene::Scene::create_particle_pool).
|
||||||
|
|
||||||
|
use crate::pipeline::DEPTH_FORMAT;
|
||||||
|
use crate::resources::Particle;
|
||||||
|
use crate::utils::PARTICLE_BILLBOARD_SHADER;
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
/// Default pool capacity (particle slots).
|
||||||
|
pub const DEFAULT_POOL_CAPACITY: u32 = 1024;
|
||||||
|
|
||||||
|
/// Size in bytes of the per-pool camera-params uniform (view + proj — a prefix
|
||||||
|
/// of `FrameUniforms`, so it can later be fed from the same buffer).
|
||||||
|
pub const CAMERA_PARAMS_SIZE: u64 = 128;
|
||||||
|
|
||||||
|
/// Blend mode frozen at pipeline creation (D9): one mode per pool.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||||
|
pub enum BlendingMode {
|
||||||
|
/// Standard alpha blending (`SrcAlpha`/`OneMinusSrcAlpha` on color,
|
||||||
|
/// `One`/`OneMinusSrcAlpha` on alpha).
|
||||||
|
#[default]
|
||||||
|
Alpha,
|
||||||
|
/// Additive blending (`One`/`One` on both) — flames, sparks, glows.
|
||||||
|
Additive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlendingMode {
|
||||||
|
/// The wgpu blend state corresponding to this mode.
|
||||||
|
fn state(&self) -> wgpu::BlendState {
|
||||||
|
match self {
|
||||||
|
BlendingMode::Alpha => wgpu::BlendState::ALPHA_BLENDING,
|
||||||
|
BlendingMode::Additive => wgpu::BlendState::ADDITIVE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for [`ParticlePool::new`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ParticlePoolConfig {
|
||||||
|
/// Pool capacity (particle slots). Default: [`DEFAULT_POOL_CAPACITY`].
|
||||||
|
pub max_count: u32,
|
||||||
|
/// Id of a texture already registered in the Scene. `None` → the built-in
|
||||||
|
/// 16×16 disc (D11).
|
||||||
|
pub texture: Option<String>,
|
||||||
|
/// Blend mode frozen at pipeline creation (D9).
|
||||||
|
pub blending: BlendingMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ParticlePoolConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_count: DEFAULT_POOL_CAPACITY,
|
||||||
|
texture: None,
|
||||||
|
blending: BlendingMode::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common trait implemented by every driver type (ARCHI §4.1). The pool calls
|
||||||
|
/// these methods in order each frame (wired into the render loop at Étape E).
|
||||||
|
pub trait ParticleDriver: Send {
|
||||||
|
/// Called BEFORE the compute (if the driver is GPU) or before the draw.
|
||||||
|
/// The driver may write into the pool's buffers (spawns, CPU updates).
|
||||||
|
fn pre_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool, dt: f32);
|
||||||
|
/// Called AFTER the compute (GPU driver only). Lets the driver update
|
||||||
|
/// post-simulation uniforms.
|
||||||
|
fn post_compute(&mut self, queue: &wgpu::Queue, pool: &mut ParticlePool);
|
||||||
|
/// Does the driver want a compute dispatch this frame?
|
||||||
|
fn needs_compute(&self) -> bool;
|
||||||
|
/// Does the driver want the pool drawn?
|
||||||
|
fn needs_draw(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The particle pool: all GPU resources needed to simulate and draw particles,
|
||||||
|
/// without the simulation logic (which is the driver's job, D1).
|
||||||
|
///
|
||||||
|
/// `allow(dead_code)`: in Étape 28 A the resources are created but not yet consumed —
|
||||||
|
/// Étapes B/E wire the driver, the compaction dispatch and the indirect draw into the
|
||||||
|
/// render loop. The allow keeps the "zero warnings" acceptance criterion in the meantime.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ParticlePool {
|
||||||
|
/// Per-particle state: N × 80 B (D15/D19). STORAGE | COPY_DST, zeroed (all dead).
|
||||||
|
pub(crate) particle_data: wgpu::Buffer,
|
||||||
|
/// Compaction index: N × u32, one per slot (D17/D19). STORAGE | COPY_DST, zeroed.
|
||||||
|
pub(crate) compact_index: wgpu::Buffer,
|
||||||
|
/// Indirect draw args: 16 B (4 × u32) (D17). Zeroed → the draw is a no-op (D12).
|
||||||
|
pub(crate) indirect_args: wgpu::Buffer,
|
||||||
|
/// Per-pool camera params: 128 B (view + proj). UNIFORM | COPY_DST, owned by
|
||||||
|
/// the pool; the renderer writes it each frame (Étape E).
|
||||||
|
pub(crate) camera_params: wgpu::Buffer,
|
||||||
|
/// Billboard render pipeline (empty vertex layout, D6).
|
||||||
|
pub(crate) pipeline: wgpu::RenderPipeline,
|
||||||
|
/// The pool's bind group layout (5 bindings, ARCHI §6).
|
||||||
|
pub(crate) layout: wgpu::BindGroupLayout,
|
||||||
|
/// The single render bind group, built once at creation (D17/D19: the pool
|
||||||
|
/// owns all its buffers — self-contained).
|
||||||
|
pub(crate) bind_group: wgpu::BindGroup,
|
||||||
|
/// Texture view bound in the group (a scene texture, or the owned default disc, D11).
|
||||||
|
pub(crate) texture_view: wgpu::TextureView,
|
||||||
|
/// Pool capacity (particle slots).
|
||||||
|
pub max_count: u32,
|
||||||
|
/// Blend mode frozen at pipeline creation (D9).
|
||||||
|
pub blending: BlendingMode,
|
||||||
|
/// Attached driver (Étapes B/C/D). `None` → the pool is inactive (D12).
|
||||||
|
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParticlePool {
|
||||||
|
/// Creates the pool's GPU resources: the 4 buffers, the shader module, the
|
||||||
|
/// bind group layout, the render pipeline (eagerly compiled) and the bind group.
|
||||||
|
pub fn new(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
|
sample_count: u32,
|
||||||
|
config: &ParticlePoolConfig,
|
||||||
|
texture_view: wgpu::TextureView,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
) -> Self {
|
||||||
|
let n = config.max_count;
|
||||||
|
|
||||||
|
// --- Buffers (ARCHI §3.2/§6) — zeroed: all particles dead, empty draw args.
|
||||||
|
let particle_data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("particle pool: particle_data"),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
contents: &vec![0u8; n as usize * Particle::SIZE as usize],
|
||||||
|
});
|
||||||
|
let compact_index = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("particle pool: compact_index"),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
contents: &vec![0u8; n as usize * 4],
|
||||||
|
});
|
||||||
|
let indirect_args = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("particle pool: indirect_args"),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
contents: &[0u8; 16],
|
||||||
|
});
|
||||||
|
let camera_params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("particle pool: camera_params"),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
contents: &vec![0u8; CAMERA_PARAMS_SIZE as usize],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Shader + bind group layout (5 bindings, ARCHI §6).
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("particle_billboard"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(PARTICLE_BILLBOARD_SHADER.into()),
|
||||||
|
});
|
||||||
|
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("particle pool: bind group layout"),
|
||||||
|
entries: &[
|
||||||
|
// 0: camera params (uniform, 128 B)
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
// 1: particle state (storage read-only)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// 2: compaction index (storage read-only, D17/D19)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// 3: sampler
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// 4: particle texture
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 4,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Render pipeline: EMPTY vertex layout (D6/D17/D19) — the quad is
|
||||||
|
// generated in the shader (QUAD[vi]), the instance slot comes from
|
||||||
|
// storage binding 2.
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("particle pool: pipeline layout"),
|
||||||
|
bind_group_layouts: &[Some(&layout)],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
let blend = config.blending.state();
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("particle pool: billboard pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
buffers: &[], // empty layout (D17/D19)
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: Some(blend),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: Some(wgpu::DepthStencilState {
|
||||||
|
format: DEPTH_FORMAT,
|
||||||
|
depth_write_enabled: Some(false), // D10
|
||||||
|
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||||
|
stencil: wgpu::StencilState::default(),
|
||||||
|
bias: wgpu::DepthBiasState::default(),
|
||||||
|
}),
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: sample_count,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Bind group (built once: the pool owns all its buffers, D17/D19).
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("particle pool: bind group"),
|
||||||
|
layout: &layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: camera_params.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: particle_data.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: compact_index.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
particle_data,
|
||||||
|
compact_index,
|
||||||
|
indirect_args,
|
||||||
|
camera_params,
|
||||||
|
pipeline,
|
||||||
|
layout,
|
||||||
|
bind_group,
|
||||||
|
texture_view,
|
||||||
|
max_count: n,
|
||||||
|
blending: config.blending,
|
||||||
|
driver: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA bytes of the built-in 16×16 soft disc (D11), used when a pool has no texture.
|
||||||
|
/// A radial gradient (opaque center → transparent edge), white.
|
||||||
|
pub(crate) fn default_disc_rgba() -> Vec<u8> {
|
||||||
|
let size = 16;
|
||||||
|
let mut data = vec![0u8; size * size * 4];
|
||||||
|
let center = (size as f32 - 1.0) / 2.0;
|
||||||
|
for y in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
let dx = (x as f32 - center) / center;
|
||||||
|
let dy = (y as f32 - center) / center;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
let alpha = ((1.0 - dist).clamp(0.0, 1.0) * 255.0) as u8;
|
||||||
|
let i = (y * size + x) * 4;
|
||||||
|
data[i] = 255;
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_config_default() {
|
||||||
|
let c = ParticlePoolConfig::default();
|
||||||
|
assert_eq!(c.max_count, DEFAULT_POOL_CAPACITY);
|
||||||
|
assert!(c.texture.is_none());
|
||||||
|
assert_eq!(c.blending, BlendingMode::Alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_disc_size() {
|
||||||
|
assert_eq!(default_disc_rgba().len(), 16 * 16 * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_disc_center_opaque() {
|
||||||
|
let d = default_disc_rgba();
|
||||||
|
// 16×16 is even: the exact center falls between the four middle pixels, so the
|
||||||
|
// highest alpha is 1 - sqrt(2)/15 ≈ 0.905 → 230, not 255. The soft disc must still
|
||||||
|
// be (nearly) opaque at its core.
|
||||||
|
let i = (7 * 16 + 7) * 4;
|
||||||
|
assert!(d[i + 3] >= 200, "center alpha was {}", d[i + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_disc_corner_transparent() {
|
||||||
|
let d = default_disc_rgba();
|
||||||
|
assert_eq!(d[3], 0); // pixel (0, 0)
|
||||||
|
assert_eq!(d[(15 * 16 + 15) * 4 + 3], 0); // pixel (15, 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,7 @@ use crate::core::Frame;
|
|||||||
pub trait AppHandler {
|
pub trait AppHandler {
|
||||||
/// Called once by `App::run`, right after the window/GPU context are created (winit `resumed`).
|
/// 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
|
/// 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.
|
/// starts. Default implementation does nothing.
|
||||||
/// Default implementation does nothing.
|
|
||||||
/// Inputs: app — mutable reference to the fully-initialized App facade.
|
/// Inputs: app — mutable reference to the fully-initialized App facade.
|
||||||
fn setup(&mut self, _app: &mut App) {}
|
fn setup(&mut self, _app: &mut App) {}
|
||||||
/// Called once per frame before rendering begins. Used for physics updates, input processing,
|
/// Called once per frame before rendering begins. Used for physics updates, input processing,
|
||||||
|
|||||||
@@ -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::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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
//! # WSG Library Crate Root
|
//! # WSG Library Crate Root
|
||||||
//!
|
//!
|
||||||
//! The top-level entry point for the wsg-lib crate. Exposes seven public modules organized by architectural responsibility:
|
//! 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 layers),
|
//! **app** (App facade + AppBuilder), **handler** (AppHandler trait), **core** (Manager + Executor + geometry types),
|
||||||
//! **resources** (data types), **pipeline** (shader compilation cache), **scene** (resource graph and entity management),
|
//! **mesh** (geometry sources: primitives + import), **resources** (data types), **pipeline** (shader compilation cache),
|
||||||
//! and **utils** (configuration and error handling).
|
//! **scene** (resource graph and entity management), **prelude** (glob re-exports), and **utils** (configuration and error handling).
|
||||||
//!
|
//!
|
||||||
//! ## Module Interaction Map
|
//! ## Module Interaction Map
|
||||||
//! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`.
|
//! - `core` consumes resources from `resources`, pipelines from `pipeline`, and errors from `utils`.
|
||||||
@@ -22,17 +22,21 @@
|
|||||||
//! ```ignore
|
//! ```ignore
|
||||||
//! use wsg_lib::core::{Context, Renderer};
|
//! use wsg_lib::core::{Context, Renderer};
|
||||||
//! use wsg_lib::resources::{Mesh, Material, Vertex};
|
//! 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%.
|
// Warn if a public API item has no rustdoc comment, keeping API coverage at 100%.
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod camera;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod math;
|
pub mod input;
|
||||||
|
pub mod lights;
|
||||||
|
pub mod mesh;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod prelude;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
@@ -44,3 +48,26 @@ pub use crate::app::App;
|
|||||||
/// Re-export of the user-defined game logic interface for convenient top-level access.
|
/// 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.
|
/// Users implement this trait to define update/render callbacks injected into the render loop.
|
||||||
pub use crate::handler::AppHandler;
|
pub use crate::handler::AppHandler;
|
||||||
|
|
||||||
|
/// Re-export of the shadow mapping configuration for convenient top-level access.
|
||||||
|
/// Users tune shadow quality via `AppBuilder::with_shadow_config`.
|
||||||
|
pub use crate::core::BloomConfig;
|
||||||
|
pub use crate::core::ShadowConfig;
|
||||||
|
|
||||||
|
/// Re-export of the tone mapping curve selector for convenient top-level access.
|
||||||
|
/// Users enable HDR via `AppBuilder::with_hdr(ToneMapper::Aces)`.
|
||||||
|
pub use crate::core::ToneMapper;
|
||||||
|
|
||||||
|
/// Re-export of the MSAA configuration for convenient top-level access.
|
||||||
|
/// Users enable MSAA via `AppBuilder::with_msaa(4)`.
|
||||||
|
pub use crate::core::MsaaConfig;
|
||||||
|
pub use crate::core::{DoFConfig, FogConfig, FogMode};
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|||||||