# Lighting

How a Talos scene is lit in the HD renderer, what each part costs, and what it cannot do yet.
Four parts make up the "lighting" package:

1. [Global illumination](#global-illumination) (GI): bounced light.
2. [The sky](#the-sky): sun, air, clouds, fog, night.
3. [The default look](#the-default-look): tonemap, exposure, grade.
4. [Ray tracing](#ray-tracing): ray-traced sun shadows and reflections.

All four are Beta (`docs/FEATURES.md`). Field-level reference for the sky and fog is
[`docs/SKY.md`](SKY.md); the render settings are in [`docs/RENDERING.md`](RENDERING.md).
GI, ray tracing and the cloud layer are HD only: the standard renderer ignores them
(`hd-only-ignored`).

## How to turn things on, off and tune them

Two places:

- **The scene** (`meta.environment`, `meta.render`): what the world is (sky, fog, GI strength, exposure, look).
- **The project's quality request** (`render.quality` in the project manifest, HD only): how much work the
  renderer does. A preset (`low`, `medium`, `high`, `ultra`, `custom`, `auto`) plus `overrides` for single knobs.
  The default is `{"preset": "auto", "targetFps": 60}`. Auto measures your scene on your GPU at start-up and picks
  the highest preset that fits the frame budget (80% of 1/targetFps), then steps down or back up as the
  scene changes. With no GPU timing it falls back to a guess by GPU class. This is windowed play only; a
  headless run or capture is fixed to High.

```json
"render": { "profile": "hd",
  "quality": { "preset": "high", "overrides": { "rayTracing": { "reflections": false, "giProbeUpdates": 2048 } } } }
```

What the presets set for GI and ray tracing:

| Knob | Low | Medium | High | Ultra |
|---|---|---|---|---|
| `rayTracing.gi` | off | off | on | on |
| `rayTracing.giProbeUpdates` (256..16384) | 1024 | 2048 | 4096 | 8192 |
| `rayTracing.giCascades` (1..4) | 2 | 3 | 3 | 4 |
| `rayTracing.shadows` | off | off | on | on |
| `rayTracing.reflections` | off | off | on | on |
| `rayTracing.reflectionResolution` | half | half | half | full |
| `rayTracing.reflectionMaxRoughness` | 0.4 | 0.5 | 0.5 | 0.7 |
| `rayTracing.windDeform` | off | off | off | on |
| `rayTracing.giAlways` | off | off | off | off |
| `rayTracing.giGatherResolution` | half | half | half | half |

GI is skipped on a frame with nothing it could light: every surface unlit (a 2D game's sprites,
tilemaps and unlit user shaders) and no virtual geometry, splats, particles, water, ground cover,
decals or fog volumes. The frame is pixel for pixel the same (GI lights nothing there), 0.6 to 1.2 ms
cheaper on an RTX 3080 at 1080p. The first frame with lit content turns GI on for the rest of the run
(switching GI rebuilds pipelines, so it never goes back off). `rayTracing.giAlways: true` keeps GI on
from the first frame.

`rayTracing.giGatherResolution` (`half` | `quarter`, optional, default `half`; every preset says `half`)
sets the GI's final gather: one ray per 2x2 render pixels, or per 4x4 with `quarter`. `quarter` is
opt-in: the gather, guided-sampling tiles, denoiser and upsample run at a quarter of the render
resolution per axis (a quarter of the rays), and the upsample reconstructs render resolution. It is
softer on thin geometry (blades, twigs): on llvmpipe its GI differs from `half` by 7 to 12% mean
per pixel in the test scenes. The radiance cache is unchanged.

Every ray-traced knob needs a GPU with ray query. Without it the knob falls back to the screen-space
path and the engine says `quality-unsupported-on-device`; if the device or build has no ray tracing at
all it says `render.hd.rt_unavailable` and continues (`docs/RUNTIME-CODES.md`).

## Global illumination

HD's GI is ray traced: a probe cache (radiance probes in `giCascades` nested levels, `giProbeUpdates`
of them re-traced every frame), a per-pixel final gather from it, and a temporal denoiser. It
lights surfaces with sky and sun light that bounces off other surfaces, and occludes sky light in
corners. It is on in the High and Ultra presets on a ray-query GPU, and off in Low and Medium (which use
the sky's image-based light and ambient occlusion only).

| To | Do |
|---|---|
| Turn it off | `render.quality.overrides.rayTracing.gi: false` |
| Settle changes faster after a light change | raise `rayTracing.giProbeUpdates` (more cost) |
| Reach further from the camera | raise `rayTracing.giCascades` (1..4) |
| Brighten or dim all bounced light | `meta.environment.gi.indirectIntensity`, 0..16, default 1 (changing it at run time does not restart the GI) |
| Brighten or dim it in one place | a `giVolume` component: `size`, `indirectIntensity` 0..16, `mode` `camera` (default) or `region`, `blend` soft edge in metres (default 0.5); up to 8 volumes per mode, overlapping volumes multiply |
| Make one light bounce more or less | that light's `indirectIntensity` |
| Light a moving interior (a car's cab, a lift) without blotches or leaks | a `giVolume` that fills it, child of the moving entity, with `probeSpacing` (0.25 suits a cab): it carries its own GI probes, which move with it ([RENDERING.md](RENDERING.md#gi-controls-hd)) |

Details of the controls: [RENDERING.md, "GI controls (HD)"](RENDERING.md#gi-controls-hd).

## The sky

The HD renderer's physical sky is `"kind": "atmosphere"` (Rayleigh and Mie scattering, ozone, multiple scattering).
The sun is wherever you put it, and the sky, the sun's colour, the image-based light and the aerial
perspective all follow. Full fields in [SKY.md](SKY.md#atmosphere-skies); the short version:

- **Time of day.** On an atmosphere sky the `preset` (`clear-noon`, `golden-hour`, `overcast`, `dusk`, `night`)
  sets the sun, a cloud layer, the air and a fog together, wherever the scene wrote nothing of its own.
  Anything the scene writes wins field by field.
- **Clouds.** `sky.clouds`: `preset` (`clear`, `fair`, `broken`, `overcast`, `storm`), `coverage` 0..1,
  `altitudeM`, `thicknessM`, `density`, `windSpeedMps`, `windDirectionRad`, `shadowStrength`. They cast shadows on the ground (see
  `render.hd.cloud_shadow_unavailable`). Thick cloud lets through only a few percent of the sun, so a scene with little ambient goes
  nearly black under it: lower `shadowStrength` (0..1, default 1, 0 no shadow) to keep the ground lit. It sets one map, so the ground,
  water, fog, particles and ray-traced hits all follow; the clouds themselves look the same.
  It softens the sun's light only. The ambient under a cloud is unchanged, so a scene that is dark there because its ambient is low
  is still dark in the parts the sun doesn't reach, and any soft mottling in that ambient stays; start around 0.5-0.7 rather than at 0,
  which removes cloud shadows altogether. They drift with the simulation clock.
  Quality knobs: `clouds.resolution`, `clouds.march`, `clouds.steps`, `clouds.shadowMapSize`, `clouds.detail`, and for
  sculpted clouds (`sky.clouds.style`) `clouds.sharpness` and `clouds.fineDetail`.
- **The air.** `sky.atmosphere`: `preset` (`earth-clear`, `hazy`, `polluted`, `stylised`), `haze` 0..10,
  `visibilityKm` 0.1..1000 (overrides `haze`; 50 clear, 25 typical, 10 hazy, 1 fog), `groundAlbedo` 0..1
  (default 0.3), and `aerialScale` 0..100 (default 1 is physical; larger values give a small world the
  layered blue depth of a big one, without changing the sky itself).
- **Fog.** `meta.environment.fog` with a `preset` (`light-haze`, `valley-mist`, `morning-mist`, `dense`,
  `aerial`) or a `visibilityM`. `aerial` (20 km visibility, thin within about 250 m of height) is the depth cue
  for a 0.3 to 1 km scene, and the one to use under a panorama sky (see Known limits). A bare `fog: {}` is
  0.02 per metre, which is thick.
- **Night.** With the sun below about -4 degrees on an atmosphere sky, HD adds a moon light (the
  directional light, opposite the sun, 35 degrees up) and a dark blue dome so the
  image-based light and shaded sides are not black. Both fade in from -4 to -10 degrees. Auto exposure
  lifts a night frame at most 2 stops; pin `exposure` for a darker night.

## Shadow tint (standard renderer)

The standard renderer lights a surface the sun cannot reach with the sky's light only, so a shadow on a
dark surface (a dark road) goes near black. A directional light can lift its shadows with an optional
`shadowTint`:

```json
{ "type": "light", "kind": "directional", "color": "#FFF1D6", "intensity": 3,
  "shadowTint": { "color": "#5B7FA6", "strength": 0.45 } }
```

A fully shadowed surface then receives `strength` of the sun's light in `color` (the sun term is
`sun * (shadow + tint * (1 - shadow))`), scaled by the surface's albedo and its angle to the sun, on top of the
sky light. A surface the sun reaches is unchanged, and so is a scene without the field (the frame is
byte for byte the same). `strength` is 0..1 and `color` a hex colour (`light-shadow-tint-range`); on a
point or spot light it does nothing (`light-shadow-tint-ignored`). It applies to the lit shading models
(pbr, toon, vertex); unlit materials, custom shaders and the HD renderer ignore it. A face turned away
from the sun gets none of the sun's light, tinted or not, so the tint shows where a cast shadow falls on
a surface that faces the sun, which is where the sun's shadow reads as black.

## The default look

A scene that writes nothing gets:

- **Tonemap `agx-punchy`** in both renderers (`meta.render.tonemap`; others: `agx`, `aces`, `neutral`,
  `none`, HD's `unreal-filmic`).
- **Auto exposure** in HD (`exposureMode: "auto"`, metered every frame), unless the scene sets `exposure`
  or the retro style. `exposure` then shifts the metered result by `log2(exposure)` stops. For a fixed
  exposure write `"exposureMode": "manual"` (with `exposure`, default 1).
- **No grade.** `meta.render.look` absent draws no grade, the same as `{ "preset": "natural" }`.

To grade, name a look and tune it:

```json
"render": { "look": { "preset": "cinematic", "contrast": 1.1, "temperatureK": 6000, "shadowsLift": -0.3 } }
```

Presets `natural`, `cinematic`, `bright-day`, `overcast`, `night`, `stylised`; overrides `contrast` 0.5..2,
`saturation` 0..2, `temperatureK` 2000..15000, `tint` -1..1, `shadowsLift` -1..1, `vignette` 0..0.9 (HD).
See [RENDERING.md, "Scene look"](RENDERING.md#scene-look-colour-grade).

## Ray tracing

Ray-traced sun shadows replace the shadow cascades, and ray-traced reflections replace the screen-space ones.
Both need a GPU with ray query. The same ray-tracing core is what GI traces against, so a mesh that is
not in it casts and receives neither.

- **On or off:** `rayTracing.shadows` and `rayTracing.reflections` (table above). Both are on in High and
  Ultra on a ray-query GPU and off in Low and Medium. To keep them off in a project that runs High, override
  them to `false`.
- **Size floor.** The HD setting `rt_min_radius_m` leaves instances smaller than that bounding radius
  (ferns, weeds, debris) out of ray tracing. The default is 0 (off: everything is traced). It is an engine
  setting, not a scene or project field yet; the capture tool sets it from its manifest.
- **Self-shadowing.** Virtual-geometry meshes are traced as a simplified copy that strays centimetres from
  what is drawn. A sun-shadow ray leaving a drawn surface passes over that copy's back faces within its
  measured deviation (the surface's own mesh, seen from inside), so a double-sided scanned rock does not
  shadow itself in stripes. Front faces always count, so foliage keeps its own shade. One exception: a tree
  with leaves as geometry (`leafMesh`) is traced as fewer, larger stand-in leaves, and a stand-in leaf within
  that distance is passed over from either side, because it lies among the real leaves it stands for. Bounce
  light passes a stand-in leaf two times in five, so the shade inside such a crown keeps some light
  (`docs/status/hd-leaf-proxy.md`).
- **Wind deformation.** `rayTracing.windDeform` (Ultra only by default) makes swaying foliage trace the
  exact deformed shape, about 0.7 ms in a dense forest (from the knob's own description).
- **Glass reflections.** `rayTracing.reflectionResolution` sets how glass (see-through surfaces with
  roughness 0.3 or less) traces its mirror reflection. `half` (Low to High): only the nearest glass pane
  traces, one ray per pixel in a pass of its own; both faces of that pane (a modelled pane has two, up to
  5 cm apart along the view) show it, and glass further behind shows the sky's prefiltered reflection.
  `full` (Ultra): every glass layer traces in the forward pass. Both trace at render resolution, as opaque
  surfaces' reflections do (despite the knob's name). The engine setting `rt_glass_reflections:
  FrontLayerHalf` traces the front pane at half resolution, one ray per 2x2 pixels; no knob value picks it yet.

## What it costs

RTX 5080, 1920x1080, High (all ray-traced effects on), median GPU time per frame spent in the GI and RT
passes, measured 16 Oct 2026 on the gate's reference scenes (raw numbers: the per-pass budgets in
`scripts/gate/perf/gpu-cells.json`, added with the gate's advisory per-pass check). Provisional: the gate does not enforce
these yet, and they will move as GI changes.

| Scene | GI (probe select, trace, blend, gather, denoise) | RT shadows and reflections | Total |
|---|---|---|---|
| Open water, UI gallery | 0.14 ms | 0.04 ms | about 0.18 ms |
| Driving course | 0.37 ms | 0.05 ms | about 0.42 ms |
| Alpine terrain | 0.32 ms | 0.11 ms | about 0.43 ms |
| Dense forest (one camera) | 0.53 ms | 0.18 ms | about 0.71 ms |

- Low and Medium run no GI and no RT, so these passes cost nothing there. Ultra has more probe updates, a
  fourth cascade and full-resolution reflections and has not been measured per pass.
- `gi-probe-blend` is a fixed 0.07 ms in every scene. Everything else scales with foliage and what the
  camera sees.
- **RTX 3080: roughly 1.7 times these numbers** (an estimate, not yet measured per pass), and more in
  leaf-heavy frames (a gather over a frame full of leaf cards read 1.9 ms on the 3080 against 0.25 ms for
  opaque terrain; docs/status/hd-gi-gather-leaf2.md).
- After a camera cut in a leaf view the GI re-converges at 1.4 to 2.2 ms for a short while (llvmpipe and
  5080 lane measurements; not gated).
- Idle cost of having the ray-tracing core built at all: about 0.05 ms and 300 to 420 MiB of GPU memory
  (docs/status/hd-quality.md).

## 2D lighting

Sprites and tilemaps are drawn as painted until they set `lit`; then they take the scene's lights as a
surface facing the camera (a sprite) or facing +Z (a tilemap). Off, nothing about them changes.

- **Normal maps.** A sprite view's `normalImage`, or a tileset's `normalImage`, is a tangent-space normal
  map the size of the picture it goes with, cut the same way: red +X, green +Y (up), blue out of the
  picture; blue below 160 is raised to it, since a picture faces the camera. A flipped sprite's normals
  flip with it. A map of another size is left out with a
  `sprite-map-size` warning and the picture draws flat. Maps go on a page beside the atlas page, in the
  same layout, so a scene with no maps spends nothing on them; with them, each page that holds one costs
  twice its texels.
- **Emissive.** `emissive` on a sprite adds a colour after lighting, so it shows in the dark.
  `emissiveImage` shapes it: its brightness times its alpha is the mask. Glow does not light other
  things; put a point light beside it for that. Tilemaps have no emissive.
- **Lights in 2D.** A 2D scene uses the ordinary `light` component. A point or spot light is lit in 3D, so
  its Z is its height over the sprites, which lie at or near Z 0: a low light rakes across a normal map
  and shows the relief, a high one lights evenly. `range` is its reach and `intensity` its strength, in
  world units as for any light. At most 8 point and spot lights reach a frame. Use `lit: true` on what
  should be lit and leave the rest painted.
- **`lighting-map-unlit`** warns about a map or `emissive` on something not `lit`, which nothing reads.
- **No shadows.** Sprites and tiles cast none in 2D, and there are no 2D occluders.

**Keep lights in front of your sort layers.** A flat sprite is drawn 1 m toward the camera per
`sortLayer`, and it is lit where it is drawn. A point light at z 1.5 is behind a sprite on layer 2, which
then takes only the ambient light. Put 2D lights in front of the highest layer they should reach.

`samples/lantern-hollow/scenes/lamplight.scene.json` puts them together: a pixel-perfect camera, a lit
tilemap with a normal sheet, a lit walker whose clip raises a `footstep` event, and two lamps.

The pixel-perfect camera and 2D lighting work together: the art-resolution pass lights each art pixel once.

## Known limits

- **GI over thin foliage is dark.** Leaves are handled approximately (the gather sees the front hemisphere
  only), so forest floors, undergrowth and crowns read darker than they should and lack a translucent glow.
- **Probe spacing is coarse, 0.5 m**, so thin walls and small gaps can leak or darken a little, and a dim room
  lit by a small lamp is noisy.
- **Moving interiors.** The GI's probes stay put in the world, so a cab driving through them shows outside light
  leaking in as blotches that never settle while it moves. Give it a `giVolume` with `probeSpacing` (above).
- **Far-terrain patches.** GI rays hit a coarse terrain proxy that can sit above the true surface in
  hollows, which gives soft darker patches (about 10%) on distant hills.
- **Noise and settling.** After a camera cut or a fast light change the GI takes a couple of seconds to
  settle, and small-source interiors stay blotchy longer.
- **Meshes over the ray-tracing binding limit** (very large scanned meshes, millions of triangles) are drawn
  but not traced: no ray-traced shadow, reflection or GI from them, and the log says
  `render.hd.rt_mesh_too_large` once per mesh. An automatic simplified copy for such meshes is in progress.
  Until then use a decimated mesh.
- **Small plants under `rt_min_radius_m`** cast no ray-traced sun shadow when the floor is set, because with
  ray-traced shadows on the cascades are off. Leave the floor at 0 if small plants must shadow.
- **Captures** (splat or probe captures) are not seen by ambient occlusion or GI.
- **No stars and no moon disc** in the night sky: night has a moon light and a dark dome only.
- **No aerial perspective under a panorama (`hdr`) sky.** It runs only with `"kind": "atmosphere"`. Under a
  panorama use the fog preset `aerial` as the depth cue.
- **The terrain horizon map is off by default.** Set `horizon: true` on the terrain component for terrain
  shadows past the cascades and valleys that darken when the sun sets behind a ridge
  ([RENDERING.md](RENDERING.md#the-terrain-horizon-map-far-sun-shadow-valley-sky)).
- **Foliage and rough ray-traced reflections:** thin foliage pixels skip the reflection ray for cost, so
  reflections of leaves are not ray traced.

## Troubleshooting

**Shade looks too dark**
- Ambient occlusion stacks on GI under plants and in shadow. Check `ambientOcclusion.enabled` in the quality
  overrides; lower the AO knobs or accept the known foliage limit above.
- Raise `meta.environment.gi.indirectIntensity` (or place a `giVolume`) to lift bounced light.
- Auto exposure meters a bright sky and leaves shadows low. Shift it with `exposure` (stops =
  `log2(exposure)`), or set `exposureMode: "manual"`.
- Low and Medium have no GI, so shadowed ground gets only the sky's flat light.
- A closed mesh around the camera (a backdrop sphere, a room) blocks ray-traced sun shadows and traps GI
  inside; in a recording or capture the log says `render.hd.black_frame`. Give the mesh a material file with `"castShadows": false`.

**Looks milky or washed out**
- Fog too thick: a bare `fog: {}` is 0.02 per metre. Write a `preset` or a `visibilityM`.
- A time-of-day preset brings its own fog (`golden-hour` and `overcast` `light-haze`, `dusk` `valley-mist`).
  Write `"fog": { "density": 0 }` to remove it.
- Air too hazy: raise `atmosphere.visibilityKm`, lower `haze`, and check `aerialScale` is not set above 1
  for a large world.
- A tonemap of `none` or a look with low `contrast` also flattens the image.

**Banding in the sky or on distant slopes.** This is not a colour-precision problem. Measured on the 5080:
output dithering is on, the sky lookup is clean, and the same view with clouds off is a smooth gradient. The
bands are the cloud layer's own structure: the deck has a sharp flat base, and its shadows lie flat on far
slopes, most visible in a low-sun glow. `clouds.steps` does not help (32, 64 and 128 steps give the same
image). Change `clouds.preset` or `coverage`, or set `"clouds": { "preset": "clear" }` to remove the layer.
A softer cloud base is a planned look change.

**Codes to look for** (`docs/RUNTIME-CODES.md`, `docs/RULE-CODES.md`):

| Code | Means |
|---|---|
| `render.hd.rt_unavailable` | no ray tracing on this device or build; the screen-space fallbacks are used |
| `quality-unsupported-on-device` | a ray-tracing knob fell back because the GPU cannot do it |
| `render.hd.rt_mesh_too_large` | a mesh is not traced: no RT shadow, reflection or GI from it |
| `render.hd.black_frame` | a frame came out black, often a closed mesh around the camera |
| `render.hd.horizon_map_unavailable` | device too limited for the terrain horizon map |
| `render.hd.cloud_shadow_unavailable` | clouds draw but cast no shadow on this device |
| `render.hd.rt-deform-over-budget` | swaying instances trace their undeformed shape |
| `render-clouds-without-atmosphere` | `sky.clouds` is set but the sky is not `atmosphere` |
| `hd-only-ignored` | a GI, cloud or exposure field was set on the standard renderer |

