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.
- package/README.md +8 -6
- package/package.json +1 -1
- package/src/DenoisePass.js +26 -2
- package/src/GIReservoirPass.js +431 -57
- package/src/RTLightingPass.js +62 -7
- package/src/RealtimeRaytracer.js +37 -0
- package/src/SceneCompiler.js +219 -33
- package/src/index.d.ts +47 -2
package/src/RTLightingPass.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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;
|
|
565
|
-
|
|
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,
|
|
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
|
-
|
|
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) },
|
package/src/RealtimeRaytracer.js
CHANGED
|
@@ -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,
|
package/src/SceneCompiler.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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 (
|
|
391
|
-
//
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
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)
|
|
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}
|
|
606
|
-
`
|
|
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
|
-
/**
|
|
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. */
|