lecodes-sdk 0.20.2 → 1.1.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 (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -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/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -1,88 +1,81 @@
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
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>
74
69
  }
75
70
 
71
+ /** one `transmit` entry (LightmapLoadOptions.transmit): the straight share, or both shares */
72
+ export type LightmapTransmit = number | { through?: number, diffuse?: number }
73
+
76
74
  export type LightmapInfo = {
77
75
  size: number
78
76
  texel: number
79
- /** Atlas pages the bake took (`texture` lists them in order). */
77
+ /** Atlas pages the bake took (`light` / `aux` list them in order). */
80
78
  pages: number
81
- /** The light volume, when one was loaded: grid dims + cell size in metres. */
82
- volume?: { dims: number[], cell: number }
83
- /** Point lights the bake carries and this load switched off (statics read them from the atlas,
84
- * movers from the volume); 0 without a `light` atlas. */
85
- lights: number
86
79
  /** keys applied / keys in the file / registered statics without a rect */
87
80
  applied: number
88
81
  total: number
@@ -91,46 +84,121 @@ export type LightmapInfo = {
91
84
 
92
85
  export type LightmapFiles = {
93
86
  data: string
94
- /** The atlas — one page, or every page in order for a bake that took several. */
95
- texture: string | string[]
96
- /** The point lights' baked irradiance (pages like `texture`). */
97
- 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. */
98
99
  volume?: string
99
100
  }
100
101
 
101
- import type { Terrain } from "./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
+ }
102
112
 
103
- 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
+ }
104
126
 
105
- 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 }
106
129
 
107
130
  type BakeConfig = {
108
- outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
109
- aoDistance?: number, bias?: number, sunAngle?: number, volumeCell?: number,
110
- pages?: number, lightRays?: number, lightRadius?: number,
111
- /** One bounce of indirect light from the point lights, gathered on the AO rays. The value is the
112
- * ALBEDO the bounce assumes for every surface it hits (0 = off; ~0.35 is concrete). A bake with
113
- * no bounce lights only what a lamp sees directly - in an interior that leaves the walls between
114
- * the fixtures black, and a cast shadow invisible for want of light to be missing from. */
115
- bounce?: number, bounceDistance?: number, bounceRays?: number,
116
- /** Metres within which an occluder PARALLEL to the surface being lit is ignored (default 0.05).
117
- * A modular kit stacks two wall pieces in the same place; to the bake they are coincident and
118
- * the AO goes black in hard-edged patches. Real contact shadows are unaffected - they come off
119
- * surfaces at an angle. 0 turns it off. */
120
- coplanarSkip?: number,
121
- /** Radius (px) of the geometry-aware denoise over the traced texels (default 2, 0 = off): the
122
- * Monte-Carlo grain goes, edges of geometry stay. */
123
- 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,
124
170
  }
125
171
 
126
- type VolumeHeader = { texture: number, dims: number[], min: number[], cell: number, light?: number, faces?: number }
127
-
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
+ }
128
185
  type BakeData = {
129
- version?: number, size: number, texel: number, pages?: number, textures?: string[],
130
- lightTextures?: string[], lightScale?: number, lightGamma?: number,
131
- lights?: { p: number[], color: number[], cd: number, range: number }[],
132
- sun?: number[] | null,
133
- 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,
134
202
  }
135
203
 
