rayzee 7.28.0 → 7.28.2

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.28.0",
3
+ "version": "7.28.2",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -10,7 +10,7 @@
10
10
  */
11
11
  export const RENDER_PROFILES = Object.freeze( {
12
12
  viewer: Object.freeze( {
13
- areaLightIntensityScale: 0.1, // authored RectAreaLight watts from glTF placeholders
13
+ areaLightIntensityScale: 0.1, // scales glTF placeholder area-light power (viewer tuning)
14
14
  environmentRotation: 270.0, // degrees
15
15
  toneMapping: 4, // ACESFilmicToneMapping
16
16
  saturation: 1.2, // compensates for ACES desaturation — see Compositor
@@ -728,6 +728,9 @@ export class PathTracerApp extends EventDispatcher {
728
728
  * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
729
729
  * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
730
730
  *
731
+ * Lights keep three.js units — `RectAreaLight.intensity` in nits, point/spot in candela — and are
732
+ * converted to radiant power on the copy. `areaLightIntensityScale` does not apply here.
733
+ *
731
734
  * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
732
735
  * @param {string} [name='object3d'] - Display name for the object
733
736
  */
@@ -51,6 +51,19 @@ function standInForSplit( source ) {
51
51
 
52
52
  }
53
53
 
54
+ // three.js nits → the engine's radiant power, inverting areaLightRadiance.
55
+ function areaLightPowerFactor( node, width, height, userData ) {
56
+
57
+ if ( ! ( userData.normalize ?? true ) ) return Math.PI;
58
+
59
+ const shapeFactor = userData.shape === 'ellipse' || userData.shape === 'disk' ? Math.PI / 4 : 1;
60
+ const scale = node.getWorldScale( new Vector3() );
61
+ // abs: a mirrored ancestor decomposes negative; the serializer takes the area as |u × v|.
62
+ const factor = Math.PI * shapeFactor * Math.abs( width * scale.x * height * scale.y );
63
+ return Number.isFinite( factor ) ? factor : Math.PI;
64
+
65
+ }
66
+
54
67
  /**
55
68
  * AssetLoader class - handles loading of 3D models, environment maps, and archives
56
69
  */
@@ -1726,7 +1739,6 @@ export class AssetLoader extends EventDispatcher {
1726
1739
 
1727
1740
  processModelObjects( model ) {
1728
1741
 
1729
- let visitedAreaLights = [];
1730
1742
  // Split after the walk: traverse() caches children.length, so splitting in place
1731
1743
  // shifts later siblings down a slot and skips one.
1732
1744
  const multiMaterialMeshes = [];
@@ -1734,9 +1746,11 @@ export class AssetLoader extends EventDispatcher {
1734
1746
 
1735
1747
  const userData = object.userData;
1736
1748
 
1737
- if ( object.isRectAreaLight && ! visitedAreaLights.includes( object.uuid ) ) {
1749
+ // An adopted light carries three.js units; convert as point/spot are below.
1750
+ if ( object.isRectAreaLight && ! userData.__radianceConverted ) {
1738
1751
 
1739
- visitedAreaLights.push( object.uuid );
1752
+ object.intensity *= areaLightPowerFactor( object, object.width, object.height, userData );
1753
+ userData.__radianceConverted = true;
1740
1754
 
1741
1755
  }
1742
1756
 
@@ -1759,23 +1773,21 @@ export class AssetLoader extends EventDispatcher {
1759
1773
 
1760
1774
  if ( userData.type === 'RectAreaLight' ) {
1761
1775
 
1762
- // Intensity is authored in Watts (Blender-style); emitted radiance
1763
- // is computed at render time as power/(π·area). Blender emission
1764
- // defaults: power-normalized, full Lambertian spread (π), rectangle.
1765
- // Viewer tuning, not physics — see RENDER_PROFILES.
1776
+ const normalize = userData.normalize ?? true;
1777
+ const shape = userData.shape ?? 'rectangle';
1778
+ const power = userData.intensity * areaLightPowerFactor( object, userData.width, userData.height, userData );
1766
1779
  const light = new RectAreaLight(
1767
1780
  new Color( ...userData.color ),
1768
- userData.intensity * this._profile.areaLightIntensityScale,
1781
+ power * this._profile.areaLightIntensityScale,
1769
1782
  userData.width,
1770
1783
  userData.height
1771
1784
  );
1772
- light.userData.normalize = userData.normalize ?? true;
1785
+ light.userData.normalize = normalize;
1773
1786
  light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
1774
- light.userData.shape = userData.shape ?? 'rectangle';
1775
- light.position.z = - 2;
1787
+ light.userData.shape = shape;
1788
+ light.userData.__radianceConverted = true; // already power, and traverse() reaches it
1776
1789
  light.name = userData.name;
1777
1790
  object.add( light );
1778
- visitedAreaLights.push( light.uuid );
1779
1791
 
1780
1792
  }
1781
1793
 
@@ -27,9 +27,15 @@ export const COUNTER = {
27
27
  // bits restricted to pixels that hit geometry, so the stop can require both fractions.
28
28
  GEOMETRY_COUNT: 5,
29
29
  CONVERGED_GEOMETRY_COUNT: 6,
30
- COUNT: 7,
30
+ // Survivors' summed throughput (max channel, fixed-point ENERGY_SCALE). The early exit gates on this,
31
+ // not the count: past Russian roulette a few survivors carry the weight of many.
32
+ ACTIVE_ENERGY: 7,
33
+ COUNT: 8,
31
34
  };
32
35
 
36
+ export const ENERGY_SCALE = 64;
37
+ export const ENERGY_RAY_CLAMP = 65535;
38
+
33
39
  /** Ray flag bits packed into rayBounceFlags (uint) */
34
40
  export const RAY_FLAG = {
35
41
  BOUNCE_MASK: 0xFF, // bits 0-7: bounce count (0-255)
@@ -81,10 +87,10 @@ export class QueueManager {
81
87
  this._countersAttr = new StorageInstancedBufferAttribute( new Uint32Array( COUNTER.COUNT ), 1 );
82
88
  this.counters = storage( this._countersAttr, 'uint' ).toAtomic();
83
89
 
84
- // per-bounce ACTIVE_RAY_COUNT snapshots; read back async to size/skip late bounces next frame
90
+ // per-bounce snapshots for the async readback: [0, MAX) ACTIVE_RAY_COUNT, [MAX, 2·MAX) ACTIVE_ENERGY
85
91
  this.MAX_BOUNCE_SNAPSHOTS = 32;
86
92
  this._bounceCountsAttr = new StorageInstancedBufferAttribute(
87
- new Uint32Array( this.MAX_BOUNCE_SNAPSHOTS ), 1,
93
+ new Uint32Array( 2 * this.MAX_BOUNCE_SNAPSHOTS ), 1,
88
94
  );
89
95
  this.bounceCounts = storage( this._bounceCountsAttr, 'uint' );
90
96
 
@@ -8,7 +8,7 @@ import { uniform, texture, storage } from 'three/tsl';
8
8
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
9
9
  import { PathTracerStage } from './PathTracerStage.js';
10
10
  import { PackedRayBuffer, GBUFFER_STRIDE, RAY_STRIDE, HIT_STRIDE, freeStorageAttribute } from '../Processor/PackedRayBuffer.js';
11
- import { QueueManager, COUNTER } from '../Processor/QueueManager.js';
11
+ import { QueueManager, COUNTER, ENERGY_SCALE } from '../Processor/QueueManager.js';
12
12
  import { VRAMTracker } from '../Processor/VRAMTracker.js';
13
13
  import { KernelManager } from '../Processor/KernelManager.js';
14
14
  import { buildGenerateKernel, GENERATE_WG_SIZE } from '../TSL/GenerateKernel.js';
@@ -85,6 +85,7 @@ export class PathTracer extends PathTracerStage {
85
85
  this._useSubgroupCompact = false;
86
86
 
87
87
  this._lastBounceCounts = null;
88
+ this._lastBounceEnergy = null;
88
89
  // maxBounces the curve was measured at; the curve is ignored once this no longer matches (-1 = none).
89
90
  this._lastBounceCountsBudget = - 1;
90
91
  this._readbackPending = false;
@@ -95,10 +96,11 @@ export class PathTracer extends PathTracerStage {
95
96
  // Whether the survivor curve may be trusted to SIZE the dispatch. False during/after camera motion
96
97
  // (or resize) until a readback measured at the settled view re-validates it — full-size until then so
97
98
  // a stale/mid-motion curve can't under-size the row-major list and drop the bottom rows. The curve is
98
- // still used for the per-bounce early-exit regardless (a stale early-exit only trims empty deep bounces).
99
+ // still used for the per-bounce early-exit regardless (it only trims bounces holding negligible energy).
99
100
  this._curveSizingValid = false;
100
- // 0.1% of primary ray count, floored at 100; -1 to disable. Updated per-scene in _buildWavefrontKernels.
101
- this._bounceEarlyExitThreshold = 100;
101
+ // Early exit once the survivors' summed throughput is below this fraction of a full frame's; -1
102
+ // disables. Never a ray count: after Russian roulette a handful of survivors carry real weight.
103
+ this._bounceEarlyExitThreshold = 1e-4;
102
104
 
103
105
  // Tier-1 convergence early-stop: fraction of pixels converged in the last settled-view readback, and a
104
106
  // single-flight guard for its async counter read. Zeroed on reset/camera-move/resize; never refreshed
@@ -400,6 +402,9 @@ export class PathTracer extends PathTracerStage {
400
402
 
401
403
  }
402
404
 
405
+ const energyCurve = this._lastBounceEnergy;
406
+ const exitEnergy = this._bounceEarlyExitThreshold >= 0 ? this._bounceEarlyExitThreshold * maxRays * ENERGY_SCALE : - 1;
407
+
403
408
  for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
404
409
 
405
410
  this._wfCurrentBounce.value = bounce;
@@ -474,10 +479,11 @@ export class PathTracer extends PathTracerStage {
474
479
 
475
480
  // Early-exit on last frame's per-bounce snapshot (single-chunk only; curveReliableUpto=0 disables it for multi-chunk).
476
481
  if (
477
- bounce < curveReliableUpto
482
+ exitEnergy >= 0
483
+ && bounce < curveReliableUpto
478
484
  && bounce < loopBound
479
- && curve[ bounce ] !== undefined
480
- && curve[ bounce ] <= this._bounceEarlyExitThreshold
485
+ && energyCurve?.[ bounce ] !== undefined
486
+ && energyCurve[ bounce ] <= exitEnergy
481
487
  ) {
482
488
 
483
489
  break;
@@ -732,6 +738,7 @@ export class PathTracer extends PathTracerStage {
732
738
  this._readbackPending = true;
733
739
  const gen = this._readbackGeneration;
734
740
  const budget = this.maxBounces.value;
741
+ const n = this._queueManager.MAX_BOUNCE_SNAPSHOTS;
735
742
  this.renderer.getArrayBufferAsync( attr ).then( ( buf ) => {
736
743
 
737
744
  // Drop counts measured at a now-stale generation (a resize or camera move happened mid-flight).
@@ -739,7 +746,9 @@ export class PathTracer extends PathTracerStage {
739
746
  // happened before it resolved, so its counts match the current view — safe to size from.
740
747
  if ( gen === this._readbackGeneration ) {
741
748
 
742
- this._lastBounceCounts = new Uint32Array( buf.slice( 0 ) );
749
+ const all = new Uint32Array( buf.slice( 0 ) );
750
+ this._lastBounceCounts = all.subarray( 0, n );
751
+ this._lastBounceEnergy = all.subarray( n, 2 * n );
743
752
  this._lastBounceCountsBudget = budget;
744
753
  this._curveSizingValid = true;
745
754
 
@@ -826,6 +835,7 @@ export class PathTracer extends PathTracerStage {
826
835
  // until the readback re-measures at the new size; bump the generation so any readback already
827
836
  // in flight (carrying the old-resolution counts) is discarded when it resolves.
828
837
  this._lastBounceCounts = null;
838
+ this._lastBounceEnergy = null;
829
839
  this._lastBounceCountsBudget = - 1;
830
840
  this._curveSizingValid = false;
831
841
  this._readbackFrameCounter = 0;
@@ -861,11 +871,6 @@ export class PathTracer extends PathTracerStage {
861
871
  this._wfRenderWidth.value = w;
862
872
  this._wfRenderHeight.value = h;
863
873
  this._updateChunkLayout();
864
- if ( this._bounceEarlyExitThreshold !== - 1 ) {
865
-
866
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
867
-
868
- }
869
874
 
870
875
  }
871
876
 
@@ -969,11 +974,6 @@ export class PathTracer extends PathTracerStage {
969
974
  // every per-ray kernel's dispatch per chunk; this is just a valid starting grid (always ≤ B).
970
975
  const maxRays = this._chunkRows * w;
971
976
 
972
- if ( this._bounceEarlyExitThreshold !== - 1 ) {
973
-
974
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
975
-
976
- }
977
977
 
978
978
  // Per-path buffers (RAY/HIT/rng) sized to the budget B and indexed by LOCAL slot r ∈ [0,B). _cap = B is
979
979
  // baked into the SoA stride but never changes with resolution, so this build happens once (model load).
@@ -1068,6 +1068,7 @@ export class PathTracer extends PathTracerStage {
1068
1068
  const cnt = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
1069
1069
  const slot = uint( wfCurrentBounce ).clamp( uint( 0 ), uint( qm.MAX_BOUNCE_SNAPSHOTS - 1 ) );
1070
1070
  bounceCountsBuf.element( slot ).assign( cnt );
1071
+ bounceCountsBuf.element( slot.add( uint( qm.MAX_BOUNCE_SNAPSHOTS ) ) ).assign( atomicLoad( counters.element( uint( COUNTER.ACTIVE_ENERGY ) ) ) );
1071
1072
  // Also set ENTERING_COUNT for the next bounce; the full-dispatch path's enterFull overrides it.
1072
1073
  atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), cnt );
1073
1074
 
@@ -1522,6 +1523,7 @@ export class PathTracer extends PathTracerStage {
1522
1523
 
1523
1524
  const slot = uint( wfCurrentBounce ).clamp( uint( 0 ), uint( qm.MAX_BOUNCE_SNAPSHOTS - 1 ) );
1524
1525
  bounceCountsBuf.element( slot ).assign( active );
1526
+ bounceCountsBuf.element( slot.add( uint( qm.MAX_BOUNCE_SNAPSHOTS ) ) ).assign( atomicLoad( counters.element( uint( COUNTER.ACTIVE_ENERGY ) ) ) );
1525
1527
  atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), active );
1526
1528
 
1527
1529
  } );
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import {
6
- Fn, uint, select,
6
+ Fn, uint, select, max, min,
7
7
  If,
8
8
  instanceIndex,
9
9
  atomicAdd, atomicLoad,
@@ -11,11 +11,20 @@ import {
11
11
  Return,
12
12
  } from 'three/tsl';
13
13
 
14
- import { readRayBounceFlags } from '../Processor/PackedRayBuffer.js';
15
- import { RAY_FLAG, COUNTER } from '../Processor/QueueManager.js';
14
+ import { readRayBounceFlags, readRayThroughput } from '../Processor/PackedRayBuffer.js';
15
+ import { RAY_FLAG, COUNTER, ENERGY_SCALE, ENERGY_RAY_CLAMP } from '../Processor/QueueManager.js';
16
16
 
17
17
  const WG_SIZE = 256;
18
18
 
19
+ // Survivor's remaining throughput as fixed-point u32; clamped so a frame's sum cannot wrap.
20
+ const rayEnergy = ( rayBufferRO, rayID ) => {
21
+
22
+ const t = readRayThroughput( rayBufferRO, rayID );
23
+ const m = max( max( t.x, t.y ), t.z ).max( 0.0 );
24
+ return min( uint( m.mul( ENERGY_SCALE ).add( 0.5 ) ), uint( ENERGY_RAY_CLAMP ) );
25
+
26
+ };
27
+
19
28
  export function buildCompactKernel( params ) {
20
29
 
21
30
  const {
@@ -46,6 +55,7 @@ export function buildCompactKernel( params ) {
46
55
 
47
56
  const writeIdx = atomicAdd( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 1 ) );
48
57
  activeIndicesWriteRW.element( writeIdx ).assign( rayID );
58
+ atomicAdd( counters.element( uint( COUNTER.ACTIVE_ENERGY ) ), rayEnergy( rayBufferRO, rayID ) );
49
59
 
50
60
  } );
51
61
 
@@ -80,10 +90,12 @@ export function buildCompactSubgroupKernel( params ) {
80
90
  const flags = readRayBounceFlags( rayBufferRO, rayID );
81
91
  const isActive = inRange.and( flags.bitAnd( uint( RAY_FLAG.ACTIVE ) ).notEqual( uint( 0 ) ) );
82
92
  const activeU = select( isActive, uint( 1 ), uint( 0 ) );
93
+ const energyU = select( isActive, rayEnergy( rayBufferRO, rayID ), uint( 0 ) );
83
94
 
84
95
  // .toVar() materializes the subgroup ops at uniform control flow; inlining into the divergent If(isActive) write is rejected by WGSL.
85
96
  const localOffset = subgroupExclusiveAdd( activeU ).toVar();
86
97
  const sgCount = subgroupAdd( activeU ).toVar();
98
+ const sgEnergy = subgroupAdd( energyU ).toVar();
87
99
 
88
100
  // laneId via exclusiveAdd(1) since TSL lacks subgroup_invocation_id; lane 0 does the single per-subgroup atomicAdd.
89
101
  const laneId = subgroupExclusiveAdd( uint( 1 ) ).toVar();
@@ -91,6 +103,7 @@ export function buildCompactSubgroupKernel( params ) {
91
103
  If( laneId.equal( uint( 0 ) ), () => {
92
104
 
93
105
  base.assign( atomicAdd( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), sgCount ) );
106
+ atomicAdd( counters.element( uint( COUNTER.ACTIVE_ENERGY ) ), sgEnergy );
94
107
 
95
108
  } );
96
109
  const sgBase = subgroupBroadcast( base, uint( 0 ) ).toVar();
@@ -527,7 +527,7 @@ export const sampleEmissiveTriangle = Fn( ( [
527
527
  // products with the PDF call below.
528
528
 
529
529
  export const calculateEmissiveTriangleContributionDebug = Fn( ( [
530
- hitPoint, normal, viewDir, material,
530
+ hitPoint, normal, geomNormal, viewDir, material,
531
531
  bounceIndex, rngState,
532
532
  pixelCoord, resolution, frame, dimBase,
533
533
  emissiveBoost,
@@ -565,10 +565,10 @@ export const calculateEmissiveTriangleContributionDebug = Fn( ( [
565
565
  // Check geometric validity
566
566
  const NoL = max( float( 0.0 ), dot( normal, emissiveSample.direction ) );
567
567
 
568
- If( NoL.greaterThan( 0.0 ), () => {
568
+ If( NoL.greaterThan( 0.0 ).and( dot( emissiveSample.direction, geomNormal ).greaterThan( 0.0 ) ), () => {
569
569
 
570
570
  // Calculate ray offset for shadow ray
571
- const rayOffset = calculateRayOffsetFn( hitPoint, normal, material );
571
+ const rayOffset = calculateRayOffsetFn( hitPoint, geomNormal, material );
572
572
  const rayOrigin = hitPoint.add( rayOffset );
573
573
 
574
574
  // Trace shadow ray
@@ -609,7 +609,7 @@ export const calculateEmissiveTriangleContributionDebug = Fn( ( [
609
609
 
610
610
  // Wrapper that returns just the contribution vec3
611
611
  export const calculateEmissiveTriangleContribution = Fn( ( [
612
- hitPoint, normal, viewDir, material,
612
+ hitPoint, normal, geomNormal, viewDir, material,
613
613
  bounceIndex, rngState,
614
614
  pixelCoord, resolution, frame, dimBase,
615
615
  emissiveBoost,
@@ -620,7 +620,7 @@ export const calculateEmissiveTriangleContribution = Fn( ( [
620
620
  ] ) => {
621
621
 
622
622
  const result = EmissiveContributionResult.wrap( calculateEmissiveTriangleContributionDebug(
623
- hitPoint, normal, viewDir, material,
623
+ hitPoint, normal, geomNormal, viewDir, material,
624
624
  bounceIndex, rngState,
625
625
  pixelCoord, resolution, frame, dimBase,
626
626
  emissiveBoost,
@@ -341,8 +341,10 @@ export const estimateLightImportance = Fn( ( [ light, hitPoint, normal, material
341
341
 
342
342
  If( lightFacing.greaterThan( 0.0 ), () => {
343
343
 
344
- const solidAngle = light.area.div( max( distSq, 0.1 ) );
345
- const power = light.intensity.mul( dot( light.color, REC709_LUMINANCE_COEFFICIENTS ) ).mul( light.area );
344
+ // Diffuse irradiance ∝ L·Ω·cosθ·cosθ′, Ω bounded by the hemisphere. Unit-free.
345
+ const solidAngle = min( light.area.div( max( distSq, 1e-12 ) ), float( 2.0 * Math.PI ) );
346
+ const invArea = select( light.normalize.greaterThan( 0.5 ), float( 1.0 ).div( max( light.area, 1e-10 ) ), float( 1.0 ) );
347
+ const radiance = light.intensity.mul( dot( light.color, REC709_LUMINANCE_COEFFICIENTS ) ).mul( invArea );
346
348
 
347
349
  // Material-aware weighting
348
350
  const materialFactor = float( 1.0 ).toVar();
@@ -358,20 +360,13 @@ export const estimateLightImportance = Fn( ( [ light, hitPoint, normal, material
358
360
 
359
361
  } );
360
362
 
361
- If( material.roughness.greaterThan( 0.6 ).and( material.metalness.lessThan( 0.3 ) ), () => {
362
-
363
- const sizeBoost = min( light.area.mul( 2.0 ), float( 2.0 ) );
364
- materialFactor.mulAssign( sizeBoost );
365
-
366
- } );
367
-
368
363
  If( material.transmission.greaterThan( 0.5 ), () => {
369
364
 
370
365
  materialFactor.mulAssign( float( 1.0 ).add( material.transmission.mul( 0.3 ) ) );
371
366
 
372
367
  } );
373
368
 
374
- result.assign( power.mul( solidAngle ).mul( NoL ).mul( lightFacing ).mul( materialFactor ) );
369
+ result.assign( radiance.mul( solidAngle ).mul( NoL ).mul( lightFacing ).mul( materialFactor ) );
375
370
 
376
371
  } );
377
372