rayzee 7.31.1 → 8.1.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 (53) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6728 -4735
  3. package/dist/rayzee.umd.js +121 -73
  4. package/package.json +2 -2
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +151 -25
  8. package/src/Processor/ArchiveReader.js +538 -0
  9. package/src/Processor/AssetLoader.js +265 -23
  10. package/src/Processor/BVHBuilder.js +48 -32
  11. package/src/Processor/BVHRefitter.js +244 -99
  12. package/src/Processor/ChunkedRecords.js +302 -0
  13. package/src/Processor/EmissiveTriangleBuilder.js +48 -21
  14. package/src/Processor/GeometryExtractor.js +580 -93
  15. package/src/Processor/HostMemory.js +222 -0
  16. package/src/Processor/InstanceTable.js +548 -97
  17. package/src/Processor/PBRT/EqualAreaOctahedral.js +32 -8
  18. package/src/Processor/PBRT/PBRTCurves.js +249 -0
  19. package/src/Processor/PBRT/PBRTMath.js +19 -0
  20. package/src/Processor/PBRT/PBRTParser.js +187 -41
  21. package/src/Processor/PBRT/PBRTSceneBuilder.js +586 -42
  22. package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
  23. package/src/Processor/PBRT/index.js +257 -50
  24. package/src/Processor/PackedRayBuffer.js +29 -18
  25. package/src/Processor/ParallelBVHBuilder.js +9 -8
  26. package/src/Processor/QueueManager.js +5 -3
  27. package/src/Processor/SceneProcessor.js +1046 -365
  28. package/src/Processor/TLASBuilder.js +546 -201
  29. package/src/Processor/TextureCreator.js +11 -9
  30. package/src/Processor/VRAMTracker.js +12 -3
  31. package/src/Processor/Workers/BVHRefitWorker.js +16 -11
  32. package/src/Processor/Workers/BVHWorker.js +12 -12
  33. package/src/Processor/Workers/TLASWorker.js +37 -0
  34. package/src/Processor/utils.js +8 -20
  35. package/src/Stages/NormalDepth.js +6 -2
  36. package/src/Stages/PathTracer.js +5 -5
  37. package/src/Stages/PathTracerStage.js +163 -41
  38. package/src/TSL/BVHTraversal.js +208 -90
  39. package/src/TSL/Common.js +135 -2
  40. package/src/TSL/Displacement.js +21 -11
  41. package/src/TSL/EmissiveSampling.js +49 -25
  42. package/src/TSL/ExtendKernel.js +3 -2
  43. package/src/TSL/LightBVHSampling.js +7 -4
  44. package/src/TSL/LightsDirect.js +30 -29
  45. package/src/TSL/ShadeKernel.js +8 -6
  46. package/src/TSL/SortGlobalKernels.js +3 -4
  47. package/src/TSL/Struct.js +3 -0
  48. package/src/TSL/TextureSampling.js +24 -9
  49. package/src/TSL/patches.js +149 -84
  50. package/src/index.js +3 -0
  51. package/src/managers/AnimationManager.js +86 -76
  52. package/src/managers/MaterialDataManager.js +29 -23
  53. package/src/managers/TransformManager.js +9 -192
@@ -1,12 +1,24 @@
1
- import { Vector3, Vector2, Color, Matrix3, Matrix4, FrontSide, BackSide, DoubleSide, RGBAFormat } from "three";
2
- import { TEXTURE_CONSTANTS, TRIANGLE_DATA_LAYOUT } from '../EngineDefaults.js';
1
+ import { BufferAttribute, Vector3, Vector2, Color, Matrix3, Matrix4, FrontSide, BackSide, DoubleSide, RGBAFormat } from "three";
2
+ import {
3
+ TEXTURE_CONSTANTS, TRIANGLE_DATA_LAYOUT, packNormalOct, packTriangleFlags
4
+ } from '../EngineDefaults.js';
3
5
  import { ISSUE_CODES } from '../EngineIssues.js';
6
+ import { ChunkedRecords, SHARED_MEMORY_AVAILABLE } from './ChunkedRecords.js';
4
7
  import { createLogger, fmt, warnOnce } from '../utils/Logger.js';
5
8
 
6
9
  const log = createLogger( 'geometry' );
7
10
 
8
11
  const MAX_TEXTURES_LIMIT = TEXTURE_CONSTANTS.MAX_TEXTURES_LIMIT;
9
12
 
