rayzee 6.5.0 → 7.0.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 (51) hide show
  1. package/README.md +24 -5
  2. package/dist/rayzee.es.js +7554 -7014
  3. package/dist/rayzee.es.js.map +1 -1
  4. package/dist/rayzee.umd.js +157 -236
  5. package/dist/rayzee.umd.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/EngineDefaults.js +12 -9
  8. package/src/PathTracerApp.js +118 -26
  9. package/src/Pipeline/PipelineContext.js +1 -2
  10. package/src/Pipeline/RenderPipeline.js +1 -1
  11. package/src/Pipeline/RenderStage.js +1 -1
  12. package/src/Processor/CameraOptimizer.js +0 -5
  13. package/src/Processor/GeometryExtractor.js +6 -0
  14. package/src/Processor/KernelManager.js +277 -0
  15. package/src/Processor/PackedRayBuffer.js +265 -0
  16. package/src/Processor/QueueManager.js +173 -0
  17. package/src/Processor/SceneProcessor.js +1 -0
  18. package/src/Processor/ShaderBuilder.js +11 -317
  19. package/src/Processor/StorageTexturePool.js +29 -15
  20. package/src/Processor/VRAMTracker.js +169 -0
  21. package/src/Processor/utils.js +11 -110
  22. package/src/RenderSettings.js +0 -3
  23. package/src/Stages/ASVGF.js +76 -20
  24. package/src/Stages/BilateralFilter.js +34 -10
  25. package/src/Stages/EdgeFilter.js +2 -3
  26. package/src/Stages/MotionVector.js +16 -9
  27. package/src/Stages/NormalDepth.js +17 -5
  28. package/src/Stages/PathTracer.js +671 -1456
  29. package/src/Stages/PathTracerStage.js +1451 -0
  30. package/src/Stages/SSRC.js +32 -15
  31. package/src/Stages/Variance.js +35 -12
  32. package/src/TSL/CompactKernel.js +110 -0
  33. package/src/TSL/DebugKernel.js +98 -0
  34. package/src/TSL/Environment.js +13 -11
  35. package/src/TSL/ExtendKernel.js +75 -0
  36. package/src/TSL/FinalWriteKernel.js +121 -0
  37. package/src/TSL/GenerateKernel.js +109 -0
  38. package/src/TSL/LightsSampling.js +2 -2
  39. package/src/TSL/PathTracerCore.js +43 -1039
  40. package/src/TSL/ShadeKernel.js +873 -0
  41. package/src/TSL/patches.js +81 -4
  42. package/src/index.js +3 -0
  43. package/src/managers/CameraManager.js +1 -1
  44. package/src/managers/DenoisingManager.js +40 -75
  45. package/src/managers/EnvironmentManager.js +30 -39
  46. package/src/managers/OverlayManager.js +7 -22
  47. package/src/managers/UniformManager.js +0 -3
  48. package/src/managers/helpers/TileHelper.js +2 -2
  49. package/src/Stages/AdaptiveSampling.js +0 -483
  50. package/src/TSL/PathTracer.js +0 -384
  51. package/src/managers/TileManager.js +0 -298
