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.
- package/README.md +29 -6
- package/package.json +10 -4
- package/src/convert/gltf-to-usd.js +166 -46
- package/src/convert/usd-material.js +65 -36
- package/src/convert/usd-to-gltf.js +8 -4
- package/src/gltf/document.js +107 -18
- package/src/gltf/glb.js +2 -2
- package/src/image/index.js +129 -1
- package/src/image/inflate.js +14 -12
- package/src/image/png.js +1 -1
- package/src/index.d.ts +92 -0
- package/src/index.js +33 -14
- package/src/limits.js +101 -0
- package/src/usd/stage.js +13 -9
- package/src/usd/usda/reader.js +17 -1
- package/src/usd/usda/writer.js +28 -7
- package/src/usd/usdc/reader.js +63 -13
- package/src/usd/usdc/writer.js +53 -3
- package/src/usd/usdz.js +9 -3
|
@@ -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
|
|
566
|
-
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
661
|
+
for ( let y = 0, o = c; y < height; y ++ ) {
|
|
626
662
|
|
|
627
|
-
|
|
663
|
+
const row = Math.min( src.height - 1, Math.floor( y * src.height / height ) ) * sw;
|
|
628
664
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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 );
|
package/src/gltf/document.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
73
|
-
const itemSize = TYPE_SIZES[ accessor
|
|
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
|
-
|
|
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 ++ )
|
|
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
|
-
|
|
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
|
|
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
|
|
192
|
-
|
|
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 (
|
|
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
|
|
package/src/image/index.js
CHANGED
|
@@ -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
|
|