three-realtime-rt 0.6.0 → 0.7.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.
@@ -1,4 +1,5 @@
1
1
  import * as THREE from "three";
2
+ import { makeMRT } from "./mrtCompat.js";
2
3
 
3
4
  // three defines USE_SKINNING and supplies the bindMatrix / bindMatrixInverse /
4
5
  // boneTexture uniforms + skinIndex / skinWeight attributes automatically when the
@@ -85,6 +86,20 @@ uniform bool uHasMetalnessMap;
85
86
  uniform bool uBlend;
86
87
  uniform float uOpacity;
87
88
 
89
+ // World-space 3D-texture albedo ("volumetric surface albedo"), compiled in ONLY
90
+ // when a scene registers a material with userData.rtVolumeAlbedo (the whole block
91
+ // is behind the RT_VOLUME_ALBEDO define, so a scene without the feature builds a
92
+ // byte-identical G-buffer program with no extra sampler). Primary visibility can
93
+ // afford this sampler3D — the raster pass has ample sampler headroom, unlike the
94
+ // lighting megakernel. The value is gated per-mesh by uHasVolume, so non-volume
95
+ // meshes sharing this program write exactly the same albedo they did before.
96
+ #ifdef RT_VOLUME_ALBEDO
97
+ uniform highp sampler3D uVolumeTex;
98
+ uniform vec3 uVolumeOrigin;
99
+ uniform vec3 uVolumeSize;
100
+ uniform bool uHasVolume;
101
+ #endif
102
+
88
103
  // Screen-space cotangent frame (Mikkelsen 2010): reconstruct a tangent basis
89
104
  // from derivatives of world position and uv, so tangent-space normal maps work
90
105
  // without a per-vertex tangent attribute (none is uploaded to the BVH/G-buffer).
@@ -105,6 +120,17 @@ void main() {
105
120
  albedo *= texture(uMap, vUvCoord).rgb;
106
121
  }
107
122
  albedo *= vColor; // vertex colours (white when the mesh has no color attribute)
123
+ // Volumetric surface albedo: sample a world-space 3D texture at this fragment's
124
+ // world position and use it as the base colour, replacing color x map x vColor.
125
+ // uvw = clamp((p - origin) / size, 0, 1); the ClampToEdge sampler + this clamp
126
+ // keep hits just outside the volume reading the boundary colour instead of
127
+ // wrapping. Gated so only rtVolumeAlbedo meshes are affected.
128
+ #ifdef RT_VOLUME_ALBEDO
129
+ if (uHasVolume) {
130
+ vec3 uvw = clamp((vWorldPos - uVolumeOrigin) / uVolumeSize, 0.0, 1.0);
131
+ albedo = texture(uVolumeTex, uvw).rgb;
132
+ }
133
+ #endif
108
134
  vec3 emissive = uEmissive;
