rayzee 7.31.0 → 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 (53) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6547 -4334
  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 +6 -0
  7. package/src/PathTracerApp.js +128 -20
  8. package/src/Processor/ArchiveReader.js +538 -0
  9. package/src/Processor/AssetLoader.js +357 -22
  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 +173 -0
  18. package/src/Processor/PBRT/LoopSubdivision.js +240 -0
  19. package/src/Processor/PBRT/PBRTCurves.js +249 -0
  20. package/src/Processor/PBRT/PBRTMath.js +28 -0
  21. package/src/Processor/PBRT/PBRTParser.js +187 -41
  22. package/src/Processor/PBRT/PBRTSceneBuilder.js +659 -34
  23. package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
  24. package/src/Processor/PBRT/index.js +306 -45
  25. package/src/Processor/PackedRayBuffer.js +29 -18
  26. package/src/Processor/ParallelBVHBuilder.js +9 -8
  27. package/src/Processor/QueueManager.js +5 -3
  28. package/src/Processor/SceneProcessor.js +1063 -366
  29. package/src/Processor/TLASBuilder.js +546 -201
  30. package/src/Processor/TextureCreator.js +11 -9
  31. package/src/Processor/VRAMTracker.js +12 -3
  32. package/src/Processor/Workers/BVHRefitWorker.js +16 -11
  33. package/src/Processor/Workers/BVHWorker.js +12 -12
  34. package/src/Processor/Workers/TLASWorker.js +37 -0
  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/Struct.js +3 -0
  47. package/src/TSL/TextureSampling.js +24 -9
  48. package/src/TSL/patches.js +128 -3
  49. package/src/index.js +3 -0
  50. package/src/managers/AnimationManager.js +86 -76
  51. package/src/managers/CameraManager.js +11 -0
  52. package/src/managers/MaterialDataManager.js +29 -23
  53. package/src/managers/TransformManager.js +9 -192
@@ -14,14 +14,133 @@
14
14
  */
15
15
 
16
16
  import {
17
- Group, Mesh, PerspectiveCamera, Matrix4, Vector3,
18
- BufferGeometry, Float32BufferAttribute, SphereGeometry,
19
- DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping
17
+ Group, Mesh, InstancedMesh, PerspectiveCamera, Matrix4, Vector3,
18
+ BufferGeometry, Float32BufferAttribute, Uint32BufferAttribute, SphereGeometry,
19
+ DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping,
20
+ SRGBColorSpace
20
21
  } from 'three';
21
22
  import { buildMaterial, pFloat, pString, resolveSpectrum } from './PBRTMaterials.js';
23
+ import { loopSubdivide } from './LoopSubdivision.js';
24
+ import { octahedralToEquirect } from './EqualAreaOctahedral.js';
25
+ import { tessellateCurve } from './PBRTCurves.js';
22
26
  import * as M from './PBRTMath.js';
23
27
 
24
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
+ }
25
144
 
