rayzee 7.20.3 → 7.22.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 (42) hide show
  1. package/README.md +75 -4
  2. package/dist/rayzee.es.js +2821 -2854
  3. package/dist/rayzee.umd.js +60 -58
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +9 -2
  6. package/src/PathTracerApp.js +83 -0
  7. package/src/Pipeline/BindingAudit.js +178 -0
  8. package/src/Pipeline/RenderPipeline.js +5 -2
  9. package/src/Processor/BVHBuilder.js +1 -4
  10. package/src/Processor/CameraOptimizer.js +0 -5
  11. package/src/Processor/KernelManager.js +17 -70
  12. package/src/Processor/PackedRayBuffer.js +33 -8
  13. package/src/Processor/QueueManager.js +4 -3
  14. package/src/Processor/SceneProcessor.js +10 -4
  15. package/src/Processor/TreeletOptimizer.js +0 -7
  16. package/src/RenderSettings.js +41 -6
  17. package/src/Stages/ASVGF.js +10 -10
  18. package/src/Stages/BilateralFilter.js +70 -11
  19. package/src/Stages/EdgeFilter.js +44 -6
  20. package/src/Stages/NormalDepth.js +26 -15
  21. package/src/Stages/PathTracer.js +36 -34
  22. package/src/Stages/PathTracerStage.js +15 -46
  23. package/src/TSL/BVHTraversal.js +1 -67
  24. package/src/TSL/CameraRay.js +141 -0
  25. package/src/TSL/Common.js +1 -47
  26. package/src/TSL/DebugKernel.js +4 -5
  27. package/src/TSL/EmissiveSampling.js +40 -44
  28. package/src/TSL/ExtendKernel.js +11 -3
  29. package/src/TSL/FinalWriteKernel.js +1 -14
  30. package/src/TSL/GenerateKernel.js +11 -8
  31. package/src/TSL/LightsCore.js +3 -0
  32. package/src/TSL/LightsDirect.js +4 -4
  33. package/src/TSL/LightsSampling.js +138 -327
  34. package/src/TSL/PathTracerCore.js +2 -2
  35. package/src/TSL/Random.js +2 -2
  36. package/src/TSL/ShadeKernel.js +39 -17
  37. package/src/TSL/SortGlobalKernels.js +52 -7
  38. package/src/TSL/Struct.js +0 -10
  39. package/src/index.js +5 -0
  40. package/src/managers/DenoisingManager.js +13 -0
  41. package/src/managers/EnvironmentManager.js +6 -8
  42. package/src/managers/UniformManager.js +12 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.20.3",
3
+ "version": "7.22.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -21,7 +21,6 @@ export const ENGINE_DEFAULTS = {
21
21
  enableEnvironment: true,
22
22
  showBackground: true,
23
23
  transparentBackground: false,
24
- useImportanceSampledEnvironment: true,
25
24
  environmentIntensity: 1,
26
25
  backgroundIntensity: 1,
27
26
  // Solid backdrop color shown on camera-ray misses in 'color' background mode
