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.
Files changed (35) hide show
  1. package/README.md +33 -5
  2. package/dist/{ArchiveImporter-D735xX-4.js → ArchiveImporter-29Nl1567.js} +2 -2
  3. package/dist/{BidirectionalIntegrator-M89c0AlP.js → BidirectionalIntegrator-CJay8tXK.js} +2 -2
  4. package/dist/{NeuralRetouch-DiBH63kz.js → NeuralRetouch-KqGVPV7u.js} +4 -3
  5. package/dist/{NeuralSuperRes-CZNQP13W.js → NeuralSuperRes-BHxhYzlD.js} +11 -5
  6. package/dist/{PathTracer-BXCCVrPK.js → PathTracer-Cldt0mXi.js} +19 -12
  7. package/dist/{PhysicalSky-DqmaS0lb.js → PhysicalSky-CUWrTcmT.js} +1 -1
  8. package/dist/{RayzeeRenderer-DUSMFhmc.js → RayzeeRenderer-Dd-Dit0v.js} +313 -281
  9. package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-sAs8wdgI.js} +44 -22
  10. package/dist/addons/archives.es.js +1 -1
  11. package/dist/addons/bidirectional.es.js +1 -1
  12. package/dist/addons/physical-sky.es.js +1 -1
  13. package/dist/rayzee-core.es.js +3 -3
  14. package/dist/rayzee.es.js +1428 -902
  15. package/dist/rayzee.umd.js +124 -113
  16. package/dist/{sceneCachePolicy-BAib5fCp.js → sceneCachePolicy--ta6moJu.js} +1 -1
  17. package/package.json +1 -1
  18. package/src/EngineDefaults.js +2 -2
  19. package/src/Passes/AIUpscaler.js +17 -12
  20. package/src/PathTracerApp.js +122 -16
  21. package/src/Pipeline/CompletionTracker.js +7 -3
  22. package/src/Processor/ToneMapCPU.js +16 -4
  23. package/src/Processor/ToneMapGPU.js +49 -14
  24. package/src/RayzeeRenderer.js +54 -7
  25. package/src/RenderSettings.js +4 -2
  26. package/src/Stages/AutoExposure.js +484 -494
  27. package/src/Stages/Compositor.js +27 -6
  28. package/src/Stages/LocalExposure.js +569 -0
  29. package/src/Stages/PathTracer.js +6 -4
  30. package/src/Stages/PathTracerStage.js +9 -7
  31. package/src/index.js +1 -0
  32. package/src/managers/DenoisingManager.js +93 -34
  33. package/src/managers/VideoRenderManager.js +30 -4
  34. package/src/neural/NeuralRetouch.js +1 -0
  35. package/src/neural/NeuralSuperRes.js +6 -1
@@ -1594,7 +1594,7 @@ var vt = class {
1594
1594
  samples: this.samples.slice()
1595
1595
  };
1596
1596
  }
1597
- }, yt = "11.3.0", $ = 1e4, bt = 15e5;
1597
+ }, yt = "11.5.0", $ = 1e4, bt = 15e5;
1598
1598
  function xt(e, t, n = $) {
1599
1599
  return e >= n && t / bt < e / 3;
1600
1600
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "11.3.0",
3
+ "version": "11.5.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",
@@ -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
@@ -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 Set();
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
- async start() {
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 ( this.hdr && this.getGPUTextures ) {
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.add( handler );
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
- if ( this._worker ) {
818
-
819
- for ( const handler of this._pendingWorkerHandlers ) {
825
+ for ( const [ handler, reject ] of this._pendingWorkerHandlers ) {
820
826
 
821
- this._worker.removeEventListener( 'message', handler );
822
-
823
- }
827
+ this._worker?.removeEventListener( 'message', handler );
828
+ reject( new DOMException( 'Aborted', 'AbortError' ) );
824
829
 
825
830
  }
826
831
 
@@ -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
- // auto exposure drives the exposure while it is on, and restores this value when turned off
330
+ // the manual exposure, and the compensation on top of auto exposure while that is on
329
331
  applyExposure: ( value ) => {
330
332
 
331
- if ( ! this.stages.autoExposure?.enabled ) core.applyExposure( value );
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
- if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
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 && snapshot.autoExposure !== undefined ) {
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
- if ( ! this.stages.autoExposure ) return;
1140
+ const ae = this.stages.autoExposure;
1141
+ if ( ! ae ) return;
1076
1142
 
1077
- this.stages.autoExposure.on( 'autoexposure:updated', ( data ) => {
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
- this.dispatchEvent( {
1080
- type: EngineEvents.AUTO_EXPOSURE_UPDATED,
1081
- exposure: data.exposure,
1082
- luminance: data.luminance
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 — the sample ceiling and convergence still bind when they come first.
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 'frames'
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
- if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
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
- scratch[ 0 ] = linear[ i ] * gain;
144
- scratch[ 1 ] = linear[ i + 1 ] * gain;
145
- scratch[ 2 ] = linear[ i + 2 ] * gain;
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
- rayzee_store( gid.y * params.width + gid.x, linear, rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation ) );
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._pipeline = null;
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._pipeline && this._shaderVersion === version ) return;
166
+ if ( this._shaderVersion !== version ) {
164
167
 
165
- this._bindings = bindings;
166
- this._shaderVersion = version;
167
- this._pipeline = this.device.createComputePipeline( {
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, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
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: this._pipeline.getBindGroupLayout( 0 ),
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( this._pipeline );
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._pipeline = null;
398
+ this._pipelines.clear();
364
399
 
365
400
  }
366
401