26
145
  export class PBRTSceneBuilder {
27
146
 
@@ -34,6 +153,11 @@ export class PBRTSceneBuilder {
34
153
  */
35
154
  constructor( resolvers = {} ) {
36
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;
37
161
  this.resolvePLY = resolvers.resolvePLY || ( async () => null );
38
162
  this.resolveImage = resolvers.resolveImage || ( async () => null );
39
163
  this.resolveEnvironment = resolvers.resolveEnvironment || resolvers.resolveImage || ( async () => null );
@@ -43,6 +167,10 @@ export class PBRTSceneBuilder {
43
167
  this.report = []; // per-mesh diagnostics for debugging imports
44
168
  this._materialCache = new Map(); // material obj -> Map(areaLight obj -> MeshPhysicalMaterial)
45
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>
46
174
 
47
175
  }
48
176
 
@@ -59,19 +187,71 @@ export class PBRTSceneBuilder {
59
187
  async build( ir ) {
60
188
 
61
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();
62
196
  const group = new Group();
63
197
  group.name = 'PBRTScene';
64
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
+
65
204
  // Shapes (direct + instanced)
66
205
  for ( let i = 0; i < ir.shapes.length; i ++ ) {
67
206
 
68
- 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}` );
69
230
  if ( mesh ) group.add( mesh );
70
231
 
71
232
  }
72
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
+
73
247
  await this._buildInstances( ir, group );
74
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
+
75
255
  // Camera
76
256
  let camera = null;
77
257
  if ( ir.camera ) {
@@ -86,9 +266,19 @@ export class PBRTSceneBuilder {
86
266
 
87
267
  this._reportUnsupportedLights( ir.lights );
88
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
+
89
273
  return {
90
274
  group, camera, environment,
91
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,
92
282
  warnings: this.warnings.concat( ir.warnings || [] )
93
283
  };
94
284
 
@@ -96,24 +286,108 @@ export class PBRTSceneBuilder {
96
286
 
97
287
  // ── shapes ─────────────────────────────────────────────────────
98
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
+ */
99
319
  async _buildInstances( ir, group ) {
100
320
 
101
321
  let n = 0;
102
- 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();
103
326
 
104
- const template = ir.objects.get( inst.name );
327
+ for ( const [ name, list ] of ir.instances ) {
328
+
329
+ const template = ir.objects.get( name );
105
330
  if ( ! template ) {
106
331
 
107
- 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;
108
337
 
109
338
  }
110
339
 
111
340
  for ( const shape of template ) {
112
341
 
113
- // instance placement: instanceCTM * (shape relative to its ObjectBegin frame)
114
- const worldCTM = M.multiply( inst.ctm, shape.relativeCTM || shape.ctm );
115
- const mesh = await this._buildShapeMesh( shape, worldCTM, `instance_${n ++}` );
116
- 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
+ } );
117
391
 
118
392
  }
119
393
 
@@ -129,21 +403,14 @@ export class PBRTSceneBuilder {
129
403
  this._getMaterial( shape )
130
404
  ] );
131
405
  if ( ! geometry ) return null;
406
+ return this._meshFromGeometry( shape, geometry, sharedMaterial, ctm, name );
132
407
 
133
- // A textured material on geometry with no UVs samples a single texel — the
134
- // usual cause of "black"/wrong meshes on import. Drop the map, but on a CLONE:
135
- // _getMaterial caches and shares one instance across every shape using the
136
- // same NamedMaterial, so mutating it would strip the texture from sibling
137
- // meshes that DO have UVs.
138
- const hasUV = !! geometry.getAttribute( 'uv' );
139
- let material = sharedMaterial;
140
- if ( sharedMaterial.map && ! hasUV ) {
408
+ }
141
409
 
142
- this.warn( `${name} (${shape.type}, "${shape.material?.type || 'diffuse'}") has a texture map but no UVs — dropping map, using base color` );
143
- material = sharedMaterial.clone();
144
- material.map = null;
410
+ _meshFromGeometry( shape, geometry, sharedMaterial, ctm, name ) {
145
411
 
146
- }
412
+ const hasUV = !! geometry.getAttribute( 'uv' );
413
+ const material = this._materialForGeometry( shape, geometry, sharedMaterial, name );
147
414
 
148
415
  const mesh = new Mesh( geometry, material );
149
416
  mesh.name = name;
@@ -159,12 +426,15 @@ export class PBRTSceneBuilder {
159
426
 
160
427
  // World-space dimensions (object bbox transformed by the baked matrix) —
161
428
  // surfaces an oversized/under-scaled mesh at a glance.
162
- geometry.computeBoundingBox();
429
+ if ( ! geometry.boundingBox ) geometry.computeBoundingBox();
163
430
  const worldSize = geometry.boundingBox
164
431
  ? geometry.boundingBox.clone().applyMatrix4( mesh.matrix ).getSize( new Vector3() )
165
432
  : new Vector3();
166
433
 
167
- 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( {
168
438
  mesh: name,
169
439
  shape: shape.type,
170
440
  material: shape.material?.type || 'diffuse',
@@ -174,19 +444,181 @@ export class PBRTSceneBuilder {
174
444
  normals: geometry.getAttribute( 'normal' ) ? 'yes' : 'NO',
175
445
  emissive: material.emissiveIntensity > 0 ? `#${material.emissive.getHexString()}×${material.emissiveIntensity}` : '-',
176
446
  size: `${worldSize.x.toFixed( 2 )}×${worldSize.y.toFixed( 2 )}×${worldSize.z.toFixed( 2 )}`,
177
- tris: geometry.index ? geometry.index.count / 3 : geometry.getAttribute( 'position' ).count / 3
447
+ tris
178
448
  } );
179
449
 
180
450
  return mesh;
181
451
 
182
452
  }
183
453
 
184
- 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 ) {
185
615
 
186
616
  switch ( shape.type ) {
187
617
 
188
618
  case 'trianglemesh': return this._triangleMesh( shape.params );
189
619
  case 'bilinearmesh': return this._bilinearMesh( shape.params );
620
+ case 'loopsubdiv': return this._loopSubdiv( shape.params );
621
+ case 'curve': return this._curve( shape.params );
190
622
  case 'plymesh': return this._plyMesh( shape.params );
191
623
  case 'sphere': return this._sphere( shape.params );
192
624
  case 'disk': return this._disk( shape.params );
@@ -208,22 +640,91 @@ export class PBRTSceneBuilder {
208
640
  }
209
641
 
210
642
  const geo = new BufferGeometry();
211
- geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
643
+ geo.setAttribute( 'position', new Float32BufferAttribute( float32( P ), 3 ) );
212
644
 
213
645
  const N = params.N?.value;
214
- 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 ) );
215
647
 
216
648
  const uv = ( params.uv || params.st )?.value;
217
- 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 ) );
218
650
 
