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.
- package/README.md +157 -14
- package/package.json +2 -1
- package/src/CompositePass.js +6 -2
- package/src/DenoisePass.js +58 -6
- package/src/GBufferPass.js +132 -46
- package/src/GIReservoirPass.js +946 -0
- package/src/RTLightingPass.js +123 -10
- package/src/RealtimeRaytracer.js +143 -6
- package/src/SceneCompiler.js +386 -51
- package/src/bvhAnyHit.glsl.js +15 -0
- package/src/index.d.ts +105 -5
package/src/SceneCompiler.js
CHANGED
|
@@ -45,6 +45,9 @@ export class CompiledScene {
|
|
|
45
45
|
// True when any dynamic segment is CPU-deformed (rtDeforming) — such segments
|
|
46
46
|
// read their live geometry every frame and force a per-frame normal upload.
|
|
47
47
|
this.hasDeforming = false;
|
|
48
|
+
// True when any dynamic segment is a SkinnedMesh — CPU-skinned every frame
|
|
49
|
+
// (shape changes each frame, so it forces a per-frame normal upload too).
|
|
50
|
+
this.hasSkinned = false;
|
|
48
51
|
|
|
49
52
|
this.materialsTex = null;
|
|
50
53
|
this.materials = [];
|
|
@@ -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.
|
|
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
|
|
182
|
-
// frame, so their normals must go up every frame or the
|
|
183
|
-
// silhouette; one
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
284
|
-
//
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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}
|
|
457
|
-
`
|
|
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
|
|
package/src/bvhAnyHit.glsl.js
CHANGED
|
@@ -35,6 +35,17 @@
|
|
|
35
35
|
|
|
36
36
|
export const BVH_ANY_HIT_GLSL = /* glsl */ `
|
|
37
37
|
|
|
38
|
+
// Traversal-cost instrumentation. Counts how many BVH nodes the current pixel's
|
|
39
|
+
// shadow rays visit this frame — the raw signal behind the "bvh cost" heatmap
|
|
40
|
+
// debug view (outputMode 7). RTLightingPass main() zeroes it at the top of the
|
|
41
|
+
// pixel and reads it after all shadow rays have run; it accumulates across every
|
|
42
|
+
// bvhIntersectAnyHit call (both BVH levels, every light / GI / reflection ray).
|
|
43
|
+
// When uCostView is off the count is written nowhere, so shading is unaffected —
|
|
44
|
+
// the only cost is one integer add per popped node. Initialised to 0 so the
|
|
45
|
+
// VolumetricPass program (which shares this GLSL but never reads the counter)
|
|
46
|
+
// still compiles and runs unchanged.
|
|
47
|
+
int gBvhVisits = 0;
|
|
48
|
+
|
|
38
49
|
// Returns true if ANY triangle in the BVH is hit by the ray within (0, maxDist).
|
|
39
50
|
// Unordered traversal with early-out; no closest-hit bookkeeping.
|
|
40
51
|
bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDist ) {
|
|
@@ -54,6 +65,10 @@ bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDi
|
|
|
54
65
|
uint currNodeIndex = stack[ ptr ];
|
|
55
66
|
ptr --;
|
|
56
67
|
|
|
68
|
+
// One node visited (popped + tested). Counts pruned nodes too — that IS
|
|
69
|
+
// the traversal cost the heatmap visualises.
|
|
70
|
+
gBvhVisits ++;
|
|
71
|
+
|
|
57
72
|
// prune: skip nodes the ray misses or whose entry distance is already past maxDist
|
|
58
73
|
float boundsHitDistance;
|
|
59
74
|
if (
|