rayzee 7.29.0 → 7.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.29.0",
3
+ "version": "7.30.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -224,6 +224,10 @@ export const ENGINE_DEFAULTS = {
224
224
  asvgfQualityPreset: 'medium',
225
225
  showAsvgfHeatmap: false,
226
226
 
227
+ // NRD (ReBLUR port) real-time denoiser — see NRD_DEFAULTS / NRD_QUALITY_PRESETS.
228
+ nrdQualityPreset: 'medium',
229
+ nrdDebugMode: 0,
230
+
227
231
  // Auto-exposure settings
228
232
  autoExposure: false,
229
233
  autoExposureKeyValue: 0.18,
@@ -233,6 +237,16 @@ export const ENGINE_DEFAULTS = {
233
237
  autoExposureAdaptSpeedDark: 0.5,
234
238
  };
235
239
 
240
+ // Ray distance NormalDepth writes on a miss. Max finite half-float, so miss−miss diffs stay 0
241
+ // rather than Inf−Inf=NaN.
242
+ export const GBUFFER_MISS_DEPTH = 65504.0;
243
+ export const GBUFFER_MISS_THRESHOLD = 6e4;
244
+
245
+ // normHitDist = hitDist / (A + B·viewZ). A constant, not a uniform: the Shade write and the NRD
246
+ // decode must agree, and the normalization is a pure round trip.
247
+ export const NRD_HIT_DIST_A = 3.0;
248
+ export const NRD_HIT_DIST_B = 0.1;
249
+
236
250
  // Albedo demodulation safety floor. ASVGF and BilateralFilter MUST use the
237
251
  // same value — demod (`color / safeAlbedo`) and remod (`lighting * safeAlbedo`)
238
252
  // only round-trip exactly when both sides agree.
@@ -307,6 +321,67 @@ export const ASVGF_QUALITY_PRESETS = {
307
321
  }
308
322
  };
309
323
 
