rayzee 7.31.1 → 8.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 (51) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6366 -4361
  3. package/dist/rayzee.umd.js +122 -73
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +5 -0
  7. package/src/PathTracerApp.js +120 -19
  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 +577 -92
  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/Stages/NormalDepth.js +6 -2
  35. package/src/Stages/PathTracer.js +5 -5
  36. package/src/Stages/PathTracerStage.js +163 -41
  37. package/src/TSL/BVHTraversal.js +208 -90
  38. package/src/TSL/Common.js +135 -2
  39. package/src/TSL/Displacement.js +21 -11
  40. package/src/TSL/EmissiveSampling.js +49 -25
  41. package/src/TSL/ExtendKernel.js +3 -2
  42. package/src/TSL/LightBVHSampling.js +7 -4
  43. package/src/TSL/LightsDirect.js +30 -29
  44. package/src/TSL/ShadeKernel.js +8 -6
  45. package/src/TSL/Struct.js +3 -0
  46. package/src/TSL/TextureSampling.js +24 -9
  47. package/src/TSL/patches.js +128 -3
  48. package/src/index.js +3 -0
  49. package/src/managers/AnimationManager.js +86 -76
  50. package/src/managers/MaterialDataManager.js +29 -23
  51. package/src/managers/TransformManager.js +9 -192
@@ -14,16 +14,133 @@
14
14
  */
15
15
 
16
16
  import {
17
- Group, Mesh, PerspectiveCamera, Matrix4, Vector3,
17
+ Group, Mesh, InstancedMesh, PerspectiveCamera, Matrix4, Vector3,
18
18
  BufferGeometry, Float32BufferAttribute, Uint32BufferAttribute, SphereGeometry,
19
- DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping
19
+ DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping,
20
+ SRGBColorSpace
20
21
  } from 'three';
21
22
  import { buildMaterial, pFloat, pString, resolveSpectrum } from './PBRTMaterials.js';
22
23
  import { loopSubdivide } from './LoopSubdivision.js';
23
24
  import { octahedralToEquirect } from './EqualAreaOctahedral.js';
25
+ import { tessellateCurve } from './PBRTCurves.js';
24
26
  import * as M from './PBRTMath.js';
25
27
 
26
28
  const FLIP_Z = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, - 1, 0, 0, 0, 0, 1 ];
