three-realtime-rt 0.5.0 → 0.6.1

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.
@@ -58,6 +58,15 @@ export class CompiledScene {
58
58
  this.emissiveTriCount = 0;
59
59
  this.triangleCount = 0;
60
60
 
61
+ // Dynamic emissive area lights: the final (post-cap) emissive triangle list
62
+ // and the subset that belongs to dynamic emitters (row + merged-position
63
+ // offset), refreshed in place each updateDynamic(). lastEmissiveRefreshMs is
64
+ // the CPU cost of the most recent refresh (0 when there are none).
65
+ this.emissiveTris = [];
66
+ this._dynamicEmissive = [];
67
+ this.hasDynamicEmissive = false;
68
+ this.lastEmissiveRefreshMs = 0;
69
+
61
70
  this._m3 = new THREE.Matrix3();
62
71
  this._normalFrame = 0;
63
72
  this._dynBuildVolume = null; // world-volume of the dynamic set at build time
@@ -251,6 +260,64 @@ export class CompiledScene {
251
260
  if (this.hasDeforming || this.hasSkinned || this._normalFrame++ % 8 === 0) {
252
261
  this.dynamicAttrTex.updateFrom(this.dynamicPackedAttr);
253
262
  }
263
+
264
+ // Refresh dynamic emissive area lights from the freshly baked world-space
265
+ // merged positions (rows in the scene-data texture + the power CDF).
266
+ if (this.hasDynamicEmissive) this._refreshDynamicEmissive();
267
+ }
268
+
269
+ /**
270
+ * Re-derive dynamic emitters' world-space NEE triangles from the merged
271
+ * dynamic positions this frame, rewrite their rows in the scene-data texture
272
+ * (row 1) and rebuild the power CDF (row 66), then flag the texture for
273
+ * re-upload. The whole (small) texture goes back up — measured in
274
+ * lastEmissiveRefreshMs. Called from updateDynamic (i.e. only when the dynamic
275
+ * set actually updated); an emissive change on an emitter that did NOT move —
276
+ * e.g. recolouring material.emissive — is frozen at compile time and needs a
277
+ * compileScene() (updateLights only rescans analytic THREE lights).
278
+ */
279
+ _refreshDynamicEmissive() {
280
+ const list = this._dynamicEmissive;
281
+ if (list.length === 0) return;
282
+ const now = typeof performance !== "undefined" ? performance : Date;
283
+ const t0 = now.now();
284
+ const tex = this.materialsTex;
285
+ const data = tex.image.data;
286
+ const row = tex.image.width * 4; // texture width in floats
287
+ const pos = this.dynamicMerged.getAttribute("position").array;
288
+ const tris = this.emissiveTris;
289
+ for (let k = 0; k < list.length; k++) {
290
+ const de = list[k];
291
+ const off = de.off;
292
+ const ax = pos[off], ay = pos[off + 1], az = pos[off + 2];
293
+ const e1x = pos[off + 3] - ax, e1y = pos[off + 4] - ay, e1z = pos[off + 5] - az;
294
+ const e2x = pos[off + 6] - ax, e2y = pos[off + 7] - ay, e2z = pos[off + 8] - az;
295
+ let nx = e1y * e2z - e1z * e2y;
296
+ let ny = e1z * e2x - e1x * e2z;
297
+ let nz = e1x * e2y - e1y * e2x;
298
+ const len = Math.hypot(nx, ny, nz);
299
+ const area = len * 0.5;
300
+ const il = len > 1e-10 ? 1 / len : 0; // keep a degenerate frame from NaN-ing
301
+ nx *= il; ny *= il; nz *= il;
302
+ const emit = de.emit;
303
+ // Keep the JS-side tri object current so writeEmissiveCdf sees fresh areas.
304
+ const t = tris[de.row];
305
+ t.v0[0] = ax; t.v0[1] = ay; t.v0[2] = az;
306
+ t.e1[0] = e1x; t.e1[1] = e1y; t.e1[2] = e1z;
307
+ t.e2[0] = e2x; t.e2[1] = e2y; t.e2[2] = e2z;
308
+ t.n[0] = nx; t.n[1] = ny; t.n[2] = nz;
309
+ t.area = area;
310
+ // Row 1 texel (16 floats) — layout must match buildSceneDataTexture.
311
+ const o = row + de.row * 16;
312
+ data[o + 0] = ax; data[o + 1] = ay; data[o + 2] = az; data[o + 3] = area;
313
+ data[o + 4] = e1x; data[o + 5] = e1y; data[o + 6] = e1z; data[o + 7] = emit[0];
314
+ data[o + 8] = e2x; data[o + 9] = e2y; data[o + 10] = e2z; data[o + 11] = emit[1];
315
+ data[o + 12] = nx; data[o + 13] = ny; data[o + 14] = nz; data[o + 15] = emit[2];
316
+ }
317
+ // Areas (and therefore pick probabilities) may have changed — rebuild the CDF.
318
+ writeEmissiveCdf(data, row, tris);
319
+ tex.needsUpdate = true;
320
+ this.lastEmissiveRefreshMs = now.now() - t0;
254
321
  }
255
322
 
256
323
  dispose() {
@@ -333,12 +400,91 @@ function resolveMeshMaterials(mesh, count, registerMaterial) {
333
400
  return { matIdx, ranges };
334
401
  }
335
402
 
403
+ // Average-colour cache for emissive maps: texture -> [r,g,b] linear, or null when
404
+ // the image is unreadable (CORS-tainted / not yet decoded). Keyed by the THREE
405
+ // texture so the two collect sites (material row + NEE tris) share one solve.
406
+ const _emissiveMapAvgCache = new Map();
407
+ let _emissiveMapWarned = false;
408
+
409
+ // sRGB -> linear for one 0..1 channel (three decodes SRGBColorSpace maps this way
410
+ // before lighting; average in linear so the cast colour matches the lit look).
411
+ function srgbToLinear(c) {
412
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
413
+ }
414
+
415
+ // CPU average of an emissiveMap: draw the texture image into a small (16x16)
416
+ // offscreen canvas and mean the texels. Returns linear [r,g,b] in 0..1, or null
417
+ // if the image can't be read (CORS-tainted, or no decoded image yet) — the
418
+ // caller then falls back to the map-zeroes-the-light behaviour, once, with a
419
+ // console.info explaining why. Result is cached per texture.
420
+ function averageEmissiveMap(map) {
421
+ if (_emissiveMapAvgCache.has(map)) return _emissiveMapAvgCache.get(map);
422
+ let result = null;
423
+ try {
424
+ const img = map.image;
425
+ const w = img && (img.width || img.videoWidth || 0);
426
+ const h = img && (img.height || img.videoHeight || 0);
427
+ if (img && w > 0 && h > 0 && typeof document !== "undefined") {
428
+ const N = 16;
429
+ const canvas = document.createElement("canvas");
430
+ canvas.width = N;
431
+ canvas.height = N;
432
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
433
+ ctx.drawImage(img, 0, 0, N, N); // downsample
434
+ const d = ctx.getImageData(0, 0, N, N).data; // throws if the image is tainted
435
+ const toLinear = map.colorSpace !== THREE.NoColorSpace && map.colorSpace !== THREE.LinearSRGBColorSpace;
436
+ let r = 0, g = 0, b = 0;
437
+ const n = d.length / 4;
438
+ for (let i = 0; i < d.length; i += 4) {
439
+ if (toLinear) {
440
+ r += srgbToLinear(d[i] / 255);
441
+ g += srgbToLinear(d[i + 1] / 255);
442
+ b += srgbToLinear(d[i + 2] / 255);
443
+ } else {
444
+ r += d[i] / 255; g += d[i + 1] / 255; b += d[i + 2] / 255;
445
+ }
446
+ }
447
+ result = [r / n, g / n, b / n];
448
+ }
449
+ } catch (e) {
450
+ result = null; // CORS-tainted (SecurityError) or unreadable
451
+ }
452
+ if (result === null && !_emissiveMapWarned) {
453
+ _emissiveMapWarned = true;
454
+ console.info(
455
+ "three-realtime-rt: an emissiveMap could not be read on the CPU (CORS-tainted " +
456
+ "or not yet decoded), so its mesh casts no area light — it is still drawn " +
457
+ "per-pixel in the G-buffer. Serve the texture same-origin (or set " +
458
+ "image.crossOrigin) to enable the average-colour approximation."
459
+ );
460
+ }
461
+ _emissiveMapAvgCache.set(map, result);
462
+ return result;
463
+ }
464
+
336
465
  // Effective emissive colour (already scaled by intensity), or null if the
337
- // material doesn't emit. Matches the shading table's emissiveMap exclusion.
466
+ // material doesn't emit. A plain emissive colour is used directly; an
467
+ // emissiveMap is approximated by its AVERAGE colour (avg(map) x emissive x
468
+ // emissiveIntensity) so a textured emitter casts (approximately) correct light —
469
+ // the G-buffer still renders it per-pixel, so it LOOKS patterned. An unreadable
470
+ // map falls back to null (visible only), matching the old exclusion.
338
471
  function emissiveColor(mat) {
339
- if (mat.emissiveMap != null || !mat.emissive) return null;
472
+ if (!mat.emissive) return null;
340
473
  const i = mat.emissiveIntensity ?? 1;
341
474
  if (i <= 0 || mat.emissive.r + mat.emissive.g + mat.emissive.b <= 0) return null;
475
+ if (mat.emissiveMap != null) {
476
+ const avg = averageEmissiveMap(mat.emissiveMap);
477
+ if (avg == null) return null; // unreadable -> current behaviour (visible only)
478
+ const emit = [mat.emissive.r * i * avg[0], mat.emissive.g * i * avg[1], mat.emissive.b * i * avg[2]];
479
+ // A map that averages to (near) black casts no meaningful light — treat it
480
+ // as visible-only, like a black emissive colour. This keeps an incidental
481
+ // textured emissive (a high-poly model whose emissiveMap is mostly dark with
482
+ // a few tiny glowing texels — e.g. DamagedHelmet) out of the NEE list rather
483
+ // than flooding it with the whole mesh; a real neon sign clears this easily.
484
+ const lum = 0.2126 * emit[0] + 0.7152 * emit[1] + 0.0722 * emit[2];
485
+ if (lum < 1e-3) return null;
486
+ return emit;
487
+ }
342
488
  return [mat.emissive.r * i, mat.emissive.g * i, mat.emissive.b * i];
343
489
  }
344
490
 
@@ -387,22 +533,9 @@ function buildSceneDataTexture(materials, emissiveTris) {
387
533
  data[o + i] = (bn[src + i] + 0.5) / 256.0;
388
534
  }
389
535
  }
390
- // Emissive power CDF (see the layout comment above). Weight = area x
391
- // emitted luminance; degenerate totals fall back to the uniform pick.
392
- if (emissiveTris.length > 0) {
393
- const w = emissiveTris.map(
394
- (t) => t.area * (0.2126 * t.emit[0] + 0.7152 * t.emit[1] + 0.0722 * t.emit[2])
395
- );
396
- const total = w.reduce((a, b) => a + b, 0);
397
- const cdfRow = (2 + BLUE_NOISE_SIZE) * row;
398
- let cum = 0;
399
- for (let i = 0; i < emissiveTris.length; i++) {
400
- const p = total > 0 ? w[i] / total : 1 / emissiveTris.length;
401
- cum += p;
402
- data[cdfRow + i * 4 + 0] = i === emissiveTris.length - 1 ? 1.0 : cum;
403
- data[cdfRow + i * 4 + 1] = p;
404
- }
405
- }
536
+ // Emissive power CDF (row 66). Factored out so updateDynamic can rebuild it
537
+ // in place when a dynamic emitter's area/position changed this frame.
538
+ writeEmissiveCdf(data, row, emissiveTris);
406
539
  const tex = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
407
540
  tex.minFilter = THREE.NearestFilter;
408
541
  tex.magFilter = THREE.NearestFilter;
@@ -410,11 +543,40 @@ function buildSceneDataTexture(materials, emissiveTris) {
410
543
  return tex;
411
544
  }
412
545
 
546
+ // Write the emissive power CDF (row 66 of the scene-data texture). Weight =
547
+ // area x emitted luminance; degenerate totals fall back to the uniform pick.
548
+ // The layout matches the row-66 comment in buildSceneDataTexture. `row` is the
549
+ // texture width in floats (width * 4). Reused by updateDynamic's in-place refresh.
550
+ function writeEmissiveCdf(data, row, emissiveTris) {
551
+ if (emissiveTris.length === 0) return;
552
+ const cdfRow = (2 + BLUE_NOISE_SIZE) * row;
553
+ let total = 0;
554
+ const w = new Array(emissiveTris.length);
555
+ for (let i = 0; i < emissiveTris.length; i++) {
556
+ const t = emissiveTris[i];
557
+ w[i] = t.area * (0.2126 * t.emit[0] + 0.7152 * t.emit[1] + 0.0722 * t.emit[2]);
558
+ total += w[i];
559
+ }
560
+ let cum = 0;
561
+ for (let i = 0; i < emissiveTris.length; i++) {
562
+ const p = total > 0 ? w[i] / total : 1 / emissiveTris.length;
563
+ cum += p;
564
+ data[cdfRow + i * 4 + 0] = i === emissiveTris.length - 1 ? 1.0 : cum;
565
+ data[cdfRow + i * 4 + 1] = p;
566
+ }
567
+ }
568
+
413
569
  // Collect world-space triangles of an emissive mesh for the NEE light list.
414
570
  // `geo` is already non-indexed and world-baked by extractMeshGeometry. An
415
571
  // optional [vStart, vCount) vertex range restricts collection to one material
416
572
  // group (ranges are triangle-aligned, so this stays whole-triangle).
417
- function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1) {
573
+ //
574
+ // `dynBase` >= 0 tags each triangle as belonging to a DYNAMIC emitter: dynBase is
575
+ // the float offset of this mesh's segment in the merged dynamic position array
576
+ // (segStart * 3), so `dynOff` records where the triangle's v0/e1/e2 live there.
577
+ // updateDynamic re-derives the world-space triangle from those merged positions
578
+ // each frame (the merged array already holds the freshly transformed vertices).
579
+ function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1, dynBase = -1) {
418
580
  const pos = geo.getAttribute("position").array;
419
581
  const begin = vStart * 3;
420
582
  const end = vCount < 0 ? pos.length : Math.min(pos.length, (vStart + vCount) * 3);
@@ -426,14 +588,19 @@ function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1) {
426
588
  const cz = e1[0] * e2[1] - e1[1] * e2[0];
427
589
  const len = Math.hypot(cx, cy, cz);
428
590
  if (len < 1e-10) continue; // degenerate
429
- out.push({
591
+ const tri = {
430
592
  v0: [pos[i], pos[i + 1], pos[i + 2]],
431
593
  e1,
432
594
  e2,
433
595
  n: [cx / len, cy / len, cz / len],
434
596
  area: len * 0.5,
435
597
  emit,
436
- });
598
+ };
599
+ if (dynBase >= 0) {
600
+ tri.dyn = true;
601
+ tri.dynOff = dynBase + i; // float offset of v0 in the merged dynamic positions
602
+ }
603
+ out.push(tri);
437
604
  }
