lecodes-sdk 2.4.8 → 2.4.9

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.
@@ -1,5 +1,5 @@
1
1
  // Baked lighting for static 3D geometry (engines/bake). A scene FILE does all of this by
2
- // itself: `env: { lightmap: { data, light, aux } }` marks the level as baked, every model/mesh node
2
+ // itself: `env: { lightmap: bake('./assets/lightmap/level.bake') }` marks the level as baked, every model/mesh node
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
@@ -10,20 +10,22 @@
10
10
  // const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
11
11
  // Lightmap.add(holder) // a Model, a Mesh, or a node holding them
12
12
  // ...
13
- // await Lightmap.load(scene, { data: asset('./assets/lightmap/level.bake'),
14
- // light: asset('./assets/lightmap/level-light.ktx2'),
15
- // aux: asset('./assets/lightmap/level-aux.ktx2'),
16
- // probes: asset('./assets/lightmap/level-probes.ktx2') })
13
+ // await Lightmap.load(scene, bake('./assets/lightmap/level.bake', { emissiveBake: false }))
17
14
  //
18
- // `probes` (optional) is the bake's REFLECTION PROBES — the level's radiance traced by the same bake from a grid of
19
- // points, prefiltered per roughness, one octahedral tile column per probe in one atlas; every pixel of a static
20
- // blends the probes around it (the .bake carries the grid). Without the file a static's specular reflects the
21
- // sky's SH alone.
15
+ // `bake()` is a COMPILE-TIME macro (compile/bakeMacro.ts), the one reference to a bake: the `.bake` is a JSON
16
+ // MANIFEST that names every file the bake wrote next to it (the atlas pages, the aux pages, the reflection probes, the
17
+ // light grid, the packed uv1, a `--split` bake's page sets). The compiler reads it, stages each file the way asset()
18
+ // does and writes the list in place (`LightmapDef`), the options (its second argument) beside it. Before the first bake
19
+ // there is no manifest: the def is `{ data: null, path }` — the level runs unbaked and says so, and under `lecodes
20
+ // lightmap bake` that path is where the bake writes. (The data file is JSON with a .bake extension: a .json would be
21
+ // scanned as a code module.)
22
22
  //
23
- // The files are asset() handles because the compiler stages exactly the files it sees (the data file
24
- // is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a no-op when
25
- // the bake is missing or the host predates the feature, so the project stays runnable — and in BAKE
26
- // mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the scene is complete".
23
+ // `probes` (when the bake placed some) is the bake's REFLECTION PROBES — the level's radiance traced by the same bake from
24
+ // a grid of points, prefiltered per roughness, one octahedral tile column per probe in one atlas; every pixel of a static
25
+ // blends the probes around it (the .bake carries the grid). Without them a static's specular reflects the sky's SH alone.
26
+ //
27
+ // `load` is a no-op when the bake is missing or the host predates the feature, so the project stays runnable — and in
28
+ // BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the scene is complete".
27
29
  //
28
30
  // What a static gets: its whole DIFFUSE light from the atlas — the sun, the sky and the lamps that the
29
31
  // bake traced onto it, in lux — and nothing from the real-time sun or lamps (the engine keeps those
@@ -35,7 +37,7 @@
35
37
  // albedo, normals, visibilities, the atlas checker, the light direction, false-colour lux) and
36
38
  // `Lightmap.probe(x, y)` names the texel under a screen point. View 1 ("irradiance") is the lighting alone:
37
39
  // a white diffuse material under the camera's exposure. Views "direct" / "indirect" show the bake's two
38
- // halves: a bake made with `--split` writes them as page sets of their own, the scene lists them as
40
+ // halves: a bake made with `--split` writes them as page sets of their own, its manifest names them as
39
41
  // `direct` / `indirect` next to `light`, and the view binds those pages in place of `light` (the shading
40
42
  // is untouched — it is the same material reading other pages). The bake's `--debug-dir` keeps the
41
43
  // float layers `lecodes lightmap inspect` reads.
@@ -50,16 +52,22 @@ import { Node, nodes } from "./Node"
50
52
  import { Light } from "./Light"
51
53
  import type { Scene } from "./Scene"
52
54
 
