lecodes-sdk 1.0.0 → 1.2.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.
Files changed (94) hide show
  1. package/dist/global.d.ts +18 -4
  2. package/dist/inject.js +260 -361
  3. package/dist/types/animate/tween/Animation.d.ts +69 -0
  4. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  5. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  6. package/dist/types/animate/tween/easing.d.ts +29 -0
  7. package/dist/types/animate/tween/spec.d.ts +178 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/Foliage.d.ts +47 -0
  12. package/dist/types/gl/Geometry.d.ts +24 -0
  13. package/dist/types/gl/Light.d.ts +28 -7
  14. package/dist/types/gl/Lightmap.d.ts +90 -60
  15. package/dist/types/gl/Material.d.ts +28 -20
  16. package/dist/types/gl/Model.d.ts +7 -5
  17. package/dist/types/gl/Node.d.ts +18 -0
  18. package/dist/types/gl/Particles.d.ts +55 -1
  19. package/dist/types/gl/Scene.d.ts +20 -0
  20. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  21. package/dist/types/gl/animation/Animator.d.ts +27 -0
  22. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  23. package/dist/types/gl/animation/IK.d.ts +86 -30
  24. package/dist/types/gl/animation/Locomotion.d.ts +52 -3
  25. package/dist/types/gl/animation/Warp.d.ts +2 -1
  26. package/dist/types/gl/animation/core.d.ts +35 -4
  27. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  28. package/dist/types/gl/terrain/Terrain.d.ts +12 -2
  29. package/dist/types/inject.d.ts +8 -2
  30. package/dist/types/runtime/input.d.ts +7 -0
  31. package/dist/types/scene/defineScene.d.ts +44 -32
  32. package/dist/types/ui/UIButton.d.ts +3 -1
  33. package/dist/types/ui/UIInput.d.ts +5 -1
  34. package/dist/types/ui/UINode.d.ts +24 -24
  35. package/dist/types.json +1 -1
  36. package/package.json +1 -1
  37. package/prompts/README.md +142 -142
  38. package/prompts/core-design.md +27 -4
  39. package/prompts/core.md +35 -6
  40. package/prompts/dist/2d-game.md +197 -408
  41. package/prompts/dist/3d-app.md +166 -491
  42. package/prompts/dist/ar-app.md +163 -373
  43. package/prompts/dist/design.md +87 -83
  44. package/prompts/dist/ui-app.md +136 -325
  45. package/prompts/select.ts +19 -4
  46. package/src/animate/tween/Animation.ts +378 -0
  47. package/src/animate/tween/Timeline.ts +175 -0
  48. package/src/animate/tween/animateValue.ts +100 -0
  49. package/src/animate/tween/easing.ts +172 -0
  50. package/src/animate/tween/spec.ts +479 -0
  51. package/src/audio/audio.ts +161 -161
  52. package/src/bridges.d.ts +235 -65
  53. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  54. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  55. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  56. package/src/compile/bundler.ts +34 -4
  57. package/src/compile/compileProject.ts +31 -1
  58. package/src/compile/detectEntry.ts +8 -3
  59. package/src/compile/index.ts +2 -0
  60. package/src/compile/serverSplit.ts +9 -3
  61. package/src/g2/Node2D.ts +38 -0
  62. package/src/g2/Sprite.ts +20 -1
  63. package/src/gl/Camera.ts +34 -1
  64. package/src/gl/CameraPlace.ts +52 -52
  65. package/src/gl/Foliage.ts +102 -0
  66. package/src/gl/Geometry.ts +393 -348
  67. package/src/gl/Light.ts +49 -16
  68. package/src/gl/Lightmap.ts +439 -275
  69. package/src/gl/Material.ts +59 -47
  70. package/src/gl/Mesh.ts +120 -120
  71. package/src/gl/Model.ts +23 -12
  72. package/src/gl/Node.ts +39 -0
  73. package/src/gl/Particles.ts +80 -2
  74. package/src/gl/Scene.ts +34 -1
  75. package/src/gl/animation/AnimationClip.ts +52 -0
  76. package/src/gl/animation/Animator.ts +42 -2
  77. package/src/gl/animation/DynamicBone.ts +35 -12
  78. package/src/gl/animation/IK.ts +173 -152
  79. package/src/gl/animation/Locomotion.ts +72 -8
  80. package/src/gl/animation/Playback.ts +5 -4
  81. package/src/gl/animation/Warp.ts +5 -2
  82. package/src/gl/animation/core.ts +65 -4
  83. package/src/gl/physics/Ragdoll.ts +451 -272
  84. package/src/gl/scenarios.ts +291 -291
  85. package/src/gl/terrain/Terrain.ts +33 -2
  86. package/src/inject.ts +236 -226
  87. package/src/runtime/input.ts +11 -0
  88. package/src/scene/defineScene.ts +72 -62
  89. package/src/scene/gizmos.ts +148 -148
  90. package/src/ui/UIButton.ts +2 -2
  91. package/src/ui/UIInput.ts +3 -3
  92. package/src/ui/UINode.ts +61 -36
  93. package/dist/types/animate/animate.d.ts +0 -20
  94. package/src/animate/animate.ts +0 -238
@@ -1,145 +1,204 @@
1
- // Baked lighting for static 3D geometry (docs/lightmap-plan.md). A scene FILE does all of this by
2
- // itself: `env: { lightmap: { data, texture } }` marks the level as baked, every model/mesh node
1
+ // Baked lighting for static 3D geometry (packages/creator-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
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 traces the live scene and writes lightmap.ktx2 +
7
- // lightmap.bake), and `Lightmap.load` applies the atlas at runtime:
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:
8
8
  //
9
9
  // const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
10
10
  // Lightmap.add(holder) // a Model, a Mesh, or a node holding them
11
- // Lightmap.add(ground) // Mesh.plane works too
12
11
  // ...
13
- // await Lightmap.load(scene, { data: asset('./assets/lightmap/lightmap.bake'),
14
- // texture: asset('./assets/lightmap/lightmap.ktx2') })
12
+ // await Lightmap.load(scene, { data: asset('./assets/lightmap/level.bake'),
13
+ // light: asset('./assets/lightmap/level-light.ktx2'),
14
+ // aux: asset('./assets/lightmap/level-aux.ktx2'),
15
+ // probes: asset('./assets/lightmap/level-probes.ktx2') })
15
16
  //
16
- // Both files are asset() handles because the compiler stages exactly the files it sees (the data
17
- // file is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a
18
- // no-op when the bake is missing or the host predates the feature, so the project stays runnable —
19
- // and in BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the
20
- // scene is complete". Atlas channels: R = sun visibility, G = ambient occlusion; the shader keeps
21
- // the real-time sun for dynamic casters and multiplies the baked mask in with an ambient floor.
17
+ // `probes` (optional) is the bake's REFLECTION PROBES — the level's radiance traced by the same bake from a grid of
18
+ // points, prefiltered per roughness, one octahedral tile column per probe in one atlas; every pixel of a static
19
+ // blends the probes around it (the .bake carries the grid). Without the file a static's specular reflects the
20
+ // sky's SH alone.
22
21
  //
23
- // PAGES: a large level takes more than one atlas (`--pages`); `texture` is then every page in order.
22
+ // The files are asset() handles because the compiler stages exactly the files it sees (the data file
23
+ // is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a no-op when
24
+ // the bake is missing or the host predates the feature, so the project stays runnable — and in BAKE
25
+ // mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the scene is complete".
24
26
  //
