rayzee 11.7.0 → 11.8.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 (59) hide show
  1. package/README.md +13 -0
  2. package/dist/{ArchiveImporter-DYcRXeKE.js → ArchiveImporter-_sJm5uaK.js} +1923 -1243
  3. package/dist/{BidirectionalIntegrator-BvjRpbTP.js → BidirectionalIntegrator-Dzt4Z97x.js} +290 -289
  4. package/dist/{ColorManagement-oeEk7oSu.js → ColorManagement-30MfpHwQ.js} +1 -1
  5. package/dist/{EngineIssues-CIZnXHsF.js → EngineIssues-xljoXM0E.js} +18 -8
  6. package/dist/{NeuralRetouch-ClaMiKYg.js → NeuralRetouch-BKQCsmEs.js} +2 -2
  7. package/dist/{NeuralSuperRes-WNEXun9s.js → NeuralSuperRes-C4ymq68e.js} +2 -2
  8. package/dist/{PathTracer-B9wZPPVL.js → PathTracer-C_to_i40.js} +3156 -3098
  9. package/dist/{PhysicalSky-DcjItqKi.js → PhysicalSky-bMHbm_7H.js} +1 -1
  10. package/dist/{RayzeeRenderer-p-NeE6E2.js → RayzeeRenderer-B_aB6VTJ.js} +370 -371
  11. package/dist/SceneMetadata-CSmlJW4c.js +183 -0
  12. package/dist/{ToneMapGPU-sAs8wdgI.js → ToneMapGPU-4GPwoG_c.js} +13 -6
  13. package/dist/addons/archives.es.js +1 -1
  14. package/dist/addons/bidirectional.es.js +1 -1
  15. package/dist/addons/color.es.js +1 -1
  16. package/dist/addons/physical-sky.es.js +1 -1
  17. package/dist/addons/storage.es.js +1 -1
  18. package/dist/{openStorage-Fe9BoS1_.js → openStorage-B085D9FR.js} +1 -1
  19. package/dist/rayzee-core.es.js +5 -5
  20. package/dist/rayzee.es.js +815 -796
  21. package/dist/rayzee.umd.js +106 -102
  22. package/dist/{sceneCachePolicy-B9w3BnaP.js → sceneCachePolicy-D_uBSbQF.js} +2 -2
  23. package/package.json +1 -1
  24. package/src/EngineIssues.js +20 -6
  25. package/src/Passes/AIUpscaler.js +4 -0
  26. package/src/Passes/OIDNDenoiser.js +8 -6
  27. package/src/PathTracerApp.js +10 -1
  28. package/src/Processor/ArchiveImporter.js +23 -6
  29. package/src/Processor/AssetLoader.js +15 -0
  30. package/src/Processor/LightSerializer.js +6 -2
  31. package/src/Processor/PBRT/PBRTMaterials.js +2 -1
  32. package/src/Processor/PBRT/PBRTSceneBuilder.js +1 -1
  33. package/src/Processor/PlatformImageLoader.js +9 -0
  34. package/src/Processor/TextureCreator.js +44 -4
  35. package/src/Processor/ToneMapCPU.js +34 -5
  36. package/src/Processor/ToneMapGPU.js +15 -9
  37. package/src/Processor/USD/USDScene.js +981 -17
  38. package/src/RayzeeRenderer.js +81 -26
  39. package/src/Stages/AutoExposure.js +21 -8
  40. package/src/Stages/Compositor.js +39 -1
  41. package/src/Stages/LocalExposure.js +44 -18
  42. package/src/Stages/NormalDepth.js +7 -3
  43. package/src/Stages/PathTracer.js +25 -1
  44. package/src/Stages/PathTracerStage.js +9 -1
  45. package/src/TSL/CameraRay.js +12 -1
  46. package/src/TSL/DebugKernel.js +3 -2
  47. package/src/TSL/FinalWriteKernel.js +4 -7
  48. package/src/TSL/GenerateKernel.js +6 -2
  49. package/src/TSL/LightSplatKernel.js +7 -4
  50. package/src/TSL/LightsCore.js +6 -2
  51. package/src/TSL/LightsSampling.js +45 -12
  52. package/src/TSL/SceneResources.js +11 -0
  53. package/src/TSL/ShadeKernel.js +1 -2
  54. package/src/integrators/BidirectionalIntegrator.js +1 -0
  55. package/src/managers/CameraManager.js +9 -0
  56. package/src/managers/DenoisingManager.js +1 -0
  57. package/src/managers/InteractionManager.js +6 -0
  58. package/src/managers/UniformManager.js +3 -1
  59. 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 = {};
