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/dist/rayzee.es.js +1979 -1934
- package/dist/rayzee.umd.js +68 -66
- package/package.json +1 -1
- package/src/EngineDefaults.js +30 -2
- package/src/PathTracerApp.js +90 -25
- package/src/Processor/AssetLoader.js +16 -5
- package/src/Processor/CameraOptimizer.js +22 -6
- package/src/RenderSettings.js +9 -1
- package/src/Stages/PathTracer.js +41 -6
- package/src/Stages/PathTracerStage.js +10 -17
- package/src/TSL/LightsCore.js +8 -3
- package/src/TSL/LightsSampling.js +1 -4
- package/src/managers/DenoisingManager.js +19 -7
- package/src/managers/VideoRenderManager.js +9 -9
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
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 ) {
|
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
//
|
|
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
|
-
//
|
|
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
|
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
|
@@ -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 =
|
|
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
|
-
? (
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
313
|
+
fireflyThreshold: {
|
|
326
314
|
get value() {
|
|
327
315
|
|
|
328
|
-
return self.
|
|
316
|
+
return self.fireflyThreshold.value;
|
|
329
317
|
|
|
330
318
|
},
|
|
331
319
|
set value( v ) {
|
|
332
320
|
|
|
333
|
-
self.
|
|
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
|
-
|
|
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();
|
package/src/TSL/LightsCore.js
CHANGED
|
@@ -251,10 +251,15 @@ export const getDistanceAttenuation = /*@__PURE__*/ wgslFn( `
|
|
|
251
251
|
}
|
|
252
252
|
` );
|
|
253
253
|
|
|
254
|
-
// Spot
|
|
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,
|
|
257
|
-
|
|
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
|
-
|
|
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.
|