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
@@ -1,705 +1,695 @@
1
- import { Fn, wgslFn, vec4, float, int, uint, ivec2, uvec2, uniform, If, max,
2
- textureLoad, textureStore, workgroupBarrier, localId, workgroupId,
3
- attributeArray, atomicAdd, atomicStore, atomicLoad, Loop } from 'three/tsl';
4
- import { RenderTarget, TextureNode, StorageTexture, ReadbackBuffer } from 'three/webgpu';
5
- import { FloatType, RGBAFormat, NearestFilter } from 'three';
1
+ import { Fn, float, int, uint, ivec2, vec2, vec4, uniform, If, Loop, max, min, select, exp, log2, dot,
2
+ textureLoad, workgroupArray, workgroupBarrier, localId, workgroupId, attributeArray,
3
+ atomicAdd, atomicLoad, atomicStore, floatBitsToUint } from 'three/tsl';
4
+ import { TextureNode, ReadbackBuffer } from 'three/webgpu';
5
+ import { Matrix3, Vector2, Vector3 } from 'three';
6
6
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
- import { luminance } from '../TSL/Common.js';
7
+ import { getWorkingMatrix } from '../Color/WorkingMatrix.js';
8
8
 
9
- // ── Histogram constants ────────────────────────────────────
10
9
  export const AUTO_EXPOSURE_DEFAULTS = {
11
10
  autoExposure: false,
11
+ autoExposureMetering: 'center',
12
+ autoExposureStrength: 0.3,
12
13
  autoExposureKeyValue: 0.18,
13
- autoExposureMinExposure: 0.1,
14
- autoExposureMaxExposure: 20.0,
14
+ autoExposureMinExposure: 2 ** - 8, // how far it may go from the manual exposure, as Bevy's range: ±8 stops
15
+ autoExposureMaxExposure: 2 ** 8,
15
16
  autoExposureAdaptSpeedBright: 3.0,
16
- autoExposureAdaptSpeedDark: 0.5,
17
+ autoExposureAdaptSpeedDark: 1.0,
17
18
  };
18
19
 
19
- const NUM_BINS = 256;
20
- const MIN_LOG_LUM = - 8.0; // ln(~0.00034) — very dark
21
- const MAX_LOG_LUM = 6.0; // ln(~403) — bright specular
22
- const LOG_LUM_RANGE = MAX_LOG_LUM - MIN_LOG_LUM; // 14 nats ≈ 20 stops
23
- const BIN_WIDTH = LOG_LUM_RANGE / NUM_BINS;
24
- const WEIGHT_SCALE = 10000; // float → uint quantisation for metering weights
20
+ export const METERING_MODES = [ 'average', 'center', 'spot' ];
21
+
22
+ const GRID = 64; // metering tiles per axis at most
23
+ const MIN_TILE = 8; // pixels per tile side at least, so a one-sample image averages enough of them
24
+ const WG = 8;
25
+ const BINS = 256;
26
+ const LOG2_MIN = - 16;
27
+ const LOG2_MAX = 12;
28
+ const BIN_STOPS = ( LOG2_MAX - LOG2_MIN ) / BINS;
29
+ // A tile below this has no light to meter: a black or transparent background is not part of the exposure.
30
+ const BLACK = 2 ** - 24;
31
+ const WEIGHT_SCALE = 65536;
32
+ const MAX_TILE_READS = 256;
33
+ const SPOT_FALLOFF = 128; // Gaussian over image heights: σ ≈ 6 % of the frame height
34
+ const TRANSITION_STOPS = 1.5;
35
+ const SETTLED_STOPS = 0.01;
36
+ // Seconds for an accumulating image's exposure to close the gap by e: it lands in ~0.3 s. Easing is for a picture
37
+ // that keeps changing; a still one is being looked at, and a low-sample render finishes before an eased exposure lands.
38
+ const LAND_SECONDS = 0.06;
39
+ const MAX_STEP_SECONDS = 0.25;
40
+ const ROOM_SECONDS = 4; // camera motion the room level takes to follow
41
+ const MOTION_HOLD = 0.3; // a metering of a new view counts as motion for this long
42
+ const DAMP_UNTIL = 1.5; // stops from the manual exposure: the room level is damped up to here...
43
+ const FOLLOW_FROM = 3; // ...and followed fully from here
44
+ const REC709_Y = [ 0.2126, 0.7152, 0.0722 ];
25
45
 
26
- // ── Metering ────────────────────────────────────────────────
27
- // Centre-weighted Gaussian is the only mode — spot and uniform
28
- // are unnecessary given the percentile clipping already handles
29
- // extreme highlights/shadows. The centerWeight uniform controls
30
- // the Gaussian falloff steepness.
46
+ /**
47
+ * One adaptation step in stops: constant speed while far, exponential near the target, never past it.
48
+ * @param {number} current - exposure, log2
49
+ * @param {number} target - exposure, log2
50
+ * @param {number} dt - seconds; Infinity lands on the target
51
+ * @param {number} speedBright - stops a second while the scene gets brighter (exposure falls)
52
+ * @param {number} speedDark - stops a second while it gets darker
53
+ */
54
+ export function adaptExposureEV( current, target, dt, speedBright, speedDark ) {
31
55
 
32
- // ── wgslFn helpers ──────────────────────────────────────────
56
+ const delta = target - current;
57
+ if ( delta === 0 || ! ( dt > 0 ) ) return current;
58
+ const speed = Math.max( delta < 0 ? speedBright : speedDark, 1e-3 );
59
+ if ( dt === Infinity ) return target;
60
+ const step = Math.min( speed * dt, Math.abs( delta ) * ( 1 - Math.exp( - speed * dt / TRANSITION_STOPS ) ) );
61
+ return current + Math.sign( delta ) * step;
62
+
63
+ }
33
64
 
34
65
  /**
35
- * Temporal adaptation: map luminance → target exposure, smooth asymmetrically.
36
- *
37
- * Returns vec4f(exposure, luminance, targetExposure, 1.0).
66
+ * Where the exposure aims, in stops from the manual exposure: the room level, damped to `strength` near the
67
+ * manual exposure and followed far from it, plus the view's difference from the room at `strength`.
38
68
  */
39
- const adaptExposure = /*@__PURE__*/ wgslFn( `
40
- fn adaptExposure(
41
- geoMean: f32,
42
- prevExposure: f32,
43
- keyValue: f32,
44
- minExp: f32,
45
- maxExp: f32,
46
- speedBright: f32,
47
- speedDark: f32,
48
- dt: f32,
49
- isFirstFrame: f32
50
- ) -> vec4f {
51
-
52
- let targetExp = clamp( keyValue / max( geoMean, 0.001 ), minExp, maxExp );
53
- var newExposure = targetExp;
54
-
55
- // Temporal smoothing (skip on first frame)
56
- if ( isFirstFrame < 0.5 ) {
57
-
58
- // Asymmetric speed: brighter scenes adapt faster
59
- let speed = select( speedDark, speedBright, targetExp < prevExposure );
60
- let alpha = 1.0 - exp( -dt * speed );
61
- newExposure = mix( prevExposure, targetExp, alpha );
69
+ export function blendExposureEV( room, view, strength ) {
62
70
 
63
- }
71
+ const a = Math.abs( room );
72
+ const w = a <= DAMP_UNTIL ? strength : a >= FOLLOW_FROM ? 1 : strength + ( 1 - strength ) * ( a - DAMP_UNTIL ) / ( FOLLOW_FROM - DAMP_UNTIL );
73
+ return room * w + strength * ( view - room );
64
74
 
65
- return vec4f( newExposure, geoMean, targetExp, 1.0 );
75
+ }
66
76
 
67
- }
68
- ` );
77
+ // Luminance weights of the working space: Rec.709's Y row through the working matrix's inverse.
78
+ function workingLuminance( matrix, out ) {
79
+
80
+ if ( ! matrix ) return out.fromArray( REC709_Y );
81
+ const inv = new Matrix3().set( ...matrix ).invert().elements; // column-major
82
+ return out.set(
83
+ REC709_Y[ 0 ] * inv[ 0 ] + REC709_Y[ 1 ] * inv[ 1 ] + REC709_Y[ 2 ] * inv[ 2 ],
84
+ REC709_Y[ 0 ] * inv[ 3 ] + REC709_Y[ 1 ] * inv[ 4 ] + REC709_Y[ 2 ] * inv[ 5 ],
85
+ REC709_Y[ 0 ] * inv[ 6 ] + REC709_Y[ 1 ] * inv[ 7 ] + REC709_Y[ 2 ] * inv[ 8 ],
86
+ );
87
+
88
+ }
89
+
90
+ // Fewer meterings as the image converges: its metered brightness settles like the noise does.
91
+ const meterInterval = samples => samples < 16 ? 1 : samples < 64 ? 4 : 16;
69
92
 
