Files
healer-man/scripts/dungeon-pipeline/optimize-environment.test.mjs
T
2026-10-07 13:05:04 -04:00

272 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import {
dungeonEnvironmentFinalPackingArguments,
dungeonEnvironmentOptimizationArguments,
} from "./optimize-environment.mjs";
import { loadRecipe } from "./lib.mjs";
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 ktx2Compressor = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
async function readGlb(file) {
const buffer = await readFile(file);
let json = null;
let binary = null;
let offset = 12;
while (offset + 8 <= buffer.length) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const chunk = buffer.subarray(offset + 8, offset + 8 + length);
if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = chunk;
offset += 8 + length;
}
assert.ok(json && binary, `${file} must contain JSON and BIN GLB chunks`);
return { json, binary };
}
function atlasGridGeometry(document, buffer, segments) {
const positions = new Float32Array((segments + 1) * (segments + 1) * 3);
const texcoords = new Float32Array((segments + 1) * (segments + 1) * 2);
const indices = new Uint16Array(segments * segments * 6);
const atlasColumns = [0.03, 0.94, 0.08, 0.89];
let indexOffset = 0;
for (let row = 0; row <= segments; row += 1) {
for (let column = 0; column <= segments; column += 1) {
const vertex = row * (segments + 1) + column;
positions.set([column, row, 0], vertex * 3);
texcoords.set([atlasColumns[column % atlasColumns.length], row / segments], vertex * 2);
if (row < segments && column < segments) {
const topLeft = vertex;
const topRight = vertex + 1;
const bottomLeft = vertex + segments + 1;
const bottomRight = bottomLeft + 1;
indices.set([
topLeft, bottomLeft, topRight,
topRight, bottomLeft, bottomRight,
], indexOffset);
indexOffset += 6;
}
}
}
return {
positions: document.createAccessor("positions").setType("VEC3").setArray(positions).setBuffer(buffer),
texcoords: document.createAccessor("texcoords").setType("VEC2").setArray(texcoords).setBuffer(buffer),
indices: document.createAccessor("indices").setType("SCALAR").setArray(indices).setBuffer(buffer),
};
}
function geometryUvStats(document) {
let triangles = 0;
let degenerateUvTriangles = 0;
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const texcoords = primitive.getAttribute("TEXCOORD_0")?.getArray();
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!texcoords || !position) continue;
const indexCount = indices?.length ?? position.getCount();
triangles += indexCount / 3;
for (let offset = 0; offset < indexCount; offset += 3) {
const ia = indices?.[offset] ?? offset;
const ib = indices?.[offset + 1] ?? offset + 1;
const ic = indices?.[offset + 2] ?? offset + 2;
const a = ia * 2;
const b = ib * 2;
const c = ic * 2;
const area = (texcoords[b] - texcoords[a]) * (texcoords[c + 1] - texcoords[a + 1])
- (texcoords[c] - texcoords[a]) * (texcoords[b + 1] - texcoords[a + 1]);
if (Math.abs(area) < 1e-8) degenerateUvTriangles += 1;
}
}
}
return { triangles, degenerateUvTriangles };
}
function precisionFixture(document, buffer) {
const positions = new Float32Array([
0, 0, 0, 1, 0, 0, 0, 1, 0,
2, 0, 0, 3, 0, 0, 2, 1, 0,
]);
const texcoords = new Float32Array([
0, 0, 1, 0, 0, 1,
0.3141, 0.1, 0.31415, 0.1, 0.3141, 0.9,
]);
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("precision-positions").setType("VEC3").setArray(positions).setBuffer(buffer))
.setAttribute("TEXCOORD_0", document.createAccessor("precision-texcoords").setType("VEC2").setArray(texcoords).setBuffer(buffer))
.setIndices(document.createAccessor("precision-indices").setType("SCALAR").setArray(new Uint16Array([0, 1, 2, 3, 4, 5])).setBuffer(buffer));
const scene = document.createScene("precision-scene").addChild(
document.createNode("precision-mesh").setMesh(document.createMesh("precision-mesh").addPrimitive(primitive)),
);
document.getRoot().setDefaultScene(scene);
}
test("Utgarde Pinnacle's recipe opts into spatial boundary preservation", async () => {
const recipe = await loadRecipe("utgarde-pinnacle");
assert.equal(recipe.visualOptimization?.preserveSpatialBoundaries, true);
const args = dungeonEnvironmentOptimizationArguments("source.glb", "optimized.glb", {
preserveSpatialBoundaries: recipe.visualOptimization.preserveSpatialBoundaries,
});
assert.equal(args[args.indexOf("--instance") + 1], "false");
assert.equal(args[args.indexOf("--flatten") + 1], "false");
assert.equal(args[args.indexOf("--join") + 1], "false");
assert.equal(args[args.indexOf("--weld") + 1], "false");
});
test("WoW visual optimization preserves source texture dimensions and UV topology", async (context) => {
const width = 4096;
const height = 8;
const gridSegments = 24;
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "wow-visual-quality-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const sourceFile = path.join(workingDirectory, "source.glb");
const outputFile = path.join(workingDirectory, "optimized.glb");
const simplifiedControlFile = path.join(workingDirectory, "simplified-control.glb");
const pixels = Buffer.alloc(width * height * 4);
for (let pixel = 0; pixel < width * height; pixel += 1) {
pixels[pixel * 4] = pixel % 256;
pixels[pixel * 4 + 1] = (pixel * 17) % 256;
pixels[pixel * 4 + 2] = (pixel * 31) % 256;
pixels[pixel * 4 + 3] = 255;
}
const sourceImage = await sharp(pixels, { raw: { width, height, channels: 4 } }).png().toBuffer();
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = new Document();
const buffer = document.createBuffer();
const texture = document.createTexture("wall-atlas").setMimeType("image/png").setImage(sourceImage);
const material = document.createMaterial("wall").setBaseColorTexture(texture);
const geometry = atlasGridGeometry(document, buffer, gridSegments);
const primitive = document.createPrimitive()
.setAttribute("POSITION", geometry.positions)
.setAttribute("TEXCOORD_0", geometry.texcoords)
.setIndices(geometry.indices)
.setMaterial(material);
const mesh = document.createMesh("wall").addPrimitive(primitive);
const scene = document.createScene("scene").addChild(document.createNode("wall").setMesh(mesh));
document.getRoot().setDefaultScene(scene);
await io.write(sourceFile, document);
const args = dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
assert.deepEqual(args.slice(3), [
"--compress", "false", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--flatten", "true", "--join", "true", "--weld", "true",
"--simplify", "false", "--texture-compress", "false",
]);
assert.deepEqual(
dungeonEnvironmentOptimizationArguments(sourceFile, outputFile, { preserveSpatialBoundaries: true }).slice(3),
[
"--compress", "false", "--meshopt-level", "high",
"--instance", "false", "--instance-min", "3",
"--flatten", "false", "--join", "false", "--weld", "false",
"--simplify", "false", "--texture-compress", "false",
],
"a recipe can opt out of global instancing and geometry regrouping while retaining the common non-lossy preparation steps",
);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const optimized = await io.read(outputFile);
const optimizedStats = geometryUvStats(optimized);
assert.deepEqual(optimizedStats, {
triangles: gridSegments * gridSegments * 2,
degenerateUvTriangles: 0,
}, "atlas geometry and source UV seams must survive optimize intact");
const outputTexture = optimized.getRoot().listTextures()[0];
assert.deepEqual(await sharp(outputTexture.getImage()).metadata().then(({ width: w, height: h }) => [w, h]), [width, height]);
const compression = spawnSync(process.execPath, [ktx2Compressor, outputFile, "--concurrency", "1", "--jobs", "2"], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(compression.status, 0, `${compression.stdout}\n${compression.stderr}`);
const packedFile = path.join(workingDirectory, "meshopt-final.glb");
const finalPackingArgs = dungeonEnvironmentFinalPackingArguments(outputFile, packedFile);
assert.deepEqual(finalPackingArgs.slice(3), [
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", "16",
]);
assert.equal(finalPackingArgs[0], "meshopt");
const finalPacking = spawnSync(process.execPath, [gltfTransformCli, ...finalPackingArgs], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(finalPacking.status, 0, `${finalPacking.stdout}\n${finalPacking.stderr}`);
const packed = await readGlb(packedFile);
assert.ok(packed.json.extensionsUsed?.includes("EXT_meshopt_compression"),
"the final pack must restore EXT_meshopt_compression after KTX2 processing");
assert.ok(packed.json.bufferViews?.some((view) => view.extensions?.EXT_meshopt_compression?.byteLength > 0),
"the final GLB must contain compressed meshopt buffer views");
const ktxImage = packed.json.images?.find((image) => image.mimeType === "image/ktx2");
assert.ok(ktxImage, "the source color texture must remain encoded as KTX2");
const ktxView = packed.json.bufferViews[ktxImage.bufferView];
const ktxBytes = packed.binary.subarray(ktxView.byteOffset ?? 0, (ktxView.byteOffset ?? 0) + ktxView.byteLength);
assert.deepEqual([ktxBytes.readUInt32LE(20), ktxBytes.readUInt32LE(24)], [width, height],
"the final meshopt pass must retain KTX2 source dimensions");
const simplifyOption = args.indexOf("--simplify");
const simplifiedControlArgs = [...args];
simplifiedControlArgs[2] = simplifiedControlFile;
simplifiedControlArgs[simplifyOption + 1] = "true";
const simplifiedControl = spawnSync(process.execPath, [gltfTransformCli, ...simplifiedControlArgs], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(simplifiedControl.status, 0, `${simplifiedControl.stdout}\n${simplifiedControl.stderr}`);
const simplifiedStats = geometryUvStats(await io.read(simplifiedControlFile));
assert.ok(
simplifiedStats.triangles < optimizedStats.triangles || simplifiedStats.degenerateUvTriangles > 0,
"the atlas fixture must detect topology loss or UV collapse when simplification is enabled",
);
const precisionSource = path.join(workingDirectory, "precision-source.glb");
const precision12File = path.join(workingDirectory, "precision-12bit.glb");
const precision16File = path.join(workingDirectory, "precision-16bit.glb");
const precisionDocument = new Document();
precisionFixture(precisionDocument, precisionDocument.createBuffer());
await io.write(precisionSource, precisionDocument);
const precisionSourceStats = geometryUvStats(await io.read(precisionSource));
assert.deepEqual(precisionSourceStats, { triangles: 2, degenerateUvTriangles: 0 });
for (const [bits, targetFile] of [[12, precision12File], [16, precision16File]]) {
const precisionRun = spawnSync(process.execPath, [gltfTransformCli,
"meshopt", precisionSource, targetFile,
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", String(bits)], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(precisionRun.status, 0, `${precisionRun.stdout}\n${precisionRun.stderr}`);
}
const lowPrecisionStats = geometryUvStats(await io.read(precision12File));
const highPrecisionStats = geometryUvStats(await io.read(precision16File));
assert.equal(lowPrecisionStats.degenerateUvTriangles, 1,
"12-bit atlas quantization must collapse the narrowly separated fixture UVs");
assert.equal(highPrecisionStats.degenerateUvTriangles, 0,
"16-bit atlas quantization must preserve the same non-zero UV triangle");
});