three-realtime-rt 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = [];
@@ -55,9 +58,19 @@ export class CompiledScene {
55
58
  this.emissiveTriCount = 0;
56
59
  this.triangleCount = 0;
57
60
 
61
+ // Dynamic emissive area lights: the final (post-cap) emissive triangle list
62
+ // and the subset that belongs to dynamic emitters (row + merged-position
63
+ // offset), refreshed in place each updateDynamic(). lastEmissiveRefreshMs is
64
+ // the CPU cost of the most recent refresh (0 when there are none).
65
+ this.emissiveTris = [];
66
+ this._dynamicEmissive = [];
67
+ this.hasDynamicEmissive = false;
68
+ this.lastEmissiveRefreshMs = 0;
69
+
58
70
  this._m3 = new THREE.Matrix3();
59
71
  this._normalFrame = 0;
60
72
  this._dynBuildVolume = null; // world-volume of the dynamic set at build time
73
+ this._skinVec = new THREE.Vector3(); // reused per-vertex temp for CPU skinning
61
74
  }
62
75
 
63
76
  /**
@@ -81,7 +94,69 @@ export class CompiledScene {
81
94
  let o = seg.start * 3;
82
95
  let p = seg.start * 4;
83
96
 
84
- if (seg.deforming) {
97
+ if (seg.skinned) {
98
+ // Animated SkinnedMesh: CPU-skin the SOURCE vertices with three's own
99
+ // applyBoneTransform (bindMatrix + bone weights/matrices), then expand
100
+ // through the de-index mapping into the merged triangle soup. In r160
101
+ // applyBoneTransform/getVertexPosition return the vertex in the mesh's
102
+ // LOCAL (bind-relative) space — NOT world — so matrixWorld is still
103
+ // applied here, exactly like the rigid/deforming paths.
104
+ const mesh = seg.mesh;
105
+ // Keep the skeleton's bone texture coherent for the raster (G-buffer)
106
+ // path; applyBoneTransform itself reads bone.matrixWorld, which the app
107
+ // must have posed (mixer.update + a world-matrix update) before this call.
108
+ if (mesh.skeleton) mesh.skeleton.update();
109
+ const local = seg.skinnedLocal; // Float32Array(srcVertexCount * 3)
110
+ const tmp = this._skinVec;
111
+ const srcN = seg.srcVertexCount;
112
+ // 1. Skin each UNIQUE source vertex ONCE (O(verts x 4 bones)); shared
113
+ // triangle-soup slots then reuse the cached result.
114
+ for (let sv = 0; sv < srcN; sv++) {
115
+ mesh.getVertexPosition(sv, tmp); // bind pos -> skinned LOCAL space
116
+ local[sv * 3] = tmp.x;
117
+ local[sv * 3 + 1] = tmp.y;
118
+ local[sv * 3 + 2] = tmp.z;
119
+ }
120
+ const map = seg.indexMap; // null = identity (non-indexed source)
121
+ // 2. Expand to the merged layout and transform to world.
122
+ for (let i = 0; i < seg.count; i++) {
123
+ const sv = map ? map[i] : i;
124
+ const x = local[sv * 3], y = local[sv * 3 + 1], z = local[sv * 3 + 2];
125
+ const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
126
+ const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
127
+ const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
128
+ pos[o] = wx;
129
+ pos[o + 1] = wy;
130
+ pos[o + 2] = wz;
131
+ if (wx < minX) minX = wx; if (wx > maxX) maxX = wx;
132
+ if (wy < minY) minY = wy; if (wy > maxY) maxY = wy;
133
+ if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz;
134
+ o += 3;
135
+ }
136
+ // 3. PER-FACE normals from the skinned world triangles — the merged
137
+ // layout is already a de-indexed triangle soup, so each face's 3
138
+ // slots get the same geometric normal (flat-shaded). Secondary rays
139
+ // (shadows/GI) only need the geometry to be right; primary visibility
140
+ // still gets smooth normals from the raster path. This skips
141
+ // CPU-skinning the normal attribute entirely.
142
+ let fp = seg.start * 4;
143
+ for (let i = 0; i < seg.count; i += 3) {
144
+ const b = (seg.start + i) * 3;
145
+ const ax = pos[b], ay = pos[b + 1], az = pos[b + 2];
146
+ const e1x = pos[b + 3] - ax, e1y = pos[b + 4] - ay, e1z = pos[b + 5] - az;
147
+ const e2x = pos[b + 6] - ax, e2y = pos[b + 7] - ay, e2z = pos[b + 8] - az;
148
+ let nx = e1y * e2z - e1z * e2y;
149
+ let ny = e1z * e2x - e1x * e2z;
150
+ let nz = e1x * e2y - e1y * e2x;
151
+ const il = 1.0 / (Math.hypot(nx, ny, nz) || 1);
152
+ nx *= il; ny *= il; nz *= il;
153
+ packed[fp] = nx; packed[fp + 1] = ny; packed[fp + 2] = nz; // v0
154
+ packed[fp + 4] = nx; packed[fp + 5] = ny; packed[fp + 6] = nz; // v1
155
+ packed[fp + 8] = nx; packed[fp + 9] = ny; packed[fp + 10] = nz; // v2
156
+ // packed[fp + 3|7|11] (matIndex) never changes
157
+ fp += 12;
158
+ }
159
+ } else if (seg.deforming) {
85
160
  // CPU-deformed mesh (water/cloth): read the LIVE geometry every frame
86
161
  // and expand it back to the merged de-indexed layout through the mapping
87
162
  // snapshotted at compile time. `indexMap` (the source geometry's index
@@ -178,12 +253,71 @@ export class CompiledScene {
178
253
  }
179
254
  this.dynamicBvhUniform.updateFrom(this.dynamicBvh);
180
255
  // 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) {
256
+ // rigid movers. Deforming and skinned meshes change shape (not just
257
+ // orientation) every frame, so their normals must go up every frame or the
258
+ // shading lags the silhouette; one such segment forces the whole (shared)
259
+ // upload.
260
+ if (this.hasDeforming || this.hasSkinned || this._normalFrame++ % 8 === 0) {
185
261
  this.dynamicAttrTex.updateFrom(this.dynamicPackedAttr);
186
262
  }
263
+
264
+ // Refresh dynamic emissive area lights from the freshly baked world-space
265
+ // merged positions (rows in the scene-data texture + the power CDF).
266
+ if (this.hasDynamicEmissive) this._refreshDynamicEmissive();
267
+ }
268
+
269
+ /**
270
+ * Re-derive dynamic emitters' world-space NEE triangles from the merged
271
+ * dynamic positions this frame, rewrite their rows in the scene-data texture
272
+ * (row 1) and rebuild the power CDF (row 66), then flag the texture for
273
+ * re-upload. The whole (small) texture goes back up — measured in
274
+ * lastEmissiveRefreshMs. Called from updateDynamic (i.e. only when the dynamic
275
+ * set actually updated); an emissive change on an emitter that did NOT move —
276
+ * e.g. recolouring material.emissive — is frozen at compile time and needs a
277
+ * compileScene() (updateLights only rescans analytic THREE lights).
278
+ */
279
+ _refreshDynamicEmissive() {
280
+ const list = this._dynamicEmissive;
281
+ if (list.length === 0) return;
282
+ const now = typeof performance !== "undefined" ? performance : Date;
283
+ const t0 = now.now();
284
+ const tex = this.materialsTex;
285
+ const data = tex.image.data;
286
+ const row = tex.image.width * 4; // texture width in floats
287
+ const pos = this.dynamicMerged.getAttribute("position").array;
288
+ const tris = this.emissiveTris;
289
+ for (let k = 0; k < list.length; k++) {
290
+ const de = list[k];
291
+ const off = de.off;
292
+ const ax = pos[off], ay = pos[off + 1], az = pos[off + 2];
293
+ const e1x = pos[off + 3] - ax, e1y = pos[off + 4] - ay, e1z = pos[off + 5] - az;
294
+ const e2x = pos[off + 6] - ax, e2y = pos[off + 7] - ay, e2z = pos[off + 8] - az;
295
+ let nx = e1y * e2z - e1z * e2y;
296
+ let ny = e1z * e2x - e1x * e2z;
297
+ let nz = e1x * e2y - e1y * e2x;
298
+ const len = Math.hypot(nx, ny, nz);
299
+ const area = len * 0.5;
300
+ const il = len > 1e-10 ? 1 / len : 0; // keep a degenerate frame from NaN-ing
301
+ nx *= il; ny *= il; nz *= il;
302
+ const emit = de.emit;
303
+ // Keep the JS-side tri object current so writeEmissiveCdf sees fresh areas.
304
+ const t = tris[de.row];
305
+ t.v0[0] = ax; t.v0[1] = ay; t.v0[2] = az;
306
+ t.e1[0] = e1x; t.e1[1] = e1y; t.e1[2] = e1z;
307
+ t.e2[0] = e2x; t.e2[1] = e2y; t.e2[2] = e2z;
308
+ t.n[0] = nx; t.n[1] = ny; t.n[2] = nz;
309
+ t.area = area;
310
+ // Row 1 texel (16 floats) — layout must match buildSceneDataTexture.
311
+ const o = row + de.row * 16;
312
+ data[o + 0] = ax; data[o + 1] = ay; data[o + 2] = az; data[o + 3] = area;
313
+ data[o + 4] = e1x; data[o + 5] = e1y; data[o + 6] = e1z; data[o + 7] = emit[0];
314
+ data[o + 8] = e2x; data[o + 9] = e2y; data[o + 10] = e2z; data[o + 11] = emit[1];
315
+ data[o + 12] = nx; data[o + 13] = ny; data[o + 14] = nz; data[o + 15] = emit[2];
316
+ }
317
+ // Areas (and therefore pick probabilities) may have changed — rebuild the CDF.
318
+ writeEmissiveCdf(data, row, tris);
319
+ tex.needsUpdate = true;
320
+ this.lastEmissiveRefreshMs = now.now() - t0;
187
321
  }
