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.
@@ -75,6 +75,18 @@ uniform float uEnvIntensity;
75
75
  uniform float uFrame;
76
76
  uniform float uEps;
77
77
  uniform bool uGIEnabled;
78
+ // EXPERIMENTAL: when an external ReSTIR GI pass supplies the 1-bounce indirect
79
+ // (added downstream at the denoise stage), skip the inline GI trace so it isn't
80
+ // counted twice. A uniform, NOT a sampler — the lighting pass is already at the
81
+ // WebGL2 16-sampler minimum and cannot take another.
82
+ uniform bool uExternalGI;
83
+
84
+ // BVH traversal-cost heatmap (outputMode 7). When uCostView is on, main() writes
85
+ // the per-pixel shadow-ray node-visit count (gBvhVisits, from bvhAnyHit.glsl.js)
86
+ // through costPalette() into the irradiance attachment INSTEAD of the accumulated
87
+ // lighting — bypassing temporal blending — so the debug view reads the raw cost.
88
+ uniform bool uCostView;
89
+ uniform float uCostScale; // multiplies the visit count before the palette (default 1/96)
78
90
 
79
91
  // Procedural sky (when enabled, replaces the flat env colour as the "miss" term
80
92
  // for GI rays — this is what gives natural outdoor bounce light).
@@ -541,13 +553,13 @@ vec3 analyticGlint(vec3 P, vec3 refl) {
541
553
 
542
554
  // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
543
555
  // refraction (enter at P, march to the exit surface, refract again).
544
- vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
556
+ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
545
557
  vec3 refl = glossyReflect(V, N, rough);
546
558
  vec3 reflRad = dot(refl, N) > 0.0
547
559
  ? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
548
560
  : vec3(0.0);
549
561
 
550
- float eta = 1.0 / uIor;
562
+ float eta = 1.0 / ior;
551
563
  vec3 rd = refract(V, N, eta);
552
564
  if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
553
565
  float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
@@ -563,7 +575,7 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
563
575
  vec3 xN = normalize(attr.xyz);
564
576
  if (dot(xN, rd) > 0.0) xN = -xN;
565
577
  vec3 xP = ro + rd * dist;
566
- vec3 rd2 = refract(rd, xN, uIor);
578
+ vec3 rd2 = refract(rd, xN, ior);
567
579
  if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
568
580
  refrRad = traceRadiance(xP - xN * uEps, rd2, true);
569
581
  } else {
@@ -574,6 +586,26 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
574
586
  return mix(refrRad, reflRad, fres);
575
587
  }
576
588
 
