rayzee 7.15.2 → 7.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.15.2",
3
+ "version": "7.17.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -184,11 +184,26 @@ export const ENGINE_DEFAULTS = {
184
184
  // only round-trip exactly when both sides agree.
185
185
  export const ALBEDO_EPS = 0.01;
186
186
 
187
- // Per-resolution compute StorageTextures are pre-allocated at this size and never
188
- // resized (works around three.js r184 StorageTexture-resize bugs — see TSL/patches
189
- // history). Render resolution must not exceed this on either axis; the engine warns
190
- // and ignores larger requests.
191
- export const MAX_STORAGE_TEXTURE_SIZE = 2048;
187
+ // Hard ceiling the engine supports for the reserved (pre-allocated) render size. 4K (3840×2160)
188
+ // fits within 4096². Raising the reserved size to this pins ~1.5 GB of MRT textures — opt-in only.
189
+ export const MAX_RESERVABLE_RENDER_SIZE = 4096;
190
+
191
+ // Reserved render size: every per-resolution compute StorageTexture + aux buffer is pre-allocated at
192
+ // this SQUARE size and never resized at runtime (works around three.js StorageTexture-resize bugs —
193
+ // see TSL/patches history). Render resolution must not exceed it; the engine warns + ignores larger.
194
+ // It is a LIVE binding (mutable) so the host can raise it (e.g. for 4K) BEFORE the pipeline/stages are
195
+ // constructed via setReservedRenderSize(); consumers read it inside their constructors. Default 2048
196
+ // (zero VRAM regression). See docs/internal/specs/wavefront-chunked-pool.md.
197
+ export let MAX_STORAGE_TEXTURE_SIZE = 2048;
198
+
199
+ // Set the reserved render size. MUST be called before pipeline construction (stages pre-allocate at
200
+ // this value and cannot resize). Clamped to [256, MAX_RESERVABLE_RENDER_SIZE]. Returns the applied value.
201
+ export function setReservedRenderSize( px ) {
202
+
203
+ MAX_STORAGE_TEXTURE_SIZE = Math.max( 256, Math.min( MAX_RESERVABLE_RENDER_SIZE, Math.floor( px ) || 256 ) );
204
+ return MAX_STORAGE_TEXTURE_SIZE;
205
+
206
+ }
192
207
 
193
208
  export const ASVGF_QUALITY_PRESETS = {
194
209
  // phiColor / phiDepth are RELATIVE tolerances (fractions). Bigger = more
@@ -16,7 +16,7 @@ import { AutoExposure } from './Stages/AutoExposure.js';
16
16
  import { Compositor } from './Stages/Compositor.js';
17
17
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
18
18
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
19
- import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE } from './EngineDefaults.js';
19
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize } from './EngineDefaults.js';
20
20
  import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory } from './Processor/utils.js';
21
21
  import { BuildTimer } from './Processor/BuildTimer.js';
22
22
  import { InteractionManager } from './managers/InteractionManager.js';
