rayzee 6.3.0 → 6.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -1,5 +1,6 @@
1
1
  import { Box3, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
2
- TextureLoader, Mesh, MeshStandardMaterial, MeshPhysicalMaterial, CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
2
+ TextureLoader, Texture, SRGBColorSpace, RepeatWrapping, Mesh, MeshStandardMaterial, MeshPhysicalMaterial,
3
+ CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
3
4
  } from 'three';
4
5
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
5
6
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
@@ -12,6 +13,7 @@ import { unzipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
12
13
  import { disposeObjectFromMemory, updateLoading } from './utils';
13
14
  import { BuildTimer } from './BuildTimer.js';
14
15
  import { getAssetConfig } from '../AssetConfig.js';
16
+ import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
15
17
 
16
18
  // Define supported file formats
17
19
  const SUPPORTED_FORMATS = {
@@ -314,6 +316,10 @@ export class AssetLoader extends EventDispatcher {
314
316
 
315
317
  const arrayBuffer = await this.readFileAsArrayBuffer( file );
316
318
  const zip = unzipSync( new Uint8Array( arrayBuffer ) );
319
+
320
+ // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
321
+ if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename );
322
+
317
323
  const result = await this.processObjMtlPairsInZip( zip, filename );
318
324
  if ( result ) return result;
319
325
  return await this.findAndLoadModelFromZip( zip, filename );
@@ -327,6 +333,150 @@ export class AssetLoader extends EventDispatcher {
327
333
 
328
334
  }
329
335
 
336
+ /**
337
+ * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
338
+ * (following Include/Import), builds a THREE.Group, sets the infinite light
339
+ * as the scene environment, and runs the standard onModelLoad pipeline.
340
+ * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
341
+ * @param {string} filename - original archive name (for display/events)
342
+ */
343
+ async loadPBRTFromZip( zip, filename ) {
344
+
345
+ updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
346
+
347
+ // Geometry decoder — reuse the cached PLYLoader (pbrt leans on .ply meshes).
348
+ if ( ! this.loaderCache.ply ) {
349
+
350
+ const { PLYLoader } = await import( 'three/examples/jsm/loaders/PLYLoader.js' );
351
+ this.loaderCache.ply = new PLYLoader();
352
+
353
+ }
354
+
355
+ const plyParser = ( buf ) => this.loaderCache.ply.parse( buf );
356
+
357
+ // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga).
358
+ const imageFromBytes = ( bytes, fname ) => this._pbrtTextureFromBytes( bytes, fname );
359
+
360
+ // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
361
+ const envFromBytes = async ( bytes, fname ) => {
362
+
363
+ const ext = fname.split( '.' ).pop().toLowerCase();
364
+ const url = URL.createObjectURL( new Blob( [ bytes ] ) );
365
+ try {
366
+
367
+ return await this.loadEnvironmentByExtension( url, ext );
368
+
369
+ } finally {
370
+
371
+ URL.revokeObjectURL( url );
372
+
373
+ }
374
+
375
+ };
376
+
377
+ const { group, environment, report, warnings, entryPath } = await loadPBRTScene( {
378
+ vfs: zip, plyParser, imageFromBytes, envFromBytes
379
+ } );
380
+
381
+ // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
382
+ if ( report && report.length && typeof console.table === 'function' ) {
383
+
384
+ console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${entryPath}"` );
385
+ console.table( report );
386
+ console.groupEnd();
387
+
388
+ }
389
+
390
+ if ( warnings && warnings.length ) {
391
+
392
+ console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${entryPath}"` );
393
+ warnings.forEach( w => console.warn( ' •', w ) );
394
+
395
+ }
396
+
397
+ // Infinite light → scene environment (CDF is built later in loadSceneData).
398
+ if ( environment?.texture ) {
399
+
400
+ environment.texture.generateMipmaps = true;
401
+ this.applyEnvironmentToScene( environment.texture );
402
+
403
+ }
404
+
405
+ group.name = entryPath || filename;
406
+ this.releaseTargetModel();
407
+ this.targetModel = group;
408
+
409
+ updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
410
+ await this.onModelLoad( this.targetModel );
411
+
412
+ this.dispatchEvent( { type: 'load', model: group, filename: `${entryPath} (from ZIP)` } );
413
+ return group;
414
+
415
+ }
416
+
417
+ /**
418
+ * Decodes a pbrt texture map from raw bytes, picking a decoder by extension.
419
+ * ImageBitmap can't handle EXR/HDR/TGA, which pbrt scenes use freely.
420
+ * @param {Uint8Array} bytes
421
+ * @param {string} fname
422
+ * @returns {Promise<import('three').Texture>}
423
+ */
424
+ async _pbrtTextureFromBytes( bytes, fname ) {
425
+
426
+ const ext = fname.split( '.' ).pop().toLowerCase();
427
+ let tex;
428
+
429
+ if ( ext === 'exr' || ext === 'hdr' ) {
430
+
431
+ const loader = ext === 'hdr'
432
+ ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
433
+ : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader().setDataType( FloatType ) ) );
434
+ tex = await this._loadViaObjectURL( loader, bytes );
435
+ // HDR/EXR maps are linear — leave colorSpace as the loader set it.
436
+
437
+ } else if ( ext === 'tga' ) {
438
+
439
+ if ( ! this.loaderCache.tga ) {
440
+
441
+ const { TGALoader } = await import( 'three/examples/jsm/loaders/TGALoader.js' );
442
+ this.loaderCache.tga = new TGALoader();
443
+
444
+ }
445
+
446
+ tex = await this._loadViaObjectURL( this.loaderCache.tga, bytes );
447
+ tex.colorSpace = SRGBColorSpace;
448
+
449
+ } else {
450
+
451
+ // png / jpg / webp / gif / bmp
452
+ const bitmap = await createImageBitmap( new Blob( [ bytes ] ) );
453
+ tex = new Texture( bitmap );
454
+ tex.colorSpace = SRGBColorSpace;
455
+
456
+ }
457
+
458
+ tex.wrapS = tex.wrapT = RepeatWrapping;
459
+ tex.needsUpdate = true;
460
+ return tex;
461
+
462
+ }
463
+
464
+ /** Decode bytes through a three loader's loadAsync via a transient object URL. */
465
+ async _loadViaObjectURL( loader, bytes ) {
466
+
467
+ const url = URL.createObjectURL( new Blob( [ bytes ] ) );
468
+ try {
469
+
470
+ return await loader.loadAsync( url );
471
+
472
+ } finally {
473
+
474
+ URL.revokeObjectURL( url );
475
+
476
+ }
477
+
478
+ }
479
+
330
480
  async processObjMtlPairsInZip( zip, filename ) {
331
481
 
332
482
  const objFiles = [];
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Translate pbrt-v4 materials and textures to THREE.MeshPhysicalMaterial.
3
+ *
4
+ * pbrt's BxDF model (diffuse / conductor / dielectric / coateddiffuse / ...) is
5
+ * mapped onto the Disney-style parameters the engine reads off a
6
+ * MeshPhysicalMaterial (see GeometryExtractor.createMaterialObject). The mapping
7
+ * is intentionally lossy — spectral data, measured BRDFs, and layered BxDFs are
8
+ * approximated to RGB. Anything unrecognized falls back to a neutral diffuse.
9
+ */
10
+
11
+ import { MeshPhysicalMaterial, Color, DoubleSide } from 'three';
12
+
13
+ // Normal-incidence reflectance approximations for pbrt's named conductor spectra.
14
+ const METAL_ALBEDO = {
15
+ au: [ 1.0, 0.78, 0.34 ], gold: [ 1.0, 0.78, 0.34 ],
16
+ cu: [ 0.95, 0.64, 0.54 ], copper: [ 0.95, 0.64, 0.54 ],
17
+ ag: [ 0.97, 0.96, 0.91 ], silver: [ 0.97, 0.96, 0.91 ],
18
+ al: [ 0.91, 0.92, 0.92 ], aluminium: [ 0.91, 0.92, 0.92 ], aluminum: [ 0.91, 0.92, 0.92 ],
19
+ mgo: [ 0.9, 0.9, 0.9 ], tio2: [ 0.9, 0.9, 0.9 ]
20
+ };
21
+
22
+ const DEFAULT_METAL = [ 0.92, 0.92, 0.92 ];
23
+
24
+ /** Crude blackbody-temperature → linear RGB (Planckian locus approximation). */
25
+ function blackbodyToRGB( kelvin ) {
26
+
27
+ const t = Math.max( 1000, Math.min( 40000, kelvin ) ) / 100;
28
+ let r, g, b;
29
+
30
+ if ( t <= 66 ) {
31
+
32
+ r = 255;
33
+ g = 99.47 * Math.log( t ) - 161.12;
34
+
35
+ } else {
36
+
37
+ r = 329.7 * Math.pow( t - 60, - 0.1332 );
38
+ g = 288.12 * Math.pow( t - 60, - 0.0755 );
39
+
40
+ }
41
+
42
+ if ( t >= 66 ) b = 255;
43
+ else if ( t <= 19 ) b = 0;
44
+ else b = 138.52 * Math.log( t - 10 ) - 305.04;
45
+
46
+ const clamp = v => Math.max( 0, Math.min( 255, v ) ) / 255;
47
+ // sRGB → approx linear
48
+ return [ clamp( r ) ** 2.2, clamp( g ) ** 2.2, clamp( b ) ** 2.2 ];
49
+
50
+ }
51
+
52
+ // ── param accessors ────────────────────────────────────────────────
53
+
54
+ export function pFloat( params, name, dflt ) {
55
+
56
+ const p = params[ name ];
57
+ return p && typeof p.value[ 0 ] === 'number' ? p.value[ 0 ] : dflt;
58
+
59
+ }
60
+
61
+ export function pString( params, name, dflt ) {
62
+
63
+ const p = params[ name ];
64
+ return p && typeof p.value[ 0 ] === 'string' ? p.value[ 0 ] : dflt;
65
+
66
+ }
67
+
68
+ /**
69
+ * Resolve a spectrum/color/float-valued parameter to an RGB triple and/or a
70
+ * texture. Returns `{ rgb, texture }` — exactly one is typically non-null.
71
+ * @returns {Promise<{rgb:number[]|null, texture:import('three').Texture|null}>}
72
+ */
73
+ export async function resolveSpectrum( params, name, ctx, dfltRGB = null ) {
74
+
75
+ const p = params[ name ];
76
+ if ( ! p ) return { rgb: dfltRGB, texture: null };
77
+
78
+ // Reference to a named texture.
79
+ if ( p.type === 'texture' ) {
80
+
81
+ const texName = p.value[ 0 ];
82
+ const tex = await ctx.resolveNamedTexture( texName );
83
+ if ( tex && tex.texture ) return { rgb: tex.constant ?? null, texture: tex.texture };
84
+ if ( tex && tex.constant ) return { rgb: tex.constant, texture: null };
85
+ ctx.warn( `texture "${texName}" could not be resolved` );
86
+ return { rgb: dfltRGB, texture: null };
87
+
88
+ }
89
+
90
+ if ( p.type === 'rgb' || p.type === 'color' ) {
91
+
92
+ return { rgb: [ p.value[ 0 ], p.value[ 1 ], p.value[ 2 ] ], texture: null };
93
+
94
+ }
95
+
96
+ if ( p.type === 'float' ) {
97
+
98
+ const v = p.value[ 0 ];
99
+ return { rgb: [ v, v, v ], texture: null };
100
+
101
+ }
102
+
103
+ if ( p.type === 'blackbody' ) {
104
+
105
+ return { rgb: blackbodyToRGB( p.value[ 0 ] ), texture: null };
106
+
107
+ }
108
+
109
+ if ( p.type === 'spectrum' ) {
110
+
111
+ // Named spectrum (e.g. "metal-Au-eta") or sampled [lambda val ...].
112
+ if ( typeof p.value[ 0 ] === 'string' ) {
113
+
114
+ const key = namedMetalKey( p.value[ 0 ] );
115
+ if ( key && METAL_ALBEDO[ key ] ) return { rgb: METAL_ALBEDO[ key ].slice(), texture: null };
116
+ ctx.warn( `named spectrum "${p.value[ 0 ]}" approximated to gray` );
117
+ return { rgb: [ 0.5, 0.5, 0.5 ], texture: null };
118
+
119
+ }
120
+
121
+ // Sampled spectrum → average value as gray (coarse).
122
+ let sum = 0, count = 0;
123
+ for ( let i = 1; i < p.value.length; i += 2 ) {
124
+
125
+ sum += p.value[ i ]; count ++;
126
+
127
+ }
128
+
129
+ const v = count ? sum / count : 0.5;
130
+ return { rgb: [ v, v, v ], texture: null };
131
+
132
+ }
133
+
134
+ return { rgb: dfltRGB, texture: null };
135
+
136
+ }
137
+
138
+ function namedMetalKey( s ) {
139
+
140
+ const m = s.toLowerCase().match( /metal-([a-z]+)/ );
141
+ if ( m ) return m[ 1 ];
142
+ for ( const k of Object.keys( METAL_ALBEDO ) ) if ( s.toLowerCase().includes( k ) ) return k;
143
+ return null;
144
+
145
+ }
146
+
147
+ /** Roughness from `roughness` or anisotropic `uroughness`/`vroughness`. */
148
+ function resolveRoughness( params, dflt ) {
149
+
150
+ if ( params.roughness && typeof params.roughness.value[ 0 ] === 'number' ) return params.roughness.value[ 0 ];
151
+ const u = pFloat( params, 'uroughness', null );
152
+ const v = pFloat( params, 'vroughness', null );
153
+ if ( u !== null && v !== null ) return ( u + v ) / 2;
154
+ if ( u !== null ) return u;
155
+ return dflt;
156
+
157
+ }
158
+
159
+ /**
160
+ * Build a MeshPhysicalMaterial from a pbrt material definition.
161
+ * @param {{type:string, params:object}} def
162
+ * @param {object} ctx - { resolveNamedTexture, warn }
163
+ * @returns {Promise<MeshPhysicalMaterial>}
164
+ */
165
+ export async function buildMaterial( def, ctx ) {
166
+
167
+ const type = def?.type || 'diffuse';
168
+ const params = def?.params || {};
169
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide, roughness: 1, metalness: 0 } );
170
+
171
+ const setColor = ( rgb ) => {
172
+
173
+ if ( rgb ) mat.color.setRGB( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
174
+
175
+ };
176
+
177
+ // Apply a resolved reflectance to the base color + map. A `scale` texture
178
+ // yields BOTH an rgb tint and a texture — keep the tint as color so three
179
+ // multiplies map×color. A plain imagemap (no tint) neutralizes color to white.
180
+ const applyAlbedo = ( refl ) => {
181
+
182
+ setColor( refl.rgb );
183
+ if ( refl.texture ) {
184
+
185
+ mat.map = refl.texture;
186
+ if ( ! refl.rgb ) mat.color.setRGB( 1, 1, 1 );
187
+
188
+ }
189
+
190
+ };
191
+
192
+ switch ( type ) {
193
+
194
+ case 'diffuse': {
195
+
196
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] ) );
197
+ mat.roughness = 1;
198
+ mat.metalness = 0;
199
+ break;
200
+
201
+ }
202
+
203
+ case 'conductor':
204
+ case 'metal': {
205
+
206
+ const refl = await resolveSpectrum( params, 'reflectance', ctx, null );
207
+ const etaP = params.eta, kP = params.k;
208
+ const etaNamed = etaP && etaP.type === 'spectrum' && typeof etaP.value[ 0 ] === 'string';
209
+
210
+ if ( refl.rgb || refl.texture ) {
211
+
212
+ applyAlbedo( refl );
213
+
214
+ } else if ( etaP && kP && ! etaNamed ) {
215
+
216
+ // Normal-incidence reflectance from complex IOR: ((η-1)²+k²)/((η+1)²+k²).
217
+ const eta = ( await resolveSpectrum( params, 'eta', ctx, [ 0.2, 0.92, 1.1 ] ) ).rgb;
218
+ const k = ( await resolveSpectrum( params, 'k', ctx, [ 3.9, 2.45, 2.14 ] ) ).rgb;
219
+ const fr = ( n, kk ) => ( ( n - 1 ) ** 2 + kk ** 2 ) / ( ( n + 1 ) ** 2 + kk ** 2 );
220
+ setColor( [ fr( eta[ 0 ], k[ 0 ] ), fr( eta[ 1 ], k[ 1 ] ), fr( eta[ 2 ], k[ 2 ] ) ] );
221
+
222
+ } else if ( etaP ) {
223
+
224
+ // Named conductor spectrum → metal albedo table.
225
+ setColor( ( await resolveSpectrum( params, 'eta', ctx, DEFAULT_METAL ) ).rgb || DEFAULT_METAL );
226
+
227
+ } else {
228
+
229
+ setColor( METAL_ALBEDO.cu ); // pbrt-v4 default conductor is copper
230
+
231
+ }
232
+
233
+ mat.metalness = 1;
234
+ mat.roughness = resolveRoughness( params, 0.1 );
235
+ break;
236
+
237
+ }
238
+
239
+ case 'dielectric':
240
+ case 'thindielectric': {
241
+
242
+ mat.transmission = 1;
243
+ mat.metalness = 0;
244
+ mat.color.setRGB( 1, 1, 1 );
245
+ mat.ior = pFloat( params, 'eta', 1.5 );
246
+ mat.roughness = resolveRoughness( params, 0 );
247
+ mat.thickness = type === 'thindielectric' ? 0 : pFloat( params, 'thickness', 0 );
248
+ break;
249
+
250
+ }
251
+
252
+ case 'coateddiffuse': {
253
+
254
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] ) );
255
+ mat.roughness = 0.6;
256
+ mat.metalness = 0;
257
+ mat.clearcoat = 1;
258
+ mat.clearcoatRoughness = resolveRoughness( params, 0 );
259
+ break;
260
+
261
+ }
262
+
263
+ case 'diffusetransmission': {
264
+
265
+ const trans = await resolveSpectrum( params, 'transmittance', ctx, [ 0.25, 0.25, 0.25 ] );
266
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.25, 0.25, 0.25 ] ) );
267
+ mat.transmission = trans.rgb ? ( trans.rgb[ 0 ] + trans.rgb[ 1 ] + trans.rgb[ 2 ] ) / 3 : 0.5;
268
+ mat.roughness = 1;
269
+ mat.ior = 1.0;
270
+ break;
271
+
272
+ }
273
+
274
+ case 'interface':
275
+ case 'none':
276
+ case '': {
277
+
278
+ // Medium boundary with no surface scattering — render as clear passthrough.
279
+ mat.transmission = 1;
280
+ mat.ior = 1.0;
281
+ mat.roughness = 0;
282
+ mat.color.setRGB( 1, 1, 1 );
283
+ break;
284
+
285
+ }
286
+
287
+ case 'mix': {
288
+
289
+ // pbrt blends two named materials by `amount`. Without a real layered BxDF
290
+ // we lerp the two resolved MeshPhysicalMaterials' scalar/color properties
291
+ // — physically loose but visually faithful, and crucially silent on success
292
+ // instead of warning per shape. Recursive: mix-in-mix terminates as the
293
+ // chain bottoms out at a non-mix.
294
+ const matNames = params.materials?.value || [];
295
+ const t = Math.max( 0, Math.min( 1, pFloat( params, 'amount', 0.5 ) ) );
296
+ const defA = matNames[ 0 ] ? ctx.namedMaterials?.get( matNames[ 0 ] ) : null;
297
+ const defB = matNames[ 1 ] ? ctx.namedMaterials?.get( matNames[ 1 ] ) : null;
298
+
299
+ if ( defA && defB ) {
300
+
301
+ const [ matA, matB ] = await Promise.all( [ buildMaterial( defA, ctx ), buildMaterial( defB, ctx ) ] );
302
+ const lerp = ( a, b ) => a * ( 1 - t ) + b * t;
303
+ mat.color.lerpColors( matA.color, matB.color, t );
304
+ mat.roughness = lerp( matA.roughness, matB.roughness );
305
+ mat.metalness = lerp( matA.metalness, matB.metalness );
306
+ mat.ior = lerp( matA.ior ?? 1.5, matB.ior ?? 1.5 );
307
+ mat.transmission = lerp( matA.transmission ?? 0, matB.transmission ?? 0 );
308
+ mat.thickness = lerp( matA.thickness ?? 0, matB.thickness ?? 0 );
309
+ mat.clearcoat = lerp( matA.clearcoat ?? 0, matB.clearcoat ?? 0 );
310
+ mat.clearcoatRoughness = lerp( matA.clearcoatRoughness ?? 0, matB.clearcoatRoughness ?? 0 );
311
+ mat.emissive.lerpColors( matA.emissive, matB.emissive, t );
312
+ mat.emissiveIntensity = lerp( matA.emissiveIntensity ?? 0, matB.emissiveIntensity ?? 0 );
313
+ // Maps can't be lerped — pick the dominant side.
314
+ mat.map = ( t < 0.5 ? matA.map : matB.map ) || null;
315
+ break;
316
+
317
+ }
318
+
319
+ ctx.warn( `material "mix" — could not resolve inner materials [${matNames.join( ', ' )}], falling back to diffuse` );
320
+ mat.color.set( new Color( 0.6, 0.6, 0.6 ) );
321
+ break;
322
+
323
+ }
324
+
325
+ case 'subsurface':
326
+ case 'hair':
327
+ case 'measured': {
328
+
329
+ ctx.warn( `material "${type}" not supported — using diffuse approximation` );
330
+ const refl = await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] );
331
+ setColor( refl.rgb );
332
+ mat.roughness = 1;
333
+ break;
334
+
335
+ }
336
+
337
+ default: {
338
+
339
+ ctx.warn( `unknown material "${type}" — using neutral diffuse` );
340
+ mat.color.set( new Color( 0.6, 0.6, 0.6 ) );
341
+ break;
342
+
343
+ }
344
+
345
+ }
346
+
347
+ mat.roughness = Math.max( 0, Math.min( 1, mat.roughness ) );
348
+ return mat;
349
+
350
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Minimal 4x4 matrix math for the pbrt CTM stack.
3
+ *
4
+ * Matrices are flat 16-element arrays in COLUMN-MAJOR order, identical to
5
+ * THREE.Matrix4.elements (element index = col*4 + row). This lets the scene
6
+ * builder do `new Matrix4().fromArray(ctm)` with no conversion.
7
+ *
8
+ * pbrt's `Transform [16]` directive supplies values that, after pbrt's internal
9
+ * transpose, are exactly column-major — so they map straight onto this layout.
10
+ *
11
+ * Pure JS so the parser stays Three.js-free and unit-testable.
12
+ */
13
+
14
+ export function identity() {
15
+
16
+ return [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ];
17
+
18
+ }
19
+
20
+ /** Matrix product a*b (applies b first, then a, to a column vector). */
21
+ export function multiply( a, b ) {
22
+
23
+ const a11 = a[ 0 ], a21 = a[ 1 ], a31 = a[ 2 ], a41 = a[ 3 ];
24
+ const a12 = a[ 4 ], a22 = a[ 5 ], a32 = a[ 6 ], a42 = a[ 7 ];
25
+ const a13 = a[ 8 ], a23 = a[ 9 ], a33 = a[ 10 ], a43 = a[ 11 ];
26
+ const a14 = a[ 12 ], a24 = a[ 13 ], a34 = a[ 14 ], a44 = a[ 15 ];
27
+
28
+ const b11 = b[ 0 ], b21 = b[ 1 ], b31 = b[ 2 ], b41 = b[ 3 ];
29
+ const b12 = b[ 4 ], b22 = b[ 5 ], b32 = b[ 6 ], b42 = b[ 7 ];
30
+ const b13 = b[ 8 ], b23 = b[ 9 ], b33 = b[ 10 ], b43 = b[ 11 ];
31
+ const b14 = b[ 12 ], b24 = b[ 13 ], b34 = b[ 14 ], b44 = b[ 15 ];
32
+
33
+ return [
34
+ a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41,
35
+ a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41,
36
+ a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41,
37
+ a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41,
38
+
39
+ a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42,
40
+ a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42,
41
+ a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42,
42
+ a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42,
43
+
44
+ a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43,
45
+ a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43,
46
+ a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43,
47
+ a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43,
48
+
49
+ a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44,
50
+ a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44,
51
+ a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44,
52
+ a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44
53
+ ];
54
+
55
+ }
56
+
57
+ export function translate( x, y, z ) {
58
+
59
+ return [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1 ];
60
+
61
+ }
62
+
63
+ export function scale( x, y, z ) {
64
+
65
+ return [ x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1 ];
66
+
67
+ }
68
+
69
+ /** Axis-angle rotation. `angle` is in DEGREES (pbrt convention). */
70
+ export function rotate( angle, x, y, z ) {
71
+
72
+ const len = Math.hypot( x, y, z ) || 1;
73
+ x /= len; y /= len; z /= len;
74
+
75
+ const rad = angle * Math.PI / 180;
76
+ const c = Math.cos( rad ), s = Math.sin( rad ), t = 1 - c;
77
+
78
+ const m00 = t * x * x + c, m01 = t * x * y - s * z, m02 = t * x * z + s * y;
79
+ const m10 = t * x * y + s * z, m11 = t * y * y + c, m12 = t * y * z - s * x;
80
+ const m20 = t * x * z - s * y, m21 = t * y * z + s * x, m22 = t * z * z + c;
81
+
82
+ return [
83
+ m00, m10, m20, 0,
84
+ m01, m11, m21, 0,
85
+ m02, m12, m22, 0,
86
+ 0, 0, 0, 1
87
+ ];
88
+
89
+ }
90
+
91
+ function sub( a, b ) {
92
+
93
+ return [ a[ 0 ] - b[ 0 ], a[ 1 ] - b[ 1 ], a[ 2 ] - b[ 2 ] ];
94
+
95
+ }
96
+
97
+ function cross( a, b ) {
98
+
99
+ return [
100
+ a[ 1 ] * b[ 2 ] - a[ 2 ] * b[ 1 ],
101
+ a[ 2 ] * b[ 0 ] - a[ 0 ] * b[ 2 ],
102
+ a[ 0 ] * b[ 1 ] - a[ 1 ] * b[ 0 ]
103
+ ];
104
+
105
+ }
106
+
107
+ function normalize( a ) {
108
+
109
+ const len = Math.hypot( a[ 0 ], a[ 1 ], a[ 2 ] ) || 1;
110
+ return [ a[ 0 ] / len, a[ 1 ] / len, a[ 2 ] / len ];
111
+
112
+ }
113
+
114
+ /**
115
+ * Builds the camera-to-world matrix from a pbrt LookAt (eye, look, up).
116
+ * pbrt uses a left-handed camera basis: +z is the viewing direction.
117
+ * Columns: [ right, newUp, dir, eye ].
118
+ */
119
+ export function lookAtCameraToWorld( eye, look, up ) {
120
+
121
+ const dir = normalize( sub( look, eye ) );
122
+ const right = normalize( cross( normalize( up ), dir ) );
123
+ const newUp = cross( dir, right );
124
+
125
+ return [
126
+ right[ 0 ], right[ 1 ], right[ 2 ], 0,
127
+ newUp[ 0 ], newUp[ 1 ], newUp[ 2 ], 0,
128
+ dir[ 0 ], dir[ 1 ], dir[ 2 ], 0,
129
+ eye[ 0 ], eye[ 1 ], eye[ 2 ], 1
130
+ ];
131
+
132
+ }
133
+
134
+ /** General 4x4 inverse (column-major in/out). Returns identity if singular. */
135
+ export function invert( m ) {
136
+
137
+ const n11 = m[ 0 ], n21 = m[ 1 ], n31 = m[ 2 ], n41 = m[ 3 ];
138
+ const n12 = m[ 4 ], n22 = m[ 5 ], n32 = m[ 6 ], n42 = m[ 7 ];
139
+ const n13 = m[ 8 ], n23 = m[ 9 ], n33 = m[ 10 ], n43 = m[ 11 ];
140
+ const n14 = m[ 12 ], n24 = m[ 13 ], n34 = m[ 14 ], n44 = m[ 15 ];
141
+
142
+ const t11 = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;
143
+ const t12 = n14 * n33 * n42 - n13 * n34 * n42 - n14 * n32 * n43 + n12 * n34 * n43 + n13 * n32 * n44 - n12 * n33 * n44;
144
+ const t13 = n13 * n24 * n42 - n14 * n23 * n42 + n14 * n22 * n43 - n12 * n24 * n43 - n13 * n22 * n44 + n12 * n23 * n44;
145
+ const t14 = n14 * n23 * n32 - n13 * n24 * n32 - n14 * n22 * n33 + n12 * n24 * n33 + n13 * n22 * n34 - n12 * n23 * n34;
146
+
147
+ const det = n11 * t11 + n21 * t12 + n31 * t13 + n41 * t14;
148
+ if ( det === 0 ) return identity();
149
+ const idet = 1 / det;
150
+
151
+ return [
152
+ t11 * idet,
153
+ ( n24 * n33 * n41 - n23 * n34 * n41 - n24 * n31 * n43 + n21 * n34 * n43 + n23 * n31 * n44 - n21 * n33 * n44 ) * idet,
154
+ ( n22 * n34 * n41 - n24 * n32 * n41 + n24 * n31 * n42 - n21 * n34 * n42 - n22 * n31 * n44 + n21 * n32 * n44 ) * idet,
155
+ ( n23 * n32 * n41 - n22 * n33 * n41 - n23 * n31 * n42 + n21 * n33 * n42 + n22 * n31 * n43 - n21 * n32 * n43 ) * idet,
156
+
157
+ t12 * idet,
158
+ ( n13 * n34 * n41 - n14 * n33 * n41 + n14 * n31 * n43 - n11 * n34 * n43 - n13 * n31 * n44 + n11 * n33 * n44 ) * idet,
159
+ ( n14 * n32 * n41 - n12 * n34 * n41 - n14 * n31 * n42 + n11 * n34 * n42 + n12 * n31 * n44 - n11 * n32 * n44 ) * idet,
160
+ ( n12 * n33 * n41 - n13 * n32 * n41 + n13 * n31 * n42 - n11 * n33 * n42 - n12 * n31 * n43 + n11 * n32 * n43 ) * idet,
161
+
162
+ t13 * idet,
163
+ ( n14 * n23 * n41 - n13 * n24 * n41 - n14 * n21 * n43 + n11 * n24 * n43 + n13 * n21 * n44 - n11 * n23 * n44 ) * idet,
164
+ ( n12 * n24 * n41 - n14 * n22 * n41 + n14 * n21 * n42 - n11 * n24 * n42 - n12 * n21 * n44 + n11 * n22 * n44 ) * idet,
165
+ ( n13 * n22 * n41 - n12 * n23 * n41 - n13 * n21 * n42 + n11 * n23 * n42 + n12 * n21 * n43 - n11 * n22 * n43 ) * idet,
166
+
167
+ t14 * idet,
168
+ ( n13 * n24 * n31 - n14 * n23 * n31 + n14 * n21 * n33 - n11 * n24 * n33 - n13 * n21 * n34 + n11 * n23 * n34 ) * idet,
169
+ ( n14 * n22 * n31 - n12 * n24 * n31 - n14 * n21 * n32 + n11 * n24 * n32 + n12 * n21 * n34 - n11 * n22 * n34 ) * idet,
170
+ ( n12 * n23 * n31 - n13 * n22 * n31 + n13 * n21 * n32 - n11 * n23 * n32 - n12 * n21 * n33 + n11 * n22 * n33 ) * idet
171
+ ];
172
+
173
+ }