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
@@ -20,6 +20,13 @@
|
||||
],
|
||||
"port": 5173,
|
||||
"url": "http://localhost:5173"
|
||||
},
|
||||
{
|
||||
"name": "animationen",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"port": 5180,
|
||||
"url": "http://localhost:5180"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leonida · Animationen</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; }
|
||||
#container { width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
<script type="module" src="/src/showroom/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,378 @@
|
||||
# Animations-Testseite mit Waffen-Dummies — Entwurf
|
||||
|
||||
**Datum:** 2026-09-17
|
||||
**Zusammenhang:** Der Nutzer hat zwei Packs mitgegeben, `~/Downloads/MotionLibrary_Weapons.zip` (Mocap) und `~/Downloads/ASISWeaponsPack.zip` (Waffen), mit dem Auftrag: „Mach eine neue Branch. In der du Character spawnst. Die aus diesem Animationen-Pack die Animationen einfach in einer Loop ausführen. Und man kann sich die angucken. Man soll aber selbst auch mit einem Charakter rumlaufen können. Und man soll […] die Waffe, die ich dir mitgebe, falls es eine Animation gibt, die passt zu der Waffe, […] diese Waffe in der Hand halten, also die Dummies, die dann da rumstehen."
|
||||
**Baut auf:** `src/characters/index.js` (Mannequin `ual.glb`, `PLAYER_MODEL_BASE`), `src/world/sky.js` und `src/world/shadows.js` (Himmel, Licht, Kaskadenschatten), `tools/assets/places.mjs` (Aufbereitung mit gltf-transform, WebP, meshopt).
|
||||
|
||||
## Ziel
|
||||
|
||||
Eine eigene Seite `animationen.html`. Darauf stehen 13 Mannequin-Dummies und spielen die 11 Mocap-Clips in Dauerschleife. Acht von ihnen halten eine Waffe aus dem Pack, die zu ihrem Clip passt. Der Nutzer läuft mit seiner eigenen Figur zwischen ihnen herum.
|
||||
|
||||
## Befund (gemessen)
|
||||
|
||||
### Animationen
|
||||
|
||||
| Punkt | Wert |
|
||||
|---|---|
|
||||
| Dateien | 11 × FBX 7.7 binär, zusammen 13,2 MB. Kein Mesh, nur Skelett, ein Clip und die Producer-Kameras von MotionBuilder. |
|
||||
| Skelett | 24 Knochen mit Xsens-MVN-Namen: `Reference`, `Hips`, `Spine`–`Spine3`, `Neck`, `Head`, je Seite `Shoulder`, `Arm`, `ForeArm`, `Hand`, `UpLeg`, `Leg`, `Foot`, `ToeBase`. Keine Finger. |
|
||||
| Einheit, Achsen | cm, Y oben, Blick nach +Z, links des Darstellers bei +X |
|
||||
| Ruhepose | T-Pose, jede Knochen-Weltdrehung 0° |
|
||||
| Spuren | `Hips.position` und 23 × `.quaternion`, 120 fps, Zeitstempel auf ms gerundet (Abstände 7–9 ms) |
|
||||
| Frame 0 | die T-Pose selbst, 8 ms vor Frame 1, mit dem die Bewegung beginnt |
|
||||
| Hüfthöhe der Ruhepose | je Aufnahmetag verschieden: 0,909 m (14.07.) und 0,900 m (15.07.) |
|
||||
|
||||
| Clip (aus dem Dateinamen) | Dauer | Drift netto | weiteste Abweichung nach Driftabzug | Hüfte am Anfang | Naht: Blickwinkel / größte Knochenabweichung |
|
||||
|---|---|---|---|---|---|
|
||||
| `hunter_bowArrow_tk01` | 77,65 s | 2,17 m | 3,13 m | −2° | 72° / 91° |
|
||||
| `leftHandThrow_grenade_tk01` | 6,65 s | 0,06 m | 0,21 m | −44° | 7° / 15° |
|
||||
| `leftHandThrow_grenade_tk02` | 4,30 s | 0,06 m | 0,18 m | −38° | 0° / 7° |
|
||||
| `taunt_shootSelfInHead_tk01` | 7,83 s | 0,09 m | 0,16 m | −17° | 1° / 15° |
|
||||
| `taunt_shootSelfInHead_tk02` | 6,71 s | 0,04 m | 0,42 m | −16° | −6° / 13° |
|
||||
| `stabTwist_Knife_tk01` | 8,48 s | 0,37 m | 0,19 m | −28° | −1° / 15° |
|
||||
| `stabTwist_Knife_tk02` | 6,27 s | 0,08 m | 0,20 m | −25° | −3° / 17° |
|
||||
| `idle_loadGun-Hand_tk01` | 6,56 s | 0,01 m | 0,05 m | −19° | 2° / 19° |
|
||||
| `idle_loadGun-Hand_tk02` | 5,89 s | 0,02 m | 0,03 m | −18° | 0° / 18° |
|
||||
| `idle_loadGun-Rifle_tk04` | 7,75 s | 0,01 m | 0,02 m | −10° | 1° / 16° |
|
||||
| `idle_loadGun-Rifle_tk05` | 6,13 s | 0,02 m | 0,02 m | −24° | 2° / 14° |
|
||||
|
||||
Drift und Naht sind ab Frame 1 gemessen. „Hüfte am Anfang" ist der Winkel des Hüft-Vorwärtsvektors gegen +Z in der Waagrechten.
|
||||
|
||||
### Mannequin (`public/models/ual.glb`)
|
||||
|
||||
| Punkt | Wert |
|
||||
|---|---|
|
||||
| Aufbau | `Armature` → `Mannequin` (Skinned Mesh, 13 743 Dreiecke, 2 Primitive, keine Texturen) und `root` (−90° um X, Blender-Z-oben) → `pelvis` … |
|
||||
| Skelett | 65 Knochen mit Unreal-Namen. Je Hand 15 Fingerknochen und 5 Blattknochen. 53 Clips. |
|
||||
| Ruhepose | T-Pose, Blick nach +Z, links bei +X, 1,83 m hoch, `pelvis` bei 0,917 m |
|
||||
| Spielfigur | Der Controller skaliert das Modell auf `playerCapsuleHeight` (180) × `scale` (`PlayerController.ts`, Zeilen 438–476). Mit `PLAYER_MODEL_BASE.scale` = 0,005 ist die Figur laut Code 0,9 Welteinheiten hoch, nicht 1,83. |
|
||||
| Finger in `Pistol_Idle_Loop`, Frame 0 | beide Hände gekrümmt, gegen die Ruhepose: Zeigefinger 45° (rechts) / 41° (links), Mittelfinger 63° / 48°, rechter Daumen 41° |
|
||||
| Finger in `Idle_Loop`, Frame 0 | locker gekrümmt: Zeige- und Mittelfinger 77°, Daumen 26° |
|
||||
|
||||
Höhen der Wirbelsäule im Vergleich (Ruhepose, m):
|
||||
|
||||
| MVN | Höhe | Mannequin | Höhe |
|
||||
|---|---|---|---|
|
||||
| `Hips` | 0,900 | `pelvis` | 0,917 |
|
||||
| `Spine` | 0,995 | `spine_01` | 1,051 |
|
||||
| `Spine1` | 1,095 | `spine_02` | 1,174 |
|
||||
| `Spine2` | 1,187 | — | — |
|
||||
| `Spine3` | 1,279 | `spine_03` | 1,315 |
|
||||
| `Neck` | 1,411 | `neck_01` | 1,488 |
|
||||
| `Head` | 1,506 | `Head` | 1,569 |
|
||||
|
||||
### Waffen
|
||||
|
||||
| Waffe | Quelle im Pack | Eckpunkte (nicht indiziert) | Länge | Texturen |
|
||||
|---|---|---|---|---|
|
||||
| Pistole | `Pistol/SM_Pistol.fbx` | 8 592, zwei UV-Sätze | 0,19 m | `T_`/`N_` 2048 px, `M_` 512 px |
|
||||
| AK47 | `AK47_Option2(Same Socketing)/SM_AK47.fbx` | 15 534 | 0,87 m | `T_`/`N_` 2048 px, `M_` 1024 px |
|
||||
| M4 | `M4 Carbine/SM_M4_Carbine.fbx` | 17 403 | 0,86 m | `T_`/`N_` 2048 px, `M_` 1024 px |
|
||||
| 870 Shotgun | `870 Shotgun/SM_870_Shotgun.fbx` | 7 812 | 1,00 m | `T_`/`N_`/`M_` 2048 px |
|
||||
| L96 | `L96 Sniper Rifle/SM_L96_Sniper_Rifle.fbx` | 21 318 | 1,17 m | `T_` 2048 px RGBA (Alpha überall 255), `N_` 2048 px, `M_` 1024 px |
|
||||
|
||||
- **Knoten:**
|
||||
- Jede Datei hat genau ein Mesh mit einem Material.
|
||||
- Die Pistole trägt den Knotenmaßstab 100 auf Eckpunkten, die schon in Metern liegen.
|
||||
- Die L96 trägt die Knotendrehung (180°, 0°, 180°).
|
||||
- Die übrigen haben keine Knotentransformation.
|
||||
- Der Ursprung liegt am Griff, bei der M4 zum Beispiel zwischen x −0,30 und 0,56.
|
||||
- **AK47 „Option 1 (Different Socketing)":** 25 Skinned Meshes an den Knochen `Root`, `Trigger` und `Magazine`, Lambert-Material, anderer Griffpunkt, Texturen in 4096 px. Wird nicht verwendet.
|
||||
- **Normalmaps:** schon im OpenGL-Format. Bei allen fünf Waffen ist ∂R/∂y negativ mit ∂G/∂x korreliert (−0,09 bis −0,42). Bei DirectX-Karten wäre die Korrelation positiv.
|
||||
- **Masken `M_*.tga`:**
|
||||
- TGA Typ 2 (unkomprimiert), 32 Bit, kein ID-Feld, Ursprung unten links, TGA-2.0-Fußzeile.
|
||||
- R, G und B sind gleich (ein Grauwert), dazu kommt Alpha.
|
||||
- AK47, Median je Fläche: Holz Grau 0,85 / Alpha 0,22, Metall Grau 0,95 / Alpha 0,70.
|
||||
- Daraus folgt: **Grau = Rauheit, Alpha = Metall**. Der Alphakanal trennt Holz von Metall, der Grauwert nicht.
|
||||
- **Lizenz:** In keinem der beiden Zips liegt eine Lizenzdatei.
|
||||
|
||||
### Welche Waffe zu welchem Clip passt
|
||||
|
||||
- **Pistole:** `idle_loadGun-Hand`, `taunt_shootSelfInHead`
|
||||
- **Gewehr (AK47, M4):** `idle_loadGun-Rifle`
|
||||
- **Messer, Granate, Bogen:** Diese Waffen sind nicht im Pack.
|
||||
- **870 und L96:** Für sie gibt es keinen eigenen Clip.
|
||||
|
||||
## Entscheidungen des Nutzers
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Ort | Eigene Testseite. Verworfen: eine Reihe Dummies in der Welt am Startpunkt. |
|
||||
| Shotgun und L96 | Sie kommen dazu, mit den beiden Gewehr-Nachladeclips. |
|
||||
| Weg | Umbau vorab mit Node-Skripten. Verworfen: FBX im Browser parsen und übertragen, sowie `SkeletonUtils.retargetClip` statt eigener Übertragung. |
|
||||
| Entwurf Abschnitt 1–3 | bestätigt (Seite, Aufstellung und Panel; Umbau, Griff und Finger; Tests, Branch und Lizenz) |
|
||||
| Arbeitsweise | Arbeitsbaum `.claude/worktrees/animationen`, Branch `leonida/animationen` von lokalem `main` (`ee49fd7`, 24 Commits vor `origin/main`). Auf `leonida/strassen` arbeitet parallel eine andere Sitzung. |
|
||||
|
||||
## Testseite
|
||||
|
||||
### Seite und Szene
|
||||
|
||||
- **Einstieg:** `animationen.html` im Wurzelverzeichnis neben `index.html` lädt `src/showroom/main.js`. Die Seite läuft über ihre eigene `.claude/launch.json`-Konfiguration `animationen` auf Port 5180; das Preview-Werkzeug erlaubt in einer localhost-Adresse keinen Pfad, deshalb wird der Server gestartet und danach im Browser selbst auf `/animationen.html` navigiert.
|
||||
- **`vite.config.js`:** `build.rollupOptions.input` nennt beide Seiten, damit `npm run build` auch die Testseite baut.
|
||||
- **Licht:** Renderer, Tonemapping, Kamera, Nebel, `createSky` und `createSunShadows` wie in `src/main.js`, mit denselben Werten für Sonne, Himmelslicht und Kuppelhelligkeit (dort lokale Konstanten, hier mit Verweis übernommen).
|
||||
- **Kamera:** Der Nahwert ist 0,1 statt 0,5 wie im Spiel: Hier steht nur eine Ebene, es gibt keine übereinanderliegenden Flächen, die im Tiefenpuffer flackern könnten, dafür geht die Kamera näher an die Dummies heran.
|
||||
- **Boden:** eine Ebene von 200 × 200 Welteinheiten bei y = 0 mit der Textur `concrete_pavement` aus `public/textures` (Diffuse, `nor_gl`, `arm`). Sie ist der einzige Collider der Spielfigur und muss schon bei `player.init` stehen. Deshalb hängt sie nicht an `stage` (siehe Maßstab) und ist für jeden Maßstab groß genug. Die Kachel misst 1,8 Mannequin-Meter (Poly Haven misst `concrete_pavement` mit 1,8 × 1,8 m). `texture.repeat` wird nach `player.init` gesetzt.
|
||||
- **Nicht auf der Seite:** Stadt, Streaming, Fahrzeuge, Minimap, Figurenwahl.
|
||||
|
||||
### Maßstab
|
||||
|
||||
Die Spielfigur hat nicht die 1,83 m des Mannequins (siehe Befund). Die Aufstellung hängt deshalb an einer Gruppe `stage`. Sie bekommt nach `player.init` den **Weltmaßstab des Spielermodells** (`player.playerModel.getWorldScale`), ausgelesen und nicht nachgerechnet. Alle Maße unten sind Mannequin-Meter: Dummies, Waffen, Schilder, Abstände und der Abstand zur Spielfigur. Welteinheit = Mannequin-Meter × Maßstab. Dummies und Spielfigur sind damit gleich groß.
|
||||
|
||||
### Spielfigur
|
||||
|
||||
- **Modell:** `ual.glb` mit `playerController` und `PLAYER_MODEL_BASE` aus `src/characters/index.js`, wie im Spiel. Laufen, Sprinten, Springen und Kamera verhalten sich wie gewohnt.
|
||||
- **Start:** Die Figur beginnt über dem Ursprung. Wohin die Kamera anfangs schaut, bestimmt der Controller: `PLAYER_MODEL_BASE.rotateY` dreht die Kapsel um −90°, und `CameraSystem.ts` setzt die Kamera daraus seitlich. Deshalb wird nicht die Figur versetzt, sondern die Aufstellung. Nach dem ersten Bild steht `stage` 8 Mannequin-Meter vor der Figur in Blickrichtung der Kamera und ist so gedreht, dass die Dummies zur Figur schauen.
|
||||
|
||||
### Aufstellung (`src/showroom/lineup.js`)
|
||||
|
||||
Reine Daten und reine Funktionen, ohne Browser testbar.
|
||||
|
||||
| Reihe, Platz | Clip | Schild | Waffe | Hand |
|
||||
|---|---|---|---|---|
|
||||
| 1, 1 | `idle_loadGun-Hand_tk01` | Pistole nachladen · Take 1 | Pistole | `r` |
|
||||
| 1, 2 | `idle_loadGun-Hand_tk02` | Pistole nachladen · Take 2 | Pistole | `r` |
|
||||
| 1, 3 | `taunt_shootSelfInHead_tk01` | Taunt: Schuss in den Kopf · Take 1 | Pistole | `r` |
|
||||
| 1, 4 | `taunt_shootSelfInHead_tk02` | Taunt: Schuss in den Kopf · Take 2 | Pistole | `r` |
|
||||
| 1, 5 | `idle_loadGun-Rifle_tk04` | Gewehr nachladen · Take 4 | AK47 | `r` |
|
||||
| 1, 6 | `idle_loadGun-Rifle_tk05` | Gewehr nachladen · Take 5 | M4 | `r` |
|
||||
| 1, 7 | `idle_loadGun-Rifle_tk04` | Gewehr nachladen · Take 4 | 870 Shotgun | `r` |
|
||||
| 1, 8 | `idle_loadGun-Rifle_tk05` | Gewehr nachladen · Take 5 | L96 | `r` |
|
||||
| 2, 1 | `stabTwist_Knife_tk01` | Messerstich mit Drehung · Take 1 | — | — |
|
||||
| 2, 2 | `stabTwist_Knife_tk02` | Messerstich mit Drehung · Take 2 | — | — |
|
||||
| 2, 3 | `leftHandThrow_grenade_tk01` | Granate werfen (links) · Take 1 | — | — |
|
||||
| 2, 4 | `leftHandThrow_grenade_tk02` | Granate werfen (links) · Take 2 | — | — |
|
||||
| 2, 5 | `hunter_bowArrow_tk01` | Bogenschütze · Take 1 | — | — |
|
||||
|
||||
- **Reihe 1:** bei z = 0, acht Plätze bei x = −12,25 + 3,5 · i.
|
||||
- **Reihe 2:** bei z = −8. Die ersten vier Plätze liegen wie in Reihe 1 (x = −12,25 bis −1,75), der Bogenschütze steht bei x = 8,75. Zum nächsten Dummy hat er damit 8 m Abstand, genug für seine 3,13 m Wanderung.
|
||||
- **Blickrichtung:** Alle Dummies schauen innerhalb von `stage` nach +Z, also zur Spielfigur (siehe Start). Die Clips sind darauf ausgerichtet (Umbau, Schritt 3), die Dummies selbst werden nicht gedreht.
|
||||
- **Abstand zur Spielfigur:** `VIEW_DISTANCE = 8` ist bewusst nah gewählt, keine Nachlässigkeit: Beim Start stehen vier der acht Dummies aus Reihe 1 im Bild, die übrigen erreicht man mit wenigen Schritten.
|
||||
- **Schild:** ein Canvas-Sprite 2,2 m über dem Platz (nicht am Dummy, damit es beim Bogenschützen stehen bleibt). Es zeigt den Text aus der Spalte „Schild" und darunter die Waffe oder „ohne Waffe".
|
||||
- **„Hand":** Vorgabe ist `r`. Hält ein Darsteller die Waffe sichtbar links, zum Beispiel weil die rechte Hand nachlädt, wird der Eintrag im Browser auf `l` gesetzt und dies im Bericht festgehalten.
|
||||
|
||||
### Dummies (`src/showroom/dummies.js`)
|
||||
|
||||
- **Laden:** `ual.glb` einmal, `weapons-mocap.glb` und die Waffen mit `GLTFLoader` samt `MeshoptDecoder`, wie `src/world/places.js`.
|
||||
- **Je Dummy:**
|
||||
- `SkeletonUtils.clone` des Mannequins, Schatten an.
|
||||
- Ein eigener `AnimationMixer` mit zwei Aktionen:
|
||||
- der Mocap-Clip mit `LoopRepeat`,
|
||||
- die Fingerhaltung als konstanter Clip, nur mit den `.quaternion`-Spuren der Fingerknochen aus dem ersten Frame. Dummies mit Waffe bekommen sie für beide Hände aus `Pistol_Idle_Loop`, Dummies ohne Waffe aus `Idle_Loop`.
|
||||
- Beide Aktionen bewegen verschiedene Knochen, keine Gewichtung nötig.
|
||||
- **Waffe:** Kind des Handknochens (`hand_r` oder `hand_l`), mit dem Griff der Waffe aus `WEAPONS` (Position in m, Drehung in Grad, Reihenfolge XYZ).
|
||||
- **Jedes Bild:** `mixer.update(delta · Tempo)`, außer bei Pause.
|
||||
|
||||
### Panel (lil-gui)
|
||||
|
||||
- **Tempo:** 0 bis 2 in Schritten von 0,05, Vorgabe 1.
|
||||
- **Pause:** an/aus.
|
||||
- **Griff:**
|
||||
- Waffe auswählen.
|
||||
- Position x/y/z von −0,3 bis 0,3 m, Drehung x/y/z von −180° bis 180°.
|
||||
- Wirkt sofort auf alle Dummies mit dieser Waffe.
|
||||
- „Werte ausgeben" schreibt den Griff als JSON in die Konsole, zum Übernehmen in `WEAPONS`.
|
||||
|
||||
## Umbau der Animationen (`tools/assets/motions.mjs`)
|
||||
|
||||
```
|
||||
node tools/assets/motions.mjs <Ordner mit den 11 FBX> -> public/models/motions/weapons-mocap.glb
|
||||
```
|
||||
|
||||
`tools/assets/fbx.mjs` lädt FBX in Node mit dem `FBXLoader` von three.js. Dazu setzt es die Browser-Platzhalter, die der Loader braucht (`self`, `window`, `document.createElementNS`), und blendet die Kamerawarnungen aus. Die Rechenschritte sind reine, exportierte Funktionen:
|
||||
|
||||
1. **Clip-Name:** `/^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/` liefert zum Beispiel `idle_loadGun-Hand_tk01`.
|
||||
2. **Abtasten:**
|
||||
- D ist die Zeit von Frame 1 bis zum Clipende, n = round(D · 30) + 1.
|
||||
- Der Quellclip läuft in einem `AnimationMixer` und wird an n gleichmäßig verteilten Zeitpunkten von Frame 1 bis zum Ende abgelesen, im Abstand D/(n−1), also etwa 1/30 s.
|
||||
- Je Zeitpunkt werden die Weltdrehung jedes zugeordneten Knochens und die Weltposition von `Hips` abgelesen.
|
||||
- Die Zeiten des neuen Clips laufen von 0 bis D. Frame 0 (T-Pose) fällt dadurch weg.
|
||||
3. **Blickrichtung:** Der Hüft-Vorwärtsvektor des ersten Bilds zeigt in der Waagrechten um θ neben +Z. Alle Weltdrehungen und Hüftpositionen des Clips werden um −θ um die Hochachse gedreht.
|
||||
4. **Ort:**
|
||||
- Die waagrechte Hüftposition des ersten Bilds wird zu 0.
|
||||
- Die Drift wird linear abgezogen: Bild i verliert i/(n−1) · (Ende − Anfang), in x und z.
|
||||
- Ende und Anfang stehen danach am selben Punkt. Die Höhe bleibt unverändert.
|
||||
5. **Übertragen:**
|
||||
- Zuordnung MVN → Mannequin: `Hips`→`pelvis`, `Spine`→`spine_01`, `Spine1`→`spine_02`, `Spine3`→`spine_03`, `Neck`→`neck_01`, `Head`→`Head`. Je Seite außerdem `Shoulder`→`clavicle`, `Arm`→`upperarm`, `ForeArm`→`lowerarm`, `Hand`→`hand`, `UpLeg`→`thigh`, `Leg`→`calf`, `Foot`→`foot`, `ToeBase`→`ball`.
|
||||
- Die Zuordnung folgt der gemessenen Höhe. `Spine2` hat kein Gegenstück, ihre Drehung geht über die Weltdrehung in `spine_03` ein.
|
||||
- Weltdrehung Mannequin = Weltdrehung MVN · Ruhe-Weltdrehung Mannequin. Das gilt, weil beide Skelette in derselben T-Pose mit gleichem Blick ruhen und jede MVN-Ruhe-Weltdrehung 0° ist.
|
||||
- Lokale Drehung = (Weltdrehung Elternknochen)⁻¹ · Weltdrehung. Eltern ohne Zuordnung (`root`, `Armature`) gehen mit ihrer Ruhepose ein.
|
||||
- Hüfte: Position in m = Position in cm / 100 · (0,917 / Hüfthöhe der Datei), umgerechnet in den Raum von `root`.
|
||||
- Finger, Blattknochen und `root` bekommen keine Spur.
|
||||
6. **Schleife schließen:**
|
||||
- Naht = größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen.
|
||||
- Überblendzeit b = Naht / 90 °/s, begrenzt auf 0,25 s bis 1,5 s. Das ergibt 1,01 s für den Bogenschützen und 0,25 s für alle übrigen.
|
||||
- In den letzten b Sekunden werden alle lokalen Drehungen (slerp) und die Hüftposition (lerp) mit dem Gewicht smoothstep((t − (T − b)) / b) zum ersten Bild gezogen. Das letzte Bild ist dann gleich dem ersten.
|
||||
7. **Schreiben:**
|
||||
- gltf-transform-Dokument mit der Knotenhierarchie von `ual.glb` (Namen und Ruhe-TRS, ohne Mesh und Skin).
|
||||
- Je Clip eine Animation mit gemeinsamem Zeit-Accessor, 22 Drehungskanälen und einem Translationskanal (`pelvis`), alle LINEAR und als Float32.
|
||||
- Keine Kompression, geschätzt 1,6 MB (144 s × 30 Bilder × 91 Werte × 4 Byte).
|
||||
|
||||
Die Clips binden über Knotennamen an die Knochen eines geklonten Mannequins.
|
||||
|
||||
## Umbau der Waffen (`tools/assets/weapons.mjs`)
|
||||
|
||||
```
|
||||
node tools/assets/weapons.mjs <Ordner WeaponsPack> -> public/models/weapons/{pistol,ak47,m4,shotgun870,l96}.glb
|
||||
```
|
||||
|
||||
- **Geometrie:**
|
||||
- Positionen, Normalen und der erste UV-Satz kommen aus dem Mesh von `FBXLoader`.
|
||||
- Die Knotendrehung wird angewendet, der Knotenmaßstab nicht. Die Eckpunkte liegen schon in Metern (siehe Befund).
|
||||
- Längenprüfung: Pistole 0,15–0,25 m, Gewehre 0,7–1,3 m, sonst bricht das Skript mit Meldung ab.
|
||||
- UV: v → 1 − v, weil FBX den Ursprung unten links hat und glTF oben links.
|
||||
- Danach `weld()` und meshopt wie in `places.mjs`.
|
||||
- **Material** (glTF metallicRoughness):
|
||||
- Grundfarbe aus `T_` (sRGB, Alpha verworfen).
|
||||
- Normalmap aus `N_`, unverändert.
|
||||
- `metallicRoughnessTexture` wird neu zusammengesetzt: G = Grauwert der Maske (Rauheit), B = Alpha der Maske (Metall), R = 255.
|
||||
- Beide Faktoren stehen auf 1.
|
||||
- **TGA:** eigener Leser `readTga(buffer)` → `{ width, height, data }` in RGBA mit oberster Zeile zuerst. Er kann nur Typ 2 mit 32 Bit, bei allem anderen bricht er mit Meldung ab. Der Ursprung kommt aus Bit 5 von Byte 17.
|
||||
- **Texturen:** `sharp` → WebP mit höchstens 1024 px Kantenlänge, die 512-px-Maske der Pistole bleibt 512 px. Qualität 80, Normalmaps 92, wie `places.mjs`.
|
||||
- **AK47:** nur „Option 2".
|
||||
|
||||
## Griff einpassen
|
||||
|
||||
- **Werkzeug:** Die Griffwerte in `WEAPONS` entstehen im Browser mit dem Panel.
|
||||
- **Ansicht:** Nahaufnahme von der Seite und von oben an einer Stelle des Clips, an der die Hand ruhig die Waffe hält.
|
||||
- **Ziel:** Das Griffstück liegt in der Handfläche, der Lauf zeigt entlang der Hand nach vorn, und die Waffe steckt nicht im Unterarm.
|
||||
- **Vorgehen:** Das Pack verspricht für Pistole, M4, 870, L96 und AK47 (Option 2) denselben Griffpunkt. Zuerst wird die Pistole eingepasst, ihr Wert ist dann der Startwert der übrigen. Jede Waffe bekommt trotzdem ihren eigenen Eintrag.
|
||||
- **Befund (gemessen, Plan-Task 9):** Startwert `[0, 0, 90]` bringt die Längsachse der Waffe zwar korrekt auf die
|
||||
Knochenachse der Hand (im Browser mit einem Vektorvergleich Fingerrichtung/Waffen-X bestätigt, Punktprodukt 1),
|
||||
aber der Handknochen selbst sitzt am Handgelenk — ohne Positionsversatz reicht die Waffe deshalb bis in den
|
||||
Unterarm. Mit `position.y = 0,09` (≈ halbe Handlänge, in Richtung der Finger) rutscht der Griffpunkt in die
|
||||
Handfläche. Dieser Wert passt für alle fünf Waffen unverändert:
|
||||
- Pistole, AK47, M4, 870 Shotgun: `position: [0, 0.09, 0]`, `rotation: [0, 0, 90]`.
|
||||
- L96: gleiche Position, aber `rotation: [0, 0, -90]`. Ihre Bounding-Box liegt seitenverkehrt zu den anderen vier
|
||||
(Länge und Höhe tauschen die Seite, an der der Ursprung sitzt) — 180° zusätzliche Drehung um die eigene
|
||||
Querachse gleicht das aus. Über den ganzen Clip geprüft (AK47, Anfang/Mitte/Ende von `idle_loadGun-Rifle_tk04`):
|
||||
Griff bleibt an der Hand.
|
||||
- **Hand je Clip:** An allen acht Waffen-Dummies hält die rechte Hand (`hand_r`) die Waffe, die Vorgabe `hand: "r"`
|
||||
stimmt. Bei „Pistole/Gewehr nachladen" sieht man das am Bewegungsablauf: Die rechte Hand bleibt vorn auf Höhe der
|
||||
Waffe, während die linke zum Magazin greift und beide Hände sich kurz treffen, wenn das Magazin eingesetzt wird.
|
||||
Bei „Schuss in den Kopf" führt ebenfalls die rechte Hand die Pistole zum Kopf.
|
||||
- **Maske und Normalmap:** An der AK47 im Browser geprüft (Nahaufnahme mit Holzschaft und Metall-Receiver im selben
|
||||
Bild plus Punktproben aus der `metallicRoughness`-Textur per Raycast/UV): Holz liegt bei Rauheit ≈ 0,85 und
|
||||
Metallwert ≈ 0,22, der Receiver bei Rauheit ≈ 0,97 und Metallwert ≈ 0,69 — Metall ist also deutlich metallischer
|
||||
als Holz, die Kanäle sind nicht vertauscht. Beide Flächen wirken unter dem bedeckten Himmel der Seite eher matt,
|
||||
weil die Rauheit auf beiden hoch ist (beim Receiver sogar am höchsten) — das liest sich wie ein mattes,
|
||||
parkerisiertes Finish, nicht wie ein vertauschter Kanal. Die Normalmap ist nicht flach (Mittelwert nahe
|
||||
`(128, 128, 255)` mit echter Streuung an den Randwerten von Blau) und liegt korrekt ohne Farbraum vor. Kein
|
||||
Tausch nötig.
|
||||
|
||||
## Tests
|
||||
|
||||
- **`tests/motions.test.js`:**
|
||||
- `clipId` liest alle 11 Dateinamen richtig.
|
||||
- MVN in Ruhepose ergibt die Ruhepose des Mannequins, in lokalen Drehungen auf 1e-6.
|
||||
- Eine Weltdrehung von 90° am linken Oberarm kommt als Weltdrehung von `upperarm_l` an (· Ruhe-Weltdrehung).
|
||||
- Eine Drehung an `Spine3` dreht Schlüsselbein und Arm in der Welt mit, deren lokale Drehung bleibt gleich.
|
||||
- Hüfte: cm → m, Faktor 0,917 / Hüfthöhe, Raum von `root`.
|
||||
- Blickrichtung: Nach Schritt 3 zeigt die Hüfte im ersten Bild nach +Z.
|
||||
- Ort: Das erste Bild steht bei x = z = 0, das letzte ebenso.
|
||||
- Abtastung: n = round(D · 30) + 1 Zeiten von 0 bis D in gleichem Abstand, das erste Bild entspricht Frame 1 der Quelle.
|
||||
- Schleife: Letztes und erstes Bild sind gleich. Die Überblendzeit hält die Grenzen 0,25 s und 1,5 s ein.
|
||||
- **`tests/weapons.test.js`:**
|
||||
- `readTga` liest eine künstliche 2 × 2-Datei richtig, mit Ursprung unten und oben.
|
||||
- Typ 10 (RLE) und 24 Bit werden abgelehnt.
|
||||
- Maske → metallicRoughness: G = Grau, B = Alpha, R = 255.
|
||||
- UV: v → 1 − v.
|
||||
- Die Längenprüfung schlägt bei falscher Einheit an (Pistole mal 100).
|
||||
- **`tests/showroom.test.js`:**
|
||||
- 13 Dummies. Jeder Clip existiert in `weapons-mocap.glb`, jede Waffe hat eine Datei in `public/models/weapons/` und einen Griff.
|
||||
- Kein Platz näher als 3,5 m an einem anderen. Um den Bogenschützen sind mindestens 4 m frei.
|
||||
- Jede Waffen-GLB hat ein Mesh, meshopt, WebP-Texturen bis 1024 px und liegt in den Längengrenzen.
|
||||
- **Browser:**
|
||||
- `animationen.html` lädt ohne Konsolenfehler, alle 13 Mixer laufen.
|
||||
- Übersichtsbild und Nahaufnahme je Waffe.
|
||||
- Schleifennaht: Bei jedem Dummy ist der größte Knochenwinkel zwischen der Pose 1/30 s vor und 1/30 s nach dem Rücksprung nicht größer als der größte Winkel zwischen zwei Posen im Abstand von 2/30 s im Clipinneren.
|
||||
- Die Spielfigur läuft per Tastatur, ihre Position ändert sich.
|
||||
- Bildzeit mit allen Dummies.
|
||||
- Maskenbelegung auf Sicht: Das Holz der AK47 glänzt nicht metallisch.
|
||||
- **Gesamt:** `npm test` grün, `npm run build` baut beide Seiten, `index.html` lädt weiter wie bisher.
|
||||
|
||||
## Gemessen im Browser (2026-09-18)
|
||||
|
||||
Schlussprüfung (Plan-Task 10): `animationen.html` über den Dev-Server geladen, `window.__showroom` genutzt.
|
||||
Die Browser-Pane lief dabei verborgen, `requestAnimationFrame` also gedrosselt — Bildtakt und Bildzeit wurden
|
||||
deshalb von Hand über `loop(time)` angetrieben statt über echtes `requestAnimationFrame` (Einzelheiten im
|
||||
Task-Bericht).
|
||||
|
||||
- **Dateigrößen** (`fs.statSync`, dezimale MB): `weapons-mocap.glb` 1,66 MB. Waffen: Pistole 0,16 MB, AK47
|
||||
0,23 MB, M4 0,21 MB, 870 Shotgun 0,12 MB, L96 0,14 MB.
|
||||
- **Maßstab und Dummy-Größe:** `figureScale` (Weltmaßstab von `player.playerModel`, wie in Task 8) ist 0,4921.
|
||||
Die Mannequin-Ruhepose ist 1,83 m hoch (Befund oben); Dummies und Spielfigur sind damit gleich groß, je
|
||||
1,83 × 0,4921 ≈ 0,90 m.
|
||||
- **Hand je Clip:** An allen acht Waffen-Dummies bestätigt: `hand_r`, wie in `DUMMIES` hinterlegt
|
||||
(`hand: "r"`). Keine Änderung nötig.
|
||||
- **Griffwerte je Waffe** (aus `WEAPONS`, unverändert seit Task 9): Pistole, AK47, M4, 870 Shotgun
|
||||
`position [0, 0.09, 0]`, `rotation [0, 0, 90]`; L96 gleiche Position, `rotation [0, 0, -90]`.
|
||||
- **Schleifennaht je Dummy:** Naht (Winkel zwischen der Pose 1/30 s vor und 1/30 s nach dem Rücksprung) gegen
|
||||
größten Zweibild-Schritt im Clipinneren (Abstand 2/30 s), in Grad. Bei allen 13 Dummies ist die Naht
|
||||
kleiner als der größte Schritt:
|
||||
|
||||
| Reihe, Platz | Clip | Waffe | Naht | Größter Schritt |
|
||||
|---|---|---|---|---|
|
||||
| 1, 1 | `idle_loadGun-Hand_tk01` | Pistole | 0,9° | 46,2° |
|
||||
| 1, 2 | `idle_loadGun-Hand_tk02` | Pistole | 0,9° | 49,4° |
|
||||
| 1, 3 | `taunt_shootSelfInHead_tk01` | Pistole | 0,7° | 39,5° |
|
||||
| 1, 4 | `taunt_shootSelfInHead_tk02` | Pistole | 0,7° | 44,7° |
|
||||
| 1, 5 | `idle_loadGun-Rifle_tk04` | AK47 | 0,8° | 55,0° |
|
||||
| 1, 6 | `idle_loadGun-Rifle_tk05` | M4 | 0,6° | 30,4° |
|
||||
| 1, 7 | `idle_loadGun-Rifle_tk04` | 870 Shotgun | 0,8° | 55,0° |
|
||||
| 1, 8 | `idle_loadGun-Rifle_tk05` | L96 | 0,6° | 30,4° |
|
||||
| 2, 1 | `stabTwist_Knife_tk01` | — | 0,9° | 58,5° |
|
||||
| 2, 2 | `stabTwist_Knife_tk02` | — | 0,8° | 61,3° |
|
||||
| 2, 3 | `leftHandThrow_grenade_tk01` | — | 1,0° | 51,3° |
|
||||
| 2, 4 | `leftHandThrow_grenade_tk02` | — | 0,6° | 59,7° |
|
||||
| 2, 5 | `hunter_bowArrow_tk01` | — | 1,2° | 40,7° |
|
||||
|
||||
Gleiche Clips (Reihe 1, Platz 5/7 und 6/8) ergeben gleiche Werte, weil die Naht eine Eigenschaft des Clips
|
||||
ist, nicht der Waffe.
|
||||
- **Bildzeit:** Gemessen als Zeit eines vollständig gezeichneten Bildes (CPU- und GPU-Zeit zusammen), weil die
|
||||
`requestAnimationFrame`-Kadenz aus dem Plan in der verborgenen Pane nicht läuft: je Bild `performance.now()`
|
||||
vor `loop(time)`, danach `gl.finish()` (erzwingt das Warten auf die GPU), `performance.now()` danach. Über
|
||||
60 so angetriebene Bilder mit allen 13 Mixern aktiv: Median 2,9 ms, Maximum 3,5 ms — weit unter den 20 ms
|
||||
Richtwert aus dem Plan. Dabei `renderer.info.render`: 45 Draw Calls, 209 145 Dreiecke (deckungsgleich mit
|
||||
der Messung aus Task 8).
|
||||
- **Maske und Normalmap:** Beim erneuten Betrachten der AK47 in dieser Prüfung kein Hinweis auf vertauschte
|
||||
Kanäle oder eine flache Normalmap. Deckt sich mit dem in „Griff einpassen" festgehaltenen, dort rechnerisch
|
||||
belegten Befund (Holz Rauheit ≈ 0,85 / Metallwert ≈ 0,22, Receiver Rauheit ≈ 0,97 / Metallwert ≈ 0,69); hier
|
||||
nur gegengeprüft, nicht neu vermessen.
|
||||
- **Durchdringungen:**
|
||||
- **Taunt am Kopf:** Bei beiden Take von `taunt_shootSelfInHead` liegt die Pistole sichtbar an der
|
||||
Kopfseite/Schläfe an, in zwei Kamerawinkeln geprüft. Kleinster gemessener Abstand zwischen
|
||||
Waffenursprung und dem `Head`-Knochen: 4,3 cm (Take 2, bei t = 2,48 s im Clip). Die in „Fehlerfälle und
|
||||
Risiken" vermerkte Möglichkeit „Pistole kann in den Kopf ragen" bestätigt sich als sichtbarer Kontakt.
|
||||
- **Linke Hand am Gewehr:** Die AK47 liegt erkennbar im beidhändigen Griff (rechte Hand hinten am
|
||||
Griffstück). Ein unverdeckter Blick auf die linke Hand gelang in dieser Prüfung nicht, die Kamera folgt
|
||||
der Spielfigur und ließ sich nicht frei zur Seite stellen. Gemessen: kleinster Abstand zwischen
|
||||
`hand_l` und dem Waffenursprung 3,7 cm (bei t = 2,25 s im Clip `idle_loadGun-Rifle_tk04`) — die Hand ist
|
||||
damit sehr nah am Gewehrkörper; eine sichtbare Durchdringung wurde nicht geprüft.
|
||||
- **Spiel:** `http://localhost:5180/` neu geladen, Welt stand nach wenigen Sekunden. Einzige Konsolenfehler:
|
||||
viermal `WrongDocumentError: pointer lock` (bekanntes Artefakt der verborgenen Pane, schon in Task 8/9
|
||||
dokumentiert) und einmal `net::ERR_BLOCKED_BY_CLIENT`. Der Netzwerk-Mitschnitt zeigt für alle eigenen
|
||||
Ressourcen der Seite (Skripte, Texturen, Modelle, Chunk-Daten) Status 200; das blockierte Element gehört zu
|
||||
keiner davon. Kein neuer, dieser Änderung zuzuordnender Fehler.
|
||||
|
||||
## Fehlerfälle und Risiken
|
||||
|
||||
- **Maskenbelegung falsch:** Glänzt Holz metallisch oder wirkt Metall stumpf wie Kunststoff, werden Grau und Alpha getauscht und neu gebaut. Die Browserprüfung oben fängt das.
|
||||
- **Proportionen:**
|
||||
- Der Darsteller ist kleiner als das Mannequin, der Kopf liegt bei 1,506 m statt 1,569 m.
|
||||
- Beim Taunt kann die Pistole in den Kopf ragen, die Hände können sich durchdringen, die Füße ein paar Zentimeter schweben oder einsinken.
|
||||
- Es gibt keine IK. Auffälliges wird im Gespräch entschieden.
|
||||
- **Linke Hand am Gewehr:** Sie ist nicht an den Handschutz gebunden und liegt dort, wo die Mocap-Daten sie hinlegen.
|
||||
- **Blickrichtung:** Nach Schritt 3 schauen die Hüften nach vorn. Der Granatwurf mit −44° Anfangsdrehung zielt danach entsprechend schräg.
|
||||
- **Bogenschütze:** Er wandert bis 3,13 m (× Maßstab) von seinem Platz und dreht sich dabei. Die Überblendung am Ende dauert 1 s.
|
||||
- **Node-Platzhalter für `FBXLoader`:** Sie hängen an three 0.184.0 (in `package.json` fest). Ein Update kann den Umbau brechen, die erzeugten GLBs bleiben davon unberührt.
|
||||
- **Lizenz:** Die Zips enthalten keine Lizenzdatei. Die erzeugten GLBs werden wie die übrigen Modelle versioniert (Remote `git.webklar.com`), die Zips selbst nicht (`*.zip` in `.gitignore`).
|
||||
|
||||
## Nicht Teil dieser Arbeit
|
||||
|
||||
- Dummies in der Leonida-Welt.
|
||||
- Waffen für die Spielfigur (Tragen, Zielen, Schießen).
|
||||
- IK für Hände oder Füße.
|
||||
- Kollision mit Dummies.
|
||||
- Superhero-Figuren oder Frisuren als Dummies.
|
||||
- AK47 „Option 1".
|
||||
- Eigene Modelle für Messer, Granate und Bogen.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
// Ein Dummy der Animations-Testseite: geklontes Mannequin, Mocap-Clip in Dauerschleife, dazu eine feste
|
||||
// Fingerhaltung und wahlweise eine Waffe im Handknochen
|
||||
// (docs/superpowers/specs/2026-09-17-animationen-design.md, "Dummies").
|
||||
import { AnimationClip, AnimationMixer, LoopRepeat, MathUtils, QuaternionKeyframeTrack } from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
|
||||
/** Fingerknochen des Mannequins ohne die Blattknochen. Die Mocap-Daten haben keine Finger. */
|
||||
export const FINGER_BONE = /^(thumb|index|middle|ring|pinky)_0[1-3]_[lr]$/;
|
||||
|
||||
/**
|
||||
* Macht aus einem Clip des Mannequins (Pistol_Idle_Loop für die Waffenhand, Idle_Loop für leere Hände) eine feste
|
||||
* Fingerhaltung: nur die Drehungen der Fingerknochen, eingefroren auf ihren ersten Keyframe. Läuft als zweite
|
||||
* Aktion auf demselben Mixer — sie berührt keinen Knochen, den der Mocap-Clip bewegt.
|
||||
*/
|
||||
export function fingerPoseClip(source, name = `${source.name}_Finger`) {
|
||||
const tracks = source.tracks
|
||||
.filter((track) => track.name.endsWith(".quaternion")
|
||||
&& FINGER_BONE.test(track.name.slice(0, track.name.lastIndexOf("."))))
|
||||
.map((track) => {
|
||||
const first = [...track.values.slice(0, 4)];
|
||||
return new QuaternionKeyframeTrack(track.name, [0, 1], [...first, ...first]);
|
||||
});
|
||||
if (tracks.length === 0) throw new Error(`${source.name}: keine Fingerspuren`);
|
||||
return new AnimationClip(name, 1, tracks);
|
||||
}
|
||||
|
||||
/** Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad (XYZ). */
|
||||
export function applyGrip(object, grip) {
|
||||
object.position.fromArray(grip.position);
|
||||
object.rotation.set(...grip.rotation.map((degrees) => MathUtils.degToRad(degrees)), "XYZ");
|
||||
}
|
||||
|
||||
/** Baut einen Dummy. template ist das geladene Mannequin, weapon die geladene Waffenszene (wird geklont). */
|
||||
export function createDummy({ template, clip, fingers, weapon = null, hand = "r", grip = null }) {
|
||||
const root = cloneSkeleton(template);
|
||||
root.traverse((object) => {
|
||||
if (!object.isMesh) return;
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
// Für ein Skinned Mesh ist die Hülle der Ruhepose ohnehin die falsche Grundlage, sobald Knochen die
|
||||
// Figur bewegen — besonders sichtbar beim Bogenschützen, der bis 3 m von seinem Platz wandert. Deshalb
|
||||
// für alle Dummies aus, nicht nur für ihn.
|
||||
object.frustumCulled = false;
|
||||
});
|
||||
const mixer = new AnimationMixer(root);
|
||||
mixer.clipAction(clip).setLoop(LoopRepeat, Infinity).play();
|
||||
mixer.clipAction(fingers).play();
|
||||
|
||||
let held = null;
|
||||
if (weapon) {
|
||||
if (!grip) throw new Error("Dummy mit Waffe ohne Griff");
|
||||
const boneName = hand === "l" ? "hand_l" : "hand_r";
|
||||
const bone = root.getObjectByName(boneName);
|
||||
if (!bone) throw new Error(`Dummy ohne Knochen ${boneName}`);
|
||||
held = weapon.clone(true);
|
||||
held.traverse((object) => {
|
||||
if (!object.isMesh) return;
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
});
|
||||
applyGrip(held, grip);
|
||||
bone.add(held);
|
||||
}
|
||||
return { root, mixer, clip, weapon: held };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Schild über einem Platz der Animations-Testseite: zwei Zeilen auf dunklem Grund, immer zur Kamera gedreht.
|
||||
import { CanvasTexture, SRGBColorSpace, Sprite, SpriteMaterial } from "three";
|
||||
|
||||
const WIDTH = 512;
|
||||
const HEIGHT = 128;
|
||||
|
||||
/** breite in Mannequin-Metern; die Höhe folgt dem Seitenverhältnis der Zeichenfläche. */
|
||||
export function createLabel([title, subtitle], width = 2.4) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "rgba(0, 0, 0, 0.6)";
|
||||
context.beginPath();
|
||||
context.roundRect(0, 0, WIDTH, HEIGHT, 24);
|
||||
context.fill();
|
||||
context.textAlign = "center";
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "600 40px system-ui, sans-serif";
|
||||
context.fillText(title, WIDTH / 2, 56, WIDTH - 32);
|
||||
context.fillStyle = "#ffd27a";
|
||||
context.font = "400 34px system-ui, sans-serif";
|
||||
context.fillText(subtitle, WIDTH / 2, 102, WIDTH - 32);
|
||||
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
// toneMapped: false, sonst zieht ACESFilmicToneMapping die Schrift grau.
|
||||
const sprite = new Sprite(new SpriteMaterial({ map: texture, depthWrite: false, toneMapped: false }));
|
||||
sprite.scale.set(width, (width * HEIGHT) / WIDTH, 1);
|
||||
return sprite;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Aufstellung der Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md, "Aufstellung"):
|
||||
// welcher Dummy welchen Clip spielt, welche Waffe er in welcher Hand hält und wo er steht. Alle Maße in
|
||||
// Mannequin-Metern — src/showroom/main.js hängt die Aufstellung an eine Gruppe mit dem Maßstab der Spielfigur.
|
||||
|
||||
/**
|
||||
* Griff = Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad in der Reihenfolge XYZ. Die Werte
|
||||
* sind im Browser mit dem Panel eingepasst (Plan-Task 9); das Pack verspricht für alle fünf denselben Griffpunkt.
|
||||
*/
|
||||
export const WEAPONS = {
|
||||
pistol: { label: "Pistole", url: "./models/weapons/pistol.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
|
||||
ak47: { label: "AK47", url: "./models/weapons/ak47.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
|
||||
m4: { label: "M4", url: "./models/weapons/m4.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
|
||||
shotgun870: { label: "870 Shotgun", url: "./models/weapons/shotgun870.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
|
||||
// Das Pistolenmaß passt auch hier (Griffpunkt, Position), aber die L96 sitzt im Modell um 180° um ihre
|
||||
// Querachse gedreht (Länge und Höhe ihrer Bounding-Box liegen seitenverkehrt zu AK47/M4/Shotgun) — deshalb
|
||||
// -90° statt 90° in Z.
|
||||
l96: { label: "L96", url: "./models/weapons/l96.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, -90] } },
|
||||
};
|
||||
|
||||
export const MOTIONS_URL = "./models/motions/weapons-mocap.glb";
|
||||
/** Reihe 1 steht bei z = 0 mit acht Plätzen, Reihe 2 bei z = −ROW_DEPTH. Die Spielfigur steht VIEW_DISTANCE davor. */
|
||||
export const ROW_SPACING = 3.5;
|
||||
export const ROW_DEPTH = 8;
|
||||
export const FIRST_SLOT_X = -12.25;
|
||||
export const BOW_X = 8.75;
|
||||
export const LABEL_HEIGHT = 2.2;
|
||||
export const VIEW_DISTANCE = 8;
|
||||
|
||||
/**
|
||||
* Reihe 1 (Plätze 1–8) hält die Waffen, Reihe 2 (Plätze 9–13) die Clips ohne passende Waffe. Die Gewehr-Clips
|
||||
* laufen doppelt, damit auch Shotgun und L96 zu sehen sind — eine eigene Animation gibt es für sie nicht.
|
||||
*/
|
||||
export const DUMMIES = [
|
||||
{ clip: "idle_loadGun-Hand_tk01", label: "Pistole nachladen · Take 1", weapon: "pistol", hand: "r" },
|
||||
{ clip: "idle_loadGun-Hand_tk02", label: "Pistole nachladen · Take 2", weapon: "pistol", hand: "r" },
|
||||
{ clip: "taunt_shootSelfInHead_tk01", label: "Taunt: Schuss in den Kopf · Take 1", weapon: "pistol", hand: "r" },
|
||||
{ clip: "taunt_shootSelfInHead_tk02", label: "Taunt: Schuss in den Kopf · Take 2", weapon: "pistol", hand: "r" },
|
||||
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "ak47", hand: "r" },
|
||||
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "m4", hand: "r" },
|
||||
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "shotgun870", hand: "r" },
|
||||
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "l96", hand: "r" },
|
||||
{ clip: "stabTwist_Knife_tk01", label: "Messerstich mit Drehung · Take 1", weapon: null, hand: null },
|
||||
{ clip: "stabTwist_Knife_tk02", label: "Messerstich mit Drehung · Take 2", weapon: null, hand: null },
|
||||
{ clip: "leftHandThrow_grenade_tk01", label: "Granate werfen (links) · Take 1", weapon: null, hand: null },
|
||||
{ clip: "leftHandThrow_grenade_tk02", label: "Granate werfen (links) · Take 2", weapon: null, hand: null },
|
||||
{ clip: "hunter_bowArrow_tk01", label: "Bogenschütze · Take 1", weapon: null, hand: null },
|
||||
];
|
||||
|
||||
/** Platz eines Dummys in Mannequin-Metern. Der Bogenschütze steht abseits, er wandert bis 3,13 m. */
|
||||
export function slotPosition(index, dummies = DUMMIES) {
|
||||
if (index < 8) return { x: FIRST_SLOT_X + ROW_SPACING * index, z: 0 };
|
||||
if (dummies[index]?.clip.startsWith("hunter_bowArrow")) return { x: BOW_X, z: -ROW_DEPTH };
|
||||
return { x: FIRST_SLOT_X + ROW_SPACING * (index - 8), z: -ROW_DEPTH };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lage der Aufstellung: VIEW_DISTANCE Mannequin-Meter vor der Figur in Blickrichtung, +Z zurück zu ihr gedreht.
|
||||
* position ist die Figurenposition (x, z genügen), view die bereits normalisierte waagrechte Blickrichtung.
|
||||
*/
|
||||
export function stageTransform(position, view, scale) {
|
||||
return {
|
||||
x: position.x + view.x * VIEW_DISTANCE * scale,
|
||||
z: position.z + view.z * VIEW_DISTANCE * scale,
|
||||
rotationY: Math.atan2(-view.x, -view.z),
|
||||
};
|
||||
}
|
||||
|
||||
/** Text des Schilds: Clip oben, Waffe darunter. */
|
||||
export function labelLines(dummy) {
|
||||
return [dummy.label, dummy.weapon ? WEAPONS[dummy.weapon].label : "ohne Waffe"];
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md): 13 Mannequin-Dummies spielen die
|
||||
// Mocap-Clips aus weapons-mocap.glb in Dauerschleife, acht davon mit Waffe in der Hand. Die Spielfigur läuft wie
|
||||
// im Spiel. Keine Stadt, kein Streaming, keine Fahrzeuge.
|
||||
import { ACESFilmicToneMapping, AnimationClip, Fog, Group, Mesh, MeshStandardMaterial, PerspectiveCamera,
|
||||
PlaneGeometry, RepeatWrapping, Scene, SRGBColorSpace, TextureLoader, Timer, Vector3,
|
||||
WebGLRenderer } from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { playerController } from "three-player-controller";
|
||||
import { PLAYER_MODEL_BASE } from "../characters/index.js";
|
||||
import { createSky } from "../world/sky.js";
|
||||
import { createSunShadows } from "../world/shadows.js";
|
||||
import { createDummy, fingerPoseClip } from "./dummies.js";
|
||||
import { createLabel } from "./labels.js";
|
||||
import { DUMMIES, labelLines, LABEL_HEIGHT, MOTIONS_URL, slotPosition, stageTransform, WEAPONS } from "./lineup.js";
|
||||
import { createShowroomPanel } from "./panel.js";
|
||||
|
||||
// Dieselben Werte wie in src/main.js (dort lokale Konstanten): Sonne in gemessener Stärke, Himmelslicht und
|
||||
// Kuppelhelligkeit fünffach.
|
||||
const SUN_STRENGTH = 1;
|
||||
const SKY_LIGHT = 5;
|
||||
const SKY_BRIGHTNESS = 5;
|
||||
// Poly Haven misst concrete_pavement mit 1,8 × 1,8 m. Die Ebene ist groß genug für jeden Figurenmaßstab.
|
||||
const GROUND_TILE = 1.8;
|
||||
const GROUND_SIZE = 200;
|
||||
|
||||
const container = document.querySelector("#container");
|
||||
const scene = new Scene();
|
||||
const renderer = new WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.toneMapping = ACESFilmicToneMapping;
|
||||
renderer.shadowMap.enabled = true;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Nahwert 0,1 statt 0,5 wie im Spiel: Hier steht nur eine Ebene, es gibt keine übereinanderliegenden Flächen,
|
||||
// die im Tiefenpuffer flackern könnten, dafür geht die Kamera näher an die Dummies heran.
|
||||
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
|
||||
scene.fog = new Fog(0xffffff, 500, 2200); // Farbe setzt sky.setBrightness() passend zum Horizont
|
||||
const sky = await createSky({ renderer, scene });
|
||||
sky.setBrightness(SKY_BRIGHTNESS);
|
||||
scene.environmentIntensity = SKY_LIGHT;
|
||||
const sun = createSunShadows({ scene, camera, sun: { ...sky.sun, intensity: sky.sun.intensity * SUN_STRENGTH } });
|
||||
|
||||
const textureLoader = new TextureLoader();
|
||||
const groundTextures = await Promise.all(
|
||||
["Diffuse", "nor_gl", "arm"].map((map) => textureLoader.loadAsync(`./textures/concrete_pavement_${map}.jpg`)));
|
||||
for (const texture of groundTextures) {
|
||||
texture.wrapS = texture.wrapT = RepeatWrapping;
|
||||
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||
}
|
||||
const [groundColor, groundNormal, groundArm] = groundTextures;
|
||||
groundColor.colorSpace = SRGBColorSpace;
|
||||
const ground = new Mesh(new PlaneGeometry(GROUND_SIZE, GROUND_SIZE), new MeshStandardMaterial({
|
||||
map: groundColor, normalMap: groundNormal, aoMap: groundArm, roughnessMap: groundArm, metalness: 0,
|
||||
}));
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
ground.updateMatrixWorld(true);
|
||||
|
||||
const gltfLoader = new GLTFLoader();
|
||||
gltfLoader.setMeshoptDecoder(MeshoptDecoder); // die Waffen sind meshopt-komprimiert
|
||||
const weaponIds = Object.keys(WEAPONS);
|
||||
const [ual, motions, ...weaponFiles] = await Promise.all([
|
||||
gltfLoader.loadAsync("./models/ual.glb"),
|
||||
gltfLoader.loadAsync(MOTIONS_URL),
|
||||
...weaponIds.map((id) => gltfLoader.loadAsync(WEAPONS[id].url)),
|
||||
]);
|
||||
// Vor player.init klonen: Der Controller skaliert ual.scene an Ort und Stelle auf seine Kapselhöhe.
|
||||
const template = cloneSkeleton(ual.scene);
|
||||
|
||||
const player = new playerController();
|
||||
await player.init({
|
||||
scene, camera, controls,
|
||||
initPos: new Vector3(0, 1, 0),
|
||||
playerModelConfig: { ...PLAYER_MODEL_BASE, model: ual.scene, animations: ual.animations },
|
||||
colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }],
|
||||
});
|
||||
// Wie in createCharacterModule (src/characters/index.js): Der Controller setzt beim Laden selbst keinen
|
||||
// Schatten, und dieses Startmodell durchläuft applyCharacter nie — ohne diese Schleife bliebe die Spielfigur
|
||||
// als einzige Figur der Seite ohne Schatten.
|
||||
player.playerModel.traverse((object) => {
|
||||
if (!object.isMesh) return;
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
});
|
||||
// Der Controller skaliert das Modell selbst (playerCapsuleHeight × scale). Ausgelesen statt nachgerechnet:
|
||||
// daran hängen Dummies, Waffen, Schilder und alle Abstände.
|
||||
const figureScale = player.playerModel.getWorldScale(new Vector3()).x;
|
||||
for (const texture of groundTextures) texture.repeat.setScalar(GROUND_SIZE / (GROUND_TILE * figureScale));
|
||||
|
||||
const stage = new Group();
|
||||
stage.scale.setScalar(figureScale);
|
||||
scene.add(stage);
|
||||
|
||||
const clips = new Map(motions.animations.map((clip) => [clip.name, clip]));
|
||||
// Wie beim Clip-Lookup der Dummies unten: findByName gibt bei einem fehlenden Clip still null zurück, das
|
||||
// bräche erst tief in fingerPoseClip mit einem unklaren TypeError ab.
|
||||
function requireAnimation(name) {
|
||||
const clip = AnimationClip.findByName(ual.animations, name);
|
||||
if (!clip) throw new Error(`ual.glb ohne Clip ${name}`);
|
||||
return clip;
|
||||
}
|
||||
const gripFingers = fingerPoseClip(requireAnimation("Pistol_Idle_Loop"), "Finger_Waffe");
|
||||
const looseFingers = fingerPoseClip(requireAnimation("Idle_Loop"), "Finger_locker");
|
||||
const weaponScenes = new Map(weaponIds.map((id, index) => [id, weaponFiles[index].scene]));
|
||||
const weaponObjects = new Map();
|
||||
const dummies = DUMMIES.map((entry, index) => {
|
||||
const clip = clips.get(entry.clip);
|
||||
if (!clip) throw new Error(`weapons-mocap.glb ohne Clip ${entry.clip}`);
|
||||
const dummy = createDummy({
|
||||
template,
|
||||
clip,
|
||||
fingers: entry.weapon ? gripFingers : looseFingers,
|
||||
weapon: entry.weapon ? weaponScenes.get(entry.weapon) : null,
|
||||
hand: entry.hand ?? "r",
|
||||
grip: entry.weapon ? WEAPONS[entry.weapon].grip : null,
|
||||
});
|
||||
const { x, z } = slotPosition(index);
|
||||
dummy.root.position.set(x, 0, z);
|
||||
stage.add(dummy.root);
|
||||
const label = createLabel(labelLines(entry));
|
||||
label.position.set(x, LABEL_HEIGHT, z);
|
||||
stage.add(label);
|
||||
if (dummy.weapon) weaponObjects.set(entry.weapon, [...(weaponObjects.get(entry.weapon) ?? []), dummy.weapon]);
|
||||
return dummy;
|
||||
});
|
||||
|
||||
const playback = { tempo: 1, paused: false };
|
||||
createShowroomPanel({ playback, weaponObjects });
|
||||
|
||||
/**
|
||||
* Stellt die Aufstellung vor die Spielfigur. Wohin die Kamera anfangs schaut, bestimmt der Controller
|
||||
* (rotateY der Kapsel, daraus die Kameraposition in CameraSystem.ts) — deshalb wird die Aufstellung nach dem
|
||||
* ersten Bild an den Blick gehängt statt die Figur zu versetzen. stage +Z zeigt zurück zur Figur, die Dummies
|
||||
* schauen sie also an.
|
||||
*/
|
||||
function placeStage() {
|
||||
const view = camera.getWorldDirection(new Vector3());
|
||||
view.y = 0;
|
||||
if (view.lengthSq() < 1e-6) return false;
|
||||
view.normalize();
|
||||
const { x, z, rotationY } = stageTransform(player.playerCapsule.position, view, figureScale);
|
||||
stage.position.set(x, 0, z);
|
||||
stage.rotation.y = rotationY;
|
||||
return true;
|
||||
}
|
||||
|
||||
const timer = new Timer();
|
||||
timer.connect(document);
|
||||
let staged = false;
|
||||
function loop(time) {
|
||||
timer.update(time);
|
||||
const delta = Math.min(timer.getDelta(), 1 / 40);
|
||||
player.update(delta);
|
||||
if (!staged) staged = placeStage();
|
||||
if (!playback.paused) {
|
||||
for (const dummy of dummies) dummy.mixer.update(delta * playback.tempo);
|
||||
}
|
||||
sun.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
renderer.setAnimationLoop(loop);
|
||||
addEventListener("resize", () => {
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
// Für die Prüfung im Browser (Griff einpassen, Schleifennaht, Bildzeit). Die Seite ist ein Prüfstand, kein Spiel.
|
||||
window.__showroom = { renderer, scene, camera, player, stage, dummies, playback, figureScale, loop };
|
||||
@@ -0,0 +1,46 @@
|
||||
// Panel der Animations-Testseite: Tempo und Pause für alle Dummies, dazu der Griff je Waffe zum Einpassen im
|
||||
// Browser (docs/superpowers/specs/2026-09-17-animationen-design.md, "Panel").
|
||||
import GUI from "lil-gui";
|
||||
import { applyGrip } from "./dummies.js";
|
||||
import { WEAPONS } from "./lineup.js";
|
||||
|
||||
/**
|
||||
* playback = { tempo, paused } liest der Bildtakt in main.js. weaponObjects ist id -> alle Kopien dieser Waffe an
|
||||
* den Dummies; ein verschobener Griff wirkt dadurch sofort auf alle.
|
||||
*/
|
||||
export function createShowroomPanel({ playback, weaponObjects }) {
|
||||
const gui = new GUI({ title: "Animationen" });
|
||||
gui.add(playback, "tempo", 0, 2, 0.05).name("Tempo");
|
||||
gui.add(playback, "paused").name("Pause");
|
||||
|
||||
const folder = gui.addFolder("Griff");
|
||||
const ids = Object.keys(WEAPONS);
|
||||
// lil-gui zeigt bei einem Objekt { Beschriftung: Wert } die Schlüssel statt der IDs im Dropdown an.
|
||||
const weaponOptions = Object.fromEntries(ids.map((id) => [WEAPONS[id].label, id]));
|
||||
const edit = { weapon: ids[0], x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 };
|
||||
const sliders = [];
|
||||
const load = () => {
|
||||
const { position, rotation } = WEAPONS[edit.weapon].grip;
|
||||
[edit.x, edit.y, edit.z] = position;
|
||||
[edit.rx, edit.ry, edit.rz] = rotation;
|
||||
for (const slider of sliders) slider.updateDisplay();
|
||||
};
|
||||
const apply = () => {
|
||||
const grip = WEAPONS[edit.weapon].grip;
|
||||
grip.position = [edit.x, edit.y, edit.z];
|
||||
grip.rotation = [edit.rx, edit.ry, edit.rz];
|
||||
for (const object of weaponObjects.get(edit.weapon) ?? []) applyGrip(object, grip);
|
||||
};
|
||||
folder.add(edit, "weapon", weaponOptions).name("Waffe").onChange(load);
|
||||
for (const axis of ["x", "y", "z"]) {
|
||||
sliders.push(folder.add(edit, axis, -0.3, 0.3, 0.001).name(`Position ${axis} (m)`).onChange(apply));
|
||||
}
|
||||
for (const axis of ["rx", "ry", "rz"]) {
|
||||
sliders.push(folder.add(edit, axis, -180, 180, 1).name(`Drehung ${axis.slice(1)} (Grad)`).onChange(apply));
|
||||
}
|
||||
folder.add({
|
||||
ausgeben: () => console.log(`${edit.weapon}:`, JSON.stringify(WEAPONS[edit.weapon].grip)),
|
||||
}, "ausgeben").name("Werte ausgeben");
|
||||
load();
|
||||
return gui;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AnimationClip, Bone, BoxGeometry, Group, Mesh, MeshBasicMaterial, Object3D,
|
||||
QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
|
||||
import { applyGrip, createDummy, fingerPoseClip } from "../src/showroom/dummies.js";
|
||||
|
||||
const source = new AnimationClip("Pistol_Idle_Loop", 2, [
|
||||
new QuaternionKeyframeTrack("index_01_r.quaternion", [0, 1], [0, 0, 0.3826834, 0.9238795, 0, 0, 0, 1]),
|
||||
new QuaternionKeyframeTrack("thumb_03_l.quaternion", [0, 2], [0.1, 0, 0, 0.9949874, 0, 0, 0, 1]),
|
||||
new QuaternionKeyframeTrack("index_04_leaf_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
new VectorKeyframeTrack("index_01_r.position", [0, 1], [0, 0, 0, 1, 1, 1]),
|
||||
]);
|
||||
|
||||
describe("fingerPoseClip", () => {
|
||||
// Bruch: Hand oder Blattknochen mitgenommen — die Fingerhaltung würde die Mocap-Bewegung überschreiben.
|
||||
it("behält nur die Drehungen der Fingerknochen", () => {
|
||||
expect(fingerPoseClip(source).tracks.map((track) => track.name).sort())
|
||||
.toEqual(["index_01_r.quaternion", "thumb_03_l.quaternion"]);
|
||||
});
|
||||
|
||||
it("hält den ersten Keyframe über die ganze Dauer", () => {
|
||||
const clip = fingerPoseClip(source, "Finger_Waffe");
|
||||
const track = clip.tracks.find((entry) => entry.name === "index_01_r.quaternion");
|
||||
expect(clip.name).toBe("Finger_Waffe");
|
||||
expect(clip.duration).toBe(1);
|
||||
expect([...track.times]).toEqual([0, 1]);
|
||||
const expected = Float32Array.from([0, 0, 0.3826834, 0.9238795, 0, 0, 0.3826834, 0.9238795]);
|
||||
expect([...track.values]).toEqual([...expected]);
|
||||
});
|
||||
|
||||
it("meldet einen Clip ohne Fingerspuren", () => {
|
||||
const empty = new AnimationClip("Leer", 1, [
|
||||
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
]);
|
||||
expect(() => fingerPoseClip(empty)).toThrow(/keine Fingerspuren/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGrip", () => {
|
||||
it("setzt Position in Metern und Drehung in Grad", () => {
|
||||
const object = new Object3D();
|
||||
applyGrip(object, { position: [0.01, -0.02, 0.03], rotation: [90, 0, -45] });
|
||||
expect(object.position.toArray()).toEqual([0.01, -0.02, 0.03]);
|
||||
expect(object.rotation.x).toBeCloseTo(Math.PI / 2, 12);
|
||||
expect(object.rotation.z).toBeCloseTo(-Math.PI / 4, 12);
|
||||
expect(object.rotation.order).toBe("XYZ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDummy", () => {
|
||||
const WEAPON_GRIP = { position: [0, 0, 0], rotation: [0, 0, 0] };
|
||||
|
||||
function template() {
|
||||
const root = new Group();
|
||||
const pelvis = new Bone();
|
||||
pelvis.name = "pelvis";
|
||||
const right = new Bone();
|
||||
right.name = "hand_r";
|
||||
const left = new Bone();
|
||||
left.name = "hand_l";
|
||||
// Ziele der Fingerspuren aus `fingers` unten (index_01_r, thumb_03_l) — ohne sie meldet three.js beim
|
||||
// Binden für jeden Testlauf "No target node found for track".
|
||||
const indexR = new Bone();
|
||||
indexR.name = "index_01_r";
|
||||
const thumbL = new Bone();
|
||||
thumbL.name = "thumb_03_l";
|
||||
root.add(pelvis);
|
||||
pelvis.add(right);
|
||||
pelvis.add(left);
|
||||
right.add(indexR);
|
||||
left.add(thumbL);
|
||||
return root;
|
||||
}
|
||||
const clip = new AnimationClip("take", 1, [
|
||||
new QuaternionKeyframeTrack("pelvis.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
]);
|
||||
const fingers = fingerPoseClip(source, "Finger_Waffe");
|
||||
const weapon = new Group();
|
||||
weapon.add(new Mesh(new BoxGeometry(0.2, 0.05, 0.02), new MeshBasicMaterial()));
|
||||
|
||||
// Bruch: Waffe an der falschen Hand oder ohne Griff — sie steckte im Unterarm.
|
||||
it("hängt eine eigene Kopie der Waffe mit Griff an die gewählte Hand", () => {
|
||||
const dummy = createDummy({
|
||||
template: template(), clip, fingers, weapon, hand: "l",
|
||||
grip: { position: [0.1, 0, 0], rotation: [0, 90, 0] },
|
||||
});
|
||||
expect(dummy.weapon.parent.name).toBe("hand_l");
|
||||
expect(dummy.weapon).not.toBe(weapon);
|
||||
expect(dummy.weapon.position.x).toBeCloseTo(0.1, 12);
|
||||
expect(dummy.weapon.rotation.y).toBeCloseTo(Math.PI / 2, 12);
|
||||
});
|
||||
|
||||
it("spielt Clip und Fingerhaltung zugleich, lässt leere Hände leer und gibt den Clip zurück", () => {
|
||||
const dummy = createDummy({ template: template(), clip, fingers });
|
||||
expect(dummy.weapon).toBeNull();
|
||||
expect(dummy.clip).toBe(clip);
|
||||
expect(dummy.mixer.existingAction(clip).isRunning()).toBe(true);
|
||||
expect(dummy.mixer.existingAction(fingers).isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it("meldet eine fehlende Hand", () => {
|
||||
const root = new Group();
|
||||
const pelvis = new Bone();
|
||||
pelvis.name = "pelvis";
|
||||
const indexR = new Bone();
|
||||
indexR.name = "index_01_r";
|
||||
const thumbL = new Bone();
|
||||
thumbL.name = "thumb_03_l";
|
||||
root.add(pelvis);
|
||||
pelvis.add(indexR);
|
||||
pelvis.add(thumbL);
|
||||
expect(() => createDummy({ template: root, clip, fingers, weapon, hand: "r", grip: WEAPON_GRIP }))
|
||||
.toThrow(/Dummy ohne Knochen hand_r/);
|
||||
});
|
||||
|
||||
// Bruch: Waffe ohne Griff — applyGrip bekäme kein Objekt und bräche mit einem rohen TypeError ab.
|
||||
it("meldet eine Waffe ohne Griff", () => {
|
||||
expect(() => createDummy({ template: template(), clip, fingers, weapon, hand: "r", grip: null }))
|
||||
.toThrow(/Waffe ohne Griff/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AnimationClip, Bone, Group, MathUtils, Quaternion, QuaternionKeyframeTrack, Vector3,
|
||||
VectorKeyframeTrack } from "three";
|
||||
import { Document } from "@gltf-transform/core";
|
||||
import { addClip, BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
|
||||
flattenPoses, headingOf, removeDrift, rigFromScene, rigWorld, sampleFbx, sampleTimes, seamAngle,
|
||||
SOURCE_HIPS, stripToSkeleton } from "../tools/assets/motions.mjs";
|
||||
|
||||
const turn = (x, y, z, degrees) =>
|
||||
new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees));
|
||||
|
||||
/**
|
||||
* Kleines Zielskelett im Bau des Mannequins: root dreht −90° um X (Blender-Z-oben), darunter Becken, Brust,
|
||||
* Schlüsselbein, Oberarm und eine nicht zugeordnete Hand. Die Ruhedrehungen sind absichtlich schief, damit eine
|
||||
* vertauschte Reihenfolge der Quaternionen auffällt.
|
||||
*/
|
||||
const RIG = [
|
||||
{ name: "root", parent: null, position: [0, 0, 0], quaternion: turn(1, 0, 0, -90).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "pelvis", parent: "root", position: [0, 0.05, 0.917], quaternion: turn(1, 0, 0, 104.5).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "spine_03", parent: "pelvis", position: [0, 0.4, 0], quaternion: turn(1, 0, 0, -12).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "clavicle_l", parent: "spine_03", position: [0.03, 0.2, 0], quaternion: turn(0.3, -0.7, 0.2, 80).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "upperarm_l", parent: "clavicle_l", position: [0, 0.15, 0], quaternion: turn(0, 0, 1, 15).toArray(), scale: [1, 1, 1] },
|
||||
{ name: "hand_l", parent: "upperarm_l", position: [0, 0.27, 0], quaternion: turn(0, 1, 0, 5).toArray(), scale: [1, 1, 1] },
|
||||
];
|
||||
const MAP = { Hips: "pelvis", Spine3: "spine_03", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l" };
|
||||
const restFrame = () => ({
|
||||
rotations: new Map(Object.keys(MAP).map((name) => [name, new Quaternion()])),
|
||||
hips: new Vector3(0, 90, 0),
|
||||
});
|
||||
const restOf = (name) => new Quaternion().fromArray(RIG.find((bone) => bone.name === name).quaternion);
|
||||
/** Quaternionen vergleichen, ohne dass acos nahe 0 die Genauigkeit frisst. */
|
||||
const expectSameRotation = (actual, expected) => expect(1 - Math.abs(actual.dot(expected))).toBeLessThan(1e-9);
|
||||
|
||||
describe("clipId", () => {
|
||||
it("liest den Namen aus jedem der elf Dateinamen", () => {
|
||||
expect(clipId("20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx")).toBe("idle_loadGun-Hand_tk01");
|
||||
expect(clipId("20210715_s231_idle_loadGun-Rifle_tk04_ERJA-mvn362.fbx")).toBe("idle_loadGun-Rifle_tk04");
|
||||
expect(clipId("20210714_s022_hunter_bowArrow_tk01_ERJA-mvn006.fbx")).toBe("hunter_bowArrow_tk01");
|
||||
expect(clipId("20210714_s055_leftHandThrow_grenade_tk02_ERJA-mvn052.fbx")).toBe("leftHandThrow_grenade_tk02");
|
||||
expect(clipId("20210714_s077_taunt_shootSelfInHead_tk01_ERJA-mvn072.fbx")).toBe("taunt_shootSelfInHead_tk01");
|
||||
expect(clipId("20210714_s085_stabTwist_Knife_tk02_ERJA-mvn086.fbx")).toBe("stabTwist_Knife_tk02");
|
||||
});
|
||||
|
||||
// Bruch: eine fremde Datei im Ordner würde sonst einen unbrauchbaren Clipnamen erzeugen.
|
||||
it("lehnt fremde Dateinamen ab", () => {
|
||||
expect(() => clipId("SM_Pistol.fbx")).toThrow(/Unbekannter Dateiname/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BONE_MAP", () => {
|
||||
it("ordnet 22 Knochen zu, ohne Spine2 und ohne doppeltes Ziel", () => {
|
||||
const targets = Object.values(BONE_MAP);
|
||||
expect(targets).toHaveLength(22);
|
||||
expect(new Set(targets).size).toBe(22);
|
||||
expect(BONE_MAP.Spine2).toBeUndefined();
|
||||
expect(BONE_MAP.Hips).toBe("pelvis");
|
||||
expect(BONE_MAP.LeftToeBase).toBe("ball_l");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rigWorld", () => {
|
||||
it("verkettet Ruhedrehungen und Ruhepositionen", () => {
|
||||
const world = rigWorld(RIG);
|
||||
const pelvis = new Vector3().setFromMatrixPosition(world.get("pelvis").matrix);
|
||||
expect(pelvis.x).toBeCloseTo(0, 9);
|
||||
expect(pelvis.y).toBeCloseTo(0.917, 9);
|
||||
expect(pelvis.z).toBeCloseTo(-0.05, 9);
|
||||
expectSameRotation(world.get("pelvis").quaternion, restOf("root").clone().multiply(restOf("pelvis")));
|
||||
});
|
||||
|
||||
// Bruch: Reihenfolge im rig vertauscht — die Kette rechnete mit einem noch leeren Elternknochen.
|
||||
it("verlangt Eltern vor Kindern", () => {
|
||||
expect(() => rigWorld([RIG[1], RIG[0]])).toThrow(/steht nicht vor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRetargeter", () => {
|
||||
// Bruch: falsche Formel — die Figur stünde dauerhaft verdreht statt in ihrer Ruhepose.
|
||||
it("macht aus der MVN-Ruhepose die Ruhepose des Mannequins", () => {
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
|
||||
for (const name of Object.values(MAP)) expectSameRotation(local.get(name), restOf(name));
|
||||
});
|
||||
|
||||
it("gibt nicht zugeordneten Knochen keine Spur", () => {
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame());
|
||||
expect(local.has("hand_l")).toBe(false);
|
||||
expect(local.has("root")).toBe(false);
|
||||
expect(local.size).toBe(4);
|
||||
});
|
||||
|
||||
it("überträgt eine Weltdrehung am Oberarm eins zu eins", () => {
|
||||
const rotation = turn(0, 0, 1, 90);
|
||||
const frame = restFrame();
|
||||
frame.rotations.set("LeftArm", rotation);
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
|
||||
const rest = rigWorld(RIG);
|
||||
// Der Schlüsselbein-Knochen bleibt in Ruhe, also ist seine Weltdrehung die Ruhe-Weltdrehung.
|
||||
const world = rest.get("clavicle_l").quaternion.clone().multiply(local.get("upperarm_l"));
|
||||
expectSameRotation(world, rotation.clone().multiply(rest.get("upperarm_l").quaternion));
|
||||
});
|
||||
|
||||
// Bruch: Elternkette übersehen — eine Drehung der Brust würde Arme und Kopf zusätzlich verbiegen.
|
||||
it("lässt die lokale Drehung der Kinder unverändert, wenn sich nur der Elternknochen dreht", () => {
|
||||
const rotation = turn(0, 1, 0, 40);
|
||||
const frame = restFrame();
|
||||
for (const name of ["Spine3", "LeftShoulder", "LeftArm"]) frame.rotations.set(name, rotation);
|
||||
const { local } = createRetargeter(RIG, { boneMap: MAP })(frame);
|
||||
expectSameRotation(local.get("clavicle_l"), restOf("clavicle_l"));
|
||||
expectSameRotation(local.get("upperarm_l"), restOf("upperarm_l"));
|
||||
expect(1 - Math.abs(local.get("spine_03").dot(restOf("spine_03")))).toBeGreaterThan(1e-3);
|
||||
});
|
||||
|
||||
// Bruch: cm als m gelesen oder Raum verwechselt — die Figur stünde 90 m über dem Boden oder läge flach.
|
||||
it("rechnet die Hüfte in Meter um, skaliert sie und legt sie in den Raum von root", () => {
|
||||
const frame = restFrame();
|
||||
frame.hips.set(10, 90, 20);
|
||||
const { hips } = createRetargeter(RIG, { boneMap: MAP, hipsScale: 1.02 })(frame);
|
||||
expect(hips.x).toBeCloseTo(0.102, 9);
|
||||
expect(hips.y).toBeCloseTo(-0.204, 9);
|
||||
expect(hips.z).toBeCloseTo(0.918, 9);
|
||||
});
|
||||
|
||||
// Bruch: Hüftposition in bewegten Elternraum gerechnet — die Figur wanderte mit jedem Bild zusätzlich mit ihrem Hüft-Elternknochen.
|
||||
it("verlangt einen unbewegten Elternknochen über der Hüfte", () => {
|
||||
expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleTimes", () => {
|
||||
// Bruch: ungleichmäßige Abstände — der Clip liefe stellenweise zu schnell.
|
||||
it("verteilt round(Dauer · 30) + 1 Zeiten gleichmäßig über den Clip", () => {
|
||||
const times = sampleTimes(0.008, 6.56, 30);
|
||||
expect(times).toHaveLength(Math.round(6.552 * 30) + 1);
|
||||
expect(times[0]).toBe(0.008);
|
||||
expect(times.at(-1)).toBeCloseTo(6.56, 12);
|
||||
const steps = times.slice(1).map((time, i) => time - times[i]);
|
||||
expect(Math.max(...steps) - Math.min(...steps)).toBeLessThan(1e-12);
|
||||
});
|
||||
|
||||
// Bruch: unbemerkt leerer Zeitraum — der Clip hätte nur ein Bild.
|
||||
it("lehnt einen leeren Zeitraum ab", () => {
|
||||
expect(() => sampleTimes(2, 2)).toThrow(/Leerer Zeitraum/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("faceForward", () => {
|
||||
const sideways = () => [0, 1].map(() => ({
|
||||
rotations: new Map([[SOURCE_HIPS, turn(0, 1, 0, -30)], ["Head", turn(1, 0, 0, 10)]]),
|
||||
hips: new Vector3(10, 90, 0),
|
||||
}));
|
||||
|
||||
// Bruch: Blickrichtung nicht gedreht — die Dummies stünden schräg zur Spielfigur (bis 44°).
|
||||
it("dreht den Clip, bis die Hüfte im ersten Bild nach +Z schaut", () => {
|
||||
const faced = faceForward(sideways());
|
||||
expect(headingOf(faced[0].rotations.get(SOURCE_HIPS))).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("dreht Positionen und alle Knochen mit derselben Drehung", () => {
|
||||
const faced = faceForward(sideways());
|
||||
expect(faced[1].hips.x).toBeCloseTo(8.660254, 5);
|
||||
expect(faced[1].hips.z).toBeCloseTo(-5, 5);
|
||||
expect(faced[1].hips.y).toBeCloseTo(90, 9);
|
||||
expectSameRotation(faced[1].rotations.get("Head"), turn(0, 1, 0, 30).multiply(turn(1, 0, 0, 10)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDrift", () => {
|
||||
// Bruch: Drift bleibt — der Dummy wanderte bei jeder Schleife weiter von seinem Platz.
|
||||
it("legt das erste Bild auf 0 und zieht die Drift linear ab", () => {
|
||||
const frames = [[4, 90, 2], [6, 91, 5], [10, 92, 8]].map(([x, y, z]) =>
|
||||
({ rotations: new Map(), hips: new Vector3(x, y, z) }));
|
||||
expect(removeDrift(frames).map((frame) => frame.hips.toArray()))
|
||||
.toEqual([[0, 90, 0], [-1, 91, 0], [0, 92, 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seamAngle und blendDuration", () => {
|
||||
const frame = (degrees) => ({
|
||||
rotations: new Map([[SOURCE_HIPS, new Quaternion()], ["LeftFoot", turn(1, 0, 0, degrees)]]),
|
||||
hips: new Vector3(),
|
||||
});
|
||||
|
||||
it("misst die größte Abweichung zwischen letztem und erstem Bild", () => {
|
||||
expect(seamAngle([frame(0), frame(5), frame(91)])).toBeCloseTo(91, 6);
|
||||
});
|
||||
|
||||
// Bruch: feste Überblendzeit — die 91°-Naht des Bogenschützen ruckte sichtbar.
|
||||
it("bemisst die Überblendung an der Naht und hält die Grenzen ein", () => {
|
||||
expect(blendDuration(91)).toBeCloseTo(91 / 90, 9);
|
||||
expect(blendDuration(19)).toBe(BLEND_MIN);
|
||||
expect(blendDuration(400)).toBe(BLEND_MAX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeLoop", () => {
|
||||
const times = [0, 0.5, 1, 1.5, 2];
|
||||
const poses = () => times.map((time) => ({
|
||||
local: new Map([["pelvis", turn(0, 1, 0, 40 * time)]]),
|
||||
hips: new Vector3(time, 1, 0),
|
||||
}));
|
||||
|
||||
// Bruch: Schleife nicht geschlossen — jeder Rücksprung wäre ein sichtbarer Ruck.
|
||||
it("macht das letzte Bild gleich dem ersten", () => {
|
||||
const closed = closeLoop(times, poses(), 1);
|
||||
expectSameRotation(closed.at(-1).local.get("pelvis"), poses()[0].local.get("pelvis"));
|
||||
expect(closed.at(-1).hips.toArray()).toEqual([0, 1, 0]);
|
||||
});
|
||||
|
||||
it("lässt alles vor der Überblendung unberührt und blendet weich", () => {
|
||||
const original = poses();
|
||||
const closed = closeLoop(times, original, 1);
|
||||
expect(closed[0]).toBe(original[0]);
|
||||
expect(closed[2]).toBe(original[2]);
|
||||
expect(closed[3].hips.x).toBeCloseTo(0.75, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleFbx", () => {
|
||||
/** Nachbau eines MVN-FBX: Reference -> Hips -> Spine, Frame 0 in T-Pose, danach Bewegung. */
|
||||
function fakeFbx() {
|
||||
const root = new Group();
|
||||
const reference = new Bone();
|
||||
reference.name = "Reference";
|
||||
const hips = new Bone();
|
||||
hips.name = "Hips";
|
||||
hips.position.set(0, 90, 0);
|
||||
const spine = new Bone();
|
||||
spine.name = "Spine";
|
||||
spine.position.set(0, 10, 0);
|
||||
root.add(reference);
|
||||
reference.add(hips);
|
||||
hips.add(spine);
|
||||
const quarter = turn(0, 1, 0, 90).toArray();
|
||||
root.animations = [new AnimationClip("take", 1.008, [
|
||||
new VectorKeyframeTrack("Hips.position", [0, 0.008, 1.008], [0, 90, 0, 5, 90, 0, 5, 90, 30]),
|
||||
new QuaternionKeyframeTrack("Hips.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, ...quarter]),
|
||||
new QuaternionKeyframeTrack("Spine.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
])];
|
||||
return root;
|
||||
}
|
||||
|
||||
// Bruch: Frame 0 mitgenommen — jede Schleife begänne mit einem Zucken in die T-Pose.
|
||||
it("lässt Frame 0 weg, tastet bis zum Ende ab und liest die Hüfthöhe der Ruhepose", () => {
|
||||
const { times, frames, hipsRestHeight } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
|
||||
expect(hipsRestHeight).toBe(90);
|
||||
expect(times).toHaveLength(31);
|
||||
expect(times[0]).toBe(0);
|
||||
expect(times.at(-1)).toBeCloseTo(1, 9);
|
||||
expect(frames[0].hips.x).toBeCloseTo(5, 6);
|
||||
expect(frames.at(-1).hips.z).toBeCloseTo(30, 6);
|
||||
});
|
||||
|
||||
it("liest Weltdrehungen, nicht lokale", () => {
|
||||
const { frames } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" });
|
||||
expectSameRotation(frames.at(-1).rotations.get("Spine"), turn(0, 1, 0, 90));
|
||||
});
|
||||
});
|
||||
|
||||
describe("rigFromScene und stripToSkeleton", () => {
|
||||
function skeletonDocument() {
|
||||
const document = new Document();
|
||||
const root = document.createNode("root").setRotation(turn(1, 0, 0, -90).toArray());
|
||||
const pelvis = document.createNode("pelvis").setTranslation([0, 0.05, 0.917]);
|
||||
const leaf = document.createNode("Head");
|
||||
root.addChild(pelvis);
|
||||
pelvis.addChild(leaf);
|
||||
document.getRoot().setDefaultScene(document.createScene("Armature").addChild(root));
|
||||
return document;
|
||||
}
|
||||
|
||||
it("liest die Knoten in Eltern-vor-Kind-Reihenfolge", () => {
|
||||
const document = skeletonDocument();
|
||||
const rig = rigFromScene(document.getRoot().listScenes()[0]);
|
||||
expect(rig.map((bone) => [bone.name, bone.parent]))
|
||||
.toEqual([["root", null], ["pelvis", "root"], ["Head", "pelvis"]]);
|
||||
expect(rig[1].position).toEqual([0, 0.05, 0.917]);
|
||||
});
|
||||
|
||||
// Bruch: Blattknoten weggeprunt — Head und die Fingerknochen fehlten als Ziel der Clips.
|
||||
it("räumt Clips und Geometrie weg, behält aber jeden Knoten", async () => {
|
||||
const document = skeletonDocument();
|
||||
const mesh = document.createMesh("Mannequin");
|
||||
document.getRoot().listNodes()[1].setMesh(mesh);
|
||||
document.createAnimation("Idle_Loop");
|
||||
await stripToSkeleton(document);
|
||||
expect(document.getRoot().listAnimations()).toHaveLength(0);
|
||||
expect(document.getRoot().listMeshes()).toHaveLength(0);
|
||||
expect(document.getRoot().listNodes().map((node) => node.getName())).toEqual(["root", "pelvis", "Head"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addClip", () => {
|
||||
it("legt einen Zeit-Accessor, Drehungen je Knochen und die Hüfte als Translation an", () => {
|
||||
const document = new Document();
|
||||
const pelvis = document.createNode("pelvis");
|
||||
const spine = document.createNode("spine_01");
|
||||
document.getRoot().setDefaultScene(document.createScene().addChild(pelvis.addChild(spine)));
|
||||
const poses = [0, 1].map((i) => ({
|
||||
local: new Map([["pelvis", new Quaternion()], ["spine_01", turn(0, 0, 1, 10 * i)]]),
|
||||
hips: new Vector3(0, 0.9 + i, 0),
|
||||
}));
|
||||
const animation = addClip(document, "test_tk01", [0, 0.5], poses, "pelvis");
|
||||
|
||||
expect(animation.getName()).toBe("test_tk01");
|
||||
expect(animation.listChannels()).toHaveLength(3);
|
||||
expect(new Set(animation.listSamplers().map((sampler) => sampler.getInput())).size).toBe(1);
|
||||
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
|
||||
expect(translation.getTargetNode().getName()).toBe("pelvis");
|
||||
// Float32Array-Speicher: 0.9 und 1.9 runden beim Schreiben, darum hier durch Math.fround vergleichen statt
|
||||
// mit den float64-Literalen (Abweichung vom Brief, siehe task-5-report.md).
|
||||
expect([...translation.getSampler().getOutput().getArray()])
|
||||
.toEqual([0, Math.fround(0.9), 0, 0, Math.fround(1.9), 0]);
|
||||
});
|
||||
|
||||
it("meldet einen fehlenden Knoten", () => {
|
||||
const document = new Document();
|
||||
document.getRoot().setDefaultScene(document.createScene().addChild(document.createNode("pelvis")));
|
||||
const poses = [{ local: new Map([["fehlt", new Quaternion()]]), hips: new Vector3() }];
|
||||
expect(() => addClip(document, "test", [0], poses, "pelvis")).toThrow(/Skelett ohne Knoten fehlt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenPoses", () => {
|
||||
it("reiht Drehungen als xyzw und die Hüfte als xyz je Bild auf", () => {
|
||||
const poses = [
|
||||
{ local: new Map([["pelvis", new Quaternion(0, 0, 0, 1)]]), hips: new Vector3(1, 2, 3) },
|
||||
{ local: new Map([["pelvis", new Quaternion(0.5, 0, 0, 0.8660254)]]), hips: new Vector3(4, 5, 6) },
|
||||
];
|
||||
const { rotations, hips } = flattenPoses(poses);
|
||||
expect([...hips]).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect([...rotations.get("pelvis")].slice(0, 4)).toEqual([0, 0, 0, 1]);
|
||||
expect([...rotations.get("pelvis")][4]).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { statSync } from "node:fs";
|
||||
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";
|
||||
import { DUMMIES, labelLines, slotPosition, stageTransform, WEAPONS } from "../src/showroom/lineup.js";
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Die elf Clips, benannt nach den Dateien im Pack. */
|
||||
const CLIP_IDS = [
|
||||
"hunter_bowArrow_tk01",
|
||||
"idle_loadGun-Hand_tk01", "idle_loadGun-Hand_tk02",
|
||||
"idle_loadGun-Rifle_tk04", "idle_loadGun-Rifle_tk05",
|
||||
"leftHandThrow_grenade_tk01", "leftHandThrow_grenade_tk02",
|
||||
"stabTwist_Knife_tk01", "stabTwist_Knife_tk02",
|
||||
"taunt_shootSelfInHead_tk01", "taunt_shootSelfInHead_tk02",
|
||||
];
|
||||
|
||||
describe("weapons-mocap.glb", () => {
|
||||
const file = fileURLToPath(new URL("../public/models/motions/weapons-mocap.glb", import.meta.url));
|
||||
let animations;
|
||||
beforeAll(async () => {
|
||||
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file);
|
||||
animations = document.getRoot().listAnimations();
|
||||
}, 30000);
|
||||
|
||||
it("ist kleiner als 3 MB", () => {
|
||||
expect(statSync(file).size).toBeLessThan(3e6);
|
||||
});
|
||||
|
||||
it("enthält die elf Clips", () => {
|
||||
expect(animations.map((animation) => animation.getName()).sort()).toEqual([...CLIP_IDS].sort());
|
||||
});
|
||||
|
||||
// Bruch: ein Knochen ohne Spur — die Figur bliebe dort in der Ruhepose stehen.
|
||||
it("hat je Clip 22 Drehungen und die Beckenposition", () => {
|
||||
for (const animation of animations) {
|
||||
const channels = animation.listChannels();
|
||||
expect(channels.filter((channel) => channel.getTargetPath() === "rotation")).toHaveLength(22);
|
||||
expect(channels.filter((channel) => channel.getTargetPath() === "translation")
|
||||
.map((channel) => channel.getTargetNode().getName())).toEqual(["pelvis"]);
|
||||
}
|
||||
});
|
||||
|
||||
// Bruch: Schleife nicht geschlossen — bei jedem Rücksprung ein sichtbarer Ruck. Bei Drehungen zählt dafür nur
|
||||
// die Drehung selbst, nicht ihr Vorzeichen: q und −q stellen dieselbe Rotation dar, und three.js' slerp
|
||||
// liefert bei Gewicht 1 die dem Ausgangswert näherliegende der beiden Varianten (Quaternion.slerp kennt
|
||||
// keinen Sonderfall für t=1). Darum wird das letzte Element vor dem Vergleich gespiegelt, wenn sein
|
||||
// Skalarprodukt mit dem ersten negativ ist — eine echte Winkelabweichung am Schleifenende bleibt davon
|
||||
// unberührt und schlägt weiterhin an. Bei Positionen (kein Vorzeichen-Zwilling) bleibt der Vergleich exakt.
|
||||
it("endet jeder Kanal dort, wo er beginnt", () => {
|
||||
for (const animation of animations) {
|
||||
for (const channel of animation.listChannels()) {
|
||||
const output = channel.getSampler().getOutput();
|
||||
const first = output.getElement(0, []);
|
||||
const last = output.getElement(output.getCount() - 1, []);
|
||||
const dot = first.reduce((sum, value, i) => sum + value * last[i], 0);
|
||||
const sign = channel.getTargetPath() === "rotation" && dot < 0 ? -1 : 1;
|
||||
for (let i = 0; i < first.length; i++) expect(sign * last[i]).toBeCloseTo(first[i], 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz. Geprüft werden die lokalen
|
||||
// Komponenten x und y, weil die Beckenspur im Raum von root liegt (−90° um X) und die Höhe dort auf der
|
||||
// lokalen z-Achse sitzt, nicht auf y.
|
||||
it("beginnt jede Beckenbahn waagrecht bei 0", () => {
|
||||
for (const animation of animations) {
|
||||
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
|
||||
const [x, y] = translation.getSampler().getOutput().getElement(0, []);
|
||||
expect(x).toBeCloseTo(0, 5);
|
||||
expect(y).toBeCloseTo(0, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("tastet mit 30 Bildern je Sekunde ab", () => {
|
||||
for (const animation of animations) {
|
||||
const times = animation.listSamplers()[0].getInput();
|
||||
const values = [...times.getArray()];
|
||||
const duration = values.at(-1);
|
||||
expect(values).toHaveLength(Math.round(duration * 30) + 1);
|
||||
expect(values[0]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aufstellung", () => {
|
||||
it("stellt 13 Dummies auf, acht mit Waffe", () => {
|
||||
expect(DUMMIES).toHaveLength(13);
|
||||
expect(DUMMIES.filter((dummy) => dummy.weapon)).toHaveLength(8);
|
||||
});
|
||||
|
||||
// Bruch: Tippfehler im Clipnamen — der Dummy bliebe regungslos stehen.
|
||||
it("nennt nur Clips, die es in weapons-mocap.glb gibt", () => {
|
||||
for (const dummy of DUMMIES) expect(CLIP_IDS).toContain(dummy.clip);
|
||||
});
|
||||
|
||||
it("spielt jeden Clip mindestens einmal", () => {
|
||||
expect(new Set(DUMMIES.map((dummy) => dummy.clip)).size).toBe(CLIP_IDS.length);
|
||||
});
|
||||
|
||||
// Bruch: Waffe ohne Datei oder ohne Griff — leere Hand oder Waffe im Boden.
|
||||
it("gibt jeder Waffe Datei, Beschriftung und Griff", () => {
|
||||
expect(Object.keys(WEAPONS)).toEqual(WEAPON_SOURCES.map((source) => source.id));
|
||||
for (const [id, weapon] of Object.entries(WEAPONS)) {
|
||||
expect(weapon.url).toBe(`./models/weapons/${id}.glb`);
|
||||
expect(statSync(fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url))).size)
|
||||
.toBeGreaterThan(0);
|
||||
expect(weapon.grip.position).toHaveLength(3);
|
||||
expect(weapon.grip.rotation).toHaveLength(3);
|
||||
expect(weapon.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("hält jede Waffe in einer Hand und lässt sonst beide leer", () => {
|
||||
for (const dummy of DUMMIES) {
|
||||
if (dummy.weapon) {
|
||||
expect(WEAPONS[dummy.weapon]).toBeDefined();
|
||||
expect(["r", "l"]).toContain(dummy.hand);
|
||||
} else {
|
||||
expect(dummy.hand).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bruch: zwei Dummies auf einem Platz oder ineinander — die Schleifen wären nicht mehr auseinanderzuhalten.
|
||||
it("hält 3,5 m Abstand zwischen allen Plätzen", () => {
|
||||
const spots = DUMMIES.map((_, index) => slotPosition(index));
|
||||
for (let i = 0; i < spots.length; i++) {
|
||||
for (let k = i + 1; k < spots.length; k++) {
|
||||
expect(Math.hypot(spots[i].x - spots[k].x, spots[i].z - spots[k].z)).toBeGreaterThanOrEqual(3.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bruch: der Bogenschütze wandert 3,13 m und liefe sonst durch seine Nachbarn.
|
||||
it("lässt dem Bogenschützen mindestens 4 m Platz", () => {
|
||||
const index = DUMMIES.findIndex((dummy) => dummy.clip === "hunter_bowArrow_tk01");
|
||||
const bow = slotPosition(index);
|
||||
for (let i = 0; i < DUMMIES.length; i++) {
|
||||
if (i === index) continue;
|
||||
const other = slotPosition(i);
|
||||
expect(Math.hypot(bow.x - other.x, bow.z - other.z)).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("beschriftet mit Clip und Waffe", () => {
|
||||
expect(labelLines(DUMMIES[0])).toEqual(["Pistole nachladen · Take 1", "Pistole"]);
|
||||
expect(labelLines(DUMMIES.at(-1))).toEqual(["Bogenschütze · Take 1", "ohne Waffe"]);
|
||||
});
|
||||
|
||||
// Bruch: falsches Vorzeichen bei Position oder Drehwinkel — die Aufstellung stünde seitlich versetzt, oder
|
||||
// die Dummies schauten an der Figur vorbei statt sie anzusehen.
|
||||
it("stellt die Aufstellung bei Blick nach +Z 8 × Maßstab vor die Figur und dreht sie um 180°", () => {
|
||||
const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: 0, z: 1 }, 1);
|
||||
expect(x).toBeCloseTo(0, 10);
|
||||
expect(z).toBeCloseTo(8, 10);
|
||||
// Math.atan2(-0, -1) liefert −π statt π (Vorzeichen der Null) — dieselbe Halbdrehung, deshalb Betrag.
|
||||
expect(Math.abs(rotationY)).toBeCloseTo(Math.PI, 10);
|
||||
});
|
||||
|
||||
// Im Browser gemessener Fall (task-8-browsercheck.md): Figur bei (0|0), Maßstab 0,4921, Ergebnis
|
||||
// (−3,94|0) und 90°.
|
||||
it("stellt die Aufstellung bei Blick nach −X 8 × Maßstab vor die Figur und dreht sie um 90°", () => {
|
||||
const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: -1, z: 0 }, 0.4921);
|
||||
expect(x).toBeCloseTo(-3.94, 2);
|
||||
expect(z).toBeCloseTo(0, 10);
|
||||
expect(rotationY).toBeCloseTo(Math.PI / 2, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BufferAttribute, BufferGeometry, Quaternion, Vector3 } from "three";
|
||||
import { checkLength, longestExtent, maskToMetallicRoughness, meshArrays, readTga,
|
||||
TEXTURE_SIZE, weaponTarget, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
|
||||
|
||||
/** Baut eine TGA-Datei wie die Masken im Pack: 18 Byte Kopf, danach BGRA je Pixel in Dateireihenfolge. */
|
||||
function tgaFile({ width, height, pixels, type = 2, depth = 32, originTop = false }) {
|
||||
const bytes = new Uint8Array(18 + pixels.length * 4);
|
||||
bytes[2] = type;
|
||||
bytes[12] = width;
|
||||
bytes[14] = height;
|
||||
bytes[16] = depth;
|
||||
bytes[17] = 8 | (originTop ? 0x20 : 0);
|
||||
pixels.forEach(([r, g, b, a], i) => bytes.set([b, g, r, a], 18 + i * 4));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe("readTga", () => {
|
||||
// Dateireihenfolge: rot, grün, blau, weiß
|
||||
const pixels = [[255, 0, 0, 10], [0, 255, 0, 20], [0, 0, 255, 30], [255, 255, 255, 40]];
|
||||
|
||||
// Bruch: Ursprung unten links übersehen — die Maske läge kopfüber auf der Waffe.
|
||||
it("dreht die Zeilen um, wenn der Ursprung unten links liegt", () => {
|
||||
const { width, height, data } = readTga(tgaFile({ width: 2, height: 2, pixels }));
|
||||
expect([width, height]).toEqual([2, 2]);
|
||||
expect([...data]).toEqual([0, 0, 255, 30, 255, 255, 255, 40, 255, 0, 0, 10, 0, 255, 0, 20]);
|
||||
});
|
||||
|
||||
it("übernimmt die Reihenfolge, wenn der Ursprung oben liegt", () => {
|
||||
const { data } = readTga(tgaFile({ width: 2, height: 2, pixels, originTop: true }));
|
||||
expect([...data]).toEqual([255, 0, 0, 10, 0, 255, 0, 20, 0, 0, 255, 30, 255, 255, 255, 40]);
|
||||
});
|
||||
|
||||
// Bruch: stillschweigend falsch gelesene Datei statt einer verständlichen Meldung.
|
||||
it("lehnt RLE, 24 Bit und Farbtabellen ab", () => {
|
||||
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, type: 10 }))).toThrow(/Typ 10/);
|
||||
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, depth: 24 }))).toThrow(/24 Bit/);
|
||||
const withColorMap = tgaFile({ width: 2, height: 2, pixels });
|
||||
withColorMap[1] = 1;
|
||||
expect(() => readTga(withColorMap)).toThrow(/Farbtabelle 1/);
|
||||
});
|
||||
|
||||
// Bruch: Dateigröße nicht geprüft — die Maske würde stillschweigend unvollständig gelesen.
|
||||
it("meldet eine zu kurze Datei", () => {
|
||||
expect(() => readTga(tgaFile({ width: 4, height: 4, pixels }))).toThrow(/kürzer/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskToMetallicRoughness", () => {
|
||||
// Bruch: Kanäle vertauscht — Holz glänzte metallisch, Metall wirkte stumpf.
|
||||
it("legt den Grauwert nach G (Rauheit) und Alpha nach B (Metall)", () => {
|
||||
const mask = { width: 2, height: 1, data: Uint8Array.from([217, 217, 217, 56, 242, 242, 242, 179]) };
|
||||
expect([...maskToMetallicRoughness(mask)]).toEqual([255, 217, 56, 255, 242, 179]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("meshArrays", () => {
|
||||
function geometry() {
|
||||
const result = new BufferGeometry();
|
||||
result.setAttribute("position", new BufferAttribute(Float32Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1]), 3));
|
||||
result.setAttribute("normal", new BufferAttribute(Float32Array.from([0, 0, 1, 0, 0, 1, 0, 0, 1]), 3));
|
||||
result.setAttribute("uv", new BufferAttribute(Float32Array.from([0, 0, 1, 0.25, 0.5, 1]), 2));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Bruch: v nicht gespiegelt — jede Waffentextur läge senkrecht verkehrt.
|
||||
it("spiegelt v, weil FBX den UV-Ursprung unten und glTF ihn oben hat", () => {
|
||||
expect([...meshArrays(geometry(), new Quaternion()).uvs]).toEqual([0, 1, 1, 0.75, 0.5, 0]);
|
||||
});
|
||||
|
||||
// Bruch: Knotendrehung übergangen — die L96 zeigte mit dem Lauf nach hinten.
|
||||
it("dreht Eckpunkte und Normalen mit der Knotendrehung", () => {
|
||||
const halfTurn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
|
||||
const { positions, normals } = meshArrays(geometry(), halfTurn);
|
||||
expect(positions[0]).toBeCloseTo(-1, 6);
|
||||
expect(positions[8]).toBeCloseTo(-1, 6);
|
||||
expect(normals[2]).toBeCloseTo(-1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLength", () => {
|
||||
const pistol = WEAPON_SOURCES.find((source) => source.id === "pistol");
|
||||
const along = (length) => Float32Array.from([0, 0, 0, length, 0.02, 0.01]);
|
||||
|
||||
it("misst die längste Ausdehnung", () => {
|
||||
expect(longestExtent(along(0.189))).toBeCloseTo(0.189, 6);
|
||||
});
|
||||
|
||||
it("lässt eine 0,19 m lange Pistole durch", () => {
|
||||
expect(checkLength("pistol", along(0.189), pistol.length)).toBeCloseTo(0.189, 6);
|
||||
});
|
||||
|
||||
// Bruch: Knotenmaßstab 100 der Pistole mitgerechnet — eine 19 m lange Waffe in der Hand.
|
||||
it("bricht ab, wenn die Einheit nicht stimmt", () => {
|
||||
expect(() => checkLength("pistol", along(18.9), pistol.length)).toThrow(/falsche Einheit/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WEAPON_SOURCES", () => {
|
||||
it("nennt die fünf Waffen und die AK47 nur als Option 2", () => {
|
||||
expect(WEAPON_SOURCES.map((source) => source.id)).toEqual(["pistol", "ak47", "m4", "shotgun870", "l96"]);
|
||||
expect(WEAPON_SOURCES.find((source) => source.id === "ak47").dir).toBe("AK47_Option2(Same Socketing)");
|
||||
expect(TEXTURE_SIZE).toBe(1024);
|
||||
});
|
||||
|
||||
// Bruch: ID-Validierung übergangen — eine manipulierte ID könnte aus public/models/weapons/ herausführen.
|
||||
it("legt jede Waffe unter public/models/weapons ab", () => {
|
||||
expect(weaponTarget("pistol").replace(/\\/g, "/")).toMatch(/public\/models\/weapons\/pistol\.glb$/);
|
||||
expect(() => weaponTarget("../ual")).toThrow(/Ungültige Waffen-ID/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import { configDefaults } from "vitest/config";
|
||||
|
||||
@@ -6,6 +7,15 @@ export default defineConfig({
|
||||
resolve: { dedupe: ["three", "three-mesh-bvh"] },
|
||||
// Der BVH-Worker wird dynamisch aus three-mesh-bvh/src/workers/ geladen und darf nicht vorgebündelt werden.
|
||||
optimizeDeps: { exclude: ["three-mesh-bvh", "three-player-controller"] },
|
||||
// Zwei Seiten: das Spiel und die Animations-Testseite. Ohne diese Liste baut vite nur index.html.
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
animationen: fileURLToPath(new URL("./animationen.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: { host: true },
|
||||
// Parallele Sitzungen legen ihre Arbeitsbäume unter .claude/worktrees/ in diesem Repository
|
||||
// an. Ohne diesen Ausschluss sammelt vitest deren Testdateien mit ein — das verdoppelt die
|
||||
|
||||
Reference in new issue
Block a user