70
93
  /**
71
- * WebGPU Auto-Exposure Stage — Histogram-Based with Centre-Weighted Metering
72
- *
73
- * GPU-based automatic exposure control with human eye-like adaptation.
74
- * Uses histogram-based luminance analysis with percentile clipping
75
- * and centre-weighted spatial metering for robust exposure estimation.
76
- *
77
- * Algorithm:
78
- * 1. Downsample (compute): full res → 64×64 log-luminance grid
79
- * 2. Histogram (compute): build 256-bin weighted histogram from the 64×64
80
- * grid. Single workgroup of 256 threads; each loads 16 texels, applies
81
- * centre-weighted Gaussian, and scatters via atomicAdd into a storage buffer.
82
- * 3. Analyze (compute): single thread reads the histogram, computes CDF,
83
- * extracts percentile-clipped weighted mean (ignoring bottom/top
84
- * extremes), and writes the geometric mean to a 1×1 storage texture.
85
- * 4. Adaptation (compute): temporal smoothing with prev exposure; writes
86
- * vec4(exposure, luminance, targetExposure, 1) into a 1-element buffer.
87
- * 5. Async readback via `renderer.getArrayBufferAsync(attr, ReadbackBuffer)`.
88
- *
89
- * Execution: ALWAYS
94
+ * Auto exposure: meters the accumulated image on the GPU and adapts on the CPU.
90
95
  *
91
- * Events listened:
92
- * pipeline:reset — soft reset (keeps the exposure, discards an in-flight readback)
93
- * pipeline:lightingChanged — a new model or environment: start the exposure history over
96
+ * Tiles are averaged in linear light, weighted by alpha, so a one-sample image meters like the
97
+ * converged one; the percentile-clipped mean of their log2 histogram is read back. It aims at a room
98
+ * level learned while the view changes plus a damped share of each view (`blendExposureEV`), moves in
99
+ * stops every loop frame (`update()`: eased while the image restarts every frame, landing in ~0.3 s once
100
+ * it accumulates), in video time (`advance()`, eased), or with each metering (`instant`), and is applied
101
+ * as 2^EV × the manual exposure.
94
102
  *
95
- * Textures published: (none — publishes state, not textures)
96
- * Textures read: edgeFiltering:output > asvgf:output > pathtracer:color
97
- * State published: autoexposure:value, autoexposure:avgLuminance
103
+ * Events listened: pipeline:reset, pipeline:lightingChanged (lands on the next metering)
104
+ * Textures read: pathtracer:color
105
+ * State published: autoexposure:value, autoexposure:avgLuminance
98
106
  */
