lecodes-cli 0.18.1 → 0.19.0

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.
@@ -0,0 +1,308 @@
1
+ // A hierarchical state machine over a plain context object — the one place a game keeps "what am I
2
+ // doing right now": an enemy brain, a weapon (ready → fire → cooldown → reload), a game mode
3
+ // (warmup → round → over), a door. Declarative: the whole machine is ONE table you can read top
4
+ // to bottom, log, and later show in an inspector; the machine knows nothing about aspects, nodes
5
+ // or the network, so it runs wherever its owner ticks it (an `updateFixed` for anything that must
6
+ // match another machine's, `update` for presentation).
7
+ //
8
+ // const Guard = defineStates<Bot>({
9
+ // initial: 'patrol',
10
+ // on: { hit: 'flinch' }, // from ANY state
11
+ // to: { dead: (b) => b.hp <= 0 }, // checked in every state, first
12
+ // states: {
13
+ // patrol: { update: (b, dt) => b.walk(dt), to: { combat: (b) => b.target !== null } },
14
+ // combat: {
15
+ // initial: 'engage',
16
+ // to: { search: (b) => b.lostFor > 4 }, // leaves whichever child is active
17
+ // states: {
18
+ // engage: { update: (b) => b.approach(), to: { shoot: (b) => b.visible && b.dist < 12 } },
19
+ // shoot: { enter: (b) => b.fire = true, exit: (b) => b.fire = false, to: { engage: (b) => !b.visible } },
20
+ // },
21
+ // },
22
+ // search: { after: [6, 'patrol'] }, // a timed transition
23
+ // flinch: { after: [0.2, '$back'] }, // '$back' = the state we came from
24
+ // dead: { enter: (b) => b.die() },
25
+ // },
26
+ // })
27
+ // const fsm = new StateMachine(Guard, bot)
28
+ // fsm.step(dt) // once per tick: guards (outer states first), then updates
29
+ // fsm.send('hit') // an event: the innermost state that handles it wins
30
+ // fsm.state // 'combat.shoot' fsm.is('combat') → true
31
+ // fsm.on('change', (to, from) => console.log(`[fsm] ${from} → ${to}`))
32
+ //
33
+ // Semantics, in order, per `step(dt)`:
34
+ // 1. every active state's clock advances (`t` = seconds in that state);
35
+ // 2. transitions are tested OUTERMOST first — the root's `to` (any-state guards), then each
36
+ // active state's `to` guards (first true wins) and its `after` timer; a taken transition is
37
+ // re-tested from the new state (a pass-through state chains), at most 8 hops per step;
38
+ // 3. `update` runs outermost → innermost; an update that RETURNS a state name transitions
39
+ // there and ends the pass (the states it entered update next step).
40
+ // A transition exits states innermost-first up to the common ancestor and enters the new branch
41
+ // outermost-first, descending into `initial` children; the target state's own name (or an
42
+ // ancestor's) re-enters it. Names resolve relative to the declaring state (siblings, then each
43
+ // ancestor's siblings), or as a dotted path from the root; unknown names throw when the machine is
44
+ // built, so a typo is a startup error, not a silent no-op. `send` looks innermost → outermost
45
+ // (the root's `on` = any state) and returns whether someone handled it.
46
+
47
+ export type StateGuard<C> = (ctx: C, t: number) => boolean
48
+ export type StateHandler<C> = (ctx: C, ...args: any[]) => string | void
49
+
50
+ export interface StateDef<C> {
51
+ /** The child entered by default when this (compound) state is entered. */
52
+ initial?: string
53
+ /** Child states — this becomes a compound state; only a leaf is ever "the" state. */
54
+ states?: Record<string, StateDef<C>>
55
+ /** Entered (from = the previous leaf's path, '' at start). */
56
+ enter?: (ctx: C, from: string) => void
57
+ /** Left (to = the next leaf's path). */
58
+ exit?: (ctx: C, to: string) => void
59
+ /** Every step while active, outer states first; `t` = seconds in this state. Return a state name
60
+ * to transition. */
61
+ update?: (ctx: C, dt: number, t: number) => string | void
62
+ /** Guarded transitions, tested every step before `update` — key = target, first true wins. */
63
+ to?: Record<string, StateGuard<C>>
64
+ /** Leave for `target` after `seconds` in this state. */
65
+ after?: [seconds: number, target: string]
66
+ /** Event handlers for `send(name, …args)`: a target name, or a function returning one (or nothing). */
67
+ on?: Record<string, string | StateHandler<C>>
68
+ }
69
+
70
+ export interface StatesDef<C> {
71
+ initial: string
72
+ states: Record<string, StateDef<C>>
73
+ /** Any-state guards — tested first, in every state. */
74
+ to?: Record<string, StateGuard<C>>
75
+ /** Any-state event handlers — the fallback when no active state handles the event. */
76
+ on?: Record<string, string | StateHandler<C>>
77
+ /** Any-state update, runs before the active states' own. */
78
+ update?: (ctx: C, dt: number, t: number) => string | void
79
+ }
80
+
81
+ export type StateMachineEvents = {
82
+ /** After a transition completed (both are leaf paths; `from` = '' for the initial entry). */
83
+ change: (to: string, from: string) => void
84
+ }
85
+
86
+ /** Identity helper — names the context type once so every callback is typed. */
87
+ export const defineStates = <C>(def: StatesDef<C>): StatesDef<C> => def
88
+
89
+ const BACK = "$back"
90
+ const MAX_HOPS = 8
91
+
92
+ interface SNode<C> {
93
+ name: string
94
+ path: string
95
+ def: StateDef<C>
96
+ parent: SNode<C> | null
97
+ children: Map<string, SNode<C>>
98
+ initial: SNode<C> | null
99
+ depth: number
100
+ /** seconds in this state (valid while active) */
101
+ t: number
102
+ /** resolved `to` targets in declaration order */
103
+ guards: Array<[SNode<C>, StateGuard<C>]>
104
+ after: [number, SNode<C> | typeof BACK] | null
105
+ /** resolution cache for names returned at runtime */
106
+ resolved: Map<string, SNode<C> | typeof BACK>
107
+ }
108
+
109
+ export class StateMachine<C> {
110
+ readonly ctx: C
111
+ private readonly root: SNode<C>
112
+ private readonly byPath = new Map<string, SNode<C>>()
113
+ /** the active branch, root's child (index 0) → leaf */
114
+ private chain: SNode<C>[] = []
115
+ private prev: SNode<C> | null = null
116
+ private listeners: Array<(to: string, from: string) => void> = []
117
+ private changed = 0
118
+
119
+ constructor(def: StatesDef<C>, ctx: C) {
120
+ this.ctx = ctx
121
+ this.root = this.build("", "", def as StateDef<C>, null, 0)
122
+ // second pass: resolve names (every state exists now)
123
+ for (const n of this.byPath.values()) this.link(n)
124
+ this.link(this.root)
125
+ if (!this.root.initial) throw new Error("StateMachine: `initial` is required")
126
+ this.enterBranch([], this.leafOf(this.root.initial), "")
127
+ }
128
+
129
+ /** The active leaf's path ('combat.shoot'). */
130
+ get state(): string { return this.chain[this.chain.length - 1]?.path ?? "" }
131
+ /** The leaf we came from ('' before the first transition). */
132
+ get previous(): string { return this.prev?.path ?? "" }
133
+ /** Seconds in the active leaf. */
134
+ get time(): number { return this.chain[this.chain.length - 1]?.t ?? 0 }
135
+ /** Every active state's path, outermost first. */
136
+ get branch(): string[] { return this.chain.map((n) => n.path) }
137
+
138
+ /** Is `name` (a state name or dotted path) active — as the leaf OR as one of its ancestors? */
139
+ is(name: string): boolean {
140
+ for (const n of this.chain) if (n.name === name || n.path === name) return true
141
+ return false
142
+ }
143
+ /** Seconds in the named active state (0 when it is not active). */
144
+ timeIn(name: string): number {
145
+ for (const n of this.chain) if (n.name === name || n.path === name) return n.t
146
+ return 0
147
+ }
148
+
149
+ /** Force a transition (a spawn, a reset from outside the table). */
150
+ go(name: string): void {
151
+ const leaf = this.chain[this.chain.length - 1] ?? this.root
152
+ this.transition(this.resolve(leaf, name))
153
+ }
154
+
155
+ /** Deliver an event. Innermost active state first, the root's `on` last. True if handled. */
156
+ send(event: string, ...args: unknown[]): boolean {
157
+ for (let i = this.chain.length - 1; i >= 0; i--) {
158
+ const n = this.chain[i]!
159
+ const h = n.def.on?.[event]
160
+ if (h === undefined) continue
161
+ const target = typeof h === "string" ? h : h(this.ctx, ...args)
162
+ if (typeof target === "string") this.transition(this.resolve(n, target))
163
+ return true
164
+ }
165
+ const h = this.root.def.on?.[event]
166
+ if (h === undefined) return false
167
+ const target = typeof h === "string" ? h : h(this.ctx, ...args)
168
+ if (typeof target === "string") this.transition(this.resolve(this.chain[this.chain.length - 1] ?? this.root, target))
169
+ return true
170
+ }
171
+
172
+ /** One tick: clocks, guards (outer first, chained), then updates (outer first). */
173
+ step(dt: number): void {
174
+ for (const n of this.chain) n.t += dt
175
+ this.root.t += dt
176
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
177
+ const target = this.findTransition()
178
+ if (target === null) break
179
+ this.transition(target)
180
+ }
181
+ const mark = this.changed
182
+ const r0 = this.root.def.update?.(this.ctx, dt, this.root.t)
183
+ if (typeof r0 === "string") { this.transition(this.resolve(this.chain[this.chain.length - 1]!, r0)); return }
184
+ if (this.changed !== mark) return
185
+ const chain = this.chain.slice()
186
+ for (const n of chain) {
187
+ const r = n.def.update?.(this.ctx, dt, n.t)
188
+ if (typeof r === "string") { this.transition(this.resolve(n, r)); return }
189
+ if (this.changed !== mark) return // an update transitioned through send()/go()
190
+ }
191
+ }
192
+
193
+ /** Listen for transitions. */
194
+ on(event: "change", fn: StateMachineEvents["change"]): this { this.listeners.push(fn); return this }
195
+ off(event: "change", fn: StateMachineEvents["change"]): this {
196
+ const i = this.listeners.indexOf(fn)
197
+ if (i >= 0) this.listeners.splice(i, 1)
198
+ return this
199
+ }
200
+
201
+ // ---- building ----
202
+ private build(name: string, path: string, def: StateDef<C>, parent: SNode<C> | null, depth: number): SNode<C> {
203
+ const node: SNode<C> = { name, path, def, parent, children: new Map(), initial: null, depth, t: 0, guards: [], after: null, resolved: new Map() }
204
+ if (path) this.byPath.set(path, node)
205
+ if (def.states) {
206
+ for (const [childName, childDef] of Object.entries(def.states)) {
207
+ if (childName.includes(".") || childName.startsWith("$")) throw new Error(`StateMachine: bad state name '${childName}'`)
208
+ node.children.set(childName, this.build(childName, path ? `${path}.${childName}` : childName, childDef, node, depth + 1))
209
+ }
210
+ const initial = def.initial ?? Object.keys(def.states)[0]!
211
+ const child = node.children.get(initial)
212
+ if (!child) throw new Error(`StateMachine: '${path || "root"}' has no child '${initial}' for initial`)
213
+ node.initial = child
214
+ } else if (def.initial) {
215
+ throw new Error(`StateMachine: '${path}' names an initial child but has no states`)
216
+ }
217
+ return node
218
+ }
219
+
220
+ private link(n: SNode<C>): void {
221
+ if (n.def.to) for (const [target, guard] of Object.entries(n.def.to)) n.guards.push([this.resolveStrict(n, target), guard])
222
+ if (n.def.after) n.after = [n.def.after[0], this.resolve(n, n.def.after[1])]
223
+ if (n.def.on) for (const h of Object.values(n.def.on)) if (typeof h === "string") this.resolve(n, h)
224
+ }
225
+
226
+ private resolveStrict(from: SNode<C>, name: string): SNode<C> {
227
+ const r = this.resolve(from, name)
228
+ if (r === BACK) throw new Error(`StateMachine: '${BACK}' is not allowed as a guard target of '${from.path}'`)
229
+ return r
230
+ }
231
+
232
+ /** Relative first (siblings of `from`, then of each ancestor), then a dotted path from the root. */
233
+ private resolve(from: SNode<C>, name: string): SNode<C> | typeof BACK {
234
+ const cached = from.resolved.get(name)
235
+ if (cached) return cached
236
+ let found: SNode<C> | typeof BACK | undefined
237
+ if (name === BACK) found = BACK
238
+ else {
239
+ for (let n: SNode<C> | null = from; n && !found; n = n.parent) {
240
+ const scope = n.parent ?? this.root
241
+ found = scope.children.get(name)
242
+ if (!found && n === from) found = n.children.get(name) // a compound state naming its own child
243
+ }
244
+ if (!found) found = this.byPath.get(name)
245
+ }
246
+ if (!found) throw new Error(`StateMachine: unknown state '${name}' (from '${from.path || "root"}')`)
247
+ from.resolved.set(name, found)
248
+ return found
249
+ }
250
+
251
+ // ---- running ----
252
+ /** Guards never re-enter a state that is already active (a `dead` guard staying true does not
253
+ * re-enter `dead` every step) — only an explicit `go()` / event does that. */
254
+ private findTransition(): SNode<C> | typeof BACK | null {
255
+ const ctx = this.ctx
256
+ for (const [target, guard] of this.root.guards) if (!this.active(target) && guard(ctx, this.root.t)) return target
257
+ for (const n of this.chain) {
258
+ for (const [target, guard] of n.guards) if (!this.active(target) && guard(ctx, n.t)) return target
259
+ if (n.after && n.t >= n.after[0] && (n.after[1] === BACK || !this.active(n.after[1]))) return n.after[1]
260
+ }
261
+ return null
262
+ }
263
+
264
+ private active(n: SNode<C>): boolean { return this.chain[n.depth - 1] === n }
265
+
266
+ private leafOf(n: SNode<C>): SNode<C> {
267
+ while (n.initial) n = n.initial
268
+ return n
269
+ }
270
+
271
+ private transition(target: SNode<C> | typeof BACK): void {
272
+ const current = this.chain[this.chain.length - 1] ?? null
273
+ let node: SNode<C>
274
+ if (target === BACK) {
275
+ if (!this.prev) return
276
+ node = this.prev
277
+ } else node = target
278
+ const leaf = this.leafOf(node)
279
+ // the new branch (root excluded)
280
+ const next: SNode<C>[] = []
281
+ for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
282
+ // common prefix; a target that is the current leaf or one of its ancestors re-enters itself
283
+ let common = 0
284
+ while (common < this.chain.length && common < next.length && this.chain[common] === next[common]) common++
285
+ if (node !== this.root && common >= node.depth && this.chain[node.depth - 1] === node) common = node.depth - 1
286
+ const from = current?.path ?? ""
287
+ const to = leaf.path
288
+ for (let i = this.chain.length - 1; i >= common; i--) this.chain[i]!.def.exit?.(this.ctx, to)
289
+ this.prev = current
290
+ this.enterBranch(this.chain.slice(0, common), leaf, from, next)
291
+ }
292
+
293
+ private enterBranch(kept: SNode<C>[], leaf: SNode<C>, from: string, next?: SNode<C>[]): void {
294
+ if (!next) {
295
+ next = []
296
+ for (let n: SNode<C> | null = leaf; n && n !== this.root; n = n.parent) next.unshift(n)
297
+ }
298
+ this.chain = kept.slice()
299
+ this.changed++
300
+ for (let i = kept.length; i < next.length; i++) {
301
+ const n = next[i]!
302
+ n.t = 0
303
+ this.chain.push(n)
304
+ n.def.enter?.(this.ctx, from)
305
+ }
306
+ for (const fn of this.listeners.slice()) fn(leaf.path, from)
307
+ }
308
+ }
@@ -15,6 +15,9 @@ const sinks: ScaleSink[] = []
15
15
  class TimeClock {
16
16
  private _scale = 1
17
17
  private _paused = false
18
+ /** The fixed step: what `updateFixed(dt)` receives, every time, on every machine (1/60). Under
19
+ * `scale` the NUMBER of fixed steps per frame changes, never this value. */
20
+ readonly fixedDt = 1 / 60
18
21
  /** Seconds of GAME time since the app started (scaled; stops while paused). */
19
22
  now = 0
20
23
  /** Seconds of wall-clock time since the app started (never stops). */
@@ -36,6 +36,12 @@ export class Camera extends Node {
36
36
  * defaults (the headless core takes a `fov` option) and a blind push would overwrite them. */
37
37
  private _projSet = false
38
38
 
39
+ // Exposure the app asked for (filament's physical defaults until it does): f/16, 1/125 s, ISO 100 =
40
+ // EV100 15, bright sunlight — what every light intensity in the docs is tuned against.
41
+ private _exposure = { aperture: 16, shutterSpeed: 1 / 125, iso: 100 }
42
+ private _ev = 0
43
+ private _exposureSet = false
44
+
39
45
  /** @internal — Scene wires the camera to its native scene id + projection callback. */
40
46
  _attach(sceneId: number): void {
41
47
  this._sceneId = sceneId
@@ -43,6 +49,39 @@ export class Camera extends Node {
43
49
  for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
44
50
  })
45
51
  if (this._projSet) this._applyProjection()
52
+ if (this._exposureSet) this._applyExposure()
53
+ }
54
+
55
+ /** Exposure compensation in stops (default 0): +1 doubles how bright the scene renders, −2
56
+ * quarters it. The one exposure knob most games need — a dark interior, a flash of white, or eye
57
+ * adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
58
+ * while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
59
+ * and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
60
+ get exposureCompensation(): number { return this._ev }
61
+ set exposureCompensation(stops: number) {
62
+ this._ev = Number.isFinite(stops) ? stops : 0
63
+ this._exposureSet = true
64
+ this._applyExposure()
65
+ }
66
+
67
+ /** The physical camera: `aperture` in f-stops, `shutterSpeed` in seconds, `iso`, plus the same
68
+ * `compensation` in stops — any subset, one call. `camera.setExposure({ iso: 400 })` is two stops
69
+ * brighter than the default f/16 · 1/125 · ISO 100. */
70
+ setExposure(exposure: { aperture?: number, shutterSpeed?: number, iso?: number, compensation?: number }): this {
71
+ if (exposure.aperture !== undefined) this._exposure.aperture = exposure.aperture
72
+ if (exposure.shutterSpeed !== undefined) this._exposure.shutterSpeed = exposure.shutterSpeed
73
+ if (exposure.iso !== undefined) this._exposure.iso = exposure.iso
74
+ if (exposure.compensation !== undefined) this._ev = exposure.compensation
75
+ this._exposureSet = true
76
+ this._applyExposure()
77
+ return this
78
+ }
79
+
80
+ /** Hosts that predate the call keep the fixed default (older wasm / native builds). */
81
+ private _applyExposure(): void {
82
+ if (this._sceneId < 0) return
83
+ const e = this._exposure
84
+ _creator.setCameraExposure?.(this._sceneId, e.aperture, e.shutterSpeed, e.iso * 2 ** this._ev)
46
85
  }
47
86
 
48
87
  /** Vertical field of view in degrees (default 60) — a smaller angle is a longer lens. */
@@ -14,12 +14,19 @@ export type SunOptions = {
14
14
  /**
15
15
  * Shadow quality (default 1):
16
16
  * 0 - no shadows
17
- * 1 - standard (1024 shadow map, hard PCF edges)
18
- * 2 - high (2048 + stable, soft contact-hardening edges)
19
- * 3 - best (4096 + stable, PCSS soft shadows)
20
- * Higher values cost more GPU/memory.
17
+ * 1 - 1024 map, hard edges (one filtered tap; the cheap level, ~3 % of a frame on an iGPU)
18
+ * 2 - 1024 map, soft edges (variance shadows + blur; ~+15 % of a frame on an iGPU)
19
+ * 3 - 2048 map, contact-hardening soft edges (PCSS: sharp where the caster touches, softer
20
+ * away; discrete-GPU territory, ~+60 % of a frame on an iGPU)
21
+ * A lightmapped level already carries every static shadow, so 0 is a legitimate choice there.
21
22
  */
22
23
  shadowsQuality?: 0 | 1 | 2 | 3
24
+ /**
25
+ * Metres from the camera within which the sun casts shadows (default 100). Shadows fade out by
26
+ * this distance and the shadow map covers only this range, so a shorter distance is crisper for
27
+ * the same quality and puts fewer casters in the shadow pass. 30-40 is plenty in first person.
28
+ */
29
+ shadowDistance?: number
23
30
  }
24
31
 
25
32
  export type PointOptions = {
@@ -59,6 +66,7 @@ export class Light extends Node {
59
66
  light._intensity,
60
67
  Color.toPackedRgb(options.color ?? 0xffffff),
61
68
  options.shadowsQuality ?? 1,
69
+ options.shadowDistance ?? 100,
62
70
  )
63
71
  return light
64
72
  }
@@ -19,6 +19,12 @@
19
19
  // and in BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the
20
20
  // scene is complete". Atlas channels: R = sun visibility, G = ambient occlusion; the shader keeps
21
21
  // the real-time sun for dynamic casters and multiplies the baked mask in with an ambient floor.
22
+ //
23
+ // DYNAMIC objects (docs/lightmap-plan.md §9): the bake also writes lightmap.volume — the same two
24
+ // channels on a 3D grid over the level. Pass it as `volume` and every model loaded with
25
+ // `{ lightmap: 'dynamic' }` (the default while a scene file with `env.lightmap.volume` runs) plus
26
+ // every dynamic Mesh a scene file registers samples it per pixel: a crate rolled under the roof
27
+ // loses the sun like the floor does, a character near a wall picks up its ambient occlusion.
22
28
 
23
29
  import { fetch } from "../runtime/fetch"
24
30
  import { Texture } from "./Texture"
@@ -40,19 +46,27 @@ export type LightmapLoadOptions = {
40
46
  export type LightmapInfo = {
41
47
  size: number
42
48
  texel: number
49
+ /** The light volume, when one was loaded: grid dims + cell size in metres. */
50
+ volume?: { dims: number[], cell: number }
43
51
  /** keys applied / keys in the file / registered statics without a rect */
44
52
  applied: number
45
53
  total: number
46
54
  missing: string[]
47
55
  }
48
56
 
49
- type Entry = { key: string, model?: Model, mesh?: Mesh }
57
+ import type { Terrain } from "./Terrain"
58
+
59
+ type Entry = { key: string, model?: Model, mesh?: Mesh, terrain?: Terrain }
60
+
61
+ const terrainOf = (n: Node): Terrain | null => (n as unknown as { _terrain?: Terrain })._terrain ?? null
50
62
 
51
63
  type BakeConfig = {
52
64
  outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
53
- aoDistance?: number, bias?: number, sunAngle?: number,
65
+ aoDistance?: number, bias?: number, sunAngle?: number, volumeCell?: number,
54
66
  }
55
67
 
68
+ type VolumeHeader = { texture: number, dims: number[], min: number[], cell: number }
69
+
56
70
  const bakeConfig = (): BakeConfig | null => {
57
71
  const g = (globalThis as unknown as { __lecodesLightmap?: BakeConfig }).__lecodesLightmap
58
72
  return g && typeof g.outDir === "string" ? g : null
@@ -71,9 +85,11 @@ export class Lightmap {
71
85
  static get baking(): boolean { return bakeConfig() !== null }
72
86
 
73
87
  private static entries: Entry[] = []
88
+ private static dynamics: Mesh[] = []
74
89
  private static ordinals = new Map<string, number>()
75
90
  private static warned = false
76
91
 
92
+
77
93
  /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
78
94
  * both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
79
95
  * the node's name + a running number, `container#3`); pass one when names are not stable. */
@@ -81,6 +97,8 @@ export class Lightmap {
81
97
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
82
98
  const found: Entry[] = []
83
99
  const walk = (n: Node): void => {
100
+ const t = terrainOf(n)
101
+ if (t) { found.push({ key: base, terrain: t }); return } // one receiver, its chunks are internal
84
102
  if (n instanceof Model) { found.push({ key: base, model: n }); return }
85
103
  if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
86
104
  for (const c of n.children) walk(c)
@@ -97,6 +115,17 @@ export class Lightmap {
97
115
  Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
98
116
  }
99
117
 
118
+ /** @internal Scene-file runtime: a terrain node (its own material carries the atlas, no swap). */
119
+ static _registerTerrain(terrain: Terrain, key: string): void {
120
+ Lightmap.entries.push({ key, terrain })
121
+ }
122
+
123
+ /** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
124
+ * when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
125
+ static _registerDynamic(mesh: Mesh): void {
126
+ Lightmap.dynamics.push(mesh)
127
+ }
128
+
100
129
  private static next(name: string): number {
101
130
  const n = Lightmap.ordinals.get(name) ?? 0
102
131
  Lightmap.ordinals.set(name, n + 1)
@@ -106,12 +135,15 @@ export class Lightmap {
106
135
  /** Forget every registration (a scene rebuild). */
107
136
  static clear(): void {
108
137
  Lightmap.entries = []
138
+ Lightmap.dynamics = []
109
139
  Lightmap.ordinals.clear()
140
+ Model._lightmapDefault = false
141
+ _creator.lightmapVolumeSet?.(0xFFFFFFFF, 0, 0, 0, 0, 0, 0, 0, 1, 1)
110
142
  }
111
143
 
112
144
  /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
113
145
  * applied (no bake yet, a host without the feature, bake mode). */
114
- static async load(scene: Scene, files: { data: string, texture: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
146
+ static async load(scene: Scene, files: { data: string, texture: string, volume?: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
115
147
  const bake = bakeConfig()
116
148
  if (bake) { Lightmap.bake(bake); return null }
117
149
  if (!_creator.lightmapApply) {
@@ -131,6 +163,8 @@ export class Lightmap {
131
163
  const texture = await Texture.load(files.texture)
132
164
  const ambientScale = options.ambientScale ?? 1
133
165
  const strength = options.sunStrength ?? 1
166
+ // the light volume first: it claims every lightmap-material instance, the atlas rects below win back the statics
167
+ const volume = files.volume ? await Lightmap.loadVolume(files.volume, strength, ambientScale) : null
134
168
  const rects = new Map<string, number[]>()
135
169
  for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, inst.st)
136
170
  let applied = 0
@@ -141,19 +175,57 @@ export class Lightmap {
141
175
  if (e.model) {
142
176
  const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], ambientScale, strength)
143
177
  if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
144
- _creator.setGlbShadows?.(e.model.id, false, true)
178
+ e.model.castShadows = false
145
179
  } else if (e.mesh) {
146
180
  Lightmap.swapMeshMaterial(e.mesh, texture, st, ambientScale, strength)
147
181
  e.mesh.castShadows = false
182
+ } else if (e.terrain) {
183
+ // terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
184
+ e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
185
+ .set("ambientScale", ambientScale).set("sunStrength", strength)
186
+ e.terrain.castShadows = false
148
187
  }
149
188
  applied++
150
189
  }
151
190
  if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
152
- return { size: data.size, texel: data.texel, applied, total: rects.size, missing }
191
+ const info: LightmapInfo = { size: data.size, texel: data.texel, applied, total: rects.size, missing }
192
+ if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
193
+ return info
194
+ }
195
+
196
+ /** lightmap.volume → the engine's 3D texture → THE volume for every dynamic lightmap-material instance,
197
+ * plus the registered dynamic Meshes. Null when the file is a placeholder or the host lacks the feature. */
198
+ private static async loadVolume(url: string, strength: number, ambientScale: number): Promise<VolumeHeader | null> {
199
+ if (!_creator.lightmapVolumeLoad || !_creator.lightmapVolumeSet) return null
200
+ let header: VolumeHeader
201
+ try {
202
+ const resp = await fetch(url, { useOnce: true })
203
+ if (resp.status >= 400) { console.warn(`Lightmap: no volume at ${url} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
204
+ const json = _creator.lightmapVolumeLoad((resp as unknown as { _id: number })._id)
205
+ resp.dispose()
206
+ if (!json) { console.warn(`Lightmap: ${url} is not a light volume — rebake`); return null }
207
+ header = JSON.parse(json) as VolumeHeader
208
+ } catch (e) {
209
+ console.warn(`Lightmap: could not read ${url}: ${String(e)}`)
210
+ return null
211
+ }
212
+ const [ nx, ny, nz ] = header.dims
213
+ if (nx * ny * nz <= 1) return null // the CLI's placeholder: nothing baked yet
214
+ const size = [ nx * header.cell, ny * header.cell, nz * header.cell ]
215
+ const texture = new Texture(nx, ny, header.texture)
216
+ _creator.lightmapVolumeSet(header.texture, header.min[0], header.min[1], header.min[2], size[0], size[1], size[2], header.cell, strength, ambientScale)
217
+ for (const mesh of Lightmap.dynamics) {
218
+ const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
219
+ m.set("probeVolume", texture)
220
+ m.set("probeMin", [ header.min[0], header.min[1], header.min[2], header.cell * 0.5 ])
221
+ m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], 1 ])
222
+ mesh.setMaterial(m)
223
+ }
224
+ return header
153
225
  }
154
226
 
155
227
  /** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
156
- private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
228
+ private static meshMaterial(mesh: Mesh, ambientScale: number, strength: number): Material {
157
229
  const src = mesh.material
158
230
  const u = src?.uniforms ?? {}
159
231
  const m = Material.lightmap()
@@ -161,19 +233,24 @@ export class Lightmap {
161
233
  if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
162
234
  if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
163
235
  if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
164
- m.set("lightmap", texture)
165
- m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
166
236
  m.set("ambientScale", ambientScale)
167
237
  m.set("sunStrength", strength)
238
+ return m
239
+ }
240
+
241
+ private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
242
+ const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
243
+ m.set("lightmap", texture)
244
+ m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
168
245
  mesh.setMaterial(m)
169
246
  }
170
247
 
171
248
  private static bake(cfg: BakeConfig): void {
172
249
  if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with CREATOR_GL_LIGHTMAP, or update lecodes desktop)"); return }
173
- const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh)!.id))
250
+ const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.terrain!.node).id))
174
251
  const keys = Lightmap.entries.map((e) => e.key).join("\n")
175
252
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
176
253
  _creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
177
- cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5)
254
+ cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5, cfg.volumeCell ?? 0.5)
178
255
  }
179
256
  }
@@ -205,6 +205,21 @@ export class Material {
205
205
  const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
206
206
  m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
207
207
  m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
208
+ m.set("probeMin", [ 0, 0, 0, 0 ]).set("probeInvSize", [ 0, 0, 0, 0 ])
209
+ return m
210
+ }
211
+
212
+ /** The terrain splat material (docs/terrain-plan.md §1.5): four albedo (+ normal-map) layers blended by
213
+ * a control map on the terrain's own grid, per-layer `tiling` (metres per repeat) / `roughness` /
214
+ * `normalScale` / `triplanar`, lightmap-aware (the same `lightmap` / `lightmapST` block as
215
+ * `Material.lightmap`). `Terrain` builds and owns one per terrain; the uniforms are primed here so an
216
+ * unset layer is white and an unbaked terrain is fully lit. */
217
+ static terrain(): Material {
218
+ // Inline literal: the compiler's preload header captures the shader name from it.
219
+ const m = new Material({ _id: _creatorUtils.fetchLocal("terrain.filamat") } as any)
220
+ m.set("tiling", [ 8, 8, 8, 8 ]).set("roughness", [ 1, 1, 1, 1 ]).set("normalScale", [ 0, 0, 0, 0 ]).set("triplanar", [ 0, 0, 0, 0 ])
221
+ m.set("gridSize", [ 2, 2 ]).set("cellSize", 1).set("tint", "#ffffff")
222
+ m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1)
208
223
  return m
209
224
  }
210
225