147 lines
7.6 KiB
Markdown
147 lines
7.6 KiB
Markdown
# GPU-driven rendering
|
||
|
||
WSG's scene rendering is **GPU-driven**: the per-entity world matrices and the indirect draw
|
||
arguments are computed on the GPU each frame, so the CPU no longer loops over entities to issue
|
||
draw calls. This page explains what that means for you and how to opt into **frustum culling**.
|
||
|
||
## What runs on the GPU
|
||
|
||
Each frame, before the render passes, two compute passes run over a fixed-capacity slot table
|
||
(256 entities, allocated once):
|
||
|
||
1. **`compute_matrices`** derives each entity's world matrix from its transform
|
||
(translation / rotation / scale). The result feeds the render pipelines as the per-entity
|
||
model matrix.
|
||
2. **`cull`** decides per-entity visibility and fills the **indirect draw arguments** (the
|
||
vertex/index count, zeroed when the entity is culled or inactive).
|
||
|
||
The main and shadow render passes are then **100 % indirect**: each active slot issues one
|
||
indirect draw that reads its own count and world matrix. A culled or inactive slot has a zero
|
||
count, so its draw is a no-op. The CPU only rewrites the transform slots and the cull uniforms
|
||
each frame — it never iterates the entities to issue draws.
|
||
|
||
You do not need to do anything special to get this: `render_scene` is GPU-driven by default.
|
||
|
||
## Batching by material
|
||
|
||
The main render pass batches the draws by material: all entities sharing the same material are
|
||
drawn back to back, so the GPU pipeline and the material's texture bind group are switched **once
|
||
per distinct material**, not once per entity (the per-draw work — matrix offset, vertex/index
|
||
buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change
|
||
the rendered image and there is nothing to configure.
|
||
|
||
> **Constraint:** the batching reorders the draws, which is safe here because every pipeline in
|
||
> the engine is **opaque** (`BlendState::REPLACE`, no alpha blending) — the depth buffer resolves
|
||
> the draw order. If transparent materials are ever added, the transparent draws must be isolated
|
||
> (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque
|
||
> draws.
|
||
|
||
## Frustum culling (opt-in)
|
||
|
||
Culling is **off by default**. The culling pass still runs, but with culling disabled it marks
|
||
every active entity visible — so the rendered image is **identical** to a CPU-culled scene.
|
||
This protects you from a culling bug (an object that should be visible vanishing) becoming a
|
||
silent correctness issue.
|
||
|
||
To enable culling, build your `App` with `.with_culling(true)`:
|
||
|
||
```rust
|
||
let app = AppBuilder::new()
|
||
.title("My app")
|
||
.with_culling(true) // skip entities whose bounding sphere leaves the frustum
|
||
.build()
|
||
.await?;
|
||
```
|
||
|
||
Or toggle it at runtime on the renderer:
|
||
|
||
```rust
|
||
app.renderer().set_culling(true); // enable
|
||
app.renderer().set_culling(false); // disable again
|
||
```
|
||
|
||
## How culling works
|
||
|
||
When culling is on, each entity's **local-axis-aligned bounding box** (computed once from its
|
||
geometry, `Geometry::bbox()`) is treated as a **bounding sphere**:
|
||
|
||
- **center** = the box center, transformed by the entity's world transform (rotation +
|
||
translation; scale is folded into the radius),
|
||
- **radius** = the box's circumradius scaled by the entity's largest scale component.
|
||
|
||
The sphere is tested against the six camera frustum planes. If it is **fully outside** (beyond
|
||
a plane by more than its radius), the entity is culled; otherwise it is drawn.
|
||
|
||
The sphere is a **conservative** approximation of the box: it can draw an object that is partly
|
||
out of view (false negative), but it will **never cull an object that is actually visible**
|
||
(false positive). For tight culling you would need per-mesh sphere fitting or per-face tests,
|
||
which are out of scope for v1.
|
||
|
||
## Debugging the GPU path
|
||
|
||
If something looks wrong — a missing object, a black window — the GPU-side slot tables can be
|
||
read back and printed. The `Renderer` ships a debug helper (intentionally **not** part of the
|
||
documented API):
|
||
|
||
```rust
|
||
app.renderer().debug_dump(8); // prints the first 8 GPU slots to stderr
|
||
```
|
||
|
||
It dumps exactly what the GPU sees: the transform slots, the derived world matrices, the
|
||
indirect draw arguments, the mesh bounding boxes and the cull uniforms. A slot whose vertex
|
||
count reads `0` was zeroed by the cull pass (culled, inactive, or beyond `num_slots`); a full
|
||
count means the entity is drawn. In the `demo` example the dump is opt-in via an environment
|
||
variable, so the showcase stays silent by default:
|
||
|
||
```sh
|
||
WSG_DEBUG_DUMP=120 cargo run -p wsg-lib --example demo
|
||
```
|
||
|
||
`WSG_DEBUG_DUMP=N` dumps for the first *N* frames. The demo stays **silent** when the variable is
|
||
unset; a set-but-non-numeric value (e.g. `WSG_DEBUG_DUMP=on`) gives 3 frames.
|
||
|
||
**How to verify culling is actually working** (a correct culling pass is invisible — culled
|
||
objects were off-screen anyway — so the proof is in the counts, not the image):
|
||
|
||
1. Launch the demo with `WSG_DEBUG_DUMP=120` (the demo has culling **on** and an orbiting
|
||
camera — drag the mouse to orbit).
|
||
2. Note first that orbiting/zooming this camera **cannot cull the entity ring**: the camera
|
||
always looks at the origin, so each entity's angular offset from the view axis is bounded
|
||
by `atan(ring radius / camera distance)` = `atan(1.7/6.1)` ≈ 15.5°, under the ~22° vertical
|
||
half-FOV. The seven demo entities therefore keep their **full** counts (cube `36`, sphere
|
||
`3840`, …) in every orientation — that is the expected and correct behaviour (verified
|
||
2026-09-22: 600-frame camera sweep, GPU cull verdicts matched an independent CPU sphere
|
||
test on all 6000 entity frames, zero flips on the ring).
|
||
3. To see the counts actually flip to **`0`**, you need an entity well **off the target axis**
|
||
— e.g. one placed far away so it ends up behind the near plane. Its count then toggles
|
||
`0` ↔ full as the camera orbits, while the on-axis entities stay full. (This off-axis test
|
||
is the one that verified the cull path end-to-end, positive and negative.)
|
||
4. Optional A/B: temporarily build with `.with_culling(false)` and repeat — with culling off,
|
||
every entity keeps its full count in **every** orientation (the off-axis one included).
|
||
|
||
This readback is the reference truth when a shader bug is suspected: it shows both the computed
|
||
counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how
|
||
the 2026-09-22 « black window » bug — an inverted WGSL `select` argument order — was diagnosed
|
||
and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
|
||
|
||
## Limitations
|
||
|
||
- **Culling is all-or-nothing per entity.** There is no partial (per-triangle) culling.
|
||
- **The sphere is a coarse bound** for elongated meshes (a long thin box gets a large sphere).
|
||
If your scene is dominated by such shapes, culling may bring little gain.
|
||
- **Capacity is 256 entities per render pass.** Beyond that, extra entities are not drawn.
|
||
This is the largest a single-buffer design can address under WebGPU's two `uniform` rules: a
|
||
single `uniform` binding is capped at 64 KB, *and* a `uniform` offset must be a multiple of 256 B.
|
||
A 64-byte matrix can never be individually addressable by a `uniform` offset, so each matrix
|
||
slot is padded to 256 B — and 256 slots × 256 B = 64 KB is the maximum. It is amply generous
|
||
for a simple scene (the demo has 7).
|
||
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
|
||
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
|
||
|
||
Culling is a **performance** feature, not a visual one: with it off you get the same image with
|
||
the indirect-draw machinery still active.
|
||
|
||
---
|
||
|
||
Next: [Examples](examples.md) · Back to [User documentation index](README.md)
|