rayzee 11.3.0 → 11.4.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-Cgyx5AF7.js} +2 -2
  3. package/dist/{BidirectionalIntegrator-M89c0AlP.js → BidirectionalIntegrator-BKF-cwH2.js} +2 -2
  4. package/dist/{NeuralRetouch-DiBH63kz.js → NeuralRetouch-C3Q2XqbF.js} +4 -3
  5. package/dist/{NeuralSuperRes-CZNQP13W.js → NeuralSuperRes-DV5TtURP.js} +11 -5
  6. package/dist/{PathTracer-BXCCVrPK.js → PathTracer-D3MvEaZK.js} +19 -12
  7. package/dist/{PhysicalSky-DqmaS0lb.js → PhysicalSky-CY4jiz6a.js} +1 -1
  8. package/dist/{RayzeeRenderer-DUSMFhmc.js → RayzeeRenderer-D23m6NqG.js} +313 -281
  9. package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-CTDBvmmS.js} +31 -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 +1343 -858
  15. package/dist/rayzee.umd.js +124 -113
  16. package/dist/{sceneCachePolicy-BAib5fCp.js → sceneCachePolicy-AKOEu2lf.js} +1 -1
  17. package/package.json +1 -1
  18. package/src/EngineDefaults.js +2 -2
  19. package/src/Passes/AIUpscaler.js +2 -0
  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 +30 -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 +546 -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 +11 -15
  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.4.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.4.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;
@@ -497,6 +498,7 @@ export class AIUpscaler extends EventDispatcher {
497
498
  exposure: this.getExposure(),
498
499
  toneMapping: this.getToneMapping() ?? ACESFilmicToneMapping,
499
500
  saturation: this.getSaturation(),
501
+ gain: this.getDisplayGain(),
500
502
  } );
501
503
  this._toneMapper.release();
502
504
 
@@ -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,8 @@ 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[]}} [tone.gain] - a per-pixel gain before the
279
+ * curve: WGSL defining `rayzee_gain( uv, linear, exposure ) -> f32` over bind group 1
265
280
  * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
266
281
  */
267
282
  async toRGBA8( src, tone ) {
@@ -283,14 +298,14 @@ export class PackedToneMapper {
283
298
 
284
299
  }
285
300
 
286
- async _run( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
301
+ async _run( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false, gain = null } = {} ) {
287
302
 
288
303
  if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
289
304
  if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
290
305
 
291
306
  // A config can be loaded between two readbacks, so the registry is re-checked here rather
292
307
  // than only on resize.
293
- this._ensurePipeline();
308
+ const pipeline = this._ensurePipeline( gain );
294
309
  this._ensureTables();
295
310
 
296
311
  const { width, height } = this;
@@ -309,7 +324,7 @@ export class PackedToneMapper {
309
324
  .map( ( [ binding, held ] ) => ( { binding, resource: held.view } ) );
310
325
 
311
326
  const group = this.device.createBindGroup( {
312
- layout: this._pipeline.getBindGroupLayout( 0 ),
327
+ layout: pipeline.getBindGroupLayout( 0 ),
313
328
  entries: [
314
329
  ...tableEntries,
315
330
  { binding: 0, resource: this.input === 'texture' ? src.createView() : { buffer: src, size: width * height * 8 } },
@@ -320,8 +335,9 @@ export class PackedToneMapper {
320
335
 
321
336
  const encoder = this.device.createCommandEncoder( { label: this.label } );
322
337
  const pass = encoder.beginComputePass();
323
- pass.setPipeline( this._pipeline );
338
+ pass.setPipeline( pipeline );
324
339
  pass.setBindGroup( 0, group );
340
+ if ( gain ) pass.setBindGroup( 1, this.device.createBindGroup( { layout: pipeline.getBindGroupLayout( 1 ), entries: gain.entries } ) );
325
341
  pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
326
342
  pass.end();
327
343
  encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, bytes );
@@ -360,7 +376,7 @@ export class PackedToneMapper {
360
376
  this._releaseBuffers();
361
377
  for ( const held of this._tables.values() ) held.texture.destroy();
362
378
  this._tables.clear();
363
- this._pipeline = null;
379
+ this._pipelines.clear();
364
380
 
365
381
  }
366
382
 
@@ -532,6 +532,13 @@ export class RayzeeRenderer extends EventDispatcher {
532
532
 
533
533
  }
534
534
 
535
+ if ( this._settling() ) {
536
+
537
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
538
+ return;
539
+
540
+ }
541
+
535
542
  // Stop the loop to avoid constant CPU usage while idle
536
543
  this.stopAnimation();
537
544
  return;
@@ -558,12 +565,7 @@ export class RayzeeRenderer extends EventDispatcher {
558
565
 
559
566
  this._traceFrame( { liveDenoise: true } );
560
567
 
561
- if ( this.stages.pathTracer.isComplete && this.completion.markComplete() ) {
562
-
563
- this._renderCompleted();
564
- this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...this._completionInfo() } );
565
-
566
- }
568
+ if ( this.stages.pathTracer.isComplete && this.completion.markComplete() ) this._announceComplete();
567
569
 
568
570
  }
569
571
 
@@ -572,6 +574,25 @@ export class RayzeeRenderer extends EventDispatcher {
572
574
 
573
575
  }
574
576
 
577
+ // The closing passes and RENDER_COMPLETE wait for _finishImage(), so both see the finished image's exposure.
578
+ _announceComplete() {
579
+
580
+ const info = this._completionInfo();
581
+ const restarts = this.stages.pathTracer.resetCount;
582
+ const announce = () => {
583
+
584
+ if ( ! this.completion.renderCompleteDispatched || this.stages.pathTracer?.resetCount !== restarts ) return;
585
+ this._renderCompleted();
586
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...info } );
587
+
588
+ };
589
+
590
+ const finishing = this._finishImage();
591
+ if ( finishing ) finishing.then( announce, announce );
592
+ else announce();
593
+
594
+ }
595
+
575
596
  // One frame of a render and its bookkeeping, shared by animate() and renderUntilComplete().
576
597
  _traceFrame( { liveDenoise = false } = {} ) {
577
598
 
@@ -2499,6 +2520,7 @@ export class RayzeeRenderer extends EventDispatcher {
2499
2520
 
2500
2521
  }
2501
2522
 
2523
+ await this._finishImage();
2502
2524
  return stage.frameCount;
2503
2525
 
2504
2526
  }
@@ -2576,6 +2598,8 @@ export class RayzeeRenderer extends EventDispatcher {
2576
2598
  }
2577
2599
 
2578
2600
  const info = this._completionInfo();
2601
+ // Before it is announced: a listener reading pixels sees the finished image's exposure.
2602
+ await this._finishImage();
2579
2603
  if ( this.completion.markComplete() ) this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...info } );