219
651
  const indices = params.indices?.value;
220
- if ( indices && indices.length ) geo.setIndex( indices );
652
+ if ( indices && indices.length ) geo.setIndex( indexAttribute( indices ) );
221
653
 
222
654
  if ( ! N ) geo.computeVertexNormals();
223
655
  return geo;
224
656
 
225
657
  }
226
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
+
701
+ _loopSubdiv( params ) {
702
+
703
+ const P = params.P?.value;
704
+ const indices = params.indices?.value;
705
+ if ( ! P || P.length < 9 || ! indices || indices.length < 3 ) {
706
+
707
+ this.warn( 'loopsubdiv missing P/indices' ); return null;
708
+
709
+ }
710
+
711
+ // pbrt's default is 3; each level quadruples the face count, so a deep level on
712
+ // a dense cage is worth refusing rather than stalling the import.
713
+ const requested = Math.max( 0, Math.min( 6, Math.round( pFloat( params, 'levels', 3 ) ) ) );
714
+ const { positions, indices: faces, levels } = loopSubdivide( P, indices, requested );
715
+
716
+ if ( levels < requested ) this.warn(
717
+ `loopsubdiv refined ${levels}/${requested} levels — the control mesh is too dense for the triangle budget`
718
+ );
719
+
720
+ const geo = new BufferGeometry();
721
+ geo.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) );
722
+ geo.setIndex( new Uint32BufferAttribute( faces, 1 ) );
723
+ geo.computeVertexNormals();
724
+ return geo;
725
+
726
+ }
727
+
227
728
  // Bilinear patch mesh → triangulate each quad (P + indices in quads of 4).
