rayzee 11.3.0 → 11.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/dist/{ArchiveImporter-D735xX-4.js → ArchiveImporter-29Nl1567.js} +2 -2
- package/dist/{BidirectionalIntegrator-M89c0AlP.js → BidirectionalIntegrator-CJay8tXK.js} +2 -2
- package/dist/{NeuralRetouch-DiBH63kz.js → NeuralRetouch-KqGVPV7u.js} +4 -3
- package/dist/{NeuralSuperRes-CZNQP13W.js → NeuralSuperRes-BHxhYzlD.js} +11 -5
- package/dist/{PathTracer-BXCCVrPK.js → PathTracer-Cldt0mXi.js} +19 -12
- package/dist/{PhysicalSky-DqmaS0lb.js → PhysicalSky-CUWrTcmT.js} +1 -1
- package/dist/{RayzeeRenderer-DUSMFhmc.js → RayzeeRenderer-Dd-Dit0v.js} +313 -281
- package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-sAs8wdgI.js} +44 -22
- package/dist/addons/archives.es.js +1 -1
- package/dist/addons/bidirectional.es.js +1 -1
- package/dist/addons/physical-sky.es.js +1 -1
- package/dist/rayzee-core.es.js +3 -3
- package/dist/rayzee.es.js +1428 -902
- package/dist/rayzee.umd.js +124 -113
- package/dist/{sceneCachePolicy-BAib5fCp.js → sceneCachePolicy--ta6moJu.js} +1 -1
- package/package.json +1 -1
- package/src/EngineDefaults.js +2 -2
- package/src/Passes/AIUpscaler.js +17 -12
- package/src/PathTracerApp.js +122 -16
- package/src/Pipeline/CompletionTracker.js +7 -3
- package/src/Processor/ToneMapCPU.js +16 -4
- package/src/Processor/ToneMapGPU.js +49 -14
- package/src/RayzeeRenderer.js +54 -7
- package/src/RenderSettings.js +4 -2
- package/src/Stages/AutoExposure.js +484 -494
- package/src/Stages/Compositor.js +27 -6
- package/src/Stages/LocalExposure.js +569 -0
- package/src/Stages/PathTracer.js +6 -4
- package/src/Stages/PathTracerStage.js +9 -7
- package/src/index.js +1 -0
- package/src/managers/DenoisingManager.js +93 -34
- package/src/managers/VideoRenderManager.js +30 -4
- package/src/neural/NeuralRetouch.js +1 -0
- package/src/neural/NeuralSuperRes.js +6 -1
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -24,8 +24,8 @@ export const ENGINE_DEFAULTS = deepFreeze( {
|
|
|
24
24
|
|
|
25
25
|
// Sampling and convergence
|
|
26
26
|
maxSamples: 60,
|
|
27
|
-
renderLimitMode: 'frames',
|
|
28
|
-
renderTimeLimit: 30,
|
|
27
|
+
renderLimitMode: 'frames', // 'frames' | 'time' (ceiling or deadline, whichever first) | 'timeOnly' (deadline lifts the ceiling)
|
|
28
|
+
renderTimeLimit: 30, // seconds; read in 'time' and 'timeOnly'
|
|
29
29
|
renderMode: 0, // 0 preview, 1 production; configureForMode() sets it
|
|
30
30
|
useAdaptiveSampling: true,
|
|
31
31
|
noiseThreshold: 0.1, // a pixel under this √-luminance noise has converged; the interactive tier's value
|
package/src/Passes/AIUpscaler.js
CHANGED
|
@@ -100,6 +100,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
100
100
|
this.getExposure = options.getExposure || ( () => 1.0 );
|
|
101
101
|
this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
|
|
102
102
|
this.getSaturation = options.getSaturation || ( () => 1.0 );
|
|
103
|
+
this.getDisplayGain = options.getDisplayGain || ( () => null );
|
|
103
104
|
|
|
104
105
|
// Configuration
|
|
105
106
|
this.enabled = false;
|
|
@@ -122,7 +123,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
122
123
|
this._worker = null;
|
|
123
124
|
this._currentModelUrl = null;
|
|
124
125
|
this._tileId = 0;
|
|
125
|
-
this._pendingWorkerHandlers = new
|
|
126
|
+
this._pendingWorkerHandlers = new Map();
|
|
126
127
|
|
|
127
128
|
// Alpha channel cache (bilinear-upscaled from source, applied per tile)
|
|
128
129
|
this._upscaledAlpha = null;
|
|
@@ -226,7 +227,8 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
226
227
|
|
|
227
228
|
// ─── Public Lifecycle ─────────────────────────────────────────────────────
|
|
228
229
|
|
|
229
|
-
|
|
230
|
+
/** @param {ImageData} [source] - what to enlarge instead of the render canvas */
|
|
231
|
+
async start( source = null ) {
|
|
230
232
|
|
|
231
233
|
if ( ! this.enabled || this.state.isUpscaling || this.state.isLoading ) {
|
|
232
234
|
|
|
@@ -237,7 +239,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
237
239
|
this.dispatchEvent( { type: 'start' } );
|
|
238
240
|
|
|
239
241
|
const startTime = performance.now();
|
|
240
|
-
const success = await this.execute();
|
|
242
|
+
const success = await this.execute( source );
|
|
241
243
|
|
|
242
244
|
if ( success ) {
|
|
243
245
|
|
|
@@ -259,7 +261,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
259
261
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
async execute() {
|
|
264
|
+
async execute( source = null ) {
|
|
263
265
|
|
|
264
266
|
if ( ! this.enabled ) return false;
|
|
265
267
|
|
|
@@ -269,7 +271,11 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
269
271
|
// Capture source image SYNCHRONOUSLY before any async work.
|
|
270
272
|
// WebGPU canvas textures expire after each compositor frame,
|
|
271
273
|
// so we must grab the pixels before awaiting model load.
|
|
272
|
-
if (
|
|
274
|
+
if ( source ) {
|
|
275
|
+
|
|
276
|
+
this._capturedSource = source;
|
|
277
|
+
|
|
278
|
+
} else if ( this.hdr && this.getGPUTextures ) {
|
|
273
279
|
|
|
274
280
|
// HDR path: float planes tone-mapped on the GPU, no 8-bit step before the network
|
|
275
281
|
this._capturedSource = await this._captureSourceHDR();
|
|
@@ -497,6 +503,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
497
503
|
exposure: this.getExposure(),
|
|
498
504
|
toneMapping: this.getToneMapping() ?? ACESFilmicToneMapping,
|
|
499
505
|
saturation: this.getSaturation(),
|
|
506
|
+
gain: this.getDisplayGain(),
|
|
500
507
|
} );
|
|
501
508
|
this._toneMapper.release();
|
|
502
509
|
|
|
@@ -584,7 +591,7 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
584
591
|
|
|
585
592
|
};
|
|
586
593
|
|
|
587
|
-
this._pendingWorkerHandlers.
|
|
594
|
+
this._pendingWorkerHandlers.set( handler, reject );
|
|
588
595
|
this._worker.addEventListener( 'message', handler );
|
|
589
596
|
this._worker.postMessage(
|
|
590
597
|
{ type: 'infer', tileData, width, height, id },
|
|
@@ -812,15 +819,13 @@ export class AIUpscaler extends EventDispatcher {
|
|
|
812
819
|
|
|
813
820
|
// ─── Disposal ─────────────────────────────────────────────────────────────
|
|
814
821
|
|
|
822
|
+
// Rejected, not dropped: an aborted run has to end before the next one starts.
|
|
815
823
|
_cleanupPendingWorkerHandlers() {
|
|
816
824
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
for ( const handler of this._pendingWorkerHandlers ) {
|
|
825
|
+
for ( const [ handler, reject ] of this._pendingWorkerHandlers ) {
|
|
820
826
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
827
|
+
this._worker?.removeEventListener( 'message', handler );
|
|
828
|
+
reject( new DOMException( 'Aborted', 'AbortError' ) );
|
|
824
829
|
|
|
825
830
|
}
|
|
826
831
|
|
package/src/PathTracerApp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Variance } from './Stages/Variance.js';
|
|
|
8
8
|
import { BilateralFilter } from './Stages/BilateralFilter.js';
|
|
9
9
|
import { EdgeFilter } from './Stages/EdgeFilter.js';
|
|
10
10
|
import { AutoExposure, AUTO_EXPOSURE_DEFAULTS } from './Stages/AutoExposure.js';
|
|
11
|
+
import { LocalExposure, LOCAL_EXPOSURE_DEFAULTS } from './Stages/LocalExposure.js';
|
|
11
12
|
import { PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings } from './EngineDefaults.js';
|
|
12
13
|
import { createLogger } from './utils/Logger.js';
|
|
13
14
|
import { EngineEvents } from './EngineEvents.js';
|
|
@@ -156,10 +157,11 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
156
157
|
stages.bilateralFilter = new BilateralFilter( renderer, { enabled: false } );
|
|
157
158
|
stages.edgeFilter = new EdgeFilter( renderer, { enabled: false } );
|
|
158
159
|
stages.autoExposure = new AutoExposure( renderer, { enabled: AUTO_EXPOSURE_DEFAULTS.autoExposure } );
|
|
160
|
+
stages.localExposure = new LocalExposure( renderer, { enabled: LOCAL_EXPOSURE_DEFAULTS.localExposure } );
|
|
159
161
|
|
|
160
162
|
return [
|
|
161
163
|
stages.normalDepth, stages.motionVector, stages.nrd, stages.asvgf,
|
|
162
|
-
stages.variance, stages.bilateralFilter, stages.edgeFilter, stages.autoExposure,
|
|
164
|
+
stages.variance, stages.bilateralFilter, stages.edgeFilter, stages.autoExposure, stages.localExposure,
|
|
163
165
|
];
|
|
164
166
|
|
|
165
167
|
}
|
|
@@ -325,10 +327,11 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
325
327
|
const core = super._settingsBindings();
|
|
326
328
|
return {
|
|
327
329
|
...core,
|
|
328
|
-
//
|
|
330
|
+
// the manual exposure, and the compensation on top of auto exposure while that is on
|
|
329
331
|
applyExposure: ( value ) => {
|
|
330
332
|
|
|
331
|
-
if (
|
|
333
|
+
if ( this.stages.autoExposure ) this.stages.autoExposure.setCompensation( value );
|
|
334
|
+
else core.applyExposure( value );
|
|
332
335
|
|
|
333
336
|
},
|
|
334
337
|
onCameraProjection: ( value ) => {
|
|
@@ -351,6 +354,7 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
351
354
|
|
|
352
355
|
this.timeline.update();
|
|
353
356
|
this.cameraManager.updateControls();
|
|
357
|
+
this._updateExposure();
|
|
354
358
|
|
|
355
359
|
this._applyPendingRenderScale();
|
|
356
360
|
|
|
@@ -406,6 +410,8 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
406
410
|
// Render completion → denoise/upscale chain
|
|
407
411
|
_renderCompleted() {
|
|
408
412
|
|
|
413
|
+
// _finishImage() landed the exposure just before, with the loop already asleep: draw it.
|
|
414
|
+
if ( this.stages.autoExposure?.enabled ) this._refreshFinished();
|
|
409
415
|
this.denoisingManager.onRenderComplete( {
|
|
410
416
|
isStillComplete: () => this.completion.renderCompleteDispatched,
|
|
411
417
|
context: this.pipeline?.context,
|
|
@@ -413,6 +419,65 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
413
419
|
|
|
414
420
|
}
|
|
415
421
|
|
|
422
|
+
_settling() {
|
|
423
|
+
|
|
424
|
+
return !! this.stages.autoExposure?.settling;
|
|
425
|
+
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_displayGain() {
|
|
429
|
+
|
|
430
|
+
const le = this.stages.localExposure;
|
|
431
|
+
// A readback outside the loop (headless) can come before the loop would have built it from this image.
|
|
432
|
+
if ( le?.wantsBuild ) le.build( this.pipeline.context );
|
|
433
|
+
return le?.toneGain() ?? null;
|
|
434
|
+
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Local exposure (Unreal Engine 5's): scales the contrast of large bright and dark areas around middle grey
|
|
439
|
+
* while keeping detail, on the canvas and every tone-mapped readback alike.
|
|
440
|
+
* @param {boolean} enabled
|
|
441
|
+
*/
|
|
442
|
+
setLocalExposure( enabled ) {
|
|
443
|
+
|
|
444
|
+
const le = this.stages.localExposure;
|
|
445
|
+
if ( ! le ) return;
|
|
446
|
+
le.setEnabled( enabled );
|
|
447
|
+
this.stages.compositor.setDisplayGain( enabled ? ( rgb, uv ) => le.gainNode( rgb, uv ) : null );
|
|
448
|
+
this.refreshFrame();
|
|
449
|
+
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @param {{highlightContrast?: number, shadowContrast?: number, detailStrength?: number}} params - 1 changes nothing */
|
|
453
|
+
setLocalExposureParams( params ) {
|
|
454
|
+
|
|
455
|
+
this.stages.localExposure?.updateParameters( params );
|
|
456
|
+
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A finished render is exposed for its own image: one reading of it, landed on.
|
|
460
|
+
_finishImage() {
|
|
461
|
+
|
|
462
|
+
const ae = this.stages.autoExposure;
|
|
463
|
+
if ( ! ae?.enabled ) return null;
|
|
464
|
+
return ae.meter( this.pipeline.context ).then( () => ae.advance( Infinity ) );
|
|
465
|
+
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The core's, plus the display-only exposure an sRGB readback was shaped by, which no setting records. */
|
|
469
|
+
getProvenance() {
|
|
470
|
+
|
|
471
|
+
const provenance = super.getProvenance();
|
|
472
|
+
const ae = this.stages.autoExposure, le = this.stages.localExposure;
|
|
473
|
+
provenance.exposure = {
|
|
474
|
+
auto: ae?.enabled ? { exposure: ae.getExposure(), strength: ae.strength, metering: ae.metering, keyValue: ae.keyValue } : null,
|
|
475
|
+
local: le?.enabled ? { highlightContrast: le.highlightContrast, shadowContrast: le.shadowContrast, detailStrength: le.detailStrength } : null,
|
|
476
|
+
};
|
|
477
|
+
return provenance;
|
|
478
|
+
|
|
479
|
+
}
|
|
480
|
+
|
|
416
481
|
_renderHelperOverlay() {
|
|
417
482
|
|
|
418
483
|
super._renderHelperOverlay();
|
|
@@ -550,7 +615,7 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
550
615
|
if ( enabled ) {
|
|
551
616
|
|
|
552
617
|
this.cameraManager?.setAutoFocusMode( 'manual' );
|
|
553
|
-
|
|
618
|
+
this.stages.autoExposure?.setEnabled( false );
|
|
554
619
|
// Cadence denoising is wall-clock driven, so which frame it lands on is not reproducible.
|
|
555
620
|
this.denoisingManager?.setContinuousDenoise( false );
|
|
556
621
|
return;
|
|
@@ -558,11 +623,7 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
558
623
|
}
|
|
559
624
|
|
|
560
625
|
if ( snapshot.autoFocusMode !== undefined ) this.cameraManager?.setAutoFocusMode( snapshot.autoFocusMode );
|
|
561
|
-
if ( this.stages.autoExposure
|
|
562
|
-
|
|
563
|
-
this.stages.autoExposure.enabled = snapshot.autoExposure;
|
|
564
|
-
|
|
565
|
-
}
|
|
626
|
+
if ( snapshot.autoExposure !== undefined ) this.stages.autoExposure?.setEnabled( snapshot.autoExposure );
|
|
566
627
|
|
|
567
628
|
if ( snapshot.continuousDenoise !== undefined ) {
|
|
568
629
|
|
|
@@ -839,6 +900,9 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
839
900
|
|
|
840
901
|
this.timeline.stop();
|
|
841
902
|
this.cameraManager.controls.enabled = ! isProduction;
|
|
903
|
+
// A final render keeps the exposure it starts with through its first, noisiest samples (20 bounces: a 0.4-stop
|
|
904
|
+
// dip and back within 0.2 s at 2–8 samples), then lands as any still image does.
|
|
905
|
+
if ( this.stages.autoExposure ) this.stages.autoExposure.holdSamples = isProduction ? 8 : 0;
|
|
842
906
|
|
|
843
907
|
// Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
|
|
844
908
|
// `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
|
|
@@ -1053,6 +1117,7 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
1053
1117
|
pipeline: this.pipeline,
|
|
1054
1118
|
getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
|
|
1055
1119
|
getSaturation: () => this.settings.get( 'saturation' ) ?? 1.0,
|
|
1120
|
+
getDisplayGain: () => this._displayGain(),
|
|
1056
1121
|
issues: this._issues,
|
|
1057
1122
|
} );
|
|
1058
1123
|
|
|
@@ -1072,20 +1137,61 @@ export class PathTracerApp extends RayzeeRenderer {
|
|
|
1072
1137
|
|
|
1073
1138
|
_setupAutoExposureListener() {
|
|
1074
1139
|
|
|
1075
|
-
|
|
1140
|
+
const ae = this.stages.autoExposure;
|
|
1141
|
+
if ( ! ae ) return;
|
|
1076
1142
|
|
|
1077
|
-
this.
|
|
1143
|
+
ae.setCompensation( this.settings.get( 'exposure' ) ?? 1 );
|
|
1144
|
+
if ( this.stages.localExposure ) this.stages.localExposure.onChange = () => this._refreshFinished();
|
|
1145
|
+
ae.onChange = () => this._refreshFinished();
|
|
1078
1146
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1147
|
+
// Another camera is a cut: meter it afresh.
|
|
1148
|
+
this._addTrackedListener( this.cameraManager, EngineEvents.CAMERA_SWITCHED, () => ae.resetHistory() );
|
|
1149
|
+
|
|
1150
|
+
ae.on( 'autoexposure:updated', ( { exposure, autoExposure, targetExposure, luminance } ) => {
|
|
1151
|
+
|
|
1152
|
+
this.dispatchEvent( { type: EngineEvents.AUTO_EXPOSURE_UPDATED, exposure, autoExposure, targetExposure, luminance } );
|
|
1084
1153
|
|
|
1085
1154
|
} );
|
|
1086
1155
|
|
|
1087
1156
|
}
|
|
1088
1157
|
|
|
1158
|
+
// Only a finished render's idle loop is woken: renderFrames() and a video export drive frames themselves.
|
|
1159
|
+
_refreshFinished() {
|
|
1160
|
+
|
|
1161
|
+
if ( ! this.completion.renderCompleteDispatched ) return;
|
|
1162
|
+
this._needsDisplayRefresh = true;
|
|
1163
|
+
this.wake();
|
|
1164
|
+
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Every loop frame, a finished render included: builds local exposure and meters a finished image when asked, adapts.
|
|
1168
|
+
_updateExposure() {
|
|
1169
|
+
|
|
1170
|
+
const le = this.stages.localExposure;
|
|
1171
|
+
if ( le?.wantsBuild && this.completion.renderCompleteDispatched ) le.build( this.pipeline.context );
|
|
1172
|
+
|
|
1173
|
+
const ae = this.stages.autoExposure;
|
|
1174
|
+
if ( ! ae?.enabled ) return;
|
|
1175
|
+
|
|
1176
|
+
this._noteExposureView();
|
|
1177
|
+
const af = this.cameraManager.afScreenPoint;
|
|
1178
|
+
if ( ae.metering === 'spot' && ( ae.meteringPoint.x !== af.x || ae.meteringPoint.y !== af.y ) ) ae.updateParameters( { meteringPoint: af } );
|
|
1179
|
+
if ( ae.wantsMetering && this.completion.renderCompleteDispatched ) ae.meter( this.pipeline.context );
|
|
1180
|
+
if ( ae.update() ) this._needsDisplayRefresh = true;
|
|
1181
|
+
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Whatever moved the camera (controls, walk, timeline, a video export), auto exposure learns of it here.
|
|
1185
|
+
_noteExposureView() {
|
|
1186
|
+
|
|
1187
|
+
const { matrixWorld, projectionMatrix } = this.camera;
|
|
1188
|
+
const last = this._exposureView;
|
|
1189
|
+
if ( last?.view.equals( matrixWorld ) && last.projection.equals( projectionMatrix ) ) return;
|
|
1190
|
+
this._exposureView = { view: matrixWorld.clone(), projection: projectionMatrix.clone() };
|
|
1191
|
+
if ( last ) this.stages.autoExposure?.noteViewChanged();
|
|
1192
|
+
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1089
1195
|
async _setupOverlayManager() {
|
|
1090
1196
|
|
|
1091
1197
|
this.overlayManager = new OverlayManager( this.cameraManager.camera );
|
|
@@ -33,20 +33,24 @@ export class CompletionTracker {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Checks whether the wall-clock render budget has expired. An additional stop condition,
|
|
36
|
-
* never a replacement —
|
|
36
|
+
* never a replacement — convergence still binds when it comes first, and in 'time' mode the
|
|
37
|
+
* sample ceiling too ('timeOnly' lifts it: PathTracerStage.updateCompletionThreshold).
|
|
37
38
|
*
|
|
38
39
|
* Floored at one sample: frameCount does not advance while the stage compiles kernels or
|
|
39
40
|
* while the camera is in interaction mode, and retiring in that window denoises an empty
|
|
40
41
|
* buffer and captures a blank canvas.
|
|
41
42
|
*
|
|
42
43
|
* @param {Object} pathTracer - The PathTracer stage
|
|
43
|
-
* @param {string} renderLimitMode - 'time' or '
|
|
44
|
+
* @param {string} renderLimitMode - 'frames', 'time' or 'timeOnly'
|
|
44
45
|
* @param {number} renderTimeLimit - Time limit in seconds
|
|
45
46
|
* @returns {boolean}
|
|
46
47
|
*/
|
|
47
48
|
isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// Decided afresh on every check: a raised limit or a switch to 'frames' reopens a render the deadline retired.
|
|
51
|
+
this.budgetOverrun = false;
|
|
52
|
+
|
|
53
|
+
if ( ( renderLimitMode !== 'time' && renderLimitMode !== 'timeOnly' ) || ! ( renderTimeLimit > 0 ) ) return false;
|
|
50
54
|
if ( this.timeElapsed < renderTimeLimit ) return false;
|
|
51
55
|
|
|
52
56
|
if ( ! ( pathTracer?.frameCount > 0 ) ) {
|
|
@@ -128,21 +128,33 @@ export function applySaturation( out, saturation ) {
|
|
|
128
128
|
* @param {number} options.toneMapping - a registered view transform id
|
|
129
129
|
* @param {number} [options.saturation=1]
|
|
130
130
|
* @param {boolean} [options.preserveAlpha=false]
|
|
131
|
+
* @param {{fn: function(number, number, number, number): number, width: number, height: number}} [options.pixelGain] -
|
|
132
|
+
* the twin of the GPU pass's `gain`: `fn( u, v, luminance, log2 exposure )` per pixel, before the curve
|
|
131
133
|
* @returns {Uint8ClampedArray} RGBA bytes
|
|
132
134
|
*/
|
|
133
|
-
export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1, preserveAlpha = false } ) {
|
|
135
|
+
export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1, preserveAlpha = false, pixelGain = null } ) {
|
|
134
136
|
|
|
135
137
|
const curve = TONE_MAP_FNS.get( toneMapping ) ?? TONE_MAP_FNS.get( NoToneMapping );
|
|
136
138
|
const gain = effectiveExposure( exposure, toneMapping );
|
|
137
139
|
const encoded = isOutputEncoded( toneMapping );
|
|
138
140
|
const out = new Uint8ClampedArray( linear.length );
|
|
139
141
|
const scratch = [ 0, 0, 0 ];
|
|
142
|
+
const logExposure = Math.log2( exposure );
|
|
140
143
|
|
|
141
144
|
for ( let i = 0; i < linear.length; i += 4 ) {
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
let g = gain;
|
|
147
|
+
if ( pixelGain ) {
|
|
148
|
+
|
|
149
|
+
const p = i >> 2, x = p % pixelGain.width, y = ( p - x ) / pixelGain.width;
|
|
150
|
+
const lum = linear[ i ] * 0.2126 + linear[ i + 1 ] * 0.7152 + linear[ i + 2 ] * 0.0722;
|
|
151
|
+
g *= pixelGain.fn( ( x + 0.5 ) / pixelGain.width, ( y + 0.5 ) / pixelGain.height, lum, logExposure );
|
|
152
|
+
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
scratch[ 0 ] = linear[ i ] * g;
|
|
156
|
+
scratch[ 1 ] = linear[ i + 1 ] * g;
|
|
157
|
+
scratch[ 2 ] = linear[ i + 2 ] * g;
|
|
146
158
|
|
|
147
159
|
applySaturation( scratch, saturation );
|
|
148
160
|
curve( scratch[ 0 ], scratch[ 1 ], scratch[ 2 ], 1.0, scratch );
|
|
@@ -88,7 +88,7 @@ fn rayzee_store( i: u32, linear: vec4<f32>, mapped: vec3<f32> ) {
|
|
|
88
88
|
|
|
89
89
|
const BYTES_PER_PIXEL = { rgba8: 4, planar: 16 };
|
|
90
90
|
|
|
91
|
-
const toneMapPassWGSL = ( transformWGSL, input, output ) => /* wgsl */ `
|
|
91
|
+
const toneMapPassWGSL = ( transformWGSL, input, output, gainWGSL ) => /* wgsl */ `
|
|
92
92
|
struct Params {
|
|
93
93
|
width: u32,
|
|
94
94
|
height: u32,
|
|
@@ -104,6 +104,7 @@ ${SOURCES[ input ]}
|
|
|
104
104
|
|
|
105
105
|
${transformWGSL}
|
|
106
106
|
${OUTPUTS[ output ]}
|
|
107
|
+
${gainWGSL ?? ''}
|
|
107
108
|
|
|
108
109
|
@compute @workgroup_size(8, 8)
|
|
109
110
|
fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
|
|
@@ -113,7 +114,9 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
|
|
|
113
114
|
if ( params.flipY == 1u ) { srcY = params.height - 1u - gid.y; }
|
|
114
115
|
|
|
115
116
|
let linear = rayzee_source( gid.x, srcY );
|
|
116
|
-
|
|
117
|
+
let uv = vec2<f32>( ( f32( gid.x ) + 0.5 ) / f32( params.width ), ( f32( gid.y ) + 0.5 ) / f32( params.height ) );
|
|
118
|
+
let rgb = linear.rgb${gainWGSL ? ' * rayzee_gain( uv, linear.rgb, params.exposure )' : ''};
|
|
119
|
+
rayzee_store( gid.y * params.width + gid.x, linear, rayzee_tone_map( rgb, params.mode, params.exposure, params.saturation ) );
|
|
117
120
|
}
|
|
118
121
|
`;
|
|
119
122
|
|
|
@@ -136,7 +139,7 @@ export class PackedToneMapper {
|
|
|
136
139
|
this.output = output;
|
|
137
140
|
this.width = 0;
|
|
138
141
|
this.height = 0;
|
|
139
|
-
this.
|
|
142
|
+
this._pipelines = new Map();
|
|
140
143
|
this._storage = null;
|
|
141
144
|
this._map = null;
|
|
142
145
|
this._params = null;
|
|
@@ -157,21 +160,31 @@ export class PackedToneMapper {
|
|
|
157
160
|
* heard of. Without this the readback silently falls through to the clamp and a saved image
|
|
158
161
|
* comes back untone-mapped.
|
|
159
162
|
*/
|
|
160
|
-
_ensurePipeline() {
|
|
163
|
+
_ensurePipeline( gain = null ) {
|
|
161
164
|
|
|
162
165
|
const { wgsl, bindings, version } = currentShader();
|
|
163
|
-
if ( this.
|
|
166
|
+
if ( this._shaderVersion !== version ) {
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
this._pipelines.clear();
|
|
169
|
+
this._bindings = bindings;
|
|
170
|
+
this._shaderVersion = version;
|
|
171
|
+
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const key = gain?.key ?? '';
|
|
175
|
+
let pipeline = this._pipelines.get( key );
|
|
176
|
+
if ( pipeline ) return pipeline;
|
|
177
|
+
|
|
178
|
+
pipeline = this.device.createComputePipeline( {
|
|
168
179
|
label: this.label,
|
|
169
180
|
layout: 'auto',
|
|
170
181
|
compute: {
|
|
171
|
-
module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input, this.output ) } ),
|
|
182
|
+
module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input, this.output, gain?.wgsl ) } ),
|
|
172
183
|
entryPoint: 'main',
|
|
173
184
|
},
|
|
174
185
|
} );
|
|
186
|
+
this._pipelines.set( key, pipeline );
|
|
187
|
+
return pipeline;
|
|
175
188
|
|
|
176
189
|
}
|
|
177
190
|
|
|
@@ -262,6 +275,9 @@ export class PackedToneMapper {
|
|
|
262
275
|
* @param {number} [tone.saturation=1]
|
|
263
276
|
* @param {boolean} [tone.flipY=false]
|
|
264
277
|
* @param {boolean} [tone.preserveAlpha=false] - the source's alpha, rounded as the CPU rounds it; else 255
|
|
278
|
+
* @param {{key: string, wgsl: string, entries: GPUBindGroupEntry[], device?: GPUDevice, copyTo?: Function}} [tone.gain] - a
|
|
279
|
+
* per-pixel gain before the curve: WGSL defining `rayzee_gain( uv, linear, exposure ) -> f32` over bind group 1.
|
|
280
|
+
* One on another device is copied to this one for the run (`copyTo`).
|
|
265
281
|
* @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
|
|
266
282
|
*/
|
|
267
283
|
async toRGBA8( src, tone ) {
|
|
@@ -283,14 +299,32 @@ export class PackedToneMapper {
|
|
|
283
299
|
|
|
284
300
|
}
|
|
285
301
|
|
|
286
|
-
async _run( src,
|
|
302
|
+
async _run( src, tone = {} ) {
|
|
303
|
+
|
|
304
|
+
const gain = tone.gain;
|
|
305
|
+
if ( ! gain?.device || gain.device === this.device ) return this._dispatch( src, tone );
|
|
306
|
+
|
|
307
|
+
const copy = await gain.copyTo( this.device );
|
|
308
|
+
try {
|
|
309
|
+
|
|
310
|
+
return await this._dispatch( src, { ...tone, gain: copy } );
|
|
311
|
+
|
|
312
|
+
} finally {
|
|
313
|
+
|
|
314
|
+
copy.destroy();
|
|
315
|
+
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async _dispatch( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false, gain = null } = {} ) {
|
|
287
321
|
|
|
288
322
|
if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
|
|
289
323
|
if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
|
|
290
324
|
|
|
291
325
|
// A config can be loaded between two readbacks, so the registry is re-checked here rather
|
|
292
326
|
// than only on resize.
|
|
293
|
-
this._ensurePipeline();
|
|
327
|
+
const pipeline = this._ensurePipeline( gain );
|
|
294
328
|
this._ensureTables();
|
|
295
329
|
|
|
296
330
|
const { width, height } = this;
|
|
@@ -309,7 +343,7 @@ export class PackedToneMapper {
|
|
|
309
343
|
.map( ( [ binding, held ] ) => ( { binding, resource: held.view } ) );
|
|
310
344
|
|
|
311
345
|
const group = this.device.createBindGroup( {
|
|
312
|
-
layout:
|
|
346
|
+
layout: pipeline.getBindGroupLayout( 0 ),
|
|
313
347
|
entries: [
|
|
314
348
|
...tableEntries,
|
|
315
349
|
{ binding: 0, resource: this.input === 'texture' ? src.createView() : { buffer: src, size: width * height * 8 } },
|
|
@@ -320,8 +354,9 @@ export class PackedToneMapper {
|
|
|
320
354
|
|
|
321
355
|
const encoder = this.device.createCommandEncoder( { label: this.label } );
|
|
322
356
|
const pass = encoder.beginComputePass();
|
|
323
|
-
pass.setPipeline(
|
|
357
|
+
pass.setPipeline( pipeline );
|
|
324
358
|
pass.setBindGroup( 0, group );
|
|
359
|
+
if ( gain ) pass.setBindGroup( 1, this.device.createBindGroup( { layout: pipeline.getBindGroupLayout( 1 ), entries: gain.entries } ) );
|
|
325
360
|
pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
|
|
326
361
|
pass.end();
|
|
327
362
|
encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, bytes );
|
|
@@ -360,7 +395,7 @@ export class PackedToneMapper {
|
|
|
360
395
|
this._releaseBuffers();
|
|
361
396
|
for ( const held of this._tables.values() ) held.texture.destroy();
|
|
362
397
|
this._tables.clear();
|
|
363
|
-
this.
|
|
398
|
+
this._pipelines.clear();
|
|
364
399
|
|
|
365
400
|
}
|
|
366
401
|
|