lecodes-sdk 1.0.0 → 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 (78) hide show
  1. package/dist/global.d.ts +18 -4
  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/g2/Node2D.d.ts +16 -0
  8. package/dist/types/g2/Sprite.d.ts +11 -1
  9. package/dist/types/gl/Camera.d.ts +15 -1
  10. package/dist/types/gl/Foliage.d.ts +47 -0
  11. package/dist/types/gl/Geometry.d.ts +24 -0
  12. package/dist/types/gl/Light.d.ts +25 -7
  13. package/dist/types/gl/Lightmap.d.ts +90 -60
  14. package/dist/types/gl/Material.d.ts +28 -20
  15. package/dist/types/gl/Model.d.ts +7 -5
  16. package/dist/types/gl/Node.d.ts +18 -0
  17. package/dist/types/gl/Particles.d.ts +40 -1
  18. package/dist/types/gl/Scene.d.ts +20 -0
  19. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  20. package/dist/types/gl/animation/Animator.d.ts +27 -0
  21. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  22. package/dist/types/gl/animation/IK.d.ts +86 -30
  23. package/dist/types/gl/animation/Warp.d.ts +2 -1
  24. package/dist/types/gl/animation/core.d.ts +35 -4
  25. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  26. package/dist/types/gl/terrain/Terrain.d.ts +4 -2
  27. package/dist/types/inject.d.ts +8 -2
  28. package/dist/types/scene/defineScene.d.ts +44 -32
  29. package/dist/types/ui/UIButton.d.ts +3 -1
  30. package/dist/types/ui/UIInput.d.ts +5 -1
  31. package/dist/types/ui/UINode.d.ts +24 -24
  32. package/dist/types.json +1 -1
  33. package/package.json +1 -1
  34. package/prompts/core-design.md +27 -4
  35. package/prompts/core.md +35 -6
  36. package/prompts/select.ts +19 -4
  37. package/src/animate/tween/Animation.ts +378 -0
  38. package/src/animate/tween/Timeline.ts +175 -0
  39. package/src/animate/tween/animateValue.ts +100 -0
  40. package/src/animate/tween/easing.ts +172 -0
  41. package/src/animate/tween/spec.ts +479 -0
  42. package/src/bridges.d.ts +226 -65
  43. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  44. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  45. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  46. package/src/compile/bundler.ts +34 -4
  47. package/src/compile/compileProject.ts +31 -1
  48. package/src/compile/detectEntry.ts +8 -3
  49. package/src/compile/index.ts +2 -0
  50. package/src/compile/serverSplit.ts +9 -3
  51. package/src/g2/Node2D.ts +38 -0
  52. package/src/g2/Sprite.ts +20 -1
  53. package/src/gl/Camera.ts +34 -1
  54. package/src/gl/Foliage.ts +102 -0
  55. package/src/gl/Geometry.ts +393 -348
  56. package/src/gl/Light.ts +46 -16
  57. package/src/gl/Lightmap.ts +439 -275
  58. package/src/gl/Material.ts +59 -47
  59. package/src/gl/Model.ts +167 -156
  60. package/src/gl/Node.ts +39 -0
  61. package/src/gl/Particles.ts +61 -2
  62. package/src/gl/Scene.ts +34 -1
  63. package/src/gl/animation/AnimationClip.ts +52 -0
  64. package/src/gl/animation/Animator.ts +42 -2
  65. package/src/gl/animation/DynamicBone.ts +482 -459
  66. package/src/gl/animation/IK.ts +173 -152
  67. package/src/gl/animation/Playback.ts +5 -4
  68. package/src/gl/animation/Warp.ts +5 -2
  69. package/src/gl/animation/core.ts +65 -4
  70. package/src/gl/physics/Ragdoll.ts +451 -272
  71. package/src/gl/terrain/Terrain.ts +4 -2
  72. package/src/inject.ts +12 -2
  73. package/src/scene/defineScene.ts +72 -62
  74. package/src/ui/UIButton.ts +2 -2
  75. package/src/ui/UIInput.ts +3 -3
  76. package/src/ui/UINode.ts +61 -36
  77. package/dist/types/animate/animate.d.ts +0 -20
  78. package/src/animate/animate.ts +0 -238
@@ -161,8 +161,6 @@ export type LitMaterialOptions = MaterialColorOptions & MaterialStateOptions & {
161
161
  metallic?: number
162
162
  }
163
163
 
