rayzee 11.2.1 → 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.
- package/README.md +91 -34
- package/dist/ArchiveImporter-Cgyx5AF7.js +7860 -0
- package/dist/BidirectionalIntegrator-BKF-cwH2.js +954 -0
- package/dist/{BufferLayout-D_IBo2ci.js → BufferLayout-xPRH9jYD.js} +41 -5
- package/dist/{NeuralRetouch-Cr-otSW8.js → NeuralRetouch-C3Q2XqbF.js} +4 -3
- package/dist/{NeuralSuperRes-B7E3_M0t.js → NeuralSuperRes-DV5TtURP.js} +11 -5
- package/dist/{PathTracer-D6nbknj5.js → PathTracer-D3MvEaZK.js} +4384 -4618
- package/dist/{PhysicalSky-Dq5VSiCQ.js → PhysicalSky-CY4jiz6a.js} +2 -2
- package/dist/{RayzeeRenderer-DbjqO6v-.js → RayzeeRenderer-D23m6NqG.js} +486 -391
- package/dist/SolarLimb-bZE6y63S.js +762 -0
- package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-CTDBvmmS.js} +31 -22
- package/dist/addons/archives.es.js +1 -1
- package/dist/addons/bidirectional.es.js +1 -1
- package/dist/addons/physical-sky.es.js +1 -1
- package/dist/rayzee-core.es.js +7 -7
- package/dist/rayzee.es.js +1327 -838
- package/dist/rayzee.umd.js +146 -134
- package/dist/sceneCachePolicy-AKOEu2lf.js +1602 -0
- package/package.json +1 -1
- package/src/EngineDefaults.js +2 -2
- package/src/Passes/AIUpscaler.js +2 -0
- package/src/PathTracerApp.js +126 -19
- package/src/Pipeline/CompletionTracker.js +7 -3
- package/src/Processor/ArchiveImporter.js +173 -94
- package/src/Processor/ArchiveReader.js +20 -23
- package/src/Processor/AssetLoader.js +48 -1
- package/src/Processor/BVHRefitter.js +109 -13
- package/src/Processor/BufferLayout.js +115 -0
- package/src/Processor/ChunkedRecords.js +3 -2
- package/src/Processor/EmissiveTriangleBuilder.js +6 -6
- package/src/Processor/GeometryExtractor.js +189 -79
- package/src/Processor/HostMemory.js +39 -9
- package/src/Processor/InstanceTable.js +714 -42
- package/src/Processor/PBRT/PBRTCurves.js +224 -6
- package/src/Processor/PBRT/PBRTMaterials.js +3 -0
- package/src/Processor/PBRT/PBRTSceneBuilder.js +82 -25
- package/src/Processor/PackedRayBuffer.js +5 -2
- package/src/Processor/SceneProcessor.js +747 -141
- package/src/Processor/TLASBuilder.js +32 -17
- package/src/Processor/ToneMapCPU.js +16 -4
- package/src/Processor/ToneMapGPU.js +30 -14
- package/src/Processor/USD/USDLayer.js +1149 -0
- package/src/Processor/USD/USDScene.js +1655 -0
- package/src/Processor/USD/USDStage.js +682 -0
- package/src/Processor/USD/USDText.js +674 -0
- package/src/Processor/USD/index.js +138 -0
- package/src/Processor/Workers/BVHRefitWorker.js +3 -3
- package/src/RayzeeRenderer.js +221 -33
- package/src/RenderSettings.js +4 -2
- package/src/Stages/AutoExposure.js +484 -494
- package/src/Stages/Compositor.js +27 -6
- package/src/Stages/LocalExposure.js +546 -0
- package/src/Stages/NormalDepth.js +7 -12
- package/src/Stages/PathTracer.js +18 -21
- package/src/Stages/PathTracerStage.js +282 -103
- package/src/Storage/GeometrySpill.js +46 -14
- package/src/Storage/SceneGraphCodec.js +17 -3
- package/src/Storage/SpillStore.js +9 -0
- package/src/TSL/BVHTraversal.js +183 -7
- package/src/TSL/Common.js +57 -13
- package/src/TSL/ShadeKernel.js +3 -2
- package/src/TSL/TextureSampling.js +11 -8
- package/src/TSL/patches.js +84 -0
- package/src/index.js +1 -0
- package/src/integrators/BidirectionalIntegrator.js +20 -2
- package/src/managers/DenoisingManager.js +15 -19
- package/src/managers/InteractionManager.js +19 -6
- package/src/managers/MaterialDataManager.js +22 -5
- package/src/managers/UniformManager.js +3 -0
- package/src/managers/VideoRenderManager.js +30 -4
- package/src/neural/NeuralRetouch.js +1 -0
- package/src/neural/NeuralSuperRes.js +6 -1
- package/dist/ArchiveImporter-D_x863yu.js +0 -5198
- package/dist/BidirectionalIntegrator-DmkxxEOR.js +0 -946
- package/dist/SolarLimb-CPaeZVwz.js +0 -721
- package/dist/sceneCachePolicy-C4-4qwt6.js +0 -485
|
@@ -1,705 +1,695 @@
|
|
|
1
|
-
import { Fn,
|
|
2
|
-
textureLoad,
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 {
|
|
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:
|
|
14
|
-
autoExposureMaxExposure:
|
|
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
|
|
17
|
+
autoExposureAdaptSpeedDark: 1.0,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
96
|
-
* Textures read:
|
|
97
|
-
* State published:
|
|
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
|
-
|
|
111
|
-
this.
|
|
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
|
-
|
|
171
|
+
}
|
|
114
172
|
|
|
115
|
-
|
|
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
|
-
|
|
175
|
+
_buildTileKernel() {
|
|
126
176
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.
|
|
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
|
-
|
|
182
|
+
return Fn( () => {
|
|
132
183
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
190
|
+
const sum = float( 0 ).toVar();
|
|
191
|
+
const cover = float( 0 ).toVar();
|
|
137
192
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
196
|
+
Loop( { start: x0.add( int( localId.x ) ), end: x1, type: 'int', condition: '<', update: step, name: 'px' }, ( { px } ) => {
|
|
142
197
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
206
|
+
} );
|
|
152
207
|
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
} );
|
|
224
|
+
workgroupBarrier();
|
|
207
225
|
|
|
208
|
-
|
|
209
|
-
this._buildHistogramCompute();
|
|
210
|
-
this._buildHistogramAnalyzeCompute();
|
|
211
|
-
this._buildAdaptationCompute();
|
|
226
|
+
}
|
|
212
227
|
|
|
213
|
-
|
|
228
|
+
If( lid.equal( uint( 0 ) ), () => {
|
|
214
229
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
249
|
+
} );
|
|
236
250
|
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
const blockOriginX = float( gx ).mul( blockW );
|
|
247
|
-
const blockOriginY = float( gy ).mul( blockH );
|
|
255
|
+
}
|
|
248
256
|
|
|
249
|
-
|
|
250
|
-
const validCount = float( 0.0 ).toVar();
|
|
257
|
+
_buildResolveKernel() {
|
|
251
258
|
|
|
252
|
-
|
|
253
|
-
|
|
259
|
+
const hist = this._histogram;
|
|
260
|
+
const result = this._result;
|
|
261
|
+
const low = this._lowU, high = this._highU;
|
|
254
262
|
|
|
255
|
-
|
|
263
|
+
return Fn( () => {
|
|
256
264
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
const t = localId.x;
|
|
266
|
+
const weight = float( atomicLoad( hist.element( t ) ) ).toVar();
|
|
267
|
+
atomicStore( hist.element( t ), uint( 0 ) );
|
|
260
268
|
|
|
261
|
-
|
|
262
|
-
|
|
269
|
+
const scan = workgroupArray( 'float', BINS );
|
|
270
|
+
const levels = workgroupArray( 'float', BINS );
|
|
271
|
+
scan.element( t ).assign( weight );
|
|
272
|
+
workgroupBarrier();
|
|
263
273
|
|
|
264
|
-
|
|
274
|
+
for ( let offset = 1; offset < BINS; offset <<= 1 ) {
|
|
265
275
|
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
const centerWeight = this.centerWeightU;
|
|
305
|
+
} );
|
|
306
|
+
workgroupBarrier();
|
|
305
307
|
|
|
306
|
-
|
|
307
|
-
const TEXELS_PER_THREAD = 16; // 4096 / 256
|
|
308
|
-
const TEX_SIZE = 64;
|
|
308
|
+
}
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
If( t.equal( uint( 0 ) ), () => {
|
|
311
311
|
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
atomicStore( histogram.element( tid ), uint( 0 ) );
|
|
316
|
-
workgroupBarrier();
|
|
315
|
+
} );
|
|
317
316
|
|
|
318
|
-
|
|
319
|
-
for ( let t = 0; t < TEXELS_PER_THREAD; t ++ ) {
|
|
317
|
+
} )().compute( [ 1, 1, 1 ], [ BINS, 1, 1 ] );
|
|
320
318
|
|
|
321
|
-
|
|
322
|
-
const px = linearIdx.mod( TEX_SIZE );
|
|
323
|
-
const py = linearIdx.div( TEX_SIZE );
|
|
319
|
+
}
|
|
324
320
|
|
|
325
|
-
|
|
326
|
-
const logLumSum = data.x;
|
|
327
|
-
const validCount = data.y;
|
|
321
|
+
// ── Pipeline ─────────────────────────────────────────────
|
|
328
322
|
|
|
329
|
-
|
|
323
|
+
setupEventListeners() {
|
|
330
324
|
|
|
331
|
-
|
|
332
|
-
|
|
325
|
+
this.on( 'pipeline:reset', () => this._restarts ++ );
|
|
326
|
+
this.on( 'pipeline:lightingChanged', () => this.resetHistory() );
|
|
333
327
|
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
const weight = dist2.mul( centerWeight ).negate().exp();
|
|
332
|
+
if ( ! this.enabled ) return;
|
|
347
333
|
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
347
|
+
context.setState( 'autoexposure:value', this.renderer.toneMappingExposure );
|
|
348
|
+
context.setState( 'autoexposure:avgLuminance', this.luminance );
|
|
358
349
|
|
|
359
350
|
}
|
|
360
351
|
|
|
361
352
|
/**
|
|
362
|
-
*
|
|
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
|
-
|
|
356
|
+
async meter( context = this.context ) {
|
|
371
357
|
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
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
|
-
|
|
362
|
+
}
|
|
378
363
|
|
|
379
|
-
|
|
380
|
-
const totalWeight = float( 0.0 ).toVar();
|
|
364
|
+
_meter( context, samples ) {
|
|
381
365
|
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
const highThreshold = totalWeight.mul( highPercentile );
|
|
377
|
+
this._workingMatrix = matrix;
|
|
378
|
+
workingLuminance( matrix, this._lumWeightsU.value );
|
|
391
379
|
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
prevCum.assign( cumWeight );
|
|
402
|
-
cumWeight.addAssign( binWeight );
|
|
394
|
+
this.renderer.compute( this._kernels );
|
|
403
395
|
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
419
|
-
const avgLogLum = logLumAccum.div( safeWeight );
|
|
420
|
-
const geometricMean = avgLogLum.exp();
|
|
409
|
+
} ).finally( () => {
|
|
421
410
|
|
|
422
|
-
|
|
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.
|
|
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
|
|
446
|
-
const
|
|
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
|
-
|
|
425
|
+
if ( this.metering === 'average' ) {
|
|
457
426
|
|
|
458
|
-
|
|
459
|
-
const geoMean = textureLoad( reductionTex, ivec2( int( 0 ), int( 0 ) ) ).x;
|
|
427
|
+
falloff.set( 0, 0 );
|
|
460
428
|
|
|
461
|
-
|
|
462
|
-
geoMean, prevExposure, keyValue,
|
|
463
|
-
minExp, maxExp, speedBright, speedDark,
|
|
464
|
-
dt, isFirst
|
|
465
|
-
);
|
|
429
|
+
} else if ( this.metering === 'spot' ) {
|
|
466
430
|
|
|
467
|
-
|
|
431
|
+
falloff.set( SPOT_FALLOFF * aspect * aspect, SPOT_FALLOFF );
|
|
432
|
+
point.copy( this.meteringPoint );
|
|
433
|
+
return;
|
|
468
434
|
|
|
469
|
-
}
|
|
435
|
+
} else {
|
|
470
436
|
|
|
471
|
-
|
|
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
|
-
|
|
443
|
+
}
|
|
480
444
|
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
+
if ( this._roomEV === null ) {
|
|
491
457
|
|
|
492
|
-
|
|
458
|
+
this._roomEV = this._viewEV;
|
|
493
459
|
|
|
494
|
-
|
|
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
|
-
|
|
462
|
+
this._movingUntil = this._clock + MOTION_HOLD;
|
|
500
463
|
|
|
501
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
471
|
+
}
|
|
514
472
|
|
|
515
|
-
this.
|
|
516
|
-
this.renderer.compute( this._downsampleComputeNode );
|
|
517
|
-
this.renderer.copyTextureToTexture( this._downsampleStorageTex, this._downsampleTarget.texture );
|
|
473
|
+
this._landed = image;
|
|
518
474
|
|
|
519
|
-
|
|
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
|
-
|
|
481
|
+
}
|
|
522
482
|
|
|
523
|
-
|
|
483
|
+
// ── Adaptation ───────────────────────────────────────────
|
|
524
484
|
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
this.renderer.compute( this._adaptationComputeNode );
|
|
497
|
+
}
|
|
532
498
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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.
|
|
506
|
+
if ( ! this.enabled || this._targetEV === null ) return false;
|
|
539
507
|
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
544
|
-
|
|
545
|
-
).then( ( readback ) => {
|
|
511
|
+
this._clock += seconds;
|
|
512
|
+
if ( this._clock <= this._movingUntil ) {
|
|
546
513
|
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
556
|
-
if ( data && generation === this._readbackGeneration ) {
|
|
517
|
+
}
|
|
557
518
|
|
|
558
|
-
|
|
519
|
+
}
|
|
559
520
|
|
|
560
|
-
|
|
521
|
+
const before = this._exposureEV;
|
|
522
|
+
if ( this._snap || this.instant ) {
|
|
561
523
|
|
|
562
|
-
|
|
524
|
+
this._exposureEV = this._targetEV;
|
|
525
|
+
this._snap = false;
|
|
563
526
|
|
|
564
|
-
|
|
527
|
+
} else {
|
|
565
528
|
|
|
566
|
-
|
|
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
|
-
|
|
534
|
+
}
|
|
569
535
|
|
|
570
|
-
|
|
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
|
-
|
|
544
|
+
return Math.min( Math.max( ev, Math.log2( this.minExposure ) ), Math.log2( this.maxExposure ) );
|
|
577
545
|
|
|
578
|
-
|
|
579
|
-
context.setState( 'autoexposure:avgLuminance', this.currentLuminance );
|
|
546
|
+
}
|
|
580
547
|
|
|
581
|
-
|
|
582
|
-
exposure: this.currentExposure,
|
|
583
|
-
luminance: this.currentLuminance,
|
|
584
|
-
targetExposure: this.targetExposure
|
|
585
|
-
} );
|
|
548
|
+
_aim() {
|
|
586
549
|
|
|
587
|
-
this.
|
|
550
|
+
return this._inRange( blendExposureEV( this._roomEV, this._viewEV, this.strength ) );
|
|
588
551
|
|
|
589
552
|
}
|
|
590
553
|
|
|
591
|
-
/**
|
|
592
|
-
|
|
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
|
-
|
|
557
|
+
this._viewMoves ++;
|
|
597
558
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Whether the exposure is still on its way to the last metering. */
|
|
562
|
+
get settling() {
|
|
601
563
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
*
|
|
635
|
-
*
|
|
593
|
+
* @param {boolean} enabled
|
|
594
|
+
* @param {number} [compensation] - the manual exposure, multiplied in while on and applied alone while off
|
|
636
595
|
*/
|
|
637
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
// resolution from uniforms and computes block sizes dynamically.
|
|
624
|
+
this._compensation = value;
|
|
625
|
+
this._apply();
|
|
654
626
|
|
|
655
627
|
}
|
|
656
628
|
|
|
657
|
-
|
|
629
|
+
/** Forget the exposure history: the next metering is applied at once. */
|
|
630
|
+
resetHistory() {
|
|
658
631
|
|
|
659
|
-
this.
|
|
660
|
-
this.
|
|
661
|
-
this.
|
|
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.
|
|
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.
|
|
658
|
+
return this.luminance;
|
|
674
659
|
|
|
675
660
|
}
|
|
676
661
|
|
|
677
662
|
updateParameters( params ) {
|
|
678
663
|
|
|
679
|
-
|
|
680
|
-
if (
|
|
681
|
-
if ( params.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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.
|
|
693
|
-
this.
|
|
694
|
-
this.
|
|
695
|
-
this.
|
|
696
|
-
this.
|
|
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
|
|