rayzee 9.3.1 → 9.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "9.3.1",
3
+ "version": "9.3.3",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -1,15 +1,8 @@
1
1
  import { EventDispatcher, ACESFilmicToneMapping } from 'three';
2
- import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure, isOutputEncoded } from '../Processor/ToneMapCPU.js';
2
+ import { PackedToneMapper } from '../Processor/ToneMapGPU.js';
3
3
  import { getAssetConfig } from '../AssetConfig.js';
4
4
  import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
5
5
 
6
-
7
- // An OCIO view already returned colour encoded for its display, so the gamma step below would
8
- // encode it twice. A built-in curve returns linear and does want it.
9
- const displayEncoder = toneMapping => isOutputEncoded( toneMapping )
10
- ? ( c => ( c > 1 ? 1 : c < 0 ? 0 : c ) )
11
- : ( c => Math.pow( c, SRGB_GAMMA ) );
12
-
13
6
  // ─── Model Configuration ───────────────────────────────────────────────────────
14
7
  // Quality presets reference relative paths against asset-config `upscalerModelBaseUrl`.
15
8
 
@@ -140,13 +133,8 @@ export class AIUpscaler extends EventDispatcher {
140
133
  this._baseWidth = output.width;
141
134
  this._baseHeight = output.height;
142
135
 
143
- // Pooled HDR readback staging buffer — reused across _captureSourceHDR calls.
144
- // Rebuilt only when the source texture dimensions change (same spirit as
145
- // r184's ReadbackBuffer; we can't use renderer.getArrayBufferAsync directly
146
- // because our source is a raw GPUTexture, not a Three.js BufferAttribute).
147
- this._hdrStagingBuffer = null;
148
- this._hdrStagingWidth = 0;
149
- this._hdrStagingHeight = 0;
136
+ // Tone-maps the HDR source on the card; its buffers are released after each capture.
137
+ this._toneMapper = null;
150
138
 
151
139
  }
152
140
 
