优化第一阶段

This commit is contained in:
yinsx
2025-12-22 12:07:12 +08:00
parent dd99e932b4
commit 1df41ac4ab
38 changed files with 340 additions and 300 deletions

View File

@ -0,0 +1,71 @@
import { listAllModelFiles } from "../../utils/gltf.js";
const transformOptions = [
{ value: "dedup", label: "dedup去重", hint: "删除重复的访问器、材质、网格" },
{ value: "prune", label: "prune清理无用节点", hint: "移除未被引用的节点、材质、动画" },
{ value: "resample", label: "resample动画重采样", hint: "统一动画关键帧间隔,减少多余数据" },
{ value: "weld", label: "weld合并顶点", hint: "合并共享位置的顶点以减少面数" },
{ value: "quantize", label: "quantize量化顶点数据", hint: "降低顶点属性精度减小模型体积" }
];
const quantizePresets = [
{ value: "high", label: "高质量位置16位法线12位UV14位", hint: "尽量保证细节,适合高保真场景" },
{ value: "balanced", label: "均衡位置14位法线10位UV12位", hint: "默认推荐,兼顾体积与质量" },
{ value: "aggressive", label: "极限压缩位置12位法线8位UV10位", hint: "最小体积,但可能损失细节" },
{ value: "light", label: "轻量位置10位法线8位UV10位", hint: "适合移动端、卡通等对精度不敏感场景" }
];
const outputFormats = [
{ value: "auto", label: "保持原格式", hint: "glTF/GLB保持原格式OBJ/FBX转为GLB" },
{ value: "glb", label: "统一导出为 GLB二进制", hint: "单文件发布更方便" },
{ value: "gltf", label: "统一导出为 glTFJSON", hint: "可读性好,调试方便" }
];
const outputOptions = [
{ value: "overwrite", label: "覆盖原文件", hint: "直接把结果写回源文件" },
{ value: "backup", label: "保留备份 (_备份)", hint: "覆盖前在同目录生成 _备份 副本" },
{ value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" }
];
export function getSteps() {
const files = listAllModelFiles();
const fileStep = {
name: "模型选择",
type: "multiselect",
message: files.length ? "选择要处理的模型(支持 glTF/GLB/OBJ/FBX" : "当前目录未找到模型文件",
options: files.map(file => ({ value: file, label: file })),
default: [...files]
};
return [
fileStep,
{
name: "压缩命令",
type: "multiselect",
message: "请选择要执行的 gltf-transform 操作",
options: transformOptions,
default: ["dedup", "prune", "weld", "quantize"]
},
{
name: "量化级别",
type: "select",
message: "量化可显著减小体积(如未启用 quantize 可直接 Tab",
options: quantizePresets,
default: "balanced"
},
{
name: "输出格式",
type: "select",
message: "选择最终模型格式",
options: outputFormats,
default: "auto"
},
{
name: "输出选项",
type: "multiselect",
message: "请选择输出行为",
options: outputOptions,
default: ["overwrite", "backup"]
}
];
}

202
lib/plugins/model/index.js Normal file
View File

@ -0,0 +1,202 @@
import fs from "fs";
import path from "path";
import color from "picocolors";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, prune, resample, weld, quantize } from "@gltf-transform/functions";
import { BACKUP_SUFFIX, needsConversion } from "../../utils/gltf.js";
import { convert } from "../convert/converters.js";
import { runInteractive, showSummary } from "./ui.js";
import { stopKeypress, waitForKey } from "../../keyboard.js";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const QUANTIZE_PRESETS = {
high: { position: 16, normal: 12, texcoord: 14, color: 10, generic: 12 },
balanced: { position: 14, normal: 10, texcoord: 12, color: 8, generic: 12 },
aggressive: { position: 12, normal: 8, texcoord: 10, color: 8, generic: 10 },
light: { position: 10, normal: 8, texcoord: 10, color: 8, generic: 10 }
};
function buildTransforms(config) {
const transforms = [];
const selected = new Set(config.commands);
if (selected.has("dedup")) transforms.push(dedup());
if (selected.has("prune")) transforms.push(prune());
if (selected.has("resample")) transforms.push(resample());
if (selected.has("weld")) transforms.push(weld());
if (selected.has("quantize")) {
const preset = QUANTIZE_PRESETS[config.quantizePreset] || QUANTIZE_PRESETS.balanced;
transforms.push(quantize({
quantizePosition: preset.position,
quantizeNormal: preset.normal,
quantizeTexcoord: preset.texcoord,
quantizeColor: preset.color,
quantizeGeneric: preset.generic
}));
}
return transforms;
}
function resolveOutput(file, config) {
const cwd = process.cwd();
const originalExt = path.extname(file).toLowerCase() || ".gltf";
const baseName = originalExt ? file.slice(0, -originalExt.length) : file;
const isConvertible = needsConversion(file);
// OBJ/FBX 默认输出 GLB除非指定了 gltf
const targetExt = config.outputFormat === "gltf" ? ".gltf"
: (config.outputFormat === "glb" || isConvertible) ? ".glb"
: originalExt;
const wantsCopy = config.outputOptions.includes("copy");
const wantsOverwrite = config.outputOptions.includes("overwrite");
const mode = wantsCopy ? "copy" : (wantsOverwrite ? "overwrite" : "copy");
const backup = config.outputOptions.includes("backup") && mode === "overwrite";
const targetName = mode === "copy"
? `${baseName}_compressed${targetExt}`
: `${baseName}${targetExt}`;
return {
cwd,
mode,
backup,
sourcePath: path.join(cwd, file),
sourceExt: originalExt,
targetExt,
targetPath: path.join(cwd, targetName),
baseName,
needsConversion: isConvertible
};
}
async function processFile(file, config, transforms) {
const output = resolveOutput(file, config);
if (!fs.existsSync(output.sourcePath)) {
console.log(color.yellow("跳过,文件不存在: " + file));
return { ok: false, file, reason: "missing" };
}
if (!transforms.length) {
console.log(color.yellow("未选择任何 gltf-transform 操作,已中止"));
return { ok: false, file, reason: "no-transform" };
}
if (output.backup) {
const backupName = `${output.baseName}${BACKUP_SUFFIX}${output.sourceExt}`;
const backupPath = path.join(output.cwd, backupName);
fs.copyFileSync(output.sourcePath, backupPath);
}
let inputPath = output.sourcePath;
// 如果是 OBJ/FBX先转换为临时 GLB
if (output.needsConversion) {
const tempPath = path.join(output.cwd, `${output.baseName}_temp.glb`);
console.log(color.dim("转换中: " + file + " → GLB"));
await convert(output.sourcePath, tempPath, { binary: true });
inputPath = tempPath;
}
const document = io.read(inputPath);
await document.transform(...transforms);
io.write(output.targetPath, document);
// 清理临时文件
if (output.needsConversion) {
fs.rmSync(inputPath, { force: true });
}
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
fs.rmSync(output.sourcePath, { force: true });
}
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
console.log(color.dim("已生成新文件: " + path.basename(output.targetPath)));
}
return { ok: true, file, output: output.targetPath };
}
async function run() {
const result = await runInteractive();
if (!result) return "back";
stopKeypress();
const { results } = result;
const config = {
files: results[0] || [],
commands: results[1] || [],
quantizePreset: results[2] || "balanced",
outputFormat: results[3] || "auto",
outputOptions: results[4] || []
};
showSummary([
"模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"),
"量化级别: " + config.quantizePreset,
"输出格式: " + config.outputFormat,
"输出选项: " + (config.outputOptions.length ? config.outputOptions.join(", ") : "默认")
]);
if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件"));
await waitForKey();
return "back";
}
const transforms = buildTransforms(config);
if (!transforms.length) {
console.log(color.yellow("未配置任何压缩命令,请至少选择一项 gltf-transform 操作"));
await waitForKey();
return "back";
}
const total = config.files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始压缩 ${total} 个文件...\n`));
for (let i = 0; i < config.files.length; i++) {
const file = config.files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const result = await processFile(file, config, transforms);
if (result.ok) {
success++;
console.log(color.green(`${progress}${file}${path.basename(result.output)}`));
} else {
failed.push(file);
console.log(color.yellow(`${progress} ⊘ 跳过: ${file}`));
}
} catch (err) {
failed.push(file);
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
console.log("\n" + color.bgGreen(color.black(" 压缩完成 ")));
if (success) {
console.log(color.green(`成功: ${success}`));
}
if (failed.length) {
console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`));
}
await waitForKey();
return "back";
}
export default {
id: "model",
name: "模型压缩",
desc: "glTF/GLB/OBJ/FBX压缩",
run,
};

9
lib/plugins/model/ui.js Normal file
View File

@ -0,0 +1,9 @@
import { createStepUI } from "../../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "模型压缩工具",
getSteps
});
export const { runInteractive, showSummary } = ui;