From a925cc5f4b674a65a9ed1f1e7b62beeec1c34eba Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Wed, 15 Feb 2023 14:36:19 -0500 Subject: [PATCH] Add 'optimize' command (CLI) (#819) * [cli] Add optimize command * [functions] Add 'min' option to instance() * [functions] Fix draco() default options. * Add Listr2 for optimize command. * Overhaul roundtrip tests * Opt out skinned nodes from join() --- packages/cli/package.json | 1 + packages/cli/src/cli.ts | 134 +++++++++++++++++++-- packages/cli/src/session.ts | 47 +++++++- packages/cli/src/transforms/toktx.ts | 16 +-- packages/cli/src/util.ts | 4 + packages/functions/src/draco.ts | 9 +- packages/functions/src/instance.ts | 32 +++-- packages/functions/src/join.ts | 23 ++-- packages/functions/src/meshopt.ts | 5 +- packages/functions/src/texture-compress.ts | 6 +- test/clean.js | 43 ------- test/{constants.js => constants.cjs} | 5 +- test/index.html | 43 +++++-- test/roundtrip.cjs | 43 +++++++ test/roundtrip.js | 39 ------ test/{validate.js => validate.cjs} | 0 yarn.lock | 71 ++++++++++- 17 files changed, 368 insertions(+), 153 deletions(-) delete mode 100644 test/clean.js rename test/{constants.js => constants.cjs} (82%) create mode 100644 test/roundtrip.cjs delete mode 100644 test/roundtrip.js rename test/{validate.js => validate.cjs} (100%) diff --git a/packages/cli/package.json b/packages/cli/package.json index f34be8b4a..9b09dea22 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ "inquirer": "^9.1.4", "ktx-parse": "^0.4.5", "language-tags": "^1.0.7", + "listr2": "^5.0.7", "meshoptimizer": "^0.18.1", "micromatch": "^4.0.5", "mikktspace": "^1.1.1", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 81685db14..54ffa3b88 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -46,13 +46,13 @@ const PACKAGE = JSON.parse( program .version(PACKAGE.version) - .description('Command-line interface (CLI) for the glTF-Transform SDK.'); + .description('Command-line interface (CLI) for the glTF Transform SDK.'); program.command('', '\n\nšŸ”Ž INSPECT ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€'); // INSPECT program - .command('inspect', 'Inspect the contents of the model') + .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 @@ -79,7 +79,7 @@ Use --format=csv or --format=md for alternative display formats. // VALIDATE program - .command('validate', 'Validate the model against the glTF spec') + .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 @@ -121,14 +121,14 @@ program.command('', '\n\nšŸ“¦ PACKAGE ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ // COPY program - .command('copy', 'Copy the model with minimal changes') + .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 +\`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 +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. @@ -145,6 +145,116 @@ certain aspects of data layout may change slightly with this process: .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 ', 'Create GPU instances from shared mesh references', { + validator: program.NUMBER, + default: 5, + required: false + }) + .option( + '--simplify ', + 'Simplification error limit, as a fraction of mesh radius.' + + 'Disable with --simplify 0.', { + validator: program.NUMBER, + required: false, + default: SIMPLIFY_DEFAULTS.error, + }) + .option( + '--compress ', + 'Floating point compression method. Draco compresses geometry; Meshopt ' + + 'and quantization compress geometry and animation.', { + validator: ['draco', 'meshopt', 'quantize', false], + required: false, + default: 'draco', + }) + .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], + required: false, + default: 'auto', + }) + .option('--texture-size ', 'Maximum texture dimensions, in pixels.', { + validator: program.NUMBER, + default: 2048, + required: false + }) + .action(async ({args, options, logger}) => { + const opts = options as { + instance: number, + simplify: number, + compress: 'draco' | 'meshopt' | 'quantize' | false, + textureCompress: 'ktx2' | 'webp' | 'webp' | 'auto' | false, + textureSize: number, + }; + + // Baseline transforms. + const transforms = [ + dedup(), + instance({min: options.instance as number}), + flatten(), + join(), + ]; + + // Simplification and welding. + if (opts.simplify > 0) { + transforms.push( + weld({ tolerance: opts.simplify }), + simplify({ simplifier: MeshoptSimplifier, ratio: 0.001, error: opts.simplify }), + ); + } else { + transforms.push(weld({ tolerance: 0.0001 })); + } + + transforms.push( + resample(), + prune({ keepAttributes: false, keepLeaves: false }), + sparse(), + ); + + // Texture compression. + if (opts.textureCompress === 'ktx2') { + const slotsUASTC = '{normalTexture,occlusionTexture,metallicRoughnessTexture}'; + transforms.push( + toktx({ mode: Mode.UASTC, slots: slotsUASTC, level: 4, rdo: 4, zstd: 18 }), + toktx({ mode: Mode.ETC1S, quality: 255 }), + ); + } else if (opts.textureCompress !== false) { + transforms.push( + textureCompress({ + encoder: sharp, + targetFormat: opts.textureCompress === 'auto' ? undefined : opts.textureCompress, + resize: [opts.textureSize, opts.textureSize] + }), + ); + } + + // 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})); + } 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') @@ -268,7 +378,7 @@ that are children of a scene. // GZIP program - .command('gzip', 'Compress the model with lossless gzip') + .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 @@ -373,7 +483,7 @@ https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/EXT_mesh_ // FLATTEN program - .command('flatten', 'Flatten scene graph') + .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 @@ -391,7 +501,7 @@ moved. // JOIN program - .command('join', 'Join meshes and reduce draw calls') + .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 @@ -733,7 +843,7 @@ Based on the meshoptimizer library (https://github.com/zeux/meshoptimizer). .transform(simplify({simplifier: MeshoptSimplifier, ...options})) ); -program.command('', '\n\nāœØ MATERIAL ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€'); +program.command('', '\n\nšŸŽØ MATERIAL ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€'); // METALROUGH program @@ -1075,7 +1185,7 @@ and require less tuning to achieve good visual and filesize results. // AVIF program - .command('avif', 'AVIF texture compression') + .command('avif', 'āœØ AVIF texture compression') .help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'AVIF')) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) @@ -1311,7 +1421,7 @@ so this workflow is not a replacement for video playback. // SPARSE program - .command('sparse', 'Reduces storage for zero-filled arrays') + .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 diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index 5be92b4fa..b8103e867 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -1,11 +1,14 @@ import { Document, NodeIO, Logger, FileUtils, Transform, Format, ILogger } from '@gltf-transform/core'; import type { Packet, KHRXMP } from '@gltf-transform/extensions'; import { unpartition } from '@gltf-transform/functions'; -import { formatBytes, XMPContext } from './util'; +import { Listr, ListrTask } from 'listr2'; +import { dim, formatBytes, formatLong, XMPContext } from './util'; +import type caporal from '@caporal/core'; /** Helper class for managing a CLI command session. */ export class Session { private _outputFormat: Format; + private _display = false; constructor(private _io: NodeIO, private _logger: ILogger, private _input: string, private _output: string) { _io.setLogger(_logger); @@ -16,14 +19,20 @@ export class Session { return new Session(io, logger as Logger, input as string, output as string); } + public setDisplay(display: boolean): this { + this._display = display; + return this; + } + public async transform(...transforms: Transform[]): Promise { - const doc = this._input + const logger = this._logger as caporal.Logger; + const document = this._input ? (await this._io.read(this._input)).setLogger(this._logger) : new Document().setLogger(this._logger); // Warn and remove lossy compression, to avoid increasing loss on round trip. for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) { - const extension = doc + const extension = document .getRoot() .listExtensionsUsed() .find((extension) => extension.extensionName === extensionName); @@ -33,13 +42,39 @@ export class Session { } } - await doc.transform(...transforms, updateMetadata); + if (this._display) { + const tasks = [] as ListrTask[]; + for (const transform of transforms) { + tasks.push({ + title: transform.name, + task: async (ctx, task) => { + let time = performance.now(); + await document.transform(transform); + time = Math.round(performance.now() - time); + task.title = task.title.padEnd(20) + dim(` ${formatLong(time)}ms`); + }, + }); + } + + const prevLevel = logger.level; + if (prevLevel === 'info') logger.level = 'warn'; + + // Simple renderer shows warnings and errors. Disable signal listeners so Ctrl+C works. + await new Listr(tasks, { renderer: 'simple', registerSignalListeners: false }).run(); + console.log(''); + + logger.level = prevLevel; + } else { + await document.transform(...transforms); + } + + await document.transform(updateMetadata); if (this._outputFormat === Format.GLB) { - await doc.transform(unpartition()); + await document.transform(unpartition()); } - await this._io.write(this._output, doc); + await this._io.write(this._output, document); const { lastReadBytes, lastWriteBytes } = this._io; if (!this._input) { diff --git a/packages/cli/src/transforms/toktx.ts b/packages/cli/src/transforms/toktx.ts index 70a417183..fe9a58cd4 100644 --- a/packages/cli/src/transforms/toktx.ts +++ b/packages/cli/src/transforms/toktx.ts @@ -3,13 +3,13 @@ import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import micromatch from 'micromatch'; import os from 'os'; -import { clean, gte, lt, valid } from 'semver'; +import semver from 'semver'; import tmp from 'tmp'; import pLimit from 'p-limit'; import { Document, FileUtils, ILogger, ImageUtils, TextureChannel, Transform, vec2, uuid } from '@gltf-transform/core'; import { KHRTextureBasisu } from '@gltf-transform/extensions'; -import { getTextureChannelMask, listTextureSlots } from '@gltf-transform/functions'; +import { createTransform, getTextureChannelMask, listTextureSlots } from '@gltf-transform/functions'; import { spawn, commandExists, formatBytes, waitExit, MICROMATCH_OPTIONS } from '../util'; tmp.setGracefulCleanup(); @@ -112,7 +112,7 @@ export const toktx = function (options: ETC1SOptions | UASTCOptions): Transform ...options, }; - return async (doc: Document): Promise => { + return createTransform(options.mode, async (doc: Document): Promise => { const logger = doc.getLogger(); // Confirm recent version of KTX-Software is installed. @@ -221,7 +221,7 @@ export const toktx = function (options: ETC1SOptions | UASTCOptions): Transform if (!usesKTX2) { basisuExtension.dispose(); } - }; + }); }; /********************************************************************************************** @@ -286,7 +286,7 @@ function createParams( if (slots.find((slot) => micromatch.isMatch(slot, '*normal*', MICROMATCH_OPTIONS))) { // See: https://github.com/KhronosGroup/KTX-Software/issues/600 - if (gte(version, KTX_SOFTWARE_VERSION_ACTIVE)) { + if (semver.gte(version, KTX_SOFTWARE_VERSION_ACTIVE)) { params.push('--normal_mode', '--input_swizzle', 'rgb1'); } else if (options.mode === Mode.ETC1S) { params.push('--normal_map'); @@ -357,15 +357,15 @@ async function checkKTXSoftware(logger: ILogger): Promise { .replace(/~\d+/, '') .trim(); - if (status !== 0 || !valid(clean(version))) { + if (status !== 0 || !semver.valid(semver.clean(version))) { throw new Error('Unable to find "toktx" version. Confirm KTX-Software is installed.'); - } else if (lt(clean(version)!, KTX_SOFTWARE_VERSION_MIN)) { + } else if (semver.lt(semver.clean(version)!, KTX_SOFTWARE_VERSION_MIN)) { logger.warn(`toktx: Expected KTX-Software >= v${KTX_SOFTWARE_VERSION_MIN}, found ${version}.`); } else { logger.debug(`toktx: Found KTX-Software ${version}.`); } - return clean(version)!; + return semver.clean(version)!; } function isPowerOfTwo(value: number): boolean { diff --git a/packages/cli/src/util.ts b/packages/cli/src/util.ts index a7c88f906..fafa2d716 100644 --- a/packages/cli/src/util.ts +++ b/packages/cli/src/util.ts @@ -157,3 +157,7 @@ export function formatXMP(value: string | number | boolean | Record { +export const draco = (_options: DracoOptions = DRACO_DEFAULTS): Transform => { const options = { ...DRACO_DEFAULTS, ..._options } as Required; - return (doc: Document): void => { + return createTransform(NAME, (doc: Document): void => { doc.createExtension(KHRDracoMeshCompression) .setRequired(true) .setEncoderOptions({ @@ -52,5 +55,5 @@ export const draco = (_options: DracoOptions): Transform => { }, quantizationVolume: options.quantizationVolume, }); - }; + }); }; diff --git a/packages/functions/src/instance.ts b/packages/functions/src/instance.ts index a4650fd8e..0318250ba 100644 --- a/packages/functions/src/instance.ts +++ b/packages/functions/src/instance.ts @@ -4,17 +4,33 @@ import { createTransform } from './utils'; const NAME = 'instance'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface InstanceOptions {} +export interface InstanceOptions { + /** Minimum number of meshes considered eligible for instancing. Default: 2. */ + min?: number; +} -const INSTANCE_DEFAULTS: Required = {}; +const INSTANCE_DEFAULTS: Required = { + min: 2, +}; /** - * Creates GPU instances (with `EXT_mesh_gpu_instancing`) for shared {@link Mesh} references. No - * options are currently implemented for this function. + * Creates GPU instances (with `EXT_mesh_gpu_instancing`) for shared {@link Mesh} references. In + * engines supporting the extension, reused Meshes will be drawn with GPU instancing, greatly + * reducing draw calls and improving performance in many cases. If you're not sure that identical + * Meshes share vertex data and materials ("linked duplicates"), run {@link dedup} first to link them. + * + * Example: + * + * ```javascript + * import { dedup, instance } from '@gltf-transform/functions'; + * + * await document.transform( + * dedup(), + * instance({min: 2}), + * ); + * ``` */ export function instance(_options: InstanceOptions = INSTANCE_DEFAULTS): Transform { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const options = { ...INSTANCE_DEFAULTS, ..._options } as Required; return createTransform(NAME, (doc: Document): void => { @@ -44,7 +60,7 @@ export function instance(_options: InstanceOptions = INSTANCE_DEFAULTS): Transfo const modifiedNodes = []; for (const mesh of Array.from(meshInstances.keys())) { const nodes = Array.from(meshInstances.get(mesh)!); - if (nodes.length < 2) continue; + if (nodes.length < options.min) continue; if (nodes.some((node) => node.getSkin())) continue; const batch = createBatch(doc, batchExtension, mesh, nodes.length); @@ -91,7 +107,7 @@ export function instance(_options: InstanceOptions = INSTANCE_DEFAULTS): Transfo if (numBatches > 0) { logger.info(`${NAME}: Created ${numBatches} batches, with ${numInstances} total instances.`); } else { - logger.info(`${NAME}: No meshes with multiple parent nodes were found.`); + logger.info(`${NAME}: No meshes with ā‰„${options.min} parent nodes were found.`); } if (batchExtension.listProperties().length === 0) { diff --git a/packages/functions/src/join.ts b/packages/functions/src/join.ts index c2fe014e1..9597110e4 100644 --- a/packages/functions/src/join.ts +++ b/packages/functions/src/join.ts @@ -13,7 +13,7 @@ import { invert, multiply } from 'gl-matrix/mat4'; import { joinPrimitives } from './join-primitives'; import { prune } from './prune'; import { transformPrimitive } from './transform-primitive'; -import { createPrimGroupKey, createTransform, formatLong, isTransformPending, isUsed } from './utils'; +import { createPrimGroupKey, createTransform, formatLong, isUsed } from './utils'; const NAME = 'join'; @@ -78,7 +78,7 @@ export const JOIN_DEFAULTS: Required = { export function join(_options: JoinOptions = JOIN_DEFAULTS): Transform { const options = { ...JOIN_DEFAULTS, ..._options } as Required; - return createTransform(NAME, async (document: Document, context): Promise => { + return createTransform(NAME, async (document: Document): Promise => { const root = document.getRoot(); const logger = document.getLogger(); @@ -89,15 +89,13 @@ export function join(_options: JoinOptions = JOIN_DEFAULTS): Transform { } // Clean up. - if (!isTransformPending(context, NAME, 'prune')) { - await document.transform( - prune({ - propertyTypes: [NODE, MESH, PRIMITIVE, ACCESSOR], - keepLeaves: false, - keepAttributes: true, - }) - ); - } + await document.transform( + prune({ + propertyTypes: [NODE, MESH, PRIMITIVE, ACCESSOR], + keepLeaves: false, + keepAttributes: true, + }) + ); logger.debug(`${NAME}: Complete.`); }); @@ -132,6 +130,9 @@ function _joinLevel(document: Document, parent: Node | Scene, options: Required< // Skip nodes with instancing; unsupported. if (node.getExtension('EXT_mesh_gpu_instancing')) continue; + // Skip nodes with skinning; unsupported. + if (node.getSkin()) continue; + for (const prim of mesh.listPrimitives()) { // Skip prims with morph targets; unsupported. if (prim.listTargets().length > 0) continue; diff --git a/packages/functions/src/meshopt.ts b/packages/functions/src/meshopt.ts index 62d8528df..b8b9bda91 100644 --- a/packages/functions/src/meshopt.ts +++ b/packages/functions/src/meshopt.ts @@ -3,6 +3,7 @@ import { EXTMeshoptCompression } from '@gltf-transform/extensions'; import type { MeshoptEncoder } from 'meshoptimizer'; import { reorder } from './reorder'; import { quantize } from './quantize'; +import { createTransform } from './utils'; export interface MeshoptOptions { encoder: unknown; @@ -44,7 +45,7 @@ export const meshopt = (_options: MeshoptOptions): Transform => { throw new Error(`${NAME}: encoder dependency required ā€” install "meshoptimizer".`); } - return async (document: Document): Promise => { + return createTransform(NAME, async (document: Document): Promise => { await document.transform( reorder({ encoder: encoder, @@ -70,5 +71,5 @@ export const meshopt = (_options: MeshoptOptions): Transform => { ? EXTMeshoptCompression.EncoderMethod.QUANTIZE : EXTMeshoptCompression.EncoderMethod.FILTER, }); - }; + }); }; diff --git a/packages/functions/src/texture-compress.ts b/packages/functions/src/texture-compress.ts index c84df5075..645101956 100644 --- a/packages/functions/src/texture-compress.ts +++ b/packages/functions/src/texture-compress.ts @@ -3,7 +3,7 @@ import { EXTTextureAVIF, EXTTextureWebP } from '@gltf-transform/extensions'; import { getTextureChannelMask } from './list-texture-channels'; import { listTextureSlots } from './list-texture-slots'; import type sharp from 'sharp'; -import { formatBytes } from './utils'; +import { createTransform, formatBytes } from './utils'; import { TextureResizeFilter } from './texture-resize'; const NAME = 'textureCompress'; @@ -99,7 +99,7 @@ export const textureCompress = function (_options: TextureCompressOptions): Tran throw new Error(`${targetFormat}: encoder dependency required ā€” install "sharp".`); } - return async (document: Document): Promise => { + return createTransform(NAME, async (document: Document): Promise => { const logger = document.getLogger(); const textures = document.getRoot().listTextures(); @@ -211,7 +211,7 @@ export const textureCompress = function (_options: TextureCompressOptions): Tran } logger.debug(`${NAME}: Complete.`); - }; + }); }; function getFormat(texture: Texture): Format { diff --git a/test/clean.js b/test/clean.js deleted file mode 100644 index 217b53239..000000000 --- a/test/clean.js +++ /dev/null @@ -1,43 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const { SOURCE, TARGET, VARIANTS, SKIPLIST } = require('./constants.js'); - -/** - * Cleans the `out/` directory, then makes fresh copies of all supported sample - * model files, in their original/unmodified forms. - */ - -const INDEX = require(path.join(SOURCE, 'model-index.json')).filter((asset) => !SKIPLIST.has(asset.name)); - -INDEX.forEach((asset) => { - cleanDir(path.join(TARGET, asset.name)); - - const unsupported = []; - for (const variant in asset.variants) { - if (!VARIANTS.has(variant)) unsupported.push(variant); - } - for (const variant of unsupported) delete asset.variants[variant]; - - Object.entries(asset.variants).forEach(([variant, filename]) => { - if (!VARIANTS.has(variant)) return; - - const src = path.join(SOURCE, asset.name, variant); - const dst = path.join(TARGET, asset.name, variant); - execSync(`cp -r "${src}" "${dst}"`); - }); -}); - -fs.writeFileSync(path.join(TARGET, 'model-index.json'), JSON.stringify(INDEX, null, 2)); - -function cleanDir(dir) { - if (fs.existsSync(dir)) { - const relPath = path.relative(TARGET, dir); - const isSafe = relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath); - if (!isSafe) { - throw new Error(`Path not safe: ${dir}`); - } - execSync(`rm -rf "${dir}"`); - } - fs.mkdirSync(dir); -} diff --git a/test/constants.js b/test/constants.cjs similarity index 82% rename from test/constants.js rename to test/constants.cjs index 6b3ea44cf..6f1a3e14f 100644 --- a/test/constants.js +++ b/test/constants.cjs @@ -11,8 +11,7 @@ const SOURCE = path.resolve(__dirname, '../../glTF-Sample-Models/2.0/'); /** Output directory for generated roundtrip assets. */ const TARGET = path.resolve(__dirname, './out'); -/** Supported variants. */ -const VARIANTS = new Set(['glTF-Binary']); +const VARIANT = 'glTF-Binary'; /** * Assets to skip. @@ -24,4 +23,4 @@ const VARIANTS = new Set(['glTF-Binary']); */ const SKIPLIST = new Set(['AnimatedTriangle', 'SimpleMorph', 'SpecGlossVsMetalRough']); -module.exports = { SOURCE, TARGET, VARIANTS, SKIPLIST }; +module.exports = { SOURCE, TARGET, VARIANT, SKIPLIST }; diff --git a/test/index.html b/test/index.html index 44f117cf9..4555f05bb 100644 --- a/test/index.html +++ b/test/index.html @@ -17,16 +17,27 @@ .container { width: 630px; margin: 30px auto; - display: grid; - grid-template-columns: 300px 300px; - grid-auto-rows: 300px; - grid-gap: 30px; } model-viewer { width: 300px; height: 300px; border: 1px dashed #ccc; } + figure { + position: relative; + display: grid; + grid-template-columns: 300px 300px; + grid-auto-rows: 300px; + grid-gap: 30px; + width: 630px; + } + figcaption { + position: absolute; + top: calc(50% - 0.5em); + left: calc(100% + 30px); + font-style: italic; + font-family: monospace; + }

Round trip tests

@@ -44,20 +55,28 @@

Round trip tests

.then((index) => { index.forEach((asset) => { Object.entries(asset.variants).forEach(([variant, filename]) => { + const rowEl = document.createElement('figure'); + containerEl.appendChild(rowEl); + const el = document.createElement('model-viewer'); - el.setAttribute('src', `./out/${asset.name}/${variant}/${filename}`); + el.setAttribute('src', `./out/${suffix(filename, '_')}`); el.setAttribute('autoplay', ''); el.alt = el.title = `${asset.name} / ${variant} / ${filename}`; - containerEl.appendChild(el.cloneNode()); + rowEl.appendChild(el.cloneNode()); - el.setAttribute( - 'src', - `./out/${asset.name}/${variant}/${filename.replace(/\.(gltf|glb)$/, '.copy.glb')}` - ); - el.alt = el.title = `${asset.name} / ${variant} / ${filename} (COPY)`; - containerEl.appendChild(el); + el.setAttribute('src', `./out/${suffix(filename, 'optimized')}`); + el.alt = el.title = `${asset.name} / ${variant} / ${filename}`; + rowEl.appendChild(el); + + const labelEl = document.createElement('figcaption'); + labelEl.textContent = asset.name; + rowEl.appendChild(labelEl); }); }); }); + + function suffix(path, suffix) { + return path.replace('.glb', `.${suffix}.glb`); + } diff --git a/test/roundtrip.cjs b/test/roundtrip.cjs new file mode 100644 index 000000000..464807f9e --- /dev/null +++ b/test/roundtrip.cjs @@ -0,0 +1,43 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { SOURCE, TARGET, VARIANT, SKIPLIST } = require('./constants.cjs'); + +/** + * Generates a copy of each sample model using `copy` and `optimize`. Copy + * not apply any meaningful edits to the files, and is intended to be a + * lossless round trip test. Optimize runs a number of other commands. + */ + +const INDEX = require(path.join(SOURCE, 'model-index.json')).filter((asset) => !SKIPLIST.has(asset.name)); + +execSync(`rm -r ${TARGET}/**`); + +INDEX.forEach((asset, assetIndex) => { + console.info(`šŸ“¦ ${asset.name} (${assetIndex + 1} / ${INDEX.length})`); + + Object.entries(asset.variants).forEach(([variant, filename]) => { + if (variant !== VARIANT) { + delete asset.variants[variant]; + return; + } + + const src = path.join(SOURCE, asset.name, VARIANT, filename); + const dst = path.join(TARGET, filename.replace(/\.(gltf|glb)$/, '.{v}.glb')); + + try { + execSync(`cp ${src} ${dst.replace('{v}', '_')}`); + execSync(`gltf-transform copy ${src} ${dst.replace('{v}', 'copy')}`); + execSync(`gltf-transform optimize ${src} ${dst.replace('{v}', 'optimized')} --texture-compress webp`); + console.info(` - āœ… ${variant}/${filename}`); + } catch (e) { + console.error(` - ā›”ļø ${variant}/${filename}: ${e.message}`); + } + }); + + console.log('\n'); +}); + +fs.writeFileSync(`${TARGET}/model-index.json`, JSON.stringify(INDEX)); + +console.info('šŸ» Done.'); diff --git a/test/roundtrip.js b/test/roundtrip.js deleted file mode 100644 index dfb4421b1..000000000 --- a/test/roundtrip.js +++ /dev/null @@ -1,39 +0,0 @@ -const path = require('path'); -const { execSync } = require('child_process'); -const { SOURCE, TARGET, VARIANTS } = require('./constants.js'); - -/** - * Generates a copy of each sample model using `gltf-transform copy`. Does - * not apply any meaningful edits to the files: this is intended to be a - * lossless round trip test. - */ - -const INDEX = require(path.join(TARGET, 'model-index.json')); - -INDEX.forEach((asset, assetIndex) => { - console.info(`šŸ“¦ ${asset.name} (${assetIndex + 1} / ${INDEX.length})`); - - Object.entries(asset.variants).forEach(([variant, filename]) => { - if (!VARIANTS.has(variant)) return; - - const src = path.join(SOURCE, asset.name, variant, filename); - const dst = path.join(TARGET, asset.name, variant, filename.replace(/\.(gltf|glb)$/, '.{v}.glb')); - - try { - execSync(`gltf-transform copy ${src} ${dst.replace('{v}', 'copy')}`); - execSync( - `gltf-transform quantize ${src} ${dst.replace('{v}', 'quantize-lo')}` + - ' --quantizePosition 14 --quantizeTexcoord 12 --quantizeColor 8 --quantizeNormal 8' - ); - execSync(`gltf-transform quantize ${src} ${dst.replace('{v}', 'quantize-hi')}`); - execSync(`gltf-transform draco ${src} ${dst.replace('{v}', 'draco')}`); - console.info(` - āœ… ${variant}/${filename}`); - } catch (e) { - console.error(` - ā›”ļø ${variant}/${filename}: ${e.message}`); - } - }); - - console.log('\n'); -}); - -console.info('šŸ» Done.'); diff --git a/test/validate.js b/test/validate.cjs similarity index 100% rename from test/validate.js rename to test/validate.cjs diff --git a/yarn.lock b/yarn.lock index 15bb18ddf..5aa5b8452 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2610,7 +2610,7 @@ ansi-escapes@^3.2.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-escapes@^4.2.1: +ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -2791,6 +2791,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + async@0.9.x: version "0.9.2" resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" @@ -3402,6 +3407,14 @@ cli-table3@^0.6.3: optionalDependencies: "@colors/colors" "1.5.0" +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" + cli-truncate@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-3.1.0.tgz#3f23ab12535e3d73e839bb43e73c9de487db1389" @@ -3559,6 +3572,11 @@ colorette@^1.2.1: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== +colorette@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== + colornames@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96" @@ -6311,6 +6329,20 @@ lines-and-columns@~2.0.3: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-2.0.3.tgz#b2f0badedb556b747020ab8ea7f0373e22efac1b" integrity sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w== +listr2@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.7.tgz#de69ccc4caf6bea7da03c74f7a2ffecf3904bd53" + integrity sha512-MD+qXHPmtivrHIDRwPYdfNkrzqDiuaKU/rfBcec3WMyMF3xylQj3jMq344OtvQxz7zaCFViRAeqlr2AFhPvXHw== + dependencies: + cli-truncate "^2.1.0" + colorette "^2.0.19" + log-update "^4.0.0" + p-map "^4.0.0" + rfdc "^1.3.0" + rxjs "^7.8.0" + through "^2.3.8" + wrap-ansi "^7.0.0" + load-json-file@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" @@ -6430,6 +6462,16 @@ log-symbols@^5.1.0: chalk "^5.0.0" is-unicode-supported "^1.1.0" +log-update@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" + integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== + dependencies: + ansi-escapes "^4.3.0" + cli-cursor "^3.1.0" + slice-ansi "^4.0.0" + wrap-ansi "^6.2.0" + logform@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2" @@ -8696,6 +8738,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + rgb-regex@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" @@ -8809,7 +8856,7 @@ rxjs@^7.0.0, rxjs@^7.5.5: dependencies: tslib "^2.1.0" -rxjs@^7.2.0, rxjs@^7.5.7: +rxjs@^7.2.0, rxjs@^7.5.7, rxjs@^7.8.0: version "7.8.0" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== @@ -9028,6 +9075,24 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +slice-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" + integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + slice-ansi@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" @@ -9614,7 +9679,7 @@ through2@^4.0.0: dependencies: readable-stream "3" -through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6: +through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=