rayzee 7.23.3 → 7.25.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 +40 -18
- package/dist/rayzee.es.js +1864 -1768
- package/dist/rayzee.umd.js +52 -52
- package/package.json +3 -3
- package/src/Passes/AIUpscaler.js +19 -11
- package/src/Passes/OIDNDenoiser.js +115 -56
- package/src/PathTracerApp.js +46 -4
- package/src/Pipeline/CompletionTracker.js +45 -6
- package/src/Processor/AssetLoader.js +77 -11
- package/src/Processor/ToneMapCPU.js +37 -7
- package/src/RenderSettings.js +1 -0
- package/src/Stages/PathTracerStage.js +6 -16
- package/src/managers/DenoisingManager.js +18 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rayzee",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.25.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Real-time WebGPU path tracing engine built on Three.js",
|
|
6
6
|
"main": "dist/rayzee.umd.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
|
-
"url": "https://github.com/atul-mourya/
|
|
21
|
+
"url": "git+https://github.com/atul-mourya/rayzee-renderer.git",
|
|
22
22
|
"directory": "rayzee"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"three": ">=0.185.0"
|
|
39
39
|
},
|
|
40
40
|
"optionalDependencies": {
|
|
41
|
-
"oidn-web": ">=0.
|
|
41
|
+
"oidn-web": ">=0.4.0",
|
|
42
42
|
"onnxruntime-web": ">=1.0.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/Passes/AIUpscaler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventDispatcher, ACESFilmicToneMapping } from 'three';
|
|
2
|
-
import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation } from '../Processor/ToneMapCPU.js';
|
|
2
|
+
import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
|
|
3
3
|
import { getAssetConfig } from '../AssetConfig.js';
|
|
4
4
|
import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
|
|
5
5
|
|
|
@@ -107,6 +107,8 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
107
107
|
this.hdr = false;
|
|
108
108
|
this.scaleFactor = options.scaleFactor || 2;
|
|
109
109
|
this.quality = options.quality || 'fast';
|
|
110
|
+
// refreshInput: () => void — re-renders the compositor so the WebGPU canvas is readable
|
|
111
|
+
this.refreshInput = options.refreshInput || null;
|
|
110
112
|
this.tileSize = options.tileSize || MODEL_CONFIG.TILE_SIZE;
|
|
111
113
|
this._tileSizeOverride = !! options.tileSize;
|
|
112
114
|
|
|
@@ -254,6 +256,14 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
254
256
|
|
|
255
257
|
}
|
|
256
258
|
|
|
259
|
+
// See OIDNDenoiser._revealOutput. Here the resize below also clears the bitmap.
|
|
260
|
+
_revealOutput() {
|
|
261
|
+
|
|
262
|
+
this.output.style.display = 'block';
|
|
263
|
+
this.input.style.opacity = '0';
|
|
264
|
+
|
|
265
|
+
}
|
|
266
|
+
|
|
257
267
|
async execute() {
|
|
258
268
|
|
|
259
269
|
if ( ! this.enabled ) return false;
|
|
@@ -261,10 +271,6 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
261
271
|
this.state.abortController = new AbortController();
|
|
262
272
|
this.state.isUpscaling = true;
|
|
263
273
|
|
|
264
|
-
// Show output canvas and hide input (matching OIDN lifecycle)
|
|
265
|
-
this.input.style.opacity = '0';
|
|
266
|
-
this.output.style.display = 'block';
|
|
267
|
-
|
|
268
274
|
// Capture source image SYNCHRONOUSLY before any async work.
|
|
269
275
|
// WebGPU canvas textures expire after each compositor frame,
|
|
270
276
|
// so we must grab the pixels before awaiting model load.
|
|
@@ -292,6 +298,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
292
298
|
ctx.imageSmoothingEnabled = true;
|
|
293
299
|
ctx.imageSmoothingQuality = 'high';
|
|
294
300
|
ctx.drawImage( this._backupCanvas, 0, 0, outW, outH );
|
|
301
|
+
this._revealOutput();
|
|
295
302
|
|
|
296
303
|
try {
|
|
297
304
|
|
|
@@ -393,7 +400,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
393
400
|
if ( sourceImageData.isHDR ) {
|
|
394
401
|
|
|
395
402
|
this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
|
|
396
|
-
this._hdrExposure = this.getExposure();
|
|
403
|
+
this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
|
|
397
404
|
this._hdrSaturation = this.getSaturation();
|
|
398
405
|
this._tmOut = new Float32Array( 3 );
|
|
399
406
|
|
|
@@ -467,10 +474,11 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
467
474
|
|
|
468
475
|
if ( sourceCanvas && sourceCanvas !== this.output ) {
|
|
469
476
|
|
|
470
|
-
// Source is the WebGPU canvas — must copy to 2D canvas first
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
477
|
+
// Source is the WebGPU canvas — must copy to a 2D canvas first, and it only reads
|
|
478
|
+
// back non-empty straight after a compositor pass. Enforced here rather than left to
|
|
479
|
+
// the caller: any awaited GPU work in between silently yields a fully transparent
|
|
480
|
+
// capture, and the upscaler would then upscale nothing at all.
|
|
481
|
+
this.refreshInput?.();
|
|
474
482
|
const offscreen = document.createElement( 'canvas' );
|
|
475
483
|
offscreen.width = sourceCanvas.width;
|
|
476
484
|
offscreen.height = sourceCanvas.height;
|
|
@@ -785,7 +793,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
785
793
|
const imageData = ctx.createImageData( width, height );
|
|
786
794
|
const pixels = imageData.data;
|
|
787
795
|
const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
|
|
788
|
-
const exposure = this.getExposure();
|
|
796
|
+
const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
|
|
789
797
|
const saturation = this.getSaturation();
|
|
790
798
|
const out = new Float32Array( 3 );
|
|
791
799
|
|
|
@@ -4,17 +4,11 @@ import { createLogger, fmt } from '../utils/Logger.js';
|
|
|
4
4
|
const log = createLogger( 'oidn' );
|
|
5
5
|
|
|
6
6
|
let _initUNetFromURL = null;
|
|
7
|
-
let _tfEngine = null;
|
|
8
7
|
async function getInitUNetFromURL() {
|
|
9
8
|
|
|
10
9
|
if ( ! _initUNetFromURL ) {
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
import( 'oidn-web' ),
|
|
14
|
-
import( '@tensorflow/tfjs-core' )
|
|
15
|
-
] );
|
|
16
|
-
_initUNetFromURL = oidnMod.initUNetFromURL;
|
|
17
|
-
_tfEngine = tfMod.engine;
|
|
11
|
+
_initUNetFromURL = ( await import( 'oidn-web' ) ).initUNetFromURL;
|
|
18
12
|
|
|
19
13
|
}
|
|
20
14
|
|
|
@@ -22,51 +16,36 @@ async function getInitUNetFromURL() {
|
|
|
22
16
|
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
// On dispose, drop it so the next instance binds to the new GPUDevice instead of
|
|
27
|
-
// reusing the destroyed one (which would produce black tiles).
|
|
28
|
-
function removeOidnTfjsBackend() {
|
|
29
|
-
|
|
30
|
-
if ( ! _tfEngine ) return;
|
|
31
|
-
try {
|
|
32
|
-
|
|
33
|
-
const eng = _tfEngine();
|
|
34
|
-
if ( eng?.registryFactory && 'webgpu-oidn' in eng.registryFactory ) {
|
|
35
|
-
|
|
36
|
-
eng.removeBackend( 'webgpu-oidn' );
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
} catch ( e ) {
|
|
41
|
-
|
|
42
|
-
log.warn( 'failed to clear cached TFJS backend', e );
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
import { TONE_MAP_FNS, linearToSRGB, applySaturation } from '../Processor/ToneMapCPU.js';
|
|
19
|
+
import { TONE_MAP_FNS, linearToSRGB, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
|
|
49
20
|
import { getAssetConfig } from '../AssetConfig.js';
|
|
50
21
|
|
|
51
22
|
/** Reusable RGB output buffer (avoids per-pixel allocation). */
|
|
52
23
|
const _tmOut = new Float32Array( 3 );
|
|
53
24
|
|
|
54
25
|
const MODEL_CONFIG = {
|
|
55
|
-
// No cleanAux flag in oidn-web, so the blob is it — and must match what setCleanAuxNormal
|
|
56
|
-
// feeds: `
|
|
57
|
-
// is
|
|
26
|
+
// No cleanAux flag in oidn-web, so the blob is it — and it must match what setCleanAuxNormal
|
|
27
|
+
// feeds the aux MRT: an `alb_nrm` model wants the point-sampled normal, a `calb_cnrm` model
|
|
28
|
+
// wants the accumulated one. `cleanAux` is carried here rather than inferred from the tier
|
|
29
|
+
// name, which stopped being possible once a tier other than `fast` used a noisy-aux model.
|
|
30
|
+
//
|
|
31
|
+
// `fast-clean` is the same topology and channel width as `fast` (both 641 KB, widest conv 64)
|
|
32
|
+
// so it costs the same to run, and only differs in being trained for a clean guide.
|
|
58
33
|
QUALITY_MODELS: {
|
|
59
|
-
fast: 'rt_hdr_alb_nrm_small',
|
|
60
|
-
|
|
61
|
-
|
|
34
|
+
fast: { model: 'rt_hdr_alb_nrm_small', cleanAux: false },
|
|
35
|
+
'fast-clean': { model: 'rt_hdr_calb_cnrm_small', cleanAux: true },
|
|
36
|
+
balance: { model: 'rt_hdr_calb_cnrm', cleanAux: true },
|
|
37
|
+
high: { model: 'rt_hdr_calb_cnrm_large', cleanAux: true }
|
|
62
38
|
},
|
|
63
39
|
DEFAULT_OPTIONS: {
|
|
64
40
|
enableOIDN: true,
|
|
65
41
|
oidnQuality: 'fast',
|
|
66
42
|
debugGbufferMaps: true,
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
tileSize:
|
|
43
|
+
// A cap, not a fixed size — the effective tile is min( max( w, h ), this ), so a frame
|
|
44
|
+
// that fits in one tile pays no overlap padding at all. Every tile otherwise runs at
|
|
45
|
+
// tileSize + 2*overlap (96px, 112 for _large): at 1024²/high, 4x512 tiles measured
|
|
46
|
+
// 422ms against 229ms for one 1024 tile. 1024 caps the one-time activation
|
|
47
|
+
// allocation at ~430MB; beyond it larger frames tile and stay bounded.
|
|
48
|
+
tileSize: 1024
|
|
70
49
|
}
|
|
71
50
|
};
|
|
72
51
|
|
|
@@ -140,6 +119,8 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
140
119
|
// getToneMapping: () => number (Three.js ToneMapping constant)
|
|
141
120
|
this.backendParamsGetter = options.backendParams || null;
|
|
142
121
|
this.getGPUTextures = options.getGPUTextures || null;
|
|
122
|
+
// refreshInput: () => void — re-renders the compositor so the WebGPU canvas is readable
|
|
123
|
+
this.refreshInput = options.refreshInput || null;
|
|
143
124
|
this.getExposure = options.getExposure || ( () => 1.0 );
|
|
144
125
|
this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
|
|
145
126
|
this.getSaturation = options.getSaturation || ( () => 1.0 );
|
|
@@ -179,8 +160,8 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
179
160
|
this.enabled = this.config.enableOIDN;
|
|
180
161
|
this.quality = this.config.oidnQuality;
|
|
181
162
|
this.debugGbufferMaps = this.config.debugGbufferMaps;
|
|
182
|
-
this.
|
|
183
|
-
//
|
|
163
|
+
this.maxTileSize = this.config.tileSize;
|
|
164
|
+
// The size actually baked into the live UNet, so a change forces a rebuild.
|
|
184
165
|
this._activeTileSize = 0;
|
|
185
166
|
|
|
186
167
|
// State management
|
|
@@ -257,6 +238,63 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
257
238
|
|
|
258
239
|
}
|
|
259
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Whether the tier's model expects a clean (accumulated) auxiliary guide rather than a
|
|
243
|
+
* point-sampled one. The aux MRT has to be fed to match, or the guide fights the weights.
|
|
244
|
+
* @param {string} [quality] defaults to the active tier
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
expectsCleanAux( quality = this.quality ) {
|
|
248
|
+
|
|
249
|
+
const { QUALITY_MODELS } = MODEL_CONFIG;
|
|
250
|
+
return ( QUALITY_MODELS[ quality ] || QUALITY_MODELS.balance ).cleanAux;
|
|
251
|
+
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Engine, precision, model, kernel, tile state and resource counts from the live UNet.
|
|
256
|
+
* @returns {Object|null} null before the weights load
|
|
257
|
+
*/
|
|
258
|
+
getRuntimeInfo() {
|
|
259
|
+
|
|
260
|
+
return this.unet?.getRuntimeInfo?.() ?? null;
|
|
261
|
+
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Arms per-layer GPU timestamping for the next denoise. Needs `timestamp-query` on the
|
|
266
|
+
* device; one denoise only, so call it again for each capture.
|
|
267
|
+
* @returns {boolean} whether the capture was armed
|
|
268
|
+
*/
|
|
269
|
+
profileNextDenoise() {
|
|
270
|
+
|
|
271
|
+
return this.unet?.profileNextExecution?.() ?? false;
|
|
272
|
+
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Per-layer GPU timings from the last denoise armed by {@link profileNextDenoise}.
|
|
277
|
+
* @returns {Promise<Object|null>}
|
|
278
|
+
*/
|
|
279
|
+
async getLastDenoiseProfile() {
|
|
280
|
+
|
|
281
|
+
return ( await this.unet?.getLastExecutionProfile?.() ) ?? null;
|
|
282
|
+
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Largest tile that still covers the frame, bounded by the configured cap. One tile means
|
|
287
|
+
* zero overlap, which is the cheapest configuration oidn-web has.
|
|
288
|
+
* @returns {number}
|
|
289
|
+
*/
|
|
290
|
+
_resolveTileSize() {
|
|
291
|
+
|
|
292
|
+
const longest = Math.max( this.output?.width || 0, this.output?.height || 0 );
|
|
293
|
+
if ( ! longest ) return this.maxTileSize;
|
|
294
|
+
return Math.min( longest, this.maxTileSize );
|
|
295
|
+
|
|
296
|
+
}
|
|
297
|
+
|
|
260
298
|
async _setupUNetDenoiser() {
|
|
261
299
|
|
|
262
300
|
if ( this.state.isLoading ) return;
|
|
@@ -264,8 +302,10 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
264
302
|
this.state.isLoading = true;
|
|
265
303
|
const tzaUrl = this._generateTzaUrl();
|
|
266
304
|
|
|
305
|
+
const tileSize = this._resolveTileSize();
|
|
306
|
+
|
|
267
307
|
// maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
|
|
268
|
-
if ( this.currentTZAUrl === tzaUrl && this._activeTileSize ===
|
|
308
|
+
if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) {
|
|
269
309
|
|
|
270
310
|
this.state.isLoading = false;
|
|
271
311
|
return;
|
|
@@ -299,11 +339,14 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
299
339
|
this.unet = await initFn( tzaUrl, backendParams, {
|
|
300
340
|
aux: true,
|
|
301
341
|
hdr: true,
|
|
302
|
-
maxTileSize:
|
|
342
|
+
maxTileSize: tileSize,
|
|
343
|
+
// Adaptive tiling ignores maxTileSize, starts at 384, and cannot see that overlap
|
|
344
|
+
// padding only vanishes once a tile covers the image — so it settles smaller.
|
|
345
|
+
dynamicTile: false
|
|
303
346
|
} );
|
|
304
347
|
|
|
305
348
|
this.currentTZAUrl = tzaUrl;
|
|
306
|
-
this._activeTileSize =
|
|
349
|
+
this._activeTileSize = tileSize;
|
|
307
350
|
this.dispatchEvent( { type: 'loaded' } );
|
|
308
351
|
log.debug( 'UNet weights loaded:', tzaUrl );
|
|
309
352
|
|
|
@@ -333,8 +376,8 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
333
376
|
|
|
334
377
|
const { oidnWeightsBaseUrl } = getAssetConfig();
|
|
335
378
|
const { QUALITY_MODELS } = MODEL_CONFIG;
|
|
336
|
-
const
|
|
337
|
-
return `${oidnWeightsBaseUrl}${
|
|
379
|
+
const tier = QUALITY_MODELS[ this.quality ] || QUALITY_MODELS.balance;
|
|
380
|
+
return `${oidnWeightsBaseUrl}${tier.model}.tza`;
|
|
338
381
|
|
|
339
382
|
}
|
|
340
383
|
|
|
@@ -349,7 +392,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
349
392
|
Object.assign( this.config, newConfig );
|
|
350
393
|
this.quality = this.config.oidnQuality;
|
|
351
394
|
this.debugGbufferMaps = this.config.debugGbufferMaps;
|
|
352
|
-
this.
|
|
395
|
+
this.maxTileSize = this.config.tileSize;
|
|
353
396
|
|
|
354
397
|
// Reload denoiser if necessary
|
|
355
398
|
await this._setupUNetDenoiser();
|
|
@@ -376,6 +419,14 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
376
419
|
|
|
377
420
|
}
|
|
378
421
|
|
|
422
|
+
// The tile tracks the frame size, so a resolution change since the last build has to be
|
|
423
|
+
// rebuilt into the UNet before denoising — otherwise the frame tiles when it need not.
|
|
424
|
+
if ( this.unet && ! this.state.isLoading && this._activeTileSize !== this._resolveTileSize() ) {
|
|
425
|
+
|
|
426
|
+
await this._setupUNetDenoiser();
|
|
427
|
+
|
|
428
|
+
}
|
|
429
|
+
|
|
379
430
|
// UNet weights still loading — defer the start (fired from _setupUNetDenoiser's
|
|
380
431
|
// finally) rather than silently dropping this frame's denoise request.
|
|
381
432
|
if ( this.state.isLoading ) {
|
|
@@ -404,6 +455,15 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
404
455
|
|
|
405
456
|
}
|
|
406
457
|
|
|
458
|
+
// Revealing before the first paint shows the output's transparent bitmap — and its
|
|
459
|
+
// checker CSS background — over the now-hidden render canvas.
|
|
460
|
+
_revealOutput() {
|
|
461
|
+
|
|
462
|
+
this.output.style.display = 'block';
|
|
463
|
+
this.input.style.opacity = '0';
|
|
464
|
+
|
|
465
|
+
}
|
|
466
|
+
|
|
407
467
|
async execute() {
|
|
408
468
|
|
|
409
469
|
if ( ! this.enabled || ! this.unet ) return false;
|
|
@@ -411,8 +471,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
411
471
|
// Create abort controller for this execution
|
|
412
472
|
this.state.abortController = new AbortController();
|
|
413
473
|
this.state.isDenoising = true;
|
|
414
|
-
this.input.style.opacity = '0';
|
|
415
|
-
this.output.style.display = 'block';
|
|
416
474
|
|
|
417
475
|
try {
|
|
418
476
|
|
|
@@ -485,6 +543,12 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
485
543
|
|
|
486
544
|
}
|
|
487
545
|
|
|
546
|
+
// Capture the base now: the readback below awaits, and a presented WebGPU canvas only
|
|
547
|
+
// reads back non-empty straight after a compositor pass — hence the refresh.
|
|
548
|
+
this.refreshInput?.();
|
|
549
|
+
this.ctx.drawImage( this.input, 0, 0, width, height );
|
|
550
|
+
this._revealOutput();
|
|
551
|
+
|
|
488
552
|
// Ensure storage buffers are sized correctly (recreate on resolution change)
|
|
489
553
|
this._ensureGPUInputBuffers( width, height );
|
|
490
554
|
|
|
@@ -543,8 +607,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
543
607
|
if ( globalThis.__OIDN_NO_INPUT_SCALE ) this._oidnInputScale = 1.0;
|
|
544
608
|
this._applyColorScale( device, width * height );
|
|
545
609
|
|
|
546
|
-
// Draw the current noisy frame as the base — denoised tiles paint on top progressively
|
|
547
|
-
this.ctx.drawImage( this.input, 0, 0, width, height );
|
|
548
610
|
// Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
|
|
549
611
|
const config = {
|
|
550
612
|
color: { data: this._gpuInputBuffers.color, width, height },
|
|
@@ -702,7 +764,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
702
764
|
/** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
|
|
703
765
|
_outputExposure() {
|
|
704
766
|
|
|
705
|
-
return this.getExposure() / this._oidnInputScale;
|
|
767
|
+
return effectiveExposure( this.getExposure(), this.getToneMapping() ) / this._oidnInputScale;
|
|
706
768
|
|
|
707
769
|
}
|
|
708
770
|
|
|
@@ -1094,9 +1156,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1094
1156
|
|
|
1095
1157
|
// Dispose resources
|
|
1096
1158
|
this.unet?.dispose();
|
|
1097
|
-
// Must precede renderer.dispose() so the GPUDevice is still alive when
|
|
1098
|
-
// TFJS tears down the cached backend's buffers/textures.
|
|
1099
|
-
removeOidnTfjsBackend();
|
|
1100
1159
|
this._destroyGPUInputBuffers();
|
|
1101
1160
|
this._colorScalePipeline = null;
|
|
1102
1161
|
|
package/src/PathTracerApp.js
CHANGED
|
@@ -366,8 +366,11 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
366
366
|
memoryPeak: tracker?.peak ?? 0,
|
|
367
367
|
} );
|
|
368
368
|
|
|
369
|
-
//
|
|
370
|
-
|
|
369
|
+
// Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
|
|
370
|
+
// itself, so whichever of the three arrives first wins.
|
|
371
|
+
if ( this.completion.isTimeLimitReached(
|
|
372
|
+
this.stages.pathTracer, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
|
|
373
|
+
) ) {
|
|
371
374
|
|
|
372
375
|
this.stages.pathTracer.isComplete = true;
|
|
373
376
|
|
|
@@ -381,8 +384,14 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
381
384
|
context: this.pipeline?.context,
|
|
382
385
|
} );
|
|
383
386
|
|
|
384
|
-
|
|
385
|
-
|
|
387
|
+
const completionInfo = {
|
|
388
|
+
samples: this.stages.pathTracer.frameCount,
|
|
389
|
+
timeElapsed: this.completion.timeElapsed,
|
|
390
|
+
budgetOverrun: this.completion.budgetOverrun,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
|
|
394
|
+
this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...completionInfo } );
|
|
386
395
|
|
|
387
396
|
}
|
|
388
397
|
|
|
@@ -1808,6 +1817,39 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1808
1817
|
|
|
1809
1818
|
}
|
|
1810
1819
|
|
|
1820
|
+
/**
|
|
1821
|
+
* Arms per-layer GPU timestamping inside the OIDN denoiser for the next denoise only.
|
|
1822
|
+
* Read the result with {@link PathTracerApp#getDenoiseProfile}.
|
|
1823
|
+
*
|
|
1824
|
+
* `getGPUTimings()` cannot see the denoise: oidn-web submits on its own command encoders,
|
|
1825
|
+
* outside the stages three.js times.
|
|
1826
|
+
*
|
|
1827
|
+
* @returns {boolean} whether the capture was armed
|
|
1828
|
+
*/
|
|
1829
|
+
profileNextDenoise() {
|
|
1830
|
+
|
|
1831
|
+
return this.denoisingManager?.denoiser?.profileNextDenoise() ?? false;
|
|
1832
|
+
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/**
|
|
1836
|
+
* Per-layer GPU timings from the denoise armed by {@link PathTracerApp#profileNextDenoise},
|
|
1837
|
+
* plus the live engine/precision/model/tile diagnostics.
|
|
1838
|
+
*
|
|
1839
|
+
* @returns {Promise<{ profile: Object|null, runtime: Object|null }|null>}
|
|
1840
|
+
*/
|
|
1841
|
+
async getDenoiseProfile() {
|
|
1842
|
+
|
|
1843
|
+
const denoiser = this.denoisingManager?.denoiser;
|
|
1844
|
+
if ( ! denoiser ) return null;
|
|
1845
|
+
|
|
1846
|
+
return {
|
|
1847
|
+
profile: await denoiser.getLastDenoiseProfile(),
|
|
1848
|
+
runtime: denoiser.getRuntimeInfo(),
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1811
1853
|
/**
|
|
1812
1854
|
* Real GPU milliseconds for the last resolved frame, from WebGPU timestamp queries.
|
|
1813
1855
|
* Returns null unless {@link PathTracerApp#enableGPUTiming} was called.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createLogger } from '../utils/Logger.js';
|
|
2
|
+
|
|
3
|
+
const log = createLogger( 'engine' );
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Tracks render completion state, time limits, and sample limits.
|
|
3
7
|
*
|
|
@@ -12,6 +16,10 @@ export class CompletionTracker {
|
|
|
12
16
|
this.lastResetTime = performance.now();
|
|
13
17
|
this.renderCompleteDispatched = false;
|
|
14
18
|
|
|
19
|
+
/** Whether the deadline — rather than the ceiling or convergence — retired the frame. */
|
|
20
|
+
this.budgetOverrun = false;
|
|
21
|
+
this._budgetHeldWarned = false;
|
|
22
|
+
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
@@ -24,19 +32,48 @@ export class CompletionTracker {
|
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
|
-
* Checks whether the
|
|
28
|
-
*
|
|
35
|
+
* Checks whether the wall-clock render budget has expired. An additional stop condition,
|
|
36
|
+
* never a replacement — the sample ceiling and convergence still bind when they come first.
|
|
37
|
+
*
|
|
38
|
+
* Floored at one sample: frameCount does not advance while the stage compiles kernels or
|
|
39
|
+
* while the camera is in interaction mode, and retiring in that window denoises an empty
|
|
40
|
+
* buffer and captures a blank canvas.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} pathTracer - The PathTracer stage
|
|
43
|
+
* @param {string} renderLimitMode - 'time' or 'frames'
|
|
29
44
|
* @param {number} renderTimeLimit - Time limit in seconds
|
|
30
45
|
* @returns {boolean}
|
|
31
46
|
*/
|
|
32
|
-
isTimeLimitReached( renderLimitMode, renderTimeLimit ) {
|
|
47
|
+
isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
|
|
48
|
+
|
|
49
|
+
if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
|
|
50
|
+
if ( this.timeElapsed < renderTimeLimit ) return false;
|
|
51
|
+
|
|
52
|
+
if ( ! ( pathTracer?.frameCount > 0 ) ) {
|
|
33
53
|
|
|
34
|
-
|
|
54
|
+
if ( ! this._budgetHeldWarned ) {
|
|
55
|
+
|
|
56
|
+
this._budgetHeldWarned = true;
|
|
57
|
+
log.warn(
|
|
58
|
+
`render time limit (${renderTimeLimit}s) expired before the first sample landed — ` +
|
|
59
|
+
'holding the deadline open to avoid capturing an empty frame'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.budgetOverrun = true;
|
|
69
|
+
return true;
|
|
35
70
|
|
|
36
71
|
}
|
|
37
72
|
|
|
38
73
|
/**
|
|
39
|
-
* Checks whether ANY render limit
|
|
74
|
+
* Checks whether ANY render limit is reached: sample ceiling, convergence, or time budget,
|
|
75
|
+
* whichever arrives first.
|
|
76
|
+
*
|
|
40
77
|
* @param {Object} pathTracer - The PathTracer stage
|
|
41
78
|
* @param {string} renderLimitMode
|
|
42
79
|
* @param {number} renderTimeLimit
|
|
@@ -46,7 +83,7 @@ export class CompletionTracker {
|
|
|
46
83
|
|
|
47
84
|
if ( ! pathTracer ) return false;
|
|
48
85
|
|
|
49
|
-
if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
|
|
86
|
+
if ( this.isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) ) return true;
|
|
50
87
|
|
|
51
88
|
// Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
|
|
52
89
|
// reconcile would flip isComplete back off and keep dispatching after the frame converged.
|
|
@@ -75,6 +112,8 @@ export class CompletionTracker {
|
|
|
75
112
|
this.timeElapsed = 0;
|
|
76
113
|
this.lastResetTime = performance.now();
|
|
77
114
|
this.renderCompleteDispatched = false;
|
|
115
|
+
this.budgetOverrun = false;
|
|
116
|
+
this._budgetHeldWarned = false;
|
|
78
117
|
|
|
79
118
|
}
|
|
80
119
|
|