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.
@@ -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 / uIor;
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, uIor);
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
- bool wantGI = uGIEnabled && !wantBehind
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) },
@@ -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
- const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
980
- const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 / this._height;
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
- if (this.denoise && this.denoiseIterations > 0) {
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();
@@ -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.deforming) {
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 orientation) every
182
- // frame, so their normals must go up every frame or the shading lags the
183
- // silhouette; one deforming segment forces the whole (shared) upload.
184
- if (this.hasDeforming || this._normalFrame++ % 8 === 0) {
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, materialIndex) {
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
- const mi = new Float32Array(count).fill(materialIndex);
215
- geo.setAttribute("materialIndex", new THREE.BufferAttribute(mi, 1));
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
- function collectEmissiveTriangles(geo, emit, out) {
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
- for (let i = 0; i + 8 < pos.length; i += 9) {
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 mat = Array.isArray(obj.material) ? obj.material[0] : obj.material;
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 (mat.transparent) return;
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 extracted = extractMeshGeometry(obj, mi);
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
- const isDynamic = dynamicSet && dynamicSet.has(obj);
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 emit = emissiveColor(mat);
396
- if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris);
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
- // Opt-in CPU deformation: the mesh must be BOTH in dynamicMeshes AND carry
401
- // userData.rtDeforming, and its live geometry is read every frame.
402
- const deforming = obj.userData.rtDeforming === true;
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
- indexMap: deforming ? extracted.indexMap : null,
413
- srcVertexCount: deforming ? extracted.srcVertexCount : 0,
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 {
@@ -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 (