lecodes-cli 0.18.0 → 0.18.2

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2013 -564
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/core/Aspect.ts +512 -255
  6. package/runtime/sdk/core/compWrite.ts +42 -0
  7. package/runtime/sdk/core/fields.ts +1 -1
  8. package/runtime/sdk/core/time.ts +81 -0
  9. package/runtime/sdk/g2/Camera2D.ts +8 -1
  10. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  11. package/runtime/sdk/g2/Node2D.ts +80 -10
  12. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  13. package/runtime/sdk/g2/Physics2D.ts +240 -30
  14. package/runtime/sdk/g2/Scene2D.ts +33 -1
  15. package/runtime/sdk/g2/Shape2D.ts +218 -22
  16. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  17. package/runtime/sdk/g2/groups2d.ts +106 -0
  18. package/runtime/sdk/g2/loop.ts +15 -4
  19. package/runtime/sdk/gl/Camera.ts +40 -1
  20. package/runtime/sdk/gl/CameraPlace.ts +52 -51
  21. package/runtime/sdk/gl/CharacterController.ts +184 -56
  22. package/runtime/sdk/gl/Gearbox.ts +212 -0
  23. package/runtime/sdk/gl/Geometry.ts +70 -9
  24. package/runtime/sdk/gl/Light.ts +64 -2
  25. package/runtime/sdk/gl/Lightmap.ts +179 -0
  26. package/runtime/sdk/gl/Material.ts +25 -0
  27. package/runtime/sdk/gl/Mesh.ts +6 -23
  28. package/runtime/sdk/gl/Model.ts +16 -2
  29. package/runtime/sdk/gl/Node.ts +119 -39
  30. package/runtime/sdk/gl/Physics.ts +75 -24
  31. package/runtime/sdk/gl/Scene.ts +161 -5
  32. package/runtime/sdk/gl/Shape.ts +42 -3
  33. package/runtime/sdk/gl/Trigger.ts +45 -50
  34. package/runtime/sdk/gl/Vehicle.ts +276 -322
  35. package/runtime/sdk/gl/Wheel.ts +240 -0
  36. package/runtime/sdk/gl/scenarios.ts +4 -30
  37. package/runtime/sdk/gl/state.ts +6 -6
  38. package/runtime/sdk/inject.ts +186 -171
  39. package/runtime/sdk/runtime/device.ts +102 -1
  40. package/runtime/sdk/scene/defineScene.ts +108 -53
  41. package/runtime/sdk/scene/gizmos.ts +31 -11
  42. package/runtime/sdk/scene/material.ts +188 -0
  43. package/runtime/sdk-types.json +1 -1
  44. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  45. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  46. package/runtime/sdk/compile/assetMacro.ts +0 -45
  47. package/runtime/sdk/compile/assetName.ts +0 -50
  48. package/runtime/sdk/compile/bundler.ts +0 -252
  49. package/runtime/sdk/compile/compileProject.ts +0 -129
  50. package/runtime/sdk/compile/detectEntry.ts +0 -128
  51. package/runtime/sdk/compile/fontMacro.ts +0 -459
  52. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  53. package/runtime/sdk/compile/header.ts +0 -67
  54. package/runtime/sdk/compile/index.ts +0 -85
  55. package/runtime/sdk/compile/libraryImports.ts +0 -52
  56. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  57. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  58. package/runtime/sdk/compile/serverSplit.ts +0 -233
  59. package/runtime/sdk/compile/serverTypes.ts +0 -227
  60. package/runtime/sdk/compile/sfnt.ts +0 -98
  61. package/runtime/sdk/compile/shaderTargets.ts +0 -42
  62. package/runtime/sdk/compile/sourcemap.ts +0 -25