53
- export type LightmapLoadOptions = {
55
+ /** The level's bake / load options: `bake()`'s second argument (an object literal, copied into the def as written). */
56
+ export type LightmapOptions = {
54
57
  /** A DEBUG multiplier on the atlas' lux (default 1): the atlas is physical, so this is a knob for looking,
55
58
  * not a look. */
56
59
  lightScale?: number
57
- /** The level's own radiance of emission 1.0 for the BAKE (cd / m², `BakeConfig.emissiveNits`): an imported pack's
58
- * emissive values are tuned for a look, not for light - this is where the level says how much its panels really give.
59
- * The CLI's `--emissive-nits` wins over it; without either the camera's exposure decides. */
60
+ /** FOR THE BAKE: the radiance in cd / m² of emission 1.0 on this level (emissive factor x strength x map) - every lit
61
+ * region of an emissive surface bakes as a rectangle lamp. An imported pack's emission is a LOOK, tuned for the eye;
62
+ * this is where the level says what its panels really give: a 1.3 m ceiling panel of ~8000 lm is ~1600 nits. Default:
63
+ * what the camera makes of 1.0 (`1.2 x 2^EV100`). 0 = emissive surfaces light nothing. The CLI's `--emissive-nits`
64
+ * wins over it. A `Material.lit` glow with `emissiveNits` of its own emits those instead. */
60
65
  emissiveNits?: number
61
- /** `false` = the emissive surfaces light NOTHING in the bake (the level is lit by its lamps, the panels are decoration)
62
- * while `emissiveNits` still says how bright their glow is drawn. Default true. */
66
+ /** `false` = the emissive surfaces light NOTHING in the bake - the level is lit by its lamps (a `kind: 'point'` light
67
+ * with a `bakeArea` next to every panel, the Unity packs' way) and the panels are decoration; `emissiveNits` then only
68
+ * says how bright their glow is DRAWN (scene-referred: nits through the camera's exposure). Default true. The
69
+ * OVERRIDE over every surface's own switch (`Material.lit`'s `emissiveBake`, a GLB material's extras
70
+ * `{"lecodes":{"emissiveBake":false}}`), which vetoes one material while the rest still light the level. */
63
71
  emissiveBake?: boolean
64
72
  /** FOR THE BAKE: materials that let light THROUGH them, by their glTF name (`"name*"` = every name with that start):
65
73
  * the share of a shadow ray that passes, tinted by the material's base colour x map - an awning's warm, patterned light
@@ -75,7 +83,7 @@ export type LightmapLoadOptions = {
75
83
  cube?: string[]
76
84
  }
77
85
 
78
- /** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
86
+ /** one `transmit` entry (LightmapOptions.transmit): the straight share, or both shares */
79
87
  export type LightmapTransmit = number | { through?: number, diffuse?: number }
80
88
 
81
89
  export type LightmapInfo = {
@@ -89,27 +97,36 @@ export type LightmapInfo = {
89
97
  missing: string[]
90
98
  }
91
99
 
100
+ /** The bake's files as `bake()` reads them off the `.bake` manifest at compile time — each one staged like an asset()
101
+ * (every name in the manifest is relative to the `.bake`'s folder). Written by the macro, never by hand. */
92
102
  export type LightmapFiles = {
93
- data: string
94
- /** The irradiance atlas — one page, or every page in order for a bake that took several. */
95
- light: string | string[]
96
- /** The aux atlas (sun / sky visibility + light direction), pages like `light`. */
97
- aux: string | string[]
103
+ /** The `.bake` (the manifest) — null until `lecodes lightmap bake` has written it: the level then runs unbaked. */
104
+ data: string | null
105
+ /** Project-relative path of the `.bake`, always set: what the messages name, and where `lecodes lightmap bake` writes. */
106
+ path: string
107
+ /** The irradiance atlas (`<stem>-light[_n].ktx2`), every page in order. */
108
+ light: string[]
109
+ /** The aux atlas (`<stem>-aux[_n].ktx2`: sun / sky visibility + light direction), pages like `light`. */
110
+ aux: string[]
98
111
  /** DEBUG: the direct-light-only pages a `--split` bake wrote (`<stem>-direct[_n].ktx2`), for the "direct" view. */
99
- direct?: string | string[]
112
+ direct?: string[]
100
113
  /** DEBUG: the bounce-only pages of a `--split` bake (`<stem>-indirect[_n].ktx2`), for the "indirect" view. */
101
- indirect?: string | string[]
114
+ indirect?: string[]
102
115
  /** The reflection probes (`<stem>-probes.ktx2`), when the bake placed some (`lecodes lightmap bake --probe-spacing`). */
103
116
  probes?: string
104
- /** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
105
- * place a mover can be. Absent = movers keep the scene's IBL. */
117
+ /** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): every node a Physics aspect or a
118
+ * CharacterController moves (and what code marks with `Lightmap.track`) takes its ambient light from the bake at the
119
+ * place it is at, whatever its shader. Absent = movers keep the scene's IBL. */
106
120
  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. */
121
+ /** THE PACKED LIGHTMAP UV1 (`<stem>-uv1.bin`): the bake packs every mesh's lightmap charts into a square of their own
122
+ * and its rects map that uv1 — the engine swaps it into the models' TEXCOORD_1 before the rects go on. */
110
123
  uv1?: string
111
124
  }
112
125
 
126
+ /** What `bake('<path>.bake', options)` compiles to — the files of the bake and the level's options in one literal:
127
+ * `env.lightmap` in a scene file, `Lightmap.load`'s argument in a hand-built scene. */
128
+ export type LightmapDef = LightmapFiles & LightmapOptions
129
+
113
130
  /** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
114
131
  export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
115
132
  const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
@@ -151,7 +168,13 @@ export type LightmapAddOptions = {
151
168
  }
152
169
 
153
170
  type BakeConfig = {
154
- outDir: string, stem: string, size?: number, texel?: number, pages?: number,
171
+ /** The project root (forward slashes): the bake writes `<root>/<the def's path>` — where the level's `bake()` points. */
172
+ root: string,
173
+ /** `--out`: write the files into this folder instead (absolute) — a second seed for `lecodes lightmap diff`. */
174
+ outDir?: string,
175
+ /** `--name`: only the level whose `.bake` has this stem bakes (a bundle that reaches several bake() calls). */
176
+ stem?: string,
177
+ size?: number, texel?: number, pages?: number,
155
178
  sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
156
179
  bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
157
180
  /** 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"`. */
@@ -193,7 +216,7 @@ type BakeConfig = {
193
216
  * 0 = one cube an object. Default 1. */
194
217
  objectSlab?: number,
195
218
  /** 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. */
219
+ * (`LightmapOptions.cube`) into one — each wrapped in `(?: )`, `|` between. */
197
220
  cube?: string[],
198
221
  /** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
199
222
  volumeSpacing?: number,
@@ -251,11 +274,9 @@ type Bound = {
251
274
 
252
275
  const bakeConfig = (): BakeConfig | null => {
253
276
  const g = (globalThis as unknown as { __lecodesLightmap?: BakeConfig }).__lecodesLightmap
254
- return g && typeof g.outDir === "string" ? g : null
277
+ return g && typeof g.root === "string" ? g : null
255
278
  }
256
279
 
257
- const list = (v: string | string[] | undefined): string[] => v === undefined ? [] : Array.isArray(v) ? v : [ v ]
258
-
259
280
  const NONE = 0xFFFFFFFF
260
281
 
261
282
  export class Lightmap {
@@ -311,7 +332,7 @@ export class Lightmap {
311
332
  private static movers = new Set<Model | Mesh>()
312
333
  private static volumeOn = false
313
334
 
314
- /** A MOVER takes its ambient light from the level's baked LIGHT GRID (`env.lightmap.volume`) at the place it is at,
335
+ /** A MOVER takes its ambient light from the level's baked LIGHT GRID (the bake's `.lgrid`) at the place it is at,
315
336
  * every frame — whatever its shader: the standard glTF one or a custom lit material. Every Model / Mesh under `node`
316
337
  * is marked (a character, a weapon with its parts). A scene file does this itself for every node a Physics aspect or
317
338
  * a CharacterController moves; call it for what CODE spawns — an enemy, a pickup, a projectile. `on = false` hands
@@ -333,10 +354,7 @@ export class Lightmap {
333
354
 
334
355
  private static async loadVolume(data: BakeData, files: LightmapFiles): Promise<void> {
335
356
  Lightmap.volumeOn = false
336
- if (!files.volume || !data.volume || !_creator.lightVolumeLoad) {
337
- if (data.volume && !files.volume) console.warn(`Lightmap: the bake wrote a light grid (${data.volume.file}) but the scene does not list it — add volume: asset('…/${data.volume.file}') to env.lightmap, movers keep the IBL until then`)
338
- return
339
- }
357
+ if (!files.volume || !data.volume || !_creator.lightVolumeLoad) return
340
358
  try {
341
359
  const resp = await fetch(files.volume, { useOnce: true })
342
360
  if (resp.status >= 400) { console.warn(`Lightmap: no light grid at ${files.volume} (HTTP ${resp.status})`); return }
@@ -361,7 +379,8 @@ export class Lightmap {
361
379
  * False = the bake needs it and it cannot be had - its rects would read the wrong texels. */
362
380
  private static async loadUv1(data: BakeData, files: LightmapFiles): Promise<boolean> {
363
381
  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 }
382
+ // bake() lists every file the manifest names: only a def written by hand can miss it
383
+ if (!files.uv1) { console.warn(`Lightmap: ${files.path} maps a packed uv1 (${data.uv1}) the def does not carry — the bake is not applied`); return false }
365
384
  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
385
  try {
367
386
  const resp = await fetch(files.uv1, { useOnce: true })
@@ -411,43 +430,40 @@ export class Lightmap {
411
430
  else _creator.lightmapSunSet?.(0, 1, 0, 0, 0, 0, false)
412
431
  }
413
432
 
414
- /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
415
- * applied (no bake yet, a host without the feature, bake mode). */
433
+ /** Apply a bake — or, under `lecodes lightmap bake`, run it. `def` is what `bake('<path>.bake', options)` compiled
434
+ * to (a scene file's `env.lightmap`). Resolves to null when nothing was applied (no bake yet, a host without the
435
+ * feature, bake mode). */
416
436
  private static scene: Scene | null = null
417
- static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
437
+ static async load(scene: Scene, def: LightmapDef): Promise<LightmapInfo | null> {
418
438
  Lightmap.scene = scene
419
439
  const bake = bakeConfig()
420
- if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit, options.cube); return null }
440
+ if (bake) { Lightmap.bake(bake, def); return null }
421
441
  if (!_creator.lightmapApply) {
422
442
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
423
443
  return null
424
444
  }
445
+ if (def.data === null) { console.warn(`Lightmap: no bake at ${def.path} yet — run \`lecodes lightmap bake\``); return null }
425
446
  let data: BakeData
426
447
  try {
427
- const resp = await fetch(files.data, { useOnce: true })
428
- if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${files.data} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
448
+ const resp = await fetch(def.data, { useOnce: true })
449
+ if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${def.path} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
429
450
  data = resp.json()
430
451
  } catch (e) {
431
- console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
452
+ console.warn(`Lightmap: could not read ${def.path}: ${String(e)}`)
432
453
  return null
433
454
  }
434
455
  if (data.version !== 4 || !Array.isArray(data.instances)) {
435
- console.warn(`Lightmap: ${files.data} is not a creator-bake file (version ${String(data.version)}) — rebake with \`lecodes lightmap bake\``)
456
+ console.warn(`Lightmap: ${def.path} is not a creator-bake file (version ${String(data.version)}) — rebake with \`lecodes lightmap bake\``)
436
457
  return null
437
458
  }
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
440
- const pages = Math.max(1, data.pages)
441
- const lightFiles = list(files.light), auxFiles = list(files.aux)
442
- if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
443
- if (lightFiles.length < pages || auxFiles.length < pages) {
444
- console.warn(`Lightmap: the bake took ${pages} page(s) but the scene lists ${Math.min(lightFiles.length, auxFiles.length)} — add ${data.light.slice(lightFiles.length).join(", ")} / ${data.aux.slice(auxFiles.length).join(", ")} to env.lightmap`)
445
- }
446
- // both atlases are DATA and exempt from Texture.maxSize (a capped atlas blurs every shadow edge)
447
- const n = Math.min(pages, lightFiles.length, auxFiles.length)
448
- const light = await Promise.all(lightFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
449
- const aux = await Promise.all(auxFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
450
- const lightScale = data.lightScale * (options.lightScale ?? 1)
459
+ if (!(await Lightmap.loadUv1(data, def))) return null // the rects map the packed uv1: it goes in first
460
+ // the pages the manifest names, in order (bake() listed them off it); both atlases are DATA and exempt from
461
+ // Texture.maxSize (a capped atlas blurs every shadow edge)
462
+ const n = Math.min(data.pages, def.light.length, def.aux.length)
463
+ if (!(n > 0)) { console.warn(`Lightmap: ${def.path} names no atlas page — rebake with \`lecodes lightmap bake\``); return null }
464
+ const light = await Promise.all(def.light.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
465
+ const aux = await Promise.all(def.aux.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
466
+ const lightScale = data.lightScale * (def.lightScale ?? 1)
451
467
  _creator.lightmapSetOptions?.(lightScale, data.emissiveNits ?? 0)
452
468
  // a mover's sun shadow on the statics: only a bake that wrote the sun map under its aux pages
453
469
  const sun = data.sunMap ? data.sun : null
@@ -457,19 +473,18 @@ export class Lightmap {
457
473
  let probes = 0
458
474
  let probeAtlas: Texture | undefined
459
475
  const pb = data.probes
460
- if (pb && pb.count > 0) {
461
- if (!files.probes) console.warn(`Lightmap: the bake has ${pb.count} reflection probe(s) but the scene lists no \`probes\` file — add probes: asset('<dir>/${pb.file}') to env.lightmap`)
462
- else if (!_creator.lightmapProbesSet) console.warn("Lightmap: this host has no reflection probes — statics reflect the sky alone")
476
+ if (pb && pb.count > 0 && def.probes) {
477
+ if (!_creator.lightmapProbesSet) console.warn("Lightmap: this host has no reflection probes — statics reflect the sky alone")
463
478
  else if (!Array.isArray(pb.encode) || pb.encode.length < 6) console.warn("Lightmap: the bake's probes are in an older format — rebake (statics reflect the sky alone until then)")
464
479
  else {
465
- probeAtlas = await Texture.load(files.probes, { fullSize: true, srgb: false })
480
+ probeAtlas = await Texture.load(def.probes, { fullSize: true, srgb: false })
466
481
  // which probes a pixel reflects is in the aux pages (their probe maps); the host needs the atlas and the records' range
467
482
  probes = _creator.lightmapProbesSet(probeAtlas._h.id, pb.oct, pb.levels, pb.perRow, pb.count,
468
483
  pb.encode[0], pb.encode[1], pb.encode[2], pb.encode[3], pb.encode[4], pb.encode[5])
469
484
  if (probes !== pb.count) console.warn(`Lightmap: the host took ${probes} of ${pb.count} probe(s) — rebake`)
470
485
  }
471
486
  }
472
- console.log(`[lightmap] bound ${n} page(s) of ${data.size} at ${(data.texel * 100).toFixed(2)} cm (${data.mips ?? 1} level(s), sun map ${data.sunMapLevels ?? (data.sunMap ? 1 : 0)}), lightScale ${lightScale}${(options.lightScale ?? 1) !== 1 ? ` (bake ${data.lightScale} x ${options.lightScale})` : ""}${probes > 0 ? `, ${probes} reflection probe(s)` : ""}`)
487
+ console.log(`[lightmap] bound ${n} page(s) of ${data.size} at ${(data.texel * 100).toFixed(2)} cm (${data.mips ?? 1} level(s), sun map ${data.sunMapLevels ?? (data.sunMap ? 1 : 0)}), lightScale ${lightScale}${(def.lightScale ?? 1) !== 1 ? ` (bake ${data.lightScale} x ${def.lightScale})` : ""}${probes > 0 ? `, ${probes} reflection probe(s)` : ""}`)
473
488
  const rects = new Map<string, BakeInstance>()
474
489
  for (const inst of data.instances) if (inst.receiver && inst.st && inst.page !== undefined) rects.set(inst.key, inst)
475
490
  // the ones the bake left out for their size: occluders there, movers here — not a rect gone missing
@@ -483,7 +498,7 @@ export class Lightmap {
483
498
  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
499
  // a bake from before the slabs (`light.cube`, one cube): not read — its small statics stay unlit until it is rebaked
485
500
  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\``)
501
+ if (data.instances.some((i) => i.small && i.light && !lit(i.light))) console.warn(`Lightmap: ${def.path} has small statics without \`light.cubes\` (baked before the slabbed object light) — they stay unlit; rebake with \`lecodes lightmap bake\``)
487
502
  // THE BAKE'S UNIT IS THE MESH: a model's primitives are records of their own, `<key>@<ordinal>` (ordinal = the primitive's
488
503
  // index over the file's meshes) - a rect, or a small static's light, each; the host binds record k to that primitive
489
504
  const parts = Lightmap.recordsOf(data.instances)
@@ -531,8 +546,8 @@ export class Lightmap {
531
546
  }
532
547
  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
548
  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`)
534
- Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
535
- await Lightmap.loadVolume(data, files)
549
+ Lightmap.bound = { data, rects, byEntity, files: def, pages: n, aux, light, probes, probeAtlas, showing: "light" }
550
+ await Lightmap.loadVolume(data, def)
536
551
  return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
537
552
  }
538
553
 
@@ -551,7 +566,7 @@ export class Lightmap {
551
566
  }
552
567
 
553
568
  /** Switch every lightmapped surface to a DATA view (or back with `"off"` / 0). Level-wide, instant —
554
- * except "direct" / "indirect", which load that page set the first time (a `--split` bake listed in the scene). */
569
+ * except "direct" / "indirect", which load that page set the first time (a bake made with `--split`). */
555
570
  static debug(mode: LightmapDebugMode | number, params: LightmapDebugParams = {}): void {
556
571
  const m = typeof mode === "number" ? mode : Math.max(0, DEBUG_MODES.indexOf(mode))
557
572
  const [ lo, hi ] = params.luxRange ?? [ 0, 5 ]
@@ -566,9 +581,9 @@ export class Lightmap {
566
581
  if (!b || b.showing === kind || !_creator.lightmapApply) return
567
582
  let tex = b[kind]
568
583
  if (!tex) {
569
- const files = list(b.files[kind])
584
+ const files = b.files[kind] ?? []
570
585
  if (files.length < b.pages) {
571
- console.warn(`Lightmap: the scene lists ${files.length ? "only " + files.length : "no"} "${kind}" page(s) — bake with \`lecodes lightmap bake --split\` and add its ${kind} pages to env.lightmap`)
586
+ console.warn(`Lightmap: ${b.files.path} has no "${kind}" pages — rebake with \`lecodes lightmap bake --split\``)
572
587
  return
573
588
  }
574
589
  tex = await Promise.all(files.slice(0, b.pages).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
@@ -656,7 +671,12 @@ export class Lightmap {
656
671
  return typeof c === "string" ? Color.toHexString(c) : "#ffffffff"
657
672
  }
658
673
 
659
- /** The lightmap material for a static Mesh, from whatever lit material it carried (colour, map, roughness, metallic). */
674
+ /** The lightmap material for a static Mesh, from whatever lit material it carried (colour, map, roughness, metallic)
675
+ * — and its GLOW, so a glowing Mesh looks the same before and after the bake applies: `emissive` (linear) becomes the
676
+ * lightmap material's `emissiveFactor` at strength 1; its own `emissiveNits` go over with `emissiveOwn` 1, which the
677
+ * level-wide write (`lightmapSetOptions`) leaves alone. A glow without nits takes the level's, like a GLB panel:
678
+ * screen-referred under a level of 0 nits, else emission x the level's nits through the exposure — the same as
679
+ * before the bake when the level's nits are the camera's (the default). */
660
680
  private static meshMaterial(mesh: Mesh): Material {
661
681
  const u = mesh.material?.uniforms ?? {}
662
682
  const m = Material.lightmap()
@@ -664,15 +684,28 @@ export class Lightmap {
664
684
  if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
665
685
  if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
666
686
  if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
687
+ // linear [r, g, b] as Material.lit writes it, or a colour string a later `.set('emissive', '#…')` left (the engine
688
+ // converts that one like any colour uniform)
689
+ const e = u.emissive
690
+ if ((Array.isArray(e) && e.length === 3 && e[0] + e[1] + e[2] > 0) || typeof e === "string") {
691
+ m.set("emissiveFactor", Array.isArray(e) ? [ e[0], e[1], e[2] ] : e as string).set("emissiveStrength", 1)
692
+ if (typeof u.emissiveNits === "number" && u.emissiveNits > 0) m.set("emissiveNits", u.emissiveNits).set("emissiveOwn", 1)
693
+ }
667
694
  return m
668
695
  }
669
696
 
670
- private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>, levelCube?: string[]): void {
697
+ private static bake(cfg: BakeConfig, def: LightmapDef): void {
698
+ // the target is where the level's bake() points (`<root>/<path>`): the files land next to the `.bake` it names
699
+ const slash = def.path.lastIndexOf("/")
700
+ const stem = def.path.slice(slash + 1).replace(/\.bake$/i, "")
701
+ if (cfg.stem !== undefined && cfg.stem !== stem) { console.log(`[lightmap] ${def.path} is not the level being baked (--name ${cfg.stem}): skipped`); return }
702
+ const outDir = cfg.outDir ?? (slash > 0 ? `${cfg.root}/${def.path.slice(0, slash)}` : cfg.root)
703
+ const levelNits = def.emissiveNits, emissiveBake = def.emissiveBake ?? true, transmit = def.transmit, levelCube = def.cube
671
704
  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
705
  const idOf = (e: Entry): number => (e.model ?? e.mesh ?? e.ground!).id
673
706
  const ids = Uint32Array.from(Lightmap.entries.map(idOf))
674
707
  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
708
+ // the statics baked as cubes: by hand per node (`cube`), and by key - the CLI's --cube and the level's `cube` option, one regex
676
709
  const cubeIds = Uint32Array.from(Lightmap.entries.filter((e) => e.cube).map(idOf))
677
710
  const cubeKeys = [ ...(cfg.cube ?? []), ...(levelCube ?? []) ].filter((p) => p !== "").map((p) => `(?:${p})`).join("|")
678
711
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
@@ -682,8 +715,8 @@ export class Lightmap {
682
715
  for (const l of live) _creator.setLightIntensity?.(l.id, 0)
683
716
  const ev100 = Lightmap.scene?.camera.ev100 ?? 15
684
717
  const emissiveNits = cfg.emissiveNits ?? levelNits ?? 1.2 * 2 ** ev100
685
- if (emissiveNits > 0) console.log(`[lightmap] emission 1.0 = ${Math.round(emissiveNits)} nits${emissiveBake ? "" : ", DRAWN only (env.lightmap.emissiveBake: false - the emissive surfaces light nothing)"} (${cfg.emissiveNits !== undefined ? "--emissive-nits" : levelNits !== undefined ? "the level's env.lightmap.emissiveNits" : `the camera's EV100 ${ev100.toFixed(1)}`})`)
686
- _creator.lightmapBake(cfg.outDir, cfg.stem, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.05, cfg.pages ?? 4,
718
+ if (emissiveNits > 0) console.log(`[lightmap] emission 1.0 = ${Math.round(emissiveNits)} nits${emissiveBake ? "" : ", DRAWN only (the level's emissiveBake: false - the emissive surfaces light nothing)"} (${cfg.emissiveNits !== undefined ? "--emissive-nits" : levelNits !== undefined ? "the level's emissiveNits" : `the camera's EV100 ${ev100.toFixed(1)}`})`)
719
+ _creator.lightmapBake(outDir, stem, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.05, cfg.pages ?? 4,
687
720
  cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
688
721
  cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
689
722
  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 ?? "",
@@ -162,12 +162,36 @@ export const _sheetColsRows = (sheet: number | [number, number] | undefined): [n
162
162
  return [ Math.max(1, Math.floor(c) || 1), Math.max(1, Math.floor(r) || 1) ]
163
163
  }
164
164
 
165
- /** `Material.lit` options — PBR scalars on top of the color/map pair. */
165
+ /** `Material.lit` options — PBR scalars on top of the color/map pair, and the surface's own glow. */
166
166
  export type LitMaterialOptions = MaterialColorOptions & MaterialStateOptions & {
167
167
  /** Perceptual roughness, 0 (mirror) … 1 (matte). Unset = the shader's default. */
168
168
  roughness?: number
169
169
  /** Metallic factor, 0 (dielectric) … 1 (metal). Unset = the shader's default. */
170
170
  metallic?: number
171
+ /** The surface's GLOW, added on top of its lit colour: a ceiling panel, a screen, a sign. Any colour (sRGB, like
172
+ * `color`); an `[r, g, b]` array may go past 1 — an HDR colour, its hue in sRGB and the excess a linear multiplier
173
+ * (`[4, 3.6, 3]` = `[1, 0.9, 0.75]` four times as bright). Unset = no glow. */
174
+ emissive?: ColorInput
175
+ /** The glow's luminance in cd / m² (nits) for `emissive` 1.0 — `#ffffff` at 1600 is a typical ceiling panel. Set,
176
+ * the glow is drawn SCENE-referred: through the camera's exposure, as bright as the light it gives the room in a
177
+ * bake. Unset (or 0) = SCREEN-referred: the colour is drawn as it is whatever the exposure, and a bake counts it at
178
+ * the level's `emissiveNits` (what the camera makes of 1.0 unless the level says otherwise). */
179
+ emissiveNits?: number
180
+ /** `false` = decoration: the glow is drawn, but a lightmap bake does not light the level with it (Unity's "Global
181
+ * Illumination: None") — the level's lamps do. Default true: a glowing static lights the level like a lamp. The
182
+ * level-wide `bake('…', { emissiveBake: false })` overrides it for every surface. */
183
+ emissiveBake?: boolean
184
+ }
185
+
186
+ // sRGB → linear, one channel (the exact curve: IEC 61966-2-1)
187
+ const srgbToLinear = (v: number): number => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
188
+
189
+ /** @internal `emissive` as lit.mat takes it: LINEAR rgb. An HDR array keeps its excess over 1 as a linear multiplier
190
+ * on the sRGB hue (`peak` = its brightest channel). */
191
+ export const _emissiveLinear = (c: ColorInput): [number, number, number] => {
192
+ const [ r, g, b ] = Color.toRgb01(c)
193
+ const peak = Math.max(r, g, b, 1)
194
+ return [ srgbToLinear(Math.max(0, r) / peak) * peak, srgbToLinear(Math.max(0, g) / peak) * peak, srgbToLinear(Math.max(0, b) / peak) * peak ]
171
195
  }
172
196
 
173
197
 
@@ -179,6 +203,9 @@ export class Material {
179
203
  readonly uniforms: Record<string, UniformValue>
180
204
 
181
205
  private _colorKey: string | null = null
206
+ /** @internal lit: whether the glow lights the level in a bake (`LitMaterialOptions.emissiveBake`); lit.mat carries
207
+ * the same as its `emissiveBake` parameter, which is what the bake reads off a Mesh's instance */
208
+ _emissiveBake = true
182
209
 
183
210
  constructor(shader: FetchResponse | Handle | number) {
184
211
  if (typeof shader === "number") {
@@ -265,7 +292,8 @@ export class Material {
265
292
  return new Material(h)
266
293
  }
267
294
 
268
- /** PBR lit material. */
295
+ /** PBR lit material. `emissive` / `emissiveNits` / `emissiveBake` are its glow (`LitMaterialOptions`); the uniforms
296
+ * are written only for a material that asks for a glow, so a plain lit material never names them. */
269
297
  static lit(options: LitMaterialOptions = {}): Material {
270
298
  const m = Material._builtin(_creator.builtinMaterial("lit"), "lit")
271
299
  m._colorKey = "baseColor"
@@ -273,6 +301,12 @@ export class Material {
273
301
  if (options.map) m.map = options.map
274
302
  if (options.roughness !== undefined) m.uniforms.roughness = options.roughness
275
303
  if (options.metallic !== undefined) m.uniforms.metallic = options.metallic
304
+ if (options.emissiveBake === false) m._emissiveBake = false
305
+ if (options.emissive !== undefined) {
306
+ m.uniforms.emissive = _emissiveLinear(options.emissive)
307
+ if (options.emissiveNits !== undefined) m.uniforms.emissiveNits = Math.max(0, options.emissiveNits)
308
+ m.uniforms.emissiveBake = m._emissiveBake ? 1 : 0
309
+ }
276
310
  applyDepth(m, options)
277
311
  return m
278
312
  }
@@ -306,7 +306,7 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
306
306
  * without this the engine picks who covers whom per frame (smoke popping over a fireball).
307
307
  * Default 0; a fireball wants 2, its smoke 1, a smoke trail -1. */
308
308
  order?: number
309
- /** Sprites take the light of the PLACE each one is in — the level's baked light grid (`env.lightmap.volume`).
309
+ /** Sprites take the light of the PLACE each one is in — the level's baked light grid (the bake's `.lgrid`).
310
310
  * A sprite is unlit: its colour is the picture, which is right for fire and wrong for dust — a puff kicked up
311
311
  * under an awning glows as if it stood in the sun. With `lit` every particle's colour is multiplied by the light
312
312
  * where it is, relative to the level's OPEN ground: exactly the authored colour out in the sun, the ambient's
package/src/host.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // JS-runtime globals provided by the host engine (web viewer / desktop / iOS), NOT by the SDK.
2
2
  // Declared only so SDK and user code type-check — never bundled or injected.
3
3
 
4
- // The one SDK type the macros return: an SVG image source, the same `SvgSource` a project has as
5
- // a global (build-types.ts points this import into dist/types when it copies the file).
4
+ // The SDK types the macros return: an SVG image source, the same `SvgSource` a project has as a
5
+ // global, and the lightmap def `bake()` compiles to (build-types.ts points these imports into
6
+ // dist/types when it copies the file).
6
7
  import type { SvgSource } from "./runtime/misc"
8
+ import type { LightmapDef, LightmapOptions } from "./gl/Lightmap"
7
9
 
8
10
  declare global {
9
11
  /** As in the browser, but the delay is REQUIRED (the native runtime throws without it) and no
@@ -50,6 +52,15 @@ declare global {
50
52
  * comments and string literals are text, not macros. */
51
53
  function asset(path: string): string
52
54
 
55
+ /** THE LEVEL'S BAKE: `lecodes lightmap bake` writes `<stem>.bake` (a JSON manifest) and the files it names next to
56
+ * it. Compile-time macro: the manifest is read, every file it names is staged, and the call becomes the literal
57
+ * `Lightmap.load` takes — `env: { lightmap: bake('./assets/lightmap/level.bake') }` in a scene file. The path is a
58
+ * string literal, relative to the file that calls it (`./`, `../`), else to the project root. No file yet →
59
+ * `{ data: null, path }`: the level runs unbaked and says so; under `lecodes lightmap bake` this is the target the
60
+ * bake writes. A file the manifest names that is missing fails the compile. `options` (an object literal, copied
61
+ * as written) are the level's bake / load options. */
62
+ function bake(path: `${string}.bake`, options?: LightmapOptions): LightmapDef
63
+
53
64
  /** Compile-time macro: installs a font and returns its family name — using a font IS declaring
54
65
  * it. A registry id (`font("manrope")`) resolves against the font registry; a project path
55
66
  * (`font("./fonts/Brand.ttf")`) reads family/weight/italic from the file itself. `opts`
package/src/inject.ts CHANGED
@@ -233,6 +233,7 @@ export type { DynamicBoneCurve, DynamicBoneFloor, DynamicBoneColliders } from ".
233
233
  export type { IKTwoBone, IKLookAt } from "./gl/animation/IK"
234
234
  export { Light } from "./gl/Light"
235
235
  export { Lightmap } from "./gl/Lightmap"
236
+ export type { LightmapDef, LightmapFiles, LightmapOptions, LightmapTransmit } from "./gl/Lightmap"
236
237
  export { Foliage } from "./gl/Foliage"
237
238
  export type { FoliageOptions, FoliageWind } from "./gl/Foliage"
238
239
  export { Terrain } from "./gl/terrain/Terrain"