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
+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)