rayzee 11.7.0 → 11.9.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 (79) hide show
  1. package/README.md +31 -6
  2. package/dist/{ArchiveImporter-DYcRXeKE.js → ArchiveImporter-BJxszU_k.js} +2353 -1356
  3. package/dist/{BidirectionalIntegrator-BvjRpbTP.js → BidirectionalIntegrator-DWg66vAd.js} +302 -301
  4. package/dist/{BufferLayout-xPRH9jYD.js → BufferLayout-Bzgccg1o.js} +1 -0
  5. package/dist/{ColorManagement-oeEk7oSu.js → ColorManagement-B_HjAiy2.js} +1 -1
  6. package/dist/{EngineIssues-CIZnXHsF.js → EngineIssues-CfJiBaNj.js} +19 -8
  7. package/dist/{NeuralRetouch-ClaMiKYg.js → NeuralRetouch-BD9E-Yht.js} +2 -2
  8. package/dist/{NeuralSuperRes-WNEXun9s.js → NeuralSuperRes-C8Mf3tJX.js} +2 -2
  9. package/dist/{PathTracer-B9wZPPVL.js → PathTracer-DeE9UJ1n.js} +3530 -3402
  10. package/dist/{PhysicalSky-DcjItqKi.js → PhysicalSky-cKy75-oR.js} +45 -45
  11. package/dist/{RayzeeRenderer-p-NeE6E2.js → RayzeeRenderer-CzLyBA3G.js} +378 -374
  12. package/dist/SceneMetadata-C17UfOEp.js +183 -0
  13. package/dist/{SolarLimb-bZE6y63S.js → SolarLimb-DRYy8tln.js} +184 -172
  14. package/dist/{ToneMapGPU-sAs8wdgI.js → ToneMapGPU-4GPwoG_c.js} +13 -6
  15. package/dist/addons/archives.es.js +1 -1
  16. package/dist/addons/bidirectional.es.js +1 -1
  17. package/dist/addons/color.es.js +1 -1
  18. package/dist/addons/physical-sky.es.js +1 -1
  19. package/dist/addons/storage.es.js +1 -1
  20. package/dist/{openStorage-Fe9BoS1_.js → openStorage-CdyVjmps.js} +1 -1
  21. package/dist/rayzee-core.es.js +7 -7
  22. package/dist/rayzee.es.js +821 -794
  23. package/dist/rayzee.umd.js +112 -103
  24. package/dist/{sceneCachePolicy-B9w3BnaP.js → sceneCachePolicy-ywbGKHD3.js} +9 -6
  25. package/package.json +1 -1
  26. package/src/EngineDefaults.js +1 -0
  27. package/src/EngineIssues.js +21 -6
  28. package/src/Passes/AIUpscaler.js +4 -0
  29. package/src/Passes/OIDNDenoiser.js +8 -6
  30. package/src/Passes/OIDNTemporalHistory.js +1 -1
  31. package/src/PathTracerApp.js +10 -1
  32. package/src/Processor/ArchiveImporter.js +106 -27
  33. package/src/Processor/AssetLoader.js +22 -0
  34. package/src/Processor/BufferLayout.js +3 -3
  35. package/src/Processor/GeometryExtractor.js +12 -4
  36. package/src/Processor/LightSerializer.js +6 -2
  37. package/src/Processor/MaterialPacking.js +2 -2
  38. package/src/Processor/PBRT/PBRTMaterials.js +49 -13
  39. package/src/Processor/PBRT/PBRTParser.js +144 -22
  40. package/src/Processor/PBRT/PBRTSceneBuilder.js +287 -67
  41. package/src/Processor/PBRT/PBRTTextureBake.js +191 -9
  42. package/src/Processor/PBRT/index.js +19 -4
  43. package/src/Processor/PlatformImageLoader.js +9 -0
  44. package/src/Processor/SceneProcessor.js +42 -26
  45. package/src/Processor/TextureCreator.js +44 -4
  46. package/src/Processor/ToneMapCPU.js +34 -5
  47. package/src/Processor/ToneMapGPU.js +15 -9
  48. package/src/Processor/USD/USDScene.js +981 -17
  49. package/src/RayzeeRenderer.js +85 -28
  50. package/src/Stages/AutoExposure.js +21 -8
  51. package/src/Stages/Compositor.js +39 -1
  52. package/src/Stages/DenoiserSettings.js +0 -4
  53. package/src/Stages/LocalExposure.js +44 -18
  54. package/src/Stages/NRD.js +53 -24
  55. package/src/Stages/NormalDepth.js +12 -6
  56. package/src/Stages/PathTracer.js +27 -1
  57. package/src/Stages/PathTracerStage.js +14 -2
  58. package/src/Storage/SceneGraphCodec.js +6 -3
  59. package/src/TSL/BVHTraversal.js +33 -20
  60. package/src/TSL/CameraRay.js +12 -1
  61. package/src/TSL/Common.js +25 -8
  62. package/src/TSL/DebugKernel.js +3 -2
  63. package/src/TSL/Displacement.js +66 -113
  64. package/src/TSL/FinalWriteKernel.js +4 -7
  65. package/src/TSL/GenerateKernel.js +6 -2
  66. package/src/TSL/LightSplatKernel.js +7 -4
  67. package/src/TSL/LightsCore.js +6 -2
  68. package/src/TSL/LightsDirect.js +33 -11
  69. package/src/TSL/LightsSampling.js +45 -12
  70. package/src/TSL/SceneResources.js +19 -0
  71. package/src/TSL/ShadeKernel.js +3 -4
  72. package/src/TSL/Struct.js +1 -0
  73. package/src/integrators/BidirectionalIntegrator.js +1 -0
  74. package/src/managers/CameraManager.js +9 -0
  75. package/src/managers/DenoisingManager.js +1 -0
  76. package/src/managers/InteractionManager.js +6 -0
  77. package/src/managers/MaterialDataManager.js +11 -0
  78. package/src/managers/UniformManager.js +3 -1
  79. package/dist/SceneMetadata-D2Sh-0e-.js +0 -110
