forked from phenom/healer-man
272 lines
13 KiB
JavaScript
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");
|
|
});
|