13
+ const IDENTITY_ELEMENTS = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ];
14
+
15
+ function isIdentityElements( e ) {
16
+
17
+ for ( let i = 0; i < 16; i ++ ) if ( e[ i ] !== IDENTITY_ELEMENTS[ i ] ) return false;
18
+ return true;
19
+
20
+ }
21
+
10
22
  /**
11
23
  * glTF 2.0 alphaMode for a three.js material: 0 OPAQUE, 1 MASK, 2 BLEND.
12
24
  *
@@ -54,6 +66,80 @@ export function deriveAlphaMode( material ) {
54
66
 
55
67
  }
56
68
 
69
+ /**
70
+ * Bytes the three.js geometry behind `object` holds, counting each `BufferGeometry` once.
71
+ *
72
+ * Instanced meshes share one geometry, so counting per placement would over-report by orders of
73
+ * magnitude on a scene with millions of placements. Worth re-reading after extraction:
74
+ * `_compressAttributes` halves normals and colours, which is ~370 MB on a 40M-triangle scene.
75
+ *
76
+ * @param {Object3D} object
77
+ * @returns {number}
78
+ */
79
+ export function geometryBytesOf( object ) {
80
+
81
+ let bytes = 0;
82
+ const counted = new Set();
83
+
84
+ const walk = node => {
85
+
86
+ const g = node.isMesh && node.material ? node.geometry : null;
87
+ if ( g && ! counted.has( g.uuid ) ) {
88
+
89
+ counted.add( g.uuid );
90
+ for ( const name in g.attributes ) bytes += g.attributes[ name ].array?.byteLength ?? 0;
91
+ bytes += g.index?.array?.byteLength ?? 0;
92
+
93
+ }
94
+
95
+ if ( node.children ) for ( const child of node.children ) walk( child );
96
+
97
+ };
98
+
99
+ walk( object );
100
+ return bytes;
101
+
102
+ }
103
+
104
+ // Affine point and linear direction transforms straight off the matrix elements: no perspective
105
+ // divide, no method dispatch, on the hottest per-vertex path of a baked mesh.
106
+ function affinePoint( v, e ) {
107
+
108
+ const x = v.x, y = v.y, z = v.z;
109
+ v.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z + e[ 12 ];
110
+ v.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z + e[ 13 ];
111
+ v.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z + e[ 14 ];
112
+
113
+ }
114
+
115
+ function linearDir( v, e ) {
116
+
117
+ const x = v.x, y = v.y, z = v.z;
118
+ v.x = e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z;
119
+ v.y = e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z;
120
+ v.z = e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z;
121
+
122
+ }
123
+
124
+ // Emissive instances become real triangles, so the expansion is capped.
125
+ const MAX_EXPANDED_EMISSIVE_TRIANGLES = 1 << 21;
126
+
127
+ // A skinned or morphed mesh is posed per copy, so it cannot share one set of triangles.
128
+ function isDeformable( mesh ) {
129
+
130
+ return !! ( mesh.isSkinnedMesh || Object.keys( mesh.geometry?.morphAttributes ?? {} ).length );
131
+
132
+ }
133
+
134
+ // Determinant of a column-major matrix's 3x3 basis; negative means the transform mirrors.
135
+ function determinant3( e ) {
136
+
137
+ return e[ 0 ] * ( e[ 5 ] * e[ 10 ] - e[ 9 ] * e[ 6 ] )
138
+ - e[ 4 ] * ( e[ 1 ] * e[ 10 ] - e[ 9 ] * e[ 2 ] )
139
+ + e[ 8 ] * ( e[ 1 ] * e[ 6 ] - e[ 5 ] * e[ 2 ] );
140
+
141
+ }
142
+
57
143
  export class GeometryExtractor {
58
144
 
59
145
  /** @param {{issues?: import('../EngineIssues.js').IssueLog}} [options] */
@@ -68,6 +154,9 @@ export class GeometryExtractor {
68
154
  vec2: Array( 6 ).fill().map( () => new Vector2() )
69
155
  };
70
156
 
157
+ this._geometryRanges = new Map();
158
+ this.expandedTriangleCount = 0;
159
+ this._allocatePlacements( 0 );
71
160
  this._matrixPool = {
72
161
  mat3: new Matrix3(),
73
162
  mat4: new Matrix4()
@@ -106,17 +195,198 @@ export class GeometryExtractor {
106
195
  // which overflowed the browser's ArrayBuffer allocator. Exact avoids both.
107
196
  const totalTriangles = this._countTriangles( object );
108
197
  this._triangleCapacity = Math.max( 1024, totalTriangles );
109
- this.triangleData = new Float32Array( this._triangleCapacity * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
198
+ this._allocateTriangles( this._triangleCapacity );
110
199
  this.currentTriangleIndex = 0;
111
200
 
201
+ // One `{ sourceMesh, matrixWorld }` object per placement cost 1.6 GB at 6M of them.
202
+ this._allocatePlacements( this._countPlacements( object ) );
203
+ this._geometryUses = this._countGeometryUses( object );
204
+ this.bakeInverse = new Map();
205
+
112
206
  // Single traversal: extract geometry, materials, lights, and cameras
113
207
  this.traverseObject( object );
208
+ this._shareInstanceMatrices();
209
+ this._compressAttributes( object );
114
210
 
115
211
  this.logStats();
116
212
  return this.getExtractedData();
117
213
 
118
214
  }
119
215
 
216
+ /**
217
+ * The sizes a memory preflight needs, without doing any of the work: what `extract()` would
218
+ * store, plus the three.js geometry already resident behind it.
219
+ *
220
+ * Geometry is counted once per unique `BufferGeometry` — instanced meshes share one, so
221
+ * counting per placement would over-report by orders of magnitude on a scene like Moana.
222
+ *
223
+ * @param {Object3D} object
224
+ * @returns {{triangles: number, placements: number, meshes: number, geometryBytes: number}}
225
+ */
226
+ surveyScene( object ) {
227
+
228
+ let meshes = 0;
229
+ const walk = node => {
230
+
231
+ if ( node.isMesh && node.geometry && node.material ) meshes ++;
232
+ if ( node.children ) for ( const child of node.children ) walk( child );
233
+
234
+ };
235
+
236
+ walk( object );
237
+
238
+ return {
239
+ triangles: this._countTriangles( object ),
240
+ placements: this._countPlacements( object ),
241
+ meshes,
242
+ geometryBytes: geometryBytesOf( object ),
243
+ };
244
+
245
+ }
246
+
247
+ /**
248
+ * How many meshes use each geometry+material pair. A pair used once has nothing to share, so
249
+ * it is baked to world space and its ray is never transformed. Walks everything, so a mesh
250
+ * the extraction later skips only ever over-counts — towards object space, never a bad bake.
251
+ * @private
252
+ */
253
+ _countGeometryUses( object ) {
254
+
255
+ const uses = new Map();
256
+
257
+ object.traverse( ( o ) => {
258
+
259
+ if ( ! o.isMesh || ! o.geometry || ! o.material ) return;
260
+ const key = `${o.geometry.uuid}|${o.material.uuid}`;
261
+ uses.set( key, ( uses.get( key ) ?? 0 ) + 1 );
262
+
263
+ } );
264
+
265
+ return uses;
266
+
267
+ }
268
+
269
+ // Placements extract() will record: one per mesh, or one per instance of an InstancedMesh.
270
+ _countPlacements( object ) {
271
+
272
+ let count = 0;
273
+
274
+ if ( object.isMesh && object.geometry && object.material ) {
275
+
276
+ count += object.isInstancedMesh && object.instanceMatrix
277
+ ? ( object.count ?? object.instanceMatrix.array.length / 16 )
278
+ : 1;
279
+
280
+ }
281
+
282
+ if ( object.children ) {
283
+
284
+ for ( const child of object.children ) count += this._countPlacements( child );
285
+
286
+ }
287
+
288
+ return count;
289
+
290
+ }
291
+
292
+ _allocatePlacements( count ) {
293
+
294
+ this.instanceCount = 0;
295
+ this.instanceCapacity = count;
296
+ this.instanceSource = new Int32Array( count );
297
+ this.instanceMatrices = new Float32Array( count * 16 );
298
+ this._shareable = [];
299
+ this._compressed = new Set();
300
+
301
+ }
302
+
303
+ /**
304
+ * Halve the scene graph's normal/tangent/colour attributes as normalized 16-bit integers.
305
+ *
306
+ * three.js denormalizes on read and uploads them as `snorm16`, so every CPU-side consumer
307
+ * keeps working; the triangle buffer already stores normals as a coarser oct16 pair.
308
+ * Positions and UVs keep the full float range.
309
+ * @private
310
+ */
311
+ _compressAttributes( object ) {
312
+
313
+ const done = this._compressed;
314
+
315
+ // A host's own Object3D is rendered as a copy, but the copy shares its geometry by
316
+ // reference, so rewriting an attribute there would rewrite the host's data. Its whole
317
+ // subtree is skipped rather than the root alone.
318
+ const walk = ( o, visit ) => {
319
+
320
+ if ( o.userData?.__rayzeeExternal ) return;
321
+ visit( o );
322
+ for ( const child of o.children ) walk( child, visit );
323
+
324
+ };
325
+
326
+ walk( object, o => {
327
+
328
+ const g = o.geometry;
329
+ if ( ! o.isMesh || ! g || done.has( g.uuid ) ) return;
330
+ done.add( g.uuid );
331
+
332
+ for ( const name of [ 'normal', 'tangent', 'color' ] ) {
333
+
334
+ const attr = g.getAttribute( name );
335
+ if ( ! attr || attr.normalized || ! ( attr.array instanceof Float32Array ) ) continue;
336
+ if ( g.morphAttributes?.[ name ]?.length ) continue;
337
+
338
+ const src = attr.array;
339
+ let ok = true;
340
+ for ( let i = 0; i < src.length; i ++ ) {
341
+
342
+ const v = src[ i ];
343
+ if ( ! ( v >= - 1.0001 && v <= 1.0001 ) ) {
344
+
345
+ ok = false; break;
346
+
347
+ }
348
+
349
+ }
350
+
351
+ // Out of snorm range (or NaN) — leave it as floats rather than silently clamp.
352
+ if ( ! ok ) continue;
353
+
354
+ const packed = new Int16Array( src.length );
355
+ for ( let i = 0; i < src.length; i ++ ) {
356
+
357
+ packed[ i ] = Math.round( Math.max( - 1, Math.min( 1, src[ i ] ) ) * 32767 );
358
+
359
+ }
360
+
361
+ g.setAttribute( name, new BufferAttribute( packed, attr.itemSize, true ) );
362
+
363
+ }
364
+
365
+ } );
366
+
367
+ }
368
+
369
+ /**
370
+ * Point each InstancedMesh's matrix attribute at the placement pool that duplicates it.
371
+ *
372
+ * With the host at the origin the two hold identical bytes — 366 MB apart at 6M instances.
373
+ * Runs after the traversal, since growth reallocates the pool.
374
+ * @private
375
+ */
376
+ _shareInstanceMatrices() {
377
+
378
+ for ( const { mesh, start, count } of this._shareable ) {
379
+
380
+ const attr = mesh.instanceMatrix;
381
+ if ( ! attr || attr.array.length !== count * 16 ) continue;
382
+ attr.array = this.instanceMatrices.subarray( start * 16, ( start + count ) * 16 );
383
+
384
+ }
385
+
386
+ this._shareable = [];
387
+
388
+ }
389
+
120
390
  // Sum the exact triangle count over every mesh extract() will process
121
391
  // (mirrors traverseObject/processMesh guards) so we can allocate once.
122
392
  _countTriangles( object ) {
@@ -148,13 +418,44 @@ export class GeometryExtractor {
148
418
 
149
419
  if ( needed <= this._triangleCapacity ) return;
150
420
 
151
- const newData = new Float32Array( needed * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
152
- newData.set( this.triangleData );
153
- this.triangleData = newData;
421
+ const previous = this.triangles;
422
+ const used = this.currentTriangleIndex;
423
+ this._allocateTriangles( needed );
424
+ for ( let t = 0; t < used; t ++ ) {
425
+
426
+ this.triangles.chunkFor( t ).set(
427
+ previous.chunkFor( t ).subarray( previous.baseOf( t ), previous.baseOf( t ) + previous.lanesPerRecord ),
428
+ this.triangles.baseOf( t )
429
+ );
430
+
431
+ }
432
+
154
433
  this._triangleCapacity = needed;
155
434
 
156
435
  }
157
436
 
437
+ /**
438
+ * The record is a uint buffer; positions and UVs are written as f32 through a view of the
439
+ * same memory, so a packed lane's bit pattern is never round-tripped through an f32.
440
+ *
441
+ * Chunked, because one array cannot hold more than ~26M triangles at 80 bytes each — the
442
+ * V8 ArrayBuffer cap, well below what the GPU buffer can take.
443
+ * @private
444
+ */
445
+ _allocateTriangles( capacity ) {
446
+
447
+ // Shared-backed so a refit reads and writes them in place; copying them into shared memory
448
+ // on first refit would be a second copy of the largest thing in the scene.
449
+ this.triangles = new ChunkedRecords(
450
+ capacity, TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE, Uint32Array,
451
+ undefined, SHARED_MEMORY_AVAILABLE
452
+ );
453
+ this.triangleFloatChunks = this.triangles.viewAs( Float32Array );
454
+ this.triangleData = this.triangles.single;
455
+ this.triangleFloats = this.triangleFloatChunks.single;
456
+
457
+ }
458
+
158
459
  traverseObject( object ) {
159
460
 
160
461
  // Process the current object
@@ -203,14 +504,190 @@ export class GeometryExtractor {
203
504
  this.meshes.push( mesh );
204
505
  mesh.userData.meshIndex = meshIndex;
205
506
 
206
- // Record triangle range start for this mesh (for TLAS/BLAS per-mesh BVH)
507
+ // Triangles are stored in object space, so two placements of the same geometry can
508
+ // point at one copy — and later at one BLAS. The material is part of the key because
509
+ // the material index is baked per triangle.
510
+ // Emissive geometry opts out: each placement is a separate light, and the light BVH
511
+ // keys its entries by triangle index, which sharing would make ambiguous. So does
512
+ // anything that deforms: a skinned or morphed copy needs triangles of its own, or an
513
+ // animation refit writes one copy's pose into every other.
514
+ const key = `${mesh.geometry.uuid}|${materialIndex}`;
515
+ const deformable = isDeformable( mesh );
516
+ const shareable = ! this._emissiveMaterial( materialIndex ) && ! deformable;
517
+ const shared = shareable ? this._geometryRanges.get( key ) : null;
518
+
519
+ // `expandedStart` is where this mesh's triangles sit in a per-mesh walk of the scene,
520
+ // which is the shape refit callers can build. `start` is where they are actually
521
+ // stored, which is the owner's copy when the geometry is shared.
522
+ if ( shared ) {
523
+
524
+ const range = {
525
+ start: shared.start, count: shared.count,
526
+ expandedStart: this.expandedTriangleCount, sharedFrom: shared.meshIndex
527
+ };
528
+ this.expandedTriangleCount += shared.count;
529
+ this.meshTriangleRanges.push( range );
530
+ this._recordPlacements( mesh, meshIndex );
531
+ return;
532
+
533
+ }
534
+
207
535
  const rangeStart = this.currentTriangleIndex;
208
536
 
209
- // Extract geometry with both material and mesh indices
210
- this.extractGeometry( mesh, materialIndex, meshIndex );
537
+ // Emission is measured per triangle, so instances of an emissive geometry have to be
538
+ // real triangles rather than one copy placed many times — otherwise the scene is lit by
539
+ // the first instance alone. Their triangles are baked, so this mesh keeps one placement.
540
+ const expand = mesh.isInstancedMesh && ! deformable
541
+ && this._emissiveMaterial( materialIndex ) && this._canExpand( mesh );
542
+
543
+ // Object space only pays when a second placement can reuse the copy. A geometry used
544
+ // once — or an emissive one, which never shares — is baked to world space instead, so
545
+ // a ray reaching it skips the per-instance transform entirely.
546
+ const bake = expand || ( ! mesh.isInstancedMesh && ! deformable && (
547
+ this._geometryUses.get( `${mesh.geometry.uuid}|${mesh.material?.uuid}` ) === 1
548
+ || this._emissiveMaterial( materialIndex )
549
+ ) );
550
+
551
+ this.extractGeometry( mesh, materialIndex, meshIndex, bake, expand );
552
+
553
+ const range = {
554
+ start: rangeStart, count: this.currentTriangleIndex - rangeStart,
555
+ expandedStart: this.expandedTriangleCount
556
+ };
557
+ this.expandedTriangleCount += range.count;
558
+ this.meshTriangleRanges.push( range );
559
+ if ( range.count > 0 && ! bake && shareable ) this._geometryRanges.set( key, { ...range, meshIndex } );
560
+ this._recordPlacements( mesh, meshIndex, bake, expand );
561
+
562
+ }
563
+
564
+ /**
565
+ * One placement per object, or one per instance for an InstancedMesh. The scene graph
566
+ * holds a single object either way — only this list grows, so a million placements cost
567
+ * a matrix each rather than a million Object3Ds.
568
+ * @private
569
+ */
570
+ _recordPlacements( mesh, meshIndex, baked = false, expanded = false ) {
571
+
572
+ const world = mesh.matrixWorld.elements;
573
+ const dst = this.instanceMatrices;
574
+ const src = this.instanceSource;
575
+
576
+ // Expanded instances live in the triangles, so the mesh keeps a single placement. The
577
+ // recorded inverse is the host's alone: moving the host is a delta that applies to
578
+ // every instance equally, which is exactly what composing against it produces.
579
+ if ( ! mesh.isInstancedMesh || ! mesh.instanceMatrix || expanded ) {
580
+
581
+ const p = this._nextPlacement();
582
+ src[ p ] = meshIndex;
583
+
584
+ if ( baked ) {
585
+
586
+ // The triangles already carry this pose, so the placement starts at identity. A
587
+ // later move composes against the inverse of what was baked in.
588
+ for ( let k = 0; k < 16; k ++ ) dst[ p * 16 + k ] = k % 5 === 0 ? 1 : 0;
589
+ this.bakeInverse.set( meshIndex, Float32Array.from( this._matrixPool.mat4.copy( mesh.matrixWorld ).invert().elements ) );
590
+
591
+ } else {
592
+
593
+ for ( let k = 0; k < 16; k ++ ) dst[ p * 16 + k ] = world[ k ];
594
+
595
+ }
596
+
597
+ return;
598
+
599
+ }
600
+
601
+ const arr = mesh.instanceMatrix.array;
602
+ const count = mesh.count ?? ( arr.length / 16 );
603
+ // A pbrt archive bakes the CTM into every instance, so the host needs no multiply.
604
+ const identityHost = isIdentityElements( world );
605
+ if ( identityHost && arr.length === count * 16 ) {
606
+
607
+ this._shareable.push( { mesh, start: this.instanceCount, count } );
608
+
609
+ }
610
+
611
+ for ( let i = 0; i < count; i ++ ) {
612
+
613
+ const o = i * 16;
614
+ const p = this._nextPlacement();
615
+ const d = p * 16;
616
+ src[ p ] = meshIndex;
617
+
618
+ if ( identityHost ) {
619
+
620
+ for ( let k = 0; k < 16; k ++ ) dst[ d + k ] = arr[ o + k ];
621
+ continue;
622
+
623
+ }
211
624
 
212
- // Record per-mesh triangle range
213
- this.meshTriangleRanges.push( { start: rangeStart, count: this.currentTriangleIndex - rangeStart } );
625
+ // world = mesh.matrixWorld * instanceMatrix, both column-major.
626
+ for ( let c = 0; c < 4; c ++ ) {
627
+
628
+ for ( let r = 0; r < 4; r ++ ) {
629
+
630
+ dst[ d + c * 4 + r ] = world[ r ] * arr[ o + c * 4 ]
631
+ + world[ 4 + r ] * arr[ o + c * 4 + 1 ]
632
+ + world[ 8 + r ] * arr[ o + c * 4 + 2 ]
633
+ + world[ 12 + r ] * arr[ o + c * 4 + 3 ];
634
+
635
+ }
636
+
637
+ }
638
+
639
+ }
640
+
641
+ }
642
+
643
+ /** Next free placement slot, growing only if the pre-count was short. @private */
644
+ _nextPlacement() {
645
+
646
+ if ( this.instanceCount >= this.instanceCapacity ) {
647
+
648
+ const grown = Math.max( 16, this.instanceCapacity * 2 );
649
+ const srcCol = new Int32Array( grown );
650
+ const matCol = new Float32Array( grown * 16 );
651
+ srcCol.set( this.instanceSource );
652
+ matCol.set( this.instanceMatrices );
653
+ this.instanceSource = srcCol;
654
+ this.instanceMatrices = matCol;
655
+ this.instanceCapacity = grown;
656
+
657
+ }
658
+
659
+ return this.instanceCount ++;
660
+
661
+ }
662
+
663
+ /** True when this material emits — those meshes keep their own triangles. */
664
+ /**
665
+ * Is expanding this instanced mesh into real triangles within budget? Past it the scene is
666
+ * lit by the first instance, which is wrong but bounded.
667
+ * @private
668
+ */
669
+ _canExpand( mesh ) {
670
+
671
+ const g = mesh.geometry;
672
+ const tris = ( g.index ? g.index.count : g.attributes.position.count ) / 3;
673
+ const count = mesh.count ?? ( mesh.instanceMatrix?.array.length ?? 0 ) / 16;
674
+ if ( tris * count <= MAX_EXPANDED_EMISSIVE_TRIANGLES ) return true;
675
+
676
+ this._issues?.record(
677
+ ISSUE_CODES.EMISSIVE_INSTANCES_COLLAPSED,
678
+ `"${mesh.name || 'instanced mesh'}" would need ${Math.round( tris * count ).toLocaleString()} triangles to light every instance; lighting the first one only`,
679
+ { instances: count, trianglesPerInstance: tris, limit: MAX_EXPANDED_EMISSIVE_TRIANGLES }
680
+ );
681
+ return false;
682
+
683
+ }
684
+
685
+ _emissiveMaterial( materialIndex ) {
686
+
687
+ const m = this.materials[ materialIndex ];
688
+ if ( ! m || ! ( m.emissiveIntensity > 0 ) ) return false;
689
+ const e = m.emissive;
690
+ return !! e && ( e.r > 0 || e.g > 0 || e.b > 0 );
214
691
 
215
692
  }
216
693
 
@@ -488,7 +965,9 @@ export class GeometryExtractor {
488
965
  getTextureMatrix( texture ) {
489
966
 
490
967
  if ( ! texture ) return new Matrix3().elements;
491
- texture.updateMatrix();
968
+ // GLTFLoader writes `matrix` itself for a rotated KHR_texture_transform (glTF is
969
+ // T·R·S, three is T·S·R) and clears the flag; updateMatrix() ignores the flag.
970
+ if ( texture.matrixAutoUpdate ) texture.updateMatrix();
492
971
  return texture.matrix.elements;
493
972
 
494
973
  }
@@ -548,7 +1027,7 @@ export class GeometryExtractor {
548
1027
 
549
1028
  }
550
1029
 
551
- extractGeometry( mesh, materialIndex, meshIndex ) {
1030
+ extractGeometry( mesh, materialIndex, meshIndex, bake = false, expandInstances = false ) {
552
1031
 
553
1032
  mesh.updateMatrix();
554
1033
  mesh.updateMatrixWorld();
@@ -560,19 +1039,42 @@ export class GeometryExtractor {
560
1039
  const uvs = geometry.attributes.uv;
561
1040
  const indices = geometry.index ? geometry.index.array : null;
562
1041
 
563
- // Compute matrices
564
- this._matrixPool.mat4.copy( mesh.matrixWorld );
565
- this._matrixPool.mat3.getNormalMatrix( this._matrixPool.mat4 );
566
-
567
1042
  const triangleCount = indices ? indices.length / 3 : positions.count / 3;
568
1043
 
569
- // Extract triangles with both material and mesh indices
570
- this.extractTrianglesInBatch( positions, normals, uvs, indices, triangleCount, materialIndex, meshIndex );
1044
+ if ( expandInstances ) {
1045
+
1046
+ const count = mesh.count ?? ( mesh.instanceMatrix.array.length / 16 );
1047
+ const inst = this._matrixPool.mat4;
1048
+ const world = new Matrix4();
1049
+
1050
+ for ( let i = 0; i < count; i ++ ) {
1051
+
1052
+ inst.fromArray( mesh.instanceMatrix.array, i * 16 );
1053
+ world.multiplyMatrices( mesh.matrixWorld, inst );
1054
+ const normalMatrix = new Matrix3().getNormalMatrix( world );
1055
+ this.extractTrianglesInBatch(
1056
+ positions, normals, uvs, indices, triangleCount, materialIndex, meshIndex,
1057
+ world, normalMatrix, determinant3( world.elements ) < 0
1058
+ );
1059
+
1060
+ }
1061
+
1062
+ return;
1063
+
1064
+ }
1065
+
1066
+ const bakeMatrix = bake ? this._matrixPool.mat4.copy( mesh.matrixWorld ) : null;
1067
+ const bakeNormal = bake ? this._matrixPool.mat3.getNormalMatrix( mesh.matrixWorld ) : null;
1068
+ // A mirroring transform reverses which way the vertices wind, so the face normal the
1069
+ // cross product yields would point into the surface. Swap two corners and it points out.
1070
+ const flip = bake && determinant3( bakeMatrix.elements ) < 0;
1071
+
1072
+ this.extractTrianglesInBatch( positions, normals, uvs, indices, triangleCount, materialIndex, meshIndex, bakeMatrix, bakeNormal, flip );
571
1073
 
572
1074
  }
573
1075
 
574
1076
  // triangle extraction that stores directly in texture format
575
- extractTrianglesInBatch( positions, normals, uvs, indices, triangleCount, materialIndex, meshIndex ) {
1077
+ extractTrianglesInBatch( positions, normals, uvs, indices, triangleCount, materialIndex, meshIndex, bakeMatrix = null, bakeNormal = null, flipWinding = false ) {
576
1078
 
577
1079
  // Track per-material triangle count for sort-bin remap (item 41)
578
1080
  while ( this.materialTriangleCounts.length <= materialIndex ) this.materialTriangleCounts.push( 0 );
@@ -594,13 +1096,16 @@ export class GeometryExtractor {
594
1096
  // Ensure capacity for this batch up front (single grow check per mesh)
595
1097
  this._ensureCapacity( this.currentTriangleIndex + triangleCount );
596
1098
 
1099
+ const bm = bakeMatrix?.elements, bn = bakeNormal?.elements;
1100
+
597
1101
  // Batch process triangles to avoid excessive function calls
598
1102
  for ( let i = 0; i < triangleCount; i ++ ) {
599
1103
 
600
1104
  const i3 = i * 3;
601
1105
  const idxA = indices ? indices[ i3 + 0 ] : i3 + 0;
602
- const idxB = indices ? indices[ i3 + 1 ] : i3 + 1;
603
- const idxC = indices ? indices[ i3 + 2 ] : i3 + 2;
1106
+ const b = flipWinding ? 2 : 1, c = flipWinding ? 1 : 2;
1107
+ const idxB = indices ? indices[ i3 + b ] : i3 + b;
1108
+ const idxC = indices ? indices[ i3 + c ] : i3 + c;
604
1109
 
605
1110
  this.getVertex( positions, idxA, posA );
606
1111
  this.getVertex( positions, idxB, posB );
@@ -624,14 +1129,18 @@ export class GeometryExtractor {
624
1129
 
625
1130
  }
626
1131
 
627
- // Apply world transformation
628
- posA.applyMatrix4( this._matrixPool.mat4 );
629
- posB.applyMatrix4( this._matrixPool.mat4 );
630
- posC.applyMatrix4( this._matrixPool.mat4 );
1132
+ // Shared geometry stays in object space, its transform on the TLAS leaf, so every
1133
+ // placement reads one copy. Single-use geometry is baked to world here instead.
1134
+ if ( bakeMatrix ) {
631
1135
 
632
- normalA.applyMatrix3( this._matrixPool.mat3 ).normalize();
633
- normalB.applyMatrix3( this._matrixPool.mat3 ).normalize();
634
- normalC.applyMatrix3( this._matrixPool.mat3 ).normalize();
1136
+ affinePoint( posA, bm ); affinePoint( posB, bm ); affinePoint( posC, bm );
1137
+ linearDir( normalA, bn ); linearDir( normalB, bn ); linearDir( normalC, bn );
1138
+
1139
+ }
1140
+
1141
+ normalA.normalize();
1142
+ normalB.normalize();
1143
+ normalC.normalize();
635
1144
 
636
1145
  // Pack triangle datas
637
1146
  this.packTriangleDataTextureFormat(
@@ -649,82 +1158,48 @@ export class GeometryExtractor {
649
1158
 
650
1159
  }
651
1160
 
652
- // Pack triangle data directly in texture format (32 floats with vec4 alignment)
1161
+ // Pack one triangle into its 5 uvec4 lanes (see TRIANGLE_DATA_LAYOUT).
653
1162
  packTriangleDataTextureFormat( triangleIndex, posA, posB, posC, normalA, normalB, normalC, uvA, uvB, uvC, materialIndex, meshIndex ) {
654
1163
 
655
- const offset = triangleIndex * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
656
-
657
- // Positions as vec4s (3 vec4s = 12 floats)
658
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET + 0 ] = posA.x;
659
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET + 1 ] = posA.y;
660
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET + 2 ] = posA.z;
661
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET + 3 ] = 0; // vec4 padding
662
-
663
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET + 0 ] = posB.x;
664
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET + 1 ] = posB.y;
665
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET + 2 ] = posB.z;
666
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET + 3 ] = 0; // vec4 padding
667
-
668
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET + 0 ] = posC.x;
669
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET + 1 ] = posC.y;
670
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET + 2 ] = posC.z;
671
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET + 3 ] = 0; // vec4 padding
672
-
673
- // Normals as vec4s (3 vec4s = 12 floats)
674
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 0 ] = normalA.x;
675
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 1 ] = normalA.y;
676
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 2 ] = normalA.z;
677
- // Repurposed padding: opaque-blocker fast-path flag for shadow rays.
678
- // 1.0 = surface fully blocks light (no alpha, transmission, or transparency) →
679
- // traceShadowRay can skip the 7-slot getShadowMaterial fetch.
680
- // 0.0 = requires full material evaluation.
681
- {
682
-
683
- const mat = this.materials[ materialIndex ];
684
- const isOpaqueBlocker = mat
685
- && ( mat.alphaMode | 0 ) === 0
686
- && ( mat.transparent | 0 ) === 0
687
- && ( mat.transmission || 0 ) === 0
688
- && ( mat.opacity ?? 1 ) >= 1
689
- ? 1.0 : 0.0;
690
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_A_OFFSET + 3 ] = isOpaqueBlocker;
1164
+ const L = TRIANGLE_DATA_LAYOUT;
1165
+ // One chunk resolve per triangle; its 20 lanes are contiguous inside that chunk.
1166
+ const offset = this.triangles.baseOf( triangleIndex );
1167
+ const f = this.triangleFloatChunks.chunkFor( triangleIndex );
1168
+ const u = this.triangles.chunkFor( triangleIndex );
691
1169
 
