three-realtime-rt 0.6.0 → 0.7.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.
@@ -9,6 +9,7 @@ import { VolumetricPass } from "./VolumetricPass.js";
9
9
  import { RestirPass } from "./RestirPass.js";
10
10
  import { GIReservoirPass } from "./GIReservoirPass.js";
11
11
  import { CopyPass } from "./CopyPass.js";
12
+ import { makeMRT } from "./mrtCompat.js";
12
13
 
13
14
  // Van der Corput / Halton radical inverse — deterministic low-discrepancy
14
15
  // sub-pixel offsets for temporal jitter.
@@ -256,7 +257,7 @@ export class RealtimeRaytracer {
256
257
  let mrt, out, mat, copy, quad, scene2, cam;
257
258
  const prevTarget = renderer.getRenderTarget();
258
259
  try {
259
- mrt = new THREE.WebGLMultipleRenderTargets(2, 2, 2, {
260
+ mrt = makeMRT(2, 2, 2, {
260
261
  format: THREE.RGBAFormat,
261
262
  type: THREE.HalfFloatType,
262
263
  depthBuffer: false,
@@ -337,6 +338,30 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
337
338
  // frames of confidence". See RTLightingPass.resizeCarry.
338
339
  static HISTORY_CARRY_FRAMES = 8;
339
340
 
341
+ // Compile-failure diagnosis polling window (see _scanPrograms). three checks
342
+ // link status lazily at a program's first USE, and under
343
+ // KHR_parallel_shader_compile it may hold a mesh back for a few frames until
344
+ // its program is ready — so a single frame-1 scan can miss a failure. Poll
345
+ // from frame 1 up to DIAG_WINDOW_FRAMES, and early-out once the set of rt:*
346
+ // programs has been stable (no new programs, no unhandled failures) for
347
+ // DIAG_STABLE_FRAMES past a DIAG_MIN_FRAMES warmup floor.
348
+ static DIAG_MIN_FRAMES = 8;
349
+ static DIAG_STABLE_FRAMES = 4;
350
+ static DIAG_WINDOW_FRAMES = 45;
351
+
352
+ // Staleness scan (see _checkStale): a static mesh edited after compileScene()
353
+ // keeps tracing its compile-time shape/place. Checked every Nth frame only —
354
+ // the comparison is an int and 16 floats per static source, and each source
355
+ // stops being checked as soon as it has reported — and capped so a scene that
356
+ // moves everything can never flood the console.
357
+ static STALE_CHECK_FRAMES = 30;
358
+ static MAX_STALE_WARNINGS = 8;
359
+
360
+ // Largest renderScale change the adaptive governor may commit in one step
361
+ // (0.25 = five 0.05 ladder steps). Bounds the reaction to a single very slow
362
+ // frame now that 100ms-2s frames feed the EMA; see _adaptQuality.
363
+ static MAX_SCALE_STEP = 0.25;
364
+
340
365
  constructor(renderer, options = {}) {
341
366
  this.renderer = renderer;
342
367
 
@@ -354,6 +379,12 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
354
379
  );
355
380
  this.compiled = null;
356
381
  this.frame = 0;
382
+ // Status surface (see the supported path for the shapes). Unsupported =
383
+ // the RT pipeline is not operational at all; `supported` is the primary
384
+ // signal, but status is kept consistent so integrators can read one field.
385
+ this.compileError = null;
386
+ this.status = { ok: false, disabled: [], coreFailure: null, warnings: [] };
387
+ this._diagDone = true;
357
388
  return;
358
389
  }
359
390
 
@@ -404,6 +435,18 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
404
435
  "(WebKit/iOS) — specular buffer disabled, alpha-blend surfaces render opaque."
405
436
  );
406
437
  }
438
+ // Fragment-shader texture-unit budget. The lighting megakernel already binds
439
+ // exactly the WebGL2-guaranteed minimum of 16 fragment samplers, so the
440
+ // world-space 3D-texture-albedo feature's SECONDARY-ray path (an extra
441
+ // sampler3D) can only be compiled in on a GPU that exposes >= 17. Primary
442
+ // visibility (the G-buffer, which has ample headroom) samples the volume
443
+ // regardless; only the traced GI/reflection bounce is gated on this. Most
444
+ // desktop GPUs report 32.
445
+ this._maxFragTexUnits = renderer.getContext().getParameter(
446
+ renderer.getContext().MAX_TEXTURE_IMAGE_UNITS
447
+ );
448
+ this._volumeUnitWarned = false;
449
+
407
450
  this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
408
451
  this.rtPass = new RTLightingPass(this._scaledW, this._scaledH, {
409
452
  specMRT: this.specMRTSupported,
@@ -690,6 +733,161 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
690
733
  );
691
734
  this._renderScale = 0.375;
692
735
  }
736
+
737
+ // ---- compile-failure status surface -----------------------------------
738
+ // The pipeline is a stack of ShaderMaterial passes; a program that fails to
739
+ // LINK renders black with no exception (three logs to the console and sets
740
+ // program.diagnostics.runnable=false, but rendering proceeds). Before this,
741
+ // a broken pass looked identical to `supported:false` from the outside — the
742
+ // failure that shipped the r166+ black image. These two fields let an
743
+ // integrator render honestly ("raster (reason)") instead of guessing:
744
+ //
745
+ // compileError : string | null
746
+ // First/most-severe failure summary ("rt:lighting: <driver log>"), or
747
+ // null while every rt:* pass is compiling clean.
748
+ // status : { ok, disabled, coreFailure }
749
+ // ok false once ANY rt:* pass failed to link (a core pass, or
750
+ // a feature that was auto-disabled). true = pipeline is
751
+ // running as intended.
752
+ // disabled [{ pass, feature, reason }] — optional features turned
753
+ // off to keep the image lit (e.g. taa, denoise, restir).
754
+ // coreFailure string | null — a core pass (gbuffer/lighting/composite)
755
+ // failed and has no fallback; the image is black-but-diagnosed.
756
+ // warnings [{ code, message }] — USAGE diagnostics (a flag that is
757
+ // being ignored, an object type that cannot be traced, a
758
+ // static mesh edited after compileScene()). Each is also
759
+ // console.warn'd once. These never affect `ok`: the
760
+ // pipeline is healthy, the SCENE SETUP is not what the app
761
+ // probably intended.
762
+ this.compileError = null;
763
+ this.status = { ok: true, disabled: [], coreFailure: null, warnings: [] };
764
+ this._diagDone = false; // set once the polling window settles
765
+ this._diagFrames = 0; // rendered frames scanned so far
766
+ this._diagStable = 0; // consecutive scans with an unchanged rt:* program set
767
+ this._diagSig = ""; // signature of the rt:* program-name set last scan
768
+ this._diagHandled = new Set(); // rt:* names already acted on (warn-once)
769
+ this._compileErrSev = -1; // severity behind the current compileError (2/1/0)
770
+
771
+ // Usage-diagnostic state (see _checkStale / _warn).
772
+ this._staleDone = false; // nothing left worth scanning
773
+ this._staleWarnings = 0; // stale reports emitted (capped)
774
+ this._implicitCompileWarned = false;
775
+ }
776
+
777
+ // Classify an rt:* pass program by how a LINK failure degrades. CORE passes
778
+ // have no fallback (record and keep rendering the black result so it is
779
+ // DIAGNOSED, not silent). Optional passes map to the EXACT runtime toggle that
780
+ // already gates them, so disabling one keeps the image lit. Unknown rt:* names
781
+ // (history-carry blits) are auxiliary: non-fatal, warn only. Returns one of
782
+ // { core:true } | { feature, disable } | { aux:true }.
783
+ _passClass(name) {
784
+ switch (name) {
785
+ case "rt:gbuffer":
786
+ case "rt:lighting":
787
+ case "rt:composite":
788
+ return { core: true };
789
+ case "rt:restir-temporal":
790
+ case "rt:restir-spatial":
791
+ return { feature: "restir", disable: () => { this.restir = false; } };
792
+ case "rt:gi-reservoir":
793
+ return { feature: "restirGI", disable: () => { this.restirGI = false; } };
794
+ case "rt:denoise":
795
+ return { feature: "denoise", disable: () => { this.denoise = false; } };
796
+ case "rt:volumetric":
797
+ return { feature: "volumetric", disable: () => { this.volumetric.enabled = false; } };
798
+ case "rt:taa":
799
+ case "rt:taa-copy":
800
+ return { feature: "taa", disable: () => { this.taa = false; } };
801
+ case "rt:specular":
802
+ return { feature: "specular", disable: () => { this.specular = false; } };
803
+ default:
804
+ return { aux: true };
805
+ }
806
+ }
807
+
808
+ // Compact one-line driver message from three's program.diagnostics. The
809
+ // GLSL-frontend error lives in the fragment (or vertex) shader log; programLog
810
+ // is the linker fallback. First line, capped, so it fits a console.warn / a UI.
811
+ _diagLog(diag) {
812
+ const pick = [
813
+ diag && diag.fragmentShader && diag.fragmentShader.log,
814
+ diag && diag.vertexShader && diag.vertexShader.log,
815
+ diag && diag.programLog,
816
+ ].find((s) => s && s.trim());
817
+ return (pick || "(no driver log)").trim().split("\n")[0].slice(0, 200);
818
+ }
819
+
820
+ // Keep compileError at the FIRST failure of the HIGHEST severity seen
821
+ // (core 2 > feature 1 > aux 0): a later core failure overrides an earlier
822
+ // feature summary, but two failures of equal severity keep the first.
823
+ _noteCompileError(summary, severity) {
824
+ if (severity > this._compileErrSev) {
825
+ this.compileError = summary;
826
+ this._compileErrSev = severity;
827
+ }
828
+ }
829
+
830
+ // Act on one failed rt:* program (called at most once per pass name).
831
+ _handleFailedProgram(name, diag) {
832
+ const log = this._diagLog(diag);
833
+ const cls = this._passClass(name);
834
+ const summary = `${name}: ${log}`;
835
+ this.status.ok = false;
836
+ if (cls.core) {
837
+ if (!this.status.coreFailure) this.status.coreFailure = summary;
838
+ this._noteCompileError(summary, 2);
839
+ console.warn(
840
+ `three-realtime-rt: core pass ${name} failed to link — the image will ` +
841
+ `be black (no fallback for a core pass). Driver log: ${log}`
842
+ );
843
+ } else if (cls.feature) {
844
+ cls.disable();
845
+ this.status.disabled.push({ pass: name, feature: cls.feature, reason: log });
846
+ this._noteCompileError(summary, 1);
847
+ console.warn(
848
+ `three-realtime-rt: pass ${name} failed to link — auto-disabled ` +
849
+ `"${cls.feature}" to keep the image lit. Driver log: ${log}`
850
+ );
851
+ } else {
852
+ this._noteCompileError(summary, 0);
853
+ console.warn(
854
+ `three-realtime-rt: auxiliary pass ${name} failed to link (non-fatal — ` +
855
+ `resize history is not carried). Driver log: ${log}`
856
+ );
857
+ }
858
+ }
859
+
860
+ // Scan renderer.info.programs for failed rt:* pass programs. Called each frame
861
+ // until the window settles (see the DIAG_* constants). Cheap: ~a dozen entries,
862
+ // string prefix check, warn-once via _diagHandled.
863
+ _scanPrograms() {
864
+ if (this._diagDone) return;
865
+ const programs = this.renderer.info && this.renderer.info.programs;
866
+ if (!programs) { this._diagDone = true; return; }
867
+ this._diagFrames++;
868
+ let names = "";
869
+ for (const p of programs) {
870
+ const name = p && p.name;
871
+ if (!name || name.slice(0, 3) !== "rt:") continue;
872
+ names += name + "|";
873
+ const diag = p.diagnostics; // set at first USE; runnable:false = link failed
874
+ if (diag && diag.runnable === false && !this._diagHandled.has(name)) {
875
+ this._diagHandled.add(name);
876
+ this._handleFailedProgram(name, diag);
877
+ }
878
+ }
879
+ // Early-out: the rt:* program set has stopped growing and nothing new failed
880
+ // for DIAG_STABLE_FRAMES past the warmup floor → every active pass has been
881
+ // seen and validated. Otherwise stop at the hard window.
882
+ if (names === this._diagSig) this._diagStable++;
883
+ else { this._diagStable = 0; this._diagSig = names; }
884
+ if (
885
+ (this._diagFrames >= RealtimeRaytracer.DIAG_MIN_FRAMES &&
886
+ this._diagStable >= RealtimeRaytracer.DIAG_STABLE_FRAMES) ||
887
+ this._diagFrames >= RealtimeRaytracer.DIAG_WINDOW_FRAMES
888
+ ) {
889
+ this._diagDone = true;
890
+ }
693
891
  }
694
892
 
695
893
  // Consecutive catastrophic frames mean the GPU is drowning — cut quality
@@ -728,6 +926,102 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
728
926
  }
729
927
  }
