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.
- package/README.md +164 -0
- package/dist/rayzee.es.js +6547 -4334
- package/dist/rayzee.umd.js +122 -73
- package/package.json +1 -1
- package/src/EngineDefaults.js +168 -13
- package/src/EngineIssues.js +6 -0
- package/src/PathTracerApp.js +128 -20
- package/src/Processor/ArchiveReader.js +538 -0
- package/src/Processor/AssetLoader.js +357 -22
- 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 +173 -0
- package/src/Processor/PBRT/LoopSubdivision.js +240 -0
- package/src/Processor/PBRT/PBRTCurves.js +249 -0
- package/src/Processor/PBRT/PBRTMath.js +28 -0
- package/src/Processor/PBRT/PBRTParser.js +187 -41
- package/src/Processor/PBRT/PBRTSceneBuilder.js +659 -34
- package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
- package/src/Processor/PBRT/index.js +306 -45
- 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 +1063 -366
- 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/CameraManager.js +11 -0
- package/src/managers/MaterialDataManager.js +29 -23
- package/src/managers/TransformManager.js +9 -192
|
@@ -11,12 +11,14 @@ 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
19
|
import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
|
|
18
20
|
import { extractSceneMetadata } from './SceneMetadata.js';
|
|
19
|
-
import { ISSUE_CODES } from '../EngineIssues.js';
|
|
21
|
+
import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
|
|
20
22
|
import { getRenderProfile } from '../EngineDefaults.js';
|
|
21
23
|
|
|
22
24
|
// Define supported file formats
|
|
@@ -32,13 +34,27 @@ 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
|
|
39
44
|
// layers and the image assets they reference.
|
|
40
45
|
const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
|
|
41
46
|
const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
|
|
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;
|
|
42
58
|
// A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
|
|
43
59
|
// reorders and disposes what it is given, but never writes the attributes.
|
|
44
60
|
function standInForSplit( source ) {
|
|
@@ -274,7 +290,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
274
290
|
}
|
|
275
291
|
|
|
276
292
|
// Asset loading methods
|
|
277
|
-
async loadAssetFromFile( file ) {
|
|
293
|
+
async loadAssetFromFile( file, options = {} ) {
|
|
278
294
|
|
|
279
295
|
const filename = file.name;
|
|
280
296
|
const format = this.getFileFormat( filename );
|
|
@@ -289,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
289
305
|
case 'model': result = await this.loadModelFromFile( file, filename ); break;
|
|
290
306
|
case 'environment':
|
|
291
307
|
case 'image': result = await this.loadEnvironmentFromFile( file, filename ); break;
|
|
292
|
-
case 'archive': result = await this.loadArchiveFromFile( file, filename ); break;
|
|
308
|
+
case 'archive': result = await this.loadArchiveFromFile( file, filename, options ); break;
|
|
293
309
|
default: throw new Error( `Unknown asset type: ${format.type}` );
|
|
294
310
|
|
|
295
311
|
}
|
|
@@ -482,37 +498,258 @@ export class AssetLoader extends EventDispatcher {
|
|
|
482
498
|
}
|
|
483
499
|
|
|
484
500
|
// Archive handling
|
|
485
|
-
|
|
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
|
+
|
|
553
|
+
/**
|
|
554
|
+
* @param {File|Blob} file
|
|
555
|
+
* @param {string} filename
|
|
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.
|
|
571
|
+
*/
|
|
572
|
+
async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
|
|
486
573
|
|
|
487
574
|
try {
|
|
488
575
|
|
|
489
|
-
const
|
|
490
|
-
|
|
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 ) ) );
|
|
491
599
|
|
|
492
600
|
// A pbrt scene archive takes priority — it owns its own geometry/texture refs.
|
|
493
|
-
if ( pickEntryPath(
|
|
601
|
+
if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
|
|
494
602
|
|
|
495
|
-
|
|
496
|
-
if ( result ) return result;
|
|
497
|
-
return await this.findAndLoadModelFromZip( zip, filename );
|
|
603
|
+
return await this._loadNonPBRTArchive( entries, filename );
|
|
498
604
|
|
|
499
605
|
} catch ( error ) {
|
|
500
606
|
|
|
501
|
-
console.error( 'Error loading
|
|
607
|
+
if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
|
|
608
|
+
throw error;
|
|
609
|
+
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
}
|
|
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
|
+
);
|
|
502
728
|
throw error;
|
|
503
729
|
|
|
504
730
|
}
|
|
505
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
|
+
|
|
506
743
|
}
|
|
507
744
|
|
|
508
745
|
/**
|
|
509
746
|
* Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
|
|
510
747
|
* (following Include/Import), builds a THREE.Group, sets the infinite light
|
|
511
748
|
* as the scene environment, and runs the standard onModelLoad pipeline.
|
|
512
|
-
* @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
|
|
749
|
+
* @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
|
|
513
750
|
* @param {string} filename - original archive name (for display/events)
|
|
514
751
|
*/
|
|
515
|
-
async loadPBRTFromZip( zip, filename ) {
|
|
752
|
+
async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
|
|
516
753
|
|
|
517
754
|
updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
|
|
518
755
|
|
|
@@ -546,14 +783,46 @@ export class AssetLoader extends EventDispatcher {
|
|
|
546
783
|
|
|
547
784
|
};
|
|
548
785
|
|
|
549
|
-
const
|
|
550
|
-
|
|
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
|
|
551
796
|
} );
|
|
552
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
|
+
|
|
805
|
+
// An archive can hold several independent scenes (transparent-machines ships five
|
|
806
|
+
// animation frames). Only one is loaded, so name it and the alternatives rather
|
|
807
|
+
// than leave the user comparing against a reference of a different scene.
|
|
808
|
+
if ( candidates.length > 1 ) {
|
|
809
|
+
|
|
810
|
+
const others = candidates.filter( p => p !== loadedEntry );
|
|
811
|
+
console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
|
|
812
|
+
this._issues?.record(
|
|
813
|
+
ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
|
|
814
|
+
`archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
|
|
815
|
+
{ loaded: loadedEntry, alternatives: others },
|
|
816
|
+
ISSUE_SEVERITY.WARNING
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
}
|
|
820
|
+
|
|
553
821
|
// Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
|
|
554
822
|
if ( report && report.length && typeof console.table === 'function' ) {
|
|
555
823
|
|
|
556
|
-
|
|
824
|
+
const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
|
|
825
|
+
console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
|
|
557
826
|
console.table( report );
|
|
558
827
|
console.groupEnd();
|
|
559
828
|
|
|
@@ -561,7 +830,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
561
830
|
|
|
562
831
|
if ( warnings && warnings.length ) {
|
|
563
832
|
|
|
564
|
-
console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${
|
|
833
|
+
console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
|
|
565
834
|
warnings.forEach( w => console.warn( ' •', w ) );
|
|
566
835
|
|
|
567
836
|
}
|
|
@@ -574,14 +843,19 @@ export class AssetLoader extends EventDispatcher {
|
|
|
574
843
|
|
|
575
844
|
}
|
|
576
845
|
|
|
577
|
-
group.name =
|
|
846
|
+
group.name = loadedEntry || filename;
|
|
578
847
|
this.releaseTargetModel();
|
|
579
848
|
this.targetModel = group;
|
|
580
849
|
|
|
850
|
+
// The light's own orientation and `scale` are already baked into the texture, so the
|
|
851
|
+
// scene is only correct at rotation 0 / intensity 1. Without this the viewer profile's
|
|
852
|
+
// default 270° rotation lands on top and the sky sits 90° from where pbrt puts it.
|
|
853
|
+
if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
|
|
854
|
+
|
|
581
855
|
updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
|
|
582
856
|
await this.onModelLoad( this.targetModel );
|
|
583
857
|
|
|
584
|
-
this.dispatchEvent( { type: 'load', model: group, filename: `${
|
|
858
|
+
this.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
|
|
585
859
|
return group;
|
|
586
860
|
|
|
587
861
|
}
|
|
@@ -932,6 +1206,56 @@ export class AssetLoader extends EventDispatcher {
|
|
|
932
1206
|
|
|
933
1207
|
}
|
|
934
1208
|
|
|
1209
|
+
// MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
|
|
1210
|
+
// SceneProcessor._bucketTextures drops any texture without one — so handing the model
|
|
1211
|
+
// over before the maps decode renders the whole scene untextured. Replaces preload().
|
|
1212
|
+
async preloadMtlTextures( materials ) {
|
|
1213
|
+
|
|
1214
|
+
const pending = [];
|
|
1215
|
+
const loadTexture = materials.loadTexture.bind( materials );
|
|
1216
|
+
|
|
1217
|
+
materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
|
|
1218
|
+
|
|
1219
|
+
let settle;
|
|
1220
|
+
pending.push( new Promise( resolve => ( settle = resolve ) ) );
|
|
1221
|
+
return loadTexture( url, mapping,
|
|
1222
|
+
texture => {
|
|
1223
|
+
|
|
1224
|
+
settle();
|
|
1225
|
+
onLoad?.( texture );
|
|
1226
|
+
|
|
1227
|
+
},
|
|
1228
|
+
onProgress,
|
|
1229
|
+
error => {
|
|
1230
|
+
|
|
1231
|
+
settle();
|
|
1232
|
+
onError?.( error );
|
|
1233
|
+
|
|
1234
|
+
}
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
materials.preload();
|
|
1240
|
+
if ( pending.length === 0 ) return materials;
|
|
1241
|
+
|
|
1242
|
+
let timer;
|
|
1243
|
+
const decoded = await Promise.race( [
|
|
1244
|
+
Promise.all( pending ).then( () => true ),
|
|
1245
|
+
new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
|
|
1246
|
+
] );
|
|
1247
|
+
clearTimeout( timer );
|
|
1248
|
+
|
|
1249
|
+
if ( ! decoded ) this._issues?.record(
|
|
1250
|
+
ISSUE_CODES.TEXTURE_BUILD_FAILED,
|
|
1251
|
+
`MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
|
|
1252
|
+
{ pending: pending.length }
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
return materials;
|
|
1256
|
+
|
|
1257
|
+
}
|
|
1258
|
+
|
|
935
1259
|
async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
|
|
936
1260
|
|
|
937
1261
|
const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
|
|
@@ -951,7 +1275,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
951
1275
|
manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
|
|
952
1276
|
const mtlLoader = new MTLLoader( manager );
|
|
953
1277
|
const materials = mtlLoader.parse( mtlContent, objDir );
|
|
954
|
-
|
|
1278
|
+
await this.preloadMtlTextures( materials );
|
|
955
1279
|
return materials;
|
|
956
1280
|
|
|
957
1281
|
}
|
|
@@ -999,7 +1323,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
999
1323
|
manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
|
|
1000
1324
|
const mtlContent = this.prepareFixedMtlContent( mtlFile );
|
|
1001
1325
|
const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
|
|
1002
|
-
|
|
1326
|
+
await this.preloadMtlTextures( materials );
|
|
1003
1327
|
|
|
1004
1328
|
const objLoader = new OBJLoader( manager );
|
|
1005
1329
|
objLoader.setMaterials( materials );
|
|
@@ -1712,6 +2036,17 @@ export class AssetLoader extends EventDispatcher {
|
|
|
1712
2036
|
object.getWorldPosition( camera.position );
|
|
1713
2037
|
object.getWorldQuaternion( camera.quaternion );
|
|
1714
2038
|
|
|
2039
|
+
// Keep only the sign of the world scale. A camera has no meaningful
|
|
2040
|
+
// size, but a negative axis is a mirror — a pbrt scene's `Scale -1 1 1`
|
|
2041
|
+
// lives here — and dropping it silently un-flips the view. decompose()
|
|
2042
|
+
// parks any mirror on x, so this lands as (±1, 1, 1).
|
|
2043
|
+
object.getWorldScale( camera.scale );
|
|
2044
|
+
camera.scale.set(
|
|
2045
|
+
Math.sign( camera.scale.x ) || 1,
|
|
2046
|
+
Math.sign( camera.scale.y ) || 1,
|
|
2047
|
+
Math.sign( camera.scale.z ) || 1
|
|
2048
|
+
);
|
|
2049
|
+
|
|
1715
2050
|
// Set a meaningful name
|
|
1716
2051
|
if ( ! camera.name || camera.name === '' ) {
|
|
1717
2052
|
|