438
605
  }
439
606
 
@@ -523,19 +690,20 @@ export function compileScene(scene, options = {}) {
523
690
  // the shared table) and get the ranges for per-group emissive collection.
524
691
  const { matIdx, ranges } = resolveMeshMaterials(obj, extracted.count, registerMaterial);
525
692
  extracted.geo.setAttribute("materialIndex", new THREE.BufferAttribute(matIdx, 1));
526
- // Static emissive meshes become NEE area lights (sampled directly with
527
- // shadow rays instead of waiting for a GI ray to stumble into them).
528
- // Dynamic emitters are left out their world-space triangles would go
529
- // stale so they keep lighting the old way, via GI-ray hits. Each emissive
530
- // GROUP contributes its own range.
531
- if (!isDynamic) {
693
+ // Emissive meshes become NEE area lights (sampled directly with shadow rays
694
+ // instead of waiting for a GI ray to stumble into them). Each emissive GROUP
695
+ // contributes its own range. STATIC emitters are collected in world space
696
+ // once; DYNAMIC emitters are ALSO collected now but tagged with their merged
697
+ // segment offset (segStart * 3) so updateDynamic() can re-derive their
698
+ // world-space triangles each frame from the freshly baked merged positions.
699
+ if (isDynamic) {
700
+ const segStart = dynVertexOffset; // this segment's vertex base in the merged array
701
+ dynamicGeoms.push(extracted.geo);
532
702
  for (const r of ranges) {
533
703
  const emit = emissiveColor(r.material);
534
- if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
704
+ if (emit)
705
+ collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount, segStart * 3);
535
706
  }
536
- }
537
- if (isDynamic) {
538
- dynamicGeoms.push(extracted.geo);
539
707
  // A SkinnedMesh is auto-detected (no userData flag): it is CPU-skinned from
540
708
  // its live skeleton pose every frame. Opt-in CPU deformation (water/cloth)
541
709
  // instead requires userData.rtDeforming and reads live geometry. The two are
@@ -564,6 +732,10 @@ export function compileScene(scene, options = {}) {
564
732
  dynVertexOffset += extracted.count;
565
733
  } else {
566
734
  staticGeoms.push(extracted.geo);
735
+ for (const r of ranges) {
736
+ const emit = emissiveColor(r.material);
737
+ if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
738
+ }
567
739
  }
568
740
  });
