Merge branch 'main' into leonida/strassen
Bringt den Stand von main (Animationen, Showroom) in den Straßenzweig, damit die zusammengeführte Fassung geprüft werden kann, bevor main auf diesen Stand vorgezogen wird. main ist in einem zweiten Arbeitsverzeichnis ausgecheckt und kann hier nicht ausgecheckt werden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
4a91e84bfc
23 files changed
+4583
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// Überträgt die Mocap-Clips aus MotionLibrary_Weapons.zip (Xsens MVN, 24 Knochen, cm) auf das Skelett des
|
||||
// Mannequins aus public/models/ual.glb (docs/superpowers/specs/2026-09-17-animationen-design.md, "Umbau der
|
||||
// Animationen"):
|
||||
//
|
||||
// node tools/assets/motions.mjs <Ordner mit den 11 FBX> -> public/models/motions/weapons-mocap.glb
|
||||
//
|
||||
// Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main()
|
||||
// liest nur Dateien und schreibt das Ergebnis.
|
||||
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { prune } from "@gltf-transform/functions";
|
||||
import { MathUtils, Matrix4, Quaternion, Vector3 } from "three";
|
||||
|
||||
/**
|
||||
* MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein
|
||||
* Gegenstück: Die Übertragung rechnet mit Weltdrehungen, seine Drehung steckt deshalb in der von spine_03.
|
||||
*/
|
||||
export const BONE_MAP = {
|
||||
Hips: "pelvis",
|
||||
Spine: "spine_01",
|
||||
Spine1: "spine_02",
|
||||
Spine3: "spine_03",
|
||||
Neck: "neck_01",
|
||||
Head: "Head",
|
||||
LeftShoulder: "clavicle_l",
|
||||
LeftArm: "upperarm_l",
|
||||
LeftForeArm: "lowerarm_l",
|
||||
LeftHand: "hand_l",
|
||||
RightShoulder: "clavicle_r",
|
||||
RightArm: "upperarm_r",
|
||||
RightForeArm: "lowerarm_r",
|
||||
RightHand: "hand_r",
|
||||
LeftUpLeg: "thigh_l",
|
||||
LeftLeg: "calf_l",
|
||||
LeftFoot: "foot_l",
|
||||
LeftToeBase: "ball_l",
|
||||
RightUpLeg: "thigh_r",
|
||||
RightLeg: "calf_r",
|
||||
RightFoot: "foot_r",
|
||||
RightToeBase: "ball_r",
|
||||
};
|
||||
export const SOURCE_HIPS = "Hips";
|
||||
|
||||
/** Clip-Name aus dem Dateinamen: 20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx -> idle_loadGun-Hand_tk01. */
|
||||
export function clipId(fileName) {
|
||||
const match = /^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/.exec(fileName);
|
||||
if (!match) throw new Error(`Unbekannter Dateiname: ${fileName}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruhepose eines Zielskeletts in Weltkoordinaten. rig ist eine Liste in Eltern-vor-Kind-Reihenfolge:
|
||||
* { name, parent, position: [x, y, z], quaternion: [x, y, z, w], scale: [x, y, z] }. Liefert je Knochen die
|
||||
* Weltmatrix und die Weltdrehung (Drehungen verkettet, das Mannequin hat überall Maßstab 1).
|
||||
*/
|
||||
export function rigWorld(rig) {
|
||||
const world = new Map();
|
||||
for (const bone of rig) {
|
||||
const parent = bone.parent === null ? null : world.get(bone.parent);
|
||||
if (bone.parent !== null && !parent) throw new Error(`Rig: ${bone.parent} steht nicht vor ${bone.name}`);
|
||||
const rotation = new Quaternion().fromArray(bone.quaternion);
|
||||
const local = new Matrix4().compose(
|
||||
new Vector3().fromArray(bone.position),
|
||||
rotation,
|
||||
new Vector3().fromArray(bone.scale ?? [1, 1, 1]),
|
||||
);
|
||||
world.set(bone.name, {
|
||||
matrix: parent ? parent.matrix.clone().multiply(local) : local,
|
||||
quaternion: parent ? parent.quaternion.clone().multiply(rotation) : rotation.clone(),
|
||||
});
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Übertragung eines MVN-Bilds auf das Zielskelett. Ein Bild ist { rotations: Map<MVN-Knochen,
|
||||
* Weltdrehung>, hips: Weltposition der Hüfte in cm }. Beide Skelette ruhen in derselben T-Pose mit Blick nach +Z,
|
||||
* und jede MVN-Ruhe-Weltdrehung ist 0°. Deshalb gilt je zugeordnetem Knochen:
|
||||
*
|
||||
* Weltdrehung Ziel = Weltdrehung MVN · Ruhe-Weltdrehung Ziel, lokal = (Weltdrehung Eltern)⁻¹ · Weltdrehung
|
||||
*
|
||||
* Knochen ohne Zuordnung (root, Finger, Blattknochen) gehen mit ihrer Ruhedrehung in die Kette ein und bekommen
|
||||
* keine Spur. Die Hüfte: cm -> m, mal hipsScale (Hüfthöhe Ziel ÷ Hüfthöhe Quelle), dann in den Raum ihres
|
||||
* Elternknochens, der dafür unbewegt sein muss.
|
||||
*/
|
||||
export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) {
|
||||
const rest = rigWorld(rig);
|
||||
const sourceOf = new Map(Object.entries(boneMap).map(([source, target]) => [target, source]));
|
||||
const hipsBone = rig.find((bone) => bone.name === boneMap[SOURCE_HIPS]);
|
||||
if (!hipsBone) throw new Error(`Rig ohne Hüftknochen ${boneMap[SOURCE_HIPS]}`);
|
||||
if (hipsBone.parent !== null && sourceOf.has(hipsBone.parent)) {
|
||||
throw new Error(`Elternknochen der Hüfte (${hipsBone.parent}) darf nicht animiert sein`);
|
||||
}
|
||||
const hipsParentInverse = hipsBone.parent === null
|
||||
? new Matrix4()
|
||||
: rest.get(hipsBone.parent).matrix.clone().invert();
|
||||
|
||||
return function retarget(frame) {
|
||||
const world = new Map();
|
||||
const local = new Map();
|
||||
for (const bone of rig) {
|
||||
const parentWorld = bone.parent === null ? new Quaternion() : world.get(bone.parent);
|
||||
const source = sourceOf.get(bone.name);
|
||||
if (source === undefined) {
|
||||
world.set(bone.name, parentWorld.clone().multiply(new Quaternion().fromArray(bone.quaternion)));
|
||||
continue;
|
||||
}
|
||||
const sourceWorld = frame.rotations.get(source);
|
||||
if (!sourceWorld) throw new Error(`Bild ohne Drehung für ${source}`);
|
||||
const targetWorld = sourceWorld.clone().multiply(rest.get(bone.name).quaternion);
|
||||
world.set(bone.name, targetWorld);
|
||||
local.set(bone.name, parentWorld.clone().invert().multiply(targetWorld));
|
||||
}
|
||||
const hips = frame.hips.clone().multiplyScalar(hipsScale / 100).applyMatrix4(hipsParentInverse);
|
||||
return { local, hips };
|
||||
};
|
||||
}
|
||||
|
||||
export const SAMPLE_RATE = 30;
|
||||
/** Überblendzeit am Schleifenende: Naht in Grad ÷ BLEND_SPEED (°/s), begrenzt auf BLEND_MIN bis BLEND_MAX Sekunden. */
|
||||
export const BLEND_SPEED = 90;
|
||||
export const BLEND_MIN = 0.25;
|
||||
export const BLEND_MAX = 1.5;
|
||||
|
||||
/** n = round((end − start) · rate) + 1 gleichmäßig verteilte Zeitpunkte von start bis end, beide eingeschlossen. */
|
||||
export function sampleTimes(start, end, rate = SAMPLE_RATE) {
|
||||
if (!(end > start)) throw new Error(`Leerer Zeitraum ${start}–${end}`);
|
||||
const count = Math.round((end - start) * rate) + 1;
|
||||
return Array.from({ length: count }, (_, i) => start + ((end - start) * i) / (count - 1));
|
||||
}
|
||||
|
||||
/** Winkel des Vorwärtsvektors (+Z) einer Drehung gegen +Z in der Waagrechten, im Bogenmaß, positiv zu +X. */
|
||||
export function headingOf(quaternion) {
|
||||
const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion);
|
||||
return Math.atan2(forward.x, forward.z);
|
||||
}
|
||||
|
||||
/** Dreht alle Bilder um die Hochachse, bis die Hüfte im ersten Bild nach +Z schaut. */
|
||||
export function faceForward(frames) {
|
||||
const angle = -headingOf(frames[0].rotations.get(SOURCE_HIPS));
|
||||
const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle);
|
||||
return frames.map((frame) => ({
|
||||
rotations: new Map([...frame.rotations].map(([name, rotation]) => [name, turn.clone().multiply(rotation)])),
|
||||
hips: frame.hips.clone().applyQuaternion(turn),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Legt die Hüfte im ersten Bild waagrecht auf 0 und zieht die Drift linear ab, sodass das letzte Bild dort endet. */
|
||||
export function removeDrift(frames) {
|
||||
const first = frames[0].hips;
|
||||
const last = frames.at(-1).hips;
|
||||
return frames.map((frame, i) => {
|
||||
const along = frames.length > 1 ? i / (frames.length - 1) : 0;
|
||||
const hips = frame.hips.clone();
|
||||
hips.x -= first.x + along * (last.x - first.x);
|
||||
hips.z -= first.z + along * (last.z - first.z);
|
||||
return { rotations: frame.rotations, hips };
|
||||
});
|
||||
}
|
||||
|
||||
/** Größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen, in Grad. */
|
||||
export function seamAngle(frames) {
|
||||
const last = frames.at(-1).rotations;
|
||||
let largest = 0;
|
||||
for (const [name, rotation] of frames[0].rotations) largest = Math.max(largest, rotation.angleTo(last.get(name)));
|
||||
return MathUtils.radToDeg(largest);
|
||||
}
|
||||
|
||||
export function blendDuration(seamDegrees) {
|
||||
return MathUtils.clamp(seamDegrees / BLEND_SPEED, BLEND_MIN, BLEND_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt die Schleife: In den letzten `blend` Sekunden werden lokale Drehungen (slerp) und die Hüfte (lerp) mit
|
||||
* smoothstep-Gewicht zum ersten Bild gezogen. Das letzte Bild gleicht danach dem ersten. poses: [{ local, hips }].
|
||||
*/
|
||||
export function closeLoop(times, poses, blend) {
|
||||
const end = times.at(-1);
|
||||
const first = poses[0];
|
||||
return poses.map((pose, i) => {
|
||||
if (times[i] <= end - blend) return pose;
|
||||
const weight = MathUtils.smoothstep(times[i], end - blend, end);
|
||||
return {
|
||||
local: new Map([...pose.local].map(([name, rotation]) =>
|
||||
[name, rotation.clone().slerp(first.local.get(name), weight)])),
|
||||
hips: pose.hips.clone().lerp(first.hips, weight),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den ersten Clip eines FBX-Objekts ab: n gleichmäßige Zeitpunkte von Frame 1 bis zum Ende (Frame 0 ist die
|
||||
* T-Pose, 8 ms davor). Je Zeitpunkt die Weltdrehungen der zugeordneten MVN-Knochen und die Hüft-Weltposition in cm.
|
||||
* times beginnt bei 0. hipsRestHeight ist die Hüfthöhe der Ruhepose in cm, abgelesen bevor ein Bild gesetzt wird —
|
||||
* die beiden Aufnahmetage liegen dort 0,9 cm auseinander.
|
||||
*/
|
||||
export function sampleFbx(root, rate = SAMPLE_RATE, boneMap = BONE_MAP) {
|
||||
const clip = root.animations?.[0];
|
||||
if (!clip) throw new Error("FBX ohne Animation");
|
||||
const hips = root.getObjectByName(SOURCE_HIPS);
|
||||
if (!hips) throw new Error(`FBX ohne Knochen ${SOURCE_HIPS}`);
|
||||
root.updateMatrixWorld(true);
|
||||
const hipsRestHeight = hips.getWorldPosition(new Vector3()).y;
|
||||
|
||||
const bindings = clip.tracks.map((track) => {
|
||||
const dot = track.name.lastIndexOf(".");
|
||||
const object = root.getObjectByName(track.name.slice(0, dot));
|
||||
if (!object) throw new Error(`Spur ohne Knochen: ${track.name}`);
|
||||
return { target: object[track.name.slice(dot + 1)], interpolant: track.createInterpolant() };
|
||||
});
|
||||
const hipsTimes = clip.tracks.find((track) => track.name === `${SOURCE_HIPS}.quaternion`)?.times;
|
||||
if (!hipsTimes || hipsTimes.length < 3) throw new Error("Hüftspur fehlt oder ist zu kurz");
|
||||
const bones = Object.keys(boneMap).map((name) => {
|
||||
const bone = root.getObjectByName(name);
|
||||
if (!bone) throw new Error(`FBX ohne Knochen ${name}`);
|
||||
return bone;
|
||||
});
|
||||
|
||||
const start = hipsTimes[1];
|
||||
const absolute = sampleTimes(start, clip.duration, rate);
|
||||
const frames = absolute.map((time) => {
|
||||
for (const { target, interpolant } of bindings) target.fromArray(interpolant.evaluate(time));
|
||||
root.updateMatrixWorld(true);
|
||||
return {
|
||||
rotations: new Map(bones.map((bone) => [bone.name, bone.getWorldQuaternion(new Quaternion())])),
|
||||
hips: hips.getWorldPosition(new Vector3()),
|
||||
};
|
||||
});
|
||||
return { times: absolute.map((time) => time - start), frames, hipsRestHeight };
|
||||
}
|
||||
|
||||
/** Knotenhierarchie einer gltf-transform-Szene als rig (siehe rigWorld), Eltern vor Kindern. */
|
||||
export function rigFromScene(scene) {
|
||||
const rig = [];
|
||||
const visit = (node, parent) => {
|
||||
rig.push({
|
||||
name: node.getName(),
|
||||
parent,
|
||||
position: node.getTranslation(),
|
||||
quaternion: node.getRotation(),
|
||||
scale: node.getScale(),
|
||||
});
|
||||
for (const child of node.listChildren()) visit(child, node.getName());
|
||||
};
|
||||
for (const node of scene.listChildren()) visit(node, null);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/** Entfernt aus einem gelesenen ual.glb alles außer der Knotenhierarchie: Clips, Meshes, Skins, Materialien, Texturen. */
|
||||
export async function stripToSkeleton(document) {
|
||||
const root = document.getRoot();
|
||||
for (const node of root.listNodes()) node.setMesh(null).setSkin(null);
|
||||
// Die Accessors müssen ausdrücklich mit weg: animation.dispose() löst seine Sampler nicht auf, prune() hält
|
||||
// deren Accessors darum weiter für benutzt. Ohne diese Zeile blieben die 53 Clips von ual.glb in der Datei
|
||||
// (gemessen: 8745 Accessors und 3,41 MB statt 264 und 1,66 MB).
|
||||
for (const property of [...root.listAnimations(), ...root.listMeshes(), ...root.listSkins(),
|
||||
...root.listMaterials(), ...root.listTextures(), ...root.listAccessors()]) {
|
||||
property.dispose();
|
||||
}
|
||||
// keepLeaves: Knochen ohne Kinder (Head, Blattknochen) sind leere Blattknoten und müssen als Ziele bleiben.
|
||||
await document.transform(prune({ keepLeaves: true }));
|
||||
}
|
||||
|
||||
/** Legt Posen als Float32-Reihen ab: je Knochen x, y, z, w pro Bild, dazu die Hüfte als x, y, z pro Bild. */
|
||||
export function flattenPoses(poses) {
|
||||
const rotations = new Map([...poses[0].local.keys()].map((bone) => [bone, new Float32Array(poses.length * 4)]));
|
||||
const hips = new Float32Array(poses.length * 3);
|
||||
poses.forEach((pose, i) => {
|
||||
for (const [bone, values] of rotations) pose.local.get(bone).toArray(values, i * 4);
|
||||
pose.hips.toArray(hips, i * 3);
|
||||
});
|
||||
return { rotations, hips };
|
||||
}
|
||||
|
||||
/** Hängt einen Clip an das Dokument: ein Zeit-Accessor, je Knochen ein Drehungskanal, die Hüfte als Translation. */
|
||||
export function addClip(document, id, times, poses, hipsBone = BONE_MAP[SOURCE_HIPS]) {
|
||||
const root = document.getRoot();
|
||||
const buffer = root.listBuffers()[0] ?? document.createBuffer();
|
||||
const nodes = new Map(root.listNodes().map((node) => [node.getName(), node]));
|
||||
const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
|
||||
const animation = document.createAnimation(id);
|
||||
const input = accessor(Float32Array.from(times), "SCALAR");
|
||||
const channel = (bone, path, values, type) => {
|
||||
const node = nodes.get(bone);
|
||||
if (!node) throw new Error(`Skelett ohne Knoten ${bone}`);
|
||||
const sampler = document.createAnimationSampler()
|
||||
.setInput(input).setOutput(accessor(values, type)).setInterpolation("LINEAR");
|
||||
animation.addSampler(sampler).addChannel(
|
||||
document.createAnimationChannel().setTargetNode(node).setTargetPath(path).setSampler(sampler));
|
||||
};
|
||||
const { rotations, hips } = flattenPoses(poses);
|
||||
for (const [bone, values] of rotations) channel(bone, "rotation", values, "VEC4");
|
||||
channel(hipsBone, "translation", hips, "VEC3");
|
||||
return animation;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [sourceDir] = process.argv.slice(2);
|
||||
if (!sourceDir) {
|
||||
console.error("Aufruf: node tools/assets/motions.mjs <Ordner mit den FBX aus MotionLibrary_Weapons.zip>");
|
||||
process.exit(1);
|
||||
}
|
||||
const { loadFbx } = await import("./fbx.mjs");
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const document = await io.read(fileURLToPath(new URL("../../public/models/ual.glb", import.meta.url)));
|
||||
const rig = rigFromScene(document.getRoot().listScenes()[0]);
|
||||
await stripToSkeleton(document);
|
||||
const pelvisHeight = new Vector3().setFromMatrixPosition(rigWorld(rig).get(BONE_MAP[SOURCE_HIPS]).matrix).y;
|
||||
|
||||
const files = (await readdir(sourceDir)).filter((name) => name.endsWith(".fbx")).sort();
|
||||
for (const file of files) {
|
||||
const id = clipId(file);
|
||||
const { times, frames, hipsRestHeight } = sampleFbx(await loadFbx(join(sourceDir, file)));
|
||||
const cleaned = removeDrift(faceForward(frames));
|
||||
const blend = blendDuration(seamAngle(cleaned));
|
||||
const retarget = createRetargeter(rig, { hipsScale: pelvisHeight / (hipsRestHeight / 100) });
|
||||
addClip(document, id, times, closeLoop(times, cleaned.map(retarget), blend));
|
||||
console.log(`${id}: ${times.at(-1).toFixed(2)} s, ${times.length} Bilder, Überblendung ${blend.toFixed(2)} s`);
|
||||
}
|
||||
|
||||
const target = fileURLToPath(new URL("../../public/models/motions/weapons-mocap.glb", import.meta.url));
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await io.write(target, document);
|
||||
console.log(`weapons-mocap.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||||
@@ -0,0 +1,202 @@
|
||||
// Baut die Waffen aus dem ASIS Weapons Pack zu GLB um (docs/superpowers/specs/2026-09-17-animationen-design.md,
|
||||
// "Umbau der Waffen"):
|
||||
//
|
||||
// node tools/assets/weapons.mjs <Ordner WeaponsPack> -> public/models/weapons/<id>.glb
|
||||
//
|
||||
// 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 { 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 = [
|
||||
{ id: "pistol", dir: "Pistol", mesh: "SM_Pistol.fbx", base: "T_Pistol.png", normal: "N_Pistol.png", mask: "M_Pistol.tga", length: [0.15, 0.25] },
|
||||
{ id: "ak47", dir: "AK47_Option2(Same Socketing)", mesh: "SM_AK47.fbx", base: "T_AK47.png", normal: "N_AK47.png", mask: "M_AK47.tga", length: [0.7, 1.3] },
|
||||
{ id: "m4", dir: "M4 Carbine", mesh: "SM_M4_Carbine.fbx", base: "T_M4_Carbine.png", normal: "N_M4_Carbine.png", mask: "M_M4_Carbine.tga", length: [0.7, 1.3] },
|
||||
{ id: "shotgun870", dir: "870 Shotgun", mesh: "SM_870_Shotgun.fbx", base: "T_870_Shotgun.png", normal: "N_870_Shotgun.png", mask: "M_870_Shotgun.tga", length: [0.7, 1.3] },
|
||||
{ id: "l96", dir: "L96 Sniper Rifle", mesh: "SM_L96_Sniper_Rifle.fbx", base: "T_L96_Sniper_Rifle.png", normal: "N_L96_Sniper_Rifle.png", mask: "M_L96_Sniper_Rifle.tga", length: [0.7, 1.3] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Liest ein unkomprimiertes 32-Bit-TGA (Typ 2) und gibt RGBA mit der obersten Bildzeile zuerst zurück. Mehr kann
|
||||
* der Leser nicht, und mehr steckt auch nicht im Pack: alle fünf Masken sind Typ 2, 32 Bit, Ursprung unten links.
|
||||
*/
|
||||
export function readTga(bytes) {
|
||||
const idLength = bytes[0];
|
||||
const colorMapType = bytes[1];
|
||||
const imageType = bytes[2];
|
||||
const width = bytes[12] | (bytes[13] << 8);
|
||||
const height = bytes[14] | (bytes[15] << 8);
|
||||
const depth = bytes[16];
|
||||
const flags = bytes[17];
|
||||
if (colorMapType !== 0 || imageType !== 2 || depth !== 32) {
|
||||
throw new Error(`TGA: nur unkomprimiert mit 32 Bit lesbar (Typ ${imageType}, ${depth} Bit, Farbtabelle ${colorMapType})`);
|
||||
}
|
||||
if (flags & 0x10) throw new Error("TGA: Zeilen von rechts nach links werden nicht unterstützt");
|
||||
const start = 18 + idLength;
|
||||
if (bytes.length < start + width * height * 4) throw new Error("TGA: Datei kürzer als ihre Bildgröße");
|
||||
const originTop = (flags & 0x20) !== 0;
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
for (let row = 0; row < height; row++) {
|
||||
const sourceRow = originTop ? row : height - 1 - row;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const from = start + (sourceRow * width + x) * 4;
|
||||
const to = (row * width + x) * 4;
|
||||
data[to] = bytes[from + 2]; // TGA speichert BGRA
|
||||
data[to + 1] = bytes[from + 1];
|
||||
data[to + 2] = bytes[from];
|
||||
data[to + 3] = bytes[from + 3];
|
||||
}
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** Maske -> metallicRoughness von glTF: Rauheit nach G, Metall nach B. R bleibt ungenutzt. */
|
||||
export function maskToMetallicRoughness({ width, height, data }) {
|
||||
const rgb = new Uint8Array(width * height * 3);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
rgb[i * 3] = 255;
|
||||
rgb[i * 3 + 1] = data[i * 4];
|
||||
rgb[i * 3 + 2] = data[i * 4 + 3];
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eckpunkte, Normalen und UVs eines FBX-Meshes für glTF. Die Knotendrehung wird eingerechnet (die L96 liegt im
|
||||
* Pack um 180° gedreht), der Knotenmaßstab nicht: Die Eckpunkte liegen schon in Metern, die Pistole trägt
|
||||
* zusätzlich den Maßstab 100. Die v-Achse wird gespiegelt, weil FBX den UV-Ursprung unten links hat, glTF oben links.
|
||||
*/
|
||||
export function meshArrays(geometry, quaternion) {
|
||||
const source = geometry.index ? geometry.toNonIndexed() : geometry;
|
||||
const position = source.getAttribute("position");
|
||||
const normal = source.getAttribute("normal");
|
||||
const uv = source.getAttribute("uv");
|
||||
if (!position || !normal || !uv) throw new Error("Mesh ohne position, normal oder uv");
|
||||
const positions = new Float32Array(position.count * 3);
|
||||
const normals = new Float32Array(position.count * 3);
|
||||
const uvs = new Float32Array(position.count * 2);
|
||||
const point = new Vector3();
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
point.fromBufferAttribute(position, i).applyQuaternion(quaternion).toArray(positions, i * 3);
|
||||
point.fromBufferAttribute(normal, i).applyQuaternion(quaternion).normalize().toArray(normals, i * 3);
|
||||
uvs[i * 2] = uv.getX(i);
|
||||
uvs[i * 2 + 1] = 1 - uv.getY(i);
|
||||
}
|
||||
return { positions, normals, uvs };
|
||||
}
|
||||
|
||||
/** Längste Ausdehnung in Metern. */
|
||||
export function longestExtent(positions) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < positions.length; i += 3) {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
min[axis] = Math.min(min[axis], positions[i + axis]);
|
||||
max[axis] = Math.max(max[axis], positions[i + axis]);
|
||||
}
|
||||
}
|
||||
return Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
||||
}
|
||||
|
||||
/** Bricht ab, wenn die Waffe nicht die erwartete Länge hat — dann stimmt die Einheit nicht. */
|
||||
export function checkLength(id, positions, [min, max]) {
|
||||
const length = longestExtent(positions);
|
||||
if (!(length >= min && length <= max)) {
|
||||
throw new Error(`${id}: ${length.toFixed(3)} m lang, erwartet ${min}–${max} m — falsche Einheit?`);
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
/** Zielpfad einer Waffe, unabhängig vom Arbeitsverzeichnis (wie placeTarget in tools/assets/places.mjs). */
|
||||
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));
|
||||
const meshes = [];
|
||||
fbx.traverse((object) => { if (object.isMesh) meshes.push(object); });
|
||||
if (meshes.length === 0) throw new Error(`${source.id}: kein Mesh in ${source.mesh}`);
|
||||
// Der Entwurf misst "genau ein Mesh mit einem Material" je Datei; hier wird das geprüft statt nur vorausgesetzt.
|
||||
if (meshes.length > 1) {
|
||||
throw new Error(`${source.id}: ${meshes.length} Meshes in ${source.mesh}, erwartet genau eins`);
|
||||
}
|
||||
const mesh = meshes[0];
|
||||
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