25
- // POINT LIGHTS: the bake traces the scene's point lights too (filament's own candela / falloff
26
- // math, shadow rays) into a second RGB atlas — pass it as `light` — and lists them in the .bake;
27
- // once that atlas applies, the real-time lights it carries are switched off (`Light.point({ baked:
28
- // false })` keeps one live and out of the bake). Statics read the lamps from the atlas, movers from
29
- // the volume's light block — for the price of nothing per frame.
27
+ // What a static gets: its whole DIFFUSE light from the atlas — the sun, the sky and the lamps that the
28
+ // bake traced onto it, in lux — and nothing from the real-time sun or lamps (the engine keeps those
29
+ // off baked renderables through light channels). Movers, glass and anything loaded without
30
+ // `lightmap: true` stay real-time. A lamp that must light statics live too (`Light.point({ baked:
31
+ // false })`) is put on the statics' channel and kept out of the bake.
30
32
  //
31
- // DYNAMIC objects (docs/lightmap-plan.md §9): the bake also writes lightmap.volume — the same
32
- // channels on a 3D grid over the level. Pass it as `volume` and every model loaded with
33
- // `{ lightmap: 'dynamic' }` (the default while a scene file with `env.lightmap.volume` runs) plus
34
- // every dynamic Mesh a scene file registers samples it per pixel: a crate rolled under the roof
35
- // loses the sun like the floor does, a character near a wall picks up its ambient occlusion.
33
+ // Debugging: `Lightmap.debug(mode)` switches every lightmapped surface to a DATA view (irradiance,
34
+ // albedo, normals, visibilities, the atlas checker, the light direction, false-colour lux) and
35
+ // `Lightmap.probe(x, y)` names the texel under a screen point. View 1 ("irradiance") is the lighting alone:
36
+ // a white diffuse material under the camera's exposure. Views "direct" / "indirect" show the bake's two
37
+ // halves: a bake made with `--split` writes them as page sets of their own, the scene lists them as
38
+ // `direct` / `indirect` next to `light`, and the view binds those pages in place of `light` (the shading
39
+ // is untouched — it is the same material reading other pages). The bake's `--debug-dir` keeps the
40
+ // float layers `lecodes lightmap inspect` reads.
36
41
 
37
42
  import { fetch } from "../runtime/fetch"
38
43
  import { Texture } from "./Texture"
39
44
  import { Material } from "./Material"
40
45
  import { Mesh } from "./Mesh"
41
46
  import { Model } from "./Model"
42
- import { Node } from "./Node"
47
+ import { Node, nodeRegistry } from "./Node"
43
48
  import { Light } from "./Light"
44
- import { cx, cy, cz } from "../math/vec"
45
49
  import type { Scene } from "./Scene"
46
50
 
47
51
  export type LightmapLoadOptions = {
48
- /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
49
- sunStrength?: number
50
- /** Multiplier on the ambient (IBL) share in the shadow math — how bright a fully shadowed texel
51
- * stays. 1 reproduces filament's own real-time shadow darkness (the shader reads the scene's sun
52
- * and IBL from filament's per-frame uniforms); raise it for lighter shadows, lower for deeper. Default 1. */
53
- ambientScale?: number
54
- /** How much of the baked ambient occlusion applies: 1 = all of it, 0 = none. An interior lit by
55
- * its ambient probe alone can want less than the geometric truth. Default 1. */
56
- aoStrength?: number
57
- /** Multiplier on the baked point lights' irradiance (default 1). Purely ARTISTIC: the bake is
58
- * physical (verified against the analytic 1/d^2 sum over the level's lamps - where line of sight
59
- * is clear the two agree within ~17 %), and a room lit to its real illuminance simply reads dim
60
- * against an ambient fill. This dials the lamps up without a re-bake, since their irradiance is
61
- * IN the atlas and `--lamp-gain` would cost a full one.
62
- * It scales the atlas AND the light volume together, so statics and movers stay consistent - but
63
- * note they are NOT equally forgiving: a static surface carries the bake's own cosine and
64
- * shadowing, while a mover reads the volume through a flat half-cosine, so a large boost blows
65
- * movers out long before it blows out the level. Past ~x100 expect to light the movers
66
- * separately. */
67
- lightBoost?: number
68
- /** Multiplier on the light VOLUME's irradiance alone — the movers' own `lightBoost`. Default =
69
- * `lightBoost`, and since 2026-08-31 that default is the right one: the volume is an ambient cube
70
- * (irradiance per axis face, blended by the pixel's normal), so a mover's top takes a lamp above it
71
- * and its underside does not — the same shading the atlas gives the statics. Before that the volume
72
- * was one flat 0.5·E on every face and this knob was the workaround. Kept as a trim. */
73
- volumeBoost?: number
74
- /** What an OCCLUDER-only static (a prop without lightmap UVs — traced into the receivers' atlas but
75
- * carrying no rect of its own) does in real time. `"baked"` (default): its shadow is in the bake, so
76
- * it stops casting and the shadow pass draws only the movers — on a level of thousands of foliage
77
- * cards that is a third of the GPU frame. `"realtime"`: it keeps casting, which is the only way its
78
- * shadow reaches OTHER non-receiver props (a rock under a tree); the honest fix for that is lightmap
79
- * UVs on the props (`lecodes assets doctor --lightmap-uv`), which makes them receivers. */
80
- occluderShadows?: "baked" | "realtime"
52
+ /** A DEBUG multiplier on the atlas' lux (default 1): the atlas is physical, so this is a knob for looking,
53
+ * not a look. */
54
+ lightScale?: number
55
+ /** The level's own radiance of emission 1.0 for the BAKE (cd / m², `BakeConfig.emissiveNits`): an imported pack's
56
+ * emissive values are tuned for a look, not for light - this is where the level says how much its panels really give.
57
+ * The CLI's `--emissive-nits` wins over it; without either the camera's exposure decides. */
58
+ emissiveNits?: number
59
+ /** `false` = the emissive surfaces light NOTHING in the bake (the level is lit by its lamps, the panels are decoration)
60
+ * while `emissiveNits` still says how bright their glow is drawn. Default true. */
61
+ emissiveBake?: boolean
62
+ /** FOR THE BAKE: materials that let light THROUGH them, by their glTF name (`"name*"` = every name with that start):
63
+ * the share of a shadow ray that passes, tinted by the material's base colour x map - an awning's warm, patterned light
64
+ * on the sand. The surface itself stays opaque: baked, drawn as before, a real-time shadow caster.
65
+ * A number = the share that goes STRAIGHT through (the gaps of a weave: it draws the map's picture on the ground);
66
+ * `{ through, diffuse }` adds the share the fibres SCATTER - the cloth's underside glows like a matte panel and lights
67
+ * what is under it by the solid angle it fills, with no picture. Dense canvas: `{ through: 0.08, diffuse: 0.25 }`. */
68
+ transmit?: Record<string, LightmapTransmit>
81
69
  }
82
70
 
71
+ /** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
72
+ export type LightmapTransmit = number | { through?: number, diffuse?: number }
73
+
83
74
  export type LightmapInfo = {
84
75
  size: number
85
76
  texel: number
86
- /** Atlas pages the bake took (`texture` lists them in order). */
77
+ /** Atlas pages the bake took (`light` / `aux` list them in order). */
87
78
  pages: number
88
- /** The light volume, when one was loaded: grid dims + cell size in metres. */
89
- volume?: { dims: number[], cell: number }
90
- /** Point lights the bake carries and this load switched off (statics read them from the atlas,
91
- * movers from the volume); 0 without a `light` atlas. */
92
- lights: number
93
79
  /** keys applied / keys in the file / registered statics without a rect */
94
80
  applied: number
95
81
  total: number
96
82
  missing: string[]
97
- /** Occluder-only statics whose real-time shadow was switched off (`occluderShadows: "baked"`). */
98
- occluders: number
99
83
  }
100
84
 
