lecodes-sdk 0.20.0 → 1.0.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 (114) hide show
  1. package/dist/global.d.ts +48 -5
  2. package/dist/inject.js +361 -260
  3. package/dist/types/audio/Bus.d.ts +45 -0
  4. package/dist/types/audio/Sound.d.ts +28 -0
  5. package/dist/types/audio/Voice.d.ts +27 -0
  6. package/dist/types/audio/audio.d.ts +83 -0
  7. package/dist/types/audio/support.d.ts +1 -0
  8. package/dist/types/canvas/Canvas.d.ts +2 -0
  9. package/dist/types/gl/DecalSet.d.ts +148 -0
  10. package/dist/types/gl/Geometry.d.ts +17 -0
  11. package/dist/types/gl/Light.d.ts +7 -0
  12. package/dist/types/gl/Lightmap.d.ts +9 -0
  13. package/dist/types/gl/Material.d.ts +90 -2
  14. package/dist/types/gl/Mesh.d.ts +18 -1
  15. package/dist/types/gl/Model.d.ts +34 -0
  16. package/dist/types/gl/Particles.d.ts +13 -0
  17. package/dist/types/gl/Scene.d.ts +23 -0
  18. package/dist/types/gl/Texture.d.ts +29 -1
  19. package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
  20. package/dist/types/gl/animation/DynamicBone.d.ts +173 -0
  21. package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
  22. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  23. package/dist/types/gl/animation/core.d.ts +15 -10
  24. package/dist/types/gl/audio/AudioSource.d.ts +60 -0
  25. package/dist/types/gl/audio/AudioZone.d.ts +32 -0
  26. package/dist/types/gl/audio/SceneAudio.d.ts +11 -0
  27. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  28. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  29. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  30. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  31. package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
  32. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  33. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  34. package/dist/types/gl/state.d.ts +0 -1
  35. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
  36. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  37. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  38. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  39. package/dist/types/inject.d.ts +33 -21
  40. package/dist/types/runtime/files.d.ts +24 -1
  41. package/dist/types/runtime/input.d.ts +11 -0
  42. package/dist/types/scene/defineScene.d.ts +10 -3
  43. package/dist/types/ui/UIImage.d.ts +15 -5
  44. package/dist/types.json +1 -1
  45. package/package.json +1 -1
  46. package/prompts/README.md +142 -142
  47. package/prompts/dist/2d-game.md +408 -197
  48. package/prompts/dist/3d-app.md +491 -166
  49. package/prompts/dist/ar-app.md +373 -163
  50. package/prompts/dist/design.md +83 -87
  51. package/prompts/dist/ui-app.md +325 -136
  52. package/src/audio/Bus.ts +102 -0
  53. package/src/audio/Sound.ts +96 -0
  54. package/src/audio/Voice.ts +102 -0
  55. package/src/audio/audio.ts +161 -0
  56. package/src/audio/support.ts +6 -0
  57. package/src/bridges.d.ts +279 -32
  58. package/src/canvas/Canvas.ts +21 -0
  59. package/src/compile/__tests__/compile.test.ts +11 -0
  60. package/src/compile/compileProject.ts +30 -15
  61. package/src/compile/header.ts +6 -3
  62. package/src/compile/index.ts +4 -0
  63. package/src/compile/sceneEditor.ts +42 -1
  64. package/src/core/Aspect.ts +33 -8
  65. package/src/g2/Scene2D.ts +7 -0
  66. package/src/gl/CameraPlace.ts +52 -52
  67. package/src/gl/DecalSet.ts +360 -0
  68. package/src/gl/Geometry.ts +348 -279
  69. package/src/gl/Light.ts +16 -0
  70. package/src/gl/Lightmap.ts +35 -7
  71. package/src/gl/Material.ts +173 -4
  72. package/src/gl/Mesh.ts +120 -83
  73. package/src/gl/Model.ts +33 -1
  74. package/src/gl/Node.ts +1 -1
  75. package/src/gl/Particles.ts +21 -3
  76. package/src/gl/Scene.ts +41 -7
  77. package/src/gl/Texture.ts +43 -3
  78. package/src/gl/animation/AnimationClip.ts +43 -20
  79. package/src/gl/animation/Animator.ts +4 -3
  80. package/src/gl/animation/DynamicBone.ts +459 -0
  81. package/src/gl/{IK.ts → animation/IK.ts} +4 -4
  82. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  83. package/src/gl/animation/core.ts +20 -15
  84. package/src/gl/audio/AudioSource.ts +113 -0
  85. package/src/gl/audio/AudioZone.ts +75 -0
  86. package/src/gl/audio/SceneAudio.ts +26 -0
  87. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  88. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  89. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  90. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  91. package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +272 -270
  92. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  93. package/src/gl/{Trigger.ts → physics/Trigger.ts} +2 -2
  94. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  95. package/src/gl/scenarios.ts +291 -291
  96. package/src/gl/state.ts +1 -1
  97. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
  98. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  99. package/src/gl/vehicle/Vehicle.ts +666 -0
  100. package/src/gl/vehicle/Wheel.ts +290 -0
  101. package/src/inject.ts +226 -212
  102. package/src/runtime/files.ts +32 -2
  103. package/src/runtime/input.ts +6 -1
  104. package/src/scene/defineScene.ts +26 -10
  105. package/src/scene/gizmos.ts +148 -148
  106. package/src/scene/level.ts +2 -2
  107. package/src/ui/UIImage.ts +21 -7
  108. package/dist/types/gl/Gearbox.d.ts +0 -86
  109. package/dist/types/gl/Vehicle.d.ts +0 -191
  110. package/dist/types/gl/Wheel.d.ts +0 -95
  111. package/src/gl/Gearbox.ts +0 -212
  112. package/src/gl/Vehicle.ts +0 -473
  113. package/src/gl/Wheel.ts +0 -240
  114. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/gl/Model.ts CHANGED