692
- }
1170
+ f[ offset + L.POSITION_A_OFFSET + 0 ] = posA.x;
1171
+ f[ offset + L.POSITION_A_OFFSET + 1 ] = posA.y;
1172
+ f[ offset + L.POSITION_A_OFFSET + 2 ] = posA.z;
1173
+ u[ offset + L.NORMAL_A_PACKED_OFFSET ] = packNormalOct( normalA.x, normalA.y, normalA.z );
1174
+
1175
+ f[ offset + L.POSITION_B_OFFSET + 0 ] = posB.x;
1176
+ f[ offset + L.POSITION_B_OFFSET + 1 ] = posB.y;
1177
+ f[ offset + L.POSITION_B_OFFSET + 2 ] = posB.z;
1178
+ u[ offset + L.NORMAL_B_PACKED_OFFSET ] = packNormalOct( normalB.x, normalB.y, normalB.z );
693
1179
 
694
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 0 ] = normalB.x;
695
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 1 ] = normalB.y;
696
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 2 ] = normalB.z;
697
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_B_OFFSET + 3 ] = 0; // vec4 padding
1180
+ f[ offset + L.POSITION_C_OFFSET + 0 ] = posC.x;
1181
+ f[ offset + L.POSITION_C_OFFSET + 1 ] = posC.y;
1182
+ f[ offset + L.POSITION_C_OFFSET + 2 ] = posC.z;
1183
+ u[ offset + L.NORMAL_C_PACKED_OFFSET ] = packNormalOct( normalC.x, normalC.y, normalC.z );
698
1184
 
