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"); });