@@ -20,6 +20,11 @@ export class Geometry {
20
20
  normals: Float32Array
21
21
  indices: Uint16Array
22
22
  uv: Float32Array
23
+ /** Lightmap UV set (2 floats per vertex, non-overlapping, inside the unit square) — the
24
+ * `Lightmap` atlas samples a Mesh through it. The box and cylinder builders fill it (their `uv`
25
+ * tiles per face, which would fold every face onto the same texels); absent = the host reuses
26
+ * `uv`, which is right for a plane. */
27
+ uv1?: Float32Array
23
28
  /** @internal native mesh-type enum (0 triangles / 1 edges / 2 vertices). */
24
29
  _meshKind = 0
25
30
 
@@ -50,29 +55,75 @@ export class Geometry {
50
55
  this._meshKind = kind === "edges" ? 1 : kind === "vertices" ? 2 : 0
51
56
  }
52
57
 
53
- static box(): Geometry { return createCube() }
58
+ /** A box; `size` (default 1) scales the vertices AND lays the lightmap chart out in proportion to
59
+ * the faces' areas (a floor's top face gets the texels, not its 1 m side strips). */
60
+ static box(size: Vec3Like | number = 1): Geometry { return createCube(size) }
54
61
  static sphere(options?: SphereOptions): Geometry { return createSphere(options) }
55
62
  static cylinder(options?: CylinderOptions): Geometry { return createCylinder(options) }
56
63
  static plane(options?: PlaneOptions): Geometry { return createPlane(options) }
57
64
  }
58
65
 
66
+ // Gutter between lightmap charts (fraction of the chart square): the bake dilates each rect by a
67
+ // few texels and the shader samples bilinearly, so neighbouring faces must not touch in uv1.
68
+ const LM_GUTTER = 0.03
69
+
70
+ type Rect = { x: number, y: number, w: number, h: number }
71
+
72
+ /** Shelf-pack rectangles (metres) into the unit square at the largest uniform scale — every face
73
+ * gets texels in proportion to its area, so a 64×1×64 floor's top face isn't squeezed into the
74
+ * same cell as its 1 m side strips. Returns rects in input order. */
75
+ const packCharts = (dims: [number, number][]): Rect[] => {
76
+ const order = dims.map((_, i) => i).sort((a, b) => dims[b][1] - dims[a][1])
77
+ const attempt = (scale: number): Rect[] | null => {
78
+ const out: Rect[] = new Array(dims.length)
79
+ let x = 0, y = 0, rowH = 0
80
+ for (const i of order) {
81
+ const w = dims[i][0] * scale, h = dims[i][1] * scale
82
+ if (x > 0 && x + w > 1) { y += rowH + LM_GUTTER; x = 0; rowH = 0 }
83
+ if (w > 1 || y + h > 1) return null
84
+ out[i] = { x, y, w, h }
85
+ x += w + LM_GUTTER
86
+ rowH = Math.max(rowH, h)
87
+ }
88
+ return out
89
+ }
90
+ let lo = 0, hi = 1 / Math.max(...dims.map((d) => Math.max(d[0], d[1])))
91
+ let best = attempt(hi)
92
+ if (!best) {
93
+ for (let it = 0; it < 40; it++) {
94
+ const mid = (lo + hi) / 2
95
+ const r = attempt(mid)
96
+ if (r) { best = r; lo = mid } else hi = mid
97
+ }
98
+ }
99
+ return best ?? dims.map(() => ({ x: 0, y: 0, w: 1, h: 1 }))
100
+ }
101
+
59
102
  // --- cube ---
