three-realtime-rt 0.5.0 → 0.6.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.
@@ -66,6 +66,7 @@ uniform bool uReflEnabled; // traced reflections on metallic surfaces
66
66
  uniform bool uRefrEnabled; // traced refraction on transmissive surfaces
67
67
  uniform bool uBlendEnabled; // straight-through view continuation on blend surfaces
68
68
  uniform float uIor; // index of refraction for transmissive materials
69
+ uniform float uDispersion; // chromatic dispersion strength for glass (0 = off)
69
70
  uniform bool uLightStochastic; // 1 direct shadow ray/pixel/frame instead of 1/light
70
71
  uniform bool uRestirEnabled; // shade the reservoir winner instead of sampling
71
72
  uniform bool uGIHalfRate; // GI ray on alternating checkerboard, doubled
@@ -476,7 +477,8 @@ vec3 sampleOneAny(vec3 P, vec3 N) {
476
477
  // Incoming radiance along rd: trace, shade the hit with direct + NEE lighting,
477
478
  // sky/env on a miss. Specular rays keep emitter emission on hit (NEE at the ray
478
479
  // origin cannot cover a specular path); diffuse GI rays drop it for NEE-listed
479
- // (static) emitters so that light isn't counted twice.
480
+ // emitters (static AND dynamic — dynamic emitters now join the NEE table, their
481
+ // rows refreshed each frame) so that light isn't counted twice.
480
482
  vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
481
483
  uvec4 fi; vec3 bary; float dist; bool isDyn;
482
484
  if (!traceBoth(ro, rd, fi, bary, dist, isDyn)) {
@@ -493,7 +495,7 @@ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
493
495
  if (dot(hN, rd) > 0.0) hN = -hN;
494
496
  vec3 hP = ro + rd * dist;
495
497
  vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
496
- vec3 hLe = (!specular && uEmissiveCount > 0 && !isDyn) ? vec3(0.0) : hEmissive;
498
+ vec3 hLe = (!specular && uEmissiveCount > 0) ? vec3(0.0) : hEmissive;
497
499
  return hLe + hAlbedo * Ld * (1.0 / PI);
498
500
  }
499
501
 
@@ -553,16 +555,66 @@ vec3 analyticGlint(vec3 P, vec3 refl) {
553
555
 
554
556
  // Glass: Fresnel-weighted blend of a surface reflection and a two-interface
555
557
  // refraction (enter at P, march to the exit surface, refract again).
558
+ //
559
+ // CHROMATIC DISPERSION (stochastic spectral sampling). Real glass has a
560
+ // wavelength-dependent ior, so white light splits into a spectrum (a diamond
561
+ // throws a rainbow). Tracing one refraction path per colour would cost three
562
+ // traceRadiance calls, but the Metal call-site budget (see the note at the
563
+ // unified secondary-ray site) forbids a fourth traceRadiance anywhere in this
564
+ // shader. Instead, when uDispersion > 0 each frame this pixel picks ONE colour
565
+ // channel c in R,G,B uniformly and traces the SAME single refraction path with
566
+ // a channel-shifted ior. The refracted radiance is then isolated to channel c
567
+ // and multiplied by 3 (to compensate the 1-of-3 pick); the temporal EMA
568
+ // averages the three per-channel estimates into a full-spectrum, dispersed
569
+ // refraction — zero extra rays, zero new call sites, unbiased in the mean. It
570
+ // therefore shimmers slightly while converging.
571
+ //
572
+ // THE MIX SPLIT. The return is mix(refrRad, reflRad, fres) = refrRad*(1-fres)
573
+ // + reflRad*fres. Only the TRANSMITTED half (refrRad) carries the channel
574
+ // mask; the reflection half (reflRad) is NOT dispersed and stays full colour
575
+ // EVERY frame. To keep the reflection deterministic frame-to-frame, the
576
+ // Fresnel weight is taken from the BASE ior (constant), not the channel-shifted
577
+ // ior — only the refracted ray DIRECTION disperses, so the reflection term
578
+ // reflRad*fres is identical every frame while refrRad*mask*3 is the spectral
579
+ // estimator.
580
+ //
581
+ // OFF-PATH IDENTITY. uDispersion == 0 skips the channel pick entirely: it
582
+ // consumes NO rand() (so the RNG stream does not shift), leaves iorC == ior and
583
+ // chanMask == vec3(1), and the whole function reduces byte-for-byte to the
584
+ // pre-dispersion path.
556
585
  vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
557
586
  vec3 refl = glossyReflect(V, N, rough);
558
587
  vec3 reflRad = dot(refl, N) > 0.0
559
588
  ? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
560
589
  : vec3(0.0);
561
590
 
562
- float eta = 1.0 / ior;
591
+ // Per-frame spectral channel pick for the transmitted term (guarded so the
592
+ // off path consumes no rand()).
593
+ vec3 chanMask = vec3(1.0); // full colour (un-masked) when dispersion is off
594
+ float iorC = ior;
595
+ if (uDispersion > 0.0) {
596
+ int c = min(int(rand() * 3.0), 2); // uniform channel: 0 = R, 1 = G, 2 = B
597
+ // Normal dispersion: BLUE has the higher refractive index and bends most,
598
+ // red least. shift = (-1.0, 0.0, +1.0) * 0.5, indexed by channel:
599
+ // R = -0.5, G = 0, B = +0.5. uDispersion (0..0.5) scales the ior spread.
600
+ // (The original spec vector had the R/B signs reversed — audit-corrected.)
601
+ float shift = c == 0 ? -0.5 : (c == 2 ? 0.5 : 0.0);
602
+ iorC = ior * (1.0 + uDispersion * shift);
603
+ // Isolate channel c and weight x3: vec3(3,0,0) / (0,3,0) / (0,0,3). The
604
+ // mean over the three equally-likely picks is (1/3)(3,0,0)+... = (1,1,1),
605
+ // so E[masked refrRad] == refrRad. The OTHER channels are zero this frame.
606
+ chanMask = c == 0 ? vec3(3.0, 0.0, 0.0)
607
+ : c == 1 ? vec3(0.0, 3.0, 0.0)
608
+ : vec3(0.0, 0.0, 3.0);
609
+ }
610
+
611
+ float eta = 1.0 / iorC; // channel-shifted: drives the refraction bend
563
612
  vec3 rd = refract(V, N, eta);
564
- if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
565
- float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
613
+ if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
614
+ // Fresnel from the BASE ior so the reflection/refraction split is the same
615
+ // every frame (reflection stays full colour and un-dispersed). Equal to the
616
+ // original schlick(..., eta) when uDispersion == 0 (iorC == ior).
617
+ float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), 1.0 / ior);
566
618
 
