eng doc
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Cameras — user documentation
|
||||
|
||||
The **viewpoint side**: the active camera, the orbital controller, unified input, and the
|
||||
GPU-driven pipeline (frustum culling, LOD) that the camera drives.
|
||||
|
||||
| Page | Topic |
|
||||
|------|-------|
|
||||
| [Camera & input](camera-input.md) | Active camera, `CameraController` (orbit/zoom/reset), unified keyboard/mouse state, recipes |
|
||||
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling, LOD, debugging |
|
||||
|
||||
Example folder: [`lib/examples/cameras/`](../../../lib/examples/cameras/README.md)
|
||||
(`culling`).
|
||||
|
||||
## Links
|
||||
|
||||
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
|
||||
@@ -0,0 +1,127 @@
|
||||
# Camera & input
|
||||
|
||||
Two bricks drive the viewpoint: the scene's **active `Camera`** (view/projection matrices
|
||||
built every frame) and the unified **`InputState`** (keyboard/mouse, cross-frame
|
||||
semantics). The orbital **`CameraController`** bridges the two.
|
||||
|
||||
## 1. The active camera
|
||||
|
||||
The scene holds a single camera, read by the engine every frame to write the view/projection
|
||||
matrices into the frame buffer (aspect recomputed from the window size).
|
||||
|
||||
```rust
|
||||
use wsg_lib::camera::Camera;
|
||||
use glam::Vec3;
|
||||
|
||||
app.scene.set_camera(Camera::new(
|
||||
Vec3::new(3.0, 2.0, 3.0), // eye position
|
||||
Vec3::ZERO, // target point
|
||||
Vec3::Y, // "up" vector
|
||||
));
|
||||
```
|
||||
|
||||
- **Default**: position `(0, 0, 3)`, looking at the origin, 45° vertical fov, near 0.1,
|
||||
far 100 — frames a unit cube with no tuning.
|
||||
- `Camera::with_perspective(fov, near, far)` adjusts the projection (fov in radians).
|
||||
- Read: `app.scene.camera()`; direct mutation: `app.scene.camera_mut()`.
|
||||
- The `up` field matters: the orbital camera forces it to `+Y` (level horizon).
|
||||
|
||||
> The matrices use the **WebGPU** convention (NDC depth `[0,1]`) — do not replace
|
||||
> `projection_matrix` with an OpenGL `[-1,1]` projection, the near part of the frustum would
|
||||
> be clipped.
|
||||
|
||||
## 2. The orbital controller
|
||||
|
||||
`CameraController` represents the viewpoint in spherical coordinates around a target:
|
||||
`yaw` (azimuth around +Y), `pitch` (elevation, bounded to ±~83°), `distance` (radius,
|
||||
bounded to `[0.1, 100]`), `target` (target point).
|
||||
|
||||
```rust
|
||||
use wsg_lib::camera::CameraController;
|
||||
|
||||
let mut ctrl = CameraController::default(); // target at origin, distance 3, front view
|
||||
ctrl.orbit(dx, dy); // mouse drag: yaw/pitch (bounded pitch, no poles)
|
||||
ctrl.zoom(scroll_y); // wheel: zoom (positive scroll = move closer)
|
||||
ctrl.reset(); // back to the default framing
|
||||
ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active camera (do this EVERY frame)
|
||||
```
|
||||
|
||||
`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera
|
||||
(useful to start the orbit from a manual framing).
|
||||
|
||||
Two public fields tune the feel of the camera (defaults in parentheses):
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|-------|---------|---------|
|
||||
| `orbit_sensitivity` | radians of yaw per pixel of mouse delta | `0.005` (~110° per full window width) |
|
||||
| `zoom_factor` | multiplicative distance change per wheel notch (`distance *= factor^scroll`) | `0.9` (10% per notch) |
|
||||
|
||||
The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from
|
||||
`app.input`) is in [`demo.rs`](../../../lib/examples/effects/demo.rs), `update()` section.
|
||||
|
||||
## 3. The unified input state
|
||||
|
||||
`app.input` (public field of `App`) is fed by winit events and **rotated** automatically
|
||||
every frame (`begin_frame`/`end_frame` around your `update`). Three semantics per control:
|
||||
|
||||
| Semantics | Methods | Meaning |
|
||||
|------------|----------|---------|
|
||||
| **pressed** | `key_pressed(code)`, `mouse_button_pressed(btn)` | true **only** on the frame the key/button was just pressed |
|
||||
| **held** | `key_held(code)`, `mouse_button_held(btn)` | true while the key/button stays down |
|
||||
| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame |
|
||||
|
||||
Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the
|
||||
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel, in **line/notch units** —
|
||||
`PixelDelta` events are normalized by /32 so one physical wheel notch ≈ 1.0 on every backend).
|
||||
|
||||
> **Button-gated orbit**: `mouse_delta()` returns movement *whenever* the mouse moves. For a
|
||||
> classic arc-rotate camera, apply it only while a button is held — that is what the `demo` does:
|
||||
> `if app.input.mouse_button_held(MouseButton::Left) { self.camera.orbit(dx, dy); }`.
|
||||
> Free-movement orbit (no button) is also possible, just drop the condition.
|
||||
|
||||
`KeyCode` values are winit's physical codes (`winit::keyboard::KeyCode`); mouse buttons are
|
||||
`winit::event::MouseButton`. The library does not re-export them: if your code mentions
|
||||
them, add `winit = "0.30"` to your own dependencies (as the examples do). Input-less
|
||||
applications (like `simple`/`cube`) don't need winit: `app.input` remains usable, only
|
||||
`KeyCode` comparisons require the import.
|
||||
|
||||
```rust
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
fn update(&mut self, app: &mut wsg_lib::App) {
|
||||
// Orbit (left-drag gated) + zoom driven by the mouse (excerpts from demo):
|
||||
let (dx, dy) = app.input.mouse_delta();
|
||||
if app.input.mouse_button_held(MouseButton::Left) {
|
||||
self.camera.orbit(dx, dy);
|
||||
}
|
||||
let (_, sy) = app.input.scroll_delta();
|
||||
self.camera.zoom(sy);
|
||||
|
||||
// R: reset — key_pressed fires once, not on key-repeat.
|
||||
if app.input.key_pressed(KeyCode::KeyR) {
|
||||
self.camera.yaw = 0.6;
|
||||
self.camera.pitch = 0.35;
|
||||
self.camera.distance = 6.5;
|
||||
}
|
||||
self.camera.apply_to(app.scene.camera_mut());
|
||||
}
|
||||
```
|
||||
|
||||
> **Gamepad**: the API is reserved (`InputState` will pass through `DeviceEvent`s) but not
|
||||
> implemented yet — deferred, see [ROADMAP](../../ROADMAP.md).
|
||||
|
||||
## 4. Common recipes
|
||||
|
||||
| Need | Recipe |
|
||||
|--------|--------|
|
||||
| Standard orbital camera | `CameraController` + `mouse_delta`/`scroll_delta` (snippet above) |
|
||||
| FPS camera (WASD) | `key_held(KeyCode::KeyW)` in `update` → move `camera.position`/`target`; override `render()` if needed |
|
||||
| Changing the orbit target | `ctrl.target = subject_position;` (following an object) |
|
||||
| View presets | `key_pressed(Digit1/2/3)` → write yaw/pitch/distance (from the `demo`) |
|
||||
| Tuning the camera speed | `ctrl.orbit_sensitivity = 0.003;` (slower orbit), `ctrl.zoom_factor = 0.95;` (gentler zoom) |
|
||||
|
||||
## Links
|
||||
|
||||
- [User README](../README.md) · [Lights](../lights/lights.md) · [Examples](../examples.md)
|
||||
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.md)
|
||||
@@ -0,0 +1,245 @@
|
||||
# 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):
|
||||
|
||||
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). 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
|
||||
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.
|
||||
|
||||
## 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 **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 strictly
|
||||
less than half a tile apart on both coordinates — an offset of exactly ½ is ambiguous: a wrap
|
||||
seam at its widest or a legitimate half-tile jump — and the normals within ~25°; a seam or a
|
||||
hard edge therefore stays a separate corner, and the weld *records* the integer-apart pairs it
|
||||
refused); on a mesh with a UV seam those **seam twins are frozen** — every edge touching one
|
||||
is excluded from the collapse queue — so the zero-width slit stays closed at every level, and
|
||||
the rim protection (no boundary collapse while any interior edge remains) keeps the surface
|
||||
geometrically complete; a survivor **moved** by a collapse gets its UV/color/normal
|
||||
**blended linearly** between the collapsed endpoints (same λ as its new position — the chart
|
||||
is bilinear, so the blend is the exact chart value at the new point: texture and shading stay
|
||||
attached to the surface and coarsen smoothly across levels, and a seam is never crossed because
|
||||
its twins are frozen, not because a blend is rejected; 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.
|
||||
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
|
||||
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, 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
|
||||
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).
|
||||
- **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](../examples.md) · Back to [User documentation index](../README.md)
|
||||
Reference in New Issue
Block a user