@@ -9,6 +9,27 @@ import { Node } from "./Node"
9
9
  import { Animator, _pushLod, type LodMode } from "./animation/Animator"
10
10
  import { Material } from "./Material"
11
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
+
12
33
  export class Model extends Node {
13
34
  /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
14
35
  * more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
@@ -69,6 +90,16 @@ export class Model extends Node {
69
90
  /** @internal both flags travel together — the bridge walks the instance once. */
70
91
  _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }
71
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
+
72
103
  /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
73
104
  * the same parent and scene and sharing this model's current transform. The clone has its own
74
105
  * independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
@@ -110,7 +141,8 @@ export class Model extends Node {
110
141
  return new Promise<Model>((resolve, reject) => {
111
142
  const mode = options.lightmap ?? Model._lightmapDefault
112
143
  const lightmapped = !!mode && !!_creator.setNextGlbLightmapped
113
- if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()))
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()))
114
146
  _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
115
147
  if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
116
148
  const model = new Model(entityId)
package/src/gl/Node.ts CHANGED
@@ -12,7 +12,7 @@ import { Quat, cw, type QuatLike } from "../math/quat"
12
12
  import { Mat4, type Mat4Like } from "../math/mat4"
13
13
  import type { Geometry } from "./Geometry"
14
14
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
15
- import { ensurePhysicsEvents } from "./physicsEvents"
15
+ 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"
@@ -231,7 +231,15 @@ type Shape =
231
231
  * birth direction (a burst that decays: `curve(4).to(0)`), `x`/`y`/`z` drift in the simulation
232
232
  * space (rising smoke: `y: curve(2).from(0).to(1)`). Unity's velocityOverLifetime. */
233
233
  export type VelocityOverLife = { radial?: ParticleValue, x?: ParticleValue, y?: ParticleValue, z?: ParticleValue }
234
- type Noise = { strength?: number, frequency?: number, speed?: number }
234
+ type Noise = {
235
+ strength?: number, frequency?: number, speed?: number,
236
+ /** How much of the displacement a particle earns with AGE (default 0 = all of it from birth).
237
+ * The field is sampled by position, so at 0 every particle born at the same spot is pushed the
238
+ * same way and a plume's root wanders off its emitter by up to `strength` metres; at 1 a particle
239
+ * is born exactly where the emitter put it and drifts into the field over its life — what you
240
+ * want whenever the source is visible (an exhaust pipe, a contact patch, a muzzle). */
241
+ ramp?: number,
242
+ }
235
243
 
236
244
  /** Ground plane for bouncing particles (all render modes). `height` is in the simulation space:
237
245
  * world y with `space: 'world'`, emitter-local y otherwise. */