567
619
  vec3 ro = P - N * (2.0 * uEps);
568
620
  vec3 refrRad;
@@ -575,7 +627,7 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
575
627
  vec3 xN = normalize(attr.xyz);
576
628
  if (dot(xN, rd) > 0.0) xN = -xN;
577
629
  vec3 xP = ro + rd * dist;
578
- vec3 rd2 = refract(rd, xN, ior);
630
+ vec3 rd2 = refract(rd, xN, iorC); // same channel-shifted ior on exit
579
631
  if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
580
632
  refrRad = traceRadiance(xP - xN * uEps, rd2, true);
581
633
  } else {
@@ -583,7 +635,9 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
583
635
  ? skyColor(rd, uSunDir, uSunColor, uSkyZenith, uSkyHorizon, uSkyIntensity)
584
636
  : uEnvColor * uEnvIntensity;
585
637
  }
586
- return mix(refrRad, reflRad, fres);
638
+ // Mask ONLY the transmitted term to the chosen channel (full colour when
639
+ // dispersion is off); the reflection term is never masked.
640
+ return mix(refrRad * chanMask, reflRad, fres);
587
641
  }
588
642
 
589
643
  // Compact cold->hot ramp for the BVH-cost heatmap. Piecewise mix of five
@@ -978,6 +1032,7 @@ export class RTLightingPass {
978
1032
  uRefrEnabled: { value: true },
979
1033
  uBlendEnabled: { value: true },
980
1034
  uIor: { value: 1.5 },
1035
+ uDispersion: { value: 0 },
981
1036
  uLightStochastic: { value: false },
982
1037
  uGIHalfRate: { value: false },
983
1038
  uEnvColor: { value: new THREE.Color(0.03, 0.04, 0.06) },
@@ -492,6 +492,17 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
492
492
  this.transparency = options.transparency ?? true;
493
493
  /** Index of refraction used for transmissive surfaces. */
494
494
  this.ior = options.ior ?? 1.5;
495
+ /**
496
+ * Chromatic dispersion strength for glass (0..0.5, clamped on upload,
497
+ * default 0 = off). Splits refracted white light into a spectrum: each
498
+ * frame every glass pixel estimates ONE colour channel through a
499
+ * channel-shifted ior and the temporal accumulator blends the three into a
500
+ * rainbow (stochastic spectral sampling — no extra rays). It shimmers
501
+ * slightly while converging, so it needs temporal accumulation to settle.
502
+ * Global control only for now (there is no free G-buffer channel for a
503
+ * per-material MeshPhysicalMaterial.dispersion).
504
+ */
505
+ this.dispersion = options.dispersion ?? 0;
495
506
  /**
496
507
  * One stochastic direct shadow ray per pixel per frame (source picked at
497
508
  * random) instead of one per light — the biggest ray-count lever for
@@ -610,6 +621,29 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
610
621
  this.restirGI = options.restirGI ?? false;
611
622
  /** Temporal M-cap for the ReSTIR GI reservoir (staleness limit). */
612
623
  this.restirGIMCap = options.restirGIMCap ?? 20;
624
+ /**
625
+ * ReSTIR GI (v2) spatial-reuse taps per frame, taken after the temporal
626
+ * merge from the previous frame's reservoirs (reconnection-Jacobian
627
+ * reweighted, with a final visibility ray). Clamped to 0..4; `0` reproduces
628
+ * the v1 temporal-only behaviour. Default 2.
629
+ */
630
+ // Default 1 (not 2): each reconnection tap adds its own estimator noise;
631
+ // one tap keeps most of the disocclusion win at half the artifact surface
632
+ // (tuned on-device — raise it for scenes with heavy camera motion).
633
+ this.restirGISpatialTaps = options.restirGISpatialTaps ?? 1;
634
+ /**
635
+ * EXPERIMENTAL — ReSTIR GI reservoir-sample validation period. Every frame a
636
+ * rotating 1-in-N subset of pixels (decorrelated by a per-pixel hash) re-aims
637
+ * its ONE candidate ray at the reservoir's STORED hit instead of a fresh
638
+ * cosine bounce and re-shades it; the reservoir is killed (so fresh candidates
639
+ * rebuild) when the geometry moved or the re-shaded target collapsed to
640
+ * near-black (a light switched off), and left untouched otherwise. This reuses
641
+ * the existing candidate trace (no extra bounce rays) and is the fix for stale
642
+ * bounce light: a switched-off light stops haunting the reservoir instead of
643
+ * fading slowly, while a static scene does not drift. `0` disables it
644
+ * (byte-identical to before the feature); default 8.
645
+ */
646
+ this.restirGIValidate = options.restirGIValidate ?? 8;
613
647
  this.giReservoirPass = new GIReservoirPass(this._scaledW, this._scaledH);
614
648
  this._giMissWarned = false;
615
649
 
@@ -1084,6 +1118,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1084
1118
  rtU.uRefrEnabled.value = this.refraction;
1085
1119
  rtU.uBlendEnabled.value = this.transparency;
1086
1120
  rtU.uIor.value = this.ior;
1121
+ rtU.uDispersion.value = Math.min(0.5, Math.max(0, this.dispersion));
1087
1122
  rtU.uLightStochastic.value = this.stochasticLights;
1088
1123
  rtU.uSkyEnabled.value = this.sky.enabled;
1089
1124
  rtU.uSunDir.value.copy(this.sky.sunDir);
@@ -1128,6 +1163,8 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1128
1163
  {
1129
1164
  fireflyClamp: this.fireflyClamp > 0 ? this.fireflyClamp : 1e6,
1130
1165
  mCap: this.restirGIMCap,
1166
+ spatialTaps: Math.max(0, Math.min(4, this.restirGISpatialTaps | 0)),
1167
+ validateInterval: Math.max(0, this.restirGIValidate | 0),
1131
1168
  emissiveCDF: this.emissiveImportance,
1132
1169
  envColor: this.envColor,
1133
1170
  envIntensity: this.envIntensity,
@@ -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/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. */
@@ -287,11 +314,19 @@ export class CompiledScene {
287
314
  triangleCount: number;
288
315
  /** Number of lights scanned into the compiled light tables. */
289
316
  lightCount: number;
290
- /** Number of emissive triangles registered as NEE area lights. */
317
+ /** Number of emissive triangles registered as NEE area lights (static + dynamic). */
291
318
  emissiveTriCount: number;
292
319
  /** World-space diagonal of the static level (used to auto-scale ray epsilon). */
293
320
  sceneDiagonal: number;
294
- /** Re-bake moving meshes' current world transforms and refit the dynamic BVH. */
321
+ /** True when any dynamic emitter contributes NEE rows refreshed each frame. */
322
+ hasDynamicEmissive: boolean;
323
+ /** CPU cost (ms) of the most recent dynamic-emissive refresh (0 if none). */
324
+ lastEmissiveRefreshMs: number;
325
+ /**
326
+ * Re-bake moving meshes' current world transforms and refit the dynamic BVH.
327
+ * Also refreshes any dynamic emitters' NEE area-light rows + power CDF from the
328
+ * freshly baked world positions.
329
+ */
295
330
  updateDynamic(): void;
296
331
  /** Release GPU resources held by this compiled scene. */
297
332
  dispose(): void;
@@ -388,6 +423,12 @@ export class RealtimeRaytracer {
388
423
  * per material for fully-transmissive glass (range [1.0, 1.98]).
389
424
  */
390
425
  ior: number;
426
+ /**
427
+ * Chromatic dispersion strength for glass, `0..0.5` (clamped on upload),
428
+ * default `0`. Stochastic spectral sampling: splits refracted white light into
429
+ * a rainbow via temporal accumulation, no extra rays. Global control only.
430
+ */
431
+ dispersion: number;
391
432
  /** One stochastic direct shadow ray per pixel per frame instead of one per light. */
392
433
  stochasticLights: boolean;
393
434
  /** Adaptive quality governor toggle. */
@@ -415,6 +456,10 @@ export class RealtimeRaytracer {
415
456
  restirGI: boolean;
416
457
  /** EXPERIMENTAL — temporal M-cap for the ReSTIR GI reservoir. */
417
458
  restirGIMCap: number;
459
+ /** EXPERIMENTAL — ReSTIR GI (v2) spatial-reuse taps per frame (0..4, 0 = v1). */
460
+ restirGISpatialTaps: number;
461
+ /** EXPERIMENTAL — ReSTIR GI reservoir-sample validation period (0 = off, default 8). */
462
+ restirGIValidate: number;
418
463
  /** Procedural-sky state. */
419
464
  sky: SkyState;
420
465
  /** Distance-fog state. */