@@ -2724,7 +2735,8 @@ export class RayzeeRenderer extends EventDispatcher {
2724
2735
  * @param {string} [options.colorSpace='srgb'] - `'srgb'` for display bytes through the active
2725
2736
  * view transform, `'linear'` for the raw working-space accumulation, or the name of a colour
2726
2737
  * space in the loaded OCIO config for a delivery buffer (float, e.g. `'ACES2065-1'`)
2727
- * @param {boolean} [options.preserveAlpha=false] - srgb only
2738
+ * @param {boolean} [options.preserveAlpha=false] - srgb only: coverage as alpha while the background is
2739
+ * transparent, else 255. Float readbacks carry coverage then and 1 otherwise, as the canvas shows it.
2728
2740
  * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2729
2741
  * viewport is showing — denoised when a denoiser has run — instead of the raw
2730
2742
  * accumulation
@@ -2750,6 +2762,8 @@ export class RayzeeRenderer extends EventDispatcher {
2750
2762
 
2751
2763
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
2752
2764
  const { width, height } = stage;
2765
+ // The accumulation's alpha is always coverage; an opaque backdrop reads back opaque, as the Compositor draws it.
2766
+ const transparent = !! this.settings.get( 'transparentBackground' );
2753
2767
 
2754
2768
  if ( colorSpace === 'srgb' ) {
2755
2769
 
@@ -2758,15 +2772,16 @@ export class RayzeeRenderer extends EventDispatcher {
2758
2772
  exposure: this.renderer.toneMappingExposure,
2759
2773
  toneMapping: this.renderer.toneMapping,
2760
2774
  saturation: this.settings.get( 'saturation' ) ?? 1,
2761
- preserveAlpha,
2775
+ preserveAlpha: preserveAlpha && transparent,
2762
2776
  gain: this._displayGain(),
2763
2777
  };
2764
2778
 
2765
2779
  const gpu = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone );
2766
- const fn = ! gpu && tone.gain ? await tone.gain.cpu() : null;
2780
+ const fn = ! gpu && tone.gain?.cpu ? await tone.gain.cpu() : null;
2781
+ const backdrop = tone.gain?.backdrop ?? 0;
2767
2782
  const data = gpu ?? toneMapToRGBA8( shown
2768
2783
  ? await this._readTexture( shown.texture, width, height )
2769
- : await readPixels( this.renderer, target, width, height ), { ...tone, pixelGain: fn && { fn, width, height } } );
2784
+ : await readPixels( this.renderer, target, width, height ), { ...tone, pixelGain: ( fn || backdrop ) ? { fn, width, height, backdrop } : null } );
2770
2785
  return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
2771
2786
 
2772
2787
  }