@@ -283,7 +271,7 @@ export class AIUpscaler extends EventDispatcher {
283
271
  // so we must grab the pixels before awaiting model load.
284
272
  if ( this.hdr && this.getGPUTextures ) {
285
273
 
286
- // HDR path: read float32 from GPU texture, tonemap happens after upscale
274
+ // HDR path: float planes tone-mapped on the GPU, no 8-bit step before the network
287
275
  this._capturedSource = await this._captureSourceHDR();
288
276
 
289
277
  } else {
@@ -403,17 +391,6 @@ export class AIUpscaler extends EventDispatcher {
403
391
  // The SR model outputs RGB only — alpha would be lost without this.
404
392
  this._cacheUpscaledAlpha( sourceImageData, srcW * scale, srcH * scale );
405
393
 
406
- // Cache HDR tonemapping state for tile extraction (avoids per-pixel lookups)
407
- if ( sourceImageData.isHDR ) {
408
-
409
- this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
410
- this._hdrEncode = displayEncoder( this.getToneMapping() );
411
- this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
412
- this._hdrSaturation = this.getSaturation();
413
- this._tmOut = new Float32Array( 3 );
414
-
415
- }
416
-
417
394
  // Tile-based inference
418
395
  const overlap = MODEL_CONFIG.TILE_OVERLAP;
419
396
  const tileSize = this.tileSize;
@@ -503,121 +480,72 @@ export class AIUpscaler extends EventDispatcher {
503
480
  }
504
481
 
505
482
  /**
506
- * HDR capture: reads float32 color data directly from the path tracer's GPU texture.
507
- * Returns an ImageData-like object with float32 RGBA data and dimensions.
483
+ * HDR capture: tone-maps the path tracer's float colour on the card and reads back float
484
+ * planes — r, g, b, a — so the network gets the curve without 8-bit quantization.
508
485
  */
509
486
  async _captureSourceHDR() {
510
487
 
511
488
  const gpuTextures = this.getGPUTextures();
512
489
  if ( ! gpuTextures?.color ) throw new Error( 'No GPU color texture available for HDR capture' );
513
490
 
514
- const device = this.renderer.backend.device;
515
491
  const colorTexture = gpuTextures.color;
516
- const width = colorTexture.width;
517
- const height = colorTexture.height;
518
-
519
- // GPU texture → pooled staging buffer → CPU readback.
520
- // The staging buffer is kept alive between calls (unmap, don't destroy)
521
- // and only re-created when texture dimensions change.
522
- const bytesPerRow = Math.ceil( width * 16 / 256 ) * 256; // rgba32float=16 bytes, aligned to 256
523
- const bufferSize = bytesPerRow * height;
524
-
525
- if ( this._hdrStagingWidth !== width || this._hdrStagingHeight !== height ) {
526
-
527
- this._hdrStagingBuffer?.destroy();
528
- this._hdrStagingBuffer = device.createBuffer( {
529
- label: 'aiupscaler-hdr-readback',
530
- size: bufferSize,
531
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
532
- } );
533
- this._hdrStagingWidth = width;
534
- this._hdrStagingHeight = height;
535
-
536
- }
537
-
538
- const stagingBuffer = this._hdrStagingBuffer;
539
-
540
- const encoder = device.createCommandEncoder();
541
- encoder.copyTextureToBuffer(
542
- { texture: colorTexture },
543
- { buffer: stagingBuffer, bytesPerRow, rowsPerImage: height },
544
- { width, height, depthOrArrayLayers: 1 }
545
- );
546
- device.queue.submit( [ encoder.finish() ] );
547
-
548
- await stagingBuffer.mapAsync( GPUMapMode.READ );
549
- const mappedData = new Float32Array( stagingBuffer.getMappedRange() );
550
-
551
- // Copy to ImageData-like structure (handle row alignment padding)
552
- const pixelFloats = width * 4;
553
- const rowFloats = bytesPerRow / 4;
554
- const data = new Float32Array( width * height * 4 );
555
-
556
- for ( let y = 0; y < height; y ++ ) {
557
-
558
- const srcOffset = y * rowFloats;
559
- const dstOffset = y * pixelFloats;
560
- data.set( mappedData.subarray( srcOffset, srcOffset + pixelFloats ), dstOffset );
561
-
562
- }
563
-
564
- stagingBuffer.unmap();
492
+ const { width, height } = colorTexture;
493
+
494
+ this._toneMapper ??= new PackedToneMapper( this.renderer.backend.device, 'aiupscaler-tonemap', { input: 'texture', output: 'planar' } );
495
+ this._toneMapper.ensureSize( width, height );
496
+ const planes = await this._toneMapper.toPlanar( colorTexture, {
497
+ exposure: this.getExposure(),
498
+ toneMapping: this.getToneMapping() ?? ACESFilmicToneMapping,
499
+ saturation: this.getSaturation(),
500
+ } );
501
+ this._toneMapper.release();
565
502
 
566
- // Mark as HDR so _extractTile and _tensorToImageData handle it correctly
567
- return { data, width, height, isHDR: true };
503
+ return { planes, width, height, isHDR: true };
568
504
 
569
505
  }
570
506
 
571
507
  /**
572
508
  * Extracts a tile region from the source image.
573
509
  * Returns a Float32Array in NCHW format [1, 3, H, W] with values in [0, 1].
574
- * Handles both LDR (uint8 ImageData) and HDR (float32) sources.
510
+ * Handles both LDR (uint8 ImageData) and HDR (tone-mapped float planes) sources.
575
511
  */
576
512
  _extractTile( sourceImageData, x, y, w, h ) {
577
513
 
578
- const { data, width } = sourceImageData;
579
- const isHDR = sourceImageData.isHDR;
514
+ const { width } = sourceImageData;
580
515
  const pixelCount = w * h;
581
516
  const floats = new Float32Array( 3 * pixelCount );
582
517
 
583
- for ( let row = 0; row < h; row ++ ) {
518
+ if ( sourceImageData.isHDR ) {
584
519
 
585
- for ( let col = 0; col < w; col ++ ) {
520
+ const { planes } = sourceImageData;
521
+ const plane = width * sourceImageData.height;
522
+ for ( let c = 0; c < 3; c ++ ) {
586
523
 
587
- const srcIdx = ( ( y + row ) * width + ( x + col ) ) * 4;
588
- const dstIdx = row * w + col;
524
+ for ( let row = 0; row < h; row ++ ) {
589
525
 
590
- if ( isHDR ) {
526
+ const from = c * plane + ( y + row ) * width + x;
527
+ floats.set( planes.subarray( from, from + w ), c * pixelCount + row * w );
591
528
 
592
- // HDR: exposure + saturation + tonemap + gamma to sRGB [0,1] at float32 precision.
593
- // The SR model expects sRGB-range input — we tonemap before the model
594
- // but keep float32 precision (no uint8 quantization bottleneck).
595
- const tmFn = this._hdrToneMapFn;
596
- const exposure = this._hdrExposure;
597
- const saturation = this._hdrSaturation;
598
- let er = data[ srcIdx ] * exposure, eg = data[ srcIdx + 1 ] * exposure, eb = data[ srcIdx + 2 ] * exposure;
599
- if ( saturation !== 1.0 ) {
529
+ }
600
530
 
601
- this._tmOut[ 0 ] = er; this._tmOut[ 1 ] = eg; this._tmOut[ 2 ] = eb;
602
- applySaturation( this._tmOut, saturation );
603
- er = this._tmOut[ 0 ]; eg = this._tmOut[ 1 ]; eb = this._tmOut[ 2 ];
531
+ }
604
532
 
605
- }
533
+ return floats;
534
+
535
+ }
606
536
 
607
- tmFn( er, eg, eb, 1.0, this._tmOut );
608
- const encode = this._hdrEncode;
609
- floats[ dstIdx ] = encode( this._tmOut[ 0 ] );
610
- floats[ pixelCount + dstIdx ] = encode( this._tmOut[ 1 ] );
611
- floats[ 2 * pixelCount + dstIdx ] = encode( this._tmOut[ 2 ] );
537
+ const { data } = sourceImageData;
538
+ for ( let row = 0; row < h; row ++ ) {
612
539
 
613
- } else {
540
+ for ( let col = 0; col < w; col ++ ) {
614
541
 
615
- // LDR: normalize uint8 [0,255] to [0,1]
616
- floats[ dstIdx ] = data[ srcIdx ] / 255;
617
- floats[ pixelCount + dstIdx ] = data[ srcIdx + 1 ] / 255;
618
- floats[ 2 * pixelCount + dstIdx ] = data[ srcIdx + 2 ] / 255;
542
+ const srcIdx = ( ( y + row ) * width + ( x + col ) ) * 4;
543
+ const dstIdx = row * w + col;
619
544
 
620
- }
545
+ // LDR: normalize uint8 [0,255] to [0,1]
546
+ floats[ dstIdx ] = data[ srcIdx ] / 255;
547
+ floats[ pixelCount + dstIdx ] = data[ srcIdx + 1 ] / 255;
548
+ floats[ 2 * pixelCount + dstIdx ] = data[ srcIdx + 2 ] / 255;
621
549
 
622
550
  }
623
551
 
@@ -715,14 +643,19 @@ export class AIUpscaler extends EventDispatcher {
715
643
  */
716
644
  _cacheUpscaledAlpha( sourceImageData, outW, outH ) {
717
645
 
718
- const { data, width, height } = sourceImageData;
646
+ const { width, height } = sourceImageData;
647
+ const count = width * height;
719
648
 
649
+ // HDR alpha is the fourth plane, LDR every fourth byte.
720
650
  const isHDR = sourceImageData.isHDR;
651
+ const data = isHDR ? sourceImageData.planes.subarray( 3 * count ) : sourceImageData.data;
652
+ const stride = isHDR ? 1 : 4;
653
+ const offset = isHDR ? 0 : 3;
721
654
  const opaqueVal = isHDR ? 1.0 : 255;
722
655
 
723
656
  // Check if source has any non-opaque pixels
724
657
  let hasAlpha = false;
725
- for ( let i = 3; i < data.length; i += 4 ) {
658
+ for ( let i = offset; i < data.length; i += stride ) {
726
659
 
727
660
  if ( data[ i ] < opaqueVal ) {
728
661
 
@@ -748,10 +681,10 @@ export class AIUpscaler extends EventDispatcher {
748
681
  const srcCtx = srcCanvas.getContext( '2d' );
749
682
  const alphaImage = srcCtx.createImageData( width, height );
750
683
 
751
- for ( let i = 0, len = width * height; i < len; i ++ ) {
684
+ for ( let i = 0; i < count; i ++ ) {
752
685
 
753
686
  const a = isHDR
754
- ? Math.min( Math.max( data[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0
687
+ ? Math.min( Math.max( data[ i ] * 255, 0 ), 255 ) | 0
755
688
  : data[ i * 4 + 3 ];
756
689
  alphaImage.data[ i * 4 ] = a;
757
690
  alphaImage.data[ i * 4 + 1 ] = a;
@@ -797,32 +730,18 @@ export class AIUpscaler extends EventDispatcher {
797
730
 
798
731
  if ( sourceImageData.isHDR ) {
799
732
 
800
- // HDR source: tonemap to LDR for the backup canvas display
801
- const { data, width, height } = sourceImageData;
733
+ // Already tone-mapped and encoded on the card; only quantized here.
734
+ const { planes, width, height } = sourceImageData;
735
+ const count = width * height;
802
736
  const imageData = ctx.createImageData( width, height );
803
737
  const pixels = imageData.data;
804
- const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
805
- const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
806
- const encode = displayEncoder( this.getToneMapping() );
807
- const saturation = this.getSaturation();
808
- const out = new Float32Array( 3 );
809
738
 
810
- for ( let i = 0, len = width * height; i < len; i ++ ) {
739
+ for ( let i = 0; i < count; i ++ ) {
811
740
 
812
741
  const si = i * 4;
813
- let er = data[ si ] * exposure, eg = data[ si + 1 ] * exposure, eb = data[ si + 2 ] * exposure;
814
- if ( saturation !== 1.0 ) {
815
-
816
- out[ 0 ] = er; out[ 1 ] = eg; out[ 2 ] = eb;
817
- applySaturation( out, saturation );
818
- er = out[ 0 ]; eg = out[ 1 ]; eb = out[ 2 ];
819
-
820
- }
821
-
822
- tmFn( er, eg, eb, 1.0, out );
823
- pixels[ si ] = ( encode( out[ 0 ] ) * 255 + 0.5 ) | 0;
824
- pixels[ si + 1 ] = ( encode( out[ 1 ] ) * 255 + 0.5 ) | 0;
825
- pixels[ si + 2 ] = ( encode( out[ 2 ] ) * 255 + 0.5 ) | 0;
742
+ pixels[ si ] = ( planes[ i ] * 255 + 0.5 ) | 0;
743
+ pixels[ si + 1 ] = ( planes[ count + i ] * 255 + 0.5 ) | 0;
744
+ pixels[ si + 2 ] = ( planes[ 2 * count + i ] * 255 + 0.5 ) | 0;
826
745
  pixels[ si + 3 ] = 255;
827
746
 
828
747
  }
@@ -927,10 +846,8 @@ export class AIUpscaler extends EventDispatcher {
927
846
  this._upscaledAlpha = null;
928
847
  this.state.abortController = null;
929
848
 
930
- this._hdrStagingBuffer?.destroy();
931
- this._hdrStagingBuffer = null;
932
- this._hdrStagingWidth = 0;
933
- this._hdrStagingHeight = 0;
849
+ this._toneMapper?.dispose();
850
+ this._toneMapper = null;
934
851
 
935
852
  console.log( 'AIUpscaler disposed' );
936
853
 
@@ -182,6 +182,20 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
182
182
  }
183
183
  `;
184
184
 
185
+ const PACK_WG_SIZE = 8;
186
+ const PACK_WGSL = /* wgsl */`
187
+ @group(0) @binding(0) var src: texture_2d<f32>;
188
+ @group(0) @binding(1) var<storage, read_write> dst: array<vec4<f32>>;
189
+ @group(0) @binding(2) var<uniform> size: vec2<u32>;
190
+
191
+ @compute @workgroup_size(${PACK_WG_SIZE}, ${PACK_WG_SIZE})
192
+ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
193
+
194
+ if ( gid.x >= size.x || gid.y >= size.y ) { return; }
195
+ dst[ gid.y * size.x + gid.x ] = textureLoad( src, gid.xy, 0 );
196
+ }
197
+ `;
198
+
185
199
  export class OIDNDenoiser extends EventDispatcher {
186
200
 
187
201
  /**
@@ -210,11 +224,10 @@ export class OIDNDenoiser extends EventDispatcher {
210
224
  // Cached GPU storage buffers for texture→buffer copies (reused across denoise calls)
211
225
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
212
226
  this._gpuInputBufferSize = { width: 0, height: 0 };
213
- // Shared pad-strip buffer for non-256-aligned widths. Reused across
214
- // color/albedo/normal copies within the same encoder (WebGPU command
215
- // order guarantees the overwrites are serialized).
216
- this._gpuInputPadBuffer = null;
217
- this._gpuInputPaddedRowBytes = 0;
227
+ // Packs widths copyTextureToBuffer cannot take; see _beginPack.
228
+ this._packPipeline = null;
229
+ this._packLayout = null;
230
+ this._packParams = null;
218
231
  // The autoexposure scale never leaves the GPU — _applyColorScale and the output pack
219
232
  // both read _inputScaleBuffer directly.
220
233
  this._lumReducePipeline = null;
@@ -671,42 +684,62 @@ export class OIDNDenoiser extends EventDispatcher {
671
684
  // Ensure storage buffers are sized correctly (recreate on resolution change)
672
685
  this._ensureGPUInputBuffers( width, height );
673
686
 
674
- // Copy render target textures → tightly packed GPU storage buffers for oidn-web.
675
- // copyTextureToBuffer requires bytesPerRow to be a multiple of 256. When the tight
676
- // row size (width * 16) isn't aligned, copy via a shared pre-allocated padded buffer
677
- // (see _ensureGPUInputBuffers) then strip padding row-by-row. The pad buffer is
678
- // reused across color/albedo/normal — safe because WebGPU serializes commands
679
- // within a single encoder.
687
+ this._copyInputs( device, textures, width, height );
688
+
689
+ // Autoexposure and the pre-multiply it drives, both on the GPU and both queued behind the
690
+ // copies above. Nothing is awaited here — an await would drain the queue before the UNet.
691
+ this._computeInputScale( device, width * height );
692
+ this._applyColorScale( device, width * height );
693
+
694
+ // A final denoise is shown tile by tile, so the tiles still to come must show this render
695
+ // rather than the blank or previous-view picture the output holds between runs.
696
+ if ( ! continuous && ! this._hasOutput ) {
697
+
698
+ this._unpackToTexture( this._gpuInputBuffers.color, width, { x: 0, y: 0, width, height }, false );
699
+
700
+ }
701
+
702
+ // Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
703
+ const config = {
704
+ color: { data: this._gpuInputBuffers.color, width, height },
705
+ albedo: { data: this._gpuInputBuffers.albedo, width, height },
706
+ normal: { data: this._gpuInputBuffers.normal, width, height }
707
+ };
708
+
709
+ return this._executeWithAbortGPU( config, continuous );
710
+
711
+ }
712
+
713
+ /** Copies the render's colour, albedo and normal into the tightly packed buffers oidn-web reads. @private */
714
+ _copyInputs( device, textures, width, height ) {
715
+
716
+ // copyTextureToBuffer takes only 256-byte-aligned rows; any other width packs on the GPU.
680
717
  const encoder = device.createCommandEncoder( { label: 'oidn-tex-to-buf' } );
681
718
  const tightRowBytes = width * 16; // rgba32float
682
- const paddedRowBytes = this._gpuInputPaddedRowBytes;
683
- const needsPadStrip = paddedRowBytes > tightRowBytes;
684
- const padBuf = this._gpuInputPadBuffer;
719
+ const pass = tightRowBytes % 256 === 0 ? null : this._beginPack( device, encoder, width, height );
685
720
 
686
721
  const copyTex = ( tex, tightBuf ) => {
687
722
 
688
- if ( ! needsPadStrip ) {
723
+ if ( pass ) {
689
724
 
690
- encoder.copyTextureToBuffer(
691
- { texture: tex, mipLevel: 0 },
692
- { buffer: tightBuf, offset: 0, bytesPerRow: tightRowBytes, rowsPerImage: height },
693
- { width, height, depthOrArrayLayers: 1 }
694
- );
725
+ pass.setBindGroup( 0, device.createBindGroup( {
726
+ layout: this._packLayout,
727
+ entries: [
728
+ { binding: 0, resource: tex.createView() },
729
+ { binding: 1, resource: { buffer: tightBuf } },
730
+ { binding: 2, resource: { buffer: this._packParams } },
731
+ ],
732
+ } ) );
733
+ pass.dispatchWorkgroups( Math.ceil( width / PACK_WG_SIZE ), Math.ceil( height / PACK_WG_SIZE ) );
695
734
 
696
735
  } else {
697
736
 
698
737
  encoder.copyTextureToBuffer(
699
738
  { texture: tex, mipLevel: 0 },
700
- { buffer: padBuf, offset: 0, bytesPerRow: paddedRowBytes, rowsPerImage: height },
739
+ { buffer: tightBuf, offset: 0, bytesPerRow: tightRowBytes, rowsPerImage: height },
701
740
  { width, height, depthOrArrayLayers: 1 }
702
741
  );
703
742
 
704
- for ( let row = 0; row < height; row ++ ) {
705
-
706
- encoder.copyBufferToBuffer( padBuf, row * paddedRowBytes, tightBuf, row * tightRowBytes, tightRowBytes );
707
-
708
- }
709
-
710
743
  }
711
744
 
712
745
  };
@@ -719,30 +752,9 @@ export class OIDNDenoiser extends EventDispatcher {
719
752
  // measured 0.878 -> 2.439 denoise ratio at 64 spp. Do not "fix" it.
720
753
  copyTex( textures.normal, this._gpuInputBuffers.normal );
721
754
 
755
+ pass?.end();
722
756
  device.queue.submit( [ encoder.finish() ] );
723
757
 
724
- // Autoexposure and the pre-multiply it drives, both on the GPU and both queued behind the
725
- // copies above. Nothing is awaited here — an await would drain the queue before the UNet.
726
- this._computeInputScale( device, width * height );
727
- this._applyColorScale( device, width * height );
728
-
729
- // A final denoise is shown tile by tile, so the tiles still to come must show this render
730
- // rather than the blank or previous-view picture the output holds between runs.
731
- if ( ! continuous && ! this._hasOutput ) {
732
-
733
- this._unpackToTexture( this._gpuInputBuffers.color, width, { x: 0, y: 0, width, height }, false );
734
-
735
- }
736
-
737
- // Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
738
- const config = {
739
- color: { data: this._gpuInputBuffers.color, width, height },
740
- albedo: { data: this._gpuInputBuffers.albedo, width, height },
741
- normal: { data: this._gpuInputBuffers.normal, width, height }
742
- };
743
-
744
- return this._executeWithAbortGPU( config, continuous );
745
-
746
758
  }
747
759
 
748
760
  /**
@@ -767,25 +779,41 @@ export class OIDNDenoiser extends EventDispatcher {
767
779
  this._gpuInputBuffers.normal = device.createBuffer( { label: 'oidn-in-normal', size: byteSize, usage } );
768
780
  this._gpuInputBufferSize = { width, height };
769
781
 
770
- // Pre-allocate the row-pad staging buffer when width * 16 isn't 256-aligned.
771
- // Shared across the three texture copies; recreated only on resolution change.
772
- const tightRowBytes = width * 16;
773
- const paddedRowBytes = Math.ceil( tightRowBytes / 256 ) * 256;
774
- if ( paddedRowBytes !== tightRowBytes ) {
782
+ }
783
+
784
+ /** Opens the compute pass that packs unaligned rows; one dispatch per texture follows. @private */
785
+ _beginPack( device, encoder, width, height ) {
786
+
787
+ if ( ! this._packPipeline ) {
775
788
 
776
- this._gpuInputPadBuffer = device.createBuffer( {
777
- label: 'oidn-in-pad',
778
- size: paddedRowBytes * height,
779
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
789
+ // Explicit: rgba32float is unfilterable, which an auto layout would not declare.
790
+ this._packLayout = device.createBindGroupLayout( {
791
+ label: 'oidn-pack',
792
+ entries: [
793
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'unfilterable-float' } },
794
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
795
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
796
+ ],
780
797
  } );
781
- this._gpuInputPaddedRowBytes = paddedRowBytes;
798
+ this._packPipeline = device.createComputePipeline( {
799
+ label: 'oidn-pack',
800
+ layout: device.createPipelineLayout( { bindGroupLayouts: [ this._packLayout ] } ),
801
+ compute: { module: device.createShaderModule( { label: 'oidn-pack', code: PACK_WGSL } ), entryPoint: 'main' },
802
+ } );
803
+
804
+ }
782
805
 
783
- } else {
806
+ if ( ! this._packParams ) {
784
807
 
785
- this._gpuInputPaddedRowBytes = tightRowBytes;
808
+ this._packParams = device.createBuffer( { label: 'oidn-pack-size', size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST } );
809
+ device.queue.writeBuffer( this._packParams, 0, new Uint32Array( [ width, height ] ) );
786
810
 
787
811
  }
788
812
 
813
+ const pass = encoder.beginComputePass( { label: 'oidn-pack' } );
814
+ pass.setPipeline( this._packPipeline );
815
+ return pass;
816
+
789
817
  }
790
818
 
791
819
  _destroyGPUInputBuffers() {
@@ -793,7 +821,7 @@ export class OIDNDenoiser extends EventDispatcher {
793
821
  this._gpuInputBuffers.color?.destroy();
794
822
  this._gpuInputBuffers.albedo?.destroy();
795
823
  this._gpuInputBuffers.normal?.destroy();
796
- this._gpuInputPadBuffer?.destroy();
824
+ this._packParams?.destroy();
797
825
 
798
826
  // Bind groups hold the destroyed buffers; pipelines are device-scoped and survive.
799
827
  this._colorScaleBindGroup = null;
@@ -805,8 +833,7 @@ export class OIDNDenoiser extends EventDispatcher {
805
833
  this._inputScaleBuffer?.destroy();
806
834
  this._inputScaleBuffer = null;
807
835
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
808
- this._gpuInputPadBuffer = null;
809
- this._gpuInputPaddedRowBytes = 0;
836
+ this._packParams = null;
810
837
  this._gpuInputBufferSize = { width: 0, height: 0 };
811
838
 
812
839
  }
@@ -994,7 +1021,7 @@ export class OIDNDenoiser extends EventDispatcher {
994
1021
 
995
1022
  const { color, albedo, normal } = this._gpuInputBuffers;
996
1023
  const out = this._outGPUTexture ? this._outTexSize.width * this._outTexSize.height * 8 : 0; // rgba16float
997
- return ( color?.size ?? 0 ) + ( albedo?.size ?? 0 ) + ( normal?.size ?? 0 ) + ( this._gpuInputPadBuffer?.size ?? 0 ) + out;
1024
+ return ( color?.size ?? 0 ) + ( albedo?.size ?? 0 ) + ( normal?.size ?? 0 ) + out;
998
1025
 
999
1026
  }
1000
1027
 
@@ -1925,10 +1925,10 @@ export class PathTracerApp extends EventDispatcher {
1925
1925
  async refitBVH( newPositions, newNormals ) {
1926
1926
 
1927
1927
  await this.ensureSceneResident();
1928
- const result = await this._sdf.refitBVH( newPositions, newNormals );
1928
+ const { triRanges, bvhRanges, ...result } = await this._sdf.refitBVH( newPositions, newNormals );
1929
1929
 
1930
- this.stages.pathTracer.updateTriangleData( this._sdf.triangles );
1931
- this.stages.pathTracer.updateBVHData( this._sdf.bvh );
1930
+ this.stages.pathTracer.updateTriangleData( this._sdf.triangles, triRanges );
1931
+ this.stages.pathTracer.updateBVHData( this._sdf.bvh, bvhRanges );
1932
1932
  this.reset( false, { motion: true } );
1933
1933
 
1934
1934
  return result;
@@ -194,6 +194,29 @@ function triangleBounds( acc, first, count, out, off ) {
194
194
 
195
195
  const folded = new Float32Array( 12 );
196
196
 
197
+ /** A node's own box from what it stores — its two child boxes, or a leaf's triangles — into out[ 0 .. 6 ). */
198
+ function storedBounds( nodes, n, acc, out ) {
199
+
200
+ const f = nodeF( nodes, n );
201
+ const idx = nodeIdx( nodes, n );
202
+ const o = nodeBase( nodes, n );
203
+
204
+ if ( idx[ o + 3 ] === LEAF_MARKER ) {
205
+
206
+ triangleBounds( acc, idx[ o ], idx[ o + 1 ], out, 0 );
207
+ return;
208
+
209
+ }
210
+
211
+ for ( let a = 0; a < 3; a ++ ) {
212
+
213
+ out[ a ] = Math.min( f[ o + a ], f[ o + 8 + a ] );
214
+ out[ 3 + a ] = Math.max( f[ o + 4 + a ], f[ o + 12 + a ] );
215
+
216
+ }
217
+
218
+ }
219
+
197
220
  /**
198
221
  * One node of a bottom-up refit: its own bounds into `bounds[ ( i - base ) * 6 ]`, and an inner
199
222
  * node's two child boxes rewritten from theirs. A leaf folded into its parent takes its box from
@@ -264,6 +287,8 @@ export class BVHRefitter {
264
287
  // Resized only when nodeCount changes (i.e., new scene loaded).
265
288
  this._bounds = null;
266
289
  this._boundsNodeCount = 0;
290
+ this._tlasBounds = null;
291
+ this._rootBox = null;
267
292
 
268
293
  }
269
294
 
@@ -355,6 +380,45 @@ export class BVHRefitter {
355
380
 
356
381
  }
357
382
 
383
+ /**
384
+ * Refit the given BLAS node ranges, then the TLAS from every BLAS root's stored box. The same
385
+ * nodes as refit() when every other BLAS is as the last refit left it.
386
+ *
387
+ * @param {Float32Array} bvhData - Combined BVH array (TLAS + all BLASes)
388
+ * @param {Uint32Array} triangleData - Global triangle records
389
+ * @param {ArrayLike<number>} blasRanges - flat [ startNode, nodeCount, ... ]
390
+ * @param {number} tlasNodeCount - the TLAS occupies nodes [0, tlasNodeCount)
391
+ */
392
+ refitPartial( bvhData, triangleData, blasRanges, tlasNodeCount ) {
393
+
394
+ for ( let r = 0; r < blasRanges.length; r += 2 ) this.refitRange( bvhData, triangleData, blasRanges[ r ], blasRanges[ r + 1 ] );
395
+
396
+ const acc = triAccess( triangleData );
397
+ const nodes = nodeAccess( bvhData );
398
+ if ( ! this._tlasBounds || this._tlasBounds.length < tlasNodeCount * 6 ) this._tlasBounds = new Float32Array( tlasNodeCount * 6 );
399
+ const bounds = this._tlasBounds;
400
+ const rootBox = this._rootBox ||= new Float32Array( 6 );
401
+
402
+ for ( let i = tlasNodeCount - 1; i >= 0; i -- ) {
403
+
404
+ const idx = nodeIdx( nodes, i );
405
+ const o = nodeBase( nodes, i );
406
+
407
+ if ( idx[ o + 3 ] === BLAS_POINTER_MARKER ) {
408
+
409
+ storedBounds( nodes, idx[ o ], acc, rootBox );
410
+ transformBoundsToWorld( nodeF( nodes, i ), o, rootBox, 0, bounds, i * 6 );
411
+
412
+ } else {
413
+
414
+ refitNode( nodes, i, acc, bounds, 0 );
415
+
416
+ }
417
+
418
+ }
419
+
420
+ }
421
+
358
422
  /**
359
423
  * Bottom-up refit of all BVH node AABBs.
360
424
  * Reverse pre-order iteration gives valid bottom-up order (children have higher