101
85
  export type LightmapFiles = {
102
86
  data: string
103
- /** The atlas — one page, or every page in order for a bake that took several. */
104
- texture: string | string[]
105
- /** The point lights' baked irradiance (pages like `texture`). */
106
- light?: string | string[]
87
+ /** The irradiance atlas — one page, or every page in order for a bake that took several. */
88
+ light: string | string[]
89
+ /** The aux atlas (sun / sky visibility + light direction), pages like `light`. */
90
+ aux: string | string[]
91
+ /** DEBUG: the direct-light-only pages a `--split` bake wrote (`<stem>-direct[_n].ktx2`), for the "direct" view. */
92
+ direct?: string | string[]
93
+ /** DEBUG: the bounce-only pages of a `--split` bake (`<stem>-indirect[_n].ktx2`), for the "indirect" view. */
94
+ indirect?: string | string[]
95
+ /** The reflection probes (`<stem>-probes.ktx2`), when the bake placed some (`lecodes lightmap bake --probe-spacing`). */
96
+ probes?: string
97
+ /** THE LIGHT GRID for movers (`<stem>.lgrid`, `lecodes lightmap bake --volume`): the baked ambient light of every
98
+ * place a mover can be. Absent = movers keep the scene's IBL. */
107
99
  volume?: string
108
100
  }
109
101
 
110
- import type { Terrain } from "./terrain/Terrain"
102
+ /** The lightmap material's data views (`Lightmap.debug`); "direct" / "indirect" bind a `--split` bake's pages instead. */
103
+ export type LightmapDebugMode = "off" | "irradiance" | "albedo" | "normal" | "shadingNormal" | "share" | "skyVis" | "atlas" | "direction" | "lux" | "relief" | "direct" | "indirect" | "probe" | "probeMap" | "skyPath" | "probePath"
104
+ const DENOISE_LEVELS = { none: 0, indirect: 1, "no-sun": 2, all: 3 } as const
105
+ const DEBUG_MODES: LightmapDebugMode[] = [ "off", "irradiance", "albedo", "normal", "shadingNormal", "share", "skyVis", "atlas", "direction", "lux", "relief", "direct", "indirect", "probe", "probeMap", "skyPath", "probePath" ]
106
+ const PAGE_VIEWS: Record<number, "direct" | "indirect"> = { 11: "direct", 12: "indirect" }
107
+
108
+ export type LightmapDebugParams = {
109
+ /** `lux`: the false-colour ramp's log10 range (default 0..5, i.e. 1 lux .. 100 000). */
110
+ luxRange?: [number, number]
111
+ }
111
112
 
112
- type Entry = { key: string, model?: Model, mesh?: Mesh, terrain?: Terrain }
113
+ /** What `Lightmap.probe` answers: the baked texel under a screen point. */
114
+ export type LightmapProbe = {
115
+ key: string
116
+ entity: number
117
+ node: string
118
+ material: string
119
+ page: number
120
+ /** The texel's column / row on its page. */
121
+ texel: [number, number]
122
+ uv1: [number, number]
123
+ world: [number, number, number]
124
+ distance: number
125
+ }
113
126
 
114
- const terrainOf = (n: Node): Terrain | null => (n as unknown as { _terrain?: Terrain })._terrain ?? null
127
+ /** `ground` = a terrain node (built with `Material.terrainLightmap()`): one rect, bound to its chunks by the host */
128
+ type Entry = { key: string, model?: Model, mesh?: Mesh, ground?: Node }
115
129
 
116
130
  type BakeConfig = {
117
- outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
118
- aoDistance?: number, bias?: number, sunAngle?: number, volumeCell?: number,
119
- pages?: number, lightRays?: number, lightRadius?: number,
120
- /** One bounce of indirect light from the point lights, gathered on the AO rays. The value is the
121
- * ALBEDO the bounce assumes for every surface it hits (0 = off; ~0.35 is concrete). A bake with
122
- * no bounce lights only what a lamp sees directly - in an interior that leaves the walls between
123
- * the fixtures black, and a cast shadow invisible for want of light to be missing from. */
124
- bounce?: number, bounceDistance?: number, bounceRays?: number,
125
- /** Metres within which an occluder PARALLEL to the surface being lit is ignored (default 0.05).
126
- * A modular kit stacks two wall pieces in the same place; to the bake they are coincident and
127
- * the AO goes black in hard-edged patches. Real contact shadows are unaffected - they come off
128
- * surfaces at an angle. 0 turns it off. */
129
- coplanarSkip?: number,
130
- /** Radius (px) of the geometry-aware denoise over the traced texels (default 2, 0 = off): the
131
- * Monte-Carlo grain goes, edges of geometry stay. */
132
- denoise?: number,
131
+ outDir: string, stem: string, size?: number, texel?: number, pages?: number,
132
+ sunRays?: number, skyRays?: number, lightRays?: number, lightRadius?: number, sunAngle?: number, bias?: number, seed?: number,
133
+ bounceRays?: number, bounces?: number, patchSamples?: number, split?: boolean,
134
+ /** 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"`. */
135
+ denoise?: "none" | "indirect" | "no-sun" | "all",
136
+ /** The texel's reconstruction filter radius in texels: 1 (the default) = a ±1 texel tent, 0.5 = within the texel, 0 = the centre alone. */
137
+ filter?: number,
138
+ /** 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). */
139
+ probeSpacing?: number, probeSize?: number, probeRays?: number,
140
+ /** Keep only the probes under a roof (a closed top, fewer than three open sides). Outdoor statics then reflect the sky through
141
+ * their baked sky visibility, like an environment map without the ground; no outdoor probe can reach indoors and the
142
+ * atlas shrinks. Default false. */
143
+ probeIndoor?: boolean,
144
+ /** Where the probes go. "rooms" (the default): every indoor point of the grid slides to the middle of its room and the
145
+ * points of one room merge — one probe per room, far from the walls, its box the room (stations every ~8 m along a
146
+ * room over 12 m); outdoor points stay on the grid. "grid": everything stays on the grid. Either way the bake records, per block of atlas
147
+ * texels, the two probes those texels SEE (rays), so no probe reflects through a wall. */
148
+ probeLayout?: "rooms" | "grid",
149
+ /** Instances smaller than this many ATLAS TEXELS along every axis (x `texel` metres: 12 at 5 cm = 0.6 m) get no place
150
+ * in the atlas: they still cast their baked shadow and bounce - the wall keeps their shadow - and take their own light
151
+ * from an ambient cube baked where they stand. In texels and not metres: what cannot hold a lightmap is a matter of
152
+ * how many texels it gets (a fire extinguisher of 0.55 m: 300 charts, the largest 29 texels - every chart edge a step).
153
+ * 0 = everything bakes. Default 12. */
154
+ minTexels?: number,
155
+ /** THE LIGHT GRID for movers: the cell size in metres (0.75 of it tall); 0 = none. Default 1. */
156
+ volumeSpacing?: number,
157
+ /** A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid
158
+ * out denser by sqrt(detailTexels / its texels), 3x at most - a pipe of 1 m is ~300 texels at 5 cm, its charts a few
159
+ * texels wide and every chart edge a step. Default 2000; 0 = off. */
160
+ detailTexels?: number,
161
+ /** EMISSIVE SURFACES LIGHT THE LEVEL: the radiance in cd / m² of emission 1.0 (a material's emissive factor x strength x
162
+ * map) - every lit region of an emissive surface becomes a rectangle lamp in the bake (a ceiling panel drawn in the
163
+ * ceiling's texture, a screen, a sign). Default: what the level's CAMERA makes of emission 1.0, `1.2 x 2^ev100` - the
164
+ * engine draws emission screen-referred, so a panel then lights the room as brightly as it looks. 0 = they light nothing. */
165
+ emissiveNits?: number,
166
+ /** Only grid cells whose centre lies inside this world box get a probe (min xyz, max xyz) — keeps probes off a roof top the
167
+ * player never sees. Default: the receivers' bounds. */
168
+ probeBox?: [number, number, number, number, number, number],
169
+ debugDir?: string,
133
170
  }