29
+ const MAX_SKY_READBACK = 4096;
30
+
31
+ // setIndex() only wraps a plain Array; hand it a typed one and it lands on the geometry
32
+ // raw, with no .count, and every consumer downstream reads NaN.
33
+ function indexAttribute( indices ) {
34
+
35
+ if ( indices instanceof Uint32Array ) return new Uint32BufferAttribute( indices, 1 );
36
+ if ( ArrayBuffer.isView( indices ) && indices.BYTES_PER_ELEMENT === 4 ) {
37
+
38
+ return new Uint32BufferAttribute( new Uint32Array( indices.buffer, indices.byteOffset, indices.length ), 1 );
39
+
40
+ }
41
+
42
+ return new Uint32BufferAttribute( Uint32Array.from( indices ), 1 );
43
+
44
+ }
45
+
46
+ function grow( array, needed ) {
47
+
48
+ if ( needed <= array.length ) return array;
49
+ let length = array.length || 1;
50
+ while ( length < needed ) length *= 2;
51
+ const out = new array.constructor( length );
52
+ out.set( array );
53
+ return out;
54
+
55
+ }
56
+
57
+ /** Reuse a Float32Array parameter as-is; the IR is discarded once the scene is built. */
58
+ function float32( values ) {
59
+
60
+ return values instanceof Float32Array ? values : Float32Array.from( values );
61
+
62
+ }
63
+
64
+ // The per-mesh table is a debug aid; an instanced scene reaches tens of millions of rows.
65
+ const MAX_REPORT_ROWS = 1000;
66
+ // Triangles are chunked across several arrays now, so the 2 GB V8 cap no longer bounds them at
67
+ // 26.8M, and the GPU ceiling is higher still: one storage buffer of 4,096 MB at 80 B a triangle
68
+ // is 53.7M. What actually binds is CPU address space, measured on Moana: 40M (7.3 GB) and 45M
69
+ // (8.5 GB) load and render, 50M dies at 9.4 GB resident with nothing thrown and nothing logged.
70
+ // So the budget sits on the highest rung that survived — past it placements are skipped and the
71
+ // scene is reported as truncated, which beats taking the tab down. Raise `maxTriangles` per load
72
+ // to go further, on a freshly started browser.
73
+ const DEFAULT_TRIANGLE_BUDGET = 45_000_000;
74
+ // Placements are their own budget: geometry is shared, but each costs a TLAS leaf and an
75
+ // instance record. isCoastline's 5.09M loaded and rendered at 23 fps.
76
+ const DEFAULT_PLACEMENT_BUDGET = 6_000_000;
77
+ // Non-instanced shapes merge into a few large meshes: isPalmRig declares 173,338 of them,
78
+ // and one BufferGeometry + Mesh each measured 3.3 GB — the loader OOM'd before tracing.
79
+ const DEFAULT_MERGE_MIN_SHAPES = 256;
80
+ const MERGE_VERTEX_LIMIT = 4096;
81
+ const MERGE_BATCH_TRIANGLES = 500_000;
82
+ // Curve tessellation defaults. pbrt sweeps a spline analytically; a triangle tracer has to
83
+ // pick a resolution, and isMountainB alone declares 5.18M curves — two samples per span and
84
+ // a crossed pair of ribbons is 16 triangles a curve.
85
+ const DEFAULT_CURVE_STEPS = 2;
86
+ const DEFAULT_CURVE_SIDES = { flat: 1, ribbon: 1, cylinder: 2 };
87
+
88
+ /** Pixels out of an HTMLImageElement / ImageBitmap, which expose none of their own. */
89
+ function pixelsFromDrawable( image, srgb ) {
90
+
91
+ const size = Math.min( image.width, MAX_SKY_READBACK );
92
+ let canvas = null;
93
+
94
+ if ( typeof OffscreenCanvas !== 'undefined' ) canvas = new OffscreenCanvas( size, size );
95
+ else if ( typeof document !== 'undefined' ) {
96
+
97
+ canvas = document.createElement( 'canvas' );
98
+ canvas.width = canvas.height = size;
99
+
100
+ } else return null;
101
+
102
+ try {
103
+
104
+ const ctx = canvas.getContext( '2d', { willReadFrequently: true } );
105
+ if ( ! ctx ) return null;
106
+ ctx.drawImage( image, 0, 0, size, size );
107
+ const { data } = ctx.getImageData( 0, 0, size, size );
108
+ return { data, width: size, height: size, channels: 4, bottomUp: false, srgb };
109
+
110
+ } catch {
111
+
112
+ return null;
113
+
114
+ }
115
+
116
+ }
117
+
118
+ /**
119
+ * Names an unreadable texture may have been derived from. Moana binds colour as
120
+ * `"texture reflectance" "el:part_Color"` and leaves `MakeNamedMaterial "el:part"` in the
121
+ * shared library, so the colour survives the .ptx files being a separate download.
122
+ */
123
+ function materialNameCandidates( texName ) {
124
+
125
+ const out = [ texName ];
126
+ let n = texName;
127
+ const strips = [
128
+ v => v.replace( /-renamed-\d+$/, '' ),
129
+ v => v.replace( /\d+$/, '' ),
130
+ v => v.replace( /_Color$/i, '' ),
131
+ v => v.replace( /\d+$/, '' )
132
+ ];
133
+
134
+ for ( const strip of strips ) {
135
+
136
+ const next = strip( n );
137
+ if ( next !== n && next ) out.push( n = next );
138
+
139
+ }
140
+
141
+ return out;
142
+
143
+ }
27
144
 
