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
+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)
+84
View File
@@ -0,0 +1,84 @@
# Lights
Lights are **scene-global**: a single list is packed into the frame uniforms every frame, and
**all** entities receive their lighting (per-material lights are out of the current scope).
## Model
- Bounded capacity: **`MAX_LIGHTS = 8`** lights in total (directional + point + spot
combined). Adding beyond that returns an error.
- **Default**: one white directional light along **+Z** (from the surface point toward the
light) + white ambient. This default exactly reproduces the historical single-light
rendering — your scene "just works" with no configuration.
- Ambient (`set_ambient`) is a global hemispherical term, independent of the lights.
## Adding lights
```rust
use glam::Vec3;
// Directional: `dir` points FROM the surface point TOWARD the light.
app.scene
.add_directional_light(Vec3::new(1.0, 1.2, 1.0).normalize(), [1.0, 0.98, 0.92], 1.5)
.unwrap();
// Point: world position, tint, intensity, attenuation radius (linear down to 0).
app.scene
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
.unwrap();
// Spot: position, cone axis (FROM the light TOWARD the scene), tint, intensity, radius,
// half-angle in radians (penumbra smoothed at the edge).
app.scene.add_spot_light(
Vec3::new(-2.5, 2.2, 1.0), // position
Vec3::new(2.5, -2.2, -1.0).normalize(), // axis, toward the scene
[0.3, 1.0, 0.5], // green tint
1.4, 8.0, 0.45, // intensity, radius, half-angle (~26°)
).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
(ambient nearly zero).
Global settings:
| Method | Effect |
|---------|--------|
| `set_ambient([r, g, b])` | hemispherical ambient color (default white) |
| `clear_lights()` | empties the list — only ambient will light the scene (useful for a flat look without switching to unlit) |
| `set_lights(Lights)` | replaces the whole list (batch reset) |
| `lights()` | reads the current list |
## ⚠️ Packed indices (important for shadows)
Lights are stacked in the GPU array **by type, in order**:
```
index 0 .. n_dir-1 : directional
index n_dir .. +n_point-1 : point
index … .. +n_spot-1 : spot
```
Two consequences:
1. **Index 0 is the default +Z directional** (the one `Lights::new()` pre-loads),
not your first added light. This is a classic pitfall — see
[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)).
## Intensities and tints
- `color` is an RGB in `[0..1]`; `intensity` is an unbounded multiplier.
- Local lights (point/spot) attenuate **linearly** — intensity drops to zero at `radius`.
Beyond the radius, the light contributes nothing.
- The `standard` shader accumulates ambient + all lights (no mutual occlusion between
lights; the spot cone culling happens at the fragment).
## Links
- [User README](../README.md) · [Shadows](shadows.md) · [Materials & textures](../meshes/materials.md)
- [Root README](../../../README.md) · [ARCHI_RENDU](../../tech/ARCHI_RENDU.md)
+79
View File
@@ -0,0 +1,79 @@
# Shadows (shadow mapping)
Shadows are **off by default** and are enabled by designating **a single** casting light:
```rust
app.scene.set_shadow_caster(Some(index)); // packed index — see the pitfall below
app.scene.set_shadow_caster(None); // shadows off (default)
```
Only a **directional or spot** light can cast shadows. A **point** light index disables the
shadow pass (cubemap shadows are out of scope).
## ⚠️ The packed-index pitfall
`set_shadow_caster` takes the light's index **in the packed array** (directionals first,
then point, then spot — recalled in [Lights](lights.md)).
**Index 0 is the default +Z directional** pre-loaded by `Lights::new()`, not necessarily
your light. Symptom of a wrong index: the shadow camera looks in an unexpected direction and
misaligned objects occlude each other (blackened objects, ghost shadows).
Two ways to avoid it:
1. **Clear the list before adding yours** — your light becomes index 0:
```rust
app.scene.clear_lights(); // removes the default +Z
app.scene.add_directional_light(dir, [1.0, 0.98, 0.92], 1.6).unwrap();
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).
2. **Count the indices** — if you keep the default light and add yours, it lands at index 1:
```rust
app.scene.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5).unwrap(); // → index 1
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).
## 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)):
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
via `SHADOW_MAP_SIZE`), with a depth bias (slope-scaled + constant) to avoid shadow acne.
2. **Color pass**: the `standard` fragment shader re-projects each fragment into light space
and compares its depth against the map via a **3×3 PCF** (softened shadow edges).
Things to know:
- **Directional light**: the shadow frustum is orthographic, centered on the scene center
(`SHADOW_SCENE_CENTER`, radius `SHADOW_SCENE_RADIUS = 5.0` by default). Objects **far from
the origin** may fall outside the frustum and stop casting.
- **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](../meshes/materials.md)) receives none.
## Tuning shadow rendering
The constants `SHADOW_MAP_SIZE`, `SHADOW_DEPTH_BIAS`, `SHADOW_SCENE_RADIUS`,
`SHADOW_SCENE_CENTER` are exposed in `wsg_lib::utils` (defaults: 1024, 0.006, 5.0, origin).
Tuning tips:
- **Speckled shadow edges (acne)**: raise the bias.
- **Peter-panning** (shadow detached from the object): lower the bias.
- **Shadow clipped at the scene edge**: raise the frustum radius (directional).
- **Shadows too blurry, want them crisper**: raise the map size.
## Links
- [User README](../README.md) · [Lights](lights.md) · [Examples](../examples.md)
- [Root README](../../../README.md) · [FRAME_LOOP](../../tech/FRAME_LOOP.md)