three-realtime-rt 0.4.0 → 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.
@@ -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
@@ -242,6 +243,72 @@ export class RealtimeRaytracer {
242
243
  }
243
244
  }
244
245
 
246
+ /**
247
+ * FUNCTIONAL probe: can this context actually DRAW into a 2-attachment
248
+ * half-float MRT? A checkFramebufferStatus probe is not enough — WebKit
249
+ * (every iOS browser) reports the framebuffer complete and then renders
250
+ * black, which killed the whole lighting pass on iPhone/iPad in 0.4.0.
251
+ * So: render one 2-output quad into a tiny fp16 MRT, resolve attachment 0
252
+ * into an RGBA8 target through a sampler, and read the pixel back. Only a
253
+ * round-trip that returns the written value counts as support.
254
+ */
255
+ static _specMrtSupported(renderer) {
256
+ let mrt, out, mat, copy, quad, scene2, cam;
257
+ const prevTarget = renderer.getRenderTarget();
258
+ try {
259
+ mrt = new THREE.WebGLMultipleRenderTargets(2, 2, 2, {
260
+ format: THREE.RGBAFormat,
261
+ type: THREE.HalfFloatType,
262
+ depthBuffer: false,
263
+ stencilBuffer: false,
264
+ });
265
+ for (const tex of mrt.texture) tex.generateMipmaps = false;
266
+ out = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false });
267
+ const vert = `out vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
268
+ mat = new THREE.ShaderMaterial({
269
+ glslVersion: THREE.GLSL3,
270
+ vertexShader: vert,
271
+ fragmentShader: `precision highp float;
272
+ layout(location = 0) out vec4 o0; layout(location = 1) out vec4 o1;
273
+ void main(){ o0 = vec4(0.5, 0.25, 0.75, 1.0); o1 = vec4(0.125); }`,
274
+ depthTest: false,
275
+ depthWrite: false,
276
+ });
277
+ copy = new THREE.ShaderMaterial({
278
+ glslVersion: THREE.GLSL3,
279
+ vertexShader: vert,
280
+ fragmentShader: `precision highp float; in vec2 vUv; out vec4 outColor;
281
+ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
282
+ uniforms: { uTex: { value: mrt.texture[0] } },
283
+ depthTest: false,
284
+ depthWrite: false,
285
+ });
286
+ scene2 = new THREE.Scene();
287
+ cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
288
+ quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
289
+ quad.frustumCulled = false;
290
+ scene2.add(quad);
291
+ renderer.setRenderTarget(mrt);
292
+ renderer.render(scene2, cam);
293
+ quad.material = copy;
294
+ renderer.setRenderTarget(out);
295
+ renderer.render(scene2, cam);
296
+ const px = new Uint8Array(4);
297
+ renderer.readRenderTargetPixels(out, 0, 0, 1, 1, px);
298
+ // 0.5 -> ~128; accept a generous tolerance (fp16 + dithering).
299
+ return Math.abs(px[0] - 128) < 24 && Math.abs(px[1] - 64) < 24;
300
+ } catch {
301
+ return false;
302
+ } finally {
303
+ renderer.setRenderTarget(prevTarget);
304
+ if (quad) quad.geometry.dispose();
305
+ if (mat) mat.dispose();
306
+ if (copy) copy.dispose();
307
+ if (mrt) mrt.dispose();
308
+ if (out) out.dispose();
309
+ }
310
+ }
311
+
245
312
  /**
246
313
  * Companion settings for a given lighting resolution. LOW resolution wants
247
314
  * MORE denoise passes, not fewer — the filter runs at lighting res so extra
@@ -325,8 +392,22 @@ export class RealtimeRaytracer {
325
392
  if (!mixedPrecision) {
326
393
  console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here — using fp32 for all targets.");
327
394
  }
395
+ // Functional probe, not just a status check: WebKit (all iOS browsers)
396
+ // reports the 2-attachment fp16 MRT complete but silently renders black,
397
+ // which blanks the whole lighting pass. On such devices the lighting pass
398
+ // runs single-attachment (0.3.x layout): the specular buffer is disabled
399
+ // and blend surfaces degrade to opaque — everything else keeps working.
400
+ this.specMRTSupported = RealtimeRaytracer._specMrtSupported(renderer);
401
+ if (!this.specMRTSupported) {
402
+ console.info(
403
+ "three-realtime-rt: multi-attachment lighting buffer failed the draw probe here " +
404
+ "(WebKit/iOS) — specular buffer disabled, alpha-blend surfaces render opaque."
405
+ );
406
+ }
328
407
  this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
329
- this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
408
+ this.rtPass = new RTLightingPass(this._scaledW, this._scaledH, {
409
+ specMRT: this.specMRTSupported,
410
+ });
330
411
  this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
331
412
  // Separate à-trous instance for the specular buffer (its own ping-pong
332
413
  // targets, so the specular denoise cannot clobber the irradiance result).
@@ -343,8 +424,15 @@ export class RealtimeRaytracer {
343
424
  this.compiled = null;
344
425
  this.frame = 0;
345
426
 
346
- /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
427
+ /** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive, 6 specular, 7 bvh cost */
347
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;
348
436
  /** Environment (sky) color used for GI rays that miss + composite background. */
