rayzee 7.11.1 → 7.12.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.
@@ -4,13 +4,15 @@
4
4
 
5
5
  import {
6
6
  Fn, wgslFn, float, vec2, vec4, int, uint, uvec2,
7
- If, mix, select, texture, textureStore, length,
7
+ If, mix, select, texture, textureStore, length, atomicAdd,
8
8
  localId, workgroupId,
9
9
  } from 'three/tsl';
10
10
 
11
11
  import {
12
12
  readRayRadiance, readGBuffer, gbDecodeNormalDepth, gbDecodeAlbedo,
13
13
  } from '../Processor/PackedRayBuffer.js';
14
+ import { luminance } from './Common.js';
15
+ import { COUNTER } from '../Processor/QueueManager.js';
14
16
 
15
17
  const WG_SIZE = 16;
16
18
 
@@ -41,10 +43,18 @@ export function buildFinalWriteKernel( params ) {
41
43
  // Clean-aux normal (1 = temporally accumulate + renormalize the aux normal). On only for clean-aux
42
44
  // OIDN models (calb_cnrm/high, alb_nrm/balanced); off for fast/ASVGF which want the bump normal.
43
45
  cleanAuxNormalEnabled,
46
+ // Tier-1 convergence early-stop: per-pixel Welford luminance second moment + converged-pixel counter.
47
+ counters, m2BufferRW, useAdaptiveSampling, noiseThreshold, darkNoiseFloor, adaptiveMinSamples,
48
+ // Tier-2 per-pixel freeze: streakBufferRW = per-pixel freeze-candidate streak (stamped here);
49
+ // frozenMaskRO = dilated frozen mask from buildActivePixels (pass-through gates on it, not streak).
50
+ usePixelFreeze, pixelFreezeThreshold, pixelFreezeStability, streakBufferRW, frozenMaskRO,
44
51
  } = params;
45
52
 
46
53
  const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
47
54
  const cleanAuxNormalOn = cleanAuxNormalEnabled.greaterThan( uint( 0 ) );
55
+ // useAdaptiveSampling is registered via UniformManager.ub() → an int 0/1 uniform, so compare against int(0).
56
+ const convOn = useAdaptiveSampling.greaterThan( int( 0 ) );
57
+ const adaptiveOn = usePixelFreeze.greaterThan( int( 0 ) );
48
58
 
