# 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` |

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`. They cast shadows on the ground (see
  `render.hd.cloud_shadow_unavailable`). They drift with the simulation clock.
  Quality knobs: `clouds.resolution`, `clouds.steps`, `clouds.shadowMapSize`.
- **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.
- **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).

## 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).

## 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.
- **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 |