349
437
  this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
350
438
  this.envIntensity = options.envIntensity ?? 1.0;
@@ -472,6 +560,13 @@ export class RealtimeRaytracer {
472
560
  this.taa = options.taa ?? true;
473
561
  /** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
474
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;
475
570
 
476
571
  /**
477
572
  * Volumetric lighting — real "god rays": single-scatter fog integrated
@@ -502,6 +597,22 @@ export class RealtimeRaytracer {
502
597
  this.restir = options.restir ?? true;
503
598
  this.restirPass = new RestirPass(this._scaledW, this._scaledH);
504
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
+
505
616
  /** Distance fog (composited in linear space before tonemap). */
506
617
  this.fog = {
507
618
  enabled: options.fog?.enabled ?? false,
@@ -623,6 +734,7 @@ export class RealtimeRaytracer {
623
734
  this.rtPass.setCompiledScene(this.compiled);
624
735
  this.volumetricPass.setCompiledScene(this.compiled);
625
736
  this.restirPass.setCompiledScene(this.compiled);
737
+ this.giReservoirPass.setCompiledScene(this.compiled);
626
738
  this.resetAccumulation();
627
739
  return this.compiled;
628
740
  }
@@ -648,6 +760,7 @@ export class RealtimeRaytracer {
648
760
  this.rtPass.setCompiledScene(this.compiled);
649
761
  this.volumetricPass.setCompiledScene(this.compiled);
650
762
  this.restirPass.setCompiledScene(this.compiled);
763
+ this.giReservoirPass.setCompiledScene(this.compiled);
651
764
  }
652
765
 
653
766
  resetAccumulation() {
@@ -751,6 +864,11 @@ export class RealtimeRaytracer {
751
864
  // clear them.
752
865
  this.restirPass.setSize(sw, sh);
753
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);
754
872
  }
755
873
 
756
874
  // Full-res / canvas-res targets: only touched on a real canvas resize (a
@@ -896,8 +1014,15 @@ export class RealtimeRaytracer {
896
1014
  // too — otherwise the raw buffers visibly shake.
897
1015
  if (this.taa && this.outputMode === 0) {
898
1016
  this._jitterIndex = (this._jitterIndex + 1) % 16;
899
- const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
900
- 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;
901
1026
  proj.elements[8] += jx;
902
1027
  proj.elements[9] += jy;
903
1028
  // Where this jitter moves the image, in UV space: elements[8/9] multiply
@@ -919,6 +1044,7 @@ export class RealtimeRaytracer {
919
1044
  this.rtPass.clearHistory(this.renderer);
920
1045
  this.volumetricPass.clearHistory(this.renderer);
921
1046
  this.restirPass.clearHistory(this.renderer);
1047
+ this.giReservoirPass.clearHistory(this.renderer);
922
1048
  this._needsClear = false;
923
1049
  }
924
1050
 
@@ -930,11 +1056,28 @@ export class RealtimeRaytracer {
930
1056
  rtU.uEnvColor.value.copy(this.envColor);
931
1057
  rtU.uEnvIntensity.value = this.envIntensity;
932
1058
  rtU.uEps.value = this.eps;
1059
+ rtU.uCostView.value = this.outputMode === 7;
1060
+ rtU.uCostScale.value = this.costScale;
933
1061
  rtU.uTemporalReprojection.value = this.temporalReprojection;
934
1062
  rtU.uMaxHistory.value = this.maxHistory;
935
1063
  rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
936
1064
  rtU.uGIEnabled.value = this.gi;
937
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;
938
1081
  rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
939
1082
  rtU.uEmissiveCDF.value = this.emissiveImportance;
940
1083
  rtU.uReflEnabled.value = this.reflections;
@@ -968,17 +1111,53 @@ export class RealtimeRaytracer {
968
1111
  this.eps
969
1112
  );
970
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
+
971
1144
  let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
972
1145
 
973
- // 3. denoise (display-only: history keeps accumulating raw samples)
974
- 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) {
975
1153
  irradiance = this.denoisePass.render(
976
1154
  this.renderer,
977
1155
  irradiance,
978
1156
  this.gbuffer,
979
1157
  this._camWorldPos,
980
1158
  this.eps,
981
- this.denoiseIterations
1159
+ this.denoiseIterations,
1160
+ giTex
982
1161
  );
983
1162
  }
984
1163
 
@@ -1099,6 +1278,7 @@ export class RealtimeRaytracer {
1099
1278
  this.taaPass.dispose();
1100
1279
  this.volumetricPass.dispose();
1101
1280
  this.restirPass.dispose();
1281
+ this.giReservoirPass.dispose();
1102
1282
  this._sceneColor.dispose();
1103
1283
  this._copyPass.dispose();
1104
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 (