three-realtime-rt 0.4.0 → 0.5.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.
@@ -0,0 +1,572 @@
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 + M (fp32), [1] reservoir radiance + W,
43
+ // [2] resolved demodulated GI irradiance (consumed by the denoise add).
44
+ layout(location = 0) out vec4 outResPos;
45
+ layout(location = 1) out vec4 outResRad;
46
+ layout(location = 2) out vec4 outGI;
47
+
48
+ in vec2 vUv;
49
+
50
+ // Two-level BVH + per-vertex attribute textures (normal.xyz + materialIndex.w),
51
+ // exactly as RTLightingPass binds them. 8 samplers (4 per BVH struct) + 2 attr.
52
+ uniform BVH bvhStatic;
53
+ uniform BVH bvhDynamic;
54
+ uniform bool uHasDynamic;
55
+ uniform sampler2D uAttrStatic;
56
+ uniform sampler2D uAttrDynamic;
57
+ uniform sampler2D uMaterialsTex; // materials + emissive NEE tris + blue noise + power CDF
58
+
59
+ uniform sampler2D uGWorldPos;
60
+ uniform sampler2D uGNormalMetal;
61
+
62
+ // Temporal reuse: reproject through last frame's G-buffer (plane-distance
63
+ // validation, same as the lighting pass) and pull last frame's reservoir.
64
+ uniform sampler2D uPrevGWorldPos;
65
+ uniform sampler2D uPrevResPos; // history attachment 0: hitPos.xyz + M
66
+ uniform sampler2D uPrevResRad; // history attachment 1: radiance.rgb + W
67
+ uniform mat4 uPrevViewProj;
68
+
69
+ uniform vec4 uLightPosType[MAX_LIGHTS];
70
+ uniform vec4 uLightColorRadius[MAX_LIGHTS];
71
+ uniform vec4 uLightDirCone[MAX_LIGHTS];
72
+ uniform int uLightCount;
73
+ uniform int uEmissiveCount;
74
+ uniform bool uEmissiveCDF;
75
+
76
+ uniform vec3 uCameraPos;
77
+ uniform float uFrame;
78
+ uniform float uEps;
79
+ uniform float uFireflyClamp;
80
+ uniform float uMCap; // temporal M-cap (staleness limit)
81
+
82
+ uniform vec3 uEnvColor;
83
+ uniform float uEnvIntensity;
84
+ uniform bool uSkyEnabled;
85
+ uniform vec3 uSunDir;
86
+ uniform vec3 uSunColor;
87
+ uniform vec3 uSkyZenith;
88
+ uniform vec3 uSkyHorizon;
89
+ uniform float uSkyIntensity;
90
+
91
+ // ---------- RNG (identical scheme to RTLightingPass) ----------
92
+ uint gSeed;
93
+ int gBnDim;
94
+ vec4 gBlueNoise;
95
+ uint pcgHash(uint s) {
96
+ uint state = s * 747796405u + 2891336453u;
97
+ uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
98
+ return (word >> 22u) ^ word;
99
+ }
100
+ float rand() {
101
+ if (gBnDim < 4) {
102
+ float v = gBlueNoise[gBnDim];
103
+ gBnDim++;
104
+ return v;
105
+ }
106
+ gSeed = pcgHash(gSeed);
107
+ return float(gSeed) * (1.0 / 4294967296.0);
108
+ }
109
+ vec2 rand2() { return vec2(rand(), rand()); }
110
+
111
+ vec4 fetchBlueNoise() {
112
+ ivec2 p = ivec2(gl_FragCoord.xy) & 63;
113
+ vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
114
+ vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
115
+ return fract(bn + shift);
116
+ }
117
+
118
+ float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
119
+
120
+ void orthoBasis(vec3 n, out vec3 t, out vec3 b) {
121
+ float s = n.z >= 0.0 ? 1.0 : -1.0;
122
+ float a = -1.0 / (s + n.z);
123
+ float m = n.x * n.y * a;
124
+ t = vec3(1.0 + s * n.x * n.x * a, s * m, -s * n.x);
125
+ b = vec3(m, s + n.y * n.y * a, -n.y);
126
+ }
127
+ vec3 cosineSampleHemisphere(vec3 n, vec2 u) {
128
+ float a = 2.0 * PI * u.x;
129
+ float r = sqrt(u.y);
130
+ vec3 t, b;
131
+ orthoBasis(n, t, b);
132
+ return normalize(t * (r * cos(a)) + b * (r * sin(a)) + n * sqrt(max(0.0, 1.0 - u.y)));
133
+ }
134
+ vec3 randUnitVector() {
135
+ vec2 u = rand2();
136
+ float z = u.x * 2.0 - 1.0;
137
+ float a = u.y * 2.0 * PI;
138
+ float r = sqrt(max(0.0, 1.0 - z * z));
139
+ return vec3(r * cos(a), r * sin(a), z);
140
+ }
141
+
142
+ // ---------- two-level BVH helpers (copied verbatim) ----------
143
+ bool traceBoth(vec3 ro, vec3 rd, out uvec4 fi, out vec3 bary, out float dist, out bool isDyn) {
144
+ uvec4 fiS; vec3 fnS; vec3 bcS; float sideS; float distS;
145
+ bool hitS = bvhIntersectFirstHit(bvhStatic, ro, rd, fiS, fnS, bcS, sideS, distS);
146
+ uvec4 fiD; vec3 fnD; vec3 bcD; float sideD; float distD;
147
+ bool hitD = uHasDynamic && bvhIntersectFirstHit(bvhDynamic, ro, rd, fiD, fnD, bcD, sideD, distD);
148
+ if (hitS && (!hitD || distS <= distD)) { fi = fiS; bary = bcS; dist = distS; isDyn = false; return true; }
149
+ if (hitD) { fi = fiD; bary = bcD; dist = distD; isDyn = true; return true; }
150
+ return false;
151
+ }
152
+ bool occluded(vec3 ro, vec3 rd, float maxDist) {
153
+ if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
154
+ if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
155
+ return false;
156
+ }
157
+
158
+ void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
159
+ out vec3 emissive, out float metalness) {
160
+ int mi = int(round(matIndex)) * 2;
161
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(mi, 0), 0);
162
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(mi + 1, 0), 0);
163
+ albedo = t0.rgb;
164
+ roughness = t0.a;
165
+ emissive = t1.rgb;
166
+ metalness = t1.a;
167
+ }
168
+
169
+ // ---------- one-light NEE at a GI-bounce hit (specular dropped) ----------
170
+ float spotFalloff(int i, vec3 lightToP) {
171
+ vec4 posType = uLightPosType[i];
172
+ if (posType.w < 1.5) return 1.0;
173
+ vec4 dc = uLightDirCone[i];
174
+ return smoothstep(dc.w, posType.w - 2.0, dot(dc.xyz, lightToP));
175
+ }
176
+
177
+ vec3 lightContribution(int i, vec3 P, vec3 N) {
178
+ vec4 posType = uLightPosType[i];
179
+ vec4 colRad = uLightColorRadius[i];
180
+ vec3 L;
181
+ float dist2 = 1.0;
182
+ float maxDist = 1e7;
183
+ float cone = 1.0;
184
+ if (posType.w < 0.5 || posType.w >= 1.5) {
185
+ vec3 lp = posType.xyz + randUnitVector() * colRad.w;
186
+ vec3 d = lp - P;
187
+ float dl = length(d);
188
+ if (dl < 1e-5) return vec3(0.0);
189
+ L = d / dl;
190
+ dist2 = dl * dl;
191
+ maxDist = dl;
192
+ cone = spotFalloff(i, -L);
193
+ if (cone <= 0.0) return vec3(0.0);
194
+ } else {
195
+ L = normalize(-posType.xyz + randUnitVector() * colRad.w);
196
+ dist2 = 1.0;
197
+ }
198
+ float NdotL = dot(N, L);
199
+ if (NdotL <= 0.0) return vec3(0.0);
200
+ if (occluded(P + N * uEps, L, maxDist)) return vec3(0.0);
201
+ return colRad.rgb * (cone / dist2) * NdotL;
202
+ }
203
+
204
+ vec3 sampleOneLight(vec3 P, vec3 N) {
205
+ if (uLightCount == 0) return vec3(0.0);
206
+ int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
207
+ return lightContribution(i, P, N) * float(uLightCount);
208
+ }
209
+
210
+ vec3 sampleEmissiveTri(vec3 P, vec3 N) {
211
+ if (uEmissiveCount == 0) return vec3(0.0);
212
+ int idx;
213
+ float invProb;
214
+ if (uEmissiveCDF) {
215
+ float u = rand();
216
+ int lo = 0;
217
+ int hi = uEmissiveCount - 1;
218
+ for (int s = 0; s < 8; s++) {
219
+ if (lo >= hi) break;
220
+ int mid = (lo + hi) >> 1;
221
+ if (u > texelFetch(uMaterialsTex, ivec2(mid, 66), 0).x) lo = mid + 1;
222
+ else hi = mid;
223
+ }
224
+ idx = lo;
225
+ invProb = 1.0 / max(texelFetch(uMaterialsTex, ivec2(idx, 66), 0).y, 1e-8);
226
+ } else {
227
+ idx = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1);
228
+ invProb = float(uEmissiveCount);
229
+ }
230
+ int i = idx * 4;
231
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0);
232
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0);
233
+ vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0);
234
+ vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0);
235
+ vec2 u = rand2();
236
+ if (u.x + u.y > 1.0) u = 1.0 - u;
237
+ vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
238
+ vec3 d = lp - P;
239
+ float d2 = dot(d, d);
240
+ float dist = sqrt(d2);
241
+ if (dist < 1e-4) return vec3(0.0);
242
+ vec3 wi = d / dist;
243
+ float cosS = dot(N, wi);
244
+ float cosL = abs(dot(t3.xyz, wi));
245
+ if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
246
+ if (occluded(P + N * uEps, wi, dist)) return vec3(0.0);
247
+ vec3 e = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * invProb * t0.w / max(d2, 1e-6));
248
+ float eLum = dot(e, vec3(0.299, 0.587, 0.114));
249
+ float eCap = uFireflyClamp * 2.0;
250
+ if (eLum > eCap) e *= eCap / eLum;
251
+ return e;
252
+ }
253
+
254
+ vec3 sampleOneAny(vec3 P, vec3 N) {
255
+ bool hasL = uLightCount > 0;
256
+ bool hasE = uEmissiveCount > 0;
257
+ if (hasL && hasE) {
258
+ return rand() < 0.5
259
+ ? sampleOneLight(P, N) * 2.0
260
+ : sampleEmissiveTri(P, N) * 2.0;
261
+ }
262
+ if (hasL) return sampleOneLight(P, N);
263
+ if (hasE) return sampleEmissiveTri(P, N);
264
+ return vec3(0.0);
265
+ }
266
+
267
+ // Incoming radiance along rd for a DIFFUSE GI bounce (specular=false in the
268
+ // inline path), plus the world-space hit position so temporal reuse can
269
+ // recompute the geometry term at the reprojected (same) surface. On a miss the
270
+ // "hit" is a far point along the ray, so the reused direction is recoverable.
271
+ vec3 traceRadianceGI(vec3 ro, vec3 rd, out vec3 hitPos) {
272
+ uvec4 fi; vec3 bary; float dist; bool isDyn;
273
+ if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
274
+ hitPos = ro + rd * 1.0e4;
275
+ return uSkyEnabled
276
+ ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
277
+ : uEnvColor * uEnvIntensity;
278
+ }
279
+ vec4 attr = isDyn
280
+ ? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
281
+ : textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
282
+ vec3 hAlbedo; float hRough; vec3 hEmissive; float hMetal;
283
+ fetchMaterial(attr.w, hAlbedo, hRough, hEmissive, hMetal);
284
+ vec3 hN = normalize(attr.xyz);
285
+ if (dot(hN, rd) > 0.0) hN = -hN;
286
+ vec3 hP = ro + rd * dist;
287
+ hitPos = hP;
288
+ vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
289
+ // Diffuse GI drops NEE-listed (static) emitter emission so it isn't double
290
+ // counted (same rule as RTLightingPass.traceRadiance with specular=false).
291
+ vec3 hLe = (uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
292
+ return hLe + hAlbedo * Ld * (1.0 / PI);
293
+ }
294
+
295
+ void main() {
296
+ vec4 wp = texture(uGWorldPos, vUv);
297
+ if (wp.w < 0.5) {
298
+ outResPos = vec4(0.0);
299
+ outResRad = vec4(0.0);
300
+ outGI = vec4(0.0);
301
+ return;
302
+ }
303
+ vec3 P = wp.xyz;
304
+ vec3 N = normalize(texture(uGNormalMetal, vUv).xyz);
305
+
306
+ ivec2 px = ivec2(gl_FragCoord.xy);
307
+ gSeed = uint(px.x) * 1471u + uint(px.y) * 8951u + uint(uFrame) * 23833u;
308
+ gSeed = pcgHash(gSeed);
309
+ gBlueNoise = fetchBlueNoise();
310
+ gBnDim = 0;
311
+
312
+ // ===================== ESTIMATOR DERIVATION =====================
313
+ // The inline path stores, per pixel, the DEMODULATED indirect irradiance
314
+ // I = (1/PI) * integral_hemisphere L_i(w) (N.w) dw
315
+ // as a single cosine-sampled sample: indirect = traceRadiance(cosine ray),
316
+ // because with the cosine pdf p(w) = cos/PI the estimate L_i(w)/p * (cos/PI)
317
+ // collapses to L_i(w). We reproduce the SAME quantity I via RIS.
318
+ //
319
+ // - Candidate sample: a hemisphere direction w (cosine-sampled), carrying
320
+ // the incoming radiance L_i(w) = traceRadianceGI(w) and its hit position.
321
+ // - Target function: p_hat(w) = luminance( L_i(w) * cos(theta) ).
322
+ // - Source pdf: p(w) = cos(theta)/PI (cosine).
323
+ // - RIS candidate weight: w_i = p_hat / p = PI * luminance(L_i) (cos cancels).
324
+ // - Reservoir picks y ~ p_hat; unbiased contribution weight
325
+ // W = wSum / (M * p_hat(y)).
326
+ // - Final estimate of I (integrand F(w) = L_i(w) cos(theta) / PI):
327
+ // <I> = F(y) * W = L_i(y) * cos(theta_y) / PI * W.
328
+ //
329
+ // Sanity (M=1, no history): W = w_1 / p_hat(y) = PI*lum(L)/(lum(L)*cos) =
330
+ // PI/cos, so <I> = L_i * cos/PI * PI/cos = L_i(y) — EXACTLY the inline
331
+ // single-sample estimate. Forcing uMCap=1 with a cleared history therefore
332
+ // makes this pass statistically identical to the legacy GI path.
333
+ //
334
+ // The reservoir is the GI temporal integrator; its output is ADDED at the
335
+ // denoise stage, DOWNSTREAM of the lighting pass's own temporal accumulation,
336
+ // so this GI never re-enters (and double-counts through) that history.
337
+ // ================================================================
338
+
339
+ // --- fresh candidate: one cosine-hemisphere GI bounce, shaded like inline ---
340
+ vec3 wi = cosineSampleHemisphere(N, rand2());
341
+ vec3 hitPos;
342
+ vec3 rad = traceRadianceGI(P + N * uEps, wi, hitPos);
343
+ // Match the inline firefly clamp, which is applied to indirect (= L_i) so
344
+ // the biased mean of the two paths agrees.
345
+ float rl = luminance(rad);
346
+ if (rl > uFireflyClamp) rad *= uFireflyClamp / rl;
347
+
348
+ float cosT = max(dot(N, wi), 0.0);
349
+ float pHatFresh = luminance(rad) * cosT;
350
+ // w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
351
+ float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
352
+
353
+ float wSum = wFresh;
354
+ float M = 1.0;
355
+ vec3 selRad = rad;
356
+ vec3 selPos = hitPos;
357
+
358
+ // --- temporal reuse: reproject P, validate the SAME surface, merge history ---
359
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
360
+ if (clip.w > 0.0) {
361
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
362
+ if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
363
+ vec4 pPos = texture(uPrevGWorldPos, prevUv);
364
+ float tol = 0.005 * distance(P, uCameraPos) + 20.0 * uEps;
365
+ if (pPos.w > 0.5 && abs(dot(P - pPos.xyz, N)) < tol) {
366
+ vec4 hp = texture(uPrevResPos, prevUv); // hitPos.xyz + M
367
+ vec4 hr = texture(uPrevResRad, prevUv); // radiance.rgb + W
368
+ float Mprev = hp.w;
369
+ float Wprev = hr.w;
370
+ if (Mprev > 0.0 && Wprev > 0.0) {
371
+ vec3 radPrev = hr.rgb;
372
+ vec3 hitPrev = hp.xyz;
373
+ // Re-evaluate the target at the CURRENT surface (reconnect at the
374
+ // stored hit point). Same world point P (validated), so no Jacobian.
375
+ vec3 dp = hitPrev - P;
376
+ float dl = length(dp);
377
+ float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
378
+ float pHatPrev = luminance(radPrev) * cosPrev;
379
+ float Mc = min(Mprev, uMCap);
380
+ // Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
381
+ float w = pHatPrev * Wprev * Mc;
382
+ wSum += w;
383
+ M += Mc;
384
+ if (w > 0.0 && rand() * wSum < w) {
385
+ selRad = radPrev;
386
+ selPos = hitPrev;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ // --- finalize: recompute p_hat(selected) at this surface, form W, resolve ---
394
+ vec3 sd = selPos - P;
395
+ float sl = length(sd);
396
+ float selCos = sl > 1e-5 ? max(dot(N, sd / sl), 0.0) : 0.0;
397
+ float pHatSel = luminance(selRad) * selCos;
398
+ float W = (M > 0.0 && pHatSel > 0.0) ? wSum / (M * pHatSel) : 0.0;
399
+
400
+ vec3 gi = selRad * (selCos / PI) * W; // demodulated indirect irradiance
401
+ float gil = luminance(gi);
402
+ if (gil > uFireflyClamp) gi *= uFireflyClamp / gil; // matches inline safety net
403
+ if (any(isnan(gi)) || any(isinf(gi))) gi = vec3(0.0);
404
+ if (any(isnan(selRad)) || any(isinf(selRad))) { selRad = vec3(0.0); W = 0.0; }
405
+
406
+ outResPos = vec4(selPos, M);
407
+ outResRad = vec4(selRad, W);
408
+ outGI = vec4(gi, 1.0);
409
+ }
410
+ `;
411
+
412
+ /**
413
+ * EXPERIMENTAL ReSTIR GI (v1, temporal-only). Per-pixel reservoirs reuse the
414
+ * 1-bounce GI sample across frames at the reprojected same-surface point. Runs
415
+ * at lighting resolution with its OWN sampler budget (16: 8 BVH + 2 attr + 1
416
+ * scene-data + gWorldPos + gNormalMetal + prevGWorldPos + 2 reservoir history),
417
+ * independent of the lighting pass. render() returns a resolved, demodulated GI
418
+ * irradiance texture whose mean matches the inline GI path (see main()).
419
+ *
420
+ * NO spatial reuse in v1: spatial reuse needs the solid-angle->area Jacobian and
421
+ * is where implementations go subtly wrong. Temporal reuse at the same surface
422
+ * point does not.
423
+ */
424
+ export class GIReservoirPass {
425
+ constructor(width, height) {
426
+ this.targetA = this._makeTarget(width, height);
427
+ this.targetB = this._makeTarget(width, height);
428
+
429
+ this.material = new THREE.ShaderMaterial({
430
+ glslVersion: THREE.GLSL3,
431
+ vertexShader: fullscreenVert,
432
+ fragmentShader: giFrag,
433
+ uniforms: {
434
+ bvhStatic: { value: null },
435
+ bvhDynamic: { value: null },
436
+ uHasDynamic: { value: false },
437
+ uAttrStatic: { value: null },
438
+ uAttrDynamic: { value: null },
439
+ uMaterialsTex: { value: null },
440
+ uGWorldPos: { value: null },
441
+ uGNormalMetal: { value: null },
442
+ uPrevGWorldPos: { value: null },
443
+ uPrevResPos: { value: null },
444
+ uPrevResRad: { value: null },
445
+ uPrevViewProj: { value: new THREE.Matrix4() },
446
+ uLightPosType: { value: [] },
447
+ uLightColorRadius: { value: [] },
448
+ uLightDirCone: { value: [] },
449
+ uLightCount: { value: 0 },
450
+ uEmissiveCount: { value: 0 },
451
+ uEmissiveCDF: { value: true },
452
+ uCameraPos: { value: new THREE.Vector3() },
453
+ uFrame: { value: 0 },
454
+ uEps: { value: 1e-3 },
455
+ uFireflyClamp: { value: 4.0 },
456
+ uMCap: { value: 20 },
457
+ uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
458
+ uEnvIntensity: { value: 1.0 },
459
+ uSkyEnabled: { value: false },
460
+ uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
461
+ uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
462
+ uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
463
+ uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
464
+ uSkyIntensity: { value: 1.0 },
465
+ },
466
+ depthTest: false,
467
+ depthWrite: false,
468
+ });
469
+
470
+ this.scene = new THREE.Scene();
471
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
472
+ this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
473
+ this.quad.frustumCulled = false;
474
+ this.scene.add(this.quad);
475
+ }
476
+
477
+ _makeTarget(width, height) {
478
+ // Three fp32 attachments, NearestFilter: the reservoir stores a hit POSITION
479
+ // (needs fp32) + M + radiance + W, which must never be interpolated. All fp32
480
+ // (rather than a mixed fp32/fp16 layout) sidesteps drivers that reject mixed
481
+ // MRT precision; the resolved GI (attachment 2) is read 1:1 by the denoise.
482
+ const t = new THREE.WebGLMultipleRenderTargets(width, height, 3, {
483
+ minFilter: THREE.NearestFilter,
484
+ magFilter: THREE.NearestFilter,
485
+ format: THREE.RGBAFormat,
486
+ type: THREE.FloatType,
487
+ depthBuffer: false,
488
+ stencilBuffer: false,
489
+ });
490
+ for (const tex of t.texture) tex.generateMipmaps = false;
491
+ return t;
492
+ }
493
+
494
+ setCompiledScene(compiled) {
495
+ const u = this.material.uniforms;
496
+ u.bvhStatic.value = compiled.staticBvhUniform;
497
+ u.bvhDynamic.value = compiled.dynamicBvhUniform;
498
+ u.uHasDynamic.value = compiled.hasDynamic;
499
+ u.uAttrStatic.value = compiled.staticAttrTex;
500
+ u.uAttrDynamic.value = compiled.dynamicAttrTex;
501
+ u.uMaterialsTex.value = compiled.materialsTex;
502
+ u.uLightPosType.value = compiled.lightPosType;
503
+ u.uLightColorRadius.value = compiled.lightColorRadius;
504
+ u.uLightDirCone.value = compiled.lightDirCone;
505
+ u.uLightCount.value = compiled.lightCount;
506
+ u.uEmissiveCount.value = compiled.emissiveTriCount;
507
+ }
508
+
509
+ /** Emissive candidates follow the emissiveNEE toggle (set per frame). */
510
+ setEmissiveCount(count) {
511
+ this.material.uniforms.uEmissiveCount.value = count;
512
+ }
513
+
514
+ clearHistory(renderer) {
515
+ const prev = renderer.getRenderTarget();
516
+ renderer.setClearColor(0x000000, 0);
517
+ for (const t of [this.targetA, this.targetB]) {
518
+ renderer.setRenderTarget(t);
519
+ renderer.clear(true, false, false);
520
+ }
521
+ renderer.setRenderTarget(prev);
522
+ }
523
+
524
+ setSize(width, height) {
525
+ this.targetA.setSize(width, height);
526
+ this.targetB.setSize(width, height);
527
+ }
528
+
529
+ /**
530
+ * Renders into targetA (reading targetB as reservoir history), swaps, and
531
+ * returns the resolved GI irradiance texture (attachment 2). `params` carries
532
+ * the per-frame lighting state (sky/env, firefly clamp, emissive CDF, M-cap).
533
+ */
534
+ render(renderer, gbuffer, prevViewProj, cameraPos, frame, eps, params) {
535
+ const u = this.material.uniforms;
536
+ u.uGWorldPos.value = gbuffer.worldPos;
537
+ u.uGNormalMetal.value = gbuffer.normalMetal;
538
+ u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
539
+ u.uPrevResPos.value = this.targetB.texture[0];
540
+ u.uPrevResRad.value = this.targetB.texture[1];
541
+ u.uPrevViewProj.value.copy(prevViewProj);
542
+ u.uCameraPos.value.copy(cameraPos);
543
+ u.uFrame.value = frame;
544
+ u.uEps.value = eps;
545
+ u.uFireflyClamp.value = params.fireflyClamp;
546
+ u.uMCap.value = params.mCap;
547
+ u.uEmissiveCDF.value = params.emissiveCDF;
548
+ u.uEnvColor.value.copy(params.envColor);
549
+ u.uEnvIntensity.value = params.envIntensity;
550
+ u.uSkyEnabled.value = params.skyEnabled;
551
+ u.uSunDir.value.copy(params.sunDir);
552
+ u.uSunColor.value.copy(params.sunColor);
553
+ u.uSkyZenith.value.copy(params.skyZenith);
554
+ u.uSkyHorizon.value.copy(params.skyHorizon);
555
+ u.uSkyIntensity.value = params.skyIntensity;
556
+
557
+ renderer.setRenderTarget(this.targetA);
558
+ renderer.render(this.scene, this.camera);
559
+ renderer.setRenderTarget(null);
560
+
561
+ const out = this.targetA;
562
+ [this.targetA, this.targetB] = [this.targetB, this.targetA];
563
+ return out.texture[2];
564
+ }
565
+
566
+ dispose() {
567
+ this.targetA.dispose();
568
+ this.targetB.dispose();
569
+ this.material.dispose();
570
+ this.quad.geometry.dispose();
571
+ }
572
+ }