699
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 0 ] = normalC.x;
700
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 1 ] = normalC.y;
701
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 2 ] = normalC.z;
702
- // Repurposed padding: per-triangle side flag (0=front, 1=back, 2=double).
703
- // Lets BVH traversal do side culling without a material-buffer read per hit.
704
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.NORMAL_C_OFFSET + 3 ] = this.materials[ materialIndex ]?.side ?? 0;
1185
+ f[ offset + L.UV_AB_OFFSET + 0 ] = uvA.x;
1186
+ f[ offset + L.UV_AB_OFFSET + 1 ] = uvA.y;
1187
+ f[ offset + L.UV_AB_OFFSET + 2 ] = uvB.x;
1188
+ f[ offset + L.UV_AB_OFFSET + 3 ] = uvB.y;
705
1189
 
706
- // UVs and material index (2 vec4s = 8 floats)
707
- // First vec4: uvA.x, uvA.y, uvB.x, uvB.y
708
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_AB_OFFSET + 0 ] = uvA.x;
709
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_AB_OFFSET + 1 ] = uvA.y;
710
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_AB_OFFSET + 2 ] = uvB.x;
711
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_AB_OFFSET + 3 ] = uvB.y;
1190
+ f[ offset + L.UV_C_OFFSET + 0 ] = uvC.x;
1191
+ f[ offset + L.UV_C_OFFSET + 1 ] = uvC.y;
712
1192
 