60
- const createCube = (): Geometry => {
103
+ const createCube = (size: Vec3Like | number = 1): Geometry => {
104
+ const sx = typeof size === "number" ? size : cx(size)
105
+ const sy = typeof size === "number" ? size : cy(size)
106
+ const sz = typeof size === "number" ? size : cz(size)
61
107
  const vertices = new Float32Array(6 * 4 * 3)
62
108
  const normals = new Float32Array(6 * 4 * 3)
63
- const uv = new Float32Array(6 * 4 * 3)
109
+ const uv = new Float32Array(6 * 4 * 2)
110
+ const uv1 = new Float32Array(6 * 4 * 2)
64
111
  const indices = new Uint16Array(6 * 6)
65
112
  let cnt = 0, indicesCnt = 0
66
113
  const sides = [ -0.5, 0.5 ]
114
+ // face extents along the (a, b) parameters below: ±x faces span (z, y), ±y faces (x, z), ±z faces (x, y)
115
+ const charts = packCharts([ [ sz, sy ], [ sz, sy ], [ sx, sz ], [ sx, sz ], [ sx, sy ], [ sx, sy ] ])
67
116
  for (let i = 0; i < 6; i++) {
68
117
  const s = cnt
69
118
  const side = i % 2 === 0 ? -0.5 : 0.5
119
+ const c = charts[i]
70
120
  for (const a of sides) {
71
121
  for (const b of sides) {
72
122
  uv.set([ a + 0.5, b + 0.5 ], cnt * 2)
73
- if (i === 0 || i === 1) { vertices.set([ side, b, -a ], cnt * 3); normals.set([ side * 2, 0, 0 ], cnt * 3) }
74
- if (i === 2 || i === 3) { vertices.set([ a, side, -b ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3) }
75
- if (i === 4 || i === 5) { vertices.set([ a, b, side ], cnt * 3); normals.set([ 0, 0, side * 2 ], cnt * 3) }
123
+ uv1.set([ c.x + (a + 0.5) * c.w, c.y + (b + 0.5) * c.h ], cnt * 2)
124
+ if (i === 0 || i === 1) { vertices.set([ side * sx, b * sy, -a * sz ], cnt * 3); normals.set([ side * 2, 0, 0 ], cnt * 3) }
125
+ if (i === 2 || i === 3) { vertices.set([ a * sx, side * sy, -b * sz ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3) }
126
+ if (i === 4 || i === 5) { vertices.set([ a * sx, b * sy, side * sz ], cnt * 3); normals.set([ 0, 0, side * 2 ], cnt * 3) }
76
127
  cnt++
77
128
  }
78
129
  }
@@ -81,7 +132,9 @@ const createCube = (): Geometry => {
81
132
  else indices.set([ s2, s1, s, s1, s2, s3 ], indicesCnt)
82
133
  indicesCnt += 6
83
134
  }
84
- return new Geometry(vertices, normals, indices, uv)
135
+ const g = new Geometry(vertices, normals, indices, uv)
136
+ g.uv1 = uv1
137
+ return g
85
138
  }
86
139
 
87
140
  // --- sphere ---
@@ -143,6 +196,8 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
143
196
  const vertices = new Float32Array(verticesCount * 3)
144
197
  const normals = new Float32Array(verticesCount * 3)
145
198
  const uv = new Float32Array(verticesCount * 2)
199
+ // lightmap chart: the side strip across the upper half, the two caps as discs in the lower half
200
+ const uv1 = new Float32Array(verticesCount * 2)
146
201
  const indices = new Uint16Array(edges * 6 + edges * 6)
147
202
  const sides = [ -0.5, 0.5 ]
148
203
  let cnt = 0, indicesCnt = 0
@@ -159,6 +214,7 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
159
214
  if (smooth) normals.set([ x, 0, y ], cnt * 3)
160
215
  else normals.set([ normalX, 0, normalY ], cnt * 3)
161
216
  uv.set([ (i + j) / edges, side + 0.5 ], cnt * 2)
217
+ uv1.set([ (i + j) / edges, 0.55 + (side + 0.5) * 0.43 ], cnt * 2)
162
218
  cnt++
163
219
  }
164
220
  }
@@ -168,19 +224,24 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
168
224
  }
169
225
  for (const side of sides) {
170
226
  const center = cnt
171
- vertices.set([ 0, side, 0 ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3); cnt++
227
+ const capU = side > 0 ? 0.75 : 0.25 // lightmap disc centre per cap
228
+ vertices.set([ 0, side, 0 ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3)
229
+ uv1.set([ capU, 0.25 ], cnt * 2); cnt++
172
230
  for (let i = 0; i < edges; i++) {
173
231
  const x = Math.cos(i / edges * Math.PI * 2), y = Math.sin(i / edges * Math.PI * 2)
174
232
  if (side > 0) vertices.set([ x * radiusTop, side, y * radiusTop ], cnt * 3)
175
233
  else vertices.set([ x * radiusBottom, side, y * radiusBottom ], cnt * 3)
176
234
  normals.set([ 0, side * 2, 0 ], cnt * 3)
235
+ uv1.set([ capU + x * 0.22, 0.25 + y * 0.22 ], cnt * 2)
177
236
  if (side < 0) indices.set([ center, center + i + 1, center + 1 + ((i + 1) % edges) ], indicesCnt)
178
237
  else indices.set([ center + 1 + ((i + 1) % edges), center + i + 1, center ], indicesCnt)
179
238
  indicesCnt += 3
180
239
  cnt++
181
240
  }
182
241
  }
183
- return new Geometry(vertices, normals, indices, uv)
242
+ const g = new Geometry(vertices, normals, indices, uv)
243
+ g.uv1 = uv1
244
+ return g
184
245
  }
185
246
 
186
247
  // --- plane ---
@@ -1,4 +1,5 @@
1
- // Lights. Currently a directional sun (the engine's primary light). Add it to a scene like any node.
1
+ // Lights. A directional sun (the engine's primary light) and punctual point lights. Add one to a
2
+ // scene like any node; `intensity` and `color` stay writable so a light can be animated.
2
3
 
3
4
  import { Color, type ColorInput } from "../core/color"
4
5
  import type { Vec3Like } from "../math/vec"
@@ -21,17 +22,78 @@ export type SunOptions = {
21
22
  shadowsQuality?: 0 | 1 | 2 | 3
22
23
  }
23
24
 
25
+ export type PointOptions = {
26
+ /**
27
+ * Luminous POWER in lumens — the same physical scale as the sun's lux and `environmentIntensity`,
28
+ * so a light keeps its look when the scene's exposure changes. A candle is ~12 lm, a bare 60 W
29
+ * bulb ~800, a car headlight ~1500, a fireball millions. Default 1000.
30
+ */
31
+ intensity?: number
32
+ color?: ColorInput
33
+ /**
34
+ * Metres of influence — past it the light contributes nothing. This is the performance knob:
35
+ * overlapping point lights are the expensive case, so keep it as small as the look allows.
36
+ * Default 10.
37
+ */
38
+ range?: number
39
+ /** Point-light shadows are a cubemap render per light; off by default. */
40
+ castShadows?: boolean
41
+ }
42
+
24
43
  export class Light extends Node {
44
+ private _intensity = 0
45
+ /** @internal sun direction as created (the engine keeps it; Lightmap.load reads it back). */
46
+ _direction: [number, number, number] = [ 0, -1, 0 ]
47
+ /** The most recently created sun — what `Lightmap.load` uses unless told otherwise. */
48
+ static lastSun: Light | null = null
49
+
25
50
  /** A directional sun light. */
26
51
  static sun(options: SunOptions = {}): Light {
27
52
  const light = new Light()
28
53
  const [ dx, dy, dz ] = options.direction ?? [ 0.548267, -0.473983, -0.689016 ]
54
+ light._direction = [ dx, dy, dz ]
55
+ light._intensity = options.intensity ?? 100000
56
+ Light.lastSun = light
29
57
  _creator.createSunLight(
30
58
  light.id, dx, dy, dz,
31
- options.intensity ?? 100000,
59
+ light._intensity,
32
60
  Color.toPackedRgb(options.color ?? 0xffffff),
33
61
  options.shadowsQuality ?? 1,
34
62
  )
35
63
  return light
36
64
  }
65
+
66
+ /**
67
+ * A point light — a lamp, a muzzle flash, a fireball. Position it like any node.
68
+ *
69
+ * Feature-detected: hosts that predate it create the node and light nothing, which keeps a scene
70
+ * that adds atmosphere on top of its sun renderable everywhere. Check `Light.supportsPoint`
71
+ * before making one carry the scene.
72
+ */
73
+ static point(options: PointOptions = {}): Light {
74
+ const light = new Light()
75
+ light._intensity = options.intensity ?? 1000
76
+ _creator.createPointLight?.(
77
+ light.id,
78
+ light._intensity,
79
+ Color.toPackedRgb(options.color ?? 0xffffff),
80
+ options.range ?? 10,
81
+ options.castShadows ?? false,
82
+ )
83
+ return light
84
+ }
85
+
86
+ /** Whether this host can create point lights at all. */
87
+ static get supportsPoint(): boolean { return _creator.createPointLight !== undefined }
88
+
89
+ /** Live intensity (sun: lux, point: lumens) — animate a flash without rebuilding the light. */
90
+ get intensity(): number { return this._intensity }
91
+ set intensity(value: number) {
92
+ this._intensity = value
93
+ _creator.setLightIntensity?.(this.id, value)
94
+ }
95
+
96
+ set color(value: ColorInput) {
97
+ _creator.setLightColor?.(this.id, Color.toPackedRgb(value))
98
+ }
37
99
  }
@@ -0,0 +1,179 @@
1
+ // Baked shadows 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
3
+ // that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
4
+ // and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
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:
8
+ //
9
+ // const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
10
+ // Lightmap.add(holder) // a Model, a Mesh, or a node holding them
11
+ // Lightmap.add(ground) // Mesh.plane works too
12
+ // ...
13
+ // await Lightmap.load(scene, { data: asset('./assets/lightmap/lightmap.bake'),
14
+ // texture: asset('./assets/lightmap/lightmap.ktx2') })
15
+ //
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.
22
+
23
+ import { fetch } from "../runtime/fetch"
24
+ import { Texture } from "./Texture"
25
+ import { Material } from "./Material"
26
+ import { Mesh } from "./Mesh"
27
+ import { Model } from "./Model"
28
+ import { Node } from "./Node"
29
+ import type { Scene } from "./Scene"
30
+
31
+ export type LightmapLoadOptions = {
32
+ /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
33
+ sunStrength?: number
34
+ /** Multiplier on the ambient (IBL) share in the shadow math — how bright a fully shadowed texel
35
+ * stays. 1 reproduces filament's own real-time shadow darkness (the shader reads the scene's sun
36
+ * and IBL from filament's per-frame uniforms); raise it for lighter shadows, lower for deeper. Default 1. */
37
+ ambientScale?: number
38
+ }
39
+
40
+ export type LightmapInfo = {
41
+ size: number
42
+ texel: number
43
+ /** keys applied / keys in the file / registered statics without a rect */
44
+ applied: number
45
+ total: number
46
+ missing: string[]
47
+ }
48
+
49
+ type Entry = { key: string, model?: Model, mesh?: Mesh }
50
+
51
+ type BakeConfig = {
52
+ outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
53
+ aoDistance?: number, bias?: number, sunAngle?: number,
54
+ }
55
+
56
+ const bakeConfig = (): BakeConfig | null => {
57
+ const g = (globalThis as unknown as { __lecodesLightmap?: BakeConfig }).__lecodesLightmap
58
+ return g && typeof g.outDir === "string" ? g : null
59
+ }
60
+
61
+ /** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
62
+ const hex8 = (c: unknown): string => {
63
+ if (typeof c !== "string") return "#ffffffff"
64
+ if (c.length === 7) return c + "ff"
65
+ if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
66
+ return c
67
+ }
68
+
69
+ export class Lightmap {
70
+ /** True while `lecodes lightmap bake` runs the app — skip menus and build the scene straight away. */
71
+ static get baking(): boolean { return bakeConfig() !== null }
72
+
73
+ private static entries: Entry[] = []
74
+ private static ordinals = new Map<string, number>()
75
+ private static warned = false
76
+
77
+ /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
78
+ * both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
79
+ * the node's name + a running number, `container#3`); pass one when names are not stable. */
80
+ static add(node: Node, key?: string): Node {
81
+ const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
82
+ const found: Entry[] = []
83
+ const walk = (n: Node): void => {
84
+ if (n instanceof Model) { found.push({ key: base, model: n }); return }
85
+ if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
86
+ for (const c of n.children) walk(c)
87
+ }
88
+ walk(node)
89
+ if (found.length === 0) { console.warn(`Lightmap.add: ${base} holds no Model or Mesh`); return node }
90
+ if (found.length > 1) found.forEach((e, i) => { e.key = `${base}/${i}` })
91
+ Lightmap.entries.push(...found)
92
+ return node
93
+ }
94
+
95
+ /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk). */
96
+ static _register(node: Model | Mesh, key: string): void {
97
+ Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
98
+ }
99
+
100
+ private static next(name: string): number {
101
+ const n = Lightmap.ordinals.get(name) ?? 0
102
+ Lightmap.ordinals.set(name, n + 1)
103
+ return n
104
+ }
105
+
106
+ /** Forget every registration (a scene rebuild). */
107
+ static clear(): void {
108
+ Lightmap.entries = []
109
+ Lightmap.ordinals.clear()
110
+ }
111
+
112
+ /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
113
+ * applied (no bake yet, a host without the feature, bake mode). */
114
+ static async load(scene: Scene, files: { data: string, texture: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
115
+ const bake = bakeConfig()
116
+ if (bake) { Lightmap.bake(bake); return null }
117
+ if (!_creator.lightmapApply) {
118
+ if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
119
+ return null
120
+ }
121
+ let data: { size: number, texel: number, sun?: number[] | null, instances: { key: string, receiver: boolean, st?: number[] }[] }
122
+ try {
123
+ const resp = await fetch(files.data, { useOnce: true })
124
+ if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${files.data} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
125
+ data = resp.json()
126
+ resp.dispose()
127
+ } catch (e) {
128
+ console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
129
+ return null
130
+ }
131
+ const texture = await Texture.load(files.texture)
132
+ const ambientScale = options.ambientScale ?? 1
133
+ const strength = options.sunStrength ?? 1
134
+ const rects = new Map<string, number[]>()
135
+ for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, inst.st)
136
+ let applied = 0
137
+ const missing: string[] = []
138
+ for (const e of Lightmap.entries) {
139
+ const st = rects.get(e.key)
140
+ if (!st) { missing.push(e.key); continue }
141
+ if (e.model) {
142
+ const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], ambientScale, strength)
143
+ if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
144
+ _creator.setGlbShadows?.(e.model.id, false, true)
145
+ } else if (e.mesh) {
146
+ Lightmap.swapMeshMaterial(e.mesh, texture, st, ambientScale, strength)
147
+ e.mesh.castShadows = false
148
+ }
149
+ applied++
150
+ }
151
+ if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
152
+ return { size: data.size, texel: data.texel, applied, total: rects.size, missing }
153
+ }
154
+
155
+ /** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
156
+ private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
157
+ const src = mesh.material
158
+ const u = src?.uniforms ?? {}
159
+ const m = Material.lightmap()
160
+ m.set("baseColorFactor", hex8(u.baseColor))
161
+ if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
162
+ if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
163
+ if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
164
+ m.set("lightmap", texture)
165
+ m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
166
+ m.set("ambientScale", ambientScale)
167
+ m.set("sunStrength", strength)
168
+ mesh.setMaterial(m)
169
+ }
170
+
171
+ private static bake(cfg: BakeConfig): void {
172
+ if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with CREATOR_GL_LIGHTMAP, or update lecodes desktop)"); return }
173
+ const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh)!.id))
174
+ const keys = Lightmap.entries.map((e) => e.key).join("\n")
175
+ if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
176
+ _creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
177
+ cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5)
178
+ }
179
+ }
@@ -58,6 +58,12 @@ export type ParticlesMaterialOptions = {
58
58
  blend?: "alpha" | "add"
59
59
  /** Soft radial falloff toward the sprite edge. Defaults to true without a map, false with one. */
