Release v0.1.9 2026-07-13
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
EXTMeshoptCompression,
|
||||
} from "@gltf-transform/extensions";
|
||||
import { dedup, mergeDocuments, prune, unpartition } from "@gltf-transform/functions";
|
||||
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = path.join(
|
||||
repositoryRoot,
|
||||
"game_assets",
|
||||
"models",
|
||||
"original",
|
||||
"environment",
|
||||
"kaykit-dungeon",
|
||||
);
|
||||
const outputPath = path.join(sourceDirectory, "dungeon-kit-uastc.glb");
|
||||
const sources = ["pillar-decorated.glb", "wall-pillar.glb", "torch-lit.glb"];
|
||||
const expectedMeshNames = ["pillar_decorated", "wall_pillar", "torch_lit"];
|
||||
await Promise.all(sources.map((source) => access(path.join(sourceDirectory, source))));
|
||||
const io = await createGameAssetIO();
|
||||
|
||||
const documents = await Promise.all(sources.map((source) => io.read(path.join(sourceDirectory, source))));
|
||||
const document = documents[0];
|
||||
const destinationScene = document.getRoot().listScenes()[0].setName("dungeon_kit");
|
||||
|
||||
for (const sourceDocument of documents.slice(1)) {
|
||||
const sourceScene = sourceDocument.getRoot().listScenes()[0];
|
||||
const propertyMap = mergeDocuments(document, sourceDocument);
|
||||
const copiedScene = propertyMap.get(sourceScene);
|
||||
if (!copiedScene) throw new Error(`Could not merge scene from ${sourceScene.getName() || "unnamed source"}.`);
|
||||
for (const child of [...copiedScene.listChildren()]) destinationScene.addChild(child);
|
||||
copiedScene.dispose();
|
||||
}
|
||||
|
||||
await document.transform(
|
||||
dedup({ keepUniqueNames: false }),
|
||||
unpartition(),
|
||||
prune({ keepSolidTextures: true }),
|
||||
);
|
||||
|
||||
const root = document.getRoot();
|
||||
const meshNames = root.listMeshes().map((mesh) => mesh.getName()).sort();
|
||||
const expectedSorted = [...expectedMeshNames].sort();
|
||||
if (JSON.stringify(meshNames) !== JSON.stringify(expectedSorted)) {
|
||||
throw new Error(`Dungeon kit mesh names changed: expected ${expectedSorted.join(", ")}; received ${meshNames.join(", ")}.`);
|
||||
}
|
||||
if (root.listTextures().length !== 1) {
|
||||
throw new Error(`Dungeon kit must contain exactly one shared texture; received ${root.listTextures().length}.`);
|
||||
}
|
||||
if (root.listMaterials().length !== 1) {
|
||||
throw new Error(`Dungeon kit must contain exactly one shared material; received ${root.listMaterials().length}.`);
|
||||
}
|
||||
|
||||
await convertDocumentTexturesToKtx2(document, "iwt-dungeon-kit-");
|
||||
const meshoptExtension = root.listExtensionsUsed()
|
||||
.find((extension) => extension.extensionName === EXTMeshoptCompression.EXTENSION_NAME)
|
||||
?? document.createExtension(EXTMeshoptCompression);
|
||||
meshoptExtension
|
||||
.setRequired(true)
|
||||
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
|
||||
|
||||
await io.write(outputPath, document);
|
||||
|
||||
const outputDocument = await io.read(outputPath);
|
||||
const outputRoot = outputDocument.getRoot();
|
||||
const outputTexture = outputRoot.listTextures()[0];
|
||||
if (
|
||||
outputRoot.listMeshes().length !== 3
|
||||
|| outputRoot.listMaterials().length !== 1
|
||||
|| outputRoot.listTextures().length !== 1
|
||||
|| outputTexture.getMimeType() !== "image/ktx2"
|
||||
) {
|
||||
throw new Error("Generated dungeon kit failed structural validation.");
|
||||
}
|
||||
|
||||
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
|
||||
console.log("Meshes: pillar_decorated, wall_pillar, torch_lit; shared textures: 1; encoding: KTX2/UASTC.");
|
||||
@@ -0,0 +1,170 @@
|
||||
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).`);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceRoot = path.join(repositoryRoot, "game_assets");
|
||||
const sourceAssets = [
|
||||
"models/claudecraft/chars/players/druid.glb",
|
||||
"models/claudecraft/chars/players/knight.glb",
|
||||
"models/claudecraft/chars/players/mage.glb",
|
||||
"models/claudecraft/chars/players/ranger.glb",
|
||||
"models/claudecraft/chars/players/rogue.glb",
|
||||
"models/claudecraft/weapons/adv_dagger.glb",
|
||||
"models/claudecraft/weapons/adv_druid_staff.glb",
|
||||
"models/claudecraft/weapons/adv_sword_1handed.glb",
|
||||
"models/claudecraft/weapons/adv_wand.glb",
|
||||
"models/claudecraft/weapons/crossbow_2handed.glb",
|
||||
"models/claudecraft/weapons/shield_badge.glb",
|
||||
"models/claudecraft/weapons/spellbook_open.glb",
|
||||
];
|
||||
|
||||
function optimizedPath(sourcePath) {
|
||||
return sourcePath.replace(/\.glb$/, "-uastc.glb");
|
||||
}
|
||||
|
||||
function sortedNames(properties) {
|
||||
return properties.map((property) => property.getName()).sort();
|
||||
}
|
||||
|
||||
const io = await createGameAssetIO();
|
||||
for (const relativePath of sourceAssets) {
|
||||
const inputPath = path.join(sourceRoot, relativePath);
|
||||
const outputRelativePath = optimizedPath(relativePath);
|
||||
const outputPath = path.join(sourceRoot, outputRelativePath);
|
||||
await access(inputPath);
|
||||
|
||||
const document = await io.read(inputPath);
|
||||
const sourceRootProperties = document.getRoot();
|
||||
const expected = {
|
||||
animations: sortedNames(sourceRootProperties.listAnimations()),
|
||||
materials: sortedNames(sourceRootProperties.listMaterials()),
|
||||
meshes: sortedNames(sourceRootProperties.listMeshes()),
|
||||
nodes: sortedNames(sourceRootProperties.listNodes()),
|
||||
scenes: sortedNames(sourceRootProperties.listScenes()),
|
||||
textureCount: sourceRootProperties.listTextures().length,
|
||||
};
|
||||
|
||||
await convertDocumentTexturesToKtx2(document, "iwt-ktx2-");
|
||||
await io.write(outputPath, document);
|
||||
|
||||
const outputDocument = await io.read(outputPath);
|
||||
const outputRoot = outputDocument.getRoot();
|
||||
const actual = {
|
||||
animations: sortedNames(outputRoot.listAnimations()),
|
||||
materials: sortedNames(outputRoot.listMaterials()),
|
||||
meshes: sortedNames(outputRoot.listMeshes()),
|
||||
nodes: sortedNames(outputRoot.listNodes()),
|
||||
scenes: sortedNames(outputRoot.listScenes()),
|
||||
textureCount: outputRoot.listTextures().length,
|
||||
};
|
||||
if (
|
||||
JSON.stringify(actual) !== JSON.stringify(expected)
|
||||
|| outputRoot.listTextures().some((texture) => texture.getMimeType() !== "image/ktx2")
|
||||
|| outputRoot.listExtensionsUsed().some((extension) => extension.extensionName === "EXT_texture_webp")
|
||||
) {
|
||||
throw new Error(`Generated asset failed round-trip validation: ${outputRelativePath}`);
|
||||
}
|
||||
console.log(`Built game_assets/${outputRelativePath}`);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const TOKTX_COMMAND = process.env.TOKTX ?? "toktx";
|
||||
|
||||
export async function createGameAssetIO() {
|
||||
await MeshoptDecoder.ready;
|
||||
await MeshoptEncoder.ready;
|
||||
return new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({
|
||||
"meshopt.decoder": MeshoptDecoder,
|
||||
"meshopt.encoder": MeshoptEncoder,
|
||||
});
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: "inherit" });
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with code ${code ?? "unknown"}.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertDocumentTexturesToKtx2(document, temporaryPrefix) {
|
||||
const textures = document.getRoot().listTextures();
|
||||
if (textures.length === 0) throw new Error("Asset contains no textures to convert.");
|
||||
|
||||
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), temporaryPrefix));
|
||||
try {
|
||||
for (const [index, texture] of textures.entries()) {
|
||||
const sourceImage = texture.getImage();
|
||||
if (!sourceImage) throw new Error(`Texture ${texture.getName() || index} contains no image data.`);
|
||||
|
||||
const pngPath = path.join(temporaryDirectory, `texture-${index}.png`);
|
||||
const ktx2Path = path.join(temporaryDirectory, `texture-${index}.ktx2`);
|
||||
await writeFile(pngPath, await sharp(sourceImage).png().toBuffer());
|
||||
await run(TOKTX_COMMAND, [
|
||||
"--t2",
|
||||
"--encode", "uastc",
|
||||
"--uastc_quality", "4",
|
||||
"--zcmp", "18",
|
||||
"--threads", process.env.TOKTX_THREADS ?? "4",
|
||||
"--genmipmap",
|
||||
"--assign_oetf", "srgb",
|
||||
"--assign_primaries", "bt709",
|
||||
ktx2Path,
|
||||
pngPath,
|
||||
]);
|
||||
|
||||
texture
|
||||
.setName(`${texture.getName() || `texture_${index}`}_uastc`)
|
||||
.setMimeType("image/ktx2")
|
||||
.setImage(new Uint8Array(await readFile(ktx2Path)));
|
||||
}
|
||||
document.getRoot().listExtensionsUsed()
|
||||
.find((extension) => extension.extensionName === EXTTextureWebP.EXTENSION_NAME)
|
||||
?.dispose();
|
||||
document.createExtension(KHRTextureBasisu).setRequired(true);
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = path.join(repositoryRoot, "node_modules", "three", "examples", "jsm", "libs", "basis");
|
||||
const outputDirectory = path.join(repositoryRoot, "public", "basis");
|
||||
const files = ["basis_transcoder.js", "basis_transcoder.wasm"];
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
for (const file of files) {
|
||||
await copyFile(path.join(sourceDirectory, file), path.join(outputDirectory, file));
|
||||
}
|
||||
|
||||
console.log(`Synced Three.js Basis transcoder ${files.join(", ")} -> public/basis`);
|
||||
Reference in New Issue
Block a user