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.
- package/README.md +151 -10
- package/package.json +2 -1
- package/src/CompositePass.js +6 -2
- package/src/DenoisePass.js +33 -5
- package/src/GBufferPass.js +132 -46
- package/src/GIReservoirPass.js +572 -0
- package/src/RTLightingPass.js +166 -53
- package/src/RealtimeRaytracer.js +187 -7
- package/src/SceneCompiler.js +174 -25
- package/src/bvhAnyHit.glsl.js +15 -0
- package/src/index.d.ts +64 -3
package/src/RealtimeRaytracer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { CompositePass } from "./CompositePass.js";
|
|
|
7
7
|
import { TAAPass } from "./TAAPass.js";
|
|
8
8
|
import { VolumetricPass } from "./VolumetricPass.js";
|
|
9
9
|
import { RestirPass } from "./RestirPass.js";
|
|
10
|
+
import { GIReservoirPass } from "./GIReservoirPass.js";
|
|
10
11
|
import { CopyPass } from "./CopyPass.js";
|
|
11
12
|
|
|
12
13
|
// Van der Corput / Halton radical inverse — deterministic low-discrepancy
|
|
@@ -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
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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();
|
package/src/SceneCompiler.js
CHANGED
|
@@ -45,6 +45,9 @@ export class CompiledScene {
|
|
|
45
45
|
// True when any dynamic segment is CPU-deformed (rtDeforming) — such segments
|
|
46
46
|
// read their live geometry every frame and force a per-frame normal upload.
|
|
47
47
|
this.hasDeforming = false;
|
|
48
|
+
// True when any dynamic segment is a SkinnedMesh — CPU-skinned every frame
|
|
49
|
+
// (shape changes each frame, so it forces a per-frame normal upload too).
|
|
50
|
+
this.hasSkinned = false;
|
|
48
51
|
|
|
49
52
|
this.materialsTex = null;
|
|
50
53
|
this.materials = [];
|
|
@@ -58,6 +61,7 @@ export class CompiledScene {
|
|
|
58
61
|
this._m3 = new THREE.Matrix3();
|
|
59
62
|
this._normalFrame = 0;
|
|
60
63
|
this._dynBuildVolume = null; // world-volume of the dynamic set at build time
|
|
64
|
+
this._skinVec = new THREE.Vector3(); // reused per-vertex temp for CPU skinning
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
|
@@ -81,7 +85,69 @@ export class CompiledScene {
|
|
|
81
85
|
let o = seg.start * 3;
|
|
82
86
|
let p = seg.start * 4;
|
|
83
87
|
|
|
84
|
-
if (seg.
|
|
88
|
+
if (seg.skinned) {
|
|
89
|
+
// Animated SkinnedMesh: CPU-skin the SOURCE vertices with three's own
|
|
90
|
+
// applyBoneTransform (bindMatrix + bone weights/matrices), then expand
|
|
91
|
+
// through the de-index mapping into the merged triangle soup. In r160
|
|
92
|
+
// applyBoneTransform/getVertexPosition return the vertex in the mesh's
|
|
93
|
+
// LOCAL (bind-relative) space — NOT world — so matrixWorld is still
|
|
94
|
+
// applied here, exactly like the rigid/deforming paths.
|
|
95
|
+
const mesh = seg.mesh;
|
|
96
|
+
// Keep the skeleton's bone texture coherent for the raster (G-buffer)
|
|
97
|
+
// path; applyBoneTransform itself reads bone.matrixWorld, which the app
|
|
98
|
+
// must have posed (mixer.update + a world-matrix update) before this call.
|
|
99
|
+
if (mesh.skeleton) mesh.skeleton.update();
|
|
100
|
+
const local = seg.skinnedLocal; // Float32Array(srcVertexCount * 3)
|
|
101
|
+
const tmp = this._skinVec;
|
|
102
|
+
const srcN = seg.srcVertexCount;
|
|
103
|
+
// 1. Skin each UNIQUE source vertex ONCE (O(verts x 4 bones)); shared
|
|
104
|
+
// triangle-soup slots then reuse the cached result.
|
|
105
|
+
for (let sv = 0; sv < srcN; sv++) {
|
|
106
|
+
mesh.getVertexPosition(sv, tmp); // bind pos -> skinned LOCAL space
|
|
107
|
+
local[sv * 3] = tmp.x;
|
|
108
|
+
local[sv * 3 + 1] = tmp.y;
|
|
109
|
+
local[sv * 3 + 2] = tmp.z;
|
|
110
|
+
}
|
|
111
|
+
const map = seg.indexMap; // null = identity (non-indexed source)
|
|
112
|
+
// 2. Expand to the merged layout and transform to world.
|
|
113
|
+
for (let i = 0; i < seg.count; i++) {
|
|
114
|
+
const sv = map ? map[i] : i;
|
|
115
|
+
const x = local[sv * 3], y = local[sv * 3 + 1], z = local[sv * 3 + 2];
|
|
116
|
+
const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
117
|
+
const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
118
|
+
const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
119
|
+
pos[o] = wx;
|
|
120
|
+
pos[o + 1] = wy;
|
|
121
|
+
pos[o + 2] = wz;
|
|
122
|
+
if (wx < minX) minX = wx; if (wx > maxX) maxX = wx;
|
|
123
|
+
if (wy < minY) minY = wy; if (wy > maxY) maxY = wy;
|
|
124
|
+
if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz;
|
|
125
|
+
o += 3;
|
|
126
|
+
}
|
|
127
|
+
// 3. PER-FACE normals from the skinned world triangles — the merged
|
|
128
|
+
// layout is already a de-indexed triangle soup, so each face's 3
|
|
129
|
+
// slots get the same geometric normal (flat-shaded). Secondary rays
|
|
130
|
+
// (shadows/GI) only need the geometry to be right; primary visibility
|
|
131
|
+
// still gets smooth normals from the raster path. This skips
|
|
132
|
+
// CPU-skinning the normal attribute entirely.
|
|
133
|
+
let fp = seg.start * 4;
|
|
134
|
+
for (let i = 0; i < seg.count; i += 3) {
|
|
135
|
+
const b = (seg.start + i) * 3;
|
|
136
|
+
const ax = pos[b], ay = pos[b + 1], az = pos[b + 2];
|
|
137
|
+
const e1x = pos[b + 3] - ax, e1y = pos[b + 4] - ay, e1z = pos[b + 5] - az;
|
|
138
|
+
const e2x = pos[b + 6] - ax, e2y = pos[b + 7] - ay, e2z = pos[b + 8] - az;
|
|
139
|
+
let nx = e1y * e2z - e1z * e2y;
|
|
140
|
+
let ny = e1z * e2x - e1x * e2z;
|
|
141
|
+
let nz = e1x * e2y - e1y * e2x;
|
|
142
|
+
const il = 1.0 / (Math.hypot(nx, ny, nz) || 1);
|
|
143
|
+
nx *= il; ny *= il; nz *= il;
|
|
144
|
+
packed[fp] = nx; packed[fp + 1] = ny; packed[fp + 2] = nz; // v0
|
|
145
|
+
packed[fp + 4] = nx; packed[fp + 5] = ny; packed[fp + 6] = nz; // v1
|
|
146
|
+
packed[fp + 8] = nx; packed[fp + 9] = ny; packed[fp + 10] = nz; // v2
|
|
147
|
+
// packed[fp + 3|7|11] (matIndex) never changes
|
|
148
|
+
fp += 12;
|
|
149
|
+
}
|
|
150
|
+
} else if (seg.deforming) {
|
|
85
151
|
// CPU-deformed mesh (water/cloth): read the LIVE geometry every frame
|
|
86
152
|
// and expand it back to the merged de-indexed layout through the mapping
|
|
87
153
|
// snapshotted at compile time. `indexMap` (the source geometry's index
|
|
@@ -178,10 +244,11 @@ export class CompiledScene {
|
|
|
178
244
|
}
|
|
179
245
|
this.dynamicBvhUniform.updateFrom(this.dynamicBvh);
|
|
180
246
|
// Normals only feed GI-bounce shading off movers — amortize their upload for
|
|
181
|
-
// rigid movers. Deforming meshes change shape (not just
|
|
182
|
-
// frame, so their normals must go up every frame or the
|
|
183
|
-
// silhouette; one
|
|
184
|
-
|
|
247
|
+
// rigid movers. Deforming and skinned meshes change shape (not just
|
|
248
|
+
// orientation) every frame, so their normals must go up every frame or the
|
|
249
|
+
// shading lags the silhouette; one such segment forces the whole (shared)
|
|
250
|
+
// upload.
|
|
251
|
+
if (this.hasDeforming || this.hasSkinned || this._normalFrame++ % 8 === 0) {
|
|
185
252
|
this.dynamicAttrTex.updateFrom(this.dynamicPackedAttr);
|
|
186
253
|
}
|
|
187
254
|
}
|
|
@@ -197,7 +264,7 @@ export class CompiledScene {
|
|
|
197
264
|
}
|
|
198
265
|
}
|
|
199
266
|
|
|
200
|
-
function extractMeshGeometry(mesh
|
|
267
|
+
function extractMeshGeometry(mesh) {
|
|
201
268
|
const indexed = mesh.geometry.index;
|
|
202
269
|
const src = indexed ? mesh.geometry.toNonIndexed() : mesh.geometry.clone();
|
|
203
270
|
|
|
@@ -211,8 +278,9 @@ function extractMeshGeometry(mesh, materialIndex) {
|
|
|
211
278
|
geo.applyMatrix4(mesh.matrixWorld); // bake world transform
|
|
212
279
|
|
|
213
280
|
const count = geo.getAttribute("position").count;
|
|
214
|
-
|
|
215
|
-
|
|
281
|
+
// The per-vertex materialIndex attribute is filled by resolveGroups (which the
|
|
282
|
+
// caller runs with the shared materials table), so a multi-material mesh gets
|
|
283
|
+
// its groups mapped to distinct materials rather than a single flat index.
|
|
216
284
|
|
|
217
285
|
// For CPU-deformed (rtDeforming) meshes we re-read the LIVE geometry each
|
|
218
286
|
// frame. The merged BVH is de-indexed triangle soup, so record how to expand
|
|
@@ -226,6 +294,45 @@ function extractMeshGeometry(mesh, materialIndex) {
|
|
|
226
294
|
return { geo, localPos, localNorm, count, indexMap, srcVertexCount };
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
// Fill the per-vertex materialIndex for a (possibly multi-material) mesh and
|
|
298
|
+
// return the material ranges for per-group emissive collection. Groups on the
|
|
299
|
+
// INDEXED geometry are ranges over the index buffer; toNonIndexed() lays vertices
|
|
300
|
+
// out in index order, so a group's [start, start+count) maps to the SAME range of
|
|
301
|
+
// de-indexed vertices (identity for an already-non-indexed source). Each group's
|
|
302
|
+
// material is registered in the shared table via registerMaterial.
|
|
303
|
+
function resolveMeshMaterials(mesh, count, registerMaterial) {
|
|
304
|
+
const isArray = Array.isArray(mesh.material);
|
|
305
|
+
const groups = mesh.geometry.groups;
|
|
306
|
+
const matIdx = new Float32Array(count);
|
|
307
|
+
const ranges = [];
|
|
308
|
+
if (isArray && groups && groups.length > 0) {
|
|
309
|
+
// Ungrouped vertices (if any) default to material[0].
|
|
310
|
+
const base = mesh.material[0];
|
|
311
|
+
matIdx.fill(registerMaterial(base));
|
|
312
|
+
for (const g of groups) {
|
|
313
|
+
const gm = mesh.material[g.materialIndex] ?? base;
|
|
314
|
+
if (gm.transparent) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"three-realtime-rt: a transparent group material on a multi-material " +
|
|
317
|
+
"mesh is not supported for BVH tracing (transparent surfaces use the " +
|
|
318
|
+
"out-of-BVH straight-through blend path, which is per-mesh). Split the " +
|
|
319
|
+
`transparent group (materialIndex ${g.materialIndex}) into its own mesh.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const gi = registerMaterial(gm);
|
|
323
|
+
const start = Math.max(0, g.start);
|
|
324
|
+
const end = Math.min(count, g.start + g.count);
|
|
325
|
+
for (let v = start; v < end; v++) matIdx[v] = gi;
|
|
326
|
+
ranges.push({ start, vcount: end - start, material: gm });
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
const mat = isArray ? mesh.material[0] : mesh.material;
|
|
330
|
+
matIdx.fill(registerMaterial(mat));
|
|
331
|
+
ranges.push({ start: 0, vcount: count, material: mat });
|
|
332
|
+
}
|
|
333
|
+
return { matIdx, ranges };
|
|
334
|
+
}
|
|
335
|
+
|
|
229
336
|
// Effective emissive colour (already scaled by intensity), or null if the
|
|
230
337
|
// material doesn't emit. Matches the shading table's emissiveMap exclusion.
|
|
231
338
|
function emissiveColor(mat) {
|
|
@@ -304,10 +411,14 @@ function buildSceneDataTexture(materials, emissiveTris) {
|
|
|
304
411
|
}
|
|
305
412
|
|
|
306
413
|
// Collect world-space triangles of an emissive mesh for the NEE light list.
|
|
307
|
-
// `geo` is already non-indexed and world-baked by extractMeshGeometry.
|
|
308
|
-
|
|
414
|
+
// `geo` is already non-indexed and world-baked by extractMeshGeometry. An
|
|
415
|
+
// optional [vStart, vCount) vertex range restricts collection to one material
|
|
416
|
+
// group (ranges are triangle-aligned, so this stays whole-triangle).
|
|
417
|
+
function collectEmissiveTriangles(geo, emit, out, vStart = 0, vCount = -1) {
|
|
309
418
|
const pos = geo.getAttribute("position").array;
|
|
310
|
-
|
|
419
|
+
const begin = vStart * 3;
|
|
420
|
+
const end = vCount < 0 ? pos.length : Math.min(pos.length, (vStart + vCount) * 3);
|
|
421
|
+
for (let i = begin; i + 9 <= end; i += 9) {
|
|
311
422
|
const e1 = [pos[i + 3] - pos[i], pos[i + 4] - pos[i + 1], pos[i + 5] - pos[i + 2]];
|
|
312
423
|
const e2 = [pos[i + 6] - pos[i], pos[i + 7] - pos[i + 1], pos[i + 8] - pos[i + 2]];
|
|
313
424
|
const cx = e1[1] * e2[2] - e1[2] * e2[1];
|
|
@@ -372,35 +483,67 @@ export function compileScene(scene, options = {}) {
|
|
|
372
483
|
let dynVertexOffset = 0;
|
|
373
484
|
const tmpGeoms = []; // to dispose after merge
|
|
374
485
|
|
|
486
|
+
const registerMaterial = (m) => {
|
|
487
|
+
let i = materials.indexOf(m);
|
|
488
|
+
if (i < 0) { i = materials.length; materials.push(m); }
|
|
489
|
+
return i;
|
|
490
|
+
};
|
|
491
|
+
|
|
375
492
|
scene.traverse((obj) => {
|
|
376
493
|
if (!obj.isMesh || !obj.geometry || !obj.visible || obj.userData.rtExclude) return;
|
|
377
|
-
const
|
|
494
|
+
const isArray = Array.isArray(obj.material);
|
|
495
|
+
const rep = isArray ? obj.material[0] : obj.material;
|
|
378
496
|
// Transparent surfaces must not act as opaque occluders — e.g.
|
|
379
497
|
// LittlestTokyo's glass display case (texture-alpha, opacity 1) would put
|
|
380
498
|
// the whole model in shadow. Alpha-textured glass can't be cheaply tested,
|
|
381
499
|
// so ANY transparent material is skipped like rtExclude (still
|
|
382
500
|
// rasterized). alphaTest cut-outs (transparent: false) stay occluders.
|
|
383
|
-
if (
|
|
384
|
-
let mi = materials.indexOf(mat);
|
|
385
|
-
if (mi < 0) { mi = materials.length; materials.push(mat); }
|
|
501
|
+
if (rep.transparent) return;
|
|
386
502
|
|
|
387
|
-
const
|
|
503
|
+
const isDynamic = dynamicSet && dynamicSet.has(obj);
|
|
504
|
+
// Opt-in CPU deformation: the mesh must be BOTH in dynamicMeshes AND carry
|
|
505
|
+
// userData.rtDeforming, and its live geometry is read every frame.
|
|
506
|
+
const deforming = isDynamic && obj.userData.rtDeforming === true;
|
|
507
|
+
const hasGroups = isArray && obj.geometry.groups && obj.geometry.groups.length > 0;
|
|
508
|
+
// Multi-material groups are supported on static and rigid-dynamic meshes; the
|
|
509
|
+
// deforming rebake path assumes a single contiguous material range per merged
|
|
510
|
+
// segment, so reject the combination clearly rather than mis-shade it.
|
|
511
|
+
if (hasGroups && deforming) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
"three-realtime-rt: multi-material groups on a CPU-deforming (rtDeforming) " +
|
|
514
|
+
"mesh are not supported — the per-frame live-geometry rebake assumes one " +
|
|
515
|
+
"material range. Use groups on a static or rigid-dynamic mesh, or split the " +
|
|
516
|
+
"deforming mesh into one mesh per material."
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const extracted = extractMeshGeometry(obj);
|
|
388
521
|
tmpGeoms.push(extracted.geo);
|
|
522
|
+
// Map groups → per-vertex material indices (registers each group material in
|
|
523
|
+
// the shared table) and get the ranges for per-group emissive collection.
|
|
524
|
+
const { matIdx, ranges } = resolveMeshMaterials(obj, extracted.count, registerMaterial);
|
|
525
|
+
extracted.geo.setAttribute("materialIndex", new THREE.BufferAttribute(matIdx, 1));
|
|
389
526
|
// Static emissive meshes become NEE area lights (sampled directly with
|
|
390
527
|
// shadow rays instead of waiting for a GI ray to stumble into them).
|
|
391
528
|
// Dynamic emitters are left out — their world-space triangles would go
|
|
392
|
-
// stale — so they keep lighting the old way, via GI-ray hits.
|
|
393
|
-
|
|
529
|
+
// stale — so they keep lighting the old way, via GI-ray hits. Each emissive
|
|
530
|
+
// GROUP contributes its own range.
|
|
394
531
|
if (!isDynamic) {
|
|
395
|
-
const
|
|
396
|
-
|
|
532
|
+
for (const r of ranges) {
|
|
533
|
+
const emit = emissiveColor(r.material);
|
|
534
|
+
if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
|
|
535
|
+
}
|
|
397
536
|
}
|
|
398
537
|
if (isDynamic) {
|
|
399
538
|
dynamicGeoms.push(extracted.geo);
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
539
|
+
// A SkinnedMesh is auto-detected (no userData flag): it is CPU-skinned from
|
|
540
|
+
// its live skeleton pose every frame. Opt-in CPU deformation (water/cloth)
|
|
541
|
+
// instead requires userData.rtDeforming and reads live geometry. The two are
|
|
542
|
+
// mutually exclusive; skinning wins if a mesh is somehow both.
|
|
543
|
+
const skinned = obj.isSkinnedMesh === true;
|
|
544
|
+
const deforming = !skinned && obj.userData.rtDeforming === true;
|
|
403
545
|
if (deforming) compiled.hasDeforming = true;
|
|
546
|
+
if (skinned) compiled.hasSkinned = true;
|
|
404
547
|
compiled.dynamic.push({
|
|
405
548
|
mesh: obj,
|
|
406
549
|
start: dynVertexOffset,
|
|
@@ -408,9 +551,15 @@ export function compileScene(scene, options = {}) {
|
|
|
408
551
|
localPos: extracted.localPos,
|
|
409
552
|
localNorm: extracted.localNorm,
|
|
410
553
|
deforming,
|
|
554
|
+
skinned,
|
|
411
555
|
liveGeometry: deforming ? obj.geometry : null,
|
|
412
|
-
|
|
413
|
-
|
|
556
|
+
// Skinned and deforming segments both expand live/source vertices back
|
|
557
|
+
// into the merged de-indexed layout through this mapping.
|
|
558
|
+
indexMap: deforming || skinned ? extracted.indexMap : null,
|
|
559
|
+
srcVertexCount: deforming || skinned ? extracted.srcVertexCount : 0,
|
|
560
|
+
// Cache of per-source-vertex skinned LOCAL positions (skinned segs only),
|
|
561
|
+
// filled each frame so shared triangle-soup slots reuse one skin solve.
|
|
562
|
+
skinnedLocal: skinned ? new Float32Array(extracted.srcVertexCount * 3) : null,
|
|
414
563
|
});
|
|
415
564
|
dynVertexOffset += extracted.count;
|
|
416
565
|
} else {
|
package/src/bvhAnyHit.glsl.js
CHANGED
|
@@ -35,6 +35,17 @@
|
|
|
35
35
|
|
|
36
36
|
export const BVH_ANY_HIT_GLSL = /* glsl */ `
|
|
37
37
|
|
|
38
|
+
// Traversal-cost instrumentation. Counts how many BVH nodes the current pixel's
|
|
39
|
+
// shadow rays visit this frame — the raw signal behind the "bvh cost" heatmap
|
|
40
|
+
// debug view (outputMode 7). RTLightingPass main() zeroes it at the top of the
|
|
41
|
+
// pixel and reads it after all shadow rays have run; it accumulates across every
|
|
42
|
+
// bvhIntersectAnyHit call (both BVH levels, every light / GI / reflection ray).
|
|
43
|
+
// When uCostView is off the count is written nowhere, so shading is unaffected —
|
|
44
|
+
// the only cost is one integer add per popped node. Initialised to 0 so the
|
|
45
|
+
// VolumetricPass program (which shares this GLSL but never reads the counter)
|
|
46
|
+
// still compiles and runs unchanged.
|
|
47
|
+
int gBvhVisits = 0;
|
|
48
|
+
|
|
38
49
|
// Returns true if ANY triangle in the BVH is hit by the ray within (0, maxDist).
|
|
39
50
|
// Unordered traversal with early-out; no closest-hit bookkeeping.
|
|
40
51
|
bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDist ) {
|
|
@@ -54,6 +65,10 @@ bool bvhIntersectAnyHit( BVH bvh, vec3 rayOrigin, vec3 rayDirection, float maxDi
|
|
|
54
65
|
uint currNodeIndex = stack[ ptr ];
|
|
55
66
|
ptr --;
|
|
56
67
|
|
|
68
|
+
// One node visited (popped + tested). Counts pruned nodes too — that IS
|
|
69
|
+
// the traversal cost the heatmap visualises.
|
|
70
|
+
gBvhVisits ++;
|
|
71
|
+
|
|
57
72
|
// prune: skip nodes the ray misses or whose entry distance is already past maxDist
|
|
58
73
|
float boundsHitDistance;
|
|
59
74
|
if (
|