-
-
Notifications
You must be signed in to change notification settings - Fork 145
/
cli.ts
executable file
·1768 lines (1647 loc) · 63.7 KB
/
cli.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { URL } from 'url';
import { promises as fs, readFileSync } from 'fs';
import micromatch from 'micromatch';
import { gzip } from 'node-gzip';
import fetch from 'node-fetch';
import mikktspace from 'mikktspace';
import { MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
import { ready as resampleReady, resample as resampleWASM } from 'keyframe-resample';
import { Logger, NodeIO, PropertyType, VertexLayout, vec2, Transform } from '@gltf-transform/core';
import {
CenterOptions,
InstanceOptions,
INSTANCE_DEFAULTS,
PartitionOptions,
PruneOptions,
QUANTIZE_DEFAULTS,
ResampleOptions,
SequenceOptions,
TextureResizeFilter,
UnweldOptions,
WeldOptions,
center,
dedup,
instance,
metalRough,
partition,
prune,
quantize,
resample,
sequence,
tangents,
unweld,
weld,
reorder,
dequantize,
unlit,
meshopt,
DRACO_DEFAULTS,
draco,
DracoOptions,
simplify,
SIMPLIFY_DEFAULTS,
textureCompress,
FlattenOptions,
flatten,
JOIN_DEFAULTS,
join,
JoinOptions,
sparse,
SparseOptions,
palette,
PaletteOptions,
PALETTE_DEFAULTS,
MESHOPT_DEFAULTS,
TEXTURE_COMPRESS_SUPPORTED_FORMATS,
PRUNE_DEFAULTS,
} from '@gltf-transform/functions';
import { inspect } from './inspect.js';
import {
ETC1S_DEFAULTS,
Filter,
Mode,
UASTC_DEFAULTS,
ktxfix,
merge,
toktx,
XMPOptions,
xmp,
} from './transforms/index.js';
import { formatBytes, MICROMATCH_OPTIONS, underline, TableFormat, dim, regexFromArray } from './util.js';
import { Session } from './session.js';
import { ValidateOptions, validate } from './validate.js';
import { getConfig, loadConfig } from './config.js';
import { Validator, program } from './program.js';
let io: NodeIO;
const programReady = new Promise<void>((resolve) => {
// Manually detect and handle --config, before program actually runs.
if (process.argv.includes('--config')) {
loadConfig(process.argv[process.argv.indexOf('--config') + 1]);
}
return getConfig().then(async (config) => {
io = new NodeIO(fetch).registerExtensions(config.extensions).registerDependencies(config.dependencies);
if (config.onProgramReady) {
program.section('User', '👤');
await config.onProgramReady({ program, io, Session });
}
resolve();
});
});
const INPUT_DESC = 'Path to read glTF 2.0 (.glb, .gltf) model';
const OUTPUT_DESC = 'Path to write output';
const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
program.version(PACKAGE.version).description('Command-line interface (CLI) for the glTF Transform SDK.');
if (process.argv && !process.argv.includes('--no-editorial')) {
program
.help(
`
To run the most common optimizations in one easy step, use the 'optimize' command:
▸ gltf-transform optimize <input> <output> --compress draco --texture-compress webp
Defaults in the 'optimize' command may not be ideal for all scenes. Some of its
features can be configured (${dim(`optimize --help`)}), or more advanced users may wish
to inspect their scenes then pick and choose optimizations.
▸ gltf-transform inspect <input>
The report printed by the 'inspect' command should identify performance issues,
and whether the scene is generally geometry-heavy, texture-heavy, has too many
draw calls, etc. Apply individual commands below to deal with any of these
issues as needed.
`.trim(),
)
.help(
`
${underline('Using glTF Transform for a personal project?')} That's great! Sponsorship is
neither expected nor required. Feel free to share screenshots if you've
made something you're excited about — I enjoy seeing those!
${underline('Using glTF Transform in for-profit work?')} That's wonderful! Your support is
important to keep glTF Transform maintained, independent, and open source under
MIT License. Please consider a subscription or GitHub sponsorship.
Learn more in the glTF Transform Pro FAQs (https://gltf-transform.dev/pro).
`.trim(),
{ sectionName: 'COMMERCIAL USE' },
);
}
program.section('Inspect', '🔎');
// INSPECT
program
.command('inspect', 'Inspect contents of the model')
.help(
`
Inspect the contents of the model, printing a table with properties and
statistics for scenes, meshes, materials, textures, and animations contained
by the file. This data is useful for understanding how much of a file's size
is comprised of geometry vs. textures, which extensions are needed when loading
the file, and which material properties are being used.
Use --format=csv or --format=md for alternative display formats.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.option('--format <format>', 'Table output format', {
validator: [TableFormat.PRETTY, TableFormat.CSV, TableFormat.MD],
default: TableFormat.PRETTY,
})
.action(async ({ args, options, logger }) => {
io.setLogger(logger as unknown as Logger);
await inspect(
await io.readAsJSON(args.input as string),
io,
logger as unknown as Logger,
options.format as TableFormat,
);
});
// VALIDATE
program
.command('validate', 'Validate model against the glTF spec')
.help(
`
Validate the model with official glTF validator. The validator detects whether
a file conforms correctly to the glTF specification, and is useful for
debugging issues with a model. Validation errors typically suggest a problem
in the authoring process, and can be reported as bugs on the software used to
export the file. Certain lower-priority issues are not technically invalid, but
may indicate an unintended situation in the file, like unused data not attached
to any particular scene.
For more details about the official validation suite used here, see:
https://github.com/KhronosGroup/glTF-Validator
Example:
▸ gltf-transform validate input.glb --ignore ACCESSOR_WEIGHTS_NON_NORMALIZED
`.trim(),
)
.argument('<input>', INPUT_DESC)
.option('--limit <limit>', 'Limit number of issues to display', {
validator: Validator.NUMBER,
default: 1e7,
})
.option('--ignore <CODE>,<CODE>,...', 'Issue codes to be ignored', {
validator: Validator.ARRAY,
default: [],
})
.option('--format <format>', 'Table output format', {
validator: [TableFormat.PRETTY, TableFormat.CSV, TableFormat.MD],
default: TableFormat.PRETTY,
})
.action(({ args, options, logger }) => {
return validate(args.input as string, options as unknown as ValidateOptions, logger as unknown as Logger);
});
program.section('Package', '📦');
// COPY
program
.command('copy', 'Copy model with minimal changes')
.alias('cp')
.help(
`
Copy the model from <input> to <output> with minimal changes. Unlike filesystem
\`cp\`, this command does parse the file into glTF Transform's internal
representation before serializing it to disk again. No other intentional
changes are made, so copying a model can be a useful first step to confirm that
glTF Transform is reading and writing the model correctly when debugging issues
in a larger script doing more complex processing of the file. Copying may also
be used to ensure consistent data layout across glTF files from different
exporters, e.g. if your engine always requires interleaved vertex attributes.
While vertex data remains byte-for-byte the same before and after copying, and
scene, node, material, and other properties are also preserved losslessly,
certain aspects of data layout may change slightly with this process:
- Vertex attributes within a mesh are interleaved.
- Accessors are organized into buffer views according to usage.
- Draco compression is removed to avoid a lossy decompress/compress round trip.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.action(({ args, logger }) => Session.create(io, logger, args.input, args.output).transform());
// OPTIMIZE
program
.command('optimize', 'Optimize model by all available methods')
.help(
`
Optimize the model by all available methods. Combines many features of the
glTF Transform CLI into a single command for convenience and faster results.
For more control over the optimization process, consider running individual
commands or using the scripting API.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--instance <bool>', 'Use GPU instancing with shared mesh references.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--instance-min <min>', 'Number of instances required for instancing.', {
validator: Validator.NUMBER,
default: INSTANCE_DEFAULTS.min,
})
.option('--meshopt-level <level>', 'Meshopt compression level.', {
validator: ['medium', 'high'],
default: 'high',
})
.option('--palette <bool>', 'Creates palette textures and merges materials.', {
validator: Validator.BOOLEAN,
default: true,
})
.option(
'--palette-min <min>',
'Minimum number of blocks in the palette texture. If fewer unique ' +
'material values are found, no palettes will be generated.',
{
validator: Validator.NUMBER,
default: PALETTE_DEFAULTS.min,
},
)
.option('--simplify <bool>', 'Simplify mesh geometry with meshoptimizer.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--simplify-error <error>', 'Simplification error tolerance, as a fraction of mesh extent.', {
validator: Validator.NUMBER,
default: SIMPLIFY_DEFAULTS.error,
})
.option('--simplify-ratio <ratio>', 'Target ratio (0–1) of vertices to keep.', {
validator: Validator.NUMBER,
default: SIMPLIFY_DEFAULTS.ratio,
})
.option('--simplify-lock-border <bool>', 'Whether to lock topological borders of the mesh.', {
validator: Validator.BOOLEAN,
default: SIMPLIFY_DEFAULTS.lockBorder,
})
.option('--prune <bool>', 'Removes properties from the file if they are not referenced by a Scene.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--prune-attributes <bool>', 'Whether to prune unused vertex attributes.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--prune-leaves <bool>', 'Whether to prune empty leaf nodes.', {
validator: Validator.BOOLEAN,
default: true,
})
.option(
'--prune-solid-textures <bool>',
'Whether to prune solid (single-color) textures, converting them to material factors.',
{
validator: Validator.BOOLEAN,
default: true,
},
)
.option(
'--compress <method>',
'Floating point compression method. Draco compresses geometry; Meshopt ' +
'and quantization compress geometry and animation.',
{
validator: ['draco', 'meshopt', 'quantize', false],
default: 'meshopt',
},
)
.option(
'--texture-compress <format>',
'Texture compression format. KTX2 optimizes VRAM usage and performance; ' +
'AVIF and WebP optimize transmission size. Auto recompresses in original format.',
{
validator: ['ktx2', 'webp', 'avif', 'auto', false],
default: 'auto',
},
)
.option('--texture-size <size>', 'Maximum texture dimensions, in pixels.', {
validator: Validator.NUMBER,
default: 2048,
})
.option('--flatten <bool>', 'Flatten scene graph.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--join <bool>', 'Join meshes and reduce draw calls. Requires `--flatten`.', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--weld <bool>', 'Merge equivalent vertices. Required when simplifying geometry.', {
validator: Validator.BOOLEAN,
default: true,
})
.action(async ({ args, options, logger }) => {
const opts = options as {
instance: boolean;
instanceMin: number;
meshoptLevel: 'medium' | 'high';
palette: boolean;
paletteMin: number;
simplify: boolean;
simplifyError: number;
simplifyRatio: number;
simplifyLockBorder: boolean;
prune: boolean;
pruneAttributes: boolean;
pruneLeaves: boolean;
pruneSolidTextures: boolean;
compress: 'draco' | 'meshopt' | 'quantize' | false;
textureCompress: 'ktx2' | 'webp' | 'webp' | 'auto' | false;
textureSize: number;
flatten: boolean;
join: boolean;
weld: boolean;
};
// Baseline transforms.
const transforms: Transform[] = [dedup()];
if (opts.instance) transforms.push(instance({ min: opts.instanceMin }));
if (opts.palette) {
transforms.push(
palette({
min: opts.paletteMin,
keepAttributes: !opts.prune || !opts.pruneAttributes,
}),
);
}
if (opts.flatten) transforms.push(flatten());
if (opts.join) transforms.push(join());
if (opts.weld) transforms.push(weld());
if (opts.simplify) {
transforms.push(
simplify({
simplifier: MeshoptSimplifier,
error: opts.simplifyError,
ratio: opts.simplifyRatio,
lockBorder: opts.simplifyLockBorder,
}),
);
}
transforms.push(resample({ ready: resampleReady, resample: resampleWASM }));
if (opts.prune) {
transforms.push(
prune({
keepAttributes: !opts.pruneAttributes,
keepIndices: false,
keepLeaves: !opts.pruneLeaves,
keepSolidTextures: !opts.pruneSolidTextures,
}),
);
}
transforms.push(sparse());
// Texture compression.
if (opts.textureCompress === 'ktx2') {
const { default: encoder } = await import('sharp');
const slotsUASTC = micromatch.makeRe(
'{normalTexture,occlusionTexture,metallicRoughnessTexture}',
MICROMATCH_OPTIONS,
);
transforms.push(
toktx({
encoder,
resize: [opts.textureSize, opts.textureSize],
mode: Mode.UASTC,
slots: slotsUASTC,
level: 4,
rdo: true,
rdoLambda: 4,
limitInputPixels: options.limitInputPixels as boolean,
}),
toktx({
encoder,
resize: [opts.textureSize, opts.textureSize],
mode: Mode.ETC1S,
quality: 255,
limitInputPixels: options.limitInputPixels as boolean,
}),
);
} else if (opts.textureCompress !== false) {
const { default: encoder } = await import('sharp');
transforms.push(
textureCompress({
encoder,
resize: [opts.textureSize, opts.textureSize],
targetFormat: opts.textureCompress === 'auto' ? undefined : opts.textureCompress,
limitInputPixels: options.limitInputPixels as boolean,
}),
);
}
// Mesh compression last. Doesn't matter here, but in one-off CLI
// commands we want to avoid recompressing mesh data.
if (opts.compress === 'draco') {
transforms.push(draco());
} else if (opts.compress === 'meshopt') {
transforms.push(meshopt({ encoder: MeshoptEncoder, level: opts.meshoptLevel }));
} else if (opts.compress === 'quantize') {
transforms.push(quantize());
}
return Session.create(io, logger, args.input, args.output)
.setDisplay(true)
.transform(...transforms);
});
// MERGE
program
.command('merge', 'Merge two or more models into one')
.help(
`
Merge two or more models into one, each in a separate Scene. Optionally, the
binary data for each model may be kept in a separate buffer with the
--partition flag.
Example:
▸ gltf-transform merge a.glb b.glb c.glb output.glb
`.trim(),
)
.argument('<path...>', `${INPUT_DESC}(s). Final path is used to write output.`)
.option('--partition', 'Whether to keep separate buffers for each input file. Invalid for GLB output.', {
validator: Validator.BOOLEAN,
default: false,
})
.option('--merge-scenes', 'Whether to merge scenes, or keep one scene per input file.', {
validator: Validator.BOOLEAN,
default: false,
})
.action(({ args, options, logger }) => {
const paths = typeof args.path === 'string' ? args.path.split(',') : (args.path as string[]);
const output = paths.pop();
return Session.create(io, logger, '', output).transform(merge({ io, paths, ...options }));
});
// PARTITION
program
.command('partition', 'Partition binary data into separate .bin files')
.help(
`
Partition binary data for meshes or animations into separate .bin files. In
engines that support lazy-loading resources within glTF files, this allows
restructuring the data to minimize initial load time, fetching additional
resources as needed. Partitioning is supported only for .gltf, not .glb, files.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--animations', 'Partition each animation into a separate .bin file', {
validator: Validator.BOOLEAN,
default: false,
})
.option('--meshes', 'Partition each mesh into a separate .bin file', {
validator: Validator.BOOLEAN,
default: false,
})
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(partition(options as PartitionOptions)),
);
// DEDUP
program
.command('dedup', 'Deduplicate accessors and textures')
.help(
`
Deduplicate accessors, textures, materials, meshes, and skins. Some exporters or
pipeline processing may lead to multiple resources within a file containing
redundant copies of the same information. This functions scans for these cases
and merges the duplicates where possible, reducing file size. The process may
be very slow on large files with many accessors.
Deduplication early in a pipeline may also help other optimizations, like
compression and instancing, to be more effective.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--accessors <accessors>', 'Remove duplicate accessors', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--materials <materials>', 'Remove duplicate materials', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--meshes <meshes>', 'Remove duplicate meshes', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--skins <skins>', 'Remove duplicate skins', {
validator: Validator.BOOLEAN,
default: true,
})
.option('--textures <textures>', 'Remove duplicate textures', {
validator: Validator.BOOLEAN,
default: true,
})
.action(({ args, options, logger }) => {
const propertyTypes: string[] = [];
if (options.accessors) propertyTypes.push(PropertyType.ACCESSOR);
if (options.materials) propertyTypes.push(PropertyType.MATERIAL);
if (options.meshes) propertyTypes.push(PropertyType.MESH);
if (options.skins) propertyTypes.push(PropertyType.SKIN);
if (options.textures) propertyTypes.push(PropertyType.TEXTURE);
return Session.create(io, logger, args.input, args.output).transform(dedup({ propertyTypes }));
});
// PRUNE
program
.command('prune', 'Remove unreferenced properties from the file')
.help(
`
Removes properties from the file if they are not referenced by a Scene. Helpful
when cleaning up after complex workflows or a faulty exporter. This function
may (conservatively) fail to identify some unused extension properties, such as
lights, but it will not remove anything that is still in use, even if used by
an extension. Animations are considered unused if they do not target any nodes
that are children of a scene.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--keep-attributes <keepAttributes>', 'Whether to keep unused vertex attributes', {
validator: Validator.BOOLEAN,
default: PRUNE_DEFAULTS.keepAttributes,
})
.option('--keep-indices <keepIndices>', 'Whether to keep unused mesh indices', {
validator: Validator.BOOLEAN,
default: PRUNE_DEFAULTS.keepIndices,
})
.option('--keep-leaves <keepLeaves>', 'Whether to keep empty leaf nodes', {
validator: Validator.BOOLEAN,
default: PRUNE_DEFAULTS.keepLeaves,
})
.option(
'--keep-solid-textures <keepSolidTextures>',
'Whether to keep solid (single-color) textures, or convert to material factors',
{
validator: Validator.BOOLEAN,
default: PRUNE_DEFAULTS.keepSolidTextures,
},
)
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(prune(options as unknown as PruneOptions)),
);
// GZIP
program
.command('gzip', 'Compress model with lossless gzip')
.help(
`
Compress the model with gzip. Gzip is a general-purpose file compression
technique, not specific to glTF models. On the web, decompression is
handled automatically by the web browser, without any intervention from the
client application.
When the model contains resources that are already effectively compressed, like
JPEG textures or Draco geometry, gzip is unlikely to add much further benefit
and can be skipped. Other compression strategies, like Meshopt and quantization,
work best when combined with gzip.
`,
)
.argument('<input>', INPUT_DESC)
.action(async ({ args, logger }) => {
const inBuffer = await fs.readFile(args.input as string);
const outBuffer = await gzip(inBuffer);
const fileName = args.input + '.gz';
const inSize = formatBytes(inBuffer.byteLength);
const outSize = formatBytes(outBuffer.byteLength);
await fs.writeFile(fileName, outBuffer);
logger.info(`Created ${fileName} (${inSize} → ${outSize})`);
});
// XMP
program
.command('xmp', 'Add or modify XMP metadata')
.help(
`
XMP metadata provides standardized fields describing the content, provenance, usage restrictions,
or other attributes of a 3D model. Such metadata does not generally affect the parsing or runtime
behavior of the content — for that, use custom extensions, custom vertex attributes, or extras.
The easiest (and default) mode of the CLI 'xmp' command provides interactive prompts, walking
through a series of questions and then constructing appropriate JSONLD output. These interactive
prompts do not include all possible XMP namespaces and fields, but should cover most common cases.
For more advanced cases, provide an external .jsonld or .json file specified by the --packet
flag, or use the scripting API to manually input JSONLD fields.
To remove XMP metadata and the KHR_xmp_json_ld extension, use the --reset flag.
${underline('Documentation')}
- https://gltf-transform.dev/classes/extensions.xmp.html
`,
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--packet <path>', 'Path to XMP packet (.jsonld or .json)')
.option('--reset', 'Reset metadata and remove XMP extension', {
validator: Validator.BOOLEAN,
default: false,
})
.action(async ({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(xmp({ ...options } as XMPOptions)),
);
program.section('Scene', '🌍');
// CENTER
program
.command('center', 'Center the scene at the origin, or above/below it')
.help(
`
Center the scene at the origin, or above/below it. When loading a model into
a larger scene, or into an augmented reality context, it's often best to ensure
the model's pivot is centered beneath the object. For objects meant to be
attached a surface, like a ceiling fan, the pivot may be located above instead.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--pivot <pivot>', 'Method used to determine the scene pivot', {
validator: ['center', 'above', 'below'],
default: 'center',
})
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(center({ ...options } as CenterOptions)),
);
// INSTANCE
program
.command('instance', 'Create GPU instances from shared mesh references')
.help(
`
For meshes reused by more than one node in a scene, this command creates an
EXT_mesh_gpu_instancing extension to aid with GPU instancing. In engines that
support the extension, this may allow GPU instancing to be used, reducing draw
calls and improving framerate.
Engines may use GPU instancing with or without the presence of this extension,
and are strongly encouraged to do so. However, particularly when loading a
model at runtime, the extension provides useful context allowing the engine to
use this technique efficiently.
Instanced meshes cannot be animated, and must share the same materials. For
further details, see:
https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/EXT_mesh_gpu_instancing.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option(
'--min <count>',
'Minimum number of meshes in a batch. If fewer compatible meshes ' +
'are found, no instanced batches will be generated.',
{
validator: Validator.NUMBER,
default: INSTANCE_DEFAULTS.min,
},
)
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(instance({ ...options } as InstanceOptions)),
);
// FLATTEN
program
.command('flatten', 'Flatten scene graph')
.help(
`
Flattens the scene graph, leaving Nodes with Meshes, Cameras, and other
attachments as direct children of the Scene. Skeletons and their
descendants are left in their original Node structure.
Animation targeting a Node or its parents will prevent that Node from being
moved.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(flatten({ ...options } as FlattenOptions)),
);
// JOIN
program
.command('join', 'Join meshes and reduce draw calls')
.help(
`
Joins compatible Primitives and reduces draw calls. Primitives are eligible for
joining if they are members of the same Mesh or, optionally, attached to sibling
Nodes in the scene hierarchy. Implicitly runs \`dedup\` and \`flatten\` commands
first, to maximize the number of Primitives that can be joined.
NOTE: In a Scene that heavily reuses the same Mesh data, joining may increase
vertex count. Consider alternatives, like \`instance\` with
EXT_mesh_gpu_instancing.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--keepMeshes <bool>', 'Prevents joining distinct Meshes and Nodes.', {
validator: Validator.BOOLEAN,
default: JOIN_DEFAULTS.keepMeshes,
})
.option('--keepNamed <bool>', 'Prevents joining named Meshes and Nodes.', {
validator: Validator.BOOLEAN,
default: JOIN_DEFAULTS.keepNamed,
})
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(
dedup({ propertyTypes: [PropertyType.MATERIAL] }),
flatten(),
join({ ...options } as unknown as JoinOptions),
),
);
program.section('Geometry', '🫖');
// DRACO
program
.command('draco', 'Compress geometry with Draco')
.help(
`
Compress mesh geometry with the Draco library. This type of compression affects
only geometry data — animation and textures are not compressed.
Compresses
- geometry (only triangle meshes)
${underline('Documentation')}
- https://gltf-transform.dev/classes/extensions.dracomeshcompression.html
${underline('References')}
- draco: https://github.com/google/draco
- KHR_draco_mesh_compression: https://github.com/KhronosGroup/gltf/blob/main/extensions/2.0/Khronos/KHR_draco_mesh_compression/
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--method <method>', 'Compression method.', {
validator: ['edgebreaker', 'sequential'],
default: 'edgebreaker',
})
.option('--encode-speed <encodeSpeed>', 'Encoding speed vs. compression level, 1–10.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.encodeSpeed,
})
.option('--decode-speed <decodeSpeed>', 'Decoding speed vs. compression level, 1–10.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.decodeSpeed,
})
.option('--quantize-position <bits>', 'Quantization bits for POSITION, 1-16.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.quantizePosition,
})
.option('--quantize-normal <bits>', 'Quantization bits for NORMAL, 1-16.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.quantizeNormal,
})
.option('--quantize-color <bits>', 'Quantization bits for COLOR_*, 1-16.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.quantizeColor,
})
.option('--quantize-texcoord <bits>', 'Quantization bits for TEXCOORD_*, 1-16.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.quantizeTexcoord,
})
.option('--quantize-generic <bits>', 'Quantization bits for other attributes, 1-16.', {
validator: Validator.NUMBER,
default: DRACO_DEFAULTS.quantizeGeneric,
})
.option('--quantization-volume <volume>', 'Bounds for quantization grid.', {
validator: ['mesh', 'scene'],
default: DRACO_DEFAULTS.quantizationVolume,
})
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(draco(options as unknown as DracoOptions)),
);
// MESHOPT
program
.command('meshopt', 'Compress geometry and animation with Meshopt')
.help(
`
Compress geometry, morph targets, and animation with Meshopt. Meshopt
compression decodes very quickly, and is best used in combination with a
lossless compression method like brotli or gzip.
Compresses
- geometry (points, lines, triangle meshes)
- morph targets
- animation tracks
${underline('Documentation')}
- https://gltf-transform.dev/classes/extensions.meshoptcompression.html
${underline('References')}
- meshoptimizer: https://github.com/zeux/meshoptimizer
- EXT_meshopt_compression: https://github.com/KhronosGroup/gltf/blob/main/extensions/2.0/Vendor/EXT_meshopt_compression/
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--level <level>', 'Compression level.', {
validator: ['medium', 'high'],
default: 'high',
})
.option('--quantize-position <bits>', 'Precision for POSITION attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizePosition,
})
.option('--quantize-normal <bits>', 'Precision for NORMAL and TANGENT attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizeNormal,
})
.option('--quantize-texcoord <bits>', 'Precision for TEXCOORD_* attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizeTexcoord,
})
.option('--quantize-color <bits>', 'Precision for COLOR_* attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizeColor,
})
.option('--quantize-weight <bits>', 'Precision for WEIGHTS_* attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizeWeight,
})
.option('--quantize-generic <bits>', 'Precision for custom (_*) attributes.', {
validator: Validator.NUMBER,
default: MESHOPT_DEFAULTS.quantizeGeneric,
})
.option('--quantization-volume <volume>', 'Bounds for quantization grid.', {
validator: ['mesh', 'scene'],
default: QUANTIZE_DEFAULTS.quantizationVolume,
})
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(meshopt({ encoder: MeshoptEncoder, ...options })),
);
// QUANTIZE
program
.command('quantize', 'Quantize geometry, reducing precision and memory')
.help(
`
Quantization is a simple type of compression taking 32-bit float vertex
attributes and storing them as 16-bit or 8-bit integers. A quantization factor
restoring the original value (with some error) is applied on the GPU, although
node scales and positions may also be changed to account for the quantization.
Quantized vertex attributes require less space, both on disk and on the GPU.
Most vertex attribute types can be quantized from 8–16 bits, but are always
stored in 8- or 16-bit accessors. While a value quantized to 12 bits still
occupies 16 bits on disk, gzip (or other lossless compression) will be more
effective on values quantized to lower bit depths. As a result, the default
bit depths used by this command are generally between 8 and 16 bits.
Bit depths for indices and JOINTS_* are determined automatically.
Requires KHR_mesh_quantization support.`.trim(),
)
.argument('<input>', 'Path to read glTF 2.0 (.glb, .gltf) input')
.argument('<output>', 'Path to write output')
.option('--pattern <pattern>', 'Pattern for vertex attributes (case-insensitive glob)', {
validator: Validator.STRING,
default: '*',
})
.option('--quantize-position <bits>', 'Precision for POSITION attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizePosition,
})
.option('--quantize-normal <bits>', 'Precision for NORMAL and TANGENT attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizeNormal,
})
.option('--quantize-texcoord <bits>', 'Precision for TEXCOORD_* attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizeTexcoord,
})
.option('--quantize-color <bits>', 'Precision for COLOR_* attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizeColor,
})
.option('--quantize-weight <bits>', 'Precision for WEIGHTS_* attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizeWeight,
})
.option('--quantize-generic <bits>', 'Precision for custom (_*) attributes.', {
validator: Validator.NUMBER,
default: QUANTIZE_DEFAULTS.quantizeGeneric,
})
.option('--quantization-volume <volume>', 'Bounds for quantization grid.', {
validator: ['mesh', 'scene'],
default: QUANTIZE_DEFAULTS.quantizationVolume,
})
.action(({ args, options, logger }) => {
const pattern = micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS);
return Session.create(io, logger, args.input, args.output).transform(quantize({ ...options, pattern }));
});
// DEQUANTIZE
program
.command('dequantize', 'Dequantize geometry')
.help(
`
Removes quantization from an asset. This will increase the size of the asset on
disk and in memory, but may be necessary for applications that don't support
quantization.
Removes KHR_mesh_quantization, if present.`.trim(),
)
.argument('<input>', 'Path to read glTF 2.0 (.glb, .gltf) input')
.argument('<output>', 'Path to write output')
.option('--pattern <pattern>', 'Pattern for vertex attributes (case-insensitive glob)', {
validator: Validator.STRING,
default: '!JOINTS_*',
})
.action(({ args, options, logger }) => {
const pattern = micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS);
return Session.create(io, logger, args.input, args.output).transform(dequantize({ ...options, pattern }));
});
// WELD
program
.command('weld', 'Merge equivalent vertices to optimize geometry')
.help(
`
Welds mesh geometry, merging bitwise identical vertices. When merged and
indexed, data is shared more efficiently between vertices. File size
can be reduced, and the GPU uses the vertex cache more efficiently.
`.trim(),
)
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.action(({ args, options, logger }) =>
Session.create(io, logger, args.input, args.output).transform(weld(options as unknown as WeldOptions)),
);
// UNWELD
program
.command('unweld', 'De-index geometry, disconnecting any shared vertices')
.help(
`
De-index geometry, disconnecting any shared vertices. This tends to increase