import { access, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Box3, Vector3 } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; import { dedup, meshopt, prune, resample } from "@gltf-transform/functions"; import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource"); const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb"); const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb"); const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb"); const requiredClips = [ "Armature|RiseUp", "Armature|Roar", "Armature|Walk", "Armature|Fall", "Gravehorn|Idle", ]; const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]); function sortedNames(properties) { return properties.map((property) => property.getName()).sort(); } function addUprightIdle(document) { const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk"); const buffer = document.getRoot().listBuffers()[0]; if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer."); const idle = document.createAnimation("Gravehorn|Idle"); for (const [index, sourceChannel] of walk.listChannels().entries()) { const sourceSampler = sourceChannel.getSampler(); const sourceOutput = sourceSampler?.getOutput(); const sourceValues = sourceOutput?.getArray(); const targetNode = sourceChannel.getTargetNode(); const targetPath = sourceChannel.getTargetPath(); if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) { throw new Error(`Gravehorn Walk channel ${index} is incomplete.`); } if (sourceSampler.getInterpolation() === "CUBICSPLINE") { throw new Error("Gravehorn upright idle builder does not support cubic animation tracks."); } const elementSize = sourceOutput.getElementSize(); const values = new sourceValues.constructor(elementSize * 2); values.set(sourceValues.subarray(0, elementSize), 0); values.set(sourceValues.subarray(0, elementSize), elementSize); const input = document.createAccessor(`gravehorn_idle_time_${index}`) .setType("SCALAR") .setArray(new Float32Array([0, 1])) .setBuffer(buffer); const output = document.createAccessor(`gravehorn_idle_value_${index}`) .setType(sourceOutput.getType()) .setNormalized(sourceOutput.getNormalized()) .setArray(values) .setBuffer(buffer); const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`) .setInput(input) .setOutput(output) .setInterpolation("LINEAR"); const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`) .setSampler(sampler) .setTargetNode(targetNode) .setTargetPath(targetPath); idle.addSampler(sampler).addChannel(channel); } } async function runtimeBounds(assetPath) { const data = await readFile(assetPath); const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); globalThis.self ??= globalThis; globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} }); const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, ""); gltf.scene.updateMatrixWorld(true); const bounds = new Box3().setFromObject(gltf.scene); return { min: bounds.min.toArray(), max: bounds.max.toArray(), center: bounds.getCenter(new Vector3()).toArray(), }; } function validateStructure(document, { optimized }) { const root = document.getRoot(); const material = root.listMaterials()[0]; const clips = sortedNames(root.listAnimations()); const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType()); const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName)); if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) { throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`); } if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) { throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin."); } if (root.listSkins()[0].listJoints().length !== 140) { throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`); } if (material.getDoubleSided()) { throw new Error("Gravehorn material must keep backface culling enabled."); } if (!extensionNames.has("EXT_meshopt_compression")) { throw new Error("Gravehorn is missing Meshopt compression."); } if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) { throw new Error("Optimized Gravehorn asset must contain only KTX2 textures."); } if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) { throw new Error("Legacy Gravehorn fallback must retain standard textures."); } } await access(sourcePath); await MeshoptDecoder.ready; await MeshoptEncoder.ready; const io = await createGameAssetIO(); const document = await io.read(sourcePath); const root = document.getRoot(); const scene = root.listScenes()[0].setName("gravehorn_triceratops"); const sceneRoot = scene.listChildren()[0]; const sourceBounds = await runtimeBounds(sourcePath); const sourceTranslation = sceneRoot.getTranslation(); sceneRoot.setTranslation([ sourceTranslation[0] - sourceBounds.center[0], sourceTranslation[1] - sourceBounds.min[1], sourceTranslation[2] - sourceBounds.center[2], ]); root.listMeshes()[0].setName("gravehorn_triceratops"); root.listMaterials()[0] .setName("gravehorn_bone") .setDoubleSided(false) .setEmissiveFactor([0.1, 0.06, 0.02]); addUprightIdle(document); for (const animation of root.listAnimations()) { if (!unusedClips.has(animation.getName())) continue; for (const channel of animation.listChannels()) channel.dispose(); for (const sampler of animation.listSamplers()) sampler.dispose(); animation.dispose(); } await document.transform( resample({ tolerance: 1e-4 }), dedup({ keepUniqueNames: true }), prune({ keepLeaves: true, keepSolidTextures: true }), meshopt({ encoder: MeshoptEncoder, level: "high" }), ); await io.write(legacyPath, document); const legacyDocument = await io.read(legacyPath); validateStructure(legacyDocument, { optimized: false }); const legacyBounds = await runtimeBounds(legacyPath); if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) { throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`); } const optimizedDocument = await io.read(legacyPath); await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-"); await io.write(optimizedPath, optimizedDocument); validateStructure(await io.read(optimizedPath), { optimized: true }); const sourceSize = (await stat(sourcePath)).size; const legacySize = (await stat(legacyPath)).size; const optimizedSize = (await stat(optimizedPath)).size; console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`); console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);