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/README.md +1 -5
- package/dist/rayzee.es.js +1975 -1981
- package/dist/rayzee.umd.js +64 -64
- package/package.json +1 -1
- package/src/EngineDefaults.js +29 -1
- package/src/EngineIssues.js +0 -1
- package/src/PathTracerApp.js +91 -28
- package/src/Processor/AssetLoader.js +46 -53
- package/src/Processor/CameraOptimizer.js +22 -6
- package/src/Processor/LightSerializer.js +9 -17
- package/src/RenderSettings.js +9 -1
- package/src/Stages/PathTracer.js +19 -4
- package/src/Stages/PathTracerStage.js +10 -17
- package/src/index.js +0 -1
- package/src/managers/DenoisingManager.js +3 -7
- package/src/managers/LightManager.js +17 -15
- package/src/managers/VideoRenderManager.js +9 -9
- package/src/LightUnits.js +0 -122
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -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 {{
|
|
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,
|
package/src/EngineIssues.js
CHANGED
|
@@ -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
|
} );
|
package/src/PathTracerApp.js
CHANGED
|
@@ -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
|
|
768
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
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
|
|
2113
|
-
|
|
2114
|
-
|
|
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
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
package/src/RenderSettings.js
CHANGED
|
@@ -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
|
}
|
package/src/Stages/PathTracer.js
CHANGED
|
@@ -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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
this.
|
|
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 ++;
|