rayzee 7.30.0 → 7.30.2

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.30.0",
3
+ "version": "7.30.2",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -666,11 +666,11 @@ export const TEXTURE_CONSTANTS = {
666
666
  MAX_TEXTURE_SIZE: 8192,
667
667
  // Default cap applied when no maxTextureSize is supplied (engine standalone use).
668
668
  DEFAULT_MAX_TEXTURE_SIZE: 4096,
669
- // Max layers (textures) per bucket array. Also the packing stride for (bucket, layer).
670
- MAX_TEXTURES_LIMIT: 128,
671
- // Size buckets per colorSpace pool. Material maps are grouped into this many
672
- // longest-edge size classes so a small map no longer pays a large neighbour's
673
- // 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).
674
674
  MATERIAL_BUCKET_COUNT: 4,
675
675
  // Packing stride: a map's stored index encodes bucketId * BUCKET_LAYER_STRIDE + layer.
676
676
  // Also the per-bucket layer cap. Kept at the WebGPU portable maxTextureArrayLayers floor (256)
@@ -678,30 +678,208 @@ export const TEXTURE_CONSTANTS = {
678
678
  BUCKET_LAYER_STRIDE: 256,
679
679
  };
680
680
 
681
- // Longest-edge size ladder for a given cap, ascending.
682
- // cap=4096, count=4 → [512, 1024, 2048, 4096].
683
- 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;
684
683
 
685
- const sizes = [];
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 ) {
751
+
752
+ const ladder = [];
686
753
  for ( let i = count - 1; i >= 0; i -- ) {
687
754
 
688
- 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 ) ) ) );
756
+
757
+ }
758
+
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 );
689
767
 
690
768
  }
691
769
 
692
- return sizes;
770
+ return bins.filter( bin => bin.length ).map( bucketOf );
693
771
 
694
772
  }
695
773
 
696
- // Bucket index for a texture, by its power-of-2 longest edge vs the cap's ladder.
697
- // Larger-than-cap maps land in the top bucket (downscaled to cap, as before).
698
- 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 ) {
699
878
 
700
- const longest = Math.max( width || 1, height || 1 );
701
- const pot = Math.pow( 2, Math.ceil( Math.log2( longest ) ) );
702
- const sizes = getTextureBucketSizes( maxTextureSize, count );
703
- for ( let i = 0; i < sizes.length; i ++ ) if ( pot <= sizes[ i ] ) return i;
704
- 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 );
705
883
 
706
884
  }
707
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',
@@ -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;
@@ -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 };
@@ -1,5 +1,5 @@
1
1
  import { DataArrayTexture, RGBAFormat, LinearFilter, UnsignedByteType, SRGBColorSpace, LinearSRGBColorSpace, RepeatWrapping } from "three";
2
- import { TEXTURE_CONSTANTS, MEMORY_CONSTANTS, DEFAULT_TEXTURE_MATRIX, MATERIAL_DATA_LAYOUT, normalizeAttenuationDistance } from '../EngineDefaults.js';
2
+ import { alignBucketWidth, TEXTURE_CONSTANTS, MEMORY_CONSTANTS, DEFAULT_TEXTURE_MATRIX, MATERIAL_DATA_LAYOUT, normalizeAttenuationDistance } from '../EngineDefaults.js';
3
3
  import TexturesWorker from './Workers/TexturesWorker.js?worker&inline';
4
4
  import { ISSUE_CODES } from '../EngineIssues.js';
5
5
  import { linearToSRGB } from './ToneMapCPU.js';
@@ -1318,18 +1318,17 @@ export class TextureCreator {
1318
1318
 
1319
1319
  }
1320
1320
 
1321
- maxWidth = Math.pow( 2, Math.ceil( Math.log2( maxWidth ) ) );
1322
- maxHeight = Math.pow( 2, Math.ceil( Math.log2( maxHeight ) ) );
1321
+ // Scale down proportionally only when the cap is exceeded; native res is kept below it.
1322
+ const longest = Math.max( maxWidth, maxHeight );
1323
+ if ( longest > this.maxTextureSize ) {
1323
1324
 
1324
- // Halve only while a dimension exceeds the cap (preserves native res up to the cap).
1325
- while ( maxWidth > this.maxTextureSize || maxHeight > this.maxTextureSize ) {
1326
-
1327
- maxWidth = Math.max( 1, Math.floor( maxWidth / 2 ) );
1328
- maxHeight = Math.max( 1, Math.floor( maxHeight / 2 ) );
1325
+ const scale = this.maxTextureSize / longest;
1326
+ maxWidth = Math.round( maxWidth * scale );
1327
+ maxHeight = Math.round( maxHeight * scale );
1329
1328
 
1330
1329
  }
1331
1330
 
1332
- return { maxWidth, maxHeight };
1331
+ return { maxWidth: alignBucketWidth( maxWidth, this.maxTextureSize ), maxHeight: Math.max( 1, maxHeight ) };
1333
1332
 
1334
1333
  }
1335
1334
 
