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.
- package/README.md +27 -0
- package/package.json +33 -0
- package/src/assets/decoders/README.md +13 -0
- package/src/assets/decoders/basis_transcoder.js +19 -0
- package/src/assets/decoders/basis_transcoder.wasm +0 -0
- package/src/assets/decoders/draco_decoder.wasm +0 -0
- package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/core/animBridge.ts +51 -0
- package/src/core/boneNames.ts +22 -0
- package/src/core/frame.ts +230 -0
- package/src/core/glbStructure.ts +100 -0
- package/src/core/headlessBridge.ts +92 -0
- package/src/core/mapView.ts +195 -0
- package/src/core/math3d.ts +275 -0
- package/src/core/particleBridge.ts +154 -0
- package/src/core/physicsBridge.ts +305 -0
- package/src/core/recording.ts +59 -0
- package/src/core/sceneCore.ts +833 -0
- package/src/core/serialize.ts +144 -0
- package/src/gen/unsupported.gen.ts +212 -0
- package/src/index.ts +5 -0
- package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
- package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
- package/src/vendor/creator-anim/js/index.ts +656 -0
- package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
- package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
- package/src/vendor/creator-nav/js/index.ts +400 -0
- package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
- package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
- package/src/vendor/creator-particles/js/index.ts +137 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
- package/src/vendor/creator-physics/js/index.ts +515 -0
- package/src/vite-env.d.ts +1 -0
- package/src/webgl/batching.ts +453 -0
- package/src/webgl/createViewerLite.ts +1627 -0
- package/src/webgl/customMaterial.ts +228 -0
- package/src/webgl/decoders/basis.ts +123 -0
- package/src/webgl/decoders/draco.ts +110 -0
- package/src/webgl/decoders/index.ts +68 -0
- package/src/webgl/decoders/worker.ts +45 -0
- package/src/webgl/editor.ts +1297 -0
- package/src/webgl/glb.ts +593 -0
- package/src/webgl/ibl.ts +137 -0
- package/src/webgl/shaders.ts +779 -0
package/src/webgl/glb.ts
ADDED
|
@@ -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
|
+
}
|