216 lines
8.2 KiB
JavaScript
216 lines
8.2 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { deflateSync } from "node:zlib";
|
|
import test from "node:test";
|
|
import { Document, NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
import { environmentOptimizationArguments } from "./lib/environment-optimization.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");
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function pngChunk(type, data) {
|
|
const typeBytes = Buffer.from(type, "ascii");
|
|
const contents = Buffer.concat([typeBytes, data]);
|
|
const header = Buffer.alloc(4);
|
|
header.writeUInt32BE(data.length);
|
|
const checksum = Buffer.alloc(4);
|
|
checksum.writeUInt32BE(crc32(contents));
|
|
return Buffer.concat([header, contents, checksum]);
|
|
}
|
|
|
|
function makePng(width, height) {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width, 0);
|
|
header.writeUInt32BE(height, 4);
|
|
header[8] = 8;
|
|
header[9] = 6;
|
|
const row = Buffer.alloc(1 + width * 4);
|
|
const pixels = Buffer.alloc(width * 4);
|
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
pixels[offset] = (offset / 4) % 256;
|
|
pixels[offset + 1] = 135;
|
|
pixels[offset + 2] = 65;
|
|
pixels[offset + 3] = 255;
|
|
}
|
|
pixels.copy(row, 1);
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
return Buffer.concat([
|
|
signature,
|
|
pngChunk("IHDR", header),
|
|
pngChunk("IDAT", deflateSync(row)),
|
|
pngChunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function pngDimensions(bytes) {
|
|
bytes = Buffer.from(bytes);
|
|
assert.equal(bytes.toString("ascii", 1, 4), "PNG");
|
|
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
}
|
|
|
|
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 indices = primitive.getIndices()?.getArray();
|
|
if (!texcoords || !indices) continue;
|
|
triangles += indices.length / 3;
|
|
for (let offset = 0; offset < indices.length; offset += 3) {
|
|
const a = indices[offset] * 2;
|
|
const b = indices[offset + 1] * 2;
|
|
const c = indices[offset + 2] * 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 };
|
|
}
|
|
|
|
test("environment optimization preserves source texture dimensions and atlas UV topology", async (context) => {
|
|
const width = 4096;
|
|
const height = 1;
|
|
const gridSegments = 24;
|
|
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "runewaker-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 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(makePng(width, height));
|
|
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 source = await io.read(sourceFile);
|
|
assert.equal(source.getRoot().listTextures().length, 1);
|
|
assert.ok(source.getRoot().listMaterials()[0].getBaseColorTexture());
|
|
|
|
const args = environmentOptimizationArguments(sourceFile, outputFile);
|
|
assert.deepEqual(args.slice(0, 3), ["optimize", sourceFile, outputFile]);
|
|
assert.deepEqual(args.slice(3), [
|
|
"--compress", "meshopt", "--meshopt-level", "high",
|
|
"--instance", "true", "--instance-min", "3",
|
|
"--simplify", "false", "--texture-compress", "false",
|
|
]);
|
|
|
|
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-grid geometry and its source UV seams must survive optimize intact");
|
|
|
|
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,
|
|
"atlas-grid fixture must detect topology loss or UV collapse when simplification is enabled",
|
|
);
|
|
const textures = optimized.getRoot().listTextures();
|
|
assert.equal(textures.length, 1, JSON.stringify({
|
|
materials: optimized.getRoot().listMaterials().map((entry) => ({
|
|
name: entry.getName(),
|
|
texture: entry.getBaseColorTexture()?.getName() ?? null,
|
|
})),
|
|
}));
|
|
const outputTexture = textures[0];
|
|
assert.deepEqual(pngDimensions(outputTexture.getImage()), [width, height]);
|
|
});
|