109
135
  if (uHasEmissiveMap) {
110
136
  emissive *= texture(uEmissiveMap, vUvCoord).rgb;
@@ -186,11 +212,48 @@ export class GBufferPass {
186
212
 
187
213
  this._materialCache = new WeakMap(); // mesh -> gbuffer ShaderMaterial
188
214
  this._swapped = []; // [mesh, originalMaterial] pairs during render
215
+ this._hidden = []; // objects temporarily hidden for the G-buffer draw
189
216
  this._normalMat3 = new THREE.Matrix3();
217
+ // World-space 3D-texture albedo: off unless a scene registers a material with
218
+ // userData.rtVolumeAlbedo (see setVolume). When off, the gbuffer program is
219
+ // compiled WITHOUT the RT_VOLUME_ALBEDO define — no sampler3D, byte-identical.
220
+ this._volumeEnabled = false;
221
+ this._dummyVolumeTex = null; // 1x1x1 fallback bound to non-volume meshes when on
222
+ }
223
+
224
+ // A valid 1x1x1 3D texture to bind on gbuffer materials whose mesh is NOT a
225
+ // volume material while the feature is compiled in — keeps every declared
226
+ // sampler3D pointing at a complete texture (its branch is never taken, so the
227
+ // value is irrelevant; it just must not be an unbound/incomplete sampler).
228
+ _dummyVolume() {
229
+ if (!this._dummyVolumeTex) {
230
+ const t = new THREE.Data3DTexture(new Uint8Array([255, 255, 255, 255]), 1, 1, 1);
231
+ t.format = THREE.RGBAFormat;
232
+ t.type = THREE.UnsignedByteType;
233
+ t.minFilter = THREE.LinearFilter;
234
+ t.magFilter = THREE.LinearFilter;
235
+ t.needsUpdate = true;
236
+ this._dummyVolumeTex = t;
237
+ }
238
+ return this._dummyVolumeTex;
239
+ }
240
+
241
+ /**
242
+ * Enable/disable the world-space 3D-texture albedo path for the primary
243
+ * (G-buffer) visibility. Compiles the RT_VOLUME_ALBEDO define into the per-mesh
244
+ * gbuffer program when on. Toggling clears the material cache so the programs
245
+ * recompile with/without the define. Called by RealtimeRaytracer after each
246
+ * compile with whether the scene registered any rtVolumeAlbedo material.
247
+ */
248
+ setVolume(enabled) {
249
+ const on = !!enabled;
250
+ if (on === this._volumeEnabled) return;
251
+ this._volumeEnabled = on;
252
+ this._materialCache = new WeakMap(); // force recompile with the new define
190
253
  }
191
254
 
192
255
  _makeTarget(width, height) {
193
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 4, {
256
+ const t = makeMRT(width, height, 4, {
194
257
  minFilter: THREE.NearestFilter,
195
258
  magFilter: THREE.NearestFilter,
196
259
  type: THREE.FloatType,
@@ -240,7 +303,15 @@ export class GBufferPass {
240
303
 
241
304
  _makeGbufferMaterial(mesh) {
242
305
  const material = new THREE.ShaderMaterial({
306
+ // Stable program name for the compile-failure self-diagnosis (see
307
+ // RealtimeRaytracer._scanPrograms). Per-mesh materials share one program
308
+ // cache key, so they all surface as the single core pass "rt:gbuffer".
309
+ name: "rt:gbuffer",
243
310
  glslVersion: THREE.GLSL3,
311
+ // RT_VOLUME_ALBEDO is present only while a scene uses the volumetric-albedo
312
+ // feature (see setVolume); absent, the compiled program is identical to the
313
+ // pre-feature G-buffer (no sampler3D, no volume branch).
314
+ defines: this._volumeEnabled ? { RT_VOLUME_ALBEDO: "1" } : {},
244
315
  vertexShader: gbufferVert,
245
316
  fragmentShader: gbufferFrag,
246
317
  uniforms: {
@@ -264,6 +335,16 @@ export class GBufferPass {
264
335
  uHasMetalnessMap: { value: false },
265
336
  uBlend: { value: false },
266
337
  uOpacity: { value: 1.0 },
338
+ // Volume-albedo uniforms are always present in the JS uniform object
339
+ // (harmless when the define is off — three uploads only uniforms that
340
+ // exist in the compiled program, so a non-volume scene never touches
341
+ // these). While the define is on, _syncGbufferMaterial binds either the
342
+ // material's own volume texture or the shared dummy, so a non-volume mesh
343
+ // never leaves an incomplete sampler3D bound.
344
+ uVolumeTex: { value: null },
345
+ uVolumeOrigin: { value: new THREE.Vector3() },
346
+ uVolumeSize: { value: new THREE.Vector3(1, 1, 1) },
347
+ uHasVolume: { value: false },
267
348
  },
268
349
  side: THREE.FrontSide,
269
350
  });
@@ -309,6 +390,23 @@ export class GBufferPass {
309
390
  // lighting pass. opacity 1 renders opaque, matching the old force-opaque path.
310
391
  u.uBlend.value = !!src.transparent;
311
392
  u.uOpacity.value = src.opacity ?? 1.0;
393
+ // World-space 3D-texture albedo. Only meshes whose material opted in via
394
+ // userData.rtVolumeAlbedo get uHasVolume=true; every other mesh keeps the
395
+ // dummy sampler (branch never taken) so its albedo is byte-identical. This is
396
+ // per-mesh, so DISTINCT volumes each render correctly in primary visibility.
397
+ if (this._volumeEnabled) {
398
+ const vol = src.userData && src.userData.rtVolumeAlbedo;
399
+ if (vol && vol.texture) {
400
+ u.uHasVolume.value = true;
401
+ u.uVolumeTex.value = vol.texture;
402
+ u.uVolumeOrigin.value.copy(vol.origin ?? { x: 0, y: 0, z: 0 });
403
+ const s = vol.size ?? { x: 1, y: 1, z: 1 };
404
+ u.uVolumeSize.value.set(s.x || 1, s.y || 1, s.z || 1);
405
+ } else {
406
+ u.uHasVolume.value = false;
407
+ u.uVolumeTex.value = this._dummyVolume();
408
+ }
409
+ }
312
410
  u.uNormalMatrixWorld.value.getNormalMatrix(mesh.matrixWorld);
313
411
  material.side = src.side ?? THREE.FrontSide;
314
412
  }
@@ -348,11 +446,27 @@ export class GBufferPass {
348
446
  // material word + gEmissive.a, and the lighting pass blends them against the
349
447
  // geometry behind. opacity 1 writes fully opaque, so alpha-textured cases
350
448
  // (LittlestTokyo's glass) look exactly as before.
449
+ //
450
+ // Objects that are NOT meshes (Sprite / Line / Points) keep their OWN
451
+ // material here, and those materials write a single gl_FragColor. Rendering
452
+ // one into this 4-attachment MRT framebuffer is a GL_INVALID_OPERATION (an
453
+ // ESSL1 fragment shader cannot feed multiple draw buffers) and the object is
454
+ // not traceable geometry either — the BVH compiler skips it. So they are
455
+ // HIDDEN for the duration of the G-buffer draw and restored right after;
456
+ // draw them yourself in an overlay pass on top of rt.render() if you need
457
+ // them on screen. compileScene() warns once naming them.
351
458
  this._swapped.length = 0;
459
+ this._hidden.length = 0;
352
460
  scene.traverse((obj) => {
353
- if (obj.isMesh && obj.geometry && obj.visible) {
461
+ if (!obj.visible) return;
462
+ if (obj.isMesh && obj.geometry) {
354
463
  this._swapped.push([obj, obj.material]);
355
464
  obj.material = this._gbufferMaterialFor(obj);
465
+ return;
466
+ }
467
+ if (obj.isSprite || obj.isLine || obj.isPoints) {
468
+ obj.visible = false;
469
+ this._hidden.push(obj);
356
470
  }
357
471
  });
358
472
 
@@ -368,9 +482,14 @@ export class GBufferPass {
368
482
  scene.background = prevBackground;
369
483
  for (const [mesh, mat] of this._swapped) mesh.material = mat;
370
484
  this._swapped.length = 0;
485
+ // Restore visibility exactly once per object (each was pushed once, and only
486
+ // if it was visible when we hid it).
487
+ for (const obj of this._hidden) obj.visible = true;
488
+ this._hidden.length = 0;
371
489
  }
372
490
 
373
491
  dispose() {
374
492
  for (const t of this._targets) t.dispose();
493
+ if (this._dummyVolumeTex) this._dummyVolumeTex.dispose();
375
494
  }
376
495
  }
@@ -3,6 +3,7 @@ import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
3
3
  import { MAX_LIGHTS } from "./SceneCompiler.js";
4
4
  import { SKY_GLSL } from "./sky.glsl.js";
5
5
  import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
6
+ import { makeMRT } from "./mrtCompat.js";
6
7
 
7
8
  const fullscreenVert = /* glsl */ `
8
9
  out vec2 vUv;
@@ -133,7 +134,10 @@ vec4 fetchBlueNoise() {
133
134
  return fract(bn + shift);
134
135
  }
135
136
 
136
- float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
137
+ // Named rtLum, NOT luminance: three r166+ prepends its own rtLum(vec3)
138
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
139
+ // (vec3) body as a redefinition — the whole program fails to compile.
140
+ float rtLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
137
141
 
138
142
  // ---------- reservoir .w bit-packing: M (8 bit) + oct-normal (12+12 bit) ------
139
143
  // The RGBA32F reservoir-position attachment is at the pass's hard 16-sampler
@@ -483,7 +487,7 @@ void main() {
483
487
  vec3 rad = traceRadianceGI(P + N * uEps, wi, nLight, hitPos, hitNormal);
484
488
  // Match the inline firefly clamp, which is applied to indirect (= L_i) so
485
489
  // the biased mean of the two paths agrees.
486
- float rl = luminance(rad);
490
+ float rl = rtLum(rad);
487
491
  if (rl > uFireflyClamp) rad *= uFireflyClamp / rl;
488
492
 
489
493
  float cosT = max(dot(N, wi), 0.0);
@@ -532,8 +536,8 @@ void main() {
532
536
  float valTol = max(0.02 * expectDist, 4.0 * uEps);
533
537
  float hitDist = length(hitPos - P);
534
538
  bool geomChanged = abs(hitDist - expectDist) > valTol;
535
- float pHatOld = luminance(radPrev) * cosT; // stored target at this pixel
536
- float pHatNew = luminance(rad) * cosT; // re-shaded target (current light)
539
+ float pHatOld = rtLum(radPrev) * cosT; // stored target at this pixel
540
+ float pHatNew = rtLum(rad) * cosT; // re-shaded target (current light)
537
541
  bool wentDark = pHatNew < VAL_DARK_FRAC * pHatOld;
538
542
  // KILL (drop the stale temporal term so this pixel's fresh candidates rebuild
539
543
  // from the current scene) on geometry change OR a collapse to near-black; leave
@@ -543,7 +547,7 @@ void main() {
543
547
  killStore = geomChanged || wentDark;
544
548
  } else {
545
549
  // --- normal fresh candidate: one cosine-hemisphere GI bounce, shaded inline.
546
- float pHatFresh = luminance(rad) * cosT;
550
+ float pHatFresh = rtLum(rad) * cosT;
547
551
  // w = p_hat / p_source = p_hat / (cos/PI). cosT cancels; guard cosT==0.
548
552
  float wFresh = cosT > 0.0 ? pHatFresh * PI / cosT : 0.0;
549
553
  wSum = wFresh;
@@ -571,7 +575,7 @@ void main() {
571
575
  vec3 dp = hitPrev - P;
572
576
  float dl = length(dp);
573
577
  float cosPrev = dl > 1e-5 ? max(dot(N, dp / dl), 0.0) : 0.0;
574
- float pHatPrev = luminance(radPrev) * cosPrev;
578
+ float pHatPrev = rtLum(radPrev) * cosPrev;
575
579
  float Mc = min(Mprev, uMCap);
576
580
  // Combine reservoirs: w = p_hat_current(sample) * W_prev * M_prev.
577
581
  float w = pHatPrev * Wprev * Mc;
@@ -585,7 +589,7 @@ void main() {
585
589
  // Reconstruct last frame's resolve from this same sample (same W cap
586
590
  // and clamp as the live resolve, for a like-for-like EMA partner).
587
591
  vec3 pg = radPrev * (cosPrev / PI) * min(Wprev, 32.0);
588
- float pgl = luminance(pg);
592
+ float pgl = rtLum(pg);
589
593
  if (pgl > uFireflyClamp) pg *= uFireflyClamp / pgl;
590
594
  if (!any(isnan(pg)) && !any(isinf(pg))) {
591
595
  emaPrevGi = pg;
@@ -650,7 +654,7 @@ void main() {
650
654
 
651
655
  // Target function at q (same shape the pass already uses).
652
656
  float cosQ = max(dot(N, normalize(xS - P)), 0.0);
653
- float pHatQ = luminance(Ls) * cosQ;
657
+ float pHatQ = rtLum(Ls) * cosQ;
654
658
  // Invalid-shift reject: if the neighbour's hit x_s lies below q's shading
655
659
  // hemisphere (cosQ == 0) or carries no radiance, the reconnected target is
656
660
  // zero — the shift could never have produced this sample at q, so it must
@@ -676,7 +680,7 @@ void main() {
676
680
  vec3 sd = selPos - P;
677
681
  float sl = length(sd);
678
682
  float selCos = sl > 1e-5 ? max(dot(N, sd / sl), 0.0) : 0.0;
679
- float pHatSel = luminance(selRad) * selCos;
683
+ float pHatSel = rtLum(selRad) * selCos;
680
684
  float W = (M > 0.0 && pHatSel > 0.0) ? wSum / (M * pHatSel) : 0.0;
681
685
 
682
686
  // --- final visibility (mandatory): ONE any-hit occlusion ray from x_q toward
@@ -715,7 +719,7 @@ void main() {
715
719
  // slightly dim GI on freshly revealed surfaces for a steady image in motion.
716
720
  float conf = clamp(M / uMCap, 0.0, 1.0);
717
721
  float cap = uFireflyClamp * mix(0.3, 1.0, conf);
718
- float gil = luminance(gi);
722
+ float gil = rtLum(gi);
719
723
  if (gil > cap) gi *= cap / gil;
720
724
  if (any(isnan(gi)) || any(isinf(gi))) gi = vec3(0.0);
721
725
  // Resolve EMA (see the emaPrevGi note above): ~5-frame effective average.
@@ -729,7 +733,7 @@ void main() {
729
733
  vec3 sdT = selPosT - P;
730
734
  float slT = length(sdT);
731
735
  float selCosT = slT > 1e-5 ? max(dot(N, sdT / slT), 0.0) : 0.0;
732
- float pHatSelT = luminance(selRadT) * selCosT;
736
+ float pHatSelT = rtLum(selRadT) * selCosT;
733
737
  float WT = (MT > 0.0 && pHatSelT > 0.0) ? wSumT / (MT * pHatSelT) : 0.0;
734
738
  if (any(isnan(selRadT)) || any(isinf(selRadT))) { selRadT = vec3(0.0); WT = 0.0; }
735
739
 
@@ -797,6 +801,9 @@ export class GIReservoirPass {
797
801
  this.targetB = this._makeTarget(width, height);
798
802
 
799
803
  this.material = new THREE.ShaderMaterial({
804
+ // Stable program name for compile-failure self-diagnosis; a link failure
805
+ // disables the experimental `restirGI` feature (falls back to inline GI).
806
+ name: "rt:gi-reservoir",
800
807
  glslVersion: THREE.GLSL3,
801
808
  vertexShader: fullscreenVert,
802
809
  fragmentShader: giFrag,
@@ -851,7 +858,7 @@ export class GIReservoirPass {
851
858
  // (needs fp32) + M + radiance + W, which must never be interpolated. All fp32
852
859
  // (rather than a mixed fp32/fp16 layout) sidesteps drivers that reject mixed
853
860
  // MRT precision; the resolved GI (attachment 2) is read 1:1 by the denoise.
854
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 3, {
861
+ const t = makeMRT(width, height, 3, {
855
862
  minFilter: THREE.NearestFilter,
856
863
  magFilter: THREE.NearestFilter,
857
864
  format: THREE.RGBAFormat,
@@ -3,6 +3,7 @@ import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
3
3
  import { MAX_LIGHTS } from "./SceneCompiler.js";
4
4
  import { SKY_GLSL } from "./sky.glsl.js";
5
5
  import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
6
+ import { makeMRT } from "./mrtCompat.js";
6
7
 
7
8
  const fullscreenVert = /* glsl */ `
8
9
  out vec2 vUv;
@@ -192,6 +193,27 @@ void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
192
193
  metalness = t1.a;
193
194
  }
194
195
 
196
+ // World-space 3D-texture albedo ("volumetric surface albedo") for the traced
197
+ // SECONDARY rays (GI bounces + reflection/refraction), so global illumination and
198
+ // mirror views carry the same field colours the primary G-buffer shows. Compiled
199
+ // in ONLY behind RT_VOLUME_ALBEDO: this megakernel already sits at the WebGL2
200
+ // 16-sampler minimum, so the extra sampler3D is added exclusively when a scene
201
+ // registers a volume AND the GPU exposes >= 17 fragment texture units (the
202
+ // RealtimeRaytracer gates both conditions). Absent, the shader is textually
203
+ // identical to the pre-feature megakernel — same 16 samplers, same program. v1 is
204
+ // single-volume: one texture + one material index; a hit on that material samples
205
+ // the field, every other hit reads its flat table albedo unchanged.
206
+ #ifdef RT_VOLUME_ALBEDO
207
+ uniform highp sampler3D uVolumeTex;
208
+ uniform vec3 uVolumeOrigin;
209
+ uniform vec3 uVolumeSize;
210
+ uniform int uVolumeMatIndex;
211
+ vec3 sampleVolumeAlbedo(vec3 p) {
212
+ vec3 uvw = clamp((p - uVolumeOrigin) / uVolumeSize, 0.0, 1.0);
213
+ return texture(uVolumeTex, uvw).rgb;
214
+ }
215
+ #endif
216
+
195
217
  // ---------- PBR specular (Cook-Torrance GGX) ----------
196
218
  // A separate specular radiance is accumulated for the primary surface's DIRECT
197
219
  // lighting alongside the demodulated diffuse irradiance. Because CompositePass
@@ -494,6 +516,12 @@ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
494
516
  vec3 hN = normalize(attr.xyz);
495
517
  if (dot(hN, rd) > 0.0) hN = -hN;
496
518
  vec3 hP = ro + rd * dist;
519
+ // Volumetric surface albedo: if this hit is the volume material, replace its
520
+ // flat table albedo with the 3D-texture sample at the world hit point, so GI /
521
+ // reflection bounces carry the field colours (matches the primary G-buffer).
522
+ #ifdef RT_VOLUME_ALBEDO
523
+ if (int(round(attr.w)) == uVolumeMatIndex) hAlbedo = sampleVolumeAlbedo(hP);
524
+ #endif
497
525
  vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
498
526
  vec3 hLe = (!specular && uEmissiveCount > 0) ? vec3(0.0) : hEmissive;
499
527
  return hLe + hAlbedo * Ld * (1.0 / PI);
@@ -995,7 +1023,16 @@ export class RTLightingPass {
995
1023
  this.specB = specMRT ? this._makeSpecTarget(width, height) : null;
996
1024
 
997
1025
  this.material = new THREE.ShaderMaterial({
1026
+ // Stable program name for compile-failure self-diagnosis: this is the
1027
+ // CORE lighting megakernel — a link failure here has no fallback (see
1028
+ // RealtimeRaytracer._passClass -> coreFailure).
1029
+ name: "rt:lighting",
998
1030
  glslVersion: THREE.GLSL3,
1031
+ // RT_VOLUME_ALBEDO is injected only when a scene uses world-space 3D-texture
1032
+ // albedo AND the GPU has a spare fragment sampler (see setVolumeAlbedo +
1033
+ // RealtimeRaytracer). Absent, this megakernel is textually identical to the
1034
+ // pre-feature build — same 16 samplers, same Metal translation.
1035
+ defines: {},
999
1036
  vertexShader: fullscreenVert,
1000
1037
  fragmentShader: specMRT
1001
1038
  ? rtLightingFrag
@@ -1049,6 +1086,13 @@ export class RTLightingPass {
1049
1086
  uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
1050
1087
  uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
1051
1088
  uSkyIntensity: { value: 1.0 },
1089
+ // World-space 3D-texture albedo (present only in the compiled program when
1090
+ // the RT_VOLUME_ALBEDO define is set; harmless otherwise — three uploads
1091
+ // only uniforms the program actually declares).
1092
+ uVolumeTex: { value: null },
1093
+ uVolumeOrigin: { value: new THREE.Vector3() },
1094
+ uVolumeSize: { value: new THREE.Vector3(1, 1, 1) },
1095
+ uVolumeMatIndex: { value: -1 },
1052
1096
  },
1053
1097
  depthTest: false,
1054
1098
  depthWrite: false,
@@ -1057,6 +1101,9 @@ export class RTLightingPass {
1057
1101
  // Specular temporal accumulation program (its own sampler budget — well
1058
1102
  // clear of the lighting pass's 16-sampler ceiling).
1059
1103
  this.specMaterial = new THREE.ShaderMaterial({
1104
+ // Optional additive specular buffer — a link failure degrades to the
1105
+ // Lambert-only look (RealtimeRaytracer disables `specular`), image stays lit.
1106
+ name: "rt:specular",
1060
1107
  glslVersion: THREE.GLSL3,
1061
1108
  vertexShader: fullscreenVert,
1062
1109
  fragmentShader: specAccumFrag,
@@ -1081,6 +1128,9 @@ export class RTLightingPass {
1081
1128
  // buffers. In single-target fallback the second output collapses the same
1082
1129
  // way as the lighting shader's.
1083
1130
  this.carryMaterial = new THREE.ShaderMaterial({
1131
+ // Resize-only history-carry blit; a link failure is non-fatal (history is
1132
+ // not carried across a resolution step) so it classifies as auxiliary.
1133
+ name: "rt:history-carry",
1084
1134
  glslVersion: THREE.GLSL3,
1085
1135
  vertexShader: fullscreenVert,
1086
1136
  fragmentShader: specMRT
@@ -1120,7 +1170,7 @@ export class RTLightingPass {
1120
1170
  t.texture.generateMipmaps = false;
1121
1171
  return t;
1122
1172
  }
1123
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 2, opts);
1173
+ const t = makeMRT(width, height, 2, opts);
1124
1174
  for (const tex of t.texture) tex.generateMipmaps = false;
1125
1175
  return t;
1126
1176
  }
@@ -1226,6 +1276,36 @@ export class RTLightingPass {
1226
1276
  u.uEmissiveCount.value = compiled.emissiveTriCount;
1227
1277
  }
1228
1278
 
1279
+ /**
1280
+ * Enable/disable the world-space 3D-texture albedo path for the traced
1281
+ * SECONDARY rays (GI + reflections). Pass the compiled `volumeAlbedo` descriptor
1282
+ * ({ texture, origin, size, matIndex }) to turn it on, or null to turn it off.
1283
+ * Toggling the RT_VOLUME_ALBEDO define recompiles this megakernel (adds/removes
1284
+ * the single sampler3D), which the caller does at compile time, not per frame.
1285
+ * The caller is responsible for only enabling this when the GPU has a spare
1286
+ * fragment texture unit — this pass is at the 16-sampler minimum, so the 17th
1287
+ * sampler would fail to link on a bare-minimum WebGL2 device.
1288
+ */
1289
+ setVolumeAlbedo(volume) {
1290
+ const wasOn = this.material.defines.RT_VOLUME_ALBEDO !== undefined;
1291
+ const on = !!volume;
1292
+ const u = this.material.uniforms;
1293
+ if (on) {
1294
+ u.uVolumeTex.value = volume.texture;
1295
+ u.uVolumeOrigin.value.copy(volume.origin);
1296
+ u.uVolumeSize.value.copy(volume.size);
1297
+ u.uVolumeMatIndex.value = volume.matIndex;
1298
+ } else {
1299
+ u.uVolumeTex.value = null;
1300
+ u.uVolumeMatIndex.value = -1;
1301
+ }
1302
+ if (on !== wasOn) {
1303
+ if (on) this.material.defines.RT_VOLUME_ALBEDO = "1";
1304
+ else delete this.material.defines.RT_VOLUME_ALBEDO;
1305
+ this.material.needsUpdate = true; // recompile with/without the sampler3D
1306
+ }
1307
+ }
1308
+
1229
1309
  /**
1230
1310
  * Renders lighting into targetA (reading targetB as irradiance history), then
1231
1311
  * accumulates the fresh specular (targetA attachment 1) into specA (reading