589
+ // Compact cold->hot ramp for the BVH-cost heatmap. Piecewise mix of five
590
+ // anchors (deep blue -> green -> yellow -> red -> white) over four equal
591
+ // segments — cheap, no textures, no extra samplers. t is the normalised cost
592
+ // (visit count * uCostScale), clamped to [0,1]; saturating at white = the most
593
+ // expensive pixels.
594
+ vec3 costPalette(float t) {
595
+ t = clamp(t, 0.0, 1.0);
596
+ const vec3 c0 = vec3(0.02, 0.05, 0.45); // cold: cheap (few boxes)
597
+ const vec3 c1 = vec3(0.05, 0.55, 0.25); // green
598
+ const vec3 c2 = vec3(0.95, 0.85, 0.10); // yellow
599
+ const vec3 c3 = vec3(0.90, 0.10, 0.05); // red
600
+ const vec3 c4 = vec3(1.00, 1.00, 1.00); // hot: expensive (many boxes)
601
+ float s = t * 4.0;
602
+ vec3 col = mix(c0, c1, clamp(s, 0.0, 1.0));
603
+ col = mix(col, c2, clamp(s - 1.0, 0.0, 1.0));
604
+ col = mix(col, c3, clamp(s - 2.0, 0.0, 1.0));
605
+ col = mix(col, c4, clamp(s - 3.0, 0.0, 1.0));
606
+ return col;
607
+ }
608
+
577
609
  void main() {
578
610
  vec4 wp = texture(uGWorldPos, vUv);
579
611
  if (wp.w < 0.5) {
@@ -599,6 +631,10 @@ void main() {
599
631
  float transmission = (matW >= 2.0 && matW < 4.0) ? clamp(matW - 2.0, 0.0, 1.0) : 0.0;
600
632
  float metal = matW < 2.0 ? matW : 0.0;
601
633
  float rough = clamp(wp.w - 1.0, 0.0, 1.0);
634
+ // Per-material IOR rides the [3,4) glass sub-band (full-transmission glass, see
635
+ // GBufferPass). Below 3 (partial glass) or non-glass, fall back to the global
636
+ // rt.ior uniform. material.ior wins whenever it was encoded. (Task 2)
637
+ float ior = (matW >= 3.0 && matW < 4.0) ? (1.0 + (matW - 3.0)) : uIor;
602
638
 
603
639
  // Cook-Torrance specular state for this primary surface. gWantSpec gates the
604
640
  // GGX term to PRIMARY direct lighting only (GI-bounce direct light, below,
@@ -608,6 +644,11 @@ void main() {
608
644
  gSpecRough = rough;
609
645
  gWantSpec = true;
610
646
 
647
+ // Reset the shadow-ray traversal-cost counter for this pixel. It accumulates
648
+ // across every occluded() call below (direct, GI, reflection, glass) and is
649
+ // read once at the end when uCostView is on (see the cost-heatmap branch).
650
+ gBvhVisits = 0;
651
+
611
652
  // --- direct lighting ---
612
653
  // ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
613
654
  // light count). Stochastic: one blind random sample. Full: one shadow ray
@@ -634,11 +675,32 @@ void main() {
634
675
  // (unbiased) while GI's ray cost halves; accumulation + denoise absorb
635
676
  // the alternation.
636
677
  gWantSpec = false; // secondary bounces contribute to diffuse GI only
678
+ // BLEND pixels reuse THIS call site as their straight-through view
679
+ // continuation instead of a GI bounce (their behind-image rides the specular
680
+ // attachment; the pane forgoes its own GI bounce — visually negligible, and
681
+ // it saves a ray). CRITICAL CALL-SITE BUDGET: traceRadiance may appear at
682
+ // most THREE times in this shader (glass refraction exit, this unified
683
+ // secondary site, the metal-reflection path). WebKit's GLSL->Metal
684
+ // translation silently emits a broken program at a FOURTH inlined call site
685
+ // (clean compile, black output on every iOS browser) — bisected live on an
686
+ // iPad, 2026-07-22. Never add a call site; extend this one.
637
687
  vec3 indirect = vec3(0.0);
638
- if (uGIEnabled) {
639
- bool trace = !uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0);
640
- if (trace) {
641
- indirect = traceRadiance(P + N * uEps, cosineSampleHemisphere(N, rand2()), false);
688
+ vec3 blendBehind = vec3(0.0);
689
+ bool wantBehind = uBlendEnabled && blend;
690
+ // uExternalGI (experimental ReSTIR GI): the GIReservoirPass supplies the
691
+ // bounce, so the inline GI ray is skipped — but the blend continuation is
692
+ // NOT GI and must keep tracing regardless.
693
+ bool wantGI = uGIEnabled && !uExternalGI && !wantBehind
694
+ && (!uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0));
695
+ if (wantBehind || wantGI) {
696
+ vec3 Vv = normalize(P - uCameraPos);
697
+ vec3 dir = wantBehind ? Vv : cosineSampleHemisphere(N, rand2());
698
+ vec3 org = wantBehind ? P + Vv * uEps : P + N * uEps;
699
+ vec3 r = traceRadiance(org, dir, wantBehind);
700
+ if (wantBehind) {
701
+ blendBehind = r;
702
+ } else {
703
+ indirect = r;
642
704
  if (uGIHalfRate) indirect *= 2.0;
643
705
  }
644
706
  }
@@ -667,7 +729,7 @@ void main() {
667
729
  // --- traced glass: Fresnel reflection + two-interface refraction ---
668
730
  if (uRefrEnabled && transmission > 0.001) {
669
731
  vec3 V = normalize(P - uCameraPos);
670
- sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
732
+ sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough, ior), transmission);
671
733
  }
672
734
 
673
735
  // --- alpha blend: straight-through view continuation ---
@@ -685,11 +747,8 @@ void main() {
685
747
  // where the pane's albedo is actually available. sampleIrr keeps only the
686
748
  // pane's own surface lighting, which is static on the surface and accumulates
687
749
  // 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
- }
750
+ // (The straight-through trace itself happens at the unified secondary-ray
751
+ // call site above — see the Metal call-site-count note there.)
693
752
 
694
753
  // A single NaN/Inf sample would poison the EMA history for good (mix() with
695
754
  // NaN stays NaN until a disocclusion resets the pixel) — sanitize first.
@@ -757,6 +816,17 @@ void main() {
757
816
  // the fresh sample is used as-is.
758
817
  vec3 blended = mix(history, sampleIrr, 1.0 / count);
759
818
  outIrradiance = vec4(blended, count);
819
+
820
+ // BVH traversal-cost heatmap (outputMode 7). Overwrite the accumulated
821
+ // lighting with the palette-mapped shadow-ray node-visit count for this pixel.
822
+ // Alpha is forced to 1.0 so temporal history never builds on the cost image
823
+ // (each frame is a fresh snapshot), and the specular attachment is cleared so
824
+ // the composite's cost branch shows the palette alone. Uniform branch: when
825
+ // uCostView is off this is skipped and the writes above stand unchanged.
826
+ if (uCostView) {
827
+ outIrradiance = vec4(costPalette(float(gBvhVisits) * uCostScale), 1.0);
828
+ outSpecular = vec4(0.0);
829
+ }
760
830
  }
761
831
  `;
762
832
 
@@ -854,18 +924,31 @@ void main() {
854
924
  * into a second ping-pong pair, specA/specB).
855
925
  */
856
926
  export class RTLightingPass {
857
- constructor(width, height) {
927
+ // `specMRT: false` is the WebKit fallback: iOS Safari (every iOS browser)
928
+ // silently fails the 2-attachment half-float MRT draw — the whole lighting
929
+ // pass renders black. RealtimeRaytracer probes this functionally at
930
+ // construction; in fallback mode this pass allocates a single-attachment
931
+ // target exactly like 0.3.x, the shader's second output collapses to a dead
932
+ // local variable, and render() returns { specular: null } (the caller then
933
+ // runs with specular off; blend surfaces degrade to opaque as documented).
934
+ constructor(width, height, { specMRT = true } = {}) {
935
+ this.specMRT = specMRT;
858
936
  this.targetA = this._makeTarget(width, height);
859
937
  this.targetB = this._makeTarget(width, height);
860
938
  // Accumulated specular history (ping-pong), fed by the fresh specular in
861
939
  // targetA/B attachment 1.
862
- this.specA = this._makeSpecTarget(width, height);
863
- this.specB = this._makeSpecTarget(width, height);
940
+ this.specA = specMRT ? this._makeSpecTarget(width, height) : null;
941
+ this.specB = specMRT ? this._makeSpecTarget(width, height) : null;
864
942
 
865
943
  this.material = new THREE.ShaderMaterial({
866
944
  glslVersion: THREE.GLSL3,
867
945
  vertexShader: fullscreenVert,
868
- fragmentShader: rtLightingFrag,
946
+ fragmentShader: specMRT
947
+ ? rtLightingFrag
948
+ : rtLightingFrag.replace(
949
+ "layout(location = 1) out vec4 outSpecular;",
950
+ "vec4 outSpecular; // single-target fallback: dead store"
951
+ ),
869
952
  uniforms: {
870
953
  bvhStatic: { value: null },
871
954
  bvhDynamic: { value: null },
@@ -902,6 +985,9 @@ export class RTLightingPass {
902
985
  uFrame: { value: 0 },
903
986
  uEps: { value: 1e-3 },
904
987
  uGIEnabled: { value: true },
988
+ uExternalGI: { value: false },
989
+ uCostView: { value: false },
990
+ uCostScale: { value: 1 / 96 },
905
991
  uSkyEnabled: { value: false },
906
992
  uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
907
993
  uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
@@ -936,11 +1022,18 @@ export class RTLightingPass {
936
1022
  depthWrite: false,
937
1023
  });
938
1024
 
939
- // 2-output history carry (see mrtCarryFrag) — matches the MRT's draw buffers.
1025
+ // 2-output history carry (see mrtCarryFrag) — matches the MRT's draw
1026
+ // buffers. In single-target fallback the second output collapses the same
1027
+ // way as the lighting shader's.
940
1028
  this.carryMaterial = new THREE.ShaderMaterial({
941
1029
  glslVersion: THREE.GLSL3,
942
1030
  vertexShader: fullscreenVert,
943
- fragmentShader: mrtCarryFrag,
1031
+ fragmentShader: specMRT
1032
+ ? mrtCarryFrag
1033
+ : mrtCarryFrag.replace(
1034
+ "layout(location = 1) out vec4 o1;",
1035
+ "vec4 o1; // single-target fallback: dead store"
1036
+ ),
944
1037
  uniforms: { uTex: { value: null }, uCountClamp: { value: -1 } },
945
1038
  depthTest: false,
946
1039
  depthWrite: false,
@@ -957,19 +1050,31 @@ export class RTLightingPass {
957
1050
  // Half-float + linear: history is sampled bilinearly at reprojected UVs,
958
1051
  // and fp16 halves the bandwidth (EMA blending never accumulates a raw sum,
959
1052
  // so fp16 precision is sufficient). Two attachments: [0] irradiance,
960
- // [1] fresh dielectric specular.
961
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, {
1053
+ // [1] fresh dielectric specular — or a single attachment in the WebKit
1054
+ // fallback (see the constructor note).
1055
+ const opts = {
962
1056
  minFilter: THREE.LinearFilter,
963
1057
  magFilter: THREE.LinearFilter,
964
1058
  format: THREE.RGBAFormat,
965
1059
  type: THREE.HalfFloatType,
966
1060
  depthBuffer: false,
967
1061
  stencilBuffer: false,
968
- });
1062
+ };
1063
+ if (!this.specMRT) {
1064
+ const t = new THREE.WebGLRenderTarget(width, height, opts);
1065
+ t.texture.generateMipmaps = false;
1066
+ return t;
1067
+ }
1068
+ const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, opts);
969
1069
  for (const tex of t.texture) tex.generateMipmaps = false;
970
1070
  return t;
971
1071
  }
972
1072
 
1073
+ // The accumulated-irradiance texture of a history target, either layout.
1074
+ _irrTex(target) {
1075
+ return this.specMRT ? target.texture[0] : target.texture;
1076
+ }
1077
+
973
1078
  _makeSpecTarget(width, height) {
974
1079
  const t = new THREE.WebGLRenderTarget(width, height, {
975
1080
  minFilter: THREE.LinearFilter,
@@ -991,6 +1096,7 @@ export class RTLightingPass {
991
1096
  const prevAlpha = renderer.getClearAlpha();
992
1097
  renderer.setClearColor(0x000000, 0);
993
1098
  for (const t of [this.targetA, this.targetB, this.specA, this.specB]) {
1099
+ if (!t) continue; // spec pair is absent in the single-target fallback
994
1100
  renderer.setRenderTarget(t);
995
1101
  renderer.clear(true, false, false);
996
1102
  }
@@ -1001,8 +1107,8 @@ export class RTLightingPass {
1001
1107
  setSize(width, height) {
1002
1108
  this.targetA.setSize(width, height);
1003
1109
  this.targetB.setSize(width, height);
1004
- this.specA.setSize(width, height);
1005
- this.specB.setSize(width, height);
1110
+ if (this.specA) this.specA.setSize(width, height);
1111
+ if (this.specB) this.specB.setSize(width, height);
1006
1112
  }
1007
1113
 
1008
1114
  /**
@@ -1025,7 +1131,7 @@ export class RTLightingPass {
1025
1131
  // material so the draw matches the MRT's draw buffers (a 1-output CopyPass
1026
1132
  // blit here is INVALID_OPERATION on ANGLE/D3D11). Attachment 1 is fresh-
1027
1133
  // written every frame, so it needs no carry.
1028
- this.carryMaterial.uniforms.uTex.value = this.targetB.texture[0];
1134
+ this.carryMaterial.uniforms.uTex.value = this._irrTex(this.targetB);
1029
1135
  this.carryMaterial.uniforms.uCountClamp.value = carryFrames;
1030
1136
  this.quad.material = this.carryMaterial;
1031
1137
  const prev = renderer.getRenderTarget();
@@ -1039,13 +1145,15 @@ export class RTLightingPass {
1039
1145
  this.targetB = newB;
1040
1146
 
1041
1147
  // 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;
1148
+ if (this.specMRT) {
1149
+ const newSpecA = this._makeSpecTarget(width, height);
1150
+ const newSpecB = this._makeSpecTarget(width, height);
1151
+ copyPass.blit(renderer, this.specB.texture, newSpecB, carryFrames);
1152
+ this.specA.dispose();
1153
+ this.specB.dispose();
1154
+ this.specA = newSpecA;
1155
+ this.specB = newSpecB;
1156
+ }
1049
1157
  }
1050
1158
 
1051
1159
  setCompiledScene(compiled) {
@@ -1074,7 +1182,7 @@ export class RTLightingPass {
1074
1182
  u.uGWorldPos.value = gbuffer.worldPos;
1075
1183
  u.uGNormalMetal.value = gbuffer.normalMetal;
1076
1184
  u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
1077
- u.uPrevAccum.value = this.targetB.texture[0];
1185
+ u.uPrevAccum.value = this._irrTex(this.targetB);
1078
1186
  u.uReservoir.value = reservoirTexture;
1079
1187
  u.uRestirEnabled.value = reservoirTexture !== null;
1080
1188
  u.uFrame.value = frame;
@@ -1085,37 +1193,42 @@ export class RTLightingPass {
1085
1193
  renderer.render(this.scene, this.camera);
1086
1194
 
1087
1195
  // 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);
1196
+ // Skipped entirely in the single-target fallback — there is no fresh
1197
+ // specular attachment to accumulate.
1198
+ let outSpec = null;
1199
+ if (this.specMRT) {
1200
+ const su = this.specMaterial.uniforms;
1201
+ su.uFreshSpec.value = this.targetA.texture[1];
1202
+ su.uPrevSpec.value = this.specB.texture;
1203
+ su.uGWorldPos.value = gbuffer.worldPos;
1204
+ su.uGNormalMetal.value = gbuffer.normalMetal;
1205
+ su.uPrevGWorldPos.value = gbuffer.prevWorldPos;
1206
+ su.uPrevViewProj.value.copy(u.uPrevViewProj.value);
1207
+ su.uViewProj.value.copy(u.uViewProj.value);
1208
+ su.uCameraPos.value.copy(u.uCameraPos.value);
1209
+ su.uEps.value = u.uEps.value;
1210
+ su.uMaxHistory.value = u.uMaxHistory.value;
1211
+ su.uTemporalReprojection.value = u.uTemporalReprojection.value;
1212
+ this.quad.material = this.specMaterial;
1213
+ renderer.setRenderTarget(this.specA);
1214
+ renderer.render(this.scene, this.camera);
1215
+ outSpec = this.specA.texture;
1216
+ }
1103
1217
 
1104
1218
  this.quad.material = this.material; // restore for the next caller
1105
1219
  renderer.setRenderTarget(null);
1106
1220
 
1107
- const outIrr = this.targetA.texture[0];
1108
- const outSpec = this.specA.texture;
1221
+ const outIrr = this._irrTex(this.targetA);
1109
1222
  [this.targetA, this.targetB] = [this.targetB, this.targetA];
1110
- [this.specA, this.specB] = [this.specB, this.specA];
1223
+ if (this.specMRT) [this.specA, this.specB] = [this.specB, this.specA];
1111
1224
  return { irradiance: outIrr, specular: outSpec };
1112
1225
  }
1113
1226
 
1114
1227
  dispose() {
1115
1228
  this.targetA.dispose();
1116
1229
  this.targetB.dispose();
1117
- this.specA.dispose();
1118
- this.specB.dispose();
1230
+ if (this.specA) this.specA.dispose();
1231
+ if (this.specB) this.specB.dispose();
1119
1232
  this.material.dispose();
1120
1233
  this.specMaterial.dispose();
1121
1234
  this.carryMaterial.dispose();