three-realtime-rt 0.3.0 → 0.3.2

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.
@@ -1,680 +1,730 @@
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
- const rtLightingFrag = /* glsl */ `
16
- precision highp float;
17
- precision highp isampler2D;
18
- precision highp usampler2D;
19
-
20
- ${shaderStructs}
21
- ${shaderIntersectFunction}
22
- ${BVH_ANY_HIT_GLSL}
23
- ${SKY_GLSL}
24
-
25
- #define MAX_LIGHTS ${MAX_LIGHTS}
26
- #define PI 3.14159265358979
27
-
28
- layout(location = 0) out vec4 outIrradiance;
29
-
30
- in vec2 vUv;
31
-
32
- // Two-level BVH: static (uploaded once) + dynamic (small, refit each frame).
33
- uniform BVH bvhStatic;
34
- uniform BVH bvhDynamic;
35
- uniform bool uHasDynamic;
36
- // One packed per-vertex texture per level: normal.xyz + materialIndex.w.
37
- // (Two BVH structs already use 8 samplers; WebGL2 guarantees only 16 total.)
38
- uniform sampler2D uAttrStatic;
39
- uniform sampler2D uAttrDynamic;
40
- uniform sampler2D uMaterialsTex; // 2 texels per material (shared)
41
-
42
- uniform sampler2D uGWorldPos;
43
- uniform sampler2D uGNormalMetal;
44
-
45
- // temporal reprojection (stage 2). Validation is plane-distance only — the
46
- // normal test was dropped to free a sampler for the ReSTIR reservoir (same
47
- // simplification the TAA resolve already made, no observed regressions).
48
- uniform sampler2D uPrevAccum; // rgb = irradiance history, a = sample count
49
- uniform sampler2D uPrevGWorldPos; // previous frame's G-buffer, for validation
50
- uniform sampler2D uReservoir; // ReSTIR winner per pixel (see RestirPass)
51
- uniform mat4 uPrevViewProj;
52
- uniform mat4 uViewProj;
53
- uniform vec3 uCameraPos;
54
- uniform float uMaxHistory;
55
- uniform bool uTemporalReprojection;
56
- uniform float uFireflyClamp;
57
-
58
- uniform vec4 uLightPosType[MAX_LIGHTS]; // xyz pos|dir, w: 0 point, 1 directional
59
- uniform vec4 uLightColorRadius[MAX_LIGHTS]; // rgb color*intensity, w radius
60
- uniform int uLightCount;
61
- uniform int uEmissiveCount; // NEE area-light triangles in row 1 of uMaterialsTex
62
- uniform bool uReflEnabled; // traced reflections on metallic surfaces
63
- uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
64
- uniform float uIor; // index of refraction for transmissive materials
65
- uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
66
- uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
67
-
68
- uniform vec3 uEnvColor;
69
- uniform float uEnvIntensity;
70
- uniform float uFrame;
71
- uniform float uEps;
72
- uniform bool uGIEnabled;
73
-
74
- // Procedural sky (when enabled, replaces the flat env colour as the "miss" term
75
- // for GI rays — this is what gives natural outdoor bounce light).
76
- uniform bool uSkyEnabled;
77
- uniform vec3 uSunDir; // direction toward the sun
78
- uniform vec3 uSunColor;
79
- uniform vec3 uSkyZenith;
80
- uniform vec3 uSkyHorizon;
81
- uniform float uSkyIntensity;
82
-
83
- // ---------- RNG ----------
84
- // The FIRST four random numbers each frame come from a 64x64 blue-noise tile
85
- // (rows 2..65 of the scene-data texture), rotated over time with an R2
86
- // low-discrepancy sequence. Those dimensions drive direct lighting light
87
- // pick + area-sample position where noise is most visible; blue noise turns
88
- // the residual error high-frequency, which temporal accumulation and the
89
- // denoiser remove far better than white-noise clumps. Later dimensions fall
90
- // back to PCG white noise (correlating many dimensions hurts).
91
- uint gSeed;
92
- int gBnDim;
93
- vec4 gBlueNoise;
94
- uint pcgHash(uint s) {
95
- uint state = s * 747796405u + 2891336453u;
96
- uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
97
- return (word >> 22u) ^ word;
98
- }
99
- float rand() {
100
- if (gBnDim < 4) {
101
- float v = gBlueNoise[gBnDim];
102
- gBnDim++;
103
- return v;
104
- }
105
- gSeed = pcgHash(gSeed);
106
- return float(gSeed) * (1.0 / 4294967296.0);
107
- }
108
- vec2 rand2() { return vec2(rand(), rand()); }
109
-
110
- vec4 fetchBlueNoise() {
111
- ivec2 p = ivec2(gl_FragCoord.xy) & 63;
112
- vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
113
- // R2 sequence: per-frame toroidal shift, decorrelated per channel.
114
- vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
115
- return fract(bn + shift);
116
- }
117
-
118
- // Branchless orthonormal basis (Duff et al. 2017) — cheaper and stable for
119
- // every n, including the poles the old cross-product picker handled branchily.
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
-
128
- vec3 cosineSampleHemisphere(vec3 n, vec2 u) {
129
- float a = 2.0 * PI * u.x;
130
- float r = sqrt(u.y);
131
- vec3 t, b;
132
- orthoBasis(n, t, b);
133
- return normalize(t * (r * cos(a)) + b * (r * sin(a)) + n * sqrt(max(0.0, 1.0 - u.y)));
134
- }
135
-
136
- vec3 randUnitVector() {
137
- vec2 u = rand2();
138
- float z = u.x * 2.0 - 1.0;
139
- float a = u.y * 2.0 * PI;
140
- float r = sqrt(max(0.0, 1.0 - z * z));
141
- return vec3(r * cos(a), r * sin(a), z);
142
- }
143
-
144
- // ---------- two-level BVH helpers ----------
145
- // Closest hit across both levels; isDyn says which one so the caller samples
146
- // the matching vertex-attribute textures. (No backticks in these GLSL comments —
147
- // they would terminate the enclosing JS template literal.)
148
- bool traceBoth(vec3 ro, vec3 rd, out uvec4 fi, out vec3 bary, out float dist, out bool isDyn) {
149
- uvec4 fiS; vec3 fnS; vec3 bcS; float sideS; float distS;
150
- bool hitS = bvhIntersectFirstHit(bvhStatic, ro, rd, fiS, fnS, bcS, sideS, distS);
151
- uvec4 fiD; vec3 fnD; vec3 bcD; float sideD; float distD;
152
- bool hitD = uHasDynamic && bvhIntersectFirstHit(bvhDynamic, ro, rd, fiD, fnD, bcD, sideD, distD);
153
- if (hitS && (!hitD || distS <= distD)) { fi = fiS; bary = bcS; dist = distS; isDyn = false; return true; }
154
- if (hitD) { fi = fiD; bary = bcD; dist = distD; isDyn = true; return true; }
155
- return false;
156
- }
157
-
158
- // Shadow rays only need to know IF something blocks, not what's closest —
159
- // the unordered any-hit traversal early-outs on the first blocker.
160
- bool occluded(vec3 ro, vec3 rd, float maxDist) {
161
- if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
162
- if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
163
- return false;
164
- }
165
-
166
- void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
167
- out vec3 emissive, out float metalness) {
168
- int mi = int(round(matIndex)) * 2;
169
- vec4 t0 = texelFetch(uMaterialsTex, ivec2(mi, 0), 0);
170
- vec4 t1 = texelFetch(uMaterialsTex, ivec2(mi + 1, 0), 0);
171
- albedo = t0.rgb;
172
- roughness = t0.a;
173
- emissive = t1.rgb;
174
- metalness = t1.a;
175
- }
176
-
177
- // ---------- lighting ----------
178
- // Direct irradiance (demodulated: no albedo) at point P with normal N,
179
- // from light i, with one shadow ray. Area-samples point lights for soft shadows.
180
- vec3 lightContribution(int i, vec3 P, vec3 N) {
181
- vec4 posType = uLightPosType[i];
182
- vec4 colRad = uLightColorRadius[i];
183
-
184
- vec3 L;
185
- float dist2 = 1.0;
186
- float maxDist = 1e7;
187
-
188
- if (posType.w < 0.5) {
189
- // point light: sample a point on its sphere for soft shadows
190
- vec3 lp = posType.xyz + randUnitVector() * colRad.w;
191
- vec3 d = lp - P;
192
- float dl = length(d);
193
- if (dl < 1e-5) return vec3(0.0);
194
- L = d / dl;
195
- dist2 = dl * dl;
196
- maxDist = dl;
197
- } else {
198
- // directional light: jitter within a small cone
199
- L = normalize(-posType.xyz + randUnitVector() * colRad.w);
200
- dist2 = 1.0;
201
- }
202
-
203
- float NdotL = dot(N, L);
204
- if (NdotL <= 0.0) return vec3(0.0);
205
-
206
- if (occluded(P + N * uEps, L, maxDist)) return vec3(0.0);
207
- return colRad.rgb * (NdotL / dist2);
208
- }
209
-
210
- // Direct light at a GI bounce hit: sample ONE random light (weighted by count).
211
- vec3 sampleOneLight(vec3 P, vec3 N) {
212
- if (uLightCount == 0) return vec3(0.0);
213
- int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
214
- return lightContribution(i, P, N) * float(uLightCount);
215
- }
216
-
217
- // Next-event estimation on emissive-mesh triangles (row 1 of uMaterialsTex):
218
- // pick one triangle, sample a point on it, cast one shadow ray, convert the
219
- // area pdf to solid angle. Turns emitters into proper soft area lights instead
220
- // of surfaces a GI ray has to hit by luck.
221
- vec3 sampleEmissiveTri(vec3 P, vec3 N) {
222
- if (uEmissiveCount == 0) return vec3(0.0);
223
- int i = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1) * 4;
224
- vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0); // v0 | area
225
- vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0); // e1 | emit.r
226
- vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0); // e2 | emit.g
227
- vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0); // n | emit.b
228
-
229
- vec2 u = rand2();
230
- if (u.x + u.y > 1.0) u = 1.0 - u; // uniform over the triangle
231
- vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
232
-
233
- vec3 d = lp - P;
234
- float d2 = dot(d, d);
235
- float dist = sqrt(d2);
236
- if (dist < 1e-4) return vec3(0.0);
237
- vec3 wi = d / dist;
238
-
239
- float cosS = dot(N, wi);
240
- // abs(): double-sided emission, matching what a GI ray hitting either face sees.
241
- float cosL = abs(dot(t3.xyz, wi));
242
- if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
243
- if (occluded(P + N * uEps, wi, dist)) return vec3(0.0);
244
-
245
- // Uniform pick of 1-of-count tris + uniform point: pdf_area = 1/(count·area).
246
- // Solid-angle conversion gives irradiance Le · cosS · cosL / (d² · pdf_area).
247
- vec3 e = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * float(uEmissiveCount) * t0.w / max(d2, 1e-6));
248
-
249
- // Uniform-area sampling has huge single-sample variance for receivers close
250
- // to a big emitter (sampled point can land almost on top of P, d² → 0);
251
- // those 100× spikes read as speckles because the EMA decays them only as
252
- // 1/count. Clamp at 2× the indirect firefly limit — slight bias right next
253
- // to the emitter, stable everywhere.
254
- float eLum = dot(e, vec3(0.299, 0.587, 0.114));
255
- float eCap = uFireflyClamp * 2.0;
256
- if (eLum > eCap) e *= eCap / eLum;
257
- return e;
258
- }
259
-
260
- // Shade this pixel's ReSTIR reservoir winner: recompute the (unshadowed)
261
- // contribution MUST match RestirPass.candidateContribution then pay the
262
- // one visibility ray and weight by W = wSum / (M · p̂). Analytic lights
263
- // re-draw their soft-radius jitter here (the reservoir stores which light,
264
- // not the jitter). The estimator inherently tames near-emitter spikes: a huge
265
- // contribution comes with a proportionally huge p̂, and W divides it out.
266
- vec3 shadeReservoir(vec3 P, vec3 N) {
267
- // Spatial-stage encoding: r = id, a = precomputed W (vs. centroid score).
268
- vec4 res = texture(uReservoir, vUv);
269
- if (res.a <= 0.0) return vec3(0.0);
270
- float id = res.r;
271
-
272
- vec3 C;
273
- vec3 wi;
274
- float maxDist;
275
- if (id < float(MAX_LIGHTS)) {
276
- int i = int(id);
277
- vec4 posType = uLightPosType[i];
278
- vec4 colRad = uLightColorRadius[i];
279
- if (posType.w < 0.5) {
280
- vec3 d = posType.xyz - P;
281
- float dl = length(d);
282
- if (dl < 1e-5) return vec3(0.0);
283
- float NdotL = dot(N, d / dl);
284
- if (NdotL <= 0.0) return vec3(0.0);
285
- C = colRad.rgb * (NdotL / (dl * dl));
286
- vec3 lp = posType.xyz + randUnitVector() * colRad.w; // soft shadows
287
- vec3 dj = lp - P;
288
- maxDist = length(dj);
289
- if (maxDist < 1e-5) return vec3(0.0);
290
- wi = dj / maxDist;
291
- } else {
292
- float NdotL = dot(N, -posType.xyz);
293
- if (NdotL <= 0.0) return vec3(0.0);
294
- C = colRad.rgb * NdotL;
295
- wi = normalize(-posType.xyz + randUnitVector() * colRad.w);
296
- maxDist = 1e7;
297
- }
298
- } else {
299
- int t = (int(id) - MAX_LIGHTS) * 4;
300
- vec4 t0 = texelFetch(uMaterialsTex, ivec2(t, 1), 0);
301
- vec4 t1 = texelFetch(uMaterialsTex, ivec2(t + 1, 1), 0);
302
- vec4 t2 = texelFetch(uMaterialsTex, ivec2(t + 2, 1), 0);
303
- vec4 t3 = texelFetch(uMaterialsTex, ivec2(t + 3, 1), 0);
304
- // v3: the reservoir chose the TRIANGLE; draw a FRESH point on it every
305
- // frame so the area light keeps averaging (no frozen-point noise). W was
306
- // normalized against the centroid score, and E[point sample] = the
307
- // triangle's true contribution, so the estimator stays consistent.
308
- vec2 uv = rand2();
309
- if (uv.x + uv.y > 1.0) uv = 1.0 - uv;
310
- vec3 lp = t0.xyz + t1.xyz * uv.x + t2.xyz * uv.y;
311
- vec3 d = lp - P;
312
- float d2 = dot(d, d);
313
- maxDist = sqrt(d2);
314
- if (maxDist < 1e-4) return vec3(0.0);
315
- wi = d / maxDist;
316
- float cosS = dot(N, wi);
317
- float cosL = abs(dot(t3.xyz, wi));
318
- if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
319
- C = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * t0.w / max(d2, 1e-6));
320
- }
321
-
322
- if (occluded(P + N * uEps, wi, maxDist)) return vec3(0.0);
323
- vec3 e = C * res.a;
324
- // Safety clamp, same budget as the emissive direct clamp elsewhere.
325
- float l = dot(e, vec3(0.299, 0.587, 0.114));
326
- float cap = uFireflyClamp * 2.0;
327
- if (l > cap) e *= cap / l;
328
- return e;
329
- }
330
-
331
- // ONE light sample for secondary path vertices: stochastically pick either the
332
- // analytic lights or the emissive set (weighted 1/p). Costs a single shadow
333
- // ray same ray budget the GI bounce had before emissive NEE existed —
334
- // instead of two; the estimator stays unbiased and temporal accumulation
335
- // averages out the extra variance.
336
- vec3 sampleOneAny(vec3 P, vec3 N) {
337
- bool hasL = uLightCount > 0;
338
- bool hasE = uEmissiveCount > 0;
339
- if (hasL && hasE) {
340
- return rand() < 0.5
341
- ? sampleOneLight(P, N) * 2.0
342
- : sampleEmissiveTri(P, N) * 2.0;
343
- }
344
- if (hasL) return sampleOneLight(P, N);
345
- if (hasE) return sampleEmissiveTri(P, N);
346
- return vec3(0.0);
347
- }
348
-
349
- // Incoming radiance along rd: trace, shade the hit with direct + NEE lighting,
350
- // sky/env on a miss. Specular rays keep emitter emission on hit (NEE at the ray
351
- // origin cannot cover a specular path); diffuse GI rays drop it for NEE-listed
352
- // (static) emitters so that light isn't counted twice.
353
- vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
354
- uvec4 fi; vec3 bary; float dist; bool isDyn;
355
- if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
356
- return uSkyEnabled
357
- ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
358
- : uEnvColor * uEnvIntensity;
359
- }
360
- vec4 attr = isDyn
361
- ? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
362
- : textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
363
- vec3 hAlbedo; float hRough; vec3 hEmissive; float hMetal;
364
- fetchMaterial(attr.w, hAlbedo, hRough, hEmissive, hMetal);
365
- vec3 hN = normalize(attr.xyz);
366
- if (dot(hN, rd) > 0.0) hN = -hN;
367
- vec3 hP = ro + rd * dist;
368
- vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
369
- vec3 hLe = (!specular && uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
370
- return hLe + hAlbedo * Ld * (1.0 / PI);
371
- }
372
-
373
- float schlick(float cosT, float eta) {
374
- float r0 = (1.0 - eta) / (1.0 + eta);
375
- r0 *= r0;
376
- return r0 + (1.0 - r0) * pow(1.0 - cosT, 5.0);
377
- }
378
-
379
- // Roughness-jittered mirror direction (glossy cone approximation).
380
- vec3 glossyReflect(vec3 V, vec3 N, float rough) {
381
- vec3 refl = reflect(V, N);
382
- if (rough > 0.0) {
383
- refl = normalize(mix(refl, cosineSampleHemisphere(N, rand2()), rough * rough));
384
- }
385
- return refl;
386
- }
387
-
388
- // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
389
- // refraction (enter at P, march to the exit surface, refract again).
390
- vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
391
- vec3 refl = glossyReflect(V, N, rough);
392
- vec3 reflRad = dot(refl, N) > 0.0 ? traceRadiance(P + N * uEps, refl, true) : vec3(0.0);
393
-
394
- float eta = 1.0 / uIor;
395
- vec3 rd = refract(V, N, eta);
396
- if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
397
- float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
398
-
399
- vec3 ro = P - N * (2.0 * uEps);
400
- vec3 refrRad;
401
- uvec4 fi; vec3 bary; float dist; bool isDyn;
402
- if (traceBoth(ro, rd, fi, bary, dist, isDyn)) {
403
- // Exit interface: refract back out (or bounce once on internal reflection).
404
- vec4 attr = isDyn
405
- ? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
406
- : textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
407
- vec3 xN = normalize(attr.xyz);
408
- if (dot(xN, rd) > 0.0) xN = -xN;
409
- vec3 xP = ro + rd * dist;
410
- vec3 rd2 = refract(rd, xN, uIor);
411
- if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
412
- refrRad = traceRadiance(xP - xN * uEps, rd2, true);
413
- } else {
414
- refrRad = uSkyEnabled
415
- ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
416
- : uEnvColor * uEnvIntensity;
417
- }
418
- return mix(refrRad, reflRad, fres);
419
- }
420
-
421
- void main() {
422
- vec4 wp = texture(uGWorldPos, vUv);
423
- if (wp.w < 0.5) {
424
- outIrradiance = vec4(0.0);
425
- return;
426
- }
427
-
428
- ivec2 px = ivec2(gl_FragCoord.xy);
429
- gSeed = uint(px.x) * 1973u + uint(px.y) * 9277u + uint(uFrame) * 26699u;
430
- gSeed = pcgHash(gSeed);
431
- gBlueNoise = fetchBlueNoise();
432
- gBnDim = 0;
433
-
434
- vec3 P = wp.xyz;
435
- vec4 nmSample = texture(uGNormalMetal, vUv);
436
- vec3 N = normalize(nmSample.xyz);
437
- // Decode the packed material word (see GBufferPass): >= 2 → glass, else metal.
438
- float transmission = nmSample.w >= 2.0 ? clamp(nmSample.w - 2.0, 0.0, 1.0) : 0.0;
439
- float metal = nmSample.w >= 2.0 ? 0.0 : nmSample.w;
440
- float rough = clamp(wp.w - 1.0, 0.0, 1.0);
441
-
442
- // --- direct lighting ---
443
- // ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
444
- // light count). Stochastic: one blind random sample. Full: one shadow ray
445
- // per light + one for the emissive set.
446
- vec3 direct = vec3(0.0);
447
- if (uRestirEnabled) {
448
- direct = shadeReservoir(P, N);
449
- } else if (uLightStochastic) {
450
- direct = sampleOneAny(P, N);
451
- } else {
452
- for (int i = 0; i < MAX_LIGHTS; i++) {
453
- if (i >= uLightCount) break;
454
- direct += lightContribution(i, P, N);
455
- }
456
- // Emissive meshes as area lights (next-event estimation, one shadow ray).
457
- direct += sampleEmissiveTri(P, N);
458
- }
459
-
460
- // --- 1-bounce indirect (cosine-weighted; pdf cancels the NdotL/PI).
461
- // traceRadiance shades the hit with direct + NEE light, or returns the
462
- // sky/env colour when the ray escapes (the natural ambient bounce).
463
- vec3 indirect = vec3(0.0);
464
- if (uGIEnabled) {
465
- indirect = traceRadiance(P + N * uEps, cosineSampleHemisphere(N, rand2()), false);
466
- }
467
-
468
- // Firefly clamp: suppress rare huge GI samples (big perceived-noise win,
469
- // slightly biased). Applied to indirect only; direct is analytic.
470
- float lum = dot(indirect, vec3(0.299, 0.587, 0.114));
471
- if (lum > uFireflyClamp) indirect *= uFireflyClamp / lum;
472
-
473
- vec3 sampleIrr = direct + indirect;
474
-
475
- // --- traced specular: mirror/glossy reflections on metals ---
476
- if (uReflEnabled && metal > 0.001) {
477
- vec3 V = normalize(P - uCameraPos);
478
- vec3 refl = glossyReflect(V, N, rough);
479
- if (dot(refl, N) > 0.0) {
480
- // Metals have no diffuse term: replace by metalness. The composite's
481
- // albedo multiply then tints the reflection (F0 = albedo for metals).
482
- sampleIrr = mix(sampleIrr, traceRadiance(P + N * uEps, refl, true), metal);
483
- }
484
- }
485
-
486
- // --- traced glass: Fresnel reflection + two-interface refraction ---
487
- if (uRefrEnabled && transmission > 0.001) {
488
- vec3 V = normalize(P - uCameraPos);
489
- sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
490
- }
491
-
492
- // A single NaN/Inf sample would poison the EMA history for good (mix() with
493
- // NaN stays NaN until a disocclusion resets the pixel) — sanitize first.
494
- if (any(isnan(sampleIrr)) || any(isinf(sampleIrr))) sampleIrr = vec3(0.0);
495
-
496
- // --- temporal reprojection: pull validated history from last frame ---
497
- float count = 1.0;
498
- vec3 history = vec3(0.0);
499
- if (uTemporalReprojection) {
500
- vec4 clip = uPrevViewProj * vec4(P, 1.0);
501
- vec4 clipC = uViewProj * vec4(P, 1.0);
502
- if (clip.w > 0.0 && clipC.w > 0.0) {
503
- vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
504
- // P comes from a full-res G-buffer texel, which sits sub-pixel off this
505
- // half-res fragment's center. That constant offset would bias bilinear
506
- // history reads every frame (content drifts/smears at renderScale < 1).
507
- // Cancel it: measure P's offset in the CURRENT frame and subtract.
508
- vec2 currUv = (clipC.xy / clipC.w) * 0.5 + 0.5;
509
- prevUv -= currUv - vUv;
510
- if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
511
- vec4 prevPos = texture(uPrevGWorldPos, prevUv);
512
- // Plane-distance test: robust at grazing angles (position error from
513
- // texel quantization lies along the surface, not along the normal).
514
- float distToCam = distance(P, uCameraPos);
515
- float tol = 0.005 * distToCam + 20.0 * uEps;
516
- bool valid = prevPos.w > 0.5
517
- && abs(dot(P - prevPos.xyz, N)) < tol;
518
- if (valid) {
519
- vec4 h = texture(uPrevAccum, prevUv); // bilinear
520
- // Mirror-like pixels keep a SHORT history: their reflected content
521
- // moves differently from the surface, so long history smears the
522
- // reflection under camera motion — and specular rays are nearly
523
- // deterministic, so they don't need the accumulation anyway.
524
- float specHist = max(metal, transmission) * (1.0 - rough);
525
- float histCap = mix(uMaxHistory, min(uMaxHistory, 10.0), specHist);
526
- count = clamp(h.a, 0.0, histCap) + 1.0;
527
- history = h.rgb;
528
- }
529
- }
530
- }
531
- }
532
-
533
- // Exponential moving average; count=1 (disocclusion / first frame) means
534
- // the fresh sample is used as-is.
535
- vec3 blended = mix(history, sampleIrr, 1.0 / count);
536
- outIrradiance = vec4(blended, count);
537
- }
538
- `;
539
-
540
- /**
541
- * Fullscreen pass: for every G-buffer pixel, trace shadow rays to every light and
542
- * one cosine-weighted GI bounce against the BVH. Outputs demodulated irradiance,
543
- * progressively accumulated into a ping-pong float target while the camera is still.
544
- */
545
- export class RTLightingPass {
546
- constructor(width, height) {
547
- this.targetA = this._makeTarget(width, height);
548
- this.targetB = this._makeTarget(width, height);
549
-
550
- this.material = new THREE.ShaderMaterial({
551
- glslVersion: THREE.GLSL3,
552
- vertexShader: fullscreenVert,
553
- fragmentShader: rtLightingFrag,
554
- uniforms: {
555
- bvhStatic: { value: null },
556
- bvhDynamic: { value: null },
557
- uHasDynamic: { value: false },
558
- uAttrStatic: { value: null },
559
- uAttrDynamic: { value: null },
560
- uMaterialsTex: { value: null },
561
- uGWorldPos: { value: null },
562
- uGNormalMetal: { value: null },
563
- uPrevAccum: { value: null },
564
- uPrevGWorldPos: { value: null },
565
- uReservoir: { value: null },
566
- uRestirEnabled: { value: false },
567
- uPrevViewProj: { value: new THREE.Matrix4() },
568
- uViewProj: { value: new THREE.Matrix4() },
569
- uCameraPos: { value: new THREE.Vector3() },
570
- uMaxHistory: { value: 128 },
571
- uTemporalReprojection: { value: true },
572
- uFireflyClamp: { value: 4.0 },
573
- uLightPosType: { value: [] },
574
- uLightColorRadius: { value: [] },
575
- uLightCount: { value: 0 },
576
- uEmissiveCount: { value: 0 },
577
- uReflEnabled: { value: true },
578
- uRefrEnabled: { value: true },
579
- uIor: { value: 1.5 },
580
- uLightStochastic: { value: false },
581
- uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
582
- uEnvIntensity: { value: 1.0 },
583
- uFrame: { value: 0 },
584
- uEps: { value: 1e-3 },
585
- uGIEnabled: { value: true },
586
- uSkyEnabled: { value: false },
587
- uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
588
- uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
589
- uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
590
- uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
591
- uSkyIntensity: { value: 1.0 },
592
- },
593
- depthTest: false,
594
- depthWrite: false,
595
- });
596
-
597
- this.scene = new THREE.Scene();
598
- this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
599
- this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
600
- this.quad.frustumCulled = false;
601
- this.scene.add(this.quad);
602
- }
603
-
604
- _makeTarget(width, height) {
605
- // Half-float + linear: history is sampled bilinearly at reprojected UVs,
606
- // and fp16 halves the bandwidth (EMA blending never accumulates a raw sum,
607
- // so fp16 precision is sufficient).
608
- const t = new THREE.WebGLRenderTarget(width, height, {
609
- minFilter: THREE.LinearFilter,
610
- magFilter: THREE.LinearFilter,
611
- format: THREE.RGBAFormat,
612
- type: THREE.HalfFloatType,
613
- depthBuffer: false,
614
- stencilBuffer: false,
615
- });
616
- t.texture.generateMipmaps = false;
617
- return t;
618
- }
619
-
620
- /** Clear both history buffers (e.g. after scene recompile or resize). */
621
- clearHistory(renderer) {
622
- const prevTarget = renderer.getRenderTarget();
623
- const prevColor = new THREE.Color();
624
- renderer.getClearColor(prevColor);
625
- const prevAlpha = renderer.getClearAlpha();
626
- renderer.setClearColor(0x000000, 0);
627
- for (const t of [this.targetA, this.targetB]) {
628
- renderer.setRenderTarget(t);
629
- renderer.clear(true, false, false);
630
- }
631
- renderer.setRenderTarget(prevTarget);
632
- renderer.setClearColor(prevColor, prevAlpha);
633
- }
634
-
635
- setSize(width, height) {
636
- this.targetA.setSize(width, height);
637
- this.targetB.setSize(width, height);
638
- }
639
-
640
- setCompiledScene(compiled) {
641
- const u = this.material.uniforms;
642
- u.bvhStatic.value = compiled.staticBvhUniform;
643
- u.bvhDynamic.value = compiled.dynamicBvhUniform;
644
- u.uHasDynamic.value = compiled.hasDynamic;
645
- u.uAttrStatic.value = compiled.staticAttrTex;
646
- u.uAttrDynamic.value = compiled.dynamicAttrTex;
647
- u.uMaterialsTex.value = compiled.materialsTex;
648
- u.uLightPosType.value = compiled.lightPosType;
649
- u.uLightColorRadius.value = compiled.lightColorRadius;
650
- u.uLightCount.value = compiled.lightCount;
651
- u.uEmissiveCount.value = compiled.emissiveTriCount;
652
- }
653
-
654
- /** Renders into targetA (reading targetB as history), then swaps. Returns the fresh accum texture. */
655
- render(renderer, gbuffer, frame, reservoirTexture = null) {
656
- const u = this.material.uniforms;
657
- u.uGWorldPos.value = gbuffer.worldPos;
658
- u.uGNormalMetal.value = gbuffer.normalMetal;
659
- u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
660
- u.uPrevAccum.value = this.targetB.texture;
661
- u.uReservoir.value = reservoirTexture;
662
- u.uRestirEnabled.value = reservoirTexture !== null;
663
- u.uFrame.value = frame;
664
-
665
- renderer.setRenderTarget(this.targetA);
666
- renderer.render(this.scene, this.camera);
667
- renderer.setRenderTarget(null);
668
-
669
- const out = this.targetA;
670
- [this.targetA, this.targetB] = [this.targetB, this.targetA];
671
- return out.texture;
672
- }
673
-
674
- dispose() {
675
- this.targetA.dispose();
676
- this.targetB.dispose();
677
- this.material.dispose();
678
- this.quad.geometry.dispose();
679
- }
680
- }
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
+ const rtLightingFrag = /* glsl */ `
16
+ precision highp float;
17
+ precision highp isampler2D;
18
+ precision highp usampler2D;
19
+
20
+ ${shaderStructs}
21
+ ${shaderIntersectFunction}
22
+ ${BVH_ANY_HIT_GLSL}
23
+ ${SKY_GLSL}
24
+
25
+ #define MAX_LIGHTS ${MAX_LIGHTS}
26
+ #define PI 3.14159265358979
27
+
28
+ layout(location = 0) out vec4 outIrradiance;
29
+
30
+ in vec2 vUv;
31
+
32
+ // Two-level BVH: static (uploaded once) + dynamic (small, refit each frame).
33
+ uniform BVH bvhStatic;
34
+ uniform BVH bvhDynamic;
35
+ uniform bool uHasDynamic;
36
+ // One packed per-vertex texture per level: normal.xyz + materialIndex.w.
37
+ // (Two BVH structs already use 8 samplers; WebGL2 guarantees only 16 total.)
38
+ uniform sampler2D uAttrStatic;
39
+ uniform sampler2D uAttrDynamic;
40
+ uniform sampler2D uMaterialsTex; // 2 texels per material (shared)
41
+
42
+ uniform sampler2D uGWorldPos;
43
+ uniform sampler2D uGNormalMetal;
44
+
45
+ // temporal reprojection (stage 2). Validation is plane-distance only — the
46
+ // normal test was dropped to free a sampler for the ReSTIR reservoir (same
47
+ // simplification the TAA resolve already made, no observed regressions).
48
+ uniform sampler2D uPrevAccum; // rgb = irradiance history, a = sample count
49
+ uniform sampler2D uPrevGWorldPos; // previous frame's G-buffer, for validation
50
+ uniform sampler2D uReservoir; // ReSTIR winner per pixel (see RestirPass)
51
+ uniform mat4 uPrevViewProj;
52
+ uniform mat4 uViewProj;
53
+ uniform vec3 uCameraPos;
54
+ uniform float uMaxHistory;
55
+ uniform bool uTemporalReprojection;
56
+ uniform float uFireflyClamp;
57
+
58
+ uniform vec4 uLightPosType[MAX_LIGHTS]; // xyz pos|dir, w: 0 point, 1 directional, >=2 spot (w-2 = cosInner)
59
+ uniform vec4 uLightColorRadius[MAX_LIGHTS]; // rgb color*intensity, w radius
60
+ uniform vec4 uLightDirCone[MAX_LIGHTS]; // spot: direction.xyz + cos(outer angle)
61
+ uniform int uLightCount;
62
+ uniform int uEmissiveCount; // NEE area-light triangles in row 1 of uMaterialsTex
63
+ uniform bool uReflEnabled; // traced reflections on metallic surfaces
64
+ uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
65
+ uniform float uIor; // index of refraction for transmissive materials
66
+ uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
67
+ uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
68
+ uniform bool uGIHalfRate; // GI ray on alternating checkerboard, doubled
69
+
70
+ uniform vec3 uEnvColor;
71
+ uniform float uEnvIntensity;
72
+ uniform float uFrame;
73
+ uniform float uEps;
74
+ uniform bool uGIEnabled;
75
+
76
+ // Procedural sky (when enabled, replaces the flat env colour as the "miss" term
77
+ // for GI rays this is what gives natural outdoor bounce light).
78
+ uniform bool uSkyEnabled;
79
+ uniform vec3 uSunDir; // direction toward the sun
80
+ uniform vec3 uSunColor;
81
+ uniform vec3 uSkyZenith;
82
+ uniform vec3 uSkyHorizon;
83
+ uniform float uSkyIntensity;
84
+
85
+ // ---------- RNG ----------
86
+ // The FIRST four random numbers each frame come from a 64x64 blue-noise tile
87
+ // (rows 2..65 of the scene-data texture), rotated over time with an R2
88
+ // low-discrepancy sequence. Those dimensions drive direct lighting light
89
+ // pick + area-sample position where noise is most visible; blue noise turns
90
+ // the residual error high-frequency, which temporal accumulation and the
91
+ // denoiser remove far better than white-noise clumps. Later dimensions fall
92
+ // back to PCG white noise (correlating many dimensions hurts).
93
+ uint gSeed;
94
+ int gBnDim;
95
+ vec4 gBlueNoise;
96
+ uint pcgHash(uint s) {
97
+ uint state = s * 747796405u + 2891336453u;
98
+ uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
99
+ return (word >> 22u) ^ word;
100
+ }
101
+ float rand() {
102
+ if (gBnDim < 4) {
103
+ float v = gBlueNoise[gBnDim];
104
+ gBnDim++;
105
+ return v;
106
+ }
107
+ gSeed = pcgHash(gSeed);
108
+ return float(gSeed) * (1.0 / 4294967296.0);
109
+ }
110
+ vec2 rand2() { return vec2(rand(), rand()); }
111
+
112
+ vec4 fetchBlueNoise() {
113
+ ivec2 p = ivec2(gl_FragCoord.xy) & 63;
114
+ vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
115
+ // R2 sequence: per-frame toroidal shift, decorrelated per channel.
116
+ vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
117
+ return fract(bn + shift);
118
+ }
119
+
120
+ // Branchless orthonormal basis (Duff et al. 2017) cheaper and stable for
121
+ // every n, including the poles the old cross-product picker handled branchily.
122
+ void orthoBasis(vec3 n, out vec3 t, out vec3 b) {
123
+ float s = n.z >= 0.0 ? 1.0 : -1.0;
124
+ float a = -1.0 / (s + n.z);
125
+ float m = n.x * n.y * a;
126
+ t = vec3(1.0 + s * n.x * n.x * a, s * m, -s * n.x);
127
+ b = vec3(m, s + n.y * n.y * a, -n.y);
128
+ }
129
+
130
+ vec3 cosineSampleHemisphere(vec3 n, vec2 u) {
131
+ float a = 2.0 * PI * u.x;
132
+ float r = sqrt(u.y);
133
+ vec3 t, b;
134
+ orthoBasis(n, t, b);
135
+ return normalize(t * (r * cos(a)) + b * (r * sin(a)) + n * sqrt(max(0.0, 1.0 - u.y)));
136
+ }
137
+
138
+ vec3 randUnitVector() {
139
+ vec2 u = rand2();
140
+ float z = u.x * 2.0 - 1.0;
141
+ float a = u.y * 2.0 * PI;
142
+ float r = sqrt(max(0.0, 1.0 - z * z));
143
+ return vec3(r * cos(a), r * sin(a), z);
144
+ }
145
+
146
+ // ---------- two-level BVH helpers ----------
147
+ // Closest hit across both levels; isDyn says which one so the caller samples
148
+ // the matching vertex-attribute textures. (No backticks in these GLSL comments
149
+ // they would terminate the enclosing JS template literal.)
150
+ bool traceBoth(vec3 ro, vec3 rd, out uvec4 fi, out vec3 bary, out float dist, out bool isDyn) {
151
+ uvec4 fiS; vec3 fnS; vec3 bcS; float sideS; float distS;
152
+ bool hitS = bvhIntersectFirstHit(bvhStatic, ro, rd, fiS, fnS, bcS, sideS, distS);
153
+ uvec4 fiD; vec3 fnD; vec3 bcD; float sideD; float distD;
154
+ bool hitD = uHasDynamic && bvhIntersectFirstHit(bvhDynamic, ro, rd, fiD, fnD, bcD, sideD, distD);
155
+ if (hitS && (!hitD || distS <= distD)) { fi = fiS; bary = bcS; dist = distS; isDyn = false; return true; }
156
+ if (hitD) { fi = fiD; bary = bcD; dist = distD; isDyn = true; return true; }
157
+ return false;
158
+ }
159
+
160
+ // Shadow rays only need to know IF something blocks, not what's closest —
161
+ // the unordered any-hit traversal early-outs on the first blocker.
162
+ bool occluded(vec3 ro, vec3 rd, float maxDist) {
163
+ if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
164
+ if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
165
+ return false;
166
+ }
167
+
168
+ void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
169
+ out vec3 emissive, out float metalness) {
170
+ int mi = int(round(matIndex)) * 2;
171
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(mi, 0), 0);
172
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(mi + 1, 0), 0);
173
+ albedo = t0.rgb;
174
+ roughness = t0.a;
175
+ emissive = t1.rgb;
176
+ metalness = t1.a;
177
+ }
178
+
179
+ // ---------- lighting ----------
180
+ // Direct irradiance (demodulated: no albedo) at point P with normal N,
181
+ // from light i, with one shadow ray. Area-samples point lights for soft shadows.
182
+ // Spot cone falloff: smooth between the outer and inner cone cosines
183
+ // (posType.w = 2 + cosInner; dirCone.w = cosOuter).
184
+ float spotFalloff(int i, vec3 lightToP) {
185
+ vec4 posType = uLightPosType[i];
186
+ if (posType.w < 1.5) return 1.0;
187
+ vec4 dc = uLightDirCone[i];
188
+ return smoothstep(dc.w, posType.w - 2.0, dot(dc.xyz, lightToP));
189
+ }
190
+
191
+ vec3 lightContribution(int i, vec3 P, vec3 N) {
192
+ vec4 posType = uLightPosType[i];
193
+ vec4 colRad = uLightColorRadius[i];
194
+
195
+ vec3 L;
196
+ float dist2 = 1.0;
197
+ float maxDist = 1e7;
198
+ float cone = 1.0;
199
+
200
+ if (posType.w < 0.5 || posType.w >= 1.5) {
201
+ // point/spot light: sample a point on its sphere for soft shadows
202
+ vec3 lp = posType.xyz + randUnitVector() * colRad.w;
203
+ vec3 d = lp - P;
204
+ float dl = length(d);
205
+ if (dl < 1e-5) return vec3(0.0);
206
+ L = d / dl;
207
+ dist2 = dl * dl;
208
+ maxDist = dl;
209
+ cone = spotFalloff(i, -L);
210
+ if (cone <= 0.0) return vec3(0.0);
211
+ } else {
212
+ // directional light: jitter within a small cone
213
+ L = normalize(-posType.xyz + randUnitVector() * colRad.w);
214
+ dist2 = 1.0;
215
+ }
216
+
217
+ float NdotL = dot(N, L);
218
+ if (NdotL <= 0.0) return vec3(0.0);
219
+
220
+ if (occluded(P + N * uEps, L, maxDist)) return vec3(0.0);
221
+ return colRad.rgb * (cone * NdotL / dist2);
222
+ }
223
+
224
+ // Direct light at a GI bounce hit: sample ONE random light (weighted by count).
225
+ vec3 sampleOneLight(vec3 P, vec3 N) {
226
+ if (uLightCount == 0) return vec3(0.0);
227
+ int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
228
+ return lightContribution(i, P, N) * float(uLightCount);
229
+ }
230
+
231
+ // Next-event estimation on emissive-mesh triangles (row 1 of uMaterialsTex):
232
+ // pick one triangle, sample a point on it, cast one shadow ray, convert the
233
+ // area pdf to solid angle. Turns emitters into proper soft area lights instead
234
+ // of surfaces a GI ray has to hit by luck.
235
+ vec3 sampleEmissiveTri(vec3 P, vec3 N) {
236
+ if (uEmissiveCount == 0) return vec3(0.0);
237
+ int i = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1) * 4;
238
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0); // v0 | area
239
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0); // e1 | emit.r
240
+ vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0); // e2 | emit.g
241
+ vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0); // n | emit.b
242
+
243
+ vec2 u = rand2();
244
+ if (u.x + u.y > 1.0) u = 1.0 - u; // uniform over the triangle
245
+ vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
246
+
247
+ vec3 d = lp - P;
248
+ float d2 = dot(d, d);
249
+ float dist = sqrt(d2);
250
+ if (dist < 1e-4) return vec3(0.0);
251
+ vec3 wi = d / dist;
252
+
253
+ float cosS = dot(N, wi);
254
+ // abs(): double-sided emission, matching what a GI ray hitting either face sees.
255
+ float cosL = abs(dot(t3.xyz, wi));
256
+ if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
257
+ if (occluded(P + N * uEps, wi, dist)) return vec3(0.0);
258
+
259
+ // Uniform pick of 1-of-count tris + uniform point: pdf_area = 1/(count·area).
260
+ // Solid-angle conversion gives irradiance Le · cosS · cosL / (d² · pdf_area).
261
+ vec3 e = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * float(uEmissiveCount) * t0.w / max(d2, 1e-6));
262
+
263
+ // Uniform-area sampling has huge single-sample variance for receivers close
264
+ // to a big emitter (sampled point can land almost on top of P, d² → 0);
265
+ // those 100× spikes read as speckles because the EMA decays them only as
266
+ // 1/count. Clamp at the indirect firefly limit — slight bias right next
267
+ // to the emitter, stable everywhere.
268
+ float eLum = dot(e, vec3(0.299, 0.587, 0.114));
269
+ float eCap = uFireflyClamp * 2.0;
270
+ if (eLum > eCap) e *= eCap / eLum;
271
+ return e;
272
+ }
273
+
274
+ // Shade this pixel's ReSTIR reservoir winner: recompute the (unshadowed)
275
+ // contribution MUST match RestirPass.candidateContribution — then pay the
276
+ // one visibility ray and weight by W = wSum / (M · p̂). Analytic lights
277
+ // re-draw their soft-radius jitter here (the reservoir stores which light,
278
+ // not the jitter). The estimator inherently tames near-emitter spikes: a huge
279
+ // contribution comes with a proportionally huge p̂, and W divides it out.
280
+ vec3 shadeReservoir(vec3 P, vec3 N) {
281
+ // Spatial-stage encoding: r = id, a = precomputed W (vs. centroid score).
282
+ vec4 res = texture(uReservoir, vUv);
283
+ if (res.a <= 0.0) return vec3(0.0);
284
+ float id = res.r;
285
+
286
+ vec3 C;
287
+ vec3 wi;
288
+ float maxDist;
289
+ if (id < float(MAX_LIGHTS)) {
290
+ int i = int(id);
291
+ vec4 posType = uLightPosType[i];
292
+ vec4 colRad = uLightColorRadius[i];
293
+ if (posType.w < 0.5 || posType.w >= 1.5) {
294
+ vec3 d = posType.xyz - P;
295
+ float dl = length(d);
296
+ if (dl < 1e-5) return vec3(0.0);
297
+ float NdotL = dot(N, d / dl);
298
+ if (NdotL <= 0.0) return vec3(0.0);
299
+ float cone = spotFalloff(i, -d / dl);
300
+ if (cone <= 0.0) return vec3(0.0);
301
+ C = colRad.rgb * (cone * NdotL / (dl * dl));
302
+ vec3 lp = posType.xyz + randUnitVector() * colRad.w; // soft shadows
303
+ vec3 dj = lp - P;
304
+ maxDist = length(dj);
305
+ if (maxDist < 1e-5) return vec3(0.0);
306
+ wi = dj / maxDist;
307
+ } else {
308
+ float NdotL = dot(N, -posType.xyz);
309
+ if (NdotL <= 0.0) return vec3(0.0);
310
+ C = colRad.rgb * NdotL;
311
+ wi = normalize(-posType.xyz + randUnitVector() * colRad.w);
312
+ maxDist = 1e7;
313
+ }
314
+ } else {
315
+ int t = (int(id) - MAX_LIGHTS) * 4;
316
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(t, 1), 0);
317
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(t + 1, 1), 0);
318
+ vec4 t2 = texelFetch(uMaterialsTex, ivec2(t + 2, 1), 0);
319
+ vec4 t3 = texelFetch(uMaterialsTex, ivec2(t + 3, 1), 0);
320
+ // v3: the reservoir chose the TRIANGLE; draw a FRESH point on it every
321
+ // frame so the area light keeps averaging (no frozen-point noise). W was
322
+ // normalized against the centroid score, and E[point sample] = the
323
+ // triangle's true contribution, so the estimator stays consistent.
324
+ vec2 uv = rand2();
325
+ if (uv.x + uv.y > 1.0) uv = 1.0 - uv;
326
+ vec3 lp = t0.xyz + t1.xyz * uv.x + t2.xyz * uv.y;
327
+ vec3 d = lp - P;
328
+ float d2 = dot(d, d);
329
+ maxDist = sqrt(d2);
330
+ if (maxDist < 1e-4) return vec3(0.0);
331
+ wi = d / maxDist;
332
+ float cosS = dot(N, wi);
333
+ float cosL = abs(dot(t3.xyz, wi));
334
+ if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
335
+ C = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * t0.w / max(d2, 1e-6));
336
+ }
337
+
338
+ if (occluded(P + N * uEps, wi, maxDist)) return vec3(0.0);
339
+ vec3 e = C * res.a;
340
+ // Safety clamp, same budget as the emissive direct clamp elsewhere.
341
+ float l = dot(e, vec3(0.299, 0.587, 0.114));
342
+ float cap = uFireflyClamp * 2.0;
343
+ if (l > cap) e *= cap / l;
344
+ return e;
345
+ }
346
+
347
+ // ONE light sample for secondary path vertices: stochastically pick either the
348
+ // analytic lights or the emissive set (weighted 1/p). Costs a single shadow
349
+ // ray same ray budget the GI bounce had before emissive NEE existed —
350
+ // instead of two; the estimator stays unbiased and temporal accumulation
351
+ // averages out the extra variance.
352
+ vec3 sampleOneAny(vec3 P, vec3 N) {
353
+ bool hasL = uLightCount > 0;
354
+ bool hasE = uEmissiveCount > 0;
355
+ if (hasL && hasE) {
356
+ return rand() < 0.5
357
+ ? sampleOneLight(P, N) * 2.0
358
+ : sampleEmissiveTri(P, N) * 2.0;
359
+ }
360
+ if (hasL) return sampleOneLight(P, N);
361
+ if (hasE) return sampleEmissiveTri(P, N);
362
+ return vec3(0.0);
363
+ }
364
+
365
+ // Incoming radiance along rd: trace, shade the hit with direct + NEE lighting,
366
+ // sky/env on a miss. Specular rays keep emitter emission on hit (NEE at the ray
367
+ // origin cannot cover a specular path); diffuse GI rays drop it for NEE-listed
368
+ // (static) emitters so that light isn't counted twice.
369
+ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
370
+ uvec4 fi; vec3 bary; float dist; bool isDyn;
371
+ if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
372
+ return uSkyEnabled
373
+ ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
374
+ : uEnvColor * uEnvIntensity;
375
+ }
376
+ vec4 attr = isDyn
377
+ ? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
378
+ : textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
379
+ vec3 hAlbedo; float hRough; vec3 hEmissive; float hMetal;
380
+ fetchMaterial(attr.w, hAlbedo, hRough, hEmissive, hMetal);
381
+ vec3 hN = normalize(attr.xyz);
382
+ if (dot(hN, rd) > 0.0) hN = -hN;
383
+ vec3 hP = ro + rd * dist;
384
+ vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
385
+ vec3 hLe = (!specular && uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
386
+ return hLe + hAlbedo * Ld * (1.0 / PI);
387
+ }
388
+
389
+ float schlick(float cosT, float eta) {
390
+ float r0 = (1.0 - eta) / (1.0 + eta);
391
+ r0 *= r0;
392
+ return r0 + (1.0 - r0) * pow(1.0 - cosT, 5.0);
393
+ }
394
+
395
+ // Roughness-jittered mirror direction (glossy cone approximation).
396
+ vec3 glossyReflect(vec3 V, vec3 N, float rough) {
397
+ vec3 refl = reflect(V, N);
398
+ if (rough > 0.0) {
399
+ refl = normalize(mix(refl, cosineSampleHemisphere(N, rand2()), rough * rough));
400
+ }
401
+ return refl;
402
+ }
403
+
404
+ // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
405
+ // refraction (enter at P, march to the exit surface, refract again).
406
+ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
407
+ vec3 refl = glossyReflect(V, N, rough);
408
+ vec3 reflRad = dot(refl, N) > 0.0 ? traceRadiance(P + N * uEps, refl, true) : vec3(0.0);
409
+
410
+ float eta = 1.0 / uIor;
411
+ vec3 rd = refract(V, N, eta);
412
+ if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
413
+ float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
414
+
415
+ vec3 ro = P - N * (2.0 * uEps);
416
+ vec3 refrRad;
417
+ uvec4 fi; vec3 bary; float dist; bool isDyn;
418
+ if (traceBoth(ro, rd, fi, bary, dist, isDyn)) {
419
+ // Exit interface: refract back out (or bounce once on internal reflection).
420
+ vec4 attr = isDyn
421
+ ? textureSampleBarycoord(uAttrDynamic, bary, fi.xyz)
422
+ : textureSampleBarycoord(uAttrStatic, bary, fi.xyz);
423
+ vec3 xN = normalize(attr.xyz);
424
+ if (dot(xN, rd) > 0.0) xN = -xN;
425
+ vec3 xP = ro + rd * dist;
426
+ vec3 rd2 = refract(rd, xN, uIor);
427
+ if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
428
+ refrRad = traceRadiance(xP - xN * uEps, rd2, true);
429
+ } else {
430
+ refrRad = uSkyEnabled
431
+ ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
432
+ : uEnvColor * uEnvIntensity;
433
+ }
434
+ return mix(refrRad, reflRad, fres);
435
+ }
436
+
437
+ void main() {
438
+ vec4 wp = texture(uGWorldPos, vUv);
439
+ if (wp.w < 0.5) {
440
+ outIrradiance = vec4(0.0);
441
+ return;
442
+ }
443
+
444
+ ivec2 px = ivec2(gl_FragCoord.xy);
445
+ gSeed = uint(px.x) * 1973u + uint(px.y) * 9277u + uint(uFrame) * 26699u;
446
+ gSeed = pcgHash(gSeed);
447
+ gBlueNoise = fetchBlueNoise();
448
+ gBnDim = 0;
449
+
450
+ vec3 P = wp.xyz;
451
+ vec4 nmSample = texture(uGNormalMetal, vUv);
452
+ vec3 N = normalize(nmSample.xyz);
453
+ // Decode the packed material word (see GBufferPass): >= 2 → glass, else metal.
454
+ float transmission = nmSample.w >= 2.0 ? clamp(nmSample.w - 2.0, 0.0, 1.0) : 0.0;
455
+ float metal = nmSample.w >= 2.0 ? 0.0 : nmSample.w;
456
+ float rough = clamp(wp.w - 1.0, 0.0, 1.0);
457
+
458
+ // --- direct lighting ---
459
+ // ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
460
+ // light count). Stochastic: one blind random sample. Full: one shadow ray
461
+ // per light + one for the emissive set.
462
+ vec3 direct = vec3(0.0);
463
+ if (uRestirEnabled) {
464
+ direct = shadeReservoir(P, N);
465
+ } else if (uLightStochastic) {
466
+ direct = sampleOneAny(P, N);
467
+ } else {
468
+ for (int i = 0; i < MAX_LIGHTS; i++) {
469
+ if (i >= uLightCount) break;
470
+ direct += lightContribution(i, P, N);
471
+ }
472
+ // Emissive meshes as area lights (next-event estimation, one shadow ray).
473
+ direct += sampleEmissiveTri(P, N);
474
+ }
475
+
476
+ // --- 1-bounce indirect (cosine-weighted; pdf cancels the NdotL/PI).
477
+ // traceRadiance shades the hit with direct + NEE light, or returns the
478
+ // sky/env colour when the ray escapes (the natural ambient bounce).
479
+ // Half-rate mode traces on alternating checkerboard parity each frame,
480
+ // DOUBLED the temporal average converges to the same brightness
481
+ // (unbiased) while GI's ray cost halves; accumulation + denoise absorb
482
+ // the alternation.
483
+ vec3 indirect = vec3(0.0);
484
+ if (uGIEnabled) {
485
+ bool trace = !uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0);
486
+ if (trace) {
487
+ indirect = traceRadiance(P + N * uEps, cosineSampleHemisphere(N, rand2()), false);
488
+ if (uGIHalfRate) indirect *= 2.0;
489
+ }
490
+ }
491
+
492
+ // Firefly clamp: suppress rare huge GI samples (big perceived-noise win,
493
+ // slightly biased). Applied to indirect only; direct is analytic.
494
+ float lum = dot(indirect, vec3(0.299, 0.587, 0.114));
495
+ if (lum > uFireflyClamp) indirect *= uFireflyClamp / lum;
496
+
497
+ vec3 sampleIrr = direct + indirect;
498
+
499
+ // --- traced specular: mirror/glossy reflections on metals ---
500
+ if (uReflEnabled && metal > 0.001) {
501
+ vec3 V = normalize(P - uCameraPos);
502
+ vec3 refl = glossyReflect(V, N, rough);
503
+ if (dot(refl, N) > 0.0) {
504
+ // Metals have no diffuse term: replace by metalness. The composite's
505
+ // albedo multiply then tints the reflection (F0 = albedo for metals).
506
+ sampleIrr = mix(sampleIrr, traceRadiance(P + N * uEps, refl, true), metal);
507
+ }
508
+ }
509
+
510
+ // --- traced glass: Fresnel reflection + two-interface refraction ---
511
+ if (uRefrEnabled && transmission > 0.001) {
512
+ vec3 V = normalize(P - uCameraPos);
513
+ sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
514
+ }
515
+
516
+ // A single NaN/Inf sample would poison the EMA history for good (mix() with
517
+ // NaN stays NaN until a disocclusion resets the pixel) sanitize first.
518
+ if (any(isnan(sampleIrr)) || any(isinf(sampleIrr))) sampleIrr = vec3(0.0);
519
+
520
+ // --- temporal reprojection: pull validated history from last frame ---
521
+ float count = 1.0;
522
+ vec3 history = vec3(0.0);
523
+ if (uTemporalReprojection) {
524
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
525
+ vec4 clipC = uViewProj * vec4(P, 1.0);
526
+ if (clip.w > 0.0 && clipC.w > 0.0) {
527
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
528
+ // P comes from a full-res G-buffer texel, which sits sub-pixel off this
529
+ // half-res fragment's center. That constant offset would bias bilinear
530
+ // history reads every frame (content drifts/smears at renderScale < 1).
531
+ // Cancel it: measure P's offset in the CURRENT frame and subtract.
532
+ vec2 currUv = (clipC.xy / clipC.w) * 0.5 + 0.5;
533
+ prevUv -= currUv - vUv;
534
+ if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
535
+ vec4 prevPos = texture(uPrevGWorldPos, prevUv);
536
+ // Plane-distance test: robust at grazing angles (position error from
537
+ // texel quantization lies along the surface, not along the normal).
538
+ float distToCam = distance(P, uCameraPos);
539
+ float tol = 0.005 * distToCam + 20.0 * uEps;
540
+ bool valid = prevPos.w > 0.5
541
+ && abs(dot(P - prevPos.xyz, N)) < tol;
542
+ if (valid) {
543
+ vec4 h = texture(uPrevAccum, prevUv); // bilinear
544
+ // Mirror-like pixels keep a SHORT history: their reflected content
545
+ // moves differently from the surface, so long history smears the
546
+ // reflection under camera motion — and specular rays are nearly
547
+ // deterministic, so they don't need the accumulation anyway.
548
+ float specHist = max(metal, transmission) * (1.0 - rough);
549
+ float histCap = mix(uMaxHistory, min(uMaxHistory, 10.0), specHist);
550
+ count = clamp(h.a, 0.0, histCap) + 1.0;
551
+ history = h.rgb;
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ // Exponential moving average; count=1 (disocclusion / first frame) means
558
+ // the fresh sample is used as-is.
559
+ vec3 blended = mix(history, sampleIrr, 1.0 / count);
560
+ outIrradiance = vec4(blended, count);
561
+ }
562
+ `;
563
+
564
+ /**
565
+ * Fullscreen pass: for every G-buffer pixel, trace shadow rays to every light and
566
+ * one cosine-weighted GI bounce against the BVH. Outputs demodulated irradiance,
567
+ * progressively accumulated into a ping-pong float target while the camera is still.
568
+ */
569
+ export class RTLightingPass {
570
+ constructor(width, height) {
571
+ this.targetA = this._makeTarget(width, height);
572
+ this.targetB = this._makeTarget(width, height);
573
+
574
+ this.material = new THREE.ShaderMaterial({
575
+ glslVersion: THREE.GLSL3,
576
+ vertexShader: fullscreenVert,
577
+ fragmentShader: rtLightingFrag,
578
+ uniforms: {
579
+ bvhStatic: { value: null },
580
+ bvhDynamic: { value: null },
581
+ uHasDynamic: { value: false },
582
+ uAttrStatic: { value: null },
583
+ uAttrDynamic: { value: null },
584
+ uMaterialsTex: { value: null },
585
+ uGWorldPos: { value: null },
586
+ uGNormalMetal: { value: null },
587
+ uPrevAccum: { value: null },
588
+ uPrevGWorldPos: { value: null },
589
+ uReservoir: { value: null },
590
+ uRestirEnabled: { value: false },
591
+ uPrevViewProj: { value: new THREE.Matrix4() },
592
+ uViewProj: { value: new THREE.Matrix4() },
593
+ uCameraPos: { value: new THREE.Vector3() },
594
+ uMaxHistory: { value: 128 },
595
+ uTemporalReprojection: { value: true },
596
+ uFireflyClamp: { value: 4.0 },
597
+ uLightPosType: { value: [] },
598
+ uLightColorRadius: { value: [] },
599
+ uLightDirCone: { value: [] },
600
+ uLightCount: { value: 0 },
601
+ uEmissiveCount: { value: 0 },
602
+ uReflEnabled: { value: true },
603
+ uRefrEnabled: { value: true },
604
+ uIor: { value: 1.5 },
605
+ uLightStochastic: { value: false },
606
+ uGIHalfRate: { value: false },
607
+ uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
608
+ uEnvIntensity: { value: 1.0 },
609
+ uFrame: { value: 0 },
610
+ uEps: { value: 1e-3 },
611
+ uGIEnabled: { value: true },
612
+ uSkyEnabled: { value: false },
613
+ uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
614
+ uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
615
+ uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
616
+ uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
617
+ uSkyIntensity: { value: 1.0 },
618
+ },
619
+ depthTest: false,
620
+ depthWrite: false,
621
+ });
622
+
623
+ this.scene = new THREE.Scene();
624
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
625
+ this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
626
+ this.quad.frustumCulled = false;
627
+ this.scene.add(this.quad);
628
+ }
629
+
630
+ _makeTarget(width, height) {
631
+ // Half-float + linear: history is sampled bilinearly at reprojected UVs,
632
+ // and fp16 halves the bandwidth (EMA blending never accumulates a raw sum,
633
+ // so fp16 precision is sufficient).
634
+ const t = new THREE.WebGLRenderTarget(width, height, {
635
+ minFilter: THREE.LinearFilter,
636
+ magFilter: THREE.LinearFilter,
637
+ format: THREE.RGBAFormat,
638
+ type: THREE.HalfFloatType,
639
+ depthBuffer: false,
640
+ stencilBuffer: false,
641
+ });
642
+ t.texture.generateMipmaps = false;
643
+ return t;
644
+ }
645
+
646
+ /** Clear both history buffers (e.g. after scene recompile or resize). */
647
+ clearHistory(renderer) {
648
+ const prevTarget = renderer.getRenderTarget();
649
+ const prevColor = new THREE.Color();
650
+ renderer.getClearColor(prevColor);
651
+ const prevAlpha = renderer.getClearAlpha();
652
+ renderer.setClearColor(0x000000, 0);
653
+ for (const t of [this.targetA, this.targetB]) {
654
+ renderer.setRenderTarget(t);
655
+ renderer.clear(true, false, false);
656
+ }
657
+ renderer.setRenderTarget(prevTarget);
658
+ renderer.setClearColor(prevColor, prevAlpha);
659
+ }
660
+
661
+ setSize(width, height) {
662
+ this.targetA.setSize(width, height);
663
+ this.targetB.setSize(width, height);
664
+ }
665
+
666
+ /**
667
+ * Reallocate the history targets to a new size while PRESERVING the
668
+ * accumulated irradiance. The plain setSize + clearHistory path dumps every
669
+ * temporal sample, which strobes the image back to 1-spp noise on every
670
+ * governor renderScale step — this carries the history over instead.
671
+ *
672
+ * The freshest history is targetB (last frame's output — see the swap in
673
+ * render()); it is resampled through copyPass into the new targetB, its
674
+ * per-pixel sample count (alpha) clamped to `carryFrames` so the EMA
675
+ * reconverges smoothly at the new resolution rather than freezing on stale
676
+ * values. targetA is overwritten on the next render, so it only needs the
677
+ * fresh allocation, not a copy.
678
+ */
679
+ resizeCarry(renderer, copyPass, width, height, carryFrames) {
680
+ const newA = this._makeTarget(width, height);
681
+ const newB = this._makeTarget(width, height);
682
+ copyPass.blit(renderer, this.targetB.texture, newB, carryFrames);
683
+ this.targetA.dispose();
684
+ this.targetB.dispose();
685
+ this.targetA = newA;
686
+ this.targetB = newB;
687
+ }
688
+
689
+ setCompiledScene(compiled) {
690
+ const u = this.material.uniforms;
691
+ u.bvhStatic.value = compiled.staticBvhUniform;
692
+ u.bvhDynamic.value = compiled.dynamicBvhUniform;
693
+ u.uHasDynamic.value = compiled.hasDynamic;
694
+ u.uAttrStatic.value = compiled.staticAttrTex;
695
+ u.uAttrDynamic.value = compiled.dynamicAttrTex;
696
+ u.uMaterialsTex.value = compiled.materialsTex;
697
+ u.uLightPosType.value = compiled.lightPosType;
698
+ u.uLightColorRadius.value = compiled.lightColorRadius;
699
+ u.uLightDirCone.value = compiled.lightDirCone;
700
+ u.uLightCount.value = compiled.lightCount;
701
+ u.uEmissiveCount.value = compiled.emissiveTriCount;
702
+ }
703
+
704
+ /** Renders into targetA (reading targetB as history), then swaps. Returns the fresh accum texture. */
705
+ render(renderer, gbuffer, frame, reservoirTexture = null) {
706
+ const u = this.material.uniforms;
707
+ u.uGWorldPos.value = gbuffer.worldPos;
708
+ u.uGNormalMetal.value = gbuffer.normalMetal;
709
+ u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
710
+ u.uPrevAccum.value = this.targetB.texture;
711
+ u.uReservoir.value = reservoirTexture;
712
+ u.uRestirEnabled.value = reservoirTexture !== null;
713
+ u.uFrame.value = frame;
714
+
715
+ renderer.setRenderTarget(this.targetA);
716
+ renderer.render(this.scene, this.camera);
717
+ renderer.setRenderTarget(null);
718
+
719
+ const out = this.targetA;
720
+ [this.targetA, this.targetB] = [this.targetB, this.targetA];
721
+ return out.texture;
722
+ }
723
+
724
+ dispose() {
725
+ this.targetA.dispose();
726
+ this.targetB.dispose();
727
+ this.material.dispose();
728
+ this.quad.geometry.dispose();
729
+ }
730
+ }