import { readFile, writeFile } from 'node:fs/promises'; import { styleText } from 'node:util'; import { type Logger, NodeIO, PropertyType, type Transform, VertexLayout, type vec2 } from '@gltf-transform/core'; import { type CenterOptions, center, DRACO_DEFAULTS, type DracoOptions, dedup, dequantize, draco, type FlattenOptions, flatten, INSTANCE_DEFAULTS, type InstanceOptions, instance, JOIN_DEFAULTS, type JoinOptions, join, MESHOPT_DEFAULTS, meshopt, metalRough, PALETTE_DEFAULTS, type PaletteOptions, type PartitionOptions, PRUNE_DEFAULTS, type PruneOptions, palette, partition, prune, QUANTIZE_DEFAULTS, quantize, type ResampleOptions, reorder, resample, type SequenceOptions, SIMPLIFY_DEFAULTS, type SparseOptions, sequence, simplify, sparse, TEXTURE_COMPRESS_SUPPORTED_FORMATS, TextureResizeFilter, tangents, textureCompress, UNWRAP_DEFAULTS, type UnweldOptions, unlit, unweld, unwrap, type WeldOptions, weld, } from '@gltf-transform/functions'; import { ready as resampleReady, resample as resampleWASM } from 'keyframe-resample'; import { MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer'; import micromatch from 'micromatch'; import mikktspace from 'mikktspace'; import * as watlas from 'watlas'; import { getConfig, loadConfig } from './config.js'; import { inspect } from './inspect.js'; import { program, Validator } from './program.js'; import { Session } from './session.js'; import { ETC1S_DEFAULTS, Filter, ktxdecompress, ktxfix, Mode, merge, toktx, UASTC_DEFAULTS, type XMPOptions, xmp, } from './transforms/index.js'; import { formatBytes, TableFormat } from './utils/format.js'; import { gzip } from './utils/gzip.js'; import { MICROMATCH_OPTIONS, regexFromArray } from './utils/match.js'; import { type ValidateOptions, validate } from './validate.js'; let io: NodeIO; const programReady: Promise = new Promise((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(global.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'; program .version(import.meta.env.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 --compress draco --texture-compress webp Defaults in the 'optimize' command may not be ideal for all scenes. Some of its features can be configured (${styleText('dim', `optimize --help`)}), or more advanced users may wish to inspect their scenes then pick and choose optimizations. ā–ø gltf-transform inspect 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(), ); } 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_DESC) .option('--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_DESC) .option('--limit ', 'Limit number of issues to display', { validator: Validator.NUMBER, default: 1e7, }) .option('--ignore ,,...', 'Issue codes to be ignored', { validator: Validator.ARRAY, default: [], }) .option('--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 to 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_DESC) .argument('', 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_DESC) .argument('', OUTPUT_DESC) .option('--instance ', 'Use GPU instancing with shared mesh references.', { validator: Validator.BOOLEAN, default: true, }) .option('--instance-min ', 'Number of instances required for instancing.', { validator: Validator.NUMBER, default: INSTANCE_DEFAULTS.min, }) .option('--meshopt-level ', 'Meshopt compression level.', { validator: ['medium', 'high'], default: 'high', }) .option('--palette ', 'Creates palette textures and merges materials.', { validator: Validator.BOOLEAN, default: true, }) .option( '--palette-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 ', 'Simplify mesh geometry with meshoptimizer.', { validator: Validator.BOOLEAN, default: true, }) .option('--simplify-error ', 'Simplification error tolerance, as a fraction of mesh extent.', { validator: Validator.NUMBER, default: SIMPLIFY_DEFAULTS.error, }) .option('--simplify-ratio ', 'Target ratio (0–1) of vertices to keep.', { validator: Validator.NUMBER, default: SIMPLIFY_DEFAULTS.ratio, }) .option('--simplify-lock-border ', 'Whether to lock topological borders of the mesh.', { validator: Validator.BOOLEAN, default: SIMPLIFY_DEFAULTS.lockBorder, }) .option('--prune ', 'Removes properties from the file if they are not referenced by a Scene.', { validator: Validator.BOOLEAN, default: true, }) .option('--prune-attributes ', 'Whether to prune unused vertex attributes.', { validator: Validator.BOOLEAN, default: true, }) .option( '--prune-solid-textures ', 'Whether to prune solid (single-color) textures, converting them to material factors.', { validator: Validator.BOOLEAN, default: true, }, ) .option( '--sparse ', 'Reduces storage for zero-filled arrays. If disabled, existing accessors (sparse or not) ' + 'are left unchanged.', { validator: Validator.BOOLEAN, default: true, }, ) .option( '--compress ', 'Floating point compression method. Draco compresses geometry; Meshopt ' + 'and quantization compress geometry and animation.', { validator: ['draco', 'meshopt', 'quantize', false], default: 'meshopt', }, ) .option( '--texture-compress ', '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 ', 'Maximum texture dimensions, in pixels.', { validator: Validator.NUMBER, default: 2048, }) .option('--flatten ', 'Flatten scene graph.', { validator: Validator.BOOLEAN, default: true, }) .option('--join ', 'Join meshes and reduce draw calls. Requires `--flatten`.', { validator: Validator.BOOLEAN, default: true, }) .option('--join-meshes ', 'Join distinct meshes and nodes. Requires `--join`.', { validator: Validator.BOOLEAN, default: !JOIN_DEFAULTS.keepMeshes, }) .option('--join-named ', 'Join named meshes and nodes. Requires `--join`.', { validator: Validator.BOOLEAN, default: !JOIN_DEFAULTS.keepNamed, }) .option('--weld ', 'Merge equivalent vertices. Required when simplifying geometry.', { validator: Validator.BOOLEAN, default: true, }) .option('--resample ', 'Resample animations, losslessly deduplicating keyframes', { 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; pruneSolidTextures: boolean; sparse: boolean; compress: 'draco' | 'meshopt' | 'quantize' | false; textureCompress: 'ktx2' | 'webp' | 'webp' | 'auto' | false; textureSize: number; flatten: boolean; resample: boolean; join: boolean; joinNamed: boolean; joinMeshes: 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 })); } if (opts.flatten) transforms.push(flatten()); if (opts.join) { transforms.push( join({ keepNamed: !opts.joinNamed, keepMeshes: !opts.joinMeshes, }), ); } if (opts.weld) transforms.push(weld()); if (opts.simplify) { transforms.push( simplify({ simplifier: MeshoptSimplifier, error: opts.simplifyError, ratio: opts.simplifyRatio, lockBorder: opts.simplifyLockBorder, }), ); } if (opts.resample) transforms.push(resample({ ready: resampleReady, resample: resampleWASM })); if (opts.prune) { transforms.push( prune({ keepAttributes: !opts.pruneAttributes, keepIndices: false, keepLeaves: false, keepSolidTextures: !opts.pruneSolidTextures, }), ); } if (opts.sparse) 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') { if (opts.weld === false) { logger.warn('Ignoring --no-weld, required for Draco compression.'); } 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('', `${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_DESC) .argument('', 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_DESC) .argument('', OUTPUT_DESC) .option('--accessors ', 'Remove duplicate accessors', { validator: Validator.BOOLEAN, default: true, }) .option('--materials ', 'Remove duplicate materials', { validator: Validator.BOOLEAN, default: true, }) .option('--meshes ', 'Remove duplicate meshes', { validator: Validator.BOOLEAN, default: true, }) .option('--skins ', 'Remove duplicate skins', { validator: Validator.BOOLEAN, default: true, }) .option('--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_DESC) .argument('', OUTPUT_DESC) .option('--keep-attributes ', 'Whether to keep unused vertex attributes', { validator: Validator.BOOLEAN, default: PRUNE_DEFAULTS.keepAttributes, }) .option('--keep-indices ', 'Whether to keep unused mesh indices', { validator: Validator.BOOLEAN, default: PRUNE_DEFAULTS.keepIndices, }) .option('--keep-leaves ', 'Whether to keep empty leaf nodes', { validator: Validator.BOOLEAN, default: PRUNE_DEFAULTS.keepLeaves, }) .option( '--keep-solid-textures ', '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_DESC) .action(async ({ args, logger }) => { const inBuffer = await 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 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. ${styleText('underline', 'Documentation')} - https://gltf-transform.dev/classes/extensions.xmp.html `, ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--packet ', '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_DESC) .argument('', OUTPUT_DESC) .option('--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_DESC) .argument('', OUTPUT_DESC) .option( '--min ', '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_DESC) .argument('', 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_DESC) .argument('', OUTPUT_DESC) .option('--keepMeshes ', 'Prevents joining distinct meshes and nodes.', { validator: Validator.BOOLEAN, default: JOIN_DEFAULTS.keepMeshes, }) .option('--keepNamed ', '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) ${styleText('underline', 'Documentation')} - https://gltf-transform.dev/classes/extensions.dracomeshcompression.html ${styleText('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_DESC) .argument('', OUTPUT_DESC) .option('--method ', 'Compression method.', { validator: ['edgebreaker', 'sequential'], default: 'edgebreaker', }) .option('--encode-speed ', 'Encoding speed vs. compression level, 0–10.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.encodeSpeed, }) .option('--decode-speed ', 'Decoding speed vs. compression level, 0–10.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.decodeSpeed, }) .option('--quantize-position ', 'Quantization bits for POSITION, 1-16.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.quantizePosition, }) .option('--quantize-normal ', 'Quantization bits for NORMAL, 1-16.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.quantizeNormal, }) .option('--quantize-color ', 'Quantization bits for COLOR_*, 1-16.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.quantizeColor, }) .option('--quantize-texcoord ', 'Quantization bits for TEXCOORD_*, 1-16.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.quantizeTexcoord, }) .option('--quantize-generic ', 'Quantization bits for other attributes, 1-16.', { validator: Validator.NUMBER, default: DRACO_DEFAULTS.quantizeGeneric, }) .option('--quantization-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 ${styleText('underline', 'Documentation')} - https://gltf-transform.dev/classes/extensions.meshoptcompression.html ${styleText('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_DESC) .argument('', OUTPUT_DESC) .option('--level ', 'Compression level.', { validator: ['medium', 'high'], default: 'high', }) .option('--quantize-position ', 'Precision for POSITION attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizePosition, }) .option('--quantize-normal ', 'Precision for NORMAL and TANGENT attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizeNormal, }) .option('--quantize-texcoord ', 'Precision for TEXCOORD_* attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizeTexcoord, }) .option('--quantize-color ', 'Precision for COLOR_* attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizeColor, }) .option('--quantize-weight ', 'Precision for WEIGHTS_* attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizeWeight, }) .option('--quantize-generic ', 'Precision for custom (_*) attributes.', { validator: Validator.NUMBER, default: MESHOPT_DEFAULTS.quantizeGeneric, }) .option('--quantization-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('', 'Path to read glTF 2.0 (.glb, .gltf) input') .argument('', 'Path to write output') .option('--pattern ', 'Pattern for vertex attributes (case-insensitive glob)', { validator: Validator.STRING, default: '*', }) .option('--quantize-position ', 'Precision for POSITION attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizePosition, }) .option('--quantize-normal ', 'Precision for NORMAL and TANGENT attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizeNormal, }) .option('--quantize-texcoord ', 'Precision for TEXCOORD_* attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizeTexcoord, }) .option('--quantize-color ', 'Precision for COLOR_* attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizeColor, }) .option('--quantize-weight ', 'Precision for WEIGHTS_* attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizeWeight, }) .option('--quantize-generic ', 'Precision for custom (_*) attributes.', { validator: Validator.NUMBER, default: QUANTIZE_DEFAULTS.quantizeGeneric, }) .option('--quantization-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('', 'Path to read glTF 2.0 (.glb, .gltf) input') .argument('', 'Path to write output') .option('--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_DESC) .argument('', 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 the file size of the geometry and decrease efficiency, and so is not recommended unless disconnected vertices ("vertex soup") are required for some particular software application. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform(unweld(options as unknown as UnweldOptions)), ); // TANGENTS program .command('tangents', 'Generate MikkTSpace vertex tangents') .help( ` Generates MikkTSpace vertex tangents. In some situations normal maps may appear incorrectly, displaying hard edges at seams, or unexpectedly inverted insets and extrusions. The issue is most commonly caused by a mismatch between the software used to bake the normal map and the pixel shader or other code used to render it. While this may be a frustration to an artist/designer, it is not always possible for the rendering engine to reconstruct the tangent space used by the authoring software. Most normal map bakers use the MikkTSpace standard (http://www.mikktspace.com/) to generate vertex tangents while creating a normal map, and the technique is recommended by the glTF 2.0 specification. Generating vertex tangents with this tool may resolve rendering issues related to normal maps in engines that cannot compute MikkTSpace tangents at runtime. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--overwrite', 'Overwrite existing vertex tangents', { validator: Validator.BOOLEAN, default: false, }) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform( unweld(), tangents({ generateTangents: mikktspace.generateTangents, ...options }), weld(), ), ); // UNWRAP program .command('unwrap', 'Generate texcoords') .help( ` Generates texture coordinates for the given attribute set index. Uses xatlas (https://github.com/jpcy/xatlas) to generate unique texture coordinates suitable for baking lightmaps or texture painting. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--texcoord ', 'Target texture coordinate index. 0 = TEXCOORD_0, etc.', { validator: Validator.NUMBER, default: 0, }) .option('--overwrite', 'Overwrite existing vertex tangents', { validator: Validator.BOOLEAN, default: false, }) .option('--group-by ', 'Grouping of texture coordinates for generated atlases', { validator: ['primitive', 'mesh', 'scene'], default: UNWRAP_DEFAULTS.groupBy, }) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform(unwrap({ watlas, ...options })), ); // REORDER program .command('reorder', 'Optimize vertex data for locality of reference') .help( ` Optimize vertex data for locality of reference. Choose whether the order should be optimal for transmission size (recommended for Web) or for GPU rendering performance. When optimizing for transmission size, reordering is expected to be a pre- processing step before applying Meshopt compression and lossless supercompression (such as gzip or brotli). Reordering will only reduce size when used in combination with other compression methods. Based on the meshoptimizer library (https://github.com/zeux/meshoptimizer). `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--target', 'Whether to optimize for transmission size or GPU performance', { validator: ['size', 'performance'], default: 'size', }) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform(reorder({ encoder: MeshoptEncoder, ...options })), ); // SIMPLIFY program .command('simplify', 'Simplify mesh, reducing number of vertices') .help( ` Simplify mesh, reducing number of vertices. Simplification algorithm producing meshes with fewer triangles and vertices. Simplification is lossy, but the algorithm aims to preserve visual quality as much as possible, for given parameters. The algorithm aims to reach the target --ratio, while minimizing error. If error exceeds the specified --error threshold, the algorithm will quit before reaching the target ratio. Examples: - ratio=0.5, error=0.001: Aims for 50% simplification, constrained to 0.1% error. - ratio=0.5, error=1: Aims for 50% simplification, unconstrained by error. - ratio=0.0, error=0.01: Aims for maximum simplification, constrained to 1% error. Topology, particularly split vertices, will also limit the simplifier. For best results, apply a 'weld' operation before simplification. Based on the meshoptimizer library (https://github.com/zeux/meshoptimizer). `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--ratio ', 'Target ratio (0–1) of vertices to keep', { validator: Validator.NUMBER, default: SIMPLIFY_DEFAULTS.ratio, }) .option('--error ', 'Limit on error, as a fraction of mesh radius', { validator: Validator.NUMBER, default: SIMPLIFY_DEFAULTS.error, }) .option('--lock-border ', 'Whether to lock topological borders of the mesh', { validator: Validator.BOOLEAN, default: SIMPLIFY_DEFAULTS.lockBorder, }) .action(async ({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform( simplify({ simplifier: MeshoptSimplifier, ...options }), ), ); program.section('Material', 'šŸŽØ'); // METALROUGH program .command('metalrough', 'Convert materials from spec/gloss to metal/rough') .help( ` Convert materials from spec/gloss to metal/rough. In general, the metal/rough workflow is better supported, more compact, and more future-proof. All features of the spec/gloss workflow can be converted to metal/rough, as long as the KHR_materials_specular and KHR_materials_ior extensions are supported. When one or both of those extensions are not supported, metallic materials may require further adjustments after the conversion. This conversion rewrites spec/gloss textures, and the resulting textures may have less optimal compression than the original. Ideally, lossless PNG textures should be used as input, and then compressed after this conversion. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, logger }) => Session.create(io, logger, args.input, args.output).transform(metalRough())); // PALETTE program .command('palette', 'Creates palette textures and merges materials') .help( ` Creates palette textures containing all unique values of scalar Material properties within the scene, then merges materials. For scenes with many solid-colored materials (often found in CAD, architectural, or low-poly styles), texture palettes can reduce the number of materials used, and significantly increase the number of Mesh objects eligible for "join" operations. Materials already containing texture coordinates (UVs) are not eligible for texture palette optimizations. Currently only a material's base color, alpha, emissive factor, metallic factor, and roughness factor are converted to palette textures. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--block-size ', 'Size (in pixels) of a single block within each palette texture.', { validator: Validator.NUMBER, default: PALETTE_DEFAULTS.blockSize, }) .option( '--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, }, ) .action(async ({ args, options, logger }) => { return Session.create(io, logger, args.input, args.output).transform( palette(options as unknown as PaletteOptions), ); }); // UNLIT program .command('unlit', 'Convert materials from metal/rough to unlit') .help( ` Convert materials to an unlit, shadeless model. Unlit materials are not affected by scene lighting, and can be rendered very efficiently on less capable mobile devices. If device framerate is high when an object occupies a small part of the viewport, and low when that object fills the viewport, it's likely that the GPU is fragment shader bound, and a simpler material (such as an unlit material) may improve performance. Unlit materials are also helpful for non-physically-based visual styles. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, logger }) => Session.create(io, logger, args.input, args.output).transform(unlit())); program.section('Texture', 'šŸ–¼'); // RESIZE program .command('resize', 'Resize PNG or JPEG textures') .help( ` Resize PNG or JPEG textures with Lanczos3 (sharp) or Lanczos2 (smooth) filtering. Typically Lanczos3 is the best method, but Lanczos2 may be helpful to reduce ringing artifacts in some cases. Limits --width and --height are applied as maximum dimensions for each texture, preserving original aspect ratio. Texture dimensions are never increased. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--filter', 'Resampling filter', { validator: [TextureResizeFilter.LANCZOS3, TextureResizeFilter.LANCZOS2], default: TextureResizeFilter.LANCZOS3, }) .option('--width ', 'Maximum width (px) of output textures.', { validator: Validator.NUMBER, }) .option('--height ', 'Maximum height (px) of output textures.', { validator: Validator.NUMBER, }) .option('--power-of-two ', 'Resize in two dimensions. Overrides --width and --height.', { validator: ['nearest', 'ceil', 'floor'], }) .action(async ({ args, options, logger }) => { const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const { default: encoder } = await import('sharp'); type Preset = 'nearest-pot' | 'ceil-pot' | 'floor-pot'; let resize: vec2 | Preset; if (options.powerOfTwo) { if (options.width || options.height) { logger.warn('When --power-of-two is specified, --width and --height are ignored.'); } resize = `${options.powerOfTwo}-pot` as Preset; } else if (Number.isInteger(options.width) && Number.isInteger(options.height)) { resize = [Number(options.width), Number(options.height)]; } else { throw new Error(`Must specify --width and --height, or --power-of-two.`); } return Session.create(io, logger, args.input, args.output).transform( textureCompress({ encoder, resize, resizeFilter: options.filter as TextureResizeFilter, pattern, }), ); }); const BASIS_SUMMARY = ` Compresses textures in the given file to .ktx2 GPU textures using the {VARIANT} Basis Universal bitstream. GPU textures offer faster GPU upload and less GPU memory consumption than traditional PNG or JPEG textures, which are fully uncompressed in GPU memory. GPU texture formats require more attention to compression settings to get similar visual results. {DETAILS} ${styleText('underline', 'Documentation')} https://gltf-transform.dev/extensions.html#khr_texture_basisu ${styleText('underline', 'Dependencies')} KTX-Software (https://github.com/KhronosGroup/KTX-Software/) `; // ETC1S program .command('etc1s', 'KTX + Basis ETC1S texture compression') .help( BASIS_SUMMARY.replace('{VARIANT}', 'ETC1S').replace( '{DETAILS}', ` ETC1S, one of the two Basis Universal bitstreams, offers lower size and lower quality than UASTC. In some cases it may be useful to increase the resolution of the texture, to minimize compression artifacts while still retaining an overall smaller filesize. Consider using less aggressive compression settings for normal maps than for other texture types: you may want to use UASTC for normal maps and ETC1S for other textures, for example.`.trim(), ), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING }) .option('--resize ', 'Maximum texture dimensions, in pixels.', { validator: Validator.NUMBER, }) .option('--mipmaps ', 'Generate mipmaps.', { validator: Validator.BOOLEAN, default: ETC1S_DEFAULTS.mipmaps, }) .option('--filter ', 'Specifies the filter to use when generating mipmaps.', { validator: Object.values(Filter), default: ETC1S_DEFAULTS.filter, }) .option('--filter-scale ', 'Specifies the filter scale to use when generating mipmaps.', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.filterScale, }) .option( '--compression ', 'Compression level, an encoding speed vs. quality tradeoff.' + ' Higher values are slower, but give higher quality. Try' + ' --quality before experimenting with this option.', { validator: [0, 1, 2, 3, 4, 5], default: ETC1S_DEFAULTS.compression }, ) .option( '--quality ', 'Quality level. Range is 1 - 255. Lower gives better' + ' compression, lower quality, and faster encoding. Higher gives less compression,' + ' higher quality, and slower encoding. Quality level determines values of' + ' --max_endpoints and --max-selectors, unless those values are explicitly set.', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.quality }, ) .option( '--max-endpoints ', 'Manually set the maximum number of color endpoint clusters from' + ' 1-16128.', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.maxEndpoints }, ) .option( '--max-selectors ', 'Manually set the maximum number of color selector clusters from' + ' 1-16128.', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.maxSelectors }, ) .option( '--rdo-threshold ', 'Set endpoint and selector RDO quality threshold. Lower' + ' is higher quality but less quality per output bit (try 1.0-3.0).' + ' Overrides --quality.', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.rdoThreshold }, ) .option( '--rdo', 'Enable endpoint and selector RDO (slightly' + ' faster, less noisy output, but lower quality per output bit).', { validator: Validator.BOOLEAN, default: ETC1S_DEFAULTS.rdo }, ) .option('--jobs ', 'Spawns up to num_jobs instances of toktx', { validator: Validator.NUMBER, default: ETC1S_DEFAULTS.jobs, }) .action(async ({ args, options, logger }) => { const { default: encoder } = await import('sharp'); const mode = Mode.ETC1S; const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const slots = options.slots ? micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS) : null; const resize: vec2 | undefined = options.resize ? [Number(options.resize), Number(options.resize)] : undefined; return Session.create(io, logger, args.input, args.output).transform( toktx({ ...options, encoder, mode, pattern, slots, resize }), ); }); // UASTC program .command('uastc', 'KTX + Basis UASTC texture compression') .help( BASIS_SUMMARY.replace('{VARIANT}', 'UASTC').replace( '{DETAILS}', ` UASTC, one of the two Basis Universal bitstreams, offers higher size and higher quality than ETC1S. While it is suitable for all texture types, you may find it useful to apply UASTC only where higher quality is necessary, and apply ETC1S for textures where the quality is sufficient.`.trim(), ), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING }) .option('--resize ', 'Maximum texture dimensions, in pixels.', { validator: Validator.NUMBER, }) .option('--mipmaps ', 'Generate mipmaps.', { validator: Validator.BOOLEAN, default: UASTC_DEFAULTS.mipmaps, }) .option('--filter ', 'Specifies the filter to use when generating mipmaps.', { validator: Object.values(Filter), default: UASTC_DEFAULTS.filter, }) .option('--filter-scale ', 'Specifies the filter scale to use when generating mipmaps.', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.filterScale, }) .option( '--level ', 'Create a texture in high-quality transcodable UASTC format.' + ' The optional parameter selects a speed' + ' vs quality tradeoff as shown in the following table:' + '\n\n' + 'Level | Speed | Quality' + '\n——————|———————————|————————' + '\n0 | Fastest | 43.45dB' + '\n1 | Faster | 46.49dB' + '\n2 | Default | 47.47dB' + '\n3 | Slower | 48.01dB' + '\n4 | Very slow | 48.24dB', { validator: [0, 1, 2, 3, 4], default: UASTC_DEFAULTS.level }, ) .option('--rdo', 'Enable UASTC RDO post-processing.', { validator: Validator.BOOLEAN, default: UASTC_DEFAULTS.rdo }) .option( '--rdo-lambda ', 'Set UASTC RDO quality scalar (lambda). Lower values yield higher' + ' quality/larger LZ compressed files, higher values yield lower' + ' quality/smaller LZ compressed files. A good range to try is [.25, 10].' + ' For normal maps, try [.25, .75]. Full range is [.001, 10.0].', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.rdoLambda }, ) .option( '--rdo-dictionary-size ', 'Set UASTC RDO dictionary size in bytes. Default is 32768. Lower' + ' values=faster, but give less compression. Possible range is [256, 65536].', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.rdoDictionarySize }, ) .option( '--rdo-block-scale ', 'Set UASTC RDO max smooth block error scale. Range is [1.0, 300.0].' + ' Default is 10.0, 1.0 is disabled. Larger values suppress more' + ' artifacts (and allocate more bits) on smooth blocks.', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.rdoBlockScale }, ) .option( '--rdo-std-dev ', 'Set UASTC RDO max smooth block standard deviation. Range is' + ' [.01, 65536.0]. Default is 18.0. Larger values expand the range' + ' of blocks considered smooth.', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.rdoStdDev }, ) .option( '--rdo-multithreading ', 'Enable RDO multithreading (slightly lower compression, non-deterministic).', { validator: Validator.BOOLEAN, default: UASTC_DEFAULTS.rdoMultithreading }, ) .option( '--zstd ', 'Supercompress the data with Zstandard.' + ' Compression level range is [1, 22], or 0 is uncompressed.' + ' Lower values decode faster but offer less compression. Values above' + ' 20 should be used with caution, requiring more memory to decompress:' + '\n\n' + // Sources: // - https://news.ycombinator.com/item?id=13814475 // - https://github.com/facebook/zstd/blob/15a7a99653c78a57d1ccbf5c5b4571e62183bf4f/lib/compress/zstd_compress.c#L3250 'Level | Window Size ' + '\n——————|—————————————' + '\n1 | 256 KB ' + '\n… | … ' + '\n10 | 2 MB ' + '\n… | … ' + '\n18 | 8 MB ' + '\n19 | 8 MB ' + '\n20 | 34 MB ' + '\n21 | 67 MB ' + '\n22 | 134 MB ', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.zstd }, ) .option('--jobs ', 'Spawns up to num_jobs instances of toktx', { validator: Validator.NUMBER, default: UASTC_DEFAULTS.jobs, }) .action(async ({ args, options, logger }) => { const { default: encoder } = await import('sharp'); const mode = Mode.UASTC; const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const slots = options.slots ? micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS) : null; const resize: vec2 | undefined = options.resize ? [Number(options.resize), Number(options.resize)] : undefined; Session.create(io, logger, args.input, args.output).transform( toktx({ ...options, encoder, mode, pattern, slots, resize }), ); }); // KTXDECOMPRESS program .command('ktxdecompress', 'KTX + Basis texture decompression') .help( ` Decompresses KTX2 textures in KTX2 format, converting to PNG. Intended for debugging, or to resolve compatibility issues in software that doesn't support KTX2 textures.`.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, logger }) => Session.create(io, logger, args.input, args.output).transform(ktxdecompress())); // KTXFIX program .command('ktxfix', 'Fixes common issues in KTX texture metadata') .help( ` Certain KTX texture metadata was written incorrectly in early (pre-release) software. In particular, viewers may misinterpret color primaries as sRGB incorrectly when a texture exhibits this issue. This command determines correct color primaries based on usage in the glTF file, and updates the KTX texture accordingly. The change is lossless, and affects only the container metadata.`.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, logger }) => Session.create(io, logger, args.input, args.output).transform(ktxfix())); const TEXTURE_COMPRESS_SUMMARY = ` Compresses textures with {VARIANT}, using sharp. Reduces transmitted file size. Compared to GPU texture compression like KTX/Basis, PNG/JPEG/WebP must be fully decompressed in GPU memory — this makes texture GPU upload much slower, and may consume 4-8x more GPU memory. However, the PNG/JPEG/WebP compression methods are typically more forgiving than GPU texture compression, and require less tuning to achieve good visual and filesize results. `.trim(); // AVIF // IMPORTANT: No defaults for quality flags, see https://github.com/donmccurdy/glTF-Transform/issues/969. program .command('avif', 'AVIF texture compression') .help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'AVIF')) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--formats ', 'Texture formats to include', { validator: [...TEXTURE_COMPRESS_SUPPORTED_FORMATS, '*'], default: '*', }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING, default: '*' }) .option('--quality ', 'Quality, 1-100', { validator: Validator.NUMBER }) .option('--effort ', 'Level of CPU effort to reduce file size, 0-100', { validator: Validator.NUMBER }) .option('--lossless ', 'Use lossless compression mode', { validator: Validator.BOOLEAN, default: false }) .action(async ({ args, options, logger }) => { const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const formats = regexFromArray([options.formats] as string[]); const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS); const { default: encoder } = await import('sharp'); return Session.create(io, logger, args.input, args.output).transform( textureCompress({ targetFormat: 'avif', encoder, pattern, formats, slots, quality: options.quality as number, effort: options.effort as number, lossless: options.lossless as boolean, limitInputPixels: options.limitInputPixels as boolean, }), ); }); // WEBP // IMPORTANT: No defaults for quality flags, see https://github.com/donmccurdy/glTF-Transform/issues/969. program .command('webp', 'WebP texture compression') .help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'WebP')) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--formats ', 'Texture formats to include', { validator: [...TEXTURE_COMPRESS_SUPPORTED_FORMATS, '*'], default: '*', }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING, default: '*' }) .option('--quality ', 'Quality, 1-100', { validator: Validator.NUMBER }) .option('--effort ', 'Level of CPU effort to reduce file size, 0-100', { validator: Validator.NUMBER }) .option('--lossless ', 'Use lossless compression mode', { validator: Validator.BOOLEAN, default: false }) .option('--near-lossless ', 'Use near lossless compression mode.', { validator: Validator.BOOLEAN, default: false, }) .action(async ({ args, options, logger }) => { const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const formats = regexFromArray([options.formats] as string[]); const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS); const { default: encoder } = await import('sharp'); return Session.create(io, logger, args.input, args.output).transform( textureCompress({ targetFormat: 'webp', encoder, pattern, formats, slots, quality: options.quality as number, effort: options.effort as number, lossless: options.lossless as boolean, nearLossless: options.nearLossless as boolean, limitInputPixels: options.limitInputPixels as boolean, }), ); }); // PNG // IMPORTANT: No defaults for quality flags, see https://github.com/donmccurdy/glTF-Transform/issues/969. program .command('png', 'PNG texture compression') .help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'PNG')) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--formats ', 'Texture formats to include', { validator: [...TEXTURE_COMPRESS_SUPPORTED_FORMATS, '*'], default: 'png', }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING, default: '*' }) .option('--quality ', 'Quality, 1-100', { validator: Validator.NUMBER }) .option('--effort ', 'Level of CPU effort to reduce file size, 0-100', { validator: Validator.NUMBER }) .action(async ({ args, options, logger }) => { const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const formats = regexFromArray([options.formats] as string[]); const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS); const { default: encoder } = await import('sharp'); return Session.create(io, logger, args.input, args.output).transform( textureCompress({ targetFormat: 'png', encoder, pattern, formats, slots, quality: options.quality as number, effort: options.effort as number, limitInputPixels: options.limitInputPixels as boolean, }), ); }); // JPEG // IMPORTANT: No defaults for quality flags, see https://github.com/donmccurdy/glTF-Transform/issues/969. program .command('jpeg', 'JPEG texture compression') .help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'JPEG')) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--pattern ', 'Pattern (glob) to match textures, by name or URI.', { validator: Validator.STRING, }) .option('--formats ', 'Texture formats to include', { validator: [...TEXTURE_COMPRESS_SUPPORTED_FORMATS, '*'], default: 'jpeg', }) .option('--slots ', 'Texture slots to include (glob)', { validator: Validator.STRING, default: '*' }) .option('--quality ', 'Quality, 1-100', { validator: Validator.NUMBER }) .action(async ({ args, options, logger }) => { const pattern = options.pattern ? micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS) : null; const formats = regexFromArray([options.formats] as string[]); const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS); const { default: encoder } = await import('sharp'); return Session.create(io, logger, args.input, args.output).transform( textureCompress({ targetFormat: 'jpeg', encoder, pattern, formats, slots, quality: options.quality as number, limitInputPixels: options.limitInputPixels as boolean, }), ); }); program.section('Animation', 'āÆļø'); // RESAMPLE program .command('resample', 'Resample animations, losslessly deduplicating keyframes') .help( ` Resample animations, losslessly deduplicating keyframes. Exporters sometimes need to "bake" animations, writing data for 20-30 frames per second, in order to correctly represent IK constraints and other animation techniques. These additional keyframes are often redundant — particularly with morph targets — as engines can interpolate animation at 60–120 FPS even with sparse keyframes. The resampling process removes redundant keyframes from animations using STEP and LINEAR interpolation. Resampling is nearly lossless, with configurable --tolerance, and should have no visible effect on animation playback. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--tolerance', 'Per-value tolerance to merge similar keyframes', { validator: Validator.NUMBER, default: 1e-4, }) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform( resample({ ready: resampleReady, resample: resampleWASM, ...(options as unknown as ResampleOptions), }), ), ); // SEQUENCE program .command('sequence', 'Animate node visibilities as a flipboard sequence') .help( ` Animate node visibilities as a flipboard sequence. An example workflow would be to create a .glb containing one geometry for each frame of a complex animation that can't be represented as TRS, skinning, or morph targets. The sequence function generates a new animation, playing back each mesh matching the given pattern, at a specific framerate. Displaying a sequence of textures is also supported, but note that texture memory usage may be quite high and so this workflow is not a replacement for video playback. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .option('--name ', 'Name of new animation', { validator: Validator.STRING, default: '', }) .option('--pattern ', 'Pattern for node names (case-insensitive glob)', { validator: Validator.STRING, required: true, }) .option('--fps ', 'FPS (frames / second)', { validator: Validator.NUMBER, default: 10, }) .option('--sort ', 'Order sequence by node name', { validator: Validator.BOOLEAN, default: true, }) .action(({ args, options, logger }) => { const pattern = micromatch.makeRe(String(options.pattern), MICROMATCH_OPTIONS); return Session.create(io, logger, args.input, args.output).transform( sequence({ ...options, pattern } as SequenceOptions), ); }); // SPARSE program .command('sparse', 'Reduces storage for zero-filled arrays') .help( ` Scans all Accessors in the Document, detecting whether each Accessor would benefit from sparse data storage. Currently, sparse data storage is used only when many values (>= 1/3) are zeroes. Particularly for assets using morph target ("shape key") animation, sparse data storage may significantly reduce file sizes. `.trim(), ) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) .action(({ args, options, logger }) => Session.create(io, logger, args.input, args.output).transform(sparse(options as unknown as SparseOptions)), ); program.option('--allow-net', 'Allows reads from network requests.', { default: false, validator: Validator.BOOLEAN, action: ({ options }) => { if (options.allowNet) io.setAllowNetwork(true); }, }); program.option('--vertex-layout ', 'Vertex buffer layout preset.', { default: VertexLayout.INTERLEAVED, validator: [VertexLayout.INTERLEAVED, VertexLayout.SEPARATE], action: ({ options }) => { io.setVertexLayout(options.vertexLayout as VertexLayout); }, }); program.option('--config ', 'Installs custom commands or extensions. (EXPERIMENTAL)', { validator: Validator.STRING, }); program.option( '--limit-input-pixels', 'Attempts to avoid processing very high resolution images, where memory or ' + 'other limits may be exceeded. (EXPERIMENTAL)', { validator: Validator.BOOLEAN, default: true, hidden: true, }, ); program.disableGlobalOption('--quiet'); program.disableGlobalOption('--no-color'); export * from './transforms/index.js'; export * from './utils/format.js'; // unit testing export * from './utils/log.js'; // unit testing export * from './utils/p-limit.js'; // unit testing export * from './utils/process.js'; // unit testing export { program, programReady, Validator };