@@ -2774,6 +2789,12 @@ export class RayzeeRenderer extends EventDispatcher {
2774
2789
  const { data: linear, source: read } = source === 'display'
2775
2790
  ? await this._readDisplaySource( target, width, height )
2776
2791
  : { data: await readPixels( this.renderer, target, width, height ), source: 'accumulation' };
2792
+ if ( ! transparent ) {
2793
+
2794
+ const one = linear instanceof Uint16Array ? 0x3C00 : 1; // a half-float target reads back as its bits
2795
+ for ( let i = 3; i < linear.length; i += 4 ) linear[ i ] = one;
2796
+
2797
+ }
2777
2798
 
2778
2799
  // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2779
2800
  // answer to "linear in what primaries", which only means something once a config is loaded.
@@ -3654,14 +3675,25 @@ export class RayzeeRenderer extends EventDispatcher {
3654
3675
 
3655
3676
  if ( typeof navigator === 'undefined' || ! navigator.gpu ) {
3656
3677
 
3657
- throw new Error( 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3678
+ this._issues.fail( ISSUE_CODES.WEBGPU_UNAVAILABLE, 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3679
+
3680
+ }
3681
+
3682
+ let adapter = null;
3683
+ let adapterError;
3684
+ try {
3685
+
3686
+ adapter = await navigator.gpu.requestAdapter( { powerPreference: 'high-performance' } );
3687
+
3688
+ } catch ( error ) {
3689
+
3690
+ adapterError = error;
3658
3691
 
3659
3692
  }
3660
3693
 
3661
- const adapter = await navigator.gpu.requestAdapter( { powerPreference: 'high-performance' } );
3662
3694
  if ( ! adapter ) {
3663
3695
 
3664
- throw new Error( 'Failed to get WebGPU adapter' );
3696
+ this._issues.fail( ISSUE_CODES.ADAPTER_UNAVAILABLE, `no WebGPU adapter${adapterError ? ': ' + adapterError.message : ''}`, {}, adapterError );
3665
3697
 
3666
3698
  }
3667
3699
 
@@ -3711,22 +3743,43 @@ export class RayzeeRenderer extends EventDispatcher {
3711
3743
  // buys nothing, and 10 is already this adapter's ceiling.
3712
3744
  requiredLimits.maxStorageBuffersPerShaderStage = Math.min( adapterLimits.maxStorageBuffersPerShaderStage, 10 );
3713
3745
 
3714
- this.renderer = new WebGPURenderer( {
3715
- canvas: this.canvas,
3716
- alpha: true,
3717
- powerPreference: 'high-performance',
3718
- requiredLimits,
3719
- } );
3746
+ // Requested here rather than by three.js, so a failure is typed where it happens.
3747
+ let device;
3748
+ try {
3749
+
3750
+ device = await adapter.requestDevice( {
3751
+ requiredFeatures: THREE_DEVICE_FEATURES.filter( ( name ) => adapter.features.has( name ) ),
3752
+ requiredLimits,
3753
+ } );
3720
3754
 
3721
- await initWithoutFrameLoop( this.renderer, this._headless );
3755
+ } catch ( error ) {
3756
+
3757
+ this._issues.fail( ISSUE_CODES.DEVICE_UNAVAILABLE, `WebGPU device request failed: ${error.message}`, { adapter: { ...this.adapterInfo } }, error );
3758
+
3759
+ }
3760
+
3761
+ this.renderer = new WebGPURenderer( { canvas: this.canvas, alpha: true, device } );
3762
+
3763
+ try {
3764
+
3765
+ await initWithoutFrameLoop( this.renderer, this._headless );
3766
+
3767
+ } catch ( error ) {
3768
+
3769
+ device.destroy();
3770
+ throw error;
3771
+
3772
+ }
3722
3773
 
3723
3774
  // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
3724
3775
  // compute-only, so every frame would fail against an empty canvas.
3725
3776
  if ( ! this.renderer.backend?.isWebGPUBackend ) {
3726
3777
 
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.'
3778
+ device.destroy();
3779
+ this._issues.fail(
3780
+ ISSUE_CODES.WEBGPU_UNAVAILABLE,
3781
+ 'three.js fell back to WebGL2, which cannot run the path tracer\'s compute kernels. ' +
3782
+ 'Check GPU process flags and driver support.'
3730
3783
  );
3731
3784
 
3732
3785
  }
@@ -3743,13 +3796,12 @@ export class RayzeeRenderer extends EventDispatcher {
3743
3796
  // BVH and CPU-side texture data per create/dispose cycle. `lost` is a pending promise
3744
3797
  // whose reaction cannot be unregistered, so it reads the app out of a holder that
3745
3798
  // dispose() empties; `onuncapturederror` is cleared there directly.
3746
- const gpuDevice = this.renderer.backend?.device;
3747
- if ( gpuDevice?.lost ) {
3799
+ this._gpuDevice = device;
3800
+ if ( device.lost ) {
3748
3801
 
3749
- this._gpuDevice = gpuDevice;
3750
3802
  this._deviceLostHolder = { app: this };
3751
- attachDeviceLostHandler( gpuDevice, this._deviceLostHolder );
3752
- gpuDevice.onuncapturederror = ( event ) => log.error( 'WebGPU uncaptured error:', event.error );
3803
+ attachDeviceLostHandler( device, this._deviceLostHolder );
3804
+ device.onuncapturederror = ( event ) => log.error( 'WebGPU uncaptured error:', event.error );
3753
3805
 
3754
3806
  }
3755
3807
 
@@ -4072,7 +4124,10 @@ export class RayzeeRenderer extends EventDispatcher {
4072
4124
 
4073
4125
  }
4074
4126
 
4075
- /** A per-pixel gain the shown picture carries before the view (the compositor's), for tone-mapped readbacks; or null. */
4127
+ /**
4128
+ * A per-pixel gain the shown picture carries before the view (the compositor's), for tone-mapped readbacks; or null.
4129
+ * Its `backdrop`, when set, is the gain a solid backdrop is held at (`PackedToneMapper`'s `gain`).
4130
+ */
4076
4131
  _displayGain() {
4077
4132
 
4078
4133
  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 );
@@ -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 ];
@@ -7,7 +7,7 @@ import { MAX_STORAGE_TEXTURE_SIZE } from '../Processor/StorageTexturePool.js';
7
7
  import { GBUFFER_MISS_DEPTH } from './DenoiserSettings.js';
8
8
  import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
9
9
  import { traverseBVH } from '../TSL/BVHTraversal.js';
10
- import { cameraRayOf, cameraRayUniforms } from '../TSL/CameraRay.js';
10
+ import { cameraRayOf, cameraRayUniforms, cameraClipDistance } from '../TSL/CameraRay.js';
11
11
  import { getMaterial } from '../TSL/Common.js';
12
12
  import { computeUVCache, processNormal, processBump, processMetalnessRoughness, triangleUVTangent, buildBucketTextureNodes, refreshBucketTextureNodes } from '../TSL/TextureSampling.js';
13
13
  import { withSceneResources } from '../TSL/SceneResources.js';
@@ -233,14 +233,18 @@ export class NormalDepth extends RenderStage {
233
233
  // No jitter — deterministic per-pixel ray so the temporal gate
234
234
  // sees stable per-pixel normals across frames.
235
235
  const uv = vec2( float( gx ).add( 0.5 ).div( resW ), float( gy ).add( 0.5 ).div( resH ) );
236
- const ray = Ray( cameraRayOf( uv, camWorldMat, camProjInvMat, this.cameraRay ) );
236
+ const primary = cameraRayOf( uv, camWorldMat, camProjInvMat, this.cameraRay );
237
+ const direction = primary.direction.toVar();
238
+ // From the camera's clip plane, as the path tracer's rays start; the depth stays measured from the camera.
239
+ const clipAlong = cameraClipDistance( direction, this.cameraRay.clipStart, camWorldMat, this.cameraRay.cameraProjection ).toVar();
240
+ const ray = Ray( { origin: primary.origin.add( direction.mul( clipAlong ) ), direction } );
237
241
  const hit = HitInfo.wrap( traverseBVH( ray, bvhStorage, triStorage ) );
238
242
 
239
243
  const encodedNormal = hit.normal.mul( 0.5 ).add( 0.5 );
240
244
  // Clamp to the max finite HalfFloat: a hit farther than 65504 units would otherwise
241
245
  // round to +Inf in the f16 texture (on backends that don't clamp), reintroducing the
242
246
  // Inf-Inf=NaN in the denoiser depth weights that the finite miss-sentinel avoids.
243
- const depth = min( hit.dst, float( 65504.0 ) );
247
+ const depth = min( hit.dst.add( clipAlong ), float( 65504.0 ) );
244
248
 
245
249
  const result = hit.didHit.select(
246
250
  vec4( encodedNormal, depth ),
@@ -27,6 +27,7 @@ import {
27
27
  SORT_GLOBAL_WG_SIZE, SORT_GLOBAL_MAX_BINS,
28
28
  } from '../TSL/SortGlobalKernels.js';
29
29
  import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
30
+ import { LIGHT_FLOATS } from '../managers/UniformManager.js';
30
31
  import { MAX_STORAGE_TEXTURE_SIZE } from '../Processor/StorageTexturePool.js';
31
32
  import { readPixels } from '../Processor/TextureReadback.js';
32
33
  import { ISSUE_CODES } from '../EngineIssues.js';
@@ -380,6 +381,26 @@ export class PathTracer extends PathTracerStage {
380
381
 
381
382
  }
382
383
 
384
+ /**
385
+ * Whether some point or spot light has a radius: only then is disc sampling compiled in, and never for an integrator
386
+ * of its own (a bidirectional light path leaves a light's centre).
387
+ */
388
+ _lightRadiusNeeded() {
389
+
390
+ if ( this._integrator ) return false;
391
+ const sized = ( type, count, slot ) => {
392
+
393
+ if ( ! count ) return false;
394
+ const data = this[ `${type}LightsBufferNode` ].array;
395
+ for ( let i = 0; i < count; i ++ ) if ( data[ i * LIGHT_FLOATS[ type ] + slot ] > 0 ) return true;
396
+ return false;
397
+
398
+ };
399
+
400
+ return sized( 'point', this.numPointLights.value, 9 ) || sized( 'spot', this.numSpotLights.value, 18 );
401
+
402
+ }
403
+
383
404
  get integrator() {
384
405
 
385
406
  return this._integrator?.name ?? 'path';
@@ -410,6 +431,7 @@ export class PathTracer extends PathTracerStage {
410
431
  }
411
432
 
412
433
  if ( PICKED_SHADOW_RAYS.includes( this._shadowRays ) && this._pickedShadowRays() !== this._compiledPick ) this._outputsChanged = true;
434
+ if ( this._lightRadiusNeeded() !== this._compiledLightRadius ) this._outputsChanged = true;
413
435
 
414
436
  if ( this._lightBufferRealloc || this._outputsChanged ) {
415
437
 
@@ -1636,6 +1658,7 @@ export class PathTracer extends PathTracerStage {
1636
1658
  anamorphicRatio: this.anamorphicRatio,
1637
1659
  dofMode: this.dofMode,
1638
1660
  dofBlur: this.dofBlur,
1661
+ cameraClipStart: this.cameraClipStart,
1639
1662
  renderWidth: this._wfRenderWidth,
1640
1663
  renderHeight: this._wfRenderHeight,
1641
1664
  chunkRowBase: this._wfChunkRowBase,
@@ -1851,6 +1874,7 @@ export class PathTracer extends PathTracerStage {
1851
1874
  iesProfiles: texNodes.iesProfilesTex,
1852
1875
  alphaShadows: this.uniforms.get( 'enableAlphaShadows' ),
1853
1876
  materialLayers: this._compiledLayers = this._layersToCompile(),
1877
+ lightRadius: this._compiledLightRadius = this._lightRadiusNeeded(),
1854
1878
  };
1855
1879
  const own = ( call ) => withSceneResources( call, resources );
1856
1880
  const freshEnvTex = _env.environmentTexture ? texture( _env.environmentTexture ) : texNodes.envTex;
@@ -2089,7 +2113,6 @@ export class PathTracer extends PathTracerStage {
2089
2113
  hasPreviousAux: this.hasPreviousAux,
2090
2114
  auxAccumulationAlpha: this.auxAccumulationAlpha,
2091
2115
  cameraIsMoving: this.cameraIsMoving,
2092
- transparentBackground: this.transparentBackground,
2093
2116
  prevAccumTexture: prevColor,
2094
2117
  prevAlbedoTexture: prevAlbedo,
2095
2118
  prevNormalDepthTexture: prevNormalDepth,
@@ -2146,6 +2169,7 @@ export class PathTracer extends PathTracerStage {
2146
2169
  anamorphicRatio: this.anamorphicRatio,
2147
2170
  dofMode: this.dofMode,
2148
2171
  dofBlur: this.dofBlur,
2172
+ cameraClipStart: this.cameraClipStart,
2149
2173
  bvhBuffer: freshBvh,
2150
2174
  triangleBuffer: freshTri,
2151
2175
  materialBuffer: freshMat,