Root cause of the user-reported artifacts (stripes disappearing on the far LOD): normals were RECOMPUTED from the surviving faces after the collapse. uv_sphere is wound inward, so the recomputed normals pointed inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0 on L0). Fix — normals are INHERITED, never recomputed: - welded() now also welds normals (first-encountered per cluster) - Collapse owns the normal table; collapse_edge λ-blends + renormalizes at the same λ as the position (no seam guard: the attribute-aware weld kept hard-edge vertices separate, so no edge crosses a shading break) - compaction reads the post-collapse table instead of recomputing - the outward reorientation added earlier is removed: the source winding + normals are preserved as-is, so every LOD level is shading-compatible with L0 whatever the source orientation Rim protection (attribute-aware weld leaves UV-seam slits / pole fans as boundary rims): a face touching such a rim is never removed while interior collapses remain — strict PQ mode (edges whose incident faces are fully interior, re-validated at pop) with a best-effort fallback when the interior alone cannot reach the target. Seam-free meshes (icosahedron) stay topologically closed; sewn meshes stay geometrically complete (no hole at the slit) — tests now assert seam-column survival. Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated with the attribute-aware weld + rim protection + inherited normals. Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓. Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2 (was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
14 KiB
GPU-driven rendering
WSG's scene rendering is GPU-driven: the per-entity world matrices and the indirect draw arguments are computed on the GPU each frame, so the CPU no longer loops over entities to issue draw calls. This page explains what that means for you, how to opt into frustum culling, and how the Level of Detail (LOD) system works.
What runs on the GPU
Each frame, before the render passes, two compute passes run over a fixed-capacity slot table (256 entities, allocated once):
compute_matricesderives each entity's world matrix from its transform (translation / rotation / scale). The result feeds the render pipelines as the per-entity model matrix.culldecides per-entity visibility and fills the indirect draw arguments (the vertex/index count, zeroed when the entity is culled or inactive). With LOD on (the default), the count it writes comes from the mesh's LOD table at the level the CPU chose for the slot this frame — see Level of Detail.
The main and shadow render passes are then 100 % indirect: each active slot issues one indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero count, so its draw is a no-op. The CPU only rewrites the transform slots and the cull uniforms each frame — it never iterates the entities to issue draws.
You do not need to do anything special to get this: render_scene is GPU-driven by default.
Batching by material
The main render pass batches the draws by material: all entities sharing the same material are drawn back to back, so the GPU pipeline and the material's texture bind group are switched once per distinct material, not once per entity (the per-draw work — matrix offset, vertex/index buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change the rendered image and there is nothing to configure.
Constraint: the batching reorders the draws, which is safe here because every pipeline in the engine is opaque (
BlendState::REPLACE, no alpha blending) — the depth buffer resolves the draw order. If transparent materials are ever added, the transparent draws must be isolated (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque draws.
Frustum culling (opt-in)
Culling is off by default. The culling pass still runs, but with culling disabled it marks every active entity visible — so the rendered image is identical to a CPU-culled scene. This protects you from a culling bug (an object that should be visible vanishing) becoming a silent correctness issue.
To enable culling, build your App with .with_culling(true):
let app = AppBuilder::new()
.title("My app")
.with_culling(true) // skip entities whose bounding sphere leaves the frustum
.build()
.await?;
Or toggle it at runtime on the renderer:
app.renderer().set_culling(true); // enable
app.renderer().set_culling(false); // disable again
How culling works
When culling is on, each entity's local-axis-aligned bounding box (computed once from its
geometry, Geometry::bbox()) is treated as a bounding sphere:
- center = the box center, transformed by the entity's world transform (rotation + translation; scale is folded into the radius),
- radius = the box's circumradius scaled by the entity's largest scale component.
The sphere is tested against the six camera frustum planes. If it is fully outside (beyond a plane by more than its radius), the entity is culled; otherwise it is drawn.
The sphere is a conservative approximation of the box: it can draw an object that is partly out of view (false negative), but it will never cull an object that is actually visible (false positive). For tight culling you would need per-mesh sphere fitting or per-face tests, which are out of scope for v1.
Level of Detail (LOD)
LOD is on by default: distant entities automatically draw a coarser version of their mesh, so the GPU stops spending fillrate and vertex work on detail the eye cannot see. It is a quality feature with a performance payoff — unlike culling, it is safe to leave on because the worst case (a level chosen too fine) is exactly what you would have drawn anyway.
How it works
LOD is a CPU-decided, GPU-executed split (the one deliberate per-entity decision kept on the CPU):
- Setup (once per mesh). Each mesh can carry up to 4 levels. Levels 1..3 are generated
automatically from level 0 by quadric edge collapse (Garland–Heckbert,
Geometry::generate_lod_levels): edges are ranked by quadric error and collapsed cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the neighbours — no new face, so a seam-free mesh stays closed (no holes, no non-manifold "books") and a boundary collapse removes one face; duplicate corners are welded aware of their attributes (relative position tolerance 1e-6, merged only when the UVs are within half a tile and the normals within ~25° — a seam or a hard edge stays a separate corner, so a collapse can never cross a texture chart or a shading break; on a mesh with a UV seam the seam's lips are then protected — their faces never collapse while interior ones remain — so the surface stays geometrically complete); a survivor moved by a collapse gets its UV/color/normal interpolated between the collapsed endpoints (same λ as its new position — texture and shading stay attached to the surface and coarsen smoothly across levels, never across a UV seam; normals are inherited from the source, never recomputed, so the lighting is identical to level 0 whatever the source's winding), and the levels are packed into the mesh's single vertex/index buffers (see the constraint below). Level 0 is always your exact geometry. - 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).
- Per frame (GPU). The
cullpass 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
// 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):
app.renderer().set_lod_enabled(false);
Constraint: packed LOD buffers
A level is not a separate buffer: the mesh's levels are concatenated into its one vertex buffer and one index buffer, and the per-mesh LOD table stores each level's offsets/counts. Two consequences:
- u16 indices → the sum of all levels must stay under 65 535 vertices (the scene rejects a level set that would not fit, with a clear error);
- at most 4 levels per mesh (
MAX_LOD_LEVELS, also the size of the GPU table row).
Indexed-ness: levels supplied through add_mesh_lod must match level 0's indexed-ness (validated).
Auto-generated levels from a non-indexed level 0 are indexed anyway (decimation rebuilds with
indices), and the packed buffer supports that mix — the per-slot draw command follows the level the
CPU chose (the shadow pass always uses the level-0 command, so casters stay at full detail).
How to verify LOD with the debug dump
The debug dump (below) prints, per frame: the per-slot levels and each mesh's LOD table
(rows = vertex_offset / vertex_count / index_offset / index_count, element units). The clean test
is to zoom the camera out: the entities' perceived size drops below the thresholds, the levels
step up (0 → 1 → 2), and the indirect argument counts shrink to the corresponding rows — e.g. the
demo's 3 840-index sphere drops to 1 824, then 912 — while the levels stay stable frame to frame
(hysteresis holding). Verified 2026-09-23: at the demo's default distance every entity sits at
level 0 with full counts; zoomed to 4.6×, all multi-level meshes select level 1 with exactly their
L1 rows, stable across frames.
Debugging the GPU path
If something looks wrong — a missing object, a black window — the GPU-side slot tables can be
read back and printed. The Renderer ships a debug helper (intentionally not part of the
documented API):
app.renderer().debug_dump(8); // prints the first 8 GPU slots to stderr
It dumps exactly what the GPU sees: the transform slots, the derived world matrices, the
indirect draw arguments, the mesh bounding boxes, the cull uniforms, the per-slot LOD levels
and the per-mesh LOD tables. A slot whose vertex count reads 0 was zeroed by the cull pass
(culled, inactive, or beyond num_slots); a full count means the entity is drawn — and with LOD
on, the row the count comes from tells you the selected level (see above). In the demo example the dump is opt-in via an environment
variable, so the showcase stays silent by default:
WSG_DEBUG_DUMP=120 cargo run -p wsg-lib --example demo
WSG_DEBUG_DUMP=N dumps for the first N frames. The demo stays silent when the variable is
unset; a set-but-non-numeric value (e.g. WSG_DEBUG_DUMP=on) gives 3 frames.
How to verify culling is actually working (a correct culling pass is invisible — culled objects were off-screen anyway — so the proof is in the counts, not the image):
- Launch the demo with
WSG_DEBUG_DUMP=120(the demo has culling on and an orbiting camera — drag the mouse to orbit). - Note first that orbiting/zooming this camera cannot cull the entity ring: the camera
always looks at the origin, so each entity's angular offset from the view axis is bounded
by
atan(ring radius / camera distance)=atan(1.7/6.1)≈ 15.5°, under the ~22° vertical half-FOV. The seven demo entities therefore keep their full counts (cube36, sphere3840, …) in every orientation — that is the expected and correct behaviour (verified 2026-09-22: 600-frame camera sweep, GPU cull verdicts matched an independent CPU sphere test on all 6000 entity frames, zero flips on the ring). - To see the counts actually flip to
0, you need an entity well off the target axis — e.g. one placed far away so it ends up behind the near plane. Its count then toggles0↔ full as the camera orbits, while the on-axis entities stay full. (This off-axis test is the one that verified the cull path end-to-end, positive and negative.) - Optional A/B: temporarily build with
.with_culling(false)and repeat — with culling off, every entity keeps its full count in every orientation (the off-axis one included).
This readback is the reference truth when a shader bug is suspected: it shows both the computed
counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how
the 2026-09-22 « black window » bug — an inverted WGSL select argument order — was diagnosed
and verified fixed, see the D14 note in docs/tech/ARCHI_CPU_GPU.md.)
Limitations
- Culling is all-or-nothing per entity. There is no partial (per-triangle) culling.
- The sphere is a coarse bound for elongated meshes (a long thin box gets a large sphere). If your scene is dominated by such shapes, culling may bring little gain.
- Capacity is 256 entities per render pass. Beyond that, extra entities are not drawn.
This is the largest a single-buffer design can address under WebGPU's two
uniformrules: a singleuniformbinding is capped at 64 KB, and auniformoffset must be a multiple of 256 B. A 64-byte matrix can never be individually addressable by auniformoffset, so each matrix slot is padded to 256 B — and 256 slots × 256 B = 64 KB is the maximum. It is amply generous for a simple scene (the demo has 7). - Mesh bounding boxes are recomputed when meshes are added; a scene whose mesh set changes at runtime simply re-uploads the small bbox table (a few bytes per mesh).
- LOD levels are packed into the mesh's own buffers: u16 indices cap the total across all
levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse
- attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the per-frame cost is one sphere projection per entity on the CPU.
- LOD detail loss is visible by design — the hysteresis dead band makes the pop rare and
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
coarser.
set_lod_enabled(false)is the escape hatch.
Culling is a performance feature, not a visual one: with it off you get the same image with the indirect-draw machinery still active.
Next: Examples · Back to User documentation index