LOD: quadric edge collapse (Garland-Heckbert) replaces Rule A
Rule A (area-sorted triangle removal) left holes and open boundaries on closed meshes (visible artifacts when zoomed far out). The decimator is now a proper quadric edge collapse: - Collapse: welded u32 topology, per-vertex quadrics (accumulated incident face planes), edge cost = quadric error at the optimal point (clamped to the segment) + edge length, BinaryHeap with a custom Ord (f32 is not Ord; inverted compare, NaN-safe). - Standard GH semantics WITHOUT the new face: both incident pair faces degenerate and are removed; neighbouring faces remap and sweep over the region. Preserves the Euler characteristic and closedness (no holes, no books, no duplicate faces); interior collapse = -2 faces, boundary = -1. Guards: non-manifold edge (>2 faces) or a fold (duplicate sorted triple) rejects the collapse. - welded(): fuzzy welding (1e-6 relative tolerance, grid + 27- neighbour broad phase, exact verify) - trig-generated seams differ by ~1e-16, exact-bit welding missed them. - Root causes fixed along the way: dead face slots are never reused (stale edge/vface entries), vfaces updated on remap, degenerate faces dropped, best-effort target (granularity -2/-1 can land 1-2 off; soft cap, deterministic). - Docs: DRAFT D10, ROADMAP 4.3, ARCHI_CPU_GPU, gpu-driven, lib README, mesh/scene/demo comments - 'greedy decimation / Rule A' replaced by 'quadric edge collapse'. - lod.rs test: deprecated glam perspective alias -> explicit glam::camera::rh::proj::opengl::perspective. cargo test --workspace: 100 passed (94 lib + 3 wgsl + 3 integration), 0 failed; demo runs clean with LOD on.
This commit is contained in:
+17
-10
@@ -118,22 +118,29 @@ ou **à chaud** (toggle de debug au runtime). Aucun changement de signature exis
|
|||||||
|
|
||||||
L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`,
|
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
|
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é —
|
capot**. Mécanisme : **quadric edge collapse** (Garland–Heckbert) sur le maillage indexé —
|
||||||
|
|
||||||
```
|
```
|
||||||
Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
|
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
|
- Weld préalable (tolérance relative 1e-6 — grille + 27 voisins : les seams trigonométriques
|
||||||
cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide
|
diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas), triangles dégénérés jetés, puis
|
||||||
est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable
|
**quadric edge collapse** : file de priorité des arêtes classées par coût (erreur quadrique de
|
||||||
(clamp `[1, T]`).
|
l'arête + longueur d'arête) ; on replie la plus bon marché jusqu'à la cible. Un repli **interne**
|
||||||
- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces
|
fusionne les 2 triangles incidents (ils dégénèrent — −2 faces) et re-mappe les voisins :
|
||||||
|
**pas de nouvelle face** — caractéristique d'Euler et **clôture préservées** (un mesh fermé
|
||||||
|
reste fermé : pas de trous, pas de « books ») ; un repli de **bordure** retire 1 face.
|
||||||
|
Garde-fous : arête non-manifold (≥ 3 faces) ou face en double (pli) → repli rejeté. La cible
|
||||||
|
est clampée à `[1, T]` et atteinte au mieux (best effort : la granularité −2/−1 peut s'en
|
||||||
|
écarter d'un ou deux triangles — jamais de géométrie corrompue).
|
||||||
|
- Sortie : re-indexation (le weld ci-dessus), **normales lisses recalculées** sur les faces
|
||||||
survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs =
|
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 ;
|
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).
|
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**).
|
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
|
||||||
Triangles dégénérés jetés.
|
Triangles dégénérés jetés.
|
||||||
- **Déterministe** (tri stable, tie-break par index d'origine) → counts reproductibles en tests.
|
- **Déterministe** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) →
|
||||||
|
counts reproductibles en tests.
|
||||||
- Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et
|
- 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).
|
le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue).
|
||||||
|
|
||||||
@@ -251,9 +258,9 @@ pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32])
|
|||||||
/// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0).
|
/// `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;
|
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`
|
/// Quadric edge collapse (D10, Garland–Heckbert) : renvoie un `Geometry` indexé avec
|
||||||
/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel),
|
/// ~`target_triangles` triangles (best effort ±1–2, clamp `[1, T]`) — les arêtes au coût
|
||||||
/// normales lisses recalculées. Déterministe.
|
/// quadrique le plus faible partent en premier, normales lisses recalculées. Déterministe.
|
||||||
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
|
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
|||||||
|
|
||||||
### 4.3 Optimisations
|
### 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é)
|
- [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é)
|
||||||
- [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)
|
- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated`/`generate_lod_levels` : arêtes classées par coût quadrique, repli interne −2 faces / bordure −1, un mesh fermé reste fermé, weld tolérance 1e-6, 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)
|
- [ ] HDR + Tone Mapping (optionnel)
|
||||||
|
|
||||||
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
|
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ Ce document sert de spécification technique et de trame d'implémentation pour
|
|||||||
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
|
> 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é) ;
|
> (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
|
> 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,
|
> générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
|
||||||
> soudure par position exacte, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en
|
> arêtes au coût quadrique minimal sont repliées en premier, un mesh fermé reste fermé — pas de
|
||||||
|
> trous, pas de « books » ; soudure tolérance 1e-6, 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 :
|
> 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
|
> 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`
|
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
|
||||||
|
|||||||
@@ -93,9 +93,12 @@ LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity deci
|
|||||||
CPU):
|
CPU):
|
||||||
|
|
||||||
1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated
|
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
|
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
|
||||||
triangles are removed first (no edge map, no crease handling — a subset of the faces of a valid
|
`Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
|
||||||
mesh is valid), duplicate corners are welded, and the levels are **packed into the mesh's single
|
cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the
|
||||||
|
neighbours — no new face, so a **closed mesh stays closed** (no holes, no non-manifold
|
||||||
|
"books"), a boundary collapse removes one face; duplicate corners are welded (relative
|
||||||
|
tolerance 1e-6), and the levels are **packed into the mesh's single
|
||||||
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
|
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
|
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
|
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ impl AppHandler for Demo {
|
|||||||
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
|
// 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
|
// 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,
|
// 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
|
// levels 1.. are auto-generated by quadric edge collapse at halving targets (D10), all
|
||||||
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
|
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
|
||||||
// switches levels on the fly (asymmetric hysteresis, D4).
|
// switches levels on the fly (asymmetric hysteresis, D4).
|
||||||
app.scene
|
app.scene
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
|
|||||||
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
|
| **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` |
|
| **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) |
|
| **scene** | Scene — resource depot and slot-based entity graph for declarative rendering setup (Step 17) |
|
||||||
| **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) |
|
| **math** | Transform, Geometry (per-attribute mesh data + AABB, quadric edge collapse `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) |
|
||||||
| **utils** | Configuration constants and WsgError type |
|
| **utils** | Configuration constants and WsgError type |
|
||||||
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
|
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
|
||||||
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
|
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
|
||||||
|
|||||||
+767
-146
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -101,7 +101,7 @@ mod tests {
|
|||||||
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
|
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
|
||||||
let view =
|
let view =
|
||||||
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
|
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);
|
let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0);
|
||||||
(view, proj)
|
(view, proj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub enum LodMode {
|
|||||||
/// No LOD: a single level (the plain `create_mesh` path).
|
/// No LOD: a single level (the plain `create_mesh` path).
|
||||||
#[default]
|
#[default]
|
||||||
Off,
|
Off,
|
||||||
/// Levels 1.. were auto-generated by greedy decimation (`Geometry::decimated`, D10).
|
/// Levels 1.. were auto-generated by quadric edge collapse (`Geometry::decimated`, D10).
|
||||||
Auto,
|
Auto,
|
||||||
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
|
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
|
||||||
Explicit,
|
Explicit,
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ impl Scene {
|
|||||||
|
|
||||||
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
|
/// 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
|
/// 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
|
/// auto-generated by quadric edge collapse (`Geometry::generate_lod_levels`, D10) at halving
|
||||||
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
|
/// 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.
|
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user