chore(monorepo): import hmwebviewer history at a717900
git-subtree-dir: apps/viewer-3d git-subtree-mainline:5e10bb1be4git-subtree-split:a71790070d
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/preprocess.mjs — Offline GLB optimization for hmwebviewer.
|
||||
*
|
||||
* SCOPE: GLB/glTF ONLY (gltf-transform pipeline). Other formats shipped as
|
||||
* samples (OBJ/DAE/FBX/IFC) are NOT processed here — they are consumed raw by
|
||||
* their respective three.js loaders; no offline Draco/KTX2 step applies.
|
||||
*
|
||||
* Compresses a glTF/GLB with Draco (geometry) + WebP texture compression in one
|
||||
* pass using gltf-transform. Output is a single self-contained .optimized.glb
|
||||
* ready for the viewer's GLTFLoader (+ DRACOLoader).
|
||||
*
|
||||
* KTX2/Basis texture encoding is NOT done here — it needs the `toktx` platform
|
||||
* encoder (KTX-Software 4.3+). gltf-transform exposes it via the CLI `etc1s`
|
||||
* (lossy, smaller) or `uastc` (higher quality) commands, e.g.:
|
||||
* gltf-transform etc1s <out.optimized.glb> <out.ktx2.glb>
|
||||
* Install KTX-Software from https://github.com/KhronosGroup/KTX-Software first.
|
||||
* Runtime: the viewer's KTX2Loader decodes these at load (verified with the
|
||||
* Khronos ABeautifulGame KTX2+Draco sample — 626ms perceived load).
|
||||
*
|
||||
* USAGE
|
||||
* node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture FMT]
|
||||
*
|
||||
* --draco-bits N Position quantization bits (default 14). 8..16; lower=smaller/lossier.
|
||||
* --texture FMT Texture re-encode: webp (default) | jpeg | png | none.
|
||||
*
|
||||
* OUTPUT
|
||||
* Default output = input with `.optimized.glb` suffix. Prints before/after
|
||||
* byte sizes + reduction %.
|
||||
*
|
||||
* INSTALL (these are devDependencies now; if absent the script prints the
|
||||
* install command and exits non-zero — no silent skip):
|
||||
* npm install -D @gltf-transform/core @gltf-transform/functions \
|
||||
* @gltf-transform/extensions @gltf-transform/cli
|
||||
*/
|
||||
|
||||
async function loadDeps() {
|
||||
let core, fns, ext;
|
||||
try {
|
||||
core = await import('@gltf-transform/core');
|
||||
fns = await import('@gltf-transform/functions');
|
||||
ext = await import('@gltf-transform/extensions');
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
console.error('[preprocess] Missing required package: ' + msg);
|
||||
console.error('[preprocess] Install the toolchain with:');
|
||||
console.error(
|
||||
' npm install -D @gltf-transform/core @gltf-transform/functions ' +
|
||||
'@gltf-transform/extensions @gltf-transform/cli'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { core, fns, ext };
|
||||
}
|
||||
|
||||
const TEXTURE_FORMATS = ['webp', 'jpeg', 'png', 'none'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
||||
return { help: true };
|
||||
}
|
||||
const positional = [];
|
||||
let dracoBits = 14;
|
||||
let texture = 'webp';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--draco-bits') {
|
||||
const v = Number(args[++i]);
|
||||
if (!Number.isInteger(v) || v < 8 || v > 16) {
|
||||
throw new Error('--draco-bits must be an integer 8..16');
|
||||
}
|
||||
dracoBits = v;
|
||||
} else if (a === '--texture') {
|
||||
const v = String(args[++i]).toLowerCase();
|
||||
if (!TEXTURE_FORMATS.includes(v)) {
|
||||
throw new Error('--texture must be one of: ' + TEXTURE_FORMATS.join(', '));
|
||||
}
|
||||
texture = v;
|
||||
} else if (a.startsWith('--')) {
|
||||
throw new Error('Unknown option: ' + a);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
if (positional.length === 0) {
|
||||
throw new Error('Missing <input.glb>');
|
||||
}
|
||||
const input = positional[0];
|
||||
let output = positional[1];
|
||||
if (!output) {
|
||||
output = input.replace(/\.glb$/i, '') + '.optimized.glb';
|
||||
}
|
||||
return { input, output, dracoBits, texture };
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture webp|jpeg|png|none]'
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const { core, fns, ext } = await loadDeps();
|
||||
const { WebIO } = core;
|
||||
const { quantize, dedup, weld, draco, textureCompress, prune } = fns;
|
||||
const { KHRDracoMeshCompression, EXTTextureWebP } = ext;
|
||||
|
||||
const { promises: fs } = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
|
||||
const inputPath = path.resolve(opts.input);
|
||||
const outputPath = path.resolve(opts.output);
|
||||
|
||||
let inputBytes;
|
||||
try {
|
||||
inputBytes = await fs.readFile(inputPath);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Cannot read input "' + inputPath + '": ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const beforeSize = inputBytes.byteLength;
|
||||
|
||||
// Draco encoder must be handed to the extension as a registered dependency.
|
||||
const draco3d = await import('draco3d');
|
||||
const dracoEncoder = await draco3d.createEncoderModule();
|
||||
const io = new WebIO()
|
||||
.registerExtensions([KHRDracoMeshCompression, EXTTextureWebP])
|
||||
.registerDependencies({ 'draco3d.encoder': dracoEncoder });
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = await io.readBinary(inputBytes);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Failed to parse glTF: ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transforms = [];
|
||||
transforms.push(dedup());
|
||||
transforms.push(weld({ tolerance: 1e-4 }));
|
||||
transforms.push(
|
||||
quantize({
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
transforms.push(prune());
|
||||
transforms.push(
|
||||
draco({
|
||||
encodeSpeed: 5,
|
||||
decodeSpeed: 5,
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
if (opts.texture !== 'none') {
|
||||
transforms.push(textureCompress({ targetFormat: opts.texture, quality: 8 }));
|
||||
}
|
||||
|
||||
await doc.transform(...transforms);
|
||||
|
||||
const outGLB = await io.writeBinary(doc);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, outGLB);
|
||||
const afterSize = outGLB.byteLength;
|
||||
|
||||
const reduction = beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100) : 0;
|
||||
const fmt = (n) => (n / 1024).toFixed(1) + ' KiB';
|
||||
console.log('[preprocess] ' + path.basename(inputPath) + ' -> ' + path.basename(outputPath));
|
||||
console.log('[preprocess] before: ' + beforeSize + ' bytes (' + fmt(beforeSize) + ')');
|
||||
console.log('[preprocess] after: ' + afterSize + ' bytes (' + fmt(afterSize) + ')');
|
||||
console.log(
|
||||
'[preprocess] reduction: ' + reduction.toFixed(1) + '% ' +
|
||||
'(draco-bits=' + opts.dracoBits + ', texture=' + opts.texture + ')'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[preprocess] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user