rayzee 7.11.0 → 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,
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
 
@@ -32,15 +34,27 @@ export function buildFinalWriteKernel( params ) {
32
34
  resolution, frame,
33
35
  enableAccumulation, hasPreviousAccumulated, accumulationAlpha, cameraIsMoving,
34
36
  transparentBackground,
35
- prevAccumTexture, prevAlbedoTexture,
37
+ prevAccumTexture, prevAlbedoTexture, prevNormalDepthTexture,
36
38
  renderWidth, renderHeight,
37
39
  visMode,
38
40
  // Aux MRT (normalDepth + albedo) feeds only the denoiser/OIDN. Gated by a live uniform (1 = denoiser
39
41
  // on): when off, skip the G-buffer decode, the prev-frame aux mix, and the two aux stores.
40
42
  auxGBufferEnabled,
43
+ // Clean-aux normal (1 = temporally accumulate + renormalize the aux normal). On only for clean-aux
44
+ // OIDN models (calb_cnrm/high, alb_nrm/balanced); off for fast/ASVGF which want the bump normal.
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,
41
51
  } = params;
42
52
 
43
53
  const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
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 ) );
44
58
 
45
59
  const computeFn = Fn( () => {
46
60
 
@@ -56,6 +70,11 @@ export function buildFinalWriteKernel( params ) {
56
70
  const finalColor = sampleColor.xyz.toVar();
57
71
  const outputAlpha = select( transparentBackground, sampleColor.w, float( 1.0 ) ).toVar();
58
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
+
59
78
  // MRT comes from the per-pixel G-buffer (rayID == pixelIndex). Half-packed: decode.
60
79
  // auxOn gates the decode + stores so a no-denoiser frame does no G-buffer read and no aux writes.
61
80
  const finalNormalDepth = vec4( 0.0 ).toVar();
@@ -77,20 +96,103 @@ export function buildFinalWriteKernel( params ) {
77
96
 
78
97
  const prevAccumSample = texture( prevAccumTexture, prevUV, 0 ).toVar();
79
98
 
80
- 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 ) ) );
81
101
  If( auxOn, () => {
82
102
 
83
- // Albedo averages cleanly (it's a colour). The NORMAL must NOT: averaging jittered
84
- // unit normals collapses toward the flat geometric mean (worse at distance), which made
85
- // OIDN smooth bump detail away. Keep this frame's point-sampled normal — it varies with
86
- // the bump, which is exactly what OIDN's edge-stop needs to preserve it.
103
+ // Albedo averages cleanly (it's a colour).
87
104
  finalAlbedo.assign( mix( texture( prevAlbedoTexture, prevUV, 0 ).xyz, finalAlbedo, accumulationAlpha ) );
88
105
 
106
+ // NORMAL: by default keep this frame's POINT-SAMPLED normal — it varies with the bump,
107
+ // which fast/ASVGF want to preserve edge detail. But a CLEAN-AUX OIDN model (calb_cnrm/high,
108
+ // alb_nrm/balanced) trusts the aux and is fed per-frame point-sampled NOISE → leaked noise
109
+ // (high) / over-smoothing (balanced). When cleanAuxNormalOn, temporally accumulate it like
110
+ // the colour. Average the RAW unit normals (decode 0.5+0.5 → [-1,1]) and RENORMALIZE — a
111
+ // plain encoded-space mix would bias toward the flat (0.5,0.5,1) mean and collapse detail.
112
+ // Depth (.w) stays this frame's value; guard the degenerate near-zero mean (opposing normals).
113
+ If( cleanAuxNormalOn, () => {
114
+
115
+ const prevN = texture( prevNormalDepthTexture, prevUV, 0 ).xyz.mul( 2.0 ).sub( 1.0 );
116
+ const curN = finalNormalDepth.xyz.mul( 2.0 ).sub( 1.0 ).toVar();
117
+ const mixedN = mix( prevN, curN, accumulationAlpha ).toVar();
118
+ const len = length( mixedN );
119
+ const avgN = select( len.greaterThan( 1e-4 ), mixedN.div( len ), curN );
120
+ finalNormalDepth.assign( vec4( avgN.mul( 0.5 ).add( 0.5 ), finalNormalDepth.w ) );
121
+
122
+ } );
123
+
89
124
  } );
90
125
 
91
126
  If( transparentBackground, () => {
92
127
 
93
- 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
+ } );
94
196
 
95
197
  } );
96
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
 
