rayzee 7.26.4 → 7.27.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": "7.26.4",
3
+ "version": "7.27.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",
@@ -393,6 +393,9 @@ export class PathTracerApp extends EventDispatcher {
393
393
  samples: this.stages.pathTracer.frameCount,
394
394
  timeElapsed: this.completion.timeElapsed,
395
395
  budgetOverrun: this.completion.budgetOverrun,
396
+ // null means isComplete was forced rather than earned (the reconcile below);
397
+ // a forced stop is closer to the ceiling than to convergence.
398
+ reason: this.completion.stopCondition( this.stages.pathTracer ) ?? 'samples',
396
399
  };
397
400
 
398
401
  this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
@@ -83,12 +83,33 @@ export class CompletionTracker {
83
83
 
84
84
  if ( ! pathTracer ) return false;
85
85
 
86
+ // Arms budgetOverrun, which stopCondition reads.
86
87
  if ( this.isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) ) return true;
87
88
 
88
- // Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
89
- // reconcile would flip isComplete back off and keep dispatching after the frame converged.
90
- return pathTracer.frameCount >= pathTracer.completionThreshold
91
- || ( pathTracer._isConvergedComplete?.() ?? false );
89
+ return this.stopCondition( pathTracer ) !== null;
90
+
91
+ }
92
+
93
+ /**
94
+ * Which stop condition is satisfied, or null to keep rendering — the one place the condition
95
+ * set lives, so "should we stop" and "why did we stop" cannot drift apart. The Tier-1
96
+ * convergence check must agree with PathTracer.render()'s own, else the app-level reconcile
97
+ * flips isComplete back off and keeps dispatching after the frame converged.
98
+ *
99
+ * Ceiling before convergence: a render that spent its whole budget reports the ceiling even
100
+ * when it also converged on that sample.
101
+ *
102
+ * @param {Object} pathTracer - The PathTracer stage
103
+ * @returns {'timeLimit'|'samples'|'converged'|null}
104
+ */
105
+ stopCondition( pathTracer ) {
106
+
107
+ if ( this.budgetOverrun ) return 'timeLimit';
108
+ if ( ! pathTracer ) return null;
109
+ if ( pathTracer.frameCount >= pathTracer.completionThreshold ) return 'samples';
110
+ if ( pathTracer._isConvergedComplete?.() ) return 'converged';
111
+
112
+ return null;
92
113
 
93
114
  }
94
115
 