713
- // Second vec4: uvC.x, uvC.y, materialIndex, meshIndex
714
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 0 ] = uvC.x;
715
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 1 ] = uvC.y;
716
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 2 ] = materialIndex;
717
- this.triangleData[ offset + TRIANGLE_DATA_LAYOUT.UV_C_MAT_OFFSET + 3 ] = meshIndex; // Store mesh index
1193
+ u[ offset + L.MATERIAL_FLAGS_OFFSET ] = packTriangleFlags( materialIndex, this.materials[ materialIndex ] );
1194
+ u[ offset + L.MESH_INDEX_OFFSET ] = meshIndex;
718
1195
 
719
1196
  }
720
1197
 
721
- // Get the raw Float32Array (optimal for worker transfer and zero-copy textures)
1198
+ /** The filled triangle records, as one chunk when they fit and several when they do not. */
722
1199
  getTriangleData() {
723
1200
 
724
- if ( ! this.triangleData ) return null;
725
-
726
- // Return only the used portion of the array
727
- return this.triangleData.subarray( 0, this.currentTriangleIndex * TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE );
1201
+ if ( ! this.triangles ) return null;
1202
+ return this.triangles.trimTo( this.currentTriangleIndex );
728
1203
 
729
1204
  }
730
1205
 
@@ -819,7 +1294,10 @@ export class GeometryExtractor {
819
1294
  this._droppedTextures = 0;
820
1295
 
821
1296
  // Reset triangle data
1297
+ this.triangles = null;
822
1298
  this.triangleData = null;
1299
+ this.triangleFloatChunks = null;
1300
+ this.triangleFloats = null;
823
1301
  this.triangleCount = 0;
824
1302
  this.currentTriangleIndex = 0;
825
1303
 
@@ -828,6 +1306,9 @@ export class GeometryExtractor {
828
1306
  this.materialTriangleCounts = []; // Per-material triangle count (for sort-bin remap, item 41)
829
1307
  this.meshes = [];
830
1308
  this.meshTriangleRanges = []; // Per-mesh { start, count } for TLAS/BLAS
1309
+ this._geometryRanges = new Map(); // geometry+material -> the range that already holds it
1310
+ this.expandedTriangleCount = 0; // triangles as a per-mesh walk would count them
1311
+ this._allocatePlacements( 0 ); // SoA placements: sourceMesh column + 16 floats each
831
1312
  this.maps = [];
832
1313
  this.normalMaps = [];
833
1314
  this.bumpMaps = [];
@@ -863,6 +1344,12 @@ export class GeometryExtractor {
863
1344
  materialTriangleCounts: this.materialTriangleCounts,
864
1345
  meshes: this.meshes,
865
1346
  meshTriangleRanges: this.meshTriangleRanges, // Per-mesh { start, count } for TLAS/BLAS
1347
+ expandedTriangleCount: this.expandedTriangleCount,
1348
+ instanceSource: this.instanceSource.subarray( 0, this.instanceCount ),
1349
+ instanceMatrices: this.instanceMatrices.subarray( 0, this.instanceCount * 16 ),
1350
+ instanceCount: this.instanceCount,
1351
+ // meshIndex -> inverse of the pose baked into its triangles; only baked meshes appear.
1352
+ bakeInverse: this.bakeInverse,
866
1353
  maps: this.maps,
867
1354
  normalMaps: this.normalMaps,
868
1355
  bumpMaps: this.bumpMaps,