diff --git a/public/models/weapons/ak47.glb b/public/models/weapons/ak47.glb new file mode 100644 index 0000000..0758573 Binary files /dev/null and b/public/models/weapons/ak47.glb differ diff --git a/public/models/weapons/l96.glb b/public/models/weapons/l96.glb new file mode 100644 index 0000000..138e3ab Binary files /dev/null and b/public/models/weapons/l96.glb differ diff --git a/public/models/weapons/m4.glb b/public/models/weapons/m4.glb new file mode 100644 index 0000000..7c675f7 Binary files /dev/null and b/public/models/weapons/m4.glb differ diff --git a/public/models/weapons/pistol.glb b/public/models/weapons/pistol.glb new file mode 100644 index 0000000..5935d31 Binary files /dev/null and b/public/models/weapons/pistol.glb differ diff --git a/public/models/weapons/shotgun870.glb b/public/models/weapons/shotgun870.glb new file mode 100644 index 0000000..5d4a670 Binary files /dev/null and b/public/models/weapons/shotgun870.glb differ diff --git a/tests/showroom.test.js b/tests/showroom.test.js new file mode 100644 index 0000000..c1723c2 --- /dev/null +++ b/tests/showroom.test.js @@ -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]); + }); +}); diff --git a/tools/assets/fbx.mjs b/tools/assets/fbx.mjs new file mode 100644 index 0000000..9b1bff1 --- /dev/null +++ b/tools/assets/fbx.mjs @@ -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; + } +} diff --git a/tools/assets/weapons.mjs b/tools/assets/weapons.mjs index fb399e6..bd34f26 100644 --- a/tools/assets/weapons.mjs +++ b/tools/assets/weapons.mjs @@ -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 "); + 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();