lecodes-sdk 2.4.6 → 2.4.8

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.
@@ -3,8 +3,9 @@
3
3
  // that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
4
4
  // and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
5
5
  // Hand-built scenes register what never moves with `Lightmap.add`, bake once with
6
- // `lecodes lightmap bake` (the desktop host describes the live scene to creator-bake, which traces it
7
- // on the NVIDIA card and writes the atlas), and `Lightmap.load` applies it at runtime:
6
+ // `lecodes lightmap bake` (the bake tool lecodes-bake — the windowless host with the bake built in —
7
+ // describes the live scene to creator-bake, which traces it on the GPU and writes the atlas), and
8
+ // `Lightmap.load` applies it at runtime:
8
9
  //
9
10
  // const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
10
11
  // Lightmap.add(holder) // a Model, a Mesh, or a node holding them
@@ -67,6 +68,11 @@ export type LightmapLoadOptions = {
67
68
  * `{ through, diffuse }` adds the share the fibres SCATTER - the cloth's underside glows like a matte panel and lights
68
69
  * what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
69
70
  transmit?: Record<string, LightmapTransmit>
71
+ /** FOR THE BAKE: CUBES BY NAME — regular expressions (ECMAScript, case-sensitive) searched in every record's key
72
+ * (`<node key>@<ordinal>`: `"SM_pipe"` takes every pipe node, `"@1$"` the second mesh of each): a record that matches
73
+ * leaves the atlas like a small or thin one — an occluder lit by ambient cubes baked where it stands — whatever its size
74
+ * or charts. Added to the CLI's `--cube` patterns (both apply). */
75
+ cube?: string[]
70
76
  }
71
77
 
72
78
  /** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
@@ -98,11 +104,17 @@ export type LightmapFiles = {
98
104
  /** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
99
105
  * place a mover can be. Absent = movers keep the scene's IBL. */
100
106
  volume?: string
107
+ /** THE PACKED LIGHTMAP UV1 (`<stem>-uv1.bin`, the .bake's `uv1`): the bake packs every mesh's lightmap charts into a
108
+ * square of their own and its rects map that uv1 — the engine swaps it into the models' TEXCOORD_1 first. Required
109
+ * when the bake names one. */
110
+ uv1?: string
101
111
  }
102
112
 
103
113
  /** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
104
114
  export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
105
115
  const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
116
+ const TRACERS = { auto: 0, optix: 1, vulkan: 2 } as const // CBAKE_TRACER_*
117
+ const DENOISERS = { auto: 0, optix: 1, oidn: 2 } as const // CBAKE_DENOISER_*
106
118
  const DEBUG_MODES: LightmapDebugMode[] = [ "off", "irradiance", "albedo", "normal", "shadingNormal", "share", "skyVis", "atlas", "direction", "lux", "relief", "direct", "indirect", "probe", "probeMap", "skyPath", "probePath" ]
107
119
  const PAGE_VIEWS: Record<number, "direct" | "indirect"> = { 11: "direct", 12: "indirect" }
108
120
 
@@ -125,15 +137,32 @@ export type LightmapProbe = {
125
137
  distance: number
126
138
  }
127
139
 
128
- /** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host */
129
- type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node }
140
+ /** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host;
141
+ * `cube` = every record of it bakes as ambient cubes (`LightmapAddOptions.cube`, a scene node's `lightmap: 'cube'`) */
142
+ type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node, cube?: boolean }
143
+
144
+ /** `Lightmap.add`'s options */
145
+ export type LightmapAddOptions = {
146
+ /** Bake it as AMBIENT CUBES instead of atlas texels: an occluder (its shadow and bounce stay in the atlas) whose own
147
+ * light is baked where it stands — the way the bake treats small and thin objects (`--min-texels`, `--thin-texels`),
148
+ * here by hand: a pipe run, a fence, a prop whose charts show their seams. A long one takes a slab of cubes per metre
149
+ * (`--object-slab`). Default false. */
150
+ cube?: boolean
151
+ }
130
152
 
