This commit is contained in:
Jérôme Bousquié
2026-09-22 20:47:36 +02:00
parent 531c43a457
commit f15e920109
16 changed files with 1981 additions and 264 deletions
+288 -158
View File
@@ -1,190 +1,320 @@
# DRAFT — Étape 18 : Batching par Material (ROADMAP 4.3)
# Étape 19 — LOD (Level of Detail) par entité
> **Statut** : brouillon de conception (à valider avant implémentation).
> Couvre le 1er item de la Phase 4.3 de `docs/ROADMAP.md` :
> « Batching par Material (réduction des state changes GPU) ».
>
> **Archive** : le draft Étape 17 (rendu GPU-driven) a été vidé après validation (2026-09-22).
> Référence durable : `docs/tech/ARCHI_CPU_GPU.md` (écarts D1/D4/D5/D12 + piège D14) ;
> texte intégral : git `3a424af` (`git show 3a424af:docs/DRAFT.md`).
## Contexte
## Contexte — où on en est
Le culling (Étape 17) supprime les objets **hors écran**. Le LOD supprime le travail **invisible**
sur les objets qui sont sur l'écran : un maillage de 3840 indices qui n'occupe que 20×20 pixels
à l'écran gaspille du GPU — presque tous ses triangles ne produisent aucun pixel.
Le rendu est 100 % indirect (Étape 17) : un draw indirect **par slot**, dans l'ordre d'insertion
des entités. À chaque draw, la passe principale met à jour (`Renderer::render_scene`,
`renderer.rs` §7) :
Principe : chaque mesh peut porter **plusieurs niveaux de géométrie** (L0 = détaillée, L1, L2 =
de plus en plus simplifiée, silhouettes proches). Chaque frame, on mesure la **taille perçue**
de chaque entité (rayon de sa bounding sphere projeté en pixels) et on choisit le niveau le plus
grossier **suffisant**. La transition est protégée par une **hystérésis** (bande morte) pour
éviter le scintillement d'un objet oscillant autour d'un seuil.
| Appel | Coût |
|---|---|
| `set_pipeline(material.pipeline)` | **changement d'état** (swap de pipeline côté driver) |
| `set_bind_group(0, frame)` | constant dans la passe (re-set inutile mais pas cher) |
| `set_bind_group(1, object, [offset dynamique])` | paramètre de draw — **pas** un changement d'état |
| `set_bind_group(2, material.texture_bind_group)` | **changement d'état** (un bind group par `Material`) |
| `set_bind_group(3, shadow)` | constant |
| `set_vertex_buffer` / `set_index_buffer` | par mesh, pas cher |
| `draw_*_indirect` | le draw lui-même |
ROADMAP : item 4.3 « Level of Detail (LOD) ». Le DRAFT de l'Étape 18 (batching par material) est
remplacé par ce draft — il est validé et son contenu est dans `ARCHI_CPU_GPU.md` + git history.
→ Le nombre de changements d'état (pipeline + bind group @2) est proportionnel au **nombre
d'entités**, même quand des dizaines d'entités partagent le même `Material`. La doc du module
promet déjà « Entity sorting ... minimizes pipeline switches (batching by material) » — c'est ce
que fait cette étape.
## La contrainte d'architecture qui tout détermine
Le pass d'ombre n'a qu'**un** pipeline et l'appelle **une seule fois** avant la boucle
(`renderer.rs` §5) → déjà « batché » sur l'état : rien à y faire.
## Objectif
Réduire les changements d'état de la passe principale de **O(entités)** à **O(matériaux
distincts)**, sans changer le rendu, sans changer l'API publique, et sans toucher au chemin
GPU-driven (compute, culling, buffers, indirect) ni au pass d'ombre.
## Contraintes & principes
- **Aucune rupture d'API** : optimisation interne du `Renderer` ; aucun type/méthode publique
nouveau ; les exemples ne sont pas modifiés.
- **Aucune régression visuelle** : tous les pipelines de l'engine sont **opaques**
(`BlendState::REPLACE`, `pipeline_cache.rs`) → le depth buffer résout l'ordre → réordonner
les draws est visuellement neutre (D4).
- **Culling/indirect intacts** : les verdicts GPU (args = 0 → no-op) et les buffers ne changent
pas ; seul l'**ordre d'émission** des draws change.
- **Déterminisme** : l'ordre des draws doit rester reproductible frame après frame (slots
append-only stables, Étape 17 D9).
- Capacité ≤ 256 slots → le groupage (O(N), `HashMap` ≤ 256 entrées) est du bruit ; on le
refait **chaque frame** (toujours correct, aucun cache à invalider).
`set_vertex_buffer` est un **état de passe posé par le CPU** — le GPU ne peut pas choisir entre
deux buffers par draw indirect. Donc **plusieurs buffers par niveau est exclu** : tous les
niveaux d'un mesh vivent dans **un seul buffer de vertices** (et un seul buffer d'indices), et
le GPU sélectionne le niveau en écrivant l'**offset** (`baseVertex` / `firstVertex`) et le
`count` dans les indirect draw args — des champs que le draw indirect lit déjà. C'est ce qui
rend le LOD compatible avec l'architecture GPU-driven existante sans aucun nouveau mécanisme de
rendu : le pass `cull` écrit déjà les draw args, il écrit juste les bonnes valeurs.
## Décisions
### D1 — Clé de groupage : l'identité du `Material` (pointeur `Arc`), pas le shader_id
- Le vrai « état » qui change entre deux draws partageant un pipeline est le **bind group @2**
(texture/sampler) : chaque `Material` en possède un propre (`Material::build`). Deux
materials qui partagent le même `shader_id` partagent le pipeline (Arc, `PipelineCache`)
mais **pas** le bind group @2 → grouper au niveau pipeline ferait alterner le @2.
- Clé retenue : `Arc::as_ptr(&material)` — même pointeur ⟺ même objet `Material` ⟺ même
pipeline **et** même bind group @2 → les deux changements d'état sont figés dans le groupe.
- Le fallback `scene.default_material()` est un `Arc` unique en cache (`RefCell`) → toutes les
entités sans material forment un groupe.
- **Subtilité de durée de vie** : le groupage matérialise d'abord les `Arc<Material>` dans un
`Vec` (un par slot actif) ; les pointeurs-clés ne sont dérivés qu'ensuite. Les `Arc` restent
donc vivants pendant toute la passe → aucun pointeur ne pend (le `Arc` retourné par
`default_material()` est un clone : sans le `Vec`, il serait libéré en fin de closure).
### D1 — Le CPU décide du niveau, le GPU mappe niveau → draw args
### D2 — Ordre des groupes : première apparition en ordre de slots ; intra-groupe : ordre de slots
- On parcourt les slots dans leur ordre stable (insertion, Étape 17 D9) ; le groupe d'un slot
est créé à la **première** apparition de sa clé. `HashMap<clé, index>` + `Vec<groupe>` :
O(N), sans tri, **déterministe** et stable frame à frame (scène inchangée → ordre inchangé).
- En pratique, le premier draw de chaque groupe est le même que l'ancien premier draw du slot
→ l'impression visuelle est préservée ; seuls les draws de matériaux *différents*
s'intercalent moins.
Le CPU calcule chaque frame le niveau par entité (rayon projeté + hystérésis) et l'uploade dans
un petit buffer par slot ; le pass `cull` existe déjà et lit ce niveau pour choisir la ligne du
tableau par mesh (count + offset).
### D3 — Les slots cullés (no-op) restent émis dans leur groupe
- Le CPU ne connaît pas le verdict GPU du culling (un readback par frame stallerait la boucle
— cf. `debug_dump`) : le draw d'un slot cullé est un indirect **zéro count ≈ gratuit** ; on
l'émet quand même, dans son groupe.
- Le nombre de **draw calls** est donc inchangé (1 par slot actif) ; seul le nombre de
**changements d'état** baisse. Réduire aussi les draw calls = multi-instancing (exclu —
Périmètre).
Raisonnement :
- **Testabilité** : la décision est une fonction pure Rust `lod_level(radius_px, last, max,
thresholds)` → testable sans GPU, comme `batch_slots` (maison).
- Le CPU **réécrit déjà** les transform slots chaque frame — un niveau de plus dans la même
passe coûte ~1 flop multipli par entité.
- Aucune extension de `CullUniforms` (pas de view-projection à ajouter) — le CPU a déjà
caméra + viewport.
- L'alternative (décision GPU : viewProj + hauteur dans les cull uniforms, état hystérésis GPU)
est documentée comme **extension future** — elle n'apporte qu'un gain marginal (1 KB de
upload/frame) au prix d'un test par readback.
### D4 — Réordonnancement sûr : pipelines 100 % opaques
- `pipeline_cache.rs` crée toutes les pipelines avec `blend: Some(BlendState::REPLACE)`
(aucun alpha blending dans l'engine) et le depth write est actif partout (Étape 9) →
l'ordre de rasterisation n'a pas d'impact visuel.
- **Contrainte à documenter** (docs user + rustdoc) : si du blending transparent est ajouté un
jour, il faudra isoler les matériaux transparents (trier back-to-front en fin de passe) —
signalé ici comme prérequis d'un futur `Material.blend`. Le groupage par Material reste
correct en l'état ; seule l'ordre inter-groupes devra évoluer.
### D2 — Un seul buffer packé par mesh, **niveau 0 à l'offset 0**
### D5 — Groupage en fonction pure, testable sans GPU
- Le groupage est factorisé en fonction libre :
`fn batch_slots<K: Eq + Hash>(keys: &[K]) -> Vec<Vec<usize>>`
(groupes dans l'ordre de première apparition de la clé ; indices dans l'ordre d'entrée ;
chaque indice apparaît exactement une fois).
- Le `Renderer` l'appelle avec `keys = [*const Material]` (D1) ; les **tests unitaires**
l'appellent avec des clés `u32` → testable sans instance/device wgpu (un `Material` exige
un pipeline compilé = device ; les tests de la crate restent headless/CI-safe).
- Les tombstones (`active == false`) sont filtrés **avant** l'appel (comme aujourd'hui) :
`batch_slots` ne voit que les slots actifs.
Les vertices de tous les niveaux sont concaténés dans le `vertex_buffer` du mesh (idem indices).
Niveau 0 en tête : la voie basse `draw_entity` (`draw(0..num_vertices)`, sans LOD) et tout draw
direct existant restent **inchangés** — ils lisent le début du buffer, qui est encore L0.
### D6 — Pass d'ombre : inchangé (déjà batché)
- Un seul `shadow_pipeline`, `set_pipeline` une seule fois avant la boucle ; l'état résiduel
par slot (offset dynamique @1 + vertex/index buffers) n'est pas un changement d'état
driver → `render_shadow_map` n'est pas modifié par cette étape.
### D3 — Indices rebasés par niveau
## Mécanisme par frame (seul le point 7 change)
Chaque niveau est une géométrie indépendante (indices 0-based sur ses propres vertices). Le
packer décale les indices du niveau k de `vertex_offset(k)` ; le draw indirect du niveau k pose
`baseVertex = vertex_offset(k)` → l'index buffer concaténé fonctionne tel quel. (Un niveau reste
< 65 536 vertices, sinon erreur à l'ajout — `u16`.)
### D4 — Hystérésis **asymétrique**
Seuils descendants `t[0] > t[1] > …` (pixels) : `t[k]` = rayon **au-dessus** duquel le niveau
k+1 est exigé (équiv. : le niveau k+1 est suffisant tant que `r ≤ t[k]`). Le niveau cible sans
hystérésis est le **plus grossier** dont la borne est encore respectée (L0 n'a pas de borne).
- Passer à un niveau **plus grossier** : seulement si `r ≤ borne × 0.8` (bande morte 20 %).
- Passer à un niveau **plus fin** : immédiatement dès que `r` franchit la borne.
Si un mesh a plus de niveaux que de seuils, les niveaux excédentaires partagent la dernière
borne (clamp) — avec `[48, 12]` seuls les 3 premiers niveaux sont distincts.
Le pop « perte de détail » (le plus visible) est retardé ; le pop « retour au détail » est
immédiat. C'est la pratique standard des moteurs — et c'est testable en série de rayons
oscillants autour d'un seuil.
### D5 — Deux nouveaux petits buffers, layout des slots existants intact
Les 4 composants de `TransformSlot.flags` sont tous occupés (x=mesh, y=active, z=count,
w=has_index) — étendre le slot à 68 B ferait ripple dans tout le contrat documenté. À la place :
- `lod_levels` : `array<u32>` par **slot** (256 × 4 B = 1 KB), re-uploadé chaque frame (comme
les transforms). Nouveau `@group(2) @binding(3)` du pass `cull`.
- `lod_tables` : par **mesh** (80 B = count + pad + 4 lignes de 16 B), re-uploadé chaque frame
(mêmes motifs que le bbox buffer : petit, et le mapping mesh-index → tableau reste correct si
des meshes/niveaux sont ajoutés à chaud).
`flags.z` (count plein) reste dans le slot (métadonne ; le GPU ne l'utilise plus pour la
branche visible, qui passe par le tableau — voir D6).
### D6 — Les deux branches visibles écrivent depuis le tableau LOD
Dans `cull`, la branche « culling désactivé » et la branche « visible » remplacent
`set_draw_count(i, flags.z)` par un helper `write_level_args(i, t)` : lire `lod_levels[i]`
(clampé au count du mesh), indexer la ligne du mesh, écrire
`.a = (count_ligne, 1, 0, baseVertex_ligne)` (indexé : `baseVertex = .a.w` ; non indexé :
`firstVertex = .a.z`). Les branches zéro (slot ≥ num_slots, inactive) sont inchangées.
Conséquence : un mesh **sans** LOD (1 niveau) a un tableau d'une seule ligne = count + offset 0
→ comportement **bit-identique** à aujourd'hui.
### D7 — Le pass d'ombre partage les draw args → il dessine le niveau sélectionné
Le pass d'ombre lit le même buffer de draw args : les ombres utilisent **automatiquement** le
niveau LOD (ombres moins chères, cohérent). Accepté pour v1 ; documenté. (Si un jour on veut
des ombres au niveau fin, ce serait un deuxième buffer de draw args — hors périmètre.)
### D8 — Niveaux ≤ 4, seuils `[48, 12]` px en `conf.rs` (v1)
`MAX_LOD_LEVELS = 4`. Un mesh avec 1 seul niveau = LOD éteint pour lui (aucun changement de
comportement). Les seuils par défaut sont des constantes (pas encore configurables par scene —
extension triviale plus tard). Rayon projeté = le **même** rayon sphere que le culling
(circonradius × max scale, centre transformé) → pas de nouvelle donnée géométrique.
### D9 — Interrupteur global LOD au niveau du Renderer
`Renderer::set_lod_enabled(bool)` (défaut `true`) — coupure générale, orthogonale au mode
par mesh. Quand il est éteint, le calcul par entité est **court-circuité** (niveaux tous à
0, aucune projection calculée) : tout se dessine au niveau 0, le GPU n'est pas touché
(il lit simplement le niveau 0). Appelable à l'instanciation (juste après `Renderer::new`)
ou **à chaud** (toggle de debug au runtime). Aucun changement de signature existante.
### D10 — Niveaux **générés automatiquement** pour les géométries utilisateur
L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`,
y compris générée procéduralement par lui) et la bibliothèque **calcule les niveaux LOD sous le
capot**. Mécanisme : décimation gloutonne du maillage indexé —
```
[CPU] write_buffer TransformBuffer / CullUniforms / (BBoxBuffer si génération changée)
[Compute 1] compute_matrices [Compute 2] cull (inchangés)
[Ombre] (si caster) draw indirect par slot, 1 pipeline (inchangé — D6)
[Main] slots groupés par Material (D1/D2) :
groups = batch_slots(keys)
pour chaque groupe G :
set_pipeline(G) + set_bind_group(0) + set_bind_group(2, G) + set_bind_group(3)
pour chaque slot s de G :
set_bind_group(1, [offset(s)]) + set_vertex/set_index + draw_indirect(s)
[Queue] submit
Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
```
- Triangles (non dégénérés) triés par aire croissante ; on retire le plus petit jusqu'à la
cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide
est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable
(clamp `[1, T]`).
- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces
survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs =
valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ;
le LOD sacrifie la précision UV au profit de la silhouette).
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
Triangles dégénérés jetés.
- **Déterministe** (tri stable, tie-break par index d'origine) → counts reproductibles en tests.
- Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et
le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue).
**L0 reste la géométrie exacte de l'utilisateur, byte-pour-byte** — seule la voie basse et les
niveaux générés sont concernés par la simplification. Coût : setup uniquement (quelques ms pour
des milliers de triangles), jamais par frame. Ratios par niveau : 0.5^k (50 %, 25 %, 12.5 %).
`add_mesh_lod(id, level, geometry)` (niveaux explicites) est la voie **générale** : elle
**remplace** un niveau existant ou **ajoute** le niveau libre suivant (≤ 4 ; même jeu
**d'attributs** et même **indexation** que L0 — validé). Applicable à **tout** mesh, y compris
Auto — ex. : remplacer un niveau décimé d'une primitive de la maison par une régénération à
la tessellation divisée par 2 (plus propre que la décimation pour les meshes réguliers).
`create_mesh_with_lod` est l'ergonomie qui génère les niveaux 1..n à la création ; les deux
voies sont combinables.
### D11 — Mode LOD par mesh (`Off` / `Auto` / `Manual`) — contrat pour les meshes updatables
Le mesh porte un mode interne : `Off` (1 niveau = comportement d'aujourd'hui), `Auto` (niveaux
décimés, D10), `Manual` (niveaux fournis, `add_mesh_lod`).
Question posée : **un mesh updatable** (positions/normals réécrits par frame) — faut-il pouvoir
désactiver le LOD au niveau mesh ? **Oui, et automatiquement** : les niveaux générés sont
**dérivés de l'ancien L0** ; si la géométrie change, ils représentent une forme obsolète. Contrat
réservé (pas d'API d'update dans v1, mais le design le prévoit) : toute future
`mesh.update_geometry(…)` **réinitialise le mode à `Off`** (niveaux jetés, buffer packé reconstruit
avec le seul nouveau L0, tableau à 1 ligne) — l'utilisateur n'a rien à désactiver : un mesh
mis à jour par frame est simplement un mesh mono-niveau. Documenter cette règle quand l'API
d'update existera.
## Flux de données (changement en surgraisse)
```
CPU (chaque frame) GPU
───────────────────── ───
Transforms (slots) ──upload──► transforms
CullUniforms (planes,…) ──upload──► cull_u
BBoxes (par mesh) ──upload──► bboxes
** LodLevels (par slot) ──upload──► lod_levels (binding 3)
** LodTables (par mesh) ──upload──► lod_tables (binding 4)
│
compute_matrices (inchangé) │
cull : zéro si culled/inactive│
sinon write_level_args └──► draw_args
│
main pass (Étape 18 : groupé par material, set_vertex_buffer
du buffer packé par slot, draw indirect lit count+offset)
```
## Gain attendu (changements d'état / frame, passe principale)
## Niveaux dans le démo
| Scène | Avant (par entité) | Après (par matériau distinct) |
|---|---|---|
| `demo` (7 entités, ~5 materials distincts) | 7 × (pipeline + @2) | 5 × (pipeline + @2) |
| 200 cubes / 1 material | 200 × (pipeline + @2) | **1** × (pipeline + @2) |
Le démo exerce l'**API utilisateur demandée** : géométrie L0 déclarée, niveaux générés sous le
capot (D10). Les primitives de la maison sont des « géométries utilisateur » pour le LOD :
Le `set_bind_group(1, offset dynamique)` et les vertex/index buffers restent par draw
(paramètres de draw, pas d'état driver). Le gain est maximal quand peu de matériaux distincts
pour beaucoup d'entités — le cas « instancé » en attendant le multi-instancing.
| Mesh | L0 (déclaré) | L1, L2 (auto : décimation 50 % / 25 %) |
|-----------|--------------|-----------------------------------------|
| sphere | 32×20 | générés par `decimated` |
| cylinder | 32 | générés |
| cone | 32 | générés |
| torus | 24×16 | générés |
| icosphere | subdiv 2 | générés (triangles quasi uniformes → décimation propre) |
| cube,plane| — | `create_mesh` simple, 1 niveau |
## Fichiers touchés
Setup du démo : `create_mesh_with_lod(id, geo, mat, 3)` pour les 5 meshes tessellés, `create_mesh`
pour cube/plane. (Les niveaux « re-tessellés » du draft initial servent aux tests unitaires de
l'issue de secours `add_mesh_lod`, pas au démo.)
| Fichier | Action |
|---------|--------|
| `lib/src/core/renderer.rs` | `render_scene` : boucle plate → `batch_slots` + boucle par groupe ; fonction libre `batch_slots` + tests unitaires ; rustdoc du module alignée |
| `docs/user/gpu-driven.md` | + note : draws groupés par matériau (interne, sans effet API) ; contrainte blending (D4) |
| `docs/tech/ARCHI_CPU_GPU.md` | + note : la passe main émet les draws groupés par Material (Étape 18) |
| `docs/ROADMAP.md` | Coche « Batching par Material » (4.3) + date |
## API publique (ajouts — aucune rupture)
## Périmètre exclus (reportés)
```rust
// Scene
/// Crée un mesh dont les `levels` niveaux (2..=4) sont générés automatiquement
/// par décimation (D10) de la géométrie fournie. L0 = la géométrie, exacte.
pub fn create_mesh_with_lod(
&mut self, id: &str, geometry: Geometry, material: Option<&str>, levels: u8,
) -> Result<String, String>;
- **Multi-instancing** (1 draw par groupe mesh+material, matrice par instance) : demande un
changement de shader (matrice instanciée) + draw instancié par groupe — étape distincte,
plus lourde (déjà reportée depuis l'Étape 17).
- **LOD, HDR + tone mapping** (items 2-3 de la Phase 4.3) : étapes distinctes.
- **Sauter les no-ops par readback** (réduire les draw calls, pas seulement les états) : un
readback synchro stalle la boucle de rendu → rejeté en v1.
- **Blending/transparent** : hors engine actuel (D4).
/// Remplace (ou ajoute, si `level` est le niveau libre suivant) un niveau **explicite** du mesh
/// `id` (≤ 4 niveaux ; même jeu d'attributs et même indexation que L0 ; pack total < 65 536
/// vertices). Reconstruit les buffers packés (D2). Combinable avec `create_mesh_with_lod`.
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String>;
## Risques & mitigations
// Renderer
/// Interrupteur global LOD (D9). Défaut `true`. `false` → tout se dessine au niveau 0,
/// calcul par entité court-circuité. Appelable à l'instanciation ou à chaud.
pub fn set_lod_enabled(&self, enabled: bool);
```
| Risque | Mitigation |
|--------|-----------|
| **Réordonnancement → rendu différent** | D4 : pipelines opaques (`REPLACE`) + depth write → le depth buffer résout l'ordre ; vérifié headless + visuellement (plan 4-5), draw args `debug_dump` inchangés (plan 6). |
| **Pointeur-clé `Arc::as_ptr` en pend** | D1 : les `Arc` sont matérialisés dans un `Vec` vivant pendant la passe ; les groupes reconstruits chaque frame → aucune hypothèse de stabilité entre frames. |
| **Groupage O(N) par frame** | N ≤ 256, `HashMap` ≤ 256 entrées → bruit ; pas de cache (D5 : toujours correct). |
| **Ordre des groupes non déterministe** | D2 : première apparition sur des slots stables (Étape 17 D9) → déterministe ; verrouillé par test (D5). |
| **Matériau transparent futur** | D4 documenté comme contrainte ; le groupage par Material reste correct, seule l'ordre inter-groupes devra évoluer. |
`create_mesh` (sans LOD) reste la voie par défaut : un mesh simple se comporte exactement comme
aujourd'hui. Le LOD s'active **explicitement** (`create_mesh_with_lod`) ou par niveaux explicites
(`add_mesh_lod`) — pas de changement de comportement pour un utilisateur qui ne demande rien.
## Plan de vérification
## Plan de code
1. `cargo build --workspace` — OK, sans avertissement.
2. `cargo test --workspace` — vert, y compris les nouveaux tests `batch_slots` (première
apparition, ordre intra-groupe, chaque indice une fois, entrées vides, un seul groupe,
tout distinct).
3. `cargo fmt --all -- --check` — clean.
4. **`demo` headless** (`WGPU_BACKEND=vulkan timeout 10 ... --example demo`) → exit 0.
5. **A/B changements d'état** : compteur temporaire de `set_pipeline` par frame (sous
`WSG_DEBUG_DUMP`) — avant : 7 dans le `demo` ; après : nombre de materials distincts.
(Compteur retiré après mesure, ou conservé sous `WSG_DEBUG_DUMP` au choix.)
6. **Rendu identique** : `demo` avant/après → même image (opaque, D4) ; `debug_dump` :
draw args inchangés (le culling n'est pas touché).
7. Check liens doc — 0 lien cassé.
| Fichier | Changement |
|---|---|
| `resources/uniform.rs` | `LodTable` (80 B : `count` + 4 × `LodRow{base, count}` 16 B) + `LOD_TABLE_SIZE` ; export. |
| `shaders/gpu_driven.wgsl` | structs `LodTable`/`LodRow` ; bindings 3+4 group(2) ; helper `write_level_args` ; `cull` : les 2 branches visibles l'appellent ; commentaire header mis à jour. |
| `math/geometry.rs` | `Geometry::decimated(target) -> Geometry` (D10, pure) + tests ; `Geometry::generate_lod_levels(levels)` (ratios 0.5^k). |
| `resources/mesh.rs` | champ `lod_geometries: Vec<Arc<Geometry>>` + `lod_mode` (D11) ; **packer** : concatène `to_vertices()` des niveaux → `vertex_buffer` packé ; indices rebasés concaténés → `index_buffer` packé ; `num_*` = niveau 0 ; `LodTable::from_geometry` par mesh. |
| `scene/scene.rs` | `create_mesh_with_lod` + `add_mesh_lod` (validation + rebuild packé via le contexte GPU de la scene) ; `packed_lod_levels()` (par frame, par slot — décision D1) ; `mesh_lod_tables()` (par mesh, comme `mesh_bboxes`). |
| `core/renderer.rs` | buffer `lod_levels_buffer` (1 KB) + `lod_table_buffer` ; layout group(2) +2 bindings ; upload par frame ; **D1** : `lod_levels: Vec<u32>` état par slot + fn pures `lod_level`/`projected_radius_px` (view + proj[1][1] + hauteur viewport, déjà disponibles) ; **D9** : champ `lod_enabled` + `set_lod_enabled` (court-circuit du calcul) ; `debug_dump` : readback du buffer de niveaux. |
| `utils/conf.rs` | `MAX_LOD_LEVELS = 4` ; `LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0]`. |
| `examples/demo.rs` | `create_mesh_with_lod(id, geo, mat, 3)` pour les 5 meshes tessellés (D10) ; cube/plane en `create_mesh`. |
## Critères d'acceptation (definition of done)
### Fonction pure (testable, module `math` ou `resources::lod`)
- [x] `render_scene` émet les draws **groupés par Material** (D1/D2) ; pass d'ombre inchangé (D6).
- [x] `batch_slots` fonction pure testable (D5) + tests unitaires verts (6 tests, CI-safe).
- [x] Aucune rupture d'API publique ; exemples non modifiés.
- [x] Rendu identique (D4) — vérifié headless ; draw args `debug_dump` inchangés (6/36/3840/960/384/192/2304).
- [x] Changements d'état réduits : compteur `set_pipeline` du `demo` = 3 = nb de materials distincts (avant : 7).
- [x] Docs : `gpu-driven.md` § « Batching by material » + `ARCHI_CPU_GPU.md` + ROADMAP 4.3 coché.
```rust
/// Niveau cible : le plus grossier dont la borne est respectée, avec hystérésis asymétrique (D4).
/// `thresholds[k]` = rayon px au-dessus duquel le niveau k+1 est exigé (descendants) ;
/// niveaux au-delà du nb de seuils : borne clamped (dernier seuil).
/// `last` = niveau de la frame précédente. Renvoie un niveau ≤ `max_level`.
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32;
/// Rayon projeté en pixels de la bounding sphere (même sphere que le culling, D8).
/// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0).
pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Mat4, height_px: f32) -> f32;
/// Décimation gloutonne (D10, Règle A) : renvoie un `Geometry` indexé avec `target_triangles`
/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel),
/// normales lisses recalculées. Déterministe.
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
```
## Vérification
1. **Unit tests `lod_level`** (sans GPU) : seuils simples (r > t0 → 0, entre → 1, < t1 → 2) ;
**anti-scintillement** : série de rayons oscillant ±10 % autour d'un seuil → le niveau reste
stable (montée immédiate, descente retardée par le facteur 0.8) ; clamp `max_level` ;
`INFINITY → 0`.
2. **Unit tests `projected_radius_px`** : objet à l'origine, caméra sur Z → valeur analytique ;
invariance d'échelle (objets 10× plus gros 10× plus loin → même rayon px).
3. **Unit tests du packer** : offsets/counts par niveau sur un cas 2 niveaux ; niveau 0 à
offset 0 ; indices rebasés.
4. **Unit tests `decimated`** : grille 2×2 (4 triangles de tailles différentes) → cible 2 :
count exact, positions ⊆ entrée, les plus petits retirés en premier ; entrée non indexée →
sortie indexée weldée (coins dupliqués fondus, UV = premier rencontré) ; triangle dégénéré
en entrée → jeté ; **déterminisme** (deux appels → sortie identique) ; icosahèdre 20 → 10 :
count exact + bbox dans l'originale ; icosphere subdiv 1 → 50 % : count exact ; clamp : cible
0 ou > T → clone ; `validate()` OK.
5. **GPU A/B** : entités temporaires à z = +6 / +30 / +60 (démo) → readback `debug_dump` :
niveaux 0 / 1 / 2 selon la distance et **draw args = count de la ligne du tableau
CPU-uploadé** (self-cohérent, pas de count codé en dur — les niveaux auto ont des counts
déterministes mais calculés) ; entité culled (hors frustum) → 0 quel que soit le niveau.
6. **D9** : `set_lod_enabled(false)` au démarrage → draw args **bit-identiques** à l'exécutable
pré-LOD (tous niveaux 0) ; toggle à chaud → bascule visible au readback.
7. **D11** (contrat) : `add_mesh_lod` s'applique à tout mesh (y compris Auto — le niveau
explicite remplace le niveau décimé au même index) ; le mode `Off` (1 niveau) = comportement
d'aujourd'hui ; un mesh updatable futur repasse en `Off` (D11).
8. **Régression** : 69 tests verts ; démo silencieux par défaut ; batching (Étape 18) intact
(compteur de switches inchangé) ; mesh à 1 niveau → draw args **bit-identiques** à avant
l'étape (D6).
9. **Visuel** : zoom arrière sur le démo → les primitives basses du ring passent au niveau
simplifié sans pop visible (silhouettes décimées proches + hystérésis D4).
## Critères d'acceptation
- [ ] `lod_level` + `projected_radius_px` pures, unit-testées (dont la série oscillante).
- [ ] `decimated` pure, déterministe, unit-testée (counts, retire-les-plus-petits, weld + UV
premier rencontré, clamp `[1, T]`, dégénérés, `validate()` OK).
- [ ] Packer multi-niveaux : un buffer packé, niveau 0 à offset 0, indices rebasés.
- [ ] `create_mesh_with_lod` (niveaux auto) + `add_mesh_lod` (niveaux explicites, ≤ 4,
validation attributs/indexation/65536, combinables) ; aucune rupture d'API ;
`create_mesh` seul = comportement d'aujourd'hui.
- [ ] `Renderer::set_lod_enabled` (D9) : `false` → bit-identique à pré-LOD ; toggle à chaud.
- [ ] Pass `cull` : les 2 branches visibles écrivent depuis le tableau LOD ; mesh 1 niveau
bit-identique au comportement actuel.
- [ ] A/B GPU : niveaux 0/1/2 choisis selon la distance, counts correspondants, culling intact.
- [ ] 69+ tests verts, `cargo fmt` clean, démo silencieux par défaut.
- [ ] Docs : `gpu-driven.md` (section LOD + contrainte buffers) ; `ARCHI_CPU_GPU.md` ; ROADMAP
4.3 coché.
- [ ] DRAFT.md vidé après validation utilisateur (convention de la maison).
## Hors périmètre (suivant)
- **API d'update de géométrie** (mesh updatable par frame) — D11 réserve le contrat
(update ⇒ LOD réinitialisé à `Off`) mais l'API d'update elle-même est un autre sujet.
- Décision du niveau **côté GPU** (viewProj dans CullUniforms + état GPU) — option D1 future.
- Seuils **configurables par scene/mesh** (v1 : constantes `conf.rs`).
- Réglage fin du **qualité/cout** de la décimation (poids par arête, quadric edge collapse)
— v1 : aire + flip, suffisant pour les silhouettes.
- Geometric morphing / transitions douces entre niveaux (les pops restent discrets avec les
primitives procédurales ; le morphing est un sujet à part entière).
- LOD de **shader** (simplification du lighting par distance) — autre item (HDR/tone mapping).
- Ombres au niveau fin (D7 accepte le niveau LOD dans les ombres).
+1 -1
View File
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 4.3 Optimisations
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé)
- [ ] Level of Detail (LOD)
- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par décimation gloutonne au setup (`Geometry::decimated`/`generate_lod_levels`, Règle A : retrait des plus petits triangles, weld par position exacte, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame)
- [ ] HDR + Tone Mapping (optionnel)
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
+24 -4
View File
@@ -16,7 +16,7 @@ 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 : ACTUEL (implémenté — Phase 3 du ROADMAP, Étape 17, validé 2026-09-22).**
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étapes 17–19, validé 2026-09-22).**
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
@@ -37,6 +37,16 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> le pass d'ombre — un seul pipeline — est inchangé). Réordonnancement sûr car tous les pipelines
> sont opaques (`BlendState::REPLACE`) ; les no-ops cullés restent émis dans leur groupe.
> Détail : `docs/user/gpu-driven.md` § « Batching by material ».
> **LOD (Étape 19, 2026-09-23)** : le pass `cull` remplit désormais les arguments indirects à partir
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
> le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont
> générés par décimation gloutonne au setup (`Geometry::decimated` : suppression des plus petits triangles,
> soudure par position exacte, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 :
> 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
> § « Level of Detail ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
@@ -45,7 +55,8 @@ Pour éviter les goulets d'étranglement dus aux allers-retours sur le bus PCIe,
Côté CPU (Source de Vérité)
- Ce qu'il conserve : Les données logiques et les transformations brutes des objets (ex: Vec<Transform> contenant la position, la rotation, et l'échelle).
- Ce qu'il fait : Il gère la logique de jeu, l'IA, le réseau et les interactions globales.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires (le culling frustum reste 100 % GPU).
- Ce qu'il fait en plus (LOD, Étape 19) : le **choix du niveau de détail** par entité — O(N) projections de sphères en pixels + hystérésis, coût négligeable. C'est l'unique décision de visibilité/détail conservée côté CPU : elle dépend de la taille écran (un choix artistique), pas de la géométrie, et l'hystérésis a besoin de l'état de la frame précédente.
Côté GPU (Exécutant Autonome)
- Ce qu'il calcule : Les World Matrices, le Frustum Culling, et la génération des listes de dessin indirectes.
@@ -64,11 +75,11 @@ L'exécution des tâches s'appuie sur une structure séquentielle stricte au sei
```
Étape par étape :
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par `queue.submit()` qui garantit la séquence d'exécution), **et le niveau LOD de chaque slot** (1 u32/slot, Étape 19). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps).
- Pass de Calcul (Compute Pass) :
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
- Frustum Culling GPU : 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).
- Remplissage du Buffer Indirect : le pass `cull` lit le **niveau LOD** du slot, en choisit la ligne dans la table LOD du mesh (`LodTable` : 4 lignes d'offsets/comptes en unités d'élément) et écrit les arguments dans le `DrawSlot` (80 o) — mis à 0 si l'objet est cullé ou inactif (no-op). Le niveau 0 porte les comptes du mesh complet, donc LOD désactivé ≡ pré-LOD bit-à-bit.
- Pass de Rendu (Render Pass) :
- Le CPU émet **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.
@@ -89,6 +100,15 @@ L'implémentation utilise les buffers wGPU suivants (tous créés par le `Render
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
| CullUniforms | 6 plans du frustum + `num_slots` + `culling` (112 o) | Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
| Lod Levels | Niveau LOD par slot choisi par le CPU (4 o/slot) | Storage Buffer (read-only) | CPU → GPU (chaque frame) |
| Lod Tables | Table par mesh : `count` + 4 lignes de 16 o (offset/compte d'éléments) (80 o/mesh) | Storage Buffer (read-only) | CPU → GPU (quand l'ensemble des meshes change) |
**Buffers de géométrie LOD (Étape 19)** : les niveaux d'un mesh sont **empilés** — un seul buffer vertex et un
seul buffer index par mesh, contenant les niveaux concaténés (L0, L1, …). Les lignes de la table LOD portent
les offsets en **unités d'élément** (premier vertex / premier index), car les arguments `drawIndirect*` de
WebGPU s'expriment en éléments, et le buffer est lié en entier à l'offset 0. Conséquences : un mesh LOD ne
peux dépasser 65 535 sommets au total (indices u16) et 4 niveaux (`MAX_LOD_LEVELS`) ; le mélange indexé/non-indexé
dans un même mesh est supporté (la commande de draw par slot suit le niveau choisi par le CPU).
## Liens
+87 -5
View File
@@ -2,7 +2,8 @@
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**.
draw calls. This page explains what that means for you, how to opt into **frustum culling**, and how the
**Level of Detail (LOD)** system works.
## What runs on the GPU
@@ -13,7 +14,9 @@ Each frame, before the render passes, two compute passes run over a fixed-capaci
(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).
vertex/index count, zeroed when the entity is culled or inactive). With LOD on (the default), the
count it writes comes from the mesh's **LOD table** at the level the CPU chose for the slot this
frame — see [Level of Detail](#level-of-detail-lod).
The main and shadow render passes are then **100 % indirect**: each active slot issues one
indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero
@@ -77,6 +80,77 @@ out of view (false negative), but it will **never cull an object that is actuall
(false positive). For tight culling you would need per-mesh sphere fitting or per-face tests,
which are out of scope for v1.
## Level of Detail (LOD)
LOD is **on by default**: distant entities automatically draw a coarser version of their mesh, so
the GPU stops spending fillrate and vertex work on detail the eye cannot see. It is a quality
feature with a performance payoff — unlike culling, it is safe to leave on because the
worst case (a level chosen too fine) is exactly what you would have drawn anyway.
### How it works
LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity decision kept on the
CPU):
1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated
automatically from level 0 by greedy decimation (`Geometry::generate_lod_levels`): the smallest
triangles are removed first (no edge map, no crease handling — a subset of the faces of a valid
mesh is valid), duplicate corners are welded, and the levels are **packed into the mesh's single
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
finer is immediate, going coarser only below 80 % of the bound (a 20 % dead band) — which is what
prevents flicker when an entity hovers around a threshold. Default thresholds: 48 px and 12 px
(bigger than 48 px → full detail; smaller than 12 px → coarsest).
3. **Per frame (GPU).** The `cull` pass reads the slot's level, looks up the matching row of the
mesh's LOD table (element-unit offsets + counts), and writes the indirect draw arguments from it.
### Using it
```rust
// One level (the default): create_mesh is unchanged.
let id = scene.create_mesh("hero", &geometry, &material)?;
// Auto-generated levels 1..3 (decimated at half, quarter, eighth the triangle count).
let id = scene.create_mesh_with_lod("hero", &geometry, &material, 4)?;
// Or supply your own levels (same attributes, same indexed-ness as level 0).
scene.add_mesh_lod("hero", 1, &my_coarse_geometry)?;
```
Toggle at runtime (off = every slot forced to level 0 = byte-identical rendering to the pre-LOD
engine — the level-0 rows carry the full-mesh counts, so nothing else changes):
```rust
app.renderer().set_lod_enabled(false);
```
### Constraint: packed LOD buffers
A level is **not a separate buffer**: the mesh's levels are concatenated into its one vertex buffer
and one index buffer, and the per-mesh LOD table stores each level's offsets/counts. Two
consequences:
- **u16 indices** → the *sum* of all levels must stay under 65 535 vertices (the scene rejects a
level set that would not fit, with a clear error);
- **at most 4 levels** per mesh (`MAX_LOD_LEVELS`, also the size of the GPU table row).
Indexed-ness: levels supplied through `add_mesh_lod` must match level 0's indexed-ness (validated).
Auto-generated levels from a **non-indexed** level 0 are indexed anyway (decimation rebuilds with
indices), and the packed buffer supports that mix — the per-slot draw command follows the level the
CPU chose (the shadow pass always uses the level-0 command, so casters stay at full detail).
### How to verify LOD with the debug dump
The debug dump (below) prints, per frame: the per-slot **levels** and each mesh's **LOD table**
(rows = `vertex_offset / vertex_count / index_offset / index_count`, element units). The clean test
is to **zoom the camera out**: the entities' perceived size drops below the thresholds, the levels
step up (0 → 1 → 2), and the indirect argument counts shrink to the corresponding rows — e.g. the
demo's 3 840-index sphere drops to 1 824, then 912 — while the levels stay **stable frame to frame**
(hysteresis holding). Verified 2026-09-23: at the demo's default distance every entity sits at
level 0 with full counts; zoomed to 4.6×, all multi-level meshes select level 1 with exactly their
L1 rows, stable across frames.
## Debugging the GPU path
If something looks wrong — a missing object, a black window — the GPU-side slot tables can be
@@ -88,9 +162,10 @@ 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
indirect draw arguments, the mesh bounding boxes, the cull uniforms, the per-slot **LOD levels**
and the per-mesh **LOD tables**. A slot whose vertex count reads `0` was zeroed by the cull pass
(culled, inactive, or beyond `num_slots`); a full count means the entity is drawn — and with LOD
on, the *row* the count comes from tells you the selected level (see above). In the `demo` example the dump is opt-in via an environment
variable, so the showcase stays silent by default:
```sh
@@ -137,6 +212,13 @@ and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
for a simple scene (the demo has 7).
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
levels at 65 535 vertices, and there are at most 4 levels. The decimation (smallest-triangle
removal + welding) is a setup-time cost only (a few ms for thousands of triangles); the
per-frame cost is one sphere projection per entity on the CPU.
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
coarser. `set_lod_enabled(false)` is the escape hatch.
Culling is a **performance** feature, not a visual one: with it off you get the same image with
the indirect-draw machinery still active.
+25 -6
View File
@@ -11,7 +11,11 @@
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
//! so the shadow of the cube and the colored light halos are all visible,
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly.
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly,
//! * **LOD** (Step 19): the rounded primitives are created with three levels each
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's
//! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and
//! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen.
//!
//! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with:
@@ -118,23 +122,38 @@ impl AppHandler for Demo {
app.scene.add_entity("ground", "ground_mesh").unwrap();
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
// The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the
// rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh,
// levels 1.. are auto-generated by greedy decimation at halving targets (D10), all
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
// switches levels on the fly (asymmetric hysteresis, D4).
app.scene
.create_mesh("cube_mesh", cube(0.8), Some("solid_mat"))
.unwrap();
app.scene
.create_mesh("sphere_mesh", uv_sphere(0.55, 32, 20), Some("stripes_mat"))
.create_mesh_with_lod(
"sphere_mesh",
uv_sphere(0.55, 32, 20),
Some("stripes_mat"),
3,
)
.unwrap();
app.scene
.create_mesh("ico_mesh", icosphere(0.5, 2), Some("solid_mat"))
.create_mesh_with_lod("ico_mesh", icosphere(0.5, 2), Some("solid_mat"), 3)
.unwrap();
app.scene
.create_mesh("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"))
.create_mesh_with_lod("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"), 3)
.unwrap();
app.scene
.create_mesh("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"))
.create_mesh_with_lod("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"), 3)
.unwrap();
app.scene
.create_mesh("torus_mesh", torus(0.42, 0.16, 24, 16), Some("solid_mat"))
.create_mesh_with_lod(
"torus_mesh",
torus(0.42, 0.16, 24, 16),
Some("solid_mat"),
3,
)
.unwrap();
place("cube_e", "cube_mesh", app, 0);
+2 -2
View File
@@ -7,11 +7,11 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
| Module | Responsibility |
|--------|---------------|
| **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`) |
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box, multi-level LOD via packed vertex/index buffers), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`/`LodRow`/`LodTable`) |
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
| **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) |
| **math** | Transform, Geometry (per-attribute mesh data + AABB, greedy decimation `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) |
| **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
+230 -13
View File
@@ -22,25 +22,26 @@
use crate::core::Context;
use crate::core::Frame;
use crate::math::Frustum;
use crate::math::lod::{lod_level, projected_radius_px};
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::{
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,
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE,
SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot,
};
use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, MAX_ENTITIES, SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE,
SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS,
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
};
use glam::{Mat4, Vec3, Vec4};
use std::cell::Cell;
use glam::{Mat4, Quat, Vec3, Vec4};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
@@ -108,6 +109,12 @@ pub struct Renderer {
draw_args_buffer: wgpu::Buffer,
/// GPU cull uniforms (`UNIFORM | COPY_DST`): frustum planes + control flags; rewritten each frame.
cull_uniform_buffer: wgpu::Buffer,
/// GPU per-slot LOD levels (`STORAGE | COPY_DST`): one u32 per entity slot, the CPU's per-frame
/// level decision (Step 19, D8); read by `cull` (group 2, binding 3).
lod_levels_buffer: wgpu::Buffer,
/// GPU per-mesh LOD tables (`STORAGE | COPY_DST`): one 80-byte [`LodTable`] per mesh in
/// `mesh_order` order; read by `cull` (group 2, binding 4) to map a level to its draw args.
lod_tables_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).
@@ -127,6 +134,17 @@ pub struct Renderer {
/// frame. Interior-mutable (all-`&self` API); exposed through `debug_dump` for the
/// state-change A/B measurement (Étape 18 verification).
debug_pipeline_switches: Cell<u32>,
/// Whether LOD is enabled (Step 19, D8). When `false` the CPU writes level 0 for every slot
/// each frame, and the GPU indirect args are byte-identical to the pre-LOD behavior
/// (level-0 rows carry the full-mesh counts). Interior-mutable (all-`&self` API).
lod_enabled: Cell<bool>,
/// Per-slot level of the PREVIOUS frame — the hysteresis state of [`lod_level`] (Step 19, D4):
/// going coarser requires a 20 % dead band measured against this value. Interior-mutable
/// (all-`&self` API); resized when the slot count grows (entity append).
last_lod_levels: RefCell<Vec<u32>>,
/// Viewport height in pixels (Step 19, D9): the unit of the LOD projected-size test. Set from
/// the initial surface size in `new` and refreshed by `resize_depth` on window resize.
viewport_height: u32,
}
impl Renderer {
@@ -310,6 +328,27 @@ impl Renderer {
},
count: None,
},
// Step 19 (LOD): per-slot levels (storage read) + per-mesh tables (storage read).
wgpu::BindGroupLayoutEntry {
binding: 3,
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: 4,
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_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -386,6 +425,25 @@ impl Renderer {
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
// Step 19 (LOD): per-slot levels (one u32 per entity slot, CPU-written each frame) and
// per-mesh tables (one 80-byte LodTable per mesh, mesh_order order). `COPY_SRC` lets
// `debug_dump` read them back. The cull pass maps each slot's level to its draw args.
let lod_levels_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU LOD levels"),
size: MAX_ENTITIES as u64 * 4,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let lod_tables_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU LOD tables"),
size: MAX_ENTITIES as u64 * LOD_TABLE_SIZE,
usage: wgpu::BufferUsages::STORAGE
| 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
@@ -423,6 +481,15 @@ impl Renderer {
binding: 2,
resource: draw_args_buffer.as_entire_binding(),
},
// Step 19 (LOD): the level + table buffers (whole-buffer bindings).
wgpu::BindGroupEntry {
binding: 3,
resource: lod_levels_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: lod_tables_buffer.as_entire_binding(),
},
],
});
let matrix_object_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -464,12 +531,17 @@ impl Renderer {
bbox_buffer,
draw_args_buffer,
cull_uniform_buffer,
lod_levels_buffer,
lod_tables_buffer,
transform_bg,
matrices_bg,
cull_bundle_bg,
matrix_object_bg,
cull_enabled: Cell::new(false),
debug_pipeline_switches: Cell::new(0),
lod_enabled: Cell::new(true),
last_lod_levels: RefCell::new(Vec::new()),
viewport_height: height,
};
// 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.
@@ -511,6 +583,8 @@ impl Renderer {
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
self._depth_texture = depth_texture;
self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
self.viewport_height = height;
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -704,16 +778,40 @@ impl Renderer {
bytemuck::cast_slice(&transform_slots),
);
// 2b. LOD (Step 19, D8): the CPU picks each slot's detail level from the projected
// bounding-sphere radius (asymmetric hysteresis in `math::lod`), then uploads the
// per-slot levels + per-mesh tables the `cull` pass maps level → indirect args.
// LOD disabled ⇒ every level is 0, and level-0 rows carry the full-mesh counts, so
// the GPU args are byte-identical to the pre-LOD behavior (D8 compatibility).
let camera = scene.camera();
let cam_view = camera.view_matrix();
let cam_proj = camera.projection_matrix(aspect);
let bboxes = scene.mesh_bboxes();
let lod_levels: Vec<u32> = if self.lod_enabled.get() {
self.compute_lod_levels(scene, &cam_view, &cam_proj, &transform_slots, &bboxes)
} else {
vec![0u32; transform_slots.len()]
};
self.queue.write_buffer(
&self.lod_levels_buffer,
0,
bytemuck::cast_slice(&lod_levels),
);
let lod_tables = scene.mesh_lod_tables();
self.queue.write_buffer(
&self.lod_tables_buffer,
0,
bytemuck::cast_slice(&lod_tables),
);
// 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 view_proj = cam_proj * cam_view;
let frustum = Frustum::from_view_proj(&view_proj);
let cull_uniforms =
CullUniforms::from_frustum(&frustum, scene.num_slots() as u32, self.cull_enabled.get());
@@ -831,7 +929,16 @@ impl Renderer {
// 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_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
// Step 19: the draw command follows the CHOSEN level's indexedness, not L0's —
// an Auto-mode mesh may mix indexed levels (e.g. L0 indexed, L1+ non-indexed).
// Level 0 (LOD off, or a single-level mesh) reproduces the pre-LOD command.
let row = slot
.mesh
.lod_rows()
.get(lod_levels[slot.slot_index] as usize)
.copied()
.unwrap_or_default();
if row.index_count > 0 {
if let Some(index_buffer) = &slot.mesh.index_buffer {
render_pass.set_index_buffer(
index_buffer.slice(..),
@@ -879,6 +986,8 @@ impl Renderer {
let cull_buf = self.cull_uniform_buffer.clone();
let transform_buf = self.transform_buffer.clone();
let bbox_buf = self.bbox_buffer.clone();
let lod_levels_buf = self.lod_levels_buffer.clone();
let lod_tables_buf = self.lod_tables_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`
@@ -900,12 +1009,14 @@ impl Renderer {
read
}
let specs: [(&wgpu::Buffer, u64); 5] = [
let specs: [(&wgpu::Buffer, u64); 7] = [
(&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),
(&lod_levels_buf, n as u64 * 4),
(&lod_tables_buf, n as u64 * LOD_TABLE_SIZE),
];
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reads = Vec::with_capacity(specs.len());
@@ -953,8 +1064,17 @@ impl Renderer {
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]);
let (
mat_data,
args_data,
tr_data,
bb_data,
cull_data,
lod_levels_data,
lod_tables_data,
) = (
&data[0], &data[1], &data[2], &data[3], &data[4], &data[5], &data[6],
);
for i in 0..n {
let off = (i as u64 * TRANSFORM_SLOT_SIZE) as usize;
let t: TransformSlot =
@@ -990,6 +1110,28 @@ impl Renderer {
for (i, p) in c.planes.iter().enumerate() {
eprintln!("[dbg] plane[{i}] = {p:?}");
}
// Step 19 (LOD): the CPU-decided per-slot levels and the per-mesh tables the GPU
// maps level → indirect args from.
let levels: Vec<u32> = (0..n)
.map(|i| {
bytemuck::pod_read_unaligned(
&lod_levels_data[i as usize * 4..i as usize * 4 + 4],
)
})
.collect();
eprintln!(
"[dbg] lod: enabled={} viewport_height={} levels={:?}",
self.lod_enabled.get(),
self.viewport_height,
levels
);
for i in 0..n {
let off = (i as u64 * LOD_TABLE_SIZE) as usize;
let t: LodTable = bytemuck::pod_read_unaligned(
&lod_tables_data[off..off + LOD_TABLE_SIZE as usize],
);
eprintln!("[dbg] lod_table[{}] count={} rows={:?}", i, t.count, t.rows);
}
eprintln!(
"[dbg] pipeline switches (this frame's main pass) = {}",
self.debug_pipeline_switches.get()
@@ -1090,6 +1232,81 @@ impl Renderer {
pub fn set_culling(&self, enabled: bool) {
self.cull_enabled.set(enabled);
}
/// Enables or disables LOD (Step 19, D8). When disabled the CPU writes level 0 for every slot
/// each frame; level-0 rows carry the full-mesh draw counts, so the GPU indirect args are
/// byte-identical to the pre-LOD behavior (the scene renders exactly as before). When enabled
/// the CPU picks each slot's level from the projected bounding-sphere radius (with the
/// asymmetric hysteresis of `math::lod::lod_level`) and the `cull` pass maps level → args.
/// Enabled by default. Inputs: enabled (true = LOD on, false = always level 0).
pub fn set_lod_enabled(&self, enabled: bool) {
self.lod_enabled.set(enabled);
}
/// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the
/// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox
/// center + max half-extent × max scale component, rotated by the entity's quaternion) — is
/// projected to screen pixels ([`projected_radius_px`]), and [`lod_level`] turns that radius
/// into a level with asymmetric hysteresis (D4: the `last` level is the previous frame's
/// choice, kept in `self.last_lod_levels`).
///
/// Inactive (tombstoned) slots and single-level meshes get level 0 (and their hysteresis state
/// resets, so a re-added entity starts fresh). The result has one entry per transform slot.
fn compute_lod_levels(
&self,
scene: &Scene,
view: &Mat4,
proj: &Mat4,
transform_slots: &[TransformSlot],
bboxes: &[BBoxSlot],
) -> Vec<u32> {
let height = self.viewport_height.max(1) as f32;
let mut last = self.last_lod_levels.borrow_mut();
if last.len() != transform_slots.len() {
// Entity slots are append-only, but a resize keeps the old levels for the surviving
// slots (their hysteresis is meaningful) and zero-fills the new ones.
let keep = last.len().min(transform_slots.len());
let tail = last.split_off(keep);
last.extend(std::iter::repeat(0).take(transform_slots.len() - keep));
drop(tail);
}
let mut levels = vec![0u32; transform_slots.len()];
for (i, t) in transform_slots.iter().enumerate() {
if !t.is_active() {
last[i] = 0;
continue; // level 0 (zeroed vec); reset hysteresis for the tombstone
}
let mesh_idx = t.flags[0] as usize;
let mesh = scene.mesh_by_index(mesh_idx as u32);
let max_level = (mesh.num_lod_levels() as u32)
.saturating_sub(1)
.min(MAX_LOD_LEVELS - 1);
if max_level == 0 {
last[i] = 0;
continue; // single-level mesh: always L0
}
let b = &bboxes[mesh_idx];
let center = Vec3::new(
(b.min[0] + b.max[0]) * 0.5,
(b.min[1] + b.max[1]) * 0.5,
(b.min[2] + b.max[2]) * 0.5,
);
let half = Vec3::new(
(b.max[0] - b.min[0]) * 0.5,
(b.max[1] - b.min[1]) * 0.5,
(b.max[2] - b.min[2]) * 0.5,
);
// Mirror the WGSL cull pass exactly (D8): radius = |half-extents| × max(scale).
let radius = half.length() * t.scale[0].max(t.scale[1].max(t.scale[2]));
let center_world =
Vec3::from_array(t.translation) + Quat::from_array(t.rotation) * center;
let r_px = projected_radius_px(center_world, radius, *view, *proj, height);
let lvl = lod_level(r_px, last[i], max_level, &LOD_THRESHOLDS);
last[i] = lvl;
levels[i] = lvl;
}
levels
}
}
/// Allocates the depth texture + view backing the render passes' `depth_stencil_attachment`
+424
View File
@@ -326,6 +326,210 @@ impl Geometry {
self.validate()?;
Ok(self.to_vertices())
}
/// Number of triangles: index count / 3, or vertex count / 3 for non-indexed geometry.
pub fn num_triangles(&self) -> usize {
match &self.indices {
Some(i) => i.len() / 3,
None => self.positions.len() / 3,
}
}
/// The source triangles (explicit indices, or implicit for non-indexed input), with
/// degenerate (zero-area) triangles discarded. Returns `None` when the input is
/// malformed (index/vertex count not a multiple of 3) — callers fall back to a clone.
fn non_degenerate_triangles(&self) -> Option<Vec<[u32; 3]>> {
let vcount = self.positions.len() as u32;
let tris: Vec<[u32; 3]> = if let Some(indices) = &self.indices {
if indices.len() % 3 != 0 {
return None;
}
indices
.chunks_exact(3)
.map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
.collect()
} else if vcount % 3 != 0 {
return None;
} else {
(0..vcount).step_by(3).map(|i| [i, i + 1, i + 2]).collect()
};
Some(
tris.into_iter()
.filter(|t| Self::triangle_area(&self.positions, t) > 1e-8)
.collect(),
)
}
/// Area of a triangle given its three vertex indices (half the cross-product magnitude).
fn triangle_area(positions: &[[f32; 3]], t: &[u32; 3]) -> f32 {
let a = positions[t[0] as usize];
let b = positions[t[1] as usize];
let c = positions[t[2] as usize];
let ux = b[0] - a[0];
let uy = b[1] - a[1];
let uz = b[2] - a[2];
let vx = c[0] - a[0];
let vy = c[1] - a[1];
let vz = c[2] - a[2];
let cx = uy * vz - uz * vy;
let cy = uz * vx - ux * vz;
let cz = ux * vy - uy * vx;
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
}
/// Greedy decimation (Step 19, D10 — pure function, no GPU): returns an **indexed**
/// `Geometry` with ≈ `target_triangles` triangles, preserving the silhouette.
///
/// Algorithm: degenerate (zero-area) triangles are dropped, then the triangles are
/// sorted by **ascending area** (stable, tie-broken by original index — deterministic)
/// and the `T - target` smallest are removed. Any subset of a mesh's faces is a valid
/// (possibly open) mesh — a removed triangle just leaves boundary edges, so no topology
/// repair is needed (full manifold preservation would require edge collapse — out of
/// scope, see DRAFT Step 19 "Out of scope"). The kept triangles are rebuilt in original
/// triangle order:
/// - vertices are **welded by exact position equality** (dedup; non-indexed input is
/// welded first), and the output is always indexed;
/// - smooth normals are **recomputed** over the kept faces (only when the source had
/// normals);
/// - UVs / colors take the value of the **first vertex of each welded group** encountered
/// during the rebuild (documented trade-off: LOD sacrifices UV precision for the
/// silhouette).
///
/// Fallback (never corrupts): target ≥ triangle count, target = 0, or malformed input
/// (empty, or counts not multiples of 3) → `self.clone()`.
pub fn decimated(&self, target_triangles: u32) -> Geometry {
let Some(tris) = self.non_degenerate_triangles() else {
return self.clone();
};
let t = tris.len() as u32;
if t == 0 || target_triangles == 0 || target_triangles >= t {
return self.clone();
}
// Ascending area (stable; tie-broken by original index) → drop the smallest first.
let mut order: Vec<(f32, u32)> = tris
.iter()
.enumerate()
.map(|(i, tri)| (Self::triangle_area(&self.positions, tri), i as u32))
.collect();
order.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.cmp(&b.1))
});
let needed = t - target_triangles;
let mut removed = vec![false; tris.len()];
for i in 0..needed as usize {
removed[order[i].1 as usize] = true;
}
let kept: Vec<[u32; 3]> = tris
.into_iter()
.enumerate()
.filter(|(i, _)| !removed[*i])
.map(|(_, tri)| tri)
.collect();
// Rebuild: weld by exact position, remap indices in original triangle order.
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len());
let mut new_uvs: Option<Vec<[f32; 2]>> = self.uvs.is_some().then(Vec::new);
let mut new_colors: Option<Vec<[f32; 4]>> = self.colors.is_some().then(Vec::new);
// Key = exact float bits (f32 is not Hash/Eq; `to_bits` preserves exact equality).
let mut map: std::collections::HashMap<(u32, u32, u32), u32> =
std::collections::HashMap::new();
let mut new_indices: Vec<u16> = Vec::with_capacity(kept.len() * 3);
for tri in &kept {
for (_k, &vi) in tri.iter().enumerate() {
let pos = self.positions[vi as usize];
let key = (pos[0].to_bits(), pos[1].to_bits(), pos[2].to_bits());
let ni = if let Some(&ni) = map.get(&key) {
ni
} else {
let ni = new_positions.len() as u32;
new_positions.push(pos);
if let Some(uvs) = &mut new_uvs {
uvs.push(self.uvs.as_ref().unwrap()[vi as usize]);
}
if let Some(colors) = &mut new_colors {
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
}
map.insert(key, ni);
ni
};
new_indices.push(ni as u16);
}
}
// Recompute smooth normals over the kept faces (only when the source had normals).
let normals = self.normals.as_ref().map(|_| {
let mut acc = vec![[0.0f32; 3]; new_positions.len()];
// Accumulate with the REMAPPED (post-weld) indices: `kept` holds original indices,
// which may not exist in `new_positions` once welding has merged duplicates.
for i in 0..kept.len() {
let (ia, ib, ic) = (
new_indices[i * 3] as usize,
new_indices[i * 3 + 1] as usize,
new_indices[i * 3 + 2] as usize,
);
let a = new_positions[ia];
let b = new_positions[ib];
let c = new_positions[ic];
let n = [
(b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]),
(b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]),
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]),
];
for vi in [ia, ib, ic] {
let v = &mut acc[vi];
v[0] += n[0];
v[1] += n[1];
v[2] += n[2];
}
}
acc.iter()
.map(|v| {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len < 1e-12 {
[0.0, 0.0, 1.0]
} else {
[v[0] / len, v[1] / len, v[2] / len]
}
})
.collect()
});
Geometry {
positions: new_positions,
normals,
uvs: new_uvs,
colors: new_colors,
indices: Some(new_indices),
}
}
/// Generates `levels` (clamped to `1..=MAX_LOD_LEVELS`) LOD geometries from `self`
/// (Step 19, D10): level 0 is `self` (**byte-exact**), level k = `decimated(T × 0.5^k)`
/// with the target clamped to `[1, T]` (a mesh never decimates below one triangle).
/// Cost is setup-only (a few ms for thousands of triangles), never per frame.
pub fn generate_lod_levels(&self, levels: u8) -> Vec<Geometry> {
let levels = levels.clamp(1, crate::utils::conf::MAX_LOD_LEVELS as u8);
let mut out = vec![self.clone()];
let Some(tris) = self.non_degenerate_triangles() else {
return out;
};
let t = tris.len() as u32;
if t == 0 {
return out;
}
for k in 1..levels {
let target = ((t as f32) * 0.5f32.powi(k as i32))
.round()
.clamp(1.0, t as f32) as u32;
out.push(self.decimated(target));
}
out
}
}
#[cfg(test)]
@@ -465,4 +669,224 @@ mod tests {
let geo = quad();
assert_eq!(geo.indices(), Some(&[0, 1, 2, 0, 2, 3][..]));
}
// ========================================================================
// decimated / generate_lod_levels (Step 19, D10)
// ========================================================================
/// Fan of three triangles sharing vertex 0, with distinct areas 0.5 / 4 / 16.
fn tri_fan() -> Geometry {
Geometry::new(vec![
[0.0, 0.0, 0.0], // 0 (shared)
[1.0, 0.0, 0.0], // 1
[0.0, 1.0, 0.0], // 2
[2.0, 0.0, 0.0], // 3
[0.0, 4.0, 0.0], // 4
[4.0, 0.0, 0.0], // 5
[0.0, 8.0, 0.0], // 6
])
.with_normals(vec![[0.0, 0.0, 1.0]; 7])
.with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6])
}
#[test]
fn decimated_removes_smallest_triangles_first() {
// Areas 0.5 / 4 / 16 → target 2 keeps the two largest.
let out = tri_fan().decimated(2);
assert_eq!(out.num_triangles(), 2);
// The smallest triangle's unique vertices (1, 2) are gone.
assert!(!out.positions.contains(&[1.0, 0.0, 0.0]));
assert!(!out.positions.contains(&[0.0, 1.0, 0.0]));
assert!(out.positions.contains(&[2.0, 0.0, 0.0]));
assert!(out.positions.contains(&[0.0, 4.0, 0.0]));
out.validate().expect("decimated output validates");
}
#[test]
fn decimated_icosahedron_20_to_10() {
use crate::math::primitives;
let ico = primitives::icosphere(1.0, 0); // 12 vertices / 20 faces, equal area
assert_eq!(ico.num_triangles(), 20);
let out = ico.decimated(10);
assert_eq!(out.num_triangles(), 10);
out.validate().expect("output validates");
// All output positions come from the input; the bbox stays inside the original.
for p in &out.positions {
assert!(ico.positions.contains(p));
}
let in_bb = ico.bbox().expect("input bbox");
let out_bb = out.bbox().expect("output bbox");
for i in 0..3 {
assert!(
(out_bb.min[i] - in_bb.min[i]).abs() < 1e-5
&& out_bb.min[i] >= in_bb.min[i] - 1e-5
&& out_bb.max[i] <= in_bb.max[i] + 1e-5
);
}
}
#[test]
fn decimated_welds_non_indexed_input() {
// Four triangles stored NON-indexed (duplicated corners). A and B share the
// (0,0,0)-(4,0,0) edge; C and D are smaller and get removed at target 2.
let geo = Geometry::new(vec![
[0.0, 0.0, 0.0],
[4.0, 0.0, 0.0],
[0.0, 4.0, 0.0], // tri A (big)
[0.0, 0.0, 0.0], // tri B (big; shared edge duplicated)
[4.0, 0.0, 0.0],
[0.0, -4.0, 0.0],
[0.0, 4.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, -4.0, 0.0], // tri C (small, removed)
[1.0, 1.0, 0.0],
[2.0, -1.0, 0.0],
[0.0, -4.0, 0.0], // tri D (smallest, removed)
]);
assert!(geo.indices().is_none());
// Target 2 keeps the two big triangles; welding merges A and B's duplicated edge corners.
let out = geo.decimated(2);
assert!(out.indices().is_some(), "output is always indexed");
assert_eq!(out.num_triangles(), 2);
assert_eq!(out.positions.len(), 4, "12 input vertices weld to 4");
assert_eq!(out.indices().unwrap(), &[0, 1, 2, 0, 1, 3]);
}
#[test]
fn decimated_is_deterministic() {
use crate::math::primitives;
let ico = primitives::icosphere(1.0, 1); // 80 equal-area faces
let a = ico.decimated(30);
let b = ico.decimated(30);
assert_eq!(a.indices(), b.indices());
assert_eq!(a.positions, b.positions);
}
#[test]
fn decimated_fallback_target_at_or_above_count() {
let geo = tri_fan(); // 3 triangles
assert_eq!(
geo.decimated(3).indices(),
geo.indices(),
"target == T → clone"
);
assert_eq!(
geo.decimated(99).indices(),
geo.indices(),
"target > T → clone"
);
assert_eq!(
geo.decimated(0).indices(),
geo.indices(),
"target 0 → clone"
);
// Malformed non-indexed input (vertex count not a multiple of 3) → clone.
let bad = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
assert_eq!(bad.decimated(1).indices(), bad.indices());
}
#[test]
fn decimated_drops_degenerate_triangles() {
// 2 real triangles + 1 degenerate (collinear) one.
let geo = Geometry::new(vec![
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0], // tri 0: area 2
[0.0, 2.0, 0.0], // tri 1: area 2
[2.0, 2.0, 0.0],
[2.0, 0.0, 0.0],
[3.0, 0.0, 0.0], // tri 2: degenerate (collinear)
[4.0, 0.0, 0.0],
[5.0, 0.0, 0.0],
])
.with_indices(vec![0, 1, 2, 3, 4, 5, 6, 7, 8]);
// T (non-degenerate) = 2; target 1 → one real triangle kept, degenerate dropped.
let out = geo.decimated(1);
assert_eq!(out.num_triangles(), 1);
assert!(
!out.positions.contains(&[3.0, 0.0, 0.0]),
"degenerate triangle dropped"
);
out.validate().expect("output validates");
}
#[test]
fn decimated_recomputes_unit_normals() {
// Garbage (unnormalized) input normals; output normals must be unit length.
let geo = tri_fan().with_normals(vec![[9.0, 9.0, 9.0]; 7]);
let out = geo.decimated(2);
let normals = out.normals.expect("normals recomputed");
for n in &normals {
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {n:?}");
}
}
#[test]
fn decimated_uv_takes_first_encountered() {
// Big triangle (area 2, good UVs) + small one (area 0.5, seam UVs) → the small
// one is removed; welded UVs come from the kept triangle's vertices.
// A and B are the two big triangles (kept); B reuses v0's position as v4 with a
// DIFFERENT (seam) UV. C and D are small and removed at target 2. Welding v4 into v0
// must keep the FIRST encountered UV (0,0) and drop the seam (9,9).
let geo = Geometry::new(vec![
[0.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
[3.0, 3.0, 0.0],
[0.0, 3.0, 0.0],
[0.0, 0.0, 0.0], // seam copy of v0
[1.5, 1.5, 0.0],
])
.with_uvs(vec![
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
[9.0, 9.0], // seam UVs on the duplicate corner
[8.0, 8.0],
])
.with_indices(vec![0, 1, 2, 4, 2, 3, 3, 4, 5, 5, 1, 2]);
let out = geo.decimated(2); // keeps the two big triangles
assert_eq!(out.num_triangles(), 2);
assert_eq!(
out.positions.len(),
4,
"seam corner welds into its first occurrence"
);
assert_eq!(
out.uvs.as_deref(),
Some(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]][..])
);
}
#[test]
fn generate_lod_levels_halving_ratios() {
use crate::math::primitives;
let geo = primitives::icosphere(1.0, 1); // 80 triangles
let levels = geo.generate_lod_levels(3);
assert_eq!(levels.len(), 3);
assert_eq!(levels[0].num_triangles(), 80, "L0 exact");
assert_eq!(levels[0].indices(), geo.indices(), "L0 byte-exact");
assert_eq!(levels[1].num_triangles(), 40);
assert_eq!(levels[2].num_triangles(), 20);
}
#[test]
fn generate_lod_levels_one_level() {
let geo = tri_fan();
let levels = geo.generate_lod_levels(1);
assert_eq!(levels.len(), 1);
assert_eq!(levels[0].indices(), geo.indices());
}
#[test]
fn generate_lod_levels_tiny_mesh_clamps_to_one() {
let geo = quad(); // 2 triangles, equal area
let levels = geo.generate_lod_levels(4);
assert_eq!(levels.len(), 4);
assert_eq!(levels[0].num_triangles(), 2);
assert_eq!(levels[1].num_triangles(), 1, "2 × 0.5");
assert_eq!(levels[2].num_triangles(), 1, "2 × 0.25 → clamped to 1");
assert_eq!(levels[3].num_triangles(), 1, "2 × 0.125 → clamped to 1");
}
}
+248
View File
@@ -0,0 +1,248 @@
//! # LOD — Per-frame Level Selection (pure, testable without a GPU)
//!
//! The pure functions behind the LOD feature (Step 19, D1/D4/D8). Each frame the **CPU**
//! decides which detail level every entity draws; these functions do that math:
//!
//! - [`projected_radius_px`]: the entity's *perceived size* — its bounding-sphere radius in
//! screen pixels (the **same sphere** the GPU frustum culling uses, D8);
//! - [`lod_level`]: the level decision with **asymmetric hysteresis** (D4) — the core
//! anti-flicker mechanism.
//!
//! Both are pure (no GPU, no state beyond the caller-supplied `last` level) → unit-testable.
//! The Renderer calls them per slot each frame and uploads the resulting levels to the GPU,
//! which only maps level → draw args (the packed-buffer offsets live in the per-mesh LOD
//! table — see `resources::uniform::LodTable`).
use glam::{Mat4, Vec3, Vec4};
/// Projected radius (in **pixels**) of a bounding sphere, given the camera's view/projection.
///
/// The sphere center (world space) is transformed into view space; a sphere at depth `d` with
/// radius `r` subtends `r / d` in view space, which the projection's vertical scale
/// (`proj.y.y = 1 / tan(fov / 2)`) maps to NDC — multiplied by `height_px / 2` (half the
/// viewport height in pixels) gives pixels.
///
/// A sphere whose center is inside/behind the near plane (`depth <= 1e-4`) returns
/// `f32::INFINITY` — the entity dominates the screen, so the finest level (0) is chosen.
pub fn projected_radius_px(
center_world: Vec3,
radius: f32,
view: Mat4,
proj: Mat4,
height_px: f32,
) -> f32 {
let v = view * Vec4::new(center_world.x, center_world.y, center_world.z, 1.0);
let depth = -v.z; // view space: the camera looks along -Z (glam `look_at_mat4`)
if depth <= 1e-4 {
return f32::INFINITY;
}
(radius / depth) * proj.y_axis.y * (height_px * 0.5)
}
/// Level decision with **asymmetric hysteresis** (Step 19, D4).
///
/// `thresholds` is a **descending** pixel radius: `thresholds[k]` is the radius *above which*
/// level k+1 is required (i.e. level k is sufficient up to that bound; level 0 has no bound).
/// Levels beyond the threshold count share the last bound (clamped) — e.g. with `[48, 12]`
/// only the first three levels are distinct.
///
/// Hysteresis (dead band):
/// - to a **finer** level: immediate, as soon as `radius_px` exceeds the current level's bound;
/// - to a **coarser** level: only if `radius_px <= bound(k) * 0.8` (20 % dead band), stepped
/// incrementally (each intermediate bound × 0.8 must hold).
///
/// The "detail loss" pop (going coarser) is therefore delayed; the "detail regain" pop (going
/// finer) is immediate — standard engine practice. `f32::INFINITY` (object at the camera)
/// always returns 0. The result is always within `0..=max_level`.
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32 {
if radius_px.is_infinite() || max_level == 0 || thresholds.is_empty() {
return 0;
}
// Bound for level k+1: the k-th threshold, clamped for levels beyond the threshold count.
let bound = |k: u32| thresholds[(k as usize).min(thresholds.len() - 1)];
let last = (last as usize).min(max_level as usize) as u32;
// Target without hysteresis: the coarsest level whose bound is still satisfied.
let mut target = 0u32;
let mut k = 0u32;
while k < max_level {
if radius_px <= bound(k) {
target = k + 1;
k += 1;
} else {
break;
}
}
if target <= last {
// Finer or equal: immediate (no dead band on the way to more detail).
target
} else {
// Coarser: 20 % dead band per step, incremental.
let mut lvl = last;
while lvl < target {
if radius_px <= bound(lvl) * 0.8 {
lvl += 1;
} else {
break;
}
}
lvl
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Mat4;
use glam::Vec3;
/// A camera at `(0, 0, dist)` looking at the origin, up `+Y`, with vertical `fov`.
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
let view =
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
let proj = glam::Mat4::perspective_rh_gl(fov, 1.0, 0.1, 100.0);
(view, proj)
}
// ========================================================================
// projected_radius_px
// ========================================================================
#[test]
fn projected_radius_analytic() {
// Sphere of radius 1 at the origin; camera 5 units away; fov = 90°
// (proj vertical scale = 1/tan(45°) = 1); viewport 1000 px tall.
// Expected: (1 / 5) * 1 * 500 = 100 px.
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::ZERO, 1.0, view, proj, 1000.0);
assert!((r - 100.0).abs() < 1e-3, "expected 100 px, got {r}");
}
#[test]
fn projected_radius_scale_invariance() {
// 10x bigger object 10x further away → same projected radius (similarity).
let (view1, proj1) = camera(5.0, std::f32::consts::PI / 2.0);
let (view2, proj2) = camera(50.0, std::f32::consts::PI / 2.0);
let r1 = projected_radius_px(Vec3::ZERO, 1.0, view1, proj1, 1000.0);
let r2 = projected_radius_px(Vec3::ZERO, 10.0, view2, proj2, 1000.0);
assert!((r1 - r2).abs() < 1e-2, "expected equal, got {r1} vs {r2}");
}
#[test]
fn projected_radius_at_camera_is_infinite() {
// Center at the camera position → depth 0 → INFINITY (finest level).
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::new(0.0, 0.0, 5.0), 1.0, view, proj, 1000.0);
assert!(r.is_infinite());
}
#[test]
fn projected_radius_behind_camera_is_infinite() {
// Center behind the camera → negative depth → INFINITY.
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
let r = projected_radius_px(Vec3::new(0.0, 0.0, 20.0), 1.0, view, proj, 1000.0);
assert!(r.is_infinite());
}
#[test]
fn projected_radius_narrower_fov_larger_pixels() {
// Narrower FOV (zoomed in) → LARGER vertical projection scale (1/tan(fov/2)) →
// more pixels for the same sphere at the same distance.
let fov_narrow = std::f32::consts::PI / 3.0; // 60°
let fov_wide = std::f32::consts::PI / 2.0; // 90°
let (v1, p1) = camera(5.0, fov_narrow);
let (v2, p2) = camera(5.0, fov_wide);
let r1 = projected_radius_px(Vec3::ZERO, 1.0, v1, p1, 1000.0);
let r2 = projected_radius_px(Vec3::ZERO, 1.0, v2, p2, 1000.0);
assert!(
r1 > r2,
"narrower FOV should give more pixels: {r1} vs {r2}"
);
}
// ========================================================================
// lod_level
// ========================================================================
#[test]
fn lod_level_simple_thresholds() {
let t = [48.0f32, 12.0];
// r > 48 → level 0 (too big for any coarser level).
assert_eq!(lod_level(100.0, 0, 2, &t), 0);
assert_eq!(lod_level(52.0, 0, 2, &t), 0);
// 48 >= r > 38.4 (0.8·48): target is L1, but the dead band holds it at L0.
assert_eq!(lod_level(44.0, 0, 2, &t), 0);
// r <= 38.4 → L1.
assert_eq!(lod_level(38.4, 0, 2, &t), 1);
assert_eq!(lod_level(30.0, 0, 2, &t), 1);
// 12 > r > 9.6 (0.8·12): target L2, dead band holds at L1.
assert_eq!(lod_level(10.0, 0, 2, &t), 1);
// r <= 9.6 → L2 (both steps pass the band).
assert_eq!(lod_level(9.6, 0, 2, &t), 2);
assert_eq!(lod_level(9.0, 0, 2, &t), 2);
}
#[test]
fn lod_level_finer_is_immediate() {
let t = [48.0f32, 12.0];
// Already coarse (L2); radius grows past 48 → immediately back to L0.
assert_eq!(lod_level(100.0, 2, 2, &t), 0);
// L2, radius between the bounds → immediately to L1.
assert_eq!(lod_level(30.0, 2, 2, &t), 1);
// L1, radius past 48 → immediately to L0.
assert_eq!(lod_level(52.0, 1, 2, &t), 0);
// L1, radius below 12 → target L2 but dead band (10 > 9.6) holds at L1.
assert_eq!(lod_level(10.0, 1, 2, &t), 1);
// L1, radius below 9.6 → L2.
assert_eq!(lod_level(9.0, 1, 2, &t), 2);
}
#[test]
fn lod_level_oscillation_is_stable() {
// Anti-flicker (D4): a radius oscillating ±10 % around threshold 48 (43.2..52.8)
// must not make the level flip back and forth.
let t = [48.0f32];
let mut level = 0u32;
for _ in 0..100 {
for r in [43.2f32, 52.8, 43.2, 52.8] {
level = lod_level(r, level, 2, &t);
}
}
// Whatever level it settled on, it must not have changed on the last pass.
let before = level;
for r in [43.2f32, 52.8, 43.2, 52.8] {
level = lod_level(r, level, 2, &t);
}
assert_eq!(before, level, "level flickered around the threshold");
// From L0 the oscillation never leaves L0 (coarser needs r ≤ 38.4).
assert_eq!(lod_level(43.2, 0, 2, &t), 0);
assert_eq!(lod_level(52.8, 0, 2, &t), 0);
}
#[test]
fn lod_level_clamped_thresholds_for_extra_levels() {
// 4 levels but only 2 thresholds: levels 2 and 3 share the last bound (12).
let t = [48.0f32, 12.0];
// r = 9 passes both bands (38.4, 9.6) AND the clamped third bound (0.8·12) → L3.
assert_eq!(lod_level(9.0, 0, 3, &t), 3);
// r = 10 passes the first two targets but the clamped band holds at L2.
assert_eq!(lod_level(10.0, 0, 3, &t), 1);
}
#[test]
fn lod_level_infinite_returns_zero() {
let t = [48.0f32, 12.0];
assert_eq!(lod_level(f32::INFINITY, 2, 2, &t), 0);
assert_eq!(lod_level(f32::INFINITY, 0, 2, &t), 0);
}
#[test]
fn lod_level_degenerate_inputs() {
let t = [48.0f32];
assert_eq!(lod_level(1.0, 5, 0, &t), 0); // max_level 0
assert_eq!(lod_level(1.0, 0, 2, &[]), 0); // no thresholds
// Stale `last` beyond max_level is clamped, not a panic.
assert_eq!(lod_level(100.0, 9, 2, &t), 0);
}
}
+3
View File
@@ -15,14 +15,17 @@
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
//! - `lod.rs`: Pure LOD level-selection functions (projected radius + hysteresis, Step 19)
pub mod frustum;
pub mod geometry;
pub mod lod;
pub mod primitives;
pub mod transform;
// Re-exports
pub use frustum::Frustum;
pub use geometry::{BBox, Geometry, GeometryError};
pub use lod::{lod_level, projected_radius_px};
pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
pub use transform::Transform;
+307 -44
View File
@@ -14,84 +14,209 @@
//! - **CPU+GPU retention (DRAFT Step 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
//! The GPU buffers are uploaded once at creation; the `Arc<Geometry>` is kept for CPU-side computations
//! without re-uploading per frame.
//! - **LOD packing (Step 19, D7)**: a multi-level mesh packs ALL its levels into **one** vertex buffer and
//! **one** index buffer (level k lives at a byte offset), because WebGPU forbids dynamic offsets on
//! vertex/index bindings — only the draw ARGS move per frame. The per-level offsets live in the
//! `LodRow`s (uploaded to the GPU LOD table); the shadow/main passes always bind level 0.
//!
//! ## Construction (DRAFT Step 8, D4)
//! The single canonical constructor is [`Mesh::from_geometry`]. The former `Mesh::new`/`Mesh::with_material`
//! ## Construction (DRAFT Step 8, D4; Step 19, D6/D7)
//! The single canonical constructor is [`Mesh::from_geometry_lod`] (levels + mode); [`Mesh::from_geometry`]
//! is its one-level convenience wrapper. The former `Mesh::new`/`Mesh::with_material`
//! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and
//! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`.
use crate::math::Geometry;
use crate::resources::Material;
use crate::resources::Vertex;
use crate::resources::uniform::LodRow;
use std::sync::Arc;
use wgpu::util::DeviceExt;
/// How a mesh's LOD levels were produced (Step 19, D6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LodMode {
/// No LOD: a single level (the plain `create_mesh` path).
#[default]
Off,
/// Levels 1.. were auto-generated by greedy decimation (`Geometry::decimated`, D10).
Auto,
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
Explicit,
}
/// Persistent GPU geometry: vertex positions, optional indices, draw call counters, and the retained
/// CPU `Geometry` (Step 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
/// Renderer for every frame. A Mesh optionally references the `Material` used to render it (`Option<Arc<Material>>`).
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Step 7.3.5).
///
/// Since Step 19 a Mesh may carry several LOD levels: they are packed into the single vertex/index
/// buffers (D7) and described by `lod_rows` (uploaded per mesh as a [`crate::resources::uniform::LodTable`]).
pub struct Mesh {
/// Shared CPU geometry this mesh was built from (Step 8, D5). Retained for CPU-side computation
/// Shared CPU geometry of level 0 (the full mesh, Step 8, D5). Retained for CPU-side computation
/// (bounding boxes, UV access, normal queries) and shared across meshes with identical geometry.
geometry: Arc<Geometry>,
/// GPU buffer containing vertex attribute data (position, UV, color).
/// GPU buffer containing the packed vertex data of ALL levels (one `Vertex` per position, levels
/// concatenated in level order; level 0 first).
pub vertex_buffer: wgpu::Buffer,
/// Optional GPU buffer for indexed drawing. Present when the mesh uses index-based rendering instead of simple vertex iteration.
/// Optional GPU buffer with the packed indices of all indexed levels (rebased onto the packed
/// vertex layout). `None` when no level is indexed.
pub index_buffer: Option<wgpu::Buffer>,
/// Number of vertices in the mesh. Used as `0..num_vertices` for non-indexed draws.
/// Number of vertices of level 0. Used as `0..num_vertices` for non-indexed draws.
pub num_vertices: u32,
/// Number of indices in the index buffer. Used as `0..num_indices` for indexed draws.
/// Number of indices of level 0. Used as `0..num_indices` for indexed draws.
pub num_indices: u32,
/// The Material used to render this mesh. `None` until assigned; the Renderer falls back to the
/// Scene's default material when absent (DRAFT Step 7.3.5).
material: Option<Arc<Material>>,
/// How the levels were produced (Step 19, D6).
lod_mode: LodMode,
/// The CPU geometry of every level, level 0 first (all retained; `geometry` is `lod_levels[0]`).
lod_levels: Vec<Arc<Geometry>>,
/// The packed-buffer offsets per level (mirrors the uploaded per-mesh LOD table).
lod_rows: Vec<LodRow>,
}
/// Error returned by [`pack_levels`] when the packed vertex total exceeds the u16 index range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackError {
/// Total packed vertices (all levels) — must be < 65536 for u16 rebased indices.
pub total_vertices: u32,
}
impl std::fmt::Display for PackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"packed LOD vertex total {} exceeds the 65535 u16 index limit",
self.total_vertices
)
}
}
impl std::error::Error for PackError {}
/// Pure packing of LOD levels (Step 19, D7) — no GPU, unit-testable.
///
/// Concatenates every level's interleaved vertices into one flat slice (level 0 first) and rebases
/// every indexed level's indices onto the packed vertex layout. Returns the packed vertices, the
/// packed indices (`None` when no level is indexed), and one [`LodRow`] per level carrying its
/// **element** offsets + counts. Offsets are in ELEMENT units (vertex indices / index elements),
/// not bytes: the GPU cull pass copies them straight into the WebGPU indirect draw args, whose
/// `first_vertex`/`first_index` fields are element indices — the packed buffers are bound in full
/// (offset 0) and only the args' first_* fields move per level.
///
/// Fails with [`PackError`] when the **total** packed vertex count reaches 65536 (u16 rebased
/// indices cannot address it). Callers (the Scene APIs) validate the total beforehand and report
/// the error to the user; a single-level mesh can never fail (its indices are already u16).
pub(crate) fn pack_levels(
levels: &[Arc<Geometry>],
) -> Result<(Vec<Vertex>, Option<Vec<u16>>, Vec<LodRow>), PackError> {
let total: u32 = levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(PackError {
total_vertices: total,
});
}
let mut vertices: Vec<Vertex> = Vec::new();
let mut indices: Vec<u16> = Vec::new();
let mut any_indexed = false;
let mut rows: Vec<LodRow> = Vec::with_capacity(levels.len());
let mut vertex_base = 0u32; // element (vertex index) base of the level in the packed buffer
let mut index_base = 0u32; // element (index element) base of the level in the packed buffer
for level in levels {
let level_vertices = level.to_vertices();
// Element units (NOT bytes): the row's offsets feed the WebGPU indirect draw args
// (first_vertex = vertex index, first_index = index element) — see the doc above.
let level_vertex_offset = vertex_base;
let level_vertex_count = level_vertices.len() as u32;
let (level_index_offset, level_index_count) = match level.indices() {
Some(data) => {
any_indexed = true;
for &idx in data {
// Safe: the total-vertex check above guarantees no u16 overflow.
indices.push(idx as u32 as u16 + vertex_base as u16);
}
(index_base, data.len() as u32)
}
None => (0, 0),
};
rows.push(LodRow::new(
level_vertex_offset,
level_vertex_count,
level_index_offset,
level_index_count,
));
vertex_base += level_vertex_count;
index_base += level_index_count;
vertices.extend(level_vertices);
}
Ok((vertices, any_indexed.then_some(indices), rows))
}
impl Mesh {
/// Canonical constructor (Step 8, D4): builds GPU buffers from a shared CPU `Geometry`.
/// Canonical constructor (Step 8, D4, now D6/D7): builds the packed GPU buffers from a list of
/// LOD levels (level 0 = the full mesh, always present).
///
/// Inputs: device (GPU command source for buffer creation), geometry (shared CPU vertex data to
/// upload), material (optional appearance; `None` falls back to the Scene default at draw time).
///
/// Internal steps: 1) derive interleaved `Vertex` array via `geometry.to_vertices()`; 2) create the
/// vertex buffer (one `Vertex` per position); 3) if the geometry has indices, create the index buffer
/// and set `num_indices`, else leave it `None`.
///
/// The provided `geometry` is retained on the mesh (`geometry` accessor) alongside the uploaded GPU
/// buffers, so the CPU data remains readable for later phases without re-uploading each frame (DRAFT Step 8, D5).
/// All levels are packed into ONE vertex buffer and ONE index buffer (D7 — WebGPU forbids dynamic
/// offsets on vertex/index bindings; only the draw args move). `num_vertices`/`num_indices`
/// describe **level 0** (the shadow and main passes always bind level 0); the per-level draw
/// arguments are emitted by the GPU cull pass from the uploaded LOD table.
pub fn from_geometry_lod(
device: &wgpu::Device,
levels: Vec<Arc<Geometry>>,
material: Option<Arc<Material>>,
mode: LodMode,
) -> Self {
assert!(!levels.is_empty(), "a mesh needs at least level 0");
// Packing can only fail when the packed vertex total reaches 65536; the Scene APIs
// validate that beforehand. A single level (from_geometry) can never fail.
let (vertices, indices, rows) =
pack_levels(&levels).expect("packed LOD vertex total exceeds the u16 limit");
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Vertex Buffer (packed LOD)"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
});
let index_buffer = indices.map(|data| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Index Buffer (packed LOD)"),
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_SRC,
contents: bytemuck::cast_slice(&data),
})
});
let l0 = &levels[0];
Self {
geometry: Arc::clone(l0),
vertex_buffer,
index_buffer,
num_vertices: l0.positions.len() as u32,
num_indices: l0.indices().map(|i| i.len() as u32).unwrap_or(0),
material,
lod_mode: mode,
lod_levels: levels,
lod_rows: rows,
}
}
/// One-level convenience constructor (Step 8, D4): builds GPU buffers from a shared CPU
/// `Geometry` (no LOD — `LodMode::Off`, `lod_rows` has a single row).
pub fn from_geometry(
device: &wgpu::Device,
geometry: Arc<Geometry>,
material: Option<Arc<Material>>,
) -> Self {
let vertices = geometry.to_vertices();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Vertex Buffer"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let (index_buffer, num_indices) = if let Some(data) = geometry.indices() {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Index Buffer"),
usage: wgpu::BufferUsages::INDEX,
contents: bytemuck::cast_slice(data),
});
(Some(buffer), data.len() as u32)
} else {
(None, 0)
};
Self {
geometry,
vertex_buffer,
index_buffer,
num_vertices: vertices.len() as u32,
num_indices,
material,
}
Self::from_geometry_lod(device, vec![geometry], material, LodMode::Off)
}
/// Returns a reference to the shared CPU geometry this mesh was built from (Step 8, D5).
/// Returns a reference to the level-0 CPU geometry this mesh was built from (Step 8, D5).
/// Read-only accessor for CPU-side queries (bounding boxes, UVs, normals).
pub fn geometry(&self) -> &Arc<Geometry> {
&self.geometry
@@ -108,4 +233,142 @@ impl Mesh {
pub fn set_material(&mut self, material: Arc<Material>) {
self.material = Some(material);
}
/// How this mesh's LOD levels were produced (Step 19, D6).
pub fn lod_mode(&self) -> LodMode {
self.lod_mode
}
/// Number of LOD levels (1 for a plain mesh).
pub fn num_lod_levels(&self) -> usize {
self.lod_rows.len()
}
/// The per-level packed-buffer rows (level 0 first).
pub fn lod_rows(&self) -> &[LodRow] {
&self.lod_rows
}
/// The CPU geometry of level k (0 = full mesh). Used by `Scene::add_mesh_lod` when
/// reconstructing a mesh with a modified level list.
pub fn lod_levels_arc(&self, k: usize) -> Arc<Geometry> {
Arc::clone(&self.lod_levels[k])
}
/// The per-mesh GPU LOD table (uploaded once by the Renderer; read by the GPU cull pass).
pub fn lod_table(&self) -> crate::resources::uniform::LodTable {
crate::resources::uniform::LodTable::from_rows(&self.lod_rows)
}
/// Whether **level 0** is indexed (the shadow/main passes always bind level 0).
pub fn l0_indexed(&self) -> bool {
self.lod_rows
.first()
.map(|r| r.index_count > 0)
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::primitives;
#[test]
fn pack_levels_offsets_and_rebasing() {
// Two levels: L0 = icosahedron (12 verts / 60 indices), L1 = decimated to 10 (welded).
let l0 = Arc::new(primitives::icosphere(1.0, 0));
let l1 = Arc::new(l0.decimated(10));
let (vertices, indices, rows) =
pack_levels(&[Arc::clone(&l0), Arc::clone(&l1)]).expect("small mesh packs");
// Packed vertices = L0 + L1 concatenated.
assert_eq!(vertices.len(), l0.positions.len() + l1.positions.len());
// Packed indices = 60 + L1's (rebased by L0's vertex count).
let packed_indices = indices.expect("both levels indexed");
assert_eq!(packed_indices.len(), 60 + l1.indices().unwrap().len());
// L0 indices unchanged (rebase base 0); L1 indices rebased by 12.
for (i, idx) in l0.indices().unwrap().iter().enumerate() {
assert_eq!(packed_indices[i], *idx);
}
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
assert_eq!(packed_indices[60 + j], *idx + 12);
}
// Rows carry the ELEMENT offsets (vertex indices / index elements, not bytes).
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].vertex_offset, 0);
assert_eq!(rows[0].vertex_count, 12);
assert_eq!(rows[0].index_offset, 0);
assert_eq!(rows[0].index_count, 60);
assert_eq!(rows[1].vertex_offset, 12);
assert_eq!(rows[1].vertex_count, l1.positions.len() as u32);
assert_eq!(rows[1].index_offset, 60);
assert_eq!(rows[1].index_count, l1.indices().unwrap().len() as u32);
}
#[test]
fn pack_levels_mixed_indexedness() {
// Non-indexed L0 (6 verts) + indexed L1 (welded) — the Auto-mode case from DRAFT D7.
let l0 = Arc::new(
Geometry::new(vec![
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0],
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, -2.0, 0.0],
])
.with_indices(vec![0, 1, 2, 3, 4, 5]),
);
// Force L0 non-indexed: strip the indices.
let l0_nonidx = Arc::new(Geometry::new(l0.positions.clone()));
let l1 = Arc::new(l0.decimated(1)); // welded + indexed
let (vertices, indices, rows) =
pack_levels(&[Arc::clone(&l0_nonidx), Arc::clone(&l1)]).expect("small mesh packs");
assert_eq!(vertices.len(), 6 + l1.positions.len());
let packed = indices.expect("an indexed level exists");
// L0 contributes no indices; L1's are rebased by 6.
assert_eq!(packed.len(), l1.indices().unwrap().len());
assert_eq!(rows[0].index_count, 0, "non-indexed L0 row");
assert_eq!(rows[1].index_offset, 0, "L1 is the first indexed level");
assert_eq!(rows[1].vertex_offset, 6, "element units, not bytes");
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
assert_eq!(packed[j], *idx + 6);
}
}
#[test]
fn pack_levels_all_non_indexed() {
let l0 = Arc::new(Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
]));
let (vertices, indices, rows) = pack_levels(&[l0.clone()]).expect("small mesh packs");
assert_eq!(vertices.len(), 3);
assert!(indices.is_none());
assert_eq!(rows[0].index_count, 0);
}
#[test]
fn lod_table_from_rows() {
let l0 = Arc::new(primitives::icosphere(1.0, 0));
let l1 = Arc::new(l0.decimated(10));
let (_, _, rows) = pack_levels(&[l0, l1]).expect("small mesh packs");
let table = crate::resources::uniform::LodTable::from_rows(&rows);
assert_eq!(table.count, 2);
assert_eq!(table.rows[0].index_count, 60);
assert_eq!(table.rows[1].vertex_count, rows[1].vertex_count);
}
#[test]
fn pack_levels_rejects_u16_overflow() {
// A level with 70k vertices (indexed) cannot be packed with u16 rebased indices.
let big = Arc::new(
Geometry::new(vec![[0.0, 0.0, 0.0]; 70_000]).with_indices(vec![0u16; 70_000 / 3 * 3]),
);
let err = pack_levels(&[big]).unwrap_err();
assert_eq!(err.total_vertices, 70_000);
}
}
+4 -4
View File
@@ -24,13 +24,13 @@ pub mod vertex;
pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights;
pub use material::Material;
pub use mesh::Mesh;
pub use mesh::{LodMode, Mesh, PackError};
pub use texture::{Texture, TextureError};
pub use uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, Light, LightType, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot,
OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE,
TransformSlot,
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow,
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform,
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot,
};
pub use vertex::Vertex;
+108
View File
@@ -383,6 +383,79 @@ impl CullUniforms {
}
}
/// Size in bytes of one [`LodRow`] (16 B = 4 u32), matching WGSL `LodRow`.
pub const LOD_ROW_SIZE: u64 = 16;
/// Size in bytes of one [`LodTable`] (80 B = 5 × 16 B), matching WGSL `LodTable`.
pub const LOD_TABLE_SIZE: u64 = 80;
/// One LOD level of a mesh's **packed** vertex/index buffers (Step 19, D7) — the per-level
/// draw offsets the GPU cull pass needs to emit the level's indirect draw args.
/// Mirrors the WGSL `LodRow` (16 bytes: `vec4<u32>`).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
pub struct LodRow {
/// **Element** offset of this level's vertices within the mesh's packed vertex buffer
/// (a vertex index, not a byte offset — the WebGPU `drawIndirectNonIndexed` first_vertex
/// is a vertex index; it goes straight into the indirect args' `first_vertex`).
pub vertex_offset: u32,
/// Number of vertices of this level.
pub vertex_count: u32,
/// **Element** offset of this level's indices within the mesh's packed index buffer
/// (an index element, not a byte offset — the `drawIndirectIndexed` first_index is an
/// index element; 0 when the level is non-indexed or no earlier level has indices).
pub index_offset: u32,
/// Number of indices of this level (0 when non-indexed); the draw count.
pub index_count: u32,
}
impl LodRow {
/// Builds a row from the packed-buffer offsets the packer computed.
/// `index_offset`/`index_count` are 0 for a non-indexed level.
pub fn new(vertex_offset: u32, vertex_count: u32, index_offset: u32, index_count: u32) -> Self {
Self {
vertex_offset,
vertex_count,
index_offset,
index_count,
}
}
}
/// Per-mesh LOD table (Step 19, D7): one [`LodRow`] per level, uploaded once per mesh and
/// read by the GPU cull pass to map a CPU-decided level to indirect draw args. Level 0
/// **always** exists and is byte-exact with the full mesh. Mirrors the WGSL `LodTable`
/// (80 bytes = count @0 + 4 × 16-byte rows @16..80, each row a `vec4<u32>`).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
pub struct LodTable {
/// Number of valid levels (1 = no LOD, single level).
pub count: u32,
/// Padding so the rows start at byte 16 (WGSL `LodTable`: `count : u32` @0, a 12-byte pad,
/// then the `array<LodRow, 4>` @16 — the WGSL pad is an `array<u32, 3>` (alignment 4, NOT
/// `vec3<u32>` which would align to 16 and grow the struct to 96 bytes).
pub _pad: [u32; 3],
/// One 16-byte row per level (`LOD_ROW_SIZE`-spaced), zeroed beyond `count`.
pub rows: [LodRow; crate::utils::conf::MAX_LOD_LEVELS as usize],
}
impl LodTable {
/// Builds a table from up to `MAX_LOD_LEVELS` rows (row 0 = level 0 = full mesh).
pub fn from_rows(rows: &[LodRow]) -> Self {
let mut table = Self::default();
table._pad = [0; 3];
table.count = rows.len() as u32;
for (i, row) in rows
.iter()
.enumerate()
.take(crate::utils::conf::MAX_LOD_LEVELS as usize)
{
table.rows[i] = *row;
}
table
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -479,4 +552,39 @@ mod tests {
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
assert_eq!(offset_of!(CullUniforms, culling), 100);
}
#[test]
fn lod_layouts_match_wgsl() {
// LodRow: four u32 -> 16 (a vec4<u32> in WGSL).
assert_eq!(size_of::<LodRow>(), 16);
assert_eq!(LOD_ROW_SIZE, 16);
assert_eq!(offset_of!(LodRow, vertex_offset), 0);
assert_eq!(offset_of!(LodRow, vertex_count), 4);
assert_eq!(offset_of!(LodRow, index_offset), 8);
assert_eq!(offset_of!(LodRow, index_count), 12);
// LodTable: count @0 + 4 rows @16 -> 80.
assert_eq!(size_of::<LodTable>(), 80);
assert_eq!(LOD_TABLE_SIZE, 80);
assert_eq!(offset_of!(LodTable, count), 0);
assert_eq!(offset_of!(LodTable, rows), 16);
// from_rows: count + rows filled, rest zeroed.
let table = LodTable::from_rows(&[LodRow {
vertex_offset: 0,
vertex_count: 100,
index_offset: 0,
index_count: 300,
}]);
assert_eq!(table.count, 1);
assert_eq!(table.rows[0].vertex_count, 100);
assert_eq!(table.rows[1], LodRow::default());
// MAX_LOD_LEVELS rows fit exactly.
let table = LodTable::from_rows(&vec![
LodRow::default();
crate::utils::conf::MAX_LOD_LEVELS as usize
]);
assert_eq!(table.count, crate::utils::conf::MAX_LOD_LEVELS);
}
}
+142
View File
@@ -277,6 +277,148 @@ impl Scene {
Ok(id.to_string())
}
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
/// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are
/// auto-generated by greedy decimation (`Geometry::generate_lod_levels`, D10) at halving
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
///
/// Inputs: id (unique mesh id), geometry (level 0 — the full mesh), material (optional
/// material id, same rule as [`create_mesh`]), levels (2..=MAX_LOD_LEVELS).
/// Returns `Err` if the id exists, the material is unknown, `levels` is out of range, or the
/// packed vertex total exceeds the u16 index limit (65535).
pub fn create_mesh_with_lod(
&mut self,
id: &str,
geometry: Geometry,
material: Option<&str>,
levels: u8,
) -> Result<String, String> {
use crate::utils::conf::MAX_LOD_LEVELS;
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
if levels < 2 || u32::from(levels) > MAX_LOD_LEVELS {
return Err(format!(
"LOD levels must be in 2..={MAX_LOD_LEVELS} (got {levels})."
));
}
let lod_levels: Vec<Arc<Geometry>> = geometry
.generate_lod_levels(levels)
.into_iter()
.map(Arc::new)
.collect();
let total: u32 = lod_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit; use fewer levels or a smaller mesh."
));
}
let mut mesh = Mesh::from_geometry_lod(
self.device(),
lod_levels,
None,
crate::resources::LodMode::Auto,
);
if let Some(name) = material {
let mat = self
.materials
.get(name)
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
.clone();
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Adds (or replaces) an **explicitly provided** LOD level on an existing mesh (Step 19, D6).
/// The level is packed into the mesh's buffers alongside the others (D7) and the per-mesh LOD
/// table is updated (it is re-uploaded every frame, so the change takes effect next frame).
///
/// Inputs: id (existing mesh id), level (index ≥ 1; must be ≤ the current level count —
/// append at the end or replace in place), geometry (the level's geometry).
/// Validation: the level must validate, carry the **same attribute set and indexedness** as
/// level 0, keep the packed vertex total under 65536, and stay within `MAX_LOD_LEVELS`.
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String> {
use crate::utils::conf::MAX_LOD_LEVELS;
let current = self
.meshes
.get(id)
.ok_or_else(|| format!("Mesh '{}' does not exist.", id))?;
let levels_count = current.num_lod_levels();
if level < 1 || u32::from(level) > MAX_LOD_LEVELS {
return Err(format!(
"LOD level must be in 1..={MAX_LOD_LEVELS} (got {level})."
));
}
if level as usize > levels_count {
return Err(format!(
"Mesh '{}' has {levels_count} level(s); level {level} does not exist and the next free level is {levels_count}.",
id
));
}
let l0 = current.geometry();
let attr = |g: &Geometry| (g.normals.is_some(), g.uvs.is_some(), g.colors.is_some());
if attr(&geometry) != attr(l0) {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same attribute set (normals/UVs/colors) as level 0.",
id
));
}
if geometry.indices().is_some() != l0.indices().is_some() {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same indexedness as level 0.",
id
));
}
geometry
.validate()
.map_err(|e| format!("LOD level {level} of mesh '{}': {e}", id))?;
let mut new_levels: Vec<Arc<Geometry>> = (0..levels_count)
.map(|i| current.lod_levels_arc(i))
.collect();
if level as usize == levels_count {
if levels_count >= MAX_LOD_LEVELS as usize {
return Err(format!(
"Mesh '{}' already has the maximum of {MAX_LOD_LEVELS} LOD levels.",
id
));
}
new_levels.push(Arc::new(geometry)); // append the next level (L_k at vec index k)
} else {
new_levels[level as usize] = Arc::new(geometry); // replace L_k in place (vec index k)
}
let total: u32 = new_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit."
));
}
let material = current.material().cloned();
let mesh = Mesh::from_geometry_lod(
self.device(),
new_levels,
material,
crate::resources::LodMode::Explicit,
);
// Entities reference the mesh by (stable) index, not by Arc — swapping the Arc is safe.
self.meshes.insert(id.to_string(), Arc::new(mesh));
Ok(())
}
/// The per-mesh LOD tables in `mesh_order` order (one 80-byte table per mesh, level 0 first)
/// — the payload of the GPU `lod_tables` buffer, uploaded every frame (Step 19, D7).
pub fn mesh_lod_tables(&self) -> Vec<crate::resources::LodTable> {
self.mesh_order
.iter()
.map(|name| self.meshes[name].lod_table())
.collect()
}
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
+77 -27
View File
@@ -9,11 +9,17 @@
// = no-op) instead of a CPU-side per-entity loop.
//
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see ARCHI_CPU_GPU.md D12) and are
// allocated once. Each frame the CPU rewrites the transform slots and cull uniforms;
// everything else is GPU-driven.
// allocated once. Each frame the CPU rewrites the transform slots, cull uniforms, LOD levels
// and LOD tables; everything else is GPU-driven.
//
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B).
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B),
// LodRow (16B), LodTable (80B).
//
// Step 19 (LOD): the CPU decides each entity's level (screen-space size + hysteresis, D8);
// the cull pass maps it to the packed level's draw args through the per-mesh LOD table
// (binding 4) and per-slot level array (binding 3). With LOD disabled the CPU writes level 0
// everywhere and the args are byte-identical to the pre-LOD behavior.
//
// GOTCHA — WGSL `select` argument order: `select(reject, accept, cond)` returns the SECOND
// argument when `cond` is true and the FIRST when false (the reverse of HLSL's
@@ -63,6 +69,22 @@ struct CullUniforms {
_pad : vec2u,
};
// 16 bytes: one LOD level's draw offsets (Step 19, D7). ELEMENT units, not bytes: x is a
// vertex index (drawIndirectNonIndexed first_vertex) and z an index element (drawIndirectIndexed
// first_index) — the packed vertex/index buffers are bound in full (offset 0), only these
// first_* values move per level. w = 0 marks a non-indexed level.
struct LodRow {
o : vec4u, // x = first_vertex, y = vertex_count, z = first_index, w = index_count
};
// 80 bytes: the per-mesh LOD table (Step 19, D7). count @0, 12-byte pad (an array<u32,3> —
// NOT vec3u, which would align to 16 and grow the struct to 96 B), rows @16..80.
struct LodTable {
count : u32, // number of valid levels (1 = no LOD)
_pad : array<u32, 3>,
rows : array<LodRow, 4>, // MAX_LOD_LEVELS = 4; zeroed beyond count
};
// ---- Bind groups (Step 15.5) ----
// Group 0 (transforms) is shared by both entry points; group 1 (matrices) by `compute_matrices`;
// group 2 (cull uniforms + bboxes + draw args) by `cull`. Each pipeline infers the subset it uses.
@@ -71,6 +93,10 @@ struct CullUniforms {
@group(2) @binding(0) var<uniform> cull_u : CullUniforms;
@group(2) @binding(1) var<storage, read> bboxes : array<BBoxSlot>;
@group(2) @binding(2) var<storage, read_write> draw_args : array<DrawSlot>;
// Step 19: per-slot CPU-decided LOD level (one u32 per entity slot) and the per-mesh LOD tables
// (one 80-byte LodTable per mesh, mesh_order order — same indexing as `bboxes`).
@group(2) @binding(3) var<storage, read> lod_levels : array<u32>;
@group(2) @binding(4) var<storage, read> lod_tables : array<LodTable>;
// ---- Shared helpers ----
@@ -119,10 +145,23 @@ fn identity_mat() -> mat4x4f {
// Fills a draw slot with a non-zero (visible) count of `count`, or zero (culled / inactive).
// The count lands in `.a.x`; `.a.y` (instance count) is the constant 1; the rest stays zero.
// (A level-0 draw produced by `write_level_args` is byte-identical to this: first_* = 0.)
fn set_draw_count(i : u32, count : u32) {
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
}
// Fills the slot's indirect args with the draw command of LOD level `row` (Step 19, D7):
// indexed levels use the 5-field layout (index_count, instances, first_index, base_vertex,
// base_instance) and non-indexed levels the 4-field layout (vertex_count, instances,
// first_vertex, base_instance). The element-unit offsets in the row become the first_* fields.
fn write_level_args(i : u32, row : LodRow) {
if (row.o.w > 0u) {
draw_args[i].a = vec4u(row.o.w, 1u, row.o.z, 0u);
} else {
draw_args[i].a = vec4u(row.o.y, 1u, row.o.x, 0u);
}
}
// ---- Pass 1: derive world matrices (Step 15.5) ----
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
@compute
@@ -137,9 +176,11 @@ fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
}
}
// ---- Pass 2: cull + fill indirect draw args (Step 15.6) ----
// ---- Pass 2: LOD level → draw args + cull (Steps 15.6 + 19) ----
// Dispatched for MAX_ENTITIES. Slots at or beyond `num_slots` (and inactive slots) are zeroed so
// the indirect render passes skip them; visible slots keep their packed count.
// the indirect render passes skip them. For visible slots the CPU-decided LOD level (binding 3)
// is mapped through the mesh's LOD table (binding 4) to the level's draw command; culling
// (when enabled) zeroes it against the frustum planes.
@compute
@workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid : vec3u) {
@@ -158,32 +199,41 @@ fn cull(@builtin(global_invocation_id) gid : vec3u) {
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;
// LOD (Step 19, D8): the CPU decides the level per slot (projected size + hysteresis);
// the GPU only maps it to the packed level's draw args. Clamped to the mesh's level count
// (the CPU clamps too — defense in depth against a stale level after an add_mesh_lod).
let table = lod_tables[u32(t.flags.x)];
var level = lod_levels[i];
if (level >= table.count) {
level = table.count - 1u;
}
let row = table.rows[level].o;
// Culling 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));
// Culling: test the entity's world bounding sphere against the frustum planes.
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;
if (cull_u.culling == 1u) {
let b = bboxes[u32(t.flags.x)];
let center_local = (b.min + b.max) * 0.5;
// World center = translation + rotation * local center (no scale; the radius carries it).
let center_world = t.translation + rotate_by_quat(center_local, t.rotation);
let half_extents = (b.max - b.min) * 0.5;
let radius = length(half_extents) * max(t.scale.x, max(t.scale.y, t.scale.z));
for (var p = 0u; p < 6u; p = p + 1u) {
let plane = cull_u.planes[p];
let dist = dot(plane.xyz, center_world) + plane.w;
if (dist < -radius) {
visible = false;
break;
}
}
}
// 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);
if (!visible) {
set_draw_count(i, 0u);
return;
}
// Visible: the chosen level's draw command (level 0 is byte-identical to the pre-LOD args).
write_level_args(i, LodRow(row));
}
+11
View File
@@ -57,6 +57,17 @@ pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
pub const MAX_ENTITIES: u32 = 256;
/// Maximum number of LOD levels per mesh (Étape 19, D8). The per-mesh LOD table
/// (`LodTable`, 80 bytes) carries one 16-byte row per level — 4 rows + the count header.
pub const MAX_LOD_LEVELS: u32 = 4;
/// Default LOD thresholds in **pixels** of projected bounding-sphere radius (Étape 19, D4/D8):
/// `thresholds[k]` is the radius *above which* level k+1 is required (descending). With these
/// values: `r > 48` → L0, `12 < r ≤ 48` → L1, `r ≤ 12` → L2+ (clamped). Constants in v1
/// (per-scene/mesh configurability is a follow-up); the hysteresis dead band (×0.8 to go
/// coarser) lives in `math::lod::lod_level`.
pub const LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0];
/// Workgroup size of the GPU-driven compute shaders (matches the `@workgroup_size` in
/// `gpu_driven.wgsl`). The compute dispatch is `MAX_ENTITIES / WORKGROUP_SIZE` workgroups.
pub const GPU_WORKGROUP_SIZE: u32 = 64;