diff --git a/.claude/skills/placing-editor-models/SKILL.md b/.claude/skills/placing-editor-models/SKILL.md index 9296794..730f391 100644 --- a/.claude/skills/placing-editor-models/SKILL.md +++ b/.claude/skills/placing-editor-models/SKILL.md @@ -10,9 +10,9 @@ description: Use when a model built in the three-d-stage editor (GLB export, oft Der Ablauf bleibt der des Hood Yard: Brainstorming mit der Lagefrage am Vergleichsbild → kurze Spec → Plan → Umsetzung mit Subagenten und Reviews → Browserprüfung → Gesamt-Review → Abschluss. Vorlage: `docs/superpowers/specs/2026-09-14-hood-yard-design.md` und `docs/superpowers/plans/2026-09-14-hood-yard.md`. Neu gebaut werden muss nichts. Orte-Schicht (`src/world/places.js`), Aufbereitung (`tools/assets/places.mjs`), Bump-Erweiterung, Tests und das Vorwärmen in `main.js` gibt es schon. Ein neuer Ort besteht aus: -- einem `PLACES`-Eintrag, +- einem `PLACES`-Eintrag, optional mit `replaces` (Weltpunkte), wenn an der Stelle schon ein Gebäudekasten aus den Chunk-Daten steht, - einer aufbereiteten `.glb`, -- Ergänzungen in den Materiallisten und Tests. +- je einer Zeile in `SOLID_BY_PLACE` und `BUMP_BY_PLACE` (`tests/placesAsset.test.js`). Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spiel. @@ -46,6 +46,7 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi node .claude/skills/placing-editor-models/scripts/site-data.mjs /site.json ``` Straßenränder, Flächen und Laternen daraus ablesen. + - **Ersetzt der Ort ein Gebäude:** Steht in `site.json` unter `buildings` ein Grundriss an der Stelle, den der Ort ersetzen soll, dessen Mitte als `replaces: [[x, y]]` in den `PLACES`-Eintrag. Der `ChunkStreamer` lässt ihn dann weg (`withoutReplacedBuildings`). Den Ort nicht um den Kasten herum verschieben, nicht `corrections.toml` vorschlagen. Vorlage: `docs/superpowers/specs/2026-09-14-tribuene-design.md`. 3. **Satellitenbild einhängen:** - **Anker:** ein Punkt, der im Bild und in den Chunk-Daten eindeutig ist (Feldecke, Kreuzung), möglichst nah am Ziel. Echte Stadt und Spielkarte weichen voneinander ab. - **Maßstab:** Meter pro Pixel aus Autos (~4,5 m) oder Feldmarkierungen schätzen, mit ±15 % Unsicherheit rechnen. @@ -67,8 +68,8 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi - Browserprüfung 7. **Aufbereiten:** `node tools/assets/places.mjs `. Aus dem Bericht die `bounds` in `PLACES` übernehmen; `gpuMB` und die Dreieckszahl mit dem Nutzer abgleichen, wenn sie deutlich über dem Hood Yard liegen (231 MB, 658 000). 8. **Tests:** - - `tests/placesSite.test.js` prüft jeden `PLACES`-Eintrag schon von selbst. - - `tests/placesAsset.test.js` ist auf `hood-yard` zugeschnitten. Beim zweiten Ort auf `describe.each(PLACES)` umbauen: feste Materialien **je Ort** prüfen statt ganz `SOLID_MATERIALS`, die Bump-Prüfung nur dort, wo das Modell Bump hat. + - `tests/placesSite.test.js` prüft jeden `PLACES`-Eintrag von selbst, bei `replaces` auch, ob der ersetzte Grundriss noch an der Stelle liegt. + - `tests/placesAsset.test.js` läuft über `describe.each(PLACES)`. Für den neuen Ort je eine Zeile ergänzen: `SOLID_BY_PLACE` (feste Materialien seiner Datei) und `BUMP_BY_PLACE` (Materialien mit Bump, sonst `[]`). 9. **Browserprüfung** nach `browser-checks.md`: - Lage prüfen. - Kollision: Kapsel steht auf der Platte, eine Wand stoppt sie, Pflanzen sind durchlässig. @@ -87,7 +88,8 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi | Boden flackert im Ort | Plattenoberkante auf y 0 | `lift` für +6 cm | | Laterne oder Palme steht im Ort | Requisiten entstehen je Chunk, Laternen bis 16 m neben der Mittellinie, auch aus Nachbarchunks | Site-Test, Lage verschieben | | Ort schwebt nach einem Minimap-Sprung kurz über dem Meer | der Streamer baut einen Chunk pro Frame | hingenommen (Ruling Hood Yard) | -| Asset-Test wird mit dem zweiten Ort rot | globaler `SOLID_MATERIALS`-Check | je Ort prüfen (Schritt 8) | +| Asset-Test rot, „verteilt SOLID_MATERIALS …“ oder eine fehlende Zeile | Ort ohne Zeile in `SOLID_BY_PLACE`/`BUMP_BY_PLACE` | Zeile für den Ort ergänzen (Schritt 8) | +| Site-Test „in keinem Gebäude“ rot | der Ort soll ein vorhandenes Gebäude ersetzen | `replaces` mit der Mitte des Grundrisses setzen (Schritt 2), statt den Ort zu verschieben | | `preview_start` findet den Server nicht | liest nur `.claude/launch.json` des Hauptordners | Nutzer fragen, Arbeitsbaum kurz verlassen, Eintrag setzen, zurück, am Ende entfernen | | Befehle im Arbeitsbaum verweigert | Isolationswache mag keine Variablen als Befehl oder Skriptpfade mit Leerzeichen | Pfade ausschreiben; statt `review-package` drei Befehle: `git log --oneline A..B >`, `git diff --stat A..B >>`, `git diff -U10 A..B >>` in eine Datei | | Draw-Call- und Dreieckszahlen zu optimistisch | `renderer.info` wird nach den Schattenstufen zurückgesetzt | `gl.finish`-Messung (`browser-checks.md` §8) | diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index d6a5c3e..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "three-player-controller"] - path = three-player-controller - url = https://github.com/hh-hang/three-player-controller.git diff --git a/docs/superpowers/plans/2026-09-14-tribuene.md b/docs/superpowers/plans/2026-09-14-tribuene.md new file mode 100644 index 0000000..3ce4144 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-tribuene.md @@ -0,0 +1,718 @@ +# Tribüne im Park J6 — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Tribüne aus `tribuene.glb` ersetzt 1:1 den Gebäudekasten in J6 (Flamingo Park Baseball Stadium): an der Stelle des alten Gebäudes, mit Schatten und Kollision, und den alten Kasten gibt es im Spiel nicht mehr. + +**Architecture:** Die Orte-Schicht aus dem Hood Yard bleibt, wie sie ist. Neu ist das Feld `replaces` im `PLACES`-Eintrag, eine Liste von Weltpunkten. `ChunkStreamer` bekommt diese Punkte aus `main.js` und lässt über die reine Funktion `withoutReplacedBuildings` jeden Grundriss weg, in dem ein Punkt liegt. Die Tribüne selbst wird mit `tools/assets/places.mjs` aufbereitet und über die globalen Materiallisten eingeordnet. Der Asset-Test prüft danach jeden Ort einzeln. + +**Tech Stack:** three.js 0.184, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. + +**Spec:** `docs/superpowers/specs/2026-09-14-tribuene-design.md` + +## Global Constraints + +- **Lage:** `at: [1409.9, 1051.1]`, `rotation: 45`, `lift: 0`, `bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }`, `replaces: [[1403.25, 1057.75]]`. `at` und `replaces` sind Welt-x und Welt-y, nie three-z. +- **Radien** bleiben: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`. +- **Materialrollen:** + - fest: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01` + - Nahflächen: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale` + - Vereinfachung 0,25: `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01` +- **Quelle:** `/Users/Kenso/Downloads/tribuene.glb` (6,3 MB) kommt nie ins Repo. Die Kopie `tribuene.glb` im Hauptordner bleibt unberührt. +- **Aufbereitung** nur mit `node tools/assets/places.mjs`, nie mit der gltf-transform-CLI. +- **Nicht anfassen:** `public/data/`, `tools/extract/`, die Minimap, `src/world/geometry.js`, `src/world/props.js`, die Radien. +- **Arbeitsbaum** `/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene`, Branch `leonida/tribuene`, alle Befehle von dort aus. + - Gestaget wird immer mit Pfaden, nie mit `git add -A`. + - Die Isolationswache verweigert Befehle mit Variablen als Befehl oder mit Skriptpfaden, die Leerzeichen enthalten. Deshalb Pfade ausschreiben und einfache Einzelbefehle nutzen. +- **Stil:** Kommentare und Testnamen auf Deutsch, wie in den vorhandenen Dateien. Über Tests, die eine Fehlerart abfangen, steht ein Kommentar „Bruch:“. +- **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 `. +- **Tests:** einzeln mit `npx vitest run `, alle mit `npm test`. Ausgangsstand: `Test Files 17 passed (17)`, `Tests 170 passed (170)`. + +## Vorbereitung (erledigt) + +Der Arbeitsbaum steht auf `6a05626` plus dem Spec-Commit `4a1b337`. `node_modules` sowie `three-player-controller/package.json` und `dist/` sind hineinkopiert, der Ausgangsstand ist grün. + +- [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 17 Dateien und 170 Tests grün. + +--- + +### Task 1: Ersetzte Gebäude beim Chunk-Aufbau weglassen + +**Files:** +- Modify: `src/world/streaming.js` (Import, neue Funktion, Konstruktor, `#load`) +- Modify: `src/main.js:11` (Import) und `src/main.js:239` (Konstruktion des `ChunkStreamer`) +- Test: `tests/streaming.test.js` + +**Interfaces:** +- Consumes: `pointInPolygon(x, z, rings)` aus `src/world/props.js`, `PLACES` aus `src/world/places.js` (bisher ohne `replaces`) +- Produces: + - `withoutReplacedBuildings(chunk, points)` in `src/world/streaming.js`. `points` ist `Array<[x, y]>` in Weltkoordinaten. Die Funktion gibt `chunk` selbst zurück, wenn nichts entfällt, sonst `{ ...chunk, buildings }` mit gefilterter Liste. + - `new ChunkStreamer({ ..., replacedBuildings = [] })` + - `main.js` übergibt `PLACES.flatMap((place) => place.replaces ?? [])`. + +- [ ] **Schritt 1: Failing Tests schreiben** + +In `tests/streaming.test.js` den Import ändern: + +```js +import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; +``` + +`makeStreamer` bekommt einen vierten Parameter (die übrigen Aufrufe bleiben gültig, `undefined` greift auf die Vorgabe zurück): + +```js +function makeStreamer(chunkData, props, pinned, replacedBuildings) { +``` + +und in derselben Funktion die Rückgabe: + +```js + return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; +``` + +Direkt nach `describe("wantedChunks", …)` (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen: + +```js +describe("withoutReplacedBuildings", () => { + // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. + const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; + const chunk = { + chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], + buildings: [ + { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 + { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 + ], + }; + + // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten + // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. + it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { + const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); + + expect(result.buildings).toEqual([chunk.buildings[1]]); + expect(result.land).toBe(chunk.land); + expect(chunk.buildings).toHaveLength(2); + }); + + it("gibt ohne Treffer den Chunk selbst zurück", () => { + expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); + expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); + }); +}); +``` + +Im `describe("ChunkStreamer", …)` als letzten Test (nach „entlädt einen angehefteten Chunk nicht …“) einfügen: + +```js + // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt + // Kollision mitten in dem Ort, der ihn ersetzt. + it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { + const chunk = { + chunk: [4, 6], origin: [0, -1000], + land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], + surfaces: [], roads: [], + buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], + }; + // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. + const plain = makeStreamer(chunk); + plain.streamer.update(emptyChunkCenter); + await flush(); + expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); + + // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. + const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); + streamer.update(emptyChunkCenter); + await flush(); + + const source = player.addCollider.mock.calls[0][0].shape.source; + expect(source.children).toHaveLength(1); + expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude + }); +``` + +(Gebäudegeometrie trägt als einzige ein `color`-Attribut, siehe `buildBuildings` in `src/world/geometry.js`.) + +- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** + +Run: `npx vitest run tests/streaming.test.js` +Erwartet: Die zwei `withoutReplacedBuildings`-Tests schlagen fehl (`withoutReplacedBuildings is not a function`). „baut ein Gebäude, das ein Ort ersetzt, …“ scheitert an `expected [ …(2) ] to have a length of 1`. + +- [ ] **Schritt 3: Umsetzen** + +In `src/world/streaming.js` die Importe ergänzen: + +```js +import { Group, Mesh } from "three"; +import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; +import { buildChunkGeometry } from "./geometry.js"; +import { pointInPolygon } from "./props.js"; +``` + +Nach `function dist(k, position) { … }` einfügen: + +```js +/** + * Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js): + * jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und + * Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt + * derselbe Chunk zurück. + */ +export function withoutReplacedBuildings(chunk, points) { + if (points.length === 0) return chunk; + const [ox, oz] = chunk.origin; + const buildings = chunk.buildings.filter((building) => + !points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly))); + return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings }; +} +``` + +Konstruktor: + +```js + constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) { + Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings }); +``` + +In `#load` die Zeile `const geos = buildChunkGeometry(chunk);` ersetzen durch: + +```js + const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings)); +``` + +`this.props.build(chunk)` bleibt beim ungefilterten Chunk, Palmen und Laternen kennen keine Gebäude. + +In `src/main.js` Zeile 11: + +```js +import { PLACES, PlaceLayer } from "./world/places.js"; +``` + +und Zeile 239 (`const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`) ersetzen durch: + +```js +// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer +// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten. +const streamer = new ChunkStreamer({ + scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS, + replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []), +}); +``` + +- [ ] **Schritt 4: Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/streaming.test.js` +Erwartet: alle Tests grün. + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 173 passed (173)`. + +- [ ] **Schritt 5: Commit** + +```bash +git add src/world/streaming.js src/main.js tests/streaming.test.js +git commit -m "feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen" -m "withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Materialrollen der Tribüne und Asset-Test je Ort + +**Files:** +- Modify: `src/world/places.js:37-52` (`SOLID_MATERIALS` und Kommentar darüber) +- Modify: `tools/assets/places.mjs:26-45` (`NEAR_MATERIALS`, `SIMPLIFY_RATIOS` und Kommentar) +- Test: `tests/places.test.js` (`describe("isSolidMaterial")`), `tests/placesPipeline.test.js` (neuer Block), `tests/placesAsset.test.js` (neu geschrieben) + +**Interfaces:** +- Consumes: nichts aus Task 1 +- Produces: + - `SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` enthalten die Tribünen-Namen. + - `tests/placesAsset.test.js` prüft jede Datei aus `PLACES` gegen die Tabellen `SOLID_BY_PLACE` und `BUMP_BY_PLACE`. Task 3 fügt `tribuene` zu `PLACES` hinzu, die Zeile in `SOLID_BY_PLACE` steht dann schon. + +- [ ] **Schritt 1: Failing Tests schreiben** + +In `tests/places.test.js` im `describe("isSolidMaterial", …)` nach dem ersten `it` einfügen: + +```js + // Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man + // bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm). + it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "utility_box_01"]) { + expect(isSolidMaterial(name)).toBe(true); + } + for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01"]) { + expect(isSolidMaterial(name)).toBe(false); + } + }); +``` + +In `tests/placesPipeline.test.js` den Import ändern: + +```js +import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget } + from "../tools/assets/places.mjs"; +``` + +und am Dateiende anhängen: + +```js +describe("Materiallisten der Tribüne", () => { + // Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt + // davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px. + it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true); + for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false); + }); + + // Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper, + // Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten). + it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => { + for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) { + expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25); + } + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "Sitzschale"]) { + expect(SIMPLIFY_RATIOS.has(name)).toBe(false); + } + }); +}); +``` + +- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** + +Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` +Erwartet: 3 fehlgeschlagene Tests, nämlich der neue `isSolidMaterial`-Test (`Sichtbeton`: expected false to be true) und beide Tests in „Materiallisten der Tribüne“. + +- [ ] **Schritt 3: Listen ergänzen** + +In `src/world/places.js` den Kommentar über `SOLID_MATERIALS` ändern und am Listenende (nach der Zeile mit den Bäumen) eine Gruppe anhängen: + +```js +/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */ +export const SOLID_MATERIALS = [ +``` + +```js + // Bäume + "quiver_tree_01_trunk", "quiver_tree_02", + // Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden, + // Glas der Pressekabine, Anschlusskästen am Boden + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01", +]; +``` + +In `tools/assets/places.mjs` in `NEAR_MATERIALS` nach `"chainlink_post", "chainlink_wire",` anhängen: + +```js + // Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale", +``` + +Den Kommentar über `SIMPLIFY_RATIOS` und die Liste so ändern: + +```js +/** + * Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit + * Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume + * haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, + * quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben + * Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298. + */ +export const SIMPLIFY_RATIOS = new Map([ + ["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15], + ["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5], + ["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5], + ["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25], + ["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25], + ["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25], +]); +``` + +- [ ] **Schritt 4: Tests laufen lassen** + +Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` +Erwartet: grün. + +Run: `npx vitest run tests/placesAsset.test.js` +Erwartet: 1 Fehlschlag in „enthält jedes feste Material der Kollisionsliste“ mit den acht Tribünen-Namen. Das ist der bekannte Fallstrick: Der Test gleicht die ganze globale Liste mit der Datei des Hood Yard ab. + +- [ ] **Schritt 5: Asset-Test auf alle Orte umbauen** + +`tests/placesAsset.test.js` vollständig ersetzen durch: + +```js +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 { PLACES, SOLID_MATERIALS } from "../src/world/places.js"; +import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; + +// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle +// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile. +const SOLID_BY_PLACE = { + "hood-yard": [ + "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete", + "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", + "ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB", + "rollershutter_window_01", "rollershutter_window_01_graffiti", + "chainlink_post", "chainlink_wire", + "modular_electricity_poles", "barrel_03", "plastic_container", + "quiver_tree_01_trunk", "quiver_tree_02", + ], + tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"], +}; + +// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps. +const BUMP_BY_PLACE = { + "hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"], +}; + +// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. +it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => { + expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort()); +}); + +describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => { + const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); + const bump = BUMP_BY_PLACE[id] ?? []; + let document; + beforeAll(async () => { + await MeshoptDecoder.ready; + const io = new NodeIO() + .registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump]) + .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); + document = await io.read(file); + }, 30000); + + // Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB. + it("ist kleiner als 30 MB", () => { + expect(statSync(file).size).toBeLessThan(30e6); + }); + + // Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest. + it("nutzt meshopt und WebP und hat keine Instanzen", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + + expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"])); + expect(used).not.toContain("EXT_mesh_gpu_instancing"); + }); + + it("hat nur WebP-Texturen bis 1024 px", () => { + for (const texture of document.getRoot().listTextures()) { + expect(texture.getMimeType()).toBe("image/webp"); + expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(1024); + } + }); + + // Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der + // Container wäre flach. + it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material])); + + expect(used).toContain(EXT_MATERIALS_BUMP); + for (const name of bump) { + expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy(); + } + }); + + // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung + // durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE. + it("enthält jedes feste Material seines Orts", () => { + const names = document.getRoot().listMaterials().map((material) => material.getName()); + + expect(SOLID_BY_PLACE[id]).toBeDefined(); + expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]); + }); + + // Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche. + it("passt auf 5 cm zu den Außenmaßen in PLACES", () => { + const { min, max } = getBounds(document.getRoot().listScenes()[0]); + const { minX, maxX, minZ, maxZ } = place.bounds; + + expect(Math.abs(min[0] - minX)).toBeLessThan(0.05); + expect(Math.abs(max[0] - maxX)).toBeLessThan(0.05); + expect(Math.abs(min[2] - minZ)).toBeLessThan(0.05); + expect(Math.abs(max[2] - maxZ)).toBeLessThan(0.05); + }); +}); +``` + +- [ ] **Schritt 6: Alle Tests laufen lassen** + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 177 passed (177)`. Gegenüber dem Ausgangsstand kommen hinzu: +- 3 Tests aus Task 1 +- 1 Test aus `places.test.js` +- 2 Tests aus `placesPipeline.test.js` +- der neue Verteilungstest im Asset-Test + +- [ ] **Schritt 7: Commit** + +```bash +git add src/world/places.js tools/assets/places.mjs tests/places.test.js tests/placesPipeline.test.js tests/placesAsset.test.js +git commit -m "feat(world): Materialrollen der Tribüne, Asset-Test je Ort" -m "Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Tribüne aufbereiten und eintragen + +**Files:** +- Create: `public/models/places/tribuene.glb` (Ergebnis von `places.mjs`) +- Modify: `src/world/places.js` (Kommentar über `PLACES`, neuer Eintrag) +- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (Abschnitt „Budget“) +- Test: `tests/places.test.js` (`describe("PLACES")`), `tests/placesSite.test.js` + +**Interfaces:** +- Consumes: + - `withoutReplacedBuildings(chunk, points)` aus Task 1 + - die Materiallisten und `SOLID_BY_PLACE.tribuene` aus Task 2 + - `node tools/assets/places.mjs ` +- Produces: `PLACES` mit dem Eintrag `tribuene`, den `main.js` (Task 1) und `PlaceLayer` verwenden, und `public/models/places/tribuene.glb`. + +- [ ] **Schritt 1: Aufbereiten** + +Run: `node tools/assets/places.mjs /Users/Kenso/Downloads/tribuene.glb tribuene` + +Erwartet (am Prototyp gemessen, Laufzeit unter 5 s): +- `vorher 6.3 MB`, darin `triangles: 117916`, `gpuMB: 53` +- `nachher 1.7 MB`, darin: + - `materials: 14`, `primitives: 14`, `triangles: 69557` + - `textures: { '512x512 image/webp': 23, '256x256 image/webp': 12, '768x768 image/webp': 4 }` + - `gpuMB: 49`, `bounds: { min: [ -20.8, 0, -14.82 ], max: [ 20.8, 12.57, 0.8 ] }` + +Weicht eine Zahl deutlich ab, anhalten und melden. + +- [ ] **Schritt 2: Eintrag in `PLACES`** + +In `src/world/places.js` den Kommentar über `PLACES` ergänzen: + +```js +/** + * at: Welt-x und Welt-y in Metern, wie auf der Minimap — nie three-z. rotation: Grad gegen den + * Uhrzeigersinn, von oben mit Norden oben (wie add_building in corrections.toml). lift: Meter auf die + * Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests + * prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces + * (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim + * Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js). + */ +``` + +und nach dem Eintrag `hood-yard` anfügen: + +```js + { + id: "tribuene", + url: "./models/places/tribuene.glb", + // Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers); + // längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten. + at: [1409.9, 1051.1], + // Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate. + rotation: 45, + // Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts. + lift: 0, + bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }, + // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6). + replaces: [[1403.25, 1057.75]], + }, +``` + +- [ ] **Schritt 3: Tests laufen lassen, die vorhandenen Datentests müssen anschlagen** + +Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` +Erwartet: +- `tribuene.glb` im Asset-Test: 5 Tests grün, 1 übersprungen (Bump). +- Rot wird „hat für jeden Ort id, url, at, rotation, lift und bounds“ in `places.test.js`, weil `replaces` ein unerwartetes Feld ist. +- Rot wird „Ort tribuene auf der Karte > liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“. Die Liste zeigt Punkte im alten Gebäude. +- Alles andere bleibt grün. + +- [ ] **Schritt 4: Form-Test lässt `replaces` zu** + +In `tests/places.test.js` den Test im `describe("PLACES", …)` ersetzen durch: + +```js + // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds + // prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg. + it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => { + for (const { replaces = [], ...entry } of PLACES) { + expect(entry).toEqual({ + id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/), + at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number), + bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) }, + }); + for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]); + } + }); +``` + +- [ ] **Schritt 5: Lage-Test kennt ersetzte Gebäude** + +In `tests/placesSite.test.js`: + +Import ergänzen (nach der Zeile mit `props.js`): + +```js +import { withoutReplacedBuildings } from "../src/world/streaming.js"; +``` + +Nach der Funktion `chunk(i, j)` einfügen: + +```js +// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in +// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht. +const REPLACED = PLACES.flatMap((place) => place.replaces ?? []); +const standing = new Map(); +function standingBuildings(i, j) { + const key = `${i}_${j}`; + if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings); + return standing.get(key); +} +``` + +Im Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ den Kommentar und die Gebäudezeile ändern: + +```js + // Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben + // in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht. +``` + +```js + const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly)); +``` + +Direkt nach diesem Test einfügen: + +```js + // Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der + // Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb. + it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => { + const bad = place.replaces.filter(([X, Y]) => { + const { i, j } = chunkForWorld(X, Y); + const c = chunk(i, j); + const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly)); + return hits.length !== 1 || !insideBounds(place, X, Y); + }); + expect(bad).toEqual([]); + }); +``` + +- [ ] **Schritt 6: Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` +Erwartet: alles grün. Übersprungen sind nur „gibt den Bump-Materialien ihre Bump-Textur“ bei `tribuene.glb` und „ersetzt mit jedem replaces-Punkt …“ beim Hood Yard. + +- [ ] **Schritt 7: Gegenprobe, der neue Datentest muss anschlagen** + +In `src/world/places.js` den Punkt vorübergehend auf `replaces: [[1403.25, 1087.75]]` setzen (30 m nördlich, auf der Straße). + +Run: `npx vitest run tests/placesSite.test.js` +Erwartet für `tribuene`: rot sind „liegt ganz auf Land, … in keinem Gebäude“ (der Kasten zählt wieder) und „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“ (`[[1403.25, 1087.75]]`). + +Danach zurück auf `replaces: [[1403.25, 1057.75]]`. Run: `npx vitest run tests/placesSite.test.js`, erwartet grün. + +- [ ] **Schritt 8: Budget in der Spec** + +In `docs/superpowers/specs/2026-09-14-tribuene-design.md` den Abschnitt „## Budget“ vollständig ersetzen, mit den Werten aus dem Bericht von Schritt 1: + +```markdown +## Budget + +- **Gemessen an der Aufbereitung (2026-09-14):** + - 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls) + - 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px + - geschätzt 49 MB Grafikspeicher, Außenmaße unverändert + - Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298. +- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB. +``` + +Weicht der Bericht vom Prototyp ab, gelten die Werte des Berichts. + +- [ ] **Schritt 9: Alle Tests laufen lassen** + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 186 passed | 2 skipped (188)`. Gegenüber Task 2 kommen hinzu: +- 6 Asset-Tests der Tribüne, davon 5 grün und 1 übersprungen +- 4 Lage-Tests der Tribüne, alle grün +- beim Hood Yard der neue Lage-Test, übersprungen + +- [ ] **Schritt 10: Commit** + +```bash +git add public/models/places/tribuene.glb src/world/places.js tests/places.test.js tests/placesSite.test.js docs/superpowers/specs/2026-09-14-tribuene-design.md +git commit -m "feat(world): Tribüne im Park J6 statt des alten Gebäudekastens" -m "tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Prüfung im Browser (Controller, nicht an Subagenten) + +Das übernimmt die Sitzung selbst. Sie braucht den Browser-Bereich, einen Eintrag in `.claude/launch.json` des Hauptordners (vorher den Nutzer fragen) und Rückfragen an den Nutzer. Grundlage ist `.claude/skills/placing-editor-models/browser-checks.md`, die Abschnitte werden unten mit §N zitiert. + +**Files:** +- Create, nie committen, am Ende löschen: `index-debug.html` (§2) +- Modify, am Ende zurücknehmen: `/Users/Kenso/Developer/gta6/.claude/launch.json` (§1, Eintrag `place-check`, Port 5181, `cd /Users/Kenso/Developer/gta6/.claude/worktrees/tribuene && npx vite --port 5181 --strictPort`) +- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (neuer Punkt „Gemessen im Spiel“ unter „Fehlerfälle und Risiken“) + +**Werte** (Welt → three: z = −y): + +| Zweck | Welt (x \| y) | three | +|---|---|---| +| Wurzel der Tribüne | (1409,9 \| 1051,1) | `position` (1409.9, 0, −1051.1), `rotation.y` = π/4 | +| Sprungziel auf dem Spielfeld | (1420,5 \| 1040,5) | — | +| Draufsicht | Mitte (1404,94 \| 1056,06) | `position [1404.94, 60, -1056.06]`, `target [1404.94, 0, -1056.06]`, `up [0, 0, -1]` | +| Blick vom Spielfeld | (1427,58 \| 1033,42) | `position [1427.58, 1.7, -1033.42]`, `target [1404.94, 5, -1056.06]` | +| Blick von hinten | (1388,69 \| 1072,31) | `position [1388.69, 1.7, -1072.31]`, `target [1404.94, 5, -1056.06]` | +| Streifen des alten Kastens hinter der Tribüne | (1397,88 \| 1063,12) | Strahl von (1397.88, 60, −1063.12) nach unten | +| Kapsel hinter der Rückwand | (1389,39 \| 1054,64) | `capsule.position.set(1389.39, 1, -1054.64)`, Blick 135° | +| Kapsel vor dem Tunnel (x 6–8) | (1402,83 \| 1068,07) | `capsule.position.set(1402.83, 1, -1068.07)`, Blick 135° | +| Kapsel auf der Sitzstufe y 6,0 | (1394,34 \| 1049,69) | `capsule.position.set(1394.34, 7, -1049.69)`, Blick 45° (längs der Reihe) | + +- [ ] **Schritt 1: Server und Debug-Seite** + - Den Nutzer fragen, ob der Eintrag `place-check` in `.claude/launch.json` des Hauptordners gesetzt werden darf. + - Dann `ExitWorktree(keep)`, Eintrag setzen, `preview_start({ name: "place-check" })`, `EnterWorktree({ path: "/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene" })`. + - `index-debug.html` nach §2 anlegen, `resize_window` auf 1280 × 800 setzen, `http://localhost:5181/index-debug.html` öffnen und den Hook nach §3 einrichten. +- [ ] **Schritt 2: Vorwärmen am Startpunkt** + - Warten, bis `renderer.info.memory.textures` 5 s lang gleich bleibt. + - Die verschiedenen Texturen zählen, die über `scene.traverse` an Materialien hängen. + - Erwartet: Die GPU-Zahl liegt um mindestens 108 höher (69 Hood Yard + 39 Tribüne), weil beide Orte vorgeladen, aber noch nicht in der Szene sind. +- [ ] **Schritt 3: Sprung** + - Minimap nach §4 öffnen und einen Screenshot machen. + - Bei (1420,5 \| 1040,5) klicken und warten, bis die Wurzel der Tribüne in `scene.children` hängt. + - Die Konsole mit `read_console_messages({ onlyErrors: true })` lesen: keine Fehler. +- [ ] **Schritt 4: Alter Kasten weg** + - Mit `THREE.Raycaster` von (1397.88, 60, −1063.12) senkrecht nach unten gegen `scene.children` schießen. + - Erwartet: Der erste Treffer mit `object.isMesh` liegt unter y 1. Die Oberkante des alten Kastens läge bei 18,6 m. + - Denselben Strahl an der alten Mitte (1403.25, 60, −1057.75) schießen: Der erste Treffer liegt höchstens bei 12,6 m, also auf dem Dach der Tribüne. +- [ ] **Schritt 5: Kollisionsmarken und Quantisierung (§5)** + - Die Meshes ohne `userData.excludeFromCollider` haben genau die Materialnamen `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`. + - Alle haben `Float32Array`-Positionen. +- [ ] **Schritt 6: Blicke (§6)** + - Draufsicht, Blick vom Spielfeld und Blick von hinten aufnehmen und mit `decode-view.py` speichern. + - Die Bilder per `SendUserFile` an den Nutzer schicken. + - Achten auf: zerrissene Kabel oder Leuchten, Schatten des Glases, Helligkeit. +- [ ] **Schritt 7: Kollision (§7)**, jede Sekunde die Lage notieren, danach `keyup`: + - **Rückwand:** Kapsel hinter die Rückwand setzen, Blick 135°, 15 s `KeyW`. Erwartet: Sie steht bei y 0,75 und bleibt wenige cm vor der Wand stehen, also Modell-z knapp unter −14,8. + - **Tunnel:** Kapsel vor den Tunnel setzen, Blick 135°, 25 s `KeyW`. Festhalten, ob sie über die 25-cm-Kante hineinkommt und die Treppe hinaufsteigt. Nur feststellen, nichts ändern. + - **Sitzstufe:** Kapsel auf die Sitzstufe setzen, Blick 45°, 15 s `KeyW`. Erwartet: Sie steht bei etwa y 6,75 und läuft durch die Sitzschalen hindurch, ohne Halt bis zum nächsten Geländer. +- [ ] **Schritt 8: Kosten (§8)** + - Den Median aus 60 Bildern mit `gl.finish` messen: Blick vom Spielfeld, einmal mit und einmal ohne die Wurzel der Tribüne in der Szene. + - Dasselbe in der Draufsicht. + - Erwartet: deutlich unter dem Hood Yard (+0,5 ms). +- [ ] **Schritt 9: Aufräumen** + - `window.__view = null`, `index-debug.html` löschen, `preview_stop`. + - `ExitWorktree(keep)`, den Eintrag `place-check` aus `.claude/launch.json` entfernen, `EnterWorktree(path)`. + - `git status --short` zeigt nur die Spec. +- [ ] **Schritt 10: Messwerte in die Spec und Commit** + - Unter „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel (2026-09-14)** anlegen: + - Kosten mit und ohne Tribüne + - Vorwärmen (Differenz des Texturzählers) + - Ergebnis der drei Kollisionsläufe + - Auffälligkeiten aus den Blicken + - Commit mit `git add docs/superpowers/specs/2026-09-14-tribuene-design.md`, Nachricht `docs: Tribünen-Spec mit Messwerten aus dem Spiel` plus Co-Authored-By-Zeile. diff --git a/docs/superpowers/specs/2026-09-14-tribuene-design.md b/docs/superpowers/specs/2026-09-14-tribuene-design.md new file mode 100644 index 0000000..8ad979c --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-tribuene-design.md @@ -0,0 +1,154 @@ +# Tribüne im Park J6 — Entwurf + +**Datum:** 2026-09-14 +**Zusammenhang:** Der Nutzer hat eine Tribüne gebaut (`~/Downloads/tribuene.glb`, erzeugt mit trimesh). Sie soll 1:1 die Tribüne des Flamingo Park Baseball Stadium (1435 Michigan Ave, Miami Beach) ersetzen, die das Spiel in J6 als einfachen Gebäudekasten zeigt. Den Kasten soll es danach nicht mehr geben. Vorlage ist ein Satellitenbild des Nutzers im Gespräch. Es liegt nicht als Datei vor. Verglichen wurde deshalb mit dem Screenshot aus dem Hood Yard (`~/Desktop/Bildschirmfoto 2026-09-14 um 00.15.53.png`), auf dem die Tribüne ebenfalls zu sehen ist. +**Baut auf:** `docs/superpowers/specs/2026-09-14-hood-yard-design.md` (Orte-Schicht, Aufbereitung, Radien, Tests). Neu ist nur, dass ein Ort ein Gebäude aus den Chunk-Daten ersetzt. + +## Ziel + +Die Tribüne steht im Spiel an der Stelle des alten Gebäudes, in Originalgröße, mit Schatten und Kollision. Das alte Gebäude ist weg. + +## Befund (gemessen) + +### Modell + +| Punkt | Wert | +|---|---| +| Datei | 6,3 MB, Generator trimesh, 19 Knoten mit je eigenem Mesh, 14 Materialien, keine Erweiterungen (kein Bump, keine Texturtransformation) | +| Texturen | 40 JPEG: 21 × 512 px, 13 × 256 px, 6 × 768 px (Grundfarbe von `Verkleidung`, `Estrich`, `Stahl`, `Trapezblech`, `Riffelblech`, `Sitzschale`), geschätzt 53 MB Grafikspeicher | +| Dreiecke | 117 916 gezeichnet, kein Mesh wird mehrfach benutzt. Die größten: `Sitzschale` 44 256, `modular_electric_cables` 21 796, `industrial_wall_lamp` 13 845, `power_box_01` 13 398, `modular_electric_cables_boxes` 8 491, `Stahl` 6 804 | +| Maße | x −20,8 bis 20,8 · z −14,82 bis 0,8 · y 0 bis 12,57, also 41,6 × 15,6 m und 12,6 m hoch (`getBounds`) | +| Einheit | Meter: Sitzstufen 0,45 m hoch und 0,8 m tief | +| Ausrichtung | Front mit der untersten Stufe bei +z, Rückwand mit Dach, Pressekabine und Tunnelportal bei −z. Ohne Drehung schaute die Tribüne nach Süden. | +| Boden | Keine Bodenplatte. Die Unterseite des Betonkörpers (`Sichtbeton`) liegt bei y 0, die tiefsten Trittflächen bei 0,25 m (Tunnel) und 0,55 m (vorderste Stufe). | +| Tunnel | Zwei 2 m breite Gänge (x −8 bis −6 und 6 bis 8) führen von der Rückseite mit zwölf 20-cm-Stufen auf die Sitzstufe bei y 2,85. | + +### Stelle im Spiel (J6, `chunk_9_5`) + +- **Altes Gebäude:** + - Ecken (1384,5 | 1052), (1409 | 1076,5), (1422 | 1063,5), (1397,5 | 1039). + - 34,65 × 18,38 m, 18,6 m hoch, 45° gedreht, Mitte (1403,25 | 1057,75). + - Kein anderes Gebäude steht näher als 21 m. +- **Nachbarschaft:** + - Südwestlich verläuft die Michigan Ave. An ihr stehen zwei Laternen, bei (1384,17 | 1048,56) und (1382,18 | 1048,37). + - Nördlich liegt die Straße am Parkrand. + - Südöstlich liegt das Spielfeld (`surfaces`, `kind: "park"`). +- **Boden unter der Tribüne:** nur Land, keine Straße und keine Bodenfläche. + +### Satellitenbild + +- Anker und Maßstab wie beim Hood Yard: Nordost-Ecke des Baseballfelds (1000 | 213) ↔ (1525 | 1058), 0,15 m je Pixel. +- Die echte Tribüne (Dach und Sitzreihen) misst etwa 24 × 15 m. Sie ist ebenfalls 45° gedreht und schaut nach Südosten auf die Home Plate. Ihre Mitte liegt bei etwa (1405 | 1058,6), 2 m neben der Mitte des alten Gebäudes. +- Das Modell ist 7 m länger als das alte Gebäude und 17 m länger als die echte Tribüne. Längs bleibt es mittig, weil an beiden Enden Straßen liegen. An der gewählten Lage steht nach 1,25 m Verschiebung nach Nordosten oder 1,8 m nach Südwesten eine Ecke auf der Fahrbahn. + +## Entscheidungen des Nutzers + +| Frage | Entscheidung | +|---|---| +| Größe | 1:1 | +| Lage | Längs mittig auf dem alten Gebäude, 45° gedreht, Front 1 m vor dessen Feldseite (Vergleichsbild im Gespräch bestätigt) | +| Ersetzen | `replaces` im Orte-Eintrag, der `ChunkStreamer` lässt den Grundriss beim Aufbau weg. Verworfen: ein Platzhalter-Kasten bis zum Einsetzen und eine Korrektur in der Extraktion. | +| Materialrollen, Aufbereitung, Tests | bestätigt | +| Arbeitsweise | Arbeitsbaum `.claude/worktrees/tribuene`, Branch `leonida/tribuene`, weil im Hauptordner nicht committete Änderungen einer anderen Sitzung liegen | + +## Lage + +`at = [1409.9, 1051.1]`, `rotation = 45`, `lift = 0`, `replaces = [[1403.25, 1057.75]]`. + +Die positive Drehung bringt +x nach Nordosten und die Front (+z) nach Südosten. + +| Nachbar | Abstand zur Außenkante | +|---|---| +| Straße am Parkrand | 0,8 m von der hinteren Nordost-Ecke (1414,13 \| 1076,29) | +| Michigan Ave | 1,3 m von der hinteren Südwest-Ecke (1384,71 \| 1046,87) | +| Laternen | 1,6 m und 2,9 m | +| Palmen | 10,1 m | +| Spielfeld | 3,2 m vor der Front | +| altes Gebäude | Die Tribüne ragt an beiden Enden 3,5 m hinaus. Ihre Front liegt 1,0 m vor der Feldseite des alten Gebäudes, ihre Rückseite 3,8 m vor dessen Rückseite. | + +**Höhe:** Es gibt keine Bodenplatte, also `lift = 0`. Keine nach oben zeigende Fläche liegt auf y 0, deshalb flackert nichts. + +**Radien:** +- Unter der Tribüne liegt nur J6. Seine Mitte ist 81,7 m von `at` entfernt, die Bedingungen 81,7 + 250 ≤ 400 und 81,7 + 450 ≤ 600 sind erfüllt. +- Vom Startpunkt (1600 | 690) sind es 408 m. Die Datei lädt also schon beim Start vor, wie der Hood Yard, der 81 m entfernt steht. + +## Altes Gebäude ersetzen + +- **`PLACES`-Eintrag:** `replaces` ist eine Liste von Weltpunkten (x, y). Jeder Grundriss eines Chunks, in dem ein solcher Punkt liegt, entfällt. Das wirkt wie `remove_building` aus der Feinkalibrierung (`docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md`), nur zur Laufzeit. +- **`src/world/streaming.js`:** Die reine Funktion `withoutReplacedBuildings(chunk, points)` gibt den Chunk ohne diese Grundrisse zurück, als flache Kopie mit neuer `buildings`-Liste. + - Der Punkt wird in Chunk-Koordinaten umgerechnet: u = x − origin[0], v = −y − origin[1]. Geprüft wird mit `pointInPolygon` aus `props.js`. + - Trifft kein Punkt, kommt der Chunk selbst zurück. +- **`ChunkStreamer`:** Neue Option `replacedBuildings` (Vorgabe `[]`). Die Geometrie entsteht aus `withoutReplacedBuildings(chunk, this.replacedBuildings)`. Gebäude liegen in der Kollisionsgruppe, mit dem Grundriss entfallen deshalb Kasten, Schatten und Kollision zugleich. +- **`src/main.js`:** `replacedBuildings: PLACES.flatMap((place) => place.replaces ?? [])`. +- **Folgen:** + - Den Kasten sieht man nie. Die Stelle bleibt leer, bis die Tribüne ab 250 m eingesetzt wird, genau wie beim Hood Yard. + - Die Minimap zeigt weiter den alten Umriss, ihr Bild kommt aus der Extraktion. Er liegt ungefähr unter der Tribüne. Ihre Begehbarkeitsmaske (`tools/extract/overview.py`) sperrt dort weiter das Landen. + - Lädt die Tribüne nicht, steht an der Stelle gar kein Gebäude. + +## Materialrollen + +`SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` gelten für alle Orte und werden über den Materialnamen nachgeschlagen. Keiner der neuen Namen steht dort schon. + +| Rolle | Materialien | +|---|---| +| fest (`SOLID_MATERIALS`) | `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01` (Anschlusskästen am Boden, 1,1 m hoch) | +| durchlässig | `Sitzschale`: Zwischen den Sitzreihen bleiben nur 38 cm, man läuft auf den Betonstufen. Außerdem `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01` (Verteilerkästen an der Wand in 1,5 m Höhe) | +| Nahflächen, 1024 px (`NEAR_MATERIALS`) | `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale`. Die Quelle hat höchstens 768 px, und `compressTexture` vergrößert nicht, 768 bleibt also 768. Alle übrigen Texturen gehen auf höchstens 512 px, darunter die von `Trapezblech` und `Riffelblech`. | +| Vereinfachung 0,25 (`SIMPLIFY_RATIOS`) | `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01`: 64 298 → rund 16 000 Dreiecke. Körper, Dach, Geländer und Sitze bleiben unverändert. | + +## Budget + +- **Gemessen an der Aufbereitung (2026-09-14):** + - 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls) + - 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px + - geschätzt 49 MB Grafikspeicher, Außenmaße unverändert + - Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298. +- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB. + +## Tests + +- **`tests/streaming.test.js`:** + - `withoutReplacedBuildings` entfernt nur den Grundriss, der den Punkt enthält, und rechnet dabei mit dem Chunk-Ursprung. + - Ohne Treffer gibt die Funktion den Chunk selbst zurück. + - Ein `ChunkStreamer` mit `replacedBuildings` baut aus einem Chunk mit nur diesem Gebäude keine Gebäudegeometrie. +- **`tests/placesSite.test.js`:** + - „in keinem Gebäude“ übergeht die Grundrisse, die ein Ort ersetzt. + - Neu: Der Punkt trifft genau einen Grundriss, liegt in den Außenmaßen, und alle Ecken dieses Grundrisses liegen höchstens 5 m außerhalb. Das fängt einen Lauf der Extraktion ab, der das Gebäude verschiebt, teilt oder mit einem Nachbarn zusammenlegt. +- **`tests/placesAsset.test.js`** wird auf `describe.each(PLACES)` umgebaut: + - Je Ort geprüft werden Dateigröße, meshopt und WebP, fehlende Instanzen, Texturen bis 1024 px und `bounds` auf 5 cm. + - Die festen Materialien je Ort kommen aus einer Tabelle im Test, deren Vereinigung `SOLID_MATERIALS` ergeben muss. + - Bump wird nur beim Hood Yard geprüft. +- **Browser** (`.claude/skills/placing-editor-models/browser-checks.md`): + - Über die Minimap ans Spielfeld vor der Tribüne springen, der alte Kasten ist nicht mehr da. + - Draufsicht zum Vergleich mit dem Satellitenbild, dazu Blicke vom Spielfeld und von der Rückseite. + - Kollision: Die Kapsel steht neben der Tribüne bei y 0,75, die Rückwand stoppt sie, die Sitze lässt sie durch. Ob der Tunnel begehbar ist, wird festgehalten. + - Kosten mit `gl.finish` messen, mit und ohne Tribüne. Vorwärmen am Startpunkt über den Texturzähler prüfen. + - Die Konsole zeigt keine Fehler. + +## Fehlerfälle und Risiken + +- **Karte neu erzeugt:** Verschiebt die Extraktion das Gebäude, trifft der Punkt nichts mehr oder ein anderes Gebäude. Dann schlägt der Datentest an. +- **Glas wirft Schatten:** Jedes Mesh eines Orts wirft Schatten, auch `Verglasung` und das Lampenglas. Fällt das im Browser auf, wird es im Gespräch entschieden. +- **Vereinfachte Kabel und Leuchten wirken zerrissen:** Dann wird der Anteil hochgesetzt, mit einem Vorher-Nachher-Bild. +- **Stufen:** Die vorderste Stufe (55 cm), die Sitzstufen (45 cm) und der Tunnelboden (25 cm) sind vielleicht zu hoch für die Kapsel. Das wird im Browser festgestellt und nicht nachgebaut. +- **Gemessen im Spiel (2026-09-14, 2560 × 1600, mit Schattenstufen):** + - **Alter Kasten:** Er ist weg. Ein Strahl im Streifen hinter der Tribüne trifft den Boden (y 0), in der alten Mitte das Tribünendach (y 10,1) statt 18,6 m. Ein Nachbargebäude in J6 steht unverändert. + - **Kollision:** + - Genau die acht festen Materialien haben Float32-Positionen und kommen in den Collider, die sechs durchlässigen nicht. + - Die Kapsel (Radius 0,15 m) steht auf dem Boden bei y 0,75. An der Rückwand bleibt ihre Mitte bei Modell-z −14,54 stehen; die Sockelfläche (Sichtbeton, y 0–1,15) liegt bei −14,39 — die Kapsel berührt die Wand also, ohne einzudringen. + - Sie steigt über die 25-cm-Kante in den Tunnel, läuft die Treppe hinauf auf die Sitzstufe (y 3,6) und im Gang hinunter bis ans vordere Geländer. + - Auf der Sitzstufe in 6 m Höhe läuft sie ohne Halt 5,7 m durch eine Sitzreihe. + - **Kosten:** Median aus 60 Bildern mit `gl.finish`: + - Blick vom Spielfeld 1,4–1,5 ms statt 1,3 ms + - Draufsicht 2,4–2,5 ms statt 2,2 ms + - Blick von hinten 4,5–4,6 ms statt 4,3 ms + - Die Tribüne kostet also +0,1–0,3 ms je Bild. + - **Vorwärmen:** Am Startpunkt hängen 112 der 139 GPU-Texturen an keinem Objekt der Szene (Hood Yard 69 und Tribüne 39, beide vorgeladen). Nach einem Rücksprung aus 511 m erscheint die Tribüne 48 ms nach dem Klick, die größte Bildlücke ist 56 ms bei 33-ms-Takt, und es kommen keine Texturen hinzu. + - **Aussehen:** Vereinfachte Kabel und Leuchten wirken nicht zerrissen, ein Schatten des Glases fällt nicht auf. + - **Konsole:** keine Warnung zu Orten. Nur Pointer-Lock-Fehler des verborgenen Browser-Bereichs und Fehler aus den künstlichen Mausereignissen der Prüfung. + +## Nicht Teil dieser Arbeit + +- Minimap-Bild, Begehbarkeitsmaske und Feinkalibrierung. +- Kein Platzhalter-Kasten von weitem, keine eigenen Radien je Ort. +- Das Modell wird nur verkleinert, nicht umgebaut. diff --git a/public/models/places/tribuene.glb b/public/models/places/tribuene.glb new file mode 100644 index 0000000..9358dd1 Binary files /dev/null and b/public/models/places/tribuene.glb differ diff --git a/src/main.js b/src/main.js index 1db8b64..50c41bf 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,7 @@ import { createMaterials } from "./world/materials.js"; import { createSky } from "./world/sky.js"; import { createSunShadows, prepareLitMaterials } from "./world/shadows.js"; import { PropLayer } from "./world/props.js"; -import { PlaceLayer } from "./world/places.js"; +import { PLACES, PlaceLayer } from "./world/places.js"; import { chunkForWorld, threeToWorld } from "./world/coords.js"; import { createCharacterModule } from "./characters/index.js"; import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js"; @@ -236,7 +236,12 @@ await props.load(); // Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine // endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt // es nur noch eine einmalige Warnung, kein Verhaltenswechsel. -const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS }); +// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer +// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten. +const streamer = new ChunkStreamer({ + scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS, + replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []), +}); // warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und // übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau // das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md, diff --git a/src/world/places.js b/src/world/places.js index 2fdf163..297466e 100644 --- a/src/world/places.js +++ b/src/world/places.js @@ -19,7 +19,9 @@ export const PLACE_HIDE_RADIUS = 450; * at: Welt-x und Welt-y in Metern, wie auf der Minimap — nie three-z. rotation: Grad gegen den * Uhrzeigersinn, von oben mit Norden oben (wie add_building in corrections.toml). lift: Meter auf die * Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests - * prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. + * prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces + * (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim + * Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js). */ export const PLACES = [ { @@ -32,9 +34,23 @@ export const PLACES = [ lift: -0.28, bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }, }, + { + id: "tribuene", + url: "./models/places/tribuene.glb", + // Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers); + // längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten. + at: [1409.9, 1051.1], + // Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate. + rotation: 45, + // Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts. + lift: 0, + bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }, + // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6). + replaces: [[1403.25, 1057.75]], + }, ]; -/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Kleinteile — läuft man durch. */ +/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */ export const SOLID_MATERIALS = [ // Boden "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete", @@ -49,6 +65,9 @@ export const SOLID_MATERIALS = [ "modular_electricity_poles", "barrel_03", "plastic_container", // Bäume "quiver_tree_01_trunk", "quiver_tree_02", + // Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden, + // Glas der Pressekabine, Anschlusskästen am Boden + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01", ]; const SOLID = new Set(SOLID_MATERIALS); diff --git a/src/world/streaming.js b/src/world/streaming.js index 6a7ae1b..3a80662 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -1,6 +1,7 @@ import { Group, Mesh } from "three"; import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; import { buildChunkGeometry } from "./geometry.js"; +import { pointInPolygon } from "./props.js"; const key = (i, j) => `${i}_${j}`; const DECALS = new Set(["roads", "sand", "park", "urban"]); @@ -31,9 +32,27 @@ function dist(k, position) { return Math.hypot(c.x - position.x, c.z - position.z); } +/** + * Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js): + * jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und + * Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt + * derselbe Chunk zurück. + * + * Ein Gebäude an einer Chunkgrenze schreibt die Extraktion ungeschnitten in jeden berührten Chunk. Deshalb + * wird hier jeder Punkt gegen jeden Chunk geprüft, statt ihn vorab einem Chunk zuzuordnen — sonst bliebe + * die Kopie im Nachbarchunk stehen. + */ +export function withoutReplacedBuildings(chunk, points) { + if (points.length === 0) return chunk; + const [ox, oz] = chunk.origin; + const buildings = chunk.buildings.filter((building) => + !points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly))); + return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings }; +} + export class ChunkStreamer { - constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) { - Object.assign(this, { scene, player, materials, props, baseUrl }); + constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) { + Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings }); this.active = new Map(); // key -> { group, handle } this.pending = new Set(); this.queue = []; @@ -63,7 +82,7 @@ export class ChunkStreamer { const [i, j] = k.split("_").map(Number); const group = new Group(); group.position.set(chunk.origin[0], 0, chunk.origin[1]); - const geos = buildChunkGeometry(chunk); + const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings)); const collide = new Group(); for (const [name, geo] of Object.entries(geos)) { if (!geo) continue; diff --git a/tests/places.test.js b/tests/places.test.js index a891f7f..2545ee4 100644 --- a/tests/places.test.js +++ b/tests/places.test.js @@ -61,6 +61,19 @@ describe("isSolidMaterial", () => { } }); + // Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man + // bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm). + it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "utility_box_01"]) { + expect(isSolidMaterial(name)).toBe(true); + } + for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01"]) { + expect(isSolidMaterial(name)).toBe(false); + } + }); + it("kennt jedes Material der Liste", () => { expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true); }); @@ -168,14 +181,15 @@ describe("prepareModel", () => { describe("PLACES", () => { // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds - // prüften nichts. - it("hat für jeden Ort id, url, at, rotation, lift und bounds", () => { - for (const entry of PLACES) { + // prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg. + it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => { + for (const { replaces = [], ...entry } of PLACES) { expect(entry).toEqual({ id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/), at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number), bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) }, }); + for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]); } }); }); diff --git a/tests/placesAsset.test.js b/tests/placesAsset.test.js index 8df9023..f1a82fb 100644 --- a/tests/placesAsset.test.js +++ b/tests/placesAsset.test.js @@ -5,14 +5,41 @@ import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { getBounds } from "@gltf-transform/functions"; import { MeshoptDecoder } from "meshoptimizer"; -import { PLACES, SOLID_MATERIALS } from "../src/world/places.js"; +import { isSolidMaterial, PLACES, SOLID_MATERIALS } from "../src/world/places.js"; import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; -const place = PLACES.find((entry) => entry.id === "hood-yard"); -const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); -const CONTAINERS = ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"]; +// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle +// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile. +const SOLID_BY_PLACE = { + "hood-yard": [ + "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete", + "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", + "ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB", + "rollershutter_window_01", "rollershutter_window_01_graffiti", + "chainlink_post", "chainlink_wire", + "modular_electricity_poles", "barrel_03", "plastic_container", + "quiver_tree_01_trunk", "quiver_tree_02", + ], + tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"], +}; -describe("hood-yard.glb", () => { +// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps. +const BUMP_BY_PLACE = { + "hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"], + tribuene: [], +}; + +// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. Als +// Mengen verglichen, damit ein Name, der in zwei Orten fest ist, keinen Fehlalarm auslöst. +it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => { + expect(new Set(Object.values(SOLID_BY_PLACE).flat())).toEqual(new Set(SOLID_MATERIALS)); +}); + +describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => { + const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); + // Kein "?? []": eine fehlende Zeile soll die Prüfung darunter rot machen (Bruch), nicht still + // durchlaufen. it.runIf gleich unten sichert nur seine eigene Bedingung gegen "undefined" ab. + const bump = BUMP_BY_PLACE[id]; let document; beforeAll(async () => { await MeshoptDecoder.ready; @@ -22,17 +49,16 @@ describe("hood-yard.glb", () => { document = await io.read(file); }, 30000); - // Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle hatte 653 MB. + // Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB. it("ist kleiner als 30 MB", () => { expect(statSync(file).size).toBeLessThan(30e6); }); - // Bruch: das Wellblech ist weg, die Geometrie unkomprimiert, oder GPU-Instanzen, deren Matrizen die - // Kollision des Controllers nicht liest. - it("nutzt meshopt und WebP, behält EXT_materials_bump und hat keine Instanzen", () => { + // Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest. + it("nutzt meshopt und WebP und hat keine Instanzen", () => { const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); - expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp", EXT_MATERIALS_BUMP])); + expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"])); expect(used).not.toContain("EXT_mesh_gpu_instancing"); }); @@ -43,18 +69,40 @@ describe("hood-yard.glb", () => { } }); - it("gibt allen sechs Container-Materialien ihre Bump-Textur", () => { + // Bruch: der Ort hat keine Zeile in BUMP_BY_PLACE — ohne diesen Test liefe die Bump-Prüfung darunter + // per it.runIf(bump?.length > 0) einfach übersprungen durch, statt rot zu werden. + it("hat eine Zeile in BUMP_BY_PLACE", () => { + expect(bump).toBeDefined(); + }); + + // Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der + // Container wäre flach. + it.runIf((bump ?? []).length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material])); - for (const name of CONTAINERS) { + + expect(used).toContain(EXT_MATERIALS_BUMP); + for (const name of bump) { expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy(); } }); - // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Hof wäre dort ohne Warnung - // durchlässig, weil die Kollision nach Namen auswählt. - it("enthält jedes feste Material der Kollisionsliste", () => { + // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung + // durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE. + it("enthält jedes feste Material seines Orts", () => { const names = document.getRoot().listMaterials().map((material) => material.getName()); - expect(SOLID_MATERIALS.filter((name) => !names.includes(name))).toEqual([]); + + expect(SOLID_BY_PLACE[id]).toBeDefined(); + expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]); + }); + + // Bruch: ein Material der Datei ist laut isSolidMaterial fest (weil sein Name anderswo fest ist), ohne + // in der Zeile seines Orts zu stehen — es würde hier still mitkollidieren, ungeprüft von SOLID_BY_PLACE. + it("hat kein fremdes festes Material ohne Zeile in SOLID_BY_PLACE", () => { + const names = document.getRoot().listMaterials().map((material) => material.getName()); + const ownSolid = new Set(SOLID_BY_PLACE[id]); + + expect(names.filter((name) => isSolidMaterial(name) && !ownSolid.has(name))).toEqual([]); }); // Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche. diff --git a/tests/placesPipeline.test.js b/tests/placesPipeline.test.js index 6d78011..55e7fdd 100644 --- a/tests/placesPipeline.test.js +++ b/tests/placesPipeline.test.js @@ -3,7 +3,8 @@ import { beforeAll, describe, expect, it } from "vitest"; import { Document } from "@gltf-transform/core"; import sharp from "sharp"; import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; -import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace, placeTarget } from "../tools/assets/places.mjs"; +import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget } + from "../tools/assets/places.mjs"; /** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */ function stripes(seed) { @@ -114,3 +115,25 @@ describe("placeTarget", () => { expect(() => placeTarget("Hood Yard")).toThrow(); }); }); + +describe("Materiallisten der Tribüne", () => { + // Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt + // davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px. + it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true); + for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false); + }); + + // Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper, + // Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten). + it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => { + for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) { + expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25); + } + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "Sitzschale"]) { + expect(SIMPLIFY_RATIOS.has(name)).toBe(false); + } + }); +}); diff --git a/tests/placesSite.test.js b/tests/placesSite.test.js index 3ab60ae..603a8f9 100644 --- a/tests/placesSite.test.js +++ b/tests/placesSite.test.js @@ -3,11 +3,15 @@ import { describe, expect, it } from "vitest"; import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js"; import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js"; import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js"; +import { withoutReplacedBuildings } from "../src/world/streaming.js"; // Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt. const CHUNK_LOAD_RADIUS = 400; const CHUNK_UNLOAD_RADIUS = 600; const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche +// Wie weit darf ein ersetzter Grundriss über die Außenmaße seines Orts hinausragen? Die Tribüne liegt +// heute 3,78 m vor der Rückseite ihres alten Kastens, Modell-z −18,60 gegen −14,82. +const REPLACED_OVERHANG = 5; // Meter const cache = new Map(); function chunk(i, j) { @@ -18,6 +22,16 @@ function chunk(i, j) { return cache.get(key); } +// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in +// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht. +const REPLACED = PLACES.flatMap((place) => place.replaces ?? []); +const standing = new Map(); +function standingBuildings(i, j) { + const key = `${i}_${j}`; + if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings); + return standing.get(key); +} + /** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */ function modelToWorld(place, x, z) { const { position, rotationY } = placeTransform(place); @@ -33,10 +47,10 @@ function worldToModel(place, X, Y) { return { x: dx * cos - dz * sin, z: dx * sin + dz * cos }; } -function insideBounds(place, X, Y) { +function insideBounds(place, X, Y, margin = 0) { const { x, z } = worldToModel(place, X, Y); const { minX, maxX, minZ, maxZ } = place.bounds; - return x >= minX && x <= maxX && z >= minZ && z <= maxZ; + return x >= minX - margin && x <= maxX + margin && z >= minZ - margin && z <= maxZ + margin; } describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => { @@ -51,7 +65,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", const under = keys.map((key) => key.split("_").map(Number)); // Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben - // in PLACES oder nach einem neuen Lauf der Extraktion. + // in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht. it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => { const bad = points.filter(({ X, Y }) => { const { i, j } = chunkForWorld(X, Y); @@ -59,12 +73,28 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", const u = X - c.origin[0], v = -Y - c.origin[1]; const onLand = c.land.some((rings) => pointInPolygon(u, v, rings)); const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly)); - const inBuilding = c.buildings.some((building) => pointInPolygon(u, v, building.poly)); + const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly)); return !onLand || onRoad || inBuilding; }); expect(bad.slice(0, 5)).toEqual([]); }); + // Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt, oder + // der Punkt trifft ein fremdes oder mit einem Nachbarn zusammengelegtes Gebäude. Der Punkt träfe dann + // nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse, einen außerhalb, oder einen mit + // Ecken weit jenseits der Außenmaße. + it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => { + const bad = place.replaces.filter(([X, Y]) => { + const { i, j } = chunkForWorld(X, Y); + const c = chunk(i, j); + const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly)); + if (hits.length !== 1 || !insideBounds(place, X, Y)) return true; + const corners = hits[0].poly.flat(1); + return corners.some(([u, v]) => !insideBounds(place, c.origin[0] + u, -(c.origin[1] + v), REPLACED_OVERHANG)); + }); + expect(bad).toEqual([]); + }); + // Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten; // Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks. it("enthält keine Palme und keine Laterne", () => { diff --git a/tests/streaming.test.js b/tests/streaming.test.js index f226a5c..ecde1ea 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BufferGeometry, Group, Mesh } from "three"; -import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js"; +import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; describe("wantedChunks", () => { it("lädt die Umgebung des Spielers", () => { @@ -29,6 +29,50 @@ describe("wantedChunks", () => { }); }); +describe("withoutReplacedBuildings", () => { + // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. + const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; + const chunk = { + chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], + buildings: [ + { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 + { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 + ], + }; + + // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten + // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. + it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { + const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); + + expect(result.buildings).toEqual([chunk.buildings[1]]); + expect(result.land).toBe(chunk.land); + expect(chunk.buildings).toHaveLength(2); + }); + + it("gibt ohne Treffer den Chunk selbst zurück", () => { + expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); + expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); + }); + + // Bruch: eine Zuordnung des Punkts per chunkForWorld ordnete ihn nur chunk_9_5 zu (i = 9) und ließe die + // ungeschnittene Kopie desselben Grundrisses in chunk_10_5 stehen — genau das Gebäude, das der Ort ersetzt. + it("lässt denselben, ungeschnittenen Grundriss in beiden berührten Chunks weg", () => { + const chunkA = { + chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], + buildings: [{ poly: square(245, 190), height: 18.6 }], // Welt x 1495–1505, y 1050–1060 + }; + const chunkB = { + chunk: [10, 5], origin: [1500, -1250], land: [], surfaces: [], roads: [], + buildings: [{ poly: square(-5, 190), height: 18.6 }], // dieselbe Welt-Fläche, andere u + }; + const points = [[1498, 1055]]; + + expect(withoutReplacedBuildings(chunkA, points).buildings).toEqual([]); + expect(withoutReplacedBuildings(chunkB, points).buildings).toEqual([]); + }); +}); + // Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer). // Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json // (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51). @@ -36,7 +80,7 @@ const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], // Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE). const emptyChunkCenter = { x: 125, z: -875 }; -function makeStreamer(chunkData, props, pinned) { +function makeStreamer(chunkData, props, pinned, replacedBuildings) { vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData }))); const player = { addCollider: vi.fn(() => ({ id: 42 })), @@ -47,7 +91,7 @@ function makeStreamer(chunkData, props, pinned) { collisionWorld: { get: vi.fn(() => ({ ready: false })) }, }; const scene = { add: vi.fn(), remove: vi.fn() }; - return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned }), player, scene }; + return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; } // #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um @@ -152,6 +196,31 @@ describe("ChunkStreamer", () => { expect(streamer.active.has("4_6")).toBe(true); expect(player.removeCollider).not.toHaveBeenCalled(); }); + + // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt + // Kollision mitten in dem Ort, der ihn ersetzt. + it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { + const chunk = { + chunk: [4, 6], origin: [0, -1000], + land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], + surfaces: [], roads: [], + buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], + }; + // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. + const plain = makeStreamer(chunk); + plain.streamer.update(emptyChunkCenter); + await flush(); + expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); + + // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. + const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); + streamer.update(emptyChunkCenter); + await flush(); + + const source = player.addCollider.mock.calls[0][0].shape.source; + expect(source.children).toHaveLength(1); + expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude + }); }); // Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine diff --git a/three-player-controller b/three-player-controller deleted file mode 160000 index 028c652..0000000 --- a/three-player-controller +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 028c65244979b1089f0549a07478f3889b86546c diff --git a/three-player-controller/.claude/launch.json b/three-player-controller/.claude/launch.json new file mode 100644 index 0000000..59f7b77 --- /dev/null +++ b/three-player-controller/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "three-player-controller", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "url": "http://localhost:5173" + } + ] +} diff --git a/three-player-controller/.github/workflows/deploy.yml b/three-player-controller/.github/workflows/deploy.yml new file mode 100644 index 0000000..5195e5a --- /dev/null +++ b/three-player-controller/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - master + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build examples + run: npm run build:example + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/three-player-controller/.gitignore b/three-player-controller/.gitignore new file mode 100644 index 0000000..9ffc02c --- /dev/null +++ b/three-player-controller/.gitignore @@ -0,0 +1,19 @@ +# dependencies +node_modules/ + +# build output +dist/ +docs/ + +# logs +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# editor +.vscode/ +.idea/ diff --git a/three-player-controller/CONTRIBUTING.md b/three-player-controller/CONTRIBUTING.md new file mode 100644 index 0000000..1e98e6d --- /dev/null +++ b/three-player-controller/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Thanks for your interest in [three-player-controller](https://github.com/hh-hang/three-player-controller). Bug reports, ideas, and pull requests are welcome. + +Before larger changes, please open or search an [Issue](https://github.com/hh-hang/three-player-controller/issues) first. + +## Development + +```bash +git clone https://github.com/hh-hang/three-player-controller.git +cd three-player-controller +npm install +npm run dev +``` + +Open `http://localhost:5173/three-player-controller/`. + +- Library code: `src/` +- Demos: `example/` + +Use `npm run build` to verify the library builds. + +## Pull Requests + +1. Branch from `master` and keep the PR focused +2. Make sure related demos work under `npm run dev`, and `npm run build` succeeds +3. Update `README.md` / `README_En.md` if public APIs change +4. Describe the change and how you tested it diff --git a/three-player-controller/LICENSE b/three-player-controller/LICENSE new file mode 100644 index 0000000..44d58e1 --- /dev/null +++ b/three-player-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 屈航 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/three-player-controller/README.md b/three-player-controller/README.md new file mode 100644 index 0000000..98685e8 --- /dev/null +++ b/three-player-controller/README.md @@ -0,0 +1,1001 @@ +中文 | [English](README_En.md) + +# three-player-controller + +[![NPM Package][npm]][npm-url] +[![Github][github]][github-url] +[![CesiumJS](https://img.shields.io/badge/CesiumJS-player_controller-blue)](https://github.com/hh-hang/cesium-player-controller) + +基于 three.js 的轻量级第一 / 第三人称玩家控制器。提供角色胶囊体碰撞、动画、相机避障、移动平台、动态刚体、车辆控制和 Foot IK,并使用 `three-mesh-bvh` 加速复杂场景中的碰撞查询。 + +## 功能 + +- 第一 / 第三人称切换 +- 行走、奔跑、跳跃、飞行 +- 角色胶囊体碰撞与地面检测 +- 相机避障、过肩视角、弹簧相机 +- 静态 / 运动学 / 动态碰撞体 +- 动态球体、盒体及刚体碰撞 +- BVH Worker 异步构建 +- 3D Tiles 流式碰撞体构建 +- 内置车辆物理、悬挂、转向、抓地和手刹 +- Foot IK 插件 +- 自定义动画与 Locomotion Set +- 移动端虚拟摇杆与按钮 +- 自定义键位与外部输入 + +# 示例 + +[![在线示例](https://github.com/hh-hang/three-player-controller/blob/master/example/public/img/readme/preview.png)](https://hh-hang.github.io/three-player-controller/index.html) + +- [Showcase](https://hh-hang.github.io/three-player-controller/showcase.html) +- [Foot IK](https://hh-hang.github.io/three-player-controller/footIK.html) +- [FPS Game](https://hh-hang.github.io/three-player-controller/shooting/shooting.html) +- [glTF Scene](https://hh-hang.github.io/three-player-controller/glTF.html) +- [3DTiles Scene](https://hh-hang.github.io/three-player-controller/3dtilesScene.html) +- [3DGS Scene](https://hh-hang.github.io/three-player-controller/3dgs.html) + +# 安装 + +```bash +npm install three-player-controller three-mesh-bvh +``` + +# 本地运行 + +```bash +git clone https://github.com/hh-hang/three-player-controller.git +cd three-player-controller +npm install +npm run dev +``` + +浏览器访问 `http://localhost:5173/three-player-controller/`。 + +# 快速开始 + +```ts +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { playerController } from "three-player-controller"; + +const scene = new THREE.Scene(); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 1000, +); + +const renderer = new THREE.WebGLRenderer(); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); + +// 加载人物模型 +const loader = new GLTFLoader(); +const playerGltf = await loader.loadAsync("./glb/player.glb"); + +// 创建玩家控制器 +const player = new playerController(); + +// 人物控制初始化 +await player.init({ + scene, // three.js 场景实例 + camera, // three.js 相机实例 + controls, // 外部相机控制器 + + // 人物模型、动画与移动参数 + playerModelConfig: { + model: playerGltf.scene, // 外部加载后的模型根节点 + animations: playerGltf.animations, // 动画片段 + scale: 0.001, // 模型缩放 + idleAnim: "idle", // 静止动画名 + walkAnim: "walk", // 行走动画名 + runAnim: "run", // 跑步动画名 + jumpAnim: "jump", // 跳跃动画名;或传 ["起跳", "循环", "落地"] 分三段播放 + }, + + // 人物初始出生点 + initPos: new THREE.Vector3(0, 0, 0), + + // 初始化时注册的碰撞体 + colliders: [ + { + motion: "static", // 静态碰撞体 + shape: { // 碰撞形状配置 + kind: "mesh", // 使用 Mesh 作为碰撞形状 + source: scene, // 从该对象收集几何并自动构建 BVH + }, + }, + ], +}); + +function animate() { + requestAnimationFrame(animate); + player.update(); + renderer.render(scene, camera); +} + +animate(); +``` + +> `player.update()` 内部已经接管传入的相机控制器。渲染循环中不要再次调用 `controls.update()`,否则可能与内部相机逻辑冲突。 + +### 完整参数示例 + +#### `init()` + +```ts +await player.init({ + // 必填 + scene, // three.js 场景实例 + camera, // three.js 相机实例 + controls, // 外部相机控制器,通常为 OrbitControls + + playerModelConfig: { // 人物模型、动画与移动参数 + model: playerGltf.scene, // 外部加载后的模型根节点 + animations: playerGltf.animations, // 动画片段 + scale: 0.001, // 模型缩放 + idleAnim: "idle", // 静止动画名 + walkAnim: "walk", // 行走动画名 + runAnim: "run", // 跑步动画名 + jumpAnim: "jump", // 跳跃动画名;或传 ["起跳", "循环", "落地"] 分三段播放 + + // 方向 / 飞行动画(可选,不填则复用对应默认动画) + leftWalkAnim: "leftWalk", // 左移动画,默认复用 walkAnim + rightWalkAnim: "rightWalk", // 右移动画,默认复用 walkAnim + backwardAnim: "walkBack", // 后退动画,默认复用 walkAnim + flyAnim: "fly", // 飞行动画,默认复用 idleAnim + flyIdleAnim: "flyIdle", // 飞行待机动画,默认复用 idleAnim + flyHoverForwardAnim: "flyFwd", // 飞行前进悬停动画,默认复用 flyAnim + flyHoverBackAnim: "flyBack", // 飞行后退悬停动画,默认复用 flyIdleAnim + flyHoverLeftAnim: "flyLeft", // 飞行左移悬停动画,默认复用 flyIdleAnim + flyHoverRightAnim: "flyRight", // 飞行右移悬停动画,默认复用 flyIdleAnim + flyHoverUpAnim: "flyUp", // 飞行上升悬停动画,默认复用 flyIdleAnim + flyHoverDownAnim: "flyDown", // 飞行下降悬停动画,默认复用 flyIdleAnim + drivingAnim: "driving", // 驾驶循环动画,默认复用 idleAnim + + // 物理参数(可选) + gravity: -2400, // 重力基准值,会按 scale 缩放,默认 -2400 + jumpHeight: 600, // 跳跃高度基准值,会按 scale 缩放,默认 600 + speed: 200, // 移动速度基准值,会按 scale 缩放,默认 200 + runSpeed: 600, // 跑步速度基准值,会按 scale 缩放,默认 600 + flySpeed: 2100, // 飞行速度基准值,会按 scale 缩放,默认 2100 + acceleration: 30, // XZ 加速响应速度,默认 30 + deceleration: 30, // XZ 减速响应速度,默认 30 + + // 模型参数(可选) + rotateY: 0, // 人物初始朝向(弧度),默认 0 + headBoneName: "Head", // 头部骨骼名,用于第一人称相机挂载 + firstPersonCameraOffset: [0, 40, 30], // 第一人称相机局部偏移 + capsuleRadiusRatio: 1, // 胶囊体半径倍率,默认 1 + }, + + // 场景与碰撞(可选) + initPos: new THREE.Vector3(0, 0, 0), // 初始出生点,默认 (0, 0, 0) + colliders: [ // 初始化时批量创建的碰撞体;不传则可在 init() 后调用 addCollider() + { + motion: "static", // 碰撞体运动类型:static / kinematic / dynamic + shape: { // 碰撞形状配置 + kind: "mesh", // Mesh 碰撞形状 + source: world, // 碰撞来源,可传 THREE.Object3D 或 THREE.Object3D[] + }, + useWorker: false, // 是否使用 Worker 异步构建 BVH + }, + ], + + // 相机(可选) + minCamDistance: 100, // 第三人称最小镜头距离,默认 100 + maxCamDistance: 440, // 第三人称最大镜头距离,默认 440 + camLookAtHeightRatio: 0.8, // 相机看向点高度比例,0=底部,1=顶部,默认 0.8 + thirdMouseMode: 1, // 第三人称鼠标控制模式 0-5,默认 1 + enableZoom: false, // 是否允许滚轮缩放,默认 false + enableOverShoulderView: false, // 是否启用过肩视角,默认 false + camOverShoulderOffsetRatio: 0.2, // 过肩视角横向偏移比例,默认 0.2 + isFirstPerson: false, // 初始是否进入第一人称,默认 false + enableSpringCamera: false, // 是否启用弹簧相机,默认 false + springCameraTime: 0.05, // 弹簧相机平滑时间(秒),默认 0.05 + + // 其他(可选) + mouseSensitivity: 5, // 鼠标灵敏度,默认 5 + timeScale: 1, // 时间缩放系数,<1 慢动作,>1 快进,默认 1 + + keyMap: { // 自定义键位;可传单键、数组多绑或 null 禁用 + forward: ["KeyW", "ArrowUp"], // 前进 + backward: ["KeyS", "ArrowDown"], // 后退 + left: ["KeyA", "ArrowLeft"], // 左移 + right: ["KeyD", "ArrowRight"], // 右移 + sprint: ["ShiftLeft", "ShiftRight"], // 冲刺;开车时为手刹 + jump: ["Space"], // 跳跃;开车时为刹车 + toggleView: ["KeyV"], // 切换第一 / 第三人称 + toggleFly: ["KeyF"], // 切换飞行模式 + toggleVehicle: ["KeyE"], // 上 / 下车 + }, + + isShowMobileControls: true, // 移动端是否显示虚拟控制 UI,默认 true + mobileControls: { // 移动端按钮显隐、图标与布局配置 + joystick: true, // 是否显示虚拟摇杆,默认 true + jump: { // 跳跃 / 刹车按钮;传 false 可隐藏 + icon: "/icons/custom-jump.svg", // 跳跃状态自定义图片 URL + brakeIcon: "/icons/custom-brake.svg", // 车辆模式下刹车状态自定义图片 URL + right: 24, // 距离右侧位置(px) + bottom: 32, // 距离底部位置(px) + size: 64, // 按钮直径(px),默认 56 + }, + fly: true, // 飞行按钮;true 使用默认样式,false 隐藏 + view: true, // 视角切换按钮;true 使用默认样式,false 隐藏 + vehicle: true, // 上 / 下车按钮;true 使用默认样式,false 隐藏 + }, +}); +``` + + +# 碰撞系统 + +使用统一的 `CollisionWorld` 和 `addCollider()` API。 + +碰撞体由两个核心属性描述: + +```ts +{ + motion: "static" | "kinematic" | "dynamic", + shape: { + kind: "mesh" | "box" | "sphere", + // ... + } +} +``` + +## 静态 Mesh + +适用于地形、建筑、墙体和不会移动的场景模型。 + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { + kind: "mesh", + source: world, + }, + useWorker: true, +}); +``` + +## 运动学 Mesh + +适用于电梯、移动平台、旋转平台、车辆外观或其它由业务代码控制变换的物体。 + +```ts +const platformCollider = player.addCollider({ + motion: "kinematic", + shape: { + kind: "mesh", + source: platform, + }, + follow: platform, + useWorker: true, +}); +``` + +碰撞体会在每帧跟随 `follow.matrixWorld`,运行时如果只需要调整已经烘焙的运动学碰撞几何,可以避免重建 BVH。 + +## 动态刚体 + +动态 `box` 和 `sphere` 会自动创建内置刚体,由 `DynamicBodySystem` 进行重力、阻尼、接触、冲量和休眠计算。 + +### 动态盒体 + +```ts +const box = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial(), +); +scene.add(box); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: new THREE.Vector3(0.5, 0.5, 0.5), + position: new THREE.Vector3(0, 5, 0), + }, + mesh: box, +}); +``` + +### 动态球体 + +```ts +const sphere = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshStandardMaterial(), +); +scene.add(sphere); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(0, 5, 0), + }, + mesh: sphere, +}); +``` + +动态刚体支持与静态 Mesh、运动学 Mesh、人物、车辆以及其它动态刚体发生碰撞。 + +## 移除与清理 + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: object }, +}); + +player.removeCollider(handle); + +// 只清除静态碰撞体 +player.clearColliders({ motion: "static" }); + +// 清除全部碰撞体 +player.clearColliders(); +``` + +## 碰撞分组 + +```ts +import { CollisionGroup } from "three-player-controller"; +``` + +| 分组 | 说明 | +| --- | --- | +| `CollisionGroup.DEFAULT` | 默认场景碰撞。 | +| `CollisionGroup.CHARACTER` | 玩家角色碰撞组。 | +| `CollisionGroup.VEHICLE` | 车辆碰撞组。 | +| `CollisionGroup.DEBRIS` | 动态杂物、可推动物体等碰撞组。 | +| `CollisionGroup.ALL` | 所有碰撞组的组合,表示允许与全部分组发生碰撞。 | + +默认情况下无需设置,会根据 `motion` 自动使用对应的碰撞组和掩码;也可以通过 `groups` 和 `mask` 自定义碰撞过滤: + +```ts +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(), + }, + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE, +}); +``` + +# 3D Tiles 流式碰撞 + +对于 3D Tiles,不建议把整个 Tileset 一次性合并成一个碰撞体。 + +`example/3dtilesScene.js` 使用流式策略: + +1. 监听瓦片加载、卸载和可见性变化。 +2. 只跟踪当前已加载瓦片。 +3. 只为人物附近且可见的瓦片创建碰撞体。 +4. 使用 `useWorker: true` 异步构建 BVH。 +5. 瓦片隐藏、卸载或距离过远时立即 `removeCollider()`。 +6. 使用进入 / 退出双半径,避免边界附近频繁创建和销毁。 + +核心形式: + +```ts +const handle = player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { + kind: "mesh", + source: tileScene, + }, + follow: tileScene, +}); + +// 瓦片卸载 / 不可见 / 距离过远时移除碰撞体 +player.removeCollider(handle); +``` + +示例同时演示了地球级大坐标场景中的局部坐标系和 rebase:人物距离局部原点过远时更新局部坐标系,并同步角色、相机和速度,避免大坐标浮点精度问题。 + +完整实现见 [`example/3dtilesScene.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js)。 + +# 车辆 + +```ts +const vehicleGltf = await loader.loadAsync("./glb/vehicle.glb"); // 外部加载车辆模型 + +const vehicle = await player.loadVehicleModel({ + // 必填 + model: vehicleGltf.scene, // 外部加载后的车辆模型根节点 + position: new THREE.Vector3(0, 0, 0), // 车辆初始世界坐标 + wheelsNames: [ // 车轮节点名,顺序:左前、右前、左后、右后 + "Wheel_LF", // 左前轮节点名 + "Wheel_RF", // 右前轮节点名 + "Wheel_LR", // 左后轮节点名 + "Wheel_RR", // 右后轮节点名 + ], + driverSeatPosition: new THREE.Vector3(-0.6, 0.7, 0.4), // 驾驶位胶囊中心,使用底盘局部坐标 + + // 模型 / 驾驶(可选) + scale: 0.1, // 车辆模型缩放,默认 1 + driverSeatRotation: 0, // 驾驶位相对底盘局部的水平旋转(弧度),默认 0 + modelRotation: -Math.PI / 2, // 车辆模型绕 Y 轴旋转(弧度),默认 -π/2 + followVehicleDirection: true, // 驾驶时镜头是否跟随车辆朝向,默认 true + + // 调试(可选) + debug: { // 车辆物理调试显示配置 + showPhysicsBox: false, // 是否显示底盘物理碰撞盒,默认 false + showWheelRays: false, // 是否显示车轮悬挂射线,默认 false + showWheelTravel: false, // 是否显示车轮 Y 向活动范围,默认 false + showWheelSpheres: false, // 是否显示车轮动态碰撞球,默认 false + }, + + // 底盘(可选) + chassis: { // 底盘碰撞盒与阻尼参数 + density: 1, // 底盘密度,质量根据碰撞盒体积与密度计算,默认 1 + linearDamping: 0.05, // 线阻尼,默认 0.05 + angularDamping: 0.5, // 角阻尼,默认 0.5 + clearance: 0.15, // 底盘盒底相对轮胎触地点高度,不传时自动计算 + sizeScale: { // 相对自动 AABB 的底盘碰撞盒尺寸倍率 + x: 1, // X 轴尺寸倍率,默认 1 + y: 1, // Y 轴尺寸倍率,默认 1 + z: 1, // Z 轴尺寸倍率,默认 1 + }, + }, + + // 悬挂 / 轮胎(可选) + suspension: { // 悬挂与轮胎参数 + restLength: 0.3, // 悬挂静止长度,不传时自动计算 + maxTravel: 0.35, // 悬挂最大行程,默认 0.35 + stiffness: 18, // 悬挂刚度,默认 18 + compression: 2.1, // 悬挂压缩阻尼,默认 2.1 + relaxation: 2.5, // 悬挂回弹阻尼,默认 2.5 + maxForce: 6000, // 单轮最大悬挂力,默认 6000 + frictionSlip: 8, // 轮胎纵向抓地,默认 8 + sideFrictionStiffness: 1, // 轮胎侧向摩擦刚度,默认 1 + rollInfluence: 0.12, // 侧向力对车身侧倾的影响,默认 0.12 + }, + + // 转向(可选) + steering: { // 转向参数 + maxSteerAngle: Math.PI / 5, // 最大转向角(弧度),默认 π/5 + steerTime: 0.45, // 从中位打到满舵所需时间(秒),默认 0.45 + steerReturnTimeSlow: 0.55, // 低速回正时间(秒),默认 0.55 + steerReturnTimeFast: 0.4, // 高速回正时间(秒),默认 0.4 + highSpeedSteerScale: 0.3, // 最高车速时最大转向比例,默认 0.3 + }, + + // 抓地 / 手刹(可选) + grip: { // 过弯抓地与手刹参数 + maxG: 1.2, // 最大侧向加速度(g),默认 1.2 + sideFrictionIdle: 1, // 直线行驶时侧向摩擦,默认 1 + sideFrictionFrontMin: 0.55, // 高负载下前轮侧向摩擦下限,默认 0.55 + sideFrictionRearMin: 0.45, // 高负载下后轮侧向摩擦下限,默认 0.45 + handbrakeRearFriction: 0.35, // 手刹时后轮侧向摩擦,默认 0.35 + handbrakeRearDriveScale: 0.65, // 手刹时后轮驱动力比例,默认 0.65 + handbrakeReleaseTime: 0.15, // 松开手刹后的恢复时间(秒),默认 0.15 + wheelbaseRatio: 0.55, // 轴距相对车长比例,默认 0.55 + }, + + // 动力(可选) + power: { // 车辆动力参数 + maxSpeed: 100, // 最高速度基准值(km/h),会按 scale 缩放,默认 100 + acceleration: 5, // 加速度基准值(m/s²),会按 scale 缩放,默认 5 + deceleration: 5, // 制动减速度基准值(m/s²),会按 scale 缩放,默认 5 + }, +}); +``` + +运行时: + +```ts +player.resetVehicle(); // 翻正当前驾驶车辆,并清零线速度和角速度 +player.setVehicleScale(vehicle, 0.12); // 动态修改车辆整体缩放 +player.setVehicleClearance(vehicle, 0.15); // 调整底盘碰撞盒离地间隙 +player.setVehicleChassisSizeScale(vehicle, { // 调整底盘碰撞盒相对自动 AABB 的尺寸倍率 + x: 1, // X 轴尺寸倍率 + y: 0.9, // Y 轴尺寸倍率 + z: 1, // Z 轴尺寸倍率 +}); +player.setVehiclePhysicsDebug(true); // 显示 / 隐藏车辆底盘物理碰撞盒 +``` + +# Foot IK 插件 + +```ts +import { FootIK } from "three-player-controller/foot-ik"; + +const footIK = new FootIK({ + // 手动指定人物骨骼映射;不传时会尝试自动匹配常见骨骼名 + skeleton: { + hips: "mixamorigHips", // 髋部 / 骨盆骨骼名 + + // 左右腿骨骼配置 + legs: { + left: { + upper: "mixamorigLeftUpLeg", // 左大腿骨骼 + lower: "mixamorigLeftLeg", // 左小腿骨骼 + foot: "mixamorigLeftFoot", // 左脚骨骼 + toe: "mixamorigLeftToeBase", // 左脚趾骨骼 + }, + right: { + upper: "mixamorigRightUpLeg", // 右大腿骨骼 + lower: "mixamorigRightLeg", // 右小腿骨骼 + foot: "mixamorigRightFoot", // 右脚骨骼 + toe: "mixamorigRightToeBase", // 右脚趾骨骼 + }, + }, + }, + + soleSkinThickness: 3, // 脚骨到鞋底的蒙皮厚度补偿 +}); + +player.use(footIK); // 将 Foot IK 插件注册到玩家控制器 +``` + +运行时: + +```ts +footIK.setEnabled(false); // 启用 / 禁用 Foot IK +footIK.setDebugEnabled(true); // 显示 / 隐藏 Foot IK 调试辅助 +player.unuse(footIK); // 从玩家控制器中卸载 Foot IK 插件 +footIK.dispose(); // 释放 Foot IK 创建的调试对象和相关资源 +``` + +`player.destroy()` 会自动销毁已注册插件。 + +完整示例见 [`example/footIK.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js)。 + +# API + +## 生命周期与核心 + +| 方法 | 说明 | +| --- | --- | +| `init(opts, callback?)` | 初始化控制器。 | +| `update(dt?)` | 每帧更新人物、碰撞、动态刚体、车辆、相机和动画。 | +| `destroy()` | 销毁控制器并释放资源。 | +| `reset(pos?)` | 将角色重置到指定位置或初始位置。 | +| `switchPlayerModel(options)` | 使用 `PlayerModelOptions` 运行时切换角色模型。 | +| `use(plugin)` | 注册插件。 | +| `unuse(plugin)` | 卸载插件。 | +| `getPlugins()` | 获取当前插件列表副本。 | + +## 碰撞 + +| 方法 | 说明 | +| --- | --- | +| `addCollider(desc)` | 添加静态 / 运动学 / 动态碰撞体,返回 `ColliderHandle`。 | +| `removeCollider(handle)` | 按句柄或 id 移除碰撞体。 | +| `clearColliders(filter?)` | 清除碰撞体,可按 `motion` 过滤。 | +| `getColliderMeshes(options?)` | 获取当前人物查询使用的 Mesh 碰撞体。 | +| `isStaticColliderUsable()` | 是否存在已经完成 BVH 构建的静态 Mesh。 | +| `getActiveKinematicCollider()` | 获取当前角色站立的运动学碰撞体。 | +| `getActiveDynamicBody()` | 获取当前角色站立的动态刚体。 | +| `getDynamicBodies()` | 获取全部动态刚体。 | +| `removeDynamicBody(body)` | 移除动态刚体。 | +| `clearDynamicBodies()` | 清除全部动态刚体。 | +| `raycastDynamicGround(...)` | 向下查询动态刚体表面。 | + +## 状态 + +| 方法 | 返回内容 | +| --- | --- | +| `getPosition()` | 当前角色位置。 | +| `getVelocity()` | 当前角色速度。 | +| `getIsFirstPerson()` | 是否第一人称。 | +| `getIsFlying()` | 是否飞行。 | +| `getIsOnGround()` | 是否在地面。 | +| `getGroundSupport()` | 当前最终地面支撑点和世界空间法线。 | +| `getCurrentDelta()` | 当前帧实际使用的时间步长。 | +| `getControllerMode()` | `0` 人物,`1` 车辆。 | +| `getPlayerModel()` | 当前人物模型。 | +| `getPlayerCapsule()` | 玩家胶囊体。 | +| `getActiveVehicle()` | 当前驾驶车辆。 | +| `getAllVehicles()` | 所有已加载车辆。 | +| `getCurrentPlayerAnimationName()` | 当前动画片段名。 | +| `getCurrentLocomotionSet()` | 当前 Locomotion Set。 | +| `getCenterScreenRaycastHit()` | 屏幕中心射线结果。 | + +## 运行时控制 + +| 方法 | 说明 | +| --- | --- | +| `setInput(input)` | 注入外部输入。 | +| `setKeyMap(map?)` | 自定义或恢复默认键位。 | +| `setMouseSensitivity(v)` | 设置鼠标灵敏度。 | +| `setPlayerScale(v)` | 动态修改人物缩放。 | +| `setPlayerSpeed(v)` | 设置行走速度。 | +| `setPlayerRunSpeed(v)` | 设置跑步速度。 | +| `setPlayerFlySpeed(v)` | 设置飞行速度。 | +| `setJumpHeight(v)` | 设置跳跃高度。 | +| `setGravity(v)` | 设置重力。 | +| `setMinCamDistance(v)` | 设置第三人称最小镜头距离。 | +| `setMaxCamDistance(v)` | 设置第三人称最大镜头距离。 | +| `setCamLookAtHeightRatio(v)` | 设置相机看向点高度比例。 | +| `setCamOverShoulderOffsetRatio(v)` | 设置过肩横向偏移比例。 | +| `setThirdMouseMode(v)` | 设置第三人称鼠标模式。 | +| `setEnableZoom(v)` | 开关滚轮缩放。 | +| `setOverShoulderView(v)` | 开关过肩视角。 | +| `setEnableToward(v)` | 开关鼠标驱动朝向。 | +| `setSkipCapsuleCollision(v)` | 临时跳过人物胶囊碰撞。 | +| `changeView()` | 切换第一 / 第三人称。 | +| `setFirstPersonCamera(v?)` | 直接进入第一人称。 | + +## 调试 + +```ts +player.setColliderDebug(true); // 显示 / 隐藏静态与运动学 Mesh 碰撞线框 +player.setPlayerCapsuleDebug(true); // 显示 / 隐藏玩家胶囊体线框 +player.setDynamicBodyDebug(true); // 显示 / 隐藏动态刚体碰撞线框 +player.setVehiclePhysicsDebug(true); // 显示 / 隐藏车辆底盘物理碰撞盒 +``` + +## 动画 + +| 方法 | 说明 | +| --- | --- | +| `playPlayerAnimationByName(name, fade?)` | 按动画片段名直接播放角色动画。 | +| `registerAnimation(key, clipName, opts?)` | 注册自定义动画片段。 | +| `playAnimation(key, opts?)` | 播放已注册的自定义动画。 | +| `registerLocomotionSet(setName, map)` | 注册一套移动动画集合,用于替换内置移动动画。 | +| `switchLocomotionSet(setName, fade?)` | 切换到指定的移动动画集合。 | + +### `registerAnimation` + +```ts +player.registerAnimation(key, clipName, { + loop?: boolean, // 是否循环播放,默认 true + timeScale?: number, // 动画播放放缩,默认 1 + duration?: number, // 动画播放时长,默认 0 + clampWhenFinished?: boolean, // 是否在播放结束后将动画时间重置为 0,默认 false + onFinished?: () => void, // 动画播放结束后触发 +}); +``` + +`duration` 与 `timeScale` 同时设置时,`duration` 优先生效。 + +### `playAnimation` + +```ts +player.playAnimation(key, { + fade?: number, // 过渡时长(秒),默认 0.18 + force?: boolean, // 为 true 时强制从头重播,即使当前已在播放该动画 + returnToPrev?: boolean, // 仅对 LoopOnce 动画生效,播放结束后自动恢复上一个动画状态 +}); +``` + +### `registerLocomotionSet` + +支持的 key:`idle` | `walking` | `walking_backward` | `running` | `jumping` | `flyidle` | `flying`,填写的 key 会替换对应内置动画,未填的保持原有动画。 + +```ts +player.registerLocomotionSet("combat", { + idle: "CombatIdle", + walking: "CombatWalk", + walking_backward: "CombatBack", + running: "CombatRun", + jumping: "CombatJump", + flyidle: "CombatFlyIdle", + flying: "CombatFly", +}); +``` + +# 默认键位 + +| 动作 | 默认键 | 功能 | +| --- | --- | --- | +| 前进 | `W` / `ArrowUp` | 前进 | +| 后退 | `S` / `ArrowDown` | 后退 | +| 左移 | `A` / `ArrowLeft` | 左移 | +| 右移 | `D` / `ArrowRight` | 右移 | +| 冲刺 | `Shift` | 人物冲刺;车辆手刹 | +| 跳跃 | `Space` | 跳跃;飞行上升;车辆刹车 | +| 切换视角 | `V` | 第一 / 第三人称 | +| 飞行 | `F` | 开关飞行 | +| 上 / 下车 | `E` | 车辆交互 | +| 视角 | 鼠标 | 控制视角 | + +# 自定义键位 + +通过 `keyMap` 可以把上表的任意动作改绑到其它按键,或禁用某个动作。键名使用 [`KeyboardEvent.code`](https://developer.mozilla.org/zh-CN/docs/Web/API/KeyboardEvent/code)(如 `"KeyE"`、`"ArrowUp"`、`"Space"`,注意是 `"KeyE"` 而非 `"e"`)。 + +每个动作三种取值: + +- **不填** → 使用默认键 +- **字符串 / 字符串数组** → 替换为指定按键(数组可绑定多个键) +- **`null`** → 禁用该动作(任何键都不会触发) + +初始化时配置: + +```ts +await player.init({ + // ... + keyMap: { + forward: "KeyE", // 改为按 E 前进(替换默认 W / ↑) + jump: null, // 禁用跳跃 + left: ["KeyA", "KeyJ"], // 同时绑定 A 和 J + // 其余动作未填,保持默认键 + }, +}); +``` + +运行时切换键位方案: + +```ts +player.setKeyMap({ forward: "KeyI", backward: "KeyK" }); // 应用新键位 +player.setKeyMap(); // 恢复全部默认 +``` + +# 外部输入 + +```ts +player.setInput({ + moveX: number, // 水平移动轴,范围 -1~1,正数向右 + moveY: number, // 纵向移动轴,范围 -1~1,正数向前 + lookDeltaX: number, // 视角水平增量,通常来自 mousemove 的 movementX + lookDeltaY: number, // 视角垂直增量,通常来自 mousemove 的 movementY + jump: boolean, // 跳跃,持续状态(true=按下,false=松开);飞行时控制上升;开车时为刹车 + shift: boolean, // 人物冲刺,开车时为手刹,持续状态(true=按下,false=松开) + toggleView: boolean, // 触发式,传 true 切换第一/第三人称视角 + toggleFly: boolean, // 触发式,传 true 切换飞行模式 + toggleVehicle: boolean, // 触发式,传 true 上车 / 下车 +}); +``` + +# 事件 + +```ts +player.onAnimationChange = (name, action) => {}; // 角色当前动画切换时触发 +player.onBeforeViewChange = (isFirstPerson) => {}; // 第一 / 第三人称切换前触发 +player.onViewChange = (isFirstPerson) => {}; // 第一 / 第三人称切换后触发 +player.onGroundChange = (onGround) => {}; // 落地状态变化时触发 +player.onVehicleEnter = (vehicle) => {}; // 上车完成后触发 +player.onVehicleExit = (vehicle) => {}; // 下车完成后触发 +player.onTowardChange = (dx, dy, speed) => {}; // 朝向 / 视角输入更新时触发 +``` + +# 主要配置 + +## `PlayerControllerOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `scene` | `THREE.Scene` | 是 | — | three.js 场景实例。 | +| `camera` | `THREE.PerspectiveCamera` | 是 | — | three.js 相机实例。 | +| `controls` | `any` | 是 | — | 外部相机控制器,通常为 `OrbitControls`。 | +| `playerModelConfig` | `PlayerModelOptions` | 是 | — | 角色模型与参数配置。 | +| `initPos` | `THREE.Vector3` | 否 | `(0, 0, 0)` | 初始出生点。 | +| `colliders` | `ColliderDesc[]` | 否 | — | 初始化时批量注册碰撞体。 | +| `mouseSensitivity` | `number` | 否 | `5` | 鼠标灵敏度。 | +| `minCamDistance` | `number` | 否 | `100` | 第三人称最小镜头距离。 | +| `maxCamDistance` | `number` | 否 | `440` | 第三人称最大镜头距离。 | +| `isShowMobileControls` | `boolean` | 否 | `true` | 是否在移动端显示虚拟控制 UI。 | +| `mobileControls` | `MobileControlsOptions` | 否 | 全部显示 | 移动端按钮显隐、图标与布局配置。 | +| `thirdMouseMode` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | 否 | `1` | 第三人称视角下的鼠标控制模式(0:隐藏鼠标,控制朝向及视角;1:隐藏鼠标,仅控制视角;2:显示鼠标,拖拽控制朝向及视角;3:显示鼠标,拖拽仅控制视角;4:显示鼠标,拖拽控制视角且人物朝向跟随相机水平方向;5:隐藏鼠标,控制视角且人物朝向跟随相机水平方向) | +| `enableZoom` | `boolean` | 否 | `false` | 是否允许滚轮缩放。 | +| `enableOverShoulderView` | `boolean` | 否 | `false` | 是否启用过肩视角。 | +| `camOverShoulderOffsetRatio` | `number` | 否 | `0.2` | 第三人称相机过肩视角横向偏移比例。 | +| `isFirstPerson` | `boolean` | 否 | `false` | 初始化时是否直接进入第一人称。 | +| `enableSpringCamera` | `boolean` | 否 | `false` | 是否启用弹簧相机(目标点以弹簧阻尼跟随角色)。 | +| `springCameraTime` | `number` | 否 | `0.05` | 弹簧平滑时间(秒),越小跟随越紧。 | +| `camLookAtHeightRatio` | `number` | 否 | `0.8` | 第三人称相机看向点高度比例(0=胶囊底部,1=顶部)。 | +| `timeScale` | `number` | 否 | `1` | 时间缩放系数,<1 慢动作,>1 快进。 | +| `keyMap` | `KeyMap` | 否 | 默认键位 | 自定义键位映射,详见[自定义键位](#自定义键位)。 | + +## `PlayerModelOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `model` | `THREE.Object3D` | 二选一 | — | 已加载的人物模型根节点。与 `url` 互斥。 | +| `animations` | `THREE.AnimationClip[]` | 与 `model` 同用 | — | 人物动画片段。与 `model` 一起传入。 | +| `url` | `string` | 二选一 | — | 人物模型路径(GLB/GLTF)。 | +| `scale` | `number` | 是 | — | 人物模型缩放。 | +| `idleAnim` | `string` | 是 | — | Idle 动画名。 | +| `walkAnim` | `string` | 是 | — | Walk 动画名。 | +| `runAnim` | `string` | 是 | — | Run 动画名。 | +| `jumpAnim` | `string \| [string, string, string]` | 是 | — | 跳跃动画名。传字符串播放单一动画;传三元素数组 `[起跳, 循环, 落地]` 分三阶段播放,落地后自动衔接移动动画。 | +| `leftWalkAnim` | `string` | 否 | `walkAnim` | 左移动画名,不填则复用 `walkAnim`。 | +| `rightWalkAnim` | `string` | 否 | `walkAnim` | 右移动画名,不填则复用 `walkAnim`。 | +| `backwardAnim` | `string` | 否 | `walkAnim` | 后退动画名,不填则复用 `walkAnim`。 | +| `flyAnim` | `string` | 否 | `idleAnim` | 飞行动画名,不填则复用 `idleAnim`。 | +| `flyIdleAnim` | `string` | 否 | `idleAnim` | 飞行待机动画名,不填则复用 `idleAnim`。 | +| `flyHoverForwardAnim` | `string` | 否 | `flyAnim` | 飞行前进时的悬停动画名,不填则复用 `flyAnim`。 | +| `flyHoverBackAnim` | `string` | 否 | `flyIdleAnim` | 飞行后退时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverLeftAnim` | `string` | 否 | `flyIdleAnim` | 飞行左移时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverRightAnim` | `string` | 否 | `flyIdleAnim` | 飞行右移时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverUpAnim` | `string` | 否 | `flyIdleAnim` | 飞行上升时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverDownAnim` | `string` | 否 | `flyIdleAnim` | 飞行下降时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `drivingAnim` | `string` | 否 | `idleAnim` | 驾驶循环动画名,不填则复用 `idleAnim`。 | +| `gravity` | `number` | 否 | `-2400` | 重力基准值(按 `scale` 缩放)。 | +| `jumpHeight` | `number` | 否 | `600` | 跳跃高度基准值(按 `scale` 缩放)。 | +| `speed` | `number` | 否 | `200` | 移动速度基准值(按 `scale` 缩放)。 | +| `runSpeed` | `number` | 否 | `600` | 跑步速度基准值(按 `scale` 缩放)。 | +| `flySpeed` | `number` | 否 | `2100` | 飞行速度基准值(按 `scale` 缩放)。 | +| `rotateY` | `number` | 否 | `0` | 人物初始朝向(弧度),用于改变模型初始化面朝方向。 | +| `headBoneName` | `string` | 否 | — | 头部骨骼或节点名称,用于第一人称相机挂载。 | +| `firstPersonCameraOffset` | `[number, number, number]` | 否 | 有内置默认值 | 第一人称相机局部偏移;有 `headBoneName` 则相对头骨骼,否则相对胶囊体。 | +| `capsuleRadiusRatio` | `number` | 否 | `1` | 胶囊体半径倍率,用于微调碰撞宽度。 | +| `acceleration` | `number` | 否 | `30` | XZ 方向加速响应速度,值越大加速越快。 | +| `deceleration` | `number` | 否 | `30` | XZ 方向减速响应速度,值越大停止越快。 | + +### `MobileControlsOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `joystick` | `boolean` | 否 | `true` | 是否显示支持 360° 连续方向的摇杆。 | +| `jump` | `boolean \| JumpButtonOptions` | 否 | `true` | 跳跃/刹车按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `fly` | `boolean \| MobileButtonOptions` | 否 | `true` | 飞行按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `view` | `boolean \| MobileButtonOptions` | 否 | `true` | 视角切换按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `vehicle` | `boolean \| MobileButtonOptions` | 否 | `true` | 上下车按钮;`false` 隐藏,对象可自定义图片和布局。 | + +### `MobileButtonOptions` / `JumpButtonOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `left` / `right` | `number` | 否 | 内置位置 | 按钮距离左侧或右侧的位置(px)。设置 `left` 且未设置 `right` 时会清除默认右侧定位。 | +| `top` / `bottom` | `number` | 否 | 内置位置 | 按钮距离顶部或底部的位置(px)。设置 `top` 且未设置 `bottom` 时会清除默认底部定位。 | +| `size` | `number` | 否 | `56` | 圆形按钮直径(px)。 | +| `icon` | `string` | 否 | 英文标签 | 自定义图片 URL。 | +| `brakeIcon` | `string` | 否 | `BRAKE` 标签 | 仅用于 `JumpButtonOptions`,设置车辆模式下的刹车图片 URL。 | + + +## `ColliderDesc` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `motion` | `"static" \| "kinematic" \| "dynamic"` | — | 碰撞体运动类型。 | +| `shape` | `ColliderShape` | — | 碰撞形状配置,支持 `mesh`、`box`、`sphere`。 | +| `groups` | `number` | 根据 `motion` 自动设置 | 当前碰撞体所属碰撞组。 | +| `mask` | `number` | 根据 `motion` 自动设置 | 当前碰撞体允许发生碰撞的分组掩码。 | +| `useWorker` | `boolean` | `false` | `mesh + source` 时是否使用 Worker 异步构建 BVH。 | +| `userData` | `unknown` | — | 自定义业务数据。 | +| `follow` | `THREE.Object3D` | — | `kinematic` 碰撞体跟随的场景对象。 | +| `density` | `number` | `1` | `dynamic` 刚体密度,用于计算质量。 | +| `restitution` | `number` | `0.2` | `dynamic` 刚体弹性系数。 | +| `friction` | `number` | `0.6` | `dynamic` 刚体摩擦系数。 | +| `linearDamping` | `number` | `0.4` | `dynamic` 刚体线阻尼。 | +| `angularDamping` | `number` | `0.6` | `dynamic` 刚体角阻尼。 | +| `gravity` | `number` | 内部默认值 | `dynamic` 刚体重力。 | +| `velocity` | `THREE.Vector3` | `(0,0,0)` | `dynamic` 刚体初始线速度。 | +| `angularVelocity` | `THREE.Vector3` | `(0,0,0)` | `dynamic` 刚体初始角速度。 | +| `mesh` | `THREE.Mesh` | — | `dynamic box / sphere` 创建内置刚体时必须提供的可视化 Mesh。 | +| `simulate` | `boolean` | `true` | 是否由内置动态刚体系统进行模拟。 | + +### `ColliderShape` + +#### Mesh + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"mesh"` | Mesh 碰撞形状。 | +| `source` | `THREE.Object3D \| THREE.Object3D[]` | 从对象中收集几何、合并碰撞网格并构建 BVH。 | +| `mesh` | `THREE.Mesh` | 直接注册已经准备好的碰撞 Mesh。 | + +#### Box + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"box"` | 盒体碰撞形状。 | +| `halfExtents` | `THREE.Vector3` | 盒体半尺寸,即完整尺寸的一半。 | +| `position` | `THREE.Vector3` | 初始世界坐标,可选。 | +| `quaternion` | `THREE.Quaternion` | 初始世界旋转,可选。 | + +#### Sphere + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"sphere"` | 球体碰撞形状。 | +| `radius` | `number` | 球体半径。 | +| `position` | `THREE.Vector3` | 初始世界坐标,可选。 | + +## `VehicleOptions` + +### 基础参数 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `model` | `THREE.Object3D` | — | 外部加载后的车辆模型根节点。 | +| `position` | `THREE.Vector3` | — | 车辆初始世界坐标。 | +| `wheelsNames` | `[string, string, string, string]` | — | 车轮节点名数组,顺序为左前、右前、左后、右后。 | +| `driverSeatPosition` | `THREE.Vector3` | — | 驾驶位胶囊中心,车辆底盘局部坐标。 | +| `scale` | `number` | `1` | 车辆模型缩放。 | +| `driverSeatRotation` | `number` | `0` | 驾驶位相对车辆底盘局部的水平旋转(弧度)。 | +| `modelRotation` | `number` | `-Math.PI / 2` | 车辆模型绕 Y 轴的初始旋转。 | +| `followVehicleDirection` | `boolean` | `true` | 驾驶时镜头是否跟随车辆朝向。 | +| `debug` | `VehicleDebugOptions` | — | 车辆物理调试显示配置。 | +| `chassis` | `VehicleChassisOptions` | — | 底盘碰撞盒与阻尼配置。 | +| `suspension` | `VehicleSuspensionOptions` | — | 悬挂与轮胎配置。 | +| `steering` | `VehicleSteeringOptions` | — | 转向配置。 | +| `grip` | `VehicleGripOptions` | — | 抓地和手刹配置。 | +| `power` | `VehiclePowerOptions` | — | 动力配置。 | + +### `debug` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `showPhysicsBox` | `boolean` | `false` | 显示底盘物理碰撞盒。 | +| `showWheelRays` | `boolean` | `false` | 显示车轮悬挂射线。 | +| `showWheelTravel` | `boolean` | `false` | 显示车轮 Y 向活动范围。 | +| `showWheelSpheres` | `boolean` | `false` | 显示车轮动态碰撞球。 | + +### `chassis` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `density` | `number` | `1` | 底盘密度,质量根据碰撞盒体积与密度计算。 | +| `linearDamping` | `number` | `0.05` | 底盘线阻尼。 | +| `angularDamping` | `number` | `0.5` | 底盘角阻尼。 | +| `clearance` | `number` | 自动计算 | 底盘盒底相对轮胎触地点高度。 | +| `sizeScale.x` | `number` | `1` | 底盘碰撞盒 X 轴尺寸倍率。 | +| `sizeScale.y` | `number` | `1` | 底盘碰撞盒 Y 轴尺寸倍率。 | +| `sizeScale.z` | `number` | `1` | 底盘碰撞盒 Z 轴尺寸倍率。 | + +### `suspension` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `restLength` | `number` | 自动计算 | 悬挂静止长度。 | +| `maxTravel` | `number` | `0.35` | 悬挂最大行程。 | +| `stiffness` | `number` | `18` | 悬挂刚度。 | +| `compression` | `number` | `2.1` | 悬挂压缩阻尼。 | +| `relaxation` | `number` | `2.5` | 悬挂回弹阻尼。 | +| `maxForce` | `number` | `6000` | 单轮最大悬挂力。 | +| `frictionSlip` | `number` | `8` | 轮胎纵向抓地。 | +| `sideFrictionStiffness` | `number` | `1` | 轮胎侧向摩擦刚度。 | +| `rollInfluence` | `number` | `0.12` | 侧向力对车身侧倾的影响。 | + +### `steering` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxSteerAngle` | `number` | `Math.PI / 5` | 最大转向角,单位为弧度。 | +| `steerTime` | `number` | `0.45` | 从中位打到满舵所需时间,单位为秒。 | +| `steerReturnTimeSlow` | `number` | `0.55` | 低速回正时间,单位为秒。 | +| `steerReturnTimeFast` | `number` | `0.4` | 高速回正时间,单位为秒。 | +| `highSpeedSteerScale` | `number` | `0.3` | 最高车速时的最大转向比例。 | + +### `grip` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxG` | `number` | `1.2` | 最大侧向加速度,单位为 g。 | +| `sideFrictionIdle` | `number` | `1` | 直线行驶时侧向摩擦。 | +| `sideFrictionFrontMin` | `number` | `0.55` | 高负载下前轮侧向摩擦下限。 | +| `sideFrictionRearMin` | `number` | `0.45` | 高负载下后轮侧向摩擦下限。 | +| `handbrakeRearFriction` | `number` | `0.35` | 手刹时后轮侧向摩擦。 | +| `handbrakeRearDriveScale` | `number` | `0.65` | 手刹时后轮驱动力比例。 | +| `handbrakeReleaseTime` | `number` | `0.15` | 松开手刹后的恢复时间,单位为秒。 | +| `wheelbaseRatio` | `number` | `0.55` | 轴距相对车长的比例。 | + +### `power` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxSpeed` | `number` | `100` | 最高速度基准值(km/h,按 `scale` 缩放)。 | +| `acceleration` | `number` | `5` | 加速度基准值(m/s²,按 `scale` 缩放)。 | +| `deceleration` | `number` | `5` | 制动减速度基准值(m/s²,按 `scale` 缩放)。 | + +# 反馈 + +如果你有任何问题或建议,欢迎提交 [issue](https://github.com/hh-hang/three-player-controller/issues)。 + +# 致谢 + +- [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh) +- [three.js](https://github.com/mrdoob/three.js) + +[npm]: https://img.shields.io/npm/v/three-player-controller +[npm-url]: https://www.npmjs.com/package/three-player-controller +[github]: https://img.shields.io/badge/-hh--hang-181717?style=flat&logo=github&logoColor=white&labelColor=888 +[github-url]: https://github.com/hh-hang \ No newline at end of file diff --git a/three-player-controller/README_En.md b/three-player-controller/README_En.md new file mode 100644 index 0000000..daa4864 --- /dev/null +++ b/three-player-controller/README_En.md @@ -0,0 +1,1001 @@ +[中文](README.md) | English + +# three-player-controller + +[![NPM Package][npm]][npm-url] +[![Github][github]][github-url] +[![CesiumJS](https://img.shields.io/badge/CesiumJS-player_controller-blue)](https://github.com/hh-hang/cesium-player-controller) + +A lightweight first-person / third-person player controller for three.js. It provides character capsule collision, animation, camera obstacle avoidance, moving platforms, dynamic rigid bodies, vehicle control, and Foot IK, with `three-mesh-bvh` accelerating collision queries in complex scenes. + +## Features + +- First-person / third-person switching +- Walking, running, jumping, and flying +- Character capsule collision and ground detection +- Camera obstacle avoidance, over-shoulder view, and spring camera +- Static / kinematic / dynamic colliders +- Dynamic spheres, boxes, and rigid-body collision +- Asynchronous BVH construction with Worker +- Streaming collider construction for 3D Tiles +- Built-in vehicle physics, suspension, steering, grip, and handbrake +- Foot IK plugin +- Custom animations and Locomotion Sets +- Mobile virtual joystick and buttons +- Custom key bindings and external input + +# Examples + +[![Online Demo](https://github.com/hh-hang/three-player-controller/blob/master/example/public/img/readme/preview.png)](https://hh-hang.github.io/three-player-controller/index.html) + +- [Showcase](https://hh-hang.github.io/three-player-controller/showcase.html) +- [Foot IK](https://hh-hang.github.io/three-player-controller/footIK.html) +- [FPS Game](https://hh-hang.github.io/three-player-controller/shooting/shooting.html) +- [glTF Scene](https://hh-hang.github.io/three-player-controller/glTF.html) +- [3DTiles Scene](https://hh-hang.github.io/three-player-controller/3dtilesScene.html) +- [3DGS Scene](https://hh-hang.github.io/three-player-controller/3dgs.html) + +# Installation + +```bash +npm install three-player-controller three-mesh-bvh +``` + +# Run Locally + +```bash +git clone https://github.com/hh-hang/three-player-controller.git +cd three-player-controller +npm install +npm run dev +``` + +Open `http://localhost:5173/three-player-controller/` in your browser. + +# Quick Start + +```ts +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { playerController } from "three-player-controller"; + +const scene = new THREE.Scene(); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 1000, +); + +const renderer = new THREE.WebGLRenderer(); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); + +// Load the player model +const loader = new GLTFLoader(); +const playerGltf = await loader.loadAsync("./glb/player.glb"); + +// Create the player controller +const player = new playerController(); + +// Initialize player control +await player.init({ + scene, // three.js scene instance + camera, // three.js camera instance + controls, // external camera controller + + // Player model, animation, and movement settings + playerModelConfig: { + model: playerGltf.scene, // root node of the externally loaded model + animations: playerGltf.animations, // animation clips + scale: 0.001, // model scale + idleAnim: "idle", // idle animation name + walkAnim: "walk", // walk animation name + runAnim: "run", // run animation name + jumpAnim: "jump", // jump animation name; or ["start", "loop", "land"] for three-stage playback + }, + + // Initial player spawn point + initPos: new THREE.Vector3(0, 0, 0), + + // Colliders registered during initialization + colliders: [ + { + motion: "static", // static collider + shape: { // collision shape settings + kind: "mesh", // use a Mesh collision shape + source: scene, // collect geometry from this object and build the BVH automatically + }, + }, + ], +}); + +function animate() { + requestAnimationFrame(animate); + player.update(); + renderer.render(scene, camera); +} + +animate(); +``` + +> `player.update()` already drives the camera controller passed to the player. Do not call `controls.update()` again in the render loop, or it may conflict with the internal camera logic. + +### Complete Parameter Example + +#### `init()` + +```ts +await player.init({ + // Required + scene, // three.js scene instance + camera, // three.js camera instance + controls, // external camera controller, usually OrbitControls + + playerModelConfig: { // player model, animation, and movement settings + model: playerGltf.scene, // root node of the externally loaded model + animations: playerGltf.animations, // animation clips + scale: 0.001, // model scale + idleAnim: "idle", // idle animation name + walkAnim: "walk", // walk animation name + runAnim: "run", // run animation name + jumpAnim: "jump", // jump animation name; or ["start", "loop", "land"] for three-stage playback + + // Directional / flight animations (optional; corresponding defaults are reused when omitted) + leftWalkAnim: "leftWalk", // left-strafe animation; defaults to walkAnim + rightWalkAnim: "rightWalk", // right-strafe animation; defaults to walkAnim + backwardAnim: "walkBack", // backward animation; defaults to walkAnim + flyAnim: "fly", // flight animation; defaults to idleAnim + flyIdleAnim: "flyIdle", // fly-idle animation; defaults to idleAnim + flyHoverForwardAnim: "flyFwd", // forward hover animation; defaults to flyAnim + flyHoverBackAnim: "flyBack", // backward hover animation; defaults to flyIdleAnim + flyHoverLeftAnim: "flyLeft", // left hover animation; defaults to flyIdleAnim + flyHoverRightAnim: "flyRight", // right hover animation; defaults to flyIdleAnim + flyHoverUpAnim: "flyUp", // upward hover animation; defaults to flyIdleAnim + flyHoverDownAnim: "flyDown", // downward hover animation; defaults to flyIdleAnim + drivingAnim: "driving", // driving loop animation; defaults to idleAnim + + // Physics settings (optional) + gravity: -2400, // base gravity value, scaled by scale; default -2400 + jumpHeight: 600, // base jump-height value, scaled by scale; default 600 + speed: 200, // base movement speed, scaled by scale; default 200 + runSpeed: 600, // base run speed, scaled by scale; default 600 + flySpeed: 2100, // base flight speed, scaled by scale; default 2100 + acceleration: 30, // XZ acceleration response; default 30 + deceleration: 30, // XZ deceleration response; default 30 + + // Model settings (optional) + rotateY: 0, // initial player facing direction in radians; default 0 + headBoneName: "Head", // head bone name used for first-person camera attachment + firstPersonCameraOffset: [0, 40, 30], // local first-person camera offset + capsuleRadiusRatio: 1, // capsule radius multiplier; default 1 + }, + + // Scene and collision (optional) + initPos: new THREE.Vector3(0, 0, 0), // initial spawn point; default (0, 0, 0) + colliders: [ // colliders created during initialization; call addCollider() after init() if omitted + { + motion: "static", // collider motion type: static / kinematic / dynamic + shape: { // collision shape settings + kind: "mesh", // Mesh collision shape + source: world, // collision source; THREE.Object3D or THREE.Object3D[] + }, + useWorker: false, // whether to build the BVH asynchronously in a Worker + }, + ], + + // Camera (optional) + minCamDistance: 100, // minimum third-person camera distance; default 100 + maxCamDistance: 440, // maximum third-person camera distance; default 440 + camLookAtHeightRatio: 0.8, // camera target height ratio, 0=bottom and 1=top; default 0.8 + thirdMouseMode: 1, // third-person mouse mode 0-5; default 1 + enableZoom: false, // whether mouse-wheel zoom is enabled; default false + enableOverShoulderView: false, // whether over-shoulder view is enabled; default false + camOverShoulderOffsetRatio: 0.2, // over-shoulder horizontal offset ratio; default 0.2 + isFirstPerson: false, // whether to start in first-person; default false + enableSpringCamera: false, // whether spring camera is enabled; default false + springCameraTime: 0.05, // spring-camera smoothing time in seconds; default 0.05 + + // Other settings (optional) + mouseSensitivity: 5, // mouse sensitivity; default 5 + timeScale: 1, // time scale; <1 slow motion, >1 fast-forward; default 1 + + keyMap: { // custom key map; use one key, multiple keys, or null to disable + forward: ["KeyW", "ArrowUp"], // move forward + backward: ["KeyS", "ArrowDown"], // move backward + left: ["KeyA", "ArrowLeft"], // move left + right: ["KeyD", "ArrowRight"], // move right + sprint: ["ShiftLeft", "ShiftRight"], // sprint; handbrake while driving + jump: ["Space"], // jump; brake while driving + toggleView: ["KeyV"], // switch first-person / third-person + toggleFly: ["KeyF"], // toggle flight mode + toggleVehicle: ["KeyE"], // enter / exit vehicle + }, + + isShowMobileControls: true, // whether to show virtual controls on mobile; default true + mobileControls: { // mobile button visibility, icons, and layout + joystick: true, // whether to show the virtual joystick; default true + jump: { // jump / brake button; pass false to hide + icon: "/icons/custom-jump.svg", // custom image URL for jump state + brakeIcon: "/icons/custom-brake.svg", // custom image URL for braking in vehicle mode + right: 24, // distance from the right edge in px + bottom: 32, // distance from the bottom edge in px + size: 64, // button diameter in px; default 56 + }, + fly: true, // flight button; true uses default style, false hides it + view: true, // view-switch button; true uses default style, false hides it + vehicle: true, // enter / exit vehicle button; true uses default style, false hides it + }, +}); +``` + + +# Collision System + +Uses the unified `CollisionWorld` and `addCollider()` API. + +A collider is described by two core properties: + +```ts +{ + motion: "static" | "kinematic" | "dynamic", + shape: { + kind: "mesh" | "box" | "sphere", + // ... + } +} +``` + +## Static Mesh + +Suitable for terrain, buildings, walls, and other scene geometry that does not move. + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { + kind: "mesh", + source: world, + }, + useWorker: true, +}); +``` + +## Kinematic Mesh + +Suitable for elevators, moving platforms, rotating platforms, vehicle visuals, or other objects whose transforms are controlled by application code. + +```ts +const platformCollider = player.addCollider({ + motion: "kinematic", + shape: { + kind: "mesh", + source: platform, + }, + follow: platform, + useWorker: true, +}); +``` + +The collider follows `follow.matrixWorld` every frame. If you only need to adjust already-baked kinematic collision geometry at runtime, the BVH does not need to be rebuilt. + +## Dynamic Rigid Bodies + +Dynamic `box` and `sphere` colliders automatically create built-in rigid bodies. `DynamicBodySystem` handles gravity, damping, contacts, impulses, and sleeping. + +### Dynamic Box + +```ts +const box = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial(), +); +scene.add(box); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: new THREE.Vector3(0.5, 0.5, 0.5), + position: new THREE.Vector3(0, 5, 0), + }, + mesh: box, +}); +``` + +### Dynamic Sphere + +```ts +const sphere = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshStandardMaterial(), +); +scene.add(sphere); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(0, 5, 0), + }, + mesh: sphere, +}); +``` + +Dynamic rigid bodies can collide with static meshes, kinematic meshes, the player, vehicles, and other dynamic rigid bodies. + +## Remove And Clear + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: object }, +}); + +player.removeCollider(handle); + +// Clear only static colliders +player.clearColliders({ motion: "static" }); + +// Clear all colliders +player.clearColliders(); +``` + +## Collision Groups + +```ts +import { CollisionGroup } from "three-player-controller"; +``` + +| Group | Description | +| --- | --- | +| `CollisionGroup.DEFAULT` | Default scene collision group. | +| `CollisionGroup.CHARACTER` | Player character collision group. | +| `CollisionGroup.VEHICLE` | Vehicle collision group. | +| `CollisionGroup.DEBRIS` | Dynamic debris, pushable objects, and similar colliders. | +| `CollisionGroup.ALL` | Combination of all collision groups, allowing collision with every group. | + +By default, you do not need to set these values. The appropriate collision group and mask are selected automatically based on `motion`. You can also customize collision filtering with `groups` and `mask`: + +```ts +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(), + }, + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE, +}); +``` + +# Streaming 3D Tiles Collision + +For 3D Tiles, it is not recommended to merge the entire Tileset into a single collider at once. + +`example/3dtilesScene.js` uses a streaming strategy: + +1. Listen for tile load, unload, and visibility changes. +2. Track only currently loaded tiles. +3. Create colliders only for visible tiles near the player. +4. Build BVHs asynchronously with `useWorker: true`. +5. Immediately call `removeCollider()` when a tile is hidden, unloaded, or too far away. +6. Use separate enter / exit radii to avoid frequent collider creation and destruction near the boundary. + +Core pattern: + +```ts +const handle = player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { + kind: "mesh", + source: tileScene, + }, + follow: tileScene, +}); + +// Remove the collider when the tile unloads, becomes invisible, or is too far away +player.removeCollider(handle); +``` + +The example also demonstrates a local coordinate system and rebasing for globe-scale large-coordinate scenes. When the player moves too far from the local origin, the local coordinate system is updated while the player, camera, and velocity are transformed together to avoid floating-point precision issues at large coordinates. + +See the full implementation in [`example/3dtilesScene.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js). + +# Vehicle + +```ts +const vehicleGltf = await loader.loadAsync("./glb/vehicle.glb"); // Load the vehicle model externally + +const vehicle = await player.loadVehicleModel({ + // Required + model: vehicleGltf.scene, // root node of the externally loaded vehicle model + position: new THREE.Vector3(0, 0, 0), // initial vehicle world position + wheelsNames: [ // wheel node names, order: front-left, front-right, rear-left, rear-right + "Wheel_LF", // front-left wheel node name + "Wheel_RF", // front-right wheel node name + "Wheel_LR", // rear-left wheel node name + "Wheel_RR", // rear-right wheel node name + ], + driverSeatPosition: new THREE.Vector3(-0.6, 0.7, 0.4), // driver-seat capsule center in chassis-local coordinates + + // Model / driving (optional) + scale: 0.1, // vehicle model scale; default 1 + driverSeatRotation: 0, // driver-seat horizontal rotation relative to chassis local space in radians; default 0 + modelRotation: -Math.PI / 2, // vehicle model Y-axis rotation in radians; default -π/2 + followVehicleDirection: true, // whether the camera follows vehicle heading while driving; default true + + // Debug (optional) + debug: { // vehicle physics debug visualization settings + showPhysicsBox: false, // whether to show the chassis physics collision box; default false + showWheelRays: false, // whether to show wheel suspension rays; default false + showWheelTravel: false, // whether to show wheel Y-axis travel range; default false + showWheelSpheres: false, // whether to show dynamic wheel collision spheres; default false + }, + + // Chassis (optional) + chassis: { // chassis collision box and damping settings + density: 1, // chassis density; mass is calculated from collision-box volume × density; default 1 + linearDamping: 0.05, // linear damping; default 0.05 + angularDamping: 0.5, // angular damping; default 0.5 + clearance: 0.15, // height of the chassis box bottom relative to tire contact points; auto-calculated if omitted + sizeScale: { // chassis collision-box size multiplier relative to the automatic AABB + x: 1, // X-axis size multiplier; default 1 + y: 1, // Y-axis size multiplier; default 1 + z: 1, // Z-axis size multiplier; default 1 + }, + }, + + // Suspension / tires (optional) + suspension: { // suspension and tire settings + restLength: 0.3, // suspension rest length; auto-calculated if omitted + maxTravel: 0.35, // maximum suspension travel; default 0.35 + stiffness: 18, // suspension stiffness; default 18 + compression: 2.1, // suspension compression damping; default 2.1 + relaxation: 2.5, // suspension rebound damping; default 2.5 + maxForce: 6000, // maximum suspension force per wheel; default 6000 + frictionSlip: 8, // longitudinal tire grip; default 8 + sideFrictionStiffness: 1, // lateral tire friction stiffness; default 1 + rollInfluence: 0.12, // effect of lateral force on body roll; default 0.12 + }, + + // Steering (optional) + steering: { // steering settings + maxSteerAngle: Math.PI / 5, // maximum steering angle in radians; default π/5 + steerTime: 0.45, // time from center to full steering lock in seconds; default 0.45 + steerReturnTimeSlow: 0.55, // low-speed steering return time in seconds; default 0.55 + steerReturnTimeFast: 0.4, // high-speed steering return time in seconds; default 0.4 + highSpeedSteerScale: 0.3, // maximum steering ratio at top speed; default 0.3 + }, + + // Grip / handbrake (optional) + grip: { // cornering grip and handbrake settings + maxG: 1.2, // maximum lateral acceleration in g; default 1.2 + sideFrictionIdle: 1, // lateral friction while driving straight; default 1 + sideFrictionFrontMin: 0.55, // minimum front-wheel lateral friction under high load; default 0.55 + sideFrictionRearMin: 0.45, // minimum rear-wheel lateral friction under high load; default 0.45 + handbrakeRearFriction: 0.35, // rear-wheel lateral friction while handbraking; default 0.35 + handbrakeRearDriveScale: 0.65, // rear-wheel drive-force ratio while handbraking; default 0.65 + handbrakeReleaseTime: 0.15, // recovery time after releasing the handbrake in seconds; default 0.15 + wheelbaseRatio: 0.55, // wheelbase relative to vehicle length; default 0.55 + }, + + // Power (optional) + power: { // vehicle power settings + maxSpeed: 100, // base top speed in km/h, scaled by scale; default 100 + acceleration: 5, // base acceleration in m/s², scaled by scale; default 5 + deceleration: 5, // base braking deceleration in m/s², scaled by scale; default 5 + }, +}); +``` + +Runtime: + +```ts +player.resetVehicle(); // right the currently driven vehicle and clear linear / angular velocity +player.setVehicleScale(vehicle, 0.12); // change the overall vehicle scale at runtime +player.setVehicleClearance(vehicle, 0.15); // adjust chassis collision-box ground clearance +player.setVehicleChassisSizeScale(vehicle, { // adjust chassis collision-box size multipliers relative to the automatic AABB + x: 1, // X-axis size multiplier + y: 0.9, // Y-axis size multiplier + z: 1, // Z-axis size multiplier +}); +player.setVehiclePhysicsDebug(true); // show / hide the vehicle chassis physics collision box +``` + +# Foot IK Plugin + +```ts +import { FootIK } from "three-player-controller/foot-ik"; + +const footIK = new FootIK({ + // Manually specify the player skeleton mapping; common bone names are matched automatically when omitted + skeleton: { + hips: "mixamorigHips", // hips / pelvis bone name + + // Left / right leg bone configuration + legs: { + left: { + upper: "mixamorigLeftUpLeg", // left upper-leg bone + lower: "mixamorigLeftLeg", // left lower-leg bone + foot: "mixamorigLeftFoot", // left foot bone + toe: "mixamorigLeftToeBase", // left toe bone + }, + right: { + upper: "mixamorigRightUpLeg", // right upper-leg bone + lower: "mixamorigRightLeg", // right lower-leg bone + foot: "mixamorigRightFoot", // right foot bone + toe: "mixamorigRightToeBase", // right toe bone + }, + }, + }, + + soleSkinThickness: 3, // skin-thickness compensation from the foot bone to the shoe sole +}); + +player.use(footIK); // register the Foot IK plugin with the player controller +``` + +Runtime: + +```ts +footIK.setEnabled(false); // enable / disable Foot IK +footIK.setDebugEnabled(true); // show / hide Foot IK debug helpers +player.unuse(footIK); // unregister the Foot IK plugin from the player controller +footIK.dispose(); // dispose debug objects and related resources created by Foot IK +``` + +`player.destroy()` automatically disposes registered plugins. + +See the full example in [`example/footIK.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js). + +# API + +## Lifecycle And Core + +| Method | Description | +| --- | --- | +| `init(opts, callback?)` | Initialize the controller. | +| `update(dt?)` | Update the player, collisions, dynamic rigid bodies, vehicles, camera, and animation every frame. | +| `destroy()` | Destroy the controller and release resources. | +| `reset(pos?)` | Reset the player to the specified position or the initial position. | +| `switchPlayerModel(options)` | Switch the player model at runtime using `PlayerModelOptions`. | +| `use(plugin)` | Register a plugin. | +| `unuse(plugin)` | Unregister a plugin. | +| `getPlugins()` | Return a copy of the current plugin list. | + +## Collision + +| Method | Description | +| --- | --- | +| `addCollider(desc)` | Add a static / kinematic / dynamic collider and return a `ColliderHandle`. | +| `removeCollider(handle)` | Remove a collider by handle or id. | +| `clearColliders(filter?)` | Clear colliders, optionally filtered by `motion`. | +| `getColliderMeshes(options?)` | Return the Mesh colliders used by current player queries. | +| `isStaticColliderUsable()` | Whether a static Mesh with a completed BVH is available. | +| `getActiveKinematicCollider()` | Return the kinematic collider currently supporting the player. | +| `getActiveDynamicBody()` | Return the dynamic rigid body currently supporting the player. | +| `getDynamicBodies()` | Return all dynamic rigid bodies. | +| `removeDynamicBody(body)` | Remove a dynamic rigid body. | +| `clearDynamicBodies()` | Clear all dynamic rigid bodies. | +| `raycastDynamicGround(...)` | Query the surface of dynamic rigid bodies below. | + +## State + +| Method | Return Value | +| --- | --- | +| `getPosition()` | Current player position. | +| `getVelocity()` | Current player velocity. | +| `getIsFirstPerson()` | Whether first-person mode is active. | +| `getIsFlying()` | Whether flight mode is active. | +| `getIsOnGround()` | Whether the player is on the ground. | +| `getGroundSupport()` | Current final ground-support point and world-space normal. | +| `getCurrentDelta()` | Actual time step used by the current frame. | +| `getControllerMode()` | `0` for player, `1` for vehicle. | +| `getPlayerModel()` | Current player model. | +| `getPlayerCapsule()` | Player capsule. | +| `getActiveVehicle()` | Currently driven vehicle. | +| `getAllVehicles()` | All loaded vehicles. | +| `getCurrentPlayerAnimationName()` | Current animation clip name. | +| `getCurrentLocomotionSet()` | Current Locomotion Set. | +| `getCenterScreenRaycastHit()` | Center-screen raycast result. | + +## Runtime Controls + +| Method | Description | +| --- | --- | +| `setInput(input)` | Inject external input. | +| `setKeyMap(map?)` | Customize or restore default key bindings. | +| `setMouseSensitivity(v)` | Set mouse sensitivity. | +| `setPlayerScale(v)` | Change player scale at runtime. | +| `setPlayerSpeed(v)` | Set walking speed. | +| `setPlayerRunSpeed(v)` | Set running speed. | +| `setPlayerFlySpeed(v)` | Set flight speed. | +| `setJumpHeight(v)` | Set jump height. | +| `setGravity(v)` | Set gravity. | +| `setMinCamDistance(v)` | Set minimum third-person camera distance. | +| `setMaxCamDistance(v)` | Set maximum third-person camera distance. | +| `setCamLookAtHeightRatio(v)` | Set camera target height ratio. | +| `setCamOverShoulderOffsetRatio(v)` | Set over-shoulder horizontal offset ratio. | +| `setThirdMouseMode(v)` | Set third-person mouse mode. | +| `setEnableZoom(v)` | Enable / disable mouse-wheel zoom. | +| `setOverShoulderView(v)` | Enable / disable over-shoulder view. | +| `setEnableToward(v)` | Enable / disable mouse-driven facing. | +| `setSkipCapsuleCollision(v)` | Temporarily skip player capsule collision. | +| `changeView()` | Switch first-person / third-person view. | +| `setFirstPersonCamera(v?)` | Enter first-person view directly. | + +## Debug + +```ts +player.setColliderDebug(true); // show / hide static and kinematic Mesh collision wireframes +player.setPlayerCapsuleDebug(true); // show / hide the player capsule wireframe +player.setDynamicBodyDebug(true); // show / hide dynamic rigid-body collision wireframes +player.setVehiclePhysicsDebug(true); // show / hide the vehicle chassis physics collision box +``` + +## Animation + +| Method | Description | +| --- | --- | +| `playPlayerAnimationByName(name, fade?)` | Play a player animation directly by clip name. | +| `registerAnimation(key, clipName, opts?)` | Register a custom animation clip. | +| `playAnimation(key, opts?)` | Play a registered custom animation. | +| `registerLocomotionSet(setName, map)` | Register a locomotion animation set to replace built-in locomotion animations. | +| `switchLocomotionSet(setName, fade?)` | Switch to the specified locomotion animation set. | + +### `registerAnimation` + +```ts +player.registerAnimation(key, clipName, { + loop?: boolean, // whether to loop; default true + timeScale?: number, // animation playback scale; default 1 + duration?: number, // animation playback duration; default 0 + clampWhenFinished?: boolean, // whether to reset animation time to 0 when playback finishes; default false + onFinished?: () => void, // called when animation playback finishes +}); +``` + +When both `duration` and `timeScale` are set, `duration` takes precedence. + +### `playAnimation` + +```ts +player.playAnimation(key, { + fade?: number, // transition duration in seconds; default 0.18 + force?: boolean, // when true, restart from the beginning even if this animation is already playing + returnToPrev?: boolean, // applies only to LoopOnce animations; automatically restore the previous animation state after playback +}); +``` + +### `registerLocomotionSet` + +Supported keys: `idle` | `walking` | `walking_backward` | `running` | `jumping` | `flyidle` | `flying`. Specified keys replace the corresponding built-in animations; omitted keys keep their existing animations. + +```ts +player.registerLocomotionSet("combat", { + idle: "CombatIdle", + walking: "CombatWalk", + walking_backward: "CombatBack", + running: "CombatRun", + jumping: "CombatJump", + flyidle: "CombatFlyIdle", + flying: "CombatFly", +}); +``` + +# Default Controls + +| Action | Default Key | Behavior | +| --- | --- | --- | +| Forward | `W` / `ArrowUp` | Move forward | +| Backward | `S` / `ArrowDown` | Move backward | +| Left | `A` / `ArrowLeft` | Move left | +| Right | `D` / `ArrowRight` | Move right | +| Sprint | `Shift` | Player sprint; vehicle handbrake | +| Jump | `Space` | Jump; ascend while flying; vehicle brake | +| Toggle view | `V` | First-person / third-person | +| Fly | `F` | Toggle flight mode | +| Enter / exit vehicle | `E` | Vehicle interaction | +| Look | Mouse | Control view | + +# Custom Key Bindings + +Use `keyMap` to rebind any action in the table above to other keys, or disable an action. Key names use [`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) (for example `"KeyE"`, `"ArrowUp"`, and `"Space"`; note that it is `"KeyE"`, not `"e"`). + +Each action supports three value forms: + +- **Omitted** → use the default key +- **String / string array** → replace with the specified key(s); an array can bind multiple keys +- **`null`** → disable the action; no key will trigger it + +Configure during initialization: + +```ts +await player.init({ + // ... + keyMap: { + forward: "KeyE", // use E to move forward instead of default W / ↑ + jump: null, // disable jumping + left: ["KeyA", "KeyJ"], // bind both A and J + // omitted actions keep their default keys + }, +}); +``` + +Switch key-binding schemes at runtime: + +```ts +player.setKeyMap({ forward: "KeyI", backward: "KeyK" }); // apply new bindings +player.setKeyMap(); // restore all defaults +``` + +# External Input + +```ts +player.setInput({ + moveX: number, // horizontal movement axis, range -1 to 1; positive moves right + moveY: number, // vertical movement axis, range -1 to 1; positive moves forward + lookDeltaX: number, // horizontal look delta, usually mousemove movementX + lookDeltaY: number, // vertical look delta, usually mousemove movementY + jump: boolean, // held state (true=pressed, false=released); jump, ascend while flying, brake while driving + shift: boolean, // held state (true=pressed, false=released); sprint, or handbrake while driving + toggleView: boolean, // trigger; pass true to switch first-person / third-person view + toggleFly: boolean, // trigger; pass true to toggle flight mode + toggleVehicle: boolean, // trigger; pass true to enter / exit vehicle +}); +``` + +# Events + +```ts +player.onAnimationChange = (name, action) => {}; // triggered when the current player animation changes +player.onBeforeViewChange = (isFirstPerson) => {}; // triggered before first-person / third-person switching +player.onViewChange = (isFirstPerson) => {}; // triggered after first-person / third-person switching +player.onGroundChange = (onGround) => {}; // triggered when grounded state changes +player.onVehicleEnter = (vehicle) => {}; // triggered after entering a vehicle +player.onVehicleExit = (vehicle) => {}; // triggered after exiting a vehicle +player.onTowardChange = (dx, dy, speed) => {}; // triggered when facing / view input updates +``` + +# Main Options + +## `PlayerControllerOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `scene` | `THREE.Scene` | Yes | — | three.js scene instance. | +| `camera` | `THREE.PerspectiveCamera` | Yes | — | three.js camera instance. | +| `controls` | `any` | Yes | — | External camera controller, usually `OrbitControls`. | +| `playerModelConfig` | `PlayerModelOptions` | Yes | — | Player model and parameter configuration. | +| `initPos` | `THREE.Vector3` | No | `(0, 0, 0)` | Initial spawn point. | +| `colliders` | `ColliderDesc[]` | No | — | Colliders registered in bulk during initialization. | +| `mouseSensitivity` | `number` | No | `5` | Mouse sensitivity. | +| `minCamDistance` | `number` | No | `100` | Minimum third-person camera distance. | +| `maxCamDistance` | `number` | No | `440` | Maximum third-person camera distance. | +| `isShowMobileControls` | `boolean` | No | `true` | Whether to show virtual control UI on mobile. | +| `mobileControls` | `MobileControlsOptions` | No | All shown | Mobile button visibility, icons, and layout settings. | +| `thirdMouseMode` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | No | `1` | Mouse control mode in third-person view (0: hide cursor, control facing and view; 1: hide cursor, control view only; 2: show cursor, drag to control facing and view; 3: show cursor, drag to control view only; 4: show cursor, drag to control view while player facing follows the camera's horizontal direction; 5: hide cursor, control view while player facing follows the camera's horizontal direction). | +| `enableZoom` | `boolean` | No | `false` | Whether mouse-wheel zoom is enabled. | +| `enableOverShoulderView` | `boolean` | No | `false` | Whether over-shoulder view is enabled. | +| `camOverShoulderOffsetRatio` | `number` | No | `0.2` | Third-person over-shoulder camera horizontal offset ratio. | +| `isFirstPerson` | `boolean` | No | `false` | Whether to enter first-person immediately during initialization. | +| `enableSpringCamera` | `boolean` | No | `false` | Whether spring camera is enabled (the target follows the player with spring damping). | +| `springCameraTime` | `number` | No | `0.05` | Spring smoothing time in seconds; smaller values follow more tightly. | +| `camLookAtHeightRatio` | `number` | No | `0.8` | Third-person camera target height ratio (0=capsule bottom, 1=top). | +| `timeScale` | `number` | No | `1` | Time scale; <1 slow motion, >1 fast-forward. | +| `keyMap` | `KeyMap` | No | Default bindings | Custom key mapping; see [Custom Key Bindings](#custom-key-bindings). | + +## `PlayerModelOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `model` | `THREE.Object3D` | One of two | — | Root node of an already-loaded player model. Mutually exclusive with `url`. | +| `animations` | `THREE.AnimationClip[]` | With `model` | — | Player animation clips. Pass together with `model`. | +| `url` | `string` | One of two | — | Player model path (GLB/GLTF). | +| `scale` | `number` | Yes | — | Player model scale. | +| `idleAnim` | `string` | Yes | — | Idle animation name. | +| `walkAnim` | `string` | Yes | — | Walk animation name. | +| `runAnim` | `string` | Yes | — | Run animation name. | +| `jumpAnim` | `string \| [string, string, string]` | Yes | — | Jump animation name. Pass a string for one animation, or a three-element `[start, loop, land]` array for three-stage playback; locomotion resumes automatically after landing. | +| `leftWalkAnim` | `string` | No | `walkAnim` | Left-strafe animation name; defaults to `walkAnim` when omitted. | +| `rightWalkAnim` | `string` | No | `walkAnim` | Right-strafe animation name; defaults to `walkAnim` when omitted. | +| `backwardAnim` | `string` | No | `walkAnim` | Backward animation name; defaults to `walkAnim` when omitted. | +| `flyAnim` | `string` | No | `idleAnim` | Flight animation name; defaults to `idleAnim` when omitted. | +| `flyIdleAnim` | `string` | No | `idleAnim` | Fly-idle animation name; defaults to `idleAnim` when omitted. | +| `flyHoverForwardAnim` | `string` | No | `flyAnim` | Hover animation while flying forward; defaults to `flyAnim` when omitted. | +| `flyHoverBackAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying backward; defaults to `flyIdleAnim` when omitted. | +| `flyHoverLeftAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying left; defaults to `flyIdleAnim` when omitted. | +| `flyHoverRightAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying right; defaults to `flyIdleAnim` when omitted. | +| `flyHoverUpAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying upward; defaults to `flyIdleAnim` when omitted. | +| `flyHoverDownAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying downward; defaults to `flyIdleAnim` when omitted. | +| `drivingAnim` | `string` | No | `idleAnim` | Driving loop animation name; defaults to `idleAnim` when omitted. | +| `gravity` | `number` | No | `-2400` | Base gravity value, scaled by `scale`. | +| `jumpHeight` | `number` | No | `600` | Base jump-height value, scaled by `scale`. | +| `speed` | `number` | No | `200` | Base movement speed, scaled by `scale`. | +| `runSpeed` | `number` | No | `600` | Base run speed, scaled by `scale`. | +| `flySpeed` | `number` | No | `2100` | Base flight speed, scaled by `scale`. | +| `rotateY` | `number` | No | `0` | Initial player facing direction in radians, used to change the model's initial facing direction. | +| `headBoneName` | `string` | No | — | Head bone or node name used for first-person camera attachment. | +| `firstPersonCameraOffset` | `[number, number, number]` | No | Built-in default | Local first-person camera offset; relative to the head bone when `headBoneName` is set, otherwise relative to the capsule. | +| `capsuleRadiusRatio` | `number` | No | `1` | Capsule radius multiplier for fine-tuning collision width. | +| `acceleration` | `number` | No | `30` | XZ acceleration response; larger values accelerate faster. | +| `deceleration` | `number` | No | `30` | XZ deceleration response; larger values stop faster. | + +### `MobileControlsOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `joystick` | `boolean` | No | `true` | Whether to show a joystick with continuous 360° direction input. | +| `jump` | `boolean \| JumpButtonOptions` | No | `true` | Jump / brake button; `false` hides it, or pass an object to customize image and layout. | +| `fly` | `boolean \| MobileButtonOptions` | No | `true` | Flight button; `false` hides it, or pass an object to customize image and layout. | +| `view` | `boolean \| MobileButtonOptions` | No | `true` | View-switch button; `false` hides it, or pass an object to customize image and layout. | +| `vehicle` | `boolean \| MobileButtonOptions` | No | `true` | Enter / exit vehicle button; `false` hides it, or pass an object to customize image and layout. | + +### `MobileButtonOptions` / `JumpButtonOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `left` / `right` | `number` | No | Built-in position | Distance from the left or right side of the screen in px. Setting `left` without `right` clears the default right positioning. | +| `top` / `bottom` | `number` | No | Built-in position | Distance from the top or bottom of the screen in px. Setting `top` without `bottom` clears the default bottom positioning. | +| `size` | `number` | No | `56` | Circular button diameter in px. | +| `icon` | `string` | No | English label | Custom image URL. | +| `brakeIcon` | `string` | No | `BRAKE` label | Used only by `JumpButtonOptions`; sets the brake image URL in vehicle mode. | + + +## `ColliderDesc` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `motion` | `"static" \| "kinematic" \| "dynamic"` | — | Collider motion type. | +| `shape` | `ColliderShape` | — | Collision shape configuration; supports `mesh`, `box`, and `sphere`. | +| `groups` | `number` | Set automatically based on `motion` | Collision group this collider belongs to. | +| `mask` | `number` | Set automatically based on `motion` | Collision group mask this collider is allowed to collide with. | +| `useWorker` | `boolean` | `false` | Whether to build the BVH asynchronously in a Worker for `mesh + source`. | +| `userData` | `unknown` | — | Custom application data. | +| `follow` | `THREE.Object3D` | — | Scene object followed by a `kinematic` collider. | +| `density` | `number` | `1` | `dynamic` rigid-body density used to calculate mass. | +| `restitution` | `number` | `0.2` | `dynamic` rigid-body restitution. | +| `friction` | `number` | `0.6` | `dynamic` rigid-body friction. | +| `linearDamping` | `number` | `0.4` | `dynamic` rigid-body linear damping. | +| `angularDamping` | `number` | `0.6` | `dynamic` rigid-body angular damping. | +| `gravity` | `number` | Internal default | `dynamic` rigid-body gravity. | +| `velocity` | `THREE.Vector3` | `(0,0,0)` | Initial linear velocity of a `dynamic` rigid body. | +| `angularVelocity` | `THREE.Vector3` | `(0,0,0)` | Initial angular velocity of a `dynamic` rigid body. | +| `mesh` | `THREE.Mesh` | — | Visual Mesh required when creating a built-in `dynamic box / sphere` rigid body. | +| `simulate` | `boolean` | `true` | Whether the built-in dynamic rigid-body system simulates this body. | + +### `ColliderShape` + +#### Mesh + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"mesh"` | Mesh collision shape. | +| `source` | `THREE.Object3D \| THREE.Object3D[]` | Collect geometry from the object(s), merge the collision mesh, and build a BVH. | +| `mesh` | `THREE.Mesh` | Register an already-prepared collision Mesh directly. | + +#### Box + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"box"` | Box collision shape. | +| `halfExtents` | `THREE.Vector3` | Box half-extents, i.e. half of the full size. | +| `position` | `THREE.Vector3` | Initial world position, optional. | +| `quaternion` | `THREE.Quaternion` | Initial world rotation, optional. | + +#### Sphere + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"sphere"` | Sphere collision shape. | +| `radius` | `number` | Sphere radius. | +| `position` | `THREE.Vector3` | Initial world position, optional. | + +## `VehicleOptions` + +### Basic Parameters + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `model` | `THREE.Object3D` | — | Root node of the externally loaded vehicle model. | +| `position` | `THREE.Vector3` | — | Initial vehicle world position. | +| `wheelsNames` | `[string, string, string, string]` | — | Array of wheel node names in front-left, front-right, rear-left, rear-right order. | +| `driverSeatPosition` | `THREE.Vector3` | — | Driver-seat capsule center in vehicle chassis-local coordinates. | +| `scale` | `number` | `1` | Vehicle model scale. | +| `driverSeatRotation` | `number` | `0` | Driver-seat horizontal rotation relative to vehicle chassis local space in radians. | +| `modelRotation` | `number` | `-Math.PI / 2` | Initial vehicle model Y-axis rotation. | +| `followVehicleDirection` | `boolean` | `true` | Whether the camera follows vehicle heading while driving. | +| `debug` | `VehicleDebugOptions` | — | Vehicle physics debug visualization settings. | +| `chassis` | `VehicleChassisOptions` | — | Chassis collision-box and damping settings. | +| `suspension` | `VehicleSuspensionOptions` | — | Suspension and tire settings. | +| `steering` | `VehicleSteeringOptions` | — | Steering settings. | +| `grip` | `VehicleGripOptions` | — | Grip and handbrake settings. | +| `power` | `VehiclePowerOptions` | — | Power settings. | + +### `debug` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `showPhysicsBox` | `boolean` | `false` | Show the chassis physics collision box. | +| `showWheelRays` | `boolean` | `false` | Show wheel suspension rays. | +| `showWheelTravel` | `boolean` | `false` | Show wheel Y-axis travel range. | +| `showWheelSpheres` | `boolean` | `false` | Show dynamic wheel collision spheres. | + +### `chassis` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `density` | `number` | `1` | Chassis density; mass is calculated from collision-box volume and density. | +| `linearDamping` | `number` | `0.05` | Chassis linear damping. | +| `angularDamping` | `number` | `0.5` | Chassis angular damping. | +| `clearance` | `number` | Auto-calculated | Height of the chassis box bottom relative to tire contact points. | +| `sizeScale.x` | `number` | `1` | Chassis collision-box X-axis size multiplier. | +| `sizeScale.y` | `number` | `1` | Chassis collision-box Y-axis size multiplier. | +| `sizeScale.z` | `number` | `1` | Chassis collision-box Z-axis size multiplier. | + +### `suspension` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `restLength` | `number` | Auto-calculated | Suspension rest length. | +| `maxTravel` | `number` | `0.35` | Maximum suspension travel. | +| `stiffness` | `number` | `18` | Suspension stiffness. | +| `compression` | `number` | `2.1` | Suspension compression damping. | +| `relaxation` | `number` | `2.5` | Suspension rebound damping. | +| `maxForce` | `number` | `6000` | Maximum suspension force per wheel. | +| `frictionSlip` | `number` | `8` | Longitudinal tire grip. | +| `sideFrictionStiffness` | `number` | `1` | Lateral tire friction stiffness. | +| `rollInfluence` | `number` | `0.12` | Effect of lateral force on body roll. | + +### `steering` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxSteerAngle` | `number` | `Math.PI / 5` | Maximum steering angle in radians. | +| `steerTime` | `number` | `0.45` | Time from center to full steering lock in seconds. | +| `steerReturnTimeSlow` | `number` | `0.55` | Low-speed steering return time in seconds. | +| `steerReturnTimeFast` | `number` | `0.4` | High-speed steering return time in seconds. | +| `highSpeedSteerScale` | `number` | `0.3` | Maximum steering ratio at top speed. | + +### `grip` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxG` | `number` | `1.2` | Maximum lateral acceleration in g. | +| `sideFrictionIdle` | `number` | `1` | Lateral friction while driving straight. | +| `sideFrictionFrontMin` | `number` | `0.55` | Minimum front-wheel lateral friction under high load. | +| `sideFrictionRearMin` | `number` | `0.45` | Minimum rear-wheel lateral friction under high load. | +| `handbrakeRearFriction` | `number` | `0.35` | Rear-wheel lateral friction while handbraking. | +| `handbrakeRearDriveScale` | `number` | `0.65` | Rear-wheel drive-force ratio while handbraking. | +| `handbrakeReleaseTime` | `number` | `0.15` | Recovery time after releasing the handbrake in seconds. | +| `wheelbaseRatio` | `number` | `0.55` | Wheelbase relative to vehicle length. | + +### `power` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxSpeed` | `number` | `100` | Base top speed in km/h, scaled by `scale`. | +| `acceleration` | `number` | `5` | Base acceleration in m/s², scaled by `scale`. | +| `deceleration` | `number` | `5` | Base braking deceleration in m/s², scaled by `scale`. | + +# Feedback + +If you have any questions or suggestions, please submit an [issue](https://github.com/hh-hang/three-player-controller/issues). + +# Acknowledgements + +- [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh) +- [three.js](https://github.com/mrdoob/three.js) + +[npm]: https://img.shields.io/npm/v/three-player-controller +[npm-url]: https://www.npmjs.com/package/three-player-controller +[github]: https://img.shields.io/badge/-hh--hang-181717?style=flat&logo=github&logoColor=white&labelColor=888 +[github-url]: https://github.com/hh-hang diff --git a/three-player-controller/example/3dgs.html b/three-player-controller/example/3dgs.html new file mode 100644 index 0000000..87a8099 --- /dev/null +++ b/three-player-controller/example/3dgs.html @@ -0,0 +1,84 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Boost +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+ Z + Scale Character +
+
+
+ +
+ 3DGS: + Huggingface +
+ + + + + + + + + diff --git a/three-player-controller/example/3dgs.js b/three-player-controller/example/3dgs.js new file mode 100644 index 0000000..b5ca138 --- /dev/null +++ b/three-player-controller/example/3dgs.js @@ -0,0 +1,213 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + EquirectangularReflectionMapping, + PerspectiveCamera, + Scene, + Vector3, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import { playerController } from "../src/PlayerController"; +import { SplatMesh, SparkRenderer } from "@sparkjsdev/spark"; +import Stats from 'three/examples/jsm/libs/stats.module.js'; + +let player; +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let gltfLoader; +let stats; + +const pos = new Vector3(1.235, 1.21, -3.9); + +const scaleNormal = 0.01; +const scaleSmall = scaleNormal / 9; +let isSmallScale = false; +let scaleAnimFrame = null; +let isScaling = false; + +init(); + +async function init() { + const cont = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setSize(cont.clientWidth, cont.clientHeight); + renderer.shadowMap.enabled = true; + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.setAnimationLoop(animate); + cont.appendChild(renderer.domElement); + + // 相机 + camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000); + camera.position.copy(pos); + camera.lookAt(pos.x, pos.y, pos.z + 1); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 2000; + controls.dampingFactor = 0.1; + controls.rotateSpeed = 1; + controls.maxPolarAngle = Math.PI / 2; + controls.target.set(pos.x, pos.y, pos.z + 1); + + // 环境光 + const ambient = new AmbientLight(0xffffff, 5); + scene.add(ambient); + + // 背景 + new HDRLoader().load( + "./img/env.hdr", + (texture) => { + texture.mapping = EquirectangularReflectionMapping; + scene.background = texture; + }, + undefined, + (err) => console.warn("HDR 加载失败:", err) + ); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 加载3DGS场景 + initGltfLoader(); + const spark = new SparkRenderer({ renderer }); + scene.add(spark); + const splatURL = './3DGS/3DGS.sog' + const splat = new SplatMesh({ + url: splatURL, + onProgress: (e) => window.setLoaderProgress?.(e.loaded, e.total), + onLoad: (mesh) => { mesh.rotateX(-Math.PI / 2); }, + }); + scene.add(splat); + + // 加载碰撞体 + const colliderGltf = await gltfLoader.loadAsync("./glb/3dgs_collider.glb"); + const playerGltf = await gltfLoader.loadAsync("./glb/josh.glb"); + + // 人物控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: scaleNormal, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "mixamorigHead", + speed: 150, + flySpeed: 1000, + jumpHeight: 400, + }, + initPos: pos, + minCamDistance: 50, + maxCamDistance: 180, + camLookAtHeightRatio: 0.8, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: colliderGltf.scene } }, + ], + enableOverShoulderView: true, + }); + + window.addEventListener("resize", onWindowResize, false); + // 监听按键 + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyZ") return; + if (isScaling) return; // 缩放中不运行 + isSmallScale = !isSmallScale; + animateToScale(isSmallScale ? scaleSmall : scaleNormal, 1); + }); + // 等待高斯泼溅模型加载完毕再隐藏 loader + await splat.initialized.catch(() => { }); + window.hideLoader(); +} + +function animateToScale(targetScale, duration = 0.5) { + if (scaleAnimFrame !== null) { + cancelAnimationFrame(scaleAnimFrame); + scaleAnimFrame = null; + } + + isScaling = true; + const fromScale = player.getScale?.() ?? (isSmallScale ? scaleNormal : scaleSmall); + const startTime = performance.now(); + + const tick = (now) => { + const t = Math.min((now - startTime) / (duration * 1000), 1); + const current = fromScale + (targetScale - fromScale) * t; + player.setPlayerScale(current); + + if (t < 1) { + scaleAnimFrame = requestAnimationFrame(tick); + } else { + scaleAnimFrame = null; + isScaling = false; + } + }; + + scaleAnimFrame = requestAnimationFrame(tick); +} + +// 初始化glb加载器 +function initGltfLoader() { + gltfLoader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(dracoLoader); + const ktx2Loader = new KTX2Loader(); + ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2Loader.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2Loader); +} + + +// 每帧调用 +function animate() { + if (player) { + player.update(); + } else { + controls.update(); + } + renderer.render(scene, camera); + stats?.update(); +} + +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + renderer.setSize(window.innerWidth, window.innerHeight); + camera.updateProjectionMatrix(); + renderer.setPixelRatio(window.devicePixelRatio * 1); +} diff --git a/three-player-controller/example/3dtilesScene.html b/three-player-controller/example/3dtilesScene.html new file mode 100644 index 0000000..85e3dad --- /dev/null +++ b/three-player-controller/example/3dtilesScene.html @@ -0,0 +1,113 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+ +
+
+ Powered by: + Google Photorealistic 3D Tiles + & + Cesium Ion +
+
+ 3D Tiles Loader: + 3DTilesRendererJS + · + Globe: + Tellux +
+
+ + + + + + + + + + + diff --git a/three-player-controller/example/3dtilesScene.js b/three-player-controller/example/3dtilesScene.js new file mode 100644 index 0000000..683b501 --- /dev/null +++ b/three-player-controller/example/3dtilesScene.js @@ -0,0 +1,574 @@ +import tellux from "tellux"; +import { + AxesHelper, + Box3, + Group, + Matrix4, + Quaternion, + Sphere, + Vector3, +} from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; + +// Google Photorealistic 3D Tiles +const GOOGLE_PHOTOREALISTIC_ASSET_ID = 2275207; +const CESIUM_ION_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmMzgwMGY3ZS1jOTMwLTQyNmQtOTkyNS03MDE4ZjlkYmY0MTYiLCJpZCI6MjIzMDk3LCJpYXQiOjE3MTg3NjgwNTN9.FcpK7jiFPzWZL8m6VxRbG7ly8LMecpXnDAMZJX_UehM"; + +// 出生城市 +const CITIES = { + paris: { longitude: 2.2945, latitude: 48.8584, height: 100, hourUTC: 12 }, + tokyo: { longitude: 139.7671, latitude: 35.6812, height: 40, hourUTC: 4 }, + newyork: { longitude: -74.006, latitude: 40.7128, height: 20, hourUTC: 17 }, + london: { longitude: -0.1246, latitude: 51.5007, height: 40, hourUTC: 14 }, + sydney: { longitude: 151.2153, latitude: -33.8568, height: 40, hourUTC: 3 }, + dubai: { longitude: 55.2744, latitude: 25.1972, height: 40, hourUTC: 8 }, + rome: { longitude: 12.4922, latitude: 41.8902, height: 40, hourUTC: 11 }, + saopaulo: { longitude: -46.6559, latitude: -23.5615, height: 1000, hourUTC: 16 }, +}; +const CITY_OPTIONS = { + "New York": "newyork", + Paris: "paris", + Tokyo: "tokyo", + London: "london", + Sydney: "sydney", + Dubai: "dubai", + Rome: "rome", + "São Paulo": "saopaulo", +}; + +const DEG2RAD = Math.PI / 180; +const RAD2DEG = 180 / Math.PI; + +// 动态碰撞:靠近才建,远离再拆 +const TILE_ENTER_RADIUS = 100; +const TILE_EXIT_RADIUS = 150; +const MAX_TILE_ADDS_PER_UPDATE = 1; +const MAX_TILE_REMOVES_PER_UPDATE = 4; + +// 人物速度;飞行时按住 Shift 进入极速 +const BASE_WALK_SPEED = 200; +const BASE_RUN_SPEED = 600; +const BASE_FLY_SPEED = 2100; +const TURBO_SPEED_SCALE = 3; + +const container = document.querySelector("#container"); +if (!(container instanceof HTMLElement)) { + throw new Error("Missing #container"); +} + +const attributionsElement = document.querySelector("#google-attributions"); + +const guiParams = { + city: "newyork", + playerCapsuleDebug: false, + colliderDebug: false, + localAxes: false, +}; + +// Tellux 视图 +const viewer = new tellux.Viewer(container, { + dracoDecoderPath: "https://unpkg.com/three@0.184.0/examples/jsm/libs/draco/gltf/", + useDefaultRenderLoop: false, + camera: { + longitude: CITIES.newyork.longitude, + latitude: CITIES.newyork.latitude, + height: 80, + heading: 0, + pitch: -20, + near: 0.1, + }, + scene: { + atmosphere: { + show: true, + lighting: { + mode: "post-process", + sunLight: true, + skyLight: true, + }, + sky: { + sun: true, + moon: true, + stars: true, + }, + }, + clouds: { + show: true, + coverage: 0.35, + }, + postProcess: { + lensFlare: { enabled: false }, + smaa: { enabled: false }, + }, + }, +}); + +// 关闭默认地球交互,改由人物控制器接管相机 +viewer.controls.enabled = false; +viewer.camera.allowUnderground = true; +viewer.tileset.group.visible = false; + +// 控制器 +const orbitControls = new OrbitControls(viewer.camera.threeCamera, viewer.renderer.domElement); +orbitControls.enablePan = false; + +// 局部坐标轴 +const localAxes = new AxesHelper(5000); +localAxes.name = "local-axes"; +localAxes.visible = false; +localAxes.renderOrder = 10; +localAxes.material.depthTest = false; +localAxes.material.depthWrite = false; +viewer.scene.threeScene.add(localAxes); + +// 帧率 +const stats = new Stats(); +Object.assign(stats.dom.style, { + position: "fixed", + left: "0", + bottom: "0", + top: "auto", + zIndex: "9998", +}); +document.body.appendChild(stats.dom); + +let activeLayer = null; +let localFrame = null; +let tileCollision = null; +let player = null; +let skipCollisionAfterRebase = false; +let running = true; +let turboMode = false; + +// 局部坐标系下每帧把世界到 ECEF 的矩阵写进大气。 +function syncAtmosphereFrame() { + const atmosphere = viewer.atmosphere; + atmosphere.aerialPerspectiveEffect.worldToECEFMatrix.copy(localFrame.localToEcef); + atmosphere.cloudsEffect.worldToECEFMatrix.copy(localFrame.localToEcef); +} + +// 以人物附近 ECEF 点建立局部 Y-up 坐标系,走远后 rebase。 +class LocalFrameManager { + // 建局部 ENU 根节点,并按出生点设原点 + constructor(ellipsoid, origin) { + this.ellipsoid = ellipsoid; + this.rebaseDistance = 10_000; + this.group = new Group(); + this.group.name = "local-frame"; + this.group.matrixAutoUpdate = false; + this.localToEcef = new Matrix4(); + this.ecefToLocal = new Matrix4(); + this.deltaMatrix = new Matrix4(); + this.deltaQuat = new Quaternion(); + this.scratchEcef = new Vector3(); + this.scratchCartographic = { lat: 0, lon: 0, height: 0 }; + this.setOrigin(origin); + } + + // 按经纬高重设局部原点 + setOrigin(origin) { + const height = origin.height ?? 0; + this.ellipsoid.getObjectFrame( + origin.latitude * DEG2RAD, + origin.longitude * DEG2RAD, + height, + 0, + 0, + 0, + this.localToEcef + ); + this.ecefToLocal.copy(this.localToEcef).invert(); + this.group.matrix.copy(this.ecefToLocal); + this.group.updateMatrixWorld(true); + } + + // 人物离原点太远时平移局部系,并同步胶囊、相机和速度 + maybeRebase(target) { + const position = target.getPosition(); + if (!position || position.length() < this.rebaseDistance) return false; + + this.scratchEcef.copy(position).applyMatrix4(this.localToEcef); + this.ellipsoid.getPositionToCartographic(this.scratchEcef, this.scratchCartographic); + + this.deltaMatrix.copy(this.localToEcef); + this.setOrigin({ + latitude: this.scratchCartographic.lat * RAD2DEG, + longitude: this.scratchCartographic.lon * RAD2DEG, + height: this.scratchCartographic.height, + }); + this.deltaMatrix.premultiply(this.ecefToLocal); + this.deltaQuat.setFromRotationMatrix(this.deltaMatrix); + + const capsule = target.getPlayerCapsule(); + if (capsule) this.applyDelta(capsule, this.deltaMatrix); + if (!this.isDescendant(target.camera, capsule)) { + this.applyDelta(target.camera, this.deltaMatrix); + } + target.controls?.target.applyMatrix4(this.deltaMatrix); + target.playerVelocity?.applyQuaternion(this.deltaQuat); + return true; + } + + // 判断 object 是否挂在 ancestor 下面 + isDescendant(object, ancestor) { + if (!ancestor) return false; + let current = object; + while (current) { + if (current === ancestor) return true; + current = current.parent; + } + return false; + } + + // 用 rebase 增量改物体位姿 + applyDelta(object, delta) { + object.updateMatrixWorld(true); + object.matrix.premultiply(delta); + object.matrix.decompose(object.position, object.quaternion, object.scale); + object.updateMatrixWorld(true); + } +} + +// 登记已加载瓦片,只给当前可见且靠近人物的瓦片建运动学 mesh 碰撞。 +class TileCollisionManager { + // 绑定瓦片集和人物,并备好事件回调 + constructor(tileset, targetPlayer) { + this.tileset = tileset; + this.player = targetPlayer; + this.attached = false; + this.tracked = new Map(); + this.scratchCenter = new Vector3(); + this.scratchScale = new Vector3(); + this.scratchBox = new Box3(); + this.scratchSphere = new Sphere(); + this.onLoadModel = (event) => this.track(event.scene, this.isTileVisible(event.tile, event.scene)); + this.onDisposeModel = (event) => this.untrack(event.scene); + this.onVisibilityChange = (event) => this.setVisible(event.scene, event.visible); + } + + // 监听加载 / 卸载 / 可见性,并补登记已在场的瓦片 + attach() { + if (this.attached) return; + this.attached = true; + this.tileset.addEventListener("load-model", this.onLoadModel); + this.tileset.addEventListener("dispose-model", this.onDisposeModel); + this.tileset.addEventListener("tile-visibility-change", this.onVisibilityChange); + this.tileset.forEachLoadedModel((scene, tile) => this.track(scene, this.isTileVisible(tile, scene))); + } + + // 去掉瓦片事件,不再跟踪新变化 + detach() { + if (!this.attached) return; + this.attached = false; + this.tileset.removeEventListener("load-model", this.onLoadModel); + this.tileset.removeEventListener("dispose-model", this.onDisposeModel); + this.tileset.removeEventListener("tile-visibility-change", this.onVisibilityChange); + } + + // 拆掉全部碰撞并停止跟踪 + dispose() { + this.detach(); + for (const entry of this.tracked.values()) this.removeCollider(entry); + this.tracked.clear(); + } + + // 只给当前可见且靠近的瓦片建碰撞;隐藏的(含 REPLACE 父级)立刻拆 + update(playerPos) { + let adds = 0; + let removes = 0; + for (const entry of this.tracked.values()) { + if (!entry.visible) { + this.removeCollider(entry); + continue; + } + const distance = this.distanceToTile(entry, playerPos); + if (!entry.handle && distance < TILE_ENTER_RADIUS) { + if (adds >= MAX_TILE_ADDS_PER_UPDATE) continue; + const handle = this.player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { kind: "mesh", source: entry.scene }, + follow: entry.scene, + }); + if (handle.id < 0) continue; + entry.handle = handle; + adds += 1; + } else if (entry.handle && distance > TILE_EXIT_RADIUS) { + if (removes >= MAX_TILE_REMOVES_PER_UPDATE) continue; + this.removeCollider(entry); + removes += 1; + } + } + } + + // 人物到瓦片包围球表面的距离;负值表示人在球内 + distanceToTile(entry, playerPos) { + entry.scene.updateWorldMatrix(true, false); + this.scratchCenter.copy(entry.localCenter).applyMatrix4(entry.scene.matrixWorld); + entry.scene.getWorldScale(this.scratchScale); + const worldRadius = entry.localRadius * (this.scratchScale.x || 1); + return playerPos.distanceTo(this.scratchCenter) - worldRadius; + } + + // 以 traversal.visible 为准,没有则看 scene 是否还挂在树上 + isTileVisible(tile, scene) { + if (typeof tile?.traversal?.visible === "boolean") return tile.traversal.visible; + return Boolean(scene?.parent); + } + + // 同步可见性;藏起来就拆碰撞,避免粗父级残留 + setVisible(scene, visible) { + const entry = this.tracked.get(scene) ?? this.track(scene, visible); + if (!entry) return; + entry.visible = visible; + if (!visible) this.removeCollider(entry); + } + + // 登记瓦片局部中心和半径,已跟踪则只改可见性 + track(scene, visible = Boolean(scene.parent)) { + const existing = this.tracked.get(scene); + if (existing) { + existing.visible = visible; + return existing; + } + scene.updateWorldMatrix(true, true); + this.scratchBox.setFromObject(scene); + if (this.scratchBox.isEmpty()) return null; + this.scratchBox.getBoundingSphere(this.scratchSphere); + const localCenter = scene.worldToLocal(this.scratchSphere.center.clone()); + scene.getWorldScale(this.scratchScale); + const scale = this.scratchScale.x || 1; + const entry = { + scene, + localCenter, + localRadius: this.scratchSphere.radius / scale, + visible, + handle: null, + }; + this.tracked.set(scene, entry); + return entry; + } + + // 瓦片卸载时拆碰撞并移出跟踪表 + untrack(scene) { + const entry = this.tracked.get(scene); + if (!entry) return; + this.removeCollider(entry); + this.tracked.delete(scene); + } + + // 从人物控制器移除该瓦片的运动学碰撞 + removeCollider(entry) { + if (!entry.handle) return; + this.player.removeCollider(entry.handle); + entry.handle = null; + } +} + +// 当前选中的出生城市 +function currentCity() { + return guiParams.city in CITIES ? guiParams.city : "newyork"; +} + +// 局部坐标系中的出生点 +function spawnLocalPosition() { + return new Vector3(0, 100, 0); +} + +// 渲染 Google / Cesium 版权信息 +function renderAttributions() { + if (!attributionsElement) return; + const attributions = activeLayer?.tileset.getAttributions() ?? []; + attributionsElement.replaceChildren(); + for (const attribution of attributions) { + if (attribution.type === "image") { + const image = document.createElement("img"); + image.src = String(attribution.value); + image.alt = ""; + attributionsElement.append(image); + continue; + } + if (attribution.value) { + const item = document.createElement("span"); + item.innerHTML = String(attribution.value); + attributionsElement.append(item); + } + } +} + +// 创建或更新局部坐标系 +function ensureLocalFrame(origin) { + if (!localFrame) { + localFrame = new LocalFrameManager(viewer.tileset.ellipsoid, origin); + viewer.scene.threeScene.add(localFrame.group); + return; + } + localFrame.setOrigin(origin); +} + +// 飞行时按住 Shift 切换极速 +function syncTurboMode() { + if (!player) return; + const next = player.isFlying && player.input.shift; + if (next === turboMode) return; + turboMode = next; + player.setPlayerFlySpeed(BASE_FLY_SPEED * (turboMode ? TURBO_SPEED_SCALE : 1)); +} + +// 极速时拉宽视野 +function updateTurboFov() { + const camera = viewer.camera.threeCamera; + const targetFov = turboMode ? 120 : 60; + if (Math.abs(camera.fov - targetFov) > 0.01) { + camera.fov += (targetFov - camera.fov) * 0.01; + camera.updateProjectionMatrix(); + } +} + +// 切换城市或重新出生时悬停,等待周围地形加载 +function respawn() { + ensureLocalFrame(CITIES[currentCity()]); + viewer.clock.hourUTC = CITIES[currentCity()].hourUTC; + player?.reset(spawnLocalPosition()); + if (player && !player.isFlying) player.setInput({ toggleFly: true }); + player?.setSkipCapsuleCollision(true); + skipCollisionAfterRebase = true; +} + +// 加载 Google Photorealistic 3D Tiles +function loadPhotorealisticTiles() { + ensureLocalFrame(CITIES[currentCity()]); + activeLayer = viewer.load3DTileset({ + type: "cesium-ion", + id: "photorealistic-3d-tiles", + assetId: GOOGLE_PHOTOREALISTIC_ASSET_ID, + apiToken: CESIUM_ION_TOKEN, + creasedNormals: true, + materialMode: "unlit", + }); + localFrame.group.add(activeLayer.tileset.group); + localFrame.group.updateMatrixWorld(true); +} + +// 玩家控制器 +async function initPlayer() { + const loader = new GLTFLoader(); + const gltf = await loader.loadAsync("./glb/josh.glb"); + player = new playerController(); + await player.init({ + scene: viewer.scene.threeScene, + camera: viewer.camera.threeCamera, + controls: orbitControls, + initPos: spawnLocalPosition(), + minCamDistance: 5, + maxCamDistance: 200, + playerModelConfig: { + model: gltf.scene, + animations: gltf.animations ?? [], + scale: 0.01, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + speed: BASE_WALK_SPEED, + runSpeed: BASE_RUN_SPEED, + flySpeed: BASE_FLY_SPEED, + acceleration: 500, + deceleration: 10000000, + }, + }); + player.setPlayerCapsuleDebug(guiParams.playerCapsuleDebug); + player.setColliderDebug(guiParams.colliderDebug); + // 启用实际太阳光、天空光和环境反射。 + viewer.atmosphere.setPostProcessMaterialLights(true); +} + +// 创建调试面板 +function initGUI() { + const gui = new GUI({ title: "3D Tiles Scene", width: 260 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + zIndex: "9999", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + gui.add(guiParams, "city", CITY_OPTIONS).name("City").onChange(respawn); + gui.add(guiParams, "playerCapsuleDebug").name("Capsule Debug").onChange((value) => { + player?.setPlayerCapsuleDebug(value); + }); + gui.add(guiParams, "colliderDebug").name("Collider Debug").onChange((value) => { + player?.setColliderDebug(value); + }); + gui.add(guiParams, "localAxes").name("Local Axes").onChange((value) => { + localAxes.visible = value; + }); +} + +// 启动示例 +async function start() { + await viewer.ready; + loadPhotorealisticTiles(); + await initPlayer(); + tileCollision = new TileCollisionManager(activeLayer.tileset, player); + tileCollision.attach(); + respawn(); + initGUI(); + requestAnimationFrame(loop); + // 关闭加载页面 + window.hideLoader?.(); +} + +// 每帧调用 +function loop(time) { + if (!running) return; + if (player) { + if (skipCollisionAfterRebase) { + player.setSkipCapsuleCollision(false); + skipCollisionAfterRebase = false; + } + syncTurboMode(); + void player.update(); + if (localFrame?.maybeRebase(player)) { + player.setSkipCapsuleCollision(true); + skipCollisionAfterRebase = true; + } + const pos = player.getPosition(); + if (pos) tileCollision?.update(pos); + } + updateTurboFov(); + // 局部坐标系下同步大气世界到 ECEF 的变换 + syncAtmosphereFrame(); + viewer.render(time); + renderAttributions(); + stats.update(); + requestAnimationFrame(loop); +} + +window.viewer = viewer; +void start(); + +// 页面卸载时释放资源 +window.addEventListener("beforeunload", () => { + running = false; + tileCollision?.dispose(); + player?.destroy(); + stats.dom.remove(); + viewer.destroy(); +}); diff --git a/three-player-controller/example/control-hints.js b/three-player-controller/example/control-hints.js new file mode 100644 index 0000000..6205816 --- /dev/null +++ b/three-player-controller/example/control-hints.js @@ -0,0 +1,173 @@ +// 按键文字 -> KeyboardEvent.code(可被 data-code 覆盖) +const LABEL_CODES = { + W: ["KeyW", "ArrowUp"], + A: ["KeyA", "ArrowLeft"], + S: ["KeyS", "ArrowDown"], + D: ["KeyD", "ArrowRight"], + Shift: ["ShiftLeft", "ShiftRight"], + Space: ["Space"], + RMB: ["Mouse2"], + LMB: ["Mouse0"], + 1: ["Digit1"], + 4: ["Digit4"], +}; + +// 在 GUI / 输入框里操作时不点亮 HUD +function shouldIgnore(el) { + if (!(el instanceof Element)) return false; + return Boolean(el.closest("input, textarea, select, [contenteditable='true'], .lil-gui")); +} + +// 解析 kbd 对应的键码:优先 data-code,否则按显示文字查表 +function codesFor(kbd) { + const explicit = kbd.getAttribute("data-code"); + if (explicit) return explicit.trim().split(/\s+/).filter(Boolean); + const label = kbd.textContent.replace(/\s+/g, " ").trim(); + if (LABEL_CODES[label]) return LABEL_CODES[label]; + if (/^[A-Za-z]$/.test(label)) return [`Key${label.toUpperCase()}`]; + return []; +} + +const LOOK_SCALE = 0.28; // 鼠标位移换算到指示点的系数 +const LOOK_MAX = 5; // 指示点最大偏移(px) +const LOOK_DECAY = 0.84; // 每帧回弹衰减 +const LOOK_REST = 0.04; // 小于此值视为静止 + +// 确保每个移动提示块都有鼠标图标(HTML 未写时补一个) +function ensureLookMice(hud) { + const mice = []; + for (const move of hud.querySelectorAll(".hint-move")) { + let mouse = move.querySelector(".hint-mouse"); + if (!mouse) { + const cluster = document.createElement("div"); + cluster.className = "hint-cluster"; + cluster.innerHTML = 'Look'; + move.append(cluster); + mouse = cluster.querySelector(".hint-mouse"); + } + mice.push(mouse); + } + return mice; +} + +// 绑定 HUD 按键高亮与鼠标 Look 指示 +export function bindControlHints(hud = document.querySelector(".hud")) { + if (!hud) return; + + const keys = [...hud.querySelectorAll("kbd")]; + const mice = ensureLookMice(hud); + if (!keys.length && !mice.length) return; + + const pressed = new Set(); // 当前按下的 KeyboardEvent.code / MouseN + let lookX = 0; + let lookY = 0; + let lookRaf = 0; + + // 同步 kbd.is-down + const syncKeys = () => { + for (const kbd of keys) { + kbd.classList.toggle("is-down", codesFor(kbd).some((code) => pressed.has(code))); + } + }; + + // 把 Look 偏移写到鼠标图标 + const applyLook = () => { + const x = `${lookX}px`; + const y = `${lookY}px`; + const active = Math.hypot(lookX, lookY) > LOOK_REST; + for (const mouse of mice) { + mouse.style.setProperty("--look-x", x); + mouse.style.setProperty("--look-y", y); + mouse.classList.toggle("is-active", active); + } + }; + + // 指示点回弹;静止后停掉 rAF + const tickLook = () => { + lookX *= LOOK_DECAY; + lookY *= LOOK_DECAY; + if (Math.abs(lookX) < LOOK_REST && Math.abs(lookY) < LOOK_REST) { + lookX = 0; + lookY = 0; + lookRaf = 0; + applyLook(); + return; + } + applyLook(); + lookRaf = requestAnimationFrame(tickLook); + }; + + // 键盘按下 / 抬起 + const onKey = (event, down) => { + if (down && shouldIgnore(event.target)) return; + if (down) pressed.add(event.code); + else pressed.delete(event.code); + syncKeys(); + }; + + // 鼠标按键(LMB / RMB 等) + const onMouse = (event, down) => { + if (down && shouldIgnore(event.target)) return; + const code = `Mouse${event.button}`; + if (down) pressed.add(code); + else pressed.delete(code); + syncKeys(); + }; + + // 指针锁定时始终跟踪;否则仅在按住拖拽转视角时跟踪 + const onMouseMove = (event) => { + if (!document.pointerLockElement && event.buttons === 0) return; + if (!document.pointerLockElement && shouldIgnore(event.target)) return; + lookX = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookX + event.movementX * LOOK_SCALE)); + lookY = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookY + event.movementY * LOOK_SCALE)); + applyLook(); + if (!lookRaf) lookRaf = requestAnimationFrame(tickLook); + }; + + // 失焦时清掉按下态和 Look 偏移 + const clear = () => { + pressed.clear(); + syncKeys(); + lookX = 0; + lookY = 0; + if (lookRaf) { + cancelAnimationFrame(lookRaf); + lookRaf = 0; + } + applyLook(); + }; + + window.addEventListener("keydown", (event) => onKey(event, true)); + window.addEventListener("keyup", (event) => onKey(event, false)); + window.addEventListener("mousedown", (event) => onMouse(event, true)); + window.addEventListener("mouseup", (event) => onMouse(event, false)); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("blur", clear); + document.addEventListener("visibilitychange", () => { + if (document.hidden) clear(); + }); +} + +// 按上下车切换走路 / 载具两套提示,并转调原有回调 +export function bindVehicleHintMode(player, hud = document.querySelector(".hud")) { + if (!player || !hud) return; + + const setMode = (mode) => { + hud.dataset.hintMode = mode; + }; + + setMode(player.getControllerMode?.() === 1 ? "vehicle" : "on-foot"); + + const prevEnter = player.onVehicleEnter; + const prevExit = player.onVehicleExit; + player.onVehicleEnter = (vehicle) => { + setMode("vehicle"); + prevEnter?.(vehicle); + }; + player.onVehicleExit = (vehicle) => { + setMode("on-foot"); + prevExit?.(vehicle); + }; +} + +bindControlHints(); diff --git a/three-player-controller/example/footIK.html b/three-player-controller/example/footIK.html new file mode 100644 index 0000000..accbec8 --- /dev/null +++ b/three-player-controller/example/footIK.html @@ -0,0 +1,87 @@ + + + + + + Foot IK + + + + + +
+ +
+ Character Model: + + Universal Animation Library + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+ + + + + + + diff --git a/three-player-controller/example/footIK.js b/three-player-controller/example/footIK.js new file mode 100644 index 0000000..c671558 --- /dev/null +++ b/three-player-controller/example/footIK.js @@ -0,0 +1,610 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + BoxGeometry, + DirectionalLight, + Group, + LineBasicMaterial, + LineSegments, + Mesh, + MeshStandardMaterial, + VSMShadowMap, + PerspectiveCamera, + Scene, + BufferGeometry, + Float32BufferAttribute, + Vector3, + WebGLRenderer, +} from "three"; +import { Sky } from "three/addons/objects/Sky.js"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; + +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let player; +let footIK; +let sky; +let gui; +let footIKDebugParams; +let writePelvisOffsetController; +let stats; +let leftMouseSlowMotion = false; +let rightMouseSlowMotion = false; +let savedPlayerTimeScale = 1; + +init(); + +async function init() { + const container = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 0.8; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + renderer.setAnimationLoop(animate); + container.appendChild(renderer.domElement); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 相机 + camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 500); + camera.position.set(-6, 6, 9); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 80; + controls.maxPolarAngle = Math.PI / 2.05; + controls.target.set(0, 0.8, 0); + + // 环境光 + scene.add(new AmbientLight(0xffffff, 1.8)); + + // 平行光 + const sun = new DirectionalLight(0xffffff, 2); + sun.position.set(6, 10, 7); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 0.5; + sun.shadow.camera.far = 35; + sun.shadow.camera.left = -12; + sun.shadow.camera.right = 12; + sun.shadow.camera.top = 10; + sun.shadow.camera.bottom = -10; + sun.shadow.bias = -0.0005; + scene.add(sun); + sky = new Sky(); + sky.scale.setScalar(450000); + scene.add(sky); + const skyUniforms = sky.material.uniforms; + skyUniforms["turbidity"].value = 10; + skyUniforms["rayleigh"].value = 2; + skyUniforms["mieCoefficient"].value = 0.005; + skyUniforms["mieDirectionalG"].value = 0.99; + updateSkySun(sun); + + // 测试场景 + const staticCollider = buildTestCourse(); + scene.add(staticCollider); + addGroundGrid(scene); + + // 人物控制器 + const gltfLoader = new GLTFLoader(); + const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb"); + + player = new playerController(); + await player.init({ + scene, + camera, + controls, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: staticCollider } }, + ], + initPos: new Vector3(-7.5, 0.25, -3.5), + minCamDistance: 10, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableSpringCamera: true, + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: 0.005, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + speed: 100, + runSpeed: 600, + headBoneName: "Head", + firstPersonCameraOffset: [0, 0.15, 0.12], + rotateY: Math.PI / 2, + }, + // keyMap: { toggleFly: null }, // 关闭飞行 + }); + enableModelShadows(player.playerModel); + + // 骨骼配置 + const skeleton = { + hips: "pelvis", + legs: { + left: { + upper: "thigh_l", + lower: "calf_l", + foot: "foot_l", + toe: "ball_l", + }, + right: { + upper: "thigh_r", + lower: "calf_r", + foot: "foot_r", + toe: "ball_r", + }, + }, + }; + + footIK = new FootIK({ + skeleton, + soleHalfWidth: 7, + soleToeExtend: 7, + soleSkinThickness: 1.6, + plantedHeightSpeed: 200, + penetrationLiftSpeed: 200, + predictivePlacement: true, + straightPoleEnabled: true, + }); + player.use(footIK); + + createDebugPanel(); + + window.addEventListener("resize", onResize); + window.addEventListener("mousedown", onMouseDown); + window.addEventListener("mouseup", onMouseUp); + window.addEventListener("blur", releaseRightMouseSlowMotion); + renderer.domElement.addEventListener("contextmenu", event => event.preventDefault()); + + window.hideLoader?.(); +} + +// 创建调试面板 +function createDebugPanel() { + const options = footIK.getOptions(); + const params = { + colliderDebug: false, + playerCapsuleDebug: false, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + predictivePlacement: options.predictivePlacement, + straightPoleEnabled: options.straightPoleEnabled, + pelvisOffset: 0, + writePelvisOffset: 0, + clearPelvisOffset: () => { + params.writePelvisOffset = 0; + writePelvisOffsetController.updateDisplay(); + footIK.clearPelvisOffset(); + }, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + leftPrediction: "disabled", + leftPredictionScore: "--", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + rightPrediction: "disabled", + rightPredictionScore: "--", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + plantedHeightSpeed: options.plantedHeightSpeed, + penetrationLiftSpeed: options.penetrationLiftSpeed, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxPredictionClearance: options.maxPredictionClearance, + }; + footIKDebugParams = params; + + const applyFootIKOptions = (patch) => { + footIK.configure(patch); + }; + + footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled); + + gui = new GUI({ title: "Foot IK Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player.setColliderDebug(value); + }); + collisionFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => { + player.setPlayerCapsuleDebug(value); + }); + collisionFolder.open(); + + const footIKFolder = gui.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK.setEnabled(value); + footIK.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => { + footIK.configure({ predictivePlacement: value }); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + footIK.configure({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable(); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisRaise: value }); + }); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisDrop: value }); + }); + writePelvisOffsetController = pelvisFolder.add(params, "writePelvisOffset", -60, 60, 0.1) + .name("Write Offset") + .decimals(1) + .onChange((value) => { + footIK.setPelvisOffset(value); + }); + pelvisFolder.add(params, "clearPelvisOffset").name("Clear Offset"); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootRaise: value }); + }); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootDrop: value }); + }); + footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ plantedHeightSpeed: value }); + }); + footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ penetrationLiftSpeed: value }); + }); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHalfWidth: value }); + }); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleToeExtend: value }); + }); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHeelExtend: value }); + }); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => { + applyFootIKOptions({ soleSkinThickness: value }); + }); + soleFolder.close(); + + const predictionFolder = footIKFolder.addFolder("Prediction"); + predictionFolder.add(params, "maxPredictionClearance", 0, 100, 1).name("Max Clearance").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPredictionClearance: value }); + }); + predictionFolder.close(); + footIKFolder.open(); +} + +// 更新天空太阳方向。 +function updateSkySun(light) { + sky.material.uniforms["sunPosition"].value.copy(light.position).normalize(); +} + +// 创建测试场景 +function buildTestCourse() { + const root = new Group(); + const groundMat = new MeshStandardMaterial({ color: 0xd8d5cf, roughness: 0.9 }); + const stairMats = [ + new MeshStandardMaterial({ color: 0xb8b0a1, roughness: 0.75 }), + new MeshStandardMaterial({ color: 0xc3a28f, roughness: 0.75 }), + new MeshStandardMaterial({ color: 0xa8b4c4, roughness: 0.75 }), + ]; + const rampMats = [ + new MeshStandardMaterial({ color: 0xadbca8, roughness: 0.8 }), + new MeshStandardMaterial({ color: 0x9fb7b4, roughness: 0.8 }), + new MeshStandardMaterial({ color: 0xc0b48c, roughness: 0.8 }), + ]; + const speedBumpMats = [ + new MeshStandardMaterial({ color: 0xe5b73b, roughness: 0.72 }), + new MeshStandardMaterial({ color: 0x4b4a46, roughness: 0.82 }), + ]; + const wallMat = new MeshStandardMaterial({ color: 0x9a9691, roughness: 0.88 }); + + addBox(root, new Vector3(0, -0.06, 0), new Vector3(20, 0.12, 12), groundMat); + createBoundaryWalls(root, wallMat); + + createStairs(root, new Vector3(-6.5, 0, -3.5), 0.08, stairMats[0]); + createStairs(root, new Vector3(-1.2, 0, -3.5), 0.12, stairMats[1]); + createStairs(root, new Vector3(4.1, 0, -3.5), 0.16, stairMats[2]); + + createSpeedBumpCourse(root, -0.25, speedBumpMats); + + createRamp(root, new Vector3(-6.1, 0, 3), 10, rampMats[0]); + createRamp(root, new Vector3(-0.3, 0, 3), 18, rampMats[1]); + createRamp(root, new Vector3(5.8, 0, 3), 28, rampMats[2]); + + return root; +} + +// 创建地面网格 +function addGroundGrid(root) { + const width = 20; + const depth = 12; + const step = 1; + const points = []; + + for (let x = -width / 2; x <= width / 2 + 0.0001; x += step) { + points.push(new Vector3(x, 0, -depth / 2), new Vector3(x, 0, depth / 2)); + } + for (let z = -depth / 2; z <= depth / 2 + 0.0001; z += step) { + points.push(new Vector3(-width / 2, 0, z), new Vector3(width / 2, 0, z)); + } + + const geometry = new BufferGeometry().setFromPoints(points); + const material = new LineBasicMaterial({ color: 0xbcb7af }); + const grid = new LineSegments(geometry, material); + grid.position.y = 0.004; + root.add(grid); + return grid; +} + +// 创建一组边界墙 +function createBoundaryWalls(root, material) { + const wallHeight = 1.2; + const wallThickness = 0.16; + const halfWidth = 10; + const halfDepth = 6; + const centerY = wallHeight / 2; + + addBox(root, new Vector3(0, centerY, -halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material); + addBox(root, new Vector3(0, centerY, halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material); + addBox(root, new Vector3(-halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material); + addBox(root, new Vector3(halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material); +} + +// 创建一组楼梯 +function createStairs(root, start, stepHeight, material) { + const stepCount = 7; + const stepDepth = 0.42; + const width = 1.9; + + for (let i = 0; i < stepCount; i++) { + const height = stepHeight * (i + 1); + addBox( + root, + new Vector3(start.x + i * stepDepth, height / 2, start.z), + new Vector3(stepDepth, height, width), + material, + ); + } +} + +// 在楼梯和斜坡之间创建 10 个高度统一、宽度不超过 0.32 的长方体。 +function createSpeedBumpCourse(root, centerZ, materials) { + const height = 0.140; + const depth = 1.7; + const widths = [0.14, 0.16, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.32]; + const gaps = [0.32, 0.58, 0.41, 0.75, 0.36, 0.63, 0.48, 0.71, 0.54]; + const totalWidth = widths.reduce((sum, width) => sum + width, 0) + + gaps.reduce((sum, gap) => sum + gap, 0); + let cursorX = -totalWidth / 2; + + widths.forEach((width, index) => { + addBox( + root, + new Vector3(cursorX + width / 2, height / 2, centerZ), + new Vector3(width, height, depth), + materials[index % materials.length], + ); + cursorX += width + (gaps[index] ?? 0); + }); +} + +// 创建一个斜坡 +function createRamp(root, position, angleDeg, material) { + const length = 3.4; + const width = 1.8; + const height = Math.tan(angleDeg * Math.PI / 180) * length; + const ramp = new Mesh(createRampGeometry(length, height, width), material); + ramp.position.copy(position); + ramp.castShadow = true; + ramp.receiveShadow = true; + root.add(ramp); + return ramp; +} + +// 创建斜坡碰撞几何体。 +function createRampGeometry(length, height, width) { + const halfLength = length / 2; + const halfWidth = width / 2; + const vertices = [ + -halfLength, 0, -halfWidth, + halfLength, 0, -halfWidth, + halfLength, height, -halfWidth, + -halfLength, 0, halfWidth, + halfLength, 0, halfWidth, + halfLength, height, halfWidth, + ]; + const indices = [ + 0, 1, 4, 0, 4, 3, + 1, 2, 5, 1, 5, 4, + 0, 3, 5, 0, 5, 2, + 0, 2, 1, + 3, 4, 5, + ]; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + const flatGeometry = geometry.toNonIndexed(); + flatGeometry.computeVertexNormals(); + return flatGeometry; +} + +function addBox(root, position, scale, material) { + const mesh = new Mesh(new BoxGeometry(1, 1, 1), material); + mesh.position.copy(position); + mesh.scale.copy(scale); + mesh.castShadow = true; + mesh.receiveShadow = true; + root.add(mesh); + return mesh; +} + +// 启用模型阴影 +function enableModelShadows(root) { + root?.traverse(obj => { + if (!obj.isMesh) return; + obj.castShadow = true; + obj.receiveShadow = true; + }); +} + +// 每帧调用 +function animate() { + if (player) { + player.update(); + } else { + controls.update(); + } + updateFootIKDebugPanel(); + renderer.render(scene, camera); + stats?.update(); +} + +// 更新 Foot IK 运行状态只读字段。 +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.pelvisOffset = footIK.getPelvisOffset(); + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left"); + footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left")); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); + footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right"); + footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right")); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} + +function formatPredictionScore(value) { + return Number.isFinite(value) ? value.toFixed(2) : "--"; +} + +// 鼠标右键慢动作 +function onMouseDown(event) { + if (!player) return; + if (event.button == 2) { + rightMouseSlowMotion = true; + } + if (event.button == 0) { + leftMouseSlowMotion = true; + } + updatePlayerTimeScale(); +} + +// 鼠标右键慢动作释放 +function onMouseUp(event) { + if (event.button == 2) { + rightMouseSlowMotion = false; + } + if (event.button == 0) { + leftMouseSlowMotion = false; + } + updatePlayerTimeScale(); + +} + +// 集中更新 player.timeScale +function releaseRightMouseSlowMotion() { + rightMouseSlowMotion = false; + leftMouseSlowMotion = false; + updatePlayerTimeScale(); +} + +// 更新 player.timeScale +function updatePlayerTimeScale() { + if (!player) return; + + if (leftMouseSlowMotion) { + player.timeScale = 0; + } else if (rightMouseSlowMotion) { + player.timeScale = 0.1; + } else { + player.timeScale = savedPlayerTimeScale; + } +} + +// 窗口尺寸变化 +function onResize() { + const container = document.querySelector("#container"); + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); +} diff --git a/three-player-controller/example/glTF.html b/three-player-controller/example/glTF.html new file mode 100644 index 0000000..8cc2209 --- /dev/null +++ b/three-player-controller/example/glTF.html @@ -0,0 +1,133 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ E + Enter +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+
+
+
+
+ W + A + S + D +
+ Drive +
+
+ + Look +
+
+
+
+ Shift + Drift +
+
+ Space + Brake +
+
+ E + Exit +
+
+ V + Toggle View +
+
+ R + Reset Vehicle +
+
+
+
+ +
+ Scene model: + Sketchfab +
+ Vehicle model: + Generic Sedan Car +
+ + + + + + + + + diff --git a/three-player-controller/example/glTF.js b/three-player-controller/example/glTF.js new file mode 100644 index 0000000..ddb00c1 --- /dev/null +++ b/three-player-controller/example/glTF.js @@ -0,0 +1,1128 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + CircleGeometry, + DoubleSide, + Float32BufferAttribute, + Timer, + EquirectangularReflectionMapping, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + Raycaster, + Scene, + SphereGeometry, + Vector2, + Vector3, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; +import { bindVehicleHintMode } from "./control-hints.js"; +import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; +import Stats from 'three/examples/jsm/libs/stats.module.js'; +import { CSM } from "three/examples/jsm/csm/CSM.js"; +import { createVolumeCloud, disposeVolumeCloud, updateVolumeCloud } from "./volumeCloud.js"; + +let player; +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let gltfLoader; +let guiParams = null; +let footIK = null; +let footIKDebugParams = null; +let gui = null; + +let stats = null; +let csm = null; +let currentBlobUrl = null; +let platform = null; +let platformCloud = null; +let dynamicPlatforms = []; +const dynamicPlatformXPath = [ + new Vector3(20.94, 3.70, 14.89), + new Vector3(-1.32, 7.65, 14.83), + new Vector3(-19.85, 14.38, 8.77), +]; +const dynamicPlatformXSegments = dynamicPlatformXPath.slice(0, -1).map((point, index) => ({ + from: point, + to: dynamicPlatformXPath[index + 1], + length: point.distanceTo(dynamicPlatformXPath[index + 1]), +})); +const dynamicPlatformXLength = dynamicPlatformXSegments.reduce((sum, segment) => sum + segment.length, 0); +const animTimer = new Timer(); + +let globalScale = 1; +let previewMesh = null; +let previewMode = false; +let previewHintEl = null; + +// CSM 基准参数 +const CSM_BASE = { maxFar: 30, lightNear: 0.1, lightFar: 50, lightMargin: 30, lightIntensity: 10 }; +const previewRaycaster = new Raycaster(); +const previewMouse = new Vector2(); + +const modelUrl = "./glb/crash_junction.glb"; +const pos = new Vector3(21.5, 4, 15); + +// 人物模型配置 +function easeEndsLinearMiddle(progress, easeRatio = 0.18) { + const ease = Math.min(Math.max(easeRatio, 0.001), 0.49); + const maxSpeed = 1 / (1 - ease); + if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease); + if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease); + return maxSpeed * (progress - ease / 2); +} + +// 根据整条路径的总长度定位平台 +function setPositionOnXPath(mesh, progress) { + if (!dynamicPlatformXSegments.length || dynamicPlatformXLength <= 0) return; + + let targetDistance = progress * dynamicPlatformXLength; + for (const segment of dynamicPlatformXSegments) { + if (targetDistance <= segment.length) { + mesh.position.lerpVectors(segment.from, segment.to, targetDistance / segment.length); + return; + } + targetDistance -= segment.length; + } + + mesh.position.copy(dynamicPlatformXPath[dynamicPlatformXPath.length - 1]); +} + +const PLAYER_MODELS = { + josh: { + url: "./glb/josh.glb", + scale: 0.001, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + }, + maw: { + url: "./glb/maw.glb", + scale: 0.005, + idleAnim: "idle", + walkAnim: "walk", + runAnim: "run", + jumpAnim: "jump", + flyAnim: "flying", + flyIdleAnim: "flyidle", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + }, + ual: { + url: "./glb/ual.glb", + scale: 0.001, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "Head", + rotateY: Math.PI, + firstPersonCameraOffset: [0, 0.15, 0.12], + }, +}; + +// 车辆配置 +const VEHICLE_TUNING = { + followVehicleDirection: true, + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, + chassis: { + density: 1, + linearDamping: 0.05, + angularDamping: 0.5, + clearance: 0.25, + sizeScale: { x: 0.9, y: 0.65, z: 0.9 }, + }, + suspension: { + restLength: 0.15, + maxTravel: 0.15, + stiffness: 25, + compression: 2.1, + relaxation: 2.5, + maxForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, + }, + steering: { + maxSteerAngle: Math.PI / 5, + steerTime: 0.45, + steerReturnTimeSlow: 0.55, + steerReturnTimeFast: 0.4, + highSpeedSteerScale: 0.3, + }, + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, + power: { + maxSpeed: 100, + acceleration: 5, + deceleration: 5, + }, +}; + +const VEHICLE_CONFIG = { + url: "./glb/sedan.glb", + scale: 0.1, + wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"], + followVehicleDirection: VEHICLE_TUNING.followVehicleDirection, + debug: VEHICLE_TUNING.debug, + chassis: VEHICLE_TUNING.chassis, + suspension: VEHICLE_TUNING.suspension, + steering: VEHICLE_TUNING.steering, + grip: VEHICLE_TUNING.grip, + power: VEHICLE_TUNING.power, + driverSeatPosition: new Vector3(-0.6, 1.0, 0.4), + driverSeatRotation: -Math.PI / 2, +}; + +init(); + +function setupCSMMaterial(material) { + if (!material || !csm) return; + const mats = Array.isArray(material) ? material : [material]; + mats.forEach((m) => { + csm.setupMaterial(m); + // 材质可能已在异步加载期间编译,注入 CSM 后必须刷新 shader。 + m.needsUpdate = true; + }); +} + +function createCSM(shadowMapSize, scale) { + const c = new CSM({ + maxFar: CSM_BASE.maxFar * scale, + cascades: 3, + mode: "practical", + parent: scene, + shadowMapSize, + shadowBias: -0.00001, + lightDirection: new Vector3(-1, -2, -1).normalize(), + lightIntensity: CSM_BASE.lightIntensity, + lightNear: CSM_BASE.lightNear * scale, + lightFar: CSM_BASE.lightFar * scale, + camera, + fade: true, + lightMargin: CSM_BASE.lightMargin * scale, + }); + c.lights.forEach((light, index) => { + const biasMult = Math.pow(2, index); + light.shadow.bias = -0.0001 * biasMult; + light.shadow.normalBias = 0.002 * biasMult; + }); + return c; +} + +function recreateCSM(scale) { + csm.remove(); + csm.dispose(); + const maxTextureSize = renderer.capabilities.maxTextureSize; + csm = createCSM(Math.min(2048, maxTextureSize), scale); + // 重新绑定场景内所有网格的材质 + scene.traverse((child) => { + if (child.isMesh) setupCSMMaterial(child.material); + }); +} + +function getModelScale(key) { + return PLAYER_MODELS[key].scale * globalScale; +} + +async function loadPlayerModelConfig(key) { + const m = PLAYER_MODELS[key]; + const gltf = await gltfLoader.loadAsync(m.url); + const { url, ...rest } = m; + return { + ...rest, + model: gltf.scene, + animations: gltf.animations, + scale: rest.scale * globalScale, + }; +} + +async function loadVehicleConfig(extra = {}) { + const gltf = await gltfLoader.loadAsync(VEHICLE_CONFIG.url); + const { url, ...rest } = VEHICLE_CONFIG; + return { + ...rest, + model: gltf.scene, + ...extra, + }; +} + +// 加载车辆。 +async function spawnSceneSedan() { + await player.loadVehicleModel(await loadVehicleConfig({ + scale: VEHICLE_CONFIG.scale * globalScale, + position: pos.clone().add(new Vector3(0, -0.3, -0.5)), + })); + + const sedan = player.getAllVehicles().at(-1); + sedan?.vehicleGroup?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (!material) return; + material.metalness = 0.8; + material.roughness = 0; + }); + }); + applyVehicleTuning(); +} + +// Foot IK 使用通用骨骼名识别,模型切换后由插件自动重新绑定。 +function attachFootIK() { + const p = guiParams; + footIK = new FootIK({ + enabled: p?.footIKEnabled ?? true, + debug: (p?.footIKEnabled ?? true) && (p?.footIKDebug ?? false), + maxPelvisRaise: p?.maxPelvisRaise ?? 36, + maxPelvisDrop: p?.maxPelvisDrop ?? 36, + maxFootRaise: p?.maxFootRaise ?? 60, + maxFootDrop: p?.maxFootDrop ?? 36, + soleHalfWidth: p?.soleHalfWidth ?? 7, + soleToeExtend: p?.soleToeExtend ?? 7, + soleHeelExtend: p?.soleHeelExtend ?? 3, + soleSkinThickness: p?.soleSkinThickness ?? 1.6, + straightPoleEnabled: p?.straightPoleEnabled ?? true, + }); + player.use(footIK); + if (p) footIKDebugParams = p; +} + +/** 动态球视觉 */ +function createDemoSphereMesh(color = 0x88c0d0) { + const geo = new SphereGeometry(1, 24, 16); + const pos = geo.attributes.position; + const colors = new Float32Array(pos.count * 3); + const r = ((color >> 16) & 255) / 255; + const g = ((color >> 8) & 255) / 255; + const b = (color & 255) / 255; + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i); + const y = pos.getY(i); + const band = Math.sin(x * 8 + y * 2) > 0; + const dark = band ? 0.35 : 1; + colors[i * 3] = r * dark; + colors[i * 3 + 1] = g * dark; + colors[i * 3 + 2] = b * dark; + } + geo.setAttribute("color", new Float32BufferAttribute(colors, 3)); + const mesh = new Mesh( + geo, + new MeshStandardMaterial({ vertexColors: true, roughness: 0.55, metalness: 0.05 }), + ); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +// 创建动态平台 +function createDynamicPlatform({ + position, + radius = 0.16, + cloudScale = [0.32, 0.15, 0.32], + motion = null, +}) { + // 网格 + const mesh = new Mesh( + new CircleGeometry(radius, 32), + new MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.7, metalness: 0, roughness: 0.5, side: DoubleSide }), + ); + mesh.position.copy(position); + mesh.rotation.x = -Math.PI / 2; + mesh.material.visible = false; + scene.add(mesh); + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: mesh }, + follow: mesh, + }); + + // 体积云 + const cloud = createVolumeCloud({ + scale: cloudScale, + opacity: 0.28, + steps: 80, + }); + cloud.position.set(0, 0, 0); + cloud.rotation.x = Math.PI / 2; + mesh.add(cloud); + + // 缓存数据 + const entry = { + mesh, + cloud, + basePosition: position.clone(), + motion, + }; + dynamicPlatforms.push(entry); + return entry; +} + +// 更新动态平台 +function updateDynamicPlatforms() { + const t = animTimer.getElapsed(); + dynamicPlatforms.forEach((entry) => { + const { mesh, basePosition, motion, cloud } = entry; + if (motion) { + // 还原基点 + if (motion.axis === "y") { + mesh.position.copy(basePosition); + const amount = Math.sin(t * motion.speed) * motion.distance; + // 纵向往返 + mesh.position.y = basePosition.y + amount + motion.distance; + } else if (motion.axis === "x") { + if (player.getActiveKinematicCollider()?.source === mesh && player.getIsOnGround()) { + entry.motionElapsed = (entry.motionElapsed ?? 0) + player.getCurrentDelta(); + + const phase = (entry.motionElapsed * motion.speed / Math.PI) % 2; + const rawProgress = phase <= 1 ? phase : 2 - phase; + const progress = easeEndsLinearMiddle(rawProgress); + + setPositionOnXPath(mesh, progress); + } + } + } + // 更新体积云 + updateVolumeCloud(cloud, camera); + }); +} + +// 移除平台 +function removeDynamicPlatforms() { + dynamicPlatforms.forEach(({ mesh, cloud }) => { + // 清理碰撞 + player?.removeKinematicCollider(mesh); + disposeVolumeCloud(cloud); + scene.remove(mesh); + }); + dynamicPlatforms = []; + platform = null; + platformCloud = null; +} + +async function init() { + const cont = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setSize(cont.clientWidth, cont.clientHeight); + renderer.shadowMap.enabled = false; + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.setAnimationLoop(animate); + cont.appendChild(renderer.domElement); + + // 相机 + camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000); + camera.position.copy(pos); + camera.lookAt(pos.x, pos.y, pos.z + 1); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 2000; + controls.dampingFactor = 0.1; + controls.rotateSpeed = 1; + controls.maxPolarAngle = Math.PI / 2; + controls.target.set(pos.x, pos.y, pos.z + 1); + + const maxTextureSize = renderer.capabilities.maxTextureSize; + const shadowMapSize = Math.min(2048, maxTextureSize); + // 级联阴影 + csm = createCSM(shadowMapSize, 1); + csm.lights.forEach((light, index) => { + const biasMult = Math.pow(2, index); + light.shadow.bias = -0.0001 * biasMult; + light.shadow.normalBias = 0.002 * biasMult; + }); + + // 环境光 + const ambient = new AmbientLight(0xffffff, 5); + scene.add(ambient); + + // 背景 + new HDRLoader().load( + "./img/env.hdr", + (texture) => { + texture.mapping = EquirectangularReflectionMapping; + scene.background = texture; + }, + undefined, + (err) => console.warn("HDR 加载失败:", err) + ); + + // 帧率显示 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0", + left: "0", + top: "auto", + zIndex: "9998", + }); + document.body.appendChild(stats.dom); + + // 加载场景 + initGltfLoader(); + const sceneModel = await initGLBScene(modelUrl); + renderer.render(scene, camera); + + // 人物控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + ...(await loadPlayerModelConfig("josh")), + speed: 150, + runSpeed: 600, + }, + initPos: pos, + minCamDistance: 8, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableSpringCamera: true, + springCameraTime: 0.1, + enableOverShoulderView: true, + camOverShoulderOffsetRatio: 0, + enableZoom: true, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: sceneModel } }, + ], + }); + bindVehicleHintMode(player); + attachFootIK(); + + // 先完成角色材质的 CSM 注入,避免等待车辆时用非 CSM shader 提前编译。 + player.getPlayerModel()?.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); + + await spawnSceneSedan(); + + // 创建动态平台 + const liftPlatform = createDynamicPlatform({ + position: new Vector3(22, 2.76, 9.7), + motion: { axis: "y", distance: 4, speed: 0.5 }, + }); + platform = liftPlatform.mesh; + platformCloud = liftPlatform.cloud; + + createDynamicPlatform({ + position: dynamicPlatformXPath[0], + motion: { axis: "x", distance: 3, speed: 0.1 }, + }); + + // 调试 + initGUI(); + + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyR" || e.repeat) return; + player.resetVehicle(); + }); + + window.addEventListener("resize", onWindowResize, false); + + // 关闭加载页面 + window.hideLoader(); +} + +// 初始化glb加载器 +function initGltfLoader() { + gltfLoader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(dracoLoader); + const ktx2Loader = new KTX2Loader(); + ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2Loader.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2Loader); +} + +// 加载场景 +async function initGLBScene(url, modelScale = [10, 10, 10]) { + try { + const gltf = await gltfLoader.loadAsync(url); + const model = gltf.scene; + model.name = "sceneGLB"; + model.scale.set(...modelScale); + model.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); + scene.add(model); + return model; + } catch (e) { + console.error("GLB 加载失败:", e); + return null; + } +} + +// 更换场景 +async function replaceScene(file) { + const blobUrl = URL.createObjectURL(file); + + // 退出指针锁定 + if (document.pointerLockElement) { + await new Promise((resolve) => { + document.addEventListener("pointerlockchange", resolve, { once: true }); + document.exitPointerLock(); + }); + } + + // 移除旧场景并释放 GPU 资源 + const old = scene.getObjectByName("sceneGLB"); + if (old) { + old.traverse((child) => { + if (child.isMesh) { + child.geometry?.dispose(); + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => m?.dispose()); + } + }); + scene.remove(old); + } + + // 销毁旧玩家 + removeDynamicPlatforms(); + player?.destroy(); + player = null; + footIK = null; + footIKDebugParams = null; + + // 加载新场景 + await initGLBScene(blobUrl, [1, 1, 1]); + + // 释放上一个 blob URL,保存新的 + if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); + currentBlobUrl = blobUrl; + + // 进入预览模式 + await enterPreviewMode(guiParams?.playerModel ?? "josh"); +} + +// 进入预览模式 +async function enterPreviewMode(playerModelKey) { + previewMode = true; + + const modelConfig = PLAYER_MODELS[playerModelKey]; + const gltf = await gltfLoader.loadAsync(modelConfig.url); + previewMesh = gltf.scene; + previewMesh.scale.setScalar(modelConfig.scale * globalScale); + previewMesh.visible = false; + previewMesh.traverse((child) => { + if (child.isMesh) { + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => { + m.transparent = true; + m.opacity = 0.5; + m.depthWrite = false; + }); + } + }); + scene.add(previewMesh); + + if (!previewHintEl) { + previewHintEl = document.createElement("div"); + Object.assign(previewHintEl.style, { + position: "fixed", bottom: "20px", left: "50%", + transform: "translateX(-50%)", background: "rgba(0,0,0,0.65)", + color: "#fff", padding: "12px 24px", borderRadius: "8px", + fontSize: "14px", zIndex: "9999", textAlign: "center", + display: "flex", flexDirection: "column", alignItems: "center", gap: "8px", + }); + const tip = document.createElement("span"); + tip.textContent = "移动鼠标预览人物位置 · 双击确认放置"; + const sliderRow = document.createElement("div"); + Object.assign(sliderRow.style, { display: "flex", alignItems: "center", gap: "8px" }); + const label = document.createElement("span"); + label.textContent = "人物比例:"; + // 对数刻度:slider在log10空间线性滑动,0.01~1000 各数量级间距相同 + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "-2"; // log10(0.01) + slider.max = "3"; // log10(1000) + slider.step = "0.01"; + slider.value = String(Math.log10(globalScale)); + slider.style.width = "160px"; + const sliderVal = document.createElement("span"); + sliderVal.textContent = globalScale.toFixed(2); + slider.addEventListener("input", () => { + globalScale = Math.pow(10, parseFloat(slider.value)); + sliderVal.textContent = globalScale.toFixed(2); + if (previewMesh) { + previewMesh.scale.setScalar(modelConfig.scale * globalScale); + } + }); + sliderRow.append(label, slider, sliderVal); + previewHintEl.append(tip, sliderRow); + document.body.appendChild(previewHintEl); + } else { + // 重置滑块值 + const slider = previewHintEl.querySelector("input[type=range]"); + const sliderVal = previewHintEl.querySelector("span:last-child"); + if (slider) { slider.value = String(Math.log10(globalScale)); } + if (sliderVal) { sliderVal.textContent = globalScale.toFixed(2); } + } + previewHintEl.style.display = "flex"; + + renderer.domElement.addEventListener("mousemove", onPreviewMouseMove); + renderer.domElement.addEventListener("dblclick", onPreviewDblClick); +} + +// 退出预览模式 +function exitPreviewMode() { + previewMode = false; + controls.enableZoom = true; + + if (previewMesh) { + previewMesh.traverse((child) => { + if (child.isMesh) { + child.geometry?.dispose(); + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => m?.dispose()); + } + }); + scene.remove(previewMesh); + previewMesh = null; + } + + if (previewHintEl) previewHintEl.style.display = "none"; + + renderer.domElement.removeEventListener("mousemove", onPreviewMouseMove); + renderer.domElement.removeEventListener("dblclick", onPreviewDblClick); +} + +// 预览:鼠标移动 → 射线交点跟随 +function onPreviewMouseMove(e) { + if (!previewMode || !previewMesh) return; + previewMouse.set( + (e.clientX / window.innerWidth) * 2 - 1, + -(e.clientY / window.innerHeight) * 2 + 1, + ); + previewRaycaster.setFromCamera(previewMouse, camera); + const sceneModel = scene.getObjectByName("sceneGLB"); + if (!sceneModel) return; + const hits = previewRaycaster.intersectObject(sceneModel, true); + if (hits.length > 0) { + previewMesh.position.copy(hits[0].point); + previewMesh.visible = true; + } else { + previewMesh.visible = false; + } +} + +// 预览:双击 → 确认位置,初始化玩家 +async function onPreviewDblClick() { + if (!previewMode || !previewMesh?.visible) return; + const initPos = previewMesh.position.clone(); + const spawnKey = guiParams?.playerModel ?? "josh"; + const spawnScale = getModelScale(spawnKey); + initPos.y += 180 * spawnScale * 0.75; + exitPreviewMode(); + + recreateCSM(globalScale); + + player = new playerController(); + const sceneModel = scene.getObjectByName("sceneGLB"); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + ...(await loadPlayerModelConfig(spawnKey)), + speed: guiParams?.playerSpeed ?? 150, + runSpeed: guiParams?.playerRunSpeed ?? 600, + }, + initPos, + minCamDistance: guiParams?.minCamDistance ?? 8, + maxCamDistance: guiParams?.maxCamDistance ?? 300, + camLookAtHeightRatio: guiParams?.camLookAtHeightRatio ?? 0.5, + enableSpringCamera: guiParams?.enableSpringCamera ?? true, + springCameraTime: guiParams?.springCameraTime ?? 0.1, + enableOverShoulderView: guiParams?.enableOverShoulderView ?? true, + camOverShoulderOffsetRatio: guiParams?.camOverShoulderOffsetRatio ?? 0, + thirdMouseMode: guiParams?.thirdMouseMode ?? 1, + enableZoom: guiParams?.enableZoom ?? true, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: sceneModel } }, + ], + }); + bindVehicleHintMode(player); + attachFootIK(); + + player.getPlayerModel()?.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); +} + +// 每帧调用 +function animate(timestamp) { + animTimer.update(timestamp); + + if (player) { + player.update(); + } else { + controls.update(); + } + + updateDynamicPlatforms(); + + csm?.update(); + updateFootIKDebugPanel(); + + renderer.render(scene, camera); + + stats?.update(); +} + +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + renderer.setSize(window.innerWidth, window.innerHeight); + camera.updateProjectionMatrix(); + renderer.setPixelRatio(window.devicePixelRatio * 1); +} + +// 调试 +function applyVehicleTuning() { + const t = VEHICLE_TUNING; + const vehicles = player?.getAllVehicles?.() ?? []; + for (const v of vehicles) { + player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale); + player?.setVehicleClearance?.(v, t.chassis.clearance); + const he = v.halfExtents; + const volume = 8 * he.x * he.y * he.z; + v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density)); + v.chassisBody.linearDamping = t.chassis.linearDamping; + v.chassisBody.angularDamping = t.chassis.angularDamping; + + Object.assign(v.steering, t.steering); + Object.assign(v.grip, t.grip); + v.maxSpeed = t.power.maxSpeed * v.scale; + v.acceleration = t.power.acceleration * v.scale; + v.deceleration = t.power.deceleration * v.scale; + v.followVehicleDirection = t.followVehicleDirection; + + const sus = t.suspension; + const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness)); + const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2); + v.sideFrictionStiffness = sus.sideFrictionStiffness; + + const wheelCount = v.vehicleController.numWheels(); + for (let i = 0; i < wheelCount; i++) { + v.vehicleController.setWheelSuspensionRestLength(i, rest); + v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale); + v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness); + v.vehicleController.setWheelSuspensionCompression(i, sus.compression); + v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation); + v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce); + v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip); + v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness); + v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence); + } + + if (v.physicsBoxMesh) { + if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh); + else v.vehicleGroup.remove(v.physicsBoxMesh); + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel; + if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres; + } + if (player?.vehicle?.params?.debug) { + Object.assign(player.vehicle.params.debug, t.debug); + } +} + +function initGUI() { + const options = footIK.getOptions(); + const playerScale = player.playerModelConfig.scale; + const toBasePlayerValue = (value) => value / playerScale; + const params = { + playerModel: "josh", + showShadow: renderer.shadowMap.enabled, + colliderDebug: false, + playerCapsuleDebug: false, + mouseSensitivity: player.cam.sensitivity, + gravity: toBasePlayerValue(player.gravity), + jumpHeight: toBasePlayerValue(player.jumpHeight), + playerSpeed: toBasePlayerValue(player.playerSpeed), + playerRunSpeed: toBasePlayerValue(player.playerRunSpeed), + flySpeed: toBasePlayerValue(player.playerFlySpeed), + playerAcceleration: player.playerAcceleration, + playerDeceleration: player.playerDeceleration, + timeScale: player.timeScale, + minCamDistance: toBasePlayerValue(player.cam.minDist), + maxCamDistance: toBasePlayerValue(player.cam.maxDist), + camLookAtHeightRatio: player.cam.lookAtHeightRatio, + enableOverShoulderView: player.enableOverShoulderView, + camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio, + enableSpringCamera: player.cam.enableSpringCamera, + springCameraTime: player.cam.springCameraTime, + thirdMouseMode: player.cam.mouseMode, + enableZoom: player.cam.zoomEnabled, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + straightPoleEnabled: options.straightPoleEnabled, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI, + }; + guiParams = params; + footIKDebugParams = params; + + const applyFootIKOptions = (options) => footIK?.configure(options); + + gui = new GUI({ title: "Showcase Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const sceneFolder = gui.addFolder("Scene"); + sceneFolder.add(params, "playerModel", Object.keys(PLAYER_MODELS)) + .name("Character Model") + .onChange(async (modelKey) => { + await player?.switchPlayerModel(await loadPlayerModelConfig(modelKey)); + player?.getPlayerModel()?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + if (modelKey !== "ual") return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (!material) return; + material.metalness = 0.8; + material.roughness = 0; + }); + }); + }); + sceneFolder.add(params, "showShadow").name("Shadows").onChange((value) => { + renderer.shadowMap.enabled = value; + scene.traverse((child) => { + if (!child.isMesh) return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (material) material.needsUpdate = true; + }); + }); + }); + sceneFolder.open(); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player?.setColliderDebug(value); + }); + collisionFolder.open(); + + const characterFolder = gui.addFolder("Character"); + characterFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => { + player?.setPlayerCapsuleDebug?.(value); + }); + + const movementFolder = characterFolder.addFolder("Movement"); + movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => player?.setGravity(value)); + movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => player?.setJumpHeight(value)); + movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => player?.setPlayerSpeed(value)); + movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => player?.setPlayerRunSpeed(value)); + movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => player?.setPlayerFlySpeed(value)); + movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => { + if (player) player.playerAcceleration = value; + }); + movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => { + if (player) player.playerDeceleration = value; + }); + movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => { + if (player) player.timeScale = value; + }); + movementFolder.close(); + + const characterCameraFolder = characterFolder.addFolder("Camera"); + characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => player?.setMouseSensitivity(value)); + characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => player?.setMinCamDistance(value)); + characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => player?.setMaxCamDistance(value)); + characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => player?.setCamLookAtHeightRatio(value)); + characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => player?.setOverShoulderView(value)); + characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => player?.setCamOverShoulderOffsetRatio(value)); + characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => { + if (player) player.cam.enableSpringCamera = value; + }); + characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => { + if (player) player.cam.springCameraTime = value; + }); + characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => player?.setThirdMouseMode(Number(value))); + characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => player?.setEnableZoom(value)); + characterCameraFolder.close(); + + const footIKFolder = characterFolder.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK?.setEnabled(value); + footIK?.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK?.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + applyFootIKOptions({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisRaise: value })); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisDrop: value })); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxFootRaise: value })); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxFootDrop: value })); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => applyFootIKOptions({ soleHalfWidth: value })); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleToeExtend: value })); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleHeelExtend: value })); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => applyFootIKOptions({ soleSkinThickness: value })); + soleFolder.close(); + footIKFolder.close(); + characterFolder.open(); + + const t = VEHICLE_TUNING; + const vehicleFolder = gui.addFolder("Vehicle"); + + const chassisFolder = vehicleFolder.addFolder("Chassis"); + chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning); + chassisFolder.close(); + + const vehicleCameraFolder = vehicleFolder.addFolder("Camera"); + vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning); + vehicleCameraFolder.close(); + + const vehicleDebugFolder = vehicleFolder.addFolder("Debug"); + vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning); + vehicleDebugFolder.close(); + + const drivingFolder = vehicleFolder.addFolder("Driving"); + drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning); + drivingFolder.close(); + + const handlingFolder = vehicleFolder.addFolder("Handling"); + handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((degrees) => { + t.steering.maxSteerAngle = degrees * Math.PI / 180; + applyVehicleTuning(); + }); + handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning); + handlingFolder.close(); + + const suspensionFolder = vehicleFolder.addFolder("Suspension"); + suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning); + suspensionFolder.close(); + + vehicleFolder.open(); +} + +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} diff --git a/three-player-controller/example/index.html b/three-player-controller/example/index.html new file mode 100644 index 0000000..e9d47b2 --- /dev/null +++ b/three-player-controller/example/index.html @@ -0,0 +1,383 @@ + + + + + + three-player-controller + + + + + + + + + + + +
+ Three.js + CesiumJS +
+ +

three-player-controller

+

A lightweight character controller for Three.js, accelerated by three-mesh-bvh for smooth performance in large-scale 3D scenes.

+ + + + diff --git a/three-player-controller/example/public/3DGS/3DGS.sog b/three-player-controller/example/public/3DGS/3DGS.sog new file mode 100644 index 0000000..348faaf Binary files /dev/null and b/three-player-controller/example/public/3DGS/3DGS.sog differ diff --git a/three-player-controller/example/public/audio/gun_shot.mp3 b/three-player-controller/example/public/audio/gun_shot.mp3 new file mode 100644 index 0000000..8cf9774 Binary files /dev/null and b/three-player-controller/example/public/audio/gun_shot.mp3 differ diff --git a/three-player-controller/example/public/audio/reload.mp3 b/three-player-controller/example/public/audio/reload.mp3 new file mode 100644 index 0000000..f90f2a9 Binary files /dev/null and b/three-player-controller/example/public/audio/reload.mp3 differ diff --git a/three-player-controller/example/public/glb/3dgs_collider.glb b/three-player-controller/example/public/glb/3dgs_collider.glb new file mode 100644 index 0000000..48c31d5 Binary files /dev/null and b/three-player-controller/example/public/glb/3dgs_collider.glb differ diff --git a/three-player-controller/example/public/glb/ak47.glb b/three-player-controller/example/public/glb/ak47.glb new file mode 100644 index 0000000..92768bb Binary files /dev/null and b/three-player-controller/example/public/glb/ak47.glb differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin b/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin new file mode 100644 index 0000000..5d8f9e3 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf b/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf new file mode 100644 index 0000000..8766ced --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf @@ -0,0 +1,1626 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Eyebrows_Female", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Eyebrows_Female", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "TEXCOORD_1":3, + "TEXCOORD_2":4, + "TEXCOORD_3":5, + "COLOR_0":6, + "COLOR_1":7, + "COLOR_2":8, + "JOINTS_0":9, + "WEIGHTS_0":10 + }, + "indices":11, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":12, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":912, + "max":[ + 0.06299881637096405, + 1.6869006156921387, + 0.0915706604719162 + ], + "min":[ + -0.06299881637096405, + 1.6492890119552612, + 0.051284290850162506 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":912, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":4, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":5, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":6, + "componentType":5121, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":7, + "componentType":5123, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":8, + "componentType":5123, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":9, + "componentType":5121, + "count":912, + "type":"VEC4" + }, + { + "bufferView":10, + "componentType":5126, + "count":912, + "type":"VEC4" + }, + { + "bufferView":11, + "componentType":5123, + "count":4440, + "type":"SCALAR" + }, + { + "bufferView":12, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":10944, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":10944, + "byteOffset":10944, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":21888, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":29184, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":36480, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":43776, + "target":34962 + }, + { + "buffer":0, + "byteLength":3648, + "byteOffset":51072, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":54720, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":62016, + "target":34962 + }, + { + "buffer":0, + "byteLength":3648, + "byteOffset":69312, + "target":34962 + }, + { + "buffer":0, + "byteLength":14592, + "byteOffset":72960, + "target":34962 + }, + { + "buffer":0, + "byteLength":8880, + "byteOffset":87552, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":96432 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":100592, + "uri":"Eyebrows_Female.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin new file mode 100644 index 0000000..93c9302 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf new file mode 100644 index 0000000..35a9469 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Eyebrows_Regular", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Cube.001", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":646, + "max":[ + 0.062340568751096725, + 1.7254674434661865, + 0.09447739273309708 + ], + "min":[ + -0.062340568751096725, + 1.6948596239089966, + 0.0569210946559906 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":646, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":646, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":646, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":646, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2952, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":7752, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":7752, + "byteOffset":7752, + "target":34962 + }, + { + "buffer":0, + "byteLength":5168, + "byteOffset":15504, + "target":34962 + }, + { + "buffer":0, + "byteLength":2584, + "byteOffset":20672, + "target":34962 + }, + { + "buffer":0, + "byteLength":10336, + "byteOffset":23256, + "target":34962 + }, + { + "buffer":0, + "byteLength":5904, + "byteOffset":33592, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":39496 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":43656, + "uri":"Eyebrows_Regular.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Beard.bin b/three-player-controller/example/public/glb/characters/Hair_Beard.bin new file mode 100644 index 0000000..dd57092 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Beard.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Beard.gltf b/three-player-controller/example/public/glb/characters/Hair_Beard.gltf new file mode 100644 index 0000000..ca79e6c --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Beard.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Beard", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Plane.006", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":596, + "max":[ + 0.08075305074453354, + 1.6893515586853027, + 0.0968303382396698 + ], + "min":[ + -0.08075305074453354, + 1.549536108970642, + -0.008104397915303707 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":596, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":596, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":596, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":596, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":3102, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":7152, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":7152, + "byteOffset":7152, + "target":34962 + }, + { + "buffer":0, + "byteLength":4768, + "byteOffset":14304, + "target":34962 + }, + { + "buffer":0, + "byteLength":2384, + "byteOffset":19072, + "target":34962 + }, + { + "buffer":0, + "byteLength":9536, + "byteOffset":21456, + "target":34962 + }, + { + "buffer":0, + "byteLength":6204, + "byteOffset":30992, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":37196 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":41356, + "uri":"Hair_Beard.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Buns.bin b/three-player-controller/example/public/glb/characters/Hair_Buns.bin new file mode 100644 index 0000000..edf82d5 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Buns.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Buns.gltf b/three-player-controller/example/public/glb/characters/Hair_Buns.gltf new file mode 100644 index 0000000..eb63768 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Buns.gltf @@ -0,0 +1,1545 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Buns", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Hair_Buns", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":3501, + "max":[ + 0.1507568359375, + 1.7791515588760376, + 0.10265952348709106 + ], + "min":[ + -0.1507568359375, + 1.5300521850585938, + -0.13409538567066193 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":3501, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":3501, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":3501, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":3501, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":9852, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":42012, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":42012, + "byteOffset":42012, + "target":34962 + }, + { + "buffer":0, + "byteLength":28008, + "byteOffset":84024, + "target":34962 + }, + { + "buffer":0, + "byteLength":14004, + "byteOffset":112032, + "target":34962 + }, + { + "buffer":0, + "byteLength":56016, + "byteOffset":126036, + "target":34962 + }, + { + "buffer":0, + "byteLength":19704, + "byteOffset":182052, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":201756 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":205916, + "uri":"Hair_Buns.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin b/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin new file mode 100644 index 0000000..9d617b8 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf b/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf new file mode 100644 index 0000000..41b3436 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Buzzed", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Cylinder.002", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":466, + "max":[ + 0.0772952064871788, + 1.813513159751892, + 0.08289127051830292 + ], + "min":[ + -0.07729528844356537, + 1.6441489458084106, + -0.10803250223398209 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":466, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":466, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":466, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":466, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2490, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":5592, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":5592, + "byteOffset":5592, + "target":34962 + }, + { + "buffer":0, + "byteLength":3728, + "byteOffset":11184, + "target":34962 + }, + { + "buffer":0, + "byteLength":1864, + "byteOffset":14912, + "target":34962 + }, + { + "buffer":0, + "byteLength":7456, + "byteOffset":16776, + "target":34962 + }, + { + "buffer":0, + "byteLength":4980, + "byteOffset":24232, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":29212 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":33372, + "uri":"Hair_Buzzed.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin new file mode 100644 index 0000000..70c8b95 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf new file mode 100644 index 0000000..3ab8630 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf @@ -0,0 +1,1544 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_BuzzedFemale", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Hair_BuzzedFemale", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":466, + "max":[ + 0.07933114469051361, + 1.7709248065948486, + 0.08289240300655365 + ], + "min":[ + -0.07933114469051361, + 1.600769281387329, + -0.11473648250102997 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":466, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":466, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":466, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":466, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2490, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":5592, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":5592, + "byteOffset":5592, + "target":34962 + }, + { + "buffer":0, + "byteLength":3728, + "byteOffset":11184, + "target":34962 + }, + { + "buffer":0, + "byteLength":1864, + "byteOffset":14912, + "target":34962 + }, + { + "buffer":0, + "byteLength":7456, + "byteOffset":16776, + "target":34962 + }, + { + "buffer":0, + "byteLength":4980, + "byteOffset":24232, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":29212 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":33372, + "uri":"Hair_BuzzedFemale.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Long.bin b/three-player-controller/example/public/glb/characters/Hair_Long.bin new file mode 100644 index 0000000..6dd35df Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Long.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Long.gltf b/three-player-controller/example/public/glb/characters/Hair_Long.gltf new file mode 100644 index 0000000..cbe0388 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Long.gltf @@ -0,0 +1,1545 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Long", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Hair_Long", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":3080, + "max":[ + 0.12235528230667114, + 1.7772293090820312, + 0.10885592550039291 + ], + "min":[ + -0.12235528230667114, + 1.5008693933486938, + -0.1615893542766571 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":3080, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":3080, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":3080, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":3080, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":8718, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":36960, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":36960, + "byteOffset":36960, + "target":34962 + }, + { + "buffer":0, + "byteLength":24640, + "byteOffset":73920, + "target":34962 + }, + { + "buffer":0, + "byteLength":12320, + "byteOffset":98560, + "target":34962 + }, + { + "buffer":0, + "byteLength":49280, + "byteOffset":110880, + "target":34962 + }, + { + "buffer":0, + "byteLength":17436, + "byteOffset":160160, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":177596 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":181756, + "uri":"Hair_Long.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin new file mode 100644 index 0000000..7acd443 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf new file mode 100644 index 0000000..1f62944 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_SimpleParted", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Plane.002", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":757, + "max":[ + 0.089996337890625, + 1.8397216796875, + 0.07879609614610672 + ], + "min":[ + -0.08542495965957642, + 1.660560131072998, + -0.10815342515707016 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":757, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":757, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":757, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":757, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":3903, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":9084, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":9084, + "byteOffset":9084, + "target":34962 + }, + { + "buffer":0, + "byteLength":6056, + "byteOffset":18168, + "target":34962 + }, + { + "buffer":0, + "byteLength":3028, + "byteOffset":24224, + "target":34962 + }, + { + "buffer":0, + "byteLength":12112, + "byteOffset":27252, + "target":34962 + }, + { + "buffer":0, + "byteLength":7806, + "byteOffset":39364, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":47172 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":51332, + "uri":"Hair_SimpleParted.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin new file mode 100644 index 0000000..6e6507c Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin differ diff --git a/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf new file mode 100644 index 0000000..0b6b8d1 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.20467473566532135,-4.759437705400649e-21,1.2439442348830942e-21,0.9788300395011902],"translation":[-1.5903435849255598e-27,0.0711912140250206,2.0489096641540527e-08]},{"children":[0],"name":"neck_01","rotation":[0.3088374435901642,8.337463586651673e-21,-5.849849968646638e-21,0.95111483335495],"translation":[-1.0141478263557337e-20,0.19815585017204285,2.60770320892334e-08]},{"name":"index_04_leaf_l","rotation":[-8.381903171539307e-09,1,-1.5432005174599583e-14,3.6517765238386346e-06],"translation":[9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[2],"name":"index_03_l","rotation":[1.0023382124302316e-09,7.603918037758783e-13,-0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[3],"name":"index_02_l","rotation":[1.8987577732332284e-08,1.4024364247688936e-07,-0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[4],"name":"index_01_l","rotation":[-0.0033091865479946136,0.7071018815040588,-0.003309202380478382,0.7070961594581604],"translation":[0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_l","rotation":[2.0489096641540527e-08,1,6.335320587363621e-14,3.6517765238386346e-06],"translation":[2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[6],"name":"middle_03_l","rotation":[2.420814304571195e-08,1.4173854798182894e-11,-0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[-4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[7],"name":"middle_02_l","rotation":[-2.949975197452659e-08,1.402399334438087e-07,0.0038676888216286898,0.9999924898147583],"translation":[1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[8],"name":"middle_01_l","rotation":[-0.007979890331625938,0.7070646286010742,-0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_l","rotation":[8.381903171539307e-09,1,5.647658583201112e-14,3.65177675121231e-06],"translation":[-1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[10],"name":"pinky_03_l","rotation":[-2.1800540039862426e-08,5.25581791629981e-12,0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[-1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[11],"name":"pinky_02_l","rotation":[1.03155857189563e-08,1.4017835781032773e-07,-0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[-2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[12],"name":"pinky_01_l","rotation":[-0.01044775452464819,0.7070325016975403,-0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_l","rotation":[2.7474015951156616e-08,1,1.0721373739979931e-13,3.65177675121231e-06],"translation":[9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[14],"name":"ring_03_l","rotation":[2.6234243222234e-08,4.3393351412124304e-11,-0.0034884933847934008,0.9999939203262329],"translation":[7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[15],"name":"ring_02_l","rotation":[-1.8197205520209536e-08,1.4009778226409253e-07,0.002474082401022315,0.9999969601631165],"translation":[-9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[16],"name":"ring_01_l","rotation":[0.0025809321086853743,0.7071049213409424,0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_l","rotation":[-1.2782692238033633e-08,0.48539119958877563,2.0375746601075662e-07,0.8742970824241638],"scale":[0.9999999403953552,1,1],"translation":[-1.2670352589339018e-07,0.03127264976501465,3.073364496231079e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-0.0012816648231819272,5.811178561998531e-05,0.0002541383437346667,0.9999991655349731],"scale":[1,1,0.9999999403953552],"translation":[2.2436142899096012e-07,0.035620346665382385,5.122274160385132e-09]},{"children":[19],"name":"thumb_02_l","rotation":[0.0019128327257931232,0.00019216042710468173,-0.0004484721284825355,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[1.453081495128572e-07,0.03582108020782471,-1.30385160446167e-08]},{"children":[20],"name":"thumb_01_l","rotation":[0.263997346162796,0.9450139999389648,0.1882738322019577,0.04250776767730713],"scale":[1,1.0000004768371582,1],"translation":[0.009999980218708515,-0.0028757674153894186,0.029958141967654228]},{"children":[5,9,13,17,21],"name":"hand_l","rotation":[-0.02237604185938835,-2.264418526465306e-06,-3.032003448311116e-08,0.9997496008872986],"translation":[1.532122695380167e-08,0.24930401146411896,5.277343007037416e-09]},{"children":[22],"name":"lowerarm_l","rotation":[0.023802155628800392,8.208974031731486e-05,-1.8556972918304382e-06,0.9997166991233826],"translation":[-4.793889729626244e-08,0.2403421401977539,-4.220055416226387e-09]},{"children":[23],"name":"upperarm_l","rotation":[0.17232586443424225,0.6858593225479126,-0.1717943400144577,0.68584805727005],"scale":[1,1,1.0000001192092896],"translation":[-0.0016045584343373775,0.1410660296678543,-0.01228591613471508]},{"children":[24],"name":"clavicle_l","rotation":[-0.5471324324607849,-0.3204275965690613,-0.39127784967422485,0.666988730430603],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.02930000051856041,0.13370320200920105,0.0448194220662117]},{"name":"index_04_leaf_r","rotation":[-8.381903171539307e-09,-1,1.5432005174599583e-14,3.6517765238386346e-06],"translation":[-9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[26],"name":"index_03_r","rotation":[1.0023382124302316e-09,-7.603918037758783e-13,0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[-4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[27],"name":"index_02_r","rotation":[1.8987577732332284e-08,-1.4024364247688936e-07,0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[-8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[28],"name":"index_01_r","rotation":[-0.0033091865479946136,-0.7071018815040588,0.003309202380478382,0.7070961594581604],"translation":[-0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_r","rotation":[2.0489096641540527e-08,-1,-6.335320587363621e-14,3.6517765238386346e-06],"translation":[-2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[30],"name":"middle_03_r","rotation":[2.420814304571195e-08,-1.4173854798182894e-11,0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[31],"name":"middle_02_r","rotation":[-2.949975197452659e-08,-1.402399334438087e-07,-0.0038676888216286898,0.9999924898147583],"translation":[-1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[32],"name":"middle_01_r","rotation":[-0.007979890331625938,-0.7070646286010742,0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[-0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_r","rotation":[8.381903171539307e-09,-1,-5.647658583201112e-14,3.65177675121231e-06],"translation":[1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[34],"name":"pinky_03_r","rotation":[-2.1800540039862426e-08,-5.25581791629981e-12,-0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[35],"name":"pinky_02_r","rotation":[1.03155857189563e-08,-1.4017835781032773e-07,0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[36],"name":"pinky_01_r","rotation":[-0.01044775452464819,-0.7070325016975403,0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[-0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_r","rotation":[2.7474015951156616e-08,-1,-1.0721373739979931e-13,3.65177675121231e-06],"translation":[-9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[38],"name":"ring_03_r","rotation":[2.6234243222234e-08,-4.3393351412124304e-11,0.0034884933847934008,0.9999939203262329],"translation":[-7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[39],"name":"ring_02_r","rotation":[-1.8197205520209536e-08,-1.4009778226409253e-07,-0.002474082401022315,0.9999969601631165],"translation":[9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[40],"name":"ring_01_r","rotation":[0.0025809321086853743,-0.7071049213409424,-0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[-0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_r","rotation":[-2.5565388028780944e-08,-0.48539119958877563,-1.9393742434203887e-07,0.8742970824241638],"scale":[0.9999998807907104,0.9999999403953552,0.9999999403953552],"translation":[1.2622331269085407e-07,0.031272709369659424,3.073364496231079e-08]},{"children":[42],"name":"thumb_03_r","rotation":[-0.0012816853122785687,-5.809385038446635e-05,-0.00025414914125576615,0.9999991655349731],"scale":[1.0000001192092896,1.0000001192092896,1],"translation":[-1.0542134987190366e-07,0.03562036156654358,-8.847564458847046e-09]},{"children":[43],"name":"thumb_02_r","rotation":[0.001912830863147974,-0.00019218510715290904,0.0004484682867769152,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[-1.453081495128572e-07,0.03582108020782471,-1.30385160446167e-08]},{"children":[44],"name":"thumb_01_r","rotation":[0.263997346162796,-0.9450139999389648,-0.1882738322019577,0.04250776767730713],"scale":[1,1.0000004768371582,1],"translation":[-0.009999980218708515,-0.0028757674153894186,0.029958141967654228]},{"children":[29,33,37,41,45],"name":"hand_r","rotation":[-0.02237604185938835,2.264418526465306e-06,3.032003448311116e-08,0.9997496008872986],"translation":[-1.532122695380167e-08,0.24930401146411896,5.277343007037416e-09]},{"children":[46],"name":"lowerarm_r","rotation":[0.023802155628800392,-8.208974031731486e-05,1.8556972918304382e-06,0.9997166991233826],"translation":[4.793889729626244e-08,0.2403421401977539,-4.220055416226387e-09]},{"children":[47],"name":"upperarm_r","rotation":[0.17232586443424225,-0.6858593225479126,0.1717943400144577,0.68584805727005],"scale":[1,1,1.0000001192092896],"translation":[0.0016045584343373775,0.1410660296678543,-0.01228591613471508]},{"children":[48],"name":"clavicle_r","rotation":[-0.5471324324607849,0.3204275965690613,0.39127784967422485,0.666988730430603],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-0.02930000051856041,0.13370320200920105,0.0448194220662117]},{"children":[1,25,49],"name":"spine_03","rotation":[-0.10099761933088303,4.5928396448426806e-14,-4.5242200827702583e-13,0.9948866963386536],"scale":[1,1,0.9999999403953552],"translation":[4.6735704609246064e-20,0.12640145421028137,3.4924596548080444e-10]},{"children":[50],"name":"spine_02","rotation":[-0.055728573352098465,-4.992229150957428e-05,-2.7864282401424134e-06,0.998445987701416],"scale":[1,0.9999998211860657,0.9999998807907104],"translation":[-4.263256414560601e-13,0.11034487932920456,8.381903171539307e-09]},{"children":[51],"name":"spine_01","rotation":[-0.06393160670995712,4.989771332475357e-05,-3.19658010994317e-06,0.9979543089866638],"scale":[1,0.9999999403953552,1],"translation":[0,0.12655510008335114,-9.313225746154785e-10]},{"name":"ball_leaf_l","rotation":[0,0.00017269056115765125,5.421010862427522e-20,1],"scale":[1,1.0000001192092896,1],"translation":[5.525180313270539e-10,0.06809987872838974,-1.204170985147357e-09]},{"children":[53],"name":"ball_l","rotation":[-3.296312570455484e-05,0.9816133975982666,-0.1908797174692154,0.00016951537691056728],"scale":[1,1.0000001192092896,0.9999998807907104],"translation":[1.4901161193847656e-08,0.14916980266571045,-6.51925802230835e-09]},{"children":[54],"name":"foot_l","rotation":[-0.5978578329086304,0,0,0.8016021847724915],"scale":[1,1.0000001192092896,1],"translation":[-7.450580596923828e-09,0.4661003053188324,2.7939677238464355e-09]},{"children":[55],"name":"calf_l","rotation":[0.0712229460477829,0,0,0.9974604249000549],"scale":[1,0.9999999403953552,1],"translation":[-7.450580596923828e-09,0.4098641872406006,1.3969838619232178e-09]},{"children":[56],"name":"thigh_l","rotation":[0.9893749952316284,0,0,0.1453862488269806],"scale":[1,1,1.0000014305114746],"translation":[0.11140000075101852,0.010460092686116695,-0.008893024176359177]},{"name":"ball_leaf_r","rotation":[0,0.00017269056115765125,5.421010862427522e-20,1],"scale":[1,1.0000001192092896,1],"translation":[5.525180313270539e-10,0.06809987872838974,-1.5897967386990786e-09]},{"children":[58],"name":"ball_r","rotation":[-3.296312570455484e-05,0.9816133975982666,-0.1908797174692154,0.00016951537691056728],"scale":[1,1.0000001192092896,0.9999998807907104],"translation":[-1.4901161193847656e-08,0.14916980266571045,-6.51925802230835e-09]},{"children":[59],"name":"foot_r","rotation":[-0.5978578329086304,0,0,0.8016021847724915],"scale":[1,1.0000001192092896,1],"translation":[7.450580596923828e-09,0.4661003053188324,2.7939677238464355e-09]},{"children":[60],"name":"calf_r","rotation":[0.0712229460477829,0,0,0.9974604249000549],"scale":[1,0.9999999403953552,1],"translation":[7.450580596923828e-09,0.4098641872406006,1.3969838619232178e-09]},{"children":[61],"name":"thigh_r","rotation":[0.9893749952316284,0,0,0.1453862488269806],"scale":[1,1,1.0000014305114746],"translation":[-0.11140000075101852,0.010460092686116695,-0.008893024176359177]},{"children":[52,57,62],"name":"pelvis","rotation":[0.7879695892333984,0,0,0.6157141923904419],"translation":[0,0.045700013637542725,0.9318000078201294]},{"children":[63],"name":"root","rotation":[-0.7071067690849304,0,0,0.7071067690849304]},{"mesh":0,"name":"Eyebrows","skin":0},{"mesh":1,"name":"Eyes","skin":0},{"mesh":2,"name":"Superhero_Female","skin":0},{"children":[65,66,67,64],"name":"Armature"}],"materials":[{"doubleSided":true,"name":"MI_Hair_2","normalTexture":{"index":0},"pbrMetallicRoughness":{"baseColorTexture":{"index":1},"metallicFactor":0,"roughnessFactor":0.8227272629737854}},{"doubleSided":true,"name":"MI_Eyes","normalTexture":{"index":2},"pbrMetallicRoughness":{"baseColorTexture":{"index":3},"metallicFactor":0,"roughnessFactor":0.34545454382896423}},{"doubleSided":true,"name":"MI_Superhero_Female","normalTexture":{"index":4},"pbrMetallicRoughness":{"baseColorTexture":{"index":5},"metallicFactor":0,"metallicRoughnessTexture":{"index":6}}}],"meshes":[{"name":"Eyebrows","primitives":[{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2,"TEXCOORD_1":3,"TEXCOORD_2":4,"TEXCOORD_3":5,"COLOR_0":6,"COLOR_1":7,"JOINTS_0":8,"WEIGHTS_0":9},"indices":10,"material":0}]},{"name":"Eyes","primitives":[{"attributes":{"POSITION":12,"NORMAL":13,"TEXCOORD_0":14,"TEXCOORD_1":15,"TEXCOORD_2":16,"TEXCOORD_3":17,"COLOR_0":18,"COLOR_1":19,"JOINTS_0":20,"WEIGHTS_0":21},"indices":22,"material":1}]},{"name":"Superhero_Female","primitives":[{"attributes":{"POSITION":23,"NORMAL":24,"TEXCOORD_0":25,"TEXCOORD_1":26,"TEXCOORD_2":27,"TEXCOORD_3":28,"TEXCOORD_4":29,"COLOR_0":30,"COLOR_1":31,"COLOR_2":32,"JOINTS_0":33,"WEIGHTS_0":34},"indices":35,"material":2}]}],"textures":[{"sampler":0,"source":0},{"sampler":0,"source":1},{"sampler":0,"source":2},{"sampler":0,"source":3},{"sampler":0,"source":4},{"sampler":0,"source":5},{"sampler":0,"source":6}],"images":[{"mimeType":"image/png","name":"T_Hair_2_Normal","uri":"T_Hair_2_Normal.png"},{"mimeType":"image/png","name":"T_Hair_2_BaseColor","uri":"T_Hair_2_BaseColor.png"},{"mimeType":"image/png","name":"T_Eye_Normal.png","uri":"T_Eye_Normal.png"},{"mimeType":"image/png","name":"T_Eye_Brown","uri":"T_Eye_Brown.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Normal","uri":"T_Superhero_Female_Normal.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Dark_BaseColor","uri":"T_Superhero_Female_Dark_BaseColor.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Roughness","uri":"T_Superhero_Female_Roughness.png"}],"skins":[{"inverseBindMatrices":11,"joints":[64,63,52,51,50,1,0,25,24,23,22,5,4,3,2,9,8,7,6,13,12,11,10,17,16,15,14,21,20,19,18,49,48,47,46,29,28,27,26,33,32,31,30,37,36,35,34,41,40,39,38,45,44,43,42,57,56,55,54,53,62,61,60,59,58],"name":"Armature"}],"accessors":[{"bufferView":0,"componentType":5126,"count":912,"max":[0.06299881637096405,1.6869006156921387,0.0915711298584938],"min":[-0.06299881637096405,1.6492890119552612,0.05128476396203041],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":912,"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":3,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":4,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":5,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":6,"componentType":5121,"count":912,"normalized":true,"type":"VEC4"},{"bufferView":7,"componentType":5123,"count":912,"normalized":true,"type":"VEC4"},{"bufferView":8,"componentType":5121,"count":912,"type":"VEC4"},{"bufferView":9,"componentType":5126,"count":912,"type":"VEC4"},{"bufferView":10,"componentType":5123,"count":4440,"type":"SCALAR"},{"bufferView":11,"componentType":5126,"count":65,"type":"MAT4"},{"bufferView":12,"componentType":5126,"count":556,"max":[0.05105403810739517,1.672553300857544,0.07533310353755951],"min":[-0.05105403810739517,1.6395376920700073,0.04291451722383499],"type":"VEC3"},{"bufferView":13,"componentType":5126,"count":556,"type":"VEC3"},{"bufferView":14,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":15,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":16,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":17,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":18,"componentType":5121,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":19,"componentType":5123,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":20,"componentType":5121,"count":556,"type":"VEC4"},{"bufferView":21,"componentType":5126,"count":556,"type":"VEC4"},{"bufferView":22,"componentType":5123,"count":2304,"type":"SCALAR"},{"bufferView":23,"componentType":5126,"count":7376,"max":[0.83443284034729,1.7666438817977905,0.1402275115251541],"min":[-0.8344329595565796,-0.008407175540924072,-0.1624695360660553],"type":"VEC3"},{"bufferView":24,"componentType":5126,"count":7376,"type":"VEC3"},{"bufferView":25,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":26,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":27,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":28,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":29,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":30,"componentType":5121,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":31,"componentType":5123,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":32,"componentType":5123,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":33,"componentType":5121,"count":7376,"type":"VEC4"},{"bufferView":34,"componentType":5126,"count":7376,"type":"VEC4"},{"bufferView":35,"componentType":5123,"count":38436,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteLength":10944,"byteOffset":0,"target":34962},{"buffer":0,"byteLength":10944,"byteOffset":10944,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":21888,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":29184,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":36480,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":43776,"target":34962},{"buffer":0,"byteLength":3648,"byteOffset":51072,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":54720,"target":34962},{"buffer":0,"byteLength":3648,"byteOffset":62016,"target":34962},{"buffer":0,"byteLength":14592,"byteOffset":65664,"target":34962},{"buffer":0,"byteLength":8880,"byteOffset":80256,"target":34963},{"buffer":0,"byteLength":4160,"byteOffset":89136},{"buffer":0,"byteLength":6672,"byteOffset":93296,"target":34962},{"buffer":0,"byteLength":6672,"byteOffset":99968,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":106640,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":111088,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":115536,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":119984,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":124432,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":126656,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":131104,"target":34962},{"buffer":0,"byteLength":8896,"byteOffset":133328,"target":34962},{"buffer":0,"byteLength":4608,"byteOffset":142224,"target":34963},{"buffer":0,"byteLength":88512,"byteOffset":146832,"target":34962},{"buffer":0,"byteLength":88512,"byteOffset":235344,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":323856,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":382864,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":441872,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":500880,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":559888,"target":34962},{"buffer":0,"byteLength":29504,"byteOffset":618896,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":648400,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":707408,"target":34962},{"buffer":0,"byteLength":29504,"byteOffset":766416,"target":34962},{"buffer":0,"byteLength":118016,"byteOffset":795920,"target":34962},{"buffer":0,"byteLength":76872,"byteOffset":913936,"target":34963}],"samplers":[{"magFilter":9729,"minFilter":9987}],"buffers":[{"byteLength":990808,"uri":"Superhero_Female_FullBody.bin"}]} \ No newline at end of file diff --git a/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin new file mode 100644 index 0000000..54309de Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin differ diff --git a/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf new file mode 100644 index 0000000..472dd07 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.13207007944583893,0,0,0.991240382194519],"translation":[0,0.08285234868526459,9.313225746154785e-09]},{"children":[0],"name":"neck_01","rotation":[0.25725916028022766,0,0,0.9663424491882324],"translation":[0,0.21509313583374023,1.862645149230957e-08]},{"name":"index_04_leaf_l","rotation":[-1.789221437675792e-09,0.9998229742050171,3.712300440383842e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[-1.1732481652870774e-09,0.024110054597258568,-1.3058706826996058e-07]},{"children":[2],"name":"index_03_l","rotation":[1.9416402210481465e-06,-7.145895324356388e-06,-0.00018566079961601645,1],"scale":[1,1,1.0000001192092896],"translation":[-5.757101462222636e-10,0.027911342680454254,5.889705789741129e-08]},{"children":[3],"name":"index_02_l","rotation":[3.8516853351211466e-07,4.9490859964862466e-05,-0.0005330402054823935,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[-8.440110832452774e-10,0.032612428069114685,1.2031887308694422e-07]},{"children":[4],"name":"index_01_l","rotation":[0.008505093865096569,0.7068116664886475,-0.01852414943277836,0.7071080803871155],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.007159475702792406,0.1174025684595108,0.040643345564603806]},{"name":"middle_04_leaf_l","rotation":[-1.5724443969133972e-08,0.9998255372047424,5.420716675530457e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[2.2610038286074996e-09,0.030519673600792885,1.955777406692505e-07]},{"children":[6],"name":"middle_03_l","rotation":[-4.686012857746391e-07,-5.432930265669711e-05,-0.0014198303688317537,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-5.793481250293553e-10,0.030116640031337738,-6.39629433862865e-08]},{"children":[7],"name":"middle_02_l","rotation":[5.612571953861334e-07,7.84045914770104e-05,0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[-4.098183126188815e-09,0.03752607852220535,-1.7083948478102684e-08]},{"children":[8],"name":"middle_01_l","rotation":[0.005067416001111269,0.7067627906799316,-0.02195817232131958,0.7070916295051575],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.004682549275457859,0.1154387816786766,0.015113381668925285]},{"name":"pinky_04_leaf_l","rotation":[-6.453817924523264e-09,0.9998291730880737,2.147510969052746e-07,0.018483733758330345],"translation":[2.8803697205148637e-09,0.021523315459489822,-3.95521055907011e-08]},{"children":[10],"name":"pinky_03_l","rotation":[3.3508222259115428e-06,-0.00013744289753958583,0.0004660093109123409,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[-4.249159246683121e-09,0.027430830523371696,1.7808633856475353e-07]},{"children":[11],"name":"pinky_02_l","rotation":[3.9539517615594377e-07,9.536039578961208e-05,-0.0015674401074647903,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[1.1932570487260818e-08,0.03163102641701698,-1.7530692275613546e-07]},{"children":[12],"name":"pinky_01_l","rotation":[0.002601056592538953,0.7066821455955505,-0.024414697661995888,0.7071049809455872],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.0060377283953130245,0.09854141622781754,-0.03461737558245659]},{"name":"ring_04_leaf_l","rotation":[8.940817863845041e-09,0.9998185634613037,5.402291662903735e-07,0.019049758091568947],"translation":[-9.167706593871117e-09,0.02910165674984455,5.82986103836447e-09]},{"children":[14],"name":"ring_03_l","rotation":[3.849756012641592e-06,-0.00025297756656073034,-0.003387551987543702,0.9999942183494568],"translation":[1.204170985147357e-09,0.028300179168581963,5.35583239980042e-08]},{"children":[15],"name":"ring_02_l","rotation":[-4.73642239740002e-06,0.00021853116049896926,0.00239976542070508,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[7.916241884231567e-09,0.03600123152136803,-8.07840478955768e-08]},{"children":[16],"name":"ring_01_l","rotation":[0.015323746018111706,0.7069170475006104,-0.011713176034390926,0.7070334553718567],"scale":[1,0.9999997615814209,1],"translation":[0.004751814063638449,0.10940060019493103,-0.012370164506137371]},{"name":"thumb_04_leaf_l","rotation":[1.285916511051255e-07,0.38270875811576843,-4.782022244853579e-08,0.9238690137863159],"scale":[1,0.9999999403953552,1],"translation":[-1.0035000741481781e-07,0.034558095037937164,2.1187588572502136e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-5.349284037947655e-08,-2.9730843380093575e-05,1.7080456018447876e-06,1],"translation":[-1.2048985809087753e-08,0.03549090400338173,-2.9336661100387573e-08]},{"children":[19],"name":"thumb_02_l","rotation":[6.149784894660115e-07,4.712928785011172e-06,-3.2112004646478454e-06,1],"scale":[1,0.9999998807907104,1],"translation":[-1.050066202878952e-07,0.039519939571619034,3.5390257835388184e-08]},{"children":[20],"name":"thumb_01_l","rotation":[-0.24446295201778412,-0.94356769323349,-0.21606169641017914,0.05687990039587021],"scale":[1,1.000000238418579,1],"translation":[0.0274813212454319,0.03381355106830597,0.04106312245130539]},{"children":[5,9,13,17,21],"name":"hand_l","rotation":[-0.015601660124957561,-4.1717584053913015e-07,-9.051257521264233e-09,0.9998782873153687],"scale":[1,1,0.9999999403953552],"translation":[-1.5123794128157897e-07,0.243594229221344,2.6994939616997726e-09]},{"children":[22],"name":"lowerarm_l","rotation":[0.030732227489352226,4.16175062127877e-05,-1.2497959005486337e-06,0.9995276927947998],"translation":[3.7845666156499647e-08,0.251115083694458,-5.1513779908418655e-09]},{"children":[23],"name":"upperarm_l","rotation":[0.149803027510643,0.6911307573318481,-0.14938844740390778,0.6910719275474548],"scale":[0.9999998211860657,0.999999463558197,0.9999998807907104],"translation":[-0.009135720320045948,0.20573900640010834,-0.03967121243476868]},{"children":[24],"name":"clavicle_l","rotation":[-0.5287374258041382,-0.32910192012786865,-0.41380804777145386,0.663996696472168],"scale":[1,0.9999998807907104,1],"translation":[0.03139999881386757,0.1737176477909088,0.06732845306396484]},{"name":"index_04_leaf_r","rotation":[-1.934766347133632e-09,-0.9998229742050171,-3.711606666456646e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[1.1732481652870774e-09,0.02411006949841976,-1.3124372344464064e-07]},{"children":[26],"name":"index_03_r","rotation":[1.943404186022235e-06,7.1459307946497574e-06,0.00018566078506410122,1],"scale":[1,1,1.0000001192092896],"translation":[4.911271389573812e-10,0.027911365032196045,6.19775164523162e-08]},{"children":[27],"name":"index_02_r","rotation":[3.823500378530298e-07,-4.935889955959283e-05,0.0005330423591658473,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[1.2951204553246498e-09,0.03261244669556618,1.2260898074600846e-07]},{"children":[28],"name":"index_01_r","rotation":[0.008505097590386868,-0.706811785697937,0.01852414757013321,0.7071079611778259],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[-0.007159472443163395,0.117402583360672,0.040643345564603806]},{"name":"middle_04_leaf_r","rotation":[-1.5782660511831637e-08,-0.9998255372047424,-5.4032970098205624e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[-2.2610038286074996e-09,0.030519699677824974,1.9423532648943365e-07]},{"children":[30],"name":"middle_03_r","rotation":[-4.687495334110281e-07,5.432928810478188e-05,0.001419831532984972,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-1.1996235116384923e-09,0.030116606503725052,-6.529444362968206e-08]},{"children":[31],"name":"middle_02_r","rotation":[5.584595896834799e-07,-7.839182944735512e-05,-0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[4.5683918870054185e-09,0.03752603381872177,-2.081651473417878e-08]},{"children":[32],"name":"middle_01_r","rotation":[0.005067416001111269,-0.7067628502845764,0.02195817045867443,0.7070915699005127],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-0.004682543221861124,0.1154387965798378,0.015113386325538158]},{"name":"pinky_04_leaf_r","rotation":[-9.396038613829205e-09,-0.9998291730880737,-2.1258703952753422e-07,0.018483733758330345],"translation":[-1.3542376109398901e-09,0.021523330360651016,-4.5925844460725784e-08]},{"children":[34],"name":"pinky_03_r","rotation":[3.3533979149069637e-06,0.0001374428829876706,-0.00046601047506555915,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[3.7834979593753815e-10,0.027430785819888115,1.7545244190841913e-07]},{"children":[35],"name":"pinky_02_r","rotation":[3.925948135474755e-07,-9.534749551676214e-05,0.0015674429014325142,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-8.046299626585096e-09,0.03163103014230728,-5.98374754190445e-08]},{"children":[36],"name":"pinky_01_r","rotation":[0.0026010593865066767,-0.7066822052001953,0.024414699524641037,0.7071049213409424],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[-0.0060377162881195545,0.09854142367839813,-0.03461737558245659]},{"name":"ring_04_leaf_r","rotation":[8.93445140093263e-09,-0.9998185634613037,-5.40070118404401e-07,0.019049877300858498],"scale":[0.9999999403953552,1,0.9999999403953552],"translation":[6.257323548197746e-09,0.029101617634296417,2.222896000603214e-08]},{"children":[38],"name":"ring_03_r","rotation":[3.850367193081183e-06,0.00025285835727117956,0.0033875522203743458,0.9999942183494568],"translation":[-1.204170985147357e-09,0.028300197795033455,5.154106474947184e-08]},{"children":[39],"name":"ring_02_r","rotation":[-4.736421942652669e-06,-0.0002185181510867551,-0.002399763325229287,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[-4.307366907596588e-09,0.036001186817884445,-8.079041435848922e-08]},{"children":[40],"name":"ring_01_r","rotation":[0.01532374694943428,-0.7069171071052551,0.011713176965713501,0.7070333957672119],"scale":[1,0.9999997615814209,1],"translation":[-0.0047518047504127026,0.10940061509609222,-0.012370162643492222]},{"name":"thumb_04_leaf_r","rotation":[1.2106264080102846e-07,-0.3827088177204132,2.8808685215153673e-08,0.9238690137863159],"scale":[1.0000001192092896,1,1],"translation":[9.080395102500916e-09,0.03455810248851776,1.7695128917694092e-08]},{"children":[42],"name":"thumb_03_r","rotation":[-5.1935785450041294e-08,2.9705788620049134e-05,-1.7005951349347015e-06,1],"scale":[0.9999999403953552,1,0.9999999403953552],"translation":[1.2456439435482025e-08,0.03549079969525337,8.381903171539307e-09]},{"children":[43],"name":"thumb_02_r","rotation":[6.680493811472843e-07,-4.789792001247406e-06,3.1683596262155334e-06,1],"scale":[1,0.9999998807907104,0.9999999403953552],"translation":[-4.563480615615845e-08,0.039520014077425,2.8172507882118225e-08]},{"children":[44],"name":"thumb_01_r","rotation":[-0.24446293711662292,0.9435676336288452,0.21606166660785675,0.05687989294528961],"scale":[0.9999999403953552,1.0000001192092896,1.0000001192092896],"translation":[-0.02748131938278675,0.03381356596946716,0.04106312245130539]},{"children":[29,33,37,41,45],"name":"hand_r","rotation":[-0.015601660124957561,4.7677681891400425e-07,8.121838313002172e-09,0.9998782873153687],"scale":[1,1,0.9999999403953552],"translation":[1.5123667651550932e-07,0.2435941994190216,2.032038537436165e-09]},{"children":[46],"name":"lowerarm_r","rotation":[0.030732227489352226,-4.1677081753732637e-05,1.251685375791567e-06,0.9995276927947998],"translation":[-3.0872001843818e-08,0.251115083694458,-2.86672729998827e-09]},{"children":[47],"name":"upperarm_r","rotation":[0.1498030126094818,-0.6911307573318481,0.14938844740390778,0.6910719275474548],"scale":[0.9999998211860657,0.999999463558197,0.9999998807907104],"translation":[0.009135720320045948,0.20573900640010834,-0.03967121243476868]},{"children":[48],"name":"clavicle_r","rotation":[-0.5287374258041382,0.32910192012786865,0.41380804777145386,0.663996696472168],"scale":[1,0.9999998807907104,1],"translation":[-0.03139999881386757,0.1737176477909088,0.06732845306396484]},{"children":[1,25,49],"name":"spine_03","rotation":[-0.12418898940086365,0,0,0.9922585487365723],"translation":[0,0.1331336796283722,0]},{"children":[50],"name":"spine_02","rotation":[-0.03958535194396973,0,0,0.9992161989212036],"translation":[0,0.10634974390268326,-1.3969838619232178e-09]},{"children":[51],"name":"spine_01","rotation":[-0.09185619652271271,0,0,0.9957723021507263],"translation":[0,0.1280922144651413,-3.259629011154175e-08]},{"name":"ball_leaf_l","rotation":[1.4901161193847656e-08,-4.371149486814829e-08,-1.355950077832355e-14,1],"translation":[-1.3288261602184548e-09,0.07889983803033829,-4.233804418163345e-10]},{"children":[53],"name":"ball_l","rotation":[6.497470650401738e-08,0.9731196165084839,-0.23030038177967072,1.894733969720619e-07],"scale":[1,1.000000238418579,1],"translation":[-4.181756274590498e-09,0.15907371044158936,-7.893650177948075e-09]},{"children":[54],"name":"foot_l","rotation":[-0.5721680521965027,1.9553584706955007e-07,-1.3641555085541768e-07,0.8201364278793335],"translation":[0,0.4587883949279785,5.587935447692871e-09]},{"children":[55],"name":"calf_l","rotation":[0.05598893761634827,0,0,0.998431384563446],"translation":[-7.450580596923828e-09,0.4288000166416168,3.1723175197839737e-09]},{"children":[56],"name":"thigh_l","rotation":[0.9898310303688049,0,0,0.14224845170974731],"scale":[1,1,1.0000019073486328],"translation":[0.11430513858795166,0.023176943883299828,0.0004878304898738861]},{"name":"ball_leaf_r","rotation":[1.4901161193847656e-08,4.371149486814829e-08,1.355950077832355e-14,1],"translation":[1.3288261602184548e-09,0.07889983803033829,-4.233804418163345e-10]},{"children":[58],"name":"ball_r","rotation":[6.497470650401738e-08,-0.9731196165084839,0.23030038177967072,1.894733969720619e-07],"scale":[1,1.000000238418579,1],"translation":[4.181756274590498e-09,0.15907371044158936,-7.893650177948075e-09]},{"children":[59],"name":"foot_r","rotation":[-0.5721680521965027,-1.9553584706955007e-07,1.3641555085541768e-07,0.8201364278793335],"translation":[0,0.4587883949279785,5.587935447692871e-09]},{"children":[60],"name":"calf_r","rotation":[0.05598893761634827,0,0,0.998431384563446],"translation":[7.450580596923828e-09,0.4288000166416168,3.1723175197839737e-09]},{"children":[61],"name":"thigh_r","rotation":[0.9898310303688049,0,0,0.14224845170974731],"scale":[1,1,1.0000019073486328],"translation":[-0.11430513858795166,0.023176943883299828,0.0004878304898738861]},{"children":[52,57,62],"name":"pelvis","rotation":[0.8005710244178772,0,0,0.5992379188537598],"translation":[0,0.042999982833862305,0.9491000175476074]},{"children":[63],"name":"root","rotation":[-0.7071067690849304,0,0,0.7071067690849304]},{"mesh":0,"name":"Eyebrows","skin":0},{"mesh":1,"name":"Eyes","skin":0},{"mesh":2,"name":"SuperHero_Male","skin":0},{"children":[65,66,67,64],"name":"Armature"}],"materials":[{"doubleSided":true,"name":"MI_Hair_1","normalTexture":{"index":0},"pbrMetallicRoughness":{"baseColorTexture":{"index":1},"metallicFactor":0,"roughnessFactor":0.8227272629737854}},{"doubleSided":true,"name":"MI_Eyes","normalTexture":{"index":2},"pbrMetallicRoughness":{"baseColorTexture":{"index":3},"metallicFactor":0,"roughnessFactor":0.34545454382896423}},{"doubleSided":true,"name":"MI_Superhero_Male","normalTexture":{"index":4},"pbrMetallicRoughness":{"baseColorTexture":{"index":5},"metallicFactor":0,"metallicRoughnessTexture":{"index":6}}}],"meshes":[{"name":"Face","primitives":[{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2,"TEXCOORD_1":3,"TEXCOORD_2":4,"TEXCOORD_3":5,"COLOR_0":6,"COLOR_1":7,"JOINTS_0":8,"WEIGHTS_0":9},"indices":10,"material":0}]},{"name":"Face.001","primitives":[{"attributes":{"POSITION":12,"NORMAL":13,"TEXCOORD_0":14,"TEXCOORD_1":15,"TEXCOORD_2":16,"TEXCOORD_3":17,"COLOR_0":18,"COLOR_1":19,"JOINTS_0":20,"WEIGHTS_0":21},"indices":22,"material":1}]},{"name":"Sphere.005_Retopology.004","primitives":[{"attributes":{"POSITION":23,"NORMAL":24,"TEXCOORD_0":25,"TEXCOORD_1":26,"COLOR_0":27,"COLOR_1":28,"JOINTS_0":29,"WEIGHTS_0":30},"indices":31,"material":2}]}],"textures":[{"sampler":0,"source":0},{"sampler":0,"source":1},{"sampler":0,"source":2},{"sampler":0,"source":3},{"sampler":0,"source":4},{"sampler":0,"source":5},{"sampler":0,"source":6}],"images":[{"mimeType":"image/png","name":"T_Hair_1_Normal.png","uri":"T_Hair_1_Normal.png"},{"mimeType":"image/png","name":"T_Hair_1_BaseColor","uri":"T_Hair_1_BaseColor.png"},{"mimeType":"image/png","name":"T_Eye_Normal.png","uri":"T_Eye_Normal.png"},{"mimeType":"image/png","name":"T_Eye_Brown","uri":"T_Eye_Brown.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Normal","uri":"T_Superhero_Male_Normal.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Dark","uri":"T_Superhero_Male_Dark.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Roughness","uri":"T_Superhero_Male_Roughness.png"}],"skins":[{"inverseBindMatrices":11,"joints":[64,63,52,51,50,1,0,25,24,23,22,5,4,3,2,9,8,7,6,13,12,11,10,17,16,15,14,21,20,19,18,49,48,47,46,29,28,27,26,33,32,31,30,37,36,35,34,41,40,39,38,45,44,43,42,57,56,55,54,53,62,61,60,59,58],"name":"Armature"}],"accessors":[{"bufferView":0,"componentType":5126,"count":646,"max":[0.062340568751096725,1.7254674434661865,0.09447786957025528],"min":[-0.062340568751096725,1.6948596239089966,0.0569215789437294],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":646,"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":3,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":4,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":5,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":6,"componentType":5121,"count":646,"normalized":true,"type":"VEC4"},{"bufferView":7,"componentType":5123,"count":646,"normalized":true,"type":"VEC4"},{"bufferView":8,"componentType":5121,"count":646,"type":"VEC4"},{"bufferView":9,"componentType":5126,"count":646,"type":"VEC4"},{"bufferView":10,"componentType":5123,"count":2952,"type":"SCALAR"},{"bufferView":11,"componentType":5126,"count":65,"type":"MAT4"},{"bufferView":12,"componentType":5126,"count":556,"max":[0.04874879866838455,1.7135447263717651,0.08063791692256927],"min":[-0.04874879866838455,1.6835992336273193,0.05123402923345566],"type":"VEC3"},{"bufferView":13,"componentType":5126,"count":556,"type":"VEC3"},{"bufferView":14,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":15,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":16,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":17,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":18,"componentType":5121,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":19,"componentType":5123,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":20,"componentType":5121,"count":556,"type":"VEC4"},{"bufferView":21,"componentType":5126,"count":556,"type":"VEC4"},{"bufferView":22,"componentType":5123,"count":2304,"type":"SCALAR"},{"bufferView":23,"componentType":5126,"count":7281,"max":[0.9294325113296509,1.8100767135620117,0.12803764641284943],"min":[-0.9294325113296509,-0.009509669616818428,-0.16345079243183136],"type":"VEC3"},{"bufferView":24,"componentType":5126,"count":7281,"type":"VEC3"},{"bufferView":25,"componentType":5126,"count":7281,"type":"VEC2"},{"bufferView":26,"componentType":5126,"count":7281,"type":"VEC2"},{"bufferView":27,"componentType":5121,"count":7281,"normalized":true,"type":"VEC4"},{"bufferView":28,"componentType":5123,"count":7281,"normalized":true,"type":"VEC4"},{"bufferView":29,"componentType":5121,"count":7281,"type":"VEC4"},{"bufferView":30,"componentType":5126,"count":7281,"type":"VEC4"},{"bufferView":31,"componentType":5123,"count":37698,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteLength":7752,"byteOffset":0,"target":34962},{"buffer":0,"byteLength":7752,"byteOffset":7752,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":15504,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":20672,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":25840,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":31008,"target":34962},{"buffer":0,"byteLength":2584,"byteOffset":36176,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":38760,"target":34962},{"buffer":0,"byteLength":2584,"byteOffset":43928,"target":34962},{"buffer":0,"byteLength":10336,"byteOffset":46512,"target":34962},{"buffer":0,"byteLength":5904,"byteOffset":56848,"target":34963},{"buffer":0,"byteLength":4160,"byteOffset":62752},{"buffer":0,"byteLength":6672,"byteOffset":66912,"target":34962},{"buffer":0,"byteLength":6672,"byteOffset":73584,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":80256,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":84704,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":89152,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":93600,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":98048,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":100272,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":104720,"target":34962},{"buffer":0,"byteLength":8896,"byteOffset":106944,"target":34962},{"buffer":0,"byteLength":4608,"byteOffset":115840,"target":34963},{"buffer":0,"byteLength":87372,"byteOffset":120448,"target":34962},{"buffer":0,"byteLength":87372,"byteOffset":207820,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":295192,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":353440,"target":34962},{"buffer":0,"byteLength":29124,"byteOffset":411688,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":440812,"target":34962},{"buffer":0,"byteLength":29124,"byteOffset":499060,"target":34962},{"buffer":0,"byteLength":116496,"byteOffset":528184,"target":34962},{"buffer":0,"byteLength":75396,"byteOffset":644680,"target":34963}],"samplers":[{"magFilter":9729,"minFilter":9987}],"buffers":[{"byteLength":720076,"uri":"Superhero_Male_FullBody.bin"}]} \ No newline at end of file diff --git a/three-player-controller/example/public/glb/characters/T_Eye_Brown.png b/three-player-controller/example/public/glb/characters/T_Eye_Brown.png new file mode 100644 index 0000000..0d037fe Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Eye_Brown.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Eye_Normal.png b/three-player-controller/example/public/glb/characters/T_Eye_Normal.png new file mode 100644 index 0000000..2da5912 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Eye_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png new file mode 100644 index 0000000..c398ca7 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png b/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png new file mode 100644 index 0000000..5aa652e Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png new file mode 100644 index 0000000..bda8d95 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png b/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png new file mode 100644 index 0000000..2d77b1d Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png new file mode 100644 index 0000000..06d49a5 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png new file mode 100644 index 0000000..20af328 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png new file mode 100644 index 0000000..d496b53 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png new file mode 100644 index 0000000..6391f45 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png new file mode 100644 index 0000000..88303e2 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png new file mode 100644 index 0000000..2bbc618 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png differ diff --git a/three-player-controller/example/public/glb/crash_junction.glb b/three-player-controller/example/public/glb/crash_junction.glb new file mode 100644 index 0000000..51f9da2 Binary files /dev/null and b/three-player-controller/example/public/glb/crash_junction.glb differ diff --git a/three-player-controller/example/public/glb/eiffel_collider.glb b/three-player-controller/example/public/glb/eiffel_collider.glb new file mode 100644 index 0000000..3e961b9 Binary files /dev/null and b/three-player-controller/example/public/glb/eiffel_collider.glb differ diff --git a/three-player-controller/example/public/glb/horror_corridor.glb b/three-player-controller/example/public/glb/horror_corridor.glb new file mode 100644 index 0000000..944fe07 Binary files /dev/null and b/three-player-controller/example/public/glb/horror_corridor.glb differ diff --git a/three-player-controller/example/public/glb/josh.glb b/three-player-controller/example/public/glb/josh.glb new file mode 100644 index 0000000..a528f2b Binary files /dev/null and b/three-player-controller/example/public/glb/josh.glb differ diff --git a/three-player-controller/example/public/glb/maw.glb b/three-player-controller/example/public/glb/maw.glb new file mode 100644 index 0000000..6f88d5e Binary files /dev/null and b/three-player-controller/example/public/glb/maw.glb differ diff --git a/three-player-controller/example/public/glb/sedan.glb b/three-player-controller/example/public/glb/sedan.glb new file mode 100644 index 0000000..274ddaf Binary files /dev/null and b/three-player-controller/example/public/glb/sedan.glb differ diff --git a/three-player-controller/example/public/glb/suv.glb b/three-player-controller/example/public/glb/suv.glb new file mode 100644 index 0000000..edfabfe Binary files /dev/null and b/three-player-controller/example/public/glb/suv.glb differ diff --git a/three-player-controller/example/public/glb/swat.glb b/three-player-controller/example/public/glb/swat.glb new file mode 100644 index 0000000..cebb3c5 Binary files /dev/null and b/three-player-controller/example/public/glb/swat.glb differ diff --git a/three-player-controller/example/public/glb/ual.glb b/three-player-controller/example/public/glb/ual.glb new file mode 100644 index 0000000..e236f34 Binary files /dev/null and b/three-player-controller/example/public/glb/ual.glb differ diff --git a/three-player-controller/example/public/glb/zombie.glb b/three-player-controller/example/public/glb/zombie.glb new file mode 100644 index 0000000..f80f565 Binary files /dev/null and b/three-player-controller/example/public/glb/zombie.glb differ diff --git a/three-player-controller/example/public/img/bullet_hole.png b/three-player-controller/example/public/img/bullet_hole.png new file mode 100644 index 0000000..aedd990 Binary files /dev/null and b/three-player-controller/example/public/img/bullet_hole.png differ diff --git a/three-player-controller/example/public/img/covers/3dgs.png b/three-player-controller/example/public/img/covers/3dgs.png new file mode 100644 index 0000000..93576ad Binary files /dev/null and b/three-player-controller/example/public/img/covers/3dgs.png differ diff --git a/three-player-controller/example/public/img/covers/3dtiles.png b/three-player-controller/example/public/img/covers/3dtiles.png new file mode 100644 index 0000000..e3b54fc Binary files /dev/null and b/three-player-controller/example/public/img/covers/3dtiles.png differ diff --git a/three-player-controller/example/public/img/covers/footik.png b/three-player-controller/example/public/img/covers/footik.png new file mode 100644 index 0000000..4e5e2ee Binary files /dev/null and b/three-player-controller/example/public/img/covers/footik.png differ diff --git a/three-player-controller/example/public/img/covers/glTF.png b/three-player-controller/example/public/img/covers/glTF.png new file mode 100644 index 0000000..67ad2bf Binary files /dev/null and b/three-player-controller/example/public/img/covers/glTF.png differ diff --git a/three-player-controller/example/public/img/covers/shooting.jpg b/three-player-controller/example/public/img/covers/shooting.jpg new file mode 100644 index 0000000..5753ef3 Binary files /dev/null and b/three-player-controller/example/public/img/covers/shooting.jpg differ diff --git a/three-player-controller/example/public/img/covers/showcase.png b/three-player-controller/example/public/img/covers/showcase.png new file mode 100644 index 0000000..b1fc3ae Binary files /dev/null and b/three-player-controller/example/public/img/covers/showcase.png differ diff --git a/three-player-controller/example/public/img/env.hdr b/three-player-controller/example/public/img/env.hdr new file mode 100644 index 0000000..cc1f3dd Binary files /dev/null and b/three-player-controller/example/public/img/env.hdr differ diff --git a/three-player-controller/example/public/img/muzzle_flash.png b/three-player-controller/example/public/img/muzzle_flash.png new file mode 100644 index 0000000..c826c44 Binary files /dev/null and b/three-player-controller/example/public/img/muzzle_flash.png differ diff --git a/three-player-controller/example/public/img/readme/preview.png b/three-player-controller/example/public/img/readme/preview.png new file mode 100644 index 0000000..928a49b Binary files /dev/null and b/three-player-controller/example/public/img/readme/preview.png differ diff --git a/three-player-controller/example/public/img/smoke.png b/three-player-controller/example/public/img/smoke.png new file mode 100644 index 0000000..02af04c Binary files /dev/null and b/three-player-controller/example/public/img/smoke.png differ diff --git a/three-player-controller/example/public/img/source.svg b/three-player-controller/example/public/img/source.svg new file mode 100644 index 0000000..2356081 --- /dev/null +++ b/three-player-controller/example/public/img/source.svg @@ -0,0 +1,4 @@ + + + + diff --git a/three-player-controller/example/public/loader.js b/three-player-controller/example/public/loader.js new file mode 100644 index 0000000..2a640ac --- /dev/null +++ b/three-player-controller/example/public/loader.js @@ -0,0 +1,221 @@ +(function () { + const script = document.currentScript || document.querySelector("script[src*='loader.js']"); + const demo = script?.dataset?.demo || ""; + const fade = 600; + + if (!document.querySelector('link[href*="Cinzel"]')) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap"; + document.head.appendChild(link); + } + + const style = document.createElement("style"); + style.id = "__loader-style__"; + style.textContent = ` + html { height: 100%; } + + #__loading-overlay__ { + position: fixed; + inset: 0; + z-index: 99999; + background: #ececec; + display: flex; + align-items: center; + justify-content: center; + transition: opacity ${fade}ms cubic-bezier(0.4, 0, 0.2, 1); + } + #__loading-overlay__.fade-out { + opacity: 0; + pointer-events: none; + } + .__ldr-content__ { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 1.5rem; + user-select: none; + } + .__ldr-play__ { + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: #e8a020; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.4rem; + box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35); + animation: __ldr-pulse__ 1.6s ease-in-out infinite; + } + .__ldr-play__ svg { + width: 14px; + height: 14px; + fill: #fff; + margin-left: 2px; + } + .__ldr-title__ { + font-family: "Cinzel", serif; + font-size: clamp(1.4rem, 3.5vw, 2.2rem); + font-weight: 900; + letter-spacing: 0.06em; + color: #1a1a2e; + text-align: center; + margin-bottom: 0.3rem; + } + .__ldr-meta__ { + display: flex; + align-items: baseline; + justify-content: center; + flex-wrap: wrap; + gap: 0.45em; + margin-bottom: 1.5rem; + } + .__ldr-kicker__ { + font-family: "Nunito", sans-serif; + font-size: 0.78rem; + font-weight: 700; + color: #999; + letter-spacing: 0.14em; + text-transform: uppercase; + } + .__ldr-status__ { + font-family: "Nunito", sans-serif; + font-size: 0.88rem; + font-weight: 700; + color: #555; + line-height: 1.4; + } + .__ldr-status__:empty { + display: none; + } + .__ldr-progress__ { + width: min(220px, 70vw); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .__ldr-track__ { + width: 100%; + height: 4px; + background: rgba(0, 0, 0, 0.12); + border-radius: 2px; + overflow: hidden; + } + .__ldr-fill__ { + height: 100%; + width: 40%; + background: #e8a020; + border-radius: 2px; + transform: translateX(-120%); + animation: __ldr-slide__ 1.35s ease-in-out infinite; + } + .__ldr-fill__.is-determinate { + width: 0%; + transform: none; + animation: none; + transition: width 0.25s ease; + } + .__ldr-pct__ { + font-family: "Nunito", system-ui, sans-serif; + font-size: 12px; + font-weight: 700; + color: #555; + letter-spacing: 0.05em; + min-height: 1em; + } + @keyframes __ldr-pulse__ { + 0%, 100% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35); + } + 50% { + transform: scale(1.08); + background: #f5b535; + box-shadow: 0 8px 24px rgba(232, 160, 32, 0.45); + } + } + @keyframes __ldr-slide__ { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(350%); } + } + @media (prefers-reduced-motion: reduce) { + .__ldr-play__, + .__ldr-fill__ { + animation: none; + } + .__ldr-fill__:not(.is-determinate) { + width: 100%; + transform: none; + } + #__loading-overlay__ { + transition: none; + } + } + `; + document.head.appendChild(style); + + const overlay = document.createElement("div"); + overlay.id = "__loading-overlay__"; + overlay.setAttribute("role", "status"); + overlay.setAttribute("aria-busy", "true"); + overlay.setAttribute("aria-live", "polite"); + overlay.innerHTML = ` +
+ +
three-player-controller
+
+
Loading
+
+
+
+
+
+
+
`; + document.documentElement.appendChild(overlay); + + const fill = overlay.querySelector("#__ldr-fill__"); + const pct = overlay.querySelector("#__ldr-pct__"); + const status = overlay.querySelector("#__ldr-status__"); + if (demo) status.textContent = demo; + + window.setLoaderStatus = function (text) { + if (status) status.textContent = text || ""; + }; + + window.setLoaderProgress = function (loaded, total) { + if (!fill) return; + if (total > 0) { + fill.classList.add("is-determinate"); + const p = Math.min(100, Math.round((loaded / total) * 100)); + fill.style.width = p + "%"; + if (pct) pct.textContent = p + "%"; + } else { + fill.classList.remove("is-determinate"); + fill.style.width = ""; + if (pct) pct.textContent = (loaded / 1048576).toFixed(1) + " MB"; + } + }; + + window.hideLoader = function () { + const el = document.getElementById("__loading-overlay__"); + if (!el || el.dataset.hiding) return; + el.dataset.hiding = "1"; + el.classList.add("fade-out"); + el.setAttribute("aria-busy", "false"); + + let cleaned = false; + const done = () => { + if (cleaned) return; + cleaned = true; + el.remove(); + document.getElementById("__loader-style__")?.remove(); + }; + el.addEventListener("transitionend", done, { once: true }); + setTimeout(done, fade + 80); + }; +})(); diff --git a/three-player-controller/example/public/textures/marble-floor.jpg b/three-player-controller/example/public/textures/marble-floor.jpg new file mode 100644 index 0000000..52eeb96 Binary files /dev/null and b/three-player-controller/example/public/textures/marble-floor.jpg differ diff --git a/three-player-controller/example/public/textures/showcase/lift.png b/three-player-controller/example/public/textures/showcase/lift.png new file mode 100644 index 0000000..3952bef Binary files /dev/null and b/three-player-controller/example/public/textures/showcase/lift.png differ diff --git a/three-player-controller/example/public/textures/showcase/prototype.png b/three-player-controller/example/public/textures/showcase/prototype.png new file mode 100644 index 0000000..02fd0ba Binary files /dev/null and b/three-player-controller/example/public/textures/showcase/prototype.png differ diff --git a/three-player-controller/example/shooting/core/SceneSetup.js b/three-player-controller/example/shooting/core/SceneSetup.js new file mode 100644 index 0000000..0fc8a76 --- /dev/null +++ b/three-player-controller/example/shooting/core/SceneSetup.js @@ -0,0 +1,124 @@ +import { + ACESFilmicToneMapping, + DirectionalLight, + MathUtils, + PerspectiveCamera, + Scene, + VSMShadowMap, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { LightProbeGrid } from "three/addons/lighting/LightProbeGrid.js"; +import { LightProbeGridHelper } from "three/addons/helpers/LightProbeGridHelper.js"; + + +export function createSceneSetup({ container }) { + // ==================== 渲染器 ==================== + const renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + container.appendChild(renderer.domElement); + + // ==================== 场景 & 相机 ==================== + const scene = new Scene(); + const camera = new PerspectiveCamera( + 60, + container.clientWidth / container.clientHeight, + 0.1, + 2000 + ); + + // ==================== 控制器 ==================== + const controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.maxDistance = 2000; + + // ==================== 参数 ==================== + const params = { + showLightHelper: true, + lightAzimuth: 45, + lightElevation: 45, + lightIntensity: 10, + shadows: true, + probesEnabled: true, + showProbes: false, + probeSize: 0.5, + boundsX: 8, boundsY: 3.2, boundsZ: -2, + sizeX: 40, sizeY: 3, sizeZ: 25, + countX: 20, countY: 4, countZ: 10, + }; + + // ==================== 平行光 ==================== + const dirLight = new DirectionalLight(0xfff2dc, params.lightIntensity); + dirLight.castShadow = true; + dirLight.shadow.mapSize.setScalar(2048); + dirLight.shadow.camera.left = -50; + dirLight.shadow.camera.right = 50; + dirLight.shadow.camera.top = 50; + dirLight.shadow.camera.bottom = -50; + dirLight.shadow.camera.near = 0.1; + dirLight.shadow.camera.far = 200; + dirLight.shadow.bias = -0.001; + scene.add(dirLight); + scene.add(dirLight.target); + + function updateLightPosition() { + const azimuth = MathUtils.degToRad(params.lightAzimuth); + const elevation = MathUtils.degToRad(params.lightElevation); + const radius = 100; + const h = Math.cos(elevation) * radius; + const v = Math.sin(elevation) * radius; + dirLight.position.set(Math.cos(azimuth) * h, v, Math.sin(azimuth) * h); + dirLight.target.position.set(0, 0, 0); + dirLight.target.updateMatrixWorld(); + } + + updateLightPosition(); + + // ==================== 光探针 ==================== + let probes = null; + let probesHelper = null; + let isBaking = false; + let bakeQueued = false; + + async function bakeProbes() { + if (isBaking) { bakeQueued = true; return; } + isBaking = true; + do { + bakeQueued = false; + if (probes) { scene.remove(probes); probes.dispose(); } + + probes = new LightProbeGrid( + params.sizeX, params.sizeY, params.sizeZ, + params.countX, params.countY, params.countZ + ); + probes.position.set(params.boundsX, params.boundsY, params.boundsZ); + probes.updateMatrixWorld(true); // 强制更新世界矩阵,否则 bake() 读到旧位置 + if (probesHelper) probesHelper.visible = false; + + const far = Math.max(params.sizeX, params.sizeY, params.sizeZ) * 2; + probes.bake(renderer, scene, { cubemapSize: 32, near: 0.05, far }); + probes.visible = params.probesEnabled; + scene.add(probes); + + if (!probesHelper) { + probesHelper = new LightProbeGridHelper(probes, params.probeSize); + scene.add(probesHelper); + } else { + probesHelper.probes = probes; + probesHelper.update(); + } + probesHelper.position.set(params.boundsX, params.boundsY, params.boundsZ); + probesHelper.visible = params.showProbes; + } while (bakeQueued); + isBaking = false; + } + + + return { scene, renderer, camera, controls, dirLight, bakeProbes }; +} diff --git a/three-player-controller/example/shooting/player/LocalPlayer.js b/three-player-controller/example/shooting/player/LocalPlayer.js new file mode 100644 index 0000000..407c88e --- /dev/null +++ b/three-player-controller/example/shooting/player/LocalPlayer.js @@ -0,0 +1,330 @@ +import { MathUtils, Quaternion, Vector3, AnimationMixer, AnimationClip, LoopOnce, LoopRepeat } from "three"; +import { playerController } from "../../../src/PlayerController"; +import { SpineIK } from "./spineIK.js"; + +const spineBoneNames = ["mixamorigSpine", "mixamorigSpine1", "mixamorigSpine2"]; + +// 运动状态集合(用于判断 isMoving) +const locomotionStates = new Set([ + "idle", + "walking", + "walking_backward", + "left_walking", + "right_walking", + "running", + "jumping", + "flyidle", + "flying", +]); + +// 允许执行战斗逻辑的运动状态(上半身分离后,奔跑时也可开火/换弹) +const combatAllowedLocomotion = new Set([ + "idle", "walking", "walking_backward", "left_walking", "right_walking", "running", +]); + +const minPitchAngle = -Math.PI * (60 / 180); // 俯仰最小角(向下) +const maxPitchAngle = Math.PI * (40 / 180); // 俯仰最大角(向上) + +export class LocalPlayer { + constructor({ scene, camera, controls }) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._camera = camera; + this._controls = controls; + + // ==================== 核心对象 ==================== + this._player = null; // playerController 实例 + this.spineIK = null; // 脊椎 IK 实例 + + // ==================== 运动状态 ==================== + this.pitchTarget1P = 0; // 第一人称俯仰累积值 + this.isMoving = false; // 当前帧是否处于移动状态(由 onAnimationChange 更新) + this._locomotionState = "idle"; // 当前运动动画状态名 + + // ==================== 配置 ==================== + this._mouseSensitivity = 5; + this._firstPersonPitchOffset = 0; // 第一人称相机俯仰初始偏移 + + // ==================== 外部注入 ==================== + this._isGunEngagedFn = null; // 由 WeaponController 注入,判断是否持枪 + + // ==================== 上半身动画层 ==================== + this._upperMixer = null; // 上半身专用 AnimationMixer(root = 模型根节点) + this._upperBodyBoneNames = null; // 脊椎以上所有骨骼名称集合,用于过滤 partial clip + this._upperBodyBones = null; // 脊椎以上骨骼引用数组(缓存,避免每帧 getObjectByName) + this._upperBoneSnapshots = null; // 主 mixer 骨骼值快照 + this._upperActions = new Map(); // key → AnimationAction + this._upperState = null; // 当前上半身动作 + + // ==================== 走路瞄准修正 ==================== + this._idleHipsQ = null; // 静止时保存的 hips 本地四元数,用于走路时抵消 hips 走路偏移 + } + + // ==================== 初始化 ==================== + + // 初始化 playerController、骨骼 IK、事件回调 + async init(config) { + const { mouseSensitivity = 5, ...rest } = config; + this._mouseSensitivity = mouseSensitivity; + this._firstPersonPitchOffset = config.playerModelConfig?.firstPersonPitchOffset + ?? this._firstPersonPitchOffset; + + this._player = new playerController(); + await this._player.init({ + scene: this._scene, + camera: this._camera, + controls: this._controls, + mouseSensitivity, + ...rest, + }); + + // 绑定脊椎 & 头部骨骼 + const model = this._player.getPlayerModel(); + const spineBones = spineBoneNames + .map((n) => model?.getObjectByName(n)) + .filter(Boolean); + const headBoneName = config.playerModelConfig?.headBoneName; + const headBone = model?.getObjectByName(headBoneName) ?? null; + this.spineIK = new SpineIK(spineBones, headBone); + + // 上半身 mixer:root = 模型根节点(与主 mixer 一致,路径解析最可靠) + // 通过 partial clip(只含脊椎以上骨骼的 track)来限制写入范围, + // 主 mixer 更新后再更新 upper mixer,后者覆写脊椎以上骨骼,下半身保持 locomotion 值。 + if (spineBones.length > 0) { + this._upperBodyBoneNames = new Set(); + this._upperBodyBones = []; + spineBones[0].traverse(b => { + this._upperBodyBoneNames.add(b.name); + this._upperBodyBones.push(b); + }); + // 预分配快照数组,避免每帧 GC + this._upperBoneSnapshots = this._upperBodyBones.map(() => new Quaternion()); + this._upperMixer = new AnimationMixer(model); + } + + // 监听动画切换,更新 isMoving + this._player.onAnimationChange = (name) => { + if (locomotionStates.has(name)) this._locomotionState = name; + this.isMoving = + name === "walking" || + name === "left_walking" || + name === "right_walking" || + name === "walking_backward" || + name === "running"; + }; + + // 接管第一人称鼠标移动 + this._player.onTowardChange = (dx, dy, speed) => { + if (!this._player.getIsFirstPerson()) return; + + // 水平朝向 + this._player.getPlayerCapsule().rotateY( + -dx * speed * this._mouseSensitivity + ); + + // 俯仰角累积 + this.pitchTarget1P = MathUtils.clamp( + this.pitchTarget1P + (-dy * speed * this._mouseSensitivity), + minPitchAngle, + maxPitchAngle + ); + + // 未持枪时直接驱动相机 + if (!this._isGunEngagedFn?.()) { + this._camera.rotation.x = MathUtils.clamp( + this._camera.rotation.x + (-dy * speed * this._mouseSensitivity), + minPitchAngle, + maxPitchAngle + ); + } + }; + + // 视角切换 + this._player.onViewChange = (isFirstPerson) => { + if (isFirstPerson) { + if (headBoneName) { + console.log(headBoneName); + this._camera.position.z = 8; + this._camera.position.x = 15; + } else { + this._camera.position.z = 0; + this._camera.position.x = 0; + } + this._camera.rotation.x = this._firstPersonPitchOffset; + this._player.setEnableToward(false); + // 同步控制器俯仰角 + const targetPolar = this._controls.getPolarAngle() - Math.PI / 2 + Math.PI * (7.5 / 180); + this.pitchTarget1P = targetPolar; + // 航向角偏移 + this._player.getPlayerCapsule().rotateY(-Math.PI * (17 / 180)); + } else { + this._player.setEnableToward(true); + // 持枪瞄准状态下刷新一次动画,保证骨骼复原 + if (this._player.getCurrentPlayerAnimationName().includes("rifle_idle_aim")) { + this._player.playAnimation("idle"); + } + // 同步第一人称俯仰角 + const targetPolar = Math.PI / 2 + this.pitchTarget1P - Math.PI * (7.5 / 180); + this._controls.minPolarAngle = targetPolar; + this._controls.maxPolarAngle = targetPolar; + this._controls.update(); + this._controls.minPolarAngle = minPitchAngle + Math.PI / 2; + this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2; + // 航向角偏移 + const delta = Math.PI * (17 / 180); + const offset = this._camera.position.clone().sub(this._controls.target); + offset.applyAxisAngle(new Vector3(0, 1, 0), delta); + this._camera.position.copy(this._controls.target).add(offset); + this._controls.update(); + } + }; + + // 限制第三人称俯仰角 + this._controls.minPolarAngle = minPitchAngle + Math.PI / 2; + this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2; + } + + // ==================== 外部注入 ==================== + + // 由 WeaponController 注入,让 1P 俯仰驱动能感知持枪状态 + setGunEngagedGetter(fn) { + this._isGunEngagedFn = fn; + } + + // ==================== 主循环 ==================== + + // 每帧驱动动画与物理 + // dt 须由主循环传入,上半身 mixer 在主 mixer 之后更新才能正确覆写骨骼 + update(dt) { + this._player?.update(dt); + + if (this._upperMixer && dt != null) { + const ua = this._upperState; + if (!ua) return; + + // Three.js PropertyMixer.apply() 有变更检测优化:仅当 accu0 ≠ accu1 时 + // 才调用 setValue() 写入骨骼。对于完全相同帧的动画(两帧值一致), + // 两个 accu buffer 永远相等,导致 setValue() 被跳过,主 mixer 的 + // locomotion 动画透过来。修复:每帧更新前把两个 accu buffer 填为 NaN, + // 使比较永远不相等,强制 setValue() 每帧执行,确保上半身覆盖生效。 + if (ua._propertyBindings) { + for (const pm of ua._propertyBindings) { + if (pm?.buffer) { + const s = pm.valueSize; + pm.buffer.fill(NaN, s, s * 3); // dirty accu0 + accu1 + } + } + } + this._upperMixer.update(dt); + } + } + + // ==================== 上半身动画层 ==================== + + // 从指定 clip 的 t=0 帧直接读取 hips 四元数 + initIdleHipsQ(clipName) { + const clip = this._player?.animation?.clips?.find(c => c.name === clipName); + if (!clip) { console.warn(`initIdleHipsQ: 找不到 "${clipName}"`); return; } + const track = clip.tracks.find(t => t.name === 'mixamorigHips.quaternion'); + if (!track || track.values.length < 4) return; + this._idleHipsQ = new Quaternion(track.values[0], track.values[1], track.values[2], track.values[3]); + } + + // 在上半身 mixer 上注册一个动画 + registerUpperAnimation(key, clipName, opts = {}) { + if (!this._upperMixer || !this._upperBodyBoneNames) return; + const clips = this._player?.animation?.clips; + if (!clips) return; + const clip = clips.find(c => c.name === clipName); + if (!clip) { console.warn(`registerUpperAnimation: 找不到 "${clipName}"`); return; } + + // 只保留脊椎以上骨骼的 track,其余 track(hips、腿部等)丢弃 + const upperTracks = clip.tracks.filter(t => { + const boneName = t.name.split('.')[0]; + return this._upperBodyBoneNames.has(boneName); + }); + const partialClip = new AnimationClip(clip.name + '_upper_' + key, clip.duration, upperTracks); + + const action = this._upperMixer.clipAction(partialClip); + action.setLoop(opts.loop === false ? LoopOnce : LoopRepeat, Infinity); + action.clampWhenFinished = opts.clampWhenFinished ?? false; + const ts = opts.duration ? clip.duration / opts.duration : (opts.timeScale ?? 1); + action.setEffectiveTimeScale(ts); + action.enabled = true; + action.setEffectiveWeight(0); + this._upperActions.set(key, action); + + if (opts.onFinished) { + this._upperMixer.addEventListener("finished", (ev) => { + if (ev.action === action) opts.onFinished(); + }); + } + } + + // 播放上半身动画(仅覆写脊椎以上骨骼) + playUpperBody(key, opts = {}) { + if (!this._upperMixer) return; + const next = this._upperActions.get(key); + if (!next) { console.warn(`playUpperBody: "${key}" 未注册`); return; } + + const fade = opts.fade ?? 0.18; + const prev = this._upperState; + + if (!opts.force && prev === next) return; + + // 直接设为 1,从第一帧起就以满权重覆盖主 mixer。 + if (prev && prev !== next) prev.fadeOut(fade); + + next.reset(); + next.setEffectiveWeight(1); + next.play(); + + this._upperState = next; + } + + // 走路/奔跑时修正 spine[0] 四元数,抵消 hips 走路旋转偏移 + // 使 spine0 的世界朝向等效于静止时(idle hips × spine_local), + applyHipsCorrection() { + if (!this._idleHipsQ || !this.spineIK?.spineBones?.length) return; + const hipsBone = this._player?.getPlayerModel()?.getObjectByName("mixamorigHips"); + if (!hipsBone) return; + + // correction = hips_walk_local⁻¹ × hips_idle_local + const correction = new Quaternion().copy(hipsBone.quaternion).invert().multiply(this._idleHipsQ); + const spine0 = this.spineIK.spineBones[0]; + spine0.quaternion.premultiply(correction); + spine0.updateWorldMatrix(false, false); + } + + // 停止上半身动画,让下半身(全身)动画完全接管 + stopUpperBody(fade = 0.18) { + if (!this._upperState) return; + this._upperState.fadeOut(fade); + this._upperState = null; + } + + // ==================== 工具方法 ==================== + + isCombatLocomotionAllowed() { return combatAllowedLocomotion.has(this._locomotionState); } + + // ==================== playerController 代理 ==================== + + getIsFirstPerson() { return this._player?.getIsFirstPerson() ?? false; } + getIsFlying() { return this._player?.getIsFlying() ?? false; } + getPosition() { return this._player?.getPosition?.() ?? null; } + getFirstPersonPitchOffset() { return this._firstPersonPitchOffset; } + getPlayerModel() { return this._player?.getPlayerModel(); } + getColliderMeshes() { return this._player?.getColliderMeshes?.() ?? []; } + getCenterScreenRaycastHit() { return this._player?.getCenterScreenRaycastHit() ?? null; } + playAnimation(name, opts) { return this._player?.playAnimation(name, opts); } + registerAnimation(key, clipName, opts) { return this._player?.registerAnimation(key, clipName, opts); } + registerLocomotionSet(...a) { return this._player?.registerLocomotionSet(...a); } + switchLocomotionSet(name) { return this._player?.switchLocomotionSet(name); } + setMaxCamDistance(d) { return this._player?.setMaxCamDistance(d); } + setPlayerSpeed(s) { return this._player?.setPlayerSpeed(s); } + setEnableToward(v) { return this._player?.setEnableToward(v); } + setThirdMouseMode(mode) { return this._player?.setThirdMouseMode(mode); } + onAllEvent() { return this._player?.onAllEvent(); } + offAllEvent() { return this._player?.offAllEvent(); } + onViewChange(isFirstPerson) { return this._player?.onViewChange(isFirstPerson); } +} diff --git a/three-player-controller/example/shooting/player/spineIK.js b/three-player-controller/example/shooting/player/spineIK.js new file mode 100644 index 0000000..cf2c860 --- /dev/null +++ b/three-player-controller/example/shooting/player/spineIK.js @@ -0,0 +1,105 @@ +import { Euler, Quaternion, Vector3 } from "three"; + +// 复用的临时量(避免每帧 GC) +const _aimQ = new Quaternion(); +const _yawQ = new Quaternion(); +const _localPitchQ = new Quaternion(); +const _parentWorldQ = new Quaternion(); +const _cameraWorldQ = new Quaternion(); +const _aimAxis = new Vector3(1, 0, 0); +const _yawAxis = new Vector3(0, 1, 0); + +const _headWorldQ = new Quaternion(); +const _headParentWorldQ = new Quaternion(); +const _headEuler = new Euler(); + +// 脊椎骨骼 IK +export class SpineIK { + constructor(spineBones, headBone) { + // ==================== 骨骼引用 ==================== + this.spineBones = spineBones; // 脊椎骨骼数组(从下到上) + this.headBone = headBone; // 头部骨骼 + + // 每帧 IK 应用前保存的"干净动画姿态",下一帧先恢复再驱动 + this.baseQuats = spineBones.map(() => new Quaternion()); + } + + // ==================== 骨骼保存 / 恢复 ==================== + + // 将脊椎骨骼恢复到上一帧保存的干净动画姿态 + // 在 player.update()(动画驱动)之前调用,防止 IK 残留影响动画混合 + restoreBones() { + for (let i = 0; i < this.spineBones.length; i++) { + this.spineBones[i].quaternion.copy(this.baseQuats[i]); + } + } + + // 清除头骨骼的横滚(roll),防止第一人称下相机随动画倾斜 + clearHeadRoll() { + const head = this.headBone; + if (!head) return; + head.updateWorldMatrix(true, false); + head.getWorldQuaternion(_headWorldQ); + _headEuler.setFromQuaternion(_headWorldQ, "YXZ"); + _headEuler.z = 0; + _headWorldQ.setFromEuler(_headEuler); + head.parent.getWorldQuaternion(_headParentWorldQ); + head.quaternion.copy(_headParentWorldQ).invert().multiply(_headWorldQ); + head.updateWorldMatrix(false, false); + } + + // ==================== IK 应用 ==================== + + // 第一人称脊椎俯仰 IK + // 将 pitchTarget 均分到每根脊椎骨骼,使持枪手臂跟随鼠标上下瞄准 + applyAim1P(camera, pitchTarget) { + if (!this.spineBones.length) return; + this.clearHeadRoll(); + + camera.getWorldQuaternion(_cameraWorldQ); + _aimAxis.set(1, 0, 0).applyQuaternion(_cameraWorldQ); + _aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length); + + this.spineBones[0].parent.updateWorldMatrix(true, false); + for (let i = 0; i < this.spineBones.length; i++) { + this.baseQuats[i].copy(this.spineBones[i].quaternion); // 保存干净姿态 + // 转到父骨骼局部空间后叠加 + this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ); + _localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ); + this.spineBones[i].quaternion.premultiply(_localPitchQ); + this.spineBones[i].updateWorldMatrix(false, false); + } + // 传播到头骨骼,让相机挂点跟随 + this.spineBones[this.spineBones.length - 1].updateWorldMatrix(false, true); + } + + // 第三人称脊椎俯仰 + 偏航 IK,同时对 camera.rotation.x 做视觉补偿偏移 + applyAim3P(camera, isGunEngaged) { + if (!this.spineBones.length) return; + + // 归一化俯仰(-1 ~ +1),用于二次曲线补偿 + const normalizedPitch = camera.rotation.x / (Math.PI * (50 / 180)); + const pitchSq = Math.pow(Math.abs(normalizedPitch), 2.0); + + const pitchTarget = isGunEngaged ? camera.rotation.x : 0; + // 相机视角补偿:向下看时额外抬高,向上看时额外压低,使瞄准更自然 + camera.rotation.x += normalizedPitch > 0 ? pitchSq * 0.35 : -pitchSq * 0.1; + + // 人物朝向补偿:需要往屏幕中心方向旋转 + const yawTarget = isGunEngaged ? -Math.PI * ((10 * (1 + pitchSq * 0.35)) / 180) : 0; + + _aimAxis.set(1, 0, 0).applyQuaternion(camera.quaternion); + _aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length); + _yawQ.setFromAxisAngle(_yawAxis, yawTarget); + _aimQ.premultiply(_yawQ); + + this.spineBones[0].parent.updateWorldMatrix(true, false); + for (let i = 0; i < this.spineBones.length; i++) { + this.baseQuats[i].copy(this.spineBones[i].quaternion); + this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ); + _localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ); + this.spineBones[i].quaternion.premultiply(_localPitchQ); + this.spineBones[i].updateWorldMatrix(false, false); + } + } +} diff --git a/three-player-controller/example/shooting/shooting.html b/three-player-controller/example/shooting/shooting.html new file mode 100644 index 0000000..3558a7c --- /dev/null +++ b/three-player-controller/example/shooting/shooting.html @@ -0,0 +1,214 @@ + + + + + + three-player-controller · FPS Game + + + + + +
+
+ +
+ +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ 14 + Switch Weapon / Scroll +
+
+ RMB + Aim (Hold) +
+
+ LMB + Shoot +
+
+ R + Reload +
+
+
+ +
+ + + + + +
+
+
+ + + + + + + + +
+
+ AMMO + 30 + / + 300 +
+
+
+ +
+ Scene model: + Sketchfab +
+
+ + + + + diff --git a/three-player-controller/example/shooting/shooting.js b/three-player-controller/example/shooting/shooting.js new file mode 100644 index 0000000..00b76be --- /dev/null +++ b/three-player-controller/example/shooting/shooting.js @@ -0,0 +1,294 @@ +import * as THREE from "three"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; + +import { createSceneSetup } from "./core/SceneSetup.js"; +import { LocalPlayer } from "./player/LocalPlayer.js"; +import { DecalSystem } from "./weapon/DecalSystem.js"; +import { WeaponController, MODE } from "./weapon/WeaponController.js"; +import { ShootingEffects } from "./weapon/effects.js"; +import { HUD } from "./ui/HUD.js"; +import { ZombieManager } from "./zombie/ZombieManager.js"; + +const base = import.meta.env.BASE_URL; + +const initPos = new THREE.Vector3(33.21, 4.6, 2.6); + +const NORMAL_MAX_CAM = 220; +const MOUSE_SENSITIVITY = 5; +const PLAYER_MODEL_URL = base + "./glb/swat.glb"; +const FIRST_PERSON_PITCH_OFFSET_BY_MODEL = { + [base + "./glb/swat.glb"]: Math.PI * (16 / 180), +}; + +async function init() { + const container = document.querySelector("#container"); + + let tiles, localPlayer, weapon, zombieManager; + + // ==================== 1. 场景基础 ==================== + const { scene, renderer, camera, controls, bakeProbes } = createSceneSetup({ container }); + + // 添加音频监听器 + const listener = new THREE.AudioListener(); + camera.add(listener); + + camera.position.copy(initPos); + camera.lookAt(initPos.x, initPos.y, initPos.z + 1); + controls.target.set(initPos.x, initPos.y, initPos.z + 1); + + const stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + const timer = new THREE.Timer(); + let _prevGunEngaged = false; + renderer.setAnimationLoop(() => { + timer.update(); + const elapsed = timer.getElapsed(); + const dt = timer.getDelta(); + + if (localPlayer && weapon && zombieManager) { + const spineIK = localPlayer.spineIK; + const gunEngaged = weapon.isGunEngaged(); + + // 开火/瞄准时切 mouseMode 5(锁定朝向跟相机),其余情况用 1 + if (gunEngaged !== _prevGunEngaged) { + localPlayer.setThirdMouseMode(gunEngaged ? 5 : 1); + _prevGunEngaged = gunEngaged; + } + + // 恢复脊椎骨骼到上一帧的干净动画姿态 + if (gunEngaged) spineIK.restoreBones(); + + // 驱动动画与物理(传入 dt,使上半身 mixer 与主 mixer 同步) + localPlayer ? localPlayer.update(dt) : controls.update(); + + // 应用脊椎 IK 与朝向(在 player.update() 后、weapon.update() 前) + if (gunEngaged) { + if (localPlayer.getIsFirstPerson()) { + localPlayer.applyHipsCorrection(); + spineIK.applyAim1P(camera, localPlayer.pitchTarget1P); + } else { + // console.log('应用 3P IK'); + localPlayer.applyHipsCorrection(); + spineIK.applyAim3P(camera, true); + } + } + + // 更新武器状态(特效、射线命中、连射节拍)——骨骼此时已含 IK + weapon.update(elapsed, dt); + + // 丧尸 AI + zombieManager.update(dt, localPlayer.getPosition()); + + } else { + controls?.update(); + } + + renderer.render(scene, camera); + stats.update(); + }); + + + // ==================== 2. 资源加载器 ==================== + const gltfLoader = new GLTFLoader(); + + const draco = new DRACOLoader(); + draco.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(draco); + + const ktx2 = new KTX2Loader(); + ktx2.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2); + + // ==================== 3. 场景模型 ==================== + const { scene: mapScene } = await gltfLoader.loadAsync( + base + "./glb/horror_corridor.glb" + ); + mapScene.name = "sceneGLB"; + mapScene.scale.set(0.09, 0.09, 0.09); + scene.add(mapScene); + scene.updateMatrixWorld(true); + bakeProbes(); + + document.addEventListener("contextmenu", (e) => e.preventDefault()); + + const playerGltf = await gltfLoader.loadAsync(PLAYER_MODEL_URL); + + // ==================== 4. 本地玩家 ==================== + localPlayer = new LocalPlayer({ scene, camera, controls }); + await localPlayer.init({ + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: 0.01, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: "jump", + flyAnim: "flying", + flyIdleAnim: "flyidle", + headBoneName: "mixamorigHead", + firstPersonPitchOffset: FIRST_PERSON_PITCH_OFFSET_BY_MODEL[PLAYER_MODEL_URL] ?? 0, + rotateY: - Math.PI / 2, + }, + initPos: initPos, + minCamDistance: 10, + maxCamDistance: NORMAL_MAX_CAM, + enableOverShoulderView: true, + mouseSensitivity: MOUSE_SENSITIVITY, + camLookAtHeightRatio: 0.9, + keyMap: { toggleFly: null }, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: mapScene } }, + ], + }); + + localPlayer.getPlayerModel()?.traverse((c) => { + if (c.isMesh) { + c.frustumCulled = false; + c.castShadow = true; + c.receiveShadow = true; + } + }); + + // ==================== 5. HUD ==================== + const weaponSlots = [ + { key: "1", mode: "primary", label: "Rifle" }, + { key: "4", mode: "normal", label: "Fists" }, + ]; + const hud = new HUD(weaponSlots); + hud.build(); + + // ==================== 6. 特效 ==================== + let effects = new ShootingEffects(scene, { + listener, + flashScale: 0.15, + smokeSize: 0.8 + }); + await effects.load( + base + "./img/muzzle_flash.png", + base + "./img/smoke.png", + base + "./audio/gun_shot.mp3", + base + "./audio/reload.mp3", + ); + + // ==================== 7. 弹痕系统 ==================== + const decalSystem = new DecalSystem(scene, 60, 0.25); + await decalSystem.loadMaterials(["img/bullet_hole.png"], base); + + // ==================== 8. 丧尸管理器 ==================== + zombieManager = new ZombieManager(scene, { + loader: gltfLoader, + colliders: localPlayer.getColliderMeshes(), + modelUrl: base + "./glb/zombie.glb", + scale: 0.01, + walkAnim: "walking", + idleAnim: "Idle", + punchAnim: "punching", + runAnim: "running", + rotateY: Math.PI, + speed: 120, + }); + + // ==================== 9. 武器控制器 ==================== + weapon = new WeaponController({ + scene, + camera, + localPlayer, + decalSystem, + effects, + hud, + zombieManager, + }); + + await weapon.load(gltfLoader, base); + weapon.setupAnimations(); + weapon.bindInput(); + weapon.switchMode(MODE.PRIMARY); + + // 让 LocalPlayer 的 1P 俯仰驱动能感知武器状态 + localPlayer.setGunEngagedGetter(() => weapon.isGunEngaged()); + + // 初始刷新武器槽高亮 + hud.update(weapon.getMode()); + + // ==================== 10. 鼠标拾取 ==================== + const raycaster = new THREE.Raycaster(); + const mouse = new THREE.Vector2(); + + const getMousePickingPoint = (event) => { + const rect = container.getBoundingClientRect(); + // 将鼠标位置归一化为设备坐标 (NDC) + mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + // 检测场景中所有的物体(包含 3D Tiles 和玩家模型) + const intersects = raycaster.intersectObjects(scene.children, true); + + if (intersects.length > 0) { + return intersects[0].point; + } + return null; + }; + + container.addEventListener("mousedown", (e) => { + // 只有当鼠标没有被锁定(非第一人称控制状态)时才执行点击拾取 + if (document.pointerLockElement !== renderer.domElement) { + const point = getMousePickingPoint(e); + if (point) { + console.log("鼠标点击的 3D 坐标:", point); + // 你可以在这里添加逻辑,比如在点击位置生成丧尸: + // zombieManager.spawnZombie(point); + } + } + }); + + // ==================== 丧尸波次 ==================== + await Promise.all([ + zombieManager.startWave({ origin: new THREE.Vector3(2.012, 4.6, -0.78), count: 5, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(-3.731, 4.6, -1.824), count: 2, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(11.911, 4.6, -4.010), count: 2, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(13.567, 4.6, 7.001), count: 2, radius: 1 }), + ]); + + window.hideLoader?.(); + + // ==================== Start Screen ==================== + await new Promise((resolve) => { + const screen = document.getElementById("start-screen"); + screen.addEventListener("mousedown", (e) => e.stopPropagation()); + screen.addEventListener("mouseup", (e) => e.stopPropagation()); + screen.addEventListener("click", (e) => e.stopPropagation()); + screen.style.display = "flex"; + document.getElementById("start-btn").addEventListener("click", () => { + screen.remove(); + document.body.style.cursor = "none"; + document.body.requestPointerLock?.(); + resolve(); + }, { once: true }); + }); + + // ==================== 窗口缩放 ==================== + window.addEventListener("resize", () => { + const cont = document.querySelector("#container"); + if (!cont) return; + camera.aspect = cont.clientWidth / cont.clientHeight; + renderer.setSize(cont.clientWidth, cont.clientHeight); + camera.updateProjectionMatrix(); + }); +} + +init(); diff --git a/three-player-controller/example/shooting/ui/HUD.js b/three-player-controller/example/shooting/ui/HUD.js new file mode 100644 index 0000000..e649630 --- /dev/null +++ b/three-player-controller/example/shooting/ui/HUD.js @@ -0,0 +1,65 @@ +// HUD:准星 + 武器选择栏 + 弹药显示 +// 不依赖任何 Three.js 对象,只操作 DOM +export class HUD { + constructor(weaponSlots) { + this._slots = weaponSlots; // [{ key, mode, label }] + + this._crosshair = document.getElementById("crosshair"); + this._weaponHud = document.getElementById("weapon-hud"); + this._ammoHud = document.getElementById("ammo-hud"); + this._ammoCurrent = document.getElementById("ammo-current-val"); + this._ammoTotal = document.getElementById("ammo-total-val"); + this._hitTimer = null; + } + + // ==================== 初始化 ==================== + + build() { + this._weaponHud.innerHTML = ""; + for (let i = this._slots.length - 1; i >= 0; i--) { + const slot = this._slots[i]; + const el = document.createElement("div"); + el.className = "weapon-slot"; + el.dataset.mode = slot.mode; + el.innerHTML = `${slot.key}${slot.label}`; + this._weaponHud.appendChild(el); + } + } + + // ==================== 武器槽 ==================== + + update(currentMode) { + this._weaponHud.querySelectorAll(".weapon-slot").forEach((el) => { + el.classList.toggle("active", el.dataset.mode === currentMode); + }); + } + + // ==================== 弹药 ==================== + + updateAmmo(current, max) { + if (this._ammoCurrent) this._ammoCurrent.textContent = current; + if (this._ammoTotal) this._ammoTotal.textContent = max; + } + + showAmmo() { + if (this._ammoHud) this._ammoHud.style.display = "flex"; + } + + hideAmmo() { + if (this._ammoHud) this._ammoHud.style.display = "none"; + } + + // ==================== 准星 ==================== + + showCrosshair() { this._crosshair.style.display = "block"; } + hideCrosshair() { this._crosshair.style.display = "none"; } + + flashHit() { + if (!this._crosshair) return; + if (this._hitTimer) clearTimeout(this._hitTimer); + this._crosshair.style.backgroundColor = "red"; + this._hitTimer = setTimeout(() => { + this._crosshair.style.backgroundColor = ""; + }, 100); + } +} diff --git a/three-player-controller/example/shooting/weapon/DecalSystem.js b/three-player-controller/example/shooting/weapon/DecalSystem.js new file mode 100644 index 0000000..05ad902 --- /dev/null +++ b/three-player-controller/example/shooting/weapon/DecalSystem.js @@ -0,0 +1,113 @@ +import { + Matrix3, + Mesh, + MeshBasicMaterial, + Object3D, + PlaneGeometry, + TextureLoader, +} from "three"; + +const _normalMatrix = new Matrix3(); // 法线矩阵 +const _decalHelper = new Object3D(); // 弹孔朝向辅助体 + +// 弹孔贴花系统(对象池) +export class DecalSystem { + constructor(scene, maxDecals = 60, decalSize = 0.02) { + // ==================== 场景引用 ==================== + this._scene = scene; + + // ==================== 对象池配置 ==================== + this._maxDecals = maxDecals; // 最大弹孔数量,超出后移除最旧的 + this._pool = []; // 场景中现存的弹孔 Mesh + this._mats = []; // 可用的弹痕材质(多张随机选取) + this._geo = new PlaneGeometry(decalSize, decalSize); // 弹孔平面几何体 + this.onSpawn = null; // (hitPoint, hitNormal) => void,多人模式注入广播回调 + } + + // ==================== 初始化 ==================== + + // 批量加载弹痕贴图,生成对应材质 + async loadMaterials(files, baseUrl) { + const loader = new TextureLoader(); + await Promise.all( + files.map(async (f) => { + const tex = await loader.loadAsync(baseUrl + f); + this._mats.push( + new MeshBasicMaterial({ + map: tex, + transparent: true, + depthTest: true, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -4, // 防止与墙面 Z-fighting + }) + ); + }) + ); + } + + // ==================== 生成 / 清除 ==================== + + // 在射线命中点生成弹孔,并触发命中硝烟特效 + spawn(hit, effects) { + if (!hit?.face || !hit?.object) return; + if (!this._mats.length) return; + + // 计算世界法线 + _normalMatrix.getNormalMatrix(hit.object.matrixWorld); + const hitNormal = hit.face.normal.clone().applyMatrix3(_normalMatrix).normalize(); + const hitPoint = hit.point.clone(); + + // 计算弹孔朝向(面向法线 + 随机旋转) + _decalHelper.position.copy(hitPoint); + _decalHelper.lookAt(hitPoint.clone().add(hitNormal)); + _decalHelper.rotation.z = Math.random() * Math.PI * 2; + + // 创建弹孔 Mesh,沿法线微偏移以避免 Z-fighting + const mat = this._mats[Math.floor(Math.random() * this._mats.length)]; + const decal = new Mesh(this._geo, mat); + decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001); + decal.rotation.copy(_decalHelper.rotation); + decal.renderOrder = 1; + this._scene.add(decal); + + // 超出上限时移除最旧弹孔 + this._pool.push(decal); + if (this._pool.length > this._maxDecals) { + this._scene.remove(this._pool.shift()); + } + + // 命中硝烟特效 + effects?.triggerHitSmoke(hitPoint, hitNormal); + + // 广播给其他客户端(多人模式注入) + this.onSpawn?.(hitPoint, hitNormal); + } + + // 根据世界坐标和法线直接生成弹孔(接收远程弹痕时使用,不触发硝烟) + spawnAtPoint(hitPoint, hitNormal) { + if (!this._mats.length) return; + + _decalHelper.position.copy(hitPoint); + _decalHelper.lookAt(hitPoint.clone().add(hitNormal)); + _decalHelper.rotation.z = Math.random() * Math.PI * 2; + + const mat = this._mats[Math.floor(Math.random() * this._mats.length)]; + const decal = new Mesh(this._geo, mat); + decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001); + decal.rotation.copy(_decalHelper.rotation); + decal.renderOrder = 1; + this._scene.add(decal); + + this._pool.push(decal); + if (this._pool.length > this._maxDecals) { + this._scene.remove(this._pool.shift()); + } + } + + // 清空所有弹孔 + clear() { + for (const d of this._pool) this._scene.remove(d); + this._pool.length = 0; + } +} diff --git a/three-player-controller/example/shooting/weapon/WeaponController.js b/three-player-controller/example/shooting/weapon/WeaponController.js new file mode 100644 index 0000000..9a53cca --- /dev/null +++ b/three-player-controller/example/shooting/weapon/WeaponController.js @@ -0,0 +1,590 @@ +import { AnimationMixer, LoopOnce, Object3D, Quaternion, Vector3 } from "three"; + +export const MODE = Object.freeze({ NORMAL: "normal", PRIMARY: "primary" }); + +const _muzzleWorldPos = new Vector3(); // 枪口世界坐标 + +export class WeaponController { + constructor({ scene, camera, localPlayer, decalSystem, effects, hud, zombieManager }) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._camera = camera; + this._player = localPlayer; + this._decalSystem = decalSystem; + this._effects = effects; + this._hud = hud; + this._zombieManager = zombieManager; + + // ==================== 武器配置 ==================== + this._gunScale = 0.1; // 枪模型缩放 + this._gunPos = [1, 26.5, 2]; // 枪挂载到右手骨骼的位置偏移 + this._gunBarrelDir = new Vector3(0, 0, -1); // 枪管沿模型局部 -Z + this._gunTargetDir = new Vector3(0, 1, 0); // 手骨 +Y = 手指方向 + this._gunRoll = Math.PI / 2; + this._gunMuzzleOffset = [0, 80, -480]; // 枪口偏移 [左, 上, 前] + + // ==================== 武器模型 ==================== + this._weaponModel = null; // 枪模型根节点 + this._muzzlePoint = null; // 枪口标记点(供特效定位) + this._weaponMixer = null; // 武器动画混合器 + this._weaponReloadAction = null; // 换弹动画 + this._weaponShootAction = null; // 射击动画 + this._shotSound = null; // 射击音效 + this._shakeIntensity = 0; // 当前镜头抖动强度 + this._shakeDecay = 10; // 抖动衰减系数 + + // ==================== 模式 & 武器槽 ==================== + this._currentMode = MODE.NORMAL; + this._weaponSlots = [ + { key: "1", mode: MODE.PRIMARY, label: "Rifle" }, + { key: "4", mode: MODE.NORMAL, label: "Fists" }, + ]; + + // ==================== 相机距离限制 ==================== + this._normalMaxCam = 220; // 徒手模式最远距离 + this._armedMaxCam = 100; // 瞄准模式最远距离 + + // ==================== 移速配置 ==================== + this._baseSpeed = 300; + this._armedSpeed = 240; // 持枪移速 = baseSpeed * 0.8 + + // ==================== 开火状态机 ==================== + this._isAiming = false; // 是否瞄准中(RMB 触发,含视角缩放) + this._isSoftAiming = false; // 是否软瞄准中(单击开火触发,不缩放视角) + this._isFiring = false; // 是否连射中 + this._isTriggerDown = false; // 鼠标左键是否按住 + this._isReloading = false; // 是否换弹中 + this._magSize = 30; // 弹夹容量 + this._currentAmmo = 30; // 当前子弹数 + this._totalAmmo = 300; // 总备用弹药量 + this._firstShotTimer = null; // 180ms 后必出一枪的计时器 + this._holdAimTimer = null; // 停火后 2s 才放枪的计时器 + this._lastFireTime = 0; + this._elapsed = 0; // 由 update() 每帧更新,供 setTimeout 回调读取 + + this._reloadTimer1 = null; // 换弹半程计时器 + this._reloadTimer2 = null; // 换弹完成计时器 + this._RELOAD_DURATION_MS = 2000; // 换弹动画时长,需根据实际模型动画调整 + this._FIRE_RATE_S = 0.1; // 连射间隔(秒) + this._FIRE_ANIM_FADE_MS = 180; // 与 playAnimation 默认 fade(0.18s) 对齐 + this._HOLD_AIM_DURATION = 2000; // 停火后保持瞄准状态时长(ms) + + // ==================== 射线命中缓存 ==================== + this._frameHit = null; // 每帧射线命中结果 + + // ==================== 多人扩展 ==================== + this.onHitPlayer = null; // (playerId, damage) => void,由外部注入 + } + + // ==================== 初始化 ==================== + + // 加载武器模型并挂载到右手骨骼 + async load(gltfLoader, baseUrl) { + const gltf = await gltfLoader.loadAsync(baseUrl + "./glb/ak47.glb"); + this._weaponModel = gltf.scene; + + const person = this._player.getPlayerModel(); + const rightHand = person?.getObjectByName("mixamorigRightHand"); + if (!rightHand) { + console.warn("[WeaponController] 未找到右手骨骼 mixamorigRightHand"); + return; + } + + this._weaponModel.scale.setScalar(this._gunScale); + this._weaponModel.position.set(...this._gunPos); + + this._magazineBone = null; + this._weaponModel.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; + } + }); + + // 找到备用弹夹骨骼,初始缩为 0 隐藏 + this._magazineBone = this._weaponModel.getObjectByName("Bone002_01"); + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + + // 对齐枪管方向并附加滚转 + const alignQ = new Quaternion().setFromUnitVectors(this._gunBarrelDir, this._gunTargetDir); + const rollQ = new Quaternion().setFromAxisAngle(this._gunTargetDir, this._gunRoll); + this._weaponModel.quaternion.copy(rollQ.multiply(alignQ)); + + this._weaponModel.visible = false; + rightHand.add(this._weaponModel); + + // 枪口标记点(供特效定位) + this._muzzlePoint = new Object3D(); + this._muzzlePoint.position.set(...this._gunMuzzleOffset); + this._weaponModel.add(this._muzzlePoint); + + // 初始化武器动画 + this._weaponMixer = new AnimationMixer(this._weaponModel); + const reloadClip = gltf.animations.find(a => a.name === "Armature.003|reload"); + if (reloadClip) { + this._weaponReloadAction = this._weaponMixer.clipAction(reloadClip); + this._weaponReloadAction.setLoop(LoopOnce); + // 关键修改:取消锁定在最后一帧。 + // 这样动画结束后,骨骼会自动通过 Mixer 的权重回归到 Bind Pose(初始插在枪上的位置)。 + this._weaponReloadAction.clampWhenFinished = false; + this._weaponReloadAction.timeScale = 1.3; + } + + } + + // 注册所有射击相关动画集 + setupAnimations() { + this._player.registerLocomotionSet("primary", { + idle: "rifle_idle", + walking: "rifle_walk", + running: "rifle_run", + jumping: "rifle_jump", + }); + this._player.registerLocomotionSet("primary_aim", { + idle: "rifle_idle_aim3", + walking: "rifle_walk", + running: "rifle_run", + jumping: "rifle_jump", + }); + + // 上半身专用动画:仅覆写脊椎以上骨骼,下半身继续播放移动动画 + this._player.registerUpperAnimation("upper_shoot", "rifle_shoot3", { loop: true, timeScale: 0.5 }); + this._player.registerUpperAnimation("upper_reload", "reload", { loop: false, timeScale: 1.5 }); + this._player.registerUpperAnimation("upper_aim", "rifle_idle_aim3", { loop: true, timeScale: 0.5 }); + + this._player.initIdleHipsQ("rifle_idle_aim3"); + } + + // 绑定键鼠输入 + bindInput() { + document.addEventListener("mousedown", (e) => { + if (!this._canRunCombatLogic()) return; + if (e.button === 2 && !this._player.getIsFirstPerson()) { + this.enterAim(); + } + if (e.button === 0) { + if (!this._firstShotTimer && !this._isFiring) this._triggerShootAnim(); + this._startFiring(); + } + }); + + document.addEventListener("mouseup", (e) => { + if (e.button === 2 && !this._player.getIsFirstPerson()) this.exitAim(); + if (e.button === 0) this._stopFiring(); + }); + + document.addEventListener("keydown", (e) => { + const k = e.key.toLowerCase(); + if (k === "1") this.switchMode(MODE.PRIMARY); + if (k === "4") this.switchMode(MODE.NORMAL); + if (k === "q") this.switchMode(this._currentMode === MODE.PRIMARY ? MODE.NORMAL : MODE.PRIMARY); + if (k === "r") this.reload(); + }); + + document.addEventListener("wheel", (e) => { + if (this._isReloading) return; + const idx = this._weaponSlots.findIndex((s) => s.mode === this._currentMode); + const next = (idx + (e.deltaY > 0 ? 1 : -1) + this._weaponSlots.length) % this._weaponSlots.length; + this.switchMode(this._weaponSlots[next].mode); + }, { passive: true }); + } + + // ==================== 主循环 ==================== + + // 每帧由 shooting.js 主循环调用 + update(elapsed, dt) { + this._elapsed = elapsed; + const canRunCombatLogic = this._canRunCombatLogic(); + + // 飞行时强制取消换弹 + if (this._currentMode === MODE.PRIMARY && this._isReloading && this._player.getIsFlying()) { + this._cancelReload(); + } + // 飞行/跳跃等不可战斗状态时强制中断武器逻辑(换弹中不打断,由换弹自己管) + if (this._currentMode === MODE.PRIMARY && !canRunCombatLogic && !this._isReloading) { + this._forceStopCombatLogic(); + } + + // 第一人称:静止时自动进入瞄准,移动时退出 + if (this._currentMode === MODE.PRIMARY && this._player.getIsFirstPerson()) { + if (canRunCombatLogic && !this._isAiming) this.enterAim(); + else if (!canRunCombatLogic && this._isAiming) this.exitAim(); + } + + // 更新粒子特效 + this._effects?.update(dt); + + // 更新武器动画混合器 + if (this._weaponMixer) this._weaponMixer.update(dt); + + // 非换弹时每帧强制隐藏备用弹夹骨骼(mixer 更新后覆写) + if (this._magazineBone && !this._isReloading) this._magazineBone.scale.setScalar(0.0001); + + // 镜头抖动(指数衰减) + if (this._shakeIntensity > 0.0001) { + this._camera.rotation.x += (Math.random() - 0.5) * this._shakeIntensity; + this._camera.rotation.y += (Math.random() - 0.5) * this._shakeIntensity * 0.4; + this._shakeIntensity *= Math.exp(-this._shakeDecay * dt); + } else { + this._shakeIntensity = 0; + } + + // 更新射线命中缓存 + this._frameHit = this._player.getCenterScreenRaycastHit(); + + // 连射节拍 + if (canRunCombatLogic && this._isFiring && elapsed - this._lastFireTime >= this._FIRE_RATE_S) { + if (this._currentAmmo <= 0) { + this._stopFiring(); + // 只有总弹药大于0时才触发自动换弹 + if (this._totalAmmo > 0) this.reload(); + } else { + this._lastFireTime = elapsed; + this._fireOnce(); + } + } + } + + // ==================== 状态查询 ==================== + + // 是否处于任意武器激活状态(等待首发 / 连射 / hold-aim 冷却 / 瞄准) + // SpineIK 和主循环通过此方法判断是否需要驱动 IK + isGunEngaged() { + return ( + this._isAiming || + this._isSoftAiming || + this._isFiring || + this._isReloading || + this._firstShotTimer !== null || + this._holdAimTimer !== null + ); + } + + getMode() { return this._currentMode; } + + resetAmmo() { + this._currentAmmo = this._magSize; + this._totalAmmo = 300; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + } + + _canRunCombatLogic() { + if (this._currentMode !== MODE.PRIMARY) return false; + if (this._isReloading) return false; + if (this._player.getIsFlying()) return false; + if (typeof this._player.isCombatLocomotionAllowed === "function") { + return this._player.isCombatLocomotionAllowed(); + } + return true; + } + + _forceStopCombatLogic() { + this._isTriggerDown = false; + if (this._firstShotTimer) { + clearTimeout(this._firstShotTimer); + this._firstShotTimer = null; + } + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + this._player.stopUpperBody(0.18); + this._cancelHoldAimTimer(); + if (this._isAiming) this.exitAim(); + else { + this._isSoftAiming = false; + this._hud.hideCrosshair(); + } + } + + // ==================== 模式切换 & 瞄准 ==================== + + switchMode(newMode) { + if (this._currentMode === newMode || this._isReloading) return; + + // 退出旧模式 + if (this._currentMode === MODE.PRIMARY) { + this.exitAim(); + this._isSoftAiming = false; + this._isTriggerDown = false; + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + this._player.stopUpperBody(0.18); + if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; } + this._cancelHoldAimTimer(); + this._hud.hideCrosshair(); + this._hud.hideAmmo?.(); + this._player.switchLocomotionSet("default"); + this._player.setMaxCamDistance(this._normalMaxCam); + this._player.setPlayerSpeed(this._baseSpeed); + } + + this._currentMode = newMode; + + // 进入新模式 + if (this._currentMode === MODE.PRIMARY) { + this._player.switchLocomotionSet("primary"); + this._player.setPlayerSpeed(this._armedSpeed); + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + this._hud.showAmmo?.(); + } + + if (this._weaponModel) this._weaponModel.visible = (this._currentMode === MODE.PRIMARY); + } + + // 进入瞄准模式;soft=true 时为软瞄准,跳过视角缩放 + enterAim(soft = false) { + if (soft) { + if (this._isSoftAiming) return; + this._isSoftAiming = true; + } else { + if (!this._canRunCombatLogic() || this._isAiming) return; + this._isAiming = true; + this._isSoftAiming = false; + this._player.setMaxCamDistance(this._armedMaxCam); + } + this._player.switchLocomotionSet("primary_aim"); + this._hud.showCrosshair(); + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + if (this._player.getIsFirstPerson()) { + this._camera.rotation.x = this._player.getFirstPersonPitchOffset(); + } + } + + // 退出瞄准模式;soft=true 时为软瞄准,跳过视角还原 + exitAim(soft = false) { + if (soft) { + if (!this._isSoftAiming) return; + this._isSoftAiming = false; + } else { + if (!this._isAiming) return; + this._isAiming = false; + this._player.setMaxCamDistance(this._normalMaxCam); + this._cancelHoldAimTimer(); + } + this._player.switchLocomotionSet( + this._currentMode === MODE.PRIMARY ? "primary" : "default" + ); + this._player.stopUpperBody(0.18); + this._hud.hideCrosshair(); + if (this._player.getIsFirstPerson()) { + this._camera.rotation.x = this._player.pitchTarget1P; + } + } + + // 换弹 + reload() { + if (this._currentMode !== MODE.PRIMARY || this._isReloading || this._totalAmmo <= 0 || this._currentAmmo === this._magSize) return; + if (this._player.getIsFlying()) return; + + this._isReloading = true; + + // 1. 中断当前所有攻击状态 + this._isTriggerDown = false; + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; } + this._cancelHoldAimTimer(); + this.exitAim(); // 换弹时强制退出瞄准 + + // 2. 播放换弹动画(仅上半身,下半身继续播放移动动画) + this._player.playUpperBody("upper_reload", { force: true, fade: 0.18 }); + + // 换弹音效 + this._effects?.triggerReloadSound(); + + // 显示备用弹夹骨骼 + if (this._magazineBone) this._magazineBone.scale.setScalar(1); + + // 播放武器自身的换弹动画 + if (this._weaponReloadAction) { + this._weaponReloadAction.reset(); + this._weaponReloadAction.play(); + } + + // 动画播放到一半时停止,同时隐藏备用弹夹骨骼(避免 snap-back 到悬空位置时可见) + this._reloadTimer1 = setTimeout(() => { + this._reloadTimer1 = null; + if (this._isReloading && this._weaponReloadAction) { + this._weaponReloadAction.stop(); + } + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + }, this._RELOAD_DURATION_MS / 2); + + // 3. 锁定状态直到动画结束 + this._reloadTimer2 = setTimeout(() => { + this._reloadTimer2 = null; + // 此时 _isReloading 变回 false,允许开火 + this._isReloading = false; + + // 停止上半身换弹动画,让全身 locomotion 动画重新完整接管上半身 + this._player.stopUpperBody(0.18); + + // 确保动画彻底停止(以防上面的一半时间停止没触发成功) + if (this._weaponReloadAction) this._weaponReloadAction.stop(); + + // 计算需要补充的子弹数量 + const need = this._magSize - this._currentAmmo; + const transfer = Math.min(need, this._totalAmmo); + this._currentAmmo += transfer; + this._totalAmmo -= transfer; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + + // 换弹结束后,衔接回持枪状态 + if (this._currentMode === MODE.PRIMARY) { + this._player.switchLocomotionSet("primary_aim"); + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + }, this._RELOAD_DURATION_MS); + } + + // ==================== 开火状态机 ==================== + + _startFiring() { + if (!this._canRunCombatLogic()) return; + if (this._firstShotTimer || this._isFiring) return; + + // 没子弹时按左键,直接触发换弹 + if (this._currentAmmo <= 0) { + if (this._totalAmmo > 0) this.reload(); + return; + } + + // 切换到软瞄准状态 + if (!this._isAiming && !this._isSoftAiming) { + this.enterAim(true); + } + + this._cancelHoldAimTimer(); + this._isTriggerDown = true; + + // 无论鼠标是否松开,180ms 后必定打出第一枪 + this._firstShotTimer = setTimeout(() => { + this._firstShotTimer = null; + if (!this._canRunCombatLogic()) { + this._forceStopCombatLogic(); + return; + } + this._fireOnce(); + this._lastFireTime = this._elapsed; + + if (this._isTriggerDown) { + this._isFiring = true; // 持续按住 → 连射 + } else { + // 单击松开:打出第一枪后处理后续状态 + if (this._weaponShootAction) this._weaponShootAction.stop(); + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + }, this._FIRE_ANIM_FADE_MS); + } + + _stopFiring() { + this._isTriggerDown = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + + if (!this._isFiring) { + // 换弹中或 hold-aim 缓冲期内松开鼠标,不打断上半身动画 + if (this._isReloading || this._holdAimTimer) return; + + // 单发(鼠标在 180ms 定时器触发前就松开):恢复或停止上半身 + if (this._isAiming || this._isSoftAiming) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + } else { + this._player.stopUpperBody(0.18); + } + return; + } + + this._isFiring = false; + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + + _fireOnce() { + if (!this._canRunCombatLogic()) return; + + // 播放音效 + if (this._shotSound && this._shotSound.buffer) { + if (this._shotSound.isPlaying) this._shotSound.stop(); + this._shotSound.play(); + } + + // 第三人称触发镜头抖动 + if (!this._player.getIsFirstPerson()) { + this._shakeIntensity = 0.01; // 弧度单位,0.02 约为 1.1 度 + } + + this._currentAmmo--; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + + // 枪口火焰 + 音效:无论命中什么都必须先触发 + if (this._effects && this._muzzlePoint) { + this._muzzlePoint.updateWorldMatrix(true, false); + this._muzzlePoint.getWorldPosition(_muzzleWorldPos); + this._effects.triggerMuzzleFlash(_muzzleWorldPos, this._camera); + } + + // 多人模式:检测是否命中远程玩家(dmgMult 由命中部位决定:头×2,躯干×1,四肢×0.75) + const hitPlayerId = this._frameHit?.object?.userData?.playerId; + if (hitPlayerId && this.onHitPlayer) { + const dmgMult = this._frameHit.object.userData.dmgMult ?? 1.0; + this.onHitPlayer(hitPlayerId, Math.round(30 * dmgMult)); + this._hud.flashHit(); + return; + } + + const zombieId = this._frameHit?.object?.userData?.zombieId; + if (zombieId && this._zombieManager) { + this._zombieManager.onHit(zombieId, 30); + this._hud.flashHit(); + } else { + this._decalSystem.spawn(this._frameHit, this._effects); + } + } + + _triggerShootAnim() { + if (!this._canRunCombatLogic()) return; + // 上半身播放开火动画,下半身继续播放当前移动动画 + this._player.playUpperBody("upper_shoot", { force: true, fade: 0.18 }); + if (this._weaponShootAction) this._weaponShootAction.play(); + } + + // 停火后延迟 2s 自动退出瞄准姿态 + _scheduleHoldAim() { + this._cancelHoldAimTimer(); + this._holdAimTimer = setTimeout(() => { + this._holdAimTimer = null; + if (!this._isAiming) { + this.exitAim(true); + } + }, this._HOLD_AIM_DURATION); + } + + _cancelReload() { + if (!this._isReloading) return; + this._isReloading = false; + if (this._reloadTimer1) { clearTimeout(this._reloadTimer1); this._reloadTimer1 = null; } + if (this._reloadTimer2) { clearTimeout(this._reloadTimer2); this._reloadTimer2 = null; } + if (this._weaponReloadAction) this._weaponReloadAction.stop(); + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + this._effects?.stopReloadSound(); + this._player.stopUpperBody(0.18); + this._hud.hideCrosshair(); + } + + _cancelHoldAimTimer() { + if (this._holdAimTimer) { clearTimeout(this._holdAimTimer); this._holdAimTimer = null; } + } +} diff --git a/three-player-controller/example/shooting/weapon/effects.js b/three-player-controller/example/shooting/weapon/effects.js new file mode 100644 index 0000000..66de6f7 --- /dev/null +++ b/three-player-controller/example/shooting/weapon/effects.js @@ -0,0 +1,229 @@ +import { + AdditiveBlending, + Audio, + AudioLoader, + BufferAttribute, + BufferGeometry, + Mesh, + MeshBasicMaterial, + NormalBlending, + PlaneGeometry, + Points, + PointsMaterial, + Quaternion, + TextureLoader, + Vector3, +} from "three"; + +// 复用的临时量(避免每帧 GC) +const _rollQ = new Quaternion(); +const _rollAxis = new Vector3(0, 0, 1); +const _camWorldQ = new Quaternion(); + +const flashDuration = 0.06; // 枪口火焰持续时间(秒) +const smokeCount = 14; // 每次命中的粒子数 +const smokeLifetime = 0.7; // 硝烟存活时间(秒) +const smokePoolSize = 8; // 硝烟对象池大小 + +// ==================== 枪口火焰 ==================== + +class MuzzleFlash { + constructor(scene, texture, scale = 0.01) { + this._mesh = new Mesh( + new PlaneGeometry(1, 1), + new MeshBasicMaterial({ + map: texture, + transparent: true, + blending: AdditiveBlending, + depthWrite: false, + opacity: 0, + }) + ); + this._mesh.visible = false; + this._timer = 0; + this._scale = scale; + this._cam = null; + scene.add(this._mesh); + } + + // 在枪口位置触发火焰 + trigger(worldPos, camera) { + this._mesh.position.copy(worldPos); + this._mesh.scale.setScalar(this._scale); + camera.getWorldQuaternion(_camWorldQ); + this._mesh.quaternion.copy(_camWorldQ); + _rollQ.setFromAxisAngle(_rollAxis, Math.random() * Math.PI * 2); // 随机滚转,避免重复感 + this._mesh.quaternion.multiply(_rollQ); + this._mesh.material.opacity = 1; + this._mesh.visible = true; + this._timer = flashDuration; + this._cam = camera; + } + + update(dt) { + if (!this._mesh.visible) return; + this._timer -= dt; + if (this._timer <= 0) { + this._mesh.visible = false; + return; + } + // billboard:每帧对齐相机朝向 + if (this._cam) { this._cam.getWorldQuaternion(_camWorldQ); this._mesh.quaternion.copy(_camWorldQ); } + this._mesh.material.opacity = this._timer / flashDuration; // 线性淡出 + } +} + +// ==================== 命中硝烟 ==================== + +class SmokeEffect { + constructor(scene, texture, size = 0.06) { + this._active = false; + this._timer = 0; + this._pos = new Float32Array(smokeCount * 3); // 粒子位置缓冲 + this._vels = Array.from({ length: smokeCount }, () => new Vector3()); // 粒子速度 + + const geo = new BufferGeometry(); + geo.setAttribute("position", new BufferAttribute(this._pos, 3)); + + this._points = new Points(geo, new PointsMaterial({ + map: texture, + size: size, + sizeAttenuation: true, + transparent: true, + opacity: 0, + depthWrite: false, + blending: NormalBlending, + })); + this._points.visible = false; + scene.add(this._points); + } + + // 在命中点触发硝烟,沿法线方向散射 + trigger(worldPos, worldNormal) { + this._active = true; + this._timer = smokeLifetime; + this._points.visible = true; + + for (let i = 0; i < smokeCount; i++) { + const i3 = i * 3; + this._pos[i3] = worldPos.x + (Math.random() - 0.5) * 0.02; + this._pos[i3 + 1] = worldPos.y + (Math.random() - 0.5) * 0.02; + this._pos[i3 + 2] = worldPos.z + (Math.random() - 0.5) * 0.02; + this._vels[i] + .set( + (Math.random() - 0.5) * 0.5, + 0.15 + Math.random() * 0.25, + (Math.random() - 0.5) * 0.5 + ) + .addScaledVector(worldNormal, 0.3 + Math.random() * 0.4); + } + this._points.geometry.attributes.position.needsUpdate = true; + } + + update(dt) { + if (!this._active) return; + this._timer -= dt; + if (this._timer <= 0) { + this._active = false; + this._points.visible = false; + return; + } + this._points.material.opacity = (this._timer / smokeLifetime) * 0.7; + for (let i = 0; i < smokeCount; i++) { + const i3 = i * 3; + this._pos[i3] += this._vels[i].x * dt; + this._pos[i3 + 1] += this._vels[i].y * dt; + this._pos[i3 + 2] += this._vels[i].z * dt; + this._vels[i].multiplyScalar(0.92); // 阻力衰减 + } + this._points.geometry.attributes.position.needsUpdate = true; + } +} + +// ==================== 对外接口 ==================== + +export class ShootingEffects { + constructor(scene, { listener = null, flashScale = 0.01, smokeSize = 0.06 } = {}) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._listener = listener; // THREE.AudioListener + + // ==================== 特效配置 ==================== + this._flashScale = flashScale; // 枪口火焰缩放 + this._smokeSize = smokeSize; // 硝烟粒子大小 + + // ==================== 特效对象 ==================== + this._flash = null; // MuzzleFlash 实例 + this._smokePool = []; // SmokeEffect 对象池 + this._smokeIdx = 0; // 轮转索引 + this._fireSound = null; // 开火音效 + this._reloadSound = null; // 换弹音效 + } + + // 加载贴图与音效,创建特效实例 + async load(flashTexPath, smokeTexPath, fireSoundPath = null, reloadSoundPath = null) { + const loader = new TextureLoader(); + const audioLoader = new AudioLoader(); + + const [flashTex, smokeTex, fireBuffer, reloadBuffer] = await Promise.all([ + loader.loadAsync(flashTexPath), + loader.loadAsync(smokeTexPath), + fireSoundPath ? audioLoader.loadAsync(fireSoundPath).catch(() => null) : Promise.resolve(null), + reloadSoundPath ? audioLoader.loadAsync(reloadSoundPath).catch(() => null) : Promise.resolve(null), + ]); + + this._flash = new MuzzleFlash(this._scene, flashTex, this._flashScale); + + if (fireBuffer && this._listener) { + this._fireSound = new Audio(this._listener); + this._fireSound.setBuffer(fireBuffer); + this._fireSound.setVolume(0.4); + } + + if (reloadBuffer && this._listener) { + this._reloadSound = new Audio(this._listener); + this._reloadSound.setBuffer(reloadBuffer); + this._reloadSound.setVolume(0.6); + } + + // 预创建硝烟对象池 + for (let i = 0; i < smokePoolSize; i++) { + this._smokePool.push(new SmokeEffect(this._scene, smokeTex, this._smokeSize)); + } + } + + // ==================== 触发接口 ==================== + + // 枪口火焰 + 开火音效 + triggerMuzzleFlash(worldPos, camera) { + this._flash?.trigger(worldPos, camera); + if (this._fireSound) { + if (this._fireSound.isPlaying) this._fireSound.stop(); + this._fireSound.play(); + } + } + + // 换弹音效 + triggerReloadSound() { + if (!this._reloadSound) return; + if (this._reloadSound.isPlaying) this._reloadSound.stop(); + this._reloadSound.play(); + } + + stopReloadSound() { + if (this._reloadSound?.isPlaying) this._reloadSound.stop(); + } + + // 命中硝烟(轮转对象池) + triggerHitSmoke(worldPos, worldNormal) { + const e = this._smokePool[this._smokeIdx % this._smokePool.length]; + this._smokeIdx++; + e.trigger(worldPos, worldNormal); + } + + // 每帧更新所有特效 + update(dt) { + this._flash?.update(dt); + for (const e of this._smokePool) e.update(dt); + } +} diff --git a/three-player-controller/example/shooting/zombie/ZombieEntity.js b/three-player-controller/example/shooting/zombie/ZombieEntity.js new file mode 100644 index 0000000..c6af816 --- /dev/null +++ b/three-player-controller/example/shooting/zombie/ZombieEntity.js @@ -0,0 +1,440 @@ +import * as THREE from "three"; +import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js"; + +export class ZombieEntity { + constructor(scene, id) { + // ==================== 场景引用 ==================== + this._scene = scene; + this.id = id; // 唯一标识 + + // ==================== 场景对象 ==================== + this._colliders = []; // 场景碰撞网格(CollisionWorld 查询结果) + this._model = null; // 模型根节点 + this._capsule = null; // 碰撞胶囊体 + this._capsuleInfo = null; // 胶囊描述(radius + segment) + this._mixer = null; // 动画混合器 + + // ==================== 动画动作 ==================== + this._walkAction = null; // 行走 + this._runAction = null; // 奔跑 + this._idleAction = null; // 待机 + this._punchAction = null; // 攻击 + this._deathAction = null; // 死亡 + this._currentAction = null; // 当前播放动作 + this._punchLoopCb = null; // 攻击循环回调(用于 removeEventListener) + + // ==================== 生命状态 ==================== + this.hp = 100; // 血量 + this.isDead = false; // 是否已死亡 + this._deathTime = 0; // 死亡后累计时间(用于定时清除) + this._isRunning = Math.random() > 0.5; // 随机决定是否为跑者 + this._state = "idle"; // 当前行为状态 + this._isPunching = false; // 是否正在攻击 + this._inAttackRange = false; // 是否在攻击范围内 + + // ==================== 物理参数 ==================== + this._gravity = -2.4; // 重力加速度(load 时按 scale 重算) + this._speed = 0.12; // 移速(load 时按 scale 重算) + this._modelScale = 0.001; // 模型缩放 + this._capsuleHeight = 180; // 胶囊高度(模型空间,load 时重算) + this._capsuleRadius = 45; // 胶囊半径(模型空间,load 时重算) + this._velocity = new THREE.Vector3(); // 当前速度(主要用于重力累积) + this._onGround = false; // 是否落地 + + // ==================== 复用向量 ==================== + this._tempBox = new THREE.Box3(); + this._tempMat = new THREE.Matrix4(); + this._tempSeg = new THREE.Line3(); + this._tempV1 = new THREE.Vector3(); + this._tempV2 = new THREE.Vector3(); + this._moveDir = new THREE.Vector3(); // 水平移动方向(单位向量) + this._lookTarget = new THREE.Vector3(); // 朝向目标(lookAt 复用) + this._raycaster = new THREE.Raycaster( + new THREE.Vector3(), + new THREE.Vector3(0, -1, 0), + ); + this._raycaster.firstHitOnly = true; // 只取最近地面交点 + } + + // 加载模型、绑定动画、构建胶囊体,挂入场景 + async load(gltfLoader, { + modelUrl, + colliders, + position, + scale = 0.001, + walkAnim, + runAnim, + idleAnim, + punchAnim, + deathAnim, + rotateY = Math.PI, + speed = 120, + }) { + this._colliders = colliders?.length ? colliders.slice() : []; + this._modelScale = scale; + this._gravity = -2400 * scale; + // 基础移速增加随机扰动 (±15%),使丧尸步频与步幅产生差异 + this._speed = speed * scale * (0.85 + Math.random() * 0.3); + + const gltf = await gltfLoader.loadAsync(modelUrl); + this._model = gltf.scene; + + this._mixer = new THREE.AnimationMixer(this._model); + const clips = gltf.animations ?? []; + + // 按名称或模糊匹配查找动画片段 + const findClip = (hint) => { + if (!hint) return null; + return ( + clips.find((clip) => clip.name === hint) ?? + clips.find((clip) => clip.name.toLowerCase().includes(hint.toLowerCase())) ?? + null + ); + }; + + const walkClip = findClip(walkAnim) ?? findClip("walk") ?? clips[0] ?? null; + const runClip = findClip(runAnim) ?? findClip("run") ?? clips[0] ?? null; + const idleClip = findClip(idleAnim) ?? findClip("idle") ?? clips[0] ?? null; + const punchClip = findClip(punchAnim) ?? findClip("punch") ?? null; + const deathClip = findClip(deathAnim) ?? findClip("dying") ?? findClip("death") ?? findClip("die") ?? null; + + // 创建循环动作并初始权重为 0(由 _playAnim 按需激活) + const makeAction = (clip) => { + if (!clip) return null; + const action = this._mixer.clipAction(clip); + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveWeight(0); + action.enabled = true; + return action; + }; + + this._walkAction = makeAction(walkClip); + if (this._walkAction) { + // 随机播放速度 (±10%) 和 随机起始进度 (0-100%) + this._walkAction.setEffectiveTimeScale(1.5 * (0.9 + Math.random() * 0.2)); + this._walkAction.time = Math.random() * walkClip.duration; + } + + this._runAction = makeAction(runClip); + if (this._runAction) { + this._runAction.setEffectiveTimeScale(1.2 * (0.9 + Math.random() * 0.2)); + this._runAction.time = Math.random() * runClip.duration; + } + + this._idleAction = idleClip !== walkClip ? makeAction(idleClip) : this._walkAction; + if (this._idleAction) { + // 即使是待机动作也打乱起始时间 + this._idleAction.time = Math.random() * (idleClip?.duration ?? 1); + } + + if (punchClip) { + this._punchAction = this._mixer.clipAction(punchClip); + this._punchAction.setLoop(THREE.LoopRepeat, Infinity); + this._punchAction.setEffectiveTimeScale(1.5); + this._punchAction.setEffectiveWeight(0); + this._punchAction.enabled = true; + + // 每次攻击循环结束时检查是否已离开攻击范围,若已离开则切回移动动画 + this._punchLoopCb = (event) => { + if (event.action !== this._punchAction || !this._isPunching) return; + if (!this._inAttackRange) { + this._isPunching = false; + this._playAnim(this._walkAction ?? this._idleAction); + } + }; + this._mixer.addEventListener("loop", this._punchLoopCb); + } + + this._deathAction = makeAction(deathClip); + if (this._deathAction) { + this._deathAction.setLoop(THREE.LoopOnce, 1); + this._deathAction.clampWhenFinished = true; // 停在最后一帧 + } + + // 驱动一帧使骨骼到位,再计算包围盒以得到准确尺寸 + this._mixer.update(0); + this._model.updateMatrixWorld(true); + + const bbox = new THREE.Box3().setFromObject(this._model); + const size = new THREE.Vector3(); + bbox.getSize(size); + + // 将模型归一化到参考高度 180,再乘以 scale 得到世界尺寸 + const refHeight = 180; + const modelScaleFactor = refHeight / size.y; + this._capsuleHeight = size.y * modelScaleFactor; + this._capsuleRadius = Math.min(size.x, size.z) * modelScaleFactor; + + const radius = this._capsuleRadius * scale; + const height = this._capsuleHeight * scale; + + // 胶囊体:透明网格,仅用于碰撞,不渲染 + this._capsule = new THREE.Mesh( + new RoundedBoxGeometry(radius * 2, height, radius * 2, 1, 0.75), + new THREE.MeshStandardMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + }), + ); + this._capsule.geometry.translate(0, -height * 0.25, 0); + this._capsuleInfo = { + radius, + segment: new THREE.Line3( + new THREE.Vector3(), + new THREE.Vector3(0, -height * 0.5, 0), + ), + }; + this._capsule.name = "zombie_capsule"; + this._capsule.userData.zombieId = this.id; + this._capsule.layers.enable(2); // layer 2:可被武器射线检测到 + this._scene.add(this._capsule); + this._capsule.position.copy(position); + + this._model.scale.setScalar(modelScaleFactor * scale); + this._model.position.set(0, -height * 0.75, 0); + this._model.rotation.y = rotateY; + this._model.traverse((child) => { + child.userData.zombieId = this.id; + child.castShadow = true; + child.receiveShadow = true; + }); + this._capsule.add(this._model); + + this._playAnim(this._idleAction ?? this._walkAction); + } + + // ==================== 主循环 ==================== + + update(delta, playerPos) { + if (!this._capsule || !this._colliders.length) return; + + delta = Math.min(delta, 1 / 40); // 防止帧间隔过大导致穿透 + + if (this.isDead) { + this._deathTime += delta; + this._mixer?.update(delta); + return; + } + + // 重力累积 + if (!this._onGround) { + this._velocity.y += delta * this._gravity; + } + this._capsule.position.addScaledVector(this._velocity, delta); + + // 计算与玩家的水平距离,判断是否进入攻击范围 + const pos = this._capsule.position; + const dx = playerPos.x - pos.x; + const dz = playerPos.z - pos.z; + const horizDist = Math.sqrt(dx * dx + dz * dz); + const stopRadius = this._capsuleInfo.radius * 2; + this._inAttackRange = horizDist <= stopRadius; + + // 朝向玩家(lookAt 反向,使模型正面对着玩家) + this._lookTarget.set( + 2 * pos.x - playerPos.x, + pos.y, + 2 * pos.z - playerPos.z, + ); + this._capsule.lookAt(this._lookTarget); + + if (this._inAttackRange) { + this._moveDir.set(0, 0, 0); + if (!this._isPunching) { + this._isPunching = true; + this._state = "attack"; + this._playAnim(this._punchAction ?? this._idleAction ?? this._walkAction); + } + } else { + // 只要不在攻击范围内,立即重置攻击标记以允许移动逻辑执行 + this._isPunching = false; + + if (horizDist > 1e-5) { + this._moveDir.set(dx / horizDist, 0, dz / horizDist); + this._state = this._isRunning ? "run" : "walk"; + const moveAnim = this._isRunning ? (this._runAction ?? this._walkAction) : this._walkAction; + this._playAnim(moveAnim ?? this._idleAction); + } else { + this._moveDir.set(0, 0, 0); + this._state = "idle"; + this._playAnim(this._idleAction ?? this._walkAction); + } + } + + const currentSpeed = this._isRunning ? this._speed * 1.5 : this._speed; + this._applyEnvironmentCollision(delta, currentSpeed); + this._applyGrounding(delta); + this._mixer?.update(delta); + } + + // ==================== 伤害 / 死亡 ==================== + + takeDamage(damage) { + if (this.isDead) return false; + this.hp -= damage; + if (this.hp <= 0) { + this.die(); + return true; // 本次命中导致死亡 + } + return false; + } + + die() { + if (this.isDead) return; + this.isDead = true; + this._state = "die"; + this._isPunching = false; + this._deathTime = 0; + this._velocity.set(0, 0, 0); + + if (this._deathAction) { + this._playAnim(this._deathAction, 0.1); + } + + // 禁用图层以便射线检测和自动瞄准忽略尸体 + this._capsule.layers.disable(2); + this._model.traverse((child) => { + if (child.isMesh) { + child.layers.disable(2); + } + }); + } + + // ==================== Getter ==================== + + getPosition() { return this._capsule?.position ?? null; } + getCapsule() { return this._capsule; } + getCapsuleInfo() { return this._capsuleInfo; } + getDeathTime() { return this._deathTime; } + + // ==================== 私有方法 ==================== + + // 切换动画,带淡入淡出 + _playAnim(action, fade = 0.2) { + if (!action || this._currentAction === action) return; + const prev = this._currentAction; + action.reset().setEffectiveWeight(1).play(); + if (prev) { + prev.fadeOut(fade); + action.fadeIn(fade); + } else { + action.fadeIn(fade); + } + this._currentAction = action; + } + + // 分步移动 + BVH 环境碰撞(只处理竖直面,水平面由 _applyGrounding 负责) + _applyEnvironmentCollision(delta, speed) { + const ci = this._capsuleInfo; + const totalDist = speed * delta; + const maxStep = ci.radius * 0.8; // 每步最大移动量,防止高速穿透 + const steps = Math.ceil(totalDist / maxStep) || 1; + const stepDist = totalDist / steps; + + for (let i = 0; i < steps; i++) { + this._capsule.position.addScaledVector(this._moveDir, stepDist); + this._capsule.updateMatrixWorld(); + + for (const collider of this._colliders) { + this._tempBox.makeEmpty(); + this._tempMat.copy(collider.matrixWorld).invert(); + this._tempSeg.copy(ci.segment); + this._tempSeg.start + .applyMatrix4(this._capsule.matrixWorld) + .applyMatrix4(this._tempMat); + this._tempSeg.end + .applyMatrix4(this._capsule.matrixWorld) + .applyMatrix4(this._tempMat); + this._tempBox.expandByPoint(this._tempSeg.start).expandByPoint(this._tempSeg.end); + this._tempBox.expandByScalar(ci.radius); + + collider.geometry?.boundsTree?.shapecast({ + intersectsBounds: (box) => box.intersectsBox(this._tempBox), + intersectsTriangle: (tri) => { + const dist = tri.closestPointToSegment( + this._tempSeg, + this._tempV1, + this._tempV2, + ); + if (dist >= ci.radius) return; + const normal = tri.getNormal(new THREE.Vector3()); + if (Math.abs(normal.y) > 0.5) return; // 水平面交给 _applyGrounding 处理 + const dir = this._tempV2.sub(this._tempV1).normalize(); + const depth = ci.radius - dist; + this._tempSeg.start.addScaledVector(dir, depth); + this._tempSeg.end.addScaledVector(dir, depth); + }, + }); + + const newPos = this._tempV1 + .copy(this._tempSeg.start) + .applyMatrix4(collider.matrixWorld); + const deltaVec = this._tempV2.subVectors(newPos, this._capsule.position); + const offset = Math.max(0, deltaVec.length() - 1e-5); + if (offset > 0) { + this._capsule.position.add(deltaVec.normalize().multiplyScalar(offset)); + this._capsule.updateMatrixWorld(); + } + } + } + } + + // 射线向下检测地面,吸附胶囊体到地面高度 + _applyGrounding(delta) { + this._raycaster.ray.origin.copy(this._capsule.position); + const hits = this._raycaster.intersectObjects(this._colliders, false); + + if (!hits.length) { + this._onGround = false; + return; + } + + const groundY = hits[0].point.y; + const scale = this._modelScale; + const snapHeight = this._capsuleHeight * scale * 0.75; // 胶囊悬停高度 + const maxHeight = this._capsuleHeight * scale * 0.9; // 超过此距离视为腾空 + const dist = this._capsule.position.y - groundY; + + if (dist >= maxHeight) { + this._onGround = false; + return; + } + + this._velocity.set(0, 0, 0); + this._onGround = true; + + if (dist >= snapHeight) { + // 平滑吸附(走上斜坡时避免抖动) + this._capsule.position.y = THREE.MathUtils.lerp( + this._capsule.position.y, + groundY + snapHeight, + Math.min(1, 15 * delta), + ); + } else { + this._capsule.position.y = groundY + snapHeight; + } + } + + // ==================== 销毁 ==================== + + destroy() { + if (this._mixer) { + if (this._punchLoopCb) { + this._mixer.removeEventListener("loop", this._punchLoopCb); + this._punchLoopCb = null; + } + this._mixer.stopAllAction(); + this._mixer.uncacheRoot(this._model); + this._mixer = null; + } + if (this._capsule) { + this._scene.remove(this._capsule); + this._capsule.geometry?.dispose(); + this._capsule.material?.dispose?.(); + this._capsule = null; + } + this._model = null; + this._colliders = []; + } +} diff --git a/three-player-controller/example/shooting/zombie/ZombieManager.js b/three-player-controller/example/shooting/zombie/ZombieManager.js new file mode 100644 index 0000000..106e4a8 --- /dev/null +++ b/three-player-controller/example/shooting/zombie/ZombieManager.js @@ -0,0 +1,150 @@ +import { Vector3 } from "three"; +import { ZombieEntity } from "./ZombieEntity.js"; + +export class ZombieManager { + constructor(scene, options = {}) { + // ==================== 场景引用 ==================== + this._scene = scene; + + // ==================== 丧尸配置 ==================== + this._loader = options.loader ?? null; // GLTF 加载器 + this._colliders = options.colliders ?? []; // 场景碰撞网格 + this._modelUrl = options.modelUrl ?? ""; // 模型路径 + this._scale = options.scale ?? 0.01; // 模型缩放 + this._rotateY = options.rotateY ?? Math.PI; // 模型初始朝向 + this._speed = options.speed ?? 120; // 移速基准值 + this._walkAnim = options.walkAnim ?? "walking"; // 行走动画名 + this._runAnim = options.runAnim ?? "running"; // 奔跑动画名 + this._idleAnim = options.idleAnim ?? "Idle"; // 待机动画名 + this._punchAnim = options.punchAnim ?? "punching"; // 攻击动画名 + this._deathAnim = options.deathAnim ?? "dying"; // 死亡动画名 + // ==================== 运行状态 ==================== + this._zombies = new Map(); // id → ZombieEntity + this._wave = 0; // 当前波次 + this._nextId = 0; // 自增 id 计数器 + } + + // ==================== 波次 / 生成 ==================== + + // 按波次配置批量生成丧尸 + async startWave(waveConfig = {}) { + if (!this._loader || !this._colliders.length || !this._modelUrl) { + console.warn("[ZombieManager] Missing loader, colliders or modelUrl"); + return; + } + + this._wave += 1; + + const origin = waveConfig.origin?.clone?.() ?? waveConfig.origin ?? new Vector3(); + const count = waveConfig.count ?? 5; + const radius = waveConfig.radius ?? 10; // 默认半径 10 + const spawnHeight = waveConfig.spawnHeight ?? 0.5; // 对应参数 y + + let spawnPoints = waveConfig.spawnPoints; + + // 如果没有预设点,则根据半径和高度生成随机点 + if (!spawnPoints) { + spawnPoints = []; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * radius; // 使用开方确保在圆盘内分布均匀 + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + spawnPoints.push(new Vector3(origin.x + x, origin.y + spawnHeight, origin.z + z)); + } + } + + await Promise.all(spawnPoints.map((point) => this.spawnZombie(point))); + } + + // 在指定位置生成单个丧尸,返回其 id + async spawnZombie(position) { + const id = `zombie_${this._nextId++}`; + const entity = new ZombieEntity(this._scene, id); + await entity.load(this._loader, { + modelUrl: this._modelUrl, + colliders: this._colliders, + position: position.clone?.() ?? new Vector3(position.x, position.y, position.z), + scale: this._scale, + walkAnim: this._walkAnim, + runAnim: this._runAnim, + idleAnim: this._idleAnim, + punchAnim: this._punchAnim, + deathAnim: this._deathAnim, + rotateY: this._rotateY, + speed: this._speed, + }); + this._zombies.set(id, entity); + return id; + } + + // ==================== 命中 ==================== + + // 武器命中回调,转发伤害给对应实体 + onHit(id, damage) { + const entity = this._zombies.get(id); + if (!entity || entity.isDead) return; + entity.takeDamage(damage); + } + + // ==================== 主循环 ==================== + + update(dt, playerPos) { + if (!playerPos) return; + + for (const [id, entity] of this._zombies.entries()) { + entity.update(dt, playerPos); + + // 如果丧尸已死亡且超过10秒,则彻底移除 + if (entity.isDead && entity.getDeathTime() >= 10) { + entity.destroy(); + this._zombies.delete(id); + } + } + + this._resolveZombieOverlaps(); + } + + // 解决丧尸之间的胶囊重叠(两两推开,仅处理水平方向) + _resolveZombieOverlaps() { + const entities = Array.from(this._zombies.values()).filter(e => !e.isDead); + for (let i = 0; i < entities.length; i++) { + const a = entities[i]; + const aCapsule = a.getCapsule(); + const aInfo = a.getCapsuleInfo(); + if (!aCapsule || !aInfo) continue; + + for (let j = i + 1; j < entities.length; j++) { + const b = entities[j]; + const bCapsule = b.getCapsule(); + const bInfo = b.getCapsuleInfo(); + if (!bCapsule || !bInfo) continue; + + const dx = bCapsule.position.x - aCapsule.position.x; + const dz = bCapsule.position.z - aCapsule.position.z; + const distSq = dx * dx + dz * dz; + const minDist = aInfo.radius + bInfo.radius; + + if (distSq >= minDist * minDist || distSq <= 1e-10) continue; + + const dist = Math.sqrt(distSq); + const half = (minDist - dist) * 0.5; // 各推开一半 + const nx = dx / dist; + const nz = dz / dist; + aCapsule.position.x -= nx * half; + aCapsule.position.z -= nz * half; + bCapsule.position.x += nx * half; + bCapsule.position.z += nz * half; + } + } + } + + // ==================== 销毁 ==================== + + destroy() { + for (const entity of this._zombies.values()) { + entity.destroy(); + } + this._zombies.clear(); + } +} diff --git a/three-player-controller/example/showcase.html b/three-player-controller/example/showcase.html new file mode 100644 index 0000000..d602acd --- /dev/null +++ b/three-player-controller/example/showcase.html @@ -0,0 +1,135 @@ + + + + + + Showcase + + + + + +
+ +
+
+ Character Model: + Universal Animation Library +
+
+ Vehicle: + Low-poly cyberpunk SUV +
+
+ +
+
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ E + Enter +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+
+
+
+
+ W + A + S + D +
+ Drive +
+
+ + Look +
+
+
+
+ Shift + Drift +
+
+ Space + Brake +
+
+ E + Exit +
+
+ V + Toggle View +
+
+ R + Reset Vehicle +
+
+
+
+ + + + + + + diff --git a/three-player-controller/example/showcase.js b/three-player-controller/example/showcase.js new file mode 100644 index 0000000..30d8e3d --- /dev/null +++ b/three-player-controller/example/showcase.js @@ -0,0 +1,2462 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + AnimationClip, + AnimationMixer, + Box3, + BoxGeometry, + BufferGeometry, + Timer, + Color, + VectorKeyframeTrack, + CylinderGeometry, + DirectionalLight, + DoubleSide, + ExtrudeGeometry, + Float32BufferAttribute, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + PlaneGeometry, + Quaternion, + QuaternionKeyframeTrack, + RepeatWrapping, + ShaderMaterial, + Shape, + SphereGeometry, + SRGBColorSpace, + Scene, + TextureLoader, + VSMShadowMap, + Vector3, + WebGLRenderer, +} from "three"; +import { Sky } from "three/addons/objects/Sky.js"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; +import { bindVehicleHintMode } from "./control-hints.js"; + +const TILE_SIZE = 1; +const SLOPE_ANGLES = [20, 30, 40]; + +const DECK_Y = -0.06; +const DECK_THICK = 0.12; + +// 车辆停放(仅大场景) +const VEHICLE_SPAWNS = [ + new Vector3(-1, 0, -11), + new Vector3(-1, 0, 11), +]; + +// 车辆调参(加载与 GUI 共用) +const VEHICLE_TUNING = { + followVehicleDirection: true, + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, + chassis: { + density: 1, + linearDamping: 0.05, + angularDamping: 0.5, + clearance: 0.8, + sizeScale: { x: 0.9, y: 0.55, z: 0.9 }, + }, + suspension: { + restLength: 0.15, + maxTravel: 0.15, + stiffness: 25, + compression: 2.1, + relaxation: 2.5, + maxForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, + }, + steering: { + maxSteerAngle: Math.PI / 5, + steerTime: 0.45, + steerReturnTimeSlow: 0.55, + steerReturnTimeFast: 0.4, + highSpeedSteerScale: 0.3, + }, + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, + power: { + maxSpeed: 100, + acceleration: 5, + deceleration: 5, + }, +}; + +// 半埋障碍带 +const OBSTACLE_X0 = -9; +const OBSTACLE_COUNT = 6; +const OBSTACLE_SPACING = 1.8; + +const PROP_SPHERE_R = 0.2; +const ACCENT_GRAY = 0xc8c8c8; +const PROP_GRID = 4; +const PROP_BOX_SIZE = 0.38; +const PROP_ACCENT_COLOR = 0x7eb0d4; + +const KINE_SHUTTLE_SPEED = 0.9; +const KINE_SPIN_SPEED = 0.35; +const STATIC_STRIP_COUNT = 6; +const STATIC_STRIP_GAP_RADIUS_RATIO = 0.5; +const STATIC_STRIP_DEPTH_RADIUS_RATIO = 3; + +const MINI_SCALE = 0.1; // 小场景相对大场景的比例 +const PLAYER_SCALE_NORMAL = 0.005; // 大场景人物缩放 +const VEHICLE_SCALE_NORMAL = 0.5; // 大场景车辆缩放 + +// 东侧巡逻甲板 +const PATROL_GAP_WIDTH = 6; // 东墙豁口宽度 +const PATROL_BRIDGE_LEN = 2; // 豁口外栈桥进深 +const PATROL_CLIMB_ANGLE = 20; // 上甲板坡度 +const PATROL_STEP_COUNT = 7; +const PATROL_DECK_LEN = 12; // 甲板 X 向长度 +const PATROL_DECK_WIDTH = 18; // 甲板 Z 向宽度 +const PATROL_NPC_COUNT = 6; +const PATROL_LANE_GAP = 3; // 巡逻道间距 +const PATROL_END_MARGIN = 0.85; // 巡逻线距甲板两端留白 +const PATROL_TURN_TIME = 0.45; // 端点转身时长 +const PATROL_LANE_MARK_WIDTH = 0.12; +const PATROL_WALK_SPEED_RAW = 100; // 回退值:与玩家 playerModelConfig.speed 一致 + +// 可切换角色与发型(Universal Base Characters,CC0) +// 三者共用同一套 65 骨骼,动画恒取自 ual.glb,无需重定向 +const CHARACTER_DIR = "./glb/characters/"; +const HAIR_NONE = "None"; +const CHARACTERS = [ + { name: "Mannequin", url: "./glb/ual.glb", heightFactor: 1 }, + { name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 }, + // 成年女性平均身高约为男性的 93%;模型自带的 97.5% 差异过小,几乎看不出 + { name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 }, +]; +// 手臂姿态微调(角度制):hang 让手臂更垂,straighten 让肘部更直 +const ARM_ADJUST_DEFAULTS = { + "Mannequin": { hang: 0, straighten: 0 }, + "Superhero Male": { hang: 0, straighten: 0 }, + "Superhero Female": { hang: 0, straighten: 0 }, +}; +const ARM_SIDES = [ + { upper: "upperarm_l", lower: "lowerarm_l", hand: "hand_l" }, + { upper: "upperarm_r", lower: "lowerarm_r", hand: "hand_r" }, +]; +const ARM_DOWN = new Vector3(0, -1, 0); +// 量躯干宽度时只取这些骨骼的顶点,避免 T-Pose 的手臂把宽度撑大 +const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"]; +// 控制器默认 playerCapsuleHeight / playerCapsuleRadius = 180 / 30 +const CAPSULE_HEIGHT_TO_RADIUS = 6; +const HAIRSTYLES = [ + "Hair_Buzzed", + "Hair_BuzzedFemale", + "Hair_SimpleParted", + "Hair_Long", + "Hair_Buns", + "Hair_Beard", +]; +const DEFAULT_CHARACTER = "Superhero Male"; + +// 与网格无关的玩家模型配置,init 与角色切换共用 +const PLAYER_MODEL_BASE = { + scale: PLAYER_SCALE_NORMAL, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "Head", + firstPersonCameraOffset: [0, 0.15, 0.12], + rotateY: -Math.PI / 2, + speed: 100, + runSpeed: 600, +}; +// 梯形连接段长度:相对宽端开口宽度(宽矩形 / 收腰) +const TRAP_WIDE_LEN_RATIO = 0.45; +const TRAP_SLOPE_LEN_RATIO = 0.4; +// 收腰段:青通道 tint +const TRAP_TINT_COLOR = 0x88c8e0; + +// 按比例生成场景布局 +function createShowcaseLayout(scale = 1) { + const s = scale; + return { + scale: s, + platform: { x: -5 * s, z: 0, sizeX: 30 * s, sizeZ: 28 * s }, + wallH: 2.0 * s, + wallT: 0.35 * s, + deckY: DECK_Y * s, + deckThick: DECK_THICK * s, + l1Top: 1.2 * s, + obstacleX0: OBSTACLE_X0 * s, + obstacleCount: OBSTACLE_COUNT, + obstacleSpacing: OBSTACLE_SPACING * s, + obstacleZ: 3.2 * s, + obstacleCylR: 0.15 * s, + obstacleCylLen: 2.5 * s, + obstacleBoxSize: new Vector3(0.25 * s, 0.275 * s, 2.5 * s), + stairWidth: 1 * s, + rampWidth: 1 * s, + kineThickness: 0.12 * s, + kineTopOffset: 0.4 * s, + tileSize: TILE_SIZE * s, + }; +} + +// 染色材质 +function tintMaterial(baseMat, hex) { + const mat = baseMat.clone(); + mat.color?.setHex?.(hex); + return mat; +} + +const scene = new Scene(); +const animTimer = new Timer(); +let scaledAnimTime = 0; + +let camera; +let renderer; +let controls; +let player; +let footIK; +let footIKDebugParams; +let stats; +const kinematicPlatforms = []; +const patrolNPCs = []; +let gltfLoader = null; +const gltfCache = new Map(); +let playerAnimations = null; // ual.glb 的 53 个动画,独立于角色网格 +let currentCharacterName = DEFAULT_CHARACTER; +let currentHairName = HAIR_NONE; +let currentHairMesh = null; +let characterBusy = false; +let currentHeightFactor = 1; +let currentHitboxScale = 1; +const characterHeights = new Map(CHARACTERS.map((c) => [c.name, c.heightFactor])); +let syncCharacterGui = null; +let sourceRestPose = null; // ual.glb 的静止姿态,用于重定基准 +const clipCache = new Map(); +const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() }; +let armChains = null; +const armAdjust = { hang: 0, straighten: 0 }; +const characterArms = new Map(Object.entries(ARM_ADJUST_DEFAULTS).map(([k, v]) => [k, { ...v }])); +const armTmp = { + a: new Vector3(), b: new Vector3(), c: new Vector3(), + dir: new Vector3(), fore: new Vector3(), axis: new Vector3(), + qWorld: new Quaternion(), qParent: new Quaternion(), qLocal: new Quaternion(), +}; +let trapScaleRange = null; // 收腰段:wideWestX 处 scale=1,westX 处 scale=0.1 +const glowPortals = []; +let zoneScale = 1; // 当前场景总缩放(1 → MINI_SCALE) +const scaleGateWorldPos = new Vector3(); // 缩放判定用的世界坐标缓冲 + +init(); + +// 初始化 +async function init() { + const container = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.05; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + renderer.setAnimationLoop(animate); + container.appendChild(renderer.domElement); + animTimer.connect(document); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 相机 + camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 800); + camera.position.set(14, 10, 10); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 260; + controls.maxPolarAngle = Math.PI / 2.05; + controls.target.set(0, 1, 0); + + // 环境光 + scene.add(new AmbientLight(0xffffff, 1.5)); + + // 平行光 + const sun = new DirectionalLight(0xffffff, 2); + sun.position.set(16, 48, 24); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 0.5; + sun.shadow.camera.far = 220; + sun.shadow.camera.left = -90; + sun.shadow.camera.right = 44; // 覆盖东侧巡逻甲板 + sun.shadow.camera.top = 70; + sun.shadow.camera.bottom = -70; + sun.shadow.bias = -0.0005; + scene.add(sun); + + const sky = new Sky(); + sky.scale.setScalar(450000); + scene.add(sky); + const skyUniforms = sky.material.uniforms; + skyUniforms["turbidity"].value = 10; + skyUniforms["rayleigh"].value = 2; + skyUniforms["mieCoefficient"].value = 0.001; + skyUniforms["mieDirectionalG"].value = 0.99; + skyUniforms["sunPosition"].value.copy(sun.position.clone().normalize()); + + // 测试场景 + const prototypeMat = await loadTiledMaterial("./textures/showcase/prototype.png"); + const mainLayout = createShowcaseLayout(); + const miniLayout = createShowcaseLayout(MINI_SCALE); + // 矩形西墙全开,开口贴合南北墙外缘,与梯形宽端相接 + const mainWestOpening = { + zCenter: 0, + width: mainLayout.platform.sizeZ + 2 * mainLayout.wallT, + }; + // 小场景南北墙外缘宽度:梯形窄端西口与小矩形东侧共用 + const miniOpening = { + zCenter: 0, + width: miniLayout.platform.sizeZ + 2 * miniLayout.wallT, + }; + + const world = new Group(); + world.name = "ShowcaseWorld"; + const mainWorld = buildShowcaseWorld(prototypeMat, mainLayout, { + westGap: mainWestOpening, + eastGap: { zCenter: 0, width: PATROL_GAP_WIDTH }, + }); + mainWorld.name = "MainWorld"; + world.add(mainWorld); + + const mainWestX = mainLayout.platform.x - mainLayout.platform.sizeX * 0.5; + const trapLayout = createTrapezoidLayout( + mainWestX, + mainLayout.platform.sizeZ, + miniLayout.platform.sizeZ, + ); + const trapWorld = buildTrapezoidArena(prototypeMat, mainLayout, trapLayout, miniLayout.wallH); + trapWorld.name = "TrapezoidWorld"; + world.add(trapWorld); + trapScaleRange = { eastX: trapLayout.wideWestX, westX: trapLayout.westX }; + + const miniWorld = buildShowcaseWorld(prototypeMat, miniLayout, { + omitEast: true, + }); + miniWorld.name = "MiniWorld"; + const miniLocalEast = miniLayout.platform.x + miniLayout.platform.sizeX * 0.5; + // 小场景台面东缘对齐梯形窄端西口 + miniWorld.position.set(trapLayout.westX - miniLocalEast, 0, 0); + world.add(miniWorld); + + // 东侧巡逻甲板:并入 world,复用静态碰撞源 + const patrol = buildPatrolDeck(prototypeMat, mainLayout); + world.add(patrol.root); + scene.add(patrol.decor); + scene.add(world); + + // 豁口发光门帘:宽矩形/收腰交界 + 梯形西口(进入小场景) + const slopeEastOpening = { zCenter: 0, width: trapLayout.halfWide * 2 }; + glowPortals.push( + createGlowGapPortal(mainLayout, slopeEastOpening, trapLayout.wideWestX), + createGlowGapPortal(miniLayout, miniOpening, trapLayout.westX), + ); + for (const portal of glowPortals) scene.add(portal); + + // 加载动画库与初始角色:动画恒取自 ual.glb,网格来自所选角色 + gltfLoader = new GLTFLoader(); + const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb"); + gltfCache.set(CHARACTERS[0].url, Promise.resolve(playerGltf)); + playerAnimations = playerGltf.animations; + sourceRestPose = collectRestPose(playerGltf.scene); + const startEntry = CHARACTERS.find((c) => c.name === DEFAULT_CHARACTER) ?? CHARACTERS[0]; + const startGltf = await loadGltfCached(startEntry.url); + currentCharacterName = startEntry.name; + currentHeightFactor = characterHeights.get(startEntry.name) ?? 1; + const startModel = cloneSkeleton(startGltf.scene); + const startRadiusRatio = measureTorsoRadiusRatio(startModel) * currentHitboxScale; + + // 初始化玩家控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: world } }, + ], + initPos: new Vector3(6.5, 0.4, 0.5), + minCamDistance: 8, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableOverShoulderView: true, + camOverShoulderOffsetRatio: 0, + enableSpringCamera: true, + springCameraTime: 0.1, + enableZoom: true, + playerModelConfig: { + ...PLAYER_MODEL_BASE, + model: startModel, + animations: clipsForModel(startEntry.name, startModel), + scale: playerScaleForZone(), + capsuleRadiusRatio: startRadiusRatio, + }, + }); + Object.assign(armAdjust, characterArms.get(currentCharacterName) ?? { hang: 0, straighten: 0 }); + armChains = resolveArmChains(player.playerModel); + bindVehicleHintMode(player); + enableModelShadows(player.playerModel); + const staticStripBridge = createStaticStripPlatforms(prototypeMat, mainLayout, scene, { + centerX: getBalancedKinematicMeetCenterX(mainLayout, trapLayout.wideWestX), + }); + const miniStripBridge = createStaticStripPlatforms(prototypeMat, miniLayout, miniWorld, { + centerX: getBalancedKinematicMeetCenterX(miniLayout), + }); + + // 初始化脚部 IK + footIK = new FootIK({ + enabled: true, + soleHalfWidth: 4, + soleToeExtend: 4, + soleSkinThickness: 1.6, + plantedHeightSpeed: 200, + penetrationLiftSpeed: 200, + predictivePlacement: true, + straightPoleEnabled: true, + skeleton: { + hips: "pelvis", + legs: { + left: { + upper: "thigh_l", + lower: "calf_l", + foot: "foot_l", + toe: "ball_l", + }, + right: { + upper: "thigh_r", + lower: "calf_r", + foot: "foot_r", + toe: "ball_r", + }, + }, + }, + }); + player.use(footIK); + + await spawnShowcaseVehicles(gltfLoader); + spawnPatrolNPCs(playerGltf, patrol); + spawnVehiclePropBodies(prototypeMat, mainLayout); + spawnLayoutPropBodies(prototypeMat, miniLayout, miniWorld.position); + createKinematicPlatforms(prototypeMat, mainLayout, scene, { + meetCenterX: staticStripBridge.centerX, + middleStripSpan: staticStripBridge.span, + slopeMeetCenterX: trapLayout.wideWestX, + slopeWestX: trapLayout.westX, + }); + createKinematicPlatforms(prototypeMat, miniLayout, miniWorld, { + meetCenterX: miniStripBridge.centerX, + middleStripSpan: miniStripBridge.span, + }); + + createDebugPanel(); + + window.addEventListener("resize", onResize); + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyR" || e.repeat) return; + player?.resetVehicle(); + }); + window["hideLoader"]?.(); +} + +// 加载 tiling 纹理材质 +async function loadTiledMaterial(url) { + const texture = await new TextureLoader().loadAsync(url); + texture.colorSpace = SRGBColorSpace; + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.repeat.set(1, 1); + texture.anisotropy = 8; + return new MeshStandardMaterial({ + map: texture, + roughness: 0.85, + metalness: 0.05, + }); +} + +// 按世界尺度写入 UV +function applyWorldTileUV(geometry, origin = { x: 0, y: 0, z: 0 }, tileSize = TILE_SIZE) { + const flat = geometry.index ? geometry.toNonIndexed() : geometry; + const pos = flat.getAttribute("position"); + const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2); + const tile = Math.max(tileSize, 1e-6); + + for (let t = 0; t < pos.count; t += 3) { + const ax = pos.getX(t); + const ay = pos.getY(t); + const az = pos.getZ(t); + const bx = pos.getX(t + 1); + const by = pos.getY(t + 1); + const bz = pos.getZ(t + 1); + const cx = pos.getX(t + 2); + const cy = pos.getY(t + 2); + const cz = pos.getZ(t + 2); + + const e1x = bx - ax; + const e1y = by - ay; + const e1z = bz - az; + const e2x = cx - ax; + const e2y = cy - ay; + const e2z = cz - az; + const nx = Math.abs(e1y * e2z - e1z * e2y); + const ny = Math.abs(e1z * e2x - e1x * e2z); + const nz = Math.abs(e1x * e2y - e1y * e2x); + + for (let i = 0; i < 3; i++) { + const idx = t + i; + const x = pos.getX(idx) + origin.x; + const y = pos.getY(idx) + origin.y; + const z = pos.getZ(idx) + origin.z; + let u = 0; + let v = 0; + if (ny >= nx && ny >= nz) { + u = x; + v = z; + } else if (nx >= ny && nx >= nz) { + u = z; + v = y; + } else { + u = x; + v = y; + } + uv.setXY(idx, u / tile, v / tile); + } + } + + flat.setAttribute("uv", uv); + flat.computeVertexNormals(); + return flat; +} + +// 创建展示场景 +function buildShowcaseWorld(baseMat, layout, options = {}) { + const root = new Group(); + const { platform, deckY, deckThick, tileSize } = layout; + + addBox( + root, + new Vector3(platform.x, deckY, platform.z), + new Vector3(platform.sizeX, deckThick, platform.sizeZ), + baseMat, + tileSize, + ); + createPlatformWalls(root, baseMat, layout, options); + createClimbGroups(root, baseMat, layout); + createHalfBuriedObstacles(root, baseMat, layout); + + return root; +} + +// 平台围墙 +function createPlatformWalls(root, baseMat, layout, options = {}) { + const { platform, wallH, wallT, tileSize } = layout; + const { x, z, sizeX, sizeZ } = platform; + const halfX = sizeX * 0.5; + const halfZ = sizeZ * 0.5; + const y = wallH * 0.5; + + // 北 / 南 + addBox(root, new Vector3(x, y, z + halfZ + wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(x, y, z - halfZ - wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize); + + // 东墙:可选中部豁口 + if (!options.omitEast) { + addWallWithGap(root, baseMat, layout, x + halfX + wallT * 0.5, options.eastGap); + } + + // 西墙:可选中部豁口 + addWallWithGap(root, baseMat, layout, x - halfX - wallT * 0.5, options.westGap); +} + +// 单面侧墙,可在中部留出豁口 +function addWallWithGap(root, baseMat, layout, wallX, gap) { + const { platform, wallH, wallT, tileSize } = layout; + const { z, sizeZ } = platform; + const halfZ = sizeZ * 0.5; + const y = wallH * 0.5; + + if (!gap || gap.width <= 0) { + addBox(root, new Vector3(wallX, y, z), new Vector3(wallT, wallH, sizeZ), baseMat, tileSize); + return; + } + + const gapHalf = gap.width * 0.5; + const southEnd = z - halfZ; + const northEnd = z + halfZ; + + const southLen = gap.zCenter - gapHalf - southEnd; + if (southLen > 1e-4) { + addBox( + root, + new Vector3(wallX, y, southEnd + southLen * 0.5), + new Vector3(wallT, wallH, southLen), + baseMat, + tileSize, + ); + } + const northLen = northEnd - (gap.zCenter + gapHalf); + if (northLen > 1e-4) { + addBox( + root, + new Vector3(wallX, y, northEnd - northLen * 0.5), + new Vector3(wallT, wallH, northLen), + baseMat, + tileSize, + ); + } +} + +// 梯形连接段尺寸(东贴大场景,西贴小场景) +function createTrapezoidLayout(eastX, eastWidth, westWidth) { + const wideLen = eastWidth * TRAP_WIDE_LEN_RATIO; + const slopeLen = eastWidth * TRAP_SLOPE_LEN_RATIO; + const wideWestX = eastX - wideLen; + const westX = wideWestX - slopeLen; + return { + eastX, + westX, + wideWestX, + halfWide: eastWidth * 0.5, + halfNarrow: westWidth * 0.5, + }; +} + +function getTrapezoidOutline(trap) { + const { eastX, westX, wideWestX, halfWide, halfNarrow } = trap; + return [ + { x: eastX, z: halfWide }, + { x: wideWestX, z: halfWide }, + { x: westX, z: halfNarrow }, + { x: westX, z: -halfNarrow }, + { x: wideWestX, z: -halfWide }, + { x: eastX, z: -halfWide }, + ]; +} + +function getWideSectionOutline(trap) { + const { eastX, wideWestX, halfWide } = trap; + return [ + { x: eastX, z: halfWide }, + { x: wideWestX, z: halfWide }, + { x: wideWestX, z: -halfWide }, + { x: eastX, z: -halfWide }, + ]; +} + +function getSlopeSectionOutline(trap) { + const { wideWestX, westX, halfWide, halfNarrow } = trap; + return [ + { x: wideWestX, z: halfWide }, + { x: westX, z: halfNarrow }, + { x: westX, z: -halfNarrow }, + { x: wideWestX, z: -halfWide }, + ]; +} + +function isSlopeSectionEdge(a, b) { + return Math.abs(b.x - a.x) > 1e-4 && Math.abs(b.z - a.z) > 1e-4; +} + +// 宽矩形 + 收腰梯形 +function buildTrapezoidArena(baseMat, layout, trap, westWallH) { + const root = new Group(); + const trapMat = tintMaterial(baseMat, TRAP_TINT_COLOR); + addPolygonFloor(root, getWideSectionOutline(trap), layout, baseMat); + addPolygonFloor(root, getSlopeSectionOutline(trap), layout, trapMat); + addOutlineWalls(root, getTrapezoidOutline(trap), layout, baseMat, trapMat, trap, westWallH); + return root; +} + +function addPolygonFloor(root, points, layout, material) { + const { deckY, deckThick, tileSize } = layout; + const shape = new Shape(); + shape.moveTo(points[0].x, -points[0].z); + for (let i = 1; i < points.length; i++) { + shape.lineTo(points[i].x, -points[i].z); + } + shape.closePath(); + + const geo = new ExtrudeGeometry(shape, { depth: deckThick, bevelEnabled: false, steps: 1 }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, deckY - deckThick * 0.5, 0); + const floor = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material); + floor.name = "TrapezoidFloor"; + return addShadowMesh(root, floor); +} + +// 沿多边形外缘砌墙,跳过东西两端开口;收腰段墙高从大场景线性降到小场景 +function addOutlineWalls(root, points, layout, wideMat, slopeMat, trap, westWallH) { + const n = points.length; + let cx = 0; + let cz = 0; + for (const p of points) { + cx += p.x; + cz += p.z; + } + cx /= n; + cz /= n; + + const heightAt = (p) => ( + Math.abs(p.x - trap.wideWestX) < 1e-4 || p.x > trap.wideWestX + ? layout.wallH + : westWallH + ); + + for (let i = 0; i < n; i++) { + const a = points[i]; + const b = points[(i + 1) % n]; + const isEastGap = Math.abs(a.x - trap.eastX) < 1e-4 && Math.abs(b.x - trap.eastX) < 1e-4; + const isWestGap = Math.abs(a.x - trap.westX) < 1e-4 && Math.abs(b.x - trap.westX) < 1e-4; + if (isEastGap || isWestGap) continue; + + const dx = b.x - a.x; + const dz = b.z - a.z; + const len = Math.hypot(dx, dz); + if (len < 1e-4) continue; + + const midX = (a.x + b.x) * 0.5; + const midZ = (a.z + b.z) * 0.5; + let nx = -dz / len; + let nz = dx / len; + if (nx * (cx - midX) + nz * (cz - midZ) > 0) { + nx = -nx; + nz = -nz; + } + + const hA = heightAt(a); + const hB = heightAt(b); + const material = isSlopeSectionEdge(a, b) ? slopeMat : wideMat; + if (Math.abs(hA - hB) > 1e-4) { + addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz); + } else { + addOrientedWall(root, layout, material, midX, midZ, len, nx, nz, dx, dz); + } + } +} + +function addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz) { + const { wallT, tileSize } = layout; + const len = Math.hypot(b.x - a.x, b.z - a.z); + const along = new Vector3((b.x - a.x) / len, 0, (b.z - a.z) / len); + const up = new Vector3(0, 1, 0); + const out = new Vector3(nx, 0, nz); + + // 外法线与 along×up 反向时会变成左手系,正面被剔掉;改为从另一端挤出 + let origin = a; + let startH = hA; + let endH = hB; + const xAxis = along.clone(); + if (xAxis.clone().cross(up).dot(out) < 0) { + xAxis.negate(); + origin = b; + startH = hB; + endH = hA; + } + + const shape = new Shape(); + shape.moveTo(0, 0); + shape.lineTo(len, 0); + shape.lineTo(len, endH); + shape.lineTo(0, startH); + shape.closePath(); + + const geo = new ExtrudeGeometry(shape, { depth: wallT, bevelEnabled: false, steps: 1 }); + const m = new Matrix4().makeBasis(xAxis, up, out); + m.setPosition(origin.x, 0, origin.z); + geo.applyMatrix4(m); + + const mesh = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material); + return addShadowMesh(root, mesh); +} + +function addOrientedWall(root, layout, material, midX, midZ, length, nx, nz, dx, dz) { + const { wallH, wallT, tileSize } = layout; + const pos = new Vector3(midX + nx * wallT * 0.5, wallH * 0.5, midZ + nz * wallT * 0.5); + const geo = new BoxGeometry(wallT, wallH, length); + geo.rotateY(Math.atan2(dx, dz)); + const mesh = new Mesh(applyWorldTileUV(geo, pos, tileSize), material); + mesh.position.copy(pos); + return addShadowMesh(root, mesh); +} + +// 创建豁口半透明发光门帘 +function createGlowGapPortal(layout, westGap, doorX) { + const group = new Group(); + group.name = "GlowGapPortal"; + + const mat = new ShaderMaterial({ + transparent: true, + depthWrite: false, + side: DoubleSide, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new Color(0x66e0ff) }, + uIntensity: { value: 1.35 }, + }, + vertexShader: /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform float uTime; + uniform vec3 uColor; + uniform float uIntensity; + varying vec2 vUv; + + void main() { + float edgeX = smoothstep(0.0, 0.12, vUv.x) * smoothstep(0.0, 0.12, 1.0 - vUv.x); + float edgeY = smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.1, 1.0 - vUv.y); + float veil = edgeX * edgeY; + float wave = 0.55 + 0.45 * sin(vUv.y * 14.0 - uTime * 2.4); + float scan = 0.35 + 0.65 * pow(abs(sin(vUv.x * 3.14159 + uTime * 1.6)), 2.0); + float alpha = veil * (0.22 + 0.38 * wave * scan); + vec3 col = uColor * (uIntensity + 0.55 * wave); + gl_FragColor = vec4(col, alpha); + } + `, + }); + + const mesh = new Mesh( + new PlaneGeometry(westGap.width, layout.wallH), + mat, + ); + mesh.name = "GlowGapCurtain"; + mesh.rotation.y = Math.PI * 0.5; + mesh.position.set(doorX, layout.wallH * 0.5, westGap.zCenter); + mesh.renderOrder = 2; + mesh.frustumCulled = false; + group.add(mesh); + group.userData.material = mat; + return group; +} + +// 更新发光门帘时间 +function updateGlowPortal(t) { + for (const portal of glowPortals) { + const mat = portal?.userData?.material; + if (mat?.uniforms?.uTime) mat.uniforms.uTime.value = t; + } +} + +// 两数是否视为同一缩放 +function isNearScale(a, b) { + return Math.abs(a - b) <= Math.max(Math.abs(b) * 1e-4, 1e-8); +} + +// 收腰段 X:wideWestX → 1,westX → MINI_SCALE;宽矩形段保持 1 +function zoneScaleFromX(x) { + if (!trapScaleRange) return 1; + const { eastX: narrowStartX, westX } = trapScaleRange; + if (x > narrowStartX) return 1; + const span = narrowStartX - westX; + if (span <= 1e-6) return x <= westX ? MINI_SCALE : 1; + const t = Math.min(1, Math.max(0, (narrowStartX - x) / span)); + return 1 + t * (MINI_SCALE - 1); +} + +function playerScaleForZone(z = zoneScale) { + // updateTrapScale 每帧调用这里,身高系数必须并入,否则会被区域缩放覆盖 + return PLAYER_SCALE_NORMAL * z * currentHeightFactor; +} + +function vehicleScaleForZone(z = zoneScale) { + return VEHICLE_SCALE_NORMAL * z; +} + +function isDriving() { + return player?.controllerMode === 1 && Boolean(player.getActiveVehicle?.()); +} + +function getScaleGatePosition() { + if (isDriving()) { + const v = player.getActiveVehicle(); + if (v?.vehicleGroup) return v.vehicleGroup.position; + } + const cap = player?.playerCapsule; + if (cap) return cap.getWorldPosition(scaleGateWorldPos); + return player?.getPosition?.() ?? null; +} + +function recomputePlayerGroundThresholds() { + const cap = player?.playerCapsule; + const info = cap?.capsuleInfo; + if (!info) return; + const sy = cap.scale.y || 1; + const ride = player.rideHeight * player.playerModelConfig.scale; + player.snapH = (-info.segment.end.y) * sy + info.radius + ride; + player.maxH = player.snapH + ride; +} + +// 应用人物缩放;驾驶中保持胶囊局部缩放,由车身缩放带动 +function applyPlayerScale(targetScale) { + if (!player?.setPlayerScale || isNearScale(player.playerModelConfig.scale, targetScale)) return; + if (!isDriving()) { + player.setPlayerScale(targetScale); + return; + } + const cap = player.playerCapsule; + const savedScale = cap.scale.clone(); + player.setPlayerScale(targetScale); + cap.scale.copy(savedScale); + recomputePlayerGroundThresholds(); +} + +// 按收腰段 X 连续缩放:越往 -X 越小 +function updateTrapScale() { + if (!player || !trapScaleRange) return; + const pos = getScaleGatePosition(); + if (!pos) return; + + zoneScale = zoneScaleFromX(pos.x); + applyPlayerScale(playerScaleForZone(zoneScale)); + + const vehicles = player.getAllVehicles?.() ?? []; + if (!vehicles.length || !player.setVehicleScale) return; + for (const v of vehicles) { + const target = vehicleScaleForZone(zoneScaleFromX(v.vehicleGroup.position.x)); + if (!isNearScale(v.scale, target)) player.setVehicleScale(v, target); + } +} + +// 半埋圆柱 + 半埋长方体 +function createHalfBuriedObstacles(root, baseMat, layout) { + const accentMat = tintMaterial(baseMat, ACCENT_GRAY); + const { + obstacleX0, + obstacleCount, + obstacleSpacing, + obstacleZ, + obstacleCylR, + obstacleCylLen, + obstacleBoxSize, + deckY, + deckThick, + tileSize, + } = layout; + const deckTop = deckY + deckThick * 0.5; + + for (let i = 0; i < obstacleCount; i++) { + const x = obstacleX0 - i * obstacleSpacing; + + const cylPos = new Vector3(x, deckTop, -obstacleZ); + const cylGeo = applyWorldTileUV( + new CylinderGeometry(obstacleCylR, obstacleCylR, obstacleCylLen, 24), + cylPos, + tileSize, + ); + cylGeo.rotateX(Math.PI / 2); + const cyl = new Mesh(cylGeo, accentMat); + cyl.position.copy(cylPos); + addShadowMesh(root, cyl); + + addBox(root, new Vector3(x, deckTop, obstacleZ), obstacleBoxSize.clone(), accentMat, tileSize); + } +} + +// 加载展示车辆 +async function spawnShowcaseVehicles(gltfLoader) { + const gltf = await gltfLoader.loadAsync("./glb/suv.glb"); + for (const spawnPos of VEHICLE_SPAWNS) { + const model = gltf.scene.clone(true); + await player.loadVehicleModel({ + model, + scale: VEHICLE_SCALE_NORMAL, + position: spawnPos.clone(), + wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"], + driverSeatPosition: new Vector3(-0.4, 1.0, 0.3), + driverSeatRotation: -Math.PI / 2, + modelRotation: -Math.PI / 2, + followVehicleDirection: VEHICLE_TUNING.followVehicleDirection, + debug: VEHICLE_TUNING.debug, + chassis: VEHICLE_TUNING.chassis, + suspension: VEHICLE_TUNING.suspension, + steering: VEHICLE_TUNING.steering, + grip: VEHICLE_TUNING.grip, + power: VEHICLE_TUNING.power, + }); + + const vehicle = player.getAllVehicles().at(-1); + vehicle?.vehicleGroup?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + const mats = Array.isArray(child.material) ? child.material : [child.material]; + for (const mat of mats) { + if (!mat) continue; + mat.metalness = 0.8; + mat.roughness = 0.0; + } + }); + } +} + +// 车辆前方动态刚体 +function spawnVehiclePropBodies(baseMat, layout) { + const vehicles = player?.getAllVehicles?.() ?? []; + if (vehicles.length < 2) return; + const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + spawnSphereGrid(vehicles[0], propMat, layout); + spawnBoxWall(vehicles[1], propMat, layout); +} + +// 按布局生成动态球阵与箱墙 +function spawnLayoutPropBodies(baseMat, layout, worldOffset) { + const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const s = layout.scale; + const forward = new Vector3(-1, 0, 0); + const right = new Vector3(0, 0, 1); + const xMid = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5; + const deckTop = (worldOffset.y ?? 0) + layout.deckY + layout.deckThick * 0.5; + const ox = worldOffset.x; + const oz = worldOffset.z ?? 0; + + const gravity = player.gravity * s; + spawnSphereGridAt({ + mat: propMat, + center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[0].z * s), + forward, + right, + radius: PROP_SPHERE_R * s, + deckTop, + gravity, + }); + spawnBoxWallAt({ + mat: propMat, + center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[1].z * s), + right, + boxSize: PROP_BOX_SIZE * s, + deckTop, + gravity, + }); +} + +// 车辆前向 / 右向轴 +function getVehicleAxes(vehicle) { + vehicle.vehicleGroup.updateMatrixWorld(true); + const forward = vehicle.forwardLocal.clone().transformDirection(vehicle.vehicleGroup.matrixWorld).normalize(); + const right = new Vector3(0, 1, 0).cross(forward).normalize(); + return { origin: vehicle.vehicleGroup.position, forward, right }; +} + +// 障碍带中线处的放置中心 +function getPropCenter(vehicle, layout) { + const { origin, forward, right } = getVehicleAxes(vehicle); + const center = origin.clone(); + center.x = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5; + return { forward, right, center }; +} + +// 4×4 贴地球阵(相对车辆) +function spawnSphereGrid(vehicle, mat, layout) { + const { forward, right, center } = getPropCenter(vehicle, layout); + spawnSphereGridAt({ + mat, + center, + forward, + right, + radius: PROP_SPHERE_R * layout.scale, + deckTop: layout.deckY + layout.deckThick * 0.5, + }); +} + +// 4×4 贴地球阵 +function spawnSphereGridAt({ mat, center, forward, right, radius, deckTop, gravity = undefined }) { + const spacing = radius * 2; + const start = center.clone().addScaledVector(forward, -(PROP_GRID - 1) * spacing * 0.5); + const geo = new SphereGeometry(1, 24, 16); + + for (let row = 0; row < PROP_GRID; row++) { + for (let col = 0; col < PROP_GRID; col++) { + const pos = start.clone() + .addScaledVector(forward, row * spacing) + .addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * spacing); + pos.y = deckTop + radius; + const mesh = new Mesh(geo, mat); + mesh.scale.setScalar(radius); + addShadowMesh(scene, mesh); + player.addCollider({ + motion: "dynamic", + shape: { kind: "sphere", radius, position: pos }, + mesh, + restitution: 0.35, + friction: 0.55, + ...(gravity != null ? { gravity } : null), + }); + } + } +} + +// 4×4 箱墙(相对车辆) +function spawnBoxWall(vehicle, mat, layout) { + const { right, center } = getPropCenter(vehicle, layout); + spawnBoxWallAt({ + mat, + center, + right, + boxSize: PROP_BOX_SIZE * layout.scale, + deckTop: layout.deckY + layout.deckThick * 0.5, + }); +} + +// 4×4 箱墙 +function spawnBoxWallAt({ mat, center, right, boxSize, deckTop, gravity = undefined }) { + const half = boxSize * 0.5; + const start = center.clone(); + const geo = new BoxGeometry(1, 1, 1); + const halfExtents = new Vector3(half, half, half); + + for (let row = 0; row < PROP_GRID; row++) { + for (let col = 0; col < PROP_GRID; col++) { + const pos = start.clone() + .addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * boxSize); + pos.y = deckTop + boxSize * (row + 0.5); + const mesh = new Mesh(geo, mat); + mesh.scale.setScalar(boxSize); + addShadowMesh(scene, mesh); + player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: halfExtents.clone(), + position: pos, + }, + mesh, + restitution: 0.12, + friction: 0.7, + density: 1.2, + ...(gravity != null ? { gravity } : null), + }); + } + } +} + +// 六棱柱攀爬尺寸 +function getHexClimbMetrics(layout) { + const stairWidth = layout.stairWidth; + const rampWidth = layout.rampWidth; + const armSpan = stairWidth + rampWidth; + const platformSides = 6; + const platformApothem = armSpan / (2 * Math.tan(Math.PI / platformSides)); + const platformCircumRadius = platformApothem / Math.cos(Math.PI / platformSides); + const sideLength = 2 * platformApothem * Math.tan(Math.PI / platformSides); + return { + stairWidth, + rampWidth, + armSpan, + platformSides, + platformApothem, + platformCircumRadius, + sideLength, + topHeight: layout.l1Top, + }; +} + +// 悬空静态条板 +function createStaticStripPlatforms(baseMat, layout, parent, options = {}) { + const { sideLength, topHeight } = getHexClimbMetrics(layout); + const capsuleRadius = (player?.playerCapsule?.capsuleInfo?.radius + ?? 30 * PLAYER_SCALE_NORMAL) * layout.scale; + const gap = capsuleRadius * STATIC_STRIP_GAP_RADIUS_RATIO; + const stripDepth = capsuleRadius * STATIC_STRIP_DEPTH_RADIUS_RATIO; + const span = STATIC_STRIP_COUNT * stripDepth + + (STATIC_STRIP_COUNT - 1) * gap; + const centerX = options.centerX ?? 0; + const stripSize = new Vector3( + stripDepth, + layout.kineThickness, + sideLength, + ); + const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5; + const firstX = centerX + span * 0.5 - stripDepth * 0.5; + const root = new Group(); + root.name = "StaticStripPlatforms"; + const mat = tintMaterial(baseMat, ACCENT_GRAY); + + for (let i = 0; i < STATIC_STRIP_COUNT; i++) { + const x = firstX - i * (stripDepth + gap); + const strip = addBox( + root, + new Vector3(x, y, 0), + stripSize, + mat, + layout.tileSize, + ); + strip.name = `StaticStripPlatform${i + 1}`; + } + + parent.add(root); + player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: root }, + }); + return { centerX, span }; +} + +/** 取两台运动学平台起点中心的中点。 */ +function getBalancedKinematicMeetCenterX(layout, spinStartBoundaryX) { + const { sideLength, platformApothem } = getHexClimbMetrics(layout); + const half = sideLength * 0.5; + const farEastX = -platformApothem - half; + const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5; + const farWestX = deckWestX + half; + const spinStartX = Number.isFinite(spinStartBoundaryX) + ? spinStartBoundaryX + half + : farWestX; + return (farEastX + spinStartX) * 0.5; +} + +// 运动学往返平台 +function createKinematicPlatforms(baseMat, layout, parent, options = {}) { + const { sideLength, topHeight, platformApothem } = getHexClimbMetrics(layout); + const size = new Vector3(sideLength, layout.kineThickness, sideLength); + const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5; + const half = sideLength * 0.5; + const mat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const prefix = layout.scale === 1 ? "Kine" : "MiniKine"; + + const farEastX = -platformApothem - half; + const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5; + const farWestX = deckWestX + half; + const slopeMeetCenterX = options.slopeMeetCenterX; + const slopeWestX = options.slopeWestX; + const defaultMeetX = getBalancedKinematicMeetCenterX(layout, slopeMeetCenterX); + const meetCenterX = options.meetCenterX ?? defaultMeetX; + const middleStripSpan = Math.max(0, options.middleStripSpan ?? 0); + + // 同一相位 p:p=1 时前两台分别停靠在静态条板两侧;p=0 时后两台在收腰起点衔接。 + const farEast = new Vector3(farEastX, y, 0); + // 有静态条板时,两台平台分别停靠在条板组东西两侧;否则保持原来的相邻停靠。 + const meetEast = new Vector3(meetCenterX + middleStripSpan * 0.5 + half, y, 0); + const spinFrom = Number.isFinite(slopeMeetCenterX) + ? new Vector3(slopeMeetCenterX + half, y, 0) + : new Vector3(farWestX, y, 0); + const spinTo = new Vector3(meetCenterX - middleStripSpan * 0.5 - half, y, 0); + + addKinematicShuttle({ + parent, + name: `${prefix}Shuttle`, + from: farEast, + to: meetEast, + size, + mat, + spinSpeed: 0, + tileSize: layout.tileSize, + }); + addKinematicShuttle({ + parent, + name: `${prefix}ShuttleSpin`, + from: spinFrom, + to: spinTo, + size, + mat, + spinSpeed: KINE_SPIN_SPEED, + tileSize: layout.tileSize, + }); + + if (Number.isFinite(slopeMeetCenterX) && Number.isFinite(slopeWestX)) { + const slopeScaleFrom = 1; + const slopeScaleTo = MINI_SCALE; + addKinematicShuttle({ + parent, + name: `${prefix}ShuttleSlope`, + // 起点东边缘贴收腰段,终点西边缘以缩小后的半宽贴小场景边界。 + from: new Vector3(slopeMeetCenterX - half * slopeScaleFrom, y, 0), + to: new Vector3( + slopeWestX + half * slopeScaleTo, + y * slopeScaleTo, + 0, + ), + size, + mat, + spinSpeed: 0, + tileSize: layout.tileSize, + zoneScale: true, + scaleFrom: slopeScaleFrom, + scaleTo: slopeScaleTo, + }); + } +} + +// 创建单个往返平台 +function addKinematicShuttle({ + parent, + name, + from, + to, + size, + mat, + spinSpeed, + tileSize, + zoneScale = false, + scaleFrom = 1, + scaleTo = scaleFrom, +}) { + const mesh = addBox(parent, from.clone(), size, mat, tileSize); + mesh.name = name; + mesh.scale.setScalar(scaleFrom); + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: mesh }, + follow: mesh, + }); + const entry = { + mesh, + from: from.clone(), + to: to.clone(), + spinSpeed, + zoneScale, + scaleFrom, + scaleTo, + lastZoneScale: scaleFrom, + }; + kinematicPlatforms.push(entry); +} + +/** 收腰段运动学平台。 */ +function applyKinematicZoneScale(entry, targetScale) { + if (!entry?.mesh || !Number.isFinite(targetScale)) return; + if (isNearScale(entry.lastZoneScale, targetScale)) return; + entry.mesh.scale.setScalar(targetScale); + entry.lastZoneScale = targetScale; +} + +// 两端缓入缓出 +function easeEndsLinearMiddle(progress, easeRatio = 0.18) { + const ease = Math.min(Math.max(easeRatio, 0.001), 0.49); + const maxSpeed = 1 / (1 - ease); + if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease); + if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease); + return maxSpeed * (progress - ease / 2); +} + +// 更新运动学平台 +function updateKinematicPlatforms(t) { + const phase = (t * KINE_SHUTTLE_SPEED / Math.PI) % 2; + const raw = phase <= 1 ? phase : 2 - phase; + const p = easeEndsLinearMiddle(raw); + + for (const entry of kinematicPlatforms) { + entry.mesh.position.lerpVectors(entry.from, entry.to, p); + if (entry.zoneScale) { + const targetScale = entry.scaleFrom + (entry.scaleTo - entry.scaleFrom) * p; + applyKinematicZoneScale(entry, targetScale); + } + if (entry.spinSpeed) entry.mesh.rotation.y = t * entry.spinSpeed; + } +} + +// ── 角色与发型切换 ── + +// glTF 缓存加载:来回切换时不重复下载 +function loadGltfCached(url) { + if (!gltfCache.has(url)) gltfCache.set(url, gltfLoader.loadAsync(url)); + return gltfCache.get(url); +} + +// 取模型下第一个蒙皮网格 +function findSkinnedMesh(root) { + let found = null; + root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; }); + return found; +} + +// 找出两条手臂的骨骼链 +function resolveArmChains(root) { + const chains = []; + for (const side of ARM_SIDES) { + const upper = root?.getObjectByName(side.upper); + const lower = root?.getObjectByName(side.lower); + const hand = root?.getObjectByName(side.hand); + if (upper && lower && hand) chains.push({ upper, lower, hand }); + } + return chains.length ? chains : null; +} + +/** + * 在骨骼的父空间里叠加一个世界空间旋转。 + * 世界朝向 W = P · L,要得到 W' = R · W,则 L' = (P⁻¹ · R · P) · L。 + */ +function rotateBoneWorld(bone, axisWorld, angle) { + if (!bone.parent || Math.abs(angle) < 1e-6 || axisWorld.lengthSq() < 1e-8) return; + armTmp.qWorld.setFromAxisAngle(axisWorld.normalize(), angle); + bone.parent.getWorldQuaternion(armTmp.qParent); + armTmp.qLocal.copy(armTmp.qParent).invert().multiply(armTmp.qWorld).multiply(armTmp.qParent); + bone.quaternion.premultiply(armTmp.qLocal); + bone.updateMatrixWorld(true); +} + +/** + * 动画更新之后微调手臂。旋转轴每帧从当前姿态现算, + * 因此与具体动作无关:手臂已经垂直或已经伸直时叉乘趋零,自动不再作用。 + */ +function applyArmAdjust() { + if (!armChains) return; + const hang = armAdjust.hang * Math.PI / 180; + const straighten = armAdjust.straighten * Math.PI / 180; + if (!hang && !straighten) return; + + for (const { upper, lower, hand } of armChains) { + if (hang) { + upper.getWorldPosition(armTmp.a); + lower.getWorldPosition(armTmp.b); + armTmp.dir.subVectors(armTmp.b, armTmp.a); + if (armTmp.dir.lengthSq() > 1e-8) { + armTmp.axis.crossVectors(armTmp.dir.normalize(), ARM_DOWN); + rotateBoneWorld(upper, armTmp.axis, hang); + } + } + if (straighten) { + upper.getWorldPosition(armTmp.a); + lower.getWorldPosition(armTmp.b); + hand.getWorldPosition(armTmp.c); + armTmp.dir.subVectors(armTmp.b, armTmp.a).normalize(); + armTmp.fore.subVectors(armTmp.c, armTmp.b).normalize(); + armTmp.axis.crossVectors(armTmp.dir, armTmp.fore); + if (armTmp.axis.lengthSq() > 1e-8) rotateBoneWorld(lower, armTmp.axis, -straighten); + } + } +} + +// 记录骨骼静止姿态(位移 + 旋转);必须在 mixer 跑过之前采集 +function collectRestPose(root) { + const rest = new Map(); + root?.traverse((obj) => { + if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() }); + }); + return rest; +} + +// 轨道是否随时间变化 +function trackVaries(track, stride) { + const v = track.values; + for (let i = stride; i < v.length; i++) { + if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true; + } + return false; +} + +/** + * 把 ual.glb 的动画适配到别的体型。 + * Blender 导出器给全部 65 根骨骼都烤了 translation / scale 轨道, + * 直接播放会把 mannequin 的骨骼位置强加给别的角色,肩宽和四肢比例随之被改写。 + * 这里只保留旋转,丢掉常量位移与全部缩放,唯一真正变化的 pelvis 位移重定到目标静止姿态。 + */ +function retargetClips(clips, targetRest) { + if (!sourceRestPose) return clips; + return clips.map((clip) => { + const tracks = []; + for (const track of clip.tracks) { + const dot = track.name.lastIndexOf("."); + const bone = track.name.slice(0, dot); + const prop = track.name.slice(dot + 1); + + const from = sourceRestPose.get(bone); + const to = targetRest.get(bone); + + if (prop === "quaternion") { + if (!from || !to) { tracks.push(track); continue; } + // 两套骨架静止朝向不同(锁骨差 9.5°、颈部差 23°), + // 直接套用绝对旋转会把目标骨架拧歪,故只搬运相对静止姿态的差量 + retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert()); + if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; } + const q = Float32Array.from(track.values); + for (let i = 0; i + 3 < q.length; i += 4) { + retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr); + q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y; + q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w; + } + tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q)); + continue; + } + + if (prop !== "position") continue; // scale 一律丢弃 + if (!trackVaries(track, 3)) continue; // 常量位移即静止姿态,丢弃 + if (!from || !to) { tracks.push(track); continue; } + + const values = Float32Array.from(track.values); + for (let i = 0; i + 2 < values.length; i += 3) { + values[i] += to.position.x - from.position.x; + values[i + 1] += to.position.y - from.position.y; + values[i + 2] += to.position.z - from.position.z; + } + tracks.push(new VectorKeyframeTrack(track.name, track.times, values)); + } + return new AnimationClip(clip.name, clip.duration, tracks); + }); +} + +// 按角色缓存适配后的动画 +function clipsForModel(name, model) { + if (!clipCache.has(name)) clipCache.set(name, retargetClips(playerAnimations, collectRestPose(model))); + return clipCache.get(name); +} + +// 量出躯干实际半宽,换算成贴合网格的 capsuleRadiusRatio +// 模型为 T-Pose,直接取包围盒会把手臂算进去,故按蒙皮权重只取躯干顶点 +function measureTorsoRadiusRatio(root) { + const mesh = findSkinnedMesh(root); + const pos = mesh?.geometry?.attributes?.position; + const skinIndex = mesh?.geometry?.attributes?.skinIndex; + const skinWeight = mesh?.geometry?.attributes?.skinWeight; + if (!mesh || !pos || !skinIndex || !skinWeight) return 1; + + const torso = new Set(); + mesh.skeleton?.bones.forEach((bone, i) => { + if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i); + }); + if (!torso.size) return 1; + + let halfWidth = 0; + for (let v = 0; v < pos.count; v++) { + let dominant = 0; + let best = -1; + for (let k = 0; k < 4; k++) { + const w = skinWeight.getComponent(v, k); + if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); } + } + if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v))); + } + if (halfWidth <= 0) return 1; + + root.updateMatrixWorld(true); + const height = new Box3().setFromObject(root).getSize(new Vector3()).y; + if (!(height > 0)) return 1; + return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height); +} + +// 换角色:网格来自角色文件,动画始终来自 ual.glb +async function applyCharacter(name) { + const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0]; + const gltf = await loadGltfCached(entry.url); + // 每次都用新副本:控制器会就地缩放模型,复用同一对象会叠加缩放 + const model = cloneSkeleton(gltf.scene); + const radiusRatio = measureTorsoRadiusRatio(model) * currentHitboxScale; + currentHeightFactor = characterHeights.get(entry.name) ?? entry.heightFactor ?? 1; + await player.switchPlayerModel({ + ...PLAYER_MODEL_BASE, + model, + animations: clipsForModel(entry.name, model), + scale: playerScaleForZone(), + capsuleRadiusRatio: radiusRatio, + }); + currentCharacterName = entry.name; + Object.assign(armAdjust, characterArms.get(entry.name) ?? { hang: 0, straighten: 0 }); + armChains = resolveArmChains(player.playerModel); + syncCharacterGui?.(); + enableModelShadows(player.playerModel); + currentHairMesh = null; // 旧发型随旧模型一并被移除 + await applyHair(currentHairName); +} + +// 换发型:与角色共用骨架,骨骼顺序一致且绑定矩阵均为单位阵,可直接绑定 +async function applyHair(name) { + currentHairName = name; + if (currentHairMesh) { + currentHairMesh.removeFromParent(); + currentHairMesh = null; + } + if (name === HAIR_NONE) return; + + const gltf = await loadGltfCached(`${CHARACTER_DIR}${name}.gltf`); + const hair = findSkinnedMesh(cloneSkeleton(gltf.scene)); + const target = findSkinnedMesh(player.playerModel); + if (!hair || !target) { + console.warn("[showcase] 发型或角色缺少蒙皮网格:", name); + return; + } + + hair.name = name; + hair.position.set(0, 0, 0); + hair.quaternion.identity(); + hair.scale.set(1, 1, 1); + target.parent.add(hair); + hair.bind(target.skeleton, hair.bindMatrix); + // 重绑骨架后包围盒失效,关闭视锥剔除避免发型被误剔除 + hair.frustumCulled = false; + enableModelShadows(hair); + currentHairMesh = hair; +} + +// GUI 入口:切换期间加锁,避免并发切换打架 +async function switchCharacter(name, force = false) { + if (characterBusy || (!force && name === currentCharacterName)) return; + characterBusy = true; + try { await applyCharacter(name); } + catch (e) { console.error("切换角色失败:", e); } + finally { characterBusy = false; } +} + +async function switchHair(name) { + if (characterBusy || name === currentHairName) return; + characterBusy = true; + try { await applyHair(name); } + catch (e) { console.error("切换发型失败:", e); } + finally { characterBusy = false; } +} + +// 东侧巡逻甲板:栈桥 + 楼梯 / 坡道 + 上层甲板 +function buildPatrolDeck(baseMat, layout) { + const { platform, wallH, wallT, l1Top, deckY, deckThick, tileSize } = layout; + const root = new Group(); + root.name = "PatrolDeck"; + const decor = new Group(); + decor.name = "PatrolDeckDecor"; + + const outerX = platform.x + platform.sizeX * 0.5 + wallT; // 东墙外缘 + const bridgeEndX = outerX + PATROL_BRIDGE_LEN; + const climbRun = l1Top / Math.tan(PATROL_CLIMB_ANGLE * Math.PI / 180); + const deckStartX = bridgeEndX + climbRun; + const deckEndX = deckStartX + PATROL_DECK_LEN; + const deckCenterX = (deckStartX + deckEndX) * 0.5; + const halfGap = PATROL_GAP_WIDTH * 0.5; + const halfDeckZ = PATROL_DECK_WIDTH * 0.5; + + // 豁口外栈桥,与主平台同高 + addBox( + root, + new Vector3((outerX + bridgeEndX) * 0.5, deckY, 0), + new Vector3(PATROL_BRIDGE_LEN, deckThick, PATROL_GAP_WIDTH), + baseMat, + tileSize, + ); + + // 南半侧楼梯 / 北半侧坡道 + addPatrolStairs(root, baseMat, layout, bridgeEndX, climbRun, halfGap, -halfGap * 0.5); + addPatrolRamp(root, baseMat, layout, bridgeEndX, climbRun, halfGap, halfGap * 0.5); + + // 上层甲板:实心块,避免悬空板 + addBox( + root, + new Vector3(deckCenterX, l1Top * 0.5, 0), + new Vector3(PATROL_DECK_LEN, l1Top, PATROL_DECK_WIDTH), + baseMat, + tileSize, + ); + + // 甲板围墙:北 / 南 / 东,西侧向坡道敞开 + const wallY = l1Top + wallH * 0.5; + addBox(root, new Vector3(deckCenterX, wallY, halfDeckZ + wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(deckCenterX, wallY, -halfDeckZ - wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(deckEndX + wallT * 0.5, wallY, 0), new Vector3(wallT, wallH, PATROL_DECK_WIDTH), baseMat, tileSize); + + // 巡逻道标线:只做视觉,不参与碰撞 + const fromX = deckStartX + PATROL_END_MARGIN; + const toX = deckEndX - PATROL_END_MARGIN; + const markMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const lanes = []; + for (let i = 0; i < PATROL_NPC_COUNT; i++) { + const z = (i - (PATROL_NPC_COUNT - 1) * 0.5) * PATROL_LANE_GAP; + lanes.push(z); + const mark = addBox( + decor, + new Vector3((fromX + toX) * 0.5, l1Top + 0.005, z), + new Vector3(toX - fromX, 0.01, PATROL_LANE_MARK_WIDTH), + markMat, + tileSize, + ); + mark.castShadow = false; + } + + return { root, decor, deckTop: l1Top, fromX, toX, lanes }; +} + +// 栈桥尽头的上行楼梯 +function addPatrolStairs(root, baseMat, layout, startX, run, width, z) { + const { l1Top, tileSize } = layout; + const stepHeight = l1Top / PATROL_STEP_COUNT; + const stepDepth = run / PATROL_STEP_COUNT; + + for (let i = 0; i < PATROL_STEP_COUNT; i++) { + const height = stepHeight * (i + 1); + addBox( + root, + new Vector3(startX + stepDepth * (i + 0.5), height * 0.5, z), + new Vector3(stepDepth, height, width), + baseMat, + tileSize, + ); + } +} + +// 栈桥尽头的上行坡道 +function addPatrolRamp(root, baseMat, layout, startX, run, width, z) { + const { l1Top, tileSize } = layout; + const origin = new Vector3(startX + run * 0.5, 0, z); + const ramp = new Mesh(createRampGeometry(run, l1Top, width, origin, 0, tileSize), baseMat); + ramp.position.copy(origin); + return addShadowMesh(root, ramp); +} + +// 巡逻 NPC:玩家 rig 副本,在甲板上来回行走 +function spawnPatrolNPCs(playerGltf, patrol) { + const walkClip = AnimationClip.findByName(playerGltf.animations, "Walk_Loop"); + if (!walkClip || !player?.playerModel) { + console.warn("[showcase] 缺少 Walk_Loop 或玩家模型,跳过巡逻 NPC"); + return; + } + + // 尺寸与速度全部取自玩家,保证 NPC 与玩家同体型、同步幅 + const modelScale = player.playerModel.scale.x; + const radius = player.playerCapsule?.capsuleInfo?.radius ?? 30 * PLAYER_SCALE_NORMAL; + const speed = Number.isFinite(player.playerSpeed) + ? player.playerSpeed + : PATROL_WALK_SPEED_RAW * PLAYER_SCALE_NORMAL; + const span = patrol.toX - patrol.fromX; + const proxyMat = new MeshStandardMaterial(); + const bbox = new Box3(); + + for (let i = 0; i < patrol.lanes.length; i++) { + const group = new Group(); + group.name = `PatrolNPC${i + 1}`; + + // SkinnedMesh 必须用 SkeletonUtils.clone,Object3D.clone 会共用骨架 + const model = cloneSkeleton(playerGltf.scene); + model.scale.setScalar(modelScale); + model.updateMatrixWorld(true); + bbox.setFromObject(model); + const bodyHeight = bbox.max.y - bbox.min.y; + model.position.y = -bbox.min.y; // 脚底贴甲板 + group.add(model); + + // 不可见碰撞代理:与模型同级,不被模型缩放带走 + const proxy = new Mesh(new BoxGeometry(radius * 2, bodyHeight, radius * 2), proxyMat); + proxy.name = `PatrolNPCBody${i + 1}`; + proxy.position.y = bodyHeight * 0.5; + proxy.visible = false; + group.add(proxy); + + // 相位错开,避免整齐划一 + const phase = i / patrol.lanes.length; + group.position.set(patrol.fromX + span * phase, patrol.deckTop, patrol.lanes[i]); + group.rotation.y = Math.PI / 2; // 控制器约定:本地 +Z 指向移动方向 + scene.add(group); + group.updateMatrixWorld(true); + enableModelShadows(model); + + const mixer = new AnimationMixer(model); + const action = mixer.clipAction(walkClip); + action.time = walkClip.duration * phase; + action.play(); + + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: proxy }, + follow: proxy, + }); + + patrolNPCs.push({ + group, + mixer, + speed, + fromX: patrol.fromX, + toX: patrol.toX, + dir: 1, + turnLeft: 0, + turnFrom: 0, + turnTo: 0, + }); + } +} + +// 推进巡逻 NPC:匀速直行,到端点掉头 +function updatePatrolNPCs(delta) { + if (!(delta > 0)) return; + + for (const npc of patrolNPCs) { + npc.mixer.update(delta); + + if (npc.turnLeft > 0) { + npc.turnLeft = Math.max(0, npc.turnLeft - delta); + const p = 1 - npc.turnLeft / PATROL_TURN_TIME; + npc.group.rotation.y = npc.turnFrom + (npc.turnTo - npc.turnFrom) * easeEndsLinearMiddle(p, 0.4); + if (npc.turnLeft === 0) npc.group.rotation.y = npc.turnTo % (Math.PI * 2); + continue; + } + + npc.group.position.x += npc.dir * npc.speed * delta; + if (npc.dir > 0 && npc.group.position.x >= npc.toX) { + npc.group.position.x = npc.toX; + startPatrolTurn(npc, -1); + } else if (npc.dir < 0 && npc.group.position.x <= npc.fromX) { + npc.group.position.x = npc.fromX; + startPatrolTurn(npc, 1); + } + } +} + +// 端点掉头:绕 Y 轴转半圈 +function startPatrolTurn(npc, dir) { + npc.dir = dir; + npc.turnLeft = PATROL_TURN_TIME; + npc.turnFrom = npc.group.rotation.y; + npc.turnTo = npc.turnFrom + Math.PI; +} + +// 创建攀爬组与中心平台 +function createClimbGroups(root, baseMat, layout) { + const { + topHeight, + stairWidth, + rampWidth, + platformSides, + platformApothem, + platformCircumRadius, + } = getHexClimbMetrics(layout); + const stepCount = 7; + const stepHeight = topHeight / stepCount; + const tileSize = layout.tileSize; + + const platformPos = new Vector3(0, topHeight * 0.5, 0); + const platformGeo = applyWorldTileUV( + new CylinderGeometry(platformCircumRadius, platformCircumRadius, topHeight, platformSides), + platformPos, + tileSize, + ); + const platform = new Mesh(platformGeo, baseMat); + platform.position.copy(platformPos); + addShadowMesh(root, platform); + + for (let index = 0; index < 3; index++) { + const arm = new Group(); + arm.rotation.y = index * (Math.PI * 2 / 3); + root.add(arm); + + const angleDeg = SLOPE_ANGLES[index]; + const stairZ = -stairWidth * 0.5; + const rampZ = rampWidth * 0.5; + const runLength = topHeight / Math.tan(angleDeg * Math.PI / 180); + const stepDepth = runLength / stepCount; + + createStairsToPlatform(arm, { + platformRadius: platformApothem, + stepHeight, + stepCount, + stepDepth, + width: stairWidth, + z: stairZ, + baseMat, + tileSize, + }); + + createRampToPlatform(arm, { + platformRadius: platformApothem, + angleDeg, + length: runLength, + width: rampWidth, + z: rampZ, + baseMat, + tileSize, + }); + } +} + +// 创建一组楼梯 +function createStairsToPlatform(root, { platformRadius, stepHeight, stepCount, stepDepth, width, z, baseMat, tileSize }) { + const stairRun = stepCount * stepDepth; + const outer = platformRadius + stairRun; + + for (let i = 0; i < stepCount; i++) { + const height = stepHeight * (i + 1); + const x = outer - (i + 0.5) * stepDepth; + addBox( + root, + new Vector3(x, height / 2, z), + new Vector3(stepDepth, height, width), + baseMat, + tileSize, + ); + } +} + +// 创建一个斜坡 +function createRampToPlatform(root, { platformRadius, angleDeg, length, width, z, baseMat, tileSize }) { + const height = Math.tan(angleDeg * Math.PI / 180) * length; + const origin = new Vector3(platformRadius + length * 0.5, 0, z); + const rotY = Math.PI; + const ramp = new Mesh(createRampGeometry(length, height, width, origin, rotY, tileSize), baseMat); + ramp.position.copy(origin); + ramp.rotation.y = rotY; + return addShadowMesh(root, ramp); +} + +// 创建斜坡几何体。 +function createRampGeometry(length, height, width, origin = { x: 0, y: 0, z: 0 }, rotY = 0, tileSize = TILE_SIZE) { + const halfLength = length / 2; + const halfWidth = width / 2; + const cosY = Math.cos(rotY); + const sinY = Math.sin(rotY); + const tile = Math.max(tileSize, 1e-6); + const vertices = [ + -halfLength, 0, -halfWidth, + halfLength, 0, -halfWidth, + halfLength, height, -halfWidth, + -halfLength, 0, halfWidth, + halfLength, 0, halfWidth, + halfLength, height, halfWidth, + ]; + const indices = [ + 0, 1, 4, 0, 4, 3, + 1, 2, 5, 1, 5, 4, + 0, 3, 5, 0, 5, 2, + 0, 2, 1, + 3, 4, 5, + ]; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + const flat = geometry.toNonIndexed(); + const pos = flat.getAttribute("position"); + const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2); + + for (let t = 0; t < pos.count; t += 3) { + const ax = pos.getX(t); + const ay = pos.getY(t); + const az = pos.getZ(t); + const bx = pos.getX(t + 1); + const by = pos.getY(t + 1); + const bz = pos.getZ(t + 1); + const cx = pos.getX(t + 2); + const cy = pos.getY(t + 2); + const cz = pos.getZ(t + 2); + + const e1x = bx - ax; + const e1y = by - ay; + const e1z = bz - az; + const e2x = cx - ax; + const e2y = cy - ay; + const e2z = cz - az; + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + const nLen = Math.hypot(nx, ny, nz) || 1; + const nnx = nx / nLen; + const nny = ny / nLen; + const nnz = nz / nLen; + + for (let i = 0; i < 3; i++) { + const idx = t + i; + const lx = pos.getX(idx); + const ly = pos.getY(idx); + const lz = pos.getZ(idx); + const x = origin.x + lx * cosY + lz * sinY; + const y = origin.y + ly; + const z = origin.z - lx * sinY + lz * cosY; + let u = 0; + let v = 0; + + if (Math.abs(nnz) > 0.7) { + u = Math.hypot(lx + halfLength, ly); + v = y; + } else if (Math.abs(nnx) > 0.7) { + u = z; + v = y; + } else if (Math.abs(nny) > 0.7 && nny < 0) { + u = x; + v = z; + } else { + u = Math.hypot(lx + halfLength, ly); + v = z; + } + uv.setXY(idx, u / tile, v / tile); + } + } + + flat.setAttribute("uv", uv); + flat.computeVertexNormals(); + return flat; +} + +// 添加盒子 +function addBox(root, position, size, material, tileSize = TILE_SIZE) { + const geo = applyWorldTileUV(new BoxGeometry(size.x, size.y, size.z), position, tileSize); + const mesh = new Mesh(geo, material); + mesh.position.copy(position); + return addShadowMesh(root, mesh); +} + +// 统一启用网格阴影并挂到场景节点 +function addShadowMesh(root, mesh) { + mesh.castShadow = true; + mesh.receiveShadow = true; + root.add(mesh); + return mesh; +} + +// 启用模型阴影 +function enableModelShadows(root) { + root?.traverse((obj) => { + if (obj.isMesh) { + obj.castShadow = true; + obj.receiveShadow = true; + } + }); +} + +// 将调参写回场上全部车辆 +function applyVehicleTuning() { + const t = VEHICLE_TUNING; + const vehicles = player?.getAllVehicles?.() ?? []; + for (const v of vehicles) { + player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale); + player?.setVehicleClearance?.(v, t.chassis.clearance); + const he = v.halfExtents; + const volume = 8 * he.x * he.y * he.z; + v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density)); + v.chassisBody.linearDamping = t.chassis.linearDamping; + v.chassisBody.angularDamping = t.chassis.angularDamping; + + Object.assign(v.steering, t.steering); + Object.assign(v.grip, t.grip); + v.maxSpeed = t.power.maxSpeed * v.scale; + v.acceleration = t.power.acceleration * v.scale; + v.deceleration = t.power.deceleration * v.scale; + v.followVehicleDirection = t.followVehicleDirection; + + const sus = t.suspension; + const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness)); + const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2); + v.sideFrictionStiffness = sus.sideFrictionStiffness; + + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + v.vehicleController.setWheelSuspensionRestLength(i, rest); + v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale); + v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness); + v.vehicleController.setWheelSuspensionCompression(i, sus.compression); + v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation); + v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce); + v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip); + v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness); + v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence); + } + + if (v.physicsBoxMesh) { + if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh); + else v.vehicleGroup.remove(v.physicsBoxMesh); + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel; + if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres; + } + if (player?.vehicle?.params?.debug) { + Object.assign(player.vehicle.params.debug, t.debug); + } +} + +// 创建调试面板 +function createDebugPanel() { + const options = footIK.getOptions(); + const playerScale = player.playerModelConfig.scale; + const toBasePlayerValue = (value) => value / playerScale; + const params = { + colliderDebug: false, + playerCapsuleDebug: false, + character: currentCharacterName, + hair: currentHairName, + height: currentHeightFactor, + hitboxScale: currentHitboxScale, + armHang: armAdjust.hang, + armStraighten: armAdjust.straighten, + dynamicBodyDebug: false, + mouseSensitivity: player.cam.sensitivity, + gravity: toBasePlayerValue(player.gravity), + jumpHeight: toBasePlayerValue(player.jumpHeight), + playerSpeed: toBasePlayerValue(player.playerSpeed), + playerRunSpeed: toBasePlayerValue(player.playerRunSpeed), + flySpeed: toBasePlayerValue(player.playerFlySpeed), + playerAcceleration: player.playerAcceleration, + playerDeceleration: player.playerDeceleration, + timeScale: player.timeScale, + minCamDistance: toBasePlayerValue(player.cam.minDist), + maxCamDistance: toBasePlayerValue(player.cam.maxDist), + camLookAtHeightRatio: player.cam.lookAtHeightRatio, + enableOverShoulderView: player.enableOverShoulderView, + camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio, + enableSpringCamera: player.cam.enableSpringCamera, + springCameraTime: player.cam.springCameraTime, + thirdMouseMode: player.cam.mouseMode, + enableZoom: player.cam.zoomEnabled, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + predictivePlacement: options.predictivePlacement, + straightPoleEnabled: options.straightPoleEnabled, + pelvisOffset: 0, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + leftPrediction: "disabled", + leftPredictionScore: "--", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + rightPrediction: "disabled", + rightPredictionScore: "--", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + plantedHeightSpeed: options.plantedHeightSpeed, + penetrationLiftSpeed: options.penetrationLiftSpeed, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxPredictionClearance: options.maxPredictionClearance, + maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI, + }; + footIKDebugParams = params; + + const applyFootIKOptions = (patch) => { + footIK.configure(patch); + }; + + footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled); + + const gui = new GUI({ title: "Showcase Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player?.setColliderDebug(value); + }); + collisionFolder.add(params, "dynamicBodyDebug").name("Dynamic Bodies").onChange((value) => { + player?.setDynamicBodyDebug?.(value); + }); + collisionFolder.open(); + + const characterFolder = gui.addFolder("Character"); + const modelFolder = characterFolder.addFolder("Model"); + modelFolder.add(params, "character", CHARACTERS.map((c) => c.name)).name("Character") + .onChange((value) => { void switchCharacter(value); }); + modelFolder.add(params, "hair", [HAIR_NONE, ...HAIRSTYLES]).name("Hair") + .onChange((value) => { void switchHair(value); }); + const heightCtrl = modelFolder.add(params, "height", 0.7, 1.3, 0.01).name("Height") + .onChange((value) => { + characterHeights.set(currentCharacterName, value); + currentHeightFactor = value; + applyPlayerScale(playerScaleForZone()); + }); + // 半径只在加载模型时读取,故松手后再重载当前角色 + modelFolder.add(params, "hitboxScale", 0.5, 2, 0.05).name("Hitbox Width") + .onFinishChange((value) => { + currentHitboxScale = value; + void switchCharacter(currentCharacterName, true); + }); + modelFolder.add(params, "playerCapsuleDebug").name("Show Hitbox").onChange((value) => { + player?.setPlayerCapsuleDebug?.(value); + }); + const armHangCtrl = modelFolder.add(params, "armHang", -20, 20, 0.5).name("Arm Hang") + .onChange((value) => { + armAdjust.hang = value; + if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 }); + characterArms.get(currentCharacterName).hang = value; + }); + const armStraightenCtrl = modelFolder.add(params, "armStraighten", -20, 30, 0.5).name("Arm Straighten") + .onChange((value) => { + armAdjust.straighten = value; + if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 }); + characterArms.get(currentCharacterName).straighten = value; + }); + + // 换角色后把记住的数值写回滑杆 + syncCharacterGui = () => { + params.height = currentHeightFactor; + params.armHang = armAdjust.hang; + params.armStraighten = armAdjust.straighten; + heightCtrl.updateDisplay(); + armHangCtrl.updateDisplay(); + armStraightenCtrl.updateDisplay(); + }; + + const movementFolder = characterFolder.addFolder("Movement"); + movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => { + player?.setGravity(value); + }); + movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => { + player?.setJumpHeight(value); + }); + movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => { + player?.setPlayerSpeed(value); + }); + movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => { + player?.setPlayerRunSpeed(value); + }); + movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => { + player?.setPlayerFlySpeed(value); + }); + movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => { + if (player) player.playerAcceleration = value; + }); + movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => { + if (player) player.playerDeceleration = value; + }); + movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => { + if (player) player.timeScale = value; + }); + movementFolder.close(); + + const characterCameraFolder = characterFolder.addFolder("Camera"); + characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => { + player?.setMouseSensitivity(value); + }); + characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => { + player?.setMinCamDistance(value); + }); + characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => { + player?.setMaxCamDistance(value); + }); + characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => { + player?.setCamLookAtHeightRatio(value); + }); + characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => { + player?.setOverShoulderView(value); + }); + characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => { + player?.setCamOverShoulderOffsetRatio(value); + }); + characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => { + if (player) player.cam.enableSpringCamera = value; + }); + characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => { + if (player) player.cam.springCameraTime = value; + }); + characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => { + player?.setThirdMouseMode(Number(value)); + }); + characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => { + player?.setEnableZoom(value); + }); + characterCameraFolder.close(); + + const footIKFolder = characterFolder.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK.setEnabled(value); + footIK.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => { + footIK.configure({ predictivePlacement: value }); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + footIK.configure({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable(); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisRaise: value }); + }); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisDrop: value }); + }); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootRaise: value }); + }); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootDrop: value }); + }); + footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ plantedHeightSpeed: value }); + }); + footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ penetrationLiftSpeed: value }); + }); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHalfWidth: value }); + }); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleToeExtend: value }); + }); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHeelExtend: value }); + }); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => { + applyFootIKOptions({ soleSkinThickness: value }); + }); + soleFolder.close(); + + const predictionFolder = footIKFolder.addFolder("Prediction"); + predictionFolder.add(params, "maxPredictionClearance", 0, 80, 1).name("Max Clearance").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPredictionClearance: value }); + }); + predictionFolder.close(); + footIKFolder.close(); + characterFolder.open(); + + const t = VEHICLE_TUNING; + const vehicleFolder = gui.addFolder("Vehicle"); + + const chassisFolder = vehicleFolder.addFolder("Chassis"); + chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning); + chassisFolder.close(); + + const vehicleCameraFolder = vehicleFolder.addFolder("Camera"); + vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning); + vehicleCameraFolder.close(); + + const vehicleDebugFolder = vehicleFolder.addFolder("Debug"); + vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning); + vehicleDebugFolder.close(); + + const drivingFolder = vehicleFolder.addFolder("Driving"); + drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning); + drivingFolder.close(); + + const handlingFolder = vehicleFolder.addFolder("Handling"); + handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((deg) => { + t.steering.maxSteerAngle = deg * Math.PI / 180; + applyVehicleTuning(); + }); + handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning); + handlingFolder.close(); + + const suspensionFolder = vehicleFolder.addFolder("Suspension"); + suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning); + suspensionFolder.close(); + + vehicleFolder.open(); +} + +// 窗口尺寸变化 +function onResize() { + const container = document.querySelector("#container"); + if (!container || !camera || !renderer) return; + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); +} + +// 每帧调用 +function animate(timestamp) { + animTimer.update(timestamp); + const rawDelta = Math.min(animTimer.getDelta(), 1 / 40); + if (player) { + scaledAnimTime += rawDelta * player.timeScale; + updateGlowPortal(scaledAnimTime); + updateKinematicPlatforms(scaledAnimTime); + updatePatrolNPCs(rawDelta * player.timeScale); + updateTrapScale(); + player.update(rawDelta); + applyArmAdjust(); + } else { + updateGlowPortal(animTimer.getElapsed()); + controls?.update(); + } + updateFootIKDebugPanel(); + renderer.render(scene, camera); + stats?.update(); +} + +// 更新 Foot IK 运行状态只读字段。 +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.pelvisOffset = footIK.getPelvisOffset(); + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left"); + footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left")); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); + footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right"); + footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right")); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} + +function formatPredictionScore(value) { + return Number.isFinite(value) ? value.toFixed(2) : "--"; +} diff --git a/three-player-controller/example/styles/common.css b/three-player-controller/example/styles/common.css new file mode 100644 index 0000000..d9d4979 --- /dev/null +++ b/three-player-controller/example/styles/common.css @@ -0,0 +1,343 @@ +.lil-gui.root, +.lil-gui.root .lil-gui { + --background-color: rgba(8, 12, 18, 0.8); + --text-color: #e8eef4; + --title-background-color: rgba(255, 255, 255, 0.055); + --title-text-color: #f4f7fb; + --widget-color: rgba(255, 255, 255, 0.09); + --hover-color: rgba(255, 255, 255, 0.15); + --focus-color: rgba(232, 160, 32, 0.28); + --number-color: #f0b64a; + --string-color: #79c0ff; + --font-size: 11px; + --input-font-size: 11px; + --font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + --font-family-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --padding: 6px; + --spacing: 5px; + --widget-height: 24px; + --name-width: 45%; + --folder-indent: 10px; + --widget-border-radius: 5px; +} + +.lil-gui.root { + color-scheme: dark; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: var(--background-color); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.lil-gui.root .lil-gui { + --background-color: transparent; +} + +.lil-gui.root > .title { + border-radius: 9px 9px 0 0; + color: var(--title-text-color); + letter-spacing: 0.02em; +} + +.lil-gui.root.closed > .title { + border-radius: 9px; +} + +.lil-gui.root .lil-gui > .title { + color: rgba(232, 238, 244, 0.88); +} + +.lil-gui.root .controller .name { + color: rgba(230, 238, 246, 0.78); + font-weight: 500; +} + +.lil-gui.root input, +.lil-gui.root .controller.option .display, +.lil-gui.root .controller.function button { + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.lil-gui.root input:focus-visible, +.lil-gui.root button:focus-visible, +.lil-gui.root select:focus-visible { + outline: 1px solid rgba(240, 192, 90, 0.9); + outline-offset: 1px; +} + +.lil-gui.root .controller.number .slider .fill { + background: linear-gradient(90deg, rgba(232, 160, 32, 0.18), rgba(240, 192, 90, 0.34)); +} + +.lil-gui.root input[type="checkbox"]:checked { + color: #1a1408; + background: linear-gradient(180deg, #f0c05a, #e8a020); + border-color: #f6d48a; +} + +.hud { + position: fixed; + left: 12px; + top: 12px; + z-index: 9999; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + color: #e8eef4; + background: rgba(8, 12, 18, 0.55); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + pointer-events: none; + user-select: none; +} + +.hint-move { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-top: 1px; +} + +.hint-cluster { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.wasd { + display: grid; + grid-template-columns: repeat(3, 26px); + grid-template-rows: repeat(2, 26px); + gap: 4px; +} + +.wasd-w { + grid-column: 2; + grid-row: 1; +} + +.wasd-a { + grid-column: 1; + grid-row: 2; +} + +.wasd-s { + grid-column: 2; + grid-row: 2; +} + +.wasd-d { + grid-column: 3; + grid-row: 2; +} + +.hud kbd { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 26px; + height: 26px; + padding: 0 7px; + border-radius: 5px; + color: #f4f7fb; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)); + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom-color: rgba(0, 0, 0, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35); + font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + transition: background 0.08s ease, border-color 0.08s ease, color 0.08s ease, box-shadow 0.08s ease, transform 0.08s ease; +} + +.hud kbd.is-down { + color: #1a1408; + background: linear-gradient(180deg, #f0c05a, #e8a020); + border-color: #f6d48a; + border-bottom-color: #b87a10; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); + transform: translateY(1px); +} + +.wasd kbd { + width: 26px; + padding: 0; +} + +.hint-actions { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 8px; + row-gap: 5px; + align-items: center; + border-left: 1px solid rgba(255, 255, 255, 0.08); + padding-left: 12px; +} + +.hint-group { + display: contents; +} + +.hint-keys { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.hint-text { + color: rgba(230, 238, 246, 0.78); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} + +.hint-mouse { + width: 20px; + height: 30px; + flex-shrink: 0; +} + +.hint-mouse-body { + position: relative; + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 10px 10px 11px 11px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)); + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom-color: rgba(0, 0, 0, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.hint-mouse-wheel { + position: absolute; + top: 6px; + left: 50%; + width: 3px; + height: 7px; + margin-left: -1.5px; + border-radius: 1px; + background: rgba(244, 247, 251, 0.35); +} + +.hint-mouse-nub { + position: absolute; + left: 50%; + top: 64%; + width: 6px; + height: 6px; + margin: -3px 0 0 -3px; + border-radius: 50%; + background: #e8a020; + box-shadow: 0 0 6px rgba(232, 160, 32, 0.55); + transform: translate(var(--look-x, 0px), var(--look-y, 0px)); + will-change: transform; +} + +.hint-mouse.is-active .hint-mouse-body { + border-color: rgba(232, 160, 32, 0.55); +} + +.hud[data-hint-mode] .hint-set { + display: none; +} + +.hud[data-hint-mode="on-foot"] .hint-set[data-for="on-foot"], +.hud[data-hint-mode="vehicle"] .hint-set[data-for="vehicle"] { + display: contents; +} + +.model-credit { + position: fixed; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + font-size: 16px; + color: #e6eef6; + background: rgba(0, 0, 0, 0.35); + padding: 6px 12px; + border-radius: 999px; + backdrop-filter: blur(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; +} + +.model-credit a { + color: #6ecbff; + text-decoration: none; +} + +.model-credit a:hover { + text-decoration: underline; +} + +.source { + position: fixed; + bottom: 16px; + right: 16px; + padding: 12px; + border-radius: 50%; + margin-bottom: 0; + background-color: #fff; + opacity: 0.9; + z-index: 999; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.15); +} + +.source img { + display: block; + width: 24px; + height: 24px; +} + +#top-notice { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + background: rgba(0, 0, 0, 0.45); + color: #e6eef6; + padding: 7px 16px; + border-radius: 8px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 13px; + line-height: 1.45; + text-align: center; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +#top-notice .credit-line { + white-space: nowrap; +} + +#top-notice a { + color: #79c0ff; + text-decoration: none; +} + +#top-notice a:hover { + text-decoration: underline; +} + +@media (hover: none) and (pointer: coarse), (max-width: 768px) { + .hud, + .source, + #top-notice, + .model-credit { + display: none !important; + } +} diff --git a/three-player-controller/example/volumeCloud.js b/three-player-controller/example/volumeCloud.js new file mode 100644 index 0000000..cc08f08 --- /dev/null +++ b/three-player-controller/example/volumeCloud.js @@ -0,0 +1,230 @@ +// 来源:https://threejs.org/examples/?q=cloud#webgl_volume_cloud +import { + BackSide, + BoxGeometry, + Color, + Data3DTexture, + GLSL3, + LinearFilter, + Mesh, + RawShaderMaterial, + RedFormat, + Vector3, +} from "three"; +import { ImprovedNoise } from "three/examples/jsm/math/ImprovedNoise.js"; + +const vertexShader = ` + in vec3 position; + + uniform mat4 modelMatrix; + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + uniform vec3 cameraPos; + + out vec3 vOrigin; + out vec3 vDirection; + + void main() { + vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 ); + + vOrigin = vec3( inverse( modelMatrix ) * vec4( cameraPos, 1.0 ) ).xyz; + vDirection = position - vOrigin; + + gl_Position = projectionMatrix * mvPosition; + } +`; + +const fragmentShader = ` + precision highp float; + precision highp sampler3D; + + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + + in vec3 vOrigin; + in vec3 vDirection; + + out vec4 color; + + uniform vec3 base; + uniform sampler3D map; + + uniform float threshold; + uniform float range; + uniform float opacity; + uniform float steps; + uniform float frame; + + uint wang_hash(uint seed) + { + seed = (seed ^ 61u) ^ (seed >> 16u); + seed *= 9u; + seed = seed ^ (seed >> 4u); + seed *= 0x27d4eb2du; + seed = seed ^ (seed >> 15u); + return seed; + } + + float randomFloat(inout uint seed) + { + return float(wang_hash(seed)) / 4294967296.; + } + + vec2 hitBox( vec3 orig, vec3 dir ) { + const vec3 box_min = vec3( - 0.5 ); + const vec3 box_max = vec3( 0.5 ); + vec3 inv_dir = 1.0 / dir; + vec3 tmin_tmp = ( box_min - orig ) * inv_dir; + vec3 tmax_tmp = ( box_max - orig ) * inv_dir; + vec3 tmin = min( tmin_tmp, tmax_tmp ); + vec3 tmax = max( tmin_tmp, tmax_tmp ); + float t0 = max( tmin.x, max( tmin.y, tmin.z ) ); + float t1 = min( tmax.x, min( tmax.y, tmax.z ) ); + return vec2( t0, t1 ); + } + + float sample1( vec3 p ) { + return texture( map, p ).r; + } + + float shading( vec3 coord ) { + float step = 0.01; + return sample1( coord + vec3( - step ) ) - sample1( coord + vec3( step ) ); + } + + vec4 linearToSRGB( in vec4 value ) { + return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a ); + } + + void main(){ + vec3 rayDir = normalize( vDirection ); + vec2 bounds = hitBox( vOrigin, rayDir ); + + if ( bounds.x > bounds.y ) discard; + + bounds.x = max( bounds.x, 0.0 ); + + float stepSize = ( bounds.y - bounds.x ) / steps; + + uint seed = uint( gl_FragCoord.x ) * uint( 1973 ) + uint( gl_FragCoord.y ) * uint( 9277 ) + uint( frame ) * uint( 26699 ); + vec3 size = vec3( textureSize( map, 0 ) ); + float randNum = randomFloat( seed ) * 2.0 - 1.0; + vec3 p = vOrigin + bounds.x * rayDir; + p += rayDir * randNum * ( 1.0 / size ); + + vec4 ac = vec4( base, 0.0 ); + + for ( float i = 0.0; i < steps; i += 1.0 ) { + float d = sample1( p + 0.5 ); + + d = smoothstep( threshold - range, threshold + range, d ) * opacity; + + float col = shading( p + 0.5 ) * 3.0 + ( ( p.x + p.y ) * 0.25 ) + 0.2; + + ac.rgb += ( 1.0 - ac.a ) * d * col; + ac.a += ( 1.0 - ac.a ) * d; + + if ( ac.a >= 0.95 ) break; + + p += rayDir * stepSize; + } + + color = linearToSRGB( ac ); + + if ( color.a == 0.0 ) discard; + } +`; + +// 生成云纹理 +function createCloudTexture(size, noiseScale) { + // 体素密度 + const data = new Uint8Array(size * size * size); + const perlin = new ImprovedNoise(); + const vector = new Vector3(); + + let i = 0; + for (let z = 0; z < size; z++) { + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const d = 1.0 - vector.set(x, y, z).subScalar(size / 2).divideScalar(size).length(); + data[i] = (128 + 128 * perlin.noise(x * noiseScale / 1.5, y * noiseScale, z * noiseScale / 1.5)) * d * d; + i++; + } + } + } + + // 上传纹理 + const texture = new Data3DTexture(data, size, size, size); + texture.format = RedFormat; + texture.minFilter = LinearFilter; + texture.magFilter = LinearFilter; + texture.unpackAlignment = 1; + texture.needsUpdate = true; + return texture; +} + +// 创建体积云 +export function createVolumeCloud(options = {}) { + // 默认参数 + const { + size = 96, + noiseScale = 0.05, + color = 0x798aa0, + threshold = 0.25, + opacity = 0.25, + range = 0.1, + steps = 80, + scale = [3, 1.5, 2.2], + } = options; + + // 构造材质 + const texture = createCloudTexture(size, noiseScale); + const material = new RawShaderMaterial({ + glslVersion: GLSL3, + uniforms: { + base: { value: new Color(color) }, + map: { value: texture }, + cameraPos: { value: new Vector3() }, + threshold: { value: threshold }, + opacity: { value: opacity }, + range: { value: range }, + steps: { value: steps }, + frame: { value: 0 }, + }, + vertexShader, + fragmentShader, + side: BackSide, + transparent: true, + depthWrite: true, + }); + + // 返回网格 + const cloud = new Mesh(new BoxGeometry(1, 1, 1), material); + cloud.name = "volumeCloud"; + cloud.scale.set(...scale); + cloud.frustumCulled = false; + return cloud; +} + +// 更新体积云 +export function updateVolumeCloud(cloud, camera, rotationSpeed = 0) { + if (!cloud?.material?.uniforms || !camera) return; + if (!cloud.material.visible) return; + + // 同步相机 + const uniforms = cloud.material.uniforms; + camera.getWorldPosition(uniforms.cameraPos.value); + uniforms.frame.value += 1; + // 可选旋转 + if (rotationSpeed !== 0) cloud.rotation.y += rotationSpeed / 60; +} + +// 释放体积云 +export function disposeVolumeCloud(cloud) { + if (!cloud) return; + + // 释放显存 + cloud.geometry?.dispose(); + cloud.material?.uniforms?.map?.value?.dispose(); + cloud.material?.dispose(); +} diff --git a/three-player-controller/package-lock.json b/three-player-controller/package-lock.json new file mode 100644 index 0000000..f3cf022 --- /dev/null +++ b/three-player-controller/package-lock.json @@ -0,0 +1,2560 @@ +{ + "name": "three-player-controller", + "version": "0.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "three-player-controller", + "version": "0.6.2", + "license": "MIT", + "devDependencies": { + "@sparkjsdev/spark": "^2.1.0", + "@types/node": "^25.0.6", + "@types/three": "^0.184.0", + "3d-tiles-renderer": "^0.4.24", + "rimraf": "^6.0.0", + "tellux": "^0.2.0", + "tsup": "^8.0.1", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "peerDependencies": { + "three": ">=0.159.0", + "three-mesh-bvh": ">=0.7.8" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dgreenheck/ez-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dgreenheck/ez-tree/-/ez-tree-1.1.0.tgz", + "integrity": "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">=0.167" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@react-three/fiber": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz", + "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sparkjsdev/spark": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sparkjsdev/spark/-/spark-2.1.0.tgz", + "integrity": "sha512-BRw+MuMzx0B3K8fDLQygt2OHEhYUV+41RX7btq9pZ3rCVrq42o57jW34VAIvC7JO/84DJh/1AutACV9ym6BfVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, + "peerDependencies": { + "three": ">=0.180.0" + } + }, + "node_modules/@takram/three-atmosphere": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@takram/three-atmosphere/-/three-atmosphere-0.19.1.tgz", + "integrity": "sha512-k/QAgED7bMkjVzHiBu2QD1Cqg5lcgHLc8vxVeiTTzUE+glZcZOXTjjvi74yjb+eKQHW5wh1WL/DjxVvluAQU0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-geospatial": "0.9.1", + "astronomy-engine": "^2.1.19", + "react-merge-refs": "^3.0.2", + "tiny-invariant": "^1.3.3", + "url-join": "^5.0.0" + }, + "peerDependencies": { + "@react-three/drei": ">=10.0.2", + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.7", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-clouds": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@takram/three-clouds/-/three-clouds-0.7.6.tgz", + "integrity": "sha512-cfoEI3W5NJuYUL1Pd4k5UXLlb7rLThh3dNIOO+/EPw8cbuRUgiIVEVtNPmA+v5KRJf87gPI+rjuxgK0huAajDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-atmosphere": "0.19.1", + "@takram/three-geospatial": "0.9.1", + "tiny-invariant": "^1.3.3", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.7", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-geospatial": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial/-/three-geospatial-0.9.1.tgz", + "integrity": "sha512-fB7DAW0afN87e0/CcgZYVkF76yI0SXaRcVuT7M6+5vLZ8ySfJ7br2ceni37GVyY7zO1sLMBtoaMCH1mal/Zf9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "tiny-invariant": "^1.3.3", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "react": ">=19.0", + "three": ">=0.170.0" + } + }, + "node_modules/@takram/three-geospatial-effects": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial-effects/-/three-geospatial-effects-0.6.4.tgz", + "integrity": "sha512-Ufgap/N30j4Go5fyI9qdax9RiVK69OY+UuGCsYeuvGyihbfN5zh8ChHfu1c8vjIkz1Czz8zP8+6OvjD9+QxkMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-geospatial": "0.9.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.4", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/3d-tiles-renderer": { + "version": "0.4.28", + "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.4.28.tgz", + "integrity": "sha512-eDFg3fTgnFeoa6kU9oyAQIci/N/CJq6JUi/ZUrmDyqLzEDH9mWd9FBs+NqS+0eO8b7y4HjIWfUVnf8o03AFtSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mapbox/vector-tile": "^2.0.3", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2" + }, + "peerDependencies": { + "@babylonjs/core": ">=8.0.0", + "@babylonjs/loaders": ">=8.0.0", + "@react-three/fiber": "^8.17.9 || ^9.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "three": ">=0.167.0" + }, + "peerDependenciesMeta": { + "@babylonjs/core": { + "optional": true + }, + "@babylonjs/loaders": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/astronomy-engine": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/astronomy-engine/-/astronomy-engine-2.1.19.tgz", + "integrity": "sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pmtiles": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz", + "integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postprocessing": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", + "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", + "dev": true, + "license": "Zlib", + "peer": true, + "peerDependencies": { + "three": ">= 0.168.0 < 0.185.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-merge-refs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-3.0.2.tgz", + "integrity": "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tellux": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tellux/-/tellux-0.2.0.tgz", + "integrity": "sha512-Ge9KYw+yKTb/leH/iwMr9YPnycODnmOcXTEwhDqHG7zAgj9UYwwt9EC+lb94sjRJ0k5oNyuRvpPg0M/aUC0wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "dotenv": "^17.4.2", + "three-mesh-bvh": "0.9.0" + }, + "peerDependencies": { + "@mapbox/vector-tile": "^2.0.3", + "@takram/three-atmosphere": "0.19.1", + "@takram/three-clouds": "0.7.6", + "@takram/three-geospatial": "0.9.1", + "@takram/three-geospatial-effects": "0.6.4", + "3d-tiles-renderer": "^0.4.27", + "pbf": "^4.0.1", + "postprocessing": "6.39.1", + "three": "^0.184.0" + }, + "peerDependenciesMeta": { + "@mapbox/vector-tile": { + "optional": true + }, + "pbf": { + "optional": true + } + } + }, + "node_modules/tellux/node_modules/three-mesh-bvh": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.0.tgz", + "integrity": "sha512-xAwZj0hZknpwVsdK5BBJTIAZDjDPZCRzURY1o+z/JHBON/jc2UetK1CzPeQZiiOVSfI4jV2z7sXnnGtgsgnjaA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.14", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.14.tgz", + "integrity": "sha512-xJzfUXnLYjgw5tRKGxjfJW3Pcx0IbwNskvpdwRryhK07SYrKrYB4W8zAaPxu784gK7G4lCBgG+vfG3YeYn95kg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/three-player-controller/package.json b/three-player-controller/package.json new file mode 100644 index 0000000..5d34e75 --- /dev/null +++ b/three-player-controller/package.json @@ -0,0 +1,52 @@ +{ + "name": "three-player-controller", + "version": "0.6.2", + "description": "Third-person / first-person player controller for three.js (three-mesh-bvh based)", + "author": "hh-hang", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./foot-ik": { + "types": "./dist/foot-ik.d.ts", + "import": "./dist/foot-ik.mjs", + "require": "./dist/foot-ik.js" + } + }, + "scripts": { + "dev": "vite", + "build": "tsup", + "clean": "rimraf dist", + "prepare": "npm run build", + "build:example": "vite build" + }, + "peerDependencies": { + "three": ">=0.159.0", + "three-mesh-bvh": ">=0.7.8" + }, + "devDependencies": { + "@sparkjsdev/spark": "^2.1.0", + "@types/node": "^25.0.6", + "@types/three": "^0.184.0", + "3d-tiles-renderer": "^0.4.24", + "rimraf": "^6.0.0", + "tellux": "^0.2.0", + "tsup": "^8.0.1", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/hh-hang/three-player-controller" + }, + "homepage": "https://github.com/hh-hang/three-player-controller" +} \ No newline at end of file diff --git a/three-player-controller/src/PlayerController.ts b/three-player-controller/src/PlayerController.ts new file mode 100644 index 0000000..fb9564a --- /dev/null +++ b/three-player-controller/src/PlayerController.ts @@ -0,0 +1,1023 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; + +import { MobileControls } from "./utils/MobileControls"; +import type { PlayerControllerOptions, PlayerModelOptions, VehicleInstance, VehicleOptions, KinematicColliderEntry, KeyMap } from "./types"; +import type { PlayerPlugin } from "./plugins/types"; +import { AnimationSystem } from "./systems/AnimationSystem"; +import { CameraSystem } from "./systems/CameraSystem"; +import { InputSystem } from "./systems/InputSystem"; +import { VehicleSystem } from "./systems/VehicleSystem"; +import { DynamicBodySystem } from "./systems/DynamicBodySystem"; +import { ColliderRegistry } from "./systems/ColliderRegistry"; +import type { DynamicBody, DynamicBodyKind } from "./collision/DynamicBody"; +import type { ColliderDesc, ColliderHandle } from "./collision/colliderDesc"; +import type { MotionType } from "./collision/CollisionWorld"; +import { applyCapsuleCollision, createCollisionTemps, type CollisionTemps } from "./utils/capsuleCollision"; +import { getBbox } from "./utils/bbox"; +import { BvhWorkerPool } from "./utils/BvhWorkerPool"; +import { CollisionWorld } from "./collision/CollisionWorld"; +import { CHARACTER_QUERY_MASK } from "./collision/groups"; +import { CharacterMovement } from "./systems/CharacterMovement"; +import { PlayerDebug } from "./systems/PlayerDebug"; + +/** 未传入delta时计算帧间隔。 */ +let _lastUpdateTime = performance.now(); + +function isMobileDevice() { + return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); +} + +export class playerController { + + // ==================== 场景引用 ==================== + /** GLTF加载器缓存。 */ + private _loader: GLTFLoader | null = null; + /** GLTF加载器。 */ + get loader(): GLTFLoader { + return this.initLoader(); + } + set loader(loader: GLTFLoader) { + this._loader = loader; + } + /** 三维场景。 */ + scene!: THREE.Scene; + /** 透视相机。 */ + camera!: THREE.PerspectiveCamera; + /** 轨道控制器。 */ + controls!: OrbitControls; + + // ==================== 玩家配置 ==================== + /** 模型配置项。 */ + playerModelConfig!: PlayerModelOptions; + /** 初始出生位置。 */ + private initPos: THREE.Vector3 = new THREE.Vector3(0, 0, 0); + /** 重力加速度。 */ + gravity = -2400; + /** 跳跃初速度。 */ + jumpHeight = 600; + /** 行走速度。 */ + playerSpeed = 200; + /** 跑步速度。 */ + playerRunSpeed = 600; + /** 飞行速度。 */ + playerFlySpeed = 2100; + /** 当前实际速度。 */ + curPlayerSpeed = 0; + /** 越肩视角开关。 */ + enableOverShoulderView = false; + /** 显示移动端控件。 */ + isShowMobileControls = true; + + // ==================== 玩家胶囊体 ==================== + /** 胶囊体半径。 */ + private playerCapsuleRadius = 30; + /** 半径缩放比。 */ + private playerCapsuleRadiusRatio = 1; + /** 胶囊体高度。 */ + private playerCapsuleHeight = 180; + /** 第一人称状态。 */ + isFirstPerson = false; + + // ==================== 运行状态 ==================== + /** 0步行 1载具。 */ + controllerMode: 0 | 1 = 0; + /** 是否在地面。 */ + playerIsOnGround = false; + /** 帧更新开关。 */ + isupdate = true; + /** 时间缩放系数。 */ + timeScale = 1; + /** 本帧实际使用的 delta(已钳制 + timeScale)。 */ + currentDelta = 0; + /** 飞行状态。 */ + isFlying = false; + /** 临时跳过玩家胶囊碰撞检测。 */ + skipCapsuleCollision = false; + /** 启用朝向输入。 */ + enableToward = true; + + // ==================== 玩家物体 ==================== + /** 玩家碰撞胶囊。 */ + playerCapsule!: THREE.Mesh & { capsuleInfo?: any }; + /** 模型根节点。 */ + playerModel: THREE.Object3D | null = null; + /** 头骨节点。 */ + playerModelHead: THREE.Object3D | null = null; + + // ==================== 碰撞体 ==================== + /** 碰撞体注册表。 */ + collisionWorld = new CollisionWorld(); + /** 统一创建 / 运动学跟随。 */ + private colliders = new ColliderRegistry(this); + /** 人物胶囊与地面探测调试。 */ + private playerDebug = new PlayerDebug(this); + /** 人物移动(胶囊推出)。 */ + private character = new CharacterMovement(this, this.playerDebug); + /** 当前站立的运动学碰撞体。 */ + activeKinematicCollider: KinematicColliderEntry | null = null; + /** 当前站立的动态刚体。 */ + activeDynamicBody: DynamicBody | null = null; + /** 移动系统本帧最终采用的地面支撑;供 IK 等插件与角色接地逻辑保持一致。 */ + private hasGroundSupport = false; + private readonly groundSupportHit = { + point: new THREE.Vector3(), + normal: new THREE.Vector3(0, 1, 0), + }; + /** 异步 BVH 构建队列。 */ + private readonly bvhWorkerPool = new BvhWorkerPool(); + + // ==================== 碰撞阈值 ==================== + /** 悬空胶囊离地高度。 */ + readonly rideHeight = 40; + // 站立 / 落地阈值 + /** 站立时胶囊原点应离地的高度。 */ + snapH = 0; + /** 离地超过此值判为悬空、施加重力。 */ + maxH = 0; + + // ==================== 台阶视觉平滑 ==================== + /** 插值追赶速度,越大追得越快。 */ + private stepSmoothFactor = 10; + /** 模型相对胶囊的基准 Y。 */ + private modelBaseY = 0; + /** 最小法线 Y 分量,地面法线与竖直夹角 ≤ 8° 视为台阶/平地(注入平滑)。 */ + readonly minFloorNormalY = Math.cos(8 * Math.PI / 180); + + // ==================== 移动端 ==================== + /** 移动端控件。 */ + mobileControls: MobileControls | null = null; + /** 靠近车辆。 */ + isNearVehicle = false; + + // ==================== 方向常量 & 复用向量 ==================== + /** 朝向旋转速度。 */ + rotationSpeed = 10; + /** 世界上方向。 */ + upVector = new THREE.Vector3(0, 1, 0); + /** 前。 */ + DIR_FWD = new THREE.Vector3(0, 0, -1); + /** 右。 */ + DIR_RGT = new THREE.Vector3(1, 0, 0); + + /** XZ 加速响应速度。 */ + playerAcceleration = 30; + /** XZ 减速响应速度。 */ + playerDeceleration = 30; + /** 减速基准速度。 */ + decelBase = 300; + /** 玩家速度。 */ + playerVelocity = new THREE.Vector3(); + /** 相机方向缓存。 */ + camDir = new THREE.Vector3(); + /** 移动方向缓存。 */ + moveDir = new THREE.Vector3(); + /** 步进方向缓存。 */ + xzDir = new THREE.Vector3(); + /** 本步移动前的胶囊位置。 */ + moveStepOrigin = new THREE.Vector3(); + /** 单帧胶囊碰撞子步上限。 */ + maxMoveSteps = 8; + /** 目标四元数。 */ + targetQuat = new THREE.Quaternion(); + /** 目标变换矩阵。 */ + targetMat = new THREE.Matrix4(); + /** 静态碰撞临时对象。 */ + staticTemps: CollisionTemps = createCollisionTemps(); + /** 运动学碰撞临时对象。 */ + kinematicTemps: CollisionTemps = createCollisionTemps(); + /** 地面检测射线。 */ + groundRaycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3(0, -1, 0)); + + // ==================== 插件 ==================== + /** 后处理等可选插件。 */ + private plugins: PlayerPlugin[] = []; + + // ==================== 事件回调 ==================== + /** 动画切换回调。 */ + onAnimationChange?: (name: string, action: THREE.AnimationAction) => void; + /** 视角切换前回调。 */ + onBeforeViewChange?: (isFirstPerson: boolean) => void; + /** 视角切换后回调。 */ + onViewChange?: (isFirstPerson: boolean) => void; + /** 落地状态回调。 */ + onGroundChange?: (onGround: boolean) => void; + /** 上车回调。 */ + onVehicleEnter?: (vehicle: VehicleInstance) => void; + /** 下车回调。 */ + onVehicleExit?: (vehicle: VehicleInstance) => void; + /** 朝向变化回调。 */ + onTowardChange?: (dx: number, dy: number, speed: number) => void; + + // ==================== 子系统 ==================== + /** 动画系统。 */ + animation = new AnimationSystem(this); + /** 相机系统。 */ + cam = new CameraSystem(this); + /** 输入系统。 */ + input = new InputSystem(this); + /** 载具系统。 */ + vehicle = new VehicleSystem(this); + /** 动态刚体系统。 */ + dynamics = new DynamicBodySystem(this); + + constructor() { + (this.groundRaycaster as any).firstHitOnly = true; + } + + // ==================== 初始化 ==================== + + /** 主初始化入口。 */ + async init(opts: PlayerControllerOptions, callback?: () => void) { + const m = opts.playerModelConfig; + const s = m.scale ?? 1; + + this.scene = opts.scene; + this.camera = opts.camera; + this.camera.rotation.order = "YXZ"; + this.controls = opts.controls; + + this.playerModelConfig = m; + this.initPos = opts.initPos ? opts.initPos.clone() : this.initPos; + + // 应用玩家参数 + const pm = this.playerModelConfig; + this.gravity = (pm.gravity ?? this.gravity) * s; + this.jumpHeight = (pm.jumpHeight ?? this.jumpHeight) * s; + this.playerSpeed = (pm.speed ?? this.playerSpeed) * s; + this.playerRunSpeed = (pm.runSpeed ?? this.playerRunSpeed) * s; + this.playerFlySpeed = (pm.flySpeed ?? this.playerFlySpeed) * s; + this.curPlayerSpeed = this.playerSpeed; + this.playerCapsuleRadiusRatio = pm.capsuleRadiusRatio ?? this.playerCapsuleRadiusRatio; + this.playerAcceleration = pm.acceleration ?? this.playerAcceleration; + this.playerDeceleration = pm.deceleration ?? this.playerDeceleration; + this.decelBase = this.playerSpeed; + + // 应用相机参数 + this.cam.sensitivity = opts.mouseSensitivity ?? this.cam.sensitivity; + this.cam.mouseMode = opts.thirdMouseMode ?? this.cam.mouseMode; + this.cam.enableSpringCamera = opts.enableSpringCamera ?? this.cam.enableSpringCamera; + this.cam.springCameraTime = opts.springCameraTime ?? this.cam.springCameraTime; + this.cam.zoomEnabled = opts.enableZoom ?? this.cam.zoomEnabled; + this.cam.minDist = (opts.minCamDistance ?? this.cam.minDist) * s; + this.cam.maxDist = (opts.maxCamDistance ?? this.cam.maxDist) * s; + this.cam.lookAtHeightRatio = opts.camLookAtHeightRatio ?? this.cam.lookAtHeightRatio; + this.cam.overShoulderOffsetRatio = opts.camOverShoulderOffsetRatio ?? this.cam.overShoulderOffsetRatio; + this.cam.originMaxDist = this.cam.maxDist; + this.cam.epsilon = this.cam.epsilon * s; + + this.isShowMobileControls = (opts.isShowMobileControls ?? this.isShowMobileControls) && isMobileDevice(); + this.enableOverShoulderView = opts.enableOverShoulderView ?? this.enableOverShoulderView; + this.isFirstPerson = opts.isFirstPerson ?? this.isFirstPerson; + this.timeScale = opts.timeScale ?? this.timeScale; + + // 自定义键位 + if (opts.keyMap) this.input.buildKeyMap(opts.keyMap); + + // 初始化移动端控件 + if (this.isShowMobileControls) { + this.mobileControls = new MobileControls(i => this.input.setInput(i), this.controls); + await this.mobileControls.init(opts.mobileControls); + } + + if (opts.colliders?.length) { + for (const desc of opts.colliders) this.addCollider(desc); + } + await this.loadPlayerModel(); + + this.input.bindEvents(); + this.cam.setCamPos(); + this.cam.initControls(); + this.cam.setOverShoulder(this.isFirstPerson ? false : this.enableOverShoulderView); + callback?.(); + } + + /** 初始化加载器。 */ + private initLoader(): GLTFLoader { + if (this._loader) return this._loader; + + const loader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.182.0/examples/jsm/libs/draco/gltf/"); + loader.setDRACOLoader(dracoLoader); + this._loader = loader; + return loader; + } + + // ==================== 玩家模型 ==================== + + /** 加载模型与动画。 */ + private async loadPlayerModel() { + try { + const config = this.playerModelConfig; + let model: THREE.Object3D; + let animations: THREE.AnimationClip[]; + + if (config.model) { + model = config.model; + animations = config.animations; + } else { + const gltf = await this.loader.loadAsync(config.url); + model = gltf.scene; + animations = gltf.animations ?? []; + } + + this.playerModel = model; + + // 初始化动画混合器 + this.animation.mixer = new THREE.AnimationMixer(this.playerModel); + this.animation.clips = animations; + this.animation.actions = new Map(); + + // 构建动作映射表 + const mc = this.playerModelConfig; + const isThreePartJump = Array.isArray(mc.jumpAnim); + this.animation.hasThreePartJump = isThreePartJump; + const mappings: [string, string][] = [ + [mc.idleAnim, "idle"], + [mc.walkAnim, "walking"], + [mc.leftWalkAnim || mc.walkAnim, "left_walking"], + [mc.rightWalkAnim || mc.walkAnim, "right_walking"], + [mc.backwardAnim || mc.walkAnim, "walking_backward"], + ...(typeof mc.jumpAnim === "string" + ? [[mc.jumpAnim, "jumping"]] as [string, string][] + : [] as [string, string][]), + [mc.runAnim, "running"], + [mc.flyIdleAnim || mc.idleAnim, "flyidle"], + [mc.flyAnim || mc.idleAnim, "flying"], + [mc.flyHoverForwardAnim || mc.flyAnim || mc.idleAnim, "flyHoverForward"], + [mc.flyHoverBackAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverBack"], + [mc.flyHoverLeftAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverLeft"], + [mc.flyHoverRightAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverRight"], + [mc.flyHoverUpAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverUp"], + [mc.flyHoverDownAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverDown"], + [mc.drivingAnim || mc.idleAnim, "driving"], + ]; + + for (const [clipName, actionName] of mappings) { + const clip = animations.find(a => a.name === clipName); + if (!clip) continue; + const action = this.animation.mixer.clipAction(clip); + if (actionName === "jumping") { + action.setLoop(THREE.LoopOnce, 1); + action.clampWhenFinished = true; + action.setEffectiveTimeScale(1.2); + } else { + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveTimeScale(1); + } + action.enabled = true; + action.setEffectiveWeight(0); + this.animation.actions.set(actionName, action); + } + + // 注册三段跳跃动画 + if (Array.isArray(mc.jumpAnim)) { + const [startClip, loopClip, endClip] = mc.jumpAnim; + const jumpDefs: [string, string, number, boolean][] = [ + [startClip, "jumpStart", THREE.LoopOnce, true], + [loopClip, "jumpLoop", THREE.LoopRepeat, false], + [endClip, "jumpEnd", THREE.LoopOnce, true], + ]; + for (const [clipName, key, loop, clamp] of jumpDefs) { + const clip = animations.find(a => a.name === clipName); + if (!clip) { console.warn(`找不到跳跃动画 clip: "${clipName}"`); continue; } + const action = this.animation.mixer!.clipAction(clip); + action.setLoop(loop as THREE.AnimationActionLoopStyles, loop === THREE.LoopOnce ? 1 : Infinity); + action.clampWhenFinished = clamp; + action.setEffectiveTimeScale(key === "jumpStart" ? 1.2 : 1); + action.enabled = true; + action.setEffectiveWeight(0); + this.animation.actions.set(key, action); + } + } + + // 注册默认动作组 + const defaultSet = new Map(); + for (const key of ["idle", "walking", "walking_backward", "running", "jumping", "flyidle", "flying"]) { + const action = this.animation.actions.get(key); + if (action) defaultSet.set(key, action); + } + this.animation.sets.set("default", defaultSet); + + this.animation.actions.get("idle")?.setEffectiveWeight(1); + this.animation.actions.get("idle")?.play(); + this.animation.state = this.animation.actions.get("idle")!; + + // 监听动画完成事件 + this.animation.mixerCb = (ev: any) => { + const done: THREE.AnimationAction = ev.action; + const resolveGroundAnim = () => { + if (this.input.fwd) { this.animation.playByName(this.input.shift ? "running" : "walking"); return; } + if (this.input.bkd) { this.animation.playByName("walking_backward"); return; } + if (this.input.rgt || this.input.lft) { this.animation.playByName("walking"); return; } + this.animation.playByName("idle"); + }; + if (done === this.animation.actions?.get("jumping")) { + if (this.animation.state === done) resolveGroundAnim(); + return; + } + if (done === this.animation.actions?.get("jumpStart")) { + if (this.animation.state === done) this.animation.playByName("jumpLoop"); + return; + } + if (done === this.animation.actions?.get("jumpEnd")) { + if (this.animation.state === done) resolveGroundAnim(); + return; + } + }; + this.animation.mixer.addEventListener("finished", this.animation.mixerCb); + + this.animation.mixer.update(0); + this.playerModel.updateMatrixWorld(true); + + // 计算胶囊体尺寸 + const { size } = getBbox(this.playerModel); + const modelScale = this.playerCapsuleHeight / size.y; + + const s = this.playerModelConfig.scale; + const r = this.playerCapsuleRadius * s * this.playerCapsuleRadiusRatio; + const h = this.playerCapsuleHeight * s; + + // 悬空胶囊:碰撞椭球底部永久离脚 rideHeight + const rideHeightScaled = this.rideHeight * s; // 当前缩放下的实际悬空高度 + const colliderHeight = h - rideHeightScaled; // 缩短后胶囊总高 + + const segmentLength = colliderHeight - 2 * r; + + // 创建胶囊体网格(原点对齐 segment 上端;depthTest 与其它调试线框一致) + this.playerCapsule = new THREE.Mesh( + new THREE.CapsuleGeometry(r, Math.max(segmentLength, 1e-6), 4, 8), + new THREE.MeshBasicMaterial({ + color: 0xc45cff, + wireframe: true, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }), + ); + this.playerCapsule.geometry.translate(0, -segmentLength / 2, 0); + this.playerCapsule.capsuleInfo = { + radius: r, + segment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -segmentLength, 0)), + // 动态体用接到脚底的完整胶囊,避免球从悬空间隙钻过 + dynamicsSegment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -segmentLength - rideHeightScaled, 0)), + }; + this.recomputeGroundThresholds(); + this.playerCapsule.name = "capsule"; + (this.playerCapsule.material as THREE.Material).visible = false; + this.scene.add(this.playerCapsule); + this.reset(); + this.playerCapsule.rotateY(this.playerModelConfig.rotateY ?? 0); + + // 挂载模型到胶囊 + this.playerModel.scale.multiplyScalar(modelScale * s); + this.modelBaseY = -segmentLength - r - rideHeightScaled; + this.playerModel.position.set(0, this.modelBaseY, 0); + this.playerModel.traverse((child: any) => { + if (child.name === this.playerModelConfig?.headBoneName) this.playerModelHead = child; + }); + this.playerCapsule.add(this.playerModel); + this.reset(); + } catch (e) { + console.error("加载玩家模型失败:", e); + } + } + + /** 切换玩家模型。 */ + async switchPlayerModel(newPlayerModel: PlayerModelOptions) { + // 保存当前状态 + const savedPos = this.playerCapsule.position.clone(); + const savedQuat = this.playerCapsule.quaternion.clone(); + const wasFirstPerson = this.isFirstPerson; + + if (wasFirstPerson) this.scene.attach(this.camera); + if (this.playerCapsule) this.scene.remove(this.playerCapsule); + if (this.playerModel) { this.playerCapsule.remove(this.playerModel); this.playerModel = null; this.playerModelHead = null; } + + // 清除旧动画资源 + const anim = this.animation; + if (anim.mixer) { + if (anim.mixerCb) { anim.mixer.removeEventListener("finished", anim.mixerCb); anim.mixerCb = undefined; } + anim.mixer.stopAllAction(); + anim.mixer.uncacheRoot(anim.mixer.getRoot()); + anim.mixer = undefined; + anim.actions = undefined; + } + + // 更新比例相关参数 + const ratio = newPlayerModel.scale / this.playerModelConfig.scale; + this.playerModelConfig = { + ...this.playerModelConfig, + url: undefined, + model: undefined, + animations: undefined, + ...newPlayerModel, + } as PlayerModelOptions; + + this.applyGameplayScaleRatio(ratio); + + await this.loadPlayerModel(); + this.playerCapsule.position.copy(savedPos); + this.playerCapsule.quaternion.copy(savedQuat); + if (wasFirstPerson) this.cam.setFirstPerson(); + this.syncDebugVisibility(); + for (const plugin of this.plugins) plugin.onPlayerModelChange?.(); + } + + // ==================== 插件 ==================== + + /** 注册后处理插件。 */ + use(plugin: PlayerPlugin): this { + if (this.plugins.includes(plugin)) return this; + if (plugin.name) { + const existing = this.plugins.find(p => p.name === plugin.name); + if (existing) this.unuse(existing); + } + this.plugins.push(plugin); + plugin.onAttach?.(this); + return this; + } + + /** 卸载已注册的插件。 */ + unuse(plugin: PlayerPlugin): this { + const index = this.plugins.indexOf(plugin); + if (index < 0) return this; + this.plugins.splice(index, 1); + plugin.onDetach?.(); + return this; + } + + /** 获取当前插件列表的只读副本。 */ + getPlugins(): PlayerPlugin[] { + return this.plugins.slice(); + } + + // ==================== 碰撞体构建和查询 ==================== + + /** @internal BVH worker。 */ + getBvhWorkerPool() { return this.bvhWorkerPool; } + /** @internal 静态 / 运动学 mesh 线框开关。 */ + getDisplayCollider() { return this.colliders.getDebugVisible(); } + /** @internal 动态刚体线框开关。 */ + getDisplayDynamicBody() { return this.dynamics.getDebugVisible(); } + + /** 统一创建碰撞体。 */ + addCollider(desc: ColliderDesc): ColliderHandle { + return this.colliders.add(desc); + } + + /** 按句柄移除碰撞体。 */ + removeCollider(handle: ColliderHandle | number): void { + this.colliders.remove(handle); + } + + /** 运行时等比缩放已烘焙的运动学 mesh 碰撞(不重建 BVH)。 */ + scaleKinematicColliderContent(source: THREE.Object3D, ratio: number): void { + this.colliders.scaleKinematicContent(source, ratio); + } + + /** 运行时平移已烘焙的运动学 mesh 碰撞(本地空间,不重建 BVH)。 */ + translateKinematicColliderContent(source: THREE.Object3D, localOffset: THREE.Vector3): void { + this.colliders.translateKinematicContent(source, localOffset); + } + + /** 清除碰撞体;可按 motion 过滤。 */ + clearColliders(filter?: { motion?: MotionType }): void { + this.colliders.clear(filter); + } + + /** 人物胶囊 / 相机查询用的静态与运动学网格。 */ + getColliderMeshes(options?: { skipIds?: number[] }): THREE.Mesh[] { + return this.collisionWorld.queryMeshes({ mask: CHARACTER_QUERY_MASK }, options); + } + + /** 车辆轮射线 / 车身接触使用的网格。跳过全部车辆外观,车车改走底盘盒。 */ + getVehicleGroundMeshes(v: VehicleInstance): THREE.Mesh[] { + const chassis = v.chassisColliderId != null ? this.collisionWorld.get(v.chassisColliderId) : null; + const skipIds = this.vehicle.meshSkipIds; + skipIds.length = 0; + for (const other of this.vehicle.list) { + if (other.meshColliderId != null) skipIds.push(other.meshColliderId); + } + if (!chassis) return []; + return this.collisionWorld.queryMeshes(chassis, { skipIds }); + } + + /** 静态碰撞体是否已有至少一份就绪的 mesh。 */ + isStaticColliderUsable() { + return this.colliders.isStaticUsable(); + } + + /** 运动学碰撞体是否已就绪。 */ + isKinematicColliderUsable(entry: KinematicColliderEntry) { + return this.colliders.isKinematicUsable(entry); + } + + /** 更新运动学碰撞体网格,并计算本帧位移增量。 */ + private updateKinematicColliders() { + this.colliders.updateKinematicFollow(); + } + + /** 本帧碰撞用完后再提交平台矩阵,供车辆按 prev→current 带走。 */ + private commitKinematicPrevMatrices() { + this.colliders.commitKinematicPrev(); + } + + // ==================== 主循环 ==================== + + /** 主循环。 */ + async update(delta?: number) { + if (delta === undefined) { + const now = performance.now(); + delta = (now - _lastUpdateTime) / 1000; + _lastUpdateTime = now; + } + if (!this.isupdate || !this.playerCapsule) return; + delta = Math.min(delta, 1 / 40) * this.timeScale; + this.currentDelta = delta; + this.updateKinematicColliders(); + if (this.controllerMode === 1) { + this.vehicle.preparePhysics(delta); + this.vehicle.finishPhysics(delta); + if (!this.isFirstPerson) this.cam.updateThirdPersonVehicle(delta); + this.runAnimationPass(delta); + } else { + this.updatePlayer(delta); + // 停着的车也每帧步进,避免球砸/叠车冲量攒着、视觉冻住 + this.vehicle.finishPhysics(delta); + } + this.dynamics.step(delta); + this.character.applyDynamicSupportCarry(); + this.commitKinematicPrevMatrices(); + } + + /** 玩家帧更新。 */ + updatePlayer(delta: number) { + this.character.update(delta); + } + + /** 在插件的恢复与应用钩子之间更新动画混合器。 */ + runAnimationPass(delta: number): void { + for (const plugin of this.plugins) plugin.onBeforeAnimationUpdate?.(delta); + this.animation.updateMixers(delta); + for (const plugin of this.plugins) plugin.onAfterAnimationUpdate?.(delta); + } + + // ==================== 内部辅助 ==================== + + /** 同步全部 debug 可见性。 */ + syncDebugVisibility() { + this.playerDebug.syncVisibility(); + this.colliders.syncDebugVisibility(); + this.dynamics.syncDebugVisibility(); + this.vehicle.syncDebugVisibility(); + } + + /** 设置落地状态。 */ + setOnGround(val: boolean) { + if (!val) this.clearGroundSupport(); // 离地时立即作废上一帧支撑。 + if (this.playerIsOnGround === val) return; + this.playerIsOnGround = val; + this.onGroundChange?.(val); + if (val) this.animation.onLand(); + else this.animation.onBecomeAirborne(); + } + + /** 应用重力。 */ + applyGravity(delta: number) { + this.playerVelocity.y += delta * this.gravity; + this.setOnGround(false); + } + + /** 判断脚下地面是否为水平台面(法线接近竖直)。 */ + isFlatFloor(hit: THREE.Intersection): boolean { + const n = hit.face?.normal; + if (!n) return true; + return n.y >= this.minFloorNormalY; // 大于等于最小法线 Y 分量时为水平台面 + } + + /** 吸附到地面。 */ + snapToGround(groundY: number, smooth = false, delta = 0) { + this.playerVelocity.y = 0; + const dy = groundY - this.playerCapsule.position.y; + if (smooth && Math.abs(dy) <= this.rideHeight * this.playerModelConfig.scale) { + // 平滑吸附 + this.playerCapsule.position.y += dy * Math.min(1, this.stepSmoothFactor * delta); + } else { + // 瞬时吸附 + this.playerCapsule.position.y = groundY; + } + this.setOnGround(true); + } + + /** 按比例同步重力、速度、相机距离等玩法参数。 */ + private applyGameplayScaleRatio(ratio: number) { + this.gravity *= ratio; + this.jumpHeight *= ratio; + this.playerSpeed *= ratio; + this.playerRunSpeed *= ratio; + this.playerFlySpeed *= ratio; + this.curPlayerSpeed *= ratio; + this.decelBase *= ratio; + this.cam.epsilon *= ratio; + this.cam.minDist *= ratio; + this.cam.maxDist *= ratio; + this.cam.originMaxDist *= ratio; + this.controls.minDistance = this.cam.minDist; + } + + /** 重算站立 / 落地阈值(snapH / maxH)。仅在胶囊创建、缩放后调用。 */ + private recomputeGroundThresholds() { + const info = this.playerCapsule?.capsuleInfo; + if (!info) return; + const sy = this.playerCapsule.scale.y || 1; + const rideHeightScaled = this.rideHeight * this.playerModelConfig.scale; + this.snapH = (-info.segment.end.y) * sy + info.radius + rideHeightScaled; + this.maxH = this.snapH + rideHeightScaled; + } + + /** 动态修改玩家缩放。 */ + setPlayerScale(newScale: number) { + if (newScale <= 0) return; + const ratio = newScale / this.playerModelConfig.scale; + this.playerModelConfig.scale = newScale; + + // 更新比例相关参数 + this.applyGameplayScaleRatio(ratio); + + if (this.isFirstPerson) this.scene.attach(this.camera); + this.playerCapsule?.scale.multiplyScalar(ratio); + if (this.playerCapsule?.capsuleInfo) { + this.playerCapsule.capsuleInfo.radius *= ratio; + this.recomputeGroundThresholds(); + } + if (this.isFirstPerson) this.cam.setFirstPerson(); + } + + /** 动态修改单辆车的缩放(绝对 scale,与加载时 opts.scale 同一语义)。 */ + setVehicleScale(vehicle: VehicleInstance, newScale: number) { + this.vehicle.setScale(vehicle, newScale); + } + + /** 动态修改单辆车的底盘离地间隙(scale=1 基准值)。 */ + setVehicleClearance(vehicle: VehicleInstance, clearance: number) { + this.vehicle.setClearance(vehicle, clearance); + } + + /** 动态修改单辆车的底盘碰撞盒三轴尺寸比例。 */ + setVehicleChassisSizeScale( + vehicle: VehicleInstance, + sizeScale: { x?: number; y?: number; z?: number }, + ) { + this.vehicle.setChassisSizeScale(vehicle, sizeScale); + } + + /** 将全部已加载车辆缩放到同一绝对 scale。 */ + setAllVehiclesScale(newScale: number) { + this.vehicle.setScaleAll(newScale); + } + + /** 重置玩家位置。 */ + reset(position?: THREE.Vector3) { + if (!this.playerCapsule) return; + if (this.controllerMode === 1) { + this.vehicle.stopActive(); + this.controllerMode = 0; + this.mobileControls?.syncControllerModeBtn(0); + this.scene.attach(this.playerCapsule); + this.animation.playByName("idle"); + this.syncDebugVisibility(); + } + this.playerVelocity.set(0, 0, 0); + this.activeDynamicBody = null; + this.clearGroundSupport(); + this.playerCapsule.position.copy(position ?? this.initPos); + } + + /** 站立时胶囊原点应离地的高度。 */ + getCapsuleGroundHeight() { return this.snapH; } + + /** 运动学碰撞体列表(供车辆下车检测使用)。 */ + getKinematicColliderEntries() { return this.colliders.kinematicColliders; } + + /** 将人物模型挂载到车辆座位点。 */ + syncMountedPlayer(vehicle: VehicleInstance) { + const cap = this.playerCapsule; + if (!cap) return; + this.clearGroundSupport(); + if (cap.parent !== vehicle.vehicleGroup) vehicle.vehicleGroup.attach(cap); + cap.position.copy(vehicle.driverSeatPosition).multiplyScalar(vehicle.scale); + cap.quaternion.setFromAxisAngle(this.upVector, vehicle.driverSeatRotation); + } + + /** 在车辆下车位置恢复人物控制。 */ + leaveVehicleAt(position: THREE.Vector3, forward: THREE.Vector3) { + const cap = this.playerCapsule; + if (!cap) return; + this.scene.attach(cap); + cap.position.copy(position); + const lookTarget = position.clone().add(forward); + this.targetMat.lookAt(position, lookTarget, this.upVector); + cap.quaternion.setFromRotationMatrix(this.targetMat); + this.playerVelocity.set(0, 0, 0); + this.setOnGround(false); + if (this.isFirstPerson) this.cam.setFirstPerson(); + } + + // ==================== API ==================== + + /** 获取当前位置。 */ + getPosition() { return this.playerCapsule?.position; } + /** 获取速度。 */ + getVelocity() { return this.playerVelocity.clone(); } + /** 获取第一人称状态。 */ + getIsFirstPerson() { return this.isFirstPerson; } + /** 获取飞行状态。 */ + getIsFlying() { return this.isFlying; } + /** 获取落地状态。 */ + getIsOnGround() { return this.playerIsOnGround; } + /** 获取移动系统本帧最终采用的地面支撑。 */ + getGroundSupport() { return this.hasGroundSupport ? this.groundSupportHit : null; } + /** 获取本帧实际使用的 delta(已钳制 + timeScale)。 */ + getCurrentDelta() { return this.currentDelta; } + /** 获取控制器模式。 */ + getControllerMode() { return this.controllerMode; } + /** 获取玩家模型。 */ + getPlayerModel() { return this.playerModel; } + /** 获取胶囊体。 */ + getPlayerCapsule() { return this.playerCapsule; } + /** 获取当前载具。 */ + getActiveVehicle() { return this.vehicle.active; } + /** 获取所有载具。 */ + getAllVehicles() { return this.vehicle.list; } + /** 获取当前站立的运动学碰撞体。 */ + getActiveKinematicCollider() { return this.activeKinematicCollider; } + /** 获取当前站立的动态刚体。 */ + getActiveDynamicBody() { return this.activeDynamicBody; } + + /** 写入移动系统最终采用的地面点和世界空间法线。 */ + setGroundSupport(point: THREE.Vector3, normal: THREE.Vector3) { + this.groundSupportHit.point.copy(point); + this.groundSupportHit.normal.copy(normal).normalize(); + this.hasGroundSupport = true; + } + + /** 清除已经失效的地面支撑。 */ + clearGroundSupport() { this.hasGroundSupport = false; } + + /** 移除动态刚体。 */ + removeDynamicBody(body: DynamicBody) { this.dynamics.remove(body); } + /** 从指定世界坐标向下查询动态刚体表面。 */ + raycastDynamicGround( + origin: THREE.Vector3, + minNormalY?: number, + excludeKinds?: readonly DynamicBodyKind[], + ) { + return this.dynamics.raycastGround(origin, minNormalY, excludeKinds); + } + /** 全部动态刚体。 */ + getDynamicBodies(): DynamicBody[] { return this.dynamics.list; } + /** 清除全部动态刚体。 */ + clearDynamicBodies() { this.dynamics.clear(); } + + /** 设置鼠标灵敏度。 */ + setMouseSensitivity(value: number) { + this.cam.sensitivity = value; + this.controls.rotateSpeed = value * 0.05; + } + + // --- 玩家参数 --- + /** 设置重力。 */ + setGravity(gravity: number) { this.gravity = gravity * this.playerModelConfig.scale; } + /** 设置跳跃高度。 */ + setJumpHeight(jumpHeight: number) { this.jumpHeight = jumpHeight * this.playerModelConfig.scale; } + /** 设置行走速度。 */ + setPlayerSpeed(speed: number) { this.playerSpeed = speed * this.playerModelConfig.scale; this.curPlayerSpeed = this.playerSpeed; } + /** 设置跑步速度。 */ + setPlayerRunSpeed(runSpeed: number) { this.playerRunSpeed = runSpeed * this.playerModelConfig.scale; } + /** 设置飞行速度。 */ + setPlayerFlySpeed(flySpeed: number) { this.playerFlySpeed = flySpeed * this.playerModelConfig.scale; } + /** 设置朝向开关。 */ + setEnableToward(v: boolean) { this.enableToward = v; } + + // --- 相机参数 --- + /** 设置相机最近距。 */ + setMinCamDistance(dist: number) { + this.cam.minDist = dist * this.playerModelConfig.scale; + this.cam.originMaxDist = Math.max(this.cam.originMaxDist, this.cam.minDist); + this.cam.maxDist = Math.max(this.cam.maxDist, this.cam.minDist); + this.controls.minDistance = this.cam.minDist; + } + /** 设置相机最远距。 */ + setMaxCamDistance(dist: number) { + this.cam.originMaxDist = Math.max(this.cam.minDist, dist * this.playerModelConfig.scale); + this.cam.maxDist = this.cam.originMaxDist; + this.cam.clearFlySprintMaxDistBoost(); + } + /** 设置相机看向点高度比例。 */ + setCamLookAtHeightRatio(ratio: number) { this.cam.lookAtHeightRatio = ratio; } + /** 设置相机过肩视角横向偏移比例。 */ + setCamOverShoulderOffsetRatio(ratio: number) { this.cam.overShoulderOffsetRatio = ratio; this.cam.setOverShoulder(this.enableOverShoulderView && !this.isFirstPerson); } + + /** 设置鼠标模式。 */ + setThirdMouseMode(mode: 0 | 1 | 2 | 3 | 4 | 5) { this.cam.mouseMode = mode; this.cam.setPointerLock(); } + /** 设置缩放开关。 */ + setEnableZoom(enable: boolean) { this.cam.setZoomEnabled(enable); } + + // --- 调试 --- + /** 设置静态碰撞线框。 */ + setColliderDebug(debug: boolean) { + this.colliders.setDebugVisible(debug); + } + /** 设置玩家胶囊线框。 */ + setPlayerCapsuleDebug(debug: boolean) { + this.playerDebug.setVisible(debug); + } + /** 设置动态刚体碰撞线框。 */ + setDynamicBodyDebug(debug: boolean) { + this.dynamics.setDebugVisible(debug); + } + /** 设置车辆底盘物理盒。 */ + setVehiclePhysicsDebug(debug: boolean) { + this.vehicle.setPhysicsDebugVisible(debug); + } + /** 临时跳过玩家胶囊碰撞检测。 */ + setSkipCapsuleCollision(skip: boolean) { this.skipCapsuleCollision = skip; } + + // --- 动画 --- + /** 按名播放动画。 */ + playPlayerAnimationByName(name: string, fade?: number) { this.animation.playByName(name, fade); } + /** 注册自定义动画。 */ + registerAnimation(key: string, clipName: string, opts?: Parameters[2]) { this.animation.register(key, clipName, opts); } + /** 播放已注册动画。 */ + playAnimation(key: string, opts?: Parameters[1]) { this.animation.play(key, opts); } + /** 注册移动动作组。 */ + registerLocomotionSet(setName: string, map: Parameters[1]) { this.animation.registerLocomotionSet(setName, map); } + /** 切换移动动作组。 */ + switchLocomotionSet(setName: string, fade?: number) { this.animation.switchLocomotionSet(setName, fade); } + /** 获取当前动画名。 */ + getCurrentPlayerAnimationName() { return this.animation.getCurrentName(); } + /** 获取当前移动动作组名。 */ + getCurrentLocomotionSet() { return this.animation.currentLocomotionSet; } + + // --- 相机 --- + /** 切换视角模式。 */ + changeView() { this.cam.changeView(); } + /** 设置第一人称。 */ + setFirstPersonCamera(v = 0) { this.cam.setFirstPerson(v); } + /** 设置越肩视角。 */ + setOverShoulderView(v: boolean) { this.cam.setOverShoulder(v); this.enableOverShoulderView = v; } + /** 屏幕中心检测。 */ + getCenterScreenRaycastHit() { return this.cam.getCenterHit(); } + + // --- 输入 --- + /** 设置输入状态。 */ + setInput(input: Parameters[0]) { this.input.setInput(input); } + /** 运行时自定义键位。 */ + setKeyMap(map?: KeyMap) { this.input.buildKeyMap(map); } + /** 绑定输入事件。 */ + onAllEvent() { this.input.bindEvents(); } + /** 解绑输入事件。 */ + offAllEvent() { this.input.unbindEvents(); } + + // --- 载具 --- + /** 加载车辆模型。 */ + loadVehicleModel(opts: VehicleOptions) { return this.vehicle.load(opts); } + /** 将当前驾驶车辆翻正复位。 */ + resetVehicle() { this.vehicle.resetUpright(); } + + // --- 销毁 --- + /** 销毁控制器并释放资源。 */ + destroy() { + this.input.unbindEvents(); + + // 卸载插件 + for (const plugin of this.plugins.slice()) { + this.unuse(plugin); + plugin.dispose?.(); + } + this.plugins = []; + + this.playerDebug.dispose(); + + // 清除玩家对象 + if (this.playerCapsule) { this.playerCapsule.remove(this.camera); this.scene.remove(this.playerCapsule); } + (this.playerCapsule as any) = null; + if (this.playerModel) { this.scene.remove(this.playerModel); this.playerModel = null; } + + // 清除碰撞体和相机 + this.cam.resetControls(); + this.clearColliders(); + this.mobileControls?.destroy(); + this.mobileControls = null; + this.bvhWorkerPool.dispose(); + + // 清除所有车辆 + this.vehicle.destroy(); + this.dynamics.clear(); + this.collisionWorld.clear(); + } +} diff --git a/three-player-controller/src/collision/CollisionWorld.ts b/three-player-controller/src/collision/CollisionWorld.ts new file mode 100644 index 0000000..abae5ec --- /dev/null +++ b/three-player-controller/src/collision/CollisionWorld.ts @@ -0,0 +1,182 @@ +import type * as THREE from "three"; +import { CollisionGroup } from "./groups"; + +/** 碰撞体运动类型。 */ +export type MotionType = "static" | "kinematic" | "dynamic"; + +/** + * 碰撞形状。 + * mesh 挂 BVH 供射线 / shapecast;box / sphere 用于动态刚体登记与解析碰撞。 + */ +export type CollisionShape = + | { kind: "mesh"; mesh: THREE.Mesh } + | { kind: "box"; halfExtents: THREE.Vector3 } + | { kind: "sphere"; radius: number }; + +/** 世界中已登记的一条碰撞体。 */ +export type CollisionCollider = { + id: number; // 稳定标识 + motion: MotionType; // 运动类型 + shape: CollisionShape; // 形状 + groups: number; // 自己属于哪些业务组 + mask: number; // 自己要撞哪些组 + ready: boolean; // BVH / 资源是否可参与查询 + userData?: unknown; // 业务侧挂载数据 +}; + +/** 底层登记参数。未传的 groups / mask 按 motion 给默认值。 */ +export type AddColliderOptions = { + motion: MotionType; + shape: CollisionShape; + groups?: number; + mask?: number; + ready?: boolean; + userData?: unknown; +}; + +/** + * 查询过滤。 + * 传入完整 collider 时双方 groups/mask 都要匹配;只传 `{ mask }` 时单向过滤。 + */ +export type MeshQueryFilter = CollisionCollider | { mask: number }; + +/** query / queryMeshes 的可选过滤。 */ +export type MeshQueryOptions = { + skipIds?: number[]; // 额外排除的 id + kinds?: CollisionShape["kind"][]; // 只保留这些形状 + motion?: MotionType | MotionType[]; // 只保留这些运动类型 +}; + +/** 按运动类型给出默认业务分组与掩码。 */ +function defaultBits(motion: MotionType): { groups: number; mask: number } { + switch (motion) { + case "static": + return { + groups: CollisionGroup.DEFAULT, + mask: CollisionGroup.DEBRIS | CollisionGroup.VEHICLE, + }; + case "kinematic": + return { + groups: CollisionGroup.DEFAULT, + mask: CollisionGroup.DEBRIS | CollisionGroup.VEHICLE | CollisionGroup.CHARACTER, + }; + case "dynamic": + return { + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS, + }; + } +} + +/** 是否为完整 collider(相对仅含 mask 的单向查询)。 */ +function isCollider(filter: MeshQueryFilter): filter is CollisionCollider { + return "id" in filter && "motion" in filter && "groups" in filter; +} + +/** + * 碰撞体注册表。 + * 人物仍用胶囊推出,车辆仍用射线 / OBB;这里只统一「谁登记了、谁和谁该测」。 + */ +export class CollisionWorld { + private nextId = 1; // 递增 id + private readonly colliders = new Map(); // 已登记碰撞体 + + /** 登记一条碰撞体,返回带 id 的条目。 */ + add(options: AddColliderOptions): CollisionCollider { + const bits = defaultBits(options.motion); + const entry: CollisionCollider = { + id: this.nextId++, + motion: options.motion, + shape: options.shape, + groups: options.groups ?? bits.groups, + mask: options.mask ?? bits.mask, + ready: options.ready ?? true, + userData: options.userData, + }; + this.colliders.set(entry.id, entry); + return entry; + } + + /** 按 id 取碰撞体,不存在则返回 undefined。 */ + get(id: number): CollisionCollider | undefined { + return this.colliders.get(id); + } + + /** 注销碰撞体。 */ + remove(id: number): void { + this.colliders.delete(id); + } + + /** 标记 BVH / 资源是否可参与查询。 */ + setReady(id: number, ready: boolean): void { + const entry = this.colliders.get(id); + if (entry) entry.ready = ready; + } + + /** 覆盖碰撞掩码。 */ + setMask(id: number, mask: number): void { + const entry = this.colliders.get(id); + if (entry) entry.mask = mask; + } + + /** 覆盖业务分组。 */ + setGroups(id: number, groups: number): void { + const entry = this.colliders.get(id); + if (entry) entry.groups = groups; + } + + /** 覆盖 userData。 */ + setUserData(id: number, userData: unknown): void { + const entry = this.colliders.get(id); + if (entry) entry.userData = userData; + } + + /** + * 查询可参与检测的碰撞体。 + * 完整 collider:双向 groups/mask;仅 mask:单向。自动跳过未 ready 与自身 id。 + */ + query(filter: MeshQueryFilter, options?: MeshQueryOptions): CollisionCollider[] { + const skip = new Set(options?.skipIds); + const self = isCollider(filter) ? filter : null; + if (self) skip.add(self.id); + const mask = filter.mask; + const kinds = options?.kinds; + const motions = options?.motion + ? (Array.isArray(options.motion) ? options.motion : [options.motion]) + : null; + const out: CollisionCollider[] = []; + for (const col of this.colliders.values()) { + if (!col.ready || skip.has(col.id)) continue; + if (motions && !motions.includes(col.motion)) continue; + if (kinds && !kinds.includes(col.shape.kind)) continue; + if ((col.groups & mask) === 0) continue; + if (self && (self.groups & col.mask) === 0) continue; + out.push(col); + } + return out; + } + + /** 查询可做 BVH 射线 / shapecast 的网格。 */ + queryMeshes(filter: MeshQueryFilter, options?: MeshQueryOptions): THREE.Mesh[] { + const meshes: THREE.Mesh[] = []; + for (const col of this.query(filter, options)) { + if (col.shape.kind === "mesh") meshes.push(col.shape.mesh); + } + return meshes; + } + + /** 按运动类型列出 id(供 clearColliders)。 */ + idsByMotion(motion?: MotionType): number[] { + const out: number[] = []; + for (const col of this.colliders.values()) { + if (motion && col.motion !== motion) continue; + out.push(col.id); + } + return out; + } + + /** 清空全部登记。 */ + clear(): void { + this.colliders.clear(); + } +} diff --git a/three-player-controller/src/collision/DynamicBody.ts b/three-player-controller/src/collision/DynamicBody.ts new file mode 100644 index 0000000..6dfea0a --- /dev/null +++ b/three-player-controller/src/collision/DynamicBody.ts @@ -0,0 +1,187 @@ +import * as THREE from "three"; +import { integrateQuaternion } from "../utils/vehiclePhysics/vehicleMath"; + +/** 冲量唤醒:质心线速度增量 ‖Δv‖ = ‖J‖·invMass 超过该值则醒(世界单位/秒)。 */ +const WAKE_DELTA_V = 1e-4; +const WAKE_DELTA_V_SQ = WAKE_DELTA_V * WAKE_DELTA_V; + +/** 动态刚体形状判别。 */ +export type DynamicBodyKind = "sphere" | "box"; + +/** 动态刚体材质 / 初态可选字段(各形状 options 共用)。 */ +export type DynamicBodyMaterialOptions = { + /** 密度,质量 = 体积 × 密度;默认见 DYNAMIC_BODY_DEFAULTS。 */ + density?: number; + /** 弹性,0 不反弹,1 完全弹;默认见 DYNAMIC_BODY_DEFAULTS。 */ + restitution?: number; + /** 摩擦;默认见 DYNAMIC_BODY_DEFAULTS。 */ + friction?: number; + /** 线阻尼,越大滑行越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + linearDamping?: number; + /** 角阻尼,越大旋转越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + angularDamping?: number; + /** 重力,默认沿用控制器重力。 */ + gravity?: number; + /** 初速度。 */ + velocity?: THREE.Vector3; + /** 初角速度。 */ + angularVelocity?: THREE.Vector3; + /** 视觉网格。 */ + mesh?: THREE.Mesh; +}; + +/** 子类构造时传入的已解析材质与视觉(缺省已在上层填好)。 */ +export type DynamicBodyBaseInit = { + density: number; // 密度 + mass: number; // 质量(体积 × 密度) + restitution: number; // 弹性 + gravity: number; // 重力加速度 + position: THREE.Vector3; // 世界位置 + velocity: THREE.Vector3; // 线速度 + angularVelocity?: THREE.Vector3; // 角速度 + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + friction: number; // 摩擦 + mesh: THREE.Mesh; // 视觉网格 + debugMesh: THREE.Mesh; // 碰撞线框 +}; + +/** + * 动态刚体基类(ImpulseBody + 材质)。 + * 形状相关惯量 / 特征尺寸由子类提供;窄相算法按 kind 分派。 + */ +export abstract class DynamicBody { + abstract readonly kind: DynamicBodyKind; // 形状判别 + + density: number; // 密度 + /** 由 density × 体积得到,供冲量求解使用。 */ + mass: number; + restitution: number; // 弹性 [0, 1] + gravity: number; // 重力加速度(通常为负) + position = new THREE.Vector3(); // 世界位置 + quaternion = new THREE.Quaternion(); // 世界朝向 + /** 线速度(保留 velocity 命名以兼容现有调用)。 */ + velocity = new THREE.Vector3(); + angularVelocity = new THREE.Vector3(); // 角速度 + invMass = 0; // 质量倒数 + invInertia = new THREE.Vector3(); // 局部惯量倒数对角元 + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + friction: number; // 碰撞摩擦 + mesh: THREE.Mesh; // 视觉网格 + /** 碰撞形状线框,由 setDynamicBodyDebug 显隐。 */ + debugMesh: THREE.Mesh; + contactMesh: THREE.Mesh | null = null; // 本帧接触的网格(平台携带用) + colliderId = 0; // CollisionWorld 登记 id + /** 休眠中则跳过积分与窄相。 */ + sleeping = false; + /** 是否允许进入休眠。 */ + canSleep = true; + /** 连续低于休眠阈值的累计时间(秒)。 */ + sleepTimer = 0; + + private _r = new THREE.Vector3(); + private _torque = new THREE.Vector3(); + private _local = new THREE.Vector3(); + private _world = new THREE.Vector3(); + private _invQuat = new THREE.Quaternion(); + + /** 写入材质、位姿与视觉引用,并计算 invMass。 */ + protected constructor(opts: DynamicBodyBaseInit) { + this.density = Math.max(1e-8, opts.density); + this.mass = Math.max(1e-6, opts.mass); + this.restitution = Math.max(0, Math.min(1, opts.restitution)); + this.gravity = opts.gravity; + this.position.copy(opts.position); + this.velocity.copy(opts.velocity); + if (opts.angularVelocity) this.angularVelocity.copy(opts.angularVelocity); + this.linearDamping = opts.linearDamping; + this.angularDamping = opts.angularDamping; + this.friction = opts.friction; + this.mesh = opts.mesh; + this.debugMesh = opts.debugMesh; + this.invMass = 1 / this.mass; + } + + /** 用于速度钳制 / 携带阈值的特征半尺寸。 */ + abstract characteristicExtent(): number; + + /** 唤醒刚体。 */ + wakeUp(): void { + if (!this.sleeping) return; + this.sleeping = false; + this.sleepTimer = 0; + } + + /** 进入休眠并清零速度。 */ + putToSleep(): void { + if (!this.canSleep) return; + this.sleeping = true; + this.sleepTimer = 0; + this.velocity.set(0, 0, 0); + this.angularVelocity.set(0, 0, 0); + this.contactMesh = null; + } + + /** 线速度 / 角速度是否低于给定阈值。 */ + isRestingCandidate(linearThreshold: number, angularThreshold: number): boolean { + return ( + this.velocity.lengthSq() <= linearThreshold * linearThreshold + && this.angularVelocity.lengthSq() <= angularThreshold * angularThreshold + ); + } + + /** 读取世界点处的刚体速度:v + ω × r。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + this._r.subVectors(point, this.position); + return out.copy(this.velocity).add(this._world.copy(this.angularVelocity).cross(this._r)); + } + + /** + * 休眠唤醒:速度增量 ‖J‖·invMass 超过阈值则醒。 + */ + private wakeIfImpulseMeaningful(impulse: THREE.Vector3): void { + if (!this.sleeping) return; + const inv = this.invMass; + if (impulse.lengthSq() * inv * inv > WAKE_DELTA_V_SQ) this.wakeUp(); + } + + /** 在质心施加冲量。 */ + applyImpulse(impulse: THREE.Vector3): void { + this.wakeIfImpulseMeaningful(impulse); + this.velocity.addScaledVector(impulse, this.invMass); + } + + /** 在世界点施加冲量,同时产生角速度。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, contactPoint: THREE.Vector3): void { + this.wakeIfImpulseMeaningful(impulse); + this.velocity.addScaledVector(impulse, this.invMass); + this._r.subVectors(contactPoint, this.position); + this.angularVelocity.add(this.angularDeltaFromImpulse(impulse, this._r)); + } + + /** 将冲量转为世界角速度增量:ω += R I⁻¹ Rᵀ (r × J)。 */ + private angularDeltaFromImpulse(impulse: THREE.Vector3, r: THREE.Vector3): THREE.Vector3 { + this._torque.copy(r).cross(impulse); + this._invQuat.copy(this.quaternion).invert(); + this._local.copy(this._torque).applyQuaternion(this._invQuat); + this._local.x *= this.invInertia.x; + this._local.y *= this.invInertia.y; + this._local.z *= this.invInertia.z; + return this._world.copy(this._local).applyQuaternion(this.quaternion); + } + + /** 指数线 / 角阻尼。 */ + applyDamping(dt: number): void { + const lin = Math.exp(-this.linearDamping * dt); + const ang = Math.exp(-this.angularDamping * dt); + this.velocity.multiplyScalar(lin); + this.angularVelocity.multiplyScalar(ang); + } + + /** 用当前速度积分位置和朝向。 */ + integrate(dt: number): void { + this.position.addScaledVector(this.velocity, dt); + integrateQuaternion(this.quaternion, this.angularVelocity, dt); + } +} diff --git a/three-player-controller/src/collision/DynamicBox.ts b/three-player-controller/src/collision/DynamicBox.ts new file mode 100644 index 0000000..44ed968 --- /dev/null +++ b/three-player-controller/src/collision/DynamicBox.ts @@ -0,0 +1,171 @@ +import * as THREE from "three"; +import { DynamicBody } from "./DynamicBody"; +import type { CollisionTemps } from "../utils/capsuleCollision"; + +/** + * 动态盒刚体。 + * 惯量按实心长方体;与网格的接触冲量走 ContactImpulseSolver,盒–盒走 resolveObbObb。 + */ +export class DynamicBoxBody extends DynamicBody { + readonly kind = "box" as const; // 形状判别 + halfExtents = new THREE.Vector3(); // 碰撞盒半边长 + + /** 按体积 × 密度算质量,并写入长方体惯量。 */ + constructor(opts: { + halfExtents: THREE.Vector3; + density: number; + restitution: number; + gravity: number; + position: THREE.Vector3; + quaternion?: THREE.Quaternion; + velocity: THREE.Vector3; + angularVelocity?: THREE.Vector3; + linearDamping: number; + angularDamping: number; + friction: number; + mesh: THREE.Mesh; + debugMesh: THREE.Mesh; + }) { + const hx = Math.max(1e-4, opts.halfExtents.x); + const hy = Math.max(1e-4, opts.halfExtents.y); + const hz = Math.max(1e-4, opts.halfExtents.z); + const volume = 8 * hx * hy * hz; + super({ + density: opts.density, + mass: volume * Math.max(1e-8, opts.density), + restitution: opts.restitution, + gravity: opts.gravity, + position: opts.position, + velocity: opts.velocity, + angularVelocity: opts.angularVelocity, + linearDamping: opts.linearDamping, + angularDamping: opts.angularDamping, + friction: opts.friction, + mesh: opts.mesh, + debugMesh: opts.debugMesh, + }); + this.halfExtents.set(hx, hy, hz); + if (opts.quaternion) this.quaternion.copy(opts.quaternion); + this.setCuboidInertia(); + } + + /** 特征半尺寸取三边最大值。 */ + characteristicExtent(): number { + return Math.max(this.halfExtents.x, this.halfExtents.y, this.halfExtents.z); + } + + /** 实心长方体惯量 I = m/12 (h²+d²) 等,并写 invInertia。 */ + setCuboidInertia(): void { + const m = this.mass; + const w = this.halfExtents.x * 2; + const h = this.halfExtents.y * 2; + const d = this.halfExtents.z * 2; + const ix = Math.max(1e-12, m / 12 * (h * h + d * d)); + const iy = Math.max(1e-12, m / 12 * (w * w + d * d)); + const iz = Math.max(1e-12, m / 12 * (w * w + h * h)); + this.invInertia.set(1 / ix, 1 / iy, 1 / iz); + } +} + +/** 类型收窄:动态盒。 */ +export function isBoxBody(body: DynamicBody): body is DynamicBoxBody { + return body.kind === "box"; +} + +const CAPSULE_OBB_SAMPLES = 8; // 胶囊轴采样点数(含端点) + +const _invQuat = new THREE.Quaternion(); +const _local = new THREE.Vector3(); +const _sample = new THREE.Vector3(); +const _bestOnSeg = new THREE.Vector3(); +const _bestDirLocal = new THREE.Vector3(); +const _candDir = new THREE.Vector3(); + +/** + * 胶囊 vs OBB:沿胶囊段采样,取到 AABB 的最近距离。 + * 命中时 temps.closestTri 为**胶囊指向刚体**的单位法线(与 capsuleSphereOverlap 一致), + * temps.closestSeg 为段上点,返回穿透深度。 + */ +export function capsuleObbOverlap( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + boxPos: THREE.Vector3, + boxQuat: THREE.Quaternion, + half: THREE.Vector3, + temps: CollisionTemps, +): number { + const segment = capsuleInfo.dynamicsSegment ?? capsuleInfo.segment; + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld); + + _invQuat.copy(boxQuat).invert(); + const hx = half.x; + const hy = half.y; + const hz = half.z; + let bestDist = Infinity; + let insideAxis = -1; // 0/1/2 表示在盒内时选的推出轴 + let insideSign = 1; + let insideDepth = 0; + + for (let i = 0; i <= CAPSULE_OBB_SAMPLES; i++) { + const t = i / CAPSULE_OBB_SAMPLES; + _sample.lerpVectors(temps.localSeg.start, temps.localSeg.end, t); + _local.copy(_sample).sub(boxPos).applyQuaternion(_invQuat); + + const inside = + Math.abs(_local.x) <= hx && + Math.abs(_local.y) <= hy && + Math.abs(_local.z) <= hz; + + if (inside) { + const dx = hx - Math.abs(_local.x); + const dy = hy - Math.abs(_local.y); + const dz = hz - Math.abs(_local.z); + const depth = capsuleInfo.radius + Math.min(dx, dy, dz); + if (depth > insideDepth) { + insideDepth = depth; + _bestOnSeg.copy(_sample); + // 取穿透最小的面,法线先取盒心→外侧(稍后翻成胶囊→盒) + if (dx <= dy && dx <= dz) { + insideAxis = 0; + insideSign = Math.sign(_local.x || 1); + } else if (dy <= dz) { + insideAxis = 1; + insideSign = Math.sign(_local.y || 1); + } else { + insideAxis = 2; + insideSign = Math.sign(_local.z || 1); + } + } + continue; + } + + const cx = Math.min(hx, Math.max(-hx, _local.x)); + const cy = Math.min(hy, Math.max(-hy, _local.y)); + const cz = Math.min(hz, Math.max(-hz, _local.z)); + // 盒表面 → 采样点(盒→胶囊);返回前取反 + _candDir.set(_local.x - cx, _local.y - cy, _local.z - cz); + const dist = _candDir.length(); + if (dist >= bestDist) continue; + bestDist = dist; + _bestOnSeg.copy(_sample); + if (dist > 1e-8) _bestDirLocal.copy(_candDir).multiplyScalar(1 / dist); + else _bestDirLocal.set(0, 1, 0); + } + + if (insideDepth > 0) { + temps.closestSeg.copy(_bestOnSeg); + if (insideAxis === 0) temps.closestTri.set(insideSign, 0, 0); + else if (insideAxis === 1) temps.closestTri.set(0, insideSign, 0); + else temps.closestTri.set(0, 0, insideSign); + // 盒外法线 → 翻成胶囊指向刚体(进入盒的方向) + temps.closestTri.applyQuaternion(boxQuat).negate(); + return insideDepth; + } + + if (bestDist >= capsuleInfo.radius) return 0; + temps.closestSeg.copy(_bestOnSeg); + // 盒→胶囊 取反 → 胶囊→盒 + temps.closestTri.copy(_bestDirLocal).applyQuaternion(boxQuat).negate(); + return capsuleInfo.radius - bestDist; +} diff --git a/three-player-controller/src/collision/DynamicSphere.ts b/three-player-controller/src/collision/DynamicSphere.ts new file mode 100644 index 0000000..c20ce79 --- /dev/null +++ b/three-player-controller/src/collision/DynamicSphere.ts @@ -0,0 +1,247 @@ +import * as THREE from "three"; +import { contactSkinForExtent, type RawContact } from "./contacts/ContactPoint"; +import { DynamicBody } from "./DynamicBody"; + +/** + * 动态球刚体。 + * 球惯量 I = ⅖ m r²;与网格的接触冲量走 ContactImpulseSolver。 + */ +export class DynamicSphereBody extends DynamicBody { + readonly kind = "sphere" as const; // 形状判别 + radius: number; // 碰撞半径 + + /** 按体积 × 密度算质量,并写入球惯量。 */ + constructor(opts: { + radius: number; + density: number; + restitution: number; + gravity: number; + position: THREE.Vector3; + velocity: THREE.Vector3; + angularVelocity?: THREE.Vector3; + linearDamping: number; + angularDamping: number; + friction: number; + mesh: THREE.Mesh; + debugMesh: THREE.Mesh; + }) { + const volume = (4 / 3) * Math.PI * opts.radius * opts.radius * opts.radius; + super({ + density: opts.density, + mass: volume * Math.max(1e-8, opts.density), + restitution: opts.restitution, + gravity: opts.gravity, + position: opts.position, + velocity: opts.velocity, + angularVelocity: opts.angularVelocity, + linearDamping: opts.linearDamping, + angularDamping: opts.angularDamping, + friction: opts.friction, + mesh: opts.mesh, + debugMesh: opts.debugMesh, + }); + this.radius = opts.radius; + this.setSphereInertia(); + } + + /** 特征半尺寸即半径。 */ + characteristicExtent(): number { + return this.radius; + } + + /** 实心球惯量 I = ⅖ m r²(对角),并写 invInertia。 */ + setSphereInertia(): void { + // 小尺度物体 I 可低于旧地板 1e-8;置 0 会导致无法滚动、地面摩擦过粘 + const i = Math.max(1e-12, 0.4 * this.mass * this.radius * this.radius); + const inv = 1 / i; + this.invInertia.set(inv, inv, inv); + } +} + +/** 类型收窄:动态球。 */ +export function isSphereBody(body: DynamicBody): body is DynamicSphereBody { + return body.kind === "sphere"; +} + +/** 球窄相预分配临时量(避免每帧 new)。 */ +export type SphereCollisionTemps = { + invMat: THREE.Matrix4; // mesh.matrixWorld 的逆 + normalMat: THREE.Matrix3; // 法线变换矩阵 + invQuat: THREE.Quaternion; // 盒朝向的逆 + localSphere: THREE.Sphere; // 网格本地空间中的查询球 + closest: THREE.Vector3; // 三角面 / 盒上最近点(本地) + worldClosest: THREE.Vector3; // 最近点世界坐标 + offset: THREE.Vector3; // 球心到最近点的偏移 + before: THREE.Vector3; // 推出前位置备份 + normal: THREE.Vector3; // 接触法线(由对面指向球) + local: THREE.Vector3; // 盒局部坐标 + contact: THREE.Vector3; // 世界接触点 + boxVel: THREE.Vector3; // 盒上接触点速度 + impulse: THREE.Vector3; // 冲量缓冲 +}; + +/** 创建球碰撞临时量。 */ +export function createSphereCollisionTemps(): SphereCollisionTemps { + return { + invMat: new THREE.Matrix4(), + normalMat: new THREE.Matrix3(), + invQuat: new THREE.Quaternion(), + localSphere: new THREE.Sphere(), + closest: new THREE.Vector3(), + worldClosest: new THREE.Vector3(), + offset: new THREE.Vector3(), + before: new THREE.Vector3(), + normal: new THREE.Vector3(), + local: new THREE.Vector3(), + contact: new THREE.Vector3(), + boxVel: new THREE.Vector3(), + impulse: new THREE.Vector3(), + }; +} + +/** + * 收集球 vs 单个 BVH 网格的原始接触(不移动球心)。 + * 法线由表面指向球心;penetration = radius - 距离;返回写入后的 rawCount。 + */ +export function collectSphereVsMeshContacts( + position: THREE.Vector3, + radius: number, + mesh: THREE.Mesh, + temps: SphereCollisionTemps, + raw: RawContact[], + rawCount: number, + maxRaw: number, +): number { + const tree = (mesh.geometry as any)?.boundsTree; + if (!tree || rawCount >= maxRaw) return rawCount; + + // 球变到网格本地,均匀缩放用 scale.x 近似 + temps.invMat.copy(mesh.matrixWorld).invert(); + temps.localSphere.center.copy(position).applyMatrix4(temps.invMat); + const scale = mesh.scale.x || 1; + temps.localSphere.radius = radius / scale; + + temps.normalMat.getNormalMatrix(mesh.matrixWorld); + + tree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsSphere(temps.localSphere), + intersectsTriangle: (tri: any) => { + if (rawCount >= maxRaw) return true; // 触达上限则提前结束遍历 + tri.closestPointToPoint(temps.localSphere.center, temps.closest); + temps.offset.subVectors(temps.localSphere.center, temps.closest); + const dist = temps.offset.length(); + const penetration = temps.localSphere.radius - dist; + // 允许轻微皮肤重叠(按球半径缩放,本地空间与 mesh.scale 对齐) + const localSkin = contactSkinForExtent(radius) / Math.max(scale, 1e-8); + if (penetration < -localSkin) return; + + if (dist > 1e-8) temps.normal.copy(temps.offset).multiplyScalar(1 / dist); + else temps.normal.set(0, 1, 0); // 球心几乎落在面上时给默认上法线 + temps.normal.applyMatrix3(temps.normalMat).normalize(); + + temps.worldClosest.copy(temps.closest).applyMatrix4(mesh.matrixWorld); + const slot = raw[rawCount++]; + slot.point.copy(temps.worldClosest); + slot.normal.copy(temps.normal); + slot.penetration = penetration * scale; // 穿透深度还原到世界尺度 + }, + boundsTraverseOrder: (box: THREE.Box3) => box.distanceToPoint(temps.localSphere.center) - temps.localSphere.radius, + }); + + return rawCount; +} + +/** + * 球 vs OBB:重叠则推出球并写 temps.normal / contact。 + * 法线由盒指向球;会就地修改 position。 + */ +export function collideSphereVsObb( + position: THREE.Vector3, + radius: number, + boxPos: THREE.Vector3, + boxQuat: THREE.Quaternion, + half: THREE.Vector3, + temps: SphereCollisionTemps, +): boolean { + // 球心变到盒局部 + temps.invQuat.copy(boxQuat).invert(); + temps.local.copy(position).sub(boxPos).applyQuaternion(temps.invQuat); + + const hx = half.x; + const hy = half.y; + const hz = half.z; + const inside = + Math.abs(temps.local.x) <= hx && + Math.abs(temps.local.y) <= hy && + Math.abs(temps.local.z) <= hz; + + if (inside) { + // 球心已在盒内:沿穿透最小的轴推出到表面外 radius + const dx = hx - Math.abs(temps.local.x); + const dy = hy - Math.abs(temps.local.y); + const dz = hz - Math.abs(temps.local.z); + if (dx <= dy && dx <= dz) { + temps.local.x = Math.sign(temps.local.x || 1) * (hx + radius); + temps.normal.set(Math.sign(temps.local.x), 0, 0); + } else if (dy <= dz) { + temps.local.y = Math.sign(temps.local.y || 1) * (hy + radius); + temps.normal.set(0, Math.sign(temps.local.y), 0); + } else { + temps.local.z = Math.sign(temps.local.z || 1) * (hz + radius); + temps.normal.set(0, 0, Math.sign(temps.local.z)); + } + temps.normal.applyQuaternion(boxQuat); + position.copy(temps.local).applyQuaternion(boxQuat).add(boxPos); + temps.contact.copy(position).addScaledVector(temps.normal, -radius); + return true; + } + + // 盒外:钳到盒表面最近点,若距球心 < radius 则推出 + temps.closest.set( + Math.min(hx, Math.max(-hx, temps.local.x)), + Math.min(hy, Math.max(-hy, temps.local.y)), + Math.min(hz, Math.max(-hz, temps.local.z)), + ); + temps.offset.subVectors(temps.local, temps.closest); + const dist = temps.offset.length(); + if (dist >= radius || dist < 1e-8) return false; + temps.offset.multiplyScalar(1 / dist); + temps.local.addScaledVector(temps.offset, radius - dist); + temps.normal.copy(temps.offset).applyQuaternion(boxQuat); + position.copy(temps.local).applyQuaternion(boxQuat).add(boxPos); + temps.contact.copy(temps.closest).applyQuaternion(boxQuat).add(boxPos); + return true; +} + +/** + * 两球按质量推出并交换法向速度。 + * 暂不走双刚体 ContactImpulseSolver(无摩擦 / 无角冲量)。 + */ +export function resolveSphereSphere( + a: DynamicSphereBody, + b: DynamicSphereBody, + temps: SphereCollisionTemps, + restitution: number, +): void { + temps.offset.subVectors(a.position, b.position); + const dist = temps.offset.length(); + const minDist = a.radius + b.radius; + if (dist >= minDist) return; + if (dist > 1e-8) temps.normal.copy(temps.offset).multiplyScalar(1 / dist); + else temps.normal.set(0, 1, 0); // 完全重合时给默认法线 + + // 位置分离(按 invMass 分配) + const depth = minDist - dist; + const invA = a.invMass; + const invB = b.invMass; + const invSum = invA + invB; + a.position.addScaledVector(temps.normal, depth * (invA / invSum)); + b.position.addScaledVector(temps.normal, -depth * (invB / invSum)); + + // 仅处理接近中的法向相对速度 + const rel = a.velocity.dot(temps.normal) - b.velocity.dot(temps.normal); + if (rel >= 0) return; + const impulse = -(1 + restitution) * rel / invSum; + a.velocity.addScaledVector(temps.normal, impulse * invA); + b.velocity.addScaledVector(temps.normal, -impulse * invB); +} diff --git a/three-player-controller/src/collision/colliderBuild.ts b/three-player-controller/src/collision/colliderBuild.ts new file mode 100644 index 0000000..e1d3e77 --- /dev/null +++ b/three-player-controller/src/collision/colliderBuild.ts @@ -0,0 +1,242 @@ +import * as THREE from "three"; +import { MeshBVH, acceleratedRaycast } from "three-mesh-bvh"; +import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import type { BvhWorkerPool } from "../utils/BvhWorkerPool"; + +/** + * 碰撞网格构建工具。 + */ + +/** 调试辅助物及其子级不应被烘焙进 mesh collider。 */ +function isExcludedFromCollider(object: THREE.Object3D): boolean { + let current: THREE.Object3D | null = object; + while (current) { + if (current.userData.excludeFromCollider === true) return true; + current = current.parent; + } + return false; +} + +/** 校验并补全合并所需属性(position / normal / uv);含 NaN 或顶点数不足时返回 null。 */ +export function ensureAttributesMinimal(geom: THREE.BufferGeometry): THREE.BufferGeometry | null { + if (!geom.attributes.position) return null; + const position = geom.attributes.position.array; + for (let i = 0; i < position.length; i++) { + if (!Number.isFinite(position[i])) return null; + } + if (geom.attributes.position.count < 3) return null; + if (!geom.attributes.normal) geom.computeVertexNormals(); + if (!geom.attributes.uv) { + const count = geom.attributes.position.count; + geom.setAttribute("uv", new THREE.BufferAttribute(new Float32Array(count * 2), 2)); + } + return geom; +} + +/** + * 统一一组几何的属性布局,便于 BufferGeometryUtils.mergeGeometries。 + * 去掉非必需属性;冲突项删除;缺失项用零填充。 + */ +export function unifiedAttribute(collected: THREE.BufferGeometry[]): THREE.BufferGeometry[] { + type AttrMeta = { itemSize: number; arrayCtor: any; examples: number; normalized: boolean }; + const attrMap = new Map(); + const attrConflict = new Set(); + const required = new Set(["position", "normal", "uv"]); + + for (const g of collected) + for (const name of Object.keys(g.attributes)) + if (!required.has(name)) g.deleteAttribute(name); + + for (const g of collected) { + for (const name of Object.keys(g.attributes)) { + const attr = g.attributes[name] as THREE.BufferAttribute; + const ctor = (attr.array as any).constructor; + if (!attrMap.has(name)) { + attrMap.set(name, { itemSize: attr.itemSize, arrayCtor: ctor, examples: 1, normalized: attr.normalized }); + } else { + const m = attrMap.get(name)!; + if (m.itemSize !== attr.itemSize || m.arrayCtor !== ctor || m.normalized !== attr.normalized) attrConflict.add(name); + else m.examples++; + } + } + } + + for (const name of attrConflict) { + for (const g of collected) if (g.attributes[name]) g.deleteAttribute(name); + attrMap.delete(name); + } + + for (const [name, meta] of attrMap) { + for (const g of collected) { + if (!g.attributes[name]) { + const count = g.attributes.position.count; + g.setAttribute(name, new THREE.BufferAttribute(new meta.arrayCtor(count * meta.itemSize), meta.itemSize, meta.normalized)); + } + } + } + return collected; +} + +/** 遍历 sources,把 Mesh / LineSegments 克隆到世界空间并收集(跳过 name === "capsule")。 */ +function collectWorldSpaceGeometries(sources: THREE.Object3D[]): THREE.BufferGeometry[] { + const collected: THREE.BufferGeometry[] = []; + const collectMesh = (mesh: THREE.Mesh | THREE.LineSegments) => { + try { + let geom = mesh.geometry.clone(); + geom.applyMatrix4(mesh.matrixWorld); + if (geom.index) geom = geom.toNonIndexed(); + const safe = ensureAttributesMinimal(geom); + if (safe) collected.push(safe); + } catch (e) { + console.warn("处理网格时出错:", mesh, e); + } + }; + for (const obj of sources) { + obj.updateMatrixWorld(true); + obj.traverse(c => { + const a = c as any; + if ( + (a.isMesh || a.isLineSegments) + && a.geometry + && c.name !== "capsule" + && !isExcludedFromCollider(c) + ) collectMesh(a); + }); + } + return collected; +} + +const _rtcTranslation = new THREE.Matrix4(); +const _rtcCenter = new THREE.Vector3(); + +/** + * 把合并后的几何收到包围盒中心附近,避免大坐标留在 Float32 顶点里。 + * 返回平移到原点的中心,调用方需把它编进 bakeInverse。 + */ +function recenterGeometry(geometry: THREE.BufferGeometry, target: THREE.Vector3): THREE.Vector3 { + geometry.computeBoundingBox(); + const box = geometry.boundingBox; + if (!box || box.isEmpty()) { + target.set(0, 0, 0); + return target; + } + box.getCenter(target); + if (target.lengthSq() === 0) return target; + geometry.translate(-target.x, -target.y, -target.z); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return target; +} + +/** + * 用合并几何创建线框调试 Mesh,并挂上 acceleratedRaycast。 + * localSpace 时关闭 matrixAutoUpdate,由外部每帧对齐 follow。 + */ +function makeDebugMesh(geometry: THREE.BufferGeometry, localSpace: boolean): THREE.Mesh { + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ + opacity: 0.5, + transparent: true, + wireframe: true, + depthTest: true, + side: THREE.DoubleSide, + }), + ); + mesh.raycast = acceleratedRaycast; + if (localSpace) { + mesh.matrixAutoUpdate = false; + } else { + mesh.layers.enable(1); + } + return mesh; +} + +/** build*MergedMesh 的返回值:调试用 mesh + 待挂 BVH 的 geometry。 */ +export type BuiltMeshCollider = { + mesh: THREE.Mesh; + geometry: THREE.BufferGeometry; + /** 运动学烘焙:inv(source.matrixWorld) * T(rtcCenter)。*/ + bakeInverse?: THREE.Matrix4; +}; + +/** + * 合并 source 为世界空间静态碰撞网格。 + * 仅合并几何,不挂 BVH;调用方需再走 attachBoundsTree,否则不能参与碰撞查询。 + */ +export function buildStaticMergedMesh(sources: THREE.Object3D | THREE.Object3D[]): BuiltMeshCollider | null { + const list = Array.isArray(sources) ? sources : [sources]; + let collected = collectWorldSpaceGeometries(list); + if (!collected.length) return null; + collected = unifiedAttribute(collected); + const merged = BufferGeometryUtils.mergeGeometries(collected, false); + if (!merged) { + console.error("合并几何失败"); + return null; + } + return { mesh: makeDebugMesh(merged, false), geometry: merged }; +} + +/** + * 合并 source 为运动学碰撞网格。 + * 先乘 matrixWorld 落到当前世界 / 局部 ENU,再绕包围盒中心收成小坐标; + * 调用方需再走 attachBoundsTree,并每帧用 + * source.matrixWorld * bakeInverse 跟随。 + */ +export function buildKinematicMergedMesh(source: THREE.Object3D): BuiltMeshCollider | null { + source.updateMatrixWorld(true); + let collected = collectWorldSpaceGeometries([source]); + if (!collected.length) return null; + collected = unifiedAttribute(collected); + const merged = BufferGeometryUtils.mergeGeometries(collected, false); + if (!merged) { + console.error("合并运动学几何失败"); + return null; + } + const rtcCenter = recenterGeometry(merged, _rtcCenter); + const bakeInverse = new THREE.Matrix4().copy(source.matrixWorld).invert(); + bakeInverse.multiply(_rtcTranslation.makeTranslation(rtcCenter.x, rtcCenter.y, rtcCenter.z)); + const mesh = makeDebugMesh(merged, true); + mesh.matrix.copy(source.matrixWorld).multiply(bakeInverse); + mesh.updateMatrixWorld(true); + return { mesh, geometry: merged, bakeInverse }; +} + +/** + * 为几何挂上 MeshBVH(写入 geometry.boundsTree)。 + * useWorker + workerPool 时异步构建,失败回退主线程;onStale 为真则丢弃过期结果。 + */ +export function attachBoundsTree( + geometry: THREE.BufferGeometry, + options: { + useWorker?: boolean; + workerPool?: BvhWorkerPool; + maxDepth?: number; + onReady: (bvh: MeshBVH) => void; + onStale?: () => boolean; + }, +): void { + const maxDepth = options.maxDepth; + const sync = () => { + const bvh = maxDepth != null + ? new MeshBVH(geometry, { maxDepth }) + : new MeshBVH(geometry); + (geometry as any).boundsTree = bvh; + options.onReady(bvh); + }; + + if (!options.useWorker || !options.workerPool) { + sync(); + return; + } + + void options.workerPool.generate(geometry).then((bvh) => { + if (options.onStale?.()) return; + (geometry as any).boundsTree = bvh; + options.onReady(bvh); + }).catch((error) => { + if (options.onStale?.()) return; + console.warn("异步构建 BVH 失败,回退到主线程:", error); + sync(); + }); +} diff --git a/three-player-controller/src/collision/colliderDesc.ts b/three-player-controller/src/collision/colliderDesc.ts new file mode 100644 index 0000000..18fc413 --- /dev/null +++ b/three-player-controller/src/collision/colliderDesc.ts @@ -0,0 +1,111 @@ +import type * as THREE from "three"; +import type { MotionType } from "./CollisionWorld"; + +/** 从场景节点采集并合并的三角网格。 */ +export type MeshSourceShape = { + kind: "mesh"; + /** 采集几何的来源;必传。 */ + source: THREE.Object3D | THREE.Object3D[]; + mesh?: never; +}; + +/** 已有(可含 BVH)的碰撞网格。 */ +export type MeshReadyShape = { + kind: "mesh"; + mesh: THREE.Mesh; + source?: never; +}; + +export type MeshShape = MeshSourceShape | MeshReadyShape; + +export type BoxShape = { + kind: "box"; + halfExtents: THREE.Vector3; + /** 世界位置,或相对 follow 的局部偏移。 */ + position?: THREE.Vector3; + quaternion?: THREE.Quaternion; +}; + +export type SphereShape = { + kind: "sphere"; + radius: number; + /** 世界位置,或相对 follow 的局部偏移。 */ + position?: THREE.Vector3; +}; + +export type ColliderShape = MeshShape | BoxShape | SphereShape; + +type ColliderDescBase = { + groups?: number; + mask?: number; + useWorker?: boolean; + userData?: unknown; +}; + +export type StaticColliderDesc = ColliderDescBase & { + motion: "static"; + shape: ColliderShape; + follow?: never; +}; + +export type KinematicColliderDesc = ColliderDescBase & { + motion: "kinematic"; + shape: ColliderShape; + /** 每帧碰撞体对齐该节点的 matrixWorld;mesh+source 时默认跟 source。 */ + follow?: THREE.Object3D; +}; + +type DynamicColliderMaterialFields = { + /** 密度,质量 = 体积 × 密度;默认见 DYNAMIC_BODY_DEFAULTS。 */ + density?: number; + /** 弹性,0 不反弹,1 完全弹;默认见 DYNAMIC_BODY_DEFAULTS。 */ + restitution?: number; + /** 摩擦;默认见 DYNAMIC_BODY_DEFAULTS。 */ + friction?: number; + /** 线阻尼,越大滑行越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + linearDamping?: number; + /** 角阻尼,越大旋转越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + angularDamping?: number; + gravity?: number; + velocity?: THREE.Vector3; + angularVelocity?: THREE.Vector3; +}; + +/** 动态碰撞体描述。 */ +export type DynamicColliderDesc = ColliderDescBase & DynamicColliderMaterialFields & { + motion: "dynamic"; + shape: ColliderShape; + follow?: never; + /** 可选视觉网格。 */ + mesh?: THREE.Mesh; + /** + * 是否由 DynamicBodySystem 创建并推进刚体。 + * - 默认 true(省略时):球 / 可推箱子等会创建 DynamicBody + * - false:只登记 CollisionWorld,不创建 DynamicBody(车辆底盘由 VehicleRigidBody 推进) + */ + simulate?: boolean; +}; + +/** 动态刚体材质默认值。 */ +export const DYNAMIC_BODY_DEFAULTS = { + density: 1, + restitution: 0.2, + friction: 0.6, + linearDamping: 0.4, + angularDamping: 0.6, +}; + +/** 碰撞体创建描述。 */ +export type ColliderDesc = StaticColliderDesc | KinematicColliderDesc | DynamicColliderDesc; + +/** addCollider 返回的句柄。 */ +export type ColliderHandle = { + id: number; + motion: MotionType; + /** 运动学跟随目标(若有)。 */ + follow: THREE.Object3D | null; + /** mesh 采集源(若有)。 */ + source: THREE.Object3D | null; + /** 用于 BVH 查询的网格(static/kinematic mesh)。 */ + collisionMesh: THREE.Mesh | null; +}; diff --git a/three-player-controller/src/collision/contacts/ContactCache.ts b/three-player-controller/src/collision/contacts/ContactCache.ts new file mode 100644 index 0000000..19f1ca8 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactCache.ts @@ -0,0 +1,85 @@ +import * as THREE from "three"; +import { ContactManifold } from "./ContactManifold"; +import { ContactPoint } from "./ContactPoint"; +import { normalClass } from "./contactReducer"; + +const MATCH_NORMAL = 0.95; // 法线点积低于此值视为不同流形 +const MATCH_DISTANCE = 0.08; // 局部接触点匹配距离上限 + +/** findMatch 的命中结果。 */ +type MatchResult = { + point: ContactPoint; // 上一帧匹配到的接触点 + normal: THREE.Vector3; // 上一帧流形法线 +}; + +/** + * 帧间接触点匹配,用于冲量热启动(warm start)。 + * 求解前 match 继承冲量;求解后 save 供下一帧使用。 + */ +export class ContactCache { + private prev: ContactManifold[] = []; // 上一帧流形快照 + + /** + * 将上一帧冲量拷到本帧对应接触点。 + * 无匹配、或支撑面 ↔ 墙面切换时清零,避免错误热启动。 + */ + match(manifolds: ContactManifold[]): void { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + const prev = this.findMatch(manifold.normal, point); + if (!prev) { + point.resetImpulses(); + continue; + } + // 法线类别突变(地面 ↔ 墙)时冲量不可复用 + const prevClass = normalClass(prev.normal); + const nextClass = normalClass(manifold.normal); + if ( + (prevClass === "support" && nextClass === "wall") || + (prevClass === "wall" && nextClass === "support") + ) { + point.resetImpulses(); + continue; + } + point.normalImpulse = prev.point.normalImpulse; + point.tangentImpulse1 = prev.point.tangentImpulse1; + point.tangentImpulse2 = prev.point.tangentImpulse2; + } + } + } + + /** 深拷贝本帧流形与接触冲量,供下一帧 match。 */ + save(manifolds: ContactManifold[]): void { + this.prev = manifolds.map((src) => { + const copy = new ContactManifold(); + copy.normal.copy(src.normal); + copy.normalSum.copy(src.normalSum); + copy.contacts = src.contacts.map((p) => { + const c = new ContactPoint(); + c.copyFrom(p); + return c; + }); + return copy; + }); + } + + /** + * 在上一帧中找法线接近且局部点最近的接触。 + * 用刚体局部坐标匹配,避免物体移动后面世界点对不上。 + */ + private findMatch(normal: THREE.Vector3, point: ContactPoint): MatchResult | null { + let best: MatchResult | null = null; + let bestDist = MATCH_DISTANCE * MATCH_DISTANCE; + for (const manifold of this.prev) { + if (manifold.normal.dot(normal) < MATCH_NORMAL) continue; + for (const prev of manifold.contacts) { + const d = prev.localPoint.distanceToSquared(point.localPoint); + if (d < bestDist) { + bestDist = d; + best = { point: prev, normal: manifold.normal }; + } + } + } + return best; + } +} diff --git a/three-player-controller/src/collision/contacts/ContactManifold.ts b/three-player-controller/src/collision/contacts/ContactManifold.ts new file mode 100644 index 0000000..53250e0 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactManifold.ts @@ -0,0 +1,15 @@ +import * as THREE from "three"; +import { ContactPoint } from "./ContactPoint"; + +/** 同一法线簇下的接触点集合。 */ +export class ContactManifold { + normal = new THREE.Vector3(); + normalSum = new THREE.Vector3(); + contacts: ContactPoint[] = []; + + clear(): void { + this.contacts.length = 0; + this.normal.set(0, 1, 0); + this.normalSum.set(0, 0, 0); + } +} diff --git a/three-player-controller/src/collision/contacts/ContactPoint.ts b/three-player-controller/src/collision/contacts/ContactPoint.ts new file mode 100644 index 0000000..89a0097 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactPoint.ts @@ -0,0 +1,48 @@ +import * as THREE from "three"; + +/** 标定用特征半尺寸;skin/slop 按 extent 相对此值缩放。 */ +export const CONTACT_REF_EXTENT = 0.2; + +export const CONTACT_SKIN = 0.002; + +/** 按刚体特征尺寸缩放接触皮肤,避免小物体相对厚度过大。 */ +export function contactSkinForExtent(extent: number): number { + const s = Math.max(1e-4, extent) / CONTACT_REF_EXTENT; + return CONTACT_SKIN * s; +} + +export type RawContact = { + point: THREE.Vector3; + normal: THREE.Vector3; + penetration: number; +}; + +/** 约束求解用的单个接触点。 */ +export class ContactPoint { + worldPoint = new THREE.Vector3(); + localPoint = new THREE.Vector3(); + penetration = 0; + biasVelocity = 0; + normalImpulse = 0; + tangentImpulse1 = 0; + tangentImpulse2 = 0; + tangent1 = new THREE.Vector3(); + tangent2 = new THREE.Vector3(); + + copyFrom(other: ContactPoint): void { + this.worldPoint.copy(other.worldPoint); + this.localPoint.copy(other.localPoint); + this.penetration = other.penetration; + this.normalImpulse = other.normalImpulse; + this.tangentImpulse1 = other.tangentImpulse1; + this.tangentImpulse2 = other.tangentImpulse2; + this.tangent1.copy(other.tangent1); + this.tangent2.copy(other.tangent2); + } + + resetImpulses(): void { + this.normalImpulse = 0; + this.tangentImpulse1 = 0; + this.tangentImpulse2 = 0; + } +} diff --git a/three-player-controller/src/collision/contacts/contactReducer.ts b/three-player-controller/src/collision/contacts/contactReducer.ts new file mode 100644 index 0000000..cc58ad8 --- /dev/null +++ b/three-player-controller/src/collision/contacts/contactReducer.ts @@ -0,0 +1,201 @@ +import * as THREE from "three"; +import type { ImpulseBody } from "../solver/ImpulseBody"; +import { ContactManifold } from "./ContactManifold"; +import { ContactPoint, CONTACT_REF_EXTENT, contactSkinForExtent, type RawContact } from "./ContactPoint"; + +const NORMAL_MERGE = 0.98; // 法线点积高于此值并入同一流形 +const MAX_POINTS = 4; // 单流形最多保留的接触点 +const SUPPORT_Y = 0.7; // ny 以上视为支撑面(地面) +const WALL_Y = 0.35; // ny 以下视为墙面 + +const _invQuat = new THREE.Quaternion(); +const _ab = new THREE.Vector3(); +const _ac = new THREE.Vector3(); +const _ap = new THREE.Vector3(); +const _n = new THREE.Vector3(); + +/** + * 将原始接触按法线聚类,并裁成至多 4 个支撑点。 + * 返回写入后的 out(长度收束为有效流形数)。 + */ +export function reduceContacts( + raw: RawContact[], + count: number, + body: ImpulseBody, + out: ContactManifold[], +): ContactManifold[] { + for (const m of out) m.clear(); + let manifoldCount = 0; + + // 按法线合并进流形,并累计 normalSum + const skin = contactSkinForExtent(body.characteristicExtent?.() ?? CONTACT_REF_EXTENT); + for (let r = 0; r < count; r++) { + const contact = raw[r]; + if (contact.penetration < -skin || contact.normal.lengthSq() < 1e-8) continue; + _n.copy(contact.normal).normalize(); + let cluster = -1; + for (let i = 0; i < manifoldCount; i++) { + if (out[i].normal.dot(_n) > NORMAL_MERGE) { + cluster = i; + break; + } + } + if (cluster < 0) { + if (manifoldCount === out.length) { + out.push(new ContactManifold()); + } + cluster = manifoldCount; + manifoldCount += 1; + out[cluster].normalSum.copy(_n); + out[cluster].normal.copy(_n); + } else { + out[cluster].normalSum.add(_n); + out[cluster].normal.copy(out[cluster].normalSum).normalize(); + } + out[cluster].contacts.push(makePoint(contact, body)); + } + + // 定稿法线、裁点、建切向基 + for (let i = 0; i < manifoldCount; i++) { + const manifold = out[i]; + if (manifold.normalSum.lengthSq() < 1e-8) manifold.normal.set(0, 1, 0); + else manifold.normal.copy(manifold.normalSum).normalize(); + if (manifold.contacts.length > MAX_POINTS) { + manifold.contacts = pickSupportPoints(manifold.contacts); + } + for (const point of manifold.contacts) { + buildTangents(manifold.normal, point); + } + } + + out.length = manifoldCount; + stabilizeEdgeManifolds(out); + return out; +} + +/** 按法线竖直分量分类:支撑面 / 墙 / 斜面。 */ +export function normalClass(normal: THREE.Vector3): "support" | "wall" | "slant" { + if (normal.y >= SUPPORT_Y) return "support"; + if (normal.y <= WALL_Y) return "wall"; + return "slant"; +} + +/** + * 边角同时碰到地面与墙时丢掉斜面流形,避免错误推力。 + * 若没有支撑面,则把斜面法线抬到至少 SUPPORT_Y,减轻卡边抖动。 + */ +function stabilizeEdgeManifolds(manifolds: ContactManifold[]): void { + let hasSupport = false; + let hasWall = false; + for (const m of manifolds) { + const cls = normalClass(m.normal); + if (cls === "support") hasSupport = true; + else if (cls === "wall") hasWall = true; + } + + if (hasSupport && hasWall) { + let write = 0; + for (let i = 0; i < manifolds.length; i++) { + if (normalClass(manifolds[i].normal) === "slant") continue; + if (write !== i) manifolds[write] = manifolds[i]; + write += 1; + } + manifolds.length = write; + return; + } + + if (!hasSupport) { + for (const m of manifolds) { + if (normalClass(m.normal) !== "slant") continue; + m.normal.y = Math.max(m.normal.y, SUPPORT_Y); + m.normal.normalize(); + m.normalSum.copy(m.normal); + for (const point of m.contacts) { + buildTangents(m.normal, point); + } + } + } +} + +/** 由原始接触生成 ContactPoint,并写入刚体局部坐标(供帧间匹配)。 */ +function makePoint(raw: RawContact, body: ImpulseBody): ContactPoint { + const point = new ContactPoint(); + point.worldPoint.copy(raw.point); + point.penetration = raw.penetration; + _invQuat.copy(body.quaternion).invert(); + point.localPoint.subVectors(raw.point, body.position).applyQuaternion(_invQuat); + return point; +} + +/** + * 从过多接触点中挑最多 4 个:最深穿透 → 最远点 → 最大面积第三点 → 离平面最远第四点。 + * 用于稳定盒/车身 resting 接触。 + */ +function pickSupportPoints(points: ContactPoint[]): ContactPoint[] { + if (points.length <= MAX_POINTS) return points; + + // p0:穿透最深 + let i0 = 0; + for (let i = 1; i < points.length; i++) { + if (points[i].penetration > points[i0].penetration) i0 = i; + } + const p0 = points[i0]; + + // p1:离 p0 最远 + let i1 = i0; + let best = -1; + for (let i = 0; i < points.length; i++) { + const d = points[i].worldPoint.distanceToSquared(p0.worldPoint); + if (d > best) { + best = d; + i1 = i; + } + } + const p1 = points[i1]; + + // p2:与 p0p1 构成面积最大 + let i2 = i0; + best = -1; + _ab.subVectors(p1.worldPoint, p0.worldPoint); + for (let i = 0; i < points.length; i++) { + _ap.subVectors(points[i].worldPoint, p0.worldPoint); + const area = _ap.cross(_ab).lengthSq(); + if (area > best) { + best = area; + i2 = i; + } + } + const p2 = points[i2]; + + // p3:离 p0p1p2 平面最远(略加权穿透) + let i3 = i0; + best = -1; + _ab.subVectors(p1.worldPoint, p0.worldPoint); + _ac.subVectors(p2.worldPoint, p0.worldPoint); + _n.copy(_ab).cross(_ac); + for (let i = 0; i < points.length; i++) { + if (i === i0 || i === i1 || i === i2) continue; + _ap.subVectors(points[i].worldPoint, p0.worldPoint); + const d = Math.abs(_ap.dot(_n)); + const score = d + points[i].penetration * 0.01; + if (score > best) { + best = score; + i3 = i; + } + } + + const picked = [p0]; + if (i1 !== i0) picked.push(p1); + if (i2 !== i0 && i2 !== i1) picked.push(p2); + if (i3 !== i0 && i3 !== i1 && i3 !== i2 && picked.length < MAX_POINTS) picked.push(points[i3]); + return picked; +} + +/** 由流形法线构造正交切向基(摩擦两轴)。 */ +function buildTangents(normal: THREE.Vector3, point: ContactPoint): void { + if (Math.abs(normal.y) < 0.99) point.tangent1.set(0, 1, 0).cross(normal); + else point.tangent1.set(1, 0, 0).cross(normal); // 法线近竖直时换参考轴 + if (point.tangent1.lengthSq() < 1e-8) point.tangent1.set(0, 0, 1).cross(normal); + point.tangent1.normalize(); + point.tangent2.copy(normal).cross(point.tangent1).normalize(); +} diff --git a/three-player-controller/src/collision/groups.ts b/three-player-controller/src/collision/groups.ts new file mode 100644 index 0000000..0a5f495 --- /dev/null +++ b/three-player-controller/src/collision/groups.ts @@ -0,0 +1,20 @@ +/** 碰撞业务分组 bitmask(与 MotionType 正交)。 */ +export const CollisionGroup = { + ALL: 0xffff, + /** 场景静态 / 运动学网格等默认层。 */ + DEFAULT: 1 << 0, + /** 人物相关(预留)。 */ + CHARACTER: 1 << 1, + /** 车辆底盘盒。 */ + VEHICLE: 1 << 2, + /** 动态碎片(球等)。 */ + DEBRIS: 1 << 3, +} as const; + +/** 人物胶囊 / 相机射线的单向查询目标。 */ +export const CHARACTER_QUERY_MASK = CollisionGroup.DEFAULT; + +/** 全部分组掩码。 */ +export const ALL_COLLISION_MASK = CollisionGroup.ALL; + +export type CollisionGroupBits = (typeof CollisionGroup)[keyof typeof CollisionGroup]; diff --git a/three-player-controller/src/collision/index.ts b/three-player-controller/src/collision/index.ts new file mode 100644 index 0000000..609c741 --- /dev/null +++ b/three-player-controller/src/collision/index.ts @@ -0,0 +1,31 @@ +export type { MotionType, CollisionCollider, CollisionShape, AddColliderOptions, MeshQueryFilter, MeshQueryOptions } from "./CollisionWorld"; +export { CollisionWorld } from "./CollisionWorld"; +export { + CollisionGroup, + CHARACTER_QUERY_MASK, + ALL_COLLISION_MASK, + type CollisionGroupBits, +} from "./groups"; +export type { + ColliderDesc, + ColliderHandle, + ColliderShape, + MeshShape, + BoxShape, + SphereShape, + StaticColliderDesc, + KinematicColliderDesc, + DynamicColliderDesc, +} from "./colliderDesc"; +export { DYNAMIC_BODY_DEFAULTS } from "./colliderDesc"; +export { DynamicBody } from "./DynamicBody"; +export type { DynamicBodyKind, DynamicBodyMaterialOptions } from "./DynamicBody"; +export { DynamicSphereBody, isSphereBody } from "./DynamicSphere"; +export { DynamicBoxBody, isBoxBody } from "./DynamicBox"; +export { + buildStaticMergedMesh, + buildKinematicMergedMesh, + attachBoundsTree, + ensureAttributesMinimal, + unifiedAttribute, +} from "./colliderBuild"; diff --git a/three-player-controller/src/collision/obbObb.ts b/three-player-controller/src/collision/obbObb.ts new file mode 100644 index 0000000..fd63846 --- /dev/null +++ b/three-player-controller/src/collision/obbObb.ts @@ -0,0 +1,223 @@ +import * as THREE from "three"; +import { impulseDenominator } from "../utils/vehiclePhysics/vehicleMath"; +import type { ImpulseBody } from "./solver/ImpulseBody"; + +const EPS = 1e-8; // 退化轴 / 零长度下限 +const AXIS_EPS = 1e-10; // 平行边叉积视为退化 +const BAUMGARTE = 0.8; // 每帧推出重叠的比例 +const RESTITUTION = 0.2; // 车车法向弹性 +const FRICTION = 0.4; // 切向摩擦相对法向冲量的上限 + +/** OBB–OBB 解算所需的刚体字段(扩展共享 ImpulseBody)。 */ +export type ObbRigidBody = ImpulseBody & { + halfExtents: THREE.Vector3; // 碰撞盒半边长 +}; + +/** OBB SAT / 接触冲量的预分配临时量。 */ +export type ObbObbTemps = { + ax: THREE.Vector3; // A 盒世界轴 X + ay: THREE.Vector3; + az: THREE.Vector3; + bx: THREE.Vector3; // B 盒世界轴 X + by: THREE.Vector3; + bz: THREE.Vector3; + axis: THREE.Vector3; // 当前测试分离轴 + bestN: THREE.Vector3; // 最小平移法线(A → B) + tangent: THREE.Vector3; // 摩擦切向 + invQuatA: THREE.Quaternion; + invQuatB: THREE.Quaternion; + local: THREE.Vector3; // 支撑点局部缓冲 + supportA: THREE.Vector3; // A 沿法线的支撑点 + supportB: THREE.Vector3; // B 沿反法线的支撑点 + contact: THREE.Vector3; // 世界接触点(两支撑中点) + velA: THREE.Vector3; // 接触点处 A 速度 + velB: THREE.Vector3; + rel: THREE.Vector3; // 质心差 / 相对速度缓冲 + impulse: THREE.Vector3; + r: THREE.Vector3; // impulseDenominator 用 + gcross: THREE.Vector3; + localI: THREE.Vector3; + worldI: THREE.Vector3; + invQuat: THREE.Quaternion; +}; + +/** 创建 OBB 碰撞临时量。 */ +export function createObbObbTemps(): ObbObbTemps { + return { + ax: new THREE.Vector3(), + ay: new THREE.Vector3(), + az: new THREE.Vector3(), + bx: new THREE.Vector3(), + by: new THREE.Vector3(), + bz: new THREE.Vector3(), + axis: new THREE.Vector3(), + bestN: new THREE.Vector3(), + tangent: new THREE.Vector3(), + invQuatA: new THREE.Quaternion(), + invQuatB: new THREE.Quaternion(), + local: new THREE.Vector3(), + supportA: new THREE.Vector3(), + supportB: new THREE.Vector3(), + contact: new THREE.Vector3(), + velA: new THREE.Vector3(), + velB: new THREE.Vector3(), + rel: new THREE.Vector3(), + impulse: new THREE.Vector3(), + r: new THREE.Vector3(), + gcross: new THREE.Vector3(), + localI: new THREE.Vector3(), + worldI: new THREE.Vector3(), + invQuat: new THREE.Quaternion(), + }; +} + +/** 盒在轴 n 上的投影半径(半宽之和)。 */ +function radiusOnAxis(ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3, half: THREE.Vector3, n: THREE.Vector3): number { + return half.x * Math.abs(n.dot(ax)) + half.y * Math.abs(n.dot(ay)) + half.z * Math.abs(n.dot(az)); +} + +/** + * 盒在 dir 方向上的支撑点(取各轴符号 * 半边,面法向时落在对应面心附近)。 + */ +function supportPoint( + pos: THREE.Vector3, + quat: THREE.Quaternion, + invQuat: THREE.Quaternion, + half: THREE.Vector3, + dir: THREE.Vector3, + local: THREE.Vector3, + out: THREE.Vector3, +): THREE.Vector3 { + local.copy(dir).applyQuaternion(invQuat); + out.set( + Math.sign(local.x) * half.x, + Math.sign(local.y) * half.y, + Math.sign(local.z) * half.z, + ); + return out.applyQuaternion(quat).add(pos); +} + +/** + * 两车底盘 OBB:SAT 最小平移推出,再按质量在接触点施法向 / 摩擦冲量。 + * `temps.bestN` 指向从 A 到 B;有重叠并处理则返回 true。 + */ +export function resolveObbObb(a: ObbRigidBody, b: ObbRigidBody, temps: ObbObbTemps): boolean { + // 两盒局部轴变到世界 + temps.ax.set(1, 0, 0).applyQuaternion(a.quaternion); + temps.ay.set(0, 1, 0).applyQuaternion(a.quaternion); + temps.az.set(0, 0, 1).applyQuaternion(a.quaternion); + temps.bx.set(1, 0, 0).applyQuaternion(b.quaternion); + temps.by.set(0, 1, 0).applyQuaternion(b.quaternion); + temps.bz.set(0, 0, 1).applyQuaternion(b.quaternion); + + let minOverlap = Infinity; + /** 测试一根分离轴:无重叠返回 false;否则更新最小平移轴。 */ + const tryAxis = (x: number, y: number, z: number): boolean => { + temps.axis.set(x, y, z); + const lenSq = temps.axis.lengthSq(); + if (lenSq < AXIS_EPS) return true; // 平行边叉积退化,跳过 + temps.axis.multiplyScalar(1 / Math.sqrt(lenSq)); + // 重叠深度 = 两投影半径之和 - 质心差在轴上的投影 + const overlap = + radiusOnAxis(temps.ax, temps.ay, temps.az, a.halfExtents, temps.axis) + + radiusOnAxis(temps.bx, temps.by, temps.bz, b.halfExtents, temps.axis) + - Math.abs(temps.axis.dot(temps.rel.subVectors(b.position, a.position))); + if (overlap <= EPS) return false; // 分离 + if (overlap < minOverlap) { + minOverlap = overlap; + temps.bestN.copy(temps.axis); + // 法线统一为 A → B + if (temps.bestN.dot(temps.rel) < 0) temps.bestN.negate(); + } + return true; + }; + + // 面法线:A、B 各 3 轴 + if (!tryAxis(temps.ax.x, temps.ax.y, temps.ax.z)) return false; + if (!tryAxis(temps.ay.x, temps.ay.y, temps.ay.z)) return false; + if (!tryAxis(temps.az.x, temps.az.y, temps.az.z)) return false; + if (!tryAxis(temps.bx.x, temps.bx.y, temps.bx.z)) return false; + if (!tryAxis(temps.by.x, temps.by.y, temps.by.z)) return false; + if (!tryAxis(temps.bz.x, temps.bz.y, temps.bz.z)) return false; + + // 边叉积:A 的 3 边 × B 的 3 边 + temps.axis.copy(temps.ax).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ax).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ax).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + + if (!Number.isFinite(minOverlap)) return false; + + // 位置修正:深度钳制后按 invMass 分配,乘 Baumgarte + const minHalf = Math.min( + a.halfExtents.x, a.halfExtents.y, a.halfExtents.z, + b.halfExtents.x, b.halfExtents.y, b.halfExtents.z, + ); + const depth = Math.min(minOverlap, minHalf * 2) * BAUMGARTE; + const invSum = a.invMass + b.invMass; + if (invSum <= EPS || depth <= EPS) return false; + + // 接触点 ≈ 两盒沿法线方向支撑点的中点 + temps.invQuatA.copy(a.quaternion).invert(); + temps.invQuatB.copy(b.quaternion).invert(); + supportPoint(a.position, a.quaternion, temps.invQuatA, a.halfExtents, temps.bestN, temps.local, temps.supportA); + supportPoint(b.position, b.quaternion, temps.invQuatB, b.halfExtents, temps.rel.copy(temps.bestN).negate(), temps.local, temps.supportB); + temps.contact.copy(temps.supportA).add(temps.supportB).multiplyScalar(0.5); + + a.position.addScaledVector(temps.bestN, -depth * (a.invMass / invSum)); + b.position.addScaledVector(temps.bestN, depth * (b.invMass / invSum)); + + // 法向冲量:仅处理接近中的相对速度 + a.getVelocityAtPoint(temps.contact, temps.velA); + b.getVelocityAtPoint(temps.contact, temps.velB); + const relN = temps.velB.dot(temps.bestN) - temps.velA.dot(temps.bestN); + if (relN >= 0) return true; + + const denomN = + impulseDenominator(a, temps.contact, temps.bestN, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat) + + impulseDenominator(b, temps.contact, temps.bestN, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat); + if (denomN <= EPS) return true; + + const jn = -(1 + RESTITUTION) * relN / denomN; + temps.impulse.copy(temps.bestN).multiplyScalar(-jn); + a.applyImpulseAtPoint(temps.impulse, temps.contact); + temps.impulse.copy(temps.bestN).multiplyScalar(jn); + b.applyImpulseAtPoint(temps.impulse, temps.contact); + + // 库仑摩擦:切向相对速度,冲量钳在 μ|jn| + a.getVelocityAtPoint(temps.contact, temps.velA); + b.getVelocityAtPoint(temps.contact, temps.velB); + temps.rel.subVectors(temps.velA, temps.velB); + temps.rel.addScaledVector(temps.bestN, -temps.rel.dot(temps.bestN)); // 去掉法向分量 + const tLen = temps.rel.length(); + if (tLen <= EPS) return true; + temps.tangent.copy(temps.rel).multiplyScalar(1 / tLen); + + const denomT = + impulseDenominator(a, temps.contact, temps.tangent, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat) + + impulseDenominator(b, temps.contact, temps.tangent, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat); + if (denomT <= EPS) return true; + + let jt = -tLen / denomT; + const maxF = FRICTION * Math.abs(jn); + jt = Math.max(-maxF, Math.min(maxF, jt)); + temps.impulse.copy(temps.tangent).multiplyScalar(jt); + a.applyImpulseAtPoint(temps.impulse, temps.contact); + temps.impulse.negate(); + b.applyImpulseAtPoint(temps.impulse, temps.contact); + return true; +} diff --git a/three-player-controller/src/collision/solver/ContactImpulseSolver.ts b/three-player-controller/src/collision/solver/ContactImpulseSolver.ts new file mode 100644 index 0000000..eefb2d4 --- /dev/null +++ b/three-player-controller/src/collision/solver/ContactImpulseSolver.ts @@ -0,0 +1,56 @@ +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ImpulseBody } from "./ImpulseBody"; +import { solveFrictionConstraint, warmStartFriction } from "./frictionConstraint"; +import { + prepareNormalConstraint, + solveNormalConstraint, + warmStartNormal, +} from "./normalConstraint"; + +/** + * 共享接触冲量求解器:法向 + 摩擦锥,可选热启动。 + * 人物胶囊不走此核(位置推出);轮悬架 / 轮胎仍在车辆模块。 + */ +export class ContactImpulseSolver { + velocityIterations = 8; // 速度迭代次数 + warmStart = true; // 是否复用上一帧冲量做热启动 + + /** + * 对单个刚体与一组流形做速度级求解。 + * 顺序:准备 bias → 热启动 → 交替解法向 / 摩擦。 + */ + solveVelocity( + body: ImpulseBody, + manifolds: ContactManifold[], + dt: number, + ): void { + if (!manifolds.length) return; + + // 写入弹性与穿透修正用的 biasVelocity + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + prepareNormalConstraint(body, manifold, point, dt); + } + } + + // 先施加缓存冲量,减少 resting 抖动 + if (this.warmStart) { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + warmStartNormal(body, manifold, point); + warmStartFriction(body, point); + } + } + } + + // PGS:每轮先法向后摩擦 + for (let i = 0; i < this.velocityIterations; i++) { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + solveNormalConstraint(body, manifold, point); + solveFrictionConstraint(body, manifold, point); + } + } + } + } +} diff --git a/three-player-controller/src/collision/solver/ImpulseBody.ts b/three-player-controller/src/collision/solver/ImpulseBody.ts new file mode 100644 index 0000000..d4feef0 --- /dev/null +++ b/three-player-controller/src/collision/solver/ImpulseBody.ts @@ -0,0 +1,28 @@ +import type * as THREE from "three"; + +/** + * 接触冲量求解所需的刚体接口。 + */ +export type ImpulseBody = { + /** 世界质心位置。 */ + position: THREE.Vector3; + /** 世界朝向。 */ + quaternion: THREE.Quaternion; + /** 质量倒数。 */ + invMass: number; + /** 局部惯量倒数对角元。 */ + invInertia: THREE.Vector3; + /** 摩擦系数 μ;缺省由摩擦约束使用默认值。 */ + friction?: number; + /** 弹性,0 不反弹,1 完全弹;缺省 0。 */ + restitution?: number; + /** + * 特征半尺寸(用于缩放接触 skin / 穿透 slop)。 + * 缺省按 CONTACT_REF_EXTENT,不缩放。 + */ + characteristicExtent?: () => number; + /** 读取世界点处速度:v + ω × r。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3; + /** 在世界点施加冲量(线速度 + 角速度)。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, point: THREE.Vector3): void; +}; diff --git a/three-player-controller/src/collision/solver/frictionConstraint.ts b/three-player-controller/src/collision/solver/frictionConstraint.ts new file mode 100644 index 0000000..1c57e41 --- /dev/null +++ b/three-player-controller/src/collision/solver/frictionConstraint.ts @@ -0,0 +1,73 @@ +import * as THREE from "three"; +import { effectiveMass } from "../../utils/vehiclePhysics/vehicleMath"; +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ContactPoint } from "../contacts/ContactPoint"; +import type { ImpulseBody } from "./ImpulseBody"; + +const DEFAULT_FRICTION = 0.6; // body.friction 未设时的库仑系数 μ + +const _r = new THREE.Vector3(); +const _gcross = new THREE.Vector3(); +const _local = new THREE.Vector3(); +const _world = new THREE.Vector3(); +const _invQuat = new THREE.Quaternion(); +const _vel = new THREE.Vector3(); +const _impulse = new THREE.Vector3(); + +/** 热启动:施加上一帧累计的切向冲量。 */ +export function warmStartFriction( + body: ImpulseBody, + point: ContactPoint, +): void { + if (point.tangentImpulse1 === 0 && point.tangentImpulse2 === 0) return; + _impulse.copy(point.tangent1).multiplyScalar(point.tangentImpulse1); + _impulse.addScaledVector(point.tangent2, point.tangentImpulse2); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} + +/** + * 解切向摩擦约束(库仑锥)。 + * 上限 maxF = μ · 法向冲量;超出则等比缩放两切向冲量。 + */ +export function solveFrictionConstraint( + body: ImpulseBody, + _manifold: ContactManifold, + point: ContactPoint, +): void { + const mu = body.friction ?? DEFAULT_FRICTION; + const maxF = mu * point.normalImpulse; + if (maxF <= 1e-8) { + // 无法向压力则无摩擦 + point.tangentImpulse1 = 0; + point.tangentImpulse2 = 0; + return; + } + + body.getVelocityAtPoint(point.worldPoint, _vel); + const vt1 = _vel.dot(point.tangent1); + const vt2 = _vel.dot(point.tangent2); + const mass1 = effectiveMass(body, point.worldPoint, point.tangent1, _r, _gcross, _local, _world, _invQuat); + const mass2 = effectiveMass(body, point.worldPoint, point.tangent2, _r, _gcross, _local, _world, _invQuat); + + // 累计切向冲量,使切向相对速度趋近 0 + const old1 = point.tangentImpulse1; + const old2 = point.tangentImpulse2; + point.tangentImpulse1 += mass1 > 0 ? -vt1 * mass1 : 0; + point.tangentImpulse2 += mass2 > 0 ? -vt2 * mass2 : 0; + + // 钳到摩擦锥:‖λt‖ ≤ μ λn + const magSq = point.tangentImpulse1 * point.tangentImpulse1 + point.tangentImpulse2 * point.tangentImpulse2; + const maxSq = maxF * maxF; + if (magSq > maxSq && magSq > 1e-12) { + const scale = maxF / Math.sqrt(magSq); + point.tangentImpulse1 *= scale; + point.tangentImpulse2 *= scale; + } + + // 只施加本轮增量 + const d1 = point.tangentImpulse1 - old1; + const d2 = point.tangentImpulse2 - old2; + if (d1 === 0 && d2 === 0) return; + _impulse.copy(point.tangent1).multiplyScalar(d1).addScaledVector(point.tangent2, d2); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} diff --git a/three-player-controller/src/collision/solver/normalConstraint.ts b/three-player-controller/src/collision/solver/normalConstraint.ts new file mode 100644 index 0000000..0c31eef --- /dev/null +++ b/three-player-controller/src/collision/solver/normalConstraint.ts @@ -0,0 +1,92 @@ +import * as THREE from "three"; +import { effectiveMass } from "../../utils/vehiclePhysics/vehicleMath"; +import { CONTACT_REF_EXTENT } from "../contacts/ContactPoint"; +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ContactPoint } from "../contacts/ContactPoint"; +import type { ImpulseBody } from "./ImpulseBody"; + +/** 下列为 CONTACT_REF_EXTENT 下的基准值;求解时按特征尺寸缩放。 */ +const PENETRATION_SLOP = 0.003; // 允许的残留穿透,小于此不修正 +const PENETRATION_BIAS_FACTOR = 0.05; // Baumgarte 系数 +const MAX_CORRECTION_VELOCITY = 0.04; // 穿透修正速度上限,避免弹飞 +const SUPPORT_BIAS_Y = 0.5; // 仅对偏竖直的支撑面做法向 bias +const RESTITUTION_THRESHOLD = 0.1; // 接近静止时不再加弹性,避免微弹 + +const _r = new THREE.Vector3(); +const _gcross = new THREE.Vector3(); +const _local = new THREE.Vector3(); +const _world = new THREE.Vector3(); +const _invQuat = new THREE.Quaternion(); +const _vel = new THREE.Vector3(); +const _impulse = new THREE.Vector3(); + +function contactScale(body: ImpulseBody): number { + const extent = body.characteristicExtent?.() ?? CONTACT_REF_EXTENT; + return Math.max(1e-4, extent) / CONTACT_REF_EXTENT; +} + +/** + * 准备法向约束的 biasVelocity。 + * 含弹性目标分离速度,以及支撑面上的穿透位置修正。 + */ +export function prepareNormalConstraint( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, + dt: number, +): void { + point.biasVelocity = 0; + const scale = contactScale(body); + body.getVelocityAtPoint(point.worldPoint, _vel); + const vn = _vel.dot(manifold.normal); + const e = body.restitution ?? 0; + if (e > 0 && vn < -RESTITUTION_THRESHOLD * scale) { + // 目标分离速度 ≈ -e·vn;并入 bias 后 λ = -(vn - bias)·m ≈ (1+e)|vn|·m + point.biasVelocity += -e * vn; + } + if (dt <= 1e-8) return; + // 墙面等非支撑接触不做穿透 bias,减少侧向拉扯 + if (manifold.normal.y <= SUPPORT_BIAS_Y) return; + const error = point.penetration - PENETRATION_SLOP * scale; + if (error <= 0) return; + point.biasVelocity += Math.min( + error * PENETRATION_BIAS_FACTOR / dt, + MAX_CORRECTION_VELOCITY * scale, + ); +} + +/** 热启动:施加上一帧累计的法向冲量。 */ +export function warmStartNormal( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, +): void { + if (point.normalImpulse === 0) return; + _impulse.copy(manifold.normal).multiplyScalar(point.normalImpulse); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} + +/** + * 解法向非穿透约束。 + * 冲量累计且钳制为 ≥ 0(只推开、不拉回)。 + */ +export function solveNormalConstraint( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, +): void { + const n = manifold.normal; + body.getVelocityAtPoint(point.worldPoint, _vel); + const vn = _vel.dot(n); + const mass = effectiveMass(body, point.worldPoint, n, _r, _gcross, _local, _world, _invQuat); + if (mass <= 0) return; + + // 使 vn 趋向 biasVelocity(弹性 + 穿透修正) + const lambda = -(vn - point.biasVelocity) * mass; + const old = point.normalImpulse; + point.normalImpulse = Math.max(0, old + lambda); + const applied = point.normalImpulse - old; + if (applied === 0) return; + _impulse.copy(n).multiplyScalar(applied); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} diff --git a/three-player-controller/src/foot-ik.ts b/three-player-controller/src/foot-ik.ts new file mode 100644 index 0000000..08084bc --- /dev/null +++ b/three-player-controller/src/foot-ik.ts @@ -0,0 +1,8 @@ +export { + FootIK, + type BoneRef, + type FootIKLegBoneConfig, + type FootIKOptions, + type FootIKSide, + type FootIKSkeletonConfig, +} from "./plugins/foot-ik"; diff --git a/three-player-controller/src/index.ts b/three-player-controller/src/index.ts new file mode 100644 index 0000000..b4f6d48 --- /dev/null +++ b/three-player-controller/src/index.ts @@ -0,0 +1,55 @@ +// 主入口 +export * from "./PlayerController"; +export type { + JumpButtonOptions, + KeyAction, + KeyMap, + LegacyPlayerModelSource, + LegacyVehicleModelSource, + LoadedPlayerModelSource, + LoadedVehicleModelSource, + MobileButtonOptions, + MobileControlsOptions, + PlayerControllerOptions, + PlayerModelOptions, + PlayerModelSource, + VehicleInstance, + VehicleModelSource, + VehicleOptions, + VehicleChassisOptions, + VehicleSuspensionOptions, + VehicleSteeringOptions, + VehicleGripOptions, + VehiclePowerOptions, + VehicleWheelColliderUserData, + KinematicColliderEntry, +} from "./types"; +export type { PlayerPlugin } from "./plugins/types"; + +// 碰撞公开 API +export { CollisionWorld } from "./collision/CollisionWorld"; +export type { CollisionCollider, CollisionShape, MotionType } from "./collision/CollisionWorld"; +export { CHARACTER_QUERY_MASK, ALL_COLLISION_MASK, CollisionGroup } from "./collision/groups"; +export type { + ColliderDesc, + ColliderHandle, + ColliderShape, + MeshShape, + BoxShape, + SphereShape, +} from "./collision/colliderDesc"; +export { DYNAMIC_BODY_DEFAULTS } from "./collision/colliderDesc"; +export { DynamicSphereBody, isSphereBody } from "./collision/DynamicSphere"; +export { DynamicBoxBody, isBoxBody } from "./collision/DynamicBox"; +export { DynamicBody } from "./collision/DynamicBody"; +export type { DynamicBodyKind, DynamicBodyMaterialOptions } from "./collision/DynamicBody"; + +// 接触 / 冲量求解 +export { ContactImpulseSolver } from "./collision/solver/ContactImpulseSolver"; +export type { ImpulseBody } from "./collision/solver/ImpulseBody"; +export { ContactManifold } from "./collision/contacts/ContactManifold"; +export { ContactPoint, CONTACT_SKIN, CONTACT_REF_EXTENT, contactSkinForExtent } from "./collision/contacts/ContactPoint"; + +// 载具底层 +export { BVHVehicleController } from "./utils/vehiclePhysics/BVHVehicleController"; +export { VehicleRigidBody } from "./utils/vehiclePhysics/VehicleRigidBody"; diff --git a/three-player-controller/src/plugins/foot-ik/FootIK.ts b/three-player-controller/src/plugins/foot-ik/FootIK.ts new file mode 100644 index 0000000..bb1a465 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/FootIK.ts @@ -0,0 +1,2409 @@ +import { + Matrix3, + MathUtils, + Quaternion, + Raycaster, + Vector3, + type Bone, + type Intersection, + type Mesh, + type Object3D, +} from "three"; +import { createTwoBoneIKScratch, solveTwoBoneIK } from "./internal/twoBoneIK"; +import { + buildFootPhaseDatabase, + createFootPhaseOptions, + createFootPhaseRuntimeState, + sampleFootPhaseRuntime, +} from "./internal/footPhase"; +import { + collectBones, + createLeg, + findHips, + isReadyLeg, + resolveConfiguredBone, +} from "./internal/skeleton"; +import { + createDebugObjects, + getFootPhaseDebugText, + setDebugVisible, + updateFootDebug, + disposeDebugObjects, +} from "./internal/debug"; +import { + createPredictiveScratch, + findPredictiveFootCandidates, + getPredictiveDemandThresholds, + getPredictivePlaneDistance, + getPredictiveTerrainDemand, + acceptPredictiveFootCandidate, + refreshPredictiveSupportAnchor, + resetPredictiveFootState, + selectPredictiveDebugCandidate, + shiftPredictiveDebugTrajectory, + type PredictiveScratch, +} from "./internal/predictivePlacement"; +import { + getPredictiveClearanceShape, + getPredictiveLocalProgress, + planPredictiveFootTrajectory, + PREDICTION_ROTATION_WARP_START, + type PredictiveTrajectoryContext, + type PredictiveTrajectoryPlan, +} from "./internal/predictiveTrajectory"; +import type { + FootIKBonePose, + FootIKGroundHit, + FootIKLeg, + FootIKLegs, + FootIKOptions, + FootIKPlayer, + FootIKSide, + FootIKSkeletonConfig, + FootPhaseControllerState, + FootPhaseDatabase, + FootPhaseOptions, + FootPhaseRuntimeState, + PredictiveFootState, + ReadyFootIKLeg, +} from "./types"; +import type { TwoBoneIKScratch } from "./internal/twoBoneIK"; + +/** Foot IK 不参与地面检测的动态刚体形状。 */ +const FOOT_IK_IGNORED_DYNAMIC_KINDS = ["sphere"] as const; +/** 摆腿进入末段后锁定地形结果,避免落地前继续切换支撑面。 */ +const PREDICTION_LOCK_PROGRESS = 0.88; +/** 支撑落点残差在新摆腿前段完成释放的相位比例。 */ +const PREDICTION_RELEASE_PROGRESS = 0.25; +/** 预测摆腿追目标时的阻尼系数。 */ +const PREDICTIVE_SWING_CHASE_DAMP = 40; +/** 预测摆腿把修正还给动画时的阻尼系数。 */ +const PREDICTIVE_SWING_FADE_DAMP = 40; + +function createGroundHit(): FootIKGroundHit { + return { + point: new Vector3(), + normal: new Vector3(0, 1, 0), + }; +} + +function copyGroundHit(source: FootIKGroundHit, target: FootIKGroundHit): FootIKGroundHit { + target.point.copy(source.point); + target.normal.copy(source.normal); + target.object = source.object; + return target; +} + +/** 根据角色动画和地面高度修正脚部姿态的控制器插件。 */ +export class FootIK { + /** 插件名称。 */ + readonly name = "foot-ik"; + + // 当前挂载的玩家控制器。 + private player: FootIKPlayer | null = null; + + // 生命周期与调试开关。 + private enabled: boolean; + private disposed = false; + private debug: boolean; + + // IK 距离、角度和脚掌贴合配置。 + private maxPelvisDrop = 50; + private maxPelvisRaise = 50; + private maxFootRaise = 50; + private maxFootDrop = 50; + private plantedHeightSpeed = Infinity; + private penetrationLiftSpeed = Infinity; + private straightPoleEnabled = false; + private soleHalfWidth = 7; + private soleToeExtend = 7; + private soleHeelExtend = 3; + private soleSkinThickness = 3; + private moveLiftThreshold = 0.1; + private footPhaseGroundThreshold = 5; + private fakeToeExtend = 24; + private sampleRayOriginY = 90; + private raycastFar = 440; + private snapEpsilon = 0.02; + // 预测落脚开关与搜索、线路和摆动修正配置。 + private predictivePlacement: boolean; + private predictionHorizon: number; + private predictionProbeFrames: number; + private predictionSearchRadius = 20; + private maxPredictionCorrection = 45; + private predictionMinNormalY: number; + private swingClearance = 8; + private maxPredictionClearance = 50; + // 当前距离和速度字段已烘焙进去的 scale;改 scale 时按 ratio 连乘。 + private appliedScale = 1; + + private footAlignWeight: number; + private maxFootTilt: number; + private minKneeBend: number; + private maxKneeBend: number; + private pelvisKneeBend: number; + + // 骨盆垂直补偿。 + private pelvisOffset = 0; + private pelvisOffsetOverride: number | null = null; + + // 地面检测与每帧复用的临时对象。 + private colliderMeshes: Mesh[] = []; + private readonly groundHit = createGroundHit(); + private readonly capsuleHit = createGroundHit(); + private readonly bestGroundHit = createGroundHit(); + private readonly sampleGroundHits = [ + createGroundHit(), + createGroundHit(), + createGroundHit(), + createGroundHit(), + ]; + private readonly sampleGroundHitSlots: Array = [ + null, + null, + null, + null, + ]; + private readonly raycaster: Raycaster; + private readonly up = new Vector3(0, 1, 0); + private readonly tmpV1 = new Vector3(); + private readonly tmpV2 = new Vector3(); + private readonly tmpV3 = new Vector3(); + private readonly tmpV4 = new Vector3(); + private readonly tmpV5 = new Vector3(); + private readonly tmpV6 = new Vector3(); + private readonly tmpV7 = new Vector3(); + private readonly tmpV8 = new Vector3(); + private readonly tmpV9 = new Vector3(); + private readonly tmpV10 = new Vector3(); + private readonly tmpQ1 = new Quaternion(); + private readonly tmpQ2 = new Quaternion(); + private readonly tmpQ3 = new Quaternion(); + private readonly tmpQ4 = new Quaternion(); + // IK 前的动画脚旋转,用于抵消腿链求解对子骨骼产生的被动旋转。 + private readonly savedFootWorldQ = new Quaternion(); + // 脚掌贴坡后的旋转,用于二次接触校正时保持已完成的倾斜结果。 + private readonly savedAlignedFootWorldQ = new Quaternion(); + // 四元数球面插值使用的单位旋转终点。 + private readonly identityQ = new Quaternion(); + // 把碰撞面局部法线转换到世界空间。 + private readonly normalMatrix = new Matrix3(); + // 双骨骼 IK 解算器跨帧复用的临时对象。 + private readonly twoBoneIKScratch: TwoBoneIKScratch; + // 预测落脚搜索和轨迹规划使用的专用临时对象。 + private readonly predictiveScratch: PredictiveScratch; + + // 胶囊实际位移速度比输入速度更能反映碰撞后的真实运动,用于预测未来根节点位置。 + private readonly previousCapsulePosition = new Vector3(); + private readonly predictionVelocity = new Vector3(); + private hasPreviousCapsulePosition = false; + + // 下一帧动画更新前需要恢复的骨骼及其动画原始姿态。 + private readonly adjusted = new Set(); + private readonly poseCache = new Map(); + + // 骨骼绑定和脚步相位配置。 + private readonly skeletonConfig: FootIKSkeletonConfig | null; + private footPhaseOptions: FootPhaseOptions; + + // 当前模型生成的脚步相位数据库和运行时状态。 + private footPhaseClips: FootPhaseDatabase = new Map(); + private footPhaseState: FootPhaseControllerState; + + // 当前模型解析出的骨盆与左右腿链。 + private hips: Bone | null = null; + private legs: FootIKLegs; + + /** 创建 FootIK 插件。 */ + constructor(options: FootIKOptions = {}) { + this.enabled = options.enabled ?? true; + this.debug = options.debug ?? false; + + // 距离和速度类参数先写入 scale=1 基准值,挂载后按 playerModelConfig.scale 连乘。 + this.maxPelvisDrop = Math.max(0, options.maxPelvisDrop ?? this.maxPelvisDrop); + this.maxPelvisRaise = Math.max(0, options.maxPelvisRaise ?? this.maxPelvisRaise); + this.maxFootRaise = Math.max(0, options.maxFootRaise ?? this.maxFootRaise); + this.maxFootDrop = Math.max(0, options.maxFootDrop ?? this.maxFootDrop); + this.plantedHeightSpeed = Math.max(0, options.plantedHeightSpeed ?? this.plantedHeightSpeed); + this.penetrationLiftSpeed = Math.max(0, options.penetrationLiftSpeed ?? this.penetrationLiftSpeed); + this.soleHalfWidth = Math.max(0, options.soleHalfWidth ?? this.soleHalfWidth); + this.soleToeExtend = Math.max(0, options.soleToeExtend ?? this.soleToeExtend); + this.soleHeelExtend = Math.max(0, options.soleHeelExtend ?? this.soleHeelExtend); + this.soleSkinThickness = Math.max(0, options.soleSkinThickness ?? this.soleSkinThickness); + this.moveLiftThreshold = Math.max(0, options.moveLiftThreshold ?? this.moveLiftThreshold); + this.footPhaseGroundThreshold = Math.max(0, options.footPhaseGroundThreshold ?? this.footPhaseGroundThreshold); + this.predictionSearchRadius = Math.max(0, options.predictionSearchRadius ?? this.predictionSearchRadius); + this.maxPredictionCorrection = Math.max(0, options.maxPredictionCorrection ?? this.maxPredictionCorrection); + this.swingClearance = Math.max(0, options.swingClearance ?? this.swingClearance); + this.maxPredictionClearance = Math.max( + 0, + options.maxPredictionClearance ?? this.maxFootRaise, + ); + + // 预测落脚默认关闭,确保未显式开启时不改变既有 Foot IK 行为。 + this.predictivePlacement = options.predictivePlacement ?? false; + this.predictionHorizon = Math.max(0, options.predictionHorizon ?? 0.45); + this.predictionProbeFrames = Math.max(0, options.predictionProbeFrames ?? 10); + this.predictionMinNormalY = MathUtils.clamp(options.predictionMinNormalY ?? 0.55, 0, 1); + this.footAlignWeight = MathUtils.clamp(options.footAlignWeight ?? 1, 0, 1); // 脚掌贴合地面法线的强度 + this.maxFootTilt = MathUtils.clamp(options.maxFootTilt ?? Math.PI / 2, 0, Math.PI); // 脚掌贴坡最大旋转角 + const configuredMinBend = MathUtils.clamp(options.minKneeBend ?? MathUtils.degToRad(2), 0, Math.PI); + const configuredMaxBend = MathUtils.clamp(options.maxKneeBend ?? MathUtils.degToRad(145), 0, Math.PI); + this.minKneeBend = Math.min(configuredMinBend, configuredMaxBend); + this.maxKneeBend = Math.max(configuredMinBend, configuredMaxBend); + this.pelvisKneeBend = MathUtils.clamp( + options.pelvisKneeBend ?? MathUtils.degToRad(0), + this.minKneeBend, + this.maxKneeBend, + ); + this.straightPoleEnabled = options.straightPoleEnabled ?? false; + + // 脚底检测射线;far 会在 rescaleDistances 中随 scale 更新。 + this.raycaster = new Raycaster(new Vector3(), new Vector3(0, -1, 0), 0, this.raycastFar); + (this.raycaster as Raycaster & { firstHitOnly?: boolean }).firstHitOnly = true; + + // 从动画中自动采样脚步相位,用于判断移动动画中的支撑脚。 + this.footPhaseOptions = createFootPhaseOptions({ + ...options, + footPhaseGroundThreshold: this.footPhaseGroundThreshold, + }); + this.footPhaseState = { + clipName: "", + normalizedTime: 0, + left: createFootPhaseRuntimeState(), + right: createFootPhaseRuntimeState(), + }; + + this.twoBoneIKScratch = createTwoBoneIKScratch(); + this.predictiveScratch = createPredictiveScratch(); + + // 未传配置时才回退到启发式匹配。 + this.skeletonConfig = options.skeleton ?? null; // 骨骼绑定配置,支持骨骼名或 Bone 对象 + this.legs = this.createEmptyLegs(); + } + + /** 挂载到玩家控制器并绑定当前模型骨骼。 */ + onAttach(player: FootIKPlayer): void { + if (this.disposed) { + throw new Error("[FootIK] 已销毁的插件不能再次挂载。"); + } + if (this.player && this.player !== player) this.detachPlayer(); + this.attachPlayer(player); + } + + /** 从玩家控制器卸载并恢复插件修改。 */ + onDetach(): void { + this.detachPlayer(); + } + + /** 在动画更新前恢复上一帧被 IK 修改的骨骼姿态。 */ + onBeforeAnimationUpdate(_delta: number): void { + if (!this.enabled || this.disposed) return; + this.restore(); + } + + /** 在动画更新后应用当前帧 Foot IK。 */ + onAfterAnimationUpdate(delta: number): void { + if (!this.enabled || this.disposed) return; + this.syncDistanceScale(); + this.update(delta); + } + + /** 玩家模型切换后重新绑定骨骼和脚步相位数据。 */ + onPlayerModelChange(): void { + if (this.disposed) return; + this.rebind(); + } + + /** 终止使用插件并释放调试资源。 */ + dispose(): void { + if (this.disposed) return; + this.detachPlayer(); + this.disposed = true; + this.footPhaseClips.clear(); + this.adjusted.clear(); + this.poseCache.clear(); + } + + // 模型切换后释放旧调试对象,并基于新模型重新绑定全部运行时数据。 + private rebind(): void { + if (!this.player?.playerModel) return; + this.restore(); + disposeDebugObjects(this.legs); + this.syncDistanceScale(); + this.bindSkeleton(); + } + + /** 读取当前角色 scale,未挂载时按 1 处理。 */ + private getPlayerScale(): number { + const scale = this.player?.playerModelConfig?.scale; + return typeof scale === "number" && scale > 0 ? scale : 1; + } + + /** 把 scale=1 基准距离写成当前世界值。 */ + private scaleDistance(value: number): number { + return Math.max(0, value) * this.getPlayerScale(); + } + + /** 把已缩放的世界距离还原为 scale=1 基准值(供 getOptions 使用)。 */ + private toBaseDistance(world: number): number { + return this.appliedScale > 0 ? world / this.appliedScale : world; + } + + /** 若角色 scale 变化,对距离字段做 ratio 连乘。 */ + private syncDistanceScale(): void { + const scale = this.getPlayerScale(); + if (scale === this.appliedScale) return; + this.rescaleDistances(scale); + } + + /** 将距离字段从 appliedScale 换算到 newScale。 */ + private rescaleDistances(newScale: number): void { + const next = newScale > 0 ? newScale : 1; + const ratio = next / this.appliedScale; + if (ratio === 1) { + this.appliedScale = next; + return; + } + + this.maxPelvisDrop *= ratio; + this.maxPelvisRaise *= ratio; + this.maxFootRaise *= ratio; + this.maxFootDrop *= ratio; + this.plantedHeightSpeed *= ratio; + this.penetrationLiftSpeed *= ratio; + this.soleHalfWidth *= ratio; + this.soleToeExtend *= ratio; + this.soleHeelExtend *= ratio; + this.soleSkinThickness *= ratio; + this.moveLiftThreshold *= ratio; + this.footPhaseGroundThreshold *= ratio; + this.fakeToeExtend *= ratio; + this.sampleRayOriginY *= ratio; + this.raycastFar *= ratio; + this.snapEpsilon *= ratio; + this.predictionSearchRadius *= ratio; + this.maxPredictionCorrection *= ratio; + this.swingClearance *= ratio; + this.maxPredictionClearance *= ratio; + this.pelvisOffset *= ratio; + for (const leg of Object.values(this.legs)) leg.offsetY *= ratio; + if (this.pelvisOffsetOverride !== null) this.pelvisOffsetOverride *= ratio; + this.raycaster.far = this.raycastFar; + this.footPhaseOptions.groundThreshold = this.footPhaseGroundThreshold; + this.appliedScale = next; + } + + /** 启用或停用 Foot IK;停用时立即恢复插件修改。 */ + setEnabled(enabled: boolean): void { + if (this.disposed) return; + this.enabled = enabled; + if (!enabled) { + this.restore(); + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + this.setDebugVisible(false); + } else if (this.debug) { + this.setDebugEnabled(true); + } + } + + // 保存玩家控制器引用并初始化模型相关状态。 + private attachPlayer(player: FootIKPlayer): void { + if (this.player === player && this.legs.left.ready) return; + this.player = player; + this.syncDistanceScale(); + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + this.bindSkeleton(); + } + + // 恢复骨骼,释放调试资源并清空所有控制器相关引用。 + private detachPlayer(): void { + this.restore(); + disposeDebugObjects(this.legs); + this.footPhaseClips.clear(); + this.adjusted.clear(); + this.poseCache.clear(); + this.hips = null; + this.legs = this.createEmptyLegs(); + // 还原到 scale=1 基准值,便于卸载后再挂载或单独 configure。 + this.rescaleDistances(1); + this.player = null; + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + } + + // 创建尚未绑定骨骼的左右腿占位状态。 + private createEmptyLegs(): FootIKLegs { + return { + left: createLeg("left", []), + right: createLeg("right", []), + }; + } + + // 解析骨骼后初始化脚底采样、脚步相位数据库和调试对象。 + private bindSkeleton(): void { + const bones = collectBones(this.player?.playerModel); + this.hips = resolveConfiguredBone(this.skeletonConfig?.hips, bones, "hips") + ?? findHips(bones); + this.legs = { + left: createLeg("left", bones, this.skeletonConfig), + right: createLeg("right", bones, this.skeletonConfig), + }; + + if (!this.hips || !isReadyLeg(this.legs.left) || !isReadyLeg(this.legs.right)) { + console.warn("[FootIK] 骨骼识别不完整,当前模型骨骼名:", bones.map(b => b.name)); + } + this.player?.playerModel?.updateMatrixWorld(true); + this.initSoleLocalSamples(this.legs.left); + this.initSoleLocalSamples(this.legs.right); + this.buildFootPhaseDatabase(); + this.createDebugObjects(); + this.resetPredictionRuntime(); + } + + /** 清空胶囊速度历史和双脚预测目标,避免挂载、切模或开关切换后沿用旧落点。 */ + private resetPredictionRuntime(): void { + this.predictionVelocity.set(0, 0, 0); + this.hasPreviousCapsulePosition = false; + if (this.player?.playerCapsule) { + this.previousCapsulePosition.copy(this.player.playerCapsule.position); + this.hasPreviousCapsulePosition = true; + } + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + this.legs.left.heightSupport = null; + this.legs.right.heightSupport = null; + } + + /** 空中 / 停用贴地时丢掉反应式 IK 残留,避免落地从起飞前的世界高度往下坠。 */ + private resetReactiveIkState(): void { + this.pelvisOffset = 0; + for (const leg of Object.values(this.legs)) { + if (!isReadyLeg(leg)) continue; + leg.weight = 0; + leg.offsetY = 0; + leg.heightSupport = null; + leg.plantedWeight = 0; + leg.hasPelvisTarget = false; + leg.movePenetrating = false; + leg.foot.getWorldPosition(leg.smoothedTarget); + } + } + + // 恢复上一帧 IK 修改前的骨骼姿态 + private restore(): void { + // 先恢复上一帧的动画原始姿态,再让 mixer 推进当前帧 + // 调用顺序:footIK.restore() -> player.update() -> footIK.update() + for (const bone of this.adjusted) { + const pose = this.poseCache.get(bone); + if (!pose) continue; + bone.position.copy(pose.position); + bone.quaternion.copy(pose.quaternion); + } + this.adjusted.clear(); + } + + // 在动画更新后执行每帧腿部 IK 修正。 + private update(delta = 1 / 60): void { + if (!this.enabled || this.disposed) return; + this.updateFootPhaseRuntime(); + if (this.predictivePlacement) this.updatePredictionVelocity(delta); + this.refreshColliderMeshes(); + + const player = this.player; + // 不在地面、飞行或载具模式时不做腿部贴地 + if ( + !player + || (!this.colliderMeshes.length && !player.getDynamicBodies?.().length) + || !player.playerCapsule + || !player.playerIsOnGround + || player.isFlying + || player.getControllerMode?.() === 1 + ) { + if (this.predictivePlacement) { + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + } + player?.playerModel?.updateMatrixWorld(true); + this.resetReactiveIkState(); + this.setDebugVisible(false); + return; + } + + const model = player.playerModel; + model?.updateMatrixWorld(true); + + // moving 决定移动与静止 IK 策略:移动使用脚步相位和稳定直弯 pole,静止保留动画 pole。 + const moving = this.isLocomotion(); + if (this.predictivePlacement && !moving) { + // 离开移动状态后交还给静止 Foot IK,避免 idle 继续沿用旧摆动线路。 + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + } + + // 先算左右脚目标,再根据两只脚目标调整骨盆,最后解腿部 IK。 + this.updateFoot("left", delta, moving); + this.updateFoot("right", delta, moving); + this.applyPelvis(delta); + this.applyLeg("left", this.straightPoleEnabled && moving); + this.applyLeg("right", this.straightPoleEnabled && moving); + } + + /** 根据胶囊碰撞修正后的实际位移更新水平预测速度。 */ + private updatePredictionVelocity(delta: number): void { + const capsule = this.player?.playerCapsule; + if (!capsule) return; + + if (!this.hasPreviousCapsulePosition || delta <= 0) { + this.previousCapsulePosition.copy(capsule.position); + this.hasPreviousCapsulePosition = true; + return; + } + + // 实际位移包含墙体阻挡和台阶推出,比单独读取输入目标速度更适合作为落脚预测依据。 + const measured = this.tmpV6 + .subVectors(capsule.position, this.previousCapsulePosition) + .multiplyScalar(1 / delta) + .setY(0); + const controllerVelocity = this.player?.getVelocity?.(); + if (controllerVelocity) { + // 少量混入控制器速度,改善刚起步时实际位移样本过小造成的预测滞后。 + measured.lerp(this.tmpV7.copy(controllerVelocity).setY(0), 0.15); + } + + const alpha = 1 - Math.exp(-12 * delta); + this.predictionVelocity.lerp(measured, alpha); + this.previousCapsulePosition.copy(capsule.position); + } + + // 构建脚步相位库。具体采样和过滤逻辑在 footPhase.ts 中。 + private buildFootPhaseDatabase(): void { + const model = this.player?.playerModel; + const clips = this.player?.animation?.clips ?? []; + this.footPhaseClips = buildFootPhaseDatabase( + model, + clips, + this.legs, + this.footPhaseOptions, + name => this.isLocomotionClipName(name), + ); + + if (this.debug && this.footPhaseClips.size === 0) { + console.warn("[FootIK] 没有生成脚步相位数据,请确认移动动画和脚骨骼配置。"); + } + } + + // 根据当前动画播放时间更新左右脚相位。这里只同步状态,不直接驱动 IK。 + private updateFootPhaseRuntime(): void { + const action = this.player?.animation?.state; + if (!action) { + this.footPhaseState.clipName = ""; + return; + } + const clip = action.getClip(); + if (clip.duration <= 0) return; + + const data = this.footPhaseClips.get(clip.name); + this.footPhaseState.clipName = clip.name; + this.footPhaseState.normalizedTime = ((action.time / clip.duration) % 1 + 1) % 1; + + if (!data) { + this.footPhaseState.left = createFootPhaseRuntimeState(); + this.footPhaseState.right = createFootPhaseRuntimeState(); + return; + } + + const runtime = sampleFootPhaseRuntime(data, this.footPhaseState.normalizedTime, clip.duration); + this.footPhaseState.left = runtime.left; + this.footPhaseState.right = runtime.right; + } + + // 计算单脚 IK 目标、权重和骨盆偏移。 + private updateFoot(side: FootIKSide, delta: number, moving: boolean): void { + const leg = this.legs[side]; + if (!isReadyLeg(leg)) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV10); + leg.hasPelvisTarget = false; + const phase = this.footPhaseState?.[side]; + const phasePlanted = moving && !!phase?.planted; + const nextPlanted = !moving || phasePlanted; + const wasPlanted = leg.planted; + const startedSwing = wasPlanted && !nextPlanted; + const previousWeight = leg.weight; + const wasMovePenetrating = leg.movePenetrating; + leg.planted = nextPlanted; + + if (this.predictivePlacement) { + this.updatePredictiveFoot( + leg, + phase, + footWorld, + startedSwing, + delta, + ); + } else if (leg.predictive.mode !== "none") { + resetPredictiveFootState(leg.predictive); + } + + // 改变水平落点的预测结果在支撑阶段继续使用原落点,避免切回动画时跳变。 + if (leg.planted && this.resolvePredictivePlantedFoot(leg, footWorld, delta)) { + leg.heightSupport = null; + return; + } + + // 预测摆动自己做四角防穿透,并从上一帧反应式权重/高度阻尼过去。 + if (!leg.planted && this.resolvePredictiveSwingFoot(leg, footWorld, phase, delta)) { + leg.heightSupport = null; + return; + } + // 偏移落点留下的支撑残差在新摆腿前段连续归还给动画。 + if (!leg.planted && this.resolvePredictiveReleaseFoot(leg, footWorld)) { + leg.heightSupport = null; + return; + } + + const hit = this.castBestFootGround(leg); + this.transportFootHeight(leg, hit); + + // 脚底没有命中地面时,本帧完全交还给原动画。 + if (!hit) { + if (!leg.planted) { + this.releaseSwingFoot( + leg, + footWorld, + startedSwing || wasMovePenetrating, + previousWeight, + delta, + ); + this.updateFootDebug(leg, footWorld); + return; + } + leg.plantedWeight = 0; + leg.movePenetrating = false; + leg.weight = 0; + leg.offsetY = 0; + this.updateFootDebug(leg, footWorld); + return; + } + + leg.hitNormal.copy(hit.normal); + leg.hitPoint.copy(hit.point); + + const groundOffset = this.getFootGroundOffset(leg); + const liftAmount = Math.max(0, groundOffset); + const pelvisTargetOffset = this.getAlignedFootTargetOffset(leg, footWorld); + // 支撑脚下探超过最大范围时,本帧交还给原动画。 + if (leg.planted && pelvisTargetOffset < -this.maxFootDrop) { + leg.weight = 0; + leg.plantedWeight = 0; + leg.offsetY = 0; + leg.movePenetrating = false; + leg.smoothedTarget.copy(footWorld); + this.updateFootDebug(leg, hit.point); + return; + } + const targetOffset = leg.planted + ? pelvisTargetOffset + : Math.max(0, pelvisTargetOffset); + // 命中面超过脚部最大上抬范围时,本帧交还给原动画。 + if (targetOffset > this.maxFootRaise) { + if (!leg.planted) { + this.releaseSwingFoot( + leg, + footWorld, + startedSwing || wasMovePenetrating, + previousWeight, + delta, + ); + this.updateFootDebug(leg, hit.point); + return; + } + leg.weight = 0; + leg.plantedWeight = 0; + leg.offsetY = 0; + leg.movePenetrating = false; + leg.smoothedTarget.copy(footWorld); + this.updateFootDebug(leg, hit.point); + return; + } + if ( + pelvisTargetOffset <= this.maxFootRaise + && pelvisTargetOffset >= -this.maxFootDrop + ) { + leg.hasPelvisTarget = true; + leg.pelvisTarget + .copy(footWorld) + .addScaledVector(this.up, pelvisTargetOffset); + } + + // 支撑脚允许在限制范围内双向贴地;摆动脚只在穿透时上抬。 + // 支撑状态和防穿透独立触发:任意相位陷入地面都会立即抬起。 + // 防穿透只负责上抬,不参与脚步锁定状态。 + leg.movePenetrating = liftAmount > this.moveLiftThreshold; + + const wantedPlantedWeight = leg.planted ? 1 : 0; + if (startedSwing || (wasMovePenetrating && !leg.movePenetrating)) { + // 离地或结束防穿透的首帧继承上一帧实际输出,避免暴露较低的内部相位权重。 + leg.plantedWeight = previousWeight; + } else { + leg.plantedWeight = MathUtils.damp( + leg.plantedWeight ?? 0, + wantedPlantedWeight, + 10, + delta, + ); + } + + // 支撑状态短时间渐入/渐出;穿透修正仍然立即介入,避免脚陷入地面。 + if (leg.movePenetrating) { + leg.weight = 1; + } else if (leg.plantedWeight > 0.001) { + leg.weight = leg.plantedWeight; + } else { + // 移动 swing 脚听原动画,IK 不生效。 + leg.weight = 0; + } + + // 支撑脚按参数追贴地高度;摆动脚保留上一帧修正并渐出。 + // 摆动脚陷入地面时按参数上抬高度。 + if (leg.planted) { + this.moveFootHeightAlongUp( + leg, + footWorld, + targetOffset, + previousWeight, + delta, + this.getHeightSpeed(this.plantedHeightSpeed), + ); + } else if (leg.movePenetrating) { + this.moveFootHeightAlongUp( + leg, + footWorld, + targetOffset, + previousWeight, + delta, + this.getHeightSpeed(this.penetrationLiftSpeed), + ); + } else if (!startedSwing) { + leg.offsetY = MathUtils.damp(leg.offsetY, 0, 10, delta); + if (Math.abs(leg.offsetY) < this.snapEpsilon) leg.offsetY = 0; + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } else { + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + // 保存接触点的局部坐标,下帧只同步平台自身的移动、旋转和缩放。 + if (hit.object) { + leg.heightSupport = hit.object; + leg.heightSupportLocal.copy(hit.point); + hit.object.worldToLocal(leg.heightSupportLocal); + leg.heightSupportWorldY = hit.point.y; + leg.heightSupportScale = this.appliedScale; + } + this.updateFootDebug(leg, hit.point); + } + + /** 先把历史高度搬到本帧支撑面,再对剩余贴地误差限速。 */ + private transportFootHeight(leg: ReadyFootIKLeg, hit: FootIKGroundHit | null): void { + if (leg.heightSupport && leg.heightSupport === hit?.object) { + const anchor = leg.heightSupport.localToWorld(this.tmpV6.copy(leg.heightSupportLocal)); + // 绕接触点缩放脚离地的高度。 + const scaleRatio = this.appliedScale / leg.heightSupportScale; + leg.smoothedTarget.y = anchor.y + + (leg.smoothedTarget.y - leg.heightSupportWorldY) * scaleRatio; + } + // 无命中、超出范围或切到预测线路后,不继续沿用旧平台。 + leg.heightSupport = null; + } + + /** 当前是跑步动画时高度速度不限制。 */ + private getHeightSpeed(speed: number): number { + const clipName = this.player?.animation.state?.getClip().name ?? ""; + const runAnim = this.player?.playerModelConfig?.runAnim; + return runAnim && clipName === runAnim ? Infinity : speed; + } + + /** 从已同步支撑面变换的历史高度沿 up 限速移动;XZ 跟当前动画脚。 */ + private moveFootHeightAlongUp( + leg: ReadyFootIKLeg, + footWorld: Vector3, + wantedOffset: number, + previousWeight: number, + delta: number, + speed: number, + ): void { + const footAlongUp = footWorld.dot(this.up); + const wantedAlongUp = footAlongUp + wantedOffset; + if (!Number.isFinite(speed)) { + leg.offsetY = wantedOffset; + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + return; + } + + const hasPrevious = previousWeight > 0.001 + || Math.abs(leg.offsetY) > this.snapEpsilon; + const previousAlongUp = hasPrevious + ? leg.smoothedTarget.dot(this.up) + : footAlongUp; + const movedAlongUp = previousAlongUp + MathUtils.clamp( + wantedAlongUp - previousAlongUp, + -speed * delta, + speed * delta, + ); + // 目标有效不代表历史仍有效;缩放或根节点突变后也必须遵守当前脚部范围。 + leg.offsetY = MathUtils.clamp(movedAlongUp - footAlongUp, -this.maxFootDrop, this.maxFootRaise); + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + // 摆动阶段命中失效时继续释放已有支撑修正,避免无命中分支把权重和目标直接清零。 + private releaseSwingFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + inheritOutputWeight: boolean, + previousWeight: number, + delta: number, + ): void { + leg.movePenetrating = false; + leg.plantedWeight = inheritOutputWeight + ? previousWeight + : MathUtils.damp(leg.plantedWeight, 0, 10, delta); + if (leg.plantedWeight < 0.001) leg.plantedWeight = 0; + leg.weight = leg.plantedWeight; + + if (!inheritOutputWeight) { + leg.offsetY = MathUtils.damp(leg.offsetY, 0, 10, delta); + if (Math.abs(leg.offsetY) < this.snapEpsilon) leg.offsetY = 0; + } + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + /** 摆动期间持续更新预测线路,并在末段锁定最终落脚结果。 */ + private updatePredictiveFoot( + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState | undefined, + footWorld: Vector3, + startedSwing: boolean, + delta: number, + ): void { + const state = leg.predictive; + + // 偏移落点进入支撑后转为 planted 锚点;中心落点则交还给普通贴地 IK。 + if (leg.planted) { + if (state.mode === "active" && state.usesOffsetLanding) { + state.mode = "planted"; + state.predictionWeight = 1; + state.debugTrajectoryVisible = false; + } else if (state.mode !== "planted" && state.mode !== "none") { + resetPredictiveFootState(state); + } + return; + } + + if (startedSwing) { + // 上一支撑若改过水平落点,把残差带到新摆腿前段再还给动画。 + const preserveRelease = state.mode === "planted" + && state.usesOffsetLanding; + if (preserveRelease) { + this.tmpV1.subVectors( + state.trajectoryCurrentTarget, + footWorld, + ); + } + resetPredictiveFootState(state); + state.mode = "tracking"; + if ( + preserveRelease + && this.tmpV1.lengthSq() > this.snapEpsilon * this.snapEpsilon + ) { + state.releaseOffset.copy(this.tmpV1); + state.releaseStartProgress = phase?.progress ?? 0; + state.releaseActive = true; + } + } + + if (!phase?.nextLanding || !Number.isFinite(phase.timeToLand)) { + resetPredictiveFootState(state); + return; + } + + if (state.mode === "none") { + // 运行中开启预测开关时也允许从当前摆动相位接管,不必等待下一个完整步态周期。 + state.mode = "tracking"; + } + + state.trajectoryProgress = Math.max( + state.trajectoryProgress, + phase.progress, + ); + + const model = this.player?.playerModel; + if (!model) return; + // 用剩余落地时间和当前胶囊速度,把动画落点推到预计踩实时的世界位置。 + const predictionTime = Math.min(phase.timeToLand, this.predictionHorizon); + state.animatedLanding + .copy(phase.nextLanding.localPosition) + .applyMatrix4(model.matrixWorld) + .addScaledVector(this.predictionVelocity, predictionTime); + state.animatedLandingRotation + .copy(model.getWorldQuaternion(this.tmpQ4)) + .multiply(phase.nextLanding.localRotation); + + // 动画中心落点始终跟随当前预测结果,落地时水平修正自然收敛为零。 + if (state.mode === "active" && !state.usesOffsetLanding) { + this.followAnimatedLandingTarget(leg); + } + + // 摆腿末段锁定支撑面,避免落地前一帧换到另一级台阶。 + if (state.trajectoryProgress >= PREDICTION_LOCK_PROGRESS) { + return; + } + + state.probeElapsed += 1; + // 射线按帧间隔更新,间隔内继续用上一份落点和当前动画脚解目标。 + if ( + this.predictionProbeFrames <= 0 + || state.probeElapsed >= this.predictionProbeFrames + ) { + state.probeElapsed = 0; + const ctx = this.getPredictiveContext(model); + const { candidate, centerSupport } = findPredictiveFootCandidates( + ctx, + leg, + phase, + state.animatedLanding, + state.animatedLandingRotation, + ); + if (!centerSupport) { + this.suppressFlatPredictiveFoot(leg); + return; + } + + const trajectoryStart = state.mode === "active" + ? state.trajectoryCurrentTarget + : state.releaseActive + ? this.predictiveScratch.trajectoryStart + .copy(footWorld) + .addScaledVector( + state.releaseOffset, + this.getPredictiveReleaseWeight(state), + ) + : this.getPredictiveSwingStart(leg, footWorld); + const trajectoryStartProgress = state.mode === "active" + ? state.trajectoryStartProgress + : state.trajectoryProgress; + + const { enter: demandEnter, exit: demandExit } = + getPredictiveDemandThresholds( + this.appliedScale, + this.snapEpsilon, + this.predictiveScratch, + ); + // 已激活时用更低的退出阈值,减少台阶边缘反复进出预测。 + const demandThreshold = state.mode === "active" + ? demandExit + : demandEnter; + + if (candidate) { + const plan = planPredictiveFootTrajectory( + ctx, + leg, + phase, + footWorld, + trajectoryStart, + trajectoryStartProgress, + candidate, + ); + const planeLift = getPredictivePlaneDistance(ctx, candidate); + const swingLift = plan?.terrainDemand ?? 0; + state.debugPlaneLift = planeLift; + state.debugSwingLift = swingLift; + const terrainDemand = getPredictiveTerrainDemand( + planeLift, + swingLift, + ); + + // 落点上台和摆腿途中凸起都不够高时,保持动画和反应式 IK。 + if (terrainDemand < demandThreshold) { + this.suppressFlatPredictiveFoot(leg); + return; + } + + if (!plan) { + state.debugCandidates[0].valid = false; + this.suppressFlatPredictiveFoot(leg); + return; + } + + acceptPredictiveFootCandidate( + state, + candidate, + this.predictiveScratch, + ); + selectPredictiveDebugCandidate(state, 0); + plan.terrainDemand = terrainDemand; + this.updatePredictiveFootTrajectory( + leg, + footWorld, + trajectoryStart, + state.trajectoryProgress, + plan, + demandExit, + demandEnter, + ); + return; + } + + // 鞋底可站但脚骨目标未通过抬降/腿长约束时,不启用预测线路。 + const planeLift = getPredictivePlaneDistance(ctx, centerSupport); + state.debugPlaneLift = planeLift; + state.debugSwingLift = 0; + this.suppressFlatPredictiveFoot(leg); + } + } + + /** 组装预测候选搜索和轨迹规划共用的配置与回调。 */ + private getPredictiveContext(model: Object3D): PredictiveTrajectoryContext { + return { + up: this.up, + appliedScale: this.appliedScale, + snapEpsilon: this.snapEpsilon, + sampleRayOriginY: this.sampleRayOriginY, + maxPredictionCorrection: this.maxPredictionCorrection, + predictionMinNormalY: this.predictionMinNormalY, + maxFootRaise: this.maxFootRaise, + maxFootDrop: this.maxFootDrop, + pelvisKneeBend: this.pelvisKneeBend, + footAlignWeight: this.footAlignWeight, + predictionHorizon: this.predictionHorizon, + predictionVelocity: this.predictionVelocity, + scratch: this.predictiveScratch, + debug: this.debug, + castGroundFrom: (x, y, z) => this.castGroundFrom(x, y, z), + castCapsuleGround: () => this.castCapsuleGround(), + getConstrainedFootAlign: (normal, alignWeight, target) => + this.getConstrainedFootAlign(normal, alignWeight, target), + mergeCoplanarHits: (hits, highest, supportPoint, supportNormal) => + this.mergeCoplanarGroundHits(hits, highest, supportPoint, supportNormal), + getSafeLegReach: (upperLen, lowerLen, kneeBend) => + this.getSafeLegReach(upperLen, lowerLen, kneeBend), + model, + footPhaseClips: this.footPhaseClips, + clipName: this.footPhaseState.clipName, + normalizedTime: this.footPhaseState.normalizedTime, + swingClearance: this.swingClearance, + maxPredictionClearance: this.maxPredictionClearance, + }; + } + + // 地形需求退出阈值后立即交还动画和普通 Foot IK。 + private suppressFlatPredictiveFoot(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + state.mode = "tracking"; + state.score = Infinity; + state.supportObject = null; + state.trajectoryStartOffset.set(0, 0, 0); + state.trajectoryCurrentTarget.set(0, 0, 0); + state.trajectoryStartProgress = state.trajectoryProgress; + state.trajectoryClearance = 0; + state.predictionWeight = 0; + state.usesOffsetLanding = false; + state.debugTrajectoryVisible = false; + } + + /** 更新预测线路,并保持重规划首帧的世界空间目标连续。 */ + private updatePredictiveFootTrajectory( + leg: ReadyFootIKLeg, + animationFoot: Vector3, + trajectoryStart: Vector3, + progress: number, + plan: PredictiveTrajectoryPlan, + demandExit: number, + demandEnter: number, + ): void { + const state = leg.predictive; + const wasActive = state.mode === "active"; + + if (!wasActive) { + state.mode = "active"; + // 记下启用瞬间脚相对动画的偏移,后续按剩余摆腿把这份残差渐隐掉。 + state.trajectoryStartOffset.subVectors( + trajectoryStart, + animationFoot, + ); + state.trajectoryCurrentTarget.copy(trajectoryStart); + state.trajectoryStartProgress = MathUtils.clamp(progress, 0, 1); + } else { + const localProgress = getPredictiveLocalProgress( + progress, + state.trajectoryStartProgress, + ); + const warpAlpha = MathUtils.smoothstep(localProgress, 0, 1); + const remainingStartWeight = 1 - warpAlpha; + if (remainingStartWeight > 1e-4) { + const landingCorrection = this.tmpV1.subVectors( + state.landingTarget, + state.animatedLanding, + ); + // 用当前输出反推起点残差,使重规划后这一帧的世界目标不变。 + const resolvedWithoutStart = this.tmpV2 + .copy(animationFoot) + .addScaledVector(landingCorrection, warpAlpha) + .addScaledVector( + this.up, + plan.clearance + * getPredictiveClearanceShape(localProgress), + ); + state.trajectoryStartOffset + .subVectors( + state.trajectoryCurrentTarget, + resolvedWithoutStart, + ) + .multiplyScalar(1 / remainingStartWeight); + } else { + state.trajectoryStartOffset.set(0, 0, 0); + } + } + + state.trajectoryClearance = plan.clearance; + // 需求刚过退出阈值时权重接近 0,接近进入阈值时才满权。 + state.predictionWeight = MathUtils.smoothstep( + plan.terrainDemand, + demandExit, + demandEnter, + ); + if (this.debug) { + for (let i = 0; i < state.debugTrajectory.length; i++) { + state.debugTrajectory[i].copy(plan.points[i]); + } + } + this.rebuildPredictiveDebugTrajectory(leg); + } + + /** 根据当前融合状态刷新预测摆动轨迹的调试可见性。 */ + private rebuildPredictiveDebugTrajectory(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + state.debugTrajectoryVisible = this.debug + && Number.isFinite(state.score) + && state.mode === "active" + && state.predictionWeight > 0.001; + } + + /** 让动画中心落点持续跟随实际落地帧,并保持当前支撑平面的高度关系。 */ + private followAnimatedLandingTarget(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + const previousTarget = this.debug + ? this.tmpV1.copy(state.landingTarget) + : null; + const deltaX = state.animatedLanding.x - state.landingTarget.x; + const deltaZ = state.animatedLanding.z - state.landingTarget.z; + state.landingTarget.x = state.animatedLanding.x; + state.landingTarget.z = state.animatedLanding.z; + // 水平跟着动画走时,沿支撑平面改 Y,避免脚骨离开已探测的地面。 + if (state.landingNormal.y > 0.18) { + state.landingTarget.y -= ( + state.landingNormal.x * deltaX + + state.landingNormal.z * deltaZ + ) / state.landingNormal.y; + } + + if (previousTarget) { + shiftPredictiveDebugTrajectory( + state.debugTrajectory, + this.tmpV2.subVectors(state.landingTarget, previousTarget), + ); + } + } + + /** 支撑阶段沿用发生水平修正的预测落点,直到下一次摆腿开始。 */ + private resolvePredictivePlantedFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + delta: number, + ): boolean { + const state = leg.predictive; + if (state.mode !== "planted" || !state.usesOffsetLanding) return false; + + refreshPredictiveSupportAnchor(leg, this.predictiveScratch, this.debug); + leg.smoothedTarget.copy(state.landingTarget); + // 骨盆使用未裁剪落点,与普通 Foot IK 一样先按可达性下拉/上抬,再限制脚目标。 + leg.hasPelvisTarget = true; + leg.pelvisTarget.copy(leg.smoothedTarget); + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + leg.hitPoint.copy(state.supportPoint); + leg.hitNormal.copy(state.landingNormal); + leg.supportPoint.copy(state.supportPoint); + leg.supportNormal.copy(state.landingNormal); + leg.movePenetrating = false; + leg.plantedWeight = MathUtils.damp( + Math.max(leg.plantedWeight, leg.weight), + 1, + 10, + delta, + ); + leg.weight = leg.plantedWeight; + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + this.updateFootDebug(leg, state.supportPoint); + return true; + } + + // 返回支撑残差在当前摆腿相位中尚未释放的权重。 + private getPredictiveReleaseWeight(state: PredictiveFootState): number { + if (!state.releaseActive) return 0; + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.releaseStartProgress, + ); + return 1 - MathUtils.smoothstep( + localProgress, + 0, + PREDICTION_RELEASE_PROGRESS, + ); + } + + /** 在新摆腿前段逐渐释放上一支撑落点相对动画脚的残差。 */ + private resolvePredictiveReleaseFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + ): boolean { + const state = leg.predictive; + const releaseWeight = this.getPredictiveReleaseWeight(state); + if (releaseWeight <= 0.001) { + state.releaseOffset.set(0, 0, 0); + state.releaseActive = false; + return false; + } + + leg.smoothedTarget + .copy(footWorld) + .addScaledVector(state.releaseOffset, releaseWeight); + leg.hasPelvisTarget = true; + leg.pelvisTarget.copy(leg.smoothedTarget); + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + leg.movePenetrating = false; + leg.plantedWeight = 0; + leg.weight = 1; + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + this.updateFootDebug(leg, leg.smoothedTarget); + return true; + } + + /** 预测摆腿起点:已有反应式输出时从抬过的脚开始。 */ + private getPredictiveSwingStart( + leg: ReadyFootIKLeg, + footWorld: Vector3, + ): Vector3 { + if (leg.weight > 0.001 || Math.abs(leg.offsetY) > this.snapEpsilon) { + return this.predictiveScratch.trajectoryStart.copy(leg.smoothedTarget); + } + return footWorld; + } + + /** 根据地形需求权重统一融合动画轨迹、预测落点和摆动净空。 */ + private resolvePredictiveSwingFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + phase: FootPhaseRuntimeState | undefined, + delta: number, + ): boolean { + if ( + !this.predictivePlacement + || !phase + || leg.planted + ) { + return false; + } + + const state = leg.predictive; + + if ( + state.mode !== "active" + || !Number.isFinite(state.score) + || state.predictionWeight <= 0.001 + ) { + return false; + } + + // 移动平台每帧刷新当前预测线路的世界空间落点。 + refreshPredictiveSupportAnchor(leg, this.predictiveScratch, this.debug); + + // 当前预测落点相对动画预计落点的世界空间修正。 + const landingCorrection = this.tmpV1.subVectors( + state.landingTarget, + state.animatedLanding, + ); + + // 把启用后的剩余摆腿阶段重新映射到 0 到 1。 + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.trajectoryStartProgress, + ); + + // 平滑渐隐重规划起点偏移,并渐入落点修正。 + const warpAlpha = MathUtils.smoothstep(localProgress, 0, 1); + const previousAlongUp = ( + leg.weight > 0.001 || Math.abs(leg.offsetY) > this.snapEpsilon + ) + ? leg.smoothedTarget.dot(this.up) + : footWorld.dot(this.up); + + // 保留动画摆腿轨迹,只叠加连续起点、预测落点和地形净空修正。 + leg.smoothedTarget + .copy(footWorld) + .addScaledVector( + state.trajectoryStartOffset, + 1 - warpAlpha, + ) + .addScaledVector( + landingCorrection, + warpAlpha, + ) + .addScaledVector( + this.up, + state.trajectoryClearance + * getPredictiveClearanceShape(localProgress), + ); + + // 按当前脚掌旋转和四角实际位置上抬,和反应式摆动防穿透同一套。 + const hit = this.castBestFootGround(leg); + if (hit) { + const groundOffset = Math.max(0, this.getFootGroundOffset(leg)); + if (groundOffset <= this.maxFootRaise) { + leg.smoothedTarget.y = Math.max( + leg.smoothedTarget.y, + footWorld.y + groundOffset, + ); + } + } else { + leg.hitPoint.copy(state.supportPoint); + leg.hitNormal.copy(state.landingNormal); + leg.supportPoint.copy(state.supportPoint); + leg.supportNormal.copy(state.landingNormal); + } + + // 骨盆使用当前帧轨迹目标,不能传尚未到达的最终落点。 + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + this.dampPredictiveSwingOutput( + leg, + footWorld, + previousAlongUp, + state.predictionWeight, + delta, + ); + leg.pelvisTarget.copy(leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + + if (state.releaseActive) { + state.releaseOffset.set(0, 0, 0); + state.releaseActive = false; + } + + leg.hasPelvisTarget = true; + leg.movePenetrating = false; + + this.rebuildPredictiveDebugTrajectory(leg); + this.updateFootDebug(leg, hit?.point ?? state.supportPoint); + return true; + } + + /** 从上一帧反应式输出阻尼到本帧预测目标。 */ + private dampPredictiveSwingOutput( + leg: ReadyFootIKLeg, + footWorld: Vector3, + previousAlongUp: number, + wantedWeight: number, + delta: number, + ): void { + const wantedAlongUp = leg.smoothedTarget.dot(this.up); + const chase = wantedWeight > 0.001; + const damp = chase ? PREDICTIVE_SWING_CHASE_DAMP : PREDICTIVE_SWING_FADE_DAMP; + let dampedAlongUp = MathUtils.damp( + previousAlongUp, + wantedAlongUp, + damp, + delta, + ); + if (Math.abs(dampedAlongUp - wantedAlongUp) < this.snapEpsilon) { + dampedAlongUp = wantedAlongUp; + } + leg.smoothedTarget.addScaledVector( + this.up, + dampedAlongUp - wantedAlongUp, + ); + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + + leg.weight = MathUtils.damp(leg.weight, wantedWeight, damp, delta); + if (leg.weight < 0.001) leg.weight = 0; + + leg.plantedWeight = MathUtils.damp( + leg.plantedWeight, + 0, + PREDICTIVE_SWING_FADE_DAMP, + delta, + ); + if (leg.plantedWeight < 0.001) leg.plantedWeight = 0; + } + + /** 把脚的 up 部分旋到地面法线,并施加最大倾角和对齐权重。 */ + private getConstrainedFootAlign( + normal: Vector3, + alignWeight: number, + target: Quaternion, + ): Quaternion { + target.setFromUnitVectors(this.up, normal); + const realAngle = 2 * Math.acos(MathUtils.clamp(target.w, -1, 1)); + if (realAngle > this.maxFootTilt && realAngle > 1e-6) { + target.slerp(this.identityQ, 1 - this.maxFootTilt / realAngle); + } + target.slerp(this.identityQ, 1 - alignWeight); + return target; + } + + /** 只合并最高命中所在的近似共面点,避免跨台阶平均出不存在的斜面。 */ + private mergeCoplanarGroundHits( + hits: Array, + highest: FootIKGroundHit, + supportPoint: Vector3, + supportNormal: Vector3, + ): number { + const planeEpsilon = Math.max(1e-6, 2 * this.appliedScale); + supportPoint.set(0, 0, 0); + supportNormal.set(0, 0, 0); + let supportCount = 0; + for (const hit of hits) { + if (!hit) continue; + const planeDistance = Math.abs( + highest.normal.dot(this.tmpV5.copy(hit.point).sub(highest.point)), + ); + if (planeDistance > planeEpsilon || hit.normal.dot(highest.normal) < 0.95) continue; + supportPoint.add(hit.point); + supportNormal.add(hit.normal); + supportCount++; + } + if (supportCount > 0) { + supportPoint.multiplyScalar(1 / supportCount); + supportNormal.normalize(); + } + return supportCount; + } + + /** 按指定膝盖弯曲角反算双骨链的安全伸展距离。 */ + private getSafeLegReach(upperLen: number, lowerLen: number, kneeBend: number): number { + return Math.sqrt( + upperLen * upperLen + + lowerLen * lowerLen + + 2 * upperLen * lowerLen * Math.cos(kneeBend), + ); + } + + /** 将脚目标限制在当前髋部的安全可达区间;落地够不着时停在最大伸展上,避免硬踩。 */ + private clampPredictiveTargetToReach(leg: ReadyFootIKLeg, target: Vector3): void { + const hip = leg.upper.getWorldPosition(this.tmpV1); + const knee = leg.lower.getWorldPosition(this.tmpV2); + const foot = leg.foot.getWorldPosition(this.tmpV3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + const safeMaxReach = this.getSafeLegReach(upperLen, lowerLen, this.pelvisKneeBend); + const safeMinReach = this.getSafeLegReach(upperLen, lowerLen, this.maxKneeBend); + const hipToTarget = this.tmpV4.subVectors(target, hip); + const targetDistance = hipToTarget.length(); + if (targetDistance < 1e-6) return; + + const safeDistance = MathUtils.clamp(targetDistance, safeMinReach, safeMaxReach); + if (Math.abs(safeDistance - targetDistance) <= 1e-6) return; + target.copy(hip).addScaledVector(hipToTarget, safeDistance / targetDistance); + } + + // 对一只脚的四个虚拟脚底点分别向下射线,选择命中高度最高的点作为本帧调试/法线参考。 + // 平地浮点抖动时用滞回。 + private castBestFootGround(leg: ReadyFootIKLeg): FootIKGroundHit | null { + this.updateSoleSamples(leg); + + const samples = leg.soleSamples; + // 设计尺度约 1 个单位。 + const stickEpsilon = Math.max(1e-6, this.appliedScale); + const hits = this.sampleGroundHitSlots; + + let maxY = -Infinity; + let maxIndex = -1; + let maxHit: FootIKGroundHit | null = null; + + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + sample.hasHit = false; + hits[i] = null; + const hit = this.castGroundAtSample(sample.point); + if (!hit) continue; + sample.hasHit = true; + sample.hitPoint.copy(hit.point); + const stored = copyGroundHit(hit, this.sampleGroundHits[i]); + hits[i] = stored; + if (stored.point.y > maxY) { + maxY = stored.point.y; + maxIndex = i; + maxHit = stored; + } + } + + if (maxIndex < 0 || !maxHit) { + leg.bestGroundSampleIndex = -1; + return null; + } + + let bestIndex = maxIndex; + let bestHit = maxHit; + const prev = leg.bestGroundSampleIndex; + if (prev >= 0 && prev < samples.length) { + const prevHit = hits[prev]; + if (prevHit && prevHit.point.y >= maxY - stickEpsilon) { + bestIndex = prev; + bestHit = prevHit; + } + } + + // 只合并最高命中所在的近似共面点,避免跨台阶时生成不存在的中间斜面。 + const supportCount = this.mergeCoplanarGroundHits( + hits, + bestHit, + leg.supportPoint, + leg.supportNormal, + ); + if (supportCount <= 0) { + leg.supportPoint.copy(bestHit.point); + leg.supportNormal.copy(bestHit.normal); + } + + leg.bestGroundSampleIndex = bestIndex; + leg.footSamplePoint.copy(samples[bestIndex].point); + this.bestGroundHit.point.copy(bestHit.point); + this.bestGroundHit.normal.copy(leg.supportNormal); + this.bestGroundHit.object = bestHit.object; + return this.bestGroundHit; + } + + // 基于初始化姿态,把脚底四个采样点固定到 foot 骨骼本地空间。 + private initSoleLocalSamples(leg: FootIKLeg): void { + if (!isReadyLeg(leg)) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + const toeWorld = leg.toe + ? leg.toe.getWorldPosition(this.tmpV2) + : this.tmpV2.copy(footWorld).add(this.tmpV3.set(0, 0, 1).applyQuaternion(leg.foot.getWorldQuaternion(this.tmpQ1)).setY(0).normalize().multiplyScalar(this.fakeToeExtend)); + + const forward = this.tmpV3.copy(toeWorld).sub(footWorld).setY(0); + const minFwdSq = Math.max(1e-8, 1e-4 * this.appliedScale * this.appliedScale); + if (forward.lengthSq() < minFwdSq) { + forward.set(0, 0, 1).applyQuaternion(leg.foot.getWorldQuaternion(this.tmpQ1)).setY(0); + } + forward.normalize(); + + const side = this.tmpV4.crossVectors(forward, this.up).normalize(); + const heelCenter = this.tmpV5.copy(footWorld).addScaledVector(forward, -this.soleHeelExtend); + const toeCenter = this.tmpV2.copy(toeWorld).addScaledVector(forward, this.soleToeExtend); + + heelCenter.y = toeCenter.y; + + leg.foot.updateMatrixWorld(true); + const samples = leg.soleSamples; + samples[0].point.copy(heelCenter).addScaledVector(side, this.soleHalfWidth); + samples[1].point.copy(heelCenter).addScaledVector(side, -this.soleHalfWidth); + samples[2].point.copy(toeCenter).addScaledVector(side, this.soleHalfWidth); + samples[3].point.copy(toeCenter).addScaledVector(side, -this.soleHalfWidth); + + // 把脚骨→鞋底厚度打进 foot-local,之后随骨骼世界缩放。 + for (const sample of samples) { + sample.point.y -= this.soleSkinThickness; + sample.local.copy(sample.point); + leg.foot.worldToLocal(sample.local); + } + leg.footSamplePoint.copy(heelCenter); + } + + // 把 foot-local 脚底四点转换到世界空间,供射线、穿透检测和调试使用。 + private updateSoleSamples(leg: FootIKLeg): void { + if (!isReadyLeg(leg)) return; + + leg.foot.updateMatrixWorld(true); + const samples = leg.soleSamples; + for (const sample of samples) { + sample.point.copy(sample.local).applyMatrix4(leg.foot.matrixWorld); + } + + if (samples.length >= 2) { + leg.footSamplePoint.copy(samples[0].point).add(samples[1].point).multiplyScalar(0.5); + } + } + + // 四个脚底采样点中取最高地面相对脚底的垂直偏移。 + private getFootGroundOffset(leg: ReadyFootIKLeg): number { + let offset = -Infinity; + for (const sample of leg.soleSamples) { + if (!sample.hasHit) continue; + offset = Math.max(offset, sample.hitPoint.y - sample.point.y); + } + + return Number.isFinite(offset) ? offset : 0; + } + + // 先把脚掌旋到支撑面方向,再计算四个脚底点同时不穿透时所需的踝骨垂直偏移。 + private getAlignedFootTargetOffset(leg: ReadyFootIKLeg, footWorld: Vector3): number { + const supportNormal = leg.supportNormal; + if (supportNormal.y <= 0.18) return this.getFootGroundOffset(leg); + + const footWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + const alignQ = this.getConstrainedFootAlign( + supportNormal, + this.footAlignWeight, + this.tmpQ2, + ); + const targetWorldQ = alignQ.multiply(footWorldQ); + const worldScale = leg.foot.getWorldScale(this.tmpV4); + + let targetOffset = -Infinity; + for (const sample of leg.soleSamples) { + const alignedPoint = this.tmpV5 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(targetWorldQ) + .add(footWorld); + const planeOffset = supportNormal.dot( + this.tmpV3.copy(leg.supportPoint).sub(alignedPoint), + ) / supportNormal.y; + targetOffset = Math.max(targetOffset, planeOffset); + } + + return Number.isFinite(targetOffset) ? targetOffset : this.getFootGroundOffset(leg); + } + + // 判断当前动画是否属于移动类动画。 + private isLocomotion(): boolean { + const name = this.player?.animation.state?.getClip().name ?? ""; + return this.isLocomotionClipName(name); + } + + // 统一判断脚步相位采样和运行时移动状态:直接使用 playerModelConfig 中的移动动画名。 + private isLocomotionClipName(name: string): boolean { + if (!name) return false; + const config = this.player?.playerModelConfig; + if (!config) return false; + return [ + config.walkAnim, + config.runAnim, + config.leftWalkAnim, + config.rightWalkAnim, + config.backwardAnim, + ].filter((value): value is string => !!value).includes(name); + } + + // 从骨骼采样点上方发射射线。 + private castGroundAtSample(sampleWorld: Vector3): FootIKGroundHit | null { + return this.castGroundFrom(sampleWorld.x, sampleWorld.y + this.sampleRayOriginY, sampleWorld.z); + } + + // 读取本帧碰撞网格,供后续脚底射线共用。 + private refreshColliderMeshes(): void { + this.colliderMeshes = this.player?.getColliderMeshes() ?? this.colliderMeshes; + } + + // 优先读取移动系统最终采用的支撑。 + private castCapsuleGround(): FootIKGroundHit | null { + const capsule = this.player?.playerCapsule; + if (!capsule) return null; + + const activeBody = this.player?.getActiveDynamicBody?.(); + const ignoreSupport = activeBody?.kind === "sphere"; + + const getGroundSupport = this.player?.getGroundSupport; + if (getGroundSupport && !ignoreSupport) { + const support = getGroundSupport.call(this.player); + if (!support) return null; + this.capsuleHit.point.copy(support.point); + this.capsuleHit.normal.copy(support.normal); + this.capsuleHit.object = undefined; + return this.capsuleHit; + } + + const hit = this.castGroundFrom( + capsule.position.x, + capsule.position.y, + capsule.position.z, + ); + return hit ? copyGroundHit(hit, this.capsuleHit) : null; + } + + // 从指定世界坐标向下检测可踩踏地面,结果写入复用命中对象。 + private castGroundFrom(x: number, y: number, z: number): FootIKGroundHit | null { + this.raycaster.ray.origin.set(x, y, z); + const hits = this.raycaster.intersectObjects(this.colliderMeshes, false); + let foundMesh = false; + for (let i = 0; i < hits.length; i++) { + const meshHit = hits[i]; + if (this.getMeshWorldHitNormal(meshHit, this.groundHit.normal).y <= 0.18) continue; + this.groundHit.point.copy(meshHit.point); + this.groundHit.object = meshHit.object; + foundMesh = true; + break; + } + + const dynamicHit = this.player?.raycastDynamicGround?.( + this.raycaster.ray.origin, + 0.18, + FOOT_IK_IGNORED_DYNAMIC_KINDS, + ); + if (!dynamicHit) return foundMesh ? this.groundHit : null; + const distance = y - dynamicHit.point.y; + if (distance < this.raycaster.near || distance > this.raycaster.far) { + return foundMesh ? this.groundHit : null; + } + if (foundMesh && this.groundHit.point.y >= dynamicHit.point.y) return this.groundHit; + this.groundHit.point.copy(dynamicHit.point); + this.groundHit.normal.copy(dynamicHit.normal); + this.groundHit.object = dynamicHit.body?.mesh; + return this.groundHit; + } + + // 将 mesh 射线命中的局部法线转换为世界空间法线。 + private getMeshWorldHitNormal(hit: Intersection, target: Vector3): Vector3 { + target.copy(hit.face?.normal ?? this.up); + this.normalMatrix.getNormalMatrix(hit.object.matrixWorld); + target.applyMatrix3(this.normalMatrix).normalize(); + return target; + } + + // 根据双脚高台面和脚目标可达性对骨盆做平滑补偿。 + private applyPelvis(delta: number): void { + if (!this.hips) return; + + if (this.pelvisOffsetOverride !== null) { + this.pelvisOffset = this.pelvisOffsetOverride; + } else { + // 双脚都高于胶囊时按较低脚上抬;其余情况只补偿超出腿长的下沉距离。 + const raiseOffset = this.getRequiredPelvisRaise(); + const leftDrop = isReadyLeg(this.legs.left) + ? this.getRequiredPelvisDrop(this.legs.left) + : 0; + const rightDrop = isReadyLeg(this.legs.right) + ? this.getRequiredPelvisDrop(this.legs.right) + : 0; + const reachOffset = MathUtils.clamp( + Math.min(leftDrop, rightDrop, 0), + -this.maxPelvisDrop, + 0, + ); + const wantedWorldOffset = raiseOffset > 0 + ? raiseOffset + : Math.max(reachOffset, this.getSupportLimitedPelvisDrop()); + + this.pelvisOffset = MathUtils.damp(this.pelvisOffset, wantedWorldOffset, 12, delta); + } + if (Math.abs(this.pelvisOffset) < this.snapEpsilon) return; + + const parent = this.hips.parent; + if (!parent) return; + + this.capture(this.hips); + // 骨盆父级可能带有轴向旋转,先在世界 Y 轴生成目标,再转换回父级局部坐标。 + const targetWorld = this.hips + .getWorldPosition(this.tmpV1) + .addScaledVector(this.up, this.pelvisOffset); + parent.worldToLocal(targetWorld); + this.hips.position.copy(targetWorld); + this.hips.updateMatrixWorld(true); + } + + // 双脚都高于胶囊时,按较低脚与胶囊的高度差上抬骨盆。 + private getRequiredPelvisRaise(): number { + const left = this.legs.left; + const right = this.legs.right; + if (!isReadyLeg(left) || !isReadyLeg(right)) return 0; + + const leftSupportY = this.getLegSupportY(left); + const rightSupportY = this.getLegSupportY(right); + if (!Number.isFinite(leftSupportY) || !Number.isFinite(rightSupportY)) return 0; + + const capsuleHit = this.castCapsuleGround(); + if (!capsuleHit) return 0; + const heightDelta = Math.min(leftSupportY, rightSupportY) - capsuleHit.point.y; + if (heightDelta <= 0) return 0; + return MathUtils.clamp(heightDelta, 0, this.maxPelvisRaise); + } + + // 计算脚骨骼水平位置在当前支撑平面上对应的高度。 + private getLegSupportY(leg: ReadyFootIKLeg): number { + if (leg.supportNormal.y <= 0.18) return NaN; + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + return leg.supportPoint.y - ( + leg.supportNormal.x * (footWorld.x - leg.supportPoint.x) + + leg.supportNormal.z * (footWorld.z - leg.supportPoint.z) + ) / leg.supportNormal.y; + } + + // 脚下支撑面只限制骨盆最多下沉到哪里,不直接推动骨盆,避免中心射线命中错误表面时抬动模型。 + private getSupportLimitedPelvisDrop(): number { + const capsuleHit = this.castCapsuleGround(); + if (!capsuleHit) return -this.maxPelvisDrop; + + let lowestSupportDelta = 0; + let hasSupport = false; + for (const leg of Object.values(this.legs)) { + if (!isReadyLeg(leg) || !leg.hasPelvisTarget) continue; + const supportY = this.getLegSupportY(leg); + if (!Number.isFinite(supportY)) continue; + lowestSupportDelta = Math.min( + lowestSupportDelta, + supportY - capsuleHit.point.y, + ); + hasSupport = true; + } + + return hasSupport + ? MathUtils.clamp(lowestSupportDelta, -this.maxPelvisDrop, 0) + : -this.maxPelvisDrop; + } + + // 按双骨骼 IK 的最大伸展距离,反算脚目标进入可达范围所需的最小骨盆下沉量。 + private getRequiredPelvisDrop(leg: ReadyFootIKLeg): number { + if (!leg.hasPelvisTarget) return 0; + + const hip = leg.upper.getWorldPosition(this.tmpV1); + const knee = leg.lower.getWorldPosition(this.tmpV2); + const foot = leg.foot.getWorldPosition(this.tmpV3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + const maxReach = Math.sqrt( + upperLen * upperLen + + lowerLen * lowerLen + + 2 * upperLen * lowerLen * Math.cos(this.pelvisKneeBend), + ); + + const deltaX = leg.pelvisTarget.x - hip.x; + const deltaY = leg.pelvisTarget.y - hip.y; + const deltaZ = leg.pelvisTarget.z - hip.z; + const horizontalSq = deltaX * deltaX + deltaZ * deltaZ; + const maxReachSq = maxReach * maxReach; + if (horizontalSq + deltaY * deltaY <= maxReachSq || deltaY >= 0) return 0; + + // 水平距离已经超出腿长时,垂直下沉无法完全满足目标;仍下沉到与目标同高的位置以取得最短距离。 + if (horizontalSq >= maxReachSq) return deltaY; + + const verticalReach = Math.sqrt(maxReachSq - horizontalSq); + return Math.min(0, deltaY + verticalReach); + } + + /** 在预测摆腿末段逐渐把脚掌旋转到落点支撑面。 */ + private preAlignPredictiveLandingRotation( + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState | undefined, + ): void { + if (!phase) return; + + const state = leg.predictive; + + if ( + state.mode !== "active" + || leg.planted + || state.landingNormal.y <= 0.18 + ) { + return; + } + + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.trajectoryStartProgress, + ); + + // 启用后先保留动画旋转,进入末段后再逐渐贴合坡面。 + const rotationWeight = MathUtils.smoothstep( + localProgress, + PREDICTION_ROTATION_WARP_START, + 1, + ) * state.predictionWeight; + + if (rotationWeight <= 0.001) return; + + this.getConstrainedFootAlign( + state.landingNormal, + this.footAlignWeight, + this.tmpQ2, + ); + const targetWorldQ = this.tmpQ3 + .copy(this.tmpQ2) + .multiply(state.animatedLandingRotation); + + // 从当前动画旋转平滑过渡到预测落脚旋转。 + const currentWorldQ = + leg.foot.getWorldQuaternion( + this.tmpQ1, + ); + + currentWorldQ.slerp( + targetWorldQ, + rotationWeight, + ); + + const parent = leg.foot.parent; + if (!parent) return; + + const parentWorldQ = + parent.getWorldQuaternion( + this.tmpQ4, + ); + + this.capture(leg.foot); + + leg.foot.quaternion.copy( + parentWorldQ + .invert() + .multiply(currentWorldQ), + ); + + leg.foot.updateMatrixWorld(true); + } + + // 对指定腿执行 IK 求解并贴合脚掌。 + private applyLeg(side: FootIKSide, useStraightPole: boolean): void { + const leg = this.legs[side]; + if (!isReadyLeg(leg) || leg.weight <= 0.001) return; + + // 保存动画给出的 foot 世界旋转,普通 IK 解腿后按权重恢复该旋转。 + this.savedFootWorldQ.copy(leg.foot.getWorldQuaternion(this.tmpQ1)); + + const predictiveSwing = this.predictivePlacement + && !leg.planted + && leg.predictive.mode === "active"; + if (predictiveSwing) { + // 骨盆修正后再检查一次可达范围。 + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + leg.predictive.trajectoryCurrentTarget.copy(leg.smoothedTarget); + } else if (leg.planted && leg.hasPelvisTarget) { + // 落地目标用未裁剪的支撑面;髋更新后再裁到当前腿长,够不着时停在伸展球面上。 + if (!Number.isFinite(this.getHeightSpeed(this.plantedHeightSpeed))) { + leg.smoothedTarget.copy(leg.pelvisTarget); + } + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + } + + // 腿部位置修正完成后恢复动画脚掌旋转,避免位置 IK 改变脚尖朝向。 + this.solveLeg( + leg, + leg.smoothedTarget, + leg.weight, + useStraightPole ? 1 : 0, + ); + this.preserveFootWorldRotation(leg, this.savedFootWorldQ, leg.weight); + // 预测摆动脚在接近落地时才逐渐旋到支撑面。 + if (predictiveSwing) { + this.preAlignPredictiveLandingRotation( + leg, + this.footPhaseState?.[side], + ); + } + // 支撑脚离地后按剩余相位权重逐渐释放坡面旋转。 + // 普通摆动脚只在防穿透时贴合坡面,预测线路不额外接管脚掌旋转。 + if ( + leg.planted + || leg.movePenetrating + || leg.plantedWeight > 0.001 + ) { + this.alignFootToGround(leg); + } + if (leg.planted) { + this.correctPostAlignSoleContact(leg); + } + } + + // 抵消腿部 IK 对 foot 子骨骼造成的被动旋转,保留动画本来的脚掌朝向。 + private preserveFootWorldRotation( + leg: ReadyFootIKLeg, + animatedWorldQ: Quaternion, + weight: number, + ): void { + this.capture(leg.foot); + + const currentWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + currentWorldQ.slerp(animatedWorldQ, MathUtils.clamp(weight, 0, 1)); + + const parentWorldQ = leg.foot.parent?.getWorldQuaternion(this.tmpQ2); + if (!parentWorldQ) return; + leg.foot.quaternion.copy(parentWorldQ.invert().multiply(currentWorldQ)); + leg.foot.updateMatrixWorld(true); + } + + // 使用受约束的双骨骼 IK,让脚靠近目标点。 + private solveLeg( + leg: ReadyFootIKLeg, + target: Vector3, + weight: number, + straightPoleWeight = 0, + ): void { + let kneePlaneNormal: Vector3 | undefined; + if (straightPoleWeight > 0.001) { + // 移动时用胶囊局部 +X 作为角色前后平面的法线。 + // 静止时不传此约束,让 IK 保留 idle 动画本身的膝盖 pole。 + const capsule = this.player?.playerCapsule; + if (!capsule) return; + kneePlaneNormal = this.tmpV4 + .set(1, 0, 0) + .applyQuaternion(capsule.getWorldQuaternion(this.tmpQ3)) + .normalize(); + } + + solveTwoBoneIK(leg, target, weight, { + capture: bone => this.capture(bone), + minKneeBend: this.minKneeBend, + maxKneeBend: this.maxKneeBend, + kneePlaneNormal, + kneePlaneWeight: straightPoleWeight, + scratch: this.twoBoneIKScratch, + }); + } + + // 脚掌贴坡会绕 foot 骨骼旋转,旋转后重新校正脚底接触。 + private correctPostAlignSoleContact( + leg: ReadyFootIKLeg, + ): void { + if (leg.weight <= 0.001) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + this.updateSoleSamples(leg); + + let contactOffset = -Infinity; + let anyContactHit = false; + for (const sample of leg.soleSamples) { + sample.hasHit = false; + const hit = this.castGroundAtSample(sample.point); + if (!hit) continue; + + sample.hasHit = true; + sample.hitPoint.copy(hit.point); + const sampleOffset = hit.point.y - sample.point.y; + // 摆动脚只做防穿透上抬,不向下吸附。 + if (!leg.planted && sampleOffset < 0) continue; + // 超出脚部 IK 范围的点不参与二次接触校正。 + if (leg.planted && sampleOffset < -this.maxFootDrop) continue; + if (sampleOffset > this.maxFootRaise) continue; + + anyContactHit = true; + contactOffset = Math.max(contactOffset, sampleOffset); + } + + if (!anyContactHit) return; + if (leg.planted && contactOffset < -this.maxFootDrop) return; + if (contactOffset > this.maxFootRaise) return; + contactOffset = leg.planted + ? contactOffset + : MathUtils.clamp(contactOffset, 0, this.maxFootRaise); + if (Math.abs(contactOffset) <= this.snapEpsilon) return; + // 站住时较大的剩余高度交给下一帧的时间阻尼。 + if ( + leg.planted + && Math.abs(contactOffset) > Math.max(this.snapEpsilon, 4 * this.appliedScale) + ) { + return; + } + + this.savedAlignedFootWorldQ.copy(leg.foot.getWorldQuaternion(this.tmpQ1)); + const correctedTarget = this.tmpV2.copy(footWorld).addScaledVector(this.up, contactOffset); + + this.solveLeg(leg, correctedTarget, leg.weight); + this.preserveFootWorldRotation(leg, this.savedAlignedFootWorldQ, 1); + } + + // 根据地面法线轻微调整脚掌朝向。 + private alignFootToGround(leg: ReadyFootIKLeg): void { + // 脚掌贴坡:把脚的 up 方向部分旋到地面法线。 + // 用最大角度限制避免台阶边缘的法线让脚踝扭得过夸张。 + if (leg.hitNormal.y < 0.35 || leg.weight <= 0.001) return; + this.capture(leg.foot); + + const footWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + const alignQ = this.getConstrainedFootAlign( + leg.hitNormal, + leg.weight * this.footAlignWeight, + this.tmpQ2, + ); + + const targetWorldQ = alignQ.multiply(footWorldQ); + const parentWorldQ = leg.foot.parent?.getWorldQuaternion(this.tmpQ3); + if (!parentWorldQ) return; + leg.foot.quaternion.copy(parentWorldQ.invert().multiply(targetWorldQ)); + leg.foot.updateMatrixWorld(true); + } + + // 保存骨骼原始姿态,便于下一帧恢复。 + private capture(bone: Object3D): void { + // 第一次修改骨骼前保存原姿态,下一帧 restore 会用它恢复。 + if (this.adjusted.has(bone)) return; + let pose = this.poseCache.get(bone); + if (!pose) { + pose = { + position: new Vector3(), + quaternion: new Quaternion(), + }; + this.poseCache.set(bone, pose); + } + pose.position.copy(bone.position); + pose.quaternion.copy(bone.quaternion); + this.adjusted.add(bone); + } + + // 创建统一调试对象:IK 目标、最高命中、脚底四点。 + private createDebugObjects(): void { + createDebugObjects(this.player?.scene ?? null, this.legs, this.debug); + } + + // 更新单脚调试标记(含脚底四点和最大上抬范围);超出范围的线框命中球标红。 + private updateFootDebug(leg: FootIKLeg, hitPoint: Vector3): void { + updateFootDebug( + this.debug, + leg, + hitPoint, + this.appliedScale, + this.maxFootRaise, + ); + } + + // 统一切换全部 Foot IK 调试对象的显示状态。 + private setDebugVisible(visible: boolean): void { + setDebugVisible(this.legs, visible); + } + + /** 控制 Foot IK 调试对象显隐(IK 目标、最高命中、脚底四点)。 */ + setDebugEnabled(enabled: boolean): void { + if (this.disposed) return; + this.debug = enabled; + if (enabled) { + this.createDebugObjects(); + } + this.setDebugVisible(enabled); + } + + /** 读取当前可调配置(不含 skeleton)。距离和速度类参数返回 scale=1 基准值。 */ + getOptions(): Required> { + return { + enabled: this.enabled, + debug: this.debug, + maxPelvisDrop: this.toBaseDistance(this.maxPelvisDrop), + maxPelvisRaise: this.toBaseDistance(this.maxPelvisRaise), + maxFootRaise: this.toBaseDistance(this.maxFootRaise), + maxFootDrop: this.toBaseDistance(this.maxFootDrop), + plantedHeightSpeed: this.toBaseDistance(this.plantedHeightSpeed), + penetrationLiftSpeed: this.toBaseDistance(this.penetrationLiftSpeed), + soleHalfWidth: this.toBaseDistance(this.soleHalfWidth), + soleToeExtend: this.toBaseDistance(this.soleToeExtend), + soleHeelExtend: this.toBaseDistance(this.soleHeelExtend), + soleSkinThickness: this.toBaseDistance(this.soleSkinThickness), + footAlignWeight: this.footAlignWeight, + maxFootTilt: this.maxFootTilt, + minKneeBend: this.minKneeBend, + maxKneeBend: this.maxKneeBend, + pelvisKneeBend: this.pelvisKneeBend, + straightPoleEnabled: this.straightPoleEnabled, + moveLiftThreshold: this.toBaseDistance(this.moveLiftThreshold), + footPhaseSampleCount: this.footPhaseOptions.sampleCount, + footPhaseGroundThreshold: this.toBaseDistance(this.footPhaseGroundThreshold), + footPhaseMinContactRatio: this.footPhaseOptions.minContactRatio, + footPhaseSpeedSlack: this.footPhaseOptions.speedSlack, + predictivePlacement: this.predictivePlacement, + predictionHorizon: this.predictionHorizon, + predictionProbeFrames: this.predictionProbeFrames, + predictionSearchRadius: this.toBaseDistance(this.predictionSearchRadius), + maxPredictionCorrection: this.toBaseDistance(this.maxPredictionCorrection), + predictionMinNormalY: this.predictionMinNormalY, + swingClearance: this.toBaseDistance(this.swingClearance), + maxPredictionClearance: this.toBaseDistance(this.maxPredictionClearance), + }; + } + + /** + * 运行时更新部分配置。 + * 距离和速度类参数传入 scale=1 基准值,内部会乘以当前 playerModelConfig.scale。 + * sole 尺寸变化会重建本地采样点,脚步相位相关参数变化会重建相位库。 + */ + configure(options: Partial): void { + if (this.disposed) return; + + if (options.enabled !== undefined) this.setEnabled(options.enabled); + if (options.debug !== undefined) this.setDebugEnabled(options.debug); + if ( + options.predictivePlacement !== undefined + && options.predictivePlacement !== this.predictivePlacement + ) { + this.predictivePlacement = options.predictivePlacement; + this.resetPredictionRuntime(); + } + + let soleDirty = false; + let phaseDirty = false; + + if (options.maxPelvisDrop !== undefined) { + this.maxPelvisDrop = this.scaleDistance(options.maxPelvisDrop); + } + if (options.maxPelvisRaise !== undefined) { + this.maxPelvisRaise = this.scaleDistance(options.maxPelvisRaise); + } + if (options.maxFootRaise !== undefined) { + this.maxFootRaise = this.scaleDistance(options.maxFootRaise); + } + if (options.maxFootDrop !== undefined) { + this.maxFootDrop = this.scaleDistance(options.maxFootDrop); + } + if (options.plantedHeightSpeed !== undefined) { + this.plantedHeightSpeed = this.scaleDistance(options.plantedHeightSpeed); + } + if (options.penetrationLiftSpeed !== undefined) { + this.penetrationLiftSpeed = this.scaleDistance(options.penetrationLiftSpeed); + } + if (options.soleHalfWidth !== undefined) { + this.soleHalfWidth = this.scaleDistance(options.soleHalfWidth); + soleDirty = true; + } + if (options.soleToeExtend !== undefined) { + this.soleToeExtend = this.scaleDistance(options.soleToeExtend); + soleDirty = true; + } + if (options.soleHeelExtend !== undefined) { + this.soleHeelExtend = this.scaleDistance(options.soleHeelExtend); + soleDirty = true; + } + if (options.soleSkinThickness !== undefined) { + this.soleSkinThickness = this.scaleDistance(options.soleSkinThickness); + soleDirty = true; + } + if (options.footAlignWeight !== undefined) { + this.footAlignWeight = MathUtils.clamp(options.footAlignWeight, 0, 1); + } + if (options.maxFootTilt !== undefined) { + this.maxFootTilt = MathUtils.clamp(options.maxFootTilt, 0, Math.PI); + } + if (options.minKneeBend !== undefined || options.maxKneeBend !== undefined) { + const minBend = MathUtils.clamp(options.minKneeBend ?? this.minKneeBend, 0, Math.PI); + const maxBend = MathUtils.clamp(options.maxKneeBend ?? this.maxKneeBend, 0, Math.PI); + this.minKneeBend = Math.min(minBend, maxBend); + this.maxKneeBend = Math.max(minBend, maxBend); + this.pelvisKneeBend = MathUtils.clamp( + this.pelvisKneeBend, + this.minKneeBend, + this.maxKneeBend, + ); + } + if (options.pelvisKneeBend !== undefined) { + this.pelvisKneeBend = MathUtils.clamp( + options.pelvisKneeBend, + this.minKneeBend, + this.maxKneeBend, + ); + } + if (options.straightPoleEnabled !== undefined) { + this.straightPoleEnabled = options.straightPoleEnabled; + } + if (options.moveLiftThreshold !== undefined) { + this.moveLiftThreshold = this.scaleDistance(options.moveLiftThreshold); + } + if (options.predictionHorizon !== undefined) { + this.predictionHorizon = Math.max(0, options.predictionHorizon); + } + if (options.predictionProbeFrames !== undefined) { + this.predictionProbeFrames = Math.max(0, options.predictionProbeFrames); + } + if (options.predictionSearchRadius !== undefined) { + this.predictionSearchRadius = this.scaleDistance(options.predictionSearchRadius); + } + if (options.maxPredictionCorrection !== undefined) { + this.maxPredictionCorrection = this.scaleDistance(options.maxPredictionCorrection); + } + if (options.predictionMinNormalY !== undefined) { + this.predictionMinNormalY = MathUtils.clamp(options.predictionMinNormalY, 0, 1); + } + if (options.swingClearance !== undefined) { + this.swingClearance = this.scaleDistance(options.swingClearance); + } + if (options.maxPredictionClearance !== undefined) { + this.maxPredictionClearance = this.scaleDistance(options.maxPredictionClearance); + } + if (options.footPhaseGroundThreshold !== undefined) { + this.footPhaseGroundThreshold = this.scaleDistance(options.footPhaseGroundThreshold); + this.footPhaseOptions.groundThreshold = this.footPhaseGroundThreshold; + phaseDirty = true; + } + + if ( + options.footPhaseSampleCount !== undefined + || options.footPhaseMinContactRatio !== undefined + || options.footPhaseSpeedSlack !== undefined + ) { + this.footPhaseOptions = createFootPhaseOptions({ + footPhaseSampleCount: options.footPhaseSampleCount ?? this.footPhaseOptions.sampleCount, + footPhaseGroundThreshold: this.footPhaseGroundThreshold, + footPhaseMinContactRatio: options.footPhaseMinContactRatio ?? this.footPhaseOptions.minContactRatio, + footPhaseSpeedSlack: options.footPhaseSpeedSlack ?? this.footPhaseOptions.speedSlack, + }); + phaseDirty = true; + } + + if (soleDirty) { + this.initSoleLocalSamples(this.legs.left); + this.initSoleLocalSamples(this.legs.right); + } + if (phaseDirty) { + this.buildFootPhaseDatabase(); + } + } + + /** 返回指定脚当前动画相位的调试文本。 */ + getFootPhaseDebugText(side: FootIKSide): string { + return getFootPhaseDebugText(this.footPhaseState, this.footPhaseClips, side); + } + + /** 返回指定脚当前的最终 IK 权重。 */ + getFootIKWeight(side: FootIKSide): number { + return this.legs[side].weight; + } + + /** 返回当前骨盆沿世界上方向的偏移,scale=1 基准值;上抬为正、下沉为负。 */ + getPelvisOffset(): number { + return this.toBaseDistance(this.pelvisOffset); + } + + /** + * 每帧写入骨盆沿世界上方向的偏移,scale=1 基准值;上抬为正、下沉为负。 + * 写入后内部 wanted 不再覆盖,直到 clearPelvisOffset()。 + */ + setPelvisOffset(value: number): void { + if (this.disposed) return; + this.pelvisOffsetOverride = Number.isFinite(value) + ? value * this.getPlayerScale() + : 0; + this.pelvisOffset = this.pelvisOffsetOverride; + } + + /** 取消外部骨盆偏移,恢复内部计算结果。 */ + clearPelvisOffset(): void { + if (this.disposed) return; + this.pelvisOffsetOverride = null; + } + + /** 返回指定脚距离下一次落地的时间(秒);无相位数据时为 Infinity。 */ + getFootTimeToLand(side: FootIKSide): number { + return this.footPhaseState[side].timeToLand; + } + + /** 返回指定脚的预测落脚调试状态;预测关闭时返回 disabled。 */ + getPredictiveFootDebugText(side: FootIKSide): string { + if (!this.predictivePlacement) return "disabled"; + const state = this.legs[side].predictive; + // d 为落点相对胶囊支撑面的上台高度,swing 为摆腿路径上的最大凸起。 + return `${state.mode} d=${state.debugPlaneLift.toFixed(2)} swing=${state.debugSwingLift.toFixed(2)}`; + } + + /** 返回指定脚当前预测候选的调试评分;分数越小越好,无有效候选或预测关闭时返回 Infinity。 */ + getPredictiveFootScore(side: FootIKSide): number { + if (!this.predictivePlacement) return Infinity; + return this.legs[side].predictive.score; + } + +} diff --git a/three-player-controller/src/plugins/foot-ik/index.ts b/three-player-controller/src/plugins/foot-ik/index.ts new file mode 100644 index 0000000..448cd12 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/index.ts @@ -0,0 +1,8 @@ +export { FootIK } from "./FootIK"; +export type { + BoneRef, + FootIKLegBoneConfig, + FootIKOptions, + FootIKSide, + FootIKSkeletonConfig, +} from "./types"; diff --git a/three-player-controller/src/plugins/foot-ik/internal/debug.ts b/three-player-controller/src/plugins/foot-ik/internal/debug.ts new file mode 100644 index 0000000..a748ca9 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/debug.ts @@ -0,0 +1,675 @@ +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + Line, + LineBasicMaterial, + LineLoop, + LineSegments, + Mesh, + MeshBasicMaterial, + SphereGeometry, + Vector3, + type Material, + type Scene, +} from "three"; +import type { + FootIKLeg, + FootIKLegs, + FootIKSide, + FootPhaseControllerState, + FootPhaseDatabase, +} from "../types"; + +const FOOT_IK_SIDES: readonly FootIKSide[] = ["left", "right"]; + +/** 设计尺度下的 IK 目标球半径;与脚底四点同级,世界半径 = 该值 × appliedScale。 */ +const IK_MARKER_RADIUS = 3; +/** 设计尺度下的最高地面命中球半径。 */ +const HIT_MARKER_RADIUS = 2.5; +/** 设计尺度下的脚底采样原点球半径。 */ +const SAMPLE_ORIGIN_RADIUS = 3; +/** 设计尺度下的脚底采样命中球半径。 */ +const SAMPLE_HIT_RADIUS = 2.5; +/** 支撑脚 IK 的目标球颜色。 */ +const DEBUG_IK_PLANTED_COLOR = 0x22c55e; +/** 摆动脚防穿透 IK 的目标球颜色。 */ +const DEBUG_IK_PENETRATION_COLOR = 0x3b82f6; +/** 未应用 IK 时的目标球颜色。 */ +const DEBUG_IK_NOT_APPLIED_COLOR = 0xef4444; +/** 脚底采样球 / 连线(左右腿共用)。 */ +const DEBUG_SOLID_COLOR = 0x2dd4bf; +/** 线框命中球(左右腿共用)。 */ +const DEBUG_HIT_COLOR = 0xffff66; +/** 命中超出脚部 IK 最大上抬范围时的线框球颜色。 */ +const DEBUG_HIT_OVER_RAISE_LIMIT_COLOR = 0xff3333; +/** 脚部 IK 最大上抬范围线框颜色。 */ +const DEBUG_RAISE_LIMIT_COLOR = 0x60a5fa; +/** 命中超出最大上抬范围时的线框颜色。 */ +const DEBUG_RAISE_LIMIT_EXCEEDED_COLOR = 0xff3333; +/** 预测目标仍在持续跟踪时的颜色。 */ +const DEBUG_PREDICTIVE_TRACKING_COLOR = 0x38bdf8; +/** 预测结果正在参与脚部融合时的颜色。 */ +const DEBUG_PREDICTIVE_ACTIVE_COLOR = 0xf59e0b; +/** 可用但未被选中的预测候选颜色。 */ +const DEBUG_PREDICTIVE_VALID_COLOR = 0xfde047; +/** 无法提供完整鞋底支撑的预测候选颜色。 */ +const DEBUG_PREDICTIVE_INVALID_COLOR = 0xef4444; +/** 设计尺度下的预测候选球半径。 */ +const PREDICTIVE_CANDIDATE_RADIUS = 2; +/** 每条腿的上抬范围由四条竖线和顶部四边组成。 */ +const RAISE_LIMIT_POINT_COUNT = 16; +const SOLE_PERIMETER = [0, 1, 3, 2] as const; +const PREDICTIVE_TRAJECTORY_POINT_COUNT = 13; + +/** 创建可每帧原地改顶点的折线几何。 */ +function createDebugLineGeometry(pointCount: number): BufferGeometry { + const geometry = new BufferGeometry(); + geometry.setAttribute( + "position", + new BufferAttribute(new Float32Array(pointCount * 3), 3), + ); + return geometry; +} + +function writeDebugLinePositions( + geometry: BufferGeometry, + points: Array<{ x: number; y: number; z: number }>, + count = points.length, +): void { + const attribute = geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute) return; + const array = attribute.array as Float32Array; + const pointCount = Math.min(count, (array.length / 3) | 0); + for (let i = 0; i < pointCount; i++) { + const point = points[i]; + const offset = i * 3; + array[offset] = point.x; + array[offset + 1] = point.y; + array[offset + 2] = point.z; + } + attribute.needsUpdate = true; +} + +function writeDebugLinePair( + geometry: BufferGeometry, + from: { x: number; y: number; z: number }, + to: { x: number; y: number; z: number }, +): void { + const attribute = geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute || attribute.array.length < 6) return; + const array = attribute.array as Float32Array; + array[0] = from.x; + array[1] = from.y; + array[2] = from.z; + array[3] = to.x; + array[4] = to.y; + array[5] = to.z; + attribute.needsUpdate = true; +} + +/** 返回当前预测落脚状态对应的调试颜色。 */ +function getPredictiveModeColor(leg: FootIKLeg): number { + if ( + leg.predictive.mode === "active" + || leg.predictive.mode === "planted" + ) { + return DEBUG_PREDICTIVE_ACTIVE_COLOR; + } + return DEBUG_PREDICTIVE_TRACKING_COLOR; +} + +function setHitMarkerColor(mesh: Mesh, overRaiseLimit: boolean): void { + const material = mesh.material; + if (Array.isArray(material)) return; + (material as MeshBasicMaterial).color.setHex( + overRaiseLimit ? DEBUG_HIT_OVER_RAISE_LIMIT_COLOR : DEBUG_HIT_COLOR, + ); +} + +function setIKMarkerColor(mesh: Mesh, leg: FootIKLeg, applied: boolean): void { + const material = mesh.material; + if (Array.isArray(material)) return; + + let color = DEBUG_IK_NOT_APPLIED_COLOR; + if (leg.predictive.mode !== "none" && Number.isFinite(leg.predictive.score)) { + color = getPredictiveModeColor(leg); + } else if (applied) { + if (leg.planted) color = DEBUG_IK_PLANTED_COLOR; + else if (leg.movePenetrating) color = DEBUG_IK_PENETRATION_COLOR; + else color = DEBUG_IK_PLANTED_COLOR; + } + (material as MeshBasicMaterial).color.setHex(color); +} + +// 创建统一 Foot IK 调试对象:IK 目标、最高命中、脚底四点(原点 + 命中 + 射线)。 +export function createDebugObjects( + scene: Scene | null, + legs: FootIKLegs, + enabled: boolean, +): void { + if (!enabled || !scene) return; + + let ikGeometry: SphereGeometry | null = null; + let hitGeometry: SphereGeometry | null = null; + let sampleOriginGeometry: SphereGeometry | null = null; + let sampleHitGeometry: SphereGeometry | null = null; + let predictiveCandidateGeometry: SphereGeometry | null = null; + + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + + if (!leg.marker) { + ikGeometry ??= new SphereGeometry(IK_MARKER_RADIUS, 10, 8); + leg.marker = new Mesh( + ikGeometry, + new MeshBasicMaterial({ + color: DEBUG_IK_NOT_APPLIED_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.marker.renderOrder = 30; + leg.marker.frustumCulled = false; + scene.add(leg.marker); + } + + if (!leg.hitMarker) { + hitGeometry ??= new SphereGeometry(HIT_MARKER_RADIUS, 10, 8); + leg.hitMarker = new Mesh( + hitGeometry, + new MeshBasicMaterial({ + color: DEBUG_HIT_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ); + leg.hitMarker.renderOrder = 29; + leg.hitMarker.frustumCulled = false; + scene.add(leg.hitMarker); + } + + if (!leg.rayLine) { + leg.rayLine = new Line( + createDebugLineGeometry(2), + new LineBasicMaterial({ + color: DEBUG_SOLID_COLOR, + depthTest: false, + transparent: true, + opacity: 0.85, + }), + ); + leg.rayLine.renderOrder = 28; + leg.rayLine.frustumCulled = false; + scene.add(leg.rayLine); + } + + if (!leg.raiseLimitLine) { + leg.raiseLimitLine = new LineSegments( + createDebugLineGeometry(RAISE_LIMIT_POINT_COUNT), + new LineBasicMaterial({ + color: DEBUG_RAISE_LIMIT_COLOR, + depthTest: false, + transparent: true, + opacity: 0.75, + }), + ); + leg.raiseLimitLine.renderOrder = 24; + leg.raiseLimitLine.frustumCulled = false; + scene.add(leg.raiseLimitLine); + } + + if (!leg.predictiveLine) { + leg.predictiveLine = new Line( + createDebugLineGeometry(PREDICTIVE_TRAJECTORY_POINT_COUNT), + new LineBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.predictiveLine.visible = false; + leg.predictiveLine.renderOrder = 32; + leg.predictiveLine.frustumCulled = false; + scene.add(leg.predictiveLine); + } + + if (leg.predictiveCandidateMarkers.length === 0) { + predictiveCandidateGeometry ??= new SphereGeometry(PREDICTIVE_CANDIDATE_RADIUS, 10, 8); + for (const _candidate of leg.predictive.debugCandidates) { + const marker = new Mesh( + predictiveCandidateGeometry, + new MeshBasicMaterial({ + color: DEBUG_PREDICTIVE_INVALID_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ); + marker.visible = false; + marker.renderOrder = 31; + marker.frustumCulled = false; + leg.predictiveCandidateMarkers.push(marker); + scene.add(marker); + } + } + + if (!leg.predictiveSupportMesh) { + const supportGeometry = new BufferGeometry(); + supportGeometry.setAttribute("position", new BufferAttribute(new Float32Array(12), 3)); + supportGeometry.setIndex([0, 1, 3, 0, 3, 2]); + leg.predictiveSupportMesh = new Mesh( + supportGeometry, + new MeshBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.28, + side: DoubleSide, + }), + ); + leg.predictiveSupportMesh.visible = false; + leg.predictiveSupportMesh.renderOrder = 22; + leg.predictiveSupportMesh.frustumCulled = false; + scene.add(leg.predictiveSupportMesh); + } + + if (!leg.predictiveSupportOutline) { + leg.predictiveSupportOutline = new LineLoop( + createDebugLineGeometry(4), + new LineBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.predictiveSupportOutline.visible = false; + leg.predictiveSupportOutline.renderOrder = 23; + leg.predictiveSupportOutline.frustumCulled = false; + scene.add(leg.predictiveSupportOutline); + } + + for (const sample of leg.soleSamples) { + // footMarker:脚底采样原点(跟骨骼) + if (!sample.footMarker) { + sampleOriginGeometry ??= new SphereGeometry(SAMPLE_ORIGIN_RADIUS, 10, 8); + sample.footMarker = new Mesh( + sampleOriginGeometry, + new MeshBasicMaterial({ + color: DEBUG_SOLID_COLOR, + depthTest: false, + transparent: true, + opacity: 0.85, + }), + ); + sample.footMarker.renderOrder = 27; + sample.footMarker.frustumCulled = false; + scene.add(sample.footMarker); + } + // marker:该采样点的地面命中 + if (!sample.marker) { + sampleHitGeometry ??= new SphereGeometry(SAMPLE_HIT_RADIUS, 10, 8); + sample.marker = new Mesh( + sampleHitGeometry, + new MeshBasicMaterial({ + color: DEBUG_HIT_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.8, + }), + ); + sample.marker.renderOrder = 26; + sample.marker.frustumCulled = false; + scene.add(sample.marker); + } + if (!sample.rayLine) { + sample.rayLine = new Line( + createDebugLineGeometry(2), + new LineBasicMaterial({ + color: DEBUG_SOLID_COLOR, + transparent: true, + opacity: 0.45, + depthTest: false, + }), + ); + sample.rayLine.renderOrder = 25; + sample.rayLine.frustumCulled = false; + scene.add(sample.rayLine); + } + } + } +} + +// 更新统一调试显示。 +export function updateFootDebug( + enabled: boolean, + leg: FootIKLeg, + hitPoint: Vector3, + scale = 1, + maxFootRaise = 0, +): void { + if (!enabled || !leg.marker || !leg.rayLine) return; + + const s = Math.max(1e-4, scale); + const bestSample = leg.soleSamples[leg.bestGroundSampleIndex]; + const bestHitOverRaiseLimit = !!bestSample?.hasHit + && hitPoint.y - bestSample.point.y > maxFootRaise; + const ikApplied = leg.weight > 0.001; + + // 腿级:IK 目标 + 最高命中 + 命中→目标 + leg.marker.visible = true; + leg.marker.position.copy(leg.smoothedTarget); + leg.marker.scale.setScalar(s); + setIKMarkerColor(leg.marker, leg, ikApplied); + + if (leg.hitMarker) { + leg.hitMarker.visible = true; + leg.hitMarker.position.copy(hitPoint); + leg.hitMarker.scale.setScalar(s); + setHitMarkerColor(leg.hitMarker, bestHitOverRaiseLimit); + } + + // 只有实际应用 IK 时才显示“最高地面命中 → IK 目标”连线。 + leg.rayLine.visible = ikApplied; + if (ikApplied) { + writeDebugLinePair(leg.rayLine.geometry, hitPoint, leg.smoothedTarget); + } + + updatePredictiveDebug(leg, s); + + // 预测摆腿只保留轨迹和落点面;脚底四点与抬高笼子仍表示反应式采样。 + if (!showReactiveSoleDebug(leg)) { + setReactiveSoleDebugVisible(leg, false); + return; + } + + updateRaiseLimitLine(leg, maxFootRaise); + + // 脚底四点:原点 + 命中 + 射线 + for (const sample of leg.soleSamples) { + if (sample.footMarker) { + sample.footMarker.visible = true; + sample.footMarker.position.copy(sample.point); + sample.footMarker.scale.setScalar(s); + } + if (sample.marker) { + sample.marker.visible = sample.hasHit; + if (sample.hasHit) { + sample.marker.position.copy(sample.hitPoint); + sample.marker.scale.setScalar(s); + setHitMarkerColor( + sample.marker, + sample.hitPoint.y - sample.point.y > maxFootRaise, + ); + } + } + if (sample.rayLine) { + sample.rayLine.visible = sample.hasHit; + if (sample.hasHit) { + writeDebugLinePair(sample.rayLine.geometry, sample.point, sample.hitPoint); + } + } + } +} + +/** 预测正在驱动摆腿时,反应式脚底采样调试不再跟当前脚骨。 */ +function showReactiveSoleDebug(leg: FootIKLeg): boolean { + return leg.predictive.mode !== "active"; +} + +function setReactiveSoleDebugVisible(leg: FootIKLeg, visible: boolean): void { + if (leg.raiseLimitLine) leg.raiseLimitLine.visible = visible; + for (const sample of leg.soleSamples) { + if (sample.footMarker) sample.footMarker.visible = visible; + if (sample.marker) sample.marker.visible = visible && sample.hasHit; + if (sample.rayLine) sample.rayLine.visible = visible && sample.hasHit; + } +} + +/** 更新预测摆动轨迹、动画中心候选点及其状态配色。 */ +function updatePredictiveDebug(leg: FootIKLeg, scale: number): void { + const state = leg.predictive; + // tracking 只显示中心候选;active 才画摆动线路和落点四边形配色。 + const showTrajectory = state.mode === "active"; + const showCandidates = state.mode === "tracking"; + + if (leg.predictiveLine) { + leg.predictiveLine.visible = showTrajectory && state.debugTrajectoryVisible; + if (leg.predictiveLine.visible) { + writeDebugLinePositions( + leg.predictiveLine.geometry, + state.debugTrajectory, + ); + const material = leg.predictiveLine.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex(getPredictiveModeColor(leg)); + } + } + } + + for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) { + const marker = leg.predictiveCandidateMarkers[i]; + const candidate = state.debugCandidates[i]; + marker.visible = showCandidates && !!candidate?.evaluated; + if (!marker.visible || !candidate) continue; + + marker.position.copy(candidate.point); + marker.scale.setScalar(scale * (candidate.selected ? 1.25 : 0.8)); + const material = marker.material; + if (Array.isArray(material)) continue; + const debugMaterial = material as MeshBasicMaterial; + debugMaterial.wireframe = !candidate.selected; + debugMaterial.color.setHex( + candidate.selected + ? candidate.valid + ? getPredictiveModeColor(leg) + : DEBUG_PREDICTIVE_INVALID_COLOR + : candidate.valid + ? DEBUG_PREDICTIVE_VALID_COLOR + : DEBUG_PREDICTIVE_INVALID_COLOR, + ); + } + + const landing = state.debugCandidates[0]; + const planeColor = landing?.valid + ? getPredictiveModeColor(leg) + : DEBUG_PREDICTIVE_INVALID_COLOR; + updatePredictiveSupportPlane(leg, planeColor); +} + +/** 用鞋底四点在合并支撑面上的投影绘制半透明四边形。 */ +function updatePredictiveSupportPlane(leg: FootIKLeg, color: number): void { + const state = leg.predictive; + const visible = state.debugSupportVisible + && (state.mode === "tracking" || state.mode === "active") + && state.debugSupportCorners.length >= 4; + if (leg.predictiveSupportMesh) { + leg.predictiveSupportMesh.visible = visible; + if (visible) { + const positions = leg.predictiveSupportMesh.geometry.getAttribute("position"); + for (let i = 0; i < 4; i++) { + const corner = state.debugSupportCorners[i]; + positions.setXYZ(i, corner.x, corner.y, corner.z); + } + positions.needsUpdate = true; + const material = leg.predictiveSupportMesh.material; + if (!Array.isArray(material)) { + (material as MeshBasicMaterial).color.setHex(color); + } + } + } + if (leg.predictiveSupportOutline) { + leg.predictiveSupportOutline.visible = visible; + if (visible) { + const attribute = leg.predictiveSupportOutline.geometry.getAttribute("position") as BufferAttribute; + const array = attribute.array as Float32Array; + for (let i = 0; i < SOLE_PERIMETER.length; i++) { + const corner = state.debugSupportCorners[SOLE_PERIMETER[i]]; + const offset = i * 3; + array[offset] = corner.x; + array[offset + 1] = corner.y; + array[offset + 2] = corner.z; + } + attribute.needsUpdate = true; + const material = leg.predictiveSupportOutline.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex(color); + } + } + } +} + +// 统一切换所有 Foot IK 调试对象的显隐。 +export function setDebugVisible(legs: FootIKLegs, visible: boolean): void { + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + if (leg.marker) leg.marker.visible = visible; + if (leg.hitMarker) leg.hitMarker.visible = visible; + if (leg.rayLine) leg.rayLine.visible = visible && leg.weight > 0.001; + if (leg.predictiveLine) { + leg.predictiveLine.visible = visible + && leg.predictive.debugTrajectoryVisible + && leg.predictive.mode === "active"; + } + for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) { + const candidate = leg.predictive.debugCandidates[i]; + leg.predictiveCandidateMarkers[i].visible = visible + && !!candidate?.evaluated + && leg.predictive.mode === "tracking"; + } + if (leg.predictiveSupportMesh) { + leg.predictiveSupportMesh.visible = visible + && leg.predictive.debugSupportVisible + && ( + leg.predictive.mode === "tracking" + || leg.predictive.mode === "active" + ); + } + if (leg.predictiveSupportOutline) { + leg.predictiveSupportOutline.visible = visible + && leg.predictive.debugSupportVisible + && ( + leg.predictive.mode === "tracking" + || leg.predictive.mode === "active" + ); + } + setReactiveSoleDebugVisible(leg, visible && showReactiveSoleDebug(leg)); + } +} + +// 返回当前动画脚步相位的简短调试文本。 +export function getFootPhaseDebugText( + state: FootPhaseControllerState, + clips: FootPhaseDatabase, + side: FootIKSide, +): string { + const sideState = state[side]; + const clipName = state.clipName || "none"; + const dataReady = clips.has(clipName); + if (!dataReady) return `${clipName}: no phase`; + + const phase = sideState.planted ? "plant" : "swing"; + const progress = Number.isFinite(sideState.progress) + ? `${Math.round(sideState.progress * 100)}%` + : "--"; + return `${clipName}: ${phase} ${progress}`; +} + +// 移除调试对象并释放对应的 GPU 资源。 +export function disposeDebugObjects(legs: FootIKLegs): void { + const geometries = new Set(); + const materials = new Set(); + + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + collectDebugObject(leg.marker, geometries, materials); + collectDebugObject(leg.hitMarker, geometries, materials); + collectDebugObject(leg.rayLine, geometries, materials); + collectDebugObject(leg.raiseLimitLine, geometries, materials); + collectDebugObject(leg.predictiveLine, geometries, materials); + collectDebugObject(leg.predictiveSupportMesh, geometries, materials); + collectDebugObject(leg.predictiveSupportOutline, geometries, materials); + for (const marker of leg.predictiveCandidateMarkers) { + collectDebugObject(marker, geometries, materials); + } + leg.marker = null; + leg.hitMarker = null; + leg.rayLine = null; + leg.raiseLimitLine = null; + leg.predictiveLine = null; + leg.predictiveSupportMesh = null; + leg.predictiveSupportOutline = null; + leg.predictiveCandidateMarkers.length = 0; + + for (const sample of leg.soleSamples) { + collectDebugObject(sample.marker, geometries, materials); + collectDebugObject(sample.rayLine, geometries, materials); + collectDebugObject(sample.footMarker, geometries, materials); + sample.marker = null; + sample.rayLine = null; + sample.footMarker = null; + } + } + + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); +} + +// 以当前动画脚底四点为起点,画出每个采样点可上抬到的最高边界。 +function updateRaiseLimitLine(leg: FootIKLeg, maxFootRaise: number): void { + if (!leg.raiseLimitLine || leg.soleSamples.length < 4) return; + + const attribute = leg.raiseLimitLine.geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute || attribute.array.length < RAISE_LIMIT_POINT_COUNT * 3) return; + const array = attribute.array as Float32Array; + let cursor = 0; + let exceeded = false; + const writePoint = (x: number, y: number, z: number): void => { + array[cursor++] = x; + array[cursor++] = y; + array[cursor++] = z; + }; + + for (const sample of leg.soleSamples) { + writePoint(sample.point.x, sample.point.y, sample.point.z); + writePoint(sample.point.x, sample.point.y + maxFootRaise, sample.point.z); + exceeded ||= sample.hasHit && sample.hitPoint.y - sample.point.y > maxFootRaise; + } + + for (let i = 0; i < SOLE_PERIMETER.length; i++) { + const from = leg.soleSamples[SOLE_PERIMETER[i]].point; + const to = leg.soleSamples[SOLE_PERIMETER[(i + 1) % SOLE_PERIMETER.length]].point; + writePoint(from.x, from.y + maxFootRaise, from.z); + writePoint(to.x, to.y + maxFootRaise, to.z); + } + + attribute.needsUpdate = true; + leg.raiseLimitLine.visible = true; + const material = leg.raiseLimitLine.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex( + exceeded ? DEBUG_RAISE_LIMIT_EXCEEDED_COLOR : DEBUG_RAISE_LIMIT_COLOR, + ); + } +} + +function collectDebugObject( + object: Mesh | Line | null, + geometries: Set, + materials: Set, +): void { + if (!object) return; + object.parent?.remove(object); + geometries.add(object.geometry); + const objectMaterials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of objectMaterials) materials.add(material); +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts b/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts new file mode 100644 index 0000000..7571e2f --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts @@ -0,0 +1,414 @@ +import { + AnimationMixer, + MathUtils, + Quaternion, + Vector3, + type AnimationClip, + type Object3D, +} from "three"; +import { collectBones, isReadyLeg } from "./skeleton"; +import type { + FootIKLegs, + FootIKOptions, + FootIKSide, + FootPhaseClipData, + FootPhaseContactRun, + FootPhaseDatabase, + FootPhaseFrameSample, + FootPhaseLanding, + FootPhaseOptions, + FootPhasePoseSample, + FootPhaseRuntime, + FootPhaseRuntimeState, + FootPhaseSamplePoint, + FootPhaseSideData, + ReadyFootIKLeg, +} from "../types"; + +const tmpFoot = new Vector3(); +const tmpToe = new Vector3(); +const tmpFootRotation = new Quaternion(); +const tmpModelRotation = new Quaternion(); + +// 创建单脚相位的运行时默认状态。 +export function createFootPhaseRuntimeState(): FootPhaseRuntimeState { + return { + planted: false, + progress: 0, + timeToLand: Infinity, + nextLanding: null, + }; +} + +// 从控制器配置中提取脚步相位采样参数。 +export function createFootPhaseOptions(options: FootIKOptions = {}): FootPhaseOptions { + return { + sampleCount: Math.max(16, Math.round(options.footPhaseSampleCount ?? 96)), + groundThreshold: Math.max(0, options.footPhaseGroundThreshold ?? 5), + minContactRatio: MathUtils.clamp(options.footPhaseMinContactRatio ?? 0.04, 0, 1), + speedSlack: Math.max(0, options.footPhaseSpeedSlack ?? 1.35), + }; +} + +// 离线采样循环移动动画,生成左右脚落地和抬脚相位。 +export function buildFootPhaseDatabase( + model: Object3D | null | undefined, + clips: readonly AnimationClip[], + legs: FootIKLegs, + options: FootPhaseOptions, + isLocomotionClip: (clipName: string) => boolean, +): FootPhaseDatabase { + const phaseClips: FootPhaseDatabase = new Map(); + if (!model || !clips.length || !isReadyLeg(legs.left) || !isReadyLeg(legs.right)) return phaseClips; + + const bones = collectBones(model); + const savedPoses = bones.map(bone => ({ + bone, + position: bone.position.clone(), + quaternion: bone.quaternion.clone(), + scale: bone.scale.clone(), + })); + const savedModelMatrixNeedsUpdate = model.matrixWorldNeedsUpdate; + const mixer = new AnimationMixer(model); + + try { + for (const clip of clips) { + if (!shouldAnalyzeFootPhaseClip(clip, isLocomotionClip)) continue; + phaseClips.set(clip.name, sampleFootPhaseClip(model, mixer, clip, legs, options)); + } + } finally { + mixer.stopAllAction(); + mixer.uncacheRoot(model); + for (const pose of savedPoses) { + pose.bone.position.copy(pose.position); + pose.bone.quaternion.copy(pose.quaternion); + pose.bone.scale.copy(pose.scale); + } + model.matrixWorldNeedsUpdate = savedModelMatrixNeedsUpdate; + model.updateMatrixWorld(true); + } + + return phaseClips; +} + +// 根据当前动画时间采样左右脚运行时相位。 +export function sampleFootPhaseRuntime( + phaseClip: FootPhaseClipData | null | undefined, + normalizedTime: number, + duration: number, +): FootPhaseRuntime { + if (!phaseClip) { + return { + left: createFootPhaseRuntimeState(), + right: createFootPhaseRuntimeState(), + }; + } + + return { + left: sampleFootPhaseRuntimeSide(phaseClip.left, normalizedTime, duration), + right: sampleFootPhaseRuntimeSide(phaseClip.right, normalizedTime, duration), + }; +} + +// 只分析 playerModelConfig 中配置为移动状态的循环动画。 +function shouldAnalyzeFootPhaseClip( + clip: AnimationClip, + isLocomotionClip: (clipName: string) => boolean, +): boolean { + return clip.duration > 0 && isLocomotionClip(clip.name); +} + +// 对单个动画片段离线采样,生成左右脚相位数据。 +function sampleFootPhaseClip( + model: Object3D, + mixer: AnimationMixer, + clip: AnimationClip, + legs: FootIKLegs, + options: FootPhaseOptions, +): FootPhaseClipData { + if (!isReadyLeg(legs.left) || !isReadyLeg(legs.right)) { + throw new Error("[FootIK] 脚步相位采样时腿部骨骼不完整。"); + } + + const action = mixer.clipAction(clip); + action.reset(); + action.setEffectiveWeight(1); + action.play(); + + const samples: FootPhaseFrameSample[] = []; + for (let i = 0; i < options.sampleCount; i++) { + const normalizedTime = i / options.sampleCount; + mixer.setTime(normalizedTime * clip.duration); + model.updateMatrixWorld(true); + samples.push({ + time: normalizedTime, + left: getFootPhaseSample(model, legs.left), + right: getFootPhaseSample(model, legs.right), + }); + } + + action.stop(); + + return { + name: clip.name, + duration: clip.duration, + left: analyzeFootPhaseSide(samples, "left", options), + right: analyzeFootPhaseSide(samples, "right", options), + }; +} + +// 采样单只脚在动画中的高度、水平位置和相对模型旋转。 +function getFootPhaseSample(model: Object3D, leg: ReadyFootIKLeg): FootPhaseSamplePoint { + const foot = leg.foot.getWorldPosition(tmpFoot); + const toeY = leg.toe ? leg.toe.getWorldPosition(tmpToe).y : foot.y; + const contactY = Math.min(foot.y, toeY); + const footWorldRotation = leg.foot.getWorldQuaternion(tmpFootRotation); + const localRotation = model + .getWorldQuaternion(tmpModelRotation) + .invert() + .multiply(footWorldRotation) + .clone(); + model.worldToLocal(foot); + return { + y: contactY, + x: foot.x, + localY: foot.y, + z: foot.z, + localRotation, + }; +} + +// 按循环相位插值离线动画中的脚骨局部位姿。 +export function sampleFootPhasePose( + sideData: FootPhaseSideData, + normalizedTime: number, + position: Vector3, + rotation: Quaternion, +): boolean { + const poses = sideData.poses; + if (poses.length === 0) return false; + + const wrappedTime = ((normalizedTime % 1) + 1) % 1; + const sample = wrappedTime * poses.length; + const fromIndex = Math.floor(sample) % poses.length; + const toIndex = (fromIndex + 1) % poses.length; + const alpha = sample - Math.floor(sample); + position.lerpVectors( + poses[fromIndex].localPosition, + poses[toIndex].localPosition, + alpha, + ); + rotation + .copy(poses[fromIndex].localRotation) + .slerp(poses[toIndex].localRotation, alpha); + return true; +} + +// 分析单只脚的落地相位。 +function analyzeFootPhaseSide( + samples: readonly FootPhaseFrameSample[], + side: FootIKSide, + options: FootPhaseOptions, +): FootPhaseSideData { + let minY = Infinity; + for (const sample of samples) minY = Math.min(minY, sample[side].y); + + const speeds = calculateFootPhaseSpeeds(samples, side); + const heightContacts = samples.map(sample => sample[side].y <= minY + options.groundThreshold); + const speedContacts = filterFootPhaseBySpeed(heightContacts, speeds, options.speedSlack); + const contacts = keepMainFootContactRun(filterShortFootContacts(speedContacts, options.minContactRatio)); + const land: number[] = []; + const lift: number[] = []; + const landings: FootPhaseLanding[] = []; + const poses: FootPhasePoseSample[] = samples.map(sample => { + const point = sample[side]; + return { + localPosition: new Vector3(point.x, point.localY, point.z), + localRotation: point.localRotation.clone(), + }; + }); + + for (let i = 0; i < contacts.length; i++) { + const prev = contacts[(i - 1 + contacts.length) % contacts.length]; + const current = contacts[i]; + if (!prev && current) { + land.push(samples[i].time); + const point = samples[i][side]; + landings.push({ + phase: samples[i].time, + localPosition: new Vector3(point.x, point.localY, point.z), + localRotation: point.localRotation.clone(), + }); + } + if (prev && !current) lift.push(samples[i].time); + } + + return { + contacts, + land, + lift, + landings, + poses, + }; +} + +// 计算脚在动画相位中的水平速度。 +function calculateFootPhaseSpeeds( + samples: readonly FootPhaseFrameSample[], + side: FootIKSide, +): number[] { + const speeds: number[] = []; + for (let i = 0; i < samples.length; i++) { + const prev = samples[(i - 1 + samples.length) % samples.length][side]; + const next = samples[(i + 1) % samples.length][side]; + speeds.push(Math.hypot(next.x - prev.x, next.z - prev.z)); + } + return speeds; +} + +// 在低高度候选点中继续过滤水平速度。 +function filterFootPhaseBySpeed( + contacts: readonly boolean[], + speeds: readonly number[], + speedSlack: number, +): boolean[] { + const contactSpeeds = speeds + .filter((_speed, index) => contacts[index]) + .sort((a, b) => a - b); + if (contactSpeeds.length === 0) return contacts.slice(); + + const median = contactSpeeds[Math.floor(contactSpeeds.length * 0.5)]; + const p75 = contactSpeeds[Math.floor(contactSpeeds.length * 0.75)]; + const threshold = Math.max(median, p75) * speedSlack; + return contacts.map((contact, index) => contact && speeds[index] <= threshold); +} + +// 去掉过短的接触段,避免脚尖高度抖动产生零碎支撑相位。 +function filterShortFootContacts( + contacts: readonly boolean[], + minContactRatio: number, +): boolean[] { + const minLength = Math.max(1, Math.round(contacts.length * minContactRatio)); + const filtered = contacts.slice(); + + for (const run of getCircularContactRuns(contacts, true)) { + if (run.indices.length >= minLength) continue; + for (const index of run.indices) filtered[index] = false; + } + + return filtered; +} + +// 每只脚只保留最长的主要支撑段。 +function keepMainFootContactRun(contacts: readonly boolean[]): boolean[] { + const runs = getCircularContactRuns(contacts, true); + if (runs.length <= 1) return contacts.slice(); + + const mainRun = runs.sort((a, b) => b.indices.length - a.indices.length)[0]; + const filtered = new Array(contacts.length).fill(false); + for (const index of mainRun.indices) filtered[index] = true; + return filtered; +} + +// 提取循环数组中连续相同值的片段。 +function getCircularContactRuns( + values: readonly boolean[], + targetValue: boolean, +): FootPhaseContactRun[] { + const runs: FootPhaseContactRun[] = []; + if (values.length === 0) return runs; + + const visited = new Array(values.length).fill(false); + const start = values.findIndex((value, index) => { + const prev = values[(index - 1 + values.length) % values.length]; + return value === targetValue && prev !== targetValue; + }); + if (start < 0) { + return values[0] === targetValue + ? [{ indices: values.map((_value, index) => index) }] + : runs; + } + + for (let offset = 0; offset < values.length; offset++) { + const index = (start + offset) % values.length; + if (visited[index] || values[index] !== targetValue) continue; + + const indices: number[] = []; + let cursor = index; + while (!visited[cursor] && values[cursor] === targetValue) { + visited[cursor] = true; + indices.push(cursor); + cursor = (cursor + 1) % values.length; + if (cursor === index) break; + } + runs.push({ indices }); + } + + return runs; +} + +// 计算单脚是否支撑、摆腿进度和下一次落地时间。 +function sampleFootPhaseRuntimeSide( + sideData: FootPhaseSideData, + normalizedTime: number, + duration: number, +): FootPhaseRuntimeState { + const sampleCount = sideData.contacts.length; + if (sampleCount === 0) return createFootPhaseRuntimeState(); + + const sampleIndex = Math.floor(normalizedTime * sampleCount) % sampleCount; + const planted = !!sideData.contacts[sampleIndex]; + const nextLanding = nextLandingEvent(sideData.landings, normalizedTime); + const nextLand = nextLanding?.phase ?? null; + const prevLift = prevPhaseEvent(sideData.lift, normalizedTime); + const swingSpan = wrapPhaseDistance(prevLift, nextLand) || 1; + const swingDone = wrapPhaseDistance(prevLift, normalizedTime); + + return { + planted, + progress: planted ? 1 : MathUtils.clamp(swingDone / swingSpan, 0, 1), + timeToLand: nextLand === null + ? Infinity + : wrapPhaseDistance(normalizedTime, nextLand) * duration, + nextLanding, + }; +} + +/** 查找循环动画时间轴上的下一次落脚事件。 */ +function nextLandingEvent( + events: readonly FootPhaseLanding[], + normalizedTime: number, +): FootPhaseLanding | null { + if (!events.length) return null; + let best = events[0]; + let bestDistance = Infinity; + for (const event of events) { + const distance = wrapPhaseDistance(normalizedTime, event.phase); + if (distance < bestDistance) { + best = event; + bestDistance = distance; + } + } + return best; +} + +// 查找循环时间轴上的上一个事件。 +function prevPhaseEvent(events: readonly number[], normalizedTime: number): number { + if (!events.length) return normalizedTime; + let best = events[0]; + let bestDistance = Infinity; + for (const eventTime of events) { + const distance = wrapPhaseDistance(eventTime, normalizedTime); + if (distance < bestDistance) { + best = eventTime; + bestDistance = distance; + } + } + return best; +} + +// 计算归一化循环时间轴上的前向距离。 +function wrapPhaseDistance(from: number | null, to: number | null): number { + if (from === null || to === null) return 0; + return (to - from + 1) % 1; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts b/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts new file mode 100644 index 0000000..d0604b1 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts @@ -0,0 +1,615 @@ +import { Matrix3, Quaternion, Vector3, type Object3D } from "three"; +import type { + FootIKGroundHit, + FootIKSoleSample, + FootPhaseRuntimeState, + PredictiveFootState, + ReadyFootIKLeg, +} from "../types"; + +/** 最小踏步高度;低于该值不启用预测 IK。 */ +export const PREDICTION_DEMAND_ENTER = 8; +/** 已启用预测时,需求回落到该高度后退出。 */ +export const PREDICTION_DEMAND_EXIT = 4; + +/** 预测候选保留脚骨目标、实际支撑面和评分,用于生成本次摆动线路。 */ +export type PredictiveFootCandidate = { + /** 鞋底贴合支撑面后的脚骨目标。 */ + target: Vector3; + /** 四条鞋底射线共面合并后的地面接触。 */ + point: Vector3; + normal: Vector3; + object: Object3D | null; + score: number; + /** 0 为动画中心落点;非 0 表示相对动画做了水平偏移。 */ + debugIndex: number; +}; + +/** 预测落脚搜索、评估和轨迹规划共用的临时对象。 */ +export type PredictiveScratch = { + v1: Vector3; + v2: Vector3; + v4: Vector3; + v5: Vector3; + v6: Vector3; + v7: Vector3; + v8: Vector3; + v9: Vector3; + q1: Quaternion; + q2: Quaternion; + q3: Quaternion; + normalMatrix: Matrix3; + trajectoryStart: Vector3; + demandEnter: number; + demandExit: number; + candidates: PredictiveFootCandidate[]; + /** 仅确认可站立的中心支撑,尚未通过抬降和腿长约束。 */ + centerSupport: PredictiveFootCandidate; + plannedPoints: Vector3[]; + currentLocalPosition: Vector3; + currentLocalRotation: Quaternion; + /** 当前动画脚相对相位采样的偏差,规划起点与运行时脚对齐。 */ + animationBias: Vector3; + /** 预测脚骨目标相对动画预计落点的世界空间差。 */ + landingCorrection: Vector3; + /** 重规划时把线路起点钉在上一帧输出,避免轨迹跳变。 */ + continuityOffset: Vector3; + modelWorldRotation: Quaternion; + landingRotation: Quaternion; + worldScale: Vector3; + localPosition: Vector3; + localRotation: Quaternion; + pathPosition: Vector3; + pathRotation: Quaternion; + solePoint: Vector3; + soleOffset: Vector3; + supportHits: FootIKGroundHit[]; + supportHitList: FootIKGroundHit[]; + clearanceSamples: Array<{ progress: number; requiredLift: number }>; + clearanceSampleCount: number; +}; + +/** 候选搜索和落点评估所需的只读配置与回调。 */ +export type PredictivePlacementContext = { + up: Vector3; + appliedScale: number; + snapEpsilon: number; + sampleRayOriginY: number; + maxPredictionCorrection: number; + predictionMinNormalY: number; + maxFootRaise: number; + maxFootDrop: number; + pelvisKneeBend: number; + footAlignWeight: number; + predictionHorizon: number; + predictionVelocity: Vector3; + scratch: PredictiveScratch; + /** 关闭时跳过支撑面四边形和调试轨迹写入。 */ + debug: boolean; + castGroundFrom: (x: number, y: number, z: number) => FootIKGroundHit | null; + castCapsuleGround: () => FootIKGroundHit | null; + getConstrainedFootAlign: ( + normal: Vector3, + alignWeight: number, + target: Quaternion, + ) => Quaternion; + mergeCoplanarHits: ( + hits: Array, + highest: FootIKGroundHit, + supportPoint: Vector3, + supportNormal: Vector3, + ) => number; + getSafeLegReach: (upperLen: number, lowerLen: number, kneeBend: number) => number; +}; + +/** 创建预测落脚搜索和规划使用的临时对象。 */ +export function createPredictiveScratch(): PredictiveScratch { + return { + v1: new Vector3(), + v2: new Vector3(), + v4: new Vector3(), + v5: new Vector3(), + v6: new Vector3(), + v7: new Vector3(), + v8: new Vector3(), + v9: new Vector3(), + q1: new Quaternion(), + q2: new Quaternion(), + q3: new Quaternion(), + normalMatrix: new Matrix3(), + trajectoryStart: new Vector3(), + demandEnter: 0, + demandExit: 0, + candidates: Array.from({ length: 1 }, () => ({ + target: new Vector3(), + point: new Vector3(), + normal: new Vector3(), + object: null, + score: 0, + debugIndex: -1, + })), + centerSupport: { + target: new Vector3(), + point: new Vector3(), + normal: new Vector3(), + object: null, + score: 0, + debugIndex: 0, + }, + plannedPoints: Array.from({ length: 13 }, () => new Vector3()), + currentLocalPosition: new Vector3(), + currentLocalRotation: new Quaternion(), + animationBias: new Vector3(), + landingCorrection: new Vector3(), + continuityOffset: new Vector3(), + modelWorldRotation: new Quaternion(), + landingRotation: new Quaternion(), + worldScale: new Vector3(), + localPosition: new Vector3(), + localRotation: new Quaternion(), + pathPosition: new Vector3(), + pathRotation: new Quaternion(), + solePoint: new Vector3(), + soleOffset: new Vector3(), + supportHits: Array.from({ length: 4 }, () => ({ + point: new Vector3(), + normal: new Vector3(0, 1, 0), + })), + supportHitList: [], + clearanceSamples: Array.from({ length: 12 }, () => ({ + progress: 0, + requiredLift: 0, + })), + clearanceSampleCount: 0, + }; +} + +/** 创建单只脚的预测落脚运行时状态。 */ +export function createPredictiveFootState(): PredictiveFootState { + return { + mode: "none", + landingTarget: new Vector3(), + landingNormal: new Vector3(0, 1, 0), + supportPoint: new Vector3(), + animatedLanding: new Vector3(), + animatedLandingRotation: new Quaternion(), + trajectoryStartOffset: new Vector3(), + trajectoryCurrentTarget: new Vector3(), + trajectoryStartProgress: 0, + trajectoryProgress: 0, + trajectoryClearance: 0, + predictionWeight: 0, + usesOffsetLanding: false, + releaseOffset: new Vector3(), + releaseStartProgress: 0, + releaseActive: false, + supportObject: null, + supportLocalTarget: new Vector3(), + supportLocalPoint: new Vector3(), + supportLocalNormal: new Vector3(0, 1, 0), + probeElapsed: Infinity, + score: Infinity, + debugPlaneLift: 0, + debugSwingLift: 0, + debugTrajectory: Array.from({ length: 13 }, () => new Vector3()), + debugTrajectoryVisible: false, + debugSupportVisible: false, + debugSupportCorners: Array.from({ length: 4 }, () => new Vector3()), + debugCandidates: Array.from({ length: 1 }, () => ({ + point: new Vector3(), + evaluated: false, + valid: false, + selected: false, + })), + }; +} + +/** 清空预测目标并保留已分配的向量对象。 */ +export function resetPredictiveFootState(state: PredictiveFootState): void { + state.mode = "none"; + state.supportObject = null; + state.probeElapsed = Infinity; + state.score = Infinity; + state.debugPlaneLift = 0; + state.debugSwingLift = 0; + state.trajectoryStartOffset.set(0, 0, 0); + state.trajectoryCurrentTarget.set(0, 0, 0); + state.trajectoryStartProgress = 0; + state.trajectoryProgress = 0; + state.trajectoryClearance = 0; + state.predictionWeight = 0; + state.usesOffsetLanding = false; + state.releaseOffset.set(0, 0, 0); + state.releaseStartProgress = 0; + state.releaseActive = false; + state.debugTrajectoryVisible = false; + state.debugSupportVisible = false; + for (const candidate of state.debugCandidates) { + candidate.evaluated = false; + candidate.valid = false; + candidate.selected = false; + } +} + +/** 计算预测 IK 进入和退出的地形需求阈值。 */ +export function getPredictiveDemandThresholds( + appliedScale: number, + snapEpsilon: number, + scratch: PredictiveScratch, +): { enter: number; exit: number } { + // 退出阈值不高于进入阈值,避免需求在边界附近反复开关预测。 + scratch.demandEnter = Math.max( + snapEpsilon, + PREDICTION_DEMAND_ENTER * appliedScale, + ); + scratch.demandExit = Math.min( + scratch.demandEnter, + Math.max(snapEpsilon, PREDICTION_DEMAND_EXIT * appliedScale), + ); + return { enter: scratch.demandEnter, exit: scratch.demandExit }; +} + +/** 按终点权重把调试轨迹从起点平移到新的落点。 */ +export function shiftPredictiveDebugTrajectory( + points: Vector3[], + delta: Vector3, +): void { + if (delta.lengthSq() <= 1e-12 || points.length < 2) return; + for (let i = 0; i < points.length; i++) { + points[i].addScaledVector(delta, i / (points.length - 1)); + } +} + +// 把通过完整线路验证的候选同步为最终调试选中项。 +export function selectPredictiveDebugCandidate( + state: PredictiveFootState, + selectedIndex: number, +): void { + for (let i = 0; i < state.debugCandidates.length; i++) { + state.debugCandidates[i].selected = i === selectedIndex; + } +} + +/** 点高出支撑平面的距离;落在平面上或下方时为 0。 */ +export function getPointPlaneLift( + support: { point: Vector3; normal: Vector3 } | null, + point: Vector3, + fallbackY: number, + scratch: Vector3, +): number { + if (!support) return Math.max(0, point.y - fallbackY); + return Math.max(0, support.normal.dot(scratch.copy(point).sub(support.point))); +} + +/** 落点高出当前支撑平面的距离;落在平面上或下方时为 0。 */ +export function getPredictivePlaneDistance( + ctx: PredictivePlacementContext, + candidate: PredictiveFootCandidate, +): number { + return getPointPlaneLift( + ctx.castCapsuleGround(), + candidate.point, + candidate.point.y, + ctx.scratch.v5, + ); +} + +/** 预测需求取落点上台高度和摆腿途中高出支撑面的凸起。 */ +export function getPredictiveTerrainDemand( + planeLift: number, + swingLift: number, +): number { + return Math.max(planeLift, Math.max(0, swingLift)); +} + +/** 保存首次有效预测候选,并把目标转换为命中平台的局部锚点。 */ +export function acceptPredictiveFootCandidate( + state: PredictiveFootState, + candidate: PredictiveFootCandidate, + scratch: PredictiveScratch, +): void { + state.landingTarget.copy(candidate.target); + state.supportPoint.copy(candidate.point); + state.landingNormal.copy(candidate.normal); + state.score = candidate.score; + // 中心落点 debugIndex 为 0,落地后交还反应式 IK;偏移落点才进入 planted。 + state.usesOffsetLanding = candidate.debugIndex !== 0; + state.supportObject = candidate.object; + + if (state.supportObject) { + state.supportObject.updateMatrixWorld(true); + state.supportLocalTarget.copy(state.landingTarget); + state.supportObject.worldToLocal(state.supportLocalTarget); + state.supportLocalPoint.copy(state.supportPoint); + state.supportObject.worldToLocal(state.supportLocalPoint); + scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld).invert(); + state.supportLocalNormal + .copy(state.landingNormal) + .applyMatrix3(scratch.normalMatrix) + .normalize(); + } +} + +/** 根据已保存的平台局部锚点刷新预测目标、支撑点和世界法线。 */ +export function refreshPredictiveSupportAnchor( + leg: ReadyFootIKLeg, + scratch: PredictiveScratch, + debug = false, +): void { + const state = leg.predictive; + // 中心落点每帧跟随动画预计落点,不能用平台锚点覆盖水平位置。 + if ( + !state.supportObject + || (state.mode === "active" && !state.usesOffsetLanding) + ) { + return; + } + + const previousTarget = debug ? scratch.v1.copy(state.landingTarget) : null; + state.supportObject.updateMatrixWorld(true); + state.landingTarget.copy(state.supportLocalTarget); + state.supportObject.localToWorld(state.landingTarget); + state.supportPoint.copy(state.supportLocalPoint); + state.supportObject.localToWorld(state.supportPoint); + scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld); + state.landingNormal + .copy(state.supportLocalNormal) + .applyMatrix3(scratch.normalMatrix) + .normalize(); + + if (previousTarget) { + shiftPredictiveDebugTrajectory( + state.debugTrajectory, + scratch.v2.subVectors(state.landingTarget, previousTarget), + ); + } +} + +/** 把候选的落点和支撑面复制到另一份临时对象。 */ +function copyPredictiveFootCandidate( + source: PredictiveFootCandidate, + target: PredictiveFootCandidate, +): void { + target.target.copy(source.target); + target.point.copy(source.point); + target.normal.copy(source.normal); + target.object = source.object; + target.score = source.score; + target.debugIndex = source.debugIndex; +} + +/** 评估动画中心落点是否可站立,以及能否作为预测目标。 */ +export function findPredictiveFootCandidates( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState, + expected: Vector3, + expectedRotation: Quaternion, +): { + candidate: PredictiveFootCandidate | null; + centerSupport: PredictiveFootCandidate | null; +} { + const scratch = ctx.scratch; + // 用落地时刻的预计位移把髋部提前送到未来位置,再检查腿是否够得到落点。 + const futureRootDelta = scratch.v8 + .copy(ctx.predictionVelocity) + .multiplyScalar(Math.min(phase.timeToLand, ctx.predictionHorizon)); + + const debugCandidate = leg.predictive.debugCandidates[0]; + debugCandidate.evaluated = true; + debugCandidate.valid = false; + debugCandidate.selected = false; + debugCandidate.point.copy(expected); + const state = leg.predictive; + state.debugSupportVisible = false; + const probed = probePredictiveFootSupport( + ctx, + leg, + expected.x, + expected.z, + expected, + expectedRotation, + ); + if (!probed) { + return { candidate: null, centerSupport: null }; + } + + if (ctx.debug) { + fillDebugSupportCorners( + ctx, + leg, + expected.x, + expected.z, + expectedRotation, + probed.candidate, + ); + state.debugSupportVisible = true; + } + debugCandidate.point.copy(probed.candidate.point); + // 鞋底探到了地面但法线过陡或没有共面支撑时,不能当作预测目标。 + if (!probed.accepted) { + return { candidate: null, centerSupport: null }; + } + + copyPredictiveFootCandidate(probed.candidate, scratch.centerSupport); + const candidate = finalizePredictiveFootCandidate( + ctx, + leg, + probed.candidate, + probed.supportCount, + expected.x, + expected.z, + expected, + expectedRotation, + futureRootDelta, + ); + // 可站但超出抬降或腿长时仍返回支撑面,供调试四边形和需求显示使用。 + if (!candidate) { + return { candidate: null, centerSupport: scratch.centerSupport }; + } + + debugCandidate.valid = true; + debugCandidate.selected = true; + debugCandidate.point.copy(candidate.target); + return { candidate, centerSupport: scratch.centerSupport }; +} + +/** 把鞋底四点投影到合并后的落点支撑面上,供调试绘制。 */ +function fillDebugSupportCorners( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + centerX: number, + centerZ: number, + expectedRotation: Quaternion, + support: PredictiveFootCandidate, +): void { + const scratch = ctx.scratch; + const worldScale = leg.foot.getWorldScale(scratch.v9); + const corners = leg.predictive.debugSupportCorners; + // 略微沿法线抬起,避免四边形 z-fight 陷入地面。 + const lift = Math.max(ctx.snapEpsilon, ctx.appliedScale * 0.4); + for (let i = 0; i < corners.length; i++) { + const sample = leg.soleSamples[i]; + if (!sample) { + corners[i].copy(support.point).addScaledVector(support.normal, lift); + continue; + } + scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(expectedRotation); + const corner = corners[i]; + corner.set( + centerX + scratch.v4.x, + support.point.y, + centerZ + scratch.v4.z, + ); + corner.addScaledVector( + support.normal, + lift - support.normal.dot(scratch.v1.subVectors(corner, support.point)), + ); + } +} + +/** 只检查鞋底能否在该落点站稳,不考虑腿长和抬降限制。 */ +function probePredictiveFootSupport( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + centerX: number, + centerZ: number, + expected: Vector3, + expectedRotation: Quaternion, +): { candidate: PredictiveFootCandidate; supportCount: number; accepted: boolean } | null { + const scratch = ctx.scratch; + const footWorldQ = expectedRotation; + const worldScale = leg.foot.getWorldScale(scratch.v9); + const hits = scratch.supportHitList; + hits.length = 0; + let highestHit: FootIKGroundHit | null = null; + + // 只在动画中心 XZ 打鞋底四角;有一个共面命中即可作为支撑面。 + for (const sample of leg.soleSamples as FootIKSoleSample[]) { + const relative = scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(footWorldQ); + const hit = ctx.castGroundFrom( + centerX + relative.x, + expected.y + ctx.sampleRayOriginY, + centerZ + relative.z, + ); + if (!hit) continue; + const stored = scratch.supportHits[hits.length]; + stored.point.copy(hit.point); + stored.normal.copy(hit.normal); + stored.object = hit.object; + hits.push(stored); + if (!highestHit || stored.point.y > highestHit.point.y) highestHit = stored; + } + if (!highestHit) return null; + + const pooled = scratch.candidates[0]; + const supportCount = ctx.mergeCoplanarHits( + hits, + highestHit, + pooled.point, + pooled.normal, + ); + pooled.target.set(centerX, pooled.point.y, centerZ); + pooled.object = highestHit.object ?? null; + pooled.score = 0; + pooled.debugIndex = 0; + const accepted = supportCount >= 1 + && pooled.normal.y >= ctx.predictionMinNormalY; + return { candidate: pooled, supportCount, accepted }; +} + +/** 在可站立支撑之上再施加修正距离、抬降和腿长约束。 */ +function finalizePredictiveFootCandidate( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + pooled: PredictiveFootCandidate, + supportCount: number, + centerX: number, + centerZ: number, + expected: Vector3, + expectedRotation: Quaternion, + futureRootDelta: Vector3, +): PredictiveFootCandidate | null { + const scratch = ctx.scratch; + const correctionSq = (centerX - expected.x) ** 2 + (centerZ - expected.z) ** 2; + const maxCorrectionSq = ctx.maxPredictionCorrection * ctx.maxPredictionCorrection; + if (correctionSq > maxCorrectionSq) return null; + + const worldScale = leg.foot.getWorldScale(scratch.v9); + const alignQ = ctx.getConstrainedFootAlign( + pooled.normal, + ctx.footAlignWeight, + scratch.q2, + ); + const targetWorldQ = scratch.q3.copy(alignQ).multiply(expectedRotation); + + // 按贴地后的脚掌旋转,把脚骨放到鞋底刚好坐在支撑平面上的高度。 + let targetY = -Infinity; + for (const sample of leg.soleSamples) { + const relative = scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(targetWorldQ); + const sampleX = centerX + relative.x; + const sampleZ = centerZ + relative.z; + const planeY = pooled.point.y - ( + pooled.normal.x * (sampleX - pooled.point.x) + + pooled.normal.z * (sampleZ - pooled.point.z) + ) / pooled.normal.y; + targetY = Math.max(targetY, planeY - relative.y); + } + if (!Number.isFinite(targetY)) return null; + + pooled.target.set(centerX, targetY, centerZ); + const heightDelta = targetY - expected.y; + if (heightDelta > ctx.maxFootRaise || heightDelta < -ctx.maxFootDrop) return null; + + const hip = leg.upper.getWorldPosition(scratch.v5); + const knee = leg.lower.getWorldPosition(scratch.v6); + const foot = leg.foot.getWorldPosition(scratch.v7); + const upperLen = hip.distanceTo(knee); + const lowerLen = knee.distanceTo(foot); + const maxReach = ctx.getSafeLegReach(upperLen, lowerLen, ctx.pelvisKneeBend); + // 落地时髋会随角色根节点前移,用未来髋位判断当前落点是否仍在腿长内。 + hip.add(futureRootDelta); + const reachRatio = maxReach > 1e-6 ? hip.distanceTo(pooled.target) / maxReach : Infinity; + if (reachRatio > 1) return null; + + const correctionRatio = ctx.maxPredictionCorrection > 1e-6 + ? Math.sqrt(correctionSq) / ctx.maxPredictionCorrection + : 0; + const heightRange = Math.max(ctx.maxFootRaise, ctx.maxFootDrop, 1e-6); + // 分数仅用于调试显示;中心落点没有第二候选比较。 + pooled.score = correctionRatio + + (1 - pooled.normal.y) * 0.7 + + Math.abs(heightDelta) / heightRange * 0.4 + + (4 - supportCount) * 2 + + Math.max(0, reachRatio - 0.85) / 0.2 * 3; + return pooled; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts b/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts new file mode 100644 index 0000000..fe8aac8 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts @@ -0,0 +1,308 @@ +import { MathUtils, Quaternion, Vector3, type Object3D } from "three"; +import { sampleFootPhasePose } from "./footPhase"; +import { + PREDICTION_DEMAND_ENTER, + getPointPlaneLift, + type PredictiveFootCandidate, + type PredictivePlacementContext, +} from "./predictivePlacement"; +import type { + FootPhaseDatabase, + FootPhaseRuntimeState, + ReadyFootIKLeg, +} from "../types"; + +/** 完整线路验证使用的最小地形采样数。 */ +export const PREDICTION_MIN_TERRAIN_SAMPLE_COUNT = 4; +/** 完整线路验证使用的最大地形采样数。 */ +export const PREDICTION_MAX_TERRAIN_SAMPLE_COUNT = 12; +/** 相邻地形采样点的设计尺度间距。 */ +export const PREDICTION_TERRAIN_SAMPLE_SPACING = 10; +/** 预测启用后的剩余摆腿达到该比例时开始对齐支撑面。 */ +export const PREDICTION_ROTATION_WARP_START = 0.55; + +/** 单个地形采样点对摆动轨迹提出的上抬要求。 */ +export type PredictiveClearanceSample = { + progress: number; + requiredLift: number; +}; + +/** 已完成地形净空验证的预测摆动线路。 */ +export type PredictiveTrajectoryPlan = { + /** 整段摆腿需要追加的最大中段抬脚量。 */ + clearance: number; + /** 路径采样得到的最大上台高度,供进入/退出预测使用。 */ + terrainDemand: number; + points: Vector3[]; +}; + +/** 轨迹规划在候选评估配置之上额外需要的动画相位数据。 */ +export type PredictiveTrajectoryContext = PredictivePlacementContext & { + model: Object3D; + footPhaseClips: FootPhaseDatabase; + clipName: string; + normalizedTime: number; + swingClearance: number; + maxPredictionClearance: number; +}; + +// 返回两端为零、中段平滑抬高的净空权重。 +export function getPredictiveClearanceShape(progress: number): number { + const t = MathUtils.clamp(progress, 0, 1); + return 4 * t * (1 - t); +} + +/** 把启用后的剩余摆腿进度重新映射到 0 到 1。 */ +export function getPredictiveLocalProgress( + progress: number, + startProgress: number, +): number { + const remainingProgress = Math.max(1e-6, 1 - startProgress); + return MathUtils.clamp((progress - startProgress) / remainingProgress, 0, 1); +} + +// 根据整段地形采样求满足所有点的最小额外净空。 +export function solvePredictiveClearance( + samples: readonly PredictiveClearanceSample[], + margin: number, + maxClearance: number, + sampleCount = samples.length, +): number | null { + let requiredClearance = 0; + let obstructed = false; + const count = Math.min(sampleCount, samples.length); + + for (let i = 0; i < count; i++) { + const sample = samples[i]; + if (sample.requiredLift <= 0) continue; + const shape = getPredictiveClearanceShape(sample.progress); + // 净空曲线在两端为 0,若障碍落在两端则无法用中段抬脚躲开。 + if (shape <= 1e-6) return null; + obstructed = true; + requiredClearance = Math.max( + requiredClearance, + sample.requiredLift / shape, + ); + } + + if (!obstructed) return 0; + const clearance = requiredClearance + Math.max(0, margin); + return clearance <= Math.max(0, maxClearance) ? clearance : null; +} + +/** 沿剩余动画摆腿路径采样地形,并生成满足鞋底净空的预测线路。 */ +export function planPredictiveFootTrajectory( + ctx: PredictiveTrajectoryContext, + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState, + footWorld: Vector3, + trajectoryStart: Vector3, + trajectoryStartProgress: number, + candidate: PredictiveFootCandidate, +): PredictiveTrajectoryPlan | null { + const phaseClip = ctx.footPhaseClips.get(ctx.clipName); + if (!phaseClip || phaseClip.duration <= 0) return null; + + const sideData = phaseClip[leg.side]; + const scratch = ctx.scratch; + if (!sampleFootPhasePose( + sideData, + ctx.normalizedTime, + scratch.currentLocalPosition, + scratch.currentLocalRotation, + )) { + return null; + } + + const currentSampleWorld = scratch.currentLocalPosition.applyMatrix4( + ctx.model.matrixWorld, + ); + scratch.animationBias.subVectors(footWorld, currentSampleWorld); + scratch.landingCorrection.subVectors( + candidate.target, + leg.predictive.animatedLanding, + ); + const currentTrajectoryProgress = getPredictiveLocalProgress( + leg.predictive.trajectoryProgress, + trajectoryStartProgress, + ); + const startWarpAlpha = MathUtils.smoothstep(currentTrajectoryProgress, 0, 1); + // 当前输出已经叠了部分落点修正,起点残差里要扣掉这部分,避免重规划把脚再推一次。 + scratch.continuityOffset + .subVectors(trajectoryStart, footWorld) + .addScaledVector(scratch.landingCorrection, -startWarpAlpha); + ctx.model.getWorldQuaternion(scratch.modelWorldRotation); + ctx.getConstrainedFootAlign( + candidate.normal, + ctx.footAlignWeight, + scratch.q1, + ); + scratch.landingRotation + .copy(scratch.q1) + .multiply(leg.predictive.animatedLandingRotation); + leg.foot.getWorldScale(scratch.worldScale); + const remainingPhase = phase.timeToLand / phaseClip.duration; + + // 沿剩余动画摆腿取点:底是相位姿势,只叠加速度、起点连续和落点差量。 + const samplePath = ( + progress: number, + position: Vector3, + rotation: Quaternion, + ): boolean => { + const t = MathUtils.clamp(progress, 0, 1); + const normalizedTime = ctx.normalizedTime + remainingPhase * t; + if (!sampleFootPhasePose( + sideData, + normalizedTime, + scratch.localPosition, + scratch.localRotation, + )) { + return false; + } + + const trajectoryProgress = MathUtils.lerp(currentTrajectoryProgress, 1, t); + const warpAlpha = MathUtils.smoothstep(trajectoryProgress, 0, 1); + position + .copy(scratch.localPosition) + .applyMatrix4(ctx.model.matrixWorld) + .addScaledVector( + ctx.predictionVelocity, + Math.min(phase.timeToLand * t, ctx.predictionHorizon), + ) + .addScaledVector(scratch.animationBias, 1 - t) + .addScaledVector(scratch.continuityOffset, 1 - t) + .addScaledVector(scratch.landingCorrection, warpAlpha); + // 末段才把脚掌旋到落点法线,避免摆腿中段提前贴坡。 + rotation + .copy(scratch.modelWorldRotation) + .multiply(scratch.localRotation) + .slerp( + scratch.landingRotation, + MathUtils.smoothstep( + trajectoryProgress, + PREDICTION_ROTATION_WARP_START, + 1, + ), + ); + return true; + }; + + if (!samplePath(0, scratch.pathPosition, scratch.pathRotation)) return null; + + let startSupportY = -Infinity; + const startRayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y) + + ctx.sampleRayOriginY; + for (const sample of leg.soleSamples) { + scratch.soleOffset + .copy(sample.local) + .multiply(scratch.worldScale) + .applyQuaternion(scratch.pathRotation); + scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset); + const hit = ctx.castGroundFrom( + scratch.solePoint.x, + startRayOriginY, + scratch.solePoint.z, + ); + if (hit) startSupportY = Math.max(startSupportY, hit.point.y); + } + const currentSupport = ctx.castCapsuleGround(); + if (!Number.isFinite(startSupportY)) { + startSupportY = currentSupport?.point.y ?? -Infinity; + } + if (!Number.isFinite(startSupportY)) startSupportY = candidate.point.y; + // 低于进入阈值的凸起当作平地,不计入摆腿净空需求。 + const terrainEpsilon = Math.max( + ctx.snapEpsilon, + PREDICTION_DEMAND_ENTER * ctx.appliedScale, + ); + const horizontalDistance = Math.hypot( + candidate.target.x - scratch.pathPosition.x, + candidate.target.z - scratch.pathPosition.z, + ); + const sampleSpacing = Math.max( + ctx.snapEpsilon, + PREDICTION_TERRAIN_SAMPLE_SPACING * ctx.appliedScale, + ); + const terrainSampleCount = MathUtils.clamp( + Math.ceil(horizontalDistance / sampleSpacing), + PREDICTION_MIN_TERRAIN_SAMPLE_COUNT, + PREDICTION_MAX_TERRAIN_SAMPLE_COUNT, + ); + let maxPlaneLift = 0; + scratch.clearanceSampleCount = 0; + + // 在起点和终点之间采样鞋底净空;两端不采,避免把落点本身当成中途障碍。 + for (let i = 1; i <= terrainSampleCount; i++) { + const progress = i / (terrainSampleCount + 1); + if (!samplePath(progress, scratch.pathPosition, scratch.pathRotation)) { + return null; + } + + let requiredLift = 0; + const rayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y) + + ctx.sampleRayOriginY; + for (const sample of leg.soleSamples) { + scratch.soleOffset + .copy(sample.local) + .multiply(scratch.worldScale) + .applyQuaternion(scratch.pathRotation); + scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset); + const hit = ctx.castGroundFrom( + scratch.solePoint.x, + rayOriginY, + scratch.solePoint.z, + ); + if (!hit) continue; + const planeLift = getPointPlaneLift( + currentSupport, + hit.point, + startSupportY, + scratch.v5, + ); + if (planeLift <= terrainEpsilon) continue; + maxPlaneLift = Math.max(maxPlaneLift, planeLift); + requiredLift = Math.max(requiredLift, hit.point.y - scratch.solePoint.y); + } + const clearanceSample = scratch.clearanceSamples[scratch.clearanceSampleCount++]; + clearanceSample.progress = MathUtils.lerp( + currentTrajectoryProgress, + 1, + progress, + ); + clearanceSample.requiredLift = Math.max(0, requiredLift); + } + + const clearance = solvePredictiveClearance( + scratch.clearanceSamples, + ctx.swingClearance, + ctx.maxPredictionClearance, + scratch.clearanceSampleCount, + ); + if (clearance === null) return null; + + const points = scratch.plannedPoints; + for (let i = 0; i < points.length; i++) { + const progress = i / (points.length - 1); + if (!samplePath(progress, points[i], scratch.pathRotation)) return null; + const trajectoryProgress = MathUtils.lerp( + currentTrajectoryProgress, + 1, + progress, + ); + // 重规划时当前帧已经带了一截净空,后面的点要扣掉这份,避免整条线再抬一次。 + const carriedClearance = clearance + * getPredictiveClearanceShape(currentTrajectoryProgress) + * (1 - progress); + points[i].addScaledVector( + ctx.up, + clearance * getPredictiveClearanceShape(trajectoryProgress) + - carriedClearance, + ); + } + + return { + clearance, + terrainDemand: Math.max(0, maxPlaneLift), + points, + }; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts b/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts new file mode 100644 index 0000000..38b3371 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts @@ -0,0 +1,155 @@ +import { Vector3, type Bone, type Object3D } from "three"; +import type { + BoneRef, + FootIKLeg, + FootIKSide, + FootIKSkeletonConfig, + ReadyFootIKLeg, +} from "../types"; +import { createPredictiveFootState } from "./predictivePlacement"; + +// 收集模型层级下的全部骨骼。 +export function collectBones(root: Object3D | null | undefined): Bone[] { + const bones: Bone[] = []; + root?.traverse(obj => { + if ((obj as Bone).isBone) bones.push(obj as Bone); + }); + return bones; +} + +// 查找常见命名里的骨盆/髋部骨骼。 +export function findHips(bones: readonly Bone[]): Bone | null { + return bones.find(bone => /hips|pelvis/i.test(bone.name)) ?? null; +} + +// 创建单条腿的骨骼引用和 IK 运行时状态。 +export function createLeg( + side: FootIKSide, + bones: readonly Bone[], + skeletonConfig: FootIKSkeletonConfig | null = null, +): FootIKLeg { + const legConfig = skeletonConfig?.legs?.[side] ?? null; + const upper = resolveConfiguredBone(legConfig?.upper, bones, `${side}.upper`) + ?? findBone(bones, side, "upper"); + const lower = resolveConfiguredBone(legConfig?.lower, bones, `${side}.lower`) + ?? findBone(bones, side, "lower"); + const foot = resolveConfiguredBone(legConfig?.foot, bones, `${side}.foot`) + ?? findBone(bones, side, "foot"); + const toe = resolveConfiguredBone(legConfig?.toe, bones, `${side}.toe`) + ?? findBone(bones, side, "toe"); + + return { + side, + upper, + lower, + foot, + toe, + ready: !!(upper && lower && foot), + + // IK 运行时目标和地面命中信息。 + smoothedTarget: new Vector3(), + heightSupport: null, + heightSupportLocal: new Vector3(), + heightSupportWorldY: 0, + heightSupportScale: 1, + pelvisTarget: new Vector3(), + hasPelvisTarget: false, + hitPoint: new Vector3(), + hitNormal: new Vector3(0, 1, 0), + supportPoint: new Vector3(), + supportNormal: new Vector3(0, 1, 0), + footSamplePoint: new Vector3(), + soleSamples: ["heelL", "heelR", "toeL", "toeR"].map(name => ({ + name, + local: new Vector3(), + point: new Vector3(), + hitPoint: new Vector3(), + hasHit: false, + marker: null, + rayLine: null, + footMarker: null, + })), + bestGroundSampleIndex: -1, + + // 本帧脚部目标对动画脚位的修正量。 + offsetY: 0, + movePenetrating: false, + + // weight 为最终 IK 权重,plantedWeight 只负责支撑脚渐入/渐出。 + weight: 0, + plantedWeight: 0, + planted: false, + predictive: createPredictiveFootState(), + + // 上一帧 pole 只在弯曲平面退化时使用。 + lastPole: new Vector3(), + hasLastPole: false, + + marker: null, + hitMarker: null, + rayLine: null, + raiseLimitLine: null, + predictiveLine: null, + predictiveCandidateMarkers: [], + predictiveSupportMesh: null, + predictiveSupportOutline: null, + }; +} + +// 判断腿链是否包含求解需要的三段骨骼。 +export function isReadyLeg(leg: FootIKLeg): leg is ReadyFootIKLeg { + return !!(leg.ready && leg.upper && leg.lower && leg.foot); +} + +// 优先使用外部配置的骨骼引用,并在缺失时给出提示。 +export function resolveConfiguredBone( + ref: BoneRef | undefined, + bones: readonly Bone[], + label: string, + warnMissing = true, +): Bone | null { + if (!ref) return null; + if (typeof ref !== "string" && ref.isBone) return ref; + if (typeof ref === "string") { + const bone = bones.find(item => item.name === ref) ?? null; + if (!bone && warnMissing) { + console.warn(`[FootIK] 配置骨骼未找到:${label} -> "${ref}"`); + } + return bone; + } + console.warn(`[FootIK] 无效骨骼配置:${label}`, ref); + return null; +} + +// 根据左右侧和骨骼类型,从模型骨骼名中启发式匹配目标骨骼。 +export function findBone( + bones: readonly Bone[], + side: FootIKSide, + type: "upper" | "lower" | "foot" | "toe", +): Bone | null { + const candidates = bones.filter(bone => matchesSide(bone.name, side)); + const score = (bone: Bone): number => { + const name = compactName(bone.name); + if (type === "upper") return Number(name.includes("upleg") || name.includes("thigh")); + if (type === "lower") return Number((name.includes("leg") || name.includes("calf") || name.includes("shin")) && !name.includes("upleg") && !name.includes("foot")); + if (type === "foot") return Number((name.includes("foot") || name.includes("ankle")) && !name.includes("toe") && !name.includes("ball")); + return Number(name.includes("toe") || (name.includes("ball") && !name.includes("leaf"))); + }; + return candidates + .map(bone => ({ bone, score: score(bone) })) + .filter(item => item.score > 0) + .sort((a, b) => b.score - a.score)[0]?.bone ?? null; +} + +// 判断骨骼名是否属于指定左右侧。 +export function matchesSide(name: string, side: FootIKSide): boolean { + const lower = name.toLowerCase(); + const compact = compactName(name); + if (side === "left") return compact.includes("left") || lower.includes("_l") || lower.includes(".l") || lower.includes(" l"); + return compact.includes("right") || lower.includes("_r") || lower.includes(".r") || lower.includes(" r"); +} + +// 把骨骼名压缩成便于规则匹配的形式。 +export function compactName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts b/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts new file mode 100644 index 0000000..fe2747d --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts @@ -0,0 +1,260 @@ +import { MathUtils, Quaternion, Vector3, type Bone, type Object3D } from "three"; + +export type TwoBoneIKScratch = { + v1: Vector3; + v2: Vector3; + v3: Vector3; + v4: Vector3; + v5: Vector3; + v6: Vector3; + v7: Vector3; + v8: Vector3; + v9: Vector3; + v10: Vector3; + v11: Vector3; + v12: Vector3; + v13: Vector3; + q1: Quaternion; + q2: Quaternion; + q3: Quaternion; + identityQ: Quaternion; +}; + +/** 双骨骼 IK 解算所需的最小腿链。 */ +export type TwoBoneIKLeg = { + upper: Bone; + lower: Bone; + foot: Bone; + /** 上一帧的世界空间 pole,只在弯曲平面退化时用于保持连续。 */ + lastPole?: Vector3; + hasLastPole?: boolean; +}; + +export type SolveTwoBoneIKOptions = { + scratch?: TwoBoneIKScratch; + /** 修改骨骼前通知外部保存动画原姿态,下一帧 restore 会用到。 */ + capture?: (bone: Object3D) => void; + /** 最小膝盖弯曲角,避免腿完全锁死伸直。 */ + minKneeBend?: number; + /** 最大膝盖弯曲角,避免过度折叠。 */ + maxKneeBend?: number; + /** + * 膝盖弯曲平面的世界空间法线。 + * 角色的右方向作为法线时,膝盖只能在角色的前后平面内弯曲。 + */ + kneePlaneNormal?: Vector3; + /** kneePlaneNormal 对原动画 pole 的覆盖权重,范围 0 到 1,默认 1。 */ + kneePlaneWeight?: number; +}; + +// 创建双骨骼 IK 解算复用对象,避免每帧重复分配临时变量。 +export function createTwoBoneIKScratch(): TwoBoneIKScratch { + return { + v1: new Vector3(), + v2: new Vector3(), + v3: new Vector3(), + v4: new Vector3(), + v5: new Vector3(), + v6: new Vector3(), + v7: new Vector3(), + v8: new Vector3(), + v9: new Vector3(), + v10: new Vector3(), + v11: new Vector3(), + v12: new Vector3(), + v13: new Vector3(), + q1: new Quaternion(), + q2: new Quaternion(), + q3: new Quaternion(), + identityQ: new Quaternion(), + }; +} + +// 受膝盖角度约束的双骨骼 IK。 +// leg.upper / leg.lower / leg.foot 分别对应大腿、小腿、脚;target 是 foot 希望靠近的世界坐标。 +export function solveTwoBoneIK( + leg: TwoBoneIKLeg | null | undefined, + target: Vector3, + weight: number, + options: SolveTwoBoneIKOptions = {}, +): void { + if (!leg?.upper || !leg?.lower || !leg?.foot || weight <= 0.001) return; + + const scratch = options.scratch ?? createTwoBoneIKScratch(); + const capture = options.capture ?? (() => { }); + const minBend = options.minKneeBend ?? MathUtils.degToRad(2); + const maxBend = options.maxKneeBend ?? MathUtils.degToRad(145); + const solveWeight = MathUtils.clamp(weight, 0, 1); + + // 静止时动画 pole 是主要弯曲方向;移动时只在稳定弯曲平面退化后作为兜底。 + // 移动状态由角色前后平面决定膝盖朝向。 + const restPole = getRestPole(leg, scratch); + + const hip = leg.upper.getWorldPosition(scratch.v1); + const knee = leg.lower.getWorldPosition(scratch.v2); + const foot = leg.foot.getWorldPosition(scratch.v3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + + const hipToTarget = scratch.v4.copy(target).sub(hip); + const targetDistance = hipToTarget.length(); + if (targetDistance < 0.0001) return; + + // 用膝盖最小/最大弯曲角反推 foot 能到达的距离范围。 + // target 太远会被夹到最大伸展距离,太近会被夹到最大弯曲距离。 + const maxReach = reachFromBend(upperLen, lowerLen, minBend); + const minReach = reachFromBend(upperLen, lowerLen, maxBend); + const clampedDistance = MathUtils.clamp(targetDistance, minReach, maxReach); + const clampedTarget = scratch.v5.copy(hip).addScaledVector(hipToTarget, clampedDistance / targetDistance); + + // 在 hip -> target 的链方向上,用两段骨骼长度求出 knee 所在圆的中心和半径。 + // pole 决定 knee 落在圆的哪一侧,也就是膝盖朝向。 + // chainDir × 角色右轴得到的方向始终位于角色前后平面内,因此是“笔直弯”。 + const chainDir = scratch.v6.copy(clampedTarget).sub(hip).normalize(); + const pole = getStablePole( + leg, + restPole, + options.kneePlaneNormal, + MathUtils.clamp(options.kneePlaneWeight ?? 1, 0, 1), + chainDir, + scratch.v7, + scratch.v4, + scratch.q1, + scratch.identityQ, + ); + const along = (upperLen * upperLen - lowerLen * lowerLen + clampedDistance * clampedDistance) / (2 * clampedDistance); + const height = Math.sqrt(Math.max(0, upperLen * upperLen - along * along)); + const desiredKnee = scratch.v8.copy(hip).addScaledVector(chainDir, along).addScaledVector(pole, height); + + // 第一步旋转大腿,让当前 knee 靠近解析出的 desiredKnee。 + rotateBoneToward(leg.upper, leg.lower.getWorldPosition(scratch.v9), desiredKnee, solveWeight, capture, scratch); + leg.upper.updateMatrixWorld(true); + leg.lower.updateMatrixWorld(true); + + // 第二步旋转小腿,让 foot 靠近被夹距后的目标点。 + rotateBoneToward(leg.lower, leg.foot.getWorldPosition(scratch.v9), clampedTarget, solveWeight, capture, scratch); + leg.lower.updateMatrixWorld(true); +} + +// 两段骨骼夹角为 bend 时,hip 到 foot 的理论距离。 +function reachFromBend(upperLen: number, lowerLen: number, bend: number): number { + return Math.sqrt(upperLen * upperLen + lowerLen * lowerLen + 2 * upperLen * lowerLen * Math.cos(bend)); +} + +// 从当前动画姿态提取膝盖弯曲方向。 +// 把 hip->knee 投影到 hip->foot 的垂直平面上,得到“膝盖偏离腿链”的方向。 +function getRestPole(leg: TwoBoneIKLeg, scratch: TwoBoneIKScratch): Vector3 { + const hip = leg.upper.getWorldPosition(scratch.v9); + const knee = leg.lower.getWorldPosition(scratch.v10); + const foot = leg.foot.getWorldPosition(scratch.v11); + const restChain = scratch.v12.copy(foot).sub(hip).normalize(); + const restPole = scratch.v13.copy(knee).sub(hip).addScaledVector(restChain, -scratch.v10.copy(knee).sub(hip).dot(restChain)); + if (restPole.lengthSq() < 0.000001) restPole.set(0, 0, 1); + return scratch.v13.normalize(); +} + +// 优先由角色弯曲平面直接求 pole。这样动画里的横向膝摆不会参与选边。 +function getStablePole( + leg: TwoBoneIKLeg, + restPole: Vector3, + kneePlaneNormal: Vector3 | undefined, + kneePlaneWeight: number, + chainDir: Vector3, + target: Vector3, + straightPole: Vector3, + poleRotation: Quaternion, + identityRotation: Quaternion, +): Vector3 { + const hasRestPole = projectPoleToChainPlane(restPole, chainDir, target); + + if (kneePlaneNormal && kneePlaneWeight > 0) { + straightPole.crossVectors(chainDir, kneePlaneNormal); + if (normalizePole(straightPole)) { + if (hasRestPole && kneePlaneWeight < 1) { + poleRotation + .setFromUnitVectors(target, straightPole) + .slerp(identityRotation, 1 - kneePlaneWeight); + target.applyQuaternion(poleRotation).normalize(); + } else { + target.copy(straightPole); + } + rememberPole(leg, target); + return target; + } + + // 移动时腿若几乎沿角色左右方向水平伸出,前后平面内没有唯一解。 + // 只在这个奇点沿用上一帧有效 pole,避免跨帧翻面。 + if (leg.hasLastPole && leg.lastPole + && projectPoleToChainPlane(leg.lastPole, chainDir, target)) { + rememberPole(leg, target); + return target; + } + } + + // 静止模式直接走这里,完整保留 idle 动画 pole,不读取上一帧的历史结果。 + if (hasRestPole) { + projectPoleToChainPlane(restPole, chainDir, target); + rememberPole(leg, target); + return target; + } + + // 极端共线姿态的最终兜底。 + target.set(0, chainDir.z, -chainDir.y); + if (!normalizePole(target)) { + target.set(-chainDir.y, chainDir.x, 0); + normalizePole(target); + } + rememberPole(leg, target); + return target; +} + +// 把方向投影到腿链垂直平面上,得到可用的膝盖 pole;投影退化时返回 false。 +function projectPoleToChainPlane(direction: Vector3, chainDir: Vector3, target: Vector3): boolean { + target.copy(direction).addScaledVector(chainDir, -direction.dot(chainDir)); + return normalizePole(target); +} + +// 归一化 pole;长度过小时视为无效方向。 +function normalizePole(pole: Vector3): boolean { + const lengthSq = pole.lengthSq(); + if (lengthSq < 0.000001) return false; + pole.multiplyScalar(1 / Math.sqrt(lengthSq)); + return true; +} + +// 缓存本帧有效 pole,供下一帧奇点兜底。 +function rememberPole(leg: TwoBoneIKLeg, pole: Vector3): void { + if (!leg.lastPole) return; + leg.lastPole.copy(pole); + leg.hasLastPole = true; +} + +// 把 bone 旋转到当前 effector 方向对齐目标方向。 +// deltaWorldQ 在世界空间计算,最后转换回 bone 的本地 quaternion 写入骨骼。 +function rotateBoneToward( + bone: Bone, + effectorWorld: Vector3, + targetWorld: Vector3, + weight: number, + capture: (bone: Object3D) => void, + scratch: TwoBoneIKScratch, +): void { + capture(bone); + + const jointWorld = bone.getWorldPosition(scratch.v10); + const from = scratch.v11.copy(effectorWorld).sub(jointWorld).normalize(); + const to = scratch.v12.copy(targetWorld).sub(jointWorld).normalize(); + if (from.lengthSq() < 0.0001 || to.lengthSq() < 0.0001) return; + + const deltaWorldQ = scratch.q1.setFromUnitVectors(from, to); + // weight < 1 时只应用部分旋转,保留一部分原动画姿态。 + deltaWorldQ.slerp(scratch.identityQ, 1 - weight); + + const jointWorldQ = bone.getWorldQuaternion(scratch.q2); + const targetWorldQ = deltaWorldQ.multiply(jointWorldQ); + const parent = bone.parent; + if (!parent) return; + const parentWorldQ = parent.getWorldQuaternion(scratch.q3); + bone.quaternion.copy(parentWorldQ.invert().multiply(targetWorldQ)); + bone.updateMatrixWorld(true); +} diff --git a/three-player-controller/src/plugins/foot-ik/types.ts b/three-player-controller/src/plugins/foot-ik/types.ts new file mode 100644 index 0000000..7734ac3 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/types.ts @@ -0,0 +1,431 @@ +import type { + AnimationAction, + AnimationClip, + Bone, + Line3, + Line, + LineSegments, + Mesh, + Object3D, + Quaternion, + Scene, + Vector3, +} from "three"; + +/** 骨骼对象或用于查找骨骼的名称。 */ +export type BoneRef = Bone | string; + +/** 左右脚标识。 */ +export type FootIKSide = "left" | "right"; + +/** Foot IK 地面检测时忽略的动态刚体形状。 */ +export type FootIKIgnoredDynamicKind = "sphere" | "box"; + +/** FootIK 使用的控制器字段;实际传入对象仍是 playerController。 */ +export type FootIKPlayer = { + /** Three.js 场景。 */ + scene: Scene; + /** 返回可供脚底射线检测的静态与运动学碰撞网格。 */ + getColliderMeshes: () => Mesh[]; + /** 返回动态刚体列表;用于判断当前是否存在可参与脚底检测的动态地面。 */ + getDynamicBodies?: () => unknown[]; + /** 从世界坐标向下检测动态刚体,并过滤法线过陡、不可站立的表面。 */ + raycastDynamicGround?: ( + origin: Vector3, + minNormalY?: number, + excludeKinds?: readonly FootIKIgnoredDynamicKind[], + ) => { + /** 动态刚体表面的世界空间命中点。 */ + point: Vector3; + /** 命中面的世界空间单位法线。 */ + normal: Vector3; + /** 动态刚体及其视觉变换;预测线路使用其局部空间保存落点。 */ + body?: { mesh: Object3D }; + } | null; + /** 当前站立的动态刚体;站在球体上时 Foot IK 会忽略该支撑。 */ + getActiveDynamicBody?: () => { kind: FootIKIgnoredDynamicKind } | null; + /** 返回角色当前世界空间速度;预测落脚启用时用于补充胶囊实际位移速度。 */ + getVelocity?: () => Vector3; + /** 角色移动系统本帧最终采用的支撑点;可来自射线或体积探测。 */ + getGroundSupport?: () => { + /** 控制器实际用于贴地的世界空间支撑点。 */ + point: Vector3; + /** 实际支撑面的世界空间单位法线。 */ + normal: Vector3; + } | null; + /** 玩家碰撞胶囊;其世界位置同时作为角色根节点和骨盆支撑参考。 */ + playerCapsule: Mesh & { + /** 胶囊几何参数;尚未完成玩家初始化时可能不存在。 */ + capsuleInfo?: { + /** 胶囊半径,使用当前角色缩放后的世界尺度。 */ + radius: number; + /** 胶囊中心轴线段,保存于胶囊本地空间。 */ + segment: Line3; + }; + }; + /** 玩家可见模型根节点;Foot IK 从该节点中查找骨盆和腿部骨骼。 */ + playerModel: Object3D | null; + /** 角色当前是否接地;未接地时跳过脚底贴合与骨盆补偿。 */ + playerIsOnGround: boolean; + /** 角色是否处于飞行模式;飞行时不执行 Foot IK。 */ + isFlying?: boolean; + /** Foot IK 需要使用的玩家缩放和移动动画名称配置。 */ + playerModelConfig: { + /** 玩家当前整体缩放,用于同步射线长度、容差和脚底尺寸。 */ + scale: number; + /** 默认向前行走动画名称。 */ + walkAnim: string; + /** 默认向前奔跑动画名称。 */ + runAnim: string; + /** 可选的左向移动动画名称。 */ + leftWalkAnim?: string; + /** 可选的右向移动动画名称。 */ + rightWalkAnim?: string; + /** 可选的后退动画名称。 */ + backwardAnim?: string; + }; + /** 当前玩家动画资源与正在播放的动画动作。 */ + animation: { + /** 用于建立脚步着地相位数据库的全部动画片段。 */ + clips: AnimationClip[]; + /** 当前动画动作;用于读取动画名称、播放时间和脚步相位。 */ + state?: AnimationAction; + }; + /** 0 步行 / 1 载具;载具模式时跳过贴地 IK。 */ + getControllerMode?: () => number; +}; + +/** 单条腿参与 IK 的骨骼配置。 */ +export type FootIKLegBoneConfig = { + /** 大腿骨骼。 */ + upper?: BoneRef; + /** 小腿骨骼。 */ + lower?: BoneRef; + /** 脚部骨骼。 */ + foot?: BoneRef; + /** 脚趾骨骼。 */ + toe?: BoneRef; +}; + +/** FootIK 使用的骨盆和左右腿骨骼配置。 */ +export type FootIKSkeletonConfig = { + /** 骨盆骨骼。 */ + hips?: BoneRef; + /** 左右腿骨骼配置。 */ + legs?: { + /** 左腿骨骼配置。 */ + left?: FootIKLegBoneConfig; + /** 右腿骨骼配置。 */ + right?: FootIKLegBoneConfig; + }; +}; + +/** FootIK 插件初始化配置。 */ +export type FootIKOptions = { + /** 腿部骨骼绑定;未传时根据骨骼名自动匹配。 */ + skeleton?: FootIKSkeletonConfig; + /** + * 是否显示统一调试对象,默认 false。 + * 包含:IK 目标、最高地面命中、脚底四点(采样原点 + 命中 + 射线)。 + */ + debug?: boolean; + /** 是否启用插件,默认 true。 */ + enabled?: boolean; + /** 骨盆最大下沉距离基准值(按 scale 缩放),默认 50。 */ + maxPelvisDrop?: number; + /** 双脚都高于胶囊时,按较低脚上抬骨盆的最大距离基准值(按 scale 缩放),默认 50。 */ + maxPelvisRaise?: number; + /** 脚部 IK 最大上抬距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */ + maxFootRaise?: number; + /** 支撑脚 IK 最大下探距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */ + maxFootDrop?: number; + /** 支撑脚相对支撑面的贴地高度速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */ + plantedHeightSpeed?: number; + /** 摆动脚相对支撑面的陷入上抬速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */ + penetrationLiftSpeed?: number; + /** 虚拟脚底左右半宽基准值(按 scale 缩放),默认 7。 */ + soleHalfWidth?: number; + /** 脚尖采样点向前延伸距离基准值(按 scale 缩放),默认 7。 */ + soleToeExtend?: number; + /** 脚跟采样点向后延伸距离基准值(按 scale 缩放),默认 3。 */ + soleHeelExtend?: number; + /** + * 脚骨到脚底蒙皮的厚度补偿基准值(按 scale 缩放),默认 3。 + * 贴地校正时额外上抬,避免按骨骼贴地导致鞋底陷入地面;平地接近满值,斜坡按法线衰减。 + */ + soleSkinThickness?: number; + /** 脚掌贴合地面法线的权重,范围 0 到 1,默认 1。 */ + footAlignWeight?: number; + /** 脚掌最大倾斜角,单位为弧度,默认 Math.PI / 2。 */ + maxFootTilt?: number; + /** 膝盖最小弯曲角,单位为弧度,默认 2°。 */ + minKneeBend?: number; + /** 膝盖最大弯曲角,单位为弧度,默认 145°。 */ + maxKneeBend?: number; + /** 骨盆可达性计算保留的膝盖弯曲角,单位为弧度,默认 0°。 */ + pelvisKneeBend?: number; + /** 移动时是否使用直 pole(膝盖朝角色前方平面),默认 false,关闭则保留动画 pole。 */ + straightPoleEnabled?: boolean; + /** 移动时脚底穿透触发阈值基准值(按 scale 缩放),默认 0.1。 */ + moveLiftThreshold?: number; + /** 单个移动动画的脚步相位采样数,默认 96。 */ + footPhaseSampleCount?: number; + /** 脚步相位接地高度阈值基准值(按 scale 缩放),默认 5。 */ + footPhaseGroundThreshold?: number; + /** 最短接触段占动画周期的比例,默认 0.04。 */ + footPhaseMinContactRatio?: number; + /** 支撑脚水平速度过滤倍率,默认 1.35。 */ + footPhaseSpeedSlack?: number; + /** 是否启用预测落脚;默认 false,关闭时完全沿用反应式 Foot IK。 */ + predictivePlacement?: boolean; + /** 最长预测时间,单位为秒,默认 0.45。 */ + predictionHorizon?: number; + /** 预测地面候选重新探测间隔,单位为帧,默认 10;0 表示每帧探测。 */ + predictionProbeFrames?: number; + /** 预测落脚候选相对动画落点的搜索半径基准值(按 scale 缩放),默认 20。 */ + predictionSearchRadius?: number; + /** 允许预测目标偏离动画落点的最大水平距离基准值(按 scale 缩放),默认 45。 */ + maxPredictionCorrection?: number; + /** 预测落脚允许的最小地面法线 Y 分量,默认 0.55。 */ + predictionMinNormalY?: number; + /** 检测到摆动障碍后追加的净空余量(按 scale 缩放),默认 8。 */ + swingClearance?: number; + /** 预测摆动轨迹允许增加的最大净空(按 scale 缩放),默认跟随 maxFootRaise。 */ + maxPredictionClearance?: number; +}; + +/** 预测落脚运行时状态。 */ +export type PredictiveFootMode = "none" | "tracking" | "active" | "planted"; + +/** 单只脚的预测目标、支撑面和平台锚点。 */ +export type PredictiveFootState = { + /** none 未启用;tracking 探测中;active 摆腿走预测线路;planted 支撑期钉住偏移落点。 */ + mode: PredictiveFootMode; + /** 脚骨 IK 目标;鞋底贴在支撑面上时脚骨应到达的位置。 */ + landingTarget: Vector3; + /** 预测落点支撑面的世界空间单位法线。 */ + landingNormal: Vector3; + /** 鞋底四点共面合并后的地面接触点,与 landingTarget 不是同一点。 */ + supportPoint: Vector3; + /** 动画预计落地时的脚骨位置,已加上剩余落地时间的水平速度。 */ + animatedLanding: Vector3; + /** 动画预计落地时的脚骨世界旋转。 */ + animatedLandingRotation: Quaternion; + /** 启用预测时当前脚相对动画脚的起点残差,随 warp 渐隐。 */ + trajectoryStartOffset: Vector3; + /** 上一帧实际输出的预测脚目标,重规划时用来保持世界空间连续。 */ + trajectoryCurrentTarget: Vector3; + /** 预测线路启用瞬间的摆腿进度,用于把剩余摆腿重映射到 0 到 1。 */ + trajectoryStartProgress: number; + /** 当前摆腿进度;只增不减,供 warp 和末段锁面使用。 */ + trajectoryProgress: number; + /** 摆腿途中为越过凸起追加的净空高度。 */ + trajectoryClearance: number; + /** 由地形需求在进入/退出阈值之间平滑得到的预测 IK 权重。 */ + predictionWeight: number; + /** 落点相对动画中心做了水平偏移时为 true;中心落点落地后交还反应式 IK。 */ + usesOffsetLanding: boolean; + /** 上一支撑偏移落点相对新摆腿动画脚的残差,前段逐渐还给动画。 */ + releaseOffset: Vector3; + /** 开始释放残差时的摆腿进度。 */ + releaseStartProgress: number; + /** 本摆腿前段是否仍在释放上一支撑残差。 */ + releaseActive: boolean; + /** 落点所在网格;移动平台用局部锚点把目标跟到世界空间。 */ + supportObject: Object3D | null; + /** landingTarget 在 supportObject 局部空间中的锚点。 */ + supportLocalTarget: Vector3; + /** supportPoint 在 supportObject 局部空间中的锚点。 */ + supportLocalPoint: Vector3; + /** landingNormal 在 supportObject 局部空间中的方向。 */ + supportLocalNormal: Vector3; + /** 距上次地面探测的帧数;Infinity 表示本摆腿尚未探测。 */ + probeElapsed: number; + /** 当前预测候选评分;越小越好,无有效候选时为 Infinity。 */ + score: number; + /** 调试:落点高出胶囊支撑面的高度。 */ + debugPlaneLift: number; + /** 调试:摆腿路径上高出支撑面的最大凸起。 */ + debugSwingLift: number; + /** 调试:规划出的摆腿线路采样点。 */ + debugTrajectory: Vector3[]; + /** 调试:当前是否绘制预测摆腿线路。 */ + debugTrajectoryVisible: boolean; + /** 调试:当前是否绘制预测支撑面。 */ + debugSupportVisible: boolean; + /** 调试:预测支撑面四角的世界坐标。 */ + debugSupportCorners: Vector3[]; + /** 调试:本帧评估过的落点候选。 */ + debugCandidates: Array<{ + /** 候选支撑点的世界坐标。 */ + point: Vector3; + /** 本帧是否评估过该候选。 */ + evaluated: boolean; + /** 该候选是否通过抬降和腿长约束。 */ + valid: boolean; + /** 是否为最终采用的候选。 */ + selected: boolean; + }>; +}; + +/** 脚底查询统一使用世界空间命中点和法线。 */ +export type FootIKGroundHit = { + point: Vector3; + normal: Vector3; + object?: Object3D; +}; + +// 单个地面探测采样点的运行时数据。 +export type FootIKProbeSample = { + name: string; + point: Vector3; + hitPoint: Vector3; + hasHit: boolean; +}; + +// 固定在 foot 本地空间中的脚底采样点 +export type FootIKSoleSample = FootIKProbeSample & { + local: Vector3; + /** 地面命中调试球(线框)。 */ + marker: Mesh | null; + /** 采样原点 → 命中点。 */ + rayLine: Line | null; + /** 脚底采样原点调试球(实心)。 */ + footMarker: Mesh | null; +}; + +// 单条腿的骨骼引用、IK 状态和调试对象。 +export type FootIKLeg = { + side: FootIKSide; + upper: Bone | null; + lower: Bone | null; + foot: Bone | null; + toe: Bone | null; + ready: boolean; + smoothedTarget: Vector3; + heightSupport: Object3D | null; + heightSupportLocal: Vector3; + heightSupportWorldY: number; + heightSupportScale: number; + pelvisTarget: Vector3; + hasPelvisTarget: boolean; + hitPoint: Vector3; + hitNormal: Vector3; + supportPoint: Vector3; + supportNormal: Vector3; + footSamplePoint: Vector3; + soleSamples: FootIKSoleSample[]; + bestGroundSampleIndex: number; + offsetY: number; + movePenetrating: boolean; + weight: number; + plantedWeight: number; + planted: boolean; + predictive: PredictiveFootState; + lastPole: Vector3; + hasLastPole: boolean; + marker: Mesh | null; + hitMarker: Mesh | null; + rayLine: Line | null; + raiseLimitLine: LineSegments | null; + predictiveLine: Line | null; + predictiveCandidateMarkers: Mesh[]; + predictiveSupportMesh: Mesh | null; + predictiveSupportOutline: Line | null; +}; + +// 已完成必要骨骼绑定的腿链。 +export type ReadyFootIKLeg = FootIKLeg & { + upper: Bone; + lower: Bone; + foot: Bone; + ready: true; +}; + +// 左右腿运行时数据集合。 +export type FootIKLegs = Record; + +// IK 修改前保存的单个骨骼姿态。 +export type FootIKBonePose = { + position: Vector3; + quaternion: Quaternion; +}; + +// 脚步相位离线采样配置。 +export type FootPhaseOptions = { + sampleCount: number; + groundThreshold: number; + minContactRatio: number; + speedSlack: number; +}; + +// 单只脚在当前动画帧的相位状态。 +export type FootPhaseRuntimeState = { + planted: boolean; + progress: number; + timeToLand: number; + nextLanding: FootPhaseLanding | null; +}; + +/** 动画周期内一次落脚事件及其模型局部脚骨位姿。 */ +export type FootPhaseLanding = { + phase: number; + localPosition: Vector3; + localRotation: Quaternion; +}; + +// 左右脚在当前动画帧的相位状态。 +export type FootPhaseRuntime = Record; + +// 当前动画及其左右脚相位状态。 +export type FootPhaseControllerState = FootPhaseRuntime & { + clipName: string; + normalizedTime: number; +}; + +// 单只脚在一个动画周期内的接触区间数据。 +export type FootPhaseSideData = { + contacts: boolean[]; + land: number[]; + lift: number[]; + landings: FootPhaseLanding[]; + poses: FootPhasePoseSample[]; +}; + +// 单个移动动画的左右脚相位数据。 +export type FootPhaseClipData = { + name: string; + duration: number; + left: FootPhaseSideData; + right: FootPhaseSideData; +}; + +// 以动画名称索引的脚步相位数据库。 +export type FootPhaseDatabase = Map; + +// 单只脚在某个采样帧的接触高度和模型局部脚骨位姿。 +export type FootPhaseSamplePoint = { + y: number; + x: number; + localY: number; + z: number; + localRotation: Quaternion; +}; + +// 单只脚在离线采样相位中的模型局部位姿。 +export type FootPhasePoseSample = { + localPosition: Vector3; + localRotation: Quaternion; +}; + +// 左右脚在动画采样时刻的位置数据。 +export type FootPhaseFrameSample = { + time: number; + left: FootPhaseSamplePoint; + right: FootPhaseSamplePoint; +}; + +// 一个连续脚底接触区间包含的采样索引。 +export type FootPhaseContactRun = { + indices: number[]; +}; diff --git a/three-player-controller/src/plugins/types.ts b/three-player-controller/src/plugins/types.ts new file mode 100644 index 0000000..fdc14c0 --- /dev/null +++ b/three-player-controller/src/plugins/types.ts @@ -0,0 +1,25 @@ +import type { playerController } from "../PlayerController"; + +/** 控制器插件的生命周期接口。 */ +export interface PlayerPlugin { + /** 插件名称;注册同名插件时会替换已有插件。 */ + readonly name?: string; + + /** 注册到控制器后调用。 */ + onAttach?(player: playerController): void; + + /** 从控制器卸载后调用。 */ + onDetach?(): void; + + /** 动画混合器更新前调用,适合恢复上一帧修改。 */ + onBeforeAnimationUpdate?(delta: number): void; + + /** 动画混合器更新后调用,适合应用本帧后处理。 */ + onAfterAnimationUpdate?(delta: number): void; + + /** 玩家模型切换后调用,插件可在此重新绑定资源。 */ + onPlayerModelChange?(): void; + + /** 控制器销毁时调用。 */ + dispose?(): void; +} diff --git a/three-player-controller/src/systems/AnimationSystem.ts b/three-player-controller/src/systems/AnimationSystem.ts new file mode 100644 index 0000000..93d549d --- /dev/null +++ b/three-player-controller/src/systems/AnimationSystem.ts @@ -0,0 +1,315 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; + +export class AnimationSystem { + private ctrl: playerController; // 主控制器引用 + + mixer?: THREE.AnimationMixer; // 动画混合器 + mixerCb?: (ev: any) => void; // 完成事件回调 + actions?: Map; // 动作映射表 + state!: THREE.AnimationAction; // 当前播放状态 + sets = new Map>(); // 动作集合组 + currentLocomotionSet: string | null = null; // 当前激活的动作集合名 + clips: THREE.AnimationClip[] = []; // 原始动画片段 + hasThreePartJump = false; // 是否使用三段跳跃动画 + isOverrideAnimationPlaying = false; // 动画锁,用于防止覆盖型动画被移动动画打断 + private overrideInputSnapshot: Record | null = null; // 覆盖动画播放时的输入快照,用于检测打断 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + } + + // 按名切换动画 + playByName(name: string, fade = 0.18) { + if (!this.actions) return; + const next = this.actions.get(name); + // 如果动画不存在,或已在播放,则忽略 + if (!next || this.state === next) return; + + const prev = this.state; + next.reset(); + next.setEffectiveWeight(1); + next.paused = false; + + next.play(); + // 平滑过渡动画 + if (prev && prev !== next) { prev.fadeOut(fade); next.fadeIn(fade); } + else next.fadeIn(fade); + + this.state = next; + this.ctrl.onAnimationChange?.(name, next); + } + + // 注册自定义动画 + register(key: string, clipName: string, opts?: { + loop?: boolean; + timeScale?: number; + duration?: number; + clampWhenFinished?: boolean; + onFinished?: () => void; + }) { + if (!this.mixer || !this.actions) return; + const clip = this.clips.find(c => c.name === clipName); + if (!clip) { console.warn(`找不到 "${clipName}" 动画`); return; } + + const action = this.mixer.clipAction(clip); + // duration 优先于 timeScale,如果指定了 duration,则据此计算 timeScale + const timeScale = opts?.duration ? clip.duration / opts.duration : (opts?.timeScale ?? 1); + action.setLoop(opts?.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity); + action.clampWhenFinished = opts?.clampWhenFinished ?? false; + action.setEffectiveTimeScale(timeScale); + action.enabled = true; + action.setEffectiveWeight(0); + this.actions.set(key, action); + + // 如果有 onFinished 回调,则监听动画完成事件 + if (opts?.onFinished) { + this.mixer.addEventListener("finished", (ev: any) => { + if (ev.action === action) opts.onFinished!(); + }); + } + } + + // 注册移动动作组 + registerLocomotionSet(setName: string, map: Partial>) { + if (!this.mixer) return; + const set = new Map(); + for (const [key, clipName] of Object.entries(map) as [string, string][]) { + const clip = this.clips.find(c => c.name === clipName); + if (!clip) { console.warn(`registerLocomotionSet: 找不到 "${clipName}"`); continue; } + const action = this.mixer.clipAction(clip); + // 跳跃动画特殊处理:只播放一次 + if (key === "jumping") { + action.setLoop(THREE.LoopOnce, 1); + action.clampWhenFinished = true; + action.setEffectiveTimeScale(1.2); + } else { + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveTimeScale(1); + } + action.enabled = true; + action.setEffectiveWeight(0); + set.set(key, action); + } + this.sets.set(setName, set); + } + + // 切换移动动作组 + switchLocomotionSet(setName: string, fade = 0.18) { + if (!this.actions) return; + const set = this.sets.get(setName); + if (!set) { console.warn(`switchLocomotionSet: 未找到集合 "${setName}"`); return; } + this.currentLocomotionSet = setName; + for (const [key, newAction] of set.entries()) { + const oldAction = this.actions.get(key); + if (oldAction === newAction) continue; + // 将旧动作淡出 + if (oldAction) oldAction.fadeOut(fade); + // 替换当前动作表中的动作为新集合中的动作 + this.actions.set(key, newAction); + // 如果正在播放的动画被替换,则立即切换到新动画 + if (this.state === oldAction) { + newAction.reset(); + newAction.setEffectiveWeight(1); + newAction.fadeIn(fade); + newAction.play(); + this.state = newAction; + this.ctrl.onAnimationChange?.(key, newAction); + } + } + } + + // 播放已注册动画 + play(key: string, opts?: { fade?: number; force?: boolean; returnToPrev?: boolean }) { + if (!this.actions) return; + const action = this.actions.get(key); + if (!action) { console.warn(`playAnimation: "${key}" 未注册`); return; } + + // 如果是播放一次的动画,则设置动画锁 + if (action.loop === THREE.LoopOnce) { + // 设置动画锁 + this.isOverrideAnimationPlaying = true; + + // 记录当前完整输入状态快照 + this.overrideInputSnapshot = { ...this.ctrl.input }; + + // 设置动画播放完毕后的自动解锁 + const onFinish = (e: any) => { + if (e.action === action) { + // 仅当锁仍然激活时才解锁 + if (this.isOverrideAnimationPlaying && this.overrideInputSnapshot) { + this.isOverrideAnimationPlaying = false; + this.overrideInputSnapshot = null; + } + this.mixer!.removeEventListener('finished', onFinish); + } + }; + this.mixer!.addEventListener('finished', onFinish); + } + + if (opts?.force) action.reset(); + + // 记录初始动画状态以便返回 + const prevState = opts?.returnToPrev ? this.state : null; + // 播放目标动画 + this.playByName(key, opts?.fade ?? 0.18); + + // 如果设置 returnToPrev,则在动画播放完毕后返回之前的动画状态 + if (opts?.returnToPrev && prevState && this.mixer) { + // 存储当前动画状态 + const action = this.actions.get(key)!; + const fade = opts?.fade ?? 0.18; + // 定义一个一次性的事件处理器 + const handler = (ev: any) => { + if (ev.action === action && this.state === action) { + this.mixer!.removeEventListener("finished", handler); + const cur = this.state; + // 停止当前动画 + cur.stop(); + // 重置前一个状态 + prevState.reset(); + // 将权重设置为1并播放前一个状态动画 + prevState.setEffectiveWeight(1); + prevState.play(); + // 更新状态 + this.state = prevState; + this.ctrl.onAnimationChange?.(prevState.getClip().name, prevState); + } + }; + // 监听动画完成事件 + this.mixer.addEventListener("finished", handler); + } + } + + // 触发跳跃动画(统一入口) + startJump(inAir = false) { + // 根据是否配置了三段跳跃,播放不同动画 + if (this.hasThreePartJump) { + this.playByName(inAir ? "jumpLoop" : "jumpStart"); + } else { + this.playByName("jumping"); + } + } + + // 离地时触发 jumpLoop(三段模式专用) + onBecomeAirborne() { + if (!this.hasThreePartJump) return; + const s = this.state; + const a = this.actions; + // 如果当前已经在跳跃动画中,则不打断 + if (s === a?.get("jumpStart") || s === a?.get("jumpLoop") || s === a?.get("jumpEnd")) return; + this.playByName("jumpLoop"); + } + + // 落地时触发 jumpEnd(三段模式专用) + onLand() { + if (!this.hasThreePartJump) return; + const s = this.state; + const a = this.actions; + // 只有在起跳或跳跃循环中才触发落地 + if (s === a?.get("jumpStart") || s === a?.get("jumpLoop")) { + const { fwd, bkd, lft, rgt } = this.ctrl.input; + if (fwd || bkd || lft || rgt) { + this.setAnimationByPressed(); + return; + } + this.playByName("jumpEnd"); + } + } + + // 是否处于任意跳跃动画中(用于防止在跳跃动画播放时重复起跳) + isJumping(): boolean { + const s = this.state; + const a = this.actions; + if (!a) return false; + return s === a.get("jumping") || s === a.get("jumpStart") || + s === a.get("jumpLoop") || s === a.get("jumpEnd"); + } + + // 获取当前动画名 + getCurrentName(): string | null { + return this.state?.getClip()?.name ?? null; + } + + // 更新所有混合器 + updateMixers(delta: number) { + this.mixer?.update(delta); + } + + // 按键状态触发动画 + setAnimationByPressed() { + // 检查并处理覆盖动画中断逻辑 + if (this.isOverrideAnimationPlaying) { + const currentInput = this.ctrl.input as Record; + const snapshot = this.overrideInputSnapshot; + let inputChanged = false; + + if (snapshot) { + // 遍历快照中的所有键,与当前输入状态进行比较 + for (const key in snapshot) { + if (snapshot[key] !== currentInput[key]) { + inputChanged = true; + break; // 不匹配,中断 + } + } + } + + // 如果输入状态发生变化,则解除动画锁,并继续执行后续的移动动画逻辑 + if (inputChanged) { + this.isOverrideAnimationPlaying = false; + this.overrideInputSnapshot = null; + } else { + // 如果输入状态未变,则保持覆盖动画,不执行移动动画 + return; + } + } + + // 未启用缩放时恢复默认距离;飞行加速时拉远;启用缩放后保留滚轮选择的 maxDist。 + this.ctrl.cam.applyFlySprintMaxDist(); + + if (this.ctrl.controllerMode === 1) return; + + const { fwd, bkd, lft, rgt, shift, space } = this.ctrl.input; + + // 飞行状态下的动画逻辑 + if (this.ctrl.isFlying) { + if (fwd) { + if (shift) { + this.playByName("flying"); + } else { + this.playByName("flyHoverForward"); + } + return; + } + if (bkd) { this.playByName("flyHoverBack"); return; } + if (lft) { this.playByName("flyHoverLeft"); return; } + if (rgt) { this.playByName("flyHoverRight"); return; } + if (space) { this.playByName("flyHoverUp"); return; } + // 无任何操作时,播放悬停动画 + this.playByName("flyidle"); + return; + } + + // 地面状态下的动画逻辑 + if (this.ctrl.playerIsOnGround) { + // 无移动输入时等待落地动画播完;有移动输入则立即切换移动动画 + if ( + this.hasThreePartJump && + this.state === this.actions?.get("jumpEnd") && + !fwd && !bkd && !lft && !rgt + ) return; + // 无方向键输入,播放站立动画 + if (!fwd && !bkd && !lft && !rgt) { this.playByName("idle"); return; } + // 向前走或跑 + if (fwd) { this.playByName(shift ? "running" : "walking"); return; } + // 第三人称下,左、右、后退也播放走/跑动画(模型会自动转向) + if (!this.ctrl.isFirstPerson && (lft || rgt || bkd)) { + this.playByName(shift ? "running" : "walking"); return; + } + // 第一人称下的平移和后退 + if (lft) { this.playByName("left_walking"); return; } + if (rgt) { this.playByName("right_walking"); return; } + if (bkd) { this.playByName("walking_backward"); return; } + } + } +} diff --git a/three-player-controller/src/systems/CameraSystem.ts b/three-player-controller/src/systems/CameraSystem.ts new file mode 100644 index 0000000..506a1f9 --- /dev/null +++ b/three-player-controller/src/systems/CameraSystem.ts @@ -0,0 +1,402 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; + +export class CameraSystem { + private ctrl: playerController; // 主控制器引用 + + collisionLerp = 0.18; // 碰撞插值速度 + epsilon = 35; // 安全距离偏移 + minDist = 100; // 最小相机距离 + maxDist = 440; // 最大相机距离 + originMaxDist = 440; // 配置的默认最远距离 + sensitivity = 5; // 鼠标灵敏度 + mouseMode: 0 | 1 | 2 | 3 | 4 | 5 = 1; // 鼠标控制模式 + zoomEnabled = false; // 是否允许缩放 + lookAtHeightRatio = 0.8; // 第三人称看向点高度比例(0=底部,1=顶部) + overShoulderOffsetRatio = 0.2; // 第三人称相机过肩视角横向偏移比例 + + private lookAtPoint = new THREE.Vector3(); // 预分配的看向点向量 + + enableSpringCamera = false; + springCameraTime = 0.05; + vehicleTurnLerp = 0.01; + private _springVelocity = new THREE.Vector3(); + private _springResult = new THREE.Vector3(); + private zoomWheelElement: HTMLElement | SVGElement | null = null; + private readonly boundZoomWheel: EventListener = (event) => this.onZoomWheel(event as WheelEvent); + private _flySprintMaxDistBoost = false; + private _flySprintSavedMaxDist = 0; + + raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3()); // 相机碰撞射线 + centerRay = new THREE.Raycaster(); // 屏幕中心射线 + centerMouse = new THREE.Vector2(); // 屏幕中心坐标 + playerToCam = new THREE.Vector3(); // 玩家到相机向量 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + (this.raycaster as any).firstHitOnly = true; + } + + // 通用弹簧阻尼:把 controls.target 朝 dest 平滑跟随,返回本帧的目标点 + springTarget(dest: THREE.Vector3, delta: number): THREE.Vector3 { + if (!this.enableSpringCamera) return dest; + const cur = this.ctrl.controls.target; + const v = this._springVelocity; + const out = this._springResult; + const smoothTime = Math.max(0.0001, this.springCameraTime); + const omega = 2 / smoothTime; + const x = omega * delta; + const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); + const axes = ['x', 'y', 'z'] as const; + for (const a of axes) { + const change = cur[a] - dest[a]; + const temp = (v[a] + omega * change) * delta; + v[a] = (v[a] - omega * temp) * exp; + let o = dest[a] + (change + temp) * exp; + if ((dest[a] - cur[a] > 0) === (o > dest[a])) { + o = dest[a]; + v[a] = 0; + } + out[a] = o; + } + return out; + } + + // 第三人称相机看向点 + getLookAtPoint(): THREE.Vector3 { + if (this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active) { + return this.lookAtPoint.copy(this.ctrl.vehicle.active.vehicleGroup.position); + } + const capsuleInfo = this.ctrl.playerCapsule.capsuleInfo; + const r = capsuleInfo.radius; + const sy = this.ctrl.playerCapsule.scale.y || 1; + const totalH = (-capsuleInfo.segment.end.y) * sy + 2 * r; + const y = this.ctrl.playerCapsule.position.y + r - totalH * (1 - this.lookAtHeightRatio); + return this.lookAtPoint.copy(this.ctrl.playerCapsule.position).setY(y); + } + + // 设置越肩视角 + setOverShoulder(enable: boolean) { + if (!enable || this.ctrl.controllerMode === 1) { this.ctrl.camera.clearViewOffset(); return; } + const w = window.innerWidth; + const h = window.innerHeight; + this.ctrl.camera.setViewOffset(w, h, w * this.overShoulderOffsetRatio, 0, w, h); + } + + // 切换视角模式 + changeView() { + this.ctrl.onBeforeViewChange?.(this.ctrl.isFirstPerson); + this.ctrl.isFirstPerson = !this.ctrl.isFirstPerson; + if (this.ctrl.isFirstPerson) { + // 切到第一人称,先对齐胶囊体朝向 + const playerFwd = new THREE.Vector3(0, 0, 1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const flatDir = new THREE.Vector3(playerFwd.x, 0, playerFwd.z).normalize(); + if (flatDir.lengthSq() > 0.001) { + const yAngle = Math.atan2(flatDir.x, flatDir.z); + this.ctrl.playerCapsule.rotation.set(0, yAngle, 0); + } + this.setFirstPerson(); + this.setOverShoulder(false); + } else { + // 切到第三人称,放置相机到玩家背后 + this.ctrl.controls.enabled = true; + this.ctrl.scene.attach(this.ctrl.camera); + const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const angle = Math.atan2(dir.z, dir.x); + const s = this.ctrl.playerModelConfig.scale; + const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s); + this.ctrl.controls.target.copy(this.getLookAtPoint()); + this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist)); + // 滚轮由 CameraSystem 改写 maxDist,禁用自带的直接位移缩放。 + this.ctrl.controls.enableZoom = false; + this.setOverShoulder(this.ctrl.enableOverShoulderView); + } + this.setPointerLock(); + this.ctrl.onViewChange?.(this.ctrl.isFirstPerson); + } + + // 进入第一人称 + setFirstPerson(vertAngle = 0) { + this.ctrl.controls.enabled = false; + const s = this.ctrl.playerModelConfig.scale; + const sharedOffset = this.ctrl.playerModelConfig.firstPersonCameraOffset; + // 有头骨节点挂到头骨,否则挂到胶囊体 + if (this.ctrl.playerModelHead) { + const [x, y, z] = sharedOffset ?? [0, 10, 20]; + this.ctrl.playerModelHead.attach(this.ctrl.camera); + this.ctrl.camera.position.set(x, y, z); + } else { + const [x, y, z] = sharedOffset ?? [0, 40, 30]; + this.ctrl.playerCapsule.attach(this.ctrl.camera); + this.ctrl.camera.position.set(x * s, y * s, z * s); + } + this.ctrl.camera.rotation.set( + THREE.MathUtils.clamp(vertAngle, -1.1, 1.4), + Math.PI, + 0, + ); + this.ctrl.controls.enableZoom = false; + } + + // 指针锁定控制 + setPointerLock() { + const lockTarget = this.ctrl.controls.domElement; + if (!lockTarget?.requestPointerLock) return; + if (((this.mouseMode === 0 || this.mouseMode === 1 || this.mouseMode === 5) && !this.ctrl.isFirstPerson) || this.ctrl.isFirstPerson) { + lockTarget.requestPointerLock(); + } else { + document.exitPointerLock(); + } + } + + // 初始相机位置 + setCamPos() { + requestAnimationFrame(() => { + if (!this.ctrl.isFirstPerson) { + const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const angle = Math.atan2(dir.z, dir.x); + const s = this.ctrl.playerModelConfig.scale; + const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s); + this.ctrl.controls.target.copy(this.getLookAtPoint()); + this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist)); + this.ctrl.controls.enableZoom = false; + } else { + this.setFirstPerson(); + } + this.ctrl.camera.updateProjectionMatrix(); + }); + } + + // 初始化轨道控制 + initControls() { + this.ctrl.controls.enableZoom = false; + this.ctrl.controls.rotateSpeed = this.sensitivity * 0.05; + this.ctrl.controls.maxPolarAngle = Math.PI; + this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + // 防止相机轨道半径归零穿越目标点 + this.ctrl.controls.minDistance = this.minDist; + this.connectZoomWheel(); + } + + /** 切换第三人称滚轮缩放;关闭时恢复配置的默认最远距离。 */ + setZoomEnabled(enabled: boolean) { + this.zoomEnabled = enabled; + this.ctrl.controls.enableZoom = false; + this._flySprintMaxDistBoost = false; + this.maxDist = enabled + ? Math.max(this.maxDist, this.minDist) + : this.originMaxDist; + } + + /** 非弹簧第三人称飞行加速时拉远相机,否则恢复默认最远距离。 */ + applyFlySprintMaxDist(): void { + const boost = !this.enableSpringCamera + && !this.ctrl.isFirstPerson + && this.ctrl.controllerMode === 0 + && this.ctrl.isFlying + && this.ctrl.input.shift; + + if (!this.zoomEnabled) { + this._flySprintMaxDistBoost = false; + this.maxDist = this.originMaxDist * (boost ? 2 : 1); + return; + } + + if (boost && !this._flySprintMaxDistBoost) { + this._flySprintSavedMaxDist = this.maxDist; + this.maxDist *= 2; + this._flySprintMaxDistBoost = true; + } else if (!boost && this._flySprintMaxDistBoost) { + this.maxDist = this._flySprintSavedMaxDist; + this._flySprintMaxDistBoost = false; + } + } + + clearFlySprintMaxDistBoost(): void { + this._flySprintMaxDistBoost = false; + } + + /** 绑定到渲染画布,使普通鼠标与 Pointer Lock 状态共用同一滚轮入口。 */ + private connectZoomWheel() { + const element = this.ctrl.controls.domElement; + if (!element) return; + if (this.zoomWheelElement === element) return; + this.disconnectZoomWheel(); + element.addEventListener("wheel", this.boundZoomWheel, { passive: false }); + this.zoomWheelElement = element; + } + + private disconnectZoomWheel() { + this.zoomWheelElement?.removeEventListener("wheel", this.boundZoomWheel); + this.zoomWheelElement = null; + } + + /** 滚轮改变目标最远距离。 */ + private onZoomWheel(event: WheelEvent) { + if (!this.zoomEnabled || this.ctrl.isFirstPerson || event.deltaY === 0) return; + event.preventDefault(); + + // 与 OrbitControls 一致地归一化像素、行和页三种 wheel delta。 + let deltaY = event.deltaY; + if (event.deltaMode === 1) deltaY *= 16; + else if (event.deltaMode === 2) deltaY *= 100; + if (event.ctrlKey) deltaY *= 10; + + const scale = Math.pow(0.95, Math.abs(deltaY * 0.01)); + const nextDistance = deltaY < 0 + ? this.maxDist * scale + : this.maxDist / scale; + this.maxDist = Math.max(nextDistance, this.minDist); + } + + // 重置轨道控制 + resetControls() { + if (!this.ctrl.controls) return; + this.disconnectZoomWheel(); + this.ctrl.controls.enabled = true; + this.ctrl.controls.enablePan = true; + this.ctrl.controls.maxPolarAngle = Math.PI / 2; + this.ctrl.controls.rotateSpeed = 1; + this.ctrl.controls.enableZoom = true; + this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + } + + // 处理鼠标朝向 + setToward(dx: number, dy: number, speed: number) { + this.ctrl.onTowardChange?.(dx, dy, speed); + if (!this.ctrl.enableToward) return; + const sens = this.sensitivity; + if (this.ctrl.controllerMode === 0) { + // 步行第一人称 + if (this.ctrl.isFirstPerson) { + this.ctrl.playerCapsule.rotateY(-dx * speed * sens); + this.ctrl.camera.rotation.x = THREE.MathUtils.clamp( + this.ctrl.camera.rotation.x + (-dy * speed * sens), + -Math.PI * (60 / 180), Math.PI * (80 / 180), + ); + } else { + // 步行第三人称 + this.orbit(this.getLookAtPoint(), -dx * speed * sens, -dy * speed * sens); + } + } else { + const v = this.ctrl.vehicle.active; + if (!v) return; + // 载具第一人称 + if (this.ctrl.isFirstPerson) { + this.ctrl.camera.rotation.y = THREE.MathUtils.clamp(this.ctrl.camera.rotation.y + (-dx * speed * sens), Math.PI * (3 / 4), Math.PI * (5 / 4)); + this.ctrl.camera.rotation.x = THREE.MathUtils.clamp(this.ctrl.camera.rotation.x + (-dy * speed * sens), 0, Math.PI * (1 / 3)); + } else { + // 载具第三人称 + this.orbit(v.vehicleGroup.position, -dx * speed * sens, -dy * speed * sens); + } + } + } + + // 手动轨道旋转 + private orbit(target: THREE.Vector3, deltaX: number, deltaY: number) { + const distance = this.ctrl.camera.position.distanceTo(target); + const cur = this.ctrl.camera.position.clone().sub(target); + let theta = Math.atan2(cur.x, cur.z) + deltaX; + let phi = Math.acos(THREE.MathUtils.clamp(cur.y / distance, -1, 1)) + deltaY; + // 限制仰角避免万向节死锁 + phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi)); + this.ctrl.camera.position.set( + target.x + distance * Math.sin(phi) * Math.sin(theta), + target.y + distance * Math.cos(phi), + target.z + distance * Math.sin(phi) * Math.cos(theta), + ); + this.ctrl.camera.lookAt(target); + } + + // 载具第三人称:弹簧跟随 + 射线避障 + 速度方向 + updateThirdPersonVehicle(delta: number) { + const v = this.ctrl.vehicle.active; + if (!v) return; + + const lookTarget = this.springTarget(v.vehicleGroup.position, delta).clone(); + this.ctrl.camera.position.sub(this.ctrl.controls.target); + this.ctrl.controls.target.copy(lookTarget); + this.ctrl.camera.position.add(lookTarget); + this.ctrl.controls.update(); + + const vehicleBaseDistance = Math.max(this.minDist, v.size.l * 0.8); + const desiredDist = Math.max(this.maxDist, vehicleBaseDistance); + this.updateWithRaycast(this.ctrl.controls.target, desiredDist); + + if ((this.ctrl.input.fwd || this.ctrl.input.bkd) && v.followVehicleDirection) { + const vel = v.chassisBody.linvel(); + if (Math.hypot(vel.x, vel.z) > 0.3) { + const targetAngle = Math.atan2(-vel.x, -vel.z); + const offX = this.ctrl.camera.position.x - this.ctrl.controls.target.x; + const offZ = this.ctrl.camera.position.z - this.ctrl.controls.target.z; + const radius = Math.hypot(offX, offZ); + const curAngle = Math.atan2(offX, offZ); + const diff = Math.atan2(Math.sin(targetAngle - curAngle), Math.cos(targetAngle - curAngle)); + const newAngle = curAngle + diff * this.vehicleTurnLerp; + this.ctrl.camera.position.x = this.ctrl.controls.target.x + Math.sin(newAngle) * radius; + this.ctrl.camera.position.z = this.ctrl.controls.target.z + Math.cos(newAngle) * radius; + this.ctrl.controls.update(); + } + } + } + + // 收集相机射线碰撞。开车时跳过当前车辆网格。 + private collectCameraHits() { + const skipIds = this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active?.meshColliderId != null + ? [this.ctrl.vehicle.active.meshColliderId] + : undefined; + const hits: THREE.Intersection[] = []; + for (const mesh of this.ctrl.getColliderMeshes({ skipIds })) { + const meshHits = this.raycaster.intersectObject(mesh, false); + if (meshHits[0] && (!hits[0] || meshHits[0].distance < hits[0].distance)) { + hits[0] = meshHits[0]; + } + } + return hits; + } + + // 射线防穿墙 + updateWithRaycast(origin: THREE.Vector3, maxDist: number = this.maxDist, minDist = this.minDist) { + this.playerToCam.subVectors(this.ctrl.camera.position, origin); + const direction = this.playerToCam.clone().normalize(); + this.raycaster.set(origin, direction); + this.raycaster.far = maxDist; + + const hits = this.collectCameraHits(); + // 有遮挡:贴近安全距离 + if (hits.length > 0) { + const safeDist = Math.max(hits[0].distance - this.epsilon, minDist); + this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp); + } else { + // 无遮挡:尝试拉到最大距离 + this.raycaster.far = maxDist; + const maxHits = this.collectCameraHits(); + const safeDist = maxHits.length > 0 ? Math.min(maxDist, maxHits[0].distance - this.epsilon) : maxDist; + this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp); + } + } + + // 屏幕中心检测 + getCenterHit(): THREE.Intersection | undefined { + this.ctrl.camera.updateMatrixWorld(); + this.centerRay.setFromCamera(this.centerMouse, this.ctrl.camera); + this.centerRay.layers.set(1); + this.centerRay.layers.enable(2); + + const meshes = this.ctrl.getColliderMeshes(); + const checkTargets = meshes.length + ? [...meshes, ...this.ctrl.scene.children] + : this.ctrl.scene.children; + const hits = this.centerRay.intersectObjects(checkTargets, true); + hits.sort((a, b) => a.distance - b.distance); + if (hits[0]) return hits[0]; + + // 无命中返回1000距离的虚拟点 + const fallbackPoint = this.centerRay.ray.at(1000, new THREE.Vector3()); + return { + distance: 1000, point: fallbackPoint, object: this.ctrl.camera, + uv: null as any, normal: null as any, face: null as any, + faceIndex: null as any, instanceId: undefined, + } as THREE.Intersection; + } +} diff --git a/three-player-controller/src/systems/CharacterMovement.ts b/three-player-controller/src/systems/CharacterMovement.ts new file mode 100644 index 0000000..2934764 --- /dev/null +++ b/three-player-controller/src/systems/CharacterMovement.ts @@ -0,0 +1,436 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import type { KinematicColliderEntry } from "../types"; +import { CHARACTER_QUERY_MASK } from "../collision/groups"; +import type { DynamicBody } from "../collision/DynamicBody"; +import type { DynamicGroundHit } from "./DynamicBodySystem"; +import type { PlayerDebug } from "./PlayerDebug"; +import { + applyCapsuleCollision, + createGroundProbeTemps, + probeMeshGround, +} from "../utils/capsuleCollision"; + +/** 体积地面传感区半径相对玩家胶囊半径的比例。 */ +const GROUND_SENSOR_RADIUS_RATIO = 0.4; +/** 可作为支撑面的最小向上法线分量。 */ +const GROUND_SUPPORT_NORMAL_Y = 0.5; + +/** + * 人物胶囊移动:地面射线、分步推出、重力 snap、平台携带。 + * 不走刚体冲量核(位置推出式 CharacterController)。 + */ +export class CharacterMovement { + private dynamicSupportBody: DynamicBody | null = null; + private dynamicSupportPrevPosition = new THREE.Vector3(); + private dynamicSupportPrevQuaternion = new THREE.Quaternion(); + private dynamicSupportInvQuaternion = new THREE.Quaternion(); + private dynamicSupportLocal = new THREE.Vector3(); + private dynamicSupportBefore = new THREE.Vector3(); + private dynamicSupportDelta = new THREE.Vector3(); + private groundProbeTemps = createGroundProbeTemps(); + private groundProbeStart = new THREE.Vector3(); + private groundProbeDown = new THREE.Vector3(); + private groundProbeOffset = new THREE.Vector3(); + private shapeGroundPoint = new THREE.Vector3(); + private shapeGroundNormal = new THREE.Vector3(); + private rayGroundNormal = new THREE.Vector3(); + private rayGroundNormalMatrix = new THREE.Matrix3(); + + constructor( + private readonly ctrl: playerController, + private readonly debug: PlayerDebug, + ) { } + + /** 步行 / 飞行一帧:输入速度 → 落地 → 分步碰撞 → 朝向与相机。 */ + update(delta: number): void { + const c = this.ctrl; + // 载具模式只跑动画,移动由车辆系统负责 + if (c.controllerMode === 1) { + c.clearGroundSupport(); + this.debug.clearGroundProbe(); + c.runAnimationPass(delta); + return; + } + // —— 输入与目标速度 —— + c.camera.getWorldDirection(c.camDir); + const angle = 2 * Math.PI - (Math.atan2(c.camDir.z, c.camDir.x) + Math.PI / 2); + + const moveAxes = c.input.getMoveAxes(); + c.moveDir.copy(c.DIR_RGT).multiplyScalar(moveAxes.x).addScaledVector(c.DIR_FWD, moveAxes.y); + if (c.isFlying) { + if (c.input.fwd || moveAxes.isAnalog) c.moveDir.copy(c.camDir); + if (c.input.space) c.moveDir.y += 1; + c.curPlayerSpeed = c.input.shift ? c.playerFlySpeed * 2 : c.playerFlySpeed; + } else { + c.curPlayerSpeed = c.input.shift ? c.playerRunSpeed : c.playerSpeed; + } + + c.moveDir.normalize(); + // 步行:相对相机水平朝向;飞行且镜头前进时已用 camDir,不再绕 Y 转 + if (!c.isFlying || (!moveAxes.isAnalog && !c.input.fwd)) { + c.moveDir.applyAxisAngle(c.upVector, angle); + } + + // 水平速度向目标加速 / 无输入时减速 + const accelStep = c.playerAcceleration * c.decelBase * delta; + const decelStep = c.playerDeceleration * c.decelBase * delta; + const targetX = c.moveDir.x * c.curPlayerSpeed; + const targetZ = c.moveDir.z * c.curPlayerSpeed; + const diffX = targetX - c.playerVelocity.x; + const diffZ = targetZ - c.playerVelocity.z; + const hasXZInput = c.moveDir.x !== 0 || c.moveDir.z !== 0; + const xzDiffLen = Math.hypot(diffX, diffZ); + if (xzDiffLen > 0) { + const xzApplied = Math.min(xzDiffLen, hasXZInput ? accelStep : decelStep); + c.playerVelocity.x += (diffX / xzDiffLen) * xzApplied; + c.playerVelocity.z += (diffZ / xzDiffLen) * xzApplied; + } + if (c.isFlying) { + const targetY = c.moveDir.y * c.curPlayerSpeed; + const diffY = targetY - c.playerVelocity.y; + c.playerVelocity.y += Math.sign(diffY) * Math.min(Math.abs(diffY), c.moveDir.y !== 0 ? accelStep : decelStep); + } + + // —— 脚下射线:静态 / 运动学网格 / 动态体,取最高命中 —— + c.playerCapsule.updateMatrixWorld(true); + c.groundRaycaster.ray.origin.copy(c.playerCapsule.position); + let bestHit: THREE.Intersection | undefined; + let hitEntry: KinematicColliderEntry | null = null; + for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) { + if (col.shape.kind !== "mesh") continue; + const hits = c.groundRaycaster.intersectObject(col.shape.mesh, false); + if (hits.length > 0 && (!bestHit || hits[0].point.y > bestHit.point.y)) { + bestHit = hits[0]; + hitEntry = col.motion === "kinematic" ? col.userData as KinematicColliderEntry : null; + } + } + let dynamicHit = c.dynamics.raycastGround(c.playerCapsule.position); + if (dynamicHit && bestHit && dynamicHit.point.y <= bestHit.point.y) dynamicHit = null; + if (dynamicHit) { + bestHit = undefined; + hitEntry = null; + } + // 射线阶段已经在网格与动态体之间选出最高命中,体积探测只作为失效时的补充。 + let groundPoint: THREE.Vector3 | undefined = dynamicHit?.point ?? bestHit?.point; + let usedShapeGround = false; + + // 中心射线没有近距离支撑时,用带宽度的体积探测覆盖细缝两侧。 + const rayGroundDistance = groundPoint + ? c.playerCapsule.position.y - groundPoint.y + : Infinity; + const groundCapsuleInfo = c.playerCapsule.capsuleInfo; + + // 从胶囊下端球心开始向下探测。 + this.groundProbeStart.copy(groundCapsuleInfo.segment.end) + .applyMatrix4(c.playerCapsule.matrixWorld); + this.groundProbeDown.copy(c.upVector).negate(); + + const lowerCenterOffset = this.groundProbeOffset + .subVectors(c.playerCapsule.position, this.groundProbeStart) + .dot(c.upVector); + const maxProbeDistance = Math.max(0, c.maxH - lowerCenterOffset); + const sensorRadius = groundCapsuleInfo.radius * GROUND_SENSOR_RADIUS_RATIO; + + // 近距离中心射线仍是快速路径;只有射线错过或命中过远时才执行体积查询。 + const useShapeProbe = rayGroundDistance > c.maxH; + if (useShapeProbe) { + let shapeDistance = Infinity; + let shapeEntry: KinematicColliderEntry | null = null; + let shapeDynamicHit: DynamicGroundHit | null = null; + + // 静态、运动学及已登记的动态网格都通过 BVH 生成候选支撑面,保留最近命中。 + for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) { + if (col.shape.kind !== "mesh") continue; + const hit = probeMeshGround( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance, + sensorRadius, + GROUND_SUPPORT_NORMAL_Y, + col.shape.mesh, + this.groundProbeTemps, + ); + if (!hit || hit.distance >= shapeDistance) continue; + shapeDistance = hit.distance; + this.shapeGroundPoint.copy(hit.point); + this.shapeGroundNormal.copy(hit.normal); + + // 保存运动学平台归属。 + shapeEntry = col.motion === "kinematic" + ? col.userData as KinematicColliderEntry + : null; + shapeDynamicHit = null; + } + + // 动态球和动态盒使用解析形状查询,再与网格结果竞争最近支撑面。 + const dynamicVolumeHit = c.dynamics.probeGroundVolume( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance, + sensorRadius, + GROUND_SUPPORT_NORMAL_Y, + ); + if (dynamicVolumeHit) { + const dynamicDistance = this.groundProbeOffset + .subVectors(dynamicVolumeHit.point, this.groundProbeStart) + .dot(this.groundProbeDown); + if (dynamicDistance < shapeDistance) { + shapeDistance = dynamicDistance; + this.shapeGroundPoint.copy(dynamicVolumeHit.point); + this.shapeGroundNormal.copy(dynamicVolumeHit.normal); + shapeEntry = null; + + // 保存动态刚体归属。 + shapeDynamicHit = dynamicVolumeHit; + } + } + + if (Number.isFinite(shapeDistance)) { + // 用体积探测结果替换失效射线,同时保持原有贴地、坡度和平台逻辑的输入格式。 + groundPoint = this.shapeGroundPoint; + bestHit = undefined; + dynamicHit = shapeDynamicHit; + hitEntry = shapeEntry; + usedShapeGround = true; + } + } + + // 调试圆柱挂在 capsule 下,传局部尺寸,由父级 scale 变换到世界尺度。 + const capsuleScale = c.playerCapsule.scale; + const sy = capsuleScale.y || 1; + const sxz = Math.max(1e-6, Math.min(capsuleScale.x || 1, capsuleScale.z || 1)); + this.debug.updateGroundSensor( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance / sy, + sensorRadius / sxz, + useShapeProbe, + usedShapeGround, + ); + this.debug.updateGroundProbe( + c.groundRaycaster.ray.origin, + groundPoint ?? null, + c.maxH, + c.groundRaycaster.near, + ); + // —— 重力 / 贴地 snap(飞行跳过) —— + if (!c.isFlying) { + if (groundPoint) { + const snapY = groundPoint.y + c.snapH; + const dist = c.playerCapsule.position.y - groundPoint.y; + if (dist > c.maxH) { + c.applyGravity(delta); // 离地过远当坠落 + } else if (c.playerVelocity.y <= 0) { + if (c.playerIsOnGround) { + c.snapToGround( + snapY, + usedShapeGround + ? this.shapeGroundNormal.y >= c.minFloorNormalY + : dynamicHit + ? dynamicHit.normal.y >= c.minFloorNormalY + : c.isFlatFloor(bestHit!), + delta, + ); + } else { + // 下落中:本帧会落到地面则贴地,否则继续重力 + const predictedY = c.playerCapsule.position.y + c.playerVelocity.y * delta; + if (predictedY <= snapY) c.snapToGround(snapY); + else c.applyGravity(delta); + } + } else { + c.applyGravity(delta); // 上升(跳跃) + } + } else { + c.applyGravity(delta); + } + c.playerCapsule.position.y += c.playerVelocity.y * delta; + } + + c.activeDynamicBody = c.playerIsOnGround ? dynamicHit?.body ?? null : null; + c.activeKinematicCollider = c.playerIsOnGround && !c.activeDynamicBody ? hitEntry : null; + this.setDynamicSupport(c.activeDynamicBody); + + // —— 水平(飞行则含竖直)分步移动 + 胶囊推出 —— + const capsuleInfo = c.playerCapsule.capsuleInfo; + const xzSpeed = Math.hypot(c.playerVelocity.x, c.playerVelocity.z); + const totalDist = c.isFlying ? c.playerVelocity.length() * delta : xzSpeed * delta; + c.xzDir.set(c.playerVelocity.x, c.isFlying ? c.playerVelocity.y : 0, c.playerVelocity.z).normalize(); + const maxStep = capsuleInfo.radius * 0.8; + const steps = Math.min(Math.max(1, Math.ceil(totalDist / maxStep)), c.maxMoveSteps); + const stepDist = totalDist / steps; + const blockedEpsSq = (maxStep * 0.05) ** 2; + for (let i = 0; i < steps; i++) { + c.moveStepOrigin.copy(c.playerCapsule.position); + c.playerCapsule.position.addScaledVector(c.xzDir, stepDist); + c.playerCapsule.updateMatrixWorld(); + + if (!c.skipCapsuleCollision) { + const kinMeshes = c.getKinematicColliderEntries(); + for (const mesh of c.getColliderMeshes()) { + c.playerCapsule.updateMatrixWorld(); + const isKin = kinMeshes.some(e => e.mesh === mesh); + // 飞行用接到脚底的段;步行用悬空段便于上台阶 + applyCapsuleCollision( + c.playerCapsule, + capsuleInfo, + mesh, + isKin ? c.kinematicTemps : c.staticTemps, + undefined, + c.isFlying, + ); + } + c.dynamics.collideWithCapsule(); + + // 本步几乎没走出去:卡住则提前结束剩余子步 + if (c.playerCapsule.position.distanceToSquared(c.moveStepOrigin) < blockedEpsSq) { + break; + } + } + } + + // 站在运动学平台上时跟随平台位移 / 偏航 + if (c.activeKinematicCollider && c.playerIsOnGround && !c.isFlying) { + c.playerCapsule.position.add(c.activeKinematicCollider.deltaPos); + if (c.activeKinematicCollider.deltaRotY !== 0) { + c.playerCapsule.rotateY(c.activeKinematicCollider.deltaRotY); + } + } + + // 在 IK 执行前缓存本帧最终接地结果。 + this.updateGroundSupport(groundPoint, bestHit, dynamicHit, usedShapeGround); + + // 移动结束后刷新调试体,使地面射线/体积传感区与胶囊同步。 + c.playerCapsule.updateMatrixWorld(true); + this.debug.refreshTransforms(); + + // —— 角色朝向(第三人称 / 飞行) —— + if (!c.isFirstPerson) { + const camDirFlat = c.camDir.clone().setY(0).normalize().negate(); + const moveDirFlat = c.moveDir.clone().normalize().negate(); + + if (!c.isFlying) { + if (c.cam.mouseMode === 4 || c.cam.mouseMode === 5) { + c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(camDirFlat), c.playerCapsule.up); + c.playerCapsule.quaternion.copy(c.targetQuat.setFromRotationMatrix(c.targetMat)); + } else if (c.cam.mouseMode === 0 || c.cam.mouseMode === 2) { + const lookTarget = c.playerCapsule.position.clone().add(moveDirFlat.lengthSq() > 0 ? moveDirFlat : camDirFlat); + c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } else if (moveDirFlat.lengthSq() > 0) { + c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(moveDirFlat), c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } + } else { + const lookTarget = c.playerCapsule.position.clone().add(c.input.fwd ? moveDirFlat : camDirFlat); + c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } + } + + // —— 第三人称相机跟随 —— + if (!c.isFirstPerson) { + const lookTarget = c.cam.springTarget(c.cam.getLookAtPoint(), delta); + c.camera.position.sub(c.controls.target); + c.camera.position.add(lookTarget); + c.controls.target.copy(lookTarget); + c.controls.update(); + c.cam.applyFlySprintMaxDist(); + c.cam.updateWithRaycast(c.controls.target); + } + + // 靠近车辆时显示上车按钮 + if (c.isShowMobileControls && c.vehicle.list.length) { + let near = false; + for (const veh of c.vehicle.list) { + if (c.vehicle.isInBoardingRange(veh, c.playerCapsule.position)) { + near = true; + break; + } + } + if (near !== c.isNearVehicle) { + c.isNearVehicle = near; + c.mobileControls?.syncVehicleBtn(near); + } + } + + c.animation.setAnimationByPressed(); + c.runAnimationPass(delta); + } + + /** 将射线或体积探测最终采用的支撑点统一转换成世界空间结果。 */ + private updateGroundSupport( + point: THREE.Vector3 | undefined, + meshHit: THREE.Intersection | undefined, + dynamicHit: DynamicGroundHit | null, + usedShapeGround: boolean, + ): void { + const c = this.ctrl; + if (c.isFlying || !c.playerIsOnGround || !point) { + c.clearGroundSupport(); + return; + } + + if (usedShapeGround) { + // 体积探测已经返回世界空间法线。 + this.rayGroundNormal.copy(this.shapeGroundNormal); + } else if (dynamicHit) { + this.rayGroundNormal.copy(dynamicHit.normal); + } else if (meshHit?.face) { + // 射线命中网格法线转换到世界空间。 + this.rayGroundNormal.copy(meshHit.face.normal) + .applyMatrix3(this.rayGroundNormalMatrix.getNormalMatrix(meshHit.object.matrixWorld)) + .normalize(); + } else { + this.rayGroundNormal.copy(c.upVector); + } + + c.setGroundSupport(point, this.rayGroundNormal); + } + + /** 切换当前动态支撑体并记录位姿。 */ + private setDynamicSupport(body: DynamicBody | null): void { + if (this.dynamicSupportBody === body) return; + this.dynamicSupportBody = body; + if (!body) return; + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + } + + /** 动态支撑体推进后按前后位姿带走人物。 */ + applyDynamicSupportCarry(): void { + const c = this.ctrl; + const body = c.activeDynamicBody; + if (body !== this.dynamicSupportBody) this.setDynamicSupport(body); + if (!body) return; + if (!c.dynamics.list.includes(body)) { + c.activeDynamicBody = null; + this.setDynamicSupport(null); + return; + } + if (c.controllerMode === 1 || c.isFlying || !c.playerIsOnGround) { + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + return; + } + + this.dynamicSupportBefore.copy(c.playerCapsule.position); + this.dynamicSupportInvQuaternion.copy(this.dynamicSupportPrevQuaternion).invert(); + this.dynamicSupportLocal.subVectors( + c.playerCapsule.position, + this.dynamicSupportPrevPosition, + ).applyQuaternion(this.dynamicSupportInvQuaternion); + c.playerCapsule.position.copy(this.dynamicSupportLocal) + .applyQuaternion(body.quaternion) + .add(body.position); + c.playerCapsule.updateMatrixWorld(); + + this.dynamicSupportDelta.subVectors(c.playerCapsule.position, this.dynamicSupportBefore); + if (!c.isFirstPerson && this.dynamicSupportDelta.lengthSq() > 0) { + c.camera.position.add(this.dynamicSupportDelta); + c.controls.target.add(this.dynamicSupportDelta); + } + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + } +} diff --git a/three-player-controller/src/systems/ColliderRegistry.ts b/three-player-controller/src/systems/ColliderRegistry.ts new file mode 100644 index 0000000..307d0fc --- /dev/null +++ b/three-player-controller/src/systems/ColliderRegistry.ts @@ -0,0 +1,488 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import type { ColliderDesc, ColliderHandle, DynamicColliderDesc } from "../collision/colliderDesc"; +import type { MotionType } from "../collision/CollisionWorld"; +import { + attachBoundsTree, + buildKinematicMergedMesh, + buildStaticMergedMesh, +} from "../collision/colliderBuild"; +import type { KinematicColliderEntry } from "../types"; + +let warnedDynamicMesh = false; + +/** + * 统一碰撞体注册:按 ColliderDesc 创建 / 移除,并维护运动学跟随。 + * 静态 / 运动学 mesh 走合并 + BVH;动态球 / 盒交给 DynamicBodySystem。 + */ +export class ColliderRegistry { + private ctrl: playerController; + kinematicColliders: KinematicColliderEntry[] = []; // 需每帧跟随的运动学项 + /** id → handle 元数据 */ + private handles = new Map(); + /** 静态构建代数:异步 BVH 完成时若代数变了则丢弃 */ + private buildGens = new Map(); + private _contentScale = new THREE.Vector3(); + private _contentOffsetMatrix = new THREE.Matrix4(); + /** 静态 / 运动学 mesh 碰撞线框开关。 */ + private debugVisible = false; + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + } + + /** 返回碰撞 mesh 调试开关。 */ + getDebugVisible(): boolean { + return this.debugVisible; + } + + /** 切换静态 / 运动学 mesh 碰撞线框。 */ + setDebugVisible(visible: boolean): void { + this.debugVisible = visible; + this.syncDebugVisibility(); + } + + /** 同步已就绪碰撞 mesh 的调试显隐。 */ + syncDebugVisibility(): void { + const synced = new Set(); + for (const handle of this.handles.values()) { + const mesh = handle.collisionMesh; + if (!mesh || synced.has(mesh)) continue; + synced.add(mesh); + this.syncDebugMesh(mesh); + } + } + + /** 按 desc 创建碰撞体;校验 motion/shape 约束后分派到 mesh/box/sphere。 */ + add(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "static" && (desc as { follow?: unknown }).follow != null) { + throw new Error("static collider 不能带 follow"); + } + if (desc.motion === "dynamic" && (desc as { follow?: unknown }).follow != null) { + throw new Error("dynamic collider 不能带 follow"); + } + if (desc.shape.kind === "mesh") { + const hasSource = "source" in desc.shape && desc.shape.source != null; + const hasMesh = "mesh" in desc.shape && desc.shape.mesh != null; + if (hasSource === hasMesh) { + throw new Error('mesh shape 必须提供 source 或 mesh 之一'); + } + if (desc.motion === "dynamic" && !warnedDynamicMesh) { + warnedDynamicMesh = true; + console.warn( + "[three-player-controller] dynamic + mesh 允许但不推荐:三角网格无体积,易卡顿/穿透;动态物请优先 box/sphere。", + ); + } + } + + switch (desc.shape.kind) { + case "mesh": + return this.addMesh(desc); + case "box": + return this.addBox(desc); + case "sphere": + return this.addSphere(desc); + default: + throw new Error("未知 shape.kind"); + } + } + + /** 按句柄或 id 移除;同步释放 mesh / 动态刚体与 CollisionWorld 登记。 */ + remove(handle: ColliderHandle | number): void { + const id = typeof handle === "number" ? handle : handle.id; + if (id < 0) return; + const meta = this.handles.get(id); + const kin = this.kinematicColliders.find(e => e.worldId === id); + if (kin) { + kin.buildId += 1; // 作废进行中的异步 BVH + this.ctrl.scene.remove(kin.mesh); + kin.mesh.geometry.dispose(); + (kin.mesh.material as THREE.Material).dispose(); + if (this.ctrl.activeKinematicCollider === kin) this.ctrl.activeKinematicCollider = null; + this.kinematicColliders = this.kinematicColliders.filter(e => e !== kin); + } else if (meta?.collisionMesh) { + this.ctrl.scene.remove(meta.collisionMesh); + meta.collisionMesh.geometry.dispose(); + (meta.collisionMesh.material as THREE.Material).dispose(); + } + + // 动态刚体:remove 内部会卸 CollisionWorld + const body = this.ctrl.dynamics.findByColliderId(id); + if (body) { + this.ctrl.dynamics.remove(body); + this.handles.delete(id); + this.buildGens.delete(id); + return; + } + + this.ctrl.collisionWorld.remove(id); + this.handles.delete(id); + this.buildGens.delete(id); + } + + /** 清除碰撞体;可按 motion 过滤。 */ + clear(filter?: { motion?: MotionType }): void { + for (const id of this.ctrl.collisionWorld.idsByMotion(filter?.motion)) { + this.remove(id); + } + } + + /** 按 follow/source Object3D 查找运动学项。 */ + findKinematicBySource(source: THREE.Object3D): KinematicColliderEntry | undefined { + return this.kinematicColliders.find(e => e.source === source); + } + + /** + * 每帧同步运动学碰撞网格到 follow 的 matrixWorld。 + * 若人物站在平台上,用 prev→current 算出本帧应带走的 deltaPos / deltaRotY。 + */ + updateKinematicFollow(): void { + const playerWorldPos = this.ctrl.playerCapsule?.position; + for (const entry of this.kinematicColliders) { + if (playerWorldPos) { + const prevInv = new THREE.Matrix4().copy(entry.prevWorldMatrix).invert(); + const playerInLocal = playerWorldPos.clone().applyMatrix4(prevInv); + this.writeKinematicMeshMatrix(entry); + const playerInNewWorld = playerInLocal.clone().applyMatrix4(entry.mesh.matrixWorld); + entry.deltaPos.subVectors(playerInNewWorld, playerWorldPos); + } else { + this.writeKinematicMeshMatrix(entry); + entry.deltaPos.set(0, 0, 0); + } + const prevEuler = new THREE.Euler().setFromRotationMatrix(entry.prevWorldMatrix, "YXZ"); + const curEuler = new THREE.Euler().setFromRotationMatrix(entry.mesh.matrixWorld, "YXZ"); + entry.deltaRotY = curEuler.y - prevEuler.y; + } + } + + /** 将 follow 世界矩阵(含 contentOffset / bakeInverse / contentScale)写入碰撞 mesh。 */ + private writeKinematicMeshMatrix(entry: KinematicColliderEntry): void { + entry.source.updateMatrixWorld(true); + entry.mesh.matrix.copy(entry.source.matrixWorld); + if (entry.contentOffset.lengthSq() > 0) { + entry.mesh.matrix.multiply( + this._contentOffsetMatrix.makeTranslation( + entry.contentOffset.x, + entry.contentOffset.y, + entry.contentOffset.z, + ), + ); + } + entry.mesh.matrix.multiply(entry.bakeInverse); + const s = entry.contentScale; + if (s !== 1) { + entry.mesh.matrix.scale(this._contentScale.set(s, s, s)); + } + entry.mesh.updateMatrixWorld(true); + } + + /** + * 运行时等比缩放已烘焙的运动学 mesh 碰撞(不重建 BVH)。 + * source 为注册时的 follow / vehicleGroup。 + */ + scaleKinematicContent(source: THREE.Object3D, ratio: number): void { + if (!source || !Number.isFinite(ratio) || ratio === 1) return; + const entry = this.kinematicColliders.find(e => e.source === source); + if (!entry) return; + entry.contentScale *= ratio; + entry.contentOffset.multiplyScalar(ratio); + this.writeKinematicMeshMatrix(entry); + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + + /** 运行时平移已烘焙的运动学 mesh 碰撞,不重建 BVH。 */ + translateKinematicContent(source: THREE.Object3D, localOffset: THREE.Vector3): void { + if (!source || !Number.isFinite(localOffset.lengthSq()) || localOffset.lengthSq() === 0) return; + const entry = this.kinematicColliders.find(e => e.source === source); + if (!entry) return; + entry.contentOffset.add(localOffset); + this.writeKinematicMeshMatrix(entry); + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + + /** 本帧物理用完后再提交平台矩阵,供下一帧算 delta。 */ + commitKinematicPrev(): void { + for (const entry of this.kinematicColliders) { + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + } + + /** 是否已有至少一份带 BVH 的静态 mesh。 */ + isStaticUsable(): boolean { + for (const col of this.ctrl.collisionWorld.query( + { mask: 0xffff }, + { motion: "static", kinds: ["mesh"] }, + )) { + if (col.shape.kind === "mesh" && (col.shape.mesh.geometry as any).boundsTree) { + return true; + } + } + return false; + } + + /** 运动学项是否已就绪(BVH 可用)。 */ + isKinematicUsable(entry: KinematicColliderEntry): boolean { + return entry.ready && Boolean((entry.mesh.geometry as any).boundsTree); + } + + /** 写入 handles 并返回公开句柄。 */ + private makeHandle( + id: number, + motion: MotionType, + follow: THREE.Object3D | null, + source: THREE.Object3D | null, + collisionMesh: THREE.Mesh | null, + ): ColliderHandle { + const h: ColliderHandle = { id, motion, follow, source, collisionMesh }; + this.handles.set(id, h); + return h; + } + + /** mesh:按 motion 走动态登记 / 运动学 / 静态合并。 */ + private addMesh(desc: ColliderDesc): ColliderHandle { + const shape = desc.shape as Extract; + if (desc.motion === "dynamic") { + // 允许但不推荐:登记已有 mesh,不合并、不建刚体求解 + const mesh = "mesh" in shape && shape.mesh + ? shape.mesh + : (() => { + throw new Error("dynamic + mesh 请传入已有 mesh,或改用 box/sphere"); + })(); + const entry = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "mesh", mesh }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, "dynamic", null, null, mesh); + } + + if (desc.motion === "kinematic") { + return this.addKinematicMesh(desc); + } + + // static:已有 BVH mesh 直接登记,否则合并 source 再挂 BVH + const source = "source" in shape && shape.source + ? shape.source + : null; + const readyMesh = "mesh" in shape && shape.mesh ? shape.mesh : null; + + if (readyMesh) { + const entry = this.ctrl.collisionWorld.add({ + motion: "static", + shape: { kind: "mesh", mesh: readyMesh }, + groups: desc.groups, + mask: desc.mask, + ready: Boolean((readyMesh.geometry as any).boundsTree), + userData: desc.userData, + }); + this.syncDebugMesh(readyMesh); + return this.makeHandle(entry.id, "static", null, null, readyMesh); + } + + const built = buildStaticMergedMesh(source!); + if (!built) { + console.warn("静态 mesh 合并失败:无可用几何"); + return { id: -1, motion: "static", follow: null, source: null, collisionMesh: null }; + } + + const world = this.ctrl.collisionWorld.add({ + motion: "static", + shape: { kind: "mesh", mesh: built.mesh }, + groups: desc.groups, + mask: desc.mask, + ready: false, // BVH 完成前不参与查询 + userData: desc.userData, + }); + const handle = this.makeHandle( + world.id, + "static", + null, + Array.isArray(source) ? (source[0] ?? null) : source, + built.mesh, + ); + this.buildGens.set(world.id, 1); + const gen = 1; + + attachBoundsTree(built.geometry, { + useWorker: desc.useWorker, + workerPool: this.ctrl.getBvhWorkerPool(), + maxDepth: 100, + onStale: () => this.buildGens.get(world.id) !== gen || this.ctrl.collisionWorld.get(world.id) == null, + onReady: () => { + if (this.buildGens.get(world.id) !== gen) return; + this.ctrl.collisionWorld.setReady(world.id, true); + this.syncDebugMesh(built.mesh); + }, + }); + return handle; + } + + /** 运动学 mesh:已有 mesh 或从 source 合并,并加入 kinematicColliders 跟随列表。 */ + private addKinematicMesh(desc: ColliderDesc): ColliderHandle { + const shape = desc.shape as Extract; + const follow = + ("follow" in desc ? desc.follow : undefined) + ?? (("source" in shape && !Array.isArray(shape.source)) ? shape.source : undefined) + ?? null; + + if ("mesh" in shape && shape.mesh) { + const mesh = shape.mesh; + mesh.matrixAutoUpdate = false; + if (follow) { + follow.updateMatrixWorld(true); + mesh.matrix.copy(follow.matrixWorld); + mesh.updateMatrixWorld(true); + } + const entry: KinematicColliderEntry = { + source: follow ?? mesh, + mesh, + contentScale: 1, + contentOffset: new THREE.Vector3(), + bakeInverse: new THREE.Matrix4(), + prevWorldMatrix: new THREE.Matrix4().copy(follow?.matrixWorld ?? mesh.matrixWorld), + deltaPos: new THREE.Vector3(), + deltaRotY: 0, + ready: Boolean((mesh.geometry as any).boundsTree), + buildId: 1, + worldId: 0, + }; + const world = this.ctrl.collisionWorld.add({ + motion: "kinematic", + shape: { kind: "mesh", mesh }, + groups: desc.groups, + mask: desc.mask, + ready: entry.ready, + userData: entry, + }); + entry.worldId = world.id; + this.kinematicColliders.push(entry); + this.syncDebugMesh(mesh); + return this.makeHandle(world.id, "kinematic", follow, follow, mesh); + } + + const sourceObj = shape.source; + const root = Array.isArray(sourceObj) ? sourceObj[0] : sourceObj; + if (!root) throw new Error("kinematic mesh 需要 source"); + + // 同一 follow/source 已注册则复用,避免重复合并 + const existing = this.kinematicColliders.find(e => e.source === (follow ?? root)); + if (existing) { + return this.handles.get(existing.worldId) + ?? this.makeHandle(existing.worldId, "kinematic", follow ?? root, root, existing.mesh); + } + + const built = buildKinematicMergedMesh(root); + if (!built) { + console.warn("运动学 mesh 合并失败:无可用几何"); + return { id: -1, motion: "kinematic", follow: follow ?? root, source: root, collisionMesh: null }; + } + + const followTarget = follow ?? root; + followTarget.updateMatrixWorld(true); + // verts 已在世界空间收心 + const bakeInverse = new THREE.Matrix4() + .copy(followTarget.matrixWorld) + .invert() + .multiply(built.mesh.matrix); + const entry: KinematicColliderEntry = { + source: followTarget, + mesh: built.mesh, + contentScale: 1, + contentOffset: new THREE.Vector3(), + bakeInverse, + prevWorldMatrix: new THREE.Matrix4().copy(built.mesh.matrixWorld), + deltaPos: new THREE.Vector3(), + deltaRotY: 0, + ready: !desc.useWorker, // Worker 时先不可用,onReady 再打开 + buildId: 1, + worldId: 0, + }; + const world = this.ctrl.collisionWorld.add({ + motion: "kinematic", + shape: { kind: "mesh", mesh: built.mesh }, + groups: desc.groups, + mask: desc.mask, + ready: entry.ready, + userData: entry, + }); + entry.worldId = world.id; + this.kinematicColliders.push(entry); + const handle = this.makeHandle(world.id, "kinematic", followTarget, root, built.mesh); + const buildId = entry.buildId; + + attachBoundsTree(built.geometry, { + useWorker: desc.useWorker, + workerPool: this.ctrl.getBvhWorkerPool(), + onStale: () => entry.buildId !== buildId || !this.kinematicColliders.includes(entry), + onReady: () => { + if (entry.buildId !== buildId || !this.kinematicColliders.includes(entry)) return; + entry.ready = true; + this.ctrl.collisionWorld.setReady(entry.worldId, true); + this.syncDebugMesh(built.mesh); + }, + }); + return handle; + } + + /** 按当前开关同步单个碰撞 mesh。 */ + private syncDebugMesh(mesh: THREE.Mesh): void { + if (this.debugVisible && (mesh.geometry as any).boundsTree) { + if (!this.ctrl.scene.children.includes(mesh)) this.ctrl.scene.add(mesh); + } else { + this.ctrl.scene.remove(mesh); + } + } + + /** + * box: + * - dynamic 且 simulate 默认 true → 创建 DynamicBoxBody + * - dynamic 且 simulate: false → 仅登记(车辆底盘) + * - static / kinematic → 仅登记 + */ + private addBox(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "dynamic" && (desc as DynamicColliderDesc).simulate !== false) { + const body = this.ctrl.dynamics.addBox(desc as DynamicColliderDesc); + if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups); + if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask); + if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData); + return this.makeHandle(body.colliderId, "dynamic", null, null, null); + } + + const shape = desc.shape as Extract; + const half = shape.halfExtents.clone(); + const entry = this.ctrl.collisionWorld.add({ + motion: desc.motion, + shape: { kind: "box", halfExtents: half }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, desc.motion, null, null, null); + } + + /** + * sphere:dynamic 时创建 DynamicSphereBody; + * static/kinematic 仅登记形状(供查询矩阵,无球刚体步进)。 + */ + private addSphere(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "dynamic") { + const body = this.ctrl.dynamics.addSphere(desc as DynamicColliderDesc); + if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups); + if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask); + if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData); + return this.makeHandle(body.colliderId, "dynamic", null, null, null); + } + + const shape = desc.shape as Extract; + const entry = this.ctrl.collisionWorld.add({ + motion: desc.motion, + shape: { kind: "sphere", radius: shape.radius }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, desc.motion, null, null, null); + } +} diff --git a/three-player-controller/src/systems/DynamicBodySystem.ts b/three-player-controller/src/systems/DynamicBodySystem.ts new file mode 100644 index 0000000..6403eb6 --- /dev/null +++ b/three-player-controller/src/systems/DynamicBodySystem.ts @@ -0,0 +1,1270 @@ +import * as THREE from "three"; +import { ExtendedTriangle } from "three-mesh-bvh"; +import type { playerController } from "../PlayerController"; +import { CollisionGroup } from "../collision/groups"; +import { ContactCache } from "../collision/contacts/ContactCache"; +import { ContactManifold } from "../collision/contacts/ContactManifold"; +import { contactSkinForExtent, type RawContact } from "../collision/contacts/ContactPoint"; +import { reduceContacts } from "../collision/contacts/contactReducer"; +import { ContactImpulseSolver } from "../collision/solver/ContactImpulseSolver"; +import { DynamicBody, type DynamicBodyKind } from "../collision/DynamicBody"; +import { + collectSphereVsMeshContacts, + collideSphereVsObb, + createSphereCollisionTemps, + resolveSphereSphere, + DynamicSphereBody, + isSphereBody, +} from "../collision/DynamicSphere"; +import { + capsuleObbOverlap, + DynamicBoxBody, + isBoxBody, +} from "../collision/DynamicBox"; +import { DYNAMIC_BODY_DEFAULTS, type DynamicColliderDesc } from "../collision/colliderDesc"; +import type { VehicleInstance, VehicleWheelColliderUserData } from "../types"; +import { + capsuleSphereOverlap, + createCollisionTemps, + createGroundProbeQuery, + resetGroundProbeQuery, + tryGroundProbeCandidate, + tryGroundProbeTriangle, +} from "../utils/capsuleCollision"; +import { VehicleCollision } from "../utils/vehiclePhysics/VehicleCollision"; +import { createObbObbTemps, resolveObbObb } from "../collision/obbObb"; + +const TARGET_SUB_DT = 1 / 1200; // 目标子步时长(秒) +const MAX_SUBSTEPS = 20; // 单帧最多子步数 +const MAX_PUSH_RATIO = 2; // 单次位置修正不超过特征尺寸的倍数 +const MAX_SPEED_GRAVITY = 2; // 速度上限相对 |gravity| 的倍数 +const CHARACTER_PUSH_MASS = 10; // 推动态体时用的人物假质量(scale = 1 时)。 +const MAX_RAW = 64; // 单体单帧原始接触上限 +const POS_CORRECT = 0.4; // 求解后残留穿透的位置修正比例 +const MAX_DEPENETRATION_EXTENTS = 20; // 位置修正每秒最多推出的特征尺寸倍数 +const ROLL_BLEND = 0.45; // 支撑面上向无滑滚动收敛的插值比例 +const SUPPORT_Y = 0.5; // 视为支撑面的法线 y 下限 +const DYNAMIC_MASK = CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS; +const KIN_CAPSULE_BAUMGARTE = 0.8; // 动态体撞运动学胶囊时的位置修正比例 +/** 人物单次相对动态体的最大位置修正(相对胶囊半径)。 */ +const CHAR_SEP_MAX_RADIUS = 1.25; +const SLEEP_LINEAR = 0.3; // 线速度低于该值可累计休眠 +const SLEEP_ANGULAR = 0.7; // 角速度低于该值可累计休眠 +const SLEEP_TIME = 0.35; // 连续满足阈值后进入休眠(秒) +const SLEEP_WAKE_FACTOR = 3; // 超过阈值该倍数才清零休眠计时(迟滞) +const BROADPHASE_CELL_SCALE = 2; // 动态体互撞宽相位:XZ 网格单元 = 最大特征尺寸 × 该系数(下限 1)。 +const BOX_TRIANGLES = [ + 0, 2, 3, 0, 3, 1, + 4, 5, 7, 4, 7, 6, + 0, 4, 6, 0, 6, 2, + 1, 3, 7, 1, 7, 5, + 0, 1, 5, 0, 5, 4, + 2, 6, 7, 2, 7, 3, +] as const; + +/** 人物向下查询命中的动态支撑面。 */ +export type DynamicGroundHit = { + body: DynamicBody; + point: THREE.Vector3; + normal: THREE.Vector3; + velocity: THREE.Vector3; +}; + +/** + * 动态刚体推进:按 kind 分派形状窄相(球 / 盒)。 + * 网格接触走 ContactImpulseSolver;球–球 / 盒–盒 / 与人 / 与车用简化或 OBB 冲量。 + */ +export class DynamicBodySystem { + private ctrl: playerController; + list: DynamicBody[] = []; // 全部动态刚体 + /** 动态刚体碰撞线框开关。 */ + private debugVisible = false; + private temps = createSphereCollisionTemps(); + private carryPrevInv = new THREE.Matrix4(); + private skipVehicleMeshes: number[] = []; // 跳过车辆外观,避免与底盘盒重复 + private capsuleTemps = createCollisionTemps(); + private contactSolver = new ContactImpulseSolver(); + private contactCaches = new Map(); + private raw: RawContact[] = Array.from({ length: MAX_RAW }, () => ({ + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + penetration: 0, + })); + private manifolds: ContactManifold[] = []; + private bodyVel = new THREE.Vector3(); + private rollTarget = new THREE.Vector3(); + private boxMeshCollision = new VehicleCollision(); + private obbTemps = createObbObbTemps(); + private contactPoint = new THREE.Vector3(); + private wheelCenter = new THREE.Vector3(); + private wheelDir = new THREE.Vector3(); + private wheelPosBefore = new THREE.Vector3(); + /** 动态体互撞宽相位(XZ 网格,每子步重建)。 */ + private broadphaseGrid = new Map(); + private broadphaseCellSize = 1; + private bodyAabbMin: THREE.Vector3[] = []; + private bodyAabbMax: THREE.Vector3[] = []; + private broadphaseAxisX = new THREE.Vector3(); + private broadphaseAxisY = new THREE.Vector3(); + private broadphaseAxisZ = new THREE.Vector3(); + private broadphasePairSeen = new Set(); + private groundRay = new THREE.Ray(); + private groundBox = new THREE.Box3(); + private groundSphere = new THREE.Sphere(); + private groundOriginLocal = new THREE.Vector3(); + private groundDirectionLocal = new THREE.Vector3(); + private groundPointLocal = new THREE.Vector3(); + private groundNormalLocal = new THREE.Vector3(); + private groundInvQuat = new THREE.Quaternion(); + private groundPoint = new THREE.Vector3(); + private groundNormal = new THREE.Vector3(); + private groundVolumeQuery = createGroundProbeQuery(); + private groundProbeSegment = new THREE.Line3(); + private groundProbeTriangle = new ExtendedTriangle(); + private groundProbeTrianglePoint = new THREE.Vector3(); + private groundProbeSegmentPoint = new THREE.Vector3(); + private groundProbeSphereDelta = new THREE.Vector3(); + private groundProbeSphereHorizontal = new THREE.Vector3(); + private groundProbeBoxVertices = Array.from({ length: 8 }, () => new THREE.Vector3()); + private groundHit: DynamicGroundHit = { + body: null as unknown as DynamicBody, + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + velocity: new THREE.Vector3(), + }; + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + this.contactSolver.velocityIterations = 12; + } + + /** 返回动态刚体调试开关。 */ + getDebugVisible(): boolean { + return this.debugVisible; + } + + /** 切换动态刚体碰撞线框。 */ + setDebugVisible(visible: boolean): void { + this.debugVisible = visible; + this.syncDebugVisibility(); + } + + /** 同步全部动态刚体碰撞线框。 */ + syncDebugVisibility(): void { + for (const body of this.list) { + this.syncDebugMesh(body); + if (this.debugVisible) { + if (!this.ctrl.scene.children.includes(body.debugMesh)) this.ctrl.scene.add(body.debugMesh); + } else { + this.ctrl.scene.remove(body.debugMesh); + } + } + } + + /** 当前人物推动假质量:CHARACTER_PUSH_MASS × scale。 */ + private characterPushMass(): number { + const s = this.ctrl.playerModelConfig.scale; + return CHARACTER_PUSH_MASS * s; + } + + /** 添加动态球。 */ + addSphere(desc: DynamicColliderDesc): DynamicSphereBody { + if (desc.shape.kind !== "sphere") { + throw new Error("[DynamicBodySystem] addSphere 需要 shape.kind === \"sphere\""); + } + const mesh = desc.mesh; + if (!mesh) { + throw new Error("[DynamicBodySystem] addSphere 需要 mesh"); + } + + const radius = Math.max(1e-4, desc.shape.radius); + const position = (desc.shape.position ?? new THREE.Vector3()).clone(); + const velocity = (desc.velocity ?? new THREE.Vector3()).clone(); + const density = desc.density ?? DYNAMIC_BODY_DEFAULTS.density; + const restitution = desc.restitution ?? DYNAMIC_BODY_DEFAULTS.restitution; + const friction = desc.friction ?? DYNAMIC_BODY_DEFAULTS.friction; + const linearDamping = desc.linearDamping ?? DYNAMIC_BODY_DEFAULTS.linearDamping; + const angularDamping = desc.angularDamping ?? DYNAMIC_BODY_DEFAULTS.angularDamping; + const gravity = desc.gravity ?? this.ctrl.gravity; + + mesh.scale.setScalar(radius); + mesh.position.copy(position); + + const debugMesh = new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 12), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.35 }), + ); + debugMesh.scale.setScalar(radius); + debugMesh.position.copy(position); + + const body = new DynamicSphereBody({ + radius, + density, + restitution, + gravity, + position, + velocity, + angularVelocity: desc.angularVelocity, + linearDamping, + angularDamping, + friction, + mesh, + debugMesh, + }); + const col = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "sphere", radius }, + mask: DYNAMIC_MASK, + userData: body, + }); + body.colliderId = col.id; + this.list.push(body); + this.contactCaches.set(body, new ContactCache()); + if (this.debugVisible) this.ctrl.scene.add(debugMesh); + return body; + } + + /** 添加动态盒。 */ + addBox(desc: DynamicColliderDesc): DynamicBoxBody { + if (desc.shape.kind !== "box") { + throw new Error("[DynamicBodySystem] addBox 需要 shape.kind === \"box\""); + } + const mesh = desc.mesh; + if (!mesh) { + throw new Error("[DynamicBodySystem] addBox 需要 mesh"); + } + + const half = desc.shape.halfExtents.clone(); + half.x = Math.max(1e-4, half.x); + half.y = Math.max(1e-4, half.y); + half.z = Math.max(1e-4, half.z); + const position = (desc.shape.position ?? new THREE.Vector3()).clone(); + const velocity = (desc.velocity ?? new THREE.Vector3()).clone(); + const density = desc.density ?? DYNAMIC_BODY_DEFAULTS.density; + const restitution = desc.restitution ?? DYNAMIC_BODY_DEFAULTS.restitution; + const friction = desc.friction ?? DYNAMIC_BODY_DEFAULTS.friction; + const linearDamping = desc.linearDamping ?? DYNAMIC_BODY_DEFAULTS.linearDamping; + const angularDamping = desc.angularDamping ?? DYNAMIC_BODY_DEFAULTS.angularDamping; + const gravity = desc.gravity ?? this.ctrl.gravity; + + mesh.scale.set(half.x * 2, half.y * 2, half.z * 2); + mesh.position.copy(position); + if (desc.shape.quaternion) mesh.quaternion.copy(desc.shape.quaternion); + + const debugMesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.35 }), + ); + debugMesh.scale.copy(mesh.scale); + debugMesh.position.copy(position); + if (desc.shape.quaternion) debugMesh.quaternion.copy(desc.shape.quaternion); + + const body = new DynamicBoxBody({ + halfExtents: half, + density, + restitution, + gravity, + position, + quaternion: desc.shape.quaternion, + velocity, + angularVelocity: desc.angularVelocity, + linearDamping, + angularDamping, + friction, + mesh, + debugMesh, + }); + const col = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "box", halfExtents: half.clone() }, + mask: DYNAMIC_MASK, + userData: body, + }); + body.colliderId = col.id; + this.list.push(body); + this.contactCaches.set(body, new ContactCache()); + if (this.debugVisible) this.ctrl.scene.add(debugMesh); + return body; + } + + /** 移除动态刚体。 */ + remove(body: DynamicBody): void { + const idx = this.list.indexOf(body); + if (idx === -1) return; + this.list.splice(idx, 1); + this.contactCaches.delete(body); + if (this.ctrl.activeDynamicBody === body) this.ctrl.activeDynamicBody = null; + this.ctrl.collisionWorld.remove(body.colliderId); + this.ctrl.scene.remove(body.debugMesh); + body.debugMesh.geometry.dispose(); + (body.debugMesh.material as THREE.Material).dispose(); + } + + /** 按 CollisionWorld id 查找动态刚体。 */ + findByColliderId(id: number): DynamicBody | undefined { + return this.list.find(b => b.colliderId === id); + } + + /** 清除全部动态刚体。 */ + clear(): void { + while (this.list.length) this.remove(this.list[this.list.length - 1]); + } + + /** 从指定世界坐标向下查询最高的动态表面。 */ + raycastGround( + origin: THREE.Vector3, + minNormalY = SUPPORT_Y, + excludeKinds?: readonly DynamicBodyKind[], + ): DynamicGroundHit | null { + let bestY = -Infinity; + let bestBody: DynamicBody | null = null; + + for (const body of this.list) { + if (excludeKinds?.includes(body.kind)) continue; + let hit = false; + if (isBoxBody(body)) hit = this.raycastBoxGround(origin, body, minNormalY); + else if (isSphereBody(body)) hit = this.raycastSphereGround(origin, body, minNormalY); + if (!hit || this.groundPoint.y <= bestY) continue; + bestY = this.groundPoint.y; + bestBody = body; + this.groundHit.point.copy(this.groundPoint); + this.groundHit.normal.copy(this.groundNormal); + } + + if (!bestBody) return null; + this.groundHit.body = bestBody; + bestBody.getVelocityAtPoint(this.groundHit.point, this.groundHit.velocity); + return this.groundHit; + } + + /** 用带半径的竖直传感区查询最近的动态支撑面。 */ + probeGroundVolume( + sensorStart: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + sensorRadius: number, + minNormalY = SUPPORT_Y, + ): DynamicGroundHit | null { + if (maxDistance <= 0 || sensorRadius <= 0 || down.lengthSq() === 0) return null; + + // 所有动态形状写入同一个查询状态。 + resetGroundProbeQuery( + this.groundVolumeQuery, + sensorStart, + down, + maxDistance, + sensorRadius, + minNormalY, + ); + this.groundProbeSegment.start.copy(this.groundVolumeQuery.start); + this.groundProbeSegment.end.copy(this.groundVolumeQuery.start) + .addScaledVector(this.groundVolumeQuery.down, this.groundVolumeQuery.maxDistance); + + let bestBody: DynamicBody | null = null; + for (const body of this.list) { + // 各形状只负责生成表面候选点;共用筛选器负责决定是否更新最近命中。 + const improved = isBoxBody(body) + ? this.probeBoxGroundVolume(body) + : isSphereBody(body) + ? this.probeSphereGroundVolume(body) + : false; + if (improved) bestBody = body; + } + + if (!bestBody) return null; + this.groundHit.point.copy(this.groundVolumeQuery.hit.point); + this.groundHit.normal.copy(this.groundVolumeQuery.hit.normal); + this.groundHit.body = bestBody; + bestBody.getVelocityAtPoint(this.groundHit.point, this.groundHit.velocity); + return this.groundHit; + } + + /** 圆柱传感区与动态 OBB 的表面三角形查询。 */ + private probeBoxGroundVolume(body: DynamicBoxBody): boolean { + const half = body.halfExtents; + // 碰撞盒只保存半尺寸和位姿;先把 8 个局部角点转换到世界空间。 + for (let i = 0; i < this.groundProbeBoxVertices.length; i++) { + this.groundProbeBoxVertices[i] + .set( + (i & 1) === 0 ? -half.x : half.x, + (i & 2) === 0 ? -half.y : half.y, + (i & 4) === 0 ? -half.z : half.z, + ) + .applyQuaternion(body.quaternion) + .add(body.position); + } + + let improved = false; + for (let i = 0; i < BOX_TRIANGLES.length; i += 3) { + // 将 OBB 的 6 个面拆成 12 个三角形,与 BVH 网格复用相同的候选点语义。 + this.groundProbeTriangle.set( + this.groundProbeBoxVertices[BOX_TRIANGLES[i]], + this.groundProbeBoxVertices[BOX_TRIANGLES[i + 1]], + this.groundProbeBoxVertices[BOX_TRIANGLES[i + 2]], + ); + if (tryGroundProbeTriangle( + this.groundVolumeQuery, + this.groundProbeSegment, + this.groundProbeTriangle, + this.groundProbeTrianglePoint, + this.groundProbeSegmentPoint, + this.groundNormal, + )) improved = true; + } + return improved; + } + + /** 圆柱传感区与动态球的解析查询。 */ + private probeSphereGroundVolume(body: DynamicSphereBody): boolean { + const query = this.groundVolumeQuery; + this.groundProbeSphereDelta.subVectors(body.position, query.start); + const centerDistance = this.groundProbeSphereDelta.dot(query.down); + this.groundProbeSphereHorizontal.copy(this.groundProbeSphereDelta) + .addScaledVector(query.down, -centerDistance); + + // 在探测圆柱的圆形截面内,选择横向最靠近球心的位置。 + const horizontalDistance = this.groundProbeSphereHorizontal.length(); + const surfaceOffset = Math.max(0, horizontalDistance - query.radius); + if (surfaceOffset > body.radius) return false; + + const upwardExtent = Math.sqrt(Math.max(0, body.radius * body.radius - surfaceOffset * surfaceOffset)); + this.groundPoint.copy(body.position); + if (horizontalDistance > 1e-8) { + this.groundPoint.addScaledVector( + this.groundProbeSphereHorizontal, + -surfaceOffset / horizontalDistance, + ); + } + this.groundPoint.addScaledVector(query.down, -upwardExtent); + this.groundNormal.subVectors(this.groundPoint, body.position).normalize(); + + // 球只负责解析求出点和法线,其余有效性检查仍交给共用筛选器。 + return tryGroundProbeCandidate(query, this.groundPoint, this.groundNormal); + } + + /** 向下射线与动态 OBB 求交。 */ + private raycastBoxGround(origin: THREE.Vector3, body: DynamicBoxBody, minNormalY: number): boolean { + this.groundInvQuat.copy(body.quaternion).invert(); + this.groundOriginLocal.copy(origin).sub(body.position).applyQuaternion(this.groundInvQuat); + this.groundDirectionLocal.set(0, -1, 0).applyQuaternion(this.groundInvQuat).normalize(); + this.groundRay.set(this.groundOriginLocal, this.groundDirectionLocal); + this.groundBox.min.copy(body.halfExtents).multiplyScalar(-1); + this.groundBox.max.copy(body.halfExtents); + if (!this.groundRay.intersectBox(this.groundBox, this.groundPointLocal)) return false; + + const dx = Math.abs(Math.abs(this.groundPointLocal.x) - body.halfExtents.x); + const dy = Math.abs(Math.abs(this.groundPointLocal.y) - body.halfExtents.y); + const dz = Math.abs(Math.abs(this.groundPointLocal.z) - body.halfExtents.z); + if (dx <= dy && dx <= dz) { + this.groundNormalLocal.set(Math.sign(this.groundPointLocal.x || 1), 0, 0); + } else if (dy <= dz) { + this.groundNormalLocal.set(0, Math.sign(this.groundPointLocal.y || 1), 0); + } else { + this.groundNormalLocal.set(0, 0, Math.sign(this.groundPointLocal.z || 1)); + } + + this.groundNormal.copy(this.groundNormalLocal).applyQuaternion(body.quaternion).normalize(); + if (this.groundNormal.y < minNormalY) return false; + this.groundPoint.copy(this.groundPointLocal).applyQuaternion(body.quaternion).add(body.position); + return this.groundPoint.y <= origin.y; + } + + /** 向下射线与动态球求交。 */ + private raycastSphereGround(origin: THREE.Vector3, body: DynamicSphereBody, minNormalY: number): boolean { + this.groundRay.set(origin, this.groundDirectionLocal.set(0, -1, 0)); + this.groundSphere.center.copy(body.position); + this.groundSphere.radius = body.radius; + if (!this.groundRay.intersectSphere(this.groundSphere, this.groundPoint)) return false; + this.groundNormal.subVectors(this.groundPoint, body.position).normalize(); + return this.groundNormal.y >= minNormalY && this.groundPoint.y <= origin.y; + } + + /** 推进全部动态刚体:子步求解 → 平台带走 → 休眠判定 → 同步视觉。 */ + step(delta: number): void { + if (!this.list.length) return; + this.skipVehicleMeshes.length = 0; + for (const v of this.ctrl.vehicle.list) { + if (v.meshColliderId != null) this.skipVehicleMeshes.push(v.meshColliderId); + } + const substeps = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(delta / TARGET_SUB_DT))); + const sub = delta / substeps; + for (let i = 0; i < substeps; i++) this.stepSub(sub); + for (const body of this.list) this.applyKinematicCarry(body); + for (const body of this.list) this.updateSleep(body, delta); + for (const body of this.list) { + body.mesh.position.copy(body.position); + body.mesh.quaternion.copy(body.quaternion); + if (this.debugVisible) this.syncDebugMesh(body); + } + } + + /** 将动态刚体当前形状与位姿写入调试 mesh。 */ + private syncDebugMesh(body: DynamicBody): void { + body.debugMesh.position.copy(body.position); + body.debugMesh.quaternion.copy(body.quaternion); + if (isSphereBody(body)) body.debugMesh.scale.setScalar(body.radius); + else if (isBoxBody(body)) { + body.debugMesh.scale.set( + body.halfExtents.x * 2, + body.halfExtents.y * 2, + body.halfExtents.z * 2, + ); + } + } + + /** 单次子步:重力积分 → 按形状窄相 → 阻尼与限速。 */ + private stepSub(dt: number): void { + for (const body of this.list) { + if (body.sleeping) { + // 休眠体仍检测车辆接触,命中则唤醒并解算 + this.collideSleepingBodyWithVehicles(body); + continue; + } + const col = this.ctrl.collisionWorld.get(body.colliderId); + if (!col) continue; + + body.velocity.y += body.gravity * dt; + body.integrate(dt); + body.contactMesh = null; + + if (isSphereBody(body)) { + this.collideSphereMeshes(body, dt); + this.collideSphereBoxes(body); + this.collideSphereVehicleWheels(body); + this.resolveBodyVsKinematicCapsule(body); // 胶囊=运动学:只推刚体 + } else if (isBoxBody(body)) { + this.collideBoxMeshes(body, dt); + this.collideBoxVehicleBoxes(body); + this.collideBoxVehicleWheels(body); + this.resolveBodyVsKinematicCapsule(body); + } + body.applyDamping(dt); + this.clampSpeed(body); + } + this.rebuildDynamicBroadphase(); + this.collideSpherePairs(); + this.collideBoxPairs(); + for (const body of this.list) { + if (!body.sleeping) this.clampSpeed(body); + } + } + + /** 休眠刚体与车辆底盘 / 车轮球接触检测。 */ + private collideSleepingBodyWithVehicles(body: DynamicBody): void { + if (isBoxBody(body)) { + this.collideBoxVehicleBoxes(body); + this.collideBoxVehicleWheels(body); + } else if (isSphereBody(body)) { + this.collideSphereVehicleBoxes(body); + this.collideSphereVehicleWheels(body); + } + } + + /** 按速度阈值累计 / 清除休眠计时。 */ + private updateSleep(body: DynamicBody, dt: number): void { + if (!body.canSleep) { + if (body.sleeping) body.wakeUp(); + return; + } + if (body.sleeping) return; + + const linSq = body.velocity.lengthSq(); + const angSq = body.angularVelocity.lengthSq(); + const linMax = SLEEP_LINEAR * SLEEP_LINEAR; + const angMax = SLEEP_ANGULAR * SLEEP_ANGULAR; + const wakeLin = linMax * SLEEP_WAKE_FACTOR * SLEEP_WAKE_FACTOR; + const wakeAng = angMax * SLEEP_WAKE_FACTOR * SLEEP_WAKE_FACTOR; + + if (linSq > wakeLin || angSq > wakeAng) { + body.sleepTimer = 0; + return; + } + if (linSq <= linMax && angSq <= angMax) { + body.sleepTimer += dt; + if (body.sleepTimer >= SLEEP_TIME) body.putToSleep(); + } + } + + /** 双方都休眠则跳过;一方休眠时按需唤醒后再解算。 */ + private shouldSolveDynamicPair(a: DynamicBody, b: DynamicBody): boolean { + if (a.sleeping && b.sleeping) return false; + if (a.sleeping || b.sleeping) { + const awake = a.sleeping ? b : a; + const sleeper = a.sleeping ? a : b; + if (awake.isRestingCandidate(SLEEP_LINEAR, SLEEP_ANGULAR)) return false; + sleeper.wakeUp(); + return true; + } + return true; + } + + /** + * 球 vs 静态/平台网格:收集接触 → 过深则硬推 → 否则冲量求解 + 位置修正。 + * 跳过车辆外观 mesh,改由底盘盒处理。 + */ + private collideSphereMeshes(body: DynamicSphereBody, dt: number): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const meshes = this.ctrl.collisionWorld.queryMeshes(worldCol, { skipIds: this.skipVehicleMeshes }); + + let rawCount = 0; + let deepest = 0; + let deepestMesh: THREE.Mesh | null = null; + for (const mesh of meshes) { + const before = rawCount; + rawCount = collectSphereVsMeshContacts( + body.position, body.radius, mesh, this.temps, this.raw, rawCount, MAX_RAW, + ); + for (let i = before; i < rawCount; i++) { + if (this.raw[i].penetration > deepest) { + deepest = this.raw[i].penetration; + deepestMesh = mesh; + } + } + } + if (rawCount === 0) return; + body.contactMesh = deepestMesh; + + // 卡进网格过深时先钳制推出,避免冲量爆炸 + const maxPush = body.radius * MAX_PUSH_RATIO; + if (deepest > maxPush) { + let nx = 0, ny = 0, nz = 0, w = 0; + for (let i = 0; i < rawCount; i++) { + const p = Math.max(this.raw[i].penetration, 0); + nx += this.raw[i].normal.x * p; + ny += this.raw[i].normal.y * p; + nz += this.raw[i].normal.z * p; + w += p; + } + if (w > 1e-8) { + this.temps.normal.set(nx / w, ny / w, nz / w); + if (this.temps.normal.lengthSq() > 1e-8) { + this.temps.normal.normalize(); + body.position.addScaledVector( + this.temps.normal, + Math.min(deepest, maxPush, this.maxPositionCorrection(body, dt)), + ); + } + } + return; + } + + this.solveMeshManifolds(body, rawCount, dt); + this.applySphereSupportRolling(body); + } + + /** 盒 vs 静态/平台网格:复用 VehicleCollision 收集接触后冲量求解。 */ + private collideBoxMeshes(body: DynamicBoxBody, dt: number): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const meshes = this.ctrl.collisionWorld.queryMeshes(worldCol, { skipIds: this.skipVehicleMeshes }); + if (!meshes.length) return; + + this.manifolds.length = 0; + let deepest = 0; + let deepestMesh: THREE.Mesh | null = null; + for (const mesh of meshes) { + const found = this.boxMeshCollision.detect(body, mesh); + if (!found.length) continue; + let meshDeep = 0; + for (const m of found) { + this.manifolds.push(m); + for (const p of m.contacts) { + if (p.penetration > meshDeep) meshDeep = p.penetration; + } + } + if (meshDeep > deepest) { + deepest = meshDeep; + deepestMesh = mesh; + } + } + if (!this.manifolds.length) return; + body.contactMesh = deepestMesh; + + const maxPush = body.characteristicExtent() * MAX_PUSH_RATIO; + if (deepest > maxPush) { + let nx = 0, ny = 0, nz = 0, w = 0; + for (const m of this.manifolds) { + for (const p of m.contacts) { + const pen = Math.max(p.penetration, 0); + nx += m.normal.x * pen; + ny += m.normal.y * pen; + nz += m.normal.z * pen; + w += pen; + } + } + if (w > 1e-8) { + this.temps.normal.set(nx / w, ny / w, nz / w); + if (this.temps.normal.lengthSq() > 1e-8) { + this.temps.normal.normalize(); + body.position.addScaledVector( + this.temps.normal, + Math.min(deepest, maxPush, this.maxPositionCorrection(body, dt)), + ); + } + } + return; + } + + const cache = this.contactCaches.get(body); + cache?.match(this.manifolds); + this.contactSolver.solveVelocity(body, this.manifolds, dt); + cache?.save(this.manifolds); + this.correctMeshPenetration(body, this.manifolds, dt); + } + + /** 球网格接触:归约 → 冲量 → 位置修正。 */ + private solveMeshManifolds(body: DynamicBody, rawCount: number, dt: number): void { + this.manifolds.length = 0; + reduceContacts(this.raw, rawCount, body, this.manifolds); + const cache = this.contactCaches.get(body); + cache?.match(this.manifolds); + this.contactSolver.solveVelocity(body, this.manifolds, dt); + cache?.save(this.manifolds); + this.correctMeshPenetration(body, this.manifolds, dt); + } + + /** 每个流形只按最深接触修正一次位置。 */ + private correctMeshPenetration(body: DynamicBody, manifolds: ContactManifold[], dt: number): void { + const skin = contactSkinForExtent(body.characteristicExtent()); + const maxCorrection = this.maxPositionCorrection(body, dt); + for (const manifold of manifolds) { + let deepest = 0; + for (const point of manifold.contacts) { + if (point.penetration > deepest) deepest = point.penetration; + } + const correction = Math.min((deepest - skin) * POS_CORRECT, maxCorrection); + if (correction > 0) body.position.addScaledVector(manifold.normal, correction); + } + } + + /** 按特征尺寸限制单子步的最大位置修正。 */ + private maxPositionCorrection(body: DynamicBody, dt: number): number { + return body.characteristicExtent() * MAX_DEPENETRATION_EXTENTS * dt; + } + + /** + * 在支撑面上将角速度拉向无滑滚动:ω → n × v_tang / R。 + * 补强摩擦迭代不足时的滚动手感。 + */ + private applySphereSupportRolling(body: DynamicSphereBody): void { + let bestY = SUPPORT_Y; + let support: ContactManifold | null = null; + for (const m of this.manifolds) { + if (m.normal.y > bestY) { + bestY = m.normal.y; + support = m; + } + } + if (!support) return; + + const n = support.normal; + this.temps.offset.copy(body.velocity).addScaledVector(n, -body.velocity.dot(n)); // v_tang + if (this.temps.offset.lengthSq() < 1e-10) return; + this.rollTarget.copy(n).cross(this.temps.offset).multiplyScalar(1 / Math.max(body.radius, 1e-6)); + body.angularVelocity.lerp(this.rollTarget, ROLL_BLEND); + } + + /** 球 vs 车辆底盘 / 动态盒 OBB。 */ + private collideSphereBoxes(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (chassis) { + this.resolveSphereVsObbBody(body, chassis); + continue; + } + const other = box.userData; + if (isBoxBody(other as DynamicBody) && other !== body) { + this.resolveSphereVsObbBody(body, other as DynamicBoxBody); + } + } + } + + /** 球 vs 车辆底盘(休眠体专用,不测其它动态盒)。 */ + private collideSphereVehicleBoxes(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (!chassis) continue; + if (this.resolveSphereVsObbBody(body, chassis)) body.wakeUp(); + } + } + + /** 球与单个 OBB 刚体:推出球并双方在接触点施冲量;有接触返回 true。 */ + private resolveSphereVsObbBody( + body: DynamicSphereBody, + other: { + position: THREE.Vector3; quaternion: THREE.Quaternion; halfExtents: THREE.Vector3; + getVelocityAtPoint(p: THREE.Vector3, o: THREE.Vector3): THREE.Vector3; + applyImpulseAtPoint(i: THREE.Vector3, p: THREE.Vector3): void + }, + ): boolean { + if (!collideSphereVsObb( + body.position, body.radius, + other.position, other.quaternion, other.halfExtents, + this.temps, + )) return false; + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + other.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + this.temps.offset.copy(this.bodyVel).sub(this.temps.boxVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + other.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** 动态盒 vs 车辆底盘 OBB;有接触时唤醒休眠盒。 */ + private collideBoxVehicleBoxes(body: DynamicBoxBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + if (box.id === body.colliderId) continue; + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (!chassis) continue; + if (resolveObbObb(body, chassis, this.obbTemps)) body.wakeUp(); + } + } + + /** 动态球 vs 车轮运动学球。 */ + private collideSphereVehicleWheels(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const spheres = this.ctrl.collisionWorld.query(worldCol, { + kinds: ["sphere"], + motion: ["kinematic", "static"], + }); + for (const col of spheres) { + if (col.shape.kind !== "sphere") continue; + const wheel = this.asVehicleWheel(col.userData); + if (!wheel) continue; + if (this.resolveSphereVsVehicleWheel( + body, wheel.vehicle, wheel.wheelIndex, col.shape.radius, + )) { + body.wakeUp(); + } + } + } + + /** 动态盒 vs 车轮运动学球。 */ + private collideBoxVehicleWheels(body: DynamicBoxBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const spheres = this.ctrl.collisionWorld.query(worldCol, { + kinds: ["sphere"], + motion: ["kinematic", "static"], + }); + for (const col of spheres) { + if (col.shape.kind !== "sphere") continue; + const wheel = this.asVehicleWheel(col.userData); + if (!wheel) continue; + if (this.resolveBoxVsVehicleWheel( + body, wheel.vehicle, wheel.wheelIndex, col.shape.radius, + )) { + body.wakeUp(); + } + } + } + + /** 识别车轮球 userData。 */ + private asVehicleWheel(userData: unknown): VehicleWheelColliderUserData | null { + if (!userData || typeof userData !== "object") return null; + const data = userData as VehicleWheelColliderUserData; + if (!data.vehicle?.chassisBody || typeof data.wheelIndex !== "number") return null; + return data; + } + + /** + * 由底盘位姿 + 悬挂长度推算轮心。 + * 成功时写入 wheelCenter,返回实际使用的半径。 + */ + private getWheelCenterWS( + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): number | null { + const chassis = vehicle.chassisBody; + const wheel = vehicle.vehicleController.wheelAt(wheelIndex); + if (!wheel) return null; + this.wheelCenter.copy(wheel.connectionPoint).applyQuaternion(chassis.quaternion).add(chassis.position); + this.wheelDir.copy(wheel.direction).applyQuaternion(chassis.quaternion); + const dirLen = this.wheelDir.length(); + if (dirLen > 1e-8) this.wheelCenter.addScaledVector(this.wheelDir, wheel.suspensionLength / dirLen); + return Math.max(1e-4, registeredRadius > 0 ? registeredRadius : wheel.radius); + } + + /** + * 动态球 vs 单轮:只推开球,冲量回写底盘。 + * 法线由轮心指向球。 + */ + private resolveSphereVsVehicleWheel( + body: DynamicSphereBody, + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): boolean { + const chassis = vehicle.chassisBody; + const radius = this.getWheelCenterWS(vehicle, wheelIndex, registeredRadius); + if (radius == null) return false; + + this.temps.offset.subVectors(body.position, this.wheelCenter); + const dist = this.temps.offset.length(); + const minDist = body.radius + radius; + if (dist >= minDist) return false; + if (dist > 1e-8) this.temps.normal.copy(this.temps.offset).multiplyScalar(1 / dist); + else this.temps.normal.set(0, 1, 0); + + const depth = minDist - dist; + body.position.addScaledVector(this.temps.normal, depth); + this.temps.contact.copy(this.wheelCenter).addScaledVector(this.temps.normal, radius); + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + chassis.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + this.temps.offset.copy(this.bodyVel).sub(this.temps.boxVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + chassis.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** + * 动态盒 vs 单轮:用球-OBB 测重叠,将本应推开轮球的位移反加到盒上,冲量回写底盘。 + * collideSphereVsObb 的法线由盒指向轮。 + */ + private resolveBoxVsVehicleWheel( + body: DynamicBoxBody, + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): boolean { + const chassis = vehicle.chassisBody; + const radius = this.getWheelCenterWS(vehicle, wheelIndex, registeredRadius); + if (radius == null) return false; + + this.wheelPosBefore.copy(this.wheelCenter); + if (!collideSphereVsObb( + this.wheelCenter, radius, + body.position, body.quaternion, body.halfExtents, + this.temps, + )) { + this.wheelCenter.copy(this.wheelPosBefore); + return false; + } + + // 轮球被推出的位移改为推开动态盒 + this.temps.offset.subVectors(this.wheelCenter, this.wheelPosBefore); + body.position.sub(this.temps.offset); + this.wheelCenter.copy(this.wheelPosBefore); + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + chassis.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + // 将轮视为「球」、盒视为 OBB:相对速度 = 轮速 - 盒速 + this.temps.offset.copy(this.temps.boxVel).sub(this.bodyVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + // 法线由盒→轮:冲量加到底盘(轮侧),反向加到盒 + chassis.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** 球与球两两求解(宽相位筛对后窄相)。 */ + private collideSpherePairs(): void { + for (const [i, j] of this.iterDynamicBroadphasePairs()) { + const a = this.list[i]; + const b = this.list[j]; + if (!isSphereBody(a) || !isSphereBody(b)) continue; + if (!this.shouldSolveDynamicPair(a, b)) continue; + resolveSphereSphere(a, b, this.temps, 0.5 * (a.restitution + b.restitution)); + } + } + + /** 盒与盒两两 OBB 求解(宽相位筛对后窄相)。 */ + private collideBoxPairs(): void { + for (const [i, j] of this.iterDynamicBroadphasePairs()) { + const a = this.list[i]; + const b = this.list[j]; + if (!isBoxBody(a) || !isBoxBody(b)) continue; + if (!this.shouldSolveDynamicPair(a, b)) continue; + resolveObbObb(a, b, this.obbTemps); + } + } + + /** 重建 XZ 网格宽相位;单元尺寸随最大特征体自适应。 */ + private rebuildDynamicBroadphase(): void { + const n = this.list.length; + this.broadphaseGrid.clear(); + if (n === 0) return; + + while (this.bodyAabbMin.length < n) { + this.bodyAabbMin.push(new THREE.Vector3()); + this.bodyAabbMax.push(new THREE.Vector3()); + } + + let maxExtent = 0.5; + for (let i = 0; i < n; i++) { + maxExtent = Math.max(maxExtent, this.list[i].characteristicExtent()); + this.setBodyAabb(this.list[i], this.bodyAabbMin[i], this.bodyAabbMax[i]); + } + this.broadphaseCellSize = Math.max(1, maxExtent * BROADPHASE_CELL_SCALE); + const invCell = 1 / this.broadphaseCellSize; + + for (let i = 0; i < n; i++) { + const min = this.bodyAabbMin[i]; + const max = this.bodyAabbMax[i]; + const ix0 = Math.floor(min.x * invCell); + const ix1 = Math.floor(max.x * invCell); + const iz0 = Math.floor(min.z * invCell); + const iz1 = Math.floor(max.z * invCell); + for (let ix = ix0; ix <= ix1; ix++) { + for (let iz = iz0; iz <= iz1; iz++) { + const key = `${ix},${iz}`; + let bucket = this.broadphaseGrid.get(key); + if (!bucket) { + bucket = []; + this.broadphaseGrid.set(key, bucket); + } + bucket.push(i); + } + } + } + } + + /** 遍历宽相位候选对(同格 + 相邻格,AABB 重叠,i < j 去重)。 */ + private *iterDynamicBroadphasePairs(): Generator<[number, number]> { + const n = this.list.length; + if (n < 2) return; + this.broadphasePairSeen.clear(); + + for (const [key, bucketA] of this.broadphaseGrid) { + const comma = key.indexOf(","); + const ix = Number(key.slice(0, comma)); + const iz = Number(key.slice(comma + 1)); + for (let dz = 0; dz <= 1; dz++) { + for (let dx = 0; dx <= 1; dx++) { + const bucketB = this.broadphaseGrid.get(`${ix + dx},${iz + dz}`); + if (!bucketB) continue; + for (const i of bucketA) { + for (const j of bucketB) { + if (i >= j) continue; + const pairKey = i * n + j; + if (this.broadphasePairSeen.has(pairKey)) continue; + this.broadphasePairSeen.add(pairKey); + if (!this.bodyAabbOverlap(i, j)) continue; + yield [i, j]; + } + } + } + } + } + } + + /** 写入刚体世界 AABB;盒体将 OBB 三轴投影后取包络(宽相位粗测用,偏保守)。 */ + private setBodyAabb(body: DynamicBody, min: THREE.Vector3, max: THREE.Vector3): void { + if (isSphereBody(body)) { + const r = body.radius; + min.set(body.position.x - r, body.position.y - r, body.position.z - r); + max.set(body.position.x + r, body.position.y + r, body.position.z + r); + return; + } + if (isBoxBody(body)) { + const hx = body.halfExtents.x; + const hy = body.halfExtents.y; + const hz = body.halfExtents.z; + this.broadphaseAxisX.set(hx, 0, 0).applyQuaternion(body.quaternion); + this.broadphaseAxisY.set(0, hy, 0).applyQuaternion(body.quaternion); + this.broadphaseAxisZ.set(0, 0, hz).applyQuaternion(body.quaternion); + const ex = Math.abs(this.broadphaseAxisX.x) + + Math.abs(this.broadphaseAxisY.x) + + Math.abs(this.broadphaseAxisZ.x); + const ey = Math.abs(this.broadphaseAxisX.y) + + Math.abs(this.broadphaseAxisY.y) + + Math.abs(this.broadphaseAxisZ.y); + const ez = Math.abs(this.broadphaseAxisX.z) + + Math.abs(this.broadphaseAxisY.z) + + Math.abs(this.broadphaseAxisZ.z); + min.set(body.position.x - ex, body.position.y - ey, body.position.z - ez); + max.set(body.position.x + ex, body.position.y + ey, body.position.z + ez); + } + } + + /** 宽相位候选对 AABB 三轴重叠检测;未通过则跳过窄相。 */ + private bodyAabbOverlap(i: number, j: number): boolean { + const aMin = this.bodyAabbMin[i]; + const aMax = this.bodyAabbMax[i]; + const bMin = this.bodyAabbMin[j]; + const bMax = this.bodyAabbMax[j]; + return ( + aMin.x <= bMax.x && aMax.x >= bMin.x + && aMin.y <= bMax.y && aMax.y >= bMin.y + && aMin.z <= bMax.z && aMax.z >= bMin.z + ); + } + + /** + * 人物分步移动时与动态体接触。 + * 胶囊只做位置推出;对刚体施水平单向冲量(不回推人物速度)。 + */ + collideWithCapsule(): void { + if (this.ctrl.controllerMode === 1 || this.ctrl.skipCapsuleCollision) return; + const cap = this.ctrl.playerCapsule; + const info = cap?.capsuleInfo; + if (!cap || !info) return; + + const maxSep = info.radius * CHAR_SEP_MAX_RADIUS; + const playerVel = this.ctrl.playerVelocity; + const charMass = this.characterPushMass(); + + for (const body of this.list) { + const hit = this.queryCapsuleBodyOverlap(cap, info, body); + if (!hit || hit.depth <= 0) continue; + + // 步行法线压到水平,避免竖直分量把人弹飞 + this.temps.normal.copy(hit.normal); + if (!this.ctrl.isFlying) { + this.temps.normal.y = 0; + if (this.temps.normal.lengthSq() < 1e-8) { + this.temps.normal.set( + body.position.x - cap.position.x, + 0, + body.position.z - cap.position.z, + ); + if (this.temps.normal.lengthSq() < 1e-8) this.temps.normal.set(1, 0, 0); + } + this.temps.normal.normalize(); + } + + const sep = Math.min(hit.depth, maxSep); + cap.position.addScaledVector(this.temps.normal, -sep); + cap.updateMatrixWorld(); + + body.getVelocityAtPoint(hit.contact, this.bodyVel); + this.temps.offset.set(playerVel.x - this.bodyVel.x, 0, playerVel.z - this.bodyVel.z); + const deltaVel = this.temps.offset.dot(this.temps.normal); + if (deltaVel <= 0) continue; + + const massRatio = (body.mass * charMass) / (body.mass + charMass); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVel * massRatio); + this.temps.impulse.y = 0; + body.applyImpulseAtPoint(this.temps.impulse, hit.contact); + } + } + + /** 查询胶囊与单个动态体的重叠;normal 由胶囊指向刚体。 */ + private queryCapsuleBodyOverlap( + cap: THREE.Object3D, + info: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + body: DynamicBody, + ): { depth: number; normal: THREE.Vector3; contact: THREE.Vector3 } | null { + if (isSphereBody(body)) { + const depth = capsuleSphereOverlap(cap, info, body.position, body.radius, this.capsuleTemps); + if (depth <= 0) return null; + this.contactPoint.copy(this.capsuleTemps.closestSeg).addScaledVector( + this.capsuleTemps.closestTri, info.radius, + ); + return { + depth, + normal: this.capsuleTemps.closestTri, + contact: this.contactPoint, + }; + } + if (isBoxBody(body)) { + const depth = capsuleObbOverlap( + cap, info, body.position, body.quaternion, body.halfExtents, this.capsuleTemps, + ); + if (depth <= 0) return null; + this.contactPoint.copy(this.capsuleTemps.closestSeg).addScaledVector( + this.capsuleTemps.closestTri, info.radius, + ); + return { + depth, + normal: this.capsuleTemps.closestTri, + contact: this.contactPoint, + }; + } + return null; + } + + /** + * 物理子步:胶囊视为运动学障碍(无限质量),只推出刚体并去掉侵入法向速度。 + */ + private resolveBodyVsKinematicCapsule(body: DynamicBody): void { + const cap = this.ctrl.playerCapsule; + const info = cap?.capsuleInfo; + if (!cap || !info || this.ctrl.controllerMode === 1) return; + + const hit = this.queryCapsuleBodyOverlap(cap, info, body); + if (!hit || hit.depth <= 0) return; + + // 法线:胶囊→刚体;推出刚体。步行压低竖直分量,避免箱子被顶飞。 + this.temps.normal.copy(hit.normal); + if (!this.ctrl.isFlying && Math.abs(this.temps.normal.y) > 0.35) { + this.temps.normal.y *= 0.15; + if (this.temps.normal.lengthSq() > 1e-8) this.temps.normal.normalize(); + else { + this.temps.normal.set( + body.position.x - cap.position.x, + 0, + body.position.z - cap.position.z, + ); + if (this.temps.normal.lengthSq() < 1e-8) return; + this.temps.normal.normalize(); + } + } + + const sep = Math.min(hit.depth, info.radius * CHAR_SEP_MAX_RADIUS); + body.position.addScaledVector(this.temps.normal, sep * KIN_CAPSULE_BAUMGARTE); + + body.getVelocityAtPoint(hit.contact, this.bodyVel); + const vn = this.bodyVel.dot(this.temps.normal); + if (vn < 0) { + this.temps.impulse.copy(this.temps.normal).multiplyScalar(-vn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, hit.contact); + } + } + + /** 限制线速度 / 角速度,避免异常弹出。 */ + private clampSpeed(body: DynamicBody): void { + const extent = body.characteristicExtent(); + const maxSpeed = Math.max(Math.abs(body.gravity) * MAX_SPEED_GRAVITY, extent * 80); + const speed = body.velocity.length(); + if (speed > maxSpeed) body.velocity.multiplyScalar(maxSpeed / speed); + const maxAng = maxSpeed / Math.max(extent, 1e-4); + const ang = body.angularVelocity.length(); + if (ang > maxAng) body.angularVelocity.multiplyScalar(maxAng / ang); + } + + /** 刚体停在运动学网格上时按平台 prev→current 矩阵带走;位移过大则回退。 */ + private applyKinematicCarry(body: DynamicBody): void { + if (body.sleeping || !body.contactMesh) return; + const entry = this.ctrl.getKinematicColliderEntries().find(e => e.mesh === body.contactMesh); + if (!entry) return; + this.carryPrevInv.copy(entry.prevWorldMatrix).invert(); + this.temps.before.copy(body.position); + body.position.applyMatrix4(this.carryPrevInv).applyMatrix4(entry.source.matrixWorld); + const maxCarry = body.characteristicExtent() * 8; + if (body.position.distanceToSquared(this.temps.before) > maxCarry * maxCarry) { + body.position.copy(this.temps.before); + } + } +} diff --git a/three-player-controller/src/systems/InputSystem.ts b/three-player-controller/src/systems/InputSystem.ts new file mode 100644 index 0000000..1946023 --- /dev/null +++ b/three-player-controller/src/systems/InputSystem.ts @@ -0,0 +1,247 @@ +import type { playerController } from "../PlayerController"; +import type { KeyAction, KeyMap } from "../types"; + +// 默认键位表(动作 -> KeyboardEvent.code 列表) +const defaultKeyMap: Record = { + forward: ["KeyW", "ArrowUp"], + backward: ["KeyS", "ArrowDown"], + left: ["KeyA", "ArrowLeft"], + right: ["KeyD", "ArrowRight"], + sprint: ["ShiftLeft", "ShiftRight"], + jump: ["Space"], + toggleView: ["KeyV"], + toggleFly: ["KeyF"], + toggleVehicle: ["KeyE"], +}; + +export class InputSystem { + private ctrl: playerController; // 主控制器引用 + + fwd = false; // 前进键 + bkd = false; // 后退键 + lft = false; // 左移键 + rgt = false; // 右移键 + space = false; // 跳跃键 + shift = false; // 加速键 + + private keyFwd = false; + private keyBkd = false; + private keyLft = false; + private keyRgt = false; + private analogMoveX = 0; + private analogMoveY = 0; + + private boundKeydown = async (e: KeyboardEvent) => this.onKeydown(e); // 键盘按下绑定 + private boundKeyup = (e: KeyboardEvent) => this.onKeyup(e); // 键盘抬起绑定 + private boundMouseMove = (e: MouseEvent) => this.onMouseMove(e); // 鼠标移动绑定 + private boundMouseClick = (e: MouseEvent) => { + if (e.target === this.ctrl.controls.domElement) this.ctrl.cam.setPointerLock(); // 鼠标点击绑定 + }; + private boundBlur = () => this.resetKeys(); // 页面失焦时重置按键状态 + + private codeToAction = new Map(); // 键码 -> 动作 反查表 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + this.buildKeyMap(); + } + + // 构建键码:动作 反查表:未传的动作用默认键,传 string/数组则覆盖,传 null 则禁用 + buildKeyMap(userMap?: KeyMap) { + this.codeToAction.clear(); // 清空旧表 + for (const action of Object.keys(defaultKeyMap) as KeyAction[]) { + let codes: string[]; + if (userMap && action in userMap) { + const v = userMap[action]; + if (v == null) continue; // null:禁用该动作 + codes = Array.isArray(v) ? v : [v]; // 覆盖默认键 + } else { + codes = defaultKeyMap[action]; // 未传:用默认键 + } + for (const code of codes) this.codeToAction.set(code, action); + } + } + + // 程序化输入接口 + setInput(input: Partial<{ + moveX: number; moveY: number; + lookDeltaX: number; lookDeltaY: number; + jump: boolean; shift: boolean; + toggleView: boolean; toggleFly: boolean; toggleVehicle: boolean; + }>) { + const c = this.ctrl; + + // 连续移动轴,供摇杆或手柄输入使用 + const prevFwd = this.fwd; + const prevBkd = this.bkd; + const prevLft = this.lft; + const prevRgt = this.rgt; + let moveChanged = false; + if (typeof input.moveX === "number") { + this.analogMoveX = Math.max(-1, Math.min(1, input.moveX)); + moveChanged = true; + } + if (typeof input.moveY === "number") { + this.analogMoveY = Math.max(-1, Math.min(1, input.moveY)); + moveChanged = true; + } + if (moveChanged) { + this.syncDirectionFlags(); + if (prevFwd !== this.fwd || prevBkd !== this.bkd || prevLft !== this.lft || prevRgt !== this.rgt) { + c.animation.setAnimationByPressed(); + } + } + + // 视角朝向 + if (typeof input.lookDeltaX === "number" && typeof input.lookDeltaY === "number") { + c.cam.setToward(input.lookDeltaX, input.lookDeltaY, 0.002); + } + + // 持续状态 + if (typeof input.jump === "boolean") this.applyAction("jump", input.jump); + if (typeof input.shift === "boolean") this.applyAction("sprint", input.shift); + + // 触发式切换 + if (input.toggleView) this.applyAction("toggleView", true); + if (input.toggleFly) this.applyAction("toggleFly", true); + if (input.toggleVehicle) this.applyAction("toggleVehicle", true); + } + + // 绑定输入事件 + bindEvents() { + this.ctrl.isupdate = true; + this.ctrl.cam.setPointerLock(); + window.addEventListener("keydown", this.boundKeydown); + window.addEventListener("keyup", this.boundKeyup); + window.addEventListener("mousemove", this.boundMouseMove); + window.addEventListener("click", this.boundMouseClick); + window.addEventListener("blur", this.boundBlur); + } + + // 解绑输入事件 + unbindEvents() { + this.ctrl.isupdate = false; + document.exitPointerLock(); + window.removeEventListener("keydown", this.boundKeydown); + window.removeEventListener("keyup", this.boundKeyup); + window.removeEventListener("mousemove", this.boundMouseMove); + window.removeEventListener("click", this.boundMouseClick); + window.removeEventListener("blur", this.boundBlur); + } + + // 重置所有按键状态 + private resetKeys() { + const c = this.ctrl; + this.keyFwd = false; + this.keyBkd = false; + this.keyLft = false; + this.keyRgt = false; + this.analogMoveX = 0; + this.analogMoveY = 0; + this.syncDirectionFlags(); + this.space = false; + this.shift = false; + c.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + c.animation.setAnimationByPressed(); + } + + // 统一动作派发 + private applyAction(action: KeyAction, pressed: boolean) { + const c = this.ctrl; + switch (action) { + // 前进 + case "forward": this.keyFwd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 后退 + case "backward": this.keyBkd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 左移 + case "left": this.keyLft = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 右移 + case "right": this.keyRgt = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 冲刺 + case "sprint": + this.shift = pressed; + c.animation.setAnimationByPressed(); + // 切换轨道拖拽键位 + c.controls.mouseButtons = pressed + ? { LEFT: 2, MIDDLE: 1, RIGHT: 0 } + : { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + break; + // 跳跃 + case "jump": + if (pressed) { + this.space = true; + if (c.controllerMode === 1) return; // 载具模式不跳跃 + if (c.isFlying) { c.animation.setAnimationByPressed(); return; } // 飞行中仅切动画 + if (!c.playerIsOnGround) return; // 不在地面不能跳 + if (c.animation.isJumping()) return; // 跳跃中不重复触发 + c.animation.startJump(); + c.playerVelocity.y = c.jumpHeight; + c.setOnGround(false); // 跳跃后设置为不在地面 + } else { + this.space = false; + if (c.isFlying) c.animation.setAnimationByPressed(); + } + break; + // 切换第一 / 第三人称视角 + case "toggleView": + if (pressed) c.cam.changeView(); + break; + // 切换飞行模式 + case "toggleFly": + if (pressed && c.controllerMode === 0) { + c.isFlying = !c.isFlying; + if (c.isFlying) c.playerVelocity.set(0, 0, 0); + c.animation.setAnimationByPressed(); + if (!c.isFlying && !c.playerIsOnGround) c.animation.startJump(true); + } + break; + // 上 / 下车 + case "toggleVehicle": + if (pressed) { + if (c.isFlying) return; + if (c.controllerMode === 0) c.vehicle.enter(); else c.vehicle.exit(); + } + break; + } + } + + // 获取最终移动轴:模拟输入优先,否则使用键盘八方向 + getMoveAxes() { + const hasAnalogInput = this.analogMoveX !== 0 || this.analogMoveY !== 0; + if (hasAnalogInput) return { x: this.analogMoveX, y: this.analogMoveY, isAnalog: true }; + return { + x: Number(this.keyRgt) - Number(this.keyLft), + y: Number(this.keyFwd) - Number(this.keyBkd), + isAnalog: false, + }; + } + + // 合并键盘与模拟输入,供动画和车辆等现有布尔逻辑使用 + private syncDirectionFlags() { + const threshold = 0.2; + this.fwd = this.keyFwd || this.analogMoveY > threshold; + this.bkd = this.keyBkd || this.analogMoveY < -threshold; + this.lft = this.keyLft || this.analogMoveX < -threshold; + this.rgt = this.keyRgt || this.analogMoveX > threshold; + } + + // 键盘按下处理 + private onKeydown(e: KeyboardEvent) { + const action = this.codeToAction.get(e.code); + if (action) this.applyAction(action, true); + } + + // 键盘抬起处理 + private onKeyup(e: KeyboardEvent) { + const action = this.codeToAction.get(e.code); + if (action) this.applyAction(action, false); + } + + // 鼠标移动处理 + private onMouseMove(e: MouseEvent) { + const lockTarget = this.ctrl.controls.domElement; + if (lockTarget && document.pointerLockElement === lockTarget) { + this.ctrl.cam.setToward(e.movementX, e.movementY, 0.0001); + } + } +} diff --git a/three-player-controller/src/systems/PlayerDebug.ts b/three-player-controller/src/systems/PlayerDebug.ts new file mode 100644 index 0000000..33e7877 --- /dev/null +++ b/three-player-controller/src/systems/PlayerDebug.ts @@ -0,0 +1,272 @@ +import * as THREE from "three"; + +import type { playerController } from "../PlayerController"; + +const HIT_COLOR = 0x66ff66; +const FAR_HIT_COLOR = 0xffcc33; +const MISS_COLOR = 0xff5555; +const SENSOR_IDLE_COLOR = 0x45d7ff; + +/** 绘制玩家胶囊与地面探测结果。 */ +export class PlayerDebug { + private visible = false; + private hasGroundProbe = false; + private groundProbeHasHit = false; + private groundProbeMaxDistance = 0; + private groundProbeNear = 0; + private groundRay: THREE.Line | null = null; + private groundHit: THREE.Mesh | null = null; + private groundProbePoint = new THREE.Vector3(); + private hasGroundSensor = false; + private groundSensorActive = false; + private groundSensorHasHit = false; + private groundSensorMaxDistance = 0; + private groundSensorRadius = 0; + private groundSensor: THREE.Mesh | null = null; + private readonly probeLocalPoint = new THREE.Vector3(); + private readonly capsuleLocalDown = new THREE.Vector3(0, -1, 0); + private debugRoot: THREE.Group | null = null; + + constructor(private readonly ctrl: playerController) { } + + /** 返回人物调试开关。 */ + isVisible(): boolean { + return this.visible; + } + + /** 切换人物胶囊与地面探测调试。 */ + setVisible(visible: boolean): void { + this.visible = visible; + this.syncVisibility(); + } + + /** 在玩家模型或控制模式变化后重新同步显隐。 */ + syncVisibility(): void { + const capsule = this.ctrl.playerCapsule; + if (capsule) { + const materials = Array.isArray(capsule.material) ? capsule.material : [capsule.material]; + for (const material of materials) material.visible = this.visible; + } + + const showProbe = this.visible && this.hasGroundProbe && this.ctrl.controllerMode === 0; + const showSensor = this.visible && this.hasGroundSensor && this.ctrl.controllerMode === 0; + if (this.groundRay) this.groundRay.visible = showProbe; + if (this.groundHit) this.groundHit.visible = showProbe && this.groundProbeHasHit; + if (this.groundSensor) this.groundSensor.visible = showSensor; + if (showProbe) this.renderGroundProbe(); + if (showSensor) this.renderGroundSensor(); + } + + /** + * 移动结束后刷新调试体姿态,使射线/体积传感区跟随胶囊实时位置。 + */ + refreshTransforms(): void { + if (!this.visible || this.ctrl.controllerMode !== 0) return; + if (this.hasGroundProbe) this.renderGroundProbe(); + if (this.hasGroundSensor) this.renderGroundSensor(); + } + + /** 写入移动系统本帧实际使用的中心地面探测结果。 */ + updateGroundProbe( + origin: THREE.Vector3, + hitPoint: THREE.Vector3 | null, + maxDistance: number, + near = 0, + ): void { + this.hasGroundProbe = true; + this.groundProbeHasHit = hitPoint !== null; + this.groundProbeMaxDistance = Math.max(0, maxDistance); + this.groundProbeNear = Math.max(0, near); + if (hitPoint) this.groundProbePoint.copy(hitPoint); + else this.groundProbePoint.copy(origin).y -= this.groundProbeMaxDistance; + } + + /** 写入胶囊下方体积地面传感区的当前状态。 */ + updateGroundSensor( + _start: THREE.Vector3, + _down: THREE.Vector3, + maxDistance: number, + radius: number, + active: boolean, + hasHit: boolean, + ): void { + this.hasGroundSensor = maxDistance > 0 && radius > 0; + this.groundSensorActive = active; + this.groundSensorHasHit = hasHit; + this.groundSensorMaxDistance = Math.max(0, maxDistance); + this.groundSensorRadius = Math.max(0, radius); + } + + /** 清除当前地面探测显示。 */ + clearGroundProbe(): void { + this.hasGroundProbe = false; + this.hasGroundSensor = false; + if (this.groundRay) this.groundRay.visible = false; + if (this.groundHit) this.groundHit.visible = false; + if (this.groundSensor) this.groundSensor.visible = false; + } + + /** 释放人物调试几何与材质。 */ + dispose(): void { + if (this.debugRoot) { + this.debugRoot.removeFromParent(); + this.debugRoot.traverse((obj) => { + const mesh = obj as THREE.Mesh; + mesh.geometry?.dispose(); + const mat = mesh.material; + if (mat) { + if (Array.isArray(mat)) mat.forEach(m => m.dispose()); + else mat.dispose(); + } + }); + this.debugRoot = null; + } + this.groundRay = null; + this.groundHit = null; + this.groundSensor = null; + this.hasGroundProbe = false; + this.hasGroundSensor = false; + } + + private ensureDebugRoot(): THREE.Group | null { + const capsule = this.ctrl.playerCapsule; + if (!capsule) return null; + + if (!this.debugRoot) { + this.debugRoot = new THREE.Group(); + this.debugRoot.name = "playerDebugRoot"; + capsule.add(this.debugRoot); + } else if (this.debugRoot.parent !== capsule) { + capsule.add(this.debugRoot); + } + return this.debugRoot; + } + + /** 按当前探测结果更新线段、命中点和颜色。 */ + private renderGroundProbe(): void { + const capsule = this.ctrl.playerCapsule; + const root = this.ensureDebugRoot(); + if (!capsule || !root) return; + + this.ensureGroundProbeObjects(root); + if (!this.groundRay || !this.groundHit) return; + + this.worldToCapsuleLocal(this.groundProbePoint, this.probeLocalPoint); + const positions = this.groundRay.geometry.getAttribute("position") as THREE.BufferAttribute; + positions.setXYZ(0, 0, 0, 0); + positions.setXYZ(1, this.probeLocalPoint.x, this.probeLocalPoint.y, this.probeLocalPoint.z); + positions.needsUpdate = true; + + const distance = capsule.position.y - this.groundProbePoint.y; + const hitInRange = this.groundProbeHasHit + && distance >= this.groundProbeNear + && distance <= this.groundProbeMaxDistance; + const color = hitInRange ? HIT_COLOR : this.groundProbeHasHit ? FAR_HIT_COLOR : MISS_COLOR; + (this.groundRay.material as THREE.LineBasicMaterial).color.setHex(color); + this.groundRay.visible = true; + + this.groundHit.visible = this.groundProbeHasHit; + if (this.groundProbeHasHit) { + this.groundHit.position.copy(this.probeLocalPoint); + const sy = Math.max(1e-6, capsule.scale.y || 1); + const localRadius = (capsule.capsuleInfo?.radius ?? 0) / sy; + this.groundHit.scale.setScalar(Math.max(1e-6, localRadius * 0.05)); + (this.groundHit.material as THREE.MeshBasicMaterial).color.setHex(color); + } + } + + /** 按当前状态更新体积地面传感区(锚定在胶囊 segment.end)。 */ + private renderGroundSensor(): void { + const capsule = this.ctrl.playerCapsule; + const root = this.ensureDebugRoot(); + const segEnd = capsule?.capsuleInfo?.segment.end; + if (!capsule || !root || !segEnd) return; + + this.ensureGroundSensorObject(root); + if (!this.groundSensor) return; + + const distance = Math.max(this.groundSensorMaxDistance, 1e-6); + this.groundSensor.position.copy(segEnd).addScaledVector(this.capsuleLocalDown, distance * 0.5); + this.groundSensor.quaternion.identity(); + this.groundSensor.scale.set(this.groundSensorRadius, distance, this.groundSensorRadius); + + const material = this.groundSensor.material as THREE.MeshBasicMaterial; + material.color.setHex( + this.groundSensorActive + ? this.groundSensorHasHit ? HIT_COLOR : MISS_COLOR + : SENSOR_IDLE_COLOR, + ); + material.opacity = this.groundSensorActive ? 0.75 : 0.4; + this.groundSensor.visible = this.hasGroundSensor; + } + + private worldToCapsuleLocal(world: THREE.Vector3, target: THREE.Vector3): THREE.Vector3 { + target.copy(world); + this.ctrl.playerCapsule.worldToLocal(target); + return target; + } + + /** 延迟创建地面射线和命中标记。 */ + private ensureGroundProbeObjects(root: THREE.Group): void { + if (!this.groundRay) { + this.groundRay = new THREE.Line( + new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), + new THREE.LineBasicMaterial({ + color: MISS_COLOR, + depthTest: true, + transparent: true, + opacity: 0.9, + }), + ); + this.groundRay.name = "playerGroundRayDebug"; + this.groundRay.renderOrder = 30; + this.groundRay.frustumCulled = false; + root.add(this.groundRay); + } else if (this.groundRay.parent !== root) { + root.add(this.groundRay); + } + + if (!this.groundHit) { + this.groundHit = new THREE.Mesh( + new THREE.SphereGeometry(2, 10, 8), + new THREE.MeshBasicMaterial({ + color: HIT_COLOR, + wireframe: true, + depthTest: true, + transparent: true, + opacity: 0.9, + }), + ); + this.groundHit.name = "playerGroundRayHitDebug"; + this.groundHit.renderOrder = 31; + this.groundHit.frustumCulled = false; + root.add(this.groundHit); + } else if (this.groundHit.parent !== root) { + root.add(this.groundHit); + } + } + + /** 延迟创建体积地面传感区线框。 */ + private ensureGroundSensorObject(root: THREE.Group): void { + if (this.groundSensor) { + if (this.groundSensor.parent !== root) root.add(this.groundSensor); + return; + } + this.groundSensor = new THREE.Mesh( + new THREE.CylinderGeometry(1, 1, 1, 16, 1, true), + new THREE.MeshBasicMaterial({ + color: SENSOR_IDLE_COLOR, + wireframe: true, + depthTest: true, + depthWrite: true, + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide, + }), + ); + this.groundSensor.name = "playerGroundVolumeDebug"; + this.groundSensor.renderOrder = 29; + this.groundSensor.frustumCulled = false; + root.add(this.groundSensor); + } +} diff --git a/three-player-controller/src/systems/VehicleSystem.ts b/three-player-controller/src/systems/VehicleSystem.ts new file mode 100644 index 0000000..714171a --- /dev/null +++ b/three-player-controller/src/systems/VehicleSystem.ts @@ -0,0 +1,833 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import { loadVehicleModel as loadVehicleModelUtil } from "../utils/vehicleLoader"; +import { DEFAULT_MAX_SUSPENSION_TRAVEL, DEFAULT_WHEEL_PHYSICS } from "../utils/vehicleController"; +import { applyCapsuleCollision, createCollisionTemps } from "../utils/capsuleCollision"; +import type { VehicleInstance, VehicleOptions, KinematicColliderEntry, VehicleWheelColliderUserData } from "../types"; +import { CollisionGroup } from "../collision/groups"; +import { createObbObbTemps, resolveObbObb } from "../collision/obbObb"; + +export class VehicleSystem { + private ctrl: playerController; // 主控制器引用 + + list: VehicleInstance[] = []; // 车辆实例列表 + active: VehicleInstance | null = null; // 当前乘坐车辆 + meshSkipIds: number[] = []; // 全部车辆外观 collider id,网格查询时跳过 + vehicleLength = 6; // 车辆模型归一化后的最大边长度 + params = { + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, // 调试显示 + chassis: { density: 1, linearDamping: 0.05, angularDamping: 0.5 }, // 车身参数 + model: { rotation: -Math.PI / 2 }, // 模型旋转 + power: { acceleration: 5, deceleration: 5, maxSpeed: 100 }, // 动力参数 + steering: { maxSteerAngle: Math.PI / 5, steerTime: 0.45, steerReturnTimeSlow: 0.55, steerReturnTimeFast: 0.4, highSpeedSteerScale: 0.3 }, // 转向:打满/回正时间(秒),高速收舵 + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, // 抓地预算 + suspension: { + maxTravel: DEFAULT_MAX_SUSPENSION_TRAVEL, + stiffness: DEFAULT_WHEEL_PHYSICS.suspensionStiffness, + compression: DEFAULT_WHEEL_PHYSICS.suspensionCompression, + relaxation: DEFAULT_WHEEL_PHYSICS.suspensionRelaxation, + maxForce: DEFAULT_WHEEL_PHYSICS.maxSuspensionForce, + frictionSlip: DEFAULT_WHEEL_PHYSICS.frictionSlip, + sideFrictionStiffness: DEFAULT_WHEEL_PHYSICS.sideFrictionStiffness, + rollInfluence: DEFAULT_WHEEL_PHYSICS.rollInfluence, + }, + followVehicleDirection: true, // 相机跟随方向 + }; + + boardingPadding = 0.25; // 上车/下车余量基准值(按车辆 scale 缩放) + parkingCreepThreshold = 0.05; // 驻车状态下清除低速蠕动的水平速度阈值 + private handbrakeBlend = 0; // 1 为手刹全开,松键后插值回 0 + + private scratchLocal = new THREE.Vector3(); + private scratchWorld = new THREE.Vector3(); + private scratchForward = new THREE.Vector3(); + private scratchUp = new THREE.Vector3(); + private scratchQuat = new THREE.Quaternion(); + private scratchDown = new THREE.Vector3(0, -1, 0); + private wheelSphereDir = new THREE.Vector3(); + private carryPrevInv = new THREE.Matrix4(); + private carryYaw = new THREE.Quaternion(); + private carryUp = new THREE.Vector3(0, 1, 0); + private raycaster = new THREE.Raycaster(); + private exitCheckTemps = createCollisionTemps(); + private obbTemps = createObbObbTemps(); + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + (this.raycaster as any).firstHitOnly = true; + } + + /** 切换车辆底盘物理盒。 */ + setPhysicsDebugVisible(visible: boolean): void { + this.params.debug.showPhysicsBox = visible; + this.syncDebugVisibility(); + } + + /** 同步全部车辆调试对象的显隐。 */ + syncDebugVisibility(): void { + for (const v of this.list) { + if (v.physicsBoxMesh) { + if (this.params.debug.showPhysicsBox) { + if (!v.vehicleGroup.children.includes(v.physicsBoxMesh)) v.vehicleGroup.add(v.physicsBoxMesh); + } else { + v.vehicleGroup.remove(v.physicsBoxMesh); + } + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel; + if (v.wheelSphereDebug) { + v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres; + if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v); + } + } + } + + /** 由车身水平包围圆和人物胶囊半径推算上车范围。 */ + boardingRadius(v: VehicleInstance): number { + return Math.hypot(v.halfExtents.x, v.halfExtents.z) + + this.capsuleRadius() + + this.boardingPadding * v.scale; + } + + /** 返回人物到车辆中心的水平距离,高度超出车辆邻域时返回 Infinity。 */ + boardingDistance(v: VehicleInstance, position: THREE.Vector3): number { + v.vehicleGroup.updateMatrixWorld(true); + this.scratchLocal.copy(position); + v.vehicleGroup.worldToLocal(this.scratchLocal); + const verticalLimit = v.halfExtents.y + this.capsuleHeight(); + if (Math.abs(this.scratchLocal.y) > verticalLimit) return Infinity; + return Math.hypot(this.scratchLocal.x, this.scratchLocal.z); + } + + /** 是否处于车辆中心的上车范围内。 */ + isInBoardingRange(v: VehicleInstance, position: THREE.Vector3): boolean { + return this.boardingDistance(v, position) <= this.boardingRadius(v); + } + + // 加载车辆模型 + async load(opts: VehicleOptions): Promise { + try { + const instance = await loadVehicleModelUtil(opts, { + loader: this.ctrl.loader, + scene: this.ctrl.scene, + vehicleParams: this.params, + vehicleLength: this.vehicleLength, + }); + + this.list.push(instance); + const kin = this.ctrl.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: instance.vehicleGroup }, + follow: instance.vehicleGroup, + }); + instance.meshColliderId = kin.id; + const chassis = this.ctrl.addCollider({ + motion: "dynamic", + shape: { kind: "box", halfExtents: instance.halfExtents.clone() }, + groups: CollisionGroup.VEHICLE, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS, + userData: instance, + simulate: false, // 默认会建 DynamicBoxBody;底盘改由 VehicleRigidBody 推进,此处只登记 + }); + instance.chassisColliderId = chassis.id; + instance.wheelColliderIds = this.registerWheelSpheres(instance); + this.attachWheelSphereDebug(instance); + return instance; + } catch (e) { + console.error("加载车辆模型失败:", e); + return undefined; + } + } + + // 触发上车流程 + enter() { + if (!this.list.length || this.ctrl.controllerMode === 1) return; + + // 查找最近可上车的车辆 + let nearest: VehicleInstance | null = null; + let nearestDist = Infinity; + const pos = this.ctrl.playerCapsule.position; + for (const v of this.list) { + const dist = this.boardingDistance(v, pos); + if (dist <= this.boardingRadius(v) && dist < nearestDist) { + nearestDist = dist; + nearest = v; + } + } + + if (!nearest) return; + // 车辆移动中不允许上车 + const vel = nearest.chassisBody.linvel(); + if (Math.hypot(vel.x, vel.z) > 0.1) return; + this.releaseParkingBrake(nearest); + this.active = nearest; + const c = this.ctrl; + c.controllerMode = 1; + c.playerVelocity.set(0, 0, 0); + c.activeDynamicBody = null; + c.mobileControls?.syncControllerModeBtn(1); + c.cam.setOverShoulder(false); + c.animation.playByName("driving"); + c.syncMountedPlayer(nearest); + c.syncDebugVisibility(); + c.onVehicleEnter?.(nearest); + } + + // 触发下车流程 + exit() { + const c = this.ctrl; + const v = this.active; + if (!v) return; + + this.applyParkingBrake(v, c.getCurrentDelta() || 1 / 60); + this.findExitPosition(v, this.scratchWorld); + this.getDriverForward(v, this.scratchForward); + c.controllerMode = 0; + c.mobileControls?.syncControllerModeBtn(0); + c.cam.setOverShoulder(c.enableOverShoulderView); + c.leaveVehicleAt(this.scratchWorld, this.scratchForward); + c.animation.playByName("idle"); + c.syncDebugVisibility(); + this.handbrakeBlend = 0; + c.onVehicleExit?.(v); + } + + // 物理步进前更新车辆控制器 + preparePhysics(delta: number) { + if (this.ctrl.controllerMode === 1 && this.active) this.applyDriving(delta, this.active); + } + + // 物理步进后同步车辆视觉 + finishPhysics(delta: number) { + for (const v of this.list) { + const parked = this.ctrl.controllerMode !== 1 || this.active !== v; + if (parked) this.applyParkingBrake(v, delta); + v.vehicleController.updateVehicle(delta, this.ctrl.getVehicleGroundMeshes(v)); + this.applyKinematicCarry(v); + } + this.resolveVehiclePairs(); + for (const v of this.list) { + const parked = this.ctrl.controllerMode !== 1 || this.active !== v; + const vel = v.chassisBody.linvel(); + const speed = Math.hypot(vel.x, vel.z); + const max = v.maxSpeed / 3.6; + if (speed > max) { + const s = max / speed; + v.chassisBody.setLinvel({ x: vel.x * s, y: vel.y, z: vel.z * s }); + } else if (parked && speed > 0 && speed <= this.parkingCreepThreshold) { + let hasWheelContact = false; + for (let i = 0; i < v.vehicleController.numWheels(); i++) { + if (v.vehicleController.wheelIsInContact(i)) { hasWheelContact = true; break; } + } + if (hasWheelContact) v.chassisBody.setLinvel({ x: 0, y: vel.y, z: 0 }); + } + this.syncVehicleVisual(v, delta); + } + if (this.ctrl.controllerMode === 1 && this.active) this.ctrl.syncMountedPlayer(this.active); + } + + /** 两车底盘盒对盒:跳过自身,驻车中的车仍可被撞开。 */ + private resolveVehiclePairs() { + const n = this.list.length; + if (n < 2) return; + for (let iter = 0; iter < 2; iter++) { + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + resolveObbObb(this.list[i].chassisBody, this.list[j].chassisBody, this.obbTemps); + } + } + } + } + + // 更新车辆驾驶 + private applyDriving(delta: number, v: VehicleInstance) { + const c = this.ctrl; + const { vehicleController, chassisBody } = v; + + // 坡度补偿 + const rotation = chassisBody.rotation(); + this.scratchQuat.set(rotation.x, rotation.y, rotation.z, rotation.w); + const forward = this.scratchForward.copy(v.forwardLocal).applyQuaternion(this.scratchQuat); + const throttle = Number(c.input.fwd) - Number(c.input.bkd); + const sinTheta = Math.max(-1, Math.min(1, forward.y)); + const gEff = 9.81 * v.scale; + const extraAccel = (throttle * sinTheta > 0.05) ? Math.abs(sinTheta) * gEff : 0; + const wheelCount = Math.max(1, vehicleController.numWheels()); + const mass = chassisBody.mass(); + const linv = chassisBody.linvel(); + const speed01 = Math.min(1, Math.hypot(linv.x, linv.z) / Math.max(0.01, v.maxSpeed / 3.6)); + + // 转向:参数为走完满舵行程的时间(秒);车速越高可用转角越小 + const { maxSteerAngle, steerTime, steerReturnTimeSlow, steerReturnTimeFast, highSpeedSteerScale = 0.3 } = v.steering; + const currentSteering = vehicleController.wheelSteering(0) || 0; + const steerDir = Number(c.input.lft) - Number(c.input.rgt); + const speedSteer = 1 - (1 - highSpeedSteerScale) * speed01 * speed01; + const steerLimit = maxSteerAngle * speedSteer; + const targetSteering = steerLimit * steerDir; + const responseTime = steerDir === 0 + ? steerReturnTimeSlow + (steerReturnTimeFast - steerReturnTimeSlow) * speed01 + : steerTime; + const maxStep = Math.max(steerLimit, 1e-4) / Math.max(1e-4, responseTime) * delta; + const steeringDelta = targetSteering - currentSteering; + const steering = currentSteering + Math.sign(steeringDelta) * Math.min(Math.abs(steeringDelta), maxStep); + vehicleController.setWheelSteering(0, steering); + vehicleController.setWheelSteering(1, steering); + + // 抓地预算 + const { maxG, sideFrictionIdle, sideFrictionFrontMin, sideFrictionRearMin, handbrakeRearFriction, handbrakeRearDriveScale, handbrakeReleaseTime, wheelbaseRatio } = v.grip; + const vFwd = linv.x * forward.x + linv.y * forward.y + linv.z * forward.z; + const wheelbase = Math.max(0.01, v.size.l * wheelbaseRatio); + const latA = vFwd * vFwd * Math.tan(Math.min(1.5, Math.abs(steering))) / wheelbase; + const latG = Math.min(maxG, latA / gEff); + const engineForce = throttle * mass * (v.acceleration + extraAccel) / wheelCount; + + // Shift 漂移:降后轮侧向摩擦甩尾,保留部分后驱维持车速,不额外后轮制动 + if (c.input.shift) this.handbrakeBlend = 1; + else if (this.handbrakeBlend > 0) { + this.handbrakeBlend = Math.max(0, this.handbrakeBlend - delta / Math.max(1e-4, handbrakeReleaseTime)); + } + const rearDriveScale = 1 - this.handbrakeBlend * (1 - handbrakeRearDriveScale); + for (let i = 0; i < wheelCount; i++) { + vehicleController.setWheelEngineForce(i, i >= 2 ? engineForce * rearDriveScale : engineForce); + } + + const spaceBrake = Number(c.input.space) * mass * v.deceleration / wheelCount * delta; + for (let i = 0; i < wheelCount; i++) { + vehicleController.setWheelBrake(i, spaceBrake); + } + + const gripUsed = latG / maxG; + const frontFriction = sideFrictionIdle + (sideFrictionFrontMin - sideFrictionIdle) * gripUsed; + const gripRearFriction = sideFrictionIdle + (sideFrictionRearMin - sideFrictionIdle) * gripUsed; + const rearFriction = gripRearFriction + (handbrakeRearFriction - gripRearFriction) * this.handbrakeBlend; + vehicleController.setWheelSideFrictionStiffness(0, frontFriction); + vehicleController.setWheelSideFrictionStiffness(1, frontFriction); + vehicleController.setWheelSideFrictionStiffness(2, rearFriction); + vehicleController.setWheelSideFrictionStiffness(3, rearFriction); + } + + // 翻车复位 + resetUpright() { + const v = this.active; + if (!v || this.ctrl.controllerMode !== 1) return; + const { chassisBody } = v; + const t = chassisBody.translation(); + chassisBody.setTranslation({ x: t.x, y: t.y + v.size.h, z: t.z }); + chassisBody.setRotation({ x: 0, y: 0, z: 0, w: 1 }); + chassisBody.setLinvel({ x: 0, y: 0, z: 0 }); + chassisBody.setAngvel({ x: 0, y: 0, z: 0 }); + this.handbrakeBlend = 0; + } + + // 计算车身外侧的可用下车位置 + private findExitPosition(v: VehicleInstance, out: THREE.Vector3): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + this.scratchLocal.copy(v.driverSeatPosition).multiplyScalar(v.scale); + const seatX = Math.max(-v.halfExtents.x, Math.min(v.halfExtents.x, this.scratchLocal.x)); + const side = this.scratchLocal.z >= 0 ? 1 : -1; + const clearance = this.capsuleRadius() + this.boardingPadding * v.scale; + const sideOffset = v.halfExtents.z + clearance; + const endOffset = v.halfExtents.x + clearance; + const candidates = [ + { x: seatX, z: side * sideOffset }, + { x: seatX, z: -side * sideOffset }, + { x: endOffset, z: 0 }, + { x: -endOffset, z: 0 }, + ]; + let groundedFallback: THREE.Vector3 | null = null; + const maxDist = v.size.h + this.capsuleHeight() * 3 + 2; + this.raycaster.far = maxDist; + + for (const candidate of candidates) { + this.scratchLocal.set(candidate.x, v.halfExtents.y + this.capsuleHeight() + 0.5, candidate.z); + v.vehicleGroup.localToWorld(this.scratchLocal); + out.copy(this.scratchLocal); + this.raycaster.set(out, this.scratchDown); + let bestHit: THREE.Intersection | undefined; + for (const mesh of this.ctrl.getColliderMeshes()) { + const hits = this.raycaster.intersectObject(mesh, false); + if (hits.length && (!bestHit || hits[0].distance < bestHit.distance)) { + bestHit = hits[0]; + } + } + if (!bestHit) continue; + out.copy(bestHit.point); + out.y += this.ctrl.getCapsuleGroundHeight(); + groundedFallback ??= out.clone(); + if (this.isCharacterPositionFree(out, v)) return out; + } + + if (groundedFallback) return out.copy(groundedFallback); + this.scratchLocal.set(seatX, -v.halfExtents.y + this.ctrl.getCapsuleGroundHeight(), side * sideOffset); + v.vehicleGroup.localToWorld(this.scratchLocal); + return out.copy(this.scratchLocal); + } + + /** 检测下车点处胶囊是否与静态/其他运动学碰撞体重叠。 */ + private isCharacterPositionFree(position: THREE.Vector3, v: VehicleInstance): boolean { + const cap = this.ctrl.playerCapsule; + const info = cap.capsuleInfo; + if (!info || !this.ctrl.getColliderMeshes().length) return true; + + const origParent = cap.parent; + const origPos = cap.position.clone(); + const origQuat = cap.quaternion.clone(); + const skipMesh = this.ctrl.getKinematicColliderEntries().find(e => e.source === v.vehicleGroup)?.mesh; + this.ctrl.scene.attach(cap); + cap.position.copy(position); + cap.updateMatrixWorld(true); + const before = position.clone(); + for (const mesh of this.ctrl.getColliderMeshes()) { + if (mesh === skipMesh) continue; + cap.updateMatrixWorld(true); + applyCapsuleCollision(cap, info, mesh, this.exitCheckTemps); + } + const free = cap.position.distanceTo(before) < info.radius * 0.5; + origParent?.attach(cap); + cap.position.copy(origPos); + cap.quaternion.copy(origQuat); + cap.updateMatrixWorld(true); + return free; + } + + /** 有轮子打在运动学网格上时,按平台 prev→current 带走车身。 */ + private applyKinematicCarry(v: VehicleInstance) { + const votes = new Map(); + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const mesh = v.vehicleController.wheelContactMesh(i); + if (!mesh) continue; + const entry = this.ctrl.getKinematicColliderEntries().find(e => e.mesh === mesh); + if (!entry) continue; + votes.set(entry, (votes.get(entry) ?? 0) + 1); + } + + let best: KinematicColliderEntry | null = null; + let bestCount = 0; + for (const [entry, count] of votes) { + if (count > bestCount) { + best = entry; + bestCount = count; + } + } + if (!best) return; + + this.carryPrevInv.copy(best.prevWorldMatrix).invert(); + this.scratchWorld.copy(v.chassisBody.position).applyMatrix4(this.carryPrevInv); + v.chassisBody.position.copy(this.scratchWorld).applyMatrix4(best.source.matrixWorld); + if (best.deltaRotY !== 0) { + this.carryYaw.setFromAxisAngle(this.carryUp, best.deltaRotY); + v.chassisBody.quaternion.premultiply(this.carryYaw); + } + } + + // 同步单辆车的模型、车轮和调试盒 + private syncVehicleVisual(v: VehicleInstance, delta = 1 / 60) { + const t = v.chassisBody.translation(); + const r = v.chassisBody.rotation(); + v.vehicleGroup.position.set(t.x, t.y, t.z); + v.vehicleGroup.quaternion.set(r.x, r.y, r.z, r.w); + if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel; + if (v.wheelSphereDebug) { + v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres; + if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v); + } + v.updateWheelVisuals?.(delta); + } + + /** 取车辆世界前向。 */ + getForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + return out.copy(v.forwardLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** 取驾驶位世界前向。 */ + getDriverForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + this.scratchQuat.setFromAxisAngle(this.scratchUp.set(0, 1, 0), v.driverSeatRotation); + this.scratchLocal.set(0, 0, -1).applyQuaternion(this.scratchQuat); + v.vehicleGroup.updateMatrixWorld(true); + return out.copy(this.scratchLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** 取车辆世界上向。 */ + getUp(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + return out.set(0, 1, 0).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** + * 为每只车轮登记只碰 DEBRIS 的运动学球。 + * 球心每帧由底盘位姿 + 悬挂长度推算,不在此写位置。 + */ + private registerWheelSpheres(v: VehicleInstance): number[] { + const ids: number[] = []; + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + const userData: VehicleWheelColliderUserData = { vehicle: v, wheelIndex: i }; + const handle = this.ctrl.addCollider({ + motion: "kinematic", + shape: { kind: "sphere", radius: Math.max(1e-4, wheel.radius) }, + groups: CollisionGroup.VEHICLE, + mask: CollisionGroup.DEBRIS, + userData, + }); + ids.push(handle.id); + } + return ids; + } + + /** 创建车轮碰撞球线框,挂在 vehicleGroup 下(底盘局部坐标)。 */ + private attachWheelSphereDebug(v: VehicleInstance): void { + const group = new THREE.Group(); + group.name = "wheelSphereDebug"; + group.userData.excludeFromCollider = true; + const geo = new THREE.SphereGeometry(1, 16, 12); + const mat = new THREE.MeshBasicMaterial({ + color: 0x00e8a0, + wireframe: true, + transparent: true, + opacity: 0.45, + }); + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + const mesh = new THREE.Mesh(geo, mat); + mesh.name = `wheelSphereDebug_${i}`; + mesh.frustumCulled = false; + mesh.renderOrder = 22; + mesh.scale.setScalar(Math.max(1e-4, wheel.radius)); + group.add(mesh); + } + group.visible = this.params.debug.showWheelSpheres; + v.vehicleGroup.add(group); + v.wheelSphereDebug = group; + this.syncWheelSphereDebug(v); + } + + /** 按悬挂长度把轮球线框对齐到轮心(底盘局部)。 */ + private syncWheelSphereDebug(v: VehicleInstance): void { + const group = v.wheelSphereDebug; + if (!group) return; + let meshIndex = 0; + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + const mesh = group.children[meshIndex] as THREE.Mesh | undefined; + if (!wheel || !mesh) continue; + meshIndex++; + this.wheelSphereDir.copy(wheel.direction); + const dirLen = this.wheelSphereDir.length(); + if (dirLen > 1e-8) this.wheelSphereDir.multiplyScalar(1 / dirLen); + else this.wheelSphereDir.set(0, -1, 0); + mesh.position.copy(wheel.connectionPoint).addScaledVector(this.wheelSphereDir, wheel.suspensionLength); + mesh.scale.setScalar(Math.max(1e-4, wheel.radius)); + } + } + + /** 移除车轮碰撞球调试线框并释放几何 / 材质。 */ + private disposeWheelSphereDebug(v: VehicleInstance): void { + const group = v.wheelSphereDebug; + if (!group) return; + group.removeFromParent(); + const geos = new Set(); + const mats = new Set(); + for (const child of group.children) { + const mesh = child as THREE.Mesh; + if (mesh.geometry) geos.add(mesh.geometry); + const mat = mesh.material; + if (Array.isArray(mat)) for (const m of mat) mats.add(m); + else if (mat) mats.add(mat); + } + for (const g of geos) g.dispose(); + for (const m of mats) m.dispose(); + v.wheelSphereDebug = undefined; + } + + // 销毁全部车辆 + destroy() { + for (const v of this.list) { + if (v.meshColliderId != null) this.ctrl.removeCollider(v.meshColliderId); + if (v.chassisColliderId != null) this.ctrl.removeCollider(v.chassisColliderId); + for (const id of v.wheelColliderIds ?? []) this.ctrl.removeCollider(id); + this.disposeWheelSphereDebug(v); + this.ctrl.scene.remove(v.vehicleGroup); + v.destroyVehicleController?.(); + } + this.list = []; + this.active = null; + } + + // 清除活动车辆的驱动力 + stopActive() { + if (this.active) { + this.applyParkingBrake(this.active, this.ctrl.getCurrentDelta() || 1 / 60); + } + } + + // 对无人车辆施加四轮驻车制动 + private applyParkingBrake(v: VehicleInstance, delta: number) { + const wheelCount = Math.max(1, v.vehicleController.numWheels()); + const brake = v.chassisBody.mass() * v.deceleration / wheelCount * delta; + for (let i = 0; i < wheelCount; i++) { + v.vehicleController.setWheelEngineForce(i, 0); + v.vehicleController.setWheelBrake(i, brake); + v.vehicleController.setWheelSideFrictionStiffness(i, v.sideFrictionStiffness); + } + } + + /** + * 运行时修改底盘盒底相对轮胎触地点的高度。 + * 重定底盘中心的同时反向平移车模和车轮,使车辆外观及轮胎触地点保持原位。 + */ + setClearance(v: VehicleInstance, clearance: number): void { + if (!v || !Number.isFinite(clearance) || v.scale <= 0) return; + + const sy = Math.max(1e-3, v.chassisSizeScaleY); + const minHeight = 1e-3; + const maxClearance = Math.max( + 0, + v.chassisTopClearance - minHeight / (v.scale * sy), + ); + const nextClearance = THREE.MathUtils.clamp(clearance, 0, maxClearance); + const previousClearance = v.chassisClearance; + if (Math.abs(nextClearance - previousClearance) < 1e-8) return; + + const oldHalfY = v.halfExtents.y; + const newHalfY = Math.max( + minHeight * 0.5, + (v.chassisTopClearance - nextClearance) * v.scale * sy * 0.5, + ); + // 底盘中心相对轮胎触地点的位置 = clearance + 碰撞盒半高。 + const centerShift = (nextClearance - previousClearance) * v.scale + + (newHalfY - oldHalfY); + + const nextHalfExtents = v.halfExtents.clone(); + nextHalfExtents.y = newHalfY; + v.chassisClearance = nextClearance; + this.applyChassisShape(v, nextHalfExtents, centerShift); + } + + /** 运行时修改底盘碰撞盒三轴尺寸比例;Y 轴仍以盒底为缩放锚点。 */ + setChassisSizeScale( + v: VehicleInstance, + sizeScale: { x?: number; y?: number; z?: number }, + ): void { + if (!v || v.scale <= 0) return; + + const oldSx = Math.max(1e-3, v.chassisSizeScaleX); + const oldSy = Math.max(1e-3, v.chassisSizeScaleY); + const oldSz = Math.max(1e-3, v.chassisSizeScaleZ); + const sx = Math.max(1e-3, sizeScale.x ?? oldSx); + const sy = Math.max(1e-3, sizeScale.y ?? oldSy); + const sz = Math.max(1e-3, sizeScale.z ?? oldSz); + if ( + Math.abs(sx - oldSx) < 1e-8 + && Math.abs(sy - oldSy) < 1e-8 + && Math.abs(sz - oldSz) < 1e-8 + ) return; + + const nextHalfExtents = new THREE.Vector3( + v.halfExtents.x / oldSx * sx, + Math.max( + 5e-4, + (v.chassisTopClearance - v.chassisClearance) * v.scale * sy * 0.5, + ), + v.halfExtents.z / oldSz * sz, + ); + const centerShift = nextHalfExtents.y - v.halfExtents.y; + v.chassisSizeScaleX = sx; + v.chassisSizeScaleY = sy; + v.chassisSizeScaleZ = sz; + this.applyChassisShape(v, nextHalfExtents, centerShift); + } + + /** 同步底盘尺寸、车轮硬点、视觉原点和两套碰撞表示。 */ + private applyChassisShape( + v: VehicleInstance, + nextHalfExtents: THREE.Vector3, + centerShift: number, + ): void { + const shiftsCenter = Math.abs(centerShift) >= 1e-8; + + if (shiftsCenter) { + for (const child of v.vehicleGroup.children) { + if ( + child === v.physicsBoxMesh + || child === v.wheelRayDebug + || child === v.wheelTravelDebug + || child === v.wheelSphereDebug + || child === this.ctrl.playerCapsule + ) continue; + child.position.y -= centerShift; + } + + const wheelCount = v.vehicleController.numWheels(); + for (let i = 0; i < wheelCount; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + this.scratchLocal.copy(wheel.connectionPoint); + this.scratchLocal.y -= centerShift; + v.vehicleController.setWheelChassisConnectionPointCs(i, this.scratchLocal); + } + + v.driverSeatPosition.y -= centerShift / v.scale; + + // 移动底盘中心以抵消本地内容位移,车身与轮胎在世界空间保持不动。 + this.scratchUp.set(0, centerShift, 0).applyQuaternion(v.chassisBody.quaternion); + v.chassisBody.position.add(this.scratchUp); + v.vehicleGroup.position.copy(v.chassisBody.position); + v.vehicleGroup.quaternion.copy(v.chassisBody.quaternion); + v.vehicleGroup.updateMatrixWorld(true); + + this.ctrl.translateKinematicColliderContent( + v.vehicleGroup, + this.scratchLocal.set(0, -centerShift, 0), + ); + } + + v.halfExtents.copy(nextHalfExtents); + v.chassisBody.setHalfExtents(v.halfExtents); + + const chassisCol = v.chassisColliderId != null + ? this.ctrl.collisionWorld.get(v.chassisColliderId) + : undefined; + if (chassisCol?.shape.kind === "box") { + chassisCol.shape.halfExtents.copy(v.halfExtents); + } + + if (v.physicsBoxMesh) { + v.physicsBoxMesh.geometry.dispose(); + v.physicsBoxMesh.geometry = new THREE.BoxGeometry( + v.halfExtents.x * 2, + v.halfExtents.y * 2, + v.halfExtents.z * 2, + ); + v.physicsBoxMesh.scale.set(1, 1, 1); + } + if (v.wheelSphereDebug) this.syncWheelSphereDebug(v); + if (this.active === v && this.ctrl.controllerMode === 1) { + this.ctrl.syncMountedPlayer(v); + } + } + + // 解除四轮驻车制动 + private releaseParkingBrake(v: VehicleInstance) { + for (let i = 0; i < v.vehicleController.numWheels(); i++) v.vehicleController.setWheelBrake(i, 0); + } + + /** + * 运行时等比缩放车辆。 + * 保持 vehicleGroup.scale = 1,只缩放子节点与物理尺寸,避免座椅/下车点与 halfExtents 双重缩放。 + * 悬挂刚度、阻尼、摩擦滑移不随 scale 变。 + */ + setScale(v: VehicleInstance, newScale: number): void { + if (!v || newScale <= 0) return; + const prev = v.scale > 0 ? v.scale : 1; + const ratio = newScale / prev; + if (Math.abs(ratio - 1) < 1e-12) { + v.scale = newScale; + return; + } + + const { chassisBody, vehicleController } = v; + const n = vehicleController.numWheels(); + + // 缩放前用局部轮底估算,缩放后抬/降底盘使胎底高度基本不变 + let localBottom = Infinity; + for (let i = 0; i < n; i++) { + const wheel = vehicleController.wheelAt(i); + if (!wheel) continue; + localBottom = Math.min(localBottom, wheel.connectionPoint.y - wheel.radius); + } + if (!Number.isFinite(localBottom)) localBottom = -v.halfExtents.y; + + for (const child of v.vehicleGroup.children) { + if (child === v.wheelRayDebug || child === v.wheelTravelDebug || child === v.wheelSphereDebug) continue; + child.position.multiplyScalar(ratio); + child.scale.multiplyScalar(ratio); + } + + v.halfExtents.multiplyScalar(ratio); + v.size.l *= ratio; + v.size.w *= ratio; + v.size.h *= ratio; + v.maxSpeed *= ratio; + v.acceleration *= ratio; + v.deceleration *= ratio; + v.scale = newScale; + + chassisBody.setHalfExtents(v.halfExtents); + chassisBody.setMass(chassisBody.mass() * ratio * ratio * ratio); + chassisBody.gravityScale = newScale; + chassisBody.linearVelocity.multiplyScalar(ratio); + + for (let i = 0; i < n; i++) { + const wheel = vehicleController.wheelAt(i); + if (!wheel) continue; + vehicleController.setWheelChassisConnectionPointCs(i, wheel.connectionPoint.multiplyScalar(ratio)); + vehicleController.setWheelRadius(i, wheel.radius * ratio); + vehicleController.setWheelSuspensionRestLength(i, wheel.restLength * ratio); + vehicleController.setWheelMaxSuspensionTravel(i, wheel.maxSuspensionTravel * ratio); + wheel.suspensionLength *= ratio; + wheel.visualLength *= ratio; + } + + const chassisCol = v.chassisColliderId != null + ? this.ctrl.collisionWorld.get(v.chassisColliderId) + : undefined; + if (chassisCol?.shape.kind === "box") { + chassisCol.shape.halfExtents.copy(v.halfExtents); + } + + for (const id of v.wheelColliderIds ?? []) { + const col = this.ctrl.collisionWorld.get(id); + if (col?.shape.kind !== "sphere") continue; + const data = col.userData as VehicleWheelColliderUserData | undefined; + const wheel = data != null ? v.vehicleController.wheelAt(data.wheelIndex) : undefined; + if (wheel) col.shape.radius = Math.max(1e-4, wheel.radius); + } + + if (v.wheelSphereDebug) this.syncWheelSphereDebug(v); + + // 外观 mesh 碰撞在加载时烘焙,用 contentScale 对齐子节点缩放 + this.ctrl.scaleKinematicColliderContent(v.vehicleGroup, ratio); + + chassisBody.position.y += localBottom * (1 - ratio); + v.vehicleGroup.position.copy(chassisBody.position); + v.vehicleGroup.quaternion.copy(chassisBody.quaternion); + v.vehicleGroup.updateMatrixWorld(true); + + if (this.active === v && this.ctrl.controllerMode === 1) { + this.ctrl.syncMountedPlayer(v); + } + } + + /** 将列表中全部车辆缩放到同一绝对 scale。 */ + setScaleAll(newScale: number): void { + for (const v of this.list) this.setScale(v, newScale); + } + + private capsuleRadius(): number { + return this.ctrl.playerCapsule?.capsuleInfo?.radius ?? 0; + } + + private capsuleHeight(): number { + const info = this.ctrl.playerCapsule?.capsuleInfo; + if (!info) return 0; + const sy = this.ctrl.playerCapsule.scale.y || 1; + return (-info.segment.end.y) * sy + 2 * info.radius; + } +} diff --git a/three-player-controller/src/types/index.ts b/three-player-controller/src/types/index.ts new file mode 100644 index 0000000..0473e24 --- /dev/null +++ b/three-player-controller/src/types/index.ts @@ -0,0 +1,455 @@ +import * as THREE from "three"; +import type { BVHVehicleController } from "../utils/vehiclePhysics/BVHVehicleController"; +import type { VehicleRigidBody } from "../utils/vehiclePhysics/VehicleRigidBody"; + +// ==================== 玩家配置 ==================== + +/** 已加载的模型来源(玩家使用)。 */ +export type LoadedModelSource = { + /** 已加载的模型根节点。 */ + model: THREE.Object3D; + /** 模型使用的动画片段。 */ + animations: THREE.AnimationClip[]; + /** 已加载模型与旧模型路径不能同时使用。 */ + url?: never; +}; + +/** 旧版 glTF 模型路径来源(玩家使用)。 */ +export type LegacyModelSource = { + /** + * 模型路径(GLB/GLTF)。 + * @deprecated 请在外部加载模型,并传入 model 和 animations。 + */ + url: string; + /** 模型路径与已加载模型不能同时使用。 */ + model?: never; + /** 模型路径与外部动画片段不能同时使用。 */ + animations?: never; +}; + +/** 已加载的玩家模型来源。 */ +export type LoadedPlayerModelSource = LoadedModelSource; + +/** 旧版 glTF 玩家模型来源。 */ +export type LegacyPlayerModelSource = LegacyModelSource; + +/** 玩家模型来源。 */ +export type PlayerModelSource = LoadedPlayerModelSource | LegacyPlayerModelSource; + +/** 玩家模型、动画和移动参数。 */ +export type PlayerModelOptions = PlayerModelSource & { + /** 模型缩放。 */ + scale: number; + /** 静止动画名。 */ + idleAnim: string; + /** 行走动画名。 */ + walkAnim: string; + /** 跑步动画名。 */ + runAnim: string; + /** 跳跃动画名;或 [起跳, 循环, 落地] 三段动画名。 */ + jumpAnim: string | [startAnim: string, loopAnim: string, endAnim: string]; + /** 左移动画,默认复用 walkAnim。 */ + leftWalkAnim?: string; + /** 右移动画,默认复用 walkAnim。 */ + rightWalkAnim?: string; + /** 后退动画,默认复用 walkAnim。 */ + backwardAnim?: string; + /** 飞行动画,默认复用 idleAnim。 */ + flyAnim?: string; + /** 飞行待机动画,默认复用 idleAnim。 */ + flyIdleAnim?: string; + /** 飞行前进悬停动画,默认复用 flyAnim。 */ + flyHoverForwardAnim?: string; + /** 飞行后退悬停动画,默认复用 flyIdleAnim。 */ + flyHoverBackAnim?: string; + /** 飞行左移悬停动画,默认复用 flyIdleAnim。 */ + flyHoverLeftAnim?: string; + /** 飞行右移悬停动画,默认复用 flyIdleAnim。 */ + flyHoverRightAnim?: string; + /** 飞行上升悬停动画,默认复用 flyIdleAnim。 */ + flyHoverUpAnim?: string; + /** 飞行下降悬停动画,默认复用 flyIdleAnim。 */ + flyHoverDownAnim?: string; + /** 驾驶循环动画名,默认复用 idleAnim。 */ + drivingAnim?: string; + /** 重力基准值(按 scale 缩放),默认 -2400。 */ + gravity?: number; + /** 跳跃高度基准值(按 scale 缩放),默认 600。 */ + jumpHeight?: number; + /** 移动速度基准值(按 scale 缩放),默认 200。 */ + speed?: number; + /** 跑步速度基准值(按 scale 缩放),默认 600。 */ + runSpeed?: number; + /** 飞行速度基准值(按 scale 缩放),默认 2100。 */ + flySpeed?: number; + /** 人物初始朝向,单位为弧度,默认 0。 */ + rotateY?: number; + /** 头部骨骼名,用于第一人称相机挂载。 */ + headBoneName?: string; + /** 第一人称相机局部偏移。 */ + firstPersonCameraOffset?: [number, number, number]; + /** 胶囊体半径倍率,默认 1。 */ + capsuleRadiusRatio?: number; + /** XZ 加速响应速度,默认 30。 */ + acceleration?: number; + /** XZ 减速响应速度,默认 30。 */ + deceleration?: number; +}; + +/** 移动端按钮的布局和图标配置。 */ +export type MobileButtonOptions = { + /** 左侧位置,单位为 px。 */ + left?: number; + /** 右侧位置,单位为 px。 */ + right?: number; + /** 顶部位置,单位为 px。 */ + top?: number; + /** 底部位置,单位为 px。 */ + bottom?: number; + /** 按钮直径,单位为 px,默认 56。 */ + size?: number; + /** 自定义图片 URL。 */ + icon?: string; +}; + +/** 跳跃按钮配置。 */ +export type JumpButtonOptions = MobileButtonOptions & { + /** 车辆模式下的刹车图片 URL。 */ + brakeIcon?: string; +}; + +/** 移动端虚拟控制 UI 配置。 */ +export type MobileControlsOptions = { + /** 是否显示摇杆,默认 true。 */ + joystick?: boolean; + /** 跳跃/刹车按钮;false 隐藏,对象可自定义图片和布局。 */ + jump?: boolean | JumpButtonOptions; + /** 飞行按钮;false 隐藏,对象可自定义图片和布局。 */ + fly?: boolean | MobileButtonOptions; + /** 视角切换按钮;false 隐藏,对象可自定义图片和布局。 */ + view?: boolean | MobileButtonOptions; + /** 上下车按钮;false 隐藏,对象可自定义图片和布局。 */ + vehicle?: boolean | MobileButtonOptions; +}; + +/** 可重映射的输入动作。 */ +export type KeyAction = + | "forward" | "backward" | "left" | "right" + | "sprint" | "jump" | "toggleView" | "toggleFly" | "toggleVehicle"; + +/** 自定义键位映射;未填使用默认键,传入 code 或数组覆盖,传入 null 禁用。 */ +export type KeyMap = Partial>; + +/** 玩家控制器初始化配置。 */ +export type PlayerControllerOptions = { + /** three.js 场景实例。 */ + scene: THREE.Scene; + /** three.js 相机实例。 */ + camera: THREE.PerspectiveCamera; + /** 外部相机控制器,通常为 OrbitControls。 */ + controls: any; + /** 角色模型与参数配置。 */ + playerModelConfig: PlayerModelOptions; + /** 初始出生点,默认 (0, 0, 0)。 */ + initPos?: THREE.Vector3; + /** 鼠标灵敏度,默认 5。 */ + mouseSensitivity?: number; + /** 第三人称最小镜头距离,默认 100。 */ + minCamDistance?: number; + /** 第三人称最大镜头距离,默认 440。 */ + maxCamDistance?: number; + /** 相机看向点高度比例(0 为底部,1 为顶部),默认 0.8。 */ + camLookAtHeightRatio?: number; + /** 初始化时批量创建的碰撞体;未传则不自动建碰撞,需事后 `addCollider`。 */ + colliders?: import("../collision/colliderDesc").ColliderDesc[]; + /** 移动端是否显示虚拟控制 UI,默认 true。 */ + isShowMobileControls?: boolean; + /** 移动端按钮显隐配置。 */ + mobileControls?: MobileControlsOptions; + /** 第三人称鼠标控制模式,默认 1。 */ + thirdMouseMode?: 0 | 1 | 2 | 3 | 4 | 5; + /** 是否允许滚轮缩放,默认 false。 */ + enableZoom?: boolean; + /** 是否启用过肩视角,默认 false。 */ + enableOverShoulderView?: boolean; + /** 第三人称相机过肩视角横向偏移比例,默认 0.2。 */ + camOverShoulderOffsetRatio?: number; + /** 初始是否进入第一人称,默认 false。 */ + isFirstPerson?: boolean; + /** 是否启用弹簧相机,默认 false。 */ + enableSpringCamera?: boolean; + /** 弹簧相机平滑时间,单位为秒,默认 0.05。 */ + springCameraTime?: number; + /** 时间缩放系数;小于 1 为慢动作,大于 1 为快进,默认 1。 */ + timeScale?: number; + /** 自定义键位映射。 */ + keyMap?: KeyMap; +}; + +// ==================== 车辆配置 ==================== + +/** 已加载的车辆模型来源(车辆不使用动画片段)。 */ +export type LoadedVehicleModelSource = { + /** 已加载的模型根节点。 */ + model: THREE.Object3D; + /** 已加载模型与旧模型路径不能同时使用。 */ + url?: never; +}; + +/** 旧版 glTF 车辆模型来源。 */ +export type LegacyVehicleModelSource = { + /** + * 模型路径(GLB/GLTF)。 + * @deprecated 请在外部加载模型,并传入 model。 + */ + url: string; + /** 模型路径与已加载模型不能同时使用。 */ + model?: never; +}; + +/** 车辆模型来源。 */ +export type VehicleModelSource = LoadedVehicleModelSource | LegacyVehicleModelSource; + +/** 底盘碰撞盒与阻尼。 */ +export type VehicleChassisOptions = { + /** 密度,质量 = 碰撞盒体积 × 密度,默认 1。 */ + density?: number; + /** 线阻尼,默认 0.05。 */ + linearDamping?: number; + /** 角阻尼,默认 0.5。 */ + angularDamping?: number; + /** 盒底相对轮胎触地点的高度(随 scale 缩放);不传则盒底至少罩到轮心。 */ + clearance?: number; + /** + * 相对自动 AABB 的尺寸比例,默认 1。 + * X/Z 绕水平中心缩放;Y 从盒底向上缩放,不改变底盘离地高度。 + */ + sizeScale?: { + x?: number; + y?: number; + z?: number; + }; +}; + +/** 悬挂与轮胎。 */ +export type VehicleSuspensionOptions = { + /** 静止长度(米,随 scale 缩放);不传则取 max(轮直径 × 0.2, 静载下沉 × 1.2)。 */ + restLength?: number; + /** 最大行程(米,随 scale 缩放),默认 0.35。 */ + maxTravel?: number; + /** 刚度(质量归一化),默认 18。 */ + stiffness?: number; + /** 压缩阻尼,默认 2.1。 */ + compression?: number; + /** 回弹阻尼,默认 2.5。 */ + relaxation?: number; + /** 单轮最大悬挂力,默认 6000。 */ + maxForce?: number; + /** 纵向抓地,默认 8。 */ + frictionSlip?: number; + /** 侧向摩擦刚度,默认 1。 */ + sideFrictionStiffness?: number; + /** 侧向力对侧倾的影响,默认 0.12。 */ + rollInfluence?: number; +}; + +/** 转向手感。 */ +export type VehicleSteeringOptions = { + /** 最大转向角(弧度),默认 π/5。 */ + maxSteerAngle?: number; + /** 打满舵时间(秒),默认 0.45。 */ + steerTime?: number; + /** 低速回正时间(秒),默认 0.55。 */ + steerReturnTimeSlow?: number; + /** 高速回正时间(秒),默认 0.4。 */ + steerReturnTimeFast?: number; + /** 最高车速时转向角相对满舵的比例,默认 0.3。 */ + highSpeedSteerScale?: number; +}; + +/** 过弯抓地与手刹。 */ +export type VehicleGripOptions = { + /** 侧向加速度上限(g),默认 1.2。 */ + maxG?: number; + /** 直线侧向摩擦,默认 1。 */ + sideFrictionIdle?: number; + /** 满载前轮侧向摩擦下限,默认 0.55。 */ + sideFrictionFrontMin?: number; + /** 满载后轮侧向摩擦下限,默认 0.45。 */ + sideFrictionRearMin?: number; + /** 手刹后轮侧向摩擦,默认 0.35。 */ + handbrakeRearFriction?: number; + /** 手刹时后驱比例,默认 0.65。 */ + handbrakeRearDriveScale?: number; + /** 松手刹恢复时间(秒),默认 0.15。 */ + handbrakeReleaseTime?: number; + /** 轴距相对车长比例,默认 0.55。 */ + wheelbaseRatio?: number; +}; + +/** 动力。 */ +export type VehiclePowerOptions = { + /** 最高速度基准(km/h,按 scale 缩放),默认 100。 */ + maxSpeed?: number; + /** 加速度基准(m/s²,按 scale 缩放),默认 5。 */ + acceleration?: number; + /** 制动减速度基准(m/s²,按 scale 缩放),默认 5。 */ + deceleration?: number; +}; + +/** 车辆模型、物理和驾驶参数。 */ +export type VehicleOptions = VehicleModelSource & { + /** 车辆初始世界坐标;y 为最低轮底对齐高度。 */ + position: THREE.Vector3; + /** 车轮节点名,顺序为左前、右前、左后、右后。 */ + wheelsNames: string[]; + /** 车辆模型缩放,默认 1。 */ + scale?: number; + /** 驾驶位胶囊中心,使用车辆底盘局部坐标。 */ + driverSeatPosition: THREE.Vector3; + /** 驾驶位相对底盘局部的水平旋转(弧度),默认 0。 */ + driverSeatRotation?: number; + /** 模型绕 Y 旋转(弧度),默认 -π/2。 */ + modelRotation?: number; + /** 驾驶时镜头是否跟随车辆朝向,默认 true。 */ + followVehicleDirection?: boolean; + /** 调试显示。 */ + debug?: { + /** 是否显示底盘物理盒,默认 false。 */ + showPhysicsBox?: boolean; + /** 是否显示车轮射线,默认 false。 */ + showWheelRays?: boolean; + /** 是否显示车轮 Y 向活动范围,默认 false。 */ + showWheelTravel?: boolean; + /** 是否显示车轮动态碰撞球,默认 false。 */ + showWheelSpheres?: boolean; + }; + /** 底盘碰撞盒与阻尼。 */ + chassis?: VehicleChassisOptions; + /** 悬挂与轮胎。 */ + suspension?: VehicleSuspensionOptions; + /** 转向手感。 */ + steering?: VehicleSteeringOptions; + /** 过弯抓地与手刹。 */ + grip?: VehicleGripOptions; + /** 动力。 */ + power?: VehiclePowerOptions; +}; + +/** 已加载车辆的运行时对象。 */ +export type VehicleInstance = { + /** 车辆模型组。 */ + vehicleGroup: THREE.Group; + /** 底盘刚体。 */ + chassisBody: VehicleRigidBody; + /** BVH 车辆控制器。 */ + vehicleController: BVHVehicleController; + /** 同步车轮视觉的回调。 */ + updateWheelVisuals: (delta?: number) => void; + /** 销毁车辆控制器的回调。 */ + destroyVehicleController: () => void; + /** 车辆缩放。 */ + scale: number; + /** 驾驶位胶囊中心,使用车辆底盘局部坐标。 */ + driverSeatPosition: THREE.Vector3; + /** 驾驶位相对车辆底盘局部的水平旋转(弧度)。 */ + driverSeatRotation: number; + /** 由前后轮中心推算的车辆底盘本地前向。 */ + forwardLocal: THREE.Vector3; + /** 驻车时侧向摩擦。 */ + sideFrictionStiffness: number; + /** 转向参数。 */ + steering: { + maxSteerAngle: number; + steerTime: number; + steerReturnTimeSlow: number; + steerReturnTimeFast: number; + highSpeedSteerScale: number; + }; + /** 抓地预算。 */ + grip: { + maxG: number; + sideFrictionIdle: number; + sideFrictionFrontMin: number; + sideFrictionRearMin: number; + handbrakeRearFriction: number; + handbrakeRearDriveScale: number; + handbrakeReleaseTime: number; + wheelbaseRatio: number; + }; + /** 车辆尺寸(长、宽、高)。 */ + size: { + l: number; + w: number; + h: number; + }; + /** 底盘碰撞盒半边长(底盘局部坐标)。 */ + halfExtents: THREE.Vector3; + /** 当前盒底相对轮胎触地点的高度,使用 scale=1 基准值。 */ + chassisClearance: number; + /** 车身顶面相对轮胎触地点的高度,使用 scale=1 基准值。 */ + chassisTopClearance: number; + /** 底盘碰撞盒的 X 轴尺寸比例。 */ + chassisSizeScaleX: number; + /** 底盘碰撞盒的 Y 轴尺寸比例。 */ + chassisSizeScaleY: number; + /** 底盘碰撞盒的 Z 轴尺寸比例。 */ + chassisSizeScaleZ: number; + /** 最高速度(km/h)。 */ + maxSpeed: number; + /** 加速度(m/s²)。 */ + acceleration: number; + /** 制动减速度(m/s²)。 */ + deceleration: number; + /** 相机是否跟随车辆方向。 */ + followVehicleDirection: boolean; + /** 物理盒体调试网格。 */ + physicsBoxMesh?: THREE.Mesh; + /** 车轮射线调试。 */ + wheelRayDebug?: THREE.Group; + /** 车轮 Y 向活动范围调试。 */ + wheelTravelDebug?: THREE.Group; + /** 车轮动态碰撞球线框调试。 */ + wheelSphereDebug?: THREE.Group; + /** CollisionWorld 中底盘动态碰撞体 id。 */ + chassisColliderId?: number; + /** CollisionWorld 中车模运动学网格 id。 */ + meshColliderId?: number; + /** + * 仅与动态刚体碰撞的车轮球 collider id(与车轮一一对应)。 + * 运动学登记,无独立质量;冲量回写到底盘。 + */ + wheelColliderIds?: number[]; +}; + +/** 车轮球在 CollisionWorld.userData 上的挂载数据。 */ +export type VehicleWheelColliderUserData = { + vehicle: VehicleInstance; + wheelIndex: number; +}; + +/** 运动学碰撞体的 BVH 与帧间变换数据。 */ +export type KinematicColliderEntry = { + /** 原始物体。 */ + source: THREE.Object3D; + /** BVH 网格;顶点是世界烘焙后绕 RTC 中心的小坐标。 */ + mesh: THREE.Mesh; + /**相对建造时几何的额外均匀缩放。 */ + contentScale: number; + /** 相对 source 本地空间的额外位移。 */ + contentOffset: THREE.Vector3; + /** inv(source.matrixWorld_bake) * T(rtcCenter);预置 mesh 时为单位阵。 */ + bakeInverse: THREE.Matrix4; + /** 上一帧世界矩阵。 */ + prevWorldMatrix: THREE.Matrix4; + /** 本帧位移增量。 */ + deltaPos: THREE.Vector3; + /** 本帧 Y 轴旋转增量,单位为弧度。 */ + deltaRotY: number; + /** Worker / 同步 BVH 是否已就绪。 */ + ready: boolean; + /** 用于作废过期的异步 BVH 构建结果。 */ + buildId: number; + /** CollisionWorld 中的运动学碰撞体 id。 */ + worldId: number; +}; diff --git a/three-player-controller/src/types/three-mesh-bvh-worker.d.ts b/three-player-controller/src/types/three-mesh-bvh-worker.d.ts new file mode 100644 index 0000000..505a099 --- /dev/null +++ b/three-player-controller/src/types/three-mesh-bvh-worker.d.ts @@ -0,0 +1,7 @@ +declare module "three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js" { + export class GenerateMeshBVHWorker { + generate(geometry: import("three").BufferGeometry, options?: object): Promise; + dispose(): void; + running: boolean; + } +} diff --git a/three-player-controller/src/utils/BvhWorkerPool.ts b/three-player-controller/src/utils/BvhWorkerPool.ts new file mode 100644 index 0000000..95d7dd9 --- /dev/null +++ b/three-player-controller/src/utils/BvhWorkerPool.ts @@ -0,0 +1,189 @@ +import type { BufferGeometry } from "three"; +import { MeshBVH } from "three-mesh-bvh"; + +/** three-mesh-bvh 的 GenerateMeshBVHWorker 最小接口。 */ +type GenerateMeshBVHWorkerLike = { + generate(geometry: BufferGeometry, options?: object): Promise; + dispose(): void; +}; + +/** 串行队列中的一项构建任务。 */ +type QueueItem = { + geometry: BufferGeometry; // 待构建的几何 + resolve: (bvh: MeshBVH) => void; // 构建成功 + reject: (error: unknown) => void; // 构建失败 +}; + +/** + * 串行复用单个 GenerateMeshBVHWorker。 + * Worker 不可用或失败时回退到原几何的同步 MeshBVH。 + */ +export class BvhWorkerPool { + private worker: GenerateMeshBVHWorkerLike | null = null; // 已创建的 Worker + private workerPromise: Promise | null = null; // 创建中的 Promise,避免重复加载 + private readonly queue: QueueItem[] = []; // 等待构建的任务 + private pumping = false; // 是否正在消费队列 + private disposed = false; // 是否已销毁 + private workerDisabled = false; // 失败后禁用 Worker,后续一律同步构建 + private workerBuildCount = 0; // Worker 构建次数 + private syncBuildCount = 0; // 同步构建次数 + private loggedWorkerReady = false; // 是否已打印 Worker 就绪日志 + + /** 将几何加入队列,返回构建完成的 MeshBVH。 */ + generate(geometry: BufferGeometry): Promise { + if (this.disposed) { + return Promise.reject(new Error("BvhWorkerPool has been disposed.")); + } + return new Promise((resolve, reject) => { + this.queue.push({ geometry, resolve, reject }); + void this.pump(); + }); + } + + /** 销毁 Worker 并清空队列。 */ + dispose() { + this.disposed = true; + this.queue.length = 0; + this.disableWorker(); + this.workerPromise = null; + } + + /** 关闭 Worker,后续任务改走同步构建。 */ + private disableWorker() { + if (!this.workerDisabled && !this.disposed) { + console.warn("[BVH] worker disabled, later builds use sync MeshBVH"); + } + this.workerDisabled = true; + this.worker?.dispose(); + this.worker = null; + this.workerPromise = null; + } + + /** 按间隔打印构建次数,避免每帧刷日志。 */ + private logBuild(mode: "worker" | "sync") { + if (mode === "worker") { + this.workerBuildCount += 1; + if (this.workerBuildCount === 1 || this.workerBuildCount % 20 === 0) { + console.info(`[BVH] worker build #${this.workerBuildCount}`); + } + return; + } + this.syncBuildCount += 1; + if (this.syncBuildCount === 1 || this.syncBuildCount % 20 === 0) { + console.info(`[BVH] sync build #${this.syncBuildCount}`); + } + } + + /** 懒加载并缓存 Worker;失败则返回 null。 */ + private async ensureWorker(): Promise { + if (this.workerDisabled) return null; + if (this.worker) return this.worker; + if (this.workerPromise) return this.workerPromise; + + this.workerPromise = this.loadWorkerCtor() + .then((GenerateMeshBVHWorker) => { + if (this.disposed || this.workerDisabled || !GenerateMeshBVHWorker) return null; + this.worker = new GenerateMeshBVHWorker(); + if (!this.loggedWorkerReady) { + this.loggedWorkerReady = true; + console.info("[BVH] worker ready"); + } + return this.worker; + }) + .catch((error) => { + console.warn("[BVH] worker create failed, sync fallback", error); + this.workerDisabled = true; + return null; + }); + + return this.workerPromise; + } + + /** 动态导入 GenerateMeshBVHWorker。 */ + private async loadWorkerCtor(): Promise<(new () => GenerateMeshBVHWorkerLike) | null> { + const mod = await import("three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js"); + return mod.GenerateMeshBVHWorker; + } + + /** 主线程同步构建。 */ + private buildSync(geometry: BufferGeometry) { + return new MeshBVH(geometry); + } + + /** + * 在克隆几何上交给 Worker 构建,避免 transfer 抽空原 buffer。 + * 构建后把 position / index 写回原几何,供后续射线与 shapecast 使用。 + */ + private async buildWithWorker( + worker: GenerateMeshBVHWorkerLike, + geometry: BufferGeometry + ) { + const clone = geometry.clone(); + const bvh = await worker.generate(clone); + const position = clone.getAttribute("position"); + if (position?.array) { + geometry.setAttribute("position", position); + } + if (clone.index) { + geometry.setIndex(clone.index); + } + return bvh; + } + + /** 让出一帧后再继续消费队列,避免连续同步构建卡住主线程。 */ + private schedulePump() { + const resume = () => { + void this.pump(); + }; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(resume); + return; + } + setTimeout(resume, 0); + } + + /** 串行消费队列:优先 Worker,失败则同步回退并禁用 Worker。 */ + private async pump() { + if (this.pumping || this.disposed) return; + this.pumping = true; + + try { + while (this.queue.length && !this.disposed) { + const item = this.queue.shift(); + if (!item) break; + + try { + const worker = await this.ensureWorker(); + if (this.disposed) { + item.reject(new Error("BvhWorkerPool has been disposed.")); + continue; + } + // Worker 不可用:本任务同步构建,剩余任务下一帧再跑 + if (!worker) { + this.logBuild("sync"); + item.resolve(this.buildSync(item.geometry)); + if (this.queue.length) this.schedulePump(); + break; + } + const bvh = await this.buildWithWorker(worker, item.geometry); + this.logBuild("worker"); + item.resolve(bvh); + } catch (error) { + // 本次 Worker 构建失败:禁用 Worker,本任务改同步,其余下一帧继续 + console.warn("[BVH] worker generate failed, switch to sync", error); + this.disableWorker(); + try { + this.logBuild("sync"); + item.resolve(this.buildSync(item.geometry)); + } catch (fallbackError) { + item.reject(fallbackError ?? error); + } + if (this.queue.length) this.schedulePump(); + break; + } + } + } finally { + this.pumping = false; + } + } +} diff --git a/three-player-controller/src/utils/MobileControls.ts b/three-player-controller/src/utils/MobileControls.ts new file mode 100644 index 0000000..2085ea1 --- /dev/null +++ b/three-player-controller/src/utils/MobileControls.ts @@ -0,0 +1,461 @@ +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { MobileButtonOptions, MobileControlsOptions } from "../types"; + +type MobileButtonName = "jump" | "fly" | "view" | "vehicle"; +type MobileIconName = MobileButtonName | "brake"; + +const iconLabels: Record = { + jump: "JUMP", + brake: "BRAKE", + fly: "FLY", + view: "VIEW", + vehicle: "CAR", +}; + +type SetInputFn = (input: Partial<{ + moveX: number; + moveY: number; + lookDeltaX: number; + lookDeltaY: number; + jump: boolean; + shift: boolean; + toggleView: boolean; + toggleFly: boolean; + toggleVehicle: boolean; +}>) => void; + +class VirtualJoystick { + private baseEl: HTMLDivElement; + private stickEl: HTMLDivElement; + private pointerId: number | null = null; + private center = { x: 0, y: 0 }; + private radius: number; + private onMove: (data: { vector: { x: number; y: number }; distance: number }) => void; + private onEnd: () => void; + + constructor( + zone: HTMLDivElement, + size: number, + onMove: (data: { vector: { x: number; y: number }; distance: number }) => void, + onEnd: () => void, + ) { + this.radius = size / 2; + this.onMove = onMove; + this.onEnd = onEnd; + + // 外环 + this.baseEl = document.createElement("div"); + Object.assign(this.baseEl.style, { + position: "absolute", + left: "50%", + bottom: "50%", + transform: "translate(-50%, 50%)", + width: `${size}px`, + height: `${size}px`, + borderRadius: "50%", + border: "2px solid rgba(0,0,0,0.5)", + backgroundColor: "rgba(0,0,0,0.2)", + boxSizing: "border-box", + pointerEvents: "none", + }); + zone.appendChild(this.baseEl); + + // 内点 + const stickSize = size * 0.2; + this.stickEl = document.createElement("div"); + Object.assign(this.stickEl.style, { + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + width: `${stickSize}px`, + height: `${stickSize}px`, + borderRadius: "50%", + backgroundColor: "rgba(255,255,255,0.7)", + pointerEvents: "none", + }); + this.baseEl.appendChild(this.stickEl); + + zone.addEventListener("pointerdown", this.onPointerDown, { passive: false }); + zone.addEventListener("pointermove", this.onPointerMove, { passive: false }); + zone.addEventListener("pointerup", this.onPointerUp, { passive: false }); + zone.addEventListener("pointercancel", this.onPointerUp, { passive: false }); + } + + private onPointerDown = (e: PointerEvent) => { + if (this.pointerId !== null) return; + this.pointerId = e.pointerId; + const rect = this.baseEl.parentElement!.getBoundingClientRect(); + this.center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + this.baseEl.parentElement!.setPointerCapture(e.pointerId); + e.preventDefault(); + this.updateStick(e.clientX, e.clientY); + }; + + private onPointerMove = (e: PointerEvent) => { + if (e.pointerId !== this.pointerId) return; + e.preventDefault(); + this.updateStick(e.clientX, e.clientY); + }; + + private onPointerUp = (e: PointerEvent) => { + if (e.pointerId !== this.pointerId) return; + this.pointerId = null; + this.stickEl.style.transform = "translate(-50%, -50%)"; + this.onEnd(); + }; + + private updateStick(clientX: number, clientY: number) { + const dx = clientX - this.center.x; + const dy = clientY - this.center.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const clampedDist = Math.min(dist, this.radius); + const angle = Math.atan2(dy, dx); + const ox = Math.cos(angle) * clampedDist; + const oy = Math.sin(angle) * clampedDist; + + this.stickEl.style.transform = `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px))`; + + // 归一化向量,y 轴取反 + const scale = dist > 0 ? clampedDist / this.radius / dist : 0; + this.onMove({ vector: { x: dx * scale, y: -dy * scale }, distance: clampedDist }); + } + + destroy() { + const zone = this.baseEl.parentElement; + if (zone) { + zone.removeEventListener("pointerdown", this.onPointerDown); + zone.removeEventListener("pointermove", this.onPointerMove); + zone.removeEventListener("pointerup", this.onPointerUp); + zone.removeEventListener("pointercancel", this.onPointerUp); + } + this.baseEl.remove(); + } +} + +export class MobileControls { + setInput: SetInputFn; + controls: OrbitControls; + private options: MobileControlsOptions = {}; + + // 摇杆状态 + joystick: VirtualJoystick | null = null; + prevJoyState = { moveX: 0, moveY: 0, shift: false }; + + // DOM 元素 + joystickZoneEl: HTMLDivElement | null = null; + lookAreaEl: HTMLDivElement | null = null; + jumpBtnEl: HTMLButtonElement | null = null; + flyBtnEl: HTMLButtonElement | null = null; + viewBtnEl: HTMLButtonElement | null = null; + vehicleBtnEl: HTMLButtonElement | null = null; + + // 触摸状态 + lookPointerId: number | null = null; + isLookDown = false; + lastTouchX = 0; + lastTouchY = 0; + + constructor(setInput: SetInputFn, controls: OrbitControls) { + this.setInput = setInput; + this.controls = controls; + } + + // 初始化移动端控制 + async init(opts: MobileControlsOptions = {}) { + this.options = opts; + const showJoystick = opts.joystick ?? true; + const showJump = opts.jump !== false; + const showFly = opts.fly !== false; + const showView = opts.view !== false; + const showVehicle = opts.vehicle !== false; + + this.controls.maxPolarAngle = Math.PI * (300 / 360); + this.controls.touches = { ONE: null as any, TWO: null as any }; + + const JOY_SIZE = 120; + const container = document.body; + + // 创建摇杆区域 + if (showJoystick) { + this.joystickZoneEl = document.createElement("div"); + this.joystickZoneEl.id = "joy-zone"; + Object.assign(this.joystickZoneEl.style, { + position: "absolute", + left: "16px", + bottom: "16px", + width: `${JOY_SIZE + 40}px`, + height: `${JOY_SIZE + 40}px`, + touchAction: "none", + zIndex: "999", + pointerEvents: "auto", + WebkitUserSelect: "none", + userSelect: "none", + }); + container.appendChild(this.joystickZoneEl); + this.blockTouch(this.joystickZoneEl); + + // 初始化自定义摇杆 + this.joystick = new VirtualJoystick( + this.joystickZoneEl, + JOY_SIZE, + (data) => { + const rawX = data.vector?.x ?? 0; + const rawY = data.vector?.y ?? 0; + const distance = data.distance ?? 0; + const deadzone = 0.2; + const magnitude = Math.hypot(rawX, rawY); + const moveX = magnitude > deadzone ? rawX / magnitude : 0; + const moveY = magnitude > deadzone ? rawY / magnitude : 0; + const isSprinting = distance >= JOY_SIZE / 2; + const prev = this.prevJoyState; + if (Math.abs(moveX - prev.moveX) < 0.0001 && Math.abs(moveY - prev.moveY) < 0.0001 && isSprinting === prev.shift) return; + this.prevJoyState = { moveX, moveY, shift: isSprinting }; + this.setInput({ moveX, moveY, shift: isSprinting }); + }, + () => { + const prev = this.prevJoyState; + if (prev.moveX !== 0 || prev.moveY !== 0 || prev.shift) { + this.prevJoyState = { moveX: 0, moveY: 0, shift: false }; + this.setInput({ moveX: 0, moveY: 0, shift: false }); + } + }, + ); + } + + // 创建视角区域 + this.lookAreaEl = document.createElement("div"); + Object.assign(this.lookAreaEl.style, { + position: "absolute", + right: "0", + bottom: "0", + width: "50%", + height: "100%", + zIndex: "998", + touchAction: "none", + WebkitUserSelect: "none", + userSelect: "none", + }); + container.appendChild(this.lookAreaEl); + this.blockTouch(this.lookAreaEl); + + // 绑定视角触摸事件 + this.lookAreaEl.addEventListener("pointerdown", this.onPointerDown, { passive: false }); + this.lookAreaEl.addEventListener("pointermove", this.onPointerMove, { passive: false }); + this.lookAreaEl.addEventListener("pointerup", this.onPointerUp, { passive: false }); + this.lookAreaEl.addEventListener("pointercancel", this.onPointerUp, { passive: false }); + + // 创建操作按钮 + if (showJump) { + this.jumpBtnEl = this.createBtn(container, "jump", 14, 14); + this.jumpBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ jump: true }); }, { passive: false }); + this.jumpBtnEl.addEventListener("touchend", (e) => { e.preventDefault(); this.setInput({ jump: false }); }, { passive: false }); + this.jumpBtnEl.addEventListener("touchcancel", (e) => { e.preventDefault(); this.setInput({ jump: false }); }, { passive: false }); + } + if (showFly) { + this.flyBtnEl = this.createBtn(container, "fly", 14, 14 + 80); + this.flyBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleFly: true }); }, { passive: false }); + } + if (showView) { + this.viewBtnEl = this.createBtn(container, "view", 14, 14 + 200); + this.viewBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleView: true }); }, { passive: false }); + } + if (showVehicle) { + this.vehicleBtnEl = this.createBtn(container, "vehicle", 14 + 100, 14 + 120); + this.vehicleBtnEl.style.display = "none"; + this.vehicleBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleVehicle: true }); }, { passive: false }); + } + } + + // 销毁移动端控制 + destroy() { + try { + this.joystick?.destroy(); + this.joystick = null; + + if (this.lookAreaEl) { + this.lookAreaEl.removeEventListener("pointerdown", this.onPointerDown); + this.lookAreaEl.removeEventListener("pointermove", this.onPointerMove); + this.lookAreaEl.removeEventListener("pointerup", this.onPointerUp); + this.lookAreaEl.removeEventListener("pointercancel", this.onPointerUp); + } + + // 移除所有 DOM 元素 + [this.joystickZoneEl, this.lookAreaEl, this.jumpBtnEl, this.flyBtnEl, this.viewBtnEl, this.vehicleBtnEl] + .forEach(el => el?.parentElement?.removeChild(el)); + + this.joystickZoneEl = this.lookAreaEl = this.jumpBtnEl = + this.flyBtnEl = this.viewBtnEl = this.vehicleBtnEl = null; + } catch (e) { + console.warn("销毁移动端控制时出错:", e); + } + } + + // 同步车辆按钮显隐 + syncVehicleBtn(show: boolean) { + if (this.vehicleBtnEl) this.vehicleBtnEl.style.display = show ? "flex" : "none"; + } + + // 同步控制模式按钮 + syncControllerModeBtn(mode: 0 | 1) { + if (!this.jumpBtnEl) return; + if (mode === 0) { + if (this.flyBtnEl) this.flyBtnEl.style.display = "flex"; + this.setButtonIcon(this.jumpBtnEl, "jump"); + } else { + if (this.flyBtnEl) this.flyBtnEl.style.display = "none"; + this.setButtonIcon(this.jumpBtnEl, "brake"); + } + } + + // 触摸按下 + private onPointerDown = (e: PointerEvent) => { + if (e.pointerType !== "touch") return; + this.isLookDown = true; + this.lookPointerId = e.pointerId; + this.lastTouchX = e.clientX; + this.lastTouchY = e.clientY; + this.lookAreaEl?.setPointerCapture?.(e.pointerId); + e.preventDefault(); + }; + + // 触摸移动 + private onPointerMove = (e: PointerEvent) => { + if (!this.isLookDown || e.pointerId !== this.lookPointerId) return; + const dx = e.clientX - this.lastTouchX; + const dy = e.clientY - this.lastTouchY; + this.lastTouchX = e.clientX; + this.lastTouchY = e.clientY; + this.setInput({ lookDeltaX: dx, lookDeltaY: dy }); + e.preventDefault(); + }; + + // 触摸抬起 + private onPointerUp = (e: PointerEvent) => { + if (e.pointerId !== this.lookPointerId) return; + this.isLookDown = false; + this.lookPointerId = null; + this.lookAreaEl?.releasePointerCapture?.(e.pointerId); + }; + + // 阻止默认触摸 + private blockTouch(el: HTMLElement) { + ["touchstart", "touchmove", "touchend", "touchcancel"].forEach(name => { + el.addEventListener(name, e => e.preventDefault(), { passive: false }); + }); + } + + private getButtonOptions(name: MobileButtonName): MobileButtonOptions | undefined { + const options = this.options[name]; + return typeof options === "object" ? options : undefined; + } + + private getIconUrl(iconName: MobileIconName): string | undefined { + if (iconName === "brake") { + const jumpOptions = this.options.jump; + return typeof jumpOptions === "object" ? jumpOptions.brakeIcon : undefined; + } + return this.getButtonOptions(iconName)?.icon; + } + + // 创建圆形按钮 + private createBtn(container: HTMLElement, name: MobileButtonName, defaultRight: number, defaultBottom: number): HTMLButtonElement { + const btn = document.createElement("button"); + const layout = this.getButtonOptions(name); + const size = layout?.size ?? 56; + const idleShadow = "0 6px 12px rgba(0,0,0,0.45), 0 2px 4px rgba(0,0,0,0.35), inset 0 2px 2px rgba(255,255,255,0.24), inset 0 -2px 3px rgba(0,0,0,0.5)"; + const pressedShadow = "0 2px 5px rgba(0,0,0,0.35), inset 0 2px 4px rgba(0,0,0,0.65), inset 0 -1px 1px rgba(255,255,255,0.12)"; + Object.assign(btn.style, { + position: "absolute", + right: `${layout?.right ?? defaultRight}px`, + bottom: `${layout?.bottom ?? defaultBottom}px`, + width: `${size}px`, + height: `${size}px`, + zIndex: "1000", + borderRadius: "50%", + border: "1px solid rgba(255,255,255,0.45)", + padding: "0", + opacity: "0.95", + touchAction: "none", + fontSize: "14px", + userSelect: "none", + overflow: "hidden", + boxSizing: "border-box", + appearance: "none", + WebkitAppearance: "none", + WebkitTapHighlightColor: "transparent", + background: "linear-gradient(145deg, rgba(82,82,82,0.95) 0%, rgba(34,34,34,0.96) 55%, rgba(10,10,10,0.98) 100%)", + boxShadow: idleShadow, + color: "white", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + transform: "translateY(0) scale(1)", + transition: "transform 80ms ease, box-shadow 80ms ease, background 80ms ease", + willChange: "transform", + }); + if (layout?.left !== undefined) { + btn.style.left = `${layout.left}px`; + if (layout.right === undefined) btn.style.right = "auto"; + } + if (layout?.top !== undefined) { + btn.style.top = `${layout.top}px`; + if (layout.bottom === undefined) btn.style.bottom = "auto"; + } + this.setButtonIcon(btn, name); + container.appendChild(btn); + + const setPressed = (pressed: boolean) => { + btn.style.transform = pressed ? "translateY(2px) scale(0.98)" : "translateY(0) scale(1)"; + btn.style.boxShadow = pressed ? pressedShadow : idleShadow; + btn.style.background = pressed + ? "linear-gradient(145deg, rgba(22,22,22,0.98), rgba(52,52,52,0.96))" + : "linear-gradient(145deg, rgba(82,82,82,0.95) 0%, rgba(34,34,34,0.96) 55%, rgba(10,10,10,0.98) 100%)"; + }; + btn.addEventListener("pointerdown", () => setPressed(true)); + ["pointerup", "pointercancel", "pointerleave"].forEach(eventName => { + btn.addEventListener(eventName, () => setPressed(false)); + }); + ["touchstart", "touchend", "touchcancel"].forEach(name => { + btn.addEventListener(name, e => e.preventDefault(), { passive: false }); + }); + return btn; + } + + // 设置按钮内容 + private setButtonIcon(btn: HTMLButtonElement, iconName: MobileIconName) { + const customIconUrl = this.getIconUrl(iconName); + let icon: HTMLImageElement | HTMLSpanElement; + + if (customIconUrl !== undefined) { + const image = document.createElement("img"); + image.src = customIconUrl; + image.alt = ""; + image.draggable = false; + icon = image; + } else { + const label = document.createElement("span"); + label.textContent = iconLabels[iconName]; + icon = label; + } + + Object.assign(icon.style, { + width: "80%", + height: "80%", + display: "flex", + alignItems: "center", + justifyContent: "center", + objectFit: "contain", + pointerEvents: "none", + flex: "none", + fontSize: "11px", + fontWeight: "700", + fontFamily: "system-ui, sans-serif", + lineHeight: "1", + letterSpacing: "0.04em", + textShadow: "0 1px 2px rgba(0,0,0,0.9)", + }); + btn.replaceChildren(icon); + } +} diff --git a/three-player-controller/src/utils/bbox.ts b/three-player-controller/src/utils/bbox.ts new file mode 100644 index 0000000..debc3c3 --- /dev/null +++ b/three-player-controller/src/utils/bbox.ts @@ -0,0 +1,23 @@ +import * as THREE from "three"; + +const _vertex = new THREE.Vector3(); +/** 获取物体世界包围盒及中心、尺寸。按世界坐标顶点扩盒子。 */ +export function getBbox(object: THREE.Object3D) { + object.updateMatrixWorld(true); + const bbox = new THREE.Box3(); + object.traverse(child => { + const geometry = (child as THREE.Mesh).geometry; + const position = geometry?.attributes?.position; + if (!position) return; + const matrixWorld = child.matrixWorld; + for (let i = 0, n = position.count; i < n; i++) { + _vertex.fromBufferAttribute(position, i).applyMatrix4(matrixWorld); + bbox.expandByPoint(_vertex); + } + }); + const center = new THREE.Vector3(); + const size = new THREE.Vector3(); + bbox.getCenter(center); + bbox.getSize(size); + return { bbox, center, size }; +} diff --git a/three-player-controller/src/utils/capsuleCollision.ts b/three-player-controller/src/utils/capsuleCollision.ts new file mode 100644 index 0000000..e7a4d35 --- /dev/null +++ b/three-player-controller/src/utils/capsuleCollision.ts @@ -0,0 +1,319 @@ +import * as THREE from "three"; + +// 胶囊碰撞所需的临时对象 +export interface CollisionTemps { + invMat: THREE.Matrix4; // 碰撞体世界矩阵的逆矩阵 + localSeg: THREE.Line3; // 胶囊段在碰撞体本地空间的副本 + localBox: THREE.Box3; // 胶囊段在碰撞体本地空间的包围盒 + closestSeg: THREE.Vector3; // 胶囊段最近点 + closestTri: THREE.Vector3; // 三角面最近点 +} + +/** 地面体积探测的通用命中结果。 */ +export interface GroundProbeHit { + point: THREE.Vector3; + normal: THREE.Vector3; + /** 从胶囊下端球心沿向下方向到支撑点的距离。 */ + distance: number; +} + +/** + * 一次圆柱地面探测的参数、临时向量和当前最近命中。 + */ +export interface GroundProbeQuery { + start: THREE.Vector3; + down: THREE.Vector3; + maxDistance: number; + radius: number; + radiusSq: number; + minNormalY: number; + delta: THREE.Vector3; + radialOffset: THREE.Vector3; + hit: GroundProbeHit; +} + +/** BVH 三角形和动态盒表面三角形共同具备的最小查询接口。 */ +export interface GroundProbeTriangle { + closestPointToSegment( + segment: THREE.Line3, + trianglePoint: THREE.Vector3, + segmentPoint: THREE.Vector3, + ): number; + getNormal(target: THREE.Vector3): THREE.Vector3; +} + +/** 地面体积探测所需的复用临时对象。 */ +export interface GroundProbeTemps { + invMat: THREE.Matrix4; + normalMat: THREE.Matrix3; + localSeg: THREE.Line3; + localBox: THREE.Box3; + colliderScale: THREE.Vector3; + localTriPoint: THREE.Vector3; + localSegPoint: THREE.Vector3; + localNormal: THREE.Vector3; + query: GroundProbeQuery; +} + +// 创建一组预分配的临时对象 +export function createCollisionTemps(): CollisionTemps { + return { + invMat: new THREE.Matrix4(), + localSeg: new THREE.Line3(), + localBox: new THREE.Box3(), + closestSeg: new THREE.Vector3(), + closestTri: new THREE.Vector3(), + }; +} + +/** 创建可跨帧复用的圆柱地面探测状态。 */ +export function createGroundProbeQuery(): GroundProbeQuery { + return { + start: new THREE.Vector3(), + down: new THREE.Vector3(0, -1, 0), + maxDistance: 0, + radius: 0, + radiusSq: 0, + minNormalY: 0, + delta: new THREE.Vector3(), + radialOffset: new THREE.Vector3(), + hit: { + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + distance: Infinity, + }, + }; +} + +/** 写入本次探测参数,并清空上一次留下的最近命中。 */ +export function resetGroundProbeQuery( + query: GroundProbeQuery, + start: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + radius: number, + minNormalY: number, +): void { + query.start.copy(start); + query.down.copy(down).normalize(); + query.maxDistance = Math.max(0, maxDistance); + query.radius = Math.max(0, radius); + query.radiusSq = query.radius * query.radius; + query.minNormalY = minNormalY; + query.hit.distance = Infinity; +} + +/** + * 将形状算法给出的表面点送入统一的圆柱筛选器。 + * 返回 true 表示该点有效,并且替换了当前最近命中。 + */ +export function tryGroundProbeCandidate( + query: GroundProbeQuery, + point: THREE.Vector3, + normal: THREE.Vector3, +): boolean { + query.delta.subVectors(point, query.start); + + // 投影到向下轴得到探测深度;轴起点上方和圆柱末端之外都不属于传感区。 + const distance = query.delta.dot(query.down); + if ( + distance < -1e-5 + || distance > query.maxDistance + 1e-5 + || distance >= query.hit.distance + ) return false; + + // 去掉轴向分量后只剩横向偏移,用它判断候选点是否落在圆柱半径内。 + query.radialOffset.copy(query.delta).addScaledVector(query.down, -distance); + if (query.radialOffset.lengthSq() > query.radiusSq) return false; + + // 只接受法线足够朝上的可站立表面。 + if (-normal.dot(query.down) < query.minNormalY) return false; + + query.hit.distance = distance; + query.hit.point.copy(point); + query.hit.normal.copy(normal); + return true; +} + +/** + * 从三角面生成表面候选点,再交给统一圆柱筛选器。 + * 网格可传入本地到世界的变换;动态盒的三角形已经在世界空间,无需传变换。 + */ +export function tryGroundProbeTriangle( + query: GroundProbeQuery, + segment: THREE.Line3, + triangle: GroundProbeTriangle, + trianglePoint: THREE.Vector3, + segmentPoint: THREE.Vector3, + normal: THREE.Vector3, + pointMatrix?: THREE.Matrix4, + normalMatrix?: THREE.Matrix3, +): boolean { + // 三角面到圆柱中心轴的最近点,就是该三角面最有可能落入圆柱的候选点。 + triangle.closestPointToSegment(segment, trianglePoint, segmentPoint); + if (pointMatrix) trianglePoint.applyMatrix4(pointMatrix); + + triangle.getNormal(normal); + if (normalMatrix) normal.applyMatrix3(normalMatrix); + normal.normalize(); + + return tryGroundProbeCandidate(query, trianglePoint, normal); +} + +/** 创建一组地面体积探测临时对象。 */ +export function createGroundProbeTemps(): GroundProbeTemps { + return { + invMat: new THREE.Matrix4(), + normalMat: new THREE.Matrix3(), + localSeg: new THREE.Line3(), + localBox: new THREE.Box3(), + colliderScale: new THREE.Vector3(), + localTriPoint: new THREE.Vector3(), + localSegPoint: new THREE.Vector3(), + localNormal: new THREE.Vector3(), + query: createGroundProbeQuery(), + }; +} + +/** + * 用带半径的竖直传感区查找胶囊脚下的可支撑网格。 + * 中心射线落在细缝中时,传感区仍能触及缝隙两侧的顶面。 + */ +export function probeMeshGround( + sensorStart: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + sensorRadius: number, + minNormalY: number, + collider: THREE.Mesh, + temps: GroundProbeTemps, +): GroundProbeHit | null { + const boundsTree = (collider.geometry as any)?.boundsTree; + if (!boundsTree || maxDistance <= 0 || sensorRadius <= 0 || down.lengthSq() === 0) return null; + + resetGroundProbeQuery( + temps.query, + sensorStart, + down, + maxDistance, + sensorRadius, + minNormalY, + ); + temps.invMat.copy(collider.matrixWorld).invert(); + temps.normalMat.getNormalMatrix(collider.matrixWorld); + temps.localSeg.start.copy(temps.query.start).applyMatrix4(temps.invMat); + temps.localSeg.end.copy(temps.query.start) + .addScaledVector(temps.query.down, temps.query.maxDistance) + .applyMatrix4(temps.invMat); + + // BVH 宽相位:用包住整个探测圆柱的本地 AABB 排除远处节点。 + // 非等比缩放时取最小轴缩放计算半径,包围盒会偏大但不会漏掉候选面。 + temps.colliderScale.setFromMatrixScale(collider.matrixWorld); + const minScale = Math.max( + 1e-8, + Math.min(temps.colliderScale.x, temps.colliderScale.y, temps.colliderScale.z), + ); + const localRadius = sensorRadius / minScale; + temps.localBox.makeEmpty() + .expandByPoint(temps.localSeg.start) + .expandByPoint(temps.localSeg.end) + .expandByScalar(localRadius); + + boundsTree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(temps.localBox), + intersectsTriangle: (tri: any) => { + // 窄相位、坐标变换和候选过滤由网格与动态盒共用的函数完成。 + tryGroundProbeTriangle( + temps.query, + temps.localSeg, + tri, + temps.localTriPoint, + temps.localSegPoint, + temps.localNormal, + collider.matrixWorld, + temps.normalMat, + ); + }, + }); + + return Number.isFinite(temps.query.hit.distance) ? temps.query.hit : null; +} + +/** + * 将胶囊与单个网格做 BVH 碰撞检测。 + * `useDynamicsSegment` 时用接到脚底的段(飞行),否则用悬空段(步行上台阶)。 + * @param capsule 胶囊所在的 Object3D(位置会被直接修改) + * @param capsuleInfo 胶囊描述:局部空间线段 + 半径 + * @param collider 目标碰撞网格 + * @param temps 预分配临时对象 + * @param skipTri 可选:返回 true 则跳过该三角面 + */ +export function applyCapsuleCollision( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + collider: THREE.Mesh, + temps: CollisionTemps, + skipTri?: (tri: any, dir: THREE.Vector3) => boolean, + useDynamicsSegment = false, +): void { + const segment = useDynamicsSegment + ? (capsuleInfo.dynamicsSegment ?? capsuleInfo.segment) + : capsuleInfo.segment; + // 胶囊段变换到碰撞体本地空间 + temps.invMat.copy(collider.matrixWorld).invert(); + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld).applyMatrix4(temps.invMat); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld).applyMatrix4(temps.invMat); + + // 构建本地包围盒 + temps.localBox.makeEmpty(); + temps.localBox.expandByPoint(temps.localSeg.start).expandByPoint(temps.localSeg.end); + temps.localBox.expandByScalar(capsuleInfo.radius); + + // 碰撞查询 + (collider.geometry as any)?.boundsTree?.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(temps.localBox), + intersectsTriangle: (tri: any) => { + // 初步筛选 + const distance = tri.closestPointToSegment(temps.localSeg, temps.closestSeg, temps.closestTri); + if (distance >= capsuleInfo.radius) return; + + // 二次筛选 + const dir = temps.closestTri.clone().sub(temps.closestSeg).normalize(); + if (skipTri?.(tri, dir)) return; + + // 推开胶囊段 + temps.localSeg.start.addScaledVector(dir, capsuleInfo.radius - distance); + temps.localSeg.end.addScaledVector(dir, capsuleInfo.radius - distance); + }, + }); + + // 应用碰撞修正 + const newPos = temps.closestSeg.copy(temps.localSeg.start).applyMatrix4(collider.matrixWorld); + const delta = temps.closestTri.subVectors(newPos, capsule.position); + const offset = Math.max(0, delta.length() - 1e-5); + capsule.position.add(delta.normalize().multiplyScalar(offset)); +} + +/** + * 胶囊段与球的重叠。 + * 命中时 temps.closestSeg 为段上最近点,temps.closestTri 为胶囊指向球的单位法线,返回穿透深度。 + */ +export function capsuleSphereOverlap( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + spherePos: THREE.Vector3, + sphereRadius: number, + temps: CollisionTemps, +): number { + const segment = capsuleInfo.dynamicsSegment ?? capsuleInfo.segment; + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld); + temps.localSeg.closestPointToPoint(spherePos, true, temps.closestSeg); + temps.closestTri.subVectors(spherePos, temps.closestSeg); + const dist = temps.closestTri.length(); + const minDist = capsuleInfo.radius + sphereRadius; + if (dist >= minDist) return 0; + if (dist > 1e-8) temps.closestTri.multiplyScalar(1 / dist); + else temps.closestTri.set(0, 1, 0); + return minDist - dist; +} \ No newline at end of file diff --git a/three-player-controller/src/utils/vehicleController.ts b/three-player-controller/src/utils/vehicleController.ts new file mode 100644 index 0000000..652ccb1 --- /dev/null +++ b/three-player-controller/src/utils/vehicleController.ts @@ -0,0 +1,244 @@ +import * as THREE from "three"; +import { BVHVehicleController } from "./vehiclePhysics/BVHVehicleController"; +import type { VehicleRigidBody } from "./vehiclePhysics/VehicleRigidBody"; + +/** 单个车轮的悬挂几何(底盘局部)。 */ +export type WheelInfo = { + axleCs: THREE.Vector3; + suspensionRestLength: number; + maxSuspensionTravel: number; + position: THREE.Vector3; + radius: number; +}; + +/** + * 车轮悬挂与抓地参数(质量归一化刚度/阻尼)。 + * stiffness / damping / friction 不随 scale 缩放。 + */ +export type WheelPhysicsParams = { + suspensionStiffness: number; + suspensionCompression: number; + suspensionRelaxation: number; + maxSuspensionForce: number; + frictionSlip: number; + sideFrictionStiffness: number; + rollInfluence: number; +}; + +/** 悬挂与抓地默认值。静止长度不传时按轮直径 × 0.2。 */ +export const DEFAULT_WHEEL_PHYSICS: WheelPhysicsParams = { + suspensionStiffness: 18, + suspensionCompression: 2.1, + suspensionRelaxation: 2.5, + maxSuspensionForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, +}; + +/** 悬挂最大行程默认值(米,scale 1)。 */ +export const DEFAULT_MAX_SUSPENSION_TRAVEL = 0.35; + +/** 视觉悬挂跟随速度(越大越贴射线)。 */ +const VISUAL_FOLLOW = 14; +/** 接地标记球相对轮胎半径的比例。 */ +const DEBUG_HIT_RADIUS_RATIO = 0.12; +/** 行程刻度线半宽相对轮胎半径的比例。 */ +const DEBUG_TICK_RADIUS_RATIO = 0.25; + +/** 创建调试线段网格。 */ +function makeDebugLine(material: THREE.LineBasicMaterial, segments: number): THREE.LineSegments { + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(segments * 6), 3)); + const line = new THREE.LineSegments(geo, material); + line.frustumCulled = false; + line.renderOrder = 20; + return line; +} + +/** 写入第 i 段线段的两端点。 */ +function setSegment(attr: THREE.BufferAttribute, i: number, a: THREE.Vector3, b: THREE.Vector3): void { + const o = i * 6; + attr.array[o] = a.x; + attr.array[o + 1] = a.y; + attr.array[o + 2] = a.z; + attr.array[o + 3] = b.x; + attr.array[o + 4] = b.y; + attr.array[o + 5] = b.z; +} + +/** 创建 BVH 车辆控制器并同步轮子视觉。 */ +export function createVehicleController( + chassisBody: VehicleRigidBody, + wheels: (THREE.Object3D | null)[], + wheelsInfo: WheelInfo[], + physics: WheelPhysicsParams = DEFAULT_WHEEL_PHYSICS, +) { + const vehicle = new BVHVehicleController(chassisBody); + const suspensionDirection = new THREE.Vector3(0, -1, 0); + + // 注册每个轮子的物理参数 + wheelsInfo.forEach((wheel, index) => { + vehicle.addWheel(wheel.position, suspensionDirection, wheel.axleCs, wheel.suspensionRestLength, wheel.radius); + vehicle.setWheelChassisConnectionPointCs(index, wheel.position); // 连接点 + vehicle.setWheelDirectionCs(index, suspensionDirection); // 悬挂方向 + vehicle.setWheelAxleCs(index, wheel.axleCs); // 轮轴方向 + vehicle.setWheelSuspensionRestLength(index, wheel.suspensionRestLength); // 静止长度 + vehicle.setWheelRadius(index, wheel.radius); // 轮胎半径 + vehicle.setWheelMaxSuspensionTravel(index, wheel.maxSuspensionTravel); // 最大行程 + vehicle.setWheelSuspensionStiffness(index, physics.suspensionStiffness); // 悬挂刚度 + vehicle.setWheelSuspensionCompression(index, physics.suspensionCompression); // 压缩阻尼 + vehicle.setWheelSuspensionRelaxation(index, physics.suspensionRelaxation); // 回弹阻尼 + vehicle.setWheelMaxSuspensionForce(index, physics.maxSuspensionForce); // 最大作用力 + vehicle.setWheelBrake(index, 0); // 制动 + vehicle.setWheelSteering(index, 0); // 转向角 + vehicle.setWheelEngineForce(index, 0); // 驱动力 + vehicle.setWheelFrictionSlip(index, physics.frictionSlip); // 纵向抓地 + vehicle.setWheelSideFrictionStiffness(index, physics.sideFrictionStiffness); // 侧向摩擦 + vehicle.setWheelRollInfluence(index, physics.rollInfluence); // 侧倾影响 + }); + + const up = new THREE.Vector3(0, 1, 0); + const wheelSteeringQuat = new THREE.Quaternion(); + const wheelRotationQuat = new THREE.Quaternion(); + const visualLengthSmooth: number[] = new Array(wheels.length).fill(Number.NaN); + const _from = new THREE.Vector3(); + const _to = new THREE.Vector3(); + const _dir = new THREE.Vector3(); + const _tick = new THREE.Vector3(); + const _rest = new THREE.Vector3(); + const _a = new THREE.Vector3(); + const _b = new THREE.Vector3(); + + const wheelRayDebug = new THREE.Group(); + wheelRayDebug.name = "wheelRayDebug"; + wheelRayDebug.userData.excludeFromCollider = true; + const wheelTravelDebug = new THREE.Group(); + wheelTravelDebug.name = "wheelTravelDebug"; + wheelTravelDebug.userData.excludeFromCollider = true; + const rayLines: THREE.LineSegments[] = []; + const hitMarks: THREE.Mesh[] = []; + const travelLines: THREE.LineSegments[] = []; + const hitGeo = new THREE.SphereGeometry(1, 8, 6); + const hitMat = new THREE.MeshBasicMaterial({ color: 0xffff66, depthTest: false }); + const rayMatHit = new THREE.LineBasicMaterial({ color: 0x66ff66, depthTest: false }); + const rayMatMiss = new THREE.LineBasicMaterial({ color: 0xff5555, depthTest: false }); + const travelMat = new THREE.LineBasicMaterial({ color: 0x44ccff, depthTest: false, transparent: true, opacity: 0.95 }); + for (let i = 0; i < wheels.length; i++) { + const ray = makeDebugLine(rayMatHit, 1); + rayLines.push(ray); + wheelRayDebug.add(ray); + const hit = new THREE.Mesh(hitGeo, hitMat); + hit.frustumCulled = false; + hit.renderOrder = 21; + hit.visible = false; + hit.scale.setScalar(Math.max(1e-6, (wheelsInfo[i]?.radius ?? 0) * DEBUG_HIT_RADIUS_RATIO)); + hitMarks.push(hit); + wheelRayDebug.add(hit); + const travel = makeDebugLine(travelMat, 4); + travelLines.push(travel); + wheelTravelDebug.add(travel); + } + + /** 同步轮子视觉:高度按指数跟随射线,转向与自转直接写入。 */ + function updateWheelVisuals(delta = 1 / 60) { + const follow = 1 - Math.exp(-VISUAL_FOLLOW * Math.max(0, delta)); + const showRays = wheelRayDebug.visible; + const showTravel = wheelTravelDebug.visible; + for (const [index, wheelObj] of wheels.entries()) { + if (!wheelObj) continue; + const wheelAxleCs = vehicle.wheelAxleCs(index) ?? new THREE.Vector3(1, 0, 0); + const connection = vehicle.wheelChassisConnectionPointCs(index)?.y ?? 0; + const target = vehicle.wheelVisualLength(index) ?? vehicle.wheelSuspensionLength(index) ?? 0; + const steering = vehicle.wheelSteering(index) ?? 0; + const rotationRad = vehicle.wheelRotation(index) ?? 0; + + let length = target; + if (Number.isFinite(visualLengthSmooth[index])) { + length = visualLengthSmooth[index] + (target - visualLengthSmooth[index]) * follow; + } + visualLengthSmooth[index] = length; + wheelObj.position.y = connection - length; + wheelSteeringQuat.setFromAxisAngle(up, steering); + wheelRotationQuat.setFromAxisAngle(wheelAxleCs, rotationRad); + wheelObj.quaternion.copy(wheelSteeringQuat).multiply(wheelRotationQuat); + + if (showRays || showTravel) { + const wheel = vehicle.wheels[index]; + if (wheel) updateWheelDebug(index, wheel, showRays, showTravel); + } + } + } + + /** 更新单轮射线与行程调试线。 */ + function updateWheelDebug( + index: number, + wheel: (typeof vehicle.wheels)[number], + showRays: boolean, + showTravel: boolean, + ) { + _dir.copy(wheel.direction); + if (_dir.lengthSq() < 1e-12) _dir.set(0, -1, 0); + else _dir.normalize(); + const rest = wheel.restLength; + const travel = wheel.maxSuspensionTravel; + const minLen = Math.max(0, rest - travel); + const maxLen = rest + travel; + const rayLen = rest + travel + wheel.radius; + const conn = wheel.connectionPoint; + + if (showRays) { + const ray = rayLines[index]; + _from.copy(conn); + _to.copy(conn).addScaledVector(_dir, rayLen); + const rayAttr = ray.geometry.getAttribute("position") as THREE.BufferAttribute; + setSegment(rayAttr, 0, _from, _to); + rayAttr.needsUpdate = true; + ray.geometry.computeBoundingSphere(); + ray.material = wheel.isInContact ? rayMatHit : rayMatMiss; + const hit = hitMarks[index]; + if (wheel.isInContact) { + const hitDist = Math.max(0, wheel.visualLength + wheel.radius); + hit.position.copy(conn).addScaledVector(_dir, hitDist); + hit.scale.setScalar(Math.max(1e-6, wheel.radius * DEBUG_HIT_RADIUS_RATIO)); + hit.visible = true; + } else { + hit.visible = false; + } + } + + if (showTravel) { + const line = travelLines[index]; + _from.copy(conn).addScaledVector(_dir, minLen); + _to.copy(conn).addScaledVector(_dir, maxLen); + _tick.copy(wheel.axle); + if (_tick.lengthSq() < 1e-12) _tick.set(1, 0, 0); + else _tick.normalize(); + _tick.multiplyScalar(Math.max(1e-6, wheel.radius * DEBUG_TICK_RADIUS_RATIO)); + _rest.copy(conn).addScaledVector(_dir, rest); + const attr = line.geometry.getAttribute("position") as THREE.BufferAttribute; + setSegment(attr, 0, _from, _to); + setSegment(attr, 1, _a.copy(_from).sub(_tick), _b.copy(_from).add(_tick)); + setSegment(attr, 2, _a.copy(_to).sub(_tick), _b.copy(_to).add(_tick)); + setSegment(attr, 3, _a.copy(_rest).sub(_tick), _b.copy(_rest).add(_tick)); + attr.needsUpdate = true; + line.geometry.computeBoundingSphere(); + } + } + + /** 销毁车辆控制器与调试网格。 */ + function destroy() { + vehicle.wheels.length = 0; + wheelRayDebug.removeFromParent(); + wheelTravelDebug.removeFromParent(); + for (const line of rayLines) line.geometry.dispose(); + for (const line of travelLines) line.geometry.dispose(); + hitGeo.dispose(); + hitMat.dispose(); + rayMatHit.dispose(); + rayMatMiss.dispose(); + travelMat.dispose(); + } + + return { vehicle, updateWheelVisuals, destroy, wheelRayDebug, wheelTravelDebug }; +} diff --git a/three-player-controller/src/utils/vehicleLoader.ts b/three-player-controller/src/utils/vehicleLoader.ts new file mode 100644 index 0000000..e634c28 --- /dev/null +++ b/three-player-controller/src/utils/vehicleLoader.ts @@ -0,0 +1,322 @@ +import * as THREE from "three"; +import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { createVehicleController, DEFAULT_MAX_SUSPENSION_TRAVEL, DEFAULT_WHEEL_PHYSICS } from "./vehicleController"; +import { VehicleRigidBody } from "./vehiclePhysics/VehicleRigidBody"; +import { getBbox } from "./bbox"; +import type { VehicleOptions, VehicleInstance } from "../types"; + +export type VehicleLoaderContext = { + loader: GLTFLoader; + scene: THREE.Scene; + vehicleParams: { + debug: { showPhysicsBox: boolean; showWheelRays: boolean; showWheelTravel: boolean; showWheelSpheres: boolean }; + chassis: { density: number; linearDamping: number; angularDamping: number; sizeScale?: { x?: number; y?: number; z?: number } }; + model: { rotation: number }; + power: { acceleration: number; deceleration: number; maxSpeed: number }; + steering: { maxSteerAngle: number; steerTime: number; steerReturnTimeSlow: number; steerReturnTimeFast: number; highSpeedSteerScale: number }; + grip: { + maxG: number; + sideFrictionIdle: number; + sideFrictionFrontMin: number; + sideFrictionRearMin: number; + handbrakeRearFriction: number; + handbrakeRearDriveScale: number; + handbrakeReleaseTime: number; + wheelbaseRatio: number; + }; + suspension: { + restLength?: number; + maxTravel: number; + stiffness: number; + compression: number; + relaxation: number; + maxForce: number; + frictionSlip: number; + sideFrictionStiffness: number; + rollInfluence: number; + }; + followVehicleDirection: boolean; + }; + vehicleLength: number; +}; + +const MIN_BOX_HEIGHT = 1e-3; + +function mergeOpts(base: T, over?: Partial): T { + return over ? { ...base, ...over } : { ...base }; +} + +// ==================== 主函数 ==================== + +// 加载车辆模型 +export async function loadVehicleModel( + opts: VehicleOptions, + ctx: VehicleLoaderContext, +): Promise { + const { loader, scene, vehicleParams, vehicleLength } = ctx; + + const scale = opts.scale ?? 1; + const debug = mergeOpts(vehicleParams.debug, opts.debug); + const chassis = mergeOpts(vehicleParams.chassis, opts.chassis); + const power = mergeOpts(vehicleParams.power, opts.power); + const steering = mergeOpts(vehicleParams.steering, opts.steering); + const grip = mergeOpts(vehicleParams.grip, opts.grip); + const sus = mergeOpts(vehicleParams.suspension, opts.suspension); + const density = Math.max(1e-8, chassis.density); + const linearDamping = chassis.linearDamping; + const angularDamping = chassis.angularDamping; + const maxSpeed = power.maxSpeed * scale; + const acceleration = power.acceleration * scale; + const deceleration = power.deceleration * scale; + const followVehicleDirection = opts.followVehicleDirection ?? vehicleParams.followVehicleDirection; + + let model: THREE.Object3D; + if (opts.model) { + model = opts.model; + } else { + const gltf = await loader.loadAsync(opts.url); + model = gltf.scene; + } + + // 计算模型缩放比 + const { size: originalSize } = getBbox(model); + const modelScale = vehicleLength / Math.max(originalSize.x, originalSize.y, originalSize.z); + + // 查找轮子节点 + const wheelObjects: THREE.Object3D[] = []; + for (const name of opts.wheelsNames) { + let found = false; + model.traverse(child => { + if (child.name === name && !found) { wheelObjects.push(child); found = true; } + }); + if (!found) console.warn(`未找到轮子: ${name}`); + } + + // 临时挂载获取世界坐标 + const tempGroup = new THREE.Group(); + scene.add(tempGroup); + model.scale.multiplyScalar(modelScale * scale); + model.rotateY(opts.modelRotation ?? vehicleParams.model.rotation); + const { center } = getBbox(model); + model.position.set(-center.x, -center.y, -center.z); + tempGroup.add(model); + tempGroup.updateMatrixWorld(true); + + // 收集轮子世界变换信息 + let wheelRadius = 0, wheelSizeInit = false; + const wheelsInfo: any[] = []; + + for (const wheel of wheelObjects) { + const worldPos = new THREE.Vector3(); + const worldQuat = new THREE.Quaternion(); + const worldScale = new THREE.Vector3(); + wheel.getWorldPosition(worldPos); + wheel.getWorldQuaternion(worldQuat); + wheel.getWorldScale(worldScale); + + // 只计算一次轮子尺寸 + if (!wheelSizeInit) { + const { size: ws } = getBbox(wheel); + wheelRadius = Math.max(ws.x, ws.y, ws.z) / 2; + wheelSizeInit = true; + } + + wheelsInfo.push({ + axleCs: new THREE.Vector3(0, 0, -1), + position: worldPos, + quaternion: worldQuat, + scale: worldScale, + radius: wheelRadius, + object: wheel, + }); + } + + tempGroup.remove(model); + scene.remove(tempGroup); + + if (wheelsInfo.length !== 4) { + throw new Error(`车辆需要 4 个有效车轮节点,当前找到 ${wheelsInfo.length} 个`); + } + + // 创建车辆根节点 + const vehicleGroup = new THREE.Group(); + scene.add(vehicleGroup); + vehicleGroup.add(model); + vehicleGroup.updateMatrixWorld(true); + + // 轮子包装组(独立旋转) + const wheelWrappers: THREE.Group[] = []; + for (let i = 0; i < wheelsInfo.length; i++) { + const wheel = wheelsInfo[i]; + const wheelWrapper = new THREE.Group(); + wheelWrapper.position.copy(vehicleGroup.worldToLocal(wheel.position.clone())); + + const wheelObj = wheel.object; + wheelObj.parent?.remove(wheelObj); + wheelObj.position.set(0, 0, 0); + wheelObj.quaternion.copy(wheel.quaternion); + wheelObj.scale.copy(wheel.scale); + wheelObj.updateMatrixWorld(); + + wheelWrapper.add(wheelObj); + vehicleGroup.add(wheelWrapper); + wheelWrappers.push(wheelWrapper); + } + + const shiftContent = (offset: THREE.Vector3) => { + model.position.sub(offset); + for (const wrapper of wheelWrappers) wrapper.position.sub(offset); + }; + + // 拆轮后按车身包围盒重定原点,物理盒不含轮胎 + vehicleGroup.updateMatrixWorld(true); + const { center: bodyCenter, size: bodySize } = getBbox(model); + shiftContent(vehicleGroup.worldToLocal(bodyCenter.clone())); + vehicleGroup.updateMatrixWorld(true); + + const { bbox: bodyBbox } = getBbox(model); + const localMin = vehicleGroup.worldToLocal(bodyBbox.min.clone()); + const localMax = vehicleGroup.worldToLocal(bodyBbox.max.clone()); + + let minTireBottom = Infinity; + let minHubY = Infinity; + for (const wrapper of wheelWrappers) { + minHubY = Math.min(minHubY, wrapper.position.y); + minTireBottom = Math.min(minTireBottom, wrapper.position.y - wheelRadius); + } + + let boxTop = localMax.y; + const chassisTopClearance = (boxTop - minTireBottom) / Math.max(scale, 1e-8); + // 默认盒底罩到轮心,弹簧压死后由盒子托住,避免只靠轮网格撑地 + let boxBottom = Math.min(localMin.y, minHubY); + if (opts.chassis?.clearance != null) { + boxBottom = minTireBottom + opts.chassis.clearance * scale; + boxBottom = Math.max(boxBottom, minTireBottom); + } + if (boxBottom > boxTop - MIN_BOX_HEIGHT) boxBottom = boxTop - MIN_BOX_HEIGHT; + const chassisClearance = (boxBottom - minTireBottom) / Math.max(scale, 1e-8); + + // X/Z 绕水平中心;Y 从盒底向上缩,离地间隙不变 + const sizeScale = chassis.sizeScale; + const sx = Math.max(1e-3, sizeScale?.x ?? 1); + const sy = Math.max(1e-3, sizeScale?.y ?? 1); + const sz = Math.max(1e-3, sizeScale?.z ?? 1); + const boxHeight = Math.max(MIN_BOX_HEIGHT, (boxTop - boxBottom) * sy); + boxTop = boxBottom + boxHeight; + + const boxCenterY = (boxTop + boxBottom) * 0.5; + if (Math.abs(boxCenterY) > 1e-8) { + shiftContent(new THREE.Vector3(0, boxCenterY, 0)); + vehicleGroup.updateMatrixWorld(true); + } + + // 硬点用模型轮心。静止长度不传时按轮直径 × 0.2,且不低于静载下沉。 + const stiffness = sus.stiffness ?? DEFAULT_WHEEL_PHYSICS.suspensionStiffness; + const staticSag = 9.81 * scale / (4 * Math.max(1e-4, stiffness)); + const restLengthOpt = sus.restLength; + // 传入值按 scale 1 的米;未传时轮半径已含 scale,不再乘 + const restFromOptOrWheel = restLengthOpt != null ? restLengthOpt * scale : wheelRadius * 2 * 0.2; + const suspensionRestLength = Math.max(restFromOptOrWheel, staticSag * 1.2); + const maxSuspensionTravel = (sus.maxTravel ?? DEFAULT_MAX_SUSPENSION_TRAVEL) * scale; + const rollInfluence = sus.rollInfluence ?? DEFAULT_WHEEL_PHYSICS.rollInfluence; + for (let i = 0; i < wheelsInfo.length; i++) { + wheelsInfo[i].position = wheelWrappers[i].position.clone(); + wheelsInfo[i].suspensionRestLength = suspensionRestLength; + wheelsInfo[i].maxSuspensionTravel = maxSuspensionTravel; + wheelsInfo[i].radius = wheelRadius; + } + + // 按左前、右前、左后、右后轮中心推算车辆前向 + const frontCenter = wheelWrappers[0].position.clone().add(wheelWrappers[1].position).multiplyScalar(0.5); + const rearCenter = wheelWrappers[2].position.clone().add(wheelWrappers[3].position).multiplyScalar(0.5); + const forwardLocal = frontCenter.sub(rearCenter); + forwardLocal.y = 0; + if (forwardLocal.lengthSq() < 1e-8) forwardLocal.set(1, 0, 0); + else forwardLocal.normalize(); + + const halfExtents = new THREE.Vector3( + bodySize.x * 0.5 * sx, + boxHeight * 0.5, + bodySize.z * 0.5 * sz, + ); + const volume = 8 * halfExtents.x * halfExtents.y * halfExtents.z; + const mass = volume * density; + + // position.y 对齐最低轮底 + minTireBottom = Infinity; + for (const wrapper of wheelWrappers) { + minTireBottom = Math.min(minTireBottom, wrapper.position.y - wheelRadius); + } + const spawnPosition = opts.position.clone(); + spawnPosition.y -= minTireBottom; + + const chassisBody = new VehicleRigidBody({ + position: spawnPosition, + mass, + halfExtents, + linearDamping, + angularDamping, + gravityScale: scale, + }); + + // 物理调试盒 + const physicsBoxMesh = new THREE.Mesh( + new THREE.BoxGeometry(halfExtents.x * 2, halfExtents.y * 2, halfExtents.z * 2), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.3 }), + ); + physicsBoxMesh.userData.excludeFromCollider = true; + if (debug.showPhysicsBox) vehicleGroup.add(physicsBoxMesh); + + vehicleGroup.position.copy(spawnPosition); + vehicleGroup.updateMatrixWorld(true); + + const wheelPhysics = { + suspensionStiffness: sus.stiffness ?? DEFAULT_WHEEL_PHYSICS.suspensionStiffness, + suspensionCompression: sus.compression ?? DEFAULT_WHEEL_PHYSICS.suspensionCompression, + suspensionRelaxation: sus.relaxation ?? DEFAULT_WHEEL_PHYSICS.suspensionRelaxation, + maxSuspensionForce: sus.maxForce ?? DEFAULT_WHEEL_PHYSICS.maxSuspensionForce, + frictionSlip: sus.frictionSlip ?? DEFAULT_WHEEL_PHYSICS.frictionSlip, + sideFrictionStiffness: sus.sideFrictionStiffness ?? DEFAULT_WHEEL_PHYSICS.sideFrictionStiffness, + rollInfluence, + }; + + const { vehicle, updateWheelVisuals, destroy, wheelRayDebug, wheelTravelDebug } = createVehicleController( + chassisBody, + wheelWrappers, + wheelsInfo, + wheelPhysics, + ); + vehicleGroup.add(wheelRayDebug); + vehicleGroup.add(wheelTravelDebug); + wheelRayDebug.visible = debug.showWheelRays; + wheelTravelDebug.visible = debug.showWheelTravel; + + return { + vehicleGroup, + chassisBody, + vehicleController: vehicle, + updateWheelVisuals, + destroyVehicleController: destroy, + scale, + driverSeatPosition: opts.driverSeatPosition.clone(), + driverSeatRotation: opts.driverSeatRotation ?? 0, + forwardLocal, + sideFrictionStiffness: wheelPhysics.sideFrictionStiffness, + steering, + grip, + size: { l: Math.max(bodySize.x, bodySize.z), w: Math.min(bodySize.x, bodySize.z), h: bodySize.y }, + halfExtents, + chassisClearance, + chassisTopClearance, + chassisSizeScaleX: sx, + chassisSizeScaleY: sy, + chassisSizeScaleZ: sz, + maxSpeed, + acceleration, + deceleration, + followVehicleDirection, + physicsBoxMesh, + wheelRayDebug, + wheelTravelDebug, + }; +} diff --git a/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts b/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts new file mode 100644 index 0000000..a5d879d --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts @@ -0,0 +1,485 @@ +import * as THREE from "three"; +import { VehicleCollision } from "./VehicleCollision"; +import { ContactCache } from "../../collision/contacts/ContactCache"; +import { ContactImpulseSolver } from "../../collision/solver/ContactImpulseSolver"; +import { effectiveMass } from "./vehicleMath"; +import type { VehicleRigidBody } from "./VehicleRigidBody"; +import { VehicleWheel } from "./VehicleWheel"; + +const SIDE_DAMPING_RATE = 13; // 侧向速度阻尼率(1/s) +const FWD_FACTOR = 0.5; // 摩擦椭圆纵向权重 +const SIDE_FACTOR = 1.0; // 摩擦椭圆侧向权重 + +/** 基于 three-mesh-bvh 射线与轻量刚体的 Raycast Vehicle 求解器。 */ +export class BVHVehicleController { + readonly chassis: VehicleRigidBody; // 车身刚体 + readonly wheels: VehicleWheel[] = []; // 车轮列表 + + private collider: THREE.Mesh[] = []; // 场景碰撞网格 + private collision = new VehicleCollision(); + private contactSolver = new ContactImpulseSolver(); + private contactCache = new ContactCache(); + private raycaster = new THREE.Raycaster(); + private prevPosition = new THREE.Vector3(); + private prevQuaternion = new THREE.Quaternion(); + private normalMat = new THREE.Matrix3(); + private steerAxis = new THREE.Vector3(); + private steerQuat = new THREE.Quaternion(); + private vel = new THREE.Vector3(); + private impulse = new THREE.Vector3(); + private r = new THREE.Vector3(); + private gcross = new THREE.Vector3(); + private local = new THREE.Vector3(); + private world = new THREE.Vector3(); + private invQuat = new THREE.Quaternion(); + private upWS = new THREE.Vector3(); + private sidePoint = new THREE.Vector3(); + + /** 绑定车身刚体,可选传入场景碰撞网格。 */ + constructor(chassis: VehicleRigidBody, collider?: THREE.Mesh | THREE.Mesh[] | null) { + this.chassis = chassis; + this.collider = this.toColliderList(collider); + (this.raycaster as any).firstHitOnly = true; + } + + /** 设置场景碰撞网格。 */ + setCollider(collider: THREE.Mesh | THREE.Mesh[] | null | undefined): void { + this.collider = this.toColliderList(collider); + } + + /** 返回车轮数量。 */ + numWheels(): number { + return this.wheels.length; + } + + // ==================== 轮子参数 ==================== + + /** 添加车轮并写入悬挂几何。 */ + addWheel( + chassisConnectionCs: THREE.Vector3, + directionCs: THREE.Vector3, + axleCs: THREE.Vector3, + suspensionRestLength: number, + radius: number, + ): VehicleWheel { + const wheel = new VehicleWheel(); + wheel.connectionPoint.copy(chassisConnectionCs); + wheel.direction.copy(directionCs); + wheel.axle.copy(axleCs); + wheel.restLength = suspensionRestLength; + wheel.suspensionLength = suspensionRestLength; + wheel.visualLength = suspensionRestLength; + wheel.maxSuspensionTravel = suspensionRestLength; + wheel.radius = radius; + this.wheels.push(wheel); + return wheel; + } + + /** 设置悬挂与底盘的局部连接点。 */ + setWheelChassisConnectionPointCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.connectionPoint.copy(value); + } + + /** 设置悬挂方向(底盘局部)。 */ + setWheelDirectionCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.direction.copy(value); + } + + /** 设置轮轴方向(底盘局部)。 */ + setWheelAxleCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.axle.copy(value); + } + + /** 设置悬挂静止长度。 */ + setWheelSuspensionRestLength(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.restLength = value; + } + + /** 设置轮胎半径。 */ + setWheelRadius(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.radius = value; + } + + /** 设置悬挂最大行程。 */ + setWheelMaxSuspensionTravel(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.maxSuspensionTravel = value; + } + + /** 设置悬挂刚度。 */ + setWheelSuspensionStiffness(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.stiffness = value; + } + + /** 设置压缩阻尼。 */ + setWheelSuspensionCompression(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.dampingCompression = value; + } + + /** 设置回弹阻尼。 */ + setWheelSuspensionRelaxation(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.dampingRelaxation = value; + } + + /** 设置最大悬挂力。 */ + setWheelMaxSuspensionForce(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.maxSuspensionForce = value; + } + + /** 设置制动力。 */ + setWheelBrake(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.brake = value; + } + + /** 设置转向角。 */ + setWheelSteering(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.steering = value; + } + + /** 设置驱动力。 */ + setWheelEngineForce(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.engineForce = value; + } + + /** 设置纵向抓地。 */ + setWheelFrictionSlip(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.frictionSlip = value; + } + + /** 设置侧向摩擦。 */ + setWheelSideFrictionStiffness(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.sideFrictionStiffness = value; + } + + /** 设置侧向力对侧倾的影响,0 为几乎不侧倾,1 为按真实触地点侧倾。 */ + setWheelRollInfluence(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.rollInfluence = value; + } + + /** 读取转向角。 */ + wheelSteering(i: number): number | null { + return this.wheelAt(i)?.steering ?? null; + } + + /** 读取是否接地。 */ + wheelIsInContact(i: number): boolean { + return this.wheelAt(i)?.isInContact ?? false; + } + + /** 读取轮轴方向(底盘局部)。 */ + wheelAxleCs(i: number): THREE.Vector3 | null { + return this.wheelAt(i)?.axle ?? null; + } + + /** 读取悬挂与底盘的局部连接点。 */ + wheelChassisConnectionPointCs(i: number): THREE.Vector3 | null { + return this.wheelAt(i)?.connectionPoint ?? null; + } + + /** 读取当前悬挂长度。 */ + wheelSuspensionLength(i: number): number | null { + return this.wheelAt(i)?.suspensionLength ?? null; + } + + /** 读取视觉悬挂长度。 */ + wheelVisualLength(i: number): number | null { + return this.wheelAt(i)?.visualLength ?? null; + } + + /** 读取车轮自转角。 */ + wheelRotation(i: number): number | null { + return this.wheelAt(i)?.rotation ?? null; + } + + /** 读取接地网格。 */ + wheelContactMesh(i: number): THREE.Mesh | null { + return this.wheelAt(i)?.contactMesh ?? null; + } + + // ==================== 仿真步进 ==================== + + /** 推进一帧:悬挂与轮胎力 → 车身接触 → 积分 → CCD。 */ + updateVehicle(dt: number, collider?: THREE.Mesh | THREE.Mesh[] | null): void { + if (collider !== undefined) this.collider = this.toColliderList(collider); + const clampedDt = Math.max(0, dt); + if (clampedDt === 0) return; + + this.prevPosition.copy(this.chassis.position); + this.prevQuaternion.copy(this.chassis.quaternion); + this.updateWheelTransforms(); + this.chassis.applyGravity(clampedDt); // 重力 + this.raycastWheels(); // 轮子向下射线 + this.solveSuspension(); // 悬挂弹簧阻尼 + this.applySuspensionImpulses(clampedDt); + this.solveFriction(clampedDt); // 纵向驱动 / 侧向抓地 + + // 积分前解一次车身接触速度 + const manifolds = this.collision.detect(this.chassis, this.collider); + this.contactCache.match(manifolds); + this.contactSolver.solveVelocity(this.chassis, manifolds, clampedDt); + this.contactCache.save(manifolds); + + this.chassis.applyDamping(clampedDt); + this.chassis.integrate(clampedDt); // 积分位置和朝向 + + // 积分后再解一次,消化穿透 + const positionManifolds = this.collision.detect(this.chassis, this.collider); + this.contactCache.match(positionManifolds); + this.contactSolver.solveVelocity(this.chassis, positionManifolds, clampedDt); + this.contactCache.save(positionManifolds); + + // 高速时扫掠回退,避免穿模 + if (this.collision.needsSwept(this.chassis, clampedDt)) { + this.collision.solveSwept( + this.chassis, this.collider, + this.prevPosition, this.prevQuaternion, + ); + } + this.updateWheelRotation(clampedDt); + } + + /** 把单个网格或列表收成可遍历数组。 */ + private toColliderList(collider?: THREE.Mesh | THREE.Mesh[] | null): THREE.Mesh[] { + if (!collider) return []; + return Array.isArray(collider) ? collider : [collider]; + } + + /** 按索引取车轮,越界返回 undefined。 */ + wheelAt(i: number): VehicleWheel | undefined { + return this.wheels[i]; + } + + // 把悬挂连接点、方向和转向后的轮轴变到世界空间 + private updateWheelTransforms(): void { + const q = this.chassis.quaternion; + for (const wheel of this.wheels) { + wheel.hardPointWS.copy(wheel.connectionPoint).applyQuaternion(q).add(this.chassis.position); + wheel.directionWS.copy(wheel.direction).applyQuaternion(q); + this.steerAxis.copy(wheel.directionWS).negate(); + this.steerQuat.setFromAxisAngle(this.steerAxis, wheel.steering); + wheel.axleWS.copy(wheel.axle).applyQuaternion(q).applyQuaternion(this.steerQuat); + } + } + + // ==================== 悬挂与摩擦 ==================== + + // 沿悬挂方向射线检测接地 + private raycastWheels(): void { + const colliders = this.collider; + for (const wheel of this.wheels) { + wheel.isInContact = false; + wheel.contactMesh = null; + const rayLen = wheel.restLength + wheel.maxSuspensionTravel + wheel.radius; + if (!colliders.length || rayLen <= 1e-6) { + this.setWheelAirborne(wheel); + continue; + } + + const dirLen = wheel.directionWS.length(); + if (dirLen < 1e-8) { + this.setWheelAirborne(wheel); + continue; + } + this.impulse.copy(wheel.directionWS).multiplyScalar(1 / dirLen); + this.raycaster.set(wheel.hardPointWS, this.impulse); + this.raycaster.near = 0; + this.raycaster.far = rayLen; + const hits = this.raycaster.intersectObjects(colliders, false); + const hit = hits[0]; + if (!hit) { + this.setWheelAirborne(wheel); + continue; + } + + wheel.isInContact = true; + wheel.contactMesh = (hit.object as THREE.Mesh).isMesh ? hit.object as THREE.Mesh : null; + wheel.contactPoint.copy(hit.point); + if (hit.face) { + this.normalMat.getNormalMatrix(hit.object.matrixWorld); + wheel.contactNormal.copy(hit.face.normal).applyMatrix3(this.normalMat).normalize(); + } else { + wheel.contactNormal.copy(wheel.directionWS).negate().normalize(); + } + if (wheel.contactNormal.dot(wheel.directionWS) > 0) wheel.contactNormal.negate(); + + // 物理长度夹在行程内;视觉长度跟射线 + const rawLength = hit.distance - wheel.radius; + const minLen = Math.max(0, wheel.restLength - wheel.maxSuspensionTravel); + const maxLen = wheel.restLength + wheel.maxSuspensionTravel; + wheel.visualLength = rawLength; + wheel.suspensionLength = Math.min(maxLen, Math.max(minLen, rawLength)); + + const denom = wheel.contactNormal.dot(wheel.directionWS); + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const projVel = wheel.contactNormal.dot(this.vel); + if (denom >= -0.1) { + // 法线几乎与悬挂平行或反向,避免除零 + wheel.suspensionRelativeVelocity = 0; + wheel.clippedInvContactDotSuspension = 10; + } else { + const inv = -1 / denom; + wheel.suspensionRelativeVelocity = projVel * inv; + wheel.clippedInvContactDotSuspension = inv; + } + } + } + + // 离地时回到静止长度 + private setWheelAirborne(wheel: VehicleWheel): void { + wheel.isInContact = false; + wheel.contactMesh = null; + wheel.suspensionLength = wheel.restLength; + wheel.visualLength = wheel.restLength; + wheel.suspensionRelativeVelocity = 0; + wheel.contactNormal.copy(wheel.directionWS).negate().normalize(); + wheel.clippedInvContactDotSuspension = 1; + wheel.suspensionForce = 0; + } + + // 弹簧 + 压缩/回弹阻尼 + private solveSuspension(): void { + const chassisMass = this.chassis.mass(); + for (const wheel of this.wheels) { + if (!wheel.isInContact) { + wheel.suspensionForce = 0; + continue; + } + const lengthDiff = wheel.restLength - wheel.suspensionLength; + let force = wheel.stiffness * lengthDiff * wheel.clippedInvContactDotSuspension; + const damping = wheel.suspensionRelativeVelocity < 0 + ? wheel.dampingCompression + : wheel.dampingRelaxation; + force -= damping * wheel.suspensionRelativeVelocity; + wheel.suspensionForce = Math.max(0, force * chassisMass); // 悬挂只推不拉 + } + } + + // 沿接触法线施加悬挂冲量;作用点用轮心,减少转向时接地点把车顶歪 + private applySuspensionImpulses(dt: number): void { + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + const force = Math.min(wheel.suspensionForce, wheel.maxSuspensionForce); + this.impulse.copy(wheel.contactNormal).multiplyScalar(force * dt); + this.chassis.applyImpulseAtPoint(this.impulse, wheel.hardPointWS); + } + } + + // 侧向抓地、驱动/制动,超出摩擦椭圆则打滑缩放 + private solveFriction(dt: number): void { + let wheelsOnGround = 0; + for (const wheel of this.wheels) { + wheel.sideImpulse = 0; + wheel.forwardImpulse = 0; + wheel.skidInfo = 1; + if (!wheel.isInContact) continue; + wheelsOnGround += 1; + + wheel.sideWS.copy(wheel.axleWS); + const proj = wheel.sideWS.dot(wheel.contactNormal); + wheel.sideWS.addScaledVector(wheel.contactNormal, -proj); + if (wheel.sideWS.lengthSq() < 1e-8) continue; + wheel.sideWS.normalize(); + wheel.forwardWS.copy(wheel.contactNormal).cross(wheel.sideWS); + if (wheel.forwardWS.lengthSq() < 1e-8) continue; + wheel.forwardWS.normalize(); + + // 侧向速度阻尼,抓地由 sideFrictionStiffness 缩放 + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const sideSpeed = wheel.sideWS.dot(this.vel); + const mass = effectiveMass( + this.chassis, wheel.contactPoint, wheel.sideWS, + this.r, this.gcross, this.local, this.world, this.invQuat, + ); + const dampFactor = Math.min( + 1, + (1 - Math.exp(-SIDE_DAMPING_RATE * dt)) * wheel.sideFrictionStiffness, + ); + wheel.sideImpulse = -dampFactor * sideSpeed * mass; + } + + // 驱动或制动纵向冲量,并检测摩擦椭圆 + let sliding = false; + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + + if (wheel.engineForce !== 0) { + wheel.forwardImpulse = wheel.engineForce * dt; + } else { + // 无驱动时用制动冲量刹住纵向滑动 + const maxImpulse = wheel.brake; + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const fwdSpeed = wheel.forwardWS.dot(this.vel); + const mass = effectiveMass( + this.chassis, wheel.contactPoint, wheel.forwardWS, + this.r, this.gcross, this.local, this.world, this.invQuat, + ); + const denom = Math.max(1, wheelsOnGround); + wheel.forwardImpulse = Math.max(-maxImpulse, Math.min(maxImpulse, -fwdSpeed * mass / denom)); + } + + const maxImp = wheel.suspensionForce * dt * wheel.frictionSlip; + const x = wheel.forwardImpulse * FWD_FACTOR; + const y = wheel.sideImpulse * SIDE_FACTOR; + const impulseSq = x * x + y * y; + const maxImpSq = maxImp * maxImp; + if (impulseSq > maxImpSq && impulseSq > 1e-12) { + sliding = true; + wheel.skidInfo *= maxImp / Math.sqrt(impulseSq); + } + } + + if (sliding) { + // 超出椭圆的轮子按 skidInfo 同时缩小纵/侧向 + for (const wheel of this.wheels) { + if (wheel.skidInfo < 1) { + wheel.forwardImpulse *= wheel.skidInfo; + wheel.sideImpulse *= wheel.skidInfo; + } + } + } + + // 纵向冲量作用在轮心;侧向冲量作用在触地点,rollInfluence 收短侧倾力臂 + this.upWS.set(0, 1, 0).applyQuaternion(this.chassis.quaternion); + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + if (wheel.forwardImpulse !== 0) { + this.impulse.copy(wheel.forwardWS).multiplyScalar(wheel.forwardImpulse); + this.chassis.applyImpulseAtPoint(this.impulse, wheel.hardPointWS); + } + if (wheel.sideImpulse !== 0) { + this.impulse.copy(wheel.sideWS).multiplyScalar(wheel.sideImpulse); + this.sidePoint.copy(wheel.contactPoint); + this.r.subVectors(wheel.contactPoint, this.chassis.position); + this.sidePoint.addScaledVector(this.upWS, -this.upWS.dot(this.r) * (1 - wheel.rollInfluence)); + this.chassis.applyImpulseAtPoint(this.impulse, this.sidePoint); + } + } + } + + // 按接地点前向速度更新车轮自转 + private updateWheelRotation(dt: number): void { + for (const wheel of this.wheels) { + if (wheel.isInContact) { + this.chassis.getVelocityAtPoint(wheel.hardPointWS, this.vel); + const proj = wheel.forwardWS.dot(this.vel); + wheel.deltaRotation = wheel.radius > 1e-6 ? -(proj * dt) / wheel.radius : 0; + wheel.rotation += wheel.deltaRotation; + } else { + wheel.rotation += wheel.deltaRotation; + } + wheel.deltaRotation *= 0.99; + } + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts new file mode 100644 index 0000000..c6a643c --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts @@ -0,0 +1,373 @@ +import * as THREE from "three"; +import type { ContactManifold } from "../../collision/contacts/ContactManifold"; +import { CONTACT_SKIN, contactSkinForExtent, type RawContact } from "../../collision/contacts/ContactPoint"; +import { reduceContacts } from "../../collision/contacts/contactReducer"; +import { closestPointOnTriangle } from "./vehicleMath"; +import type { ImpulseBody } from "../../collision/solver/ImpulseBody"; +import type { VehicleRigidBody } from "./VehicleRigidBody"; + +const MAX_RAW = 96; // 单帧原始接触上限 +const CCD_SIZE_RATIO = 0.12; // 位移超过最小边长该比例时启用扫掠 + +/** detect 所需的 OBB 刚体(车辆底盘 / 动态盒共用)。 */ +export type ObbMeshBody = ImpulseBody & { + halfExtents: THREE.Vector3; +}; + +/** 车身 OBB 与 BVH 三角网格:只收集接触,高速时做扫掠。 */ +export class VehicleCollision { + private raw: RawContact[] = Array.from({ length: MAX_RAW }, () => ({ + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + penetration: 0, + })); + private rawCount = 0; + private manifolds: ContactManifold[] = []; + private toward = new THREE.Vector3(); + + private invMat = new THREE.Matrix4(); + private worldMat = new THREE.Matrix4(); + private normalMat = new THREE.Matrix3(); + private localAABB = new THREE.Box3(); + private worldAABB = new THREE.Box3(); + private corner = new THREE.Vector3(); + private prevCorner = new THREE.Vector3(); + private axisX = new THREE.Vector3(); + private axisY = new THREE.Vector3(); + private axisZ = new THREE.Vector3(); + private prevAxisX = new THREE.Vector3(); + private prevAxisY = new THREE.Vector3(); + private prevAxisZ = new THREE.Vector3(); + private toiQuat = new THREE.Quaternion(); + private localCenter = new THREE.Vector3(); + private localAxisX = new THREE.Vector3(); + private localAxisY = new THREE.Vector3(); + private localAxisZ = new THREE.Vector3(); + private triN = new THREE.Vector3(); + private edge = new THREE.Vector3(); + private testAxis = new THREE.Vector3(); + private bestAxis = new THREE.Vector3(); + private rangeA = { min: 0, max: 0 }; + private rangeB = { min: 0, max: 0 }; + private closest = new THREE.Vector3(); + private ab = new THREE.Vector3(); + private ac = new THREE.Vector3(); + private ap = new THREE.Vector3(); + private bp = new THREE.Vector3(); + private cp = new THREE.Vector3(); + private impulse = new THREE.Vector3(); + private support = new THREE.Vector3(); + private bodyHalf = new THREE.Vector3(); + private raycaster = new THREE.Raycaster(); + /** 当前 detect 按刚体尺寸缩放后的接触皮肤。 */ + private contactSkin = CONTACT_SKIN; + + /** 配置射线只取最近命中。 */ + constructor() { + (this.raycaster as any).firstHitOnly = true; + } + + /** 收集 OBB 与一组网格的接触流形。 */ + detect( + body: ObbMeshBody, + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + ): ContactManifold[] { + this.rawCount = 0; + this.manifolds.length = 0; + const extent = Math.max(body.halfExtents.x, body.halfExtents.y, body.halfExtents.z); + this.contactSkin = contactSkinForExtent(extent); + for (const collider of this.toList(colliders)) { + const tree = (collider.geometry as any)?.boundsTree; + if (!tree) continue; + this.collectRawContacts(body, collider, tree); + } + if (this.rawCount === 0) return this.manifolds; + return reduceContacts(this.raw, this.rawCount, body, this.manifolds); + } + + /** 本帧位移是否大到需要扫掠。 */ + needsSwept(body: VehicleRigidBody, dt: number): boolean { + const hx = body.halfExtents.x; + const hy = body.halfExtents.y; + const hz = body.halfExtents.z; + const minSize = 2 * Math.min(hx, hy, hz); + const linTravel = body.linearVelocity.length() * dt; + const angTravel = body.angularVelocity.length() * Math.hypot(hx, hy, hz) * dt; + return linTravel + angTravel > minSize * CCD_SIZE_RATIO; + } + + /** 高速穿模时按 TOI 回退位置和朝向。 */ + solveSwept( + body: VehicleRigidBody, + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + prevPosition: THREE.Vector3, + prevQuaternion: THREE.Quaternion, + ): void { + const list = this.toList(colliders); + if (!list.length) return; + this.solveSweptCollision(body, list, prevPosition, prevQuaternion); + } + + /** 对 8 个角点和中心扫过所有网格,按最早 TOI 回退。 */ + private solveSweptCollision( + body: VehicleRigidBody, + colliders: THREE.Mesh[], + prevPosition: THREE.Vector3, + prevQuaternion: THREE.Quaternion, + ): void { + this.bodyHalf.copy(body.halfExtents); + this.setAxes(prevQuaternion, this.prevAxisX, this.prevAxisY, this.prevAxisZ); + this.setAxes(body.quaternion, this.axisX, this.axisY, this.axisZ); + this.raycaster.near = 0; + let minToi = 1; + let hitNormal: THREE.Vector3 | null = null; + + // 8 角点 + 中心沿本帧位移射线,取最早命中 + for (let i = 0; i < 9; i++) { + if (i === 8) { + this.prevCorner.copy(prevPosition); + this.corner.copy(body.position); + } else { + const sx = (i & 1) ? 1 : -1; + const sy = (i & 2) ? 1 : -1; + const sz = (i & 4) ? 1 : -1; + this.cornerFrom(prevPosition, this.prevAxisX, this.prevAxisY, this.prevAxisZ, sx, sy, sz, this.prevCorner); + this.cornerFrom(body.position, this.axisX, this.axisY, this.axisZ, sx, sy, sz, this.corner); + } + this.impulse.subVectors(this.corner, this.prevCorner); + const len = this.impulse.length(); + if (len < 1e-6) continue; + this.impulse.multiplyScalar(1 / len); + this.raycaster.set(this.prevCorner, this.impulse); + this.raycaster.far = len; + const hits = this.raycaster.intersectObjects(colliders, false); + if (!hits.length) continue; + const hit = hits[0]; + const toi = hit.distance / len; + if (toi >= minToi || toi <= 1e-4) continue; + minToi = toi; + if (hit.face) { + this.normalMat.getNormalMatrix(hit.object.matrixWorld); + this.bestAxis.copy(hit.face.normal).applyMatrix3(this.normalMat).normalize(); + if (this.bestAxis.dot(this.impulse) > 0) this.bestAxis.negate(); + this.toward.copy(this.bestAxis); + hitNormal = this.toward; + } + } + + if (minToi >= 1) return; + const t = minToi * 0.95; // 略提前停在命中前 + body.position.lerpVectors(prevPosition, body.position, t); + this.toiQuat.slerpQuaternions(prevQuaternion, body.quaternion, t); + body.quaternion.copy(this.toiQuat); + // 去掉沿命中法线的入射速度 + if (!hitNormal) return; + const vn = body.linearVelocity.dot(hitNormal); + if (vn < 0) body.linearVelocity.addScaledVector(hitNormal, -vn); + } + + /** 把单个网格或列表收成可遍历数组。 */ + private toList( + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + ): THREE.Mesh[] { + if (!colliders) return []; + return Array.isArray(colliders) ? colliders : [colliders]; + } + + /** 用 BVH shapecast 收集与 OBB 相交的三角。 */ + private collectRawContacts( + body: ObbMeshBody, + collider: THREE.Mesh, + tree: { shapecast: (opts: any) => void }, + ): void { + this.setWorldAxes(body); + this.worldMat.copy(collider.matrixWorld); + this.invMat.copy(this.worldMat).invert(); + this.normalMat.getNormalMatrix(this.worldMat); + + // OBB 变到网格本地,再用 AABB 做 BVH 粗测 + this.localCenter.copy(body.position).applyMatrix4(this.invMat); + this.localAxisX.copy(this.axisX).transformDirection(this.invMat); + this.localAxisY.copy(this.axisY).transformDirection(this.invMat); + this.localAxisZ.copy(this.axisZ).transformDirection(this.invMat); + + this.computeOBBWorldAABB(body.position, this.worldAABB); + this.localAABB.copy(this.worldAABB).applyMatrix4(this.invMat); + + const self = this; + tree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(self.localAABB), + intersectsTriangle: (tri: { a: THREE.Vector3; b: THREE.Vector3; c: THREE.Vector3 }) => { + self.testTriangle(body, tri.a, tri.b, tri.c); + }, + }); + } + + /** SAT 分离则丢弃;否则用支撑点投影出接触。 */ + private testTriangle( + body: ObbMeshBody, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): void { + this.ab.subVectors(b, a); + this.ac.subVectors(c, a); + this.triN.copy(this.ab).cross(this.ac); + if (this.triN.lengthSq() < 1e-12) return; + this.triN.normalize(); + + // 面法线、OBB 三轴、边×轴,任一分离则不相交 + if (!this.tryAxis(this.triN, a, b, c)) return; + if (!this.tryAxis(this.localAxisX, a, b, c)) return; + if (!this.tryAxis(this.localAxisY, a, b, c)) return; + if (!this.tryAxis(this.localAxisZ, a, b, c)) return; + this.edge.subVectors(b, a); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + this.edge.subVectors(c, b); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + this.edge.subVectors(a, c); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + + const radius = + this.bodyHalf.x * Math.abs(this.triN.dot(this.localAxisX)) + + this.bodyHalf.y * Math.abs(this.triN.dot(this.localAxisY)) + + this.bodyHalf.z * Math.abs(this.triN.dot(this.localAxisZ)); + const plane = a.dot(this.triN); + const depth = plane - (this.localCenter.dot(this.triN) - radius); + if (depth <= -this.contactSkin) return; + + // 沿法线取盒子支撑点,再投影到三角得到接触 + this.support.copy(this.localCenter) + .addScaledVector(this.localAxisX, -this.bodyHalf.x * Math.sign(this.triN.dot(this.localAxisX) || 1)) + .addScaledVector(this.localAxisY, -this.bodyHalf.y * Math.sign(this.triN.dot(this.localAxisY) || 1)) + .addScaledVector(this.localAxisZ, -this.bodyHalf.z * Math.sign(this.triN.dot(this.localAxisZ) || 1)); + closestPointOnTriangle( + this.support, a, b, c, this.closest, + this.ab, this.ac, this.ap, this.bp, this.cp, + ); + this.addRawContact(this.closest, this.triN, depth, body); + } + + /** 用三角边与 OBB 轴的叉积做分离轴测试。 */ + private tryCrossAxes( + edge: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): boolean { + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisX), a, b, c)) return false; + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisY), a, b, c)) return false; + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisZ), a, b, c)) return false; + return true; + } + + /** 沿一根轴投影 OBB 与三角,重叠则通过。 */ + private tryAxis( + axis: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): boolean { + const lenSq = axis.lengthSq(); + if (lenSq < 1e-10) { + this.rangeB.max = Infinity; + return true; // 退化轴视为不分离 + } + this.testAxis.copy(axis).multiplyScalar(1 / Math.sqrt(lenSq)); + this.projectOBB(this.testAxis, this.rangeA); + const p0 = a.dot(this.testAxis); + const p1 = b.dot(this.testAxis); + const p2 = c.dot(this.testAxis); + this.rangeB.min = Math.min(p0, p1, p2); + const triMax = Math.max(p0, p1, p2); + if (this.rangeA.max + this.contactSkin < this.rangeB.min || this.rangeA.min - this.contactSkin > triMax) return false; + this.rangeB.max = Math.min(this.rangeA.max, triMax) - Math.max(this.rangeA.min, this.rangeB.min); + return true; + } + + /** 把 OBB 投影到指定轴,写出 min/max。 */ + private projectOBB(axis: THREE.Vector3, out: { min: number; max: number }): void { + const r = + this.bodyHalf.x * Math.abs(axis.dot(this.localAxisX)) + + this.bodyHalf.y * Math.abs(axis.dot(this.localAxisY)) + + this.bodyHalf.z * Math.abs(axis.dot(this.localAxisZ)); + const c = this.localCenter.dot(axis); + out.min = c - r; + out.max = c + r; + } + + /** 写入原始接触;槽满时替换最浅的一个。 */ + private addRawContact( + localPoint: THREE.Vector3, + localNormal: THREE.Vector3, + depth: number, + body: ObbMeshBody, + ): void { + let slot = this.rawCount; + if (slot === MAX_RAW) { + let shallow = 0; + for (let i = 1; i < MAX_RAW; i++) { + if (this.raw[i].penetration < this.raw[shallow].penetration) shallow = i; + } + if (depth <= this.raw[shallow].penetration) return; + slot = shallow; + } else { + this.rawCount += 1; + } + const contact = this.raw[slot]; + contact.point.copy(localPoint).applyMatrix4(this.worldMat); + contact.normal.copy(localNormal).applyMatrix3(this.normalMat).normalize(); + this.toward.subVectors(body.position, contact.point); + if (contact.normal.dot(this.toward) < 0) contact.normal.negate(); // 法线指向盒子 + contact.penetration = depth; + } + + /** 从朝向取出世界轴,并缓存半边长。 */ + private setWorldAxes(body: ObbMeshBody): void { + this.setAxes(body.quaternion, this.axisX, this.axisY, this.axisZ); + this.bodyHalf.copy(body.halfExtents); + } + + /** 由四元数写出三个正交轴。 */ + private setAxes( + quat: THREE.Quaternion, + x: THREE.Vector3, + y: THREE.Vector3, + z: THREE.Vector3, + ): void { + x.set(1, 0, 0).applyQuaternion(quat); + y.set(0, 1, 0).applyQuaternion(quat); + z.set(0, 0, 1).applyQuaternion(quat); + } + + /** 由 8 个角点计算 OBB 世界 AABB。 */ + private computeOBBWorldAABB(center: THREE.Vector3, out: THREE.Box3): void { + out.makeEmpty(); + for (let i = 0; i < 8; i++) { + this.cornerFrom( + center, this.axisX, this.axisY, this.axisZ, + (i & 1) ? 1 : -1, (i & 2) ? 1 : -1, (i & 4) ? 1 : -1, + this.corner, + ); + out.expandByPoint(this.corner); + } + out.expandByScalar(0.08); // 略放大,避免贴边漏三角 + } + + /** 按轴向符号组合出 OBB 角点。 */ + private cornerFrom( + center: THREE.Vector3, + axisX: THREE.Vector3, + axisY: THREE.Vector3, + axisZ: THREE.Vector3, + sx: number, + sy: number, + sz: number, + out: THREE.Vector3, + ): THREE.Vector3 { + return out.copy(center) + .addScaledVector(axisX, sx * this.bodyHalf.x) + .addScaledVector(axisY, sy * this.bodyHalf.y) + .addScaledVector(axisZ, sz * this.bodyHalf.z); + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts new file mode 100644 index 0000000..cf562df --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts @@ -0,0 +1,174 @@ +import * as THREE from "three"; +import { integrateQuaternion } from "./vehicleMath"; + +export type VehicleRigidBodyOptions = { + position: THREE.Vector3; // 初始世界位置 + mass: number; // 质量(kg) + halfExtents: THREE.Vector3; // 碰撞盒半边长 + linearDamping?: number; // 线阻尼 + angularDamping?: number; // 角阻尼 + gravityScale?: number; // 重力缩放 +}; + +const GRAVITY = 9.81; // 重力加速度 + +/** 车辆专用轻量刚体。 */ +export class VehicleRigidBody { + position = new THREE.Vector3(); // 世界位置 + quaternion = new THREE.Quaternion(); // 世界朝向 + + linearVelocity = new THREE.Vector3(); // 线速度 + angularVelocity = new THREE.Vector3(); // 角速度 + + private _mass: number; // 质量 + invMass: number; // 质量倒数 + + inertia = new THREE.Vector3(); // 局部惯量对角元 + invInertia = new THREE.Vector3(); // 惯量倒数 + + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + gravityScale: number; // 重力缩放 + halfExtents: THREE.Vector3; // 碰撞盒半边长 + + private _r = new THREE.Vector3(); + private _torque = new THREE.Vector3(); + private _local = new THREE.Vector3(); + private _world = new THREE.Vector3(); + private _invQuat = new THREE.Quaternion(); + + constructor(opts: VehicleRigidBodyOptions) { + this.position.copy(opts.position); + this.halfExtents = opts.halfExtents.clone(); + this._mass = Math.max(1e-6, opts.mass); + this.invMass = 1 / this._mass; + this.linearDamping = opts.linearDamping ?? 0.05; + this.angularDamping = opts.angularDamping ?? 0.5; + this.gravityScale = opts.gravityScale ?? 1; + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 按长方体质量分布设置惯量。 */ + setCuboidInertia(mass: number, half: THREE.Vector3): void { + const w = half.x * 2; + const h = half.y * 2; + const d = half.z * 2; + this.inertia.set( + mass / 12 * (h * h + d * d), + mass / 12 * (w * w + d * d), + mass / 12 * (w * w + h * h), + ); + this.invInertia.set( + this.inertia.x > 1e-8 ? 1 / this.inertia.x : 0, + this.inertia.y > 1e-8 ? 1 / this.inertia.y : 0, + this.inertia.z > 1e-8 ? 1 / this.inertia.z : 0, + ); + } + + /** 读取线速度。 */ + linvel(): THREE.Vector3 { + return this.linearVelocity; + } + + /** 读取角速度。 */ + angvel(): THREE.Vector3 { + return this.angularVelocity; + } + + /** 设置线速度。 */ + setLinvel(v: { x: number; y: number; z: number }): void { + this.linearVelocity.set(v.x, v.y, v.z); + } + + /** 设置角速度。 */ + setAngvel(v: { x: number; y: number; z: number }): void { + this.angularVelocity.set(v.x, v.y, v.z); + } + + /** 读取世界位置。 */ + translation(): THREE.Vector3 { + return this.position; + } + + /** 读取世界朝向。 */ + rotation(): THREE.Quaternion { + return this.quaternion; + } + + /** 设置世界位置。 */ + setTranslation(v: { x: number; y: number; z: number }): void { + this.position.set(v.x, v.y, v.z); + } + + /** 设置世界朝向。 */ + setRotation(q: { x: number; y: number; z: number; w: number }): void { + this.quaternion.set(q.x, q.y, q.z, q.w).normalize(); + } + + /** 读取质量。 */ + mass(): number { + return this._mass; + } + + /** 设置质量并按当前碰撞盒重算惯量。 */ + setMass(mass: number): void { + this._mass = Math.max(1e-6, mass); + this.invMass = 1 / this._mass; + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 设置碰撞盒半边长并按当前质量重算惯量。 */ + setHalfExtents(half: THREE.Vector3): void { + this.halfExtents.copy(half); + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 在质心施加冲量。 */ + applyImpulse(impulse: THREE.Vector3): void { + this.linearVelocity.addScaledVector(impulse, this.invMass); + } + + /** 在世界点施加冲量,同时产生角速度。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, contactPoint: THREE.Vector3): void { + this.linearVelocity.addScaledVector(impulse, this.invMass); + this._r.subVectors(contactPoint, this.position); + this.angularVelocity.add(this.angularDeltaFromImpulse(impulse, this._r)); + } + + // 将冲量转为世界角速度增量:ω += R I⁻¹ Rᵀ (r × J) + private angularDeltaFromImpulse(impulse: THREE.Vector3, r: THREE.Vector3): THREE.Vector3 { + this._torque.copy(r).cross(impulse); + this._invQuat.copy(this.quaternion).invert(); + this._local.copy(this._torque).applyQuaternion(this._invQuat); + this._local.x *= this.invInertia.x; + this._local.y *= this.invInertia.y; + this._local.z *= this.invInertia.z; + return this._world.copy(this._local).applyQuaternion(this.quaternion); + } + + /** 读取世界点处的刚体速度。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + this._r.subVectors(point, this.position); + return out.copy(this.linearVelocity).add(this._world.copy(this.angularVelocity).cross(this._r)); // v + ω × r + } + + /** 施加重力。 */ + applyGravity(dt: number): void { + this.linearVelocity.y -= GRAVITY * this.gravityScale * dt; + } + + /** 施加线/角阻尼。 */ + applyDamping(dt: number): void { + // 指数阻尼,与步长无关的衰减 + const lin = Math.exp(-this.linearDamping * dt); + const ang = Math.exp(-this.angularDamping * dt); + this.linearVelocity.multiplyScalar(lin); + this.angularVelocity.multiplyScalar(ang); + } + + /** 用当前速度积分位置和朝向。 */ + integrate(dt: number): void { + this.position.addScaledVector(this.linearVelocity, dt); + integrateQuaternion(this.quaternion, this.angularVelocity, dt); + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts new file mode 100644 index 0000000..ead1181 --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts @@ -0,0 +1,48 @@ +import * as THREE from "three"; + +/** 单个车轮的悬挂、接地与轮胎力状态。 */ +export class VehicleWheel { + connectionPoint = new THREE.Vector3(); // 悬挂与底盘的局部连接点 + direction = new THREE.Vector3(0, -1, 0); // 悬挂方向(底盘局部) + axle = new THREE.Vector3(0, 0, -1); // 轮轴方向(底盘局部) + + radius = 0.3; // 轮胎半径 + restLength = 0.1; // 悬挂静止长度 + suspensionLength = 0.1; // 当前悬挂长度(夹在行程内) + visualLength = 0.1; // 视觉悬挂长度,跟射线 + maxSuspensionTravel = 0.1; // 最大行程 + + stiffness = 18; // 悬挂刚度(质量归一化) + dampingCompression = 2.1; // 压缩阻尼 + dampingRelaxation = 2.5; // 回弹阻尼 + maxSuspensionForce = 6000; // 最大悬挂力 + + steering = 0; // 转向角 + engineForce = 0; // 驱动力 + brake = 0; // 制动力 + + frictionSlip = 8; // 纵向抓地 + sideFrictionStiffness = 1; // 侧向摩擦 + rollInfluence = 0.12; // 侧向力对侧倾的影响 + + isInContact = false; // 是否接地 + contactPoint = new THREE.Vector3(); // 接地点 + contactNormal = new THREE.Vector3(0, 1, 0); // 接触法线 + contactMesh: THREE.Mesh | null = null; // 本帧接地网格 + + suspensionForce = 0; // 本帧悬挂力 + forwardImpulse = 0; // 纵向冲量 + sideImpulse = 0; // 侧向冲量 + rotation = 0; // 车轮自转角 + deltaRotation = 0; // 本帧自转增量 + + hardPointWS = new THREE.Vector3(); // 连接点世界坐标 + directionWS = new THREE.Vector3(0, -1, 0); // 悬挂方向(世界) + axleWS = new THREE.Vector3(0, 0, -1); // 轮轴(世界) + forwardWS = new THREE.Vector3(1, 0, 0); // 滚动前向(世界) + sideWS = new THREE.Vector3(0, 0, 1); // 侧向(世界) + + clippedInvContactDotSuspension = 1; // 法线与悬挂方向夹角修正 + suspensionRelativeVelocity = 0; // 沿悬挂方向的相对速度 + skidInfo = 1; // 打滑系数,1 为未打滑 +} diff --git a/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts b/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts new file mode 100644 index 0000000..27d195c --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts @@ -0,0 +1,115 @@ +import * as THREE from "three"; + +const EPS = 1e-8; // 有效质量分母下限 + +/** 冲量公式所需的刚体字段。 */ +type RigidBodyMassProps = { + position: THREE.Vector3; + quaternion: THREE.Quaternion; + invMass: number; + invInertia: THREE.Vector3; +}; + +/** 冲量分母:invMass + n · (I⁻¹ (r × n) × r) */ +export function impulseDenominator( + body: RigidBodyMassProps, + point: THREE.Vector3, + normal: THREE.Vector3, + r: THREE.Vector3, + gcross: THREE.Vector3, + local: THREE.Vector3, + world: THREE.Vector3, + invQuat: THREE.Quaternion, +): number { + r.subVectors(point, body.position); + gcross.copy(r).cross(normal); + // 力矩变到局部惯量,再变回世界:I⁻¹ (r × n) + invQuat.copy(body.quaternion).invert(); + local.copy(gcross).applyQuaternion(invQuat); + local.x *= body.invInertia.x; + local.y *= body.invInertia.y; + local.z *= body.invInertia.z; + world.copy(local).applyQuaternion(body.quaternion); + world.cross(r); + return body.invMass + normal.dot(world); +} + +/** 沿方向约束的有效质量(冲量分母的倒数)。 */ +export function effectiveMass( + body: RigidBodyMassProps, + point: THREE.Vector3, + normal: THREE.Vector3, + r: THREE.Vector3, + gcross: THREE.Vector3, + local: THREE.Vector3, + world: THREE.Vector3, + invQuat: THREE.Quaternion, +): number { + const denom = impulseDenominator(body, point, normal, r, gcross, local, world, invQuat); + return denom > EPS ? 1 / denom : 0; +} + +/** 用世界角速度积分四元数:q += 0.5 * ω * q * dt */ +export function integrateQuaternion(q: THREE.Quaternion, omega: THREE.Vector3, dt: number): void { + const halfDt = 0.5 * dt; + const qx = q.x, qy = q.y, qz = q.z, qw = q.w; + const wx = omega.x, wy = omega.y, wz = omega.z; + q.x += halfDt * (wx * qw + wy * qz - wz * qy); + q.y += halfDt * (wy * qw + wz * qx - wx * qz); + q.z += halfDt * (wz * qw + wx * qy - wy * qx); + q.w += halfDt * (-wx * qx - wy * qy - wz * qz); + q.normalize(); +} + +/** 三角形上距点 p 最近的点。 */ +export function closestPointOnTriangle( + p: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + out: THREE.Vector3, + ab: THREE.Vector3, + ac: THREE.Vector3, + ap: THREE.Vector3, + bp: THREE.Vector3, + cp: THREE.Vector3, +): THREE.Vector3 { + ab.subVectors(b, a); + ac.subVectors(c, a); + ap.subVectors(p, a); + + const d1 = ab.dot(ap); + const d2 = ac.dot(ap); + if (d1 <= 0 && d2 <= 0) return out.copy(a); // 顶点 A + + bp.subVectors(p, b); + const d3 = ab.dot(bp); + const d4 = ac.dot(bp); + if (d3 >= 0 && d4 <= d3) return out.copy(b); // 顶点 B + + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + return out.copy(a).addScaledVector(ab, v); // 边 AB + } + + cp.subVectors(p, c); + const d5 = ab.dot(cp); + const d6 = ac.dot(cp); + if (d6 >= 0 && d5 <= d6) return out.copy(c); // 顶点 C + + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return out.copy(a).addScaledVector(ac, w); // 边 AC + } + + const va = d3 * d6 - d5 * d4; + if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) { + const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return out.copy(b).addScaledVector(ab.subVectors(c, b), w); // 边 BC + } + + const denom = 1 / (va + vb + vc); + return out.copy(a).addScaledVector(ab, vb * denom).addScaledVector(ac, vc * denom); // 面内 +} diff --git a/three-player-controller/tsconfig.json b/three-player-controller/tsconfig.json new file mode 100644 index 0000000..ec34ad1 --- /dev/null +++ b/three-player-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2020", + "declaration": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/three-player-controller/tsup.config.ts b/three-player-controller/tsup.config.ts new file mode 100644 index 0000000..5095176 --- /dev/null +++ b/three-player-controller/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + "foot-ik": "src/foot-ik.ts", + }, + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + external: [ + "three", + "three-mesh-bvh", + "three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js", + ], + target: "es2020", + minify: false, +}); diff --git a/three-player-controller/vite.config.ts b/three-player-controller/vite.config.ts new file mode 100644 index 0000000..bac8922 --- /dev/null +++ b/three-player-controller/vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from "path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "/three-player-controller/", + root: resolve(__dirname, "example"), + server: { host: true }, + optimizeDeps: { + exclude: ["tellux", "three-mesh-bvh"], + }, + build: { + outDir: resolve(__dirname, "docs"), + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(__dirname, "example", "index.html"), + gltf: resolve(__dirname, "example", "glTF.html"), + tiles: resolve(__dirname, "example", "3dtilesScene.html"), + dgs: resolve(__dirname, "example", "3dgs.html"), + shooting: resolve(__dirname, "example", "shooting", "shooting.html"), + footik: resolve(__dirname, "example", "footIK.html"), + showcase: resolve(__dirname, "example", "showcase.html"), + }, + }, + }, +}); diff --git a/tools/assets/places.mjs b/tools/assets/places.mjs index 1c8f008..7aa33cf 100644 --- a/tools/assets/places.mjs +++ b/tools/assets/places.mjs @@ -29,19 +29,24 @@ export const NEAR_MATERIALS = new Set([ "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", "contHardware", "ironPlain", "ironTeal", "ironBlue", "osb", "osbB", "rollershutter_window_01", "rollershutter_window_01_graffiti", "chainlink_post", "chainlink_wire", + // Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale", ]); /** - * Anteil der Dreiecke, der je Material bleibt; Container, Schuppen, Zaun und Boden fehlen hier und - * bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume haben zusammen - * 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, quiver_tree_01_trunk - * 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. + * Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit + * Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume + * haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, + * quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben + * Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298. */ export const SIMPLIFY_RATIOS = new Map([ ["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15], ["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5], ["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5], ["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25], + ["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25], + ["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25], ]); // Anteil am Mesh-Radius. Nach join umspannt ein Pflanzenmaterial den ganzen Hof (~17 m Radius), die // Grenze liegt dann bei ~17 cm und greift kaum — die Anteile oben steuern (am Modell gemessen: 0,002