730
928
 
929
+ // ---- usage diagnostics (status.warnings) ---------------------------------
930
+ // A usage warning is console.warn'd ONCE and recorded on status.warnings so a
931
+ // UI (or an automated check) can read the same signal. `ok` is untouched: the
932
+ // pipeline is fine, the scene setup is what looks wrong. Duplicate
933
+ // code+message pairs are collapsed so a recompile can't grow the array.
934
+ _warn(code, message) {
935
+ console.warn(message);
936
+ this._recordWarning(code, message);
937
+ }
938
+
939
+ _recordWarning(code, message) {
940
+ const list = this.status && this.status.warnings;
941
+ if (!list) return;
942
+ for (let i = 0; i < list.length; i++) {
943
+ if (list[i].code === code && list[i].message === message) return;
944
+ }
945
+ list.push({ code, message });
946
+ }
947
+
948
+ // Mirror the compiler's usage diagnostics onto the status surface. The
949
+ // compiler already wrote them to the console (once per offending object);
950
+ // this only makes them readable.
951
+ _absorbCompilerWarnings(compiled) {
952
+ const w = compiled && compiled.warnings;
953
+ if (!w || w.length === 0) return;
954
+ for (let i = 0; i < w.length; i++) this._recordWarning(w[i].code, w[i].message);
955
+ }
956
+
957
+ // Detect a STATIC mesh that was edited after compileScene(): its vertices were
958
+ // deformed, or it was moved. Either way the traced lighting still uses the
959
+ // shape/place baked into the static BVH, which reads as "the shadow doesn't
960
+ // move" or "rays still hit the original shape" — the single most common
961
+ // integration mistake, and completely silent before this.
962
+ //
963
+ // Cost: runs on every STALE_CHECK_FRAMES-th frame only, over a plain array of
964
+ // fingerprints, with no allocation; each source is checked until it warns once
965
+ // (or its mesh is collected), and the whole scan switches off when nothing is
966
+ // left to report or the warning cap is reached.
967
+ _checkStale() {
968
+ if (this._staleDone) return;
969
+ const srcs = this.compiled && this.compiled.staticSources;
970
+ if (!srcs || srcs.length === 0) { this._staleDone = true; return; }
971
+ let live = 0;
972
+ for (let i = 0; i < srcs.length; i++) {
973
+ const s = srcs[i];
974
+ if (s.warned) continue;
975
+ const mesh = s.ref.deref();
976
+ if (!mesh) { s.warned = true; continue; } // collected — nothing to report
977
+ let stale = null;
978
+ const geo = mesh.geometry;
979
+ const pos = geo ? geo.getAttribute("position") : null;
980
+ if (!pos || pos.version !== s.version) {
981
+ stale = "geometry";
982
+ } else {
983
+ // Relative tolerance rather than an exact compare: an app that rebuilds
984
+ // an unchanged transform each frame (setFromEuler, a physics engine
985
+ // writing back the same pose) can land a few ULPs away, and a sub-micron
986
+ // "move" is not what this diagnostic is about.
987
+ const e = mesh.matrixWorld.elements;
988
+ const m = s.matrix;
989
+ for (let k = 0; k < 16; k++) {
990
+ const d = e[k] - m[k];
991
+ if ((d < 0 ? -d : d) > 1e-6 * (1 + (m[k] < 0 ? -m[k] : m[k]))) {
992
+ stale = "transform";
993
+ break;
994
+ }
995
+ }
996
+ }
997
+ if (!stale) { live++; continue; }
998
+ s.warned = true;
999
+ this._staleWarnings++;
1000
+ if (stale === "geometry") {
1001
+ this._warn(
1002
+ "stale-geometry",
1003
+ `three-realtime-rt: position buffer of ${s.name} changed after compileScene() ` +
1004
+ `but it is not a dynamic mesh — traced lighting still uses the ORIGINAL shape. ` +
1005
+ `Add it to compileScene(scene, {dynamicMeshes:[...]}) and set ` +
1006
+ `mesh.userData.rtDeforming = true, then call updateDynamic() each frame.`
1007
+ );
1008
+ } else {
1009
+ this._warn(
1010
+ "stale-transform",
1011
+ `three-realtime-rt: ${s.name} was moved after compileScene() but it is not a ` +
1012
+ `dynamic mesh — traced lighting still uses the ORIGINAL transform (its shadow ` +
1013
+ `stays behind). Recompile with compileScene(scene), or declare it in ` +
1014
+ `compileScene(scene, {dynamicMeshes:[...]}) and call updateDynamic() each frame.`
1015
+ );
1016
+ }
1017
+ if (this._staleWarnings >= RealtimeRaytracer.MAX_STALE_WARNINGS) {
1018
+ this._staleDone = true; // enough — do not flood the console
1019
+ return;
1020
+ }
1021
+ }
1022
+ if (live === 0) this._staleDone = true;
1023
+ }
1024
+
731
1025
  _makeColorTarget(width, height) {
732
1026
  const t = new THREE.WebGLRenderTarget(width, height, {
733
1027
  minFilter: THREE.LinearFilter,
@@ -748,8 +1042,36 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
748
1042
  */
749
1043
  compileScene(scene, options) {
750
1044
  if (!this.supported) return null;
1045
+ // "Construct the tracer, then add meshes" is a natural call order, so a
1046
+ // scene with no traceable meshes is a NO-OP, not a throw: warn once and keep
1047
+ // any previously compiled scene. Compile the new scene BEFORE disposing the
1048
+ // old one so an empty-scene call never destroys a good scene. Only the
1049
+ // SceneCompiler's specific "no meshes" signal is swallowed here; every other
1050
+ // compile error (bad geometry, oversized attribute) still propagates.
1051
+ let compiled;
1052
+ try {
1053
+ compiled = compileScene(scene, options);
1054
+ } catch (err) {
1055
+ if (/no meshes found/.test(String(err && err.message))) {
1056
+ if (!this._emptyWarned) {
1057
+ console.warn(
1058
+ "three-realtime-rt: compileScene() called on a scene with no traceable " +
1059
+ "meshes — keeping the current scene. Until meshes are added and " +
1060
+ "recompiled, render() falls back to plain rasterization (no crash, no black)."
1061
+ );
1062
+ this._emptyWarned = true;
1063
+ }
1064
+ return this.compiled; // unchanged (may still be null)
1065
+ }
1066
+ throw err;
1067
+ }
751
1068
  if (this.compiled) this.compiled.dispose();
752
- this.compiled = compileScene(scene, options);
1069
+ this.compiled = compiled;
1070
+ // Usage diagnostics raised while compiling (already console.warn'd once per
1071
+ // offending object) become readable on status.warnings. A fresh compile also
1072
+ // re-arms the staleness scan against the NEW fingerprints.
1073
+ this._absorbCompilerWarnings(compiled);
1074
+ this._staleDone = false;
753
1075
  // Emissive area lights are the noisiest direct-light path: one triangle
754
1076
  // sample per pixel per frame, and the 1/dist^2 term spikes near a small
755
1077
  // emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
@@ -769,10 +1091,40 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
769
1091
  this.volumetricPass.setCompiledScene(this.compiled);
770
1092
  this.restirPass.setCompiledScene(this.compiled);
771
1093
  this.giReservoirPass.setCompiledScene(this.compiled);
1094
+ this._syncVolumeAlbedo();
772
1095
  this.resetAccumulation();
773
1096
  return this.compiled;
774
1097
  }
775
1098
 
1099
+ /**
1100
+ * Push the compiled scene's world-space 3D-texture albedo (if any) into the
1101
+ * passes. Primary visibility (G-buffer) always gets it — it has spare samplers.
1102
+ * The traced SECONDARY-ray path (GI/reflection colour) needs a 17th fragment
1103
+ * sampler, so it is only enabled on a GPU that exposes one; on a bare-minimum
1104
+ * 16-unit device the bounces fall back to the material's flat table albedo
1105
+ * (primary visibility still shows the full field), logged once.
1106
+ */
1107
+ _syncVolumeAlbedo() {
1108
+ const vol = this.compiled ? this.compiled.volumeAlbedo : null;
1109
+ this.gbuffer.setVolume(!!vol);
1110
+ const secondaryOk = !!vol && this._maxFragTexUnits >= 17;
1111
+ // NOTE: only the inline-GI lighting pass (rtPass.traceRadiance) samples the
1112
+ // volume for the traced bounce. The experimental ReSTIR GI pass
1113
+ // (giReservoirPass, off by default) has its own GI kernel and is NOT wired
1114
+ // for volume albedo in v1 — a volume surface's indirect bounce falls back to
1115
+ // its flat table colour while restirGI is on (documented limitation).
1116
+ this.rtPass.setVolumeAlbedo(secondaryOk ? vol : null);
1117
+ if (vol && !secondaryOk && !this._volumeUnitWarned) {
1118
+ this._volumeUnitWarned = true;
1119
+ console.info(
1120
+ "[three-realtime-rt] volume albedo: this GPU exposes only " +
1121
+ `${this._maxFragTexUnits} fragment texture units (< 17 needed for the ` +
1122
+ "traced-bounce sampler), so GI / reflection bounces use the material's flat " +
1123
+ "base colour. Primary visibility still shows the full 3D-texture field."
1124
+ );
1125
+ }
1126
+ }
1127
+
776
1128
  /**
777
1129
  * Re-bake moving (dynamic) meshes into the dynamic BVH level. Call each frame
778
1130
  * after moving them. Only the dynamic level is touched — the static BVH was
@@ -928,10 +1280,24 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
928
1280
  // cap the frame time can't reveal headroom, so upscaling only happens when
929
1281
  // frames are measurably faster than the target — it never thrashes.
930
1282
  _adaptQuality() {
1283
+ // Hidden tabs are exempt: browser throttling makes every frame look
1284
+ // catastrophic, and adapting on that would drop quality for a tab nobody is
1285
+ // watching (same rule as _overloadBrake).
1286
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1287
+ this._qLastT = null;
1288
+ return;
1289
+ }
931
1290
  const now = performance.now();
932
1291
  const dt = this._qLastT == null ? null : now - this._qLastT;
933
1292
  this._qLastT = now;
934
- if (dt == null || dt > 100) return; // first frame or hidden-tab stall
1293
+ if (dt == null) return; // first frame no interval yet
1294
+ // Only a genuine stall/resume (a blocked main thread, a tab coming back, a
1295
+ // debugger pause) is discarded. The old guard bailed above 100ms, which left
1296
+ // every device running slower than 10fps unable to adapt AT ALL: the
1297
+ // governor never saw a sample, and _overloadBrake only reacts past 400ms
1298
+ // with three consecutive strikes. Frames from 100ms to 2s now feed the EMA,
1299
+ // so a 3fps device walks down the ladder like any other.
1300
+ if (dt > 2000) return;
935
1301
  this._qEma = this._qEma == null ? dt : this._qEma * 0.9 + dt * 0.1;
936
1302
  // Calmness: normally 2s between changes. When the last two steps reversed
937
1303
  // direction the governor is hunting the boundary, so hold for 5s AND widen
@@ -946,6 +1312,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
946
1312
  if (ratio < dbHi && ratio > dbLo) return; // comfortable — leave it alone
947
1313
 
948
1314
  let s = this._renderScale * Math.pow(1 / ratio, 0.35);
1315
+ // Per-step clamp. Now that multi-hundred-millisecond frames feed the EMA, a
1316
+ // single very slow measurement (ratio can reach ~100 at dt 2s) would
1317
+ // otherwise slam the scale from 1.0 to the 0.2 floor in ONE step and throw
1318
+ // away the image on a transient. Move at most MAX_SCALE_STEP per adaptation
1319
+ // (5 ladder steps) and let the cooldown take the next one if it is still slow.
1320
+ const step = RealtimeRaytracer.MAX_SCALE_STEP;
1321
+ s = Math.min(this._renderScale + step, Math.max(this._renderScale - step, s));
949
1322
  s = Math.round(Math.min(1, Math.max(0.2, s)) * 20) / 20; // 0.05 steps
950
1323
 
951
1324
  // When we're fast, give back the deepest lever FIRST: restore canvas scale
@@ -1018,9 +1391,34 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1018
1391
  }
1019
1392
  if (this.adaptiveQuality) this._adaptQuality();
1020
1393
  if (this.overloadProtection) this._overloadBrake();
1021
- if (!this.compiled) this.compileScene(scene);
1394
+ if (!this.compiled) {
1395
+ this.compileScene(scene);
1396
+ // An implicit compile is the zero-config path: it works, but it takes NO
1397
+ // options, so every mesh is static forever. Anything that moves needs the
1398
+ // explicit call — say so once, and only once the compile actually produced
1399
+ // a scene (an empty scene has its own warning).
1400
+ if (this.compiled && !this._implicitCompileWarned) {
1401
+ this._implicitCompileWarned = true;
1402
+ this._warn(
1403
+ "implicit-compile",
1404
+ "three-realtime-rt: render() compiled the scene implicitly (no compileScene() " +
1405
+ "call), so it was compiled with NO options — every mesh is static and " +
1406
+ "updateDynamic() has nothing to update. Call compileScene(scene, options) " +
1407
+ "yourself (e.g. {dynamicMeshes:[...]}) before the first render() if anything moves."
1408
+ );
1409
+ }
1410
+ }
1411
+ // Still nothing to trace (empty scene — tracer built before meshes were
1412
+ // added). Show the user's raster scene rather than crashing or rendering
1413
+ // black; the pipeline picks up automatically once compileScene() succeeds.
1414
+ if (!this.compiled) {
1415
+ this.renderer.render(scene, camera);
1416
+ return;
1417
+ }
1022
1418
 
1023
1419
  this.frame += 1;
1420
+ // Cheap periodic check that no STATIC mesh was edited behind the BVH's back.
1421
+ if (this.frame % RealtimeRaytracer.STALE_CHECK_FRAMES === 0) this._checkStale();
1024
1422
  camera.updateMatrixWorld();
1025
1423
 
1026
1424
  // --- sub-pixel jitter (TAA): offset the projection a fraction of a pixel
@@ -1303,6 +1701,12 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1303
1701
  // Record this frame's (jittered) view-projection + jitter for next frame.
1304
1702
  this._prevViewProj.copy(this._jitteredViewProj);
1305
1703
  this._prevJitterUv.copy(this._jitterUv);
1704
+
1705
+ // Compile-failure diagnosis: every pass program used this frame has now had
1706
+ // its link status checked by three (diagnostics populated on first use), so
1707
+ // scan for failures. Runs at frame END (downstream of the passes) and only
1708
+ // until the polling window settles — a no-op on the healthy steady state.
1709
+ if (!this._diagDone) this._scanPrograms();
1306
1710
  }
1307
1711
 
1308
1712
  dispose() {
package/src/RestirPass.js CHANGED
@@ -52,7 +52,10 @@ vec4 fetchBlueNoise() {
52
52
  return fract(bn + shift);
53
53
  }
54
54
 
55
- float luminance(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
55
+ // Named rtLum, NOT luminance: three r166+ prepends its own luminance(vec3)
56
+ // to every non-raw ShaderMaterial fragment shader, and GLSL treats a second
57
+ // (vec3) body as a redefinition — the whole program fails to compile.
58
+ float rtLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
56
59
 
57
60
  // Primary-surface roughness, set per pixel in main(). Drives the cheap specular
58
61
  // lobe below so reservoirs favour lights that land on a highlight.
@@ -121,7 +124,7 @@ vec3 candidateContribution(float id, vec2 uv, vec3 P, vec3 N) {
121
124
  // (Known approximation: a triangle whose centroid contributes zero but whose
122
125
  // far corner doesn't can be under-selected at grazing setups.)
123
126
  float phatOf(float id, vec3 P, vec3 N) {
124
- return luminance(candidateContribution(id, vec2(1.0 / 3.0), P, N));
127
+ return rtLum(candidateContribution(id, vec2(1.0 / 3.0), P, N));
125
128
  }
126
129
  `;
127
130
 
@@ -297,8 +300,12 @@ export class RestirPass {
297
300
  this.targetB = this._makeTarget(width, height);
298
301
  this.spatialTarget = this._makeTarget(width, height);
299
302
 
300
- const mkMaterial = (frag) =>
303
+ const mkMaterial = (frag, name) =>
301
304
  new THREE.ShaderMaterial({
305
+ // Stable program name for compile-failure self-diagnosis; a link failure
306
+ // in either reservoir stage disables `restir` (falls back to the
307
+ // non-reservoir per-light direct sampling path — image stays lit).
308
+ name,
302
309
  glslVersion: THREE.GLSL3,
303
310
  vertexShader: fullscreenVert,
304
311
  fragmentShader: frag,
@@ -329,8 +336,8 @@ export class RestirPass {
329
336
  depthWrite: false,
330
337
  });
331
338
 
332
- this.material = mkMaterial(temporalFrag);
333
- this.spatialMaterial = mkMaterial(spatialFrag);
339
+ this.material = mkMaterial(temporalFrag, "rt:restir-temporal");
340
+ this.spatialMaterial = mkMaterial(spatialFrag, "rt:restir-spatial");
334
341
 
335
342
  this.scene = new THREE.Scene();
336
343
  this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);