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,1627 @@
1
+ // The lite web 3D engine: a from-scratch WebGL2 implementation of the `_creator` bridge
2
+ // (sdk/src/bridges.d.ts) built on OGL instead of the 3.2MB Filament wasm. Same object
3
+ // shape as viewer/createViewer.ts, so host.ts / the SDK / the touch system consume it unchanged.
4
+ //
5
+ // Scene semantics live in the shared, GL-free SceneCore (gl/core) — the SAME core the headless
6
+ // renderer drives — so the pixels a human sees and the JSON an AI reads come from one state and can't
7
+ // disagree. This file is the WebGL BACKEND for that core: it owns the OGL objects (Transforms,
8
+ // Programs, Geometry/Mesh, Textures) keyed by the core's entity ids and reacts to the core's
9
+ // SceneBackend hooks (build a Program on materialCreated, a Mesh on meshSet, sample a clip on
10
+ // morphWeights, …). The scene-graph bridge methods come from core.methods; this file adds the
11
+ // GL-only + capability methods (textures, shadows, raycast, AR throws, capture).
12
+ //
13
+ // Lighting follows Filament's model: photometric intensities (lux) × the default-camera exposure, the
14
+ // published BRDF, the same neutral IBL asset (KTX cubemap + SH). See shaders.ts. Custom .mat shaders
15
+ // run via the backend's webgl-lite JSON artifact (customMaterial.ts); a binary filamat or an
16
+ // `unsupported` artifact falls back to unlit white + warn.
17
+ //
18
+ // Not implemented (honest stubs, same failure shape as the Filament web build where possible):
19
+ // particles (warn-once no-op), AR/VR (throw / reject), Jolt physics (the core's creator-physics wasm),
20
+ // bloom (no-op). The headless backend records those instead (recording.ts).
21
+
22
+ import { Camera, Geometry, Mesh, GLTFSkin, Program, Renderer, RenderTarget, Texture, Transform, type OGLRenderingContext } from 'ogl'
23
+ import { _fetch, getBlob, getBuffer as getFetchedBuffer, getLocalName, hasLocalBuffer, putBuffer, putLocalBuffer } from 'lecodes-web-shared/src/utils/fetch'
24
+ import { getPlayer, removePlayers } from 'lecodes-web-shared/src/utils/mediaplayer'
25
+ import type { CanvasHost } from 'lecodes-web-shared/src/canvas/createCanvasHost'
26
+ import {
27
+ identity, intersectRayBox, intersectRaySphere, invertTRS, multiply, orthographic,
28
+ transformPoint, type Mat4Like,
29
+ } from '../core/math3d'
30
+ import {
31
+ EXPOSURE, depthFragment, depthVertex, gltfVertex, hexToLinearRgb, hexToLinearRgba,
32
+ litFragment, litVertex, shadowCatcherFragment, terrainFragment, tonemapJs, unlitBlendFragment, unlitFragment,
33
+ videoFragment, wireFragment,
34
+ } from './shaders'
35
+ import { loadIbl, type IblData } from './ibl'
36
+ import { buildEdgeIndices, createBlackCubemap, createWhiteTexture, loadGlbLite } from './glb'
37
+ import { buildCustomProgram, parseLiteMaterial } from './customMaterial'
38
+ import { createEditorLayer, type EditorHandlers, type EditorLayer } from './editor'
39
+ import { createBatcher, type Batcher } from './batching'
40
+ import { particlesPointVertex, particlesPointFragment, particlesQuadVertex, particlesQuadFragment } from './shaders'
41
+ import { createSceneCore, type SceneBackend, type UniformRec } from '../core/sceneCore'
42
+ import { loadCreatorAnim } from '../core/animBridge'
43
+ import { loadCreatorPhysics } from '../core/physicsBridge'
44
+ import { loadCreatorParticles, type ParticleFrame } from '../core/particleBridge'
45
+ import { MODE } from '../vendor/creator-particles/js/index'
46
+ import { serialize as serializeScene3d } from '../core/serialize'
47
+ import { unsupported } from '../gen/unsupported.gen'
48
+ // The global `Handle` the contract's creators return (declared with the tree bridge): without it
49
+ // gl.d.ts reads `Handle` as any and the `satisfies` check cannot see a creator returning a number.
50
+ import type {} from 'lecodes-sdk/bridges/tree'
51
+ import iblTextureUrl from '../assets/neutral_ibl128.ktx?url'
52
+
53
+ // `backend` is only ever a name fragment the compiled bundle substitutes into a shader URL
54
+ // (`<hash>_${_creator.backend}.filamat`); 'webgl-lite' selects the parsed-.mat JSON artifact
55
+ // (sdk/compile liteMaterial.ts) this engine wraps in its own GLSL template.
56
+ export const basePropsLite = {
57
+ backend: 'webgl-lite',
58
+ }
59
+
60
+ // Same shape as the full viewer's ViewerOptions (viewer/createViewer.ts) — defined locally so the
61
+ // lite engine has no dependency on the viewer package.
62
+ export type ViewerOptions = {
63
+ preloads?: Set<string>
64
+ /** The core 2D-canvas host (`_creatorCanvas`); lets createTextureFromCanvas read baked surfaces. */
65
+ canvasHost?: CanvasHost
66
+ /** Clear to transparent instead of the skybox color (and open the context with an alpha
67
+ * channel). Capture hosts use this so PNG thumbnails composite over any page theme. */
68
+ transparent?: boolean
69
+ }
70
+
71
+ const FOV = 60 // vertical fov set by the native setViewport (scene.cpp) — matches the core default
72
+ const NEAR = 0.01
73
+ const FAR = 1000 // matches the native far plane (scene.cpp setViewport)
74
+
75
+ type Collider =
76
+ | { type: 'box', center: number[], extent: number[] }
77
+ | { type: 'sphere', center: number[], radius: number }
78
+
79
+ /** The OGL objects the backend keeps per core entity id. `transform.matrix` is the core entity's `m`. */
80
+ type GlEnt = { transform: Transform, drawables: Mesh[] }
81
+
82
+ type MaterialKind = 'lit' | 'unlit' | 'video' | 'shadow' | 'terrain' | 'particles' | 'particles-quad' | 'gltf' | 'custom' | 'unknown'
83
+
84
+ /** The OGL objects behind one particle system (creator-particles' stream → one dynamic drawable). */
85
+ type GlParticles = {
86
+ materialId: number
87
+ mesh: Mesh | null
88
+ geometry: Geometry | null
89
+ /** The interleaved stream buffer (points / quads / ribbon); mesh mode uses plain attributes. */
90
+ buffer: WebGLBuffer | null
91
+ version: number
92
+ mode: number
93
+ }
94
+ type GlMaterial = {
95
+ kind: MaterialKind
96
+ program: Program
97
+ /** custom (.mat) materials: declared parameter names — the SDK sets uniforms by the bare
98
+ * parameter name, the program declares them as materialParams_<name> */
99
+ customParams?: Set<string>
100
+ }
101
+
102
+ // glTF ubershader parameter names (what the SDK sets on model materials) → our uniform names.
103
+ const GLTF_UNIFORM_ALIAS: Record<string, string> = {
104
+ baseColorFactor: 'uBaseColorFactor',
105
+ metallicFactor: 'uMetallic',
106
+ roughnessFactor: 'uRoughness',
107
+ emissiveFactor: 'uEmissive',
108
+ baseColorMap: 'uBaseColorMap',
109
+ baseColorIndex: 'uBaseColorIndex',
110
+ }
111
+
112
+ export const createViewerLite = async (canvas: HTMLCanvasElement, options: ViewerOptions = {}) => {
113
+ const renderer = new Renderer({
114
+ canvas,
115
+ dpr: 1, // set per-resize to the host's resolution factor (devicePixelRatio × CSS zoom); see _onResize
116
+ alpha: options.transparent ?? false,
117
+ antialias: true,
118
+ premultipliedAlpha: true,
119
+ powerPreference: 'high-performance',
120
+ preserveDrawingBuffer: options.preloads?.has('capture') ?? false,
121
+ })
122
+ const gl = renderer.gl as OGLRenderingContext & WebGL2RenderingContext
123
+ renderer.autoClear = false // we clear manually (per-scene clear color, overlay depth clears)
124
+
125
+ const white = createWhiteTexture(gl)
126
+ const blackCube = createBlackCubemap(gl)
127
+
128
+ // The SDK resolves built-in shaders via `_creatorFetch.local("lit.filamat")` — register the
129
+ // names with empty payloads so fetchLocal yields a systemId whose NAME identifies the material
130
+ // (the lite engine picks shaders by name; it never compiles filamat packages). ALWAYS registered,
131
+ // never from options.preloads: a host that passes no preloads (the scene editor) must still get
132
+ // lit materials — an unresolved name means every material silently renders unlit. Names the
133
+ // Filament engine already materialized keep their real buffers (same-page engine switches).
134
+ for (const name of [
135
+ 'lit.filamat', 'unlit.filamat', 'unlit-transparent.filamat', 'video.filamat', 'shadow.filamat',
136
+ 'particles.filamat', 'particles-quad.filamat', 'terrain.filamat',
137
+ ]) {
138
+ if (!hasLocalBuffer(name)) putLocalBuffer(name, new ArrayBuffer(0))
139
+ }
140
+
141
+ // ---- shared per-frame lighting uniforms (every lit/gltf/shadow program references these) ------
142
+ const sceneUniforms = {
143
+ sunDirection: { value: [0, -1, 0] as number[] },
144
+ sunColorIntensity: { value: [1, 1, 1, 0] as number[] },
145
+ iblLuminance: { value: 0 },
146
+ // NOTE: uniform ARRAYS must be plain JS arrays — OGL's name-component resolution drops typed
147
+ // arrays for `iblSH[0]`-style active uniforms.
148
+ iblSH: { value: new Array(27).fill(0) as number[] },
149
+ envMap: { value: blackCube as Texture },
150
+ envMapMaxLod: { value: 0 },
151
+ shadowMap: { value: white as Texture },
152
+ shadowEnabled: { value: 0 },
153
+ shadowMapSize: { value: 1024 },
154
+ shadowMatrix: { value: identity() },
155
+ exposure: { value: EXPOSURE },
156
+ }
157
+ // getUserTime() for custom .mat materials: [seconds, fract(seconds), 0, 0], updated per frame.
158
+ const userTime = { value: [0, 0, 0, 0] as number[] }
159
+
160
+ // The same neutral IBL the Filament build ships (KTX cubemap + SH metadata).
161
+ let ibl: IblData | null = null
162
+ const iblReady = fetch(iblTextureUrl)
163
+ .then((r) => r.arrayBuffer())
164
+ .then((buf) => {
165
+ ibl = loadIbl(gl, buf)
166
+ sceneUniforms.envMap.value = ibl.cubemap
167
+ sceneUniforms.envMapMaxLod.value = ibl.maxLod
168
+ sceneUniforms.iblSH.value = Array.from(ibl.sh)
169
+ })
170
+ .catch((e) => console.warn('[viewer-lite] IBL load failed:', e))
171
+
172
+ // ---- WebGL backend state (GL objects keyed by the core's ids) ---------------------------------
173
+ const glEnts = new Map<number, GlEnt>()
174
+ const sceneRoots = new Map<number, Transform>()
175
+ const sceneColliders = new Map<number, Map<number, Collider>>() // web CPU raycast (not in the core)
176
+ const glMaterials = new Map<number, GlMaterial>()
177
+ const glTextures = new Map<number, Texture>()
178
+ const glParticles = new Map<number, GlParticles>()
179
+ /** viewportHeightPx / (2 tan(fov/2)) — point sprites' world-space diameter (particles.mat's material global 3). */
180
+ const perspScale = { value: 1 }
181
+ /** CPU copies of raw-pixel textures (createTexturePixels) for sub-rectangle updates. */
182
+ const pixelTextures = new Map<number, { rgba: Uint8Array, width: number, height: number, channels: number }>()
183
+ let nextTexture = 1
184
+ let castShadowsGlobal = true
185
+ let receiveShadowsGlobal = true
186
+ /** Engine-side editor layer (grid/outline/gizmo/picking) — created on _editorConnect, editor-only. */
187
+ let editorLayer: EditorLayer | null = null
188
+ const activeVideoTextures: { video: HTMLVideoElement, texture: Texture, lastTime: number }[] = []
189
+
190
+ const glEnt = (id: number): GlEnt => {
191
+ let g = glEnts.get(id)
192
+ if (!g) {
193
+ const transform = new Transform()
194
+ transform.matrixAutoUpdate = false
195
+ ;(transform as any)._entId = id
196
+ g = { transform, drawables: [] }
197
+ glEnts.set(id, g)
198
+ }
199
+ return g
200
+ }
201
+
202
+ // ---- materials --------------------------------------------------------------------------------
203
+
204
+ // `blend` splits the two unlit materials: unlit.filamat is opaque (depth-written, no blending),
205
+ // unlit-transparent.filamat is premultiplied alpha. Everything else ignores it.
206
+ const makeMaterial = (kind: MaterialKind, blend = false): GlMaterial => {
207
+ let program: Program
208
+ if (kind === 'lit') {
209
+ program = new Program(gl, {
210
+ vertex: litVertex,
211
+ fragment: litFragment,
212
+ uniforms: {
213
+ ...sceneUniforms,
214
+ // factor×map identity defaults — color alone or map alone shows through unchanged
215
+ baseColor: { value: [1, 1, 1] },
216
+ baseColorMap: { value: white },
217
+ roughness: { value: 0 },
218
+ metallic: { value: 0 },
219
+ } as any,
220
+ })
221
+ } else if (kind === 'terrain') {
222
+ // terrain.mat's twin (shaders.ts terrainFragment): the SDK primes every scalar; samplers start white
223
+ program = new Program(gl, {
224
+ vertex: litVertex,
225
+ fragment: terrainFragment,
226
+ uniforms: {
227
+ ...sceneUniforms,
228
+ control: { value: white }, albedo0: { value: white }, albedo1: { value: white }, albedo2: { value: white }, albedo3: { value: white },
229
+ normals01: { value: white }, normals23: { value: white },
230
+ tiling: { value: [8, 8, 8, 8] }, roughness: { value: [1, 1, 1, 1] }, normalScale: { value: [0, 0, 0, 0] }, triplanar: { value: [0, 0, 0, 0] },
231
+ gridSize: { value: [2, 2] }, cellSize: { value: 1 }, tint: { value: [1, 1, 1] },
232
+ } as any,
233
+ })
234
+ } else if (kind === 'video') {
235
+ program = new Program(gl, {
236
+ vertex: litVertex,
237
+ fragment: videoFragment,
238
+ // shadowMatrix: the shared vertex shader declares it even though video ignores shadows.
239
+ uniforms: { baseColorMap: { value: white }, shadowMatrix: sceneUniforms.shadowMatrix } as any,
240
+ })
241
+ } else if (kind === 'shadow') {
242
+ program = new Program(gl, {
243
+ vertex: litVertex,
244
+ fragment: shadowCatcherFragment,
245
+ uniforms: { ...sceneUniforms, color: { value: [0, 0, 0, 0.66] } } as any,
246
+ transparent: true,
247
+ })
248
+ program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
249
+ } else if (kind === 'particles' || kind === 'particles-quad') {
250
+ // particles.mat / particles-quad.mat twins (shaders.ts): the SDK's Material.particles primes
251
+ // every look knob; the stream carries the per-vertex rest. Premultiplied, no depth write.
252
+ const quad = kind === 'particles-quad'
253
+ program = new Program(gl, {
254
+ vertex: quad ? particlesQuadVertex : particlesPointVertex,
255
+ fragment: quad ? particlesQuadFragment : particlesPointFragment,
256
+ uniforms: {
257
+ baseColorMap: { value: white }, sheetSize: { value: [1, 1] },
258
+ emissive: { value: 0 }, additive: { value: 0 }, softness: { value: 0 }, depthFade: { value: 0 },
259
+ ...(quad
260
+ ? { mode: { value: 0 }, stretch: { value: 0 }, emissiveMap: { value: white }, emissiveColor: { value: [0, 0, 0] }, frameBlend: { value: 0 } }
261
+ : { perspScale }),
262
+ } as any,
263
+ transparent: true,
264
+ depthWrite: false,
265
+ cullFace: false,
266
+ } as any)
267
+ program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
268
+ } else {
269
+ // unlit + unknown custom shaders (Material.load of a .filamat can't run here)
270
+ program = new Program(gl, {
271
+ vertex: litVertex,
272
+ fragment: blend ? unlitBlendFragment : unlitFragment,
273
+ uniforms: {
274
+ baseColor: { value: [1, 1, 1, 1] },
275
+ baseColorMap: { value: white },
276
+ shadowMatrix: sceneUniforms.shadowMatrix,
277
+ } as any,
278
+ transparent: blend,
279
+ })
280
+ if (blend) program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
281
+ }
282
+ return { kind, program }
283
+ }
284
+
285
+ // Custom .mat material: the fetched buffer is the backend's webgl-lite JSON artifact (parsed
286
+ // .mat) — wrap its fragment body in the lite GLSL template. Null → caller falls back to unlit.
287
+ const makeCustomMaterial = (systemId: number): GlMaterial | null => {
288
+ const artifact = parseLiteMaterial(getFetchedBuffer(systemId))
289
+ if (!artifact) return null
290
+ if (artifact.unsupported) {
291
+ console.warn(`[viewer-lite] custom material not supported in lite (${artifact.unsupported}) — rendering unlit`)
292
+ return null
293
+ }
294
+ if (artifact.hasVertexBlock) {
295
+ console.warn('[viewer-lite] custom material has a vertex{} block — the custom vertex stage is ignored in lite')
296
+ }
297
+ const spec = buildCustomProgram(artifact)
298
+ if (!spec) return null
299
+
300
+ const uniforms: Record<string, { value: any }> = spec.needsSceneUniforms
301
+ ? { ...sceneUniforms, userTime }
302
+ : { shadowMatrix: sceneUniforms.shadowMatrix, userTime }
303
+ for (const u of spec.uniforms) uniforms[u.key] = { value: u.value === 'texture' ? white : u.value }
304
+
305
+ const opaque = artifact.blending === 'opaque' || artifact.blending === 'masked'
306
+ const program = new Program(gl, {
307
+ vertex: litVertex,
308
+ fragment: spec.fragment,
309
+ uniforms: uniforms as any,
310
+ transparent: !opaque,
311
+ cullFace: artifact.doubleSided || artifact.culling === 'none' ? false
312
+ : artifact.culling === 'front' ? gl.FRONT : gl.BACK,
313
+ depthWrite: artifact.depthWrite,
314
+ } as any)
315
+ if (!gl.getProgramParameter(program.program, gl.LINK_STATUS)) {
316
+ console.warn('[viewer-lite] custom material failed to compile — rendering unlit')
317
+ return null
318
+ }
319
+ if (!opaque) program.setBlendFunc(gl.ONE, artifact.blending === 'add' ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA)
320
+ return { kind: 'custom', program, customParams: spec.paramNames }
321
+ }
322
+
323
+ // Build a GlMaterial from a shader NAME (createMaterial / builtinMaterial via the materialCreated hook).
324
+ // systemId is present for fetched shaders (needed to load a custom .mat), absent for a built-in one.
325
+ const buildProgramFor = (name: string, systemId?: number): GlMaterial => {
326
+ const kind: MaterialKind =
327
+ name.startsWith('lit') ? 'lit'
328
+ : name.startsWith('unlit') ? 'unlit'
329
+ : name.startsWith('video') ? 'video'
330
+ : name.startsWith('shadow') ? 'shadow'
331
+ : name.startsWith('terrain') ? 'terrain'
332
+ : name.startsWith('particles-quad') ? 'particles-quad'
333
+ : name.startsWith('particles') ? 'particles'
334
+ : 'unknown'
335
+ if (kind === 'unknown' && systemId !== undefined) {
336
+ const custom = makeCustomMaterial(systemId)
337
+ if (custom) return custom
338
+ console.warn(`[viewer-lite] custom shader can't run here (material "${name || systemId}") — rendering unlit`)
339
+ }
340
+ // 'unlit-transparent' is the same shader family (kind 'unlit'), drawn blended instead of opaque.
341
+ return makeMaterial(kind, name.startsWith('unlit-transparent'))
342
+ }
343
+
344
+ const materialUniform = (rec: GlMaterial, name: string): { value: any } => {
345
+ const key = rec.kind === 'gltf' ? (GLTF_UNIFORM_ALIAS[name] ?? name)
346
+ : rec.customParams?.has(name) ? `materialParams_${name}`
347
+ : name
348
+ const uniforms = rec.program.uniforms as Record<string, { value: any }>
349
+ if (!uniforms[key]) uniforms[key] = { value: 0 }
350
+ return uniforms[key]
351
+ }
352
+
353
+ // React to a core uniform change (semantic value) by converting + setting it on the Program. Texture
354
+ // uniforms are finalized by setUniformTexture (which also carries wrap modes); here we bind by id.
355
+ const applyUniform = (materialId: number, uniform: string, u: UniformRec): void => {
356
+ const rec = glMaterials.get(materialId)
357
+ if (!rec) return
358
+ // Legacy bundles cleared the lit/unlit map with `useBaseColorMap = false` — the factor×map
359
+ // shaders dropped that flag, so translate the clear into the white-texture reset it meant
360
+ // (custom materials may declare their own useBaseColorMap; leave those alone).
361
+ if (uniform === 'useBaseColorMap' && (rec.kind === 'lit' || rec.kind === 'unlit')) {
362
+ if (u.kind === 'bool' && !u.v) {
363
+ const map = materialUniform(rec, 'baseColorMap')
364
+ if (map.value instanceof Texture) map.value = white
365
+ }
366
+ return
367
+ }
368
+ const target = materialUniform(rec, uniform)
369
+ switch (u.kind) {
370
+ case 'rgb': {
371
+ const [r, g, b] = hexToLinearRgb(u.v)
372
+ if (Array.isArray(target.value) && target.value.length === 4) target.value = [r, g, b, target.value[3]]
373
+ else target.value = [r, g, b]
374
+ break
375
+ }
376
+ case 'rgba': {
377
+ // A vec3 uniform (lit baseColor, terrain tint) takes the colour and drops the alpha, like
378
+ // creator-gl's FLOAT3 branch: every SDK colour is '#rrggbbaa' now, opaque or not.
379
+ const rgba = hexToLinearRgba(u.v)
380
+ target.value = Array.isArray(target.value) && target.value.length === 3 ? rgba.slice(0, 3) : rgba
381
+ break
382
+ }
383
+ case 'float': target.value = u.v; break
384
+ case 'bool': target.value = u.v ? 1 : 0; break
385
+ case 'array': target.value = Float32Array.from(u.v); break
386
+ case 'texture': { const tex = glTextures.get(u.v); if (tex) target.value = tex; break }
387
+ case 'null': if (target.value instanceof Texture) target.value = white; break
388
+ }
389
+ }
390
+
391
+ // receiveShadows is per-drawable, programs are per-material — resolve at draw time.
392
+ const wireReceiveShadows = (mesh: Mesh, rec: GlMaterial): void => {
393
+ if (rec.kind !== 'lit' && rec.kind !== 'gltf' && rec.kind !== 'shadow' && rec.kind !== 'custom') return
394
+ mesh.onBeforeRender(() => {
395
+ const uniforms = mesh.program.uniforms as any
396
+ if (uniforms.shadowEnabled) {
397
+ const on = sceneUniforms.shadowEnabled.value === 1 && (mesh as any)._receiveShadows !== false
398
+ uniforms.shadowEnabled = on ? sceneUniforms.shadowEnabled : { value: 0 }
399
+ }
400
+ })
401
+ }
402
+
403
+ // Build the OGL Mesh for a primitive setMesh (core already recorded bounds + kind). The core always
404
+ // records the mesh; if the material never resolved to a program we skip the drawable (native/web).
405
+ const buildMesh = (entityId: number, materialId: number, arrays: {
406
+ vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, colors?: Uint8Array,
407
+ }, meshType: number): void => {
408
+ const rec = glMaterials.get(materialId)
409
+ if (!rec) return
410
+ const { vertices, normals, indices, uv, colors } = arrays
411
+ const geometry = new Geometry(gl, {
412
+ position: { size: 3, data: Float32Array.from(vertices) },
413
+ normal: { size: 3, data: normals.length ? Float32Array.from(normals) : new Float32Array(vertices.length) },
414
+ uv: { size: 2, data: uv.length ? Float32Array.from(uv) : new Float32Array((vertices.length / 3) * 2) },
415
+ // the vertex colours as 0..1 floats (Geometry.colors is bytes); a program that declares no
416
+ // `color` attribute simply ignores them — lite's custom materials have no getColor() yet
417
+ ...(colors && colors.length ? { color: { size: 4, data: Float32Array.from(colors, (v) => v / 255) } } : {}),
418
+ index: { data: Uint16Array.from(indices) },
419
+ } as any)
420
+ geometry.computeBoundingBox()
421
+ const mode = meshType === 1 ? gl.LINES : meshType === 2 ? gl.POINTS : gl.TRIANGLES
422
+ const mesh = new Mesh(gl, { geometry, program: rec.program, mode })
423
+ mesh.matrixAutoUpdate = false
424
+ ;(mesh as any)._entId = entityId
425
+ ;(mesh as any)._castShadows = castShadowsGlobal
426
+ ;(mesh as any)._receiveShadows = receiveShadowsGlobal
427
+ wireReceiveShadows(mesh, rec)
428
+ const g = glEnt(entityId)
429
+ for (const d of g.drawables) {
430
+ g.transform.removeChild(d) // native rebuilds the renderable
431
+ const wire = wireMeshes.get(d) as any
432
+ if (wire) { wire.setParent(null as any); wireMeshes.delete(d) }
433
+ }
434
+ g.drawables = [mesh]
435
+ g.transform.addChild(mesh)
436
+ }
437
+
438
+ // Stage 2 of GLB load (the core built the structure entities): run OGL's GLTFLoader, then adopt the
439
+ // loader's node Transforms AS this GLB's entities (rebindMatrix) so animation/skinning — which the
440
+ // loader binds to those transforms — keep working, and attach the drawable meshes under them.
441
+ const attachGlb = async (systemId: number, rootId: number, entIds: number[]): Promise<void> => {
442
+ const buf = getFetchedBuffer(systemId)
443
+ let result
444
+ try {
445
+ result = await loadGlbLite(gl, buf!, sceneUniforms, white)
446
+ } catch (e) {
447
+ // Honest degrade: the core's structure entities (hierarchy, bounds, animation clocks) stay —
448
+ // this model just gets no drawables. A bad/unsupported GLB must not take the whole run down.
449
+ console.warn(`[viewer-lite] GLB "${getLocalName(systemId) ?? systemId}" drawables failed to load:`,
450
+ e instanceof Error ? e.message : e)
451
+ return
452
+ }
453
+
454
+ result.nodes.forEach((node: any, i: number) => {
455
+ const coreId = entIds[i]
456
+ if (coreId === undefined) return
457
+ const g = glEnt(coreId)
458
+ g.transform.setParent(null as any) // drop the gl-free placeholder transform
459
+ core.rebindMatrix(coreId, node.matrix as unknown as Mat4Like)
460
+ node.matrixAutoUpdate = false
461
+ node._entId = coreId
462
+ g.transform = node
463
+ g.drawables = []
464
+ for (const child of node.children as any[]) {
465
+ if (child.draw) {
466
+ child.matrixAutoUpdate = false
467
+ child._entId = coreId
468
+ child._castShadows = castShadowsGlobal
469
+ child._receiveShadows = receiveShadowsGlobal
470
+ g.drawables.push(child)
471
+ }
472
+ }
473
+ })
474
+ // The loader parents child nodes under parent nodes already; parent its scene roots under the
475
+ // model-root entity's transform so the whole model moves with the root.
476
+ for (const lr of result.roots as any[]) lr.setParent(glEnt(rootId).transform)
477
+ }
478
+
479
+ /** Skins cloned during the current `cloneEntity`, still pointing at the SOURCE's joints — the
480
+ * clones of those joints do not exist yet when their mesh is cloned (pre-order). `subtreeCloned`
481
+ * re-points them once the whole subtree is there. */
482
+ const pendingSkinClones: GLTFSkin[] = []
483
+ const cloneDrawables = (sourceId: number, cloneId: number): void => {
484
+ const src = glEnt(sourceId)
485
+ const copy = glEnt(cloneId)
486
+ for (const d of src.drawables as any[]) {
487
+ const mesh = d instanceof GLTFSkin
488
+ ? new GLTFSkin(gl, { skeleton: d.skeleton, geometry: d.geometry, program: d.program, mode: d.mode })
489
+ : new Mesh(gl, { geometry: d.geometry, program: d.program, mode: d.mode })
490
+ if (mesh instanceof GLTFSkin) pendingSkinClones.push(mesh)
491
+ ;(mesh as any)._entId = cloneId
492
+ ;(mesh as any)._castShadows = d._castShadows
493
+ ;(mesh as any)._receiveShadows = d._receiveShadows
494
+ mesh.frustumCulled = d.frustumCulled
495
+ mesh.matrixAutoUpdate = false
496
+ copy.drawables.push(mesh)
497
+ copy.transform.addChild(mesh)
498
+ }
499
+ }
500
+
501
+ /** A clone's skin must follow the CLONE's joints: the Animator on the clone writes those, while
502
+ * the source's (a `defineScene` model template, never in a scene) stay at rest — sharing the
503
+ * source skeleton drew every cloned character in its bind pose. Each joint is swapped for its
504
+ * clone's Transform with the same inverse bind matrix; a joint outside the cloned subtree keeps
505
+ * the source's. Native gets this from gltfio's createInstance (one skeleton per instance). */
506
+ const rebindClonedSkins = (map: ReadonlyMap<number, number>): void => {
507
+ for (const mesh of pendingSkinClones.splice(0) as any[]) {
508
+ const joints = (mesh.skeleton.joints as any[]).map((j) => {
509
+ const cloneId = j._entId === undefined ? undefined : map.get(j._entId)
510
+ if (cloneId === undefined) return j
511
+ const t = glEnt(cloneId).transform as any
512
+ t.bindInverse = j.bindInverse
513
+ return t
514
+ })
515
+ mesh.skeleton = { ...mesh.skeleton, joints }
516
+ }
517
+ }
518
+
519
+ // ---- wireframe (editor viewport display modes — HOST API, not part of the bridge ABI) ---------
520
+ // A gl.LINES companion mesh per triangle drawable: unique-edge indices (buildEdgeIndices) over the
521
+ // SAME vertex data (shared GPU buffers when already uploaded), drawn with a solid-color program.
522
+ // 'overlay' keeps the shaded pass and polygon-offsets its fill so the lines win the depth tie;
523
+ // 'only' hides the shaded drawables. Existing LINES/POINTS content (grid, path lines) stays as-is.
524
+ // Companions are created lazily and re-synced every frame while a wire mode is active, so
525
+ // drawables that appear later (live patches, async GLB loads, clones) pick theirs up.
526
+ type WireframeMode = 'off' | 'overlay' | 'only'
527
+ let wireframeMode: WireframeMode = 'off'
528
+
529
+ const wireColor = { value: [0.05, 0.06, 0.08] as number[] }
530
+ const wireProgram = new Program(gl, {
531
+ vertex: litVertex,
532
+ fragment: wireFragment,
533
+ uniforms: { uWireColor: wireColor, shadowMatrix: sceneUniforms.shadowMatrix } as any,
534
+ })
535
+ const wireSkinProgram = new Program(gl, {
536
+ vertex: gltfVertex('HAS_SKIN'),
537
+ fragment: wireFragment,
538
+ uniforms: {
539
+ uWireColor: wireColor,
540
+ shadowMatrix: sceneUniforms.shadowMatrix,
541
+ boneTexture: { value: white },
542
+ boneTextureSize: { value: 4 },
543
+ } as any,
544
+ })
545
+
546
+ const wireGeometries = new WeakMap<object, Geometry>() // triangle geometry → LINES geometry
547
+ const wireMeshes = new WeakMap<object, Mesh>() // drawable → its wire companion
548
+
549
+ const wireGeometryFor = (src: any): Geometry => {
550
+ let g = wireGeometries.get(src)
551
+ if (!g) {
552
+ const attrs: Record<string, any> = {}
553
+ for (const [ name, a ] of Object.entries<any>(src.attributes)) {
554
+ if (name === 'index') continue
555
+ // Share the source attribute's CPU data AND (when already uploaded) its GPU buffer;
556
+ // stride/offset carry over for interleaved glTF attributes.
557
+ attrs[name] = {
558
+ size: a.size, data: a.data, type: a.type, normalized: a.normalized,
559
+ stride: a.stride, offset: a.offset, buffer: a.buffer, count: a.count,
560
+ }
561
+ }
562
+ const position = src.attributes.position
563
+ const vertexCount = position ? (position.count ?? position.data.length / position.size) : 0
564
+ attrs.index = { data: buildEdgeIndices(src.attributes.index?.data, vertexCount) }
565
+ g = new Geometry(gl, attrs)
566
+ wireGeometries.set(src, g)
567
+ }
568
+ return g
569
+ }
570
+
571
+ const ensureWireMesh = (d: any): Mesh | null => {
572
+ let wire = wireMeshes.get(d) as any
573
+ if (wire) return wire
574
+ if (!d.parent || !d.geometry?.attributes?.position) return null
575
+ const geometry = wireGeometryFor(d.geometry)
576
+ wire = d instanceof GLTFSkin
577
+ ? new GLTFSkin(gl, { skeleton: d.skeleton, geometry, program: wireSkinProgram, mode: gl.LINES })
578
+ : new Mesh(gl, { geometry, program: wireProgram, mode: gl.LINES })
579
+ wire.frustumCulled = false
580
+ wire.matrixAutoUpdate = false
581
+ wire.matrix.copy(d.matrix)
582
+ wire._entId = d._entId
583
+ wire._castShadows = false
584
+ wire._receiveShadows = false
585
+ wire.setParent(d.parent)
586
+ wireMeshes.set(d, wire)
587
+ touchGraph()
588
+ return wire
589
+ }
590
+
591
+ const updateWireframe = (): void => {
592
+ for (const g of glEnts.values()) {
593
+ for (const d of g.drawables as any[]) {
594
+ if (d.mode !== gl.TRIANGLES) continue
595
+ if (wireframeMode === 'off') {
596
+ d.visible = true
597
+ const wire = wireMeshes.get(d) as any
598
+ if (wire) wire.visible = false
599
+ } else {
600
+ const wire = ensureWireMesh(d) as any
601
+ d.visible = wireframeMode === 'overlay'
602
+ if (wire) wire.visible = true
603
+ }
604
+ }
605
+ }
606
+ }
607
+
608
+ const updateVideoTextures = (): void => {
609
+ for (const v of activeVideoTextures) {
610
+ if (v.video.videoWidth === 0 || v.video.currentTime === v.lastTime) continue
611
+ v.texture.image = v.video as any
612
+ ;(v.texture as any).needsUpdate = true
613
+ v.lastTime = v.video.currentTime
614
+ }
615
+ }
616
+
617
+ // ---- render loop ------------------------------------------------------------------------------
618
+
619
+ const camera = new Camera(gl, { fov: FOV, near: NEAR, far: FAR, aspect: 1 })
620
+ camera.matrixAutoUpdate = false
621
+
622
+ let sceneOpened = false
623
+ let lastTime = 0
624
+ let lastAspect = 0
625
+ // last projection pushed to the OGL camera — the scene's own (setCameraProjection) or the defaults
626
+ let lastFov = FOV, lastNear = NEAR, lastFar = FAR
627
+ // Resolution factor (devicePixelRatio × CSS zoom) the backing store renders at, set by _onResize.
628
+ // The bridge's screen-space contract is LOGICAL px, so getViewRay scales its inputs up to backing
629
+ // px by this factor and getDisplaySize reports the logical size. See docs/simulator-plan.md phase 3.
630
+ let resFactor = 1
631
+
632
+ // Shadow pass resources (lazy)
633
+ let shadowTarget: RenderTarget | null = null
634
+ let shadowMapSize = 0
635
+ const depthProgram = new Program(gl, {
636
+ vertex: depthVertex(false),
637
+ fragment: depthFragment,
638
+ uniforms: { modelMatrix: { value: identity() }, shadowViewProjection: { value: identity() } } as any,
639
+ cullFace: gl.FRONT, // reduces acne on closed meshes
640
+ })
641
+ const depthSkinProgram = new Program(gl, {
642
+ vertex: depthVertex(true),
643
+ fragment: depthFragment,
644
+ uniforms: {
645
+ modelMatrix: { value: identity() },
646
+ shadowViewProjection: { value: identity() },
647
+ boneTexture: { value: white },
648
+ boneTextureSize: { value: 4 },
649
+ } as any,
650
+ cullFace: gl.FRONT,
651
+ })
652
+ // the batches' depth program: the world matrix is the instance stream (batching.ts)
653
+ const depthInstancedProgram = new Program(gl, {
654
+ vertex: depthVertex(false, true),
655
+ fragment: depthFragment,
656
+ uniforms: { shadowViewProjection: { value: identity() } } as any,
657
+ cullFace: gl.FRONT,
658
+ })
659
+
660
+ // One batcher per scene root (the main scene, each overlay): its batches persist across frames,
661
+ // `collect` refreshes them from the graph, the passes draw from them. See batching.ts.
662
+ const batchers = new Map<Transform, Batcher>()
663
+ let batchingEnabled = true
664
+ const batcherFor = (root: Transform): Batcher => {
665
+ let b = batchers.get(root)
666
+ if (!b) { b = createBatcher(gl, renderer); b.setEnabled(batchingEnabled); batchers.set(root, b) }
667
+ return b
668
+ }
669
+ // The scene-graph mutation counter the batchers key their graph walk on. Every path that moves
670
+ // a node, re-parents it, shows/hides it or changes what it draws bumps it — the core's backend
671
+ // hooks cover the SDK, physics and animation; the host bumps for its own drawable changes
672
+ // (GLB attach, clones, particles, wireframe companions, flags). A frame whose counter didn't
673
+ // move skips the 57 k-node traversal entirely.
674
+ let graphVersion = 0
675
+ const touchGraph = (): void => { graphVersion++ }
676
+ /** OGL's world-matrix pass is a full recursive walk too — run it per root only when the graph
677
+ * moved since that root's last pass (the dirty flags it propagates were all set through
678
+ * `touchGraph`'s callers). */
679
+ const worldPassVersion = new Map<Transform, number>()
680
+ const updateWorld = (root: Transform): void => {
681
+ if (worldPassVersion.get(root) === graphVersion) return
682
+ root.updateMatrixWorld()
683
+ worldPassVersion.set(root, graphVersion)
684
+ }
685
+ /** Draw a scene root through the batcher with an arbitrary camera (the editor's preview inset
686
+ * reuses it — the main frame's shadow map / lighting uniforms stay as set). `force` re-walks
687
+ * the graph: the preview hides drawables behind the counter's back. */
688
+ const renderScene = (root: Transform, cam: Camera, force = false): void => {
689
+ const b = batcherFor(root)
690
+ updateWorld(root)
691
+ b.collect(root, cam, true, graphVersion, force)
692
+ b.drawMain(cam, sceneUniforms.shadowEnabled)
693
+ if (force) touchGraph() // the next main frame re-walks too (the hidden drawables come back)
694
+ }
695
+
696
+ /** The visible sun light entity that resolves to this scene, if any. */
697
+ const findSun = (sceneId: number) => {
698
+ for (const [id, e] of core.entities) {
699
+ if (e.light && core.methods.isVisible(id) && core.sceneOf(id)?.id === sceneId) return e.light
700
+ }
701
+ return null
702
+ }
703
+
704
+ const collectMeshes = (root: Transform): Mesh[] => {
705
+ const out: Mesh[] = []
706
+ root.traverse((node: any) => {
707
+ if (!node.visible) return true
708
+ if (node.draw) out.push(node)
709
+ return false
710
+ })
711
+ return out
712
+ }
713
+
714
+ const renderShadowPass = (root: Transform, sun: NonNullable<ReturnType<typeof findSun>>): void => {
715
+ const size = sun.shadowsQuality >= 3 ? 4096 : sun.shadowsQuality >= 2 ? 2048 : 1024
716
+ if (!shadowTarget || shadowMapSize !== size) {
717
+ shadowTarget = new RenderTarget(gl, { width: size, height: size, depthTexture: true })
718
+ shadowMapSize = size
719
+ }
720
+
721
+ // Casters: the walk's caster singles (every hierarchy-visible one, not only the camera-visible —
722
+ // a tree behind the camera still shadows the view) + the batches' `all` streams.
723
+ const batcher = batcherFor(root)
724
+ const meshes = batcher.casterSingles
725
+
726
+ // Fit an ortho frustum around the casters along the sun direction: the singles' transformed
727
+ // AABB corners + the batches' cached world bounds.
728
+ const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
729
+ const corner: number[] = [0, 0, 0]
730
+ for (const mesh of meshes as any[]) {
731
+ const g = mesh.geometry
732
+ if (!g.bounds) g.computeBoundingBox()
733
+ if (!g.bounds || !isFinite(g.bounds.min.x)) continue
734
+ const w = mesh.worldMatrix
735
+ for (let i = 0; i < 8; i++) {
736
+ transformPoint(corner, w,
737
+ i & 1 ? g.bounds.max.x : g.bounds.min.x,
738
+ i & 2 ? g.bounds.max.y : g.bounds.min.y,
739
+ i & 4 ? g.bounds.max.z : g.bounds.min.z, 1)
740
+ for (let k = 0; k < 3; k++) { if (corner[k] < min[k]) min[k] = corner[k]; if (corner[k] > max[k]) max[k] = corner[k] }
741
+ }
742
+ }
743
+ batcher.casterBounds(min, max)
744
+ if (!isFinite(min[0])) { sceneUniforms.shadowEnabled.value = 0; return }
745
+ const [ minX, minY, minZ ] = min, [ maxX, maxY, maxZ ] = max
746
+
747
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2
748
+ const radius = Math.max(0.5, Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2)
749
+
750
+ // Light view: looking along the sun direction at the caster bounds center.
751
+ let dx = sun.direction[0], dy = sun.direction[1], dz = sun.direction[2]
752
+ const dl = Math.hypot(dx, dy, dz) || 1
753
+ dx /= dl; dy /= dl; dz /= dl
754
+ const eye = [cx - dx * radius * 2, cy - dy * radius * 2, cz - dz * radius * 2]
755
+ // Build an orthonormal basis (z = -dir → light looks down +dir).
756
+ const zx = -dx, zy = -dy, zz = -dz
757
+ const upY = Math.abs(dy) > 0.99 ? [1, 0, 0] : [0, 1, 0]
758
+ let xx = upY[1] * zz - upY[2] * zy, xy = upY[2] * zx - upY[0] * zz, xz = upY[0] * zy - upY[1] * zx
759
+ const xl = Math.hypot(xx, xy, xz) || 1; xx /= xl; xy /= xl; xz /= xl
760
+ const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx
761
+ // View matrix = inverse of the light's world matrix (rigid).
762
+ const view = new Float32Array(16)
763
+ view[0] = xx; view[4] = xy; view[8] = xz
764
+ view[1] = yx; view[5] = yy; view[9] = yz
765
+ view[2] = zx; view[6] = zy; view[10] = zz
766
+ view[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2])
767
+ view[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2])
768
+ view[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2])
769
+ view[15] = 1
770
+ const proj = orthographic(-radius, radius, -radius, radius, 0.05, radius * 4)
771
+ const viewProj = multiply(new Float32Array(16), proj, view)
772
+
773
+ depthProgram.uniforms.shadowViewProjection.value = viewProj
774
+ depthSkinProgram.uniforms.shadowViewProjection.value = viewProj
775
+ depthInstancedProgram.uniforms.shadowViewProjection.value = viewProj
776
+ sceneUniforms.shadowMatrix.value = viewProj
777
+ sceneUniforms.shadowMap.value = shadowTarget.depthTexture
778
+ sceneUniforms.shadowMapSize.value = size
779
+ sceneUniforms.shadowEnabled.value = 1
780
+
781
+ renderer.bindFramebuffer(shadowTarget)
782
+ renderer.setViewport(size, size)
783
+ gl.clearColor(1, 1, 1, 1)
784
+ renderer.enable(gl.DEPTH_TEST)
785
+ renderer.setDepthMask(true)
786
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
787
+
788
+ const identityMat = identity()
789
+ for (const mesh of meshes as any[]) {
790
+ const isSkin = mesh instanceof GLTFSkin
791
+ const program = isSkin ? depthSkinProgram : depthProgram
792
+ const original = mesh.program
793
+ program.uniforms.modelMatrix.value = isSkin ? identityMat : mesh.worldMatrix
794
+ mesh.program = program
795
+ mesh.draw({})
796
+ mesh.program = original
797
+ }
798
+ batcher.drawShadow(depthInstancedProgram)
799
+ renderer.bindFramebuffer({ buffer: null } as any)
800
+ }
801
+
802
+ const stepFrame = (nowMs: number): void => {
803
+ let dt = lastTime === 0 ? 0 : (nowMs - lastTime) / 1000
804
+ if (dt > 0.05) dt = 0.05
805
+ lastTime = nowMs
806
+ const t = nowMs / 1000
807
+ userTime.value[0] = t
808
+ userTime.value[1] = t - Math.floor(t)
809
+ // The core runs early update → physics → animators (joint writes + morphWeights) → late update.
810
+ core.tick(dt)
811
+ }
812
+
813
+ const syncCamera = (sceneId: number): void => {
814
+ const aspect = canvas.width / Math.max(1, canvas.height)
815
+ const { fov, near, far } = core.projectionOf(sceneId)
816
+ perspScale.value = Math.max(1, canvas.height) / (2 * Math.tan((fov * Math.PI) / 360))
817
+ if (aspect !== lastAspect || fov !== lastFov || near !== lastNear || far !== lastFar) {
818
+ lastAspect = aspect
819
+ lastFov = fov; lastNear = near; lastFar = far
820
+ camera.perspective({ fov, aspect, near, far })
821
+ }
822
+ const camId = core.scenes.get(sceneId)?.camera
823
+ const world = camId !== undefined ? core.worldMatrix(camId) : identity()
824
+ for (let i = 0; i < 16; i++) (camera.matrix as any)[i] = world[i]
825
+ camera.worldMatrixNeedsUpdate = true
826
+ }
827
+
828
+ const renderFrame = (nowMs: number): boolean => {
829
+ const sceneId = core.activeSceneId()
830
+ const scene = core.scenes.get(sceneId)
831
+ const root = sceneRoots.get(sceneId)
832
+ if (!scene || !root) return false
833
+ stepFrame(nowMs)
834
+ updateVideoTextures()
835
+ syncCamera(sceneId)
836
+
837
+ // Wireframe companions track late-arriving drawables; polygon offset pushes the shaded fill
838
+ // back so overlay lines win the depth tie (fill-only state — lines/points are unaffected, and
839
+ // during the shadow pass it just adds a hair of depth bias).
840
+ if (wireframeMode !== 'off') {
841
+ updateWireframe()
842
+ gl.enable(gl.POLYGON_OFFSET_FILL)
843
+ gl.polygonOffset(1, 1)
844
+ } else {
845
+ gl.disable(gl.POLYGON_OFFSET_FILL)
846
+ }
847
+
848
+ // Sun + shadows. The batcher walks the graph once here; the shadow pass and the main pass
849
+ // both draw from what it collected.
850
+ updateWorld(root)
851
+ const batcher = batcherFor(root)
852
+ batcher.collect(root, camera, true, graphVersion)
853
+ const sun = findSun(sceneId)
854
+ if (sun) {
855
+ // Native passes HEX_TO_RGB straight to LightManager::Builder::color (linear) — no sRGB decode.
856
+ const r = ((sun.color >>> 16) & 0xff) / 255, g = ((sun.color >>> 8) & 0xff) / 255, b = (sun.color & 0xff) / 255
857
+ sceneUniforms.sunDirection.value = sun.direction as unknown as number[]
858
+ sceneUniforms.sunColorIntensity.value = [r, g, b, sun.intensity]
859
+ if (sun.shadowsQuality > 0) renderShadowPass(root, sun)
860
+ else sceneUniforms.shadowEnabled.value = 0
861
+ } else {
862
+ sceneUniforms.sunColorIntensity.value = [1, 1, 1, 0]
863
+ sceneUniforms.shadowEnabled.value = 0
864
+ }
865
+ sceneUniforms.iblLuminance.value = scene.ibl ?? 0
866
+
867
+ // Skybox clear color: native treats the hex as a LINEAR color rendered through the tonemapper;
868
+ // the default (no setSkybox) is Filament's default skybox color.
869
+ const clear = scene.skybox !== undefined
870
+ ? [tonemapJs(((scene.skybox >>> 16) & 0xff) / 255), tonemapJs(((scene.skybox >>> 8) & 0xff) / 255), tonemapJs((scene.skybox & 0xff) / 255)]
871
+ : [tonemapJs(0.1), tonemapJs(0.25), tonemapJs(0.57)]
872
+
873
+ // Main pass. Transparent hosts leave alpha 0 everywhere the scene doesn't draw — MSAA edge
874
+ // coverage lands as premultiplied (color × coverage, coverage), which is what the canvas declares.
875
+ renderer.bindFramebuffer({ buffer: null } as any)
876
+ renderer.setViewport(canvas.width, canvas.height)
877
+ if (options.transparent) gl.clearColor(0, 0, 0, 0)
878
+ else gl.clearColor(clear[0], clear[1], clear[2], 1)
879
+ renderer.enable(gl.DEPTH_TEST)
880
+ renderer.setDepthMask(true)
881
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
882
+ batcher.drawMain(camera, sceneUniforms.shadowEnabled)
883
+
884
+ // Editor grid (if connected + enabled): depth-tested against the scene content just drawn.
885
+ // It gets the skybox color even on a transparent host — that's the backdrop the scene declares,
886
+ // and the grid inks itself against it (dark lines on a light sky, light lines on a dark one).
887
+ editorLayer?.renderScenePass(clear)
888
+
889
+ // Overlays: fresh depth, blended over (View::BlendMode::TRANSLUCENT equivalent)
890
+ for (const overlayId of scene.overlays) {
891
+ const overlay = sceneRoots.get(overlayId)
892
+ if (!overlay) continue
893
+ gl.clear(gl.DEPTH_BUFFER_BIT)
894
+ renderScene(overlay, camera)
895
+ }
896
+
897
+ // Editor selection outline + transform gizmo — always on top.
898
+ editorLayer?.renderOverlayPass()
899
+ // Editor camera preview inset (a CameraPlace's view) — last, in its own rect.
900
+ editorLayer?.renderPreviewPass(root, clear)
901
+ return true
902
+ }
903
+
904
+ // A throwing frame must not kill the loop permanently — log each distinct error once and keep going.
905
+ const frameErrors = new Set<string>()
906
+ const loop = (): void => {
907
+ if (!sceneOpened) return
908
+ try {
909
+ renderFrame(performance.now())
910
+ } catch (e) {
911
+ const msg = e instanceof Error ? e.message : String(e)
912
+ if (!frameErrors.has(msg)) {
913
+ frameErrors.add(msg)
914
+ console.error('[viewer-lite] render frame failed:', e)
915
+ }
916
+ }
917
+ requestAnimationFrame(loop)
918
+ }
919
+
920
+ // ---- raycast (port of collider.cpp) ------------------------------------------------------------
921
+
922
+ const sceneIdOf = (id: number): number | undefined => core.sceneOf(id)?.id
923
+
924
+ let cameraRayOrigin = [0, 0, 0]
925
+ const getViewRay = (sceneId: number, screenX: number, screenY: number): { origin: number[], dir: number[] } => {
926
+ const camId = core.scenes.get(sceneId)?.camera
927
+ const w = camId !== undefined ? core.worldMatrix(camId) : identity()
928
+ const width = Math.max(1, canvas.width), height = Math.max(1, canvas.height)
929
+ const aspect = width / height
930
+ // Screen inputs are logical px; the backing store is logical × resFactor → scale up to match.
931
+ const sx = screenX * resFactor, sy = screenY * resFactor
932
+ const t = Math.tan((core.projectionOf(sceneId).fov * Math.PI) / 360)
933
+ const cx = ((sx / width) * 2 - 1) * t * aspect
934
+ const cy = (1 - (sy / height) * 2) * t
935
+ const dx = w[0] * cx + w[4] * cy - w[8]
936
+ const dy = w[1] * cx + w[5] * cy - w[9]
937
+ const dz = w[2] * cx + w[6] * cy - w[10]
938
+ const len = Math.hypot(dx, dy, dz) || 1
939
+ cameraRayOrigin = [w[12], w[13], w[14]]
940
+ return { origin: cameraRayOrigin, dir: [dx / len, dy / len, dz / len] }
941
+ }
942
+
943
+ const raycastColliders = (sceneId: number, origin: number[], dir: number[]): number => {
944
+ const colliders = sceneColliders.get(sceneId)
945
+ if (!colliders) return 0
946
+ let minDistance = Infinity
947
+ let target = 0
948
+ const localO: number[] = [0, 0, 0], localD: number[] = [0, 0, 0]
949
+ for (const [entityId, collider] of colliders) {
950
+ if (!core.entities.has(entityId) || !core.methods.isVisible(entityId)) continue
951
+ const inv = invertTRS(core.worldMatrixF(entityId))
952
+ transformPoint(localO, inv, origin[0], origin[1], origin[2], 1)
953
+ transformPoint(localD, inv, dir[0], dir[1], dir[2], 0)
954
+ const d = collider.type === 'box'
955
+ ? intersectRayBox(collider.center, collider.extent, localO, localD)
956
+ : intersectRaySphere(collider.center, collider.radius, localO, localD)
957
+ if (d > 0 && d < minDistance) { minDistance = d; target = entityId }
958
+ }
959
+ return target
960
+ }
961
+
962
+ const raycastRenderables = (sceneId: number, origin: number[], dir: number[]): number => {
963
+ const root = sceneRoots.get(sceneId)
964
+ if (!root) return 0
965
+ let minDistance = Infinity
966
+ let target = 0
967
+ const localO: number[] = [0, 0, 0], localD: number[] = [0, 0, 0]
968
+ root.updateMatrixWorld()
969
+ for (const mesh of collectMeshes(root) as any[]) {
970
+ const g = mesh.geometry
971
+ if (!g.bounds) g.computeBoundingBox()
972
+ if (!g.bounds || !isFinite(g.bounds.min.x)) continue
973
+ const inv = invertTRS(mesh.worldMatrix)
974
+ transformPoint(localO, inv, origin[0], origin[1], origin[2], 1)
975
+ transformPoint(localD, inv, dir[0], dir[1], dir[2], 0)
976
+ const c = [
977
+ (g.bounds.min.x + g.bounds.max.x) / 2, (g.bounds.min.y + g.bounds.max.y) / 2, (g.bounds.min.z + g.bounds.max.z) / 2,
978
+ ]
979
+ const e = [
980
+ (g.bounds.max.x - g.bounds.min.x) / 2, (g.bounds.max.y - g.bounds.min.y) / 2, (g.bounds.max.z - g.bounds.min.z) / 2,
981
+ ]
982
+ const d = intersectRayBox(c, e, localO, localD)
983
+ if (d > 0 && d < minDistance) {
984
+ minDistance = d
985
+ target = (mesh as any)._entId ?? 0
986
+ }
987
+ }
988
+ return target
989
+ }
990
+
991
+ // Pick order mirrors collider.cpp raycastView: overlay colliders (reverse) → scene colliders.
992
+ const raycastView = (screenX: number, screenY: number): number => {
993
+ const sceneId = core.activeSceneId()
994
+ const scene = core.scenes.get(sceneId)
995
+ if (!scene || scene.camera === undefined) return 0
996
+ const { origin, dir } = getViewRay(sceneId, screenX, screenY)
997
+ for (let i = scene.overlays.length - 1; i >= 0; i--) {
998
+ const hit = raycastColliders(scene.overlays[i], origin, dir)
999
+ if (hit !== 0) return hit
1000
+ }
1001
+ return raycastColliders(sceneId, origin, dir)
1002
+ }
1003
+
1004
+ // ---- particle systems (creator-particles' streams → dynamic drawables) -----------------------
1005
+ // One drawable per system under the emitter's transform: points / quads / ribbons share ONE
1006
+ // interleaved buffer (the stream as is, re-uploaded per frame); a mesh system is unpacked into
1007
+ // position / normal / uv for the lit / unlit programs (the frame's tangent quaternion → normal).
1008
+ // The index buffer is the mode's pattern for the capacity, rebuilt with the geometry version.
1009
+ const disposeParticles = (rec: GlParticles): void => {
1010
+ if (rec.mesh) rec.mesh.setParent(null as any)
1011
+ if (rec.buffer) gl.deleteBuffer(rec.buffer)
1012
+ rec.mesh = null; rec.geometry = null; rec.buffer = null
1013
+ }
1014
+ const particleProgram = (rec: GlParticles, mode: number): Program => {
1015
+ const mat = glMaterials.get(rec.materialId)
1016
+ if (mode === MODE.MESH) return (mat ?? makeMaterial('unlit')).program
1017
+ const wantQuad = mode !== MODE.POINTS
1018
+ if (mat && (mat.kind === (wantQuad ? 'particles-quad' : 'particles'))) return mat.program
1019
+ // a material of another family (custom, lit) cannot read the stream — draw with the default look
1020
+ return makeMaterial(wantQuad ? 'particles-quad' : 'particles').program
1021
+ }
1022
+ const buildParticleGeometry = (entityId: number, rec: GlParticles, frame: ParticleFrame): void => {
1023
+ disposeParticles(rec)
1024
+ rec.version = frame.geometryVersion
1025
+ rec.mode = frame.mode
1026
+ if (frame.capacity === 0) return
1027
+ const pattern = core.particleIndexPattern(entityId)
1028
+ if (pattern.length === 0) return
1029
+ let geometry: Geometry
1030
+ if (frame.mode === MODE.MESH) {
1031
+ const n = frame.capacity * frame.meshVertexCount
1032
+ geometry = new Geometry(gl, {
1033
+ position: { size: 3, data: new Float32Array(n * 3), usage: gl.DYNAMIC_DRAW },
1034
+ normal: { size: 3, data: new Float32Array(n * 3), usage: gl.DYNAMIC_DRAW },
1035
+ uv: { size: 2, data: new Float32Array(n * 2), usage: gl.DYNAMIC_DRAW },
1036
+ index: { data: pattern },
1037
+ } as any)
1038
+ } else {
1039
+ const buffer = gl.createBuffer()!
1040
+ rec.buffer = buffer
1041
+ const stride = frame.stride
1042
+ const count = frame.mode === MODE.POINTS ? frame.capacity : frame.mode === MODE.QUADS ? frame.capacity * 4 : (frame.capacity + 1) * 2
1043
+ const attr = (size: number, offset: number) => ({ size, type: gl.FLOAT, buffer, stride, offset, count, data: new Float32Array(0) })
1044
+ const attrs: Record<string, unknown> = { position: attr(3, 0), color: attr(4, 12), custom: attr(4, 28) }
1045
+ if (frame.mode !== MODE.POINTS) { attrs.aux = attr(3, 44); attrs.corner = attr(2, 56) }
1046
+ attrs.index = { data: Uint16Array.from(pattern) }
1047
+ geometry = new Geometry(gl, attrs as any)
1048
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
1049
+ gl.bufferData(gl.ARRAY_BUFFER, count * stride, gl.DYNAMIC_DRAW)
1050
+ ;(renderer.state as any).boundBuffer = buffer
1051
+ }
1052
+ const mesh = new Mesh(gl, { geometry, program: particleProgram(rec, frame.mode), mode: frame.mode === MODE.POINTS ? gl.POINTS : gl.TRIANGLES })
1053
+ mesh.frustumCulled = false
1054
+ mesh.matrixAutoUpdate = false
1055
+ ;(mesh as any)._entId = entityId
1056
+ ;(mesh as any)._particles = true
1057
+ mesh.setParent(glEnt(entityId).transform)
1058
+ rec.geometry = geometry
1059
+ rec.mesh = mesh
1060
+ }
1061
+ const uploadParticles = (entityId: number, frame: ParticleFrame): void => {
1062
+ let rec = glParticles.get(entityId)
1063
+ if (!rec) { rec = { materialId: 0, mesh: null, geometry: null, buffer: null, version: 0, mode: -1 }; glParticles.set(entityId, rec) }
1064
+ if (rec.version !== frame.geometryVersion || rec.mode !== frame.mode || !rec.mesh) { buildParticleGeometry(entityId, rec, frame); touchGraph() }
1065
+ if (!rec.mesh || !rec.geometry) return
1066
+ const show = frame.indexCount > 0
1067
+ if (rec.mesh.visible !== show) { rec.mesh.visible = show; touchGraph() }
1068
+ if (!show) return
1069
+ rec.mesh.renderOrder = frame.order
1070
+ if (frame.mode === MODE.MESH) {
1071
+ const n = frame.vertexCount
1072
+ const f = new Float32Array(frame.bytes.buffer, frame.bytes.byteOffset, n * 10)
1073
+ const pos = rec.geometry.attributes.position, nrm = rec.geometry.attributes.normal, uv = rec.geometry.attributes.uv
1074
+ const P = pos.data as Float32Array, N = nrm.data as Float32Array, U = uv.data as Float32Array
1075
+ for (let i = 0; i < n; i++) {
1076
+ const o = i * 10
1077
+ P[i * 3] = f[o]; P[i * 3 + 1] = f[o + 1]; P[i * 3 + 2] = f[o + 2]
1078
+ // the tangent frame's +z (the vertex normal): rotate (0, 0, 1) by the quaternion
1079
+ const x = f[o + 3], y = f[o + 4], z = f[o + 5], w = f[o + 6]
1080
+ N[i * 3] = 2 * (x * z + y * w); N[i * 3 + 1] = 2 * (y * z - x * w); N[i * 3 + 2] = 1 - 2 * (x * x + y * y)
1081
+ U[i * 2] = f[o + 8]; U[i * 2 + 1] = f[o + 9]
1082
+ }
1083
+ pos.needsUpdate = nrm.needsUpdate = uv.needsUpdate = true
1084
+ } else if (rec.buffer) {
1085
+ gl.bindBuffer(gl.ARRAY_BUFFER, rec.buffer)
1086
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, frame.bytes)
1087
+ ;(renderer.state as any).boundBuffer = rec.buffer
1088
+ }
1089
+ rec.geometry.drawRange.start = 0
1090
+ rec.geometry.drawRange.count = frame.indexCount
1091
+ }
1092
+
1093
+ // ---- SceneBackend hooks (core → GL reactions) -------------------------------------------------
1094
+
1095
+ const backend: SceneBackend = {
1096
+ allocMatrix: (id) => glEnt(id).transform.matrix as unknown as Mat4Like,
1097
+ localMatrixChanged: (id) => { glEnt(id).transform.worldMatrixNeedsUpdate = true; touchGraph() },
1098
+ parentChanged: (childId, parentId) => {
1099
+ touchGraph()
1100
+ const child = glEnt(childId).transform
1101
+ if (parentId !== 0) child.setParent(glEnt(parentId).transform)
1102
+ else {
1103
+ // Detach → back under the owning scene's root (if a member), else orphaned.
1104
+ const s = core.sceneOf(childId)
1105
+ child.setParent(((s && sceneRoots.get(s.id)) ?? null) as any)
1106
+ }
1107
+ },
1108
+ sceneCreated: (sceneId) => { sceneRoots.set(sceneId, new Transform()); sceneColliders.set(sceneId, new Map()) },
1109
+ entitySceneChanged: (id, sceneId, added) => {
1110
+ touchGraph()
1111
+ const root = sceneRoots.get(sceneId)
1112
+ if (!root) return
1113
+ const g = glEnt(id)
1114
+ if (added) {
1115
+ // Move any collider bookkeeping to this scene (scene.cpp move_elements; colliders are web-side).
1116
+ for (const [sid, cmap] of sceneColliders) {
1117
+ if (sid === sceneId) continue
1118
+ const col = cmap.get(id)
1119
+ if (col) { sceneColliders.get(sceneId)!.set(id, col); cmap.delete(id) }
1120
+ }
1121
+ if (core.ent(id).parent === 0) g.transform.setParent(root)
1122
+ } else {
1123
+ if (core.ent(id).parent === 0 && g.transform.parent === root) g.transform.setParent(null as any)
1124
+ }
1125
+ },
1126
+ visibleChanged: (id, visible) => { glEnt(id).transform.visible = visible; touchGraph() },
1127
+ materialCreated: (id, rec, systemId) => { glMaterials.set(id, buildProgramFor(rec.shader, systemId)) },
1128
+ uniformChanged: (materialId, uniform, u) => applyUniform(materialId, uniform, u),
1129
+ meshSet: (entityId, materialId, mesh, arrays) => { buildMesh(entityId, materialId, arrays, mesh.kind); touchGraph() },
1130
+ glbAttach: (systemId, rootId, entIds) => attachGlb(systemId, rootId, entIds).then(touchGraph),
1131
+ // Animator-sampled blendshape weights → the node's weight array from buildMorphState (glb.ts)
1132
+ morphWeights: (entityId, weights) => {
1133
+ const w = (glEnt(entityId).transform as any).weights as Float32Array | undefined
1134
+ if (w) w.set(weights.subarray(0, Math.min(w.length, weights.length)))
1135
+ },
1136
+ entityDestroyed: (id) => {
1137
+ touchGraph()
1138
+ const g = glEnts.get(id)
1139
+ if (g) { g.transform.setParent(null as any); glEnts.delete(id) }
1140
+ for (const cmap of sceneColliders.values()) cmap.delete(id)
1141
+ },
1142
+ entityCloned: (sourceId, cloneId) => { cloneDrawables(sourceId, cloneId); touchGraph() },
1143
+ subtreeCloned: (_sourceRootId, _cloneRootId, map) => rebindClonedSkins(map),
1144
+ particleSystemCreated: (entityId, materialId) => {
1145
+ const rec = glParticles.get(entityId)
1146
+ if (rec) { disposeParticles(rec); rec.materialId = materialId; rec.version = 0 }
1147
+ else glParticles.set(entityId, { materialId, mesh: null, geometry: null, buffer: null, version: 0, mode: -1 })
1148
+ },
1149
+ particlesFrame: (entityId, frame) => uploadParticles(entityId, frame),
1150
+ particleSystemDestroyed: (entityId) => {
1151
+ const rec = glParticles.get(entityId)
1152
+ if (rec) { disposeParticles(rec); glParticles.delete(entityId) }
1153
+ touchGraph()
1154
+ },
1155
+ }
1156
+
1157
+ // The animation runtime (creator-anim wasm) must be resident before the first animator call.
1158
+ // The animation + physics runtimes (creator-anim / creator-physics wasm, base64-embedded in the bundle):
1159
+ // resident before the scene core exists, so the SDK's first call finds them ready.
1160
+ await Promise.all([loadCreatorAnim(), loadCreatorPhysics(), loadCreatorParticles()])
1161
+ const core = createSceneCore(backend, {
1162
+ getBuffer: getFetchedBuffer,
1163
+ getSourceName: getLocalName,
1164
+ width: canvas.width || 1,
1165
+ height: canvas.height || 1,
1166
+ fov: FOV,
1167
+ })
1168
+
1169
+ // ---- the bridge: shared scene-graph (core.methods) + GL-only / capability methods --------------
1170
+
1171
+ // PNG/JPG/WebP… via the browser decoder → sRGB8_A8 texture.
1172
+ const createBitmapTexture = (blob: Blob, onComplete: (id: number, width: number, height: number) => void, onReject: () => void): void => {
1173
+ createImageBitmap(blob)
1174
+ .then((bitmap) => {
1175
+ const texture = new Texture(gl, {
1176
+ image: bitmap as any,
1177
+ internalFormat: gl.SRGB8_ALPHA8,
1178
+ format: gl.RGBA,
1179
+ generateMipmaps: false,
1180
+ flipY: false,
1181
+ premultiplyAlpha: false,
1182
+ })
1183
+ const id = nextTexture++
1184
+ glTextures.set(id, texture)
1185
+ onComplete(id, bitmap.width, bitmap.height)
1186
+ })
1187
+ .catch(onReject)
1188
+ }
1189
+ const bridge = {
1190
+ // the contract's completeness layer (generated): every required method a stub UNDER ours
1191
+ ...unsupported,
1192
+ ...basePropsLite,
1193
+ ...core.methods,
1194
+
1195
+ // --- the OWNED creators (docs/owned.md): the SDK keeps the Handle and puts `_h.id` on the wire. The
1196
+ // lite engine keeps no owned records (a drop frees nothing), so it is a plain `{ id }` over the
1197
+ // core's id — the wrap creator-gl's generated bridge does. createTexture* below return one too ---
1198
+ createEntity: (): Handle => ({ id: core.methods.createEntity() }),
1199
+ cloneEntity: (entityId: number): Handle => ({ id: core.methods.cloneEntity(entityId) }),
1200
+ createMaterial: (systemId: number): Handle => ({ id: core.methods.createMaterial(systemId) }),
1201
+ // Never null: a name this engine has no program for draws unlit (buildProgramFor), as it always did.
1202
+ builtinMaterial: (name: string): Handle | null => ({ id: core.methods.createMaterialNamed(name) }),
1203
+ createGlb(systemId: number, onComplete: (root: Handle | null) => void, onReject: () => void): void {
1204
+ core.methods.createGlb(systemId, (root) => onComplete({ id: root }), onReject)
1205
+ },
1206
+
1207
+ // --- scene lifecycle (core sets active scene; web adds the render loop + canvas display) ---
1208
+ openScene(sceneId: number): void {
1209
+ const wasOpen = sceneOpened
1210
+ core.methods.openScene(sceneId)
1211
+ sceneOpened = core.scenes.has(sceneId)
1212
+ canvas.style.display = 'block'
1213
+ if (!wasOpen && sceneOpened) requestAnimationFrame(loop)
1214
+ },
1215
+ closeScene(): void {
1216
+ canvas.style.display = 'none'
1217
+ sceneOpened = false
1218
+ core.methods.closeScene()
1219
+ },
1220
+ launchAR(): void { throw new Error('launchAR is not implemented') },
1221
+ stopAR(): void { throw new Error('stopAR is not implemented') },
1222
+ launchVR(_sceneId: number, _onComplete: () => void, onReject: (err?: unknown) => void): void {
1223
+ onReject(new Error('VR is not supported on this device'))
1224
+ },
1225
+ stopVR(): void {},
1226
+ async warmRender(_sceneId: number, onComplete: () => void): Promise<void> { onComplete() },
1227
+
1228
+ // --- camera (dynamic canvas viewport; web ignores the projection callback like the Filament host) ---
1229
+ attachCameraToAR(sceneId: number, cameraEntityId: number, _onProjectionChange: unknown): void {
1230
+ const s = core.scenes.get(sceneId)
1231
+ if (!s) return
1232
+ s.camera = cameraEntityId
1233
+ for (const overlayId of s.overlays) { const o = core.scenes.get(overlayId); if (o) o.camera = cameraEntityId }
1234
+ },
1235
+ // core.methods carries setCameraProjection (per-scene state); this override only re-derives the
1236
+ // HORIZONTAL fov against the live canvas aspect instead of the core's fixed width/height.
1237
+ getCameraFov(sceneId: number, fovType: number): number {
1238
+ const fov = core.projectionOf(sceneId).fov
1239
+ if (fovType !== 1) return fov
1240
+ const vert = (fov * Math.PI) / 180
1241
+ const aspect = canvas.width / Math.max(1, canvas.height)
1242
+ return (2 * Math.atan(Math.tan(vert / 2) * aspect) * 180) / Math.PI
1243
+ },
1244
+ getCameraViewDirection(sceneId: number, screenX: number, screenY: number, out: Float32Array): void {
1245
+ if (!core.scenes.has(sceneId)) { out[0] = 0; out[1] = 0; out[2] = -1; return }
1246
+ const { dir } = getViewRay(sceneId, screenX, screenY)
1247
+ out[0] = dir[0]; out[1] = dir[1]; out[2] = dir[2]
1248
+ },
1249
+ // Logical (CSS-px) viewport — device.pixelRatio carries the density separately. renderer.width/
1250
+ // height hold the logical size passed to setSize (backing is that × dpr).
1251
+ getDisplaySize: (): Float32Array => Float32Array.of(renderer.width || canvas.width, renderer.height || canvas.height),
1252
+
1253
+ // --- textures (GL) ---
1254
+ createTexture(systemId: number, onComplete: (texture: Handle, width: number, height: number) => void, onReject: () => void): void {
1255
+ const done = (id: number, width: number, height: number) => onComplete({ id }, width, height)
1256
+ const blob = getBlob(systemId)
1257
+ // KTX2 (Basis Universal): the browser can't decode it — route through the same lazy basis
1258
+ // worker GLBs use (ASTC/BPTC/S3TC, else an rgba32 bitmap). sRGB vs linear from the file's DFD.
1259
+ blob.slice(0, 12).arrayBuffer().then(async (head) => {
1260
+ const decoders = await import('./decoders')
1261
+ if (!decoders.isKtx2Bytes(new Uint8Array(head))) return false
1262
+ const buf = await blob.arrayBuffer()
1263
+ const parsed = await decoders.transcodeKtx2(gl, buf)
1264
+ const srgb = decoders.ktx2IsSrgb(buf)
1265
+ let texture: Texture
1266
+ if (parsed.isCompressedTexture) {
1267
+ texture = new Texture(gl, {
1268
+ image: parsed,
1269
+ internalFormat: srgb && parsed.srgbInternalFormat ? parsed.srgbInternalFormat : parsed.internalFormat,
1270
+ generateMipmaps: false,
1271
+ minFilter: parsed.length > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
1272
+ flipY: false,
1273
+ })
1274
+ } else {
1275
+ texture = new Texture(gl, {
1276
+ image: parsed,
1277
+ internalFormat: srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8,
1278
+ format: gl.RGBA,
1279
+ generateMipmaps: true,
1280
+ flipY: false,
1281
+ premultiplyAlpha: false,
1282
+ })
1283
+ }
1284
+ const id = nextTexture++
1285
+ glTextures.set(id, texture)
1286
+ done(id, parsed.width, parsed.height)
1287
+ return true
1288
+ }).then((handled) => { if (!handled) createBitmapTexture(blob, done, onReject) }, onReject)
1289
+ },
1290
+ // Raw-pixel textures (Texture.fromPixels / a terrain's control map). OGL textures re-upload the
1291
+ // whole image, so a CPU copy per texture takes the sub-rectangle updates.
1292
+ createTexturePixels(width: number, height: number, channels: number, data: Uint8Array, srgb: boolean): Handle {
1293
+ if (width <= 0 || height <= 0 || data.length < width * height * channels) return { id: 0xFFFFFFFF }
1294
+ const rgba = new Uint8Array(width * height * 4)
1295
+ for (let i = 0, n = width * height; i < n; i++) {
1296
+ const s = i * channels, d = i * 4
1297
+ rgba[d] = data[s]
1298
+ rgba[d + 1] = channels >= 2 ? data[s + 1] : data[s]
1299
+ rgba[d + 2] = channels >= 3 ? data[s + 2] : data[s]
1300
+ rgba[d + 3] = channels >= 4 ? data[s + 3] : 255
1301
+ }
1302
+ const texture = new Texture(gl, {
1303
+ image: rgba as any, width, height,
1304
+ internalFormat: srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8, format: gl.RGBA,
1305
+ generateMipmaps: false, flipY: false, premultiplyAlpha: false,
1306
+ minFilter: gl.LINEAR, magFilter: gl.LINEAR,
1307
+ })
1308
+ const id = nextTexture++
1309
+ glTextures.set(id, texture)
1310
+ pixelTextures.set(id, { rgba, width, height, channels })
1311
+ return { id }
1312
+ },
1313
+ updateTexturePixels(textureId: number, x: number, y: number, width: number, height: number, data: Uint8Array): void {
1314
+ const rec = pixelTextures.get(textureId)
1315
+ const texture = glTextures.get(textureId)
1316
+ if (!rec || !texture) return
1317
+ const ch = rec.channels
1318
+ for (let row = 0; row < height; row++) {
1319
+ const ty = y + row
1320
+ if (ty < 0 || ty >= rec.height) continue
1321
+ for (let col = 0; col < width; col++) {
1322
+ const tx = x + col
1323
+ if (tx < 0 || tx >= rec.width) continue
1324
+ const s = (row * width + col) * ch, d = (ty * rec.width + tx) * 4
1325
+ rec.rgba[d] = data[s]
1326
+ rec.rgba[d + 1] = ch >= 2 ? data[s + 1] : data[s]
1327
+ rec.rgba[d + 2] = ch >= 3 ? data[s + 2] : data[s]
1328
+ rec.rgba[d + 3] = ch >= 4 ? data[s + 3] : 255
1329
+ }
1330
+ }
1331
+ texture.image = rec.rgba as any
1332
+ ;(texture as any).needsUpdate = true
1333
+ },
1334
+ createTextureFromCanvas(surfaceId: number): number {
1335
+ const surf = options.canvasHost?.readSurface(surfaceId)
1336
+ if (!surf) return 0
1337
+ const texture = new Texture(gl, {
1338
+ image: new Uint8Array(surf.data.buffer.slice(0)) as any,
1339
+ width: surf.width,
1340
+ height: surf.height,
1341
+ internalFormat: gl.SRGB8_ALPHA8,
1342
+ format: gl.RGBA,
1343
+ generateMipmaps: false,
1344
+ flipY: false,
1345
+ })
1346
+ const id = nextTexture++
1347
+ glTextures.set(id, texture)
1348
+ return id
1349
+ },
1350
+ updateTextureFromCanvas(texId: number, surfaceId: number): void {
1351
+ const surf = options.canvasHost?.readSurface(surfaceId)
1352
+ const texture = glTextures.get(texId)
1353
+ if (!surf || !texture) return
1354
+ texture.image = new Uint8Array(surf.data.buffer.slice(0)) as any
1355
+ texture.width = surf.width
1356
+ texture.height = surf.height
1357
+ ;(texture as any).needsUpdate = true
1358
+ },
1359
+ createMediaPlayerTexture(id: number): number {
1360
+ const video = getPlayer(id) as HTMLVideoElement
1361
+ const texture = new Texture(gl, { generateMipmaps: false, flipY: false, premultiplyAlpha: false })
1362
+ activeVideoTextures.push({ video, texture, lastTime: -1 })
1363
+ const texId = nextTexture++
1364
+ glTextures.set(texId, texture)
1365
+ return texId
1366
+ },
1367
+
1368
+ // --- materials that need GL specifics on top of the core's semantic recording ---
1369
+ setUniformTexture(materialId: number, uniform: string, systemId: number, wrapS: number, wrapT: number): void {
1370
+ core.methods.setUniformTexture(materialId, uniform, systemId) // record semantic + bind by id
1371
+ const rec = glMaterials.get(materialId)
1372
+ const texture = glTextures.get(systemId)
1373
+ if (!rec || !texture) return
1374
+ const WRAP = [gl.CLAMP_TO_EDGE, gl.REPEAT, gl.MIRRORED_REPEAT]
1375
+ texture.wrapS = WRAP[wrapS] ?? gl.REPEAT
1376
+ texture.wrapT = WRAP[wrapT] ?? gl.REPEAT
1377
+ ;(texture as any).needsUpdate = true
1378
+ materialUniform(rec, uniform).value = texture
1379
+ },
1380
+ setMaterial(entityId: number, materialId: number, index: number): void {
1381
+ core.methods.setMaterial(entityId, materialId, index)
1382
+ const rec = glMaterials.get(materialId)
1383
+ const mesh = glEnt(entityId).drawables[index]
1384
+ if (!rec || !mesh) return
1385
+ mesh.program = rec.program
1386
+ wireReceiveShadows(mesh, rec)
1387
+ touchGraph()
1388
+ },
1389
+ getMaterial(entityId: number, index: number): number {
1390
+ const mesh = glEnt(entityId).drawables[index] as any
1391
+ if (!mesh) return 0
1392
+ const id = core.nextMaterialId()
1393
+ glMaterials.set(id, { kind: 'gltf', program: mesh.program })
1394
+ return id
1395
+ },
1396
+
1397
+ // --- culling / shadows (per-drawable GL flags; core also records cast/receive semantics) ---
1398
+ setCulling(entityId: number, culling: boolean): void {
1399
+ for (const d of glEnt(entityId).drawables) d.frustumCulled = culling
1400
+ touchGraph()
1401
+ },
1402
+ setGlbCulling(entityId: number, culling: boolean): void {
1403
+ const walk = (id: number): void => {
1404
+ for (const d of glEnt(id).drawables) d.frustumCulled = culling
1405
+ for (const c of core.ent(id).children) walk(c)
1406
+ }
1407
+ walk(entityId)
1408
+ touchGraph()
1409
+ },
1410
+ // draw order / depth state (Mesh.renderPriority, Material.depthTest / depthWrite): the OGL
1411
+ // renderer sorts by renderOrder inside a pass; the depth flags live on the program
1412
+ setRenderPriority(entityId: number, priority: number): void {
1413
+ for (const d of glEnt(entityId).drawables as any[]) d.renderOrder = priority
1414
+ touchGraph()
1415
+ },
1416
+ setMaterialDepthTest(materialId: number, enable: boolean): void {
1417
+ const rec = glMaterials.get(materialId)
1418
+ if (rec) (rec.program as any).depthTest = enable
1419
+ },
1420
+ setMaterialDepthWrite(materialId: number, enable: boolean): void {
1421
+ const rec = glMaterials.get(materialId)
1422
+ if (rec) (rec.program as any).depthWrite = enable
1423
+ },
1424
+ setMaterialCulling(materialId: number, mode: number): void {
1425
+ const rec = glMaterials.get(materialId)
1426
+ if (rec) (rec.program as any).cullFace = mode === 0 ? false : mode === 1 ? gl.FRONT : gl.BACK
1427
+ },
1428
+ setCastShadows(entityId: number, value: boolean): void {
1429
+ core.methods.setCastShadows(entityId, value)
1430
+ for (const d of glEnt(entityId).drawables as any[]) d._castShadows = value
1431
+ touchGraph()
1432
+ },
1433
+ setReceiveShadows(entityId: number, value: boolean): void {
1434
+ core.methods.setReceiveShadows(entityId, value)
1435
+ for (const d of glEnt(entityId).drawables as any[]) d._receiveShadows = value
1436
+ touchGraph()
1437
+ },
1438
+ setGlbShadows(entityId: number, cast: boolean, receive: boolean): void {
1439
+ core.methods.setGlbShadows(entityId, cast, receive)
1440
+ const walk = (id: number): void => {
1441
+ for (const d of glEnt(id).drawables as any[]) { d._castShadows = cast; d._receiveShadows = receive }
1442
+ for (const c of core.ent(id).children) walk(c)
1443
+ }
1444
+ walk(entityId)
1445
+ touchGraph()
1446
+ },
1447
+ setCastShadowsGlobal(value: boolean): void { castShadowsGlobal = value },
1448
+ setReceiveShadowsGlobal(value: boolean): void { receiveShadowsGlobal = value },
1449
+
1450
+ // --- colliders (CPU raycast picking; web-side, from mesh bounds) ---
1451
+ setColliderFromMesh(entityId: number, meshEntityId: number, form: number): void {
1452
+ if (form !== 0) return
1453
+ const mesh = glEnt(meshEntityId).drawables[0] as any
1454
+ if (!mesh) return
1455
+ const g = mesh.geometry
1456
+ if (!g.bounds) g.computeBoundingBox()
1457
+ if (!g.bounds) return
1458
+ const sid = sceneIdOf(entityId) ?? core.activeSceneId()
1459
+ sceneColliders.get(sid)?.set(entityId, {
1460
+ type: 'box',
1461
+ center: [(g.bounds.min.x + g.bounds.max.x) / 2, (g.bounds.min.y + g.bounds.max.y) / 2, (g.bounds.min.z + g.bounds.max.z) / 2],
1462
+ extent: [(g.bounds.max.x - g.bounds.min.x) / 2, (g.bounds.max.y - g.bounds.min.y) / 2, (g.bounds.max.z - g.bounds.min.z) / 2],
1463
+ })
1464
+ },
1465
+ setColliderBox(entityId: number, cx: number, cy: number, cz: number, sx: number, sy: number, sz: number): void {
1466
+ const sid = sceneIdOf(entityId) ?? core.activeSceneId()
1467
+ sceneColliders.get(sid)?.set(entityId, { type: 'box', center: [cx, cy, cz], extent: [sx / 2, sy / 2, sz / 2] })
1468
+ },
1469
+ setColliderSphere(entityId: number, cx: number, cy: number, cz: number, radius: number): void {
1470
+ const sid = sceneIdOf(entityId) ?? core.activeSceneId()
1471
+ sceneColliders.get(sid)?.set(entityId, { type: 'sphere', center: [cx, cy, cz], radius })
1472
+ },
1473
+
1474
+ // (physics: the core's real simulation — creator-physics' wasm, loaded above with the animation runtime)
1475
+
1476
+ // --- AR (not supported in lite; the simulator overrides these with its own webcam AR methods) ---
1477
+ createARController(): void { throw new Error('createARController is not implemented') },
1478
+ createRootAnchor(): number { throw new Error('createRootAnchor is not implemented') },
1479
+ createAnchor(): number { throw new Error('createAnchor is not implemented') },
1480
+ createARPlane: (): number => 0,
1481
+ updateProjectionMatrixAR(): void {},
1482
+ _setARController(): void {},
1483
+
1484
+ // (particles + noise: the core's creator-particles wasm, drawn through particlesFrame above)
1485
+
1486
+ // --- capture ---
1487
+ /** Draw one frame synchronously — hosts call this immediately before reading the canvas back
1488
+ * (the simulator's "Save image"), since without preserveDrawingBuffer the pixels only survive
1489
+ * inside the task that drew them. captureImage below does the same thing for the app-facing ABI. */
1490
+ _renderNow: () => renderFrame(performance.now()),
1491
+ async captureImage(_sceneId: number, onComplete: (bufferId: number, name: string, size: number) => void, onReject: () => void): Promise<void> {
1492
+ try {
1493
+ renderFrame(performance.now()) // make sure the framebuffer holds a fresh frame
1494
+ const temp = new OffscreenCanvas(canvas.width, canvas.height)
1495
+ const ctx = temp.getContext('2d')!
1496
+ ctx.drawImage(canvas, 0, 0)
1497
+ const blob = await temp.convertToBlob({ type: 'image/png' })
1498
+ const buffer = await blob.arrayBuffer()
1499
+ onComplete(putBuffer(buffer), 'image.png', blob.size)
1500
+ } catch {
1501
+ onReject()
1502
+ }
1503
+ },
1504
+
1505
+ // --- host plumbing (same seam as viewer/createViewer.ts) ---
1506
+ raycastView,
1507
+ _raycastView: raycastView,
1508
+ /** VR-style pick (native raycastPick): colliders first, then renderable AABBs as fallback. */
1509
+ raycastPick(screenX: number, screenY: number): number {
1510
+ const sceneId = core.activeSceneId()
1511
+ const scene = core.scenes.get(sceneId)
1512
+ if (!scene || scene.camera === undefined) return 0
1513
+ const hit = raycastView(screenX, screenY)
1514
+ if (hit !== 0) return hit
1515
+ const { origin, dir } = getViewRay(sceneId, screenX, screenY)
1516
+ return raycastRenderables(sceneId, origin, dir)
1517
+ },
1518
+ computeBoundingBox(entityId: number): Float32Array {
1519
+ // Subtree AABB in the entity's local space (mirrors collider.cpp computeBoundingBox).
1520
+ const out = Float32Array.of(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity)
1521
+ const corner: number[] = [0, 0, 0]
1522
+ const walk = (id: number, mat: Mat4Like | null): void => {
1523
+ for (const mesh of glEnt(id).drawables as any[]) {
1524
+ const g = mesh.geometry
1525
+ if (!g.bounds) g.computeBoundingBox()
1526
+ if (!g.bounds || !isFinite(g.bounds.min.x)) continue
1527
+ for (let i = 0; i < 8; i++) {
1528
+ const x = i & 1 ? g.bounds.max.x : g.bounds.min.x
1529
+ const y = i & 2 ? g.bounds.max.y : g.bounds.min.y
1530
+ const z = i & 4 ? g.bounds.max.z : g.bounds.min.z
1531
+ if (mat) transformPoint(corner, mat, x, y, z, 1)
1532
+ else { corner[0] = x; corner[1] = y; corner[2] = z }
1533
+ for (let k = 0; k < 3; k++) {
1534
+ if (corner[k] < out[k]) out[k] = corner[k]
1535
+ if (corner[k] > out[k + 3]) out[k + 3] = corner[k]
1536
+ }
1537
+ }
1538
+ }
1539
+ for (const c of core.ent(id).children) {
1540
+ const childLocal = glEnt(c).transform.matrix as unknown as Mat4Like
1541
+ const childMat = mat ? multiply(new Float32Array(16), mat, childLocal) : Float32Array.from(childLocal as ArrayLike<number>)
1542
+ walk(c, childMat)
1543
+ }
1544
+ }
1545
+ walk(entityId, null)
1546
+ if (!isFinite(out[0])) out.fill(0)
1547
+ return out
1548
+ },
1549
+ stopAllPlayers(): void { removePlayers() },
1550
+ /** The agreement seam: serialize the LIVE core state to the exact `scene3d` JSON `lecodes render`
1551
+ * emits — so a scene running in the browser can be dumped and cross-checked against the pixels
1552
+ * (and the headless output). Colliders/physics/texture names are GL-side here, so they don't
1553
+ * appear; the scene graph, materials, camera and screen-space `frame` do. */
1554
+ _dumpScene3d: (): unknown => serializeScene3d(core),
1555
+ /** EDITOR host API (not part of the bridge ABI / parity surface): wireframe display mode —
1556
+ * 'overlay' draws edge lines over the shaded pass, 'only' replaces it. See the wireframe
1557
+ * section above. The scene editor calls this on its lite viewport; apps never see it. */
1558
+ _setWireframeMode(mode: 'off' | 'overlay' | 'only'): void {
1559
+ wireframeMode = mode === 'overlay' || mode === 'only' ? mode : 'off'
1560
+ // dark lines read on shaded surfaces; light lines read on the bare skybox
1561
+ wireColor.value = wireframeMode === 'only' ? [ 0.62, 0.66, 0.72 ] : [ 0.05, 0.06, 0.08 ]
1562
+ updateWireframe()
1563
+ touchGraph()
1564
+ },
1565
+ /** HOST API (not part of the bridge ABI / parity surface): instanced batching on/off — off
1566
+ * draws every mesh through the per-object path, the reference the batched picture must
1567
+ * match (render-check compares the two). Diagnostics via `_batchStats`. */
1568
+ _setBatching(enabled: boolean): void {
1569
+ for (const b of batchers.values()) b.setEnabled(enabled)
1570
+ batchingEnabled = enabled
1571
+ touchGraph()
1572
+ },
1573
+ _batchStats(): { batches: number, instances: number, visible: number, singles: number } {
1574
+ const root = sceneRoots.get(core.activeSceneId())
1575
+ return root ? batcherFor(root).stats() : { batches: 0, instances: 0, visible: 0, singles: 0 }
1576
+ },
1577
+ /** EDITOR host API (not part of the bridge ABI / parity surface): connect the engine-side
1578
+ * editor layer — grid, selection outline, viewport picking, and the transform gizmo (see
1579
+ * editor.ts). Returns { setGrid, setSelection, setGizmoMode, setGizmoSpace, setColliders, setGizmos };
1580
+ * the handlers
1581
+ * receive picks (entity ids) and completed gizmo drags. The scene editor is the only caller. */
1582
+ _editorConnect(handlers: EditorHandlers) {
1583
+ editorLayer ??= createEditorLayer({
1584
+ gl, renderer, canvas, camera, core, sceneRoots,
1585
+ drawablesOf: (id) => glEnt(id).drawables,
1586
+ collectMeshes, wireGeometryFor, getViewRay, renderScene,
1587
+ })
1588
+ editorLayer.setHandlers(handlers)
1589
+ return editorLayer.api
1590
+ },
1591
+ async createEngine(): Promise<void> {},
1592
+ _fetch,
1593
+ _getPlayer: getPlayer,
1594
+ _createGLTexture(source: HTMLCanvasElement | OffscreenCanvas) {
1595
+ const texture = new Texture(gl, {
1596
+ image: source as any,
1597
+ generateMipmaps: false,
1598
+ flipY: false,
1599
+ premultiplyAlpha: false,
1600
+ })
1601
+ const textureId = nextTexture++
1602
+ glTextures.set(textureId, texture)
1603
+ texture.update()
1604
+ return { gl, glTexture: texture.texture, textureId }
1605
+ },
1606
+ _onResize(width: number, height: number, factor = 1): void {
1607
+ resFactor = factor
1608
+ renderer.dpr = factor // OGL setSize then sizes the backing (× dpr) and pins the CSS box to logical px
1609
+ renderer.setSize(width, height)
1610
+ lastAspect = 0 // force projection refresh
1611
+ },
1612
+ _dispose(): void {
1613
+ editorLayer?.dispose()
1614
+ editorLayer = null
1615
+ for (const b of batchers.values()) b.dispose()
1616
+ batchers.clear()
1617
+ this.closeScene()
1618
+ this.stopAllPlayers()
1619
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
1620
+ },
1621
+ }
1622
+
1623
+ bridge satisfies typeof _creator // the whole contract, our signatures checked against it
1624
+ return bridge
1625
+ }
1626
+
1627
+ export type CreatorLite = Awaited<ReturnType<typeof createViewerLite>>