rayzee 8.4.1 → 8.6.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.
Files changed (65) hide show
  1. package/README.md +61 -9
  2. package/dist/{DLSSNeural-BNSJ8V3z.js → DLSSNeural-BhBCBgKf.js} +5 -5
  3. package/dist/{DLSSSuperRes-BoEW89Cl.js → DLSSSuperRes-BCA7F_k5.js} +5 -5
  4. package/dist/ToneMapGPU-BzcLheoR.js +533 -0
  5. package/dist/rayzee.es.js +27278 -9133
  6. package/dist/rayzee.umd.js +255 -187
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +15 -2
  9. package/src/Color/BakedViews.js +134 -0
  10. package/src/Color/BuiltinViews.js +261 -0
  11. package/src/Color/ColorManagement.js +1130 -0
  12. package/src/Color/ColorSpaces.js +432 -0
  13. package/src/Color/Displays.js +19 -0
  14. package/src/Color/InputColorSpaces.js +243 -0
  15. package/src/Color/LutBake.js +371 -0
  16. package/src/Color/OcioRuntime.js +338 -0
  17. package/src/Color/OcioViews.js +419 -0
  18. package/src/Color/ViewTransforms.js +323 -0
  19. package/src/Color/WorkingMatrix.js +86 -0
  20. package/src/EngineDefaults.js +84 -4
  21. package/src/EngineIssues.js +3 -1
  22. package/src/Passes/AIUpscaler.js +16 -7
  23. package/src/PathTracerApp.js +318 -35
  24. package/src/Processor/AssetLoader.js +47 -55
  25. package/src/Processor/CameraOptimizer.js +22 -6
  26. package/src/Processor/EmissiveTriangleBuilder.js +34 -2
  27. package/src/Processor/GeometryExtractor.js +151 -138
  28. package/src/Processor/LightSerializer.js +17 -20
  29. package/src/Processor/MaterialPacking.js +168 -0
  30. package/src/Processor/PackedRayBuffer.js +6 -2
  31. package/src/Processor/ProceduralSky.js +4 -0
  32. package/src/Processor/SceneProcessor.js +25 -1
  33. package/src/Processor/SimpleSky.js +8 -0
  34. package/src/Processor/TextureCreator.js +137 -91
  35. package/src/Processor/TextureReadback.js +92 -0
  36. package/src/Processor/ToneMapCPU.js +80 -165
  37. package/src/Processor/ToneMapGPU.js +122 -151
  38. package/src/RenderSettings.js +10 -1
  39. package/src/Stages/Compositor.js +11 -2
  40. package/src/Stages/PathTracer.js +21 -4
  41. package/src/Stages/PathTracerStage.js +11 -18
  42. package/src/TSL/Common.js +15 -0
  43. package/src/TSL/Debugger.js +2 -3
  44. package/src/TSL/EmissiveSampling.js +6 -8
  45. package/src/TSL/ExtendKernel.js +9 -0
  46. package/src/TSL/Fresnel.js +23 -1
  47. package/src/TSL/HitFacet.js +102 -0
  48. package/src/TSL/LightsDirect.js +12 -33
  49. package/src/TSL/LightsSampling.js +15 -7
  50. package/src/TSL/MaterialEvaluation.js +26 -19
  51. package/src/TSL/MaterialProperties.js +74 -37
  52. package/src/TSL/MaterialTransmission.js +2 -3
  53. package/src/TSL/ShadeKernel.js +34 -21
  54. package/src/TSL/ShadowTerminator.js +125 -0
  55. package/src/TSL/Struct.js +9 -0
  56. package/src/TSL/Subsurface.js +2 -3
  57. package/src/index.js +29 -2
  58. package/src/managers/DenoisingManager.js +3 -7
  59. package/src/managers/EnvironmentManager.js +20 -0
  60. package/src/managers/LightManager.js +17 -15
  61. package/src/managers/MaterialDataManager.js +118 -208
  62. package/src/managers/UniformManager.js +1 -0
  63. package/src/managers/VideoRenderManager.js +9 -9
  64. package/dist/ToneMapGPU-DzqSyzTx.js +0 -341
  65. package/src/LightUnits.js +0 -122
