feat(assets): fünf Waffen aus dem Pack als GLB mit WebP und meshopt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 21:19:40 +02:00
1 parent e97ef14f78
commit 2a60f92539
8 files changed
+152 -1

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { TEXTURE_SIZE, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
describe.each(WEAPON_SOURCES.map((source) => [source.id, source]))("%s.glb", (id, source) => {
const file = fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url));
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
document = await io.read(file);
}, 30000);
it("hat ein Mesh mit einem Primitive und einem Material", () => {
const root = document.getRoot();
expect(root.listMeshes()).toHaveLength(1);
expect(root.listMeshes()[0].listPrimitives()).toHaveLength(1);
expect(root.listMaterials()).toHaveLength(1);
});
// Bruch: Aufbereitung übersprungen — rohe Geometrie und PNG-Texturen bis 4096 px im Spiel.
it("nutzt meshopt und WebP", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
});
it("hat drei Texturen bis 1024 px", () => {
const textures = document.getRoot().listTextures();
expect(textures).toHaveLength(3);
for (const texture of textures) expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(TEXTURE_SIZE);
});
// Bruch: falsche Einheit — die Waffe wäre hundertmal zu groß oder zu klein in der Hand.
it("ist so lang wie eine echte Waffe", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
const length = Math.max(...max.map((value, axis) => value - min[axis]));
expect(length).toBeGreaterThanOrEqual(source.length[0]);
expect(length).toBeLessThanOrEqual(source.length[1]);
});
});
+26
View File
@@ -0,0 +1,26 @@
// three.js' FBXLoader läuft auch in Node, braucht dafür aber Browser-Platzhalter: `self` und `window` für den
// Loader-Code, `document.createElementNS` für die Bilder, die sein TextureLoader zu referenzierten Texturen anlegt.
// Diese Bilder laden nie, das schadet nicht: tools/assets/weapons.mjs baut die Materialien selbst.
// Nur die Umbau-Skripte importieren diese Datei, und zwar erst in main() — Tests sollen die Platzhalter nie sehen.
import { readFile } from "node:fs/promises";
globalThis.self ??= globalThis;
globalThis.window ??= globalThis;
globalThis.document ??= {
createElementNS: () => ({ style: {}, setAttribute() {}, addEventListener() {}, removeEventListener() {} }),
};
const { FBXLoader } = await import("three/examples/jsm/loaders/FBXLoader.js");
/** Liest eine FBX-Datei. Die Warnung zu den orthografischen Producer-Kameras von MotionBuilder wird verschluckt. */
export async function loadFbx(path) {
const bytes = await readFile(path);
const warn = console.warn;
console.warn = (...args) => {
if (!String(args[0]).includes("Orthographic cameras")) warn(...args);
};
try {
return new FBXLoader().parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
} finally {
console.warn = warn;
}
}
+80 -1
View File
@@ -6,10 +6,20 @@
// Das Pack bleibt außerhalb des Repos. Die Geometrie kommt über three.js' FBXLoader, die Materialien werden neu
// gebaut: T_ ist die Grundfarbe, N_ die Normalmap (schon im OpenGL-Format von glTF), M_ eine TGA-Maske mit
// Grau = Rauheit und Alpha = Metall (am AK47-Holz gemessen, s. Entwurf).
import { fileURLToPath } from "node:url";
import { mkdir, readFile, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
import { getBounds, meshopt, weld } from "@gltf-transform/functions";
import { MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
import { Vector3 } from "three";
export const TEXTURE_SIZE = 1024;
const QUALITY = 80;
// Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster (wie in tools/assets/places.mjs).
const NORMAL_QUALITY = 92;
/** Die fünf Waffen des Packs. length ist die erwartete Länge in Metern und fängt eine falsche Einheit ab. */
export const WEAPON_SOURCES = [
@@ -116,3 +126,72 @@ export function weaponTarget(id) {
if (!/^[a-z0-9]+$/.test(id)) throw new Error(`Ungültige Waffen-ID "${id}": nur Kleinbuchstaben und Ziffern.`);
return fileURLToPath(new URL(`../../public/models/weapons/${id}.glb`, import.meta.url));
}
async function encodeWebp(image, quality) {
const buffer = await image
.resize({ width: TEXTURE_SIZE, height: TEXTURE_SIZE, fit: "inside", withoutEnlargement: true })
.webp({ quality })
.toBuffer();
return new Uint8Array(buffer);
}
/** Baut das Dokument einer Waffe. loadFbx kommt von außen, damit nur main() die Browser-Platzhalter zieht. */
export async function buildWeapon(source, packDir, loadFbx) {
const dir = join(packDir, source.dir);
const fbx = await loadFbx(join(dir, source.mesh));
let mesh = null;
fbx.traverse((object) => { if (!mesh && object.isMesh) mesh = object; });
if (!mesh) throw new Error(`${source.id}: kein Mesh in ${source.mesh}`);
const { positions, normals, uvs } = meshArrays(mesh.geometry, mesh.quaternion);
const length = checkLength(source.id, positions, source.length);
const document = new Document();
const buffer = document.createBuffer();
const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
const mask = readTga(await readFile(join(dir, source.mask)));
const [baseColor, normalMap, metallicRoughness] = await Promise.all([
encodeWebp(sharp(join(dir, source.base)).removeAlpha(), QUALITY),
encodeWebp(sharp(join(dir, source.normal)), NORMAL_QUALITY),
encodeWebp(sharp(Buffer.from(maskToMetallicRoughness(mask)),
{ raw: { width: mask.width, height: mask.height, channels: 3 } }), QUALITY),
]);
const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/webp");
const material = document.createMaterial(source.id)
.setBaseColorTexture(texture(`${source.id}_baseColor`, baseColor))
.setNormalTexture(texture(`${source.id}_normal`, normalMap))
.setMetallicRoughnessTexture(texture(`${source.id}_metallicRoughness`, metallicRoughness))
.setMetallicFactor(1)
.setRoughnessFactor(1);
const primitive = document.createPrimitive()
.setAttribute("POSITION", accessor(positions, "VEC3"))
.setAttribute("NORMAL", accessor(normals, "VEC3"))
.setAttribute("TEXCOORD_0", accessor(uvs, "VEC2"))
.setMaterial(material);
const node = document.createNode(source.id).setMesh(document.createMesh(source.id).addPrimitive(primitive));
document.getRoot().setDefaultScene(document.createScene(source.id).addChild(node));
document.createExtension(EXTTextureWebP).setRequired(true);
await MeshoptEncoder.ready;
await document.transform(weld(), meshopt({ encoder: MeshoptEncoder, level: "medium" }));
return { document, length, vertices: positions.length / 3 };
}
async function main() {
const [packDir] = process.argv.slice(2);
if (!packDir) {
console.error("Aufruf: node tools/assets/weapons.mjs <Ordner WeaponsPack aus ASISWeaponsPack.zip>");
process.exit(1);
}
const { loadFbx } = await import("./fbx.mjs");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
for (const source of WEAPON_SOURCES) {
const { document, length, vertices } = await buildWeapon(source, packDir, loadFbx);
const target = weaponTarget(source.id);
await mkdir(dirname(target), { recursive: true });
await io.write(target, document);
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
console.log(`${source.id}.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB, ${length.toFixed(2)} m lang,`,
`${vertices} Eckpunkte, Maße ${min.map((value, axis) => (max[axis] - value).toFixed(2)).join(" × ")} m`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();