28
145
  export class PBRTSceneBuilder {
29
146
 
@@ -36,6 +153,11 @@ export class PBRTSceneBuilder {
36
153
  */
37
154
  constructor( resolvers = {} ) {
38
155
 
156
+ this.maxTriangles = resolvers.maxTriangles ?? DEFAULT_TRIANGLE_BUDGET;
157
+ this.maxPlacements = resolvers.maxPlacements ?? DEFAULT_PLACEMENT_BUDGET;
158
+ this.mergeShapesAbove = resolvers.mergeShapesAbove ?? DEFAULT_MERGE_MIN_SHAPES;
159
+ this.curveSteps = resolvers.curveSteps ?? DEFAULT_CURVE_STEPS;
160
+ this.curveSides = resolvers.curveSides ?? null;
39
161
  this.resolvePLY = resolvers.resolvePLY || ( async () => null );
40
162
  this.resolveImage = resolvers.resolveImage || ( async () => null );
41
163
  this.resolveEnvironment = resolvers.resolveEnvironment || resolvers.resolveImage || ( async () => null );
@@ -45,6 +167,10 @@ export class PBRTSceneBuilder {
45
167
  this.report = []; // per-mesh diagnostics for debugging imports
46
168
  this._materialCache = new Map(); // material obj -> Map(areaLight obj -> MeshPhysicalMaterial)
47
169
  this._textureCache = new Map(); // texName -> { texture } | { constant }
170
+ this._geometryCache = new Map(); // shape object -> Promise<BufferGeometry|null>
171
+ this._materialBySignature = new Map(); // visual signature -> shared MeshPhysicalMaterial
172
+ this._noUVMaterials = new Map(); // textured material -> its map-less clone
173
+ this._plyCache = new Map(); // filename -> Promise<BufferGeometry|null>
48
174
 
49
175
  }
50
176
 
@@ -61,19 +187,71 @@ export class PBRTSceneBuilder {
61
187
  async build( ir ) {
62
188
 
63
189
  this.ir = ir;
190
+ this._recoveredColors = 0;
191
+ this.reportedMeshes = 0;
192
+ this.triangleCount = 0; // stored triangles — a shared geometry counts once
193
+ this.placementCount = 0;
194
+ this.skippedForBudget = 0;
195
+ this._countedGeometries = new WeakSet();
64
196
  const group = new Group();
65
197
  group.name = 'PBRTScene';
66
198
 
199
+ this._batches = ir.shapes.length >= this.mergeShapesAbove ? new Map() : null;
200
+ this._mergedBatches = 0;
201
+ this.mergedShapes = 0;
202
+ this.droppedNoTemplate = 0;
203
+
67
204
  // Shapes (direct + instanced)
68
205
  for ( let i = 0; i < ir.shapes.length; i ++ ) {
69
206
 
70
- const mesh = await this._buildShapeMesh( ir.shapes[ i ], ir.shapes[ i ].ctm, `shape_${i}` );
207
+ if ( this._overBudget() ) {
208
+
209
+ this.skippedForBudget += ir.shapes.length - i;
210
+ break;
211
+
212
+ }
213
+
214
+ const shape = ir.shapes[ i ];
215
+ const [ geometry, sharedMaterial ] = await Promise.all( [
216
+ this._batches ? this._createGeometry( shape ) : this._buildGeometry( shape ),
217
+ this._getMaterial( shape )
218
+ ] );
219
+ if ( ! geometry ) continue;
220
+
221
+ if ( this._batches && geometry.getAttribute( 'position' ).count <= MERGE_VERTEX_LIMIT ) {
222
+
223
+ this._mergeShape( shape, geometry, sharedMaterial, group );
224
+ ir.shapes[ i ] = null; // its parsed arrays are copied out; let them go
225
+ continue;
226
+
227
+ }
228
+
229
+ const mesh = this._meshFromGeometry( shape, geometry, sharedMaterial, shape.ctm, `shape_${i}` );
71
230
  if ( mesh ) group.add( mesh );
72
231
 
73
232
  }
74
233
 
234
+ if ( this._batches ) {
235
+
236
+ for ( const slot of this._batches.values() ) {
237
+
238
+ this._flushBatch( slot[ 0 ], group );
239
+ this._flushBatch( slot[ 1 ], group );
240
+
241
+ }
242
+
243
+ this._batches = null;
244
+
245
+ }
246
+
75
247
  await this._buildInstances( ir, group );
76
248
 
249
+ if ( this.skippedForBudget > 0 ) this.warn(
250
+ `stopped at ${this.triangleCount.toLocaleString()} stored triangles ` +
251
+ `(budget ${this.maxTriangles.toLocaleString()}) and ${this.placementCount.toLocaleString()} placements ` +
252
+ `(budget ${this.maxPlacements.toLocaleString()}); ${this.skippedForBudget.toLocaleString()} placement(s) skipped`
253
+ );
254
+
77
255
  // Camera
78
256
  let camera = null;
79
257
  if ( ir.camera ) {
@@ -88,9 +266,19 @@ export class PBRTSceneBuilder {
88
266
 
89
267
  this._reportUnsupportedLights( ir.lights );
90
268
 
269
+ if ( this._recoveredColors > 0 ) this.warn(
270
+ `${this._recoveredColors} unreadable texture(s) fell back to the like-named material's colour`
271
+ );
272
+
91
273
  return {
92
274
  group, camera, environment,
93
275
  report: this.report,
276
+ meshCount: this.reportedMeshes,
277
+ triangleCount: this.triangleCount,
278
+ placementCount: this.placementCount,
279
+ mergedShapes: this.mergedShapes,
280
+ droppedNoTemplate: this.droppedNoTemplate,
281
+ skippedForBudget: this.skippedForBudget,
94
282
  warnings: this.warnings.concat( ir.warnings || [] )
95
283
  };
96
284
 
@@ -98,24 +286,108 @@ export class PBRTSceneBuilder {
98
286
 
99
287
  // ── shapes ─────────────────────────────────────────────────────
100
288
 
289
+ _overBudget() {
290
+
291
+ return this.triangleCount >= this.maxTriangles || this.placementCount >= this.maxPlacements;
292
+
293
+ }
294
+
295
+ /**
296
+ * Charge a geometry's triangles to the storage budget once, however many placements use
297
+ * it. Returns its triangle count either way.
298
+ * @private
299
+ */
300
+ _accountGeometry( geometry ) {
301
+
302
+ const tris = geometry.index ? geometry.index.count / 3 : geometry.getAttribute( 'position' ).count / 3;
303
+ if ( ! this._countedGeometries.has( geometry ) ) {
304
+
305
+ this._countedGeometries.add( geometry );
306
+ this.triangleCount += tris;
307
+
308
+ }
309
+
310
+ return tris;
311
+
312
+ }
313
+
314
+ /**
315
+ * Placements become InstancedMesh batches: one object per template shape, carrying a
316
+ * matrix per placement. Moana's beach ground cover alone is 21 million placements, and a
317
+ * Three.js object each would be the whole budget before a triangle is traced.
318
+ */
101
319
  async _buildInstances( ir, group ) {
102
320
 
103
321
  let n = 0;
104
- for ( const inst of ir.instances ) {
322
+ if ( ir.skippedInstances > 0 ) this.skippedForBudget += ir.skippedInstances;
323
+
324
+ const scratch = new Float64Array( 16 );
325
+ const matrix = new Matrix4();
326
+
327
+ for ( const [ name, list ] of ir.instances ) {
105
328
 
106
- const template = ir.objects.get( inst.name );
329
+ const template = ir.objects.get( name );
107
330
  if ( ! template ) {
108
331
 
109
- this.warn( `ObjectInstance "${inst.name}" has no template` ); continue;
332
+ // Counted, not just warned: these are placements that silently leave the scene,
333
+ // and a caller reading skippedForBudget would otherwise be told nothing was lost.
334
+ this.droppedNoTemplate += list.count;
335
+ this.warn( `ObjectInstance "${name}" has no template — ${list.count.toLocaleString()} placement(s) dropped` );
336
+ continue;
110
337
 
111
338
  }
112
339
 
113
340
  for ( const shape of template ) {
114
341
 
115
- // instance placement: instanceCTM * (shape relative to its ObjectBegin frame)
116
- const worldCTM = M.multiply( inst.ctm, shape.relativeCTM || shape.ctm );
117
- const mesh = await this._buildShapeMesh( shape, worldCTM, `instance_${n ++}` );
118
- if ( mesh ) group.add( mesh );
342
+ if ( this._overBudget() ) {
343
+
344
+ this.skippedForBudget += list.count;
345
+ continue;
346
+
347
+ }
348
+
349
+ const [ geometry, sharedMaterial ] = await Promise.all( [
350
+ this._buildGeometry( shape ),
351
+ this._getMaterial( shape )
352
+ ] );
353
+ if ( ! geometry ) continue;
354
+
355
+ const tris = this._accountGeometry( geometry );
356
+ const affordable = Math.max( 0, this.maxPlacements - this.placementCount );
357
+ const count = Math.min( list.count, affordable );
358
+ if ( count < list.count ) this.skippedForBudget += list.count - count;
359
+ if ( count === 0 ) continue;
360
+
361
+ const material = this._materialForGeometry( shape, geometry, sharedMaterial, `instances_${name}` );
362
+ const mesh = new InstancedMesh( geometry, material, count );
363
+ mesh.name = `instance_${n ++}`;
364
+ mesh.frustumCulled = false;
365
+
366
+ const rel = shape.relativeCTM || shape.ctm;
367
+ for ( let i = 0; i < count; i ++ ) {
368
+
369
+ M.multiplyInto( scratch, list.matrices, i * 16, rel );
370
+ mesh.setMatrixAt( i, matrix.fromArray( this.convertHandedness ? M.multiply( FLIP_Z, scratch ) : scratch ) );
371
+
372
+ }
373
+
374
+ mesh.instanceMatrix.needsUpdate = true;
375
+ group.add( mesh );
376
+
377
+ this.placementCount += count;
378
+ this.reportedMeshes += count;
379
+ if ( this.report.length < MAX_REPORT_ROWS ) this.report.push( {
380
+ mesh: `${mesh.name} ×${count}`,
381
+ shape: shape.type,
382
+ material: shape.material?.type || 'diffuse',
383
+ color: '#' + material.color.getHexString(),
384
+ map: material.map ? 'yes' : '-',
385
+ uv: geometry.getAttribute( 'uv' ) ? 'yes' : 'NO',
386
+ normals: geometry.getAttribute( 'normal' ) ? 'yes' : 'NO',
387
+ emissive: material.emissiveIntensity > 0 ? `#${material.emissive.getHexString()}×${material.emissiveIntensity}` : '-',
388
+ size: `instanced`,
389
+ tris,
390
+ } );
119
391
 
120
392
  }
121
393
 
@@ -131,21 +403,14 @@ export class PBRTSceneBuilder {
131
403
  this._getMaterial( shape )
132
404
  ] );
133
405
  if ( ! geometry ) return null;
406
+ return this._meshFromGeometry( shape, geometry, sharedMaterial, ctm, name );
134
407
 
135
- // A textured material on geometry with no UVs samples a single texel — the
136
- // usual cause of "black"/wrong meshes on import. Drop the map, but on a CLONE:
137
- // _getMaterial caches and shares one instance across every shape using the
138
- // same NamedMaterial, so mutating it would strip the texture from sibling
139
- // meshes that DO have UVs.
140
- const hasUV = !! geometry.getAttribute( 'uv' );
141
- let material = sharedMaterial;
142
- if ( sharedMaterial.map && ! hasUV ) {
408
+ }
143
409
 
144
- this.warn( `${name} (${shape.type}, "${shape.material?.type || 'diffuse'}") has a texture map but no UVs — dropping map, using base color` );
145
- material = sharedMaterial.clone();
146
- material.map = null;
410
+ _meshFromGeometry( shape, geometry, sharedMaterial, ctm, name ) {
147
411
 
148
- }
412
+ const hasUV = !! geometry.getAttribute( 'uv' );
413
+ const material = this._materialForGeometry( shape, geometry, sharedMaterial, name );
149
414
 
150
415
  const mesh = new Mesh( geometry, material );
151
416
  mesh.name = name;
@@ -161,12 +426,15 @@ export class PBRTSceneBuilder {
161
426
 
162
427
  // World-space dimensions (object bbox transformed by the baked matrix) —
163
428
  // surfaces an oversized/under-scaled mesh at a glance.
164
- geometry.computeBoundingBox();
429
+ if ( ! geometry.boundingBox ) geometry.computeBoundingBox();
165
430
  const worldSize = geometry.boundingBox
166
431
  ? geometry.boundingBox.clone().applyMatrix4( mesh.matrix ).getSize( new Vector3() )
167
432
  : new Vector3();
168
433
 
169
- this.report.push( {
434
+ const tris = this._accountGeometry( geometry );
435
+ this.placementCount ++;
436
+ this.reportedMeshes ++;
437
+ if ( this.report.length < MAX_REPORT_ROWS ) this.report.push( {
170
438
  mesh: name,
171
439
  shape: shape.type,
172
440
  material: shape.material?.type || 'diffuse',
@@ -176,20 +444,181 @@ export class PBRTSceneBuilder {
176
444
  normals: geometry.getAttribute( 'normal' ) ? 'yes' : 'NO',
177
445
  emissive: material.emissiveIntensity > 0 ? `#${material.emissive.getHexString()}×${material.emissiveIntensity}` : '-',
178
446
  size: `${worldSize.x.toFixed( 2 )}×${worldSize.y.toFixed( 2 )}×${worldSize.z.toFixed( 2 )}`,
179
- tris: geometry.index ? geometry.index.count / 3 : geometry.getAttribute( 'position' ).count / 3
447
+ tris
180
448
  } );
181
449
 
182
450
  return mesh;
183
451
 
184
452
  }
185
453
 
186
- async _buildGeometry( shape ) {
454
+ /**
455
+ * A textured material on geometry with no UVs samples a single texel — the usual cause of
456
+ * "black"/wrong meshes on import. Drop the map, but on a CLONE: _getMaterial shares one
457
+ * instance across every shape using the same NamedMaterial, so mutating it would strip
458
+ * the texture from siblings that DO have UVs.
459
+ * @private
460
+ */
461
+ _materialForGeometry( shape, geometry, sharedMaterial, name ) {
462
+
463
+ if ( ! sharedMaterial.map || geometry.getAttribute( 'uv' ) ) return sharedMaterial;
464
+
465
+ // One clone per source material, not per shape — else 173,000 warnings and 173,000
466
+ // materials, defeating every downstream cache that keys on material identity.
467
+ let material = this._noUVMaterials.get( sharedMaterial );
468
+ if ( ! material ) {
469
+
470
+ this.warn( `${name} (${shape.type}, "${shape.material?.type || 'diffuse'}") has a texture map but no UVs — dropping map, using base color` );
471
+ material = sharedMaterial.clone();
472
+ material.map = null;
473
+ this._noUVMaterials.set( sharedMaterial, material );
474
+
475
+ }
476
+
477
+ return material;
478
+
479
+ }
480
+
481
+ /**
482
+ * Copy one shape's triangles into a world-space batch, keyed by material and UV presence.
483
+ * Nothing references the source geometry afterwards, so it is collectable on return.
484
+ * @private
485
+ */
486
+ _mergeShape( shape, geometry, sharedMaterial, group ) {
487
+
488
+ const material = this._materialForGeometry( shape, geometry, sharedMaterial, 'merged shape' );
489
+ const position = geometry.getAttribute( 'position' );
490
+ const normal = geometry.getAttribute( 'normal' );
491
+ const uv = geometry.getAttribute( 'uv' );
492
+ const index = geometry.index;
493
+ const vertices = position.count;
494
+
495
+ // Charged directly: a merged shape stores its own copy even when two share one .ply.
496
+ this.triangleCount += ( index ? index.count : vertices ) / 3;
497
+ this.reportedMeshes ++;
498
+ this.mergedShapes ++;
499
+
500
+ let slot = this._batches.get( material );
501
+ if ( ! slot ) this._batches.set( material, slot = [ null, null ] );
502
+ const k = uv ? 1 : 0;
503
+ let batch = slot[ k ];
504
+ if ( ! batch ) slot[ k ] = batch = {
505
+ material,
506
+ positions: new Float32Array( 3072 ),
507
+ normals: new Float32Array( 3072 ),
508
+ uvs: uv ? new Float32Array( 2048 ) : null,
509
+ indices: new Uint32Array( 3072 ),
510
+ vertexCount: 0, indexCount: 0, triangles: 0
511
+ };
512
+
513
+ const world = this.convertHandedness ? M.multiply( FLIP_Z, shape.ctm ) : shape.ctm;
514
+ const inv = M.invert( world );
515
+ const base = batch.vertexCount;
516
+
517
+ batch.positions = grow( batch.positions, ( base + vertices ) * 3 );
518
+ batch.normals = grow( batch.normals, ( base + vertices ) * 3 );
519
+ if ( uv ) batch.uvs = grow( batch.uvs, ( base + vertices ) * 2 );
520
+
521
+ const src = position.array, dst = batch.positions;
522
+ const nsrc = normal ? normal.array : null, ndst = batch.normals;
523
+ for ( let i = 0; i < vertices; i ++ ) {
524
+
525
+ const s = i * 3, d = ( base + i ) * 3;
526
+ const x = src[ s ], y = src[ s + 1 ], z = src[ s + 2 ];
527
+ dst[ d ] = world[ 0 ] * x + world[ 4 ] * y + world[ 8 ] * z + world[ 12 ];
528
+ dst[ d + 1 ] = world[ 1 ] * x + world[ 5 ] * y + world[ 9 ] * z + world[ 13 ];
529
+ dst[ d + 2 ] = world[ 2 ] * x + world[ 6 ] * y + world[ 10 ] * z + world[ 14 ];
530
+
531
+ if ( ! nsrc ) continue;
532
+ // Inverse transpose, which in column-major order is the inverse read by rows.
533
+ const nx = nsrc[ s ], ny = nsrc[ s + 1 ], nz = nsrc[ s + 2 ];
534
+ let a = inv[ 0 ] * nx + inv[ 1 ] * ny + inv[ 2 ] * nz;
535
+ let b = inv[ 4 ] * nx + inv[ 5 ] * ny + inv[ 6 ] * nz;
536
+ let c = inv[ 8 ] * nx + inv[ 9 ] * ny + inv[ 10 ] * nz;
537
+ const len = Math.hypot( a, b, c );
538
+ if ( len > 0 ) {
539
+
540
+ a /= len; b /= len; c /= len;
541
+
542
+ }
543
+
544
+ ndst[ d ] = a; ndst[ d + 1 ] = b; ndst[ d + 2 ] = c;
545
+
546
+ }
547
+
548
+ if ( uv ) batch.uvs.set( uv.array.subarray( 0, vertices * 2 ), base * 2 );
549
+
550
+ const count = index ? index.count : vertices;
551
+ batch.indices = grow( batch.indices, batch.indexCount + count );
552
+ const idst = batch.indices;
553
+ const isrc = index ? index.array : null;
554
+ for ( let i = 0; i < count; i ++ ) idst[ batch.indexCount + i ] = base + ( isrc ? isrc[ i ] : i );
555
+
556
+ batch.vertexCount += vertices;
557
+ batch.indexCount += count;
558
+ batch.triangles += count / 3;
559
+
560
+ if ( batch.triangles >= MERGE_BATCH_TRIANGLES ) {
561
+
562
+ this._flushBatch( batch, group );
563
+ slot[ k ] = null;
564
+
565
+ }
566
+
567
+ }
568
+
569
+ /** Turn one accumulated batch into a single Mesh at the scene origin. @private */
570
+ _flushBatch( batch, group ) {
571
+
572
+ if ( ! batch || batch.triangles === 0 ) return;
573
+
574
+ const geometry = new BufferGeometry();
575
+ geometry.setAttribute( 'position', new Float32BufferAttribute( batch.positions.slice( 0, batch.vertexCount * 3 ), 3 ) );
576
+ geometry.setAttribute( 'normal', new Float32BufferAttribute( batch.normals.slice( 0, batch.vertexCount * 3 ), 3 ) );
577
+ if ( batch.uvs ) geometry.setAttribute( 'uv', new Float32BufferAttribute( batch.uvs.slice( 0, batch.vertexCount * 2 ), 2 ) );
578
+ geometry.setIndex( new Uint32BufferAttribute( batch.indices.slice( 0, batch.indexCount ), 1 ) );
579
+
580
+ const mesh = new Mesh( geometry, batch.material );
581
+ mesh.name = `merged_${this._mergedBatches ++}`;
582
+ mesh.frustumCulled = false;
583
+ group.add( mesh );
584
+
585
+ this.placementCount ++;
586
+ if ( this.report.length < MAX_REPORT_ROWS ) this.report.push( {
587
+ mesh: mesh.name,
588
+ shape: 'merged',
589
+ material: '-',
590
+ color: '#' + batch.material.color.getHexString(),
591
+ map: batch.material.map ? 'yes' : '-',
592
+ uv: batch.uvs ? 'yes' : 'NO',
593
+ normals: 'yes',
594
+ emissive: batch.material.emissiveIntensity > 0 ? `#${batch.material.emissive.getHexString()}×${batch.material.emissiveIntensity}` : '-',
595
+ size: 'merged',
596
+ tris: batch.triangles
597
+ } );
598
+
599
+ batch.positions = batch.normals = batch.uvs = batch.indices = null;
600
+
601
+ }
602
+
603
+ _buildGeometry( shape ) {
604
+
605
+ // An ObjectInstance template is built once per placement, and the Moana palm debris
606
+ // alone places one 208-triangle leaf 2.25 million times. three.js is happy to share
607
+ // one BufferGeometry across meshes; only the per-mesh matrix differs.
608
+ let pending = this._geometryCache.get( shape );
609
+ if ( ! pending ) this._geometryCache.set( shape, pending = this._createGeometry( shape ) );
610
+ return pending;
611
+
612
+ }
613
+
614
+ async _createGeometry( shape ) {
187
615
 
188
616
  switch ( shape.type ) {
189
617
 
190
618
  case 'trianglemesh': return this._triangleMesh( shape.params );
191
619
  case 'bilinearmesh': return this._bilinearMesh( shape.params );
192
620
  case 'loopsubdiv': return this._loopSubdiv( shape.params );
621
+ case 'curve': return this._curve( shape.params );
193
622
  case 'plymesh': return this._plyMesh( shape.params );
194
623
  case 'sphere': return this._sphere( shape.params );
195
624
  case 'disk': return this._disk( shape.params );
@@ -211,22 +640,64 @@ export class PBRTSceneBuilder {
211
640
  }
212
641
 
213
642
  const geo = new BufferGeometry();
214
- geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
643
+ geo.setAttribute( 'position', new Float32BufferAttribute( float32( P ), 3 ) );
215
644
 
216
645
  const N = params.N?.value;
217
- if ( N && N.length === P.length ) geo.setAttribute( 'normal', new Float32BufferAttribute( Float32Array.from( N ), 3 ) );
646
+ if ( N && N.length === P.length ) geo.setAttribute( 'normal', new Float32BufferAttribute( float32( N ), 3 ) );
218
647
 
219
648
  const uv = ( params.uv || params.st )?.value;
220
- if ( uv && uv.length === ( P.length / 3 ) * 2 ) geo.setAttribute( 'uv', new Float32BufferAttribute( Float32Array.from( uv ), 2 ) );
649
+ if ( uv && uv.length === ( P.length / 3 ) * 2 ) geo.setAttribute( 'uv', new Float32BufferAttribute( float32( uv ), 2 ) );
221
650
 
222
651
  const indices = params.indices?.value;
223
- if ( indices && indices.length ) geo.setIndex( indices );
652
+ if ( indices && indices.length ) geo.setIndex( indexAttribute( indices ) );
224
653
 
225
654
  if ( ! N ) geo.computeVertexNormals();
226
655
  return geo;
227
656
 
228
657
  }
229
658
 
659
+ _curve( params ) {
660
+
661
+ const P = params.P?.value;
662
+ if ( ! P || P.length < 12 ) {
663
+
664
+ this.warn( 'curve needs at least 4 control points' ); return null;
665
+
666
+ }
667
+
668
+ const degree = Math.round( pFloat( params, 'degree', 3 ) );
669
+ if ( degree !== 3 ) {
670
+
671
+ this.warn( `curve degree ${degree} not supported — only cubic` ); return null;
672
+
673
+ }
674
+
675
+ const type = pString( params, 'type', 'flat' );
676
+ const width = pFloat( params, 'width', 1 );
677
+ const built = tessellateCurve( {
678
+ P,
679
+ basis: pString( params, 'basis', 'bezier' ),
680
+ width0: pFloat( params, 'width0', width ),
681
+ width1: pFloat( params, 'width1', width ),
682
+ N: params.N?.value || null,
683
+ steps: this.curveSteps,
684
+ sides: this.curveSides ?? DEFAULT_CURVE_SIDES[ type ] ?? 1
685
+ } );
686
+
687
+ if ( ! built ) {
688
+
689
+ this.warn( 'curve could not be tessellated' ); return null;
690
+
691
+ }
692
+
693
+ const geo = new BufferGeometry();
694
+ geo.setAttribute( 'position', new Float32BufferAttribute( built.positions, 3 ) );
695
+ geo.setIndex( new Uint32BufferAttribute( built.indices, 1 ) );
696
+ geo.computeVertexNormals();
697
+ return geo;
698
+
699
+ }
700
+
230
701
  _loopSubdiv( params ) {
231
702
 
232
703
  const P = params.P?.value;
@@ -281,7 +752,7 @@ export class PBRTSceneBuilder {
281
752
 
282
753
  }
283
754
 
284
- async _plyMesh( params ) {
755
+ _plyMesh( params ) {
285
756
 
286
757
  const filename = pString( params, 'filename', null );
287
758
  if ( ! filename ) {
@@ -290,6 +761,15 @@ export class PBRTSceneBuilder {
290
761
 
291
762
  }
292
763
 
764
+ // Two shapes can name the same .ply, and decoding it twice costs the full parse.
765
+ let pending = this._plyCache.get( filename );
766
+ if ( ! pending ) this._plyCache.set( filename, pending = this._decodePly( filename ) );
767
+ return pending;
768
+
769
+ }
770
+
771
+ async _decodePly( filename ) {
772
+
293
773
  try {
294
774
 
295
775
  const geo = await this.resolvePLY( filename );
@@ -374,7 +854,28 @@ export class PBRTSceneBuilder {
374
854
 
375
855
  if ( shape.areaLight ) await this._applyAreaLight( material, shape.areaLight, ctx );
376
856
 
377
- byLight.set( shape.areaLight, material );
857
+ const shared = this._dedupeMaterial( material );
858
+ byLight.set( shape.areaLight, shared );
859
+ return shared;
860
+
861
+ }
862
+
863
+ /**
864
+ * Collapse materials that render identically onto one instance — an inline `Material` per
865
+ * shape is a distinct object every time, so every batch would hold exactly one shape.
866
+ * @private
867
+ */
868
+ _dedupeMaterial( material ) {
869
+
870
+ const c = material.color, e = material.emissive;
871
+ const key = `${c.r},${c.g},${c.b}|${e.r},${e.g},${e.b}|${material.emissiveIntensity}|` +
872
+ `${material.map ? material.map.uuid : '-'}|${material.roughness}|${material.metalness}|` +
873
+ `${material.transmission}|${material.ior}|${material.thickness}|` +
874
+ `${material.clearcoat}|${material.clearcoatRoughness}|${material.opacity}|${material.side}`;
875
+
876
+ const existing = this._materialBySignature.get( key );
877
+ if ( existing ) return existing;
878
+ this._materialBySignature.set( key, material );
378
879
  return material;
379
880
 
380
881
  }
@@ -435,7 +936,17 @@ export class PBRTSceneBuilder {
435
936
 
436
937
  } else {
437
938
 
438
- this.warn( `texture class "${def.class}" not supported (texture "${name}")` );
939
+ const recovered = this._colorFromLikeNamedMaterial( name );
940
+ if ( recovered ) {
941
+
942
+ result = { constant: recovered.rgb };
943
+ this._recoveredColors ++;
944
+
945
+ } else {
946
+
947
+ this.warn( `texture class "${def.class}" not supported (texture "${name}")` );
948
+
949
+ }
439
950
 
440
951
  }
441
952
 
@@ -444,6 +955,24 @@ export class PBRTSceneBuilder {
444
955
 
445
956
  }
446
957
 
958
+ _colorFromLikeNamedMaterial( texName ) {
959
+
960
+ for ( const candidate of materialNameCandidates( texName ) ) {
961
+
962
+ const mat = this.ir?.namedMaterials?.get( candidate );
963
+ const p = mat?.params?.reflectance;
964
+ if ( p && ( p.type === 'rgb' || p.type === 'color' ) ) {
965
+
966
+ return { name: candidate, rgb: [ p.value[ 0 ], p.value[ 1 ], p.value[ 2 ] ] };
967
+
968
+ }
969
+
970
+ }
971
+
972
+ return null;
973
+
974
+ }
975
+
447
976
  async _resolveScaleTexture( name, def ) {
448
977
 
449
978
  // pbrt-v4 uses "tex" (the inner texture or constant) and "scale" (the multiplier).
@@ -555,8 +1084,7 @@ export class PBRTSceneBuilder {
555
1084
  _equirectFromInfiniteLight( tex, inf, scale, filename ) {
556
1085
 
557
1086
  const image = tex.image;
558
- const data = image?.data;
559
- if ( ! data || ! image.width || image.width !== image.height ) {
1087
+ if ( ! image?.width || image.width !== image.height ) {
560
1088
 
561
1089
  if ( image?.width !== image?.height ) this.warn(
562
1090
  `infinite-light image "${filename}" is ${image?.width}x${image?.height}, not square — ` +
@@ -566,14 +1094,30 @@ export class PBRTSceneBuilder {
566
1094
 
567
1095
  }
568
1096
 
569
- const channels = data.length / ( image.width * image.height );
570
- if ( ! Number.isInteger( channels ) || channels < 3 ) return null;
1097
+ let src = null;
571
1098
 
572
- // three's EXR/HDR loaders fill DataTexture rows bottom-up; pbrt reads the square top-down.
573
- const { data: pixels, width, height } = octahedralToEquirect(
574
- { data, width: image.width, height: image.height, channels, bottomUp: true },
575
- inf.ctm, scale
576
- );
1099
+ if ( image.data ) {
1100
+
1101
+ const channels = image.data.length / ( image.width * image.height );
1102
+ if ( ! Number.isInteger( channels ) || channels < 3 ) return null;
1103
+ // three's EXR/HDR loaders fill DataTexture rows bottom-up; pbrt reads the square top-down.
1104
+ src = { data: image.data, width: image.width, height: image.height, channels, bottomUp: true };
1105
+
1106
+ } else {
1107
+
1108
+ // A PNG sky has no readable pixels of its own; without these the octahedral map
1109
+ // used to fall through and be sampled as a lat-long panorama.
1110
+ src = pixelsFromDrawable( image, tex.colorSpace === SRGBColorSpace );
1111
+ if ( ! src ) {
1112
+
1113
+ this.warn( `infinite-light image "${filename}" could not be read back for octahedral conversion` );
1114
+ return null;
1115
+
1116
+ }
1117
+
1118
+ }
1119
+
1120
+ const { data: pixels, width, height } = octahedralToEquirect( src, inf.ctm, scale );
577
1121
 
578
1122
  const out = new DataTexture( pixels, width, height, RGBAFormat, FloatType );
579
1123
  out.mapping = EquirectangularReflectionMapping;