rayzee 7.30.2 → 7.31.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 +113 -2
- package/dist/rayzee.es.js +588 -314
- package/dist/rayzee.umd.js +242 -17
- package/package.json +1 -1
- package/src/EngineDefaults.js +9 -1
- package/src/Passes/OIDNDenoiser.js +497 -347
- package/src/PathTracerApp.js +15 -2
- package/src/Processor/ToneMapWGSL.js +162 -0
- package/src/managers/DenoisingManager.js +251 -22
- package/src/managers/OverlayManager.js +4 -0
- package/src/managers/VideoRenderManager.js +5 -5
|
@@ -16,12 +16,10 @@ async function getInitUNetFromURL() {
|
|
|
16
16
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { effectiveExposure } from '../Processor/ToneMapCPU.js';
|
|
20
|
+
import { TONE_MAP_WGSL, toneMapMode } from '../Processor/ToneMapWGSL.js';
|
|
20
21
|
import { getAssetConfig } from '../AssetConfig.js';
|
|
21
22
|
|
|
22
|
-
/** Reusable RGB output buffer (avoids per-pixel allocation). */
|
|
23
|
-
const _tmOut = new Float32Array( 3 );
|
|
24
|
-
|
|
25
23
|
const MODEL_CONFIG = {
|
|
26
24
|
// No cleanAux flag in oidn-web, so the blob is it — and it must match what setCleanAuxNormal
|
|
27
25
|
// feeds the aux MRT: an `alb_nrm` model wants the point-sampled normal, a `calb_cnrm` model
|
|
@@ -39,7 +37,6 @@ const MODEL_CONFIG = {
|
|
|
39
37
|
DEFAULT_OPTIONS: {
|
|
40
38
|
enableOIDN: true,
|
|
41
39
|
oidnQuality: 'fast',
|
|
42
|
-
debugGbufferMaps: true,
|
|
43
40
|
// A cap, not a fixed size — the effective tile is min( max( w, h ), this ), so a frame
|
|
44
41
|
// that fits in one tile pays no overlap padding at all. Every tile otherwise runs at
|
|
45
42
|
// tileSize + 2*overlap (96px, 112 for _large): at 1024²/high, 4x512 tiles measured
|
|
@@ -52,10 +49,79 @@ const MODEL_CONFIG = {
|
|
|
52
49
|
// Stands in for OIDN's inputScale, which oidn-web only applies on its CPU path — the GPUBuffer
|
|
53
50
|
// path leaves the uniform at 1.0. Equivalent because oidn-web does PUForward(col*inputScale) in
|
|
54
51
|
// and PUInverse(..)/inputScale out, so pre-multiplying here and dividing it back out of the
|
|
55
|
-
// output exposure round-trips.
|
|
52
|
+
// output exposure round-trips.
|
|
53
|
+
//
|
|
54
|
+
// The scale is produced and consumed entirely on the GPU. Averaging it on the CPU meant copying
|
|
55
|
+
// the whole colour buffer back (16 MB at 1024²) and running a per-pixel log loop on the main
|
|
56
|
+
// thread, and the `await` on the map drained the queue before the UNet could start.
|
|
57
|
+
const OIDN_AUTOEXPOSURE_KEY = 0.18;
|
|
58
|
+
const LUM_WG_SIZE = 256;
|
|
59
|
+
const LUM_GROUPS = 256;
|
|
60
|
+
|
|
61
|
+
const INPUT_SCALE_WGSL = /* wgsl */`
|
|
62
|
+
const WG: u32 = ${LUM_WG_SIZE}u;
|
|
63
|
+
const GROUPS: u32 = ${LUM_GROUPS}u;
|
|
64
|
+
|
|
65
|
+
@group(0) @binding(0) var<storage, read> col: array<vec4<f32>>;
|
|
66
|
+
@group(0) @binding(1) var<storage, read_write> partials: array<f32>;
|
|
67
|
+
@group(0) @binding(2) var<uniform> params: vec4<u32>;
|
|
68
|
+
@group(0) @binding(3) var<storage, read_write> scaleOut: array<f32>;
|
|
69
|
+
|
|
70
|
+
var<workgroup> sdata: array<f32, WG>;
|
|
71
|
+
|
|
72
|
+
fn treeSum( lid: u32 ) {
|
|
73
|
+
var s: u32 = WG >> 1u;
|
|
74
|
+
loop {
|
|
75
|
+
if ( s == 0u ) { break; }
|
|
76
|
+
if ( lid < s ) { sdata[ lid ] = sdata[ lid ] + sdata[ lid + s ]; }
|
|
77
|
+
workgroupBarrier();
|
|
78
|
+
s = s >> 1u;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@compute @workgroup_size(${LUM_WG_SIZE})
|
|
83
|
+
fn reduce( @builtin(global_invocation_id) gid: vec3<u32>,
|
|
84
|
+
@builtin(local_invocation_id) lid: vec3<u32>,
|
|
85
|
+
@builtin(workgroup_id) wid: vec3<u32> ) {
|
|
86
|
+
|
|
87
|
+
let total = params.x;
|
|
88
|
+
var sum = 0.0;
|
|
89
|
+
var i = gid.x;
|
|
90
|
+
loop {
|
|
91
|
+
if ( i >= total ) { break; }
|
|
92
|
+
let c = col[ i ].xyz;
|
|
93
|
+
let lum = 0.212671 * c.r + 0.71516 * c.g + 0.072169 * c.b;
|
|
94
|
+
sum = sum + log2( max( lum, 0.0 ) + 0.0001 );
|
|
95
|
+
i = i + WG * GROUPS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sdata[ lid.x ] = sum;
|
|
99
|
+
workgroupBarrier();
|
|
100
|
+
treeSum( lid.x );
|
|
101
|
+
|
|
102
|
+
if ( lid.x == 0u ) { partials[ wid.x ] = sdata[ 0 ]; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@compute @workgroup_size(${LUM_WG_SIZE})
|
|
106
|
+
fn finalize( @builtin(local_invocation_id) lid: vec3<u32> ) {
|
|
107
|
+
|
|
108
|
+
var v = 0.0;
|
|
109
|
+
if ( lid.x < GROUPS ) { v = partials[ lid.x ]; }
|
|
110
|
+
sdata[ lid.x ] = v;
|
|
111
|
+
workgroupBarrier();
|
|
112
|
+
treeSum( lid.x );
|
|
113
|
+
|
|
114
|
+
if ( lid.x == 0u ) {
|
|
115
|
+
let scale = ${OIDN_AUTOEXPOSURE_KEY} / exp2( sdata[ 0 ] / f32( params.x ) );
|
|
116
|
+
// Mirrors the CPU guard: a non-finite or non-positive scale means no scaling at all.
|
|
117
|
+
scaleOut[ 0 ] = select( 1.0, scale, scale > 0.0 && scale < 3.4e38 );
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
|
|
56
122
|
const COLOR_SCALE_WGSL = /* wgsl */`
|
|
57
123
|
@group(0) @binding(0) var<storage, read_write> col: array<vec4<f32>>;
|
|
58
|
-
@group(0) @binding(1) var<
|
|
124
|
+
@group(0) @binding(1) var<storage, read> scaleBuf: array<f32>;
|
|
59
125
|
|
|
60
126
|
@compute @workgroup_size(64)
|
|
61
127
|
fn main( @builtin(global_invocation_id) gid: vec3<u32>,
|
|
@@ -63,7 +129,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32>,
|
|
|
63
129
|
let i = gid.y * nwg.x * 64u + gid.x;
|
|
64
130
|
if ( i >= arrayLength( &col ) ) { return; }
|
|
65
131
|
let c = col[ i ];
|
|
66
|
-
col[ i ] = vec4<f32>( c.xyz *
|
|
132
|
+
col[ i ] = vec4<f32>( c.xyz * scaleBuf[ 0 ], c.w );
|
|
67
133
|
}
|
|
68
134
|
`;
|
|
69
135
|
|
|
@@ -71,24 +137,51 @@ const SCALE_WG_SIZE = 64;
|
|
|
71
137
|
// 1D would exceed maxComputeWorkgroupsPerDimension (65535) past ~2048², so the dispatch is 2D.
|
|
72
138
|
const SCALE_MAX_WG_X = 32768;
|
|
73
139
|
|
|
74
|
-
//
|
|
75
|
-
|
|
140
|
+
// Denoised linear float -> sRGB bytes, on the GPU. The JS equivalent was a per-pixel loop with
|
|
141
|
+
// three pow() calls, and it froze the main thread for 42-51 ms per denoise at 1024².
|
|
142
|
+
const PACK_WG_SIZE = 16;
|
|
143
|
+
const PACK_PARAMS_BYTES = 48;
|
|
144
|
+
|
|
145
|
+
const PACK_WGSL = /* wgsl */`
|
|
146
|
+
${TONE_MAP_WGSL}
|
|
147
|
+
|
|
148
|
+
struct PackParams {
|
|
149
|
+
srcWidth: u32,
|
|
150
|
+
tileX: u32,
|
|
151
|
+
tileY: u32,
|
|
152
|
+
tileW: u32,
|
|
153
|
+
tileH: u32,
|
|
154
|
+
mode: u32,
|
|
155
|
+
useAlpha: u32,
|
|
156
|
+
pad0: u32,
|
|
157
|
+
exposure: f32,
|
|
158
|
+
saturation: f32,
|
|
159
|
+
pad1: vec2<f32>,
|
|
160
|
+
};
|
|
76
161
|
|
|
77
|
-
|
|
162
|
+
@group(0) @binding(0) var<storage, read> src: array<vec4<f32>>;
|
|
163
|
+
@group(0) @binding(1) var<storage, read_write> dst: array<u32>;
|
|
164
|
+
@group(0) @binding(2) var<uniform> P: PackParams;
|
|
165
|
+
@group(0) @binding(3) var<storage, read> scaleBuf: array<f32>;
|
|
166
|
+
@group(0) @binding(4) var<storage, read> inColor: array<vec4<f32>>;
|
|
78
167
|
|
|
79
|
-
|
|
80
|
-
|
|
168
|
+
@compute @workgroup_size(${PACK_WG_SIZE}, ${PACK_WG_SIZE})
|
|
169
|
+
fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
|
|
81
170
|
|
|
82
|
-
|
|
83
|
-
const lum = 0.212671 * f32[ o ] + 0.71516 * f32[ o + 1 ] + 0.072169 * f32[ o + 2 ];
|
|
84
|
-
logSum += Math.log2( Math.max( lum, 0 ) + 0.0001 );
|
|
171
|
+
if ( gid.x >= P.tileW || gid.y >= P.tileH ) { return; }
|
|
85
172
|
|
|
86
|
-
|
|
173
|
+
let si = ( P.tileY + gid.y ) * P.srcWidth + ( P.tileX + gid.x );
|
|
174
|
+
let di = gid.y * P.tileW + gid.x;
|
|
175
|
+
|
|
176
|
+
let rgb = toneMapPixel( src[ si ].xyz, P.exposure / scaleBuf[ 0 ], P.saturation, P.mode );
|
|
87
177
|
|
|
88
|
-
|
|
89
|
-
|
|
178
|
+
var a: u32 = 255u;
|
|
179
|
+
if ( P.useAlpha == 1u ) { a = u32( clamp( inColor[ si ].w, 0.0, 1.0 ) * 255.0 + 0.5 ); }
|
|
90
180
|
|
|
181
|
+
let q = vec3<u32>( clamp( rgb, vec3f( 0.0 ), vec3f( 1.0 ) ) * 255.0 + vec3f( 0.5 ) );
|
|
182
|
+
dst[ di ] = q.x | ( q.y << 8u ) | ( q.z << 16u ) | ( a << 24u );
|
|
91
183
|
}
|
|
184
|
+
`;
|
|
92
185
|
|
|
93
186
|
export class OIDNDenoiser extends EventDispatcher {
|
|
94
187
|
|
|
@@ -108,9 +201,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
108
201
|
this.camera = camera;
|
|
109
202
|
this.input = renderer.domElement;
|
|
110
203
|
this.output = output;
|
|
111
|
-
this.extractGBufferData = options.extractGBufferData || null;
|
|
112
|
-
this.getMRTRenderTarget = options.getMRTRenderTarget || null;
|
|
113
|
-
|
|
114
204
|
|
|
115
205
|
// WebGPU GPU-native path (no CPU readback for inputs)
|
|
116
206
|
// backendParams: () => { device: GPUDevice, adapterInfo: GPUAdapterInfo|null }
|
|
@@ -136,22 +226,19 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
136
226
|
// order guarantees the overwrites are serialized).
|
|
137
227
|
this._gpuInputPadBuffer = null;
|
|
138
228
|
this._gpuInputPaddedRowBytes = 0;
|
|
139
|
-
//
|
|
229
|
+
// The autoexposure scale never leaves the GPU — _applyColorScale and the output pack
|
|
230
|
+
// both read _inputScaleBuffer directly.
|
|
231
|
+
this._lumReducePipeline = null;
|
|
232
|
+
this._lumFinalizePipeline = null;
|
|
233
|
+
this._lumLayout = null;
|
|
234
|
+
this._lumBindGroup = null;
|
|
235
|
+
this._lumPartials = null;
|
|
236
|
+
this._lumParams = null;
|
|
237
|
+
this._inputScaleBuffer = null;
|
|
238
|
+
|
|
140
239
|
this._colorScalePipeline = null;
|
|
141
240
|
this._colorScaleBindGroup = null;
|
|
142
|
-
this.
|
|
143
|
-
this._oidnInputScale = 1.0;
|
|
144
|
-
// Pooled MAP_READ staging buffer for _cacheInputAlpha. Only allocated
|
|
145
|
-
// when transparent-background readback is used, destroyed on resolution
|
|
146
|
-
// change or dispose. Same spirit as r184's ReadbackBuffer — we can't use
|
|
147
|
-
// renderer.getArrayBufferAsync because the source is a raw GPUBuffer,
|
|
148
|
-
// not a Three.js BufferAttribute.
|
|
149
|
-
this._alphaReadbackBuffer = null;
|
|
150
|
-
this._alphaReadbackMapped = false;
|
|
151
|
-
|
|
152
|
-
// Cached alpha channel from the input color buffer (OIDN discards alpha)
|
|
153
|
-
this._cachedAlpha = null;
|
|
154
|
-
this._cachedAlphaWidth = 0;
|
|
241
|
+
this._packPipeline = null;
|
|
155
242
|
|
|
156
243
|
// Merge options with defaults
|
|
157
244
|
this.config = { ...MODEL_CONFIG.DEFAULT_OPTIONS, ...options };
|
|
@@ -159,7 +246,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
159
246
|
// Destructure for easier access
|
|
160
247
|
this.enabled = this.config.enableOIDN;
|
|
161
248
|
this.quality = this.config.oidnQuality;
|
|
162
|
-
this.debugGbufferMaps = this.config.debugGbufferMaps;
|
|
163
249
|
this.maxTileSize = this.config.tileSize;
|
|
164
250
|
// The size actually baked into the live UNet, so a change forces a rebuild.
|
|
165
251
|
this._activeTileSize = 0;
|
|
@@ -171,16 +257,28 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
171
257
|
abortController: null
|
|
172
258
|
};
|
|
173
259
|
|
|
174
|
-
// Track in-flight tile
|
|
260
|
+
// Track in-flight per-tile GPU buffers so they can be destroyed on abort
|
|
175
261
|
this._pendingStagingBuffers = new Set();
|
|
176
262
|
// Per-run tile-blit promises; done() awaits these so capture waits for every tile to paint.
|
|
177
263
|
this._pendingTileBlits = [];
|
|
178
264
|
|
|
265
|
+
// Identifies the live run. A run superseded by abort() must not clear the state its
|
|
266
|
+
// successor already owns.
|
|
267
|
+
this._runId = 0;
|
|
268
|
+
// The output canvas holds a valid denoised frame, so the next run must not repaint the
|
|
269
|
+
// noisy base over it.
|
|
270
|
+
this._hasLatchedFrame = false;
|
|
271
|
+
// Wall time of the last completed denoise, for cadence policy. 0 until one finishes.
|
|
272
|
+
this.lastDenoiseMs = 0;
|
|
273
|
+
|
|
179
274
|
this.currentTZAUrl = null;
|
|
180
275
|
this.unet = null;
|
|
181
276
|
// A start() requested while the UNet is still loading is deferred here and fired
|
|
182
277
|
// once loading finishes, instead of being silently dropped.
|
|
183
278
|
this._pendingStart = false;
|
|
279
|
+
this._pendingStartContinuous = false;
|
|
280
|
+
// A weight load requested while another is in flight, to run when that one finishes.
|
|
281
|
+
this._reloadPending = false;
|
|
184
282
|
|
|
185
283
|
// Initialize asynchronously
|
|
186
284
|
this._initialize().catch( error => {
|
|
@@ -297,79 +395,96 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
297
395
|
|
|
298
396
|
async _setupUNetDenoiser() {
|
|
299
397
|
|
|
300
|
-
if ( this.state.isLoading )
|
|
398
|
+
if ( this.state.isLoading ) {
|
|
301
399
|
|
|
302
|
-
|
|
303
|
-
|
|
400
|
+
// Dropping this would leave `quality` describing weights that were never fetched, and
|
|
401
|
+
// nothing would ever fetch them. Reachable on every render now that the refreshes and
|
|
402
|
+
// the finished image use different tiers. Flag it; the running load picks it up.
|
|
403
|
+
this._reloadPending = true;
|
|
404
|
+
return;
|
|
304
405
|
|
|
305
|
-
|
|
406
|
+
}
|
|
306
407
|
|
|
307
|
-
|
|
308
|
-
if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) {
|
|
408
|
+
do {
|
|
309
409
|
|
|
310
|
-
this.
|
|
311
|
-
|
|
410
|
+
this._reloadPending = false;
|
|
411
|
+
this.state.isLoading = true;
|
|
312
412
|
|
|
313
|
-
|
|
413
|
+
try {
|
|
314
414
|
|
|
315
|
-
|
|
415
|
+
await this._loadUNetWeights();
|
|
416
|
+
|
|
417
|
+
} catch ( error ) {
|
|
316
418
|
|
|
317
|
-
|
|
419
|
+
log.error( 'UNet weights failed to load:', error );
|
|
420
|
+
this.dispatchEvent( { type: 'error', error: new Error( `Denoiser loading failed: ${error.message}` ) } );
|
|
318
421
|
|
|
319
|
-
|
|
320
|
-
if ( this.unet ) {
|
|
422
|
+
} finally {
|
|
321
423
|
|
|
322
|
-
this.
|
|
323
|
-
this.unet = null;
|
|
424
|
+
this.state.isLoading = false;
|
|
324
425
|
|
|
325
426
|
}
|
|
326
427
|
|
|
327
|
-
|
|
328
|
-
// same device as the renderer — no second device, no CPU roundtrip for inputs.
|
|
329
|
-
let backendParams;
|
|
330
|
-
if ( this.isGPUMode && this.backendParamsGetter ) {
|
|
428
|
+
} while ( this._reloadPending );
|
|
331
429
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
430
|
+
// Fire a start() that arrived mid-load. Guard on unet+enabled so a failed load
|
|
431
|
+
// or a disable during loading doesn't kick off a denoise. Outside the loop, so a
|
|
432
|
+
// no-op reload still releases it — it used to be stranded by the early return below.
|
|
433
|
+
if ( this._pendingStart && this.unet && this.enabled ) {
|
|
335
434
|
|
|
336
|
-
|
|
435
|
+
const continuous = this._pendingStartContinuous;
|
|
436
|
+
this._pendingStart = false;
|
|
437
|
+
this._pendingStartContinuous = false;
|
|
438
|
+
this.start( { continuous } );
|
|
337
439
|
|
|
338
|
-
|
|
339
|
-
this.unet = await initFn( tzaUrl, backendParams, {
|
|
340
|
-
aux: true,
|
|
341
|
-
hdr: true,
|
|
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
|
|
346
|
-
} );
|
|
440
|
+
}
|
|
347
441
|
|
|
348
|
-
|
|
349
|
-
this._activeTileSize = tileSize;
|
|
350
|
-
this.dispatchEvent( { type: 'loaded' } );
|
|
351
|
-
log.debug( 'UNet weights loaded:', tzaUrl );
|
|
442
|
+
}
|
|
352
443
|
|
|
353
|
-
|
|
444
|
+
async _loadUNetWeights() {
|
|
445
|
+
|
|
446
|
+
const tzaUrl = this._generateTzaUrl();
|
|
447
|
+
const tileSize = this._resolveTileSize();
|
|
354
448
|
|
|
355
|
-
|
|
356
|
-
|
|
449
|
+
// maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
|
|
450
|
+
if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) return;
|
|
357
451
|
|
|
358
|
-
}
|
|
452
|
+
this.dispatchEvent( { type: 'loading', message: 'Loading UNet denoiser...' } );
|
|
359
453
|
|
|
360
|
-
|
|
454
|
+
// Dispose previous instance
|
|
455
|
+
if ( this.unet ) {
|
|
361
456
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if ( this._pendingStart && this.unet && this.enabled ) {
|
|
457
|
+
this.unet.dispose();
|
|
458
|
+
this.unet = null;
|
|
365
459
|
|
|
366
|
-
|
|
367
|
-
this.start();
|
|
460
|
+
}
|
|
368
461
|
|
|
369
|
-
|
|
462
|
+
// GPU-native path: share the existing GPUDevice so oidn-web uses the
|
|
463
|
+
// same device as the renderer — no second device, no CPU roundtrip for inputs.
|
|
464
|
+
let backendParams;
|
|
465
|
+
if ( this.isGPUMode && this.backendParamsGetter ) {
|
|
466
|
+
|
|
467
|
+
const params = this.backendParamsGetter();
|
|
468
|
+
this.gpuDevice = params?.device ?? null;
|
|
469
|
+
backendParams = params?.device ? params : undefined;
|
|
370
470
|
|
|
371
471
|
}
|
|
372
472
|
|
|
473
|
+
const initFn = await getInitUNetFromURL();
|
|
474
|
+
this.unet = await initFn( tzaUrl, backendParams, {
|
|
475
|
+
aux: true,
|
|
476
|
+
hdr: true,
|
|
477
|
+
maxTileSize: tileSize,
|
|
478
|
+
// Adaptive tiling ignores maxTileSize, starts at 384, and cannot see that overlap
|
|
479
|
+
// padding only vanishes once a tile covers the image — so it settles smaller.
|
|
480
|
+
dynamicTile: false
|
|
481
|
+
} );
|
|
482
|
+
|
|
483
|
+
this.currentTZAUrl = tzaUrl;
|
|
484
|
+
this._activeTileSize = tileSize;
|
|
485
|
+
this.dispatchEvent( { type: 'loaded' } );
|
|
486
|
+
log.debug( 'UNet weights loaded:', tzaUrl );
|
|
487
|
+
|
|
373
488
|
}
|
|
374
489
|
|
|
375
490
|
_generateTzaUrl() {
|
|
@@ -391,7 +506,6 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
391
506
|
// Update configuration
|
|
392
507
|
Object.assign( this.config, newConfig );
|
|
393
508
|
this.quality = this.config.oidnQuality;
|
|
394
|
-
this.debugGbufferMaps = this.config.debugGbufferMaps;
|
|
395
509
|
this.maxTileSize = this.config.tileSize;
|
|
396
510
|
|
|
397
511
|
// Reload denoiser if necessary
|
|
@@ -411,7 +525,13 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
411
525
|
|
|
412
526
|
}
|
|
413
527
|
|
|
414
|
-
|
|
528
|
+
/**
|
|
529
|
+
* @param {Object} [options]
|
|
530
|
+
* @param {boolean} [options.continuous=false] - A cadence run rather than the final denoise.
|
|
531
|
+
* Tagged onto the start/end events so consumers can tell a background refresh from the
|
|
532
|
+
* one-shot denoise that ends a render.
|
|
533
|
+
*/
|
|
534
|
+
async start( { continuous = false } = {} ) {
|
|
415
535
|
|
|
416
536
|
if ( ! this.enabled || this.state.isDenoising ) {
|
|
417
537
|
|
|
@@ -431,23 +551,30 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
431
551
|
// finally) rather than silently dropping this frame's denoise request.
|
|
432
552
|
if ( this.state.isLoading ) {
|
|
433
553
|
|
|
554
|
+
// A deferred cadence run must stay a cadence run, or it surfaces as the final
|
|
555
|
+
// denoise: tile border, status badge, upscaler chain. A non-cadence start joining
|
|
556
|
+
// the same deferral wins.
|
|
557
|
+
this._pendingStartContinuous = this._pendingStart
|
|
558
|
+
? this._pendingStartContinuous && continuous
|
|
559
|
+
: continuous;
|
|
434
560
|
this._pendingStart = true;
|
|
435
561
|
return false;
|
|
436
562
|
|
|
437
563
|
}
|
|
438
564
|
|
|
439
|
-
this.dispatchEvent( { type: 'start' } );
|
|
565
|
+
this.dispatchEvent( { type: 'start', continuous } );
|
|
440
566
|
|
|
441
567
|
const startTime = performance.now();
|
|
442
|
-
const success = await this.execute();
|
|
568
|
+
const success = await this.execute( continuous );
|
|
443
569
|
|
|
444
570
|
if ( success ) {
|
|
445
571
|
|
|
572
|
+
this._hasLatchedFrame = true;
|
|
446
573
|
this.renderer?.resetState?.();
|
|
447
574
|
this.input.style.opacity = '0';
|
|
448
575
|
|
|
449
|
-
|
|
450
|
-
log.debug( `denoise complete in ${fmt.ms(
|
|
576
|
+
this.lastDenoiseMs = performance.now() - startTime;
|
|
577
|
+
log.debug( `denoise complete in ${fmt.ms( this.lastDenoiseMs )} · quality ${this.quality}` );
|
|
451
578
|
|
|
452
579
|
}
|
|
453
580
|
|
|
@@ -464,17 +591,18 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
464
591
|
|
|
465
592
|
}
|
|
466
593
|
|
|
467
|
-
async execute() {
|
|
594
|
+
async execute( continuous = false ) {
|
|
468
595
|
|
|
469
596
|
if ( ! this.enabled || ! this.unet ) return false;
|
|
470
597
|
|
|
471
598
|
// Create abort controller for this execution
|
|
599
|
+
const runId = ++ this._runId;
|
|
472
600
|
this.state.abortController = new AbortController();
|
|
473
601
|
this.state.isDenoising = true;
|
|
474
602
|
|
|
475
603
|
try {
|
|
476
604
|
|
|
477
|
-
await this._executeUNet();
|
|
605
|
+
await this._executeUNet( continuous );
|
|
478
606
|
return true;
|
|
479
607
|
|
|
480
608
|
} catch ( error ) {
|
|
@@ -489,23 +617,30 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
489
617
|
|
|
490
618
|
}
|
|
491
619
|
|
|
492
|
-
// Restore original rendering on error
|
|
493
|
-
this.input.style.opacity = '1';
|
|
620
|
+
// Restore original rendering on error, unless a previous denoise is still on screen.
|
|
621
|
+
if ( ! this._hasLatchedFrame ) this.input.style.opacity = '1';
|
|
494
622
|
return false;
|
|
495
623
|
|
|
496
624
|
} finally {
|
|
497
625
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
this.
|
|
626
|
+
// An aborted run resolves after its replacement has already started; clearing the
|
|
627
|
+
// shared state then would strand the live run without an abort controller.
|
|
628
|
+
if ( this._runId === runId ) {
|
|
629
|
+
|
|
630
|
+
this.state.isDenoising = false;
|
|
631
|
+
this.state.abortController = null;
|
|
632
|
+
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.dispatchEvent( { type: 'end', continuous } );
|
|
501
636
|
|
|
502
637
|
}
|
|
503
638
|
|
|
504
639
|
}
|
|
505
640
|
|
|
506
|
-
async _executeUNet() {
|
|
641
|
+
async _executeUNet( continuous = false ) {
|
|
507
642
|
|
|
508
|
-
return this._executeUNetGPU();
|
|
643
|
+
return this._executeUNetGPU( continuous );
|
|
509
644
|
|
|
510
645
|
}
|
|
511
646
|
|
|
@@ -516,7 +651,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
516
651
|
*
|
|
517
652
|
* Note: oidn-web's GPUTexture input path produces NaN outputs — using GPUBuffer instead.
|
|
518
653
|
*/
|
|
519
|
-
async _executeUNetGPU() {
|
|
654
|
+
async _executeUNetGPU( continuous = false ) {
|
|
520
655
|
|
|
521
656
|
const { width, height } = this.output;
|
|
522
657
|
|
|
@@ -544,9 +679,16 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
544
679
|
}
|
|
545
680
|
|
|
546
681
|
// 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
|
-
|
|
549
|
-
|
|
682
|
+
// reads back non-empty straight after a compositor pass — hence the refresh. Skipped
|
|
683
|
+
// once a denoised frame is latched: repainting it on every cadence tick would flash
|
|
684
|
+
// the noisy image back over an already-clean one.
|
|
685
|
+
if ( ! this._hasLatchedFrame ) {
|
|
686
|
+
|
|
687
|
+
this.refreshInput?.();
|
|
688
|
+
this.ctx.drawImage( this.input, 0, 0, width, height );
|
|
689
|
+
|
|
690
|
+
}
|
|
691
|
+
|
|
550
692
|
this._revealOutput();
|
|
551
693
|
|
|
552
694
|
// Ensure storage buffers are sized correctly (recreate on resolution change)
|
|
@@ -602,9 +744,9 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
602
744
|
|
|
603
745
|
device.queue.submit( [ encoder.finish() ] );
|
|
604
746
|
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
747
|
+
// Autoexposure and the pre-multiply it drives, both on the GPU and both queued behind the
|
|
748
|
+
// copies above. Nothing is awaited here — an await would drain the queue before the UNet.
|
|
749
|
+
this._computeInputScale( device, width * height );
|
|
608
750
|
this._applyColorScale( device, width * height );
|
|
609
751
|
|
|
610
752
|
// Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
|
|
@@ -614,7 +756,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
614
756
|
normal: { data: this._gpuInputBuffers.normal, width, height }
|
|
615
757
|
};
|
|
616
758
|
|
|
617
|
-
return this._executeWithAbortGPU( config );
|
|
759
|
+
return this._executeWithAbortGPU( config, continuous );
|
|
618
760
|
|
|
619
761
|
}
|
|
620
762
|
|
|
@@ -668,113 +810,174 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
668
810
|
this._gpuInputBuffers.normal?.destroy();
|
|
669
811
|
this._gpuInputPadBuffer?.destroy();
|
|
670
812
|
|
|
671
|
-
//
|
|
672
|
-
// If mapAsync is still pending, destroy() will reject it — _cacheInputAlpha's
|
|
673
|
-
// catch handler covers that case.
|
|
674
|
-
if ( this._alphaReadbackMapped && this._alphaReadbackBuffer ) {
|
|
675
|
-
|
|
676
|
-
try {
|
|
677
|
-
|
|
678
|
-
this._alphaReadbackBuffer.unmap();
|
|
679
|
-
|
|
680
|
-
} catch { /* already unmapped or destroyed */ }
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
this._alphaReadbackBuffer?.destroy();
|
|
685
|
-
this._alphaReadbackMapped = false;
|
|
686
|
-
// Bind group holds the destroyed buffer; the pipeline is device-scoped and survives.
|
|
813
|
+
// Bind groups hold the destroyed buffers; pipelines are device-scoped and survive.
|
|
687
814
|
this._colorScaleBindGroup = null;
|
|
688
|
-
this.
|
|
689
|
-
this.
|
|
815
|
+
this._lumBindGroup = null;
|
|
816
|
+
this._lumPartials?.destroy();
|
|
817
|
+
this._lumPartials = null;
|
|
818
|
+
this._lumParams?.destroy();
|
|
819
|
+
this._lumParams = null;
|
|
820
|
+
this._inputScaleBuffer?.destroy();
|
|
821
|
+
this._inputScaleBuffer = null;
|
|
690
822
|
this._gpuInputBuffers = { color: null, albedo: null, normal: null };
|
|
691
823
|
this._gpuInputPadBuffer = null;
|
|
692
824
|
this._gpuInputPaddedRowBytes = 0;
|
|
693
|
-
this._alphaReadbackBuffer = null;
|
|
694
825
|
this._gpuInputBufferSize = { width: 0, height: 0 };
|
|
695
826
|
|
|
696
827
|
}
|
|
697
828
|
|
|
698
829
|
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
830
|
+
* Builds the autoexposure reduction, the colour pre-multiply and the output pack. All three
|
|
831
|
+
* share the scale buffer, so they are created together.
|
|
832
|
+
* @returns {boolean} whether the GPU path is usable
|
|
701
833
|
*/
|
|
702
|
-
|
|
834
|
+
_ensureScalePipelines( device ) {
|
|
835
|
+
|
|
836
|
+
const buffer = this._gpuInputBuffers.color;
|
|
837
|
+
if ( ! device || ! buffer ) return false;
|
|
703
838
|
|
|
704
|
-
|
|
839
|
+
if ( ! this._lumLayout ) {
|
|
705
840
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
841
|
+
// Explicit rather than 'auto': `finalize` does not reference `col`, and an auto
|
|
842
|
+
// layout would drop that binding and reject the shared bind group.
|
|
843
|
+
this._lumLayout = device.createBindGroupLayout( {
|
|
844
|
+
label: 'oidn-autoexposure',
|
|
845
|
+
entries: [
|
|
846
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
|
847
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
848
|
+
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
|
849
|
+
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }
|
|
850
|
+
]
|
|
851
|
+
} );
|
|
710
852
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
853
|
+
const module = device.createShaderModule( { label: 'oidn-autoexposure', code: INPUT_SCALE_WGSL } );
|
|
854
|
+
const layout = device.createPipelineLayout( { bindGroupLayouts: [ this._lumLayout ] } );
|
|
855
|
+
this._lumReducePipeline = device.createComputePipeline( {
|
|
856
|
+
label: 'oidn-autoexposure-reduce', layout, compute: { module, entryPoint: 'reduce' }
|
|
857
|
+
} );
|
|
858
|
+
this._lumFinalizePipeline = device.createComputePipeline( {
|
|
859
|
+
label: 'oidn-autoexposure-finalize', layout, compute: { module, entryPoint: 'finalize' }
|
|
715
860
|
} );
|
|
716
861
|
|
|
717
862
|
}
|
|
718
863
|
|
|
719
|
-
|
|
864
|
+
if ( ! this._colorScalePipeline ) {
|
|
720
865
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
866
|
+
this._colorScalePipeline = device.createComputePipeline( {
|
|
867
|
+
label: 'oidn-color-scale',
|
|
868
|
+
layout: 'auto',
|
|
869
|
+
compute: {
|
|
870
|
+
module: device.createShaderModule( { label: 'oidn-color-scale', code: COLOR_SCALE_WGSL } ),
|
|
871
|
+
entryPoint: 'main'
|
|
872
|
+
}
|
|
873
|
+
} );
|
|
724
874
|
|
|
725
|
-
|
|
726
|
-
try {
|
|
875
|
+
}
|
|
727
876
|
|
|
728
|
-
|
|
877
|
+
if ( ! this._packPipeline ) {
|
|
878
|
+
|
|
879
|
+
this._packPipeline = device.createComputePipeline( {
|
|
880
|
+
label: 'oidn-output-pack',
|
|
881
|
+
layout: 'auto',
|
|
882
|
+
compute: {
|
|
883
|
+
module: device.createShaderModule( { label: 'oidn-output-pack', code: PACK_WGSL } ),
|
|
884
|
+
entryPoint: 'main'
|
|
885
|
+
}
|
|
886
|
+
} );
|
|
887
|
+
|
|
888
|
+
}
|
|
729
889
|
|
|
730
|
-
|
|
890
|
+
if ( ! this._inputScaleBuffer ) {
|
|
731
891
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
892
|
+
this._inputScaleBuffer = device.createBuffer( {
|
|
893
|
+
label: 'oidn-input-scale',
|
|
894
|
+
// COPY_SRC is for debugging only: it lets the chosen exposure be read back.
|
|
895
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
896
|
+
size: 4
|
|
897
|
+
} );
|
|
898
|
+
this._lumPartials = device.createBuffer( {
|
|
899
|
+
label: 'oidn-autoexposure-partials',
|
|
900
|
+
size: LUM_GROUPS * 4,
|
|
901
|
+
usage: GPUBufferUsage.STORAGE
|
|
902
|
+
} );
|
|
903
|
+
this._lumParams = device.createBuffer( {
|
|
904
|
+
label: 'oidn-autoexposure-params',
|
|
905
|
+
size: 16,
|
|
906
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
907
|
+
} );
|
|
735
908
|
|
|
736
909
|
}
|
|
737
910
|
|
|
738
|
-
|
|
739
|
-
const pixelCount = width * height;
|
|
911
|
+
if ( ! this._lumBindGroup ) {
|
|
740
912
|
|
|
741
|
-
|
|
913
|
+
this._lumBindGroup = device.createBindGroup( {
|
|
914
|
+
label: 'oidn-autoexposure',
|
|
915
|
+
layout: this._lumLayout,
|
|
916
|
+
entries: [
|
|
917
|
+
{ binding: 0, resource: { buffer } },
|
|
918
|
+
{ binding: 1, resource: { buffer: this._lumPartials } },
|
|
919
|
+
{ binding: 2, resource: { buffer: this._lumParams } },
|
|
920
|
+
{ binding: 3, resource: { buffer: this._inputScaleBuffer } }
|
|
921
|
+
]
|
|
922
|
+
} );
|
|
742
923
|
|
|
743
|
-
|
|
744
|
-
if ( this.getTransparentBackground() ) {
|
|
924
|
+
}
|
|
745
925
|
|
|
746
|
-
|
|
747
|
-
alpha = new Uint8Array( pixelCount );
|
|
748
|
-
for ( let i = 0; i < pixelCount; i ++ ) {
|
|
926
|
+
if ( ! this._colorScaleBindGroup ) {
|
|
749
927
|
|
|
750
|
-
|
|
928
|
+
this._colorScaleBindGroup = device.createBindGroup( {
|
|
929
|
+
label: 'oidn-color-scale',
|
|
930
|
+
layout: this._colorScalePipeline.getBindGroupLayout( 0 ),
|
|
931
|
+
entries: [
|
|
932
|
+
{ binding: 0, resource: { buffer } },
|
|
933
|
+
{ binding: 1, resource: { buffer: this._inputScaleBuffer } }
|
|
934
|
+
]
|
|
935
|
+
} );
|
|
751
936
|
|
|
752
|
-
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return true;
|
|
940
|
+
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Writes OIDN's autoexposure scale into `_inputScaleBuffer`. Never read back. */
|
|
944
|
+
_computeInputScale( device, pixelCount ) {
|
|
945
|
+
|
|
946
|
+
if ( ! this._ensureScalePipelines( device ) ) return;
|
|
947
|
+
|
|
948
|
+
if ( globalThis.__OIDN_NO_INPUT_SCALE ) {
|
|
949
|
+
|
|
950
|
+
device.queue.writeBuffer( this._inputScaleBuffer, 0, new Float32Array( [ 1.0 ] ) );
|
|
951
|
+
return;
|
|
753
952
|
|
|
754
953
|
}
|
|
755
954
|
|
|
756
|
-
|
|
757
|
-
this._alphaReadbackMapped = false;
|
|
955
|
+
device.queue.writeBuffer( this._lumParams, 0, new Uint32Array( [ pixelCount, 0, 0, 0 ] ) );
|
|
758
956
|
|
|
759
|
-
|
|
760
|
-
this._cachedAlphaWidth = width;
|
|
957
|
+
const encoder = device.createCommandEncoder( { label: 'oidn-autoexposure' } );
|
|
761
958
|
|
|
762
|
-
|
|
959
|
+
// Two passes, not two dispatches in one: `finalize` consumes what `reduce` wrote, and
|
|
960
|
+
// pass boundaries are where WebGPU guarantees that ordering.
|
|
961
|
+
const reduce = encoder.beginComputePass( { label: 'oidn-autoexposure-reduce' } );
|
|
962
|
+
reduce.setPipeline( this._lumReducePipeline );
|
|
963
|
+
reduce.setBindGroup( 0, this._lumBindGroup );
|
|
964
|
+
reduce.dispatchWorkgroups( LUM_GROUPS, 1, 1 );
|
|
965
|
+
reduce.end();
|
|
763
966
|
|
|
764
|
-
|
|
765
|
-
|
|
967
|
+
const finalize = encoder.beginComputePass( { label: 'oidn-autoexposure-finalize' } );
|
|
968
|
+
finalize.setPipeline( this._lumFinalizePipeline );
|
|
969
|
+
finalize.setBindGroup( 0, this._lumBindGroup );
|
|
970
|
+
finalize.dispatchWorkgroups( 1, 1, 1 );
|
|
971
|
+
finalize.end();
|
|
766
972
|
|
|
767
|
-
|
|
973
|
+
device.queue.submit( [ encoder.finish() ] );
|
|
768
974
|
|
|
769
975
|
}
|
|
770
976
|
|
|
771
977
|
/** Pre-multiplies the colour buffer by the autoexposure scale. */
|
|
772
978
|
_applyColorScale( device, pixelCount ) {
|
|
773
979
|
|
|
774
|
-
|
|
775
|
-
if ( ! this._ensureColorScalePipeline( device ) ) return;
|
|
776
|
-
|
|
777
|
-
device.queue.writeBuffer( this._colorScaleUniform, 0, new Float32Array( [ scale ] ) );
|
|
980
|
+
if ( ! this._ensureScalePipelines( device ) ) return;
|
|
778
981
|
|
|
779
982
|
const wgTotal = Math.ceil( pixelCount / SCALE_WG_SIZE );
|
|
780
983
|
const wgX = Math.min( wgTotal, SCALE_MAX_WG_X );
|
|
@@ -790,49 +993,110 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
790
993
|
|
|
791
994
|
}
|
|
792
995
|
|
|
793
|
-
/**
|
|
794
|
-
|
|
996
|
+
/**
|
|
997
|
+
* Tone-maps a rectangle of a denoised linear buffer to sRGB bytes on the GPU and paints it
|
|
998
|
+
* onto the 2D output canvas.
|
|
999
|
+
*
|
|
1000
|
+
* The canvas stays 2D on purpose — `getCanvas()` hands it to screenshot and video capture,
|
|
1001
|
+
* and AIUpscaler draws into the same element. What moved to the GPU is the conversion, so
|
|
1002
|
+
* the readback carries 4 bytes per pixel instead of 16 and the main thread only memcpys.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {GPUBuffer} src - denoised rgba32float, full image
|
|
1005
|
+
* @param {number} srcWidth
|
|
1006
|
+
* @param {{x: number, y: number, width: number, height: number}} rect
|
|
1007
|
+
* @returns {Promise<void>}
|
|
1008
|
+
*/
|
|
1009
|
+
async _packAndBlit( src, srcWidth, rect ) {
|
|
795
1010
|
|
|
796
|
-
const
|
|
797
|
-
if ( ! device || !
|
|
1011
|
+
const device = this.gpuDevice;
|
|
1012
|
+
if ( ! device || ! this._ensureScalePipelines( device ) ) return;
|
|
798
1013
|
|
|
799
|
-
|
|
1014
|
+
const { x, y, width, height } = rect;
|
|
1015
|
+
const byteSize = width * height * 4;
|
|
800
1016
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1017
|
+
const dst = device.createBuffer( {
|
|
1018
|
+
label: 'oidn-pack-out',
|
|
1019
|
+
size: byteSize,
|
|
1020
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
|
1021
|
+
} );
|
|
1022
|
+
const staging = device.createBuffer( {
|
|
1023
|
+
label: 'oidn-pack-staging',
|
|
1024
|
+
size: byteSize,
|
|
1025
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
|
|
1026
|
+
} );
|
|
1027
|
+
const params = device.createBuffer( {
|
|
1028
|
+
label: 'oidn-pack-params',
|
|
1029
|
+
size: PACK_PARAMS_BYTES,
|
|
1030
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
1031
|
+
} );
|
|
809
1032
|
|
|
810
|
-
|
|
1033
|
+
this._pendingStagingBuffers.add( dst );
|
|
1034
|
+
this._pendingStagingBuffers.add( staging );
|
|
1035
|
+
this._pendingStagingBuffers.add( params );
|
|
811
1036
|
|
|
812
|
-
|
|
1037
|
+
const release = () => {
|
|
813
1038
|
|
|
814
|
-
|
|
815
|
-
label: 'oidn-color-scale-uniform',
|
|
816
|
-
size: 16, // min uniform binding size
|
|
817
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
818
|
-
} );
|
|
1039
|
+
for ( const b of [ dst, staging, params ] ) {
|
|
819
1040
|
|
|
820
|
-
|
|
1041
|
+
b.destroy();
|
|
1042
|
+
this._pendingStagingBuffers.delete( b );
|
|
821
1043
|
|
|
822
|
-
|
|
1044
|
+
}
|
|
823
1045
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
|
|
1050
|
+
const words = new ArrayBuffer( PACK_PARAMS_BYTES );
|
|
1051
|
+
new Uint32Array( words, 0, 8 ).set( [
|
|
1052
|
+
srcWidth, x, y, width, height,
|
|
1053
|
+
toneMapMode( this.getToneMapping() ),
|
|
1054
|
+
this.getTransparentBackground() ? 1 : 0,
|
|
1055
|
+
0
|
|
1056
|
+
] );
|
|
1057
|
+
new Float32Array( words, 32, 2 ).set( [
|
|
1058
|
+
effectiveExposure( this.getExposure(), this.getToneMapping() ),
|
|
1059
|
+
this.getSaturation()
|
|
1060
|
+
] );
|
|
1061
|
+
device.queue.writeBuffer( params, 0, words );
|
|
1062
|
+
|
|
1063
|
+
const bindGroup = device.createBindGroup( {
|
|
1064
|
+
label: 'oidn-output-pack',
|
|
1065
|
+
layout: this._packPipeline.getBindGroupLayout( 0 ),
|
|
827
1066
|
entries: [
|
|
828
|
-
{ binding: 0, resource: { buffer } },
|
|
829
|
-
{ binding: 1, resource: { buffer:
|
|
1067
|
+
{ binding: 0, resource: { buffer: src } },
|
|
1068
|
+
{ binding: 1, resource: { buffer: dst } },
|
|
1069
|
+
{ binding: 2, resource: { buffer: params } },
|
|
1070
|
+
{ binding: 3, resource: { buffer: this._inputScaleBuffer } },
|
|
1071
|
+
{ binding: 4, resource: { buffer: this._gpuInputBuffers.color } }
|
|
830
1072
|
]
|
|
831
1073
|
} );
|
|
832
1074
|
|
|
833
|
-
|
|
1075
|
+
const encoder = device.createCommandEncoder( { label: 'oidn-output-pack' } );
|
|
1076
|
+
const pass = encoder.beginComputePass( { label: 'oidn-output-pack' } );
|
|
1077
|
+
pass.setPipeline( this._packPipeline );
|
|
1078
|
+
pass.setBindGroup( 0, bindGroup );
|
|
1079
|
+
pass.dispatchWorkgroups(
|
|
1080
|
+
Math.ceil( width / PACK_WG_SIZE ),
|
|
1081
|
+
Math.ceil( height / PACK_WG_SIZE ),
|
|
1082
|
+
1
|
|
1083
|
+
);
|
|
1084
|
+
pass.end();
|
|
1085
|
+
encoder.copyBufferToBuffer( dst, 0, staging, 0, byteSize );
|
|
1086
|
+
device.queue.submit( [ encoder.finish() ] );
|
|
1087
|
+
|
|
1088
|
+
await staging.mapAsync( GPUMapMode.READ );
|
|
1089
|
+
// Copied, not viewed: unmap() detaches the mapped range and ImageData outlives it.
|
|
1090
|
+
const bytes = new Uint8ClampedArray( staging.getMappedRange().slice( 0 ) );
|
|
1091
|
+
staging.unmap();
|
|
834
1092
|
|
|
835
|
-
|
|
1093
|
+
this.ctx.putImageData( new ImageData( bytes, width, height ), x, y );
|
|
1094
|
+
|
|
1095
|
+
} finally {
|
|
1096
|
+
|
|
1097
|
+
release();
|
|
1098
|
+
|
|
1099
|
+
}
|
|
836
1100
|
|
|
837
1101
|
}
|
|
838
1102
|
|
|
@@ -840,7 +1104,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
840
1104
|
* Promise wrapper around tileExecute for the GPU path.
|
|
841
1105
|
* Outputs a GPUBuffer — copied to a staging buffer then converted to ImageData for the 2D canvas.
|
|
842
1106
|
*/
|
|
843
|
-
_executeWithAbortGPU( config ) {
|
|
1107
|
+
_executeWithAbortGPU( config, continuous = false ) {
|
|
844
1108
|
|
|
845
1109
|
return new Promise( ( resolve, reject ) => {
|
|
846
1110
|
|
|
@@ -909,106 +1173,32 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
909
1173
|
progress: ( outputData, _tileData, tile ) => {
|
|
910
1174
|
|
|
911
1175
|
// oidn-web GPU path: tileData is null, but outputData holds the assembled
|
|
912
|
-
// full-image buffer updated after each tile.
|
|
913
|
-
//
|
|
1176
|
+
// full-image buffer updated after each tile. The pack pass reads that tile's
|
|
1177
|
+
// rectangle straight out of it, so there are no row-by-row buffer copies.
|
|
914
1178
|
if ( ! outputData?.data || ! tile ) return;
|
|
915
1179
|
|
|
916
|
-
const device = this.gpuDevice;
|
|
917
1180
|
const fullWidth = outputData.width;
|
|
918
1181
|
const fullHeight = outputData.height;
|
|
919
|
-
const bytesPerPixel = 16; // rgba32float = 4 × float32
|
|
920
1182
|
|
|
921
1183
|
// Clamp tile to image bounds (edge tiles may extend past the image)
|
|
922
|
-
const
|
|
923
|
-
const
|
|
924
|
-
if (
|
|
925
|
-
|
|
926
|
-
const tileRowBytes = clampedW * bytesPerPixel;
|
|
927
|
-
const tileByteSize = clampedW * clampedH * bytesPerPixel;
|
|
928
|
-
|
|
929
|
-
const staging = device.createBuffer( {
|
|
930
|
-
size: tileByteSize,
|
|
931
|
-
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
|
|
932
|
-
} );
|
|
933
|
-
|
|
934
|
-
this._pendingStagingBuffers.add( staging );
|
|
935
|
-
|
|
936
|
-
// Copy each tile row from its position in the full output buffer
|
|
937
|
-
const enc = device.createCommandEncoder();
|
|
938
|
-
|
|
939
|
-
for ( let row = 0; row < clampedH; row ++ ) {
|
|
940
|
-
|
|
941
|
-
const srcOffset = ( ( tile.y + row ) * fullWidth + tile.x ) * bytesPerPixel;
|
|
942
|
-
const dstOffset = row * tileRowBytes;
|
|
943
|
-
enc.copyBufferToBuffer( outputData.data, srcOffset, staging, dstOffset, tileRowBytes );
|
|
944
|
-
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
device.queue.submit( [ enc.finish() ] );
|
|
948
|
-
|
|
949
|
-
// Map and blit asynchronously — GPU copy is already queued. Track the whole chain so
|
|
950
|
-
// done() can await every tile paint before resolving (replaces the full-frame readback).
|
|
951
|
-
const tileBlit = staging.mapAsync( GPUMapMode.READ ).then( () => {
|
|
952
|
-
|
|
953
|
-
const f32 = new Float32Array( staging.getMappedRange() );
|
|
954
|
-
const tileImageData = new ImageData( clampedW, clampedH );
|
|
955
|
-
const exposure = this._outputExposure();
|
|
956
|
-
const saturation = this.getSaturation();
|
|
957
|
-
const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
|
|
958
|
-
const alpha = this._cachedAlpha;
|
|
959
|
-
const alphaW = this._cachedAlphaWidth;
|
|
960
|
-
|
|
961
|
-
for ( let i = 0, len = f32.length; i < len; i += 4 ) {
|
|
962
|
-
|
|
963
|
-
// Exposure + saturation (pre-tonemap, matching Display)
|
|
964
|
-
let er = f32[ i ] * exposure, eg = f32[ i + 1 ] * exposure, eb = f32[ i + 2 ] * exposure;
|
|
965
|
-
if ( saturation !== 1.0 ) {
|
|
1184
|
+
const width = Math.min( tile.width, fullWidth - tile.x );
|
|
1185
|
+
const height = Math.min( tile.height, fullHeight - tile.y );
|
|
1186
|
+
if ( width <= 0 || height <= 0 ) return;
|
|
966
1187
|
|
|
967
|
-
|
|
968
|
-
applySaturation( _tmOut, saturation );
|
|
969
|
-
er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
|
|
1188
|
+
const rect = { x: tile.x, y: tile.y, width, height };
|
|
970
1189
|
|
|
971
|
-
|
|
1190
|
+
// Track the whole chain so done() can await every tile paint before resolving.
|
|
1191
|
+
const tileBlit = this._packAndBlit( outputData.data, fullWidth, rect ).then( () => {
|
|
972
1192
|
|
|
973
|
-
tmFn( er, eg, eb, 1.0, _tmOut );
|
|
974
|
-
tileImageData.data[ i ] = linearToSRGB( _tmOut[ 0 ] ) * 255 + 0.5 | 0;
|
|
975
|
-
tileImageData.data[ i + 1 ] = linearToSRGB( _tmOut[ 1 ] ) * 255 + 0.5 | 0;
|
|
976
|
-
tileImageData.data[ i + 2 ] = linearToSRGB( _tmOut[ 2 ] ) * 255 + 0.5 | 0;
|
|
977
|
-
|
|
978
|
-
if ( alpha ) {
|
|
979
|
-
|
|
980
|
-
const px = ( i >> 2 ) % clampedW;
|
|
981
|
-
const py = ( i >> 2 ) / clampedW | 0;
|
|
982
|
-
tileImageData.data[ i + 3 ] = alpha[ ( tile.y + py ) * alphaW + tile.x + px ];
|
|
983
|
-
|
|
984
|
-
} else {
|
|
985
|
-
|
|
986
|
-
tileImageData.data[ i + 3 ] = 255;
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
staging.unmap();
|
|
993
|
-
staging.destroy();
|
|
994
|
-
this._pendingStagingBuffers.delete( staging );
|
|
995
|
-
this.ctx.putImageData( tileImageData, tile.x, tile.y );
|
|
996
|
-
|
|
997
|
-
// Emit tile progress for OverlayManager's TileHelper
|
|
998
1193
|
this.dispatchEvent( {
|
|
999
1194
|
type: 'tileProgress',
|
|
1000
|
-
tile:
|
|
1195
|
+
tile: rect,
|
|
1001
1196
|
imageWidth: fullWidth,
|
|
1002
|
-
imageHeight: fullHeight
|
|
1197
|
+
imageHeight: fullHeight,
|
|
1198
|
+
continuous
|
|
1003
1199
|
} );
|
|
1004
1200
|
|
|
1005
|
-
} ).catch( () => {
|
|
1006
|
-
|
|
1007
|
-
// mapAsync rejected (abort or GPU lost) — destroy the buffer
|
|
1008
|
-
staging.destroy();
|
|
1009
|
-
this._pendingStagingBuffers.delete( staging );
|
|
1010
|
-
|
|
1011
|
-
} );
|
|
1201
|
+
} ).catch( () => { /* aborted or GPU lost — _packAndBlit already released */ } );
|
|
1012
1202
|
|
|
1013
1203
|
this._pendingTileBlits.push( tileBlit );
|
|
1014
1204
|
|
|
@@ -1020,73 +1210,19 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1020
1210
|
}
|
|
1021
1211
|
|
|
1022
1212
|
/**
|
|
1023
|
-
*
|
|
1024
|
-
* applies exposure * pow(4) + ACES filmic tonemap + sRGB gamma 2.2, then draws to the 2D canvas.
|
|
1213
|
+
* Degenerate fallback when no per-tile progress was emitted: one authoritative full paint.
|
|
1025
1214
|
* @param {{ data: GPUBuffer, width: number, height: number }} output
|
|
1026
1215
|
*/
|
|
1027
1216
|
async _displayGPUOutput( { data: gpuBuffer, width, height } ) {
|
|
1028
1217
|
|
|
1029
|
-
|
|
1030
|
-
if ( ! device ) {
|
|
1218
|
+
if ( ! this.gpuDevice ) {
|
|
1031
1219
|
|
|
1032
1220
|
log.error( 'gpuDevice not available for output readback' );
|
|
1033
1221
|
return;
|
|
1034
1222
|
|
|
1035
1223
|
}
|
|
1036
1224
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
// Staging buffer with MAP_READ so we can copy the output into it and read from CPU
|
|
1040
|
-
const stagingBuffer = device.createBuffer( {
|
|
1041
|
-
label: 'oidn-output-staging',
|
|
1042
|
-
size: byteSize,
|
|
1043
|
-
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
|
|
1044
|
-
} );
|
|
1045
|
-
|
|
1046
|
-
try {
|
|
1047
|
-
|
|
1048
|
-
// Queue a copy from the oidn output buffer (STORAGE|COPY_SRC) to staging
|
|
1049
|
-
const encoder = device.createCommandEncoder( { label: 'oidn-readback' } );
|
|
1050
|
-
encoder.copyBufferToBuffer( gpuBuffer, 0, stagingBuffer, 0, byteSize );
|
|
1051
|
-
device.queue.submit( [ encoder.finish() ] );
|
|
1052
|
-
|
|
1053
|
-
await stagingBuffer.mapAsync( GPUMapMode.READ );
|
|
1054
|
-
const float32 = new Float32Array( stagingBuffer.getMappedRange() );
|
|
1055
|
-
|
|
1056
|
-
const imageData = new ImageData( width, height );
|
|
1057
|
-
const exposure = this._outputExposure();
|
|
1058
|
-
const saturation = this.getSaturation();
|
|
1059
|
-
const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
|
|
1060
|
-
const alpha = this._cachedAlpha;
|
|
1061
|
-
|
|
1062
|
-
for ( let i = 0, len = float32.length; i < len; i += 4 ) {
|
|
1063
|
-
|
|
1064
|
-
// Exposure + saturation (pre-tonemap, matching Display)
|
|
1065
|
-
let er = float32[ i ] * exposure, eg = float32[ i + 1 ] * exposure, eb = float32[ i + 2 ] * exposure;
|
|
1066
|
-
if ( saturation !== 1.0 ) {
|
|
1067
|
-
|
|
1068
|
-
_tmOut[ 0 ] = er; _tmOut[ 1 ] = eg; _tmOut[ 2 ] = eb;
|
|
1069
|
-
applySaturation( _tmOut, saturation );
|
|
1070
|
-
er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
|
|
1071
|
-
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
tmFn( er, eg, eb, 1.0, _tmOut );
|
|
1075
|
-
imageData.data[ i ] = linearToSRGB( _tmOut[ 0 ] ) * 255 + 0.5 | 0;
|
|
1076
|
-
imageData.data[ i + 1 ] = linearToSRGB( _tmOut[ 1 ] ) * 255 + 0.5 | 0;
|
|
1077
|
-
imageData.data[ i + 2 ] = linearToSRGB( _tmOut[ 2 ] ) * 255 + 0.5 | 0;
|
|
1078
|
-
imageData.data[ i + 3 ] = alpha ? alpha[ i >> 2 ] : 255;
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
stagingBuffer.unmap();
|
|
1083
|
-
this.ctx.putImageData( imageData, 0, 0 );
|
|
1084
|
-
|
|
1085
|
-
} finally {
|
|
1086
|
-
|
|
1087
|
-
stagingBuffer.destroy();
|
|
1088
|
-
|
|
1089
|
-
}
|
|
1225
|
+
await this._packAndBlit( gpuBuffer, width, { x: 0, y: 0, width, height } );
|
|
1090
1226
|
|
|
1091
1227
|
}
|
|
1092
1228
|
|
|
@@ -1094,6 +1230,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1094
1230
|
|
|
1095
1231
|
// Cancel any start deferred during loading so a reset supersedes it.
|
|
1096
1232
|
this._pendingStart = false;
|
|
1233
|
+
this._pendingStartContinuous = false;
|
|
1097
1234
|
|
|
1098
1235
|
if ( ! this.enabled || ! this.state.isDenoising ) return;
|
|
1099
1236
|
|
|
@@ -1106,14 +1243,22 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1106
1243
|
// Restore input visibility
|
|
1107
1244
|
this.input.style.opacity = '1';
|
|
1108
1245
|
|
|
1109
|
-
//
|
|
1246
|
+
// No 'end' here: the aborted execute()'s finally always runs and dispatches one, tagged
|
|
1247
|
+
// with that run's `continuous`. Dispatching here too emitted a second, untagged end —
|
|
1248
|
+
// a cancelled cadence run announcing itself as the denoise that finishes a render.
|
|
1110
1249
|
this.state.isDenoising = false;
|
|
1111
|
-
this.dispatchEvent( { type: 'end' } );
|
|
1112
1250
|
|
|
1113
1251
|
log.debug( 'denoise aborted' );
|
|
1114
1252
|
|
|
1115
1253
|
}
|
|
1116
1254
|
|
|
1255
|
+
// Call whenever the output canvas stops holding a valid frame — hidden on reset, or resized.
|
|
1256
|
+
invalidateLatch() {
|
|
1257
|
+
|
|
1258
|
+
this._hasLatchedFrame = false;
|
|
1259
|
+
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1117
1262
|
setSize( width, height ) {
|
|
1118
1263
|
|
|
1119
1264
|
if ( width <= 0 || height <= 0 ) {
|
|
@@ -1124,6 +1269,7 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1124
1269
|
|
|
1125
1270
|
this.output.width = width;
|
|
1126
1271
|
this.output.height = height;
|
|
1272
|
+
this._hasLatchedFrame = false;
|
|
1127
1273
|
|
|
1128
1274
|
// Reinitialize denoiser if tile size changes relative to image size
|
|
1129
1275
|
this._setupUNetDenoiser().catch( error => {
|
|
@@ -1158,6 +1304,10 @@ export class OIDNDenoiser extends EventDispatcher {
|
|
|
1158
1304
|
this.unet?.dispose();
|
|
1159
1305
|
this._destroyGPUInputBuffers();
|
|
1160
1306
|
this._colorScalePipeline = null;
|
|
1307
|
+
this._lumReducePipeline = null;
|
|
1308
|
+
this._lumFinalizePipeline = null;
|
|
1309
|
+
this._lumLayout = null;
|
|
1310
|
+
this._packPipeline = null;
|
|
1161
1311
|
|
|
1162
1312
|
// Clean up DOM
|
|
1163
1313
|
if ( this.output?.parentNode ) {
|