rayzee 7.20.3 → 7.21.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/dist/rayzee.es.js +2621 -2683
- package/dist/rayzee.umd.js +60 -58
- package/package.json +1 -1
- package/src/EngineDefaults.js +3 -2
- package/src/PathTracerApp.js +7 -0
- package/src/Pipeline/BindingAudit.js +178 -0
- package/src/Pipeline/RenderPipeline.js +5 -2
- package/src/Processor/CameraOptimizer.js +0 -5
- package/src/Processor/PackedRayBuffer.js +33 -8
- package/src/Stages/ASVGF.js +10 -10
- package/src/Stages/BilateralFilter.js +70 -11
- package/src/Stages/EdgeFilter.js +44 -6
- package/src/Stages/NormalDepth.js +10 -2
- package/src/Stages/PathTracer.js +7 -3
- package/src/Stages/PathTracerStage.js +15 -46
- package/src/TSL/Common.js +1 -47
- package/src/TSL/EmissiveSampling.js +40 -44
- package/src/TSL/LightsCore.js +3 -0
- package/src/TSL/LightsDirect.js +4 -4
- package/src/TSL/LightsSampling.js +138 -327
- package/src/TSL/PathTracerCore.js +2 -2
- package/src/TSL/Random.js +2 -2
- package/src/TSL/ShadeKernel.js +39 -17
- package/src/TSL/Struct.js +0 -10
- package/src/index.js +5 -0
- package/src/managers/EnvironmentManager.js +0 -5
- package/src/managers/UniformManager.js +4 -1
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -21,7 +21,6 @@ export const ENGINE_DEFAULTS = {
|
|
|
21
21
|
enableEnvironment: true,
|
|
22
22
|
showBackground: true,
|
|
23
23
|
transparentBackground: false,
|
|
24
|
-
useImportanceSampledEnvironment: true,
|
|
25
24
|
environmentIntensity: 1,
|
|
26
25
|
backgroundIntensity: 1,
|
|
27
26
|
// Solid backdrop color shown on camera-ray misses in 'color' background mode
|
|
@@ -164,7 +163,9 @@ export const ENGINE_DEFAULTS = {
|
|
|
164
163
|
asvgfPhiDepth: 1.0,
|
|
165
164
|
asvgfVarianceBoost: 1.0,
|
|
166
165
|
asvgfMaxAccumFrames: 32,
|
|
167
|
-
|
|
166
|
+
// Must be > 0: the gradient also rejects fireflies before the EMA smears them across
|
|
167
|
+
// ~1/alpha frames. At 0 the denoiser measures ~3x worse than no denoiser at 1 spp.
|
|
168
|
+
asvgfGradientStrength: 1.0,
|
|
168
169
|
asvgfGradientSigmaScale: 2.0,
|
|
169
170
|
asvgfGradientNoiseFloor: 0.0,
|
|
170
171
|
asvgfDebugMode: 0,
|
package/src/PathTracerApp.js
CHANGED
|
@@ -1562,6 +1562,11 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1562
1562
|
this.cameraManager?.setAutoFocusMode( 'manual' );
|
|
1563
1563
|
if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
|
|
1564
1564
|
|
|
1565
|
+
// The seed axis free-runs across accumulation resets so a camera drag gets fresh
|
|
1566
|
+
// sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
|
|
1567
|
+
// frameCount, so N samples reproduce bit-for-bit. reset() below zeroes the tick.
|
|
1568
|
+
stage._pinSeedToFrame = true;
|
|
1569
|
+
|
|
1565
1570
|
this._deterministic = true;
|
|
1566
1571
|
this._dispatchPinned = pinDispatch;
|
|
1567
1572
|
|
|
@@ -1580,6 +1585,8 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1580
1585
|
|
|
1581
1586
|
}
|
|
1582
1587
|
|
|
1588
|
+
stage._pinSeedToFrame = false;
|
|
1589
|
+
|
|
1583
1590
|
this._deterministicRestore = null;
|
|
1584
1591
|
this._deterministic = false;
|
|
1585
1592
|
this._dispatchPinned = false;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only guard against texture-binding aliasing.
|
|
3
|
+
*
|
|
4
|
+
* Two TextureNodes still holding the default EmptyTexture when a stage's kernel is first
|
|
5
|
+
* compiled can share one GPU binding, which then resolves to whichever is assigned last.
|
|
6
|
+
* Nothing throws — the aliased node reads a real texture, just not its own. BilateralFilter
|
|
7
|
+
* shipped this way, reading ASVGF's history counter as its variance.
|
|
8
|
+
*
|
|
9
|
+
* Rule enforced: every TextureNode a kernel reads must hold its real texture before that
|
|
10
|
+
* kernel is first dispatched. StorageTexture-typed nodes are exempt — `textureLoad` codegen
|
|
11
|
+
* only emits the required `level` parameter while the node still holds EmptyTexture, so those
|
|
12
|
+
* are deliberately bound after compile (see ASVGF.render).
|
|
13
|
+
*
|
|
14
|
+
* Off by default; the bench harness turns it on.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let enabled = false;
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
/** Per-stage audit state. WeakMap so a disposed stage does not pin its entry. */
|
|
21
|
+
const stageState = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {boolean} on
|
|
25
|
+
*/
|
|
26
|
+
export function setBindingAudit( on ) {
|
|
27
|
+
|
|
28
|
+
enabled = !! on;
|
|
29
|
+
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isBindingAuditEnabled() {
|
|
33
|
+
|
|
34
|
+
return enabled;
|
|
35
|
+
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @returns {Array<{stage: string, nodes: string[], unboundAtCompile: string[], message: string}>} */
|
|
39
|
+
export function getBindingAuditFindings() {
|
|
40
|
+
|
|
41
|
+
return findings.slice();
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearBindingAuditFindings() {
|
|
46
|
+
|
|
47
|
+
findings.length = 0;
|
|
48
|
+
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Own enumerable properties holding a TSL TextureNode. */
|
|
52
|
+
function textureNodeKeys( stage ) {
|
|
53
|
+
|
|
54
|
+
const keys = [];
|
|
55
|
+
|
|
56
|
+
for ( const key of Object.keys( stage ) ) {
|
|
57
|
+
|
|
58
|
+
if ( stage[ key ]?.isTextureNode === true ) keys.push( key );
|
|
59
|
+
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return keys;
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// EmptyTexture (the TextureNode default) is a bare `new Texture()`, so its image is null.
|
|
67
|
+
// Every real texture the stages bind — RenderTarget textures and StorageTextures alike —
|
|
68
|
+
// carries an image with a nonzero width.
|
|
69
|
+
const isUnbound = ( texture ) => ! texture || ! texture.image || ! ( texture.image.width > 0 );
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Runs a stage's render with the audit wrapped around it.
|
|
73
|
+
*
|
|
74
|
+
* The snapshot must be taken when the stage first calls `renderer.compute`, not before or
|
|
75
|
+
* after render() — the binding assignments and the compile are interleaved inside that one
|
|
76
|
+
* call, so by the time it returns everything is bound. Sampling after render() was the first
|
|
77
|
+
* version of this guard and it silently caught nothing.
|
|
78
|
+
*
|
|
79
|
+
* A later frame then resolves: a candidate now holding a StorageTexture is the sanctioned
|
|
80
|
+
* pattern; anything else was bound too late.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} stage
|
|
83
|
+
* @param {Object} renderer
|
|
84
|
+
* @param {function(): void} renderFn
|
|
85
|
+
*/
|
|
86
|
+
export function auditStageRender( stage, renderer, renderFn ) {
|
|
87
|
+
|
|
88
|
+
if ( ! enabled ) {
|
|
89
|
+
|
|
90
|
+
renderFn();
|
|
91
|
+
return;
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let state = stageState.get( stage );
|
|
96
|
+
|
|
97
|
+
if ( ! state ) {
|
|
98
|
+
|
|
99
|
+
state = { phase: 'pending', unboundAtCompile: [] };
|
|
100
|
+
stageState.set( stage, state );
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ( state.phase !== 'pending' ) {
|
|
105
|
+
|
|
106
|
+
renderFn();
|
|
107
|
+
if ( state.phase === 'watch' ) resolve( stage, state );
|
|
108
|
+
return;
|
|
109
|
+
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Shadow the prototype method for the duration of this stage's render only.
|
|
113
|
+
const hadOwn = Object.prototype.hasOwnProperty.call( renderer, 'compute' );
|
|
114
|
+
const original = renderer.compute;
|
|
115
|
+
|
|
116
|
+
renderer.compute = function ( ...args ) {
|
|
117
|
+
|
|
118
|
+
if ( state.phase === 'pending' ) {
|
|
119
|
+
|
|
120
|
+
state.unboundAtCompile = textureNodeKeys( stage )
|
|
121
|
+
.filter( ( key ) => isUnbound( stage[ key ].value ) );
|
|
122
|
+
|
|
123
|
+
// A single unbound node has nothing to alias with, so it cannot produce this bug.
|
|
124
|
+
state.phase = state.unboundAtCompile.length < 2 ? 'done' : 'watch';
|
|
125
|
+
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return original.apply( this, args );
|
|
129
|
+
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
|
|
134
|
+
renderFn();
|
|
135
|
+
|
|
136
|
+
} finally {
|
|
137
|
+
|
|
138
|
+
if ( hadOwn ) renderer.compute = original;
|
|
139
|
+
else delete renderer.compute;
|
|
140
|
+
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolve( stage, state ) {
|
|
146
|
+
|
|
147
|
+
const lateBound = [];
|
|
148
|
+
|
|
149
|
+
for ( const key of state.unboundAtCompile ) {
|
|
150
|
+
|
|
151
|
+
const texture = stage[ key ]?.value;
|
|
152
|
+
|
|
153
|
+
// Still unassigned — the stage may bind it on a later frame (or never, which is also
|
|
154
|
+
// not this bug). Leave the audit open rather than guessing.
|
|
155
|
+
if ( isUnbound( texture ) ) return;
|
|
156
|
+
|
|
157
|
+
if ( texture.isStorageTexture !== true ) lateBound.push( key );
|
|
158
|
+
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
state.phase = 'done';
|
|
162
|
+
|
|
163
|
+
if ( lateBound.length === 0 ) return;
|
|
164
|
+
|
|
165
|
+
findings.push( {
|
|
166
|
+
stage: stage.name,
|
|
167
|
+
nodes: lateBound,
|
|
168
|
+
unboundAtCompile: state.unboundAtCompile.slice(),
|
|
169
|
+
message:
|
|
170
|
+
`${stage.name}: ${lateBound.join( ', ' )} ` +
|
|
171
|
+
`${lateBound.length === 1 ? 'was' : 'were'} still holding EmptyTexture when the stage ` +
|
|
172
|
+
`first dispatched, alongside ${state.unboundAtCompile.length - lateBound.length} ` +
|
|
173
|
+
'other unbound node(s), and later received a non-storage texture. Two nodes unbound at ' +
|
|
174
|
+
'compile time can share one GPU binding and then both resolve to whichever is assigned ' +
|
|
175
|
+
'last. Assign these before the first dispatch.',
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PipelineContext } from './PipelineContext.js';
|
|
2
2
|
import { EventDispatcher } from './EventDispatcher.js';
|
|
3
|
+
import { auditStageRender } from './BindingAudit.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* RenderPipeline - Orchestrates execution of pipeline stages
|
|
@@ -183,8 +184,10 @@ export class RenderPipeline {
|
|
|
183
184
|
|
|
184
185
|
const stageStartTime = this.stats.enabled ? performance.now() : 0;
|
|
185
186
|
|
|
186
|
-
//
|
|
187
|
-
|
|
187
|
+
// Pass-through unless setBindingAudit(true) — see BindingAudit.js.
|
|
188
|
+
auditStageRender(
|
|
189
|
+
stage, this.renderer, () => stage.render( this.context, writeBuffer )
|
|
190
|
+
);
|
|
188
191
|
|
|
189
192
|
// Track timing
|
|
190
193
|
if ( this.stats.enabled ) {
|
|
@@ -24,7 +24,6 @@ export class CameraOptimizer {
|
|
|
24
24
|
// Enhanced interaction mode settings for reduced quality during interaction
|
|
25
25
|
this.interactionQualitySettings = {
|
|
26
26
|
maxBounceCount: 1,
|
|
27
|
-
useEnvMapIS: false,
|
|
28
27
|
// pixelRatio: 0.25,
|
|
29
28
|
enableAccumulation: false,
|
|
30
29
|
...settings.qualitySettings
|
|
@@ -303,25 +302,21 @@ export class CameraOptimizer {
|
|
|
303
302
|
const presets = {
|
|
304
303
|
'ultra-low': {
|
|
305
304
|
maxBounceCount: 1,
|
|
306
|
-
useEnvMapIS: false,
|
|
307
305
|
pixelRatio: 0.125,
|
|
308
306
|
enableAccumulation: false
|
|
309
307
|
},
|
|
310
308
|
'low': {
|
|
311
309
|
maxBounceCount: 1,
|
|
312
|
-
useEnvMapIS: false,
|
|
313
310
|
pixelRatio: 0.25,
|
|
314
311
|
enableAccumulation: false
|
|
315
312
|
},
|
|
316
313
|
'medium': {
|
|
317
314
|
maxBounceCount: 2,
|
|
318
|
-
useEnvMapIS: true,
|
|
319
315
|
pixelRatio: 0.5,
|
|
320
316
|
enableAccumulation: false
|
|
321
317
|
},
|
|
322
318
|
'high': {
|
|
323
319
|
maxBounceCount: 3,
|
|
324
|
-
useEnvMapIS: true,
|
|
325
320
|
pixelRatio: 0.75,
|
|
326
321
|
enableAccumulation: true
|
|
327
322
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int, float, clamp,
|
|
8
|
-
packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16,
|
|
8
|
+
packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16, select, floor, log2, exp2, max,
|
|
9
9
|
} from 'three/tsl';
|
|
10
10
|
import { StorageInstancedBufferAttribute } from 'three/webgpu';
|
|
11
11
|
import { createLogger, fmt } from '../utils/Logger.js';
|
|
@@ -186,13 +186,38 @@ export const gbDecodeAlbedo = ( packed ) =>
|
|
|
186
186
|
// .w packs per-ray bounce state: perRayBounces (bits 0-7) | sssSteps (bits 8-15) | transparentCount
|
|
187
187
|
// (bits 16-21), each masked so an overrun can't bleed into its neighbour. pixelIndex is NOT stored —
|
|
188
188
|
// it equals rayID (one ray per pixel).
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
189
|
+
// Bits 22-31 of ORIGIN_META.w: alpha-passthrough distance since the last real scatter (Cycles'
|
|
190
|
+
// mis_ray_t), as a 10-bit float (4e6m, bias 8, ~0.004-500 range, ≤1.6% rel err). The emissive-hit
|
|
191
|
+
// MIS partner pdf must be evaluated at the scatter point that produced prevBouncePdf, not at the
|
|
192
|
+
// last skip surface — feeding it the skip origin collapses dist² and lets full-Le BSDF hits
|
|
193
|
+
// through nearly unweighted (fireflies on emitters inside alpha-blend shades).
|
|
194
|
+
const encodeMisRayT = ( t ) => {
|
|
195
|
+
|
|
196
|
+
const e = clamp( floor( log2( max( t, 1e-6 ) ) ), - 8.0, 7.0 );
|
|
197
|
+
const m = clamp( float( t ).mul( exp2( e.negate() ) ).sub( 1.0 ).mul( 64.0 ), 0.0, 63.0 );
|
|
198
|
+
return select( float( t ).lessThan( 0.004 ), uint( 0 ), uint( e.add( 8.0 ) ).shiftLeft( 6 ).bitOr( uint( m ) ) );
|
|
199
|
+
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const readMisRayT = ( buf, id ) => {
|
|
203
|
+
|
|
204
|
+
const bits = floatBitsToUint( buf.element( soa( id, RAY.ORIGIN_META ) ).w ).shiftRight( 22 );
|
|
205
|
+
const e = float( bits.shiftRight( 6 ).bitAnd( uint( 15 ) ) ).sub( 8.0 );
|
|
206
|
+
const m = float( bits.bitAnd( uint( 63 ) ) ).div( 64.0 ).add( 1.0 );
|
|
207
|
+
return select( bits.equal( uint( 0 ) ), float( 0.0 ), m.mul( exp2( e ) ) );
|
|
208
|
+
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps, transparentCount = uint( 0 ), misRayT = null ) => {
|
|
212
|
+
|
|
213
|
+
let packed = uint( bounces ).bitAnd( uint( 0xFF ) )
|
|
214
|
+
.bitOr( uint( sssSteps ).bitAnd( uint( 0xFF ) ).shiftLeft( 8 ) )
|
|
215
|
+
.bitOr( uint( transparentCount ).bitAnd( uint( 0x3F ) ).shiftLeft( 16 ) );
|
|
216
|
+
if ( misRayT !== null ) packed = packed.bitOr( encodeMisRayT( misRayT ).shiftLeft( 22 ) );
|
|
217
|
+
return buf.element( soa( id, RAY.ORIGIN_META ) )
|
|
218
|
+
.assign( vec4( origin, uintBitsToFloat( packed ) ) );
|
|
219
|
+
|
|
220
|
+
};
|
|
196
221
|
|
|
197
222
|
export const writeRayDirFlags = ( buf, id, direction, bounceFlags ) =>
|
|
198
223
|
buf.element( soa( id, RAY.DIR_FLAGS ) )
|
package/src/Stages/ASVGF.js
CHANGED
|
@@ -40,7 +40,8 @@ export class ASVGF extends RenderStage {
|
|
|
40
40
|
this.renderer = renderer;
|
|
41
41
|
|
|
42
42
|
this.temporalAlpha = uniform( options.temporalAlpha ?? 0.0 );
|
|
43
|
-
|
|
43
|
+
// > 0 is required for firefly rejection, not just anti-lag — see ENGINE_DEFAULTS.
|
|
44
|
+
this.gradientStrength = uniform( options.gradientStrength ?? 1.0 );
|
|
44
45
|
// σ multiplier for the per-pixel noise floor (NRD luminanceSigmaScale ≈ 2).
|
|
45
46
|
this.gradientSigmaScale = uniform( options.gradientSigmaScale ?? 2.0 );
|
|
46
47
|
// Secondary relative floor on the normalised gradient (0 = rely on σ alone).
|
|
@@ -380,7 +381,12 @@ export class ASVGF extends RenderStage {
|
|
|
380
381
|
If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
|
|
381
382
|
|
|
382
383
|
const coord = ivec2( gx, gy );
|
|
383
|
-
const
|
|
384
|
+
const currentSample = textureLoad( colorTex, coord );
|
|
385
|
+
const currentColor = currentSample.xyz;
|
|
386
|
+
// Coverage from the path tracer — 0 on background rays when
|
|
387
|
+
// transparentBackground is on. Forcing 1.0 here made the whole frame opaque,
|
|
388
|
+
// so a transparent render composited as black.
|
|
389
|
+
const currentAlpha = currentSample.w;
|
|
384
390
|
const currentAlbedo = textureLoad( albedoTex, coord ).xyz;
|
|
385
391
|
|
|
386
392
|
// Same safeAlbedo on both demod and re-mod sides → exact
|
|
@@ -390,7 +396,7 @@ export class ASVGF extends RenderStage {
|
|
|
390
396
|
|
|
391
397
|
// Defaults = fresh sample (no temporal blend).
|
|
392
398
|
const demodResult = vec4( currentLighting, 1.0 ).toVar();
|
|
393
|
-
const modulatedResult = vec4( currentColor,
|
|
399
|
+
const modulatedResult = vec4( currentColor, currentAlpha ).toVar();
|
|
394
400
|
|
|
395
401
|
// forceResetU skips the blend for one frame after asvgf:reset → re-anchors
|
|
396
402
|
// history to the current (post-reset) scene instead of the stale ping-pong.
|
|
@@ -491,7 +497,7 @@ export class ASVGF extends RenderStage {
|
|
|
491
497
|
const newHistory = min( prevHistory.add( 1.0 ), maxAccumFrames );
|
|
492
498
|
|
|
493
499
|
demodResult.assign( vec4( blendedLighting, newHistory ) );
|
|
494
|
-
modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ),
|
|
500
|
+
modulatedResult.assign( vec4( blendedLighting.mul( safeAlbedo ), currentAlpha ) );
|
|
495
501
|
|
|
496
502
|
} );
|
|
497
503
|
|
|
@@ -643,12 +649,6 @@ export class ASVGF extends RenderStage {
|
|
|
643
649
|
|
|
644
650
|
this.on( 'asvgf:reset', () => this.resetTemporalData() );
|
|
645
651
|
|
|
646
|
-
this.on( 'asvgf:setTemporal', ( data ) => {
|
|
647
|
-
|
|
648
|
-
if ( data && data.enabled !== undefined ) this.setTemporalEnabled( data.enabled );
|
|
649
|
-
|
|
650
|
-
} );
|
|
651
|
-
|
|
652
652
|
this.on( 'asvgf:updateParameters', ( data ) => this.updateParameters( data ) );
|
|
653
653
|
|
|
654
654
|
}
|
|
@@ -24,7 +24,9 @@ const bilateralWeight = /*@__PURE__*/ wgslFn( `
|
|
|
24
24
|
let lumW = exp( -abs( centerLum - sLum ) / sigmaL );
|
|
25
25
|
// clamp dot to [0,1]: miss-ray normals decode to (-1,-1,-1) with
|
|
26
26
|
// dot=3 → pow saturates to +inf → inf*0 = NaN. See project_tsl_pitfalls.
|
|
27
|
-
|
|
27
|
+
// max(phiNorm, 1e-6): pow(0.0, 0.0) is undefined in WGSL and returned NaN on ~12%
|
|
28
|
+
// of pixels when phiNormal was set to 0.
|
|
29
|
+
let normW = pow( clamp( dot( centerNormal, sNormal ), 0.0, 1.0 ), max( phiNorm, 1e-6 ) );
|
|
28
30
|
let depW = exp( -abs( centerDepth - sDepth ) / max( centerDepth * phiDep, 0.001 ) );
|
|
29
31
|
let maxDiff = max( max( abs( centerColor.x - sColor.x ),
|
|
30
32
|
abs( centerColor.y - sColor.y ) ),
|
|
@@ -79,6 +81,8 @@ export class BilateralFilter extends RenderStage {
|
|
|
79
81
|
this.stepSizeU = uniform( 1, 'int' );
|
|
80
82
|
// 1 on the final iteration → multiply by albedo to remodulate.
|
|
81
83
|
this.isLastIterationU = uniform( 0, 'int' );
|
|
84
|
+
// 1 on pass 0 → seed variance from variance:output; later passes read the carried value.
|
|
85
|
+
this.isFirstIterationU = uniform( 1, 'int' );
|
|
82
86
|
this.resW = uniform( options.width || 1 );
|
|
83
87
|
this.resH = uniform( options.height || 1 );
|
|
84
88
|
|
|
@@ -87,6 +91,8 @@ export class BilateralFilter extends RenderStage {
|
|
|
87
91
|
this._shadingNormalTexNode = new TextureNode();
|
|
88
92
|
this._albedoTexNode = new TextureNode();
|
|
89
93
|
this._varianceTexNode = new TextureNode();
|
|
94
|
+
// pathtracer:color, read only for its alpha (coverage) on the final pass.
|
|
95
|
+
this._srcAlphaTexNode = new TextureNode();
|
|
90
96
|
|
|
91
97
|
const w = options.width || 1;
|
|
92
98
|
const h = options.height || 1;
|
|
@@ -144,6 +150,7 @@ export class BilateralFilter extends RenderStage {
|
|
|
144
150
|
const snTexNode = this._shadingNormalTexNode;
|
|
145
151
|
const albedoTexNode = this._albedoTexNode;
|
|
146
152
|
const varTexNode = this._varianceTexNode;
|
|
153
|
+
const srcAlphaTexNode = this._srcAlphaTexNode;
|
|
147
154
|
const phiColor = this.phiColor;
|
|
148
155
|
const phiNormal = this.phiNormal;
|
|
149
156
|
const phiDepth = this.phiDepth;
|
|
@@ -151,6 +158,7 @@ export class BilateralFilter extends RenderStage {
|
|
|
151
158
|
const spatialVarianceWeight = this.spatialVarianceWeight;
|
|
152
159
|
const stepSize = this.stepSizeU;
|
|
153
160
|
const isLastIterationU = this.isLastIterationU;
|
|
161
|
+
const isFirstIterationU = this.isFirstIterationU;
|
|
154
162
|
const resW = this.resW;
|
|
155
163
|
const resH = this.resH;
|
|
156
164
|
|
|
@@ -173,7 +181,8 @@ export class BilateralFilter extends RenderStage {
|
|
|
173
181
|
If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
|
|
174
182
|
|
|
175
183
|
const coord = ivec2( gx, gy );
|
|
176
|
-
const
|
|
184
|
+
const centerSample = textureLoad( readTexNode, coord );
|
|
185
|
+
const centerColor = centerSample.xyz;
|
|
177
186
|
const centerND = textureLoad( ndTexNode, coord );
|
|
178
187
|
// Normal edge-stop reads the mapped (shading) normal; depth gate stays geometric.
|
|
179
188
|
const centerNormal = textureLoad( snTexNode, coord ).xyz.mul( 2.0 ).sub( 1.0 );
|
|
@@ -182,22 +191,43 @@ export class BilateralFilter extends RenderStage {
|
|
|
182
191
|
const centerSafeAlbedo = max( textureLoad( albedoTexNode, coord ).xyz, vec3( ALBEDO_EPS ) );
|
|
183
192
|
const centerAlbedoLum = max( luminance( centerSafeAlbedo ), float( ALBEDO_EPS ) ).toVar();
|
|
184
193
|
|
|
194
|
+
// Variance is filtered alongside colour and carried between passes in the
|
|
195
|
+
// ping-pong's spare alpha, so sigma_l tightens as the signal smooths. Without
|
|
196
|
+
// it every pass re-read the unfiltered variance:output and filter strength
|
|
197
|
+
// never backed off as the render converged.
|
|
198
|
+
//
|
|
199
|
+
// Stored as the ROOT: converged variance reaches ~1e-6, a half-float subnormal
|
|
200
|
+
// that flushes to zero on some drivers. select() rather than If() — an If
|
|
201
|
+
// around a texture read lets the inactive branch contaminate the result.
|
|
202
|
+
// .z = temporal, .w = spatial 3×3; blending toward max widens sigma_l where
|
|
203
|
+
// history is thin.
|
|
204
|
+
const isFirst = isFirstIterationU.equal( int( 1 ) );
|
|
205
|
+
const seedVariance = ( at ) => {
|
|
206
|
+
|
|
207
|
+
const v = textureLoad( varTexNode, at );
|
|
208
|
+
return mix( v.z, max( v.z, v.w ), spatialVarianceWeight );
|
|
209
|
+
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const centerVariance = isFirst.select(
|
|
213
|
+
seedVariance( coord ),
|
|
214
|
+
centerSample.w.mul( centerSample.w )
|
|
215
|
+
).toVar();
|
|
216
|
+
|
|
185
217
|
// sigma_l = phiLum * √variance / albedoLum + ε. Dividing by
|
|
186
218
|
// albedoLum compensates for the 1/albedo noise amplification
|
|
187
219
|
// from demodulation — otherwise dark materials get an
|
|
188
220
|
// under-estimated sigma → over-strict luminance gate → no
|
|
189
221
|
// blending → silhouette dark-outline artifact.
|
|
190
|
-
// .z = temporal variance, .w = spatial (3×3) variance. Blend toward
|
|
191
|
-
// max(temporal, spatial) so disoccluded/low-history pixels widen sigma_l.
|
|
192
|
-
const vSample = textureLoad( varTexNode, coord );
|
|
193
|
-
const variance = mix( vSample.z, max( vSample.z, vSample.w ), spatialVarianceWeight );
|
|
194
222
|
const sigmaL = phiLuminance
|
|
195
|
-
.mul( sqrt( max(
|
|
223
|
+
.mul( sqrt( max( centerVariance, float( 0.0 ) ) ) )
|
|
196
224
|
.div( centerAlbedoLum )
|
|
197
225
|
.add( float( 0.0001 ) );
|
|
198
226
|
|
|
199
227
|
const colorSum = vec3( 0.0 ).toVar();
|
|
200
228
|
const weightSum = float( 0.0 ).toVar();
|
|
229
|
+
// Squared weights, normalised by weightSum² — the variance of a weighted mean.
|
|
230
|
+
const varianceSum = float( 0.0 ).toVar();
|
|
201
231
|
|
|
202
232
|
// 5×5 à-trous kernel (Gaussian-approx, Σ=1)
|
|
203
233
|
for ( let iy = 0; iy < 5; iy ++ ) {
|
|
@@ -213,7 +243,8 @@ export class BilateralFilter extends RenderStage {
|
|
|
213
243
|
const sy = gy.add( stepSize.mul( dy ) )
|
|
214
244
|
.clamp( int( 0 ), int( resH ).sub( 1 ) );
|
|
215
245
|
|
|
216
|
-
const
|
|
246
|
+
const sSample = textureLoad( readTexNode, ivec2( sx, sy ) );
|
|
247
|
+
const sColor = sSample.xyz;
|
|
217
248
|
const sND = textureLoad( ndTexNode, ivec2( sx, sy ) );
|
|
218
249
|
const sNormal = textureLoad( snTexNode, ivec2( sx, sy ) ).xyz.mul( 2.0 ).sub( 1.0 );
|
|
219
250
|
const sDepth = sND.w;
|
|
@@ -228,24 +259,39 @@ export class BilateralFilter extends RenderStage {
|
|
|
228
259
|
sigmaL, phiNormal, phiDepth, phiColor
|
|
229
260
|
);
|
|
230
261
|
|
|
262
|
+
const sVariance = isFirst.select(
|
|
263
|
+
seedVariance( ivec2( sx, sy ) ),
|
|
264
|
+
sSample.w.mul( sSample.w )
|
|
265
|
+
);
|
|
266
|
+
|
|
231
267
|
colorSum.addAssign( sColor.mul( w ) );
|
|
232
268
|
weightSum.addAssign( w );
|
|
269
|
+
varianceSum.addAssign( w.mul( w ).mul( max( sVariance, float( 0.0 ) ) ) );
|
|
233
270
|
|
|
234
271
|
}
|
|
235
272
|
|
|
236
273
|
}
|
|
237
274
|
|
|
238
275
|
const filtered = colorSum.div( max( weightSum, float( 0.0001 ) ) );
|
|
276
|
+
const filteredVariance = varianceSum
|
|
277
|
+
.div( max( weightSum.mul( weightSum ), float( 1e-8 ) ) );
|
|
239
278
|
|
|
240
279
|
// Remodulate by albedo only on the final iteration so the
|
|
241
280
|
// inner ping-pong stays in demodulated space.
|
|
242
281
|
const isLast = isLastIterationU.equal( int( 1 ) );
|
|
243
282
|
const output = isLast.select( filtered.mul( centerSafeAlbedo ), filtered );
|
|
244
283
|
|
|
284
|
+
// The last pass restores the path tracer's coverage — Compositor reads .w as
|
|
285
|
+
// image alpha when transparentBackground is on.
|
|
286
|
+
const carriedAlpha = isLast.select(
|
|
287
|
+
textureLoad( srcAlphaTexNode, coord ).w,
|
|
288
|
+
sqrt( max( filteredVariance, float( 0.0 ) ) )
|
|
289
|
+
);
|
|
290
|
+
|
|
245
291
|
textureStore(
|
|
246
292
|
writeStorageTex,
|
|
247
293
|
uvec2( uint( gx ), uint( gy ) ),
|
|
248
|
-
vec4( output,
|
|
294
|
+
vec4( output, carriedAlpha )
|
|
249
295
|
).toWriteOnly();
|
|
250
296
|
|
|
251
297
|
} );
|
|
@@ -271,6 +317,7 @@ export class BilateralFilter extends RenderStage {
|
|
|
271
317
|
const snTex = context.getTexture( this.shadingNormalTextureName ) || ndTex;
|
|
272
318
|
const albedoTex = context.getTexture( this.albedoTextureName );
|
|
273
319
|
const varTex = context.getTexture( this.varianceTextureName );
|
|
320
|
+
const srcAlphaTex = context.getTexture( 'pathtracer:color' );
|
|
274
321
|
|
|
275
322
|
if ( ! inputTex ) return;
|
|
276
323
|
|
|
@@ -291,6 +338,18 @@ export class BilateralFilter extends RenderStage {
|
|
|
291
338
|
if ( ndTex ) this._normalDepthTexNode.value = ndTex;
|
|
292
339
|
if ( snTex ) this._shadingNormalTexNode.value = snTex;
|
|
293
340
|
if ( albedoTex ) this._albedoTexNode.value = albedoTex;
|
|
341
|
+
// Bind these BEFORE compile too: two TextureNodes still holding the default empty
|
|
342
|
+
// texture when the node builder runs share one GPU binding, which then resolves to
|
|
343
|
+
// whichever is assigned last. That aliased varTexNode onto the colour input.
|
|
344
|
+
//
|
|
345
|
+
// Safe only because inputTex is a RenderTarget texture, which produces the same
|
|
346
|
+
// `textureLoad(t, coord, level)` codegen as EmptyTexture — the form the StorageTexture
|
|
347
|
+
// ping-pong needs on passes 1+. Binding an actual StorageTexture here would emit the
|
|
348
|
+
// no-level form and later reads would return zero. EdgeFilter must NOT do this: its
|
|
349
|
+
// pass-0 input IS a StorageTexture.
|
|
350
|
+
this._readTexNode.value = inputTex;
|
|
351
|
+
if ( varTex ) this._varianceTexNode.value = varTex;
|
|
352
|
+
if ( srcAlphaTex ) this._srcAlphaTexNode.value = srcAlphaTex;
|
|
294
353
|
|
|
295
354
|
// First-frame compile while StorageTexture-typed nodes still hold
|
|
296
355
|
// EmptyTexture — codegen then emits textureLoad with the level
|
|
@@ -303,8 +362,6 @@ export class BilateralFilter extends RenderStage {
|
|
|
303
362
|
|
|
304
363
|
}
|
|
305
364
|
|
|
306
|
-
if ( varTex ) this._varianceTexNode.value = varTex;
|
|
307
|
-
|
|
308
365
|
// À-trous iterations: step size 2^i, ping-pong write direction.
|
|
309
366
|
// Last iteration multiplies by albedo to remodulate.
|
|
310
367
|
let readTex = inputTex;
|
|
@@ -316,6 +373,7 @@ export class BilateralFilter extends RenderStage {
|
|
|
316
373
|
this.stepSizeU.value = 1 << i;
|
|
317
374
|
this._readTexNode.value = readTex;
|
|
318
375
|
this.isLastIterationU.value = ( i === this.iterations - 1 ) ? 1 : 0;
|
|
376
|
+
this.isFirstIterationU.value = i === 0 ? 1 : 0;
|
|
319
377
|
|
|
320
378
|
this.renderer.compute( writeNode );
|
|
321
379
|
|
|
@@ -420,6 +478,7 @@ export class BilateralFilter extends RenderStage {
|
|
|
420
478
|
this._shadingNormalTexNode?.dispose();
|
|
421
479
|
this._albedoTexNode?.dispose();
|
|
422
480
|
this._varianceTexNode?.dispose();
|
|
481
|
+
this._srcAlphaTexNode?.dispose();
|
|
423
482
|
|
|
424
483
|
}
|
|
425
484
|
|