rayzee 8.4.1 → 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.1",
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",
@@ -10,11 +10,13 @@
10
10
  */
11
11
  export const RENDER_PROFILES = Object.freeze( {
12
12
  viewer: Object.freeze( {
13
+ areaLightIntensityScale: 0.1, // scales glTF placeholder area-light power (viewer tuning)
13
14
  environmentRotation: 270.0, // degrees
14
15
  toneMapping: 4, // ACESFilmicToneMapping
15
16
  saturation: 1.2, // compensates for ACES desaturation — see Compositor
16
17
  } ),
17
18
  physical: Object.freeze( {
19
+ areaLightIntensityScale: 1.0,
18
20
  environmentRotation: 0.0,
19
21
  // AgX, matching Blender/Cycles' default view transform. ACES crushes shadows on this
20
22
  // engine's own corpus (shade 1.48 vs AgX 3.01 from identical radiance).
@@ -25,7 +27,7 @@ export const RENDER_PROFILES = Object.freeze( {
25
27
 
26
28
  /**
27
29
  * @param {string} [name] - a RENDER_PROFILES key
28
- * @returns {{environmentRotation: number, toneMapping: number, saturation: number}}
30
+ * @returns {{areaLightIntensityScale: number, environmentRotation: number}}
29
31
  * @throws {Error} on an unknown name — a typo must not silently select viewer tuning
30
32
  */
31
33
  export function getRenderProfile( name = 'viewer' ) {
@@ -208,6 +210,8 @@ export const ENGINE_DEFAULTS = {
208
210
  bloomRadius: 0.15,
209
211
  bloomThreshold: 0.85,
210
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,
211
215
  debugVisScale: 100,
212
216
 
213
217
  // Denoising strategy
@@ -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,
@@ -19,7 +19,6 @@ export const ISSUE_CODES = Object.freeze( {
19
19
  STAGE_RENDER_FAILED: 'stage.render_failed',
20
20
  RENDER_RESERVE_CAPPED: 'render.reserve_capped',
21
21
  SCENE_MEMORY_BUDGET: 'scene.memory_budget',
22
- LIGHT_PLACEHOLDER_INVALID: 'light.placeholder_invalid',
23
22
  EMISSIVE_INSTANCES_COLLAPSED: 'emissive.instances_collapsed',
24
23
  REFIT_SHARED_GEOMETRY: 'refit.shared_geometry',
25
24
  } );
@@ -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 ) {
@@ -764,9 +772,8 @@ export class PathTracerApp extends EventDispatcher {
764
772
  * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
765
773
  * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
766
774
  *
767
- * Lights are read as three.js lights in RADIOMETRIC units — W/sr for point and spot, W/m² for
768
- * directional, radiance for area — and are left as they are; only an area light's rectangle is
769
- * put into world metres on the copy. See `LightUnits.js`.
775
+ * Lights keep three.js units — `RectAreaLight.intensity` in nits, point/spot in candela — and are
776
+ * converted to radiant power on the copy. `areaLightIntensityScale` does not apply here.
770
777
  *
771
778
  * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
772
779
  * @param {string} [name='object3d'] - Display name for the object
@@ -1813,9 +1820,7 @@ export class PathTracerApp extends EventDispatcher {
1813
1820
  if ( ! this._isRenderSizeSupported( width, height ) ) return;
1814
1821
 
1815
1822
  this.renderer.setPixelRatio( 1.0 );
1816
- this.renderer.setSize( width, height, false );
1817
- this.cameraManager.camera.aspect = width / height;
1818
- this.cameraManager.camera.updateProjectionMatrix();
1823
+ this._setDisplaySize( width, height );
1819
1824
 
1820
1825
  const lastW = this.denoisingManager?._lastRenderWidth ?? 0;
1821
1826
  const lastH = this.denoisingManager?._lastRenderHeight ?? 0;
@@ -1834,7 +1839,8 @@ export class PathTracerApp extends EventDispatcher {
1834
1839
 
1835
1840
  if ( ! this._isRenderSizeSupported( renderWidth, renderHeight ) ) return;
1836
1841
 
1837
- 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.
1838
1844
  this.denoisingManager?.setRenderSize( renderWidth, renderHeight );
1839
1845
  this.needsReset = true;
1840
1846
 
@@ -1857,9 +1863,7 @@ export class PathTracerApp extends EventDispatcher {
1857
1863
  if ( ! this._isRenderSizeSupported( width, height ) ) return null;
1858
1864
 
1859
1865
  this.renderer.setPixelRatio( 1.0 );
1860
- this.renderer.setSize( width, height, false );
1861
- this.cameraManager.camera.aspect = width / height;
1862
- this.cameraManager.camera.updateProjectionMatrix();
1866
+ this._setDisplaySize( width, height );
1863
1867
 
1864
1868
  clearTimeout( this._resizeDebounceTimer );
1865
1869
  this._applyRenderResize( width, height );
@@ -1868,6 +1872,66 @@ export class PathTracerApp extends EventDispatcher {
1868
1872
 
1869
1873
  }
1870
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
+
1871
1935
  // ═══════════════════════════════════════════════════════════════
1872
1936
  // Mode Configuration
1873
1937
  // ═══════════════════════════════════════════════════════════════
@@ -1890,22 +1954,7 @@ export class PathTracerApp extends EventDispatcher {
1890
1954
 
1891
1955
  // Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
1892
1956
  // `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
1893
- this.settings.setMany( {
1894
- maxSamples: config.maxSamples,
1895
- maxBounces: config.bounces,
1896
- transmissiveBounces: config.transmissiveBounces,
1897
- maxSubsurfaceSteps: config.maxSubsurfaceSteps,
1898
- enableAlphaShadows: config.enableAlphaShadows ?? false,
1899
- // Tier-1 convergence early-stop
1900
- useAdaptiveSampling: config.useAdaptiveSampling ?? false,
1901
- noiseThreshold: config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold,
1902
- adaptiveStopFraction: config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction,
1903
- adaptiveMinSamples: config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples,
1904
- // Tier-2 per-pixel freeze
1905
- usePixelFreeze: config.usePixelFreeze ?? false,
1906
- pixelFreezeThreshold: config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold,
1907
- pixelFreezeStability: config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability,
1908
- }, { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1957
+ this.settings.setMany( modePresetSettings( config ), { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1909
1958
 
1910
1959
  // renderMode has no SETTING_ROUTES entry
1911
1960
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
@@ -2015,7 +2064,7 @@ export class PathTracerApp extends EventDispatcher {
2015
2064
  * - `_bounceEarlyExitThreshold` / `_useDynamicDispatch` both consume the async
2016
2065
  * survivor curve. Kernels bind on ENTERING_COUNT, so an under-sized grid silently
2017
2066
  * drops rays, and the frame a readback lands on is GPU-scheduled.
2018
- * - `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
2019
2068
  * accumulation and freezes frameCount; it engages on the very first frame.
2020
2069
  * - auto-focus raycasts per frame and auto-exposure adapts off `performance.now()`.
2021
2070
  *
@@ -3028,6 +3077,8 @@ export class PathTracerApp extends EventDispatcher {
3028
3077
  this._addTrackedListener( this.cameraManager.controls, 'change', () => {
3029
3078
 
3030
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();
3031
3082
  this.wake();
3032
3083
 
3033
3084
  } );
@@ -3054,8 +3105,15 @@ export class PathTracerApp extends EventDispatcher {
3054
3105
 
3055
3106
  const initRenderW = this.canvas.clientWidth || 1;
3056
3107
  const initRenderH = this.canvas.clientHeight || 1;
3108
+ this._displayWidth = initRenderW;
3109
+ this._displayHeight = initRenderH;
3057
3110
  this.pipeline.setSize( initRenderW, initRenderH );
3058
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
+
3059
3117
  }
3060
3118
 
3061
3119
  async _initManagers() {
@@ -3176,6 +3234,11 @@ export class PathTracerApp extends EventDispatcher {
3176
3234
  reconcileCompletion: () => this._reconcileCompletion(),
3177
3235
  denoisingManager: this.denoisingManager,
3178
3236
  cameraManager: this.cameraManager,
3237
+ onInteractionRenderScale: () => {
3238
+
3239
+ if ( this.stages.pathTracer?.interactionMode ) this._requestRenderScale( this._interactionRenderScale() );
3240
+
3241
+ },
3179
3242
  } );
3180
3243
 
3181
3244
  this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
@@ -19,7 +19,6 @@ import { getAssetConfig } from '../AssetConfig.js';
19
19
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
20
20
  import { extractSceneMetadata } from './SceneMetadata.js';
21
21
  import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
22
- import { bakeAreaLightScale, LUMENS_PER_WATT } from '../LightUnits.js';
23
22
  import { getRenderProfile } from '../EngineDefaults.js';
24
23
 
25
24
  // Define supported file formats
@@ -68,43 +67,19 @@ function standInForSplit( source ) {
68
67
 
69
68
  }
70
69
 
71
- const PLACEHOLDER_SHAPES = new Set( [ 'rectangle', 'square', 'disk', 'ellipse' ] );
70
+ // Luminous efficacy glTF, three.js and Blender's exporter all assume (PBR_WATTS_TO_LUMENS).
71
+ const LUMENS_PER_WATT = 683;
72
72
 
73
- /**
74
- * Build the light a placeholder node stands for. glTF has no area light, so a three.js host's
75
- * patched GLTFExporter writes the RectAreaLight into the node's `extras` and leaves the node
76
- * itself carrying the transform. `intensity` is three.js radiance, which is what the engine
77
- * stores, so the number is used as authored; only the rectangle needs normalising to world
78
- * metres (see `bakeAreaLightScale`).
79
- *
80
- * `power`, `position`, `rotation` and `direction` are present in the record and deliberately
81
- * unused. The node's matrix already carries the transform, `direction` is not even normalised,
82
- * and `power` was written from the light's UNSCALED dimensions — on 24155522.glb's window light,
83
- * whose ancestor scale is non-uniform (1.6 × 1.2 m in world, not 1.2 × 1.2), it is 25 % low.
84
- *
85
- * @returns {RectAreaLight|null} null when the record carries no usable size or intensity.
86
- */
87
- function areaLightFromPlaceholder( node, record ) {
73
+ // three.js nits → the engine's radiant power, inverting areaLightRadiance.
74
+ function areaLightPowerFactor( node, width, height, userData ) {
88
75
 
89
- const width = Number( record.width );
90
- const height = Number( record.height );
91
- const intensity = Number( record.intensity );
92
- if ( ! ( width > 0 ) || ! ( height > 0 ) || ! Number.isFinite( intensity ) ) return null;
76
+ if ( ! ( userData.normalize ?? true ) ) return Math.PI;
93
77
 
94
- const shape = PLACEHOLDER_SHAPES.has( record.shape ) ? record.shape : 'rectangle';
95
- const color = Array.isArray( record.color ) && record.color.length >= 3 ? record.color : [ 1, 1, 1 ];
96
-
97
- const light = new RectAreaLight( new Color( ...color ), intensity, width, height );
98
- light.name = record.name || 'RectAreaLight';
99
- light.userData.normalize = record.normalize ?? true;
100
- light.userData.spread = Number.isFinite( record.spread ) ? record.spread : Math.PI;
101
- light.userData.shape = shape;
102
-
103
- // Parented before baking: the bake needs the node's world scale, and on the append path
104
- // nothing has refreshed the tree yet.
105
- node.add( light );
106
- bakeAreaLightScale( light );
107
- return light;
78
+ const shapeFactor = userData.shape === 'ellipse' || userData.shape === 'disk' ? Math.PI / 4 : 1;
79
+ const scale = node.getWorldScale( new Vector3() );
80
+ // abs: a mirrored ancestor decomposes negative; the serializer takes the area as |u × v|.
81
+ const factor = Math.PI * shapeFactor * Math.abs( width * scale.x * height * scale.y );
82
+ return Number.isFinite( factor ) ? factor : Math.PI;
108
83
 
109
84
  }
110
85
 
@@ -2109,18 +2084,22 @@ export class AssetLoader extends EventDispatcher {
2109
2084
 
2110
2085
  const userData = object.userData;
2111
2086
 
2112
- // three.js states RectAreaLight.intensity as radiance, which is what the engine
2113
- // stores, so only the rectangle needs putting into world metres.
2114
- if ( object.isRectAreaLight ) bakeAreaLightScale( object );
2087
+ // An adopted light carries three.js units; convert as point/spot are below.
2088
+ if ( object.isRectAreaLight && ! userData.__radianceConverted ) {
2089
+
2090
+ object.intensity *= areaLightPowerFactor( object, object.width, object.height, userData );
2091
+ userData.__radianceConverted = true;
2092
+
2093
+ }
2115
2094
 
2116
2095
  // Punctual lights arrive photometric: glTF (and three.js) state point/spot in
2117
- // candela and directional in lux. The engine keeps the same QUANTITY in radiometric
2118
- // units — W/sr and W/m² — so only the luminous efficacy is divided back out, which
2119
- // is what Blender's own glTF importer does. Skipping it rendered every Blender lamp
2120
- // 683x too bright.
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.
2121
2100
  if ( ( object.isPointLight || object.isSpotLight ) && ! userData.__candelaConverted ) {
2122
2101
 
2123
- object.intensity /= LUMENS_PER_WATT;
2102
+ object.intensity *= 4 * Math.PI / LUMENS_PER_WATT;
2124
2103
  userData.__candelaConverted = true;
2125
2104
 
2126
2105
  }
@@ -2132,17 +2111,31 @@ export class AssetLoader extends EventDispatcher {
2132
2111
 
2133
2112
  }
2134
2113
 
2135
- // An area light serialized into a placeholder node. Keyed on the record's own type
2136
- // rather than the node's name, so a renamed placeholder still lights the scene.
2137
- if ( userData.type === 'RectAreaLight' ) {
2114
+ // Process ceiling lights
2115
+ if ( object.name.startsWith( 'RectAreaLightPlaceholder' ) &&
2116
+ userData.name
2117
+ // && userData.name.includes( "ceilingLight" )
2118
+ ) {
2119
+
2120
+ if ( userData.type === 'RectAreaLight' ) {
2121
+
2122
+ const normalize = userData.normalize ?? true;
2123
+ const shape = userData.shape ?? 'rectangle';
2124
+ const power = userData.intensity * areaLightPowerFactor( object, userData.width, userData.height, userData );
2125
+ const light = new RectAreaLight(
2126
+ new Color( ...userData.color ),
2127
+ power * this._profile.areaLightIntensityScale,
2128
+ userData.width,
2129
+ userData.height
2130
+ );
2131
+ light.userData.normalize = normalize;
2132
+ light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
2133
+ light.userData.shape = shape;
2134
+ light.userData.__radianceConverted = true; // already power, and traverse() reaches it
2135
+ light.name = userData.name;
2136
+ object.add( light );
2138
2137
 
2139
- const light = areaLightFromPlaceholder( object, userData );
2140
- if ( ! light ) this._issues?.record(
2141
- ISSUE_CODES.LIGHT_PLACEHOLDER_INVALID,
2142
- `area-light placeholder "${object.name}" carries no usable size or intensity`,
2143
- { node: object.name, record: userData },
2144
- ISSUE_SEVERITY.WARNING,
2145
- );
2138
+ }
2146
2139
 
2147
2140
  }
2148
2141
 
@@ -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
@@ -1,11 +1,10 @@
1
1
  import { Vector3, Quaternion } from 'three';
2
2
  import { blackbodyToLinearRGB } from './blackbody.js';
3
- import { lightPower } from '../LightUnits.js';
4
3
 
5
- // Scene lights hold the three.js quantity in radiometric units (see LightUnits.js): point and
6
- // spot in W/sr, the Sun in W/m², area lights in radiance. The GPU buffer wants radiant intensity
7
- // for point/spot — already what they hold — and radiant POWER for area lights, which the shader
8
- // turns back into radiance with its own π·area. This is where that adaptation happens.
4
+ // Point & spot lights specify Power in Watts; the shader uses radiant intensity
5
+ // (W/sr) as `intensity / dist²`, so convert with I = P / 4π (isotropic emitter),
6
+ // matching Blender. Area lights are already radiant power; the Sun is irradiance.
7
+ const INV_4PI = 1 / ( 4 * Math.PI );
9
8
 
10
9
  // Effective emission colour + intensity after per-light Exposure (EV stops) and
11
10
  // optional blackbody Temperature tint (Blender-style). Both fold into the values
@@ -62,10 +61,9 @@ export class LightSerializer {
62
61
 
63
62
  calculateLightImportance( light, type = 'directional' ) {
64
63
 
65
- // Ranked on Power, so the ordering does not shift when a light type's stored quantity
66
- // differs from what the panel shows.
64
+ // Calculate luminance-weighted importance
67
65
  const luminance = 0.2126 * light.color.r + 0.7152 * light.color.g + 0.0722 * light.color.b;
68
- let importance = lightPower( light ) * luminance;
66
+ let importance = light.intensity * luminance;
69
67
 
70
68
  // Area lights get additional importance based on size
71
69
  if ( type === 'area' ) {
@@ -174,12 +172,6 @@ export class LightSerializer {
174
172
 
175
173
  const em = effectiveEmission( light );
176
174
 
177
- // The shader recovers radiance as power/(π·area), so hand it the power that inverts to
178
- // the radiance the light holds. Area is measured off u/v exactly as the shader does it.
179
- const rectArea = 4 * u.clone().cross( v ).length();
180
- const emittingArea = shape > 0.5 ? rectArea * Math.PI / 4 : rectArea;
181
- const power = em.intensity * Math.PI * ( normalize > 0.5 ? emittingArea : 1 );
182
-
183
175
  // Store in cache with importance (16 floats, vec4-aligned)
184
176
  this.areaLightCache.push( {
185
177
  data: [
@@ -187,7 +179,7 @@ export class LightSerializer {
187
179
  u.x, u.y, u.z, // u half-vector (3)
188
180
  v.x, v.y, v.z, // v half-vector (3)
189
181
  em.r, em.g, em.b, // color (3) — incl. temperature tint
190
- power, // radiant power in Watts (1) — incl. exposure
182
+ em.intensity, // radiant power in Watts (1) — incl. exposure
191
183
  normalize, // power-normalize flag (1)
192
184
  spread, // emission spread in radians (1)
193
185
  shape, // 0 = rect, 1 = disk/ellipse (1)
@@ -217,7 +209,7 @@ export class LightSerializer {
217
209
  data: [
218
210
  position.x, position.y, position.z, // position (3)
219
211
  em.r, em.g, em.b, // color (3) — incl. temperature tint
220
- em.intensity, // radiant intensity W/sr, incl. exposure (1)
212
+ em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
221
213
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
222
214
  light.decay !== undefined ? light.decay : 2.0 // decay exponent (1)
223
215
  ],
@@ -260,7 +252,7 @@ export class LightSerializer {
260
252
  position.x, position.y, position.z, // position (3)
261
253
  direction.x, direction.y, direction.z, // direction (3)
262
254
  em.r, em.g, em.b, // color (3) — incl. temperature tint
263
- em.intensity, // radiant intensity W/sr, incl. exposure (1)
255
+ em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
264
256
  light.angle || Math.PI / 4, // cone half-angle in radians (1)
265
257
  light.penumbra || 0.0, // penumbra [0,1] (1)
266
258
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
@@ -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
  }
@@ -853,10 +853,25 @@ export class PathTracer extends PathTracerStage {
853
853
  // (row-major active list → under-coverage of the lower rows → GI band). Force full coverage
854
854
  // until the readback re-measures at the new size; bump the generation so any readback already
855
855
  // in flight (carrying the old-resolution counts) is discarded when it resolves.
856
- this._lastBounceCounts = null;
857
- this._lastBounceEnergy = null;
858
- this._lastBounceCountsBudget = - 1;
859
- this._lastBounceCountsLoopBound = - 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
+
860
875
  this._curveSizingValid = false;
861
876
  this._readbackFrameCounter = 0;
862
877
  this._readbackGeneration ++;