Files
gta6/tools/assets/weapons.mjs
T
knsoandClaude Opus 5 a0cd95cceb fix(showroom): Funde der Schlussdurchsicht auf der Testseite behoben
Die Spielfigur wirft jetzt einen Schatten (player.init durchlief applyCharacter
nie); drei stille Fehlerpfade meckern jetzt statt roh abzubrechen (fehlender
Clip fuer die Fingerhaltung, Waffe ohne Griff, mit neuem Test); die
Waffenauswahl im Panel zeigt Beschriftungen statt IDs; der Waffen-Umbau prueft
jetzt die Meshzahl je Datei; stageTransform ist aus main.js nach lineup.js
gezogen und mit dem im Browser gemessenen Fall getestet; slotPosition nimmt
DUMMIES als Argument statt es aus dem Modul zu lesen; dazu Kommentar- und
Entwurfskorrekturen (Docstring, Beckenspur-Test, Frustum-Culling, Port/Kamera/
VIEW_DISTANCE im Entwurf).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 03:19:42 +02:00

203 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();