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.
- package/README.md +164 -0
- package/dist/rayzee.es.js +6366 -4361
- package/dist/rayzee.umd.js +122 -73
- package/package.json +1 -1
- package/src/EngineDefaults.js +168 -13
- package/src/EngineIssues.js +5 -0
- package/src/PathTracerApp.js +120 -19
- package/src/Processor/ArchiveReader.js +538 -0
- package/src/Processor/AssetLoader.js +265 -23
- package/src/Processor/BVHBuilder.js +48 -32
- package/src/Processor/BVHRefitter.js +244 -99
- package/src/Processor/ChunkedRecords.js +302 -0
- package/src/Processor/EmissiveTriangleBuilder.js +48 -21
- package/src/Processor/GeometryExtractor.js +577 -92
- package/src/Processor/HostMemory.js +222 -0
- package/src/Processor/InstanceTable.js +548 -97
- package/src/Processor/PBRT/EqualAreaOctahedral.js +32 -8
- package/src/Processor/PBRT/PBRTCurves.js +249 -0
- package/src/Processor/PBRT/PBRTMath.js +19 -0
- package/src/Processor/PBRT/PBRTParser.js +187 -41
- package/src/Processor/PBRT/PBRTSceneBuilder.js +586 -42
- package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
- package/src/Processor/PBRT/index.js +257 -50
- package/src/Processor/PackedRayBuffer.js +29 -18
- package/src/Processor/ParallelBVHBuilder.js +9 -8
- package/src/Processor/QueueManager.js +5 -3
- package/src/Processor/SceneProcessor.js +1046 -365
- package/src/Processor/TLASBuilder.js +546 -201
- package/src/Processor/TextureCreator.js +11 -9
- package/src/Processor/VRAMTracker.js +12 -3
- package/src/Processor/Workers/BVHRefitWorker.js +16 -11
- package/src/Processor/Workers/BVHWorker.js +12 -12
- package/src/Processor/Workers/TLASWorker.js +37 -0
- package/src/Stages/NormalDepth.js +6 -2
- package/src/Stages/PathTracer.js +5 -5
- package/src/Stages/PathTracerStage.js +163 -41
- package/src/TSL/BVHTraversal.js +208 -90
- package/src/TSL/Common.js +135 -2
- package/src/TSL/Displacement.js +21 -11
- package/src/TSL/EmissiveSampling.js +49 -25
- package/src/TSL/ExtendKernel.js +3 -2
- package/src/TSL/LightBVHSampling.js +7 -4
- package/src/TSL/LightsDirect.js +30 -29
- package/src/TSL/ShadeKernel.js +8 -6
- package/src/TSL/Struct.js +3 -0
- package/src/TSL/TextureSampling.js +24 -9
- package/src/TSL/patches.js +128 -3
- package/src/index.js +3 -0
- package/src/managers/AnimationManager.js +86 -76
- package/src/managers/MaterialDataManager.js +29 -23
- package/src/managers/TransformManager.js +9 -192
|
@@ -11,10 +11,12 @@ import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils
|
|
|
11
11
|
import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
|
|
12
12
|
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
|
|
13
13
|
import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
|
|
14
|
+
import {
|
|
15
|
+
detectArchiveKind, readTarGz, readTar, elementFilter, listArchiveElements, openTar } from './ArchiveReader.js';
|
|
14
16
|
import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
|
|
15
17
|
import { BuildTimer } from './BuildTimer.js';
|
|
16
18
|
import { getAssetConfig } from '../AssetConfig.js';
|
|
17
|
-
import { loadPBRTScene, pickEntryPath
|
|
19
|
+
import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
|
|
18
20
|
import { extractSceneMetadata } from './SceneMetadata.js';
|
|
19
21
|
import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
|
|
20
22
|
import { getRenderProfile } from '../EngineDefaults.js';
|
|
@@ -32,7 +34,10 @@ const SUPPORTED_FORMATS = {
|
|
|
32
34
|
'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
|
|
33
35
|
'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
|
|
34
36
|
'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
|
|
35
|
-
'zip': { type: 'archive', name: 'ZIP Archive' }
|
|
37
|
+
'zip': { type: 'archive', name: 'ZIP Archive' },
|
|
38
|
+
'gz': { type: 'archive', name: 'Gzipped TAR Archive' },
|
|
39
|
+
'tgz': { type: 'archive', name: 'Gzipped TAR Archive' },
|
|
40
|
+
'tar': { type: 'archive', name: 'TAR Archive' }
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
// Loose USD layers inside a ZIP compose into one scene; these pick out the
|
|
@@ -40,6 +45,16 @@ const SUPPORTED_FORMATS = {
|
|
|
40
45
|
const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
|
|
41
46
|
const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
|
|
42
47
|
const MTL_TEXTURE_TIMEOUT_MS = 30000;
|
|
48
|
+
/**
|
|
49
|
+
* Unpacked size past which a multi-part scene archive asks which parts to load rather than
|
|
50
|
+
* taking all of them. Not a hard limit — picking every part is a valid answer.
|
|
51
|
+
*
|
|
52
|
+
* Measured on Moana: a 7.5 GB archive of 15 parts parses to 40M triangles and ~7.3 GB of CPU
|
|
53
|
+
* memory, which loads on a freshly started browser and fails on one that has been up a while.
|
|
54
|
+
* Anything near that is worth a question, because the failure past it kills the tab rather than
|
|
55
|
+
* throwing. Override per load with `promptBytes`.
|
|
56
|
+
*/
|
|
57
|
+
export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
|
|
43
58
|
// A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
|
|
44
59
|
// reorders and disposes what it is given, but never writes the attributes.
|
|
45
60
|
function standInForSplit( source ) {
|
|
@@ -275,7 +290,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
275
290
|
}
|
|
276
291
|
|
|
277
292
|
// Asset loading methods
|
|
278
|
-
async loadAssetFromFile( file ) {
|
|
293
|
+
async loadAssetFromFile( file, options = {} ) {
|
|
279
294
|
|
|
280
295
|
const filename = file.name;
|
|
281
296
|
const format = this.getFileFormat( filename );
|
|
@@ -290,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
290
305
|
case 'model': result = await this.loadModelFromFile( file, filename ); break;
|
|
291
306
|
case 'environment':
|
|
292
307
|
case 'image': result = await this.loadEnvironmentFromFile( file, filename ); break;
|
|
293
|
-
case 'archive': result = await this.loadArchiveFromFile( file, filename ); break;
|
|
308
|
+
case 'archive': result = await this.loadArchiveFromFile( file, filename, options ); break;
|
|
294
309
|
default: throw new Error( `Unknown asset type: ${format.type}` );
|
|
295
310
|
|
|
296
311
|
}
|
|
@@ -483,43 +498,258 @@ export class AssetLoader extends EventDispatcher {
|
|
|
483
498
|
}
|
|
484
499
|
|
|
485
500
|
// Archive handling
|
|
501
|
+
/** First bytes of a file, without pulling the whole thing into memory. */
|
|
502
|
+
async _readHead( file, bytes = 512 ) {
|
|
503
|
+
|
|
504
|
+
if ( file instanceof Uint8Array ) return file.subarray( 0, bytes );
|
|
505
|
+
if ( typeof file.slice === 'function' && typeof file.arrayBuffer === 'function' ) {
|
|
506
|
+
|
|
507
|
+
return new Uint8Array( await file.slice( 0, bytes ).arrayBuffer() );
|
|
508
|
+
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return new Uint8Array( ( await this.readFileAsArrayBuffer( file ) ).slice( 0, bytes ) );
|
|
512
|
+
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Enumerates a TAR/TAR.GZ archive without keeping any of it, so a caller can offer a
|
|
517
|
+
* choice before committing memory. ZIP archives are random-access and report directly.
|
|
518
|
+
* @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
|
|
519
|
+
*/
|
|
520
|
+
async inspectArchive( file ) {
|
|
521
|
+
|
|
522
|
+
const kind = detectArchiveKind( await this._readHead( file ) );
|
|
523
|
+
|
|
524
|
+
if ( kind === 'gzip' || kind === 'tar' ) {
|
|
525
|
+
|
|
526
|
+
const read = kind === 'gzip' ? readTarGz : readTar;
|
|
527
|
+
const { listing } = await read( file, {
|
|
528
|
+
filter: () => false,
|
|
529
|
+
onProgress: p => updateLoading( {
|
|
530
|
+
isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
|
|
531
|
+
} )
|
|
532
|
+
} );
|
|
533
|
+
const { root, elements } = listArchiveElements( listing );
|
|
534
|
+
return {
|
|
535
|
+
kind, root, elements,
|
|
536
|
+
entryCount: listing.length,
|
|
537
|
+
totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const zip = unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
|
|
543
|
+
const listing = Object.keys( zip ).map( path => ( { path, size: zip[ path ].length } ) );
|
|
544
|
+
const { root, elements } = listArchiveElements( listing );
|
|
545
|
+
return {
|
|
546
|
+
kind: 'zip', root, elements,
|
|
547
|
+
entryCount: listing.length,
|
|
548
|
+
totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
}
|
|
552
|
+
|
|
486
553
|
/**
|
|
487
554
|
* @param {File|Blob} file
|
|
488
555
|
* @param {string} filename
|
|
489
|
-
* @param {
|
|
490
|
-
*
|
|
556
|
+
* @param {object} [options]
|
|
557
|
+
* @param {string} [options.pbrtEntry] - which .pbrt to load when the archive holds several
|
|
558
|
+
* independent scenes; the issue log lists what else was available.
|
|
559
|
+
* @param {string|string[]} [options.element] - path prefix of one subtree to load on its own,
|
|
560
|
+
* or several to load together, as reported by `inspectArchive()`. Everything above them
|
|
561
|
+
* (scene file, materials, textures) comes along; subtrees left out are skipped without ever
|
|
562
|
+
* being held in memory, and the Includes that point at them only warn.
|
|
563
|
+
* @param {number} [options.byteBudget] - cap on retained bytes when no element is chosen.
|
|
564
|
+
* @param {number} [options.promptBytes] - size past which a multi-part archive asks which
|
|
565
|
+
* parts to load instead of taking all of them.
|
|
566
|
+
* @param {number} [options.maxTriangles] - stop past this many STORED triangles.
|
|
567
|
+
* @param {number} [options.maxPlacements] - stop past this many instance placements.
|
|
568
|
+
* @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
|
|
569
|
+
* @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
|
|
570
|
+
* @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
|
|
491
571
|
*/
|
|
492
|
-
async loadArchiveFromFile( file, filename, { pbrtEntry = null } = {} ) {
|
|
572
|
+
async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
|
|
493
573
|
|
|
494
574
|
try {
|
|
495
575
|
|
|
496
|
-
const
|
|
497
|
-
|
|
576
|
+
const kind = detectArchiveKind( await this._readHead( file ) );
|
|
577
|
+
|
|
578
|
+
// An uncompressed tar over a File is seekable, so it is indexed rather than read:
|
|
579
|
+
// entries are pulled out as the scene asks for them and never all held at once.
|
|
580
|
+
// That is the difference between a 7 GB archive being refused and loading.
|
|
581
|
+
if ( kind === 'tar' ) {
|
|
582
|
+
|
|
583
|
+
const source = await this._openSeekableArchive( file, filename, element, promptBytes );
|
|
584
|
+
if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
|
|
585
|
+
|
|
586
|
+
return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
|
|
587
|
+
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Not a pbrt scene: fall back to materialising it, which those paths still expect.
|
|
591
|
+
for ( const e of source.listing ) source.entries[ e.path ] ??= await source.read( e.path );
|
|
592
|
+
return await this._loadNonPBRTArchive( source.entries, filename );
|
|
593
|
+
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const entries = kind === 'gzip'
|
|
597
|
+
? await this._readStreamedArchive( file, filename, kind, element, byteBudget )
|
|
598
|
+
: unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
|
|
498
599
|
|
|
499
600
|
// A pbrt scene archive takes priority — it owns its own geometry/texture refs.
|
|
500
|
-
if ( pickEntryPath(
|
|
601
|
+
if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
|
|
501
602
|
|
|
502
|
-
|
|
503
|
-
if ( result ) return result;
|
|
504
|
-
return await this.findAndLoadModelFromZip( zip, filename );
|
|
603
|
+
return await this._loadNonPBRTArchive( entries, filename );
|
|
505
604
|
|
|
506
605
|
} catch ( error ) {
|
|
507
606
|
|
|
508
|
-
console.error( 'Error loading
|
|
607
|
+
if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
|
|
509
608
|
throw error;
|
|
510
609
|
|
|
511
610
|
}
|
|
512
611
|
|
|
513
612
|
}
|
|
514
613
|
|
|
614
|
+
async _loadNonPBRTArchive( entries, filename ) {
|
|
615
|
+
|
|
616
|
+
const result = await this.processObjMtlPairsInZip( entries, filename );
|
|
617
|
+
if ( result ) return result;
|
|
618
|
+
return await this.findAndLoadModelFromZip( entries, filename );
|
|
619
|
+
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Index an uncompressed tar without retaining it. Entries are read back from the File on
|
|
624
|
+
* demand, so residency is the open include chain rather than the whole archive.
|
|
625
|
+
*
|
|
626
|
+
* Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
|
|
627
|
+
* the scene that comes out is what runs the tab out of memory. So a large multi-part archive
|
|
628
|
+
* stops here and asks which parts to load, the same as the streamed path does. The index is
|
|
629
|
+
* already built at that point, so the question costs nothing.
|
|
630
|
+
* @private
|
|
631
|
+
*/
|
|
632
|
+
async _openSeekableArchive( file, filename, element, promptBytes ) {
|
|
633
|
+
|
|
634
|
+
const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
|
|
635
|
+
|
|
636
|
+
const source = await openTar( file, {
|
|
637
|
+
filter: chosen.length ? elementFilter( chosen ) : null,
|
|
638
|
+
retain: () => false,
|
|
639
|
+
} );
|
|
640
|
+
|
|
641
|
+
const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
|
|
642
|
+
|
|
643
|
+
if ( chosen.length === 0 ) this._requireElementChoice( filename, source.listing, totalBytes, promptBytes );
|
|
644
|
+
|
|
645
|
+
console.info(
|
|
646
|
+
`Archive "${filename}": indexed ${source.indexed} entries, ` +
|
|
647
|
+
`${( totalBytes / 1e9 ).toFixed( 1 )} GB, none resident` +
|
|
648
|
+
( chosen.length ? `, ${chosen.length} part${chosen.length > 1 ? 's' : ''} selected.` : '.' )
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
return source;
|
|
652
|
+
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Stop and ask which parts to load, when the archive is big enough that the answer matters
|
|
657
|
+
* and it actually has parts to choose between. Not a capability limit — selecting every part
|
|
658
|
+
* is a valid answer and loads the whole scene.
|
|
659
|
+
* @private
|
|
660
|
+
*/
|
|
661
|
+
_requireElementChoice( filename, listing, totalBytes, promptBytes = ARCHIVE_ELEMENT_PROMPT_BYTES ) {
|
|
662
|
+
|
|
663
|
+
if ( totalBytes < promptBytes ) return;
|
|
664
|
+
|
|
665
|
+
const { root, elements } = listArchiveElements( listing );
|
|
666
|
+
if ( elements.length < 2 ) return;
|
|
667
|
+
|
|
668
|
+
const error = new Error(
|
|
669
|
+
`"${filename}" holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts. ` +
|
|
670
|
+
'Choose which to load — loading all of them at once may exhaust memory.'
|
|
671
|
+
);
|
|
672
|
+
error.code = 'ARCHIVE_NEEDS_ELEMENT';
|
|
673
|
+
error.root = root;
|
|
674
|
+
error.elements = elements;
|
|
675
|
+
error.totalBytes = totalBytes;
|
|
676
|
+
|
|
677
|
+
// Logged as a warning on purpose: recording an ERROR makes a strict host throw an
|
|
678
|
+
// EngineIssueError from inside record(), and the caller never learns which parts it
|
|
679
|
+
// could have chosen. The typed throw below is the refusal, and it carries the list.
|
|
680
|
+
this._issues?.record(
|
|
681
|
+
ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
|
|
682
|
+
`archive holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts; choose which to load`,
|
|
683
|
+
{ root, elements: elements.map( e => e.prefix ), totalBytes },
|
|
684
|
+
ISSUE_SEVERITY.WARNING
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
throw error;
|
|
688
|
+
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
|
|
692
|
+
|
|
693
|
+
const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
|
|
694
|
+
const read = kind === 'gzip' ? readTarGz : readTar;
|
|
695
|
+
const { entries, listing, retainedBytes, truncated } = await read( file, {
|
|
696
|
+
filter: chosen.length ? elementFilter( chosen ) : null,
|
|
697
|
+
...( byteBudget === undefined ? {} : { byteBudget } ),
|
|
698
|
+
onProgress: p => updateLoading( {
|
|
699
|
+
isLoading: true,
|
|
700
|
+
status: `Reading archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`,
|
|
701
|
+
progress: 4
|
|
702
|
+
} )
|
|
703
|
+
} );
|
|
704
|
+
|
|
705
|
+
// Choosing every part is a valid answer, so a selection is not refused again for being
|
|
706
|
+
// large: the read budget exists to stop an unasked-for whole-archive load. Past this
|
|
707
|
+
// point the scene's own memory preflight is what refuses, with a figure to act on.
|
|
708
|
+
if ( truncated && chosen.length === 0 ) {
|
|
709
|
+
|
|
710
|
+
const { root, elements } = listArchiveElements( listing );
|
|
711
|
+
const total = listing.reduce( ( n, e ) => n + e.size, 0 );
|
|
712
|
+
const error = new Error(
|
|
713
|
+
`"${filename}" unpacks to ${( total / 1e9 ).toFixed( 1 )} GB, past the load budget. ` +
|
|
714
|
+
`Load one of its ${elements.length} parts instead.`
|
|
715
|
+
);
|
|
716
|
+
error.code = 'ARCHIVE_NEEDS_ELEMENT';
|
|
717
|
+
error.root = root;
|
|
718
|
+
error.elements = elements;
|
|
719
|
+
error.totalBytes = total;
|
|
720
|
+
// Warning, not error: a strict host's throw from record() would replace the typed
|
|
721
|
+
// error below and take the part list with it.
|
|
722
|
+
this._issues?.record(
|
|
723
|
+
ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
|
|
724
|
+
`archive unpacks to ${( total / 1e9 ).toFixed( 1 )} GB; choose one of ${elements.length} parts`,
|
|
725
|
+
{ root, elements: elements.map( e => e.prefix ), totalBytes: total },
|
|
726
|
+
ISSUE_SEVERITY.WARNING
|
|
727
|
+
);
|
|
728
|
+
throw error;
|
|
729
|
+
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if ( chosen.length ) {
|
|
733
|
+
|
|
734
|
+
console.info(
|
|
735
|
+
`Archive "${filename}": loaded ${chosen.map( c => `"${c}"` ).join( ', ' )} — ` +
|
|
736
|
+
`${Object.keys( entries ).length} of ${listing.length} files, ${( retainedBytes / 1048576 ).toFixed( 1 )} MB.`
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return entries;
|
|
742
|
+
|
|
743
|
+
}
|
|
744
|
+
|
|
515
745
|
/**
|
|
516
746
|
* Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
|
|
517
747
|
* (following Include/Import), builds a THREE.Group, sets the infinite light
|
|
518
748
|
* as the scene environment, and runs the standard onModelLoad pipeline.
|
|
519
|
-
* @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
|
|
749
|
+
* @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
|
|
520
750
|
* @param {string} filename - original archive name (for display/events)
|
|
521
751
|
*/
|
|
522
|
-
async loadPBRTFromZip( zip, filename, entryPath = null ) {
|
|
752
|
+
async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
|
|
523
753
|
|
|
524
754
|
updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
|
|
525
755
|
|
|
@@ -553,17 +783,28 @@ export class AssetLoader extends EventDispatcher {
|
|
|
553
783
|
|
|
554
784
|
};
|
|
555
785
|
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
vfs: zip, entryPath
|
|
786
|
+
const pbrtStart = performance.now();
|
|
787
|
+
const { group, environment, report, warnings, meshCount, entryPath: loadedEntry, candidates,
|
|
788
|
+
parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
|
|
789
|
+
droppedNoTemplate } = await loadPBRTScene( {
|
|
790
|
+
vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes,
|
|
791
|
+
maxTriangles: options.maxTriangles,
|
|
792
|
+
maxPlacements: options.maxPlacements,
|
|
793
|
+
mergeShapesAbove: options.mergeShapesAbove,
|
|
794
|
+
curveSteps: options.curveSteps,
|
|
795
|
+
curveSides: options.curveSides
|
|
561
796
|
} );
|
|
562
797
|
|
|
798
|
+
// Phase breakdown for scaling work; the engine's own build timings live in
|
|
799
|
+
// SceneProcessor.performanceMetrics.
|
|
800
|
+
this.lastPBRTStats = {
|
|
801
|
+
parseMs, buildMs, loaderMs: performance.now() - pbrtStart,
|
|
802
|
+
triangleCount, placementCount, mergedShapes, skippedForBudget, droppedNoTemplate, meshCount
|
|
803
|
+
};
|
|
804
|
+
|
|
563
805
|
// An archive can hold several independent scenes (transparent-machines ships five
|
|
564
806
|
// animation frames). Only one is loaded, so name it and the alternatives rather
|
|
565
807
|
// than leave the user comparing against a reference of a different scene.
|
|
566
|
-
const candidates = listEntryPaths( zip );
|
|
567
808
|
if ( candidates.length > 1 ) {
|
|
568
809
|
|
|
569
810
|
const others = candidates.filter( p => p !== loadedEntry );
|
|
@@ -580,7 +821,8 @@ export class AssetLoader extends EventDispatcher {
|
|
|
580
821
|
// Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
|
|
581
822
|
if ( report && report.length && typeof console.table === 'function' ) {
|
|
582
823
|
|
|
583
|
-
|
|
824
|
+
const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
|
|
825
|
+
console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
|
|
584
826
|
console.table( report );
|
|
585
827
|
console.groupEnd();
|
|
586
828
|
|
|
@@ -5,6 +5,21 @@ import { createLogger, fmt } from '../utils/Logger.js';
|
|
|
5
5
|
|
|
6
6
|
const log = createLogger( 'bvh' );
|
|
7
7
|
|
|
8
|
+
// Inline copies of EngineDefaults.BVH_LEAF_MARKERS / bvhIndexView — this module also runs inside
|
|
9
|
+
// a worker, where importing Constants is not safe. Keep in step with EngineDefaults.js.
|
|
10
|
+
// Index fields are u32 BIT PATTERNS, never float values: as floats they round past 2^24.
|
|
11
|
+
const TRIANGLE_LEAF = 0x40000000;
|
|
12
|
+
const FRONTIER_LEAF = 0x40000002;
|
|
13
|
+
const indexView = f32 => new Uint32Array( f32.buffer, f32.byteOffset, f32.length );
|
|
14
|
+
const BVH_MAX_INDEX = 0x40000000;
|
|
15
|
+
|
|
16
|
+
function assertFits( count, what ) {
|
|
17
|
+
|
|
18
|
+
if ( count >= BVH_MAX_INDEX ) throw new RangeError( `${what} is ${count}, at or past the BVH index limit of ${BVH_MAX_INDEX}` );
|
|
19
|
+
return count;
|
|
20
|
+
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
// Injected, not imported: this module also runs inside the worker, and `?worker&inline`
|
|
9
24
|
// would embed a second copy of BVHWorker's source there.
|
|
10
25
|
let createBVHWorker = null;
|
|
@@ -19,19 +34,18 @@ export function setBVHWorkerFactory( factory ) {
|
|
|
19
34
|
// Cannot import Constants.js because BVHBuilder runs inside BVHWorker
|
|
20
35
|
// where `window` (used elsewhere in Constants.js) is not defined.
|
|
21
36
|
const TRIANGLE_DATA_LAYOUT = {
|
|
22
|
-
FLOATS_PER_TRIANGLE:
|
|
37
|
+
FLOATS_PER_TRIANGLE: 20,
|
|
23
38
|
POSITION_A_OFFSET: 0,
|
|
24
39
|
POSITION_B_OFFSET: 4,
|
|
25
|
-
POSITION_C_OFFSET: 8
|
|
26
|
-
NORMAL_A_OFFSET: 12,
|
|
27
|
-
NORMAL_B_OFFSET: 16,
|
|
28
|
-
NORMAL_C_OFFSET: 20,
|
|
29
|
-
UV_AB_OFFSET: 24,
|
|
30
|
-
UV_C_MAT_OFFSET: 28 // vec4: uvC.x, uvC.y, materialIndex, meshIndex
|
|
40
|
+
POSITION_C_OFFSET: 8
|
|
31
41
|
};
|
|
32
42
|
|
|
33
43
|
const FPT = TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE;
|
|
34
44
|
|
|
45
|
+
// The record buffer is uint; positions are f32 in it. Views share the memory, no copy.
|
|
46
|
+
const floatView = ( data ) => data instanceof Float32Array
|
|
47
|
+
? data : new Float32Array( data.buffer, data.byteOffset, data.length );
|
|
48
|
+
|
|
35
49
|
class BVHNode {
|
|
36
50
|
|
|
37
51
|
constructor() {
|
|
@@ -215,7 +229,7 @@ export class BVHBuilder {
|
|
|
215
229
|
initializeTriangleArrays() {
|
|
216
230
|
|
|
217
231
|
const n = this.totalTriangles;
|
|
218
|
-
const src = this.triangles;
|
|
232
|
+
const src = floatView( this.triangles );
|
|
219
233
|
const PA = TRIANGLE_DATA_LAYOUT.POSITION_A_OFFSET;
|
|
220
234
|
const PB = TRIANGLE_DATA_LAYOUT.POSITION_B_OFFSET;
|
|
221
235
|
const PC = TRIANGLE_DATA_LAYOUT.POSITION_C_OFFSET;
|
|
@@ -679,7 +693,7 @@ export class BVHBuilder {
|
|
|
679
693
|
// Phase 6: Create reordered triangle data from final index order
|
|
680
694
|
const reorderStart = performance.now();
|
|
681
695
|
const triSrc = this.triangles;
|
|
682
|
-
const reordered = reorderTarget || new
|
|
696
|
+
const reordered = reorderTarget || new triSrc.constructor( n * FPT );
|
|
683
697
|
for ( let i = 0; i < n; i ++ ) {
|
|
684
698
|
|
|
685
699
|
const srcOff = this.indices[ i ] * FPT;
|
|
@@ -1580,7 +1594,9 @@ export class BVHBuilder {
|
|
|
1580
1594
|
// Inner: [leftMin.xyz, leftChild] [leftMax.xyz, rightChild] [rightMin.xyz, 0] [rightMax.xyz, 0]
|
|
1581
1595
|
// Leaf: [triOffset, triCount, 0, -1] [0,0,0,0] [0,0,0,0] [0,0,0,0]
|
|
1582
1596
|
const FLOATS_PER_NODE = 16;
|
|
1597
|
+
assertFits( nodes.length, 'BLAS node count' );
|
|
1583
1598
|
const data = new Float32Array( nodes.length * FLOATS_PER_NODE );
|
|
1599
|
+
const idx = indexView( data );
|
|
1584
1600
|
|
|
1585
1601
|
for ( let i = 0; i < nodes.length; i ++ ) {
|
|
1586
1602
|
|
|
@@ -1596,12 +1612,12 @@ export class BVHBuilder {
|
|
|
1596
1612
|
data[ o ] = left.minX;
|
|
1597
1613
|
data[ o + 1 ] = left.minY;
|
|
1598
1614
|
data[ o + 2 ] = left.minZ;
|
|
1599
|
-
|
|
1615
|
+
idx[ o + 3 ] = left._flatIndex;
|
|
1600
1616
|
|
|
1601
1617
|
data[ o + 4 ] = left.maxX;
|
|
1602
1618
|
data[ o + 5 ] = left.maxY;
|
|
1603
1619
|
data[ o + 6 ] = left.maxZ;
|
|
1604
|
-
|
|
1620
|
+
idx[ o + 7 ] = right._flatIndex;
|
|
1605
1621
|
|
|
1606
1622
|
data[ o + 8 ] = right.minX;
|
|
1607
1623
|
data[ o + 9 ] = right.minY;
|
|
@@ -1615,11 +1631,10 @@ export class BVHBuilder {
|
|
|
1615
1631
|
|
|
1616
1632
|
} else {
|
|
1617
1633
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
data[ o + 1 ] = node.triangleCount;
|
|
1634
|
+
idx[ o ] = node.triangleOffset;
|
|
1635
|
+
idx[ o + 1 ] = node.triangleCount;
|
|
1621
1636
|
// data[o+2] = 0 (padding)
|
|
1622
|
-
|
|
1637
|
+
idx[ o + 3 ] = TRIANGLE_LEAF;
|
|
1623
1638
|
|
|
1624
1639
|
}
|
|
1625
1640
|
|
|
@@ -1630,7 +1645,7 @@ export class BVHBuilder {
|
|
|
1630
1645
|
}
|
|
1631
1646
|
|
|
1632
1647
|
/**
|
|
1633
|
-
* Flatten BVH tree marking frontier leaves with
|
|
1648
|
+
* Flatten BVH tree marking frontier leaves with the frontier tag.
|
|
1634
1649
|
* Returns the flat data and a frontier map for assembly.
|
|
1635
1650
|
* @param {BVHNode} root
|
|
1636
1651
|
* @returns {{ flatData: Float32Array, frontierMap: Array<{taskId: number, flatIndex: number}> }}
|
|
@@ -1653,8 +1668,10 @@ export class BVHBuilder {
|
|
|
1653
1668
|
}
|
|
1654
1669
|
|
|
1655
1670
|
// Second pass: write flat data
|
|
1671
|
+
assertFits( nodes.length, 'BLAS node count' );
|
|
1656
1672
|
const data = new Float32Array( nodes.length * FLOATS_PER_NODE );
|
|
1657
1673
|
const frontierMap = [];
|
|
1674
|
+
const idx = indexView( data );
|
|
1658
1675
|
|
|
1659
1676
|
for ( let i = 0; i < nodes.length; i ++ ) {
|
|
1660
1677
|
|
|
@@ -1670,12 +1687,12 @@ export class BVHBuilder {
|
|
|
1670
1687
|
data[ o ] = left.minX;
|
|
1671
1688
|
data[ o + 1 ] = left.minY;
|
|
1672
1689
|
data[ o + 2 ] = left.minZ;
|
|
1673
|
-
|
|
1690
|
+
idx[ o + 3 ] = left._flatIndex;
|
|
1674
1691
|
|
|
1675
1692
|
data[ o + 4 ] = left.maxX;
|
|
1676
1693
|
data[ o + 5 ] = left.maxY;
|
|
1677
1694
|
data[ o + 6 ] = left.maxZ;
|
|
1678
|
-
|
|
1695
|
+
idx[ o + 7 ] = right._flatIndex;
|
|
1679
1696
|
|
|
1680
1697
|
data[ o + 8 ] = right.minX;
|
|
1681
1698
|
data[ o + 9 ] = right.minY;
|
|
@@ -1687,21 +1704,19 @@ export class BVHBuilder {
|
|
|
1687
1704
|
|
|
1688
1705
|
} else if ( node.isFrontier ) {
|
|
1689
1706
|
|
|
1690
|
-
// Frontier leaf: mark with -2 sentinel, use the taskId stored on the node
|
|
1691
1707
|
const taskId = node.frontierTaskId;
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1708
|
+
idx[ o ] = node.triangleOffset;
|
|
1709
|
+
idx[ o + 1 ] = node.triangleCount;
|
|
1710
|
+
idx[ o + 2 ] = taskId;
|
|
1711
|
+
idx[ o + 3 ] = FRONTIER_LEAF;
|
|
1696
1712
|
|
|
1697
1713
|
frontierMap.push( { taskId, flatIndex: i } );
|
|
1698
1714
|
|
|
1699
1715
|
} else {
|
|
1700
1716
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
data[ o + 3 ] = - 1; // Leaf marker
|
|
1717
|
+
idx[ o ] = node.triangleOffset;
|
|
1718
|
+
idx[ o + 1 ] = node.triangleCount;
|
|
1719
|
+
idx[ o + 3 ] = TRIANGLE_LEAF;
|
|
1705
1720
|
|
|
1706
1721
|
}
|
|
1707
1722
|
|
|
@@ -1735,7 +1750,9 @@ export class BVHBuilder {
|
|
|
1735
1750
|
}
|
|
1736
1751
|
|
|
1737
1752
|
// Allocate final array
|
|
1753
|
+
assertFits( totalNodes, 'assembled BLAS node count' );
|
|
1738
1754
|
const finalData = new Float32Array( totalNodes * FLOATS_PER_NODE );
|
|
1755
|
+
const finalIdx = indexView( finalData );
|
|
1739
1756
|
|
|
1740
1757
|
// Copy top-level data
|
|
1741
1758
|
finalData.set( topFlatData );
|
|
@@ -1766,12 +1783,11 @@ export class BVHBuilder {
|
|
|
1766
1783
|
|
|
1767
1784
|
const o = destOffset + j * FLOATS_PER_NODE;
|
|
1768
1785
|
|
|
1769
|
-
//
|
|
1770
|
-
if (
|
|
1786
|
+
// Leaf tags sit above every valid index, so one unsigned compare separates them.
|
|
1787
|
+
if ( finalIdx[ o + 3 ] < TRIANGLE_LEAF ) {
|
|
1771
1788
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
finalData[ o + 7 ] += globalOffset;
|
|
1789
|
+
finalIdx[ o + 3 ] += globalOffset;
|
|
1790
|
+
finalIdx[ o + 7 ] += globalOffset;
|
|
1775
1791
|
|
|
1776
1792
|
}
|
|
1777
1793
|
|