134
171
 
135
- type VolumeHeader = { texture: number, dims: number[], min: number[], cell: number, light?: number, faces?: number }
136
-
172
+ /** a small static's light where it stands (creator-bake objects.h): per side of its bounds (+x -x +y -y +z -z) the
173
+ * irradiance rgb in lux + the sky visibility; probes = a, b, a's weight (-1 = none, -2 = the sky) */
174
+ type BakeObjectLight = { at: number[], cube: number[][], probes: number[] }
175
+ type BakeInstance = { key: string, receiver: boolean, small?: boolean, light?: BakeObjectLight, page?: number, rect?: number[], st?: number[] }
176
+ type BakeProbes = {
177
+ file: string, size: number, levels: number, count: number, oct: number, tile: number[], perRow: number, atlas: number[],
178
+ spacing: number, layout: "rooms" | "grid",
179
+ /** the block size of the pages' probe maps (atlas texels) and the range their records decode with: lo xyz, size xyz */
180
+ map: number, encode: number[],
181
+ positions: number[][], boxes: number[][],
182
+ /** per probe: its open share of the sphere (0 = a room, over 0.15 = under the sky) */
183
+ sky: number[],
184
+ }
137
185
  type BakeData = {
138
- version?: number, size: number, texel: number, pages?: number, textures?: string[],
139
- lightTextures?: string[], lightScale?: number, lightGamma?: number,
140
- lights?: { p: number[], color: number[], cd: number, range: number }[],
141
- sun?: number[] | null,
142
- instances: { key: string, receiver: boolean, st?: number[], page?: number }[],
186
+ version: number, size: number, pages: number, texel: number, lightScale: number,
187
+ light: string[], aux: string[], direct?: string[], indirect?: string[],
188
+ probes?: BakeProbes | null,
189
+ /** the light grid (creator-bake volume.h): its file and lattice */
190
+ volume?: { file: string, n: number[], origin: number[], cell: number[], valid: number } | null,
191
+ sun?: { direction: number[], color: number[], lux: number } | null, sunMap?: boolean, sunMapLevels?: number, mips?: number, emissiveNits?: number,
192
+ instances: BakeInstance[],
193
+ }
194
+ type PageKind = "light" | "direct" | "indirect"
195
+ type Bound = {
196
+ data: BakeData, rects: Map<string, BakeInstance>, byEntity: Map<number, Entry>, files: LightmapFiles, pages: number,
197
+ aux: Texture[], light: Texture[], direct?: Texture[], indirect?: Texture[],
198
+ /** how many reflection probes the host holds (0 = none loaded), and their atlas */
199
+ probes: number, probeAtlas?: Texture,
200
+ /** which page set the surfaces read right now */
201
+ showing: PageKind,
143
202
  }
144
203
 