2580
2604
 
2581
2605
  const denoised = denoise ? await this._finalDenoise() : false;
@@ -2735,12 +2759,14 @@ export class RayzeeRenderer extends EventDispatcher {
2735
2759
  toneMapping: this.renderer.toneMapping,
2736
2760
  saturation: this.settings.get( 'saturation' ) ?? 1,
2737
2761
  preserveAlpha,
2762
+ gain: this._displayGain(),
2738
2763
  };
2739
2764
 
2740
2765
  const gpu = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone );
2766
+ const fn = ! gpu && tone.gain ? await tone.gain.cpu() : null;
2741
2767
  const data = gpu ?? toneMapToRGBA8( shown
2742
2768
  ? await this._readTexture( shown.texture, width, height )
2743
- : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
2769
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), { ...tone, pixelGain: fn && { fn, width, height } } );
2744
2770
  return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
2745
2771
 
2746
2772
  }
@@ -3982,6 +4008,20 @@ export class RayzeeRenderer extends EventDispatcher {
3982
4008
  /** Once, on the loop frame the render completes. */
3983
4009
  _renderCompleted() {}
3984
4010
 
4011
+ /** True keeps the loop running over a finished render, to redraw what is still changing. */
4012
+ _settling() {
4013
+
4014
+ return false;
4015
+
4016
+ }
4017
+
4018
+ /** A render has its samples, before any closing pass or announcement: a promise to wait for, or null. */
4019
+ _finishImage() {
4020
+
4021
+ return null;
4022
+
4023
+ }
4024
+
3985
4025
  /** Draws what sits over the image. Here only brings the light scene's matrices up to date. */
3986
4026
  _renderHelperOverlay() {
3987
4027
 
@@ -4032,6 +4072,13 @@ export class RayzeeRenderer extends EventDispatcher {
4032
4072
 
4033
4073
  }
4034
4074
 
4075
+ /** A per-pixel gain the shown picture carries before the view (the compositor's), for tone-mapped readbacks; or null. */
4076
+ _displayGain() {
4077
+
4078
+ return null;
4079
+
4080
+ }
4081
+
4035
4082
  /** Whether a 'display' read should find a processed picture. */
4036
4083
  _denoiserInUse() {
4037
4084
 
@@ -258,7 +258,7 @@ export class RenderSettings extends EventDispatcher {
258
258
 
259
259
  handleRenderLimitMode: ( value ) => {
260
260
 
261
- stages.pathTracer?.setRenderLimitMode?.( value );
261
+ stages.pathTracer?.setRenderLimitMode?.( value, this.get( 'renderTimeLimit' ) );
262
262
  reconcileCompletion?.();
263
263
 
264
264
  },
@@ -271,8 +271,10 @@ export class RenderSettings extends EventDispatcher {
271
271
 
272
272
  },
273
273
 
274
- handleRenderTimeLimit: () => {
274
+ // Arming or disarming the deadline lifts or restores the ceiling in 'timeOnly'.
275
+ handleRenderTimeLimit: ( value ) => {
275
276
 
277
+ stages.pathTracer?.setRenderLimitMode?.( this.get( 'renderLimitMode' ), value );
276
278
  reconcileCompletion?.();
277
279
 
278
280
  },