324
+ // NRD ReBLUR port (Stages/NRD.js). Names follow nrd::ReblurSettings so NVIDIA's tuning notes apply.
325
+ export const NRD_DEFAULTS = {
326
+ maxAccumulatedFrameNum: 30,
327
+ maxFastAccumulatedFrameNum: 6,
328
+ maxStabilizedFrameNum: 63,
329
+ historyFixFrameNum: 3,
330
+ historyFixBasePixelStride: 14,
331
+ prepassBlurRadius: 30,
332
+ minBlurRadius: 1,
333
+ maxBlurRadius: 30,
334
+ lobeAngleFraction: 0.15,
335
+ roughnessFraction: 0.15,
336
+ planeDistanceSensitivity: 0.02,
337
+ minHitDistanceWeight: 0.1,
338
+ fastHistoryClampingSigmaScale: 2.0,
339
+ fireflySuppressorMinRelativeScale: 2.0,
340
+ enableAntiFirefly: true,
341
+ antilagLuminanceSigmaScale: 2.0,
342
+ antilagLuminanceSensitivity: 3.0,
343
+ disocclusionThreshold: 0.01,
344
+ convergenceS: 1.0,
345
+ convergenceB: 0.2,
346
+ convergenceP: 0.8,
347
+ // Share of the lobe the normal weight accepts before any history exists. NRD's own constant is
348
+ // 0.75, which its source flags as probably too much; at that width it smears curved surfaces.
349
+ lobeVolumePercent: 0.1,
350
+ // Progressive handover: input sample count at which the denoiser passes the render through untouched.
351
+ // 0 = 2 · maxAccumulatedFrameNum.
352
+ handoverFrames: 0,
353
+ };
354
+
355
+ // Keys a preset may override. Applying one resets every key to its default first, so a preset only
356
+ // states its deltas and `medium` can be empty.
357
+ export const NRD_PRESET_KEYS = [
358
+ 'maxAccumulatedFrameNum', 'maxFastAccumulatedFrameNum', 'maxStabilizedFrameNum',
359
+ 'historyFixFrameNum', 'prepassBlurRadius', 'maxBlurRadius', 'enableAntiFirefly',
360
+ ];
361
+
362
+ export const NRD_QUALITY_PRESETS = {
363
+ // Short history + no pre-pass: most responsive, noisiest.
364
+ low: {
365
+ maxAccumulatedFrameNum: 16,
366
+ maxFastAccumulatedFrameNum: 4,
367
+ maxStabilizedFrameNum: 16,
368
+ historyFixFrameNum: 2,
369
+ prepassBlurRadius: 0,
370
+ maxBlurRadius: 20,
371
+ enableAntiFirefly: false,
372
+ },
373
+ // nrd::ReblurSettings defaults.
374
+ medium: {},
375
+ // Longer history and wider kernels: smoother, more lag on lighting change.
376
+ high: {
377
+ maxAccumulatedFrameNum: 45,
378
+ maxFastAccumulatedFrameNum: 8,
379
+ historyFixFrameNum: 4,
380
+ prepassBlurRadius: 40,
381
+ maxBlurRadius: 40,
382
+ },
383
+ };
384
+
310
385
  export const CAMERA_RANGES = {
311
386
  fov: {
312
387
  min: 10,
@@ -591,11 +666,11 @@ export const TEXTURE_CONSTANTS = {
591
666
  MAX_TEXTURE_SIZE: 8192,
592
667
  // Default cap applied when no maxTextureSize is supplied (engine standalone use).
593
668
  DEFAULT_MAX_TEXTURE_SIZE: 4096,
594
- // Max layers (textures) per bucket array. Also the packing stride for (bucket, layer).
595
- MAX_TEXTURES_LIMIT: 128,
596
- // Size buckets per colorSpace pool. Material maps are grouped into this many
597
- // longest-edge size classes so a small map no longer pays a large neighbour's
598
- // footprint. 4 → ~8 bound material arrays (4 sRGB + 4 linear).
669
+ // Per-map-type ceiling before bucketing; the real cap is per (pool, bucket) in _bucketTextures.
670
+ MAX_TEXTURES_LIMIT: 4 * 256,
671
+ // Array shapes per colorSpace pool. Material maps are grouped into this many
672
+ // (width, height) classes so a small or oddly-shaped map no longer pays a large
673
+ // neighbour's footprint. 4 → ~8 bound material arrays (4 sRGB + 4 linear).
599
674
  MATERIAL_BUCKET_COUNT: 4,
600
675
  // Packing stride: a map's stored index encodes bucketId * BUCKET_LAYER_STRIDE + layer.
601
676
  // Also the per-bucket layer cap. Kept at the WebGPU portable maxTextureArrayLayers floor (256)
@@ -603,30 +678,208 @@ export const TEXTURE_CONSTANTS = {
603
678
  BUCKET_LAYER_STRIDE: 256,
604
679
  };
605
680
 
606
- // Longest-edge size ladder for a given cap, ascending.
607
- // cap=4096, count=4 → [512, 1024, 2048, 4096].
608
- export function getTextureBucketSizes( maxTextureSize, count = TEXTURE_CONSTANTS.MATERIAL_BUCKET_COUNT ) {
681
+ // An RGBA8 row upload must be a multiple of 256 bytes, so bucket widths land on 64 texels.
682
+ const BUCKET_WIDTH_ALIGN = 64;
683
+
684
+ export function alignBucketWidth( width, maxTextureSize ) {
685
+
686
+ const aligned = Math.ceil( Math.max( 1, width ) / BUCKET_WIDTH_ALIGN ) * BUCKET_WIDTH_ALIGN;
687
+ return Math.min( maxTextureSize, Math.max( TEXTURE_CONSTANTS.MIN_TEXTURE_WIDTH, aligned ) );
688
+
689
+ }
690
+
691
+ function bucketOf( units ) {
692
+
693
+ let width = 0, height = 0, count = 0;
694
+ for ( const unit of units ) {
695
+
696
+ width = Math.max( width, unit.width );
697
+ height = Math.max( height, unit.height );
698
+ count += unit.count;
699
+
700
+ }
701
+
702
+ return { units, width, height, count };
703
+
704
+ }
705
+
706
+ // Footprint times layer count.
707
+ function bucketCost( bucket, maxTextureSize ) {
708
+
709
+ return alignBucketWidth( bucket.width, maxTextureSize ) * bucket.height * bucket.count * 4;
710
+
711
+ }
712
+
713
+ function totalCost( buckets, maxTextureSize ) {
714
+
715
+ return buckets.reduce( ( sum, b ) => sum + bucketCost( b, maxTextureSize ), 0 );
716
+
717
+ }
718
+
719
+ // Repeatedly fuse the pair of buckets whose union wastes least, until `count` remain.
720
+ function mergeDown( buckets, count, maxTextureSize ) {
721
+
722
+ let current = buckets.slice();
723
+
724
+ while ( current.length > count ) {
725
+
726
+ let best = null;
727
+
728
+ for ( let i = 0; i < current.length; i ++ ) {
729
+
730
+ for ( let j = i + 1; j < current.length; j ++ ) {
731
+
732
+ const fused = bucketOf( current[ i ].units.concat( current[ j ].units ) );
733
+ const delta = bucketCost( fused, maxTextureSize )
734
+ - bucketCost( current[ i ], maxTextureSize ) - bucketCost( current[ j ], maxTextureSize );
735
+ if ( ! best || delta < best.delta ) best = { i, j, delta, fused };
736
+
737
+ }
738
+
739
+ }
740
+
741
+ current = current.filter( ( _, k ) => k !== best.i && k !== best.j ).concat( [ best.fused ] );
742
+
743
+ }
744
+
745
+ return current;
746
+
747
+ }
748
+
749
+ // The pre-shape-aware layout, kept as a rival opening: square scenes still do well on it.
750
+ function ladderBuckets( units, count, maxTextureSize ) {
609
751
 
610
- const sizes = [];
752
+ const ladder = [];
611
753
  for ( let i = count - 1; i >= 0; i -- ) {
612
754
 
613
- sizes.push( Math.max( TEXTURE_CONSTANTS.MIN_TEXTURE_WIDTH, Math.round( maxTextureSize / Math.pow( 2, i ) ) ) );
755
+ ladder.push( Math.max( TEXTURE_CONSTANTS.MIN_TEXTURE_WIDTH, Math.round( maxTextureSize / Math.pow( 2, i ) ) ) );
614
756
 
615
757
  }
616
758
 
617
- return sizes;
759
+ const bins = ladder.map( () => [] );
760
+
761
+ for ( const unit of units ) {
762
+
763
+ const longest = Math.pow( 2, Math.ceil( Math.log2( Math.max( unit.width, unit.height ) ) ) );
764
+ let slot = ladder.findIndex( size => longest <= size );
765
+ if ( slot < 0 ) slot = ladder.length - 1;
766
+ bins[ slot ].push( unit );
767
+
768
+ }
769
+
770
+ return bins.filter( bin => bin.length ).map( bucketOf );
618
771
 
619
772
  }
620
773
 
621
- // Bucket index for a texture, by its power-of-2 longest edge vs the cap's ladder.
622
- // Larger-than-cap maps land in the top bucket (downscaled to cap, as before).
623
- export function getTextureBucketId( width, height, maxTextureSize, count = TEXTURE_CONSTANTS.MATERIAL_BUCKET_COUNT ) {
774
+ // Both openings above are greedy and stop short on mixed aspects; moving one shape at a time
775
+ // to whichever bucket makes the pool cheapest recovers the difference.
776
+ function refine( buckets, maxTextureSize ) {
777
+
778
+ let current = buckets;
779
+
780
+ for ( let pass = 0; pass < 8; pass ++ ) {
781
+
782
+ let improved = false;
783
+
784
+ for ( const unit of current.flatMap( b => b.units ) ) {
785
+
786
+ const from = current.findIndex( b => b.units.includes( unit ) );
787
+ if ( from < 0 ) continue;
788
+
789
+ for ( let to = 0; to < current.length; to ++ ) {
790
+
791
+ if ( to === from ) continue;
792
+
793
+ const moved = [];
794
+ for ( let i = 0; i < current.length; i ++ ) {
795
+
796
+ if ( i === from ) {
797
+
798
+ const rest = current[ i ].units.filter( u => u !== unit );
799
+ if ( rest.length ) moved.push( bucketOf( rest ) );
800
+
801
+ } else if ( i === to ) moved.push( bucketOf( current[ i ].units.concat( [ unit ] ) ) );
802
+ else moved.push( current[ i ] );
803
+
804
+ }
805
+
806
+ if ( totalCost( moved, maxTextureSize ) < totalCost( current, maxTextureSize ) ) {
807
+
808
+ current = moved;
809
+ improved = true;
810
+ break;
811
+
812
+ }
813
+
814
+ }
815
+
816
+ }
817
+
818
+ if ( ! improved ) break;
819
+
820
+ }
821
+
822
+ return current;
823
+
824
+ }
825
+
826
+ /**
827
+ * Choose up to `count` array shapes for one colorSpace pool. Grouping by longest edge alone
828
+ * forces a 2000x453 banner into a 2048x2048 array; grouping on both axes does not.
829
+ *
830
+ * @param {Array<{width: number, height: number}>} sizes - every texture in the pool
831
+ * @param {number} maxTextureSize
832
+ * @param {number} [count]
833
+ * @returns {Array<{width: number, height: number}>} ascending by footprint
834
+ */
835
+ export function planTextureBuckets( sizes, maxTextureSize, count = TEXTURE_CONSTANTS.MATERIAL_BUCKET_COUNT ) {
836
+
837
+ const distinct = new Map();
838
+
839
+ for ( const { width, height } of sizes ) {
840
+
841
+ const w = Math.min( maxTextureSize, Math.max( 1, width || 1 ) );
842
+ const h = Math.min( maxTextureSize, Math.max( 1, height || 1 ) );
843
+ const key = `${w}x${h}`;
844
+ const seen = distinct.get( key );
845
+ if ( seen ) seen.count ++;
846
+ else distinct.set( key, { width: w, height: h, count: 1 } );
847
+
848
+ }
849
+
850
+ const units = [ ...distinct.values() ];
851
+ let buckets = units.map( unit => bucketOf( [ unit ] ) );
852
+
853
+ if ( buckets.length > count ) {
854
+
855
+ const ladder = ladderBuckets( units, count, maxTextureSize );
856
+ const merged = mergeDown( buckets, count, maxTextureSize );
857
+ buckets = totalCost( ladder, maxTextureSize ) < totalCost( merged, maxTextureSize ) ? ladder : merged;
858
+ buckets = refine( buckets, maxTextureSize );
859
+
860
+ }
861
+
862
+ return buckets
863
+ .map( b => ( { width: alignBucketWidth( b.width, maxTextureSize ), height: b.height } ) )
864
+ .sort( ( a, b ) => a.width * a.height - b.width * b.height );
865
+
866
+ }
867
+
868
+ /**
869
+ * Index of the cheapest planned bucket that can hold a texture at its native size.
870
+ * A texture larger than every bucket lands in the largest and is downscaled there.
871
+ *
872
+ * @param {number} width
873
+ * @param {number} height
874
+ * @param {Array<{width: number, height: number}>} shapes - from {@link planTextureBuckets}
875
+ * @returns {number}
876
+ */
877
+ export function getTextureBucketId( width, height, shapes ) {
624
878
 
625
- const longest = Math.max( width || 1, height || 1 );
626
- const pot = Math.pow( 2, Math.ceil( Math.log2( longest ) ) );
627
- const sizes = getTextureBucketSizes( maxTextureSize, count );
628
- for ( let i = 0; i < sizes.length; i ++ ) if ( pot <= sizes[ i ] ) return i;
629
- return sizes.length - 1;
879
+ const w = Math.max( 1, width || 1 );
880
+ const h = Math.max( 1, height || 1 );
881
+ for ( let i = 0; i < shapes.length; i ++ ) if ( shapes[ i ].width >= w && shapes[ i ].height >= h ) return i;
882
+ return Math.max( 0, shapes.length - 1 );
630
883
 
631
884
  }
632
885
 
@@ -9,6 +9,7 @@ export const ISSUE_CODES = Object.freeze( {
9
9
  ASSET_UNREACHABLE: 'asset.unreachable',
10
10
  TEXTURE_BUILD_FAILED: 'texture.build_failed',
11
11
  TEXTURE_PROCESSING_FALLBACK: 'texture.processing_fallback',
12
+ TEXTURE_LIMIT_EXCEEDED: 'texture.limit_exceeded',
12
13
  ENVIRONMENT_LOAD_FAILED: 'environment.load_failed',
13
14
  SETTING_UNKNOWN_KEY: 'setting.unknown_key',
14
15
  RENDER_SIZE_DECLINED: 'render.size_declined',
@@ -9,6 +9,7 @@ import { PathTracer } from './Stages/PathTracer.js';
9
9
  import { NormalDepth } from './Stages/NormalDepth.js';
10
10
  import { MotionVector } from './Stages/MotionVector.js';
11
11
  import { ASVGF } from './Stages/ASVGF.js';
12
+ import { NRD } from './Stages/NRD.js';
12
13
  import { Variance } from './Stages/Variance.js';
13
14
  import { BilateralFilter } from './Stages/BilateralFilter.js';
14
15
  import { EdgeFilter } from './Stages/EdgeFilter.js';
@@ -524,7 +525,12 @@ export class PathTracerApp extends EventDispatcher {
524
525
  if ( this.pipeline ) {
525
526
 
526
527
  this.pipeline.reset();
527
- if ( ! soft ) this.pipeline.eventBus.emit( 'asvgf:reset' );
528
+ if ( ! soft ) {
529
+
530
+ this.pipeline.eventBus.emit( 'asvgf:reset' );
531
+ this.pipeline.eventBus.emit( 'denoiser:reset' );
532
+
533
+ }
528
534
 
529
535
  }
530
536
 
@@ -2857,6 +2863,7 @@ export class PathTracerApp extends EventDispatcher {
2857
2863
  this.pipeline.addStage( this.stages.pathTracer );
2858
2864
  this.pipeline.addStage( this.stages.normalDepth );
2859
2865
  this.pipeline.addStage( this.stages.motionVector );
2866
+ this.pipeline.addStage( this.stages.nrd );
2860
2867
  this.pipeline.addStage( this.stages.asvgf );
2861
2868
  this.pipeline.addStage( this.stages.variance );
2862
2869
  this.pipeline.addStage( this.stages.bilateralFilter );
@@ -3090,6 +3097,7 @@ export class PathTracerApp extends EventDispatcher {
3090
3097
  pathTracer: this.stages.pathTracer
3091
3098
  } );
3092
3099
  this.stages.asvgf = new ASVGF( this.renderer, { enabled: false } );
3100
+ this.stages.nrd = new NRD( this.renderer, { enabled: false, pathTracer: this.stages.pathTracer } );
3093
3101
  this.stages.variance = new Variance( this.renderer, { enabled: false } );
3094
3102
  this.stages.bilateralFilter = new BilateralFilter( this.renderer, { enabled: false } );
3095
3103
  this.stages.edgeFilter = new EdgeFilter( this.renderer, { enabled: false } );
@@ -3114,6 +3122,7 @@ export class PathTracerApp extends EventDispatcher {
3114
3122
  normalDepth: this.stages.normalDepth,
3115
3123
  motionVector: this.stages.motionVector,
3116
3124
  asvgf: this.stages.asvgf,
3125
+ nrd: this.stages.nrd,
3117
3126
  variance: this.stages.variance,
3118
3127
  bilateralFilter: this.stages.bilateralFilter,
3119
3128
  edgeFilter: this.stages.edgeFilter,
@@ -1,10 +1,11 @@
1
1
  import { Vector3, Vector2, Color, Matrix3, Matrix4, FrontSide, BackSide, DoubleSide, RGBAFormat } from "three";
2
- import { TRIANGLE_DATA_LAYOUT } from '../EngineDefaults.js';
2
+ import { TEXTURE_CONSTANTS, TRIANGLE_DATA_LAYOUT } from '../EngineDefaults.js';
3
+ import { ISSUE_CODES } from '../EngineIssues.js';
3
4
  import { createLogger, fmt, warnOnce } from '../utils/Logger.js';
4
5
 
5
6
  const log = createLogger( 'geometry' );
6
7
 
7
- const MAX_TEXTURES_LIMIT = 128;
8
+ const MAX_TEXTURES_LIMIT = TEXTURE_CONSTANTS.MAX_TEXTURES_LIMIT;
8
9
 
9
10
  /**
10
11
  * glTF 2.0 alphaMode for a three.js material: 0 OPAQUE, 1 MASK, 2 BLEND.
@@ -55,7 +56,11 @@ export function deriveAlphaMode( material ) {
55
56
 
56
57
  export class GeometryExtractor {
57
58
 
58
- constructor() {
59
+ /** @param {{issues?: import('../EngineIssues.js').IssueLog}} [options] */
60
+ constructor( { issues = null } = {} ) {
61
+
62
+ this._issues = issues;
63
+ this._droppedTextures = 0;
59
64
 
60
65
  // Object pools for reusing objects
61
66
  this._vectorPool = {
@@ -528,6 +533,17 @@ export class GeometryExtractor {
528
533
 
529
534
  }
530
535
 
536
+ this._droppedTextures ++;
537
+ if ( this._droppedTextures === 1 ) {
538
+
539
+ this._issues?.record(
540
+ ISSUE_CODES.TEXTURE_LIMIT_EXCEEDED,
541
+ `scene needs more than ${MAX_TEXTURES_LIMIT} distinct textures for one map type; the rest render untextured`,
542
+ { limit: MAX_TEXTURES_LIMIT, firstDropped: texture.name || texture.source?.uuid }
543
+ );
544
+
545
+ }
546
+
531
547
  return - 1;
532
548
 
533
549
  }
@@ -800,6 +816,8 @@ export class GeometryExtractor {
800
816
 
801
817
  resetArrays() {
802
818
 
819
+ this._droppedTextures = 0;
820
+
803
821
  // Reset triangle data
804
822
  this.triangleData = null;
805
823
  this.triangleCount = 0;
@@ -16,9 +16,10 @@ export const RAY_STRIDE = 7;
16
16
  export const HIT_STRIDE = 2;
17
17
  // Per-pixel G-buffer (first-hit MRT staging): 1 uvec4/pixel — half-packed normal/depth/albedo
18
18
  // (pack2x16, no f32 bitcast); read by FinalWrite:
19
- // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
19
+ // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg)
20
+ // .w=packUnorm2x16(albedo.b, normHitDist)
21
+ // normHitDist is written by Shade; writeGBuffer preserves it so the DDFA albedo commits can't zero it.
20
22
  // Separate buffer from RAY (per-pixel, not per-ray×S) — written by Generate/Shade bounce-0.
21
- // (A second lane reserved for an A-SVGF Tier-1 surface ID was removed — write-only, never read.)
22
23
  export const GBUFFER_STRIDE = 1;
23
24
 
24
25
  export const RAY = {
@@ -162,15 +163,35 @@ export const readRayRadiance = ( buf, id ) =>
162
163
  // One uvec4 per pixel (stride 1); AoS base = pixelIndex * GBUFFER_STRIDE.
163
164
  const gbLane = ( pixelIndex ) => uint( pixelIndex ).mul( GBUFFER_STRIDE );
164
165
 
165
- export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo ) =>
166
- buf.element( gbLane( pixelIndex ) ).assign( uvec4(
166
+ // hitDist = null keeps the lane's current normHitDist.
167
+ export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo, hitDist = null ) => {
168
+
169
+ const lane = gbLane( pixelIndex );
170
+ const h = hitDist === null ? unpackUnorm2x16( buf.element( lane ).w ).y : float( hitDist );
171
+ return buf.element( lane ).assign( uvec4(
167
172
  packSnorm2x16( vec2( normal.x, normal.y ) ),
168
173
  packSnorm2x16( vec2( normal.z, depth ) ),
169
174
  packUnorm2x16( vec2( albedo.x, albedo.y ) ),
170
- packUnorm2x16( vec2( albedo.z, 0.0 ) ),
175
+ packUnorm2x16( vec2( albedo.z, h ) ),
176
+ ) );
177
+
178
+ };
179
+
180
+ export const writeGBufferHitDist = ( buf, pixelIndex, normHitDist ) => {
181
+
182
+ const lane = gbLane( pixelIndex );
183
+ const cur = buf.element( lane ).toVar();
184
+ return buf.element( lane ).assign( uvec4(
185
+ cur.x, cur.y, cur.z,
186
+ packUnorm2x16( vec2( unpackUnorm2x16( cur.w ).x, normHitDist ) ),
171
187
  ) );
188
+
189
+ };
190
+
172
191
  export const readGBuffer = ( buf, pixelIndex ) => buf.element( gbLane( pixelIndex ) );
173
192
 
193
+ export const gbDecodeHitDist = ( packed ) => unpackUnorm2x16( packed.w ).y;
194
+
174
195
  // Decode for FinalWrite. normalDepth.xyz matches the prior path (normal*0.5+0.5), .w = raw depth.
175
196
  export const gbDecodeNormalDepth = ( packed ) => {
176
197
 
@@ -11,7 +11,8 @@ import { updateLoading } from '../Processor/utils.js';
11
11
  import { BuildTimer } from './BuildTimer.js';
12
12
  import { createLogger, fmt, workerLogLevel } from '../utils/Logger.js';
13
13
  import { SRGBColorSpace } from 'three';
14
- import { TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex } from '../EngineDefaults.js';
14
+ import { TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS, getTextureBucketId, packTextureIndex, planTextureBuckets } from '../EngineDefaults.js';
15
+ import { ISSUE_CODES } from '../EngineIssues.js';
15
16
  import BVHWorker from './Workers/BVHWorker.js?worker&inline';
16
17
  import BVHRefitWorker from './Workers/BVHRefitWorker.js?worker&inline';
17
18
 
@@ -145,7 +146,7 @@ export class SceneProcessor {
145
146
  _initProcessors() {
146
147
 
147
148
  // Create and configure geometry extractor
148
- this.geometryExtractor = new GeometryExtractor();
149
+ this.geometryExtractor = new GeometryExtractor( { issues: this.config.issues } );
149
150
 
150
151
  // Create and configure BVH builder
151
152
  this.bvhBuilder = new BVHBuilder();
@@ -869,8 +870,9 @@ export class SceneProcessor {
869
870
  /**
870
871
  * Group the extractor's seven per-type texture arrays into two consolidated colorSpace
871
872
  * pools (sRGB: albedo+emissive; linear: normal/bump/roughness/metalness/displacement),
872
- * each split into MATERIAL_BUCKET_COUNT longest-edge size buckets. Textures are deduped
873
- * across types within a (pool, bucket) so a shared image (e.g. ORM) costs one layer.
873
+ * each split into at most MATERIAL_BUCKET_COUNT buckets whose shapes are planned from the
874
+ * pool's own texture dimensions. Textures are deduped across types within a (pool, bucket)
875
+ * so a shared image (e.g. ORM) costs one layer.
874
876
  * @returns {{ srgbLists: Array<Array>, linearLists: Array<Array>, remap: Object }}
875
877
  * bucket lists + per-type remap arrays (old per-type layer → packed bucket index).
876
878
  * @private
@@ -881,6 +883,43 @@ export class SceneProcessor {
881
883
  const K = TEXTURE_CONSTANTS.MATERIAL_BUCKET_COUNT;
882
884
  const STRIDE = TEXTURE_CONSTANTS.BUCKET_LAYER_STRIDE;
883
885
 
886
+ const poolSizes = ( types ) => {
887
+
888
+ const seen = new Set();
889
+ const sizes = [];
890
+
891
+ for ( const arr of types ) {
892
+
893
+ for ( const tex of arr || [] ) {
894
+
895
+ if ( ! tex?.image ) continue;
896
+ const uuid = tex.source?.uuid ?? tex.uuid;
897
+ if ( seen.has( uuid ) ) continue;
898
+ seen.add( uuid );
899
+ sizes.push( { width: tex.image.width, height: tex.image.height } );
900
+
901
+ }
902
+
903
+ }
904
+
905
+ return sizes;
906
+
907
+ };
908
+
909
+ const srgbTypes = [ this.maps, this.emissiveMaps, this.sheenColorMaps, this.specularColorMaps ];
910
+ const linearTypes = [
911
+ this.normalMaps, this.bumpMaps, this.roughnessMaps, this.metalnessMaps, this.displacementMaps,
912
+ this.anisotropyMaps, this.transmissionMaps, this.clearcoatMaps, this.clearcoatRoughnessMaps,
913
+ this.sheenRoughnessMaps, this.iridescenceMaps, this.iridescenceThicknessMaps, this.specularIntensityMaps,
914
+ ];
915
+
916
+ const srgbShapes = planTextureBuckets( poolSizes( srgbTypes ), cap, K );
917
+ const linearShapes = planTextureBuckets( poolSizes( linearTypes ), cap, K );
918
+ this._srgbBucketShapes = srgbShapes;
919
+ this._linearBucketShapes = linearShapes;
920
+
921
+ // Always K-length even when the plan needs fewer shapes: downstream nodes are built
922
+ // per MATERIAL_BUCKET_COUNT and a short array would leave stale bindings behind.
884
923
  const srgbLists = Array.from( { length: K }, () => [] );
885
924
  const linearLists = Array.from( { length: K }, () => [] );
886
925
  const srgbDedup = Array.from( { length: K }, () => new Map() );
@@ -891,17 +930,25 @@ export class SceneProcessor {
891
930
  this._srgbTexPacked = new Map();
892
931
  this._linearTexPacked = new Map();
893
932
 
933
+ let bucketOverflowReported = false;
934
+
894
935
  // Assign one texture to its (bucket, layer) within a pool; dedup by source uuid.
895
- const assign = ( tex, lists, dedup, flat ) => {
936
+ const assign = ( tex, lists, dedup, flat, shapes ) => {
896
937
 
897
938
  if ( ! tex || ! tex.image ) return - 1;
898
- const bucket = getTextureBucketId( tex.image.width, tex.image.height, cap, K );
939
+ const bucket = getTextureBucketId( tex.image.width, tex.image.height, shapes );
899
940
  const uuid = tex.source?.uuid ?? tex.uuid;
900
941
  const seen = dedup[ bucket ].get( uuid );
901
942
  if ( seen !== undefined ) return packTextureIndex( bucket, seen );
902
943
  if ( lists[ bucket ].length >= STRIDE ) {
903
944
 
904
945
  log.warn( `texture bucket ${bucket} full (${STRIDE}); dropping a map` );
946
+ if ( ! bucketOverflowReported ) this.config.issues?.record(
947
+ ISSUE_CODES.TEXTURE_LIMIT_EXCEEDED,
948
+ `texture bucket ${bucket} full (${STRIDE} layers); the rest render untextured`,
949
+ { bucket, limit: STRIDE }
950
+ );
951
+ bucketOverflowReported = true;
905
952
  return - 1;
906
953
 
907
954
  }
@@ -917,27 +964,27 @@ export class SceneProcessor {
917
964
 
918
965
  // Per-type arrays hold unique textures indexed by the layer the extractor assigned
919
966
  // (= array position), so remap[type][oldLayer] = packed index.
920
- const remapType = ( arr, lists, dedup, flat ) => ( arr || [] ).map( tex => assign( tex, lists, dedup, flat ) );
967
+ const remapType = ( arr, lists, dedup, flat, shapes ) => ( arr || [] ).map( tex => assign( tex, lists, dedup, flat, shapes ) );
921
968
 
922
969
  const remap = {
923
- albedo: remapType( this.maps, srgbLists, srgbDedup, this._srgbTexPacked ),
924
- emissive: remapType( this.emissiveMaps, srgbLists, srgbDedup, this._srgbTexPacked ),
925
- normal: remapType( this.normalMaps, linearLists, linearDedup, this._linearTexPacked ),
926
- bump: remapType( this.bumpMaps, linearLists, linearDedup, this._linearTexPacked ),
927
- roughness: remapType( this.roughnessMaps, linearLists, linearDedup, this._linearTexPacked ),
928
- metalness: remapType( this.metalnessMaps, linearLists, linearDedup, this._linearTexPacked ),
929
- displacement: remapType( this.displacementMaps, linearLists, linearDedup, this._linearTexPacked ),
930
- anisotropy: remapType( this.anisotropyMaps, linearLists, linearDedup, this._linearTexPacked ),
970
+ albedo: remapType( this.maps, srgbLists, srgbDedup, this._srgbTexPacked, srgbShapes ),
971
+ emissive: remapType( this.emissiveMaps, srgbLists, srgbDedup, this._srgbTexPacked, srgbShapes ),
972
+ normal: remapType( this.normalMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
973
+ bump: remapType( this.bumpMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
974
+ roughness: remapType( this.roughnessMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
975
+ metalness: remapType( this.metalnessMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
976
+ displacement: remapType( this.displacementMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
977
+ anisotropy: remapType( this.anisotropyMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
931
978
  // Extension maps — data maps → linear pool; color maps (sheenColor, specularColor) → sRGB pool.
932
- transmission: remapType( this.transmissionMaps, linearLists, linearDedup, this._linearTexPacked ),
933
- clearcoat: remapType( this.clearcoatMaps, linearLists, linearDedup, this._linearTexPacked ),
934
- clearcoatRoughness: remapType( this.clearcoatRoughnessMaps, linearLists, linearDedup, this._linearTexPacked ),
935
- sheenColor: remapType( this.sheenColorMaps, srgbLists, srgbDedup, this._srgbTexPacked ),
936
- sheenRoughness: remapType( this.sheenRoughnessMaps, linearLists, linearDedup, this._linearTexPacked ),
937
- iridescence: remapType( this.iridescenceMaps, linearLists, linearDedup, this._linearTexPacked ),
938
- iridescenceThickness: remapType( this.iridescenceThicknessMaps, linearLists, linearDedup, this._linearTexPacked ),
939
- specularIntensity: remapType( this.specularIntensityMaps, linearLists, linearDedup, this._linearTexPacked ),
940
- specularColor: remapType( this.specularColorMaps, srgbLists, srgbDedup, this._srgbTexPacked ),
979
+ transmission: remapType( this.transmissionMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
980
+ clearcoat: remapType( this.clearcoatMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
981
+ clearcoatRoughness: remapType( this.clearcoatRoughnessMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
982
+ sheenColor: remapType( this.sheenColorMaps, srgbLists, srgbDedup, this._srgbTexPacked, srgbShapes ),
983
+ sheenRoughness: remapType( this.sheenRoughnessMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
984
+ iridescence: remapType( this.iridescenceMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
985
+ iridescenceThickness: remapType( this.iridescenceThicknessMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
986
+ specularIntensity: remapType( this.specularIntensityMaps, linearLists, linearDedup, this._linearTexPacked, linearShapes ),
987
+ specularColor: remapType( this.specularColorMaps, srgbLists, srgbDedup, this._srgbTexPacked, srgbShapes ),
941
988
  };
942
989
 
943
990
  return { srgbLists, linearLists, remap };