@@ -619,23 +619,22 @@ function calculateOptimalDimensions( textures, maxTextureSize ) {
619
619
 
620
620
  }
621
621
 
622
- // Round to power of 2 for optimal GPU performance
623
- maxWidth = Math.pow( 2, Math.ceil( Math.log2( maxWidth ) ) );
624
- maxHeight = Math.pow( 2, Math.ceil( Math.log2( maxHeight ) ) );
622
+ // Scale down proportionally only when the cap is exceeded; native res is kept below it.
623
+ const cap = Math.min( maxTextureSize, MEMORY_LIMITS.MAX_TEXTURE_DIMENSION );
624
+ const longest = Math.max( maxWidth, maxHeight );
625
+ if ( longest > cap ) {
625
626
 
626
- // Respect texture size limits
627
- maxWidth = Math.min( maxWidth, maxTextureSize, MEMORY_LIMITS.MAX_TEXTURE_DIMENSION );
628
- maxHeight = Math.min( maxHeight, maxTextureSize, MEMORY_LIMITS.MAX_TEXTURE_DIMENSION );
629
-
630
- // Halve only while a dimension exceeds the cap (preserves native res up to the cap).
631
- while ( maxWidth > maxTextureSize || maxHeight > maxTextureSize ) {
632
-
633
- maxWidth = Math.max( 1, Math.floor( maxWidth / 2 ) );
634
- maxHeight = Math.max( 1, Math.floor( maxHeight / 2 ) );
627
+ const scale = cap / longest;
628
+ maxWidth = Math.round( maxWidth * scale );
629
+ maxHeight = Math.round( maxHeight * scale );
635
630
 
636
631
  }
637
632
 
638
- return { maxWidth, maxHeight };
633
+ // An RGBA8 row upload must be a multiple of 256 bytes, so widths land on 64 texels.
634
+ // Mirrors alignBucketWidth in EngineDefaults (not importable from worker context).
635
+ maxWidth = Math.min( cap, Math.max( 4, Math.ceil( Math.max( 1, maxWidth ) / 64 ) * 64 ) );
636
+
637
+ return { maxWidth, maxHeight: Math.max( 1, maxHeight ) };
639
638
 
640
639
  }
641
640
 
@@ -645,7 +644,7 @@ function calculateReducedDimensions( textures, maxTextureSize ) {
645
644
  const original = calculateOptimalDimensions( textures, maxTextureSize );
646
645
 
647
646
  return {
648
- maxWidth: Math.max( 1, Math.floor( original.maxWidth / 2 ) ),
647
+ maxWidth: Math.max( 64, Math.ceil( original.maxWidth / 128 ) * 64 ),
649
648
  maxHeight: Math.max( 1, Math.floor( original.maxHeight / 2 ) )
650
649
  };
651
650
 
@@ -38,12 +38,6 @@ export function getDisplaySamples( pathTracerStage ) {
38
38
 
39
39
  }
40
40
 
41
- export function getNearestPowerOf2( size ) {
42
-
43
- return Math.pow( 2, Math.ceil( Math.log2( size ) ) );
44
-
45
- }
46
-
47
41
  export function disposeMaterial( material ) {
48
42
 
49
43
  if ( Array.isArray( material ) ) {
@@ -24,7 +24,9 @@ import { HitInfo } from './Struct.js';
24
24
  import { getDatafromStorageBuffer } from './Common.js';
25
25
 
26
26
  const MAX_STACK_DEPTH = 32;
27
- const MAX_BVH_ITERATIONS = 512;
27
+ // Hang guard only — tripping it reports a MISS, which the shade kernel pays out as
28
+ // full-intensity environment. San Miguel needs ~1024; cost is flat from 1024 up.
29
+ const MAX_BVH_ITERATIONS = 4096;
28
30
  const BVH_STRIDE = 4;
29
31
  const TRI_STRIDE = 8;
30
32
  const HUGE_VAL = 1e8;
@@ -10,11 +10,12 @@ import { getDatafromStorageBuffer } from './Common.js';
10
10
  import { TEXTURE_CONSTANTS } from '../EngineDefaults.js';
11
11
 
12
12
  // ================================================================================
13
- // CONSOLIDATED SIZE-BUCKETED MATERIAL TEXTURES
13
+ // CONSOLIDATED SHAPE-BUCKETED MATERIAL TEXTURES
14
14
  // ================================================================================
15
15
  // Material maps are packed into two colorSpace pools (sRGB: albedo+emissive; linear:
16
16
  // normal/bump/roughness/metalness/displacement), each split into MATERIAL_BUCKET_COUNT
17
- // longest-edge size buckets so a small map no longer pays a large neighbour's footprint.
17
+ // (width, height) buckets so a small or oddly-shaped map no longer pays a large neighbour's
18
+ // footprint. Bucket shapes are planned per scene (planTextureBuckets) — not powers of two.
18
19
  // A map's stored index encodes (bucket, layer) as bucket * BUCKET_LAYER_STRIDE + layer.
19
20
  //
20
21
  // The bucket texture nodes live at module level (same pattern as gobo/IES/shadowAlbedo):