275 lines
9.0 KiB
JavaScript
275 lines
9.0 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { cpus } from "node:os";
|
|
import {
|
|
access,
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
stat,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(scriptDirectory, "../..");
|
|
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
|
|
const executableName = process.platform === "win32" ? "toktx.exe" : "toktx";
|
|
|
|
function argumentValue(args, name, fallback) {
|
|
const index = args.indexOf(name);
|
|
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
|
}
|
|
|
|
function positionalArguments(args) {
|
|
const valueOptions = new Set(["--concurrency", "--jobs", "--quality", "--report"]);
|
|
const result = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
if (valueOptions.has(args[index])) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (!args[index].startsWith("--")) result.push(args[index]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function exists(file) {
|
|
try {
|
|
await access(file);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function findToktxDirectory() {
|
|
const candidates = [
|
|
process.env.KTX_BIN_DIR,
|
|
path.join(projectRoot, ".tools/ktx/bin"),
|
|
...(process.env.PATH ?? "").split(path.delimiter),
|
|
].filter(Boolean);
|
|
for (const candidate of candidates) {
|
|
if (await exists(path.join(candidate, executableName))) return path.resolve(candidate);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function collectGlbs(target) {
|
|
const resolved = path.resolve(projectRoot, target);
|
|
const details = await stat(resolved);
|
|
if (details.isFile()) return resolved.toLowerCase().endsWith(".glb") ? [resolved] : [];
|
|
const files = [];
|
|
for (const entry of await readdir(resolved, { withFileTypes: true })) {
|
|
const child = path.join(resolved, entry.name);
|
|
if (entry.isDirectory()) files.push(...await collectGlbs(child));
|
|
else if (entry.isFile() && !entry.name.startsWith(".") && entry.name.toLowerCase().endsWith(".glb")) files.push(child);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function inspectGlb(file) {
|
|
const buffer = await readFile(file);
|
|
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
|
throw new Error(`${file} is not a binary glTF file.`);
|
|
}
|
|
const jsonLength = buffer.readUInt32LE(12);
|
|
const json = JSON.parse(buffer.toString("utf8", 20, 20 + jsonLength));
|
|
return {
|
|
textureCount: json.textures?.length ?? 0,
|
|
compressed: json.extensionsUsed?.includes("KHR_texture_basisu") ?? false,
|
|
};
|
|
}
|
|
|
|
function runTransform(args, environment) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [gltfTransformCli, ...args], {
|
|
cwd: projectRoot,
|
|
env: environment,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let output = "";
|
|
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
child.stderr.on("data", (chunk) => { output += chunk; });
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`gltf-transform exited with ${code}.\n${output.trim()}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function removeIfPresent(file) {
|
|
try {
|
|
await unlink(file);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
export async function compressGlbTextures(file, options) {
|
|
const inspection = await inspectGlb(file);
|
|
if (!inspection.textureCount) return { file, status: "no-textures", before: (await stat(file)).size };
|
|
if (inspection.compressed && !options.force) {
|
|
return { file, status: "already-compressed", before: (await stat(file)).size };
|
|
}
|
|
|
|
const before = (await stat(file)).size;
|
|
const directory = path.dirname(file);
|
|
const stem = path.basename(file, path.extname(file));
|
|
const nonce = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const uastcOutput = path.join(directory, `.${stem}.${nonce}.uastc.glb`);
|
|
const finalOutput = path.join(directory, `.${stem}.${nonce}.ktx2.glb`);
|
|
const environment = {
|
|
...process.env,
|
|
PATH: `${options.ktxBinDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
};
|
|
|
|
try {
|
|
await runTransform([
|
|
"uastc",
|
|
file,
|
|
uastcOutput,
|
|
"--slots",
|
|
"{normalTexture,occlusionTexture,metallicRoughnessTexture}",
|
|
"--level",
|
|
"2",
|
|
"--rdo",
|
|
"--rdo-lambda",
|
|
"1",
|
|
"--zstd",
|
|
"18",
|
|
"--jobs",
|
|
String(options.jobs),
|
|
], environment);
|
|
await runTransform([
|
|
"etc1s",
|
|
uastcOutput,
|
|
finalOutput,
|
|
"--quality",
|
|
String(options.quality),
|
|
"--jobs",
|
|
String(options.jobs),
|
|
], environment);
|
|
await copyFile(finalOutput, file);
|
|
} finally {
|
|
await removeIfPresent(uastcOutput);
|
|
await removeIfPresent(finalOutput);
|
|
}
|
|
|
|
const after = (await stat(file)).size;
|
|
return { file, status: "compressed", before, after };
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const force = args.includes("--force");
|
|
const visualOnly = args.includes("--visual-only");
|
|
const creaturesOnly = args.includes("--creatures-only");
|
|
const concurrency = Math.max(1, Number.parseInt(argumentValue(args, "--concurrency", "2"), 10));
|
|
const jobs = Math.max(
|
|
1,
|
|
Number.parseInt(argumentValue(args, "--jobs", String(Math.max(1, Math.floor(cpus().length / concurrency)))), 10),
|
|
);
|
|
const quality = Math.min(255, Math.max(1, Number.parseInt(argumentValue(args, "--quality", "180"), 10)));
|
|
const reportPath = argumentValue(args, "--report", null);
|
|
const targets = positionalArguments(args);
|
|
if (!targets.length) {
|
|
targets.push("src/assets/game/dungeons", "public/assets/game/manastorm");
|
|
}
|
|
|
|
const ktxBinDirectory = await findToktxDirectory();
|
|
if (!ktxBinDirectory) {
|
|
throw new Error(
|
|
"toktx was not found. Run scripts/asset-pipeline/bootstrap-ktx.ps1 on Windows, "
|
|
+ "or set KTX_BIN_DIR to a KTX-Software 4.3+ bin directory.",
|
|
);
|
|
}
|
|
await mkdir(path.join(projectRoot, ".tools"), { recursive: true });
|
|
|
|
const discovered = (await Promise.all(targets.map(collectGlbs))).flat();
|
|
const files = [...new Set(discovered)]
|
|
.filter((file) => !visualOnly || /(?:^|[-_])visual\.glb$/i.test(path.basename(file)))
|
|
.filter((file) => !creaturesOnly || file.split(path.sep).includes("creatures"))
|
|
.sort();
|
|
console.log(`KTX2: ${files.length} GLB files, concurrency=${concurrency}, encoder jobs=${jobs}.`);
|
|
|
|
let cursor = 0;
|
|
let beforeTotal = 0;
|
|
let afterTotal = 0;
|
|
let compressedCount = 0;
|
|
let skippedCount = 0;
|
|
const errors = [];
|
|
const results = [];
|
|
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
|
|
while (cursor < files.length) {
|
|
const file = files[cursor];
|
|
cursor += 1;
|
|
try {
|
|
const result = await compressGlbTextures(file, {
|
|
force,
|
|
jobs,
|
|
quality,
|
|
ktxBinDirectory,
|
|
});
|
|
results.push(result);
|
|
beforeTotal += result.before;
|
|
afterTotal += result.after ?? result.before;
|
|
if (result.status === "compressed") compressedCount += 1;
|
|
else skippedCount += 1;
|
|
const relative = path.relative(projectRoot, file);
|
|
const sizeNote = result.after
|
|
? ` ${(result.before / 1_048_576).toFixed(1)} -> ${(result.after / 1_048_576).toFixed(1)} MiB`
|
|
: "";
|
|
console.log(`[${compressedCount + skippedCount}/${files.length}] ${result.status}: ${relative}${sizeNote}`);
|
|
} catch (error) {
|
|
errors.push({ file, error });
|
|
console.error(`KTX2 failed: ${path.relative(projectRoot, file)}\n${error.message}`);
|
|
}
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
|
|
console.log(
|
|
`KTX2 complete: ${compressedCount} compressed, ${skippedCount} skipped, ${errors.length} failed; `
|
|
+ `${(beforeTotal / 1_048_576).toFixed(1)} -> ${(afterTotal / 1_048_576).toFixed(1)} MiB.`,
|
|
);
|
|
if (reportPath) {
|
|
const absoluteReportPath = path.resolve(projectRoot, reportPath);
|
|
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
|
|
await writeFile(absoluteReportPath, `${JSON.stringify({
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
codec: {
|
|
color: `ETC1S quality ${quality}`,
|
|
data: "UASTC level 2, RDO lambda 1, Zstandard 18",
|
|
},
|
|
totals: {
|
|
files: files.length,
|
|
compressed: compressedCount,
|
|
skipped: skippedCount,
|
|
failed: errors.length,
|
|
beforeBytes: beforeTotal,
|
|
afterBytes: afterTotal,
|
|
},
|
|
files: results.map((result) => ({
|
|
...result,
|
|
file: path.relative(projectRoot, result.file).replaceAll("\\", "/"),
|
|
})),
|
|
errors: errors.map(({ file, error }) => ({
|
|
file: path.relative(projectRoot, file).replaceAll("\\", "/"),
|
|
message: error.message,
|
|
})),
|
|
}, null, 2)}\n`, "utf8");
|
|
}
|
|
if (errors.length) process.exitCode = 1;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main();
|
|
}
|