99
107
  export class AutoExposure extends RenderStage {
100
108
 
101
109
  constructor( renderer, options = {} ) {
102
110
 
103
- super( 'AutoExposure', {
104
- ...options,
105
- executionMode: StageExecutionMode.ALWAYS
106
- } );
111
+ super( 'AutoExposure', { ...options, executionMode: StageExecutionMode.ALWAYS } );
107
112
 
108
113
  this.renderer = renderer;
109
114
 
110
- // Reduction constant
111
- this.REDUCTION_SIZE = 64;
115
+ this.metering = options.metering ?? AUTO_EXPOSURE_DEFAULTS.autoExposureMetering;
116
+ this.meteringPoint = new Vector2( 0.5, 0.5 ); // image uv, y down
117
+ this.keyValue = options.keyValue ?? AUTO_EXPOSURE_DEFAULTS.autoExposureKeyValue;
118
+ this.minExposure = options.minExposure ?? AUTO_EXPOSURE_DEFAULTS.autoExposureMinExposure;
119
+ this.maxExposure = options.maxExposure ?? AUTO_EXPOSURE_DEFAULTS.autoExposureMaxExposure;
120
+ this.adaptSpeedBright = options.adaptSpeedBright ?? AUTO_EXPOSURE_DEFAULTS.autoExposureAdaptSpeedBright;
121
+ this.adaptSpeedDark = options.adaptSpeedDark ?? AUTO_EXPOSURE_DEFAULTS.autoExposureAdaptSpeedDark;
122
+ this.centerWeight = options.centerWeight ?? 8.0;
123
+ this.strength = options.strength ?? AUTO_EXPOSURE_DEFAULTS.autoExposureStrength;
124
+ this.instant = false;
125
+ // Readings of an image with fewer samples leave an existing exposure where it is (a final render's first
126
+ // samples meter its many bounces noisily); a reading through meter() is always taken.
127
+ this.holdSamples = 0;
128
+ /** Called when a metering moves the target, or a change needs a frame; the viewer wakes its loop. */
129
+ this.onChange = null;
130
+
131
+ this._lowU = uniform( options.lowPercentile ?? 0.10 );
132
+ this._highU = uniform( options.highPercentile ?? 0.90 );
133
+ this._falloffU = uniform( new Vector2() );
134
+ this._pointU = uniform( new Vector2( 0.5, 0.5 ) );
135
+ this._lumWeightsU = uniform( new Vector3().fromArray( REC709_Y ) );
136
+ this._widthU = uniform( 1, 'int' );
137
+ this._heightU = uniform( 1, 'int' );
138
+ this._strideU = uniform( 1, 'int' );
139
+ this._gridXU = uniform( GRID, 'int' );
140
+ this._gridYU = uniform( GRID, 'int' );
141
+ this._inputNode = new TextureNode();
142
+ this._workingMatrix = undefined;
143
+
144
+ this._histogram = attributeArray( BINS, 'uint' ).toAtomic();
145
+ this._result = attributeArray( 1, 'vec4' );
146
+ this._readback = new ReadbackBuffer( 16 );
147
+ this._readback.name = 'AutoExposure';
148
+ this._kernels = [ this._buildTileKernel(), this._buildResolveKernel() ];
149
+
150
+ this._compensation = 1;
151
+ this._exposureEV = 0;
152
+ this._targetEV = null;
153
+ this._viewEV = null;
154
+ this._roomEV = null;
155
+ this._clock = 0;
156
+ this._movingUntil = - 1;
157
+ this._viewMoves = 0;
158
+ this._landed = null;
159
+ this._meteredLog2 = null;
160
+ this._snap = true;
161
+ this._lastUpdate = null;
162
+ this._accumulating = false;
163
+ this._emittedEV = null;
164
+ this._pending = null;
165
+ this._generation = 0;
166
+ this._restarts = 0;
167
+ this._meteredAt = { restarts: - 1, samples: - 1 };
168
+ this._stale = false;
169
+ this._disposed = false;
112
170
 
113
- // ── Adaptation uniforms ──────────────────────────
171
+ }
114
172
 
115
- this.keyValueU = uniform( options.keyValue ?? AUTO_EXPOSURE_DEFAULTS.autoExposureKeyValue );
116
- this.minExposureU = uniform( options.minExposure ?? AUTO_EXPOSURE_DEFAULTS.autoExposureMinExposure );
117
- this.maxExposureU = uniform( options.maxExposure ?? AUTO_EXPOSURE_DEFAULTS.autoExposureMaxExposure );
118
- this.adaptSpeedBrightU = uniform( options.adaptSpeedBright ?? AUTO_EXPOSURE_DEFAULTS.autoExposureAdaptSpeedBright );
119
- this.adaptSpeedDarkU = uniform( options.adaptSpeedDark ?? AUTO_EXPOSURE_DEFAULTS.autoExposureAdaptSpeedDark );
120
- this.epsilonU = uniform( options.epsilon ?? 0.0001 );
121
- this.deltaTimeU = uniform( 1.0 / 60.0 );
122
- this.isFirstFrameU = uniform( 1.0 ); // 1.0 = true
123
- this.previousExposureU = uniform( options.initialExposure ?? 1.0 );
173
+ // ── Kernels ──────────────────────────────────────────────
124
174
 
125
- // ── Histogram & metering uniforms ────────────────
175
+ _buildTileKernel() {
126
176
 
127
- this.lowPercentileU = uniform( options.lowPercentile ?? 0.10 );
128
- this.highPercentileU = uniform( options.highPercentile ?? 0.90 );
129
- this.centerWeightU = uniform( options.centerWeight ?? 8.0 );
177
+ const input = this._inputNode;
178
+ const hist = this._histogram;
179
+ const width = this._widthU, height = this._heightU, stride = this._strideU, gridX = this._gridXU, gridY = this._gridYU;
180
+ const lumWeights = this._lumWeightsU, falloff = this._falloffU, point = this._pointU;
130
181
 
131
- // ── Input resolution uniforms (for downsample compute) ──
182
+ return Fn( () => {
132
183
 
133
- this.inputResW = uniform( 1 );
134
- this.inputResH = uniform( 1 );
184
+ const lid = localId.y.mul( uint( WG ) ).add( localId.x );
185
+ const tx = int( workgroupId.x ), ty = int( workgroupId.y );
186
+ const x0 = tx.mul( width ).div( gridX ).toVar(), x1 = tx.add( int( 1 ) ).mul( width ).div( gridX ).toVar();
187
+ const y0 = ty.mul( height ).div( gridY ).toVar(), y1 = ty.add( int( 1 ) ).mul( height ).div( gridY ).toVar();
188
+ const step = stride.mul( int( WG ) );
135
189
 
136
- // ── Input texture nodes (swap .value, no recompile) ──
190
+ const sum = float( 0 ).toVar();
191
+ const cover = float( 0 ).toVar();
137
192
 
138
- this._inputTexNode = new TextureNode();
139
- this._reductionReadTexNode = new TextureNode();
193
+ // Whole 8×8 blocks, every stride-th: skipping single pixels would still fetch their memory.
194
+ Loop( { start: y0.add( int( localId.y ) ), end: y1, type: 'int', condition: '<', update: step, name: 'py' }, ( { py } ) => {
140
195
 
141
- // ── CPU-side state ───────────────────────────────
196
+ Loop( { start: x0.add( int( localId.x ) ), end: x1, type: 'int', condition: '<', update: step, name: 'px' }, ( { px } ) => {
142
197
 
143
- this.currentExposure = options.initialExposure ?? 1.0;
144
- this.currentLuminance = 0.18;
145
- this.targetExposure = 1.0;
146
- this.lastTime = performance.now();
147
- this.isFirstFrame = true;
148
- this._pendingReadback = false;
149
- this._readbackGeneration = 0;
198
+ const c = textureLoad( input, ivec2( px, py ) );
199
+ const lum = dot( c.xyz, lumWeights ).toVar();
200
+ // NaN or Inf would poison the whole tile.
201
+ const finite = floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) );
202
+ const alpha = c.w.clamp( 0.0, 1.0 );
203
+ sum.addAssign( select( finite, lum.clamp( 0.0, 1e6 ), float( 0 ) ).mul( alpha ) );
204
+ cover.addAssign( alpha );
150
205
 
151
- // ── Render targets & storage textures ────────────
206
+ } );
152
207
 
153
- this._initRenderTargets();
154
- this._buildCompute();
208
+ } );
155
209
 
156
- }
210
+ const sharedSum = workgroupArray( 'float', WG * WG );
211
+ const sharedCover = workgroupArray( 'float', WG * WG );
212
+ sharedSum.element( lid ).assign( sum );
213
+ sharedCover.element( lid ).assign( cover );
214
+ workgroupBarrier();
157
215
 
158
- _initRenderTargets() {
159
-
160
- const rtOpts = {
161
- type: FloatType,
162
- format: RGBAFormat,
163
- minFilter: NearestFilter,
164
- magFilter: NearestFilter,
165
- depthBuffer: false,
166
- stencilBuffer: false
167
- };
168
-
169
- // Downsample RenderTarget (64×64) — copy destination from compute StorageTexture
170
- this._downsampleTarget = new RenderTarget( this.REDUCTION_SIZE, this.REDUCTION_SIZE, rtOpts );
171
-
172
- // Downsample StorageTexture (64×64) — compute writes here
173
- this._downsampleStorageTex = new StorageTexture( this.REDUCTION_SIZE, this.REDUCTION_SIZE );
174
- this._downsampleStorageTex.type = FloatType;
175
- this._downsampleStorageTex.format = RGBAFormat;
176
- this._downsampleStorageTex.minFilter = NearestFilter;
177
- this._downsampleStorageTex.magFilter = NearestFilter;
178
-
179
- // 1×1 StorageTexture for histogram analysis output
180
- this._reductionStorageTex = new StorageTexture( 1, 1 );
181
- this._reductionStorageTex.type = FloatType;
182
- this._reductionStorageTex.format = RGBAFormat;
183
- this._reductionStorageTex.minFilter = NearestFilter;
184
- this._reductionStorageTex.magFilter = NearestFilter;
185
-
186
- // 1×1 RenderTarget — readable copy of analysis output (cross-dispatch reads
187
- // from StorageTexture return zeros — must copy to RenderTarget first)
188
- this._reductionReadTarget = new RenderTarget( 1, 1, rtOpts );
189
-
190
- // Adaptation result — 1×vec4 storage buffer attribute. Compute writes
191
- // vec4(exposure, luminance, targetExposure, 1) here; CPU reads via
192
- // getArrayBufferAsync + a pooled ReadbackBuffer (16 bytes).
193
- this._adaptationResult = attributeArray( 1, 'vec4' );
194
- this._readbackBuffer = new ReadbackBuffer( 16 );
195
- this._readbackBuffer.name = 'AutoExposureAdaptation';
196
-
197
- // ── Histogram storage buffer (atomic uint, 256 bins) ─────
198
- this._histogramBuffer = attributeArray( NUM_BINS, 'uint' ).toAtomic();
216
+ for ( let s = ( WG * WG ) >> 1; s > 0; s >>= 1 ) {
199
217
 
200
- }
218
+ If( lid.lessThan( uint( s ) ), () => {
201
219
 
202
- // ──────────────────────────────────────────────────
203
- // TSL shader builders
204
- // ──────────────────────────────────────────────────
220
+ sharedSum.element( lid ).addAssign( sharedSum.element( lid.add( uint( s ) ) ) );
221
+ sharedCover.element( lid ).addAssign( sharedCover.element( lid.add( uint( s ) ) ) );
205
222
 
206
- _buildCompute() {
223
+ } );
224
+ workgroupBarrier();
207
225
 
208
- this._buildDownsampleCompute();
209
- this._buildHistogramCompute();
210
- this._buildHistogramAnalyzeCompute();
211
- this._buildAdaptationCompute();
226
+ }
212
227
 
213
- }
228
+ If( lid.equal( uint( 0 ) ), () => {
214
229
 
215
- /**
216
- * Downsample (compute): full resolution → 64×64
217
- *
218
- * Dispatch: [8, 8, 1] workgroups of [8, 8, 1] = 64×64 threads total.
219
- * Each thread (one output pixel) samples a NxN grid from the input texture.
220
- *
221
- * Output: R = Σ log(L + ε), G = valid pixel count
222
- */
223
- _buildDownsampleCompute() {
230
+ const total = sharedSum.element( uint( 0 ) ).toVar();
231
+ const covered = sharedCover.element( uint( 0 ) ).toVar();
232
+ const readsAlong = ( span ) => span.div( step ).mul( int( WG ) ).add( min( span.mod( step ), int( WG ) ) );
233
+ const reads = float( readsAlong( x1.sub( x0 ) ).mul( readsAlong( y1.sub( y0 ) ) ) );
224
234
 
225
- const inputTex = this._inputTexNode;
226
- const outputTex = this._downsampleStorageTex;
227
- const epsilon = this.epsilonU;
228
- const resW = this.inputResW;
229
- const resH = this.inputResH;
235
+ If( covered.greaterThan( 0.0 ).and( total.greaterThan( covered.mul( BLACK ) ) ), () => {
230
236
 
231
- const SAMPLES = 4;
232
- const OUT_SIZE = 64;
233
- const WG_SIZE = 8;
237
+ const level = log2( total.div( covered ) );
238
+ const bin = uint( level.sub( LOG2_MIN ).div( BIN_STOPS ).floor().clamp( 0.0, BINS - 1 ) );
239
+ const uv = vec2( float( x0.add( x1 ) ).div( float( width ) ), float( y0.add( y1 ) ).div( float( height ) ) ).mul( 0.5 );
240
+ const d = uv.sub( point );
241
+ const weight = exp( dot( d.mul( d ), falloff ).negate() ).mul( covered.div( max( reads, 1.0 ) ) );
242
+ const q = uint( weight.mul( WEIGHT_SCALE ).add( 0.5 ) );
243
+ If( q.greaterThan( uint( 0 ) ), () => {
244
+
245
+ atomicAdd( hist.element( bin ), q );
246
+
247
+ } );
234
248
 
235
- const computeFn = Fn( () => {
249
+ } );
236
250
 
237
- // Global thread ID → output pixel coordinate
238
- const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
239
- const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
251
+ } );
240
252
 
241
- // Block size in input pixels: how many input pixels each output pixel covers
242
- const blockW = resW.div( float( OUT_SIZE ) );
243
- const blockH = resH.div( float( OUT_SIZE ) );
253
+ } )().compute( [ GRID, GRID, 1 ], [ WG, WG, 1 ] );
244
254
 
245
- // Block origin in input pixel space
246
- const blockOriginX = float( gx ).mul( blockW );
247
- const blockOriginY = float( gy ).mul( blockH );
255
+ }
248
256
 
249
- const logLumSum = float( 0.0 ).toVar();
250
- const validCount = float( 0.0 ).toVar();
257
+ _buildResolveKernel() {
251
258
 
252
- // Sample a SAMPLES×SAMPLES grid within the block
253
- for ( let sy = 0; sy < SAMPLES; sy ++ ) {
259
+ const hist = this._histogram;
260
+ const result = this._result;
261
+ const low = this._lowU, high = this._highU;
254
262
 
255
- for ( let sx = 0; sx < SAMPLES; sx ++ ) {
263
+ return Fn( () => {
256
264
 
257
- // Offset within block: (sx+0.5)/SAMPLES normalised to block size
258
- const inputX = int( blockOriginX.add( float( ( sx + 0.5 ) / SAMPLES ).mul( blockW ) ) );
259
- const inputY = int( blockOriginY.add( float( ( sy + 0.5 ) / SAMPLES ).mul( blockH ) ) );
265
+ const t = localId.x;
266
+ const weight = float( atomicLoad( hist.element( t ) ) ).toVar();
267
+ atomicStore( hist.element( t ), uint( 0 ) );
260
268
 
261
- const sample = textureLoad( inputTex, ivec2( inputX, inputY ) );
262
- const lum = luminance( sample.xyz );
269
+ const scan = workgroupArray( 'float', BINS );
270
+ const levels = workgroupArray( 'float', BINS );
271
+ scan.element( t ).assign( weight );
272
+ workgroupBarrier();
263
273
 
264
- If( lum.greaterThan( epsilon ), () => {
274
+ for ( let offset = 1; offset < BINS; offset <<= 1 ) {
265
275
 
266
- logLumSum.addAssign( lum.add( epsilon ).log() );
267
- validCount.addAssign( 1.0 );
276
+ const v = scan.element( t ).toVar();
277
+ If( t.greaterThanEqual( uint( offset ) ), () => {
268
278
 
269
- } );
279
+ v.addAssign( scan.element( t.sub( uint( offset ) ) ) );
270
280
 
271
- }
281
+ } );
282
+ workgroupBarrier();
283
+ scan.element( t ).assign( v );
284
+ workgroupBarrier();
272
285
 
273
286
  }
274
287
 
275
- textureStore(
276
- outputTex,
277
- uvec2( uint( gx ), uint( gy ) ),
278
- vec4( logLumSum, validCount, 0.0, 1.0 )
279
- ).toWriteOnly();
288
+ const total = scan.element( uint( BINS - 1 ) ).toVar();
289
+ const above = scan.element( t ).toVar();
290
+ workgroupBarrier();
280
291
 
281
- } );
292
+ // Only the bin's share inside the percentiles: a bin crossing one moves the mean smoothly.
293
+ const part = max( min( above, total.mul( high ) ).sub( max( above.sub( weight ), total.mul( low ) ) ), 0.0 );
294
+ scan.element( t ).assign( part );
295
+ levels.element( t ).assign( part.mul( float( t ).add( 0.5 ).mul( BIN_STOPS ).add( LOG2_MIN ) ) );
296
+ workgroupBarrier();
282
297
 
283
- this._downsampleComputeNode = computeFn().compute(
284
- [ OUT_SIZE / WG_SIZE, OUT_SIZE / WG_SIZE, 1 ],
285
- [ WG_SIZE, WG_SIZE, 1 ]
286
- );
298
+ for ( let s = BINS >> 1; s > 0; s >>= 1 ) {
287
299
 
288
- }
300
+ If( t.lessThan( uint( s ) ), () => {
289
301
 
290
- /**
291
- * Histogram Build (compute): 64×64 downsample → 256-bin weighted histogram
292
- *
293
- * Single workgroup of 256 threads. Each thread processes 16 texels from
294
- * the downsample grid, applies spatial metering weight, and atomically
295
- * scatters into the histogram storage buffer.
296
- *
297
- * Phase 1: Clear all 256 bins (one per thread)
298
- * Phase 2: Build histogram with metering-weighted atomic scatter
299
- */
300
- _buildHistogramCompute() {
302
+ scan.element( t ).addAssign( scan.element( t.add( uint( s ) ) ) );
303
+ levels.element( t ).addAssign( levels.element( t.add( uint( s ) ) ) );
301
304
 
302
- const downsampleTex = this._downsampleTarget.texture;
303
- const histogram = this._histogramBuffer;
304
- const centerWeight = this.centerWeightU;
305
+ } );
306
+ workgroupBarrier();
305
307
 
306
- const WGSIZE = 256;
307
- const TEXELS_PER_THREAD = 16; // 4096 / 256
308
- const TEX_SIZE = 64;
308
+ }
309
309
 
310
- const computeFn = Fn( () => {
310
+ If( t.equal( uint( 0 ) ), () => {
311
311
 
312
- const tid = localId.x;
312
+ const kept = scan.element( uint( 0 ) );
313
+ result.element( uint( 0 ) ).assign( vec4( levels.element( uint( 0 ) ).div( max( kept, 1e-6 ) ), total.div( WEIGHT_SCALE ), kept.div( WEIGHT_SCALE ), 1.0 ) );
313
314
 
314
- // ── Phase 1: Clear histogram ──────────────────
315
- atomicStore( histogram.element( tid ), uint( 0 ) );
316
- workgroupBarrier();
315
+ } );
317
316
 
318
- // ── Phase 2: Build histogram ──────────────────
319
- for ( let t = 0; t < TEXELS_PER_THREAD; t ++ ) {
317
+ } )().compute( [ 1, 1, 1 ], [ BINS, 1, 1 ] );
320
318
 
321
- const linearIdx = tid.mul( TEXELS_PER_THREAD ).add( t );
322
- const px = linearIdx.mod( TEX_SIZE );
323
- const py = linearIdx.div( TEX_SIZE );
319
+ }
324
320
 
325
- const data = textureLoad( downsampleTex, ivec2( int( px ), int( py ) ) );
326
- const logLumSum = data.x;
327
- const validCount = data.y;
321
+ // ── Pipeline ─────────────────────────────────────────────
328
322
 
329
- If( validCount.greaterThan( 0.0 ), () => {
323
+ setupEventListeners() {
330
324
 
331
- // Per-cell average log-luminance (natural log, matches downsample output)
332
- const avgLogLum = logLumSum.div( validCount );
325
+ this.on( 'pipeline:reset', () => this._restarts ++ );
326
+ this.on( 'pipeline:lightingChanged', () => this.resetHistory() );
333
327
 
334
- // Map to histogram bin [0, NUM_BINS-1]
335
- const normalized = avgLogLum.sub( float( MIN_LOG_LUM ) ).div( float( LOG_LUM_RANGE ) );
336
- const bin = uint( normalized.mul( float( NUM_BINS ) ).floor().clamp( 0.0, float( NUM_BINS - 1 ) ) );
328
+ }
337
329
 
338
- // ── Centre-weighted metering ──────────
339
- const uvx = float( px ).add( 0.5 ).div( float( TEX_SIZE ) );
340
- const uvy = float( py ).add( 0.5 ).div( float( TEX_SIZE ) );
341
- const dx = uvx.sub( 0.5 );
342
- const dy = uvy.sub( 0.5 );
343
- const dist2 = dx.mul( dx ).add( dy.mul( dy ) );
330
+ render( context ) {
344
331
 
345
- // Gaussian falloff: 1.0 at centre, ~0.02 at corners
346
- const weight = dist2.mul( centerWeight ).negate().exp();
332
+ if ( ! this.enabled ) return;
347
333
 
348
- const weightUint = uint( weight.mul( float( WEIGHT_SCALE ) ) );
349
- atomicAdd( histogram.element( bin ), weightUint );
334
+ const samples = context.getState( 'pathtracer:samples' ) ?? 0;
335
+ // Two samples since the last restart: no camera move or playback is restarting it every frame.
336
+ this._accumulating = samples >= 2;
350
337
 
351
- } );
338
+ if ( ! this._pending ) {
352
339
 
353
- }
340
+ const last = this._meteredAt;
341
+ const due = this._stale || this._targetEV === null || last.restarts !== this._restarts || samples - last.samples >= meterInterval( samples );
342
+ const held = samples < this.holdSamples && this._targetEV !== null && ! this._stale;
343
+ if ( due && ! held ) this._meter( context, samples );
354
344
 
355
- } );
345
+ }
356
346
 
357
- this._histogramComputeNode = computeFn().compute( [ 1, 1, 1 ], [ WGSIZE, 1, 1 ] );
347
+ context.setState( 'autoexposure:value', this.renderer.toneMappingExposure );
348
+ context.setState( 'autoexposure:avgLuminance', this.luminance );
358
349
 
359
350
  }