@@ -1,1105 +1,156 @@
1
- import { storage } from 'three/tsl';
2
- import { StorageInstancedBufferAttribute } from 'three/webgpu';
3
- import {
4
- NearestFilter, Vector2, Matrix4,
5
- TextureLoader, RepeatWrapping
6
- } from 'three';
7
- import { stbnScalarTextureNode, stbnVec2TextureNode } from '../TSL/Random.js';
8
-
9
- // Pipeline system
10
- import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
11
-
12
- // Managers (renderer-agnostic)
13
- import { TileManager } from '../managers/TileManager.js';
14
- import { CameraOptimizer } from '../Processor/CameraOptimizer.js';
15
- import { createPerformanceMonitor, calculateAccumulationAlpha, updateCompletionThreshold } from '../Processor/utils.js';
16
- import { StorageTexturePool } from '../Processor/StorageTexturePool.js';
17
- import { UniformManager } from '../managers/UniformManager.js';
18
- import { MaterialDataManager } from '../managers/MaterialDataManager.js';
19
- import { EnvironmentManager } from '../managers/EnvironmentManager.js';
20
- import { ShaderBuilder } from '../Processor/ShaderBuilder.js';
21
-
22
- // Scene building
23
- import { SceneProcessor } from '../Processor/SceneProcessor.js';
24
- import { LightSerializer } from '../Processor/LightSerializer';
25
-
26
- // Constants
27
- import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
28
- import { getAssetConfig } from '../AssetConfig.js';
29
-
30
- /**
31
- * Data layout constants
32
- */
33
- const BVH_VEC4_PER_NODE = 4;
34
-
35
- /**
36
- * Path Tracing Stage for WebGPU.
37
- *
38
- * Full-featured path tracing stage:
39
- * - BVH-accelerated ray traversal
40
- * - GGX/Diffuse BSDF sampling
41
- * - Environment lighting with importance sampling
42
- * - Progressive and tiled accumulation
43
- * - MRT outputs for denoising (normal/depth, albedo)
44
- * - Camera interaction mode optimization
45
- * - Event-driven pipeline communication
46
- *
47
- * Events emitted:
48
- * - pathtracer:frameComplete - When a frame finishes rendering
49
- * - camera:moved - When camera position/orientation changes
50
- * - tile:changed - When current tile changes (for OverlayManager TileHelper). @internal — payload carries internal tile coordinates and may change without notice.
51
- * - asvgf:reset - Request ASVGF to reset temporal data
52
- * - asvgf:updateParameters - Update ASVGF parameters
53
- * - asvgf:setTemporal - Enable/disable ASVGF temporal accumulation
54
- *
55
- * Textures published to context:
56
- * - pathtracer:color - Main color output
57
- * - pathtracer:normalDepth - Normal/depth buffer
58
- */
59
- export class PathTracer extends RenderStage {
60
-
61
- /**
62
- * @param {WebGPURenderer} renderer - Three.js WebGPU renderer
63
- * @param {Scene} scene - Three.js scene
64
- * @param {PerspectiveCamera} camera - Three.js camera
65
- * @param {Object} options - Configuration options
66
- */
67
- constructor( renderer, scene, camera, options = {} ) {
68
-
69
- super( 'PathTracer', {
70
- ...options,
71
- executionMode: StageExecutionMode.ALWAYS
72
- } );
73
-
74
- const width = options.width || 1920;
75
- const height = options.height || 1080;
76
-
77
- this.camera = camera;
78
- this.width = width;
79
- this.height = height;
80
- this.renderer = renderer;
81
- this.scene = scene;
82
-
83
- // Initialize managers
84
- this.tileManager = new TileManager( width, height, DEFAULT_STATE.tiles );
85
-
86
- // Scene building
87
- this.sdfs = new SceneProcessor();
88
- this.lightSerializer = new LightSerializer();
89
-
90
- // State management
91
- this.accumulationEnabled = true;
92
- this.isComplete = false;
93
- this.cameras = [];
94
- // Performance monitoring
95
- this.performanceMonitor = createPerformanceMonitor();
96
- this.completionThreshold = 0;
97
- this.renderLimitMode = 'frames';
98
-
99
- // Initialize data textures
100
- this._initDataTextures();
101
-
102
- // Initialize storage texture pool (ping-pong compute output)
103
- this.storageTextures = new StorageTexturePool( 0, 0 );
104
-
105
- // Initialize uniforms via UniformManager
106
- this.uniforms = new UniformManager( width, height );
107
-
108
- // Define getters for every uniform so that this.maxBounces, this.frame, etc.
109
- // return the uniform node (backward-compat with this.X.value pattern).
110
- this._defineUniformGetters();
111
-
112
- // Initialize material data manager
113
- this.materialData = new MaterialDataManager( this.sdfs );
114
- this.materialData.callbacks.onReset = () => this.reset();
115
- // Triangle data carries the per-triangle `side` flag (NORMAL_C.w). The
116
- // authoritative CPU array is triangleStorageAttr.array (not sdfs.triangleData,
117
- // which isn't populated on the PathTracerApp build path). The patch mutates
118
- // the array in place — only a dirty flag is needed for GPU re-upload.
119
- this.materialData.callbacks.getTriangleData = () => ( {
120
- array: this.triangleStorageAttr?.array,
121
- count: this.triangleCount,
122
- } );
123
- this.materialData.callbacks.onTriangleDataChanged = () => {
124
-
125
- if ( this.triangleStorageAttr ) this.triangleStorageAttr.needsUpdate = true;
126
-
127
- };
128
-
129
- // Initialize environment manager
130
- this.environment = new EnvironmentManager( this.scene, this.uniforms );
131
- this.environment.callbacks.onReset = () => this.reset();
132
- this.environment.callbacks.getSceneTextureNodes = () => this.shaderBuilder.getSceneTextureNodes();
133
-
134
- // Initialize shader composer
135
- this.shaderBuilder = new ShaderBuilder();
136
-
137
- // Initialize rendering state
138
- this._initRenderingState();
139
-
140
- // Setup blue noise
141
- this.setupBlueNoise();
142
-
143
- // Cache frequently used objects
144
- this.tempVector2 = new Vector2();
145
- this.lastCameraMatrix = new Matrix4();
146
- this.lastProjectionMatrix = new Matrix4();
147
-
148
- // Denoising management state
149
- this.lastRenderMode = - 1;
150
- this.tileCompletionFrame = 0;
151
- this.renderModeChangeTimeout = null;
152
- this.renderModeChangeDelay = 50;
153
- this.pendingRenderMode = null;
154
-
155
- // Adaptive sampling state
156
- this.adaptiveSamplingFrameToggle = false;
157
-
158
- // Track interaction mode state for accumulation
159
- this.lastInteractionModeState = false;
160
-
161
- // Track changes for event emission
162
- this.cameraChanged = false;
163
- this.tileChanged = false;
164
-
165
- // Update completion threshold
166
- this.updateCompletionThreshold();
167
-
168
- }
169
-
170
- /**
171
- * Initialize data texture references and metadata
172
- */
173
- _initDataTextures() {
174
-
175
- // Triangle data (storage buffer for WebGPU)
176
- this.triangleStorageAttr = null;
177
- this.triangleStorageNode = null;
178
- this.triangleCount = 0;
179
-
180
- // BVH data (storage buffer for WebGPU)
181
- this.bvhStorageAttr = null;
182
- this.bvhStorageNode = null;
183
- this.bvhNodeCount = 0;
184
-
185
- // Lights
186
- this.directionalLightsData = null;
187
- this.pointLightsData = null;
188
- this.spotLightsData = null;
189
- this.areaLightsData = null;
190
-
191
- // Spot light gobo (projection mask) DataArrayTexture. Owned externally
192
- // (GoboManager); ShaderBuilder reads via this property at graph build time
193
- // and refreshes the bound TextureNode in-place when it changes.
194
- this.goboMaps = null;
195
-
196
- // Spot light IES photometric profiles DataArrayTexture. Owned externally
197
- // (IESManager); ShaderBuilder reads via this property at graph build time
198
- // and refreshes the bound TextureNode in-place when it changes.
199
- this.iesProfiles = null;
200
-
201
- // STBN noise textures
202
- this.stbnScalarTexture = null;
203
- this.stbnVec2Texture = null;
204
-
205
- // Packed light buffer — [lightBVH nodes (4 vec4s each) | emissive triangles (2 vec4s each)]
206
- // emissiveVec4Offset uniform tracks the vec4-count offset where emissive data starts.
207
- // Initialized with dummy data so TSL compilation never sees null.
208
- this.lightStorageAttr = new StorageInstancedBufferAttribute( new Float32Array( 16 ), 4 );
209
- this.lightStorageNode = storage( this.lightStorageAttr, 'vec4', 1 ).toReadOnly();
210
-
211
- // Cached CPU-side data — rebuilt into the packed buffer whenever either source changes.
212
- this._lbvhDataCache = null;
213
- this._emissiveDataCache = null;
214
-
215
- // Per-mesh visibility is packed into the TLAS BLAS-pointer leaf's slot [2]
216
- // (see TLASBuilder.flatten + BVHTraversal.js). The InstanceTable holds the
217
- // tlasLeafIndex for each mesh so we can patch visibility in place.
218
- this._instanceTable = null;
219
-
220
- // Adaptive sampling
221
- this.adaptiveSamplingTexture = null;
222
-
223
- // Spheres
224
- this.spheres = [];
225
-
226
- }
227
-
228
- /**
229
- * Dynamically defines getters for all uniform names so that
230
- * this.maxBounces, this.frame, etc. return the uniform node.
231
- * Also defines light buffer node getters.
232
- * @private
233
- */
234
- _defineUniformGetters() {
235
-
236
- const uniforms = this.uniforms;
237
-
238
- for ( const name of uniforms.keys() ) {
239
-
240
- Object.defineProperty( this, name, {
241
- get: () => uniforms.get( name ),
242
- configurable: true,
243
- } );
244
-
245
- }
246
-
247
- // Light buffer node getters
248
- const lightBuffers = uniforms.getLightBufferNodes();
249
- for ( const [ suffix, node ] of Object.entries( lightBuffers ) ) {
250
-
251
- Object.defineProperty( this, `${suffix}LightsBufferNode`, {
252
- get: () => node,
253
- configurable: true,
254
- } );
255
-
256
- }
257
-
258
- }
259
-
260
- /**
261
- * Initialize rendering state
262
- */
263
- _initRenderingState() {
264
-
265
- // State flags
266
- this.isReady = false;
267
- this.frameCount = 0;
268
-
269
- }
270
-
271
- /**
272
- * Initialize camera movement optimizer
273
- */
274
- _initCameraOptimizer() {
275
-
276
- // Create adapter interface for TSL uniforms
277
- const self = this;
278
- const materialInterface = {
279
- uniforms: {
280
- maxBounceCount: {
281
- get value() {
282
-
283
- return self.maxBounces.value;
284
-
285
- },
286
- set value( v ) {
287
-
288
- self.maxBounces.value = v;
289
-
290
- }
291
- },
292
- numRaysPerPixel: {
293
- get value() {
294
-
295
- return self.samplesPerPixel.value;
296
-
297
- },
298
- set value( v ) {
299
-
300
- self.samplesPerPixel.value = v;
301
-
302
- }
303
- },
304
- useAdaptiveSampling: {
305
- get value() {
306
-
307
- return self.useAdaptiveSampling.value;
308
-
309
- },
310
- set value( v ) {
311
-
312
- self.useAdaptiveSampling.value = v;
313
-
314
- }
315
- },
316
- useEnvMapIS: {
317
- get value() {
318
-
319
- return self.useEnvMapIS.value;
320
-
321
- },
322
- set value( v ) {
323
-
324
- self.useEnvMapIS.value = v;
325
-
326
- }
327
- },
328
- enableAccumulation: {
329
- get value() {
330
-
331
- return self.enableAccumulation.value;
332
-
333
- },
334
- set value( v ) {
335
-
336
- self.enableAccumulation.value = v;
337
-
338
- }
339
- },
340
- enableEmissiveTriangleSampling: {
341
- get value() {
342
-
343
- return self.enableEmissiveTriangleSampling.value;
344
-
345
- },
346
- set value( v ) {
347
-
348
- self.enableEmissiveTriangleSampling.value = v;
349
-
350
- }
351
- },
352
- cameraIsMoving: {
353
- get value() {
354
-
355
- return self.cameraIsMoving.value;
356
-
357
- },
358
- set value( v ) {
359
-
360
- self.cameraIsMoving.value = v;
361
-
362
- }
363
- }
364
- }
365
- };
366
-
367
- this.cameraOptimizer = new CameraOptimizer( this.renderer, materialInterface, {
368
- enabled: DEFAULT_STATE.interactionModeEnabled,
369
- qualitySettings: {
370
- maxBounceCount: 1,
371
- numRaysPerPixel: 1,
372
- useAdaptiveSampling: false,
373
- useEnvMapIS: false,
374
- enableAccumulation: false,
375
- enableEmissiveTriangleSampling: false,
376
- },
377
- onReset: () => {
378
-
379
- this.reset();
380
- this.emit( 'pathtracer:viewpointChanged' );
381
-
382
- }
383
- } );
384
-
385
- }
386
-
387
- /**
388
- * Load STBN (Spatiotemporal Blue Noise) atlas textures.
389
- * Each atlas is 1024×1024: 8×8 grid of 128×128 tiles, 64 temporal slices.
390
- */
391
- setupBlueNoise() {
392
-
393
- const loader = new TextureLoader();
394
- loader.setCrossOrigin( 'anonymous' );
395
-
396
- const configure = ( tex ) => {
397
-
398
- tex.minFilter = NearestFilter;
399
- tex.magFilter = NearestFilter;
400
- tex.wrapS = RepeatWrapping;
401
- tex.wrapT = RepeatWrapping;
402
- tex.generateMipmaps = false;
403
- return tex;
404
-
405
- };
406
-
407
- const { stbnScalarAtlas, stbnVec2Atlas } = getAssetConfig();
408
-
409
- loader.load( stbnScalarAtlas, ( tex ) => {
410
-
411
- this.stbnScalarTexture = configure( tex );
412
- stbnScalarTextureNode.value = tex;
413
- console.log( `PathTracer: STBN scalar atlas loaded ${tex.image.width}x${tex.image.height}` );
414
-
415
- } );
416
-
417
- loader.load( stbnVec2Atlas, ( tex ) => {
418
-
419
- this.stbnVec2Texture = configure( tex );
420
- stbnVec2TextureNode.value = tex;
421
- console.log( `PathTracer: STBN vec2 atlas loaded ${tex.image.width}x${tex.image.height}` );
422
-
423
- } );
424
-
425
- }
426
-
427
- /**
428
- * Setup event listeners for pipeline events
429
- */
430
- setupEventListeners() {
431
-
432
- this.on( 'pipeline:reset', () => {
433
-
434
- this.reset();
435
-
436
- } );
437
-
438
- this.on( 'pipeline:resize', ( data ) => {
439
-
440
- if ( data && data.width && data.height ) {
441
-
442
- this.setSize( data.width, data.height );
443
-
444
- }
445
-
446
- } );
447
-
448
- this.on( 'pathtracer:setCompletionThreshold', ( data ) => {
449
-
450
- if ( data && data.threshold !== undefined ) {
451
-
452
- this.completionThreshold = data.threshold;
453
-
454
- }
455
-
456
- } );
457
-
458
- }
459
-
460
- // ===== PUBLIC API METHODS =====
461
-
462
- /**
463
- * Build scene data (BVH, geometry, materials)
464
- * @param {Object3D} scene - Three.js scene or object
465
- */
466
- async build( scene ) {
467
-
468
- this.dispose();
469
- this.scene = scene;
470
-
471
- await this.sdfs.buildBVH( scene );
472
- this.cameras = this.sdfs.cameras;
473
-
474
- // Inject shader defines based on detected material features
475
- this.materialData.injectMaterialFeatureDefines();
476
-
477
- // Update uniforms with scene data
478
- this.updateSceneUniforms();
479
- this.updateLights();
480
-
481
- // Initialize camera optimizer after scene is built
482
- this._initCameraOptimizer();
483
-
484
- // Setup material now that we have scene data
485
- this.setupMaterial();
486
-
487
- }
488
-
489
- /**
490
- * Update scene uniforms from SceneProcessor data
491
- */
492
- updateSceneUniforms() {
493
-
494
- // Set data references
495
- this.setTriangleData( this.sdfs.triangleData, this.sdfs.triangleCount );
496
- this.setBVHData( this.sdfs.bvhData );
497
- this.setInstanceTable( this.sdfs.instanceTable );
498
- this.materialData.setMaterialData( this.sdfs.materialData );
499
-
500
- // Material texture arrays
501
- this.materialData.loadTexturesFromSdfs();
502
-
503
- // Emissive triangles (storage buffer)
504
- if ( this.sdfs.emissiveTriangleData ) {
505
-
506
- this.setEmissiveTriangleData( this.sdfs.emissiveTriangleData, this.sdfs.emissiveTriangleCount || 0 );
507
-
508
- } else {
509
-
510
- this.emissiveTriangleCount.value = 0;
511
-
512
- }
513
-
514
- // Light BVH
515
- if ( this.sdfs.lightBVHNodeData ) {
516
-
517
- this.setLightBVHData( this.sdfs.lightBVHNodeData, this.sdfs.lightBVHNodeCount || 0 );
518
-
519
- } else {
520
-
521
- this.lightBVHNodeCount.value = 0;
522
-
523
- }
524
-
525
- // Per-mesh visibility — collect meshes from scene ordered by meshIndex
526
- this._meshRefs = this._collectMeshRefs( this.scene );
527
- this.setMeshVisibilityData( this._meshRefs );
528
-
529
- // Spheres
530
- this.spheres = this.sdfs.spheres || [];
531
-
532
- }
533
-
534
- /**
535
- * Update lights from scene
536
- */
537
- updateLights() {
538
-
539
- // Process scene lights
540
- const mockMaterial = {
541
- uniforms: {
542
- directionalLights: { value: null },
543
- pointLights: { value: null },
544
- spotLights: { value: null },
545
- areaLights: { value: null }
546
- },
547
- defines: {}
548
- };
549
-
550
- this.lightSerializer.processSceneLights( this.scene, mockMaterial );
551
-
552
- // Store light data
553
- this.directionalLightsData = mockMaterial.uniforms.directionalLights.value;
554
- this.pointLightsData = mockMaterial.uniforms.pointLights.value;
555
- this.spotLightsData = mockMaterial.uniforms.spotLights.value;
556
- this.areaLightsData = mockMaterial.uniforms.areaLights.value;
557
-
558
- // Add sun as directional light if procedural sky is active
559
- if ( this.hasSun.value ) {
560
-
561
- const scaledSunIntensity = this.environment.envParams.skySunIntensity * 950.0;
562
-
563
- const sunLight = {
564
- intensity: scaledSunIntensity,
565
- color: { r: 1.0, g: 1.0, b: 1.0 },
566
- userData: {
567
- angle: this.sunAngularSize.value
568
- },
569
- updateMatrixWorld: () => {},
570
- getWorldPosition: ( target ) => {
571
-
572
- const sunDir = this.sunDirection.value;
573
- return target.set( sunDir.x, sunDir.y, sunDir.z ).multiplyScalar( 1e10 );
574
-
575
- }
576
- };
577
-
578
- this.lightSerializer.addDirectionalLight( sunLight );
579
- this.lightSerializer.preprocessLights();
580
- this.lightSerializer.updateShaderUniforms( mockMaterial );
581
-
582
- this.directionalLightsData = mockMaterial.uniforms.directionalLights.value;
583
-
584
- console.log( `Sun added as directional light (intensity: ${scaledSunIntensity.toFixed( 2 )})` );
585
-
586
- }
587
-
588
- // Update TSL uniform buffer nodes from raw Float32Array data
589
- this._updateLightBufferNodes();
590
-
591
- }
592
-
593
- /**
594
- * Update TSL uniformArray nodes with current light Float32Array data
595
- */
596
- _updateLightBufferNodes() {
597
-
598
- // Directional lights (12 floats per light — 8 light fields + gobo {index, signed intensity, scale, pad})
599
- if ( this.directionalLightsData && this.directionalLightsData.length > 0 ) {
600
-
601
- this.directionalLightsBufferNode.array = Array.from( this.directionalLightsData );
602
- this.numDirectionalLights.value = Math.floor( this.directionalLightsData.length / 12 );
603
-
604
- } else {
605
-
606
- this.numDirectionalLights.value = 0;
607
-
608
- }
609
-
610
- // Area lights (13 floats per light)
611
- if ( this.areaLightsData && this.areaLightsData.length > 0 ) {
612
-
613
- this.areaLightsBufferNode.array = Array.from( this.areaLightsData );
614
- this.numAreaLights.value = Math.floor( this.areaLightsData.length / 13 );
615
-
616
- } else {
617
-
618
- this.numAreaLights.value = 0;
619
-
620
- }
621
-
622
- // Point lights (9 floats per light)
623
- if ( this.pointLightsData && this.pointLightsData.length > 0 ) {
624
-
625
- this.pointLightsBufferNode.array = Array.from( this.pointLightsData );
626
- this.numPointLights.value = Math.floor( this.pointLightsData.length / 9 );
627
-
628
- } else {
629
-
630
- this.numPointLights.value = 0;
631
-
632
- }
633
-
634
- // Spot lights (20 floats per light — 14 light fields + gobo {idx, signed intensity} + IES {idx, intensity} + 2 reserved)
635
- if ( this.spotLightsData && this.spotLightsData.length > 0 ) {
636
-
637
- this.spotLightsBufferNode.array = Array.from( this.spotLightsData );
638
- this.numSpotLights.value = Math.floor( this.spotLightsData.length / 20 );
639
-
640
- } else {
641
-
642
- this.numSpotLights.value = 0;
643
-
644
- }
645
-
646
- }
647
-
648
- /**
649
- * Reset accumulation
650
- */
651
- reset() {
652
-
653
- this.frameCount = 0;
654
- this.frame.value = 0;
655
- this.hasPreviousAccumulated.value = 0;
656
- this.storageTextures.currentTarget = 0;
657
-
658
- // Reset tile manager
659
- this.tileManager.spiralOrder = this.tileManager.generateSpiralOrder( this.tileManager.tiles );
660
-
661
- // Update completion threshold
662
- this.updateCompletionThreshold();
663
- this.isComplete = false;
664
- this.performanceMonitor?.reset();
665
-
666
- this.lastRenderMode = - 1;
667
- this.tileCompletionFrame = 0;
668
-
669
- this.lastInteractionModeState = false;
670
-
671
- }
672
-
673
- /**
674
- * Set tile count for tiled rendering
675
- * @param {number} newTileCount
676
- */
677
- setTileCount( newTileCount ) {
678
-
679
- this.tileManager.setTileCount( newTileCount );
680
- this.updateCompletionThreshold();
681
- this.reset();
682
-
683
- }
684
-
685
- /**
686
- * Set render size
687
- * @param {number} width
688
- * @param {number} height
689
- */
690
- setSize( width, height ) {
691
-
692
- this.width = width;
693
- this.height = height;
694
-
695
- this.resolution.value.set( width, height );
696
- this.tileManager.setSize( width, height );
697
- this.createStorageTextures( width, height );
698
- this.shaderBuilder.setSize( width, height );
699
-
700
- }
701
-
702
- /**
703
- * Set accumulation enabled state
704
- * @param {boolean} enabled
705
- */
706
- setAccumulationEnabled( enabled ) {
707
-
708
- this.accumulationEnabled = enabled;
709
- this.enableAccumulation.value = enabled ? 1 : 0;
710
-
711
- }
712
-
713
- // ===== MANAGER DELEGATION METHODS =====
714
-
715
- enterInteractionMode() {
716
-
717
- this.cameraOptimizer?.enterInteractionMode();
718
-
719
- }
720
-
721
- setInteractionModeEnabled( enabled ) {
722
-
723
- this.cameraOptimizer?.setInteractionModeEnabled( enabled );
724
-
725
- }
726
-
727
- // ===== PROPERTY GETTERS =====
728
-
729
- get tiles() {
730
-
731
- return this.tileManager.tiles;
732
-
733
- }
734
-
735
- get interactionMode() {
736
-
737
- return this.cameraOptimizer?.isInInteractionMode() ?? false;
738
-
739
- }
740
-
741
- // ===== TEXTURE SETTERS =====
742
-
743
- /**
744
- * Sets the triangle data from raw Float32Array via storage buffer.
745
- * On first call, creates the storage buffer and node.
746
- * On subsequent calls, creates a new attribute with the correct size
747
- * and updates the storage node's value to preserve shader graph references.
748
- * @param {Float32Array} triangleData - Raw triangle data
749
- * @param {number} triangleCount - Number of triangles
750
- */
751
- setTriangleData( triangleData, triangleCount ) {
752
-
753
- if ( ! triangleData ) return;
754
-
755
- const vec4Count = triangleData.length / 4;
756
-
757
- if ( this.triangleStorageNode ) {
758
-
759
- // Create new attribute with correct size (old one is GC'd, backend WeakMap cleans up GPU buffer)
760
- this.triangleStorageAttr = new StorageInstancedBufferAttribute( triangleData, 4 );
761
-
762
- // Update storage node references (preserves compiled shader graph)
763
- this.triangleStorageNode.value = this.triangleStorageAttr;
764
- this.triangleStorageNode.bufferCount = vec4Count;
765
-
766
- } else {
767
-
768
- // First time: create storage buffer and node
769
- this.triangleStorageAttr = new StorageInstancedBufferAttribute( triangleData, 4 );
770
- this.triangleStorageNode = storage( this.triangleStorageAttr, 'vec4', vec4Count ).toReadOnly();
771
-
772
- }
773
-
774
- this.triangleCount = triangleCount;
775
-
776
- console.log( `PathTracer: ${this.triangleCount} triangles (storage buffer)` );
777
-
778
- }
779
-
780
- /**
781
- * Sets the BVH data from raw Float32Array via storage buffer.
782
- * @param {Float32Array} bvhImageData - Raw BVH data from DataTexture.image.data
783
- */
784
- setBVHData( bvhImageData ) {
785
-
786
- if ( ! bvhImageData ) return;
787
-
788
- const vec4Count = bvhImageData.length / 4;
789
-
790
- if ( this.bvhStorageNode ) {
791
-
792
- this.bvhStorageAttr = new StorageInstancedBufferAttribute( bvhImageData, 4 );
793
- this.bvhStorageNode.value = this.bvhStorageAttr;
794
- this.bvhStorageNode.bufferCount = vec4Count;
795
-
796
- } else {
797
-
798
- this.bvhStorageAttr = new StorageInstancedBufferAttribute( bvhImageData, 4 );
799
- this.bvhStorageNode = storage( this.bvhStorageAttr, 'vec4', vec4Count ).toReadOnly();
800
-
801
- }
802
-
803
- this.bvhNodeCount = Math.floor( vec4Count / BVH_VEC4_PER_NODE );
804
- console.log( `PathTracer: ${this.bvhNodeCount} BVH nodes (storage buffer)` );
805
-
806
- }
807
-
808
- /**
809
- * Bind the InstanceTable used to locate each mesh's TLAS leaf for in-place
810
- * visibility patching. Called by SceneProcessor during upload.
811
- * @param {import('../Processor/InstanceTable.js').InstanceTable} instanceTable
812
- */
813
- setInstanceTable( instanceTable ) {
814
-
815
- this._instanceTable = instanceTable;
816
-
817
- }
818
-
819
- /**
820
- * Initialize packed visibility for each mesh from current world-visibility.
821
- * Patches the TLAS leaf slots in the combined BVH buffer that was just uploaded.
822
- * @param {Array} meshes - Array of Three.js mesh objects, ordered by meshIndex
823
- */
824
- setMeshVisibilityData( meshes ) {
825
-
826
- if ( ! meshes || meshes.length === 0 || ! this._instanceTable ) return;
827
-
828
- for ( let i = 0; i < meshes.length; i ++ ) {
829
-
830
- this._patchTLASLeafVisibility( i, this._isWorldVisible( meshes[ i ] ) );
831
-
832
- }
833
-
834
- if ( this.bvhStorageAttr ) this.bvhStorageAttr.needsUpdate = true;
835
-
836
- }
837
-
838
- /**
839
- * Update visibility for a single mesh by patching its TLAS leaf slot [2].
840
- * @param {number} meshIndex
841
- * @param {boolean} visible
842
- */
843
- updateMeshVisibility( meshIndex, visible ) {
1
+ /**
2
+ * Wavefront path tracer — decomposed kernel dispatch (Extend → [Sort] → Shade → Compact per
3
+ * bounce, bookended by Generate + FinalWrite; DebugKernel for visMode). Extends PathTracerStage
4
+ * for shared engine/scene infrastructure (managers, uniforms, camera, lights, BVH, accumulation).
5
+ */
844
6
 
845
- if ( ! this._patchTLASLeafVisibility( meshIndex, visible ) ) return;
846
- if ( this.bvhStorageAttr ) this.bvhStorageAttr.needsUpdate = true;
7
+ import { uniform, texture, storage } from 'three/tsl';
8
+ import { StorageInstancedBufferAttribute } from 'three/webgpu';
9
+ import { PathTracerStage } from './PathTracerStage.js';
10
+ import { PackedRayBuffer, GBUFFER_STRIDE } from '../Processor/PackedRayBuffer.js';
11
+ import { QueueManager, COUNTER } from '../Processor/QueueManager.js';
12
+ import { VRAMTracker } from '../Processor/VRAMTracker.js';
13
+ import { KernelManager } from '../Processor/KernelManager.js';
14
+ import { buildGenerateKernel, GENERATE_WG_SIZE } from '../TSL/GenerateKernel.js';
15
+ import { buildExtendKernel, EXTEND_WG_SIZE } from '../TSL/ExtendKernel.js';
16
+ import { buildShadeKernel, SHADE_WG_SIZE } from '../TSL/ShadeKernel.js';
17
+ import { buildCompactKernel, buildCompactSubgroupKernel, COMPACT_WG_SIZE } from '../TSL/CompactKernel.js';
18
+ import { buildFinalWriteKernel, FINALWRITE_WG_SIZE } from '../TSL/FinalWriteKernel.js';
19
+ import { buildDebugKernel, DEBUG_WG_SIZE } from '../TSL/DebugKernel.js';
20
+ import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
21
+ import {
22
+ Fn, uint, atomicStore, atomicLoad, instanceIndex, If, Return,
23
+ } from 'three/tsl';
847
24
 
848
- }
25
+ export class PathTracer extends PathTracerStage {
849
26
 
850
- /**
851
- * Recompute world-visibility for all meshes and patch TLAS leaves in place.
852
- * Call this when group visibility changes at runtime.
853
- */
854
- updateAllMeshVisibility() {
27
+ constructor( renderer, scene, camera, options = {} ) {
855
28
 
856
- if ( ! this._meshRefs || ! this._instanceTable ) return;
29
+ super( renderer, scene, camera, options );
30
+ this.name = 'PathTracer';
857
31
 
858
- for ( let i = 0; i < this._meshRefs.length; i ++ ) {
32
+ this._packedBuffers = null;
33
+ this._queueManager = null;
34
+ this._kernelManager = null;
35
+ this._gBufferAttr = null; // per-pixel first-hit MRT (ND + albedo); see _buildWavefrontKernels
36
+ this._wavefrontReady = false;
859
37
 
860
- this._patchTLASLeafVisibility( i, this._isWorldVisible( this._meshRefs[ i ] ) );
38
+ // CPU sizes per-bounce kernels from last frame's survivor curve; kernels bound on ENTERING_COUNT so over-sizing is safe. (indirect dispatch not viable — three.js doesn't sync compute-written indirect buffers across submissions)
39
+ this._useDynamicDispatch = true;
861
40
 
862
- }
41
+ // Flag-gated off: perf-neutral vs atomic-append and adds a 'subgroups' feature dependency.
42
+ this._useSubgroupCompact = false;
863
43
 
864
- if ( this.bvhStorageAttr ) this.bvhStorageAttr.needsUpdate = true;
44
+ // Multi-sample pool: S=samplesPerPixel primary rays/pixel/frame (interactive-only, the pixel cap; else S=1). FinalWrite averages the S slots. Baked into kernels; _ensureSamplesPerPass() rebuilds on change.
45
+ this._multiSampleMaxPixels = ENGINE_DEFAULTS.wavefrontMultiSampleMaxPixels ?? 589824; // 768²
46
+ this._samplesPerPass = 1;
865
47
 
866
- }
48
+ this._lastBounceCounts = null;
49
+ // maxBounces the curve was measured at; the curve is ignored once this no longer matches (-1 = none).
50
+ this._lastBounceCountsBudget = - 1;
51
+ this._readbackPending = false;
52
+ this._readbackEveryNFrames = 4;
53
+ this._readbackFrameCounter = 0;
54
+ // Bumped on resolution change; a readback that resolves with a stale generation is dropped.
55
+ this._readbackGeneration = 0;
56
+ // 0.1% of primary ray count, floored at 100; -1 to disable. Updated per-scene in _buildWavefrontKernels.
57
+ this._bounceEarlyExitThreshold = 100;
867
58
 
868
- /**
869
- * Patch a single TLAS leaf's visibility flag in the combined BVH buffer.
870
- * Returns true if the patch was applied.
871
- * @private
872
- */
873
- _patchTLASLeafVisibility( meshIndex, visible ) {
59
+ this._wfRenderWidth = uniform( 1920, 'int' );
60
+ this._wfRenderHeight = uniform( 1080, 'int' );
61
+ this._wfMaxRayCount = uniform( 0, 'uint' );
62
+ this._wfCurrentBounce = uniform( 0, 'int' );
874
63
 
875
- const entry = this._instanceTable?.entries?.[ meshIndex ];
876
- if ( ! entry || entry.tlasLeafIndex < 0 || ! this.bvhStorageAttr ) return false;
64
+ // VRAM accounting providers are thunks reading CURRENT live resources,
65
+ // so they survive buffer/texture reallocation (resize, scene/material reload).
66
+ this.vramTracker = new VRAMTracker();
67
+ this._registerVRAMProviders();
877
68
 
878
- entry.visible = visible;
879
- this.bvhStorageAttr.array[ entry.tlasLeafIndex * 16 + 2 ] = visible ? 1.0 : 0.0;
880
- return true;
69
+ console.log( 'PathTracer: initialized (wavefront)' );
881
70
 
882
71
  }
883
72
 
884
- /**
885
- * Collect mesh references from scene, ordered by meshIndex (assigned during extraction).
886
- * @param {Object3D} scene
887
- * @returns {Array}
888
- * @private
889
- */
890
- _collectMeshRefs( scene ) {
73
+ _registerVRAMProviders() {
891
74
 
892
- if ( ! scene ) return [];
75
+ const t = this.vramTracker;
893
76
 
894
- const meshes = [];
895
- scene.traverse( obj => {
77
+ // Wavefront ray-state SoA buffers (rw/ro nodes share one GPU buffer per attr)
78
+ t.register( 'rays', () => {
896
79
 
897
- if ( obj.isMesh && obj.userData.meshIndex !== undefined ) {
898
-
899
- meshes[ obj.userData.meshIndex ] = obj;
900
-
901
- }
80
+ const a = this._packedBuffers?._attrs;
81
+ return a ? [ a.ray, a.rng, a.hit ] : null;
902
82
 
903
83
  } );
904
84
 
905
- return meshes;
906
-
907
- }
908
-
909
- /**
910
- * Walk the parent chain to determine world-space visibility.
911
- * @param {Object3D} object
912
- * @returns {boolean}
913
- * @private
914
- */
915
- _isWorldVisible( object ) {
916
-
917
- while ( object ) {
918
-
919
- if ( ! object.visible ) return false;
920
- object = object.parent;
921
-
922
- }
923
-
924
- return true;
925
-
926
- }
927
-
928
- // ===== FAST BUFFER UPDATES (BVH Refit / Animation) =====
929
-
930
- /**
931
- * Update an existing GPU storage buffer in-place (no reallocation).
932
- * @param {StorageInstancedBufferAttribute} attr
933
- * @param {Float32Array} data
934
- * @private
935
- */
936
- _updateStorageBuffer( attr, data ) {
937
-
938
- if ( ! attr ) return;
939
- attr.array.set( data );
940
- attr.needsUpdate = true;
941
-
942
- }
943
-
944
- /** Update triangle positions in the existing GPU buffer (full). */
945
- updateTriangleData( triangleData ) {
946
-
947
- this._updateStorageBuffer( this.triangleStorageAttr, triangleData );
948
-
949
- }
950
-
951
- /** Update BVH node data in the existing GPU buffer (full). */
952
- updateBVHData( bvhData ) {
953
-
954
- this._updateStorageBuffer( this.bvhStorageAttr, bvhData );
955
-
956
- }
957
-
958
- /**
959
- * Update only specific ranges of the GPU storage buffers.
960
- * Uses addUpdateRange for partial GPU upload instead of full buffer copy.
961
- *
962
- * @param {Array<{offset: number, count: number}>} triRanges - Dirty triangle ranges (element index + count)
963
- * @param {Array<{offset: number, count: number}>} bvhRanges - Dirty BVH node ranges (element index + count)
964
- */
965
- updateBufferRanges( triRanges, bvhRanges ) {
966
-
967
- if ( this.triangleStorageAttr && triRanges.length > 0 ) {
968
-
969
- this.triangleStorageAttr.clearUpdateRanges();
970
-
971
- for ( const r of triRanges ) {
972
-
973
- this.triangleStorageAttr.addUpdateRange( r.offset, r.count );
85
+ // Queue indices + atomic counters
86
+ t.register( 'queues', () => {
974
87
 
975
- }
976
-
977
- this.triangleStorageAttr.version ++;
978
-
979
- }
980
-
981
- if ( this.bvhStorageAttr && bvhRanges.length > 0 ) {
982
-
983
- this.bvhStorageAttr.clearUpdateRanges();
984
-
985
- for ( const r of bvhRanges ) {
986
-
987
- this.bvhStorageAttr.addUpdateRange( r.offset, r.count );
988
-
989
- }
990
-
991
- this.bvhStorageAttr.version ++;
992
-
993
- }
994
-
995
- }
996
-
997
- // ===== STORAGE TEXTURES =====
998
-
999
- /**
1000
- * Creates storage textures for compute accumulation.
1001
- * @param {number} width
1002
- * @param {number} height
1003
- */
1004
- createStorageTextures( width, height ) {
1005
-
1006
- if ( this.storageTextures.writeColor ) {
1007
-
1008
- // Resize existing textures — preserves JS object references
1009
- // so the compiled compute node's bindings remain valid
1010
- this.storageTextures.setSize( width, height );
1011
-
1012
- } else {
1013
-
1014
- // Initial creation
1015
- this.storageTextures.create( width, height );
1016
-
1017
- }
1018
-
1019
- // Update resolution uniform
1020
- this.resolution.value.set( width, height );
1021
-
1022
- }
1023
-
1024
- // ===== MATERIAL SETUP =====
1025
-
1026
- /**
1027
- * Creates the path tracing material and quad.
1028
- * On subsequent calls (after the first), updates texture node values
1029
- * in-place instead of rebuilding the entire shader to avoid TSL/WGSL
1030
- * compilation failures from duplicate variable names.
1031
- */
1032
- setupMaterial() {
1033
-
1034
- // Ensure camera optimizer exists (build() creates it, but loadSceneData() skips build())
1035
- if ( ! this.cameraOptimizer ) {
1036
-
1037
- this._initCameraOptimizer();
88
+ const qm = this._queueManager;
89
+ if ( ! qm ) return null;
90
+ return [
91
+ qm._countersAttr, qm._bounceCountsAttr,
92
+ qm._attrA, qm._attrB,
93
+ ];
1038
94
 
1039
- }
95
+ } );
1040
96
 
1041
- if ( ! this.triangleStorageNode ) {
97
+ // Per-pixel first-hit G-buffer (normal/depth + albedo)
98
+ t.register( 'gbuffer', () => this._gBufferAttr ? [ this._gBufferAttr ] : null );
1042
99
 
1043
- console.error( 'PathTracer: Triangle data required' );
1044
- return;
100
+ // Accumulation pool: 3 write StorageTextures (2048²) + readable MRT RenderTarget
101
+ t.register( 'accum', () => {
1045
102
 
1046
- }
103
+ const sp = this.storageTextures;
104
+ return sp ? [ sp.writeColor, sp.writeNormalDepth, sp.writeAlbedo, sp.readTarget ] : null;
1047
105
 
1048
- if ( ! this.bvhStorageNode ) {
106
+ } );
1049
107
 
1050
- console.error( 'PathTracer: BVH data required' );
1051
- return;
108
+ // Scene geometry (triangle data, two-level BVH, light BVH + emissive)
109
+ t.register( 'geometry', () => [ this.triangleStorageAttr, this.bvhStorageAttr, this.lightStorageAttr ] );
1052
110
 
1053
- }
111
+ // Material storage buffer + per-property texture arrays
112
+ t.register( 'materials', () => {
1054
113
 
1055
- // If compute nodes already exist, update texture nodes in-place
1056
- // instead of rebuilding the shader (avoids TSL recompilation issues)
1057
- if ( this.isReady && this.shaderBuilder.getSceneTextureNodes() ) {
114
+ const m = this.materialData;
115
+ if ( ! m ) return null;
116
+ return [
117
+ m.materialStorageAttr,
118
+ m.albedoMaps, m.emissiveMaps, m.normalMaps, m.bumpMaps,
119
+ m.roughnessMaps, m.metalnessMaps, m.displacementMaps,
120
+ ];
1058
121
 
1059
- this.shaderBuilder.updateSceneTextures( this );
1060
- return;
122
+ } );
1061
123
 
1062
- }
124
+ // Environment map + importance-sampling CDF
125
+ t.register( 'environment', () => {
1063
126
 
1064
- this._ensureStorageTextures();
127
+ const e = this.environment;
128
+ return e ? [ e.environmentTexture, e.envCDFTexture ] : null;
1065
129
 
1066
- this.shaderBuilder.setupCompute( {
1067
- stage: this,
1068
- storageTextures: this.storageTextures,
1069
130
  } );
1070
131
 
1071
- this.isReady = true;
1072
-
1073
132
  }
1074
133
 
1075
- /**
1076
- * Ensure storage textures exist at correct size
1077
- */
1078
- _ensureStorageTextures() {
134
+ setupMaterial() {
1079
135
 
1080
- const canvas = this.renderer.domElement;
1081
- const width = Math.max( 1, canvas.width || this.width );
1082
- const height = Math.max( 1, canvas.height || this.height );
136
+ super.setupMaterial();
1083
137
 
1084
- if ( this.storageTextures.ensureSize( width, height ) ) {
138
+ // First setupMaterial call has 0 triangles/materials — skip it.
139
+ if ( this.materialData?.materialCount > 0 ) {
1085
140
 
1086
- this.resolution.value.set( width, height );
141
+ if ( this._kernelManager ) this._kernelManager.dispose();
142
+ this._wavefrontReady = false;
143
+ this._buildWavefrontKernels();
1087
144
 
1088
145
  }
1089
146
 
1090
147
  }
1091
148
 
1092
- // ===== CORE RENDER METHOD =====
1093
-
1094
- /**
1095
- * Renders the path tracing pass with accumulation.
1096
- * @param {PipelineContext} context - Pipeline context
1097
- */
1098
149
  render( context ) {
1099
150
 
1100
- if ( ! this.isReady ) return;
151
+ // Kernels not built yet (first frame / mid-resize) — skip until ready.
152
+ if ( ! this.isReady || ! this._wavefrontReady ) return;
1101
153
 
1102
- // Early exit conditions
1103
154
  if ( this.isComplete || this.frameCount >= this.completionThreshold ) {
1104
155
 
1105
156
  if ( ! this.isComplete ) this.isComplete = true;
@@ -1109,26 +160,12 @@ export class PathTracer extends RenderStage {
1109
160
 
1110
161
  this.performanceMonitor?.start();
1111
162
 
1112
- // Read adaptive sampling guidance from pipeline context (produced by AdaptiveSampling)
1113
- if ( context && this.shaderBuilder.adaptiveSamplingTexNode ) {
1114
-
1115
- const asTex = context.getTexture( 'adaptiveSampling:output' );
1116
- if ( asTex ) {
1117
-
1118
- this.shaderBuilder.adaptiveSamplingTexNode.value = asTex;
1119
-
1120
- }
1121
-
1122
- }
1123
-
1124
163
  const frameValue = this.frameCount;
1125
164
  const renderMode = this.renderMode.value;
1126
165
 
1127
- // Store original rendering parameters for first frame override in tile mode
1128
166
  let originalMaxBounces = null;
1129
167
  let originalSamplesPerPixel = null;
1130
168
 
1131
- // In tile rendering mode, cap the first frame at 1spp and 1 bounce
1132
169
  if ( renderMode === 1 && frameValue === 0 ) {
1133
170
 
1134
171
  originalMaxBounces = this.maxBounces.value;
@@ -1138,75 +175,20 @@ export class PathTracer extends RenderStage {
1138
175
 
1139
176
  }
1140
177
 
1141
- // Handle resize
1142
178
  this._handleResize();
179
+ this._ensureSamplesPerPass();
180
+ this.manageASVGFForRenderMode( renderMode );
1143
181
 
1144
- // Handle ASVGF denoising
1145
- this.manageASVGFForRenderMode( renderMode, frameValue );
1146
-
1147
- // Handle tile rendering
1148
- const tileInfo = this.tileManager.handleTileRendering(
1149
- this.renderer,
1150
- renderMode,
1151
- frameValue,
1152
- null
1153
- );
1154
-
1155
- // Publish tile state to context
1156
- if ( context ) {
1157
-
1158
- context.setState( 'tileRenderingComplete', tileInfo.isCompleteCycle );
1159
-
1160
- }
1161
-
1162
- // Emit tile:changed event
1163
- if ( tileInfo.tileIndex >= 0 ) {
1164
-
1165
- const tileBounds = this.tileManager.calculateTileBounds(
1166
- tileInfo.tileIndex,
1167
- this.tileManager.tiles,
1168
- this.width,
1169
- this.height
1170
- );
1171
-
1172
- this.emit( 'tile:changed', {
1173
- tileIndex: tileInfo.tileIndex,
1174
- tileBounds: tileBounds,
1175
- renderMode: renderMode
1176
- } );
1177
-
1178
- this.tileChanged = true;
1179
-
1180
- }
182
+ // Full-frame render is always a complete cycle (PER_CYCLE stages gate on this).
183
+ if ( context ) context.setState( 'tileRenderingComplete', true );
1181
184
 
1182
- // Update camera and movement optimization
1183
185
  this.cameraChanged = this._updateCameraUniforms();
1184
186
  this.cameraOptimizer?.updateInteractionMode( this.cameraChanged );
1185
-
1186
- // Update accumulation state
1187
187
  this._updateAccumulationUniforms( frameValue, renderMode );
1188
-
1189
- // Update frame uniform
1190
188
  this.frame.value = frameValue;
1191
189
 
1192
- // Set dispatch region — tile-only dispatch for tiled mode, full-screen otherwise
1193
- if ( tileInfo.tileIndex >= 0 && tileInfo.tileBounds ) {
1194
-
1195
- // Dispatch only the workgroups covering this tile
1196
- this.shaderBuilder.setTileDispatch(
1197
- tileInfo.tileBounds.x, tileInfo.tileBounds.y,
1198
- tileInfo.tileBounds.width, tileInfo.tileBounds.height
1199
- );
1200
-
1201
- } else {
1202
-
1203
- // Full-screen render — dispatch all workgroups
1204
- this.shaderBuilder.setFullScreenDispatch();
1205
-
1206
- }
190
+ this._setWfDispatch();
1207
191
 
1208
- // Update previous-frame texture node values from readTarget
1209
- // (these sample the last frame's results via texture())
1210
192
  const readTextures = this.storageTextures.getReadTextures();
1211
193
  if ( this.shaderBuilder.prevColorTexNode ) {
1212
194
 
@@ -1216,498 +198,731 @@ export class PathTracer extends RenderStage {
1216
198
 
1217
199
  }
1218
200
 
1219
- // Dispatch single compute node
1220
- this.renderer.compute( this.shaderBuilder.computeNode );
1221
-
1222
- // Copy StorageTextures → RenderTarget textures for downstream reads
1223
- this.storageTextures.copyToReadTargets( this.renderer );
1224
-
1225
- // Publish readable textures to context for downstream stages
1226
- const readTex = this.storageTextures.getReadTextures();
1227
- if ( context ) {
1228
-
1229
- this._publishTexturesToContext( context, readTex );
201
+ // Wavefront's texture nodes are independent; monolithic's updateSceneTextures doesn't reach them.
202
+ this._refreshWfTextureNodes();
1230
203
 
1231
- }
204
+ const km = this._kernelManager;
1232
205
 
1233
- // Emit state events
1234
- this._emitStateEvents();
206
+ // Debug visualization (visMode 1-10): single-pass primary-ray kernel — no bounce loop or
207
+ // accumulation. Mode 11 (NaN/Inf) flows through the normal pipeline below; FinalWrite flags it.
208
+ if ( ( this.visMode?.value | 0 ) > 0 && this.visMode.value !== 11 ) {
1235
209
 
1236
- // Only count frames toward completion when accumulating.
1237
- // Interaction-mode frames provide visual feedback but should not
1238
- // consume the sample budget — otherwise the render "completes"
1239
- // with N frames of 1-SPP noise before the timeout exits.
1240
- if ( ! ( this.cameraOptimizer?.isInInteractionMode() ) ) {
210
+ km.dispatch( 'debug' );
1241
211
 
1242
- this.frameCount ++;
212
+ this.storageTextures.copyToReadTargets( this.renderer );
213
+ const dbgReadTex = this.storageTextures.getReadTextures();
214
+ if ( context ) this._publishTexturesToContext( context, dbgReadTex );
1243
215
 
1244
- }
216
+ this._emitStateEvents();
217
+ // Don't count interaction-mode (1-SPP feedback) frames toward completion (megakernel parity Stages/PathTracer.js:1240) — else a continuous orbit "completes" on noise.
218
+ if ( ! this.cameraOptimizer?.isInInteractionMode() ) this.frameCount ++;
1245
219
 
1246
- // Restore original values
1247
- if ( originalMaxBounces !== null ) {
220
+ if ( originalMaxBounces !== null ) this.maxBounces.value = originalMaxBounces;
221
+ if ( originalSamplesPerPixel !== null ) this.samplesPerPixel.value = originalSamplesPerPixel;
1248
222
 
1249
- this.maxBounces.value = originalMaxBounces;
223
+ this.performanceMonitor?.end();
224
+ return;
1250
225
 
1251
226
  }
1252
227
 
1253
- if ( originalSamplesPerPixel !== null ) {
228
+ km.dispatch( 'resetCounters' );
229
+ km.dispatch( 'generate' );
230
+ // Generate traces every pixel; seed ENTERING_COUNT from the full identity active list.
231
+ km.dispatch( 'initActiveIndices' );
1254
232
 
1255
- this.samplesPerPixel.value = originalSamplesPerPixel;
233
+ const maxBounces = this.maxBounces.value;
234
+ // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
235
+ const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
236
+ const maxRays = this._wfMaxRayCount.value;
1256
237
 
1257
- }
238
+ // The survivor curve survives a maxBounces change (reset() preserves it), and is reusable
239
+ // across one: for loop iterations below BOTH the old and new camera-bounce caps, no ray has
240
+ // been killed by either cap, so the counts are cap-independent. Trust the curve up to that
241
+ // cutoff — the whole curve when same-budget or decreasing (old counts only over-estimate, so
242
+ // sizing over-sizes and early-exit fires later — both safe); only the overlap [0, oldBudget)
243
+ // when increasing (beyond it the old cap already culled rays → under-estimate → would drop
244
+ // rays). Past the cutoff, full dispatch + no early-exit. Avoids the full-work spike (a visible
245
+ // hitch) on every bounce-count change while a fresh curve is read back. budget=-1 (no curve,
246
+ // e.g. cold start / post-resize) → cutoff 0 → full dispatch everywhere, matching prior behavior.
247
+ const curve = this._lastBounceCounts;
248
+ const curveReliableUpto = curve
249
+ ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
250
+ : 0;
1258
251
 
1259
- this.performanceMonitor?.end();
252
+ for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
1260
253
 
1261
- }
254
+ this._wfCurrentBounce.value = bounce;
1262
255
 
1263
- /**
1264
- * Handle canvas resize
1265
- */
1266
- _handleResize() {
256
+ // Functional-compaction path (dynamic dispatch): copyback keeps the read buffer dense, kernels sized to live survivors. Dynamic-off uses the full path (ENTERING=maxRays, identity buffer).
257
+ const useFunctionalCompaction = this._useDynamicDispatch;
258
+ if ( useFunctionalCompaction ) {
1267
259
 
1268
- const canvas = this.renderer.domElement;
1269
- const { width, height } = canvas;
260
+ // ENTERING_COUNT already set (bounce 0 by initActiveIndices, N>0 by snapshotBounceCount); size from last frame's survivor curve with a 1.5×+1024 margin.
261
+ let entering = maxRays;
262
+ if ( bounce > 0 ) {
1270
263
 
1271
- if ( width !== this.storageTextures.renderWidth || height !== this.storageTextures.renderHeight ) {
264
+ const idx = bounce - 1;
265
+ let prev;
266
+ if ( idx < curveReliableUpto && curve[ idx ] !== undefined ) {
1272
267
 
1273
- this.createStorageTextures( width, height );
1274
- this.shaderBuilder.setSize( width, height );
1275
- this.frameCount = 0;
268
+ prev = curve[ idx ]; // trusted exact count
1276
269
 
1277
- }
270
+ } else if ( curveReliableUpto > 0 ) {
1278
271
 
1279
- this.resolution.value.set( width, height );
272
+ // Untrusted tail after a maxBounces increase: survivor counts are monotonically
273
+ // non-increasing across bounces (rays only terminate), so the last trusted count is
274
+ // a safe upper bound — far below maxRays, so no full-dispatch spike. Safe even if it
275
+ // reads low: a count <= threshold would have tripped the early-exit before this bounce.
276
+ prev = curve[ curveReliableUpto - 1 ];
1280
277
 
1281
- }
278
+ }
1282
279
 
1283
- /**
1284
- * Compare two Matrix4 with tolerance to avoid false positives from
1285
- * floating-point drift (e.g. OrbitControls spherical↔cartesian round-trips).
1286
- * @param {Matrix4} a
1287
- * @param {Matrix4} b
1288
- * @param {number} epsilon
1289
- * @returns {boolean} True if matrices are approximately equal
1290
- */
1291
- _matricesApproxEqual( a, b, epsilon = 1e-10 ) {
280
+ entering = prev > 0 ? prev : maxRays;
1292
281
 
1293
- const ae = a.elements;
1294
- const be = b.elements;
1295
- for ( let i = 0; i < 16; i ++ ) {
282
+ }
1296
283
 
1297
- if ( Math.abs( ae[ i ] - be[ i ] ) > epsilon ) return false;
284
+ const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
285
+ const wg = [ Math.ceil( sized / 256 ), 1, 1 ];
286
+ km.setDispatchCount( 'extend', wg );
287
+ km.setDispatchCount( 'shade', wg );
288
+ km.setDispatchCount( 'compact', wg );
289
+ km.setDispatchCount( 'compactCopyback', wg );
1298
290
 
1299
- }
291
+ } else {
1300
292
 
1301
- return true;
293
+ km.dispatch( 'enterFull' );
294
+ const full = [ Math.ceil( maxRays / 256 ), 1, 1 ];
295
+ km.setDispatchCount( 'extend', full );
296
+ km.setDispatchCount( 'shade', full );
297
+ km.setDispatchCount( 'compact', full );
1302
298
 
1303
- }
299
+ }
1304
300
 
1305
- /**
1306
- * Update camera uniforms
1307
- * @returns {boolean} True if camera changed
1308
- */
1309
- _updateCameraUniforms() {
301
+ // Extend/Shade kept separate (not fused): a fused kernel's register pressure drops occupancy more than fusion saves.
302
+ km.dispatch( 'extend' );
303
+ km.dispatch( 'shade' );
1310
304
 
1311
- if ( ! this._matricesApproxEqual( this.lastCameraMatrix, this.camera.matrixWorld ) ||
1312
- ! this._matricesApproxEqual( this.lastProjectionMatrix, this.camera.projectionMatrixInverse ) ) {
305
+ km.dispatch( 'resetActiveCounter' );
306
+ km.dispatch( 'compact' );
307
+ if ( useFunctionalCompaction ) km.dispatch( 'compactCopyback' );
308
+ km.dispatch( 'snapshotBounceCount' );
309
+ // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
1313
310
 
1314
- this.cameraWorldMatrix.value.copy( this.camera.matrixWorld );
1315
- this.cameraViewMatrix.value.copy( this.camera.matrixWorldInverse );
1316
- this.cameraProjectionMatrix.value.copy( this.camera.projectionMatrix );
1317
- this.cameraProjectionMatrixInverse.value.copy( this.camera.projectionMatrixInverse );
311
+ // Early-exit on last frame's per-bounce snapshot (stale via async readback, fine for a heuristic).
312
+ if (
313
+ bounce < curveReliableUpto
314
+ && bounce < loopBound
315
+ && curve[ bounce ] !== undefined
316
+ && curve[ bounce ] <= this._bounceEarlyExitThreshold
317
+ ) {
1318
318
 
1319
- this.lastCameraMatrix.copy( this.camera.matrixWorld );
1320
- this.lastProjectionMatrix.copy( this.camera.projectionMatrixInverse );
319
+ break;
1321
320
 
1322
- return true;
321
+ }
1323
322
 
1324
323
  }
1325
324
 
1326
- return false;
1327
-
1328
- }
1329
-
1330
- /**
1331
- * Update accumulation uniforms
1332
- * @param {number} frameValue
1333
- * @param {number} renderMode
1334
- */
1335
- _updateAccumulationUniforms( frameValue, renderMode ) {
325
+ km.dispatch( 'finalWrite' );
1336
326
 
1337
- const currentInteractionMode = this.cameraOptimizer?.isInInteractionMode() ?? false;
1338
- this.lastInteractionModeState = currentInteractionMode;
327
+ this._maybeReadbackCounters();
1339
328
 
1340
- if ( this.accumulationEnabled ) {
329
+ this.storageTextures.copyToReadTargets( this.renderer );
1341
330
 
1342
- if ( currentInteractionMode ) {
331
+ const readTex = this.storageTextures.getReadTextures();
332
+ if ( context ) this._publishTexturesToContext( context, readTex );
1343
333
 
1344
- this.accumulationAlpha.value = 1.0;
1345
- this.hasPreviousAccumulated.value = 0;
334
+ this._emitStateEvents();
335
+ // Don't count interaction-mode (1-SPP feedback) frames toward completion (megakernel parity Stages/PathTracer.js:1240) else a continuous orbit "completes" on noise.
336
+ if ( ! this.cameraOptimizer?.isInInteractionMode() ) this.frameCount ++;
1346
337
 
1347
- } else {
338
+ if ( originalMaxBounces !== null ) this.maxBounces.value = originalMaxBounces;
339
+ if ( originalSamplesPerPixel !== null ) this.samplesPerPixel.value = originalSamplesPerPixel;
1348
340
 
1349
- this.accumulationAlpha.value = calculateAccumulationAlpha(
1350
- frameValue,
1351
- renderMode,
1352
- this.tileManager.totalTilesCache,
1353
- false
1354
- );
341
+ this.performanceMonitor?.end();
1355
342
 
1356
- this.hasPreviousAccumulated.value = frameValue > 0 ? 1 : 0;
343
+ }
1357
344
 
1358
- }
345
+ // Parent resizes storageTextures/shaderBuilder; wavefront also needs its buffers/uniforms/kernels rebuilt.
346
+ _handleResize() {
1359
347
 
1360
- } else {
348
+ const oldW = this.storageTextures.renderWidth;
349
+ const oldH = this.storageTextures.renderHeight;
1361
350
 
1362
- this.accumulationAlpha.value = 1.0;
1363
- this.hasPreviousAccumulated.value = 0;
351
+ super._handleResize();
1364
352
 
1365
- }
353
+ this._rebuildKernelsIfResized( oldW, oldH );
1366
354
 
1367
355
  }
1368
356
 
1369
- /**
1370
- * Publish textures to pipeline context
1371
- * @param {PipelineContext} context
1372
- * @param {Object} writeTex - The just-written StorageTexture set { color, normalDepth, albedo }
1373
- */
1374
- _publishTexturesToContext( context, writeTex ) {
357
+ // S=samplesPerPixel for interactive within the pixel cap; production/tiled and high-res get S=1.
358
+ _resolveSamplesPerPass( w, h ) {
1375
359
 
1376
- context.setTexture( 'pathtracer:color', writeTex.color );
1377
- context.setTexture( 'pathtracer:normalDepth', writeTex.normalDepth );
1378
- context.setTexture( 'pathtracer:albedo', writeTex.albedo );
1379
-
1380
- context.setState( 'interactionMode', this.cameraOptimizer?.isInInteractionMode() ?? false );
1381
- context.setState( 'renderMode', this.renderMode.value );
1382
- context.setState( 'tiles', this.tileManager.tiles );
360
+ const interactive = this.renderMode.value === 0;
361
+ const within = ( w * h ) <= this._multiSampleMaxPixels;
362
+ return ( interactive && within ) ? Math.max( 1, this.samplesPerPixel.value | 0 ) : 1;
1383
363
 
1384
364
  }
1385
365
 
1386
- /**
1387
- * Emit state change events
1388
- */
1389
- _emitStateEvents() {
1390
-
1391
- this.emit( 'pathtracer:frameComplete', {
1392
- frame: this.frameCount,
1393
- isComplete: this.isComplete
1394
- } );
366
+ // S is baked at build but samplesPerPixel/mode can change without a resize; rebuild when the implied S differs.
367
+ _ensureSamplesPerPass() {
1395
368
 
1396
- if ( this.cameraChanged ) {
369
+ if ( ! this._wavefrontReady ) return;
370
+ const w = this.storageTextures.renderWidth;
371
+ const h = this.storageTextures.renderHeight;
372
+ if ( this._resolveSamplesPerPass( w, h ) !== this._samplesPerPass ) {
1397
373
 
1398
- this.emit( 'camera:moved' );
1399
- this.cameraChanged = false;
374
+ this._wavefrontReady = false;
375
+ this._buildWavefrontKernels();
1400
376
 
1401
377
  }
1402
378
 
1403
379
  }
1404
380
 
1405
- /**
1406
- * Update completion threshold based on render mode
1407
- */
1408
- updateCompletionThreshold() {
1409
-
1410
- const renderMode = this.renderMode.value;
1411
- const maxFrames = this.maxSamples.value;
1412
-
1413
- if ( this.renderLimitMode === 'time' ) {
1414
-
1415
- this.completionThreshold = Infinity;
381
+ // UI-driven resize (Resolution dropdown) — parent bypasses _handleResize(), so hook here too.
382
+ setSize( width, height ) {
1416
383
 
1417
- } else {
384
+ const oldW = this.storageTextures.renderWidth;
385
+ const oldH = this.storageTextures.renderHeight;
1418
386
 
1419
- this.completionThreshold = updateCompletionThreshold(
1420
- renderMode,
1421
- maxFrames,
1422
- this.tileManager.totalTilesCache
1423
- );
387
+ super.setSize( width, height );
1424
388
 
1425
- }
389
+ this._rebuildKernelsIfResized( oldW, oldH );
1426
390
 
1427
391
  }
1428
392
 
1429
- setRenderLimitMode( mode ) {
393
+ // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
394
+ _maybeReadbackCounters() {
1430
395
 
1431
- this.renderLimitMode = mode;
1432
- this.updateCompletionThreshold();
1433
-
1434
- }
396
+ if ( this._readbackPending ) return;
1435
397
 
1436
- // ===== ASVGF DENOISING MANAGEMENT =====
398
+ this._readbackFrameCounter ++;
399
+ if ( this._readbackFrameCounter < this._readbackEveryNFrames ) return;
400
+ this._readbackFrameCounter = 0;
1437
401
 
1438
- manageASVGFForRenderMode( renderMode, frameValue ) {
402
+ const attr = this._queueManager?.getBounceCountsAttribute();
403
+ if ( ! attr ) return;
1439
404
 
1440
- if ( renderMode !== this.lastRenderMode ) {
405
+ this._readbackPending = true;
406
+ const gen = this._readbackGeneration;
407
+ const budget = this.maxBounces.value;
408
+ this.renderer.getArrayBufferAsync( attr ).then( ( buf ) => {
1441
409
 
1442
- if ( this.renderModeChangeTimeout ) {
410
+ // Drop counts measured at a now-stale resolution (a resize happened mid-flight).
411
+ if ( gen === this._readbackGeneration ) {
1443
412
 
1444
- clearTimeout( this.renderModeChangeTimeout );
413
+ this._lastBounceCounts = new Uint32Array( buf.slice( 0 ) );
414
+ this._lastBounceCountsBudget = budget;
1445
415
 
1446
416
  }
1447
417
 
1448
- this.pendingRenderMode = renderMode;
1449
-
1450
- this.renderModeChangeTimeout = setTimeout( () => {
418
+ this._readbackPending = false;
1451
419
 
1452
- if ( this.pendingRenderMode !== null && this.pendingRenderMode !== this.lastRenderMode ) {
420
+ } ).catch( ( e ) => {
1453
421
 
1454
- this.lastRenderMode = this.pendingRenderMode;
1455
- this._onRenderModeChanged( this.pendingRenderMode );
422
+ console.warn( 'Wavefront bounceCounts readback failed:', e );
423
+ this._readbackPending = false;
1456
424
 
1457
- }
425
+ } );
1458
426
 
1459
- this.renderModeChangeTimeout = null;
1460
- this.pendingRenderMode = null;
427
+ }
1461
428
 
1462
- }, this.renderModeChangeDelay );
429
+ // Sync wavefront's texture nodes with current env/material textures; only a changed ref triggers GPU rebind.
430
+ _refreshWfTextureNodes() {
1463
431
 
1464
- }
432
+ const t = this._wfTexNodes;
433
+ if ( ! t ) return;
1465
434
 
1466
- if ( renderMode === 1 ) {
435
+ const env = this.environment?.environmentTexture;
436
+ if ( env && t.envTex ) t.envTex.value = env;
437
+ // CDF texture is replaced (new DataTexture) on each HDRI/env build — repoint the node.
438
+ if ( this.environment?.envCDFTexture && t.envCDFTex ) t.envCDFTex.value = this.environment.envCDFTexture;
1467
439
 
1468
- this._handleTiledASVGF( frameValue );
440
+ const mat = this.materialData;
441
+ if ( ! mat ) return;
442
+ if ( mat.albedoMaps && t.albedoMaps ) t.albedoMaps.value = mat.albedoMaps;
443
+ if ( mat.normalMaps && t.normalMaps ) t.normalMaps.value = mat.normalMaps;
444
+ if ( mat.bumpMaps && t.bumpMaps ) t.bumpMaps.value = mat.bumpMaps;
445
+ if ( mat.metalnessMaps && t.metalnessMaps ) t.metalnessMaps.value = mat.metalnessMaps;
446
+ if ( mat.roughnessMaps && t.roughnessMaps ) t.roughnessMaps.value = mat.roughnessMaps;
447
+ if ( mat.emissiveMaps && t.emissiveMaps ) t.emissiveMaps.value = mat.emissiveMaps;
448
+ if ( mat.displacementMaps && t.displacementMaps ) t.displacementMaps.value = mat.displacementMaps;
1469
449
 
1470
- } else {
450
+ }
1471
451
 
1472
- this._handleFullQuadASVGF();
452
+ _rebuildKernelsIfResized( oldW, oldH ) {
1473
453
 
1474
- }
454
+ const newW = this.storageTextures.renderWidth;
455
+ const newH = this.storageTextures.renderHeight;
456
+ if ( ( newW === oldW && newH === oldH ) || ! ( this.materialData?.materialCount > 0 ) ) return;
1475
457
 
1476
- }
458
+ // A survivor curve from the old resolution mis-sizes the per-bounce dispatch at the new one
459
+ // (row-major active list → under-coverage of the lower rows → GI band). Force full coverage
460
+ // until the readback re-measures at the new size; bump the generation so any readback already
461
+ // in flight (carrying the old-resolution counts) is discarded when it resolves.
462
+ this._lastBounceCounts = null;
463
+ this._lastBounceCountsBudget = - 1;
464
+ this._readbackFrameCounter = 0;
465
+ this._readbackGeneration ++;
1477
466
 
1478
- _onRenderModeChanged( newMode ) {
467
+ // Recompile only when buffers reallocate (capacity grows) or S changes; otherwise resize uniforms in place.
468
+ const newS = this._resolveSamplesPerPass( newW, newH );
469
+ const neededCap = PackedRayBuffer.requiredCapacity( newW * newH * newS );
470
+ const mustRebuild = ! this._packedBuffers
471
+ || neededCap > this._packedBuffers.capacity
472
+ || newS !== this._samplesPerPass;
1479
473
 
1480
- if ( newMode === 1 ) {
474
+ if ( mustRebuild ) {
1481
475
 
1482
- this.emit( 'asvgf:updateParameters', {
1483
- enableDebug: false,
1484
- temporalAlpha: 0.15
1485
- } );
476
+ if ( this._kernelManager ) this._kernelManager.dispose();
477
+ this._wavefrontReady = false;
478
+ this._buildWavefrontKernels();
1486
479
 
1487
480
  } else {
1488
481
 
1489
- this.emit( 'asvgf:updateParameters', {
1490
- temporalAlpha: 0.1,
1491
- } );
482
+ this._resizeWavefrontInPlace( newW, newH );
1492
483
 
1493
484
  }
1494
485
 
1495
- this.emit( 'asvgf:reset' );
1496
-
1497
486
  }
1498
487
 
1499
- _handleTiledASVGF( frameValue ) {
1500
-
1501
- const isFirstFrame = frameValue === 0;
1502
- const currentTileIndex = isFirstFrame ? - 1 : ( ( frameValue - 1 ) % this.tileManager.totalTilesCache );
1503
- const isLastTileInSample = currentTileIndex === this.tileManager.totalTilesCache - 1;
1504
-
1505
- if ( isFirstFrame ) {
1506
-
1507
- this.emit( 'asvgf:setTemporal', { enabled: true } );
488
+ // Same-capacity, same-S resize: update render-size uniforms + early-exit threshold, no recompile.
489
+ _resizeWavefrontInPlace( w, h ) {
1508
490
 
1509
- } else if ( isLastTileInSample ) {
491
+ const maxRays = w * h * this._samplesPerPass;
492
+ this._wfRenderWidth.value = w;
493
+ this._wfRenderHeight.value = h;
494
+ this._wfMaxRayCount.value = maxRays;
495
+ // initActiveIndices is dispatched bare (not re-sized per frame); its grid must cover the grown range or the identity buffer is left unseeded over [oldMaxRays, maxRays).
496
+ this._kernelManager?.setDispatchCount( 'initActiveIndices', [ Math.ceil( maxRays / 256 ), 1, 1 ] );
497
+ if ( this._bounceEarlyExitThreshold !== - 1 ) {
1510
498
 
1511
- this.emit( 'asvgf:setTemporal', { enabled: true } );
1512
- this.tileCompletionFrame = frameValue;
1513
-
1514
- } else {
1515
-
1516
- this.emit( 'asvgf:setTemporal', { enabled: false } );
499
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
1517
500
 
1518
501
  }
1519
502
 
1520
503
  }
1521
504
 
1522
- _handleFullQuadASVGF() {
505
+ _buildWavefrontKernels() {
1523
506
 
1524
- this.emit( 'asvgf:setTemporal', { enabled: true } );
507
+ const texNodes = this.shaderBuilder.getSceneTextureNodes();
508
+ if ( ! texNodes ) return;
1525
509
 
1526
- }
510
+ const w = this.storageTextures.renderWidth;
511
+ const h = this.storageTextures.renderHeight;
512
+ // maxRays = pool capacity (pixels × S); all downstream sizing scales off it, so S propagates for free.
513
+ this._samplesPerPass = this._resolveSamplesPerPass( w, h );
514
+ const S = this._samplesPerPass | 0;
515
+ const maxRaysPerSample = w * h;
516
+ const maxRays = maxRaysPerSample * S;
1527
517
 
1528
- // ===== UNIFORM & DATA SETTERS =====
518
+ if ( this._bounceEarlyExitThreshold !== - 1 ) {
1529
519
 
1530
- /**
1531
- * Generic uniform setter. Handles booleans (→ int 0/1),
1532
- * vectors/matrices (→ .copy()), and plain scalars automatically.
1533
- * @param {string} name - Uniform name (e.g. 'maxBounces', 'showBackground')
1534
- * @param {*} value
1535
- */
1536
- setUniform( name, value ) {
520
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
1537
521
 
1538
- this.uniforms.set( name, value );
522
+ }
1539
523
 
1540
- }
524
+ if ( ! this._packedBuffers ) {
1541
525
 
1542
- setBlueNoiseTexture( tex ) {
526
+ this._packedBuffers = new PackedRayBuffer( maxRays );
1543
527
 
1544
- // Legacy API — sets the scalar STBN atlas texture
1545
- this.stbnScalarTexture = tex;
1546
- if ( tex ) stbnScalarTextureNode.value = tex;
528
+ } else {
1547
529
 
1548
- }
530
+ this._packedBuffers.resize( maxRays );
1549
531
 
1550
- /**
1551
- * Rebuild the packed light buffer from cached lightBVH + emissive data.
1552
- * Layout: [ lightBVH (LBVH_STRIDE vec4s per node) | emissive (EMISSIVE_STRIDE vec4s per entry) ].
1553
- * Also updates `emissiveVec4Offset` uniform (in vec4 elements).
1554
- * @private
1555
- */
1556
- _rebuildLightBuffer() {
1557
-
1558
- const LBVH_STRIDE = 4; // vec4s per LBVH node — must match LightBVHSampling.js
1559
- const lbvh = this._lbvhDataCache;
1560
- const emis = this._emissiveDataCache;
1561
- const lbvhLen = lbvh ? lbvh.length : 0;
1562
- const emisLen = emis ? emis.length : 0;
1563
-
1564
- // Ensure at least a minimal non-empty buffer so GPU allocation remains valid.
1565
- const totalLen = Math.max( lbvhLen + emisLen, 4 );
1566
- const combined = new Float32Array( totalLen );
1567
- if ( lbvh ) combined.set( lbvh, 0 );
1568
- if ( emis ) combined.set( emis, lbvhLen );
1569
-
1570
- this.lightStorageAttr = new StorageInstancedBufferAttribute( combined, 4 );
1571
- this.lightStorageNode.value = this.lightStorageAttr;
1572
- this.lightStorageNode.bufferCount = combined.length / 4;
1573
-
1574
- // Offset (in vec4 elements) where emissive data starts.
1575
- this.emissiveVec4Offset.value = ( this.lightBVHNodeCount.value || 0 ) * LBVH_STRIDE;
532
+ }
1576
533
 
1577
- }
534
+ // Per-pixel G-buffer (first-hit MRT: ND + albedo), 1 uvec4/pixel — half-precision packed (pack2x16).
535
+ // uint (not f32) buffer: packed lanes can hit the NaN exponent range (e.g. snorm 1.0 → 0x7FFF), which a
536
+ // GPU may canonicalize through f32 storage; u32 stores the bits verbatim. Separate from RAY — it's
537
+ // per-pixel (not per-ray×S), written by Generate/Shade bounce-0 and read only by FinalWrite.
538
+ // 1.25× margin (same as the per-ray buffers) so it survives the in-place-resize range.
539
+ const gBufferVec4s = PackedRayBuffer.requiredCapacity( maxRaysPerSample ) * GBUFFER_STRIDE;
540
+ this._gBufferAttr = new StorageInstancedBufferAttribute( new Uint32Array( gBufferVec4s * 4 ), 4 );
541
+ const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
542
+ const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
1578
543
 
1579
- setEmissiveTriangleData( emissiveData, count, totalPower = 0 ) {
544
+ if ( ! this._queueManager ) {
1580
545
 
1581
- if ( ! emissiveData ) return;
546
+ this._queueManager = new QueueManager( this._packedBuffers.capacity );
1582
547
 
1583
- this._emissiveDataCache = emissiveData;
1584
- this.emissiveTriangleCount.value = count;
1585
- this.emissiveTotalPower.value = totalPower;
1586
- this._rebuildLightBuffer();
1587
- console.log( `PathTracer: ${count} emissive triangles, totalPower=${totalPower.toFixed( 4 )} (storage buffer)` );
548
+ } else {
1588
549
 
1589
- }
550
+ this._queueManager.resize( this._packedBuffers.capacity );
1590
551
 
1591
- setLightBVHData( nodeData, nodeCount ) {
552
+ }
1592
553
 
1593
- if ( ! nodeData ) return;
554
+ if ( ! this._kernelManager ) {
1594
555
 
1595
- this._lbvhDataCache = nodeData;
1596
- this.lightBVHNodeCount.value = nodeCount;
1597
- this._rebuildLightBuffer();
1598
- console.log( `PathTracer: Light BVH ${nodeCount} nodes` );
556
+ this._kernelManager = new KernelManager( this.renderer );
1599
557
 
1600
- }
558
+ }
559
+
560
+ const pb = this._packedBuffers;
561
+ const qm = this._queueManager;
1601
562
 
1602
- // ===== UTILITY METHODS =====
563
+ this._wfRenderWidth.value = w;
564
+ this._wfRenderHeight.value = h;
565
+ this._wfMaxRayCount.value = maxRays;
1603
566
 
1604
- updateUniforms( updates ) {
567
+ const prevColor = this.shaderBuilder.prevColorTexNode;
568
+ const prevND = this.shaderBuilder.prevNormalDepthTexNode;
569
+ const prevAlbedo = this.shaderBuilder.prevAlbedoTexNode;
570
+ const writeTex = this.storageTextures.getWriteTextures();
1605
571
 
1606
- let hasChanges = false;
572
+ const counters = qm.getCounters();
573
+ const resetFn = Fn( () => {
1607
574
 
1608
- for ( const [ key, value ] of Object.entries( updates ) ) {
575
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
1609
576
 
1610
- if ( this[ key ] && this[ key ].value !== undefined ) {
577
+ } );
578
+ this._kernelManager.register( 'resetCounters',
579
+ resetFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
580
+ );
1611
581
 
1612
- if ( this[ key ].value !== value ) {
582
+ const resetActiveFn = Fn( () => {
1613
583
 
1614
- this[ key ].value = value;
1615
- hasChanges = true;
584
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
1616
585
 
1617
- }
586
+ } );
587
+ this._kernelManager.register( 'resetActiveCounter',
588
+ resetActiveFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
589
+ );
1618
590
 
1619
- }
591
+ // Copy ACTIVE_RAY_COUNT into bounceCounts[currentBounce] for the readback survivor curve.
592
+ const bounceCountsBuf = qm.getBounceCounts();
593
+ const wfCurrentBounce = this._wfCurrentBounce;
594
+ const snapshotFn = Fn( () => {
1620
595
 
1621
- }
596
+ const cnt = atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) );
597
+ const slot = uint( wfCurrentBounce ).clamp( uint( 0 ), uint( qm.MAX_BOUNCE_SNAPSHOTS - 1 ) );
598
+ bounceCountsBuf.element( slot ).assign( cnt );
599
+ // Also set ENTERING_COUNT for the next bounce; the full-dispatch path's enterFull overrides it.
600
+ atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), cnt );
601
+
602
+ } );
603
+ this._kernelManager.register( 'snapshotBounceCount',
604
+ snapshotFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
605
+ );
1622
606
 
1623
- if ( hasChanges ) {
607
+ const activeWriteA = qm.activeIndices.a;
608
+ const initFn = Fn( () => {
1624
609
 
1625
- this.reset();
610
+ const tid = instanceIndex;
611
+ activeWriteA.element( tid ).assign( tid );
612
+ // Seed ACTIVE_RAY_COUNT + ENTERING_COUNT from the _wfMaxRayCount uniform (not a literal) so in-place resize works.
613
+ If( tid.equal( uint( 0 ) ), () => {
1626
614
 
1627
- }
615
+ atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), this._wfMaxRayCount );
616
+ atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), this._wfMaxRayCount );
1628
617
 
1629
- }
618
+ } );
1630
619
 
1631
- async rebuildMaterials( scene ) {
620
+ } );
621
+ this._kernelManager.register( 'initActiveIndices',
622
+ initFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
623
+ );
1632
624
 
1633
- if ( ! this.sdfs ) {
625
+ const genFn = buildGenerateKernel( {
626
+ rayBufferRW: pb.rayBuffer.rw,
627
+ rngBufferRW: pb.rngBuffer.rw,
628
+ gBufferRW,
629
+ resolution: this.resolution,
630
+ frame: this.frame,
631
+ cameraWorldMatrix: this.cameraWorldMatrix,
632
+ cameraProjectionMatrixInverse: this.cameraProjectionMatrixInverse,
633
+ enableDOF: this.enableDOF,
634
+ focalLength: this.focalLength,
635
+ aperture: this.aperture,
636
+ focusDistance: this.focusDistance,
637
+ sceneScale: this.sceneScale,
638
+ apertureScale: this.apertureScale,
639
+ anamorphicRatio: this.anamorphicRatio,
640
+ renderWidth: this._wfRenderWidth,
641
+ renderHeight: this._wfRenderHeight,
642
+ samplesPerPass: S,
643
+ transmissiveBounces: this.transmissiveBounces,
644
+ transparentBackground: this.transparentBackground,
645
+ } );
646
+ this._kernelManager.register( 'generate',
647
+ genFn().compute(
648
+ // Multi-sample: dispatch covers h·S rows (each sub-sample is a row band).
649
+ [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( ( h * S ) / GENERATE_WG_SIZE ), 1 ],
650
+ [ GENERATE_WG_SIZE, GENERATE_WG_SIZE, 1 ]
651
+ )
652
+ );
1634
653
 
1635
- throw new Error( "Scene not built yet. Call build() first." );
654
+ const freshBvh = this.bvhStorageNode;
655
+ const freshTri = this.triangleStorageNode;
656
+ const freshMat = this.materialData.materialStorageNode;
657
+ const freshEnvCDF = texture( this.environment.envCDFTexture ); // independent CDF texture node; refreshed in _refreshWfTextureNodes
658
+ const freshLight = this.lightStorageNode;
659
+ // Independent texture nodes (never compiled elsewhere) avoid Three.js TextureNode caching across pipelines; refreshed via _refreshWfTextureNodes.
660
+ const _mat = this.materialData;
661
+ const _env = this.environment;
662
+ const _placeholder = texNodes.albedoMapsTex;
663
+ const freshAlbedoMaps = _mat.albedoMaps ? texture( _mat.albedoMaps ) : _placeholder;
664
+ const freshNormalMaps = _mat.normalMaps ? texture( _mat.normalMaps ) : texNodes.normalMapsTex;
665
+ const freshBumpMaps = _mat.bumpMaps ? texture( _mat.bumpMaps ) : texNodes.bumpMapsTex;
666
+ const freshMetalnessMaps = _mat.metalnessMaps ? texture( _mat.metalnessMaps ) : texNodes.metalnessMapsTex;
667
+ const freshRoughnessMaps = _mat.roughnessMaps ? texture( _mat.roughnessMaps ) : texNodes.roughnessMapsTex;
668
+ const freshEmissiveMaps = _mat.emissiveMaps ? texture( _mat.emissiveMaps ) : texNodes.emissiveMapsTex;
669
+ const freshDisplacementMaps = _mat.displacementMaps ? texture( _mat.displacementMaps ) : texNodes.displacementMapsTex;
670
+ const freshEnvTex = _env.environmentTexture ? texture( _env.environmentTexture ) : texNodes.envTex;
671
+
672
+ this._wfTexNodes = {
673
+ envTex: freshEnvTex,
674
+ envCDFTex: freshEnvCDF,
675
+ albedoMaps: freshAlbedoMaps,
676
+ normalMaps: freshNormalMaps,
677
+ bumpMaps: freshBumpMaps,
678
+ metalnessMaps: freshMetalnessMaps,
679
+ roughnessMaps: freshRoughnessMaps,
680
+ emissiveMaps: freshEmissiveMaps,
681
+ displacementMaps: freshDisplacementMaps,
682
+ };
1636
683
 
1637
- }
684
+ const extFn = buildExtendKernel( {
685
+ bvhBuffer: freshBvh,
686
+ triangleBuffer: freshTri,
687
+ materialBuffer: freshMat,
688
+ rayBufferRO: pb.rayBuffer.ro,
689
+ hitBufferRW: pb.hitBuffer.rw,
690
+ activeIndicesRO: qm.getActiveReadRO(),
691
+ counters,
692
+ maxRayCount: this._wfMaxRayCount,
693
+ } );
694
+ this._kernelManager.register( 'extend',
695
+ extFn().compute(
696
+ [ Math.ceil( maxRays / EXTEND_WG_SIZE ), 1, 1 ],
697
+ [ EXTEND_WG_SIZE, 1, 1 ]
698
+ )
699
+ );
1638
700
 
1639
- try {
701
+ const shadeFn = buildShadeKernel( {
702
+ gBufferRW,
703
+ envCompensationDelta: this.envCompensationDelta,
704
+ bvhBuffer: freshBvh,
705
+ triangleBuffer: freshTri,
706
+ materialBuffer: freshMat,
707
+ envCDFTexture: freshEnvCDF,
708
+ lightBuffer: freshLight,
709
+ rayBufferRW: pb.rayBuffer.rw,
710
+ rngBufferRW: pb.rngBuffer.rw,
711
+ hitBufferRO: pb.hitBuffer.ro,
712
+ counters,
713
+ activeIndicesRO: qm.getActiveReadRO(),
714
+ albedoMaps: freshAlbedoMaps,
715
+ normalMaps: freshNormalMaps,
716
+ bumpMaps: freshBumpMaps,
717
+ metalnessMaps: freshMetalnessMaps,
718
+ roughnessMaps: freshRoughnessMaps,
719
+ emissiveMaps: freshEmissiveMaps,
720
+ displacementMaps: freshDisplacementMaps,
721
+ envTexture: freshEnvTex,
722
+ environmentIntensity: this.environmentIntensity,
723
+ envMatrix: this.environmentMatrix,
724
+ enableEnvironmentLight: this.enableEnvironment,
725
+ useEnvMapIS: this.useEnvMapIS,
726
+ groundProjectionEnabled: this.groundProjectionEnabled,
727
+ groundProjectionRadius: this.groundProjectionRadius,
728
+ groundProjectionHeight: this.groundProjectionHeight,
729
+ envTotalSum: this.envTotalSum,
730
+ envResolution: this.envResolution,
731
+ directionalLightsBuffer: this.directionalLightsBufferNode,
732
+ numDirectionalLights: this.numDirectionalLights,
733
+ areaLightsBuffer: this.areaLightsBufferNode,
734
+ numAreaLights: this.numAreaLights,
735
+ pointLightsBuffer: this.pointLightsBufferNode,
736
+ numPointLights: this.numPointLights,
737
+ spotLightsBuffer: this.spotLightsBufferNode,
738
+ numSpotLights: this.numSpotLights,
739
+ maxBounceCount: this.maxBounces,
740
+ maxSubsurfaceSteps: this.maxSubsurfaceSteps,
741
+ transparentBackground: this.transparentBackground,
742
+ backgroundIntensity: this.backgroundIntensity,
743
+ showBackground: this.showBackground,
744
+ globalIlluminationIntensity: this.globalIlluminationIntensity,
745
+ cameraProjectionMatrix: this.cameraProjectionMatrix,
746
+ cameraViewMatrix: this.cameraViewMatrix,
747
+ fireflyThreshold: this.fireflyThreshold,
748
+ frame: this.frame,
749
+ resolution: this.resolution,
750
+ emissiveTriangleCount: this.emissiveTriangleCount,
751
+ emissiveVec4Offset: this.emissiveVec4Offset,
752
+ emissiveTotalPower: this.emissiveTotalPower,
753
+ emissiveBoost: this.emissiveBoost,
754
+ totalTriangleCount: this.totalTriangleCount,
755
+ enableEmissiveTriangleSampling: this.enableEmissiveTriangleSampling,
756
+ lightBVHNodeCount: this.lightBVHNodeCount,
757
+ currentBounce: this._wfCurrentBounce,
758
+ maxRayCount: this._wfMaxRayCount,
759
+ } );
760
+ this._kernelManager.register( 'shade',
761
+ shadeFn().compute(
762
+ [ Math.ceil( maxRays / SHADE_WG_SIZE ), 1, 1 ],
763
+ [ SHADE_WG_SIZE, 1, 1 ]
764
+ )
765
+ );
1640
766
 
1641
- console.log( 'PathTracer: Starting material rebuild...' );
767
+ // Subgroup prefix-sum variant when supported.
768
+ const subgroupsOK = this._useSubgroupCompact
769
+ && ( this.renderer.hasFeature ? this.renderer.hasFeature( 'subgroups' ) : false );
770
+ this._compactIsSubgroup = subgroupsOK;
771
+ const compactBuilder = subgroupsOK ? buildCompactSubgroupKernel : buildCompactKernel;
772
+ const compactFn = compactBuilder( {
773
+ rayBufferRO: pb.rayBuffer.ro,
774
+ activeIndicesReadRO: qm.getActiveReadRO(),
775
+ activeIndicesWriteRW: qm.getActiveWrite(),
776
+ counters,
777
+ currentActiveCount: this._wfMaxRayCount,
778
+ } );
779
+ this._kernelManager.register( 'compact',
780
+ compactFn().compute(
781
+ [ Math.ceil( maxRays / COMPACT_WG_SIZE ), 1, 1 ],
782
+ [ COMPACT_WG_SIZE, 1, 1 ]
783
+ )
784
+ );
1642
785
 
1643
- await this.sdfs.rebuildMaterials( scene );
1644
- this.updateSceneUniforms();
1645
- this.shaderBuilder.updateSceneTextures( this );
1646
- this.updateLights();
1647
- this.reset();
786
+ // Storage nodes bind buffer A at build time, so compactCopyback copies the dense survivor list B→A for the next bounce.
787
+ // Full-dispatch path: ENTERING_COUNT = maxRays, kernels read the identity buffer over [0,maxRays).
788
+ const enterFullFn = Fn( () => {
1648
789
 
1649
- console.log( 'PathTracer materials rebuilt successfully' );
790
+ atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), this._wfMaxRayCount );
1650
791
 
1651
- } catch ( error ) {
792
+ } );
793
+ this._kernelManager.register( 'enterFull',
794
+ enterFullFn().compute( [ 1, 1, 1 ], [ 1, 1, 1 ] )
795
+ );
1652
796
 
1653
- console.error( 'Error rebuilding PathTracer materials:', error );
797
+ const copyReadB = qm.activeIndicesRO.b; // compact writes B (pingPong fixed at 0)
798
+ const copyWriteA = qm.activeIndices.a;
799
+ const copyFn = Fn( () => {
1654
800
 
1655
- try {
801
+ const tid = instanceIndex;
802
+ If( tid.greaterThanEqual( atomicLoad( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ) ) ), () => {
1656
803
 
1657
- console.warn( 'Attempting recovery by resetting path tracer...' );
1658
- this.reset();
804
+ Return();
1659
805
 
1660
- } catch ( recoveryError ) {
806
+ } );
807
+ copyWriteA.element( tid ).assign( copyReadB.element( tid ) );
1661
808
 
1662
- console.error( 'Recovery failed:', recoveryError );
809
+ } );
810
+ this._kernelManager.register( 'compactCopyback',
811
+ copyFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
812
+ );
1663
813
 
1664
- }
814
+ const fwFn = buildFinalWriteKernel( {
815
+ rayBufferRO: pb.rayBuffer.ro,
816
+ gBufferRO,
817
+ writeColorTex: writeTex.color,
818
+ writeNDTex: writeTex.normalDepth,
819
+ writeAlbedoTex: writeTex.albedo,
820
+ resolution: this.resolution,
821
+ frame: this.frame,
822
+ enableAccumulation: this.enableAccumulation,
823
+ hasPreviousAccumulated: this.hasPreviousAccumulated,
824
+ accumulationAlpha: this.accumulationAlpha,
825
+ cameraIsMoving: this.cameraIsMoving,
826
+ transparentBackground: this.transparentBackground,
827
+ prevAccumTexture: prevColor,
828
+ prevNormalDepthTexture: prevND,
829
+ prevAlbedoTexture: prevAlbedo,
830
+ renderWidth: this._wfRenderWidth,
831
+ renderHeight: this._wfRenderHeight,
832
+ samplesPerPass: S,
833
+ visMode: this.visMode,
834
+ } );
835
+ this._kernelManager.register( 'finalWrite',
836
+ // Per-pixel (w×h) — kernel averages the S sample-slots internally.
837
+ fwFn().compute(
838
+ [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( h / FINALWRITE_WG_SIZE ), 1 ],
839
+ [ FINALWRITE_WG_SIZE, FINALWRITE_WG_SIZE, 1 ]
840
+ )
841
+ );
1665
842
 
1666
- throw error;
843
+ // Debug visualization (visMode 1-10): single-pass primary-ray kernel. Reuses the same fresh*
844
+ // scene nodes so _refreshWfTextureNodes keeps it current; mode 11 (NaN/Inf) is FinalWrite's branch.
845
+ const debugFn = buildDebugKernel( {
846
+ writeColorTex: writeTex.color,
847
+ writeNDTex: writeTex.normalDepth,
848
+ writeAlbedoTex: writeTex.albedo,
849
+ resolution: this.resolution,
850
+ renderWidth: this._wfRenderWidth,
851
+ renderHeight: this._wfRenderHeight,
852
+ cameraWorldMatrix: this.cameraWorldMatrix,
853
+ cameraProjectionMatrixInverse: this.cameraProjectionMatrixInverse,
854
+ cameraProjectionMatrix: this.cameraProjectionMatrix,
855
+ cameraViewMatrix: this.cameraViewMatrix,
856
+ enableDOF: this.enableDOF,
857
+ focalLength: this.focalLength,
858
+ aperture: this.aperture,
859
+ focusDistance: this.focusDistance,
860
+ sceneScale: this.sceneScale,
861
+ apertureScale: this.apertureScale,
862
+ anamorphicRatio: this.anamorphicRatio,
863
+ bvhBuffer: freshBvh,
864
+ triangleBuffer: freshTri,
865
+ materialBuffer: freshMat,
866
+ envTexture: freshEnvTex,
867
+ environmentMatrix: this.environmentMatrix,
868
+ environmentIntensity: this.environmentIntensity,
869
+ enableEnvironmentLight: this.enableEnvironment,
870
+ visMode: this.visMode,
871
+ debugVisScale: this.debugVisScale,
872
+ samplesPerPass: this._samplesPerPass,
873
+ albedoMaps: freshAlbedoMaps,
874
+ normalMaps: freshNormalMaps,
875
+ bumpMaps: freshBumpMaps,
876
+ metalnessMaps: freshMetalnessMaps,
877
+ roughnessMaps: freshRoughnessMaps,
878
+ emissiveMaps: freshEmissiveMaps,
879
+ frame: this.frame,
880
+ } );
881
+ this._kernelManager.register( 'debug',
882
+ debugFn().compute(
883
+ [ Math.ceil( w / DEBUG_WG_SIZE ), Math.ceil( h / DEBUG_WG_SIZE ), 1 ],
884
+ [ DEBUG_WG_SIZE, DEBUG_WG_SIZE, 1 ]
885
+ )
886
+ );
1667
887
 
1668
- }
888
+ this._wavefrontReady = true;
889
+ console.log( `PathTracer: all wavefront kernels built (${w}×${h}, ${maxRays} rays)` );
1669
890
 
1670
891
  }
1671
892
 
1672
- // ===== DISPOSE =====
893
+ _setWfDispatch() {
1673
894
 
1674
- /**
1675
- * Disposes of GPU resources.
1676
- */
1677
- dispose() {
895
+ const w = this._wfRenderWidth.value;
896
+ const h = this._wfRenderHeight.value;
897
+ const S = this._samplesPerPass | 0;
1678
898
 
1679
- // Clear timeouts
1680
- if ( this.renderModeChangeTimeout ) {
899
+ this._kernelManager.setDispatchCount( 'generate', [
900
+ Math.ceil( w / GENERATE_WG_SIZE ),
901
+ Math.ceil( ( h * S ) / GENERATE_WG_SIZE ), 1
902
+ ] );
903
+ this._kernelManager.setDispatchCount( 'finalWrite', [
904
+ Math.ceil( w / FINALWRITE_WG_SIZE ),
905
+ Math.ceil( h / FINALWRITE_WG_SIZE ), 1
906
+ ] );
907
+ this._kernelManager.setDispatchCount( 'debug', [
908
+ Math.ceil( w / DEBUG_WG_SIZE ),
909
+ Math.ceil( h / DEBUG_WG_SIZE ), 1
910
+ ] );
1681
911
 
1682
- clearTimeout( this.renderModeChangeTimeout );
1683
- this.renderModeChangeTimeout = null;
912
+ }
1684
913
 
1685
- }
914
+ dispose() {
1686
915
 
1687
- // Dispose managers
1688
- this.tileManager?.dispose();
1689
- this.cameraOptimizer?.dispose();
1690
- this.materialData?.dispose();
1691
- this.environment?.dispose();
1692
- this.shaderBuilder?.dispose();
1693
- this.uniforms?.dispose();
1694
-
1695
- // Dispose storage textures
1696
- this.storageTextures?.dispose();
1697
-
1698
- // Dispose textures
1699
- this.stbnScalarTexture?.dispose();
1700
- this.stbnVec2Texture?.dispose();
1701
- this.placeholderTexture?.dispose();
1702
-
1703
- // Clear data references
1704
- this.triangleStorageAttr = null;
1705
- this.triangleStorageNode = null;
1706
- this.bvhStorageAttr = null;
1707
- this.bvhStorageNode = null;
1708
- this.placeholderTexture = null;
1709
-
1710
- this.isReady = false;
916
+ super.dispose();
917
+ this._packedBuffers?.dispose();
918
+ this._queueManager?.dispose();
919
+ this._kernelManager?.dispose();
920
+ this._gBufferAttr?.dispose?.();
921
+ this._packedBuffers = null;
922
+ this._queueManager = null;
923
+ this._kernelManager = null;
924
+ this._gBufferAttr = null;
925
+ this._wavefrontReady = false;
1711
926
 
1712
927
  }
1713
928