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/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]
+[](https://github.com/hh-hang/cesium-player-controller)
+
+基于 three.js 的轻量级第一 / 第三人称玩家控制器。提供角色胶囊体碰撞、动画、相机避障、移动平台、动态刚体、车辆控制和 Foot IK,并使用 `three-mesh-bvh` 加速复杂场景中的碰撞查询。
+
+## 功能
+
+- 第一 / 第三人称切换
+- 行走、奔跑、跳跃、飞行
+- 角色胶囊体碰撞与地面检测
+- 相机避障、过肩视角、弹簧相机
+- 静态 / 运动学 / 动态碰撞体
+- 动态球体、盒体及刚体碰撞
+- BVH Worker 异步构建
+- 3D Tiles 流式碰撞体构建
+- 内置车辆物理、悬挂、转向、抓地和手刹
+- Foot IK 插件
+- 自定义动画与 Locomotion Set
+- 移动端虚拟摇杆与按钮
+- 自定义键位与外部输入
+
+# 示例
+
+[](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]
+[](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
+
+[](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
+
+
+
+
+
+ Shift
+ Boost
+
+
+ Space
+ Jump
+
+
+ V
+ Toggle View
+
+
+ F
+ Toggle Fly
+
+
+ Z
+ Scale Character
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+ Shift
+ Sprint
+
+
+ Space
+ Jump
+
+
+ V
+ Toggle View
+
+
+ F
+ Toggle Fly
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ W
+ A
+ S
+ D
+
+
Move
+
+
+
+
+
+ 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
+
+
+
+
+
+ Shift
+ Sprint
+
+
+ Space
+ Jump
+
+
+ E
+ Enter
+
+
+ V
+ Toggle View
+
+
+ F
+ Toggle Fly
+
+
+
+
+
+
+
+ W
+ A
+ S
+ D
+
+
Drive
+
+
+
+
+
+ Shift
+ Drift
+
+
+ Space
+ Brake
+
+
+ E
+ Exit
+
+
+ V
+ Toggle View
+
+
+ R
+ Reset Vehicle
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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-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
+
+
+
`;
+ 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
+
+
+
+
+
+ Shift
+ Sprint
+
+
+ Space
+ Jump
+
+
+ V
+ Toggle View
+
+
+ 14
+ Switch Weapon / Scroll
+
+
+ RMB
+ Aim (Hold)
+
+
+ LMB
+ Shoot
+
+
+ R
+ Reload
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AMMO
+ 30
+ /
+ 300
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ W
+ A
+ S
+ D
+
+
Move
+
+
+
+
+
+ Shift
+ Sprint
+
+
+ Space
+ Jump
+
+
+ E
+ Enter
+
+
+ V
+ Toggle View
+
+
+ F
+ Toggle Fly
+
+
+
+
+
+
+
+ W
+ A
+ S
+ D
+
+
Drive
+
+
+
+
+
+ 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"),
+ },
+ },
+ },
+});