360
351
 
361
352
  /**
362
- * Histogram Analysis (compute): extract percentile-clipped geometric mean
363
- *
364
- * Single thread. Reads the 256-bin histogram, computes the CDF, clips
365
- * the bottom and top percentiles, and computes the weighted geometric
366
- * mean of luminance within the accepted range.
367
- *
368
- * Output: StorageTexture(1×1) = vec4(geometricMean, totalCount, avgLogLum, 1)
353
+ * Meters the current image once, after any metering in flight, and resolves to the metered
354
+ * luminance (log2), or null when nothing could be metered. Does not move the exposure.
369
355
  */
370
- _buildHistogramAnalyzeCompute() {
356
+ async meter( context = this.context ) {
371
357
 
372
- const histogram = this._histogramBuffer;
373
- const outputTex = this._reductionStorageTex;
374
- const lowPercentile = this.lowPercentileU;
375
- const highPercentile = this.highPercentileU;
358
+ while ( this._pending ) await this._pending;
359
+ const done = this._meter( context, context?.getState( 'pathtracer:samples' ) ?? 0 );
360
+ return done ? ( await done ) : null;
376
361
 
377
- const computeFn = Fn( () => {
362
+ }
378
363
 
379
- // ── Pass 1: compute total weight ──────────────
380
- const totalWeight = float( 0.0 ).toVar();
364
+ _meter( context, samples ) {
381
365
 
382
- Loop( NUM_BINS, ( { i } ) => {
366
+ const input = context?.getTexture( 'pathtracer:color' );
367
+ const width = input?.image?.width | 0, height = input?.image?.height | 0;
368
+ if ( ! input || width < 1 || height < 1 || this._disposed ) return null;
383
369
 
384
- totalWeight.addAssign( float( atomicLoad( histogram.element( i ) ) ) );
370
+ this._meteredAt.restarts = this._restarts;
371
+ this._meteredAt.samples = samples;
372
+ this._stale = false;
385
373
 
386
- } );
374
+ const matrix = getWorkingMatrix();
375
+ if ( matrix !== this._workingMatrix ) {
387
376
 
388
- // Percentile thresholds (in quantised weight units)
389
- const lowThreshold = totalWeight.mul( lowPercentile );
390
- const highThreshold = totalWeight.mul( highPercentile );
377
+ this._workingMatrix = matrix;
378
+ workingLuminance( matrix, this._lumWeightsU.value );
391
379
 
392
- // ── Pass 2: percentile-clipped weighted mean ──
393
- const cumWeight = float( 0.0 ).toVar();
394
- const logLumAccum = float( 0.0 ).toVar();
395
- const validWeight = float( 0.0 ).toVar();
396
- const prevCum = float( 0.0 ).toVar();
380
+ }
397
381
 
398
- Loop( NUM_BINS, ( { i } ) => {
382
+ const gridX = Math.min( GRID, Math.max( 1, Math.floor( width / MIN_TILE ) ) );
383
+ const gridY = Math.min( GRID, Math.max( 1, Math.floor( height / MIN_TILE ) ) );
384
+ const tileReads = ( width / gridX ) * ( height / gridY );
385
+ this._gridXU.value = gridX;
386
+ this._gridYU.value = gridY;
387
+ this._kernels[ 0 ].dispatchSize = [ gridX, gridY, 1 ];
388
+ this._strideU.value = Math.max( 1, Math.round( Math.sqrt( tileReads / MAX_TILE_READS ) ) );
389
+ this._widthU.value = width;
390
+ this._heightU.value = height;
391
+ this._updateMeteringPattern( width / height );
392
+ this._inputNode.value = input;
399
393
 
400
- const binWeight = float( atomicLoad( histogram.element( i ) ) );
401
- prevCum.assign( cumWeight );
402
- cumWeight.addAssign( binWeight );
394
+ this.renderer.compute( this._kernels );
403
395
 
404
- // Include bin if it overlaps the [lowThreshold, highThreshold] range
405
- If( prevCum.lessThan( highThreshold ).and( cumWeight.greaterThan( lowThreshold ) ), () => {
396
+ const generation = this._generation;
397
+ const image = { restarts: this._restarts, moves: this._viewMoves };
398
+ const pending = this.renderer.getArrayBufferAsync( this._result.value, this._readback ).then( ( readback ) => {
406
399
 
407
- // Bin centre in log-luminance space
408
- const binCenter = float( MIN_LOG_LUM ).add(
409
- float( i ).add( 0.5 ).mul( float( BIN_WIDTH ) )
410
- );
411
- logLumAccum.addAssign( binCenter.mul( binWeight ) );
412
- validWeight.addAssign( binWeight );
400
+ const data = readback?.buffer ? new Float32Array( readback.buffer.slice( 0, 16 ) ) : null;
401
+ this._readback.release();
402
+ return data && generation === this._generation && ! this._disposed ? this._applyMetering( data, image ) : null;
413
403
 
414
- } );
404
+ }, () => {
415
405
 
416
- } );
406
+ if ( ! this._disposed && this._readback._mapped ) this._readback.release();
407
+ return null;
417
408
 
418
- const safeWeight = max( validWeight, float( 1.0 ) );
419
- const avgLogLum = logLumAccum.div( safeWeight );
420
- const geometricMean = avgLogLum.exp();
409
+ } ).finally( () => {
421
410
 
422
- textureStore(
423
- outputTex,
424
- uvec2( uint( 0 ), uint( 0 ) ),
425
- vec4( geometricMean, totalWeight, avgLogLum, 1.0 )
426
- ).toWriteOnly();
411
+ if ( this._pending === pending ) this._pending = null;
427
412
 
428
413
  } );
429
414
 
430
- this._histogramAnalyzeNode = computeFn().compute( 1, [ 1, 1, 1 ] );
415
+ this._pending = pending;
416
+ return pending;
431
417
 
432
418
  }
433
419
 
434
- /**
435
- * Adaptation (compute): temporal smoothing
436
- *
437
- * Single-thread compute dispatch [1, 1, 1], workgroup [1, 1, 1].
438
- * Reads geometric mean from analysis RenderTarget, applies asymmetric
439
- * temporal smoothing using the previous-exposure uniform, and writes
440
- * vec4(exposure, luminance, targetExposure, 1) into a 1-element storage
441
- * buffer which the CPU reads via getArrayBufferAsync + ReadbackBuffer.
442
- */
443
- _buildAdaptationCompute() {
420
+ _updateMeteringPattern( aspect ) {
444
421
 
445
- const reductionTex = this._reductionReadTexNode;
446
- const resultBuf = this._adaptationResult;
447
- const keyValue = this.keyValueU;
448
- const minExp = this.minExposureU;
449
- const maxExp = this.maxExposureU;
450
- const speedBright = this.adaptSpeedBrightU;
451
- const speedDark = this.adaptSpeedDarkU;
452
- const dt = this.deltaTimeU;
453
- const isFirst = this.isFirstFrameU;
454
- const prevExposure = this.previousExposureU;
422
+ const falloff = this._falloffU.value;
423
+ const point = this._pointU.value;
455
424
 
456
- const computeFn = Fn( () => {
425
+ if ( this.metering === 'average' ) {
457
426
 
458
- // Read geometric mean from histogram analysis result (1×1 RenderTarget)
459
- const geoMean = textureLoad( reductionTex, ivec2( int( 0 ), int( 0 ) ) ).x;
427
+ falloff.set( 0, 0 );
460
428
 
461
- const result = adaptExposure(
462
- geoMean, prevExposure, keyValue,
463
- minExp, maxExp, speedBright, speedDark,
464
- dt, isFirst
465
- );
429
+ } else if ( this.metering === 'spot' ) {
466
430
 
467
- resultBuf.element( uint( 0 ) ).assign( result );
431
+ falloff.set( SPOT_FALLOFF * aspect * aspect, SPOT_FALLOFF );
432
+ point.copy( this.meteringPoint );
433
+ return;
468
434
 
469
- } );
435
+ } else {
470
436
 
471
- this._adaptationComputeNode = computeFn().compute( 1, [ 1, 1, 1 ] );
437
+ falloff.set( this.centerWeight, this.centerWeight );
472
438
 
473
- }
439
+ }
474
440
 
475
- // ──────────────────────────────────────────────────
476
- // Event listeners
477
- // ──────────────────────────────────────────────────
441
+ point.set( 0.5, 0.5 );
478
442
 
479
- setupEventListeners() {
443
+ }
480
444
 
481
- this.on( 'pipeline:reset', () => this.reset() );
482
- this.on( 'pipeline:lightingChanged', () => this.resetHistory() );
445
+ // `image` is the restart and view count it was metered at; none re-reads the same image.
446
+ _applyMetering( data, image = this._landed ) {
483
447
 
484
- }
448
+ const [ level, total, kept ] = data;
449
+ if ( ! this.enabled || ! ( total > 0 && kept > 0 ) || ! Number.isFinite( level ) ) return null;
485
450
 
486
- // ──────────────────────────────────────────────────
487
- // Render
488
- // ──────────────────────────────────────────────────
451
+ const before = this._viewEV;
452
+ this._meteredLog2 = level;
453
+ // Unclamped: the range caps where the exposure lands, not what the curve is told the scene needs.
454
+ this._viewEV = Math.log2( this.keyValue ) - level;
489
455
 
490
- render( context ) {
456
+ if ( this._roomEV === null ) {
491
457
 
492
- if ( ! this.enabled ) return;
458
+ this._roomEV = this._viewEV;
493
459
 
494
- // Resolve input texture (fallback chain)
495
- const inputTex = context.getTexture( 'edgeFiltering:output' )
496
- || context.getTexture( 'asvgf:output' )
497
- || context.getTexture( 'pathtracer:color' );
460
+ } else if ( image?.moves !== this._landed?.moves ) {
498
461
 
499
- if ( ! inputTex ) return;
462
+ this._movingUntil = this._clock + MOTION_HOLD;
500
463
 
501
- // Delta time
502
- const now = performance.now();
503
- const dt = Math.min( ( now - this.lastTime ) / 1000, 0.1 );
504
- this.lastTime = now;
505
- this.deltaTimeU.value = this.isFirstFrame ? 1.0 : dt;
506
- this.isFirstFrameU.value = this.isFirstFrame ? 1.0 : 0.0;
507
- this.previousExposureU.value = this.currentExposure;
464
+ } else {
508
465
 
509
- // Update input resolution uniforms for downsample compute
510
- this.inputResW.value = inputTex.image?.width || 1;
511
- this.inputResH.value = inputTex.image?.height || 1;
466
+ // Under a still camera the room moves with every reading: the scene changed, or the image reads better as it
467
+ // fills in. Held at a render's first reading, a final render's 1-bounce frame 0 (0.75 stops dark) became the
468
+ // room, and the finished image was exposed +2.1 stops where it asked for +1.95.
469
+ this._roomEV += this._viewEV - before;
512
470
 
513
- // ── Pass 1: Downsample full res → 64×64 (compute) ──
471
+ }
514
472
 
515
- this._inputTexNode.value = inputTex;
516
- this.renderer.compute( this._downsampleComputeNode );
517
- this.renderer.copyTextureToTexture( this._downsampleStorageTex, this._downsampleTarget.texture );
473
+ this._landed = image;
518
474
 
519
- // ── Pass 2: Histogram build (compute) ───────────────
475
+ const target = this._aim();
476
+ const moved = this._targetEV === null || Math.abs( target - this._targetEV ) > SETTLED_STOPS;
477
+ this._targetEV = target;
478
+ if ( moved || this.settling ) this.onChange?.();
479
+ return level;
520
480
 
521
- this.renderer.compute( this._histogramComputeNode );
481
+ }
522
482
 
523
- // ── Pass 3: Histogram analysis → 1×1 result ─────────
483
+ // ── Adaptation ───────────────────────────────────────────
524
484
 
525
- this.renderer.compute( this._histogramAnalyzeNode );
526
- this.renderer.copyTextureToTexture( this._reductionStorageTex, this._reductionReadTarget.texture );
485
+ /**
486
+ * Steps the exposure by the wall-clock time since the last call. Returns whether it changed.
487
+ * @param {number} [now=performance.now()]
488
+ */
489
+ update( now = performance.now() ) {
527
490
 
528
- // ── Pass 4: Temporal adaptation (compute) ───────────
491
+ const dt = this._lastUpdate === null ? 0 : Math.min( ( now - this._lastUpdate ) / 1000, MAX_STEP_SECONDS );
492
+ const changed = this.advance( dt, this._accumulating );
493
+ // Settled and still, the loop may sleep: the time until the next metering is not adaptation time.
494
+ this._lastUpdate = this.settling || this._clock <= this._movingUntil ? now : null;
495
+ return changed;
529
496
 
530
- this._reductionReadTexNode.value = this._reductionReadTarget.texture;
531
- this.renderer.compute( this._adaptationComputeNode );
497
+ }
532
498
 
533
- // ── Async readback via pooled ReadbackBuffer ─────
534
- // getArrayBufferAsync reuses the ReadbackBuffer's internal staging
535
- // GPUBuffer across frames. ReadbackBuffer.release() must be called
536
- // before it can be reused — the _pendingReadback flag gates reentry.
499
+ /**
500
+ * Steps the exposure by `seconds` towards the last metering; Infinity lands on it. Returns whether it changed.
501
+ * @param {number} seconds
502
+ * @param {boolean} [still=false] - the image is accumulating: land in ~0.3 s instead of easing
503
+ */
504
+ advance( seconds, still = false ) {
537
505
 
538
- if ( ! this._pendingReadback ) {
506
+ if ( ! this.enabled || this._targetEV === null ) return false;
539
507
 
540
- this._pendingReadback = true;
541
- const generation = this._readbackGeneration;
508
+ // The room level learns only while the view changes: staring at one view does not make it the room.
509
+ if ( seconds > 0 && seconds !== Infinity ) {
542
510
 
543
- this.renderer.getArrayBufferAsync(
544
- this._adaptationResult.value, this._readbackBuffer
545
- ).then( ( readback ) => {
511
+ this._clock += seconds;
512
+ if ( this._clock <= this._movingUntil ) {
546
513
 
547
- // Copy the 4 floats out of the mapped buffer before release(),
548
- // because release() nulls readback.buffer and unmaps the GPU buffer.
549
- const data = readback && readback.buffer
550
- ? new Float32Array( readback.buffer.slice( 0 ) )
551
- : null;
552
- this._readbackBuffer.release();
553
- this._pendingReadback = false;
514
+ this._roomEV += ( this._viewEV - this._roomEV ) * ( 1 - Math.exp( - Math.min( seconds, MAX_STEP_SECONDS ) / ROOM_SECONDS ) );
515
+ this._targetEV = this._aim();
554
516
 
555
- // Discard stale readback from before a reset
556
- if ( data && generation === this._readbackGeneration ) {
517
+ }
557
518
 
558
- this._applyReadback( data );
519
+ }
559
520
 
560
- }
521
+ const before = this._exposureEV;
522
+ if ( this._snap || this.instant ) {
561
523
 
562
- } ).catch( () => {
524
+ this._exposureEV = this._targetEV;
525
+ this._snap = false;
563
526
 
564
- try {
527
+ } else {
565
528
 
566
- this._readbackBuffer.release();
529
+ this._exposureEV = still
530
+ ? before + ( this._targetEV - before ) * ( 1 - Math.exp( - seconds / LAND_SECONDS ) )
531
+ : adaptExposureEV( before, this._targetEV, seconds, this.adaptSpeedBright, this.adaptSpeedDark );
532
+ if ( Math.abs( this._targetEV - this._exposureEV ) <= SETTLED_STOPS ) this._exposureEV = this._targetEV;
567
533
 
568
- } catch { /* buffer may not be mapped on error */ }
534
+ }
569
535
 
570
- this._pendingReadback = false;
536
+ if ( this._exposureEV === before ) return false;
537
+ this._apply();
538
+ return true;
571
539
 
572
- } );
540
+ }
573
541
 
574
- }
542
+ _inRange( ev ) {
575
543
 
576
- // ── Publish state ────────────────────────────────
544
+ return Math.min( Math.max( ev, Math.log2( this.minExposure ) ), Math.log2( this.maxExposure ) );
577
545
 
578
- context.setState( 'autoexposure:value', this.currentExposure );
579
- context.setState( 'autoexposure:avgLuminance', this.currentLuminance );
546
+ }
580
547
 
581
- this.emit( 'autoexposure:updated', {
582
- exposure: this.currentExposure,
583
- luminance: this.currentLuminance,
584
- targetExposure: this.targetExposure
585
- } );
548
+ _aim() {
586
549
 
587
- this.isFirstFrame = false;
550
+ return this._inRange( blendExposureEV( this._roomEV, this._viewEV, this.strength ) );
588
551
 
589
552
  }
590
553
 
591
- /**
592
- * Process async readback data from 1×1 adaptation target.
593
- */
594
- _applyReadback( data ) {
554
+ /** The camera moved: readings until it stops are of other views, not of a changed scene. */
555
+ noteViewChanged() {
595
556
 
596
- if ( ! this.enabled || ! data || data.length < 3 ) return;
557
+ this._viewMoves ++;
597
558
 
598
- let exposure = data[ 0 ];
599
- let luminance = data[ 1 ];
600
- let targetExp = data[ 2 ];
559
+ }
560
+
561
+ /** Whether the exposure is still on its way to the last metering. */
562
+ get settling() {
601
563
 
602
- // Validate
603
- if ( ! isFinite( exposure ) || exposure <= 0 ) exposure = 1.0;
604
- if ( ! isFinite( luminance ) || luminance <= 0 ) luminance = 0.18;
605
- if ( ! isFinite( targetExp ) || targetExp <= 0 ) targetExp = exposure;
564
+ return this.enabled && this._targetEV !== null && ( this._snap || Math.abs( this._targetEV - this._exposureEV ) > 0 );
565
+
566
+ }
606
567
 
607
- this.currentExposure = exposure;
608
- this.currentLuminance = luminance;
609
- this.targetExposure = targetExp;
568
+ /** Whether the image on screen should be metered again though no new samples arrive. */
569
+ get wantsMetering() {
610
570
 
611
- // Apply to renderer
612
- this.renderer.toneMappingExposure = exposure;
571
+ return this.enabled && ! this._pending && ( this._stale || this._targetEV === null );
613
572
 
614
573
  }
615
574
 
616
- // ──────────────────────────────────────────────────
617
- // Lifecycle
618
- // ──────────────────────────────────────────────────
575
+ _apply() {
619
576
 
620
- /**
621
- * Soft reset: preserve exposure state, let temporal smoothing adapt.
622
- * Called on pipeline:reset (camera moves, parameter changes).
623
- */
624
- reset() {
577
+ this.renderer.toneMappingExposure = this.enabled ? 2 ** this._exposureEV * this._compensation : this._compensation;
578
+ if ( ! this.enabled || ( this._emittedEV !== null && Math.abs( this._exposureEV - this._emittedEV ) < 0.005 ) ) return;
625
579
 
626
- this.lastTime = performance.now();
627
- // Bump generation so any in-flight readback is discarded
628
- this._readbackGeneration ++;
629
- this._pendingReadback = false;
580
+ this._emittedEV = this._exposureEV;
581
+ this.emit( 'autoexposure:updated', {
582
+ exposure: this.renderer.toneMappingExposure,
583
+ autoExposure: this.getExposure(),
584
+ targetExposure: this._targetEV === null ? null : 2 ** this._targetEV,
585
+ luminance: this.luminance,
586
+ } );
630
587
 
631
588
  }
632
589
 
590
+ // ── State ────────────────────────────────────────────────
591
+
633
592
  /**
634
- * Hard reset: wipe exposure history for a clean start.
635
- * Called on scene/environment changes where previous exposure is meaningless.
593
+ * @param {boolean} enabled
594
+ * @param {number} [compensation] - the manual exposure, multiplied in while on and applied alone while off
636
595
  */
637
- resetHistory() {
596
+ setEnabled( enabled, compensation = this._compensation ) {
597
+
598
+ this._compensation = compensation;
599
+ if ( enabled === this.enabled ) {
600
+
601
+ this._apply();
602
+ return;
603
+
604
+ }
605
+
606
+ this.enabled = enabled;
607
+ this._emittedEV = null;
608
+ if ( enabled ) {
638
609
 
639
- this.isFirstFrame = true;
640
- this.currentExposure = 1.0;
641
- this.currentLuminance = 0.18;
642
- this.targetExposure = 1.0;
643
- this.lastTime = performance.now();
644
- this._readbackGeneration ++;
645
- this._pendingReadback = false;
610
+ // From the manual exposure, not one left from when it was last on, perhaps on another scene.
611
+ this.resetHistory();
612
+ this._exposureEV = 0;
613
+
614
+ }
615
+
616
+ this._apply();
617
+ this.onChange?.();
646
618
 
647
619
  }
648
620
 
649
- setSize( /* width, height */ ) {
621
+ /** The manual exposure: a bias in stops on top of the metered exposure while auto exposure is on. */
622
+ setCompensation( value ) {
650
623
 
651
- // Downsample and histogram targets are fixed-size (64×64 → 256 bins → 1×1)
652
- // No resizing needed — the downsample compute shader reads input
653
- // resolution from uniforms and computes block sizes dynamically.
624
+ this._compensation = value;
625
+ this._apply();
654
626
 
655
627
  }
656
628
 
657
- setExposure( value ) {
629
+ /** Forget the exposure history: the next metering is applied at once. */
630
+ resetHistory() {
658
631
 
659
- this.currentExposure = value;
660
- this.previousExposureU.value = value;
661
- this.renderer.toneMappingExposure = value;
632
+ this._generation ++;
633
+ this._targetEV = null;
634
+ this._viewEV = null;
635
+ this._roomEV = null;
636
+ this._landed = null;
637
+ this._meteredLog2 = null;
638
+ this._snap = true;
662
639
 
663
640
  }
664
641
 
642
+ /** The metered exposure, without compensation. */
665
643
  getExposure() {
666
644
 
667
- return this.currentExposure;
645
+ return 2 ** this._exposureEV;
646
+
647
+ }
648
+
649
+ /** Geometric mean luminance of the last metering, or null. */
650
+ get luminance() {
651
+
652
+ return this._meteredLog2 === null ? null : 2 ** this._meteredLog2;
668
653
 
669
654
  }
670
655
 
671
656
  getLuminance() {
672
657
 
673
- return this.currentLuminance;
658
+ return this.luminance;
674
659
 
675
660
  }
676
661
 
677
662
  updateParameters( params ) {
678
663
 
679
- if ( params.keyValue !== undefined ) this.keyValueU.value = params.keyValue;
680
- if ( params.minExposure !== undefined ) this.minExposureU.value = params.minExposure;
681
- if ( params.maxExposure !== undefined ) this.maxExposureU.value = params.maxExposure;
682
- if ( params.adaptSpeedBright !== undefined ) this.adaptSpeedBrightU.value = params.adaptSpeedBright;
683
- if ( params.adaptSpeedDark !== undefined ) this.adaptSpeedDarkU.value = params.adaptSpeedDark;
684
- if ( params.lowPercentile !== undefined ) this.lowPercentileU.value = params.lowPercentile;
685
- if ( params.highPercentile !== undefined ) this.highPercentileU.value = params.highPercentile;
686
- if ( params.centerWeight !== undefined ) this.centerWeightU.value = params.centerWeight;
664
+ const metering = params.metering;
665
+ if ( metering !== undefined && METERING_MODES.includes( metering ) ) this.metering = metering;
666
+ if ( params.meteringPoint ) this.meteringPoint.set( params.meteringPoint.x, params.meteringPoint.y );
667
+ for ( const key of [ 'keyValue', 'minExposure', 'maxExposure', 'adaptSpeedBright', 'adaptSpeedDark', 'centerWeight', 'strength' ] ) {
668
+
669
+ if ( params[ key ] !== undefined ) this[ key ] = params[ key ];
670
+
671
+ }
672
+
673
+ if ( params.lowPercentile !== undefined ) this._lowU.value = params.lowPercentile;
674
+ if ( params.highPercentile !== undefined ) this._highU.value = params.highPercentile;
675
+
676
+ this._stale = true;
677
+ if ( this._meteredLog2 !== null ) this._applyMetering( [ this._meteredLog2, 1, 1 ] );
678
+ this.onChange?.();
687
679
 
688
680
  }
689
681
 
682
+ reset() {}
683
+
684
+ setSize() {}
685
+
690
686
  dispose() {
691
687
 
692
- this._downsampleComputeNode?.dispose();
693
- this._histogramComputeNode?.dispose();
694
- this._histogramAnalyzeNode?.dispose();
695
- this._adaptationComputeNode?.dispose();
696
- this._downsampleTarget?.dispose();
697
- this._downsampleStorageTex?.dispose();
698
- this._reductionStorageTex?.dispose();
699
- this._reductionReadTarget?.dispose();
700
- this._readbackBuffer?.dispose();
701
- this._inputTexNode?.dispose();
702
- this._reductionReadTexNode?.dispose();
688
+ this._disposed = true;
689
+ this.onChange = null;
690
+ for ( const kernel of this._kernels ) kernel.dispose();
691
+ this._readback.dispose();
692
+ this._inputNode.dispose();
703
693
 
704
694
  }
705
695