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,453 @@
1
+ // Instanced batching for the lite engine's scene passes.
2
+ //
3
+ // A level built from a modular kit is thousands of nodes over a few hundred distinct meshes
4
+ // (Twig & Twine's map: 9 441 prefab placements of 152 GLBs → ~57 k drawables). Drawn one OGL Mesh at a
5
+ // time that is 57 k draw calls, each re-uploading every uniform of its program — the frame was
6
+ // 250 ms+ before a single triangle rasterized. Here every drawable that shares (geometry, program,
7
+ // mode, render order, shadow flags, winding) joins ONE batch and the batch draws with
8
+ // `drawElementsInstanced`, the per-instance world matrix coming from an `instanceMatrix` vertex
9
+ // attribute (shaders.ts: the `INSTANCED` define swaps `modelMatrix` for it). The per-node Mesh
10
+ // objects stay exactly as they were — picking, the selection outline, wireframe companions, bounds
11
+ // and the editor all keep reading them — they just stop being drawn one by one.
12
+ //
13
+ // `collect()` walks the OGL graph only when the host says the graph changed (a `graphVersion` the
14
+ // engine bumps on every matrix / parent / visibility / drawable mutation — every one of them goes
15
+ // through the core's backend hooks): the walk fills one instance stream per batch, `all` (every
16
+ // hierarchy-visible instance — the shadow casters), plus the instances' world bounding spheres
17
+ // and the list of singles. Every frame the camera moved, a flat pass over those cached spheres
18
+ // (no graph traversal — 57 k nodes are ~2 ms this way, ~40 ms as a recursive walk) fills `vis`,
19
+ // the camera-visible subset the main pass draws, and culls the singles. A static scene with a
20
+ // still camera does nothing at all. What can't batch — transparent programs (their draw order is
21
+ // depth-sorted per object), skinned meshes (per-mesh bone textures), morph targets (per-node
22
+ // weights), points, particle streams, programs whose vertex shader has no INSTANCED path — is a
23
+ // `single` and draws the way OGL's renderer draws it, in OGL's sort order.
24
+
25
+ import { Camera, Geometry, GLTFSkin, Mesh, Program, Renderer, Transform, type OGLRenderingContext } from 'ogl'
26
+ import { transformPoint } from '../core/math3d'
27
+
28
+ type GL = OGLRenderingContext & WebGL2RenderingContext
29
+
30
+ type Batch = {
31
+ key: string
32
+ source: Geometry
33
+ program: Program
34
+ /** The instanced twin of `program` (same uniforms object, INSTANCED vertex path). */
35
+ twin: Program
36
+ mode: number
37
+ renderOrder: number
38
+ castShadows: boolean
39
+ receiveShadows: boolean
40
+ /** Mirrored instances (negative world determinant) draw with the front face flipped. */
41
+ flip: boolean
42
+ all: Float32Array
43
+ allCount: number
44
+ allDirty: boolean
45
+ geoAll: Geometry
46
+ /** Per `all` instance: world bounding sphere (cx, cy, cz, r) — the cull pass reads these. */
47
+ sph: Float32Array
48
+ vis: Float32Array
49
+ visCount: number
50
+ visDirty: boolean
51
+ geoVis: Geometry
52
+ /** World AABB of `all` (min xyz, max xyz) — recomputed when `all` changes. */
53
+ bounds: Float32Array
54
+ lastUsed: number
55
+ }
56
+
57
+ const ZERO = { value: 0 }
58
+ const INITIAL_CAPACITY = 16
59
+ /** Frames a batch may sit empty before its GPU buffers go. */
60
+ const IDLE_FRAMES = 600
61
+
62
+ const maxScaleOnAxis = (m: ArrayLike<number>): number => Math.sqrt(Math.max(
63
+ m[0] * m[0] + m[1] * m[1] + m[2] * m[2],
64
+ m[4] * m[4] + m[5] * m[5] + m[6] * m[6],
65
+ m[8] * m[8] + m[9] * m[9] + m[10] * m[10],
66
+ ))
67
+
68
+ const determinant3 = (m: ArrayLike<number>): number =>
69
+ m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8]) + m[2] * (m[4] * m[9] - m[5] * m[8])
70
+
71
+ export type Batcher = ReturnType<typeof createBatcher>
72
+
73
+ export const createBatcher = (gl: GL, renderer: Renderer) => {
74
+ const batches = new Map<string, Batch>()
75
+ /** This frame's camera-visible singles (the main pass). */
76
+ const singles: Mesh[] = []
77
+ /** Every hierarchy-visible single (the walk's output; the cull pass filters it into `singles`). */
78
+ const singlesAll: Mesh[] = []
79
+ /** The shadow-casting subset of `singlesAll`. */
80
+ const casterSingles: Mesh[] = []
81
+ let lastGraph = -1
82
+ const lastPV = new Float32Array(16)
83
+ let lastCull = true
84
+ /** Off = every drawable is a single (the per-mesh path, for A/B checks and debugging). */
85
+ let enabled = true
86
+ /** Source program → its instanced twin (null = the vertex shader has no INSTANCED path). */
87
+ const twins = new Map<Program, Program | null>()
88
+ let frame = 0
89
+
90
+ // ---- instanced program twins ----------------------------------------------------------------
91
+
92
+ const twinFor = (src: Program): Program | null => {
93
+ const known = twins.get(src)
94
+ if (known !== undefined) return known
95
+ let twin: Program | null = null
96
+ const vs = gl.getShaderSource(src.vertexShader)
97
+ const fs = gl.getShaderSource(src.fragmentShader)
98
+ if (vs && fs && vs.startsWith('#version')) {
99
+ // the define goes right after the #version line (the only thing allowed before it)
100
+ const vertex = vs.replace(/^([^\n]*\n)/, '$1#define INSTANCED\n')
101
+ const t = new Program(gl, {
102
+ vertex, fragment: fs, uniforms: src.uniforms,
103
+ transparent: src.transparent, cullFace: src.cullFace as any, frontFace: src.frontFace,
104
+ depthTest: src.depthTest, depthWrite: src.depthWrite, depthFunc: src.depthFunc,
105
+ })
106
+ let hasInstanceAttr = false
107
+ t.attributeLocations.forEach((_loc: number, info: { name: string }) => { if (info.name === 'instanceMatrix') hasInstanceAttr = true })
108
+ if (hasInstanceAttr && gl.getProgramParameter(t.program, gl.LINK_STATUS)) twin = t
109
+ else t.remove()
110
+ }
111
+ twins.set(src, twin)
112
+ return twin
113
+ }
114
+
115
+ /** The per-draw GL state lives on the source program (the SDK mutates it: depth flags, cull
116
+ * mode, blend) — mirror it onto the twin right before the batch draws. */
117
+ const syncState = (twin: Program, src: Program): void => {
118
+ twin.transparent = src.transparent
119
+ twin.cullFace = src.cullFace
120
+ twin.frontFace = src.frontFace
121
+ twin.depthTest = src.depthTest
122
+ twin.depthWrite = src.depthWrite
123
+ twin.depthFunc = src.depthFunc
124
+ twin.blendFunc = src.blendFunc
125
+ twin.blendEquation = src.blendEquation
126
+ }
127
+
128
+ // ---- geometry twins (shared vertex buffers + an instanceMatrix stream) -------------------------
129
+
130
+ const makeInstanceGeometry = (source: Geometry, capacity: number): Geometry => {
131
+ const attrs: Record<string, unknown> = {}
132
+ for (const [ name, a ] of Object.entries<any>(source.attributes)) {
133
+ attrs[name] = {
134
+ size: a.size, data: a.data, type: a.type, normalized: a.normalized,
135
+ stride: a.stride, offset: a.offset, buffer: a.buffer, count: a.count,
136
+ }
137
+ }
138
+ attrs.instanceMatrix = { instanced: 1, size: 16, data: new Float32Array(capacity * 16), usage: gl.DYNAMIC_DRAW }
139
+ const g = new Geometry(gl, attrs as any)
140
+ g.drawRange.start = source.drawRange.start
141
+ g.drawRange.count = source.drawRange.count
142
+ return g
143
+ }
144
+
145
+ const disposeInstanceGeometry = (g: Geometry): void => {
146
+ // ONLY the instance stream and the VAOs are ours — every other buffer belongs to the source
147
+ for (const vao of Object.values(g.VAOs)) renderer.deleteVertexArray(vao as WebGLVertexArrayObject)
148
+ g.VAOs = {}
149
+ const attr = (g.attributes as any).instanceMatrix
150
+ if (attr?.buffer) { gl.deleteBuffer(attr.buffer); attr.buffer = null }
151
+ ;(renderer as any).currentGeometry = null
152
+ }
153
+
154
+ /** Grow the instance stream to hold `needed` matrices (the VAOs re-bind on the next draw). */
155
+ const growInstanceGeometry = (g: Geometry, needed: number): Float32Array => {
156
+ const attr = (g.attributes as any).instanceMatrix
157
+ let capacity = Math.max(INITIAL_CAPACITY, attr.count)
158
+ while (capacity < needed) capacity *= 2
159
+ const data = new Float32Array(capacity * 16)
160
+ data.set(attr.data)
161
+ disposeInstanceGeometry(g)
162
+ g.instancedCount = 0
163
+ g.addAttribute('instanceMatrix', { instanced: 1, size: 16, data, usage: gl.DYNAMIC_DRAW } as any)
164
+ return data
165
+ }
166
+
167
+ const upload = (g: Geometry, data: Float32Array, count: number): void => {
168
+ const attr = (g.attributes as any).instanceMatrix
169
+ if (count > 0) {
170
+ gl.bindBuffer(gl.ARRAY_BUFFER, attr.buffer)
171
+ ;(renderer.state as any).boundBuffer = attr.buffer
172
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, count * 16)
173
+ }
174
+ g.setInstancedCount(count)
175
+ }
176
+
177
+ // ---- batches ------------------------------------------------------------------------------------
178
+
179
+ const batchFor = (node: any, twin: Program, flip: boolean): Batch => {
180
+ const program: Program = node.program
181
+ const cast = node._castShadows !== false
182
+ const receive = node._receiveShadows !== false
183
+ const key = `${node.geometry.id}|${(program as any).id}|${node.mode}|${node.renderOrder}|${cast ? 1 : 0}${receive ? 1 : 0}${flip ? 1 : 0}`
184
+ let b = batches.get(key)
185
+ if (!b) {
186
+ const source: Geometry = node.geometry
187
+ b = {
188
+ key, source, program, twin, mode: node.mode, renderOrder: node.renderOrder,
189
+ castShadows: cast, receiveShadows: receive, flip,
190
+ all: new Float32Array(INITIAL_CAPACITY * 16), allCount: 0, allDirty: true,
191
+ geoAll: makeInstanceGeometry(source, INITIAL_CAPACITY),
192
+ sph: new Float32Array(INITIAL_CAPACITY * 4),
193
+ vis: new Float32Array(INITIAL_CAPACITY * 16), visCount: 0, visDirty: true,
194
+ geoVis: makeInstanceGeometry(source, INITIAL_CAPACITY),
195
+ bounds: new Float32Array(6),
196
+ lastUsed: frame,
197
+ }
198
+ batches.set(key, b)
199
+ }
200
+ return b
201
+ }
202
+
203
+ /** Why a drawable can't join a batch — see the header. */
204
+ const batchable = (node: any): Program | null => {
205
+ if (!enabled) return null
206
+ if (node instanceof GLTFSkin || node._particles || node.mode === gl.POINTS) return null
207
+ const program: Program = node.program
208
+ if (program.transparent || !program.depthTest) return null
209
+ if (!node.geometry?.attributes?.position || node.geometry.isInstanced) return null
210
+ if ((program.uniforms as any).uMorphTex) return null
211
+ return twinFor(program)
212
+ }
213
+
214
+ const bounds = (node: any): { center: { x: number, y: number, z: number }, radius: number, min: any, max: any } | null => {
215
+ const g = node.geometry
216
+ if (!g.bounds || g.bounds.radius === Infinity) g.computeBoundingSphere()
217
+ return g.bounds && isFinite(g.bounds.radius) ? g.bounds : null
218
+ }
219
+
220
+ const sphereInFrustum = (camera: Camera, cx: number, cy: number, cz: number, r: number): boolean => {
221
+ const planes = camera.frustum as unknown as (ArrayLike<number> & { constant: number })[]
222
+ for (let i = 0; i < 6; i++) {
223
+ const p = planes[i]
224
+ if (p[0] * cx + p[1] * cy + p[2] * cz + p.constant < -r) return false
225
+ }
226
+ return true
227
+ }
228
+
229
+ /** Write matrix `w` at instance slot `i` of `data`; true when any value changed. */
230
+ const writeMatrix = (data: Float32Array, i: number, w: ArrayLike<number>): boolean => {
231
+ const o = i * 16
232
+ let changed = false
233
+ for (let k = 0; k < 16; k++) {
234
+ const v = w[k]
235
+ if (data[o + k] !== v) { data[o + k] = v; changed = true }
236
+ }
237
+ return changed
238
+ }
239
+
240
+ /** World AABB over a batch's `all` instances × its geometry's local AABB (8 corners each). */
241
+ const computeBounds = (b: Batch): void => {
242
+ const g = b.source
243
+ if (!g.bounds) g.computeBoundingBox()
244
+ const out = b.bounds
245
+ out[0] = out[1] = out[2] = Infinity; out[3] = out[4] = out[5] = -Infinity
246
+ if (!g.bounds || !isFinite(g.bounds.min.x)) return
247
+ const { min, max } = g.bounds
248
+ const corner = [ 0, 0, 0 ]
249
+ for (let i = 0; i < b.allCount; i++) {
250
+ const w = b.all.subarray(i * 16, i * 16 + 16)
251
+ for (let c = 0; c < 8; c++) {
252
+ transformPoint(corner, w, c & 1 ? max.x : min.x, c & 2 ? max.y : min.y, c & 4 ? max.z : min.z, 1)
253
+ for (let k = 0; k < 3; k++) {
254
+ if (corner[k] < out[k]) out[k] = corner[k]
255
+ if (corner[k] > out[k + 3]) out[k + 3] = corner[k]
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ // ---- the per-frame walk -------------------------------------------------------------------------
262
+
263
+ const centre = [ 0, 0, 0 ]
264
+
265
+ /** The graph walk (world matrices must be current): rebuild every batch's `all` stream + spheres
266
+ * and the singles lists. Runs only when the graph version moved. */
267
+ const walk = (root: Transform): void => {
268
+ frame++
269
+ singlesAll.length = 0
270
+ casterSingles.length = 0
271
+ for (const b of batches.values()) b.allCount = 0
272
+ root.traverse((node: any) => {
273
+ if (!node.visible) return true
274
+ if (!node.draw) return false
275
+ const w = node.worldMatrix
276
+ const twin = batchable(node)
277
+ if (!twin) {
278
+ singlesAll.push(node)
279
+ if (node._castShadows !== false) casterSingles.push(node)
280
+ return false
281
+ }
282
+ const flip = !!node.program.cullFace && determinant3(w) < 0
283
+ const b = batchFor(node, twin, flip)
284
+ b.lastUsed = frame
285
+ const i = b.allCount
286
+ if (i * 16 >= b.all.length) {
287
+ b.all = growInstanceGeometry(b.geoAll, i + 1)
288
+ const sph = new Float32Array(b.all.length / 4)
289
+ sph.set(b.sph)
290
+ b.sph = sph
291
+ }
292
+ if (writeMatrix(b.all, i, w)) b.allDirty = true
293
+ const bnd = bounds(node)
294
+ const o = i * 4
295
+ if (bnd && node.frustumCulled) {
296
+ transformPoint(centre, w, bnd.center.x, bnd.center.y, bnd.center.z, 1)
297
+ b.sph[o] = centre[0]; b.sph[o + 1] = centre[1]; b.sph[o + 2] = centre[2]
298
+ b.sph[o + 3] = bnd.radius * maxScaleOnAxis(w)
299
+ } else {
300
+ b.sph[o + 3] = -1 // never culled (frustumCulled off, or no bounds to test)
301
+ }
302
+ b.allCount++
303
+ return false
304
+ })
305
+ for (const [ key, b ] of batches) {
306
+ if (b.allCount !== b.geoAll.instancedCount) b.allDirty = true
307
+ if (b.allDirty) { upload(b.geoAll, b.all, b.allCount); computeBounds(b); b.allDirty = false }
308
+ if (b.allCount === 0 && frame - b.lastUsed > IDLE_FRAMES) {
309
+ disposeInstanceGeometry(b.geoAll)
310
+ disposeInstanceGeometry(b.geoVis)
311
+ batches.delete(key)
312
+ }
313
+ }
314
+ }
315
+
316
+ /** The cull pass: `all` → `vis` per batch from the cached spheres, `singlesAll` → `singles`.
317
+ * `cull` off copies everything through (a drawable with `frustumCulled = false` always
318
+ * passes, as in OGL). */
319
+ const cullPass = (camera: Camera, cull: boolean): void => {
320
+ camera.updateFrustum()
321
+ for (const b of batches.values()) {
322
+ b.visCount = 0
323
+ const n = b.allCount
324
+ for (let i = 0; i < n; i++) {
325
+ const o = i * 4
326
+ if (cull && b.sph[o + 3] >= 0 && !sphereInFrustum(camera, b.sph[o], b.sph[o + 1], b.sph[o + 2], b.sph[o + 3])) continue
327
+ const v = b.visCount
328
+ if (v * 16 >= b.vis.length) b.vis = growInstanceGeometry(b.geoVis, v + 1)
329
+ const src = i * 16, dst = v * 16
330
+ for (let k = 0; k < 16; k++) {
331
+ const x = b.all[src + k]
332
+ if (b.vis[dst + k] !== x) { b.vis[dst + k] = x; b.visDirty = true }
333
+ }
334
+ b.visCount++
335
+ }
336
+ if (b.visCount !== b.geoVis.instancedCount) b.visDirty = true
337
+ if (b.visDirty) { upload(b.geoVis, b.vis, b.visCount); b.visDirty = false }
338
+ }
339
+ singles.length = 0
340
+ for (const node of singlesAll) {
341
+ if (cull && node.frustumCulled && !camera.frustumIntersectsMesh(node)) continue
342
+ singles.push(node)
343
+ }
344
+ }
345
+
346
+ /** Refresh the frame's draw lists. `graphVersion` = the engine's mutation counter (a change
347
+ * re-walks the graph); the camera moving re-culls; otherwise nothing happens. `force` re-walks
348
+ * regardless (a caller that changed drawables behind the counter's back). */
349
+ const collect = (root: Transform, camera: Camera, cull: boolean, graphVersion = -2, force = false): void => {
350
+ const graphChanged = force || graphVersion !== lastGraph
351
+ if (graphChanged) { walk(root); lastGraph = graphVersion }
352
+ camera.updateMatrixWorld()
353
+ const pv = camera.projectionViewMatrix as unknown as ArrayLike<number>
354
+ let camChanged = cull !== lastCull
355
+ for (let k = 0; k < 16 && !camChanged; k++) if (lastPV[k] !== pv[k]) camChanged = true
356
+ if (!graphChanged && !camChanged) return
357
+ cullPass(camera, cull)
358
+ for (let k = 0; k < 16; k++) lastPV[k] = pv[k]
359
+ lastCull = cull
360
+ }
361
+
362
+ // ---- draws ------------------------------------------------------------------------------------
363
+
364
+ const sortedBatches = (): Batch[] => [ ...batches.values() ].sort((a, b) => a.renderOrder - b.renderOrder)
365
+
366
+ /** The main pass: batches (all opaque) in render-order, then the singles in OGL's order —
367
+ * opaque front-to-back, transparent back-to-front, depth-test-off last. `shadowEnabled` is the
368
+ * scene's live { value } slot; batches that don't receive shadows draw with it forced off (the
369
+ * same swap wireReceiveShadows does per single). */
370
+ const drawMain = (camera: Camera, shadowEnabled: { value: number }): void => {
371
+ for (const b of sortedBatches()) {
372
+ if (b.visCount === 0) continue
373
+ const u = b.twin.uniforms as Record<string, { value: unknown }>
374
+ ;(u.projectionMatrix ??= { value: null }).value = camera.projectionMatrix
375
+ ;(u.viewMatrix ??= { value: null }).value = camera.viewMatrix
376
+ ;(u.cameraPosition ??= { value: null }).value = camera.worldPosition
377
+ // the uniforms object is the source program's too — swap the slot for this draw only, so
378
+ // a single drawn later through the same program still sees the scene's live value
379
+ const prevShadow = u.shadowEnabled
380
+ if (prevShadow) u.shadowEnabled = b.receiveShadows ? shadowEnabled : ZERO
381
+ syncState(b.twin, b.program)
382
+ b.twin.use({ flipFaces: b.flip })
383
+ b.geoVis.draw({ mode: b.mode, program: b.twin })
384
+ if (prevShadow) u.shadowEnabled = prevShadow
385
+ }
386
+
387
+ const opaque: Mesh[] = [], transparent: Mesh[] = [], ui: Mesh[] = []
388
+ const pv = camera.projectionViewMatrix as unknown as ArrayLike<number>
389
+ for (const node of singles as any[]) {
390
+ if (!node.program.transparent) opaque.push(node)
391
+ else if (node.program.depthTest) transparent.push(node)
392
+ else ui.push(node)
393
+ node.zDepth = 0
394
+ if (node.renderOrder !== 0 || !node.program.depthTest) continue
395
+ const w = node.worldMatrix
396
+ const x = w[12], y = w[13], z = w[14]
397
+ const cw = pv[3] * x + pv[7] * y + pv[11] * z + pv[15] || 1
398
+ node.zDepth = (pv[2] * x + pv[6] * y + pv[10] * z + pv[14]) / cw
399
+ }
400
+ ;(opaque as any[]).sort(renderer.sortOpaque)
401
+ ;(transparent as any[]).sort(renderer.sortTransparent)
402
+ ;(ui as any[]).sort(renderer.sortUI)
403
+ for (const node of opaque) node.draw({ camera })
404
+ for (const node of transparent) node.draw({ camera })
405
+ for (const node of ui) node.draw({ camera })
406
+ }
407
+
408
+ /** The shadow pass for the batches: every `all` instance of every casting batch through the
409
+ * instanced depth program (`shadowViewProjection` already set by the caller). */
410
+ const drawShadow = (depthInstanced: Program): void => {
411
+ for (const b of batches.values()) {
412
+ if (!b.castShadows || b.allCount === 0) continue
413
+ depthInstanced.use({ flipFaces: b.flip })
414
+ b.geoAll.draw({ mode: b.mode, program: depthInstanced })
415
+ }
416
+ }
417
+
418
+ /** Extend `min`/`max` by the world AABB of every casting batch (cached per batch). */
419
+ const casterBounds = (min: number[], max: number[]): void => {
420
+ for (const b of batches.values()) {
421
+ if (!b.castShadows || b.allCount === 0 || !isFinite(b.bounds[0])) continue
422
+ for (let k = 0; k < 3; k++) {
423
+ if (b.bounds[k] < min[k]) min[k] = b.bounds[k]
424
+ if (b.bounds[k + 3] > max[k]) max[k] = b.bounds[k + 3]
425
+ }
426
+ }
427
+ }
428
+
429
+ const dispose = (): void => {
430
+ for (const b of batches.values()) { disposeInstanceGeometry(b.geoAll); disposeInstanceGeometry(b.geoVis) }
431
+ batches.clear()
432
+ for (const t of twins.values()) t?.remove()
433
+ twins.clear()
434
+ }
435
+
436
+ /** Whether a drawable is drawn through a batch (so a caller enumerating singles skips it). */
437
+ const isBatched = (node: Mesh): boolean => batchable(node) !== null
438
+
439
+ return {
440
+ collect, drawMain, drawShadow, casterBounds, isBatched, dispose,
441
+ setEnabled: (v: boolean): void => { enabled = v; lastGraph = -1 },
442
+ /** This frame's individually drawn meshes (after `collect`). */
443
+ singles,
444
+ /** The hierarchy-visible singles that cast shadows (every one, not only the camera-visible). */
445
+ casterSingles,
446
+ /** Diagnostics: batch count / instance totals. */
447
+ stats: () => {
448
+ let all = 0, vis = 0, n = 0
449
+ for (const b of batches.values()) if (b.allCount > 0) { n++; all += b.allCount; vis += b.visCount }
450
+ return { batches: n, instances: all, visible: vis, singles: singles.length }
451
+ },
452
+ }
453
+ }