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,593 @@
1
+ // GLB loading for the lite engine: OGL's GLTFLoader parses the container/geometry/skins/animations,
2
+ // then we replace its placeholder programs with our Filament-calibrated PBR programs (shaders.ts)
3
+ // built from the glTF metallic-roughness material data. The caller (createViewerLite) maps the
4
+ // returned node graph onto bridge entities.
5
+
6
+ import { GLTFLoader, GLTFSkin, Mesh, Program, Quat, Texture, Transform } from 'ogl'
7
+ import { gltfFragment, gltfVertex } from './shaders'
8
+
9
+ export type GlbClipChannel = {
10
+ node: Transform
11
+ /** `weights` writes the node's morph-weight array (set up by buildMorphState), not a TRS field. */
12
+ transform: 'position' | 'quaternion' | 'scale' | 'weights'
13
+ interpolation: 'STEP' | 'LINEAR' | 'CUBICSPLINE'
14
+ times: ArrayLike<number>
15
+ values: ArrayLike<number>
16
+ }
17
+
18
+ export type GlbClip = {
19
+ name: string
20
+ duration: number
21
+ animation: { loop: boolean, duration: number, startTime: number, data: GlbClipChannel[] }
22
+ /** Unique nodes this clip writes TRS to — recomposed into matrices after each update. */
23
+ nodes: Transform[]
24
+ }
25
+
26
+ export type GlbResult = {
27
+ roots: Transform[]
28
+ nodes: Transform[]
29
+ animations: GlbClip[]
30
+ }
31
+
32
+ export type SceneUniforms = Record<string, { value: unknown }>
33
+
34
+ // Compression extensions the lite loader has no decoder for. A GLB *requiring* one would render
35
+ // garbage/nothing — loadGlbLite throws instead, and the backend degrades that model to its
36
+ // structure entities (hierarchy + animation clocks, no drawables). Draco and KTX2/basisu are NOT
37
+ // here anymore: their decoders lazy-load on first use (src/gl/decoders).
38
+ const UNSUPPORTED_REQUIRED = [ 'EXT_meshopt_compression' ]
39
+
40
+ /** OGL's GLTFLoader auto-instances any mesh referenced by more than one node — and then NULLS every
41
+ * non-first referencing node (`parseNodes` → "Remove unused nodes just providing an instance
42
+ * transform"). That crashes `parseAnimations` on channels targeting those nodes ("Cannot read
43
+ * properties of null"), silently bakes per-node animation into static instance matrices, and
44
+ * splices `gltf.nodes` out of glTF-index alignment — which the backend relies on to bind loader
45
+ * transforms onto the core's index-aligned entities. We want plain per-node drawables: give every
46
+ * extra reference its own mesh entry (a shallow copy — primitives/accessors stay shared in the
47
+ * JSON; only the GL-side Geometry duplicates). Exported for tests. */
48
+ export const dedupeSharedMeshes = (desc: { nodes?: { mesh?: number }[], meshes?: Record<string, unknown>[] }): void => {
49
+ const meshes = desc.meshes
50
+ if (!meshes) return
51
+ const seen = new Set<number>()
52
+ for (const n of desc.nodes ?? []) {
53
+ if (n.mesh === undefined || !meshes[n.mesh]) continue
54
+ if (seen.has(n.mesh)) {
55
+ meshes.push({ ...meshes[n.mesh] })
56
+ n.mesh = meshes.length - 1
57
+ } else {
58
+ seen.add(n.mesh)
59
+ }
60
+ }
61
+ }
62
+
63
+ export type MorphChannelRef = {
64
+ anim: number
65
+ node: number
66
+ input: number
67
+ output: number
68
+ interpolation: 'STEP' | 'LINEAR' | 'CUBICSPLINE'
69
+ }
70
+
71
+ /** OGL has no morph-target support: a `weights` animation channel maps through its TRANSFORMS
72
+ * table to `undefined`, and GLTFAnimation.update would crash on `node[undefined].lerp` EVERY
73
+ * frame — killing the whole render loop (city.scene.ts: snake.glb carries 4 such channels).
74
+ * Remove them from the desc BEFORE parse and return refs so loadGlbLite can re-attach them to
75
+ * OUR sampler afterwards — morph support (buildMorphState + the HAS_MORPH programs) is ours,
76
+ * not OGL's. Animations left with zero channels are KEPT so clip indices stay glTF-aligned;
77
+ * a morph-only clip still becomes a working GlbClip. Exported for tests. */
78
+ export const extractMorphAnimationChannels = (
79
+ desc: { animations?: { channels: { sampler?: number, target?: { node?: number, path?: string } }[], samplers?: { input: number, output: number, interpolation?: string }[] }[] },
80
+ ): MorphChannelRef[] => {
81
+ const out: MorphChannelRef[] = []
82
+ ;(desc.animations ?? []).forEach((anim, ai) => {
83
+ const kept: typeof anim.channels = []
84
+ for (const c of anim.channels ?? []) {
85
+ const sampler = c.sampler !== undefined ? anim.samplers?.[c.sampler] : undefined
86
+ if (c.target?.path === 'weights' && c.target.node !== undefined && sampler) {
87
+ out.push({
88
+ anim: ai, node: c.target.node,
89
+ input: sampler.input, output: sampler.output,
90
+ interpolation: (sampler.interpolation ?? 'LINEAR') as MorphChannelRef['interpolation'],
91
+ })
92
+ } else {
93
+ kept.push(c)
94
+ }
95
+ }
96
+ anim.channels = kept
97
+ })
98
+ return out
99
+ }
100
+
101
+ const COMPONENT_CTORS: Record<number, new (buf: ArrayBuffer, off: number, len: number) => ArrayLike<number>> = {
102
+ 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array,
103
+ }
104
+ const NORMALIZE_SCALE: Record<number, number> = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 }
105
+ const TYPE_COMPONENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }
106
+
107
+ /** Read a glTF accessor into a Float32Array (denormalized) straight from the desc + raw GLB
108
+ * binary. Unlike OGL's parseAccessor this handles SPARSE accessors and accessors with no
109
+ * bufferView (both common for morph targets — only the displaced vertices are stored).
110
+ * Exported for tests. */
111
+ export const readAccessorF32 = (desc: any, buffers: (ArrayBuffer | undefined)[], index: number): Float32Array => {
112
+ const acc = desc?.accessors?.[index]
113
+ const comps = TYPE_COMPONENTS[acc?.type as string] ?? 1
114
+ const out = new Float32Array((acc?.count ?? 0) * comps)
115
+ if (!acc || out.length === 0) return out
116
+
117
+ const readRaw = (bufferViewIndex: number, byteOffset: number, componentType: number, count: number, n: number, byteStride: number): Float32Array => {
118
+ const dst = new Float32Array(count * n)
119
+ const bv = desc.bufferViews?.[bufferViewIndex]
120
+ // After OGL's parse, bufferView.buffer is MUTATED from an index into the live WebGLBuffer
121
+ // (parseBufferViews). A GLB has exactly one binary buffer, so anything non-numeric means 0.
122
+ const bin = bv ? buffers[typeof bv.buffer === 'number' ? bv.buffer : 0] : undefined
123
+ const Ctor = COMPONENT_CTORS[componentType]
124
+ if (!bv || !bin || !Ctor) return dst
125
+ const elemBytes = (Ctor as any).BYTES_PER_ELEMENT as number
126
+ const base = (bv.byteOffset ?? 0) + byteOffset
127
+ if (!byteStride || byteStride === n * elemBytes) {
128
+ const src = new Ctor(bin, base, count * n)
129
+ for (let i = 0; i < dst.length; i++) dst[i] = src[i]!
130
+ } else {
131
+ for (let i = 0; i < count; i++) {
132
+ const src = new Ctor(bin, base + i * byteStride, n)
133
+ for (let k = 0; k < n; k++) dst[i * n + k] = src[k]!
134
+ }
135
+ }
136
+ return dst
137
+ }
138
+
139
+ if (acc.bufferView !== undefined) {
140
+ out.set(readRaw(acc.bufferView, acc.byteOffset ?? 0, acc.componentType, acc.count, comps, desc.bufferViews?.[acc.bufferView]?.byteStride ?? 0))
141
+ }
142
+ if (acc.sparse) {
143
+ const s = acc.sparse
144
+ const idx = readRaw(s.indices.bufferView, s.indices.byteOffset ?? 0, s.indices.componentType, s.count, 1, 0)
145
+ const vals = readRaw(s.values.bufferView, s.values.byteOffset ?? 0, acc.componentType, s.count, comps, 0)
146
+ for (let i = 0; i < s.count; i++) {
147
+ for (let k = 0; k < comps; k++) out[idx[i]! * comps + k] = vals[i * comps + k]!
148
+ }
149
+ }
150
+ const scale = acc.normalized ? NORMALIZE_SCALE[acc.componentType] : undefined
151
+ if (scale) for (let i = 0; i < out.length; i++) out[i] = Math.max(out[i]! / scale, -1)
152
+ return out
153
+ }
154
+
155
+ const tmpQuatA = new Quat()
156
+ const tmpQuatB = new Quat()
157
+
158
+ /** Sample one channel at an absolute clip time, writing the node's TRS component (or its morph
159
+ * weights). Values CLAMP to the first/last keyframe outside the channel's time range, per glTF
160
+ * spec. */
161
+ const sampleChannel = (ch: GlbClipChannel, elapsed: number): void => {
162
+ const { node, transform, interpolation, times, values } = ch
163
+ const isQuat = transform === 'quaternion'
164
+ const isWeights = transform === 'weights'
165
+ // weights → the node's Float32Array from buildMorphState (absent = no usable targets; skip)
166
+ const target: any = isWeights ? (node as any).weights : node[transform]
167
+ if (!target) return
168
+ const size = isWeights ? target.length as number : isQuat ? 4 : 3
169
+ if (size === 0) return
170
+ const cubic = interpolation === 'CUBICSPLINE'
171
+ // CUBICSPLINE keyframes are [inTangent, value, outTangent] triplets
172
+ const stride = cubic ? size * 3 : size
173
+ const valueOffset = cubic ? size : 0
174
+ const setAt = (k: number): void => {
175
+ const o = k * stride + valueOffset
176
+ if (target.fromArray) target.fromArray(values, o)
177
+ else for (let i = 0; i < size; i++) target[i] = values[o + i]
178
+ }
179
+ const last = times.length - 1
180
+ if (last === 0 || elapsed <= times[0]!) return setAt(0)
181
+ if (elapsed >= times[last]!) return setAt(last)
182
+ let lo = 0, hi = last // binary search: greatest lo with times[lo] <= elapsed
183
+ while (hi - lo > 1) {
184
+ const mid = (lo + hi) >> 1
185
+ if (times[mid]! <= elapsed) lo = mid
186
+ else hi = mid
187
+ }
188
+ const dt = times[hi]! - times[lo]!
189
+ const t = interpolation === 'STEP' || dt <= 0 ? 0 : (elapsed - times[lo]!) / dt
190
+ if (cubic) {
191
+ // Hermite with tangents scaled by the keyframe interval (glTF spec formula)
192
+ const t2 = t * t, t3 = t2 * t
193
+ const s0 = 2 * t3 - 3 * t2 + 1, s1 = dt * (t3 - 2 * t2 + t), s2 = -2 * t3 + 3 * t2, s3 = dt * (t3 - t2)
194
+ const p0 = lo * stride + size, m0 = lo * stride + size * 2, m1 = hi * stride, p1 = hi * stride + size
195
+ for (let i = 0; i < size; i++) {
196
+ target[i] = s0 * (values[p0 + i] as number) + s1 * (values[m0 + i] as number)
197
+ + s2 * (values[p1 + i] as number) + s3 * (values[m1 + i] as number)
198
+ }
199
+ if (isQuat) target.normalize()
200
+ } else if (isQuat) {
201
+ tmpQuatA.fromArray(values as number[], lo * 4)
202
+ tmpQuatB.fromArray(values as number[], hi * 4)
203
+ tmpQuatA.slerp(tmpQuatB, t)
204
+ target.copy(tmpQuatA)
205
+ } else {
206
+ for (let i = 0; i < size; i++) {
207
+ const a = values[lo * size + i] as number
208
+ target[i] = a + t * ((values[hi * size + i] as number) - a)
209
+ }
210
+ }
211
+ }
212
+
213
+ /** Set a clip's pose at `time` (seconds from clip start). Replaces OGL's `GLTFAnimation.update`,
214
+ * which EXTRAPOLATES outside a channel's keyframe range (negative/huge alpha from the first
215
+ * segment) — city.scene.ts: snake.glb's snowflakes keep scale keys only for t=19.8–24s of a 60s
216
+ * clip, so at t=0 OGL put them at scale ≈ −224 instead of the clamped 0.005. The caller
217
+ * recomposes matrices from clip.nodes afterwards. Exported for tests. */
218
+ export const sampleClip = (clip: GlbClip, time: number): void => {
219
+ const anim = clip.animation
220
+ const elapsed = anim.duration > 0
221
+ ? (anim.loop ? time % anim.duration : Math.min(time, anim.duration)) + anim.startTime
222
+ : anim.startTime
223
+ for (const ch of anim.data) sampleChannel(ch, elapsed)
224
+ }
225
+
226
+ /** Unique-edge index list (gl.LINES pairs) for a triangle geometry — the wireframe overlay's
227
+ * index buffer. `index` is the triangle index data (absent = non-indexed, sequential triples).
228
+ * Exported for tests. */
229
+ export const buildEdgeIndices = (index: ArrayLike<number> | undefined, vertexCount: number): Uint16Array | Uint32Array => {
230
+ const triCount = Math.floor((index ? index.length : vertexCount) / 3)
231
+ // key = lo·2²⁴ + hi — exact in a double for vertex indices < 16.7M (far beyond any real GLB)
232
+ const K = 1 << 24
233
+ const edges = new Set<number>()
234
+ for (let t = 0; t < triCount; t++) {
235
+ const a = index ? index[t * 3] : t * 3
236
+ const b = index ? index[t * 3 + 1] : t * 3 + 1
237
+ const c = index ? index[t * 3 + 2] : t * 3 + 2
238
+ edges.add(Math.min(a, b) * K + Math.max(a, b))
239
+ edges.add(Math.min(b, c) * K + Math.max(b, c))
240
+ edges.add(Math.min(c, a) * K + Math.max(c, a))
241
+ }
242
+ const out = vertexCount > 65535 ? new Uint32Array(edges.size * 2) : new Uint16Array(edges.size * 2)
243
+ let i = 0
244
+ for (const key of edges) {
245
+ out[i++] = Math.floor(key / K)
246
+ out[i++] = key % K
247
+ }
248
+ return out
249
+ }
250
+
251
+ /** Shared 1x1 white fallback so declared-but-unset samplers are always bound. */
252
+ export const createWhiteTexture = (gl: any): Texture =>
253
+ new Texture(gl, { image: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1, generateMipmaps: false })
254
+
255
+ export const createBlackCubemap = (gl: any): Texture => {
256
+ const tex = new Texture(gl, {
257
+ target: gl.TEXTURE_CUBE_MAP,
258
+ width: 1, height: 1,
259
+ generateMipmaps: false,
260
+ minFilter: gl.LINEAR,
261
+ })
262
+ // OGL's cubemap upload path only accepts TexImageSource faces — typed-array faces make
263
+ // texImage2D throw ("Overload resolution failed") on first bind, which happens whenever a lit
264
+ // program renders before the real IBL finished loading, and the throw kills the render loop.
265
+ // Upload the 1×1 black faces manually and mark the texture clean (same trick as loadIbl).
266
+ gl.bindTexture(gl.TEXTURE_CUBE_MAP, tex.texture)
267
+ const black = new Uint8Array(4)
268
+ for (let i = 0; i < 6; i++) {
269
+ gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, black)
270
+ }
271
+ ;(tex as any).store.image = (tex as any).image = black
272
+ ;(tex as any).needsUpdate = false
273
+ return tex
274
+ }
275
+
276
+ // ---- morph targets (blendshapes) --------------------------------------------------------------
277
+
278
+ /** Uniform array size in the shader (`uMorphWeights[8]`); targets beyond it don't deform. */
279
+ const MAX_MORPH_TARGETS = 8
280
+
281
+ type MorphInfo = {
282
+ texture: Texture
283
+ count: number // active targets (≤ MAX_MORPH_TARGETS)
284
+ vertexCount: number
285
+ texWidth: number
286
+ hasNormals: boolean
287
+ /** The node's live weight array — the SAME Float32Array the clip sampler writes, so mutation
288
+ * reaches the GPU via OGL's per-draw uniform compare (it caches a copy, not the reference). */
289
+ weights: Float32Array
290
+ }
291
+
292
+ /** Build morph state for every node whose mesh carries primitive `targets`: a per-node weight
293
+ * array (defaults from node.weights ?? mesh.weights) stored on the OGL node Transform for the
294
+ * clip sampler, and per-primitive RGBA32F delta textures (three.js-style, fetched by gl_VertexID)
295
+ * tagged onto the OGL meshes as `_morph` for buildProgram. */
296
+ const buildMorphState = (gl: any, desc: any, gltf: any, buffers: (ArrayBuffer | undefined)[]): void => {
297
+ const nodes = desc.nodes ?? []
298
+ for (let i = 0; i < nodes.length; i++) {
299
+ const meshIndex = nodes[i].mesh
300
+ if (meshIndex === undefined) continue
301
+ const meshDesc = desc.meshes?.[meshIndex]
302
+ const prims = meshDesc?.primitives as any[] | undefined
303
+ if (!prims?.some((p) => p.targets?.length)) continue
304
+ const nodeTransform = gltf.nodes?.[i]
305
+ const gltfMesh = gltf.meshes?.[meshIndex]
306
+ if (!nodeTransform || !gltfMesh) continue
307
+
308
+ const targetCount = Math.max(...prims.map((p) => p.targets?.length ?? 0))
309
+ const weights = new Float32Array(targetCount)
310
+ const defaults = nodes[i].weights ?? meshDesc.weights
311
+ if (defaults) for (let k = 0; k < Math.min(targetCount, defaults.length); k++) weights[k] = defaults[k]
312
+ nodeTransform.weights = weights
313
+ if (targetCount > MAX_MORPH_TARGETS) {
314
+ console.warn(`[viewer-lite] GLB: mesh has ${targetCount} morph targets — only the first ${MAX_MORPH_TARGETS} deform`)
315
+ }
316
+
317
+ // Skinned meshes nest per-instance primitive arrays (exactly one after dedupeSharedMeshes).
318
+ const primList: any[] = Array.isArray(gltfMesh.primitives?.[0]) ? gltfMesh.primitives[0] : gltfMesh.primitives ?? []
319
+ for (let j = 0; j < prims.length; j++) {
320
+ const targets = prims[j].targets as { POSITION?: number, NORMAL?: number }[] | undefined
321
+ const prim = primList[j]
322
+ if (!targets?.length || !prim?.geometry) continue
323
+ const vertexCount = prim.geometry.attributes?.position?.count ?? 0
324
+ if (vertexCount === 0) continue
325
+ const n = Math.min(targets.length, MAX_MORPH_TARGETS)
326
+ // Draco-compressed primitives keep their morph deltas as PLAIN accessors in the original
327
+ // vertex order — encoders are expected to preserve that order when a mesh carries targets
328
+ // (Filament's gltfio applies them under the same assumption, so files that look right in
329
+ // the full viewer line up here too). A decode that DID drop/merge vertices shows up as an
330
+ // accessor-count mismatch — skip then, garbage deltas are worse than a rest pose.
331
+ const badTarget = targets.slice(0, n).find((t) => {
332
+ const check = (ai?: number): boolean => ai !== undefined && desc.accessors?.[ai]?.count !== vertexCount
333
+ return check(t.POSITION) || check(t.NORMAL)
334
+ })
335
+ if (badTarget) {
336
+ console.warn(`[viewer-lite] GLB: morph targets skipped on a primitive — target accessor count doesn't match the ${vertexCount}-vertex geometry (draco decode changed the vertex set)`)
337
+ continue
338
+ }
339
+ const hasNormals = targets.slice(0, n).some((t) => t.NORMAL !== undefined)
340
+ const total = vertexCount * n * (hasNormals ? 2 : 1)
341
+ const texWidth = Math.max(1, Math.ceil(Math.sqrt(total)))
342
+ const texHeight = Math.max(1, Math.ceil(total / texWidth))
343
+ const data = new Float32Array(texWidth * texHeight * 4)
344
+ for (let t = 0; t < n; t++) {
345
+ const pos = targets[t]!.POSITION !== undefined ? readAccessorF32(desc, buffers, targets[t]!.POSITION!) : null
346
+ const nrm = hasNormals && targets[t]!.NORMAL !== undefined ? readAccessorF32(desc, buffers, targets[t]!.NORMAL!) : null
347
+ for (let v = 0; v < vertexCount; v++) {
348
+ if (pos) {
349
+ const d = (t * vertexCount + v) * 4
350
+ data[d] = pos[v * 3] ?? 0; data[d + 1] = pos[v * 3 + 1] ?? 0; data[d + 2] = pos[v * 3 + 2] ?? 0
351
+ }
352
+ if (nrm) {
353
+ const d = ((n + t) * vertexCount + v) * 4
354
+ data[d] = nrm[v * 3] ?? 0; data[d + 1] = nrm[v * 3 + 1] ?? 0; data[d + 2] = nrm[v * 3 + 2] ?? 0
355
+ }
356
+ }
357
+ }
358
+ const texture = new Texture(gl, {
359
+ image: data, width: texWidth, height: texHeight,
360
+ type: gl.FLOAT, format: gl.RGBA, internalFormat: gl.RGBA32F,
361
+ generateMipmaps: false, minFilter: gl.NEAREST, magFilter: gl.NEAREST, flipY: false,
362
+ })
363
+ prim._morph = { texture, count: n, vertexCount, texWidth, hasNormals, weights } satisfies MorphInfo
364
+ }
365
+ }
366
+ }
367
+
368
+ /** Tag baseColor/emissive textures sRGB before their first GPU upload (glTF color inputs are sRGB).
369
+ * Basis-compressed images swap to their sRGB sibling internal format instead (srgbInternalFormat,
370
+ * set by the basis manager; 0 = the context lacks the sRGB variant — stay linear rather than
371
+ * break the compressed upload). */
372
+ const markSrgb = (gl: any, textureInfo: any): void => {
373
+ const tex = textureInfo?.texture
374
+ if (!tex || tex._srgbTagged) return
375
+ tex._srgbTagged = true
376
+ if (tex.image?.isCompressedTexture) {
377
+ if (tex.image.srgbInternalFormat) tex.internalFormat = tex.image.srgbInternalFormat
378
+ return
379
+ }
380
+ tex.internalFormat = gl.SRGB8_ALPHA8
381
+ tex.format = gl.RGBA
382
+ }
383
+
384
+ const buildProgram = (
385
+ gl: any,
386
+ material: any,
387
+ isSkin: boolean,
388
+ hasColor: boolean,
389
+ sceneUniforms: SceneUniforms,
390
+ white: Texture,
391
+ morph?: MorphInfo,
392
+ ): Program => {
393
+ const m = material ?? {}
394
+ const defines: string[] = []
395
+ if (isSkin) defines.push('#define HAS_SKIN')
396
+ if (hasColor) defines.push('#define HAS_COLOR')
397
+ if (m.baseColorTexture?.texture) defines.push('#define HAS_BASECOLOR_MAP')
398
+ if (m.metallicRoughnessTexture?.texture) defines.push('#define HAS_MR_MAP')
399
+ if (m.normalTexture?.texture) defines.push('#define HAS_NORMAL_MAP')
400
+ if (m.occlusionTexture?.texture) defines.push('#define HAS_OCCLUSION_MAP')
401
+ if (m.emissiveTexture?.texture) defines.push('#define HAS_EMISSIVE_MAP')
402
+ // KHR_materials_unlit — flat base colour, the shading model stylised assets are authored for.
403
+ // gltfio implements it natively (unlit_opaque in the uberarchive), so both engines agree.
404
+ if (m.extensions?.KHR_materials_unlit) defines.push('#define UNLIT')
405
+ if (m.doubleSided) defines.push('#define DOUBLE_SIDED')
406
+ if (m.alphaMode === 'MASK') defines.push('#define ALPHA_MASK')
407
+ if (m.alphaMode === 'BLEND') defines.push('#define ALPHA_BLEND')
408
+ const defineStr = defines.join('\n')
409
+
410
+ markSrgb(gl, m.baseColorTexture)
411
+ markSrgb(gl, m.emissiveTexture)
412
+
413
+ const uniforms: SceneUniforms = {
414
+ // Scene-shared: same { value } objects across every program — updated once per frame.
415
+ ...sceneUniforms,
416
+ uBaseColorFactor: { value: m.baseColorFactor ?? [1, 1, 1, 1] },
417
+ uMetallic: { value: m.metallicFactor ?? 1 },
418
+ uRoughness: { value: m.roughnessFactor ?? 1 },
419
+ uEmissive: { value: m.emissiveFactor ?? [0, 0, 0] },
420
+ uAlphaCutoff: { value: m.alphaCutoff ?? 0.5 },
421
+ }
422
+ if (m.baseColorTexture?.texture) uniforms.uBaseColorMap = { value: m.baseColorTexture.texture }
423
+ if (m.metallicRoughnessTexture?.texture) uniforms.uMRMap = { value: m.metallicRoughnessTexture.texture }
424
+ if (m.normalTexture?.texture) uniforms.uNormalMap = { value: m.normalTexture.texture }
425
+ if (m.occlusionTexture?.texture) uniforms.uOcclusionMap = { value: m.occlusionTexture.texture }
426
+ if (m.emissiveTexture?.texture) uniforms.uEmissiveMap = { value: m.emissiveTexture.texture }
427
+ if (morph) {
428
+ uniforms.uMorphTex = { value: morph.texture }
429
+ uniforms.uMorphCount = { value: morph.count }
430
+ uniforms.uMorphVertexCount = { value: morph.vertexCount }
431
+ uniforms.uMorphTexWidth = { value: morph.texWidth }
432
+ // The subarray view shares the node's buffer, so the clip sampler's writes flow through. It
433
+ // must be WRAPPED in a plain array: OGL resolves the active uniform `uMorphWeights[0]` by
434
+ // walking name components and only recognizes an array value via Array.isArray — a bare
435
+ // Float32Array fails that and the uniform is never uploaded ("has not been supplied"). The
436
+ // wrapper also routes through OGL's flatten(), which re-copies the live values every draw.
437
+ uniforms.uMorphWeights = { value: [ morph.weights.length > MAX_MORPH_TARGETS ? morph.weights.subarray(0, MAX_MORPH_TARGETS) : morph.weights ] }
438
+ }
439
+
440
+ const defineVertexFlags = (isSkin ? 'HAS_SKIN ' : '') + (hasColor ? 'HAS_COLOR ' : '')
441
+ + (morph ? 'HAS_MORPH ' : '') + (morph?.hasNormals ? 'HAS_MORPH_NORMALS' : '')
442
+ const program = new Program(gl, {
443
+ vertex: gltfVertex(defineVertexFlags),
444
+ fragment: gltfFragment(defineStr),
445
+ uniforms: uniforms as any,
446
+ transparent: m.alphaMode === 'BLEND',
447
+ cullFace: m.doubleSided ? false : gl.BACK,
448
+ })
449
+ if (m.alphaMode === 'BLEND') program.setBlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
450
+ return program
451
+ }
452
+
453
+ /**
454
+ * Parse a GLB buffer and outfit it with the lite PBR programs. `sceneUniforms` are the shared
455
+ * per-frame lighting uniform slots (sun/IBL/shadow/exposure) every program references.
456
+ */
457
+ export const loadGlbLite = async (
458
+ gl: any,
459
+ buffer: ArrayBuffer,
460
+ sceneUniforms: SceneUniforms,
461
+ white: Texture,
462
+ ): Promise<GlbResult> => {
463
+ const desc = (GLTFLoader as any).unpackGLB(buffer)
464
+ const unsupported = ((desc.extensionsRequired ?? []) as string[]).filter((e) => UNSUPPORTED_REQUIRED.includes(e))
465
+ if (unsupported.length > 0) {
466
+ throw new Error(`GLB requires ${unsupported.join(', ')} — the lite engine has no decoder for it`)
467
+ }
468
+
469
+ // Draco geometry / KTX2 textures: bring the lazy decoder workers up BEFORE parse (page-global
470
+ // singletons — the import and wasm fetch happen once, every later model reuses the same worker).
471
+ const extensions = [ ...(desc.extensionsUsed ?? []), ...(desc.extensionsRequired ?? []) ] as string[]
472
+ const needsDraco = extensions.includes('KHR_draco_mesh_compression')
473
+ const needsBasis = extensions.includes('KHR_texture_basisu')
474
+ || ((desc.images ?? []) as { mimeType?: string }[]).some((i) => i.mimeType === 'image/ktx2')
475
+ if (needsDraco || needsBasis) {
476
+ const decoders = await import('./decoders')
477
+ await Promise.all([
478
+ needsDraco ? decoders.ensureDracoDecoder() : null,
479
+ needsBasis ? decoders.ensureBasisTranscoder(gl) : null,
480
+ ])
481
+ }
482
+
483
+ dedupeSharedMeshes(desc)
484
+ // Morph `weights` channels must not reach OGL's parser (it crashes on them) — pull them out and
485
+ // re-attach to OUR sampler after parse, once buildMorphState has set up the weight arrays.
486
+ const morphChannels = extractMorphAnimationChannels(desc)
487
+ const gltf = await (GLTFLoader as any).parse(gl, desc, '')
488
+ const rawBuffers = ((desc.buffers ?? []) as { binary?: ArrayBuffer }[]).map((b) => b.binary)
489
+ buildMorphState(gl, desc, gltf, rawBuffers)
490
+
491
+ // Replace the loader's NormalProgram placeholders with our PBR programs. `gltf.nodes` holds both
492
+ // parents and children, so traversing every node reaches shared meshes more than once — dedupe,
493
+ // otherwise the second visit reads the already-swapped program (no gltfMaterial) and overwrites
494
+ // the textured program with a material-less white one.
495
+ const programCache = new Map<any, Program>()
496
+ const seen = new Set<any>()
497
+ const allMeshes: Mesh[] = []
498
+ for (const node of gltf.nodes as Transform[]) {
499
+ node.traverse((child: any) => {
500
+ if (child.draw && !seen.has(child)) { seen.add(child); allMeshes.push(child) }
501
+ return false
502
+ })
503
+ }
504
+ for (const mesh of allMeshes as any[]) {
505
+ // The lite programs consume `normal` and `uv` unconditionally, and OGL crashes drawing a
506
+ // program whose active attribute the geometry lacks ("reading 'needsUpdate'") — but glTF only
507
+ // guarantees POSITION (robot.glb ships no UVs; snake.glb has a skinned primitive without
508
+ // TEXCOORD_0). Pad missing ones with zeros: untextured materials are unaffected, textured
509
+ // ones sample texel (0,0) — a solid-color degrade instead of a dead render loop. The
510
+ // wireframe companions copy attributes from here, so they inherit the padding.
511
+ const geoAttrs = mesh.geometry?.attributes
512
+ const vertexCount = geoAttrs?.position?.count ?? 0
513
+ if (geoAttrs && vertexCount > 0) {
514
+ if (!geoAttrs.normal) {
515
+ const n = new Float32Array(vertexCount * 3)
516
+ for (let i = 2; i < n.length; i += 3) n[i] = 1 // +Z, not zeros — normalize(0) is NaN
517
+ mesh.geometry.addAttribute('normal', { size: 3, data: n })
518
+ }
519
+ if (!geoAttrs.uv) mesh.geometry.addAttribute('uv', { size: 2, data: new Float32Array(vertexCount * 2) })
520
+ }
521
+ const material = mesh.program?.gltfMaterial
522
+ const isSkin = mesh instanceof GLTFSkin
523
+ const colorAttr = mesh.geometry?.attributes?.color
524
+ const hasColor = !!colorAttr && colorAttr.size === 4
525
+ const morph = mesh._morph as MorphInfo | undefined
526
+ // Programs are shareable when the material AND variant flags match. Skinned programs can be
527
+ // shared too (GLTFSkin re-binds boneTexture per draw); morphed ones can't (per-mesh texture
528
+ // + weight uniforms).
529
+ const key = material ?? mesh
530
+ const cacheKey = isSkin || hasColor || morph ? mesh : key
531
+ let program = programCache.get(cacheKey)
532
+ if (!program) {
533
+ program = buildProgram(gl, material, isSkin, hasColor, sceneUniforms, white, morph)
534
+ programCache.set(cacheKey, program)
535
+ }
536
+ mesh.program = program
537
+ mesh._receiveShadows = true
538
+ mesh._castShadows = true
539
+ }
540
+
541
+ // Wait for texture image decodes so the model doesn't pop in texture-less (mirrors the Filament
542
+ // path's poll-until-transcoded behavior).
543
+ if (gltf.images) {
544
+ await Promise.all((gltf.images as any[]).map((img) => img?.ready).filter(Boolean))
545
+ }
546
+
547
+ // Animation clips: name + duration + the unique set of nodes each clip drives.
548
+ const animations: GlbClip[] = (gltf.animations ?? []).map((a: any, i: number) => {
549
+ const nodes: Transform[] = []
550
+ for (const ch of a.animation.data) if (!nodes.includes(ch.node)) nodes.push(ch.node)
551
+ return {
552
+ name: typeof a.name === 'string' && a.name.length > 0 ? a.name : `animation_${i}`,
553
+ duration: a.animation.duration,
554
+ animation: a.animation,
555
+ nodes,
556
+ }
557
+ })
558
+
559
+ // Re-attach the extracted morph channels to our sampler (only nodes that built morph state —
560
+ // a weights channel on a target-less mesh is meaningless and skipped).
561
+ let attachedMorph = 0
562
+ for (const mc of morphChannels) {
563
+ const clip = animations[mc.anim]
564
+ const node = gltf.nodes?.[mc.node] as any
565
+ if (!clip || !node?.weights) continue
566
+ clip.animation.data.push({
567
+ node, transform: 'weights', interpolation: mc.interpolation,
568
+ times: readAccessorF32(desc, rawBuffers, mc.input),
569
+ values: readAccessorF32(desc, rawBuffers, mc.output),
570
+ })
571
+ attachedMorph++
572
+ }
573
+ if (morphChannels.length > attachedMorph) {
574
+ console.warn(`[viewer-lite] GLB: ${morphChannels.length - attachedMorph} morph (weights) animation channel(s) ignored — their nodes carry no usable morph targets`)
575
+ }
576
+ // Recompute every clip's clock from its channels: weights channels may extend or (for
577
+ // morph-only clips, which OGL parsed as empty) entirely define it — and OGL's reduce over an
578
+ // empty channel list leaves duration at -Infinity.
579
+ for (const clip of animations) {
580
+ let start = Infinity, end = 0
581
+ for (const chd of clip.animation.data) {
582
+ start = Math.min(start, chd.times[0] as number)
583
+ end = Math.max(end, chd.times[chd.times.length - 1] as number)
584
+ }
585
+ clip.animation.startTime = start === Infinity ? 0 : start
586
+ clip.animation.duration = clip.duration = start === Infinity ? 0 : end - start
587
+ }
588
+
589
+ // glTF `scene` (the default scene index) is OPTIONAL — glTF-Transform output (lecodes-assets
590
+ // convert/doctor) omits it, and OGL then resolves `scenes[undefined]` to nothing: the model loads
591
+ // with zero roots, nothing is parented, zero draw calls. Fall back to the first scene like gltfio.
592
+ return { roots: gltf.scene ?? gltf.scenes?.[0] ?? [], nodes: gltf.nodes ?? [], animations }
593
+ }