rayzee 11.3.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +33 -5
  2. package/dist/{ArchiveImporter-D735xX-4.js → ArchiveImporter-29Nl1567.js} +2 -2
  3. package/dist/{BidirectionalIntegrator-M89c0AlP.js → BidirectionalIntegrator-CJay8tXK.js} +2 -2
  4. package/dist/{NeuralRetouch-DiBH63kz.js → NeuralRetouch-KqGVPV7u.js} +4 -3
  5. package/dist/{NeuralSuperRes-CZNQP13W.js → NeuralSuperRes-BHxhYzlD.js} +11 -5
  6. package/dist/{PathTracer-BXCCVrPK.js → PathTracer-Cldt0mXi.js} +19 -12
  7. package/dist/{PhysicalSky-DqmaS0lb.js → PhysicalSky-CUWrTcmT.js} +1 -1
  8. package/dist/{RayzeeRenderer-DUSMFhmc.js → RayzeeRenderer-Dd-Dit0v.js} +313 -281
  9. package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-sAs8wdgI.js} +44 -22
  10. package/dist/addons/archives.es.js +1 -1
  11. package/dist/addons/bidirectional.es.js +1 -1
  12. package/dist/addons/physical-sky.es.js +1 -1
  13. package/dist/rayzee-core.es.js +3 -3
  14. package/dist/rayzee.es.js +1428 -902
  15. package/dist/rayzee.umd.js +124 -113
  16. package/dist/{sceneCachePolicy-BAib5fCp.js → sceneCachePolicy--ta6moJu.js} +1 -1
  17. package/package.json +1 -1
  18. package/src/EngineDefaults.js +2 -2
  19. package/src/Passes/AIUpscaler.js +17 -12
  20. package/src/PathTracerApp.js +122 -16
  21. package/src/Pipeline/CompletionTracker.js +7 -3
  22. package/src/Processor/ToneMapCPU.js +16 -4
  23. package/src/Processor/ToneMapGPU.js +49 -14
  24. package/src/RayzeeRenderer.js +54 -7
  25. package/src/RenderSettings.js +4 -2
  26. package/src/Stages/AutoExposure.js +484 -494
  27. package/src/Stages/Compositor.js +27 -6
  28. package/src/Stages/LocalExposure.js +569 -0
  29. package/src/Stages/PathTracer.js +6 -4
  30. package/src/Stages/PathTracerStage.js +9 -7
  31. package/src/index.js +1 -0
  32. package/src/managers/DenoisingManager.js +93 -34
  33. package/src/managers/VideoRenderManager.js +30 -4
  34. package/src/neural/NeuralRetouch.js +1 -0
  35. package/src/neural/NeuralSuperRes.js +6 -1
@@ -1,4 +1,4 @@
1
- import { vec4, vec3, uv, uniform, select, dot, mix, float, int, uint, storage, Fn, If } from 'three/tsl';
1
+ import { vec4, vec3, uv, screenUV, uniform, select, dot, mix, float, int, uint, storage, Fn, If } from 'three/tsl';
2
2
  import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
3
3
  import { NoBlending, NoToneMapping } from 'three';
4
4
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
@@ -49,19 +49,40 @@ export class Compositor extends RenderStage {
49
49
 
50
50
  // TextureNode reused across frames — only `.value` mutates, shader doesn't recompile.
51
51
  this._sourceTexNode = new TextureNode();
52
+ this._displayGain = null;
53
+
54
+ this.compositorMaterial = new MeshBasicNodeMaterial();
55
+ this.compositorMaterial.blending = NoBlending;
56
+ this._buildColorNode();
57
+
58
+ this.compositorQuad = new QuadMesh( this.compositorMaterial );
59
+
60
+ }
61
+
62
+ _buildColorNode() {
52
63
 
53
64
  const texSample = this._sourceTexNode.sample( uv() );
65
+ const color = this._displayGain ? texSample.xyz.mul( this._displayGain( texSample.xyz, screenUV ) ) : texSample.xyz;
54
66
 
55
- const luma = dot( texSample.xyz, REC709_LUMINANCE_COEFFICIENTS );
56
- const gradedColor = mix( vec3( luma ), texSample.xyz, this.saturation );
67
+ const luma = dot( color, REC709_LUMINANCE_COEFFICIENTS );
68
+ const gradedColor = mix( vec3( luma ), color, this.saturation );
57
69
 
58
70
  const outputAlpha = select( this._transparentBackground, texSample.w, 1.0 );
59
71
 
60
- this.compositorMaterial = new MeshBasicNodeMaterial();
61
72
  this.compositorMaterial.colorNode = vec4( gradedColor, outputAlpha );
62
- this.compositorMaterial.blending = NoBlending;
73
+ this.compositorMaterial.needsUpdate = true;
63
74
 
64
- this.compositorQuad = new QuadMesh( this.compositorMaterial );
75
+ }
76
+
77
+ /**
78
+ * A per-pixel gain on the shown picture, before exposure and the view: `( rgb, uv ) => float node`,
79
+ * `uv` the image position with y down. A readback applies the same through `_displayGain()`. Null removes it.
80
+ */
81
+ setDisplayGain( gain ) {
82
+
83
+ if ( gain === this._displayGain ) return;
84
+ this._displayGain = gain;
85
+ this._buildColorNode();
65
86
 
66
87
  }
67
88
 
