three-gpu-pathtracer 0.0.27 → 0.0.28

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 (46) hide show
  1. package/README.md +0 -1
  2. package/build/index.module.js +5 -3
  3. package/build/index.module.js.map +1 -1
  4. package/build/index.umd.cjs +4 -2
  5. package/build/index.umd.cjs.map +1 -1
  6. package/package.json +7 -7
  7. package/src/core/PathTracingSceneGenerator.js +1 -1
  8. package/src/uniforms/EquirectHdrInfoUniform.js +4 -2
  9. package/src/webgpu/API.md +167 -19
  10. package/src/webgpu/AtlasTexture.js +2 -3
  11. package/src/webgpu/EquirectHdrInfoNode.js +89 -48
  12. package/src/webgpu/LightsInfoNode.js +1 -1
  13. package/src/webgpu/MegaKernelPathTracer.js +10 -3
  14. package/src/webgpu/PathTracerBackend.js +172 -21
  15. package/src/webgpu/WaveFrontPathTracer.js +156 -71
  16. package/src/webgpu/WebGPUPathTracer.js +628 -48
  17. package/src/webgpu/compute/ComputeKernel.js +8 -0
  18. package/src/webgpu/compute/PathTracerMegaKernel.js +187 -36
  19. package/src/webgpu/compute/TallySampleCountsKernel.js +10 -10
  20. package/src/webgpu/compute/UnpackAuxKernel.js +48 -0
  21. package/src/webgpu/compute/ZeroOutKernel.js +3 -1
  22. package/src/webgpu/compute/targets.js +55 -0
  23. package/src/webgpu/compute/wavefront/LogicKernel.js +67 -20
  24. package/src/webgpu/compute/wavefront/MaterialKernel.js +153 -66
  25. package/src/webgpu/compute/wavefront/MediumKernel.js +154 -0
  26. package/src/webgpu/compute/wavefront/PopulatePixelIndicesKernel.js +5 -5
  27. package/src/webgpu/compute/wavefront/QueueLengthToDispatchKernel.js +5 -5
  28. package/src/webgpu/compute/wavefront/QueueMediumWalksKernel.js +52 -0
  29. package/src/webgpu/compute/wavefront/ResetSlotsKernel.js +6 -6
  30. package/src/webgpu/compute/wavefront/TraceRayKernel.js +11 -9
  31. package/src/webgpu/compute/wavefront/TraceShadowRayKernel.js +18 -25
  32. package/src/webgpu/compute/wavefront/structs.js +63 -37
  33. package/src/webgpu/constants.js +2 -2
  34. package/src/webgpu/denoise/OIDNDenoiser.js +81 -78
  35. package/src/webgpu/index.d.ts +41 -6
  36. package/src/webgpu/materials/GltfCompliantMaterial.js +20 -6
  37. package/src/webgpu/materials/debug/AuxDebugMaterial.js +54 -0
  38. package/src/webgpu/nodes/PathtracerBVHComputeData.js +35 -13
  39. package/src/webgpu/nodes/aux.wgsl.js +61 -0
  40. package/src/webgpu/nodes/eon.wgsl.js +1 -1
  41. package/src/webgpu/nodes/material.wgsl.js +2 -1
  42. package/src/webgpu/nodes/medium.wgsl.js +165 -0
  43. package/src/webgpu/nodes/random.wgsl.js +2 -0
  44. package/src/webgpu/nodes/sampling.wgsl.js +44 -3
  45. package/src/webgpu/nodes/structs.wgsl.js +7 -1
  46. package/src/webgpu/upscale/FSRUpscaler.js +1 -1
@@ -1,4 +1,4 @@
1
- import { Box3, DataTexture, LinearFilter, Vector2, Scene, PerspectiveCamera, Color, NoToneMapping, FloatType, Timer, StorageTexture, MeshBasicNodeMaterial, Matrix4, WebGPUCoordinateSystem } from 'three/webgpu';
1
+ import { Box3, DataTexture, EventDispatcher, LinearFilter, MathUtils, Vector2, Scene, PerspectiveCamera, Color, NoToneMapping, FloatType, Timer, StorageTexture, MeshBasicNodeMaterial, Matrix4, WebGPUCoordinateSystem } from 'three/webgpu';
2
2
  import { uv, uniform, varying } from 'three/tsl';
3
3
  import { SkinnedMeshBVH, MeshBVH, SAH } from 'three-mesh-bvh';
4
4
  import { ndcToCameraRay, rayStruct, wgslTagFn } from 'three-mesh-bvh/webgpu';
@@ -11,11 +11,11 @@ import { CubeToEquirectGenerator } from '../utils/CubeToEquirectGenerator.js';
11
11
  import { PathtracerBVHComputeData } from './nodes/PathtracerBVHComputeData.js';
12
12
  import { AtlasDebugMaterial } from './materials/debug/AtlasDebugMaterial.js';
13
13
  import { SampleDensityMaterial } from './materials/debug/SampleDensityMaterial.js';
14
+ import { AuxDebugMaterial } from './materials/debug/AuxDebugMaterial.js';
14
15
  import { setCommonAttributes } from '../core/utils/GeometryPreparationUtils.js';
15
16
  import { getLights } from '../core/utils/sceneUpdateUtils.js';
16
17
  import { GltfCompliantMaterial } from './materials/GltfCompliantMaterial.js';
17
- import { TRANSMISSIVE_BACKGROUND_OVERLAY } from './constants.js';
18
- import * as RANDOM_BLUE_DITHER from './nodes/rand/bluedither.wgsl.js';
18
+ import { TRANSMISSIVE_BACKGROUND_OVERLAY, RANDOM_SOBOL } from './constants.js';
19
19
  /** @import { Camera, Scene, Texture, WebGPURenderer } from 'three/webgpu' */
20
20
  /** @import { AtlasTexture } from './AtlasTexture.js' */