188
322
 
189
323
  dispose() {
@@ -197,7 +331,7 @@ export class CompiledScene {
197
331
  }
198
332
  }
199
333
 
200
- function extractMeshGeometry(mesh, materialIndex) {
334
+ function extractMeshGeometry(mesh) {
201
335
  const indexed = mesh.geometry.index;
202
336
  const src = indexed ? mesh.geometry.toNonIndexed() : mesh.geometry.clone();
203
337
 
@@ -211,8 +345,9 @@ function extractMeshGeometry(mesh, materialIndex) {
211
345
  geo.applyMatrix4(mesh.matrixWorld); // bake world transform
212
346
 
213
347
  const count = geo.getAttribute("position").count;
214
- const mi = new Float32Array(count).fill(materialIndex);
215
- geo.setAttribute("materialIndex", new THREE.BufferAttribute(mi, 1));
348
+ // The per-vertex materialIndex attribute is filled by resolveGroups (which the
349
+ // caller runs with the shared materials table), so a multi-material mesh gets
350
+ // its groups mapped to distinct materials rather than a single flat index.
216
351
 
217
352
  // For CPU-deformed (rtDeforming) meshes we re-read the LIVE geometry each
218
353
  // frame. The merged BVH is de-indexed triangle soup, so record how to expand
@@ -226,12 +361,130 @@ function extractMeshGeometry(mesh, materialIndex) {
226
361
  return { geo, localPos, localNorm, count, indexMap, srcVertexCount };
227
362
  }
228
363
 
364
+ // Fill the per-vertex materialIndex for a (possibly multi-material) mesh and
365
+ // return the material ranges for per-group emissive collection. Groups on the
366
+ // INDEXED geometry are ranges over the index buffer; toNonIndexed() lays vertices
367
+ // out in index order, so a group's [start, start+count) maps to the SAME range of
368
+ // de-indexed vertices (identity for an already-non-indexed source). Each group's
369
+ // material is registered in the shared table via registerMaterial.
370
+ function resolveMeshMaterials(mesh, count, registerMaterial) {
371
+ const isArray = Array.isArray(mesh.material);
372
+ const groups = mesh.geometry.groups;
373
+ const matIdx = new Float32Array(count);
374
+ const ranges = [];
375
+ if (isArray && groups && groups.length > 0) {
376
+ // Ungrouped vertices (if any) default to material[0].
377
+ const base = mesh.material[0];
378
+ matIdx.fill(registerMaterial(base));
379
+ for (const g of groups) {
380
+ const gm = mesh.material[g.materialIndex] ?? base;
381
+ if (gm.transparent) {
382
+ throw new Error(
383
+ "three-realtime-rt: a transparent group material on a multi-material " +
384
+ "mesh is not supported for BVH tracing (transparent surfaces use the " +
385
+ "out-of-BVH straight-through blend path, which is per-mesh). Split the " +
386
+ `transparent group (materialIndex ${g.materialIndex}) into its own mesh.`
387
+ );
388
+ }
389
+ const gi = registerMaterial(gm);
390
+ const start = Math.max(0, g.start);
391
+ const end = Math.min(count, g.start + g.count);
392
+ for (let v = start; v < end; v++) matIdx[v] = gi;
393
+ ranges.push({ start, vcount: end - start, material: gm });
394
+ }
395
+ } else {
396
+ const mat = isArray ? mesh.material[0] : mesh.material;
397
+ matIdx.fill(registerMaterial(mat));
398
+ ranges.push({ start: 0, vcount: count, material: mat });
399
+ }
400
+ return { matIdx, ranges };
401
+ }
402
+
403
+ // Average-colour cache for emissive maps: texture -> [r,g,b] linear, or null when
404
+ // the image is unreadable (CORS-tainted / not yet decoded). Keyed by the THREE
405
+ // texture so the two collect sites (material row + NEE tris) share one solve.
406
+ const _emissiveMapAvgCache = new Map();
407
+ let _emissiveMapWarned = false;
408
+
409
+ // sRGB -> linear for one 0..1 channel (three decodes SRGBColorSpace maps this way
410
+ // before lighting; average in linear so the cast colour matches the lit look).
411
+ function srgbToLinear(c) {
412
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
413
+ }
414
+
415
+ // CPU average of an emissiveMap: draw the texture image into a small (16x16)
416
+ // offscreen canvas and mean the texels. Returns linear [r,g,b] in 0..1, or null
417
+ // if the image can't be read (CORS-tainted, or no decoded image yet) — the
418
+ // caller then falls back to the map-zeroes-the-light behaviour, once, with a
419
+ // console.info explaining why. Result is cached per texture.
420
+ function averageEmissiveMap(map) {
421
+ if (_emissiveMapAvgCache.has(map)) return _emissiveMapAvgCache.get(map);
422
+ let result = null;
423
+ try {
424
+ const img = map.image;
425
+ const w = img && (img.width || img.videoWidth || 0);
426
+ const h = img && (img.height || img.videoHeight || 0);
427
+ if (img && w > 0 && h > 0 && typeof document !== "undefined") {
428
+ const N = 16;
429
+ const canvas = document.createElement("canvas");
430
+ canvas.width = N;
431
+ canvas.height = N;
432
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
433
+ ctx.drawImage(img, 0, 0, N, N); // downsample
434
+ const d = ctx.getImageData(0, 0, N, N).data; // throws if the image is tainted
435
+ const toLinear = map.colorSpace !== THREE.NoColorSpace && map.colorSpace !== THREE.LinearSRGBColorSpace;
436
+ let r = 0, g = 0, b = 0;
437
+ const n = d.length / 4;
438
+ for (let i = 0; i < d.length; i += 4) {
439
+ if (toLinear) {
440
+ r += srgbToLinear(d[i] / 255);
441
+ g += srgbToLinear(d[i + 1] / 255);
442
+ b += srgbToLinear(d[i + 2] / 255);
443
+ } else {
444
+ r += d[i] / 255; g += d[i + 1] / 255; b += d[i + 2] / 255;
445
+ }
446
+ }
447
+ result = [r / n, g / n, b / n];
448
+ }
449
+ } catch (e) {
450
+ result = null; // CORS-tainted (SecurityError) or unreadable
451
+ }
452
+ if (result === null && !_emissiveMapWarned) {
453
+ _emissiveMapWarned = true;
454
+ console.info(
455
+ "three-realtime-rt: an emissiveMap could not be read on the CPU (CORS-tainted " +
456
+ "or not yet decoded), so its mesh casts no area light — it is still drawn " +
457
+ "per-pixel in the G-buffer. Serve the texture same-origin (or set " +
458
+ "image.crossOrigin) to enable the average-colour approximation."
459
+ );
460
+ }
461
+ _emissiveMapAvgCache.set(map, result);
462
+ return result;
463
+ }
464
+
229
465
  // Effective emissive colour (already scaled by intensity), or null if the
230
- // material doesn't emit. Matches the shading table's emissiveMap exclusion.
466
+ // material doesn't emit. A plain emissive colour is used directly; an
467
+ // emissiveMap is approximated by its AVERAGE colour (avg(map) x emissive x
468
+ // emissiveIntensity) so a textured emitter casts (approximately) correct light —
469
+ // the G-buffer still renders it per-pixel, so it LOOKS patterned. An unreadable
470
+ // map falls back to null (visible only), matching the old exclusion.
231
471
  function emissiveColor(mat) {
232
- if (mat.emissiveMap != null || !mat.emissive) return null;
472
+ if (!mat.emissive) return null;
233
473
  const i = mat.emissiveIntensity ?? 1;
234
474
  if (i <= 0 || mat.emissive.r + mat.emissive.g + mat.emissive.b <= 0) return null;
475
+ if (mat.emissiveMap != null) {
476
+ const avg = averageEmissiveMap(mat.emissiveMap);
477
+ if (avg == null) return null; // unreadable -> current behaviour (visible only)
478
+ const emit = [mat.emissive.r * i * avg[0], mat.emissive.g * i * avg[1], mat.emissive.b * i * avg[2]];
479
+ // A map that averages to (near) black casts no meaningful light — treat it
480
+ // as visible-only, like a black emissive colour. This keeps an incidental
481
+ // textured emissive (a high-poly model whose emissiveMap is mostly dark with
482
+ // a few tiny glowing texels — e.g. DamagedHelmet) out of the NEE list rather
483
+ // than flooding it with the whole mesh; a real neon sign clears this easily.
484
+ const lum = 0.2126 * emit[0] + 0.7152 * emit[1] + 0.0722 * emit[2];
485
+ if (lum < 1e-3) return null;
486
+ return emit;
487
+ }
235
488
  return [mat.emissive.r * i, mat.emissive.g * i, mat.emissive.b * i];
236
489
  }
237
490
 
@@ -280,22 +533,9 @@ function buildSceneDataTexture(materials, emissiveTris) {
280
533
  data[o + i] = (bn[src + i] + 0.5) / 256.0;
281
534
  }
282
535
  }
283
- // Emissive power CDF (see the layout comment above). Weight = area x
284
- // emitted luminance; degenerate totals fall back to the uniform pick.
285
- if (emissiveTris.length > 0) {
286
- const w = emissiveTris.map(
287
- (t) => t.area * (0.2126 * t.emit[0] + 0.7152 * t.emit[1] + 0.0722 * t.emit[2])
288
- );
289
- const total = w.reduce((a, b) => a + b, 0);
290
- const cdfRow = (2 + BLUE_NOISE_SIZE) * row;
291
- let cum = 0;
292
- for (let i = 0; i < emissiveTris.length; i++) {
293
- const p = total > 0 ? w[i] / total : 1 / emissiveTris.length;
294
- cum += p;
295
- data[cdfRow + i * 4 + 0] = i === emissiveTris.length - 1 ? 1.0 : cum;
296
- data[cdfRow + i * 4 + 1] = p;
297
- }
298
- }
536
+ // Emissive power CDF (row 66). Factored out so updateDynamic can rebuild it
537
+ // in place when a dynamic emitter's area/position changed this frame.
538
+ writeEmissiveCdf(data, row, emissiveTris);
299
539
  const tex = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
300
540
  tex.minFilter = THREE.NearestFilter;
301
541
  tex.magFilter = THREE.NearestFilter;
@@ -303,11 +543,44 @@ function buildSceneDataTexture(materials, emissiveTris) {
303
543
  return tex;
304
544
  }
305
545
 
546
+ // Write the emissive power CDF (row 66 of the scene-data texture). Weight =
547
+ // area x emitted luminance; degenerate totals fall back to the uniform pick.
548
+ // The layout matches the row-66 comment in buildSceneDataTexture. `row` is the
549
+ // texture width in floats (width * 4). Reused by updateDynamic's in-place refresh.
550
+ function writeEmissiveCdf(data, row, emissiveTris) {
551
+ if (emissiveTris.length === 0) return;
552
+ const cdfRow = (2 + BLUE_NOISE_SIZE) * row;
553
+ let total = 0;
554
+ const w = new Array(emissiveTris.length);
555
+ for (let i = 0; i < emissiveTris.length; i++) {
556
+ const t = emissiveTris[i];
557
+ w[i] = t.area * (0.2126 * t.emit[0] + 0.7152 * t.emit[1] + 0.0722 * t.emit[2]);
558
+ total += w[i];
559
+ }
560
+ let cum = 0;
561
+ for (let i = 0; i < emissiveTris.length; i++) {
562
+ const p = total > 0 ? w[i] / total : 1 / emissiveTris.length;
563
+ cum += p;
564
+ data[cdfRow + i * 4 + 0] = i === emissiveTris.length - 1 ? 1.0 : cum;
565
+ data[cdfRow + i * 4 + 1] = p;
566
+ }
567
+ }
568
+
306
569
  // 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) {
570
+ // `geo` is already non-indexed and world-baked by extractMeshGeometry. An
571
+ // optional [vStart, vCount) vertex range restricts collection to one material
572
+ // group (ranges are triangle-aligned, so this stays whole-triangle).
573
+ //
574
+ // `dynBase` >= 0 tags each triangle as belonging to a DYNAMIC emitter: dynBase is
575
+ // the float offset of this mesh's segment in the merged dynamic position array
576
+ // (segStart * 3), so `dynOff` records where the triangle's v0/e1/e2 live there.
577
+ // updateDynamic re-derives the world-space triangle from those merged positions
578
+ // each frame (the merged array already holds the freshly transformed vertices).
579
+ function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1, dynBase = -1) {
309
580
  const pos = geo.getAttribute("position").array;
310
- for (let i = 0; i + 8 < pos.length; i += 9) {
581
+ const begin = vStart * 3;
582
+ const end = vCount < 0 ? pos.length : Math.min(pos.length, (vStart + vCount) * 3);
583
+ for (let i = begin; i + 9 <= end; i += 9) {
311
584
  const e1 = [pos[i + 3] - pos[i], pos[i + 4] - pos[i + 1], pos[i + 5] - pos[i + 2]];
312
585
  const e2 = [pos[i + 6] - pos[i], pos[i + 7] - pos[i + 1], pos[i + 8] - pos[i + 2]];
313
586
  const cx = e1[1] * e2[2] - e1[2] * e2[1];
@@ -315,14 +588,19 @@ function collectEmissiveTriangles(geo, emit, out) {
315
588
  const cz = e1[0] * e2[1] - e1[1] * e2[0];
316
589
  const len = Math.hypot(cx, cy, cz);
317
590
  if (len < 1e-10) continue; // degenerate
318
- out.push({
591
+ const tri = {
319
592
  v0: [pos[i], pos[i + 1], pos[i + 2]],
320
593
  e1,
321
594
  e2,
322
595
  n: [cx / len, cy / len, cz / len],
323
596
  area: len * 0.5,
324
597
  emit,
325
- });
598
+ };
599
+ if (dynBase >= 0) {
600
+ tri.dyn = true;
601
+ tri.dynOff = dynBase + i; // float offset of v0 in the merged dynamic positions
602
+ }
603
+ out.push(tri);
326
604
  }
327
605
  }
328
606
 
@@ -372,35 +650,68 @@ export function compileScene(scene, options = {}) {
372
650
  let dynVertexOffset = 0;
373
651
  const tmpGeoms = []; // to dispose after merge
374
652
 
653
+ const registerMaterial = (m) => {
654
+ let i = materials.indexOf(m);
655
+ if (i < 0) { i = materials.length; materials.push(m); }
656
+ return i;
657
+ };
658
+
375
659
  scene.traverse((obj) => {
376
660
  if (!obj.isMesh || !obj.geometry || !obj.visible || obj.userData.rtExclude) return;
377
- const mat = Array.isArray(obj.material) ? obj.material[0] : obj.material;
661
+ const isArray = Array.isArray(obj.material);
662
+ const rep = isArray ? obj.material[0] : obj.material;
378
663
  // Transparent surfaces must not act as opaque occluders — e.g.
379
664
  // LittlestTokyo's glass display case (texture-alpha, opacity 1) would put
380
665
  // the whole model in shadow. Alpha-textured glass can't be cheaply tested,
381
666
  // so ANY transparent material is skipped like rtExclude (still
382
667
  // 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); }
668
+ if (rep.transparent) return;
386
669
 
387
- const extracted = extractMeshGeometry(obj, mi);
388
- tmpGeoms.push(extracted.geo);
389
- // Static emissive meshes become NEE area lights (sampled directly with
390
- // shadow rays instead of waiting for a GI ray to stumble into them).
391
- // 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
670
  const isDynamic = dynamicSet && dynamicSet.has(obj);
394
- if (!isDynamic) {
395
- const emit = emissiveColor(mat);
396
- if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris);
671
+ // Opt-in CPU deformation: the mesh must be BOTH in dynamicMeshes AND carry
672
+ // userData.rtDeforming, and its live geometry is read every frame.
673
+ const deforming = isDynamic && obj.userData.rtDeforming === true;
674
+ const hasGroups = isArray && obj.geometry.groups && obj.geometry.groups.length > 0;
675
+ // Multi-material groups are supported on static and rigid-dynamic meshes; the
676
+ // deforming rebake path assumes a single contiguous material range per merged
677
+ // segment, so reject the combination clearly rather than mis-shade it.
678
+ if (hasGroups && deforming) {
679
+ throw new Error(
680
+ "three-realtime-rt: multi-material groups on a CPU-deforming (rtDeforming) " +
681
+ "mesh are not supported — the per-frame live-geometry rebake assumes one " +
682
+ "material range. Use groups on a static or rigid-dynamic mesh, or split the " +
683
+ "deforming mesh into one mesh per material."
684
+ );
397
685
  }
686
+
687
+ const extracted = extractMeshGeometry(obj);
688
+ tmpGeoms.push(extracted.geo);
689
+ // Map groups → per-vertex material indices (registers each group material in
690
+ // the shared table) and get the ranges for per-group emissive collection.
691
+ const { matIdx, ranges } = resolveMeshMaterials(obj, extracted.count, registerMaterial);
692
+ extracted.geo.setAttribute("materialIndex", new THREE.BufferAttribute(matIdx, 1));
693
+ // Emissive meshes become NEE area lights (sampled directly with shadow rays
694
+ // instead of waiting for a GI ray to stumble into them). Each emissive GROUP
695
+ // contributes its own range. STATIC emitters are collected in world space
696
+ // once; DYNAMIC emitters are ALSO collected now but tagged with their merged
697
+ // segment offset (segStart * 3) so updateDynamic() can re-derive their
698
+ // world-space triangles each frame from the freshly baked merged positions.
398
699
  if (isDynamic) {
700
+ const segStart = dynVertexOffset; // this segment's vertex base in the merged array
399
701
  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;
702
+ for (const r of ranges) {
703
+ const emit = emissiveColor(r.material);
704
+ if (emit)
705
+ collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount, segStart * 3);
706
+ }
707
+ // A SkinnedMesh is auto-detected (no userData flag): it is CPU-skinned from
708
+ // its live skeleton pose every frame. Opt-in CPU deformation (water/cloth)
709
+ // instead requires userData.rtDeforming and reads live geometry. The two are
710
+ // mutually exclusive; skinning wins if a mesh is somehow both.
711
+ const skinned = obj.isSkinnedMesh === true;
712
+ const deforming = !skinned && obj.userData.rtDeforming === true;
403
713
  if (deforming) compiled.hasDeforming = true;
714
+ if (skinned) compiled.hasSkinned = true;
404
715
  compiled.dynamic.push({
405
716
  mesh: obj,
406
717
  start: dynVertexOffset,
@@ -408,13 +719,23 @@ export function compileScene(scene, options = {}) {
408
719
  localPos: extracted.localPos,
409
720
  localNorm: extracted.localNorm,
410
721
  deforming,
722
+ skinned,
411
723
  liveGeometry: deforming ? obj.geometry : null,
412
- indexMap: deforming ? extracted.indexMap : null,
413
- srcVertexCount: deforming ? extracted.srcVertexCount : 0,
724
+ // Skinned and deforming segments both expand live/source vertices back
725
+ // into the merged de-indexed layout through this mapping.
726
+ indexMap: deforming || skinned ? extracted.indexMap : null,
727
+ srcVertexCount: deforming || skinned ? extracted.srcVertexCount : 0,
728
+ // Cache of per-source-vertex skinned LOCAL positions (skinned segs only),
729
+ // filled each frame so shared triangle-soup slots reuse one skin solve.
730
+ skinnedLocal: skinned ? new Float32Array(extracted.srcVertexCount * 3) : null,
414
731
  });
415
732
  dynVertexOffset += extracted.count;
416
733
  } else {
417
734
  staticGeoms.push(extracted.geo);
735
+ for (const r of ranges) {
736
+ const emit = emissiveColor(r.material);
737
+ if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
738
+ }
418
739
  }
419
740
  });
420
741
 
@@ -453,13 +774,27 @@ export function compileScene(scene, options = {}) {
453
774
  if (emissiveTris.length > MAX_EMISSIVE_TRIS) {
454
775
  console.warn(
455
776
  `three-realtime-rt: ${emissiveTris.length} emissive triangles exceed the ` +
456
- `NEE cap of ${MAX_EMISSIVE_TRIS}; keeping the largest by area. Dropped ` +
457
- `triangles no longer act as lights prefer low-poly emitter meshes.`
777
+ `NEE cap of ${MAX_EMISSIVE_TRIS} (shared across static + dynamic emitters); ` +
778
+ `keeping the largest by area (measured at compile time). Dropped triangles ` +
779
+ `no longer act as lights — prefer low-poly emitter meshes, especially for ` +
780
+ `dynamic ones (their tris are refreshed every frame).`
458
781
  );
459
782
  emissiveTris.sort((a, b) => b.area - a.area);
460
783
  emissiveTris.length = MAX_EMISSIVE_TRIS;
461
784
  }
462
785
  compiled.emissiveTriCount = emissiveTris.length;
786
+ // Keep the final (post-cap) emissive list so updateDynamic can rebuild the
787
+ // power CDF, and record which surviving rows belong to dynamic emitters (with
788
+ // their merged-position offset) so their world-space triangles can be
789
+ // refreshed per frame. Rows stay index-stable after this point, so the
790
+ // reservoir passes' triangle ids remain valid across refreshes.
791
+ compiled.emissiveTris = emissiveTris;
792
+ compiled._dynamicEmissive = [];
793
+ for (let r = 0; r < emissiveTris.length; r++) {
794
+ const t = emissiveTris[r];
795
+ if (t.dyn) compiled._dynamicEmissive.push({ row: r, off: t.dynOff, emit: t.emit });
796
+ }
797
+ compiled.hasDynamicEmissive = compiled._dynamicEmissive.length > 0;
463
798
  compiled.materialsTex = buildSceneDataTexture(materials, emissiveTris);
464
799
  syncLights(scene, compiled);
465
800
 
@@ -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 (