three-realtime-rt 0.4.2 → 0.6.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/README.md +157 -14
- package/package.json +2 -1
- package/src/CompositePass.js +6 -2
- package/src/DenoisePass.js +58 -6
- package/src/GBufferPass.js +132 -46
- package/src/GIReservoirPass.js +946 -0
- package/src/RTLightingPass.js +123 -10
- package/src/RealtimeRaytracer.js +143 -6
- package/src/SceneCompiler.js +386 -51
- package/src/bvhAnyHit.glsl.js +15 -0
- package/src/index.d.ts +105 -5
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
|
|
3
|
+
import { MAX_LIGHTS } from "./SceneCompiler.js";
|
|
4
|
+
import { SKY_GLSL } from "./sky.glsl.js";
|
|
5
|
+
import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
|
|
6
|
+
|
|
7
|
+
const fullscreenVert = /* glsl */ `
|
|
8
|
+
out vec2 vUv;
|
|
9
|
+
void main() {
|
|
10
|
+
vUv = uv;
|
|
11
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
12
|
+
}
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
// ReSTIR GI (EXPERIMENTAL) — temporal-only reservoir reuse of the 1-bounce GI
|
|
16
|
+
// path. This standalone pass owns its OWN sampler budget (the lighting pass is
|
|
17
|
+
// already AT the WebGL2 16-sampler minimum and cannot take another), and shades
|
|
18
|
+
// GI-bounce hits IDENTICALLY to RTLightingPass.traceRadiance so the mean equals
|
|
19
|
+
// the inline path. See the estimator derivation in main() below.
|
|
20
|
+
//
|
|
21
|
+
// The GI-hit shading below is duplicated (not shared via include) from
|
|
22
|
+
// RTLightingPass ON PURPOSE: the inline path must stay byte-identical when
|
|
23
|
+
// restirGI is off, so RTLightingPass is left completely untouched. The specular
|
|
24
|
+
// (gWantSpec) accumulation is dropped here — GI bounces never contribute to the
|
|
25
|
+
// primary-surface highlight buffer — but every quantity that affects the
|
|
26
|
+
// RETURNED radiance is copied faithfully (RNG scheme, cosine sampling, two-level
|
|
27
|
+
// BVH trace, one-light NEE incl. emissive importance sampling + the near-emitter
|
|
28
|
+
// firefly clamp, sky/env on a miss).
|
|
29
|
+
const giFrag = /* glsl */ `
|
|
30
|
+
precision highp float;
|
|
31
|
+
precision highp isampler2D;
|
|
32
|
+
precision highp usampler2D;
|
|
33
|
+
|
|
34
|
+
${shaderStructs}
|
|
35
|
+
${shaderIntersectFunction}
|
|
36
|
+
${BVH_ANY_HIT_GLSL}
|
|
37
|
+
${SKY_GLSL}
|
|
38
|
+
|
|
39
|
+
#define MAX_LIGHTS ${MAX_LIGHTS}
|
|
40
|
+
#define PI 3.14159265358979
|
|
41
|
+
|
|
42
|
+
// MRT: [0] reservoir hit position + packed(M, oct-normal) (fp32),
|
|
43
|
+
// [1] reservoir radiance + W,
|
|
44
|
+
// [2] resolved demodulated GI irradiance (consumed by the denoise add).
|
|
45
|
+
layout(location = 0) out vec4 outResPos;
|
|
46
|
+
layout(location = 1) out vec4 outResRad;
|
|
47
|
+
layout(location = 2) out vec4 outGI;
|
|
48
|
+
|
|
49
|
+
in vec2 vUv;
|
|
50
|
+
|
|
51
|
+
// Two-level BVH + per-vertex attribute textures (normal.xyz + materialIndex.w),
|
|
52
|
+
// exactly as RTLightingPass binds them. 8 samplers (4 per BVH struct) + 2 attr.
|
|
53
|
+
uniform BVH bvhStatic;
|
|
54
|
+
uniform BVH bvhDynamic;
|
|
55
|
+
uniform bool uHasDynamic;
|
|
56
|
+
uniform sampler2D uAttrStatic;
|
|
57
|
+
uniform sampler2D uAttrDynamic;
|
|
58
|
+
uniform sampler2D uMaterialsTex; // materials + emissive NEE tris + blue noise + power CDF
|
|
59
|
+
|
|
60
|
+
uniform sampler2D uGWorldPos;
|
|
61
|
+
uniform sampler2D uGNormalMetal;
|
|
62
|
+
|
|
63
|
+
// Temporal reuse: reproject through last frame's G-buffer (plane-distance
|
|
64
|
+
// validation, same as the lighting pass) and pull last frame's reservoir.
|
|
65
|
+
uniform sampler2D uPrevGWorldPos;
|
|
66
|
+
uniform sampler2D uPrevResPos; // history attachment 0: hitPos.xyz + M
|
|
67
|
+
uniform sampler2D uPrevResRad; // history attachment 1: radiance.rgb + W
|
|
68
|
+
uniform mat4 uPrevViewProj;
|
|
69
|
+
|
|
70
|
+
uniform vec4 uLightPosType[MAX_LIGHTS];
|
|
71
|
+
uniform vec4 uLightColorRadius[MAX_LIGHTS];
|
|
72
|
+
uniform vec4 uLightDirCone[MAX_LIGHTS];
|
|
73
|
+
uniform int uLightCount;
|
|
74
|
+
uniform int uEmissiveCount;
|
|
75
|
+
uniform bool uEmissiveCDF;
|
|
76
|
+
|
|
77
|
+
uniform vec3 uCameraPos;
|
|
78
|
+
uniform float uFrame;
|
|
79
|
+
uniform float uEps;
|
|
80
|
+
uniform float uFireflyClamp;
|
|
81
|
+
uniform float uMCap; // temporal M-cap (staleness limit)
|
|
82
|
+
uniform int uSpatialTaps; // spatial reuse taps after the temporal merge (0 = v1)
|
|
83
|
+
uniform int uValidateInterval; // reservoir-sample validation period (0 = off, e.g. 8)
|
|
84
|
+
|
|
85
|
+
// Validation tuning (see the reservoir-sample-validation block in main()).
|
|
86
|
+
// VAL_NEE_SAMPLES: NEE samples averaged when RE-SHADING the stored hit. A single
|
|
87
|
+
// NEE sample is black whenever its random light point is occluded (~30% of the
|
|
88
|
+
// time even for a fully-lit hit), so a 1-sample re-shade cannot tell "light off"
|
|
89
|
+
// from "unlucky shadow ray"; averaging a few de-noises the kill decision. Costs
|
|
90
|
+
// a few extra SHADOW rays on only the ~1/uValidateInterval validating pixels (no
|
|
91
|
+
// extra bounce rays -- the single candidate trace is reused).
|
|
92
|
+
// VAL_DARK_FRAC: kill the reservoir when the (multi-sampled) re-shaded target
|
|
93
|
+
// falls below this fraction of the stored one. Kept LOW so the kill fires on a
|
|
94
|
+
// real collapse to near-black (a switched-off light drives it to ~0), not on
|
|
95
|
+
// residual shadow noise -- false kills reset pixels to low confidence, where the
|
|
96
|
+
// pre-existing anti-firefly clamp tightens and would darken bright GI.
|
|
97
|
+
#define VAL_NEE_SAMPLES 8
|
|
98
|
+
#define VAL_DARK_FRAC 0.02
|
|
99
|
+
|
|
100
|
+
uniform vec3 uEnvColor;
|
|
101
|
+
uniform float uEnvIntensity;
|
|
102
|
+
uniform bool uSkyEnabled;
|
|
103
|
+
uniform vec3 uSunDir;
|
|
104
|
+
uniform vec3 uSunColor;
|
|
105
|
+
uniform vec3 uSkyZenith;
|
|
106
|
+
uniform vec3 uSkyHorizon;
|
|
107
|
+
uniform float uSkyIntensity;
|
|
108
|
+
|
|
109
|
+
// ---------- RNG (identical scheme to RTLightingPass) ----------
|
|
110
|
+
uint gSeed;
|
|
111
|
+
int gBnDim;
|
|
112
|
+
vec4 gBlueNoise;
|
|
113
|
+
uint pcgHash(uint s) {
|
|
114
|
+
uint state = s * 747796405u + 2891336453u;
|
|
115
|
+
uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
|
|
116
|
+
return (word >> 22u) ^ word;
|
|
117
|
+
}
|
|
118
|
+
float rand() {
|
|
119
|
+
if (gBnDim < 4) {
|
|
120
|
+
float v = gBlueNoise[gBnDim];
|
|
121
|
+
gBnDim++;
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
gSeed = pcgHash(gSeed);
|
|
125
|
+
return float(gSeed) * (1.0 / 4294967296.0);
|
|
126
|
+
}
|
|
127
|
+
vec2 rand2() { return vec2(rand(), rand()); }
|
|
128
|
+
|
|
129
|
+
vec4 fetchBlueNoise() {
|
|
130
|
+
ivec2 p = ivec2(gl_FragCoord.xy) & 63;
|
|
131
|
+
vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
|
|
132
|
+
vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
|
|
133
|
+
return fract(bn + shift);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
|
|
137
|
+
|
|
138
|
+
// ---------- reservoir .w bit-packing: M (8 bit) + oct-normal (12+12 bit) ------
|
|
139
|
+
// The RGBA32F reservoir-position attachment is at the pass's hard 16-sampler
|
|
140
|
+
// ceiling, so the reconnection normal n_s (needed by the spatial Jacobian) is
|
|
141
|
+
// bit-packed into the SAME .w channel that holds M. fp32 + NEAREST round-trips
|
|
142
|
+
// the bits exactly. Layout: (uint(M)&0xFF)<<24 | octX12<<12 | octY12. M caps at
|
|
143
|
+
// 255 (clamped on write). 12 bits/axis is ample for a cosine-weighted normal.
|
|
144
|
+
vec2 signNotZero(vec2 v) {
|
|
145
|
+
return vec2(v.x >= 0.0 ? 1.0 : -1.0, v.y >= 0.0 ? 1.0 : -1.0);
|
|
146
|
+
}
|
|
147
|
+
void octEncode12(vec3 n, out uint ox, out uint oy) {
|
|
148
|
+
n /= (abs(n.x) + abs(n.y) + abs(n.z));
|
|
149
|
+
vec2 e = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero(n.xy);
|
|
150
|
+
vec2 u = clamp(e * 0.5 + 0.5, 0.0, 1.0);
|
|
151
|
+
ox = uint(u.x * 4095.0 + 0.5) & 0xFFFu;
|
|
152
|
+
oy = uint(u.y * 4095.0 + 0.5) & 0xFFFu;
|
|
153
|
+
}
|
|
154
|
+
vec3 octDecode12(uint ox, uint oy) {
|
|
155
|
+
vec2 e = vec2(float(ox), float(oy)) / 4095.0 * 2.0 - 1.0;
|
|
156
|
+
vec3 v = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
|
|
157
|
+
if (v.z < 0.0) v.xy = (1.0 - abs(v.yx)) * signNotZero(v.xy);
|
|
158
|
+
return normalize(v);
|
|
159
|
+
}
|
|
160
|
+
// Pack M (clamped to [0,126]) + hit normal into a single fp32 word.
|
|
161
|
+
// M's ceiling is 126, NOT 255: bits 30..23 of the packed word are the float's
|
|
162
|
+
// exponent field, and if they are ever all-ones (M lower bits all 1 AND
|
|
163
|
+
// octX >= 2048) the word is a NaN/Inf bit pattern — some GPUs CANONICALIZE
|
|
164
|
+
// NaNs on texture write, silently destroying the payload. M <= 126 keeps at
|
|
165
|
+
// least one exponent bit zero, so the pattern is unrepresentable by
|
|
166
|
+
// construction. (126 is far above any practical restirGIMCap.)
|
|
167
|
+
float packMN(float M, vec3 n) {
|
|
168
|
+
uint ox, oy;
|
|
169
|
+
octEncode12(n, ox, oy);
|
|
170
|
+
uint mi = uint(clamp(M, 0.0, 126.0)) & 0xFFu;
|
|
171
|
+
return uintBitsToFloat((mi << 24) | (ox << 12) | oy);
|
|
172
|
+
}
|
|
173
|
+
void unpackMN(float w, out float M, out vec3 n) {
|
|
174
|
+
uint packed = floatBitsToUint(w);
|
|
175
|
+
M = float((packed >> 24) & 0xFFu);
|
|
176
|
+
n = octDecode12((packed >> 12) & 0xFFFu, packed & 0xFFFu);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
void orthoBasis(vec3 n, out vec3 t, out vec3 b) {
|
|
180
|
+
float s = n.z >= 0.0 ? 1.0 : -1.0;
|
|
181
|
+
float a = -1.0 / (s + n.z);
|
|
182
|
+
float m = n.x * n.y * a;
|
|
183
|
+
t = vec3(1.0 + s * n.x * n.x * a, s * m, -s * n.x);
|
|
184
|
+
b = vec3(m, s + n.y * n.y * a, -n.y);
|
|
185
|
+
}
|
|
186
|
+
vec3 cosineSampleHemisphere(vec3 n, vec2 u) {
|
|
187
|
+
float a = 2.0 * PI * u.x;
|
|
188
|
+
float r = sqrt(u.y);
|
|
189
|
+
vec3 t, b;
|
|
190
|
+
orthoBasis(n, t, b);
|
|
191
|
+
return normalize(t * (r * cos(a)) + b * (r * sin(a)) + n * sqrt(max(0.0, 1.0 - u.y)));
|
|
192
|
+
}
|
|
193
|
+
vec3 randUnitVector() {
|
|
194
|
+
vec2 u = rand2();
|
|
195
|
+
float z = u.x * 2.0 - 1.0;
|
|
196
|
+
float a = u.y * 2.0 * PI;
|
|
197
|
+
float r = sqrt(max(0.0, 1.0 - z * z));
|
|
198
|
+
return vec3(r * cos(a), r * sin(a), z);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------- two-level BVH helpers (copied verbatim) ----------
|
|
202
|
+
bool traceBoth(vec3 ro, vec3 rd, out uvec4 fi, out vec3 bary, out float dist, out bool isDyn) {
|
|
203
|
+
uvec4 fiS; vec3 fnS; vec3 bcS; float sideS; float distS;
|
|
204
|
+
bool hitS = bvhIntersectFirstHit(bvhStatic, ro, rd, fiS, fnS, bcS, sideS, distS);
|
|
205
|
+
uvec4 fiD; vec3 fnD; vec3 bcD; float sideD; float distD;
|
|
206
|
+
bool hitD = uHasDynamic && bvhIntersectFirstHit(bvhDynamic, ro, rd, fiD, fnD, bcD, sideD, distD);
|
|
207
|
+
if (hitS && (!hitD || distS <= distD)) { fi = fiS; bary = bcS; dist = distS; isDyn = false; return true; }
|
|
208
|
+
if (hitD) { fi = fiD; bary = bcD; dist = distD; isDyn = true; return true; }
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
bool occluded(vec3 ro, vec3 rd, float maxDist) {
|
|
212
|
+
if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
|
|
213
|
+
if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
|
|
218
|
+
out vec3 emissive, out float metalness) {
|
|
219
|
+
int mi = int(round(matIndex)) * 2;
|
|
220
|
+
vec4 t0 = texelFetch(uMaterialsTex, ivec2(mi, 0), 0);
|
|
221
|
+
vec4 t1 = texelFetch(uMaterialsTex, ivec2(mi + 1, 0), 0);
|
|
222
|
+
albedo = t0.rgb;
|
|
223
|
+
roughness = t0.a;
|
|
224
|
+
emissive = t1.rgb;
|
|
225
|
+
metalness = t1.a;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- one-light NEE at a GI-bounce hit (specular dropped) ----------
|
|
229
|
+
float spotFalloff(int i, vec3 lightToP) {
|
|
230
|
+
vec4 posType = uLightPosType[i];
|
|
231
|
+
if (posType.w < 1.5) return 1.0;
|
|
232
|
+
vec4 dc = uLightDirCone[i];
|
|
233
|
+
return smoothstep(dc.w, posType.w - 2.0, dot(dc.xyz, lightToP));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
vec3 lightContribution(int i, vec3 P, vec3 N) {
|
|
237
|
+
vec4 posType = uLightPosType[i];
|
|
238
|
+
vec4 colRad = uLightColorRadius[i];
|
|
239
|
+
vec3 L;
|
|
240
|
+
float dist2 = 1.0;
|
|
241
|
+
float maxDist = 1e7;
|
|
242
|
+
float cone = 1.0;
|
|
243
|
+
if (posType.w < 0.5 || posType.w >= 1.5) {
|
|
244
|
+
vec3 lp = posType.xyz + randUnitVector() * colRad.w;
|
|
245
|
+
vec3 d = lp - P;
|
|
246
|
+
float dl = length(d);
|
|
247
|
+
if (dl < 1e-5) return vec3(0.0);
|
|
248
|
+
L = d / dl;
|
|
249
|
+
dist2 = dl * dl;
|
|
250
|
+
maxDist = dl;
|
|
251
|
+
cone = spotFalloff(i, -L);
|
|
252
|
+
if (cone <= 0.0) return vec3(0.0);
|
|
253
|
+
} else {
|
|
254
|
+
L = normalize(-posType.xyz + randUnitVector() * colRad.w);
|
|
255
|
+
dist2 = 1.0;
|
|
256
|
+
}
|
|
257
|
+
float NdotL = dot(N, L);
|
|
258
|
+
if (NdotL <= 0.0) return vec3(0.0);
|
|
259
|
+
if (occluded(P + N * uEps, L, maxDist)) return vec3(0.0);
|
|
260
|
+
return colRad.rgb * (cone / dist2) * NdotL;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
vec3 sampleOneLight(vec3 P, vec3 N) {
|
|
264
|
+
if (uLightCount == 0) return vec3(0.0);
|
|
265
|
+
int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
|
|
266
|
+
return lightContribution(i, P, N) * float(uLightCount);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
vec3 sampleEmissiveTri(vec3 P, vec3 N) {
|
|
270
|
+
if (uEmissiveCount == 0) return vec3(0.0);
|
|
271
|
+
int idx;
|
|
272
|
+
float invProb;
|
|
273
|
+
if (uEmissiveCDF) {
|
|
274
|
+
float u = rand();
|
|
275
|
+
int lo = 0;
|
|
276
|
+
int hi = uEmissiveCount - 1;
|
|
277
|
+
for (int s = 0; s < 8; s++) {
|
|
278
|
+
if (lo >= hi) break;
|
|
279
|
+
int mid = (lo + hi) >> 1;
|
|
280
|
+
if (u > texelFetch(uMaterialsTex, ivec2(mid, 66), 0).x) lo = mid + 1;
|
|
281
|
+
else hi = mid;
|
|
282
|
+
}
|
|
283
|
+
idx = lo;
|
|
284
|
+
invProb = 1.0 / max(texelFetch(uMaterialsTex, ivec2(idx, 66), 0).y, 1e-8);
|
|
285
|
+
} else {
|
|
286
|
+
idx = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1);
|
|
287
|
+
invProb = float(uEmissiveCount);
|
|
288
|
+
}
|
|
289
|
+
int i = idx * 4;
|
|
290
|
+
vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0);
|
|
291
|
+
vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0);
|
|
292
|
+
vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0);
|
|
293
|
+
vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0);
|
|
294
|
+
vec2 u = rand2();
|
|
295
|
+
if (u.x + u.y > 1.0) u = 1.0 - u;
|
|
296
|
+
vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
|
|
297
|
+
vec3 d = lp - P;
|
|
298
|
+
float d2 = dot(d, d);
|
|
299
|
+
float dist = sqrt(d2);
|
|
300
|
+
if (dist < 1e-4) return vec3(0.0);
|
|
301
|
+
vec3 wi = d / dist;
|
|
302
|
+
float cosS = dot(N, wi);
|
|
303
|
+
float cosL = abs(dot(t3.xyz, wi));
|
|
304
|
+
if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
|
|
305
|
+
if (occluded(P + N * uEps, wi, dist)) return vec3(0.0);
|
|
306
|
+
vec3 e = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * invProb * t0.w / max(d2, 1e-6));
|
|
307
|
+
float eLum = dot(e, vec3(0.299, 0.587, 0.114));
|
|
308
|
+
float eCap = uFireflyClamp * 2.0;
|
|
309
|
+
if (eLum > eCap) e *= eCap / eLum;
|
|
310
|
+
return e;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
vec3 sampleOneAny(vec3 P, vec3 N) {
|
|
314
|
+
bool hasL = uLightCount > 0;
|
|
315
|
+
bool hasE = uEmissiveCount > 0;
|
|
316
|
+
if (hasL && hasE) {
|
|
317
|
+
return rand() < 0.5
|
|
318
|
+
? sampleOneLight(P, N) * 2.0
|
|
319
|
+
: sampleEmissiveTri(P, N) * 2.0;
|
|
320
|
+
}
|
|
321
|
+
if (hasL) return sampleOneLight(P, N);
|
|
322
|
+
if (hasE) return sampleEmissiveTri(P, N);
|
|
323
|
+
return vec3(0.0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Incoming radiance along rd for a DIFFUSE GI bounce (specular=false in the
|
|
327
|
+
// inline path), plus the world-space hit position so temporal reuse can
|
|
328
|
+
// recompute the geometry term at the reprojected (same) surface. On a miss the
|
|
329
|
+
// "hit" is a far point along the ray, so the reused direction is recoverable.
|
|
330
|
+
// nLight = number of averaged NEE samples at the bounce hit. The fresh candidate
|
|
331
|
+
// passes 1 (byte-identical to the inline path). The reservoir-sample VALIDATION
|
|
332
|
+
// passes a small number > 1: a single NEE sample is black whenever its random
|
|
333
|
+
// light point is occluded, which happens ~30% of the time even for a fully-lit
|
|
334
|
+
// hit, so a 1-sample re-shade cannot reliably tell "light switched off" from
|
|
335
|
+
// "unlucky shadow sample". Averaging a few NEE samples de-noises pHatNew enough to
|
|
336
|
+
// make the validation kill decision robust, at the cost of a few extra shadow rays
|
|
337
|
+
// on only the ~1/uValidateInterval validating pixels (no extra BOUNCE rays).
|
|
338
|
+
vec3 traceRadianceGI(vec3 ro, vec3 rd, int nLight, out vec3 hitPos, out vec3 hitNormal) {
|
|
339
|
+
uvec4 fi; vec3 bary; float dist; bool isDyn;
|
|
340
|
+
if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
|
|
341
|
+
hitPos = ro + rd * 1.0e4;
|
|
342
|
+
// Sky "hit" has no surface; face the normal back along the ray so a
|
|
343
|
+
// neighbour reconnecting to this point sees a sane, positive cosPhi.
|
|
344
|
+
hitNormal = -rd;
|
|
345
|
+
return uSkyEnabled
|
|
346
|
+
? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
|
|
347
|
+
: uEnvColor * uEnvIntensity;
|
|
348
|
+
}
|
|
349
|
+
vec4 attr = isDyn
|
|
350
|
+
? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
|
|
351
|
+
: textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
|
|
352
|
+
vec3 hAlbedo; float hRough; vec3 hEmissive; float hMetal;
|
|
353
|
+
fetchMaterial(attr.w, hAlbedo, hRough, hEmissive, hMetal);
|
|
354
|
+
vec3 hN = normalize(attr.xyz);
|
|
355
|
+
if (dot(hN, rd) > 0.0) hN = -hN;
|
|
356
|
+
vec3 hP = ro + rd * dist;
|
|
357
|
+
hitPos = hP;
|
|
358
|
+
hitNormal = hN;
|
|
359
|
+
vec3 Ld = vec3(0.0);
|
|
360
|
+
for (int s = 0; s < 8; s++) {
|
|
361
|
+
if (s >= nLight) break;
|
|
362
|
+
Ld += sampleOneAny(hP + hN * uEps, hN);
|
|
363
|
+
}
|
|
364
|
+
Ld /= float(max(nLight, 1));
|
|
365
|
+
// Diffuse GI drops NEE-listed (static) emitter emission so it isn't double
|
|
366
|
+
// counted (same rule as RTLightingPass.traceRadiance with specular=false).
|
|
367
|
+
vec3 hLe = (uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
|
|
368
|
+
return hLe + hAlbedo * Ld * (1.0 / PI);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
void main() {
|
|
372
|
+
vec4 wp = texture(uGWorldPos, vUv);
|
|
373
|
+
if (wp.w < 0.5) {
|
|
374
|
+
outResPos = vec4(0.0);
|
|
375
|
+
outResRad = vec4(0.0);
|
|
376
|
+
outGI = vec4(0.0);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
vec3 P = wp.xyz;
|
|
380
|
+
vec3 N = normalize(texture(uGNormalMetal, vUv).xyz);
|
|
381
|
+
|
|
382
|
+
ivec2 px = ivec2(gl_FragCoord.xy);
|
|
383
|
+
gSeed = uint(px.x) * 1471u + uint(px.y) * 8951u + uint(uFrame) * 23833u;
|
|
384
|
+
gSeed = pcgHash(gSeed);
|
|
385
|
+
gBlueNoise = fetchBlueNoise();
|
|
386
|
+
gBnDim = 0;
|
|
387
|
+
|
|
388
|
+
// ===================== ESTIMATOR DERIVATION =====================
|
|
389
|
+
// The inline path stores, per pixel, the DEMODULATED indirect irradiance
|
|
390
|
+
// I = (1/PI) * integral_hemisphere L_i(w) (N.w) dw
|
|
391
|
+
// as a single cosine-sampled sample: indirect = traceRadiance(cosine ray),
|
|
392
|
+
// because with the cosine pdf p(w) = cos/PI the estimate L_i(w)/p * (cos/PI)
|
|
393
|
+
// collapses to L_i(w). We reproduce the SAME quantity I via RIS.
|
|
394
|
+
//
|
|
395
|
+
// - Candidate sample: a hemisphere direction w (cosine-sampled), carrying
|
|
396
|
+
// the incoming radiance L_i(w) = traceRadianceGI(w) and its hit position.
|
|
397
|
+
// - Target function: p_hat(w) = luminance( L_i(w) * cos(theta) ).
|
|
398
|
+
// - Source pdf: p(w) = cos(theta)/PI (cosine).
|
|
399
|
+
// - RIS candidate weight: w_i = p_hat / p = PI * luminance(L_i) (cos cancels).
|
|
400
|
+
// - Reservoir picks y ~ p_hat; unbiased contribution weight
|
|
401
|
+
// W = wSum / (M * p_hat(y)).
|
|
402
|
+
// - Final estimate of I (integrand F(w) = L_i(w) cos(theta) / PI):
|
|
403
|
+
// <I> = F(y) * W = L_i(y) * cos(theta_y) / PI * W.
|
|
404
|
+
//
|
|
405
|
+
// Sanity (M=1, no history): W = w_1 / p_hat(y) = PI*lum(L)/(lum(L)*cos) =
|
|
406
|
+
// PI/cos, so <I> = L_i * cos/PI * PI/cos = L_i(y) — EXACTLY the inline
|
|
407
|
+
// single-sample estimate. Forcing uMCap=1 with a cleared history therefore
|
|
408
|
+
// makes this pass statistically identical to the legacy GI path.
|
|
409
|
+
//
|
|
410
|
+
// The reservoir is the GI temporal integrator; its output is ADDED at the
|
|
411
|
+
// denoise stage, DOWNSTREAM of the lighting pass's own temporal accumulation,
|
|
412
|
+
// so this GI never re-enters (and double-counts through) that history.
|
|
413
|
+
// ================================================================
|
|
414
|
+
|
|
415
|
+
// Reprojected UV of this pixel's primary point into the previous frame; shared
|
|
416
|
+
// by the reservoir-sample validation, the temporal merge and the spatial taps.
|
|
417
|
+
vec4 clip = uPrevViewProj * vec4(P, 1.0);
|
|
418
|
+
vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
|
|
419
|
+
bool haveReproj = clip.w > 0.0 &&
|
|
420
|
+
prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0;
|
|
421
|
+
// Plane-distance tolerance the temporal validation and the spatial taps share.
|
|
422
|
+
float tol = 0.005 * distance(P, uCameraPos) + 20.0 * uEps;
|
|
423
|
+
|
|
424
|
+
// --- fetch this pixel's TEMPORAL-history reservoir ONCE. Both the reservoir-
|
|
425
|
+
// sample validation (below) and the temporal merge read it. Texture reads
|
|
426
|
+
// only, so this consumes no RNG: the fresh-candidate random stream stays
|
|
427
|
+
// aligned with the validation-off path (uValidateInterval==0 is byte-identical
|
|
428
|
+
// to before this feature). ---
|
|
429
|
+
bool histValid = false;
|
|
430
|
+
float Mprev = 0.0;
|
|
431
|
+
float Wprev = 0.0;
|
|
432
|
+
vec3 radPrev = vec3(0.0);
|
|
433
|
+
vec3 hitPrev = vec3(0.0);
|
|
434
|
+
vec3 nPrev = N;
|
|
435
|
+
if (haveReproj) {
|
|
436
|
+
vec4 pPos = texture(uPrevGWorldPos, prevUv);
|
|
437
|
+
if (pPos.w > 0.5 && abs(dot(P - pPos.xyz, N)) < tol) {
|
|
438
|
+
vec4 hp = texture(uPrevResPos, prevUv); // hitPos.xyz + packed(M, n_s)
|
|
439
|
+
vec4 hr = texture(uPrevResRad, prevUv); // radiance.rgb + W
|
|
440
|
+
unpackMN(hp.w, Mprev, nPrev);
|
|
441
|
+
Wprev = hr.w;
|
|
442
|
+
if (Mprev > 0.0 && Wprev > 0.0) {
|
|
443
|
+
histValid = true;
|
|
444
|
+
radPrev = hr.rgb;
|
|
445
|
+
hitPrev = hp.xyz;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- RESERVOIR-SAMPLE VALIDATION (the fix for stale bounce light). On a
|
|
451
|
+
// rotating 1-in-uValidateInterval subset of pixels — selected by a per-pixel
|
|
452
|
+
// hash added to uFrame so the validating set is decorrelated in space AND
|
|
453
|
+
// changes every frame — spend this frame's ONE candidate ray re-tracing the
|
|
454
|
+
// STORED reservoir hit instead of a fresh cosine bounce. This costs ZERO extra
|
|
455
|
+
// rays (it REPLACES the fresh candidate on those pixels) and is what stops a
|
|
456
|
+
// switched-off light from haunting the reservoir: the stale bright sample gets
|
|
457
|
+
// re-shaded (now dark) or, if its geometry moved, dropped. Direction selection
|
|
458
|
+
// happens HERE, before the single trace below, so the trace call site is shared. ---
|
|
459
|
+
uint pixHash = pcgHash(uint(px.x) * 2654435761u + uint(px.y) * 40503u + 1u);
|
|
460
|
+
bool validateFrame = uValidateInterval > 0 &&
|
|
461
|
+
((uint(uFrame) + pixHash) % uint(uValidateInterval)) == 0u;
|
|
462
|
+
|
|
463
|
+
vec3 wi;
|
|
464
|
+
bool doValidate = false;
|
|
465
|
+
float expectDist = 0.0;
|
|
466
|
+
if (validateFrame && histValid) {
|
|
467
|
+
vec3 dpv = hitPrev - P;
|
|
468
|
+
expectDist = length(dpv);
|
|
469
|
+
if (expectDist > 1e-5) {
|
|
470
|
+
wi = dpv / expectDist; // aim the candidate AT the stored hit
|
|
471
|
+
doValidate = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!doValidate) {
|
|
475
|
+
wi = cosineSampleHemisphere(N, rand2()); // fresh cosine-hemisphere GI bounce
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// --- the SINGLE candidate trace. Validation reuses this exact call site (no
|
|
479
|
+
// second trace is added to the shader); the trace already shades the hit. ---
|
|
480
|
+
vec3 hitPos;
|
|
481
|
+
vec3 hitNormal;
|
|
482
|
+
int nLight = doValidate ? VAL_NEE_SAMPLES : 1;
|
|
483
|
+
vec3 rad = traceRadianceGI(P + N * uEps, wi, nLight, hitPos, hitNormal);
|
|
484
|
+
// Match the inline firefly clamp, which is applied to indirect (= L_i) so
|
|
485
|
+
// the biased mean of the two paths agrees.
|
|
486
|
+
float rl = luminance(rad);
|
|
487
|
+
if (rl > uFireflyClamp) rad *= uFireflyClamp / rl;
|
|
488
|
+
|
|
489
|
+
float cosT = max(dot(N, wi), 0.0);
|
|
490
|
+
|
|
491
|
+
float wSum;
|
|
492
|
+
float M;
|
|
493
|
+
vec3 selRad;
|
|
494
|
+
vec3 selPos;
|
|
495
|
+
vec3 selNormal; // n_s of the selected sample (packed into .w)
|
|
496
|
+
bool killStore = false; // validation flags the STORED reservoir for reset
|
|
497
|
+
|
|
498
|
+
if (doValidate) {
|
|
499
|
+
// On a validation pixel there is NO fresh exploration candidate this frame
|
|
500
|
+
// (the ray was spent re-tracing the stored hit); the reservoir for THIS frame
|
|
501
|
+
// is the temporal history alone (the merge below re-adds it, histValid stays
|
|
502
|
+
// true so a valid pixel keeps showing its GI — no dropout, no darkening).
|
|
503
|
+
// Documented ~1/uValidateInterval exploration reduction (12.5% at interval 8).
|
|
504
|
+
wSum = 0.0;
|
|
505
|
+
M = 0.0;
|
|
506
|
+
selRad = vec3(0.0);
|
|
507
|
+
selPos = P + N;
|
|
508
|
+
selNormal = N;
|
|
509
|
+
|
|
510
|
+
// Validation is KILL-only, and the kill hits only the STORED reservoir (next
|
|
511
|
+
// frame), NOT this frame's displayed estimate. Re-shade the stored hit and, if
|
|
512
|
+
// it is stale, mark the stored reservoir for reset so this pixel's fresh
|
|
513
|
+
// candidate takes over next frame and the estimate tracks the current lighting.
|
|
514
|
+
// Two staleness signals:
|
|
515
|
+
// (1) GEOMETRY changed — the re-traced hit distance no longer matches the
|
|
516
|
+
// stored one (a nearer occluder appeared, geometry moved, or the ray
|
|
517
|
+
// missed; a miss puts hitPos far down the ray, so it trips this too).
|
|
518
|
+
// (2) the target WENT DARK — the re-shaded radiance collapsed below a small
|
|
519
|
+
// fraction (VAL_DARK_FRAC) of the stored one (a light switched off). This
|
|
520
|
+
// is THE fix for stale bounce light.
|
|
521
|
+
// Why kill-only, and why store-deferred: the reservoir stores a RIS-selected
|
|
522
|
+
// sample whose radiance is BRIGHT-biased with a compensating small W
|
|
523
|
+
// (gi_luminance = selRad*selCos/PI*W = wSum/(M*PI), so W is a purely geometric
|
|
524
|
+
// 1/pdf term). Overwriting that bright radiance with a single average re-shade
|
|
525
|
+
// sample, or rescaling W by the noisy luminance ratio, provably DARKENS the
|
|
526
|
+
// mean (bright-selected pHatOld in the denominator) — the originally-specified
|
|
527
|
+
// "refresh radiance + W *= clamp(pHatOld/pHatNew, 0.25, 4)" path was measured
|
|
528
|
+
// to darken static GI ~25% at interval 8, so it is NOT used. Killing (dropping
|
|
529
|
+
// the stale term so fresh candidates rebuild) is unbiased; deferring the kill
|
|
530
|
+
// to the STORE keeps the displayed frame equal to validation-off, so even a
|
|
531
|
+
// false kill from single-sample noise costs a little variance, not brightness.
|
|
532
|
+
float valTol = max(0.02 * expectDist, 4.0 * uEps);
|
|
533
|
+
float hitDist = length(hitPos - P);
|
|
534
|
+
bool geomChanged = abs(hitDist - expectDist) > valTol;
|
|
535
|
+
float pHatOld = luminance(radPrev) * cosT; // stored target at this pixel
|
|
536
|
+
float pHatNew = luminance(rad) * cosT; // re-shaded target (current light)
|
|
537
|
+
bool wentDark = pHatNew < VAL_DARK_FRAC * pHatOld;
|
|
538
|
+
// KILL (drop the stale temporal term so this pixel's fresh candidates rebuild
|
|
539
|
+
// from the current scene) on geometry change OR a collapse to near-black; leave
|
|
540
|
+
// a still-valid sample UNTOUCHED so a static scene does not drift. A switched-
|
|
541
|
+
// off light drives pHatNew -> 0 and trips wentDark. The kill only marks the
|
|
542
|
+
// STORE (killStore); the displayed frame still uses the merged history.
|
|
543
|
+
killStore = geomChanged || wentDark;
|
|
544
|
+
} else {
|
|
545
|
+
// --- normal fresh candidate: one cosine-hemisphere GI bounce, shaded inline.
|
|
546
|
+
float pHatFresh = luminance(rad) * cosT;
|
|
547
|
+
// w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
|
|
548
|
+
float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
|
|
549
|
+
wSum = wFresh;
|
|
550
|
+
M = 1.0;
|
|
551
|
+
selRad = rad;
|
|
552
|
+
selPos = hitPos;
|
|
553
|
+
selNormal = hitNormal;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- temporal reuse: merge the (possibly radiance-refreshed, or killed)
|
|
557
|
+
// history. When the validation above KILLED the sample, histValid is false and
|
|
558
|
+
// the merge is skipped -> the reservoir stays empty this frame. ---
|
|
559
|
+
// emaPrevGi: last frame's resolved GI, RECONSTRUCTED from the previous
|
|
560
|
+
// reservoir (all inputs already bound — no extra sampler). Reservoirs persist
|
|
561
|
+
// WHICH sample matters, not a variance average: near emitters many samples
|
|
562
|
+
// are legitimately bright and the per-frame selection churn reads as
|
|
563
|
+
// flickering fireflies (the inline GI path hid the same variance inside the
|
|
564
|
+
// lighting pass's deep EMA). The resolve below blends against this
|
|
565
|
+
// reconstruction to restore that smoothing.
|
|
566
|
+
vec3 emaPrevGi = vec3(0.0);
|
|
567
|
+
bool emaPrevOk = false;
|
|
568
|
+
if (histValid) {
|
|
569
|
+
// Re-evaluate the target at the CURRENT surface (reconnect at the stored hit
|
|
570
|
+
// point). Same world point P (validated), so no Jacobian.
|
|
571
|
+
vec3 dp = hitPrev - P;
|
|
572
|
+
float dl = length(dp);
|
|
573
|
+
float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
|
|
574
|
+
float pHatPrev = luminance(radPrev) * cosPrev;
|
|
575
|
+
float Mc = min(Mprev, uMCap);
|
|
576
|
+
// Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
|
|
577
|
+
float w = pHatPrev * Wprev * Mc;
|
|
578
|
+
wSum += w;
|
|
579
|
+
M += Mc;
|
|
580
|
+
if (w > 0.0 && rand() * wSum < w) {
|
|
581
|
+
selRad = radPrev;
|
|
582
|
+
selPos = hitPrev;
|
|
583
|
+
selNormal = nPrev;
|
|
584
|
+
}
|
|
585
|
+
// Reconstruct last frame's resolve from this same sample (same W cap
|
|
586
|
+
// and clamp as the live resolve, for a like-for-like EMA partner).
|
|
587
|
+
vec3 pg = radPrev * (cosPrev / PI) * min(Wprev, 32.0);
|
|
588
|
+
float pgl = luminance(pg);
|
|
589
|
+
if (pgl > uFireflyClamp) pg *= uFireflyClamp / pgl;
|
|
590
|
+
if (!any(isnan(pg)) && !any(isinf(pg))) {
|
|
591
|
+
emaPrevGi = pg;
|
|
592
|
+
emaPrevOk = true;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Snapshot the TEMPORAL-only reservoir. This — not the spatially-merged one — is
|
|
597
|
+
// what gets STORED as history, exactly as v1 did. Spatial reuse below is terminal
|
|
598
|
+
// (it only sharpens THIS frame's resolved GI output); it is deliberately NOT fed
|
|
599
|
+
// back into the stored reservoir. The reconnection shift carries a small target
|
|
600
|
+
// -function bias, and the high default M-cap's temporal feedback would amplify it
|
|
601
|
+
// by ~1/(1-M/(M+1)) ≈ (M+1)x, so storing the merged reservoir makes the GI drift
|
|
602
|
+
// frame-over-frame (dark in grazing views, blown out in steep ones). Keeping the
|
|
603
|
+
// history temporal-only — the pattern the shipped direct-light ReSTIR uses, where
|
|
604
|
+
// "history feeds back from the TEMPORAL stage only" — keeps it stable and
|
|
605
|
+
// unbiased while still delivering the per-frame spatial variance reduction into
|
|
606
|
+
// the denoiser. taps==0 leaves selRad/M/wSum untouched, so this is a no-op there.
|
|
607
|
+
vec3 selRadT = selRad; vec3 selPosT = selPos; vec3 selNormalT = selNormal;
|
|
608
|
+
float wSumT = wSum; float MT = M;
|
|
609
|
+
|
|
610
|
+
// --- spatial reuse (v2): fused spatiotemporal, streamed RIS over K taps of the
|
|
611
|
+
// PREVIOUS frame's reservoir textures around the reprojected UV. Each adopted
|
|
612
|
+
// neighbour sample S = (hit x_s, hit normal n_s, radiance L_s) is reweighted by
|
|
613
|
+
// the reconnection Jacobian |J| = (cosPhi_q/cosPhi_r)*(d_r^2/d_q^2). x_q is this
|
|
614
|
+
// pixel's primary point; x_r is the NEIGHBOUR's primary point read from the
|
|
615
|
+
// previous frame's gWorldPos. A final visibility ray (below) prevents leaks. ---
|
|
616
|
+
if (haveReproj && uSpatialTaps > 0) {
|
|
617
|
+
vec2 texel = 1.0 / vec2(textureSize(uPrevResPos, 0));
|
|
618
|
+
for (int k = 0; k < 4; k++) {
|
|
619
|
+
if (k >= uSpatialTaps) break;
|
|
620
|
+
// Offset: radius uniform in [4, 20] lighting-res pixels, angle from RNG,
|
|
621
|
+
// decorrelated per frame (gSeed carries uFrame; blue noise is frame-shifted).
|
|
622
|
+
float ang = rand() * 2.0 * PI;
|
|
623
|
+
float rad_px = mix(4.0, 20.0, rand());
|
|
624
|
+
vec2 nUv = prevUv + vec2(cos(ang), sin(ang)) * rad_px * texel;
|
|
625
|
+
// (a) neighbour uv in [0,1].
|
|
626
|
+
if (nUv.x < 0.0 || nUv.x > 1.0 || nUv.y < 0.0 || nUv.y > 1.0) continue;
|
|
627
|
+
// (b) plane-distance validation of the neighbour's PREVIOUS primary point
|
|
628
|
+
// against q's plane (same tolerance as the temporal validation).
|
|
629
|
+
vec4 nPrimary = texture(uPrevGWorldPos, nUv); // x_r + validFlag
|
|
630
|
+
if (nPrimary.w < 0.5 || abs(dot(P - nPrimary.xyz, N)) >= tol) continue;
|
|
631
|
+
vec4 nhp = texture(uPrevResPos, nUv); // x_s + packed(M_r, n_s)
|
|
632
|
+
vec4 nhr = texture(uPrevResRad, nUv); // L_s + W_r
|
|
633
|
+
float Mr; vec3 nS;
|
|
634
|
+
unpackMN(nhp.w, Mr, nS);
|
|
635
|
+
float Wr = nhr.w;
|
|
636
|
+
// (c) skip reservoirs with M == 0 or W <= 0.
|
|
637
|
+
if (Mr <= 0.0 || Wr <= 0.0) continue;
|
|
638
|
+
vec3 xS = nhp.xyz;
|
|
639
|
+
vec3 Ls = nhr.rgb;
|
|
640
|
+
vec3 xR = nPrimary.xyz;
|
|
641
|
+
|
|
642
|
+
// Reconnection Jacobian for q adopting the neighbour's sample.
|
|
643
|
+
float dq = length(xS - P);
|
|
644
|
+
float dr = length(xS - xR);
|
|
645
|
+
if (dq < 1e-5 || dr < 1e-5) continue;
|
|
646
|
+
float cosPhiQ = max(dot(nS, normalize(P - xS)), 1e-4);
|
|
647
|
+
float cosPhiR = max(dot(nS, normalize(xR - xS)), 1e-4);
|
|
648
|
+
float J = (cosPhiQ / cosPhiR) * (dr * dr) / (dq * dq);
|
|
649
|
+
J = clamp(J, 0.1, 10.0); // grazing-angle firefly guard
|
|
650
|
+
|
|
651
|
+
// Target function at q (same shape the pass already uses).
|
|
652
|
+
float cosQ = max(dot(N, normalize(xS - P)), 0.0);
|
|
653
|
+
float pHatQ = luminance(Ls) * cosQ;
|
|
654
|
+
// Invalid-shift reject: if the neighbour's hit x_s lies below q's shading
|
|
655
|
+
// hemisphere (cosQ == 0) or carries no radiance, the reconnected target is
|
|
656
|
+
// zero — the shift could never have produced this sample at q, so it must
|
|
657
|
+
// NOT add confidence weight. Skipping the whole tap (not just its w) keeps
|
|
658
|
+
// the M normalization honest; adding Mc here while w == 0 would inflate M
|
|
659
|
+
// without wSum and systematically darken the GI. (The temporal path never
|
|
660
|
+
// trips this — same surface point, always a valid, non-zero target.)
|
|
661
|
+
if (pHatQ <= 0.0) continue;
|
|
662
|
+
float Mc = min(Mr, uMCap);
|
|
663
|
+
float w = pHatQ * J * Wr * Mc;
|
|
664
|
+
wSum += w;
|
|
665
|
+
M += Mc;
|
|
666
|
+
if (w > 0.0 && rand() * wSum < w) {
|
|
667
|
+
selRad = Ls;
|
|
668
|
+
selPos = xS;
|
|
669
|
+
selNormal = nS;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// --- finalize OUTPUT: recompute p_hat(selected) at this surface from the
|
|
675
|
+
// SPATIALLY-merged reservoir, form W, resolve the GI for this frame. ---
|
|
676
|
+
vec3 sd = selPos - P;
|
|
677
|
+
float sl = length(sd);
|
|
678
|
+
float selCos = sl > 1e-5 ? max(dot(N, sd / sl), 0.0) : 0.0;
|
|
679
|
+
float pHatSel = luminance(selRad) * selCos;
|
|
680
|
+
float W = (M > 0.0 && pHatSel > 0.0) ? wSum / (M * pHatSel) : 0.0;
|
|
681
|
+
|
|
682
|
+
// --- final visibility (mandatory): ONE any-hit occlusion ray from x_q toward
|
|
683
|
+
// x_s. If the reconnection point is blocked, drop THIS FRAME's OUTPUT estimate
|
|
684
|
+
// (Wout=0) — this is what stops reused samples leaking light through walls.
|
|
685
|
+
// occluded() already trims 2*eps off maxDist to avoid self-intersecting the far
|
|
686
|
+
// surface. The STORED reservoir keeps the un-occluded W: the sample is real and
|
|
687
|
+
// may be visible to a neighbour, so each pixel re-tests visibility from its own
|
|
688
|
+
// position. Storing the zeroed W instead would bleed energy out of the reservoir
|
|
689
|
+
// over frames (spatial samples fail visibility more often than temporal ones,
|
|
690
|
+
// and the zero would propagate to neighbours), darkening the GI. ---
|
|
691
|
+
// Gated on uSpatialTaps > 0: the temporal-only path reuses at the SAME surface
|
|
692
|
+
// point, whose sample is visible by construction, so v1 (taps==0) needs no
|
|
693
|
+
// occlusion ray and stays byte-identical. Spatial reconnections to a neighbour's
|
|
694
|
+
// hit point are the ones that can pierce a wall, so they get the visibility test.
|
|
695
|
+
float Wout = W;
|
|
696
|
+
if (uSpatialTaps > 0 && Wout > 0.0 && sl > 1e-5) {
|
|
697
|
+
if (occluded(P + N * uEps, sd / sl, sl)) Wout = 0.0;
|
|
698
|
+
}
|
|
699
|
+
// W cap: W ~ pi/cos for the cosine source pdf, so values beyond ~32 mean the
|
|
700
|
+
// recomputed p_hat(selected) collapsed this frame (grazing cos after a camera
|
|
701
|
+
// or normal change) while wSum still carries past-frame magnitudes — the
|
|
702
|
+
// classic reservoir firefly. The inline GI path hid the same spikes inside
|
|
703
|
+
// its deep temporal EMA; this resolve has no EMA downstream, so the spike
|
|
704
|
+
// would live on screen for a whole frame (visibly on Metal/iOS). Capping W
|
|
705
|
+
// trusts reconnection angles down to cos ~ 0.1 and slightly darkens grazing
|
|
706
|
+
// GI beyond that — the standard ReSTIR trade.
|
|
707
|
+
Wout = min(Wout, 32.0);
|
|
708
|
+
|
|
709
|
+
vec3 gi = selRad * (selCos / PI) * Wout; // demodulated indirect irradiance
|
|
710
|
+
// Confidence-weighted firefly clamp: a young reservoir (M small — fresh
|
|
711
|
+
// pixels under camera motion, where the resolve EMA has no partner yet) is
|
|
712
|
+
// one raw sample, and at the full clamp it reads as motion sparkle. Tighten
|
|
713
|
+
// the cap for low-M pixels and relax it to the inline-path clamp as
|
|
714
|
+
// confidence grows; converged pixels are untouched. Trades a few frames of
|
|
715
|
+
// slightly dim GI on freshly revealed surfaces for a steady image in motion.
|
|
716
|
+
float conf = clamp(M / uMCap, 0.0, 1.0);
|
|
717
|
+
float cap = uFireflyClamp * mix(0.3, 1.0, conf);
|
|
718
|
+
float gil = luminance(gi);
|
|
719
|
+
if (gil > cap) gi *= cap / gil;
|
|
720
|
+
if (any(isnan(gi)) || any(isinf(gi))) gi = vec3(0.0);
|
|
721
|
+
// Resolve EMA (see the emaPrevGi note above): ~5-frame effective average.
|
|
722
|
+
// Cuts selection-churn flicker near emitters ~5x for ~5 frames of lag.
|
|
723
|
+
if (emaPrevOk) gi = mix(emaPrevGi, gi, 0.15);
|
|
724
|
+
|
|
725
|
+
// --- STORE the TEMPORAL-only reservoir as history (see snapshot note above).
|
|
726
|
+
// Resolve its own W from the temporal-merged wSum/M so the stored W is valid for
|
|
727
|
+
// next frame's temporal AND spatial reuse. For taps==0 this is exactly the v1
|
|
728
|
+
// reservoir. A NaN sample is scrubbed so it can't poison the history. ---
|
|
729
|
+
vec3 sdT = selPosT - P;
|
|
730
|
+
float slT = length(sdT);
|
|
731
|
+
float selCosT = slT > 1e-5 ? max(dot(N, sdT / slT), 0.0) : 0.0;
|
|
732
|
+
float pHatSelT = luminance(selRadT) * selCosT;
|
|
733
|
+
float WT = (MT > 0.0 && pHatSelT > 0.0) ? wSumT / (MT * pHatSelT) : 0.0;
|
|
734
|
+
if (any(isnan(selRadT)) || any(isinf(selRadT))) { selRadT = vec3(0.0); WT = 0.0; }
|
|
735
|
+
|
|
736
|
+
// Validation store policy. The DISPLAYED gi above always used the merged history
|
|
737
|
+
// (no dropout). The STORED reservoir, however, must be handled so validation
|
|
738
|
+
// does not shift the temporal fixed point:
|
|
739
|
+
// - KEEP (valid sample): pass the previous reservoir through UNCHANGED. A
|
|
740
|
+
// validation frame carries no fresh candidate, so RE-DERIVING and re-storing
|
|
741
|
+
// the merged reservoir (M -> min(Mprev,cap), W recomputed) perturbs the
|
|
742
|
+
// recursion and was measured to darken the static estimate ~13-16%. Writing
|
|
743
|
+
// back the exact (hitPrev, radPrev, Wprev, Mprev) leaves the fixed point
|
|
744
|
+
// identical to validation-off, so a static scene does not drift.
|
|
745
|
+
// - KILL (stale sample): reset to empty so next frame's fresh cosine candidate
|
|
746
|
+
// rebuilds and the estimate tracks the current lighting.
|
|
747
|
+
if (doValidate) {
|
|
748
|
+
if (killStore) {
|
|
749
|
+
selPosT = P + N; selRadT = vec3(0.0); selNormalT = N; MT = 0.0; WT = 0.0;
|
|
750
|
+
} else {
|
|
751
|
+
// KEEP: write the previous reservoir back verbatim (hitPrev, radPrev, nPrev,
|
|
752
|
+
// Mprev, Wprev). A validation frame adds no fresh candidate, so re-deriving
|
|
753
|
+
// and re-storing the merged reservoir (M -> min(Mprev,cap), W recomputed)
|
|
754
|
+
// perturbs the temporal recursion and was measured to darken the static
|
|
755
|
+
// estimate ~13-16%; a verbatim write-back keeps the fixed point identical to
|
|
756
|
+
// validation-off.
|
|
757
|
+
selPosT = hitPrev; selRadT = radPrev; selNormalT = nPrev; MT = Mprev; WT = Wprev;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
outResPos = vec4(selPosT, packMN(MT, selNormalT));
|
|
762
|
+
outResRad = vec4(selRadT, WT);
|
|
763
|
+
outGI = vec4(gi, 1.0);
|
|
764
|
+
}
|
|
765
|
+
`;
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* EXPERIMENTAL ReSTIR GI (v2, fused spatiotemporal). Per-pixel reservoirs reuse
|
|
769
|
+
* the 1-bounce GI sample across frames at the reprojected same-surface point,
|
|
770
|
+
* then take K spatial taps (`restirGISpatialTaps`, default 2; 0 = exact v1
|
|
771
|
+
* temporal-only behaviour) of the previous frame's reservoirs around that UV.
|
|
772
|
+
* Runs at lighting resolution with its OWN sampler budget (16: 8 BVH + 2 attr +
|
|
773
|
+
* 1 scene-data + gWorldPos + gNormalMetal + prevGWorldPos + 2 reservoir history),
|
|
774
|
+
* independent of the lighting pass. render() returns a resolved, demodulated GI
|
|
775
|
+
* irradiance texture whose mean matches the inline GI path (see main()).
|
|
776
|
+
*
|
|
777
|
+
* Spatial reuse applies the reconnection solid-angle->area Jacobian per adopted
|
|
778
|
+
* neighbour and a final visibility ray at the reconnection point (anti-leak); the
|
|
779
|
+
* hit normal n_s it needs is bit-packed into the reservoir-position .w alongside
|
|
780
|
+
* M (the pass is at the 16-sampler ceiling and can add none). Taps are gated by
|
|
781
|
+
* uv bounds, a plane-distance check of the neighbour's previous primary point,
|
|
782
|
+
* and a non-empty reservoir, so it stays unbiased and does not leak.
|
|
783
|
+
*
|
|
784
|
+
* `restirGIValidate` (default 8, 0 = off) adds reservoir-sample validation: a
|
|
785
|
+
* rotating 1-in-N subset of pixels re-aims its single candidate ray AT the stored
|
|
786
|
+
* reservoir hit (instead of a fresh cosine bounce) and re-shades it; the reservoir
|
|
787
|
+
* is KILLED (so fresh candidates rebuild) when the geometry moved or the re-shaded
|
|
788
|
+
* target collapsed to near-black (a light switched off), and left untouched
|
|
789
|
+
* otherwise, with the kill deferred to the store so a valid pixel never drops out.
|
|
790
|
+
* It reuses the existing candidate trace (no extra bounce rays, no extra samplers)
|
|
791
|
+
* and is the fix for stale bounce light: a switched-off light stops haunting the
|
|
792
|
+
* reservoir instead of fading slowly. 0 is byte-identical to the pre-feature path.
|
|
793
|
+
*/
|
|
794
|
+
export class GIReservoirPass {
|
|
795
|
+
constructor(width, height) {
|
|
796
|
+
this.targetA = this._makeTarget(width, height);
|
|
797
|
+
this.targetB = this._makeTarget(width, height);
|
|
798
|
+
|
|
799
|
+
this.material = new THREE.ShaderMaterial({
|
|
800
|
+
glslVersion: THREE.GLSL3,
|
|
801
|
+
vertexShader: fullscreenVert,
|
|
802
|
+
fragmentShader: giFrag,
|
|
803
|
+
uniforms: {
|
|
804
|
+
bvhStatic: { value: null },
|
|
805
|
+
bvhDynamic: { value: null },
|
|
806
|
+
uHasDynamic: { value: false },
|
|
807
|
+
uAttrStatic: { value: null },
|
|
808
|
+
uAttrDynamic: { value: null },
|
|
809
|
+
uMaterialsTex: { value: null },
|
|
810
|
+
uGWorldPos: { value: null },
|
|
811
|
+
uGNormalMetal: { value: null },
|
|
812
|
+
uPrevGWorldPos: { value: null },
|
|
813
|
+
uPrevResPos: { value: null },
|
|
814
|
+
uPrevResRad: { value: null },
|
|
815
|
+
uPrevViewProj: { value: new THREE.Matrix4() },
|
|
816
|
+
uLightPosType: { value: [] },
|
|
817
|
+
uLightColorRadius: { value: [] },
|
|
818
|
+
uLightDirCone: { value: [] },
|
|
819
|
+
uLightCount: { value: 0 },
|
|
820
|
+
uEmissiveCount: { value: 0 },
|
|
821
|
+
uEmissiveCDF: { value: true },
|
|
822
|
+
uCameraPos: { value: new THREE.Vector3() },
|
|
823
|
+
uFrame: { value: 0 },
|
|
824
|
+
uEps: { value: 1e-3 },
|
|
825
|
+
uFireflyClamp: { value: 4.0 },
|
|
826
|
+
uMCap: { value: 20 },
|
|
827
|
+
uSpatialTaps: { value: 2 },
|
|
828
|
+
uValidateInterval: { value: 8 },
|
|
829
|
+
uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
|
|
830
|
+
uEnvIntensity: { value: 1.0 },
|
|
831
|
+
uSkyEnabled: { value: false },
|
|
832
|
+
uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
|
|
833
|
+
uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
|
|
834
|
+
uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
|
|
835
|
+
uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
|
|
836
|
+
uSkyIntensity: { value: 1.0 },
|
|
837
|
+
},
|
|
838
|
+
depthTest: false,
|
|
839
|
+
depthWrite: false,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
this.scene = new THREE.Scene();
|
|
843
|
+
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
844
|
+
this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
|
|
845
|
+
this.quad.frustumCulled = false;
|
|
846
|
+
this.scene.add(this.quad);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
_makeTarget(width, height) {
|
|
850
|
+
// Three fp32 attachments, NearestFilter: the reservoir stores a hit POSITION
|
|
851
|
+
// (needs fp32) + M + radiance + W, which must never be interpolated. All fp32
|
|
852
|
+
// (rather than a mixed fp32/fp16 layout) sidesteps drivers that reject mixed
|
|
853
|
+
// MRT precision; the resolved GI (attachment 2) is read 1:1 by the denoise.
|
|
854
|
+
const t = new THREE.WebGLMultipleRenderTargets(width, height, 3, {
|
|
855
|
+
minFilter: THREE.NearestFilter,
|
|
856
|
+
magFilter: THREE.NearestFilter,
|
|
857
|
+
format: THREE.RGBAFormat,
|
|
858
|
+
type: THREE.FloatType,
|
|
859
|
+
depthBuffer: false,
|
|
860
|
+
stencilBuffer: false,
|
|
861
|
+
});
|
|
862
|
+
for (const tex of t.texture) tex.generateMipmaps = false;
|
|
863
|
+
return t;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
setCompiledScene(compiled) {
|
|
867
|
+
const u = this.material.uniforms;
|
|
868
|
+
u.bvhStatic.value = compiled.staticBvhUniform;
|
|
869
|
+
u.bvhDynamic.value = compiled.dynamicBvhUniform;
|
|
870
|
+
u.uHasDynamic.value = compiled.hasDynamic;
|
|
871
|
+
u.uAttrStatic.value = compiled.staticAttrTex;
|
|
872
|
+
u.uAttrDynamic.value = compiled.dynamicAttrTex;
|
|
873
|
+
u.uMaterialsTex.value = compiled.materialsTex;
|
|
874
|
+
u.uLightPosType.value = compiled.lightPosType;
|
|
875
|
+
u.uLightColorRadius.value = compiled.lightColorRadius;
|
|
876
|
+
u.uLightDirCone.value = compiled.lightDirCone;
|
|
877
|
+
u.uLightCount.value = compiled.lightCount;
|
|
878
|
+
u.uEmissiveCount.value = compiled.emissiveTriCount;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Emissive candidates follow the emissiveNEE toggle (set per frame). */
|
|
882
|
+
setEmissiveCount(count) {
|
|
883
|
+
this.material.uniforms.uEmissiveCount.value = count;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
clearHistory(renderer) {
|
|
887
|
+
const prev = renderer.getRenderTarget();
|
|
888
|
+
renderer.setClearColor(0x000000, 0);
|
|
889
|
+
for (const t of [this.targetA, this.targetB]) {
|
|
890
|
+
renderer.setRenderTarget(t);
|
|
891
|
+
renderer.clear(true, false, false);
|
|
892
|
+
}
|
|
893
|
+
renderer.setRenderTarget(prev);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
setSize(width, height) {
|
|
897
|
+
this.targetA.setSize(width, height);
|
|
898
|
+
this.targetB.setSize(width, height);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Renders into targetA (reading targetB as reservoir history), swaps, and
|
|
903
|
+
* returns the resolved GI irradiance texture (attachment 2). `params` carries
|
|
904
|
+
* the per-frame lighting state (sky/env, firefly clamp, emissive CDF, M-cap).
|
|
905
|
+
*/
|
|
906
|
+
render(renderer, gbuffer, prevViewProj, cameraPos, frame, eps, params) {
|
|
907
|
+
const u = this.material.uniforms;
|
|
908
|
+
u.uGWorldPos.value = gbuffer.worldPos;
|
|
909
|
+
u.uGNormalMetal.value = gbuffer.normalMetal;
|
|
910
|
+
u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
|
|
911
|
+
u.uPrevResPos.value = this.targetB.texture[0];
|
|
912
|
+
u.uPrevResRad.value = this.targetB.texture[1];
|
|
913
|
+
u.uPrevViewProj.value.copy(prevViewProj);
|
|
914
|
+
u.uCameraPos.value.copy(cameraPos);
|
|
915
|
+
u.uFrame.value = frame;
|
|
916
|
+
u.uEps.value = eps;
|
|
917
|
+
u.uFireflyClamp.value = params.fireflyClamp;
|
|
918
|
+
u.uMCap.value = params.mCap;
|
|
919
|
+
u.uSpatialTaps.value = params.spatialTaps;
|
|
920
|
+
u.uValidateInterval.value = params.validateInterval;
|
|
921
|
+
u.uEmissiveCDF.value = params.emissiveCDF;
|
|
922
|
+
u.uEnvColor.value.copy(params.envColor);
|
|
923
|
+
u.uEnvIntensity.value = params.envIntensity;
|
|
924
|
+
u.uSkyEnabled.value = params.skyEnabled;
|
|
925
|
+
u.uSunDir.value.copy(params.sunDir);
|
|
926
|
+
u.uSunColor.value.copy(params.sunColor);
|
|
927
|
+
u.uSkyZenith.value.copy(params.skyZenith);
|
|
928
|
+
u.uSkyHorizon.value.copy(params.skyHorizon);
|
|
929
|
+
u.uSkyIntensity.value = params.skyIntensity;
|
|
930
|
+
|
|
931
|
+
renderer.setRenderTarget(this.targetA);
|
|
932
|
+
renderer.render(this.scene, this.camera);
|
|
933
|
+
renderer.setRenderTarget(null);
|
|
934
|
+
|
|
935
|
+
const out = this.targetA;
|
|
936
|
+
[this.targetA, this.targetB] = [this.targetB, this.targetA];
|
|
937
|
+
return out.texture[2];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
dispose() {
|
|
941
|
+
this.targetA.dispose();
|
|
942
|
+
this.targetB.dispose();
|
|
943
|
+
this.material.dispose();
|
|
944
|
+
this.quad.geometry.dispose();
|
|
945
|
+
}
|
|
946
|
+
}
|