49
59
  const computeFn = Fn( () => {
50
60
 
@@ -60,6 +70,11 @@ export function buildFinalWriteKernel( params ) {
60
70
  const finalColor = sampleColor.xyz.toVar();
61
71
  const outputAlpha = select( transparentBackground, sampleColor.w, float( 1.0 ) ).toVar();
62
72
 
73
+ // A pixel frozen in a prior frame was skipped by Generate, so its rayBuffer sample is stale — pass
74
+ // the accumulated colour through unchanged (below) instead of mixing it. Inert unless adaptiveOn.
75
+ const wasFrozen = adaptiveOn.and( frame.greaterThan( uint( 0 ) ) )
76
+ .and( frozenMaskRO.element( rayID ).equal( uint( 1 ) ) ).toVar();
77
+
63
78
  // MRT comes from the per-pixel G-buffer (rayID == pixelIndex). Half-packed: decode.
64
79
  // auxOn gates the decode + stores so a no-denoiser frame does no G-buffer read and no aux writes.
65
80
  const finalNormalDepth = vec4( 0.0 ).toVar();
@@ -81,7 +96,8 @@ export function buildFinalWriteKernel( params ) {
81
96
 
82
97
  const prevAccumSample = texture( prevAccumTexture, prevUV, 0 ).toVar();
83
98
 
84
- finalColor.assign( mix( prevAccumSample.xyz, sampleColor.xyz, accumulationAlpha ) );
99
+ // Frozen pixels pass prev colour through unchanged (stale sample); active pixels accumulate.
100
+ finalColor.assign( select( wasFrozen, prevAccumSample.xyz, mix( prevAccumSample.xyz, sampleColor.xyz, accumulationAlpha ) ) );
85
101
  If( auxOn, () => {
86
102
 
87
103
  // Albedo averages cleanly (it's a colour).
@@ -109,7 +125,74 @@ export function buildFinalWriteKernel( params ) {
109
125
 
110
126
  If( transparentBackground, () => {
111
127
 
112
- outputAlpha.assign( mix( prevAccumSample.w, sampleColor.w, accumulationAlpha ) );
128
+ outputAlpha.assign( select( wasFrozen, prevAccumSample.w, mix( prevAccumSample.w, sampleColor.w, accumulationAlpha ) ) );
129
+
130
+ } );
131
+
132
+ } );
133
+
134
+ // Tier-1 convergence: per-pixel running second moment of LUMINANCE (Welford). luminance() is linear,
135
+ // so luminance(running-mean color) == running-mean luminance == E[L]; m2 tracks E[L²] under the SAME
136
+ // global 1/(frame+1) alpha (NO per-pixel alpha). sampleVar = E[L²]-E[L]²; varOfMean = sampleVar/(N);
137
+ // relErr = SE(mean)/mean. A pixel counts as converged once frame>=minSamples AND relErr<threshold.
138
+ // The m2 write runs every frame (incl. frame 0, where alpha==1 self-inits it → no explicit clear).
139
+ // Sits AFTER the accumulation mix (finalColor is the mean) and BEFORE the visMode-11 mutation.
140
+ If( convOn, () => {
141
+
142
+ const sampleLum = luminance( sampleColor.xyz );
143
+ const prevM2 = m2BufferRW.element( rayID ).toVar();
144
+ const m2 = mix( prevM2, sampleLum.mul( sampleLum ), accumulationAlpha ).toVar();
145
+ m2BufferRW.element( rayID ).assign( m2 );
146
+
147
+ const meanLum = luminance( finalColor ).toVar();
148
+ const sampleVar = m2.sub( meanLum.mul( meanLum ) ).max( float( 0 ) );
149
+ const varOfMean = sampleVar.div( float( frame ).add( 1.0 ) );
150
+ // absSE = absolute standard error of the mean luminance; relErr = its ratio to the mean.
151
+ // Combined criterion: bright pixels converge on relErr<threshold; dark/dim pixels (where relErr
152
+ // stays high forever) converge on absSE<absFloor, since their absolute noise is imperceptible.
153
+ const absSE = varOfMean.sqrt().toVar();
154
+ const relErr = absSE.div( meanLum.add( float( 1e-4 ) ) );
155
+
156
+ If( frame.greaterThanEqual( uint( adaptiveMinSamples ) ).and(
157
+ relErr.lessThan( noiseThreshold ).or( absSE.lessThan( darkNoiseFloor ) )
158
+ ), () => {
159
+
160
+ atomicAdd( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 1 ) );
161
+
162
+ } );
163
+
164
+ // Maintain the freeze streak on the relErr-only predicate (NO absFloor — it bakes dim regions dark
165
+ // when it permanently freezes a pixel). Monotonic: streak>=K freezes for the run, so global alpha stays exact.
166
+ If( adaptiveOn, () => {
167
+
168
+ If( frame.equal( uint( 0 ) ), () => {
169
+
170
+ // Frame 0 (post-reset/camera-move) clears the frozen state so each run re-freezes fresh.
171
+ streakBufferRW.element( rayID ).assign( uint( 0 ) );
172
+
173
+ } );
174
+
175
+ If( wasFrozen, () => {
176
+
177
+ // Already frozen (passed through above) — count it.
178
+ atomicAdd( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 1 ) );
179
+
180
+ } );
181
+
182
+ If( wasFrozen.not().and( frame.greaterThan( uint( 0 ) ) ), () => {
183
+
184
+ const freezeCandidate = frame.greaterThanEqual( uint( adaptiveMinSamples ) )
185
+ .and( relErr.lessThan( pixelFreezeThreshold ) );
186
+ const newStreak = select( freezeCandidate, streakBufferRW.element( rayID ).add( uint( 1 ) ), uint( 0 ) ).toVar();
187
+ streakBufferRW.element( rayID ).assign( newStreak );
188
+ // Just reached K → frozen next frame; count it now so the frozen population is current.
189
+ If( newStreak.greaterThanEqual( uint( pixelFreezeStability ) ), () => {
190
+
191
+ atomicAdd( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 1 ) );
192
+
193
+ } );
194
+
195
+ } );
113
196
 
114
197
  } );
115
198
 
@@ -1,10 +1,12 @@
1
1
  /**
2
- * GenerateKernel.js — wavefront primary ray generation (16×16, 2D screen-space dispatch).
2
+ * GenerateKernel.js — wavefront primary ray generation.
3
+ * Two dispatch modes from one builder: 2D screen-space (default) and 1D list-driven over the active-pixel
4
+ * list (Tier-2 per-pixel freeze).
3
5
  */
4
6
 
5
7
  import {
6
8
  Fn, float, vec2, vec3, vec4, int, uint,
7
- If, select,
9
+ If, select, instanceIndex, atomicLoad,
8
10
  localId, workgroupId,
9
11
  } from 'three/tsl';
10
12
 
@@ -16,7 +18,7 @@ import {
16
18
 
17
19
  import { generateRayFromCamera } from './BVHTraversal.js';
18
20
  import { Ray } from './Struct.js';
19
- import { RAY_FLAG } from '../Processor/QueueManager.js';
21
+ import { RAY_FLAG, COUNTER } from '../Processor/QueueManager.js';
20
22
  import {
21
23
  writeRayOriginMeta, writeRayDirFlags, writeRayThroughputPdf,
22
24
  writeRayRadiance, writeGBuffer,
@@ -36,68 +38,94 @@ export function buildGenerateKernel( params ) {
36
38
  transmissiveBounces, // per-ray refraction budget (megakernel parity: PathTracerCore.js:606)
37
39
  transparentBackground, // alpha inits to 1 here (megakernel parity: PathTracerCore.js:554) — env-escape-without-opaque zeroes it in Shade
38
40
  auxGBufferEnabled, // live uniform: 1 = init the per-pixel G-buffer (denoiser on), 0 = skip it
41
+ // listDriven: 1D dispatch over the active-pixel list (activeIndicesRO[tid] = pixelID) instead of 2D.
42
+ listDriven = false, activeIndicesRO = null, counters = null,
39
43
  } = params;
40
44
 
41
45
  const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
42
46
 
43
- const computeFn = Fn( () => {
47
+ // Shared ray-gen body for a pixel (gx, gy) identical in both dispatch modes.
48
+ const emitRay = ( gx, gy ) => {
49
+
50
+ const pixelCoord = vec2( float( gx ).add( 0.5 ), float( gy ).add( 0.5 ) );
51
+ const pixelIndex = gy.mul( int( resolution.x ) ).add( gx );
52
+ // One ray per pixel: rayID is the pixel index.
53
+ const rayID = uint( pixelIndex );
54
+
55
+ const screenPosition = pixelCoord.div( resolution ).mul( 2.0 ).sub( 1.0 ).toVar();
56
+ screenPosition.y.assign( screenPosition.y.negate() );
57
+
58
+ const baseSeed = getDecorrelatedSeed( { pixelCoord, rayIndex: int( 0 ), frame } ).toVar();
59
+ const seed = pcgHash( { state: baseSeed } ).toVar();
60
+
61
+ // Sample index 1 (not 0) so the AA sub-pixel jitter draws a DIFFERENT STBN cell
62
+ // than the first-bounce BSDF sample (ShadeKernel uses sampleIndex 0). Every bounce
63
+ // samples at index 0, so index 1 is collision-free — this decorrelates the sub-pixel
64
+ // position from the first scatter direction (they were reading the identical cell).
65
+ const stratifiedJitter = getStratifiedSample( pixelCoord, int( 1 ), int( 1 ), seed, resolution, frame ).toVar();
66
+
67
+ const jitterScale = vec2( 2.0 ).div( resolution );
68
+ const jitter = stratifiedJitter.sub( 0.5 ).mul( jitterScale );
69
+ const jitteredScreenPosition = screenPosition.add( jitter );
70
+
71
+ const ray = Ray.wrap( generateRayFromCamera(
72
+ jitteredScreenPosition, seed,
73
+ cameraWorldMatrix, cameraProjectionMatrixInverse,
74
+ enableDOF, focalLength, aperture, focusDistance, sceneScale, apertureScale, anamorphicRatio,
75
+ ) );
76
+
77
+ writeRayOriginMeta( rayBufferRW, rayID, ray.origin, int( 0 ), int( 0 ) );
78
+ // A fresh camera ray is NOT redirected — REDIRECTED stays clear so it sees the direct backdrop;
79
+ // ShadeKernel sets REDIRECTED on the first direction-changing interaction (see RAY_FLAG.REDIRECTED).
80
+ writeRayDirFlags( rayBufferRW, rayID, ray.direction, uint( RAY_FLAG.ACTIVE ) );
81
+ // pdf inits to 0 = prevBouncePdf (megakernel parity PathTracerCore.js:556). The bounce>0 env/emissive
82
+ // MIS gate skips until an opaque scatter writes a real combinedPdf; free bounces preserve it.
83
+ writeRayThroughputPdf( rayBufferRW, rayID, vec4( 1.0, 1.0, 1.0, 0.0 ).xyz, float( 0.0 ) );
84
+ // Alpha inits to 1 in transparent-bg mode (megakernel parity: PathTracerCore.js:554). Shade zeroes
85
+ // it only on env-escape-without-opaque; a ray that dies inside geometry (e.g. SSS walk termination)
86
+ // keeps alpha 1 → solid. Non-transparent mode is inert (FinalWrite forces alpha 1).
87
+ writeRayRadiance( rayBufferRW, rayID, vec4( vec3( 0.0 ), select( transparentBackground, float( 1.0 ), float( 0.0 ) ) ) );
88
+
89
+ If( auxOn, () => {
90
+
91
+ // default: normal +Z, depth 1 (far), black albedo (background/miss)
92
+ writeGBuffer( gBufferRW, uint( pixelIndex ), vec3( 0.0, 0.0, 1.0 ), float( 1.0 ), vec3( 0.0 ) );
44
93
 
45
- const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
46
- const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
94
+ } );
47
95
 
48
- If( gx.lessThan( renderWidth ).and( gy.lessThan( renderHeight ) ), () => {
96
+ writeMediumStack( rayBufferRW, rayID, uint( 0 ), uint( transmissiveBounces ), float( 1.0 ), float( 1.0 ), float( 1.0 ) );
49
97
 
50
- const pixelCoord = vec2( float( gx ).add( 0.5 ), float( gy ).add( 0.5 ) );
51
- const pixelIndex = gy.mul( int( resolution.x ) ).add( gx );
52
- // One ray per pixel: rayID is the pixel index.
53
- const rayID = uint( pixelIndex );
98
+ rngBufferRW.element( rayID ).assign( seed );
54
99
 
55
- const screenPosition = pixelCoord.div( resolution ).mul( 2.0 ).sub( 1.0 ).toVar();
56
- screenPosition.y.assign( screenPosition.y.negate() );
100
+ };
57
101
 
58
- const baseSeed = getDecorrelatedSeed( { pixelCoord, rayIndex: int( 0 ), frame } ).toVar();
59
- const seed = pcgHash( { state: baseSeed } ).toVar();
102
+ const computeFn = Fn( () => {
60
103
 
61
- // Sample index 1 (not 0) so the AA sub-pixel jitter draws a DIFFERENT STBN cell
62
- // than the first-bounce BSDF sample (ShadeKernel uses sampleIndex 0). Every bounce
63
- // samples at index 0, so index 1 is collision-free — this decorrelates the sub-pixel
64
- // position from the first scatter direction (they were reading the identical cell).
65
- const stratifiedJitter = getStratifiedSample( pixelCoord, int( 1 ), int( 1 ), seed, resolution, frame ).toVar();
104
+ if ( listDriven ) {
66
105
 
67
- const jitterScale = vec2( 2.0 ).div( resolution );
68
- const jitter = stratifiedJitter.sub( 0.5 ).mul( jitterScale );
69
- const jitteredScreenPosition = screenPosition.add( jitter );
106
+ const tid = instanceIndex;
107
+ // Grid is over-sized from a stale readback; bound on the live ENTERING_COUNT so extra threads no-op.
108
+ If( tid.lessThan( atomicLoad( counters.element( uint( COUNTER.ENTERING_COUNT ) ) ) ), () => {
70
109
 
71
- const ray = Ray.wrap( generateRayFromCamera(
72
- jitteredScreenPosition, seed,
73
- cameraWorldMatrix, cameraProjectionMatrixInverse,
74
- enableDOF, focalLength, aperture, focusDistance, sceneScale, apertureScale, anamorphicRatio,
75
- ) );
110
+ const pixelIndex = int( activeIndicesRO.element( tid ) );
111
+ const gx = pixelIndex.mod( int( resolution.x ) );
112
+ const gy = pixelIndex.div( int( resolution.x ) );
113
+ emitRay( gx, gy );
76
114
 
77
- writeRayOriginMeta( rayBufferRW, rayID, ray.origin, int( 0 ), int( 0 ) );
78
- // A fresh camera ray is NOT redirected — REDIRECTED stays clear so it sees the direct backdrop;
79
- // ShadeKernel sets REDIRECTED on the first direction-changing interaction (see RAY_FLAG.REDIRECTED).
80
- writeRayDirFlags( rayBufferRW, rayID, ray.direction, uint( RAY_FLAG.ACTIVE ) );
81
- // pdf inits to 0 = prevBouncePdf (megakernel parity PathTracerCore.js:556). The bounce>0 env/emissive
82
- // MIS gate skips until an opaque scatter writes a real combinedPdf; free bounces preserve it.
83
- writeRayThroughputPdf( rayBufferRW, rayID, vec4( 1.0, 1.0, 1.0, 0.0 ).xyz, float( 0.0 ) );
84
- // Alpha inits to 1 in transparent-bg mode (megakernel parity: PathTracerCore.js:554). Shade zeroes
85
- // it only on env-escape-without-opaque; a ray that dies inside geometry (e.g. SSS walk termination)
86
- // keeps alpha 1 → solid. Non-transparent mode is inert (FinalWrite forces alpha 1).
87
- writeRayRadiance( rayBufferRW, rayID, vec4( vec3( 0.0 ), select( transparentBackground, float( 1.0 ), float( 0.0 ) ) ) );
115
+ } );
88
116
 
89
- If( auxOn, () => {
117
+ } else {
90
118
 
91
- // default: normal +Z, depth 1 (far), black albedo (background/miss)
92
- writeGBuffer( gBufferRW, uint( pixelIndex ), vec3( 0.0, 0.0, 1.0 ), float( 1.0 ), vec3( 0.0 ) );
119
+ const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
120
+ const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
93
121
 
94
- } );
122
+ If( gx.lessThan( renderWidth ).and( gy.lessThan( renderHeight ) ), () => {
95
123
 
96
- writeMediumStack( rayBufferRW, rayID, uint( 0 ), uint( transmissiveBounces ), float( 1.0 ), float( 1.0 ), float( 1.0 ) );
124
+ emitRay( gx, gy );
97
125
 
98
- rngBufferRW.element( rayID ).assign( seed );
126
+ } );
99
127
 
100
- } );
128
+ }
101
129
 
102
130
  } );
103
131
 
@@ -10,7 +10,7 @@ import {
10
10
  If, Loop, normalize, max, exp, log, clamp, dot, length, select,
11
11
  instanceIndex,
12
12
  sampler,
13
- atomicAdd, atomicLoad, uintBitsToFloat,
13
+ atomicAdd, atomicLoad, atomicStore, uintBitsToFloat,
14
14
  Return,
15
15
  } from 'three/tsl';
16
16
 
@@ -137,6 +137,19 @@ export function buildShadeKernel( params ) {
137
137
 
138
138
  const threadIdx = instanceIndex;
139
139
 
140
+ // Folds the former resetActiveCounter 1-thread kernel: thread 0 zeroes the survivor counter
141
+ // before compact re-counts it. Kept ABOVE the ENTERING_COUNT guard so it fires even at bound 0.
142
+ // Safe: shade never touches ACTIVE_RAY_COUNT, and the shade→compact dispatch boundary publishes it.
143
+ if ( counters ) {
144
+
145
+ If( threadIdx.equal( uint( 0 ) ), () => {
146
+
147
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
148
+
149
+ } );
150
+
151
+ }
152
+
140
153
  // bound on ENTERING_COUNT so an over-sized margin dispatch is safe
141
154
  const bound = counters ? atomicLoad( counters.element( uint( COUNTER.ENTERING_COUNT ) ) ) : maxRayCount;
142
155
  If( threadIdx.greaterThanEqual( bound ), () => {
@@ -167,6 +167,21 @@ export class UniformManager {
167
167
  u( 'visMode', DEFAULT_STATE.debugMode, 'int' );
168
168
  u( 'debugVisScale', DEFAULT_STATE.debugVisScale, 'float' );
169
169
 
170
+ // Tier-1 convergence early-stop (FinalWrite reads these; live-toggled, no shader rebuild)
171
+ ub( 'useAdaptiveSampling', DEFAULT_STATE.useAdaptiveSampling );
172
+ u( 'noiseThreshold', DEFAULT_STATE.noiseThreshold, 'float' );
173
+ u( 'darkNoiseFloor', DEFAULT_STATE.darkNoiseFloor, 'float' );
174
+ u( 'adaptiveMinSamples', DEFAULT_STATE.adaptiveMinSamples, 'int' );
175
+ // CPU-only (read in PathTracer._isConvergedComplete, not bound to any kernel); registered here for
176
+ // the settings/configureForMode plumbing + the _defineUniformGetters accessor.
177
+ u( 'adaptiveStopFraction', DEFAULT_STATE.adaptiveStopFraction, 'float' );
178
+
179
+ // Tier-2 per-pixel freeze. usePixelFreeze gates both FinalWrite (stamp + pass-through) and render()'s
180
+ // frozen-compaction dispatch path.
181
+ ub( 'usePixelFreeze', DEFAULT_STATE.usePixelFreeze );
182
+ u( 'pixelFreezeThreshold', DEFAULT_STATE.pixelFreezeThreshold, 'float' );
183
+ u( 'pixelFreezeStability', DEFAULT_STATE.pixelFreezeStability, 'int' );
184
+
170
185
  // Accumulation
171
186
  ub( 'enableAccumulation', true );
172
187
  u( 'accumulationAlpha', 0.0, 'float' );