881 lines
46 KiB
JavaScript
881 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
|
import { gunzipSync } from "node:zlib";
|
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import sharp from "sharp";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
import {
|
|
mappingMetrics,
|
|
nodeInstanceTransforms,
|
|
transformInstancedPoint,
|
|
triangleArea,
|
|
} from "../runewaker-pipeline/audit-environment-quality.mjs";
|
|
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(scriptDirectory, "../..");
|
|
const campaignCatalogPath = path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.compressed.ts");
|
|
const publicRoot = path.join(projectRoot, "public");
|
|
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK
|
|
?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
|
|
const defaultJsonPath = path.join(projectRoot, "artifacts/dungeon-pipeline/wow-visual-quality-audit.json");
|
|
const defaultMarkdownPath = path.join(projectRoot, "artifacts/dungeon-pipeline/wow-visual-quality-audit.md");
|
|
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const WRAP_NAMES = new Map([[10497, "repeat"], [33071, "clamp-to-edge"], [33648, "mirrored-repeat"]]);
|
|
const MIN_FILTER_NAMES = new Map([[9728, "nearest"], [9729, "linear"], [9984, "nearest-mipmap-nearest"], [9985, "linear-mipmap-nearest"], [9986, "nearest-mipmap-linear"], [9987, "linear-mipmap-linear"]]);
|
|
const MAG_FILTER_NAMES = new Map([[9728, "nearest"], [9729, "linear"]]);
|
|
|
|
function projectPath(file) {
|
|
return path.relative(projectRoot, file).split(path.sep).join("/");
|
|
}
|
|
|
|
function round(value, digits = 3) {
|
|
if (!Number.isFinite(value)) return null;
|
|
const factor = 10 ** digits;
|
|
return Math.round(value * factor) / factor;
|
|
}
|
|
|
|
async function exists(file) {
|
|
try {
|
|
return (await stat(file)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJson(file) {
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
}
|
|
|
|
function parseGlb(buffer, label) {
|
|
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF"
|
|
|| buffer.readUInt32LE(4) !== 2 || buffer.readUInt32LE(8) > buffer.length) {
|
|
throw new Error(`${label} is not a valid binary glTF 2.0 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 dataStart = offset + 8;
|
|
const dataEnd = dataStart + length;
|
|
if (dataEnd > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`);
|
|
const chunk = buffer.subarray(dataStart, dataEnd);
|
|
if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim());
|
|
if (type === 0x004e4942) binary = chunk;
|
|
offset = dataEnd;
|
|
}
|
|
if (!json || !binary) throw new Error(`${label} must contain JSON and BIN chunks.`);
|
|
return { json, binary };
|
|
}
|
|
|
|
function canonicalImageName(value) {
|
|
const basename = path.posix.basename(String(value ?? "").replaceAll("\\", "/"));
|
|
return basename.replace(/\.[^.]+$/u, "").replace(/^[0-9a-f]{8}_/iu, "").toLowerCase();
|
|
}
|
|
|
|
function parseKtx2(bytes) {
|
|
if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) return null;
|
|
const vkFormat = bytes.readUInt32LE(12);
|
|
const supercompressionScheme = bytes.readUInt32LE(44);
|
|
return {
|
|
width: bytes.readUInt32LE(20),
|
|
height: bytes.readUInt32LE(24),
|
|
mipLevels: Math.max(1, bytes.readUInt32LE(40)),
|
|
vkFormat,
|
|
supercompressionScheme,
|
|
codec: vkFormat === 0 && supercompressionScheme === 1
|
|
? "ETC1S/BasisLZ"
|
|
: vkFormat === 0 && supercompressionScheme === 2
|
|
? "UASTC/Zstandard"
|
|
: "KTX2-other",
|
|
byteLength: bytes.length,
|
|
};
|
|
}
|
|
|
|
function imageBytes(parsed, image, label) {
|
|
if (image.bufferView === undefined) return null;
|
|
const view = parsed.json.bufferViews?.[image.bufferView];
|
|
if (!view) throw new Error(`${label} references missing image bufferView ${image.bufferView}.`);
|
|
const start = view.byteOffset ?? 0;
|
|
const end = start + view.byteLength;
|
|
if (end > parsed.binary.length) throw new Error(`${label} image bufferView exceeds the GLB BIN chunk.`);
|
|
return parsed.binary.subarray(start, end);
|
|
}
|
|
|
|
async function inspectImage(parsed, image, imageIndex, label) {
|
|
const bytes = imageBytes(parsed, image, `${label} image ${image.name ?? imageIndex}`);
|
|
if (!bytes) return {
|
|
imageIndex,
|
|
name: image.name ?? image.uri ?? `image-${imageIndex}`,
|
|
key: canonicalImageName(image.name ?? image.uri),
|
|
mimeType: image.mimeType ?? null,
|
|
width: null,
|
|
height: null,
|
|
codec: "external-or-missing",
|
|
mipLevels: null,
|
|
byteLength: null,
|
|
};
|
|
const ktx = image.mimeType === "image/ktx2" ? parseKtx2(bytes) : null;
|
|
if (image.mimeType === "image/ktx2" && !ktx) {
|
|
throw new Error(`${label} image ${image.name ?? imageIndex} declares KTX2 but has an invalid header.`);
|
|
}
|
|
if (ktx) return {
|
|
imageIndex,
|
|
name: image.name ?? image.uri ?? `image-${imageIndex}`,
|
|
key: canonicalImageName(image.name ?? image.uri),
|
|
mimeType: image.mimeType,
|
|
...ktx,
|
|
};
|
|
const dimensions = await sharp(bytes).metadata().catch(() => ({}));
|
|
return {
|
|
imageIndex,
|
|
name: image.name ?? image.uri ?? `image-${imageIndex}`,
|
|
key: canonicalImageName(image.name ?? image.uri),
|
|
mimeType: image.mimeType ?? null,
|
|
width: dimensions.width ?? null,
|
|
height: dimensions.height ?? null,
|
|
codec: "non-KTX2 image",
|
|
mipLevels: null,
|
|
byteLength: bytes.length,
|
|
};
|
|
}
|
|
|
|
function textureImageIndex(texture) {
|
|
return texture.extensions?.KHR_texture_basisu?.source ?? texture.source;
|
|
}
|
|
|
|
function textureRoles(json) {
|
|
const roles = new Map();
|
|
const baseColorInfos = [];
|
|
const add = (index, role) => {
|
|
if (!Number.isInteger(index)) return;
|
|
const entry = roles.get(index) ?? new Set();
|
|
entry.add(role);
|
|
roles.set(index, entry);
|
|
};
|
|
for (const [materialIndex, material] of (json.materials ?? []).entries()) {
|
|
const pbr = material.pbrMetallicRoughness;
|
|
const base = pbr?.baseColorTexture;
|
|
if (base) {
|
|
add(base.index, "baseColor");
|
|
const transform = base.extensions?.KHR_texture_transform;
|
|
baseColorInfos.push({
|
|
materialIndex,
|
|
textureIndex: base.index,
|
|
texCoord: transform?.texCoord ?? base.texCoord ?? 0,
|
|
transform,
|
|
});
|
|
}
|
|
add(pbr?.metallicRoughnessTexture?.index, "metallicRoughness");
|
|
add(material.normalTexture?.index, "normal");
|
|
add(material.occlusionTexture?.index, "occlusion");
|
|
add(material.emissiveTexture?.index, "emissive");
|
|
}
|
|
return { roles, baseColorInfos };
|
|
}
|
|
|
|
function textureSamplerSummary(json, textureIndex) {
|
|
const samplerIndex = json.textures?.[textureIndex]?.sampler;
|
|
const sampler = Number.isInteger(samplerIndex) ? json.samplers?.[samplerIndex] ?? {} : {};
|
|
const minFilter = sampler.minFilter ?? null;
|
|
const magFilter = sampler.magFilter ?? null;
|
|
const wrapS = sampler.wrapS ?? 10497;
|
|
const wrapT = sampler.wrapT ?? 10497;
|
|
return {
|
|
minFilter: minFilter === null ? "default" : (MIN_FILTER_NAMES.get(minFilter) ?? String(minFilter)),
|
|
magFilter: magFilter === null ? "default" : (MAG_FILTER_NAMES.get(magFilter) ?? String(magFilter)),
|
|
wrapS: WRAP_NAMES.get(wrapS) ?? String(wrapS),
|
|
wrapT: WRAP_NAMES.get(wrapT) ?? String(wrapT),
|
|
explicitSampler: Number.isInteger(samplerIndex),
|
|
};
|
|
}
|
|
|
|
async function auditGlb(file, label) {
|
|
const bytes = await readFile(file);
|
|
const parsed = parseGlb(bytes, label);
|
|
const [images, fileStats] = await Promise.all([
|
|
Promise.all((parsed.json.images ?? []).map((image, index) => inspectImage(parsed, image, index, label))),
|
|
stat(file),
|
|
]);
|
|
const { roles, baseColorInfos } = textureRoles(parsed.json);
|
|
const textures = (parsed.json.textures ?? []).map((texture, textureIndex) => {
|
|
const imageIndex = textureImageIndex(texture);
|
|
const image = images[imageIndex];
|
|
const textureRolesForImage = [...(roles.get(textureIndex) ?? [])].sort();
|
|
return {
|
|
textureIndex,
|
|
name: texture.name ?? image?.name ?? `texture-${textureIndex}`,
|
|
imageIndex: imageIndex ?? null,
|
|
imageName: image?.name ?? null,
|
|
imageKey: image?.key ?? "",
|
|
width: image?.width ?? null,
|
|
height: image?.height ?? null,
|
|
mimeType: image?.mimeType ?? null,
|
|
codec: image?.codec ?? "missing-image",
|
|
mipLevels: image?.mipLevels ?? null,
|
|
roles: textureRolesForImage,
|
|
sampler: textureSamplerSummary(parsed.json, textureIndex),
|
|
};
|
|
});
|
|
const codecs = {};
|
|
for (const texture of textures) {
|
|
const role = texture.roles.includes("baseColor") ? "baseColor" : "nonBaseColor";
|
|
const key = `${role}:${texture.codec}`;
|
|
codecs[key] = (codecs[key] ?? 0) + 1;
|
|
}
|
|
const samplerModes = {};
|
|
for (const base of baseColorInfos) {
|
|
const sampler = textureSamplerSummary(parsed.json, base.textureIndex);
|
|
const key = `${sampler.minFilter}/${sampler.magFilter}/${sampler.wrapS}/${sampler.wrapT}`;
|
|
samplerModes[key] = (samplerModes[key] ?? 0) + 1;
|
|
}
|
|
const uvSets = {};
|
|
let singularTextureTransforms = 0;
|
|
for (const info of baseColorInfos) {
|
|
uvSets[info.texCoord] = (uvSets[info.texCoord] ?? 0) + 1;
|
|
const scale = info.transform?.scale ?? [1, 1];
|
|
if (Math.abs((scale[0] ?? 1) * (scale[1] ?? 1)) <= 1e-12) singularTextureTransforms += 1;
|
|
}
|
|
const imageRows = images.map((image) => ({
|
|
...image,
|
|
roles: [...new Set(textures.filter((texture) => texture.imageIndex === image.imageIndex)
|
|
.flatMap((texture) => texture.roles))].sort(),
|
|
}));
|
|
return {
|
|
file: projectPath(file),
|
|
bytes: fileStats.size,
|
|
imageCount: images.length,
|
|
textureCount: textures.length,
|
|
baseColorTextureCount: baseColorInfos.length,
|
|
ktx2ImageCount: images.filter((image) => image.codec.startsWith("ETC1S") || image.codec.startsWith("UASTC") || image.codec === "KTX2-other").length,
|
|
fallbackImageCount: images.filter((image) => image.codec === "non-KTX2 image" || image.codec === "external-or-missing").length,
|
|
codecs,
|
|
samplerModes,
|
|
baseColorUvSets: uvSets,
|
|
singularTextureTransforms,
|
|
images: imageRows,
|
|
textures,
|
|
};
|
|
}
|
|
|
|
function applyTextureTransform(uv, transform) {
|
|
if (!transform) return uv;
|
|
const scale = transform.getScale();
|
|
const offset = transform.getOffset();
|
|
const rotation = transform.getRotation();
|
|
const x = uv[0] * scale[0];
|
|
const y = uv[1] * scale[1];
|
|
const cosine = Math.cos(rotation);
|
|
const sine = Math.sin(rotation);
|
|
return [offset[0] + cosine * x - sine * y, offset[1] + sine * x + cosine * y];
|
|
}
|
|
|
|
function cross(a, b) {
|
|
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
}
|
|
|
|
function subtract(a, b) {
|
|
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
}
|
|
|
|
function emptySurfaceMetrics() {
|
|
return {
|
|
triangleCount: 0,
|
|
uvMissingTriangles: 0,
|
|
uvDegenerateTriangles: 0,
|
|
rankCollapsedTriangles: 0,
|
|
worldAreaM2: 0,
|
|
uvDegenerateAreaM2: 0,
|
|
rankCollapsedAreaM2: 0,
|
|
};
|
|
}
|
|
|
|
function textureSizeByIndex(json, images) {
|
|
return (json.textures ?? []).map((texture) => {
|
|
const imageIndex = textureImageIndex(texture);
|
|
const image = images[imageIndex];
|
|
return image?.width && image?.height ? [image.width, image.height] : null;
|
|
});
|
|
}
|
|
|
|
export async function auditGeometry(file, textureSizes, label = path.basename(file), options = {}) {
|
|
const hotspotCellSizeM = Number(options.hotspotCellSizeM);
|
|
const collectHotspots = Number.isFinite(hotspotCellSizeM) && hotspotCellSizeM > 0;
|
|
const io = new NodeIO()
|
|
.registerExtensions(ALL_EXTENSIONS)
|
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
|
const document = await io.read(file);
|
|
const totals = {
|
|
triangleCount: 0,
|
|
uvMissingTriangles: 0,
|
|
uvDegenerateTriangles: 0,
|
|
rankCollapsedTriangles: 0,
|
|
worldAreaM2: 0,
|
|
uvDegenerateAreaM2: 0,
|
|
rankCollapsedAreaM2: 0,
|
|
};
|
|
const materialCoverage = {
|
|
totalTriangles: 0,
|
|
baseColorTexturedTriangles: 0,
|
|
untexturedTriangles: 0,
|
|
baseColorTexturedAreaM2: 0,
|
|
untexturedAreaM2: 0,
|
|
untexturedMaterials: {},
|
|
};
|
|
const bySurface = { floor: emptySurfaceMetrics(), wall: emptySurfaceMetrics(), other: emptySurfaceMetrics() };
|
|
const hotspotCells = new Map();
|
|
for (const scene of document.getRoot().listScenes()) {
|
|
const sceneNodes = [];
|
|
const visit = (node) => {
|
|
sceneNodes.push(node);
|
|
for (const child of node.listChildren()) visit(child);
|
|
};
|
|
for (const node of scene.listChildren()) visit(node);
|
|
for (const node of sceneNodes) {
|
|
const mesh = node.getMesh();
|
|
if (!mesh) continue;
|
|
const worldMatrix = node.getWorldMatrix();
|
|
const instances = nodeInstanceTransforms(node);
|
|
for (const instance of instances) {
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
if (primitive.getMode() !== 4) continue;
|
|
const material = primitive.getMaterial();
|
|
const texture = material?.getBaseColorTexture();
|
|
const info = material?.getBaseColorTextureInfo();
|
|
const hasBaseColorTexture = Boolean(texture && info);
|
|
const textureIndex = document.getRoot().listTextures().indexOf(texture);
|
|
const size = textureSizes[textureIndex];
|
|
const texCoord = info?.getExtension("KHR_texture_transform")?.getTexCoord()
|
|
?? info?.getTexCoord()
|
|
?? 0;
|
|
const positions = primitive.getAttribute("POSITION");
|
|
const uvs = primitive.getAttribute(`TEXCOORD_${texCoord}`);
|
|
const indices = primitive.getIndices();
|
|
if (!positions) continue;
|
|
const indexCount = indices?.getCount() ?? positions.getCount();
|
|
if (indexCount % 3 !== 0) throw new Error(`${label}: non-triangle index count in ${node.getName() || "unnamed node"}.`);
|
|
for (let offset = 0; offset < indexCount; offset += 3) {
|
|
const vertexIndices = [0, 1, 2].map((step) => indices?.getScalar(offset + step) ?? offset + step);
|
|
const points = vertexIndices.map((vertexIndex) => transformInstancedPoint(
|
|
worldMatrix,
|
|
positions.getElement(vertexIndex, [0, 0, 0]),
|
|
instance,
|
|
));
|
|
const area = triangleArea(points);
|
|
if (!(area > 1e-12)) continue;
|
|
materialCoverage.totalTriangles += 1;
|
|
if (hasBaseColorTexture) {
|
|
materialCoverage.baseColorTexturedTriangles += 1;
|
|
materialCoverage.baseColorTexturedAreaM2 += area;
|
|
} else {
|
|
materialCoverage.untexturedTriangles += 1;
|
|
materialCoverage.untexturedAreaM2 += area;
|
|
const name = material?.getName() || "(no material)";
|
|
const row = materialCoverage.untexturedMaterials[name] ?? {
|
|
triangles: 0,
|
|
worldAreaM2: 0,
|
|
baseColorFactor: material?.getBaseColorFactor() ?? null,
|
|
};
|
|
row.triangles += 1;
|
|
row.worldAreaM2 += area;
|
|
materialCoverage.untexturedMaterials[name] = row;
|
|
}
|
|
if (!hasBaseColorTexture) continue;
|
|
const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0]));
|
|
const normalLength = Math.hypot(...normal);
|
|
const normalY = normalLength > 0 ? normal[1] / normalLength : 0;
|
|
const surfaceKey = normalY >= 0.7 ? "floor" : Math.abs(normalY) <= 0.3 ? "wall" : "other";
|
|
const surface = bySurface[surfaceKey];
|
|
for (const metric of [totals, surface]) {
|
|
metric.triangleCount += 1;
|
|
metric.worldAreaM2 += area;
|
|
}
|
|
if (!uvs || !size) {
|
|
for (const metric of [totals, surface]) metric.uvMissingTriangles += 1;
|
|
continue;
|
|
}
|
|
const uvValues = vertexIndices.map((vertexIndex) => applyTextureTransform(
|
|
uvs.getElement(vertexIndex, [0, 0]),
|
|
info.getExtension("KHR_texture_transform"),
|
|
));
|
|
const mapping = mappingMetrics(points, uvValues, size[0], size[1]);
|
|
const uvDegenerate = !mapping || mapping.uvDoubleArea <= 1e-12;
|
|
const rankCollapsed = uvDegenerate || mapping.smallestStretchPxPerMeter <= 0.01;
|
|
if (uvDegenerate) {
|
|
for (const metric of [totals, surface]) {
|
|
metric.uvDegenerateTriangles += 1;
|
|
metric.rankCollapsedTriangles += 1;
|
|
metric.uvDegenerateAreaM2 += area;
|
|
metric.rankCollapsedAreaM2 += area;
|
|
}
|
|
} else if (rankCollapsed) {
|
|
for (const metric of [totals, surface]) {
|
|
metric.rankCollapsedTriangles += 1;
|
|
metric.rankCollapsedAreaM2 += area;
|
|
}
|
|
}
|
|
if (collectHotspots && rankCollapsed) {
|
|
const centroid = [0, 1, 2].map((axis) => points.reduce((sum, point) => sum + point[axis], 0) / 3);
|
|
const cellX = Math.floor(centroid[0] / hotspotCellSizeM);
|
|
const cellZ = Math.floor(centroid[2] / hotspotCellSizeM);
|
|
const key = `${cellX}:${cellZ}`;
|
|
const cell = hotspotCells.get(key) ?? {
|
|
cellX,
|
|
cellZ,
|
|
cellSizeM: hotspotCellSizeM,
|
|
uvDegenerateTriangles: 0,
|
|
rankCollapsedTriangles: 0,
|
|
uvDegenerateAreaM2: 0,
|
|
rankCollapsedAreaM2: 0,
|
|
centroidAreaWeight: 0,
|
|
centroidAreaSum: [0, 0, 0],
|
|
bySurface: {},
|
|
};
|
|
const surfaceCell = cell.bySurface[surfaceKey] ?? {
|
|
uvDegenerateTriangles: 0,
|
|
rankCollapsedTriangles: 0,
|
|
uvDegenerateAreaM2: 0,
|
|
rankCollapsedAreaM2: 0,
|
|
};
|
|
cell.rankCollapsedTriangles += 1;
|
|
cell.rankCollapsedAreaM2 += area;
|
|
surfaceCell.rankCollapsedTriangles += 1;
|
|
surfaceCell.rankCollapsedAreaM2 += area;
|
|
if (uvDegenerate) {
|
|
cell.uvDegenerateTriangles += 1;
|
|
cell.uvDegenerateAreaM2 += area;
|
|
surfaceCell.uvDegenerateTriangles += 1;
|
|
surfaceCell.uvDegenerateAreaM2 += area;
|
|
}
|
|
cell.centroidAreaWeight += area;
|
|
for (let axis = 0; axis < 3; axis += 1) cell.centroidAreaSum[axis] += centroid[axis] * area;
|
|
cell.bySurface[surfaceKey] = surfaceCell;
|
|
hotspotCells.set(key, cell);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const metric of [totals, ...Object.values(bySurface)]) {
|
|
metric.worldAreaM2 = round(metric.worldAreaM2, 2);
|
|
metric.uvDegenerateAreaM2 = round(metric.uvDegenerateAreaM2, 2);
|
|
metric.rankCollapsedAreaM2 = round(metric.rankCollapsedAreaM2, 2);
|
|
}
|
|
materialCoverage.baseColorTexturedAreaM2 = round(materialCoverage.baseColorTexturedAreaM2, 2);
|
|
materialCoverage.untexturedAreaM2 = round(materialCoverage.untexturedAreaM2, 2);
|
|
for (const material of Object.values(materialCoverage.untexturedMaterials)) {
|
|
material.worldAreaM2 = round(material.worldAreaM2, 2);
|
|
}
|
|
const uvHotspots = collectHotspots
|
|
? [...hotspotCells.values()]
|
|
.map((cell) => ({
|
|
cellX: cell.cellX,
|
|
cellZ: cell.cellZ,
|
|
cellSizeM: cell.cellSizeM,
|
|
center: [
|
|
round((cell.cellX + 0.5) * cell.cellSizeM, 2),
|
|
round(cell.centroidAreaSum[1] / cell.centroidAreaWeight, 2),
|
|
round((cell.cellZ + 0.5) * cell.cellSizeM, 2),
|
|
],
|
|
uvDegenerateTriangles: cell.uvDegenerateTriangles,
|
|
rankCollapsedTriangles: cell.rankCollapsedTriangles,
|
|
uvDegenerateAreaM2: round(cell.uvDegenerateAreaM2, 2),
|
|
rankCollapsedAreaM2: round(cell.rankCollapsedAreaM2, 2),
|
|
bySurface: Object.fromEntries(Object.entries(cell.bySurface).map(([surface, metric]) => [surface, {
|
|
uvDegenerateTriangles: metric.uvDegenerateTriangles,
|
|
rankCollapsedTriangles: metric.rankCollapsedTriangles,
|
|
uvDegenerateAreaM2: round(metric.uvDegenerateAreaM2, 2),
|
|
rankCollapsedAreaM2: round(metric.rankCollapsedAreaM2, 2),
|
|
}])),
|
|
}))
|
|
.sort((left, right) => right.rankCollapsedAreaM2 - left.rankCollapsedAreaM2 || left.cellX - right.cellX || left.cellZ - right.cellZ)
|
|
: undefined;
|
|
return { ...totals, bySurface, materialCoverage, ...(uvHotspots ? { uvHotspots } : {}) };
|
|
}
|
|
|
|
function compareImages(source, shipped) {
|
|
const sourceByKey = new Map();
|
|
for (const image of source.images) {
|
|
if (!image.width || !image.height || !image.key) continue;
|
|
const matches = sourceByKey.get(image.key) ?? [];
|
|
matches.push(image);
|
|
sourceByKey.set(image.key, matches);
|
|
}
|
|
const rows = [];
|
|
for (const image of shipped.images) {
|
|
const candidates = sourceByKey.get(image.key) ?? [];
|
|
const sourceImage = candidates.find((candidate) => candidate.width === image.width && candidate.height === image.height)
|
|
?? candidates[0]
|
|
?? null;
|
|
rows.push({
|
|
name: image.name,
|
|
key: image.key,
|
|
sourceWidth: sourceImage?.width ?? null,
|
|
sourceHeight: sourceImage?.height ?? null,
|
|
shippedWidth: image.width,
|
|
shippedHeight: image.height,
|
|
matched: Boolean(sourceImage),
|
|
dimensionsRetained: Boolean(sourceImage && image.width === sourceImage.width && image.height === sourceImage.height),
|
|
downsized: Boolean(sourceImage && image.width && image.height
|
|
&& (image.width < sourceImage.width || image.height < sourceImage.height)),
|
|
capHitAt2048: Boolean(sourceImage && Math.max(sourceImage.width, sourceImage.height) > 2048
|
|
&& Math.max(image.width ?? 0, image.height ?? 0) === 2048),
|
|
ambiguousSourceName: candidates.length > 1,
|
|
});
|
|
}
|
|
return {
|
|
shippedImages: rows.length,
|
|
matchedImages: rows.filter((row) => row.matched).length,
|
|
dimensionsRetained: rows.filter((row) => row.dimensionsRetained).length,
|
|
downsizedImages: rows.filter((row) => row.downsized).length,
|
|
capHitsAt2048: rows.filter((row) => row.capHitAt2048).length,
|
|
unmatchedImages: rows.filter((row) => !row.matched).slice(0, 20),
|
|
downsizedExamples: rows.filter((row) => row.downsized).slice(0, 30),
|
|
ambiguousSourceNames: rows.filter((row) => row.ambiguousSourceName).length,
|
|
rows,
|
|
};
|
|
}
|
|
|
|
function delta(after, before, key) {
|
|
if (!after || !before) return null;
|
|
return round(after[key] - before[key], 2);
|
|
}
|
|
|
|
function compareUvHotspots(sourceCells = [], runtimeCells = []) {
|
|
const sourceByCell = new Map(sourceCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
|
|
const runtimeByCell = new Map(runtimeCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
|
|
const keys = new Set([...sourceByCell.keys(), ...runtimeByCell.keys()]);
|
|
return [...keys].map((key) => {
|
|
const source = sourceByCell.get(key);
|
|
const runtime = runtimeByCell.get(key);
|
|
const center = runtime?.center ?? source?.center;
|
|
const sourceArea = source?.rankCollapsedAreaM2 ?? 0;
|
|
const runtimeArea = runtime?.rankCollapsedAreaM2 ?? 0;
|
|
const sourceUvArea = source?.uvDegenerateAreaM2 ?? 0;
|
|
const runtimeUvArea = runtime?.uvDegenerateAreaM2 ?? 0;
|
|
return {
|
|
cellX: runtime?.cellX ?? source.cellX,
|
|
cellZ: runtime?.cellZ ?? source.cellZ,
|
|
cellSizeM: runtime?.cellSizeM ?? source.cellSizeM,
|
|
center,
|
|
rankCollapsedAreaM2Delta: round(runtimeArea - sourceArea, 2),
|
|
uvDegenerateAreaM2Delta: round(runtimeUvArea - sourceUvArea, 2),
|
|
rankCollapsedTrianglesDelta: (runtime?.rankCollapsedTriangles ?? 0) - (source?.rankCollapsedTriangles ?? 0),
|
|
uvDegenerateTrianglesDelta: (runtime?.uvDegenerateTriangles ?? 0) - (source?.uvDegenerateTriangles ?? 0),
|
|
sourceRankCollapsedAreaM2: sourceArea,
|
|
runtimeRankCollapsedAreaM2: runtimeArea,
|
|
sourceUvDegenerateAreaM2: sourceUvArea,
|
|
runtimeUvDegenerateAreaM2: runtimeUvArea,
|
|
};
|
|
})
|
|
.filter((cell) => cell.rankCollapsedAreaM2Delta > 0 || cell.uvDegenerateAreaM2Delta > 0)
|
|
.sort((left, right) => right.rankCollapsedAreaM2Delta - left.rankCollapsedAreaM2Delta
|
|
|| right.uvDegenerateAreaM2Delta - left.uvDegenerateAreaM2Delta)
|
|
.slice(0, 50);
|
|
}
|
|
|
|
function resolvePublishedPath(url) {
|
|
if (typeof url !== "string" || !url.startsWith("/assets/game/")) return null;
|
|
const file = path.resolve(publicRoot, url.replace(/^\//u, ""));
|
|
const relative = path.relative(publicRoot, file);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
return file;
|
|
}
|
|
|
|
async function findCanonicalSource(slug) {
|
|
const candidates = [
|
|
path.join(workRoot, slug, "staging", "environment", `${slug}-visual.glb`),
|
|
path.join(workRoot, slug, "staging", "environment", "conversion-report.json"),
|
|
];
|
|
const source = candidates[0];
|
|
if (await exists(source)) return { path: source, kind: "lossless-converted-source-glb", candidates };
|
|
return { path: null, kind: null, candidates };
|
|
}
|
|
|
|
async function readWowDefinitions() {
|
|
const source = await readFile(campaignCatalogPath, "utf8");
|
|
const base64 = source.match(/export default "([^"]+)"/u)?.[1];
|
|
if (!base64) throw new Error("Could not read generated WoW campaign catalog payload.");
|
|
const catalog = JSON.parse(gunzipSync(Buffer.from(base64, "base64")));
|
|
const definitions = (catalog.definitions ?? [])
|
|
.filter((definition) => definition.expansion !== "runewaker")
|
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
if (definitions.length !== 54) {
|
|
throw new Error(`Expected 54 WoW campaign definitions, found ${definitions.length}.`);
|
|
}
|
|
return definitions;
|
|
}
|
|
|
|
async function auditDungeon(definition, io, visualOverride = null, hotspotCellSizeM = null) {
|
|
const visualEntry = definition.assets?.visual?.[0];
|
|
const authoredRuntimePath = definition.id === "wailing-caverns"
|
|
? path.join(projectRoot, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb")
|
|
: null;
|
|
const publishedPath = visualOverride ?? authoredRuntimePath ?? resolvePublishedPath(visualEntry?.url);
|
|
const source = await findCanonicalSource(definition.id);
|
|
const packageManifest = path.join(publicRoot, "assets/game/dungeons", definition.id, "dungeon-pack.json");
|
|
const row = {
|
|
id: definition.id,
|
|
expansion: definition.expansion,
|
|
mapId: definition.mapId ?? null,
|
|
publishedUrl: visualOverride
|
|
? `audit override: ${projectPath(visualOverride)}`
|
|
: authoredRuntimePath ? "bundled runtime asset (authored Wailing Caverns visual)" : visualEntry?.url ?? null,
|
|
runtimeAssetKind: visualOverride ? "audit-override-candidate"
|
|
: authoredRuntimePath ? "bundled-authored-source-asset" : "public-generated-visual",
|
|
packageManifest: await exists(packageManifest) ? projectPath(packageManifest) : null,
|
|
sourceStatus: source.path ? "canonical-lossless-source-available" : "canonical-lossless-source-missing",
|
|
sourceFile: source.path ? projectPath(source.path) : null,
|
|
publishedStatus: publishedPath && await exists(publishedPath) ? "published-visual-found" : "published-visual-missing",
|
|
issues: [],
|
|
};
|
|
if (!publishedPath || !(await exists(publishedPath))) {
|
|
row.issues.push("Runtime visual asset does not resolve to a shipped GLB.");
|
|
return row;
|
|
}
|
|
try {
|
|
row.shipped = await auditGlb(publishedPath, `${definition.id} shipped`);
|
|
row.shippedGeometry = await auditGeometry(publishedPath,
|
|
textureSizeByIndex(parseGlb(await readFile(publishedPath), `${definition.id} shipped`).json, row.shipped.images),
|
|
`${definition.id} shipped`, { hotspotCellSizeM });
|
|
if (source.path) {
|
|
const sourceAudit = await auditGlb(source.path, `${definition.id} source`);
|
|
const sourceParsed = parseGlb(await readFile(source.path), `${definition.id} source`);
|
|
row.source = sourceAudit;
|
|
row.sourceGeometry = await auditGeometry(source.path, textureSizeByIndex(sourceParsed.json, sourceAudit.images), `${definition.id} source`, { hotspotCellSizeM });
|
|
row.textureComparison = compareImages(sourceAudit, row.shipped);
|
|
row.geometryComparison = {
|
|
triangleCountDelta: row.shippedGeometry.triangleCount - row.sourceGeometry.triangleCount,
|
|
uvMissingTriangleDelta: row.shippedGeometry.uvMissingTriangles - row.sourceGeometry.uvMissingTriangles,
|
|
uvDegenerateTriangleDelta: row.shippedGeometry.uvDegenerateTriangles - row.sourceGeometry.uvDegenerateTriangles,
|
|
rankCollapsedTriangleDelta: row.shippedGeometry.rankCollapsedTriangles - row.sourceGeometry.rankCollapsedTriangles,
|
|
worldAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "worldAreaM2"),
|
|
uvDegenerateAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "uvDegenerateAreaM2"),
|
|
rankCollapsedAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "rankCollapsedAreaM2"),
|
|
materialCoverage: {
|
|
totalTrianglesDelta: row.shippedGeometry.materialCoverage.totalTriangles - row.sourceGeometry.materialCoverage.totalTriangles,
|
|
baseColorTexturedTrianglesDelta: row.shippedGeometry.materialCoverage.baseColorTexturedTriangles - row.sourceGeometry.materialCoverage.baseColorTexturedTriangles,
|
|
untexturedTrianglesDelta: row.shippedGeometry.materialCoverage.untexturedTriangles - row.sourceGeometry.materialCoverage.untexturedTriangles,
|
|
baseColorTexturedAreaM2Delta: round(row.shippedGeometry.materialCoverage.baseColorTexturedAreaM2
|
|
- row.sourceGeometry.materialCoverage.baseColorTexturedAreaM2, 2),
|
|
untexturedAreaM2Delta: round(row.shippedGeometry.materialCoverage.untexturedAreaM2
|
|
- row.sourceGeometry.materialCoverage.untexturedAreaM2, 2),
|
|
},
|
|
bySurface: Object.fromEntries(["floor", "wall", "other"].map((surface) => {
|
|
const shippedMetric = row.shippedGeometry.bySurface[surface];
|
|
const sourceMetric = row.sourceGeometry.bySurface[surface];
|
|
return [surface, {
|
|
triangleCountDelta: shippedMetric.triangleCount - sourceMetric.triangleCount,
|
|
uvDegenerateTriangleDelta: shippedMetric.uvDegenerateTriangles - sourceMetric.uvDegenerateTriangles,
|
|
rankCollapsedTriangleDelta: shippedMetric.rankCollapsedTriangles - sourceMetric.rankCollapsedTriangles,
|
|
uvDegenerateAreaM2Delta: round(shippedMetric.uvDegenerateAreaM2 - sourceMetric.uvDegenerateAreaM2, 2),
|
|
rankCollapsedAreaM2Delta: round(shippedMetric.rankCollapsedAreaM2 - sourceMetric.rankCollapsedAreaM2, 2),
|
|
}];
|
|
})),
|
|
...(hotspotCellSizeM ? { uvHotspots: compareUvHotspots(row.sourceGeometry.uvHotspots, row.shippedGeometry.uvHotspots) } : {}),
|
|
};
|
|
if (row.textureComparison.downsizedImages) {
|
|
row.issues.push(`${row.textureComparison.downsizedImages} shipped images are smaller than their matched source dimensions.`);
|
|
}
|
|
if (row.shipped.fallbackImageCount) row.issues.push(`${row.shipped.fallbackImageCount} shipped images are not embedded KTX2.`);
|
|
if (row.geometryComparison.uvDegenerateTriangleDelta > 0) {
|
|
row.issues.push(`Shipped base-color geometry adds ${row.geometryComparison.uvDegenerateTriangleDelta} UV-degenerate triangles versus source.`);
|
|
}
|
|
if (row.geometryComparison.rankCollapsedTriangleDelta > 0) {
|
|
row.issues.push(`Shipped base-color geometry adds ${row.geometryComparison.rankCollapsedTriangleDelta} rank-collapsed triangles versus source.`);
|
|
}
|
|
} else if (row.shipped.fallbackImageCount) {
|
|
row.issues.push(`${row.shipped.fallbackImageCount} shipped images are not embedded KTX2.`);
|
|
}
|
|
} catch (error) {
|
|
row.status = "audit-error";
|
|
row.issues.push(error.message);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function sumBy(rows, getter) {
|
|
return rows.reduce((sum, row) => sum + getter(row), 0);
|
|
}
|
|
|
|
function markdownTable(rows) {
|
|
const lines = [
|
|
"| WoW dungeon | Type | Source | Texture match | Downsized | KTX2 color ETC1S | KTX2 data UASTC | UV triangles source → runtime | Degenerate UV Δ | Rank collapse Δ m² floor/wall | Untextured material area source → runtime m² |",
|
|
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
|
];
|
|
for (const row of rows) {
|
|
const comparison = row.textureComparison;
|
|
const geometry = row.geometryComparison;
|
|
const colorCount = row.shipped?.codecs?.["baseColor:ETC1S/BasisLZ"] ?? 0;
|
|
const dataCount = row.shipped?.codecs?.["nonBaseColor:UASTC/Zstandard"] ?? 0;
|
|
const sourceTriangles = row.sourceGeometry?.triangleCount;
|
|
const shippedTriangles = row.shippedGeometry?.triangleCount;
|
|
const floorDelta = geometry?.bySurface.floor.rankCollapsedAreaM2Delta;
|
|
const wallDelta = geometry?.bySurface.wall.rankCollapsedAreaM2Delta;
|
|
const untexturedSource = row.sourceGeometry?.materialCoverage.untexturedAreaM2;
|
|
const untexturedRuntime = row.shippedGeometry?.materialCoverage.untexturedAreaM2;
|
|
lines.push(`| ${row.id} | ${row.expansion} | ${row.source ? "available" : "missing"} | ${comparison ? `${comparison.matchedImages}/${comparison.shippedImages}` : "—"} | ${comparison?.downsizedImages ?? "—"} | ${colorCount} | ${dataCount} | ${sourceTriangles === undefined ? "—" : `${sourceTriangles.toLocaleString()} → ${shippedTriangles.toLocaleString()}`} | ${geometry?.uvDegenerateTriangleDelta ?? "—"} | ${floorDelta === undefined || wallDelta === undefined ? "—" : `${floorDelta} / ${wallDelta}`} | ${untexturedSource === undefined ? "—" : `${untexturedSource} → ${untexturedRuntime}`} |`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function makeMarkdown(report) {
|
|
const coverageSummary = report.selection.completeWoWCatalog
|
|
? `Coverage: **${report.summary.publishedVisuals}/${report.summary.definitionCount}** runtime visuals found; **${report.summary.canonicalSources}/${report.summary.definitionCount}** matching lossless converted source GLBs available; **${report.summary.auditedComparisons}** source-to-runtime comparisons completed.`
|
|
: `Selected audit: **${report.summary.publishedVisuals}** runtime visual(s) inspected (${report.selection.ids.join(", ")}); the full catalog contains 54. **${report.summary.canonicalSources}** canonical source(s) were compared.`;
|
|
const lines = [
|
|
"# WoW dungeon visual texture and UV quality audit",
|
|
"",
|
|
`Generated: ${report.generatedAt}`,
|
|
"",
|
|
coverageSummary,
|
|
"",
|
|
report.selection.completeWoWCatalog
|
|
? "Scope follows all 54 non-RoM definitions in the generated WoW campaign catalog, including the four fallback/custom definitions. Fifty-three runtime visuals resolve to public Manastorm GLBs; the registry replaces Wailing Caverns with its bundled authored visual at `src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb`. Source comparisons use the pipeline's pre-optimization converted visual GLB under `HealerMan-Storage/pipeline-work/dungeons/<id>/staging/environment/`. Wailing Caverns has no canonical staged source GLB in the configured work root, so its dimension and geometry deltas are unverified."
|
|
: `Scope is limited to the selected runtime visual(s). Canonical source comparisons use the pre-optimization converted GLB under the configured dungeon pipeline work root. ${report.selection.visualOverride ? "The runtime path was overridden for a staged candidate comparison." : ""}`,
|
|
"",
|
|
"The inventory records source and runtime image dimensions, KTX2 codec and mip levels, material texture roles, glTF sampler modes, base-color UV sets, and singular UV transforms. Material coverage also counts textured and untextured triangle area by material. Geometry comparisons expand scene-node and GPU-instance transforms and use the same per-triangle texture-to-world singular-value calculation as the RoM environment quality audit. A rank-collapsed triangle has a minimum mapped stretch at or below 0.01 px/m; degenerate UV triangles have zero mapped area. Positive source-to-runtime deltas are candidate regressions. Triangle totals include repeated GPU instances and all base-color textured surfaces, with floor/wall separation for rank deltas.",
|
|
"",
|
|
`Across ${report.summary.publishedVisuals} runtime visuals, ${report.summary.shippedImages} image entries were found: ${report.summary.shippedKtx2Images} KTX2 and ${report.summary.shippedFallbackImages} non-KTX2 or missing images. ${report.summary.matchedSourceImages}/${report.summary.shippedImagesWithSource} source-comparable runtime images matched a source basename; ${report.summary.downsizedImages} matched images are smaller than source and ${report.summary.capHitsAt2048} hit the old 2048-pixel cap.`,
|
|
"",
|
|
`Published KTX2 role counts: **${report.summary.baseColorEtc1s} base-color ETC1S**, **${report.summary.baseColorUastc} base-color UASTC**, **${report.summary.dataUastc} non-color UASTC**, **${report.summary.dataEtc1s} non-color ETC1S**. This reports codec selection and dimensions; it does not decode KTX2 pixels for PSNR/MAE. The run-time anisotropy option is applied by scene material preparation and is not serialized in GLB samplers.`,
|
|
"",
|
|
"## Per-dungeon results",
|
|
"",
|
|
markdownTable(report.dungeons),
|
|
"",
|
|
"## Texture sampling inventory",
|
|
"",
|
|
`Base-color sampler modes: ${Object.entries(report.summary.baseColorSamplerModes).map(([mode, count]) => `\`${mode}\` (${count})`).join(", ") || "none"}. Base-color UV-set usage: ${Object.entries(report.summary.baseColorUvSets).map(([set, count]) => `UV${set} (${count})`).join(", ") || "none"}. Singular texture transforms: ${report.summary.singularTextureTransforms}.`,
|
|
"",
|
|
"## Residuals and limits",
|
|
"",
|
|
`Source dimension matches are exact basename joins, not pixel identity checks. ${report.summary.unmatchedSourceImages} shipped image names did not match a source image. ${report.summary.sourceDimensionComparisons} dimension comparisons were available. Numeric KTX2 pixel fidelity and visual review are not included in this report.`,
|
|
"",
|
|
"The generated source GLBs are the reviewed conversion outputs before the dungeon optimizer. They establish regeneration inputs for those packages; source `.blp`/`.dds` client archives are not needed for reoptimization. Missing-source entries require a source-export recovery before any shipped rewrite can be considered proven safe. Wailing Caverns' bundled runtime GLB can be inventoried but lacks a canonical comparison source.",
|
|
];
|
|
if (report.summary.positiveUvDegenerateRegressions || report.summary.positiveRankCollapseRegressions) {
|
|
lines.push("", `Source comparison flagged ${report.summary.positiveUvDegenerateRegressions} packages with more UV-degenerate area and ${report.summary.positiveRankCollapseRegressions} packages with more rank-collapsed area in runtime output. Review those rows and their source/runtime hotspot details before regeneration.`);
|
|
} else {
|
|
lines.push("", "No source-to-runtime increase in UV-degenerate or rank-collapsed area was found in the matched source comparisons. This supports preserving current packages while making the optimizer safe for future builds.");
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
async function main() {
|
|
const jsonArg = process.argv.indexOf("--report-json");
|
|
const markdownArg = process.argv.indexOf("--report-markdown");
|
|
const reportJsonPath = path.resolve(jsonArg >= 0 ? process.argv[jsonArg + 1] : defaultJsonPath);
|
|
const reportMarkdownPath = path.resolve(markdownArg >= 0 ? process.argv[markdownArg + 1] : defaultMarkdownPath);
|
|
const allDefinitions = await readWowDefinitions();
|
|
const onlyArg = process.argv.indexOf("--only");
|
|
const onlyIds = onlyArg >= 0 ? process.argv[onlyArg + 1].split(",").map((id) => id.trim()).filter(Boolean) : null;
|
|
const overrideArg = process.argv.indexOf("--visual-override");
|
|
const overrideText = overrideArg >= 0 ? process.argv[overrideArg + 1] : null;
|
|
const overrideSeparator = overrideText?.indexOf("=") ?? -1;
|
|
const overrideId = overrideSeparator > 0 ? overrideText.slice(0, overrideSeparator) : null;
|
|
const visualOverride = overrideId ? path.resolve(projectRoot, overrideText.slice(overrideSeparator + 1)) : null;
|
|
if (overrideText && !visualOverride) throw new Error("--visual-override must be <dungeon-id>=<glb-path>.");
|
|
const hotspotArg = process.argv.indexOf("--hotspot-cell-m");
|
|
const hotspotCellSizeM = hotspotArg >= 0 ? Number(process.argv[hotspotArg + 1]) : null;
|
|
if (hotspotArg >= 0 && (!Number.isFinite(hotspotCellSizeM) || hotspotCellSizeM <= 0)) {
|
|
throw new Error("--hotspot-cell-m must be a positive world-space cell size in meters.");
|
|
}
|
|
const selectedIds = new Set([...(onlyIds ?? []), ...(overrideId ? [overrideId] : [])]);
|
|
const unknownIds = [...selectedIds].filter((id) => !allDefinitions.some((definition) => definition.id === id));
|
|
if (unknownIds.length) throw new Error(`Unknown WoW dungeon id(s): ${unknownIds.join(", ")}.`);
|
|
const definitions = allDefinitions.filter((definition) => !selectedIds.size || selectedIds.has(definition.id));
|
|
const io = new NodeIO()
|
|
.registerExtensions(ALL_EXTENSIONS)
|
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
|
const dungeons = [];
|
|
for (const [index, definition] of definitions.entries()) {
|
|
console.log(`[${index + 1}/${definitions.length}] auditing ${definition.id}`);
|
|
dungeons.push(await auditDungeon(definition, io, definition.id === overrideId ? visualOverride : null, hotspotCellSizeM));
|
|
}
|
|
const compared = dungeons.filter((dungeon) => dungeon.source && dungeon.shipped);
|
|
const shipped = dungeons.filter((dungeon) => dungeon.shipped);
|
|
const textureComparisons = compared.flatMap((dungeon) => dungeon.textureComparison.rows);
|
|
const surfaces = ["floor", "wall", "other"];
|
|
const summary = {
|
|
definitionCount: definitions.length,
|
|
publishedVisuals: shipped.length,
|
|
canonicalSources: dungeons.filter((dungeon) => dungeon.source).length,
|
|
auditedComparisons: compared.length,
|
|
packageManifests: dungeons.filter((dungeon) => dungeon.packageManifest).length,
|
|
missingPublishedVisuals: dungeons.filter((dungeon) => !dungeon.shipped).map((dungeon) => dungeon.id),
|
|
missingCanonicalSources: dungeons.filter((dungeon) => !dungeon.source).map((dungeon) => dungeon.id),
|
|
shippedImages: sumBy(shipped, (dungeon) => dungeon.shipped.imageCount),
|
|
shippedImagesWithSource: textureComparisons.length,
|
|
shippedKtx2Images: sumBy(shipped, (dungeon) => dungeon.shipped.ktx2ImageCount),
|
|
shippedFallbackImages: sumBy(shipped, (dungeon) => dungeon.shipped.fallbackImageCount),
|
|
matchedSourceImages: textureComparisons.filter((row) => row.matched).length,
|
|
sourceDimensionComparisons: textureComparisons.filter((row) => row.matched && row.sourceWidth && row.shippedWidth).length,
|
|
dimensionsRetained: textureComparisons.filter((row) => row.dimensionsRetained).length,
|
|
downsizedImages: textureComparisons.filter((row) => row.downsized).length,
|
|
capHitsAt2048: textureComparisons.filter((row) => row.capHitAt2048).length,
|
|
unmatchedSourceImages: textureComparisons.filter((row) => !row.matched).length,
|
|
positiveUvDegenerateRegressions: compared.filter((dungeon) => dungeon.geometryComparison.uvDegenerateAreaM2Delta > 1e-3).length,
|
|
positiveRankCollapseRegressions: compared.filter((dungeon) => dungeon.geometryComparison.rankCollapsedAreaM2Delta > 1e-3).length,
|
|
uvDegenerateTriangleDelta: sumBy(compared, (dungeon) => dungeon.geometryComparison.uvDegenerateTriangleDelta),
|
|
rankCollapsedTriangleDelta: sumBy(compared, (dungeon) => dungeon.geometryComparison.rankCollapsedTriangleDelta),
|
|
baseColorEtc1s: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["baseColor:ETC1S/BasisLZ"] ?? 0),
|
|
baseColorUastc: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["baseColor:UASTC/Zstandard"] ?? 0),
|
|
dataEtc1s: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["nonBaseColor:ETC1S/BasisLZ"] ?? 0),
|
|
dataUastc: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["nonBaseColor:UASTC/Zstandard"] ?? 0),
|
|
singularTextureTransforms: sumBy(shipped, (dungeon) => dungeon.shipped.singularTextureTransforms),
|
|
baseColorSamplerModes: {},
|
|
baseColorUvSets: {},
|
|
bySurface: Object.fromEntries(surfaces.map((surface) => [surface, {
|
|
sourceRankCollapsedAreaM2: round(sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].rankCollapsedAreaM2), 2),
|
|
shippedRankCollapsedAreaM2: round(sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].rankCollapsedAreaM2), 2),
|
|
sourceUvDegenerateAreaM2: round(sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].uvDegenerateAreaM2), 2),
|
|
shippedUvDegenerateAreaM2: round(sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].uvDegenerateAreaM2), 2),
|
|
sourceTriangles: sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].triangleCount),
|
|
shippedTriangles: sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].triangleCount),
|
|
}])),
|
|
};
|
|
for (const dungeon of shipped) {
|
|
for (const [mode, count] of Object.entries(dungeon.shipped.samplerModes)) {
|
|
summary.baseColorSamplerModes[mode] = (summary.baseColorSamplerModes[mode] ?? 0) + count;
|
|
}
|
|
for (const [uvSet, count] of Object.entries(dungeon.shipped.baseColorUvSets)) {
|
|
summary.baseColorUvSets[uvSet] = (summary.baseColorUvSets[uvSet] ?? 0) + count;
|
|
}
|
|
}
|
|
const report = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
sourceRoot: path.relative(projectRoot, workRoot).split(path.sep).join("/"),
|
|
shippedRoot: projectPath(publicRoot),
|
|
generatedBy: "scripts/dungeon-pipeline/audit-wow-visual-quality.mjs",
|
|
selection: {
|
|
completeWoWCatalog: definitions.length === allDefinitions.length,
|
|
ids: definitions.map((definition) => definition.id),
|
|
visualOverride: overrideId ? { dungeonId: overrideId, file: projectPath(visualOverride) } : null,
|
|
},
|
|
summary,
|
|
dungeons,
|
|
};
|
|
await mkdir(path.dirname(reportJsonPath), { recursive: true });
|
|
await mkdir(path.dirname(reportMarkdownPath), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
|
|
writeFile(reportMarkdownPath, makeMarkdown(report), "utf8"),
|
|
]);
|
|
console.log(JSON.stringify({
|
|
...summary,
|
|
json: path.relative(projectRoot, reportJsonPath).split(path.sep).join("/"),
|
|
markdown: path.relative(projectRoot, reportMarkdownPath).split(path.sep).join("/"),
|
|
}, null, 2));
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main();
|
|
}
|