@@ -283,6 +291,11 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
283
291
  * without this the engine picks who covers whom per frame (smoke popping over a fireball).
284
292
  * Default 0; a fireball wants 2, its smoke 1, a smoke trail -1. */
285
293
  order?: number
294
+ /** Coarse draw order among ALL blended draws, 0 (first) … 7 (last) — see `Mesh.renderPriority`.
295
+ * Default 5: meshes sit at 4 and decals at 3, so smoke covers a car's glass and its skid marks
296
+ * whatever the camera does (the engine's depth sort compares object centres, and an emitter's
297
+ * centre says nothing about where its cloud is). `order` breaks ties inside one priority. */
298
+ renderPriority?: number
286
299
  shape?: Shape
287
300
  /** Extra velocity over life (radial burst that decays, axis drift) — see `VelocityOverLife`. */
288
301
  velocityOverLife?: VelocityOverLife
@@ -437,6 +450,7 @@ export class Particles extends Node {
437
450
  super()
438
451
  this._material = options.material ?? (options.mesh ? Material.lit() : Material.particles(options))
439
452
  _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
453
+ this.renderPriority = options.renderPriority ?? 5
440
454
 
441
455
  const id = this.id
442
456
  this.custom = new Proxy([] as ParticleValue[], {
@@ -496,7 +510,7 @@ export class Particles extends Node {
496
510
  }
497
511
  if (options.noise !== undefined) {
498
512
  const n = options.noise
499
- out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
513
+ out.push(TAG_NOISE, 4, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0, n ? n.ramp ?? 0 : 0)
500
514
  }
501
515
  if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
502
516
 
@@ -517,6 +531,10 @@ export class Particles extends Node {
517
531
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
518
532
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
519
533
  set order(val: number) { this._send([ TAG_ORDER, 1, val ]) }
534
+ /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
535
+ set renderPriority(v: number) {
536
+ if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
537
+ }
520
538
 
521
539
  set velocityOverLife(v: VelocityOverLife) {
522
540
  const out: number[] = []
@@ -585,7 +603,7 @@ export class Particles extends Node {
585
603
 
586
604
  set noise(noise: Noise | null) {
587
605
  this._send(noise
588
- ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
606
+ ? [ TAG_NOISE, 4, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1, noise.ramp ?? 0 ]
589
607
  : [ TAG_NOISE, 3, 0, 0, 0 ])
590
608
  }
591
609
  }
package/src/gl/Scene.ts CHANGED
@@ -13,8 +13,10 @@ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
13
13
  import type { FetchResponse } from "../runtime/fetch"
14
14
  import { _requestCameraPermission } from "../plugins/permission"
15
15
  import { Camera } from "./Camera"
16
+ import { SceneAudio } from "./audio/SceneAudio"
16
17
  import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
17
18
  import { Material } from "./Material"
19
+ import { Texture } from "./Texture"
18
20
  import { Node, nodeRegistry } from "./Node"
19
21
  import { glState } from "./state"
20
22
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
@@ -107,6 +109,9 @@ export type SceneOptions = {
107
109
  * takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
108
110
  * fill-rate cost after resolution — turn it down on big screens before anything else. */
109
111
  antialias?: boolean | 2 | 4
112
+ /** Keep a stencil buffer for this scene (off by default: it costs memory and a clear per frame).
113
+ * Needed before any material's `stencil` test or write does anything. */
114
+ stencil?: boolean
110
115
  /** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
111
116
  * resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
112
117
  * barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
@@ -128,6 +133,10 @@ export type SceneOptions = {
128
133
  * scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
129
134
  * On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
130
135
  anisotropy?: number
136
+ /** Engine-wide cap on texture size, 0 / unset = none (see `Texture.maxSize`): a KTX2 above it
137
+ * loses its top mip levels on load, a glTF image is downsampled. Applied before this scene's
138
+ * assets load; like `anisotropy` it does not touch textures already loaded. */
139
+ maxTextureSize?: number
131
140
  /** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
132
141
  * macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
133
142
  * display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
@@ -148,17 +157,16 @@ const installRenderSyncedFrames = (): void => {
148
157
  framesInstalled = true
149
158
  _installAspectFrames((early, late) => {
150
159
  if (typeof _creator.setEarlyUpdate !== "function") return false
151
- // the frame stamp invalidates every Node's local-matrix cache once per frame (physics / character
152
- // controllers / animation rewrite transforms natively between frames)
153
- _creator.setEarlyUpdate((dt: number) => { glState.frame++; early(dt) })
160
+ // (The per-phase read-cache stamp — `_phaseStamp` in core/Aspect.ts — advances inside the phase
161
+ // runners themselves, so it is right on this path and on the setLoop fallback alike.)
162
+ _creator.setEarlyUpdate(early)
154
163
  _creator.setLateUpdate(late)
155
164
  return true
156
165
  }, (fixed) => {
157
166
  // The fixed phase runs INSIDE the engine's substep loop (creator-physics' frame protocol) when the host has the
158
- // hook; each substep moves bodies natively, so the frame stamp bumps per step too. Feature-detected:
159
- // an older host returns false and the dispatcher steps its own 1/60 accumulator.
167
+ // hook. Feature-detected: an older host returns false and the dispatcher steps its own 1/60 accumulator.
160
168
  if (typeof _creator.setFixedUpdate !== "function") return false
161
- _creator.setFixedUpdate((dt: number) => { glState.frame++; fixed(dt) })
169
+ _creator.setFixedUpdate(fixed)
162
170
  return true
163
171
  })
164
172
  // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
@@ -172,6 +180,8 @@ export class Scene implements Presentable {
172
180
  /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
173
181
  _environmentIntensity = 20000
174
182
  readonly camera: Camera
183
+ /** The listener + global 3D audio knobs (docs/audio-plan.md). */
184
+ readonly audio: SceneAudio = new SceneAudio()
175
185
 
176
186
  /** @internal touch listeners, read by the dispatch system. */
177
187
  readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
@@ -220,9 +230,11 @@ export class Scene implements Presentable {
220
230
  // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
221
231
  _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
222
232
  }
233
+ if (options.stencil !== undefined) this.setStencil(options.stencil)
223
234
  // Before anything else in this constructor that could load a texture: the value has to be in
224
235
  // place by the time assets bind their samplers.
225
- if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
236
+ if (options.anisotropy !== undefined) Texture.anisotropy = options.anisotropy
237
+ if (options.maxTextureSize !== undefined) Texture.maxSize = options.maxTextureSize
226
238
  if (options.hdr) {
227
239
  if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
228
240
  if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
@@ -280,6 +292,28 @@ export class Scene implements Presentable {
280
292
  setAntialias(enabled: boolean, scale = 4): void {
281
293
  _creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
282
294
  }
295
+ /** Depth-reading effects on / off (a graphics-settings menu): soft particles and projected decals
296
+ * read the scene depth, which costs a depth pre-pass of every opaque draw (~12 % of a fill-bound
297
+ * frame). Off = hard-edged particles, no decals, no pre-pass. Engine-wide, live. */
298
+ setDepthEffects(enabled: boolean): void {
299
+ _creator.setDepthEffects?.(enabled)
300
+ }
301
+ /** LOD distance (a graphics-settings menu): the engine's LOD thresholds × `bias`. 2 = every level
302
+ * switches at half the distance (a model must look twice as big on screen to keep its detail),
303
+ * 0.5 = full detail twice as far, 1 = the defaults. Engine-wide, live. Only GLBs that carry
304
+ * `_LOD<n>` meshes (`lecodes assets doctor --lod`) have levels to switch. */
305
+ setLodBias(bias: number): void {
306
+ _creator.setLodBias?.(bias)
307
+ }
308
+ /** Runtime form of `bloom` / `bloomIntensity` (a graphics-settings menu). */
309
+ setBloom(enabled: boolean, intensity = 0.2): void {
310
+ _creator.setBloomOptions(this._id, enabled, intensity, 1)
311
+ }
312
+ /** The scene's stencil buffer on / off (see `SceneOptions.stencil`). */
313
+ setStencil(enabled: boolean): void {
314
+ if (_creator.setSceneStencil) _creator.setSceneStencil(this._id, enabled)
315
+ else if (enabled) console.warn("[scene] this host has no setSceneStencil — stencil ignored")
316
+ }
283
317
 
284
318
  add(...nodes: Node[]): this {
285
319
  for (const n of nodes) _creator.addEntityToScene(this._id, n.id)
package/src/gl/Texture.ts CHANGED
@@ -2,7 +2,41 @@
2
2
 
3
3
  import { fetch, type FetchResponse, type File } from "../runtime/fetch"
4
4
 
5
+ /** `Texture.load` options. */
6
+ export type TextureLoadOptions = {
7
+ /** `true` (default): colour, stored sRGB. `false`: data (normal map, mask, heightmap) — kept linear. */
8
+ srgb?: boolean
9
+ /** Ignore `Texture.maxSize` for this texture (a lightmap page, a lookup table). */
10
+ fullSize?: boolean
11
+ }
12
+ /** `_creator.createTexture` flags bit: linear data (creator-gl CREATOR_TEXTURE_LINEAR). */
13
+ const TEXTURE_FLAG_LINEAR = 1
14
+ /** `_creator.createTexture` flags bit: exempt from `Texture.maxSize` (creator-gl CREATOR_TEXTURE_FULL_SIZE). */
15
+ const TEXTURE_FLAG_FULL_SIZE = 2
16
+
5
17
  export class Texture {
18
+ static _maxSize = 0
19
+ /** Engine-wide cap on texture size (a "texture quality" setting), 0 = none. A KTX2 wider or
20
+ * taller than this loses its top mip levels on load (nothing resampled, less memory and
21
+ * bandwidth), a glTF PNG/JPEG is downsampled. Reaches textures loaded AFTER it is set — a loaded
22
+ * level keeps its textures — so set it up front (`SceneOptions.maxTextureSize`, or before the
23
+ * level loads) and apply a menu change on the next level load. Lightmap pages are exempt
24
+ * (`TextureLoadOptions.fullSize`). A host may pin it (desktop `CREATOR_TEXTURE_MAX_SIZE`). */
25
+ static _anisotropy = 2
26
+ /** Engine-wide anisotropic filtering, 1 (off) … 16 (default 2; `SceneOptions.anisotropy` sets it up
27
+ * front). A sampler is baked when its texture is bound, so like `maxSize` this reaches textures
28
+ * loaded AFTER it — set it before the level loads. Measured on a lightmapped interior at 720p:
29
+ * 4× costs ~20 % of the frame over 1× on an integrated GPU. A host may pin it. */
30
+ static get anisotropy(): number { return Texture._anisotropy }
31
+ static set anisotropy(level: number) {
32
+ Texture._anisotropy = level >= 1 ? Math.min(16, level) : 1
33
+ _creator.setTextureAnisotropy?.(Texture._anisotropy)
34
+ }
35
+ static get maxSize(): number { return Texture._maxSize }
36
+ static set maxSize(size: number) {
37
+ Texture._maxSize = size > 0 ? Math.floor(size) : 0
38
+ _creator.setTextureMaxSize?.(Texture._maxSize)
39
+ }
6
40
  /** @internal native texture handle. */
7
41
  readonly _id: number
8
42
  readonly width: number
@@ -47,17 +81,23 @@ export class Texture {
47
81
  _creator.updateTexturePixels?.(this._id, x, y, width, height, data)
48
82
  }
49
83
 
50
- static load(source: string | FetchResponse | File): Promise<Texture> {
84
+ /** Decode an image (PNG / JPG, or a KTX2 the core transcodes) into a texture. `srgb` (default
85
+ * true) says the bytes are COLOUR, stored sRGB so the GPU linearises them on sample; pass
86
+ * `false` for DATA — a normal map, a mask, a heightmap — which must come back as stored (a
87
+ * flat normal read through sRGB bends by ~35°). A KTX2 decides by its own header. Image
88
+ * textures get a mip chain (trilinear) on hosts that build one. */
89
+ static load(source: string | FetchResponse | File, options: TextureLoadOptions = {}): Promise<Texture> {
51
90
  if (typeof source === "string") {
52
91
  return fetch(source, { useOnce: true }).then((resp) => {
53
92
  if (resp.status >= 400) {
54
93
  return Promise.reject(new Error(`Failed to fetch texture from ${source}. HTTP ${resp.status}`))
55
94
  }
56
- return Texture.load(resp)
95
+ return Texture.load(resp, options)
57
96
  })
58
97
  }
98
+ const flags = (options.srgb === false ? TEXTURE_FLAG_LINEAR : 0) | (options.fullSize ? TEXTURE_FLAG_FULL_SIZE : 0)
59
99
  return new Promise<Texture>((resolve, reject) => {
60
- _creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject)
100
+ _creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject, flags)
61
101
  })
62
102
  }
63
103
  }
@@ -10,7 +10,9 @@
10
10
  // AnimationClip.load(asset('./run.glb')),
11
11
  // ])
12
12
  // const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash') // one out of many
13
- // const bob = AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
13
+ // const bob = AnimationClip.fromCurves({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
14
+ // const stopM = AnimationClip.from(stop, { mirror: true }) // the other side of the body
15
+ // const kick = AnimationClip.from(take, { from: 0.2, to: 1.1 }) // a window of a longer take
14
16
 
15
17
  import { fetch, type FetchResponse } from "../../runtime/fetch"
16
18
  import type { Vec3Like } from "../../math/vec"
@@ -37,6 +39,20 @@ export type ClipDef = {
37
39
 
38
40
  export type ClipInfo = { name: string, duration: number, trackCount: number }
39
41
 
42
+ /** What `AnimationClip.from(clip, …)` derives from a clip. */
43
+ export type DeriveOptions = {
44
+ /** The clip MIRRORED — left ↔ right, the motion on the other side of the body (a stop that brakes on the
45
+ * left foot brakes on the right). Derived on the rig of the clip's own file (joints pair by name, the
46
+ * sagittal plane comes from the rest pose), so the result is the same on every model: its contacts,
47
+ * phase, root motion and heading are the mirrored ones, its name is `<name>_M`. Only clips from a GLB
48
+ * carry a rig; a curve-built clip cannot be mirrored. */
49
+ mirror?: boolean
50
+ /** Start of the window, seconds of the source (default 0). */
51
+ from?: number
52
+ /** End of the window, seconds of the source (default the clip's end). */
53
+ to?: number
54
+ }
55
+
40
56
  const PATH = { position: 0, rotation: 1, scale: 2 } as const
41
57
  const INTERP = { linear: 0, step: 1 } as const
42
58
 
@@ -90,8 +106,8 @@ export class AnimationClip {
90
106
 
91
107
  /** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
92
108
  * → `anim.on('hit', (clip, layer) => …)` fires when the playhead crosses it, loops included.
93
- * Events are part of the clip: every model playing it gets them; `slice()` keeps the ones inside
94
- * the range, re-timed. Chainable. */
109
+ * Events are part of the clip: every model playing it gets them; `AnimationClip.from(clip, { from, to })`
110
+ * keeps the ones inside the window, re-timed. Chainable. */
95
111
  addEvent(time: number, name: string): this {
96
112
  this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
97
113
  this._events.sort((a, b) => a.t - b.t)
@@ -128,7 +144,7 @@ export class AnimationClip {
128
144
 
129
145
  /** Build a clip from curves in code — no DCC needed. Keys are `[time, value]`; a track binds to the
130
146
  * node of that name when the clip is used by an Animator (bones, or any child node). */
131
- static from(def: ClipDef): AnimationClip {
147
+ static fromCurves(def: ClipDef): AnimationClip {
132
148
  const names: string[] = []
133
149
  const data: number[] = [0, 0]
134
150
  let trackCount = 0
@@ -153,30 +169,37 @@ export class AnimationClip {
153
169
  if (def.duration !== undefined) duration = def.duration
154
170
  data[1] = duration
155
171
  const setId = _creator.createClipFromTracks(names.join("\n"), new Float32Array(data))
156
- if (setId === 0) throw new Error("AnimationClip.from: invalid track data")
172
+ if (setId === 0) throw new Error("AnimationClip.fromCurves: invalid track data")
157
173
  const info = readInfo(setId)[0] ?? { name: "clip", duration, trackCount }
158
174
  return new AnimationClip(setId, 0, info)
159
175
  }
160
176
 
161
- /** A sub-range of this clip as a NEW clip — `[start, end]` seconds of the source, re-timed to 0
162
- * (`end` defaults to the clip's end; array-slice semantics). Cut a too-long take down to the
163
- * action, or carve several sub-clips out of one packed timeline:
177
+ /** A NEW clip derived from `clip`: its mirror (`{ mirror: true }` — the other side of the body), a window
178
+ * of it (`{ from, to }` seconds of the source, re-timed to 0; array-slice semantics), or both. Cut a
179
+ * too-long take down to the action, carve several sub-clips out of one packed timeline, get the
180
+ * left-footed stop from the right-footed one:
164
181
  *
165
- * clips: { Kick: kick.slice(0.2, 1.1) }
182
+ * clips: { Kick: AnimationClip.from(kick, { from: 0.2, to: 1.1 }), StopM: AnimationClip.from(stop, { mirror: true }) }
166
183
  *
167
- * Keys outside the range are dropped and exact boundary values are interpolated in, so the
168
- * sliced clip starts and ends precisely on the source's pose at the cut points. Everything
169
- * downstream (blend spaces, events' 0–1 times, root motion, phase sync) sees a normal clip of
170
- * the new duration. */
171
- slice(start: number, end?: number): AnimationClip {
172
- const e = Math.min(end ?? this.duration, this.duration)
173
- const st = Math.max(0, start)
174
- const setId = _creator.sliceClip(this._set, this._index, st, e)
175
- if (setId === 0) throw new Error(`AnimationClip.slice: bad range ${start}${end !== undefined ? `–${end}` : ""} of '${this.name}' (${this.duration.toFixed(2)}s)`)
176
- const info = readInfo(setId)[0] ?? { name: this.name, duration: e - st, trackCount: this.trackCount }
184
+ * A window drops the keys outside it and interpolates exact boundary values in, so the clip starts and
185
+ * ends precisely on the source's pose at the cut points. Everything downstream (blend spaces, events'
186
+ * times, root motion, foot contacts, phase sync) sees a normal clip. */
187
+ static from(clip: AnimationClip, options: DeriveOptions): AnimationClip {
188
+ const mirror = options.mirror ?? false
189
+ const windowed = options.from !== undefined || options.to !== undefined
190
+ const st = windowed ? Math.max(0, options.from ?? 0) : -1
191
+ const e = windowed ? Math.min(options.to ?? clip.duration, clip.duration) : -1
192
+ const setId = _creator.deriveClip(clip._set, clip._index, mirror, st, e)
193
+ if (setId === 0) {
194
+ const what = windowed ? `bad range ${options.from ?? 0}–${options.to ?? clip.duration}` : "cannot mirror (no rig in the clip's file, or nothing to mirror)"
195
+ throw new Error(`AnimationClip.from: ${what} of '${clip.name}' (${clip.duration.toFixed(2)}s)`)
196
+ }
197
+ const info = readInfo(setId)[0] ?? { name: mirror ? `${clip.name}_M` : clip.name, duration: windowed ? e - st : clip.duration, trackCount: clip.trackCount }
177
198
  const out = new AnimationClip(setId, 0, info)
178
199
  // the engine re-timed the event TIMES; carry the names the same way
179
- out._events = this._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
200
+ out._events = windowed
201
+ ? clip._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
202
+ : clip._events.map((ev) => ({ ...ev }))
180
203
  return out
181
204
  }
182
205
 
@@ -5,15 +5,16 @@
5
5
  //
6
6
  // const hero = await Model.load(asset('./hero.glb'))
7
7
  // hero.anim.playLoop('Idle') // the loop: what the character rests on
8
- // await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it, back to the loop after
8
+ // await hero.anim.play('Jump', { fade: 0.2 }) // a one-shot over it, back to the loop after
9
9
  //
10
10
  // const loco = hero.anim.playLoop({ Idle: 0, Walk: 2, Run: 6 }) // a blend space as the loop
11
11
  // loco.value = hero.controller.velocity.length // where we are on its axis
12
12
  // const upper = hero.anim.addLayer({ mask: 'Spine1' })
13
13
  // upper.playLoop('Aim') // arms aim, legs keep walking
14
14
  //
15
- // Every switch is an inertialized transition: the pose shown does not fade away, its difference to the
16
- // new source decays over `fade` (default 0.2 s; 0 = cut). A clip may be replaced at any time.
15
+ // Every switch is an inertialized transition: the pose shown (a loop, a one-shot, rest) does not fade
16
+ // away, its difference to the new source decays over `fade` — and no `fade` means a cut, never a
17
+ // default. A clip may be replaced at any time; a one-shot on a layer with no loop holds its last frame.
17
18
  //
18
19
  // The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
19
20
  // `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.