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
@@ -13,7 +13,8 @@ import { Mat4, type Mat4Like } from "../math/mat4"
13
13
  import type { Geometry } from "./Geometry"
14
14
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
15
15
  import { ensurePhysicsEvents } from "./physicsEvents"
16
- import { glState } from "./state"
16
+ import { Material } from "./Material"
17
+ import type { CompAxis, CompWriter } from "../core/compWrite"
17
18
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
18
19
 
19
20
  /** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
@@ -49,7 +50,10 @@ const findNodeByWalk = (rootId: number, name: string): number => {
49
50
  return exact || loose
50
51
  }
51
52
 
52
- export class Node extends AspectHost<NodeEvents> {
53
+ // Scratch for the world-position read-back in _setOwnedPosition (one per module, never handed out).
54
+ const worldScratch = new Float32Array(3)
55
+
56
+ export class Node extends AspectHost<NodeEvents> implements CompWriter {
53
57
  /** Native entity handle. */
54
58
  readonly id: number
55
59
 
@@ -58,11 +62,10 @@ export class Node extends AspectHost<NodeEvents> {
58
62
  isTracked = false
59
63
 
60
64
  private _matrix?: Float32Array
61
- private _lastSync = 0
62
- private _syncFrame = -1
63
65
  private _worldMatrix?: Float32Array
64
- private _scaleCache?: [number, number, number]
65
66
  private _boneCache?: Map<string, Node | null>
67
+ /** Materials assigned through setMaterial, by primitive slot (a Mesh fills slot 0 itself). */
68
+ protected _materials?: Material[]
66
69
 
67
70
  constructor(internalId?: number) {
68
71
  super()
@@ -80,29 +83,31 @@ export class Node extends AspectHost<NodeEvents> {
80
83
  set visible(value: boolean) { _creator.setVisible(this.id, value) }
81
84
 
82
85
  // --- local transform ---
83
- // Native owns the authoritative matrix; we keep a Float32Array cache (re-synced lazily because physics
84
- // can rewrite it each frame). The PUBLIC `matrix` getter hands back a fresh Mat4 *copy* (value
85
- // semantics — mutating it never touches the node until you assign it back). `_sync()` is the internal
86
- // hot path that returns the cache directly for cheap component reads.
86
+ // Native owns the authoritative matrix. `_sync()` reads it into a per-node scratch buffer on EVERY
87
+ // call: a bridge read is ~40 ns on QuickJS — what the Date.now() of a staleness check cost — so a
88
+ // cache bought nothing and needed invalidation hooks (physics / controllers / animation rewrite
89
+ // transforms natively between frames). The PUBLIC `matrix` getter hands back a fresh Mat4 *copy*
90
+ // (value semantics — mutating it never touches the node until you assign it back); `_sync()` is
91
+ // the internal hot path for component reads.
87
92
  private _sync(): Float32Array {
88
- if (!this._matrix) this._matrix = new Float32Array(16)
89
- // stale once per render frame (glState.frame — physics/controllers/animation rewrite transforms
90
- // between frames), or after 10 ms of wall time when no scene loop is running
91
- if (this._lastSync === 0 || this._syncFrame !== glState.frame || Date.now() - this._lastSync > 10) {
92
- _creator.getMatrix(this.id, this._matrix)
93
- this._scaleCache = undefined
94
- this._lastSync = Date.now()
95
- this._syncFrame = glState.frame
96
- }
97
- return this._matrix
93
+ const m = this._matrix ?? (this._matrix = new Float32Array(16))
94
+ _creator.getMatrix(this.id, m)
95
+ return m
98
96
  }
99
97
 
100
98
  get matrix(): Mat4 { return new Mat4(this._sync()) }
101
99
  set matrix(m: Mat4Like) {
102
- if (!this._matrix) this._matrix = new Float32Array(16)
103
- this._matrix.set(matArr(m))
104
- this._lastSync = Date.now()
105
- _creator.setMatrix(this.id, this._matrix)
100
+ const buf = this._matrix ?? (this._matrix = new Float32Array(16))
101
+ buf.set(matArr(m))
102
+ _creator.setMatrix(this.id, buf)
103
+ // A matrix write on a physics-owned node must move the engine object too, or it is silently
104
+ // overwritten by the next sync. Position always; rotation only for a BODY (see _setOwnedRotation).
105
+ // Scale is never routed — a Jolt shape is built at a fixed size, that is `Shape`'s job.
106
+ // `worldMatrix` composes into this setter, so it is covered here too.
107
+ if (this._xf) {
108
+ this._setOwnedPosition(buf[12], buf[13], buf[14])
109
+ this._setOwnedRotation()
110
+ }
106
111
  }
107
112
 
108
113
  get worldMatrix(): Mat4 {
@@ -120,39 +125,86 @@ export class Node extends AspectHost<NodeEvents> {
120
125
  this.matrix = p.worldMatrix.invert().mul(m)
121
126
  }
122
127
 
128
+ /** @internal The engine object that mirrors this node's transform, if any — a physics body or a
129
+ * character (0 = none). `position` writes are routed to it as well, because the engine is the
130
+ * authority: on a dynamic body a plain setPosition is overwritten by the next sync, and on a
131
+ * static / pick / trigger body it would leave the COLLIDER behind while the node moved. Set by
132
+ * Shape (bodies) and CharacterController. */
133
+ _xf = 0
134
+ /** @internal What `_xf` is: 1 = character, 2 = physics body. */
135
+ _xfKind = 0
136
+
137
+ // Route a position write to the engine object that owns this node. Kept out of the setters' hot path
138
+ // (they only pay one truthiness check). `position` stays a LOCAL coordinate like on every node, while
139
+ // physics works in world space — so the node is written first and the world point read back natively
140
+ // (one bridge call for any parent depth, no allocation; the native transform manager updates the
141
+ // world matrix synchronously).
142
+ private _setOwnedPosition(x: number, y: number, z: number): void {
143
+ _creator.setPosition(this.id, x, y, z) // keep the node itself right, so a read-back is immediate
144
+ _creator.getWorldPosition(this.id, worldScratch)
145
+ if (this._xfKind === 1) _creator.characterSetPosition(this._xf, worldScratch[0], worldScratch[1], worldScratch[2])
146
+ else _creator.physicsSetBodyPosition(this._xf, worldScratch[0], worldScratch[1], worldScratch[2])
147
+ }
148
+
149
+ // The rotation half of the same routing. BODIES ONLY: a character's rotation is node-owned by design
150
+ // (the engine writes its position and nothing else — a capsule is symmetric about its up axis, so its
151
+ // yaw is physically meaningless), and pushing one into Jolt would invent an authority that isn't there.
152
+ // The node has already been written by the caller, so the world rotation is read back from it — that
153
+ // way the engine's own euler convention decides, instead of this file re-deriving it.
154
+ private _setOwnedRotation(): void {
155
+ if (this._xfKind !== 2 || !_creator.physicsSetBodyRotation) return
156
+ const q = this.worldMatrix.rotation
157
+ _creator.physicsSetBodyRotation(this._xf, q.x, q.y, q.z, q.w)
158
+ }
159
+
123
160
  get position(): Vec3 { const m = this._sync(); return new Vec3(m[12], m[13], m[14]) }
124
- set position(v: Vec3Like) { this._lastSync = 0; _creator.setPosition(this.id, cx(v), cy(v), cz(v)) }
161
+ set position(v: Vec3Like) {
162
+ if (this._xf) this._setOwnedPosition(cx(v), cy(v), cz(v))
163
+ else _creator.setPosition(this.id, cx(v), cy(v), cz(v))
164
+ }
125
165
 
126
- // Scalar position accessors — the loud, correct way to nudge one axis (`node.x = 3`), so nobody reaches
127
- // for `node.position.x = 3` (a no-op: the getter returns a fresh copy).
166
+ // Scalar position accessors — one axis without touching the others (`node.x = 3`). The direct spelling
167
+ // `node.position.x = 3` compiles to the same thing (chisel's comp_write pass routes it through
168
+ // `_writeComp` below); only a STORED copy of `position` is still a copy.
128
169
  get x(): number { return this._sync()[12] }
129
- set x(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, v, m[13], m[14]) }
170
+ set x(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(v, m[13], m[14]); else _creator.setPosition(this.id, v, m[13], m[14]) }
130
171
  get y(): number { return this._sync()[13] }
131
- set y(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], v, m[14]) }
172
+ set y(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(m[12], v, m[14]); else _creator.setPosition(this.id, m[12], v, m[14]) }
132
173
  get z(): number { return this._sync()[14] }
133
- set z(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], m[13], v) }
174
+ set z(v: number) { const m = this._sync(); if (this._xf) this._setOwnedPosition(m[12], m[13], v); else _creator.setPosition(this.id, m[12], m[13], v) }
175
+
176
+ /** @internal chisel `comp_write` (see core/compWrite.ts): `node.position.<axis> = v` is compiled to a
177
+ * call here; the scalar setters already carry the physics routing. The list is compile-time data. */
178
+ static _comps = [ "position" ]
179
+ _writeComp(_prop: string, axis: CompAxis, v: number): void {
180
+ if (axis === "x") this.x = v
181
+ else if (axis === "y") this.y = v
182
+ else if (axis === "z") this.z = v
183
+ }
134
184
 