@@ -54,7 +54,7 @@ import {
54
54
  readHitDistance, readHitBarycentrics, readHitNormal,
55
55
  readHitMaterialIndex, readHitTriangleIndex,
56
56
  writeRayOriginMeta, writeRayDirFlags, writeRayThroughputPdf, writeRayRadiance,
57
- writeGBuffer, readGBuffer, gbDecodeNormalDepth,
57
+ writeGBuffer, readGBuffer, gbDecodeNormalDepth, gbDecodeAlbedo,
58
58
  readRayRadiance,
59
59
  } from '../Processor/PackedRayBuffer.js';
60
60
 
@@ -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 ), () => {
@@ -320,6 +333,11 @@ export function buildShadeKernel( params ) {
320
333
  const wantBackdrop = isBackdropView.and( showBackground ); // draw env image as backdrop
321
334
  const wantEnvLight = isBackdropView.not().and( enableEnvironmentLight ); // env as light on redirected bounces
322
335
 
336
+ // Fix ①: OIDN clean-aux for a redirected ray that escapes to the environment (e.g. smooth
337
+ // glass over sky). Holds the clamped env colour this ray sees; written into the aux G-buffer
338
+ // before the miss Return below, but only when the pixel still carries the black bounce-0 default.
339
+ const escapedAuxAlbedo = vec3( 0.0 ).toVar();
340
+
323
341
  If( wantBackdrop.or( wantEnvLight ), () => {
324
342
 
325
343
  // Ground projection bends the backdrop-view env lookup onto a projected sphere+disk so the lower
@@ -358,6 +376,10 @@ export function buildShadeKernel( params ) {
358
376
 
359
377
  } );
360
378
 
379
+ // Fix ①: remember the (clamped) env colour this escaped ray sees, so a redirected ray
380
+ // that never captured a surface aux can feed OIDN a meaningful albedo below.
381
+ escapedAuxAlbedo.assign( clamp( envColor, vec3( 0.0 ), vec3( 1.0 ) ) );
382
+
361
383
  // MIS weight for implicit env hit — prevents double-counting with NEE
362
384
  const envMisWeight = float( 1.0 ).toVar();
363
385
  If( isBackdropView.not().and( useEnvMapIS ), () => {
@@ -425,6 +447,25 @@ export function buildShadeKernel( params ) {
425
447
 
426
448
  } );
427
449
 
450
+ // Fix ①: fill the black bounce-0 default aux for a redirected ray that reached the
451
+ // environment without ever locking a surface guide (smooth glass / specular transmission
452
+ // over sky). Only when the stored albedo is still the black default (dot < 1e-4), so a real
453
+ // specular-surface aux written upstream is never clobbered. Normal = the escaped ray
454
+ // direction (varies per pixel → OIDN sees structure that tracks the refracted view);
455
+ // albedo = the clamped env colour it sees; depth kept at the primary hit.
456
+ If( auxOn
457
+ .and( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ) )
458
+ .and( flags.bitAnd( uint( RAY_FLAG.REDIRECTED ) ).notEqual( uint( 0 ) ) ), () => {
459
+
460
+ const gPrev = readGBuffer( gBufferRW, pixelIndex );
461
+ If( dot( gbDecodeAlbedo( gPrev ), vec3( 1.0 ) ).lessThan( float( 1e-4 ) ), () => {
462
+
463
+ writeGBuffer( gBufferRW, pixelIndex, normalize( direction ), gbDecodeNormalDepth( gPrev ).w, escapedAuxAlbedo );
464
+
465
+ } );
466
+
467
+ } );
468
+
428
469
  writeRayRadiance( rayBufferRW, rayID, currentRadiance );
429
470
  writeRayDirFlags( rayBufferRW, rayID, direction, flags.bitAnd( uint( ~ RAY_FLAG.ACTIVE ) ) );
430
471
  Return();
@@ -607,7 +648,23 @@ export function buildShadeKernel( params ) {
607
648
  // (aux-extend) and may extend through specular surfaces (gap #9). Glass rays Return at the transparency
608
649
  // block before that capture, so a glass-then-escape pixel keeps this default aux — megakernel parity
609
650
  // (objectNormal/objectColor stay at their init for transmissive-then-miss).
610
- writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
651
+ //
652
+ // Fix ③: BLEND (semi-transparent) glass instead LOCKS its OWN normal + albedo here. Its per-frame
653
+ // stochastic alpha-skip-vs-opaque-shade outcome (MaterialTransmission.js:522-540) otherwise writes a
654
+ // DIFFERENT surface into the aux each frame (wall-behind on skip vs glass on opaque); with the normal
655
+ // not temporally averaged that becomes the speckle OIDN smears on — the visible artefact on
656
+ // alpha-transparent glass. A deterministic glass-surface guide every frame removes it. MASK / opaque
657
+ // materials keep the default + rely on the downstream aux-extend.
658
+ If( material.alphaMode.equal( int( 2 ) ), () => {
659
+
660
+ writeGBuffer( gBufferRW, pixelIndex, N, linearDepth, albedo.xyz );
661
+ flags.assign( flags.bitOr( uint( RAY_FLAG.AUX_LOCKED ) ) );
662
+
663
+ } ).Else( () => {
664
+
665
+ writeGBuffer( gBufferRW, pixelIndex, vec3( 0.0, 0.0, 1.0 ), linearDepth, vec3( 0.0 ) );
666
+
667
+ } );
611
668
 
612
669
  } );
613
670
 
@@ -744,6 +801,26 @@ export function buildShadeKernel( params ) {
744
801
 
745
802
  throughput.mulAssign( interaction.throughput );
746
803
 
804
+ // Fix ②: OIDN clean-aux for STOCHASTIC glass (rough / frosted / dispersive), whose refracted
805
+ // ray hits a different behind-surface every frame. Extending aux through it (the aux-extend on
806
+ // the opaque-hit path) captures that per-frame surface — albedo then temporally averaged, normal
807
+ // point-sampled — an inconsistent guide OIDN smears on. Instead lock the glass surface's OWN
808
+ // normal + tint here (deterministic, spatially coherent). Smooth non-dispersive glass is left
809
+ // alone: its refraction is a per-pixel delta, so the behind-surface aux captured downstream is
810
+ // already stable. Gated to a genuine dielectric event (not alpha-cutout passthrough or SSS).
811
+ If( auxOn
812
+ .and( flags.bitAnd( uint( RAY_FLAG.AUX_LOCKED ) ).equal( uint( 0 ) ) )
813
+ .and( interaction.isTransmissive )
814
+ .and( interaction.isAlphaSkip.not() )
815
+ .and( interaction.isSubsurface.not() )
816
+ .and( material.roughness.greaterThan( 0.05 ).or( material.dispersion.greaterThan( 0.0 ) ) ), () => {
817
+
818
+ const primaryDepth = gbDecodeNormalDepth( readGBuffer( gBufferRW, pixelIndex ) ).w;
819
+ writeGBuffer( gBufferRW, pixelIndex, N, primaryDepth, albedo.xyz );
820
+ flags.assign( flags.bitOr( uint( RAY_FLAG.AUX_LOCKED ) ) );
821
+
822
+ } );
823
+
747
824
  // reflection stays on same side, transmission pushes through
748
825
  const reflectOffsetDir = select( interaction.entering, N, N.negate() );
749
826
  const offsetDir = select( interaction.didReflect, reflectOffsetDir, direction );
@@ -368,6 +368,10 @@ export class DenoisingManager extends EventDispatcher {
368
368
  // MRT read-targets directly). When none are active the wavefront skips those writes entirely.
369
369
  s.pathTracer?.setAuxGBufferEnabled?.( normalNeeded || !! this.denoiser?.enabled );
370
370
 
371
+ // Clean-aux normal: temporally accumulate the aux normal only for OIDN clean-aux models
372
+ // (balanced/high). 'fast' and the real-time denoisers keep the point-sampled bump normal.
373
+ s.pathTracer?.setCleanAuxNormal?.( !! this.denoiser?.enabled && this.denoiser?.quality !== 'fast' );
374
+
371
375
  // Reclaim VRAM: free the big 2048² StorageTextures of any denoiser/G-buffer stage that ended up
372
376
  // disabled (lazily re-created on the next dispatch after re-enable). Every strategy/denoiser
373
377
  // toggle funnels through here after the enabled flags above are settled, so this is the one
@@ -610,6 +614,8 @@ export class DenoisingManager extends EventDispatcher {
610
614
  setOIDNQuality( quality ) {
611
615
 
612
616
  this.denoiser?.updateQuality( quality );
617
+ // Clean-aux normal follows the model: balanced/high (clean-aux models) → accumulate; fast → keep bump.
618
+ this._stages.pathTracer?.setCleanAuxNormal?.( !! this.denoiser?.enabled && quality !== 'fast' );
613
619
 
614
620
  }
615
621
 
@@ -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' );