rayzee 7.30.2 → 7.31.1
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 +113 -2
- package/dist/rayzee.es.js +2733 -2251
- package/dist/rayzee.umd.js +232 -7
- package/package.json +1 -1
- package/src/EngineDefaults.js +9 -1
- package/src/EngineIssues.js +1 -0
- package/src/Passes/OIDNDenoiser.js +497 -347
- package/src/PathTracerApp.js +23 -3
- package/src/Processor/AssetLoader.js +106 -13
- package/src/Processor/PBRT/EqualAreaOctahedral.js +149 -0
- package/src/Processor/PBRT/LoopSubdivision.js +240 -0
- package/src/Processor/PBRT/PBRTMath.js +9 -0
- package/src/Processor/PBRT/PBRTSceneBuilder.js +82 -1
- package/src/Processor/PBRT/index.js +63 -9
- package/src/Processor/SceneProcessor.js +17 -1
- package/src/Processor/ToneMapWGSL.js +162 -0
- package/src/managers/CameraManager.js +11 -0
- package/src/managers/DenoisingManager.js +251 -22
- package/src/managers/OverlayManager.js +4 -0
- package/src/managers/VideoRenderManager.js +5 -5
package/src/PathTracerApp.js
CHANGED
|
@@ -404,6 +404,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
404
404
|
if ( ! this.stages.pathTracer.isComplete ) {
|
|
405
405
|
|
|
406
406
|
this.completion.updateTime();
|
|
407
|
+
this.denoisingManager?.tickContinuousDenoise( this.stages.pathTracer.frameCount );
|
|
407
408
|
|
|
408
409
|
}
|
|
409
410
|
|
|
@@ -1096,7 +1097,14 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1096
1097
|
timer.start( 'Environment CDF build (worker)' );
|
|
1097
1098
|
this.stages.pathTracer.scene.environment = environmentTexture;
|
|
1098
1099
|
cdfPromise = this.stages.pathTracer.environment.buildEnvironmentCDF()
|
|
1099
|
-
.then( () =>
|
|
1100
|
+
.then( () => {
|
|
1101
|
+
|
|
1102
|
+
timer.end( 'Environment CDF build (worker)' );
|
|
1103
|
+
// A loader can install the environment itself and still author how it should
|
|
1104
|
+
// be read — a pbrt scene bakes the light's transform in and needs rotation 0.
|
|
1105
|
+
if ( this._applySceneMetadataEnabled ) this._applySceneMetadataSettings( environmentTexture );
|
|
1106
|
+
|
|
1107
|
+
} );
|
|
1100
1108
|
|
|
1101
1109
|
}
|
|
1102
1110
|
|
|
@@ -1797,8 +1805,11 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1797
1805
|
if ( denoiser ) {
|
|
1798
1806
|
|
|
1799
1807
|
denoiser.abort();
|
|
1800
|
-
denoiser.enabled
|
|
1801
|
-
denoiser.
|
|
1808
|
+
// Through the manager both times: `denoiser.enabled` is the union of its two jobs and
|
|
1809
|
+
// `denoiser.quality` dips to a cheaper model between refreshes, so neither is the
|
|
1810
|
+
// record of what the host asked for.
|
|
1811
|
+
this.denoisingManager.applyOIDNEnabled( config.enableOIDN );
|
|
1812
|
+
this.denoisingManager.applyOIDNQuality( config.oidnQuality );
|
|
1802
1813
|
|
|
1803
1814
|
}
|
|
1804
1815
|
|
|
@@ -1933,6 +1944,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1933
1944
|
dynamicDispatch: stage._useDynamicDispatch,
|
|
1934
1945
|
autoFocusMode: this.cameraManager?.autoFocusMode,
|
|
1935
1946
|
autoExposure: this.stages.autoExposure?.enabled,
|
|
1947
|
+
continuousDenoise: this.denoisingManager?.continuousDenoise,
|
|
1936
1948
|
};
|
|
1937
1949
|
|
|
1938
1950
|
}
|
|
@@ -1966,6 +1978,8 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1966
1978
|
|
|
1967
1979
|
this.cameraManager?.setAutoFocusMode( 'manual' );
|
|
1968
1980
|
if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
|
|
1981
|
+
// Cadence denoising is wall-clock driven, so which frame it lands on is not reproducible.
|
|
1982
|
+
this.denoisingManager?.setContinuousDenoise( false );
|
|
1969
1983
|
|
|
1970
1984
|
// The seed axis free-runs across accumulation resets so a camera drag gets fresh
|
|
1971
1985
|
// sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
|
|
@@ -1990,6 +2004,12 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1990
2004
|
|
|
1991
2005
|
}
|
|
1992
2006
|
|
|
2007
|
+
if ( prev.continuousDenoise !== undefined ) {
|
|
2008
|
+
|
|
2009
|
+
this.denoisingManager?.setContinuousDenoise( prev.continuousDenoise );
|
|
2010
|
+
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1993
2013
|
stage._pinSeedToFrame = false;
|
|
1994
2014
|
|
|
1995
2015
|
this._deterministicRestore = null;
|
|
@@ -14,9 +14,9 @@ import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.j
|
|
|
14
14
|
import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
|
|
15
15
|
import { BuildTimer } from './BuildTimer.js';
|
|
16
16
|
import { getAssetConfig } from '../AssetConfig.js';
|
|
17
|
-
import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
|
|
17
|
+
import { loadPBRTScene, pickEntryPath, listEntryPaths } from './PBRT/index.js';
|
|
18
18
|
import { extractSceneMetadata } from './SceneMetadata.js';
|
|
19
|
-
import { ISSUE_CODES } from '../EngineIssues.js';
|
|
19
|
+
import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
|
|
20
20
|
import { getRenderProfile } from '../EngineDefaults.js';
|
|
21
21
|
|
|
22
22
|
// Define supported file formats
|
|
@@ -39,6 +39,7 @@ const SUPPORTED_FORMATS = {
|
|
|
39
39
|
// layers and the image assets they reference.
|
|
40
40
|
const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
|
|
41
41
|
const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
|
|
42
|
+
const MTL_TEXTURE_TIMEOUT_MS = 30000;
|
|
42
43
|
// A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
|
|
43
44
|
// reorders and disposes what it is given, but never writes the attributes.
|
|
44
45
|
function standInForSplit( source ) {
|
|
@@ -482,7 +483,13 @@ export class AssetLoader extends EventDispatcher {
|
|
|
482
483
|
}
|
|
483
484
|
|
|
484
485
|
// Archive handling
|
|
485
|
-
|
|
486
|
+
/**
|
|
487
|
+
* @param {File|Blob} file
|
|
488
|
+
* @param {string} filename
|
|
489
|
+
* @param {{pbrtEntry?: string}} [options] - `pbrtEntry` names which .pbrt to load when the
|
|
490
|
+
* archive holds several independent scenes; the issue log lists what else was available.
|
|
491
|
+
*/
|
|
492
|
+
async loadArchiveFromFile( file, filename, { pbrtEntry = null } = {} ) {
|
|
486
493
|
|
|
487
494
|
try {
|
|
488
495
|
|
|
@@ -490,7 +497,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
490
497
|
const zip = unzipSync( new Uint8Array( arrayBuffer ) );
|
|
491
498
|
|
|
492
499
|
// A pbrt scene archive takes priority — it owns its own geometry/texture refs.
|
|
493
|
-
if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename );
|
|
500
|
+
if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename, pbrtEntry );
|
|
494
501
|
|
|
495
502
|
const result = await this.processObjMtlPairsInZip( zip, filename );
|
|
496
503
|
if ( result ) return result;
|
|
@@ -512,7 +519,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
512
519
|
* @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
|
|
513
520
|
* @param {string} filename - original archive name (for display/events)
|
|
514
521
|
*/
|
|
515
|
-
async loadPBRTFromZip( zip, filename ) {
|
|
522
|
+
async loadPBRTFromZip( zip, filename, entryPath = null ) {
|
|
516
523
|
|
|
517
524
|
updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
|
|
518
525
|
|
|
@@ -546,14 +553,34 @@ export class AssetLoader extends EventDispatcher {
|
|
|
546
553
|
|
|
547
554
|
};
|
|
548
555
|
|
|
549
|
-
const
|
|
550
|
-
|
|
556
|
+
const requested = entryPath && listEntryPaths( zip ).includes( entryPath ) ? entryPath : null;
|
|
557
|
+
if ( entryPath && ! requested ) console.warn( `PBRT entry "${entryPath}" is not a scene in this archive — auto-detecting instead` );
|
|
558
|
+
|
|
559
|
+
const { group, environment, report, warnings, entryPath: loadedEntry } = await loadPBRTScene( {
|
|
560
|
+
vfs: zip, entryPath: requested, plyParser, imageFromBytes, envFromBytes
|
|
551
561
|
} );
|
|
552
562
|
|
|
563
|
+
// An archive can hold several independent scenes (transparent-machines ships five
|
|
564
|
+
// animation frames). Only one is loaded, so name it and the alternatives rather
|
|
565
|
+
// than leave the user comparing against a reference of a different scene.
|
|
566
|
+
const candidates = listEntryPaths( zip );
|
|
567
|
+
if ( candidates.length > 1 ) {
|
|
568
|
+
|
|
569
|
+
const others = candidates.filter( p => p !== loadedEntry );
|
|
570
|
+
console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
|
|
571
|
+
this._issues?.record(
|
|
572
|
+
ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
|
|
573
|
+
`archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
|
|
574
|
+
{ loaded: loadedEntry, alternatives: others },
|
|
575
|
+
ISSUE_SEVERITY.WARNING
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
}
|
|
579
|
+
|
|
553
580
|
// Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
|
|
554
581
|
if ( report && report.length && typeof console.table === 'function' ) {
|
|
555
582
|
|
|
556
|
-
console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${
|
|
583
|
+
console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${loadedEntry}"` );
|
|
557
584
|
console.table( report );
|
|
558
585
|
console.groupEnd();
|
|
559
586
|
|
|
@@ -561,7 +588,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
561
588
|
|
|
562
589
|
if ( warnings && warnings.length ) {
|
|
563
590
|
|
|
564
|
-
console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${
|
|
591
|
+
console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
|
|
565
592
|
warnings.forEach( w => console.warn( ' •', w ) );
|
|
566
593
|
|
|
567
594
|
}
|
|
@@ -574,14 +601,19 @@ export class AssetLoader extends EventDispatcher {
|
|
|
574
601
|
|
|
575
602
|
}
|
|
576
603
|
|
|
577
|
-
group.name =
|
|
604
|
+
group.name = loadedEntry || filename;
|
|
578
605
|
this.releaseTargetModel();
|
|
579
606
|
this.targetModel = group;
|
|
580
607
|
|
|
608
|
+
// The light's own orientation and `scale` are already baked into the texture, so the
|
|
609
|
+
// scene is only correct at rotation 0 / intensity 1. Without this the viewer profile's
|
|
610
|
+
// default 270° rotation lands on top and the sky sits 90° from where pbrt puts it.
|
|
611
|
+
if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
|
|
612
|
+
|
|
581
613
|
updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
|
|
582
614
|
await this.onModelLoad( this.targetModel );
|
|
583
615
|
|
|
584
|
-
this.dispatchEvent( { type: 'load', model: group, filename: `${
|
|
616
|
+
this.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
|
|
585
617
|
return group;
|
|
586
618
|
|
|
587
619
|
}
|
|
@@ -932,6 +964,56 @@ export class AssetLoader extends EventDispatcher {
|
|
|
932
964
|
|
|
933
965
|
}
|
|
934
966
|
|
|
967
|
+
// MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
|
|
968
|
+
// SceneProcessor._bucketTextures drops any texture without one — so handing the model
|
|
969
|
+
// over before the maps decode renders the whole scene untextured. Replaces preload().
|
|
970
|
+
async preloadMtlTextures( materials ) {
|
|
971
|
+
|
|
972
|
+
const pending = [];
|
|
973
|
+
const loadTexture = materials.loadTexture.bind( materials );
|
|
974
|
+
|
|
975
|
+
materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
|
|
976
|
+
|
|
977
|
+
let settle;
|
|
978
|
+
pending.push( new Promise( resolve => ( settle = resolve ) ) );
|
|
979
|
+
return loadTexture( url, mapping,
|
|
980
|
+
texture => {
|
|
981
|
+
|
|
982
|
+
settle();
|
|
983
|
+
onLoad?.( texture );
|
|
984
|
+
|
|
985
|
+
},
|
|
986
|
+
onProgress,
|
|
987
|
+
error => {
|
|
988
|
+
|
|
989
|
+
settle();
|
|
990
|
+
onError?.( error );
|
|
991
|
+
|
|
992
|
+
}
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
materials.preload();
|
|
998
|
+
if ( pending.length === 0 ) return materials;
|
|
999
|
+
|
|
1000
|
+
let timer;
|
|
1001
|
+
const decoded = await Promise.race( [
|
|
1002
|
+
Promise.all( pending ).then( () => true ),
|
|
1003
|
+
new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
|
|
1004
|
+
] );
|
|
1005
|
+
clearTimeout( timer );
|
|
1006
|
+
|
|
1007
|
+
if ( ! decoded ) this._issues?.record(
|
|
1008
|
+
ISSUE_CODES.TEXTURE_BUILD_FAILED,
|
|
1009
|
+
`MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
|
|
1010
|
+
{ pending: pending.length }
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
return materials;
|
|
1014
|
+
|
|
1015
|
+
}
|
|
1016
|
+
|
|
935
1017
|
async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
|
|
936
1018
|
|
|
937
1019
|
const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
|
|
@@ -951,7 +1033,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
951
1033
|
manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
|
|
952
1034
|
const mtlLoader = new MTLLoader( manager );
|
|
953
1035
|
const materials = mtlLoader.parse( mtlContent, objDir );
|
|
954
|
-
|
|
1036
|
+
await this.preloadMtlTextures( materials );
|
|
955
1037
|
return materials;
|
|
956
1038
|
|
|
957
1039
|
}
|
|
@@ -999,7 +1081,7 @@ export class AssetLoader extends EventDispatcher {
|
|
|
999
1081
|
manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
|
|
1000
1082
|
const mtlContent = this.prepareFixedMtlContent( mtlFile );
|
|
1001
1083
|
const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
|
|
1002
|
-
|
|
1084
|
+
await this.preloadMtlTextures( materials );
|
|
1003
1085
|
|
|
1004
1086
|
const objLoader = new OBJLoader( manager );
|
|
1005
1087
|
objLoader.setMaterials( materials );
|
|
@@ -1712,6 +1794,17 @@ export class AssetLoader extends EventDispatcher {
|
|
|
1712
1794
|
object.getWorldPosition( camera.position );
|
|
1713
1795
|
object.getWorldQuaternion( camera.quaternion );
|
|
1714
1796
|
|
|
1797
|
+
// Keep only the sign of the world scale. A camera has no meaningful
|
|
1798
|
+
// size, but a negative axis is a mirror — a pbrt scene's `Scale -1 1 1`
|
|
1799
|
+
// lives here — and dropping it silently un-flips the view. decompose()
|
|
1800
|
+
// parks any mirror on x, so this lands as (±1, 1, 1).
|
|
1801
|
+
object.getWorldScale( camera.scale );
|
|
1802
|
+
camera.scale.set(
|
|
1803
|
+
Math.sign( camera.scale.x ) || 1,
|
|
1804
|
+
Math.sign( camera.scale.y ) || 1,
|
|
1805
|
+
Math.sign( camera.scale.z ) || 1
|
|
1806
|
+
);
|
|
1807
|
+
|
|
1715
1808
|
// Set a meaningful name
|
|
1716
1809
|
if ( ! camera.name || camera.name === '' ) {
|
|
1717
1810
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Equal-area octahedral environment maps, as pbrt-v4 stores them.
|
|
3
|
+
*
|
|
4
|
+
* `LightSource "infinite" "string filename"` does NOT take a lat-long panorama.
|
|
5
|
+
* pbrt-v4's ImageInfiniteLight reads a square image through EqualAreaSphereToSquare:
|
|
6
|
+
* the upper hemisphere fills a centre diamond and the lower hemisphere the four
|
|
7
|
+
* corner triangles (`imgtool makeequiarea` / `makesky` write this). Sampling one as
|
|
8
|
+
* equirectangular smears the black corners over the whole sphere and puts the sun
|
|
9
|
+
* in the wrong place — the scene goes dark.
|
|
10
|
+
*
|
|
11
|
+
* The engine's environment path (CDF build, importance sampling, background) is
|
|
12
|
+
* equirectangular throughout, so convert once at load rather than teach every
|
|
13
|
+
* consumer a second projection. The light's own transform folds into the same
|
|
14
|
+
* resample, which is also how the engine gets a rotation it cannot otherwise
|
|
15
|
+
* express (its environmentRotation is a single Y angle; pbrt lights carry a full
|
|
16
|
+
* matrix).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as M from './PBRTMath.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* pbrt-v4 `EqualAreaSphereToSquare`: unit direction → [0,1]² in the octahedral map.
|
|
23
|
+
* Uses Math.atan where pbrt uses a polynomial approximation — this runs once on the
|
|
24
|
+
* CPU, so take the exact value.
|
|
25
|
+
*/
|
|
26
|
+
export function equalAreaSphereToSquare( dx, dy, dz ) {
|
|
27
|
+
|
|
28
|
+
const x = Math.abs( dx ), y = Math.abs( dy ), z = Math.abs( dz );
|
|
29
|
+
const r = Math.sqrt( Math.max( 0, 1 - z ) );
|
|
30
|
+
|
|
31
|
+
const a = Math.max( x, y );
|
|
32
|
+
const b = a === 0 ? 0 : Math.min( x, y ) / a;
|
|
33
|
+
|
|
34
|
+
let phi = Math.atan( b ) * ( 2 / Math.PI );
|
|
35
|
+
if ( x < y ) phi = 1 - phi;
|
|
36
|
+
|
|
37
|
+
let v = phi * r;
|
|
38
|
+
let u = r - v;
|
|
39
|
+
|
|
40
|
+
if ( dz < 0 ) {
|
|
41
|
+
|
|
42
|
+
const prevU = u;
|
|
43
|
+
u = 1 - v;
|
|
44
|
+
v = 1 - prevU;
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ( dx < 0 ) u = - u;
|
|
49
|
+
if ( dy < 0 ) v = - v;
|
|
50
|
+
|
|
51
|
+
return [ 0.5 * ( u + 1 ), 0.5 * ( v + 1 ) ];
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bilinear fetch from a tightly packed float image, clamped at the edges. */
|
|
56
|
+
function sampleBilinear( data, width, height, channels, u, v, out ) {
|
|
57
|
+
|
|
58
|
+
const fx = Math.min( Math.max( u * width - 0.5, 0 ), width - 1 );
|
|
59
|
+
const fy = Math.min( Math.max( v * height - 0.5, 0 ), height - 1 );
|
|
60
|
+
const x0 = Math.floor( fx ), y0 = Math.floor( fy );
|
|
61
|
+
const x1 = Math.min( x0 + 1, width - 1 ), y1 = Math.min( y0 + 1, height - 1 );
|
|
62
|
+
const tx = fx - x0, ty = fy - y0;
|
|
63
|
+
|
|
64
|
+
const i00 = ( y0 * width + x0 ) * channels;
|
|
65
|
+
const i10 = ( y0 * width + x1 ) * channels;
|
|
66
|
+
const i01 = ( y1 * width + x0 ) * channels;
|
|
67
|
+
const i11 = ( y1 * width + x1 ) * channels;
|
|
68
|
+
|
|
69
|
+
for ( let c = 0; c < 3; c ++ ) {
|
|
70
|
+
|
|
71
|
+
const top = data[ i00 + c ] * ( 1 - tx ) + data[ i10 + c ] * tx;
|
|
72
|
+
const bottom = data[ i01 + c ] * ( 1 - tx ) + data[ i11 + c ] * tx;
|
|
73
|
+
out[ c ] = top * ( 1 - ty ) + bottom * ty;
|
|
74
|
+
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resample a square equal-area octahedral map into an equirectangular RGBA float image.
|
|
81
|
+
*
|
|
82
|
+
* The output uses the engine's own mapping (Environment.js): u = atan2(z,x)/2pi + 0.5,
|
|
83
|
+
* v = 1 - acos(y)/pi, with row 0 at v = 1 so "up" lands at the top like any other HDRI.
|
|
84
|
+
*
|
|
85
|
+
* @param {{data: ArrayLike<number>, width: number, height: number, channels: number,
|
|
86
|
+
* bottomUp?: boolean}} src - `bottomUp` when row 0 holds the BOTTOM of the image, which is
|
|
87
|
+
* what three's EXR/HDR loaders produce; pbrt indexes the square top-down, and reading it
|
|
88
|
+
* the wrong way round turns out to rotate the sky 180° about the light's z axis.
|
|
89
|
+
* @param {number[]} lightToWorld - the light's pbrt CTM (column-major 16), or null
|
|
90
|
+
* @param {number} scale - the light's `scale` parameter, baked into the output
|
|
91
|
+
* @param {number} [outWidth] - defaults to the source width, capped at 4096
|
|
92
|
+
* @returns {{data: Float32Array, width: number, height: number}}
|
|
93
|
+
*/
|
|
94
|
+
export function octahedralToEquirect( src, lightToWorld, scale = 1, outWidth = 0 ) {
|
|
95
|
+
|
|
96
|
+
const width = outWidth || Math.min( src.width, 4096 );
|
|
97
|
+
const height = Math.max( 1, width >> 1 );
|
|
98
|
+
const out = new Float32Array( width * height * 4 );
|
|
99
|
+
|
|
100
|
+
// Directions go world → light, the opposite of the CTM, matching pbrt's
|
|
101
|
+
// renderFromLight.ApplyInverse(ray.d) before the square lookup.
|
|
102
|
+
const worldToLight = lightToWorld ? M.invert( lightToWorld ) : null;
|
|
103
|
+
const rgb = [ 0, 0, 0 ];
|
|
104
|
+
|
|
105
|
+
for ( let j = 0; j < height; j ++ ) {
|
|
106
|
+
|
|
107
|
+
// Row index IS the v axis the engine samples on, so row 0 is v = 0 (straight down)
|
|
108
|
+
// and the last row is v = 1 (up). Verified against a rendered frame — inverting
|
|
109
|
+
// this turns the sky upside down with no other visible symptom.
|
|
110
|
+
const v = ( j + 0.5 ) / height;
|
|
111
|
+
const polar = ( 1 - v ) * Math.PI;
|
|
112
|
+
const sinPolar = Math.sin( polar ), cosPolar = Math.cos( polar );
|
|
113
|
+
|
|
114
|
+
for ( let i = 0; i < width; i ++ ) {
|
|
115
|
+
|
|
116
|
+
const u = ( i + 0.5 ) / width;
|
|
117
|
+
const azimuth = ( u - 0.5 ) * 2 * Math.PI;
|
|
118
|
+
|
|
119
|
+
let dx = sinPolar * Math.cos( azimuth );
|
|
120
|
+
let dy = cosPolar;
|
|
121
|
+
let dz = sinPolar * Math.sin( azimuth );
|
|
122
|
+
|
|
123
|
+
if ( worldToLight ) {
|
|
124
|
+
|
|
125
|
+
const m = worldToLight;
|
|
126
|
+
const tx = m[ 0 ] * dx + m[ 4 ] * dy + m[ 8 ] * dz;
|
|
127
|
+
const ty = m[ 1 ] * dx + m[ 5 ] * dy + m[ 9 ] * dz;
|
|
128
|
+
const tz = m[ 2 ] * dx + m[ 6 ] * dy + m[ 10 ] * dz;
|
|
129
|
+
const len = Math.hypot( tx, ty, tz ) || 1;
|
|
130
|
+
dx = tx / len; dy = ty / len; dz = tz / len;
|
|
131
|
+
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const [ su, sv ] = equalAreaSphereToSquare( dx, dy, dz );
|
|
135
|
+
sampleBilinear( src.data, src.width, src.height, src.channels, su, src.bottomUp ? 1 - sv : sv, rgb );
|
|
136
|
+
|
|
137
|
+
const o = ( j * width + i ) * 4;
|
|
138
|
+
out[ o ] = rgb[ 0 ] * scale;
|
|
139
|
+
out[ o + 1 ] = rgb[ 1 ] * scale;
|
|
140
|
+
out[ o + 2 ] = rgb[ 2 ] * scale;
|
|
141
|
+
out[ o + 3 ] = 1;
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { data: out, width, height };
|
|
148
|
+
|
|
149
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop subdivision for pbrt's `loopsubdiv` shape.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors pbrt-v4's loopsubdiv.cpp: Warren's simplified beta for the even-vertex
|
|
5
|
+
* one-ring rule, 1/8 for the boundary rule, 3/8–1/8 for interior edge points, and a
|
|
6
|
+
* final push onto the limit surface. Scenes use this shape for exactly the objects
|
|
7
|
+
* that read wrong as a coarse cage — a tub, a cushion, a curtain — so approximating
|
|
8
|
+
* it by its control mesh is visibly not the same model.
|
|
9
|
+
*
|
|
10
|
+
* Topology is rebuilt per level from the index buffer alone; no half-edge structure,
|
|
11
|
+
* since the meshes involved are control cages (thousands of faces, not millions).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** pbrt: Beta(valence) — the one-ring weight for an interior even vertex. */
|
|
15
|
+
function beta( valence ) {
|
|
16
|
+
|
|
17
|
+
return valence === 3 ? 3 / 16 : 3 / ( 8 * valence );
|
|
18
|
+
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** pbrt: LoopGamma(valence) — the one-ring weight for the limit-surface push. */
|
|
22
|
+
function loopGamma( valence ) {
|
|
23
|
+
|
|
24
|
+
return 1 / ( valence + 3 / ( 8 * beta( valence ) ) );
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Edge records plus per-vertex neighbour rings.
|
|
30
|
+
*
|
|
31
|
+
* A vertex is on the boundary when it touches an edge with a single adjacent face;
|
|
32
|
+
* `boundaryRing` keeps only those neighbours, because the boundary rule weights the
|
|
33
|
+
* two vertices along the boundary curve, not the whole ring.
|
|
34
|
+
*/
|
|
35
|
+
function buildTopology( nv, indices ) {
|
|
36
|
+
|
|
37
|
+
const edgeIndex = new Map();
|
|
38
|
+
const edgeV0 = [], edgeV1 = [], edgeOpp0 = [], edgeOpp1 = [], edgeFaces = [];
|
|
39
|
+
const faceCount = indices.length / 3;
|
|
40
|
+
|
|
41
|
+
for ( let f = 0; f < faceCount; f ++ ) {
|
|
42
|
+
|
|
43
|
+
for ( let e = 0; e < 3; e ++ ) {
|
|
44
|
+
|
|
45
|
+
const a = indices[ f * 3 + e ];
|
|
46
|
+
const b = indices[ f * 3 + ( e + 1 ) % 3 ];
|
|
47
|
+
const opposite = indices[ f * 3 + ( e + 2 ) % 3 ];
|
|
48
|
+
const lo = a < b ? a : b;
|
|
49
|
+
const hi = a < b ? b : a;
|
|
50
|
+
const key = lo * nv + hi;
|
|
51
|
+
|
|
52
|
+
const existing = edgeIndex.get( key );
|
|
53
|
+
if ( existing === undefined ) {
|
|
54
|
+
|
|
55
|
+
edgeIndex.set( key, edgeV0.length );
|
|
56
|
+
edgeV0.push( lo );
|
|
57
|
+
edgeV1.push( hi );
|
|
58
|
+
edgeOpp0.push( opposite );
|
|
59
|
+
edgeOpp1.push( - 1 );
|
|
60
|
+
edgeFaces.push( 1 );
|
|
61
|
+
|
|
62
|
+
} else {
|
|
63
|
+
|
|
64
|
+
if ( edgeOpp1[ existing ] < 0 ) edgeOpp1[ existing ] = opposite;
|
|
65
|
+
edgeFaces[ existing ] ++;
|
|
66
|
+
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ring = Array.from( { length: nv }, () => [] );
|
|
74
|
+
const boundaryRing = Array.from( { length: nv }, () => [] );
|
|
75
|
+
|
|
76
|
+
for ( let i = 0; i < edgeV0.length; i ++ ) {
|
|
77
|
+
|
|
78
|
+
const v0 = edgeV0[ i ], v1 = edgeV1[ i ];
|
|
79
|
+
ring[ v0 ].push( v1 );
|
|
80
|
+
ring[ v1 ].push( v0 );
|
|
81
|
+
|
|
82
|
+
if ( edgeFaces[ i ] === 1 ) {
|
|
83
|
+
|
|
84
|
+
boundaryRing[ v0 ].push( v1 );
|
|
85
|
+
boundaryRing[ v1 ].push( v0 );
|
|
86
|
+
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { edgeIndex, edgeV0, edgeV1, edgeOpp0, edgeOpp1, edgeFaces, ring, boundaryRing };
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reposition every vertex by a one-ring weighting: (1 - n·w)·v + w·Σring for an
|
|
97
|
+
* interior vertex, (1 - 2w)·v + w·(both boundary neighbours) on a boundary. Used for
|
|
98
|
+
* both the even-vertex step and the limit push, which differ only in the weights.
|
|
99
|
+
*/
|
|
100
|
+
function weightVertices( P, topology, nv, interiorWeight, boundaryWeight ) {
|
|
101
|
+
|
|
102
|
+
const out = new Float64Array( P.length );
|
|
103
|
+
|
|
104
|
+
for ( let v = 0; v < nv; v ++ ) {
|
|
105
|
+
|
|
106
|
+
const base = v * 3;
|
|
107
|
+
const boundary = topology.boundaryRing[ v ];
|
|
108
|
+
|
|
109
|
+
// Anything other than a clean two-neighbour boundary (a wire edge, a
|
|
110
|
+
// non-manifold fan) has no defined rule — leave the vertex where it is.
|
|
111
|
+
if ( boundary.length === 2 ) {
|
|
112
|
+
|
|
113
|
+
const a = boundary[ 0 ] * 3, b = boundary[ 1 ] * 3;
|
|
114
|
+
const centre = 1 - 2 * boundaryWeight;
|
|
115
|
+
for ( let k = 0; k < 3; k ++ ) {
|
|
116
|
+
|
|
117
|
+
out[ base + k ] = centre * P[ base + k ] + boundaryWeight * ( P[ a + k ] + P[ b + k ] );
|
|
118
|
+
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
continue;
|
|
122
|
+
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ring = topology.ring[ v ];
|
|
126
|
+
const n = ring.length;
|
|
127
|
+
if ( n === 0 || boundary.length > 0 ) {
|
|
128
|
+
|
|
129
|
+
for ( let k = 0; k < 3; k ++ ) out[ base + k ] = P[ base + k ];
|
|
130
|
+
continue;
|
|
131
|
+
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const w = interiorWeight( n );
|
|
135
|
+
let sx = 0, sy = 0, sz = 0;
|
|
136
|
+
for ( let i = 0; i < n; i ++ ) {
|
|
137
|
+
|
|
138
|
+
const r = ring[ i ] * 3;
|
|
139
|
+
sx += P[ r ];
|
|
140
|
+
sy += P[ r + 1 ];
|
|
141
|
+
sz += P[ r + 2 ];
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const centre = 1 - n * w;
|
|
146
|
+
out[ base ] = centre * P[ base ] + w * sx;
|
|
147
|
+
out[ base + 1 ] = centre * P[ base + 1 ] + w * sy;
|
|
148
|
+
out[ base + 2 ] = centre * P[ base + 2 ] + w * sz;
|
|
149
|
+
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return out;
|
|
153
|
+
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One subdivision step: 4 faces per face, one new vertex per edge. */
|
|
157
|
+
function subdivideOnce( P, indices ) {
|
|
158
|
+
|
|
159
|
+
const nv = P.length / 3;
|
|
160
|
+
const topology = buildTopology( nv, indices );
|
|
161
|
+
const edgeCount = topology.edgeV0.length;
|
|
162
|
+
|
|
163
|
+
const even = weightVertices( P, topology, nv, beta, 1 / 8 );
|
|
164
|
+
const out = new Float64Array( ( nv + edgeCount ) * 3 );
|
|
165
|
+
out.set( even );
|
|
166
|
+
|
|
167
|
+
for ( let i = 0; i < edgeCount; i ++ ) {
|
|
168
|
+
|
|
169
|
+
const v0 = topology.edgeV0[ i ] * 3;
|
|
170
|
+
const v1 = topology.edgeV1[ i ] * 3;
|
|
171
|
+
const dst = ( nv + i ) * 3;
|
|
172
|
+
const opp1 = topology.edgeOpp1[ i ];
|
|
173
|
+
|
|
174
|
+
if ( opp1 < 0 ) {
|
|
175
|
+
|
|
176
|
+
for ( let k = 0; k < 3; k ++ ) out[ dst + k ] = 0.5 * ( P[ v0 + k ] + P[ v1 + k ] );
|
|
177
|
+
|
|
178
|
+
} else {
|
|
179
|
+
|
|
180
|
+
const o0 = topology.edgeOpp0[ i ] * 3, o1 = opp1 * 3;
|
|
181
|
+
for ( let k = 0; k < 3; k ++ ) {
|
|
182
|
+
|
|
183
|
+
out[ dst + k ] = 0.375 * ( P[ v0 + k ] + P[ v1 + k ] ) + 0.125 * ( P[ o0 + k ] + P[ o1 + k ] );
|
|
184
|
+
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const faceCount = indices.length / 3;
|
|
192
|
+
const outIndices = new Int32Array( faceCount * 12 );
|
|
193
|
+
const midpoint = ( a, b ) => nv + topology.edgeIndex.get( ( a < b ? a : b ) * nv + ( a < b ? b : a ) );
|
|
194
|
+
|
|
195
|
+
for ( let f = 0; f < faceCount; f ++ ) {
|
|
196
|
+
|
|
197
|
+
const a = indices[ f * 3 ], b = indices[ f * 3 + 1 ], c = indices[ f * 3 + 2 ];
|
|
198
|
+
const ab = midpoint( a, b ), bc = midpoint( b, c ), ca = midpoint( c, a );
|
|
199
|
+
const o = f * 12;
|
|
200
|
+
|
|
201
|
+
outIndices[ o ] = a; outIndices[ o + 1 ] = ab; outIndices[ o + 2 ] = ca;
|
|
202
|
+
outIndices[ o + 3 ] = ab; outIndices[ o + 4 ] = b; outIndices[ o + 5 ] = bc;
|
|
203
|
+
outIndices[ o + 6 ] = ca; outIndices[ o + 7 ] = bc; outIndices[ o + 8 ] = c;
|
|
204
|
+
outIndices[ o + 9 ] = ab; outIndices[ o + 10 ] = bc; outIndices[ o + 11 ] = ca;
|
|
205
|
+
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { positions: out, indices: outIndices };
|
|
209
|
+
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {ArrayLike<number>} P - control-cage positions, 3 per vertex
|
|
214
|
+
* @param {ArrayLike<number>} indices - control-cage triangles, 3 per face
|
|
215
|
+
* @param {number} levels - refinement steps; each multiplies face count by 4
|
|
216
|
+
* @param {number} [maxTriangles=2000000] - stop refining early rather than exhaust memory
|
|
217
|
+
* @returns {{positions: Float32Array, indices: Uint32Array, levels: number}} levels actually applied
|
|
218
|
+
*/
|
|
219
|
+
export function loopSubdivide( P, indices, levels, maxTriangles = 2000000 ) {
|
|
220
|
+
|
|
221
|
+
let positions = Float64Array.from( P );
|
|
222
|
+
let faces = Int32Array.from( indices );
|
|
223
|
+
let applied = 0;
|
|
224
|
+
|
|
225
|
+
for ( let i = 0; i < levels; i ++ ) {
|
|
226
|
+
|
|
227
|
+
if ( faces.length / 3 * 4 > maxTriangles ) break;
|
|
228
|
+
const step = subdivideOnce( positions, faces );
|
|
229
|
+
positions = step.positions;
|
|
230
|
+
faces = step.indices;
|
|
231
|
+
applied ++;
|
|
232
|
+
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const nv = positions.length / 3;
|
|
236
|
+
const limit = weightVertices( positions, buildTopology( nv, faces ), nv, loopGamma, 1 / 5 );
|
|
237
|
+
|
|
238
|
+
return { positions: Float32Array.from( limit ), indices: Uint32Array.from( faces ), levels: applied };
|
|
239
|
+
|
|
240
|
+
}
|
|
@@ -17,6 +17,15 @@ export function identity() {
|
|
|
17
17
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Determinant of the upper-left 3x3 basis. Negative means the transform mirrors. */
|
|
21
|
+
export function determinant3( m ) {
|
|
22
|
+
|
|
23
|
+
return m[ 0 ] * ( m[ 5 ] * m[ 10 ] - m[ 6 ] * m[ 9 ] )
|
|
24
|
+
- m[ 4 ] * ( m[ 1 ] * m[ 10 ] - m[ 2 ] * m[ 9 ] )
|
|
25
|
+
+ m[ 8 ] * ( m[ 1 ] * m[ 6 ] - m[ 2 ] * m[ 5 ] );
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/** Matrix product a*b (applies b first, then a, to a column vector). */
|
|
21
30
|
export function multiply( a, b ) {
|
|
22
31
|
|