rayzee 7.21.0 → 7.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.21.0",
3
+ "version": "7.22.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -62,6 +62,12 @@ export const ENGINE_DEFAULTS = {
62
62
  skyMieAnisotropy: 0.76,
63
63
  skyPreset: 'clearMorning',
64
64
 
65
+ // Camera projection — 'perspective' | 'equirectangular'. Panorama ranges are UI-facing degrees.
66
+ cameraProjection: 'perspective',
67
+ panoramaLonRange: [ - 180, 180 ],
68
+ panoramaLatRange: [ - 90, 90 ],
69
+ panoramaLevelHorizon: true,
70
+
65
71
  enableDOF: false,
66
72
  fov: 55,
67
73
  focusDistance: 0.8,
@@ -1722,6 +1722,80 @@ export class PathTracerApp extends EventDispatcher {
1722
1722
 
1723
1723
  }
1724
1724
 
1725
+ /**
1726
+ * Per-kernel GPU milliseconds for the last resolved frame.
1727
+ *
1728
+ * {@link PathTracerApp#getGPUTimings} only reports the frame aggregate, which buries a change to
1729
+ * one kernel under everything else. The backend's timestamp query pool already retains one
1730
+ * duration per compute pass, keyed `c:<frameCalls>:<nodeId>:f<frame>` (three.js
1731
+ * `WebGPUTimestampQueryPool.resolveQueriesAsync` → `timestamps`); this attributes those back to
1732
+ * kernel names through `KernelManager`'s registry.
1733
+ *
1734
+ * Durations are SUMMED per kernel across the frame, so `extend` reports its whole per-frame cost
1735
+ * over every bounce iteration rather than one bounce. `unattributed` collects passes belonging to
1736
+ * no registered kernel (other stages, denoisers) so `sum(kernels) + unattributed` reconciles with
1737
+ * `total` — a gap there means a pass was missed, not that a kernel is free.
1738
+ *
1739
+ * The pool's map accumulates across frames and is never pruned, hence the newest-frame filter.
1740
+ *
1741
+ * Requires {@link PathTracerApp#enableGPUTiming}. Returns null when timestamps are unavailable.
1742
+ *
1743
+ * @returns {Promise<{kernels: Object<string, number>, total: number, unattributed: number, frame: number}|null>}
1744
+ */
1745
+ async getKernelGPUTimings() {
1746
+
1747
+ const renderer = this.renderer;
1748
+ if ( ! renderer?.backend?.trackTimestamp ) return null;
1749
+
1750
+ await renderer.resolveTimestampsAsync( 'compute' );
1751
+
1752
+ const timestamps = renderer.backend.timestampQueryPool?.compute?.timestamps;
1753
+ if ( ! timestamps || timestamps.size === 0 ) return null;
1754
+
1755
+ const nameByNodeId = new Map();
1756
+ const kernelMap = this.stages?.pathTracer?._kernelManager?.kernels;
1757
+ if ( kernelMap ) {
1758
+
1759
+ for ( const [ name, node ] of kernelMap ) nameByNodeId.set( node.id, name );
1760
+
1761
+ }
1762
+
1763
+ const parsed = [];
1764
+ let frame = - 1;
1765
+
1766
+ for ( const [ uid, ms ] of timestamps ) {
1767
+
1768
+ // 'c:<frameCalls>:<nodeId>:f<frame>'
1769
+ const parts = uid.split( ':' );
1770
+ if ( parts.length !== 4 ) continue;
1771
+
1772
+ const f = Number( parts[ 3 ].slice( 1 ) );
1773
+ if ( ! Number.isFinite( f ) ) continue;
1774
+
1775
+ parsed.push( { nodeId: Number( parts[ 2 ] ), f, ms } );
1776
+ if ( f > frame ) frame = f;
1777
+
1778
+ }
1779
+
1780
+ const kernels = {};
1781
+ let total = 0;
1782
+ let unattributed = 0;
1783
+
1784
+ for ( const entry of parsed ) {
1785
+
1786
+ if ( entry.f !== frame ) continue;
1787
+
1788
+ total += entry.ms;
1789
+ const name = nameByNodeId.get( entry.nodeId );
1790
+ if ( name === undefined ) unattributed += entry.ms;
1791
+ else kernels[ name ] = ( kernels[ name ] ?? 0 ) + entry.ms;
1792
+
1793
+ }
1794
+
1795
+ return { kernels, total, unattributed, frame };
1796
+
1797
+ }
1798
+
1725
1799
  // ═══════════════════════════════════════════════════════════════