145
204
  const bakeConfig = (): BakeConfig | null => {
@@ -147,14 +206,6 @@ const bakeConfig = (): BakeConfig | null => {
147
206
  return g && typeof g.outDir === "string" ? g : null
148
207
  }
149
208
 
150
- /** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
151
- const hex8 = (c: unknown): string => {
152
- if (typeof c !== "string") return "#ffffffff"
153
- if (c.length === 7) return c + "ff"
154
- if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
155
- return c
156
- }
157
-
158
209
  const list = (v: string | string[] | undefined): string[] => v === undefined ? [] : Array.isArray(v) ? v : [ v ]
159
210
 
160
211
  const NONE = 0xFFFFFFFF
@@ -164,21 +215,18 @@ export class Lightmap {
164
215
  static get baking(): boolean { return bakeConfig() !== null }
165
216
 
166
217
  private static entries: Entry[] = []
167
- private static dynamics: Mesh[] = []
168
218
  private static ordinals = new Map<string, number>()
169
219
  private static warned = false
170
- private static switchedOff: Light[] = []
171
-
220
+ /** The bake bound last (`load`): what `probe` reads rects from. */
221
+ private static bound: Bound | null = null
172
222
 
173
223
  /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
174
- * both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
224
+ * both receivers and occluders in the bake. `key` names the entry in the .bake (default:
175
225
  * the node's name + a running number, `container#3`); pass one when names are not stable. */
176
226
  static add(node: Node, key?: string): Node {
177
227
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
178
228
  const found: Entry[] = []
179
229
  const walk = (n: Node): void => {
180
- const t = terrainOf(n)
181
- if (t) { found.push({ key: base, terrain: t }); return } // one receiver, its chunks are internal
182
230
  if (n instanceof Model) { found.push({ key: base, model: n }); return }
183
231
  if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
184
232
  for (const c of n.children) walk(c)
@@ -194,16 +242,9 @@ export class Lightmap {
194
242
  static _register(node: Model | Mesh, key: string): void {
195
243
  Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
196
244
  }
197
-
198
- /** @internal Scene-file runtime: a terrain node (its own material carries the atlas, no swap). */
199
- static _registerTerrain(terrain: Terrain, key: string): void {
200
- Lightmap.entries.push({ key, terrain })
201
- }
202
-
203
- /** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
204
- * when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
205
- static _registerDynamic(mesh: Mesh): void {
206
- Lightmap.dynamics.push(mesh)
245
+ /** @internal Scene-file runtime: a terrain node - a receiver with one rect (the host keeps its triangles for the bake). */
246
+ static _registerGround(node: Node, key: string): void {
247
+ Lightmap.entries.push({ key, ground: node })
207
248
  }
208
249
 
209
250
  private static next(name: string): number {
@@ -212,23 +253,77 @@ export class Lightmap {
212
253
  return n
213
254
  }
214
255
 
215
- /** Forget every registration (a scene rebuild); baked-off lights come back to the real-time path. */
256
+ // ---- movers: the light grid ------------------------------------------------------------------------
257
+ private static movers = new Set<Model | Mesh>()
258
+ private static volumeOn = false
259
+
260
+ /** A MOVER takes its ambient light from the level's baked LIGHT GRID (`env.lightmap.volume`) at the place it is at,
261
+ * every frame — whatever its shader: the standard glTF one or a custom lit material. Every Model / Mesh under `node`
262
+ * is marked (a character, a weapon with its parts). A scene file does this itself for every node a Physics aspect or
263
+ * a CharacterController moves; call it for what CODE spawns — an enemy, a pickup, a projectile. `on = false` hands
264
+ * the subtree back to the scene's IBL. Safe before the level's bake has loaded. */
265
+ static track(node: Node, on = true): void {
266
+ // by entity id through the bridge, not `node.children`: the bundler drops that accessor from a build where
267
+ // nothing else reads it (the leash in lightcheck, 2026-09-18: "children" was undefined on a Mesh)
268
+ const walk = (id: number): void => {
269
+ const n = nodeRegistry.get(id)
270
+ if (n instanceof Model || n instanceof Mesh) {
271
+ if (on) Lightmap.movers.add(n); else Lightmap.movers.delete(n)
272
+ if (Lightmap.volumeOn || !on) _creator.lightVolumeTrack?.(id, on)
273
+ }
274
+ for (const c of _creator.getChildren(id)) walk(c)
275
+ }
276
+ walk(node.id)
277
+ }
278
+
279
+ private static async loadVolume(data: BakeData, files: LightmapFiles): Promise<void> {
280
+ Lightmap.volumeOn = false
281
+ if (!files.volume || !data.volume || !_creator.lightVolumeLoad) {
282
+ 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`)
283
+ return
284
+ }
285
+ try {
286
+ const resp = await fetch(files.volume, { useOnce: true })
287
+ if (resp.status >= 400) { console.warn(`Lightmap: no light grid at ${files.volume} (HTTP ${resp.status})`); return }
288
+ const ok = _creator.lightVolumeLoad((resp as unknown as { _id: number })._id)
289
+ resp.dispose()
290
+ if (!ok) { console.warn(`Lightmap: ${files.volume} is not a light grid — rebake`); return }
291
+ } catch (e) {
292
+ console.warn(`Lightmap: could not read ${files.volume}: ${String(e)}`)
293
+ return
294
+ }
295
+ Lightmap.volumeOn = true
296
+ // the baked lamps are in the grid's cubes now: burning on, they would light every mover twice (Light._holdDark)
297
+ for (const l of Light._points) if (l._baked) l._holdDark(true)
298
+ for (const m of Lightmap.movers) _creator.lightVolumeTrack?.(m.id, true)
299
+ // the statics the atlas left out (glass, a material the lightmap shader declines) take the grid's light once; the
300
+ // host ignores a static whose every primitive is baked
301
+ for (const e of Lightmap.entries) if (!e.ground) _creator.lightVolumeTrack?.((e.model ?? e.mesh!).id, true, true)
302
+ 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)`)
303
+ }
304
+
305
+ /** Forget every registration (a scene rebuild). */
216
306
  static clear(): void {
307
+ for (const l of Light._points) l._holdDark(false)
308
+ Lightmap.movers.clear()
309
+ Lightmap.volumeOn = false
310
+ _creator.lightVolumeClear?.()
217
311
  Lightmap.entries = []
218
- Lightmap.dynamics = []
219
312
  Lightmap.ordinals.clear()
220
- Model._lightmapDefault = false
221
- for (const l of Lightmap.switchedOff) l._setBakedOff(false)
222
- Lightmap.switchedOff = []
223
- _creator.lightmapSetOptions?.(1, 1, 1, 0)
224
- _creator.lightmapVolumeSet?.(NONE, NONE, 0, 0, 0, 0, 0, 0, 0)
313
+ Lightmap.bound = null
314
+ if (Lightmap.balls) { Lightmap.balls.destroy(); Lightmap.balls = null }
315
+ _creator.lightmapSetOptions?.(1, 0)
316
+ _creator.lightmapSunSet?.(0, 1, 0, 0, 0, 0, false)
317
+ _creator.lightmapDebug?.(0)
225
318
  }
226
319
 
227
320
  /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
228
321
  * applied (no bake yet, a host without the feature, bake mode). */
322
+ private static scene: Scene | null = null
229
323
  static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
324
+ Lightmap.scene = scene
230
325
  const bake = bakeConfig()
231
- if (bake) { Lightmap.bake(bake); return null }
326
+ if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
232
327
  if (!_creator.lightmapApply) {
233
328
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
234
329
  return null
@@ -243,188 +338,257 @@ export class Lightmap {
243
338
  console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
244
339
  return null
245
340
  }
246
- // the atlas pages: a bake that took more pages than the scene lists applies what it can and says so
247
- const pages = Math.max(1, data.pages ?? 1)
248
- const pageFiles = list(files.texture)
249
- if (pageFiles.length === 0) { console.warn("Lightmap: no atlas texture given"); return null }
250
- if (pageFiles.length < pages) {
251
- console.warn(`Lightmap: the bake took ${pages} atlas pages but the scene lists ${pageFiles.length} — add the other page files (${(data.textures ?? []).slice(pageFiles.length).join(", ")}) to env.lightmap.texture`)
341
+ if (data.version !== 4 || !Array.isArray(data.instances)) {
342
+ console.warn(`Lightmap: ${files.data} is not a creator-bake file (version ${String(data.version)}) — rebake with \`lecodes lightmap bake\``)
343
+ return null
252
344
  }
253
- // baked lighting is exempt from Texture.maxSize: a capped atlas blurs every shadow edge
254
- const textures = await Promise.all(pageFiles.slice(0, pages).map((f) => Texture.load(f, { fullSize: true })))
255
- // the light atlas: the bake carries the point lights only when the scene passes their atlas
256
- const lightFiles = list(files.light)
257
- let lightTextures: Texture[] = []
258
- let lightScale = 0
259
- let volumeScale = 0
260
- if (data.lightTextures?.length && (data.lightScale ?? 0) > 0) {
261
- if (lightFiles.length === 0) {
262
- console.warn(`Lightmap: the bake carries ${data.lights?.length ?? 0} baked point light(s) but the scene passes no \`light\` atlas (${data.lightTextures[0]}) — the lamps stay real-time`)
263
- } else {
264
- if (lightFiles.length < pages) console.warn(`Lightmap: the light atlas has ${pages} pages but the scene lists ${lightFiles.length}`)
265
- lightTextures = await Promise.all(lightFiles.slice(0, pages).map((f) => Texture.load(f, { fullSize: true })))
266
- lightScale = (data.lightScale ?? 0) * (options.lightBoost ?? 1)
267
- volumeScale = (data.lightScale ?? 0) * (options.volumeBoost ?? options.lightBoost ?? 1)
268
- }
345
+ if (!data.pages || !(data.lightScale > 0)) return null // the CLI's placeholder: nothing baked yet
346
+ const pages = Math.max(1, data.pages)
347
+ const lightFiles = list(files.light), auxFiles = list(files.aux)
348
+ if (lightFiles.length === 0 || auxFiles.length === 0) { console.warn("Lightmap: the scene passes no `light` / `aux` atlas"); return null }
349
+ if (lightFiles.length < pages || auxFiles.length < pages) {
350
+ 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`)
269
351
  }
270
- const ambientScale = options.ambientScale ?? 1
271
- const strength = options.sunStrength ?? 1
272
- const aoStrength = options.aoStrength ?? 1
273
- // the light store's curve: sRGB(pow(E / lightScale, 1 / lightGamma)); bakes before 2026-08-31 are linear (1)
274
- const lightGamma = data.lightGamma ?? 1
275
- // level-wide knobs first: the volume and every rect below inherit them
276
- _creator.lightmapSetOptions?.(ambientScale, strength, aoStrength, lightScale, volumeScale, lightGamma)
277
- // what the bake actually BOUND, so a level can be debugged without guessing (FEEDBACK 95): the
278
- // scale is the one number standing between the atlas' physical irradiance and the pixel
279
- console.log(`[lightmap] bound ${textures.length} page(s)${lightTextures.length ? ` + ${lightTextures.length} light page(s)` : ""}`
280
- + ` lightScale ${lightScale.toFixed(2)}${(options.lightBoost ?? 1) !== 1 ? ` (bake ${(data.lightScale ?? 0).toFixed(2)} x boost ${options.lightBoost})` : ""}`
281
- + `${volumeScale !== lightScale ? ` volumeScale ${volumeScale.toFixed(2)}` : ""}`
282
- + ` gamma ${lightGamma} ao ${aoStrength} ambient ${ambientScale} sun ${strength}`)
283
- // the light volume next: it claims every lightmap-material instance, the atlas rects below win back the statics
284
- const volume = files.volume ? await Lightmap.loadVolume(files.volume, volumeScale, lightGamma, ambientScale, strength, aoStrength) : null
285
- const rects = new Map<string, { st: number[], page: number }>()
286
- // an OCCLUDER-only static (no lightmap UVs): the bake traced its shadow onto every receiver, so it
287
- // has nothing left to cast in real time — the shadow pass then draws only the movers. Twig & Twine's
288
- // 9 441 props (5 M alpha-masked spruce cards) cost the GPU a third of the frame as live casters.
289
- const occluders = new Set<string>()
290
- for (const inst of data.instances) {
291
- if (inst.receiver && inst.st) rects.set(inst.key, { st: inst.st, page: inst.page ?? 0 })
292
- else if (!inst.receiver) occluders.add(inst.key)
352
+ // both atlases are DATA and exempt from Texture.maxSize (a capped atlas blurs every shadow edge)
353
+ const n = Math.min(pages, lightFiles.length, auxFiles.length)
354
+ const light = await Promise.all(lightFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
355
+ const aux = await Promise.all(auxFiles.slice(0, n).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
356
+ const lightScale = data.lightScale * (options.lightScale ?? 1)
357
+ _creator.lightmapSetOptions?.(lightScale, data.emissiveNits ?? 0)
358
+ // a mover's sun shadow on the statics: only a bake that wrote the sun map under its aux pages
359
+ const sun = data.sunMap ? data.sun : null
360
+ if (sun) _creator.lightmapSunSet?.(-sun.direction[0], -sun.direction[1], -sun.direction[2], sun.color[0] * sun.lux, sun.color[1] * sun.lux, sun.color[2] * sun.lux, true, data.sunMapLevels ?? 1)
361
+ else _creator.lightmapSunSet?.(0, 1, 0, 0, 0, 0, false)
362
+ // the reflection probes: the atlas loads like a page, the grid travels as numbers; level-wide on the host
363
+ let probes = 0
364
+ let probeAtlas: Texture | undefined
365
+ const pb = data.probes
366
+ if (pb && pb.count > 0) {
367
+ 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`)
368
+ else if (!_creator.lightmapProbesSet) console.warn("Lightmap: this host has no reflection probes — statics reflect the sky alone")
369
+ 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)")
370
+ else {
371
+ probeAtlas = await Texture.load(files.probes, { fullSize: true, srgb: false })
372
+ // which probes a pixel reflects is in the aux pages (their probe maps); the host needs the atlas and the records' range
373
+ probes = _creator.lightmapProbesSet(probeAtlas._id, pb.oct, pb.levels, pb.perRow, pb.count,
374
+ pb.encode[0], pb.encode[1], pb.encode[2], pb.encode[3], pb.encode[4], pb.encode[5])
375
+ if (probes !== pb.count) console.warn(`Lightmap: the host took ${probes} of ${pb.count} probe(s) — rebake`)
376
+ }
293
377
  }
294
- const bakedOccluders = (options.occluderShadows ?? "baked") === "baked"
295
- let applied = 0, shadowless = 0
378
+ 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)` : ""}`)
379
+ const rects = new Map<string, BakeInstance>()
380
+ for (const inst of data.instances) if (inst.receiver && inst.st && inst.page !== undefined) rects.set(inst.key, inst)
381
+ // the ones the bake left out for their size: occluders there, movers here — not a rect gone missing
382
+ const small = new Map<string, BakeInstance>()
383
+ for (const inst of data.instances) if (inst.small) small.set(inst.key, inst)
384
+ const declined = new Set<string>()
385
+ for (const inst of data.instances) if (!inst.receiver && !inst.small) declined.add(inst.key)
386
+ const probeId = (k: number) => k === -2 ? 65534 : k < 0 ? 65535 : k
387
+ // A GLB UNWRAPPED IN GROUPS (`assets doctor --lightmap-uv --lightmap-groups n`): the bake keys its parts `<key>#k`, a rect
388
+ // (or a small static's cube) each, and the host binds part k to the nodes that carry extras.lightmapGroup = k
389
+ const parts = Lightmap.groupsOf(data.instances)
390
+ let smallLit = 0
391
+ const byEntity = new Map<number, Entry>()
392
+ let applied = 0
296
393
  const missing: string[] = []
297
394
  for (const e of Lightmap.entries) {
298
- const r = rects.get(e.key)
299
- if (!r || r.page >= textures.length) {
300
- if (occluders.has(e.key)) {
301
- if (bakedOccluders) {
302
- if (e.model) e.model.castShadows = false
303
- else if (e.mesh) e.mesh.castShadows = false
304
- shadowless++
395
+ const node = e.model ?? e.mesh ?? e.ground!
396
+ byEntity.set(node.id, e)
397
+ const groups = parts.get(e.key)
398
+ if (groups) {
399
+ let took = 0
400
+ for (const [ k, inst ] of groups) {
401
+ if (inst.small && inst.light && _creator.lightmapObjectApply && n > 0) {
402
+ const l = inst.light
403
+ took += _creator.lightmapObjectApply(node.id, light[0]._id, aux[0]._id, Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ]), k)
404
+ } else if (inst.receiver && inst.st && inst.page !== undefined && inst.page < n) {
405
+ took += _creator.lightmapApply(node.id, light[inst.page]._id, aux[inst.page]._id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
305
406
  }
306
- } else missing.push(e.key)
407
+ }
408
+ if (took === 0) console.warn(`Lightmap: ${e.key} (${groups.size} groups) was not loaded lightmapped, or the host binds no groups (rebuild it)`)
409
+ else applied++
307
410
  continue
308
411
  }
309
- const st = r.st
310
- const texture = textures[r.page]
311
- const light = r.page < lightTextures.length ? lightTextures[r.page] : null
312
- if (e.model) {
313
- const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], light ? light._id : NONE)
314
- if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
315
- e.model.castShadows = false
316
- } else if (e.mesh) {
317
- Lightmap.swapMeshMaterial(e.mesh, texture, light, light ? lightScale : 0, lightGamma, st, ambientScale, strength, aoStrength)
318
- e.mesh.castShadows = false
319
- } else if (e.terrain) {
320
- // terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
321
- const m = e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
322
- .set("ambientScale", ambientScale).set("sunStrength", strength).set("bakedAo", aoStrength).set("lightGamma", lightGamma)
323
- if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
324
- else m.set("lightScale", 0)
325
- e.terrain.castShadows = false
412
+ const r = rects.get(e.key)
413
+ const tiny = small.get(e.key)
414
+ if (tiny) {
415
+ // no rect: the light baked where it stands (any page's samplers — the probes' records sit under every aux page)
416
+ const l = tiny.light
417
+ if (!l || !_creator.lightmapObjectApply || n === 0) continue
418
+ if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
419
+ const d = Float32Array.from([ ...l.cube.flat(), probeId(l.probes[0]), probeId(l.probes[1]), l.probes[2] ])
420
+ if (_creator.lightmapObjectApply(node.id, light[0]._id, aux[0]._id, d) > 0) smallLit++
421
+ continue
326
422
  }
423
+ if (!r && declined.has(e.key)) continue // glass / a declined material: the bake knows it as an occluder - the light grid lights it (loadVolume)
424
+ if (!r || r.page! >= n) { missing.push(e.key); continue }
425
+ const st = r.st!
426
+ // a Mesh receiver: its lit material becomes a lightmap material carrying the same colour / map / factors
427
+ if (e.mesh) e.mesh.material = Lightmap.meshMaterial(e.mesh)
428
+ const took = _creator.lightmapApply(node.id, light[r.page!]._id, aux[r.page!]._id, st[0], st[1], st[2], st[3])
429
+ if (took === 0) { console.warn(`Lightmap: ${e.key} was not loaded lightmapped (Model.load { lightmap: true } / Material.lightmap())`); continue }
327
430
  applied++
328
431
  }
432
+ 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`)
329
433
  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`)
330
- // the lights the bake carries go dark: the atlas holds them for the statics, the volume for the movers
331
- let lights = 0
332
- if (lightTextures.length > 0 && data.lights?.length) lights = Lightmap.switchOffBaked(data.lights)
333
- if (shadowless > 0) console.log(`[lightmap] ${shadowless} occluder-only static(s) cast no real-time shadow (baked); the shadow pass draws the movers only`)
334
- const info: LightmapInfo = { size: data.size, texel: data.texel, pages, applied, total: rects.size, missing, lights, occluders: shadowless }
335
- if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
336
- return info
434
+ Lightmap.bound = { data, rects, byEntity, files, pages: n, aux, light, probes, probeAtlas, showing: "light" }
435
+ await Lightmap.loadVolume(data, files)
436
+ return { size: data.size, texel: data.texel, pages: n, applied, total: rects.size, missing }
337
437
  }
338
438
 
339
- /** Every live point light within 5 cm of a baked one is held dark (a moved lamp keeps lighting live
340
- * — and double, until the next bake, which is the honest state of a stale bake). */
341
- private static switchOffBaked(baked: { p: number[] }[]): number {
439
+ /** The bake's `<key>#k` instances by their base key: the parts of a GLB unwrapped in groups (k = extras.lightmapGroup). */
440
+ private static groupsOf(instances: BakeInstance[]): Map<string, Map<number, BakeInstance>> {
441
+ const out = new Map<string, Map<number, BakeInstance>>()
442
+ const registered = new Set(Lightmap.entries.map((e) => e.key))
443
+ for (const inst of instances) {
444
+ const m = /^(.*)#(\d+)$/.exec(inst.key)
445
+ if (!m || !registered.has(m[1])) continue
446
+ let g = out.get(m[1])
447
+ if (!g) { g = new Map(); out.set(m[1], g) }
448
+ g.set(Number(m[2]), inst)
449
+ }
450
+ return out
451
+ }
452
+
453
+ /** Switch every lightmapped surface to a DATA view (or back with `"off"` / 0). Level-wide, instant —
454
+ * except "direct" / "indirect", which load that page set the first time (a `--split` bake listed in the scene). */
455
+ static debug(mode: LightmapDebugMode | number, params: LightmapDebugParams = {}): void {
456
+ const m = typeof mode === "number" ? mode : Math.max(0, DEBUG_MODES.indexOf(mode))
457
+ const [ lo, hi ] = params.luxRange ?? [ 0, 5 ]
458
+ const pages = PAGE_VIEWS[m]
459
+ void Lightmap.show(pages ?? "light")
460
+ _creator.lightmapDebug?.(pages ? 0 : m, 0, lo, hi, 0)
461
+ }
462
+
463
+ /** Bind another page set of the bound bake to every surface (the "direct" / "indirect" views). */
464
+ private static async show(kind: PageKind): Promise<void> {
465
+ const b = Lightmap.bound
466
+ if (!b || b.showing === kind || !_creator.lightmapApply) return
467
+ let tex = b[kind]
468
+ if (!tex) {
469
+ const files = list(b.files[kind])
470
+ if (files.length < b.pages) {
471
+ 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`)
472
+ return
473
+ }
474
+ tex = await Promise.all(files.slice(0, b.pages).map((f) => Texture.load(f, { fullSize: true, srgb: false })))
475
+ if (Lightmap.bound !== b) return // rebuilt meanwhile
476
+ b[kind] = tex
477
+ }
478
+ b.showing = kind
479
+ const parts = Lightmap.groupsOf(b.data.instances)
480
+ for (const [ id, e ] of b.byEntity) {
481
+ for (const [ k, inst ] of parts.get(e.key) ?? []) {
482
+ if (inst.receiver && inst.st && inst.page !== undefined && inst.page < b.pages) _creator.lightmapApply(id, tex[inst.page]._id, b.aux[inst.page]._id, inst.st[0], inst.st[1], inst.st[2], inst.st[3], k)
483
+ }
484
+ const r = b.rects.get(e.key)
485
+ if (!r || r.page! >= b.pages) continue
486
+ const st = r.st!
487
+ _creator.lightmapApply(id, tex[r.page!]._id, b.aux[r.page!]._id, st[0], st[1], st[2], st[3])
488
+ }
489
+ console.log(`[lightmap] showing the ${kind} pages`)
490
+ }
491
+ private static balls: Node | null = null
492
+ /** DEBUG: a mirror ball at every reflection probe of the bound bake (`on`), or none (`off`) — a ball reflects the
493
+ * probes around its point unoccluded (at a probe's own position that is the probe itself), so a probe inside a
494
+ * wall, a black one or a leak across a wall shows at a glance. Metallic, roughness 0; view "probe" shows the
495
+ * probes alone. */
496
+ static debugProbes(on: boolean, radius = 0.2): number {
497
+ if (Lightmap.balls) { Lightmap.balls.destroy(); Lightmap.balls = null }
498
+ const b = Lightmap.bound
499
+ if (!on || !b || b.probes === 0 || !b.data.probes || !_creator.lightmapApply) return 0
500
+ const holder = new Node()
501
+ holder.name = "lightmap-probes"
342
502
  let n = 0
343
- for (const light of Light._points) {
344
- if (!light._baked) continue
345
- const wp = light.worldPosition
346
- const x = cx(wp), y = cy(wp), z = cz(wp)
347
- const hit = baked.some((b) => Math.abs(b.p[0] - x) < 0.05 && Math.abs(b.p[1] - y) < 0.05 && Math.abs(b.p[2] - z) < 0.05)
348
- if (!hit) continue
349
- light._setBakedOff(true)
350
- Lightmap.switchedOff.push(light)
503
+ for (let k = 0; k < Math.min(b.probes, b.data.probes.positions.length); k++) {
504
+ const [ x, y, z ] = b.data.probes.positions[k]
505
+ const m = Material.lightmap()
506
+ m.set("baseColorFactor", "#ffffffff").set("metallicFactor", 1).set("roughnessFactor", 0).set("hasNormalMap", 0)
507
+ const ball = Mesh.sphere({ radius, material: m })
508
+ ball.name = `probe#${k}`
509
+ ball.position = [ x, y, z ]
510
+ holder.add(ball)
511
+ _creator.lightmapApply(ball.id, b.light[0]._id, b.aux[0]._id, 0, 0, 0, 0)
512
+ m.set("probeBall", k + 1) // probe k alone, unoccluded
351
513
  n++
352
514
  }
515
+ Lightmap.scene?.add(holder)
516
+ Lightmap.balls = holder
517
+ console.log(`[lightmap] ${n} probe ball(s)`)
353
518
  return n
354
519
  }
355
520
 
356
- /** lightmap.volume → the engine's 3D textures → THE volume for every dynamic lightmap-material instance,
357
- * plus the registered dynamic Meshes. Null when the file is a placeholder or the host lacks the feature. */
358
- private static async loadVolume(url: string, lightScale: number, lightGamma: number, ambientScale: number, strength: number, aoStrength: number): Promise<VolumeHeader | null> {
359
- if (!_creator.lightmapVolumeLoad || !_creator.lightmapVolumeSet) return null
360
- let header: VolumeHeader
361
- try {
362
- const resp = await fetch(url, { useOnce: true })
363
- if (resp.status >= 400) { console.warn(`Lightmap: no volume at ${url} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
364
- const json = _creator.lightmapVolumeLoad((resp as unknown as { _id: number })._id)
365
- resp.dispose()
366
- if (!json) { console.warn(`Lightmap: ${url} is not a light volume — rebake`); return null }
367
- header = JSON.parse(json) as VolumeHeader
368
- } catch (e) {
369
- console.warn(`Lightmap: could not read ${url}: ${String(e)}`)
370
- return null
521
+ /** The view names in `lightmapDebug`'s order — for a knob that cycles them. */
522
+ static get debugModes(): readonly LightmapDebugMode[] { return DEBUG_MODES }
523
+
524
+ /** The baked texel under a screen point (logical px): its instance key, page and texel column / row,
525
+ * so the value can be looked up in the bake's debug layers (`lecodes lightmap inspect`). null when
526
+ * nothing baked is under the point or the host has no triangle pick. */
527
+ static probe(screenX: number, screenY: number): LightmapProbe | null {
528
+ const b = Lightmap.bound
529
+ if (!b || !_creator.pickTriangle) return null
530
+ type Hit = { entity: number, nodeName: string, material: string, point: number[], distance: number, uv1?: number[], lightmapGroup?: number }
531
+ // the nearest hit over the registered statics: one pick per entry (the all-instances pick, entity 0, walks
532
+ // every GLB instance including the off-scene templates and is what a scene file's clones confuse)
533
+ let hit: Hit | null = null
534
+ for (const id of b.byEntity.keys()) {
535
+ const j = _creator.pickTriangle(id, screenX, screenY)
536
+ if (!j) continue
537
+ const h = JSON.parse(j) as Hit
538
+ if (!hit || h.distance < hit.distance) hit = h
371
539
  }
372
- const [ nx, ny, nz ] = header.dims
373
- if (nx * ny * nz <= 1) return null // the CLI's placeholder: nothing baked yet
374
- const size = [ nx * header.cell, ny * header.cell, nz * header.cell ]
375
- const texture = new Texture(nx, ny, header.texture)
376
- const hasLight = header.light !== undefined && lightScale > 0
377
- const light = hasLight ? new Texture(nx, ny, header.light!) : null
378
- if (hasLight && (header.faces ?? 1) < 6) console.warn("Lightmap: the light volume predates the ambient cube (one directionless value per cell) — rebake")
379
- _creator.lightmapVolumeSet(header.texture, hasLight ? header.light! : NONE, header.min[0], header.min[1], header.min[2], size[0], size[1], size[2], header.cell)
380
- for (const mesh of Lightmap.dynamics) {
381
- const m = Lightmap.meshMaterial(mesh, ambientScale, strength, aoStrength, lightGamma)
382
- m.set("probeVolume", texture)
383
- if (light) m.set("probeLight", light).set("lightScale", lightScale)
384
- m.set("probeMin", [ header.min[0], header.min[1], header.min[2], header.cell * 0.5 ])
385
- // .w = the grid's depth: the shader keeps its fetch inside one face slab of the light texture
386
- m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], nz ])
387
- mesh.setMaterial(m)
540
+ if (!hit) { console.log(`[probe] nothing baked under ${screenX.toFixed(0)},${screenY.toFixed(0)}`); return null }
541
+ const entry = b.byEntity.get(hit.entity)
542
+ 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 }
543
+ const hitKey = hit.lightmapGroup !== undefined && b.rects.has(`${entry.key}#${hit.lightmapGroup}`) ? `${entry.key}#${hit.lightmapGroup}` : entry.key
544
+ const inst = b.rects.get(hitKey)
545
+ if (!inst || !inst.st) { console.log(`[probe] ${hitKey}: no rect in the bake`); return null }
546
+ if (!hit.uv1) { console.log(`[probe] ${entry.key}: the hit primitive carries no TEXCOORD_1`); return null }
547
+ const st = inst.st
548
+ const u = hit.uv1[0] * st[0] + st[2], v = hit.uv1[1] * st[1] + st[3]
549
+ return {
550
+ key: hitKey, entity: hit.entity, node: hit.nodeName, material: hit.material, page: inst.page ?? 0,
551
+ texel: [ Math.floor(u * b.data.size), Math.floor(v * b.data.size) ],
552
+ uv1: [ hit.uv1[0], hit.uv1[1] ], world: [ hit.point[0], hit.point[1], hit.point[2] ], distance: hit.distance,
388
553
  }
389
- return header
390
554
  }
391
555
 
392
- /** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
393
- private static meshMaterial(mesh: Mesh, ambientScale: number, strength: number, aoStrength: number, lightGamma = 1): Material {
394
- const src = mesh.material
395
- const u = src?.uniforms ?? {}
556
+ /** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
557
+ private static hex8(c: unknown): string {
558
+ if (typeof c !== "string") return "#ffffffff"
559
+ if (c.length === 7) return c + "ff"
560
+ if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
561
+ return c
562
+ }
563
+
564
+ /** The lightmap material for a static Mesh, from whatever lit material it carried (colour, map, roughness, metallic). */
565
+ private static meshMaterial(mesh: Mesh): Material {
566
+ const u = mesh.material?.uniforms ?? {}
396
567
  const m = Material.lightmap()
397
- m.set("baseColorFactor", hex8(u.baseColor))
568
+ m.set("baseColorFactor", Lightmap.hex8(u.baseColor ?? u.color))
398
569
  if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
399
570
  if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
400
571
  if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
401
- m.set("ambientScale", ambientScale)
402
- m.set("sunStrength", strength)
403
- m.set("bakedAo", aoStrength)
404
- m.set("lightGamma", lightGamma)
405
572
  return m
406
573
  }
407
574
 
408
- private static swapMeshMaterial(mesh: Mesh, texture: Texture, light: Texture | null, lightScale: number, lightGamma: number, st: number[],
409
- ambientScale: number, strength: number, aoStrength: number): void {
410
- const m = Lightmap.meshMaterial(mesh, ambientScale, strength, aoStrength, lightGamma)
411
- m.set("lightmap", texture)
412
- m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
413
- if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
414
- mesh.setMaterial(m)
415
- }
416
-
417
- private static bake(cfg: BakeConfig): void {
418
- 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 }
419
- const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.terrain!.node).id))
575
+ private static bake(cfg: BakeConfig, levelNits?: number, emissiveBake = true, transmit?: Record<string, LightmapTransmit>): void {
576
+ 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 }
577
+ const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.ground!).id))
420
578
  const keys = Lightmap.entries.map((e) => e.key).join("\n")
421
579
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
422
580
  // a `baked: false` light is held dark for the bake — the engine bakes every lit point light it finds
581
+ for (const l of Light._points) l._holdDark(false) // a bake started over a loaded level
423
582
  const live = [ ...Light._points ].filter((l) => !l._baked)
424
583
  for (const l of live) _creator.setLightIntensity?.(l.id, 0)
425
- _creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
426
- cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5, cfg.volumeCell ?? 0.5, cfg.pages ?? 1, cfg.lightRays ?? 8, cfg.lightRadius ?? 0.1,
427
- cfg.bounce ?? 0, cfg.bounceDistance ?? 8, cfg.bounceRays ?? 1, cfg.coplanarSkip ?? 0.05, cfg.denoise ?? 2)
584
+ const ev100 = Lightmap.scene?.camera.ev100 ?? 15
585
+ const emissiveNits = cfg.emissiveNits ?? levelNits ?? 1.2 * 2 ** ev100
586
+ 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)}`})`)
587
+ _creator.lightmapBake(cfg.outDir, cfg.stem, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.05, cfg.pages ?? 4,
588
+ cfg.sunRays ?? 64, cfg.skyRays ?? 2048, cfg.lightRays ?? 64, cfg.lightRadius ?? 0.05, cfg.sunAngle ?? 0.53, cfg.bias ?? 0.01, cfg.seed ?? 1,
589
+ cfg.bounceRays ?? 128, cfg.bounces ?? 3, cfg.patchSamples ?? 64, cfg.split ?? false, DENOISE_LEVELS[cfg.denoise ?? "no-sun"], cfg.filter ?? 1,
590
+ 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 ?? "",
591
+ Object.entries(transmit ?? {}).map(([name, share]) => typeof share === "number" ? `${name}=${share}` : `${name}=${share.through ?? 0},${share.diffuse ?? 0}`).join(";"))
428
592
  for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
429
593
  }
430
594
  }