@@ -123,6 +123,15 @@ function attachDeviceLostHandler( device, holder ) {
123
123
 
124
124
  }
125
125
 
126
+ // three.js's GPUFeatureName, which it does not export: what its own device request enables.
127
+ const THREE_DEVICE_FEATURES = [
128
+ 'core-features-and-limits', 'depth-clip-control', 'depth32float-stencil8', 'texture-compression-bc',
129
+ 'texture-compression-bc-sliced-3d', 'texture-compression-etc2', 'texture-compression-astc',
130
+ 'texture-compression-astc-sliced-3d', 'timestamp-query', 'indirect-first-instance', 'shader-f16',
131
+ 'rg11b10ufloat-renderable', 'bgra8unorm-storage', 'float32-filterable', 'float32-blendable', 'clip-distances',
132
+ 'dual-source-blending', 'subgroups', 'texture-formats-tier1', 'texture-formats-tier2',
133
+ ];
134
+
126
135
  const SOFTWARE_ADAPTER = /swiftshader|llvmpipe|lavapipe|basic render|microsoft basic|warp/i;
127
136
 
128
137
  /**
@@ -822,9 +831,10 @@ export class RayzeeRenderer extends EventDispatcher {
822
831
  // disposed graph stays reachable until another app claims the same canvas.
823
832
  if ( this.canvas && _appsByCanvas.get( this.canvas ) === this ) _appsByCanvas.delete( this.canvas );
824
833
 
825
- if ( this._gpuDevice ) {
834
+ const device = this._gpuDevice;
835
+ if ( device ) {
826
836
 
827
- this._gpuDevice.onuncapturederror = null;
837
+ device.onuncapturederror = null;
828
838
  this._gpuDevice = null;
829
839
 
830
840
  }
@@ -836,7 +846,8 @@ export class RayzeeRenderer extends EventDispatcher {
836
846
 
837
847
  }
838
848
 
839
- disposeRenderer( this.renderer );
849
+ // three.js destroys only a device it requested itself.
850
+ Promise.resolve( disposeRenderer( this.renderer ) ).finally( () => device?.destroy() );
840
851
  this.renderer = null;
841
852
 
842
853
  this.stages = {};
@@ -1222,7 +1233,7 @@ export class RayzeeRenderer extends EventDispatcher {
1222
1233
  this._modelReplaced();
1223
1234
  // Before the scene's own environment starts loading: a spilling scene's is narrowed as it decodes.
1224
1235
  this._planSpill();
1225
- await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
1236
+ const built = await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
1226
1237
  this._maybeSpill();
1227
1238
  this.pipeline?.eventBus.emit( 'pipeline:lightingChanged' );
1228
1239
  // Not held: the first denoise of a new scene lands ~1.1 s after the load (upload and
@@ -1231,6 +1242,7 @@ export class RayzeeRenderer extends EventDispatcher {
1231
1242
  this.reset();
1232
1243
  this.dispatchEvent( eventPayload );
1233
1244
  this._modelAnnounced();
1245
+ if ( built ) this.assetLoader?.commitPendingGraph( this.sceneModel );
1234
1246
 
1235
1247
  } catch ( error ) {
1236
1248
 
@@ -2724,7 +2736,8 @@ export class RayzeeRenderer extends EventDispatcher {
2724
2736
  * @param {string} [options.colorSpace='srgb'] - `'srgb'` for display bytes through the active
2725
2737
  * view transform, `'linear'` for the raw working-space accumulation, or the name of a colour
2726
2738
  * space in the loaded OCIO config for a delivery buffer (float, e.g. `'ACES2065-1'`)
2727
- * @param {boolean} [options.preserveAlpha=false] - srgb only
2739
+ * @param {boolean} [options.preserveAlpha=false] - srgb only: coverage as alpha while the background is
2740
+ * transparent, else 255. Float readbacks carry coverage then and 1 otherwise, as the canvas shows it.
2728
2741
  * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2729
2742
  * viewport is showing — denoised when a denoiser has run — instead of the raw
2730
2743
  * accumulation
@@ -2750,6 +2763,8 @@ export class RayzeeRenderer extends EventDispatcher {
2750
2763
 
2751
2764
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
2752
2765
  const { width, height } = stage;
2766
+ // The accumulation's alpha is always coverage; an opaque backdrop reads back opaque, as the Compositor draws it.
2767
+ const transparent = !! this.settings.get( 'transparentBackground' );
2753
2768
 
2754
2769
  if ( colorSpace === 'srgb' ) {
2755
2770
 
@@ -2758,15 +2773,16 @@ export class RayzeeRenderer extends EventDispatcher {
2758
2773
  exposure: this.renderer.toneMappingExposure,
2759
2774
  toneMapping: this.renderer.toneMapping,
2760
2775
  saturation: this.settings.get( 'saturation' ) ?? 1,
2761
- preserveAlpha,
2776
+ preserveAlpha: preserveAlpha && transparent,
2762
2777
  gain: this._displayGain(),
2763
2778
  };
2764
2779
 
2765
2780
  const gpu = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone );
2766
- const fn = ! gpu && tone.gain ? await tone.gain.cpu() : null;
2781
+ const fn = ! gpu && tone.gain?.cpu ? await tone.gain.cpu() : null;
2782
+ const backdrop = tone.gain?.backdrop ?? 0;
2767
2783
  const data = gpu ?? toneMapToRGBA8( shown
2768
2784
  ? await this._readTexture( shown.texture, width, height )
2769
- : await readPixels( this.renderer, target, width, height ), { ...tone, pixelGain: fn && { fn, width, height } } );
2785
+ : await readPixels( this.renderer, target, width, height ), { ...tone, pixelGain: ( fn || backdrop ) ? { fn, width, height, backdrop } : null } );
2770
2786
  return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
2771
2787
 
2772
2788
  }
@@ -2774,6 +2790,12 @@ export class RayzeeRenderer extends EventDispatcher {
2774
2790
  const { data: linear, source: read } = source === 'display'
2775
2791
  ? await this._readDisplaySource( target, width, height )
2776
2792
  : { data: await readPixels( this.renderer, target, width, height ), source: 'accumulation' };
2793
+ if ( ! transparent ) {
2794
+
2795
+ const one = linear instanceof Uint16Array ? 0x3C00 : 1; // a half-float target reads back as its bits
2796
+ for ( let i = 3; i < linear.length; i += 4 ) linear[ i ] = one;
2797
+
2798
+ }
2777
2799
 
2778
2800
  // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2779
2801
  // answer to "linear in what primaries", which only means something once a config is loaded.
@@ -3654,14 +3676,25 @@ export class RayzeeRenderer extends EventDispatcher {
3654
3676
 
3655
3677
  if ( typeof navigator === 'undefined' || ! navigator.gpu ) {
3656
3678
 
3657
- throw new Error( 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3679
+ this._issues.fail( ISSUE_CODES.WEBGPU_UNAVAILABLE, 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3680
+
3681
+ }
3682
+
3683
+ let adapter = null;
3684
+ let adapterError;
3685
+ try {
3686
+
3687
+ adapter = await navigator.gpu.requestAdapter( { powerPreference: 'high-performance' } );
3688
+
3689
+ } catch ( error ) {
3690
+
3691
+ adapterError = error;
3658
3692
 
3659
3693
  }
3660
3694
 
3661
- const adapter = await navigator.gpu.requestAdapter( { powerPreference: 'high-performance' } );
3662
3695
  if ( ! adapter ) {
3663
3696
 
3664
- throw new Error( 'Failed to get WebGPU adapter' );
3697
+ this._issues.fail( ISSUE_CODES.ADAPTER_UNAVAILABLE, `no WebGPU adapter${adapterError ? ': ' + adapterError.message : ''}`, {}, adapterError );
3665
3698
 
3666
3699
  }
3667
3700
 
@@ -3711,22 +3744,43 @@ export class RayzeeRenderer extends EventDispatcher {
3711
3744
  // buys nothing, and 10 is already this adapter's ceiling.
3712
3745
  requiredLimits.maxStorageBuffersPerShaderStage = Math.min( adapterLimits.maxStorageBuffersPerShaderStage, 10 );
3713
3746
 
3714
- this.renderer = new WebGPURenderer( {
3715
- canvas: this.canvas,
3716
- alpha: true,
3717
- powerPreference: 'high-performance',
3718
- requiredLimits,
3719
- } );
3747
+ // Requested here rather than by three.js, so a failure is typed where it happens.
3748
+ let device;
3749
+ try {
3750
+
3751
+ device = await adapter.requestDevice( {
3752
+ requiredFeatures: THREE_DEVICE_FEATURES.filter( ( name ) => adapter.features.has( name ) ),
3753
+ requiredLimits,
3754
+ } );
3720
3755
 
3721
- await initWithoutFrameLoop( this.renderer, this._headless );
3756
+ } catch ( error ) {
3757
+
3758
+ this._issues.fail( ISSUE_CODES.DEVICE_UNAVAILABLE, `WebGPU device request failed: ${error.message}`, { adapter: { ...this.adapterInfo } }, error );
3759
+
3760
+ }
3761
+
3762
+ this.renderer = new WebGPURenderer( { canvas: this.canvas, alpha: true, device } );
3763
+
3764
+ try {
3765
+
3766
+ await initWithoutFrameLoop( this.renderer, this._headless );
3767
+
3768
+ } catch ( error ) {
3769
+
3770
+ device.destroy();
3771
+ throw error;
3772
+
3773
+ }
3722
3774
 
3723
3775
  // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
3724
3776
  // compute-only, so every frame would fail against an empty canvas.
3725
3777
  if ( ! this.renderer.backend?.isWebGPUBackend ) {
3726
3778
 
3727
- throw new Error(
3728
- 'WebGPU backend unavailable — three.js fell back to WebGL2, which cannot run the ' +
3729
- 'path tracer\'s compute kernels. Check GPU process flags and driver support.'
3779
+ device.destroy();
3780
+ this._issues.fail(
3781
+ ISSUE_CODES.WEBGPU_UNAVAILABLE,
3782
+ 'three.js fell back to WebGL2, which cannot run the path tracer\'s compute kernels. ' +
3783
+ 'Check GPU process flags and driver support.'
3730
3784
  );
3731
3785
 
3732
3786
  }
@@ -3743,13 +3797,12 @@ export class RayzeeRenderer extends EventDispatcher {
3743
3797
  // BVH and CPU-side texture data per create/dispose cycle. `lost` is a pending promise
3744
3798
  // whose reaction cannot be unregistered, so it reads the app out of a holder that
3745
3799
  // dispose() empties; `onuncapturederror` is cleared there directly.
3746
- const gpuDevice = this.renderer.backend?.device;
3747
- if ( gpuDevice?.lost ) {
3800
+ this._gpuDevice = device;
3801
+ if ( device.lost ) {
3748
3802
 
3749
- this._gpuDevice = gpuDevice;
3750
3803
  this._deviceLostHolder = { app: this };
3751
- attachDeviceLostHandler( gpuDevice, this._deviceLostHolder );
3752
- gpuDevice.onuncapturederror = ( event ) => log.error( 'WebGPU uncaptured error:', event.error );
3804
+ attachDeviceLostHandler( device, this._deviceLostHolder );
3805
+ device.onuncapturederror = ( event ) => log.error( 'WebGPU uncaptured error:', event.error );
3753
3806
 
3754
3807
  }
3755
3808
 
@@ -3860,7 +3913,8 @@ export class RayzeeRenderer extends EventDispatcher {
3860
3913
 
3861
3914
  // Drag-drop / file load is a replace: clear any appended models first.
3862
3915
  this._clearAppendedModels();
3863
- await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
3916
+ const built = await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
3917
+ if ( built ) this.assetLoader?.commitPendingGraph( event.model );
3864
3918
 
3865
3919
  } else if ( event.texture ) {
3866
3920
 
@@ -4072,7 +4126,10 @@ export class RayzeeRenderer extends EventDispatcher {
4072
4126
 
4073
4127
  }
4074
4128
 
4075
- /** A per-pixel gain the shown picture carries before the view (the compositor's), for tone-mapped readbacks; or null. */
4129
+ /**
4130
+ * A per-pixel gain the shown picture carries before the view (the compositor's), for tone-mapped readbacks; or null.
4131
+ * Its `backdrop`, when set, is the gain a solid backdrop is held at (`PackedToneMapper`'s `gain`).
4132
+ */
4076
4133
  _displayGain() {
4077
4134
 
4078
4135
  return null;
@@ -26,7 +26,7 @@ const BINS = 256;
26
26
  const LOG2_MIN = - 16;
27
27
  const LOG2_MAX = 12;
28
28
  const BIN_STOPS = ( LOG2_MAX - LOG2_MIN ) / BINS;
29
- // A tile below this has no light to meter: a black or transparent background is not part of the exposure.
29
+ // A tile below this has no light to meter (a black sky, say): it is not part of the exposure.
30
30
  const BLACK = 2 ** - 24;
31
31
  const WEIGHT_SCALE = 65536;
32
32
  const MAX_TILE_READS = 256;
@@ -42,6 +42,10 @@ const MOTION_HOLD = 0.3; // a metering of a new view counts as motion for this l
42
42
  const DAMP_UNTIL = 1.5; // stops from the manual exposure: the room level is damped up to here...
43
43
  const FOLLOW_FROM = 3; // ...and followed fully from here
44
44
  const REC709_Y = [ 0.2126, 0.7152, 0.0722 ];
45
+ // A pixel this covered saw only the scene. One less carries the backdrop's own colour: an edge, a window onto it.
46
+ const FULL_COVERAGE = 0.999;
47
+ // How the backdrop counts: weighed out by coverage (transparent), in full (the environment), not at all (a solid colour).
48
+ const BACKDROP_MODES = { transparent: 0, environment: 1, color: 2 };
45
49
 
46
50
  /**
47
51
  * One adaptation step in stops: constant speed while far, exponential near the target, never past it.
@@ -93,15 +97,19 @@ const meterInterval = samples => samples < 16 ? 1 : samples < 64 ? 4 : 16;
93
97
  /**
94
98
  * Auto exposure: meters the accumulated image on the GPU and adapts on the CPU.
95
99
  *
96
- * Tiles are averaged in linear light, weighted by alpha, so a one-sample image meters like the
97
- * converged one; the percentile-clipped mean of their log2 histogram is read back. It aims at a room
98
- * level learned while the view changes plus a damped share of each view (`blendExposureEV`), moves in
99
- * stops every loop frame (`update()`: eased while the image restarts every frame, landing in ~0.3 s once
100
- * it accumulates), in video time (`advance()`, eased), or with each metering (`instant`), and is applied
101
- * as 2^EV × the manual exposure.
100
+ * Tiles are averaged in linear light, so a one-sample image meters like the converged one; the
101
+ * percentile-clipped mean of their log2 histogram is read back. The sky, also seen through windows, is
102
+ * part of what is lit and is metered; a transparent backdrop is weighed out by the path tracer's coverage
103
+ * (alpha); under a solid colour only pixels that saw the scene alone count, so its colour moves nothing.
104
+ *
105
+ * It aims at a room level learned while the view changes plus a damped share of each view
106
+ * (`blendExposureEV`), moves in stops every loop frame (`update()`: eased while the image restarts
107
+ * every frame, landing in ~0.3 s once it accumulates), in video time (`advance()`, eased), or with each
108
+ * metering (`instant`), and is applied as 2^EV × the manual exposure.
102
109
  *
103
110
  * Events listened: pipeline:reset, pipeline:lightingChanged (lands on the next metering)
104
111
  * Textures read: pathtracer:color
112
+ * State read: pathtracer:samples, pathtracer:backdrop
105
113
  * State published: autoexposure:value, autoexposure:avgLuminance
106
114
  */
107
115
  export class AutoExposure extends RenderStage {
@@ -133,6 +141,7 @@ export class AutoExposure extends RenderStage {
133
141
  this._falloffU = uniform( new Vector2() );
134
142
  this._pointU = uniform( new Vector2( 0.5, 0.5 ) );
135
143
  this._lumWeightsU = uniform( new Vector3().fromArray( REC709_Y ) );
144
+ this._backdropModeU = uniform( BACKDROP_MODES.transparent, 'int' );
136
145
  this._widthU = uniform( 1, 'int' );
137
146
  this._heightU = uniform( 1, 'int' );
138
147
  this._strideU = uniform( 1, 'int' );
@@ -178,6 +187,7 @@ export class AutoExposure extends RenderStage {
178
187
  const hist = this._histogram;
179
188
  const width = this._widthU, height = this._heightU, stride = this._strideU, gridX = this._gridXU, gridY = this._gridYU;
180
189
  const lumWeights = this._lumWeightsU, falloff = this._falloffU, point = this._pointU;
190
+ const backdropMode = this._backdropModeU;
181
191
 
182
192
  return Fn( () => {
183
193
 
@@ -199,7 +209,9 @@ export class AutoExposure extends RenderStage {
199
209
  const lum = dot( c.xyz, lumWeights ).toVar();
200
210
  // NaN or Inf would poison the whole tile.
201
211
  const finite = floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) );
202
- const alpha = c.w.clamp( 0.0, 1.0 );
212
+ const coverage = c.w.clamp( 0.0, 1.0 );
213
+ const alpha = select( backdropMode.equal( int( BACKDROP_MODES.environment ) ), float( 1 ),
214
+ select( backdropMode.equal( int( BACKDROP_MODES.color ) ), select( coverage.greaterThanEqual( FULL_COVERAGE ), float( 1 ), float( 0 ) ), coverage ) );
203
215
  sum.addAssign( select( finite, lum.clamp( 0.0, 1e6 ), float( 0 ) ).mul( alpha ) );
204
216
  cover.addAssign( alpha );
205
217
 
@@ -388,6 +400,7 @@ export class AutoExposure extends RenderStage {
388
400
  this._strideU.value = Math.max( 1, Math.round( Math.sqrt( tileReads / MAX_TILE_READS ) ) );
389
401
  this._widthU.value = width;
390
402
  this._heightU.value = height;
403
+ this._backdropModeU.value = BACKDROP_MODES[ context.getState( 'pathtracer:backdrop' ) ] ?? BACKDROP_MODES.transparent;
391
404
  this._updateMeteringPattern( width / height );
392
405
  this._inputNode.value = input;
393
406
 
@@ -3,6 +3,7 @@ import { MeshBasicNodeMaterial, QuadMesh, TextureNode } from 'three/webgpu';
3
3
  import { NoBlending, NoToneMapping } from 'three';
4
4
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
5
5
  import { REC709_LUMINANCE_COEFFICIENTS } from '../TSL/Common.js';
6
+ import { backdropGain } from '../Processor/ToneMapCPU.js';
6
7
 
7
8
 
8
9
  /**
@@ -51,6 +52,11 @@ export class Compositor extends RenderStage {
51
52
  this._sourceTexNode = new TextureNode();
52
53
  this._displayGain = null;
53
54
 
55
+ // A solid backdrop held at this exposure, or null: see setBackdropExposure().
56
+ this._backdropExposure = null;
57
+ this._holdBackdrop = uniform( 0, 'int' );
58
+ this._backdropGain = uniform( 1.0 );
59
+
54
60
  this.compositorMaterial = new MeshBasicNodeMaterial();
55
61
  this.compositorMaterial.blending = NoBlending;
56
62
  this._buildColorNode();
@@ -62,7 +68,17 @@ export class Compositor extends RenderStage {
62
68
  _buildColorNode() {
63
69
 
64
70
  const texSample = this._sourceTexNode.sample( uv() );
65
- const color = this._displayGain ? texSample.xyz.mul( this._displayGain( texSample.xyz, screenUV ) ) : texSample.xyz;
71
+ let gain = this._displayGain ? this._displayGain( texSample.xyz, screenUV ) : null;
72
+ if ( this._backdropExposure !== null ) {
73
+
74
+ // Alpha is coverage: the backdrop takes its own gain, a partly covered pixel a blend (ToneMapCPU's heldGain).
75
+ const g = gain ?? float( 1 );
76
+ const held = g.add( this._backdropGain.sub( g ).mul( texSample.w.clamp( 0, 1 ).oneMinus() ) );
77
+ gain = select( this._holdBackdrop.greaterThan( int( 0 ) ), held, g );
78
+
79
+ }
80
+
81
+ const color = gain ? texSample.xyz.mul( gain ) : texSample.xyz;
66
82
 
67
83
  const luma = dot( color, REC709_LUMINANCE_COEFFICIENTS );
68
84
  const gradedColor = mix( vec3( luma ), color, this.saturation );
@@ -86,6 +102,20 @@ export class Compositor extends RenderStage {
86
102
 
87
103
  }
88
104
 
105
+ /**
106
+ * Holds a solid-colour backdrop at `exposure` (the manual one): whatever the renderer's exposure does — auto
107
+ * exposure — and whatever display gain the scene takes, the backdrop is shown as at that exposure. Applies while
108
+ * the path tracer reports a 'color' backdrop. Null removes it.
109
+ * @param {number|null} exposure
110
+ */
111
+ setBackdropExposure( exposure ) {
112
+
113
+ const changes = ( exposure === null ) !== ( this._backdropExposure === null );
114
+ this._backdropExposure = exposure;
115
+ if ( changes ) this._buildColorNode();
116
+
117
+ }
118
+
89
119
  /**
90
120
  * The picture to show: the first published of the display sources, else `pathtracer:color` — the newest
91
121
  * scene-referred image the pipeline holds, denoised when a denoiser has run.
@@ -224,6 +254,14 @@ export class Compositor extends RenderStage {
224
254
  const sourceTexture = this.resolveLightTexture( context );
225
255
  if ( ! sourceTexture ) return;
226
256
 
257
+ if ( this._backdropExposure !== null ) {
258
+
259
+ const hold = context.getState( 'pathtracer:backdrop' ) === 'color';
260
+ this._holdBackdrop.value = hold ? 1 : 0;
261
+ if ( hold ) this._backdropGain.value = backdropGain( this._backdropExposure, this.renderer.toneMappingExposure, this.renderer.toneMapping );
262
+
263
+ }
264
+
227
265
  this._sourceTexNode.value = sourceTexture;
228
266
 
229
267
  this.renderer.setRenderTarget( null );
@@ -4,10 +4,6 @@
4
4
  export const GBUFFER_MISS_DEPTH = 65504.0;
5
5
  export const GBUFFER_MISS_THRESHOLD = 6e4;
6
6
 
7
- // Albedo demodulation safety floor. Demod (`color / safeAlbedo`) and remod (`lighting * safeAlbedo`)
8
- // only round-trip exactly when both sides use the same value.
9
- export const ALBEDO_EPS = 0.01;
10
-
11
7
  // The live view's denoisers, for setDenoiserStrategy.
12
8
  export const DENOISER_STRATEGIES = Object.freeze( [ 'none', 'nrd', 'oidn' ] );
13
9
 
@@ -26,6 +26,7 @@ const BLUR_TEXEL = 32;
26
26
  const BLUR_BLEND = 0.6;
27
27
  const BLUR_PERCENT = 50;
28
28
  const MIDDLE_GREY = Math.log2( 0.18 );
29
+ const FULL_COVERAGE = 0.999; // under a solid backdrop, a pixel counts only when it saw the scene alone
29
30
  const DARKEST = 2 ** - 24;
30
31
  const MAX_CELLS = Math.ceil( MAX_RESERVABLE_RENDER_SIZE / CELL );
31
32
  const MAX_BLUR = Math.ceil( MAX_RESERVABLE_RENDER_SIZE / BLUR_TEXEL );
@@ -127,9 +128,12 @@ fn rayzee_gain( uv: vec2<f32>, linear: vec3<f32>, exposure: f32 ) -> f32 {
127
128
  * shadows apart, and the detail kept. It changes only what is shown: the compositor and every tone-mapped
128
129
  * readback apply the same per-pixel gain (`gainNode`, `toneGain`), so a saved picture matches the canvas.
129
130
  *
130
- * Built from `pathtracer:color` when the image restarts, less often as it converges.
131
+ * Built from `pathtracer:color` when the image restarts, less often as it converges. While the backdrop is a solid
132
+ * colour, both are built from the pixels that saw the scene alone (full coverage in alpha): the backdrop is not part of
133
+ * what is lit, and the compositor holds it at the manual exposure without this gain.
131
134
  * Events listened: pipeline:reset
132
135
  * Textures read: pathtracer:color
136
+ * State read: pathtracer:samples, pathtracer:backdrop
133
137
  */
134
138
  export class LocalExposure extends RenderStage {
135
139
 
@@ -146,7 +150,9 @@ export class LocalExposure extends RenderStage {
146
150
 
147
151
  this._grid = attributeArray( MAX_CELLS * MAX_CELLS * BINS, 'vec2' );
148
152
  this._blur = attributeArray( MAX_BLUR * MAX_BLUR, 'float' );
149
- this._blurTmp = attributeArray( MAX_BLUR * MAX_BLUR, 'float' );
153
+ // The blur runs on ( log2 luminance × share, share ), divided out at the end: see the blur source kernel.
154
+ this._blurSource = attributeArray( MAX_BLUR * MAX_BLUR, 'vec2' );
155
+ this._blurTmp = attributeArray( MAX_BLUR * MAX_BLUR, 'vec2' );
150
156
  this._inputNode = new TextureNode();
151
157
  this._widthU = uniform( 1, 'int' );
152
158
  this._heightU = uniform( 1, 'int' );
@@ -155,10 +161,11 @@ export class LocalExposure extends RenderStage {
155
161
  this._blurHU = uniform( 1, 'int' );
156
162
  this._radiusU = uniform( 1 );
157
163
  this._logExposureU = uniform( 0 );
164
+ this._sceneOnlyU = uniform( 0, 'int' ); // 1: only pixels that saw the scene alone, a solid backdrop left out
158
165
  this._p = [ new Vector4(), new Vector4(), new Vector4(), new Vector4() ].map( v => uniform( v ) );
159
166
  this._params = new Float32Array( 16 );
160
167
 
161
- this._kernels = [ this._buildGridKernel(), this._buildBlurSourceKernel(), this._buildBlurKernel( this._blur, this._blurTmp, true ), this._buildBlurKernel( this._blurTmp, this._blur, false ) ];
168
+ this._kernels = [ this._buildGridKernel(), this._buildBlurSourceKernel(), this._buildBlurKernel( this._blurSource, this._blurTmp, true ), this._buildBlurKernel( this._blurTmp, this._blur, false ) ];
162
169
 
163
170
  this._built = false;
164
171
  this._restarts = 0;
@@ -175,6 +182,7 @@ export class LocalExposure extends RenderStage {
175
182
 
176
183
  const input = this._inputNode, grid = this._grid;
177
184
  const width = this._widthU, height = this._heightU, cellsX = this._cellsXU, logExposure = this._logExposureU;
185
+ const sceneOnly = this._sceneOnlyU.greaterThan( int( 0 ) );
178
186
  const yw = vec4( ...Y, 0 ).xyz;
179
187
 
180
188
  return Fn( () => {
@@ -202,20 +210,24 @@ export class LocalExposure extends RenderStage {
202
210
  If( x.lessThan( width ).and( y.lessThan( height ) ), () => {
203
211
 
204
212
  const x1 = min( x.add( int( 1 ) ), width.sub( int( 1 ) ) ), y1 = min( y.add( int( 1 ) ), height.sub( int( 1 ) ) );
205
- const lum = dot( textureLoad( input, ivec2( x, y ) ).xyz, yw )
206
- .add( dot( textureLoad( input, ivec2( x1, y ) ).xyz, yw ) )
207
- .add( dot( textureLoad( input, ivec2( x, y1 ) ).xyz, yw ) )
208
- .add( dot( textureLoad( input, ivec2( x1, y1 ) ).xyz, yw ) ).mul( 0.25 ).toVar();
213
+ const t00 = textureLoad( input, ivec2( x, y ) ).toVar(), t10 = textureLoad( input, ivec2( x1, y ) ).toVar();
214
+ const t01 = textureLoad( input, ivec2( x, y1 ) ).toVar(), t11 = textureLoad( input, ivec2( x1, y1 ) ).toVar();
215
+ const coverage = ( t ) => select( sceneOnly.and( t.w.lessThan( FULL_COVERAGE ) ), float( 0 ), float( 1 ) );
216
+ const a00 = coverage( t00 ), a10 = coverage( t10 ), a01 = coverage( t01 ), a11 = coverage( t11 );
217
+ const covered = a00.add( a10 ).add( a01 ).add( a11 ).toVar();
218
+ const lum = dot( t00.xyz, yw ).mul( a00 ).add( dot( t10.xyz, yw ).mul( a10 ) )
219
+ .add( dot( t01.xyz, yw ).mul( a01 ) ).add( dot( t11.xyz, yw ).mul( a11 ) ).div( max( covered, 1e-6 ) ).toVar();
220
+ const share = covered.mul( 0.25 ).toVar();
209
221
  // A NaN or infinite texel is left out, as the meter leaves it out: max() may keep a NaN on some
210
222
  // GPUs, and an infinite one would read as 2^40 and darken its whole cell.
211
- If( floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) ), () => {
223
+ If( floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) ).and( covered.greaterThan( 0.0 ) ), () => {
212
224
 
213
225
  const logL = log2( max( lum, DARKEST ) ).clamp( LOG_FLOOR, LOG_CEIL ).toVar();
214
226
  const f = logL.add( logExposure ).sub( BIN_MIN ).div( BIN_MAX - BIN_MIN ).clamp( 0.0, 1.0 ).mul( BINS - 1 ).toVar();
215
227
  const b0 = uint( floor( f ) ).min( uint( BINS - 1 ) );
216
228
  const b1 = b0.add( uint( 1 ) ).min( uint( BINS - 1 ) );
217
- const w1 = f.sub( floor( f ) );
218
- const w0 = float( 1 ).sub( w1 );
229
+ const w1 = f.sub( floor( f ) ).mul( share );
230
+ const w0 = float( 1 ).sub( f.sub( floor( f ) ) ).mul( share );
219
231
  const level = logL.sub( LOG_FLOOR ).mul( FIXED );
220
232
  atomicAdd( sums.element( b0 ), uint( level.mul( w0 ).add( 0.5 ) ) );
221
233
  atomicAdd( weights.element( b0 ), uint( w0.mul( FIXED ).add( 0.5 ) ) );
@@ -246,10 +258,12 @@ export class LocalExposure extends RenderStage {
246
258
  }
247
259
 
248
260
  // The log of each 32×32 block's mean luminance: averaged in linear light, like the meter, so noise does not darken it.
261
+ // Stored as ( log × share, share ), share the fraction of its pixels counted: 1 unless a solid backdrop is left out.
249
262
  _buildBlurSourceKernel() {
250
263
 
251
- const input = this._inputNode, out = this._blur;
264
+ const input = this._inputNode, out = this._blurSource;
252
265
  const width = this._widthU, height = this._heightU, blurW = this._blurWU;
266
+ const sceneOnly = this._sceneOnlyU.greaterThan( int( 0 ) );
253
267
  const yw = vec4( ...Y, 0 ).xyz;
254
268
 
255
269
  return Fn( () => {
@@ -257,6 +271,7 @@ export class LocalExposure extends RenderStage {
257
271
  const lid = localId.y.mul( uint( 8 ) ).add( localId.x );
258
272
  const ox = int( workgroupId.x ).mul( int( BLUR_TEXEL ) ), oy = int( workgroupId.y ).mul( int( BLUR_TEXEL ) );
259
273
  const sum = float( 0 ).toVar();
274
+ const covered = float( 0 ).toVar();
260
275
  const count = float( 0 ).toVar();
261
276
 
262
277
  Loop( { start: int( localId.y ), end: int( BLUR_TEXEL ), type: 'int', condition: '<', update: 8, name: 'by' }, ( { by } ) => {
@@ -266,9 +281,12 @@ export class LocalExposure extends RenderStage {
266
281
  const x = ox.add( bx ), y = oy.add( by );
267
282
  If( x.lessThan( width ).and( y.lessThan( height ) ), () => {
268
283
 
269
- const lum = dot( textureLoad( input, ivec2( x, y ) ).xyz, yw ).toVar();
284
+ const texel = textureLoad( input, ivec2( x, y ) ).toVar();
285
+ const lum = dot( texel.xyz, yw ).toVar();
270
286
  const finite = floatBitsToUint( lum ).bitAnd( uint( 0x7f800000 ) ).notEqual( uint( 0x7f800000 ) );
271
- sum.addAssign( select( finite, lum.max( 0.0 ), float( 0 ) ) );
287
+ const a = select( sceneOnly.and( texel.w.lessThan( FULL_COVERAGE ) ), float( 0 ), float( 1 ) );
288
+ sum.addAssign( select( finite, lum.max( 0.0 ).mul( a ), float( 0 ) ) );
289
+ covered.addAssign( select( finite, a, float( 0 ) ) );
272
290
  count.addAssign( select( finite, float( 1 ), float( 0 ) ) );
273
291
 
274
292
  } );
@@ -278,8 +296,10 @@ export class LocalExposure extends RenderStage {
278
296
  } );
279
297
 
280
298
  const sharedSum = workgroupArray( 'float', 64 );
299
+ const sharedCovered = workgroupArray( 'float', 64 );
281
300
  const sharedCount = workgroupArray( 'float', 64 );
282
301
  sharedSum.element( lid ).assign( sum );
302
+ sharedCovered.element( lid ).assign( covered );
283
303
  sharedCount.element( lid ).assign( count );
284
304
  workgroupBarrier();
285
305
 
@@ -288,6 +308,7 @@ export class LocalExposure extends RenderStage {
288
308
  If( lid.lessThan( uint( s ) ), () => {
289
309
 
290
310
  sharedSum.element( lid ).addAssign( sharedSum.element( lid.add( uint( s ) ) ) );
311
+ sharedCovered.element( lid ).addAssign( sharedCovered.element( lid.add( uint( s ) ) ) );
291
312
  sharedCount.element( lid ).addAssign( sharedCount.element( lid.add( uint( s ) ) ) );
292
313
 
293
314
  } );
@@ -297,8 +318,10 @@ export class LocalExposure extends RenderStage {
297
318
 
298
319
  If( lid.equal( uint( 0 ) ), () => {
299
320
 
300
- const mean = sharedSum.element( uint( 0 ) ).div( max( sharedCount.element( uint( 0 ) ), 1.0 ) );
301
- out.element( uint( int( workgroupId.y ).mul( blurW ).add( int( workgroupId.x ) ) ) ).assign( log2( max( mean, DARKEST ) ).clamp( LOG_FLOOR, LOG_CEIL ) );
321
+ const mean = sharedSum.element( uint( 0 ) ).div( max( sharedCovered.element( uint( 0 ) ), 1.0 ) );
322
+ const level = log2( max( mean, DARKEST ) ).clamp( LOG_FLOOR, LOG_CEIL );
323
+ const share = select( sceneOnly, sharedCovered.element( uint( 0 ) ).div( max( sharedCount.element( uint( 0 ) ), 1.0 ) ), float( 1 ) ).toVar();
324
+ out.element( uint( int( workgroupId.y ).mul( blurW ).add( int( workgroupId.x ) ) ) ).assign( vec2( level.mul( share ), share ) );
302
325
 
303
326
  } );
304
327
 
@@ -306,7 +329,8 @@ export class LocalExposure extends RenderStage {
306
329
 
307
330
  }
308
331
 
309
- // Unreal's Gaussian, exp( -16.7 ( x / r )² ), along one axis, mirrored at the edges.
332
+ // Unreal's Gaussian, exp( -16.7 ( x / r )² ), along one axis, mirrored at the edges. Runs on ( level × share,
333
+ // share ); the second pass divides it out, so backdrop the build left out takes no part.
310
334
  _buildBlurKernel( src, dst, ax ) {
311
335
 
312
336
  const blurW = this._blurWU, blurH = this._blurHU, radius = this._radiusU;
@@ -320,7 +344,7 @@ export class LocalExposure extends RenderStage {
320
344
  const size = ax ? blurW : blurH;
321
345
  const at = ax ? x : y;
322
346
  const reach = int( radius.ceil() );
323
- const sum = float( 0 ).toVar();
347
+ const sum = vec2( 0 ).toVar();
324
348
  const total = float( 0 ).toVar();
325
349
 
326
350
  Loop( { start: reach.negate(), end: reach.add( int( 1 ) ), type: 'int', condition: '<', name: 'o' }, ( { o } ) => {
@@ -342,7 +366,8 @@ export class LocalExposure extends RenderStage {
342
366
 
343
367
  } );
344
368
 
345
- dst.element( uint( y.mul( blurW ).add( x ) ) ).assign( sum.div( total ) );
369
+ const blurred = sum.div( total ).toVar();
370
+ dst.element( uint( y.mul( blurW ).add( x ) ) ).assign( ax ? blurred : blurred.x.div( max( blurred.y, 1e-30 ) ) );
346
371
 
347
372
  } );
348
373
 
@@ -390,6 +415,7 @@ export class LocalExposure extends RenderStage {
390
415
  this._blurHU.value = blurH;
391
416
  this._radiusU.value = Math.max( 1, BLUR_PERCENT / 100 * blurW );
392
417
  this._logExposureU.value = logExposure;
418
+ this._sceneOnlyU.value = context.getState( 'pathtracer:backdrop' ) === 'color' ? 1 : 0;
393
419
  this._kernels[ 0 ].dispatchSize = [ cellsX, cellsY, 1 ];
394
420
  this._kernels[ 1 ].dispatchSize = [ blurW, blurH, 1 ];
395
421
  this._kernels[ 2 ].dispatchSize = this._kernels[ 3 ].dispatchSize = [ Math.ceil( blurW / 8 ), Math.ceil( blurH / 8 ), 1 ];