lecodes-cli 0.17.2 → 0.18.1

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 +2376 -755
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +52 -8
  6. package/runtime/sdk/compile/assetMacro.ts +116 -15
  7. package/runtime/sdk/compile/bundler.ts +39 -4
  8. package/runtime/sdk/compile/compileProject.ts +16 -1
  9. package/runtime/sdk/compile/header.ts +6 -1
  10. package/runtime/sdk/compile/index.ts +31 -0
  11. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  12. package/runtime/sdk/compile/sceneEditor.ts +11 -1
  13. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  14. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  15. package/runtime/sdk/core/Aspect.ts +363 -95
  16. package/runtime/sdk/core/compWrite.ts +42 -0
  17. package/runtime/sdk/core/fields.ts +1 -1
  18. package/runtime/sdk/core/time.ts +81 -0
  19. package/runtime/sdk/g2/Camera2D.ts +8 -1
  20. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  21. package/runtime/sdk/g2/Node2D.ts +80 -10
  22. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  23. package/runtime/sdk/g2/Physics2D.ts +240 -30
  24. package/runtime/sdk/g2/Scene2D.ts +33 -1
  25. package/runtime/sdk/g2/Shape2D.ts +218 -22
  26. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  27. package/runtime/sdk/g2/groups2d.ts +106 -0
  28. package/runtime/sdk/g2/loop.ts +15 -4
  29. package/runtime/sdk/gl/Camera.ts +41 -0
  30. package/runtime/sdk/gl/CameraPlace.ts +52 -0
  31. package/runtime/sdk/gl/CharacterController.ts +184 -55
  32. package/runtime/sdk/gl/Gearbox.ts +212 -0
  33. package/runtime/sdk/gl/Geometry.ts +70 -9
  34. package/runtime/sdk/gl/IK.ts +193 -174
  35. package/runtime/sdk/gl/Light.ts +64 -2
  36. package/runtime/sdk/gl/Lightmap.ts +179 -0
  37. package/runtime/sdk/gl/Material.ts +36 -0
  38. package/runtime/sdk/gl/Mesh.ts +6 -23
  39. package/runtime/sdk/gl/Model.ts +23 -8
  40. package/runtime/sdk/gl/Node.ts +350 -285
  41. package/runtime/sdk/gl/Physics.ts +222 -126
  42. package/runtime/sdk/gl/Scene.ts +175 -8
  43. package/runtime/sdk/gl/Shape.ts +255 -12
  44. package/runtime/sdk/gl/Trigger.ts +1 -6
  45. package/runtime/sdk/gl/Vehicle.ts +473 -0
  46. package/runtime/sdk/gl/Wheel.ts +240 -0
  47. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  48. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  49. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  50. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  51. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  52. package/runtime/sdk/gl/animation/core.ts +294 -0
  53. package/runtime/sdk/gl/scenarios.ts +291 -349
  54. package/runtime/sdk/inject.ts +186 -162
  55. package/runtime/sdk/runtime/app.ts +13 -0
  56. package/runtime/sdk/runtime/input.ts +169 -6
  57. package/runtime/sdk/scene/defineScene.ts +1227 -1016
  58. package/runtime/sdk/scene/gizmos.ts +148 -0
  59. package/runtime/sdk/scene/material.ts +188 -0
  60. package/runtime/sdk-types.json +1 -1
  61. package/runtime/sdk/gl/Animator.ts +0 -642
  62. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -1,126 +1,222 @@