131
153
  type BakeConfig = {
132
154
  outDir: string, stem: string, size?: number, texel?: number, pages?: number,
133
155
  sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
134
156
  bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
135
- /** OptiX's AI denoiser: `"no-sun"` (the default) the bounce, the sky and the lamps, `"indirect"` the bounce alone, `"all"` + the sun (its shadow edges too), `"none"`. */
157
+ /** The AI denoiser (see `denoiser`) over: `"no-sun"` (the default) the bounce, the sky and the lamps, `"indirect"` the bounce alone, `"all"` + the sun (its shadow edges too), `"none"`. */
136
158
  denoise?: "none" | "indirect" | "no-sun" | "all",
159
+ /** Which GPU API traces: `"auto"` (the default) = OptiX where an NVIDIA driver with OptiX opens, else Vulkan ray query — any card
160
+ * with hardware ray tracing (RTX, Radeon RX 6000+, Arc) with its driver alone; `"optix"` / `"vulkan"` by name (the bake fails
161
+ * when it cannot open). */
162
+ tracer?: "auto" | "optix" | "vulkan",
163
+ /** Which AI denoiser `denoise` runs: `"auto"` (the default) = OptiX's on an NVIDIA card, else Intel Open Image Denoise (its CUDA /
164
+ * HIP / CPU device); `"optix"` / `"oidn"` by name. Independent of the tracer. One that cannot open is a warning: the pages stay as traced. */
165
+ denoiser?: "auto" | "optix" | "oidn",
137
166
  /** The texel's reconstruction filter radius in texels: 1 (the default) = a ±1 texel tent, 0.5 = within the texel, 0 = the centre alone. */
138
167
  filter?: number,
139
168
  /** Reflection probes: the grid spacing in metres (4; 0 = none), a probe face's edge in texels (32) and the paths per probe texel (128). */
@@ -153,6 +182,19 @@ type BakeConfig = {
153
182
  * how many texels it gets (a fire extinguisher of 0.55 m: 300 charts, the largest 29 texels - every chart edge a step).
154
183
  * 0 = everything bakes. Default 12. */
155
184
  minTexels?: number,
185
+ /** ROUND AND NARROW objects leave the atlas the same way: an instance none of whose lightmap charts is wider than this
186
+ * many texels across its short side (at `texel`) — a railing's tubes, a cable, a pipe run, blinds. A strip of 1-3 texels
187
+ * around a tube cannot hold the light's change around it and its uv seam shows as a step; the ambient cube baked where
188
+ * it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off. Default 4 (20 cm at 5 cm texels). */
189
+ thinTexels?: number,
190
+ /** A LONG object the atlas left out takes its light in SLABS of this many metres along the longest axis of its bounds
191
+ * (round(length / objectSlab), 16 at most) — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in
192
+ * the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis.
193
+ * 0 = one cube an object. Default 1. */
194
+ objectSlab?: number,
195
+ /** CUBES BY NAME (the CLI's `--cube`): regular expressions over the records' keys, joined with the level's own
196
+ * (`LightmapLoadOptions.cube`) into one — each wrapped in `(?: )`, `|` between. */
197
+ cube?: string[],
156
198
  /** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
157
199
  volumeSpacing?: number,
158
200
  /** A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid
@@ -170,9 +212,12 @@ type BakeConfig = {
170
212
  debugDir?: string,
171
213
  }
172
214
 
173
- /** a small static's light where it stands (creator-bake objects.h): per side of its bounds (+x -x +y -y +z -z) the
174
- * irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none, -2 = the sky) */
175
- type BakeObjectLight = { at: number[], cube: number[][], probes: number[] }
215
+ /** a small static's light where it stands (creator-bake objects.h): its SLABS along `axis` (0 x, 1 y, 2 z; its bounds run
216
+ * from `from` to `to` along it) — one for a small one, round(length / objectSlab) for a long one — and per slab per side
217
+ * of its box (+x -x +y -y +z -z) the irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none,
218
+ * -2 = the sky) */
219
+ type BakeObjectLight = { at: number[], axis: number, from: number, to: number, cubes: number[][][], probes: number[] }
220
+ /** a RECORD of the .bake: a registered node's key, or `<key>@<ordinal>` for one mesh of a model (creator-bake atlas.h) */
176
221
  type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
177
222
  type BakeProbes = {
178
223
  file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
@@ -189,6 +234,8 @@ type BakeData = {
189
234
  probes?: BakeProbes | null,
190
235
  /** the light grid (creator-bake volume.h): its file and lattice */
191
236
  volume?: { file: string, n: number[], origin: number[], cell: number[], valid: number } | null,
237
+ /** the packed lightmap uv1 the rects map (creator-bake atlas.h), null = the models' own */
238
+ uv1?: string | null,
192
239
  sun?: { direction: number[], color: number[], lux: number } | null, sunMap?: boolean, sunMapLevels?: number, mips?: number, emissiveNits?: number,
193
240
  instances: BakeInstance[],
194
241
  }
@@ -225,16 +272,17 @@ export class Lightmap {
225
272
  * both receivers and occluders in the bake (a model without lightmap UVs, or with materials the lightmap shader
226
273
  * declines — alpha cards — is an occluder alone; a terrain is ONE rect, built with `Material.terrainLightmap()`).
227
274
  * `key` names the entry in the .bake (default: the node's name + a running number, `container#3`); pass one when
228
- * names are not stable. */
229
- static add(node: Node, key?: string): Node {
275
+ * names are not stable. `{ cube: true }` bakes it as ambient cubes instead of atlas texels (`LightmapAddOptions`). */
276
+ static add(node: Node, key?: string, options: LightmapAddOptions = {}): Node {
230
277
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
278
+ const cube = options.cube === true
231
279
  const found: Entry[] = []
232
280
  const walk = (n: Node): void => {
233
281
  // the node `Terrain.create` returned carries its terrain (Terrain.of reads the same field): the host keeps the
234
282
  // terrain's triangles under this node's id for the bake and binds the rect to its chunks
235
- if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n }); return }
236
- if (n instanceof Model) { found.push({ key: base, model: n }); return }
237
- if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
283
+ if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n, cube }); return }
284
+ if (n instanceof Model) { found.push({ key: base, model: n, cube }); return }
285
+ if (n instanceof Mesh) { found.push({ key: base, mesh: n, cube }); return }
238
286
  for (const c of n.children) walk(c)
239
287
  }
240
288
  walk(node)
@@ -244,13 +292,13 @@ export class Lightmap {
244
292
  return node
245
293
  }
246
294
 
247
- /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk). */
248
- static _register(node: Model | Mesh, key: string): void {
249
- Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
295
+ /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk); `cube` = `lightmap: 'cube'`. */
296
+ static _register(node: Model | Mesh, key: string, cube = false): void {
297
+ Lightmap.entries.push(node instanceof Model ? { key, model: node, cube } : { key, mesh: node, cube })
250
298
  }
