@hypersoniclabs/helix-mcp 0.2.5 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
@@ -0,0 +1,667 @@
1
+ # Lighting a world — the visual runtime
2
+
3
+ **Lighting is platform-owned by default.** A HELIX world does not construct a renderer, place a sun, wire a tone
4
+ mapper, build a post stack or pick an AA pass. **`createVisualRuntime()` from `@helix/visual` owns all of it** — the
5
+ renderer and its whole platform policy (AgX + sRGB output + scheduled shadows), the physical sky and its ambient, the
6
+ sun and its fitted shadow rig, the environment cadence, ambient occlusion, height fog, auto-exposure, the colour
7
+ grade, the probe-GI rung, the time-of-day clock and the quality ladder. Your world declares **its look** — how it is
8
+ lit, and how the post stack renders it — in one JSON contract, **`public/helix.visuals.json`**: mode, time of day,
9
+ sun, sky, environment, shadows, grade, exposure, bloom, fog, AO, the probe field, practicals. **The look is yours in
10
+ full; quality tiers degrade it, they never re-author it** — a weak tier may drop a pass entirely (`minimal` has no
11
+ composer), but wherever a pass runs, it runs with your authored values.
12
+
13
+ Two decisions, in order, before you light anything:
14
+
15
+ 1. **In or out?** The runtime is the default; a world that already has its own visual identity — a custom renderer or
16
+ post-processing chain, a stylized non-photometric look, an art direction the calibrated pipeline would fight — may
17
+ **opt out** and keep the classic hand-lit path. That is a deliberate, legitimate choice, not a defect. See
18
+ "Opting out" below.
19
+ 2. **Baked or dynamic?** A runtime-lit world that is **bounded with static geometry** (an interior, an arena, a
20
+ diorama — nearly every template-class world) ships **`"baked"`**: its light field is computed once at publish time
21
+ by `bake_world_lighting` and imports in milliseconds at boot. **`"dynamic"` is the exception**, kept for
22
+ unbounded/open worlds, very large maps, or worlds whose geometry moves. The decision tree below walks it.
23
+
24
+ Lighting is half the look. The other half — broken imported assets, material consumption, texture hygiene,
25
+ composition — is `read_doc({ name: "world-look" })`: run its asset-truth checklist before calibrating anything here,
26
+ because styling on top of asset defects teaches wrong numbers.
27
+
28
+ ## The integration — four lines of contact
29
+
30
+ Your world hands the runtime a **canvas**, your `scene` and `camera`, the fetched **`public/helix.visuals.json`**,
31
+ and `quality: 'auto'`; then it calls **`visuals.tick(dt)` once per frame — which renders the frame itself** — and
32
+ `visuals.catchUp()` after any discontinuity (a room join, a teleport, a scripted clock set). That is the entire
33
+ integration. Probe budgets, cube-face sizes, hysteresis, pass order, mip bias, AA method and tier ladders are engine
34
+ internals with **no author surface**: if a knob is not in the JSON below, it is not yours.
35
+
36
+ ```ts
37
+ import { createVisualRuntime } from '@helix/visual';
38
+
39
+ // How this world is LIT lives in public/helix.visuals.json — the per-world visual contract that
40
+ // `helix validate` audits and the lighting bake patches. Fetch the FILE; never inline the object (see below).
41
+ async function loadVisualsConfig(): Promise<unknown> {
42
+ const response = await fetch(new URL('helix.visuals.json', document.baseURI));
43
+ if (!response.ok) throw new Error(`helix.visuals.json failed to load — HTTP ${response.status}`);
44
+ return response.json();
45
+ }
46
+
47
+ // `?quality=low` overrides the tier the runtime would pick itself; anything unrecognized means `auto`.
48
+ const LEVELS = ['minimal', 'low', 'medium', 'high', 'ultra', 'auto'] as const;
49
+ const param = new URLSearchParams(location.search).get('quality');
50
+ const quality = LEVELS.find((level) => level === param) ?? 'auto';
51
+
52
+ const visuals = await createVisualRuntime({
53
+ canvas, scene, camera,
54
+ config: await loadVisualsConfig(),
55
+ quality,
56
+ size: { width: innerWidth, height: innerHeight },
57
+ assetsReady: materials.ready(), // awaited before the first probe bake — see the DO/DON'T table
58
+ });
59
+ const renderer = visuals.renderer; // hand THIS to the character facade / your own loaders
60
+
61
+ renderer.setAnimationLoop(() => {
62
+ const dt = Math.min(clock.getDelta(), 0.1);
63
+ visuals.tick(dt); // clock, sun, cadences, probe slice — AND the frame's render
64
+ // …your world's per-frame code… // do NOT call renderer.render() yourself
65
+ });
66
+ ```
67
+
68
+ Construct the runtime **before** the character facade (it takes `visuals.renderer`), and after your static scene
69
+ geometry exists — the GI sees the scene as it stands at construction: the room, never the characters that stream in
70
+ later.
71
+
72
+ | member | what it does |
73
+ |---|---|
74
+ | `visuals.tick(dt)` | one frame: the clock, the sun, the environment cadence, the shadow slice, the probe refresh slice, and the render |
75
+ | `visuals.catchUp()` | **the discontinuity API** — re-bakes the environment, re-arms the shadow map, bursts the probe field to the new hour |
76
+ | `visuals.timeOfDay.set(h) / scrub(dh) / pause(b) / setCycleSeconds(s)` | the clock surface; a hard `set()` runs `catchUp()` for you |
77
+ | `visuals.setQuality(tier)` | returns `{ tier, kind, reload, pending }` — the live subset lands now, `reload` says the rest needs a world reload |
78
+ | `visuals.setSize({ width, height, pixelRatio? })` | resize the renderer and every resident pass (call it from your `resize` handler) |
79
+ | `visuals.stats()` | `{ tier, frames, hours, elevationDeg, exposure, envBakes, probes }` — what the boot log line prints |
80
+ | `visuals.renderer` / `.quality` / `.plan` / `.config` | the constructed renderer, the resolved tier, its plan, the parsed config |
81
+ | `visuals.dispose()` | releases the composer, the probe rung, the environment target and everything the runtime added to the scene |
82
+
83
+ **Two construction paths.** `canvas` = the runtime **owns** the renderer (the default path). `renderer` = **retrofit**
84
+ onto one you already have (the `bring-your-world` path): it then overwrites `outputColorSpace`, `toneMapping`,
85
+ `toneMappingExposure`, `shadowMap.enabled` and `shadowMap.type`, and takes ownership of **both** shadow `needsUpdate`
86
+ flags — a caller that also writes them fights the scheduler. On the retrofit path the runtime never touches your
87
+ canvas, size, pixel ratio or clear colour, so supersampling tiers are not available there and warn.
88
+
89
+ **Installing it:** the visual system is a platform system like `humanoid-character` — add `"visual": "^0.1"` to the
90
+ `systems` block in `helix.json` and run `install_world_packages` with `update: true` (terminal: `helix install --update`
91
+ — a newly added pin is refused against an existing lock until it is re-resolved); the import map delivers
92
+ `@helix/visual` like every other `@helix/*` package (the generated vite external predicate already covers it).
93
+
94
+ **Never guess a setting.** Run `get_package_manifest("visual")` — the system publishes a `capabilities.json`
95
+ generated from the real tier table, the real look presets and the real parser defaults, so it cannot drift from the
96
+ code. Read it before you write a pin or a config key.
97
+
98
+ ## Opting out — keeping your own visual style
99
+
100
+ A world may skip the visual runtime entirely and own its renderer, lighting and post stack. Good reasons: an
101
+ established art direction the calibrated pipeline would fight (flat-shaded, toon, deliberately non-photometric), a
102
+ custom post-processing chain that IS the world's look, or a `bring-your-world` project whose rendering already works
103
+ and should not churn. On this path the classic doctrine applies in full — a real key light (never ambient alone),
104
+ motivated local lights, `renderer.toneMapping` + `toneMappingExposure` set, `scene.environment` from a
105
+ PMREM-processed environment, and the eight-punctual-light budget. The build/QA gates recognise both paths; neither is
106
+ penalised. What is NOT a good reason: a lighting mood or a post-stack preference — time of day, grade, exposure,
107
+ bloom, fog and AO are all authorable in the file, so "the default is too bright / too bloomy / too foggy" is a config
108
+ edit, not an opt-out. An opt-out world has no `helix.visuals.json` and never bakes.
109
+
110
+ ## Decision tree
111
+
112
+ ```
113
+ START: does this world keep its own visual identity (custom renderer / post stack / stylized look)?
114
+ │
115
+ ├─ YES → OPT OUT. No helix.visuals.json, no bake. Classic hand-lit doctrine (see "Opting out"). Stop here.
116
+ │
117
+ └─ NO (the default) → the runtime lights it. Write public/helix.visuals.json with "version": 1, then:
118
+ │
119
+ ├─ 1. BAKED OR DYNAMIC? Bounded play space + static geometry and lights?
120
+ │ ├─ YES — an interior, an arena, a diorama, a hangout, a café: almost every template-class world
121
+ │ │ → author "mode": "dynamic" NOW, and plan to ship "baked": the bake is a BUILD STEP
122
+ │ │ (`bake_world_lighting` flips the mode and writes the artifact — you never hand-author
123
+ │ │ either). Baked = the full keyframed GI look for a ~1 MB artifact that imports in
124
+ │ │ milliseconds; a day/night cycle costs nothing extra — blending keyframes IS the cycle.
125
+ │ └─ NO — unbounded/open terrain, a very large map (you PLACE and SIZE the bake volume and may buy
126
+ │ more probes with `probes.budget`, but the cap is 1536 — a huge box still means coarse
127
+ │ cells), or geometry that moves/streams → stay "mode": "dynamic": the always-on foundation lights it
128
+ │ live (sky + ambient + environment + one fitted sun shadow + AO + fog + AgX + auto-exposure),
129
+ │ and the probe-GI rung switches on at tier `medium` and up.
130
+ │
131
+ ├─ 2. DOES THE SUN MOVE?
132
+ │ ├─ NO — a fixed hour (the common case, and the cheapest world there is)
133
+ │ │ → timeOfDay: { hours, cycleSeconds: 0, noonAzimuthDeg }
134
+ │ │ Every cadence settles once. `noonAzimuthDeg` is the knob that aims the sun at YOUR
135
+ │ │ geometry — set it, then set `hours`.
136
+ │ └─ YES — a day/night cycle
137
+ │ → timeOfDay: { hours: <start>, cycleSeconds: <wall seconds per 24 h>, latitudeDeg, declinationDeg }
138
+ │ The clock runs live; the runtime tracks the sun, the sky, the shadow rig and the probe
139
+ │ field (on baked worlds the cycle blends the keyframes — free). Anything that jumps the
140
+ │ clock goes through visuals.timeOfDay.set() or visuals.catchUp() — never by editing state
141
+ │ and ticking on.
142
+ │
143
+ └─ 3. IS THE KNOB I WANT IN THE FILE?
144
+ ├─ IN THE FILE (yours): mode · timeOfDay.{hours,cycleSeconds,paused,latitudeDeg,declinationDeg,noonAzimuthDeg}
145
+ │ · sun.{intensityScale,tint,tintMode} · sky.{turbidity,irradianceScale} · environment.intensity
146
+ │ · shadows.{opacity,extent} — the TRANSPORT knobs (they feed the probe bake: a change on a
147
+ │ baked world means re-bake, see the transport section)
148
+ │ · grade.{look,slope,power,saturation}
149
+ │ · exposure.{compensation,min,max,key,postGain} — the eye-adaptation envelope; negative
150
+ │ compensation is how a night scene stays dark, min == max pins the exposure outright, `key`
151
+ │ sets the adaptation target ABSOLUTELY, `postGain` is the tone-mapper exposure slider
152
+ │ · bloom.{intensity,nightIntensity,threshold} · fog.{enabled,density,heightDensity}
153
+ │ · ao.{intensity,radius} · practicals (carried verbatim, nothing reads it yet)
154
+ │ · probes.{center,size,budget,bounces,skyFallback} — WHERE the bake volume sits, how big, how
155
+ │ many probes it spends and how many bounces it photographs; an interior authors it. Transport.
156
+ │ · probes.volumes — NESTED probe volumes: a denser lattice over ONE room inside a big map,
157
+ │ each {center,size,budget,blendMetres,pad}. The escape from "one box, one cell size".
158
+ │ A tier that DROPS a pass ignores its block (`minimal` has no composer); wherever the pass
159
+ │ runs, it runs with your values — never author a per-tier look.
160
+ └─ NOT IN THE FILE (not yours): AA method, render scale, upscaler + sharpness, mip bias, AO resolution
161
+ and quality, MSAA, shadow-map SIZE (`shadows.extent` is yours, its texel count is not), anisotropy,
162
+ environment cube size, fog color (derived from the sky horizon),
163
+ pass order. These are QUALITY-TIER territory and engine internals. The player's tier chooses them.
164
+ Reaching for one means you are about to hand-roll a renderer the platform already owns — read the
165
+ DO/DON'T table instead.
166
+ ```
167
+
168
+ ## `helix.visuals.json` v1 — the complete reference
169
+
170
+ Lives at **`public/helix.visuals.json`** in the world source; it lands at the bundle root on build. It is **not**
171
+ part of `helix.json` (the manifest root rejects unknown keys) — it is a world-root JSON contract audited by
172
+ `helix validate` / `validate_world`, exactly like `public/helix.controls.json`. **Author the FILE and fetch it —
173
+ never inline the object in TypeScript.** The bake patches the FILE; a world that reads an inlined const keeps booting
174
+ the old config while the patched file ships as dead weight, silently.
175
+
176
+ `version` and `mode` are **required whenever the file exists**; every other field is optional and falls back to the
177
+ default below. A missing file means the whole default document (dynamic, noon, the `daylight` grade) — a complete,
178
+ correctly-lit world, but also one the bake cannot patch, so runtime-lit worlds should ship the file.
179
+
180
+ | field | type | default | guidance |
181
+ |---|---|---|---|
182
+ | `version` | number | — (required) | Must be exactly `1`. Any other value fails the parse at boot |
183
+ | `mode` | `"dynamic" \| "baked"` | — (required) | `"dynamic"` = lit live at runtime. `"baked"` = the light field loads from the bake artifact; **only the bake step writes this value** — hand-authoring `"baked"` without its artifact is a hard `validate` error and a boot throw |
184
+ | `timeOfDay.hours` | number | `12` | 0..24. On a world with no cycle this **is** the sun position. Out-of-range wraps (25 → 1) with a warning |
185
+ | `timeOfDay.cycleSeconds` | number | `0` | Wall seconds for a full 24 h day. `0` (or omitted) = the sun never moves and every cadence settles once. Negative clamps to 0 with a warning |
186
+ | `timeOfDay.paused` | boolean | `false` | Start a cycling world with the clock held. Irrelevant when `cycleSeconds` is 0 |
187
+ | `timeOfDay.latitudeDeg` | number | `45` | Sun-path latitude — how high noon gets. Low values (10–20) give a high, near-vertical noon; high values a long, raking one |
188
+ | `timeOfDay.declinationDeg` | number | `0` | Solar declination; `0` is the equinox. Season, effectively |
189
+ | `timeOfDay.noonAzimuthDeg` | number | `180` | The compass bearing noon sits at — **the one knob that aligns the sun path to your geometry.** Set this first |
190
+ | `sun.intensityScale` | number | `1` | Multiplies the photometrically-scheduled sun intensity — the whole day curve scales together, twilight fade included (clamped 0.05..20 with a warning). **THE high-contrast-interior knob**: open ground reads balanced at 1, but an enclosed space lit through an opening wants its direct sun several times hotter than its ambient (the Sponza reference runs 8× — the calibrated bench's sun 100 over the schedule's 12.5 at 55° elevation). Transport — re-bake after changing |
191
+ | `sun.tint` | hex color string | untinted | Constant sun tint, multiplied onto the sun color AFTER the elevation warm ramp — the ramp is white by day, so what you author is what you get at noon (`"#fff2dc"` is the classic warm key). Invalid hex warns and is ignored. Transport — re-bake after changing |
192
+ | `sun.tintMode` | `"multiply" \| "replace"` | `"multiply"` | How `sun.tint` combines with the engine's elevation warm ramp. `multiply` tints on top of it; **`replace` uses your hex verbatim** — the only way to a pure white `#ffffff` sun, since the ramp tops out at (1, 0.98, 0.94). `replace` with no `tint` warns and behaves as `multiply`. Transport — re-bake after changing |
193
+ | `sky.turbidity` | number | `2.5` | Linke's TL — the ONE haze number both halves of the daylight read (clamped 1..10): the sky dome the probes photograph AND the sun beam's own elevation curve. `2.5` is a clear day. Hazier air dims the beam at every elevation, but the 35° photometric anchor renormalises, so raising TL 2.5 → 4 actually **raises** the anchored 55° sun slightly (12.48 → 13.22 rig units) while sun-to-sky CONTRAST falls (1.705 → 1.542) — the frame reads flatter and more shadowless. Transport — re-bake after changing |
194
+ | `sky.irradianceScale` | number | `1` | Multiplies the whole sky level at its one choke point (clamped 0.1..4) — the dome, the SH sky fallback, the sky probe and the IBL's PMREM source all move together. **Not `environment.intensity`**: this says the SKY IS BRIGHTER (and the bake photographs it that way); that one says how much of the sky the materials see as unoccluded IBL, on top. The Intel bench's rayleigh 2 → 3 step measured +37 % sky ⇒ `irradianceScale ≈ 1.37`. Transport — re-bake after changing |
195
+ | `sky.twilightFloor` | number | `0.001` | The NIGHT sky's level (clamped 0.0001..1): the sky model is undefined below the horizon, so the runtime freezes the dome at 0° (a sunset) and fades it to this fraction of itself by −6°, log-spaced — the sky, the fog colour and the sky fallback outside the probe box all follow. The `0.001` default is the calibrated open-ground night (a black-blue sky, the lamps own the frame); a lit street can afford `0.003`, and `0.01` puts the night sky brighter than a lamp-lit floor. Rule: the night sky must end DARKER than your lamp-lit ground. A static-sun world authored below the horizon lives on this floor permanently. Runtime-blended — **no re-bake** |
196
+ | `environment.intensity` | number | `0.05` | The unoccluded sky-environment (IBL) level on scene materials (clamped 0..4). The default suits open ground; a bounded interior wants a whisper — the Sponza reference measured `0.002` against a path trace, and recorded anything higher as its largest single error: unoccluded sky grey-washes exactly the shadows the probe field already lights. Transport — re-bake after changing |
197
+ | `shadows.opacity` | number | `1` | How OPAQUE the sun's shadow is, 0..1 — three's `shadow.intensity`, a darkness knob separate from the sun's brightness. A receiver the shadow map marks as occluded gets `(1 − opacity)` of the sun: BOTH its diffuse and its specular. At `1` (the default since visual 0.1.6) a shadow is fully dark of the sun and the light you see in it is the probe field's bounce and sky — which is what a shadow is. Below 1 the sun leaks through every occluder: on matte surfaces that reads as a faint fill, on any glossy surface at the sun's mirror angle it reads as a sun glare on a floor that is behind a wall (the old `0.85` default put a highlight on a shed floor whose roof blocked the sun). Lower it only as a deliberate stylised lift, never to 'fill' shadows — the field does that. Transport — re-bake after changing |
198
+ | `shadows.extent` | number | derived from the probe box | Half-width in metres of the sun's shadow frustum, 4..200. Derived, the pinned fit is `max(x, z) / 2 + h · sin(90° − elevation)` over the probe volume (the Intel bench derives 21.25 m and hand-fits 24). Author it when the derived box clips shadows you can see — and know the cost: the texel is `2 · extent / mapSize`, so a bigger extent buys coverage with resolution. `mapSize` stays tier-owned. Transport — re-bake after changing (the bake photographs shadows) |
199
+ | `grade.look` | `"none" \| "needle" \| "daylight" \| "punchy"` | `"daylight"` | A calibrated ASC-CDL preset applied **inside** the AgX tone mapper. `none` = identity CDL (the look is installed, changes nothing) |
200
+ | `grade.slope` | number | preset's (`daylight` 1.05) | CDL slope ≈ exposure. Overrides the preset, on its own |
201
+ | `grade.power` | number | preset's (`daylight` 1.22) | CDL power ≈ contrast. Overrides the preset, on its own |
202
+ | `grade.saturation` | number | preset's (`daylight` 1.28) | CDL saturation. Overrides the preset, on its own |
203
+ | `exposure.compensation` | number | `0` | **THE night-scene knob.** EV stops added to the auto-exposure target (clamped ±5 with a warning): auto-exposure lifts a lit night interior toward mid grey, and negative compensation is how a world stays dark. Applies on every tier, `minimal` included |
204
+ | `exposure.min` | number | `0.4` | The lowest exposure multiplier adaptation may reach. **`min` equal to `max` pins the exposure** — the fixed-exposure idiom. Must be positive; `min > max` is a parse error |
205
+ | `exposure.max` | number | `40` | The highest multiplier adaptation may reach — the ceiling that stops a dark scene being lifted without bound. Lower it when night still reads too bright after compensation |
206
+ | `exposure.key` | number | absent — the day/night schedule (`0.624` day / `0.06` night, then × 2^`compensation`) | **The ABSOLUTE auto-exposure target** (0.005..1): present, it replaces the schedule outright, and `compensation` is ignored with a warning (authoring both is the mistake — `key` wins). The runtime meters the FULL-FRAME log-average (a 64² mip chain), so a bench number metered over the same window transfers 1:1. On a high-contrast interior treat it as a STARTING point and re-derive by eye — the measured gap has run 1–1.7 stops. Post — no re-bake, and volume-overridable (volumes blend it in log space) |
207
+ | `exposure.postGain` | number | `1` | The tone-mapper "exposure" slider (0.1..4): a multiplier applied AFTER bloom and BEFORE the AgX curve, so **bloom does not see it** — which is exactly why it is not the same as lifting `key`. The Intel bench ships `key: 0.094` + `postGain: 0.75` (the pair used to fold lossily into one `compensation: -3.146`). Post — no re-bake, volume-overridable |
208
+ | `bloom.intensity` | number | `0.4` | Bloom by day; the runtime blends toward `nightIntensity` on the same night schedule the exposure key rides. Tiers without a composer have no bloom and ignore the block |
209
+ | `bloom.nightIntensity` | number | `0.12` | Bloom at night — the calibrated default is what stops emissive lamps turning a street into fog |
210
+ | `bloom.threshold` | number | `4` | HDR luminance bloom triggers above, **pre-tonemap**. The default clears the sky dome's measured peak (3.43) — lower it with care or the sky itself blooms and veils the frame |
211
+ | `fog.enabled` | boolean | `true` | `false` removes atmospheric fog outright (the fog pass, and `scene.fog` on `minimal`). Fog **color** stays engine-derived from the sky horizon — not authorable |
212
+ | `fog.density` | number | `0.0012` | Uniform aerial perspective. Clear-day fog is TINY — the default reads ~4% at 40 m; ten times it is pea soup |
213
+ | `fog.heightDensity` | number | `0.002` | The ground-hugging height-fog term (composer tiers only) |
214
+ | `ao.intensity` | number | `3` | N8AO occlusion strength. The tier still owns AO resolution/quality, and tiers without AO ignore the block |
215
+ | `ao.radius` | number | `1.8` | AO sampling radius in metres — scale with the world's spaces (interiors smaller, plazas larger) |
216
+ | `practicals` | array of objects | `[]` | Night/practical light declarations. **Carried verbatim — nothing in the runtime reads them yet** (`validate` warns when non-empty) |
217
+ | `probes.center` | `[x, y, z]` metres | auto — pinned off the boot camera | Where the probe volume sits. **Fit the box to your room — required in practice for any interior**, because the auto pin follows wherever the camera booted, not your floor plan. Authored values are used verbatim (never snapped). An authored `center` also pins a `"dynamic"` world's RUNTIME volume statically to the same box — the pre-bake world previews exactly what its bake will capture, instead of a camera-following volume that ignores your floor plan |
218
+ | `probes.size` | `[x, y, z]` metres | `[36, 18, 36]` (7×4×7 probes at 6 m cells) | How BIG the probe volume is. Per-axis cell size derives as `size / (count − 1)`, so at a fixed budget fitting a room means FINER cells: shrink the box first, buy probes second (`probes.budget`). See the volume-fit rule below. Shapes any pinned volume — the bake's, or a `"dynamic"` world pinned by an authored `center`; ignored only while the volume follows the camera (`"dynamic"` with no authored `center`) |
219
+ | `probes.budget` | `[nx, ny, nz]` or `"default" \| "large"` | `[7, 4, 7]` = 196 probes | How MANY probes fill the box. `"default"` is `[7, 4, 7]`, `"large"` is `[12, 6, 10]` = 720. Each axis 2..16, product at most 1536; a violation WARNS and falls back to the default budget **whole** — never a per-axis clamp, which would be a grid nobody authored. A bigger budget buys tighter cells over the SAME box, never a bigger box. It costs at both ends — see "Size the probe budget" below. Transport — re-bake after changing |
220
+ | `probes.bounces` | number | `1` | Radiance passes the PUBLISH-TIME bake runs, 1..4. `1` is the direct pass alone (what every bake did before the field existed); each further pass re-photographs the scene lit by the field the pass before it left, so `2` is one indirect bounce — measured +1.8 stops on the Intel arcade's rear wall, and 3 lifted the Crytek atrium floor 2×. Bake time scales with it, and the practicals key runs the same count. **Bake only**: a `"dynamic"` world's live refresh has its own feedback bounce and ignores the field. Transport — re-bake after changing |
221
+ | `probes.skyFallback` | boolean | absent = AUTO | What the field answers OUTSIDE its box: `true` = the live sky SH, `false` = the sampler's clamped edge layer. AUTO is `false` wherever `probes.center` is authored — a world that fitted its box to a room is an interior, whose honest out-of-volume answer is its own walls — and `true` otherwise (open ground). A fitted box that genuinely wants skylight beyond it authors `true` explicitly. Transport — re-bake after changing |
222
+ | `probes.volumes` | array of boxes | `[]` | **Nested probe volumes — a DENSER lattice over one room, inside the world volume.** Up to 4; each entry: required `center`/`size` ([x, y, z] metres, axis-aligned), `budget` (same band as the world's), optional `blendMetres` (default 0.5 — the cross-fade into the world field, measured INWARD from the padded faces) and `pad` (default 0 — metres past the box the volume still owns; a number, `[x, y, z]`, or per-axis `{ x, y, z }` where each axis takes a number or a `[minFace, maxFace]` pair). The sampler picks the innermost volume per fragment; the bake fills every volume into the ONE artifact, and a PINNED `"dynamic"` world (authored `probes.center`) serves them live. See "Nested probe volumes" below — the layout rules there are measured, not stylistic. Transport — re-bake after changing |
223
+ | `volumes` | array of boxes | `[]` | **Post-processing volumes — local overrides of the look where the CAMERA stands.** Each entry: required `center`/`size` ([x, y, z] metres, axis-aligned box), optional `blendMetres` (default 1 — the blend ramp extends OUTWARD from the box surface; 0 = hard edge), and a SPARSE override of `exposure`, `bloom`, `fog`, `ao` — only the fields you author pull, everything else keeps the document's value. See the section below |
224
+ | `bakedLighting` | `{ "version": 1, "artifact": "helix.visuals.bake.json", "keys": 1 }` | absent | **Written by `bake_world_lighting`, never by hand.** Points a `"baked"` world at its artifact; a `"dynamic"` world carrying one gets a `validate` warning (the runtime ignores it). `keys` is the bake's receipt of how many sun keys the field carries — ONE key under a cycling sun is a `validate` ERROR (the indirect light stays pinned to the baked hour while the sky turns), 9 keys under a static sun a hint to re-bake for a ~9× smaller artifact |
225
+
226
+ **Unknown TOP-LEVEL keys are carried, never rejected** — that is how `bakedLighting` rides in and how an older
227
+ runtime still boots a newer world. `validate` only speaks up when a carried key is a near-miss of a real one
228
+ (`"timeofDay"` → *did you mean "timeOfDay"?*). **Unknown keys INSIDE a claimed block (`timeOfDay`, `sun`, `sky`,
229
+ `environment`, `shadows`, `grade`, `exposure`, `bloom`, `fog`, `ao`, `probes`) are DROPPED by the parser** — the
230
+ newer strict blocks (`sky`, `probes`, `exposure`) name the nearest real key as they drop one, the older ones go
231
+ quietly, which is exactly why the audit warns on them: a typo there costs you the default value and a world that
232
+ looks subtly wrong with no error anywhere.
233
+
234
+ A static-sun world before its bake, complete:
235
+
236
+ ```json
237
+ {
238
+ "version": 1,
239
+ "mode": "dynamic",
240
+ "timeOfDay": { "hours": 10, "cycleSeconds": 0, "latitudeDeg": 10, "declinationDeg": 0, "noonAzimuthDeg": 130 }
241
+ }
242
+ ```
243
+
244
+ The same world after `bake_world_lighting` — this is the shipped `hangout` template's actual contract; note that
245
+ **the bake wrote the last two lines**, not the author:
246
+
247
+ ```json
248
+ {
249
+ "version": 1,
250
+ "mode": "baked",
251
+ "timeOfDay": { "hours": 10, "cycleSeconds": 0, "latitudeDeg": 10, "declinationDeg": 0, "noonAzimuthDeg": 130 },
252
+ "bakedLighting": { "version": 1, "artifact": "helix.visuals.bake.json" }
253
+ }
254
+ ```
255
+
256
+ A ten-minute day/night cycle with a punchier grade (bake it too — on a baked world the cycle blends keyframes):
257
+
258
+ ```json
259
+ {
260
+ "version": 1,
261
+ "mode": "dynamic",
262
+ "timeOfDay": { "hours": 6.5, "cycleSeconds": 600, "latitudeDeg": 45, "noonAzimuthDeg": 180 },
263
+ "grade": { "look": "punchy" }
264
+ }
265
+ ```
266
+
267
+ ### Post-processing volumes — one document, two spaces
268
+
269
+ A single global look often has to serve spaces that disagree. The measured case: a night café authors
270
+ `exposure.compensation: -1.5` + `bloom.nightIntensity: 0.07` so its city balcony reads as real night — and the lit
271
+ interior goes too dark under the same numbers. A **volume** gives the interior its own values without moving the
272
+ balcony's:
273
+
274
+ ```json
275
+ {
276
+ "exposure": { "compensation": -1.5 },
277
+ "bloom": { "nightIntensity": 0.07 },
278
+ "volumes": [
279
+ { "center": [0, 1.6, 0], "size": [16, 3.2, 12], "blendMetres": 1.2,
280
+ "exposure": { "compensation": -0.5 } }
281
+ ]
282
+ }
283
+ ```
284
+
285
+ The box is the interior room, verbatim from its floor plan; the global document is the balcony's night. Walking out
286
+ the door fades the exposure over 1.2 m — which incidentally reads as your eyes adjusting to the dark.
287
+
288
+ The rules, exactly as the runtime applies them:
289
+
290
+ - **The CAMERA drives it** (post is a camera property): weight 1 anywhere inside the box, fading linearly to 0 over
291
+ `blendMetres` measured OUTWARD from the box surface (euclidean, so corners fade on the diagonal). `blendMetres: 0`
292
+ is a hard edge.
293
+ - **Overrides are SPARSE.** Author only the fields that should differ; everything else keeps the document's value. A
294
+ volume may override `exposure`, `bloom`, `fog` (`density`/`heightDensity` — NOT `enabled`: fade fog by blending
295
+ density toward 0), and `ao`.
296
+ - **Not overridable**: `grade` (the CDL is compiled into the tone-mapping — changing it per volume would be a
297
+ reload-class recompile), `timeOfDay`, `probes`, `mode`, and the transport blocks `sun` / `environment` /
298
+ `shadows` — light is where it is; it cannot vary by where the camera stands. `validate` warns on each with the
299
+ reason.
300
+ - **Overlaps compose in array order** — each volume lerps the running value toward its own by its weight, so a later
301
+ entry wins where its weight saturates. Independent rooms with disjoint boxes never interact.
302
+ - **Zero volumes costs zero**: a document without the key does no per-frame work at all. Tiers are unchanged — a tier
303
+ that drops a pass still ignores that pass's values, volume or not.
304
+ - **Third-person worlds: probe the CHARACTER, not the camera.** The trailing camera hangs metres behind the player
305
+ and dips through walls — measured: standing on the balcony facing the moon put the camera inside the interior
306
+ volume's band through the wall, and the freed exposure clamp blew the night out. Call
307
+ `visuals.setVolumeFocus(character.root)` once the character exists (and again after an avatar swap — the body is
308
+ replaced wholesale); `setVolumeFocus(null)` restores the camera default, which is correct for first-person. A tight
309
+ `blendMetres` (the café ships 0.4) narrows any residual dip; the auto-exposure's own adaptation keeps even a hard
310
+ edge from popping.
311
+
312
+ **Night and practicals, honestly.** The `practicals` array is a placeholder: the night/practical rig has not moved
313
+ into the package, so declaring lamps there lights nothing. A world that needs night atmosphere today authors it as
314
+ ordinary world content, following two measured rules: **pair a small emissive mesh with a real light** (an emissive
315
+ material is not a light source — it glows, it does not illuminate), and **do not keep a large lamp rig resident at
316
+ intensity 0** — layer-mask it out by day. Measured: 48 resident lights cost **+2.64 ms/frame**; 4 cost 0.032 ms, so a
317
+ handful can just stay. On a **baked** world, know that world-placed lights are currently counted twice (they lit the
318
+ bake AND they run live) — exact by day, ~1 % at night, worst ~16 % at twilight — so keep practicals modest until the
319
+ runtime consumes the declaration.
320
+
321
+ ## Transport vs post — the re-bake rule
322
+
323
+ The document authors two different kinds of value, and on a baked world they behave differently:
324
+
325
+ - **Light transport** — `sun`, `sky`, `environment`, `shadows`, the `timeOfDay` *path* (`latitudeDeg`,
326
+ `declinationDeg`, `noonAzimuthDeg`) and `probes` (`center`, `size`, `budget`, `bounces`, `skyFallback` alike):
327
+ these decide what light IS in the scene, and the probe bake photographs their effect into the artifact. On a
328
+ `"baked"` world a transport edit is therefore half-invisible until you re-run the pipeline — build →
329
+ `bake_world_lighting` → `validate_world` → publish. Ship it without the re-bake and the live sun disagrees with
330
+ the baked bounce, with no error anywhere. (`timeOfDay.hours` and `cycleSeconds` are the exception: the bake
331
+ sweeps the whole day, so playing a different hour or cycle speed just blends existing keyframes — no re-bake.)
332
+ - **Post** — `grade`, `exposure` (`key` and `postGain` included), `bloom`, `fog`, `ao` and `volumes`: these decide
333
+ how the lit frame is DEVELOPED. They apply live on every boot, never require a re-bake, and are the only blocks a
334
+ volume may override. A volume carrying a transport knob is a `validate` warning and the runtime ignores it.
335
+
336
+ **One re-bake is not optional: every artifact baked before the pinned shadow rig was sun-flooded.** The bake's own
337
+ shadow map used to cover only ±10 m around the spawn, and everything outside that frustum was treated as LIT — so
338
+ deep interiors were baked lit THROUGH THEIR WALLS. If a world's artifact predates the fix, re-bake it: it is
339
+ carrying light that never existed. Expect the honest bake to come back DARKER, because it is. That darkness is a
340
+ transport problem with transport answers — `probes.bounces` 2 or 3 (one bounce is direct light only) and
341
+ `sky.irradianceScale` where the sky itself is short, then the exposure re-derived by eye. **Never a fill light**:
342
+ the no-fake-fills boundary in world-look is exactly about this moment.
343
+
344
+ ## Calibrating a high-contrast interior — the measured recipe
345
+
346
+ The failure looks like this: a bounded interior lit through an opening — an atrium, a courtyard house, a hall with
347
+ clerestory windows — boots washed and flat, and **no `exposure` value fixes it**. The defect is not brightness but
348
+ the **sun-to-ambient ratio**: the open-ground defaults run a balanced sun over a generous sky environment, while an
349
+ interior wants blown sun pools over deep probe-lit shadow. Exposure multiplies both ends together; only the
350
+ transport knobs move the ratio. Measured on the Crytek Sponza atrium against its path-traced calibration reference:
351
+
352
+ 1. **`environment.intensity` down to a whisper.** The reference measured `0.002`; the unoccluded sky term was its
353
+ single largest error, grey-washing exactly the shadows the probe field already lights correctly.
354
+ 2. **`shadows.opacity` stays at its `1` default.** (Before visual 0.1.6 the default was `0.85`, and that 15% fill read as haze at this contrast — a world that authored `0.85` should drop the line.)
355
+ 3. **`sun.intensityScale` up** until the direct pools sit several stops over the ambient — the Sponza class lands
356
+ around 7×. The sunlit courtyard is MEANT to clip toward white while the arcades hold detail; that contrast IS
357
+ the look.
358
+ 4. Optionally **`sun.tint`** for a warm key (`"#fff2dc"` is the classic), with `sun.tintMode: "replace"` when you
359
+ want that hex verbatim instead of tinted on top of the elevation ramp.
360
+ 5. **`probes.bounces` 2 or 3.** An honest deep interior is DARK on one bounce, because one bounce is direct light
361
+ only. The second pass measured +1.8 stops on the Intel arcade rear wall; three lifted the Crytek atrium floor 2×.
362
+ Bake time scales with the count, so raise it only where the darkness is real.
363
+ 6. **Re-derive the exposure LAST, then re-bake.** Auto-exposure now meters a scene with real contrast, so whatever
364
+ `compensation` was right under the defaults is guaranteed stale — and a bench-metered `exposure.key` (+
365
+ `postGain`) is the more direct way to say it. Either way, re-derive by EYE: the measured gap between a folded
366
+ bench number and what the interior actually wanted has run 1–1.7 stops.
367
+
368
+ **Sky level is not IBL level.** `sky.irradianceScale` makes the SKY brighter — the dome the probes photograph, the
369
+ SH fallback, the sky probe and the IBL source together — and the bake records it; `environment.intensity` decides
370
+ how much of that sky the materials see as unoccluded IBL, on top. Raise the first when the whole daylight is short,
371
+ lower the second when unoccluded sky is grey-washing shadows the probes already light. `sky.turbidity` is the third
372
+ axis and it is a CONTRAST knob: TL 2.5 → 4 leaves the anchored sun where it is (12.48 → 13.22 at 55°) but drops the
373
+ sun-to-sky ratio 1.705 → 1.542, which reads as flatter and more shadowless — the opposite of what a high-contrast
374
+ interior wants.
375
+
376
+ The worked transport contract for that world (post blocks trimmed; the bake writes `mode`/`bakedLighting`):
377
+
378
+ ```json
379
+ {
380
+ "version": 1,
381
+ "mode": "dynamic",
382
+ "timeOfDay": { "hours": 12, "cycleSeconds": 0, "latitudeDeg": 35, "declinationDeg": 0, "noonAzimuthDeg": 315 },
383
+ "sun": { "intensityScale": 8, "tint": "#fff2dc" },
384
+ "environment": { "intensity": 0.002 },
385
+ "shadows": { "opacity": 1.0 },
386
+ "probes": { "center": [-0.5, 6, -0.3], "size": [21, 11, 9] }
387
+ }
388
+ ```
389
+
390
+ ## Baked lighting — the build step
391
+
392
+ Baking turns a bounded, static, runtime-lit world's GI into a **keyframed probe field computed once at publish
393
+ time**: the full lighting look, across the whole day, shipped as a ~1–2.5 MB artifact that imports in milliseconds at
394
+ boot instead of minutes of live convergence. Time-of-day comes free — blending keyframes IS the cycle. This is the
395
+ target state for every bounded static world.
396
+
397
+ **The pipeline** (the bake runs on the BUILT world, never a source tree):
398
+
399
+ ```
400
+ npm run build → bake_world_lighting({ directory }) → validate_world → publish_world
401
+ ```
402
+
403
+ What the tool does (it is `helix bake-lighting` under the hood): serves the build headlessly, drives the in-page bake
404
+ driver across the sun keyframes, audits and repairs the probe field, then writes `helix.visuals.bake.json` and
405
+ patches `helix.visuals.json` to `mode: "baked"` in **both** the source `public/` and the built root — so you do not
406
+ rebuild after baking; the artifact is already in `dist/`.
407
+
408
+ **Bake at a fixed hour: leave `keys` alone.** A world whose `timeOfDay.cycleSeconds` is 0 (or absent) has ONE sun
409
+ position, and the engine plans ONE key for it — the whole day sweep is nine keys of an artifact you would never
410
+ play. So OMIT `keys` and let the engine decide: 1 on a static sun, 9 on a cycle. Asking for `keys: 1` on a cycling
411
+ world is refused outright (its indirect light would stay pinned to the baked hour while the sky turns), and the
412
+ pointer the bake writes carries the count as `bakedLighting.keys`, which `validate` then checks against the
413
+ world's own clock. The artifact tracks the key count, so a 1-key field is roughly a ninth of a 9-key one.
414
+
415
+ **`gpu: true` when the wait hurts.** The default bakes on the bundled SwiftShader software renderer — reproducible,
416
+ and slow: measured on a 45 MiB world, `gpu: true` through a system Chrome/Edge took **41 s against 27 minutes**
417
+ (5.5 s vs 21 minutes for the key itself). It is not bit-reproducible across machines, which is why software stays
418
+ the default; use it while iterating, and know that the shipped artifact is whatever the last bake wrote. The boot
419
+ budget scales with the bundle either way (20 s per MiB, floor 300 s), so `timeoutSeconds` is only for a boot that
420
+ is slow for some OTHER reason.
421
+
422
+ What to know before you run it:
423
+
424
+ - **It costs minutes of blocking headless GPU work.** A small world is a minute or two; software rendering is slower.
425
+ Do not interrupt it, do not call it in a loop. `timeoutSeconds` bounds only the wait for the world to boot and
426
+ publish the bake handle — not the bake itself.
427
+ - **Nothing it writes ships until you re-validate and re-publish.** The artifact and the patched contract are on disk
428
+ only.
429
+ - **It fails loudly rather than write a wrong artifact** — an unbuilt world, a world that never calls
430
+ `createVisualRuntime` (not runtime-lit ⇒ no probe field to bake), a slow boot, or the donor-occlusion gate (a
431
+ repair donor invisible from its recipient means light would teleport through a wall; nothing is written). The
432
+ failure text leads with the page errors — that is the diagnosis, read it.
433
+ - **An SH-only warning means the bake ran on a tier without GI visibility data** — the world plays, but indirect
434
+ light can leak through walls. Re-bake on a machine whose headless GPU reaches the `high` tier if the look matters.
435
+ - **There is no staleness detector.** Re-bake whenever geometry, lights or a transport knob changes (`sun`, `sky`,
436
+ `environment`, `shadows`, the `timeOfDay` path, `probes` — see the transport section) — the artifact is a
437
+ snapshot, and a stale one ships the OLD room's light with no error anywhere. Make "did the scene change?" part of
438
+ every republish of a baked world.
439
+ - **The probe budget is yours, and it is the one knob that costs on both sides** — bake time and artifact bytes both
440
+ scale with the probe count. Size it deliberately (below). A world too large to cover at useful cell size even at a
441
+ raised budget is a `"dynamic"` world — the "very large map" branch of the tree.
442
+
443
+ ### Fit the probe volume to the room — an interior MUST author it before baking
444
+
445
+ The bake pins ONE probe volume: by default 7×4×7 probes at 6 m cells (a 36 × 18 × 36 m box) anchored off the boot
446
+ camera. **That default is sized for open ground.** A bounded interior authors `probes: { center, size }` in
447
+ `helix.visuals.json` to fit its playable space *before* it bakes — otherwise the box is mostly outside the room.
448
+ Authoring it is never premature: a `"dynamic"` world with an authored `center` pins its **runtime** volume to the
449
+ same box (no camera-follow), so the pre-bake world already previews the volume its bake will use — and avoids this
450
+ exact artifact class live, where a scrolling camera-anchored volume can leave an interior a single ankle-height layer.
451
+
452
+ The café case, in numbers. A room ~16 × 12 m with a 3.5 m ceiling took the default volume: exactly **one probe layer
453
+ landed inside the room — at ankle height** — and the other three sat above the roof in dark night air. One ankle
454
+ probe landed inside the fireplace hearth, 0.55 m from a strong point light, and baked **6× hotter** than every other
455
+ probe. The ceiling then interpolated between a fire-hot ankle probe and above-roof darkness across a 6 m cell: a huge
456
+ red trilinear wash with a hard diagonal edge. The audit signature is unmistakable — nearly every probe
457
+ flagged from-below-dominant, the bottom layer carrying ~7× the field energy of the other three combined.
458
+
459
+ Sizing it is arithmetic: `center` on the room, `size` on the space you actually play in, and cells fall out as
460
+ `size / (count − 1)`. That café authors roughly `{ "center": [0, 1.6, 0], "size": [16, 2.6, 12] }` — 2.7 × 0.9 × 2.0 m
461
+ cells, four probe layers between floor and ceiling, instead of 6 m cells and one. **The bake now warns when its
462
+ volume does not fit the play space** (too few useful layers, or the field energy piled into one layer) and names this
463
+ block as the fix: heed it by authoring `probes` and re-baking, not by shipping the artifact it just wrote.
464
+
465
+ ### Size the probe budget — what more probes actually cost
466
+
467
+ **Fit the box first, buy probes second.** Cells are `size / (count − 1)` per axis, so shrinking the box and raising
468
+ the budget do the same thing to cell pitch — and only one of them is free. `probes.budget` takes `"default"`
469
+ (`[7, 4, 7]` = 196 probes), `"large"` (`[12, 6, 10]` = 720), or your own `[nx, ny, nz]`, each axis 2..16 and at most
470
+ 1536 probes in total. A budget outside that band is refused WHOLE and falls back to the default — never clamped
471
+ per axis, because a half-honoured budget is a grid nobody authored.
472
+
473
+ What it costs, in the two places it costs:
474
+
475
+ - **Bake time is proportional to the probe count**, and multiplied again by `probes.bounces`.
476
+ - **The artifact grows ~12 KB per probe** (the geometry block is base64 float32 depth tiles). Measured: `"large"`
477
+ wrote a **9.2 MB** field and blew the **50 MiB bundle cap** on a 45 MiB world outright; `[8, 5, 8]` — 320 probes —
478
+ wrote 4.0 MB and fit. Bytes track the probe count almost exactly, so a budget is a bundle decision too.
479
+ - **The GI-visibility atlas grows linearly too**, one 3D-texture slice per probe plus fixed rows: 196 probes ask for
480
+ **322 slices**, 720 for **1045** (nested volumes stack their own slabs on top — see the section below). GLES3 only
481
+ guarantees **256**, so past that a phone that caps there loses DDGI visibility (the world still plays); desktop
482
+ drivers report 2048.
483
+ - **A `"dynamic"` world pays the budget on EVERY refresh pass**, not once at publish — there, a raised budget is a
484
+ per-frame bill, not a one-time one.
485
+
486
+ `helix validate` / `validate_world` prints what your numbers bought — the cell pitch inside the box and the atlas
487
+ slice count — so read the audit instead of guessing:
488
+
489
+ ```json
490
+ { "probes": { "center": [0, 1.6, 0], "size": [16, 2.6, 12], "budget": [8, 5, 8], "bounces": 2 } }
491
+ ```
492
+
493
+ ### Nested probe volumes — buy cell size for ONE room, not the whole map
494
+
495
+ The probe budget buys cell size over ONE box, so a big map and a small interior cannot both be served: fitting the
496
+ world box to the map means 4–6 m cells inside every building (leaky corners, washed creases), and fitting it to the
497
+ building abandons the map. `probes.volumes` is the escape — up to 4 nested boxes, each with its own lattice, baked
498
+ into the same artifact; per fragment the sampler picks the innermost volume containing it and cross-fades into the
499
+ world field over `blendMetres`.
500
+
501
+ ```json
502
+ {
503
+ "probes": {
504
+ "center": [-3, 4.65, -3], "size": [40, 8.7, 40], "budget": [9, 4, 9], "bounces": 3,
505
+ "volumes": [{
506
+ "center": [0, 2.2, -11.7], "size": [9.4, 3.7, 7.4], "budget": [6, 3, 6],
507
+ "blendMetres": 0.25, "pad": { "y": [0.85, 0.62] }
508
+ }]
509
+ }
510
+ }
511
+ ```
512
+
513
+ That is the reference shed's working set — a 7.7 × 5.7 m room with 0.3 m walls under a 40 m map — and every rule
514
+ below was measured on it, artifact by artifact. Follow them in order; each guards a distinct failure class.
515
+
516
+ 1. **The box ends OUTSIDE the building in XZ** — faces ~0.5 m past the outer walls. The outermost probe columns then
517
+ stand in open air (honest outdoor probes) and every interior surface sits deep inside the volume. A box fitted
518
+ tight to the interior hands the wall bases and corners to the coarse world field through the blend band.
519
+ 2. **Y stays TIGHT — probe layers must land inside the room** (bottom layer above the floor, top layer under the
520
+ ceiling); a box stretched floor-to-roof puts boundary layers underground and above the roof. Cover the floor and
521
+ ceiling with `pad`, not the box: pad DOWN by (box-bottom − floor-top) + `blendMetres` so the band is buried
522
+ underground; pad UP past the ceiling underside by at least `blendMetres` but keep the padded face INSIDE the roof
523
+ slab — the sunlit roof top must stay outside the volume, or it darkens with clamped interior values.
524
+ 3. **The band rule**: every surface the volume should own outright must sit MORE than `blendMetres` inside the
525
+ PADDED box — the blend measures from the padded faces. Thin walls force a small band: `0.25` for a 0.3 m shell.
526
+ A band wider than the geometry allows silently blends the coarse world field back into your walls.
527
+ 4. **The density floor: nested cells ≤ ~1.9 m, or interior corners bleed.** Measured: `[4, 2, 4]` over this room
528
+ (2.5–3.1 m cells) leaked a bright sunlit strip down every interior corner — the corner cells were majority
529
+ OUTDOOR probes with the nearest honest interior donor 2.3 m away; `[6, 3, 6]` (1.5–1.9 m cells) rendered the same
530
+ corners clean. Fine cells are the entire point of a nested volume — an under-budgeted one is worse than none.
531
+ 5. **`pad` defaults to 0 because it is a trade, not a convenience.** Outside the lattice the sampler CLAMPS to the
532
+ boundary probes — extension without falloff — so a pad reaching open ground stamps one probe's value across
533
+ terrain where the light should fade (measured: a bright plateau on night cobbles beside a lamp). Pad into
534
+ geometry only: down through floors, up into roof slabs, sideways only into masonry.
535
+
536
+ What it costs: the same per-probe arithmetic as the world budget — bake time and ~12 KB of artifact per probe, plus
537
+ each volume's own 3D-atlas slab (the `[6, 3, 6]` room adds ~200 slices to the world's ~500). Phones on the GLES3
538
+ 256-slice floor lose DDGI visibility on multi-volume atlases (the world still plays); desktops report 2048.
539
+ `validate` prints each volume's cell pitch — read it against the ≤ 1.9 m floor before baking.
540
+
541
+ **Nested volumes also serve LIVE on a `"dynamic"` world — but only a PINNED one.** Author `probes.center` (+
542
+ `size`): the runtime then packs every volume into one live atlas, refreshes them round-robin on the same per-frame
543
+ slice budget, and builds each volume its own runtime occlusion field. Without `probes.center` the volume follows the
544
+ camera and nested volumes are OFF (the runtime says so in the console; `validate` warns at author time) — a
545
+ scrolling volume cannot carry them by design. Two live costs to know: the refresh round now covers every volume, so
546
+ the whole field's refresh period stretches with the nested probe count (the reference set: ~1.5 s → ~2.4 s per
547
+ round), and a nested volume's captures run at a 32² face — measured on the reference shed at midnight, the 16²
548
+ face's narrow-aperture noise reads as probe-scale light pools (neighbour probes disagreeing up to ~20×; 32² brings
549
+ that to ~3×, which is the field's own falloff). The five layout rules above apply unchanged — they are properties
550
+ of the box, not of the bake.
551
+
552
+ ## Quality tiers — what the agent needs to know
553
+
554
+ The player picks quality, not you. The runtime resolves it into one **tier plan** and applies the whole plan
555
+ coherently; a tier is the *same look degraded*, never a second art direction.
556
+
557
+ | tier | render scale / upscaler | AA | AO | GI rung | shadow map | aniso |
558
+ |---|---|---|---|---|---|---|
559
+ | `minimal` | 1.0, **no composer at all** — direct render, zero fullscreen passes | none | none | none | 1024 | 4 |
560
+ | `low` | 0.59 → FSR1 (EASU→RCAS) | FXAA | none | none (foundation only) | 1024 | 8 |
561
+ | `medium` | 0.77 → FSR1 | FXAA | half-res + measured multi-bounce | probes | 2048 | 8 |
562
+ | `high` | 1.0 (+MSAA 4) | SMAA | half-res + measured multi-bounce | probes + GI visibility | 2048 | 8 |
563
+ | `ultra` | 1.5 supersample (via pixel ratio) | SMAA | full-res + measured multi-bounce | probes + GI visibility | 4096 | 16 |
564
+
565
+ **`minimal` is defined by METHOD, not by resolution**: no composer, so AgX + the CDL are stamped onto the scene
566
+ materials, exposure is scheduled off a closed-form sun curve, fog is `scene.fog` and one small sun map does shadows.
567
+ Each fullscreen pass is a tile load/store on a mobile tiler, which is why the floor drops the *passes* rather than
568
+ the pixels. Tiers without probes simply do not consume a baked field — a baked world still plays everywhere.
569
+
570
+ **A dynamic world's probe refresh SLEEPS when nothing feeds it.** The rung re-bakes its field continuously — that is
571
+ the price of `"dynamic"` — but once the field converges under a static sun, static lights and a still volume, the
572
+ refresh idles (the debug HUD's probes line reads `sleeping`) and the frame costs what a baked world's does, until an
573
+ input moves: the sun past a quarter degree, any visible light's intensity/colour/position/count, a volume move, or a
574
+ clock scrub. Corollary: **a practical you animate every frame — a flickering fire driving `light.intensity` — keeps
575
+ the field awake forever and pays the full refresh for it.** When the pulse does not need to reach the GI field,
576
+ flicker the emissive MATERIAL instead and leave the light steady.
577
+
578
+ **`auto` is capability detection, and it never picks `ultra`.** In order: mobile (or `maxTextureSize < 4096`) →
579
+ `minimal`; `maxSamples < 4` or `maxTextureSize < 8192` → `low`; no `OffscreenCanvas` → `medium`; otherwise `high`.
580
+ `ultra` is an explicit opt-in only. Every desktop row was measured on ONE GPU in ONE browser; the mobile mapping is
581
+ provisional.
582
+
583
+ **Two tier changes are reload-class, and that is program-cache physics, not policy**: turning the composer on or off
584
+ (any move into or out of `minimal`) re-stamps every scene material, and changing the GI rung or GI visibility moves
585
+ the program set — both in the measured ~14 s recompile class, which is never acceptable mid-play. Everything else
586
+ applies live. `setQuality()` tells you which you got: `{ tier, kind: 'live' | 'reload', reload, pending }` — surface
587
+ `pending` to the player as *"applies on next load"* rather than pretending it landed.
588
+
589
+ **How the player's choice reaches you today:** `?quality=` on the play URL (see the boot snippet above — 3 lines,
590
+ copy them). A shell protocol message is planned, not built; until it exists, reading the param is the world's job.
591
+
592
+ ### DO / DON'T (runtime-lit worlds)
593
+
594
+ | DON'T | DO | Why |
595
+ |---|---|---|
596
+ | construct a `WebGLRenderer` and set `outputColorSpace` / `toneMapping` / `shadowMap` yourself | pass `canvas` to `createVisualRuntime` and use `visuals.renderer` everywhere else | that five-line ACES/sRGB block WAS the platform's whole rendering policy; the runtime replaces it with the calibrated one (AgX + CDL + scheduled shadows). Two tone mappers means grading a graded frame |
597
+ | call `renderer.render(scene, camera)` in your loop | call **`visuals.tick(dt)`** — it renders the frame | on composer tiers the frame is a pass chain into the canvas; a raw `render()` draws the ungraded, un-AO'd, un-upscaled image over it |
598
+ | add a `DirectionalLight` "sun" or a `HemisphereLight` "ambient" | author `timeOfDay` and let the runtime place the sun; scale it with `sun.intensityScale`, never with a second light; keep your own lights for *local* sources only | sky, sun intensity and the shadow rig are photometrically calibrated **together**; a second sun double-lights the scene and breaks the light-count parity the probe bake asserts |
599
+ | fight a flat, washed interior with `exposure` — or by hand-adding lights to "fill it in" | calibrate the transport: `environment.intensity` to a whisper, `shadows.opacity` `1.0`, `sun.intensityScale` up, THEN re-derive exposure and re-bake | the wash is a sun-to-ambient RATIO defect and exposure multiplies both ends together, so it can never fix it; added lights double-light a scene the probes already carry. The calibrated-interior recipe above is the fix |
600
+ | author `sun`, `sky`, `environment` or `shadows` inside a `volumes[]` entry | keep volumes to the post blocks (`exposure`, `bloom`, `fog`, `ao`) | transport cannot vary by camera position — light is where it is; `validate` warns and the runtime ignores the entry's transport keys |
601
+ | fight a DARK honest interior bake with a fill light, or by lifting `environment.intensity` back up | raise `probes.bounces` to 2–3, and `sky.irradianceScale` if the sky itself is short; re-derive the exposure after | one bounce is DIRECT LIGHT ONLY, so a deep interior is legitimately dark on the default — the second pass measured +1.8 stops on the Intel arcade rear wall. Unoccluded sky and fill lights both fake the bounce the bake can just compute |
602
+ | pass `keys` to the bake because 9 is "the default" | omit it — the engine plans ONE key for a static sun and 9 for a cycle | a world with `cycleSeconds: 0` has one sun position; nine keys is ~9× the artifact for eight hours nobody plays. `keys: 1` on a CYCLING sun is refused outright, and `validate` re-checks the written `bakedLighting.keys` against the clock |
603
+ | reach for `probes.budget: "large"` to sharpen a blocky interior | fit `probes.size` to the room first, then step the budget (`[8, 5, 8]` = 320 probes) and read what `validate` prints | probes cost twice — bake minutes and ~12 KB each in the artifact. `"large"` (720) wrote **9.2 MB** and blew the 50 MiB bundle cap on a 45 MiB world, where `[8, 5, 8]` wrote 4.0 MB and fit; past 256 atlas slices a phone loses DDGI visibility |
604
+ | author `exposure.key` and `exposure.compensation` together, or expect `sun.tint: "#ffffff"` to give a white sun | pick one exposure idiom — `key` is absolute and wins with a warning; add `sun.tintMode: "replace"` when the tint must be verbatim | `key` REPLACES the day/night schedule that `compensation` biases, so authoring both says two things at once. And `tint` multiplies onto the elevation warm ramp, which tops out at (1, 0.98, 0.94) — pure white is only reachable in `replace` |
605
+ | inline the visuals config as a TypeScript const | author `public/helix.visuals.json` and fetch it | the bake patches the FILE and `validate` audits the FILE; an inlined const keeps booting the old config while the patched file ships as dead weight — silently |
606
+ | hand-write `"mode": "baked"` or a `bakedLighting` pointer | run `bake_world_lighting` on the built world | the mode flip and the pointer are the bake's receipt that the artifact exists and passed its gates; hand-authoring either is a hard `validate` error / boot throw |
607
+ | bake a bounded interior on the default probe volume | author `probes: { center, size }` to fit the room, then bake | the default box is sized for open ground: the café (~16 × 12 m, 3.5 m ceiling) got ONE probe layer inside the room — at ankle height, with one probe inside the hearth baking 6× hot — and three layers above the roof, so the ceiling washed red across a 6 m trilinear cell |
608
+ | republish a baked world after moving geometry or lights | re-bake first — build → bake → validate → publish | the artifact is a snapshot with no staleness detector; a stale bake ships the old room's light with no error anywhere |
609
+ | `renderer.setPixelRatio(...)` / `renderer.setSize(...)` on the owned path | `visuals.setSize({ width, height })` from your resize handler | the tier owns the pixel ratio: supersampling raises it, a render-scaled tier keeps the canvas native and scales the *internal* targets. A manual `setSize` desyncs every resident pass |
610
+ | hardcode `quality: 'high'` (or any tier) in world code | `quality: 'auto'`, overridden by `?quality=` | `auto` is capability detection with a `minimal` floor; a hardcoded tier ships a slideshow to a weak GPU and a soft frame to a strong one |
611
+ | request `antialias: true` on the canvas, or add your own FXAA/SMAA/FSR/CAS pass | let the tier place exactly one AA pass — last, on LDR, after the grade | the composer draws into an offscreen target, so the canvas' multisampled framebuffer is never the draw target; two AA passes double-blur, and AA convolutions cannot blend HDR values > 1 |
612
+ | reach for render scale to fix a slow frame | **count draw calls first** — render scale is the *second* lever | measured: the whole ladder 1.0 → 0.5 is FLAT on a 2 905-draw scene up to a 3840×2160 output. Upscaling only pays once the frame is pixel-bound |
613
+ | chase shimmer with the quality tier | fix **textures first** — a real mip chain in every KTX2; the runtime's anisotropy pass sets 4–16 per tier | anisotropy 1 → 8 plus mips measured **+5.06 dB / +0.153 SSIM, ~3× the entire AA ladder**, and texture moiré is immune to every AA setting |
614
+ | start the runtime before your material pack / GLBs resolve, or ignore `assetsReady` | hand the runtime `assetsReady: <promise>` | a probe bake taken while textures stream reads black albedo — **silent, and 26 % under-lit** |
615
+ | teleport the player, join a room or jump the clock and keep ticking | call **`visuals.catchUp()`** (a hard `timeOfDay.set()` does it for you) | the environment bake, the shadow map and the probe field all lag deliberately; without the discontinuity call the world stays lit for where the player *was* |
616
+ | flip quality mid-play and assume it landed | read `setQuality()`'s `{ reload, pending }` and tell the player "applies on next load" | composer on/off re-stamps every material and a GI-rung change moves the program set — the measured ~14 s recompile class |
617
+ | write a per-tier look ("darker art for low"), or author `bloom`/`fog`/`ao` around what one tier shows | author ONE look; let the tier degrade it | tiers are one art direction at different costs — a tier may DROP a pass on weak hardware (`minimal` has no composer), but wherever a pass runs it runs with your authored values, so tune against `high` and let the ladder fall away |
618
+ | set `renderer.toneMappingExposure`, or add your own tone-map / colour-grade pass | author the `exposure` block (`compensation` to bias the target or `key` to set it absolutely, `min`/`max` for the band, `min == max` to pin, `postGain` for the tone-mapper slider) and grade through `grade.look` (+ the three CDL numbers) | auto-exposure owns the frame-by-frame multiply and a hand write fights it every frame; the authored block is how you bias or pin it. A CDL applied *after* the grade pivots in display-linear and is not the same operation |
619
+ | fight a night scene that auto-exposure lifted by darkening albedos or deleting lamps | `exposure.compensation: -1` (or lower), then `exposure.max` if the ceiling still lets it climb | adaptation aims a lit night interior at mid grey by design; the compensation biases the target in EV stops on every tier, and the calibrated night look survives quality changes |
620
+ | keep a big lamp rig resident at intensity 0 for night | layer-mask it out by day; keep a handful resident | 48 resident lights = **+2.64 ms/frame**; 4 = 0.032 ms |
621
+ | guess a config key or a tier name from this page's prose | `get_package_manifest("visual")` | `capabilities.json` is generated from the real tier table, look presets and parser defaults — it cannot drift; this page can |
622
+
623
+ ## Converting a hand-lit world (the scaffold included)
624
+
625
+ `helix init` still writes the classic starter: its `src/main.ts` constructs a `WebGLRenderer` with
626
+ `setPixelRatio` / `SRGBColorSpace` / `ACESFilmicToneMapping` / `PCFSoftShadowMap`, plus a `HemisphereLight` and a
627
+ `DirectionalLight` sun, and no `helix.visuals.json`. Converting it — or any existing hand-lit world — is one
628
+ mechanical swap:
629
+
630
+ 1. Add `"visual": "^0.1"` to the `systems` block in `helix.json`; run `install_world_packages` with `update: true`.
631
+ 2. Author `public/helix.visuals.json` (`version: 1`, `mode: "dynamic"`, your `timeOfDay` / `grade`).
632
+ 3. Delete the renderer-construction block **and both hand-placed lights**; construct the runtime from a canvas
633
+ (the boot snippet above), hand `visuals.renderer` to whatever used the old renderer (the character facade takes
634
+ it), and replace `renderer.render(...)` in the loop with `visuals.tick(dt)`.
635
+ 4. Wire `visuals.setSize` into the resize handler and `visuals.catchUp()` after teleports/joins.
636
+ 5. Build, then — bounded static world — `bake_world_lighting`, then `validate_world`, then republish. **A bounded
637
+ interior authors the `probes` block first**, so the bake volume fits the room instead of the open-ground default.
638
+
639
+ Expect the look to shift: the calibrated sky/sun/grade replaces whatever the two hand lights were doing. Re-aim with
640
+ `noonAzimuthDeg` + `hours` first, then `grade` — never by re-adding a sun.
641
+
642
+ ## Verify your lighting — observable checks, all cheap
643
+
644
+ - **The boot line.** The world prints one `[visuals] <tier> · sun <elevation>° up at azimuth <azimuth>°` line (plus
645
+ the first-probe-field timing on probe tiers). The tier must be the one asked for; `?quality=minimal` and
646
+ `?quality=ultra` must both boot and report themselves.
647
+ - **No `VisualInvariantError` in the console.** Every rule that has cost a session throws by name. One of these
648
+ firing is a wiring bug in the world, not a runtime bug.
649
+ - **No hand-rolled rendering left** (runtime-lit worlds): `src/main.ts` contains no `new THREE.WebGLRenderer`, no
650
+ `toneMapping` / `outputColorSpace` / `setPixelRatio` write, no `DirectionalLight` or `HemisphereLight` standing in
651
+ for the sun, and no `renderer.render(` in the frame loop.
652
+ - **`validate_world` clean**, with the visuals audit's one-line summary printed and zero warnings you cannot
653
+ explain. On a baked world it verifies the artifact exists, parses, and matches its probe volume — and the audit
654
+ prints the authored transport values, so if you edited one after the last bake, the artifact is stale: re-bake.
655
+ - **A baked world boots its field fast** — the boot line's probe timing is tens of milliseconds, not seconds; if the
656
+ world visibly converges its lighting after boot, the bake is not being consumed (check the file, not the const).
657
+
658
+ ## Not available yet (do not chase these)
659
+
660
+ 1. **`practicals` is carried, not read** — see the night note above.
661
+ 2. **The manifest has no `visuals` block.** `helix.json` rejects unknown keys; `public/helix.visuals.json` is the v1
662
+ home. Do not put visual settings in `helix.json`.
663
+ 3. **The scaffold starter is still hand-lit** — converting it is the five steps above, not a scaffold flag.
664
+ 4. **No deployed graphics settings UI.** `?quality=` is the whole transport today; a world reads the param itself.
665
+ 5. **The probe budget is capped at 1536** (`probes.budget`, each axis 2..16) and every probe costs bake time and
666
+ ~12 KB of artifact — a very large map still spreads probes too thin to buy its way out, which is why it ships
667
+ `"dynamic"`.