228
729
  _bilinearMesh( params ) {
229
730
 
@@ -251,7 +752,7 @@ export class PBRTSceneBuilder {
251
752
 
252
753
  }
253
754
 
254
- async _plyMesh( params ) {
755
+ _plyMesh( params ) {
255
756
 
256
757
  const filename = pString( params, 'filename', null );
257
758
  if ( ! filename ) {
@@ -260,6 +761,15 @@ export class PBRTSceneBuilder {
260
761
 
261
762
  }
262
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
+
263
773
  try {
264
774
 
265
775
  const geo = await this.resolvePLY( filename );
@@ -344,7 +854,28 @@ export class PBRTSceneBuilder {
344
854
 
345
855
  if ( shape.areaLight ) await this._applyAreaLight( material, shape.areaLight, ctx );
346
856
 
347
- 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 );
348
879
  return material;
349
880
 
350
881
  }
@@ -405,7 +936,17 @@ export class PBRTSceneBuilder {
405
936
 
406
937
  } else {
407
938
 
408
- 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
+ }
409
950
 
410
951
  }
411
952
 
@@ -414,6 +955,24 @@ export class PBRTSceneBuilder {
414
955
 
415
956
  }
416
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
+
417
976
  async _resolveScaleTexture( name, def ) {
418
977
 
419
978
  // pbrt-v4 uses "tex" (the inner texture or constant) and "scale" (the multiplier).
@@ -487,6 +1046,16 @@ export class PBRTSceneBuilder {
487
1046
  camera.up.copy( up.normalize() );
488
1047
  camera.position.copy( eye );
489
1048
  camera.lookAt( target );
1049
+
1050
+ // pbrt's camera space is left-handed: image-right is cameraToWorld's first column,
1051
+ // = cross(up, dir). three's lookAt() builds cross(up, -dir) — the opposite — so a
1052
+ // plain pbrt scene imports left-right flipped against pbrt's own render. An
1053
+ // exported scene's `Scale -1 1 1` already negates that column (det < 0), and then
1054
+ // lookAt() happens to agree and no correction is wanted. Hence: mirror exactly when
1055
+ // the scene does NOT. Verified against the reference images for contemporary-bathroom
1056
+ // (has the Scale) and killeroos/killeroo-simple (does not).
1057
+ if ( ( M.determinant3( cam.cameraToWorld ) > 0 ) !== this.convertHandedness ) camera.scale.x = - 1;
1058
+
490
1059
  camera.updateMatrixWorld( true );
491
1060
  return camera;
492
1061
 
@@ -506,6 +1075,59 @@ export class PBRTSceneBuilder {
506
1075
 
507
1076
  // ── lights / environment ───────────────────────────────────────
508
1077
 
1078
+ /**
1079
+ * A square infinite-light image is pbrt's equal-area octahedral layout, not a
1080
+ * lat-long panorama — resample it, folding in the light's transform and `scale`.
1081
+ * Returns null when the image is not square (nothing to convert) or its pixels are
1082
+ * not readable, leaving the caller's equirectangular path in place.
1083
+ */
1084
+ _equirectFromInfiniteLight( tex, inf, scale, filename ) {
1085
+
1086
+ const image = tex.image;
1087
+ if ( ! image?.width || image.width !== image.height ) {
1088
+
1089
+ if ( image?.width !== image?.height ) this.warn(
1090
+ `infinite-light image "${filename}" is ${image?.width}x${image?.height}, not square — ` +
1091
+ 'read as equirectangular, but pbrt would read it as equal-area octahedral'
1092
+ );
1093
+ return null;
1094
+
1095
+ }
1096
+
1097
+ let src = null;
1098
+
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 );
1121
+
1122
+ const out = new DataTexture( pixels, width, height, RGBAFormat, FloatType );
1123
+ out.mapping = EquirectangularReflectionMapping;
1124
+ out.minFilter = LinearFilter;
1125
+ out.magFilter = LinearFilter;
1126
+ out.needsUpdate = true;
1127
+ return out;
1128
+
1129
+ }
1130
+
509
1131
  async _buildEnvironment( lights ) {
510
1132
 
511
1133
  const inf = lights.find( l => l.type === 'infinite' );
@@ -521,6 +1143,9 @@ export class PBRTSceneBuilder {
521
1143
  const tex = await this.resolveEnvironment( filename );
522
1144
  if ( tex ) {
523
1145
 
1146
+ const converted = this._equirectFromInfiniteLight( tex, inf, scale, filename );
1147
+ if ( converted ) return { texture: converted };
1148
+
524
1149
  tex.mapping = EquirectangularReflectionMapping;
525
1150
  return { texture: tex };
526
1151