three-realtime-rt 0.4.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-realtime-rt",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Turn-on ray traced lighting for three.js: a hybrid deferred renderer with BVH-traced soft shadows, one-bounce global illumination, temporal reprojection, an a-trous denoiser and TAA.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -634,11 +634,29 @@ void main() {
634
634
  // (unbiased) while GI's ray cost halves; accumulation + denoise absorb
635
635
  // the alternation.
636
636
  gWantSpec = false; // secondary bounces contribute to diffuse GI only
637
+ // BLEND pixels reuse THIS call site as their straight-through view
638
+ // continuation instead of a GI bounce (their behind-image rides the specular
639
+ // attachment; the pane forgoes its own GI bounce — visually negligible, and
640
+ // it saves a ray). CRITICAL CALL-SITE BUDGET: traceRadiance may appear at
641
+ // most THREE times in this shader (glass refraction exit, this unified
642
+ // secondary site, the metal-reflection path). WebKit's GLSL->Metal
643
+ // translation silently emits a broken program at a FOURTH inlined call site
644
+ // (clean compile, black output on every iOS browser) — bisected live on an
645
+ // iPad, 2026-07-22. Never add a call site; extend this one.
637
646
  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);
647
+ vec3 blendBehind = vec3(0.0);
648
+ bool wantBehind = uBlendEnabled && blend;
649
+ bool wantGI = uGIEnabled && !wantBehind
650
+ && (!uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0));
651
+ if (wantBehind || wantGI) {
652
+ vec3 Vv = normalize(P - uCameraPos);
653
+ vec3 dir = wantBehind ? Vv : cosineSampleHemisphere(N, rand2());
654
+ vec3 org = wantBehind ? P + Vv * uEps : P + N * uEps;
655
+ vec3 r = traceRadiance(org, dir, wantBehind);
656
+ if (wantBehind) {
657
+ blendBehind = r;
658
+ } else {
659
+ indirect = r;
642
660
  if (uGIHalfRate) indirect *= 2.0;
643
661
  }
644
662
  }
@@ -685,11 +703,8 @@ void main() {
685
703
  // where the pane's albedo is actually available. sampleIrr keeps only the
686
704
  // pane's own surface lighting, which is static on the surface and accumulates
687
705
  // 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
- }
706
+ // (The straight-through trace itself happens at the unified secondary-ray
707
+ // call site above — see the Metal call-site-count note there.)
693
708
 
694
709
  // A single NaN/Inf sample would poison the EMA history for good (mix() with
695
710
  // NaN stays NaN until a disocclusion resets the pixel) — sanitize first.
@@ -854,18 +869,31 @@ void main() {
854
869
  * into a second ping-pong pair, specA/specB).
855
870
  */