60
60
  soft?: boolean
61
+ /** Soft particles: fade each sprite out over this many metres as it cuts into the scene geometry
62
+ * behind it (Unity's "Soft Particles Factor"), so smoke and dust no longer slice through the
63
+ * ground and walls with a hard edge. 0 = off (default). Reads the scene's depth buffer, which the
64
+ * engine keeps bound while any soft system is on screen — the extra depth pass is only paid then.
65
+ * Filament hosts (desktop / web / Android / Apple); web-lite draws such sprites unfaded. */
66
+ depthFade?: number
61
67
  /** How each particle is drawn: `'point'` (default — GPU point sprites, cheapest), `'quad'`
62
68
  * (real camera-facing quads — no driver size cap, geometric rotation), or `'stretch'`
63
69
  * (quads stretched along velocity — sparks, rain, speed streaks). `'ribbon'` is what `Trail`
@@ -169,6 +175,7 @@ export class Material {
169
175
  m.uniforms.emissive = options.emissive ?? 0
170
176
  m.uniforms.additive = options.blend === "add" ? 1 : 0
171
177
  m.uniforms.softness = (options.soft ?? !options.map) ? 1 : 0
178
+ m.uniforms.depthFade = options.depthFade ?? 0
172
179
  if (quad) {
173
180
  m.uniforms.mode = options.render === "ribbon" ? 2 : options.render === "stretch" ? 1 : 0
174
181
  m.uniforms.stretch = options.stretch ?? (options.render === "stretch" ? 0.05 : 0)
@@ -188,6 +195,24 @@ export class Material {
188
195
  return m
189
196
  }
190
197
 
198
+ /** The lightmap material (docs/lightmap-plan.md): PBR base colour × a baked shadow/AO atlas on UV1.
199
+ * Models take it through `Model.load(…, { lightmap: true })`; `Lightmap.load` builds one per static
200
+ * Mesh. Parameters use gltfio's names (`baseColorFactor`, `baseColorMap`, `roughnessFactor`,
201
+ * `metallicFactor`) plus `lightmap`, `lightmapST`, `ambientScale`, `sunStrength` (the sun / IBL terms come from
202
+ * filament's per-frame uniforms inside the shader). */
203
+ static lightmap(): Material {
204
+ // Inline literal: the compiler's preload header captures the shader name from it.
205
+ const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
206
+ m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
207
+ m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
208
+ return m
209
+ }
210
+
211
+ /** @internal the shared template instance `Model.load({ lightmap })` hands the engine (its MATERIAL is what
212
+ * the GLB provider instantiates; the template itself never draws). */
213
+ static _lightmapTemplate(): Material { return (Material._lmTemplate ??= Material.lightmap()) }
214
+ private static _lmTemplate: Material | undefined
215
+
191
216
  /** Shadow-catcher material (transparent except where shadows fall). */
