lecodes-gl-lite 2.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 (46) hide show
  1. package/README.md +27 -0
  2. package/package.json +33 -0
  3. package/src/assets/decoders/README.md +13 -0
  4. package/src/assets/decoders/basis_transcoder.js +19 -0
  5. package/src/assets/decoders/basis_transcoder.wasm +0 -0
  6. package/src/assets/decoders/draco_decoder.wasm +0 -0
  7. package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
  8. package/src/assets/neutral_ibl128.ktx +0 -0
  9. package/src/core/animBridge.ts +51 -0
  10. package/src/core/boneNames.ts +22 -0
  11. package/src/core/frame.ts +230 -0
  12. package/src/core/glbStructure.ts +100 -0
  13. package/src/core/headlessBridge.ts +92 -0
  14. package/src/core/mapView.ts +195 -0
  15. package/src/core/math3d.ts +275 -0
  16. package/src/core/particleBridge.ts +154 -0
  17. package/src/core/physicsBridge.ts +305 -0
  18. package/src/core/recording.ts +59 -0
  19. package/src/core/sceneCore.ts +833 -0
  20. package/src/core/serialize.ts +144 -0
  21. package/src/gen/unsupported.gen.ts +212 -0
  22. package/src/index.ts +5 -0
  23. package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
  24. package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
  25. package/src/vendor/creator-anim/js/index.ts +656 -0
  26. package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
  27. package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
  28. package/src/vendor/creator-nav/js/index.ts +400 -0
  29. package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
  30. package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
  31. package/src/vendor/creator-particles/js/index.ts +137 -0
  32. package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
  33. package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
  34. package/src/vendor/creator-physics/js/index.ts +515 -0
  35. package/src/vite-env.d.ts +1 -0
  36. package/src/webgl/batching.ts +453 -0
  37. package/src/webgl/createViewerLite.ts +1627 -0
  38. package/src/webgl/customMaterial.ts +228 -0
  39. package/src/webgl/decoders/basis.ts +123 -0
  40. package/src/webgl/decoders/draco.ts +110 -0
  41. package/src/webgl/decoders/index.ts +68 -0
  42. package/src/webgl/decoders/worker.ts +45 -0
  43. package/src/webgl/editor.ts +1297 -0
  44. package/src/webgl/glb.ts +593 -0
  45. package/src/webgl/ibl.ts +137 -0
  46. package/src/webgl/shaders.ts +779 -0