136
204
  const bakeConfig = (): BakeConfig | null => {
@@ -138,14 +206,6 @@ const bakeConfig = (): BakeConfig | null => {
138
206
  return g && typeof g.outDir === "string" ? g : null
139
207
  }
140
208
 
141
- /** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
142
- const hex8 = (c: unknown): string => {
143
- if (typeof c !== "string") return "#ffffffff"
144
- if (c.length === 7) return c + "ff"
145
- if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
146
- return c
147
- }
148
-
149
209
  const list = (v: string | string[] | undefined): string[] => v === undefined ? [] : Array.isArray(v) ? v : [ v ]
150
210
 
151
211
  const NONE = 0xFFFFFFFF
@@ -155,21 +215,18 @@ export class Lightmap {
155
215
  static get baking(): boolean { return bakeConfig() !== null }
156
216
 
157
217
  private static entries: Entry[] = []
158
- private static dynamics: Mesh[] = []
159
218
  private static ordinals = new Map<string, number>()
160
219
  private static warned = false
161
- private static switchedOff: Light[] = []
162
-
220
+ /** The bake bound last (`load`): what `probe` reads rects from. */
221
+ private static bound: Bound | null = null
163
222
 
164
223
  /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
165
- * 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:
166
225
  * the node's name + a running number, `container#3`); pass one when names are not stable. */
167
226
  static add(node: Node, key?: string): Node {
168
227
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
169
228
  const found: Entry[] = []
170
229
  const walk = (n: Node): void => {
171
- const t = terrainOf(n)
172
- if (t) { found.push({ key: base, terrain: t }); return } // one receiver, its chunks are internal
173
230
  if (n instanceof Model) { found.push({ key: base, model: n }); return }
174
231
  if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
175
232
  for (const c of n.children) walk(c)
@@ -185,16 +242,9 @@ export class Lightmap {
185
242
  static _register(node: Model | Mesh, key: string): void {
186
243
  Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
187
244
  }
188
-
189
- /** @internal Scene-file runtime: a terrain node (its own material carries the atlas, no swap). */
190
- static _registerTerrain(terrain: Terrain, key: string): void {
191
- Lightmap.entries.push({ key, terrain })
192
- }
193
-
194
- /** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
195
- * when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
196
- static _registerDynamic(mesh: Mesh): void {
197
- 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 })
198
248
  }
199
249
 
200
250
  private static next(name: string): number {
@@ -203,23 +253,77 @@ export class Lightmap {
203
253
  return n
204
254
  }
205
255
 
206
- /** 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). */
207
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?.()
208
311
  Lightmap.entries = []
209
- Lightmap.dynamics = []
210
312
  Lightmap.ordinals.clear()
211
- Model._lightmapDefault = false
212
- for (const l of Lightmap.switchedOff) l._setBakedOff(false)
213
- Lightmap.switchedOff = []
214
- _creator.lightmapSetOptions?.(1, 1, 1, 0)
215
- _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)
216
318
  }
217
319
 
218
320
  /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
219
321
  * applied (no bake yet, a host without the feature, bake mode). */
322
+ private static scene: Scene | null = null
220
323
  static async load(scene: Scene, files: LightmapFiles, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
324
+ Lightmap.scene = scene
221
325
  const bake = bakeConfig()
222
- if (bake) { Lightmap.bake(bake); return null }
326
+ if (bake) { Lightmap.bake(bake, options.emissiveNits, options.emissiveBake ?? true, options.transmit); return null }
223
327
  if (!_creator.lightmapApply) {
224
328
  if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
225
329
  return null
@@ -234,170 +338,257 @@ export class Lightmap {
234
338
  console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
235
339
  return null
236
340
  }
237
- // the atlas pages: a bake that took more pages than the scene lists applies what it can and says so
238
- const pages = Math.max(1, data.pages ?? 1)
239
- const pageFiles = list(files.texture)
240
- if (pageFiles.length === 0) { console.warn("Lightmap: no atlas texture given"); return null }
241
- if (pageFiles.length < pages) {
242
- 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
344
+ }
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`)
243
351
  }
244
- // baked lighting is exempt from Texture.maxSize: a capped atlas blurs every shadow edge
245
- const textures = await Promise.all(pageFiles.slice(0, pages).map((f) => Texture.load(f, { fullSize: true })))
246
- // the light atlas: the bake carries the point lights only when the scene passes their atlas
247
- const lightFiles = list(files.light)
248
- let lightTextures: Texture[] = []
249
- let lightScale = 0
250
- let volumeScale = 0
251
- if (data.lightTextures?.length && (data.lightScale ?? 0) > 0) {
252
- if (lightFiles.length === 0) {
253
- 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`)
254
- } else {
255
- if (lightFiles.length < pages) console.warn(`Lightmap: the light atlas has ${pages} pages but the scene lists ${lightFiles.length}`)
256
- lightTextures = await Promise.all(lightFiles.slice(0, pages).map((f) => Texture.load(f, { fullSize: true })))
257
- lightScale = (data.lightScale ?? 0) * (options.lightBoost ?? 1)
258
- volumeScale = (data.lightScale ?? 0) * (options.volumeBoost ?? options.lightBoost ?? 1)
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`)
259
376
  }
260
377
  }
261
- const ambientScale = options.ambientScale ?? 1
262
- const strength = options.sunStrength ?? 1
263
- const aoStrength = options.aoStrength ?? 1
264
- // the light store's curve: sRGB(pow(E / lightScale, 1 / lightGamma)); bakes before 2026-08-31 are linear (1)
265
- const lightGamma = data.lightGamma ?? 1
266
- // level-wide knobs first: the volume and every rect below inherit them
267
- _creator.lightmapSetOptions?.(ambientScale, strength, aoStrength, lightScale, volumeScale, lightGamma)
268
- // what the bake actually BOUND, so a level can be debugged without guessing (FEEDBACK 95): the
269
- // scale is the one number standing between the atlas' physical irradiance and the pixel
270
- console.log(`[lightmap] bound ${textures.length} page(s)${lightTextures.length ? ` + ${lightTextures.length} light page(s)` : ""}`
271
- + ` lightScale ${lightScale.toFixed(2)}${(options.lightBoost ?? 1) !== 1 ? ` (bake ${(data.lightScale ?? 0).toFixed(2)} x boost ${options.lightBoost})` : ""}`
272
- + `${volumeScale !== lightScale ? ` volumeScale ${volumeScale.toFixed(2)}` : ""}`
273
- + ` gamma ${lightGamma} ao ${aoStrength} ambient ${ambientScale} sun ${strength}`)
274
- // the light volume next: it claims every lightmap-material instance, the atlas rects below win back the statics
275
- const volume = files.volume ? await Lightmap.loadVolume(files.volume, volumeScale, lightGamma, ambientScale, strength, aoStrength) : null
276
- const rects = new Map<string, { st: number[], page: number }>()
277
- for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, { st: inst.st, page: inst.page ?? 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>()
278
392
  let applied = 0
279
393
  const missing: string[] = []
280
394
  for (const e of Lightmap.entries) {
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)
406
+ }
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++
410
+ continue
411
+ }
281
412
  const r = rects.get(e.key)
282
- if (!r || r.page >= textures.length) { missing.push(e.key); continue }
283
- const st = r.st
284
- const texture = textures[r.page]
285
- const light = r.page < lightTextures.length ? lightTextures[r.page] : null
286
- if (e.model) {
287
- const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], light ? light._id : NONE)
288
- if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
289
- e.model.castShadows = false
290
- } else if (e.mesh) {
291
- Lightmap.swapMeshMaterial(e.mesh, texture, light, light ? lightScale : 0, lightGamma, st, ambientScale, strength, aoStrength)
292
- e.mesh.castShadows = false
293
- } else if (e.terrain) {
294
- // terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
295
- const m = e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
296
- .set("ambientScale", ambientScale).set("sunStrength", strength).set("bakedAo", aoStrength).set("lightGamma", lightGamma)
297
- if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
298
- else m.set("lightScale", 0)
299
- e.terrain.castShadows = false
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
300
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 }
301
430
  applied++
302
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`)
303
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`)
304
- // the lights the bake carries go dark: the atlas holds them for the statics, the volume for the movers
305
- let lights = 0
306
- if (lightTextures.length > 0 && data.lights?.length) lights = Lightmap.switchOffBaked(data.lights)
307
- const info: LightmapInfo = { size: data.size, texel: data.texel, pages, applied, total: rects.size, missing, lights }
308
- if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
309
- 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 }
437
+ }
438
+
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)
310
461
  }
311
462
 
312
- /** Every live point light within 5 cm of a baked one is held dark (a moved lamp keeps lighting live
313
- * — and double, until the next bake, which is the honest state of a stale bake). */
314
- private static switchOffBaked(baked: { p: number[] }[]): number {
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"
315
502
  let n = 0
316
- for (const light of Light._points) {
317
- if (!light._baked) continue
318
- const wp = light.worldPosition
319
- const x = cx(wp), y = cy(wp), z = cz(wp)
320
- 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)
321
- if (!hit) continue
322
- light._setBakedOff(true)
323
- 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
324
513
  n++
325
514
  }
515
+ Lightmap.scene?.add(holder)
516
+ Lightmap.balls = holder
517
+ console.log(`[lightmap] ${n} probe ball(s)`)
326
518
  return n
327
519
  }
328
520
 
329
- /** lightmap.volume → the engine's 3D textures → THE volume for every dynamic lightmap-material instance,
330
- * plus the registered dynamic Meshes. Null when the file is a placeholder or the host lacks the feature. */
331
- private static async loadVolume(url: string, lightScale: number, lightGamma: number, ambientScale: number, strength: number, aoStrength: number): Promise<VolumeHeader | null> {
332
- if (!_creator.lightmapVolumeLoad || !_creator.lightmapVolumeSet) return null
333
- let header: VolumeHeader
334
- try {
335
- const resp = await fetch(url, { useOnce: true })
336
- if (resp.status >= 400) { console.warn(`Lightmap: no volume at ${url} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
337
- const json = _creator.lightmapVolumeLoad((resp as unknown as { _id: number })._id)
338
- resp.dispose()
339
- if (!json) { console.warn(`Lightmap: ${url} is not a light volume — rebake`); return null }
340
- header = JSON.parse(json) as VolumeHeader
341
- } catch (e) {
342
- console.warn(`Lightmap: could not read ${url}: ${String(e)}`)
343
- 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
344
539
  }
345
- const [ nx, ny, nz ] = header.dims
346
- if (nx * ny * nz <= 1) return null // the CLI's placeholder: nothing baked yet
347
- const size = [ nx * header.cell, ny * header.cell, nz * header.cell ]
348
- const texture = new Texture(nx, ny, header.texture)
349
- const hasLight = header.light !== undefined && lightScale > 0
350
- const light = hasLight ? new Texture(nx, ny, header.light!) : null
351
- if (hasLight && (header.faces ?? 1) < 6) console.warn("Lightmap: the light volume predates the ambient cube (one directionless value per cell) — rebake")
352
- _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)
353
- for (const mesh of Lightmap.dynamics) {
354
- const m = Lightmap.meshMaterial(mesh, ambientScale, strength, aoStrength, lightGamma)
355
- m.set("probeVolume", texture)
356
- if (light) m.set("probeLight", light).set("lightScale", lightScale)
357
- m.set("probeMin", [ header.min[0], header.min[1], header.min[2], header.cell * 0.5 ])
358
- // .w = the grid's depth: the shader keeps its fetch inside one face slab of the light texture
359
- m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], nz ])
360
- 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,
361
553
  }
