rayzee 7.3.0 → 7.4.1

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.3.0",
3
+ "version": "7.4.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -34,7 +34,7 @@
34
34
  "prepublishOnly": "npm run build"
35
35
  },
36
36
  "peerDependencies": {
37
- "three": ">=0.184.0"
37
+ "three": ">=0.185.0"
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "oidn-web": ">=0.3.0",
@@ -19,6 +19,13 @@ export const ENGINE_DEFAULTS = {
19
19
  useImportanceSampledEnvironment: true,
20
20
  environmentIntensity: 1,
21
21
  backgroundIntensity: 1,
22
+ // Solid backdrop color shown on camera-ray misses in 'color' background mode
23
+ // (showBackground=false, transparentBackground=false). Black = legacy hidden-backdrop look.
24
+ backgroundColor: '#000000',
25
+ // Backdrop blur (env background only). 0 = sharp/off (no cost). Cone-jitter blur of the
26
+ // primary-ray env lookup; lighting/reflections stay sharp. Samples = taps/frame (noise vs cost).
27
+ backgroundBlurriness: 0,
28
+ backgroundBlurSamples: 8,
22
29
  environmentRotation: 270.0,
23
30
  groundProjectionEnabled: false,
24
31
  groundProjectionRadius: 100,
@@ -64,16 +71,11 @@ export const ENGINE_DEFAULTS = {
64
71
  afScreenPoint: { x: 0.5, y: 0.5 },
65
72
  afSmoothingFactor: 0.15,
66
73
 
67
- // Multi-sample pool: S=samplesPerPixel rays/pixel/frame, FinalWrite averages them; interactive-only (renderMode 0, ≤ cap), else S=1.
68
- // Pixel cap (768²) bounds pool memory; covers the 512² default, excludes ≥768².
69
- wavefrontMultiSampleMaxPixels: 589824,
70
-
71
74
  enablePathTracer: true,
72
75
  enableAccumulation: true,
73
76
  pauseRendering: false,
74
77
  maxSamples: 60,
75
78
  bounces: 3,
76
- samplesPerPixel: 1,
77
79
  transmissiveBounces: 5,
78
80
  maxSubsurfaceSteps: 8, // interactive default: low cap (bounded random-walk SSS)
79
81
  samplingTechnique: 3,
@@ -487,7 +489,7 @@ export const DEFAULT_TEXTURE_MATRIX = [ 0, 0, 1, 1, 0, 0, 0, 1 ];
487
489
  // 'interactive' — low-sample, bounded bounces, no offline denoising, controls enabled.
488
490
  // 'production' — high-sample, deep bounces, OIDN enabled, controls disabled.
489
491
  export const PRODUCTION_RENDER_CONFIG = {
490
- maxSamples: 30, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64, samplesPerPixel: 1,
492
+ maxSamples: 30, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
491
493
  renderMode: 1, enableAlphaShadows: true,
492
494
  enableOIDN: true, oidnQuality: 'balance',
493
495
  interactionModeEnabled: false,
@@ -495,7 +497,7 @@ export const PRODUCTION_RENDER_CONFIG = {
495
497
 
496
498
  export const INTERACTIVE_RENDER_CONFIG = {
497
499
  maxSamples: ENGINE_DEFAULTS.maxSamples, bounces: ENGINE_DEFAULTS.bounces,
498
- samplesPerPixel: ENGINE_DEFAULTS.samplesPerPixel, renderMode: ENGINE_DEFAULTS.renderMode, enableAlphaShadows: ENGINE_DEFAULTS.enableAlphaShadows,
500
+ renderMode: ENGINE_DEFAULTS.renderMode, enableAlphaShadows: ENGINE_DEFAULTS.enableAlphaShadows,
499
501
  transmissiveBounces: ENGINE_DEFAULTS.transmissiveBounces,
500
502
  maxSubsurfaceSteps: ENGINE_DEFAULTS.maxSubsurfaceSteps,
501
503
  enableOIDN: false, oidnQuality: 'fast',
@@ -137,6 +137,8 @@ export class OIDNDenoiser extends EventDispatcher {
137
137
 
138
138
  // Track in-flight tile staging buffers so they can be destroyed on abort
139
139
  this._pendingStagingBuffers = new Set();
140
+ // Per-run tile-blit promises; done() awaits these so capture waits for every tile to paint.
141
+ this._pendingTileBlits = [];
140
142
 
141
143
  this.currentTZAUrl = null;
142
144
  this.unet = null;
@@ -629,6 +631,9 @@ export class OIDNDenoiser extends EventDispatcher {
629
631
 
630
632
  let abortDenoise = null;
631
633
 
634
+ // Fresh per-run list of tile-blit promises (the progress callback appends to it).
635
+ this._pendingTileBlits = [];
636
+
632
637
  const abortHandler = () => {
633
638
 
634
639
  if ( abortDenoise ) {
@@ -653,7 +658,23 @@ export class OIDNDenoiser extends EventDispatcher {
653
658
 
654
659
  try {
655
660
 
656
- await this._displayGPUOutput( output );
661
+ if ( this._pendingTileBlits.length > 0 ) {
662
+
663
+ // Normal path: the progress callback already painted every tile progressively, with
664
+ // the same exposure/saturation/tonemap/sRGB math the full-frame readback uses (verified:
665
+ // tiles tile the image exactly, no overlap). Just wait for those blits — no redundant
666
+ // full-frame re-read + re-tonemap.
667
+ await Promise.allSettled( this._pendingTileBlits );
668
+
669
+ } else {
670
+
671
+ // Degenerate fallback (no per-tile progress was emitted): one authoritative full paint.
672
+ await this._displayGPUOutput( output );
673
+
674
+ }
675
+
676
+ // DENOISING_END (which gates screenshot/video capture) fires only after this resolves,
677
+ // so the captured canvas is always complete.
657
678
  resolve();
658
679
 
659
680
  } catch ( err ) {
@@ -703,8 +724,9 @@ export class OIDNDenoiser extends EventDispatcher {
703
724
 
704
725
  device.queue.submit( [ enc.finish() ] );
705
726
 
706
- // Map and blit asynchronously — GPU copy is already queued
707
- staging.mapAsync( GPUMapMode.READ ).then( () => {
727
+ // Map and blit asynchronously — GPU copy is already queued. Track the whole chain so
728
+ // done() can await every tile paint before resolving (replaces the full-frame readback).
729
+ const tileBlit = staging.mapAsync( GPUMapMode.READ ).then( () => {
708
730
 
709
731
  const f32 = new Float32Array( staging.getMappedRange() );
710
732
  const tileImageData = new ImageData( clampedW, clampedH );
@@ -766,6 +788,8 @@ export class OIDNDenoiser extends EventDispatcher {
766
788
 
767
789
  } );
768
790
 
791
+ this._pendingTileBlits.push( tileBlit );
792
+
769
793
  }
770
794
  } );
771
795
 
@@ -959,7 +959,6 @@ export class PathTracerApp extends EventDispatcher {
959
959
  this.settings.setMany( {
960
960
  maxSamples: config.maxSamples,
961
961
  maxBounces: config.bounces,
962
- samplesPerPixel: config.samplesPerPixel,
963
962
  transmissiveBounces: config.transmissiveBounces,
964
963
  maxSubsurfaceSteps: config.maxSubsurfaceSteps,
965
964
  }, { silent: true } );
@@ -978,6 +977,10 @@ export class PathTracerApp extends EventDispatcher {
978
977
 
979
978
  }
980
979
 
980
+ // OIDN toggled directly above (bypassing setOIDNEnabled) — re-sync so the wavefront produces the
981
+ // aux MRT when OIDN is on and skips it otherwise. Runs before the reset below so kernels rebuild once.
982
+ this.denoisingManager?._syncGBufferStages?.();
983
+
981
984
  this.denoisingManager?.upscaler?.abort();
982
985
 
983
986
  if ( options.canvasWidth && options.canvasHeight ) {
@@ -24,7 +24,6 @@ export class CameraOptimizer {
24
24
  // Enhanced interaction mode settings for reduced quality during interaction
25
25
  this.interactionQualitySettings = {
26
26
  maxBounceCount: 1,
27
- numRaysPerPixel: 1,
28
27
  useEnvMapIS: false,
29
28
  // pixelRatio: 0.25,
30
29
  enableAccumulation: false,
@@ -304,28 +303,24 @@ export class CameraOptimizer {
304
303
  const presets = {
305
304
  'ultra-low': {
306
305
  maxBounceCount: 1,
307
- numRaysPerPixel: 1,
308
306
  useEnvMapIS: false,
309
307
  pixelRatio: 0.125,
310
308
  enableAccumulation: false
311
309
  },
312
310
  'low': {
313
311
  maxBounceCount: 1,
314
- numRaysPerPixel: 1,
315
312
  useEnvMapIS: false,
316
313
  pixelRatio: 0.25,
317
314
  enableAccumulation: false
318
315
  },
319
316
  'medium': {
320
317
  maxBounceCount: 2,
321
- numRaysPerPixel: 1,
322
318
  useEnvMapIS: true,
323
319
  pixelRatio: 0.5,
324
320
  enableAccumulation: false
325
321
  },
326
322
  'high': {
327
323
  maxBounceCount: 3,
328
- numRaysPerPixel: 1,
329
324
  useEnvMapIS: true,
330
325
  pixelRatio: 0.75,
331
326
  enableAccumulation: true
@@ -443,8 +443,9 @@ export class EmissiveTriangleBuilder {
443
443
  // If not emissive before and not now, nothing to do
444
444
  if ( ! isNowEmissive ) return false;
445
445
 
446
- // Fast path: just update power + CDF for affected entries
447
- const avgEmissive = ( emissive.r + emissive.g + emissive.b ) / 3.0;
446
+ // Fast path: just update power + CDF for affected entries.
447
+ // Rec.709 luma must match the build path + shader MIS weighting.
448
+ const luma = 0.2126 * emissive.r + 0.7152 * emissive.g + 0.0722 * emissive.b;
448
449
 
449
450
  this.totalEmissivePower = 0;
450
451
  for ( let i = 0; i < this.emissiveCount; i ++ ) {
@@ -452,7 +453,7 @@ export class EmissiveTriangleBuilder {
452
453
  const tri = this.emissiveTriangles[ i ];
453
454
  if ( tri.materialIndex === materialIndex ) {
454
455
 
455
- tri.power = avgEmissive * emissiveIntensity * tri.area;
456
+ tri.power = luma * emissiveIntensity * tri.area;
456
457
  tri.emissive = { r: emissive.r, g: emissive.g, b: emissive.b };
457
458
  tri.emissiveIntensity = emissiveIntensity;
458
459
  this.emissivePowerArray[ i ] = tri.power;
@@ -11,16 +11,15 @@ import { StorageInstancedBufferAttribute } from 'three/webgpu';
11
11
 
12
12
  export const RAY_STRIDE = 7;
13
13
  export const HIT_STRIDE = 2;
14
- // Per-pixel G-buffer (first-hit MRT staging): 2 uvec4/pixel (AoS, element p*GBUFFER_STRIDE + lane).
15
- // lane 0 — half-packed normal/depth/albedo (pack2x16, no f32 bitcast); read by FinalWrite:
16
- // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
17
- // lane 1 — primary-hit surface ID for A-SVGF correlated-gradient re-projection (Tier 1); written at the
18
- // bounce-0 hit, valid=0 on miss (Generate inits): .x=triIndex .y=meshIndex .z=packUnorm2x16(bary.u,bary.v) .w=valid
14
+ // Per-pixel G-buffer (first-hit MRT staging): 1 uvec4/pixel half-packed normal/depth/albedo
15
+ // (pack2x16, no f32 bitcast); read by FinalWrite:
16
+ // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
19
17
  // Separate buffer from RAY (per-pixel, not per-ray×S) — written by Generate/Shade bounce-0.
20
- export const GBUFFER_STRIDE = 2;
18
+ // (A second lane reserved for an A-SVGF Tier-1 surface ID was removed — write-only, never read.)
19
+ export const GBUFFER_STRIDE = 1;
21
20
 
22
21
  export const RAY = {
23
- ORIGIN_META: 0, // vec4(origin.xyz, uintBitsToFloat(perRayBounces | sssSteps<<8)); pixelIndex+sampleIndex derived from rayID
22
+ ORIGIN_META: 0, // vec4(origin.xyz, uintBitsToFloat(perRayBounces | sssSteps<<8)); pixelIndex == rayID
24
23
  DIR_FLAGS: 1, // vec4(direction.xyz, uintBitsToFloat(bounceFlags))
25
24
  THROUGHPUT_PDF: 2, // vec4(throughput.xyz, pdf)
26
25
  RADIANCE_ALPHA: 3, // vec4(radiance.xyz, alpha)
@@ -152,36 +151,17 @@ export const readRayRadiance = ( buf, id ) =>
152
151
  // ── Per-pixel G-buffer (first-hit MRT). 2 uvec4/pixel (AoS), pack2x16 lanes. ──
153
152
  // normal: raw unit vec3; depth: linear [0,1]; albedo: vec3 [0,1]. Packed values live in u32 lanes
154
153
  // verbatim (no f32 bitcast) so NaN-range bit patterns (snorm ±1 → 0x7FFF) survive store/load intact.
155
- // gbLane resolves the AoS slot for a pixel (lane 0 = MRT, lane 1 = surface ID).
156
- const gbLane = ( pixelIndex, lane ) => {
157
-
158
- const base = uint( pixelIndex ).mul( GBUFFER_STRIDE );
159
- return lane === 0 ? base : base.add( lane );
160
-
161
- };
154
+ // One uvec4 per pixel (stride 1); AoS base = pixelIndex * GBUFFER_STRIDE.
155
+ const gbLane = ( pixelIndex ) => uint( pixelIndex ).mul( GBUFFER_STRIDE );
162
156
 
163
157
  export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo ) =>
164
- buf.element( gbLane( pixelIndex, 0 ) ).assign( uvec4(
158
+ buf.element( gbLane( pixelIndex ) ).assign( uvec4(
165
159
  packSnorm2x16( vec2( normal.x, normal.y ) ),
166
160
  packSnorm2x16( vec2( normal.z, depth ) ),
167
161
  packUnorm2x16( vec2( albedo.x, albedo.y ) ),
168
162
  packUnorm2x16( vec2( albedo.z, 0.0 ) ),
169
163
  ) );
170
- export const readGBuffer = ( buf, pixelIndex ) => buf.element( gbLane( pixelIndex, 0 ) );
171
-
172
- // Lane 1 — primary-hit surface ID for A-SVGF correlated gradient re-projection (Tier 1).
173
- // valid=0 marks a miss (no primary surface); bary packed unorm (both in [0,1]).
174
- export const writeGBufferSurfaceID = ( buf, pixelIndex, triIndex, meshIndex, baryU, baryV, valid ) =>
175
- buf.element( gbLane( pixelIndex, 1 ) ).assign( uvec4(
176
- uint( triIndex ), uint( meshIndex ), packUnorm2x16( vec2( baryU, baryV ) ), uint( valid ),
177
- ) );
178
- export const readGBufferSurfaceID = ( buf, pixelIndex ) => {
179
-
180
- const p = buf.element( gbLane( pixelIndex, 1 ) );
181
- const bary = unpackUnorm2x16( p.z );
182
- return { triIndex: p.x, meshIndex: p.y, baryU: bary.x, baryV: bary.y, valid: p.w };
183
-
184
- };
164
+ export const readGBuffer = ( buf, pixelIndex ) => buf.element( gbLane( pixelIndex ) );
185
165
 
186
166
  // Decode for FinalWrite. normalDepth.xyz matches the prior path (normal*0.5+0.5), .w = raw depth.
187
167
  export const gbDecodeNormalDepth = ( packed ) => {
@@ -195,8 +175,8 @@ export const gbDecodeNormalDepth = ( packed ) => {
195
175
  export const gbDecodeAlbedo = ( packed ) =>
196
176
  vec3( unpackUnorm2x16( packed.z ), unpackUnorm2x16( packed.w ).x );
197
177
 
198
- // .w packs per-ray bounce state: perRayBounces (bits 0-7) | sssSteps (bits 8-15). pixelIndex +
199
- // sampleIndex are NOT stored — derived from rayID (= subSample*w*h + pixelIndex) in-kernel.
178
+ // .w packs per-ray bounce state: perRayBounces (bits 0-7) | sssSteps (bits 8-15). pixelIndex is
179
+ // NOT stored — it equals rayID (one ray per pixel).
200
180
  export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps ) =>
201
181
  buf.element( soa( id, RAY.ORIGIN_META ) )
202
182
  .assign( vec4( origin, uintBitsToFloat(
@@ -273,7 +253,6 @@ export const writeMediumSigmaA = ( buf, id, sigmaA ) =>
273
253
  // Per-ray bounce state packed into ORIGIN_META.w (written by writeRayOriginMeta alongside the origin):
274
254
  // perRayBounces = bits 0-7 (camera-bounce depth; the loop index can't track it once free bounces decouple it)
275
255
  // sssSteps = bits 8-15 (SSS random-walk step counter)
276
- // sampleIndex (the multi-sample sub-sample 0..S-1) is derived in-kernel from rayID, not stored.
277
256
  export const readPathBounces = ( buf, id ) =>
278
257
  int( floatBitsToUint( buf.element( soa( id, RAY.ORIGIN_META ) ).w ).bitAnd( 0xFF ) );
279
258
  export const readSssSteps = ( buf, id ) =>
@@ -25,6 +25,7 @@ export const RAY_FLAG = {
25
25
  // bits 16-31: spare per-ray state carried across bounces
26
26
  HAS_HIT_OPAQUE: 1 << 16, // bit 16: ray chain has hit non-transmissive geometry (transparent-bg alpha; megakernel hasHitOpaqueSurface)
27
27
  AUX_LOCKED: 1 << 17, // bit 17: OIDN aux (normal/albedo) locked onto first non-specular hit (megakernel auxLocked)
28
+ REDIRECTED: 1 << 18, // bit 18: ray has been redirected (refraction/reflection/SSS/opaque scatter) since the camera, so env it reaches is transported light (sharp), NOT the direct backdrop. NOT set by pure alpha/transparent passthrough (direction unchanged) → env through cutout holes is still the backdrop (blur/intensity/show/color/ground-projection). Set via bitOr only (positive mask) so it never disturbs ACTIVE/bounce bits.
28
29
  };
29
30
 
30
31
  export class QueueManager {
@@ -141,16 +141,24 @@ export class StorageTexturePool {
141
141
  * Copy StorageTextures → RenderTarget textures via GPU copy.
142
142
  * Must be called after each compute dispatch.
143
143
  * @param {WebGPURenderer} renderer
144
+ * @param {boolean} [includeAux=true] Copy the normalDepth + albedo attachments too. When no
145
+ * denoiser/OIDN consumes the aux MRT (default interactive), the wavefront doesn't write those
146
+ * StorageTextures, so skipping their copies saves two full-res GPU copies/frame. Color is always copied.
144
147
  */
145
- copyToReadTargets( renderer ) {
148
+ copyToReadTargets( renderer, includeAux = true ) {
146
149
 
147
150
  // Source write StorageTextures are over-allocated at the max size; the Box2 region
148
151
  // restricts the copy to the active render size so source and destination extents match.
149
152
  this._srcRegion.max.set( this.renderWidth, this.renderHeight );
150
153
 
151
154
  renderer.copyTextureToTexture( this.writeColor, this.readTarget.textures[ 0 ], this._srcRegion );
152
- renderer.copyTextureToTexture( this.writeNormalDepth, this.readTarget.textures[ 1 ], this._srcRegion );
153
- renderer.copyTextureToTexture( this.writeAlbedo, this.readTarget.textures[ 2 ], this._srcRegion );
155
+
156
+ if ( includeAux ) {
157
+
158
+ renderer.copyTextureToTexture( this.writeNormalDepth, this.readTarget.textures[ 1 ], this._srcRegion );
159
+ renderer.copyTextureToTexture( this.writeAlbedo, this.readTarget.textures[ 2 ], this._srcRegion );
160
+
161
+ }
154
162
 
155
163
  }
156
164
 
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * VRAMTracker.js — current/peak GPU memory accounting.
3
3
  *
4
- * Measures ACTUAL live bytes (attribute.array.byteLength, texture dims × format/type)
5
- * rather than re-deriving allocation formulas, so it never drifts when strides,
6
- * capacity rounding, or layouts change. Providers are thunks that read current state,
7
- * so they survive reallocation (resize, scene/material/env reload). A per-pass WeakSet
8
- * dedupes by resource identity, so overlapping registrations never double-count.
4
+ * Measures live GPU bytes: buffer attributes by backing-array byteLength, textures by
5
+ * dims × format/type but ONLY when the backend actually holds a GPUTexture. three.js
6
+ * allocates a texture's GPUTexture lazily on first use, so a constructed-but-never-dispatched
7
+ * StorageTexture (e.g. a disabled stage's render targets) costs 0 here even though its JS
8
+ * `image` dimensions are set. Residency probing needs a renderer (constructor arg or
9
+ * setRenderer); without one it falls back to counting by dimensions (legacy behavior).
10
+ * Providers are thunks that read current state, so they survive reallocation (resize,
11
+ * scene/material/env reload). A per-pass WeakSet dedupes by resource identity, so
12
+ * overlapping registrations never double-count.
9
13
  */
10
14
 
11
15
  import {
@@ -60,15 +64,23 @@ export function textureBytes( tex ) {
60
64
 
61
65
  export class VRAMTracker {
62
66
 
63
- constructor() {
67
+ constructor( renderer = null ) {
64
68
 
65
69
  this._providers = [];
70
+ this._renderer = renderer;
66
71
  this.current = 0;
67
72
  this.peak = 0;
68
73
  this.byCategory = {};
69
74
 
70
75
  }
71
76
 
77
+ /** Late-bind the renderer so texture accounting can probe actual GPU residency. */
78
+ setRenderer( renderer ) {
79
+
80
+ this._renderer = renderer;
81
+
82
+ }
83
+
72
84
  /**
73
85
  * @param {string} category - grouping label in the report
74
86
  * @param {Function} fn - returns a resource or array of resources: buffer
@@ -138,12 +150,12 @@ export class VRAMTracker {
138
150
 
139
151
  }
140
152
 
141
- // texture / render target — dedupe by object identity
153
+ // texture / render target — dedupe by object identity; count only GPU-resident bytes
142
154
  if ( r.isRenderTarget || r.isTexture ) {
143
155
 
144
156
  if ( seen.has( r ) ) return 0;
145
157
  seen.add( r );
146
- return textureBytes( r );
158
+ return this._residentTextureBytes( r );
147
159
 
148
160
  }
149
161
 
@@ -151,6 +163,36 @@ export class VRAMTracker {
151
163
 
152
164
  }
153
165
 
166
+ // three.js allocates a texture's GPUTexture lazily on first dispatch; a never-used StorageTexture
167
+ // (disabled stage) has none. Count only when the backend holds a real GPUTexture so the report is
168
+ // resident VRAM, not JS-declared dimensions. No renderer bound → assume resident (legacy behavior).
169
+ _isResident( tex ) {
170
+
171
+ const backend = this._renderer?.backend;
172
+ if ( ! backend || typeof backend.get !== 'function' ) return true;
173
+ if ( typeof backend.has === 'function' && ! backend.has( tex ) ) return false;
174
+ const data = backend.get( tex );
175
+ return !! ( data && ( data.texture || data.gpuTexture ) );
176
+
177
+ }
178
+
179
+ // RenderTarget bytes are attributed to its underlying texture(s); count each only if resident.
180
+ _residentTextureBytes( tex ) {
181
+
182
+ if ( tex.isRenderTarget ) {
183
+
184
+ const list = tex.textures?.length ? tex.textures : [ tex.texture ];
185
+ const w = tex.width || 0, h = tex.height || 0, d = tex.depth || 1;
186
+ let sum = 0;
187
+ for ( const t of list ) if ( t && this._isResident( t ) ) sum += w * h * d * texelBytes( t );
188
+ return sum;
189
+
190
+ }
191
+
192
+ return this._isResident( tex ) ? textureBytes( tex ) : 0;
193
+
194
+ }
195
+
154
196
  /** Drop the high-water mark to the current value (call when a new render begins). */
155
197
  resetPeak() {
156
198
 
@@ -1,4 +1,4 @@
1
- import { EventDispatcher } from 'three';
1
+ import { EventDispatcher, Color } from 'three';
2
2
  import { ENGINE_DEFAULTS } from './EngineDefaults.js';
3
3
  import { EngineEvents } from './EngineEvents.js';
4
4
 
@@ -16,11 +16,12 @@ const SETTING_ROUTES = {
16
16
  // ── Simple PathTracer uniforms ──────────────────────────
17
17
 
18
18
  maxBounces: { uniform: 'maxBounces', reset: true },
19
- samplesPerPixel: { uniform: 'samplesPerPixel', reset: true },
20
19
  transmissiveBounces: { uniform: 'transmissiveBounces', reset: true },
21
20
  maxSubsurfaceSteps: { uniform: 'maxSubsurfaceSteps', reset: true },
22
21
  environmentIntensity: { uniform: 'environmentIntensity', reset: true },
23
22
  backgroundIntensity: { uniform: 'backgroundIntensity', reset: true },
23
+ backgroundBlurriness: { uniform: 'backgroundBlurriness', reset: true },
24
+ backgroundBlurSamples: { uniform: 'backgroundBlurSamples', reset: true },
24
25
  showBackground: { uniform: 'showBackground', reset: true },
25
26
  enableEnvironment: { uniform: 'enableEnvironment', reset: true },
26
27
  groundProjectionEnabled: { uniform: 'groundProjectionEnabled', reset: true },
@@ -49,6 +50,7 @@ const SETTING_ROUTES = {
49
50
  interactionModeEnabled: { handler: 'handleInteractionModeEnabled', reset: false },
50
51
  maxSamples: { handler: 'handleMaxSamples', reset: false },
51
52
  transparentBackground: { handler: 'handleTransparentBackground' },
53
+ backgroundColor: { handler: 'handleBackgroundColor', reset: true },
52
54
  exposure: { handler: 'handleExposure' },
53
55
  saturation: { handler: 'handleSaturation' },
54
56
  renderLimitMode: { handler: 'handleRenderLimitMode' },
@@ -136,6 +138,15 @@ export class RenderSettings extends EventDispatcher {
136
138
 
137
139
  },
138
140
 
141
+ handleBackgroundColor: ( value ) => {
142
+
143
+ // Accept a hex string ('#rrggbb') or a Color; THREE.Color converts sRGB → linear working
144
+ // space, which is what the shader adds to radiance.
145
+ const c = value?.isColor ? value : new Color( value );
146
+ stages.pathTracer?.setUniform( 'backgroundColor', c );
147
+
148
+ },
149
+
139
150
  handleExposure: ( value ) => {
140
151
 
141
152
  // Three.js applies toneMappingExposure inside the tone-mapping branch,
@@ -832,6 +832,33 @@ export class ASVGF extends RenderStage {
832
832
 
833
833
  }
834
834
 
835
+ // Free the over-allocated 2048² StorageTextures' GPU memory when the stage is disabled, so toggling
836
+ // the denoiser off reclaims VRAM. The JS texture objects + compiled compute nodes are kept; three.js
837
+ // lazily re-creates each GPUTexture on the next dispatch after re-enable. History re-anchors via the
838
+ // warm reset (the RTs are render-res, not 2048² — left alone).
839
+ releaseGPUMemory() {
840
+
841
+ this._temporalTexA?.dispose();
842
+ this._temporalTexB?.dispose();
843
+ this._outputModulatedTex?.dispose();
844
+ this._gradientStorageTex?.dispose();
845
+ this._heatmapStorageTex?.dispose();
846
+ // Render-res RT textures (sized to render resolution → meaningful VRAM at high res). Dispose the
847
+ // .texture, not the RenderTarget (RT.dispose() doesn't free the backing GPUTexture here); these RTs
848
+ // are copyTextureToTexture targets, so three.js reallocates on the next dispatch after re-enable.
849
+ // Drop the published context outputs first so the Compositor fallback (reads asvgf:output) can't
850
+ // sample a freed texture; render() re-publishes on re-enable.
851
+ this.context?.removeTexture( 'asvgf:demodulated' );
852
+ this.context?.removeTexture( 'asvgf:output' );
853
+ this.context?.removeTexture( 'asvgf:gradient' );
854
+ this._demodulatedRT?.texture?.dispose();
855
+ this._outputRT?.texture?.dispose();
856
+ this._gradientRT?.texture?.dispose();
857
+ this.heatmapTarget?.texture?.dispose();
858
+ this.resetTemporalData();
859
+
860
+ }
861
+
835
862
  dispose() {
836
863
 
837
864
  this._gradientNode?.dispose();
@@ -367,6 +367,18 @@ export class BilateralFilter extends RenderStage {
367
367
 
368
368
  }
369
369
 
370
+ // Free the 2048² StorageTextures when disabled; three.js re-creates them on the next dispatch
371
+ // after re-enable (no temporal state to re-anchor). See ASVGF.releaseGPUMemory.
372
+ releaseGPUMemory() {
373
+
374
+ this._storageTexA?.dispose();
375
+ this._storageTexB?.dispose();
376
+ // Render-res RT texture (dispose .texture, not the RT — RT.dispose() doesn't free it here).
377
+ this.context?.removeTexture( 'bilateralFiltering:output' );
378
+ this._outputTarget?.texture?.dispose();
379
+
380
+ }
381
+
370
382
  reset() {
371
383
 
372
384
  // No temporal state to reset
@@ -267,6 +267,18 @@ export class EdgeFilter extends RenderStage {
267
267
 
268
268
  }
269
269
 
270
+ // Free the 2048² StorageTexture when disabled; three.js re-creates it on the next dispatch
271
+ // after re-enable, and reset() clears the iteration history. See ASVGF.releaseGPUMemory.
272
+ releaseGPUMemory() {
273
+
274
+ this._outputStorageTex?.dispose();
275
+ // Render-res RT texture (dispose .texture, not the RT — RT.dispose() doesn't free it here).
276
+ this.context?.removeTexture( 'edgeFiltering:output' );
277
+ this.outputTarget?.texture?.dispose();
278
+ this.reset();
279
+
280
+ }
281
+
270
282
  reset() {
271
283
 
272
284
  this._iterations = 0;
@@ -495,6 +495,16 @@ export class MotionVector extends RenderStage {
495
495
  * Reset — intentionally does NOT reset matricesInitialized.
496
496
  * Motion vectors must track camera motion across pipeline resets.
497
497
  */
498
+ // Free the 2048² StorageTextures when disabled (no consumer); three.js re-creates them on the next
499
+ // dispatch after re-enable. _syncGBufferStages additionally reseeds camera history on re-enable.
500
+ releaseGPUMemory() {
501
+
502
+ this._screenSpaceStorageTex?.dispose();
503
+ this._worldSpaceStorageTex?.dispose();
504
+ this.reset();
505
+
506
+ }
507
+
498
508
  reset() {
499
509
 
500
510
  if ( ! this.matricesInitialized ) {
@@ -367,6 +367,16 @@ export class NormalDepth extends RenderStage {
367
367
 
368
368
  }
369
369
 
370
+ // Free the 2048² StorageTextures when disabled (no consumer); three.js re-creates them on the next
371
+ // dispatch after re-enable, and reset() re-arms the dirty/history fast-path. See ASVGF.releaseGPUMemory.
372
+ releaseGPUMemory() {
373
+
374
+ this._outputStorageTex?.dispose();
375
+ this._shadingStorageTex?.dispose();
376
+ this.reset();
377
+
378
+ }
379
+
370
380
  reset() {
371
381
 
372
382
  this._dirty = true;