rayzee 7.1.0 → 7.2.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.
@@ -14,6 +14,8 @@ import {
14
14
  dot,
15
15
  sqrt,
16
16
  max,
17
+ clamp,
18
+ select,
17
19
  normalize,
18
20
  cross,
19
21
  length,
@@ -29,6 +31,8 @@ import {
29
31
  sampleSphericalTriangle,
30
32
  barycentricFromPoint,
31
33
  useSphericalSampling,
34
+ sphericalTriangleSolidAngle,
35
+ triangleArea,
32
36
  SphericalTriangleSampleResult,
33
37
  } from './EmissiveSampling.js';
34
38
 
@@ -37,6 +41,58 @@ const LBVH_STRIDE = 4; // 4 vec4s per node
37
41
  const EMISSIVE_STRIDE = 2; // 2 vec4s per emissive entry (matches EmissiveSampling.js)
38
42
  const MAX_LBVH_DEPTH = 32;
39
43
 
44
+ // ================================================================================
45
+ // LIGHT-BVH NODE IMPORTANCE (Conty-Estevez & Kulla 2018 / PBRT-v4 BVHLightSampler)
46
+ // ================================================================================
47
+ // cos((thetaA - thetaB) clamped to >= 0). cosA > cosB ⇒ thetaA < thetaB ⇒ diff clamped to 0.
48
+ const cosSubClamped = Fn( ( [ sinThetaA, cosThetaA, sinThetaB, cosThetaB ] ) => {
49
+
50
+ return select( cosThetaA.greaterThan( cosThetaB ), float( 1.0 ), cosThetaA.mul( cosThetaB ).add( sinThetaA.mul( sinThetaB ) ) );
51
+
52
+ } );
53
+
54
+ // sin((thetaA - thetaB) clamped to >= 0).
55
+ const sinSubClamped = Fn( ( [ sinThetaA, cosThetaA, sinThetaB, cosThetaB ] ) => {
56
+
57
+ return select( cosThetaA.greaterThan( cosThetaB ), float( 0.0 ), sinThetaA.mul( cosThetaB ).sub( cosThetaA.mul( sinThetaB ) ) );
58
+
59
+ } );
60
+
61
+ // Importance of a Light-BVH node for a shading point (power × orientation × inverse-square),
62
+ // θ_e = π/2 (diffuse emitters). cosThetaO = -1 ⇒ whole-sphere cone (never culled by orientation).
63
+ // SHARED by both the stochastic descent and the MIS pdf re-walk — they MUST stay byte-identical.
64
+ export const lbvhNodeImportance = Fn( ( [ nMin, power, nMax, coneAxis, cosThetaO, hitPoint ] ) => {
65
+
66
+ const center = nMin.add( nMax ).mul( 0.5 );
67
+ const diagLen = length( nMax.sub( nMin ) );
68
+ const toCenter = hitPoint.sub( center );
69
+ const d2c = max( dot( toCenter, toCenter ), float( 1e-12 ) );
70
+ // PBRT distance clamp (note: clamps to diagLen/2, a length not a square — matches PBRT)
71
+ const d2 = max( d2c, diagLen.mul( 0.5 ) );
72
+
73
+ const wi = toCenter.div( sqrt( d2c ) ); // direction from cluster center to shading point
74
+ const cosThetaW = dot( coneAxis, wi );
75
+ const sinThetaW = sqrt( max( float( 1.0 ).sub( cosThetaW.mul( cosThetaW ) ), float( 0.0 ) ) );
76
+
77
+ // Half-angle subtended by the cluster's bounding sphere from the shading point.
78
+ const r2 = diagLen.mul( diagLen ).mul( 0.25 );
79
+ const sin2ThetaB = clamp( r2.div( d2c ), float( 0.0 ), float( 1.0 ) );
80
+ const cosThetaB = select( d2c.lessThan( r2 ), float( - 1.0 ), sqrt( max( float( 1.0 ).sub( sin2ThetaB ), float( 0.0 ) ) ) );
81
+ const sinThetaB = sqrt( max( float( 1.0 ).sub( cosThetaB.mul( cosThetaB ) ), float( 0.0 ) ) );
82
+
83
+ const sinThetaO = sqrt( max( float( 1.0 ).sub( cosThetaO.mul( cosThetaO ) ), float( 0.0 ) ) );
84
+
85
+ // cosThetap = cos( (theta_w - theta_o - theta_b) clamped >= 0 )
86
+ const cosThetaX = cosSubClamped( sinThetaW, cosThetaW, sinThetaO, cosThetaO );
87
+ const sinThetaX = sinSubClamped( sinThetaW, cosThetaW, sinThetaO, cosThetaO );
88
+ const cosThetap = cosSubClamped( sinThetaX, cosThetaX, sinThetaB, cosThetaB );
89
+
90
+ // θ_e = π/2 ⇒ cosThetaE = 0; cluster cannot illuminate the point when cosThetap <= 0.
91
+ const imp = select( cosThetap.greaterThan( float( 0.0 ) ), power.mul( cosThetap ).div( d2 ), float( 0.0 ) );
92
+ return max( imp, float( 0.0 ) );
93
+
94
+ } );
95
+
40
96
  /**
41
97
  * Sample one emissive triangle using the Light BVH for spatially-aware importance sampling.
42
98
  *
@@ -105,31 +161,12 @@ export const sampleLightBVHTriangle = Fn( ( [
105
161
  const rd0 = lbvhBuffer.element( rBase ); // [minX, minY, minZ, totalPower]
106
162
  const rd1 = lbvhBuffer.element( rBase.add( int( 1 ) ) ); // [maxX, maxY, maxZ, isLeaf]
107
163
 
108
- // Compute center of each child's AABB
109
- const lCenter = vec3(
110
- ld0.x.add( ld1.x ).mul( 0.5 ),
111
- ld0.y.add( ld1.y ).mul( 0.5 ),
112
- ld0.z.add( ld1.z ).mul( 0.5 )
113
- );
114
- const rCenter = vec3(
115
- rd0.x.add( rd1.x ).mul( 0.5 ),
116
- rd0.y.add( rd1.y ).mul( 0.5 ),
117
- rd0.z.add( rd1.z ).mul( 0.5 )
118
- );
119
-
120
- // Compute squared distance from hitPoint to each child center
121
- const lDiff = lCenter.sub( hitPoint );
122
- const rDiff = rCenter.sub( hitPoint );
123
- const lDistSq = max( dot( lDiff, lDiff ), float( 0.01 ) );
124
- const rDistSq = max( dot( rDiff, rDiff ), float( 0.01 ) );
125
-
126
- // Child power
127
- const lPower = max( ld0.w, float( 0.0 ) );
128
- const rPower = max( rd0.w, float( 0.0 ) );
129
-
130
- // Importance = power / dist²
131
- const lImportance = lPower.div( lDistSq );
132
- const rImportance = rPower.div( rDistSq );
164
+ // Conty-Kulla importance: power × orientation-cone × inverse-square (shared with the MIS re-walk).
165
+ // d3 = [coneAxis, cosThetaO]; cosThetaO = -1 ⇒ whole-sphere cone (never culled by orientation).
166
+ const ld3 = lbvhBuffer.element( lBase.add( int( 3 ) ) );
167
+ const rd3 = lbvhBuffer.element( rBase.add( int( 3 ) ) );
168
+ const lImportance = lbvhNodeImportance( ld0.xyz, ld0.w, ld1.xyz, ld3.xyz, ld3.w, hitPoint );
169
+ const rImportance = lbvhNodeImportance( rd0.xyz, rd0.w, rd1.xyz, rd3.xyz, rd3.w, hitPoint );
133
170
  const totalImportance = lImportance.add( rImportance );
134
171
 
135
172
  If( totalImportance.lessThanEqual( float( 0.0 ) ), () => {
@@ -307,3 +344,157 @@ export const sampleLightBVHTriangle = Fn( ( [
307
344
  return result;
308
345
 
309
346
  } );
347
+
348
+ // ================================================================================
349
+ // LIGHT-BVH MIS PDF (re-walk)
350
+ // ================================================================================
351
+ // Computes the solid-angle pdf that sampleLightBVHTriangle WOULD assign to a given emissive
352
+ // triangle hit by a BSDF bounce, by re-walking the exact stochastic descent along the triangle's
353
+ // stored bit-trail. Required so the bounce-hit MIS weight uses the SAME light pdf the NEE sampler
354
+ // used — without this the MIS partition-of-unity breaks (a real bias).
355
+ //
356
+ // lightBuffer packs [ LBVH nodes | emissive entries | bit-trail map ]. The bit-trail map holds one
357
+ // float per absolute triangleIndex (4 packed per vec4): the root→leaf left(0)/right(1) choices.
358
+ export const calculateLightBVHPdf = Fn( ( [
359
+ triangleIndex, hitDistance, rayDir, shadingPoint,
360
+ lightBuffer, emissiveVec4Offset, reverseMapVec4Offset, triangleBuffer,
361
+ ] ) => {
362
+
363
+ const result = float( 0.0 ).toVar();
364
+
365
+ const triIdx = int( triangleIndex ).toVar();
366
+
367
+ // Fetch this triangle's bit-trail (4 packed per vec4). -1 ⇒ not in the BVH (shouldn't happen on
368
+ // an emissive hit) → leave pdf 0 (MIS falls back to BSDF-only).
369
+ const packed = lightBuffer.element( reverseMapVec4Offset.add( triIdx.shiftRight( int( 2 ) ) ) );
370
+ const lane = triIdx.bitAnd( int( 3 ) );
371
+ const trailF = select( lane.equal( int( 0 ) ), packed.x,
372
+ select( lane.equal( int( 1 ) ), packed.y,
373
+ select( lane.equal( int( 2 ) ), packed.z, packed.w ) ) ).toVar();
374
+
375
+ If( trailF.greaterThanEqual( float( 0.0 ) ), () => {
376
+
377
+ const trail = int( trailF ).toVar();
378
+ const selectionPdf = float( 1.0 ).toVar();
379
+ const nodeIndex = int( 0 ).toVar();
380
+ const depth = int( 0 ).toVar();
381
+ const foundLeaf = tslBool( false ).toVar();
382
+
383
+ Loop( MAX_LBVH_DEPTH, () => {
384
+
385
+ const base = nodeIndex.mul( int( LBVH_STRIDE ) );
386
+ const d1 = lightBuffer.element( base.add( int( 1 ) ) ); // [max, isLeaf]
387
+
388
+ If( d1.w.greaterThan( 0.5 ), () => {
389
+
390
+ foundLeaf.assign( tslBool( true ) );
391
+ Break();
392
+
393
+ } );
394
+
395
+ const d2 = lightBuffer.element( base.add( int( 2 ) ) ); // [left, right]
396
+ const leftChildIdx = int( d2.x );
397
+ const rightChildIdx = int( d2.y );
398
+
399
+ const lBase = leftChildIdx.mul( int( LBVH_STRIDE ) );
400
+ const ld0 = lightBuffer.element( lBase );
401
+ const ld1 = lightBuffer.element( lBase.add( int( 1 ) ) );
402
+ const ld3 = lightBuffer.element( lBase.add( int( 3 ) ) );
403
+ const rBase = rightChildIdx.mul( int( LBVH_STRIDE ) );
404
+ const rd0 = lightBuffer.element( rBase );
405
+ const rd1 = lightBuffer.element( rBase.add( int( 1 ) ) );
406
+ const rd3 = lightBuffer.element( rBase.add( int( 3 ) ) );
407
+
408
+ const lImp = lbvhNodeImportance( ld0.xyz, ld0.w, ld1.xyz, ld3.xyz, ld3.w, shadingPoint );
409
+ const rImp = lbvhNodeImportance( rd0.xyz, rd0.w, rd1.xyz, rd3.xyz, rd3.w, shadingPoint );
410
+ const totalImp = lImp.add( rImp );
411
+
412
+ // Trail bit at this depth: 0 → left, 1 → right. Mirror the descent's choice logic EXACTLY.
413
+ const goRight = trail.shiftRight( depth ).bitAnd( int( 1 ) ).equal( int( 1 ) );
414
+
415
+ If( totalImp.lessThanEqual( float( 0.0 ) ), () => {
416
+
417
+ // Descent forces left with no pdf update; if the trail goes right it is unreachable.
418
+ If( goRight, () => {
419
+
420
+ selectionPdf.assign( float( 0.0 ) );
421
+ nodeIndex.assign( rightChildIdx );
422
+
423
+ } ).Else( () => {
424
+
425
+ nodeIndex.assign( leftChildIdx );
426
+
427
+ } );
428
+
429
+ } ).Else( () => {
430
+
431
+ const pLeft = lImp.div( totalImp );
432
+ If( goRight, () => {
433
+
434
+ selectionPdf.mulAssign( float( 1.0 ).sub( pLeft ) );
435
+ nodeIndex.assign( rightChildIdx );
436
+
437
+ } ).Else( () => {
438
+
439
+ selectionPdf.mulAssign( pLeft );
440
+ nodeIndex.assign( leftChildIdx );
441
+
442
+ } );
443
+
444
+ } );
445
+
446
+ depth.addAssign( int( 1 ) );
447
+
448
+ } );
449
+
450
+ If( foundLeaf.and( selectionPdf.greaterThan( float( 0.0 ) ) ), () => {
451
+
452
+ // Leaf: power-weighted within-leaf selection prob for the target triangle (matches the sampler).
453
+ const base = nodeIndex.mul( int( LBVH_STRIDE ) );
454
+ const d0 = lightBuffer.element( base );
455
+ const d2 = lightBuffer.element( base.add( int( 2 ) ) );
456
+ const emissiveStart = int( d2.x );
457
+ const emissiveCount = int( d2.y );
458
+ const leafTotalPower = max( d0.w, float( 1e-10 ) );
459
+
460
+ const targetPower = float( 0.0 ).toVar();
461
+ Loop( { start: int( 0 ), end: emissiveCount }, ( { i } ) => {
462
+
463
+ const entryIdx = emissiveStart.add( i );
464
+ const emData0 = lightBuffer.element( emissiveVec4Offset.add( entryIdx.mul( int( EMISSIVE_STRIDE ) ) ) );
465
+ If( int( emData0.r ).equal( triIdx ), () => {
466
+
467
+ targetPower.assign( max( emData0.g, float( 0.0 ) ) );
468
+ Break();
469
+
470
+ } );
471
+
472
+ } );
473
+
474
+ selectionPdf.mulAssign( targetPower.div( leafTotalPower ) );
475
+
476
+ // Convert selection pdf → solid-angle measure using the SAME heuristic as the sampler.
477
+ const triData = TriangleData.wrap( fetchTriangleData( triIdx, triangleBuffer ) );
478
+ If( useSphericalSampling( triData.v0, triData.v1, triData.v2, shadingPoint ), () => {
479
+
480
+ const solidAngle = sphericalTriangleSolidAngle( triData.v0, triData.v1, triData.v2, shadingPoint );
481
+ result.assign( selectionPdf.div( max( solidAngle, float( 1e-10 ) ) ) );
482
+
483
+ } ).Else( () => {
484
+
485
+ const geoNormal = normalize( cross( triData.v1.sub( triData.v0 ), triData.v2.sub( triData.v0 ) ) );
486
+ const cosLight = max( dot( rayDir.negate(), geoNormal ), float( 0.001 ) );
487
+ const area = triangleArea( triData.v0, triData.v1, triData.v2 );
488
+ const distSq = hitDistance.mul( hitDistance );
489
+ const pdfArea = selectionPdf.div( max( area, float( 1e-10 ) ) );
490
+ result.assign( pdfArea.mul( distSq ).div( cosLight ) );
491
+
492
+ } );
493
+
494
+ } );
495
+
496
+ } );
497
+
498
+ return max( result, MIN_PDF );
499
+
500
+ } );
@@ -29,7 +29,7 @@ import { getImportanceSamplingInfo } from './MaterialProperties.js';
29
29
  import { sampleClearcoat, ClearcoatResult } from './Clearcoat.js';
30
30
  import { refineDisplacedIntersection, DisplacementResult } from './Displacement.js';
31
31
  import { calculateEmissiveTriangleContribution, calculateEmissiveLightPdf, EmissiveSample } from './EmissiveSampling.js';
32
- import { sampleLightBVHTriangle } from './LightBVHSampling.js';
32
+ import { sampleLightBVHTriangle, calculateLightBVHPdf } from './LightBVHSampling.js';
33
33
  import {
34
34
  Ray,
35
35
  HitInfo,
@@ -85,7 +85,7 @@ export function buildShadeKernel( params ) {
85
85
  fireflyThreshold, frame, resolution,
86
86
  emissiveTriangleCount, emissiveVec4Offset, emissiveTotalPower,
87
87
  emissiveBoost, totalTriangleCount, enableEmissiveTriangleSampling,
88
- lightBVHNodeCount,
88
+ lightBVHNodeCount, reverseMapVec4Offset,
89
89
  maxRayCount,
90
90
  } = params;
91
91
 
@@ -560,10 +560,25 @@ export function buildShadeKernel( params ) {
560
560
  const prevBouncePdf = readRayPdf( rayBufferRW, rayID );
561
561
  If( prevBouncePdf.greaterThan( 0.0 ), () => {
562
562
 
563
- const lightPdf = calculateEmissiveLightPdf(
564
- int( hitTriIdx ), hitDist, direction, origin,
565
- triangleBuffer, materialBuffer, emissiveTotalPower,
566
- );
563
+ // MIS partner pdf MUST match the actual NEE sampler: re-walk the Light BVH descent
564
+ // when it is active, else use the flat-CDF pdf. Mismatching them breaks MIS
565
+ // partition-of-unity → a real bias (see calculateLightBVHPdf).
566
+ const lightPdf = float( 0.0 ).toVar();
567
+ If( lightBVHNodeCount.greaterThan( int( 0 ) ), () => {
568
+
569
+ lightPdf.assign( calculateLightBVHPdf(
570
+ int( hitTriIdx ), hitDist, direction, origin,
571
+ lightBuffer, emissiveVec4Offset, reverseMapVec4Offset, triangleBuffer,
572
+ ) );
573
+
574
+ } ).Else( () => {
575
+
576
+ lightPdf.assign( calculateEmissiveLightPdf(
577
+ int( hitTriIdx ), hitDist, direction, origin,
578
+ triangleBuffer, materialBuffer, emissiveTotalPower,
579
+ ) );
580
+
581
+ } );
567
582
  emissiveMISWeight.assign( powerHeuristic( { pdf1: prevBouncePdf, pdf2: lightPdf } ) );
568
583
 
569
584
  } );
@@ -42,6 +42,7 @@ export class CameraManager extends EventDispatcher {
42
42
  this._lastValidFocusDistance = null;
43
43
  this._smoothedFocusDistance = null;
44
44
  this._afPointDirty = false;
45
+ this._afSuspended = false;
45
46
 
46
47
  // Saved state for default camera when switching to model cameras
47
48
  this._defaultCameraState = null;
@@ -273,6 +274,24 @@ export class CameraManager extends EventDispatcher {
273
274
 
274
275
  if ( this.autoFocusMode === 'manual' ) return;
275
276
 
277
+ // Depth-of-field is the only consumer of the auto-focus distance. With DOF
278
+ // off (the default) the per-frame scene raycast is pure waste, so skip it.
279
+ // Re-snap on the frame DOF turns back on so focus is correct immediately
280
+ // rather than racking from a stale smoothed value.
281
+ if ( ! pathTracer?.enableDOF?.value ) {
282
+
283
+ this._afSuspended = true;
284
+ return;
285
+
286
+ }
287
+
288
+ if ( this._afSuspended ) {
289
+
290
+ this._afSuspended = false;
291
+ this._smoothedFocusDistance = null;
292
+
293
+ }
294
+
276
295
  // Lock focus during active tiled final rendering
277
296
  const stage = pathTracer;
278
297
  if ( stage?.isReady
@@ -242,6 +242,10 @@ export class UniformManager {
242
242
  // Offset (in vec4 elements) within the packed light buffer where emissive
243
243
  // triangle data starts. Equals lightBVHNodeCount * LBVH_STRIDE; computed on upload.
244
244
  u( 'emissiveVec4Offset', 0, 'int' );
245
+ // Offset (in vec4 elements) within the packed light buffer where the per-triangle
246
+ // bit-trail map starts (4 trails packed per vec4); computed on upload. Used by the
247
+ // bounce-hit MIS path to re-walk the Light BVH descent pdf.
248
+ u( 'reverseMapVec4Offset', 0, 'int' );
245
249
 
246
250
  // Render mode
247
251
  u( 'renderMode', DEFAULT_STATE.renderMode, 'int' );