This commit is contained in:
Jérôme Bousquié
2026-09-25 20:06:10 +02:00
parent 24fbafc810
commit d4c2d93fc5
31 changed files with 988 additions and 881 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Note: `standard_shader.wgsl` (Phong + PBR, with an explicit **unlit** mode) is t
A Compute Pass derives each entity's world matrix and fills per-entity indirect draw arguments (with opt-in frustum culling), then the main and shadow render passes issue one indirect draw per active slot.
Spec: [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) · User guide: [docs/user/gpu-driven.md](docs/user/gpu-driven.md)
Spec: [docs/tech/ARCHI_CPU_GPU.md](docs/tech/ARCHI_CPU_GPU.md) · User guide: [docs/user/cameras/gpu-driven.md](docs/user/cameras/gpu-driven.md)
### Module layout
+2 -2
View File
@@ -36,7 +36,7 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> groupés par `Material` (1 `set_pipeline` + 1 bind group @2 par matériau distinct, pas par entité ;
> 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 ».
> Détail : `docs/user/cameras/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é) ;
@@ -55,7 +55,7 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> 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`
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/cameras/gpu-driven.md`
> § « Level of Detail ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
+42 -49
View File
@@ -1,64 +1,57 @@
# User documentation — WSG
# WSG — User documentation
**Usage** documentation for the `wsg-lib` crate: how to build a 3D rendering application
without touching wgpu directly. It targets a developer with basic Rust knowledge; no prior
GPU graphics background is required.
> **Not to be confused**: these pages explain *how to use* the API. The **technical**
> documentation (internal architecture, design decisions, future targets) lives in
> [../tech/](../tech/ARCHI_APP.md), and the exhaustive API reference is generated by rustdoc
> (`cargo doc -p wsg-lib --no-deps`).
WSG (WGPU Simple Graphics) is a 3D graphics engine built on top of
[wgpu](https://docs.rs/wgpu) and [winit](https://docs.rs/winit).
It deliberately provides **no scene-graph abstraction**: you create resources,
place entities, and write the frame loop yourself. The engine handles the rest
(GPU context, compilation, command encoding, presentation).
## Where to start
1. [Quickstart](quickstart.md) — your first window and your first object, in ~30 lines.
2. Then, at your own pace, depending on what you need:
2. Then, at your pace, pick a **topic folder** (which mirrors the example folders in
[`lib/examples/`](../../lib/examples/README.md) — each page is paired with its examples):
| Page | Topic |
|-------|-------|
| [Meshes](meshes.md) | Geometries: procedural primitives, custom `Geometry`, entities and `Transform` |
| [Materials & textures](materials.md) | Appearance: the `standard` shader, unlit mode, diffuse textures |
| [Lights](lights.md) | Directional, point, spot, ambient, `MAX_LIGHTS` |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall |
| [Mesh & primitives](mesh.md) | Procedural generators + file import (OBJ), feature-gated |
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` |
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus plane, opt-in via `with_dof()` |
| [GPU-driven rendering](gpu-driven.md) | GPU world matrices + indirect draws, opt-in frustum culling |
| [Camera & input](camera-input.md) | Active camera, orbital controller, unified keyboard/mouse state |
| [Examples](examples.md) | The 16 repo examples in 4 folders (`meshes/`, `lights/`, `cameras/`, `effects/`), the advanced `manual` workflow, adding your own example |
| Folder | Pages |
|--------|-------|
| [`meshes/`](meshes/README.md) | [Meshes](meshes/meshes.md) — geometries, entities and `Transform` · [Geometry sources](meshes/sources.md) — procedural generators + file import · [Materials & textures](meshes/materials.md) — the `standard` shader, unlit mode, textures |
| [`lights/`](lights/README.md) | [Lights](lights/lights.md) — directional/point/spot/ambient, `MAX_LIGHTS` · [Shadows](lights/shadows.md) — shadow mapping · [Emissive + Exposure](lights/emissive-exposure.md) |
| [`cameras/`](cameras/README.md) | [Camera & input](cameras/camera-input.md) — orbital controller, unified input · [GPU-driven rendering](cameras/gpu-driven.md) — culling, LOD |
| [`effects/`](effects/README.md) | [HDR](effects/hdr.md) · [Bloom](effects/bloom.md) · [MSAA](effects/msaa.md) · [Fog](effects/fog.md) · [DoF](effects/dof.md) |
The pages are cross-linked: each page ends with a link to the next one.
Plus [Examples](examples.md) — the 16 examples of the repo in 4 folders, the advanced
`manual` workflow, and how to add your own example.
## Design principle: opt-in = zero cost
The pages are cross-linked: each page ends with links to its related pages.
WSG follows a strict rule: **a feature you don't enable costs nothing at runtime**.
## Design principles
| Feature | How to enable | If NOT enabled |
|---------|--------------|----------------|
| Shadows | `scene.set_shadow_caster(Some(idx))` | No shadow map allocated, no depth pass, no PCF sampling |
| HDR + Tone mapping | `AppBuilder::with_hdr(ToneMapper::Aces)` | No offscreen texture, no TM pass, direct-to-surface render |
| MSAA | `AppBuilder::with_msaa(4)` | Single-sample (1×), zero overhead |
| GPU-driven culling | `AppBuilder::with_gpu_driven(true)` | No compute pipeline, no indirect draw buffers |
| LOD | `scene.create_mesh_with_lod(…, levels)` | Single-level mesh, no decimation, no hysteresis |
| Primitives | Cargo feature `prim-*` (default: all) | Not compiled at all |
| File import | Cargo feature `import-*` | Not compiled at all |
- **Explicit over magic**: no scene graph, no ECS, no hidden state machine. What you write
is what runs.
- **The handler drives the loop**: `AppHandler` is the only required trait (`setup`,
`update`, `render` + optional event hook).
- **String IDs everywhere**: meshes, materials, textures and entities are referenced by
label — no integer handles to manage, errors are readable.
- **Safe core, `unsafe` at the edges**: the public API is fully safe; `unsafe` is confined
to the raw-pointer interop layer.
- **Feature-gated primitives**: every primitive and importer behind a Cargo feature
(`prim-cube`, `import-obj`, …) — default is `all-prims` + `import-obj`.
The distinction matters:
- **Runtime opt-in** (shadows, HDR, culling, LOD): the code is compiled into your binary
but is **completely inert** if you never call the activation method. No GPU resources are
allocated, no passes execute, no per-frame overhead. The cost of the code being in the
binary is a few KB — negligible.
- **Compile-time opt-in** (primitives, import): the code is **not compiled at all** unless
you opt in via Cargo features. This matters when you want to minimize compile time or
binary size for a minimal build.
## Documentation tree
You can mix both: build with `--no-default-features --features "prim-cube"` for a minimal
binary, then enable shadows/HDR at runtime only for the scenes that need them.
```
README.md this index (the one you are reading)
quickstart.md the 30-line path to a window + a cube
examples.md the 16 repo examples, the manual workflow, adding your own
meshes/ meshes, geometry sources, materials & textures
lights/ lights, shadows, emissive + exposure
cameras/ camera & input, GPU-driven rendering (culling, LOD)
effects/ HDR, bloom, MSAA, fog, DoF
```
## Links
- Technical documentation (architecture): [ARCHI_APP](../tech/ARCHI_APP.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md) · [ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_ARENES](../tech/ARCHI_ARENES.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md) · [DRAFT](../DRAFT.md)
- Full API reference: `cargo doc -p wsg-lib --no-deps`
- [Root README](../../README.md)
- Technical docs: [ARCHI_APP](../tech/ARCHI_APP.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) ·
[ARCHI_CPU_GPU](../tech/ARCHI_CPU_GPU.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
- [ROADMAP](../ROADMAP.md)
-93
View File
@@ -1,93 +0,0 @@
# Bloom (Étape 23)
Le **bloom** est un post-process qui crée un effet de "glow" autour des zones brillantes de
l'image. Les pixels dont la luminance dépasse un seuil sont extraits, floutés, puis ajoutés
à l'image originale.
> **Prérequis** : le bloom nécessite l'HDR (`AppBuilder::with_hdr`). Sans HDR, les valeurs
> sont déjà clampées à [0,1] et il n'y a rien de "brillant" à extraire.
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces) // requis
.with_bloom(BloomConfig {
threshold: 1.0, // seuil de luminance HDR
knee: 0.5, // largeur du soft-knee
intensity: 0.8, // intensité du glow
radius: 4.0, // rayon du blur (pixels, demi-rés)
..Default::default()
})
.build()
.await?;
```
## `BloomConfig`
| Champ | Type | Défaut | Description |
|-------|------|--------|-------------|
| `threshold` | `f32` | `1.0` | Seuil de luminance (unités HDR linéaires). Seuls les pixels > seuil contribuent au bloom. |
| `knee` | `f32` | `0.5` | Largeur du soft-knee. Plus grand = transition plus douce. |
| `intensity` | `f32` | `0.8` | Multiplicateur appliqué au résultat flouté avant addition à l'HDR. |
| `radius` | `f32` | `4.0` | Rayon du blur en pixels (à la demi-résolution). Plus grand = glow plus étendu. |
## Mise à jour runtime
```rust
// Dans le handler (fn update):
if app.bloom_enabled() {
app.set_bloom_config(BloomConfig {
intensity: new_intensity,
..app.bloom_config()
});
}
```
Les changements prennent effet au frame suivant (les uniforms sont ré-écrits chaque frame).
## Pipeline (4 passes GPU)
```
Scene ──→ HDR (full res, Rgba16Float)
│
├──→ [1] Threshold (full → half res)
│ Soft-knee: smoothstep(knee, knee+1, lum)
│
├──→ [2] Blur H (half res)
│ 9-tap Gaussian séparable, direction = (1/w, 0)
│
├──→ [3] Blur V (half res)
│ 9-tap Gaussian séparable, direction = (0, 1/h)
│ (ping-pong: écrit dans la texture bright)
│
└──→ [4] Composite (full res)
output = HDR + bloom × intensity
(écrit dans une 3e texture full-res)
│
▼
Tone Mapping (lit le composite)
│
▼
Surface (sRGB)
```
## Coût
- **Sans bloom** (défaut) : zéro overhead. Le TM lit directement la texture HDR.
- **Avec bloom** : 4 passes supplémentaires (1 full-res + 3 half-res) + 3 textures
intermédiaires. Le coût est modéré car le blur est en demi-résolution.
## Non-régression
- `with_bloom()` sans `with_hdr()` → warning + no-op (le bloom est ignoré).
- Sans `with_bloom()` → le TM lit la texture HDR directement (comportement Étape 20 inchangé).
## Limitations (MVP)
- Un seul niveau de mip (pas de multi-mip "soft" bloom à la Unreal).
- Pas de directional bloom.
- Le blur est un Gaussian 9-taps (qualité suffisante pour un glow "soft").
- Pas de bloom séparé par couche (pas de "bloom mask" par matériau).
+16
View File
@@ -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)
@@ -57,7 +57,7 @@ Two public fields tune the feel of the camera (defaults in parentheses):
| `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.
`app.input`) is in [`demo.rs`](../../../lib/examples/effects/demo.rs), `update()` section.
## 3. The unified input state
@@ -109,7 +109,7 @@ fn update(&mut self, app: &mut wsg_lib::App) {
```
> **Gamepad**: the API is reserved (`InputState` will pass through `DeviceEvent`s) but not
> implemented yet — deferred, see [ROADMAP](../ROADMAP.md).
> implemented yet — deferred, see [ROADMAP](../../ROADMAP.md).
## 4. Common recipes
@@ -123,5 +123,5 @@ fn update(&mut self, app: &mut wsg_lib::App) {
## Links
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
- [User README](../README.md) · [Lights](../lights/lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.md)
@@ -242,4 +242,4 @@ the indirect-draw machinery still active.
---
Next: [Examples](examples.md) · Back to [User documentation index](README.md)
Next: [Examples](../examples.md) · Back to [User documentation index](../README.md)
+19
View File
@@ -0,0 +1,19 @@
# Effects — user documentation
The **post-process side**: everything that happens between the main pass and the screen.
All effects are opt-in — a feature you don't enable costs nothing (no textures, no passes).
| Page | Topic |
|------|-------|
| [HDR & tone mapping](hdr.md) | Offscreen float render + ACES/Reinhard, opt-in via `with_hdr` |
| [Bloom](bloom.md) | Post-process glow: threshold → blur → composite |
| [MSAA (anti-aliasing)](msaa.md) | Multi-sample edge smoothing, opt-in via `with_msaa(4)` |
| [Fog (distance)](fog.md) | Distance fog (3 modes), masks world edges, opt-in via `with_fog()` |
| [DoF (depth of field)](dof.md) | Cinematic bokeh blur, focus distance, opt-in via `with_dof()` |
Example folder: [`lib/examples/effects/`](../../../lib/examples/effects/README.md)
(`demo`, `bloom`, `hdr`, `msaa`, `fog`, `dof`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
+99
View File
@@ -0,0 +1,99 @@
# Bloom (Step 23)
**Bloom** is a post-process that creates a "glow" effect around the bright areas of the image.
Pixels whose luminance exceeds a threshold are extracted, blurred, then added back to the
original image.
> **Prerequisite**: bloom requires HDR (`AppBuilder::with_hdr`). Without HDR, values are already
> clamped to [0,1] and there is nothing "bright" to extract.
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces) // required
.with_bloom(BloomConfig {
threshold: 1.0, // HDR luminance threshold
knee: 0.5, // soft-knee width
intensity: 0.8, // glow intensity
radius: 4.0, // blur radius (pixels, half-res)
..Default::default()
})
.build()
.await?;
```
## `BloomConfig`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `threshold` | `f32` | `1.0` | Luminance threshold (linear HDR units). Only pixels above the threshold contribute to the bloom. |
| `knee` | `f32` | `0.5` | Soft-knee width. Larger = smoother transition. |
| `intensity` | `f32` | `0.8` | Multiplier applied to the blurred result before adding it to the HDR. |
| `radius` | `f32` | `4.0` | Blur radius in pixels (at half resolution). Larger = wider glow. |
## Runtime update
```rust
// In the handler (fn update):
if app.bloom_enabled() {
app.set_bloom_config(BloomConfig {
intensity: new_intensity,
..app.bloom_config()
});
}
```
Changes take effect on the next frame (the uniforms are re-written every frame).
## Pipeline (4 GPU passes)
```
Scene ──→ HDR (full res, Rgba16Float)
│
├──→ [1] Threshold (full → half res)
│ Soft-knee: smoothstep(knee, knee+1, lum)
│
├──→ [2] Blur H (half res)
│ 9-tap separable Gaussian, direction = (1/w, 0)
│
├──→ [3] Blur V (half res)
│ 9-tap separable Gaussian, direction = (0, 1/h)
│ (ping-pong: writes into the bright texture)
│
└──→ [4] Composite (full res)
output = HDR + bloom × intensity
(writes into a 3rd full-res texture)
│
▼
Tone Mapping (reads the composite)
│
▼
Surface (sRGB)
```
## Cost
- **Without bloom** (default): zero overhead. The TM reads the HDR texture directly.
- **With bloom**: 4 extra passes (1 full-res + 3 half-res) + 3 intermediate textures. The cost
is moderate because the blur runs at half resolution.
## Non-regression
- `with_bloom()` without `with_hdr()` → warning + no-op (the bloom is ignored).
- Without `with_bloom()` → the TM reads the HDR texture directly (the Step 20 behavior is
unchanged).
## Limitations (MVP)
- A single mip level (no multi-mip "soft" bloom à la Unreal).
- No directional bloom.
- The blur is a 9-tap Gaussian (good enough for a "soft" glow).
- No per-layer bloom (no per-material "bloom mask").
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+64
View File
@@ -0,0 +1,64 @@
# DoF (depth of field)
Depth of field simulates camera-lens behavior: objects at the **focus distance** are sharp,
everything else is progressively blurred (the cinematic "bokeh" look). DoF is a post-process
that operates on the HDR texture + depth buffer, before tone mapping.
> **Prerequisite**: like bloom, DoF reads the HDR texture (and the depth buffer for the
> focus/blur computation). Enable HDR together with it.
## Activation
DoF is opt-in through the builder. Without it, no DoF textures are allocated and the pipeline
cost is **zero**:
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces)
.with_dof(DoFConfig::cinematic(4.0)) // sharp at 4.0 world units
.build()
.await?;
```
## `DoFConfig`
| Field | Type | Meaning |
|-------|------|---------|
| `focus_distance` | `f32` | World distance where the image is perfectly sharp |
| `aperture` | `f32` | Blur intensity (0.0–1.0, clamped). Scales the circle of confusion |
| `max_blur` | `f32` | Maximum blur radius in pixels (clamps the CoC) |
Presets:
```rust
DoFConfig::new(focus_distance, aperture, max_blur) // custom
DoFConfig::cinematic(focus_distance) // aperture 0.3, max blur 12 px (cutscenes)
DoFConfig::subtle(focus_distance) // aperture 0.1, max blur 8 px (gameplay)
```
## Runtime change
The `dof` example switches focus presets with the keys `1`–`4` (near / mid / far / infinity)
and follows the zoom:
```sh
cargo run -p wsg-lib --example dof
```
## Cost
- **Without DoF** (default): zero overhead — no textures, no pass.
- **With DoF**: 1 extra fullscreen pass + 2 intermediate textures (the bokeh buffer), before
tone mapping.
## Limitations (MVP)
- A single focus distance per frame (no per-pixel focus / rack-focus over time).
- The blur is a fixed-radius Gaussian scaled by the circle of confusion.
## Links
- [User README](../README.md) · [Bloom](bloom.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+135
View File
@@ -0,0 +1,135 @@
# Distance fog
## Principle
Distance fog blends objects toward a predefined color based on their distance to the camera.
It is the standard tool for:
- **Hiding the rendered edge of the world** — the illusion of an infinite world (Skyrim, GTA,
Minecraft)
- **Adding depth** — a natural atmospheric effect
- **Masking transitions** — tile loading, LOD pops
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.build()
.await?;
```
Without `.with_fog()`, fog is disabled — **zero GPU cost** (the shader branch is never taken).
## Modes
| Mode | Formula | Use |
|------|---------|-----|
| `Linear` | `saturate((far - d) / (far - near))` | Sharp cutoff between two distances |
| `Exponential` | `exp(-density × d)` | Natural fog (forest, lake) |
| `Exponential2` | `exp(-density² × d²)` | Gradual start, sharp cutoff — **ideal for masking** |
### Constructors
```rust
// Linear: fade between near and far
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
// Exponential: natural fade
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
// Exponential²: world-edge masking
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
```
## Parameters
| Field | Type | Description |
|-------|------|-------------|
| `mode` | `FogMode` | Linear / Exponential / Exponential2 |
| `color` | `[f32; 3]` | Fog color (RGB, linear space) |
| `near` | `f32` | Start distance (linear mode only) |
| `far` | `f32` | End distance, full fog (linear mode) |
| `density` | `f32` | Density (exp / exp² modes). Typical: 0.01–0.3 |
### Choosing the color
The fog color **must match the sky/clear color** for a seamless "infinite world" effect. With
HDR + ACES, use linear values consistent with the tone mapping.
### Choosing the density (exp²)
To mask the edge of the world at a distance `D`:
```
density ≈ 2.0 / D
```
Examples:
- World visible up to 25 units → `density = 0.08`
- World visible up to 50 units → `density = 0.04`
- World visible up to 100 units → `density = 0.02`
## Runtime change
```rust
// In update():
if key_pressed(KeyCode::Digit1) {
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
}
if key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None); // disable
}
```
The change takes effect on the next frame.
## Pipeline
```text
Main pass (fragment shader)
↓
Lighting → final_rgb
↓
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← here
↓
→ HDR texture / swapchain
↓
(Bloom) → Tone Mapping → surface
```
Fog is applied **before** tone mapping: HDR values stay unclamped, and the TM applies the
ACES/Reinhard curve to the already-fogged result. Result: the fog is perceptually coherent.
## Compatibility
| With | OK? | Note |
|------|-----|------|
| HDR + TM | ✅ | Fog before TM (recommended) |
| Bloom | ✅ | Bloom extracts the bright areas of the post-fog result |
| MSAA | ✅ | Independent (rasterizer vs fragment shader) |
| GPU culling | ✅ | Independent (culling decides what to draw, fog decides the color) |
| Shadows | ✅ | The shadow is computed before the fog |
## Limitations (v1)
- **Scene-level only**: a single fog for the whole scene. Per-material fog would require an
extra parameter in the per-object bind group.
- **Euclidean distance**: no volumetric or directional fog.
- **Fixed color**: no color gradient with distance.
## Example
See `lib/examples/effects/fog.rs`: 15 cubes in a row + 5 spheres on an 80×80 plane, with
runtime switching between the 3 modes.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
+88
View File
@@ -0,0 +1,88 @@
# HDR & tone mapping
> **Step 20** — Opt-in HDR rendering with tone mapping.
## Principle
By default, WSG renders **directly to the swapchain** in 8-bit sRGB. This is fine for simple
scenes, but the moment you want **bloom** or physically plausible intensities, you hit the
ceiling: 8-bit clamps everything to [0,1] before any post-process can run.
When HDR is enabled, the scene is first rendered into an **offscreen float texture**
(`Rgba16Float`, full window resolution), where values are unbounded (no clamping). The
**tone mapping** pass then compresses the HDR signal into [0,1] sRGB for the swapchain.
```
Without HDR (default) With HDR (.with_hdr(ToneMapper::Aces))
───────────────────── ──────────────────────────────────────
Scene ──────────→ Swapchain Scene ──→ HDR texture (Rgba16Float)
(8-bit sRGB, clamped) │ (float, unbounded)
▼
Tone Mapping (ACES / Reinhard)
│
▼
Swapchain (sRGB)
```
## Enabling
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces) // or ToneMapper::Reinhard
.build()
.await?;
```
- **Without** `with_hdr`: the pipeline is untouched, **zero cost** (no extra texture, no
extra pass).
- **With** `with_hdr`: one extra full-res texture + one fullscreen pass per frame. Negligible
cost on any discrete GPU; moderate on an integrated one (full-res read+write).
## Tone mappers
| Curve | Characteristics |
|-------|----------------|
| `Aces` | **Default.** Filmic look, good highlight roll-off, slightly desaturated in the shadows. The standard for games and engines. |
| `Reinhard` | Simple `c / (1 + c)`. Neutral and fast, but highlights "washed out" (the curve saturates quickly). |
> The `demo` example starts in **LDR** (no HDR): the sphere's emissive intensity of 3.0 is
> clamped to 1.0 — it looks "burnt" but no glow. Pressing a key enables HDR+ACES and the
> highlight rolls off gracefully.
## Exposure
Runtime-adjustable since Step 22: initialize with `AppBuilder::with_exposure(…)`, adjust with
`app.set_exposure(…)` (multiplicative, clamped to [0.01, 10.0] — only active when HDR is on).
See [Emissive + Exposure](../lights/emissive-exposure.md).
## Interaction with other features
| Feature | Behavior under HDR |
|---------|-------------------|
| **Shadows** | Unchanged — the shadow pass still writes depth; the color pass just targets the HDR texture instead of the swapchain. |
| **Fog** | Applied **before** tone mapping (inside the main pass). The fog color must be chosen in linear space, consistent with the tone curve. |
| **MSAA** | Compatible — the HDR texture becomes the multisample render target and is resolved before tone mapping. |
| **Bloom** | **Requires** HDR. Without it, bloom is ignored (warning). |
## Cost and non-regression
- No HDR (default): nothing is allocated, nothing is run. The swapchain is targeted directly.
- HDR enabled: one extra `Rgba16Float` texture + one fullscreen pass (tone mapping). If bloom
is also enabled, three more half-res textures and a few more passes (see the bloom page).
- The `demo` example shows both paths side by side: the LDR start (emissive clamped) and the
HDR+ACES mode (highlight roll-off).
## Limitations
- No **auto-exposure** (histogram-based). Exposure is fixed at build time (the demo hardcodes
1.0).
- No **DITHERING** on the output: banding may appear in smooth gradients near black (sRGB 8-bit
ceiling).
## See also
- [Shadows](../lights/shadows.md)
- [GPU-driven rendering](../cameras/gpu-driven.md)
- [Examples](../examples.md)
+79
View File
@@ -0,0 +1,79 @@
# MSAA (anti-aliasing)
MSAA (Multisample Anti-Aliasing) smooths the edges of meshes by sampling each pixel multiple
times **at rasterization time** (before the fragment shader).
## Enabling
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_msaa(4) // 4x MSAA
.build()
.await?;
```
- **Without** `with_msaa`: the swapchain is used as-is (1x, no cost).
- **With** `with_msaa(4)`: the swapchain is created with `sample_count = 4`, and a
**resolve** pass (multisample → swapchain) runs at the end of each frame.
## Cost
- The **main pass** becomes more expensive (fragment shader run `sample_count` times per pixel
on aliased edges — in practice much less, since fully-covered pixels are only processed once).
- One extra **fullscreen resolve** per frame (GPU-native, very cheap).
- VRAM: the swapchain buffer is multiplied by `sample_count` (4x for 4x MSAA).
In practice: 4x MSAA is **negligible** on a discrete GPU and perfectly acceptable on an
integrated one for scenes of this complexity.
## MSAA + HDR
The two compose naturally:
```
Scene ──→ HDR multisample texture (sample_count = N)
│
▼ resolve
HDR texture (single sample)
│
▼ (bloom?)
▼ tone mapping
Swapchain (sRGB)
```
- `with_msaa(4)` + `with_hdr(…)`: the offscreen HDR texture becomes multisample and is
**resolved** before tone mapping (and before bloom, which operates on the single-sample
buffer).
- `with_msaa(4)` without HDR: the swapchain itself is multisample, resolved at the end of the
frame.
## MSAA + fog
Fog is applied **inside** the fragment shader (per sample), so it is inherently MSAA-compatible:
each sample computes its own fog factor based on its own depth. No aliasing on the fog
boundaries.
## What MSAA does NOT fix
- **Transparency aliasing** (there is no transparency in the engine — all opaque).
- **Texture shimmering** at distance: this is the domain of **anisotropic filtering** (already
enabled: `SampleFilter::AnisotropicClamped` + `anisotropy = 4`).
- **Temporal flicker** of fine details: this would be the domain of TAA (out of scope).
## Limitations
- `sample_count` is fixed **at swapchain creation** (not changeable at runtime without
recreating the window/surface).
- `2` and `4` are the useful values. `8` exists but the cost/quality ratio is bad.
- Not all adapters support MSAA on the swapchain — if unsupported, the builder falls back to
1x with a warning.
See `lib/examples/effects/msaa.rs` for a full interactive demo (torus + Icosphere, with
zoom/orbit, MSAA 4x by default).
## Links
- [User README](../README.md) · [HDR & tone mapping](hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
-107
View File
@@ -1,107 +0,0 @@
# Émissive + Exposure
## Principe
Deux features complémentaires (Étape 22) :
| Feature | Effet | Coût |
|---------|-------|------|
| **Exposure** (6.1) | Multiplie la luminance avant la courbe de tone mapping | Zéro si HDR inactif |
| **Emissive** (6.2) | Ajoute une couleur émise (indépendante des lumières) | Zéro si `emissive = [0,0,0,0]` |
## Exposure
### API
```rust
// Initialisation (optionnel, default = 1.0)
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces)
.with_exposure(1.5) // démarre plus clair
.build().await?;
// Runtime (dans update())
app.set_exposure(app.exposure() * 1.1); // +1 "stop"
app.set_exposure(1.0); // reset
```
### Comportement
- L'exposure est un **multiplicateur** appliqué à la texture HDR avant la courbe de tone mapping.
- `exposure = 2.0` → l'image est 2× plus claire (comme ouvrir le diaphragme d'un photo).
- `exposure = 0.5` → l'image est 2× plus sombre.
- Clampé à `[0.01, 10.0]` pour éviter les valeurs dégénérées.
- **N'a d'effet que si HDR est actif** (`with_hdr(...)`). En LDR, la valeur est ignorée.
### Clavier (demo)
| Touche | Effet |
|--------|-------|
| `+` | ×1.1 (plus clair) |
| `-` | ÷1.1 (plus sombre) |
| `0` | Reset à 1.0 |
## Emissive
### API
```rust
use wsg_lib::resources::Material;
// Créer un matériau avec émissivité
let mut mat = /* ... */;
mat.emissive = [1.0, 0.3, 0.1, 1.5]; // orange, intensité 1.5 (> 1.0 = glow HDR)
```
### Format
`emissive = [r, g, b, intensity]` :
- **rgb** : la couleur de l'émission (même espace que la couleur base du vertex)
- **a (intensity)** : le multiplicateur. `1.0` = couleur normale, `> 1.0` = surbrillance (ne se voit qu'en HDR)
### Formule shader
```
final_color = lit + base_color * emissive.rgb * emissive.a
```
- L'émission est **additive** : visible même dans le noir total (pas de lumière nécessaire).
- Elle est **indépendante des ombres** : un objet émissif ne projette pas d'ombre et n'est pas ombragé.
- `emissive = [0,0,0,0]` (default) → aucun changement (non-régression garantie).
### Cas d'usage
| Usage | Valeur |
|-------|--------|
| LED / indicateur | `[0, 1, 0, 1.0]` (vert, intensité normale) |
| Flamme / soleil | `[1, 0.8, 0.2, 3.0]` (orange, glow HDR) |
| Neon | `[0, 0.5, 1, 2.5]` (cyan, glow) |
| Inactif | `[0, 0, 0, 0]` (default) |
### Clavier (demo)
| Touche | Effet |
|--------|-------|
| `E` | Toggle glow orange sur la sphère/cylindre |
## Interactions
| Combination | Résultat |
|-------------|----------|
| Emissive + HDR + ACES | Glow doux, highlights roll off (le plus joli) |
| Emissive + LDR | Clamped à 1.0 (pas de glow, mais couleur visible dans le noir) |
| Emissive + shadows | L'objet émissif n'est PAS ombragé (l'émission bypass le shadow term) |
| Exposure + Emissive | L'exposure amplifie aussi l'émission (cohérent : tout est dans la texture HDR) |
## Non-régression
- **Emissive** : `[0,0,0,0]` par défaut → le shader additionne `base * 0 * 0 = 0` → aucun changement.
- **Exposure** : `1.0` par défaut → `pow(color, 1/1) = color` → aucun changement.
- Les deux sont **opt-in** : sans `with_hdr(...)` ni `emissive != 0`, le pipeline est identique à l'état précédent.
## Limitations (MVP)
- L'emissive est **par matériau**, pas par vertex (pas de gradient d'émission dans un mesh).
- L'emissive est **statique** à la création du matériau (changer `mat.emissive` requiert de re-registrer le matériau via `add_material`).
- Pas de **bloom** (Étape 23) : le glow HDR est visible mais pas "flou" / diffusé.
+53 -51
View File
@@ -1,70 +1,72 @@
# Examples
Sixteen examples live in [`lib/examples/`](../../lib/examples/), organized into
**four category folders** — [`meshes/`](../../lib/examples/meshes/README.md),
[`lights/`](../../lib/examples/lights/README.md),
[`cameras/`](../../lib/examples/cameras/README.md),
[`effects/`](../../lib/examples/effects/README.md) — each folder with its own
`README.md` (per-example details: keys, what to observe). All launch with
`cargo run -p wsg-lib --example <name>` (names are stable, run from the repo
root). They are **self-contained**: no assets on disk (procedural textures,
hard-coded geometries).
16 examples in **4 folders** (mirroring the topic folders of this documentation), covering the
full range of the engine — from a 2D quad to GPU-driven rendering.
| Folder | Example | What it shows | Corresponding page |
|--------|---------|---------------|--------------------|
| `meshes/` | `simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) |
| `meshes/` | `cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) |
| `meshes/` | `pbr` | PBR metallic/roughness + normal mapping (6 materials) | [Materials](materials.md) |
| `meshes/` | `import` | OBJ file import (non-graphical, prints stats to stdout) | [Meshes](mesh.md) |
| `meshes/` | `manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade | below |
| `lights/` | `shadow` | Shadow mapping in isolation (4 objects on a floor) | [Shadows](shadows.md) |
| `lights/` | `shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) |
| `lights/` | `spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) |
| `lights/` | `emissive` | Emissive materials (intensities 0 → 4.0) + runtime exposure | [Emissive & exposure](emissive-exposure.md) |
| `cameras/` | `culling` | GPU-driven frustum culling: 15×15 grid, off-frustum cubes skipped (zero CPU cost) | [GPU-driven](gpu-driven.md) |
| `effects/` | `demo` | The full showcase: ground + 6 LOD primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse, HDR/ACES, bloom | [All pages](README.md) |
| `effects/` | `bloom` | Post-process bloom (threshold → blur → composite) | [Bloom](bloom.md) |
| `effects/` | `hdr` | HDR + tone mapping (ACES) + runtime exposure control | [HDR](hdr.md) |
| `effects/` | `msaa` | MSAA 4× (smooth edges vs stair-stepped) | [MSAA](msaa.md) |
| `effects/` | `fog` | Distance fog (3 modes: linear, exp, exp²) | [Fog](fog.md) |
| `effects/` | `dof` | Depth of field (cinematic bokeh, focus presets) | [effects README](../../lib/examples/effects/README.md) |
All examples are in [`lib/examples/`](../../lib/examples/README.md); each folder has its own
README (description + how to run): [`meshes/`](../../lib/examples/meshes/README.md),
[`lights/`](../../lib/examples/lights/README.md), [`cameras/`](../../lib/examples/cameras/README.md),
[`effects/`](../../lib/examples/effects/README.md).
## The `manual` workflow (advanced)
| Example | Folder | What it shows | How to run | Corresponding page |
|---------|--------|---------------|------------|--------------------|
| `simple` | meshes | A 2D quad with vertex colors, unlit mode (~30 lines) | `cargo run -p wsg-lib --example simple` | [Quickstart](quickstart.md), [Materials](meshes/materials.md) |
| `cube` | meshes | A rotating cube: point + spot light, checkerboard texture, procedural normal map, orbit/zoom | `cargo run -p wsg-lib --example cube` | [Meshes](meshes/meshes.md), [Materials](meshes/materials.md), [Lights](lights/lights.md) |
| `pbr` | meshes | A procedural PBR material (metal/roughness) + a checker diffuse | `cargo run -p wsg-lib --example pbr` | [Materials](meshes/materials.md) |
| `import` | meshes | Wavefront **OBJ** import (CLI: file path as argument, procedural cube as fallback) | `cargo run -p wsg-lib --example import --features import-obj -- model.obj` | [Geometry sources](meshes/sources.md) |
| `manual` | meshes | **Advanced**: the full manual workflow — buffers, pipelines, command encoding, no helpers | `cargo run -p wsg-lib --example manual` | [ARCHI_APP](../tech/ARCHI_APP.md), [FRAME_LOOP](../tech/FRAME_LOOP.md) |
| `shadow` | lights | Shadow mapping: the classic pitfall — the packed-index shadow caster | `cargo run -p wsg-lib --example shadow` | [Shadows](lights/shadows.md) |
| `shadow_test` | lights | Shadow mapping in isolation (cleared list → your light is index 0) | `cargo run -p wsg-lib --example shadow_test` | [Shadows](lights/shadows.md) |
| `spot_test` | lights | A single spotlight (cone + penumbra), ambient nearly zero | `cargo run -p wsg-lib --example spot_test` | [Lights](lights/lights.md) |
| `emissive` | lights | Emissive materials + HDR glow, runtime exposure (+/-/0 keys) | `cargo run -p wsg-lib --example emissive` | [Emissive & exposure](lights/emissive-exposure.md) |
| `culling` | cameras | **GPU-driven**: world matrices + indirect draws on the GPU, opt-in frustum culling, LOD | `cargo run -p wsg-lib --example culling` | [GPU-driven](cameras/gpu-driven.md) |
| `demo` | effects | The full showcase: all features combined (shadows, HDR, bloom, MSAA, fog, lights, orbital camera) | `cargo run -p wsg-lib --example demo` | [All pages](README.md) |
| `bloom` | effects | HDR + bloom: threshold → blur → composite | `cargo run -p wsg-lib --example bloom` | [Bloom](effects/bloom.md) |
| `hdr` | effects | HDR + tone mapping (ACES / Reinhard), emissive showcase | `cargo run -p wsg-lib --example hdr` | [HDR](effects/hdr.md) |
| `msaa` | effects | 4x MSAA anti-aliasing on the swapchain | `cargo run -p wsg-lib --example msaa` | [MSAA](effects/msaa.md) |
| `fog` | effects | Distance fog, 3 modes switchable at runtime (linear / exponential / exp²) | `cargo run -p wsg-lib --example fog` | [Fog](effects/fog.md) |
| `dof` | effects | Depth of field: Gaussian blur scaled by defocus distance, cinematic bokeh; focus presets 1-4 + continuous zoom | `cargo run -p wsg-lib --example dof` | [DoF](effects/dof.md) |
When the `App` facade doesn't fit (fine-grained loop control, integration into an existing
framework, experimentation), you bypass `App` and drive directly:
## The `manual` example: bypassing the helpers
- `Context` (*Manager* layer): GPU lifecycle — `Instance`/`Surface`/`Adapter`/`Device`/
`Queue`, `configure()` for the swapchain, `get_next_frame()`.
- `Renderer` (*Executor* layer): `render(view, mesh, material)` = one object per submission;
`present(frame)`.
- `PipelineCache`: `register_shader(id, path)` then `Material::new(format, id, &mut cache)`.
[`manual.rs`](../../lib/examples/meshes/manual.rs) renders a rotating cube with **no
high-level helper at all** — no `Scene`, no `Renderer` convenience API, no `AppHandler`
default `render()`. It shows the full pipeline:
The window and GPU are created in winit 0.30's `resumed()` callback (`run_app` +
`ApplicationHandler`), as in `app.rs`. The reference file is
[`manual.rs`](../../lib/examples/meshes/manual.rs); the two-layer architecture is detailed in
[ARCHI_APP](../tech/ARCHI_APP.md) and [FRAME_LOOP](../tech/FRAME_LOOP.md).
1. **`setup`**: manual creation of vertex/index buffers, bind groups, render/compute
pipelines, the swapchain.
2. **`render` (overridden)**: manual command encoding per frame (clear, draw, present) —
the handler controls **every** `CommandEncoder` operation.
3. **Uniforms written by hand** with `queue.write_buffer` (projection, view, model matrices).
> **Tip**: start with the declarative workflow. The manual workflow doesn't render more
> pixels — it gives more control over command encoding.
This is the reference for what the high-level API does under the hood, and the starting
point for features that don't exist yet in the engine (custom pipelines, post-processes,
custom compute). The technical details are in [ARCHI_APP](../tech/ARCHI_APP.md) and
[FRAME_LOOP](../tech/FRAME_LOOP.md).
Rule of thumb: **use `AppHandler` + `Scene` for everything the engine already does, and drop
to `manual` style only when you need what it doesn't** — the two styles can be mixed in the
same app (e.g. `Scene` for the scene, a manual post-process pass in `render()`).
## Adding your own example
Repo conventions (see [`lib/examples/README.md`](../../lib/examples/README.md)):
1. Create `lib/examples/<folder>/<name>.rs` — pick the folder it belongs to
(`meshes` / `lights` / `cameras` / `effects`).
2. Declare the `[[example]]` entry in `lib/Cargo.toml` (the folder structure means Cargo
no longer auto-discovers examples):
1. Create `lib/examples/<folder>/my_example.rs` (pick the matching category —
`meshes/`, `lights/`, `cameras/`, `effects/` — or add a new folder + README).
2. Declare it in `lib/Cargo.toml` — examples live in subfolders, so Cargo does
**not** discover them automatically:
```toml
[[example]]
name = "my_example"
path = "examples/<folder>/my_example.rs"
name = "<name>"
path = "examples/<folder>/<name>.rs"
```
3. Keep it **self-contained**: procedural textures, hard-coded geometries, no external assets.
4. Document the example in the folder's `README.md` (and here, `docs/user/examples.md`).
3. Required features: the base crate has no primitives by default in examples — declare
`required-features` if your example uses them (e.g. `required-features = ["prim-cube"]`).
4. Register it in the folder's README and in the table above.
5. Verify: `cargo build --workspace --examples` + run it.
## Links
- [User README](README.md) · [Quickstart](quickstart.md) · [Camera & input](camera-input.md)
- [User README](README.md) · [Quickstart](quickstart.md)
- [Root README](../../README.md) · [ROADMAP](../ROADMAP.md)
-133
View File
@@ -1,133 +0,0 @@
# Brouillard de distance (Fog)
## Principe
Le brouillard de distance fond les objets vers une couleur prédéfinie en fonction
de leur distance à la caméra. C'est l'outil standard pour :
- **Masquer le bord du monde rendu** — illusion d'un monde infini (Skyrim, GTA, Minecraft)
- **Donner de la profondeur** — effet atmosphérique naturel
- **Camoufler les transitions** — chargement de tuiles, LOD pops
## Activation
```rust
use wsg_lib::prelude::*;
let app = AppBuilder::new()
.with_fog(FogConfig::exponential2([0.7, 0.75, 0.85], 0.06))
.build()
.await?;
```
Sans `.with_fog()`, le brouillard est désactivé — **zéro coût GPU** (la branche
shader est jamais prise).
## Modes
| Mode | Formule | Usage |
|------|---------|-------|
| `Linear` | `saturate((far - d) / (far - near))` | Cutoff net entre deux distances |
| `Exponential` | `exp(-density × d)` | Brouillard naturel (forêt, lac) |
| `Exponential2` | `exp(-density² × d²)` | Départ progressif, cutoff net — **idéal pour masquer** |
### Constructeurs
```rust
// Linéaire : fondu entre near et far
FogConfig::linear([0.7, 0.8, 0.9], 5.0, 50.0)
// Exponentiel : fondu naturel
FogConfig::exponential([0.6, 0.7, 0.8], 0.03)
// Exponentiel² : masquage de bord de monde
FogConfig::exponential2([0.7, 0.75, 0.85], 0.08)
```
## Paramètres
| Champ | Type | Description |
|-------|------|-------------|
| `mode` | `FogMode` | Linéaire / Exponentiel / Exponential2 |
| `color` | `[f32; 3]` | Couleur du brouillard (RGB, espace linéaire) |
| `near` | `f32` | Distance début (mode linéaire uniquement) |
| `far` | `f32` | Distance fin, brouillard complet (mode linéaire) |
| `density` | `f32` | Densité (modes exp / exp²). Typique : 0.01–0.3 |
### Choisir la couleur
La couleur du brouillard **doit correspondre à la couleur du ciel/clear color**
pour un effet "monde infini" seamless. Avec HDR + ACES, utiliser des valeurs
linéaires cohérentes avec le tone mapping.
### Choisir la densité (exp²)
Pour masquer le bord du monde à une distance `D` :
```
density ≈ 2.0 / D
```
Exemples :
- Monde visible jusqu'à 25 unités → `density = 0.08`
- Monde visible jusqu'à 50 unités → `density = 0.04`
- Monde visible jusqu'à 100 unités → `density = 0.02`
## Changement à l'exécution
```rust
// Dans update() :
if key_pressed(KeyCode::Digit1) {
app.renderer_mut().set_fog(Some(FogConfig::linear([0.7, 0.8, 0.9], 5.0, 30.0)));
}
if key_pressed(KeyCode::Digit4) {
app.renderer_mut().set_fog(None); // désactiver
}
```
Le changement prend effet au frame suivant.
## Pipeline
```text
Main pass (shader fragment)
↓
Lighting → final_rgb
↓
FOG: mix(final_rgb, fog_color, 1 - fog_factor) ← ici
↓
→ HDR texture / swapchain
↓
(Bloom) → Tone Mapping → surface
```
Le brouillard s'applique **avant** le tone mapping : les valeurs HDR restent
non clampées, et le TM applique la courbe ACES/Reinhard au résultat déjà
brouillé. Résultat : le brouillard est perceptuellement cohérent.
## Compatibilité
| Avec | OK ? | Note |
|------|------|------|
| HDR + TM | ✅ | Fog avant TM (recommandé) |
| Bloom | ✅ | Le bloom extrait les zones brillantes du résultat post-fog |
| MSAA | ✅ | Indépendant (rasterizer vs fragment shader) |
| Culling GPU | ✅ | Indépendant (culling décide quoi dessiner, fog décide la couleur) |
| Shadows | ✅ | L'ombre est calculée avant le fog |
## Limitations (v1)
- **Scene-level uniquement** : un seul brouillard pour toute la scène.
Un brouillard par matériau nécessiterait un paramètre additionnel dans le
bind group par objet.
- **Distance euclidienne** : pas de brouillard volumétrique ni directionnel.
- **Couleur fixe** : pas de gradient de couleur avec la distance.
## Exemple
Voir `lib/examples/effects/fog.rs` : 15 cubes en rangée + 5 sphères sur un plan 80×80,
avec commutation runtime entre les 3 modes.
```sh
cargo run -p wsg-lib --example fog --features "all-prims"
```
-77
View File
@@ -1,77 +0,0 @@
# HDR & Tone Mapping
> **Étape 20** — Opt-in HDR rendering with tone mapping.
## What it does
By default, the WSG renderer draws directly to the window's sRGB surface. Color values
above 1.0 are **clipped** (saturated to white) — you lose all information in bright areas.
When HDR is enabled, the pipeline becomes:
```
Main pass → offscreen Rgba16Float texture (unbounded float)
TM pass → fullscreen triangle samples HDR texture, applies curve, writes to sRGB surface
```
The tone mapping **compresses** the [0, ∞) range to [0, 1] with a perceptual curve,
so bright areas are smoothly rolled off instead of clipping.
## Enabling HDR
```rust
use wsg_lib::core::ToneMapper;
use wsg_lib::app::AppBuilder;
let app = AppBuilder::new()
.title("My HDR App")
.with_hdr(ToneMapper::Aces) // ← enables HDR
.build()
.await?;
```
Without `.with_hdr(...)`, the renderer operates in LDR mode (direct to surface, zero overhead).
## Tone mapping curves
| Variant | Curve | Use case |
|---------|-------|----------|
| `ToneMapper::Aces` | ACES Filmic (Narkowicz 2015) | Cinematic look, soft highlight rolloff, good contrast |
| `ToneMapper::Reinhard` | `x / (1 + x)` | Simple, flat; less contrast but computationally trivial |
The curve is **compiled into the pipeline** at construction time (one WGSL entry point
per variant) — there is no runtime branching cost.
## Cost
| HDR state | Extra per-frame cost |
|-----------|---------------------|
| Disabled (default) | **Zero** — no texture, no pass, no pipeline |
| Enabled | +1 fullscreen render pass (triangle, 3 verts) + 1 offscreen texture (same size as window) |
The extra pass is negligible on any GPU (a few hundred microseconds). The offscreen
texture costs ~12 bytes/pixel of VRAM (RGBA16F = 8 bytes/px + the surface's own buffer).
## How it works (technical)
- **Offscreen texture**: `Rgba16Float`, same size as the window. Created in `Renderer::new`,
recreated on resize.
- **Main pass**: the color attachment targets the HDR texture instead of the surface.
The `standard_shader.wgsl` fragment output (linear float, unbounded) is stored as-is.
- **TM pass**: a fullscreen triangle (3 vertices, no vertex buffer) samples the HDR texture,
multiplies by exposure (currently fixed at 1.0), applies the tone curve, and writes to
the sRGB surface. The hardware performs the linear→sRGB gamma conversion automatically
(the surface format is `Rgba8UnormSrgb`).
- **No double gamma**: the shader outputs linear [0,1]; the sRGB surface encoding is
handled by the rasterizer.
## Exposure
Currently fixed at 1.0 (no user control yet). A future step will expose an
`exposure` field in a `HdrConfig` struct for live adjustment.
## See also
- [Shadows](shadows.md) — the other opt-in visual feature
- [GPU-driven rendering](gpu-driven.md) — the compute pipeline that feeds the main pass
- [Examples](examples.md) — the `demo` example enables HDR by default
+16
View File
@@ -0,0 +1,16 @@
# Lights — user documentation
The **lighting side** of the scene: the light model, shadows, and emissive materials.
| Page | Topic |
|------|-------|
| [Lights](lights.md) | Scene-global lights (directional/point/spot/ambient), `MAX_LIGHTS`, packed indices |
| [Shadows](shadows.md) | Shadow mapping: picking the casting light, the packed-index pitfall, tuning |
| [Emissive + Exposure](emissive-exposure.md) | Emissive materials (HDR glow) and runtime exposure |
Example folder: [`lib/examples/lights/`](../../../lib/examples/lights/README.md)
(`shadow`, `shadow_test`, `spot_test`, `emissive`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
+114
View File
@@ -0,0 +1,114 @@
# Emissive + Exposure
## Principle
Two complementary features (Step 22):
| Feature | Effect | Cost |
|---------|--------|------|
| **Exposure** (6.1) | Multiplies luminance before the tone-mapping curve | Zero when HDR is inactive |
| **Emissive** (6.2) | Adds an emitted color (independent of the lights) | Zero when `emissive = [0,0,0,0]` |
## Exposure
### API
```rust
// Initialization (optional, default = 1.0)
let app = AppBuilder::new()
.with_hdr(ToneMapper::Aces)
.with_exposure(1.5) // start brighter
.build().await?;
// Runtime (in update())
app.set_exposure(app.exposure() * 1.1); // +1 "stop"
app.set_exposure(1.0); // reset
```
### Behavior
- Exposure is a **multiplier** applied to the HDR texture before the tone-mapping curve.
- `exposure = 2.0` → the image is 2× brighter (like opening a camera's aperture).
- `exposure = 0.5` → the image is 2× darker.
- Clamped to `[0.01, 10.0]` to avoid degenerate values.
- **Only has an effect when HDR is active** (`with_hdr(...)`). In LDR the value is ignored.
### Keyboard (demo)
| Key | Effect |
|-----|--------|
| `+` | ×1.1 (brighter) |
| `-` | ÷1.1 (darker) |
| `0` | Reset to 1.0 |
## Emissive
### API
```rust
use wsg_lib::resources::Material;
// Create a material with emissivity
let mut mat = /* ... */;
mat.emissive = [1.0, 0.3, 0.1, 1.5]; // orange, intensity 1.5 (> 1.0 = HDR glow)
```
### Format
`emissive = [r, g, b, intensity]`:
- **rgb**: the emission color (same space as the vertex base color)
- **a (intensity)**: the multiplier. `1.0` = normal color, `> 1.0` = highlight (only visible in HDR)
### Shader formula
```
final_color = lit + base_color * emissive.rgb * emissive.a
```
- The emission is **additive**: visible even in total darkness (no light needed).
- It is **independent of shadows**: an emissive object casts no shadow and is not shadowed.
- `emissive = [0,0,0,0]` (default) → no change (non-regression guaranteed).
### Use cases
| Use | Value |
|-----|-------|
| LED / indicator | `[0, 1, 0, 1.0]` (green, normal intensity) |
| Flame / sun | `[1, 0.8, 0.2, 3.0]` (orange, HDR glow) |
| Neon | `[0, 0.5, 1, 2.5]` (cyan, glow) |
| Inactive | `[0, 0, 0, 0]` (default) |
### Keyboard (demo)
| Key | Effect |
|-----|--------|
| `E` | Toggle orange glow on the sphere/cylinder |
## Interactions
| Combination | Result |
|-------------|--------|
| Emissive + HDR + ACES | Soft glow, highlights roll off (the nicest) |
| Emissive + LDR | Clamped to 1.0 (no glow, but the color is visible in the dark) |
| Emissive + shadows | The emissive object is NOT shadowed (emission bypasses the shadow term) |
| Exposure + Emissive | Exposure also amplifies the emission (consistent: everything is in the HDR texture) |
## Non-regression
- **Emissive**: `[0,0,0,0]` by default → the shader adds `base * 0 * 0 = 0` → no change.
- **Exposure**: `1.0` by default → `pow(color, 1/1) = color` → no change.
- Both are **opt-in**: without `with_hdr(...)` and `emissive != 0`, the pipeline is identical
to the previous state.
## Limitations (MVP)
- Emissive is **per material**, not per vertex (no emission gradient within a mesh).
- Emissive is **static** at material creation (changing `mat.emissive` requires re-registering
the material via `add_material`).
- No **bloom** (Step 23): the HDR glow is visible but not "blurred" / spread.
## Links
- [User README](../README.md) · [HDR & tone mapping](../effects/hdr.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
@@ -37,9 +37,9 @@ app.scene.add_spot_light(
).unwrap();
```
These three calls are the ones in the [`demo`](../../lib/examples/effects/demo.rs) example;
[`cube.rs`](../../lib/examples/meshes/cube.rs) shows a point + a spot on top of the default
directional, and [`spot_test.rs`](../../lib/examples/lights/spot_test.rs) isolates a single spot
These three calls are the ones in the [`demo`](../../../lib/examples/effects/demo.rs) example;
[`cube.rs`](../../../lib/examples/meshes/cube.rs) shows a point + a spot on top of the default
directional, and [`spot_test.rs`](../../../lib/examples/lights/spot_test.rs) isolates a single spot
(ambient nearly zero).
Global settings:
@@ -68,7 +68,7 @@ Two consequences:
[Shadows](shadows.md).
2. If you want **your** light to be the only one (and thus at index 0), clear the list
first: `app.scene.clear_lights();` then `add_*_light(…)` (this is the technique in
[`shadow_test.rs`](../../lib/examples/lights/shadow_test.rs)).
[`shadow_test.rs`](../../../lib/examples/lights/shadow_test.rs)).
## Intensities and tints
@@ -80,5 +80,5 @@ Two consequences:
## Links
- [User README](README.md) · [Shadows](shadows.md) · [Materials & textures](materials.md)
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
- [User README](../README.md) · [Shadows](shadows.md) · [Materials & textures](../meshes/materials.md)
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.md)
@@ -29,7 +29,7 @@ Two ways to avoid it:
app.scene.set_shadow_caster(Some(0)); // now it really is YOUR light
```
This is the technique in [`shadow_test.rs`](../../lib/examples/lights/shadow_test.rs).
This is the technique in [`shadow_test.rs`](../../../lib/examples/lights/shadow_test.rs).
2. **Count the indices** — if you keep the default light and add yours, it lands at index 1:
@@ -38,12 +38,12 @@ Two ways to avoid it:
app.scene.set_shadow_caster(Some(1)); // this is the demo's warm light that casts
```
This is the technique in [`demo.rs`](../../lib/examples/effects/demo.rs).
This is the technique in [`demo.rs`](../../../lib/examples/effects/demo.rs).
## How it works (to understand the limits)
Each frame, if a caster is active, the engine runs **two passes** (technical details in
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
[FRAME_LOOP](../../tech/FRAME_LOOP.md)):
1. **Shadow pass**: the scene is rendered as seen *from the light* (depth-only
`shadow_shader.wgsl` shader) into a 1024² `Depth32Float` shadow map (size configurable
@@ -59,7 +59,7 @@ Things to know:
- **Spot light**: the light's cone naturally bounds the shadow.
- Only one light casts at a time (no multi-light shadows).
- Shadows only affect meshes rendered by `standard` in lit mode — a renderer in unlit mode
(see [Materials & textures](materials.md)) receives none.
(see [Materials & textures](../meshes/materials.md)) receives none.
## Tuning shadow rendering
@@ -75,5 +75,5 @@ Tuning tips:
## Links
- [User README](README.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
- [User README](../README.md) · [Lights](lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [FRAME_LOOP](../../tech/FRAME_LOOP.md)
-108
View File
@@ -1,108 +0,0 @@
# Module `mesh` — Sources de géométrie
Le module `wsg::mesh` est le point d'entrée unique pour **d'où vient la géométrie** :
générateurs procéduraux ou import de fichiers.
## Primitives procédurales
Chaque famille de primitives est derrière une **feature** — vous ne compilez que ce dont vous avez besoin.
| Feature | Fonction | Description |
|---------|----------|-------------|
| `prim-cube` | `cube(size)` | Cube centré, 24 sommets, normales par face |
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Plan horizontal XZ (normale +Y), subdivisé |
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Sphère lat/long, normales lisses |
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphère (subdiv icosahedron) |
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylindre (côté + caps), normales analytiques |
| `prim-cone` | `cone(r, h, sectors)` | Cône (apex + base fermée) |
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Tore, normales lisses |
### Features par défaut
```toml
# Cargo.toml de votre projet
[dependencies]
wsg-lib = { path = "../lib" }
# Default: toutes les primitives activées (all-prims)
```
```toml
# Ne compiler que le cube et la sphère :
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube", "prim-sphere"] }
```
### Usage
```rust
use wsg_lib::prelude::*;
let cube = cube(2.0);
let sphere = uv_sphere(1.0, 32, 16);
let ico = icosphere(1.0, 2);
// Tous retournent un Geometry (positions + normals + UVs + indices)
assert_eq!(cube.positions.len(), 24);
```
## Import de fichiers
| Feature | Fonction | Format |
|---------|----------|--------|
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
### Parser OBJ
Supporte : `v`, `vn`, `vt`, `f` (3-4 sommets, triangulation en éventail).
Si le fichier n'a pas de normales, elles sont **calculées** (pondération par aire).
```rust
use wsg_lib::mesh::{load_obj, parse_obj};
// Depuis un fichier
let geom = load_obj("model.obj")?;
// Depuis une string
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
```
### Erreurs
```rust
use wsg_lib::mesh::import::MeshImportError;
match load_obj("missing.obj") {
Ok(geom) => { /* … */ }
Err(MeshImportError::Io(e)) => eprintln!("fichier inaccessible: {e}"),
Err(MeshImportError::Parse(e)) => eprintln!("syntaxe invalide: {e}"),
Err(MeshImportError::Unsupported(e)) => eprintln!("feature non supportée: {e}"),
}
```
## De `Geometry` à la scène
Le module `mesh` produit des `Geometry` (données CPU). Pour les rendre,
passez par `Scene::create_mesh` qui les transfère en GPU :
```rust
use wsg_lib::prelude::*;
use wsg_lib::mesh::cube;
// Dans AppHandler::setup :
let geom = cube(1.0);
app.scene.create_mesh("my_mesh", geom, Some("my_mat"))?;
app.scene.add_entity("my_entity", "my_mesh")?;
```
## Example
```sh
cargo run -p wsg-lib --example import --features import-obj -- model.obj
```
## Convention
- **Y-up**, origine centrée (sauf `plane` : plan XZ à y=0)
- Normales **sortantes**
- UVs dans [0,1]²
- Winding **CCW** (face avant)
+17
View File
@@ -0,0 +1,17 @@
# Meshes — user documentation
The **geometry side** of the scene: where the geometry comes from, how meshes and entities are
organized, and how objects look.
| Page | Topic |
|------|-------|
| [Meshes](meshes.md) | The three levels `Geometry` → `Mesh` → `Entity`; procedural primitives, custom geometry, `Transform`, mesh sharing |
| [Geometry sources](sources.md) | The `wsg::mesh` module: feature-gated procedural generators + file import (OBJ, glTF stub) |
| [Materials & textures](materials.md) | The `standard` shader, unlit mode, diffuse textures |
Example folder: [`lib/examples/meshes/`](../../../lib/examples/meshes/README.md)
(`simple`, `cube`, `pbr`, `import`, `manual`).
## Links
- [User documentation index](../README.md) · [Quickstart](../quickstart.md) · [Examples](../examples.md)
@@ -5,7 +5,7 @@ optionally a **diffuse texture**. Several materials pointing at the same shader
same compiled GPU pipeline (the `PipelineCache` held by the scene).
The engine ships a single shader: **`standard`** — multi-light Phong lighting (see
[Lights](lights.md)), with an **unlit** mode for flat rendering.
[Lights](../lights/lights.md)), with an **unlit** mode for flat rendering.
## 1. Registering the shader
@@ -22,8 +22,8 @@ app.scene
For a custom shader: register your `.wgsl` file path under an id of your choice (it must
expose the same bind groups as `standard` — frame @0, object @1, texture @2, shadow @3 — see
[ARCHI_RENDU](../tech/ARCHI_RENDU.md) and the
[`shaders/standard_shader.wgsl`](../../lib/src/shaders/standard_shader.wgsl) file).
[ARCHI_RENDU](../../tech/ARCHI_RENDU.md) and the
[`shaders/standard_shader.wgsl`](../../../lib/src/shaders/standard_shader.wgsl) file).
## 2. Creating materials
@@ -43,7 +43,7 @@ app.scene.create_mesh("cube_mesh", cube(1.0), Some("mat_textured")).unwrap();
A mesh created with `material = None` is rendered with the scene's **default material**
(`standard`, built once then cached) — that is the behavior of the
[`simple`](../../lib/examples/meshes/simple.rs) example.
[`simple`](../../../lib/examples/meshes/simple.rs) example.
## 3. Diffuse textures
@@ -70,7 +70,7 @@ app.scene.add_material_texture("ground_mat", "standard", "checker_texture").unwr
```
The exact snippet (8×8 checkerboard + stripes generation) is in
[`demo.rs`](../../lib/examples/effects/demo.rs) and [`cube.rs`](../../lib/examples/meshes/cube.rs).
[`demo.rs`](../../../lib/examples/effects/demo.rs) and [`cube.rs`](../../../lib/examples/meshes/cube.rs).
Two conditions for a texture to show up:
1. the material is created via `add_material_texture` (otherwise the 1×1 white placeholder
@@ -90,11 +90,11 @@ This is the mode of the `simple` example (2D quad). In this mode the scene's lig
ignored; per-vertex colors (or white) are rendered directly. 2D is a special case of 3D:
the single `standard` pipeline serves both.
> `clear_lights()` (see [Lights](lights.md)) gives a similar result but keeps the lit
> `clear_lights()` (see [Lights](../lights/lights.md)) gives a similar result but keeps the lit
> pipeline: only ambient stays active. Use it when you want to "turn off the lights" without
> switching to unlit.
## Links
- [User README](README.md) · [Meshes](meshes.md) · [Lights](lights.md) · [Examples](examples.md)
- [Root README](../../README.md) · [ARCHI_RENDU](../tech/ARCHI_RENDU.md)
- [User README](../README.md) · [Meshes](meshes.md) · [Lights](../lights/lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.md)
@@ -79,14 +79,14 @@ app.scene.add_entity_with_transform("cube", "cube_mesh", transform)?;
```
All these methods return `Result<_, String>` (unifying the typed errors is on the
horizon — see [ROADMAP](../ROADMAP.md)).
horizon — see [ROADMAP](../../ROADMAP.md)).
## 4. Moving / animating: the `Transform`
Placement lives on the **entity** (not on the mesh): `Transform { translation: Vec3,
rotation: Quat, scale: Vec3 }`, converted to a world matrix by the engine every frame.
The snippet below is the animation from the [`cube`](../../lib/examples/meshes/cube.rs) example:
The snippet below is the animation from the [`cube`](../../../lib/examples/meshes/cube.rs) example:
```rust
fn update(&mut self, app: &mut wsg_lib::App) {
@@ -121,5 +121,5 @@ The GPU buffers are uploaded only once; only the world matrices differ.
## Links
- [User README](README.md) · [Quickstart](quickstart.md) · [Materials & textures](materials.md) · [Lights](lights.md)
- [Root README](../../README.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
- [User README](../README.md) · [Quickstart](../quickstart.md) · [Materials & textures](materials.md) · [Lights](../lights/lights.md)
- [Root README](../../../README.md) · [ARCHI_APP](../../tech/ARCHI_APP.md)
+113
View File
@@ -0,0 +1,113 @@
# Geometry sources: procedural generators and file import
The `wsg::mesh` module is the single entry point for **where the geometry comes from**:
procedural generators or file import.
## Procedural primitives
Each primitive family is behind a **feature** — you only compile what you need.
| Feature | Function | Description |
|---------|----------|-------------|
| `prim-cube` | `cube(size)` | Centered cube, 24 vertices, per-face normals |
| `prim-plane` | `plane(w, d, seg_x, seg_z)` | Horizontal XZ plane (normal +Y), subdivided |
| `prim-sphere` | `uv_sphere(r, sectors, stacks)` | Lat/long sphere, smooth normals |
| `prim-sphere` | `icosphere(r, subdivisions)` | Icosphere (subdivided icosahedron) |
| `prim-cylinder` | `cylinder(r, h, sectors)` | Cylinder (side + caps), analytic normals |
| `prim-cone` | `cone(r, h, sectors)` | Cone (apex + closed base) |
| `prim-torus` | `torus(major, minor, seg_maj, seg_min)` | Torus, smooth normals |
### Default features
```toml
# Your project's Cargo.toml
[dependencies]
wsg-lib = { path = "../lib" }
# Default: all primitives enabled (all-prims)
```
```toml
# Only compile the cube and the sphere:
wsg-lib = { path = "../lib", default-features = false, features = ["prim-cube", "prim-sphere"] }
```
### Usage
```rust
use wsg_lib::prelude::*;
let cube = cube(2.0);
let sphere = uv_sphere(1.0, 32, 16);
let ico = icosphere(1.0, 2);
// All return a Geometry (positions + normals + UVs + indices)
assert_eq!(cube.positions.len(), 24);
```
## File import
| Feature | Function | Format |
|---------|----------|--------|
| `import-obj` | `load_obj(path)` / `parse_obj(str)` | Wavefront OBJ |
| `import-gltf` | `load_gltf(path)` | glTF 2.0 / GLB (stub) |
### OBJ parser
Supports: `v`, `vn`, `vt`, `f` (3–4 vertices, fan triangulation).
If the file has no normals, they are **computed** (area-weighted).
```rust
use wsg_lib::mesh::{load_obj, parse_obj};
// From a file
let geom = load_obj("model.obj")?;
// From a string
let geom = parse_obj("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n")?;
```
### Errors
```rust
use wsg_lib::mesh::import::MeshImportError;
match load_obj("missing.obj") {
Ok(geom) => { /* … */ }
Err(MeshImportError::Io(e)) => eprintln!("file not accessible: {e}"),
Err(MeshImportError::Parse(e)) => eprintln!("invalid syntax: {e}"),
Err(MeshImportError::Unsupported(e)) => eprintln!("unsupported feature: {e}"),
}
```
## From `Geometry` to the scene
The `mesh` module produces `Geometry` (CPU data). To render it, go through
`Scene::create_mesh`, which uploads it to the GPU:
```rust
use wsg_lib::prelude::*;
use wsg_lib::mesh::cube;
// In AppHandler::setup:
let geom = cube(1.0);
app.scene.create_mesh("my_mesh", geom, Some("my_mat"))?;
app.scene.add_entity("my_entity", "my_mesh")?;
```
## Example
```sh
cargo run -p wsg-lib --example import --features import-obj -- model.obj
```
## Conventions
- **Y-up**, centered on the origin (except `plane`: XZ plane at y=0)
- **Outward** normals
- UVs in [0,1]²
- **CCW** winding (front face)
## Links
- [User README](../README.md) · [Meshes](meshes.md) · [Materials & textures](materials.md) · [Examples](../examples.md)
- [Root README](../../../README.md)
-127
View File
@@ -1,127 +0,0 @@
# MSAA (Anti-aliasing)
Multi-Sample Anti-Aliasing (MSAA) smooths jagged edges by rendering the scene
at a higher sample count (e.g. 4 samples per pixel), then averaging the samples
into the final image.
## Activation
MSAA is opt-in via the builder. When disabled (default), the renderer uses
single-sample rendering with zero overhead:
```rust
let app = AppBuilder::new()
.title("My App")
.with_msaa(4) // 4× MSAA (also: 2 or 8)
.build()
.await?;
```
## How it works
MSAA is a **rasterizer feature** — no new shader is needed. The pipeline:
```text
┌─────────────────────────────────────────────────────────────────────┐
│ Without MSAA (default) │
│ │
│ Main pass ──→ HDR texture (1 sample) ──→ [Bloom] ──→ TM ──→ Surface │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ With MSAA 4× (and HDR) │
│ │
│ Main pass ──→ MSAA texture (4 samples, Rgba16Float) │
│ ↓ resolve (hardware average) │
│ HDR texture (1 sample) │
│ ↓ │
│ [Bloom] ──→ TM ──→ Surface │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ With MSAA 4× (no HDR) │
│ │
│ Main pass ──→ MSAA texture (4 samples, surface format) │
│ ↓ resolve │
│ Swapchain (surface) │
└─────────────────────────────────────────────────────────────────────┘
```
Key points:
- The **main scene pass** renders into the MSAA texture (N samples/pixel).
- The **resolve** (hardware average) produces the single-sample output.
- **Post-processes** (bloom, tone mapping) operate on the **resolved**
single-sample texture — they are completely unaffected by MSAA.
- The **shadow map** is always single-sample (depth-only, not visible directly).
## Sample count
| Count | Quality | Cost (approx.) | Use case |
|-------|---------|----------------|----------|
| 2 | Basic | ~1.1× | Low-end / battery |
| **4** | **Good** | **~1.3–1.5×** | **Default, most games** |
| 8 | Excellent | ~1.6–2.0× | High-end / static scenes |
The cost is in the rasterizer/fill-rate (each pixel is shaded N times at
triangles' edges). Interior pixels (covered by a single triangle) are
shaded only once — MSAA only multiplies the **edge** cost.
## Runtime query
```rust
// In AppHandler::setup or update:
let active = app.renderer().msaa_enabled(); // true if sample_count > 1
let count = app.renderer().msaa_sample_count(); // 1, 2, 4, or 8
```
MSAA is a **build-time** setting: the multi-sample textures are allocated
at startup. Changing the sample count requires recreating the textures
(window resize does this automatically).
## Configuration struct
```rust
use wsg_lib::MsaaConfig;
let config = MsaaConfig { sample_count: 4 };
// Or use the builder shortcut (validated):
let app = AppBuilder::new().with_msaa(4).build().await?;
```
## Compatibility
| Feature | Compatible? | Notes |
|---------|-------------|-------|
| HDR + Tone Mapping | ✅ | MSAA texture is `Rgba16Float`, resolves into HDR |
| Bloom | ✅ | Bloom reads the resolved (single-sample) HDR texture |
| Shadows | ✅ | Shadow map is always single-sample |
| Frustum Culling | ✅ | Independent (compute pass) |
| LOD | ✅ | Independent (draw args) |
| Emissive | ✅ | Per-entity, in the main pass |
## Limitations
- **Does not smooth UV-dependent aliasing** (texture shimmer). For that,
use mipmaps + anisotropic filtering (future: texture module).
- **Cost scales with overdraw**: fully transparent or heavily overlapping
geometry pays the full N× cost.
- **GPU support**: most modern GPUs support 4× for all formats. 8× may be
limited for float formats (check `Device::limits().max_color_attachment_samples`).
## Example
```rust
use wsg_lib::app::AppBuilder;
use wsg_lib::core::ToneMapper;
let app = AppBuilder::new()
.title("MSAA Demo")
.size(1280, 720)
.with_msaa(4)
.with_hdr(ToneMapper::Aces)
.build()
.await?;
```
See `lib/examples/effects/msaa.rs` for a full interactive demo with cube, sphere,
and ground plane where aliasing is clearly visible without MSAA.
+96 -99
View File
@@ -1,132 +1,129 @@
# Quickstart
Goal: a window showing an object, with the render loop handled by the library. You will only
write three things: a struct implementing `AppHandler`, your scene declaration in `setup()`,
and your `main()`.
Get a window with a rotating cube on screen in ~30 lines. The full version with comments is
in the [`simple`](../../lib/examples/meshes/simple.rs) example (2D quad, unlit) and
[`cube`](../../lib/examples/meshes/cube.rs) (3D cube, lit).
## Prerequisites
- A recent Rust toolchain (the library is **edition 2024** — run `rustup update` if needed).
- A windowing environment (X11/Wayland on Linux, or native macOS/Windows).
- WSG is **not published on crates.io**: it is consumed by file path.
## 1. Dependencies
In your application's `Cargo.toml`:
## 1. Add the dependency
```toml
# Cargo.toml
[dependencies]
wsg-lib = { path = "/path/to/wsg/lib" }
pollster = { version = "1", features = ["macro"] } # for #[pollster::main] (AppBuilder is async)
wsg-lib = { path = "../lib" }
glam = "0.29" # Vec3/Quat — re-exported but you need it in your own code
winit = "0.30" # KeyCode/MouseButton for the input (only if you use app.input)
```
## 2. The minimal application
> The workspace pins `glam 0.29` and `winit 0.30`; match these versions to avoid
> type mismatches.
This snippet is the [`simple`](../../lib/examples/meshes/simple.rs) example from the repo, almost
verbatim: a flat two-tone quad, rendered automatically every frame.
## 2. Implement `AppHandler`
Three mandatory methods (`setup`, `update`, `render`) and an optional event hook.
```rust
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::utils::WsgError;
use wsg_lib::AppHandler;
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::camera::CameraController;
use wsg_lib::prelude::*;
struct MyQuad;
struct MyHandler {
camera: CameraController,
}
impl AppHandler for MyQuad {
fn setup(&mut self, app: &mut wsg_lib::App) {
// Flat 2D: the `standard` shader in unlit mode returns the vertex color as-is.
app.renderer_mut().set_unlit(true);
impl AppHandler for MyHandler {
fn new() -> Self {
Self { camera: CameraController::default() }
}
fn setup(&mut self, app: &mut App) -> Result<(), String> {
// A cube (primitive) + the standard material.
app.scene.create_mesh("cube_mesh", cube(1.0), Some("cube_mat"))?;
app.scene.add_entity("cube", "cube_mesh")?;
// A warm directional light + shadows on it.
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let geometry = Geometry::new(vec![
[-0.5, 0.5, 0.0],
[ 0.5, 0.5, 0.0],
[ 0.5, -0.5, 0.0],
[-0.5, -0.5, 0.0],
])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0], // red
[0.0, 1.0, 0.0, 1.0], // green
[0.0, 0.0, 1.0, 1.0], // blue
[1.0, 1.0, 0.0, 1.0], // yellow
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
// `None`: the scene injects its default material (`standard`) at render time.
app.scene.create_mesh("quad_mesh", geometry, None).unwrap();
app.scene.add_entity("quad", "quad_mesh").unwrap();
}
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)?;
app.scene.set_shadow_caster(Some(0));
Ok(())
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Simple").build().await?;
app.run(MyQuad)
fn update(&mut self, app: &mut App) {
// Spin the cube.
let mut tf = *app.scene.entity_transform("cube").unwrap();
tf.rotation = Quat::from_rotation_y(self.t) * tf.rotation;
app.scene.set_entity_transform("cube", tf);
self.t += 0.02;
// Camera: orbit (left-drag), zoom (wheel), reset (R), presets (1/2/3).
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);
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());
}
fn render(&mut self, app: &mut App) -> Result<(), String> {
// Default implementation: renders the whole scene. Override only for custom passes.
app.renderer().render_scene(app.context())
}
}
```
Note: **no `wgpu` or `winit` imports** — the `App` facade encapsulates them entirely.
## 3. Build the app
## 3. What the library does for you
```rust
fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = AppBuilder::new()
.title("My WSG app")
.size(1024, 768)
.with_culling(true) // optional: skip off-screen entities
.with_shadows() // optional: enable shadow mapping
.build()
.await?;
The full lifecycle, as driven by `App::run` (technical details in
[FRAME_LOOP](../tech/FRAME_LOOP.md)):
```
AppBuilder::build() creates the event loop
│
App::run(handler) starts the loop
│
resumed (winit) window + GPU (Instance/Surface/Adapter/Device/Queue) + Renderer
│
handler.setup(&mut app) ← you declare the scene here (once, GPU ready)
│
▼ per frame, in a loop:
input.begin_frame() current frame's keyboard/mouse state
handler.update(&mut app) ← your logic (motion, input, …)
input.end_frame()
handler.render(app, frame) ← default: app.render_scene(frame.view())
│ (the whole scene is drawn automatically, one pass per frame)
└─ present → next frame
let mut handler = MyHandler::new();
app.run(&mut handler).await?;
Ok(())
}
```
So you implement:
`AppBuilder` methods you will use early:
| Hook | When | Role | Default |
|------|-------|------|---------|
| `setup(&mut self, app)` | once, GPU ready | declare shaders, materials, textures, meshes, entities, lights, camera | empty |
| `update(&mut self, app)` | every frame, before render | animate: transforms, input, lights… | empty |
| `render(&mut self, app, frame)` | every frame, after update | **default**: draws the whole scene; override for custom rendering | `app.render_scene(frame.view())` |
| Method | Purpose |
|--------|---------|
| `.title(…)` / `.size(w, h)` | Window |
| `.with_vsync(false)` / `.with_frame_limit(n)` | Frame pacing (vsync off + 144 fps cap in the `demo`) |
| `.with_culling(true)` | Opt-in frustum culling (see [GPU-driven](cameras/gpu-driven.md)) |
| `.with_shadows()` | Opt-in shadow mapping (see [Shadows](lights/shadows.md)) |
| `.with_hdr(ToneMapper::Aces)` | Opt-in HDR + tone mapping (see [HDR](effects/hdr.md)) |
Golden rule: **mutate the scene in `update()`** (and `setup()`), only read it in `render()`
(model detailed in [ARCHI_RENDU](../tech/ARCHI_RENDU.md)).
## 4. Run it
## 4. Running it
```sh
cargo run -p wsg-lib --example cube # the reference "hello world" of the engine
```
From the WSG repo root (the examples live in `lib/examples/`, one folder per
category: `meshes/`, `lights/`, `cameras/`, `effects/`):
| Command | What you see |
|----------|--------------|
| `cargo run -p wsg-lib --example simple` | the quad above (flat 2D, unlit) |
| `cargo run -p wsg-lib --example cube` | a textured, lit, spinning cube (3D) |
| `cargo run -p wsg-lib --example demo` | the full showcase: 6 primitives + lights + shadows + orbital camera |
For your own application: create a crate, add the §1 dependency, paste the §2 code into
`src/main.rs`, and `cargo run`.
Controls (in the `cube`/`demo` examples): **left-drag** orbit, **wheel** zoom, **R** reset
camera, **1/2/3** view presets, **H** help overlay, **Esc** quit.
## 5. Where to go next
- Want a 3D object? → [Meshes](meshes.md)
- Want to change the look / add a texture? → [Materials & textures](materials.md)
- Want lights? → [Lights](lights.md)
- Want to see everything at once? → the `demo` example ([Examples](examples.md))
- [Meshes](meshes/meshes.md) — entities, transforms, custom geometries
- [Materials & textures](meshes/materials.md) — diffuse textures, unlit mode
- [Lights](lights/lights.md) — point/spot lights, ambient
- [Camera & input](cameras/camera-input.md) — the full input API
- [GPU-driven](cameras/gpu-driven.md) — culling, LOD, debugging the GPU path
## Links
- [User README](README.md) · [Meshes](meshes.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md) · [ARCHI_APP](../tech/ARCHI_APP.md)
- [User README](README.md) · [Meshes](meshes/meshes.md) · [Examples](examples.md)
- [Root README](../../README.md) · [FRAME_LOOP](../tech/FRAME_LOOP.md)
+2 -2
View File
@@ -48,5 +48,5 @@ cargo run -p wsg-lib --example culling
> even off-screen).
>
> The GPU-driven pipeline (compute matrices → culling → indirect draws) is
> documented in [`docs/tech/ARCHI_CPU_GPU.md`](../../docs/tech/ARCHI_CPU_GPU.md)
> and [`docs/user/gpu-driven.md`](../../docs/user/gpu-driven.md).
> documented in [`docs/tech/ARCHI_CPU_GPU.md`](../../../docs/tech/ARCHI_CPU_GPU.md)
> and [`docs/user/cameras/gpu-driven.md`](../../../docs/user/cameras/gpu-driven.md).
+1 -1
View File
@@ -295,7 +295,7 @@ impl AppHandler for Demo {
// entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under
// the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all
// 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far
// off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md.
// off-axis (e.g. behind the near plane) — see docs/user/cameras/gpu-driven.md.
// Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3.
let frames = match std::env::var("WSG_DEBUG_DUMP") {
Ok(v) => v.parse::<u32>().ok().filter(|&n| n > 0).unwrap_or(3),
+2 -2
View File
@@ -105,8 +105,8 @@ Useful for understanding what the `App` facade encapsulates:
The window and GPU are created in winit 0.30's `resumed()` callback
(`run_app` + `ApplicationHandler`). The two-layer architecture is detailed in
[`docs/tech/ARCHI_APP.md`](../../docs/tech/ARCHI_APP.md) and
[`FRAME_LOOP.md`](../../docs/tech/FRAME_LOOP.md).
[`docs/tech/ARCHI_APP.md`](../../../docs/tech/ARCHI_APP.md) and
[`FRAME_LOOP.md`](../../../docs/tech/FRAME_LOOP.md).
```sh
cargo run -p wsg-lib --example manual