@@ -0,0 +1,569 @@
1
+ import { Fn, float, int, uint, ivec2, vec2, vec4, uniform, If, Loop, max, min, select, exp, exp2, log2, dot, mix, floor,
2
+ floatBitsToUint, textureLoad, workgroupArray, workgroupBarrier, localId, workgroupId, attributeArray, storage,
3
+ atomicAdd, atomicLoad, atomicStore, toneMappingExposure } from 'three/tsl';
4
+ import { TextureNode } from 'three/webgpu';
5
+ import { Vector4 } from 'three';
6
+ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
7
+ import { MAX_RESERVABLE_RENDER_SIZE } from '../Processor/StorageTexturePool.js';
8
+
9
+ export const LOCAL_EXPOSURE_DEFAULTS = {
10
+ localExposure: false,
11
+ localExposureHighlightContrast: 0.6,
12
+ localExposureShadowContrast: 1.0,
13
+ localExposureDetailStrength: 1.0,
14
+ };
15
+
16
+ // Unreal's sizes: grid cells of 64 half-resolution texels, 32 bins, a 1/32 blurred picture.
17
+ const CELL = 128;
18
+ const CELL_TEXELS = ( CELL / 2 ) ** 2;
19
+ const BINS = 32;
20
+ const BIN_MIN = - 16; // exposed log2 luminance the bins span
21
+ const BIN_MAX = 16;
22
+ const LOG_FLOOR = - 40;
23
+ const LOG_CEIL = 40;
24
+ const FIXED = 256;
25
+ const BLUR_TEXEL = 32;
26
+ const BLUR_BLEND = 0.6;
27
+ const BLUR_PERCENT = 50;
28
+ const MIDDLE_GREY = Math.log2( 0.18 );
29
+ const DARKEST = 2 ** - 24;
30
+ const MAX_CELLS = Math.ceil( MAX_RESERVABLE_RENDER_SIZE / CELL );
31
+ const MAX_BLUR = Math.ceil( MAX_RESERVABLE_RENDER_SIZE / BLUR_TEXEL );
32
+ const Y = [ 0.2126, 0.7152, 0.0722 ];
33
+
34
+ const meterInterval = samples => samples < 16 ? 1 : samples < 64 ? 4 : 16;
35
+ const clamp = ( x, a, b ) => Math.min( Math.max( x, a ), b );
36
+
37
+ /**
38
+ * The gain local exposure applies to one pixel, from the grid and blurred picture as stored. `p` is the
39
+ * stage's 16 parameter floats (`_params`); the shader twins below must stay identical.
40
+ * @param {Float32Array} p
41
+ * @param {Float32Array} grid - two floats a bin: Σ log2 luminance and Σ weight, per cell texel
42
+ * @param {Float32Array} blur - log2 luminance of the blurred picture
43
+ * @param {number} u - image x in [0, 1]
44
+ * @param {number} v - image y in [0, 1], down
45
+ * @param {number} lum - the pixel's linear luminance
46
+ * @param {number} logExposure - log2 of the exposure it is shown at
47
+ */
48
+ export function localExposureGain( p, grid, blur, u, v, lum, logExposure ) {
49
+
50
+ // Capped: an infinite pixel's y − y would be NaN.
51
+ const logL = Math.min( Math.log2( Math.max( lum, DARKEST ) ), LOG_CEIL );
52
+
53
+ const bw = p[ 6 ], bh = p[ 7 ];
54
+ const bx = clamp( u * p[ 2 ] - 0.5, 0, bw - 1 ), by = clamp( v * p[ 3 ] - 0.5, 0, bh - 1 );
55
+ const bx0 = Math.floor( bx ), by0 = Math.floor( by ), bx1 = Math.min( bx0 + 1, bw - 1 ), by1 = Math.min( by0 + 1, bh - 1 );
56
+ const bfx = bx - bx0, bfy = by - by0;
57
+ const blurRow = ( row ) => blur[ row * bw + bx0 ] + ( blur[ row * bw + bx1 ] - blur[ row * bw + bx0 ] ) * bfx;
58
+ const blurred = blurRow( by0 ) + ( blurRow( by1 ) - blurRow( by0 ) ) * bfy;
59
+
60
+ const cw = p[ 4 ], ch = p[ 5 ];
61
+ const gx = clamp( u * p[ 0 ] - 0.5, 0, cw - 1 ), gy = clamp( v * p[ 1 ] - 0.5, 0, ch - 1 );
62
+ const gz = clamp( ( logL + p[ 8 ] - BIN_MIN ) / ( BIN_MAX - BIN_MIN ), 0, 1 ) * ( BINS - 1 );
63
+ const gx0 = Math.floor( gx ), gy0 = Math.floor( gy ), gz0 = Math.floor( gz );
64
+ const fx = gx - gx0, fy = gy - gy0, fz = gz - gz0;
65
+ let s = 0, w = 0;
66
+ for ( let k = 0; k < 8; k ++ ) {
67
+
68
+ const dx = k & 1, dy = ( k >> 1 ) & 1, dz = k >> 2;
69
+ const weight = ( dx ? fx : 1 - fx ) * ( dy ? fy : 1 - fy ) * ( dz ? fz : 1 - fz );
70
+ const i = ( ( Math.min( gy0 + dy, ch - 1 ) * cw + Math.min( gx0 + dx, cw - 1 ) ) * BINS + Math.min( gz0 + dz, BINS - 1 ) ) * 2;
71
+ s += grid[ i ] * weight;
72
+ w += grid[ i + 1 ] * weight;
73
+
74
+ }
75
+
76
+ const bilateral = w * CELL_TEXELS < 1e-3 ? blurred : s / w;
77
+ const base = bilateral + ( blurred - bilateral ) * p[ 12 ] + logExposure;
78
+ const y = logL + logExposure;
79
+ const contrast = base > p[ 13 ] ? p[ 9 ] : p[ 10 ];
80
+ return 2 ** ( p[ 13 ] + ( base - p[ 13 ] ) * contrast + ( y - base ) * p[ 11 ] - y );
81
+
82
+ }
83
+
84
+ /** The same gain in WGSL, for a pass outside three.js: group 1 holds the grid, the blurred picture and the params. */
85
+ export const LOCAL_EXPOSURE_WGSL = /* wgsl */ `
86
+ @group(1) @binding(0) var<storage, read> leGrid: array<vec2<f32>>;
87
+ @group(1) @binding(1) var<storage, read> leBlur: array<f32>;
88
+ @group(1) @binding(2) var<uniform> le: array<vec4<f32>, 4>;
89
+
90
+ fn rayzee_gain( uv: vec2<f32>, linear: vec3<f32>, exposure: f32 ) -> f32 {
91
+ let logL = min( log2( max( dot( linear, vec3<f32>( ${Y.join( ', ' )} ) ), ${DARKEST} ) ), ${LOG_CEIL}.0 );
92
+ let logExposure = log2( exposure );
93
+
94
+ let bs = vec2<i32>( le[ 1 ].zw );
95
+ let bp = clamp( uv * le[ 0 ].zw - 0.5, vec2<f32>( 0.0 ), vec2<f32>( bs - 1 ) );
96
+ let b0 = vec2<i32>( floor( bp ) );
97
+ let b1 = min( b0 + 1, bs - 1 );
98
+ let bf = bp - floor( bp );
99
+ let top = mix( leBlur[ b0.y * bs.x + b0.x ], leBlur[ b0.y * bs.x + b1.x ], bf.x );
100
+ let bottom = mix( leBlur[ b1.y * bs.x + b0.x ], leBlur[ b1.y * bs.x + b1.x ], bf.x );
101
+ let blurred = mix( top, bottom, bf.y );
102
+
103
+ let cs = vec2<i32>( le[ 1 ].xy );
104
+ let gp = clamp( uv * le[ 0 ].xy - 0.5, vec2<f32>( 0.0 ), vec2<f32>( cs - 1 ) );
105
+ let gz = clamp( ( logL + le[ 2 ].x - (${BIN_MIN}.0) ) / ${BIN_MAX - BIN_MIN}.0, 0.0, 1.0 ) * ${BINS - 1}.0;
106
+ let g0 = vec3<i32>( vec2<i32>( floor( gp ) ), i32( floor( gz ) ) );
107
+ let gf = vec3<f32>( gp - floor( gp ), gz - floor( gz ) );
108
+ var acc = vec2<f32>( 0.0 );
109
+ for ( var k = 0; k < 8; k++ ) {
110
+ let d = vec3<i32>( k & 1, ( k >> 1u ) & 1, k >> 2u );
111
+ let wv = select( 1.0 - gf, gf, d == vec3<i32>( 1 ) );
112
+ let c = min( g0 + d, vec3<i32>( cs - 1, ${BINS - 1} ) );
113
+ acc += leGrid[ ( c.y * cs.x + c.x ) * ${BINS} + c.z ] * ( wv.x * wv.y * wv.z );
114
+ }
115
+
116
+ let bilateral = select( acc.x / acc.y, blurred, acc.y * ${CELL_TEXELS}.0 < 1e-3 );
117
+ let base = mix( bilateral, blurred, le[ 3 ].x ) + logExposure;
118
+ let y = logL + logExposure;
119
+ let contrast = select( le[ 2 ].z, le[ 2 ].y, base > le[ 3 ].y );
120
+ return exp2( le[ 3 ].y + ( base - le[ 3 ].y ) * contrast + ( y - base ) * le[ 2 ].w - y );
121
+ }
122
+ `;
123
+
124
+ /**
125
+ * Local exposure — Unreal Engine 5's: the frame's log luminance is split into a base layer (a bilateral grid
126
+ * blended with a blurred picture) and detail; the base's contrast around middle grey is scaled, highlights and
127
+ * shadows apart, and the detail kept. It changes only what is shown: the compositor and every tone-mapped
128
+ * readback apply the same per-pixel gain (`gainNode`, `toneGain`), so a saved picture matches the canvas.
129
+ *
130
+ * Built from `pathtracer:color` when the image restarts, less often as it converges.
131
+ * Events listened: pipeline:reset
132
+ * Textures read: pathtracer:color
133
+ */
134
+ export class LocalExposure extends RenderStage {
135
+
136
+ constructor( renderer, options = {} ) {
137
+
138
+ super( 'LocalExposure', { ...options, executionMode: StageExecutionMode.ALWAYS } );
139
+
140
+ this.renderer = renderer;
141
+ this.highlightContrast = options.highlightContrast ?? LOCAL_EXPOSURE_DEFAULTS.localExposureHighlightContrast;
142
+ this.shadowContrast = options.shadowContrast ?? LOCAL_EXPOSURE_DEFAULTS.localExposureShadowContrast;
143
+ this.detailStrength = options.detailStrength ?? LOCAL_EXPOSURE_DEFAULTS.localExposureDetailStrength;
144
+ /** Called when the shown picture should be redrawn; the viewer wakes its loop. */
145
+ this.onChange = null;
146
+
147
+ this._grid = attributeArray( MAX_CELLS * MAX_CELLS * BINS, 'vec2' );
148
+ this._blur = attributeArray( MAX_BLUR * MAX_BLUR, 'float' );
149
+ this._blurTmp = attributeArray( MAX_BLUR * MAX_BLUR, 'float' );
150
+ this._inputNode = new TextureNode();
151
+ this._widthU = uniform( 1, 'int' );
152
+ this._heightU = uniform( 1, 'int' );
153
+ this._cellsXU = uniform( 1, 'int' );
154
+ this._blurWU = uniform( 1, 'int' );
155
+ this._blurHU = uniform( 1, 'int' );
156
+ this._radiusU = uniform( 1 );
157
+ this._logExposureU = uniform( 0 );
158
+ this._p = [ new Vector4(), new Vector4(), new Vector4(), new Vector4() ].map( v => uniform( v ) );
159
+ this._params = new Float32Array( 16 );
160
+
161
+ this._kernels = [ this._buildGridKernel(), this._buildBlurSourceKernel(), this._buildBlurKernel( this._blur, this._blurTmp, true ), this._buildBlurKernel( this._blurTmp, this._blur, false ) ];
162
+
163
+ this._built = false;
164
+ this._restarts = 0;
165
+ this._builtAt = { restarts: - 1, samples: - 1 };
166
+ this._stale = false;
167
+ this._paramsBuffer = null;
168
+ this._disposed = false;
169
+
170
+ }
171
+
172
+ // ── Kernels ──────────────────────────────────────────────
173
+
174
+ _buildGridKernel() {
175
+
176
+ const input = this._inputNode, grid = this._grid;
177
+ const width = this._widthU, height = this._heightU, cellsX = this._cellsXU, logExposure = this._logExposureU;
178
+ const yw = vec4( ...Y, 0 ).xyz;
179
+
180
+ return Fn( () => {
181
+
182
+ const lid = localId.y.mul( uint( 16 ) ).add( localId.x );
183
+ const sums = workgroupArray( 'uint', BINS ).toAtomic();
184
+ const weights = workgroupArray( 'uint', BINS ).toAtomic();
185
+ If( lid.lessThan( uint( BINS ) ), () => {
186
+
187
+ atomicStore( sums.element( lid ), uint( 0 ) );
188
+ atomicStore( weights.element( lid ), uint( 0 ) );
189
+
190
+ } );
191
+ workgroupBarrier();
192
+
193
+ const ox = int( workgroupId.x ).mul( int( CELL ) ).add( int( localId.x ).mul( int( 8 ) ) );
194
+ const oy = int( workgroupId.y ).mul( int( CELL ) ).add( int( localId.y ).mul( int( 8 ) ) );
195
+
196
+ // Each thread bins its 4×4 half-resolution texels (2×2 pixels each), split between the two nearest bins.
197
+ Loop( { start: int( 0 ), end: int( 8 ), type: 'int', condition: '<', update: 2, name: 'ty' }, ( { ty } ) => {
198
+
199
+ Loop( { start: int( 0 ), end: int( 8 ), type: 'int', condition: '<', update: 2, name: 'tx' }, ( { tx } ) => {
200
+
201
+ const x = ox.add( tx ), y = oy.add( ty );
202
+ If( x.lessThan( width ).and( y.lessThan( height ) ), () => {
203
+
204
+ const x1 = min( x.add( int( 1 ) ), width.sub( int( 1 ) ) ), y1 = min( y.add( int( 1 ) ), height.sub( int( 1 ) ) );
205
+ const lum = dot( textureLoad( input, ivec2( x, y ) ).xyz, yw )
206
+ .add( dot( textureLoad( input, ivec2( x1, y ) ).xyz, yw ) )
207
+ .add( dot( textureLoad( input, ivec2( x, y1 ) ).xyz, yw ) )
208
+ .add( dot( textureLoad( input, ivec2( x1, y1 ) ).xyz, yw ) ).mul( 0.25 ).toVar();
209
+ // A NaN or infinite texel is left out, as the meter leaves it out: max() may keep a NaN on some
210
+ // GPUs, and an infinite one would read as 2^40 and darken its whole cell.
211
+ If( floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) ), () => {
212
+
213
+ const logL = log2( max( lum, DARKEST ) ).clamp( LOG_FLOOR, LOG_CEIL ).toVar();
214
+ const f = logL.add( logExposure ).sub( BIN_MIN ).div( BIN_MAX - BIN_MIN ).clamp( 0.0, 1.0 ).mul( BINS - 1 ).toVar();
215
+ const b0 = uint( floor( f ) ).min( uint( BINS - 1 ) );
216
+ const b1 = b0.add( uint( 1 ) ).min( uint( BINS - 1 ) );
217
+ const w1 = f.sub( floor( f ) );
218
+ const w0 = float( 1 ).sub( w1 );
219
+ const level = logL.sub( LOG_FLOOR ).mul( FIXED );
220
+ atomicAdd( sums.element( b0 ), uint( level.mul( w0 ).add( 0.5 ) ) );
221
+ atomicAdd( weights.element( b0 ), uint( w0.mul( FIXED ).add( 0.5 ) ) );
222
+ atomicAdd( sums.element( b1 ), uint( level.mul( w1 ).add( 0.5 ) ) );
223
+ atomicAdd( weights.element( b1 ), uint( w1.mul( FIXED ).add( 0.5 ) ) );
224
+
225
+ } );
226
+
227
+ } );
228
+
229
+ } );
230
+
231
+ } );
232
+
233
+ workgroupBarrier();
234
+
235
+ If( lid.lessThan( uint( BINS ) ), () => {
236
+
237
+ const w = float( atomicLoad( weights.element( lid ) ) ).div( FIXED );
238
+ const s = float( atomicLoad( sums.element( lid ) ) ).div( FIXED ).add( w.mul( LOG_FLOOR ) );
239
+ const cell = uint( workgroupId.y ).mul( uint( cellsX ) ).add( uint( workgroupId.x ) );
240
+ grid.element( cell.mul( uint( BINS ) ).add( lid ) ).assign( vec2( s, w ).div( CELL_TEXELS ) );
241
+
242
+ } );
243
+
244
+ } )().compute( [ 1, 1, 1 ], [ 16, 16, 1 ] );
245
+
246
+ }
247
+
248
+ // The log of each 32×32 block's mean luminance: averaged in linear light, like the meter, so noise does not darken it.
249
+ _buildBlurSourceKernel() {
250
+
251
+ const input = this._inputNode, out = this._blur;
252
+ const width = this._widthU, height = this._heightU, blurW = this._blurWU;
253
+ const yw = vec4( ...Y, 0 ).xyz;
254
+
255
+ return Fn( () => {
256
+
257
+ const lid = localId.y.mul( uint( 8 ) ).add( localId.x );
258
+ const ox = int( workgroupId.x ).mul( int( BLUR_TEXEL ) ), oy = int( workgroupId.y ).mul( int( BLUR_TEXEL ) );
259
+ const sum = float( 0 ).toVar();
260
+ const count = float( 0 ).toVar();
261
+
262
+ Loop( { start: int( localId.y ), end: int( BLUR_TEXEL ), type: 'int', condition: '<', update: 8, name: 'by' }, ( { by } ) => {
263
+
264
+ Loop( { start: int( localId.x ), end: int( BLUR_TEXEL ), type: 'int', condition: '<', update: 8, name: 'bx' }, ( { bx } ) => {
265
+
266
+ const x = ox.add( bx ), y = oy.add( by );
267
+ If( x.lessThan( width ).and( y.lessThan( height ) ), () => {
268
+
269
+ const lum = dot( textureLoad( input, ivec2( x, y ) ).xyz, yw ).toVar();
270
+ const finite = floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) );
271
+ sum.addAssign( select( finite, lum.max( 0.0 ), float( 0 ) ) );
272
+ count.addAssign( select( finite, float( 1 ), float( 0 ) ) );
273
+
274
+ } );
275
+
276
+ } );
277
+
278
+ } );
279
+
280
+ const sharedSum = workgroupArray( 'float', 64 );
281
+ const sharedCount = workgroupArray( 'float', 64 );
282
+ sharedSum.element( lid ).assign( sum );
283
+ sharedCount.element( lid ).assign( count );
284
+ workgroupBarrier();
285
+
286
+ for ( let s = 32; s > 0; s >>= 1 ) {
287
+
288
+ If( lid.lessThan( uint( s ) ), () => {
289
+
290
+ sharedSum.element( lid ).addAssign( sharedSum.element( lid.add( uint( s ) ) ) );
291
+ sharedCount.element( lid ).addAssign( sharedCount.element( lid.add( uint( s ) ) ) );
292
+
293
+ } );
294
+ workgroupBarrier();
295
+
296
+ }
297
+
298
+ If( lid.equal( uint( 0 ) ), () => {
299
+
300
+ const mean = sharedSum.element( uint( 0 ) ).div( max( sharedCount.element( uint( 0 ) ), 1.0 ) );
301
+ out.element( uint( int( workgroupId.y ).mul( blurW ).add( int( workgroupId.x ) ) ) ).assign( log2( max( mean, DARKEST ) ).clamp( LOG_FLOOR, LOG_CEIL ) );
302
+
303
+ } );
304
+
305
+ } )().compute( [ 1, 1, 1 ], [ 8, 8, 1 ] );
306
+
307
+ }
308
+
309
+ // Unreal's Gaussian, exp( -16.7 ( x / r )² ), along one axis, mirrored at the edges.
310
+ _buildBlurKernel( src, dst, ax ) {
311
+
312
+ const blurW = this._blurWU, blurH = this._blurHU, radius = this._radiusU;
313
+
314
+ return Fn( () => {
315
+
316
+ const x = int( workgroupId.x ).mul( int( 8 ) ).add( int( localId.x ) );
317
+ const y = int( workgroupId.y ).mul( int( 8 ) ).add( int( localId.y ) );
318
+ If( x.lessThan( blurW ).and( y.lessThan( blurH ) ), () => {
319
+
320
+ const size = ax ? blurW : blurH;
321
+ const at = ax ? x : y;
322
+ const reach = int( radius.ceil() );
323
+ const sum = float( 0 ).toVar();
324
+ const total = float( 0 ).toVar();
325
+
326
+ Loop( { start: reach.negate(), end: reach.add( int( 1 ) ), type: 'int', condition: '<', name: 'o' }, ( { o } ) => {
327
+
328
+ const t = float( o ).div( radius );
329
+ const w = exp( t.mul( t ).mul( - 16.7 ) );
330
+ // Mirrored as often as it takes: the radius follows the width, so across a strip four times wider than
331
+ // tall the vertical pass reaches past one reflection, and clamping there piled the taps on row 0.
332
+ const period = size.mul( int( 2 ) );
333
+ const c = at.add( o ).mod( period ).add( period ).mod( period ).toVar();
334
+ If( c.greaterThanEqual( size ), () => {
335
+
336
+ c.assign( period.sub( c ).sub( int( 1 ) ) );
337
+
338
+ } );
339
+ const i = ax ? y.mul( blurW ).add( c ) : c.mul( blurW ).add( x );
340
+ sum.addAssign( src.element( uint( i ) ).mul( w ) );
341
+ total.addAssign( w );
342
+
343
+ } );
344
+
345
+ dst.element( uint( y.mul( blurW ).add( x ) ) ).assign( sum.div( total ) );
346
+
347
+ } );
348
+
349
+ } )().compute( [ 1, 1, 1 ], [ 8, 8, 1 ] );
350
+
351
+ }
352
+
353
+ // ── Pipeline ─────────────────────────────────────────────
354
+
355
+ setupEventListeners() {
356
+
357
+ this.on( 'pipeline:reset', () => this._restarts ++ );
358
+
359
+ }
360
+
361
+ render( context ) {
362
+
363
+ if ( ! this.enabled ) return;
364
+ const samples = context.getState( 'pathtracer:samples' ) ?? 0;
365
+ const last = this._builtAt;
366
+ if ( this._stale || ! this._built || last.restarts !== this._restarts || samples - last.samples >= meterInterval( samples ) ) this.build( context );
367
+
368
+ }
369
+
370
+ /** Builds the grid and blurred picture from the image on screen now. */
371
+ build( context = this.context ) {
372
+
373
+ const input = context?.getTexture( 'pathtracer:color' );
374
+ const width = input?.image?.width | 0, height = input?.image?.height | 0;
375
+ if ( ! input || width < 1 || height < 1 || this._disposed ) return false;
376
+
377
+ this._builtAt.restarts = this._restarts;
378
+ this._builtAt.samples = context.getState( 'pathtracer:samples' ) ?? 0;
379
+ this._stale = false;
380
+
381
+ const cellsX = Math.min( MAX_CELLS, Math.ceil( width / CELL ) ), cellsY = Math.min( MAX_CELLS, Math.ceil( height / CELL ) );
382
+ const blurW = Math.min( MAX_BLUR, Math.ceil( width / BLUR_TEXEL ) ), blurH = Math.min( MAX_BLUR, Math.ceil( height / BLUR_TEXEL ) );
383
+ const logExposure = Math.log2( Math.max( this.renderer.toneMappingExposure, 1e-6 ) );
384
+
385
+ this._inputNode.value = input;
386
+ this._widthU.value = width;
387
+ this._heightU.value = height;
388
+ this._cellsXU.value = cellsX;
389
+ this._blurWU.value = blurW;
390
+ this._blurHU.value = blurH;
391
+ this._radiusU.value = Math.max( 1, BLUR_PERCENT / 100 * blurW );
392
+ this._logExposureU.value = logExposure;
393
+ this._kernels[ 0 ].dispatchSize = [ cellsX, cellsY, 1 ];
394
+ this._kernels[ 1 ].dispatchSize = [ blurW, blurH, 1 ];
395
+ this._kernels[ 2 ].dispatchSize = this._kernels[ 3 ].dispatchSize = [ Math.ceil( blurW / 8 ), Math.ceil( blurH / 8 ), 1 ];
396
+ this.renderer.compute( this._kernels );
397
+
398
+ const p = this._params;
399
+ p.set( [ width / CELL, height / CELL, width / BLUR_TEXEL, height / BLUR_TEXEL, cellsX, cellsY, blurW, blurH, logExposure ], 0 );
400
+ this._built = true;
401
+ this._writeParams();
402
+ return true;
403
+
404
+ }
405
+
406
+ _writeParams() {
407
+
408
+ const p = this._params;
409
+ p[ 9 ] = this.highlightContrast;
410
+ p[ 10 ] = this.shadowContrast;
411
+ p[ 11 ] = this.detailStrength;
412
+ p[ 12 ] = BLUR_BLEND;
413
+ p[ 13 ] = MIDDLE_GREY;
414
+ for ( let i = 0; i < 4; i ++ ) this._p[ i ].value.fromArray( p, i * 4 );
415
+ this.onChange?.();
416
+
417
+ }
418
+
419
+ /** Whether the image on screen should be built from again though no new samples arrive. */
420
+ get wantsBuild() {
421
+
422
+ return this.enabled && ( this._stale || ! this._built );
423
+
424
+ }
425
+
426
+ updateParameters( params ) {
427
+
428
+ for ( const key of [ 'highlightContrast', 'shadowContrast', 'detailStrength' ] ) {
429
+
430
+ if ( params[ key ] !== undefined ) this[ key ] = params[ key ];
431
+
432
+ }
433
+
434
+ this._writeParams();
435
+
436
+ }
437
+
438
+ setEnabled( enabled ) {
439
+
440
+ if ( this.enabled === enabled ) return;
441
+ this.enabled = enabled;
442
+ this._stale = true;
443
+ this.onChange?.();
444
+
445
+ }
446
+
447
+ // ── Applying ─────────────────────────────────────────────
448
+
449
+ /** The gain as a TSL node, for the compositor: `rgb` the shown linear colour, `uv` its image position (y down). */
450
+ gainNode( rgb, uv ) {
451
+
452
+ const grid = storage( this._grid.value, 'vec2', MAX_CELLS * MAX_CELLS * BINS ).toReadOnly();
453
+ const blur = storage( this._blur.value, 'float', MAX_BLUR * MAX_BLUR ).toReadOnly();
454
+ const [ p0, p1, p2, p3 ] = this._p;
455
+
456
+ return Fn( ( [ color, at ] ) => {
457
+
458
+ const logL = log2( max( dot( color, vec4( ...Y, 0 ).xyz ), DARKEST ) ).min( LOG_CEIL ).toVar();
459
+ const logExposure = log2( toneMappingExposure ).toVar();
460
+
461
+ const bs = ivec2( p1.zw );
462
+ const bp = at.mul( p0.zw ).sub( 0.5 ).clamp( vec2( 0 ), vec2( bs.sub( int( 1 ) ) ) ).toVar();
463
+ const b0 = ivec2( floor( bp ) ).toVar();
464
+ const b1 = b0.add( int( 1 ) ).min( bs.sub( int( 1 ) ) ).toVar();
465
+ const bf = bp.sub( floor( bp ) ).toVar();
466
+ const blurAt = ( x, y ) => blur.element( uint( y.mul( bs.x ).add( x ) ) );
467
+ const blurred = mix( mix( blurAt( b0.x, b0.y ), blurAt( b1.x, b0.y ), bf.x ), mix( blurAt( b0.x, b1.y ), blurAt( b1.x, b1.y ), bf.x ), bf.y ).toVar();
468
+
469
+ const cs = ivec2( p1.xy );
470
+ const gp = at.mul( p0.xy ).sub( 0.5 ).clamp( vec2( 0 ), vec2( cs.sub( int( 1 ) ) ) ).toVar();
471
+ const gz = logL.add( p2.x ).sub( BIN_MIN ).div( BIN_MAX - BIN_MIN ).clamp( 0.0, 1.0 ).mul( BINS - 1 ).toVar();
472
+ const gx0 = int( floor( gp.x ) ), gy0 = int( floor( gp.y ) ), gz0 = int( floor( gz ) );
473
+ const fx = gp.x.sub( floor( gp.x ) ), fy = gp.y.sub( floor( gp.y ) ), fz = gz.sub( floor( gz ) );
474
+ const acc = vec2( 0 ).toVar();
475
+ for ( let k = 0; k < 8; k ++ ) {
476
+
477
+ const dx = k & 1, dy = ( k >> 1 ) & 1, dz = k >> 2;
478
+ const weight = ( dx ? fx : fx.oneMinus() ).mul( dy ? fy : fy.oneMinus() ).mul( dz ? fz : fz.oneMinus() );
479
+ const cx = gx0.add( int( dx ) ).min( cs.x.sub( int( 1 ) ) );
480
+ const cy = gy0.add( int( dy ) ).min( cs.y.sub( int( 1 ) ) );
481
+ const cz = gz0.add( int( dz ) ).min( int( BINS - 1 ) );
482
+ acc.addAssign( grid.element( uint( cy.mul( cs.x ).add( cx ).mul( int( BINS ) ).add( cz ) ) ).mul( weight ) );
483
+
484
+ }
485
+
486
+ const bilateral = select( acc.y.mul( CELL_TEXELS ).lessThan( 1e-3 ), blurred, acc.x.div( acc.y ) );
487
+ const base = mix( bilateral, blurred, p3.x ).add( logExposure ).toVar();
488
+ const y = logL.add( logExposure );
489
+ const contrast = select( base.greaterThan( p3.y ), p2.y, p2.z );
490
+ return exp2( p3.y.add( base.sub( p3.y ).mul( contrast ) ).add( y.sub( base ).mul( p2.w ) ).sub( y ) );
491
+
492
+ } )( rgb, uv );
493
+
494
+ }
495
+
496
+ /**
497
+ * The gain for a tone-mapping pass outside three.js (`PackedToneMapper`'s `gain`), or null before the
498
+ * first build. `cpu()` reads the grid back for the CPU tone map; `copyTo( device )` copies it to another
499
+ * GPU device (the neural models' own).
500
+ */
501
+ toneGain() {
502
+
503
+ if ( ! this.enabled || ! this._built ) return null;
504
+ const backend = this.renderer.backend;
505
+ const grid = backend.get( this._grid.value )?.buffer;
506
+ const blur = backend.get( this._blur.value )?.buffer;
507
+ if ( ! grid || ! blur ) return null;
508
+
509
+ this._paramsBuffer ??= backend.device.createBuffer( { label: 'local-exposure-params', size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST } );
510
+ backend.device.queue.writeBuffer( this._paramsBuffer, 0, this._params );
511
+ const params = this._params.slice();
512
+ const read = () => Promise.all( [ this.renderer.getArrayBufferAsync( this._grid.value ), this.renderer.getArrayBufferAsync( this._blur.value ) ] );
513
+
514
+ return {
515
+ key: 'local-exposure',
516
+ wgsl: LOCAL_EXPOSURE_WGSL,
517
+ device: backend.device,
518
+ entries: [
519
+ { binding: 0, resource: { buffer: grid } },
520
+ { binding: 1, resource: { buffer: blur } },
521
+ { binding: 2, resource: { buffer: this._paramsBuffer } },
522
+ ],
523
+ cpu: async () => {
524
+
525
+ const [ g, b ] = await read();
526
+ const gridData = new Float32Array( g ), blurData = new Float32Array( b );
527
+ return ( u, v, lum, logExposure ) => localExposureGain( params, gridData, blurData, u, v, lum, logExposure );
528
+
529
+ },
530
+ copyTo: async device => {
531
+
532
+ const [ g, b ] = await read();
533
+ const buffers = [[ g, GPUBufferUsage.STORAGE ], [ b, GPUBufferUsage.STORAGE ], [ params, GPUBufferUsage.UNIFORM ]].map( ( [ data, usage ] ) => {
534
+
535
+ const buffer = device.createBuffer( { label: 'local-exposure-copy', size: data.byteLength, usage: usage | GPUBufferUsage.COPY_DST } );
536
+ device.queue.writeBuffer( buffer, 0, data );
537
+ return buffer;
538
+
539
+ } );
540
+
541
+ return {
542
+ key: 'local-exposure',
543
+ wgsl: LOCAL_EXPOSURE_WGSL,
544
+ device,
545
+ entries: buffers.map( ( buffer, binding ) => ( { binding, resource: { buffer } } ) ),
546
+ destroy: () => buffers.forEach( buffer => buffer.destroy() ),
547
+ };
548
+
549
+ },
550
+ };
551
+
552
+ }
553
+
554
+ reset() {}
555
+
556
+ setSize() {}
557
+
558
+ dispose() {
559
+
560
+ this._disposed = true;
561
+ this.onChange = null;
562
+ for ( const kernel of this._kernels ) kernel.dispose();
563
+ this._inputNode.dispose();
564
+ this._paramsBuffer?.destroy();
565
+ this._paramsBuffer = null;
566
+
567
+ }
568
+
569
+ }
@@ -1093,9 +1093,9 @@ export class PathTracer extends PathTracerStage {
1093
1093
  */
1094
1094
  _willReadCountersThisFrame() {
1095
1095
 
1096
- if ( this.cameraChanged || this.cameraOptimizer?.isInInteractionMode() ) return false;
1096
+ if ( this.cameraOptimizer?.isInInteractionMode() ) return false;
1097
1097
  if ( this._lockstep ) return ! this._lockstepRead && this.frameCount % this._readbackEveryNFrames === 0;
1098
- if ( this._readbackPending || this._convergedReadbackPending ) return false;
1098
+ if ( this.cameraChanged || this._readbackPending || this._convergedReadbackPending ) return false;
1099
1099
  return this._readbackFrameCounter + 1 >= this._readbackEveryNFrames;
1100
1100
 
1101
1101
  }
@@ -1207,9 +1207,11 @@ export class PathTracer extends PathTracerStage {
1207
1207
 
1208
1208
  _issueLockstepReadback() {
1209
1209
 
1210
- // One in flight, and none mid-motion: frames there do not count, so frameCount stands still.
1210
+ // One in flight, and none in interaction mode: frames there do not count, so frameCount stands still. A moved
1211
+ // camera skips none: a reset's frame 0 sees one whenever the frame before it traced another view, and skipping
1212
+ // it there made the render depend on what was traced before.
1211
1213
  if ( this._lockstepRead || this.frameCount % this._readbackEveryNFrames !== 0 ) return;
1212
- if ( this.cameraChanged || this.cameraOptimizer?.isInInteractionMode() ) return;
1214
+ if ( this.cameraOptimizer?.isInInteractionMode() ) return;
1213
1215
 
1214
1216
  const bounceAttr = this._queueManager?.getBounceCountsAttribute();
1215
1217
  const counterAttr = this._queueManager?.getCountersAttribute();
@@ -115,6 +115,7 @@ export class PathTracerStage extends RenderStage {
115
115
  this.performanceMonitor = createPerformanceMonitor();
116
116
  this.completionThreshold = 0;
117
117
  this.renderLimitMode = 'frames';
118
+ this.renderTimeLimit = 0;
118
119
 
119
120
  // Initialize data textures
120
121
  this._initDataTextures();
@@ -1536,21 +1537,22 @@ export class PathTracerStage extends RenderStage {
1536
1537
  }
1537
1538
 
1538
1539
  /**
1539
- * Update completion threshold based on render mode. The ceiling holds in every limit mode —
1540
- * uncapping it under a time budget lets a generous deadline silently slow every render.
1540
+ * Update completion threshold based on render mode. The ceiling holds in 'frames' and 'time' —
1541
+ * uncapping it under a time budget lets a generous deadline silently slow every render. Only
1542
+ * 'timeOnly' lifts it, and only while a deadline is armed: without one nothing would stop the render.
1541
1543
  */
1542
1544
  updateCompletionThreshold() {
1543
1545
 
1544
- this.completionThreshold = updateCompletionThreshold(
1545
- this.renderMode.value,
1546
- this.maxSamples.value
1547
- );
1546
+ this.completionThreshold = this.renderLimitMode === 'timeOnly' && this.renderTimeLimit > 0
1547
+ ? Infinity
1548
+ : updateCompletionThreshold( this.renderMode.value, this.maxSamples.value );
1548
1549
 
1549
1550
  }
1550
1551
 
1551
- setRenderLimitMode( mode ) {
1552
+ setRenderLimitMode( mode, timeLimit = this.renderTimeLimit ) {
1552
1553
 
1553
1554
  this.renderLimitMode = mode;
1555
+ this.renderTimeLimit = timeLimit;
1554
1556
  this.updateCompletionThreshold();
1555
1557
 
1556
1558
  }