three-realtime-rt 0.4.2 → 0.5.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 +151 -10
- package/package.json +2 -1
- package/src/CompositePass.js +6 -2
- package/src/DenoisePass.js +33 -5
- package/src/GBufferPass.js +132 -46
- package/src/GIReservoirPass.js +572 -0
- package/src/RTLightingPass.js +63 -5
- package/src/RealtimeRaytracer.js +106 -6
- package/src/SceneCompiler.js +174 -25
- package/src/bvhAnyHit.glsl.js +15 -0
- package/src/index.d.ts +58 -3
package/src/RTLightingPass.js
CHANGED
|
@@ -75,6 +75,18 @@ uniform float uEnvIntensity;
|
|
|
75
75
|
uniform float uFrame;
|
|
76
76
|
uniform float uEps;
|
|
77
77
|
uniform bool uGIEnabled;
|
|
78
|
+
// EXPERIMENTAL: when an external ReSTIR GI pass supplies the 1-bounce indirect
|
|
79
|
+
// (added downstream at the denoise stage), skip the inline GI trace so it isn't
|
|
80
|
+
// counted twice. A uniform, NOT a sampler — the lighting pass is already at the
|
|
81
|
+
// WebGL2 16-sampler minimum and cannot take another.
|
|
82
|
+
uniform bool uExternalGI;
|
|
83
|
+
|
|
84
|
+
// BVH traversal-cost heatmap (outputMode 7). When uCostView is on, main() writes
|
|
85
|
+
// the per-pixel shadow-ray node-visit count (gBvhVisits, from bvhAnyHit.glsl.js)
|
|
86
|
+
// through costPalette() into the irradiance attachment INSTEAD of the accumulated
|
|
87
|
+
// lighting — bypassing temporal blending — so the debug view reads the raw cost.
|
|
88
|
+
uniform bool uCostView;
|
|
89
|
+
uniform float uCostScale; // multiplies the visit count before the palette (default 1/96)
|
|
78
90
|
|
|
79
91
|
// Procedural sky (when enabled, replaces the flat env colour as the "miss" term
|
|
80
92
|
// for GI rays — this is what gives natural outdoor bounce light).
|
|
@@ -541,13 +553,13 @@ vec3 analyticGlint(vec3 P, vec3 refl) {
|
|
|
541
553
|
|
|
542
554
|
// Glass: Fresnel-weighted blend of a surface reflection and a two-interface
|
|
543
555
|
// refraction (enter at P, march to the exit surface, refract again).
|
|
544
|
-
vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
|
|
556
|
+
vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough, float ior) {
|
|
545
557
|
vec3 refl = glossyReflect(V, N, rough);
|
|
546
558
|
vec3 reflRad = dot(refl, N) > 0.0
|
|
547
559
|
? traceRadiance(P + N * uEps, refl, true) + analyticGlint(P, refl)
|
|
548
560
|
: vec3(0.0);
|
|
549
561
|
|
|
550
|
-
float eta = 1.0 /
|
|
562
|
+
float eta = 1.0 / ior;
|
|
551
563
|
vec3 rd = refract(V, N, eta);
|
|
552
564
|
if (rd == vec3(0.0)) return reflRad; // total internal reflection at entry
|
|
553
565
|
float fres = schlick(clamp(-dot(V, N), 0.0, 1.0), eta);
|
|
@@ -563,7 +575,7 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
|
|
|
563
575
|
vec3 xN = normalize(attr.xyz);
|
|
564
576
|
if (dot(xN, rd) > 0.0) xN = -xN;
|
|
565
577
|
vec3 xP = ro + rd * dist;
|
|
566
|
-
vec3 rd2 = refract(rd, xN,
|
|
578
|
+
vec3 rd2 = refract(rd, xN, ior);
|
|
567
579
|
if (rd2 == vec3(0.0)) rd2 = reflect(rd, xN);
|
|
568
580
|
refrRad = traceRadiance(xP - xN * uEps, rd2, true);
|
|
569
581
|
} else {
|
|
@@ -574,6 +586,26 @@ vec3 glassRadiance(vec3 P, vec3 N, vec3 V, float rough) {
|
|
|
574
586
|
return mix(refrRad, reflRad, fres);
|
|
575
587
|
}
|
|
576
588
|
|
|
589
|
+
// Compact cold->hot ramp for the BVH-cost heatmap. Piecewise mix of five
|
|
590
|
+
// anchors (deep blue -> green -> yellow -> red -> white) over four equal
|
|
591
|
+
// segments — cheap, no textures, no extra samplers. t is the normalised cost
|
|
592
|
+
// (visit count * uCostScale), clamped to [0,1]; saturating at white = the most
|
|
593
|
+
// expensive pixels.
|
|
594
|
+
vec3 costPalette(float t) {
|
|
595
|
+
t = clamp(t, 0.0, 1.0);
|
|
596
|
+
const vec3 c0 = vec3(0.02, 0.05, 0.45); // cold: cheap (few boxes)
|
|
597
|
+
const vec3 c1 = vec3(0.05, 0.55, 0.25); // green
|
|
598
|
+
const vec3 c2 = vec3(0.95, 0.85, 0.10); // yellow
|
|
599
|
+
const vec3 c3 = vec3(0.90, 0.10, 0.05); // red
|
|
600
|
+
const vec3 c4 = vec3(1.00, 1.00, 1.00); // hot: expensive (many boxes)
|
|
601
|
+
float s = t * 4.0;
|
|
602
|
+
vec3 col = mix(c0, c1, clamp(s, 0.0, 1.0));
|
|
603
|
+
col = mix(col, c2, clamp(s - 1.0, 0.0, 1.0));
|
|
604
|
+
col = mix(col, c3, clamp(s - 2.0, 0.0, 1.0));
|
|
605
|
+
col = mix(col, c4, clamp(s - 3.0, 0.0, 1.0));
|
|
606
|
+
return col;
|
|
607
|
+
}
|
|
608
|
+
|
|
577
609
|
void main() {
|
|
578
610
|
vec4 wp = texture(uGWorldPos, vUv);
|
|
579
611
|
if (wp.w < 0.5) {
|
|
@@ -599,6 +631,10 @@ void main() {
|
|
|
599
631
|
float transmission = (matW >= 2.0 && matW < 4.0) ? clamp(matW - 2.0, 0.0, 1.0) : 0.0;
|
|
600
632
|
float metal = matW < 2.0 ? matW : 0.0;
|
|
601
633
|
float rough = clamp(wp.w - 1.0, 0.0, 1.0);
|
|
634
|
+
// Per-material IOR rides the [3,4) glass sub-band (full-transmission glass, see
|
|
635
|
+
// GBufferPass). Below 3 (partial glass) or non-glass, fall back to the global
|
|
636
|
+
// rt.ior uniform. material.ior wins whenever it was encoded. (Task 2)
|
|
637
|
+
float ior = (matW >= 3.0 && matW < 4.0) ? (1.0 + (matW - 3.0)) : uIor;
|
|
602
638
|
|
|
603
639
|
// Cook-Torrance specular state for this primary surface. gWantSpec gates the
|
|
604
640
|
// GGX term to PRIMARY direct lighting only (GI-bounce direct light, below,
|
|
@@ -608,6 +644,11 @@ void main() {
|
|
|
608
644
|
gSpecRough = rough;
|
|
609
645
|
gWantSpec = true;
|
|
610
646
|
|
|
647
|
+
// Reset the shadow-ray traversal-cost counter for this pixel. It accumulates
|
|
648
|
+
// across every occluded() call below (direct, GI, reflection, glass) and is
|
|
649
|
+
// read once at the end when uCostView is on (see the cost-heatmap branch).
|
|
650
|
+
gBvhVisits = 0;
|
|
651
|
+
|
|
611
652
|
// --- direct lighting ---
|
|
612
653
|
// ReSTIR: shade the reservoir's winner with one visibility ray (flat cost in
|
|
613
654
|
// light count). Stochastic: one blind random sample. Full: one shadow ray
|
|
@@ -646,7 +687,10 @@ void main() {
|
|
|
646
687
|
vec3 indirect = vec3(0.0);
|
|
647
688
|
vec3 blendBehind = vec3(0.0);
|
|
648
689
|
bool wantBehind = uBlendEnabled && blend;
|
|
649
|
-
|
|
690
|
+
// uExternalGI (experimental ReSTIR GI): the GIReservoirPass supplies the
|
|
691
|
+
// bounce, so the inline GI ray is skipped — but the blend continuation is
|
|
692
|
+
// NOT GI and must keep tracing regardless.
|
|
693
|
+
bool wantGI = uGIEnabled && !uExternalGI && !wantBehind
|
|
650
694
|
&& (!uGIHalfRate || (((px.x + px.y + int(uFrame)) & 1) == 0));
|
|
651
695
|
if (wantBehind || wantGI) {
|
|
652
696
|
vec3 Vv = normalize(P - uCameraPos);
|
|
@@ -685,7 +729,7 @@ void main() {
|
|
|
685
729
|
// --- traced glass: Fresnel reflection + two-interface refraction ---
|
|
686
730
|
if (uRefrEnabled && transmission > 0.001) {
|
|
687
731
|
vec3 V = normalize(P - uCameraPos);
|
|
688
|
-
sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough), transmission);
|
|
732
|
+
sampleIrr = mix(sampleIrr, glassRadiance(P, N, V, rough, ior), transmission);
|
|
689
733
|
}
|
|
690
734
|
|
|
691
735
|
// --- alpha blend: straight-through view continuation ---
|
|
@@ -772,6 +816,17 @@ void main() {
|
|
|
772
816
|
// the fresh sample is used as-is.
|
|
773
817
|
vec3 blended = mix(history, sampleIrr, 1.0 / count);
|
|
774
818
|
outIrradiance = vec4(blended, count);
|
|
819
|
+
|
|
820
|
+
// BVH traversal-cost heatmap (outputMode 7). Overwrite the accumulated
|
|
821
|
+
// lighting with the palette-mapped shadow-ray node-visit count for this pixel.
|
|
822
|
+
// Alpha is forced to 1.0 so temporal history never builds on the cost image
|
|
823
|
+
// (each frame is a fresh snapshot), and the specular attachment is cleared so
|
|
824
|
+
// the composite's cost branch shows the palette alone. Uniform branch: when
|
|
825
|
+
// uCostView is off this is skipped and the writes above stand unchanged.
|
|
826
|
+
if (uCostView) {
|
|
827
|
+
outIrradiance = vec4(costPalette(float(gBvhVisits) * uCostScale), 1.0);
|
|
828
|
+
outSpecular = vec4(0.0);
|
|
829
|
+
}
|
|
775
830
|
}
|
|
776
831
|
`;
|
|
777
832
|
|
|
@@ -930,6 +985,9 @@ export class RTLightingPass {
|
|
|
930
985
|
uFrame: { value: 0 },
|
|
931
986
|
uEps: { value: 1e-3 },
|
|
932
987
|
uGIEnabled: { value: true },
|
|
988
|
+
uExternalGI: { value: false },
|
|
989
|
+
uCostView: { value: false },
|
|
990
|
+
uCostScale: { value: 1 / 96 },
|
|
933
991
|
uSkyEnabled: { value: false },
|
|
934
992
|
uSunDir: { value: new THREE.Vector3(0.4, 0.8, 0.45).normalize() },
|
|
935
993
|
uSunColor: { value: new THREE.Color(1.0, 0.9, 0.75) },
|
package/src/RealtimeRaytracer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { CompositePass } from "./CompositePass.js";
|
|
|
7
7
|
import { TAAPass } from "./TAAPass.js";
|
|
8
8
|
import { VolumetricPass } from "./VolumetricPass.js";
|
|
9
9
|
import { RestirPass } from "./RestirPass.js";
|
|
10
|
+
import { GIReservoirPass } from "./GIReservoirPass.js";
|
|
10
11
|
import { CopyPass } from "./CopyPass.js";
|
|
11
12
|
|
|
12
13
|
// Van der Corput / Halton radical inverse — deterministic low-discrepancy
|
|
@@ -423,8 +424,15 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
423
424
|
this.compiled = null;
|
|
424
425
|
this.frame = 0;
|
|
425
426
|
|
|
426
|
-
/** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular */
|
|
427
|
+
/** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular, 7 bvh cost */
|
|
427
428
|
this.outputMode = 0;
|
|
429
|
+
/**
|
|
430
|
+
* BVH-cost heatmap scale (outputMode 7): the per-pixel shadow-ray node-visit
|
|
431
|
+
* count is multiplied by this before the palette, so 1/costScale visits map
|
|
432
|
+
* to the hot (white) end. Default 1/96 — ~96 visits saturate. Live-tunable
|
|
433
|
+
* (the demo's "cost scale" slider drives it).
|
|
434
|
+
*/
|
|
435
|
+
this.costScale = options.costScale ?? 1 / 96;
|
|
428
436
|
/** Environment (sky) color used for GI rays that miss + composite background. */
|
|
429
437
|
this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
|
|
430
438
|
this.envIntensity = options.envIntensity ?? 1.0;
|
|
@@ -552,6 +560,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
552
560
|
this.taa = options.taa ?? true;
|
|
553
561
|
/** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
|
|
554
562
|
this.taaBlend = options.taaBlend ?? 0.1;
|
|
563
|
+
/**
|
|
564
|
+
* Scales the TAA sub-pixel jitter amplitude. Set this to your canvas scale
|
|
565
|
+
* when you render a reduced drawing buffer CSS-stretched to the screen
|
|
566
|
+
* (canvasScaleHook), so the jitter stays constant in SCREEN pixels instead
|
|
567
|
+
* of being magnified by the stretch (visible wobble at low quality).
|
|
568
|
+
*/
|
|
569
|
+
this.taaJitterScale = options.taaJitterScale ?? 1;
|
|
555
570
|
|
|
556
571
|
/**
|
|
557
572
|
* Volumetric lighting — real "god rays": single-scatter fog integrated
|
|
@@ -582,6 +597,22 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
582
597
|
this.restir = options.restir ?? true;
|
|
583
598
|
this.restirPass = new RestirPass(this._scaledW, this._scaledH);
|
|
584
599
|
|
|
600
|
+
/**
|
|
601
|
+
* EXPERIMENTAL — ReSTIR GI (v1, temporal-only): per-pixel reservoirs reuse
|
|
602
|
+
* the 1-bounce global-illumination sample across frames (at the reprojected
|
|
603
|
+
* same-surface point, no spatial reuse / no Jacobian). Runs in a standalone
|
|
604
|
+
* pass with its own sampler budget; when on, the lighting pass skips its
|
|
605
|
+
* inline GI trace and this pass's resolved GI is added at the denoise stage.
|
|
606
|
+
* Only meaningful when `gi` is on, and injected via the à-trous denoise, so
|
|
607
|
+
* it requires `denoise` (denoiseIterations >= 1). Default OFF. Its mean
|
|
608
|
+
* matches the inline GI path — see GIReservoirPass. Live-toggleable.
|
|
609
|
+
*/
|
|
610
|
+
this.restirGI = options.restirGI ?? false;
|
|
611
|
+
/** Temporal M-cap for the ReSTIR GI reservoir (staleness limit). */
|
|
612
|
+
this.restirGIMCap = options.restirGIMCap ?? 20;
|
|
613
|
+
this.giReservoirPass = new GIReservoirPass(this._scaledW, this._scaledH);
|
|
614
|
+
this._giMissWarned = false;
|
|
615
|
+
|
|
585
616
|
/** Distance fog (composited in linear space before tonemap). */
|
|
586
617
|
this.fog = {
|
|
587
618
|
enabled: options.fog?.enabled ?? false,
|
|
@@ -703,6 +734,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
703
734
|
this.rtPass.setCompiledScene(this.compiled);
|
|
704
735
|
this.volumetricPass.setCompiledScene(this.compiled);
|
|
705
736
|
this.restirPass.setCompiledScene(this.compiled);
|
|
737
|
+
this.giReservoirPass.setCompiledScene(this.compiled);
|
|
706
738
|
this.resetAccumulation();
|
|
707
739
|
return this.compiled;
|
|
708
740
|
}
|
|
@@ -728,6 +760,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
728
760
|
this.rtPass.setCompiledScene(this.compiled);
|
|
729
761
|
this.volumetricPass.setCompiledScene(this.compiled);
|
|
730
762
|
this.restirPass.setCompiledScene(this.compiled);
|
|
763
|
+
this.giReservoirPass.setCompiledScene(this.compiled);
|
|
731
764
|
}
|
|
732
765
|
|
|
733
766
|
resetAccumulation() {
|
|
@@ -831,6 +864,11 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
831
864
|
// clear them.
|
|
832
865
|
this.restirPass.setSize(sw, sh);
|
|
833
866
|
this.restirPass.clearHistory(this.renderer);
|
|
867
|
+
// Reservoir GI history is packed (hit position + M + radiance + W) —
|
|
868
|
+
// invalid to linearly resample — but reconverges in a few frames, so
|
|
869
|
+
// reallocate and clear like the DI reservoirs.
|
|
870
|
+
this.giReservoirPass.setSize(sw, sh);
|
|
871
|
+
this.giReservoirPass.clearHistory(this.renderer);
|
|
834
872
|
}
|
|
835
873
|
|
|
836
874
|
// Full-res / canvas-res targets: only touched on a real canvas resize (a
|
|
@@ -976,8 +1014,15 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
976
1014
|
// too — otherwise the raw buffers visibly shake.
|
|
977
1015
|
if (this.taa && this.outputMode === 0) {
|
|
978
1016
|
this._jitterIndex = (this._jitterIndex + 1) % 16;
|
|
979
|
-
|
|
980
|
-
|
|
1017
|
+
// taaJitterScale: when the app renders a REDUCED drawing buffer and CSS-
|
|
1018
|
+
// stretches it to the screen (the canvas-scale ladder), a half-buffer-pixel
|
|
1019
|
+
// jitter is magnified by the stretch and reads as visible screen wobble.
|
|
1020
|
+
// The app sets this to its canvas scale (see canvasScaleHook) so the
|
|
1021
|
+
// jitter stays constant in SCREEN pixels — slightly less sub-pixel AA
|
|
1022
|
+
// coverage at low canvas scales, in exchange for a steady image.
|
|
1023
|
+
const js = this.taaJitterScale;
|
|
1024
|
+
const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 * js / this._width;
|
|
1025
|
+
const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 * js / this._height;
|
|
981
1026
|
proj.elements[8] += jx;
|
|
982
1027
|
proj.elements[9] += jy;
|
|
983
1028
|
// Where this jitter moves the image, in UV space: elements[8/9] multiply
|
|
@@ -999,6 +1044,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
999
1044
|
this.rtPass.clearHistory(this.renderer);
|
|
1000
1045
|
this.volumetricPass.clearHistory(this.renderer);
|
|
1001
1046
|
this.restirPass.clearHistory(this.renderer);
|
|
1047
|
+
this.giReservoirPass.clearHistory(this.renderer);
|
|
1002
1048
|
this._needsClear = false;
|
|
1003
1049
|
}
|
|
1004
1050
|
|
|
@@ -1010,11 +1056,28 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
1010
1056
|
rtU.uEnvColor.value.copy(this.envColor);
|
|
1011
1057
|
rtU.uEnvIntensity.value = this.envIntensity;
|
|
1012
1058
|
rtU.uEps.value = this.eps;
|
|
1059
|
+
rtU.uCostView.value = this.outputMode === 7;
|
|
1060
|
+
rtU.uCostScale.value = this.costScale;
|
|
1013
1061
|
rtU.uTemporalReprojection.value = this.temporalReprojection;
|
|
1014
1062
|
rtU.uMaxHistory.value = this.maxHistory;
|
|
1015
1063
|
rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
|
|
1016
1064
|
rtU.uGIEnabled.value = this.gi;
|
|
1017
1065
|
rtU.uGIHalfRate.value = this.giHalfRate;
|
|
1066
|
+
// ReSTIR GI (experimental) supplies the 1-bounce indirect externally when
|
|
1067
|
+
// on; the lighting pass then skips its inline GI trace so it isn't double
|
|
1068
|
+
// counted. It's injected at the denoise stage, so it needs denoise on.
|
|
1069
|
+
const giExternal =
|
|
1070
|
+
this.restirGI && this.gi && this.denoise && this.denoiseIterations > 0;
|
|
1071
|
+
rtU.uExternalGI.value = giExternal;
|
|
1072
|
+
if (this.restirGI && this.gi && !giExternal && !this._giMissWarned) {
|
|
1073
|
+
console.info(
|
|
1074
|
+
"[three-realtime-rt] restirGI is on but denoise is off — ReSTIR GI is " +
|
|
1075
|
+
"injected during the à-trous denoise, so enable denoise " +
|
|
1076
|
+
"(denoiseIterations >= 1) to see its contribution."
|
|
1077
|
+
);
|
|
1078
|
+
this._giMissWarned = true;
|
|
1079
|
+
}
|
|
1080
|
+
if (giExternal) this._giMissWarned = false;
|
|
1018
1081
|
rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
|
|
1019
1082
|
rtU.uEmissiveCDF.value = this.emissiveImportance;
|
|
1020
1083
|
rtU.uReflEnabled.value = this.reflections;
|
|
@@ -1048,17 +1111,53 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
1048
1111
|
this.eps
|
|
1049
1112
|
);
|
|
1050
1113
|
}
|
|
1114
|
+
// 2b. ReSTIR GI reservoirs (experimental). Runs after the lighting pass's
|
|
1115
|
+
// inline GI is skipped (uExternalGI); the resolved GI is added at denoise.
|
|
1116
|
+
let giTex = null;
|
|
1117
|
+
if (giExternal) {
|
|
1118
|
+
this.giReservoirPass.setEmissiveCount(
|
|
1119
|
+
this.emissiveNEE ? this.compiled.emissiveTriCount : 0
|
|
1120
|
+
);
|
|
1121
|
+
giTex = this.giReservoirPass.render(
|
|
1122
|
+
this.renderer,
|
|
1123
|
+
this.gbuffer,
|
|
1124
|
+
this._prevViewProj,
|
|
1125
|
+
this._camWorldPos,
|
|
1126
|
+
this.frame,
|
|
1127
|
+
this.eps,
|
|
1128
|
+
{
|
|
1129
|
+
fireflyClamp: this.fireflyClamp > 0 ? this.fireflyClamp : 1e6,
|
|
1130
|
+
mCap: this.restirGIMCap,
|
|
1131
|
+
emissiveCDF: this.emissiveImportance,
|
|
1132
|
+
envColor: this.envColor,
|
|
1133
|
+
envIntensity: this.envIntensity,
|
|
1134
|
+
skyEnabled: this.sky.enabled,
|
|
1135
|
+
sunDir: this.sky.sunDir,
|
|
1136
|
+
sunColor: this.sky.sunColor,
|
|
1137
|
+
skyZenith: this.sky.zenith,
|
|
1138
|
+
skyHorizon: this.sky.horizon,
|
|
1139
|
+
skyIntensity: this.sky.intensity,
|
|
1140
|
+
}
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1051
1144
|
let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
1052
1145
|
|
|
1053
|
-
// 3. denoise (display-only: history keeps accumulating raw samples)
|
|
1054
|
-
|
|
1146
|
+
// 3. denoise (display-only: history keeps accumulating raw samples). The
|
|
1147
|
+
// experimental ReSTIR GI (giTex) is added on the first à-trous iteration —
|
|
1148
|
+
// downstream of the lighting pass's temporal history, so it never
|
|
1149
|
+
// double-counts through it. The bvh-cost heatmap (mode 7) is a per-pixel
|
|
1150
|
+
// debug signal, not lighting — the edge-aware blur would smear its bands,
|
|
1151
|
+
// so it bypasses the denoiser (which also keeps the GI add out of mode 7).
|
|
1152
|
+
if (this.denoise && this.denoiseIterations > 0 && this.outputMode !== 7) {
|
|
1055
1153
|
irradiance = this.denoisePass.render(
|
|
1056
1154
|
this.renderer,
|
|
1057
1155
|
irradiance,
|
|
1058
1156
|
this.gbuffer,
|
|
1059
1157
|
this._camWorldPos,
|
|
1060
1158
|
this.eps,
|
|
1061
|
-
this.denoiseIterations
|
|
1159
|
+
this.denoiseIterations,
|
|
1160
|
+
giTex
|
|
1062
1161
|
);
|
|
1063
1162
|
}
|
|
1064
1163
|
|
|
@@ -1179,6 +1278,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
|
|
|
1179
1278
|
this.taaPass.dispose();
|
|
1180
1279
|
this.volumetricPass.dispose();
|
|
1181
1280
|
this.restirPass.dispose();
|
|
1281
|
+
this.giReservoirPass.dispose();
|
|
1182
1282
|
this._sceneColor.dispose();
|
|
1183
1283
|
this._copyPass.dispose();
|
|
1184
1284
|
if (this.compiled) this.compiled.dispose();
|
package/src/SceneCompiler.js
CHANGED
|
@@ -45,6 +45,9 @@ export class CompiledScene {
|
|
|
45
45
|
// True when any dynamic segment is CPU-deformed (rtDeforming) — such segments
|
|
46
46
|
// read their live geometry every frame and force a per-frame normal upload.
|
|
47
47
|
this.hasDeforming = false;
|
|
48
|
+
// True when any dynamic segment is a SkinnedMesh — CPU-skinned every frame
|
|
49
|
+
// (shape changes each frame, so it forces a per-frame normal upload too).
|
|
50
|
+
this.hasSkinned = false;
|
|
48
51
|
|
|
49
52
|
this.materialsTex = null;
|
|
50
53
|
this.materials = [];
|
|
@@ -58,6 +61,7 @@ export class CompiledScene {
|
|
|
58
61
|
this._m3 = new THREE.Matrix3();
|
|
59
62
|
this._normalFrame = 0;
|
|
60
63
|
this._dynBuildVolume = null; // world-volume of the dynamic set at build time
|
|
64
|
+
this._skinVec = new THREE.Vector3(); // reused per-vertex temp for CPU skinning
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
|
@@ -81,7 +85,69 @@ export class CompiledScene {
|
|
|
81
85
|
let o = seg.start * 3;
|
|
82
86
|
let p = seg.start * 4;
|
|
83
87
|
|
|
84
|
-
if (seg.
|
|
88
|
+
if (seg.skinned) {
|
|
89
|
+
// Animated SkinnedMesh: CPU-skin the SOURCE vertices with three's own
|
|
90
|
+
// applyBoneTransform (bindMatrix + bone weights/matrices), then expand
|
|
91
|
+
// through the de-index mapping into the merged triangle soup. In r160
|
|
92
|
+
// applyBoneTransform/getVertexPosition return the vertex in the mesh's
|
|
93
|
+
// LOCAL (bind-relative) space — NOT world — so matrixWorld is still
|
|
94
|
+
// applied here, exactly like the rigid/deforming paths.
|
|
95
|
+
const mesh = seg.mesh;
|
|
96
|
+
// Keep the skeleton's bone texture coherent for the raster (G-buffer)
|
|
97
|
+
// path; applyBoneTransform itself reads bone.matrixWorld, which the app
|
|
98
|
+
// must have posed (mixer.update + a world-matrix update) before this call.
|
|
99
|
+
if (mesh.skeleton) mesh.skeleton.update();
|
|
100
|
+
const local = seg.skinnedLocal; // Float32Array(srcVertexCount * 3)
|
|
101
|
+
const tmp = this._skinVec;
|
|
102
|
+
const srcN = seg.srcVertexCount;
|
|
103
|
+
// 1. Skin each UNIQUE source vertex ONCE (O(verts x 4 bones)); shared
|
|
104
|
+
// triangle-soup slots then reuse the cached result.
|
|
105
|
+
for (let sv = 0; sv < srcN; sv++) {
|
|
106
|
+
mesh.getVertexPosition(sv, tmp); // bind pos -> skinned LOCAL space
|
|
107
|
+
local[sv * 3] = tmp.x;
|
|
108
|
+
local[sv * 3 + 1] = tmp.y;
|
|
109
|
+
local[sv * 3 + 2] = tmp.z;
|
|
110
|
+
}
|
|
111
|
+
const map = seg.indexMap; // null = identity (non-indexed source)
|
|
112
|
+
// 2. Expand to the merged layout and transform to world.
|
|
113
|
+
for (let i = 0; i < seg.count; i++) {
|
|
114
|
+
const sv = map ? map[i] : i;
|
|
115
|
+
const x = local[sv * 3], y = local[sv * 3 + 1], z = local[sv * 3 + 2];
|
|
116
|
+
const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
117
|
+
const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
118
|
+
const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
119
|
+
pos[o] = wx;
|
|
120
|
+
pos[o + 1] = wy;
|
|
121
|
+
pos[o + 2] = wz;
|
|
122
|
+
if (wx < minX) minX = wx; if (wx > maxX) maxX = wx;
|
|
123
|
+
if (wy < minY) minY = wy; if (wy > maxY) maxY = wy;
|
|
124
|
+
if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz;
|
|
125
|
+
o += 3;
|
|
126
|
+
}
|
|
127
|
+
// 3. PER-FACE normals from the skinned world triangles — the merged
|
|
128
|
+
// layout is already a de-indexed triangle soup, so each face's 3
|
|
129
|
+
// slots get the same geometric normal (flat-shaded). Secondary rays
|
|
130
|
+
// (shadows/GI) only need the geometry to be right; primary visibility
|
|
131
|
+
// still gets smooth normals from the raster path. This skips
|
|
132
|
+
// CPU-skinning the normal attribute entirely.
|
|
133
|
+
let fp = seg.start * 4;
|
|
134
|
+
for (let i = 0; i < seg.count; i += 3) {
|
|
135
|
+
const b = (seg.start + i) * 3;
|
|
136
|
+
const ax = pos[b], ay = pos[b + 1], az = pos[b + 2];
|
|
137
|
+
const e1x = pos[b + 3] - ax, e1y = pos[b + 4] - ay, e1z = pos[b + 5] - az;
|
|
138
|
+
const e2x = pos[b + 6] - ax, e2y = pos[b + 7] - ay, e2z = pos[b + 8] - az;
|
|
139
|
+
let nx = e1y * e2z - e1z * e2y;
|
|
140
|
+
let ny = e1z * e2x - e1x * e2z;
|
|
141
|
+
let nz = e1x * e2y - e1y * e2x;
|
|
142
|
+
const il = 1.0 / (Math.hypot(nx, ny, nz) || 1);
|
|
143
|
+
nx *= il; ny *= il; nz *= il;
|
|
144
|
+
packed[fp] = nx; packed[fp + 1] = ny; packed[fp + 2] = nz; // v0
|
|
145
|
+
packed[fp + 4] = nx; packed[fp + 5] = ny; packed[fp + 6] = nz; // v1
|
|
146
|
+
packed[fp + 8] = nx; packed[fp + 9] = ny; packed[fp + 10] = nz; // v2
|
|
147
|
+
// packed[fp + 3|7|11] (matIndex) never changes
|
|
148
|
+
fp += 12;
|
|
149
|
+
}
|
|
150
|
+
} else if (seg.deforming) {
|
|
85
151
|
// CPU-deformed mesh (water/cloth): read the LIVE geometry every frame
|
|
86
152
|
// and expand it back to the merged de-indexed layout through the mapping
|
|
87
153
|
// snapshotted at compile time. `indexMap` (the source geometry's index
|
|
@@ -178,10 +244,11 @@ export class CompiledScene {
|
|
|
178
244
|
}
|
|
179
245
|
this.dynamicBvhUniform.updateFrom(this.dynamicBvh);
|
|
180
246
|
// Normals only feed GI-bounce shading off movers — amortize their upload for
|
|
181
|
-
// rigid movers. Deforming meshes change shape (not just
|
|
182
|
-
// frame, so their normals must go up every frame or the
|
|
183
|
-
// silhouette; one
|
|
184
|
-
|
|
247
|
+
// rigid movers. Deforming and skinned meshes change shape (not just
|
|
248
|
+
// orientation) every frame, so their normals must go up every frame or the
|
|
249
|
+
// shading lags the silhouette; one such segment forces the whole (shared)
|
|
250
|
+
// upload.
|
|
251
|
+
if (this.hasDeforming || this.hasSkinned || this._normalFrame++ % 8 === 0) {
|
|
185
252
|
this.dynamicAttrTex.updateFrom(this.dynamicPackedAttr);
|
|
186
253
|
}
|
|
187
254
|
}
|
|
@@ -197,7 +264,7 @@ export class CompiledScene {
|
|
|
197
264
|
}
|
|
198
265
|
}
|
|
199
266
|
|
|
200
|
-
function extractMeshGeometry(mesh
|
|
267
|
+
function extractMeshGeometry(mesh) {
|
|
201
268
|
const indexed = mesh.geometry.index;
|
|
202
269
|
const src = indexed ? mesh.geometry.toNonIndexed() : mesh.geometry.clone();
|
|
203
270
|
|
|
@@ -211,8 +278,9 @@ function extractMeshGeometry(mesh, materialIndex) {
|
|
|
211
278
|
geo.applyMatrix4(mesh.matrixWorld); // bake world transform
|
|
212
279
|
|
|
213
280
|
const count = geo.getAttribute("position").count;
|
|
214
|
-
|
|
215
|
-
|
|
281
|
+
// The per-vertex materialIndex attribute is filled by resolveGroups (which the
|
|
282
|
+
// caller runs with the shared materials table), so a multi-material mesh gets
|
|
283
|
+
// its groups mapped to distinct materials rather than a single flat index.
|
|
216
284
|
|
|
217
285
|
// For CPU-deformed (rtDeforming) meshes we re-read the LIVE geometry each
|
|
218
286
|
// frame. The merged BVH is de-indexed triangle soup, so record how to expand
|
|
@@ -226,6 +294,45 @@ function extractMeshGeometry(mesh, materialIndex) {
|
|
|
226
294
|
return { geo, localPos, localNorm, count, indexMap, srcVertexCount };
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
// Fill the per-vertex materialIndex for a (possibly multi-material) mesh and
|
|
298
|
+
// return the material ranges for per-group emissive collection. Groups on the
|
|
299
|
+
// INDEXED geometry are ranges over the index buffer; toNonIndexed() lays vertices
|
|
300
|
+
// out in index order, so a group's [start, start+count) maps to the SAME range of
|
|
301
|
+
// de-indexed vertices (identity for an already-non-indexed source). Each group's
|
|
302
|
+
// material is registered in the shared table via registerMaterial.
|
|
303
|
+
function resolveMeshMaterials(mesh, count, registerMaterial) {
|
|
304
|
+
const isArray = Array.isArray(mesh.material);
|
|
305
|
+
const groups = mesh.geometry.groups;
|
|
306
|
+
const matIdx = new Float32Array(count);
|
|
307
|
+
const ranges = [];
|
|
308
|
+
if (isArray && groups && groups.length > 0) {
|
|
309
|
+
// Ungrouped vertices (if any) default to material[0].
|
|
310
|
+
const base = mesh.material[0];
|
|
311
|
+
matIdx.fill(registerMaterial(base));
|
|
312
|
+
for (const g of groups) {
|
|
313
|
+
const gm = mesh.material[g.materialIndex] ?? base;
|
|
314
|
+
if (gm.transparent) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"three-realtime-rt: a transparent group material on a multi-material " +
|
|
317
|
+
"mesh is not supported for BVH tracing (transparent surfaces use the " +
|
|
318
|
+
"out-of-BVH straight-through blend path, which is per-mesh). Split the " +
|
|
319
|
+
`transparent group (materialIndex ${g.materialIndex}) into its own mesh.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const gi = registerMaterial(gm);
|
|
323
|
+
const start = Math.max(0, g.start);
|
|
324
|
+
const end = Math.min(count, g.start + g.count);
|
|
325
|
+
for (let v = start; v < end; v++) matIdx[v] = gi;
|
|
326
|
+
ranges.push({ start, vcount: end - start, material: gm });
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
const mat = isArray ? mesh.material[0] : mesh.material;
|
|
330
|
+
matIdx.fill(registerMaterial(mat));
|
|
331
|
+
ranges.push({ start: 0, vcount: count, material: mat });
|
|
332
|
+
}
|
|
333
|
+
return { matIdx, ranges };
|
|
334
|
+
}
|
|
335
|
+
|
|
229
336
|
// Effective emissive colour (already scaled by intensity), or null if the
|
|
230
337
|
// material doesn't emit. Matches the shading table's emissiveMap exclusion.
|
|
231
338
|
function emissiveColor(mat) {
|
|
@@ -304,10 +411,14 @@ function buildSceneDataTexture(materials, emissiveTris) {
|
|
|
304
411
|
}
|
|
305
412
|
|
|
306
413
|
// Collect world-space triangles of an emissive mesh for the NEE light list.
|
|
307
|
-
// `geo` is already non-indexed and world-baked by extractMeshGeometry.
|
|
308
|
-
|
|
414
|
+
// `geo` is already non-indexed and world-baked by extractMeshGeometry. An
|
|
415
|
+
// optional [vStart, vCount) vertex range restricts collection to one material
|
|
416
|
+
// group (ranges are triangle-aligned, so this stays whole-triangle).
|
|
417
|
+
function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1) {
|
|
309
418
|
const pos = geo.getAttribute("position").array;
|
|
310
|
-
|
|
419
|
+
const begin = vStart * 3;
|
|
420
|
+
const end = vCount < 0 ? pos.length : Math.min(pos.length, (vStart + vCount) * 3);
|
|
421
|
+
for (let i = begin; i + 9 <= end; i += 9) {
|
|
311
422
|
const e1 = [pos[i + 3] - pos[i], pos[i + 4] - pos[i + 1], pos[i + 5] - pos[i + 2]];
|
|
312
423
|
const e2 = [pos[i + 6] - pos[i], pos[i + 7] - pos[i + 1], pos[i + 8] - pos[i + 2]];
|
|
313
424
|
const cx = e1[1] * e2[2] - e1[2] * e2[1];
|
|
@@ -372,35 +483,67 @@ export function compileScene(scene, options = {}) {
|
|
|
372
483
|
let dynVertexOffset = 0;
|
|
373
484
|
const tmpGeoms = []; // to dispose after merge
|
|
374
485
|
|
|
486
|
+
const registerMaterial = (m) => {
|
|
487
|
+
let i = materials.indexOf(m);
|
|
488
|
+
if (i < 0) { i = materials.length; materials.push(m); }
|
|
489
|
+
return i;
|
|
490
|
+
};
|
|
491
|
+
|
|
375
492
|
scene.traverse((obj) => {
|
|
376
493
|
if (!obj.isMesh || !obj.geometry || !obj.visible || obj.userData.rtExclude) return;
|
|
377
|
-
const
|
|
494
|
+
const isArray = Array.isArray(obj.material);
|
|
495
|
+
const rep = isArray ? obj.material[0] : obj.material;
|
|
378
496
|
// Transparent surfaces must not act as opaque occluders — e.g.
|
|
379
497
|
// LittlestTokyo's glass display case (texture-alpha, opacity 1) would put
|
|
380
498
|
// the whole model in shadow. Alpha-textured glass can't be cheaply tested,
|
|
381
499
|
// so ANY transparent material is skipped like rtExclude (still
|
|
382
500
|
// rasterized). alphaTest cut-outs (transparent: false) stay occluders.
|
|
383
|
-
if (
|
|
384
|
-
let mi = materials.indexOf(mat);
|
|
385
|
-
if (mi < 0) { mi = materials.length; materials.push(mat); }
|
|
501
|
+
if (rep.transparent) return;
|
|
386
502
|
|
|
387
|
-
const
|
|
503
|
+
const isDynamic = dynamicSet && dynamicSet.has(obj);
|
|
504
|
+
// Opt-in CPU deformation: the mesh must be BOTH in dynamicMeshes AND carry
|
|
505
|
+
// userData.rtDeforming, and its live geometry is read every frame.
|
|
506
|
+
const deforming = isDynamic && obj.userData.rtDeforming === true;
|
|
507
|
+
const hasGroups = isArray && obj.geometry.groups && obj.geometry.groups.length > 0;
|
|
508
|
+
// Multi-material groups are supported on static and rigid-dynamic meshes; the
|
|
509
|
+
// deforming rebake path assumes a single contiguous material range per merged
|
|
510
|
+
// segment, so reject the combination clearly rather than mis-shade it.
|
|
511
|
+
if (hasGroups && deforming) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
"three-realtime-rt: multi-material groups on a CPU-deforming (rtDeforming) " +
|
|
514
|
+
"mesh are not supported — the per-frame live-geometry rebake assumes one " +
|
|
515
|
+
"material range. Use groups on a static or rigid-dynamic mesh, or split the " +
|
|
516
|
+
"deforming mesh into one mesh per material."
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const extracted = extractMeshGeometry(obj);
|
|
388
521
|
tmpGeoms.push(extracted.geo);
|
|
522
|
+
// Map groups → per-vertex material indices (registers each group material in
|
|
523
|
+
// the shared table) and get the ranges for per-group emissive collection.
|
|
524
|
+
const { matIdx, ranges } = resolveMeshMaterials(obj, extracted.count, registerMaterial);
|
|
525
|
+
extracted.geo.setAttribute("materialIndex", new THREE.BufferAttribute(matIdx, 1));
|
|
389
526
|
// Static emissive meshes become NEE area lights (sampled directly with
|
|
390
527
|
// shadow rays instead of waiting for a GI ray to stumble into them).
|
|
391
528
|
// Dynamic emitters are left out — their world-space triangles would go
|
|
392
|
-
// stale — so they keep lighting the old way, via GI-ray hits.
|
|
393
|
-
|
|
529
|
+
// stale — so they keep lighting the old way, via GI-ray hits. Each emissive
|
|
530
|
+
// GROUP contributes its own range.
|
|
394
531
|
if (!isDynamic) {
|
|
395
|
-
const
|
|
396
|
-
|
|
532
|
+
for (const r of ranges) {
|
|
533
|
+
const emit = emissiveColor(r.material);
|
|
534
|
+
if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
|
|
535
|
+
}
|
|
397
536
|
}
|
|
398
537
|
if (isDynamic) {
|
|
399
538
|
dynamicGeoms.push(extracted.geo);
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
539
|
+
// A SkinnedMesh is auto-detected (no userData flag): it is CPU-skinned from
|
|
540
|
+
// its live skeleton pose every frame. Opt-in CPU deformation (water/cloth)
|
|
541
|
+
// instead requires userData.rtDeforming and reads live geometry. The two are
|
|
542
|
+
// mutually exclusive; skinning wins if a mesh is somehow both.
|
|
543
|
+
const skinned = obj.isSkinnedMesh === true;
|
|
544
|
+
const deforming = !skinned && obj.userData.rtDeforming === true;
|
|
403
545
|
if (deforming) compiled.hasDeforming = true;
|
|
546
|
+
if (skinned) compiled.hasSkinned = true;
|
|
404
547
|
compiled.dynamic.push({
|
|
405
548
|
mesh: obj,
|
|
406
549
|
start: dynVertexOffset,
|
|
@@ -408,9 +551,15 @@ export function compileScene(scene, options = {}) {
|
|
|
408
551
|
localPos: extracted.localPos,
|
|
409
552
|
localNorm: extracted.localNorm,
|
|
410
553
|
deforming,
|
|
554
|
+
skinned,
|
|
411
555
|
liveGeometry: deforming ? obj.geometry : null,
|
|
412
|
-
|
|
413
|
-
|
|
556
|
+
// Skinned and deforming segments both expand live/source vertices back
|
|
557
|
+
// into the merged de-indexed layout through this mapping.
|
|
558
|
+
indexMap: deforming || skinned ? extracted.indexMap : null,
|
|
559
|
+
srcVertexCount: deforming || skinned ? extracted.srcVertexCount : 0,
|
|
560
|
+
// Cache of per-source-vertex skinned LOCAL positions (skinned segs only),
|
|
561
|
+
// filled each frame so shared triangle-soup slots reuse one skin solve.
|
|
562
|
+
skinnedLocal: skinned ? new Float32Array(extracted.srcVertexCount * 3) : null,
|
|
414
563
|
});
|
|
415
564
|
dynVertexOffset += extracted.count;
|
|
416
565
|
} else {
|
package/src/bvhAnyHit.glsl.js
CHANGED
|
@@ -35,6 +35,17 @@
|
|
|
35
35
|
|
|
36
36
|
export const BVH_ANY_HIT_GLSL = /* glsl */ `
|
|
37
37
|
|
|
38
|
+
// Traversal-cost instrumentation. Counts how many BVH nodes the current pixel's
|
|
39
|
+
// shadow rays visit this frame — the raw signal behind the "bvh cost" heatmap
|
|
40
|
+
// debug view (outputMode 7). RTLightingPass main() zeroes it at the top of the
|
|
41
|
+
// pixel and reads it after all shadow rays have run; it accumulates across every
|
|
42
|
+
// bvhIntersectAnyHit call (both BVH levels, every light / GI / reflection ray).
|
|
43
|
+
// When uCostView is off the count is written nowhere, so shading is unaffected —
|
|
44
|
+
// the only cost is one integer add per popped node. Initialised to 0 so the
|
|
45
|
+
// VolumetricPass program (which shares this GLSL but never reads the counter)
|
|
46
|
+
// still compiles and runs unchanged.
|
|
47
|
+
int gBvhVisits = 0;
|
|
48
|
+
|
|
38
49
|
// Returns true if ANY triangle in the BVH is hit by the ray within (0, maxDist).
|
|
39
50
|
// Unordered traversal with early-out; no closest-hit bookkeeping.
|
|
40
51
|
bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDist ) {
|
|
@@ -54,6 +65,10 @@ bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDi
|
|
|
54
65
|
uint currNodeIndex = stack[ ptr ];
|
|
55
66
|
ptr --;
|
|
56
67
|
|
|
68
|
+
// One node visited (popped + tested). Counts pruned nodes too — that IS
|
|
69
|
+
// the traversal cost the heatmap visualises.
|
|
70
|
+
gBvhVisits ++;
|
|
71
|
+
|
|
57
72
|
// prune: skip nodes the ray misses or whose entry distance is already past maxDist
|
|
58
73
|
float boundsHitDistance;
|
|
59
74
|
if (
|