135
185
  get scale(): Vec3 {
136
- if (this._lastSync === 0 || !this._scaleCache) {
137
- const m = this._sync()
138
- this._scaleCache = [ Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]) ]
139
- }
140
- return new Vec3(this._scaleCache)
186
+ const m = this._sync()
187
+ return new Vec3(Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]))
141
188
  }
142
189
  set scale(v: Vec3Like | number) {
143
- this._lastSync = 0
144
190
  if (typeof v === "number") _creator.setScale(this.id, v, v, v)
145
191
  else _creator.setScale(this.id, cx(v), cy(v), cz(v))
146
192
  }
147
193
 
148
194
  get quaternion(): Quat { return new Mat4(this._sync()).rotation }
149
- set quaternion(v: QuatLike) { this._lastSync = 0; _creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v)) }
195
+ set quaternion(v: QuatLike) {
196
+ _creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v))
197
+ if (this._xf) this._setOwnedRotation()
198
+ }
150
199
 
151
200
  get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
152
201
  // order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
153
202
  // the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix
154
203
  // transposed — the setter applied the INVERSE rotation. Both fixed 2026-07-10.)
155
- set eulerAngles(v: Vec3Like) { this._lastSync = 0; _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 1) }
204
+ set eulerAngles(v: Vec3Like) {
205
+ _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 1)
206
+ if (this._xf) this._setOwnedRotation()
207
+ }
156
208
 
