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.
@@ -15,10 +15,12 @@
15
15
 
16
16
  import {
17
17
  Group, Mesh, PerspectiveCamera, Matrix4, Vector3,
18
- BufferGeometry, Float32BufferAttribute, SphereGeometry,
18
+ BufferGeometry, Float32BufferAttribute, Uint32BufferAttribute, SphereGeometry,
19
19
  DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping
20
20
  } from 'three';
21
21
  import { buildMaterial, pFloat, pString, resolveSpectrum } from './PBRTMaterials.js';
22
+ import { loopSubdivide } from './LoopSubdivision.js';
23
+ import { octahedralToEquirect } from './EqualAreaOctahedral.js';
22
24
  import * as M from './PBRTMath.js';
23
25
 
24
26
  const FLIP_Z = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, - 1, 0, 0, 0, 0, 1 ];
@@ -187,6 +189,7 @@ export class PBRTSceneBuilder {
187
189
 
188
190
  case 'trianglemesh': return this._triangleMesh( shape.params );
189
191
  case 'bilinearmesh': return this._bilinearMesh( shape.params );
192
+ case 'loopsubdiv': return this._loopSubdiv( shape.params );
190
193
  case 'plymesh': return this._plyMesh( shape.params );
191
194
  case 'sphere': return this._sphere( shape.params );
192
195
  case 'disk': return this._disk( shape.params );
@@ -224,6 +227,33 @@ export class PBRTSceneBuilder {
224
227
 
225
228
  }
226
229
 
230
+ _loopSubdiv( params ) {
231
+
232
+ const P = params.P?.value;
233
+ const indices = params.indices?.value;
234
+ if ( ! P || P.length < 9 || ! indices || indices.length < 3 ) {
235
+
236
+ this.warn( 'loopsubdiv missing P/indices' ); return null;
237
+
238
+ }
239
+
240
+ // pbrt's default is 3; each level quadruples the face count, so a deep level on
241
+ // a dense cage is worth refusing rather than stalling the import.
242
+ const requested = Math.max( 0, Math.min( 6, Math.round( pFloat( params, 'levels', 3 ) ) ) );
243
+ const { positions, indices: faces, levels } = loopSubdivide( P, indices, requested );
244
+
245
+ if ( levels < requested ) this.warn(
246
+ `loopsubdiv refined ${levels}/${requested} levels — the control mesh is too dense for the triangle budget`
247
+ );
248
+
249
+ const geo = new BufferGeometry();
250
+ geo.setAttribute( 'position', new Float32BufferAttribute( positions, 3 ) );
251
+ geo.setIndex( new Uint32BufferAttribute( faces, 1 ) );
252
+ geo.computeVertexNormals();
253
+ return geo;
254
+
255
+ }
256
+
227
257
  // Bilinear patch mesh → triangulate each quad (P + indices in quads of 4).
228
258
  _bilinearMesh( params ) {
229
259
 
@@ -487,6 +517,16 @@ export class PBRTSceneBuilder {
487
517
  camera.up.copy( up.normalize() );
488
518
  camera.position.copy( eye );
489
519
  camera.lookAt( target );
520
+
521
+ // pbrt's camera space is left-handed: image-right is cameraToWorld's first column,
522
+ // = cross(up, dir). three's lookAt() builds cross(up, -dir) — the opposite — so a
523
+ // plain pbrt scene imports left-right flipped against pbrt's own render. An
524
+ // exported scene's `Scale -1 1 1` already negates that column (det < 0), and then
525
+ // lookAt() happens to agree and no correction is wanted. Hence: mirror exactly when
526
+ // the scene does NOT. Verified against the reference images for contemporary-bathroom
527
+ // (has the Scale) and killeroos/killeroo-simple (does not).
528
+ if ( ( M.determinant3( cam.cameraToWorld ) > 0 ) !== this.convertHandedness ) camera.scale.x = - 1;
529
+
490
530
  camera.updateMatrixWorld( true );
491
531
  return camera;
492
532
 
@@ -506,6 +546,44 @@ export class PBRTSceneBuilder {
506
546
 
507
547
  // ── lights / environment ───────────────────────────────────────
508
548
 
549
+ /**
550
+ * A square infinite-light image is pbrt's equal-area octahedral layout, not a
551
+ * lat-long panorama — resample it, folding in the light's transform and `scale`.
552
+ * Returns null when the image is not square (nothing to convert) or its pixels are
553
+ * not readable, leaving the caller's equirectangular path in place.
554
+ */
555
+ _equirectFromInfiniteLight( tex, inf, scale, filename ) {
556
+
557
+ const image = tex.image;
558
+ const data = image?.data;
559
+ if ( ! data || ! image.width || image.width !== image.height ) {
560
+
561
+ if ( image?.width !== image?.height ) this.warn(
562
+ `infinite-light image "${filename}" is ${image?.width}x${image?.height}, not square — ` +
563
+ 'read as equirectangular, but pbrt would read it as equal-area octahedral'
564
+ );
565
+ return null;
566
+
567
+ }
568
+
569
+ const channels = data.length / ( image.width * image.height );
570
+ if ( ! Number.isInteger( channels ) || channels < 3 ) return null;
571
+
572
+ // three's EXR/HDR loaders fill DataTexture rows bottom-up; pbrt reads the square top-down.
573
+ const { data: pixels, width, height } = octahedralToEquirect(
574
+ { data, width: image.width, height: image.height, channels, bottomUp: true },
575
+ inf.ctm, scale
576
+ );
577
+
578
+ const out = new DataTexture( pixels, width, height, RGBAFormat, FloatType );
579
+ out.mapping = EquirectangularReflectionMapping;
580
+ out.minFilter = LinearFilter;
581
+ out.magFilter = LinearFilter;
582
+ out.needsUpdate = true;
583
+ return out;
584
+
585
+ }
586
+
509
587
  async _buildEnvironment( lights ) {
510
588
 
511
589
  const inf = lights.find( l => l.type === 'infinite' );
@@ -521,6 +599,9 @@ export class PBRTSceneBuilder {
521
599
  const tex = await this.resolveEnvironment( filename );
522
600
  if ( tex ) {
523
601
 
602
+ const converted = this._equirectFromInfiniteLight( tex, inf, scale, filename );
603
+ if ( converted ) return { texture: converted };
604
+
524
605
  tex.mapping = EquirectangularReflectionMapping;
525
606
  return { texture: tex };
526
607
 
@@ -91,24 +91,78 @@ class VirtualFS {
91
91
 
92
92
  }
93
93
 
94
- /** Pick the top-level .pbrt entry: shallowest path, preferring scene/main names. */
95
- export function pickEntryPath( entries ) {
94
+ /** Decoded text of a zip entry, or '' when the value is not decodable bytes. */
95
+ function entryText( bytes ) {
96
+
97
+ try {
98
+
99
+ return decoder.decode( bytes );
100
+
101
+ } catch {
102
+
103
+ return '';
104
+
105
+ }
106
+
107
+ }
108
+
109
+ /**
110
+ * Rank every .pbrt in the archive that could be a top-level scene, best first.
111
+ *
112
+ * Name shape alone is not enough: a scene's `geometry.pbrt` fragment carries a shorter
113
+ * name than the real entry and would win on sort, loading every shape with an unresolved
114
+ * NamedMaterial and no camera or lights. Content decides first — a fragment has no
115
+ * `WorldBegin` and is named by someone else's Include — and the name only breaks ties.
116
+ *
117
+ * More than one survivor is a real archive shape, not an error: transparent-machines
118
+ * ships five independent frames side by side. Callers should say which one they took.
119
+ */
120
+ export function listEntryPaths( entries ) {
96
121
 
97
122
  const pbrts = Object.keys( entries ).filter( k => k.toLowerCase().endsWith( '.pbrt' ) );
98
- if ( pbrts.length === 0 ) return null;
123
+ if ( pbrts.length <= 1 ) return pbrts;
124
+
125
+ const texts = new Map( pbrts.map( k => [ k, entryText( entries[ k ] ) ] ) );
99
126
 
100
- const preferred = pbrts.filter( k => /(^|\/)(scene|main)\.pbrt$/i.test( k ) );
101
- const pool = preferred.length ? preferred : pbrts;
127
+ const included = new Set();
128
+ for ( const text of texts.values() ) {
102
129
 
103
- // Shallowest (fewest path segments), then shortest name.
104
- pool.sort( ( a, b ) => {
130
+ for ( const m of text.matchAll( /^[ \t]*(?:Include|Import)[ \t]+"([^"]+)"/gm ) ) {
131
+
132
+ included.add( normalizePath( m[ 1 ] ).toLowerCase().split( '/' ).pop() );
133
+
134
+ }
135
+
136
+ }
137
+
138
+ const narrow = ( candidates, keep ) => {
139
+
140
+ const kept = candidates.filter( keep );
141
+ return kept.length ? kept : candidates;
142
+
143
+ };
144
+
145
+ let pool = narrow( pbrts, k => /^[ \t]*WorldBegin\b/m.test( texts.get( k ) ) );
146
+ pool = narrow( pool, k => ! included.has( k.toLowerCase().split( '/' ).pop() ) );
147
+ pool = narrow( pool, k => /(^|\/)(scene|main)\.pbrt$/i.test( k ) );
148
+
149
+ // Shallowest, then shortest name, then the path itself — the last key keeps the
150
+ // order independent of however the zip happened to enumerate its entries.
151
+ return pool.sort( ( a, b ) => {
105
152
 
106
153
  const da = a.split( '/' ).length, db = b.split( '/' ).length;
107
- return da !== db ? da - db : a.length - b.length;
154
+ if ( da !== db ) return da - db;
155
+ if ( a.length !== b.length ) return a.length - b.length;
156
+ return a < b ? - 1 : a > b ? 1 : 0;
108
157
 
109
158
  } );
110
159
 
111
- return pool[ 0 ];
160
+ }
161
+
162
+ /** Best top-level .pbrt entry, or null when the archive has none. */
163
+ export function pickEntryPath( entries ) {
164
+
165
+ return listEntryPaths( entries )[ 0 ] || null;
112
166
 
113
167
  }
114
168
 
@@ -931,11 +931,27 @@ export class SceneProcessor {
931
931
  this._linearTexPacked = new Map();
932
932
 
933
933
  let bucketOverflowReported = false;
934
+ let undecodedReported = false;
934
935
 
935
936
  // Assign one texture to its (bucket, layer) within a pool; dedup by source uuid.
936
937
  const assign = ( tex, lists, dedup, flat, shapes ) => {
937
938
 
938
- if ( ! tex || ! tex.image ) return - 1;
939
+ if ( ! tex ) return - 1;
940
+
941
+ // A map whose image has not landed yet is indistinguishable here from no map at
942
+ // all, and the material would render untextured with nothing in the log.
943
+ if ( ! tex.image ) {
944
+
945
+ if ( ! undecodedReported ) this.config.issues?.record(
946
+ ISSUE_CODES.TEXTURE_BUILD_FAILED,
947
+ 'a material map had not finished decoding when the scene was packed; it renders untextured',
948
+ { texture: tex.name || tex.source?.uuid || tex.uuid }
949
+ );
950
+ undecodedReported = true;
951
+ return - 1;
952
+
953
+ }
954
+
939
955
  const bucket = getTextureBucketId( tex.image.width, tex.image.height, shapes );
940
956
  const uuid = tex.source?.uuid ?? tex.uuid;
941
957
  const seen = dedup[ bucket ].get( uuid );
@@ -0,0 +1,162 @@
1
+ /**
2
+ * WGSL tone mapping, a transcription of ToneMapCPU.js.
3
+ *
4
+ * The two must stay identical: the same image is produced on the CPU by `toneMapToRGBA8`
5
+ * (renderToBuffer, upscaler) and on the GPU by this (the OIDN output pack). Order is part of
6
+ * the contract — exposure, saturation, clamp-negative, curve, sRGB OETF — and moving any step
7
+ * shifts every mid-tone.
8
+ */
9
+ import {
10
+ NoToneMapping, LinearToneMapping, ReinhardToneMapping,
11
+ CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
12
+ } from 'three';
13
+
14
+ const SUPPORTED = new Set( [
15
+ NoToneMapping, LinearToneMapping, ReinhardToneMapping,
16
+ CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
17
+ ] );
18
+
19
+ /**
20
+ * Three.js ToneMapping constant → shader mode. Anything unsupported falls back to ACES,
21
+ * matching `TONE_MAP_FNS.get( x ) || TONE_MAP_FNS.get( ACESFilmicToneMapping )`.
22
+ * @param {number} toneMapping
23
+ * @returns {number}
24
+ */
25
+ export function toneMapMode( toneMapping ) {
26
+
27
+ return SUPPORTED.has( toneMapping ) ? toneMapping : ACESFilmicToneMapping;
28
+
29
+ }
30
+
31
+ /**
32
+ * Prelude defining `toneMapPixel( linear, gain, saturation, mode ) -> vec3f` (sRGB, [0,1]).
33
+ * Concatenate ahead of a kernel that wants it.
34
+ */
35
+ export const TONE_MAP_WGSL = /* wgsl */`
36
+ fn tmSaturation( c: vec3f, saturation: f32 ) -> vec3f {
37
+ if ( saturation == 1.0 ) { return c; }
38
+ let luma = dot( c, vec3f( 0.2126, 0.7152, 0.0722 ) );
39
+ return vec3f( luma ) + ( c - vec3f( luma ) ) * saturation;
40
+ }
41
+
42
+ fn tmSrgb1( c: f32 ) -> f32 {
43
+ if ( c <= 0.0031308 ) { return 12.92 * c; }
44
+ return 1.055 * pow( c, 1.0 / 2.4 ) - 0.055;
45
+ }
46
+
47
+ fn tmSrgb( c: vec3f ) -> vec3f {
48
+ return vec3f( tmSrgb1( c.r ), tmSrgb1( c.g ), tmSrgb1( c.b ) );
49
+ }
50
+
51
+ fn tmReinhard( c: vec3f ) -> vec3f {
52
+ return clamp( c / ( c + vec3f( 1.0 ) ), vec3f( 0.0 ), vec3f( 1.0 ) );
53
+ }
54
+
55
+ fn tmCineon1( x: f32 ) -> f32 {
56
+ let c = max( x - 0.004, 0.0 );
57
+ return pow( ( c * ( 6.2 * c + 0.5 ) ) / ( c * ( 6.2 * c + 1.7 ) + 0.06 ), 2.2 );
58
+ }
59
+
60
+ fn tmCineon( c: vec3f ) -> vec3f {
61
+ return vec3f( tmCineon1( c.r ), tmCineon1( c.g ), tmCineon1( c.b ) );
62
+ }
63
+
64
+ fn tmAcesFit( c: f32 ) -> f32 {
65
+ return ( c * ( c + 0.0245786 ) - 0.000090537 ) / ( c * ( 0.983729 * c + 0.4329510 ) + 0.238081 );
66
+ }
67
+
68
+ fn tmAces( cIn: vec3f ) -> vec3f {
69
+ let c = cIn / 0.6;
70
+ var i = vec3f(
71
+ 0.59719 * c.r + 0.35458 * c.g + 0.04823 * c.b,
72
+ 0.07600 * c.r + 0.90834 * c.g + 0.01566 * c.b,
73
+ 0.02840 * c.r + 0.13383 * c.g + 0.83777 * c.b
74
+ );
75
+ i = vec3f( tmAcesFit( i.r ), tmAcesFit( i.g ), tmAcesFit( i.b ) );
76
+ let o = vec3f(
77
+ 1.60475 * i.r - 0.53108 * i.g - 0.07367 * i.b,
78
+ -0.10208 * i.r + 1.10813 * i.g - 0.00605 * i.b,
79
+ -0.00327 * i.r - 0.07276 * i.g + 1.07602 * i.b
80
+ );
81
+ return clamp( o, vec3f( 0.0 ), vec3f( 1.0 ) );
82
+ }
83
+
84
+ fn tmAgxApprox( x: f32 ) -> f32 {
85
+ let x2 = x * x;
86
+ let x4 = x2 * x2;
87
+ return 15.5 * x4 * x2 - 40.14 * x4 * x + 31.96 * x4 - 6.868 * x2 * x + 0.4298 * x2 + 0.1191 * x - 0.00232;
88
+ }
89
+
90
+ fn tmAgx( r: vec3f ) -> vec3f {
91
+ let c = vec3f(
92
+ 0.6274 * r.r + 0.3293 * r.g + 0.0433 * r.b,
93
+ 0.0691 * r.r + 0.9195 * r.g + 0.0113 * r.b,
94
+ 0.0164 * r.r + 0.0880 * r.g + 0.8956 * r.b
95
+ );
96
+ var a = vec3f(
97
+ 0.856627153315983 * c.r + 0.0951212405381588 * c.g + 0.0482516061458583 * c.b,
98
+ 0.137318972929847 * c.r + 0.761241990602591 * c.g + 0.101439036467562 * c.b,
99
+ 0.11189821299995 * c.r + 0.0767994186031903 * c.g + 0.811302368396859 * c.b
100
+ );
101
+
102
+ let agxMinEv = -12.47393;
103
+ let agxMaxEv = 4.026069;
104
+ let range = agxMaxEv - agxMinEv;
105
+ a = clamp( ( log2( max( a, vec3f( 1e-10 ) ) ) - vec3f( agxMinEv ) ) / range, vec3f( 0.0 ), vec3f( 1.0 ) );
106
+ a = vec3f( tmAgxApprox( a.r ), tmAgxApprox( a.g ), tmAgxApprox( a.b ) );
107
+
108
+ var o = vec3f(
109
+ 1.1271005818144368 * a.r - 0.11060664309660323 * a.g - 0.016493938717834573 * a.b,
110
+ -0.1413297634984383 * a.r + 1.157823702216272 * a.g - 0.016493938717834257 * a.b,
111
+ -0.14132976349843826 * a.r - 0.11060664309660294 * a.g + 1.2519364065950405 * a.b
112
+ );
113
+ o = pow( max( o, vec3f( 0.0 ) ), vec3f( 2.2 ) );
114
+
115
+ return clamp( vec3f(
116
+ 1.6605 * o.r - 0.5876 * o.g - 0.0728 * o.b,
117
+ -0.1246 * o.r + 1.1329 * o.g - 0.0083 * o.b,
118
+ -0.0182 * o.r - 0.1006 * o.g + 1.1187 * o.b
119
+ ), vec3f( 0.0 ), vec3f( 1.0 ) );
120
+ }
121
+
122
+ fn tmNeutral( cIn: vec3f ) -> vec3f {
123
+ let startCompression = 0.8 - 0.04;
124
+ let desaturation = 0.15;
125
+
126
+ let x = min( cIn.r, min( cIn.g, cIn.b ) );
127
+ var offset = 0.04;
128
+ if ( x < 0.08 ) { offset = x - 6.25 * x * x; }
129
+
130
+ var c = cIn - vec3f( offset );
131
+ let peak = max( c.r, max( c.g, c.b ) );
132
+ if ( peak < startCompression ) { return c; }
133
+
134
+ let d = 1.0 - startCompression;
135
+ let newPeak = 1.0 - d * d / ( peak + d - startCompression );
136
+ c = c * ( newPeak / peak );
137
+ let gFactor = 1.0 - 1.0 / ( desaturation * ( peak - newPeak ) + 1.0 );
138
+
139
+ return c + ( vec3f( newPeak ) - c ) * gFactor;
140
+ }
141
+
142
+ fn tmCurve( cIn: vec3f, mode: u32 ) -> vec3f {
143
+ // Three.js clamps the fragment output before tone mapping ("force unsigned floats"), and
144
+ // AgX/Neutral mix negatives across channels rather than clipping them.
145
+ let c = max( cIn, vec3f( 0.0 ) );
146
+ switch mode {
147
+ case 0u, 1u: { return clamp( c, vec3f( 0.0 ), vec3f( 1.0 ) ); }
148
+ case 2u: { return tmReinhard( c ); }
149
+ case 3u: { return tmCineon( c ); }
150
+ case 6u: { return tmAgx( c ); }
151
+ case 7u: { return tmNeutral( c ); }
152
+ default: { return tmAces( c ); }
153
+ }
154
+ }
155
+
156
+ fn toneMapPixel( linear: vec3f, gain: f32, saturation: f32, mode: u32 ) -> vec3f {
157
+ var c = linear * gain;
158
+ c = tmSaturation( c, saturation );
159
+ c = tmCurve( c, mode );
160
+ return tmSrgb( c );
161
+ }
162
+ `;
@@ -3,6 +3,8 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
3
  import { EngineEvents, } from '../EngineEvents.js';
4
4
  import { AF_DEFAULTS } from '../EngineDefaults.js';
5
5
 
6
+ const DEFAULT_CAMERA_SCALE = Object.freeze( new Vector3( 1, 1, 1 ) );
7
+
6
8
  /**
7
9
  * Manages camera creation, switching, auto-focus, and AF point placement.
8
10
  *
@@ -83,6 +85,7 @@ export class CameraManager extends EventDispatcher {
83
85
  const cam = new PerspectiveCamera( src.fov, src.aspect, src.near, src.far );
84
86
  cam.position.copy( src.position );
85
87
  cam.quaternion.copy( src.quaternion );
88
+ cam.scale.copy( src.scale );
86
89
  cam.updateMatrixWorld( true );
87
90
 
88
91
  cam.userData.__rayzeeUserCamera = true;
@@ -259,6 +262,7 @@ export class CameraManager extends EventDispatcher {
259
262
  this._defaultCameraState = {
260
263
  position: this.camera.position.clone(),
261
264
  quaternion: this.camera.quaternion.clone(),
265
+ scale: this.camera.scale.clone(),
262
266
  fov: this.camera.fov,
263
267
  near: this.camera.near,
264
268
  far: this.camera.far,
@@ -275,6 +279,9 @@ export class CameraManager extends EventDispatcher {
275
279
  const s = this._defaultCameraState;
276
280
  this.camera.position.copy( s.position );
277
281
  this.camera.quaternion.copy( s.quaternion );
282
+ // Clears a mirror picked up from an imported camera; older saved states
283
+ // predate the field and are unmirrored by definition.
284
+ this.camera.scale.copy( s.scale ?? DEFAULT_CAMERA_SCALE );
278
285
  this.camera.fov = s.fov;
279
286
  this.camera.near = s.near;
280
287
  this.camera.far = s.far;
@@ -294,6 +301,10 @@ export class CameraManager extends EventDispatcher {
294
301
 
295
302
  this.camera.position.copy( sourceCamera.position );
296
303
  this.camera.quaternion.copy( sourceCamera.quaternion );
304
+ // An imported camera can carry a mirror (a negative axis scale) that no
305
+ // quaternion can express — a pbrt scene's `Scale -1 1 1`, for one. Copying
306
+ // pose alone would silently un-mirror the view.
307
+ this.camera.scale.copy( sourceCamera.scale );
297
308
  this.camera.fov = sourceCamera.fov;
298
309
  this.camera.near = sourceCamera.near;
299
310
  this.camera.far = sourceCamera.far;