@@ -330,6 +330,67 @@ export const samplePointLightWithAttenuation = Fn( ( [ light, rayOrigin, lightSe
330
330
 
331
331
  } );
332
332
 
333
+ // Sun sampling. `angle` is angular diameter: 0 is a hard shadow, >0 cone-samples the disc.
334
+ //
335
+ // intensity is IRRADIANCE (Blender Sun "Strength"), already integrated over the disc, so it has
336
+ // to become radiance before NEE divides by the 1/solidAngle cone pdf. Passing it through raw
337
+ // scaled the sun BY its own solid angle — a real 0.53° sun measured ~15000x too dark.
338
+ export const sampleDirectionalLight = Fn( ( [ light, rayOrigin, lightSelectionPdf, uCos, uPhi ] ) => {
339
+
340
+ const ls_valid = tslBool( false ).toVar();
341
+ const ls_direction = vec3( 0.0, 1.0, 0.0 ).toVar();
342
+ const ls_emission = vec3( 0.0 ).toVar();
343
+ const ls_pdf = float( 0.0 ).toVar();
344
+
345
+ If( light.intensity.greaterThan( 0.0 ), () => {
346
+
347
+ const w = normalize( light.direction ).toVar();
348
+ const emission = light.color.mul( light.intensity ).mul( sampleDirectionalGoboMask( light, rayOrigin ) ).toVar();
349
+
350
+ ls_direction.assign( w );
351
+ ls_pdf.assign( lightSelectionPdf );
352
+
353
+ If( light.angle.greaterThan( 0.0 ), () => {
354
+
355
+ const cosHalfAngle = cos( light.angle.mul( 0.5 ) ).toVar();
356
+ const cosTheta = mix( cosHalfAngle, float( 1.0 ), uCos ).toVar();
357
+ const sinTheta = sqrt( max( float( 0.0 ), float( 1.0 ).sub( cosTheta.mul( cosTheta ) ) ) ).toVar();
358
+ const phi = float( TWO_PI ).mul( uPhi ).toVar();
359
+
360
+ const u = normalize( cross(
361
+ select( abs( w.x ).greaterThan( 0.9 ), vec3( 0.0, 1.0, 0.0 ), vec3( 1.0, 0.0, 0.0 ) ),
362
+ w
363
+ ) ).toVar();
364
+ const v = cross( w, u ).toVar();
365
+
366
+ ls_direction.assign( normalize(
367
+ w.mul( cosTheta ).add( u.mul( cos( phi ) ).add( v.mul( sin( phi ) ) ).mul( sinTheta ) )
368
+ ) );
369
+
370
+ // Guard the division: (1 - cosHalfAngle) underflows to zero at tiny angles.
371
+ const solidAngle = float( TWO_PI ).mul( max( float( 1.0 ).sub( cosHalfAngle ), 1e-10 ) ).toVar();
372
+ emission.assign( emission.div( solidAngle ) );
373
+ ls_pdf.assign( lightSelectionPdf.div( solidAngle ) );
374
+
375
+ } );
376
+
377
+ ls_emission.assign( emission );
378
+ ls_valid.assign( tslBool( true ) );
379
+
380
+ } );
381
+
382
+ return LightSample( {
383
+ valid: ls_valid,
384
+ direction: ls_direction,
385
+ emission: ls_emission,
386
+ distance: float( 1e6 ),
387
+ pdf: ls_pdf,
388
+ lightType: int( LIGHT_TYPE_DIRECTIONAL ),
389
+ selectionTotalWeight: float( 0.0 ),
390
+ } );
391
+
392
+ } );
393
+
333
394
  // =============================================================================
334
395
  // Importance-Weighted Light Sampling
335
396
  // =============================================================================
@@ -496,12 +557,16 @@ export const sampleLightWithImportance = Fn( ( [
496
557
 
497
558
  If( light.intensity.greaterThan( 0.0 ), () => {
498
559
 
499
- const dirGoboMask = sampleDirectionalGoboMask( light, rayOrigin );
500
- r_direction.assign( normalize( light.direction ) );
501
- r_emission.assign( light.color.mul( light.intensity ).mul( dirGoboMask ) );
502
- r_distance.assign( 1e6 );
503
- r_lightType.assign( int( LIGHT_TYPE_DIRECTIONAL ) );
504
- r_valid.assign( tslBool( true ) );
560
+ const dirSample = LightSample.wrap( sampleDirectionalLight(
561
+ light, rayOrigin, lightSelectionPdf, randomSeed.y,
562
+ getRandomSample1D( pixelCoord, int( 0 ), dimBase.add( int( 14 ) ), rngState, resolution, frame )
563
+ ) );
564
+ r_valid.assign( dirSample.valid );
565
+ r_direction.assign( dirSample.direction );
566
+ r_emission.assign( dirSample.emission );
567
+ r_distance.assign( dirSample.distance );
568
+ r_pdf.assign( dirSample.pdf );
569
+ r_lightType.assign( dirSample.lightType );
505
570
  sampled.assign( tslBool( true ) );
506
571
 
507
572
  } );
@@ -601,39 +666,17 @@ export const sampleLightWithImportance = Fn( ( [
601
666
 
602
667
  const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, selectedIdx ) );
603
668
 
604
- const direction = normalize( light.direction ).toVar();
605
- const dirPdf = float( 1.0 ).toVar();
606
-
607
- If( light.angle.greaterThan( 0.0 ), () => {
608
-
609
- const cosHalfAngle = cos( light.angle.mul( 0.5 ) ).toVar();
610
- const cosTheta = mix( cosHalfAngle, float( 1.0 ), randomSeed.y ).toVar();
611
- const sinTheta = sqrt( max( float( 0.0 ), float( 1.0 ).sub( cosTheta.mul( cosTheta ) ) ) ).toVar();
612
- const phi = float( TWO_PI ).mul( getRandomSample1D( pixelCoord, int( 0 ), dimBase.add( int( 14 ) ), rngState, resolution, frame ) ).toVar();
613
-
614
- const w = normalize( light.direction ).toVar();
615
- const u = normalize( cross(
616
- select( abs( w.x ).greaterThan( 0.9 ), vec3( 0.0, 1.0, 0.0 ), vec3( 1.0, 0.0, 0.0 ) ),
617
- w
618
- ) ).toVar();
619
- const v = cross( w, u ).toVar();
620
-
621
- direction.assign( normalize(
622
- w.mul( cosTheta ).add( u.mul( cos( phi ) ).add( v.mul( sin( phi ) ) ).mul( sinTheta ) )
623
- ) );
624
- // Guard division: (1.0 - cosHalfAngle) could be zero if angle is 0
625
- const solidAngle = float( TWO_PI ).mul( max( float( 1.0 ).sub( cosHalfAngle ), 1e-10 ) );
626
- dirPdf.assign( float( 1.0 ).div( solidAngle ) );
627
-
628
- } );
629
-
630
- const dirGoboMask = sampleDirectionalGoboMask( light, rayOrigin );
631
- r_direction.assign( direction );
632
- r_emission.assign( light.color.mul( light.intensity ).mul( dirGoboMask ) );
633
- r_distance.assign( 1e6 );
634
- r_pdf.assign( dirPdf.mul( pdf ) );
635
- r_lightType.assign( int( LIGHT_TYPE_DIRECTIONAL ) );
636
- r_valid.assign( tslBool( true ) );
669
+ const dirSample = LightSample.wrap( sampleDirectionalLight(
670
+ light, rayOrigin, pdf, randomSeed.y,
671
+ getRandomSample1D( pixelCoord, int( 0 ), dimBase.add( int( 14 ) ), rngState, resolution, frame )
672
+ ) );
673
+
674
+ r_direction.assign( dirSample.direction );
675
+ r_emission.assign( dirSample.emission );
676
+ r_distance.assign( dirSample.distance );
677
+ r_pdf.assign( dirSample.pdf );
678
+ r_lightType.assign( dirSample.lightType );
679
+ r_valid.assign( dirSample.valid );
637
680
 
638
681
  } );
639
682
 
@@ -324,6 +324,13 @@ export class LightManager extends EventDispatcher {
324
324
 
325
325
  angle = MathUtils.radToDeg( light.angle );
326
326
 
327
+ } else if ( light.type === 'DirectionalLight' ) {
328
+
329
+ // Sun angular diameter. Lives on userData because three.js gives DirectionalLight no
330
+ // angle property, and an ad-hoc one would not survive the light.clone() in
331
+ // transferSceneLights. Reported here or the panel reads back 0 and the slider snaps home.
332
+ angle = MathUtils.radToDeg( light.userData?.angle ?? 0 );
333
+
327
334
  }
328
335
 
329
336
  const descriptor = {