@hypersoniclabs/helix-mcp 0.2.4 → 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.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- 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"`.
|