251
299
  /** @internal Scene-file runtime: a terrain node - a receiver with one rect (the host keeps its triangles for the bake). */
252
- static _registerGround(node: Node, key: string): void {
253
- Lightmap.entries.push({ key, ground: node })
300
+ static _registerGround(node: Node, key: string, cube = false): void {
301
+ Lightmap.entries.push({ key, ground: node, cube })
254
302
  }
255
303
 
256
304
  private static next(name: string): number {
@@ -308,12 +356,31 @@ export class Lightmap {
308
356
  console.log(`[lightmap] light grid ${data.volume.n.join(" x ")} cells of ${data.volume.cell.map((v) => v.toFixed(2)).join(" x ")} m, ${Lightmap.movers.size} mover(s)`)
309
357
  }
310
358
 
359
+ /** THE PACKED UV1 the bake's rects map (the .bake's `uv1`), into the host BEFORE any rect is applied: the host swaps it
360
+ * into the models' TEXCOORD_1 as their rects go on. A bake without one clears the last (the models' own uv1 back).
361
+ * False = the bake needs it and it cannot be had - its rects would read the wrong texels. */
362
+ private static async loadUv1(data: BakeData, files: LightmapFiles): Promise<boolean> {
363
+ if (!data.uv1) { _creator.lightmapUv1Clear?.(); return true }
364
+ if (!files.uv1) { console.warn(`Lightmap: the bake packed the models' lightmap charts (${data.uv1}) but the scene does not list it — add uv1: asset('…/${data.uv1}') to env.lightmap; the bake is not applied until then`); return false }
365
+ if (!_creator.lightmapUv1Load) { console.warn("Lightmap: this host cannot take the bake's packed lightmap uv1 (rebuild it) — the bake is not applied"); return false }
366
+ try {
367
+ const resp = await fetch(files.uv1, { useOnce: true })
368
+ if (resp.status >= 400) { console.warn(`Lightmap: no packed uv1 at ${files.uv1} (HTTP ${resp.status}) — the bake is not applied`); return false }
369
+ if (!_creator.lightmapUv1Load((resp as unknown as { _h: Handle })._h.id)) { console.warn(`Lightmap: ${files.uv1} is not a packed uv1 — rebake`); return false }
370
+ } catch (e) {
371
+ console.warn(`Lightmap: could not read ${files.uv1}: ${String(e)}`)
372
+ return false
373
+ }
374
+ return true
375
+ }
376
+
311
377
  /** Forget every registration (a scene rebuild). */
312
378
  static clear(): void {
313
379
  for (const l of Light._points) l._holdDark(false)
314
380
  Lightmap.movers.clear()
315
381
  Lightmap.volumeOn = false
316
382
  _creator.lightVolumeClear?.()
383
+ _creator.lightmapUv1Clear?.()
317
384
  Lightmap.entries = []
318
385
  Lightmap.ordinals.clear()
319
386
  Lightmap.bound = null
@@ -350,7 +417,7 @@ export class Lightmap {
350
417
  static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
351
418
  Lightmap.scene = scene
352
419
  const bake = bakeConfig()
353
- if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
420
+ if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit, options.cube); return null }
354
421
  if (!_creator.lightmapApply) {
355
422
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
356
423
  return null
@@ -369,6 +436,7 @@ export class Lightmap {
369
436
  return null
370
437
  }
371
438
  if (!data.pages || !(data.lightScale > 0)) return null // the CLI's placeholder: nothing baked yet
439
+ if (!(await Lightmap.loadUv1(data, files))) return null // the rects map the packed uv1: it goes in first
372
440
  const pages = Math.max(1, data.pages)
373
441
  const lightFiles = list(files.light), auxFiles = list(files.aux)
374
442
  if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
@@ -410,9 +478,15 @@ export class Lightmap {
410
478
  const declined = new Set<string>()
411
479
  for (const inst of data.instances) if (!inst.receiver && !inst.small) declined.add(inst.key)
412
480
  const probeId = (k: number) => k === -2 ? 65534 : k < 0 ? 65535 : k
413
- // A GLB UNWRAPPED IN GROUPS (`assets doctor --lightmap-uv --lightmap-groups n`): the bake keys its parts `<key>#k`, a rect
414
- // (or a small static's cube) each, and the host binds part k to the nodes that carry extras.lightmapGroup = k
415
- const parts = Lightmap.groupsOf(data.instances)
481
+ // lightmapObjectApply's numbers: axis, from, to, n, then n slabs x six sides x (rgb, sky), then probe a, probe b, a's weight
482
+ const objectData = (l: BakeObjectLight) =>
483
+ Float32Array.from([ l.axis, l.from, l.to, l.cubes.length, ...l.cubes.flat(2), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
484
+ // a bake from before the slabs (`light.cube`, one cube): not read — its small statics stay unlit until it is rebaked
485
+ const lit = (l?: BakeObjectLight): l is BakeObjectLight => !!l && Array.isArray(l.cubes)
486
+ if (data.instances.some((i) => i.small && i.light && !lit(i.light))) console.warn(`Lightmap: ${files.data} has small statics without \`light.cubes\` (baked before the slabbed object light) — they stay unlit; rebake with \`lecodes lightmap bake\``)
487
+ // THE BAKE'S UNIT IS THE MESH: a model's primitives are records of their own, `<key>@<ordinal>` (ordinal = the primitive's
488
+ // index over the file's meshes) - a rect, or a small static's light, each; the host binds record k to that primitive
489
+ const parts = Lightmap.recordsOf(data.instances)
416
490
  let smallLit = 0
417
491
  const byEntity = new Map<number, Entry>()
418
492
  let applied = 0
@@ -420,19 +494,20 @@ export class Lightmap {
420
494
  for (const e of Lightmap.entries) {
421
495
  const node = e.model ?? e.mesh ?? e.ground!
422
496
  byEntity.set(node.id, e)
423
- const groups = parts.get(e.key)
424
- if (groups) {
425
- let took = 0
426
- for (const [ k, inst ] of groups) {
427
- if (inst.small && inst.light && _creator.lightmapObjectApply && n > 0) {
428
- const l = inst.light
429
- took += _creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ]), k)
430
- } else if (inst.receiver && inst.st && inst.page !== undefined && inst.page < n) {
431
- took += _creator.lightmapApply(node.id, light[inst.page]._h.id, aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
497
+ const records = parts.get(e.key)
498
+ if (records) {
499
+ let took = 0, receivers = 0
500
+ for (const [ k, inst ] of records) {
501
+ if (inst.small) {
502
+ if (lit(inst.light) && _creator.lightmapObjectApply && n > 0 && _creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(inst.light), k) > 0) { took++; smallLit++ }
503
+ } else if (inst.receiver) {
504
+ receivers++
505
+ if (!inst.st || inst.page === undefined || inst.page >= n) { missing.push(inst.key); continue }
506
+ if (_creator.lightmapApply(node.id, light[inst.page]._h.id, aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k) > 0) took++
432
507
  }
433
508
  }
434
- if (took === 0) console.warn(`Lightmap: ${e.key} (${groups.size} groups) was not loaded lightmapped, or the host binds no groups (rebuild it)`)
435
- else applied++
509
+ if (took > 0) applied++
510
+ else if (receivers > 0) console.warn(`Lightmap: ${e.key} (${records.size} mesh record(s)) took no rect — not loaded lightmapped (Model.load { lightmap: true }), or the host binds no records (rebuild it)`)
436
511
  continue
437
512
  }
438
513
  const r = rects.get(e.key)
@@ -440,10 +515,9 @@ export class Lightmap {
440
515
  if (tiny) {
441
516
  // no rect: the light baked where it stands (any page's samplers — the probes' records sit under every aux page)
442
517
  const l = tiny.light
443
- if (!l || !_creator.lightmapObjectApply || n === 0) continue
518
+ if (!lit(l) || !_creator.lightmapObjectApply || n === 0) continue
444
519
  if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
445
- const d = Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
446
- if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, d) > 0) smallLit++
520
+ if (_creator.lightmapObjectApply(node.id, light[0]._h.id, aux[0]._h.id, objectData(l)) > 0) smallLit++
447
521
  continue
448
522
  }
449
523
  if (!r && declined.has(e.key)) continue // glass / a declined material: the bake knows it as an occluder - the light grid lights it (loadVolume)
@@ -455,19 +529,19 @@ export class Lightmap {
455
529
  if (took === 0) { console.warn(`Lightmap: ${e.key} was not loaded lightmapped (Model.load { lightmap: true } / Material.lightmap())`); continue }
456
530
  applied++
457
531
  }
458
- if (small.size) console.log(`[lightmap] ${small.size} small static(s) are not in the atlas (the bake's minTexels): ${smallLit} lit by the light baked where they stand`)
459
- 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`)
532
+ if (small.size) console.log(`[lightmap] ${small.size} small, thin or cube-marked record(s) are not in the atlas (the bake's minTexels / thinTexels / cube): ${smallLit} lit by the light baked where they stand`)
533
+ if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) or mesh record(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
460
534
  Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
461
535
  await Lightmap.loadVolume(data, files)
462
536
  return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
463
537
  }
464
538
 
465
- /** The bake's `<key>#k` instances by their base key: the parts of a GLB unwrapped in groups (k = extras.lightmapGroup). */
466
- private static groupsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
539
+ /** The bake's `<key>@k` records by their node key: a model's meshes (k = the primitive's ordinal in its file). */
540
+ private static recordsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
467
541
  const out = new Map<string, Map<number, BakeInstance>>()
468
542
  const registered = new Set(Lightmap.entries.map((e) => e.key))
469
543
  for (const inst of instances) {
470
- const m = /^(.*)#(\d+)$/.exec(inst.key)
544
+ const m = /^(.*)@(\d+)$/.exec(inst.key)
471
545
  if (!m || !registered.has(m[1])) continue
472
546
  let g = out.get(m[1])
473
547
  if (!g) { g = new Map(); out.set(m[1], g) }
@@ -502,7 +576,7 @@ export class Lightmap {
502
576
  b[kind] = tex
503
577
  }
504
578
  b.showing = kind
505
- const parts = Lightmap.groupsOf(b.data.instances)
579
+ const parts = Lightmap.recordsOf(b.data.instances)
506
580
  for (const [ id, e ] of b.byEntity) {
507
581
  for (const [ k, inst ] of parts.get(e.key) ?? []) {
508
582
  if (inst.receiver && inst.st && inst.page !== undefined && inst.page < b.pages) _creator.lightmapApply(id, tex[inst.page]._h.id, b.aux[inst.page]._h.id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
@@ -553,7 +627,7 @@ export class Lightmap {
553
627
  static probe(screenX: number, screenY: number): LightmapProbe | null {
554
628
  const b = Lightmap.bound
555
629
  if (!b || !_creator.pickTriangle) return null
556
- type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapGroup?: number }
630
+ type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: [number, number], lightmapOrdinal?: number }
557
631
  // the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
558
632
  // every GLB instance including the off-scene templates and is what a scene file's clones confuse)
559
633
  let hit: Hit | null = null
@@ -564,7 +638,7 @@ export class Lightmap {
564
638
  if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
565
639
  const entry = b.byEntity.get(hit.entity)
566
640
  if (!entry) { console.log(`[probe] hit entity ${hit.entity} node "${hit.nodeName}" at ${hit.point.map((c) => c.toFixed(2)).join(",")} is not a registered static`); return null }
567
- const hitKey = hit.lightmapGroup !== undefined && b.rects.has(`${entry.key}#${hit.lightmapGroup}`) ? `${entry.key}#${hit.lightmapGroup}` : entry.key
641
+ const hitKey = hit.lightmapOrdinal !== undefined && b.rects.has(`${entry.key}@${hit.lightmapOrdinal}`) ? `${entry.key}@${hit.lightmapOrdinal}` : entry.key
568
642
  const inst = b.rects.get(hitKey)
569
643
  if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
570
644
  if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
@@ -593,10 +667,14 @@ export class Lightmap {
593
667
  return m
594
668
  }
595
669
 
596
- private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>): void {
597
- if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with the CUDA toolkit, or update lecodes desktop)"); return }
598
- const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.ground!).id))
670
+ private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>, levelCube?: string[]): void {
671
+ if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (the bake runs in lecodes-bake: LECODES_BAKE_EXE names a host without it)"); return }
672
+ const idOf = (e: Entry): number => (e.model ?? e.mesh ?? e.ground!).id
673
+ const ids = Uint32Array.from(Lightmap.entries.map(idOf))
599
674
  const keys = Lightmap.entries.map((e) => e.key).join("\n")
675
+ // the statics baked as cubes: by hand per node (`cube`), and by key - the CLI's --cube and the level's env.lightmap.cube, one regex
676
+ const cubeIds = Uint32Array.from(Lightmap.entries.filter((e) => e.cube).map(idOf))
677
+ const cubeKeys = [ ...(cfg.cube ?? []), ...(levelCube ?? []) ].filter((p) => p !== "").map((p) => `(?:${p})`).join("|")
600
678
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
601
679
  // a `baked: false` light is held dark for the bake — the engine bakes every lit point light it finds
602
680
  for (const l of Light._points) l._holdDark(false) // a bake started over a loaded level
@@ -609,7 +687,8 @@ export class Lightmap {
609
687
  cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
610
688
  cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
611
689
  cfg.probeSpacing ?? 4, cfg.probeSize ?? 32, cfg.probeRays ?? 128, cfg.probeIndoor ?? false, cfg.probeLayout === "grid" ? 1 : 0, cfg.probeBox ? Float32Array.from(cfg.probeBox) : null, (cfg.minTexels ?? 12) * (cfg.texel ?? 0.05), cfg.volumeSpacing ?? 1, emissiveBake ? emissiveNits : -emissiveNits, cfg.detailTexels ?? 2000, cfg.debugDir ?? "",
612
- Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"))
690
+ Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"),
691
+ TRACERS[cfg.tracer ?? "auto"] ?? 0, DENOISERS[cfg.denoiser ?? "auto"] ?? 0, cfg.thinTexels ?? 4, cfg.objectSlab ?? 1, cubeKeys, cubeIds)
613
692
  for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
614
693
  }
615
694
  }
package/src/gl/Model.ts CHANGED
@@ -20,8 +20,8 @@ export interface TrianglePick {
20
20
  mesh: string
21
21
  primitive: number
22
22
  material: string
23
- /** the node's lightmap group (scene-file extras), when it has one */
24
- lightmapGroup?: number
23
+ /** the primitive's index over its file's meshes: the lightmap bake's record `<key>@<ordinal>` */
24
+ lightmapOrdinal: number
25
25
  /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
26
26
  triangle: number
27
27
  backface: boolean
@@ -130,8 +130,8 @@ export class Model extends Node {
130
130
  culling?: boolean
131
131
  onProgress?: (p: { loaded: number, total?: number }) => void
132
132
  /** Baked lighting (engines/bake). `true` = a STATIC: loads through the lightmap material so
133
- * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
134
- * --lightmap-uv`) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
133
+ * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 —
134
+ * `lecodes assets unwrap`, which `lightmap bake` runs itself on raw GLBs) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
135
135
  * the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
136
136
  lightmap?: boolean
137
137
  /** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
package/src/gl/Node.ts CHANGED
@@ -94,6 +94,43 @@ const findNodeByWalk = (rootId: number, name: string): number => {
94
94
  // Scratch for the world-position read-back in _setOwnedPosition (one per module, never handed out).
95
95
  const worldScratch = new Float32Array(3)
96
96
 
97
+ /** @internal The scene a node is in: the one `scene.add` put it or its nearest such ancestor in (`_inScene`), -1 = none
98
+ * yet. A scene's nodes are pinned, so the walk meets the very wrappers `scene.add` stamped. */
99
+ export const sceneIdOf = (node: Node): number => {
100
+ if (node._inScene >= 0) return node._inScene
101
+ for (let id = _creator.getParent(node.id); id !== 0; id = _creator.getParent(id)) {
102
+ const n = nodes.get(id)
103
+ if (n && n._inScene >= 0) return n._inScene
104
+ }
105
+ return -1
106
+ }
107
+
108
+ /** @internal A node whose render view starts once the node is in a scene (a secondary Camera's `renderToTexture`, a
109
+ * ReflectionProbe): `_bindView(sceneId)` starts it if it has none. */
110
+ export type ViewBinder = Node & { _bindView(sceneId: number): void }
111
+ // The binders waiting for a scene, WEAKLY: a view never placed must not pin its node. Tried whenever the scene graph
112
+ // grows (bindPendingViews) — an empty set costs that call one size check.
113
+ const pendingViews = new Set<WeakRef<ViewBinder>>()
114
+ /** @internal Start `binder`'s view now if its node is in a scene, else at the scene.add / node.add that puts it in one.
115
+ * One try per placement: a view the host refuses (an overlay scene) warns and is not retried. */
116
+ export const awaitScene = (binder: ViewBinder): void => {
117
+ const sceneId = sceneIdOf(binder)
118
+ if (sceneId >= 0) binder._bindView(sceneId)
119
+ else pendingViews.add(new WeakRef(binder))
120
+ }
121
+ /** @internal The scene graph grew (scene.add, node.add, setParent): start the waiting views whose node is in a scene now. */
122
+ export const bindPendingViews = (): void => {
123
+ if (pendingViews.size === 0) return
124
+ for (const ref of pendingViews) {
125
+ const binder = ref.deref()
126
+ if (!binder) { pendingViews.delete(ref); continue }
127
+ const sceneId = sceneIdOf(binder)
128
+ if (sceneId < 0) continue
129
+ pendingViews.delete(ref)
130
+ binder._bindView(sceneId)
131
+ }
132
+ }
133
+
97
134
  export class Node extends AspectHost<NodeEvents> implements CompWriter {
98
135
  /** @internal The entity's owned handle (`_h.id` = the entity id; DEAD_HANDLE once destroyed). An
99
136
  * engine-created entity (a glTF part, an anchor) is wrapped from its number and pinned as the
@@ -101,6 +138,9 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
101
138
  _h: Handle
102
139
  /** Native entity id. */
103
140
  get id(): number { return this._h.id }
141
+ /** @internal The id of the scene `scene.add` put this node in (-1 = none; `scene.remove` clears it) — how a render
142
+ * view finds its scene (sceneIdOf). */
143
+ _inScene = -1
104
144
 
105
145
  private _matrix?: Float32Array
106
146
  private _worldMatrix?: Float32Array
@@ -132,6 +172,25 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
132
172
  set name(value: string) { _creator.setName(this.id, value) }
133
173
  get visible(): boolean { return _creator.isVisible(this.id) }
134
174
  set visible(value: boolean) { _creator.setVisible(this.id, value) }
175
+ /**
176
+ * The node's visibility LAYERS, a bit mask 1..255. The scene's own view draws every layer; a render
177
+ * view (a secondary `Camera`'s or a `ReflectionProbe`'s, their `layers` option) draws only the
178
+ * renderables whose layers share a bit with its mask — which is what layers are for: leaving a subtree
179
+ * out of a second camera (the car out of the reflection probe riding on it, the player's own body out
180
+ * of a mirror). A node without a layer
181
+ * of its own takes its nearest ancestor's, else 1, so a car's layer covers everything parented under
182
+ * it; `0` clears the node's own (inherit again). `visible = false` wins over any layer. Reads the
183
+ * effective bits. A host without layers draws everything everywhere (reads 1, a write warns once).
184
+ */
185
+ get layer(): number { return _creator.getLayer ? _creator.getLayer(this.id) : 1 }
186
+ set layer(mask: number) {
187
+ if (_creator.setLayer) _creator.setLayer(this.id, mask & 0xff)
188
+ else if (!Node._warnedLayer) {
189
+ Node._warnedLayer = true
190
+ console.warn("[node] this host has no setLayer — node layers are ignored, every view draws everything")
191
+ }
192
+ }
193
+ private static _warnedLayer = false
135
194
 
136
195
  // --- local transform ---
137
196
  // Native owns the authoritative matrix. `_sync()` reads it into a per-node scratch buffer on EVERY
@@ -317,6 +376,7 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
317
376
  /** Parent the given nodes under this one. */
318
377
  add(...children: Node[]): this {
319
378
  for (const c of children) { _creator.setParent(c.id, this.id, false); nodes.pin(c) }
379
+ bindPendingViews()
320
380
  return this
321
381
  }
322
382
  setParent(parent: Node | null, worldPositionStays = false): this {
@@ -326,6 +386,7 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
326
386
  } else {
327
387
  _creator.setParent(this.id, parent.id, worldPositionStays)
328
388
  nodes.pin(this)
389
+ bindPendingViews()
329
390
  }
330
391
  return this
331
392
  }
@@ -0,0 +1,141 @@
1
+ // A reflection probe: a cube of the scene's light drawn around a point, live (creator-gl renderview.cpp: a render view of
2
+ // six faces into an HDR cube render texture; with PREFILTER, Filament's libs/iblprefilter filter after each completed
3
+ // cube). A Node, placed and parented like any; its view starts once the node is in a scene (Node.ts awaitScene) — or at
4
+ // `scene.environment = probe` while it is in none. RenderTexture.ts holds the half it shares with a secondary Camera.
5
+
6
+ import { Node, awaitScene } from "./Node"
7
+ import { CAMERA_DEFAULTS } from "./Camera"
8
+ import {
9
+ RenderTexture, RT_FLAG_CUBEMAP, RT_FLAG_HDR, RT_FLAG_PREFILTER, RT_MAX_SIDE, makeRenderTexture, requireRenderTextures, startRenderView,
10
+ } from "./RenderTexture"
11
+
12
+ /** `new ReflectionProbe(options)`: the cube, what its view draws, and how often. */
13
+ export type ReflectionProbeOptions = {
14
+ /** The cube's edge, texels per face side (default 128): a power of two, 16..4096, when prefiltered; 1..4096 otherwise.
15
+ * A probe is light, not a picture — 64..128 is plenty for a car's reflections. */
16
+ size?: number
17
+ /** The layers its view draws, a bit mask over `Node.layer` (default 0xff = everything): leave out the probe's own
18
+ * carrier, or the cube is the inside of the car it rides on. Fixed. */
19
+ layers?: number
20
+ /** Shadows in its view (default `false`): with them, a full cube renders its own shadow maps six times. Fixed. */
21
+ shadows?: boolean
22
+ /** Faces drawn per render, 1..6, round-robin (default 6 = the whole cube) — `probe.facesPerFrame`. */
23
+ facesPerFrame?: number
24
+ /** Render once every N frames drawn (default 1 = every frame) — `probe.everyNth`. */
25
+ everyNth?: number
26
+ /** Filter the cube for ROUGHNESS (default `true`; `size` a power of two then). Without it the cube has one sharp level:
27
+ * under `scene.environment` only mirror-like surfaces (roughness near 0) are right, and the diffuse ambient stays the
28
+ * scene's own probe's. With it the GPU filters every completed cube into five roughness levels — a brushed or painted
29
+ * body shows its surroundings blurred as much as its roughness says — and the roughest level becomes the diffuse
30
+ * ambient: what the probe sees lights matte surfaces too (a red wall tints them red).
31
+ *
32
+ * COST: one filtering per completed cube (every render at `facesPerFrame` 6, every third at 2), on the frame after:
33
+ * the cube's mip chain plus a pass per level whose taps are the level's texels × `samples` — for a 64 cube at the
34
+ * default 256 samples about 2 million cube taps — and ten small driver submissions on the CPU. `everyNth` /
35
+ * `facesPerFrame` space the filterings out. Fixed. */
36
+ prefilter?: boolean
37
+ /** The filter's samples per texel, 1..2048 (default 256 — a quarter of Filament's offline default: each sample reads
38
+ * the mip that matches its footprint, which keeps a low count smooth; raise it if a mid-roughness surface reads
39
+ * blotchy). Smoothness for time; read with `prefilter` only. */
40
+ samples?: number
41
+ /** Near clip plane in metres (default 0.01). Fixed. */
42
+ near?: number
43
+ /** Far clip plane in metres (default 1000). Fixed. */
44
+ far?: number
45
+ }
46
+
47
+ /**
48
+ * A REFLECTION PROBE: a cube of the scene's light drawn around a point, live — chrome and glass show what is actually
49
+ * around them, moving objects included. A Node: place it, or parent it (`car.add(probe)` — it rides along); the six faces
50
+ * look along the world axes from its WORLD position (its rotation and scale are ignored). It draws once it is in a scene
51
+ * (added to one, or under a node that is), every frame that scene is on show — after the frame's update passes, before
52
+ * the scene's own picture — in linear HDR light (no tone mapping) at the scene camera's exposure. It sees the scene's
53
+ * renderables, lights, environment and sky, not its overlays.
54
+ *
55
+ * Two ways to use it:
56
+ * - `scene.environment = probe`: the scene's reflections come from the cube instead of the scene's own probe
57
+ * (`SceneOptions.ibl`) — and, prefiltered (the default), its diffuse ambient too. A probe in no scene yet starts in
58
+ * that one.
59
+ * - `probe.texture` in a material that samples a cube itself (a custom shader's samplerCubemap): the same cube as a
60
+ * `RenderTexture` — when prefiltered, the filtered one, five roughness levels.
61
+ *
62
+ * COST: a full render is six passes over the scene, plus (prefiltered) a GPU filtering per completed cube (`prefilter`
63
+ * says what one costs). Keep `size` small (128 is a lot), spread the cube over frames (`facesPerFrame: 2` = a whole cube
64
+ * every 3 frames, a third of the cost each), render less often (`everyNth`), leave out what it need not see (`layers` —
65
+ * its carrier first), keep `shadows` off, and turn it off once a static surrounding is drawn (`enabled = false` keeps the
66
+ * last cube).
67
+ *
68
+ * LIFETIME: the cube and its view live as long as the probe — keep a reference (`scene.environment` holds it too).
69
+ * Destroying the probe's node stops the view (the cube keeps its last light); moving it to another scene later does not
70
+ * move the view. Throws on a host without render textures (`createRenderTexture` — the web hosts, for now).
71
+ */
72
+ export class ReflectionProbe extends Node {
73
+ /** The cube the probe draws (`cubemap`, linear HDR light) — the filtered one, five roughness levels, when `prefilter`. */
74
+ readonly texture: RenderTexture
75
+ /** Filtered for roughness (the `prefilter` option). */
76
+ readonly prefilter: boolean
77
+ /** @internal The render view's owned handle (`_view.id` = the engine's view id), null until the view starts: it lives
78
+ * exactly as long as this probe holds it. */
79
+ _view: Handle | null = null
80
+ private readonly _layers: number
81
+ private readonly _shadows: boolean
82
+ private readonly _near: number
83
+ private readonly _far: number
84
+ private _enabled = true
85
+ private _everyNth: number
86
+ private _facesPerFrame: number
87
+
88
+ constructor(options: ReflectionProbeOptions = {}) {
89
+ requireRenderTextures("new ReflectionProbe")
90
+ const size = Math.max(1, Math.floor(options.size ?? 128))
91
+ const prefilter = options.prefilter !== false
92
+ if (prefilter && (size < 16 || size > RT_MAX_SIDE || (size & (size - 1)) !== 0)) {
93
+ throw new Error(`new ReflectionProbe: a prefiltered probe's size is a power of two, 16..${RT_MAX_SIDE} (got ${size}) — or prefilter: false`)
94
+ }
95
+ super()
96
+ const samples = prefilter ? Math.max(0, Math.floor(options.samples ?? 0)) : 0
97
+ this.texture = makeRenderTexture("new ReflectionProbe", size, size, RT_FLAG_CUBEMAP | RT_FLAG_HDR | (prefilter ? RT_FLAG_PREFILTER : 0), samples)
98
+ this.prefilter = prefilter
99
+ this._layers = (options.layers ?? 0xff) & 0xff
100
+ this._shadows = options.shadows === true
101
+ this._near = options.near ?? CAMERA_DEFAULTS.near
102
+ this._far = options.far ?? CAMERA_DEFAULTS.far
103
+ this._everyNth = Math.max(1, Math.floor(options.everyNth ?? 1))
104
+ this._facesPerFrame = Math.min(6, Math.max(1, Math.floor(options.facesPerFrame ?? 6)))
105
+ awaitScene(this)
106
+ }
107
+
108
+ /** @internal Node.ts awaitScene / Scene.environment: the probe is in scene `sceneId` — start its view into its cube. */
109
+ _bindView(sceneId: number): void {
110
+ if (this._view) return
111
+ this._view = startRenderView("ReflectionProbe", sceneId, this.texture, this.id, this._layers, 90, this._near, this._far, this._shadows)
112
+ if (this._view && (!this._enabled || this._everyNth !== 1 || this._facesPerFrame !== 6)) this._sendSchedule()
113
+ }
114
+
115
+ /** Whether the probe draws (default `true`). Off keeps the last cube and costs nothing — a probe that has drawn its
116
+ * static surroundings once; back on, it renders on the next frame drawn. */
117
+ get enabled(): boolean { return this._enabled }
118
+ set enabled(on: boolean) {
119
+ this._enabled = on
120
+ this._sendSchedule()
121
+ }
122
+ /** Render once every N frames DRAWN (default 1 = every frame). The count restarts with each change: the next frame
123
+ * drawn renders, then one in every N. */
124
+ get everyNth(): number { return this._everyNth }
125
+ set everyNth(n: number) {
126
+ this._everyNth = Math.max(1, Math.floor(n))
127
+ this._sendSchedule()
128
+ }
129
+ /** The cube's faces drawn per render, 1..6 (default 6), round-robin from where the last render stopped: 2 spreads a
130
+ * whole cube over 3 renders at a third of the cost each. A prefiltered cube is filtered each time it completes. */
131
+ get facesPerFrame(): number { return this._facesPerFrame }
132
+ set facesPerFrame(n: number) {
133
+ this._facesPerFrame = Math.min(6, Math.max(1, Math.floor(n)))
134
+ this._sendSchedule()
135
+ }
136
+
137
+ /** @internal Send the whole schedule (`setRenderViewOptions` restarts the count, so the next frame drawn renders). */
138
+ private _sendSchedule(): void {
139
+ if (this._view) _creator.setRenderViewOptions?.(this._view.id, this._enabled, this._everyNth, this._facesPerFrame)
140
+ }
141
+ }