@@ -0,0 +1,833 @@
1
+ // The GL-free scene core for the lite 3D engine — one CPU-side implementation of the `_creator`
2
+ // scene semantics (sdk/src/bridges.d.ts, mirroring creator-gl's scene.cpp /
3
+ // transformUtils.cpp / collider.cpp) that BOTH lite backends drive:
4
+ //
5
+ // • the WebGL backend (viewer-lite, browser) — builds OGL programs/geometry from core state, and
6
+ // • the headless backend (lecodes-headless) — records + serializes the same state to scene3d JSON.
7
+ //
8
+ // The core owns entities, hierarchy, local matrices, semantic materials/meshes/lights, GLB structure,
9
+ // colliders, and the animation playback clock. It creates NO GL objects: where GL work is needed it
10
+ // calls an optional `SceneBackend` hook, and the backend reacts at its edge (build a Program, sample
11
+ // a clip, invalidate a cached world matrix). Headless supplies only `allocMatrix`; every GL hook is a
12
+ // no-op there, so the recorded state IS the core state — that's why the two views can't disagree.
13
+ //
14
+ // Divergences between the old ports are resolved to creator-gl native here (see gl-lite-core-plan.md):
15
+ // destroyEntity destroys the whole subtree (scene.cpp), cloneEntity attaches to the source's parent +
16
+ // scene (scene.cpp), and the camera fov is the real engines' 60° (backend-configurable).
17
+
18
+ import {
19
+ composeTRS, getEulerYXZDeg, getScale, identity, invertTRS, multiply, perspective,
20
+ setEulerOn, setQuaternionOn, setScaleOn, type Mat4, type Mat4Like,
21
+ } from "./math3d"
22
+ import { parseGlbStructure, type GlbAnimation } from "./glbStructure"
23
+ import { createAnimationSystem } from "./animBridge"
24
+ import { createPhysicsSystem } from "./physicsBridge"
25
+ import { createParticleRuntime, type ParticleFrame } from "./particleBridge"
26
+ import { nameMatchRank, shortName } from "./boneNames"
27
+
28
+ export type UniformRec =
29
+ | { kind: "rgb" | "rgba", v: number }
30
+ | { kind: "float", v: number }
31
+ | { kind: "bool", v: boolean }
32
+ | { kind: "texture", v: number }
33
+ | { kind: "array", v: number[] }
34
+ | { kind: "null" }
35
+
36
+ export type MaterialRec = { shader: string, uniforms: Record<string, UniformRec> }
37
+
38
+ export type MeshRec = {
39
+ kind: number
40
+ vertexCount: number
41
+ size?: [number, number, number]
42
+ center?: [number, number, number]
43
+ primitives?: number
44
+ }
45
+
46
+ export type TextureRec = { source?: string, width: number, height: number }
47
+
48
+ /** The animation playback clock — lives in the core; the backend only *samples* poses (onAnimationApply). */
49
+
50
+ export type Ent = {
51
+ id: number
52
+ name: string
53
+ /** Local transform (column-major), supplied by the backend so mutations flow to the renderer. */
54
+ m: Mat4Like
55
+ parent: number // 0 = none
56
+ children: number[]
57
+ visible: boolean
58
+ mesh?: MeshRec
59
+ materials: number[]
60
+ light?: { direction: [number, number, number], intensity: number, color: number, shadowsQuality: number, shadowDistance?: number, shadowCascades?: number }
61
+ /** `buffer` = the GLB bytes, retained on the root for mesh colliders built from the model
62
+ * (physicsBuildMeshFromEntity / glbTriangles) — the fetch store's copy may be gone by then. */
63
+ model?: { source?: string, systemId?: number, animations: GlbAnimation[], loaded: boolean, clipSet?: number, buffer?: ArrayBuffer }
64
+ /** A skin joint of a loaded GLB (the Animator prefers these over same-named plain nodes). */
65
+ skinJoint?: boolean
66
+ castShadows?: boolean
67
+ receiveShadows?: boolean
68
+ // --- headless recording surface (populated by recording.ts; the WebGL backend leaves these empty) ---
69
+ particles?: Record<string, unknown>
70
+ physics?: Record<string, unknown>
71
+ /** A Ragdoll's parts on the model entity (recording.ts ragdollCreate) + active/velocity while on. */
72
+ ragdoll?: Record<string, unknown>
73
+ collider?: { type: string, [k: string]: unknown }
74
+ anchor?: string
75
+ }
76
+
77
+ /** A scene's camera projection (setCameraProjection) — vertical fov in degrees + clip distances. */
78
+ export type Projection = { fov: number, near: number, far: number }
79
+
80
+ /** An inspection camera: eye + look-at point in world units (Y up), optional vertical fov (deg). */
81
+ export type CameraOverride = { eye: [number, number, number], target: [number, number, number], fov?: number }
82
+
83
+ export type SceneRec = {
84
+ id: number
85
+ entities: Set<number>
86
+ overlays: number[]
87
+ overlayOf?: number
88
+ camera?: number
89
+ /** Set by setCameraProjection; absent = the core defaults (fov option / 0.01 / 1000). */
90
+ projection?: Projection
91
+ skybox?: number
92
+ ibl?: number
93
+ bloom?: { strength: number }
94
+ antialias?: boolean
95
+ arMode?: string
96
+ }
97
+
98
+ /** The seam between the shared core and a concrete backend. Headless implements only `allocMatrix`. */
99
+ export interface SceneBackend {
100
+ /** Allocate an entity's local matrix. Headless: a fresh identity Float32Array. The WebGL backend
101
+ * hands back the OGL Transform's own matrix so the core mutates the renderable in place. */
102
+ allocMatrix(entityId: number): Mat4Like
103
+ /** A local matrix was mutated in place (WebGL: worldMatrixNeedsUpdate = true). */
104
+ localMatrixChanged?(entityId: number): void
105
+ /** Parent changed (WebGL: OGL setParent / reparent under the owning scene root). */
106
+ parentChanged?(childId: number, parentId: number): void
107
+ /** A scene was created (WebGL: create its root Transform). */
108
+ sceneCreated?(sceneId: number): void
109
+ /** Entity added (added=true) or removed from a scene (WebGL: reparent under / off the scene root). */
110
+ entitySceneChanged?(entityId: number, sceneId: number, added: boolean): void
111
+ /** Visibility toggled (WebGL: Transform.visible). */
112
+ visibleChanged?(entityId: number, visible: boolean): void
113
+ /** A material was created (WebGL: build the Program for this shader). `systemId` is present for
114
+ * createMaterial (a fetched shader — needed to load a custom .mat buffer), absent for a built-in
115
+ * one (createMaterialNamed: `rec.shader` is its name). */
116
+ materialCreated?(materialId: number, rec: MaterialRec, systemId?: number): void
117
+ /** A uniform was set with its SEMANTIC value (WebGL: convert hex→linear + set on the Program). */
118
+ uniformChanged?(materialId: number, uniform: string, u: UniformRec): void
119
+ /** A mesh was set (WebGL: build Geometry/Mesh from the raw arrays). */
120
+ meshSet?(entityId: number, materialId: number, mesh: MeshRec, arrays: {
121
+ vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, colors?: Uint8Array,
122
+ }): void
123
+ /** GLB entities were created from the structure (WebGL: run GLTFLoader + attach drawables by node
124
+ * index). `nodeEntityIds[i]` is the entity for glTF node i; `rootId` is the synthetic model root.
125
+ * May be async (createGlb awaits it before firing the SDK's onComplete). */
126
+ glbAttach?(systemId: number, rootId: number, nodeEntityIds: number[]): void | Promise<void>
127
+ /** An Animator sampled morph-target weights for an entity (WebGL: write the node's blendshape weights). */
128
+ morphWeights?(entityId: number, weights: Float32Array): void
129
+ /** An entity is being destroyed (WebGL: tear down its drawables). */
130
+ entityDestroyed?(entityId: number): void
131
+ /** A particle system was created on the entity with a material (WebGL: prepare its drawable). */
132
+ particleSystemCreated?(entityId: number, materialId: number): void
133
+ /** A particle system's vertex stream for this frame (WebGL: upload + draw; headless ignores it).
134
+ * `frame.bytes` is a view into the wasm heap — copy or upload before the next tick. */
135
+ particlesFrame?(entityId: number, frame: ParticleFrame): void
136
+ particleSystemDestroyed?(entityId: number): void
137
+ /** An entity was cloned (WebGL: clone drawables). Called after the core copies scalar state. */
138
+ entityCloned?(sourceId: number, cloneId: number): void
139
+ /** A whole `cloneEntity` finished; `map` is source entity → clone entity for every node of the
140
+ * subtree. `entityCloned` runs node by node in pre-order, so a skinned mesh is cloned before the
141
+ * clones of its joints exist — this is where a backend re-points such cross-node references. */
142
+ subtreeCloned?(sourceRootId: number, cloneRootId: number, map: ReadonlyMap<number, number>): void
143
+ }
144
+
145
+ export type SceneCoreOptions = {
146
+ getBuffer: (systemId: number) => ArrayBuffer | undefined
147
+ getSourceName?: (systemId: number) => string | undefined
148
+ /** Viewport in logical px (drives getDisplaySize + the camera projection). */
149
+ width?: number
150
+ height?: number
151
+ /** Vertical camera fov in degrees (creator-gl native / web-lite use 60). */
152
+ fov?: number
153
+ }
154
+
155
+ /** Copy up to `n` elements from `src` into `dst` in place — works for Float32Array and Array alike
156
+ * (the WebGL backend's matrix is an OGL Array subclass, which has no typed-array `.set`). */
157
+ const copyInto = (dst: Mat4Like, src: ArrayLike<number>, n = 16): void => {
158
+ for (let i = 0; i < n; i++) (dst as number[])[i] = src[i]
159
+ }
160
+
161
+ export function createSceneCore(backend: SceneBackend, opts: SceneCoreOptions) {
162
+ const width = opts.width ?? 390
163
+ const height = opts.height ?? 844
164
+ const fov = opts.fov ?? 60
165
+ /** Matches creator-gl's SceneStruct defaults (scene.cpp applyCameraProjection). */
166
+ const defaultProjection: Projection = { fov, near: 0.01, far: 1000 }
167
+
168
+ const entities = new Map<number, Ent>()
169
+ const scenes = new Map<number, SceneRec>()
170
+ const materials = new Map<number, MaterialRec>()
171
+ const textures = new Map<number, TextureRec>()
172
+ let nextEntity = 1
173
+ let nextMaterial = 1
174
+ let nextTexture = 1
175
+ let activeScene = -1
176
+
177
+ let earlyUpdate: ((dt: number) => void) | null = null
178
+ let lateUpdate: ((dt: number) => void) | null = null
179
+ let fixedUpdate: ((dt: number) => void) | null = null // the SDK's updateFixed: once per physics substep (creator-physics' frame protocol)
180
+
181
+ // The physics system (creator-physics' wasm — the same Jolt simulation creator-gl runs). Bodies and
182
+ // characters write world poses back into entity local matrices; the backend is told via localMatrixChanged.
183
+ const physics = createPhysicsSystem({
184
+ ent: (id) => ent(id),
185
+ get: (id) => entities.get(id),
186
+ world: (id) => worldMatrixF(id),
187
+ setWorldTRS: (id, x, y, z, qx, qy, qz, qw, sx, sy, sz) => writeWorldMatrix(id, composeTRS([x, y, z], [qx, qy, qz, qw], [sx, sy, sz])),
188
+ setWorldPosition: (id, x, y, z) => writeWorldPosition(id, x, y, z),
189
+ localMatrixChanged: (id) => backend.localMatrixChanged?.(id),
190
+ parentOf: (id) => entities.get(id)?.parent ?? 0,
191
+ glbBytes: (id) => entities.get(id)?.model?.buffer,
192
+ // a ragdoll let go with a blend: the animator transitions out of the fallen pose (`anim` is
193
+ // created right below; the hook only runs long after)
194
+ seedAnimator: (joint, blend) => anim.methods.animatorSeedPose(joint, blend),
195
+ // learned ragdoll limits: the clips' joint ranges from the same animator
196
+ jointRanges: (joint, pairs, frames) => anim.methods.animatorJointRanges(joint, pairs, frames),
197
+ })
198
+
199
+ // The particle runtime (creator-particles' wasm — the same simulation creator-gl runs): emitters
200
+ // read their world matrix here, the streams go to the backend's uploader, the counts to the record.
201
+ const particles = createParticleRuntime({
202
+ ent: (id) => ent(id),
203
+ world: (id) => worldMatrixF(id),
204
+ cameraPosition: () => {
205
+ const cam = scenes.get(activeScene)?.camera
206
+ if (cam === undefined) return null
207
+ const w = worldMatrix(cam)
208
+ return [w[12], w[13], w[14]]
209
+ },
210
+ created: (id, materialId) => backend.particleSystemCreated?.(id, materialId),
211
+ frame: (id, frame) => backend.particlesFrame?.(id, frame),
212
+ destroyed: (id) => backend.particleSystemDestroyed?.(id),
213
+ })
214
+
215
+ // The animation system (AnimationClip sets + Animators — creator-anim.s wasm, the same evaluator creator-gl runs).
216
+ // Joint writes go straight into entity local matrices; the backend is told via localMatrixChanged.
217
+ const anim = createAnimationSystem({
218
+ get: (id) => entities.get(id),
219
+ world: (id) => worldMatrixF(id),
220
+ localMatrixChanged: (id) => backend.localMatrixChanged?.(id),
221
+ getBuffer: (systemId) => opts.getBuffer(systemId),
222
+ setMorphWeights: (id, w) => backend.morphWeights?.(id, w),
223
+ applyRootMotion: (id, dx, dy, dz, dt, yaw) => {
224
+ const w = worldMatrixF(id) // node-local → world direction
225
+ return physics.applyRootMotion(id, w[0] * dx + w[4] * dy + w[8] * dz, w[2] * dx + w[6] * dy + w[10] * dz, dt, yaw)
226
+ },
227
+ })
228
+
229
+ const makeEnt = (id: number): Ent => {
230
+ const e: Ent = {
231
+ id, name: "", m: backend.allocMatrix(id), parent: 0, children: [], visible: true, materials: [],
232
+ }
233
+ // allocMatrix may hand back an un-initialised buffer — ensure identity.
234
+ if (e.m[0] !== 1 || e.m[5] !== 1 || e.m[10] !== 1 || e.m[15] !== 1) copyInto(e.m, identity())
235
+ entities.set(id, e)
236
+ return e
237
+ }
238
+ const newEnt = (): Ent => makeEnt(nextEntity++)
239
+ // The SDK can hand back ids it saw from callbacks; be forgiving and materialize unknown ids.
240
+ const ent = (id: number): Ent => {
241
+ let e = entities.get(id)
242
+ if (!e) { e = makeEnt(id); if (id >= nextEntity) nextEntity = id + 1 }
243
+ return e
244
+ }
245
+
246
+ const worldMatrix = (id: number): Mat4Like => {
247
+ const e = ent(id)
248
+ if (e.parent === 0) return e.m
249
+ return multiply(new Float32Array(16), worldMatrix(e.parent), e.m)
250
+ }
251
+ /** worldMatrix as a concrete Float32Array (for math that needs `.set`/`.subarray`). */
252
+ const worldMatrixF = (id: number): Float32Array => {
253
+ const w = worldMatrix(id)
254
+ return w instanceof Float32Array ? w : Float32Array.from(w as ArrayLike<number>)
255
+ }
256
+
257
+ const detach = (e: Ent): void => {
258
+ if (e.parent !== 0) {
259
+ const p = entities.get(e.parent)
260
+ if (p) p.children = p.children.filter((c) => c !== e.id)
261
+ e.parent = 0
262
+ }
263
+ }
264
+
265
+ const setParentInternal = (childId: number, parentId: number, worldPositionStays: boolean): void => {
266
+ const e = ent(childId)
267
+ if (e.parent === parentId) return
268
+ if (worldPositionStays) {
269
+ const world = worldMatrixF(childId)
270
+ const local = parentId === 0 ? world : multiply(new Float32Array(16), invertTRS(worldMatrixF(parentId)), world)
271
+ copyInto(e.m, local)
272
+ }
273
+ detach(e)
274
+ if (parentId !== 0) {
275
+ e.parent = parentId
276
+ ent(parentId).children.push(childId)
277
+ }
278
+ backend.parentChanged?.(childId, parentId)
279
+ backend.localMatrixChanged?.(childId)
280
+ }
281
+
282
+ // A world-space transform onto an entity (into its parent's space when parented) — the physics
283
+ // system's pose writers (creator-gl's writeWorldTransform / writeWorldPosition twins).
284
+ const writeWorldMatrix = (id: number, world: Mat4Like): void => {
285
+ const e = ent(id)
286
+ copyInto(e.m, e.parent === 0 ? world : multiply(new Float32Array(16), invertTRS(worldMatrixF(e.parent)), world))
287
+ backend.localMatrixChanged?.(id)
288
+ }
289
+ const writeWorldPosition = (id: number, x: number, y: number, z: number): void => {
290
+ const e = ent(id)
291
+ const m = e.m as number[]
292
+ if (e.parent === 0) { m[12] = x; m[13] = y; m[14] = z }
293
+ else {
294
+ const inv = invertTRS(worldMatrixF(e.parent))
295
+ m[12] = inv[0] * x + inv[4] * y + inv[8] * z + inv[12]
296
+ m[13] = inv[1] * x + inv[5] * y + inv[9] * z + inv[13]
297
+ m[14] = inv[2] * x + inv[6] * y + inv[10] * z + inv[14]
298
+ }
299
+ backend.localMatrixChanged?.(id)
300
+ }
301
+
302
+ /** A scene's projection, falling back to the core defaults. Overlays follow their parent (they
303
+ * share its camera, exactly like createOverlayScene in scene.cpp). */
304
+ const projectionOf = (sceneId: number): Projection => {
305
+ const s = scenes.get(sceneId)
306
+ const owner = s?.overlayOf !== undefined ? scenes.get(s.overlayOf) : s
307
+ return owner?.projection ?? defaultProjection
308
+ }
309
+
310
+ const sceneOf = (id: number): SceneRec | null => {
311
+ for (let cur = id; cur !== 0;) {
312
+ for (const s of scenes.values()) if (s.entities.has(cur)) return s
313
+ cur = ent(cur).parent
314
+ }
315
+ return null
316
+ }
317
+
318
+ const sourceName = (systemId: number): string | undefined => {
319
+ const name = opts.getSourceName?.(systemId)
320
+ if (!name) return undefined
321
+ // Local project assets arrive as lecodesfile://<abs-path>; keep just the file name.
322
+ const clean = name.startsWith("lecodesfile://") ? decodeURIComponent(name.slice("lecodesfile://".length)) : name
323
+ const isPathy = clean.includes("/") || clean.includes("\\")
324
+ return isPathy && !clean.startsWith("http") ? (clean.split(/[\\/]/).pop() ?? clean) : clean
325
+ }
326
+
327
+ const material = (id: number): MaterialRec => {
328
+ let m = materials.get(id)
329
+ if (!m) { m = { shader: "unknown", uniforms: {} }; materials.set(id, m) }
330
+ return m
331
+ }
332
+ const setUniform = (materialId: number, uniform: string, u: UniformRec): void => {
333
+ material(materialId).uniforms[uniform] = u
334
+ backend.uniformChanged?.(materialId, uniform, u)
335
+ }
336
+
337
+ // Stage 1 of GLB load (gl-free): parse the JSON structure and create the entity hierarchy (the
338
+ // synthetic model root + one entity per glTF node, in node-index order). Returns the ids so the
339
+ // backend's stage-2 glbAttach can attach drawables by node index (both derive from the same glTF
340
+ // JSON, so indices align). `entIds` is empty when the buffer isn't a parseable GLB.
341
+ const buildGlbEntities = (systemId: number): { rootId: number, entIds: number[] } => {
342
+ const buf = opts.getBuffer(systemId)
343
+ const structure = buf ? parseGlbStructure(buf) : null
344
+ const root = newEnt()
345
+ root.model = { source: sourceName(systemId), systemId, animations: structure?.animations ?? [], loaded: structure !== null, ...(buf && { buffer: buf }) }
346
+ // Embedded clips are parsed NOW (like native loadGlb): the fetched buffer is `useOnce` and may be
347
+ // gone by the time an Animator asks for the set. Only when the file has animations.
348
+ if (structure && structure.animations.length > 0 && buf) root.model.clipSet = anim.registerGlbClips(buf)
349
+ if (!structure) return { rootId: root.id, entIds: [] }
350
+ const entIds = structure.nodes.map(() => newEnt().id)
351
+ structure.nodes.forEach((n, i) => {
352
+ const e = ent(entIds[i])
353
+ e.name = n.name ?? ""
354
+ copyInto(e.m, n.matrix)
355
+ backend.localMatrixChanged?.(e.id)
356
+ if (n.skinJoint) e.skinJoint = true
357
+ if (n.mesh) {
358
+ e.mesh = { kind: 0, vertexCount: 0, primitives: n.mesh.primitives }
359
+ if (n.mesh.min && n.mesh.max) {
360
+ e.mesh.size = [n.mesh.max[0] - n.mesh.min[0], n.mesh.max[1] - n.mesh.min[1], n.mesh.max[2] - n.mesh.min[2]]
361
+ e.mesh.center = [(n.mesh.max[0] + n.mesh.min[0]) / 2, (n.mesh.max[1] + n.mesh.min[1]) / 2, (n.mesh.max[2] + n.mesh.min[2]) / 2]
362
+ }
363
+ }
364
+ for (const c of n.children) setParentInternal(entIds[c], e.id, false)
365
+ })
366
+ for (const r of structure.roots) setParentInternal(entIds[r], root.id, false)
367
+ return { rootId: root.id, entIds }
368
+ }
369
+
370
+ // cloneEntity → creator-gl native: the clone attaches to the source's parent and joins the source's
371
+ // scene (scene.cpp cloneEntity). The subtree is cloned recursively (shared by both TS ports).
372
+ const cloneSubtree = (id: number, map: Map<number, number>): number => {
373
+ const src = ent(id)
374
+ const copy = newEnt()
375
+ map.set(src.id, copy.id)
376
+ copy.name = src.name
377
+ copyInto(copy.m, src.m)
378
+ copy.visible = src.visible
379
+ if (src.skinJoint) copy.skinJoint = true
380
+ if (src.light) copy.light = { ...src.light }
381
+ if (src.mesh) copy.mesh = { ...src.mesh }
382
+ copy.materials = [...src.materials]
383
+ if (src.model) copy.model = { ...src.model, animations: src.model.animations }
384
+ if (src.castShadows !== undefined) copy.castShadows = src.castShadows
385
+ if (src.receiveShadows !== undefined) copy.receiveShadows = src.receiveShadows
386
+ if (src.particles) copy.particles = structuredClone(src.particles)
387
+ if (src.physics) copy.physics = structuredClone(src.physics)
388
+ if (src.collider) copy.collider = structuredClone(src.collider)
389
+ if (src.anchor) copy.anchor = src.anchor
390
+ backend.localMatrixChanged?.(copy.id)
391
+ backend.entityCloned?.(src.id, copy.id)
392
+ for (const c of src.children) setParentInternal(cloneSubtree(c, map), copy.id, false)
393
+ return copy.id
394
+ }
395
+
396
+ const destroySubtree = (id: number): void => {
397
+ const e = entities.get(id)
398
+ if (!e) return
399
+ for (const c of [...e.children]) destroySubtree(c)
400
+ detach(e)
401
+ for (const s of scenes.values()) s.entities.delete(id)
402
+ backend.entityDestroyed?.(id)
403
+ anim.onEntityDestroyed(id)
404
+ particles.onEntityDestroyed(id)
405
+ entities.delete(id)
406
+ }
407
+
408
+ // Visible only if the whole ancestor chain is visible (native isVisible).
409
+ const visibleChain = (entityId: number): boolean => {
410
+ for (let id = entityId; id !== 0; id = ent(id).parent) if (!ent(id).visible) return false
411
+ return true
412
+ }
413
+
414
+ /** One render step: aspect update(dt) phases around the animator evaluation (creator-gl's
415
+ * render-loop order — early → physics → animators → late → particles). The WebGL backend drives this per frame with the
416
+ * real frame dt; headless drives it in fixed 16ms steps from `simulate`. */
417
+ // The engine clock multiplier (SDK Time.scale / Time.paused): the sims advance by dt × scale, the JS
418
+ // phases keep the raw dt — creator-gl's setTimeScale contract.
419
+ let timeScale = 1
420
+ const tick = (dt: number): void => {
421
+ const sim = dt * timeScale
422
+ earlyUpdate?.(dt)
423
+ // creator-gl's frame (init.cpp stepFrameUpdates): the physics steps right after the early phase
424
+ // (controllers feed it) with the fixed phase between the substeps — once per 1/60 of GAME time, at
425
+ // most 4 per frame, backlog dropped past the cap, dt always exactly 1/60 — then the render poses
426
+ // land on the entities, the animators run, and the active ragdolls overwrite their bones.
427
+ physics.frame(sim, fixedUpdate)
428
+ anim.evaluate(sim)
429
+ physics.syncRagdolls(sim)
430
+ lateUpdate?.(dt)
431
+ anim.lateSolve() // creator-gl's lateSolveAnimators: the IK chains on the joints as the late phase left them
432
+ // creator-gl's updateParticles — AFTER the late phase since 2026-09-20 (init.cpp stepFrameUpdates has the why): a
433
+ // world-space stream is written against the emitter's matrix, so it must be the matrix the draw uses, and an
434
+ // emitter moved by a default `update(dt)` aspect / system is moved in the late phase.
435
+ particles.update(sim)
436
+ }
437
+
438
+ /** Advance to `timeMs` in fixed 16ms steps (headless). 0 = don't step (the initial state). */
439
+ const simulate = (timeMs: number): void => {
440
+ const steps = Math.min(1000, Math.floor(timeMs / 16))
441
+ for (let i = 0; i < steps; i++) tick(0.016)
442
+ }
443
+
444
+ const hasActiveScene = (): boolean => activeScene >= 0 || scenes.size > 0
445
+ const activeSceneId = (): number => activeScene
446
+
447
+ // ---- the shared, backend-agnostic `_creator` methods --------------------------------------------
448
+
449
+ const methods = {
450
+ // --- scenes ---
451
+ createScene(): number {
452
+ const id = scenes.size
453
+ scenes.set(id, { id, entities: new Set(), overlays: [] })
454
+ backend.sceneCreated?.(id)
455
+ return id
456
+ },
457
+ createOverlayScene(parentId: number): number {
458
+ const id = scenes.size
459
+ // Overlays render through the parent scene's camera (the WebGL backend draws them with the main
460
+ // camera object), so we don't give the overlay its own camera — matching what serializes today.
461
+ scenes.set(id, { id, entities: new Set(), overlays: [], overlayOf: parentId })
462
+ scenes.get(parentId)?.overlays.push(id)
463
+ backend.sceneCreated?.(id)
464
+ return id
465
+ },
466
+ createGLView(): void {},
467
+ openScene(sceneId: number): void { activeScene = sceneId },
468
+ closeScene(): void { activeScene = -1 },
469
+ addEntityToScene(sceneId: number, entityId: number): void {
470
+ const s = scenes.get(sceneId)
471
+ if (!s) return
472
+ // An entity lives in exactly one scene (native scene.cpp move_elements): drop it from any other.
473
+ for (const other of scenes.values()) if (other !== s) other.entities.delete(entityId)
474
+ s.entities.add(entityId)
475
+ backend.entitySceneChanged?.(entityId, sceneId, true)
476
+ },
477
+ removeEntityFromScene(sceneId: number, entityId: number): void {
478
+ const s = scenes.get(sceneId)
479
+ if (!s) return
480
+ s.entities.delete(entityId)
481
+ backend.entitySceneChanged?.(entityId, sceneId, false)
482
+ },
483
+
484
+ // --- entities + hierarchy ---
485
+ createEntity: (): number => newEnt().id,
486
+ cloneEntity(entityId: number): number {
487
+ const src = ent(entityId)
488
+ const map = new Map<number, number>()
489
+ const cloneId = cloneSubtree(entityId, map)
490
+ // Native: attach to the source's parent; if the source is a bare scene member, join its scene.
491
+ if (src.parent !== 0) {
492
+ setParentInternal(cloneId, src.parent, false)
493
+ } else {
494
+ const scene = sceneOf(entityId)
495
+ if (scene) {
496
+ scene.entities.add(cloneId)
497
+ backend.entitySceneChanged?.(cloneId, scene.id, true)
498
+ }
499
+ }
500
+ backend.subtreeCloned?.(entityId, cloneId, map)
501
+ return cloneId
502
+ },
503
+ destroyEntity(entityId: number): void { destroySubtree(entityId) },
504
+
505
+ setMatrix(entityId: number, mat: Float32Array): void {
506
+ const e = ent(entityId)
507
+ copyInto(e.m, mat)
508
+ backend.localMatrixChanged?.(entityId)
509
+ },
510
+ setWorldMatrix(entityId: number, mat: Float32Array): void {
511
+ const e = ent(entityId)
512
+ if (e.parent === 0) { copyInto(e.m, mat); backend.localMatrixChanged?.(entityId); return }
513
+ const local = multiply(new Float32Array(16), invertTRS(worldMatrixF(e.parent)), Float32Array.from(mat.subarray(0, 16)))
514
+ copyInto(e.m, local)
515
+ backend.localMatrixChanged?.(entityId)
516
+ },
517
+ getChildCount: (entityId: number): number => ent(entityId).children.length,
518
+ getChild: (entityId: number, index: number): number => ent(entityId).children[index] ?? 0,
519
+
520
+ setPosition(entityId: number, x: number, y: number, z: number): void {
521
+ const m = ent(entityId).m
522
+ m[12] = x; m[13] = y; m[14] = z
523
+ backend.localMatrixChanged?.(entityId)
524
+ },
525
+ setQuaternion(entityId: number, x: number, y: number, z: number, w: number): void {
526
+ setQuaternionOn(ent(entityId).m, x, y, z, w); backend.localMatrixChanged?.(entityId)
527
+ },
528
+ setEulerAngles(entityId: number, x: number, y: number, z: number, order: number): void {
529
+ setEulerOn(ent(entityId).m, x, y, z, order); backend.localMatrixChanged?.(entityId)
530
+ },
531
+ setScale(entityId: number, x: number, y: number, z: number): void {
532
+ setScaleOn(ent(entityId).m, x, y, z); backend.localMatrixChanged?.(entityId)
533
+ },
534
+
535
+ setParent(entityId: number, parentEntityId: number, worldPositionStays: boolean): void {
536
+ setParentInternal(entityId, parentEntityId, worldPositionStays)
537
+ },
538
+ setParentNull(entityId: number, worldPositionStays: boolean): void {
539
+ setParentInternal(entityId, 0, worldPositionStays)
540
+ },
541
+ getParent: (entityId: number): number => ent(entityId).parent,
542
+ traverse(entityId: number, callback: (entityId: number) => void): void {
543
+ // Descendants only (the SDK's Node.traverse visits `this` itself before delegating here).
544
+ const walk = (id: number): void => { callback(id); for (const c of ent(id).children) walk(c) }
545
+ for (const c of ent(entityId).children) walk(c)
546
+ },
547
+ findNode(entityId: number, name: string): number {
548
+ // Descendant by name with the Animator's binding rule (boneNames.ts): exact before short name,
549
+ // skin joints before plain nodes, first in tree order otherwise. 0 = absent. Native twin:
550
+ // creator-gl scene.cpp findNode.
551
+ const want = shortName(name)
552
+ let best = 0, bestRank = 4
553
+ const walk = (id: number): void => {
554
+ const e = ent(id)
555
+ const rank = nameMatchRank(e.name, !!e.skinJoint, name, want)
556
+ if (rank < bestRank && rank >= 0) { best = id; bestRank = rank }
557
+ for (const c of e.children) { if (bestRank === 0) return; walk(c) }
558
+ }
559
+ for (const c of ent(entityId).children) { if (bestRank === 0) break; walk(c) }
560
+ return best
561
+ },
562
+
563
+ // --- lights + environment ---
564
+ createSunLight(entityId: number, x: number, y: number, z: number, intensity: number, color: number, shadowsQuality: number, shadowDistance = 100, shadowCascades = 1): void {
565
+ ent(entityId).light = { direction: [x, y, z], intensity, color, shadowsQuality, shadowDistance, shadowCascades }
566
+ },
567
+ setDefaultIbl(sceneId: number, intensity: number): void { const s = scenes.get(sceneId); if (s) s.ibl = intensity },
568
+ setBloomOptions(sceneId: number, enabled: boolean, strength: number): void {
569
+ const s = scenes.get(sceneId)
570
+ if (s) s.bloom = enabled ? { strength } : undefined
571
+ },
572
+ setSkybox(sceneId: number, color: number): void { const s = scenes.get(sceneId); if (s) s.skybox = color },
573
+ setSceneMultiSampleAntiAliasing(sceneId: number, enabled: boolean): void {
574
+ const s = scenes.get(sceneId); if (s) s.antialias = enabled
575
+ },
576
+ // Recorded only: the lite engine draws at canvas resolution (web-lite) or reports JSON (headless) —
577
+ // neither scales its buffers. Kept so the call is not a missing-method error on older bundles.
578
+ setSceneRenderOptions(): void {},
579
+ setMaterialGlobalParameter(): void {},
580
+ getCameraFov(sceneId: number, fovType: number): number {
581
+ const { fov: vfov } = projectionOf(sceneId)
582
+ if (fovType !== 1) return vfov
583
+ const vert = (vfov * Math.PI) / 180
584
+ return (2 * Math.atan(Math.tan(vert / 2) * (width / height)) * 180) / Math.PI
585
+ },
586
+ setCameraProjection(sceneId: number, newFov: number, near: number, far: number): void {
587
+ const s = scenes.get(sceneId)
588
+ if (s) s.projection = { fov: newFov, near, far }
589
+ },
590
+
591
+ // --- transform reads ---
592
+ getMatrix(entityId: number, mat: Float32Array): boolean { mat.set(localMatrixF(entityId)); return true },
593
+ getWorldMatrix(entityId: number, mat: Float32Array): void { mat.set(worldMatrixF(entityId)) },
594
+ getWorldPosition(entityId: number, out: Float32Array): void {
595
+ const w = worldMatrix(entityId)
596
+ out[0] = w[12]; out[1] = w[13]; out[2] = w[14]
597
+ },
598
+ getWorldDirection(entityId: number, mode: number, out: Float32Array): void {
599
+ const w = worldMatrix(entityId)
600
+ out[0] = w[mode * 4]; out[1] = w[mode * 4 + 1]; out[2] = w[mode * 4 + 2]
601
+ },
602
+ getWorldMatrixInverse(entityId: number, mat: Float32Array): void { mat.set(invertTRS(worldMatrixF(entityId))) },
603
+ getCameraViewDirection(sceneId: number, screenX: number, screenY: number, out: Float32Array): void {
604
+ // A real unproject through the camera world matrix + the same projection we report.
605
+ const camId = scenes.get(sceneId)?.camera
606
+ const w = camId ? worldMatrix(camId) : identity()
607
+ const aspect = width / height
608
+ const t = Math.tan((projectionOf(sceneId).fov * Math.PI) / 360)
609
+ const cx = ((screenX / width) * 2 - 1) * t * aspect
610
+ const cy = (1 - (screenY / height) * 2) * t
611
+ const dx = w[0] * cx + w[4] * cy - w[8]
612
+ const dy = w[1] * cx + w[5] * cy - w[9]
613
+ const dz = w[2] * cx + w[6] * cy - w[10]
614
+ const len = Math.hypot(dx, dy, dz) || 1
615
+ out[0] = dx / len; out[1] = dy / len; out[2] = dz / len
616
+ },
617
+
618
+ // --- meshes ---
619
+ setMesh(entityId: number, materialId: number, vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, meshType: number, _uv1?: Float32Array, colors?: Uint8Array): void {
620
+ const e = ent(entityId)
621
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
622
+ for (let i = 0; i + 2 < vertices.length; i += 3) {
623
+ const x = vertices[i], y = vertices[i + 1], z = vertices[i + 2]
624
+ if (x < minX) minX = x; if (x > maxX) maxX = x
625
+ if (y < minY) minY = y; if (y > maxY) maxY = y
626
+ if (z < minZ) minZ = z; if (z > maxZ) maxZ = z
627
+ }
628
+ const has = vertices.length >= 3
629
+ const mesh: MeshRec = {
630
+ kind: meshType,
631
+ vertexCount: Math.floor(vertices.length / 3),
632
+ size: has ? [maxX - minX, maxY - minY, maxZ - minZ] : undefined,
633
+ center: has ? [(maxX + minX) / 2, (maxY + minY) / 2, (maxZ + minZ) / 2] : undefined,
634
+ }
635
+ e.mesh = mesh
636
+ e.materials[0] = materialId
637
+ backend.meshSet?.(entityId, materialId, mesh, { vertices, normals, indices, uv, colors })
638
+ },
639
+ setCulling(): void {},
640
+ setRenderPriority(): void {},
641
+ setMaterialDepthTest(): void {},
642
+ setMaterialDepthWrite(): void {},
643
+ setMaterialCulling(): void {},
644
+ // no stencil in the lite renderer: a stencil test passes, a write is dropped (the outline recipe
645
+ // then draws its hull everywhere — an approximation, like lite's lighting)
646
+ setMaterialStencil(): void {},
647
+ setSceneStencil(): void {},
648
+ setCastShadows(entityId: number, v: boolean): void { ent(entityId).castShadows = v },
649
+ setReceiveShadows(entityId: number, v: boolean): void { ent(entityId).receiveShadows = v },
650
+ // creator-gl walks every renderable of the GLB instance; the twin walks the entity subtree
651
+ setGlbShadows(entityId: number, cast: boolean, receive: boolean): void {
652
+ const walk = (id: number): void => {
653
+ const e = ent(id)
654
+ e.castShadows = cast
655
+ e.receiveShadows = receive
656
+ for (const c of e.children) walk(c)
657
+ }
658
+ walk(entityId)
659
+ },
660
+ setCastShadowsGlobal(): void {},
661
+ setReceiveShadowsGlobal(): void {},
662
+ hasMesh: (entityId: number): boolean => ent(entityId).mesh !== undefined,
663
+
664
+ // --- materials + uniforms ---
665
+ createMaterial(systemId: number): number {
666
+ const id = nextMaterial++
667
+ const rec: MaterialRec = { shader: sourceName(systemId) ?? "unknown", uniforms: {} }
668
+ materials.set(id, rec)
669
+ backend.materialCreated?.(id, rec, systemId)
670
+ return id
671
+ },
672
+ /** A built-in material by NAME (`_creator.builtinMaterial`): this engine draws with its own programs,
673
+ * picked by that name — there is no shader file to find. */
674
+ createMaterialNamed(name: string): number {
675
+ const id = nextMaterial++
676
+ const rec: MaterialRec = { shader: name, uniforms: {} }
677
+ materials.set(id, rec)
678
+ backend.materialCreated?.(id, rec)
679
+ return id
680
+ },
681
+ setMaterial(entityId: number, materialId: number, index: number): void { ent(entityId).materials[index] = materialId },
682
+ getMaterial: (entityId: number, index: number): number => ent(entityId).materials[index] ?? 0,
683
+
684
+ setUniformRgb(materialId: number, uniform: string, color: number): void { setUniform(materialId, uniform, { kind: "rgb", v: color }) },
685
+ setUniformRgba(materialId: number, uniform: string, color: number): void { setUniform(materialId, uniform, { kind: "rgba", v: color }) },
686
+ setUniformFloat(materialId: number, uniform: string, value: number): void { setUniform(materialId, uniform, { kind: "float", v: value }) },
687
+ setUniformBoolean(materialId: number, uniform: string, value: boolean): void { setUniform(materialId, uniform, { kind: "bool", v: value }) },
688
+ setUniformTexture(materialId: number, uniform: string, systemId: number): void { setUniform(materialId, uniform, { kind: "texture", v: systemId }) },
689
+ setUniformArray(materialId: number, uniform: string, value: Float32Array): void { setUniform(materialId, uniform, { kind: "array", v: Array.from(value) }) },
690
+ setUniformNull(materialId: number, uniform: string): void { setUniform(materialId, uniform, { kind: "null" }) },
691
+
692
+ // --- textures ---
693
+ createTexture(systemId: number, onComplete: (id: number, width: number, height: number) => void, onReject: () => void): void {
694
+ const buf = opts.getBuffer(systemId)
695
+ if (!buf || buf.byteLength === 0) { onReject(); return }
696
+ const [w, h] = sniffImageSize(buf)
697
+ const id = nextTexture++
698
+ textures.set(id, { source: sourceName(systemId), width: w, height: h })
699
+ onComplete(id, w, h)
700
+ },
701
+ createTextureFromCanvas(surfaceId: number): number {
702
+ const id = nextTexture++
703
+ textures.set(id, { source: `canvas#${surfaceId}`, width: 0, height: 0 })
704
+ return id
705
+ },
706
+ updateTextureFromCanvas(): void {},
707
+ // Raw-pixel textures (Texture.fromPixels / a terrain's control map): the size is recorded, the
708
+ // WebGL backend overrides both with real uploads.
709
+ createTexturePixels(width: number, height: number, channels: number): number {
710
+ const id = nextTexture++
711
+ textures.set(id, { source: `pixels ${width}x${height}x${channels}`, width, height })
712
+ return id
713
+ },
714
+ updateTexturePixels(): void {},
715
+ createMediaPlayerTexture(playerId: number): number {
716
+ const id = nextTexture++
717
+ textures.set(id, { source: `mediaplayer#${playerId}`, width: 0, height: 0 })
718
+ return id
719
+ },
720
+
721
+ // --- camera ---
722
+ attachCameraToAR(sceneId: number, cameraEntityId: number, onProjectionChange: (mat: Float32Array, fov: number) => void): void {
723
+ const s = scenes.get(sceneId)
724
+ if (s) s.camera = cameraEntityId
725
+ // Headless (recording) reports the projection so a captured scene knows its camera; the WebGL
726
+ // backend (like the Filament viewer) ignores this callback and drives projection via the viewport.
727
+ const p = projectionOf(sceneId)
728
+ onProjectionChange(perspective(p.fov, width / height, p.near, p.far), p.fov)
729
+ },
730
+ getDisplaySize: (): Float32Array => Float32Array.of(width, height),
731
+
732
+ // --- visibility / names ---
733
+ setVisible(entityId: number, visible: boolean): void {
734
+ ent(entityId).visible = visible
735
+ backend.visibleChanged?.(entityId, visible)
736
+ // a hidden subtree's particle systems stop simulating (creator-gl renderable.cpp updateVisibleChild)
737
+ const walk = (id: number): void => {
738
+ const e = ent(id)
739
+ if (e.particles) particles.setVisible(id, visibleChain(id))
740
+ for (const c of e.children) walk(c)
741
+ }
742
+ walk(entityId)
743
+ },
744
+ isVisible: (entityId: number): boolean => visibleChain(entityId),
745
+ getName: (entityId: number): string => ent(entityId).name,
746
+ setName(entityId: number, name: string): void { ent(entityId).name = name },
747
+
748
+ // --- GLB models ---
749
+ // Two-stage: the core builds the entity structure (sync, gl-free), then the backend attaches
750
+ // drawables. Headless has no glbAttach → completes synchronously; the WebGL backend's glbAttach is
751
+ // async (GLTFLoader) → onComplete fires once geometry/skins are ready.
752
+ createGlb(systemId: number, onComplete: (entityId: number) => void, onReject?: (err: unknown) => void): void {
753
+ const { rootId, entIds } = buildGlbEntities(systemId)
754
+ if (!backend.glbAttach) { onComplete(rootId); return }
755
+ Promise.resolve(backend.glbAttach(systemId, rootId, entIds)).then(() => onComplete(rootId)).catch((e) => onReject?.(e))
756
+ },
757
+ setGlbCulling(): void {},
758
+ // The model's embedded clip set (docs/animation-plan.md), parsed at createGlb; clones share it. 0 = none.
759
+ getGlbClipSet: (entityId: number): number => entities.get(entityId)?.model?.clipSet ?? 0,
760
+ ...anim.methods,
761
+
762
+ setEarlyUpdate(cb: (dt: number) => void): void { earlyUpdate = cb },
763
+ setLateUpdate(cb: (dt: number) => void): void { lateUpdate = cb },
764
+ setFixedUpdate(cb: ((dt: number) => void) | null): void { fixedUpdate = cb },
765
+ setTimeScale(scale: number): void { timeScale = Number.isFinite(scale) && scale > 0 ? scale : 0 },
766
+ ...physics.methods,
767
+ ...particles.methods,
768
+ }
769
+
770
+ // The LOCAL matrix as a concrete Float32Array (native getMatrix reads local, not world).
771
+ function localMatrixF(entityId: number): Float32Array {
772
+ const m = ent(entityId).m
773
+ return m instanceof Float32Array ? m : Float32Array.from(m as ArrayLike<number>)
774
+ }
775
+
776
+ // Inspection camera override (host tooling — the headless twin of creator-gl's setCameraOverride):
777
+ // while set, the frame report + serialized camera project through THIS eye/look-at (and fov)
778
+ // instead of the scene's camera entity. The entity itself is untouched — raycasts still use it.
779
+ let cameraOverride: CameraOverride | null = null
780
+
781
+ return {
782
+ // config
783
+ width, height, fov,
784
+ // state (read by serialize.ts; written by recording.ts)
785
+ entities, scenes, materials, textures,
786
+ cameraOverride: (): CameraOverride | null => cameraOverride,
787
+ setCameraOverride: (o: CameraOverride | null): void => { cameraOverride = o },
788
+ // helpers (used by recording.ts + the WebGL backend)
789
+ ent, newEnt, worldMatrix, worldMatrixF, sourceName, setParentInternal, sceneOf, projectionOf,
790
+ /** Repoint an entity's local matrix at a backend-owned buffer (WebGL uses this for GLB nodes so
791
+ * the entity IS the loader's animated transform — animation/skinning keep working). */
792
+ rebindMatrix: (entityId: number, m: Mat4Like): void => { ent(entityId).m = m },
793
+ // lifecycle
794
+ methods, simulate, tick, hasActiveScene, activeSceneId,
795
+ /** The physics world config the SDK set (serialize.ts), undefined if never configured. */
796
+ physicsConfig: physics.config,
797
+ /** A particle system's index pattern for its current capacity (the WebGL backend's index buffer). */
798
+ particleIndexPattern: particles.indexPattern,
799
+ nextTextureId: () => nextTexture++,
800
+ nextMaterialId: () => nextMaterial++,
801
+ }
802
+ }
803
+
804
+ export type SceneCore = ReturnType<typeof createSceneCore>
805
+
806
+ /** Sniff PNG/JPEG pixel dimensions from encoded bytes (headless textures aren't decoded). */
807
+ export const sniffImageSize = (buf: ArrayBuffer): [number, number] => {
808
+ const b = new Uint8Array(buf)
809
+ if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
810
+ const dv = new DataView(buf)
811
+ return [dv.getUint32(16), dv.getUint32(20)]
812
+ }
813
+ // KTX2 («KTX 20» CR LF 0x1A LF): pixelWidth/pixelHeight little-endian @20/@24.
814
+ if (b.length >= 28 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58 && b[4] === 0x20 && b[5] === 0x32 && b[6] === 0x30 && b[7] === 0xbb) {
815
+ const dv = new DataView(buf)
816
+ return [dv.getUint32(20, true), dv.getUint32(24, true)]
817
+ }
818
+ if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
819
+ const dv = new DataView(buf)
820
+ let p = 2
821
+ while (p + 9 < b.length) {
822
+ if (b[p] !== 0xff) break
823
+ const marker = b[p + 1]
824
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
825
+ return [dv.getUint16(p + 7), dv.getUint16(p + 5)]
826
+ }
827
+ p += 2 + dv.getUint16(p + 2)
828
+ }
829
+ }
830
+ return [0, 0]
831
+ }
832
+
833
+ export { copyInto }