157
209
  // --- world-space reads ---
158
210
  get forward(): Vec3 {
@@ -190,13 +242,29 @@ export class Node extends AspectHost<NodeEvents> {
190
242
  setParent(parent: Node | null, worldPositionStays = false): this {
191
243
  if (parent === null) _creator.setParentNull(this.id, worldPositionStays)
192
244
  else _creator.setParent(this.id, parent.id, worldPositionStays)
193
- if (worldPositionStays) this._lastSync = 0
194
245
  return this
195
246
  }
196
247
  traverse(callback: (node: Node) => void): void {
197
248
  callback(this)
198
249
  _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
199
250
  }
251
+ // --- materials (any renderable: a Mesh, or an internal node of a loaded Model) ---
252
+ /** The material assigned to slot 0 through this API (null before one is set — a GLB part's own
253
+ * glTF material stays native-side). */
254
+ get material(): Material | null { return this.getMaterial(0) }
255
+ set material(m: Material) { this.setMaterial(m, 0) }
256
+ /** Replace the material of one primitive slot (`index` = the primitive's order in the glTF
257
+ * mesh; a primitive-shape Mesh has one slot). */
258
+ setMaterial(material: Material, index = 0): this {
259
+ (this._materials ??= [])[index] = material
260
+ _creator.setMaterial(this.id, Material.idOf(material), index)
261
+ return this
262
+ }
263
+ /** The material previously assigned to `index` (null when none was — see Mesh for the probe). */
264
+ getMaterial(index = 0): Material | null {
265
+ return this._materials?.[index] ?? null
266
+ }
267
+
200
268
  /** Find a descendant by name — bones of a loaded Model included (`hero.bone('RightHand').add(sword)`).
201
269
  * Same rule the Animator binds clips with: exact name first, then the part after the last `:` / `|`
202
270
  * (Mixamo `mixamorig:Hips` matches `Hips`); a skinned joint beats a plain node of the same name.
@@ -260,8 +328,20 @@ export class Node extends AspectHost<NodeEvents> {
260
328
  _emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
261
329
 
262
330
 
331
+ /** Destroy this node and its whole subtree. Every aspect in the subtree is detached first (its
332
+ * `onDetach` runs: physics bodies released, updaters unregistered, listeners dropped) — native
333
+ * `destroyEntity` frees the entity tree but knows nothing about JS-side aspects, and a destroyed
334
+ * node's `update()` must not keep ticking. Deepest nodes go first, then this one. */
263
335
  destroy(): void {
264
- nodeRegistry.delete(this.id)
336
+ const subtree: Node[] = [this]
337
+ _creator.traverse(this.id, (id) => { const n = nodeRegistry.get(id); if (n) subtree.push(n) })
338
+ for (let i = subtree.length - 1; i >= 0; i--) {
339
+ const n = subtree[i]
340
+ n._detachAll()
341
+ n._xf = 0
342
+ n._xfKind = 0
343
+ nodeRegistry.delete(n.id)
344
+ }
265
345
  _creator.destroyEntity(this.id)
266
346
  }
267
347
  }
@@ -8,17 +8,23 @@
8
8
  // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
9
  //
10
10
  // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
11
- // velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic bodies
12
- // are driven with moveTo.
11
+ // velocity / applyImpulse. Reading node.position is free (native already wrote it). A `position` write
12
+ // is a TELEPORT that reaches the body (Shape routes it), which is how a kinematic body is placed too.
13
13
 
14
14
  import { Aspect } from "../core/Aspect"
15
15
  import type { FieldMeta } from "../core/fields"
16
+ import type { CompAxis, CompWriter } from "../core/compWrite"
16
17
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
17
18
  import { nodeRegistry, type Node } from "./Node"
18
19
  import { Shape } from "./Shape"
19
20
 
20
21
  export type MotionType = "static" | "dynamic" | "kinematic"
21
22
  const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
23
+ // Jolt's angular velocity is rad/s; every angle the SDK exposes is in degrees (like `eulerAngles`).
24
+ const DEG2RAD = Math.PI / 180
25
+ const RAD2DEG = 180 / Math.PI
26
+ // Scratch for velocity reads (one per module, never handed out) — a read allocates the Vec3 only.
27
+ const scratch = new Float32Array(3)
22
28
 
23
29
  export interface PhysicsConfig {
24
30
  /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
@@ -35,7 +41,14 @@ export interface RayHit {
35
41
  fraction: number
36
42
  }
37
43
 
38
- export class Physics extends Aspect<"physics", Node> {
44
+ /**
45
+ * What a body's friction already is the moment the host creates it. This is part of the ABI, not an
46
+ * assumption: bridges.d.ts states it, so every host owes us this value. Knowing it lets attach skip
47
+ * a bridge call per body whenever nobody named a surface.
48
+ */
49
+ export const DEFAULT_FRICTION = 0.6
50
+
51
+ export class Physics extends Aspect<"physics", Node> implements CompWriter {
39
52
  static readonly aspect = "physics"
40
53
 
41
54
  /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
@@ -60,11 +73,19 @@ export class Physics extends Aspect<"physics", Node> {
60
73
  * that decides how well a car corners.
61
74
  *
62
75
  * Change it at runtime by re-configuring — `floor.aspect(Physics, { friction: 0.02 })` (an ice
63
- * patch). It is a PLAIN FIELD on purpose: an accessor pair would be tree-shaken out of the
64
- * bundle, because passing `{ friction }` in a config object is not a member reference the
65
- * bundler can see, and the write would then silently land on a dead property.
76
+ * patch) — or by assigning `floor.physics.friction = 0.02`; either way the setter pushes the new
77
+ * surface to the live body at once. (chisel keeps an instance setter whose name appears as an
78
+ * object-literal key anywhere in the bundle, so the config-object route survives tree-shaking
79
+ * even when no code reads `.friction`.)
66
80
  */
67
- friction = 0.6
81
+ get friction(): number { return this._friction }
82
+ set friction(v: number) {
83
+ this._friction = v
84
+ // Unconditional on a live body, unlike attach: it may be on its way BACK to the default from
85
+ // something else, and skipping that would leave the ice patch in place.
86
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, v)
87
+ }
88
+ private _friction = DEFAULT_FRICTION
68
89
 
69
90
  /** Scene-editor inspector: `motion` as a dropdown (a string default alone infers a text box). */
70
91
  static fields: FieldMeta<Physics> = {
@@ -86,7 +107,9 @@ export class Physics extends Aspect<"physics", Node> {
86
107
  }
87
108
  const shapeId = shape._claim() // drop the pick-only body; reuse its shape
88
109
  this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
89
- this._applyFriction()
110
+ // A fresh body already carries DEFAULT_FRICTION host-side, so only a NAMED surface has to
111
+ // travel — the common case costs no bridge call at all.
112
+ if (this._friction !== DEFAULT_FRICTION) _creator.physicsSetFriction?.(this._bodyId, this._friction)
90
113
  shape._ownBody(this._bodyId)
91
114
  }
92
115
 
@@ -99,26 +122,57 @@ export class Physics extends Aspect<"physics", Node> {
99
122
  }
100
123
  }
101
124
 
102
- /** Re-configuring (`node.aspect(Physics, { friction })`) pushes the new surface to the live body. */
103
- onReconfigure(): void { this._applyFriction() }
104
-
105
- private _applyFriction(): void {
106
- if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
107
- }
108
-
109
125
  /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
110
126
  get id(): number { return this._bodyId }
111
127
 
112
- /** Linear velocity in world units/second (fresh Vec3 on read). */
128
+ /** Linear velocity in world units/second (fresh Vec3 on read; `velocity.y = 5` written directly on
129
+ * the aspect is compiled to a component write — see `_writeComp`). */
113
130
  get velocity(): Vec3 {
114
- const out = new Float32Array(3)
115
- if (this._bodyId) _creator.physicsGetLinearVelocity(this._bodyId, out)
116
- return new Vec3(out[0], out[1], out[2])
131
+ if (!this._bodyId) return new Vec3(0, 0, 0)
132
+ _creator.physicsGetLinearVelocity(this._bodyId, scratch)
133
+ return new Vec3(scratch[0], scratch[1], scratch[2])
117
134
  }
118
135
  set velocity(v: Vec3Like) {
119
136
  if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
120
137
  }
121
138
 
139
+ /**
140
+ * Angular velocity — **degrees/second about each world axis** (the SDK's angle unit everywhere;
141
+ * the engine works in radians and converts here). Fresh Vec3 on read.
142
+ *
143
+ * This is the only handle on a body's spin, and placing an object usually needs it: a `position`
144
+ * write is a pure teleport, so a crate that was tumbling keeps tumbling at its new home. Putting
145
+ * something down is `body.velocity = [0,0,0]; body.angularVelocity = [0,0,0]; node.position = p`.
146
+ */
147
+ get angularVelocity(): Vec3 {
148
+ if (!this._bodyId || !_creator.physicsGetAngularVelocity) return new Vec3(0, 0, 0)
149
+ _creator.physicsGetAngularVelocity(this._bodyId, scratch)
150
+ return new Vec3(scratch[0] * RAD2DEG, scratch[1] * RAD2DEG, scratch[2] * RAD2DEG)
151
+ }
152
+ set angularVelocity(v: Vec3Like) {
153
+ if (this._bodyId && _creator.physicsSetAngularVelocity) {
154
+ _creator.physicsSetAngularVelocity(this._bodyId, cx(v) * DEG2RAD, cy(v) * DEG2RAD, cz(v) * DEG2RAD)
155
+ }
156
+ }
157
+
158
+ /** @internal chisel `comp_write` (see core/compWrite.ts): `body.velocity.y = v` / `body.angularVelocity.z
159
+ * = v` written directly on the aspect compile to a call here — one read into the scratch, one set, no
160
+ * vector. The list is compile-time data. */
161
+ static _comps = [ "velocity", "angularVelocity" ]
162
+ _writeComp(prop: string, axis: CompAxis, v: number): void {
163
+ if (!this._bodyId || axis === "w") return
164
+ const i = axis === "x" ? 0 : axis === "y" ? 1 : 2
165
+ if (prop === "velocity") {
166
+ _creator.physicsGetLinearVelocity(this._bodyId, scratch)
167
+ scratch[i] = v
168
+ _creator.physicsSetLinearVelocity(this._bodyId, scratch[0], scratch[1], scratch[2])
169
+ } else if (_creator.physicsGetAngularVelocity && _creator.physicsSetAngularVelocity) {
170
+ _creator.physicsGetAngularVelocity(this._bodyId, scratch)
171
+ scratch[i] = v * DEG2RAD
172
+ _creator.physicsSetAngularVelocity(this._bodyId, scratch[0], scratch[1], scratch[2])
173
+ }
174
+ }
175
+
122
176
  /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
123
177
  applyImpulse(v: Vec3Like): this {
124
178
  if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
@@ -138,11 +192,8 @@ export class Physics extends Aspect<"physics", Node> {
138
192
  return this
139
193
  }
140
194
 
141
- /** Teleport / drive a kinematic (or dynamic) body to a world position. */
142
- moveTo(p: Vec3Like): this {
143
- if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
144
- return this
145
- }
195
+ // No moveTo(): `node.position = p` IS the move. Shape hands the body id to the node, so a position
196
+ // write reaches the body — one way to place anything, whether or not physics is involved.
146
197
 
147
198
  // --- world (static) -------------------------------------------------------------------------
148
199
  /** Configure gravity / limits. Call once before creating bodies. */
@@ -6,8 +6,9 @@
6
6
  // direct light + shadows.
7
7
 
8
8
  import { Color, type ColorInput } from "../core/color"
9
- import { _installAspectFrames } from "../core/Aspect"
9
+ import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
10
10
  import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
11
+ import { device } from "../runtime/device"
11
12
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
12
13
  import type { FetchResponse } from "../runtime/fetch"
13
14
  import { _requestCameraPermission } from "../plugins/permission"
@@ -18,6 +19,42 @@ import { Node, nodeRegistry } from "./Node"
18
19
  import { glState } from "./state"
19
20
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
20
21
 
22
+ export type AmbientOcclusionOptions = {
23
+ /** Strength of the darkening (default 1). */
24
+ intensity?: number
25
+ /** How far the occlusion reaches, in metres (default 0.3). */
26
+ radius?: number
27
+ /** Falloff contrast; >1 tightens it into the crease (default 1). */
28
+ power?: number
29
+ /** Sample count + filtering (default 'medium'). Not the buffer resolution — that stays half. */
30
+ quality?: "low" | "medium" | "high" | "ultra"
31
+ }
32
+
33
+ export type FogOptions = {
34
+ /** A TINT on the in-scattered ambient, not an absolute colour: the engine multiplies it by the
35
+ * environment luminance, so white (the default) means "fog as bright as the ambient" and the fog
36
+ * brightens with `environmentIntensity`. Do NOT expect the same hex to look like it does in
37
+ * `skybox` — that one IS an absolute radiance and reads roughly an order of magnitude brighter.
38
+ * Tint towards the sky's hue; leave it white to sit at the ambient level. */
39
+ color?: ColorInput
40
+ /** Metres from the camera before the fog starts (default 0). */
41
+ start?: number
42
+ /** Extinction per metre at `height`; 0.01 ≈ clearly visible over ~100 m (default 0.01). */
43
+ density?: number
44
+ /** The fog's "sea level" in world Y (default 0). */
45
+ height?: number
46
+ /** How fast it thins with altitude, 1/m. 0 = uniform everywhere; higher = a ground-hugging layer
47
+ * you can see over (default 0). */
48
+ heightFalloff?: number
49
+ /** Cap on how opaque it can get, 0–1 — keeps far shapes readable (default 1). */
50
+ maxOpacity?: number
51
+ /** Metres after which fog stops applying; 0 = everywhere (default 0). */
52
+ cutoff?: number
53
+ /** Take the colour from the environment in the view direction, tinted by `color`, instead of a
54
+ * flat `color`. Convincing when the IBL is a real sky (default false). */
55
+ fromEnvironment?: boolean
56
+ }
57
+
21
58
  export type SceneOptions = {
22
59
  /** Image-based ambient lighting (default on). */
23
60
  ibl?: boolean
@@ -31,8 +68,29 @@ export type SceneOptions = {
31
68
  * saturation until very bright; `'linear'` clips each channel (what an engine without a
32
69
  * tonemapper shows — saturated, Unity-without-post-processing look); `'filmic'` (Uncharted). */
33
70
  toneMapping?: "aces" | "neutral" | "linear" | "filmic"
34
- /** Skybox / clear color. */
35
- skybox?: ColorInput
71
+ /** The sky. Three forms:
72
+ * - a colour — a flat clear colour;
73
+ * - `{ texture }` — a KTX1 **cubemap**, the sharp `<name>_skybox.ktx` that filament's `cmgen`
74
+ * produces from an equirectangular .hdr/.exr. This is the one to use for a real sky. Pass an
75
+ * `asset('../assets/sky.ktx')` handle or a bare staged filename;
76
+ * - `'environment'` — reuse the scene's IBL cubemap. Cheapest (no second texture) but that file
77
+ * is prefiltered for roughness, so the sky comes out soft; fine as a fallback, not as the goal.
78
+ *
79
+ * Either texture form draws through filament's own skybox pass: a full-screen pass in device
80
+ * space after the opaque queue. No geometry, no meridian seam, no pole distortion — do NOT build
81
+ * a sky dome or a fullscreen equirect material by hand, both are strictly worse. */
82
+ skybox?: ColorInput | "environment" | { texture: string }
83
+ /** Screen-space ambient occlusion — the contact darkening in creases and where props meet the
84
+ * ground. Without it an IBL lights a crease exactly as brightly as an open face, so everything
85
+ * reads as pasted onto the floor rather than standing on it. `true` takes defaults tuned for
86
+ * human-scale props; `radius` is world-space metres and is the one knob that must follow the
87
+ * scene's scale (~0.3 for objects on a table, ~0.6-1 for a yard of crates and containers). */
88
+ ambientOcclusion?: boolean | AmbientOcclusionOptions
89
+ /** Distance fog / aerial perspective: distant geometry loses contrast so the eye reads depth, and
90
+ * the hard edge where a finite level ends against the skybox goes away. By default the fog applies
91
+ * at every distance — the skybox included — so the sky itself takes the fog colour and the horizon
92
+ * blends on its own; `cutoff` opts geometry beyond a distance back out. */
93
+ fog?: FogOptions
36
94
  /** Multisample anti-aliasing: `true` = 4×, `2`/`4` = that many samples, `false` = off (FXAA
37
95
  * takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
38
96
  * fill-rate cost after resolution — turn it down on big screens before anything else. */
@@ -45,6 +103,27 @@ export type SceneOptions = {
45
103
  * (Filament dynamic resolution, sharpened upscale) down to `min` (default 0.5). Off by default:
46
104
  * it makes the output frame-time dependent, so flow tests / headless renders never enable it. */
47
105
  dynamicResolution?: boolean | { min?: number }
106
+ /** Anisotropic filtering, 1 (off) … 16. Default 2. What it buys is detail on surfaces seen at a
107
+ * GRAZING angle — a first-person weapon, a floor, a wall — where the sample footprint is far
108
+ * longer in one axis than the other and isotropic filtering has to pick a mip for the long one,
109
+ * several levels coarser than the short axis deserves. Measured on a weapon seen from the side
110
+ * (local contrast): 8.18 at 1× → 10.02 at 2× → 10.84 at 4× → 11.29 at 8×. The default is 2
111
+ * because it is where the curve is steepest per unit of memory bandwidth; raise it if you have
112
+ * the headroom.
113
+ *
114
+ * It is an ENGINE-WIDE default, not a property of this scene: a sampler is baked when its
115
+ * texture is bound, so this reaches the models a scene loads AFTER it (which is every model in a
116
+ * scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
117
+ * On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
118
+ anisotropy?: number
119
+ /** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
120
+ * macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
121
+ * display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
122
+ * 1..8 is where white lands as a multiple of SDR white — the "HDR brightness" of a console
123
+ * calibration screen, where 1.5–2 is the norm; default 1 keeps a white wall at the UI's white,
124
+ * faithful but dim next to what "HDR on" is expected to look like. Engine-wide like `anisotropy`;
125
+ * `device.hdr` is the runtime form and tells you whether the display has any headroom at all. */
126
+ hdr?: { strength?: number; paperWhite?: number }
48
127
  }
49
128
 
50
129
  // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
@@ -63,11 +142,16 @@ const installRenderSyncedFrames = (): void => {
63
142
  _creator.setLateUpdate(late)
64
143
  return true
65
144
  })
145
+ // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
146
+ // setTimeScale; a host without it keeps running its sims at wall-clock speed (feature-detected).
147
+ _installTimeScale((scale) => { _creator.setTimeScale?.(scale) })
66
148
  }
67
149
 
68
150
  export class Scene implements Presentable {
69
151
  /** @internal native scene handle. */
70
152
  readonly _id: number
153
+ /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
154
+ _environmentIntensity = 20000
71
155
  readonly camera: Camera
72
156
 
73
157
  /** @internal touch listeners, read by the dispatch system. */
@@ -97,16 +181,26 @@ export class Scene implements Presentable {
97
181
  }
98
182
 
99
183
  if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
184
+ this._environmentIntensity = options.ibl === false ? 0 : (options.environmentIntensity ?? 20000)
100
185
  if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
101
186
  if (options.toneMapping !== undefined && options.toneMapping !== "aces") {
102
187
  _creator.setToneMapping?.(this._id, { neutral: 1, linear: 2, filmic: 3 }[options.toneMapping] ?? 0)
103
188
  }
104
- if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
189
+ if (options.skybox !== undefined) this.skybox = options.skybox
190
+ if (options.ambientOcclusion) this.setAmbientOcclusion(options.ambientOcclusion)
191
+ if (options.fog) this.setFog(options.fog)
105
192
  if (options.antialias !== undefined) {
106
193
  const a = options.antialias
107
194
  // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
108
195
  _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
109
196
  }
197
+ // Before anything else in this constructor that could load a texture: the value has to be in
198
+ // place by the time assets bind their samplers.
199
+ if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
200
+ if (options.hdr) {
201
+ if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
202
+ if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
203
+ }
110
204
  if (options.renderScale !== undefined || options.dynamicResolution) {
111
205
  this.setRenderOptions(options.renderScale ?? 1, options.dynamicResolution ?? false)
112
206
  }
@@ -125,7 +219,34 @@ export class Scene implements Presentable {
125
219
  return this._material
126
220
  }
127
221
 
128
- set skybox(color: ColorInput) { _creator.setSkybox(this._id, Color.toPackedRgb(color)) }
222
+ set skybox(sky: ColorInput | "environment" | { texture: string }) {
223
+ if (sky === "environment") { _creator.setSkyboxFromEnvironment?.(this._id); return }
224
+ if (typeof sky === "object" && sky !== null && "texture" in sky) {
225
+ const src = sky.texture
226
+ // `asset()` hands back "id:<n>" (already staged); a bare name is resolved on the spot.
227
+ const id = src.startsWith("id:") ? Number(src.slice(3)) : _creatorUtils.fetchLocal(src)
228
+ _creator.setSkyboxTexture?.(this._id, id)
229
+ return
230
+ }
231
+ _creator.setSkybox(this._id, Color.toPackedRgb(sky))
232
+ }
233
+
234
+ /** Runtime form of `ambientOcclusion` (a graphics-settings menu). `false` turns it off. */
235
+ setAmbientOcclusion(options: boolean | AmbientOcclusionOptions): void {
236
+ const o: AmbientOcclusionOptions = typeof options === "object" ? options : {}
237
+ const quality = { low: 0, medium: 1, high: 2, ultra: 3 }[o.quality ?? "medium"] ?? 1
238
+ _creator.setAmbientOcclusionOptions?.(this._id, options !== false, o.intensity ?? 1, o.radius ?? 0.3, o.power ?? 1, quality)
239
+ }
240
+
241
+ /** Runtime form of `fog` (weather, entering a building). `false` turns it off. */
242
+ setFog(options: FogOptions | false): void {
243
+ const o: FogOptions = options === false ? {} : options
244
+ _creator.setFogOptions?.(
245
+ this._id, options !== false, Color.toPackedRgb(o.color ?? "#ffffff"),
246
+ o.start ?? 0, o.density ?? 0.01, o.height ?? 0, o.heightFalloff ?? 0,
247
+ o.maxOpacity ?? 1, o.cutoff ?? 0, o.fromEnvironment ?? false,
248
+ )
249
+ }
129
250
 
130
251
  setMaterialGlobalParameter(i: number, x: number, y: number, z: number, w: number): void {
131
252
  _creator.setMaterialGlobalParameter(this._id, i, x, y, z, w)
@@ -143,6 +264,41 @@ export class Scene implements Presentable {
143
264
  return this
144
265
  }
145
266
 
267
+ // ---- systems: aspects of the scene (core/Aspect.ts `System`) ----
268
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
269
+ readonly _systems = new Map<Function, Aspect<any, any, any>>()
270
+
271
+ /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
272
+ * as now-having it (`scene.system(Hud).hud`). A `System<'x', Scene2D>` is rejected here. */
273
+ // `this: Self` (not `Self & Scene`): with the intersection TS infers Self = Scene and a chain
274
+ // `scene.system(A).system(B)` loses A's accessor from the result type.
275
+ system<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
276
+ this: Self,
277
+ ctor: AspectCtor<A>,
278
+ opts?: Partial<A>,
279
+ ): Self & FieldOf<A> {
280
+ return _attachSystem(this as unknown as Scene, ctor, opts) as unknown as Self & FieldOf<A>
281
+ }
282
+ /** Safe access — undefined if the system isn't attached. */
283
+ get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
284
+ return this._systems.get(ctor) as A | undefined
285
+ }
286
+ /** Existence check AND type guard: inside `if (scene.has(Hud))`, `scene.hud` is present. */
287
+ has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
288
+ return this._systems.has(ctor)
289
+ }
290
+ /** Detach a system (runs its onDetach). Chainable. */
291
+ removeSystem<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
292
+ _detachSystem(this, ctor)
293
+ return this
294
+ }
295
+ /** Tear the scene down: every system detaches (last-attached first) and the scene closes. Nodes
296
+ * are yours — destroy the ones you own; the native scene object itself is not released. */
297
+ destroy(): void {
298
+ _detachAllSystems(this)
299
+ this.close()
300
+ }
301
+
146
302
  createOverlay(options: SceneOptions = {}): Scene {
147
303
  return new Scene({ ...options, _parent: this } as any)
148
304
  }