rayzee 8.4.0 → 8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "8.4.0",
3
+ "version": "8.5.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",
@@ -210,6 +210,8 @@ export const ENGINE_DEFAULTS = {
210
210
  bloomRadius: 0.15,
211
211
  bloomThreshold: 0.85,
212
212
  interactionModeEnabled: true,
213
+ // Per-axis render scale while the camera moves (0.5 = a quarter of the pixels); 1 turns it off.
214
+ interactionRenderScale: 0.5,
213
215
  debugVisScale: 100,
214
216
 
215
217
  // Denoising strategy
@@ -1062,7 +1064,8 @@ export const DEFAULT_TEXTURE_MATRIX = [ 0, 0, 1, 1, 0, 0, 0, 1 ];
1062
1064
  export const PRODUCTION_RENDER_CONFIG = {
1063
1065
  // maxSamples is a CEILING: adaptive sampling retires the frame once adaptiveStopFraction of pixels converge,
1064
1066
  // so easy scenes finish well under it while hard GI scenes use the full budget.
1065
- maxSamples: 150, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
1067
+ // Below 24 a ray that spends its transmissive budget is shaded opaque — black pixels, not dim glass.
1068
+ maxSamples: 30, bounces: 20, transmissiveBounces: 24, maxSubsurfaceSteps: 64,
1066
1069
  renderMode: 1, enableAlphaShadows: true,
1067
1070
  // 'high' is the only tier that reaches OIDN's _large weights (calb_cnrm); ~2x denoise cost.
1068
1071
  enableOIDN: true, oidnQuality: 'high',
@@ -1077,7 +1080,8 @@ export const PRODUCTION_RENDER_CONFIG = {
1077
1080
  export const INTERACTIVE_RENDER_CONFIG = {
1078
1081
  maxSamples: ENGINE_DEFAULTS.maxSamples, bounces: ENGINE_DEFAULTS.bounces,
1079
1082
  renderMode: ENGINE_DEFAULTS.renderMode, enableAlphaShadows: ENGINE_DEFAULTS.enableAlphaShadows,
1080
- transmissiveBounces: ENGINE_DEFAULTS.transmissiveBounces,
1083
+ // 12, not 5: a spent budget leaves black glass and costs MORE — the ray then bounces diffusely.
1084
+ transmissiveBounces: 12,
1081
1085
  maxSubsurfaceSteps: ENGINE_DEFAULTS.maxSubsurfaceSteps,
1082
1086
  // On, like production. The final pass costs one cheap denoise when the preview settles, and both
1083
1087
  // neural passes need a denoised frame to be worth running — on Monte-Carlo noise the upscaler
@@ -1090,6 +1094,30 @@ export const INTERACTIVE_RENDER_CONFIG = {
1090
1094
  usePixelFreeze: true, // speeds up idle refinement on heavy/high-res views; inert while moving (freeze resets)
1091
1095
  };
1092
1096
 
1097
+ // The RenderSettings a mode preset owns. configureForMode applies these, and anything that borrows a
1098
+ // mode for a while (the video renderer) restores exactly these keys.
1099
+ export function modePresetSettings( config ) {
1100
+
1101
+ return {
1102
+ maxSamples: config.maxSamples,
1103
+ maxBounces: config.bounces,
1104
+ transmissiveBounces: config.transmissiveBounces,
1105
+ maxSubsurfaceSteps: config.maxSubsurfaceSteps,
1106
+ enableAlphaShadows: config.enableAlphaShadows ?? false,
1107
+ // Tier-1 convergence early-stop
1108
+ useAdaptiveSampling: config.useAdaptiveSampling ?? false,
1109
+ noiseThreshold: config.noiseThreshold ?? ENGINE_DEFAULTS.noiseThreshold,
1110
+ adaptiveStopFraction: config.adaptiveStopFraction ?? ENGINE_DEFAULTS.adaptiveStopFraction,
1111
+ adaptiveMinSamples: config.adaptiveMinSamples ?? ENGINE_DEFAULTS.adaptiveMinSamples,
1112
+ // Tier-2 per-pixel freeze
1113
+ usePixelFreeze: config.usePixelFreeze ?? false,
1114
+ pixelFreezeThreshold: config.pixelFreezeThreshold ?? ENGINE_DEFAULTS.pixelFreezeThreshold,
1115
+ pixelFreezeStability: config.pixelFreezeStability ?? ENGINE_DEFAULTS.pixelFreezeStability,
1116
+ interactionModeEnabled: config.interactionModeEnabled ?? ENGINE_DEFAULTS.interactionModeEnabled,
1117
+ };
1118
+
1119
+ }
1120
+
1093
1121
  // Memory management constants
1094
1122
  export const MEMORY_CONSTANTS = {
1095
1123
  MAX_BUFFER_MEMORY: 1024 * 1024 * 1024,
@@ -17,7 +17,7 @@ import { AutoExposure } from './Stages/AutoExposure.js';
17
17
  import { Compositor } from './Stages/Compositor.js';
18
18
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
19
19
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
20
- import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile } from './EngineDefaults.js';
20
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile } from './EngineDefaults.js';
21
21
  import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
22
22
  import { BuildTimer } from './Processor/BuildTimer.js';
23
23
  import { createLogger, fmt } from './utils/Logger.js';
@@ -239,6 +239,12 @@ export class PathTracerApp extends EventDispatcher {
239
239
 
240
240
  // Resolution state
241
241
  this._resizeDebounceTimer = null;
242
+ // The size the host asked for. The canvas backing store is this × _renderScale, which drops
243
+ // below 1 only while the camera moves.
244
+ this._displayWidth = 0;
245
+ this._displayHeight = 0;
246
+ this._renderScale = 1;
247
+ this._pendingRenderScale = null;
242
248
 
243
249
  // Tracked listeners for clean dispose()
244
250
  this._trackedListeners = [];
@@ -342,6 +348,8 @@ export class PathTracerApp extends EventDispatcher {
342
348
 
343
349
  if ( this.cameraManager.controls ) this.cameraManager.controls.update();
344
350
 
351
+ this._applyPendingRenderScale();
352
+
345
353
  // Animation playback: compute skinned positions and refit BVH.
346
354
  // Guard prevents overlapping async refits (fire-and-forget with 1-frame latency).
347
355
  if ( this.animationManager?.isPlaying && ! this._animRefitInFlight ) {
@@ -1812,9 +1820,7 @@ export class PathTracerApp extends EventDispatcher {
1812
1820
  if ( ! this._isRenderSizeSupported( width, height ) ) return;
1813
1821
 
1814
1822
  this.renderer.setPixelRatio( 1.0 );
1815
- this.renderer.setSize( width, height, false );
1816
- this.cameraManager.camera.aspect = width / height;
1817
- this.cameraManager.camera.updateProjectionMatrix();
1823
+ this._setDisplaySize( width, height );
1818
1824
 
1819
1825
  const lastW = this.denoisingManager?._lastRenderWidth ?? 0;
1820
1826
  const lastH = this.denoisingManager?._lastRenderHeight ?? 0;
@@ -1833,7 +1839,8 @@ export class PathTracerApp extends EventDispatcher {
1833
1839
 
1834
1840
  if ( ! this._isRenderSizeSupported( renderWidth, renderHeight ) ) return;
1835
1841
 
1836
- this.pipeline?.setSize( renderWidth, renderHeight );
1842
+ this.pipeline?.setSize( this._scaled( renderWidth ), this._scaled( renderHeight ) );
1843
+ // Full size: the denoiser only runs once the camera has stopped.
1837
1844
  this.denoisingManager?.setRenderSize( renderWidth, renderHeight );
1838
1845
  this.needsReset = true;
1839
1846
 
@@ -1856,9 +1863,7 @@ export class PathTracerApp extends EventDispatcher {
1856
1863
  if ( ! this._isRenderSizeSupported( width, height ) ) return null;
1857
1864
 
1858
1865
  this.renderer.setPixelRatio( 1.0 );
1859
- this.renderer.setSize( width, height, false );
1860
- this.cameraManager.camera.aspect = width / height;
1861
- this.cameraManager.camera.updateProjectionMatrix();
1866
+ this._setDisplaySize( width, height );
1862
1867
 
1863
1868
  clearTimeout( this._resizeDebounceTimer );
1864
1869
  this._applyRenderResize( width, height );
@@ -1867,6 +1872,66 @@ export class PathTracerApp extends EventDispatcher {
1867
1872
 
1868
1873
  }
1869
1874
 
1875
+ _scaled( size ) {
1876
+
1877
+ return Math.max( 1, Math.round( size * this._renderScale ) );
1878
+
1879
+ }
1880
+
1881
+ // The wavefront takes its resolution from the canvas backing store, so that is what has to shrink.
1882
+ // updateStyle=false keeps the CSS size, and the browser stretches the smaller frame over it.
1883
+ _setDisplaySize( width, height ) {
1884
+
1885
+ this._displayWidth = width;
1886
+ this._displayHeight = height;
1887
+
1888
+ this.renderer.setSize( this._scaled( width ), this._scaled( height ), false );
1889
+ this.cameraManager.camera.aspect = width / height;
1890
+ this.cameraManager.camera.updateProjectionMatrix();
1891
+
1892
+ }
1893
+
1894
+ // OIDN as the live denoiser rebuilds its network on every size change, so it keeps full size.
1895
+ _interactionRenderScale() {
1896
+
1897
+ if ( this.denoisingManager?.continuousDenoise ) return 1;
1898
+ const scale = Number( this.settings.get( 'interactionRenderScale' ) );
1899
+ return scale > 0 ? Math.min( 1, Math.max( 0.125, scale ) ) : 1;
1900
+
1901
+ }
1902
+
1903
+ // Interaction can start inside PathTracer.render(), where resizing would pull textures out from
1904
+ // under the frame, so the change waits for the next frame boundary. No wake(): the move that
1905
+ // started it already woke the loop, and waking from inside render() would re-enter it.
1906
+ _requestRenderScale( scale ) {
1907
+
1908
+ this._pendingRenderScale = scale;
1909
+
1910
+ }
1911
+
1912
+ _applyPendingRenderScale() {
1913
+
1914
+ if ( this._pendingRenderScale !== null ) this._applyRenderScale( this._pendingRenderScale );
1915
+
1916
+ }
1917
+
1918
+ _applyRenderScale( scale ) {
1919
+
1920
+ this._pendingRenderScale = null;
1921
+ if ( this._disposed || scale === this._renderScale ) return;
1922
+
1923
+ this._renderScale = scale;
1924
+ if ( ! this._displayWidth || ! this._displayHeight ) return;
1925
+
1926
+ this._setDisplaySize( this._displayWidth, this._displayHeight );
1927
+ this.pipeline?.setSize( this._scaled( this._displayWidth ), this._scaled( this._displayHeight ) );
1928
+ // History from the other size would reproject garbage.
1929
+ this.pipeline?.eventBus.emit( 'asvgf:reset' );
1930
+ this.pipeline?.eventBus.emit( 'denoiser:reset' );
1931
+ this.needsReset = true;
1932
+
1933
+ }
1934
+
1870
1935
  // ═══════════════════════════════════════════════════════════════
1871
1936
  // Mode Configuration
1872
1937
  // ═══════════════════════════════════════════════════════════════
@@ -1889,22 +1954,7 @@ export class PathTracerApp extends EventDispatcher {
1889
1954
 
1890
1955
  // Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
1891
1956
  // `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
1892
- this.settings.setMany( {
1893
- maxSamples: config.maxSamples,
1894
- maxBounces: config.bounces,
1895
- transmissiveBounces: config.transmissiveBounces,
1896
- maxSubsurfaceSteps: config.maxSubsurfaceSteps,
1897
- enableAlphaShadows: config.enableAlphaShadows ?? false,
1898
- // Tier-1 convergence early-stop
1899
- useAdaptiveSampling: config.useAdaptiveSampling ?? false,
1900
- noiseThreshold: config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold,
1901
- adaptiveStopFraction: config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction,
1902
- adaptiveMinSamples: config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples,
1903
- // Tier-2 per-pixel freeze
1904
- usePixelFreeze: config.usePixelFreeze ?? false,
1905
- pixelFreezeThreshold: config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold,
1906
- pixelFreezeStability: config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability,
1907
- }, { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1957
+ this.settings.setMany( modePresetSettings( config ), { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1908
1958
 
1909
1959
  // renderMode has no SETTING_ROUTES entry
1910
1960
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
@@ -2014,7 +2064,7 @@ export class PathTracerApp extends EventDispatcher {
2014
2064
  * - `_bounceEarlyExitThreshold` / `_useDynamicDispatch` both consume the async
2015
2065
  * survivor curve. Kernels bind on ENTERING_COUNT, so an under-sized grid silently
2016
2066
  * drops rays, and the frame a readback lands on is GPU-scheduled.
2017
- * - `interactionModeEnabled` is a 100 ms timer that clamps bounces to 1, disables
2067
+ * - `interactionModeEnabled` is a 100 ms timer that lowers the render resolution, disables
2018
2068
  * accumulation and freezes frameCount; it engages on the very first frame.
2019
2069
  * - auto-focus raycasts per frame and auto-exposure adapts off `performance.now()`.
2020
2070
  *
@@ -3027,6 +3077,8 @@ export class PathTracerApp extends EventDispatcher {
3027
3077
  this._addTrackedListener( this.cameraManager.controls, 'change', () => {
3028
3078
 
3029
3079
  this.needsReset = true;
3080
+ // Here rather than in render(), so the first frame of the move is already at the lower resolution.
3081
+ this.stages.pathTracer?.enterInteractionMode();
3030
3082
  this.wake();
3031
3083
 
3032
3084
  } );
@@ -3053,8 +3105,15 @@ export class PathTracerApp extends EventDispatcher {
3053
3105
 
3054
3106
  const initRenderW = this.canvas.clientWidth || 1;
3055
3107
  const initRenderH = this.canvas.clientHeight || 1;
3108
+ this._displayWidth = initRenderW;
3109
+ this._displayHeight = initRenderH;
3056
3110
  this.pipeline.setSize( initRenderW, initRenderH );
3057
3111
 
3112
+ this.pipeline.eventBus.on( 'pathtracer:interactionStart', () => this._requestRenderScale( this._interactionRenderScale() ) );
3113
+ // Never fires inside a frame (a timer, or interaction mode being switched off), so it applies at once —
3114
+ // renderFrames() and the video renderer drive pipeline.render() without passing through animate().
3115
+ this.pipeline.eventBus.on( 'pathtracer:interactionEnd', () => this._applyRenderScale( 1 ) );
3116
+
3058
3117
  }
3059
3118
 
3060
3119
  async _initManagers() {
@@ -3106,6 +3165,7 @@ export class PathTracerApp extends EventDispatcher {
3106
3165
  this.denoisingManager.setOverlayManager( this.overlayManager );
3107
3166
  this.denoisingManager.setResetCallback( () => this.reset() );
3108
3167
  this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
3168
+ this.denoisingManager.setDisplayRefreshCallback( () => this.refreshFrame() );
3109
3169
  this.denoisingManager.setSettings( this.settings );
3110
3170
 
3111
3171
  // Expose environment manager (lives on pathTracer stage)
@@ -3174,6 +3234,11 @@ export class PathTracerApp extends EventDispatcher {
3174
3234
  reconcileCompletion: () => this._reconcileCompletion(),
3175
3235
  denoisingManager: this.denoisingManager,
3176
3236
  cameraManager: this.cameraManager,
3237
+ onInteractionRenderScale: () => {
3238
+
3239
+ if ( this.stages.pathTracer?.interactionMode ) this._requestRenderScale( this._interactionRenderScale() );
3240
+
3241
+ },
3177
3242
  } );
3178
3243
 
3179
3244
  this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
@@ -67,6 +67,9 @@ function standInForSplit( source ) {
67
67
 
68
68
  }
69
69
 
70
+ // Luminous efficacy glTF, three.js and Blender's exporter all assume (PBR_WATTS_TO_LUMENS).
71
+ const LUMENS_PER_WATT = 683;
72
+
70
73
  // three.js nits → the engine's radiant power, inverting areaLightRadiance.
71
74
  function areaLightPowerFactor( node, width, height, userData ) {
72
75
 
@@ -2089,17 +2092,25 @@ export class AssetLoader extends EventDispatcher {
2089
2092
 
2090
2093
  }
2091
2094
 
2092
- // glTF punctual point/spot intensity is candela (W/sr); the engine's light
2093
- // model is Blender-style Power (W) that LightSerializer divides by 4π. Convert
2094
- // glTF candela → Power once at import (×4π) so it nets to the correct I/d².
2095
- // Directional (lux ≈ irradiance) is used directly and needs no conversion.
2095
+ // Punctual lights arrive photometric: glTF (and three.js) state point/spot in
2096
+ // candela and directional in lux. The engine is radiometric Blender Watts, which
2097
+ // LightSerializer turns into W/sr with ÷4π, so the luminous efficacy has to be
2098
+ // divided back out — Blender's own glTF importer does exactly this. Skipping it
2099
+ // rendered every Blender lamp 683x too bright.
2096
2100
  if ( ( object.isPointLight || object.isSpotLight ) && ! userData.__candelaConverted ) {
2097
2101
 
2098
- object.intensity *= 4 * Math.PI;
2102
+ object.intensity *= 4 * Math.PI / LUMENS_PER_WATT;
2099
2103
  userData.__candelaConverted = true;
2100
2104
 
2101
2105
  }
2102
2106
 
2107
+ if ( object.isDirectionalLight && ! userData.__luxConverted ) {
2108
+
2109
+ object.intensity /= LUMENS_PER_WATT;
2110
+ userData.__luxConverted = true;
2111
+
2112
+ }
2113
+
2103
2114
  // Process ceiling lights
2104
2115
  if ( object.name.startsWith( 'RectAreaLightPlaceholder' ) &&
2105
2116
  userData.name
@@ -19,12 +19,11 @@ export class CameraOptimizer {
19
19
  this.interactionMode = false;
20
20
  this.interactionTimeout = null;
21
21
  this.originalValues = {};
22
+ this.appliedValues = {};
22
23
  this.wasAccumulationEnabled = true; // Track original accumulation state
23
24
 
24
25
  // Enhanced interaction mode settings for reduced quality during interaction
25
26
  this.interactionQualitySettings = {
26
- maxBounceCount: 1,
27
- // pixelRatio: 0.25,
28
27
  enableAccumulation: false,
29
28
  ...settings.qualitySettings
30
29
  };
@@ -54,6 +53,7 @@ export class CameraOptimizer {
54
53
  // Enter interaction mode and save original values
55
54
  this.interactionMode = true;
56
55
  this.originalValues = {}; // Reset stored values
56
+ this.appliedValues = {};
57
57
 
58
58
  // Store original accumulation state before any changes
59
59
  if ( this.material.uniforms.enableAccumulation ) {
@@ -80,7 +80,8 @@ export class CameraOptimizer {
80
80
 
81
81
  // Handle material uniforms
82
82
  this.originalValues[ key ] = this.material.uniforms[ key ].value;
83
- this.material.uniforms[ key ].value = this.interactionQualitySettings[ key ];
83
+ this.material.uniforms[ key ].value = this._resolveQualityValue( key, this.interactionQualitySettings[ key ] );
84
+ this.appliedValues[ key ] = this.material.uniforms[ key ].value;
84
85
 
85
86
  }
86
87
 
@@ -136,8 +137,13 @@ export class CameraOptimizer {
136
137
 
137
138
  } else if ( this.material.uniforms[ key ] ) {
138
139
 
139
- // Restore material uniforms
140
- this.material.uniforms[ key ].value = this.originalValues[ key ];
140
+ // A value written while we held it (a settings change) is newer than the one we saved.
141
+ const applied = this.appliedValues[ key ];
142
+ if ( applied === undefined || this.material.uniforms[ key ].value === applied ) {
143
+
144
+ this.material.uniforms[ key ].value = this.originalValues[ key ];
145
+
146
+ }
141
147
 
142
148
  }
143
149
 
@@ -145,6 +151,7 @@ export class CameraOptimizer {
145
151
 
146
152
  this.interactionMode = false;
147
153
  this.originalValues = {}; // Clear stored values
154
+ this.appliedValues = {};
148
155
 
149
156
  // Call exit callback first (this may trigger a reset)
150
157
  if ( this.onExitCallback ) {
@@ -213,7 +220,8 @@ export class CameraOptimizer {
213
220
 
214
221
  } else if ( this.material.uniforms[ key ] ) {
215
222
 
216
- this.material.uniforms[ key ].value = settings[ key ];
223
+ this.material.uniforms[ key ].value = this._resolveQualityValue( key, settings[ key ] );
224
+ this.appliedValues[ key ] = this.material.uniforms[ key ].value;
217
225
 
218
226
  }
219
227
 
@@ -223,6 +231,14 @@ export class CameraOptimizer {
223
231
 
224
232
  }
225
233
 
234
+ // A function derives the interaction value from the one it replaces.
235
+ _resolveQualityValue( key, target ) {
236
+
237
+ if ( typeof target !== 'function' ) return target;
238
+ return target( this.originalValues[ key ] ?? this.material.uniforms[ key ].value );
239
+
240
+ }
241
+
226
242
  /**
227
243
  * Set the interaction delay
228
244
  * @param {number} delay - Delay in milliseconds
@@ -63,6 +63,7 @@ const SETTING_ROUTES = {
63
63
  panoramaLonRange: { handler: 'handlePanoramaLonRange', reset: true },
64
64
  panoramaLatRange: { handler: 'handlePanoramaLatRange', reset: true },
65
65
  interactionModeEnabled: { handler: 'handleInteractionModeEnabled', reset: false },
66
+ interactionRenderScale: { handler: 'handleInteractionRenderScale', reset: false },
66
67
  maxSamples: { handler: 'handleMaxSamples', reset: false },
67
68
  transparentBackground: { handler: 'handleTransparentBackground' },
68
69
  backgroundColor: { handler: 'handleBackgroundColor', reset: true },
@@ -148,6 +149,7 @@ export class RenderSettings extends EventDispatcher {
148
149
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
149
150
  * @param {Object} [params.denoisingManager] - Needed to force ASVGF off under panorama
150
151
  * @param {Object} [params.cameraManager] - Needed to force auto-focus manual under panorama
152
+ * @param {Function} [params.onInteractionRenderScale] - Applies a new moving-camera render scale
151
153
  */
152
154
  bind( params ) {
153
155
 
@@ -162,7 +164,7 @@ export class RenderSettings extends EventDispatcher {
162
164
  * Builds handler functions for multi-stage settings that can't
163
165
  * be routed with a simple uniform forward.
164
166
  */
165
- _buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, cameraManager } ) {
167
+ _buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, cameraManager, onInteractionRenderScale } ) {
166
168
 
167
169
  // UniformManager copies into the existing node, so one scratch vector serves every write.
168
170
  const panoScratch = new Vector2();
@@ -279,6 +281,12 @@ export class RenderSettings extends EventDispatcher {
279
281
 
280
282
  },
281
283
 
284
+ handleInteractionRenderScale: ( value ) => {
285
+
286
+ onInteractionRenderScale?.( value );
287
+
288
+ },
289
+
282
290
  };
283
291
 
284
292
  }
@@ -88,6 +88,8 @@ export class PathTracer extends PathTracerStage {
88
88
  this._lastBounceEnergy = null;
89
89
  // maxBounces the curve was measured at; the curve is ignored once this no longer matches (-1 = none).
90
90
  this._lastBounceCountsBudget = - 1;
91
+ // Loop length the curve covers; maxBounces alone is not it (free bounces extend the loop).
92
+ this._lastBounceCountsLoopBound = - 1;
91
93
  this._readbackPending = false;
92
94
  this._readbackEveryNFrames = 4;
93
95
  this._readbackFrameCounter = 0;
@@ -341,7 +343,7 @@ export class PathTracer extends PathTracerStage {
341
343
 
342
344
  const maxBounces = this.maxBounces.value;
343
345
  // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
344
- const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
346
+ const loopBound = this._bounceLoopBound();
345
347
 
346
348
  const frameHeight = this._wfRenderHeight.value;
347
349
  const chunkRows = this._chunkRows;
@@ -356,9 +358,17 @@ export class PathTracer extends PathTracerStage {
356
358
  // new camera-bounce caps the counts are cap-independent, so trust it up to that cutoff (whole curve when
357
359
  // same/decreasing budget; only the [0, oldBudget) overlap when increasing). budget=-1 → cutoff 0 → full
358
360
  // dispatch everywhere. This avoids the full-work spike on every bounce-count change.
361
+ // A curve measured with a smaller free-bounce budget under-counts survivors at EVERY depth, not
362
+ // just past its tail, so sizing off it drops the deep glass paths the bigger budget bought.
363
+ if ( loopBound > this._lastBounceCountsLoopBound ) this._curveSizingValid = false;
364
+
359
365
  const curve = this._lastBounceCounts;
366
+ const curveMeasuredThisFar = maxBounces <= this._lastBounceCountsBudget
367
+ && loopBound <= this._lastBounceCountsLoopBound;
360
368
  const curveReliableUpto = ( singleChunk && curve )
361
- ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
369
+ ? ( curveMeasuredThisFar
370
+ ? loopBound + 1
371
+ : Math.min( this._lastBounceCountsBudget, this._lastBounceCountsLoopBound ) )
362
372
  : 0;
363
373
 
364
374
  // Blender-style row-band streaming: the fixed-budget path pool processes the image in bands of ≤ chunkRows
@@ -698,6 +708,12 @@ export class PathTracer extends PathTracerStage {
698
708
 
699
709
  }
700
710
 
711
+ _bounceLoopBound() {
712
+
713
+ return this.maxBounces.value + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
714
+
715
+ }
716
+
701
717
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
702
718
  _maybeReadbackCounters() {
703
719
 
@@ -722,7 +738,8 @@ export class PathTracer extends PathTracerStage {
722
738
  // the budget 3 -> 20 doubles the frame's dispatch work (13 -> 26 bounce iterations, 109 -> 214
723
739
  // compute passes) for as long as that lasts. Waiting out the N-frame cadence just extends it, so
724
740
  // re-measure on the next frame instead — same idiom as the mid-motion priming above.
725
- if ( this.maxBounces.value !== this._lastBounceCountsBudget ) {
741
+ if ( this.maxBounces.value !== this._lastBounceCountsBudget
742
+ || this._bounceLoopBound() !== this._lastBounceCountsLoopBound ) {
726
743
 
727
744
  this._readbackFrameCounter = this._readbackEveryNFrames;
728
745
 
@@ -738,6 +755,7 @@ export class PathTracer extends PathTracerStage {
738
755
  this._readbackPending = true;
739
756
  const gen = this._readbackGeneration;
740
757
  const budget = this.maxBounces.value;
758
+ const measuredLoopBound = this._bounceLoopBound();
741
759
  const n = this._queueManager.MAX_BOUNCE_SNAPSHOTS;
742
760
  this.renderer.getArrayBufferAsync( attr ).then( ( buf ) => {
743
761
 
@@ -750,6 +768,7 @@ export class PathTracer extends PathTracerStage {
750
768
  this._lastBounceCounts = all.subarray( 0, n );
751
769
  this._lastBounceEnergy = all.subarray( n, 2 * n );
752
770
  this._lastBounceCountsBudget = budget;
771
+ this._lastBounceCountsLoopBound = measuredLoopBound;
753
772
  this._curveSizingValid = true;
754
773
 
755
774
  }
@@ -834,9 +853,25 @@ export class PathTracer extends PathTracerStage {
834
853
  // (row-major active list → under-coverage of the lower rows → GI band). Force full coverage
835
854
  // until the readback re-measures at the new size; bump the generation so any readback already
836
855
  // in flight (carrying the old-resolution counts) is discarded when it resolves.
837
- this._lastBounceCounts = null;
838
- this._lastBounceEnergy = null;
839
- this._lastBounceCountsBudget = - 1;
856
+ // The early exit only compares energy against a per-pixel threshold, so the curve rescaled to the
857
+ // new pixel count keeps it working — nothing re-measures while the camera moves, and the moving-camera
858
+ // resolution drop would otherwise run every bounce slot. A multi-chunk curve holds only the last band.
859
+ if ( this._lastBounceCounts && this._numChunks <= 1 && oldW > 0 && oldH > 0 ) {
860
+
861
+ const ratio = ( newW * newH ) / ( oldW * oldH );
862
+ const rescale = ( v ) => Math.min( 0xFFFFFFFF, Math.round( v * ratio ) );
863
+ this._lastBounceCounts = this._lastBounceCounts.map( rescale );
864
+ this._lastBounceEnergy = this._lastBounceEnergy.map( rescale );
865
+
866
+ } else {
867
+
868
+ this._lastBounceCounts = null;
869
+ this._lastBounceEnergy = null;
870
+ this._lastBounceCountsBudget = - 1;
871
+ this._lastBounceCountsLoopBound = - 1;
872
+
873
+ }
874
+
840
875
  this._curveSizingValid = false;
841
876
  this._readbackFrameCounter = 0;
842
877
  this._readbackGeneration ++;
@@ -298,18 +298,6 @@ export class PathTracerStage extends RenderStage {
298
298
  const self = this;
299
299
  const materialInterface = {
300
300
  uniforms: {
301
- maxBounceCount: {
302
- get value() {
303
-
304
- return self.maxBounces.value;
305
-
306
- },
307
- set value( v ) {
308
-
309
- self.maxBounces.value = v;
310
-
311
- }
312
- },
313
301
  enableAccumulation: {
314
302
  get value() {
315
303
 
@@ -322,15 +310,15 @@ export class PathTracerStage extends RenderStage {
322
310
 
323
311
  }
324
312
  },
325
- enableEmissiveTriangleSampling: {
313
+ fireflyThreshold: {
326
314
  get value() {
327
315
 
328
- return self.enableEmissiveTriangleSampling.value;
316
+ return self.fireflyThreshold.value;
329
317
 
330
318
  },
331
319
  set value( v ) {
332
320
 
333
- self.enableEmissiveTriangleSampling.value = v;
321
+ self.fireflyThreshold.value = v;
334
322
 
335
323
  }
336
324
  },
@@ -351,11 +339,16 @@ export class PathTracerStage extends RenderStage {
351
339
 
352
340
  this.cameraOptimizer = new CameraOptimizer( this.renderer, materialInterface, {
353
341
  enabled: DEFAULT_STATE.interactionModeEnabled,
342
+ // Nothing that costs light: the app lowers the resolution instead (PathTracerApp._applyRenderScale).
343
+ // The firefly limit grows with sqrt(frame + 1), and every moving frame is frame 0, where it clipped
344
+ // ~10% of an emissive-lit interior's light. ×8 is the limit a still image reaches at 64 samples:
345
+ // lowest large-area error of the values measured, and half the extra grain of turning it off.
354
346
  qualitySettings: {
355
- maxBounceCount: 1,
356
347
  enableAccumulation: false,
357
- enableEmissiveTriangleSampling: false,
348
+ fireflyThreshold: ( threshold ) => threshold * 8,
358
349
  },
350
+ onEnter: () => this.emit( 'pathtracer:interactionStart' ),
351
+ onExit: () => this.emit( 'pathtracer:interactionEnd' ),
359
352
  onReset: () => {
360
353
 
361
354
  this.reset();
@@ -251,10 +251,15 @@ export const getDistanceAttenuation = /*@__PURE__*/ wgslFn( `
251
251
  }
252
252
  ` );
253
253
 
254
- // Spot light attenuation
254
+ // Spot cone falloff, Blender/Cycles' shape: the soft edge is a smoothstep over a band of
255
+ // COSINE width (1 - cosCone) * blend, not one that ends at the inner cone's angle. The two
256
+ // agree at blend 0 and 1 and diverge in between — three.js' ramp ran twice as wide at 0.5,
257
+ // leaving the penumbra up to 2x dark against a Cycles render of the same lamp.
255
258
  export const getSpotAttenuation = /*@__PURE__*/ wgslFn( `
256
- fn getSpotAttenuation( coneCosine: f32, penumbraCosine: f32, angleCosine: f32 ) -> f32 {
257
- return smoothstep( coneCosine, penumbraCosine, angleCosine );
259
+ fn getSpotAttenuation( coneCosine: f32, blend: f32, angleCosine: f32 ) -> f32 {
260
+ let smoothWidth = ( 1.0 - coneCosine ) * blend;
261
+ if ( smoothWidth <= 0.0 ) { return 1.0; }
262
+ return smoothstep( 0.0, 1.0, ( angleCosine - coneCosine ) / smoothWidth );
258
263
  }
259
264
  ` );
260
265
 
@@ -261,10 +261,7 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, lightSelectio
261
261
 
262
262
  If( ls_valid, () => {
263
263
 
264
- // Penumbra: inner cone angle = outerAngle * (1 - penumbra)
265
- // Clamp penumbraCosAngle > coneCosAngle to avoid smoothstep UB when penumbra = 0
266
- const penumbraCosAngle = cos( light.angle.mul( float( 1.0 ).sub( light.penumbra ) ) ).max( coneCosAngle.add( 1e-5 ) ).toVar();
267
- const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, penumbraCosine: penumbraCosAngle, angleCosine: spotCosAngle } );
264
+ const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, blend: light.penumbra, angleCosine: spotCosAngle } );
268
265
  const distanceAttenuation = getDistanceAttenuation( { lightDistance: lightDist, cutoffDistance: light.distance, decayExponent: light.decay } );
269
266
 
270
267
  // Gobo projection mask + IES photometric profile — both 1.0 when not assigned.