1726
1800
  // Output (absorbed from OutputAPI)
1727
1801
  // ═══════════════════════════════════════════════════════════════
@@ -2298,6 +2372,8 @@ export class PathTracerApp extends EventDispatcher {
2298
2372
  renderer: this.renderer,
2299
2373
  resetCallback: () => this.reset(),
2300
2374
  reconcileCompletion: () => this._reconcileCompletion(),
2375
+ denoisingManager: this.denoisingManager,
2376
+ cameraManager: this.cameraManager,
2301
2377
  } );
2302
2378
 
2303
2379
  this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
@@ -96,13 +96,12 @@ export class BVHBuilder {
96
96
  reinsertionIterations: 0
97
97
  };
98
98
 
99
- // Treelet optimization configuration
100
- this.enableTreeletOptimization = true;
99
+ // Off: repairs LBVH trees, so on this binned-SAH build it buys 0.005% tree SAH for 72%
100
+ // of build time.
101
+ this.enableTreeletOptimization = false;
101
102
  this.treeletSize = 5;
102
103
  this.treeletOptimizationPasses = 1;
103
104
  this.treeletMinImprovement = 0.02;
104
- this.maxTreeletDepth = 3;
105
- this.maxTreeletsPerScene = 20;
106
105
  this.treeletComplexityThreshold = 50000;
107
106
 
108
107
  // Reinsertion optimization configuration
@@ -191,6 +190,7 @@ export class BVHBuilder {
191
190
  if ( config.size !== undefined ) this.treeletSize = Math.max( 3, Math.min( 12, config.size ) );
192
191
  if ( config.passes !== undefined ) this.treeletOptimizationPasses = Math.max( 1, Math.min( 3, config.passes ) );
193
192
  if ( config.minImprovement !== undefined ) this.treeletMinImprovement = Math.max( 0.001, config.minImprovement );
193
+ if ( config.complexityThreshold !== undefined ) this.treeletComplexityThreshold = config.complexityThreshold;
194
194
 
195
195
  }
196
196
 