569
741
 
@@ -602,13 +774,27 @@ export function compileScene(scene, options = {}) {
602
774
  if (emissiveTris.length > MAX_EMISSIVE_TRIS) {
603
775
  console.warn(
604
776
  `three-realtime-rt: ${emissiveTris.length} emissive triangles exceed the ` +
605
- `NEE cap of ${MAX_EMISSIVE_TRIS}; keeping the largest by area. Dropped ` +
606
- `triangles no longer act as lights prefer low-poly emitter meshes.`
777
+ `NEE cap of ${MAX_EMISSIVE_TRIS} (shared across static + dynamic emitters); ` +
778
+ `keeping the largest by area (measured at compile time). Dropped triangles ` +
779
+ `no longer act as lights — prefer low-poly emitter meshes, especially for ` +
780
+ `dynamic ones (their tris are refreshed every frame).`
607
781
  );
608
782
  emissiveTris.sort((a, b) => b.area - a.area);
609
783
  emissiveTris.length = MAX_EMISSIVE_TRIS;
610
784
  }
611
785
  compiled.emissiveTriCount = emissiveTris.length;
786
+ // Keep the final (post-cap) emissive list so updateDynamic can rebuild the
787
+ // power CDF, and record which surviving rows belong to dynamic emitters (with
788
+ // their merged-position offset) so their world-space triangles can be
789
+ // refreshed per frame. Rows stay index-stable after this point, so the
790
+ // reservoir passes' triangle ids remain valid across refreshes.
791
+ compiled.emissiveTris = emissiveTris;
792
+ compiled._dynamicEmissive = [];
793
+ for (let r = 0; r < emissiveTris.length; r++) {
794
+ const t = emissiveTris[r];
795
+ if (t.dyn) compiled._dynamicEmissive.push({ row: r, off: t.dynOff, emit: t.emit });
796
+ }
797
+ compiled.hasDynamicEmissive = compiled._dynamicEmissive.length > 0;
612
798
  compiled.materialsTex = buildSceneDataTexture(materials, emissiveTris);
613
799
  syncLights(scene, compiled);
614
800
 
package/src/TAAPass.js CHANGED
@@ -128,6 +128,9 @@ export class TAAPass {
128
128
  this._reset = true;
129
129
 
130
130
  this.material = new THREE.ShaderMaterial({
131
+ // Stable program name for compile-failure self-diagnosis; a link failure
132
+ // disables the optional `taa` feature (image stays lit, just aliased).
133
+ name: "rt:taa",
131
134
  glslVersion: THREE.GLSL3,
132
135
  vertexShader: fullscreenVert,
133
136
  fragmentShader: taaFrag,
@@ -147,6 +150,9 @@ export class TAAPass {
147
150
  });
148
151
 
149
152
  this.copyMaterial = new THREE.ShaderMaterial({
153
+ // The TAA resolve-to-screen / history-carry blit; folded into the `taa`
154
+ // feature classification (a failure makes TAA unusable → disable taa).
155
+ name: "rt:taa-copy",
150
156
  glslVersion: THREE.GLSL3,
151
157
  vertexShader: fullscreenVert,
152
158
  fragmentShader: copyFrag,
@@ -277,6 +277,9 @@ export class VolumetricPass {
277
277
  this.targetB = this._makeTarget(width, height);
278
278
 
279
279
  this.material = new THREE.ShaderMaterial({
280
+ // Stable program name for compile-failure self-diagnosis; a link failure
281
+ // disables the optional `volumetric` feature (image stays lit, no god rays).
282
+ name: "rt:volumetric",
280
283
  glslVersion: THREE.GLSL3,
281
284
  vertexShader: fullscreenVert,
282
285
  fragmentShader: volumetricFrag,
package/src/index.d.ts CHANGED
@@ -173,6 +173,24 @@ export interface RealtimeRaytracerOptions {
173
173
  restirGI?: boolean;
174
174
  /** EXPERIMENTAL — temporal M-cap for the ReSTIR GI reservoir (default 20). */
175
175
  restirGIMCap?: number;
176
+ /**
177
+ * EXPERIMENTAL — ReSTIR GI (v2) spatial-reuse taps per frame, taken after the
178
+ * temporal merge from the previous frame's reservoirs (reconnection-Jacobian
179
+ * reweighted, with a final visibility ray to prevent leaks). Clamped to 0..4;
180
+ * `0` reproduces v1 temporal-only behaviour. Default 2.
181
+ */
182
+ restirGISpatialTaps?: number;
183
+ /**
184
+ * EXPERIMENTAL — ReSTIR GI reservoir-sample validation period. Every frame a
185
+ * rotating 1-in-N subset of pixels re-aims its single candidate ray at the
186
+ * reservoir's stored hit (instead of a fresh cosine bounce) and re-shades it;
187
+ * the reservoir is killed when the geometry moved or the re-shaded target
188
+ * collapsed to near-black (a light switched off), and left untouched otherwise.
189
+ * Reuses the existing candidate trace (no extra bounce rays); fixes stale bounce
190
+ * light (a switched-off light stops haunting the reservoir) without drifting a
191
+ * static scene. `0` disables it (byte-identical to before the feature). Default 8.
192
+ */
193
+ restirGIValidate?: number;
176
194
  /**
177
195
  * Global fallback index of refraction for transmissive surfaces. A
178
196
  * `MeshPhysicalMaterial.ior` overrides this per material for fully-transmissive
@@ -181,6 +199,15 @@ export interface RealtimeRaytracerOptions {
181
199
  * the default when no per-material ior is carried.
182
200
  */
183
201
  ior?: number;
202
+ /**
203
+ * Chromatic dispersion strength for glass, `0..0.5` (clamped), default `0`
204
+ * (off). Splits refracted white light into a spectrum via stochastic spectral
205
+ * sampling — each frame every glass pixel estimates one colour channel through
206
+ * a channel-shifted ior, and temporal accumulation blends the three into a
207
+ * rainbow (no extra rays). Needs accumulation to converge, so it shimmers
208
+ * slightly in motion. Global control only (no per-material dispersion).
209
+ */
210
+ dispersion?: number;
184
211
  /** History length cap: higher = smoother but slower to react. */
185
212
  maxHistory?: number;
186
213
  /** Clamp on indirect luminance to suppress fireflies. 0 disables. */
@@ -250,6 +277,45 @@ export interface VolumetricState {
250
277
  zones: FogZone[];
251
278
  }
252
279
 
280
+ /** One optional feature auto-disabled after its pass program failed to link. */
281
+ export interface DisabledPass {
282
+ /** The failed pass program's stable name (e.g. `"rt:taa"`, `"rt:denoise"`). */
283
+ pass: string;
284
+ /**
285
+ * The instance toggle that was set false to keep the image lit: one of
286
+ * `"restir"`, `"restirGI"`, `"denoise"`, `"volumetric"`, `"taa"`, `"specular"`.
287
+ */
288
+ feature: string;
289
+ /** First line of the driver's shader/link info log for the failure. */
290
+ reason: string;
291
+ }
292
+
293
+ /**
294
+ * Compile-health summary for the ray tracing pipeline, on
295
+ * {@link RealtimeRaytracer.status}. A pass whose program fails to LINK renders
296
+ * black without throwing (three logs and sets `program.diagnostics.runnable`
297
+ * false, but rendering proceeds), so this is how an integrator distinguishes a
298
+ * working RT image from a broken one — and renders an honest raster fallback
299
+ * with a reason instead of guessing. Populated over the first several rendered
300
+ * frames (link status is checked lazily / can be deferred by the driver).
301
+ */
302
+ export interface RaytracerStatus {
303
+ /**
304
+ * True while every ray tracing pass is running as intended. Flips to false the
305
+ * moment ANY `rt:*` pass program fails to link — whether a core pass (see
306
+ * `coreFailure`) or an optional feature that was auto-disabled (see `disabled`).
307
+ */
308
+ ok: boolean;
309
+ /** Optional features turned off to keep the image lit after their pass failed to link. */
310
+ disabled: DisabledPass[];
311
+ /**
312
+ * Set when a CORE pass (gbuffer / lighting / composite) failed to link. Such a
313
+ * pass has no fallback, so the image is black — but now diagnosed. The string
314
+ * is a `"rt:<pass>: <driver log>"` summary; null when no core pass has failed.
315
+ */
316
+ coreFailure: string | null;
317
+ }
318
+
253
319
  /** Options accepted by {@link RealtimeRaytracer.compileScene} and {@link compileScene}. */
254
320
  export interface CompileSceneOptions {
255
321
  /**
@@ -287,11 +353,19 @@ export class CompiledScene {
287
353
  triangleCount: number;
288
354
  /** Number of lights scanned into the compiled light tables. */
289
355
  lightCount: number;
290
- /** Number of emissive triangles registered as NEE area lights. */
356
+ /** Number of emissive triangles registered as NEE area lights (static + dynamic). */
291
357
  emissiveTriCount: number;
292
358
  /** World-space diagonal of the static level (used to auto-scale ray epsilon). */
293
359
  sceneDiagonal: number;
294
- /** Re-bake moving meshes' current world transforms and refit the dynamic BVH. */
360
+ /** True when any dynamic emitter contributes NEE rows refreshed each frame. */
361
+ hasDynamicEmissive: boolean;
362
+ /** CPU cost (ms) of the most recent dynamic-emissive refresh (0 if none). */
363
+ lastEmissiveRefreshMs: number;
364
+ /**
365
+ * Re-bake moving meshes' current world transforms and refit the dynamic BVH.
366
+ * Also refreshes any dynamic emitters' NEE area-light rows + power CDF from the
367
+ * freshly baked world positions.
368
+ */
295
369
  updateDynamic(): void;
296
370
  /** Release GPU resources held by this compiled scene. */
297
371
  dispose(): void;
@@ -335,6 +409,22 @@ export class RealtimeRaytracer {
335
409
  specMRTSupported: boolean;
336
410
  /** The current compiled scene, or null before the first compile / when unsupported. */
337
411
  compiled: CompiledScene | null;
412
+ /**
413
+ * First/most-severe pass compile-failure summary (`"rt:<pass>: <driver log>"`),
414
+ * or null while every ray tracing pass compiles clean. A quick honest signal
415
+ * for "should I show the RT image?"; see {@link status} for the structured form.
416
+ * Core failures (gbuffer/lighting/composite) outrank auto-disabled features here.
417
+ */
418
+ compileError: string | null;
419
+ /**
420
+ * Structured compile-health of the pipeline (see {@link RaytracerStatus}).
421
+ * `status.ok` is true on the healthy path and false once any pass fails to
422
+ * link; `status.disabled` lists auto-disabled optional features; and
423
+ * `status.coreFailure` names an unrecoverable core-pass failure. Populated over
424
+ * the first several rendered frames. When `supported` is false, `status.ok` is
425
+ * false too (the RT pipeline is not operational).
426
+ */
427
+ status: RaytracerStatus;
338
428
  /** Accumulated frame counter. */
339
429
  frame: number;
340
430
  /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular, 7 bvh cost. */
@@ -388,6 +478,12 @@ export class RealtimeRaytracer {
388
478
  * per material for fully-transmissive glass (range [1.0, 1.98]).
389
479
  */
390
480
  ior: number;
481
+ /**
482
+ * Chromatic dispersion strength for glass, `0..0.5` (clamped on upload),
483
+ * default `0`. Stochastic spectral sampling: splits refracted white light into
484
+ * a rainbow via temporal accumulation, no extra rays. Global control only.
485
+ */
486
+ dispersion: number;
391
487
  /** One stochastic direct shadow ray per pixel per frame instead of one per light. */
392
488
  stochasticLights: boolean;
393
489
  /** Adaptive quality governor toggle. */
@@ -415,6 +511,10 @@ export class RealtimeRaytracer {
415
511
  restirGI: boolean;
416
512
  /** EXPERIMENTAL — temporal M-cap for the ReSTIR GI reservoir. */
417
513
  restirGIMCap: number;
514
+ /** EXPERIMENTAL — ReSTIR GI (v2) spatial-reuse taps per frame (0..4, 0 = v1). */
515
+ restirGISpatialTaps: number;
516
+ /** EXPERIMENTAL — ReSTIR GI reservoir-sample validation period (0 = off, default 8). */
517
+ restirGIValidate: number;
418
518
  /** Procedural-sky state. */
419
519
  sky: SkyState;
420
520
  /** Distance-fog state. */
@@ -0,0 +1,37 @@
1
+ import * as THREE from "three";
2
+
3
+ /**
4
+ * Multiple-render-target constructor that works across the three versions in
5
+ * this library's peer range (three >=0.155.0).
6
+ *
7
+ * three r172 removed `WebGLMultipleRenderTargets`. Its replacement is the base
8
+ * `WebGLRenderTarget` with a `count` option (added r162) for N color
9
+ * attachments, which it exposes as the ARRAY `.textures` — while `.texture`
10
+ * became a single texture (an alias for `textures[0]`). This library predates
11
+ * that split and indexes the attachment array as `.texture[i]` throughout the
12
+ * passes, so on new three we shadow the instance `.texture` accessor with the
13
+ * `.textures` array. That keeps every existing `.texture[i]` / `for (... of
14
+ * .texture)` call site correct with no per-site changes, and does not disturb
15
+ * three's WebGL backend (which binds/reads/reads-back via `.textures`, and only
16
+ * touches the single `.texture` on cube/PMREM targets, never on these 2D MRTs).
17
+ *
18
+ * On old three (<= r171) `WebGLMultipleRenderTargets` already IS an
19
+ * array-`.texture` MRT, so it is used unchanged and the result is byte-identical
20
+ * to before this shim existed.
21
+ *
22
+ * Signature matches the old constructor: (width, height, count, options).
23
+ */
24
+ export function makeMRT(width, height, count, options = {}) {
25
+ if (THREE.WebGLMultipleRenderTargets) {
26
+ return new THREE.WebGLMultipleRenderTargets(width, height, count, options);
27
+ }
28
+ const rt = new THREE.WebGLRenderTarget(width, height, { ...options, count });
29
+ // Re-point `.texture` at the attachment array (old-three semantics). Shadows
30
+ // the prototype get/set texture() accessor on this instance only.
31
+ Object.defineProperty(rt, "texture", {
32
+ value: rt.textures,
33
+ writable: true,
34
+ configurable: true,
35
+ });
36
+ return rt;
37
+ }