21
21
  /** @import { OIDNDenoiser } from './denoise/OIDNDenoiser.js' */
@@ -33,6 +33,71 @@ import * as RANDOM_BLUE_DITHER from './nodes/rand/bluedither.wgsl.js';
33
33
  const _resolution = new Vector2();
34
34
  const _color = new Color();
35
35
 
36
+ // milliseconds between sample count readbacks while waiting for the render to complete
37
+ const COMPLETE_CHECK_INTERVAL = 250;
38
+
39
+ // How the adaptive frame budget follows the measured frame time. The budget is only reconsidered once
40
+ // a window of measurements has been taken at it, and only changes when their average is off the target
41
+ // by the threshold ratio, since a wavefront budget change reallocates the path buffers. It moves at most
42
+ // the step ratio per window.
43
+ // TODO: adapt more often, with a window of around 4, once the wavefront path buffers are grow-only so a budget
44
+ // change only changes the active slot count rather than reallocating them
45
+ const BUDGET_WINDOW = 16;
46
+ const BUDGET_CHANGE_THRESHOLD = 1.15;
47
+ const BUDGET_MAX_STEP = 2;
48
+ const MIN_FRAME_BUDGET = 4096;
49
+
50
+ // the options the mesh geometry bvhs are built with and how many workers build them at once
51
+ const MESH_BVH_OPTIONS = { strategy: SAH, targetLeafSize: 5 };
52
+ const MAX_BVH_WORKERS = 4;
53
+
54
+ // the stages before a scene renders, in order
55
+ const STAGE_BUILDING = 0;
56
+ const STAGE_COMPILING = 1;
57
+ const STAGE_RENDERING = 2;
58
+
59
+ // Builds a geometry's BVH in a worker without giving up its buffers. The workers transfer the position and
60
+ // index buffers, which neuters them for every other array viewing them, such as the attributes of other
61
+ // meshes in a glTF. So the worker gets copies, the original arrays are restored, and the reordered index
62
+ // is written back into the original. Shared array buffers aren't transferred, so they're sent as is.
63
+ // TODO: this is imperfect, and depends on how the worker transfers. Remove once three-mesh-bvh's worker
64
+ // has an option not to transfer
65
+ async function generateBVHInWorker( worker, geometry, options ) {
66
+
67
+ const getCopy = array => {
68
+
69
+ const isShared = typeof SharedArrayBuffer !== 'undefined' && array.buffer instanceof SharedArrayBuffer;
70
+ return isShared ? array : array.slice();
71
+
72
+ };
73
+
74
+ const { position } = geometry.attributes;
75
+ const { index } = geometry;
76
+ const positionArray = position.array;
77
+ const indexArray = index ? index.array : null;
78
+
79
+ position.array = getCopy( positionArray );
80
+ if ( index ) {
81
+
82
+ index.array = getCopy( indexArray );
83
+
84
+ }
85
+
86
+ const bvh = await worker.generate( geometry, options );
87
+
88
+ position.array = positionArray;
89
+ if ( index ) {
90
+
91
+ indexArray.set( index.array );
92
+ index.array = indexArray;
93
+ index.needsUpdate = true;
94
+
95
+ }
96
+
97
+ return bvh;
98
+
99
+ }
100
+
36
101
  class TextureCache {
37
102
 
38
103
  constructor( renderer, key ) {
@@ -94,8 +159,18 @@ class TextureCache {
94
159
 
95
160
  } else {
96
161
 
97
- value = value.clone();
162
+ // hash the scene's texture rather than the clone, whose uuid is new on every call, so an
163
+ // unchanged texture isn't cloned and re-processed each time the environment is updated
98
164
  hash = value.uuid + '_' + value.version;
165
+ if ( hash !== this.hash ) {
166
+
167
+ // a nearest filtered texture gets no sampler from TSL, which the equirect sampling
168
+ // needs, so the copy is always filtered
169
+ value = value.clone();
170
+ value.minFilter = LinearFilter;
171
+ value.magFilter = LinearFilter;
172
+
173
+ }
99
174
 
100
175
  }
101
176
 
@@ -126,6 +201,21 @@ class TextureCache {
126
201
 
127
202
  }
128
203
 
204
+ // WebGPUPathTracer Event Definitions
205
+
206
+ /**
207
+ * Fired once every pixel has reached `maxSamples` and an attached denoiser has finished. Fires
208
+ * again after a reset, or after `maxSamples` is raised, once the render has caught up.
209
+ * @event WebGPUPathTracer#complete
210
+ */
211
+
212
+ /**
213
+ * Fired as the render advances. `stage` is "building", "compiling", "rendering" or "denoising", and
214
+ * `progress` is the fraction of it done, or null when unknown. "rendering" also gives the sample
215
+ * counts as `samples`, reported a few times a second.
216
+ * @event WebGPUPathTracer#progress
217
+ */
218
+
129
219
  /**
130
220
  * Progressive path tracer running on WebGPU. Call {@link WebGPUPathTracer#setScene} once,
131
221
  * then {@link WebGPUPathTracer#renderSample} every frame to accumulate samples into the canvas.
@@ -134,7 +224,7 @@ class TextureCache {
134
224
  * the environment afterward require the matching `update*` function. Any change that invalidates
135
225
  * the accumulated image restarts it.
136
226
  */
137
- export class WebGPUPathTracer {
227
+ export class WebGPUPathTracer extends EventDispatcher {
138
228
 
139
229
  /**
140
230
  * Maximum number of times a ray can scatter before the path is terminated. Higher values
@@ -156,7 +246,7 @@ export class WebGPUPathTracer {
156
246
  }
157
247
 
158
248
  /**
159
- * Number of path slots dispatched per {@link WebGPUPathTracer#renderSample} call,
249
+ * Number of path steps dispatched per {@link WebGPUPathTracer#renderSample} call,
160
250
  * independent of resolution. Raising it trades frame rate for convergence speed.
161
251
  * @type {number}
162
252
  * @default 250000
@@ -173,11 +263,23 @@ export class WebGPUPathTracer {
173
263
 
174
264
  }
175
265
 
266
+ /**
267
+ * GPU time in milliseconds of a recent {@link WebGPUPathTracer#renderSample} call. Only measured
268
+ * when the renderer is created with `trackTimestamp: true`.
269
+ * @type {number}
270
+ * @readonly
271
+ */
272
+ get frameTime() {
273
+
274
+ return this._pathTracer.timing.frameTime;
275
+
276
+ }
277
+
176
278
  /**
177
279
  * Maximum number of alpha tested surfaces a ray can pass through. Counted separately from
178
280
  * {@link WebGPUPathTracer#maxBounces} so foliage and cutouts cannot exhaust the bounce budget.
179
281
  * @type {number}
180
- * @default 5
282
+ * @default 15
181
283
  */
182
284
  get maxTransparentBounces() {
183
285
 
@@ -192,6 +294,25 @@ export class WebGPUPathTracer {
192
294
 
193
295
  }
194
296
 
297
+ /**
298
+ * Maximum number of scatter events a path can take inside a subsurface scattering volume. Counted
299
+ * separately from {@link WebGPUPathTracer#maxBounces}. Paths that reach it end, darkening dense volumes.
300
+ * @type {number}
301
+ * @default 256
302
+ */
303
+ get maxSubsurfaceBounces() {
304
+
305
+ return this._pathTracer.maxSubsurfaceBounces;
306
+
307
+ }
308
+
309
+ set maxSubsurfaceBounces( v ) {
310
+
311
+ this._pathTracer.maxSubsurfaceBounces = v;
312
+ this.reset();
313
+
314
+ }
315
+
195
316
  /**
196
317
  * Stops accumulating once every pixel reaches this many samples. A denoiser only runs once the
197
318
  * render has stopped, so it has no effect while this is `0`.
@@ -215,6 +336,21 @@ export class WebGPUPathTracer {
215
336
 
216
337
  }
217
338
 
339
+ /**
340
+ * Whether every pixel has reached {@link WebGPUPathTracer#maxSamples} and an attached denoiser
341
+ * has finished, as of the last measurement. Always false while rendering indefinitely.
342
+ * @type {boolean}
343
+ */
344
+ get complete() {
345
+
346
+ const maxSamples = this.maxSamples;
347
+ const denoiser = this._denoiser;
348
+
349
+ // "min" so every pixel has stopped, not just the average
350
+ return maxSamples > 0 && this._lastSampleCounts.min >= maxSamples && ( ! denoiser || denoiser.complete );
351
+
352
+ }
353
+
218
354
  /**
219
355
  * Measures the per pixel sample counts. Use `min` for convergence checks and `avg` to display.
220
356
  * @returns {Promise<SampleCounts>}
@@ -404,6 +540,19 @@ export class WebGPUPathTracer {
404
540
 
405
541
  }
406
542
 
543
+ /**
544
+ * Whether the kernels are compiling, such as after the scene or material changes. Nothing new
545
+ * renders until they finish and the canvas keeps the last frame, so an app may draw its own
546
+ * preview in the meantime.
547
+ * @type {boolean}
548
+ * @readonly
549
+ */
550
+ get compiling() {
551
+
552
+ return this._pathTracer.compiling;
553
+
554
+ }
555
+
407
556
  /**
408
557
  * The atlas every scene texture is packed into for the kernels to sample.
409
558
  * @type {AtlasTexture}
@@ -429,6 +578,7 @@ export class WebGPUPathTracer {
429
578
  this._pathTracer.dispose();
430
579
  this._pathTracer = value ? new MegaKernelPathTracer( this._renderer ) : new WaveFrontPathTracer( this._renderer );
431
580
  this._pathTracer.maxSamples = maxSamples;
581
+ this._timingCount = 0;
432
582
  this._pathTracer.setBVHData( this._bvhData );
433
583
  this._pathTracer.setMaterial( this.material );
434
584
  this._pathTracer.setRandom( this.random );
@@ -436,6 +586,7 @@ export class WebGPUPathTracer {
436
586
  this._pathTracer.setTransmissiveBackground( this._transmissiveBackground );
437
587
  this._pathTracer.setFilterGlossy( this._filterGlossyFactor );
438
588
  this._pathTracer.setClamping( this._clampDirect, this._clampIndirect );
589
+ this._pathTracer.setAccumulateAux( Boolean( this._denoiser && this._denoiser.useAuxiliaryBuffers ) );
439
590
  this.setCamera( this.camera );
440
591
 
441
592
  // the new tracer has default environment and background maps so apply the cached scene textures
@@ -451,6 +602,8 @@ export class WebGPUPathTracer {
451
602
  */
452
603
  constructor( renderer ) {
453
604
 
605
+ super();
606
+
454
607
  // members
455
608
  this._renderer = renderer;
456
609
  this._timer = new Timer();
@@ -464,6 +617,16 @@ export class WebGPUPathTracer {
464
617
  this._size = new Vector2();
465
618
  this._blitQuad = new FullScreenQuad( new RenderToScreenNodeMaterial() );
466
619
 
620
+ // adaptive frame budget and preview state
621
+ this._timingCount = 0;
622
+ this._frameTimeTotal = 0;
623
+ this._frameTimeCount = 0;
624
+ this._warnedTimestamps = false;
625
+
626
+ // the budget from before the first pixels finished, restored on reset. The last few pixels render
627
+ // quickly and inflate the budget
628
+ this._fullFrameBudget = null;
629
+
467
630
  // avoid mipmap gen on copy, not always supported with float type
468
631
  this._lowResTarget = new StorageTexture( 1, 1 );
469
632
  this._lowResTarget.type = FloatType;
@@ -475,10 +638,19 @@ export class WebGPUPathTracer {
475
638
  this._denoiser = null;
476
639
  this._upscaler = null;
477
640
 
641
+ // the attached denoiser's progress events are forwarded
642
+ this._onDenoiserProgress = e => this.dispatchEvent( { type: 'progress', stage: 'denoising', progress: e.progress } );
643
+
644
+ // the current stage. Only the latest "setSceneAsync" call sets its scene, and a geometry already building
645
+ // for an earlier call is awaited rather than built again
646
+ this._stage = STAGE_RENDERING;
647
+ this._sceneRequest = 0;
648
+ this._pendingBVHs = new WeakMap();
649
+
478
650
  // options
479
651
 
480
652
  /**
481
- * Samples every pixel must reach before the full resolution render is faded in.
653
+ * Average samples per pixel the full resolution render must reach before it is faded in.
482
654
  * @type {number}
483
655
  * @default 1
484
656
  */
@@ -487,16 +659,16 @@ export class WebGPUPathTracer {
487
659
  /**
488
660
  * Milliseconds to show the low resolution preview for after a reset.
489
661
  * @type {number}
490
- * @default 500
662
+ * @default 16
491
663
  */
492
- this.renderDelay = 500;
664
+ this.renderDelay = 16;
493
665
 
494
666
  /**
495
667
  * Milliseconds taken to cross fade from the preview to the full render.
496
668
  * @type {number}
497
- * @default 500
669
+ * @default 250
498
670
  */
499
- this.fadeDuration = 500;
671
+ this.fadeDuration = 250;
500
672
 
501
673
  /**
502
674
  * Whether to render a low resolution preview while the camera is moving.
@@ -506,12 +678,38 @@ export class WebGPUPathTracer {
506
678
  this.dynamicLowRes = true;
507
679
 
508
680
  /**
509
- * Resolution of the low resolution preview, as a fraction of the render size.
681
+ * Resolution of the low resolution preview, as a fraction of the render size. Adjusted when
682
+ * {@link WebGPUPathTracer#adaptiveDynamicRes} is set. The preview never has more pixels than a
683
+ * frame of {@link WebGPUPathTracer#frameBudget} can render.
510
684
  * @type {number}
511
685
  * @default 0.1
512
686
  */
513
687
  this.lowResScale = 0.1;
514
688
 
689
+ /**
690
+ * Whether {@link WebGPUPathTracer#lowResScale} is adjusted to the pixels a frame of
691
+ * {@link WebGPUPathTracer#frameBudget} can render.
692
+ * @type {boolean}
693
+ * @default true
694
+ */
695
+ this.adaptiveDynamicRes = true;
696
+
697
+ /**
698
+ * Whether {@link WebGPUPathTracer#frameBudget} is adjusted to keep each
699
+ * {@link WebGPUPathTracer#renderSample} call near {@link WebGPUPathTracer#targetFrameTime} of
700
+ * GPU time. Requires a renderer created with `trackTimestamp: true`.
701
+ * @type {boolean}
702
+ * @default true
703
+ */
704
+ this.adaptiveFrameBudget = true;
705
+
706
+ /**
707
+ * GPU time in milliseconds per frame targeted by {@link WebGPUPathTracer#adaptiveFrameBudget}.
708
+ * @type {number}
709
+ * @default 16
710
+ */
711
+ this.targetFrameTime = 16;
712
+
515
713
  /**
516
714
  * Resolution the path tracer renders at, as a fraction of the canvas size. Lowering it
517
715
  * converges faster at the cost of detail, and pairs with an upscaler.
@@ -528,6 +726,23 @@ export class WebGPUPathTracer {
528
726
  */
529
727
  this.synchronizeRenderSize = true;
530
728
 
729
+ /**
730
+ * Whether {@link WebGPUPathTracer#renderSample} draws the result to the current render
731
+ * target, the canvas by default. Set to `false` to only accumulate, as when baking to a
732
+ * texture read through {@link WebGPUPathTracer#target}.
733
+ * @type {boolean}
734
+ * @default true
735
+ */
736
+ this.renderToCanvas = true;
737
+
738
+ /**
739
+ * Whether to compile the kernels asynchronously so a compile doesn't stall the page. Nothing
740
+ * new renders while {@link WebGPUPathTracer#compiling} is true.
741
+ * @type {boolean}
742
+ * @default false
743
+ */
744
+ this.asyncCompile = false;
745
+
531
746
  /**
532
747
  * Whether to generate the attributes in {@link WebGPUPathTracer#commonAttributes} on
533
748
  * geometry that is missing them when the scene is set.
@@ -569,7 +784,7 @@ export class WebGPUPathTracer {
569
784
  * @type {Object}
570
785
  * @private
571
786
  */
572
- this.random = RANDOM_BLUE_DITHER;
787
+ this.random = RANDOM_SOBOL;
573
788
 
574
789
  /**
575
790
  * Material model the kernels evaluate surfaces with.
@@ -583,6 +798,11 @@ export class WebGPUPathTracer {
583
798
  // ( world * inverseProjection ), premultiplied on the CPU so no matrix multiply runs per ray.
584
799
  this._invViewProjectionMatrix = uniform( new Matrix4() );
585
800
  this._cameraRayFnHandle = null;
801
+ this._BVHWorker = null;
802
+
803
+ // the render time of the last check for the render having stopped
804
+ this._lastCompleteCheck = 0;
805
+ this._wasComplete = false;
586
806
 
587
807
  // initialize the scene so it doesn't fail
588
808
  this.setMaterial( this.material );
@@ -604,7 +824,8 @@ export class WebGPUPathTracer {
604
824
 
605
825
  /**
606
826
  * Attaches a denoiser, run once the render settles and displayed in place of the raw image.
607
- * Settings live on the instance. Pass null to remove it.
827
+ * Settings live on the instance. Pass null to remove it. The render restarts when the auxiliary
828
+ * buffers it needs are added or dropped.
608
829
  *
609
830
  * @param {OIDNDenoiser|null} denoiser
610
831
  */
@@ -613,15 +834,19 @@ export class WebGPUPathTracer {
613
834
  // a detached instance stops receiving resets, so clear it rather than let it hold a
614
835
  // result from a camera position that has since moved
615
836
  this._denoiser?.reset();
837
+ this._denoiser?.removeEventListener( 'progress', this._onDenoiserProgress );
616
838
  this._denoiser = denoiser;
617
839
 
618
840
  if ( denoiser ) {
619
841
 
620
842
  denoiser.init( this._renderer );
621
- denoiser.setScene( this.scene, this.camera );
843
+ denoiser.addEventListener( 'progress', this._onDenoiserProgress );
622
844
 
623
845
  }
624
846
 
847
+ this._pathTracer.setAccumulateAux( Boolean( denoiser && denoiser.useAuxiliaryBuffers ) );
848
+ this.reset();
849
+
625
850
  }
626
851
 
627
852
  /**
@@ -688,7 +913,7 @@ export class WebGPUPathTracer {
688
913
 
689
914
  if ( ! child.geometry.boundsTree ) {
690
915
 
691
- child.geometry.boundsTree = new MeshBVH( child.geometry, { strategy: SAH, targetLeafSize: 5 } );
916
+ child.geometry.boundsTree = new MeshBVH( child.geometry, MESH_BVH_OPTIONS );
692
917
 
693
918
  }
694
919
 
@@ -715,8 +940,150 @@ export class WebGPUPathTracer {
715
940
  this.updateEnvironment();
716
941
  this.updateLights();
717
942
 
718
- // the denoiser rasterizes its own guide buffers, so it needs the scene too
719
- this._denoiser?.setScene( scene, camera );
943
+ }
944
+
945
+ /**
946
+ * Sets the worker class {@link WebGPUPathTracer#setSceneAsync} builds the mesh geometry BVHs
947
+ * with, such as `GenerateMeshBVHWorker` from three-mesh-bvh. Several are created and disposed
948
+ * per call so geometries build in parallel.
949
+ *
950
+ * @param {Function} WorkerConstructor
951
+ */
952
+ setBVHWorker( WorkerConstructor ) {
953
+
954
+ this._BVHWorker = WorkerConstructor;
955
+
956
+ }
957
+
958
+ /**
959
+ * Like {@link WebGPUPathTracer#setScene} but builds the mesh geometry BVHs in the workers set
960
+ * with {@link WebGPUPathTracer#setBVHWorker} so the page is not blocked. Skinned meshes are
961
+ * still built synchronously.
962
+ *
963
+ * @param {Scene} scene
964
+ * @param {Camera} camera
965
+ * @returns {Promise<void>}
966
+ */
967
+ async setSceneAsync( scene, camera ) {
968
+
969
+ const WorkerConstructor = this._BVHWorker;
970
+ if ( ! WorkerConstructor ) {
971
+
972
+ throw new Error( 'WebGPUPathTracer: "setBVHWorker" must be called before "setSceneAsync" can be called.' );
973
+
974
+ }
975
+
976
+ this._sceneRequest ++;
977
+
978
+ const request = this._sceneRequest;
979
+ const pendingBVHs = this._pendingBVHs;
980
+
981
+ // the worker doesn't support interleaved attributes, so those geometries are built in "setScene" instead
982
+ const geometries = new Set();
983
+ const pending = new Set();
984
+ scene.traverse( child => {
985
+
986
+ if ( child.isMesh && ! child.isSkinnedMesh && ! child.geometry.boundsTree ) {
987
+
988
+ const { geometry } = child;
989
+ if ( geometry.attributes.position.isInterleavedBufferAttribute || geometry.index && geometry.index.isInterleavedBufferAttribute ) {
990
+
991
+ return;
992
+
993
+ }
994
+
995
+ if ( pendingBVHs.has( geometry ) ) {
996
+
997
+ pending.add( pendingBVHs.get( geometry ) );
998
+
999
+ } else {
1000
+
1001
+ geometries.add( geometry );
1002
+
1003
+ }
1004
+
1005
+ }
1006
+
1007
+ } );
1008
+
1009
+ // each worker runs one job at a time so they pull from a shared queue
1010
+ const queue = Array.from( geometries );
1011
+
1012
+ // build progress, weighted by triangle count
1013
+ const getTriangleCount = geometry => ( geometry.index ? geometry.index.count : geometry.attributes.position.count ) / 3;
1014
+ const totalTriangles = queue.reduce( ( sum, geometry ) => sum + getTriangleCount( geometry ), 0 );
1015
+ let builtTriangles = 0;
1016
+
1017
+ const workers = [];
1018
+ for ( let i = 0, l = Math.min( MAX_BVH_WORKERS, queue.length ); i < l; i ++ ) {
1019
+
1020
+ workers.push( new WorkerConstructor() );
1021
+
1022
+ }
1023
+
1024
+ // stop rendering the previous scene while the next one builds, keeping the last frame. The denoiser runs
1025
+ // its tiles on its own schedule, so it's reset separately
1026
+ this._setStage( STAGE_BUILDING );
1027
+ this._denoiser?.reset();
1028
+
1029
+ try {
1030
+
1031
+ await Promise.all( [ ...pending, ...workers.map( async worker => {
1032
+
1033
+ while ( queue.length > 0 ) {
1034
+
1035
+ const geometry = queue.shift();
1036
+ const triangles = getTriangleCount( geometry );
1037
+ let reported = 0;
1038
+ const onProgress = progress => {
1039
+
1040
+ builtTriangles += ( progress - reported ) * triangles;
1041
+ reported = progress;
1042
+
1043
+ // skip if a later call replaced this scene
1044
+ if ( request === this._sceneRequest ) {
1045
+
1046
+ this.dispatchEvent( { type: 'progress', stage: 'building', progress: builtTriangles / totalTriangles } );
1047
+
1048
+ }
1049
+
1050
+ };
1051
+
1052
+ const promise = generateBVHInWorker( worker, geometry, { ...MESH_BVH_OPTIONS, onProgress } );
1053
+ pendingBVHs.set( geometry, promise );
1054
+
1055
+ try {
1056
+
1057
+ geometry.boundsTree = await promise;
1058
+ onProgress( 1 );
1059
+
1060
+ } finally {
1061
+
1062
+ pendingBVHs.delete( geometry );
1063
+
1064
+ }
1065
+
1066
+ }
1067
+
1068
+ } ) ] );
1069
+
1070
+ } finally {
1071
+
1072
+ workers.forEach( worker => worker.dispose() );
1073
+ if ( request === this._sceneRequest ) {
1074
+
1075
+ this._setStage( STAGE_COMPILING );
1076
+
1077
+ }
1078
+
1079
+ }
1080
+
1081
+ // skip the scene if a later call replaced it while it built
1082
+ if ( request === this._sceneRequest ) {
1083
+
1084
+ this.setScene( scene, camera );
1085
+
1086
+ }
720
1087
 
721
1088
  }
722
1089
 
@@ -942,6 +1309,8 @@ export class WebGPUPathTracer {
942
1309
 
943
1310
  // a fresh object rather than a clear, so measurements still in flight detach
944
1311
  this._lastSampleCounts = { min: 0, max: 0, avg: 0, samplesPerSecond: 0 };
1312
+ this._lastCompleteCheck = 0;
1313
+ this._wasComplete = false;
945
1314
 
946
1315
  if ( this.stableNoise ) {
947
1316
 
@@ -949,6 +1318,130 @@ export class WebGPUPathTracer {
949
1318
 
950
1319
  }
951
1320
 
1321
+ if ( this._fullFrameBudget !== null ) {
1322
+
1323
+ this.frameBudget = this._fullFrameBudget;
1324
+ this._fullFrameBudget = null;
1325
+
1326
+ }
1327
+
1328
+ }
1329
+
1330
+ // Sets the stage, reporting when each one starts
1331
+ _setStage( stage ) {
1332
+
1333
+ if ( this._stage === stage ) {
1334
+
1335
+ return;
1336
+
1337
+ }
1338
+
1339
+ this._stage = stage;
1340
+ if ( stage === STAGE_BUILDING ) {
1341
+
1342
+ this.dispatchEvent( { type: 'progress', stage: 'building', progress: 0 } );
1343
+
1344
+ } else if ( stage === STAGE_COMPILING ) {
1345
+
1346
+ this.dispatchEvent( { type: 'progress', stage: 'compiling', progress: null } );
1347
+
1348
+ } else {
1349
+
1350
+ this._dispatchRenderProgress( { min: 0, max: 0, avg: 0, samplesPerSecond: 0 } );
1351
+
1352
+ }
1353
+
1354
+ }
1355
+
1356
+ // Measures the sample counts and reports them
1357
+ _measureSamples() {
1358
+
1359
+ const counts = this._lastSampleCounts;
1360
+ this.getSampleCountsAsync().then( samples => {
1361
+
1362
+ // skip if the render was reset or a new scene started building meanwhile
1363
+ if ( counts === this._lastSampleCounts && this._stage === STAGE_RENDERING ) {
1364
+
1365
+ // save the budget once the first pixels finish
1366
+ const { maxSamples } = this;
1367
+ if ( this._fullFrameBudget === null && maxSamples > 0 && samples.max >= maxSamples ) {
1368
+
1369
+ this._fullFrameBudget = this.frameBudget;
1370
+
1371
+ }
1372
+
1373
+ this._dispatchRenderProgress( samples );
1374
+
1375
+ }
1376
+
1377
+ } );
1378
+
1379
+ }
1380
+
1381
+ _dispatchRenderProgress( samples ) {
1382
+
1383
+ const { maxSamples } = this;
1384
+ const progress = maxSamples > 0 ? Math.min( samples.avg / maxSamples, 1 ) : null;
1385
+ this.dispatchEvent( { type: 'progress', stage: 'rendering', progress, samples } );
1386
+
1387
+ }
1388
+
1389
+ // Adapts the frame budget to the tracer's latest gpu time measurement
1390
+ _readTiming() {
1391
+
1392
+ const { timing } = this._pathTracer;
1393
+ const { trackTimestamp } = this._renderer.backend;
1394
+ if ( this.adaptiveFrameBudget && ! trackTimestamp && ! this._warnedTimestamps ) {
1395
+
1396
+ console.warn( 'WebGPUPathTracer: "adaptiveFrameBudget" needs timestamp queries. Create the renderer with "trackTimestamp: true" on a device that supports them.' );
1397
+ this._warnedTimestamps = true;
1398
+
1399
+ }
1400
+
1401
+ if ( timing.count === this._timingCount ) {
1402
+
1403
+ return;
1404
+
1405
+ }
1406
+
1407
+ this._timingCount = timing.count;
1408
+
1409
+ // a measurement from before the budget changed doesn't describe the current one
1410
+ const { frameBudget } = timing;
1411
+ if ( ! this.adaptiveFrameBudget || frameBudget !== this.frameBudget ) {
1412
+
1413
+ this._frameTimeTotal = 0;
1414
+ this._frameTimeCount = 0;
1415
+ return;
1416
+
1417
+ }
1418
+
1419
+ this._frameTimeTotal += timing.frameTime;
1420
+ this._frameTimeCount ++;
1421
+ if ( this._frameTimeCount < BUDGET_WINDOW ) {
1422
+
1423
+ return;
1424
+
1425
+ }
1426
+
1427
+ const { targetFrameTime } = this;
1428
+ const average = this._frameTimeTotal / this._frameTimeCount;
1429
+ this._frameTimeTotal = 0;
1430
+ this._frameTimeCount = 0;
1431
+
1432
+ // leave the budget alone while it's close enough to the target
1433
+ const ratio = average / targetFrameTime;
1434
+ if ( ratio < BUDGET_CHANGE_THRESHOLD && ratio > 1 / BUDGET_CHANGE_THRESHOLD ) {
1435
+
1436
+ return;
1437
+
1438
+ }
1439
+
1440
+ // scale the budget to land the average on the target
1441
+ const next = frameBudget * targetFrameTime / average;
1442
+ const stepped = MathUtils.clamp( next, frameBudget / BUDGET_MAX_STEP, frameBudget * BUDGET_MAX_STEP );
1443
+ this.frameBudget = Math.max( Math.round( stepped ), MIN_FRAME_BUDGET );
1444
+
952
1445
  }
953
1446
 
954
1447
  /**
@@ -967,7 +1460,7 @@ export class WebGPUPathTracer {
967
1460
  dynamicLowRes,
968
1461
  synchronizeRenderSize,
969
1462
  renderScale,
970
- lowResScale,
1463
+ adaptiveDynamicRes,
971
1464
  minSamples,
972
1465
  maxSamples,
973
1466
  } = this;
@@ -1016,35 +1509,61 @@ export class WebGPUPathTracer {
1016
1509
  const lowResMode = this._resetTime < renderDelay || ( firstFrame && dynamicLowRes && minSamples !== 0 );
1017
1510
  if ( lowResMode ) {
1018
1511
 
1019
- width = Math.ceil( lowResScale * width );
1020
- height = Math.ceil( lowResScale * height );
1512
+ // the most pixels a preview frame can render within the frame budget. Pixels past it are never
1513
+ // reached by the wavefront in a preview
1514
+ const budgetScale = Math.min( Math.sqrt( pathTracer.getPreviewPixelBudget() / ( width * height ) ), 1 );
1515
+
1516
+ if ( adaptiveDynamicRes ) {
1517
+
1518
+ this.lowResScale = budgetScale;
1519
+
1520
+ }
1521
+
1522
+ // checked every frame since the budget adapts separately
1523
+ const scale = Math.min( this.lowResScale, budgetScale );
1524
+ width = Math.max( 1, Math.floor( scale * width ) );
1525
+ height = Math.max( 1, Math.floor( scale * height ) );
1021
1526
 
1022
1527
  }
1023
1528
 
1024
1529
  // set the size if necessary
1025
1530
  pathTracer.getSize( _resolution );
1026
1531
 
1027
- const resized = _resolution.x !== width || _resolution.y !== height;
1028
- if ( resized ) {
1532
+ // copy the low res content when transitioning to the full resolution view so we can fade from
1533
+ // it, including when the preview was already full size and nothing is resized
1534
+ if ( ! lowResMode && pathTracer.lowResMode && dynamicLowRes ) {
1029
1535
 
1030
- if ( ! lowResMode && dynamicLowRes ) {
1536
+ lowResTarget.setSize( _resolution.x, _resolution.y );
1537
+ renderer.copyTextureToTexture( pathTracer.outputTarget, lowResTarget );
1031
1538
 
1032
- // copy the low reset content if we're transitioning to the full
1033
- // resolution view so we can fade to it
1034
- lowResTarget.setSize( Math.ceil( lowResScale * width ), Math.ceil( lowResScale * height ) );
1035
- renderer.copyTextureToTexture( pathTracer.outputTarget, lowResTarget );
1539
+ }
1036
1540
 
1037
- }
1541
+ if ( _resolution.x !== width || _resolution.y !== height ) {
1038
1542
 
1039
1543
  pathTracer.setSize( width, height );
1040
1544
 
1041
1545
  }
1042
1546
 
1043
- // update the samples
1044
- if ( ! this.pause && ( ! lowResMode || ( lowResMode && dynamicLowRes ) ) ) {
1547
+ // update the samples, stopping once every pixel has reached "maxSamples"
1548
+ const samplesComplete = maxSamples > 0 && this._lastSampleCounts.min >= maxSamples;
1549
+ if ( ! this.pause && ! samplesComplete && this._stage > STAGE_BUILDING && ( ! lowResMode || ( lowResMode && dynamicLowRes ) ) ) {
1045
1550
 
1046
1551
  pathTracer.lowResMode = lowResMode;
1552
+ pathTracer.asyncCompile = this.asyncCompile;
1047
1553
  pathTracer.update();
1554
+ this._readTiming();
1555
+ this._setStage( pathTracer.compiling ? STAGE_COMPILING : STAGE_RENDERING );
1556
+
1557
+ }
1558
+
1559
+ // nothing renders while the next scene's BVHs build or the kernels compile, so restart the preview timer and
1560
+ // keep the last frame
1561
+ // TODO: decide what to display meanwhile - the last frame goes stale when the camera moves, and a
1562
+ // rasterized preview compiles its own materials
1563
+ if ( this._stage < STAGE_RENDERING ) {
1564
+
1565
+ this._resetTime = - 1;
1566
+ return;
1048
1567
 
1049
1568
  }
1050
1569
 
@@ -1056,17 +1575,25 @@ export class WebGPUPathTracer {
1056
1575
  // counts every frame. A count of the camera rays dispatched would answer it in one value
1057
1576
  const awaitingDenoise = Boolean( denoiser ) && ! denoiser.complete && ! denoiser.running && maxSamples > 0;
1058
1577
 
1059
- // Gate on the least converged pixel. Measuring is expensive so it stops once faded in, and
1578
+ // Gate on the average pixel. Measuring is expensive so it stops once faded in, and
1060
1579
  // the check reads the last measurement rather than waiting on this one.
1061
1580
  if ( ! lowResMode ) {
1062
1581
 
1063
- if ( ( this._fadeState < 1 && minSamples > 0 ) || awaitingDenoise ) {
1582
+ // the counts can't change once every pixel has finished, so measuring stops
1583
+ if ( ! samplesComplete && this._fadeState < 1 && minSamples > 0 ) {
1584
+
1585
+ this._measureSamples();
1064
1586
 
1065
- this.getSampleCountsAsync();
1587
+ } else if ( ! samplesComplete && this._resetTime - this._lastCompleteCheck >= COMPLETE_CHECK_INTERVAL ) {
1588
+
1589
+ // the readback stalls the gpu pipeline, so the render only measures a few times a second.
1590
+ // The "progress" and "complete" events and the denoiser key off it
1591
+ this._lastCompleteCheck = this._resetTime;
1592
+ this._measureSamples();
1066
1593
 
1067
1594
  }
1068
1595
 
1069
- if ( this._lastSampleCounts.min >= minSamples ) {
1596
+ if ( this._lastSampleCounts.avg >= minSamples ) {
1070
1597
 
1071
1598
  this._fadeState += delta / this.fadeDuration;
1072
1599
  this._fadeState = Math.min( 1.0, this._fadeState ) || 1.0;
@@ -1086,7 +1613,7 @@ export class WebGPUPathTracer {
1086
1613
  // "min" so every pixel has stopped, not just the average
1087
1614
  if ( awaitingDenoise && this._lastSampleCounts.min >= maxSamples ) {
1088
1615
 
1089
- denoiser.update( pathTracer.outputTarget );
1616
+ denoiser.update( pathTracer.outputTarget, pathTracer.auxTarget );
1090
1617
 
1091
1618
  }
1092
1619
 
@@ -1094,26 +1621,41 @@ export class WebGPUPathTracer {
1094
1621
 
1095
1622
  }
1096
1623
 
1097
- if ( upscaler && ! lowResMode ) {
1624
+ // the upscale and the draw only serve the display
1625
+ if ( this.renderToCanvas ) {
1098
1626
 
1099
- texture = upscaler.upscale( texture, this.camera );
1627
+ if ( upscaler && ! lowResMode ) {
1100
1628
 
1101
- // the pass above binds its own targets
1102
- renderer.setRenderTarget( originalTarget );
1629
+ texture = upscaler.upscale( texture, this.camera );
1103
1630
 
1104
- }
1631
+ // the pass above binds its own targets
1632
+ renderer.setRenderTarget( originalTarget );
1633
+
1634
+ }
1635
+
1636
+ renderer.autoClear = dynamicLowRes ? true : opacity === 1.0;
1637
+ blitQuad.material.transition = dynamicLowRes ? opacity : 1.0;
1638
+ blitQuad.material.opacity = dynamicLowRes ? 1.0 : opacity;
1639
+ blitQuad.material.fromTexture = lowResTarget;
1640
+ blitQuad.material.texture = texture;
1641
+ blitQuad.render( renderer );
1105
1642
 
1106
- renderer.autoClear = dynamicLowRes ? true : opacity === 1.0;
1107
- blitQuad.material.transition = dynamicLowRes ? opacity : 1.0;
1108
- blitQuad.material.opacity = dynamicLowRes ? 1.0 : opacity;
1109
- blitQuad.material.fromTexture = lowResTarget;
1110
- blitQuad.material.texture = texture;
1111
- blitQuad.render( renderer );
1643
+ }
1112
1644
 
1113
1645
  // reset the renderer
1114
1646
  renderer.autoClear = originalAutoClear;
1115
1647
  renderer.setRenderTarget( originalTarget );
1116
1648
 
1649
+ // fires once the render, and the denoiser if one is attached, has finished and the result is drawn
1650
+ const complete = this.complete;
1651
+ if ( complete && ! this._wasComplete ) {
1652
+
1653
+ this.dispatchEvent( { type: 'complete' } );
1654
+
1655
+ }
1656
+
1657
+ this._wasComplete = complete;
1658
+
1117
1659
  }
1118
1660
 
1119
1661
  /**
@@ -1263,6 +1805,42 @@ export class WebGPUPathTracer {
1263
1805
 
1264
1806
  }
1265
1807
 
1808
+ /**
1809
+ * Draws the albedo or normal auxiliary buffer accumulated for the denoiser, for checking what
1810
+ * it is given. Nothing is drawn until a denoiser that uses them is attached.
1811
+ *
1812
+ * @param {'albedo'|'normal'} [buffer='albedo']
1813
+ */
1814
+ renderAuxBuffer( buffer = 'albedo' ) {
1815
+
1816
+ const renderer = this._renderer;
1817
+ const pathTracer = this._pathTracer;
1818
+
1819
+ if ( ! renderer._initialized || ! pathTracer.auxTarget ) {
1820
+
1821
+ return;
1822
+
1823
+ }
1824
+
1825
+ if ( ! this._auxDebugQuad ) {
1826
+
1827
+ this._auxDebugQuad = new FullScreenQuad( new AuxDebugMaterial() );
1828
+
1829
+ }
1830
+
1831
+ const originalToneMapping = renderer.toneMapping;
1832
+ renderer.toneMapping = NoToneMapping;
1833
+
1834
+ const quad = this._auxDebugQuad;
1835
+ quad.material.texture = pathTracer.auxTarget;
1836
+ quad.material.showNormal = buffer === 'normal';
1837
+ renderer.setRenderTarget( null );
1838
+ quad.render( renderer );
1839
+
1840
+ renderer.toneMapping = originalToneMapping;
1841
+
1842
+ }
1843
+
1266
1844
  /**
1267
1845
  * Frees every GPU resource held by the tracer, including any attached denoiser and upscaler.
1268
1846
  */
@@ -1284,6 +1862,8 @@ export class WebGPUPathTracer {
1284
1862
  this._atlasDebugQuad?.material.dispose();
1285
1863
  this._sampleDensityQuad?.dispose();
1286
1864
  this._sampleDensityQuad?.material.dispose();
1865
+ this._auxDebugQuad?.dispose();
1866
+ this._auxDebugQuad?.material.dispose();
1287
1867
 
1288
1868
  if ( this._debugBoundsQuad !== undefined ) {
1289
1869