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:
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.
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user