@@ -1,4 +1,4 @@
1
- import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace } from 'three/webgpu';
1
+ import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace, LinearSRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
4
  Scene, EventDispatcher, Box3
@@ -17,15 +17,18 @@ 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
+ import { TextureReadback } from './Processor/TextureReadback.js';
23
24
  import { createLogger, fmt } from './utils/Logger.js';
24
25
  import { InteractionManager } from './managers/InteractionManager.js';
25
26
  import { EngineEvents } from './EngineEvents.js';
26
27
  import { IssueLog, ISSUE_CODES } from './EngineIssues.js';
27
28
  import { SETTING_SOURCE } from './RenderSettings.js';
28
29
  import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
30
+ import { ColorManagement, setActiveColorManagement } from './Color/ColorManagement.js';
31
+ import { getViewTransform } from './Color/ViewTransforms.js';
29
32
  import { AssetLoader } from './Processor/AssetLoader.js';
30
33
  import { SceneProcessor } from './Processor/SceneProcessor.js';
31
34
 
@@ -163,6 +166,15 @@ export class PathTracerApp extends EventDispatcher {
163
166
  onIssue: ( issue ) => this.dispatchEvent( { type: EngineEvents.ISSUE, issue } ),
164
167
  } );
165
168
 
169
+ /**
170
+ * Colour management: what the engine renders in, shows it as, and hands out.
171
+ *
172
+ * Inert until a host loads an OCIO config — until then the working space is linear
173
+ * Rec.709 and the view transforms are three.js's own seven, exactly as before.
174
+ */
175
+ this.color = new ColorManagement( { issues: this._issues } );
176
+ setActiveColorManagement( this.color );
177
+
166
178
  // ── Settings (single source of truth for all render parameters) ──