@@ -775,11 +775,7 @@ export class PathTracerApp extends EventDispatcher {
775
775
  this.reset();
776
776
  this.cameraManager.currentCameraIndex = 0;
777
777
  this.dispatchEvent( eventPayload );
778
- this.dispatchEvent( {
779
- type: 'CamerasUpdated',
780
- cameras: this.cameraManager.cameras,
781
- cameraNames: this.cameraManager.getCameraNames()
782
- } );
778
+ this._dispatchCamerasUpdated();
783
779
 
784
780
  } finally {
785
781
 
@@ -1093,6 +1089,57 @@ export class PathTracerApp extends EventDispatcher {
1093
1089
 
1094
1090
  }
1095
1091
 
1092
+ // ═══════════════════════════════════════════════════════════════
1093
+ // Dynamic cameras (add / remove)
1094
+ // ═══════════════════════════════════════════════════════════════
1095
+
1096
+ /** The index of the currently active camera (0 = built-in default). */
1097
+ get currentCameraIndex() {
1098
+
1099
+ return this.cameraManager?.currentCameraIndex ?? 0;
1100
+
1101
+ }
1102
+
1103
+ /**
1104
+ * Snapshot the current view as a new named camera and switch to it.
1105
+ * @param {Object} [opts]
1106
+ * @param {string} [opts.name] - Display name (auto-generated otherwise).
1107
+ * @returns {number} The index of the newly added camera.
1108
+ */
1109
+ addCamera( { name } = {} ) {
1110
+
1111
+ const index = this.cameraManager.addCameraFromView( name );
1112
+ this.cameraManager.switchCamera( index );
1113
+ this._dispatchCamerasUpdated();
1114
+ return index;
1115
+
1116
+ }
1117
+
1118
+ /**
1119
+ * Remove a user-added camera by index. Built-in and model-embedded cameras
1120
+ * are protected. Falls back to the default camera if the active one is removed.
1121
+ * @param {number} index
1122
+ * @returns {boolean} true if a camera was removed.
1123
+ */
1124
+ removeCamera( index ) {
1125
+
1126
+ const removed = this.cameraManager.removeCamera( index );
1127
+ if ( removed ) this._dispatchCamerasUpdated();
1128
+ return removed;
1129
+
1130
+ }
1131
+
1132
+ /** Notify consumers that the camera list changed (names / count). */
1133
+ _dispatchCamerasUpdated() {
1134
+
1135
+ this.dispatchEvent( {
1136
+ type: 'CamerasUpdated',
1137
+ cameras: this.cameraManager.cameras,
1138
+ cameraNames: this.cameraManager.getCameraNames(),
1139
+ } );
1140
+
1141
+ }
1142
+
1096
1143
  // ═══════════════════════════════════════════════════════════════
1097
1144
  // BVH Refit (Animation)
1098
1145
  // ═══════════════════════════════════════════════════════════════
@@ -1155,6 +1202,56 @@ export class PathTracerApp extends EventDispatcher {
1155
1202
  // Resize
1156
1203
  // ═══════════════════════════════════════════════════════════════
1157
1204
 
1205
+ /**
1206
+ * Raise (or lower) the reserved render size — the square dimension every compute StorageTexture + aux
1207
+ * buffer is pre-allocated at. Needed to enable resolutions above the 2048 default (e.g. 4K). The request
1208
+ * is device-capped: 4K reservation (~1.5 GB of MRT textures) is only granted on GPUs with ample VRAM +
1209
+ * a large storage-buffer binding limit; weaker devices clamp to 2048.
1210
+ *
1211
+ * MUST be called before the pipeline/stages are constructed (they pre-allocate at this value and cannot
1212
+ * resize). Calling it after init only takes effect on the next full pipeline (re)build.
1213
+ * @param {number} requestedPx desired reserved size (longest edge)
1214
+ * @returns {number} the applied reserved size
1215
+ */
1216
+ setReservedRenderResolution( requestedPx ) {
1217
+
1218
+ const limits = this.renderer?.backend?.device?.limits;
1219
+ const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
1220
+ const deviceMemGB = ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4;
1221
+ // 4K reserve pins the accum MRT (~1.5 GB) + aux; only grant it on clearly-capable GPUs.
1222
+ const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
1223
+ ? MAX_RESERVABLE_RENDER_SIZE : 2048;
1224
+ const prev = MAX_STORAGE_TEXTURE_SIZE;
1225
+ const applied = setReservedRenderSize( Math.min( requestedPx, deviceSafeMax ) );
1226
+
1227
+ // If the pipeline is already built and the reserved size changed, re-init the reserved GPU storage in
1228
+ // place: each stage recreates its pre-allocated StorageTextures at the new size + rebuilds its compute
1229
+ // pipelines. Stage OBJECTS, manager refs and event wiring are preserved (so no re-subscription needed);
1230
+ // scene geometry buffers are resolution-independent and reused. Rendering is paused across the swap so
1231
+ // no in-flight dispatch references a disposed texture.
1232
+ if ( applied !== prev && this.stages?.pathTracer?._packedBuffers ) {
1233
+
1234
+ const wasPaused = this.pauseRendering;
1235
+ this.pauseRendering = true;
1236
+ try {
1237
+
1238
+ for ( const stage of Object.values( this.stages ) ) stage?.reallocateReservedStorage?.();
1239
+
1240
+ } finally {
1241
+
1242
+ this.pauseRendering = wasPaused;
1243
+
1244
+ }
1245
+
1246
+ this.reset();
1247
+ this.dispatchEvent( { type: 'reserved_render_size_changed', size: applied } );
1248
+
1249
+ }
1250
+
1251
+ return applied;
1252
+
1253
+ }
1254
+
1158
1255
  /**
1159
1256
  * Guard against render resolutions the compute pipeline can't support.
1160
1257
  * Per-resolution StorageTextures are pre-allocated at MAX_STORAGE_TEXTURE_SIZE
@@ -1292,6 +1389,9 @@ export class PathTracerApp extends EventDispatcher {
1292
1389
 
1293
1390
  if ( options.canvasWidth && options.canvasHeight ) {
1294
1391
 
1392
+ // Raise the reserved storage first so a > 2048 final-render resolution (4K) fits (device-capped,
1393
+ // in-place re-init). No-op when the size already fits.
1394
+ this.setReservedRenderResolution( Math.max( options.canvasWidth, options.canvasHeight ) );
1295
1395
  this.setCanvasSize( options.canvasWidth, options.canvasHeight );
1296
1396
 
1297
1397
  }
@@ -1874,6 +1974,8 @@ export class PathTracerApp extends EventDispatcher {
1874
1974
  onResize: () => this.onResize(),
1875
1975
  onReset: () => this.reset(),
1876
1976
  getSettings: ( k ) => this.settings.get( k ),
1977
+ // Per-camera DOF restore — silent + reset:false so switchCamera's own onReset() is the single reset.
1978
+ applySettings: ( updates ) => this.settings.setMany( updates, { silent: true, reset: false } ),
1877
1979
  } );
1878
1980
 
1879
1981
  // Auto-focus context — CameraManager stores it, reads it each frame
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import {
7
- storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int,
7
+ storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int, float, clamp,
8
8
  packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16,
9
9
  } from 'three/tsl';
10
10
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
@@ -24,7 +24,7 @@ export const RAY = {
24
24
  THROUGHPUT_PDF: 2, // vec4(throughput.xyz, pdf)
25
25
  RADIANCE_ALPHA: 3, // vec4(radiance.xyz, alpha)
26
26
  MEDIUM_STACK: 4, // vec4(uintBitsToFloat(stackDepth|transTraversals<<8|wavelength<<16), ior1, ior2, ior3)
27
- MEDIUM_SIGMA_A: 5, // vec4(sigmaA.xyz, _) — Beer-Lambert absorption coeff of the active medium (KHR_materials_volume + SSS)
27
+ MEDIUM_SIGMA_A: 5, // vec4(sigmaA.xyz, featTP.w) — Beer-Lambert absorption (KHR_materials_volume + SSS) + DDFA feature-throughput tint
28
28
  SSS_SIGMA_S: 6, // vec4(sigmaS.xyz, g) — SSS scattering coeff + Henyey-Greenstein anisotropy (sigmaS==0 ⇒ glass)
29
29
  };
30
30
 
@@ -50,16 +50,11 @@ export function freeStorageAttribute( renderer, attr ) {
50
50
 
51
51
  export class PackedRayBuffer {
52
52
 
53
- // Capacity maxRays would allocate (mirrors allocate()/resize()). 1.25× headroom, NO pow2 rounding —
54
- // the pow2 jump nearly doubled VRAM (e.g. 2048²: 5.24M→8.39M) for no realloc benefit: the app's
55
- // discrete resolution presets always exceed the 1.25× margin on a tier change, so they rebuild anyway.
56
- static requiredCapacity( maxRays ) {
57
-
58
- return Math.ceil( maxRays * 1.25 );
59
-
60
- }
61
-
62
- constructor( maxRays = 0, renderer = null ) {
53
+ // `capacity` is the exact paths-in-flight budget B (Blender-style path pool). It is DECOUPLED from
54
+ // image resolution and fixed for the device — the image is streamed through the pool in row bands
55
+ // (see docs/internal/specs/wavefront-chunked-pool.md), so the SoA stride _cap never changes with
56
+ // resolution and the wavefront kernels are built once (no resize-triggered WGSL regen).
57
+ constructor( capacity = 0, renderer = null ) {
63
58
 
64
59
  this.capacity = 0;
65
60
  this._renderer = renderer;
@@ -70,15 +65,14 @@ export class PackedRayBuffer {
70
65
  this.rngBuffer = null;
71
66
  this.hitBuffer = null;
72
67
 
73
- if ( maxRays > 0 ) this.allocate( maxRays );
68
+ if ( capacity > 0 ) this.allocate( capacity );
74
69
 
75
70
  }
76
71
 
77
- allocate( maxRays ) {
72
+ allocate( capacity ) {
78
73
 
79
74
  this.dispose();
80
75
 
81
- const capacity = Math.ceil( maxRays * 1.25 );
82
76
  this.capacity = capacity;
83
77
  _cap = capacity;
84
78
 
@@ -117,12 +111,11 @@ export class PackedRayBuffer {
117
111
 
118
112
  }
119
113
 
120
- // Reallocates only if maxRays needs more capacity; returns true if it did.
121
- resize( maxRays ) {
114
+ // Reallocates only if a larger budget capacity is requested; returns true if it did.
115
+ resize( capacity ) {
122
116
 
123
- const needed = Math.ceil( maxRays * 1.25 );
124
- if ( needed <= this.capacity && this.capacity > 0 ) return false;
125
- this.allocate( maxRays );
117
+ if ( capacity <= this.capacity && this.capacity > 0 ) return false;
118
+ this.allocate( capacity );
126
119
  return true;
127
120
 
128
121
  }
@@ -258,11 +251,44 @@ export const writeMediumStack = ( buf, id, stackDepth, transTraversals, ior1, io
258
251
  stackDepth.bitOr( transTraversals.shiftLeft( 8 ) ).bitOr( wavelength.shiftLeft( 16 ) )
259
252
  ), ior1, ior2, ior3 ) );
260
253
 
261
- // Region 7: Beer-Lambert sigmaA of the active medium; single-slot, absorption gated on stackDepth>0.
254
+ // Region 7: Beer-Lambert sigmaA (.xyz) of the active medium + DDFA feature-throughput (.w).
255
+ // .xyz absorption is gated on stackDepth>0; .w carries the OIDN/ASVGF see-through aux tint (see below).
256
+ // Both writers RMW-preserve the other half so sigmaA (live in glass) and featTP never clobber each other.
262
257
  export const readMediumSigmaA = ( buf, id ) => buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).xyz;
263
258
 
264
- export const writeMediumSigmaA = ( buf, id, sigmaA ) =>
265
- buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).assign( vec4( sigmaA, 0.0 ) );
259
+ export const writeMediumSigmaA = ( buf, id, sigmaA ) => {
260
+
261
+ const keepW = buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).w.toVar();
262
+ buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).assign( vec4( sigmaA, keepW ) );
263
+
264
+ };
265
+
266
+ // DDFA (Deferred Denoising-Feature Aux) feature-throughput: a clean per-ray tint carried through smooth
267
+ // glass/mirror, committed into the OIDN/ASVGF albedo guide at the first diffuse-enough surface. Rides the
268
+ // otherwise-dead MEDIUM_SIGMA_A.w, packed 10/10/10 into 30 bits (top 2 bits zero ⇒ the float bit-pattern is
269
+ // always finite — never NaN/Inf). LDR [0,1] per channel; final albedo re-quantizes to unorm16 anyway.
270
+ const pack30 = ( tp ) => {
271
+
272
+ const c = clamp( tp, vec3( 0.0 ), vec3( 1.0 ) ).mul( 1023.0 ).add( 0.5 );
273
+ return uint( c.x ).bitOr( uint( c.y ).shiftLeft( 10 ) ).bitOr( uint( c.z ).shiftLeft( 20 ) );
274
+
275
+ };
276
+
277
+ const unpack30 = ( u ) => vec3(
278
+ float( u.bitAnd( uint( 1023 ) ) ),
279
+ float( u.shiftRight( 10 ).bitAnd( uint( 1023 ) ) ),
280
+ float( u.shiftRight( 20 ).bitAnd( uint( 1023 ) ) ),
281
+ ).div( 1023.0 );
282
+
283
+ export const readFeatureThroughput = ( buf, id ) =>
284
+ unpack30( floatBitsToUint( buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).w ) );
285
+
286
+ export const writeFeatureThroughput = ( buf, id, tp ) => {
287
+
288
+ const keepXYZ = buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).xyz.toVar();
289
+ buf.element( soa( id, RAY.MEDIUM_SIGMA_A ) ).assign( vec4( keepXYZ, uintBitsToFloat( pack30( tp ) ) ) );
290
+
291
+ };
266
292
 
267
293
  // Per-ray bounce state packed into ORIGIN_META.w (written by writeRayOriginMeta alongside the origin):
268
294
  // perRayBounces = bits 0-7 (camera-bounce depth; the loop index can't track it once free bounces decouple it)
@@ -252,8 +252,9 @@ export class RenderSettings extends EventDispatcher {
252
252
  * @param {Object} updates - Key/value pairs
253
253
  * @param {Object} [options]
254
254
  * @param {boolean} [options.silent] - Suppress settingChanged events
255
+ * @param {boolean} [options.reset] - Override the routes' default reset behavior
255
256
  */
256
- setMany( updates, { silent } = {} ) {
257
+ setMany( updates, { silent, reset } = {} ) {
257
258
 
258
259
  let needsReset = false;
259
260
 
@@ -279,7 +280,8 @@ export class RenderSettings extends EventDispatcher {
279
280
 
280
281
  }
281
282
 
282
- if ( needsReset ) this._resetCallback?.();
283
+ const shouldReset = reset !== undefined ? reset : needsReset;
284
+ if ( shouldReset ) this._resetCallback?.();
283
285
 
284
286
  }
285
287
 
@@ -796,6 +796,44 @@ export class ASVGF extends RenderStage {
796
796
 
797
797
  }
798
798
 
799
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
800
+ // reserved size + rebuild all compute nodes in place. Same stage object, so refs/wiring stay valid. Fresh
801
+ // build ⇒ the first-render warmup re-runs (no aliasing — the nodes are new, matching initial construction).
802
+ reallocateReservedStorage() {
803
+
804
+ this._gradientNode?.dispose();
805
+ this._temporalNodeA?.dispose();
806
+ this._temporalNodeB?.dispose();
807
+ this._heatmapComputeNode?.dispose();
808
+ this._temporalTexA?.dispose();
809
+ this._temporalTexB?.dispose();
810
+ this._outputModulatedTex?.dispose();
811
+ this._gradientStorageTex?.dispose();
812
+ this._heatmapStorageTex?.dispose();
813
+
814
+ const mk = ( type, filter ) => {
815
+
816
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
817
+ t.type = type; t.format = RGBAFormat; t.minFilter = filter; t.magFilter = filter;
818
+ return t;
819
+
820
+ };
821
+
822
+ this._temporalTexA = mk( FloatType, LinearFilter );
823
+ this._temporalTexB = mk( FloatType, LinearFilter );
824
+ this._outputModulatedTex = mk( FloatType, LinearFilter );
825
+ this._gradientStorageTex = mk( HalfFloatType, LinearFilter );
826
+ this._heatmapStorageTex = mk( FloatType, NearestFilter );
827
+
828
+ this._buildGradientCompute();
829
+ this._buildTemporalCompute();
830
+ this._buildHeatmapCompute();
831
+ this._compiled = false;
832
+ this.currentMoments = 0;
833
+ this._needsWarmReset = true;
834
+
835
+ }
836
+
799
837
  setSize( width, height ) {
800
838
 
801
839
  // StorageTextures stay at max alloc — see resize crash fix (three.js #33061).
@@ -385,6 +385,29 @@ export class BilateralFilter extends RenderStage {
385
385
 
386
386
  }
387
387
 
388
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
389
+ // reserved size + rebuild the compute nodes in place. Same stage object, so refs/wiring stay valid.
390
+ reallocateReservedStorage() {
391
+
392
+ this._computeNodeA?.dispose();
393
+ this._computeNodeB?.dispose();
394
+ this._storageTexA?.dispose();
395
+ this._storageTexB?.dispose();
396
+ const mk = () => {
397
+
398
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
399
+ t.type = HalfFloatType; t.format = RGBAFormat; t.minFilter = LinearFilter; t.magFilter = LinearFilter;
400
+ return t;
401
+
402
+ };
403
+
404
+ this._storageTexA = mk();
405
+ this._storageTexB = mk();
406
+ this._buildCompute();
407
+ this._compiled = false;
408
+
409
+ }
410
+
388
411
  dispose() {
389
412
 
390
413
  this._computeNodeA?.dispose();
@@ -454,6 +454,24 @@ export class EdgeFilter extends RenderStage {
454
454
 
455
455
  }
456
456
 
457
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
458
+ // reserved size + rebuild the compute nodes in place. Same stage object, so refs/wiring stay valid.
459
+ reallocateReservedStorage() {
460
+
461
+ this._computeDemod?.dispose();
462
+ this._computeAtrousA?.dispose();
463
+ this._computeAtrousB?.dispose();
464
+ this._demodTex?.dispose();
465
+ this._storageTexA?.dispose();
466
+ this._storageTexB?.dispose();
467
+ this._demodTex = this._makeStorageTex();
468
+ this._storageTexA = this._makeStorageTex();
469
+ this._storageTexB = this._makeStorageTex();
470
+ this._buildCompute();
471
+ this._compiled = false;
472
+
473
+ }
474
+
457
475
  dispose() {
458
476
 
459
477
  this._computeDemod?.dispose();
@@ -556,6 +556,29 @@ export class MotionVector extends RenderStage {
556
556
 
557
557
  }
558
558
 
559
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
560
+ // reserved size + rebuild the compute nodes in place. Same stage object, so refs/wiring stay valid.
561
+ reallocateReservedStorage() {
562
+
563
+ this._screenSpaceComputeNode?.dispose();
564
+ this._worldSpaceComputeNode?.dispose();
565
+ this._screenSpaceStorageTex?.dispose();
566
+ this._worldSpaceStorageTex?.dispose();
567
+ const mk = () => {
568
+
569
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
570
+ t.type = HalfFloatType; t.format = RGBAFormat; t.minFilter = NearestFilter; t.magFilter = NearestFilter;
571
+ return t;
572
+
573
+ };
574
+
575
+ this._screenSpaceStorageTex = mk();
576
+ this._worldSpaceStorageTex = mk();
577
+ this._buildScreenSpaceCompute();
578
+ this._buildWorldSpaceCompute();
579
+
580
+ }
581
+
559
582
  dispose() {
560
583
 
561
584
  this._screenSpaceComputeNode?.dispose();
@@ -405,6 +405,28 @@ export class NormalDepth extends RenderStage {
405
405
 
406
406
  }
407
407
 
408
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
409
+ // reserved size. The compute node rebuilds lazily on the next execute (guarded by _computeBuilt).
410
+ reallocateReservedStorage() {
411
+
412
+ this._computeNode?.dispose();
413
+ this._computeNode = null;
414
+ this._outputStorageTex?.dispose();
415
+ this._shadingStorageTex?.dispose();
416
+ const mk = () => {
417
+
418
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
419
+ t.type = HalfFloatType; t.format = RGBAFormat; t.minFilter = NearestFilter; t.magFilter = NearestFilter;
420
+ return t;
421
+
422
+ };
423
+
424
+ this._outputStorageTex = mk();
425
+ this._shadingStorageTex = mk();
426
+ this._computeBuilt = false;
427
+
428
+ }
429
+
408
430
  dispose() {
409
431
 
410
432
  this._computeNode?.dispose();