192
217
  static shadow(color: ColorInput = "#000000aa"): Material {
193
218
  const m = new Material({ _id: _creatorUtils.fetchLocal("shadow.filamat") } as any)
@@ -24,7 +24,6 @@ export type MeshOptions = {
24
24
 
25
25
  export class Mesh extends Node {
26
26
  private _geometry?: Geometry
27
- private _materials: Material[] = []
28
27
 
29
28
  constructor(geometry?: Geometry, material?: Material) {
30
29
  super()
@@ -32,29 +31,15 @@ export class Mesh extends Node {
32
31
  const mat = material ?? Material.unlit()
33
32
  this._geometry = geometry
34
33
  this._materials = [ mat ]
35
- _creator.setMesh(this.id, Material.idOf(mat), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, geometry._meshKind)
34
+ _creator.setMesh(this.id, Material.idOf(mat), geometry.vertices, geometry.normals, geometry.indices, geometry.uv, geometry._meshKind, geometry.uv1)
36
35
  }
37
36
  }
38
37
 
39
38
  override get geometry(): Geometry | undefined { return this._geometry }
40
39
 
41
- get material(): Material { return this._materials[0] }
42
- set material(m: Material) { this.setMaterial(m, 0) }
43
-
44
- setMaterial(material: Material, index = 0): this {
45
- this._materials[index] = material
46
- _creator.setMaterial(this.id, Material.idOf(material), index)
47
- return this
48
- }
49
- getMaterial(index = 0): Material | null {
50
- const existing = this._materials[index]
51
- if (existing) return existing
52
- const id = _creator.getMaterial(this.id, index)
53
- if (id === 0) return null
54
- const mat = new Material(id)
55
- this._materials[index] = mat
56
- return mat
57
- }
40
+ /** A Mesh always carries a material (slot 0) — see Node.setMaterial for the slot API. */
41
+ override get material(): Material { return this._materials![0]! }
42
+ override set material(m: Material) { this.setMaterial(m, 0) }
58
43
 
59
44
  set culling(v: boolean) { _creator.setCulling(this.id, v) }
60
45
  set castShadows(v: boolean) { _creator.setCastShadows(this.id, v) }
@@ -63,10 +48,8 @@ export class Mesh extends Node {
63
48
  // --- factories ---
64
49
 
65
50
  static box(options: MeshOptions & { size?: Vec3Like | number } = {}): Mesh {
66
- const geo = Geometry.box()
67
- const [ sx, sy, sz ] = sizeToVec(options.size ?? 1)
68
- if (sx !== 1 || sy !== 1 || sz !== 1) geo.scale(sx, sy, sz)
69
- return apply(new Mesh(geo, options.material), options)
51
+ // the size goes into the geometry (not a scale after): the lightmap chart is laid out per face area
52
+ return apply(new Mesh(Geometry.box(options.size ?? 1), options.material), options)
70
53
  }
71
54
 
72
55
  static sphere(options: MeshOptions & SphereOptions = {}): Mesh {
@@ -7,11 +7,14 @@
7
7
  import { fetch, type FetchResponse } from "../runtime/fetch"
8
8
  import { Node } from "./Node"
9
9
  import { Animator } from "./animation/Animator"
10
+ import { Material } from "./Material"
10
11
 
11
12
  export class Model extends Node {
12
13
  /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
13
14
  * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
14
15
  declare readonly anim: Animator
16
+ /** @internal loaded through the lightmap material (docs/lightmap-plan.md); clones inherit it. */
17
+ _lightmapped = false
15
18
 
16
19
  constructor(internalId: number) {
17
20
  super(internalId)
@@ -24,13 +27,21 @@ export class Model extends Node {
24
27
  clone(): Model {
25
28
  const id = _creator.cloneEntity(this.id)
26
29
  _creator.setGlbCulling(id, false)
27
- return new Model(id)
30
+ const m = new Model(id)
31
+ m._lightmapped = this._lightmapped
32
+ return m
28
33
  }
29
34
 
30
35
  /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
31
36
  static load(
32
37
  source: string | FetchResponse,
33
- options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {},
38
+ options: {
39
+ culling?: boolean
40
+ onProgress?: (p: { loaded: number, total?: number }) => void
41
+ /** Load through the lightmap material so `Lightmap.load` can bind a baked atlas (the GLB needs
42
+ * TEXCOORD_1 — `lecodes assets doctor --lightmap-uv`). Hosts without lightmap support ignore it. */
43
+ lightmap?: boolean
44
+ } = {},
34
45
  ): Promise<Model> {
35
46
  if (typeof source === "string") {
36
47
  return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
@@ -39,9 +50,12 @@ export class Model extends Node {
39
50
  })
40
51
  }
41
52
  return new Promise<Model>((resolve, reject) => {
53
+ const lightmapped = !!options.lightmap && !!_creator.setNextGlbLightmapped
54
+ if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()))
42
55
  _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
43
56
  if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
44
57
  const model = new Model(entityId)
58
+ model._lightmapped = lightmapped
45
59
  if (options.culling !== true) _creator.setGlbCulling(entityId, false)
46
60
  resolve(model)
47
61
  }, reject)