GPU culling

This commit is contained in:
Jérôme Bousquié
2026-09-22 15:48:15 +02:00
parent 3dd372410f
commit 3a424afe8c
25 changed files with 2155 additions and 177 deletions
+1
View File
@@ -43,6 +43,7 @@ WGPU doesn't have a native "Context" object — this type groups them together f
- wgpu 30.0.0 is pinned in `lib/Cargo.toml`. The comment says "check the latest version" — verify compatibility before upgrading.
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
- **WGSL `select` argument order** (cost us a day): `select(reject, accept, cond)` returns the **second** arg when `cond` is true — the reverse of HLSL's `select(trueVal, falseVal, cond)`. In `shaders/gpu_driven.wgsl` the cull pass must stay `select(0u, u32(flags.z), visible)` (visible ⇒ full count, culled ⇒ 0). Swapped args silently zero the counts of every visible entity → black window. See the GOTCHA comment at the top of that shader.
<!-- lean-ctx -->
## lean-ctx
+7 -6
View File
@@ -2,7 +2,7 @@
WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [winit](https://crates.io/crates/winit) for simple GPU drawing. It groups the five core wgpu objects (Instance, Surface, Adapter, Device, Queue) behind a single `Context`, adds small building blocks (`Mesh`, `Material`, `PipelineCache`, `Frame`), and exposes the low-level primitives for advanced users.
> **Status: unstable development version.** The **declarative workflow** (`AppBuilder` + `App` + `AppHandler`) is the **recommended** path and is fully working: scene auto-rendering (`App::render_scene`), 3D Phong lighting, textures, shadows, camera and unified input — the `demo` example is the showcase. The **manual workflow** (`Context`/`Renderer`/`PipelineCache`) coexists for fine-grained control. Meshes are declared from a CPU `Geometry` (retained as `Arc<Geometry>` on the Mesh). The GPU-driven two-pass pipeline described in the architecture docs is **not implemented yet** — see [Status](#status) and [Roadmap](#roadmap).
> **Status: unstable development version.** The **declarative workflow** (`AppBuilder` + `App` + `AppHandler`) is the **recommended** path and is fully working: scene auto-rendering (`App::render_scene`), 3D Phong lighting, textures, shadows, camera and unified input — the `demo` example is the showcase. The **manual workflow** (`Context`/`Renderer`/`PipelineCache`) coexists for fine-grained control. Meshes are declared from a CPU `Geometry` (retained as `Arc<Geometry>` on the Mesh). The **GPU-driven two-pass pipeline** (Compute Pass deriving world matrices + frustum culling → indirect draws) is **implemented** (Step 15, Phase 3): `render_scene` and the shadow pass are 100 % indirect, and frustum culling is opt-in (`AppBuilder::with_culling(true)`, off by default) — see [Status](#status), [docs/user/gpu-driven.md](docs/user/gpu-driven.md) and [Roadmap](#roadmap).
## Status
@@ -11,7 +11,7 @@ WSG is a Rust library that wraps [wgpu](https://github.com/gfx-rs/wgpu) and [win
| Manual workflow (`Context` + `Renderer` + `PipelineCache`) | ✅ Working (advanced — fine-grained control) |
| `App` / `AppBuilder` / `AppHandler` event-loop facade | ✅ Working — window, events, frame presentation, and **automatic scene rendering** (the per-frame view is exposed via `Frame::view()`) |
| `Scene` resource/entity registry | ✅ Working — the engine renders every registered entity automatically in one batched render pass (`App::render_scene`) |
| GPU-driven two-pass pipeline (Compute → indirect draw) | 📋 Roadmap — spec in [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
| GPU-driven two-pass pipeline (Compute → indirect draw) | ✅ Working (Step 15, Phase 3) — `render_scene` + shadow pass are 100 % indirect; opt-in frustum culling (bug « fenêtre noire » fixed 2026-09-22 — WGSL `select` argument order — and verified by GPU readback; see DRAFT D14). User doc: [gpu-driven.md](docs/user/gpu-driven.md) · spec: [ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) |
| 3D infrastructure (uniform bind groups, MVP + camera in the pipeline) | ✅ Working — the `Renderer` uploads per-frame camera matrices (active `Camera`) and per-entity world matrices to shared uniform buffers every frame; the **MVP is reached** (Step 5) : the `cube` example renders a rotating Phong-lit cube via the `standard` shader |
Note: `standard_shader.wgsl` (Phong, with an explicit **unlit** mode) is the **single** shader the library ships — flat 2D drawing is its unlit variant (`Renderer::set_unlit(true)` or `app.renderer_mut().set_unlit(true)`). See the `cube` example (3D, lit) and the `simple` example (2D, unlit).
@@ -145,7 +145,7 @@ fn main() {
- **Executor layer (`Renderer`)** — binds a `Material` pipeline + `Mesh` buffers into a RenderPass and submits the commands. Rendering a whole `Scene` (`render_scene`) batches all entities into **one encoder + one submit per frame**; the low-level `render` still allocates one per object.
- **Supporting pieces** — `PipelineCache` (shader → compiled RenderPipeline, `Arc`-shared), `Material`, `Geometry`/`Mesh`/`Vertex`, `Scene` (string-ID registry), `Camera`/`Transform` (active camera wired to the frame uniforms, Step 4.3). `Geometry` is the CPU source of truth (positions/normals/UVs/colors), `Mesh` uploads it to GPU buffers and retains the `Arc<Geometry>`, `Vertex` is the interleaved upload contract (Step 8).
The planned target architecture — a GPU-driven two-pass pipeline (Compute Pass: world matrices + frustum culling → Indirect Draw Buffer, then a single `draw_indexed_indirect` per frame) — is specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) but is **not implemented yet**.
The **GPU-driven two-pass pipeline** (Step 15, Phase 3) is implemented: a Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot. Specified in [docs/tech/ARCHI_APP.md](docs/tech/ARCHI_APP.md) and [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md); user-facing guide in [docs/user/gpu-driven.md](docs/user/gpu-driven.md).
## Quick reference
@@ -201,8 +201,8 @@ Three layers (user docs and API reference in **English**; technical docs in **Fr
- [Shadows](docs/user/shadows.md) · [Camera & input](docs/user/camera-input.md) · [Examples](docs/user/examples.md)
**Technical documentation — `docs/tech/`** (internal architecture; each document states whether it describes the **current** or the **target** architecture):
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ Current facade (`App`/`AppHandler`) / 🎯 **Target** — the GPU-driven two-pass pipeline parts are not implemented yet.
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. 🎯 **Target** — GPU-driven pipeline, ROADMAP Phase 3.
- [ARCHI_APP](docs/tech/ARCHI_APP.md) — engine architecture. ✅ **Current** — facade (`App`/`AppHandler`) and GPU-driven two-pass pipeline (implemented in Phase 3, 2026-07-20, with the documented deviations); only the future double-buffering notes remain target.
- [ARCHI_CPU_GPU](docs/tech/ARCHI_CPU_GPU.md) — CPU/GPU workload split specification. ✅ **Current** — implemented in ROADMAP Phase 3 (2026-07-20, DRAFT Étape 17, decisions D1–D14); deviations from the original spec are noted in the document.
- [ARCHI_RENDU](docs/tech/ARCHI_RENDU.md) — update/render mutability model. ✅ Current dichotomy (auto scene render) / 🎯 **Target** — material batching.
- [ARCHI_ARENES](docs/tech/ARCHI_ARENES.md) — 🎯 **Target/deferred** — slotmap generational handles; String IDs are used today.
- [FRAME_LOOP](docs/tech/FRAME_LOOP.md) — frame lifetime and resource persistence. ✅ **Current** — implemented.
@@ -212,7 +212,7 @@ Three layers (user docs and API reference in **English**; technical docs in **Fr
## Roadmap
1. ✅ **Scene auto-rendering** — `App::render_scene` iterates registered entities and draws them in one encoder/submit per frame; the frame view is exposed to `AppHandler::render` for custom draws. (Done 2026-09-16.)
2. **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, single `draw_indexed_indirect` (see ARCHI_CPU_GPU).
2. ✅ **GPU-driven two-pass pipeline** — Compute Pass (world matrices + frustum culling) filling an indirect draw buffer, then indirect draws (see ARCHI_CPU_GPU). *(Done 2026-07-20 — see item 17. Deviation from the original spec: one indirect draw **per slot** rather than a single fused draw, DRAFT D1.)*
3. **CPU→GPU transform sync** — persistent transform buffers with ring (triple) buffering.
4. ✅ **Real 3D pipeline (MVP reached)** — MVP uniforms + camera support in the vertex shader. *(Engine plumbing done 2026-09-16; Step 5, 2026-09-17: `standard` wired into the `cube` example — a unit cube lit (Phong) and spinning, rendered automatically by `App::render_scene`. Removal of `basic`: flat 2D = unlit variant of `standard` via `Renderer::set_unlit`.)*
5. **Typed resource handles** — keep String IDs for the MVP (current design, source of truth in `Scene`); slotmap-based generational handles (`ARCHI_ARENES.md`) are deferred to a later performance pass.
@@ -227,3 +227,4 @@ Three layers (user docs and API reference in **English**; technical docs in **Fr
14. ✅ **Unified input (Step 15.B)** — `core::input::InputState` gives cross-frame **pressed/held/released** semantics for keyboard (physical `KeyCode`) and mouse (buttons, position, per-frame delta, wheel scroll), rotated by `begin_frame`/`end_frame` around `AppHandler::update`. `App` exposes it as a public `input` field, fed from winit `WindowEvent`s and reset each frame. Gamepad is reserved/deferred (DRAFT D7). (Done 2026-09-20; 5 unit tests; winit event handling is host-driven on the CPU, not WGSL.)
15. ✅ **Orbital camera + final demo (Step 15.C)** — `resources::CameraController` (yaw/pitch/distance/target, `apply_to` writes into a `Camera`, drag-orbit + wheel-zoom + clamps) drives the new `demo` example: one of each primitive, procedural textures, standard Phong material, a shadow-casting directional light + point + spot, and live mouse-orbit / wheel-zoom / `R` reset / `1`/`2`/`3` view presets. Run with `cargo run -p wsg-lib --example demo`. (Done 2026-09-20; runtime-verified headless.)
16. ✅ **User documentation (Step 16, Phase 5)** — `docs/user/` (quickstart, meshes, materials, lights, shadows, camera & input, examples) written in English and cross-linked to each other, to the tech docs and to rustdoc; tech docs interlinked with their stale status banners refreshed; this README re-anchored (declarative workflow = recommended, manual = advanced, `demo` = showcase, pollster 1.x). (Done 2026-07-19.)
17. ✅ **GPU-driven rendering (Step 15, Phase 3.1/3.2/3.3)** — world matrices and indirect draw args move from CPU to GPU. `shaders/gpu_driven.wgsl` (two compute entry points, `compute_matrices` + `cull`, one module, explicit 3-group layout) runs before the render passes over a fixed 256-slot table (the world-matrix buffer is bound to the `uniform` object slot; WebGPU caps a `uniform` binding at 64 KB and a `uniform` offset at 256 B, so each matrix slot is padded to 256 B and 256 × 256 B = 64 KB is the max); `render_scene` and the shadow pass become **100 % indirect** (one indirect draw per active slot, culled/inactive slots are no-ops), and the per-entity CPU draw loop is gone. New `math::Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) + `BBox` on `Geometry`; `TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms` Pod mirrors of the WGSL structs. Frustum **culling is off by default** (non-regression) and opt-in via `AppBuilder::with_culling(true)` / `Renderer::set_culling(bool)`; the `demo` enables it. The object bind-group layout is now dynamic so every entity shares one GPU matrix buffer via per-slot offsets. (Done 2026-07-20; WGSL + frustum + scene-slot tests, 56 lib / 3 WGSL / 3 doctests all green. **Culling fix 2026-09-22**: the WGSL `select` arguments had been written HLSL-style, silently zeroing the draw count of every *visible* entity — a black window; fixed and verified by GPU readback, see DRAFT D14.)
+400 -20
View File
@@ -1,24 +1,404 @@
# Prochaine étape
# DRAFT — Étape 17 : Rendu GPU-driven (ROADMAP 3.1 / 3.2 / 3.3)
> Étape 16 (Phase 5 — Documentation & Polish) **terminée** le 2026-07-19.
> **Statut** : **implémenté et validé** (voir la section « Acceptation » plus bas).
> Conventions : cette étape couvre les 3 items de la Phase 3 de `docs/ROADMAP.md`.
> ROADMAP / README / docs user sont mis à jour ; ce DRAFT est conservé comme référence de
> conception (les décisions D1–D14 y sont documentées).
>
> **Tuning caméra 2026-07-19** — sur rétroaction utilisateur (caméra trop sensible, orbit permanent) :
> (1) `CameraController` a gagné deux champs configurables `orbit_sensitivity`/`zoom_factor` (defaults : 0.01→0.005
> rad/px, 0.9/tic) ; (2) `InputState` normalise `PixelDelta`/32 en unités « notch » (le Wayland renvoyait ~100 px/tic,
> donc `0.9^100` → zoom au clamping en un geste) ; (3) le `demo` orbite désormais **sur clic gauche enfoncé** (arc-rotate
> classique). Docs `camera-input.md`/`examples.md` mises à jour. 51 tests OK.
> **Note de validation runtime (correction D12)** : la capacité est passée de **4096 à 256**
> entités, et le slot de matrice mondes est **padded de 64 à 256 o**. Le buffer de matrices est
> lié au slot `uniform` « object » du pipeline de rendu, et WebGPU impose **deux** contraintes :
> (1) une binding `uniform` unique est plafonnée à `max_uniform_buffer_binding_size` (64 ko), et
> (2) un offset de buffer `uniform` doit être un multiple de `min_uniform_buffer_offset_alignment`
> (256 o). Une matrice de 64 o ne peut donc jamais être adressée individuellement par un offset
> dynamique `uniform` : chaque slot de matrice est **padded à 256 o** (`MatSlot { m, pad }`), et
> 256 slots × 256 o = 64 ko est le maximum adressable. Par ailleurs, le bind group « object » lie
> une **slice de 64 o** (une matrice) plutôt que le buffer entier — une binding sur tout le buffer
> plafonnerait l'offset dynamique à 0. Les mentions « 4096 » / « 1024 » ci-dessous renvoient au
> plan initial ; la valeur réelle (et les tailles de buffer dérivées) est 256 (cf. D2 / D4 / D12).
>
> **Bug fix 2026-07-19** — `InputState::begin_frame()` remettait à zéro `mouse_delta`/`scroll` AVANT que
> `update()` ne les lise, alors que les événements winit (CursorMoved/MouseWheel) s'accumulent ENTRE deux
> frames. Résultat : la caméra orbitale du `demo` ne bougeait jamais (souris/molette toujours (0,0) dans
> `update`). Corrigé par rotation des accumulateurs (`frame_mouse_delta`/`frame_scroll` → queryables à
> `begin_frame`), aligné sur le pattern clavier/boutons. Tests mis à jour (ordre réel winit) + 45/50 tests OK.
>
> Traduction anglaise de toute la documentation (hors `docs/tech/`, DRAFT/PLAN/ROADMAP) **terminée** le 2026-07-19 :
> `docs/user/*`, `README.md`, READMEs de modules, doc/rustdoc de tous les `.rs` (src + examples + tests),
> `Étape`→`Step` global. Vérifications : 50 tests OK, `cargo fmt` clean, aucun lien cassé, 0 accent restant hors zone franche.
> **Note du 2026-09-22 (D14, correction post-implémentation)** : le `demo` (culling activé) affichait
> une **fenêtre noire** — readback GPU : tous les comptes de draw args étaient à 0 alors que les
> transforms, bboxes et plans de frustum vus par le GPU étaient corrects. Cause racine : l'ordre des
> arguments de `select` en WGSL (`select(reject, accept, cond)` renvoie le **second** argument quand
> `cond` est vrai — l'inverse de la convention HLSL). Corrigé et vérifié par readback (cf. D14).
## Prochaines options
- **Phase 3 — GPU-driven rendering** (ROADMAP 3.1/3.2/3.3) : indirect draw, buffers de paramètres GPU, culling GPU. C'est le gros morceau performance qui reste.
- **Phase 4.4 — Performance** : LOD, instancing/multi-instancing, occlusion culling, batching par matériau (4.3).
- Multi-caméras (`scene.set_active_camera`) — restant de la Phase 2.1.
## Objectifs
1. **3.1 — Matrices mondes sur le GPU** : un compute shader dérive la matrice monde de
chaque entité à partir de ses données de transform (T·R·S), au lieu d'un `to_matrix()`
CPU par entité chaque frame.
2. **3.2 — Culling GPU** : un compute shader teste la visibilité de chaque entité
(approximation sphère vs les 6 plans du frustum) et écrit un slot d'arguments de draw.
3. **3.3 — Draw indirect** : le rendu de la scène passe en `draw_indexed_indirect` /
`draw_indirect` par slot d'entité, piloté par les arguments produits par le culling.
## Contraintes & principes
- **API publique stable** : les exemples existants (cube, simple, shadow_test, spot_test,
manual) restent fonctionnels **sans modification de leurs appels**. En pratique aucun n'est
touché (cf. D11) ; seul `demo.rs` change (activation du culling, cf. D8).
- **Aucune régression visuelle** : le culling est **désactivé par défaut** (D8). Le chemin
GPU-driven (matrices + draw indirect) est actif pour `render_scene` mais produit un rendu
**identique** au chemin CPU actuel.
- **`standard_shader.wgsl` non modifié** (D2) : le binding group 1 reste
`var<uniform> object: ObjectUniform`. Seuls les commentaires en français sont traduits en
anglais (convention : toute la doc est en anglais).
## Décisions (à valider)
### D1 — Un draw indirect par entité (pas de multi-instancing par groupe de mesh)
Chaque entité possède son **slot GPU** (index stable `0..N`). Un draw indirect par entité lit
les arguments de son slot.
- ✅ Couvre les 3 items de la roadmap : matrices mondes GPU (3.1), culling GPU (3.2),
draw indirect (3.3).
- ✅ API publique inchangée ; les entités hétérogènes (meshs différents) restent trivialement
supportées.
- ❌ Coût CPU : 1 `draw_indirect` par entité (vs 1 draw multi-instanced par groupe de mesh).
Pour la scale (milliers d'instances d'un même mesh), le multi-instancing par groupe est plus
efficace → **reporté en Phase 4.4** (instancing / multi-instancing).
- **Capacité fixe : 4096 entités** (`MAX_GPU_ENTITIES`). `Scene::add_entity` renvoie `Err`
au-delà. Les slots sont **append-only avec tombstones** : la suppression ne décale pas les
indices (stabilité GPU) ; l'entité est marquée inactive (flag dans le slot de transform) et
le culling met ses arguments à 0.
### D2 — Le bind group « object » par entité devient une slice du buffer GPU-computé
- Le binding group layout existant (group 1, `var<uniform> object: ObjectUniform`, 64 o) est
**conservé tel quel** (shader inchangé), mais rendu **dynamique** (`has_dynamic_offset: true`).
- La valeur de 64 o vient d'une **slice de `WorldMatrixBuffer`** (storage+uniform buffer
GPU-computé, **slot 256 o** padded — cf. D12) plutôt que d'un UBO CPU par entité.
- **Un seul** bind group « object » partagé (`matrix_object_bg`) lie une **slice de 64 o**
(une matrice) du buffer ; chaque draw l'utilise avec un **offset dynamique** `slot × 256` qui
sélectionne le slot (pas un bind group par slot — un seul bind group + offset dynamique, ce
qui évite N bind groups). Une slice de 64 o (et non le buffer entier) est **requise** : une
binding sur tout le buffer plafonnerait l'offset dynamique à 0 (cf. D12).
- Le shadow pass bénéficie automatiquement (même bind group, même offset dynamique, cf. D10).
- Conséquence : les UBO d'objet CPU par entité (`object_cache`) ne sont plus utilisés par
`render_scene` ; ils restent pour le chemin bas niveau `render()` (non-régression, offset 0).
### D3 — Layout des slots d'indirect draw : 80 o (pdc(16,20))
- `DrawSlot` = 80 o = 5 × `vec4<u32>`. Les 5 premiers u32 = arguments **indexed**
(`index_count, instance_count, base_vertex, first_instance, instance_offset`) ; les 4
premiers u32 = arguments **non-indexed** (`vertex_count, instance_count, first_vertex,
first_instance`).
- Raison : l'offset du buffer indirect doit être multiple de 16 o (alignement WGSL du
tableau) **et** multiple de 20 o (contrainte WebGPU pour 5 args) → pdc(16,20) = 80 o. Avec
un stride de 80 o, l'offset du slot i est `80*i` (multiple de 8 et de 4 → valide pour
`draw_indirect` **et** `draw_indexed_indirect`).
- Le buffer est **zéro-initialisé à la création** ; le culling écrit `a` (et `b`) à chaque
frame ; `c..e` restent 0 (jamais lus par le draw).
### D4 — Un seul buffer par ressource, upload CPU chaque frame (pas de double-buffering)
- `TransformBuffer` (64 o × 256 = 16 ko, storage, CPU→GPU) : écrit par
`queue.write_buffer` chaque frame (les transforms viennent de `Scene`, modifiés par
l'utilisateur dans `update()`).
- `WorldMatrixBuffer` (256 o × 256 = 64 ko, storage+uniform, GPU-write, **slots padded à 256 o**
— cf. D12) : écrit par le compute « matrices » ; lu par le pipeline de rendu via le slot
`uniform` « object »
(64 ko = la limite `max_uniform_buffer_binding_size`, cf. D12).
- `IndirectArgsBuffer` (80 o × 256 = 20 ko, storage, GPU-write) : zéro-initialisé, écrit
par le compute « culling ».
- `CullUniformsBuffer` (112 o, uniform, CPU→GPU) : uploadé chaque frame (6 plans +
`num_slots` + `culling`).
- Pas de ring-buffer pour v1 (simplicité) ; le `write_buffer` + les compute + le render sont
dans **un seul CommandEncoder** → ordre garanti sur le GPU. Le ring-buffer est une
optimisation possible plus tard (Phase 4.4).
### D5 — Bounding box : AABB dans `Geometry`, approximaté par une sphère pour le culling v1
- `Geometry` calcule son **AABB** (min/max des positions) à la construction ; `Mesh` le
conserve (champ `bbox: BBox`).
- `Scene::create_mesh` / `add_mesh` en dérive un **`BBoxSlot`** (32 o : `min: vec3f` + `max: vec3f`,
coins **locaux** du box) stocké dans `BBoxBuffer` (32 o × 256 = 8 ko, storage, upload
**une fois** par mesh — ré-upload seulement quand l'ensemble des meshes change, via
`gpu_generation`). Le centre et les demi-extents sont **dérivés dans le shader**
(`center = (min+max)/2`, `half_extents = (max-min)/2`) — le buffer ne stocke que les coins.
- **Culling v1 = test sphère vs 6 plans** : le rayon GPU est `length(half_extents) * max(scale)`
(demi-diagonale du box × plus grand facteur d'échelle) et le centre monde est
`translation + rotation * center_local` (pas d'échelle sur le centre — l'échelle est portée par le
rayon). Visible si aucun plan n'a `distance(centre, plan) < -rayon`.
- Pourquoi une sphère et non l'AABB exact : l'AABB exact transformé nécessite 8 sommets +
projections par axe (coût compute plus élevé, complexité WGSL) ; la sphère est
**conservative** (ne culle jamais un objet visible) et suffit pour v1. L'AABB exact
transformé (8 sommets, min/max par axe) est une amélioration possible (Phase 4.4).
- Le **mesh_index** de chaque entité est stocké dans `flags.x` du slot de transform (rempli
par le Renderer chaque frame) ; le culling lit `bboxes[transforms[i].flags.x]`.
### D6 — Plans du frustum : Gribb-Hartmann côté CPU, upload dans `CullUniforms`
- Extrait les 6 plans (left, right, bottom, top, near, far) de `view * proj`
(Gribb-Hartmann, adapté au clip depth [0,1] de WebGPU).
- Chaque plan = `vec4<f32>` (normale + d), normalisé.
- Uploadé dans `CullUniformsBuffer` (group **2** du compute, binding 0, `var<uniform>`) chaque frame
(layout final explicite à 3 groupes — cf. écart noté dans les critères d'acceptation :
group 0 = transforms, group 1 = matrices, group 2 = culling).
- Implémenté dans `math/frustum.rs` (nouveau module) avec des tests unitaires (plans de
l'identité, plans d'une perspective standard, orientation des normales).
### D7 — Un shader compute `gpu_driven.wgsl` avec deux entry points
- **`compute_matrices`** (`@workgroup_size(64)`) : `m[i] = T·R·S` pour `i in 0..num_slots`
(T=translation, R=quaternion normalisé, S=scale → `mat4x4`). La construction WGSL
reproduit `Transform::to_matrix()` (colonnes de rotation échelonnées par S, translation
dans la 4e colonne).
- **`cull`** (`@workgroup_size(64)`) : pour `i in 0..num_slots` : si inactive →
`draws[i] = 0` ; sinon test sphère vs 6 plans → `draws[i].a = visible ? args : 0`.
- Deux `ComputePipeline` créés depuis le **même module** (même layout de 3 bind groups).
- `num_slots` (nombre de slots **alloués**, pas le nombre d'entités vivantes) dans
`CullUniforms` → le compute parcourt tous les slots alloués (les tombstones sont marqués
inactifs et produisent `draws[i] = 0`).
### D8 — Culling désactivé par défaut ; activation par `AppBuilder::with_culling(true)`
- `Renderer::set_culling(bool)` + `culling_enabled()` (pub, documenté : n'affecte que
`render_scene`).
- `AppBuilder::with_culling(self, bool) -> Self` ; appliqué dans `resumed()` après création
du Renderer.
- Par défaut **OFF** (non-régression) : le pass « culling » tourne quand même (marque tout
visible, `culling=0`) → le chemin indirect est toujours actif, mais rien n'est cullé.
Le `demo` active le culling.
- Justification : le culling est un gain de perf, pas un changement de comportement visuel ;
le désactiver par défaut protège contre un bug de culling (objet qui disparaît) qui
casserait les exemples.
### D9 — Slots d'entité : `Vec<Option<Entity>>` append-only + index label→slot
- `Scene::entities` passe de `HashMap<String, Entity>` à :
- `entity_slots: Vec<Option<Entity>>` (append-only, tombstones)
- `entity_labels: Vec<Option<String>>` (parallèle)
- `entity_index: HashMap<String, usize>` (label → slot)
- `entity_generation: u64` (incrémenté à chaque add/remove → le Renderer ré-uploade le
mapping bbox si changé)
- **`Entity` est inchangé** (pas de champ label ; le label reste dans `entity_labels`).
- L'ordre d'itération est **stable** (ordre d'insertion) — important pour la cohérence des
slots (le `HashMap` actuel a un ordre d'itération non-déterministe).
- `add_entity` / `add_entity_with_transform` : trouvent un slot libre (premier `None` ou
append si `len < MAX_GPU_ENTITIES`) ; `Err` si capacité atteinte. `remove_entity` :
tombstone + dé-map de l'index. `set_entity_transform` : mise à jour in-place (pas de bump
de génération — le buffer de transforms est re-uploadé chaque frame de toute façon).
- `iter_entities()` : signature inchangée `(label, &Arc<Mesh>, &Transform)`, saute les
tombstones.
- `entity_count()` : nombre de slots vivants (comportement inchangé pour les tests).
### D10 — Le shadow pass est GPU-driven aussi (gratuitement)
- Le pass ombre (casters) utilise les **mêmes** bind groups d'objet (slices de
`WorldMatrixBuffer`) et les **mêmes** slots d'indirect → les casters sont cullés par le
même pass compute.
- Le shadow pass de `Renderer::render_scene` passe en draw indirect (les casters =
sous-ensemble des slots ; le Renderer itère les slots et ne draw que ceux dont le mesh est
caster, avec les args du slot).
### D11 — Stratégie non-régression : `render_scene` devient GPU-driven, `render` reste CPU
- `Renderer::render_scene` passe de `&self` à `&mut self` (nécessaire pour
`queue.write_buffer` des buffers transform/cull + mise à jour du cache de bind groups).
- `App::render_scene` (qui prend déjà `&mut self`) utilise `renderer_mut()` → **aucun
exemple n'appelle `Renderer::render_scene` directement** (le default `AppHandler::render`
passe par `App::render_scene`) → **aucune rupture d'API pour les exemples**.
- Le chemin bas niveau `Renderer::render()` (utilisé par `manual.rs`) **garde** les UBO
d'objet CPU → `manual.rs` n'est pas touché.
- Les 45 tests unitaires + 2 tests WGSL + 3 doctests restent verts (les tests Scene ne
touchent pas le GPU ; les tests de frustum sont nouveaux).
### D12 — Capacité fixe de 256 entités, slot matrice padded à 256 o *(corrigé à la validation : initialement 4096, puis 1024)*
- `MAX_ENTITIES = 256` (constante pub dans `conf.rs`).
- **Pourquoi 256 (et pourquoi le slot matrice est padded à 256 o)** : le buffer de matrices
mondes est lié au slot `uniform` « object » (group 1) du pipeline de rendu, et WebGPU impose
**deux** contraintes :
1. une binding `uniform` unique est plafonnée à `max_uniform_buffer_binding_size` (64 ko) ;
2. un offset de buffer `uniform` (dynamique **ou** statique) doit être un multiple de
`min_uniform_buffer_offset_alignment` (256 o).
Une matrice de 64 o ne peut donc jamais être adressée individuellement par un offset
`uniform` (son offset serait `slot × 64`, pas multiple de 256). La solution : **padded chaque
slot de matrice à 256 o** (`MatSlot { m: mat4x4f, pad: array<vec4f,12> }` = 64 + 192 o). Avec
des slots de 256 o, l'offset du slot i est `i × 256` (toujours aligné), et 256 slots × 256 o =
64 ko est le maximum adressable par une binding `uniform` unique. (Les buffers transforms /
bboxes / indirect sont des bindings `storage` — limite 128 Mo, pas de règle d'offset 256 o —
donc ils gardent leurs tailles naturelles 64 / 32 / 80 o.)
- **Bind group « object » sur une slice de 64 o** : le bind group `matrix_object_bg` lie une
**slice de 64 o** (une matrice) du buffer, **pas** le buffer entier — une binding sur tout le
buffer (65536 o) plafonnerait l'offset dynamique à 0 (le binding couvrirait déjà tout le
buffer). Avec une slice de 64 o, l'offset dynamique peut glisser jusqu'à `65536 − 64`.
- Justification mémoire : 16 ko (transforms) + 64 ko (matrices) + 8 ko (bboxes) + 20 ko
(indirect) ≈ 108 ko total — négligeable. Le compute dispatche 256 threads (4 workgroups de
64) — trivial.
- 256 est largement suffisant pour le scope du projet (le demo a 7 entités). Pour aller au-delà,
il faudrait chunker le buffer de matrices en tranches ≤ 64 ko (≤ 256 slots chacune) avec une
binding `uniform` par tranche (reporté — hors scope v1).
### D13 — Buffers de culling séparés, pas d'impact sur le layout de rendu
- `CullUniformsBuffer` (112 o) est dédié au compute (group **2** du compute, binding 0) ; il n'apparaît
**pas** dans le layout des pipelines de rendu → le layout de rendu (groups 0..3) est
inchangé.
- Le `BBoxBuffer` (8 ko) est group 2 du compute (binding 1, avec `draws` au binding 2) → pas dans
le layout de rendu. Le group 0 (transforms, storage read) est **partagé** par les deux entry points.
### D14 — Incident « fenêtre noire » : l'ordre des arguments de `select` en WGSL (2026-09-22)
- **Symptôme** : le `demo` (culling activé, D8) affichait une **fenêtre noire**. Readback GPU
(`debug_dump`) : les 7 slots de draw args avaient un compte de sommets à **0** (`.a.x = 0`) alors
que `culling = 1`, `num_slots = 7`, et que les entrées vues par le GPU (transforms, bboxes, plans
du frustum) étaient corrects — le test sphère simulé **côté CPU** passait pour les 7 entités
(distances de plans toutes ≥ +2.06), ce qui rendait l'échec inexplicable côté données.
- **Cause racine** : la convention des arguments de `select` en WGSL est
**`select(reject, accept, cond)`** — renvoie le **second** argument quand `cond` est vrai, le
premier quand il est faux : **l'inverse de la convention HLSL** (`select(trueVal, falseVal, cond)`)
sur laquelle le pass `cull` avait été écrit. `select(u32(t.flags.z), 0u, visible)` mettait donc le
compte à 0 pour **toute entité visible** (et aurait dessiné les entités cullées) : inversion
silencieuse de la visibilité, sans aucune erreur de validation wgpu ni de log driver.
- **Preuves** : (1) instrumentation par slot du pass (distances de plans toutes positives, flag
`visible` à 0 — contradiction pure) ; (2) sonde constante `select(2.0, 3.0, true)` → **3.0** sur le
GPU (NVIDIA, Vulkan) ; (3) source de **naga 30** (le compilateur WGSL de wgpu) : le frontend mappe
`arg0 → reject`, `arg1 → accept`, et les backends SPIR-V/GLSL émettent `cond ? accept : reject`
→ conforme à la spec WGSL, **le driver n'est pas en cause** : c'est une erreur d'API WGSL.
- **Correction** : `select(0u, u32(t.flags.z), visible)` (visible ⇒ compte plein, cullé ⇒ 0) +
commentaire GOTCHA en tête de `gpu_driven.wgsl` + piège documenté dans `AGENTS.md`.
- **Vérification** : readback après correction — les 7 entités du `demo` reprennent leurs comptes
pleins (6/36/3840/960/384/192/2304, égaux aux `flags.z` packés côté CPU) ; une entité ajoutée hors
frustum (z = 60, au-delà du plan lointain) est **cullée à 0** ; les slots ≥ `num_slots` restent à
0. `cargo build --workspace` sans avertissement, `cargo test --workspace` vert (63 tests).
- **Leçon** : ne pas suspecter le driver avant d'avoir vérifié les conventions d'arguments des
builtins WGSL ; pour ce shader, la vérité de référence est le **readback des slots**
(`debug_dump`, opt-in `WSG_DEBUG_DUMP=1` dans le `demo`), pas l'image à l'écran.
## Pipeline par frame (un seul CommandEncoder)
```
[CPU] write_buffer TransformBuffer (64 o × num_slots, depuis Scene)
[CPU] write_buffer CullUniforms (6 plans + num_slots + culling)
[CPU] (si gpu_generation changée) write_buffer BBoxBuffer
[Compute 1] compute_matrices : m[i] = T·R·S (i in 0..num_slots)
[Compute 2] cull : draws[i] = visible ? args : 0 (sphère vs 6 plans)
[Render pass ombre] (si ombres activées) : draw_indexed_indirect par caster (slice mat + args slot)
[Render pass main] : draw_indexed_indirect / draw_indirect par slot vivant
[Queue] submit(encoder)
```
Même encoder → exécution séquentielle garantie (write → compute → render).
## Layouts GPU (WGSL)
```wgsl
struct TransformSlot { translation: vec3f, flags: vec4f, rotation: vec4f, scale: vec3f } // 64 o
// flags.x = mesh_index (index dans BBoxBuffer), flags.y = active (1/0),
// flags.z = draw count (compte de sommets/indices du mesh), flags.w = has_index (1/0)
struct MatSlot { m: mat4x4<f32>, pad: array<vec4<f32>, 12> } // 256 o (padded : offset `uniform` 256-aligné)
struct BBoxSlot { min: vec3f, max: vec3f } // 32 o (coins locaux ; centre/demi-extents dérivés dans le shader)
struct DrawSlot { a: vec4<u32>, b: vec4<u32>, c: vec4<u32>, d: vec4<u32>, e: vec4<u32> } // 80 o
struct CullUniforms {
planes: array<vec4<f32>, 6>, num_slots: u32, culling: u32, _pad: vec2<u32> // 112 o
}
```
Alignements vérifiés : stride de tableau = max(alignment) arrondi à 16 → 64 / 64 / 32 / 80
(tous multiples de 16). `CullUniforms` : 6×16 + 4 + 4 + 8 = 112 (multiple de 16).
Côté Rust (`uniform.rs`, `#[repr(C)]` + bytemuck `Pod`/`Zeroable`) : même taille, avec
`#[repr(align(16))]` sur les structs contenant un tableau de `vec4` pour matcher l'alignement
GPU. Static asserts `size_of == 64/64/32/80/112`.
## Fichiers touchés
| Fichier | Action |
|---------|--------|
| `lib/assets/shaders/gpu_driven.wgsl` | **Nouveau** — compute `compute_matrices` + `cull` |
| `lib/src/math/frustum.rs` | **Nouveau** — extraction des 6 plans (Gribb-Hartmann [0,1]) + tests |
| `lib/src/math/geometry.rs` | + struct `BBox` + `Geometry::bbox()` + tests |
| `lib/src/math/mod.rs` | + `pub mod frustum;` |
| `lib/src/resources/uniform.rs` | + `TransformSlot`, `BBoxSlot`, `CullUniforms` (Pod) + constantes `MAX_GPU_ENTITIES`, `MAX_MESH_BBOXES` + tailles de slots |
| `lib/src/resources/mesh.rs` | + champ `bbox: BBox` (calculé dans `from_geometry`) + accès `mesh.bbox()` |
| `lib/src/scene/scene.rs` | `entities` → slots append-only + index + génération ; `create_mesh`/`add_mesh` capturent le bbox ; + `iter_entity_slots()`, `gpu_generation()`, `gpu_bbox_slots()` |
| `lib/src/core/renderer.rs` | + 4 buffers GPU + buffer cull-uniforms + 2 pipelines compute + layout compute ; `render_scene` → `&mut self`, passe indirect ; + `set_culling`/`culling_enabled` ; shadow pass indirect |
| `lib/src/pipeline/pipeline_cache.rs` | + `create_compute_pipelines()` (module gpu_driven, 2 entry points) + `create_compute_bind_group_layout()` |
| `lib/src/app.rs` | + `AppBuilder::with_culling(bool)` + champ `culling` ; `resumed()` applique `renderer.set_culling` |
| `lib/src/utils/conf.rs` | + fallback embarqué `include_str!` de `gpu_driven.wgsl` (comme standard/shadow) |
| `lib/examples/demo.rs` | + `AppBuilder::with_culling(true)` (démo du culling) |
| `lib/tests/wgsl_validate.rs` | + test `gpu_driven_shader_is_valid_wgsl` |
| `lib/src/assets/shaders/standard_shader.wgsl`, `shadow_shader.wgsl` | Commentaires FR → EN (convention doc anglaise) |
| `docs/user/gpu-driven.md` | **Nouveau** — doc user (activation du culling, limites, comportement) |
| `docs/user/README.md` | + lien vers `gpu-driven.md` |
| `README.md` | + ligne « rendu GPU-driven (indirect draw, culling GPU) » dans les features |
| `docs/ROADMAP.md` | Coche 3.1, 3.2, 3.3 |
| `docs/PLAN.md` | + ligne Étape 17 |
## Périmètre exclus (reportés)
- **Multi-instancing par groupe de mesh** (Phase 4.4) — 1 draw par groupe de mesh identique.
- **AABB exact transformé** (8 sommets) — v1 utilise une sphère conservative.
- **Ring-buffering** des buffers GPU (D4) — v1 fait un `write_buffer` par frame.
- **Occlusion culling**, **LOD**, **batching par matériau** (Phase 4.3 / 4.4).
- **Multi-caméras** (2.1 restant).
## Risques & mitigations
| Risque | Mitigation |
|--------|-----------|
| **Alignement indirect draw** (offset multiple de 16/20/8/4 o) | D3 : slot 80 o = pdc(16,20), multiple de 8 et 4 → valide pour les 2 variants de draw. |
| **Buffer undefined à la création** | `IndirectArgsBuffer` zéro-initialisé (`create_buffer_init` zéros) → `c..e` restent 0. |
| **Ordre compute → render** | Même `CommandEncoder` → séquentiel. `write_buffer` + compute + render dans le même encoder. |
| **Bind groups par slot : mémoire** | 4096 bind groups ≈ trivial (état driver partagé via le layout). |
| **Bug de culling (objet qui disparaît)** | D8 : OFF par défaut ; le `demo` l'active → bug visible immédiatement. Sphère conservative (D5). **Réalisé en 2026-09-22 (D14)** : le bug s'est produit (fenêtre noire) et a été traçable grâce à cette mitigation + readback. |
| **Builtins WGSL aux conventions d'arguments non intuitives** (ex. `select`, l'inverse de HLSL) | D14 : piège documenté en tête du shader + dans `AGENTS.md` ; diagnostic par **readback des slots** (`debug_dump`) plutôt que par l'image seule. |
| **Ordre non-déterministe des entités** | D9 : `Vec` append-only (stable) remplace le `HashMap` (ordre aléatoire). |
| **`render_scene` → `&mut self`** | Aucun exemple n'appelle `Renderer::render_scene` directement (cf. D11) → non-régression. |
| **WGSL compute non validé** | Test `wgsl_validate` sur le nouveau shader + `cargo build` (wgpu compile à l'exécution). |
| **Shadow pass indirect** | Les casters utilisent les mêmes slots d'args → cohérent ; testé dans le `demo` (ombres + culling actifs). |
| **Alignement WGSL ≠ Rust** | `#[repr(align(16))]` + static asserts de taille sur chaque struct Pod (cf. section « Layouts GPU »). |
## Plan de vérification
1. `cargo build --workspace` — OK.
2. `cargo test --workspace` — 45 tests unitaires + **nouveaux tests frustum/geometry** +
2→3 tests WGSL + 3 doctests, tous verts.
3. `cargo fmt --all -- --check` — clean.
4. **`demo` en headless** : `WGPU_BACKEND=vulkan timeout 10 cargo run -p examples --example
demo` → exit 0, **culling actif** (`with_culling(true)`), pas d'objet qui disparaît.
5. **Tous les autres exemples** (cube, simple, shadow_test, spot_test) en headless → exit 0,
**rendu identique** (culling OFF par défaut, chemin indirect actif).
6. **`manual.rs`** en headless → exit 0 (chemin `render()` bas niveau inchangé, UBO CPU).
7. **Check liens** : 0 lien cassé (nouveaux `docs/user/gpu-driven.md` + liens README).
8. **Aucun caractère accentué** dans les fichiers modifiés (sauf `docs/tech/`, DRAFT, PLAN,
ROADMAP, DOCUMENTATION) — les commentaires WGSL traduits.
9. **WGSL valide** : le nouveau `gpu_driven.wgsl` compile (test `wgsl_validate`).
## Critères d'acceptation (definition of done)
> **Implémenté le 2026-07-20.** Tous les critères sont remplis (avec les écarts de nommage
> notés ci-dessous). `cargo build`/`cargo test --workspace` : 56 lib + 3 WGSL + 3 doctests verts,
> clippy sans avertissement dans `renderer.rs`.
- [x] `compute_matrices` + `cull` dans `gpu_driven.wgsl` (2 entry points, 1 module).
*Écart : layout compute explicite à 3 groupes partagé par les 2 pipelines (évite les gaps de
groupes du layout inféré) — cf. D11.*
- [x] 4 buffers GPU (transform, matrices, bboxes, draw-args) + 1 buffer cull-uniforms dans le `Renderer`.
- [x] `render_scene` 100 % indirect (main + shadow).
*Écart : `render_scene` reste `&self` (buffers persistants créés dans `new()` + `Cell<bool>`
pour le culling) — plus simple que le `&mut self` prévu, et sans ré-allocation par frame.*
- [x] `Scene` slots append-only + tombstones (indices stables).
*Écart de nommage : `packed_transform_slots()` / `iter_slot_draws()` / `mesh_bboxes()` /
`mesh_index_of()` / `mesh_by_index()` / `num_slots()` / `num_active_slots()` (plutôt que
`iter_entity_slots` / `gpu_generation` / `gpu_bbox_slots`).*
- [x] `Geometry::bbox()` + `BBoxSlot` ; upload du buffer de bboxes à chaque frame (peu coûteux,
toujours correct si des meshes sont ajoutés).
- [x] `math/frustum.rs` (Gribb-Hartmann [0,1]) + 5 tests.
- [x] `AppBuilder::with_culling(bool)` + `Renderer::set_culling` (OFF par défaut).
- [x] `demo` active le culling (`with_culling(true)`) ; `cargo build --workspace` vert.
- [x] Commentaires WGSL EN ; aucun accent dans les fichiers de code touchés.
- [x] Docs user (`docs/user/gpu-driven.md`), README, ROADMAP (3.1/3.2/3.3 cochés).
- [ ] DRAFT.md vidé après validation utilisateur (conservé en référence pour l'instant).
---
**Validation** : les décisions D1–D14 sont implémentées (D1 = draw indirect par entité,
256 slots, culling OFF par défaut). Le bug « fenêtre noire » (D14) est **corrigé et vérifié par
readback GPU** le 2026-09-22 (comptes pleins pour les entités visibles, compte 0 pour l'entité hors
frustum, slots ≥ `num_slots` à 0 ; 63 tests verts). Il reste à confirmer le rendu **visuel** du
`demo` avec culling ON, puis autoriser le vidage de ce DRAFT.
+1 -1
View File
@@ -63,7 +63,7 @@ Une fois la plomberie encapsulée, nous devons rendre l'assemblage des objets co
### Gestion des Matériaux et Shaders
- [X] S'assurer que chaque Mesh possède une référence vers un Material (à l'heure actuelle le lien est porté par l'entité `(mesh_id, material_id)` de la Scene, pas par le Mesh lui-même). *(fait — 2026-09-17, DRAFT Étape 7 : `Mesh.material: Option<Arc<Material>>` ; `Entity { mesh_id, transform }`, plus de `material_id`)*
- [X] Implémenter le comportement par défaut : si aucun matériau n'est assigné, le moteur injecte automatiquement le `standard_shader` (variante unlit) (non implémenté). *(fait — 2026-09-17, DRAFT Étape 7.3.5 : `Scene::default_material()` injecte `standard` ; le flat reste piloté par `Renderer::set_unlit`)*
- [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")
+12 -10
View File
@@ -117,24 +117,25 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
---
## Phase 3️⃣ — GPU-Driven Rendering
## Phase 3️⃣ — GPU-Driven Rendering ✅ (2026-07-20)
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md).
**Statut** : implémenté (DRAFT Étape 17, décisions D1–D14). Culling **désactivé par défaut** (non-régression), opt-in `AppBuilder::with_culling(true)`. **Correction 2026-09-22** : bug « fenêtre noire » avec culling ON (arguments de `select` WGSL écrits à la convention HLSL — toutes les entités visibles étaient remises à 0) ; corrigé et vérifié par readback GPU (DRAFT D14).
### 3.1 Compute Shader
- [ ] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes
- [ ] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales
- [ ] Compute shader : calcul des World Matrices pour tous les meshes
- [x] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes (`TransformSlot`, 64 B)
- [x] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales (`MatSlot`, `STORAGE|UNIFORM`)
- [x] Compute shader : calcul des World Matrices pour tous les meshes (`compute_matrices`)
### 3.2 Frustum Culling GPU
- [ ] Ajouter `BBox` dans `Geometry` (center + extents)
- [ ] Buffer `BoundingBoxBuffer` (CPU → GPU, statique)
- [ ] Compute shader : culling basé sur la frustum de caméra
- [ ] Buffer `IndirectDrawBuffer` rempli par le GPU
- [x] Ajouter `BBox` dans `Geometry` (coins min/max locaux) + `math::Frustum` (Gribb–Hartmann `[0,1]`)
- [x] Buffer `BoundingBoxBuffer` (CPU → GPU, ré-upload quand l'ensemble des meshes change, peu coûteux)
- [x] Compute shader : culling sphère vs frustum (`cull`), **désactivé par défaut** *(bug « fenêtre noire » corrigé le 2026-09-22 — ordre des arguments de `select` WGSL inversé ; cf. DRAFT D14)*
- [x] Buffer `IndirectDrawBuffer` rempli par le GPU (`DrawSlot`, 80 B, zéro = no-op)
### 3.3 Rendu Indirect
- [ ] `draw_indexed_indirect()` au lieu de draw calls individuels
- [ ] Un seul command draw pour tous les objets visibles
- [x] `draw_indexed_indirect()`/`draw_indirect()` au lieu de draw calls individuels
- [x] Un draw indirect **par slot actif** (décision D1 — pas un draw fusionné unique) ; le shadow pass est aussi indirect
---
@@ -198,3 +199,4 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
| **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 |
| **Resize géré (avec recréation de la depth texture), acté en D3 (2026-09-18), réalisé en Étape 11 (2026-09-18)** | L'app reconfigure désormais la surface et recrée la depth texture **en même temps** à chaque `Resized` (`App::resize` → `Context::configure` + `Renderer::resize_depth`), via le helper `create_depth_texture` isolé. Vérifié au runtime (exemple `cube`) : pas de crash, pas d'artefact, aspect correct |
| **WGSL `select(reject, accept, cond)`** | L'ordre des arguments est l'inverse de la convention HLSL : le **second** argument est retenu quand la condition est vraie. L'avoir écrit à la convention HLSL a produit le bug « fenêtre noire » du culling (comptes remis à 0 pour les entités visibles), corrigé le 2026-09-22 (DRAFT D14). Piège documenté en tête de `gpu_driven.wgsl` + `AGENTS.md` |
+18 -14
View File
@@ -15,17 +15,20 @@ stale_after: 2027-01-31
wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architecture à deux niveaux : une façade de haut niveau pour la productivité et un accès bas niveau pour un contrôle total.
> **État du document : ACTUEL pour la façade (`App`/`AppHandler`, §3, §4A) ; CIBLE pour la partie
> GPU-driven (§1, §4B, §5, §6).** La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le
> **workflow recommandé** : `setup` (déclaration de la scène) → par frame `update` (mutation) →
> **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (2026-07-20, DRAFT Étape 17, décisions
> D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
> `setup` (déclaration de la scène) → par frame `update` (mutation) →
> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
> un `CommandEncoder`/soumission par frame ; passe d'ombre en tête si un caster est actif).
> Exemples : `simple` (2D unlit), `cube` (3D éclairé), `demo` (vitrine : primitives, lumières,
> ombres, caméra orbitale). Le workflow **manuel** (exemple `manual`) coexiste pour le contrôle fin.
> Les sections §1, §4B, §5 et §6 décrivent la **cible** : pipeline GPU-driven à deux passes
> (Compute Pass → `draw_indexed_indirect`), buffers persistants en VRAM (Transform/Matrix/BBox/Indirect)
> et synchronisation single/double buffer. **Rien de tout cela n'existe encore dans le code** — c'est
> la trajectoire ROADMAP Phase 3.
> 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 (DRAFT Étape 17) : 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
@@ -107,8 +110,8 @@ Le moteur gère la renderloop interne via un pipeline à **deux passes séquenti
1. **Update** (`AppHandler::update`) — L'utilisateur modifie la scène (transformations, entités). Ces changements sont synchronisés vers le GPU via un **single buffer** Transform avant la passe de calcul.
> La synchronisation est assurée par le pipeline wgpu : `queue.submit()` après le compute pass garantit que les données Transform sont valides avant le render pass suivant. Aucun double buffering n'est nécessaire tant que la latence maximale de la surface (via `desired_maximum_frame_latency`) est ≥ 3.
2. **Compute Pass** — Un compute shader lit les Transform bruts, calcule les World Matrices finales, effectue le Frustum Culling par AABB, et remplit l'Indirect Draw Buffer avec les identifiants des objets visibles.
3. **Render Pass** — Le CPU émet une unique commande `draw_indexed_indirect`. Le GPU pioche dans l'Indirect Draw Buffer et dessine uniquement les objets visibles, sans intervention du CPU.
2. **Compute Pass** — Deux entry points compute séquentiels (`compute_matrices` puis `cull`, un seul module WGSL) lisent les Transform bruts, calculent les World Matrices finales, effectuent le Frustum Culling par **sphère conservative** (D5), et remplissent l'Indirect Draw Buffer avec les **comptes** de draw des objets visibles (0 si cullé/inactif).
3. **Render Pass** — Le CPU émet **un draw indirect par slot** (écart D1 — la cible initiale prévoyait une commande unique fusionnée). Le GPU pioche les comptes dans l'Indirect Draw Buffer et dessine uniquement les objets non cullés et actifs, sans intervention du CPU.
4. **Présentation** — La surface est présentée à l'écran.
L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis `begin_render_pass`) garantit l'exécution séquentielle. Les barrières de mémoire entre passes sont insérées automatiquement par le pilote.
@@ -117,10 +120,11 @@ L'ordre d'appel des méthodes sur le `CommandEncoder` (`begin_compute_pass` puis
| Buffer | Rôle | Type wGPU | Direction du flux |
|--------|------|-----------|-------------------|
| Transform Buffer | Positions/rotations/échelles brutes | Storage Buffer | CPU → GPU |
| Matrix Buffer | World Matrices finales calculées | Storage Buffer | GPU (Calculé) → GPU (Lu par Render) |
| Bounding Box Buffer | AABB de chaque mesh pour culling | Storage Buffer | CPU → GPU (Statique) |
| Indirect Draw Buffer | Liste dynamique des objets à dessiner | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform | GPU (Calculé) → GPU (Lu par Render) |
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage | GPU (Rempli par Compute) → GPU (Lu par Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
> **Synchronisation single buffer** : Les buffers Transform et Matrix utilisent un **single buffer** en phase initiale. Le CPU écrit dans le buffer pendant `update()`, puis le compute shader lit les données au frame suivant via `queue.submit()` qui garantit la séquence d'exécution. Cette approche fonctionne correctement tant que la surface a une latence maximale ≥ 2 frames (configuré via `desired_maximum_frame_latency`). Le double buffering sera ajouté uniquement si des artefacts visuels apparaissent à haute fréquence (typiquement > 90 fps sur machines rapides).
+25 -16
View File
@@ -7,7 +7,7 @@ actor: person/jerome
sources: []
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
verified: true
status: target
status: current
stale_after: 2027-01-31
---
@@ -16,13 +16,19 @@ Bonnes Pratiques & Guide d'Implémentation
Ce document sert de spécification technique et de trame d'implémentation pour l'architecture de rendu 3D pilotée par le GPU (GPU-Driven Rendering) utilisant wgpu. L'objectif est de déléguer un maximum de charges de calcul au GPU pour soulager le CPU et maximiser les performances de parallélisme.
> **État du document : CIBLE (spécification du pipeline GPU-driven, non implémenté).**
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, 2026-07-20, DRAFT Étape 17).**
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
> et les buffers persistants en VRAM décrits ici correspondent à la **Phase 3 du ROADMAP** et aux
> README étapes 2-3. **Aucun de ces mécanismes n'existe encore dans le code.** Aujourd'hui le rendu est
> piloté par le CPU, **objet par objet** (une soumission par mesh, voir README.md et l'exemple `manual`).
> Considérez ce document comme la spécification de référence pour l'implémentation future du pipeline
> GPU-driven, pas comme une description de l'état actuel.
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
> buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
> capacité fixe de 256 slots).
> **Écarts documentés** (cf. DRAFT Étape 17) : (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`.
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
@@ -53,10 +59,10 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
- 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).
- Pass de Calcul (Compute Pass) :
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
- Frustum Culling GPU : Le même compute shader (ou un compute pass dédié) compare la Bounding Box (AABB) de chaque objet avec les plans de la caméra (matrice de projection/vue).
- Remplissage du Buffer Indirect : Si l'objet est visible, son identifiant est injecté dans un buffer de commandes de dessin indirect (Indirect Draw Buffer).
- Frustum Culling GPU : Un compute pass dédié (`cull`) compare la **sphère bounding** de chaque objet (D5 — conservative, dérivée de l'AABB locale du mesh et de l'échelle de l'entité) avec les 6 plans du frustum de la caméra.
- Remplissage du Buffer Indirect : le pass `cull` écrit le **compte de sommets/indices** de chaque objet dans son `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op).
- Pass de Rendu (Render Pass) :
- Le CPU émet une unique commande globale : draw_indexed_indirect.
- Le CPU émet **un draw indirect par slot** (écart D1 — la spécification initiale prévoyait une commande unique fusionnée) ; les slots à compte 0 (cullés/inactifs/vides) sont des no-ops.
- Le GPU pioche directement dans le buffer préparé par le compute pass et dessine uniquement les objets visibles, sans intervention du CPU.
3. Stratégie de Synchronisation
@@ -66,12 +72,15 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
4. Synthèse des Structures de Données en VRAM
Pour implémenter cette architecture, prévoyez l'utilisation des buffers wGPU suivants :
Nom du Buffer,Rôle,Type wGPU,Direction du flux
Transform Buffer,Stocke les positions/rotations/échelles brutes.,Storage Buffer,CPU → GPU
Matrix Buffer,Stocke les World Matrices finales calculées.,Storage Buffer,GPU (Calculé) → GPU (Lu par le Render)
Bounding Box Buffer,Stocke les AABB de chaque mesh pour le culling.,Storage Buffer,CPU → GPU (Statique)
Indirect Draw Buffer,Contient la liste dynamique des objets à dessiner.,Indirect Buffer + Storage,GPU (Rempli par Compute) → GPU (Lu par Render)
L'implémentation utilise les buffers wGPU suivants (tous créés par le `Renderer` à l'initialisation, capacité fixe de 256 slots) :
| Buffer | Rôle | Type wGPU | Direction du flux |
|--------|------|-----------|-------------------|
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, `write_buffer`) |
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform Buffer | GPU (Calculé) → GPU (Lu par le Render) |
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
## Liens
+6 -5
View File
@@ -68,10 +68,11 @@ Avec notre nouvelle architecture "Atelier", la distinction est devenue encore pl
| CommandEncoder | Par-Frame | Ton "carnet de notes" temporaire pour les ordres du GPU. |
| TextureView | Par-Frame | Fenêtre temporaire sur la texture active du swapchain. |
> **Ressources GPU persistantes (single buffer) — CIBLE, non implémenté** : À l'état **visé**, les
> buffers Transform et Matrix vivent en VRAM avec un single buffer en phase initiale (le CPU écrit
> pendant `update()`, le compute shader lit au frame suivant, séquencé par `queue.submit()`), puis un
> double buffering si des artefacts apparaissent à haute fréquence. **Aucune de ces ressources n'existe
> encore dans le code** — c'est la cible GPU-driven (ROADMAP Phase 3 / ARCHI_CPU_GPU).
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-07-20)** : les buffers
> Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
> capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
> **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
> (l'ordre est garanti par l'encoder, pas par `queue.submit()` inter-frames). Le double buffering
> reste la **cible** si des artefacts apparaissent à haute fréquence (voir ARCHI_CPU_GPU / ARCHI_APP).
---
+1
View File
@@ -20,6 +20,7 @@ GPU graphics background is required.
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
| [Examples](examples.md) | The 7 repo examples, the advanced `manual` workflow, adding your own example |
+132
View File
@@ -0,0 +1,132 @@
# 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 and how to opt into **frustum culling**.
## 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).
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.
## 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.
## 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 and the cull uniforms. 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. 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 DRAFT D14 in `docs/DRAFT.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).
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)
+30 -1
View File
@@ -61,6 +61,8 @@ fn stripes_rgba() -> Vec<u8> {
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.
@@ -222,13 +224,40 @@ impl AppHandler for Demo {
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4);
app.scene.set_entity_transform("cube_e", tf);
}
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
app.render_scene(frame.view());
// Opt-in GPU readback (black-window investigation tooling): WSG_DEBUG_DUMP=N dumps the
// first 8 slots of the transform/matrix/draw-args/bbox buffers for N frames (unset = silent,
// non-numeric value = 3 frames). Note: orbiting/zooming this camera
// can never cull the entity ring — the camera always looks at the origin, so each
// entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under
// the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all
// 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far
// off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md.
// Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3.
let frames = match std::env::var("WSG_DEBUG_DUMP") {
Ok(v) => v.parse::<u32>().ok().filter(|&n| n > 0).unwrap_or(3),
Err(_) => 0,
};
if self.dbg < frames {
self.dbg += 1;
app.renderer().debug_dump(8);
}
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Demo").build().await?;
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere.
let app = AppBuilder::new()
.title("WSG Demo")
.with_culling(true)
.build()
.await?;
app.run(Demo {
camera: CameraController::default(),
angle: 0.0,
dbg: 0,
})
}
+6 -5
View File
@@ -2,15 +2,16 @@
## Overview
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules:
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules (plus the `shaders/` asset directory):
| Module | Responsibility |
|--------|---------------|
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration; also `InputState` (unified keyboard/mouse input, Step 15.B) |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor), Texture, Lights, Camera + CameraController |
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration (incl. the GPU-driven compute passes + opt-in frustum culling); also `InputState` (unified keyboard/mouse input, Step 15.B) |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`) |
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
| **scene** | Scene — resource depot and entity graph for declarative rendering setup |
| **math** | Transform, Geometry (per-attribute mesh data) and `primitives` (procedural mesh generators) |
| **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), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) and `primitives` (procedural mesh generators) |
| **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
+20
View File
@@ -56,6 +56,8 @@ pub struct App {
pub(crate) width: u32,
/// Window height, read by the runner when the window is created in `resumed`.
pub(crate) height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
pub(crate) culling: bool,
/// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
@@ -118,6 +120,7 @@ impl App {
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
handler,
app: None,
};
@@ -173,6 +176,8 @@ pub struct AppBuilder {
width: u32,
/// Window height in pixels.
height: u32,
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
culling: bool,
}
impl AppBuilder {
@@ -183,6 +188,7 @@ impl AppBuilder {
title: APP_DEFAULT_TITLE.to_string(),
width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT,
culling: false,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
@@ -198,6 +204,14 @@ impl AppBuilder {
self.height = height;
self
}
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
/// every active entity visible, so the rendered image is identical to culling-off.
pub fn with_culling(mut self, enabled: bool) -> Self {
self.culling = enabled;
self
}
/// Builds the configured `App` instance: creates the event loop and stores the window
/// configuration. The GPU context, window and renderer are created later, when the event loop
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
@@ -211,6 +225,7 @@ impl AppBuilder {
title: self.title,
width: self.width,
height: self.height,
culling: self.culling,
event_loop: Some(event_loop),
context: None,
renderer: None,
@@ -229,6 +244,8 @@ struct AppRunner<H: AppHandler> {
width: u32,
/// Window height in pixels, applied when the window is created in `resumed`.
height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
culling: bool,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
@@ -262,6 +279,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer = Renderer::new(&context, format, self.width, self.height);
// 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.
@@ -274,6 +293,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
event_loop: None,
context: Some(context),
renderer: Some(renderer),
+532 -89
View File
@@ -20,27 +20,30 @@
use crate::core::Context;
use crate::core::Frame;
use crate::math::Transform;
use crate::math::Frustum;
use crate::pipeline::{
DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout,
create_shadow_uniform_layout, create_uniform_bind_group_layouts,
};
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE};
use crate::resources::uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE,
MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE,
TRANSFORM_SLOT_SIZE, TransformSlot,
};
use crate::resources::{
Camera, FrameUniforms, Lights, MAX_LIGHTS, Material, Mesh, ObjectUniform, ShadowUniform,
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::utils::conf::{
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, MAX_ENTITIES, SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE,
SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
};
use glam::{Mat4, Vec3, Vec4};
use std::cell::RefCell;
use std::collections::HashMap;
use std::cell::Cell;
/// The Executor layer of the architecture. Holds shared references to Device and Queue from Context,
/// plus the surface texture format. Executes WGPU rendering commands by binding Materials and Meshes
/// into RenderPasses during each frame. Does not own raw hardware resources (they are Arc-cloned from Context).
///
/// Since Step 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the
/// Renderer owns the matching GPU buffers and `BindGroup`s and binds them around every draw call.
pub struct Renderer {
@@ -58,8 +61,6 @@ pub struct Renderer {
/// Depth attachment view used by both render passes (`render`, `render_scene`). Format
/// `DEPTH_FORMAT` (Depth32Float) — matches every pipeline's `DepthStencilState` (D1).
depth_view: wgpu::TextureView,
/// Bind group layout for the per-object uniforms (group 1) — must match every pipeline layout.
object_layout: wgpu::BindGroupLayout,
/// Shared per-frame uniform buffer handle — kept so the camera matrices can be rewritten each
/// frame (`render_scene`) and shipped to the GPU before the frame bind group is used.
frame_buffer: wgpu::Buffer,
@@ -67,10 +68,6 @@ pub struct Renderer {
frame_bind_group: wgpu::BindGroup,
/// Shared per-object bind group (identity model) used by the low-level `render` path.
shared_object_bind_group: wgpu::BindGroup,
/// Per-entity object uniform buffers + bind groups, lazily created on first encounter and keyed by
/// entity label. Needed because `render_scene(&self, &Scene)` is immutable; the model matrix is
/// rewritten each frame for every entity.
object_cache: RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>,
/// Flat (unlit) rendering flag, exposed via [`Renderer::set_unlit`]. When true, `options.x` of the
/// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D
/// rendering is thus a special case of the 3D lit path (DRAFT Step 5). Defaults to `false` (lit).
@@ -91,6 +88,36 @@ pub struct Renderer {
shadow_uniform_bind_group: wgpu::BindGroup,
/// Depth-only pipeline rendering the scene from the shadow light's point of view (D4).
shadow_pipeline: wgpu::RenderPipeline,
// ---- Phase 3 (Step 15) — GPU-driven rendering: compute pipelines + slot buffers + bind groups ----
/// Compute pipeline deriving per-entity world matrices from the transform buffer (Step 15.5).
compute_matrices_pipeline: wgpu::ComputePipeline,
/// Compute pipeline culling entities + filling the indirect draw args (Step 15.6).
cull_pipeline: wgpu::ComputePipeline,
/// GPU world-matrix slots (`STORAGE | COPY_DST`), written by `compute_matrices`, bound per-slot
/// via `matrix_object_bg`.
matrix_buffer: wgpu::Buffer,
/// GPU transform slots (`STORAGE | COPY_DST`), read by both compute passes; rewritten by the CPU each frame.
transform_buffer: wgpu::Buffer,
/// GPU local-space bounding boxes (`STORAGE | COPY_DST`), read by `cull`; uploaded once per mesh set.
bbox_buffer: wgpu::Buffer,
/// GPU indirect draw args (`STORAGE | INDIRECT`), written by `cull`, read by the indirect renders.
draw_args_buffer: wgpu::Buffer,
/// GPU cull uniforms (`UNIFORM | COPY_DST`): frustum planes + control flags; rewritten each frame.
cull_uniform_buffer: wgpu::Buffer,
/// `compute_matrices`/`cull` group 0 (transform buffer, storage read) — shared by both compute passes.
transform_bg: wgpu::BindGroup,
/// `compute_matrices` group 1 (matrix buffer, storage read_write).
matrices_bg: wgpu::BindGroup,
/// `cull` group 2 (cull uniforms + bboxes + draw args).
cull_bundle_bg: wgpu::BindGroup,
/// Shared object bind group (group 1, dynamic) binding ONE 64-byte matrix slice of the GPU matrix
/// buffer — every entity's render/shadow draw binds this with a per-slot dynamic offset
/// (`slot_index * MAT_SLOT_SIZE`, 256-aligned) to select its slot.
matrix_object_bg: wgpu::BindGroup,
/// Whether GPU frustum culling is enabled (off by default until validated, Step 15.6).
/// Interior-mutable so `set_culling` can toggle it from an immutable `&Renderer` (matching the
/// Renderer's all-`&self` API). Read each frame by `render_scene` when building the cull uniforms.
cull_enabled: Cell<bool>,
}
impl Renderer {
@@ -204,17 +231,216 @@ impl Renderer {
});
let shadow_pipeline = build_shadow_pipeline(&device, &object_layout);
// Phase 3 (Step 15) — GPU-driven rendering. One compute shader module with two entry points
// (`compute_matrices`, `cull`); a single explicit 3-group pipeline layout is shared by both
// pipelines so they bind the same transforms / matrices / cull buffers (DRAFT Step 15.5).
let gpu_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-driven compute shader"),
source: wgpu::ShaderSource::Wgsl(GPU_DRIVEN_SHADER.into()),
});
// Group 0: transform slots (storage read). Group 1: world matrices (storage read_write).
let gpu_transforms_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu transforms layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let gpu_matrices_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu matrices layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
// Group 2: cull uniforms (uniform) + bounding boxes (storage read) + draw args (storage rw).
let gpu_cull_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu cull layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
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::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let gpu_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu pipeline layout"),
bind_group_layouts: &[
Some(&gpu_transforms_layout),
Some(&gpu_matrices_layout),
Some(&gpu_cull_layout),
],
immediate_size: 0,
});
let compute_matrices_pipeline =
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("compute_matrices pipeline"),
layout: Some(&gpu_pipeline_layout),
module: &gpu_shader,
entry_point: Some("compute_matrices"),
compilation_options: Default::default(),
cache: None,
});
let cull_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cull pipeline"),
layout: Some(&gpu_pipeline_layout),
module: &gpu_shader,
entry_point: Some("cull"),
compilation_options: Default::default(),
cache: None,
});
// Fixed-capacity slot buffers (allocated once). Transform + cull-uniform buffers are rewritten
// by the CPU each frame; matrix + draw-args buffers are GPU-written; the bbox buffer is uploaded
// once per mesh set.
let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU transform slots"),
size: MAX_ENTITIES as u64 * TRANSFORM_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the slots back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let matrix_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU world matrices"),
size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU bounding boxes"),
size: MAX_ENTITIES as u64 * BBOX_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the boxes back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let draw_args_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU indirect draw args"),
size: MAX_ENTITIES as u64 * DRAW_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written args back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::INDIRECT
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let cull_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU cull uniforms"),
size: CULL_UNIFORMS_SIZE,
usage: wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
// Bind groups against the explicit layouts. `transform_bg` is shared by both compute passes
// (group 0); `matrices_bg` by `compute_matrices` (group 1); `cull_bundle_bg` by `cull`
// (group 2). `matrix_object_bg` uses the render pipeline's dynamic object layout (group 1) and
// binds a single 64-byte slice of the matrix buffer — per-entity draws select the slice's 256-byte
// slot via a dynamic offset (a whole-buffer binding would cap the dynamic offset at 0).
let transform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu transforms bind group"),
layout: &gpu_transforms_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: transform_buffer.as_entire_binding(),
}],
});
let matrices_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu matrices bind group"),
layout: &gpu_matrices_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: matrix_buffer.as_entire_binding(),
}],
});
let cull_bundle_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu cull bundle bind group"),
layout: &gpu_cull_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: cull_uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: bbox_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: draw_args_buffer.as_entire_binding(),
},
],
});
let matrix_object_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("GPU matrix object bind group"),
layout: &object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
// Bind ONE 64-byte matrix (not the whole buffer) so the per-slot dynamic offset can
// slide across the 256-byte slots. The offset is `slot_index * MAT_SLOT_SIZE` (256-aligned).
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &matrix_buffer,
offset: 0,
// One 64-byte matrix (OBJECT_UNIFORM_SIZE is a non-zero const, so the unwrap is safe).
size: Some(std::num::NonZeroU64::new(OBJECT_UNIFORM_SIZE).unwrap()),
}),
}],
});
let renderer = Self {
queue,
device,
format,
_depth_texture: depth_texture,
depth_view,
object_layout,
frame_buffer,
frame_bind_group,
shared_object_bind_group,
object_cache: RefCell::new(HashMap::new()),
unlit: false,
_shadow_texture: shadow_texture,
shadow_view,
@@ -222,6 +448,18 @@ impl Renderer {
shadow_uniform_buffer,
shadow_uniform_bind_group,
shadow_pipeline,
compute_matrices_pipeline,
cull_pipeline,
matrix_buffer,
transform_buffer,
bbox_buffer,
draw_args_buffer,
cull_uniform_buffer,
transform_bg,
matrices_bg,
cull_bundle_bg,
matrix_object_bg,
cull_enabled: Cell::new(false),
};
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
@@ -352,7 +590,7 @@ impl Renderer {
-light.position_dir.y,
-light.position_dir.z,
),
crate::resources::LightType::Spot { .. } => {
crate::resources::LightType::Spot => {
Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z)
}
crate::resources::LightType::Point => return None,
@@ -423,23 +661,22 @@ impl Renderer {
material,
&self.frame_bind_group,
&self.shared_object_bind_group,
0, // object offset 0 — the identity object buffer (low-level path, no slot).
&self.shadow_bind_group,
);
}
self.queue.submit(std::iter::once(encoder.finish()));
}
/// Renders every entity in `scene` into the given color view within a single batched render pass.
/// This avoids allocating a separate encoder and render pass per entity (which the low-level
/// `render` does), minimizing GPU submissions. Called automatically each frame by the default
/// `AppHandler::render` through `App::render_scene`.
/// Renders every entity in `scene` into the given color view, fully GPU-driven (Phase 3, Step 15).
/// Per frame the CPU rewrites only the transform slots + cull uniforms; the GPU then derives the
/// world matrices (`compute_matrices`), culls + fills the indirect draw args (`cull`), and the
/// main + shadow render passes are 100% indirect (a culled/inactive slot's args are zero → a no-op
/// draw). This removes the CPU-side per-entity loop from the render hot path.
/// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are
/// drawn; aspect — the viewport aspect ratio (width/height), used to build the camera's perspective
/// projection.
///
/// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU
/// receives the active camera's view/projection matrices and position for this frame (Step 4.3).
/// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection.
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
// 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags).
self.write_frame_uniforms(
scene.camera(),
scene.lights(),
@@ -448,17 +685,78 @@ impl Renderer {
scene.shadow_caster(),
);
// 2. Rewrite the GPU transform slots — the single source of truth for world matrices, and the
// only per-entity CPU→GPU copy each frame (packed TRS, 64 B per slot).
let transform_slots = scene.packed_transform_slots();
self.queue.write_buffer(
&self.transform_buffer,
0,
bytemuck::cast_slice(&transform_slots),
);
// 3. Upload the local-space bounding boxes (small; the mesh set is static in practice, but
// re-uploading each frame keeps the mesh-index → bbox mapping correct if meshes are added).
let bboxes = scene.mesh_bboxes();
self.queue
.write_buffer(&self.bbox_buffer, 0, bytemuck::cast_slice(&bboxes));
// 4. Compute the view frustum from the camera's view-projection and write the cull uniforms
// (six unit planes + the num_slots / culling control flags).
let camera = scene.camera();
let view_proj = camera.projection_matrix(aspect) * camera.view_matrix();
let frustum = Frustum::from_view_proj(&view_proj);
let cull_uniforms =
CullUniforms::from_frustum(&frustum, scene.num_slots() as u32, self.cull_enabled.get());
self.queue.write_buffer(
&self.cull_uniform_buffer,
0,
bytemuck::bytes_of(&cull_uniforms),
);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("scene encoder"),
});
// Step 14 (DRAFT 3.2): run the depth-only shadow pass first when a light is configured to
// cast shadows (D4). It populates `shadow_view` on the shared encoder; the main pass below
// then samples it via `shadow_bind_group`. `render_shadow_map` no-ops when shadows are off.
// 5. Dispatch the two compute passes (Step 15.5): `compute_matrices` derives each entity's
// world matrix into the matrix buffer, then `cull` fills the indirect draw args (a zero
// count for a culled/inactive slot). Both read the transform buffer written above.
let workgroups = MAX_ENTITIES.div_ceil(GPU_WORKGROUP_SIZE);
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("compute_matrices pass"),
timestamp_writes: None,
});
// Bind ALL THREE groups: the shared layout has a non-null bind group at every index,
// so WebGPU requires each to be set even when the entry point doesn't read it. The
// cull bundle (group 2) is unused by this entry point but must still be bound.
pass.set_pipeline(&self.compute_matrices_pipeline);
pass.set_bind_group(0, &self.transform_bg, &[]);
pass.set_bind_group(1, &self.matrices_bg, &[]);
pass.set_bind_group(2, &self.cull_bundle_bg, &[]);
pass.dispatch_workgroups(workgroups, 1, 1);
}
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("cull pass"),
timestamp_writes: None,
});
// Same: bind all three. Group 1 (matrices) is unused by the cull entry point (it
// computes the world-space centre from the transform directly) but must be bound.
pass.set_pipeline(&self.cull_pipeline);
pass.set_bind_group(0, &self.transform_bg, &[]);
pass.set_bind_group(1, &self.matrices_bg, &[]);
pass.set_bind_group(2, &self.cull_bundle_bg, &[]);
pass.dispatch_workgroups(workgroups, 1, 1);
}
// 6. Run the depth-only shadow pass (indirect) first when a light casts shadows (DRAFT 3.2/D4);
// it reads the same matrix + draw-args buffers. No-op when shadows are off.
self.render_shadow_map(&mut encoder, scene);
// 7. Main render pass: one indirect draw per active slot. The matrix + draw-args are read via
// per-slot offsets; a culled/inactive slot's args are zero, so its draw is a no-op.
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"),
@@ -484,42 +782,201 @@ impl Renderer {
..Default::default()
});
// Step 7 (DRAFT 7.3.4): the Material is resolved from the Mesh itself, falling back
// to the Scene's default material when the mesh carries none.
for (label, mesh, transform) in scene.iter_entities() {
let material = mesh
for slot in scene.iter_slot_draws() {
if !slot.active {
continue; // tombstone — the GPU already zeroed this slot's draw args.
}
// The Material is resolved from the Mesh itself, falling back to the Scene's default
// material when the mesh carries none.
let material = slot
.mesh
.material()
.cloned()
.unwrap_or_else(|| scene.default_material());
let object_bind_group = self.object_bind_group_for(label, transform);
draw_entity(
&mut render_pass,
mesh,
&material,
&self.frame_bind_group,
&object_bind_group,
&self.shadow_bind_group,
);
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
render_pass.set_pipeline(&material.pipeline);
render_pass.set_bind_group(0, &self.frame_bind_group, &[]);
// Group 1 (dynamic): the 64-byte matrix slice for this slot.
render_pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
render_pass.set_bind_group(2, &material.texture_bind_group, &[]);
render_pass.set_bind_group(3, &self.shadow_bind_group, &[]);
render_pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
if let Some(index_buffer) = &slot.mesh.index_buffer {
render_pass
.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
}
render_pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
render_pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
}
}
self.queue.submit(std::iter::once(encoder.finish()));
}
/// Debug helper (Phase 3 black-window investigation): reads back the first `n` slots of the
/// transform / matrix / indirect-draw-args / bbox buffers (plus the cull uniforms) and prints
/// them to stderr. Call it from a render callback *after* `render_scene` so the compute
/// passes of the current frame have been submitted.
///
/// **Synchronous on purpose** (blocks the calling thread until every staging buffer is read
/// back and unmapped). Two wgpu-core rules make this necessary (both cost a frozen render
/// loop if violated — observed empirically with this very tool):
///
/// 1. `Queue::write_buffer` on a buffer with a **pending map** fails with
/// `TransferError::BufferNotAvailable` (and `render_scene` writes the transform / bbox /
/// cull buffers every frame). A detached-thread readback that leaves its maps pending
/// when the next frame starts corrupts/skips that frame (demo froze after 3 async dumps).
/// 2. Map callbacks are only fired by the queue `maintain`, which runs **inside**
/// `Queue::submit` — a thread blocked waiting on its own callbacks can never trigger it
/// (demo froze on the first purely-synchronous dump).
///
/// This implementation closes both loops itself: it submits the copies, requests the maps,
/// then pumps the queue with empty submits (each one runs a `maintain` and fires whatever
/// callbacks are due) until every map has completed, and only then unmaps. At the point it
/// returns, all staging buffers are `Idle` again, so the next frame's `write_buffer` calls
/// are safe. Each dump briefly stalls the render loop (a few ms).
#[doc(hidden)]
pub fn debug_dump(&self, n: u32) {
let device = self.device.clone();
let queue = self.queue.clone();
let matrix_buf = self.matrix_buffer.clone();
let draw_args_buf = self.draw_args_buffer.clone();
let cull_buf = self.cull_uniform_buffer.clone();
let transform_buf = self.transform_buffer.clone();
let bbox_buf = self.bbox_buffer.clone();
let n = n.min(MAX_ENTITIES as u32).max(1);
{
// Creates a MAP_READ staging buffer and records a copy of `size` bytes from `src`
// into the caller's encoder. NOTE: the map must only be requested AFTER the submit —
// wgpu-core rejects a submission that references a buffer in a non-idle map state.
fn readback(
device: &wgpu::Device,
src: &wgpu::Buffer,
size: u64,
enc: &mut wgpu::CommandEncoder,
) -> wgpu::Buffer {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dbg readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
enc.copy_buffer_to_buffer(src, 0, &read, 0, size);
read
}
let specs: [(&wgpu::Buffer, u64); 5] = [
(&matrix_buf, n as u64 * MAT_SLOT_SIZE),
(&draw_args_buf, n as u64 * DRAW_SLOT_SIZE),
(&transform_buf, n as u64 * TRANSFORM_SLOT_SIZE),
(&bbox_buf, n as u64 * BBOX_SLOT_SIZE),
(&cull_buf, CULL_UNIFORMS_SIZE),
];
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reads = Vec::with_capacity(specs.len());
{
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("dbg readback encoder"),
});
for (src, size) in &specs {
reads.push(readback(&device, src, *size, &mut enc));
}
queue.submit([enc.finish()]);
}
// Only now request the maps (wgpu 30 has no async `map` future — one message each).
for read in &reads {
let size = read.size();
let tx = tx.clone();
read.map_async(wgpu::MapMode::Read, 0..size, move |_| {
let _ = tx.send(());
});
}
// Pump the queue until every map callback has fired. The callbacks are delivered by
// the `maintain` that runs inside `Queue::submit` — and this thread is the only one
// that will submit from now on (the caller is blocked here), so it must pump itself.
// Empty submits are cheap: each one polls the GPU and fires whatever is due.
loop {
let mut done = 0;
while rx.try_recv().is_ok() {
done += 1;
}
if done == specs.len() {
break;
}
queue.submit([]);
std::thread::sleep(std::time::Duration::from_millis(2));
}
let data: Vec<Vec<u8>> = reads
.iter()
.map(|b| {
b.slice(..)
.get_mapped_range()
.expect("dbg mapped range")
.to_vec()
})
.collect();
for b in &reads {
b.unmap();
}
let (mat_data, args_data, tr_data, bb_data, cull_data) =
(&data[0], &data[1], &data[2], &data[3], &data[4]);
for i in 0..n {
let off = (i as u64 * TRANSFORM_SLOT_SIZE) as usize;
let t: TransformSlot =
bytemuck::pod_read_unaligned(&tr_data[off..off + TRANSFORM_SLOT_SIZE as usize]);
eprintln!(
"[dbg] transform[{i}] translation={:?} flags={:?} rotation={:?} scale={:?}",
t.translation, t.flags, t.rotation, t.scale
);
}
for i in 0..n {
let off = (i as u64 * BBOX_SLOT_SIZE) as usize;
let b: BBoxSlot =
bytemuck::pod_read_unaligned(&bb_data[off..off + BBOX_SLOT_SIZE as usize]);
eprintln!("[dbg] bbox[{i}] min={:?} max={:?}", b.min, b.max);
}
for i in 0..n {
let off = (i as u64 * MAT_SLOT_SIZE) as usize;
let m: MatSlot =
bytemuck::pod_read_unaligned(&mat_data[off..off + MAT_SLOT_SIZE as usize]);
eprintln!("[dbg] matrix[{i}] m = {:?}", m.m);
}
for i in 0..n {
let off = (i as u64 * DRAW_SLOT_SIZE) as usize;
let d: DrawSlot =
bytemuck::pod_read_unaligned(&args_data[off..off + DRAW_SLOT_SIZE as usize]);
eprintln!("[dbg] draw_args[{i}].a = {:?}", d.a);
}
let c: CullUniforms = bytemuck::pod_read_unaligned(&cull_data[..]);
eprintln!(
"[dbg] cull: num_slots={} culling={}",
c.num_slots, c.culling
);
for (i, p) in c.planes.iter().enumerate() {
eprintln!("[dbg] plane[{i}] = {p:?}");
}
eprintln!("[dbg] done");
}
}
/// Renders every entity of `scene` from the shadow-casting light's point of view into the
/// shadow depth map (Step 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at
/// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is
/// `None`. The shadow light's `view_proj` is written to `shadow_uniform_buffer`, and the shadow
/// pass writes depth into `shadow_view` (clear 1.0, store). The per-entity model bind groups are
/// reused from `object_bind_group_for`, so transforms match the main pass exactly.
/// Inputs: encoder (the shared command encoder for the frame), scene (entities to cast).
/// `None`. Phase 3 (Step 15): like the main pass, the shadow pass is 100% indirect — it reads the
/// GPU world matrices (group 1, dynamic offset) and the GPU indirect draw args, so transforms and
/// culling match the main pass exactly. Inputs: encoder (the shared command encoder for the frame,
/// with the compute passes already dispatched), scene (entities to cast).
fn render_shadow_map(&self, encoder: &mut wgpu::CommandEncoder, scene: &Scene) {
let _caster = match scene.shadow_caster() {
let caster = match scene.shadow_caster() {
Some(c) => c,
None => return,
};
// Recompute the light's view_proj and write it into the shadow uniform buffer so the
// depth-only vertex shader transforms vertices into light-clip space (D4).
let (light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(_caster)) {
let (_light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(caster)) {
Some(pair) => pair,
None => return,
};
@@ -548,20 +1005,26 @@ impl Renderer {
pass.set_pipeline(&self.shadow_pipeline);
// Group 0: the shadow light view_proj (D4) — the shadow pipeline's only uniform group.
pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]);
for (label, mesh, transform) in scene.iter_entities() {
let object_bind_group = self.object_bind_group_for(label, transform);
// Group 1: per-entity model. The shadow pipeline has no texture/sampler groups.
pass.set_bind_group(1, &object_bind_group, &[]);
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..mesh.num_indices, 0, 0..1);
// Phase 3: one indirect draw per active slot; group 1 (dynamic) selects the matrix slice and
// the draw-args offset. The shadow pipeline has no texture/sampler groups.
for slot in scene.iter_slot_draws() {
if !slot.active {
continue;
}
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
if let Some(index_buffer) = &slot.mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
}
pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
pass.draw(0..mesh.num_vertices, 0..1);
pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
}
drop(pass);
let _ = light_index; // (index retained for future per-light shadow options)
}
/// Presents the rendered frame by submitting the acquired surface texture to the GPU queue.
@@ -583,37 +1046,13 @@ impl Renderer {
self.format
}
/// Returns the per-entity object bind group for `label`, creating its uniform buffer on first
/// encounter and rewriting the model matrix each call. Since `render_scene(&self)` is immutable,
/// the lazily-populated cache is interior-mutable (`RefCell`). Step 4.2.
/// Inputs: label (entity identifier used as cache key), transform (world placement to upload).
/// Returns an owned (cheaply Arc-cloned) reference handle to the object bind group (group 1).
fn object_bind_group_for(&self, label: &str, transform: &Transform) -> wgpu::BindGroup {
let mut cache = self.object_cache.borrow_mut();
let (buffer, bind_group) = cache.entry(label.to_string()).or_insert_with(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("object uniform buffer"),
size: OBJECT_UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("object bind group"),
layout: &self.object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, bind_group)
});
// Rewrite the model matrix every frame so entity transforms can update (e.g. rotation).
let object_uniforms = ObjectUniform {
model: transform.to_matrix(),
};
self.queue
.write_buffer(buffer, 0, bytemuck::bytes_of(&object_uniforms));
bind_group.clone()
/// Enables or disables GPU frustum culling (Phase 3, Step 15.6). Culling is off by default: with
/// it disabled, the `cull` pass copies every active slot's draw count (nothing is culled), so the
/// scene renders identically to the pre-Phase-3 CPU loop. Enabling it makes `cull` test each
/// slot's world-space bounding box against the camera frustum and zero the draw args of culled
/// slots (so their indirect draws become no-ops). Inputs: enabled (true = cull, false = draw all).
pub fn set_culling(&self, enabled: bool) {
self.cull_enabled.set(enabled);
}
}
@@ -691,6 +1130,7 @@ fn draw_entity(
material: &Material,
frame_bind_group: &wgpu::BindGroup,
object_bind_group: &wgpu::BindGroup,
object_offset: u32,
shadow_bind_group: &wgpu::BindGroup,
) {
if mesh.num_vertices == 0 {
@@ -699,7 +1139,10 @@ fn draw_entity(
}
pass.set_pipeline(&material.pipeline);
pass.set_bind_group(0, frame_bind_group, &[]);
pass.set_bind_group(1, object_bind_group, &[]);
// Phase 3 (D12): the object (model) binding is dynamic — `object_offset` selects the 64-byte
// slice. The low-level path passes 0 (the shared identity buffer); the GPU-driven path uses its
// own inline `set_bind_group` calls (not this helper) with a per-slot `slot_index * MAT_SLOT_SIZE`.
pass.set_bind_group(1, object_bind_group, &[object_offset]);
// Step 10 (DRAFT 10.4): texture group — the Material owns its bind group (placeholder
// white if it has no texture, D1/D2). Always bindable since it is attached to every pipeline.
pass.set_bind_group(2, &material.texture_bind_group, &[]);
+178
View File
@@ -0,0 +1,178 @@
//! # Frustum Module
//!
//! View-projection frustum representation and plane extraction, for frustum culling (Phase 3,
//! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space
//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]).
//!
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
//! view-projection matrix `M` (world to clip space), whose NDC conventions are x, y in `[-1, 1]` and
//! z in `[0, 1]`:
//!
//! | plane | clip-space inequality | row combination |
//! |--------|-----------------------|-----------------|
//! | left | cx + cw >= 0 | w + x |
//! | right | -cx + cw >= 0 | w - x |
//! | bottom | cy + cw >= 0 | w + y |
//! | top | -cy + cw >= 0 | w - y |
//! | near | cz >= 0 | z |
//! | far | -cz + cw >= 0 | w - z |
//!
//! (For the `[0, 1]` z range the near plane is the z row alone — `cz >= 0` — whereas the classic
//! `[-1, 1]` Gribb-Hartmann uses `w + z`. The far plane `w - z` is the same in both.)
//! Each plane is normalized to a unit normal so the signed-distance test is scale-invariant.
use glam::{Mat4, Vec3, Vec4};
/// A view-projection frustum represented by its six bounding planes.
///
/// Each plane is a `[f32; 4]` `(normal, d)`: a world point `p` is inside when
/// `dot(p, normal) + d >= 0`. Built from a view-projection matrix via
/// [`Frustum::from_view_proj`] and uploaded to the GPU culling compute shader (Phase 3).
#[derive(Debug, Clone, Copy)]
pub struct Frustum {
/// The six frustum planes, order: `[left, right, bottom, top, near, far]`.
pub planes: [[f32; 4]; 6],
}
impl Frustum {
/// Extracts the six frustum planes from a view-projection matrix (Gribb-Hartmann, adapted to
/// WebGPU's `[0, 1]` clip-space z). Inputs: m — the `projection * view` matrix (world to clip
/// space). Returns the frustum with unit-length plane normals.
pub fn from_view_proj(m: &Mat4) -> Self {
// glam stores Mat4 by column; transpose so `.x_axis`/`.y_axis`/... are the rows of M,
// i.e. the clip-space basis vectors the Gribb-Hartmann method combines.
let mt = m.transpose();
let r0 = mt.x_axis; // row 0 of M -> clip x
let r1 = mt.y_axis; // row 1 of M -> clip y
let r2 = mt.z_axis; // row 2 of M -> clip z
let r3 = mt.w_axis; // row 3 of M -> clip w
let raw: [Vec4; 6] = [
r3 + r0, // left
r3 - r0, // right
r3 + r1, // bottom
r3 - r1, // top
r2, // near
r3 - r2, // far
];
let planes = raw.map(|p| {
let n = Vec3::new(p.x, p.y, p.z);
let len = n.length();
if len > 1e-8 {
let nn = n / len;
[nn.x, nn.y, nn.z, p.w / len]
} else {
[0.0, 0.0, 0.0, 0.0]
}
});
Self { planes }
}
/// Tests whether a world-space point lies inside the frustum (inside every plane).
/// Inputs: p — a world-space point. Returns true if it satisfies all six plane inequalities.
pub fn contains_point(&self, p: Vec3) -> bool {
self.planes
.iter()
.all(|plane| plane[0] * p.x + plane[1] * p.y + plane[2] * p.z + plane[3] >= 0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resources::camera::Camera;
/// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov,
/// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer.
fn vp(d: f32) -> Mat4 {
let cam = Camera::new(Vec3::new(0.0, 0.0, d), Vec3::ZERO, Vec3::Y).with_perspective(
45.0_f32.to_radians(),
0.1,
100.0,
);
cam.projection_matrix(1.0) * cam.view_matrix()
}
#[test]
fn origin_inside_when_camera_looks_at_it() {
let fr = Frustum::from_view_proj(&vp(10.0));
assert!(
fr.contains_point(Vec3::new(0.0, 0.0, 0.0)),
"the look-at target must be inside the frustum"
);
}
#[test]
fn behind_camera_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// Camera at z = 10 looks toward -z; a point at z = 50 is behind it.
assert!(
!fr.contains_point(Vec3::new(0.0, 0.0, 50.0)),
"a point behind the camera must be culled"
);
}
#[test]
fn far_to_the_side_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// Far off to the side, well outside the 45-degree field of view.
assert!(
!fr.contains_point(Vec3::new(1000.0, 0.0, 0.0)),
"a point far to the side must be culled"
);
}
#[test]
fn beyond_far_plane_is_culled() {
let fr = Frustum::from_view_proj(&vp(10.0));
// z = -500 is 510 units in front of the camera (at z = 10), beyond far = 100.
assert!(
!fr.contains_point(Vec3::new(0.0, 0.0, -500.0)),
"a point beyond the far plane must be culled"
);
}
#[test]
fn planes_are_unit_length() {
let fr = Frustum::from_view_proj(&vp(10.0));
for plane in fr.planes {
let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!(
(len - 1.0).abs() < 1e-3,
"plane normal must be unit length, got {len}"
);
}
}
/// Reproduces the `demo` example's exact camera + entity layout and confirms the GPU cull
/// pass would NOT cull any of them (they sit at radius 1.7 around the origin, in front of the
/// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
#[test]
fn demo_camera_sees_all_primitives() {
use crate::resources::CameraController;
let mut ctrl = CameraController::default();
ctrl.yaw = 0.6;
ctrl.pitch = 0.35;
ctrl.distance = 6.5;
ctrl.target = Vec3::ZERO;
let mut cam = Camera::default();
ctrl.apply_to(&mut cam);
let vp = cam.projection_matrix(1.0) * cam.view_matrix();
let fr = Frustum::from_view_proj(&vp);
// Ground plane center (origin).
assert!(
fr.contains_point(Vec3::ZERO),
"origin (ground center) must be inside"
);
// The six primitives, placed by demo::place at radius 1.7, y = 0.5.
for i in 0..6 {
let a = i as f32 / 6.0 * std::f32::consts::TAU;
let p = Vec3::new(a.cos() * 1.7, 0.5, a.sin() * 1.7);
assert!(
fr.contains_point(p),
"primitive {i} at {} must be inside the frustum",
p
);
}
}
}
+78
View File
@@ -22,6 +22,57 @@
use crate::resources::Vertex;
/// An axis-aligned bounding box in object (local) space: the min/max corners of a geometry's
/// positions. Used for conservative sphere culling (Phase 3): the culling radius is the box's
/// circumradius and the culling center is its center, both computed once per mesh and uploaded
/// to the GPU culling buffer.
#[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
#[repr(C)]
pub struct BBox {
/// Minimum corner (local space).
pub min: [f32; 3],
/// Maximum corner (local space).
pub max: [f32; 3],
}
impl BBox {
/// Builds a bounding box from a list of local-space positions. Returns `None` when the input
/// is empty (a geometry must have at least one position to have a bounding box).
pub fn from_positions(positions: &[[f32; 3]]) -> Option<Self> {
if positions.is_empty() {
return None;
}
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
Some(Self { min, max })
}
/// The center of the box (local space), i.e. the midpoint of the min and max corners.
pub fn center(&self) -> [f32; 3] {
[
(self.min[0] + self.max[0]) * 0.5,
(self.min[1] + self.max[1]) * 0.5,
(self.min[2] + self.max[2]) * 0.5,
]
}
/// The circumradius: distance from the center to a corner (all corners are equidistant for an
/// axis-aligned box). This is the conservative culling radius — the sphere of this radius
/// around the center fully contains the box.
pub fn circumradius(&self) -> f32 {
let dx = (self.max[0] - self.min[0]) * 0.5;
let dy = (self.max[1] - self.min[1]) * 0.5;
let dz = (self.max[2] - self.min[2]) * 0.5;
(dx * dx + dy * dy + dz * dz).sqrt()
}
}
/// Validation error produced when a `Geometry` is inconsistent, i.e. its optional
/// per-vertex arrays (`normals`, `uvs`, `colors`) have a length different from
/// `positions`, or an index is out of bounds.
@@ -223,6 +274,13 @@ impl Geometry {
self.indices.as_deref()
}
/// Computes the axis-aligned bounding box of this geometry's positions (local space).
/// Returns `None` when the geometry has no positions. Used by `Mesh`/`Scene` to build the
/// per-mesh culling sphere (Phase 3).
pub fn bbox(&self) -> Option<BBox> {
BBox::from_positions(&self.positions)
}
/// Returns the CPU vertices in interleaved `resources::Vertex` layout, suitable
/// for GPU upload. Assumes the geometry is valid; missing per-vertex attributes
/// are filled with defaults:
@@ -307,6 +365,26 @@ mod tests {
assert_eq!(geo.validate(), Err(GeometryError::EmptyPositions));
}
#[test]
fn bbox_of_quad() {
let bb = quad().bbox().expect("quad has positions");
assert_eq!(bb.min, [0.0, 0.0, 0.0]);
assert_eq!(bb.max, [1.0, 1.0, 0.0]);
assert_eq!(bb.center(), [0.5, 0.5, 0.0]);
// Circumradius of a unit square: half-diagonal = sqrt(0.5^2 + 0.5^2) = sqrt(0.5).
let expected = (0.5_f32 * 0.5 + 0.5_f32 * 0.5).sqrt();
assert!(
(bb.circumradius() - expected).abs() < 1e-6,
"got {}",
bb.circumradius()
);
}
#[test]
fn bbox_of_empty_is_none() {
assert!(Geometry::new(Vec::new()).bbox().is_none());
}
#[test]
fn validate_rejects_attribute_count_mismatch() {
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
+3 -1
View File
@@ -16,11 +16,13 @@
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
pub mod frustum;
pub mod geometry;
pub mod primitives;
pub mod transform;
// Re-exports
pub use geometry::{Geometry, GeometryError};
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
pub use transform::Transform;
+11 -5
View File
@@ -22,12 +22,15 @@ use std::collections::HashMap;
use std::sync::Arc;
/// Creates the two bind group layouts shared by **every** pipeline (Step 3 — decision ratified
/// "a single layout for all"). Both buffers are `Uniform`, 16-byte aligned, no dynamic offset.
/// Matching CPU types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
/// "a single layout for all"). Both buffers are `Uniform` and 16-byte aligned. Matching CPU
/// types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
/// Returns `[frame_layout, object_layout]` in renderer binding order.
///
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages.
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only.
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages. Static
/// (one shared `FrameUniforms` buffer per frame, no dynamic offset).
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only. **Dynamic**
/// (Phase 3, D12): the offset selects a 64-byte slice of the single GPU-written matrix buffer,
/// so every entity shares one buffer. The low-level `render` path passes offset 0.
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
[
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -50,7 +53,10 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
// matrix buffer (one 64-byte slice per slot), instead of a per-entity buffer.
// The low-level `render` path passes offset 0 (its identity object buffer).
has_dynamic_offset: true,
min_binding_size: None,
},
count: None,
+4 -2
View File
@@ -27,8 +27,10 @@ pub use material::Material;
pub use mesh::Mesh;
pub use texture::{Texture, TextureError};
pub use uniform::{
FRAME_UNIFORMS_SIZE, FrameUniforms, Light, LightType, MAX_LIGHTS, OBJECT_UNIFORM_SIZE,
ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform,
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, Light, LightType, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot,
OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE,
TransformSlot,
};
pub use vertex::Vertex;
+236
View File
@@ -178,6 +178,211 @@ pub struct ShadowUniform {
pub view_proj: Mat4,
}
// ============================================================================
// Phase 3 — GPU-driven entity slot buffers (Step 15)
// ============================================================================
//
// CPU-side `Pod` mirrors of the GPU buffer element types declared in `gpu_driven.wgsl` (see that
// file's "GPU buffer layouts" note). Their byte layout must match the WGSL structs **exactly** —
// including the 16-byte alignment of `vec3` (std140), which is why `vec3` fields below carry an
// explicit `_pad` so the Rust offsets line up with the WGSL ones.
//
// All buffers are fixed-capacity (`MAX_ENTITIES`), allocated once. Per frame the CPU rewrites only
// the transform slots + cull uniforms; the matrices and indirect draw args are written by the GPU.
/// Byte size of one GPU entity transform slot (`TransformSlot`).
pub const TRANSFORM_SLOT_SIZE: u64 = std::mem::size_of::<TransformSlot>() as u64;
/// Byte size of one GPU world-matrix slot (`MatSlot`).
pub const MAT_SLOT_SIZE: u64 = std::mem::size_of::<MatSlot>() as u64;
/// Byte size of one GPU local-space bounding box (`BBoxSlot`).
pub const BBOX_SLOT_SIZE: u64 = std::mem::size_of::<BBoxSlot>() as u64;
/// Byte size of one GPU indirect draw-args slot (`DrawSlot`).
pub const DRAW_SLOT_SIZE: u64 = std::mem::size_of::<DrawSlot>() as u64;
/// Byte size of the GPU cull/uniform block (`CullUniforms`).
pub const CULL_UNIFORMS_SIZE: u64 = std::mem::size_of::<CullUniforms>() as u64;
/// A packed entity transform slot (64 bytes) — the single CPU→GPU source of truth for world
/// matrices (Step 15, D13). Mirrors the WGSL `TransformSlot`.
///
/// Layout (std140, 16-byte aligned): translation (vec3 @0) + pad, flags (vec4 @16), rotation
/// (vec4 @32), scale (vec3 @48) + pad → 64 bytes.
///
/// `flags` packing: x = mesh index (stable index into the mesh list), y = active (0/1),
/// z = draw count (vertex or index count for this mesh), w = has_index (0/1).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct TransformSlot {
/// World translation (xyz). Offset 0.
pub translation: [f32; 3],
_pad0: [f32; 1],
/// Packed flags: x = mesh index, y = active, z = draw count, w = has_index. Offset 16.
pub flags: [f32; 4],
/// Rotation quaternion (x, y, z, w). Offset 32.
pub rotation: [f32; 4],
/// Non-uniform scale (xyz). Offset 48.
pub scale: [f32; 3],
_pad1: [f32; 1],
}
impl TransformSlot {
/// Builds an active transform slot from a CPU [`crate::math::Transform`] + the mesh's draw
/// metadata. `mesh_index` / `draw_count` are packed into `flags`; `active` is 1.
pub fn from_transform(
t: &crate::math::Transform,
mesh_index: u32,
draw_count: u32,
has_index: bool,
) -> Self {
Self {
translation: t.translation.to_array(),
_pad0: [0.0; 1],
flags: [
mesh_index as f32,
1.0,
draw_count as f32,
if has_index { 1.0 } else { 0.0 },
],
rotation: [t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w],
scale: t.scale.to_array(),
_pad1: [0.0; 1],
}
}
/// Builds an inactive (tombstone) slot: `active` = 0; the GPU writes identity and skips the draw.
pub fn inactive() -> Self {
Self {
translation: [0.0; 3],
_pad0: [0.0; 1],
flags: [0.0, 0.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
scale: [0.0; 3],
_pad1: [0.0; 1],
}
}
/// Stable index of the entity's mesh (from `flags.x`).
pub fn mesh_index(&self) -> u32 {
self.flags[0] as u32
}
/// Draw count (vertex or index count) packed in `flags.z`.
pub fn draw_count(&self) -> u32 {
self.flags[2] as u32
}
/// Whether the entity's mesh is indexed (from `flags.w`).
pub fn has_index(&self) -> bool {
self.flags[3] >= 0.5
}
/// Whether the slot is active (from `flags.y`).
pub fn is_active(&self) -> bool {
self.flags[1] >= 0.5
}
}
/// A 4x4 world matrix (64 B) followed by 192 B of padding = **256 B** total. The padding is
/// REQUIRED: the render pipelines read this slot through the `uniform` object group with a
/// per-slot dynamic offset, and WebGPU demands that offset be a multiple of
/// `min_uniform_buffer_offset_alignment` (256 B). A bare 64-byte matrix can never be individually
/// addressable via a uniform offset, so each slot is padded to a 256-byte boundary (capacity is
/// capped at 256 = 64 KB / 256 B). Derived on the GPU by `compute_matrices` (Step 15). Mirrors the
/// WGSL `MatSlot`. (No `Default`: the matrices are GPU-written, so the CPU never constructs a
/// `MatSlot` — this type exists only to fix the buffer's slot size. `pad` is `[f32; 48]`, beyond
/// the `N ≤ 32` bound of the array `Default` impl, so `Default` cannot be derived.)
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct MatSlot {
/// The world matrix (object → world), column-major. Offset 0.
pub m: Mat4,
/// 192 bytes of padding (alignment only, never read). Offset 64.
pub pad: [f32; 48],
}
/// A local-space axis-aligned bounding box (32 bytes), uploaded once per mesh (Step 15).
/// Mirrors the WGSL `BBoxSlot` (min vec3 @0 + pad, max vec3 @16 + pad).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct BBoxSlot {
/// Bounding box minimum corner (xyz). Offset 0.
pub min: [f32; 3],
_pad0: [f32; 1],
/// Bounding box maximum corner (xyz). Offset 16.
pub max: [f32; 3],
_pad1: [f32; 1],
}
impl BBoxSlot {
/// Builds a slot from a CPU [`crate::math::BBox`] (padding zeroed).
pub fn from_bbox(b: &crate::math::BBox) -> Self {
Self {
min: b.min,
max: b.max,
_pad0: [0.0; 1],
_pad1: [0.0; 1],
}
}
/// A degenerate (all-zero) box, used as the placeholder for a mesh with no bounding box.
pub fn empty() -> Self {
Self {
min: [0.0; 3],
max: [0.0; 3],
_pad0: [0.0; 1],
_pad1: [0.0; 1],
}
}
}
/// Indirect draw arguments for one entity (80 bytes = five u32 vec4s). The shader writes only `.a`;
/// the rest stays zero (the constant instance count of 1 lives in `.a.y`). Mirrors the WGSL `DrawSlot`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct DrawSlot {
/// `.a` = (count, instance_count, first_vertex/first_index, base_vertex); `.b..e` = 0.
pub a: [u32; 4],
/// Reserved (first_instance for the indexed block). Kept zero.
pub b: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub c: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub d: [u32; 4],
/// Reserved padding; kept zero (part of the 80-byte slot).
pub e: [u32; 4],
}
/// Per-frame GPU cull/uniform block (112 bytes), rewritten by the CPU each frame (Step 15).
/// Mirrors the WGSL `CullUniforms`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct CullUniforms {
/// Six frustum planes, unit (normal, d); inside the frustum iff dot(p, normal) + d >= 0.
pub planes: [[f32; 4]; 6],
/// Number of live entity slots (slots at/ beyond this are no-ops).
pub num_slots: u32,
/// 0 = culling disabled (every active entity draws), 1 = enabled (sphere test).
pub culling: u32,
_pad: [u32; 2],
}
impl CullUniforms {
/// Builds the cull block from six frustum planes (each a unit `[normal; d]` `[f32; 4]`) + the
/// control flags. `num_slots` = number of live entity slots.
pub fn new(planes: [[f32; 4]; 6], num_slots: u32, culling: bool) -> Self {
Self {
planes,
num_slots,
culling: if culling { 1 } else { 0 },
_pad: [0; 2],
}
}
/// Builds the cull block directly from a [`crate::math::Frustum`] (its six unit planes).
pub fn from_frustum(f: &crate::math::Frustum, num_slots: u32, culling: bool) -> Self {
Self::new(f.planes, num_slots, culling)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,4 +448,35 @@ mod tests {
assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0);
}
#[test]
fn gpu_slot_layouts_match_wgsl() {
// TransformSlot: translation @0 (vec3+pad), flags @16, rotation @32, scale @48 (vec3+pad) -> 64.
assert_eq!(size_of::<TransformSlot>(), 64);
assert_eq!(offset_of!(TransformSlot, translation), 0);
assert_eq!(offset_of!(TransformSlot, flags), 16);
assert_eq!(offset_of!(TransformSlot, rotation), 32);
assert_eq!(offset_of!(TransformSlot, scale), 48);
// MatSlot: one 4x4 column-major matrix (64 B) + 192 B padding -> 256 (padded to the
// uniform offset alignment; see the struct doc). m @0, pad @64.
assert_eq!(size_of::<MatSlot>(), 256);
assert_eq!(align_of::<MatSlot>(), 16);
assert_eq!(offset_of!(MatSlot, m), 0);
assert_eq!(offset_of!(MatSlot, pad), 64);
// BBoxSlot: min @0 (vec3+pad), max @16 (vec3+pad) -> 32.
assert_eq!(size_of::<BBoxSlot>(), 32);
assert_eq!(offset_of!(BBoxSlot, min), 0);
assert_eq!(offset_of!(BBoxSlot, max), 16);
// DrawSlot: five u32 vec4s -> 80 (a multiple of both 16 and 20, per the WebGPU indirect rule).
assert_eq!(size_of::<DrawSlot>(), 80);
// CullUniforms: 6 planes (96) + num_slots @96 + culling @100 + pad -> 112.
assert_eq!(size_of::<CullUniforms>(), 112);
assert_eq!(offset_of!(CullUniforms, planes), 0);
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
assert_eq!(offset_of!(CullUniforms, culling), 100);
}
}
+1 -1
View File
@@ -21,4 +21,4 @@ pub mod scene;
// Re-export
pub use entity::Entity;
pub use scene::Scene;
pub use scene::{Scene, SlotDraw};
+208 -1
View File
@@ -17,7 +17,7 @@
use crate::math::{Geometry, Transform};
use crate::pipeline::PipelineCache;
use crate::resources::{Camera, Lights, Material, Mesh, Texture};
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
use crate::scene::Entity;
use glam::Vec3;
use std::cell::RefCell;
@@ -37,6 +37,42 @@ struct SceneGpu {
cache: RefCell<PipelineCache>,
}
/// A stable, append-only slot for an entity in the GPU-driven slot buffers (Phase 3, Step 15).
/// Slots are **never freed**: removing an entity leaves a tombstone (its label drops out of the
/// `entities` map) so that slot indices stay stable across frames and the fixed-capacity GPU buffers
/// can be indexed by a constant slot index. The transform itself is read from the `entities` map
/// (by `label`) at pack time; the slot only carries the mesh identity + draw metadata, which are
/// stable once the entity is (re-)added.
#[derive(Clone)]
struct EntitySlot {
/// Entity label (key into the `entities` map; the transform is read from there each frame).
label: String,
/// Stable index of the entity's mesh (position in `mesh_order`) — indexes the GPU bbox buffer.
mesh_index: u32,
/// Draw count for the entity's mesh (vertex count, or index count when indexed) → packed into
/// the transform slot's `flags.z`.
draw_count: u32,
/// Whether the entity's mesh is indexed → packed into the transform slot's `flags.w`.
has_index: bool,
}
/// Per-slot draw descriptor for the GPU-driven render loop (Phase 3). Carries everything the
/// renderer needs to issue one indirect draw: the slot index (→ indirect-args + matrix buffer
/// offset), whether the slot is active (tombstones are skipped on the CPU), the mesh, and whether
/// it is indexed. The world matrix is **not** carried here — it is derived on the GPU (Step 15.5)
/// and read from the matrix buffer by the render pipeline.
#[derive(Clone)]
pub struct SlotDraw {
/// Stable slot index (offset into the indirect-args and matrix buffers, in slot units).
pub slot_index: usize,
/// Whether the slot is active (false = tombstone; the CPU skips it, the GPU zeros its draw args).
pub active: bool,
/// The entity's mesh (vertex/index buffers + material).
pub mesh: Arc<Mesh>,
/// Whether the mesh is indexed (`draw_indexed_indirect` vs `draw_indirect`).
pub has_index: bool,
}
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
@@ -69,6 +105,13 @@ pub struct Scene {
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
/// to compute the light `view_proj` and enable shadow sampling.
shadow_caster: Option<usize>,
/// Stable, append-only entity slots for the GPU-driven buffers (Phase 3). Grows only; removed
/// entities leave tombstones so slot indices stay stable.
entity_slots: Vec<EntitySlot>,
/// Map of entity label to slot index (O(1) lookup so a re-added label reuses its slot).
slot_of_label: HashMap<String, usize>,
/// Ordered mesh identifiers (index = the stable `mesh_index` used by slots and the bbox buffer).
mesh_order: Vec<String>,
}
impl Scene {
@@ -88,6 +131,9 @@ impl Scene {
lights: Lights::new(),
ambient: [1.0, 1.0, 1.0],
shadow_caster: None,
entity_slots: Vec::new(),
slot_of_label: HashMap::new(),
mesh_order: Vec::new(),
}
}
@@ -227,6 +273,7 @@ impl Scene {
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
@@ -399,6 +446,7 @@ impl Scene {
return Err(format!("Mesh ID '{}' already exists.", id));
}
self.meshes.insert(id.to_string(), mesh);
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
@@ -440,6 +488,40 @@ impl Scene {
}
self.entities
.insert(label.to_string(), Entity::new(mesh_id, transform));
// Keep the stable slot in sync (Phase 3): a re-added label reuses its slot (stable index);
// a new label appends a slot. The mesh index + draw metadata are read from the mesh.
let mesh_index = self
.mesh_order
.iter()
.position(|id| id == mesh_id)
.expect("mesh validated above") as u32;
let mesh = &self.meshes[mesh_id];
let has_index = mesh.index_buffer.is_some();
let draw_count = if has_index {
mesh.num_indices
} else {
mesh.num_vertices
};
let slot_index = match self.slot_of_label.get(label) {
Some(&i) => i,
None => {
let i = self.entity_slots.len();
self.slot_of_label.insert(label.to_string(), i);
self.entity_slots.push(EntitySlot {
label: label.to_string(),
mesh_index: 0,
draw_count: 0,
has_index: false,
});
i
}
};
self.entity_slots[slot_index] = EntitySlot {
label: label.to_string(),
mesh_index,
draw_count,
has_index,
};
Ok(label.to_string())
}
@@ -497,6 +579,91 @@ impl Scene {
pub fn entity_count(&self) -> usize {
self.entities.len()
}
// ========================================================================
// Phase 3 — GPU-driven entity slot accessors (Step 15)
// ========================================================================
// These expose the stable slot system to the Renderer: the packed transform slots (uploaded
// to the GPU each frame), the per-slot draw descriptors (for the indirect render loop), the
// per-mesh bounding boxes (uploaded once), and the slot/mesh counts. The world matrices are
// derived on the GPU; these methods only feed the CPU→GPU inputs and the draw-loop metadata.
/// Packs the stable entity slots into GPU [`TransformSlot`]s (one per slot; tombstones →
/// inactive). The renderer uploads this to the transform buffer each frame (Phase 3, Step 15).
pub fn packed_transform_slots(&self) -> Vec<TransformSlot> {
self.entity_slots
.iter()
.map(|slot| match self.entities.get(&slot.label) {
Some(entity) => TransformSlot::from_transform(
entity.transform(),
slot.mesh_index,
slot.draw_count,
slot.has_index,
),
None => TransformSlot::inactive(),
})
.collect()
}
/// Iterates the stable entity slots as per-slot draw descriptors (Phase 3). Each item carries
/// the slot index (→ indirect-args/matrix buffer offset), whether the slot is active (tombstones
/// are skipped on the CPU), the mesh, and whether it is indexed. Used by the indirect render loop.
pub fn iter_slot_draws(&self) -> impl Iterator<Item = SlotDraw> + '_ {
self.entity_slots
.iter()
.enumerate()
.map(|(i, slot)| SlotDraw {
slot_index: i,
active: self.entities.contains_key(&slot.label),
mesh: self.meshes[&self.mesh_order[slot.mesh_index as usize]].clone(),
has_index: slot.has_index,
})
}
/// The local-space bounding boxes for all registered meshes, in `mesh_index` order (one per
/// mesh). Uploaded once to the GPU bbox buffer (Phase 3). Meshes without a bounding box get a
/// degenerate (all-zero) box, which the cull pass treats as a zero-radius sphere.
pub fn mesh_bboxes(&self) -> Vec<BBoxSlot> {
self.mesh_order
.iter()
.map(|id| {
self.meshes[id]
.geometry()
.bbox()
.map(|b| BBoxSlot::from_bbox(&b))
.unwrap_or_else(BBoxSlot::empty)
})
.collect()
}
/// Stable index of `mesh_id` in the ordered mesh list (its slot in the GPU bbox buffer), if
/// the mesh is registered.
pub fn mesh_index_of(&self, mesh_id: &str) -> Option<u32> {
self.mesh_order
.iter()
.position(|id| id == mesh_id)
.map(|p| p as u32)
}
/// The registered mesh at a stable `mesh_index` (panics if the index is out of range; in
/// practice it is always valid, being derived from `mesh_order` positions).
pub fn mesh_by_index(&self, index: u32) -> &Arc<Mesh> {
&self.meshes[&self.mesh_order[index as usize]]
}
/// Number of entity slots (tombstones included) — the `num_slots` written to the cull uniforms
/// (slots at/beyond this are no-ops on the GPU).
pub fn num_slots(&self) -> usize {
self.entity_slots.len()
}
/// Number of *active* entity slots (tombstones excluded) — the live entity count.
pub fn num_active_slots(&self) -> usize {
self.entity_slots
.iter()
.filter(|s| self.entities.contains_key(&s.label))
.count()
}
}
#[cfg(test)]
@@ -586,4 +753,44 @@ mod tests {
assert!(!scene.set_entity_transform("missing", Transform::identity()));
assert!(!scene.remove_entity("missing"));
}
#[test]
fn gpu_driven_slot_bookkeeping_is_empty_when_no_entities() {
// The GPU-driven slot system starts empty; the full slot/mesh interplay requires a
// wgpu device (to build meshes) and is validated by the examples.
let scene = Scene::new();
assert_eq!(scene.num_slots(), 0);
assert_eq!(scene.num_active_slots(), 0);
assert!(scene.packed_transform_slots().is_empty());
assert!(scene.mesh_bboxes().is_empty());
assert!(scene.iter_slot_draws().next().is_none());
assert!(scene.mesh_index_of("nope").is_none());
}
#[test]
fn packed_slot_roundtrips_transform_and_is_inactive_when_tombstoned() {
// Exercises the CPU→GPU packing (GPU-independent): an active slot packs the transform +
// mesh index + draw count + index flag; a tombstoned slot packs to `inactive()`.
let t = Transform {
translation: glam::Vec3::new(1.0, 2.0, 3.0),
..Transform::identity()
};
let slot = TransformSlot::from_transform(&t, 7, 36, true);
assert_eq!(slot.mesh_index(), 7, "mesh index packed in flags.x");
assert!(slot.is_active(), "active = 1");
assert_eq!(slot.draw_count(), 36, "draw count packed in flags.z");
assert!(slot.has_index(), "indexed flag packed in flags.w");
assert!(
slot.translation
.iter()
.zip([1.0, 2.0, 3.0].iter())
.all(|(a, b)| (a - b).abs() < 1e-5)
);
let inactive = TransformSlot::inactive();
assert!(!inactive.is_active(), "inactive active-flag = 0");
assert_eq!(inactive.mesh_index(), 0);
assert_eq!(inactive.draw_count(), 0);
assert!(!inactive.has_index());
}
}
+188
View File
@@ -0,0 +1,188 @@
// # GPU-driven rendering compute shader (Phase 3, Step 15)
//
// Two compute entry points run sequentially in a single command encoder, before the render passes:
// 1. `compute_matrices` derives each entity's world matrix on the GPU from its transform slot.
// 2. `cull` decides per-entity visibility (bounding sphere vs frustum) and fills the indirect
// draw arguments (the vertex/index count, zeroed when the entity is culled or inactive).
//
// The main and shadow render passes are then 100% indirect: they read the draw slots (zero count
// = no-op) instead of a CPU-side per-entity loop.
//
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see DRAFT D12) and allocated once. Per frame the CPU
// rewrites only the transform slots and the cull uniforms; everything else is GPU-driven.
//
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B).
//
// GOTCHA — WGSL `select` argument order: `select(reject, accept, cond)` returns the SECOND
// argument when `cond` is true and the FIRST when false (the reverse of HLSL's
// `select(trueVal, falseVal, cond)`). The original cull pass wrote
// `select(u32(t.flags.z), 0u, visible)`, which zeroed the count of every VISIBLE entity (and
// would have drawn the culled ones) — the source of the black-window bug.
// 64 bytes: packed TRS, the single source of truth for world matrices.
struct TransformSlot {
translation : vec3f,
flags : vec4f, // x = mesh index, y = active, z = draw count, w = has_index
rotation : vec4f,
scale : vec3f,
};
// 256 bytes: a 64-byte world matrix followed by 192 bytes of padding. The padding is REQUIRED —
// the render pipelines read this slot through the `uniform` object group with a per-slot dynamic
// offset, and WebGPU demands that offset be a multiple of `min_uniform_buffer_offset_alignment`
// (256 bytes). A bare 64-byte matrix can never be individually addressable via a uniform offset,
// so each slot is padded to a 256-byte boundary (capacity is capped at 256 = 64 KB / 256 B).
struct MatSlot {
m : mat4x4f,
pad : array<vec4f, 12>, // 192 bytes, alignment only — never read
};
// 32 bytes: local-space axis-aligned bounding box (uploaded once per mesh).
struct BBoxSlot {
min : vec3f,
max : vec3f,
};
// 80 bytes: indirect draw arguments for one entity (a 4-u32 non-indexed / 5-u32 indexed block).
// Only `.a` is written by the shader; the rest stays zero (instance count is the constant 1 in `.a.y`).
struct DrawSlot {
a : vec4u,
b : vec4u,
c : vec4u,
d : vec4u,
e : vec4u,
};
// 112 bytes: frustum planes + control flags, rewritten by the CPU each frame.
struct CullUniforms {
planes : array<vec4f, 6>, // unit (normal, d); inside iff dot(p, normal) + d >= 0
num_slots : u32, // number of live entity slots
culling : u32, // 0 = culling disabled, 1 = enabled
_pad : vec2u,
};
// ---- Bind groups (Step 15.5) ----
// Group 0 (transforms) is shared by both entry points; group 1 (matrices) by `compute_matrices`;
// group 2 (cull uniforms + bboxes + draw args) by `cull`. Each pipeline infers the subset it uses.
@group(0) @binding(0) var<storage, read> transforms : array<TransformSlot>;
@group(1) @binding(0) var<storage, read_write> matrices : array<MatSlot>;
@group(2) @binding(0) var<uniform> cull_u : CullUniforms;
@group(2) @binding(1) var<storage, read> bboxes : array<BBoxSlot>;
@group(2) @binding(2) var<storage, read_write> draw_args : array<DrawSlot>;
// ---- Shared helpers ----
// Builds a rotation mat4x4f from a quaternion (x, y, z, w) in column-major form.
fn quat_to_mat4(q : vec4f) -> mat4x4f {
let x = q.x;
let y = q.y;
let z = q.z;
let w = q.w;
return mat4x4f(
vec4f(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y), 0.0),
vec4f(2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x), 0.0),
vec4f(2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y), 0.0),
vec4f(0.0, 0.0, 0.0, 1.0)
);
}
// Rotates a local-space vector by a quaternion (via the rotation matrix).
fn rotate_by_quat(v : vec3f, q : vec4f) -> vec3f {
let m = quat_to_mat4(q);
return (m * vec4f(v, 0.0)).xyz;
}
// World matrix = T * R * S, column-major (matches the CPU `Transform::to_matrix`, D13).
// Returns just the 4x4 matrix; the caller stores it in `matrices[i].m` (the slot's 192-byte pad
// is left at its zero-initialised value).
fn world_matrix(t : TransformSlot) -> mat4x4f {
let r = quat_to_mat4(t.rotation);
return mat4x4f(
r[0] * t.scale.x,
r[1] * t.scale.y,
r[2] * t.scale.z,
vec4f(t.translation.x, t.translation.y, t.translation.z, 1.0)
);
}
// The identity world matrix (used for inactive slots so any stale read is harmless).
fn identity_mat() -> mat4x4f {
return mat4x4f(
vec4f(1.0, 0.0, 0.0, 0.0),
vec4f(0.0, 1.0, 0.0, 0.0),
vec4f(0.0, 0.0, 1.0, 0.0),
vec4f(0.0, 0.0, 0.0, 1.0)
);
}
// Fills a draw slot with a non-zero (visible) count of `count`, or zero (culled / inactive).
// The count lands in `.a.x`; `.a.y` (instance count) is the constant 1; the rest stays zero.
fn set_draw_count(i : u32, count : u32) {
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
}
// ---- Pass 1: derive world matrices (Step 15.5) ----
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
@compute
@workgroup_size(64)
fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
let i = gid.x;
let t = transforms[i];
if (t.flags.y < 0.5) {
matrices[i].m = identity_mat();
} else {
matrices[i].m = world_matrix(t);
}
}
// ---- Pass 2: cull + fill indirect draw args (Step 15.6) ----
// Dispatched for MAX_ENTITIES. Slots at or beyond `num_slots` (and inactive slots) are zeroed so
// the indirect render passes skip them; visible slots keep their packed count.
@compute
@workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid : vec3u) {
let i = gid.x;
let t = transforms[i];
// Beyond the live slots: zero the count so the indirect draw is a no-op.
if (i >= cull_u.num_slots) {
set_draw_count(i, 0u);
return;
}
// Inactive slot (tombstone): no draw.
if (t.flags.y < 0.5) {
set_draw_count(i, 0u);
return;
}
// Culling disabled: every active entity is visible, with its packed count.
if (cull_u.culling == 0u) {
set_draw_count(i, u32(t.flags.z));
return;
}
// Culling enabled: test the entity's world bounding sphere against the frustum planes.
let b = bboxes[u32(t.flags.x)];
let center_local = (b.min + b.max) * 0.5;
// World center = translation + rotation * local center (no scale; the radius carries the scale).
let center_world = t.translation + rotate_by_quat(center_local, t.rotation);
let half_extents = (b.max - b.min) * 0.5;
let radius = length(half_extents) * max(t.scale.x, max(t.scale.y, t.scale.z));
var visible = true;
for (var p = 0u; p < 6u; p = p + 1u) {
let plane = cull_u.planes[p];
let dist = dot(plane.xyz, center_world) + plane.w;
if (dist < -radius) {
visible = false;
break;
}
}
// NOTE: WGSL `select(reject, accept, cond)` — the accept value is the SECOND argument.
// visible => full count; culled => 0.
let count = select(0u, u32(t.flags.z), visible);
set_draw_count(i, count);
}
+26
View File
@@ -35,6 +35,32 @@ pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
/// (Step 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
/// The GPU-driven rendering compute shader source (Phase 3, Step 15), embedded at compile time.
/// It carries two compute entry points — `compute_matrices` (derive world matrices) and `cull`
/// (per-entity visibility + indirect draw args) — compiled directly by the renderer (internal to
/// the library; no external file is read).
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
/// rewrites only the transform slots and the cull uniforms.
///
/// **Why 256 (and why the matrix slot is padded to 256 B):** the matrix buffer is bound to the
/// render pipeline's `uniform` object slot (group 1), which imposes TWO limits:
/// (1) a single uniform binding is capped at `max_uniform_buffer_binding_size` (64 KB on most
/// backends), and (2) a uniform offset must be a multiple of `min_uniform_buffer_offset_alignment`
/// (256 B). A 64-byte matrix can therefore never be individually addressable via a per-slot
/// dynamic offset, so each matrix slot is padded to 256 B (see `MatSlot`); with 256-byte slots,
/// 256 slots × 256 B is exactly 64 KB — the largest the single-buffer / dynamic-offset design
/// can address. (The transform / bbox / draw-args buffers are `storage` bindings with a 128 MB
/// limit and no 256-B offset rule, so they keep their natural 64 / 32 / 80 B slot sizes.)
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
pub const MAX_ENTITIES: u32 = 256;
/// Workgroup size of the GPU-driven compute shaders (matches the `@workgroup_size` in
/// `gpu_driven.wgsl`). The compute dispatch is `MAX_ENTITIES / WORKGROUP_SIZE` workgroups.
pub const GPU_WORKGROUP_SIZE: u32 = 64;
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
pub const SHADOW_MAP_SIZE: u32 = 1024;
+31
View File
@@ -54,3 +54,34 @@ fn shadow_shader_is_valid_wgsl() {
.collect();
assert_eq!(entry_names, vec!["vs_main"], "only vs_main expected");
}
/// Parses and fully validates the embedded `gpu_driven.wgsl` compute shader (Phase 3, Step 15)
/// via naga. The renderer compiles it directly into two `ComputePipeline`s (one per entry point),
/// so this offline validation is the guarantee of its validity. The contract expects exactly the
/// two compute entry points: `compute_matrices` and `cull`.
#[test]
fn gpu_driven_shader_is_valid_wgsl() {
let src = include_str!("../src/shaders/gpu_driven.wgsl");
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: parsing error: {e:?}"));
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: validation failed: {e:?}"));
let mut entry_names: Vec<&str> = module
.entry_points
.iter()
.map(|ep| ep.name.as_str())
.collect();
entry_names.sort();
assert_eq!(
entry_names,
vec!["compute_matrices", "cull"],
"the two compute entry points are expected"
);
}