rayzee 7.22.2 → 7.22.4
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/dist/rayzee.es.js +1594 -1468
- package/dist/rayzee.umd.js +43 -43
- package/package.json +1 -1
- package/src/EngineDefaults.js +24 -16
- package/src/PathTracerApp.js +89 -18
- package/src/Processor/QueueManager.js +6 -1
- package/src/RenderSettings.js +13 -1
- package/src/Stages/Compositor.js +132 -2
- package/src/Stages/PathTracer.js +199 -17
- package/src/TSL/FinalWriteKernel.js +61 -34
- package/src/TSL/GenerateKernel.js +4 -4
- package/src/TSL/ShadeKernel.js +4 -1
- package/src/index.js +1 -0
- package/src/managers/UniformManager.js +4 -0
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -93,24 +93,31 @@ export const ENGINE_DEFAULTS = {
|
|
|
93
93
|
|
|
94
94
|
// Adaptive sampling (Blender-style): stop the frame once enough pixels drop below the noise threshold.
|
|
95
95
|
useAdaptiveSampling: true,
|
|
96
|
-
|
|
96
|
+
// √-luminance-normalized per-pixel noise below which a pixel counts as converged. Base tracks the
|
|
97
|
+
// interactive tier — that is what a freshly booted engine renders before configureForMode runs.
|
|
98
|
+
noiseThreshold: 0.1,
|
|
97
99
|
adaptiveMinSamples: 8, // min samples before adaptive sampling can trigger
|
|
98
|
-
|
|
100
|
+
// Fraction of pixels that must pass the 3×3-eroded convergence count before the frame retires; the
|
|
101
|
+
// geometry-only fraction has to clear the same bar (PathTracer._isConvergedComplete).
|
|
102
|
+
// ENGINE-INTERNAL: a calibration constant rather than a quality dial — it only means anything against
|
|
103
|
+
// those two counts. configureForMode supplies the per-tier value.
|
|
104
|
+
adaptiveStopFraction: 0.90,
|
|
99
105
|
// Per-pixel freeze: skip tracing pixels that individually converged (noise threshold only — no dark floor,
|
|
100
106
|
// which would bake dim regions too dark). Naturally engages only on static/idle views.
|
|
107
|
+
// Set together with useAdaptiveSampling by the single UI switch — two tiers of one feature.
|
|
101
108
|
usePixelFreeze: true,
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
// ENGINE-INTERNAL, and not the sibling of noiseThreshold it looks like: freeze bars on plain relErr where
|
|
110
|
+
// the frame test uses the √-normalized error, so the same number is 2-5× stricter here. Deriving it from
|
|
111
|
+
// noiseThreshold was measured and rejected — a pixel frozen before it satisfies the frame test can never
|
|
112
|
+
// satisfy it afterwards, so a looser bar delays the early stop rather than hastening it.
|
|
113
|
+
pixelFreezeThreshold: 0.02,
|
|
114
|
+
pixelFreezeStability: 8, // ENGINE-INTERNAL: consecutive candidate frames before a pixel freezes
|
|
115
|
+
convergenceOverlay: false, // display-only Compositor overlay; never alters the render
|
|
104
116
|
|
|
105
117
|
samplingTechnique: 2,
|
|
106
118
|
enableEmissiveTriangleSampling: false,
|
|
107
119
|
emissiveBoost: 1.0,
|
|
108
120
|
|
|
109
|
-
temporalVarianceWeight: 0.6,
|
|
110
|
-
enableEarlyTermination: true,
|
|
111
|
-
earlyTerminationThreshold: 0.002,
|
|
112
|
-
performanceModeAdaptive: 'medium',
|
|
113
|
-
|
|
114
121
|
fireflyThreshold: 3.0,
|
|
115
122
|
// Wavefront material-coherence sort: global counting-sort of entering rays by material before
|
|
116
123
|
// Shade (material-pure workgroups), under dynamic dispatch. Measured −8% at 1024²/8b. Gated on
|
|
@@ -199,9 +206,10 @@ export const MAX_RESERVABLE_RENDER_SIZE = 4096;
|
|
|
199
206
|
// Reserved render size: every per-resolution compute StorageTexture + aux buffer is pre-allocated at
|
|
200
207
|
// this SQUARE size and never resized at runtime (works around three.js StorageTexture-resize bugs —
|
|
201
208
|
// see TSL/patches history). Render resolution must not exceed it; the engine warns + ignores larger.
|
|
202
|
-
// It is a LIVE binding (mutable) so
|
|
203
|
-
//
|
|
204
|
-
// (
|
|
209
|
+
// It is a LIVE binding (mutable) so it can be raised (e.g. for 4K) via setReservedRenderSize(); consumers
|
|
210
|
+
// read it inside their constructors, and stages already built at a lower value are re-initialised in place
|
|
211
|
+
// by PathTracerApp.setReservedRenderResolution() — which is the device-gated API hosts should call, at any
|
|
212
|
+
// point in the lifecycle. Default 2048 (zero VRAM regression). See docs/internal/specs/wavefront-chunked-pool.md.
|
|
205
213
|
export let MAX_STORAGE_TEXTURE_SIZE = 2048;
|
|
206
214
|
|
|
207
215
|
// Set the reserved render size. MUST be called before pipeline construction (stages pre-allocate at
|
|
@@ -598,11 +606,10 @@ export const PRODUCTION_RENDER_CONFIG = {
|
|
|
598
606
|
renderMode: 1, enableAlphaShadows: true,
|
|
599
607
|
enableOIDN: true, oidnQuality: 'balance',
|
|
600
608
|
interactionModeEnabled: false,
|
|
601
|
-
//
|
|
602
|
-
// the unbiased residual for OIDN.
|
|
609
|
+
// 0.94 against the eroded count ≈ the old raw-count 0.98; erosion holds the fraction a few points lower.
|
|
603
610
|
useAdaptiveSampling: true,
|
|
604
|
-
noiseThreshold: 0.
|
|
605
|
-
adaptiveStopFraction: 0.
|
|
611
|
+
noiseThreshold: 0.02,
|
|
612
|
+
adaptiveStopFraction: 0.94,
|
|
606
613
|
usePixelFreeze: true,
|
|
607
614
|
};
|
|
608
615
|
|
|
@@ -614,6 +621,7 @@ export const INTERACTIVE_RENDER_CONFIG = {
|
|
|
614
621
|
enableOIDN: false, oidnQuality: 'fast',
|
|
615
622
|
interactionModeEnabled: true,
|
|
616
623
|
useAdaptiveSampling: true, // idle refine stops early when converged; frozen during motion
|
|
624
|
+
noiseThreshold: 0.1, // loose: preview wants a fast settle, not a clean one
|
|
617
625
|
usePixelFreeze: true, // speeds up idle refinement on heavy/high-res views; inert while moving (freeze resets)
|
|
618
626
|
};
|
|
619
627
|
|
package/src/PathTracerApp.js
CHANGED
|
@@ -114,6 +114,8 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
114
114
|
// ── Pipeline & stages ──
|
|
115
115
|
this.pipeline = null;
|
|
116
116
|
|
|
117
|
+
this._pendingReservedRenderSize = null;
|
|
118
|
+
|
|
117
119
|
/**
|
|
118
120
|
* Named access to all pipeline stages.
|
|
119
121
|
* Advanced consumers can reach into stages for fine-grained control.
|
|
@@ -220,6 +222,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
220
222
|
async init() {
|
|
221
223
|
|
|
222
224
|
await this._initRenderer();
|
|
225
|
+
this._applyPendingReservedRenderSize();
|
|
223
226
|
this._initCameraManager();
|
|
224
227
|
this._initScenes();
|
|
225
228
|
this._initAssetPipeline();
|
|
@@ -1233,34 +1236,53 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1233
1236
|
* is device-capped: 4K reservation (~1.5 GB of MRT textures) is only granted on GPUs with ample VRAM +
|
|
1234
1237
|
* a large storage-buffer binding limit; weaker devices clamp to 2048.
|
|
1235
1238
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1239
|
+
* Callable at any point in the lifecycle:
|
|
1240
|
+
* - before init(): recorded, then applied during init() before the stages are constructed, so they
|
|
1241
|
+
* pre-allocate at the raised size directly. The device gate cannot run until the device exists, so
|
|
1242
|
+
* the value returned here is the request, not the verdict — read getReservedRenderResolution() after
|
|
1243
|
+
* init(), or listen for `reserved_render_size_changed`.
|
|
1244
|
+
* - after init(): applied immediately, re-initialising the reserved GPU storage in place.
|
|
1238
1245
|
* @param {number} requestedPx desired reserved size (longest edge)
|
|
1239
1246
|
* @param {Object} [opts]
|
|
1240
1247
|
* @param {boolean} [opts.allowLower=false] permit lowering, paying a rebuild, to reclaim VRAM
|
|
1241
|
-
* @returns {number} the applied reserved size
|
|
1248
|
+
* @returns {number} the applied reserved size (the pending request when called before init)
|
|
1242
1249
|
*/
|
|
1243
1250
|
setReservedRenderResolution( requestedPx, { allowLower = false } = {} ) {
|
|
1244
1251
|
|
|
1245
|
-
const limits = this.renderer?.backend?.device?.limits;
|
|
1246
|
-
const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
|
|
1247
|
-
const deviceMemGB = ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4;
|
|
1248
|
-
// 4K reserve pins the accum MRT (~1.5 GB) + aux; only grant it on clearly-capable GPUs.
|
|
1249
|
-
const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
|
|
1250
|
-
? MAX_RESERVABLE_RENDER_SIZE : 2048;
|
|
1251
1252
|
const prev = MAX_STORAGE_TEXTURE_SIZE;
|
|
1252
1253
|
|
|
1253
1254
|
// Monotonic up: UI-driven callers request whatever the current view needs, so honouring decreases made
|
|
1254
1255
|
// the reserve oscillate on every preview↔render switch and paid a full kernel rebuild each time.
|
|
1255
1256
|
const target = allowLower ? requestedPx : Math.max( requestedPx, prev );
|
|
1257
|
+
|
|
1258
|
+
// No device yet: the gate below has nothing to interrogate and would clamp a 4K-capable GPU to 2048.
|
|
1259
|
+
// init() replays the request once the device exists, still before the stages allocate.
|
|
1260
|
+
if ( ! this.renderer ) {
|
|
1261
|
+
|
|
1262
|
+
this._pendingReservedRenderSize = { requestedPx, allowLower };
|
|
1263
|
+
return Math.max( 256, Math.min( MAX_RESERVABLE_RENDER_SIZE, Math.floor( target ) || 256 ) );
|
|
1264
|
+
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const limits = this.renderer.backend?.device?.limits;
|
|
1268
|
+
const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
|
|
1269
|
+
const deviceMemGB = ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4;
|
|
1270
|
+
// 4K reserve pins the accum MRT (~1.5 GB) + aux; only grant it on clearly-capable GPUs.
|
|
1271
|
+
const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
|
|
1272
|
+
? MAX_RESERVABLE_RENDER_SIZE : 2048;
|
|
1256
1273
|
const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
|
|
1257
1274
|
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1275
|
+
// Gate the realloc on the textures that EXIST, not on how the binding moved: a raise applied while
|
|
1276
|
+
// nothing was allocated leaves `applied === prev` for every later call, so keying off that let the
|
|
1277
|
+
// first ineffective call poison every effective one after it (issue #9). The path tracer's write MRT
|
|
1278
|
+
// witnesses the reserve the stages were last built at.
|
|
1279
|
+
const allocated = this.stages?.pathTracer?.storageTextures?.writeColor?.image?.width ?? applied;
|
|
1280
|
+
|
|
1281
|
+
// Re-init the reserved GPU storage in place: each stage recreates its pre-allocated StorageTextures at
|
|
1282
|
+
// the new size + rebuilds its compute pipelines. Stage OBJECTS, manager refs and event wiring are
|
|
1283
|
+
// preserved (so no re-subscription needed); scene geometry buffers are resolution-independent and
|
|
1284
|
+
// reused. Rendering is paused across the swap so no in-flight dispatch references a disposed texture.
|
|
1285
|
+
if ( allocated !== applied ) {
|
|
1264
1286
|
|
|
1265
1287
|
const wasPaused = this.pauseRendering;
|
|
1266
1288
|
this.pauseRendering = true;
|
|
@@ -1294,6 +1316,31 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1294
1316
|
|
|
1295
1317
|
}
|
|
1296
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Replay a setReservedRenderResolution() call made before init(): after the device exists, so the gate is
|
|
1321
|
+
* evaluated against the real GPU, and before _initPipeline() constructs the stages, which read the reserve
|
|
1322
|
+
* in their constructors. The event carries the device's verdict — the only authoritative answer a pre-init
|
|
1323
|
+
* caller can get.
|
|
1324
|
+
*/
|
|
1325
|
+
_applyPendingReservedRenderSize() {
|
|
1326
|
+
|
|
1327
|
+
const pending = this._pendingReservedRenderSize;
|
|
1328
|
+
if ( ! pending ) return;
|
|
1329
|
+
|
|
1330
|
+
this._pendingReservedRenderSize = null;
|
|
1331
|
+
|
|
1332
|
+
const applied = this.setReservedRenderResolution( pending.requestedPx, { allowLower: pending.allowLower } );
|
|
1333
|
+
|
|
1334
|
+
if ( applied < pending.requestedPx ) {
|
|
1335
|
+
|
|
1336
|
+
log.warn( `reserved render size ${fmt.n( pending.requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — renders above ${fmt.n( applied )}px will be declined.` );
|
|
1337
|
+
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
this.dispatchEvent( { type: 'reserved_render_size_changed', size: applied } );
|
|
1341
|
+
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1297
1344
|
/**
|
|
1298
1345
|
* The current reserved (pre-allocated) square render size in px.
|
|
1299
1346
|
* @returns {number}
|
|
@@ -1314,7 +1361,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1314
1361
|
|
|
1315
1362
|
if ( width > MAX_STORAGE_TEXTURE_SIZE || height > MAX_STORAGE_TEXTURE_SIZE ) {
|
|
1316
1363
|
|
|
1317
|
-
log.warn( `render resolution ${width}×${height} exceeds the ${MAX_STORAGE_TEXTURE_SIZE}px
|
|
1364
|
+
log.warn( `render resolution ${width}×${height} exceeds the ${MAX_STORAGE_TEXTURE_SIZE}px reserve (compute storage textures are pre-allocated at ${MAX_STORAGE_TEXTURE_SIZE}px). Ignoring resize — raise the reserve with setReservedRenderResolution( ${Math.max( width, height )} ) first, or use a resolution ≤ ${MAX_STORAGE_TEXTURE_SIZE}.` );
|
|
1318
1365
|
return false;
|
|
1319
1366
|
|
|
1320
1367
|
}
|
|
@@ -1360,10 +1407,19 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1360
1407
|
|
|
1361
1408
|
}
|
|
1362
1409
|
|
|
1410
|
+
/**
|
|
1411
|
+
* Set the render resolution in pixels, applied immediately (unlike the debounced onResize()).
|
|
1412
|
+
* @param {number} width
|
|
1413
|
+
* @param {number} height
|
|
1414
|
+
* @returns {{width: number, height: number}|null} the size now in effect, or null if the request was
|
|
1415
|
+
* declined — zero, or above the reserved render size (raise it with setReservedRenderResolution).
|
|
1416
|
+
* A declined request leaves the previous size in place, so ignoring this return renders at the
|
|
1417
|
+
* wrong resolution with nothing but a warning to show for it.
|
|
1418
|
+
*/
|
|
1363
1419
|
setCanvasSize( width, height ) {
|
|
1364
1420
|
|
|
1365
|
-
if ( width === 0 || height === 0 ) return;
|
|
1366
|
-
if ( ! this._isRenderSizeSupported( width, height ) ) return;
|
|
1421
|
+
if ( width === 0 || height === 0 ) return null;
|
|
1422
|
+
if ( ! this._isRenderSizeSupported( width, height ) ) return null;
|
|
1367
1423
|
|
|
1368
1424
|
this.renderer.setPixelRatio( 1.0 );
|
|
1369
1425
|
this.renderer.setSize( width, height, false );
|
|
@@ -1373,6 +1429,8 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1373
1429
|
clearTimeout( this._resizeDebounceTimer );
|
|
1374
1430
|
this._applyRenderResize( width, height );
|
|
1375
1431
|
|
|
1432
|
+
return { width, height };
|
|
1433
|
+
|
|
1376
1434
|
}
|
|
1377
1435
|
|
|
1378
1436
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1926,6 +1984,18 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1926
1984
|
|
|
1927
1985
|
}
|
|
1928
1986
|
|
|
1987
|
+
/**
|
|
1988
|
+
* Adaptive-sampling telemetry: `{ converged, activePixels, totalPixels, frame }`.
|
|
1989
|
+
* Counts come from an async readback taken on settled views only, so they lag a few frames
|
|
1990
|
+
* and read zero while the camera moves.
|
|
1991
|
+
* @returns {?Object}
|
|
1992
|
+
*/
|
|
1993
|
+
getConvergenceStats() {
|
|
1994
|
+
|
|
1995
|
+
return this.stages.pathTracer?.getConvergenceStats?.() ?? null;
|
|
1996
|
+
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1929
1999
|
/** The path tracer's VRAM tracker, or null before stages are built. */
|
|
1930
2000
|
get vram() {
|
|
1931
2001
|
|
|
@@ -2509,6 +2579,7 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
2509
2579
|
|
|
2510
2580
|
this.stages.compositor = new Compositor( this.renderer, {
|
|
2511
2581
|
saturation: this.settings.get( 'saturation' ) ?? DEFAULT_STATE.saturation,
|
|
2582
|
+
pathTracer: this.stages.pathTracer,
|
|
2512
2583
|
} );
|
|
2513
2584
|
|
|
2514
2585
|
}
|
|
@@ -22,7 +22,12 @@ export const COUNTER = {
|
|
|
22
22
|
RESERVED_3: 3,
|
|
23
23
|
// Tier-2 per-pixel freeze: bounce-0 active count (maxRays − frozen), read back to size next frame's grid.
|
|
24
24
|
ACTIVE_PIXEL_COUNT: 4,
|
|
25
|
-
|
|
25
|
+
// Geometry-only convergence gate. CONVERGED_COUNT counts every pixel, so a mostly-sky frame inherits the
|
|
26
|
+
// sky's free convergence and can retire while the subject is still noisy. These count the same eroded
|
|
27
|
+
// bits restricted to pixels that hit geometry, so the stop can require both fractions.
|
|
28
|
+
GEOMETRY_COUNT: 5,
|
|
29
|
+
CONVERGED_GEOMETRY_COUNT: 6,
|
|
30
|
+
COUNT: 7,
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
/** Ray flag bits packed into rayBounceFlags (uint) */
|
package/src/RenderSettings.js
CHANGED
|
@@ -50,6 +50,7 @@ const SETTING_ROUTES = {
|
|
|
50
50
|
usePixelFreeze: { uniform: 'usePixelFreeze', reset: true },
|
|
51
51
|
pixelFreezeThreshold: { uniform: 'pixelFreezeThreshold', reset: true },
|
|
52
52
|
pixelFreezeStability: { uniform: 'pixelFreezeStability', reset: true },
|
|
53
|
+
convergenceOverlay: { handler: 'handleConvergenceOverlay', reset: false },
|
|
53
54
|
enableEmissiveTriangleSampling: { uniform: 'enableEmissiveTriangleSampling', reset: true },
|
|
54
55
|
emissiveBoost: { uniform: 'emissiveBoost', reset: true },
|
|
55
56
|
visMode: { uniform: 'visMode', reset: true },
|
|
@@ -142,7 +143,7 @@ export class RenderSettings extends EventDispatcher {
|
|
|
142
143
|
* Builds handler functions for multi-stage settings that can't
|
|
143
144
|
* be routed with a simple uniform forward.
|
|
144
145
|
*/
|
|
145
|
-
_buildHandlers( { stages, renderer, reconcileCompletion, denoisingManager, cameraManager } ) {
|
|
146
|
+
_buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, cameraManager } ) {
|
|
146
147
|
|
|
147
148
|
// UniformManager copies into the existing node, so one scratch vector serves every write.
|
|
148
149
|
const panoScratch = new Vector2();
|
|
@@ -209,6 +210,17 @@ export class RenderSettings extends EventDispatcher {
|
|
|
209
210
|
|
|
210
211
|
},
|
|
211
212
|
|
|
213
|
+
handleConvergenceOverlay: ( value ) => {
|
|
214
|
+
|
|
215
|
+
stages.pathTracer?.setUniform( 'convergenceOverlay', value );
|
|
216
|
+
stages.compositor?.setConvergenceOverlay( value );
|
|
217
|
+
|
|
218
|
+
// With adaptive sampling off nothing kept m2 current, so the estimate has no history to
|
|
219
|
+
// read — restart accumulation so it self-inits at frame 0 instead of painting stale error.
|
|
220
|
+
if ( value && ! this.get( 'useAdaptiveSampling' ) ) resetCallback?.();
|
|
221
|
+
|
|
222
|
+
},
|
|
223
|
+
|
|
212
224
|
handleRenderLimitMode: ( value ) => {
|
|
213
225
|
|
|
214
226
|
stages.pathTracer?.setRenderLimitMode?.( value );
|
package/src/Stages/Compositor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { vec4, vec3, uv, uniform, select, dot, mix } from 'three/tsl';
|
|
1
|
+
import { vec4, vec3, uv, uniform, select, dot, mix, float, int, uint, storage, Fn, If } from 'three/tsl';
|
|
2
2
|
import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
|
|
3
|
-
import { NoBlending } from 'three';
|
|
3
|
+
import { NoBlending, NoToneMapping } from 'three';
|
|
4
4
|
import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
|
|
5
5
|
import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
|
|
6
6
|
|
|
@@ -31,6 +31,17 @@ export class Compositor extends RenderStage {
|
|
|
31
31
|
|
|
32
32
|
this._transparentBackground = uniform( 0, 'int' );
|
|
33
33
|
|
|
34
|
+
// Convergence debug overlay — reads the path tracer's per-pixel convergence buffers read-only.
|
|
35
|
+
// Material is compiled on first enable, so the normal display path carries none of its bindings.
|
|
36
|
+
this._pathTracer = options.pathTracer ?? null;
|
|
37
|
+
this._convergenceOverlay = false;
|
|
38
|
+
this._debugMaterial = null;
|
|
39
|
+
this._debugQuad = null;
|
|
40
|
+
this._debugTexNode = null;
|
|
41
|
+
this._debugM2Attr = null;
|
|
42
|
+
this._debugMaskAttr = null;
|
|
43
|
+
this._debugExposureComp = uniform( 1.0 );
|
|
44
|
+
|
|
34
45
|
// TextureNode reused across frames — only `.value` mutates, shader doesn't recompile.
|
|
35
46
|
this._sourceTexNode = new TextureNode();
|
|
36
47
|
|
|
@@ -63,10 +74,117 @@ export class Compositor extends RenderStage {
|
|
|
63
74
|
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Builds (or rebuilds) the convergence-overlay material.
|
|
79
|
+
*
|
|
80
|
+
* Re-derives FinalWrite's own convergence predicate from the accumulated colour (the running mean)
|
|
81
|
+
* and the m2 buffer (its running second moment), so what is painted IS the decision the renderer
|
|
82
|
+
* makes — nothing is recomputed differently or written back.
|
|
83
|
+
*
|
|
84
|
+
* @returns {boolean} True when a material is ready to render.
|
|
85
|
+
*/
|
|
86
|
+
_ensureDebugMaterial() {
|
|
87
|
+
|
|
88
|
+
const src = this._pathTracer?.getConvergenceDebugSource?.();
|
|
89
|
+
if ( ! src ) return false;
|
|
90
|
+
|
|
91
|
+
// The buffers are only reallocated when the render reserve grows; rebind when that happens.
|
|
92
|
+
if ( this._debugMaterial && this._debugM2Attr === src.m2 && this._debugMaskAttr === src.frozenMask ) return true;
|
|
93
|
+
|
|
94
|
+
this._debugMaterial?.dispose();
|
|
95
|
+
this._debugTexNode?.dispose();
|
|
96
|
+
|
|
97
|
+
this._debugM2Attr = src.m2;
|
|
98
|
+
this._debugMaskAttr = src.frozenMask;
|
|
99
|
+
this._debugTexNode = new TextureNode();
|
|
100
|
+
|
|
101
|
+
// Must stay one Fn body: as a raw node graph TSL emits a .toVar() inside the FIRST branch that demands
|
|
102
|
+
// it, and sibling branches then read an unassigned var<private>.
|
|
103
|
+
const overlayColor = Fn( () => {
|
|
104
|
+
|
|
105
|
+
const texel = this._debugTexNode.sample( uv() ).toVar();
|
|
106
|
+
const lum = dot( texel.xyz, REC709_LUMINANCE_COEFFICIENTS ).toVar();
|
|
107
|
+
|
|
108
|
+
// The overlay draws at canvas size, the buffers are indexed at render size — go through the pixel index.
|
|
109
|
+
const w = src.renderWidth;
|
|
110
|
+
const h = src.renderHeight;
|
|
111
|
+
const ix = int( uv().x.mul( float( w ) ) ).min( w.sub( int( 1 ) ) ).max( int( 0 ) );
|
|
112
|
+
const iy = int( uv().y.mul( float( h ) ) ).min( h.sub( int( 1 ) ) ).max( int( 0 ) );
|
|
113
|
+
const pixelId = uint( iy.mul( w ).add( ix ) ).toVar();
|
|
114
|
+
|
|
115
|
+
const m2 = storage( src.m2, 'float' ).toReadOnly().element( pixelId ).toVar();
|
|
116
|
+
const frozenFlag = storage( src.frozenMask, 'uint' ).toReadOnly().element( pixelId ).toVar();
|
|
117
|
+
|
|
118
|
+
const sampleVar = m2.sub( lum.mul( lum ) ).max( float( 0 ) );
|
|
119
|
+
const absSE = sampleVar.div( float( src.frame ).add( 1.0 ) ).sqrt().toVar();
|
|
120
|
+
|
|
121
|
+
// Freeze bars on the raw relative error; the whole-frame stop bars on the √-normalized one.
|
|
122
|
+
// Show whichever is actually steering the render.
|
|
123
|
+
const freezeOn = src.usePixelFreeze.greaterThan( int( 0 ) );
|
|
124
|
+
const convNorm = select( lum.lessThan( float( 1.0 ) ), lum.sqrt(), lum ).toVar();
|
|
125
|
+
const err = select( freezeOn, absSE.div( lum.add( float( 1e-4 ) ) ), absSE.div( convNorm.add( float( 1e-4 ) ) ) ).toVar();
|
|
126
|
+
const bar = select( freezeOn, src.pixelFreezeThreshold, src.noiseThreshold ).toVar();
|
|
127
|
+
const ratio = err.div( bar.max( float( 1e-6 ) ) ).toVar();
|
|
128
|
+
|
|
129
|
+
// Dim tone-mapped grayscale so scene structure stays readable under the overlay.
|
|
130
|
+
const base = vec3( lum.div( lum.add( float( 1.0 ) ) ).sqrt().mul( 0.55 ) ).toVar();
|
|
131
|
+
const out = base.toVar();
|
|
132
|
+
|
|
133
|
+
// Below minSamples the variance is untrustworthy — at frame 0 it is identically zero, which would
|
|
134
|
+
// paint a converged image over pure noise.
|
|
135
|
+
const eligible = float( src.frame ).greaterThanEqual( float( src.adaptiveMinSamples ) );
|
|
136
|
+
|
|
137
|
+
If( ratio.greaterThanEqual( float( 1.0 ) ).or( eligible.not() ), () => {
|
|
138
|
+
|
|
139
|
+
const heat = mix( vec3( 1.0, 0.8, 0.1 ), vec3( 1.0, 0.1, 0.05 ), ratio.sub( 1.0 ).div( 3.0 ).clamp( 0.0, 1.0 ) );
|
|
140
|
+
out.assign( mix( base, heat, 0.85 ) );
|
|
141
|
+
|
|
142
|
+
} );
|
|
143
|
+
|
|
144
|
+
// Frozen wins: these are the pixels the renderer actually skipped, dilation halo included.
|
|
145
|
+
If( frozenFlag.equal( uint( 1 ) ).and( freezeOn ), () => {
|
|
146
|
+
|
|
147
|
+
out.assign( mix( base, vec3( 0.1, 0.45, 1.0 ), 0.55 ) );
|
|
148
|
+
|
|
149
|
+
} );
|
|
150
|
+
|
|
151
|
+
return out;
|
|
152
|
+
|
|
153
|
+
} );
|
|
154
|
+
|
|
155
|
+
// Undo the output pass's exposure (tone-mapped configs only) and linear→sRGB so the palette reads the
|
|
156
|
+
// same at any exposure.
|
|
157
|
+
this._debugMaterial = new MeshBasicNodeMaterial();
|
|
158
|
+
this._debugMaterial.colorNode = vec4( overlayColor().pow( 2.2 ).mul( this._debugExposureComp ), 1.0 );
|
|
159
|
+
this._debugMaterial.blending = NoBlending;
|
|
160
|
+
this._debugQuad = new QuadMesh( this._debugMaterial );
|
|
161
|
+
|
|
162
|
+
return true;
|
|
163
|
+
|
|
164
|
+
}
|
|
165
|
+
|
|
66
166
|
render( context ) {
|
|
67
167
|
|
|
68
168
|
if ( ! this.enabled ) return;
|
|
69
169
|
|
|
170
|
+
if ( this._convergenceOverlay && this._ensureDebugMaterial() ) {
|
|
171
|
+
|
|
172
|
+
// The error estimate is paired with the raw accumulated colour, not a denoised derivative.
|
|
173
|
+
const ptColor = context.getTexture( 'pathtracer:color' );
|
|
174
|
+
if ( ptColor ) {
|
|
175
|
+
|
|
176
|
+
this._debugTexNode.value = ptColor;
|
|
177
|
+
const exposed = this.renderer.toneMapping !== NoToneMapping;
|
|
178
|
+
this._debugExposureComp.value = exposed ? 1 / Math.max( this.renderer.toneMappingExposure, 1e-4 ) : 1;
|
|
179
|
+
|
|
180
|
+
this.renderer.setRenderTarget( null );
|
|
181
|
+
this._debugQuad.render( this.renderer );
|
|
182
|
+
return;
|
|
183
|
+
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
}
|
|
187
|
+
|
|
70
188
|
const sourceTexture = this._resolveSourceTexture( context );
|
|
71
189
|
if ( ! sourceTexture ) return;
|
|
72
190
|
|
|
@@ -77,6 +195,12 @@ export class Compositor extends RenderStage {
|
|
|
77
195
|
|
|
78
196
|
}
|
|
79
197
|
|
|
198
|
+
setConvergenceOverlay( enabled ) {
|
|
199
|
+
|
|
200
|
+
this._convergenceOverlay = !! enabled;
|
|
201
|
+
|
|
202
|
+
}
|
|
203
|
+
|
|
80
204
|
setSaturation( value ) {
|
|
81
205
|
|
|
82
206
|
this.saturation.value = value;
|
|
@@ -93,8 +217,14 @@ export class Compositor extends RenderStage {
|
|
|
93
217
|
|
|
94
218
|
this._sourceTexNode?.dispose();
|
|
95
219
|
this.compositorMaterial?.dispose();
|
|
220
|
+
this._debugTexNode?.dispose();
|
|
221
|
+
this._debugMaterial?.dispose();
|
|
96
222
|
// QuadMesh extends Mesh — no dispose method; material already released above.
|
|
97
223
|
this.compositorQuad = null;
|
|
224
|
+
this._debugQuad = null;
|
|
225
|
+
this._debugMaterial = null;
|
|
226
|
+
this._debugTexNode = null;
|
|
227
|
+
this._pathTracer = null;
|
|
98
228
|
|
|
99
229
|
}
|
|
100
230
|
|