167
179
  this.settings = new RenderSettings(
168
180
  {
@@ -239,6 +251,12 @@ export class PathTracerApp extends EventDispatcher {
239
251
 
240
252
  // Resolution state
241
253
  this._resizeDebounceTimer = null;
254
+ // The size the host asked for. The canvas backing store is this × _renderScale, which drops
255
+ // below 1 only while the camera moves.
256
+ this._displayWidth = 0;
257
+ this._displayHeight = 0;
258
+ this._renderScale = 1;
259
+ this._pendingRenderScale = null;
242
260
 
243
261
  // Tracked listeners for clean dispose()
244
262
  this._trackedListeners = [];
@@ -342,6 +360,8 @@ export class PathTracerApp extends EventDispatcher {
342
360
 
343
361
  if ( this.cameraManager.controls ) this.cameraManager.controls.update();
344
362
 
363
+ this._applyPendingRenderScale();
364
+
345
365
  // Animation playback: compute skinned positions and refit BVH.
346
366
  // Guard prevents overlapping async refits (fire-and-forget with 1-frame latency).
347
367
  if ( this.animationManager?.isPlaying && ! this._animRefitInFlight ) {
@@ -590,6 +610,12 @@ export class PathTracerApp extends EventDispatcher {
590
610
 
591
611
  this._issues.detach(); // onIssue captures `this`; see IssueLog.detach()
592
612
 
613
+ // Holds the renderer and the issue log, both of which outlive it otherwise.
614
+ this.color?.dispose();
615
+ this.color = null;
616
+ this._textureReadback?.dispose();
617
+ this._textureReadback = null;
618
+
593
619
  this.interactionManager?.deselect?.();
594
620
  this.transformManager?.detach?.();
595
621
 
@@ -764,9 +790,8 @@ export class PathTracerApp extends EventDispatcher {
764
790
  * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
765
791
  * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
766
792
  *
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`.
793
+ * Lights keep three.js units — `RectAreaLight.intensity` in nits, point/spot in candela — and are
794
+ * converted to radiant power on the copy. `areaLightIntensityScale` does not apply here.
770
795
  *
771
796
  * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
772
797
  * @param {string} [name='object3d'] - Display name for the object
@@ -1813,9 +1838,7 @@ export class PathTracerApp extends EventDispatcher {
1813
1838
  if ( ! this._isRenderSizeSupported( width, height ) ) return;
1814
1839
 
1815
1840
  this.renderer.setPixelRatio( 1.0 );
1816
- this.renderer.setSize( width, height, false );
1817
- this.cameraManager.camera.aspect = width / height;
1818
- this.cameraManager.camera.updateProjectionMatrix();
1841
+ this._setDisplaySize( width, height );
1819
1842
 
1820
1843
  const lastW = this.denoisingManager?._lastRenderWidth ?? 0;
1821
1844
  const lastH = this.denoisingManager?._lastRenderHeight ?? 0;
@@ -1834,7 +1857,8 @@ export class PathTracerApp extends EventDispatcher {
1834
1857
 
1835
1858
  if ( ! this._isRenderSizeSupported( renderWidth, renderHeight ) ) return;
1836
1859
 
1837
- this.pipeline?.setSize( renderWidth, renderHeight );
1860
+ this.pipeline?.setSize( this._scaled( renderWidth ), this._scaled( renderHeight ) );
1861
+ // Full size: the denoiser only runs once the camera has stopped.
1838
1862
  this.denoisingManager?.setRenderSize( renderWidth, renderHeight );
1839
1863
  this.needsReset = true;
1840
1864
 
@@ -1857,9 +1881,7 @@ export class PathTracerApp extends EventDispatcher {
1857
1881
  if ( ! this._isRenderSizeSupported( width, height ) ) return null;
1858
1882
 
1859
1883
  this.renderer.setPixelRatio( 1.0 );
1860
- this.renderer.setSize( width, height, false );
1861
- this.cameraManager.camera.aspect = width / height;
1862
- this.cameraManager.camera.updateProjectionMatrix();
1884
+ this._setDisplaySize( width, height );
1863
1885
 
1864
1886
  clearTimeout( this._resizeDebounceTimer );
1865
1887
  this._applyRenderResize( width, height );
@@ -1868,6 +1890,66 @@ export class PathTracerApp extends EventDispatcher {
1868
1890
 
1869
1891
  }
1870
1892
 
1893
+ _scaled( size ) {
1894
+
1895
+ return Math.max( 1, Math.round( size * this._renderScale ) );
1896
+
1897
+ }
1898
+
1899
+ // The wavefront takes its resolution from the canvas backing store, so that is what has to shrink.
1900
+ // updateStyle=false keeps the CSS size, and the browser stretches the smaller frame over it.
1901
+ _setDisplaySize( width, height ) {
1902
+
1903
+ this._displayWidth = width;
1904
+ this._displayHeight = height;
1905
+
1906
+ this.renderer.setSize( this._scaled( width ), this._scaled( height ), false );
1907
+ this.cameraManager.camera.aspect = width / height;
1908
+ this.cameraManager.camera.updateProjectionMatrix();
1909
+
1910
+ }
1911
+
1912
+ // OIDN as the live denoiser rebuilds its network on every size change, so it keeps full size.
1913
+ _interactionRenderScale() {
1914
+
1915
+ if ( this.denoisingManager?.continuousDenoise ) return 1;
1916
+ const scale = Number( this.settings.get( 'interactionRenderScale' ) );
1917
+ return scale > 0 ? Math.min( 1, Math.max( 0.125, scale ) ) : 1;
1918
+
1919
+ }
1920
+
1921
+ // Interaction can start inside PathTracer.render(), where resizing would pull textures out from
1922
+ // under the frame, so the change waits for the next frame boundary. No wake(): the move that
1923
+ // started it already woke the loop, and waking from inside render() would re-enter it.
1924
+ _requestRenderScale( scale ) {
1925
+
1926
+ this._pendingRenderScale = scale;
1927
+
1928
+ }
1929
+
1930
+ _applyPendingRenderScale() {
1931
+
1932
+ if ( this._pendingRenderScale !== null ) this._applyRenderScale( this._pendingRenderScale );
1933
+
1934
+ }
1935
+
1936
+ _applyRenderScale( scale ) {
1937
+
1938
+ this._pendingRenderScale = null;
1939
+ if ( this._disposed || scale === this._renderScale ) return;
1940
+
1941
+ this._renderScale = scale;
1942
+ if ( ! this._displayWidth || ! this._displayHeight ) return;
1943
+
1944
+ this._setDisplaySize( this._displayWidth, this._displayHeight );
1945
+ this.pipeline?.setSize( this._scaled( this._displayWidth ), this._scaled( this._displayHeight ) );
1946
+ // History from the other size would reproject garbage.
1947
+ this.pipeline?.eventBus.emit( 'asvgf:reset' );
1948
+ this.pipeline?.eventBus.emit( 'denoiser:reset' );
1949
+ this.needsReset = true;
1950
+
1951
+ }
1952
+
1871
1953
  // ═══════════════════════════════════════════════════════════════
1872
1954
  // Mode Configuration
1873
1955
  // ═══════════════════════════════════════════════════════════════
@@ -1890,22 +1972,7 @@ export class PathTracerApp extends EventDispatcher {
1890
1972
 
1891
1973
  // Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
1892
1974
  // `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 } );
1975
+ this.settings.setMany( modePresetSettings( config ), { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1909
1976
 
1910
1977
  // renderMode has no SETTING_ROUTES entry
1911
1978
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
@@ -2015,7 +2082,7 @@ export class PathTracerApp extends EventDispatcher {
2015
2082
  * - `_bounceEarlyExitThreshold` / `_useDynamicDispatch` both consume the async
2016
2083
  * survivor curve. Kernels bind on ENTERING_COUNT, so an under-sized grid silently
2017
2084
  * 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
2085
+ * - `interactionModeEnabled` is a 100 ms timer that lowers the render resolution, disables
2019
2086
  * accumulation and freezes frameCount; it engages on the very first frame.
2020
2087
  * - auto-focus raycasts per frame and auto-exposure adapts off `performance.now()`.
2021
2088
  *
@@ -2263,15 +2330,23 @@ export class PathTracerApp extends EventDispatcher {
2263
2330
  * the viewport shows.
2264
2331
  *
2265
2332
  * @param {Object} [options]
2266
- * @param {'linear'|'srgb'} [options.colorSpace='srgb']
2333
+ * @param {string} [options.colorSpace='srgb'] - `'srgb'` for display bytes through the active
2334
+ * view transform, `'linear'` for the raw working-space accumulation, or the name of a colour
2335
+ * space in the loaded OCIO config for a delivery buffer (float, e.g. `'ACES2065-1'`)
2267
2336
  * @param {boolean} [options.preserveAlpha=false] - srgb only
2337
+ * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2338
+ * viewport is showing — denoised when a denoiser has run, without bloom — instead of the raw
2339
+ * accumulation
2268
2340
  * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string}>}
2269
2341
  */
2270
- async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false } = {} ) {
2342
+ async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false, source = 'accumulation' } = {} ) {
2271
2343
 
2272
- if ( colorSpace !== 'linear' && colorSpace !== 'srgb' ) {
2344
+ const named = colorSpace !== 'linear' && colorSpace !== 'srgb';
2345
+ if ( named && ! this.color?.hasConfig ) {
2273
2346
 
2274
- throw new Error( `renderToBuffer: colorSpace must be 'linear' or 'srgb', got "${colorSpace}"` );
2347
+ throw new Error(
2348
+ `renderToBuffer: colorSpace must be 'linear' or 'srgb' without a colour config loaded, got "${colorSpace}"`
2349
+ );
2275
2350
 
2276
2351
  }
2277
2352
 
@@ -2282,9 +2357,27 @@ export class PathTracerApp extends EventDispatcher {
2282
2357
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
2283
2358
  const { width, height } = stage;
2284
2359
 
2285
- const linear = await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 );
2360
+ const linear = source === 'display'
2361
+ ? await this._readDisplaySource( target, width, height )
2362
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 );
2286
2363
 
2287
- if ( colorSpace === 'linear' ) return { data: linear, width, height, colorSpace };
2364
+ // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2365
+ // answer to "linear in what primaries", which only means something once a config is loaded.
2366
+ if ( colorSpace === 'linear' ) {
2367
+
2368
+ return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null };
2369
+
2370
+ }
2371
+
2372
+ // A named space is a delivery buffer, not a picture: scene-referred float in whatever the
2373
+ // config calls that space. Saving an EXR for a compositor and grading through an sRGB view
2374
+ // on screen are different questions, and this is the one that answers the first.
2375
+ if ( named ) {
2376
+
2377
+ const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
2378
+ return { data: rgba, width, height, colorSpace: got };
2379
+
2380
+ }
2288
2381
 
2289
2382
  return {
2290
2383
  data: toneMapToRGBA8( linear, {
@@ -2716,6 +2809,20 @@ export class PathTracerApp extends EventDispatcher {
2716
2809
 
2717
2810
  }
2718
2811
 
2812
+ /**
2813
+ * Where a material property's value came from: `'material'` (the three.js material carried
2814
+ * it), `'mapped'` (converted from a Basic/Lambert/Phong/Toon material), `'default'` (the model
2815
+ * never said; filled from MATERIAL_DEFAULTS) or `'host'` (set through setMaterialProperty).
2816
+ * @param {number} materialIndex
2817
+ * @param {string} property
2818
+ * @returns {string|undefined}
2819
+ */
2820
+ getMaterialPropertySource( materialIndex, property ) {
2821
+
2822
+ return this.stages.pathTracer?.materialData.getMaterialPropertySource( materialIndex, property );
2823
+
2824
+ }
2825
+
2719
2826
  setMaterialProperty( materialIndex, property, value ) {
2720
2827
 
2721
2828
  this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
@@ -2868,6 +2975,163 @@ export class PathTracerApp extends EventDispatcher {
2868
2975
 
2869
2976
  }
2870
2977
 
2978
+ /**
2979
+ * What the Compositor is drawing from, as linear float. Falls back to the accumulation when no
2980
+ * denoiser has published anything, which is also what the viewport shows then.
2981
+ * @private
2982
+ */
2983
+ async _readDisplaySource( accumulation, width, height ) {
2984
+
2985
+ const context = this.pipeline?.context;
2986
+ const texture = context && this.stages.compositor?.resolveLightTexture( context );
2987
+ if ( ! texture || texture === accumulation.texture ) {
2988
+
2989
+ return await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 );
2990
+
2991
+ }
2992
+
2993
+ this._textureReadback ??= new TextureReadback( this.renderer );
2994
+ return await this._textureReadback.read( texture, width, height );
2995
+
2996
+ }
2997
+
2998
+ /**
2999
+ * Set what colour space one texture is in, then rebuild so it takes effect.
3000
+ *
3001
+ * The per-image colour space every OCIO application offers. Three kinds of answer:
3002
+ * - `null` — automatic: tag, override, the config's file rules, then three.js
3003
+ * - `'srgb'`/`'linear'` — three.js's own two, which work with no config at all
3004
+ * - a config space — anything the loaded config names, including log and camera spaces
3005
+ *
3006
+ * A config space is carried as `userData.ocioColorSpace`, and the texture's three.js colour space
3007
+ * is set to match the colour pool while it is, so harmonization leaves the stored bytes as the
3008
+ * author wrote them — the named transform has to read them raw. The original is kept and put
3009
+ * back when the choice is cleared.
3010
+ *
3011
+ * @param {import('three').Texture} texture
3012
+ * @param {?string} choice
3013
+ */
3014
+ async setTextureColorSpace( texture, choice ) {
3015
+
3016
+ if ( ! texture?.isTexture ) throw new Error( 'setTextureColorSpace wants a three.js texture' );
3017
+
3018
+ const ud = texture.userData ?? ( texture.userData = {} );
3019
+ if ( ud.__rayzeeOriginalColorSpace === undefined ) ud.__rayzeeOriginalColorSpace = texture.colorSpace;
3020
+ const original = ud.__rayzeeOriginalColorSpace;
3021
+
3022
+ delete ud.ocioColorSpace;
3023
+ delete ud.__rayzeeColorSpaceChoice;
3024
+
3025
+ if ( choice === null || choice === undefined ) {
3026
+
3027
+ texture.colorSpace = original;
3028
+ delete ud.__rayzeeOriginalColorSpace;
3029
+
3030
+ } else if ( choice === 'srgb' || choice === 'linear' ) {
3031
+
3032
+ texture.colorSpace = choice === 'srgb' ? SRGBColorSpace : LinearSRGBColorSpace;
3033
+ ud.__rayzeeColorSpaceChoice = choice;
3034
+
3035
+ } else {
3036
+
3037
+ if ( ! this.color?.hasConfig ) throw new Error( `"${choice}" needs a colour config loaded` );
3038
+ ud.ocioColorSpace = choice;
3039
+ texture.colorSpace = SRGBColorSpace;
3040
+
3041
+ }
3042
+
3043
+ texture.needsUpdate = true;
3044
+ if ( this.stages.pathTracer?.sdfs ) await this.rebuildMaterials();
3045
+
3046
+ }
3047
+
3048
+ /**
3049
+ * Load a colour config, keeping the scene consistent with it.
3050
+ *
3051
+ * Prefer this to `app.color.loadConfig()` whenever a scene is loaded. A working space adopted
3052
+ * under the previous config is undone first, *while that config is still loaded* — the
3053
+ * environment is converted in place, and only the config that converted it can convert it
3054
+ * back. With `adoptWorkingSpace` the scene is rebuilt in the new space afterwards.
3055
+ *
3056
+ * @param {Object} options - as `ColorManagement.loadConfig`
3057
+ * @returns {Promise<Object>} the config description
3058
+ */
3059
+ async loadColorConfig( options = {} ) {
3060
+
3061
+ await this._leaveColorWorkingSpace();
3062
+ const shown = getViewTransform( this.renderer?.toneMapping );
3063
+ const described = await this.color.loadConfig( options );
3064
+ if ( this.color.workingSpaceAdopted ) await this.applyColorWorkingSpace();
3065
+ // A baked view the config kept is still what is on screen.
3066
+ const kept = shown?.source === 'ocio' && getViewTransform( this.renderer?.toneMapping ) === shown;
3067
+ if ( ! kept || this.color.workingSpaceAdopted ) this.reset();
3068
+ return described;
3069
+
3070
+ }
3071
+
3072
+ /** Unload the colour config, putting the scene back into linear Rec.709 first. */
3073
+ async unloadColorConfig() {
3074
+
3075
+ await this._leaveColorWorkingSpace();
3076
+ this.color.unloadConfig();
3077
+ this.reset();
3078
+
3079
+ }
3080
+
3081
+ /** @private */
3082
+ async _leaveColorWorkingSpace() {
3083
+
3084
+ if ( ! this.color?.workingSpaceAdopted ) return;
3085
+ this.color.setWorkingSpace( null );
3086
+ await this.applyColorWorkingSpace();
3087
+
3088
+ }
3089
+
3090
+ /**
3091
+ * Rebuild everything the working space touches, after it has been changed.
3092
+ *
3093
+ * Changing what the engine renders in is not a display setting — it changes what every texture,
3094
+ * tint and light colour already loaded *means*. Textures and materials are re-packed from their
3095
+ * pristine three.js sources, so they convert cleanly; the environment is converted where it
3096
+ * lies, from whichever space it currently holds, and its importance-sampling table is rebuilt
3097
+ * because its pixels moved.
3098
+ *
3099
+ * Call this after `app.color.setWorkingSpace()`. Doing nothing instead leaves a scene half in
3100
+ * one space and half in another, which reads as a colour cast with no obvious cause.
3101
+ */
3102
+ async applyColorWorkingSpace() {
3103
+
3104
+ const env = this.environmentManager?.getEnvironmentTexture?.();
3105
+ let envChanged = false;
3106
+
3107
+ if ( env && this.color?.hasConfig ) {
3108
+
3109
+ try {
3110
+
3111
+ envChanged = this.color.convertTexturePixels( env );
3112
+
3113
+ } catch ( error ) {
3114
+
3115
+ log.warn( `environment colour conversion failed: ${error.message}` );
3116
+
3117
+ }
3118
+
3119
+ }
3120
+
3121
+ // Nothing to rebuild before a scene exists; anything loaded later converts on its way in.
3122
+ if ( this.stages.pathTracer?.sdfs ) {
3123
+
3124
+ await this.rebuildMaterials();
3125
+ this._uploadEmissivePayload( this._sdf?.rebuildEmissiveColors?.() ?? null );
3126
+
3127
+ }
3128
+
3129
+ if ( envChanged ) await this.environmentManager?.buildEnvironmentCDF?.();
3130
+
3131
+ this.reset();
3132
+
3133
+ }
3134
+
2871
3135
  /**
2872
3136
  * Full material rebuild (required after texture changes).
2873
3137
  * @param {import('three').Scene} [scene]
@@ -2995,6 +3259,11 @@ export class PathTracerApp extends EventDispatcher {
2995
3259
  this.renderer.toneMappingExposure = 1.0;
2996
3260
  this.renderer.setPixelRatio( 1.0 );
2997
3261
 
3262
+ // Hands every registered view transform to the renderer and keeps `outputColorSpace` in
3263
+ // step with whichever is selected — an OCIO view already encoded for its display must not
3264
+ // be encoded a second time by the output pass.
3265
+ this.color.attachRenderer( this.renderer );
3266
+
2998
3267
  }
2999
3268
 
3000
3269
  _initCameraManager() {
@@ -3028,6 +3297,8 @@ export class PathTracerApp extends EventDispatcher {
3028
3297
  this._addTrackedListener( this.cameraManager.controls, 'change', () => {
3029
3298
 
3030
3299
  this.needsReset = true;
3300
+ // Here rather than in render(), so the first frame of the move is already at the lower resolution.
3301
+ this.stages.pathTracer?.enterInteractionMode();
3031
3302
  this.wake();
3032
3303
 
3033
3304
  } );
@@ -3054,8 +3325,15 @@ export class PathTracerApp extends EventDispatcher {
3054
3325
 
3055
3326
  const initRenderW = this.canvas.clientWidth || 1;
3056
3327
  const initRenderH = this.canvas.clientHeight || 1;
3328
+ this._displayWidth = initRenderW;
3329
+ this._displayHeight = initRenderH;
3057
3330
  this.pipeline.setSize( initRenderW, initRenderH );
3058
3331
 
3332
+ this.pipeline.eventBus.on( 'pathtracer:interactionStart', () => this._requestRenderScale( this._interactionRenderScale() ) );
3333
+ // Never fires inside a frame (a timer, or interaction mode being switched off), so it applies at once —
3334
+ // renderFrames() and the video renderer drive pipeline.render() without passing through animate().
3335
+ this.pipeline.eventBus.on( 'pathtracer:interactionEnd', () => this._applyRenderScale( 1 ) );
3336
+
3059
3337
  }
3060
3338
 
3061
3339
  async _initManagers() {
@@ -3176,6 +3454,11 @@ export class PathTracerApp extends EventDispatcher {
3176
3454
  reconcileCompletion: () => this._reconcileCompletion(),
3177
3455
  denoisingManager: this.denoisingManager,
3178
3456
  cameraManager: this.cameraManager,
3457
+ onInteractionRenderScale: () => {
3458
+
3459
+ if ( this.stages.pathTracer?.interactionMode ) this._requestRenderScale( this._interactionRenderScale() );
3460
+
3461
+ },
3179
3462
  } );
3180
3463
 
3181
3464
  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
 
@@ -876,8 +851,7 @@ export class AssetLoader extends EventDispatcher {
876
851
  this.targetModel = group;
877
852
 
878
853
  // The light's own orientation and `scale` are already baked into the texture, so the
879
- // scene is only correct at rotation 0 / intensity 1. Without this the viewer profile's
880
- // default 270° rotation lands on top and the sky sits 90° from where pbrt puts it.
854
+ // scene is only correct at rotation 0 / intensity 1 — pinned, whatever the host's defaults.
881
855
  if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
882
856
 
883
857
  updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
@@ -2109,18 +2083,22 @@ export class AssetLoader extends EventDispatcher {
2109
2083
 
2110
2084
  const userData = object.userData;
2111
2085
 
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 );
2086
+ // An adopted light carries three.js units; convert as point/spot are below.
2087
+ if ( object.isRectAreaLight && ! userData.__radianceConverted ) {
2088
+
2089
+ object.intensity *= areaLightPowerFactor( object, object.width, object.height, userData );
2090
+ userData.__radianceConverted = true;
2091
+
2092
+ }
2115
2093
 
2116
2094
  // 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.
2095
+ // candela and directional in lux. The engine is radiometric Blender Watts, which
2096
+ // LightSerializer turns into W/sr with ÷4π, so the luminous efficacy has to be
2097
+ // divided back out — Blender's own glTF importer does exactly this. Skipping it
2098
+ // rendered every Blender lamp 683x too bright.
2121
2099
  if ( ( object.isPointLight || object.isSpotLight ) && ! userData.__candelaConverted ) {
2122
2100
 
2123
- object.intensity /= LUMENS_PER_WATT;
2101
+ object.intensity *= 4 * Math.PI / LUMENS_PER_WATT;
2124
2102
  userData.__candelaConverted = true;
2125
2103
 
2126
2104
  }
@@ -2132,17 +2110,31 @@ export class AssetLoader extends EventDispatcher {
2132
2110
 
2133
2111
  }
2134
2112
 
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' ) {
2113
+ // Process ceiling lights
2114
+ if ( object.name.startsWith( 'RectAreaLightPlaceholder' ) &&
2115
+ userData.name
2116
+ // && userData.name.includes( "ceilingLight" )
2117
+ ) {
2118
+
2119
+ if ( userData.type === 'RectAreaLight' ) {
2120
+
2121
+ const normalize = userData.normalize ?? true;
2122
+ const shape = userData.shape ?? 'rectangle';
2123
+ const power = userData.intensity * areaLightPowerFactor( object, userData.width, userData.height, userData );
2124
+ const light = new RectAreaLight(
2125
+ new Color( ...userData.color ),
2126
+ power * this._profile.areaLightIntensityScale,
2127
+ userData.width,
2128
+ userData.height
2129
+ );
2130
+ light.userData.normalize = normalize;
2131
+ light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
2132
+ light.userData.shape = shape;
2133
+ light.userData.__radianceConverted = true; // already power, and traverse() reaches it
2134
+ light.name = userData.name;
2135
+ object.add( light );
2138
2136
 
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
- );
2137
+ }
2146
2138
 
2147
2139
  }
2148
2140