856
871
  export class RTLightingPass {
857
- constructor(width, height) {
872
+ // `specMRT: false` is the WebKit fallback: iOS Safari (every iOS browser)
873
+ // silently fails the 2-attachment half-float MRT draw — the whole lighting
874
+ // pass renders black. RealtimeRaytracer probes this functionally at
875
+ // construction; in fallback mode this pass allocates a single-attachment
876
+ // target exactly like 0.3.x, the shader's second output collapses to a dead
877
+ // local variable, and render() returns { specular: null } (the caller then
878
+ // runs with specular off; blend surfaces degrade to opaque as documented).
879
+ constructor(width, height, { specMRT = true } = {}) {
880
+ this.specMRT = specMRT;
858
881
  this.targetA = this._makeTarget(width, height);
859
882
  this.targetB = this._makeTarget(width, height);
860
883
  // Accumulated specular history (ping-pong), fed by the fresh specular in
861
884
  // targetA/B attachment 1.
862
- this.specA = this._makeSpecTarget(width, height);
863
- this.specB = this._makeSpecTarget(width, height);
885
+ this.specA = specMRT ? this._makeSpecTarget(width, height) : null;
886
+ this.specB = specMRT ? this._makeSpecTarget(width, height) : null;
864
887
 
865
888
  this.material = new THREE.ShaderMaterial({
866
889
  glslVersion: THREE.GLSL3,
867
890
  vertexShader: fullscreenVert,
868
- fragmentShader: rtLightingFrag,
891
+ fragmentShader: specMRT
892
+ ? rtLightingFrag
893
+ : rtLightingFrag.replace(
894
+ "layout(location = 1) out vec4 outSpecular;",
895
+ "vec4 outSpecular; // single-target fallback: dead store"
896
+ ),
869
897
  uniforms: {
870
898
  bvhStatic: { value: null },
871
899
  bvhDynamic: { value: null },
@@ -936,11 +964,18 @@ export class RTLightingPass {
936
964
  depthWrite: false,
937
965
  });
938
966
 
939
- // 2-output history carry (see mrtCarryFrag) — matches the MRT's draw buffers.
967
+ // 2-output history carry (see mrtCarryFrag) — matches the MRT's draw
968
+ // buffers. In single-target fallback the second output collapses the same
969
+ // way as the lighting shader's.
940
970
  this.carryMaterial = new THREE.ShaderMaterial({
941
971
  glslVersion: THREE.GLSL3,
942
972
  vertexShader: fullscreenVert,
943
- fragmentShader: mrtCarryFrag,
973
+ fragmentShader: specMRT
974
+ ? mrtCarryFrag
975
+ : mrtCarryFrag.replace(
976
+ "layout(location = 1) out vec4 o1;",
977
+ "vec4 o1; // single-target fallback: dead store"
978
+ ),
944
979
  uniforms: { uTex: { value: null }, uCountClamp: { value: -1 } },
945
980
  depthTest: false,
946
981
  depthWrite: false,
@@ -957,19 +992,31 @@ export class RTLightingPass {
957
992
  // Half-float + linear: history is sampled bilinearly at reprojected UVs,
958
993
  // and fp16 halves the bandwidth (EMA blending never accumulates a raw sum,
959
994
  // so fp16 precision is sufficient). Two attachments: [0] irradiance,
960
- // [1] fresh dielectric specular.
961
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, {
995
+ // [1] fresh dielectric specular — or a single attachment in the WebKit
996
+ // fallback (see the constructor note).
997
+ const opts = {
962
998
  minFilter: THREE.LinearFilter,
963
999
  magFilter: THREE.LinearFilter,
964
1000
  format: THREE.RGBAFormat,
965
1001
  type: THREE.HalfFloatType,
966
1002
  depthBuffer: false,
967
1003
  stencilBuffer: false,
968
- });
1004
+ };
1005
+ if (!this.specMRT) {
1006
+ const t = new THREE.WebGLRenderTarget(width, height, opts);
1007
+ t.texture.generateMipmaps = false;
1008
+ return t;
1009
+ }
1010
+ const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, opts);
969
1011
  for (const tex of t.texture) tex.generateMipmaps = false;
970
1012
  return t;
971
1013
  }
972
1014
 
1015
+ // The accumulated-irradiance texture of a history target, either layout.
1016
+ _irrTex(target) {
1017
+ return this.specMRT ? target.texture[0] : target.texture;
1018
+ }
1019
+
973
1020
  _makeSpecTarget(width, height) {
974
1021
  const t = new THREE.WebGLRenderTarget(width, height, {
975
1022
  minFilter: THREE.LinearFilter,
@@ -991,6 +1038,7 @@ export class RTLightingPass {
991
1038
  const prevAlpha = renderer.getClearAlpha();
992
1039
  renderer.setClearColor(0x000000, 0);
993
1040
  for (const t of [this.targetA, this.targetB, this.specA, this.specB]) {
1041
+ if (!t) continue; // spec pair is absent in the single-target fallback
994
1042
  renderer.setRenderTarget(t);
995
1043
  renderer.clear(true, false, false);
996
1044
  }
@@ -1001,8 +1049,8 @@ export class RTLightingPass {
1001
1049
  setSize(width, height) {
1002
1050
  this.targetA.setSize(width, height);
1003
1051
  this.targetB.setSize(width, height);
1004
- this.specA.setSize(width, height);
1005
- this.specB.setSize(width, height);
1052
+ if (this.specA) this.specA.setSize(width, height);
1053
+ if (this.specB) this.specB.setSize(width, height);
1006
1054
  }
1007
1055
 
1008
1056
  /**
@@ -1025,7 +1073,7 @@ export class RTLightingPass {
1025
1073
  // material so the draw matches the MRT's draw buffers (a 1-output CopyPass
1026
1074
  // blit here is INVALID_OPERATION on ANGLE/D3D11). Attachment 1 is fresh-
1027
1075
  // written every frame, so it needs no carry.
1028
- this.carryMaterial.uniforms.uTex.value = this.targetB.texture[0];
1076
+ this.carryMaterial.uniforms.uTex.value = this._irrTex(this.targetB);
1029
1077
  this.carryMaterial.uniforms.uCountClamp.value = carryFrames;
1030
1078
  this.quad.material = this.carryMaterial;
1031
1079
  const prev = renderer.getRenderTarget();
@@ -1039,13 +1087,15 @@ export class RTLightingPass {
1039
1087
  this.targetB = newB;
1040
1088
 
1041
1089
  // 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;
1090
+ if (this.specMRT) {
1091
+ const newSpecA = this._makeSpecTarget(width, height);
1092
+ const newSpecB = this._makeSpecTarget(width, height);
1093
+ copyPass.blit(renderer, this.specB.texture, newSpecB, carryFrames);
1094
+ this.specA.dispose();
1095
+ this.specB.dispose();
1096
+ this.specA = newSpecA;
1097
+ this.specB = newSpecB;
1098
+ }
1049
1099
  }
1050
1100
 
1051
1101
  setCompiledScene(compiled) {
@@ -1074,7 +1124,7 @@ export class RTLightingPass {
1074
1124
  u.uGWorldPos.value = gbuffer.worldPos;
1075
1125
  u.uGNormalMetal.value = gbuffer.normalMetal;
1076
1126
  u.uPrevGWorldPos.value = gbuffer.prevWorldPos;
1077
- u.uPrevAccum.value = this.targetB.texture[0];
1127
+ u.uPrevAccum.value = this._irrTex(this.targetB);
1078
1128
  u.uReservoir.value = reservoirTexture;
1079
1129
  u.uRestirEnabled.value = reservoirTexture !== null;
1080
1130
  u.uFrame.value = frame;
@@ -1085,37 +1135,42 @@ export class RTLightingPass {
1085
1135
  renderer.render(this.scene, this.camera);
1086
1136
 
1087
1137
  // 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);
1138
+ // Skipped entirely in the single-target fallback — there is no fresh
1139
+ // specular attachment to accumulate.
1140
+ let outSpec = null;
1141
+ if (this.specMRT) {
1142
+ const su = this.specMaterial.uniforms;
1143
+ su.uFreshSpec.value = this.targetA.texture[1];
1144
+ su.uPrevSpec.value = this.specB.texture;
1145
+ su.uGWorldPos.value = gbuffer.worldPos;
1146
+ su.uGNormalMetal.value = gbuffer.normalMetal;
1147
+ su.uPrevGWorldPos.value = gbuffer.prevWorldPos;
1148
+ su.uPrevViewProj.value.copy(u.uPrevViewProj.value);
1149
+ su.uViewProj.value.copy(u.uViewProj.value);
1150
+ su.uCameraPos.value.copy(u.uCameraPos.value);
1151
+ su.uEps.value = u.uEps.value;
1152
+ su.uMaxHistory.value = u.uMaxHistory.value;
1153
+ su.uTemporalReprojection.value = u.uTemporalReprojection.value;
1154
+ this.quad.material = this.specMaterial;
1155
+ renderer.setRenderTarget(this.specA);
1156
+ renderer.render(this.scene, this.camera);
1157
+ outSpec = this.specA.texture;
1158
+ }
1103
1159
 
1104
1160
  this.quad.material = this.material; // restore for the next caller
1105
1161
  renderer.setRenderTarget(null);
1106
1162
 
1107
- const outIrr = this.targetA.texture[0];
1108
- const outSpec = this.specA.texture;
1163
+ const outIrr = this._irrTex(this.targetA);
1109
1164
  [this.targetA, this.targetB] = [this.targetB, this.targetA];
1110
- [this.specA, this.specB] = [this.specB, this.specA];
1165
+ if (this.specMRT) [this.specA, this.specB] = [this.specB, this.specA];
1111
1166
  return { irradiance: outIrr, specular: outSpec };
1112
1167
  }
1113
1168
 
1114
1169
  dispose() {
1115
1170
  this.targetA.dispose();
1116
1171
  this.targetB.dispose();
1117
- this.specA.dispose();
1118
- this.specB.dispose();
1172
+ if (this.specA) this.specA.dispose();
1173
+ if (this.specB) this.specB.dispose();
1119
1174
  this.material.dispose();
1120
1175
  this.specMaterial.dispose();
1121
1176
  this.carryMaterial.dispose();
@@ -242,6 +242,72 @@ export class RealtimeRaytracer {
242
242
  }
243
243
  }
244
244
 
245
+ /**
246
+ * FUNCTIONAL probe: can this context actually DRAW into a 2-attachment
247
+ * half-float MRT? A checkFramebufferStatus probe is not enough — WebKit
248
+ * (every iOS browser) reports the framebuffer complete and then renders
249
+ * black, which killed the whole lighting pass on iPhone/iPad in 0.4.0.
250
+ * So: render one 2-output quad into a tiny fp16 MRT, resolve attachment 0
251
+ * into an RGBA8 target through a sampler, and read the pixel back. Only a
252
+ * round-trip that returns the written value counts as support.
253
+ */
254
+ static _specMrtSupported(renderer) {
255
+ let mrt, out, mat, copy, quad, scene2, cam;
256
+ const prevTarget = renderer.getRenderTarget();
257
+ try {
258
+ mrt = new THREE.WebGLMultipleRenderTargets(2, 2, 2, {
259
+ format: THREE.RGBAFormat,
260
+ type: THREE.HalfFloatType,
261
+ depthBuffer: false,
262
+ stencilBuffer: false,
263
+ });
264
+ for (const tex of mrt.texture) tex.generateMipmaps = false;
265
+ out = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false });
266
+ const vert = `out vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
267
+ mat = new THREE.ShaderMaterial({
268
+ glslVersion: THREE.GLSL3,
269
+ vertexShader: vert,
270
+ fragmentShader: `precision highp float;
271
+ layout(location = 0) out vec4 o0; layout(location = 1) out vec4 o1;
272
+ void main(){ o0 = vec4(0.5, 0.25, 0.75, 1.0); o1 = vec4(0.125); }`,
273
+ depthTest: false,
274
+ depthWrite: false,
275
+ });
276
+ copy = new THREE.ShaderMaterial({
277
+ glslVersion: THREE.GLSL3,
278
+ vertexShader: vert,
279
+ fragmentShader: `precision highp float; in vec2 vUv; out vec4 outColor;
280
+ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
281
+ uniforms: { uTex: { value: mrt.texture[0] } },
282
+ depthTest: false,
283
+ depthWrite: false,
284
+ });
285
+ scene2 = new THREE.Scene();
286
+ cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
287
+ quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
288
+ quad.frustumCulled = false;
289
+ scene2.add(quad);
290
+ renderer.setRenderTarget(mrt);
291
+ renderer.render(scene2, cam);
292
+ quad.material = copy;
293
+ renderer.setRenderTarget(out);
294
+ renderer.render(scene2, cam);
295
+ const px = new Uint8Array(4);
296
+ renderer.readRenderTargetPixels(out, 0, 0, 1, 1, px);
297
+ // 0.5 -> ~128; accept a generous tolerance (fp16 + dithering).
298
+ return Math.abs(px[0] - 128) < 24 && Math.abs(px[1] - 64) < 24;
299
+ } catch {
300
+ return false;
301
+ } finally {
302
+ renderer.setRenderTarget(prevTarget);
303
+ if (quad) quad.geometry.dispose();
304
+ if (mat) mat.dispose();
305
+ if (copy) copy.dispose();
306
+ if (mrt) mrt.dispose();
307
+ if (out) out.dispose();
308
+ }
309
+ }
310
+
245
311
  /**
246
312
  * Companion settings for a given lighting resolution. LOW resolution wants
247
313
  * MORE denoise passes, not fewer — the filter runs at lighting res so extra
@@ -325,8 +391,22 @@ export class RealtimeRaytracer {
325
391
  if (!mixedPrecision) {
326
392
  console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here — using fp32 for all targets.");
327
393
  }
394
+ // Functional probe, not just a status check: WebKit (all iOS browsers)
395
+ // reports the 2-attachment fp16 MRT complete but silently renders black,
396
+ // which blanks the whole lighting pass. On such devices the lighting pass
397
+ // runs single-attachment (0.3.x layout): the specular buffer is disabled
398
+ // and blend surfaces degrade to opaque — everything else keeps working.
399
+ this.specMRTSupported = RealtimeRaytracer._specMrtSupported(renderer);
400
+ if (!this.specMRTSupported) {
401
+ console.info(
402
+ "three-realtime-rt: multi-attachment lighting buffer failed the draw probe here " +
403
+ "(WebKit/iOS) — specular buffer disabled, alpha-blend surfaces render opaque."
404
+ );
405
+ }
328
406
  this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
329
- this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
407
+ this.rtPass = new RTLightingPass(this._scaledW, this._scaledH, {
408
+ specMRT: this.specMRTSupported,
409
+ });
330
410
  this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
331
411
  // Separate à-trous instance for the specular buffer (its own ping-pong
332
412
  // targets, so the specular denoise cannot clobber the irradiance result).
@@ -343,7 +423,7 @@ export class RealtimeRaytracer {
343
423
  this.compiled = null;
344
424
  this.frame = 0;
345
425
 
346
- /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
426
+ /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular */
347
427
  this.outputMode = 0;
348
428
  /** Environment (sky) color used for GI rays that miss + composite background. */
349
429
  this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
package/src/index.d.ts CHANGED
@@ -288,6 +288,12 @@ export class RealtimeRaytracer {
288
288
  renderer: WebGLRenderer;
289
289
  /** False when the platform can't run the tracer; render() then forwards to renderer.render. */
290
290
  supported: boolean;
291
+ /**
292
+ * False when the 2-attachment lighting buffer fails the construction-time
293
+ * draw probe (WebKit/iOS): the specular buffer is disabled there and
294
+ * alpha-blend surfaces render opaque; everything else keeps working.
295
+ */
296
+ specMRTSupported: boolean;
291
297
  /** The current compiled scene, or null before the first compile / when unsupported. */
292
298
  compiled: CompiledScene | null;
293
299
  /** Accumulated frame counter. */