164
- /** `Material.lightmapShading` tiers — see the setter. */
165
- export type LightmapShading = "full" | "baked" | "baked-lite"
166
164
 
167
165
  export class Material {
168
166
  /** @internal native material-instance handle. */
@@ -328,54 +326,50 @@ export class Material {
328
326
  return m
329
327
  }
330
328
 
331
- /** The lightmap material (docs/lightmap-plan.md): PBR base colour × a baked shadow/AO atlas on UV1.
332
- * Models take it through `Model.load(…, { lightmap: true })`; `Lightmap.load` builds one per static
333
- * Mesh. Parameters use gltfio's names (`baseColorFactor`, `baseColorMap`, `roughnessFactor`,
334
- * `metallicFactor`) plus `lightmap`, `lightmapST`, `ambientScale`, `sunStrength` (the sun / IBL terms come from
335
- * filament's per-frame uniforms inside the shader). */
329
+ /** The lightmap material (packages/creator-bake): a lit PBR material whose whole DIFFUSE light is the
330
+ * baked irradiance atlas on UV1 (`lightmapLight`, lux) — the real-time sun, lamps and diffuse IBL do not
331
+ * touch it; the specular IBL stays, occluded by the baked sky visibility. Models take it through
332
+ * `Model.load(…, { lightmap: true })`; a static Mesh gets one from here. Parameters use gltfio's names
333
+ * (`baseColorFactor`, `baseColorMap`, `roughnessFactor`, `metallicFactor`) plus `lightmapLight`, `lightmapAux`,
334
+ * `lightmapST`, `lightScale` (the level-wide value `Lightmap.load` sets) and the debug view knobs. */
336
335
  static lightmap(): Material {
337
- // Inline literals: the compiler's preload header captures the shader names from them.
338
- const id = Material.lightmapShading === "baked"
339
- ? _creatorUtils.fetchLocal("lightmap-baked.filamat")
340
- : Material.lightmapShading === "baked-lite"
341
- ? _creatorUtils.fetchLocal("lightmap-baked-lite.filamat")
342
- : _creatorUtils.fetchLocal("lightmap.filamat")
343
- const m = new Material({ _id: id } as any)
344
- m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
345
- m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
346
- // lightScale 0 = no baked point lights until Lightmap.load binds a light atlas / volume
347
- m.set("bakedAo", 1).set("lightScale", 0)
348
- m.set("probeMin", [ 0, 0, 0, 0 ]).set("probeInvSize", [ 0, 0, 0, 0 ])
336
+ // Inline literal: the compiler's preload header captures the shader name from it.
337
+ const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
338
+ Material._lightmapDefaults(m)
349
339
  return m
350
340
  }
351
341
 
352
342
  /** `Material.lightmap()`'s masked twin (`blending: masked`, same shader and parameters): what the engine
353
343
  * gives a lightmapped model's alpha-MASK materials. The cutoff comes from the glTF material. */
354
344
  static lightmapMasked(): Material {
355
- const id = Material.lightmapShading === "baked"
356
- ? _creatorUtils.fetchLocal("lightmap-baked-masked.filamat")
357
- : Material.lightmapShading === "baked-lite"
358
- ? _creatorUtils.fetchLocal("lightmap-baked-lite-masked.filamat")
359
- : _creatorUtils.fetchLocal("lightmap-masked.filamat")
360
- const m = new Material({ _id: id } as any)
361
- m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
362
- m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
363
- m.set("bakedAo", 1).set("lightScale", 0)
364
- m.set("probeMin", [ 0, 0, 0, 0 ]).set("probeInvSize", [ 0, 0, 0, 0 ])
345
+ const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap-masked.filamat") } as any)
346
+ Material._lightmapDefaults(m)
365
347
  return m
366
348
  }
367
349
 
368
350
  /** The terrain splat material (docs/terrain-plan.md §1.5): four albedo (+ normal-map) layers blended by
369
351
  * a control map on the terrain's own grid, per-layer `tiling` (metres per repeat) / `roughness` /
370
- * `normalScale` / `triplanar`, lightmap-aware (the same `lightmap` / `lightmapST` block as
371
- * `Material.lightmap`). `Terrain` builds and owns one per terrain; the uniforms are primed here so an
372
- * unset layer is white and an unbaked terrain is fully lit. */
352
+ * `normalScale` / `triplanar`. `Terrain` builds and owns one per terrain; the uniforms are primed here so an
353
+ * unset layer is white. Lit real-time; a BAKED terrain takes `Material.terrainLightmap()`. */
373
354
  static terrain(): Material {
374
355
  // Inline literal: the compiler's preload header captures the shader name from it.
375
356
  const m = new Material({ _id: _creatorUtils.fetchLocal("terrain.filamat") } as any)
376
357
  m.set("tiling", [ 8, 8, 8, 8 ]).set("roughness", [ 1, 1, 1, 1 ]).set("normalScale", [ 0, 0, 0, 0 ]).set("triplanar", [ 0, 0, 0, 0 ])
377
358
  m.set("gridSize", [ 2, 2 ]).set("cellSize", 1).set("tint", "#ffffff")
378
- m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("bakedAo", 1).set("lightScale", 0)
359
+ return m
360
+ }
361
+
362
+ /** `Material.terrain()` for a BAKED terrain (terrain-lightmap.mat): the same layers and uniforms, shaded like
363
+ * `Material.lightmap()` — the whole diffuse light is the baked atlas on the terrain's UV1 (one rect), which
364
+ * `Lightmap.load` binds. A scene file's `terrain:` node that is a lightmap static is built with it; without a
365
+ * bound bake it draws black, like any lightmapped static. No reflection probes (the sampler budget): it reflects the
366
+ * sky through the baked sky visibility. */
367
+ static terrainLightmap(): Material {
368
+ // Inline literal: the compiler's preload header captures the shader name from it.
369
+ const m = new Material({ _id: _creatorUtils.fetchLocal("terrain-lightmap.filamat") } as any)
370
+ m.set("tiling", [ 8, 8, 8, 8 ]).set("roughness", [ 1, 1, 1, 1 ]).set("normalScale", [ 0, 0, 0, 0 ]).set("triplanar", [ 0, 0, 0, 0 ])
371
+ m.set("gridSize", [ 2, 2 ]).set("cellSize", 1).set("tint", "#ffffff")
372
+ m.set("lightmapST", [ 1, 1, 0, 0 ]).set("lightScale", 1).set("debugMode", 0).set("debugParams", [ 0.001, 0, 5, 0 ])
379
373
  return m
380
374
  }
381
375
 
@@ -387,23 +381,41 @@ export class Material {
387
381
  * materials (foliage cards) take it instead of falling back to the ubershader's full lit path. */
388
382
  static _lightmapMaskedTemplate(): Material { return (Material._lmMaskedTemplate ??= Material.lightmapMasked()) }
389
383
  private static _lmMaskedTemplate: Material | undefined
384
+ /** The FOLIAGE tier (`Model.load(…, { foliage: true })`, see `Foliage`): an unlit shader with its own cheap
385
+ * lighting plus a vertex shader that sways in the wind, bends away from benders and thins out with distance,
386
+ * and a per-copy tint. glTF parameters like `Material.lightmap()` plus `wind` / `sway` / `fade` / `benders`,
387
+ * which the engine writes itself. Not baked by the rewritten bake yet. */
388
+ static foliage(): Material {
389
+ const m = new Material({ _id: _creatorUtils.fetchLocal("foliage.filamat") } as any)
390
+ Material._glbDefaults(m)
391
+ return m
392
+ }
393
+
394
+ /** `Material.foliage()`'s masked twin (`blending: masked`): what the engine gives a foliage model's alpha-MASK
395
+ * materials — the cards. */
396
+ static foliageMasked(): Material {
397
+ const m = new Material({ _id: _creatorUtils.fetchLocal("foliage-masked.filamat") } as any)
398
+ Material._glbDefaults(m)
399
+ return m
400
+ }
401
+
402
+ /** the glTF-side identity values every provider-handed material starts from */
403
+ private static _glbDefaults(m: Material): void {
404
+ m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0).set("hasNormalMap", 0)
405
+ }
390
406
 
391
- static _lightmapShading: LightmapShading = "full"
392
- /** Which shader lightmapped models take — a graphics-quality tier, engine-wide. `"full"` is filament's
393
- * lit path over the baked atlas (IBL specular, real-time point lights such as a muzzle flash, sun
394
- * shadows on dynamic objects). `"baked"` keeps the baked light and an approximated ambient but drops
395
- * the lit path: ~40 % cheaper per pixel on a fill-bound GPU. `"baked-lite"` is that minus the normal,
396
- * metallic/roughness and occlusion map reads (the factors stand in, the atlas keeps the baked AO;
397
- * emissive still glows): +20 % more at 1080p on the same GPU. Read when a lightmapped model LOADS, so set
398
- * it before the level (a settings menu applies it on the next level load), like `Texture.maxSize`. */
399
- static get lightmapShading(): LightmapShading { return Material._lightmapShading }
400
- static set lightmapShading(mode: LightmapShading) {
401
- if (mode === Material._lightmapShading) return
402
- Material._lightmapShading = mode
403
- Material._lmTemplate = undefined // the next load builds a template on the new shader
404
- Material._lmMaskedTemplate = undefined
407
+ private static _lightmapDefaults(m: Material): void {
408
+ Material._glbDefaults(m)
409
+ m.set("lightmapST", [ 1, 1, 0, 0 ]).set("lightScale", 1).set("debugMode", 0).set("debugParams", [ 0.001, 0, 5, 0 ])
405
410
  }
406
411
 
412
+ /** @internal the foliage template pair, like the lightmap one (a host that lacks the tier returns id 0 from
413
+ * fetchLocal — `Model.load` then falls back to the lightmap tier). */
414
+ static _foliageTemplate(): Material { return (Material._folTemplate ??= Material.foliage()) }
415
+ private static _folTemplate: Material | undefined
416
+ static _foliageMaskedTemplate(): Material { return (Material._folMaskedTemplate ??= Material.foliageMasked()) }
417
+ private static _folMaskedTemplate: Material | undefined
418
+
407
419
  /** Shadow-catcher material (transparent except where shadows fall). */
408
420
  static shadow(color: ColorInput = "#000000aa"): Material {
409
421
  const m = new Material({ _id: _creatorUtils.fetchLocal("shadow.filamat") } as any)
package/src/gl/Model.ts CHANGED
@@ -1,156 +1,167 @@
1
- // A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
2
- // distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
3
- // model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
4
- // const hero = await Model.load(asset('./hero.glb'))
5
- // hero.anim.play('Run', { loop: true })
6
-
7
- import { fetch, type FetchResponse } from "../runtime/fetch"
8
- import { Node } from "./Node"
9
- import { Animator, _pushLod, type LodMode } from "./animation/Animator"
10
- import { Material } from "./Material"
11
-
12
- /** One polygon under a screen point — what `Model.pickTriangle` returns. `bones` are the vertex's raw
13
- * JOINTS_0 / WEIGHTS_0 pairs (weight > 0; empty = unweighted), `bind` its position in mesh space,
14
- * `world` its skinned position this frame. */
15
- export interface TrianglePick {
16
- /** the Model's root entity, and the mesh node's own entity (0 when the host has no entity for it) */
17
- entity: number
18
- node: number
19
- nodeName: string
20
- mesh: string
21
- primitive: number
22
- material: string
23
- /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
24
- triangle: number
25
- backface: boolean
26
- distance: number
27
- point: [number, number, number]
28
- bary: [number, number, number]
29
- skin: string
30
- vertices: { index: number, bind: [number, number, number], world: [number, number, number], bones: { name: string, weight: number }[] }[]
31
- }
32
-
33
- export class Model extends Node {
34
- /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
35
- * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
36
- declare readonly anim: Animator
37
- /** @internal loaded through the lightmap material (docs/lightmap-plan.md); clones inherit it. */
38
- _lightmapped = false
39
- /** @internal what `load` does without an explicit `lightmap` option: `'dynamic'` while a scene file with
40
- * `env.lightmap.volume` runs (Lightmap sets it), so weapons, spawns and make() subtrees read the volume. */
41
- static _lightmapDefault: false | "dynamic" = false
42
- /** @internal the shadow pair, mirrored here because the bridge writes both at once. */
43
- _castShadows = true
44
- /** @internal */
45
- _receiveShadows = true
46
- /** @internal */
47
- _culling = true
48
- /** @internal -1 = auto (see `lod`) */
49
- _lodMesh = -1
50
-
51
- constructor(internalId: number) {
52
- super(internalId)
53
- this.aspect(Animator)
54
- }
55
-
56
- /** Does this model cast a real-time shadow? Unlike Mesh (one renderable) a GLB is a whole
57
- * hierarchy, so the flag goes to EVERY renderable of the instance. A first-person viewmodel —
58
- * arms, weapon, attachments — sets it false: it lives in front of the camera and its shadow
59
- * is never wanted. */
60
- get castShadows(): boolean { return this._castShadows }
61
- set castShadows(v: boolean) { this._castShadows = v; this._applyShadows() }
62
-
63
- /** Is this model lit by other casters' shadows? Same instance-wide reach as castShadows. */
64
- get receiveShadows(): boolean { return this._receiveShadows }
65
- set receiveShadows(v: boolean) { this._receiveShadows = v; this._applyShadows() }
66
-
67
- /** Frustum culling for the whole instance: a model the camera cannot see skips the draw and the
68
- * shadow pass. ON by default where the host keeps a skinned mesh's bounds honest — the engine refits
69
- * them to the joints every frame, so a walking, kneeling or ragdolled body is never culled while on
70
- * screen (`_creator.skinnedCullingSupported`); OFF (always draw) on hosts without that, where
71
- * Filament would cull an animated body by its bind-pose box. `false` = always draw (a skybox-sized
72
- * mesh, a debugging aid); `true` forces it on regardless of the host. */
73
- get culling(): boolean { return this._culling }
74
- set culling(v: boolean) { this._culling = v; _creator.setGlbCulling(this.id, v) }
75
- /** Level of detail (docs/lod-plan.md). `'auto'` (default): the engine shows the `_LOD<n>` mesh level
76
- * that fits the model's size on screen (`lecodes assets doctor --lod` makes them) and scales the
77
- * animation rate with it; a number 0–3 pins that level for both — `0` = always full detail (a hero,
78
- * a showcase), `2`/`3` = always cheap (a crowd filler). `model.anim.lod = 'full'` keeps the animation
79
- * exact while the mesh still switches. No-op on hosts without the LOD pass. */
80
- get lod(): LodMode { return this._lodMesh < 0 ? "auto" : (this._lodMesh as 0 | 1 | 2 | 3) }
81
- set lod(v: LodMode) { this._lodMesh = v === "auto" ? -1 : Math.max(0, Math.min(3, Math.round(v))); _pushLod(this) }
82
-
83
- /** @internal the default for a fresh instance (load / clone), decided once per host. */
84
- static _cullingDefault(): boolean {
85
- if (Model._cullDefault === undefined) Model._cullDefault = !!_creator.skinnedCullingSupported?.()
86
- return Model._cullDefault
87
- }
88
- private static _cullDefault: boolean | undefined
89
-
90
- /** @internal both flags travel together — the bridge walks the instance once. */
91
- _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }
92
-
93
- /** DEBUG: the closest polygon of this model under a screen point (logical px — `Input.mouse.position`,
94
- * a touch event's clientX/Y), tested against the CPU-skinned CURRENT pose, both faces. Names the
95
- * triangle, its three vertices (bind + skinned positions) and their raw bone weights, so a stretched
96
- * or misbound polygon can be traced to its binding. One full CPU skin of the model per call: click-rate
97
- * only. null = miss, or a host without the pick (desktop today). */
98
- pickTriangle(screenX: number, screenY: number): TrianglePick | null {
99
- const json = _creator.pickTriangle?.(this.id, screenX, screenY)
100
- return json ? JSON.parse(json) as TrianglePick : null
101
- }
102
-
103
- /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
104
- * the same parent and scene and sharing this model's current transform. The clone has its own
105
- * independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
106
- clone(): Model {
107
- const id = _creator.cloneEntity(this.id)
108
- const m = new Model(id)
109
- m._lightmapped = this._lightmapped
110
- if (!this._culling) { m._culling = false; _creator.setGlbCulling(id, false) } // the engine default is on
111
- if (this._lodMesh >= 0) { m._lodMesh = this._lodMesh; _pushLod(m) }
112
- // the clone is a FRESH gltfio instance — it comes back with the asset's own shadow flags,
113
- // not this model's, so a cleared flag has to be re-applied
114
- m._castShadows = this._castShadows
115
- m._receiveShadows = this._receiveShadows
116
- if (!this._castShadows || !this._receiveShadows) m._applyShadows()
117
- return m
118
- }
119
-
120
- /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
121
- static load(
122
- source: string | FetchResponse,
123
- options: {
124
- /** Frustum culling — see `culling` (default: on where the host refits skinned bounds, else off). */
125
- culling?: boolean
126
- onProgress?: (p: { loaded: number, total?: number }) => void
127
- /** Baked lighting (docs/lightmap-plan.md). `true` = a STATIC: loads through the lightmap material so
128
- * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
129
- * --lightmap-uv`). `'dynamic'` = a mover: the same material, lit by the level's light VOLUME instead
130
- * (no UV1 needed). Omitted: `'dynamic'` while a scene file with `env.lightmap.volume` is running,
131
- * else off. `false` = the standard shader. Hosts without lightmap support ignore it. */
132
- lightmap?: boolean | "dynamic"
133
- } = {},
134
- ): Promise<Model> {
135
- if (typeof source === "string") {
136
- return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
137
- if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
138
- return Model.load(resp, options)
139
- })
140
- }
141
- return new Promise<Model>((resolve, reject) => {
142
- const mode = options.lightmap ?? Model._lightmapDefault
143
- const lightmapped = !!mode && !!_creator.setNextGlbLightmapped
144
- // the tier + its masked twin (alpha-MASK materials — foliage — take the lightmap shader too, 2026-09-11)
145
- if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()), Material.idOf(Material._lightmapMaskedTemplate()))
146
- _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
147
- if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
148
- const model = new Model(entityId)
149
- model._lightmapped = lightmapped
150
- const culling = options.culling ?? Model._cullingDefault()
151
- if (!culling) { model._culling = false; _creator.setGlbCulling(entityId, false) }
152
- resolve(model)
153
- }, reject)
154
- })
155
- }
156
- }
1
+ // A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
2
+ // distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
3
+ // model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
4
+ // const hero = await Model.load(asset('./hero.glb'))
5
+ // hero.anim.play('Run', { loop: true })
6
+
7
+ import { fetch, type FetchResponse } from "../runtime/fetch"
8
+ import { Node } from "./Node"
9
+ import { Animator, _pushLod, type LodMode } from "./animation/Animator"
10
+ import { Material } from "./Material"
11
+
12
+ /** One polygon under a screen point — what `Model.pickTriangle` returns. `bones` are the vertex's raw
13
+ * JOINTS_0 / WEIGHTS_0 pairs (weight > 0; empty = unweighted), `bind` its position in mesh space,
14
+ * `world` its skinned position this frame. */
15
+ export interface TrianglePick {
16
+ /** the Model's root entity, and the mesh node's own entity (0 when the host has no entity for it) */
17
+ entity: number
18
+ node: number
19
+ nodeName: string
20
+ mesh: string
21
+ primitive: number
22
+ material: string
23
+ /** triangle index within the primitive (index-buffer order), and whether the ray came from behind */
24
+ triangle: number
25
+ backface: boolean
26
+ distance: number
27
+ point: [number, number, number]
28
+ bary: [number, number, number]
29
+ skin: string
30
+ vertices: { index: number, bind: [number, number, number], world: [number, number, number], bones: { name: string, weight: number }[] }[]
31
+ }
32
+
33
+ export class Model extends Node {
34
+ /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
35
+ * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
36
+ declare readonly anim: Animator
37
+ /** @internal loaded through the lightmap material (a baked static); clones inherit it. */
38
+ _lightmapped = false
39
+ /** @internal loaded through the foliage tier (`Foliage`); clones inherit it. */
40
+ _foliage = false
41
+ /** @internal the shadow pair, mirrored here because the bridge writes both at once. */
42
+ _castShadows = true
43
+ /** @internal */
44
+ _receiveShadows = true
45
+ /** @internal */
46
+ _culling = true
47
+ /** @internal -1 = auto (see `lod`) */
48
+ _lodMesh = -1
49
+
50
+ constructor(internalId: number) {
51
+ super(internalId)
52
+ this.aspect(Animator)
53
+ }
54
+
55
+ /** Does this model cast a real-time shadow? Unlike Mesh (one renderable) a GLB is a whole
56
+ * hierarchy, so the flag goes to EVERY renderable of the instance. A first-person viewmodel —
57
+ * arms, weapon, attachments — sets it false: it lives in front of the camera and its shadow
58
+ * is never wanted. */
59
+ get castShadows(): boolean { return this._castShadows }
60
+ set castShadows(v: boolean) { this._castShadows = v; this._applyShadows() }
61
+
62
+ /** Is this model lit by other casters' shadows? Same instance-wide reach as castShadows. */
63
+ get receiveShadows(): boolean { return this._receiveShadows }
64
+ set receiveShadows(v: boolean) { this._receiveShadows = v; this._applyShadows() }
65
+
66
+ /** Frustum culling for the whole instance: a model the camera cannot see skips the draw and the
67
+ * shadow pass. ON by default where the host keeps a skinned mesh's bounds honest — the engine refits
68
+ * them to the joints every frame, so a walking, kneeling or ragdolled body is never culled while on
69
+ * screen (`_creator.skinnedCullingSupported`); OFF (always draw) on hosts without that, where
70
+ * Filament would cull an animated body by its bind-pose box. `false` = always draw (a skybox-sized
71
+ * mesh, a debugging aid); `true` forces it on regardless of the host. */
72
+ get culling(): boolean { return this._culling }
73
+ set culling(v: boolean) { this._culling = v; _creator.setGlbCulling(this.id, v) }
74
+ /** Level of detail (docs/lod-plan.md). `'auto'` (default): the engine shows the `_LOD<n>` mesh level
75
+ * that fits the model's size on screen (`lecodes assets doctor --lod` makes them) and scales the
76
+ * animation rate with it; a number 0–3 pins that level for both — `0` = always full detail (a hero,
77
+ * a showcase), `2`/`3` = always cheap (a crowd filler). `model.anim.lod = 'full'` keeps the animation
78
+ * exact while the mesh still switches. No-op on hosts without the LOD pass. */
79
+ get lod(): LodMode { return this._lodMesh < 0 ? "auto" : (this._lodMesh as 0 | 1 | 2 | 3) }
80
+ set lod(v: LodMode) { this._lodMesh = v === "auto" ? -1 : Math.max(0, Math.min(3, Math.round(v))); _pushLod(this) }
81
+
82
+ /** @internal the default for a fresh instance (load / clone), decided once per host. */
83
+ static _cullingDefault(): boolean {
84
+ if (Model._cullDefault === undefined) Model._cullDefault = !!_creator.skinnedCullingSupported?.()
85
+ return Model._cullDefault
86
+ }
87
+ private static _cullDefault: boolean | undefined
88
+
89
+ /** @internal both flags travel together — the bridge walks the instance once. */
90
+ _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }
91
+
92
+ /** DEBUG: the closest polygon of this model under a screen point (logical px — `Input.mouse.position`,
93
+ * a touch event's clientX/Y), tested against the CPU-skinned CURRENT pose, both faces. Names the
94
+ * triangle, its three vertices (bind + skinned positions) and their raw bone weights, so a stretched
95
+ * or misbound polygon can be traced to its binding. One full CPU skin of the model per call: click-rate
96
+ * only. null = miss, or a host without the pick (desktop today). */
97
+ pickTriangle(screenX: number, screenY: number): TrianglePick | null {
98
+ const json = _creator.pickTriangle?.(this.id, screenX, screenY)
99
+ return json ? JSON.parse(json) as TrianglePick : null
100
+ }
101
+
102
+ /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
103
+ * the same parent and scene and sharing this model's current transform. The clone has its own
104
+ * independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
105
+ clone(): Model {
106
+ const id = _creator.cloneEntity(this.id)
107
+ const m = new Model(id)
108
+ m._lightmapped = this._lightmapped
109
+ m._foliage = this._foliage
110
+ if (!this._culling) { m._culling = false; _creator.setGlbCulling(id, false) } // the engine default is on
111
+ if (this._lodMesh >= 0) { m._lodMesh = this._lodMesh; _pushLod(m) }
112
+ // the clone is a FRESH gltfio instance — it comes back with the asset's own shadow flags,
113
+ // not this model's, so a cleared flag has to be re-applied
114
+ m._castShadows = this._castShadows
115
+ m._receiveShadows = this._receiveShadows
116
+ if (!this._castShadows || !this._receiveShadows) m._applyShadows()
117
+ return m
118
+ }
119
+
120
+ /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
121
+ static load(
122
+ source: string | FetchResponse,
123
+ options: {
124
+ /** Frustum culling — see `culling` (default: on where the host refits skinned bounds, else off). */
125
+ culling?: boolean
126
+ onProgress?: (p: { loaded: number, total?: number }) => void
127
+ /** Baked lighting (packages/creator-bake). `true` = a STATIC: loads through the lightmap material so
128
+ * `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
129
+ * --lightmap-uv`) and takes nothing from the real-time lights once the bake applies. Omitted / `false` =
130
+ * the standard shader, lit real-time (movers). Hosts without lightmap support ignore it. */
131
+ lightmap?: boolean
132
+ /** Vegetation: load through the FOLIAGE tier (see `Foliage`) — wind, touch bending, distance fade,
133
+ * per-copy tint. Hosts without the tier fall back to the standard shader. */
134
+ foliage?: boolean
135
+ } = {},
136
+ ): Promise<Model> {
137
+ if (typeof source === "string") {
138
+ return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
139
+ if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
140
+ return Model.load(resp, options)
141
+ })
142
+ }
143
+ return new Promise<Model>((resolve, reject) => {
144
+ // the provider path: a foliage model takes the vegetation tier's pair when the host ships it (fetchLocal
145
+ // id 0 = it does not), a static the lightmap material + its masked twin; anything else the ubershader
146
+ const foliageWanted = !!options.foliage && !!_creator.setNextGlbLightmapped
147
+ let foliage = false
148
+ if (foliageWanted) {
149
+ const fol = Material.idOf(Material._foliageTemplate())
150
+ const folMasked = fol ? Material.idOf(Material._foliageMaskedTemplate()) : 0
151
+ foliage = fol > 0 && folMasked > 0
152
+ if (foliage) _creator.setNextGlbLightmapped!(fol, folMasked)
153
+ }
154
+ const lightmapped = !foliage && !!options.lightmap && !!_creator.setNextGlbLightmapped
155
+ if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()), Material.idOf(Material._lightmapMaskedTemplate()))
156
+ _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
157
+ if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
158
+ const model = new Model(entityId)
159
+ model._lightmapped = lightmapped
160
+ model._foliage = foliage
161
+ const culling = options.culling ?? Model._cullingDefault()
162
+ if (!culling) { model._culling = false; _creator.setGlbCulling(entityId, false) }
163
+ resolve(model)
164
+ }, reject)
165
+ })
166
+ }
167
+ }
package/src/gl/Node.ts CHANGED
@@ -16,6 +16,19 @@ import { ensurePhysicsEvents } from "./physics/physicsEvents"
16
16
  import { Material } from "./Material"