362
- return header
363
554
  }
364
555
 
365
- /** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
366
- private static meshMaterial(mesh: Mesh, ambientScale: number, strength: number, aoStrength: number, lightGamma = 1): Material {
367
- const src = mesh.material
368
- 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 ?? {}
369
567
  const m = Material.lightmap()
370
- m.set("baseColorFactor", hex8(u.baseColor))
568
+ m.set("baseColorFactor", Lightmap.hex8(u.baseColor ?? u.color))
371
569
  if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
372
570
  if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
373
571
  if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
374
- m.set("ambientScale", ambientScale)
375
- m.set("sunStrength", strength)
376
- m.set("bakedAo", aoStrength)
377
- m.set("lightGamma", lightGamma)
378
572
  return m
379
573
  }
380
574
 
381
- private static swapMeshMaterial(mesh: Mesh, texture: Texture, light: Texture | null, lightScale: number, lightGamma: number, st: number[],
382
- ambientScale: number, strength: number, aoStrength: number): void {
383
- const m = Lightmap.meshMaterial(mesh, ambientScale, strength, aoStrength, lightGamma)
384
- m.set("lightmap", texture)
385
- m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
386
- if (light) m.set("lightmapLight", light).set("lightScale", lightScale)
387
- mesh.setMaterial(m)
388
- }
389
-
390
- private static bake(cfg: BakeConfig): void {
391
- 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 }
392
- 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))
393
578
  const keys = Lightmap.entries.map((e) => e.key).join("\n")
394
579
  if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
395
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
396
582
  const live = [ ...Light._points ].filter((l) => !l._baked)
397
583
  for (const l of live) _creator.setLightIntensity?.(l.id, 0)
398
- _creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
399
- 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,
400
- 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(";"))
401
592
  for (const l of live) _creator.setLightIntensity?.(l.id, l.intensity)
402
593
  }
403
594
  }