usd-gltf 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,10 @@
6
6
  // and lets textures be referenced directly when glTF can express the result, or be repacked
7
7
  // (baked) into a new image when it can't, e.g. separate roughness and metallic maps.
8
8
 
9
- import { decodeImageAsync, encodePNG, imageMimeType } from '../image/index.js';
9
+ import { decodeImageAsync, encodePNG, encodePNGAsync, imageMimeType, isGreyscaleImage } from '../image/index.js';
10
10
  import { multiply } from '../util/math.js';
11
11
  import { resolveAssetPath } from '../usd/stage.js';
12
+ import { Budget, rethrowFatal } from '../limits.js';
12
13
 
13
14
  const WRAP = { repeat: 10497, mirror: 33648, clamp: 33071, black: 33071 };
14
15
  const CHANNEL = { r: 0, g: 1, b: 2, a: 3, x: 0, y: 1, z: 2, w: 3 };
@@ -68,9 +69,10 @@ function binary( a, b, op ) {
68
69
 
69
70
  export class MaterialConverter {
70
71
 
71
- constructor( stage, builder, { log = () => {}, imageDecoder = null } = {} ) {
72
+ constructor( stage, builder, { log = () => {}, imageDecoder = null, budget = new Budget() } = {} ) {
72
73
 
73
74
  this.stage = stage;
75
+ this.budget = budget;
74
76
  this.builder = builder;
75
77
  this.log = log;
76
78
  this.imageDecoder = imageDecoder;
@@ -467,6 +469,7 @@ export class MaterialConverter {
467
469
 
468
470
  } catch ( e ) {
469
471
 
472
+ rethrowFatal( e, this.budget );
470
473
  this.log( `texture ${ file }: ${ e.message }` );
471
474
 
472
475
  }
@@ -476,6 +479,20 @@ export class MaterialConverter {
476
479
 
477
480
  }
478
481
 
482
+ async isGrey( file ) {
483
+
484
+ try {
485
+
486
+ return isGreyscaleImage( await this.stage.readAsset( file ) );
487
+
488
+ } catch {
489
+
490
+ return false;
491
+
492
+ }
493
+
494
+ }
495
+
479
496
  decode( file ) {
480
497
 
481
498
  if ( ! this.decoded.has( file ) ) {
@@ -484,10 +501,11 @@ export class MaterialConverter {
484
501
 
485
502
  try {
486
503
 
487
- return await decodeImageAsync( await this.stage.readAsset( file ), undefined, this.imageDecoder );
504
+ return await decodeImageAsync( await this.stage.readAsset( file ), undefined, this.imageDecoder, this.budget );
488
505
 
489
506
  } catch ( e ) {
490
507
 
508
+ rethrowFatal( e, this.budget );
491
509
  this.log( `decode ${ file }: ${ e.message }` );
492
510
  return null;
493
511
 
@@ -562,8 +580,10 @@ export class MaterialConverter {
562
580
  const tex = textured[ 0 ].term.tex;
563
581
  const sameTexture = textured.every( ( s ) => s.term.tex.file === tex.file && s.term.tex.uv.name === tex.uv.name && JSON.stringify( s.term.tex.uv.transform ) === JSON.stringify( tex.uv.transform ) );
564
582
 
565
- // direct use: each textured slot reads its own channel, no bias, scale expressible as a factor
566
- const direct = sameTexture && textured.every( ( s ) => s.term.channel === s.out && Math.abs( s.term.bias ) < 1e-6 && ( allowFactor || Math.abs( s.term.scale - 1 ) < 1e-6 ) ) &&
583
+ // direct use: each textured slot reads its own channel (any colour channel of a grey image
584
+ // reads the same value), no bias, and a scale expressible as a factor
585
+ const grey = sameTexture && textured.some( ( s ) => s.term.channel !== s.out ) && await this.isGrey( tex.file );
586
+ const direct = sameTexture && textured.every( ( s ) => ( s.term.channel === s.out || ( grey && s.term.channel < 3 && s.out < 3 ) ) && Math.abs( s.term.bias ) < 1e-6 && ( allowFactor || Math.abs( s.term.scale - 1 ) < 1e-6 ) ) &&
567
587
  slots.every( ( s ) => ! s || s.term.tex || s.ignoreConst || ( allowFactor && s.constOk ) );
568
588
 
569
589
  if ( direct ) {
@@ -588,47 +608,64 @@ export class MaterialConverter {
588
608
 
589
609
  }
590
610
 
611
+ const key = 'baked:' + slots.map( ( s ) => s ? `${ s.term.tex?.file }#${ s.term.channel }*${ s.term.scale }+${ s.term.bias }` : '-' ).join( ',' );
612
+ let imageIndex = this.images.get( key )?.index;
613
+
614
+ if ( imageIndex === undefined ) {
615
+
616
+ const image = this.bake( slots, textured, sources );
617
+ imageIndex = this.builder.addImage( await encodePNGAsync( image ), 'image/png', imageName( tex.file ) + '_packed' );
618
+ this.images.set( key, { index: imageIndex, mimeType: 'image/png' } );
619
+
620
+ }
621
+
622
+ return { info: this.textureInfo( imageIndex, tex, key ), factors: slots.map( () => 1 ) };
623
+
624
+ }
625
+
626
+ // Writes each slot's term into its output channel, resampling sources to the largest size.
627
+ // Only the channels glTF reads are stored: grey for a lone R, RGB, or RGBA when alpha is used.
628
+ bake( slots, textured, sources ) {
629
+
591
630
  const width = Math.max( ...sources.map( ( s ) => s.width ) );
592
631
  const height = Math.max( ...sources.map( ( s ) => s.height ) );
593
- const out = new Uint8Array( width * height * 4 );
632
+ const used = slots.map( ( s, c ) => ( s ? c : - 1 ) ).filter( ( c ) => c >= 0 );
633
+ const channels = used.length === 1 && used[ 0 ] === 0 ? 1 : used.includes( 3 ) ? 4 : 3;
634
+ const out = new Uint8Array( width * height * channels );
594
635
  const srcOf = new Map( textured.map( ( s, i ) => [ s, sources[ i ] ] ) );
595
636
 
596
- for ( let c = 0; c < 4; c ++ ) {
637
+ for ( let c = 0; c < channels; c ++ ) {
597
638
 
598
639
  const slot = slots.find( ( s ) => s && s.out === c );
599
640
 
600
- if ( ! slot ) {
641
+ if ( ! slot || ! slot.term.tex ) {
601
642
 
602
- for ( let i = c; i < out.length; i += 4 ) out[ i ] = 255;
643
+ const v = slot ? Math.round( clamp01( slot.term.bias ) * 255 ) : 255;
644
+ for ( let i = c; i < out.length; i += channels ) out[ i ] = v;
603
645
  continue;
604
646
 
605
647
  }
606
648
 
607
649
  const { term } = slot;
608
-
609
- if ( ! term.tex ) {
610
-
611
- const v = Math.round( clamp01( term.bias ) * 255 );
612
- for ( let i = c; i < out.length; i += 4 ) out[ i ] = v;
613
- continue;
614
-
615
- }
616
-
617
650
  const src = srcOf.get( slot );
618
651
  const max = src.bitDepth === 16 ? 65535 : 255;
619
- const sc = Math.min( term.channel, src.channels - 1 );
620
652
  const alphaMissing = term.channel === 3 && src.channels < 4 && src.channels !== 2;
621
- const channel = src.channels === 2 && term.channel === 3 ? 1 : src.channels <= 2 && term.channel < 3 ? 0 : sc;
653
+ const channel = src.channels === 2 && term.channel === 3 ? 1 : src.channels <= 2 && term.channel < 3 ? 0 : Math.min( term.channel, src.channels - 1 );
622
654
 
623
- for ( let y = 0; y < height; y ++ ) {
655
+ // output = clamp( raw / max * scale + bias ) * 255, as one multiply and add per pixel
656
+ const k = term.scale * 255 / max, b = term.bias * 255 + 0.5;
657
+ const xs = new Int32Array( width );
658
+ for ( let x = 0; x < width; x ++ ) xs[ x ] = Math.min( src.width - 1, Math.floor( x * src.width / width ) ) * src.channels + channel;
659
+ const data = src.data, sw = src.width * src.channels;
624
660
 
625
- const sy = Math.min( src.height - 1, Math.floor( y * src.height / height ) );
661
+ for ( let y = 0, o = c; y < height; y ++ ) {
626
662
 
627
- for ( let x = 0; x < width; x ++ ) {
663
+ const row = Math.min( src.height - 1, Math.floor( y * src.height / height ) ) * sw;
628
664
 
629
- const sx = Math.min( src.width - 1, Math.floor( x * src.width / width ) );
630
- const raw = alphaMissing ? 1 : src.data[ ( sy * src.width + sx ) * src.channels + channel ] / max;
631
- out[ ( y * width + x ) * 4 + c ] = Math.round( clamp01( raw * term.scale + term.bias ) * 255 );
665
+ for ( let x = 0; x < width; x ++, o += channels ) {
666
+
667
+ const v = alphaMissing ? 255 * term.scale + b : data[ row + xs[ x ] ] * k + b;
668
+ out[ o ] = v <= 0 ? 0 : v >= 255 ? 255 : v | 0;
632
669
 
633
670
  }
634
671
 
@@ -636,16 +673,7 @@ export class MaterialConverter {
636
673
 
637
674
  }
638
675
 
639
- const key = 'baked:' + slots.map( ( s ) => s ? `${ s.term.tex?.file }#${ s.term.channel }*${ s.term.scale }+${ s.term.bias }` : '-' ).join( ',' );
640
- let imageIndex = this.images.get( key )?.index;
641
- if ( imageIndex === undefined ) {
642
-
643
- imageIndex = this.builder.addImage( encodePNG( { width, height, channels: 4, data: out } ), 'image/png', imageName( tex.file ) + '_packed' );
644
- this.images.set( key, { index: imageIndex, mimeType: 'image/png' } );
645
-
646
- }
647
-
648
- return { info: this.textureInfo( imageIndex, tex, key ), factors: slots.map( () => 1 ) };
676
+ return { width, height, channels, data: out };
649
677
 
650
678
  }
651
679
 
@@ -656,6 +684,7 @@ export class MaterialConverter {
656
684
  */
657
685
  async convert( material, { doubleSided = false, vertexColors = false, variant = '' } = {} ) {
658
686
 
687
+ this.budget.check();
659
688
  const key = material.path + ( doubleSided ? '|ds' : '' ) + ( variant ? '|' + variant : '' );
660
689
  if ( this.materials.has( key ) ) return this.materials.get( key );
661
690
 
@@ -6,6 +6,7 @@ import { getXformOps, evaluateOps, xformSampleTimes } from '../usd/xform.js';
6
6
  import { MaterialConverter, gltfTextureTransform } from './usd-material.js';
7
7
  import { triangulateMesh, normalizeNormals } from './usd-mesh.js';
8
8
  import { generateShape } from './usd-shapes.js';
9
+ import { Budget } from '../limits.js';
9
10
  import { compose, decompose, identity, invert, isIdentity, multiply, normalMatrix, transformDirection, transformPoint } from '../util/math.js';
10
11
 
11
12
  const SKIP_TYPES = new Set( [ 'Material', 'Shader', 'NodeGraph', 'GeomSubset', 'SkelAnimation', 'BlendShape', 'RenderSettings', 'RenderProduct', 'RenderVar', 'GeometryLight', 'DomeLight', 'Backdrop' ] );
@@ -14,14 +15,15 @@ const SHAPE_TYPES = new Set( [ 'Cube', 'Sphere', 'Cylinder', 'Cone', 'Capsule',
14
15
 
15
16
  export class USDToGLTF {
16
17
 
17
- constructor( stage, { log = () => {}, materialVariants = true, debug = false, imageDecoder = null } = {} ) {
18
+ constructor( stage, { log = () => {}, materialVariants = true, debug = false, imageDecoder = null, budget = new Budget() } = {} ) {
18
19
 
19
20
  this.stage = stage;
21
+ this.budget = budget;
20
22
  this.log = log;
21
23
  this.debug = debug; // records usd paths and time mapping in extras, for verification
22
24
  this.materialVariants = materialVariants;
23
- this.builder = new GLTFBuilder();
24
- this.materials = new MaterialConverter( stage, this.builder, { log, imageDecoder } );
25
+ this.builder = new GLTFBuilder( 'usd-gltf', budget );
26
+ this.materials = new MaterialConverter( stage, this.builder, { log, imageDecoder, budget } );
25
27
  this.json = this.builder.json;
26
28
  this.meshCache = new Map();
27
29
  this.primNodes = new Map(); // prim path -> node index (first instance)
@@ -101,6 +103,7 @@ export class USDToGLTF {
101
103
 
102
104
  async convertPrim( prim, parentPrim ) {
103
105
 
106
+ this.budget.check();
104
107
  if ( ! this.isRenderable( prim ) ) return null;
105
108
 
106
109
  const node = { name: prim.name };
@@ -199,6 +202,7 @@ export class USDToGLTF {
199
202
 
200
203
  async convertMesh( prim, nodeIndex ) {
201
204
 
205
+ this.budget.check();
202
206
  const node = this.json.nodes[ nodeIndex ];
203
207
  const skel = this.skinningFor( prim );
204
208
 
@@ -289,7 +293,7 @@ export class USDToGLTF {
289
293
 
290
294
  const indices = data.vertexCount < 65536 ? Uint16Array.from( group.indices ) : Uint32Array.from( group.indices );
291
295
  const primitive = { attributes: { ...attributes }, indices: this.builder.addAccessor( indices, 'SCALAR', { target: 34963 } ) };
292
- const material = ( group.subset && this.boundMaterial( group.subset ) ) || this.defaultMaterial( prim ) || this.boundMaterial( prim );
296
+ const material = ( group.subset && ( this.defaultMaterial( group.subset ) || this.boundMaterial( group.subset ) ) ) || this.defaultMaterial( prim ) || this.boundMaterial( prim );
293
297
 
294
298
  if ( material ) primitive.material = await this.materials.convert( material, { doubleSided, vertexColors } );
295
299
  else primitive.material = this.materials.defaultMaterial( data.constantColor, data.constantOpacity, vertexColors, doubleSided );
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readGLB, writeGLB } from './glb.js';
4
4
  import { decodeMeshoptView } from './meshopt.js';
5
+ import { Budget, checkSize } from '../limits.js';
5
6
 
6
7
  const COMPONENT_ARRAYS = {
7
8
  5120: Int8Array,
@@ -14,6 +15,43 @@ const COMPONENT_ARRAYS = {
14
15
 
15
16
  export const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
16
17
 
18
+ // Extensions an asset may require. Material extensions without a UsdPreviewSurface equivalent are
19
+ // approximated and kept on the USD material for the way back, so every KHR_materials_* is accepted.
20
+ const SUPPORTED_EXTENSIONS = new Set( [
21
+ 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization',
22
+ 'EXT_texture_webp', 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_animation_pointer', 'KHR_lights_punctual',
23
+ 'KHR_node_visibility', 'KHR_xmp_json_ld',
24
+ ] );
25
+
26
+ /**
27
+ * The asset's required extensions that can't be converted (e.g. KHR_draco_mesh_compression, KHR_texture_basisu).
28
+ * @param {object} json
29
+ * @returns {string[]}
30
+ */
31
+ export function unsupportedExtensions( json ) {
32
+
33
+ return ( json.extensionsRequired || [] ).filter( ( name ) => ! SUPPORTED_EXTENSIONS.has( name ) && ! name.startsWith( 'KHR_materials_' ) );
34
+
35
+ }
36
+
37
+ // Optional Draco needs uncompressed fallback data: without a bufferView or sparse values the
38
+ // accessors would read as zeros.
39
+ function checkDracoFallbacks( json ) {
40
+
41
+ ( json.meshes || [] ).forEach( ( mesh, m ) => mesh.primitives.forEach( ( primitive, p ) => {
42
+
43
+ if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return;
44
+ const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
45
+ if ( used.some( ( a ) => json.accessors[ a ].bufferView === undefined && ! json.accessors[ a ].sparse ) ) {
46
+
47
+ throw new Error( `usd-gltf: mesh ${ m } primitive ${ p } is Draco compressed with no uncompressed fallback; KHR_draco_mesh_compression is not supported` );
48
+
49
+ }
50
+
51
+ } ) );
52
+
53
+ }
54
+
17
55
  function decodeDataURI( uri ) {
18
56
 
19
57
  const comma = uri.indexOf( ',' );
@@ -38,11 +76,12 @@ function decodeDataURI( uri ) {
38
76
  */
39
77
  export class GLTFDocument {
40
78
 
41
- constructor( json, buffers, images ) {
79
+ constructor( json, buffers, images, budget = new Budget() ) {
42
80
 
43
81
  this.json = json;
44
82
  this.buffers = buffers; // Uint8Array per json.buffers entry
45
83
  this.images = images; // { bytes, mimeType, name } per json.images entry
84
+ this.budget = budget;
46
85
  this.accessorCache = new Map();
47
86
  this.decodedViews = new Map(); // bufferView index -> bytes, for compressed views
48
87
 
@@ -52,9 +91,11 @@ export class GLTFDocument {
52
91
 
53
92
  const decoded = this.decodedViews.get( index );
54
93
  if ( decoded ) return decoded;
55
- const view = this.json.bufferViews[ index ];
56
- const buffer = this.buffers[ view.buffer ];
57
- return buffer.subarray( view.byteOffset || 0, ( view.byteOffset || 0 ) + view.byteLength );
94
+ const view = this.json.bufferViews?.[ index ];
95
+ const buffer = view && this.buffers[ view.buffer ];
96
+ const start = view?.byteOffset || 0;
97
+ if ( ! buffer || ! ( start + view.byteLength <= buffer.length ) ) throw new Error( `usd-gltf: bufferView ${ index } is outside its buffer` );
98
+ return buffer.subarray( start, start + view.byteLength );
58
99
 
59
100
  }
60
101
 
@@ -68,11 +109,16 @@ export class GLTFDocument {
68
109
  const key = index + ( normalize ? 'n' : '' );
69
110
  if ( this.accessorCache.has( key ) ) return this.accessorCache.get( key );
70
111
 
71
- const accessor = this.json.accessors[ index ];
72
- const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
73
- const itemSize = TYPE_SIZES[ accessor.type ];
112
+ const accessor = this.json.accessors?.[ index ];
113
+ const ArrayType = COMPONENT_ARRAYS[ accessor?.componentType ];
114
+ const itemSize = TYPE_SIZES[ accessor?.type ];
115
+ if ( ! ArrayType || ! itemSize ) throw new Error( `usd-gltf: accessor ${ index } is missing or has an invalid type` );
74
116
  const count = accessor.count;
75
- let array = new ArrayType( count * itemSize );
117
+ const stage = `accessor ${ index }`;
118
+ const size = count * itemSize * ArrayType.BYTES_PER_ELEMENT;
119
+ checkSize( size, stage );
120
+
121
+ let read = null;
76
122
 
77
123
  if ( accessor.bufferView !== undefined ) {
78
124
 
@@ -88,6 +134,17 @@ export class GLTFDocument {
88
134
  const columnStride = Math.ceil( rows * elementBytes / 4 ) * 4;
89
135
  const packed = columns === 1 ? itemSize * elementBytes : columnStride * columns;
90
136
  const stride = view.byteStride || packed;
137
+ if ( count > 0 && ! ( offset + stride * ( count - 1 ) + packed <= bytes.length ) ) throw new Error( `usd-gltf: ${ stage } reads past the end of its bufferView` );
138
+ read = { view, bytes, elementBytes, offset, columns, rows, columnStride, stride };
139
+
140
+ }
141
+
142
+ this.budget.decode( size, stage );
143
+ let array = new ArrayType( count * itemSize );
144
+
145
+ if ( read ) {
146
+
147
+ const { bytes, elementBytes, offset, columns, rows, columnStride, stride } = read;
91
148
 
92
149
  const dv = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength );
93
150
  const getters = { 5120: 'getInt8', 5121: 'getUint8', 5122: 'getInt16', 5123: 'getUint16', 5125: 'getUint32', 5126: 'getFloat32' };
@@ -120,9 +177,16 @@ export class GLTFDocument {
120
177
  const IndexType = COMPONENT_ARRAYS[ indices.componentType ];
121
178
  const ib = this.bufferViewBytes( indices.bufferView );
122
179
  const vb = this.bufferViewBytes( values.bufferView );
180
+ if ( ! IndexType || ! Number.isSafeInteger( n ) || n < 0 || n > count ) throw new Error( `usd-gltf: ${ stage } has an invalid sparse count` );
181
+ if ( ! ( ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT <= ib.length && ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT <= vb.length ) ) throw new Error( `usd-gltf: ${ stage } sparse data is outside its bufferViews` );
123
182
  const idx = new IndexType( ib.slice( indices.byteOffset || 0, ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT ).buffer );
124
183
  const vals = new ArrayType( vb.slice( values.byteOffset || 0, ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT ).buffer );
125
- for ( let i = 0; i < n; i ++ ) for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
184
+ for ( let i = 0; i < n; i ++ ) {
185
+
186
+ if ( idx[ i ] >= count ) throw new Error( `usd-gltf: ${ stage } sparse index is out of range` );
187
+ for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
188
+
189
+ }
126
190
 
127
191
  }
128
192
 
@@ -130,6 +194,7 @@ export class GLTFDocument {
130
194
 
131
195
  if ( normalize && normalized ) {
132
196
 
197
+ this.budget.decode( array.length * 4, stage );
133
198
  const out = new Float32Array( array.length );
134
199
  const ct = accessor.componentType;
135
200
  for ( let i = 0; i < array.length; i ++ ) {
@@ -154,7 +219,9 @@ export class GLTFDocument {
154
219
  getFloats( index ) {
155
220
 
156
221
  const { array, itemSize, count } = this.getAccessor( index );
157
- return { array: array instanceof Float32Array ? array : Float32Array.from( array ), itemSize, count };
222
+ if ( array instanceof Float32Array ) return { array, itemSize, count };
223
+ this.budget.decode( array.length * 4, `accessor ${ index }` );
224
+ return { array: Float32Array.from( array ), itemSize, count };
158
225
 
159
226
  }
160
227
 
@@ -165,13 +232,18 @@ export class GLTFDocument {
165
232
  * @param {Uint8Array} bytes
166
233
  * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array }} [options] loads external uris (already decoded from URI escaping)
167
234
  */
168
- export async function readGLTF( bytes, { resolver = null } = {} ) {
235
+ export async function readGLTF( bytes, { resolver = null, budget = new Budget() } = {} ) {
169
236
 
170
237
  let json, bin = null;
238
+ bytes = plainBytes( bytes );
171
239
 
172
240
  if ( bytes[ 0 ] === 0x67 && bytes[ 1 ] === 0x6C && bytes[ 2 ] === 0x54 && bytes[ 3 ] === 0x46 ) ( { json, bin } = readGLB( bytes ) );
173
241
  else json = JSON.parse( new TextDecoder().decode( bytes ).replace( /^/, '' ) );
174
242
 
243
+ const unsupported = unsupportedExtensions( json );
244
+ if ( unsupported.length ) throw new Error( `usd-gltf: required extension${ unsupported.length > 1 ? 's' : '' } ${ unsupported.join( ', ' ) } not supported` );
245
+ checkDracoFallbacks( json );
246
+
175
247
  async function load( uri ) {
176
248
 
177
249
  if ( uri.startsWith( 'data:' ) ) return decodeDataURI( uri ).bytes;
@@ -179,7 +251,7 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
179
251
  let path = uri;
180
252
  try { path = decodeURIComponent( uri ); } catch { /* keep as is */ }
181
253
  const data = await resolver( path );
182
- return data instanceof Uint8Array ? data : new Uint8Array( data );
254
+ return plainBytes( data );
183
255
 
184
256
  }
185
257
 
@@ -188,8 +260,9 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
188
260
  if ( buffer.uri === undefined ) {
189
261
 
190
262
  if ( i === 0 && bin ) return bin;
191
- // e.g. EXT_meshopt_compression fallback buffers have no data
192
- return new Uint8Array( buffer.byteLength );
263
+ // e.g. EXT_meshopt_compression fallback buffers: only read through decoded views, so they
264
+ // need no storage, and any other view into one fails its bounds check
265
+ return new Uint8Array( 0 );
193
266
 
194
267
  }
195
268
 
@@ -197,12 +270,16 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
197
270
 
198
271
  } ) );
199
272
 
200
- const doc = new GLTFDocument( json, buffers, [] );
273
+ const doc = new GLTFDocument( json, buffers, [], budget );
201
274
 
202
275
  ( json.bufferViews || [] ).forEach( ( view, i ) => {
203
276
 
204
277
  const ext = view.extensions?.KHR_meshopt_compression || view.extensions?.EXT_meshopt_compression;
205
- if ( ext ) doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
278
+ if ( ! ext ) return;
279
+ const start = ext.byteOffset || 0;
280
+ if ( ! buffers[ ext.buffer ] || ! ( start + ext.byteLength <= buffers[ ext.buffer ].length ) ) throw new Error( `usd-gltf: bufferView ${ i } compressed data is outside its buffer` );
281
+ budget.decode( ext.count * ext.byteStride, `bufferView ${ i }` );
282
+ doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
206
283
 
207
284
  } );
208
285
 
@@ -228,6 +305,14 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
228
305
 
229
306
  }
230
307
 
308
+ // A plain Uint8Array view: a Node Buffer's slice() aliases its memory instead of copying, so data
309
+ // read through .slice().buffer would be the wrong bytes.
310
+ function plainBytes( data ) {
311
+
312
+ return ArrayBuffer.isView( data ) ? new Uint8Array( data.buffer, data.byteOffset, data.byteLength ) : new Uint8Array( data );
313
+
314
+ }
315
+
231
316
  function guessMime( uri ) {
232
317
 
233
318
  const ext = uri.split( '?' )[ 0 ].split( '.' ).pop().toLowerCase();
@@ -240,8 +325,9 @@ function guessMime( uri ) {
240
325
  */
241
326
  export class GLTFBuilder {
242
327
 
243
- constructor( generator = 'usd-gltf' ) {
328
+ constructor( generator = 'usd-gltf', budget = new Budget() ) {
244
329
 
330
+ this.budget = budget;
245
331
  this.json = { asset: { version: '2.0', generator }, scenes: [], nodes: [], meshes: [], materials: [], textures: [], images: [], samplers: [], accessors: [], bufferViews: [], buffers: [] };
246
332
  this.chunks = [];
247
333
  this.byteLength = 0;
@@ -262,6 +348,7 @@ export class GLTFBuilder {
262
348
  const pad = ( 4 - ( this.byteLength % 4 ) ) % 4;
263
349
  if ( pad ) { this.chunks.push( new Uint8Array( pad ) ); this.byteLength += pad; }
264
350
 
351
+ this.budget.output( this.byteLength + bytes.byteLength, 'glb buffer' );
265
352
  const view = { buffer: 0, byteOffset: this.byteLength, byteLength: bytes.byteLength };
266
353
  if ( target ) view.target = target;
267
354
  this.chunks.push( bytes instanceof Uint8Array ? bytes : new Uint8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength ) );
@@ -323,7 +410,9 @@ export class GLTFBuilder {
323
410
 
324
411
  toGLB() {
325
412
 
326
- const json = this.finalize();
413
+ const json = new TextEncoder().encode( JSON.stringify( this.finalize() ) );
414
+ const pad4 = ( n ) => Math.ceil( n / 4 ) * 4;
415
+ this.budget.output( 20 + pad4( json.byteLength ) + ( this.byteLength ? 8 + pad4( this.byteLength ) : 0 ), 'glb' );
327
416
  const bin = new Uint8Array( this.byteLength );
328
417
  let offset = 0;
329
418
  for ( const chunk of this.chunks ) {
package/src/gltf/glb.js CHANGED
@@ -68,13 +68,13 @@ export function readGLB( data ) {
68
68
 
69
69
  /**
70
70
  * Packs glTF JSON and an optional binary buffer into a GLB.
71
- * @param {object} json
71
+ * @param {object|Uint8Array} json the glTF JSON, or it already encoded as UTF-8
72
72
  * @param {Uint8Array|null} [bin]
73
73
  * @returns {Uint8Array}
74
74
  */
75
75
  export function writeGLB( json, bin = null ) {
76
76
 
77
- const jsonBytes = encoder.encode( JSON.stringify( json ) );
77
+ const jsonBytes = json instanceof Uint8Array ? json : encoder.encode( JSON.stringify( json ) );
78
78
  const jsonLength = pad4( jsonBytes.byteLength );
79
79
  const binLength = bin ? pad4( bin.byteLength ) : 0;
80
80
 
@@ -37,6 +37,82 @@ export function imageMimeType( bytes ) {
37
37
 
38
38
  }
39
39
 
40
+ /**
41
+ * Whether a PNG or JPEG stores a single grey channel, from its header.
42
+ * @param {Uint8Array} bytes
43
+ */
44
+ export function isGreyscaleImage( bytes ) {
45
+
46
+ bytes = toBytes( bytes );
47
+ const type = imageMimeType( bytes );
48
+ if ( type === 'image/png' ) return bytes[ 25 ] === 0 || bytes[ 25 ] === 4; // IHDR colour type
49
+
50
+ if ( type === 'image/jpeg' ) {
51
+
52
+ // walk the markers to the frame header, whose component count follows the dimensions
53
+ for ( let o = 2; o + 9 < bytes.length; ) {
54
+
55
+ if ( bytes[ o ] !== 0xFF ) return false;
56
+ const marker = bytes[ o + 1 ];
57
+ if ( marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC ) return bytes[ o + 9 ] === 1;
58
+ o += 2 + ( ( bytes[ o + 2 ] << 8 ) | bytes[ o + 3 ] );
59
+
60
+ }
61
+
62
+ }
63
+
64
+ return false;
65
+
66
+ }
67
+
68
+ /**
69
+ * Width, height and bytes per pixel of a PNG, JPEG or WebP from its header, or null.
70
+ * @param {Uint8Array} bytes
71
+ * @returns {{ width: number, height: number, pixelBytes: number }|null}
72
+ */
73
+ export function imageSize( bytes ) {
74
+
75
+ bytes = toBytes( bytes );
76
+ const type = imageMimeType( bytes );
77
+ const be16 = ( o ) => ( bytes[ o ] << 8 ) | bytes[ o + 1 ];
78
+ const le16 = ( o ) => bytes[ o ] | ( bytes[ o + 1 ] << 8 );
79
+ const le24 = ( o ) => le16( o ) | ( bytes[ o + 2 ] << 16 );
80
+
81
+ if ( type === 'image/png' && bytes.length >= 26 ) {
82
+
83
+ const width = ( be16( 16 ) * 65536 ) + be16( 18 ), height = ( be16( 20 ) * 65536 ) + be16( 22 );
84
+ return { width, height, pixelBytes: bytes[ 24 ] === 16 ? 8 : 4 };
85
+
86
+ }
87
+
88
+ if ( type === 'image/jpeg' ) {
89
+
90
+ for ( let o = 2; o + 9 < bytes.length; ) {
91
+
92
+ if ( bytes[ o ] !== 0xFF ) return null;
93
+ const marker = bytes[ o + 1 ];
94
+ if ( marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC ) return { width: be16( o + 7 ), height: be16( o + 5 ), pixelBytes: 4 };
95
+ o += 2 + be16( o + 2 );
96
+
97
+ }
98
+
99
+ return null;
100
+
101
+ }
102
+
103
+ if ( type === 'image/webp' && bytes.length >= 30 ) {
104
+
105
+ const chunk = String.fromCharCode( bytes[ 12 ], bytes[ 13 ], bytes[ 14 ], bytes[ 15 ] );
106
+ if ( chunk === 'VP8X' ) return { width: le24( 24 ) + 1, height: le24( 27 ) + 1, pixelBytes: 4 };
107
+ if ( chunk === 'VP8 ' ) return { width: le16( 26 ) & 0x3FFF, height: le16( 28 ) & 0x3FFF, pixelBytes: 4 };
108
+ if ( chunk === 'VP8L' ) { const b = bytes[ 21 ] | ( bytes[ 22 ] << 8 ) | ( bytes[ 23 ] << 16 ) | ( bytes[ 24 ] << 24 ); return { width: ( b & 0x3FFF ) + 1, height: ( ( b >>> 14 ) & 0x3FFF ) + 1, pixelBytes: 4 }; }
109
+
110
+ }
111
+
112
+ return null;
113
+
114
+ }
115
+
40
116
  /**
41
117
  * Decodes a PNG, JPEG or WebP. The format is sniffed from the bytes; mimeType is only a fallback.
42
118
  * @param {Uint8Array|ArrayBuffer} bytes
@@ -57,6 +133,55 @@ export function decodeImage( bytes, mimeType ) {
57
133
 
58
134
  const hasNativeDecoder = typeof createImageBitmap === 'function' && typeof OffscreenCanvas === 'function';
59
135
 
136
+ /**
137
+ * Encodes a PNG, with the platform's encoder where there is one (much faster) unless the image has
138
+ * partial transparency, which canvases store premultiplied.
139
+ * @param {{ width: number, height: number, channels: number, data: Uint8Array }} image 8 bit
140
+ * @returns {Promise<Uint8Array>}
141
+ */
142
+ export async function encodePNGAsync( image ) {
143
+
144
+ const { width, height, channels, data } = image;
145
+ let opaque = channels !== 2 && channels !== 4;
146
+ if ( ! opaque ) {
147
+
148
+ opaque = true;
149
+ for ( let i = channels - 1; i < data.length; i += channels ) if ( data[ i ] !== 255 ) { opaque = false; break; }
150
+
151
+ }
152
+
153
+ if ( hasNativeDecoder && opaque && ( image.bitDepth ?? 8 ) === 8 ) {
154
+
155
+ try {
156
+
157
+ const rgba = new Uint8ClampedArray( width * height * 4 );
158
+ for ( let i = 0, n = width * height; i < n; i ++ ) {
159
+
160
+ const s = i * channels;
161
+ rgba[ i * 4 ] = data[ s ];
162
+ rgba[ i * 4 + 1 ] = data[ channels >= 3 ? s + 1 : s ];
163
+ rgba[ i * 4 + 2 ] = data[ channels >= 3 ? s + 2 : s ];
164
+ rgba[ i * 4 + 3 ] = 255;
165
+
166
+ }
167
+
168
+ const canvas = new OffscreenCanvas( width, height );
169
+ canvas.getContext( '2d' ).putImageData( new ImageData( rgba, width, height ), 0, 0 );
170
+ const blob = await canvas.convertToBlob( { type: 'image/png' } );
171
+ return new Uint8Array( await blob.arrayBuffer() );
172
+
173
+ } catch {
174
+
175
+ // fall through to the JavaScript encoder
176
+
177
+ }
178
+
179
+ }
180
+
181
+ return encodePNG( image );
182
+
183
+ }
184
+
60
185
  /**
61
186
  * Decodes with the platform's image decoder where there is one (browsers and workers), which is
62
187
  * faster and supports more formats, falling back to the JavaScript decoders (Node).
@@ -64,10 +189,13 @@ const hasNativeDecoder = typeof createImageBitmap === 'function' && typeof Offsc
64
189
  * @param {Uint8Array} bytes
65
190
  * @param {string} [mimeType]
66
191
  * @param {( bytes: Uint8Array, mimeType?: string ) => Promise<object>|object} [decoder] overrides both
192
+ * @param {import('../limits.js').Budget} [budget] charged with the decoded size, from the header, before decoding
67
193
  */
68
- export async function decodeImageAsync( bytes, mimeType, decoder = null ) {
194
+ export async function decodeImageAsync( bytes, mimeType, decoder = null, budget = null ) {
69
195
 
70
196
  bytes = toBytes( bytes );
197
+ const size = budget && imageSize( bytes );
198
+ if ( size ) budget.decode( size.width * size.height * size.pixelBytes, 'image' );
71
199
  if ( decoder ) return decoder( bytes, mimeType );
72
200
  const type = imageMimeType( bytes ) || mimeType;
73
201