1
- // Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
2
- // aspect on the same node (its geometry becomes the body's collision shape).
3
- //
4
- // Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
5
- // ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
6
- // crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
7
- // crate.physics.applyImpulse([0, 6, 0])
8
- // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
- //
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.
13
-
14
- import { Aspect } from "../core/Aspect"
15
- import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
16
- import { nodeRegistry, type Node } from "./Node"
17
- import { Shape } from "./Shape"
18
-
19
- export type MotionType = "static" | "dynamic" | "kinematic"
20
- const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
21
-
22
- export interface PhysicsConfig {
23
- /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
24
- gravity?: Vec3Like
25
- /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
26
- maxBodies?: number
27
- }
28
-
29
- /** Closest raycast hit. */
30
- export interface RayHit {
31
- node: Node | null
32
- point: Vec3
33
- normal: Vec3
34
- fraction: number
35
- }
36
-
37
- export class Physics extends Aspect<"physics", Node> {
38
- static readonly aspect = "physics"
39
-
40
- /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
41
- * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
42
- * smoothness — bodies then advance in discrete steps. */
43
- private static _interpolation = true
44
- static get interpolation(): boolean { return Physics._interpolation }
45
- static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
46
-
47
- /** Default "dynamic". */
48
- motion: MotionType = "dynamic"
49
- /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
50
- mass = 1
51
-
52
- private _bodyId = 0
53
-
54
- onAttach(): void {
55
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
56
- const shape = this.node.get(Shape)
57
- if (!shape) {
58
- throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
59
- }
60
- if (this.motion === "dynamic" && shape._isTriangleMesh) {
61
- throw new Error("Physics: a dynamic body cannot use Shape { mesh: true } (triangle meshes are static/kinematic only) — use { mesh: 'convex' } or a primitive shape")
62
- }
63
- const shapeId = shape._claim() // drop the pick-only body; reuse its shape
64
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
65
- shape._ownBody(this._bodyId)
66
- }
67
-
68
- onDetach(): void {
69
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
70
- const shape = this.node.get(Shape)
71
- if (shape) {
72
- shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
73
- shape._recreatePickBody() // keep the node pickable if the Shape stays
74
- }
75
- }
76
-
77
- /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
78
- get id(): number { return this._bodyId }
79
-
80
- /** Linear velocity in world units/second (fresh Vec3 on read). */
81
- get velocity(): Vec3 {
82
- const out = new Float32Array(3)
83
- if (this._bodyId) _creator.physicsGetLinearVelocity(this._bodyId, out)
84
- return new Vec3(out[0], out[1], out[2])
85
- }
86
- set velocity(v: Vec3Like) {
87
- if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
88
- }
89
-
90
- /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
91
- applyImpulse(v: Vec3Like): this {
92
- if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
93
- return this
94
- }
95
-
96
- /** Teleport / drive a kinematic (or dynamic) body to a world position. */
97
- moveTo(p: Vec3Like): this {
98
- if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
99
- return this
100
- }
101
-
102
- // --- world (static) -------------------------------------------------------------------------
103
- /** Configure gravity / limits. Call once before creating bodies. */
104
- static configure(config: PhysicsConfig = {}): void {
105
- const g = config.gravity ?? [0, -9.81, 0]
106
- _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
107
- }
108
-
109
- /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
110
- static get supported(): boolean {
111
- return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
112
- }
113
-
114
- /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
115
- static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
116
- const out = new Float32Array(7)
117
- const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
118
- if (!id) return null
119
- return {
120
- node: nodeRegistry.get(id) ?? null,
121
- point: new Vec3(out[0], out[1], out[2]),
122
- normal: new Vec3(out[3], out[4], out[5]),
123
- fraction: out[6],
124
- }
125
- }
126
- }
1
+ // Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
2
+ // aspect on the same node (its geometry becomes the body's collision shape).
3
+ //
4
+ // Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
5
+ // ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
6
+ // crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
7
+ // crate.physics.applyImpulse([0, 6, 0])
8
+ // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
+ //
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). A `position` write
12
+ // is a TELEPORT that reaches the body (Shape routes it), which is how a kinematic body is placed too.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import type { FieldMeta } from "../core/fields"
16
+ import type { CompAxis, CompWriter } from "../core/compWrite"
17
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
18
+ import { nodeRegistry, type Node } from "./Node"
19
+ import { Shape } from "./Shape"
20
+
21
+ export type MotionType = "static" | "dynamic" | "kinematic"
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)
28
+
29
+ export interface PhysicsConfig {
30
+ /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
31
+ gravity?: Vec3Like
32
+ /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
33
+ maxBodies?: number
34
+ }
35
+
36
+ /** Closest raycast hit. */
37
+ export interface RayHit {
38
+ node: Node | null
39
+ point: Vec3
40
+ normal: Vec3
41
+ fraction: number
42
+ }
43
+
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 {
52
+ static readonly aspect = "physics"
53
+
54
+ /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
55
+ * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
56
+ * smoothness — bodies then advance in discrete steps. */
57
+ private static _interpolation = true
58
+ static get interpolation(): boolean { return Physics._interpolation }
59
+ static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
60
+
61
+ /** Default "dynamic". */
62
+ motion: MotionType = "dynamic"
63
+ /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
64
+ mass = 1
65
+ /**
66
+ * Surface friction: 0 = ice, ~1 = grippy asphalt, and values above 1 are allowed (rubber on
67
+ * tarmac).
68
+ *
69
+ * The engine COMBINES the two touching bodies as `sqrt(a * b)`, so the LOWER value dominates and
70
+ * the floor caps everything standing on it. The default **0.6** is a neutral solid surface (the
71
+ * raw Jolt default of 0.2 is ice by game standards); dry tarmac a car should corner on wants a
72
+ * `1`, ice `0.02`. A Vehicle's tires read the GROUND body's value, so this is the single number
73
+ * that decides how well a car corners.
74
+ *
75
+ * Change it at runtime by re-configuring — `floor.aspect(Physics, { friction: 0.02 })` (an ice
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`.)
80
+ */
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
89
+
90
+ /** Scene-editor inspector: `motion` as a dropdown (a string default alone infers a text box). */
91
+ static fields: FieldMeta<Physics> = {
92
+ motion: { options: [ "static", "kinematic", "dynamic" ] },
93
+ mass: { min: 0, step: 0.1 },
94
+ friction: { min: 0, max: 2, step: 0.05 },
95
+ }
96
+
97
+ private _bodyId = 0
98
+
99
+ onAttach(): void {
100
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
101
+ const shape = this.node.get(Shape)
102
+ if (!shape) {
103
+ throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
104
+ }
105
+ if (this.motion === "dynamic" && shape._isTriangleMesh) {
106
+ throw new Error("Physics: a dynamic body cannot use Shape { mesh: true } (triangle meshes are static/kinematic only) — use { mesh: 'convex' } or a primitive shape")
107
+ }
108
+ const shapeId = shape._claim() // drop the pick-only body; reuse its shape
109
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
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)
113
+ shape._ownBody(this._bodyId)
114
+ }
115
+
116
+ onDetach(): void {
117
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
118
+ const shape = this.node.get(Shape)
119
+ if (shape) {
120
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
121
+ shape._recreatePickBody() // keep the node pickable if the Shape stays
122
+ }
123
+ }
124
+
125
+ /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
126
+ get id(): number { return this._bodyId }
127
+
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`). */
130
+ get velocity(): Vec3 {
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])
134
+ }
135
+ set velocity(v: Vec3Like) {
136
+ if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
137
+ }
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
+
176
+ /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
177
+ applyImpulse(v: Vec3Like): this {
178
+ if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
179
+ return this
180
+ }
181
+
182
+ /** The same impulse applied at a WORLD-SPACE point instead of the centre of mass: the lever arm
183
+ * becomes angular impulse, so the body spins as well as moves (a bullet hitting a crate off
184
+ * centre, a blast lifting it by its base). Falls back to the central impulse on an older host. */
185
+ applyImpulseAt(v: Vec3Like, point: Vec3Like): this {
186
+ if (!this._bodyId) return this
187
+ if (_creator.physicsApplyImpulseAt) {
188
+ _creator.physicsApplyImpulseAt(this._bodyId, cx(v), cy(v), cz(v), cx(point), cy(point), cz(point))
189
+ } else {
190
+ _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
191
+ }
192
+ return this
193
+ }
194
+
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.
197
+
198
+ // --- world (static) -------------------------------------------------------------------------
199
+ /** Configure gravity / limits. Call once before creating bodies. */
200
+ static configure(config: PhysicsConfig = {}): void {
201
+ const g = config.gravity ?? [0, -9.81, 0]
202
+ _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
203
+ }
204
+
205
+ /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
206
+ static get supported(): boolean {
207
+ return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
208
+ }
209
+
210
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
211
+ static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
212
+ const out = new Float32Array(7)
213
+ const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
214
+ if (!id) return null
215
+ return {
216
+ node: nodeRegistry.get(id) ?? null,
217
+ point: new Vec3(out[0], out[1], out[2]),
218
+ normal: new Vec3(out[3], out[4], out[5]),
219
+ fraction: out[6],
220
+ }
221
+ }
222
+ }
@@ -6,7 +6,7 @@
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
11
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
12
12
  import type { FetchResponse } from "../runtime/fetch"
@@ -18,6 +18,42 @@ import { Node, nodeRegistry } from "./Node"
18
18
  import { glState } from "./state"
19
19
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
20
20
 
21
+ export type AmbientOcclusionOptions = {
22
+ /** Strength of the darkening (default 1). */
23
+ intensity?: number
24
+ /** How far the occlusion reaches, in metres (default 0.3). */
25
+ radius?: number
26
+ /** Falloff contrast; >1 tightens it into the crease (default 1). */
27
+ power?: number
28
+ /** Sample count + filtering (default 'medium'). Not the buffer resolution — that stays half. */
29
+ quality?: "low" | "medium" | "high" | "ultra"
30
+ }
31
+
32
+ export type FogOptions = {
33
+ /** A TINT on the in-scattered ambient, not an absolute colour: the engine multiplies it by the
34
+ * environment luminance, so white (the default) means "fog as bright as the ambient" and the fog
35
+ * brightens with `environmentIntensity`. Do NOT expect the same hex to look like it does in
36
+ * `skybox` — that one IS an absolute radiance and reads roughly an order of magnitude brighter.
37
+ * Tint towards the sky's hue; leave it white to sit at the ambient level. */
38
+ color?: ColorInput
39
+ /** Metres from the camera before the fog starts (default 0). */
40
+ start?: number
41
+ /** Extinction per metre at `height`; 0.01 ≈ clearly visible over ~100 m (default 0.01). */
42
+ density?: number
43
+ /** The fog's "sea level" in world Y (default 0). */
44
+ height?: number
45
+ /** How fast it thins with altitude, 1/m. 0 = uniform everywhere; higher = a ground-hugging layer
46
+ * you can see over (default 0). */
47
+ heightFalloff?: number
48
+ /** Cap on how opaque it can get, 0–1 — keeps far shapes readable (default 1). */
49
+ maxOpacity?: number
50
+ /** Metres after which fog stops applying; 0 = everywhere (default 0). */
51
+ cutoff?: number
52
+ /** Take the colour from the environment in the view direction, tinted by `color`, instead of a
53
+ * flat `color`. Convincing when the IBL is a real sky (default false). */
54
+ fromEnvironment?: boolean
55
+ }
56
+
21
57
  export type SceneOptions = {
22
58
  /** Image-based ambient lighting (default on). */
23
59
  ibl?: boolean
@@ -31,10 +67,54 @@ export type SceneOptions = {
31
67
  * saturation until very bright; `'linear'` clips each channel (what an engine without a
32
68
  * tonemapper shows — saturated, Unity-without-post-processing look); `'filmic'` (Uncharted). */
33
69
  toneMapping?: "aces" | "neutral" | "linear" | "filmic"
34
- /** Skybox / clear color. */
35
- skybox?: ColorInput
36
- /** Multisample anti-aliasing. */
37
- antialias?: boolean
70
+ /** The sky. Three forms:
71
+ * - a colour — a flat clear colour;
72
+ * - `{ texture }` — a KTX1 **cubemap**, the sharp `<name>_skybox.ktx` that filament's `cmgen`
73
+ * produces from an equirectangular .hdr/.exr. This is the one to use for a real sky. Pass an
74
+ * `asset('../assets/sky.ktx')` handle or a bare staged filename;
75
+ * - `'environment'` — reuse the scene's IBL cubemap. Cheapest (no second texture) but that file
76
+ * is prefiltered for roughness, so the sky comes out soft; fine as a fallback, not as the goal.
77
+ *
78
+ * Either texture form draws through filament's own skybox pass: a full-screen pass in device
79
+ * space after the opaque queue. No geometry, no meridian seam, no pole distortion — do NOT build
80
+ * a sky dome or a fullscreen equirect material by hand, both are strictly worse. */
81
+ skybox?: ColorInput | "environment" | { texture: string }
82
+ /** Screen-space ambient occlusion — the contact darkening in creases and where props meet the
83
+ * ground. Without it an IBL lights a crease exactly as brightly as an open face, so everything
84
+ * reads as pasted onto the floor rather than standing on it. `true` takes defaults tuned for
85
+ * human-scale props; `radius` is world-space metres and is the one knob that must follow the
86
+ * scene's scale (~0.3 for objects on a table, ~0.6-1 for a yard of crates and containers). */
87
+ ambientOcclusion?: boolean | AmbientOcclusionOptions
88
+ /** Distance fog / aerial perspective: distant geometry loses contrast so the eye reads depth, and
89
+ * the hard edge where a finite level ends against the skybox goes away. By default the fog applies
90
+ * at every distance — the skybox included — so the sky itself takes the fog colour and the horizon
91
+ * blends on its own; `cutoff` opts geometry beyond a distance back out. */
92
+ fog?: FogOptions
93
+ /** Multisample anti-aliasing: `true` = 4×, `2`/`4` = that many samples, `false` = off (FXAA
94
+ * takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
95
+ * fill-rate cost after resolution — turn it down on big screens before anything else. */
96
+ antialias?: boolean | 2 | 4
97
+ /** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
98
+ * resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
99
+ * barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
100
+ renderScale?: number
101
+ /** Let the engine shrink the 3D buffers under `renderScale` only when frames run over budget
102
+ * (Filament dynamic resolution, sharpened upscale) down to `min` (default 0.5). Off by default:
103
+ * it makes the output frame-time dependent, so flow tests / headless renders never enable it. */
104
+ dynamicResolution?: boolean | { min?: number }
105
+ /** Anisotropic filtering, 1 (off) … 16. Default 2. What it buys is detail on surfaces seen at a
106
+ * GRAZING angle — a first-person weapon, a floor, a wall — where the sample footprint is far
107
+ * longer in one axis than the other and isotropic filtering has to pick a mip for the long one,
108
+ * several levels coarser than the short axis deserves. Measured on a weapon seen from the side
109
+ * (local contrast): 8.18 at 1× → 10.02 at 2× → 10.84 at 4× → 11.29 at 8×. The default is 2
110
+ * because it is where the curve is steepest per unit of memory bandwidth; raise it if you have
111
+ * the headroom.
112
+ *
113
+ * It is an ENGINE-WIDE default, not a property of this scene: a sampler is baked when its
114
+ * texture is bound, so this reaches the models a scene loads AFTER it (which is every model in a
115
+ * scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
116
+ * On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
117
+ anisotropy?: number
38
118
  }
39
119
 
40
120
  // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
@@ -53,11 +133,16 @@ const installRenderSyncedFrames = (): void => {
53
133
  _creator.setLateUpdate(late)
54
134
  return true
55
135
  })
136
+ // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
137
+ // setTimeScale; a host without it keeps running its sims at wall-clock speed (feature-detected).
138
+ _installTimeScale((scale) => { _creator.setTimeScale?.(scale) })
56
139
  }
57
140
 
58
141
  export class Scene implements Presentable {
59
142
  /** @internal native scene handle. */
60
143
  readonly _id: number
144
+ /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
145
+ _environmentIntensity = 20000
61
146
  readonly camera: Camera
62
147
 
63
148
  /** @internal touch listeners, read by the dispatch system. */
@@ -87,12 +172,32 @@ export class Scene implements Presentable {
87
172
  }
88
173
 
89
174
  if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
175
+ this._environmentIntensity = options.ibl === false ? 0 : (options.environmentIntensity ?? 20000)
90
176
  if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
91
177
  if (options.toneMapping !== undefined && options.toneMapping !== "aces") {
92
178
  _creator.setToneMapping?.(this._id, { neutral: 1, linear: 2, filmic: 3 }[options.toneMapping] ?? 0)
93
179
  }
94
- if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
95
- if (options.antialias) _creator.setSceneMultiSampleAntiAliasing(this._id, true, 4)
180
+ if (options.skybox !== undefined) this.skybox = options.skybox
181
+ if (options.ambientOcclusion) this.setAmbientOcclusion(options.ambientOcclusion)
182
+ if (options.fog) this.setFog(options.fog)
183
+ if (options.antialias !== undefined) {
184
+ const a = options.antialias
185
+ // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
186
+ _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
187
+ }
188
+ // Before anything else in this constructor that could load a texture: the value has to be in
189
+ // place by the time assets bind their samplers.
190
+ if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
191
+ if (options.renderScale !== undefined || options.dynamicResolution) {
192
+ this.setRenderOptions(options.renderScale ?? 1, options.dynamicResolution ?? false)
193
+ }
194
+ }
195
+
196
+ /** Runtime form of `renderScale` / `dynamicResolution` (a graphics-settings menu). `renderScale`
197
+ * 0.25–1; older hosts without the method ignore it. */
198
+ setRenderOptions(renderScale: number, dynamicResolution: boolean | { min?: number } = false): void {
199
+ const min = typeof dynamicResolution === "object" ? (dynamicResolution.min ?? 0.5) : 0.5
200
+ _creator.setSceneRenderOptions?.(this._id, renderScale, !!dynamicResolution, min)
96
201
  }
97
202
 
98
203
  /** Scene occlusion material. */
@@ -101,7 +206,34 @@ export class Scene implements Presentable {
101
206
  return this._material
102
207
  }
103
208
 
104
- set skybox(color: ColorInput) { _creator.setSkybox(this._id, Color.toPackedRgb(color)) }
209
+ set skybox(sky: ColorInput | "environment" | { texture: string }) {
210
+ if (sky === "environment") { _creator.setSkyboxFromEnvironment?.(this._id); return }
211
+ if (typeof sky === "object" && sky !== null && "texture" in sky) {
212
+ const src = sky.texture
213
+ // `asset()` hands back "id:<n>" (already staged); a bare name is resolved on the spot.
214
+ const id = src.startsWith("id:") ? Number(src.slice(3)) : _creatorUtils.fetchLocal(src)
215
+ _creator.setSkyboxTexture?.(this._id, id)
216
+ return
217
+ }
218
+ _creator.setSkybox(this._id, Color.toPackedRgb(sky))
219
+ }
220
+
221
+ /** Runtime form of `ambientOcclusion` (a graphics-settings menu). `false` turns it off. */
222
+ setAmbientOcclusion(options: boolean | AmbientOcclusionOptions): void {
223
+ const o: AmbientOcclusionOptions = typeof options === "object" ? options : {}
224
+ const quality = { low: 0, medium: 1, high: 2, ultra: 3 }[o.quality ?? "medium"] ?? 1
225
+ _creator.setAmbientOcclusionOptions?.(this._id, options !== false, o.intensity ?? 1, o.radius ?? 0.3, o.power ?? 1, quality)
226
+ }
227
+
228
+ /** Runtime form of `fog` (weather, entering a building). `false` turns it off. */
229
+ setFog(options: FogOptions | false): void {
230
+ const o: FogOptions = options === false ? {} : options
231
+ _creator.setFogOptions?.(
232
+ this._id, options !== false, Color.toPackedRgb(o.color ?? "#ffffff"),
233
+ o.start ?? 0, o.density ?? 0.01, o.height ?? 0, o.heightFalloff ?? 0,
234
+ o.maxOpacity ?? 1, o.cutoff ?? 0, o.fromEnvironment ?? false,
235
+ )
236
+ }
105
237
 
106
238
  setMaterialGlobalParameter(i: number, x: number, y: number, z: number, w: number): void {
107
239
  _creator.setMaterialGlobalParameter(this._id, i, x, y, z, w)
@@ -119,6 +251,41 @@ export class Scene implements Presentable {
119
251
  return this
120
252
  }
121
253
 
254
+ // ---- systems: aspects of the scene (core/Aspect.ts `System`) ----
255
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
256
+ readonly _systems = new Map<Function, Aspect<any, any, any>>()
257
+
258
+ /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
259
+ * as now-having it (`scene.system(Hud).hud`). A `System<'x', Scene2D>` is rejected here. */
260
+ // `this: Self` (not `Self & Scene`): with the intersection TS infers Self = Scene and a chain
261
+ // `scene.system(A).system(B)` loses A's accessor from the result type.
262
+ system<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
263
+ this: Self,
264
+ ctor: AspectCtor<A>,
265
+ opts?: Partial<A>,
266
+ ): Self & FieldOf<A> {
267
+ return _attachSystem(this as unknown as Scene, ctor, opts) as unknown as Self & FieldOf<A>
268
+ }
269
+ /** Safe access — undefined if the system isn't attached. */
270
+ get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
271
+ return this._systems.get(ctor) as A | undefined
272
+ }
273
+ /** Existence check AND type guard: inside `if (scene.has(Hud))`, `scene.hud` is present. */
274
+ has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
275
+ return this._systems.has(ctor)
276
+ }
277
+ /** Detach a system (runs its onDetach). Chainable. */
278
+ removeSystem<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
279
+ _detachSystem(this, ctor)
280
+ return this
281
+ }
282
+ /** Tear the scene down: every system detaches (last-attached first) and the scene closes. Nodes
283
+ * are yours — destroy the ones you own; the native scene object itself is not released. */
284
+ destroy(): void {
285
+ _detachAllSystems(this)
286
+ this.close()
287
+ }
288
+
122
289
  createOverlay(options: SceneOptions = {}): Scene {
123
290
  return new Scene({ ...options, _parent: this } as any)
124
291
  }