@@ -595,12 +595,10 @@ export class BVHBuilder {
595
595
 
596
596
  const isLargeScene = this.totalTriangles > this.treeletComplexityThreshold;
597
597
  const adaptiveTreeletSize = isLargeScene ? 3 : this.treeletSize;
598
- const adaptiveMaxTreelets = isLargeScene ? 10 : this.maxTreeletsPerScene;
599
598
 
600
599
  const optimizer = new TreeletOptimizer( this.traversalCost, this.intersectionCost );
601
600
  optimizer.setTreeletSize( adaptiveTreeletSize );
602
601
  optimizer.setMinImprovement( this.treeletMinImprovement );
603
- optimizer.setMaxTreelets( adaptiveMaxTreelets );
604
602
 
605
603
  const optimizationStartTime = performance.now();
606
604
 
@@ -4,25 +4,14 @@
4
4
  * Builds, caches, and dispatches individual compute nodes for the wavefront
5
5
  * path tracing pipeline. Each kernel is a separate `Fn().compute()` node.
6
6
  *
7
- * Manages workgroup sizes, dispatch dimensions, and provides a unified
8
- * dispatch interface that wraps `renderer.compute(node)`.
7
+ * Dispatch grids are derived from each node's own `workgroupSize`, so the divisor can never
8
+ * drift from the size the kernel was compiled with — see `setDispatchForCount`.
9
9
  */
10
10
 
11
11
  import { createLogger, fmt } from '../utils/Logger.js';
12
12
 
13
13
  const log = createLogger( 'wavefront' );
14
14
 
15
- /** Default workgroup sizes per kernel type */
16
- const WORKGROUP_SIZES = {
17
- generate: [ 16, 16, 1 ], // 2D screen-space
18
- extend: [ 256, 1, 1 ], // 1D ray-parallel
19
- shade: [ 256, 1, 1 ], // 1D ray-parallel (material-sorted when sort enabled)
20
- connect: [ 256, 1, 1 ], // 1D shadow-ray-parallel
21
- accumulate: [ 256, 1, 1 ], // 1D shadow-ray-parallel
22
- compact: [ 256, 1, 1 ], // 1D ray-parallel
23
- finalWrite: [ 16, 16, 1 ], // 2D screen-space
24
- };
25
-
26
15
  export class KernelManager {
27
16
 
28
17
  /**
@@ -41,12 +30,6 @@ export class KernelManager {
41
30
  */
42
31
  this.kernels = new Map();
43
32
 
44
- /**
45
- * Map of kernel name → workgroup size [x, y, z].
46
- * @type {Map<string, number[]>}
47
- */
48
- this.workgroupSizes = new Map();
49
-
50
33
  /**
51
34
  * Timing data for performance profiling.
52
35
  * @type {Map<string, {compiledOnce: boolean, lastDispatchMs: number}>}
@@ -74,13 +57,6 @@ export class KernelManager {
74
57
  this._firstDispatches = [];
75
58
  this._firstDispatchFlush = null;
76
59
 
77
- // Initialize workgroup sizes from defaults
78
- for ( const [ name, wgSize ] of Object.entries( WORKGROUP_SIZES ) ) {
79
-
80
- this.workgroupSizes.set( name, wgSize );
81
-
82
- }
83
-
84
60
  }
85
61
 
86
62
  /**
@@ -177,61 +153,32 @@ export class KernelManager {
177
153
  }
178
154
 
179
155
  /**
180
- * Update dispatch dimensions for a kernel.
156
+ * Size a 1D kernel's grid to cover `count` items, deriving the divisor from the workgroup size the
157
+ * kernel was actually registered with. Extend/Shade bound on ENTERING_COUNT rather than on the grid,
158
+ * so an under-sized grid silently drops the tail of the active list — never hardcode the divisor.
181
159
  * @param {string} name - Kernel name
182
- * @param {number[]} count - Dispatch dimensions [x, y, z]
160
+ * @param {number} count - Number of items to cover
183
161
  */
184
- setDispatchCount( name, count ) {
162
+ setDispatchForCount( name, count ) {
185
163
 
186
164
  const node = this.kernels.get( name );
187
165
  if ( ! node ) return;
188
- node.dispatchSize = count;
189
-
190
- }
191
-
192
- /**
193
- * Calculate 2D dispatch dimensions for a screen-space kernel.
194
- * @param {number} width - Render width in pixels
195
- * @param {number} height - Render height in pixels
196
- * @param {string} kernelName - Kernel name for WG size lookup
197
- * @returns {number[]} [dispatchX, dispatchY, 1]
198
- */
199
- calcScreenDispatch( width, height, kernelName ) {
200
-
201
- const wg = this.workgroupSizes.get( kernelName ) || [ 16, 16, 1 ];
202
- return [
203
- Math.ceil( width / wg[ 0 ] ),
204
- Math.ceil( height / wg[ 1 ] ),
205
- 1
206
- ];
207
-
208
- }
209
-
210
- /**
211
- * Calculate 1D dispatch dimensions for a ray-parallel kernel.
212
- * @param {number} rayCount - Number of rays to process
213
- * @param {string} kernelName - Kernel name for WG size lookup
214
- * @returns {number[]} [dispatchX, 1, 1]
215
- */
216
- calcRayDispatch( rayCount, kernelName ) {
217
-
218
- const wg = this.workgroupSizes.get( kernelName ) || [ 256, 1, 1 ];
219
- return [
220
- Math.ceil( rayCount / wg[ 0 ] ),
221
- 1,
222
- 1
223
- ];
166
+ node.dispatchSize = [ Math.ceil( count / node.workgroupSize[ 0 ] ), 1, 1 ];
224
167
 
225
168
  }
226
169
 
227
170
  /**
228
- * Get the workgroup size for a kernel.
229
- * @param {string} name
230
- * @returns {number[]}
171
+ * Size a 2D screen-space kernel's grid, deriving both divisors from the registered workgroup size.
172
+ * @param {string} name - Kernel name
173
+ * @param {number} width - Width in pixels
174
+ * @param {number} height - Height in pixels
231
175
  */
232
- getWorkgroupSize( name ) {
176
+ setDispatchForGrid( name, width, height ) {
233
177
 
234
- return this.workgroupSizes.get( name ) || [ 256, 1, 1 ];
178
+ const node = this.kernels.get( name );
179
+ if ( ! node ) return;
180
+ const wg = node.workgroupSize;
181
+ node.dispatchSize = [ Math.ceil( width / wg[ 0 ] ), Math.ceil( height / wg[ 1 ] ), 1 ];
235
182
 
236
183
  }
237
184
 
@@ -17,9 +17,10 @@ export const COUNTER = {
17
17
  // per-frame count of pixels whose Tier-1 relative-error dropped below threshold; zeroed at frame start by
18
18
  // initActiveIndices, incremented in FinalWrite, read back async to drive the whole-frame convergence early-stop.
19
19
  CONVERGED_COUNT: 2,
20
- // Tier-2 per-pixel freeze: FROZEN_COUNT = pixels skipped this frame; ACTIVE_PIXEL_COUNT = bounce-0 active
21
- // count (maxRays − frozen), read back to size next frame's grid.
22
- FROZEN_COUNT: 3,
20
+ // Reserved hole — was FROZEN_COUNT, a write-only counter no reader ever consumed. Kept as a hole so
21
+ // slot indices stay stable across the JS enum and every kernel that stores by index.
22
+ RESERVED_3: 3,
23
+ // Tier-2 per-pixel freeze: bounce-0 active count (maxRays − frozen), read back to size next frame's grid.
23
24
  ACTIVE_PIXEL_COUNT: 4,
24
25
  COUNT: 5,
25
26
  };
@@ -48,9 +48,13 @@ export class SceneProcessor {
48
48
  enableTextureCache: true,
49
49
  maxConcurrentTextureTasks: Math.min( navigator.hardwareConcurrency || 4, 6 ),
50
50
  // Treelet optimization configuration
51
+ // Keep: `_buildBVH` sends `enabled: value !== false`, so undefined re-enables treelets.
52
+ enableTreeletOptimization: false,
51
53
  treeletSize: 7, // 7 nodes gives 315 topologies for optimal enumeration
52
54
  treeletOptimizationPasses: 1,
53
55
  treeletMinImprovement: 0.01, // Minimum SAH improvement threshold
56
+ // Above this triangle count the builder drops treelets to size 3.
57
+ treeletComplexityThreshold: 50000,
54
58
  ...options
55
59
  };
56
60
 
@@ -151,7 +155,8 @@ export class SceneProcessor {
151
155
  enabled: this.config.enableTreeletOptimization,
152
156
  size: this.config.treeletSize,
153
157
  passes: this.config.treeletOptimizationPasses,
154
- minImprovement: this.config.treeletMinImprovement
158
+ minImprovement: this.config.treeletMinImprovement,
159
+ complexityThreshold: this.config.treeletComplexityThreshold
155
160
  } );
156
161
 
157
162
  // Create and configure texture creator
@@ -436,7 +441,8 @@ export class SceneProcessor {
436
441
  enabled: originalTreeletEnabled !== false,
437
442
  size: this.config.treeletSize,
438
443
  passes: this.config.treeletOptimizationPasses,
439
- minImprovement: this.config.treeletMinImprovement
444
+ minImprovement: this.config.treeletMinImprovement,
445
+ complexityThreshold: this.config.treeletComplexityThreshold
440
446
  },
441
447
  reinsertionOptimization: {
442
448
  enabled: this.bvhBuilder.enableReinsertionOptimization,
@@ -1267,7 +1273,8 @@ export class SceneProcessor {
1267
1273
  enabled: this.config.enableTreeletOptimization,
1268
1274
  size: this.config.treeletSize,
1269
1275
  passes: this.config.treeletOptimizationPasses,
1270
- minImprovement: this.config.treeletMinImprovement
1276
+ minImprovement: this.config.treeletMinImprovement,
1277
+ complexityThreshold: this.config.treeletComplexityThreshold
1271
1278
  } );
1272
1279
 
1273
1280
  }
@@ -1953,7 +1960,8 @@ export class SceneProcessor {
1953
1960
  enabled: treeletEnabled,
1954
1961
  size: this.config.treeletSize,
1955
1962
  passes: this.config.treeletOptimizationPasses,
1956
- minImprovement: this.config.treeletMinImprovement
1963
+ minImprovement: this.config.treeletMinImprovement,
1964
+ complexityThreshold: this.config.treeletComplexityThreshold
1957
1965
  },
1958
1966
  reinsertionOptimization: {
1959
1967
  enabled: this.bvhBuilder.enableReinsertionOptimization,
@@ -545,13 +545,6 @@ export class TreeletOptimizer {
545
545
 
546
546
  }
547
547
 
548
- setMaxTreelets() {
549
-
550
- // No-op — we process all valid treelets now.
551
- // Kept for API compatibility with BVHBuilder.
552
-
553
- }
554
-
555
548
  getStatistics() {
556
549
 
557
550
  return { ...this.stats };
@@ -1,4 +1,4 @@
1
- import { EventDispatcher, Color } from 'three';
1
+ import { EventDispatcher, Color, Vector2, MathUtils } from 'three';
2
2
  import { ENGINE_DEFAULTS } from './EngineDefaults.js';
3
3
  import { EngineEvents } from './EngineEvents.js';
4
4
 
@@ -32,6 +32,7 @@ const SETTING_ROUTES = {
32
32
  enableGroundCatcher: { uniform: 'enableGroundCatcher', reset: true },
33
33
  groundCatcherHeight: { uniform: 'groundCatcherHeight', reset: true },
34
34
  globalIlluminationIntensity: { uniform: 'globalIlluminationIntensity', reset: true },
35
+ panoramaLevelHorizon: { uniform: 'panoLevelHorizon', reset: true },
35
36
  enableDOF: { uniform: 'enableDOF', reset: true },
36
37
  focusDistance: { uniform: 'focusDistance', reset: false },
37
38
  focalLength: { uniform: 'focalLength', reset: true },
@@ -56,6 +57,9 @@ const SETTING_ROUTES = {
56
57
 
57
58
  // ── Multi-stage / special handling ────────────────────────────
58
59
 
60
+ cameraProjection: { handler: 'handleCameraProjection', reset: true },
61
+ panoramaLonRange: { handler: 'handlePanoramaLonRange', reset: true },
62
+ panoramaLatRange: { handler: 'handlePanoramaLatRange', reset: true },
59
63
  interactionModeEnabled: { handler: 'handleInteractionModeEnabled', reset: false },
60
64
  maxSamples: { handler: 'handleMaxSamples', reset: false },
61
65
  transparentBackground: { handler: 'handleTransparentBackground' },
@@ -122,13 +126,15 @@ export class RenderSettings extends EventDispatcher {
122
126
  * @param {Object} params.stages - Pipeline stages { pathTracer, compositor, autoExposure, ... }
123
127
  * @param {Function} params.resetCallback - Called to reset accumulation
124
128
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
129
+ * @param {Object} [params.denoisingManager] - Needed to force ASVGF off under panorama
130
+ * @param {Object} [params.cameraManager] - Needed to force auto-focus manual under panorama
125
131
  */
126
- bind( { stages, renderer, resetCallback, reconcileCompletion } ) {
132
+ bind( params ) {
127
133
 
128
- this._pathTracer = stages.pathTracer;
129
- this._resetCallback = resetCallback;
134
+ this._pathTracer = params.stages.pathTracer;
135
+ this._resetCallback = params.resetCallback;
130
136
  this._delegates = {};
131
- this._handlers = this._buildHandlers( stages, renderer, reconcileCompletion );
137
+ this._handlers = this._buildHandlers( params );
132
138
 
133
139
  }
134
140
 
@@ -136,10 +142,39 @@ export class RenderSettings extends EventDispatcher {
136
142
  * Builds handler functions for multi-stage settings that can't
137
143
  * be routed with a simple uniform forward.
138
144
  */
139
- _buildHandlers( stages, renderer, reconcileCompletion ) {
145
+ _buildHandlers( { stages, renderer, reconcileCompletion, denoisingManager, cameraManager } ) {
146
+
147
+ // UniformManager copies into the existing node, so one scratch vector serves every write.
148
+ const panoScratch = new Vector2();
149
+ const setPanoRange = ( uniform, value ) => {
150
+
151
+ panoScratch.set( value?.[ 0 ] ?? 0, value?.[ 1 ] ?? 0 ).multiplyScalar( MathUtils.DEG2RAD );
152
+ stages.pathTracer?.setUniform( uniform, panoScratch );
153
+
154
+ };
140
155
 
141
156
  return {
142
157
 
158
+ handleCameraProjection: ( value ) => {
159
+
160
+ const isPanorama = value === 'equirectangular';
161
+ stages.pathTracer?.setUniform( 'cameraProjection', isPanorama ? 1 : 0 );
162
+
163
+ if ( ! isPanorama ) return;
164
+
165
+ // ASVGF is driven entirely by MotionVector, which unprojects through
166
+ // projectionMatrixInverse — meaningless once every pixel is its own direction.
167
+ // Fall back to the spatial-only denoiser rather than leaving no strategy.
168
+ if ( denoisingManager?.denoiserStrategy === 'asvgf' ) denoisingManager.setDenoiserStrategy( 'edgeaware' );
169
+ // Auto-focus raycasts via Raycaster.setFromCamera, which only knows the frustum.
170
+ cameraManager?.setAutoFocusMode( 'manual' );
171
+
172
+ },
173
+
174
+ handlePanoramaLonRange: ( value ) => setPanoRange( 'panoLonRange', value ),
175
+
176
+ handlePanoramaLatRange: ( value ) => setPanoRange( 'panoLatRange', value ),
177
+
143
178
  handleTransparentBackground: ( value ) => {
144
179
 
145
180
  stages.pathTracer?.setUniform( 'transparentBackground', value );
@@ -1,4 +1,4 @@
1
- import { Fn, vec3, vec4, float, int, uint, uvec2, uniform, normalize, mat3, min, storage, If,
1
+ import { Fn, vec2, vec3, vec4, float, int, uint, uvec2, uniform, min, storage, If,
2
2
  textureStore, workgroupId, localId } from 'three/tsl';
3
3
  import { RenderTarget, StorageTexture } from 'three/webgpu';
4
4
  import { HalfFloatType, RGBAFormat, NearestFilter, Matrix4, Box2, Vector2 } from 'three';
@@ -6,6 +6,7 @@ import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
6
6
  import { MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
7
7
  import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
8
8
  import { traverseBVH } from '../TSL/BVHTraversal.js';
9
+ import { cameraRayDirection } from '../TSL/CameraRay.js';
9
10
  import { getMaterial } from '../TSL/Common.js';
10
11
  import { computeUVCache, processNormal, processBump, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
11
12
 
@@ -48,6 +49,12 @@ export class NormalDepth extends RenderStage {
48
49
 
49
50
  this.cameraWorldMatrix = uniform( new Matrix4(), 'mat4' );
50
51
  this.cameraProjectionMatrixInverse = uniform( new Matrix4(), 'mat4' );
52
+ // Mirrored from the path tracer each frame — these rays must use the same camera model
53
+ // as the colour buffer or the denoiser's normal/depth edge-stops fight the image.
54
+ this.cameraProjection = uniform( 0, 'int' );
55
+ this.panoLonRange = uniform( new Vector2(), 'vec2' );
56
+ this.panoLatRange = uniform( new Vector2(), 'vec2' );
57
+ this.panoLevelHorizon = uniform( 1, 'int' );
51
58
  this.resolutionWidth = uniform( options.width || 1 );
52
59
  this.resolutionHeight = uniform( options.height || 1 );
53
60
 
@@ -218,20 +225,12 @@ export class NormalDepth extends RenderStage {
218
225
 
219
226
  If( gx.lessThan( int( resW ) ).and( gy.lessThan( int( resH ) ) ), () => {
220
227
 
221
- // Pixel center → NDC, Y negated for Three.js WebGPU.
222
- const ndcX = float( gx ).add( 0.5 ).div( resW ).mul( 2.0 ).sub( 1.0 );
223
- const ndcY = float( gy ).add( 0.5 ).div( resH ).mul( 2.0 ).sub( 1.0 ).negate();
224
- const ndcPos = vec3( ndcX, ndcY, 1.0 );
225
-
226
228
  // No jitter — deterministic per-pixel ray so the temporal gate
227
229
  // sees stable per-pixel normals across frames.
228
- const rayDirCS = camProjInvMat.mul( vec4( ndcPos, 1.0 ) );
229
- const rayDirWorld = normalize(
230
- mat3(
231
- camWorldMat[ 0 ].xyz,
232
- camWorldMat[ 1 ].xyz,
233
- camWorldMat[ 2 ].xyz
234
- ).mul( rayDirCS.xyz.div( rayDirCS.w ) )
230
+ const uv = vec2( float( gx ).add( 0.5 ).div( resW ), float( gy ).add( 0.5 ).div( resH ) );
231
+ const rayDirWorld = cameraRayDirection(
232
+ uv, camWorldMat, camProjInvMat,
233
+ this.cameraProjection, this.panoLonRange, this.panoLatRange, this.panoLevelHorizon
235
234
  );
236
235
  const rayOrigin = vec3( camWorldMat[ 3 ] );
237
236
 
@@ -307,6 +306,10 @@ export class NormalDepth extends RenderStage {
307
306
 
308
307
  this.cameraWorldMatrix.value.copy( pt.uniforms.get( 'cameraWorldMatrix' ).value );
309
308
  this.cameraProjectionMatrixInverse.value.copy( pt.uniforms.get( 'cameraProjectionMatrixInverse' ).value );
309
+ this.cameraProjection.value = pt.uniforms.get( 'cameraProjection' ).value;
310
+ this.panoLonRange.value.copy( pt.uniforms.get( 'panoLonRange' ).value );
311
+ this.panoLatRange.value.copy( pt.uniforms.get( 'panoLatRange' ).value );
312
+ this.panoLevelHorizon.value = pt.uniforms.get( 'panoLevelHorizon' ).value;
310
313
 
311
314
  }
312
315