@@ -63,6 +62,12 @@ export const ENGINE_DEFAULTS = {
63
62
  skyMieAnisotropy: 0.76,
64
63
  skyPreset: 'clearMorning',
65
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
+
66
71
  enableDOF: false,
67
72
  fov: 55,
68
73
  focusDistance: 0.8,
@@ -164,7 +169,9 @@ export const ENGINE_DEFAULTS = {
164
169
  asvgfPhiDepth: 1.0,
165
170
  asvgfVarianceBoost: 1.0,
166
171
  asvgfMaxAccumFrames: 32,
167
- asvgfGradientStrength: 0.0,
172
+ // Must be > 0: the gradient also rejects fireflies before the EMA smears them across
173
+ // ~1/alpha frames. At 0 the denoiser measures ~3x worse than no denoiser at 1 spp.
174
+ asvgfGradientStrength: 1.0,
168
175
  asvgfGradientSigmaScale: 2.0,
169
176
  asvgfGradientNoiseFloor: 0.0,
170
177
  asvgfDebugMode: 0,
@@ -1562,6 +1562,11 @@ export class PathTracerApp extends EventDispatcher {
1562
1562
  this.cameraManager?.setAutoFocusMode( 'manual' );
1563
1563
  if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
1564
1564
 
1565
+ // The seed axis free-runs across accumulation resets so a camera drag gets fresh
1566
+ // sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
1567
+ // frameCount, so N samples reproduce bit-for-bit. reset() below zeroes the tick.
1568
+ stage._pinSeedToFrame = true;
1569
+
1565
1570
  this._deterministic = true;
1566
1571
  this._dispatchPinned = pinDispatch;
1567
1572
 
@@ -1580,6 +1585,8 @@ export class PathTracerApp extends EventDispatcher {
1580
1585
 
1581
1586
  }
1582
1587
 
1588
+ stage._pinSeedToFrame = false;
1589
+
1583
1590
  this._deterministicRestore = null;
1584
1591
  this._deterministic = false;
1585
1592
  this._dispatchPinned = false;
@@ -1715,6 +1722,80 @@ export class PathTracerApp extends EventDispatcher {
1715
1722
 
1716
1723
  }
1717
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
+
1718
1799
  // ═══════════════════════════════════════════════════════════════
1719
1800
  // Output (absorbed from OutputAPI)
1720
1801
  // ═══════════════════════════════════════════════════════════════
@@ -2291,6 +2372,8 @@ export class PathTracerApp extends EventDispatcher {
2291
2372
  renderer: this.renderer,
2292
2373
  resetCallback: () => this.reset(),
2293
2374
  reconcileCompletion: () => this._reconcileCompletion(),
2375
+ denoisingManager: this.denoisingManager,
2376
+ cameraManager: this.cameraManager,
2294
2377
  } );
2295
2378
 
2296
2379
  this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Dev-only guard against texture-binding aliasing.
3
+ *
4
+ * Two TextureNodes still holding the default EmptyTexture when a stage's kernel is first
5
+ * compiled can share one GPU binding, which then resolves to whichever is assigned last.
6
+ * Nothing throws — the aliased node reads a real texture, just not its own. BilateralFilter
7
+ * shipped this way, reading ASVGF's history counter as its variance.
8
+ *
9
+ * Rule enforced: every TextureNode a kernel reads must hold its real texture before that
10
+ * kernel is first dispatched. StorageTexture-typed nodes are exempt — `textureLoad` codegen
11
+ * only emits the required `level` parameter while the node still holds EmptyTexture, so those
12
+ * are deliberately bound after compile (see ASVGF.render).
13
+ *
14
+ * Off by default; the bench harness turns it on.
15
+ */
16
+
17
+ let enabled = false;
18
+ const findings = [];
19
+
20
+ /** Per-stage audit state. WeakMap so a disposed stage does not pin its entry. */
21
+ const stageState = new WeakMap();
22
+
23
+ /**
24
+ * @param {boolean} on
25
+ */
26
+ export function setBindingAudit( on ) {
27
+
28
+ enabled = !! on;
29
+
30
+ }
31
+
32
+ export function isBindingAuditEnabled() {
33
+
34
+ return enabled;
35
+
36
+ }
37
+
38
+ /** @returns {Array<{stage: string, nodes: string[], unboundAtCompile: string[], message: string}>} */
39
+ export function getBindingAuditFindings() {
40
+
41
+ return findings.slice();
42
+
43
+ }
44
+
45
+ export function clearBindingAuditFindings() {
46
+
47
+ findings.length = 0;
48
+
49
+ }
50
+
51
+ /** Own enumerable properties holding a TSL TextureNode. */
52
+ function textureNodeKeys( stage ) {
53
+
54
+ const keys = [];
55
+
56
+ for ( const key of Object.keys( stage ) ) {
57
+
58
+ if ( stage[ key ]?.isTextureNode === true ) keys.push( key );
59
+
60
+ }
61
+
62
+ return keys;
63
+
64
+ }
65
+
66
+ // EmptyTexture (the TextureNode default) is a bare `new Texture()`, so its image is null.
67
+ // Every real texture the stages bind — RenderTarget textures and StorageTextures alike —
68
+ // carries an image with a nonzero width.
69
+ const isUnbound = ( texture ) => ! texture || ! texture.image || ! ( texture.image.width > 0 );
70
+
71
+ /**
72
+ * Runs a stage's render with the audit wrapped around it.
73
+ *
74
+ * The snapshot must be taken when the stage first calls `renderer.compute`, not before or
75
+ * after render() — the binding assignments and the compile are interleaved inside that one
76
+ * call, so by the time it returns everything is bound. Sampling after render() was the first
77
+ * version of this guard and it silently caught nothing.
78
+ *
79
+ * A later frame then resolves: a candidate now holding a StorageTexture is the sanctioned
80
+ * pattern; anything else was bound too late.
81
+ *
82
+ * @param {Object} stage
83
+ * @param {Object} renderer
84
+ * @param {function(): void} renderFn
85
+ */
86
+ export function auditStageRender( stage, renderer, renderFn ) {
87
+
88
+ if ( ! enabled ) {
89
+
90
+ renderFn();
91
+ return;
92
+
93
+ }
94
+
95
+ let state = stageState.get( stage );
96
+
97
+ if ( ! state ) {
98
+
99
+ state = { phase: 'pending', unboundAtCompile: [] };
100
+ stageState.set( stage, state );
101
+
102
+ }
103
+
104
+ if ( state.phase !== 'pending' ) {
105
+
106
+ renderFn();
107
+ if ( state.phase === 'watch' ) resolve( stage, state );
108
+ return;
109
+
110
+ }
111
+
112
+ // Shadow the prototype method for the duration of this stage's render only.
113
+ const hadOwn = Object.prototype.hasOwnProperty.call( renderer, 'compute' );
114
+ const original = renderer.compute;
115
+
116
+ renderer.compute = function ( ...args ) {
117
+
118
+ if ( state.phase === 'pending' ) {
119
+
120
+ state.unboundAtCompile = textureNodeKeys( stage )
121
+ .filter( ( key ) => isUnbound( stage[ key ].value ) );
122
+
123
+ // A single unbound node has nothing to alias with, so it cannot produce this bug.
124
+ state.phase = state.unboundAtCompile.length < 2 ? 'done' : 'watch';
125
+
126
+ }
127
+
128
+ return original.apply( this, args );
129
+
130
+ };
131
+
132
+ try {
133
+
134
+ renderFn();
135
+
136
+ } finally {
137
+
138
+ if ( hadOwn ) renderer.compute = original;
139
+ else delete renderer.compute;
140
+
141
+ }
142
+
143
+ }
144
+
145
+ function resolve( stage, state ) {
146
+
147
+ const lateBound = [];
148
+
149
+ for ( const key of state.unboundAtCompile ) {
150
+
151
+ const texture = stage[ key ]?.value;
152
+
153
+ // Still unassigned — the stage may bind it on a later frame (or never, which is also
154
+ // not this bug). Leave the audit open rather than guessing.
155
+ if ( isUnbound( texture ) ) return;
156
+
157
+ if ( texture.isStorageTexture !== true ) lateBound.push( key );
158
+
159
+ }
160
+
161
+ state.phase = 'done';
162
+
163
+ if ( lateBound.length === 0 ) return;
164
+
165
+ findings.push( {
166
+ stage: stage.name,
167
+ nodes: lateBound,
168
+ unboundAtCompile: state.unboundAtCompile.slice(),
169
+ message:
170
+ `${stage.name}: ${lateBound.join( ', ' )} ` +
171
+ `${lateBound.length === 1 ? 'was' : 'were'} still holding EmptyTexture when the stage ` +
172
+ `first dispatched, alongside ${state.unboundAtCompile.length - lateBound.length} ` +
173
+ 'other unbound node(s), and later received a non-storage texture. Two nodes unbound at ' +
174
+ 'compile time can share one GPU binding and then both resolve to whichever is assigned ' +
175
+ 'last. Assign these before the first dispatch.',
176
+ } );
177
+
178
+ }
@@ -1,5 +1,6 @@
1
1
  import { PipelineContext } from './PipelineContext.js';
2
2
  import { EventDispatcher } from './EventDispatcher.js';
3
+ import { auditStageRender } from './BindingAudit.js';
3
4
 
4
5
  /**
5
6
  * RenderPipeline - Orchestrates execution of pipeline stages
@@ -183,8 +184,10 @@ export class RenderPipeline {
183
184
 
184
185
  const stageStartTime = this.stats.enabled ? performance.now() : 0;
185
186
 
186
- // Execute stage
187
- stage.render( this.context, writeBuffer );
187
+ // Pass-through unless setBindingAudit(true) — see BindingAudit.js.
188
+ auditStageRender(
189
+ stage, this.renderer, () => stage.render( this.context, writeBuffer )
190
+ );
188
191
 
189
192
  // Track timing
190
193
  if ( this.stats.enabled ) {
@@ -101,8 +101,6 @@ export class BVHBuilder {
101
101
  this.treeletSize = 5;
102
102
  this.treeletOptimizationPasses = 1;
103
103
  this.treeletMinImprovement = 0.02;
104
- this.maxTreeletDepth = 3;
105
- this.maxTreeletsPerScene = 20;
106
104
  this.treeletComplexityThreshold = 50000;
107
105
 
108
106
  // Reinsertion optimization configuration
@@ -191,6 +189,7 @@ export class BVHBuilder {
191
189
  if ( config.size !== undefined ) this.treeletSize = Math.max( 3, Math.min( 12, config.size ) );
192
190
  if ( config.passes !== undefined ) this.treeletOptimizationPasses = Math.max( 1, Math.min( 3, config.passes ) );
193
191
  if ( config.minImprovement !== undefined ) this.treeletMinImprovement = Math.max( 0.001, config.minImprovement );
192
+ if ( config.complexityThreshold !== undefined ) this.treeletComplexityThreshold = config.complexityThreshold;
194
193
 
195
194
  }
196
195
 
@@ -595,12 +594,10 @@ export class BVHBuilder {
595
594
 
596
595
  const isLargeScene = this.totalTriangles > this.treeletComplexityThreshold;
597
596
  const adaptiveTreeletSize = isLargeScene ? 3 : this.treeletSize;
598
- const adaptiveMaxTreelets = isLargeScene ? 10 : this.maxTreeletsPerScene;
599
597
 
600
598
  const optimizer = new TreeletOptimizer( this.traversalCost, this.intersectionCost );
601
599
  optimizer.setTreeletSize( adaptiveTreeletSize );
602
600
  optimizer.setMinImprovement( this.treeletMinImprovement );
603
- optimizer.setMaxTreelets( adaptiveMaxTreelets );
604
601
 
605
602
  const optimizationStartTime = performance.now();
606
603
 
@@ -24,7 +24,6 @@ export class CameraOptimizer {
24
24
  // Enhanced interaction mode settings for reduced quality during interaction
25
25
  this.interactionQualitySettings = {
26
26
  maxBounceCount: 1,
27
- useEnvMapIS: false,
28
27
  // pixelRatio: 0.25,
29
28
  enableAccumulation: false,
30
29
  ...settings.qualitySettings
@@ -303,25 +302,21 @@ export class CameraOptimizer {
303
302
  const presets = {
304
303
  'ultra-low': {
305
304
  maxBounceCount: 1,
306
- useEnvMapIS: false,
307
305
  pixelRatio: 0.125,
308
306
  enableAccumulation: false
309
307
  },
310
308
  'low': {
311
309
  maxBounceCount: 1,
312
- useEnvMapIS: false,
313
310
  pixelRatio: 0.25,
314
311
  enableAccumulation: false
315
312
  },
316
313
  'medium': {
317
314
  maxBounceCount: 2,
318
- useEnvMapIS: true,
319
315
  pixelRatio: 0.5,
320
316
  enableAccumulation: false
321
317
  },
322
318
  'high': {
323
319
  maxBounceCount: 3,
324
- useEnvMapIS: true,
325
320
  pixelRatio: 0.75,
326
321
  enableAccumulation: true
327
322
  }
@@ -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
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  import {
7
7
  storage, uintBitsToFloat, floatBitsToUint, vec2, vec3, vec4, uvec4, uint, int, float, clamp,
8
- packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16,
8
+ packSnorm2x16, packUnorm2x16, unpackSnorm2x16, unpackUnorm2x16, select, floor, log2, exp2, max,
9
9
  } from 'three/tsl';
10
10
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
11
11
  import { createLogger, fmt } from '../utils/Logger.js';
@@ -186,13 +186,38 @@ export const gbDecodeAlbedo = ( packed ) =>
186
186
  // .w packs per-ray bounce state: perRayBounces (bits 0-7) | sssSteps (bits 8-15) | transparentCount
187
187
  // (bits 16-21), each masked so an overrun can't bleed into its neighbour. pixelIndex is NOT stored —
188
188
  // it equals rayID (one ray per pixel).
189
- export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps, transparentCount = uint( 0 ) ) =>
190
- buf.element( soa( id, RAY.ORIGIN_META ) )
191
- .assign( vec4( origin, uintBitsToFloat(
192
- uint( bounces ).bitAnd( uint( 0xFF ) )
193
- .bitOr( uint( sssSteps ).bitAnd( uint( 0xFF ) ).shiftLeft( 8 ) )
194
- .bitOr( uint( transparentCount ).bitAnd( uint( 0x3F ) ).shiftLeft( 16 ) )
195
- ) ) );
189
+ // Bits 22-31 of ORIGIN_META.w: alpha-passthrough distance since the last real scatter (Cycles'
190
+ // mis_ray_t), as a 10-bit float (4e6m, bias 8, ~0.004-500 range, ≤1.6% rel err). The emissive-hit
191
+ // MIS partner pdf must be evaluated at the scatter point that produced prevBouncePdf, not at the
192
+ // last skip surface — feeding it the skip origin collapses dist² and lets full-Le BSDF hits
193
+ // through nearly unweighted (fireflies on emitters inside alpha-blend shades).
194
+ const encodeMisRayT = ( t ) => {
195
+
196
+ const e = clamp( floor( log2( max( t, 1e-6 ) ) ), - 8.0, 7.0 );
197
+ const m = clamp( float( t ).mul( exp2( e.negate() ) ).sub( 1.0 ).mul( 64.0 ), 0.0, 63.0 );
198
+ return select( float( t ).lessThan( 0.004 ), uint( 0 ), uint( e.add( 8.0 ) ).shiftLeft( 6 ).bitOr( uint( m ) ) );
199
+
200
+ };
201
+
202
+ export const readMisRayT = ( buf, id ) => {
203
+
204
+ const bits = floatBitsToUint( buf.element( soa( id, RAY.ORIGIN_META ) ).w ).shiftRight( 22 );
205
+ const e = float( bits.shiftRight( 6 ).bitAnd( uint( 15 ) ) ).sub( 8.0 );
206
+ const m = float( bits.bitAnd( uint( 63 ) ) ).div( 64.0 ).add( 1.0 );
207
+ return select( bits.equal( uint( 0 ) ), float( 0.0 ), m.mul( exp2( e ) ) );
208
+
209
+ };
210
+
211
+ export const writeRayOriginMeta = ( buf, id, origin, bounces, sssSteps, transparentCount = uint( 0 ), misRayT = null ) => {
212
+
213
+ let packed = uint( bounces ).bitAnd( uint( 0xFF ) )
214
+ .bitOr( uint( sssSteps ).bitAnd( uint( 0xFF ) ).shiftLeft( 8 ) )
215
+ .bitOr( uint( transparentCount ).bitAnd( uint( 0x3F ) ).shiftLeft( 16 ) );
216
+ if ( misRayT !== null ) packed = packed.bitOr( encodeMisRayT( misRayT ).shiftLeft( 22 ) );
217
+ return buf.element( soa( id, RAY.ORIGIN_META ) )
218
+ .assign( vec4( origin, uintBitsToFloat( packed ) ) );
219
+
220
+ };
196
221
 
197
222
  export const writeRayDirFlags = ( buf, id, direction, bounceFlags ) =>
198
223
  buf.element( soa( id, RAY.DIR_FLAGS ) )
@@ -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
  };
@@ -51,6 +51,8 @@ export class SceneProcessor {
51
51
  treeletSize: 7, // 7 nodes gives 315 topologies for optimal enumeration
52
52
  treeletOptimizationPasses: 1,
53
53
  treeletMinImprovement: 0.01, // Minimum SAH improvement threshold
54
+ // Above this triangle count the builder drops treelets to size 3.
55
+ treeletComplexityThreshold: 50000,
54
56
  ...options
55
57
  };
56
58
 
@@ -151,7 +153,8 @@ export class SceneProcessor {
151
153
  enabled: this.config.enableTreeletOptimization,
152
154
  size: this.config.treeletSize,
153
155
  passes: this.config.treeletOptimizationPasses,
154
- minImprovement: this.config.treeletMinImprovement
156
+ minImprovement: this.config.treeletMinImprovement,
157
+ complexityThreshold: this.config.treeletComplexityThreshold
155
158
  } );
156
159
 
157
160
  // Create and configure texture creator
@@ -436,7 +439,8 @@ export class SceneProcessor {
436
439
  enabled: originalTreeletEnabled !== false,
437
440
  size: this.config.treeletSize,
438
441
  passes: this.config.treeletOptimizationPasses,
439
- minImprovement: this.config.treeletMinImprovement
442
+ minImprovement: this.config.treeletMinImprovement,
443
+ complexityThreshold: this.config.treeletComplexityThreshold
440
444
  },
441
445
  reinsertionOptimization: {
442
446
  enabled: this.bvhBuilder.enableReinsertionOptimization,
@@ -1267,7 +1271,8 @@ export class SceneProcessor {
1267
1271
  enabled: this.config.enableTreeletOptimization,
1268
1272
  size: this.config.treeletSize,
1269
1273
  passes: this.config.treeletOptimizationPasses,
1270
- minImprovement: this.config.treeletMinImprovement
1274
+ minImprovement: this.config.treeletMinImprovement,
1275
+ complexityThreshold: this.config.treeletComplexityThreshold
1271
1276
  } );
1272
1277
 
1273
1278
  }
@@ -1953,7 +1958,8 @@ export class SceneProcessor {
1953
1958
  enabled: treeletEnabled,
1954
1959
  size: this.config.treeletSize,
1955
1960
  passes: this.config.treeletOptimizationPasses,
1956
- minImprovement: this.config.treeletMinImprovement
1961
+ minImprovement: this.config.treeletMinImprovement,
1962
+ complexityThreshold: this.config.treeletComplexityThreshold
1957
1963
  },
1958
1964
  reinsertionOptimization: {
1959
1965
  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 };