246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFile,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
|
|
import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs";
|
|
import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs";
|
|
import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs";
|
|
import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs";
|
|
import { projectRoot, readJson } from "./lib/recipe.mjs";
|
|
|
|
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work");
|
|
const assetRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
|
|
const gltfTransformCli = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
|
|
const ktx2Compressor = path.join(projectRoot, "scripts", "runewaker-pipeline", "compress-environment-textures.mjs");
|
|
const args = process.argv.slice(2);
|
|
const selectedSlug = args.includes("--slug") ? args[args.indexOf("--slug") + 1] : null;
|
|
const applyStaged = args.includes("--apply-staged");
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function run(command, commandArgs, label) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, commandArgs, {
|
|
cwd: projectRoot,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
|
|
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`${label} exited with ${code}.`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function collectDungeons() {
|
|
const recipeFiles = (await readdir(recipeDirectory, { withFileTypes: true }))
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith("-population.json"))
|
|
.map((entry) => path.join(recipeDirectory, entry.name));
|
|
const recipes = [];
|
|
for (const recipeFile of recipeFiles) {
|
|
const recipe = await readJson(recipeFile);
|
|
if (recipe.slug && recipe.source?.model && recipe.source?.wdb) recipes.push(recipe);
|
|
}
|
|
recipes.sort((left, right) => left.slug.localeCompare(right.slug));
|
|
const expectedRecipes = selectedSlug ? recipes.filter((recipe) => recipe.slug === selectedSlug) : recipes;
|
|
if (selectedSlug && !expectedRecipes.length) {
|
|
throw new Error(`No converted and shipped environment found for ${selectedSlug}.`);
|
|
}
|
|
const dungeons = [];
|
|
for (const recipe of expectedRecipes) {
|
|
const slug = recipe.slug;
|
|
const convertedDirectory = path.join(workRoot, slug, "converted");
|
|
const source = path.join(convertedDirectory, `${slug}-visual.glb`);
|
|
const metadataFile = path.join(assetRoot, slug, "import-metadata.json");
|
|
try {
|
|
await stat(source);
|
|
await stat(metadataFile);
|
|
} catch (error) {
|
|
throw new Error(`Expected RoM environment ${slug} is incomplete: ${error.message}`);
|
|
}
|
|
dungeons.push({ slug, recipe, convertedDirectory, source, metadataFile });
|
|
}
|
|
if (!dungeons.length) throw new Error("No RoM environment recipes were found.");
|
|
return dungeons;
|
|
}
|
|
|
|
async function stageDungeon(dungeon, index, count) {
|
|
const { slug, convertedDirectory, source } = dungeon;
|
|
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
|
|
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
|
|
let sourceReport;
|
|
if (dungeon.recipe.visualUvAdaptationPolicies?.length || dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.derivedSupportStylingPolicy) {
|
|
sourceReport = await readJson(path.join(workRoot, slug, "prepared", "export-report.json"));
|
|
sourceReport.sourceResourceSha256 ??= (await readJson(dungeon.metadataFile)).source?.modelSha256;
|
|
}
|
|
const visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(source, {
|
|
dungeonId: slug,
|
|
policyIds: dungeon.recipe.visualUvAdaptationPolicies ?? [],
|
|
surfaceQualityPolicyIds: dungeon.recipe.visualSurfaceQualityPolicies ?? [],
|
|
sourceReport,
|
|
});
|
|
const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === dungeon.recipe.derivedSupportStylingPolicy);
|
|
const derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile(source, {
|
|
dungeonId: slug, policyId: dungeon.recipe.derivedSupportStylingPolicy, sourceReport,
|
|
sourceDdsFile: supportPolicy ? path.join(workRoot, slug, "raw", "textures", supportPolicy.stagedTexture) : null,
|
|
});
|
|
const nativeMaterialSemantics = await applyNativeMaterialSemantics(source);
|
|
const sourceSize = (await stat(source)).size;
|
|
await rm(stagedVisual, { force: true });
|
|
console.log(`\n[${index}/${count}] Optimizing ${slug} from converted geometry (simplification disabled).`);
|
|
await run(
|
|
process.execPath,
|
|
[gltfTransformCli, ...environmentOptimizationArguments(source, stagedVisual, {
|
|
preserveNativeWaterLayers: Boolean(dungeon.recipe.source?.nativeWater),
|
|
preserveMaterialVariantCarriers: Boolean(dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.visualUvAdaptationPolicies?.length),
|
|
})],
|
|
`${slug} glTF optimization`,
|
|
);
|
|
await run(
|
|
process.execPath,
|
|
[ktx2Compressor, stagedVisual, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.reoptimized.json")],
|
|
`${slug} KTX2 texture compression`,
|
|
);
|
|
const optimizedBytes = await readFile(stagedVisual);
|
|
const report = {
|
|
schemaVersion: 1,
|
|
dungeonId: slug,
|
|
status: "review-required",
|
|
source: { file: path.basename(source), size: sourceSize },
|
|
optimized: {
|
|
file: `${slug}-visual.optimized.glb`,
|
|
size: optimizedBytes.length,
|
|
checksum: sha256(optimizedBytes),
|
|
},
|
|
compression: "meshopt+ktx2",
|
|
commands: [
|
|
"node scripts/runewaker-pipeline/lib/native-material-semantics.mjs <converted.glb> (before optimization)",
|
|
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
|
|
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
|
|
],
|
|
textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.reoptimized.json"), "utf8")),
|
|
nativeMaterialSemantics,
|
|
visualUvAdaptations,
|
|
derivedSupportStyling,
|
|
};
|
|
await writeFile(stagedOptimizationReport, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
console.log(`[${index}/${count}] Staged ${slug}: ${optimizedBytes.length} bytes, SHA-256 ${report.optimized.checksum}.`);
|
|
return { ...dungeon, stagedVisual, stagedOptimizationReport };
|
|
}
|
|
|
|
async function validateStagedDungeon(dungeon) {
|
|
const { slug, convertedDirectory } = dungeon;
|
|
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
|
|
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
|
|
const stagedBytes = await readFile(stagedVisual);
|
|
const stagedReport = JSON.parse(await readFile(stagedOptimizationReport, "utf8"));
|
|
if (stagedReport.dungeonId !== slug
|
|
|| stagedReport.optimized?.size !== stagedBytes.length
|
|
|| stagedReport.optimized?.checksum !== sha256(stagedBytes)) {
|
|
throw new Error(`${slug} staged visual and optimization report do not match.`);
|
|
}
|
|
return { ...dungeon, stagedVisual, stagedOptimizationReport, stagedReport, stagedBytes };
|
|
}
|
|
|
|
async function preparePackage(dungeon) {
|
|
const {
|
|
slug,
|
|
convertedDirectory,
|
|
stagedVisual,
|
|
stagedOptimizationReport,
|
|
metadataFile,
|
|
stagedBytes,
|
|
} = dungeon;
|
|
const shippingDirectory = path.dirname(metadataFile);
|
|
const shippingVisual = path.join(shippingDirectory, `${slug}-visual.glb`);
|
|
const shippingReport = path.join(shippingDirectory, "optimization-report.json");
|
|
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
|
|
const visualEntry = metadata.assets?.find((entry) => entry.role === "visual" && entry.fileName === path.basename(shippingVisual));
|
|
if (!visualEntry) throw new Error(`${slug} import metadata has no matching visual asset record.`);
|
|
|
|
const stagedOptimized = path.join(convertedDirectory, `${slug}-visual.optimized.pending.glb`);
|
|
const stagedShipping = path.join(shippingDirectory, `.${slug}-visual.pending.glb`);
|
|
const stagedShippingReport = path.join(shippingDirectory, ".optimization-report.pending.json");
|
|
const stagedMetadata = `${metadataFile}.pending`;
|
|
await rm(stagedOptimized, { force: true });
|
|
await rm(stagedShipping, { force: true });
|
|
await rm(stagedShippingReport, { force: true });
|
|
await rm(stagedMetadata, { force: true });
|
|
|
|
visualEntry.size = stagedBytes.length;
|
|
visualEntry.sha256 = sha256(stagedBytes);
|
|
metadata.generatedAt = new Date().toISOString();
|
|
await copyFile(stagedVisual, stagedOptimized);
|
|
await copyFile(stagedVisual, stagedShipping);
|
|
await copyFile(stagedOptimizationReport, stagedShippingReport);
|
|
await writeFile(stagedMetadata, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
|
|
return {
|
|
...dungeon,
|
|
stagedOptimized,
|
|
stagedShipping,
|
|
stagedShippingReport,
|
|
stagedMetadata,
|
|
shippingVisual,
|
|
shippingReport,
|
|
metadataFile,
|
|
};
|
|
}
|
|
|
|
async function applyPackage(dungeon) {
|
|
const {
|
|
slug,
|
|
convertedDirectory,
|
|
stagedVisual,
|
|
stagedOptimizationReport,
|
|
stagedOptimized,
|
|
stagedShipping,
|
|
stagedShippingReport,
|
|
stagedMetadata,
|
|
shippingVisual,
|
|
shippingReport,
|
|
metadataFile,
|
|
} = dungeon;
|
|
await rename(stagedOptimized, path.join(convertedDirectory, `${slug}-visual.optimized.glb`));
|
|
await rename(stagedShipping, shippingVisual);
|
|
await rename(stagedShippingReport, shippingReport);
|
|
await rename(stagedMetadata, metadataFile);
|
|
await rm(stagedVisual, { force: true });
|
|
await rm(stagedOptimizationReport, { force: true });
|
|
console.log(`[package] ${slug}: visual checksum/size and optimization report refreshed.`);
|
|
}
|
|
|
|
const dungeons = await collectDungeons();
|
|
console.log(`[runewaker-reoptimize] ${dungeons.length} environment visual(s) selected.`);
|
|
let staged;
|
|
if (applyStaged) {
|
|
staged = [];
|
|
for (const dungeon of dungeons) staged.push(await validateStagedDungeon(dungeon));
|
|
const preparedPackages = [];
|
|
for (const dungeon of staged) preparedPackages.push(await preparePackage(dungeon));
|
|
for (const dungeon of preparedPackages) await applyPackage(dungeon);
|
|
console.log(`[runewaker-reoptimize] Applied ${staged.length} staged visuals without re-encoding textures.`);
|
|
} else {
|
|
staged = [];
|
|
for (const [index, dungeon] of dungeons.entries()) {
|
|
staged.push(await stageDungeon(dungeon, index + 1, dungeons.length));
|
|
}
|
|
console.log(`[runewaker-reoptimize] Staged ${staged.length} corrected visuals. After the audit baseline is frozen, re-run with --apply-staged to replace shipped visuals.`);
|
|
}
|