17
17
  import type { CompAxis, CompWriter } from "../core/compWrite"
18
18
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
19
+ import type { Animation } from "../animate/tween/Animation"
20
+ import { CLOCK_GAME, DOM_GL_NODE, eulerValue, quatValue, vec3Value, type TweenChannel, type TweenMeta } from "../animate/tween/spec"
21
+ import { tweenBag } from "../animate/tween/Timeline"
22
+
23
+ /** Animatable transform props of a 3D node: a value tweens from the current one, an ARRAY OF
24
+ * VALUES is a keyframe list (`position: [[0,0,0], [0,2,0]]`). */
25
+ export type NodeTweenProps = {
26
+ position?: Vec3Like | Vec3Like[],
27
+ scale?: number | Vec3Like | (number | Vec3Like)[],
28
+ quaternion?: QuatLike | QuatLike[],
29
+ /** Degrees, YXZ; interpolated per axis without shortest-arc, so `[0, 720, 0]` spins twice. */
30
+ eulerAngles?: Vec3Like | Vec3Like[],
31
+ }
19
32
 
20
33
  /** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
21
34
  export const nodeRegistry = new Registry<Node>()
@@ -197,6 +210,32 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
197
210
  if (this._xf) this._setOwnedRotation()
198
211
  }
199
212
 
213
+ // --- keyframe animation (docs/timeline-plan.md) ---
214
+ /** Tween the transform to the given values — `node.animateTo({ position: [0, 2, 0], duration: 800,
215
+ * easing: 'inOutCubic' })`; arrays of values are keyframes. Runs on the game clock (pauses with
216
+ * the game) unless `clock: 'ui'`. Returns the {@link Animation} handle. Phase 1: plain nodes —
217
+ * a physics-owned node (a body / character) is not routed through its engine object yet. */
218
+ animateTo(props: NodeTweenProps & TweenMeta): Animation {
219
+ return tweenBag(this, props as Record<string, unknown>, true)
220
+ }
221
+ /** Tween FROM the given values to the node's current transform (an entrance). */
222
+ animateFrom(props: NodeTweenProps & TweenMeta): Animation {
223
+ return tweenBag(this, props as Record<string, unknown>, false)
224
+ }
225
+ /** @internal keyframe-core target protocol */
226
+ readonly _tweenClock: number = CLOCK_GAME
227
+ /** @internal */
228
+ _tweenChannel(prop: string): TweenChannel | null {
229
+ const id = () => this.id
230
+ switch (prop) {
231
+ case "position": return { domain: DOM_GL_NODE, id, value: vec3Value }
232
+ case "scale": return { domain: DOM_GL_NODE, id, value: vec3Value }
233
+ case "quaternion": return { domain: DOM_GL_NODE, id, value: quatValue }
234
+ case "eulerAngles": return { domain: DOM_GL_NODE, id, value: eulerValue }
235
+ default: return null
236
+ }
237
+ }
238
+
200
239
  get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
201
240
  // order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
202
241
  // the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix