three-realtime-rt 0.3.2 → 0.4.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.
@@ -26,6 +26,7 @@ ${SKY_GLSL}
26
26
  #define PI 3.14159265358979
27
27
 
28
28
  layout(location = 0) out vec4 outIrradiance;
29
+ layout(location = 1) out vec4 outSpecular; // dielectric direct specular (fresh, this frame)
29
30
 
30
31
  in vec2 vUv;
31
32
 
@@ -60,8 +61,10 @@ uniform vec4 uLightColorRadius[MAX_LIGHTS]; // rgb color*intensity, w radius
60
61
  uniform vec4 uLightDirCone[MAX_LIGHTS]; // spot: direction.xyz + cos(outer angle)
61
62
  uniform int uLightCount;
62
63
  uniform int uEmissiveCount; // NEE area-light triangles in row 1 of uMaterialsTex
64
+ uniform bool uEmissiveCDF; // importance-sample tris by the power CDF (row 66)
63
65
  uniform bool uReflEnabled; // traced reflections on metallic surfaces
64
66
  uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
67
+ uniform bool uBlendEnabled; // straight-through view continuation on blend surfaces
65
68
  uniform float uIor; // index of refraction for transmissive materials
66
69
  uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
67
70
  uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
@@ -176,6 +179,57 @@ void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
176
179
  metalness = t1.a;
177
180
  }
178
181
 
182
+ // ---------- PBR specular (Cook-Torrance GGX) ----------
183
+ // A separate specular radiance is accumulated for the primary surface's DIRECT
184
+ // lighting alongside the demodulated diffuse irradiance. Because CompositePass
185
+ // multiplies the irradiance by albedo, a white dielectric highlight (F0 ~= 0.04)
186
+ // cannot ride in that buffer — it is emitted into gSpec and written to a second
187
+ // MRT attachment (added by the composite WITHOUT the albedo multiply). Metals'
188
+ // specular is albedo-tinted (F0 = albedo), so it stays in the reflection path
189
+ // where the composite's albedo multiply supplies the tint; gSpec is therefore
190
+ // scaled by (1 - metal)(1 - transmission) at output. Net effective Fresnel
191
+ // across both buffers is mix(0.04, albedo, metal) without the lighting pass ever
192
+ // sampling albedo (that would push it past the 16-sampler minimum).
193
+ vec3 gSpec; // accumulated dielectric direct specular radiance
194
+ vec3 gViewDir; // unit vector from the primary surface toward the camera
195
+ float gSpecRough; // primary surface roughness (drives the GGX lobe width)
196
+ bool gWantSpec; // true only while shading the PRIMARY surface's direct light
197
+
198
+ float D_GGX(float NoH, float a) {
199
+ float a2 = a * a;
200
+ float d = NoH * NoH * (a2 - 1.0) + 1.0;
201
+ return a2 / max(PI * d * d, 1e-8);
202
+ }
203
+ // Height-correlated Smith visibility (already folds in the 1/(4 NoL NoV) term).
204
+ float V_SmithGGX(float NoV, float NoL, float a) {
205
+ float a2 = a * a;
206
+ float gv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
207
+ float gl = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
208
+ return 0.5 / max(gv + gl, 1e-5);
209
+ }
210
+ vec3 F_Schlick(float VoH, vec3 f0) {
211
+ return f0 + (1.0 - f0) * pow(clamp(1.0 - VoH, 0.0, 1.0), 5.0);
212
+ }
213
+ // Specular BRDF value (without the incoming NoL*radiance factor). F0 fixed at
214
+ // the dielectric 0.04 — metals are handled in the reflection path.
215
+ float ggxSpec(vec3 N, vec3 L) {
216
+ vec3 H = normalize(gViewDir + L);
217
+ float NoH = max(dot(N, H), 0.0);
218
+ float NoV = max(dot(N, gViewDir), 1e-4);
219
+ float NoL = max(dot(N, L), 1e-4);
220
+ float VoH = max(dot(gViewDir, H), 0.0);
221
+ // Clamp alpha off zero so a mirror-smooth dielectric does not produce an
222
+ // infinite spike the temporal buffer cannot resolve.
223
+ float a = max(gSpecRough * gSpecRough, 2e-3);
224
+ return D_GGX(NoH, a) * V_SmithGGX(NoV, NoL, a) * F_Schlick(VoH, vec3(0.04)).x;
225
+ }
226
+ // Add the dielectric specular for one light: li is the incoming radiance
227
+ // factor (light colour * cone / dist^2), NoL the geometric cosine.
228
+ void addSpec(vec3 N, vec3 L, vec3 li, float NoL) {
229
+ if (!gWantSpec) return;
230
+ gSpec += li * (NoL * ggxSpec(N, L));
231
+ }
232
+
179
233
  // ---------- lighting ----------
180
234
  // Direct irradiance (demodulated: no albedo) at point P with normal N,
181
235
  // from light i, with one shadow ray. Area-samples point lights for soft shadows.
@@ -218,7 +272,9 @@ vec3 lightContribution(int i, vec3 P, vec3 N) {
218
272
  if (NdotL <= 0.0) return vec3(0.0);
219
273
 
220
274
  if (occluded(P + N * uEps, L, maxDist)) return vec3(0.0);
221
- return colRad.rgb * (cone * NdotL / dist2);
275
+ vec3 li = colRad.rgb * (cone / dist2);
276
+ addSpec(N, L, li, NdotL); // same shadow ray shadows the highlight
277
+ return li * NdotL;
222
278
  }
223
279
 
224
280
  // Direct light at a GI bounce hit: sample ONE random light (weighted by count).
@@ -232,9 +288,44 @@ vec3 sampleOneLight(vec3 P, vec3 N) {
232
288
  // pick one triangle, sample a point on it, cast one shadow ray, convert the
233
289
  // area pdf to solid angle. Turns emitters into proper soft area lights instead
234
290
  // of surfaces a GI ray has to hit by luck.
291
+ //
292
+ // NOISE CAVEAT: emissive NEE is the highest-variance direct-light path in the
293
+ // engine — one triangle sample per pixel per frame, and the area-to-solid-angle
294
+ // conversion carries a 1/dist^2 that spikes into fireflies when a shading point
295
+ // sits close to a small emitter. Two mitigations stack here:
296
+ // 1. uEmissiveCDF (default on): the triangle is IMPORTANCE-SAMPLED by
297
+ // area x emitted luminance via the power CDF in the scene-data texture
298
+ // (row 2 + 64 — see SceneCompiler's layout comment). A big bright panel is
299
+ // picked proportionally more often than a tiny dim strip, and each sample
300
+ // is weighted by its true pick probability — same mean, far less variance
301
+ // than the uniform 1-of-N pick.
302
+ // 2. ReSTIR reservoirs converge each pixel onto the emitter that matters
303
+ // (the demo keeps restir on whenever emissive NEE is on;
304
+ // RealtimeRaytracer.compileScene logs a hint otherwise).
305
+ // fireflyClamp and the denoiser absorb the residual tail. Distance-aware
306
+ // selection and solid-angle triangle sampling remain future work.
235
307
  vec3 sampleEmissiveTri(vec3 P, vec3 N) {
236
308
  if (uEmissiveCount == 0) return vec3(0.0);
237
- int i = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1) * 4;
309
+ int idx;
310
+ float invProb; // 1 / P(picked this triangle)
311
+ if (uEmissiveCDF) {
312
+ // Binary search the power CDF: 8 steps covers MAX_EMISSIVE_TRIS = 256.
313
+ float u = rand();
314
+ int lo = 0;
315
+ int hi = uEmissiveCount - 1;
316
+ for (int s = 0; s < 8; s++) {
317
+ if (lo >= hi) break;
318
+ int mid = (lo + hi) >> 1;
319
+ if (u > texelFetch(uMaterialsTex, ivec2(mid, 66), 0).x) lo = mid + 1;
320
+ else hi = mid;
321
+ }
322
+ idx = lo;
323
+ invProb = 1.0 / max(texelFetch(uMaterialsTex, ivec2(idx, 66), 0).y, 1e-8);
324
+ } else {
325
+ idx = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1);
326
+ invProb = float(uEmissiveCount);
327
+ }
328
+ int i = idx * 4;
238
329
  vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0); // v0 | area
239
330
  vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0); // e1 | emit.r
240
331
  vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0); // e2 | emit.g
@@ -256,9 +347,14 @@ vec3 sampleEmissiveTri(vec3 P, vec3 N) {
256
347
  if (cosS <= 0.0 || cosL < 1e-4) return vec3(0.0);
257
348
  if (occluded(P + N * uEps, wi, dist)) return vec3(0.0);
258
349
 
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));
350
+ // Pick of one tri (probability 1/invProb) + uniform point on it:
351
+ // pdf_area = 1/(invProb·area). Solid-angle conversion gives irradiance
352
+ // Le · cosS · cosL / ( · pdf_area).
353
+ vec3 e = vec3(t1.w, t2.w, t3.w) * (cosS * cosL * invProb * t0.w / max(d2, 1e-6));
354
+
355
+ // Dielectric highlight from this emitter: e already folds in cosS, so the
356
+ // specular is e * (GGX BRDF) toward the sampled point (wi).
357
+ if (gWantSpec) gSpec += e * ggxSpec(N, wi);
262
358
 
263
359
  // Uniform-area sampling has huge single-sample variance for receivers close
264
360
  // to a big emitter (sampled point can land almost on top of P, d² → 0);
@@ -336,6 +432,9 @@ vec3 shadeReservoir(vec3 P, vec3 N) {
336
432
  }
337
433
 
338
434
  if (occluded(P + N * uEps, wi, maxDist)) return vec3(0.0);
435
+ // Dielectric highlight from the reservoir winner (C = li * cos, shared with
436
+ // the diffuse term; W = res.a is applied to both).
437
+ if (gWantSpec) gSpec += C * (ggxSpec(N, wi) * res.a);
339
438
  vec3 e = C * res.a;
340
439
  // Safety clamp, same budget as the emissive direct clamp elsewhere.
341
440
  float l = dot(e, vec3(0.299, 0.587, 0.114));
@@ -401,11 +500,52 @@ vec3 glossyReflect(vec3 V, vec3 N, float rough) {
401
500
  return refl;
402
501
  }
403
502
 
503
+ // Analytic lights live in uniform arrays, not the BVH, so a traced reflection
504
+ // ray never sees them — a mirror under a spotlight would show no glint. Evaluate
505
+ // each light as a small area source along the (roughness-jittered) reflection
506
+ // direction: if refl points within the light's angular radius, the light's disc
507
+ // is reflected, so add its radiance. The jitter in refl (from glossyReflect)
508
+ // softens the disc over temporal accumulation, widening the glint with
509
+ // roughness. Shadowed with the same any-hit occluder as direct lighting.
510
+ vec3 analyticGlint(vec3 P, vec3 refl) {
511
+ vec3 sum = vec3(0.0);
512
+ for (int i = 0; i < MAX_LIGHTS; i++) {
513
+ if (i >= uLightCount) break;
514
+ vec4 posType = uLightPosType[i];
515
+ vec4 colRad = uLightColorRadius[i];
516
+ if (posType.w < 0.5 || posType.w >= 1.5) {
517
+ // point / spot
518
+ vec3 d = posType.xyz - P;
519
+ float dl = length(d);
520
+ if (dl < 1e-4) continue;
521
+ vec3 toL = d / dl;
522
+ float cone = spotFalloff(i, -toL);
523
+ if (cone <= 0.0) continue;
524
+ // Angular radius of the sphere light + a small floor so a zero-radius
525
+ // light still shows a pin-point glint.
526
+ float ang = atan(max(colRad.w, 1e-3) / dl) + 0.01;
527
+ if (dot(refl, toL) < cos(ang)) continue;
528
+ if (occluded(P + refl * uEps, refl, dl)) continue;
529
+ sum += colRad.rgb * (cone / (dl * dl));
530
+ } else {
531
+ // directional: fixed small angular size (colRad.w = sun softness)
532
+ vec3 toL = normalize(-posType.xyz);
533
+ float ang = max(colRad.w, 0.02) + 0.01;
534
+ if (dot(refl, toL) < cos(ang)) continue;
535
+ if (occluded(P + refl * uEps, refl, 1e7)) continue;
536
+ sum += colRad.rgb;
537
+ }
538
+ }
539
+ return sum;
540
+ }
541
+
404
542
  // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
405
543
  // refraction (enter at P, march to the exit surface, refract again).
406
544
  vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
407
545
  vec3 refl = glossyReflect(V, N, rough);
408
- vec3 reflRad = dot(refl, N) > 0.0 ? traceRadiance(P + N * uEps, refl, true) : vec3(0.0);
546
+ vec3 reflRad = dot(refl, N) > 0.0
547
+ ? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
548
+ : vec3(0.0);
409
549
 
410
550
  float eta = 1.0 / uIor;
411
551
  vec3 rd = refract(V, N, eta);
@@ -438,6 +578,7 @@ void main() {
438
578
  vec4 wp = texture(uGWorldPos, vUv);
439
579
  if (wp.w < 0.5) {
440
580
  outIrradiance = vec4(0.0);
581
+ outSpecular = vec4(0.0);
441
582
  return;
442
583
  }
443
584
 
@@ -450,11 +591,23 @@ void main() {
450
591
  vec3 P = wp.xyz;
451
592
  vec4 nmSample = texture(uGNormalMetal, vUv);
452
593
  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;
594
+ // Decode the packed material word (see GBufferPass): [4,5]alpha blend
595
+ // (w - 4 = opacity), [2,4) glass (w - 2 = transmission), else metalness.
596
+ float matW = nmSample.w;
597
+ bool blend = matW >= 4.0;
598
+ float opacity = blend ? clamp(matW - 4.0, 0.0, 1.0) : 1.0;
599
+ float transmission = (matW >= 2.0 && matW < 4.0) ? clamp(matW - 2.0, 0.0, 1.0) : 0.0;
600
+ float metal = matW < 2.0 ? matW : 0.0;
456
601
  float rough = clamp(wp.w - 1.0, 0.0, 1.0);
457
602
 
603
+ // Cook-Torrance specular state for this primary surface. gWantSpec gates the
604
+ // GGX term to PRIMARY direct lighting only (GI-bounce direct light, below,
605
+ // reuses the same functions but must not pollute the highlight buffer).
606
+ gSpec = vec3(0.0);
607
+ gViewDir = normalize(uCameraPos - P);
608
+ gSpecRough = rough;
609
+ gWantSpec = true;
610
+
458
611
  // --- direct lighting ---
459
612
  // ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
460
613
  // light count). Stochastic: one blind random sample. Full: one shadow ray
@@ -480,6 +633,7 @@ void main() {
480
633
  // DOUBLED — the temporal average converges to the same brightness
481
634
  // (unbiased) while GI's ray cost halves; accumulation + denoise absorb
482
635
  // the alternation.
636
+ gWantSpec = false; // secondary bounces contribute to diffuse GI only
483
637
  vec3 indirect = vec3(0.0);
484
638
  if (uGIEnabled) {
485
639
  bool trace = !uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0);
@@ -503,7 +657,10 @@ void main() {
503
657
  if (dot(refl, N) > 0.0) {
504
658
  // Metals have no diffuse term: replace by metalness. The composite's
505
659
  // albedo multiply then tints the reflection (F0 = albedo for metals).
506
- sampleIrr = mix(sampleIrr, traceRadiance(P + N * uEps, refl, true), metal);
660
+ // analyticGlint adds the direct lights the reflection ray cannot see, so
661
+ // a metal under a spotlight shows a proper (albedo-tinted) glint.
662
+ vec3 reflRad = traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl);
663
+ sampleIrr = mix(sampleIrr, reflRad, metal);
507
664
  }
508
665
  }
509
666
 
@@ -513,10 +670,48 @@ void main() {
513
670
  sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
514
671
  }
515
672
 
673
+ // --- alpha blend: straight-through view continuation ---
674
+ // A transparent surface is primary-visible in the G-buffer but was kept out of
675
+ // the BVH, so a ray along the view direction passes THROUGH it to whatever is
676
+ // behind. Trace that continuation and shade it like a glass/GI hit (emitters
677
+ // keep their emission — this is direct visibility through the pane — sky/env on
678
+ // a miss). The two quantities live at DIFFERENT scales: sampleIrr is the
679
+ // pane's own demodulated surface light (composite re-applies albedo), while
680
+ // the behind trace is final outgoing radiance — mixing them in one slot makes
681
+ // the pane term drown out what shows through. So the behind image rides the
682
+ // SPECULAR attachment instead (composite adds that buffer without the albedo
683
+ // multiply, and its short-history accumulation suits behind-content that
684
+ // parallaxes against the pane), and CompositePass performs the opacity blend
685
+ // where the pane's albedo is actually available. sampleIrr keeps only the
686
+ // pane's own surface lighting, which is static on the surface and accumulates
687
+ // with normal full-length history.
688
+ vec3 blendBehind = vec3(0.0);
689
+ if (uBlendEnabled && blend) {
690
+ vec3 V = normalize(P - uCameraPos);
691
+ blendBehind = traceRadiance(P + V * uEps, V, true);
692
+ }
693
+
516
694
  // A single NaN/Inf sample would poison the EMA history for good (mix() with
517
695
  // NaN stays NaN until a disocclusion resets the pixel) — sanitize first.
518
696
  if (any(isnan(sampleIrr)) || any(isinf(sampleIrr))) sampleIrr = vec3(0.0);
519
697
 
698
+ // Fresh dielectric direct specular for this frame. Metals/glass carry their
699
+ // (albedo-tinted) specular in the reflection path above, so scale their share
700
+ // out of the white buffer — the effective F0 is mix(0.04, albedo, metal),
701
+ // split across the two buffers. The separate SpecularAccumPass reprojects and
702
+ // temporally accumulates this with a short (near-mirror) history.
703
+ // Blend pixels repurpose this attachment for the straight-through behind
704
+ // radiance (see above) — their dielectric highlight is dropped, a fair trade
705
+ // for a correct-scale see-through image.
706
+ vec3 spec = blend ? blendBehind : gSpec * ((1.0 - metal) * (1.0 - transmission));
707
+ if (any(isnan(spec)) || any(isinf(spec))) spec = vec3(0.0);
708
+ if (!blend) {
709
+ float specLum = dot(spec, vec3(0.299, 0.587, 0.114));
710
+ float specCap = uFireflyClamp * 4.0; // narrow lobes spike; keep the EMA stable
711
+ if (specLum > specCap) spec *= specCap / specLum;
712
+ }
713
+ outSpecular = vec4(spec, 1.0);
714
+
520
715
  // --- temporal reprojection: pull validated history from last frame ---
521
716
  float count = 1.0;
522
717
  vec3 history = vec3(0.0);
@@ -546,6 +741,10 @@ void main() {
546
741
  // reflection under camera motion — and specular rays are nearly
547
742
  // deterministic, so they don't need the accumulation anyway.
548
743
  float specHist = max(metal, transmission) * (1.0 - rough);
744
+ // (Blend pixels need no shortening here: this slot holds only the
745
+ // pane's own surface light, which is static on the surface. The
746
+ // parallaxing behind-image rides the specular attachment, whose
747
+ // accumulation is short-history by design.)
549
748
  float histCap = mix(uMaxHistory, min(uMaxHistory, 10.0), specHist);
550
749
  count = clamp(h.a, 0.0, histCap) + 1.0;
551
750
  history = h.rgb;
@@ -561,15 +760,107 @@ void main() {
561
760
  }
562
761
  `;
563
762
 
763
+ // Specular accumulation: the lighting pass emits FRESH dielectric specular in
764
+ // MRT attachment 1 (it has no spare sampler to read its own specular history).
765
+ // This second, cheap program reprojects that fresh sample against the previous
766
+ // accumulated specular and EMA-blends it — the same temporal scheme as the
767
+ // irradiance buffer, but with the SHORT (near-mirror) history a view-dependent
768
+ // highlight needs so it tracks moving lights and the camera without smearing.
769
+ const specAccumFrag = /* glsl */ `
770
+ precision highp float;
771
+
772
+ layout(location = 0) out vec4 outSpec;
773
+
774
+ in vec2 vUv;
775
+
776
+ uniform sampler2D uFreshSpec;
777
+ uniform sampler2D uPrevSpec;
778
+ uniform sampler2D uGWorldPos;
779
+ uniform sampler2D uGNormalMetal;
780
+ uniform sampler2D uPrevGWorldPos;
781
+ uniform mat4 uPrevViewProj;
782
+ uniform mat4 uViewProj;
783
+ uniform vec3 uCameraPos;
784
+ uniform float uEps;
785
+ uniform float uMaxHistory;
786
+ uniform bool uTemporalReprojection;
787
+
788
+ void main() {
789
+ vec4 wp = texture(uGWorldPos, vUv);
790
+ if (wp.w < 0.5) { outSpec = vec4(0.0); return; }
791
+ vec3 P = wp.xyz;
792
+ vec3 N = normalize(texture(uGNormalMetal, vUv).xyz);
793
+ float rough = clamp(wp.w - 1.0, 0.0, 1.0);
794
+ vec3 fresh = texture(uFreshSpec, vUv).rgb;
795
+
796
+ float count = 1.0;
797
+ vec3 history = vec3(0.0);
798
+ if (uTemporalReprojection) {
799
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
800
+ vec4 clipC = uViewProj * vec4(P, 1.0);
801
+ if (clip.w > 0.0 && clipC.w > 0.0) {
802
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
803
+ vec2 currUv = (clipC.xy / clipC.w) * 0.5 + 0.5;
804
+ prevUv -= currUv - vUv; // cancel the G-buffer texel sub-pixel offset
805
+ if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
806
+ vec4 prevPos = texture(uPrevGWorldPos, prevUv);
807
+ float tol = 0.005 * distance(P, uCameraPos) + 20.0 * uEps;
808
+ if (prevPos.w > 0.5 && abs(dot(P - prevPos.xyz, N)) < tol) {
809
+ vec4 h = texture(uPrevSpec, prevUv);
810
+ // Short history: specular is view-dependent, so a long EMA smears the
811
+ // highlight under motion. Smoother (sharper) highlights react fastest.
812
+ float specHist = 1.0 - rough;
813
+ float histCap = mix(min(uMaxHistory, 32.0), min(uMaxHistory, 8.0), specHist);
814
+ count = clamp(h.a, 0.0, histCap) + 1.0;
815
+ history = h.rgb;
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ vec3 blended = mix(history, fresh, 1.0 / count);
822
+ if (any(isnan(blended)) || any(isinf(blended))) blended = vec3(0.0);
823
+ outSpec = vec4(blended, count);
824
+ }
825
+ `;
826
+
827
+ // Irradiance-history carry for renderScale/canvas resizes. The shared CopyPass
828
+ // writes ONE output; rendering it into the 2-attachment MRT is a draw-buffer
829
+ // mismatch that some drivers (ANGLE/D3D11) reject with INVALID_OPERATION. This
830
+ // 2-output copy matches the MRT: attachment 0 = resampled history (alpha/count
831
+ // clamped), attachment 1 = 0 (fresh-written next frame anyway).
832
+ const mrtCarryFrag = /* glsl */ `
833
+ precision highp float;
834
+ layout(location = 0) out vec4 o0;
835
+ layout(location = 1) out vec4 o1;
836
+ in vec2 vUv;
837
+ uniform sampler2D uTex;
838
+ uniform float uCountClamp;
839
+ void main() {
840
+ vec4 c = texture(uTex, vUv);
841
+ if (uCountClamp >= 0.0) c.a = min(c.a, uCountClamp);
842
+ o0 = c;
843
+ o1 = vec4(0.0);
844
+ }
845
+ `;
846
+
564
847
  /**
565
848
  * Fullscreen pass: for every G-buffer pixel, trace shadow rays to every light and
566
849
  * one cosine-weighted GI bounce against the BVH. Outputs demodulated irradiance,
567
850
  * progressively accumulated into a ping-pong float target while the camera is still.
851
+ *
852
+ * The target is a 2-attachment MRT: [0] demodulated diffuse irradiance,
853
+ * [1] FRESH dielectric direct specular (temporally accumulated by specAccumFrag
854
+ * into a second ping-pong pair, specA/specB).
568
855
  */
569
856
  export class RTLightingPass {
570
857
  constructor(width, height) {
571
858
  this.targetA = this._makeTarget(width, height);
572
859
  this.targetB = this._makeTarget(width, height);
860
+ // Accumulated specular history (ping-pong), fed by the fresh specular in
861
+ // targetA/B attachment 1.
862
+ this.specA = this._makeSpecTarget(width, height);
863
+ this.specB = this._makeSpecTarget(width, height);
573
864
 
574
865
  this.material = new THREE.ShaderMaterial({
575
866
  glslVersion: THREE.GLSL3,
@@ -599,8 +890,10 @@ export class RTLightingPass {
599
890
  uLightDirCone: { value: [] },
600
891
  uLightCount: { value: 0 },
601
892
  uEmissiveCount: { value: 0 },
893
+ uEmissiveCDF: { value: true },
602
894
  uReflEnabled: { value: true },
603
895
  uRefrEnabled: { value: true },
896
+ uBlendEnabled: { value: true },
604
897
  uIor: { value: 1.5 },
605
898
  uLightStochastic: { value: false },
606
899
  uGIHalfRate: { value: false },
@@ -620,6 +913,39 @@ export class RTLightingPass {
620
913
  depthWrite: false,
621
914
  });
622
915
 
916
+ // Specular temporal accumulation program (its own sampler budget — well
917
+ // clear of the lighting pass's 16-sampler ceiling).
918
+ this.specMaterial = new THREE.ShaderMaterial({
919
+ glslVersion: THREE.GLSL3,
920
+ vertexShader: fullscreenVert,
921
+ fragmentShader: specAccumFrag,
922
+ uniforms: {
923
+ uFreshSpec: { value: null },
924
+ uPrevSpec: { value: null },
925
+ uGWorldPos: { value: null },
926
+ uGNormalMetal: { value: null },
927
+ uPrevGWorldPos: { value: null },
928
+ uPrevViewProj: { value: new THREE.Matrix4() },
929
+ uViewProj: { value: new THREE.Matrix4() },
930
+ uCameraPos: { value: new THREE.Vector3() },
931
+ uEps: { value: 1e-3 },
932
+ uMaxHistory: { value: 128 },
933
+ uTemporalReprojection: { value: true },
934
+ },
935
+ depthTest: false,
936
+ depthWrite: false,
937
+ });
938
+
939
+ // 2-output history carry (see mrtCarryFrag) — matches the MRT's draw buffers.
940
+ this.carryMaterial = new THREE.ShaderMaterial({
941
+ glslVersion: THREE.GLSL3,
942
+ vertexShader: fullscreenVert,
943
+ fragmentShader: mrtCarryFrag,
944
+ uniforms: { uTex: { value: null }, uCountClamp: { value: -1 } },
945
+ depthTest: false,
946
+ depthWrite: false,
947
+ });
948
+
623
949
  this.scene = new THREE.Scene();
624
950
  this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
625
951
  this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
@@ -630,7 +956,21 @@ export class RTLightingPass {
630
956
  _makeTarget(width, height) {
631
957
  // Half-float + linear: history is sampled bilinearly at reprojected UVs,
632
958
  // and fp16 halves the bandwidth (EMA blending never accumulates a raw sum,
633
- // so fp16 precision is sufficient).
959
+ // so fp16 precision is sufficient). Two attachments: [0] irradiance,
960
+ // [1] fresh dielectric specular.
961
+ const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, {
962
+ minFilter: THREE.LinearFilter,
963
+ magFilter: THREE.LinearFilter,
964
+ format: THREE.RGBAFormat,
965
+ type: THREE.HalfFloatType,
966
+ depthBuffer: false,
967
+ stencilBuffer: false,
968
+ });
969
+ for (const tex of t.texture) tex.generateMipmaps = false;
970
+ return t;
971
+ }
972
+
973
+ _makeSpecTarget(width, height) {
634
974
  const t = new THREE.WebGLRenderTarget(width, height, {
635
975
  minFilter: THREE.LinearFilter,
636
976
  magFilter: THREE.LinearFilter,
@@ -650,7 +990,7 @@ export class RTLightingPass {
650
990
  renderer.getClearColor(prevColor);
651
991
  const prevAlpha = renderer.getClearAlpha();
652
992
  renderer.setClearColor(0x000000, 0);
653
- for (const t of [this.targetA, this.targetB]) {
993
+ for (const t of [this.targetA, this.targetB, this.specA, this.specB]) {
654
994
  renderer.setRenderTarget(t);
655
995
  renderer.clear(true, false, false);
656
996
  }
@@ -661,6 +1001,8 @@ export class RTLightingPass {
661
1001
  setSize(width, height) {
662
1002
  this.targetA.setSize(width, height);
663
1003
  this.targetB.setSize(width, height);
1004
+ this.specA.setSize(width, height);
1005
+ this.specB.setSize(width, height);
664
1006
  }
665
1007
 
666
1008
  /**
@@ -679,11 +1021,31 @@ export class RTLightingPass {
679
1021
  resizeCarry(renderer, copyPass, width, height, carryFrames) {
680
1022
  const newA = this._makeTarget(width, height);
681
1023
  const newB = this._makeTarget(width, height);
682
- copyPass.blit(renderer, this.targetB.texture, newB, carryFrames);
1024
+ // Carry the irradiance history (attachment 0) with the 2-output carry
1025
+ // material so the draw matches the MRT's draw buffers (a 1-output CopyPass
1026
+ // blit here is INVALID_OPERATION on ANGLE/D3D11). Attachment 1 is fresh-
1027
+ // written every frame, so it needs no carry.
1028
+ this.carryMaterial.uniforms.uTex.value = this.targetB.texture[0];
1029
+ this.carryMaterial.uniforms.uCountClamp.value = carryFrames;
1030
+ this.quad.material = this.carryMaterial;
1031
+ const prev = renderer.getRenderTarget();
1032
+ renderer.setRenderTarget(newB);
1033
+ renderer.render(this.scene, this.camera);
1034
+ renderer.setRenderTarget(prev);
1035
+ this.quad.material = this.material;
683
1036
  this.targetA.dispose();
684
1037
  this.targetB.dispose();
685
1038
  this.targetA = newA;
686
1039
  this.targetB = newB;
1040
+
1041
+ // Specular history carries the same way (freshest is specB — see render()).
1042
+ const newSpecA = this._makeSpecTarget(width, height);
1043
+ const newSpecB = this._makeSpecTarget(width, height);
1044
+ copyPass.blit(renderer, this.specB.texture, newSpecB, carryFrames);
1045
+ this.specA.dispose();
1046
+ this.specB.dispose();
1047
+ this.specA = newSpecA;
1048
+ this.specB = newSpecB;
687
1049
  }
688
1050
 
689
1051
  setCompiledScene(compiled) {
@@ -701,30 +1063,62 @@ export class RTLightingPass {
701
1063
  u.uEmissiveCount.value = compiled.emissiveTriCount;
702
1064
  }
703
1065
 
704
- /** Renders into targetA (reading targetB as history), then swaps. Returns the fresh accum texture. */
1066
+ /**
1067
+ * Renders lighting into targetA (reading targetB as irradiance history), then
1068
+ * accumulates the fresh specular (targetA attachment 1) into specA (reading
1069
+ * specB as history). Swaps both ping-pong pairs. Returns { irradiance,
1070
+ * specular } textures for this frame.
1071
+ */
705
1072
  render(renderer, gbuffer, frame, reservoirTexture = null) {
706
1073
  const u = this.material.uniforms;
707
1074
  u.uGWorldPos.value = gbuffer.worldPos;
708
1075
  u.uGNormalMetal.value = gbuffer.normalMetal;
709
1076
  u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
710
- u.uPrevAccum.value = this.targetB.texture;
1077
+ u.uPrevAccum.value = this.targetB.texture[0];
711
1078
  u.uReservoir.value = reservoirTexture;
712
1079
  u.uRestirEnabled.value = reservoirTexture !== null;
713
1080
  u.uFrame.value = frame;
714
1081
 
1082
+ // 1. lighting (MRT): [0] accumulated irradiance, [1] fresh specular.
1083
+ this.quad.material = this.material;
715
1084
  renderer.setRenderTarget(this.targetA);
716
1085
  renderer.render(this.scene, this.camera);
1086
+
1087
+ // 2. specular temporal accumulation: fresh (targetA[1]) + history (specB).
1088
+ const su = this.specMaterial.uniforms;
1089
+ su.uFreshSpec.value = this.targetA.texture[1];
1090
+ su.uPrevSpec.value = this.specB.texture;
1091
+ su.uGWorldPos.value = gbuffer.worldPos;
1092
+ su.uGNormalMetal.value = gbuffer.normalMetal;
1093
+ su.uPrevGWorldPos.value = gbuffer.prevWorldPos;
1094
+ su.uPrevViewProj.value.copy(u.uPrevViewProj.value);
1095
+ su.uViewProj.value.copy(u.uViewProj.value);
1096
+ su.uCameraPos.value.copy(u.uCameraPos.value);
1097
+ su.uEps.value = u.uEps.value;
1098
+ su.uMaxHistory.value = u.uMaxHistory.value;
1099
+ su.uTemporalReprojection.value = u.uTemporalReprojection.value;
1100
+ this.quad.material = this.specMaterial;
1101
+ renderer.setRenderTarget(this.specA);
1102
+ renderer.render(this.scene, this.camera);
1103
+
1104
+ this.quad.material = this.material; // restore for the next caller
717
1105
  renderer.setRenderTarget(null);
718
1106
 
719
- const out = this.targetA;
1107
+ const outIrr = this.targetA.texture[0];
1108
+ const outSpec = this.specA.texture;
720
1109
  [this.targetA, this.targetB] = [this.targetB, this.targetA];
721
- return out.texture;
1110
+ [this.specA, this.specB] = [this.specB, this.specA];
1111
+ return { irradiance: outIrr, specular: outSpec };
722
1112
  }
723
1113
 
724
1114
  dispose() {
725
1115
  this.targetA.dispose();
726
1116
  this.targetB.dispose();
1117
+ this.specA.dispose();
1118
+ this.specB.dispose();
727
1119
  this.material.dispose();
1120
+ this.specMaterial.dispose();
1121
+ this.carryMaterial.dispose();
728
1122
  this.quad.geometry.dispose();
729
1123
  }
730
1124
  }