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] +[![CesiumJS](https://img.shields.io/badge/CesiumJS-player_controller-blue)](https://github.com/hh-hang/cesium-player-controller) + +基于 three.js 的轻量级第一 / 第三人称玩家控制器。提供角色胶囊体碰撞、动画、相机避障、移动平台、动态刚体、车辆控制和 Foot IK,并使用 `three-mesh-bvh` 加速复杂场景中的碰撞查询。 + +## 功能 + +- 第一 / 第三人称切换 +- 行走、奔跑、跳跃、飞行 +- 角色胶囊体碰撞与地面检测 +- 相机避障、过肩视角、弹簧相机 +- 静态 / 运动学 / 动态碰撞体 +- 动态球体、盒体及刚体碰撞 +- BVH Worker 异步构建 +- 3D Tiles 流式碰撞体构建 +- 内置车辆物理、悬挂、转向、抓地和手刹 +- Foot IK 插件 +- 自定义动画与 Locomotion Set +- 移动端虚拟摇杆与按钮 +- 自定义键位与外部输入 + +# 示例 + +[![在线示例](https://github.com/hh-hang/three-player-controller/blob/master/example/public/img/readme/preview.png)](https://hh-hang.github.io/three-player-controller/index.html) + +- [Showcase](https://hh-hang.github.io/three-player-controller/showcase.html) +- [Foot IK](https://hh-hang.github.io/three-player-controller/footIK.html) +- [FPS Game](https://hh-hang.github.io/three-player-controller/shooting/shooting.html) +- [glTF Scene](https://hh-hang.github.io/three-player-controller/glTF.html) +- [3DTiles Scene](https://hh-hang.github.io/three-player-controller/3dtilesScene.html) +- [3DGS Scene](https://hh-hang.github.io/three-player-controller/3dgs.html) + +# 安装 + +```bash +npm install three-player-controller three-mesh-bvh +``` + +# 本地运行 + +```bash +git clone https://github.com/hh-hang/three-player-controller.git +cd three-player-controller +npm install +npm run dev +``` + +浏览器访问 `http://localhost:5173/three-player-controller/`。 + +# 快速开始 + +```ts +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { playerController } from "three-player-controller"; + +const scene = new THREE.Scene(); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 1000, +); + +const renderer = new THREE.WebGLRenderer(); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); + +// 加载人物模型 +const loader = new GLTFLoader(); +const playerGltf = await loader.loadAsync("./glb/player.glb"); + +// 创建玩家控制器 +const player = new playerController(); + +// 人物控制初始化 +await player.init({ + scene, // three.js 场景实例 + camera, // three.js 相机实例 + controls, // 外部相机控制器 + + // 人物模型、动画与移动参数 + playerModelConfig: { + model: playerGltf.scene, // 外部加载后的模型根节点 + animations: playerGltf.animations, // 动画片段 + scale: 0.001, // 模型缩放 + idleAnim: "idle", // 静止动画名 + walkAnim: "walk", // 行走动画名 + runAnim: "run", // 跑步动画名 + jumpAnim: "jump", // 跳跃动画名;或传 ["起跳", "循环", "落地"] 分三段播放 + }, + + // 人物初始出生点 + initPos: new THREE.Vector3(0, 0, 0), + + // 初始化时注册的碰撞体 + colliders: [ + { + motion: "static", // 静态碰撞体 + shape: { // 碰撞形状配置 + kind: "mesh", // 使用 Mesh 作为碰撞形状 + source: scene, // 从该对象收集几何并自动构建 BVH + }, + }, + ], +}); + +function animate() { + requestAnimationFrame(animate); + player.update(); + renderer.render(scene, camera); +} + +animate(); +``` + +> `player.update()` 内部已经接管传入的相机控制器。渲染循环中不要再次调用 `controls.update()`,否则可能与内部相机逻辑冲突。 + +### 完整参数示例 + +#### `init()` + +```ts +await player.init({ + // 必填 + scene, // three.js 场景实例 + camera, // three.js 相机实例 + controls, // 外部相机控制器,通常为 OrbitControls + + playerModelConfig: { // 人物模型、动画与移动参数 + model: playerGltf.scene, // 外部加载后的模型根节点 + animations: playerGltf.animations, // 动画片段 + scale: 0.001, // 模型缩放 + idleAnim: "idle", // 静止动画名 + walkAnim: "walk", // 行走动画名 + runAnim: "run", // 跑步动画名 + jumpAnim: "jump", // 跳跃动画名;或传 ["起跳", "循环", "落地"] 分三段播放 + + // 方向 / 飞行动画(可选,不填则复用对应默认动画) + leftWalkAnim: "leftWalk", // 左移动画,默认复用 walkAnim + rightWalkAnim: "rightWalk", // 右移动画,默认复用 walkAnim + backwardAnim: "walkBack", // 后退动画,默认复用 walkAnim + flyAnim: "fly", // 飞行动画,默认复用 idleAnim + flyIdleAnim: "flyIdle", // 飞行待机动画,默认复用 idleAnim + flyHoverForwardAnim: "flyFwd", // 飞行前进悬停动画,默认复用 flyAnim + flyHoverBackAnim: "flyBack", // 飞行后退悬停动画,默认复用 flyIdleAnim + flyHoverLeftAnim: "flyLeft", // 飞行左移悬停动画,默认复用 flyIdleAnim + flyHoverRightAnim: "flyRight", // 飞行右移悬停动画,默认复用 flyIdleAnim + flyHoverUpAnim: "flyUp", // 飞行上升悬停动画,默认复用 flyIdleAnim + flyHoverDownAnim: "flyDown", // 飞行下降悬停动画,默认复用 flyIdleAnim + drivingAnim: "driving", // 驾驶循环动画,默认复用 idleAnim + + // 物理参数(可选) + gravity: -2400, // 重力基准值,会按 scale 缩放,默认 -2400 + jumpHeight: 600, // 跳跃高度基准值,会按 scale 缩放,默认 600 + speed: 200, // 移动速度基准值,会按 scale 缩放,默认 200 + runSpeed: 600, // 跑步速度基准值,会按 scale 缩放,默认 600 + flySpeed: 2100, // 飞行速度基准值,会按 scale 缩放,默认 2100 + acceleration: 30, // XZ 加速响应速度,默认 30 + deceleration: 30, // XZ 减速响应速度,默认 30 + + // 模型参数(可选) + rotateY: 0, // 人物初始朝向(弧度),默认 0 + headBoneName: "Head", // 头部骨骼名,用于第一人称相机挂载 + firstPersonCameraOffset: [0, 40, 30], // 第一人称相机局部偏移 + capsuleRadiusRatio: 1, // 胶囊体半径倍率,默认 1 + }, + + // 场景与碰撞(可选) + initPos: new THREE.Vector3(0, 0, 0), // 初始出生点,默认 (0, 0, 0) + colliders: [ // 初始化时批量创建的碰撞体;不传则可在 init() 后调用 addCollider() + { + motion: "static", // 碰撞体运动类型:static / kinematic / dynamic + shape: { // 碰撞形状配置 + kind: "mesh", // Mesh 碰撞形状 + source: world, // 碰撞来源,可传 THREE.Object3D 或 THREE.Object3D[] + }, + useWorker: false, // 是否使用 Worker 异步构建 BVH + }, + ], + + // 相机(可选) + minCamDistance: 100, // 第三人称最小镜头距离,默认 100 + maxCamDistance: 440, // 第三人称最大镜头距离,默认 440 + camLookAtHeightRatio: 0.8, // 相机看向点高度比例,0=底部,1=顶部,默认 0.8 + thirdMouseMode: 1, // 第三人称鼠标控制模式 0-5,默认 1 + enableZoom: false, // 是否允许滚轮缩放,默认 false + enableOverShoulderView: false, // 是否启用过肩视角,默认 false + camOverShoulderOffsetRatio: 0.2, // 过肩视角横向偏移比例,默认 0.2 + isFirstPerson: false, // 初始是否进入第一人称,默认 false + enableSpringCamera: false, // 是否启用弹簧相机,默认 false + springCameraTime: 0.05, // 弹簧相机平滑时间(秒),默认 0.05 + + // 其他(可选) + mouseSensitivity: 5, // 鼠标灵敏度,默认 5 + timeScale: 1, // 时间缩放系数,<1 慢动作,>1 快进,默认 1 + + keyMap: { // 自定义键位;可传单键、数组多绑或 null 禁用 + forward: ["KeyW", "ArrowUp"], // 前进 + backward: ["KeyS", "ArrowDown"], // 后退 + left: ["KeyA", "ArrowLeft"], // 左移 + right: ["KeyD", "ArrowRight"], // 右移 + sprint: ["ShiftLeft", "ShiftRight"], // 冲刺;开车时为手刹 + jump: ["Space"], // 跳跃;开车时为刹车 + toggleView: ["KeyV"], // 切换第一 / 第三人称 + toggleFly: ["KeyF"], // 切换飞行模式 + toggleVehicle: ["KeyE"], // 上 / 下车 + }, + + isShowMobileControls: true, // 移动端是否显示虚拟控制 UI,默认 true + mobileControls: { // 移动端按钮显隐、图标与布局配置 + joystick: true, // 是否显示虚拟摇杆,默认 true + jump: { // 跳跃 / 刹车按钮;传 false 可隐藏 + icon: "/icons/custom-jump.svg", // 跳跃状态自定义图片 URL + brakeIcon: "/icons/custom-brake.svg", // 车辆模式下刹车状态自定义图片 URL + right: 24, // 距离右侧位置(px) + bottom: 32, // 距离底部位置(px) + size: 64, // 按钮直径(px),默认 56 + }, + fly: true, // 飞行按钮;true 使用默认样式,false 隐藏 + view: true, // 视角切换按钮;true 使用默认样式,false 隐藏 + vehicle: true, // 上 / 下车按钮;true 使用默认样式,false 隐藏 + }, +}); +``` + + +# 碰撞系统 + +使用统一的 `CollisionWorld` 和 `addCollider()` API。 + +碰撞体由两个核心属性描述: + +```ts +{ + motion: "static" | "kinematic" | "dynamic", + shape: { + kind: "mesh" | "box" | "sphere", + // ... + } +} +``` + +## 静态 Mesh + +适用于地形、建筑、墙体和不会移动的场景模型。 + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { + kind: "mesh", + source: world, + }, + useWorker: true, +}); +``` + +## 运动学 Mesh + +适用于电梯、移动平台、旋转平台、车辆外观或其它由业务代码控制变换的物体。 + +```ts +const platformCollider = player.addCollider({ + motion: "kinematic", + shape: { + kind: "mesh", + source: platform, + }, + follow: platform, + useWorker: true, +}); +``` + +碰撞体会在每帧跟随 `follow.matrixWorld`,运行时如果只需要调整已经烘焙的运动学碰撞几何,可以避免重建 BVH。 + +## 动态刚体 + +动态 `box` 和 `sphere` 会自动创建内置刚体,由 `DynamicBodySystem` 进行重力、阻尼、接触、冲量和休眠计算。 + +### 动态盒体 + +```ts +const box = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial(), +); +scene.add(box); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: new THREE.Vector3(0.5, 0.5, 0.5), + position: new THREE.Vector3(0, 5, 0), + }, + mesh: box, +}); +``` + +### 动态球体 + +```ts +const sphere = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshStandardMaterial(), +); +scene.add(sphere); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(0, 5, 0), + }, + mesh: sphere, +}); +``` + +动态刚体支持与静态 Mesh、运动学 Mesh、人物、车辆以及其它动态刚体发生碰撞。 + +## 移除与清理 + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: object }, +}); + +player.removeCollider(handle); + +// 只清除静态碰撞体 +player.clearColliders({ motion: "static" }); + +// 清除全部碰撞体 +player.clearColliders(); +``` + +## 碰撞分组 + +```ts +import { CollisionGroup } from "three-player-controller"; +``` + +| 分组 | 说明 | +| --- | --- | +| `CollisionGroup.DEFAULT` | 默认场景碰撞。 | +| `CollisionGroup.CHARACTER` | 玩家角色碰撞组。 | +| `CollisionGroup.VEHICLE` | 车辆碰撞组。 | +| `CollisionGroup.DEBRIS` | 动态杂物、可推动物体等碰撞组。 | +| `CollisionGroup.ALL` | 所有碰撞组的组合,表示允许与全部分组发生碰撞。 | + +默认情况下无需设置,会根据 `motion` 自动使用对应的碰撞组和掩码;也可以通过 `groups` 和 `mask` 自定义碰撞过滤: + +```ts +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(), + }, + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE, +}); +``` + +# 3D Tiles 流式碰撞 + +对于 3D Tiles,不建议把整个 Tileset 一次性合并成一个碰撞体。 + +`example/3dtilesScene.js` 使用流式策略: + +1. 监听瓦片加载、卸载和可见性变化。 +2. 只跟踪当前已加载瓦片。 +3. 只为人物附近且可见的瓦片创建碰撞体。 +4. 使用 `useWorker: true` 异步构建 BVH。 +5. 瓦片隐藏、卸载或距离过远时立即 `removeCollider()`。 +6. 使用进入 / 退出双半径,避免边界附近频繁创建和销毁。 + +核心形式: + +```ts +const handle = player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { + kind: "mesh", + source: tileScene, + }, + follow: tileScene, +}); + +// 瓦片卸载 / 不可见 / 距离过远时移除碰撞体 +player.removeCollider(handle); +``` + +示例同时演示了地球级大坐标场景中的局部坐标系和 rebase:人物距离局部原点过远时更新局部坐标系,并同步角色、相机和速度,避免大坐标浮点精度问题。 + +完整实现见 [`example/3dtilesScene.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js)。 + +# 车辆 + +```ts +const vehicleGltf = await loader.loadAsync("./glb/vehicle.glb"); // 外部加载车辆模型 + +const vehicle = await player.loadVehicleModel({ + // 必填 + model: vehicleGltf.scene, // 外部加载后的车辆模型根节点 + position: new THREE.Vector3(0, 0, 0), // 车辆初始世界坐标 + wheelsNames: [ // 车轮节点名,顺序:左前、右前、左后、右后 + "Wheel_LF", // 左前轮节点名 + "Wheel_RF", // 右前轮节点名 + "Wheel_LR", // 左后轮节点名 + "Wheel_RR", // 右后轮节点名 + ], + driverSeatPosition: new THREE.Vector3(-0.6, 0.7, 0.4), // 驾驶位胶囊中心,使用底盘局部坐标 + + // 模型 / 驾驶(可选) + scale: 0.1, // 车辆模型缩放,默认 1 + driverSeatRotation: 0, // 驾驶位相对底盘局部的水平旋转(弧度),默认 0 + modelRotation: -Math.PI / 2, // 车辆模型绕 Y 轴旋转(弧度),默认 -π/2 + followVehicleDirection: true, // 驾驶时镜头是否跟随车辆朝向,默认 true + + // 调试(可选) + debug: { // 车辆物理调试显示配置 + showPhysicsBox: false, // 是否显示底盘物理碰撞盒,默认 false + showWheelRays: false, // 是否显示车轮悬挂射线,默认 false + showWheelTravel: false, // 是否显示车轮 Y 向活动范围,默认 false + showWheelSpheres: false, // 是否显示车轮动态碰撞球,默认 false + }, + + // 底盘(可选) + chassis: { // 底盘碰撞盒与阻尼参数 + density: 1, // 底盘密度,质量根据碰撞盒体积与密度计算,默认 1 + linearDamping: 0.05, // 线阻尼,默认 0.05 + angularDamping: 0.5, // 角阻尼,默认 0.5 + clearance: 0.15, // 底盘盒底相对轮胎触地点高度,不传时自动计算 + sizeScale: { // 相对自动 AABB 的底盘碰撞盒尺寸倍率 + x: 1, // X 轴尺寸倍率,默认 1 + y: 1, // Y 轴尺寸倍率,默认 1 + z: 1, // Z 轴尺寸倍率,默认 1 + }, + }, + + // 悬挂 / 轮胎(可选) + suspension: { // 悬挂与轮胎参数 + restLength: 0.3, // 悬挂静止长度,不传时自动计算 + maxTravel: 0.35, // 悬挂最大行程,默认 0.35 + stiffness: 18, // 悬挂刚度,默认 18 + compression: 2.1, // 悬挂压缩阻尼,默认 2.1 + relaxation: 2.5, // 悬挂回弹阻尼,默认 2.5 + maxForce: 6000, // 单轮最大悬挂力,默认 6000 + frictionSlip: 8, // 轮胎纵向抓地,默认 8 + sideFrictionStiffness: 1, // 轮胎侧向摩擦刚度,默认 1 + rollInfluence: 0.12, // 侧向力对车身侧倾的影响,默认 0.12 + }, + + // 转向(可选) + steering: { // 转向参数 + maxSteerAngle: Math.PI / 5, // 最大转向角(弧度),默认 π/5 + steerTime: 0.45, // 从中位打到满舵所需时间(秒),默认 0.45 + steerReturnTimeSlow: 0.55, // 低速回正时间(秒),默认 0.55 + steerReturnTimeFast: 0.4, // 高速回正时间(秒),默认 0.4 + highSpeedSteerScale: 0.3, // 最高车速时最大转向比例,默认 0.3 + }, + + // 抓地 / 手刹(可选) + grip: { // 过弯抓地与手刹参数 + maxG: 1.2, // 最大侧向加速度(g),默认 1.2 + sideFrictionIdle: 1, // 直线行驶时侧向摩擦,默认 1 + sideFrictionFrontMin: 0.55, // 高负载下前轮侧向摩擦下限,默认 0.55 + sideFrictionRearMin: 0.45, // 高负载下后轮侧向摩擦下限,默认 0.45 + handbrakeRearFriction: 0.35, // 手刹时后轮侧向摩擦,默认 0.35 + handbrakeRearDriveScale: 0.65, // 手刹时后轮驱动力比例,默认 0.65 + handbrakeReleaseTime: 0.15, // 松开手刹后的恢复时间(秒),默认 0.15 + wheelbaseRatio: 0.55, // 轴距相对车长比例,默认 0.55 + }, + + // 动力(可选) + power: { // 车辆动力参数 + maxSpeed: 100, // 最高速度基准值(km/h),会按 scale 缩放,默认 100 + acceleration: 5, // 加速度基准值(m/s²),会按 scale 缩放,默认 5 + deceleration: 5, // 制动减速度基准值(m/s²),会按 scale 缩放,默认 5 + }, +}); +``` + +运行时: + +```ts +player.resetVehicle(); // 翻正当前驾驶车辆,并清零线速度和角速度 +player.setVehicleScale(vehicle, 0.12); // 动态修改车辆整体缩放 +player.setVehicleClearance(vehicle, 0.15); // 调整底盘碰撞盒离地间隙 +player.setVehicleChassisSizeScale(vehicle, { // 调整底盘碰撞盒相对自动 AABB 的尺寸倍率 + x: 1, // X 轴尺寸倍率 + y: 0.9, // Y 轴尺寸倍率 + z: 1, // Z 轴尺寸倍率 +}); +player.setVehiclePhysicsDebug(true); // 显示 / 隐藏车辆底盘物理碰撞盒 +``` + +# Foot IK 插件 + +```ts +import { FootIK } from "three-player-controller/foot-ik"; + +const footIK = new FootIK({ + // 手动指定人物骨骼映射;不传时会尝试自动匹配常见骨骼名 + skeleton: { + hips: "mixamorigHips", // 髋部 / 骨盆骨骼名 + + // 左右腿骨骼配置 + legs: { + left: { + upper: "mixamorigLeftUpLeg", // 左大腿骨骼 + lower: "mixamorigLeftLeg", // 左小腿骨骼 + foot: "mixamorigLeftFoot", // 左脚骨骼 + toe: "mixamorigLeftToeBase", // 左脚趾骨骼 + }, + right: { + upper: "mixamorigRightUpLeg", // 右大腿骨骼 + lower: "mixamorigRightLeg", // 右小腿骨骼 + foot: "mixamorigRightFoot", // 右脚骨骼 + toe: "mixamorigRightToeBase", // 右脚趾骨骼 + }, + }, + }, + + soleSkinThickness: 3, // 脚骨到鞋底的蒙皮厚度补偿 +}); + +player.use(footIK); // 将 Foot IK 插件注册到玩家控制器 +``` + +运行时: + +```ts +footIK.setEnabled(false); // 启用 / 禁用 Foot IK +footIK.setDebugEnabled(true); // 显示 / 隐藏 Foot IK 调试辅助 +player.unuse(footIK); // 从玩家控制器中卸载 Foot IK 插件 +footIK.dispose(); // 释放 Foot IK 创建的调试对象和相关资源 +``` + +`player.destroy()` 会自动销毁已注册插件。 + +完整示例见 [`example/footIK.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js)。 + +# API + +## 生命周期与核心 + +| 方法 | 说明 | +| --- | --- | +| `init(opts, callback?)` | 初始化控制器。 | +| `update(dt?)` | 每帧更新人物、碰撞、动态刚体、车辆、相机和动画。 | +| `destroy()` | 销毁控制器并释放资源。 | +| `reset(pos?)` | 将角色重置到指定位置或初始位置。 | +| `switchPlayerModel(options)` | 使用 `PlayerModelOptions` 运行时切换角色模型。 | +| `use(plugin)` | 注册插件。 | +| `unuse(plugin)` | 卸载插件。 | +| `getPlugins()` | 获取当前插件列表副本。 | + +## 碰撞 + +| 方法 | 说明 | +| --- | --- | +| `addCollider(desc)` | 添加静态 / 运动学 / 动态碰撞体,返回 `ColliderHandle`。 | +| `removeCollider(handle)` | 按句柄或 id 移除碰撞体。 | +| `clearColliders(filter?)` | 清除碰撞体,可按 `motion` 过滤。 | +| `getColliderMeshes(options?)` | 获取当前人物查询使用的 Mesh 碰撞体。 | +| `isStaticColliderUsable()` | 是否存在已经完成 BVH 构建的静态 Mesh。 | +| `getActiveKinematicCollider()` | 获取当前角色站立的运动学碰撞体。 | +| `getActiveDynamicBody()` | 获取当前角色站立的动态刚体。 | +| `getDynamicBodies()` | 获取全部动态刚体。 | +| `removeDynamicBody(body)` | 移除动态刚体。 | +| `clearDynamicBodies()` | 清除全部动态刚体。 | +| `raycastDynamicGround(...)` | 向下查询动态刚体表面。 | + +## 状态 + +| 方法 | 返回内容 | +| --- | --- | +| `getPosition()` | 当前角色位置。 | +| `getVelocity()` | 当前角色速度。 | +| `getIsFirstPerson()` | 是否第一人称。 | +| `getIsFlying()` | 是否飞行。 | +| `getIsOnGround()` | 是否在地面。 | +| `getGroundSupport()` | 当前最终地面支撑点和世界空间法线。 | +| `getCurrentDelta()` | 当前帧实际使用的时间步长。 | +| `getControllerMode()` | `0` 人物,`1` 车辆。 | +| `getPlayerModel()` | 当前人物模型。 | +| `getPlayerCapsule()` | 玩家胶囊体。 | +| `getActiveVehicle()` | 当前驾驶车辆。 | +| `getAllVehicles()` | 所有已加载车辆。 | +| `getCurrentPlayerAnimationName()` | 当前动画片段名。 | +| `getCurrentLocomotionSet()` | 当前 Locomotion Set。 | +| `getCenterScreenRaycastHit()` | 屏幕中心射线结果。 | + +## 运行时控制 + +| 方法 | 说明 | +| --- | --- | +| `setInput(input)` | 注入外部输入。 | +| `setKeyMap(map?)` | 自定义或恢复默认键位。 | +| `setMouseSensitivity(v)` | 设置鼠标灵敏度。 | +| `setPlayerScale(v)` | 动态修改人物缩放。 | +| `setPlayerSpeed(v)` | 设置行走速度。 | +| `setPlayerRunSpeed(v)` | 设置跑步速度。 | +| `setPlayerFlySpeed(v)` | 设置飞行速度。 | +| `setJumpHeight(v)` | 设置跳跃高度。 | +| `setGravity(v)` | 设置重力。 | +| `setMinCamDistance(v)` | 设置第三人称最小镜头距离。 | +| `setMaxCamDistance(v)` | 设置第三人称最大镜头距离。 | +| `setCamLookAtHeightRatio(v)` | 设置相机看向点高度比例。 | +| `setCamOverShoulderOffsetRatio(v)` | 设置过肩横向偏移比例。 | +| `setThirdMouseMode(v)` | 设置第三人称鼠标模式。 | +| `setEnableZoom(v)` | 开关滚轮缩放。 | +| `setOverShoulderView(v)` | 开关过肩视角。 | +| `setEnableToward(v)` | 开关鼠标驱动朝向。 | +| `setSkipCapsuleCollision(v)` | 临时跳过人物胶囊碰撞。 | +| `changeView()` | 切换第一 / 第三人称。 | +| `setFirstPersonCamera(v?)` | 直接进入第一人称。 | + +## 调试 + +```ts +player.setColliderDebug(true); // 显示 / 隐藏静态与运动学 Mesh 碰撞线框 +player.setPlayerCapsuleDebug(true); // 显示 / 隐藏玩家胶囊体线框 +player.setDynamicBodyDebug(true); // 显示 / 隐藏动态刚体碰撞线框 +player.setVehiclePhysicsDebug(true); // 显示 / 隐藏车辆底盘物理碰撞盒 +``` + +## 动画 + +| 方法 | 说明 | +| --- | --- | +| `playPlayerAnimationByName(name, fade?)` | 按动画片段名直接播放角色动画。 | +| `registerAnimation(key, clipName, opts?)` | 注册自定义动画片段。 | +| `playAnimation(key, opts?)` | 播放已注册的自定义动画。 | +| `registerLocomotionSet(setName, map)` | 注册一套移动动画集合,用于替换内置移动动画。 | +| `switchLocomotionSet(setName, fade?)` | 切换到指定的移动动画集合。 | + +### `registerAnimation` + +```ts +player.registerAnimation(key, clipName, { + loop?: boolean, // 是否循环播放,默认 true + timeScale?: number, // 动画播放放缩,默认 1 + duration?: number, // 动画播放时长,默认 0 + clampWhenFinished?: boolean, // 是否在播放结束后将动画时间重置为 0,默认 false + onFinished?: () => void, // 动画播放结束后触发 +}); +``` + +`duration` 与 `timeScale` 同时设置时,`duration` 优先生效。 + +### `playAnimation` + +```ts +player.playAnimation(key, { + fade?: number, // 过渡时长(秒),默认 0.18 + force?: boolean, // 为 true 时强制从头重播,即使当前已在播放该动画 + returnToPrev?: boolean, // 仅对 LoopOnce 动画生效,播放结束后自动恢复上一个动画状态 +}); +``` + +### `registerLocomotionSet` + +支持的 key:`idle` | `walking` | `walking_backward` | `running` | `jumping` | `flyidle` | `flying`,填写的 key 会替换对应内置动画,未填的保持原有动画。 + +```ts +player.registerLocomotionSet("combat", { + idle: "CombatIdle", + walking: "CombatWalk", + walking_backward: "CombatBack", + running: "CombatRun", + jumping: "CombatJump", + flyidle: "CombatFlyIdle", + flying: "CombatFly", +}); +``` + +# 默认键位 + +| 动作 | 默认键 | 功能 | +| --- | --- | --- | +| 前进 | `W` / `ArrowUp` | 前进 | +| 后退 | `S` / `ArrowDown` | 后退 | +| 左移 | `A` / `ArrowLeft` | 左移 | +| 右移 | `D` / `ArrowRight` | 右移 | +| 冲刺 | `Shift` | 人物冲刺;车辆手刹 | +| 跳跃 | `Space` | 跳跃;飞行上升;车辆刹车 | +| 切换视角 | `V` | 第一 / 第三人称 | +| 飞行 | `F` | 开关飞行 | +| 上 / 下车 | `E` | 车辆交互 | +| 视角 | 鼠标 | 控制视角 | + +# 自定义键位 + +通过 `keyMap` 可以把上表的任意动作改绑到其它按键,或禁用某个动作。键名使用 [`KeyboardEvent.code`](https://developer.mozilla.org/zh-CN/docs/Web/API/KeyboardEvent/code)(如 `"KeyE"`、`"ArrowUp"`、`"Space"`,注意是 `"KeyE"` 而非 `"e"`)。 + +每个动作三种取值: + +- **不填** → 使用默认键 +- **字符串 / 字符串数组** → 替换为指定按键(数组可绑定多个键) +- **`null`** → 禁用该动作(任何键都不会触发) + +初始化时配置: + +```ts +await player.init({ + // ... + keyMap: { + forward: "KeyE", // 改为按 E 前进(替换默认 W / ↑) + jump: null, // 禁用跳跃 + left: ["KeyA", "KeyJ"], // 同时绑定 A 和 J + // 其余动作未填,保持默认键 + }, +}); +``` + +运行时切换键位方案: + +```ts +player.setKeyMap({ forward: "KeyI", backward: "KeyK" }); // 应用新键位 +player.setKeyMap(); // 恢复全部默认 +``` + +# 外部输入 + +```ts +player.setInput({ + moveX: number, // 水平移动轴,范围 -1~1,正数向右 + moveY: number, // 纵向移动轴,范围 -1~1,正数向前 + lookDeltaX: number, // 视角水平增量,通常来自 mousemove 的 movementX + lookDeltaY: number, // 视角垂直增量,通常来自 mousemove 的 movementY + jump: boolean, // 跳跃,持续状态(true=按下,false=松开);飞行时控制上升;开车时为刹车 + shift: boolean, // 人物冲刺,开车时为手刹,持续状态(true=按下,false=松开) + toggleView: boolean, // 触发式,传 true 切换第一/第三人称视角 + toggleFly: boolean, // 触发式,传 true 切换飞行模式 + toggleVehicle: boolean, // 触发式,传 true 上车 / 下车 +}); +``` + +# 事件 + +```ts +player.onAnimationChange = (name, action) => {}; // 角色当前动画切换时触发 +player.onBeforeViewChange = (isFirstPerson) => {}; // 第一 / 第三人称切换前触发 +player.onViewChange = (isFirstPerson) => {}; // 第一 / 第三人称切换后触发 +player.onGroundChange = (onGround) => {}; // 落地状态变化时触发 +player.onVehicleEnter = (vehicle) => {}; // 上车完成后触发 +player.onVehicleExit = (vehicle) => {}; // 下车完成后触发 +player.onTowardChange = (dx, dy, speed) => {}; // 朝向 / 视角输入更新时触发 +``` + +# 主要配置 + +## `PlayerControllerOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `scene` | `THREE.Scene` | 是 | — | three.js 场景实例。 | +| `camera` | `THREE.PerspectiveCamera` | 是 | — | three.js 相机实例。 | +| `controls` | `any` | 是 | — | 外部相机控制器,通常为 `OrbitControls`。 | +| `playerModelConfig` | `PlayerModelOptions` | 是 | — | 角色模型与参数配置。 | +| `initPos` | `THREE.Vector3` | 否 | `(0, 0, 0)` | 初始出生点。 | +| `colliders` | `ColliderDesc[]` | 否 | — | 初始化时批量注册碰撞体。 | +| `mouseSensitivity` | `number` | 否 | `5` | 鼠标灵敏度。 | +| `minCamDistance` | `number` | 否 | `100` | 第三人称最小镜头距离。 | +| `maxCamDistance` | `number` | 否 | `440` | 第三人称最大镜头距离。 | +| `isShowMobileControls` | `boolean` | 否 | `true` | 是否在移动端显示虚拟控制 UI。 | +| `mobileControls` | `MobileControlsOptions` | 否 | 全部显示 | 移动端按钮显隐、图标与布局配置。 | +| `thirdMouseMode` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | 否 | `1` | 第三人称视角下的鼠标控制模式(0:隐藏鼠标,控制朝向及视角;1:隐藏鼠标,仅控制视角;2:显示鼠标,拖拽控制朝向及视角;3:显示鼠标,拖拽仅控制视角;4:显示鼠标,拖拽控制视角且人物朝向跟随相机水平方向;5:隐藏鼠标,控制视角且人物朝向跟随相机水平方向) | +| `enableZoom` | `boolean` | 否 | `false` | 是否允许滚轮缩放。 | +| `enableOverShoulderView` | `boolean` | 否 | `false` | 是否启用过肩视角。 | +| `camOverShoulderOffsetRatio` | `number` | 否 | `0.2` | 第三人称相机过肩视角横向偏移比例。 | +| `isFirstPerson` | `boolean` | 否 | `false` | 初始化时是否直接进入第一人称。 | +| `enableSpringCamera` | `boolean` | 否 | `false` | 是否启用弹簧相机(目标点以弹簧阻尼跟随角色)。 | +| `springCameraTime` | `number` | 否 | `0.05` | 弹簧平滑时间(秒),越小跟随越紧。 | +| `camLookAtHeightRatio` | `number` | 否 | `0.8` | 第三人称相机看向点高度比例(0=胶囊底部,1=顶部)。 | +| `timeScale` | `number` | 否 | `1` | 时间缩放系数,<1 慢动作,>1 快进。 | +| `keyMap` | `KeyMap` | 否 | 默认键位 | 自定义键位映射,详见[自定义键位](#自定义键位)。 | + +## `PlayerModelOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `model` | `THREE.Object3D` | 二选一 | — | 已加载的人物模型根节点。与 `url` 互斥。 | +| `animations` | `THREE.AnimationClip[]` | 与 `model` 同用 | — | 人物动画片段。与 `model` 一起传入。 | +| `url` | `string` | 二选一 | — | 人物模型路径(GLB/GLTF)。 | +| `scale` | `number` | 是 | — | 人物模型缩放。 | +| `idleAnim` | `string` | 是 | — | Idle 动画名。 | +| `walkAnim` | `string` | 是 | — | Walk 动画名。 | +| `runAnim` | `string` | 是 | — | Run 动画名。 | +| `jumpAnim` | `string \| [string, string, string]` | 是 | — | 跳跃动画名。传字符串播放单一动画;传三元素数组 `[起跳, 循环, 落地]` 分三阶段播放,落地后自动衔接移动动画。 | +| `leftWalkAnim` | `string` | 否 | `walkAnim` | 左移动画名,不填则复用 `walkAnim`。 | +| `rightWalkAnim` | `string` | 否 | `walkAnim` | 右移动画名,不填则复用 `walkAnim`。 | +| `backwardAnim` | `string` | 否 | `walkAnim` | 后退动画名,不填则复用 `walkAnim`。 | +| `flyAnim` | `string` | 否 | `idleAnim` | 飞行动画名,不填则复用 `idleAnim`。 | +| `flyIdleAnim` | `string` | 否 | `idleAnim` | 飞行待机动画名,不填则复用 `idleAnim`。 | +| `flyHoverForwardAnim` | `string` | 否 | `flyAnim` | 飞行前进时的悬停动画名,不填则复用 `flyAnim`。 | +| `flyHoverBackAnim` | `string` | 否 | `flyIdleAnim` | 飞行后退时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverLeftAnim` | `string` | 否 | `flyIdleAnim` | 飞行左移时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverRightAnim` | `string` | 否 | `flyIdleAnim` | 飞行右移时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverUpAnim` | `string` | 否 | `flyIdleAnim` | 飞行上升时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `flyHoverDownAnim` | `string` | 否 | `flyIdleAnim` | 飞行下降时的悬停动画名,不填则复用 `flyIdleAnim`。 | +| `drivingAnim` | `string` | 否 | `idleAnim` | 驾驶循环动画名,不填则复用 `idleAnim`。 | +| `gravity` | `number` | 否 | `-2400` | 重力基准值(按 `scale` 缩放)。 | +| `jumpHeight` | `number` | 否 | `600` | 跳跃高度基准值(按 `scale` 缩放)。 | +| `speed` | `number` | 否 | `200` | 移动速度基准值(按 `scale` 缩放)。 | +| `runSpeed` | `number` | 否 | `600` | 跑步速度基准值(按 `scale` 缩放)。 | +| `flySpeed` | `number` | 否 | `2100` | 飞行速度基准值(按 `scale` 缩放)。 | +| `rotateY` | `number` | 否 | `0` | 人物初始朝向(弧度),用于改变模型初始化面朝方向。 | +| `headBoneName` | `string` | 否 | — | 头部骨骼或节点名称,用于第一人称相机挂载。 | +| `firstPersonCameraOffset` | `[number, number, number]` | 否 | 有内置默认值 | 第一人称相机局部偏移;有 `headBoneName` 则相对头骨骼,否则相对胶囊体。 | +| `capsuleRadiusRatio` | `number` | 否 | `1` | 胶囊体半径倍率,用于微调碰撞宽度。 | +| `acceleration` | `number` | 否 | `30` | XZ 方向加速响应速度,值越大加速越快。 | +| `deceleration` | `number` | 否 | `30` | XZ 方向减速响应速度,值越大停止越快。 | + +### `MobileControlsOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `joystick` | `boolean` | 否 | `true` | 是否显示支持 360° 连续方向的摇杆。 | +| `jump` | `boolean \| JumpButtonOptions` | 否 | `true` | 跳跃/刹车按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `fly` | `boolean \| MobileButtonOptions` | 否 | `true` | 飞行按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `view` | `boolean \| MobileButtonOptions` | 否 | `true` | 视角切换按钮;`false` 隐藏,对象可自定义图片和布局。 | +| `vehicle` | `boolean \| MobileButtonOptions` | 否 | `true` | 上下车按钮;`false` 隐藏,对象可自定义图片和布局。 | + +### `MobileButtonOptions` / `JumpButtonOptions` + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `left` / `right` | `number` | 否 | 内置位置 | 按钮距离左侧或右侧的位置(px)。设置 `left` 且未设置 `right` 时会清除默认右侧定位。 | +| `top` / `bottom` | `number` | 否 | 内置位置 | 按钮距离顶部或底部的位置(px)。设置 `top` 且未设置 `bottom` 时会清除默认底部定位。 | +| `size` | `number` | 否 | `56` | 圆形按钮直径(px)。 | +| `icon` | `string` | 否 | 英文标签 | 自定义图片 URL。 | +| `brakeIcon` | `string` | 否 | `BRAKE` 标签 | 仅用于 `JumpButtonOptions`,设置车辆模式下的刹车图片 URL。 | + + +## `ColliderDesc` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `motion` | `"static" \| "kinematic" \| "dynamic"` | — | 碰撞体运动类型。 | +| `shape` | `ColliderShape` | — | 碰撞形状配置,支持 `mesh`、`box`、`sphere`。 | +| `groups` | `number` | 根据 `motion` 自动设置 | 当前碰撞体所属碰撞组。 | +| `mask` | `number` | 根据 `motion` 自动设置 | 当前碰撞体允许发生碰撞的分组掩码。 | +| `useWorker` | `boolean` | `false` | `mesh + source` 时是否使用 Worker 异步构建 BVH。 | +| `userData` | `unknown` | — | 自定义业务数据。 | +| `follow` | `THREE.Object3D` | — | `kinematic` 碰撞体跟随的场景对象。 | +| `density` | `number` | `1` | `dynamic` 刚体密度,用于计算质量。 | +| `restitution` | `number` | `0.2` | `dynamic` 刚体弹性系数。 | +| `friction` | `number` | `0.6` | `dynamic` 刚体摩擦系数。 | +| `linearDamping` | `number` | `0.4` | `dynamic` 刚体线阻尼。 | +| `angularDamping` | `number` | `0.6` | `dynamic` 刚体角阻尼。 | +| `gravity` | `number` | 内部默认值 | `dynamic` 刚体重力。 | +| `velocity` | `THREE.Vector3` | `(0,0,0)` | `dynamic` 刚体初始线速度。 | +| `angularVelocity` | `THREE.Vector3` | `(0,0,0)` | `dynamic` 刚体初始角速度。 | +| `mesh` | `THREE.Mesh` | — | `dynamic box / sphere` 创建内置刚体时必须提供的可视化 Mesh。 | +| `simulate` | `boolean` | `true` | 是否由内置动态刚体系统进行模拟。 | + +### `ColliderShape` + +#### Mesh + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"mesh"` | Mesh 碰撞形状。 | +| `source` | `THREE.Object3D \| THREE.Object3D[]` | 从对象中收集几何、合并碰撞网格并构建 BVH。 | +| `mesh` | `THREE.Mesh` | 直接注册已经准备好的碰撞 Mesh。 | + +#### Box + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"box"` | 盒体碰撞形状。 | +| `halfExtents` | `THREE.Vector3` | 盒体半尺寸,即完整尺寸的一半。 | +| `position` | `THREE.Vector3` | 初始世界坐标,可选。 | +| `quaternion` | `THREE.Quaternion` | 初始世界旋转,可选。 | + +#### Sphere + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `kind` | `"sphere"` | 球体碰撞形状。 | +| `radius` | `number` | 球体半径。 | +| `position` | `THREE.Vector3` | 初始世界坐标,可选。 | + +## `VehicleOptions` + +### 基础参数 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `model` | `THREE.Object3D` | — | 外部加载后的车辆模型根节点。 | +| `position` | `THREE.Vector3` | — | 车辆初始世界坐标。 | +| `wheelsNames` | `[string, string, string, string]` | — | 车轮节点名数组,顺序为左前、右前、左后、右后。 | +| `driverSeatPosition` | `THREE.Vector3` | — | 驾驶位胶囊中心,车辆底盘局部坐标。 | +| `scale` | `number` | `1` | 车辆模型缩放。 | +| `driverSeatRotation` | `number` | `0` | 驾驶位相对车辆底盘局部的水平旋转(弧度)。 | +| `modelRotation` | `number` | `-Math.PI / 2` | 车辆模型绕 Y 轴的初始旋转。 | +| `followVehicleDirection` | `boolean` | `true` | 驾驶时镜头是否跟随车辆朝向。 | +| `debug` | `VehicleDebugOptions` | — | 车辆物理调试显示配置。 | +| `chassis` | `VehicleChassisOptions` | — | 底盘碰撞盒与阻尼配置。 | +| `suspension` | `VehicleSuspensionOptions` | — | 悬挂与轮胎配置。 | +| `steering` | `VehicleSteeringOptions` | — | 转向配置。 | +| `grip` | `VehicleGripOptions` | — | 抓地和手刹配置。 | +| `power` | `VehiclePowerOptions` | — | 动力配置。 | + +### `debug` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `showPhysicsBox` | `boolean` | `false` | 显示底盘物理碰撞盒。 | +| `showWheelRays` | `boolean` | `false` | 显示车轮悬挂射线。 | +| `showWheelTravel` | `boolean` | `false` | 显示车轮 Y 向活动范围。 | +| `showWheelSpheres` | `boolean` | `false` | 显示车轮动态碰撞球。 | + +### `chassis` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `density` | `number` | `1` | 底盘密度,质量根据碰撞盒体积与密度计算。 | +| `linearDamping` | `number` | `0.05` | 底盘线阻尼。 | +| `angularDamping` | `number` | `0.5` | 底盘角阻尼。 | +| `clearance` | `number` | 自动计算 | 底盘盒底相对轮胎触地点高度。 | +| `sizeScale.x` | `number` | `1` | 底盘碰撞盒 X 轴尺寸倍率。 | +| `sizeScale.y` | `number` | `1` | 底盘碰撞盒 Y 轴尺寸倍率。 | +| `sizeScale.z` | `number` | `1` | 底盘碰撞盒 Z 轴尺寸倍率。 | + +### `suspension` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `restLength` | `number` | 自动计算 | 悬挂静止长度。 | +| `maxTravel` | `number` | `0.35` | 悬挂最大行程。 | +| `stiffness` | `number` | `18` | 悬挂刚度。 | +| `compression` | `number` | `2.1` | 悬挂压缩阻尼。 | +| `relaxation` | `number` | `2.5` | 悬挂回弹阻尼。 | +| `maxForce` | `number` | `6000` | 单轮最大悬挂力。 | +| `frictionSlip` | `number` | `8` | 轮胎纵向抓地。 | +| `sideFrictionStiffness` | `number` | `1` | 轮胎侧向摩擦刚度。 | +| `rollInfluence` | `number` | `0.12` | 侧向力对车身侧倾的影响。 | + +### `steering` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxSteerAngle` | `number` | `Math.PI / 5` | 最大转向角,单位为弧度。 | +| `steerTime` | `number` | `0.45` | 从中位打到满舵所需时间,单位为秒。 | +| `steerReturnTimeSlow` | `number` | `0.55` | 低速回正时间,单位为秒。 | +| `steerReturnTimeFast` | `number` | `0.4` | 高速回正时间,单位为秒。 | +| `highSpeedSteerScale` | `number` | `0.3` | 最高车速时的最大转向比例。 | + +### `grip` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxG` | `number` | `1.2` | 最大侧向加速度,单位为 g。 | +| `sideFrictionIdle` | `number` | `1` | 直线行驶时侧向摩擦。 | +| `sideFrictionFrontMin` | `number` | `0.55` | 高负载下前轮侧向摩擦下限。 | +| `sideFrictionRearMin` | `number` | `0.45` | 高负载下后轮侧向摩擦下限。 | +| `handbrakeRearFriction` | `number` | `0.35` | 手刹时后轮侧向摩擦。 | +| `handbrakeRearDriveScale` | `number` | `0.65` | 手刹时后轮驱动力比例。 | +| `handbrakeReleaseTime` | `number` | `0.15` | 松开手刹后的恢复时间,单位为秒。 | +| `wheelbaseRatio` | `number` | `0.55` | 轴距相对车长的比例。 | + +### `power` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `maxSpeed` | `number` | `100` | 最高速度基准值(km/h,按 `scale` 缩放)。 | +| `acceleration` | `number` | `5` | 加速度基准值(m/s²,按 `scale` 缩放)。 | +| `deceleration` | `number` | `5` | 制动减速度基准值(m/s²,按 `scale` 缩放)。 | + +# 反馈 + +如果你有任何问题或建议,欢迎提交 [issue](https://github.com/hh-hang/three-player-controller/issues)。 + +# 致谢 + +- [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh) +- [three.js](https://github.com/mrdoob/three.js) + +[npm]: https://img.shields.io/npm/v/three-player-controller +[npm-url]: https://www.npmjs.com/package/three-player-controller +[github]: https://img.shields.io/badge/-hh--hang-181717?style=flat&logo=github&logoColor=white&labelColor=888 +[github-url]: https://github.com/hh-hang \ No newline at end of file diff --git a/three-player-controller/README_En.md b/three-player-controller/README_En.md new file mode 100644 index 0000000..daa4864 --- /dev/null +++ b/three-player-controller/README_En.md @@ -0,0 +1,1001 @@ +[中文](README.md) | English + +# three-player-controller + +[![NPM Package][npm]][npm-url] +[![Github][github]][github-url] +[![CesiumJS](https://img.shields.io/badge/CesiumJS-player_controller-blue)](https://github.com/hh-hang/cesium-player-controller) + +A lightweight first-person / third-person player controller for three.js. It provides character capsule collision, animation, camera obstacle avoidance, moving platforms, dynamic rigid bodies, vehicle control, and Foot IK, with `three-mesh-bvh` accelerating collision queries in complex scenes. + +## Features + +- First-person / third-person switching +- Walking, running, jumping, and flying +- Character capsule collision and ground detection +- Camera obstacle avoidance, over-shoulder view, and spring camera +- Static / kinematic / dynamic colliders +- Dynamic spheres, boxes, and rigid-body collision +- Asynchronous BVH construction with Worker +- Streaming collider construction for 3D Tiles +- Built-in vehicle physics, suspension, steering, grip, and handbrake +- Foot IK plugin +- Custom animations and Locomotion Sets +- Mobile virtual joystick and buttons +- Custom key bindings and external input + +# Examples + +[![Online Demo](https://github.com/hh-hang/three-player-controller/blob/master/example/public/img/readme/preview.png)](https://hh-hang.github.io/three-player-controller/index.html) + +- [Showcase](https://hh-hang.github.io/three-player-controller/showcase.html) +- [Foot IK](https://hh-hang.github.io/three-player-controller/footIK.html) +- [FPS Game](https://hh-hang.github.io/three-player-controller/shooting/shooting.html) +- [glTF Scene](https://hh-hang.github.io/three-player-controller/glTF.html) +- [3DTiles Scene](https://hh-hang.github.io/three-player-controller/3dtilesScene.html) +- [3DGS Scene](https://hh-hang.github.io/three-player-controller/3dgs.html) + +# Installation + +```bash +npm install three-player-controller three-mesh-bvh +``` + +# Run Locally + +```bash +git clone https://github.com/hh-hang/three-player-controller.git +cd three-player-controller +npm install +npm run dev +``` + +Open `http://localhost:5173/three-player-controller/` in your browser. + +# Quick Start + +```ts +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { playerController } from "three-player-controller"; + +const scene = new THREE.Scene(); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 1000, +); + +const renderer = new THREE.WebGLRenderer(); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); + +// Load the player model +const loader = new GLTFLoader(); +const playerGltf = await loader.loadAsync("./glb/player.glb"); + +// Create the player controller +const player = new playerController(); + +// Initialize player control +await player.init({ + scene, // three.js scene instance + camera, // three.js camera instance + controls, // external camera controller + + // Player model, animation, and movement settings + playerModelConfig: { + model: playerGltf.scene, // root node of the externally loaded model + animations: playerGltf.animations, // animation clips + scale: 0.001, // model scale + idleAnim: "idle", // idle animation name + walkAnim: "walk", // walk animation name + runAnim: "run", // run animation name + jumpAnim: "jump", // jump animation name; or ["start", "loop", "land"] for three-stage playback + }, + + // Initial player spawn point + initPos: new THREE.Vector3(0, 0, 0), + + // Colliders registered during initialization + colliders: [ + { + motion: "static", // static collider + shape: { // collision shape settings + kind: "mesh", // use a Mesh collision shape + source: scene, // collect geometry from this object and build the BVH automatically + }, + }, + ], +}); + +function animate() { + requestAnimationFrame(animate); + player.update(); + renderer.render(scene, camera); +} + +animate(); +``` + +> `player.update()` already drives the camera controller passed to the player. Do not call `controls.update()` again in the render loop, or it may conflict with the internal camera logic. + +### Complete Parameter Example + +#### `init()` + +```ts +await player.init({ + // Required + scene, // three.js scene instance + camera, // three.js camera instance + controls, // external camera controller, usually OrbitControls + + playerModelConfig: { // player model, animation, and movement settings + model: playerGltf.scene, // root node of the externally loaded model + animations: playerGltf.animations, // animation clips + scale: 0.001, // model scale + idleAnim: "idle", // idle animation name + walkAnim: "walk", // walk animation name + runAnim: "run", // run animation name + jumpAnim: "jump", // jump animation name; or ["start", "loop", "land"] for three-stage playback + + // Directional / flight animations (optional; corresponding defaults are reused when omitted) + leftWalkAnim: "leftWalk", // left-strafe animation; defaults to walkAnim + rightWalkAnim: "rightWalk", // right-strafe animation; defaults to walkAnim + backwardAnim: "walkBack", // backward animation; defaults to walkAnim + flyAnim: "fly", // flight animation; defaults to idleAnim + flyIdleAnim: "flyIdle", // fly-idle animation; defaults to idleAnim + flyHoverForwardAnim: "flyFwd", // forward hover animation; defaults to flyAnim + flyHoverBackAnim: "flyBack", // backward hover animation; defaults to flyIdleAnim + flyHoverLeftAnim: "flyLeft", // left hover animation; defaults to flyIdleAnim + flyHoverRightAnim: "flyRight", // right hover animation; defaults to flyIdleAnim + flyHoverUpAnim: "flyUp", // upward hover animation; defaults to flyIdleAnim + flyHoverDownAnim: "flyDown", // downward hover animation; defaults to flyIdleAnim + drivingAnim: "driving", // driving loop animation; defaults to idleAnim + + // Physics settings (optional) + gravity: -2400, // base gravity value, scaled by scale; default -2400 + jumpHeight: 600, // base jump-height value, scaled by scale; default 600 + speed: 200, // base movement speed, scaled by scale; default 200 + runSpeed: 600, // base run speed, scaled by scale; default 600 + flySpeed: 2100, // base flight speed, scaled by scale; default 2100 + acceleration: 30, // XZ acceleration response; default 30 + deceleration: 30, // XZ deceleration response; default 30 + + // Model settings (optional) + rotateY: 0, // initial player facing direction in radians; default 0 + headBoneName: "Head", // head bone name used for first-person camera attachment + firstPersonCameraOffset: [0, 40, 30], // local first-person camera offset + capsuleRadiusRatio: 1, // capsule radius multiplier; default 1 + }, + + // Scene and collision (optional) + initPos: new THREE.Vector3(0, 0, 0), // initial spawn point; default (0, 0, 0) + colliders: [ // colliders created during initialization; call addCollider() after init() if omitted + { + motion: "static", // collider motion type: static / kinematic / dynamic + shape: { // collision shape settings + kind: "mesh", // Mesh collision shape + source: world, // collision source; THREE.Object3D or THREE.Object3D[] + }, + useWorker: false, // whether to build the BVH asynchronously in a Worker + }, + ], + + // Camera (optional) + minCamDistance: 100, // minimum third-person camera distance; default 100 + maxCamDistance: 440, // maximum third-person camera distance; default 440 + camLookAtHeightRatio: 0.8, // camera target height ratio, 0=bottom and 1=top; default 0.8 + thirdMouseMode: 1, // third-person mouse mode 0-5; default 1 + enableZoom: false, // whether mouse-wheel zoom is enabled; default false + enableOverShoulderView: false, // whether over-shoulder view is enabled; default false + camOverShoulderOffsetRatio: 0.2, // over-shoulder horizontal offset ratio; default 0.2 + isFirstPerson: false, // whether to start in first-person; default false + enableSpringCamera: false, // whether spring camera is enabled; default false + springCameraTime: 0.05, // spring-camera smoothing time in seconds; default 0.05 + + // Other settings (optional) + mouseSensitivity: 5, // mouse sensitivity; default 5 + timeScale: 1, // time scale; <1 slow motion, >1 fast-forward; default 1 + + keyMap: { // custom key map; use one key, multiple keys, or null to disable + forward: ["KeyW", "ArrowUp"], // move forward + backward: ["KeyS", "ArrowDown"], // move backward + left: ["KeyA", "ArrowLeft"], // move left + right: ["KeyD", "ArrowRight"], // move right + sprint: ["ShiftLeft", "ShiftRight"], // sprint; handbrake while driving + jump: ["Space"], // jump; brake while driving + toggleView: ["KeyV"], // switch first-person / third-person + toggleFly: ["KeyF"], // toggle flight mode + toggleVehicle: ["KeyE"], // enter / exit vehicle + }, + + isShowMobileControls: true, // whether to show virtual controls on mobile; default true + mobileControls: { // mobile button visibility, icons, and layout + joystick: true, // whether to show the virtual joystick; default true + jump: { // jump / brake button; pass false to hide + icon: "/icons/custom-jump.svg", // custom image URL for jump state + brakeIcon: "/icons/custom-brake.svg", // custom image URL for braking in vehicle mode + right: 24, // distance from the right edge in px + bottom: 32, // distance from the bottom edge in px + size: 64, // button diameter in px; default 56 + }, + fly: true, // flight button; true uses default style, false hides it + view: true, // view-switch button; true uses default style, false hides it + vehicle: true, // enter / exit vehicle button; true uses default style, false hides it + }, +}); +``` + + +# Collision System + +Uses the unified `CollisionWorld` and `addCollider()` API. + +A collider is described by two core properties: + +```ts +{ + motion: "static" | "kinematic" | "dynamic", + shape: { + kind: "mesh" | "box" | "sphere", + // ... + } +} +``` + +## Static Mesh + +Suitable for terrain, buildings, walls, and other scene geometry that does not move. + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { + kind: "mesh", + source: world, + }, + useWorker: true, +}); +``` + +## Kinematic Mesh + +Suitable for elevators, moving platforms, rotating platforms, vehicle visuals, or other objects whose transforms are controlled by application code. + +```ts +const platformCollider = player.addCollider({ + motion: "kinematic", + shape: { + kind: "mesh", + source: platform, + }, + follow: platform, + useWorker: true, +}); +``` + +The collider follows `follow.matrixWorld` every frame. If you only need to adjust already-baked kinematic collision geometry at runtime, the BVH does not need to be rebuilt. + +## Dynamic Rigid Bodies + +Dynamic `box` and `sphere` colliders automatically create built-in rigid bodies. `DynamicBodySystem` handles gravity, damping, contacts, impulses, and sleeping. + +### Dynamic Box + +```ts +const box = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial(), +); +scene.add(box); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: new THREE.Vector3(0.5, 0.5, 0.5), + position: new THREE.Vector3(0, 5, 0), + }, + mesh: box, +}); +``` + +### Dynamic Sphere + +```ts +const sphere = new THREE.Mesh( + new THREE.SphereGeometry(0.5), + new THREE.MeshStandardMaterial(), +); +scene.add(sphere); + +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(0, 5, 0), + }, + mesh: sphere, +}); +``` + +Dynamic rigid bodies can collide with static meshes, kinematic meshes, the player, vehicles, and other dynamic rigid bodies. + +## Remove And Clear + +```ts +const handle = player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: object }, +}); + +player.removeCollider(handle); + +// Clear only static colliders +player.clearColliders({ motion: "static" }); + +// Clear all colliders +player.clearColliders(); +``` + +## Collision Groups + +```ts +import { CollisionGroup } from "three-player-controller"; +``` + +| Group | Description | +| --- | --- | +| `CollisionGroup.DEFAULT` | Default scene collision group. | +| `CollisionGroup.CHARACTER` | Player character collision group. | +| `CollisionGroup.VEHICLE` | Vehicle collision group. | +| `CollisionGroup.DEBRIS` | Dynamic debris, pushable objects, and similar colliders. | +| `CollisionGroup.ALL` | Combination of all collision groups, allowing collision with every group. | + +By default, you do not need to set these values. The appropriate collision group and mask are selected automatically based on `motion`. You can also customize collision filtering with `groups` and `mask`: + +```ts +player.addCollider({ + motion: "dynamic", + shape: { + kind: "sphere", + radius: 0.5, + position: new THREE.Vector3(), + }, + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE, +}); +``` + +# Streaming 3D Tiles Collision + +For 3D Tiles, it is not recommended to merge the entire Tileset into a single collider at once. + +`example/3dtilesScene.js` uses a streaming strategy: + +1. Listen for tile load, unload, and visibility changes. +2. Track only currently loaded tiles. +3. Create colliders only for visible tiles near the player. +4. Build BVHs asynchronously with `useWorker: true`. +5. Immediately call `removeCollider()` when a tile is hidden, unloaded, or too far away. +6. Use separate enter / exit radii to avoid frequent collider creation and destruction near the boundary. + +Core pattern: + +```ts +const handle = player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { + kind: "mesh", + source: tileScene, + }, + follow: tileScene, +}); + +// Remove the collider when the tile unloads, becomes invisible, or is too far away +player.removeCollider(handle); +``` + +The example also demonstrates a local coordinate system and rebasing for globe-scale large-coordinate scenes. When the player moves too far from the local origin, the local coordinate system is updated while the player, camera, and velocity are transformed together to avoid floating-point precision issues at large coordinates. + +See the full implementation in [`example/3dtilesScene.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js). + +# Vehicle + +```ts +const vehicleGltf = await loader.loadAsync("./glb/vehicle.glb"); // Load the vehicle model externally + +const vehicle = await player.loadVehicleModel({ + // Required + model: vehicleGltf.scene, // root node of the externally loaded vehicle model + position: new THREE.Vector3(0, 0, 0), // initial vehicle world position + wheelsNames: [ // wheel node names, order: front-left, front-right, rear-left, rear-right + "Wheel_LF", // front-left wheel node name + "Wheel_RF", // front-right wheel node name + "Wheel_LR", // rear-left wheel node name + "Wheel_RR", // rear-right wheel node name + ], + driverSeatPosition: new THREE.Vector3(-0.6, 0.7, 0.4), // driver-seat capsule center in chassis-local coordinates + + // Model / driving (optional) + scale: 0.1, // vehicle model scale; default 1 + driverSeatRotation: 0, // driver-seat horizontal rotation relative to chassis local space in radians; default 0 + modelRotation: -Math.PI / 2, // vehicle model Y-axis rotation in radians; default -π/2 + followVehicleDirection: true, // whether the camera follows vehicle heading while driving; default true + + // Debug (optional) + debug: { // vehicle physics debug visualization settings + showPhysicsBox: false, // whether to show the chassis physics collision box; default false + showWheelRays: false, // whether to show wheel suspension rays; default false + showWheelTravel: false, // whether to show wheel Y-axis travel range; default false + showWheelSpheres: false, // whether to show dynamic wheel collision spheres; default false + }, + + // Chassis (optional) + chassis: { // chassis collision box and damping settings + density: 1, // chassis density; mass is calculated from collision-box volume × density; default 1 + linearDamping: 0.05, // linear damping; default 0.05 + angularDamping: 0.5, // angular damping; default 0.5 + clearance: 0.15, // height of the chassis box bottom relative to tire contact points; auto-calculated if omitted + sizeScale: { // chassis collision-box size multiplier relative to the automatic AABB + x: 1, // X-axis size multiplier; default 1 + y: 1, // Y-axis size multiplier; default 1 + z: 1, // Z-axis size multiplier; default 1 + }, + }, + + // Suspension / tires (optional) + suspension: { // suspension and tire settings + restLength: 0.3, // suspension rest length; auto-calculated if omitted + maxTravel: 0.35, // maximum suspension travel; default 0.35 + stiffness: 18, // suspension stiffness; default 18 + compression: 2.1, // suspension compression damping; default 2.1 + relaxation: 2.5, // suspension rebound damping; default 2.5 + maxForce: 6000, // maximum suspension force per wheel; default 6000 + frictionSlip: 8, // longitudinal tire grip; default 8 + sideFrictionStiffness: 1, // lateral tire friction stiffness; default 1 + rollInfluence: 0.12, // effect of lateral force on body roll; default 0.12 + }, + + // Steering (optional) + steering: { // steering settings + maxSteerAngle: Math.PI / 5, // maximum steering angle in radians; default π/5 + steerTime: 0.45, // time from center to full steering lock in seconds; default 0.45 + steerReturnTimeSlow: 0.55, // low-speed steering return time in seconds; default 0.55 + steerReturnTimeFast: 0.4, // high-speed steering return time in seconds; default 0.4 + highSpeedSteerScale: 0.3, // maximum steering ratio at top speed; default 0.3 + }, + + // Grip / handbrake (optional) + grip: { // cornering grip and handbrake settings + maxG: 1.2, // maximum lateral acceleration in g; default 1.2 + sideFrictionIdle: 1, // lateral friction while driving straight; default 1 + sideFrictionFrontMin: 0.55, // minimum front-wheel lateral friction under high load; default 0.55 + sideFrictionRearMin: 0.45, // minimum rear-wheel lateral friction under high load; default 0.45 + handbrakeRearFriction: 0.35, // rear-wheel lateral friction while handbraking; default 0.35 + handbrakeRearDriveScale: 0.65, // rear-wheel drive-force ratio while handbraking; default 0.65 + handbrakeReleaseTime: 0.15, // recovery time after releasing the handbrake in seconds; default 0.15 + wheelbaseRatio: 0.55, // wheelbase relative to vehicle length; default 0.55 + }, + + // Power (optional) + power: { // vehicle power settings + maxSpeed: 100, // base top speed in km/h, scaled by scale; default 100 + acceleration: 5, // base acceleration in m/s², scaled by scale; default 5 + deceleration: 5, // base braking deceleration in m/s², scaled by scale; default 5 + }, +}); +``` + +Runtime: + +```ts +player.resetVehicle(); // right the currently driven vehicle and clear linear / angular velocity +player.setVehicleScale(vehicle, 0.12); // change the overall vehicle scale at runtime +player.setVehicleClearance(vehicle, 0.15); // adjust chassis collision-box ground clearance +player.setVehicleChassisSizeScale(vehicle, { // adjust chassis collision-box size multipliers relative to the automatic AABB + x: 1, // X-axis size multiplier + y: 0.9, // Y-axis size multiplier + z: 1, // Z-axis size multiplier +}); +player.setVehiclePhysicsDebug(true); // show / hide the vehicle chassis physics collision box +``` + +# Foot IK Plugin + +```ts +import { FootIK } from "three-player-controller/foot-ik"; + +const footIK = new FootIK({ + // Manually specify the player skeleton mapping; common bone names are matched automatically when omitted + skeleton: { + hips: "mixamorigHips", // hips / pelvis bone name + + // Left / right leg bone configuration + legs: { + left: { + upper: "mixamorigLeftUpLeg", // left upper-leg bone + lower: "mixamorigLeftLeg", // left lower-leg bone + foot: "mixamorigLeftFoot", // left foot bone + toe: "mixamorigLeftToeBase", // left toe bone + }, + right: { + upper: "mixamorigRightUpLeg", // right upper-leg bone + lower: "mixamorigRightLeg", // right lower-leg bone + foot: "mixamorigRightFoot", // right foot bone + toe: "mixamorigRightToeBase", // right toe bone + }, + }, + }, + + soleSkinThickness: 3, // skin-thickness compensation from the foot bone to the shoe sole +}); + +player.use(footIK); // register the Foot IK plugin with the player controller +``` + +Runtime: + +```ts +footIK.setEnabled(false); // enable / disable Foot IK +footIK.setDebugEnabled(true); // show / hide Foot IK debug helpers +player.unuse(footIK); // unregister the Foot IK plugin from the player controller +footIK.dispose(); // dispose debug objects and related resources created by Foot IK +``` + +`player.destroy()` automatically disposes registered plugins. + +See the full example in [`example/footIK.js`](https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js). + +# API + +## Lifecycle And Core + +| Method | Description | +| --- | --- | +| `init(opts, callback?)` | Initialize the controller. | +| `update(dt?)` | Update the player, collisions, dynamic rigid bodies, vehicles, camera, and animation every frame. | +| `destroy()` | Destroy the controller and release resources. | +| `reset(pos?)` | Reset the player to the specified position or the initial position. | +| `switchPlayerModel(options)` | Switch the player model at runtime using `PlayerModelOptions`. | +| `use(plugin)` | Register a plugin. | +| `unuse(plugin)` | Unregister a plugin. | +| `getPlugins()` | Return a copy of the current plugin list. | + +## Collision + +| Method | Description | +| --- | --- | +| `addCollider(desc)` | Add a static / kinematic / dynamic collider and return a `ColliderHandle`. | +| `removeCollider(handle)` | Remove a collider by handle or id. | +| `clearColliders(filter?)` | Clear colliders, optionally filtered by `motion`. | +| `getColliderMeshes(options?)` | Return the Mesh colliders used by current player queries. | +| `isStaticColliderUsable()` | Whether a static Mesh with a completed BVH is available. | +| `getActiveKinematicCollider()` | Return the kinematic collider currently supporting the player. | +| `getActiveDynamicBody()` | Return the dynamic rigid body currently supporting the player. | +| `getDynamicBodies()` | Return all dynamic rigid bodies. | +| `removeDynamicBody(body)` | Remove a dynamic rigid body. | +| `clearDynamicBodies()` | Clear all dynamic rigid bodies. | +| `raycastDynamicGround(...)` | Query the surface of dynamic rigid bodies below. | + +## State + +| Method | Return Value | +| --- | --- | +| `getPosition()` | Current player position. | +| `getVelocity()` | Current player velocity. | +| `getIsFirstPerson()` | Whether first-person mode is active. | +| `getIsFlying()` | Whether flight mode is active. | +| `getIsOnGround()` | Whether the player is on the ground. | +| `getGroundSupport()` | Current final ground-support point and world-space normal. | +| `getCurrentDelta()` | Actual time step used by the current frame. | +| `getControllerMode()` | `0` for player, `1` for vehicle. | +| `getPlayerModel()` | Current player model. | +| `getPlayerCapsule()` | Player capsule. | +| `getActiveVehicle()` | Currently driven vehicle. | +| `getAllVehicles()` | All loaded vehicles. | +| `getCurrentPlayerAnimationName()` | Current animation clip name. | +| `getCurrentLocomotionSet()` | Current Locomotion Set. | +| `getCenterScreenRaycastHit()` | Center-screen raycast result. | + +## Runtime Controls + +| Method | Description | +| --- | --- | +| `setInput(input)` | Inject external input. | +| `setKeyMap(map?)` | Customize or restore default key bindings. | +| `setMouseSensitivity(v)` | Set mouse sensitivity. | +| `setPlayerScale(v)` | Change player scale at runtime. | +| `setPlayerSpeed(v)` | Set walking speed. | +| `setPlayerRunSpeed(v)` | Set running speed. | +| `setPlayerFlySpeed(v)` | Set flight speed. | +| `setJumpHeight(v)` | Set jump height. | +| `setGravity(v)` | Set gravity. | +| `setMinCamDistance(v)` | Set minimum third-person camera distance. | +| `setMaxCamDistance(v)` | Set maximum third-person camera distance. | +| `setCamLookAtHeightRatio(v)` | Set camera target height ratio. | +| `setCamOverShoulderOffsetRatio(v)` | Set over-shoulder horizontal offset ratio. | +| `setThirdMouseMode(v)` | Set third-person mouse mode. | +| `setEnableZoom(v)` | Enable / disable mouse-wheel zoom. | +| `setOverShoulderView(v)` | Enable / disable over-shoulder view. | +| `setEnableToward(v)` | Enable / disable mouse-driven facing. | +| `setSkipCapsuleCollision(v)` | Temporarily skip player capsule collision. | +| `changeView()` | Switch first-person / third-person view. | +| `setFirstPersonCamera(v?)` | Enter first-person view directly. | + +## Debug + +```ts +player.setColliderDebug(true); // show / hide static and kinematic Mesh collision wireframes +player.setPlayerCapsuleDebug(true); // show / hide the player capsule wireframe +player.setDynamicBodyDebug(true); // show / hide dynamic rigid-body collision wireframes +player.setVehiclePhysicsDebug(true); // show / hide the vehicle chassis physics collision box +``` + +## Animation + +| Method | Description | +| --- | --- | +| `playPlayerAnimationByName(name, fade?)` | Play a player animation directly by clip name. | +| `registerAnimation(key, clipName, opts?)` | Register a custom animation clip. | +| `playAnimation(key, opts?)` | Play a registered custom animation. | +| `registerLocomotionSet(setName, map)` | Register a locomotion animation set to replace built-in locomotion animations. | +| `switchLocomotionSet(setName, fade?)` | Switch to the specified locomotion animation set. | + +### `registerAnimation` + +```ts +player.registerAnimation(key, clipName, { + loop?: boolean, // whether to loop; default true + timeScale?: number, // animation playback scale; default 1 + duration?: number, // animation playback duration; default 0 + clampWhenFinished?: boolean, // whether to reset animation time to 0 when playback finishes; default false + onFinished?: () => void, // called when animation playback finishes +}); +``` + +When both `duration` and `timeScale` are set, `duration` takes precedence. + +### `playAnimation` + +```ts +player.playAnimation(key, { + fade?: number, // transition duration in seconds; default 0.18 + force?: boolean, // when true, restart from the beginning even if this animation is already playing + returnToPrev?: boolean, // applies only to LoopOnce animations; automatically restore the previous animation state after playback +}); +``` + +### `registerLocomotionSet` + +Supported keys: `idle` | `walking` | `walking_backward` | `running` | `jumping` | `flyidle` | `flying`. Specified keys replace the corresponding built-in animations; omitted keys keep their existing animations. + +```ts +player.registerLocomotionSet("combat", { + idle: "CombatIdle", + walking: "CombatWalk", + walking_backward: "CombatBack", + running: "CombatRun", + jumping: "CombatJump", + flyidle: "CombatFlyIdle", + flying: "CombatFly", +}); +``` + +# Default Controls + +| Action | Default Key | Behavior | +| --- | --- | --- | +| Forward | `W` / `ArrowUp` | Move forward | +| Backward | `S` / `ArrowDown` | Move backward | +| Left | `A` / `ArrowLeft` | Move left | +| Right | `D` / `ArrowRight` | Move right | +| Sprint | `Shift` | Player sprint; vehicle handbrake | +| Jump | `Space` | Jump; ascend while flying; vehicle brake | +| Toggle view | `V` | First-person / third-person | +| Fly | `F` | Toggle flight mode | +| Enter / exit vehicle | `E` | Vehicle interaction | +| Look | Mouse | Control view | + +# Custom Key Bindings + +Use `keyMap` to rebind any action in the table above to other keys, or disable an action. Key names use [`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) (for example `"KeyE"`, `"ArrowUp"`, and `"Space"`; note that it is `"KeyE"`, not `"e"`). + +Each action supports three value forms: + +- **Omitted** → use the default key +- **String / string array** → replace with the specified key(s); an array can bind multiple keys +- **`null`** → disable the action; no key will trigger it + +Configure during initialization: + +```ts +await player.init({ + // ... + keyMap: { + forward: "KeyE", // use E to move forward instead of default W / ↑ + jump: null, // disable jumping + left: ["KeyA", "KeyJ"], // bind both A and J + // omitted actions keep their default keys + }, +}); +``` + +Switch key-binding schemes at runtime: + +```ts +player.setKeyMap({ forward: "KeyI", backward: "KeyK" }); // apply new bindings +player.setKeyMap(); // restore all defaults +``` + +# External Input + +```ts +player.setInput({ + moveX: number, // horizontal movement axis, range -1 to 1; positive moves right + moveY: number, // vertical movement axis, range -1 to 1; positive moves forward + lookDeltaX: number, // horizontal look delta, usually mousemove movementX + lookDeltaY: number, // vertical look delta, usually mousemove movementY + jump: boolean, // held state (true=pressed, false=released); jump, ascend while flying, brake while driving + shift: boolean, // held state (true=pressed, false=released); sprint, or handbrake while driving + toggleView: boolean, // trigger; pass true to switch first-person / third-person view + toggleFly: boolean, // trigger; pass true to toggle flight mode + toggleVehicle: boolean, // trigger; pass true to enter / exit vehicle +}); +``` + +# Events + +```ts +player.onAnimationChange = (name, action) => {}; // triggered when the current player animation changes +player.onBeforeViewChange = (isFirstPerson) => {}; // triggered before first-person / third-person switching +player.onViewChange = (isFirstPerson) => {}; // triggered after first-person / third-person switching +player.onGroundChange = (onGround) => {}; // triggered when grounded state changes +player.onVehicleEnter = (vehicle) => {}; // triggered after entering a vehicle +player.onVehicleExit = (vehicle) => {}; // triggered after exiting a vehicle +player.onTowardChange = (dx, dy, speed) => {}; // triggered when facing / view input updates +``` + +# Main Options + +## `PlayerControllerOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `scene` | `THREE.Scene` | Yes | — | three.js scene instance. | +| `camera` | `THREE.PerspectiveCamera` | Yes | — | three.js camera instance. | +| `controls` | `any` | Yes | — | External camera controller, usually `OrbitControls`. | +| `playerModelConfig` | `PlayerModelOptions` | Yes | — | Player model and parameter configuration. | +| `initPos` | `THREE.Vector3` | No | `(0, 0, 0)` | Initial spawn point. | +| `colliders` | `ColliderDesc[]` | No | — | Colliders registered in bulk during initialization. | +| `mouseSensitivity` | `number` | No | `5` | Mouse sensitivity. | +| `minCamDistance` | `number` | No | `100` | Minimum third-person camera distance. | +| `maxCamDistance` | `number` | No | `440` | Maximum third-person camera distance. | +| `isShowMobileControls` | `boolean` | No | `true` | Whether to show virtual control UI on mobile. | +| `mobileControls` | `MobileControlsOptions` | No | All shown | Mobile button visibility, icons, and layout settings. | +| `thirdMouseMode` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` | No | `1` | Mouse control mode in third-person view (0: hide cursor, control facing and view; 1: hide cursor, control view only; 2: show cursor, drag to control facing and view; 3: show cursor, drag to control view only; 4: show cursor, drag to control view while player facing follows the camera's horizontal direction; 5: hide cursor, control view while player facing follows the camera's horizontal direction). | +| `enableZoom` | `boolean` | No | `false` | Whether mouse-wheel zoom is enabled. | +| `enableOverShoulderView` | `boolean` | No | `false` | Whether over-shoulder view is enabled. | +| `camOverShoulderOffsetRatio` | `number` | No | `0.2` | Third-person over-shoulder camera horizontal offset ratio. | +| `isFirstPerson` | `boolean` | No | `false` | Whether to enter first-person immediately during initialization. | +| `enableSpringCamera` | `boolean` | No | `false` | Whether spring camera is enabled (the target follows the player with spring damping). | +| `springCameraTime` | `number` | No | `0.05` | Spring smoothing time in seconds; smaller values follow more tightly. | +| `camLookAtHeightRatio` | `number` | No | `0.8` | Third-person camera target height ratio (0=capsule bottom, 1=top). | +| `timeScale` | `number` | No | `1` | Time scale; <1 slow motion, >1 fast-forward. | +| `keyMap` | `KeyMap` | No | Default bindings | Custom key mapping; see [Custom Key Bindings](#custom-key-bindings). | + +## `PlayerModelOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `model` | `THREE.Object3D` | One of two | — | Root node of an already-loaded player model. Mutually exclusive with `url`. | +| `animations` | `THREE.AnimationClip[]` | With `model` | — | Player animation clips. Pass together with `model`. | +| `url` | `string` | One of two | — | Player model path (GLB/GLTF). | +| `scale` | `number` | Yes | — | Player model scale. | +| `idleAnim` | `string` | Yes | — | Idle animation name. | +| `walkAnim` | `string` | Yes | — | Walk animation name. | +| `runAnim` | `string` | Yes | — | Run animation name. | +| `jumpAnim` | `string \| [string, string, string]` | Yes | — | Jump animation name. Pass a string for one animation, or a three-element `[start, loop, land]` array for three-stage playback; locomotion resumes automatically after landing. | +| `leftWalkAnim` | `string` | No | `walkAnim` | Left-strafe animation name; defaults to `walkAnim` when omitted. | +| `rightWalkAnim` | `string` | No | `walkAnim` | Right-strafe animation name; defaults to `walkAnim` when omitted. | +| `backwardAnim` | `string` | No | `walkAnim` | Backward animation name; defaults to `walkAnim` when omitted. | +| `flyAnim` | `string` | No | `idleAnim` | Flight animation name; defaults to `idleAnim` when omitted. | +| `flyIdleAnim` | `string` | No | `idleAnim` | Fly-idle animation name; defaults to `idleAnim` when omitted. | +| `flyHoverForwardAnim` | `string` | No | `flyAnim` | Hover animation while flying forward; defaults to `flyAnim` when omitted. | +| `flyHoverBackAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying backward; defaults to `flyIdleAnim` when omitted. | +| `flyHoverLeftAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying left; defaults to `flyIdleAnim` when omitted. | +| `flyHoverRightAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying right; defaults to `flyIdleAnim` when omitted. | +| `flyHoverUpAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying upward; defaults to `flyIdleAnim` when omitted. | +| `flyHoverDownAnim` | `string` | No | `flyIdleAnim` | Hover animation while flying downward; defaults to `flyIdleAnim` when omitted. | +| `drivingAnim` | `string` | No | `idleAnim` | Driving loop animation name; defaults to `idleAnim` when omitted. | +| `gravity` | `number` | No | `-2400` | Base gravity value, scaled by `scale`. | +| `jumpHeight` | `number` | No | `600` | Base jump-height value, scaled by `scale`. | +| `speed` | `number` | No | `200` | Base movement speed, scaled by `scale`. | +| `runSpeed` | `number` | No | `600` | Base run speed, scaled by `scale`. | +| `flySpeed` | `number` | No | `2100` | Base flight speed, scaled by `scale`. | +| `rotateY` | `number` | No | `0` | Initial player facing direction in radians, used to change the model's initial facing direction. | +| `headBoneName` | `string` | No | — | Head bone or node name used for first-person camera attachment. | +| `firstPersonCameraOffset` | `[number, number, number]` | No | Built-in default | Local first-person camera offset; relative to the head bone when `headBoneName` is set, otherwise relative to the capsule. | +| `capsuleRadiusRatio` | `number` | No | `1` | Capsule radius multiplier for fine-tuning collision width. | +| `acceleration` | `number` | No | `30` | XZ acceleration response; larger values accelerate faster. | +| `deceleration` | `number` | No | `30` | XZ deceleration response; larger values stop faster. | + +### `MobileControlsOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `joystick` | `boolean` | No | `true` | Whether to show a joystick with continuous 360° direction input. | +| `jump` | `boolean \| JumpButtonOptions` | No | `true` | Jump / brake button; `false` hides it, or pass an object to customize image and layout. | +| `fly` | `boolean \| MobileButtonOptions` | No | `true` | Flight button; `false` hides it, or pass an object to customize image and layout. | +| `view` | `boolean \| MobileButtonOptions` | No | `true` | View-switch button; `false` hides it, or pass an object to customize image and layout. | +| `vehicle` | `boolean \| MobileButtonOptions` | No | `true` | Enter / exit vehicle button; `false` hides it, or pass an object to customize image and layout. | + +### `MobileButtonOptions` / `JumpButtonOptions` + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `left` / `right` | `number` | No | Built-in position | Distance from the left or right side of the screen in px. Setting `left` without `right` clears the default right positioning. | +| `top` / `bottom` | `number` | No | Built-in position | Distance from the top or bottom of the screen in px. Setting `top` without `bottom` clears the default bottom positioning. | +| `size` | `number` | No | `56` | Circular button diameter in px. | +| `icon` | `string` | No | English label | Custom image URL. | +| `brakeIcon` | `string` | No | `BRAKE` label | Used only by `JumpButtonOptions`; sets the brake image URL in vehicle mode. | + + +## `ColliderDesc` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `motion` | `"static" \| "kinematic" \| "dynamic"` | — | Collider motion type. | +| `shape` | `ColliderShape` | — | Collision shape configuration; supports `mesh`, `box`, and `sphere`. | +| `groups` | `number` | Set automatically based on `motion` | Collision group this collider belongs to. | +| `mask` | `number` | Set automatically based on `motion` | Collision group mask this collider is allowed to collide with. | +| `useWorker` | `boolean` | `false` | Whether to build the BVH asynchronously in a Worker for `mesh + source`. | +| `userData` | `unknown` | — | Custom application data. | +| `follow` | `THREE.Object3D` | — | Scene object followed by a `kinematic` collider. | +| `density` | `number` | `1` | `dynamic` rigid-body density used to calculate mass. | +| `restitution` | `number` | `0.2` | `dynamic` rigid-body restitution. | +| `friction` | `number` | `0.6` | `dynamic` rigid-body friction. | +| `linearDamping` | `number` | `0.4` | `dynamic` rigid-body linear damping. | +| `angularDamping` | `number` | `0.6` | `dynamic` rigid-body angular damping. | +| `gravity` | `number` | Internal default | `dynamic` rigid-body gravity. | +| `velocity` | `THREE.Vector3` | `(0,0,0)` | Initial linear velocity of a `dynamic` rigid body. | +| `angularVelocity` | `THREE.Vector3` | `(0,0,0)` | Initial angular velocity of a `dynamic` rigid body. | +| `mesh` | `THREE.Mesh` | — | Visual Mesh required when creating a built-in `dynamic box / sphere` rigid body. | +| `simulate` | `boolean` | `true` | Whether the built-in dynamic rigid-body system simulates this body. | + +### `ColliderShape` + +#### Mesh + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"mesh"` | Mesh collision shape. | +| `source` | `THREE.Object3D \| THREE.Object3D[]` | Collect geometry from the object(s), merge the collision mesh, and build a BVH. | +| `mesh` | `THREE.Mesh` | Register an already-prepared collision Mesh directly. | + +#### Box + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"box"` | Box collision shape. | +| `halfExtents` | `THREE.Vector3` | Box half-extents, i.e. half of the full size. | +| `position` | `THREE.Vector3` | Initial world position, optional. | +| `quaternion` | `THREE.Quaternion` | Initial world rotation, optional. | + +#### Sphere + +| Field | Type | Description | +| --- | --- | --- | +| `kind` | `"sphere"` | Sphere collision shape. | +| `radius` | `number` | Sphere radius. | +| `position` | `THREE.Vector3` | Initial world position, optional. | + +## `VehicleOptions` + +### Basic Parameters + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `model` | `THREE.Object3D` | — | Root node of the externally loaded vehicle model. | +| `position` | `THREE.Vector3` | — | Initial vehicle world position. | +| `wheelsNames` | `[string, string, string, string]` | — | Array of wheel node names in front-left, front-right, rear-left, rear-right order. | +| `driverSeatPosition` | `THREE.Vector3` | — | Driver-seat capsule center in vehicle chassis-local coordinates. | +| `scale` | `number` | `1` | Vehicle model scale. | +| `driverSeatRotation` | `number` | `0` | Driver-seat horizontal rotation relative to vehicle chassis local space in radians. | +| `modelRotation` | `number` | `-Math.PI / 2` | Initial vehicle model Y-axis rotation. | +| `followVehicleDirection` | `boolean` | `true` | Whether the camera follows vehicle heading while driving. | +| `debug` | `VehicleDebugOptions` | — | Vehicle physics debug visualization settings. | +| `chassis` | `VehicleChassisOptions` | — | Chassis collision-box and damping settings. | +| `suspension` | `VehicleSuspensionOptions` | — | Suspension and tire settings. | +| `steering` | `VehicleSteeringOptions` | — | Steering settings. | +| `grip` | `VehicleGripOptions` | — | Grip and handbrake settings. | +| `power` | `VehiclePowerOptions` | — | Power settings. | + +### `debug` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `showPhysicsBox` | `boolean` | `false` | Show the chassis physics collision box. | +| `showWheelRays` | `boolean` | `false` | Show wheel suspension rays. | +| `showWheelTravel` | `boolean` | `false` | Show wheel Y-axis travel range. | +| `showWheelSpheres` | `boolean` | `false` | Show dynamic wheel collision spheres. | + +### `chassis` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `density` | `number` | `1` | Chassis density; mass is calculated from collision-box volume and density. | +| `linearDamping` | `number` | `0.05` | Chassis linear damping. | +| `angularDamping` | `number` | `0.5` | Chassis angular damping. | +| `clearance` | `number` | Auto-calculated | Height of the chassis box bottom relative to tire contact points. | +| `sizeScale.x` | `number` | `1` | Chassis collision-box X-axis size multiplier. | +| `sizeScale.y` | `number` | `1` | Chassis collision-box Y-axis size multiplier. | +| `sizeScale.z` | `number` | `1` | Chassis collision-box Z-axis size multiplier. | + +### `suspension` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `restLength` | `number` | Auto-calculated | Suspension rest length. | +| `maxTravel` | `number` | `0.35` | Maximum suspension travel. | +| `stiffness` | `number` | `18` | Suspension stiffness. | +| `compression` | `number` | `2.1` | Suspension compression damping. | +| `relaxation` | `number` | `2.5` | Suspension rebound damping. | +| `maxForce` | `number` | `6000` | Maximum suspension force per wheel. | +| `frictionSlip` | `number` | `8` | Longitudinal tire grip. | +| `sideFrictionStiffness` | `number` | `1` | Lateral tire friction stiffness. | +| `rollInfluence` | `number` | `0.12` | Effect of lateral force on body roll. | + +### `steering` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxSteerAngle` | `number` | `Math.PI / 5` | Maximum steering angle in radians. | +| `steerTime` | `number` | `0.45` | Time from center to full steering lock in seconds. | +| `steerReturnTimeSlow` | `number` | `0.55` | Low-speed steering return time in seconds. | +| `steerReturnTimeFast` | `number` | `0.4` | High-speed steering return time in seconds. | +| `highSpeedSteerScale` | `number` | `0.3` | Maximum steering ratio at top speed. | + +### `grip` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxG` | `number` | `1.2` | Maximum lateral acceleration in g. | +| `sideFrictionIdle` | `number` | `1` | Lateral friction while driving straight. | +| `sideFrictionFrontMin` | `number` | `0.55` | Minimum front-wheel lateral friction under high load. | +| `sideFrictionRearMin` | `number` | `0.45` | Minimum rear-wheel lateral friction under high load. | +| `handbrakeRearFriction` | `number` | `0.35` | Rear-wheel lateral friction while handbraking. | +| `handbrakeRearDriveScale` | `number` | `0.65` | Rear-wheel drive-force ratio while handbraking. | +| `handbrakeReleaseTime` | `number` | `0.15` | Recovery time after releasing the handbrake in seconds. | +| `wheelbaseRatio` | `number` | `0.55` | Wheelbase relative to vehicle length. | + +### `power` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `maxSpeed` | `number` | `100` | Base top speed in km/h, scaled by `scale`. | +| `acceleration` | `number` | `5` | Base acceleration in m/s², scaled by `scale`. | +| `deceleration` | `number` | `5` | Base braking deceleration in m/s², scaled by `scale`. | + +# Feedback + +If you have any questions or suggestions, please submit an [issue](https://github.com/hh-hang/three-player-controller/issues). + +# Acknowledgements + +- [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh) +- [three.js](https://github.com/mrdoob/three.js) + +[npm]: https://img.shields.io/npm/v/three-player-controller +[npm-url]: https://www.npmjs.com/package/three-player-controller +[github]: https://img.shields.io/badge/-hh--hang-181717?style=flat&logo=github&logoColor=white&labelColor=888 +[github-url]: https://github.com/hh-hang diff --git a/three-player-controller/example/3dgs.html b/three-player-controller/example/3dgs.html new file mode 100644 index 0000000..87a8099 --- /dev/null +++ b/three-player-controller/example/3dgs.html @@ -0,0 +1,84 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Boost +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+ Z + Scale Character +
+
+
+ +
+ 3DGS: + Huggingface +
+ + + + + + + + + diff --git a/three-player-controller/example/3dgs.js b/three-player-controller/example/3dgs.js new file mode 100644 index 0000000..b5ca138 --- /dev/null +++ b/three-player-controller/example/3dgs.js @@ -0,0 +1,213 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + EquirectangularReflectionMapping, + PerspectiveCamera, + Scene, + Vector3, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import { playerController } from "../src/PlayerController"; +import { SplatMesh, SparkRenderer } from "@sparkjsdev/spark"; +import Stats from 'three/examples/jsm/libs/stats.module.js'; + +let player; +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let gltfLoader; +let stats; + +const pos = new Vector3(1.235, 1.21, -3.9); + +const scaleNormal = 0.01; +const scaleSmall = scaleNormal / 9; +let isSmallScale = false; +let scaleAnimFrame = null; +let isScaling = false; + +init(); + +async function init() { + const cont = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setSize(cont.clientWidth, cont.clientHeight); + renderer.shadowMap.enabled = true; + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.setAnimationLoop(animate); + cont.appendChild(renderer.domElement); + + // 相机 + camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000); + camera.position.copy(pos); + camera.lookAt(pos.x, pos.y, pos.z + 1); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 2000; + controls.dampingFactor = 0.1; + controls.rotateSpeed = 1; + controls.maxPolarAngle = Math.PI / 2; + controls.target.set(pos.x, pos.y, pos.z + 1); + + // 环境光 + const ambient = new AmbientLight(0xffffff, 5); + scene.add(ambient); + + // 背景 + new HDRLoader().load( + "./img/env.hdr", + (texture) => { + texture.mapping = EquirectangularReflectionMapping; + scene.background = texture; + }, + undefined, + (err) => console.warn("HDR 加载失败:", err) + ); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 加载3DGS场景 + initGltfLoader(); + const spark = new SparkRenderer({ renderer }); + scene.add(spark); + const splatURL = './3DGS/3DGS.sog' + const splat = new SplatMesh({ + url: splatURL, + onProgress: (e) => window.setLoaderProgress?.(e.loaded, e.total), + onLoad: (mesh) => { mesh.rotateX(-Math.PI / 2); }, + }); + scene.add(splat); + + // 加载碰撞体 + const colliderGltf = await gltfLoader.loadAsync("./glb/3dgs_collider.glb"); + const playerGltf = await gltfLoader.loadAsync("./glb/josh.glb"); + + // 人物控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: scaleNormal, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "mixamorigHead", + speed: 150, + flySpeed: 1000, + jumpHeight: 400, + }, + initPos: pos, + minCamDistance: 50, + maxCamDistance: 180, + camLookAtHeightRatio: 0.8, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: colliderGltf.scene } }, + ], + enableOverShoulderView: true, + }); + + window.addEventListener("resize", onWindowResize, false); + // 监听按键 + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyZ") return; + if (isScaling) return; // 缩放中不运行 + isSmallScale = !isSmallScale; + animateToScale(isSmallScale ? scaleSmall : scaleNormal, 1); + }); + // 等待高斯泼溅模型加载完毕再隐藏 loader + await splat.initialized.catch(() => { }); + window.hideLoader(); +} + +function animateToScale(targetScale, duration = 0.5) { + if (scaleAnimFrame !== null) { + cancelAnimationFrame(scaleAnimFrame); + scaleAnimFrame = null; + } + + isScaling = true; + const fromScale = player.getScale?.() ?? (isSmallScale ? scaleNormal : scaleSmall); + const startTime = performance.now(); + + const tick = (now) => { + const t = Math.min((now - startTime) / (duration * 1000), 1); + const current = fromScale + (targetScale - fromScale) * t; + player.setPlayerScale(current); + + if (t < 1) { + scaleAnimFrame = requestAnimationFrame(tick); + } else { + scaleAnimFrame = null; + isScaling = false; + } + }; + + scaleAnimFrame = requestAnimationFrame(tick); +} + +// 初始化glb加载器 +function initGltfLoader() { + gltfLoader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(dracoLoader); + const ktx2Loader = new KTX2Loader(); + ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2Loader.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2Loader); +} + + +// 每帧调用 +function animate() { + if (player) { + player.update(); + } else { + controls.update(); + } + renderer.render(scene, camera); + stats?.update(); +} + +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + renderer.setSize(window.innerWidth, window.innerHeight); + camera.updateProjectionMatrix(); + renderer.setPixelRatio(window.devicePixelRatio * 1); +} diff --git a/three-player-controller/example/3dtilesScene.html b/three-player-controller/example/3dtilesScene.html new file mode 100644 index 0000000..85e3dad --- /dev/null +++ b/three-player-controller/example/3dtilesScene.html @@ -0,0 +1,113 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+ +
+
+ Powered by: + Google Photorealistic 3D Tiles + & + Cesium Ion +
+
+ 3D Tiles Loader: + 3DTilesRendererJS + · + Globe: + Tellux +
+
+ + + + + + + + + + + diff --git a/three-player-controller/example/3dtilesScene.js b/three-player-controller/example/3dtilesScene.js new file mode 100644 index 0000000..683b501 --- /dev/null +++ b/three-player-controller/example/3dtilesScene.js @@ -0,0 +1,574 @@ +import tellux from "tellux"; +import { + AxesHelper, + Box3, + Group, + Matrix4, + Quaternion, + Sphere, + Vector3, +} from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; + +// Google Photorealistic 3D Tiles +const GOOGLE_PHOTOREALISTIC_ASSET_ID = 2275207; +const CESIUM_ION_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmMzgwMGY3ZS1jOTMwLTQyNmQtOTkyNS03MDE4ZjlkYmY0MTYiLCJpZCI6MjIzMDk3LCJpYXQiOjE3MTg3NjgwNTN9.FcpK7jiFPzWZL8m6VxRbG7ly8LMecpXnDAMZJX_UehM"; + +// 出生城市 +const CITIES = { + paris: { longitude: 2.2945, latitude: 48.8584, height: 100, hourUTC: 12 }, + tokyo: { longitude: 139.7671, latitude: 35.6812, height: 40, hourUTC: 4 }, + newyork: { longitude: -74.006, latitude: 40.7128, height: 20, hourUTC: 17 }, + london: { longitude: -0.1246, latitude: 51.5007, height: 40, hourUTC: 14 }, + sydney: { longitude: 151.2153, latitude: -33.8568, height: 40, hourUTC: 3 }, + dubai: { longitude: 55.2744, latitude: 25.1972, height: 40, hourUTC: 8 }, + rome: { longitude: 12.4922, latitude: 41.8902, height: 40, hourUTC: 11 }, + saopaulo: { longitude: -46.6559, latitude: -23.5615, height: 1000, hourUTC: 16 }, +}; +const CITY_OPTIONS = { + "New York": "newyork", + Paris: "paris", + Tokyo: "tokyo", + London: "london", + Sydney: "sydney", + Dubai: "dubai", + Rome: "rome", + "São Paulo": "saopaulo", +}; + +const DEG2RAD = Math.PI / 180; +const RAD2DEG = 180 / Math.PI; + +// 动态碰撞:靠近才建,远离再拆 +const TILE_ENTER_RADIUS = 100; +const TILE_EXIT_RADIUS = 150; +const MAX_TILE_ADDS_PER_UPDATE = 1; +const MAX_TILE_REMOVES_PER_UPDATE = 4; + +// 人物速度;飞行时按住 Shift 进入极速 +const BASE_WALK_SPEED = 200; +const BASE_RUN_SPEED = 600; +const BASE_FLY_SPEED = 2100; +const TURBO_SPEED_SCALE = 3; + +const container = document.querySelector("#container"); +if (!(container instanceof HTMLElement)) { + throw new Error("Missing #container"); +} + +const attributionsElement = document.querySelector("#google-attributions"); + +const guiParams = { + city: "newyork", + playerCapsuleDebug: false, + colliderDebug: false, + localAxes: false, +}; + +// Tellux 视图 +const viewer = new tellux.Viewer(container, { + dracoDecoderPath: "https://unpkg.com/three@0.184.0/examples/jsm/libs/draco/gltf/", + useDefaultRenderLoop: false, + camera: { + longitude: CITIES.newyork.longitude, + latitude: CITIES.newyork.latitude, + height: 80, + heading: 0, + pitch: -20, + near: 0.1, + }, + scene: { + atmosphere: { + show: true, + lighting: { + mode: "post-process", + sunLight: true, + skyLight: true, + }, + sky: { + sun: true, + moon: true, + stars: true, + }, + }, + clouds: { + show: true, + coverage: 0.35, + }, + postProcess: { + lensFlare: { enabled: false }, + smaa: { enabled: false }, + }, + }, +}); + +// 关闭默认地球交互,改由人物控制器接管相机 +viewer.controls.enabled = false; +viewer.camera.allowUnderground = true; +viewer.tileset.group.visible = false; + +// 控制器 +const orbitControls = new OrbitControls(viewer.camera.threeCamera, viewer.renderer.domElement); +orbitControls.enablePan = false; + +// 局部坐标轴 +const localAxes = new AxesHelper(5000); +localAxes.name = "local-axes"; +localAxes.visible = false; +localAxes.renderOrder = 10; +localAxes.material.depthTest = false; +localAxes.material.depthWrite = false; +viewer.scene.threeScene.add(localAxes); + +// 帧率 +const stats = new Stats(); +Object.assign(stats.dom.style, { + position: "fixed", + left: "0", + bottom: "0", + top: "auto", + zIndex: "9998", +}); +document.body.appendChild(stats.dom); + +let activeLayer = null; +let localFrame = null; +let tileCollision = null; +let player = null; +let skipCollisionAfterRebase = false; +let running = true; +let turboMode = false; + +// 局部坐标系下每帧把世界到 ECEF 的矩阵写进大气。 +function syncAtmosphereFrame() { + const atmosphere = viewer.atmosphere; + atmosphere.aerialPerspectiveEffect.worldToECEFMatrix.copy(localFrame.localToEcef); + atmosphere.cloudsEffect.worldToECEFMatrix.copy(localFrame.localToEcef); +} + +// 以人物附近 ECEF 点建立局部 Y-up 坐标系,走远后 rebase。 +class LocalFrameManager { + // 建局部 ENU 根节点,并按出生点设原点 + constructor(ellipsoid, origin) { + this.ellipsoid = ellipsoid; + this.rebaseDistance = 10_000; + this.group = new Group(); + this.group.name = "local-frame"; + this.group.matrixAutoUpdate = false; + this.localToEcef = new Matrix4(); + this.ecefToLocal = new Matrix4(); + this.deltaMatrix = new Matrix4(); + this.deltaQuat = new Quaternion(); + this.scratchEcef = new Vector3(); + this.scratchCartographic = { lat: 0, lon: 0, height: 0 }; + this.setOrigin(origin); + } + + // 按经纬高重设局部原点 + setOrigin(origin) { + const height = origin.height ?? 0; + this.ellipsoid.getObjectFrame( + origin.latitude * DEG2RAD, + origin.longitude * DEG2RAD, + height, + 0, + 0, + 0, + this.localToEcef + ); + this.ecefToLocal.copy(this.localToEcef).invert(); + this.group.matrix.copy(this.ecefToLocal); + this.group.updateMatrixWorld(true); + } + + // 人物离原点太远时平移局部系,并同步胶囊、相机和速度 + maybeRebase(target) { + const position = target.getPosition(); + if (!position || position.length() < this.rebaseDistance) return false; + + this.scratchEcef.copy(position).applyMatrix4(this.localToEcef); + this.ellipsoid.getPositionToCartographic(this.scratchEcef, this.scratchCartographic); + + this.deltaMatrix.copy(this.localToEcef); + this.setOrigin({ + latitude: this.scratchCartographic.lat * RAD2DEG, + longitude: this.scratchCartographic.lon * RAD2DEG, + height: this.scratchCartographic.height, + }); + this.deltaMatrix.premultiply(this.ecefToLocal); + this.deltaQuat.setFromRotationMatrix(this.deltaMatrix); + + const capsule = target.getPlayerCapsule(); + if (capsule) this.applyDelta(capsule, this.deltaMatrix); + if (!this.isDescendant(target.camera, capsule)) { + this.applyDelta(target.camera, this.deltaMatrix); + } + target.controls?.target.applyMatrix4(this.deltaMatrix); + target.playerVelocity?.applyQuaternion(this.deltaQuat); + return true; + } + + // 判断 object 是否挂在 ancestor 下面 + isDescendant(object, ancestor) { + if (!ancestor) return false; + let current = object; + while (current) { + if (current === ancestor) return true; + current = current.parent; + } + return false; + } + + // 用 rebase 增量改物体位姿 + applyDelta(object, delta) { + object.updateMatrixWorld(true); + object.matrix.premultiply(delta); + object.matrix.decompose(object.position, object.quaternion, object.scale); + object.updateMatrixWorld(true); + } +} + +// 登记已加载瓦片,只给当前可见且靠近人物的瓦片建运动学 mesh 碰撞。 +class TileCollisionManager { + // 绑定瓦片集和人物,并备好事件回调 + constructor(tileset, targetPlayer) { + this.tileset = tileset; + this.player = targetPlayer; + this.attached = false; + this.tracked = new Map(); + this.scratchCenter = new Vector3(); + this.scratchScale = new Vector3(); + this.scratchBox = new Box3(); + this.scratchSphere = new Sphere(); + this.onLoadModel = (event) => this.track(event.scene, this.isTileVisible(event.tile, event.scene)); + this.onDisposeModel = (event) => this.untrack(event.scene); + this.onVisibilityChange = (event) => this.setVisible(event.scene, event.visible); + } + + // 监听加载 / 卸载 / 可见性,并补登记已在场的瓦片 + attach() { + if (this.attached) return; + this.attached = true; + this.tileset.addEventListener("load-model", this.onLoadModel); + this.tileset.addEventListener("dispose-model", this.onDisposeModel); + this.tileset.addEventListener("tile-visibility-change", this.onVisibilityChange); + this.tileset.forEachLoadedModel((scene, tile) => this.track(scene, this.isTileVisible(tile, scene))); + } + + // 去掉瓦片事件,不再跟踪新变化 + detach() { + if (!this.attached) return; + this.attached = false; + this.tileset.removeEventListener("load-model", this.onLoadModel); + this.tileset.removeEventListener("dispose-model", this.onDisposeModel); + this.tileset.removeEventListener("tile-visibility-change", this.onVisibilityChange); + } + + // 拆掉全部碰撞并停止跟踪 + dispose() { + this.detach(); + for (const entry of this.tracked.values()) this.removeCollider(entry); + this.tracked.clear(); + } + + // 只给当前可见且靠近的瓦片建碰撞;隐藏的(含 REPLACE 父级)立刻拆 + update(playerPos) { + let adds = 0; + let removes = 0; + for (const entry of this.tracked.values()) { + if (!entry.visible) { + this.removeCollider(entry); + continue; + } + const distance = this.distanceToTile(entry, playerPos); + if (!entry.handle && distance < TILE_ENTER_RADIUS) { + if (adds >= MAX_TILE_ADDS_PER_UPDATE) continue; + const handle = this.player.addCollider({ + motion: "kinematic", + useWorker: true, + shape: { kind: "mesh", source: entry.scene }, + follow: entry.scene, + }); + if (handle.id < 0) continue; + entry.handle = handle; + adds += 1; + } else if (entry.handle && distance > TILE_EXIT_RADIUS) { + if (removes >= MAX_TILE_REMOVES_PER_UPDATE) continue; + this.removeCollider(entry); + removes += 1; + } + } + } + + // 人物到瓦片包围球表面的距离;负值表示人在球内 + distanceToTile(entry, playerPos) { + entry.scene.updateWorldMatrix(true, false); + this.scratchCenter.copy(entry.localCenter).applyMatrix4(entry.scene.matrixWorld); + entry.scene.getWorldScale(this.scratchScale); + const worldRadius = entry.localRadius * (this.scratchScale.x || 1); + return playerPos.distanceTo(this.scratchCenter) - worldRadius; + } + + // 以 traversal.visible 为准,没有则看 scene 是否还挂在树上 + isTileVisible(tile, scene) { + if (typeof tile?.traversal?.visible === "boolean") return tile.traversal.visible; + return Boolean(scene?.parent); + } + + // 同步可见性;藏起来就拆碰撞,避免粗父级残留 + setVisible(scene, visible) { + const entry = this.tracked.get(scene) ?? this.track(scene, visible); + if (!entry) return; + entry.visible = visible; + if (!visible) this.removeCollider(entry); + } + + // 登记瓦片局部中心和半径,已跟踪则只改可见性 + track(scene, visible = Boolean(scene.parent)) { + const existing = this.tracked.get(scene); + if (existing) { + existing.visible = visible; + return existing; + } + scene.updateWorldMatrix(true, true); + this.scratchBox.setFromObject(scene); + if (this.scratchBox.isEmpty()) return null; + this.scratchBox.getBoundingSphere(this.scratchSphere); + const localCenter = scene.worldToLocal(this.scratchSphere.center.clone()); + scene.getWorldScale(this.scratchScale); + const scale = this.scratchScale.x || 1; + const entry = { + scene, + localCenter, + localRadius: this.scratchSphere.radius / scale, + visible, + handle: null, + }; + this.tracked.set(scene, entry); + return entry; + } + + // 瓦片卸载时拆碰撞并移出跟踪表 + untrack(scene) { + const entry = this.tracked.get(scene); + if (!entry) return; + this.removeCollider(entry); + this.tracked.delete(scene); + } + + // 从人物控制器移除该瓦片的运动学碰撞 + removeCollider(entry) { + if (!entry.handle) return; + this.player.removeCollider(entry.handle); + entry.handle = null; + } +} + +// 当前选中的出生城市 +function currentCity() { + return guiParams.city in CITIES ? guiParams.city : "newyork"; +} + +// 局部坐标系中的出生点 +function spawnLocalPosition() { + return new Vector3(0, 100, 0); +} + +// 渲染 Google / Cesium 版权信息 +function renderAttributions() { + if (!attributionsElement) return; + const attributions = activeLayer?.tileset.getAttributions() ?? []; + attributionsElement.replaceChildren(); + for (const attribution of attributions) { + if (attribution.type === "image") { + const image = document.createElement("img"); + image.src = String(attribution.value); + image.alt = ""; + attributionsElement.append(image); + continue; + } + if (attribution.value) { + const item = document.createElement("span"); + item.innerHTML = String(attribution.value); + attributionsElement.append(item); + } + } +} + +// 创建或更新局部坐标系 +function ensureLocalFrame(origin) { + if (!localFrame) { + localFrame = new LocalFrameManager(viewer.tileset.ellipsoid, origin); + viewer.scene.threeScene.add(localFrame.group); + return; + } + localFrame.setOrigin(origin); +} + +// 飞行时按住 Shift 切换极速 +function syncTurboMode() { + if (!player) return; + const next = player.isFlying && player.input.shift; + if (next === turboMode) return; + turboMode = next; + player.setPlayerFlySpeed(BASE_FLY_SPEED * (turboMode ? TURBO_SPEED_SCALE : 1)); +} + +// 极速时拉宽视野 +function updateTurboFov() { + const camera = viewer.camera.threeCamera; + const targetFov = turboMode ? 120 : 60; + if (Math.abs(camera.fov - targetFov) > 0.01) { + camera.fov += (targetFov - camera.fov) * 0.01; + camera.updateProjectionMatrix(); + } +} + +// 切换城市或重新出生时悬停,等待周围地形加载 +function respawn() { + ensureLocalFrame(CITIES[currentCity()]); + viewer.clock.hourUTC = CITIES[currentCity()].hourUTC; + player?.reset(spawnLocalPosition()); + if (player && !player.isFlying) player.setInput({ toggleFly: true }); + player?.setSkipCapsuleCollision(true); + skipCollisionAfterRebase = true; +} + +// 加载 Google Photorealistic 3D Tiles +function loadPhotorealisticTiles() { + ensureLocalFrame(CITIES[currentCity()]); + activeLayer = viewer.load3DTileset({ + type: "cesium-ion", + id: "photorealistic-3d-tiles", + assetId: GOOGLE_PHOTOREALISTIC_ASSET_ID, + apiToken: CESIUM_ION_TOKEN, + creasedNormals: true, + materialMode: "unlit", + }); + localFrame.group.add(activeLayer.tileset.group); + localFrame.group.updateMatrixWorld(true); +} + +// 玩家控制器 +async function initPlayer() { + const loader = new GLTFLoader(); + const gltf = await loader.loadAsync("./glb/josh.glb"); + player = new playerController(); + await player.init({ + scene: viewer.scene.threeScene, + camera: viewer.camera.threeCamera, + controls: orbitControls, + initPos: spawnLocalPosition(), + minCamDistance: 5, + maxCamDistance: 200, + playerModelConfig: { + model: gltf.scene, + animations: gltf.animations ?? [], + scale: 0.01, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + speed: BASE_WALK_SPEED, + runSpeed: BASE_RUN_SPEED, + flySpeed: BASE_FLY_SPEED, + acceleration: 500, + deceleration: 10000000, + }, + }); + player.setPlayerCapsuleDebug(guiParams.playerCapsuleDebug); + player.setColliderDebug(guiParams.colliderDebug); + // 启用实际太阳光、天空光和环境反射。 + viewer.atmosphere.setPostProcessMaterialLights(true); +} + +// 创建调试面板 +function initGUI() { + const gui = new GUI({ title: "3D Tiles Scene", width: 260 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + zIndex: "9999", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + gui.add(guiParams, "city", CITY_OPTIONS).name("City").onChange(respawn); + gui.add(guiParams, "playerCapsuleDebug").name("Capsule Debug").onChange((value) => { + player?.setPlayerCapsuleDebug(value); + }); + gui.add(guiParams, "colliderDebug").name("Collider Debug").onChange((value) => { + player?.setColliderDebug(value); + }); + gui.add(guiParams, "localAxes").name("Local Axes").onChange((value) => { + localAxes.visible = value; + }); +} + +// 启动示例 +async function start() { + await viewer.ready; + loadPhotorealisticTiles(); + await initPlayer(); + tileCollision = new TileCollisionManager(activeLayer.tileset, player); + tileCollision.attach(); + respawn(); + initGUI(); + requestAnimationFrame(loop); + // 关闭加载页面 + window.hideLoader?.(); +} + +// 每帧调用 +function loop(time) { + if (!running) return; + if (player) { + if (skipCollisionAfterRebase) { + player.setSkipCapsuleCollision(false); + skipCollisionAfterRebase = false; + } + syncTurboMode(); + void player.update(); + if (localFrame?.maybeRebase(player)) { + player.setSkipCapsuleCollision(true); + skipCollisionAfterRebase = true; + } + const pos = player.getPosition(); + if (pos) tileCollision?.update(pos); + } + updateTurboFov(); + // 局部坐标系下同步大气世界到 ECEF 的变换 + syncAtmosphereFrame(); + viewer.render(time); + renderAttributions(); + stats.update(); + requestAnimationFrame(loop); +} + +window.viewer = viewer; +void start(); + +// 页面卸载时释放资源 +window.addEventListener("beforeunload", () => { + running = false; + tileCollision?.dispose(); + player?.destroy(); + stats.dom.remove(); + viewer.destroy(); +}); diff --git a/three-player-controller/example/control-hints.js b/three-player-controller/example/control-hints.js new file mode 100644 index 0000000..6205816 --- /dev/null +++ b/three-player-controller/example/control-hints.js @@ -0,0 +1,173 @@ +// 按键文字 -> KeyboardEvent.code(可被 data-code 覆盖) +const LABEL_CODES = { + W: ["KeyW", "ArrowUp"], + A: ["KeyA", "ArrowLeft"], + S: ["KeyS", "ArrowDown"], + D: ["KeyD", "ArrowRight"], + Shift: ["ShiftLeft", "ShiftRight"], + Space: ["Space"], + RMB: ["Mouse2"], + LMB: ["Mouse0"], + 1: ["Digit1"], + 4: ["Digit4"], +}; + +// 在 GUI / 输入框里操作时不点亮 HUD +function shouldIgnore(el) { + if (!(el instanceof Element)) return false; + return Boolean(el.closest("input, textarea, select, [contenteditable='true'], .lil-gui")); +} + +// 解析 kbd 对应的键码:优先 data-code,否则按显示文字查表 +function codesFor(kbd) { + const explicit = kbd.getAttribute("data-code"); + if (explicit) return explicit.trim().split(/\s+/).filter(Boolean); + const label = kbd.textContent.replace(/\s+/g, " ").trim(); + if (LABEL_CODES[label]) return LABEL_CODES[label]; + if (/^[A-Za-z]$/.test(label)) return [`Key${label.toUpperCase()}`]; + return []; +} + +const LOOK_SCALE = 0.28; // 鼠标位移换算到指示点的系数 +const LOOK_MAX = 5; // 指示点最大偏移(px) +const LOOK_DECAY = 0.84; // 每帧回弹衰减 +const LOOK_REST = 0.04; // 小于此值视为静止 + +// 确保每个移动提示块都有鼠标图标(HTML 未写时补一个) +function ensureLookMice(hud) { + const mice = []; + for (const move of hud.querySelectorAll(".hint-move")) { + let mouse = move.querySelector(".hint-mouse"); + if (!mouse) { + const cluster = document.createElement("div"); + cluster.className = "hint-cluster"; + cluster.innerHTML = 'Look'; + move.append(cluster); + mouse = cluster.querySelector(".hint-mouse"); + } + mice.push(mouse); + } + return mice; +} + +// 绑定 HUD 按键高亮与鼠标 Look 指示 +export function bindControlHints(hud = document.querySelector(".hud")) { + if (!hud) return; + + const keys = [...hud.querySelectorAll("kbd")]; + const mice = ensureLookMice(hud); + if (!keys.length && !mice.length) return; + + const pressed = new Set(); // 当前按下的 KeyboardEvent.code / MouseN + let lookX = 0; + let lookY = 0; + let lookRaf = 0; + + // 同步 kbd.is-down + const syncKeys = () => { + for (const kbd of keys) { + kbd.classList.toggle("is-down", codesFor(kbd).some((code) => pressed.has(code))); + } + }; + + // 把 Look 偏移写到鼠标图标 + const applyLook = () => { + const x = `${lookX}px`; + const y = `${lookY}px`; + const active = Math.hypot(lookX, lookY) > LOOK_REST; + for (const mouse of mice) { + mouse.style.setProperty("--look-x", x); + mouse.style.setProperty("--look-y", y); + mouse.classList.toggle("is-active", active); + } + }; + + // 指示点回弹;静止后停掉 rAF + const tickLook = () => { + lookX *= LOOK_DECAY; + lookY *= LOOK_DECAY; + if (Math.abs(lookX) < LOOK_REST && Math.abs(lookY) < LOOK_REST) { + lookX = 0; + lookY = 0; + lookRaf = 0; + applyLook(); + return; + } + applyLook(); + lookRaf = requestAnimationFrame(tickLook); + }; + + // 键盘按下 / 抬起 + const onKey = (event, down) => { + if (down && shouldIgnore(event.target)) return; + if (down) pressed.add(event.code); + else pressed.delete(event.code); + syncKeys(); + }; + + // 鼠标按键(LMB / RMB 等) + const onMouse = (event, down) => { + if (down && shouldIgnore(event.target)) return; + const code = `Mouse${event.button}`; + if (down) pressed.add(code); + else pressed.delete(code); + syncKeys(); + }; + + // 指针锁定时始终跟踪;否则仅在按住拖拽转视角时跟踪 + const onMouseMove = (event) => { + if (!document.pointerLockElement && event.buttons === 0) return; + if (!document.pointerLockElement && shouldIgnore(event.target)) return; + lookX = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookX + event.movementX * LOOK_SCALE)); + lookY = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookY + event.movementY * LOOK_SCALE)); + applyLook(); + if (!lookRaf) lookRaf = requestAnimationFrame(tickLook); + }; + + // 失焦时清掉按下态和 Look 偏移 + const clear = () => { + pressed.clear(); + syncKeys(); + lookX = 0; + lookY = 0; + if (lookRaf) { + cancelAnimationFrame(lookRaf); + lookRaf = 0; + } + applyLook(); + }; + + window.addEventListener("keydown", (event) => onKey(event, true)); + window.addEventListener("keyup", (event) => onKey(event, false)); + window.addEventListener("mousedown", (event) => onMouse(event, true)); + window.addEventListener("mouseup", (event) => onMouse(event, false)); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("blur", clear); + document.addEventListener("visibilitychange", () => { + if (document.hidden) clear(); + }); +} + +// 按上下车切换走路 / 载具两套提示,并转调原有回调 +export function bindVehicleHintMode(player, hud = document.querySelector(".hud")) { + if (!player || !hud) return; + + const setMode = (mode) => { + hud.dataset.hintMode = mode; + }; + + setMode(player.getControllerMode?.() === 1 ? "vehicle" : "on-foot"); + + const prevEnter = player.onVehicleEnter; + const prevExit = player.onVehicleExit; + player.onVehicleEnter = (vehicle) => { + setMode("vehicle"); + prevEnter?.(vehicle); + }; + player.onVehicleExit = (vehicle) => { + setMode("on-foot"); + prevExit?.(vehicle); + }; +} + +bindControlHints(); diff --git a/three-player-controller/example/footIK.html b/three-player-controller/example/footIK.html new file mode 100644 index 0000000..accbec8 --- /dev/null +++ b/three-player-controller/example/footIK.html @@ -0,0 +1,87 @@ + + + + + + Foot IK + + + + + +
+ +
+ Character Model: + + Universal Animation Library + +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+ + + + + + + diff --git a/three-player-controller/example/footIK.js b/three-player-controller/example/footIK.js new file mode 100644 index 0000000..c671558 --- /dev/null +++ b/three-player-controller/example/footIK.js @@ -0,0 +1,610 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + BoxGeometry, + DirectionalLight, + Group, + LineBasicMaterial, + LineSegments, + Mesh, + MeshStandardMaterial, + VSMShadowMap, + PerspectiveCamera, + Scene, + BufferGeometry, + Float32BufferAttribute, + Vector3, + WebGLRenderer, +} from "three"; +import { Sky } from "three/addons/objects/Sky.js"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; + +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let player; +let footIK; +let sky; +let gui; +let footIKDebugParams; +let writePelvisOffsetController; +let stats; +let leftMouseSlowMotion = false; +let rightMouseSlowMotion = false; +let savedPlayerTimeScale = 1; + +init(); + +async function init() { + const container = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 0.8; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + renderer.setAnimationLoop(animate); + container.appendChild(renderer.domElement); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 相机 + camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 500); + camera.position.set(-6, 6, 9); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 80; + controls.maxPolarAngle = Math.PI / 2.05; + controls.target.set(0, 0.8, 0); + + // 环境光 + scene.add(new AmbientLight(0xffffff, 1.8)); + + // 平行光 + const sun = new DirectionalLight(0xffffff, 2); + sun.position.set(6, 10, 7); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 0.5; + sun.shadow.camera.far = 35; + sun.shadow.camera.left = -12; + sun.shadow.camera.right = 12; + sun.shadow.camera.top = 10; + sun.shadow.camera.bottom = -10; + sun.shadow.bias = -0.0005; + scene.add(sun); + sky = new Sky(); + sky.scale.setScalar(450000); + scene.add(sky); + const skyUniforms = sky.material.uniforms; + skyUniforms["turbidity"].value = 10; + skyUniforms["rayleigh"].value = 2; + skyUniforms["mieCoefficient"].value = 0.005; + skyUniforms["mieDirectionalG"].value = 0.99; + updateSkySun(sun); + + // 测试场景 + const staticCollider = buildTestCourse(); + scene.add(staticCollider); + addGroundGrid(scene); + + // 人物控制器 + const gltfLoader = new GLTFLoader(); + const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb"); + + player = new playerController(); + await player.init({ + scene, + camera, + controls, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: staticCollider } }, + ], + initPos: new Vector3(-7.5, 0.25, -3.5), + minCamDistance: 10, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableSpringCamera: true, + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: 0.005, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + speed: 100, + runSpeed: 600, + headBoneName: "Head", + firstPersonCameraOffset: [0, 0.15, 0.12], + rotateY: Math.PI / 2, + }, + // keyMap: { toggleFly: null }, // 关闭飞行 + }); + enableModelShadows(player.playerModel); + + // 骨骼配置 + const skeleton = { + hips: "pelvis", + legs: { + left: { + upper: "thigh_l", + lower: "calf_l", + foot: "foot_l", + toe: "ball_l", + }, + right: { + upper: "thigh_r", + lower: "calf_r", + foot: "foot_r", + toe: "ball_r", + }, + }, + }; + + footIK = new FootIK({ + skeleton, + soleHalfWidth: 7, + soleToeExtend: 7, + soleSkinThickness: 1.6, + plantedHeightSpeed: 200, + penetrationLiftSpeed: 200, + predictivePlacement: true, + straightPoleEnabled: true, + }); + player.use(footIK); + + createDebugPanel(); + + window.addEventListener("resize", onResize); + window.addEventListener("mousedown", onMouseDown); + window.addEventListener("mouseup", onMouseUp); + window.addEventListener("blur", releaseRightMouseSlowMotion); + renderer.domElement.addEventListener("contextmenu", event => event.preventDefault()); + + window.hideLoader?.(); +} + +// 创建调试面板 +function createDebugPanel() { + const options = footIK.getOptions(); + const params = { + colliderDebug: false, + playerCapsuleDebug: false, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + predictivePlacement: options.predictivePlacement, + straightPoleEnabled: options.straightPoleEnabled, + pelvisOffset: 0, + writePelvisOffset: 0, + clearPelvisOffset: () => { + params.writePelvisOffset = 0; + writePelvisOffsetController.updateDisplay(); + footIK.clearPelvisOffset(); + }, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + leftPrediction: "disabled", + leftPredictionScore: "--", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + rightPrediction: "disabled", + rightPredictionScore: "--", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + plantedHeightSpeed: options.plantedHeightSpeed, + penetrationLiftSpeed: options.penetrationLiftSpeed, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxPredictionClearance: options.maxPredictionClearance, + }; + footIKDebugParams = params; + + const applyFootIKOptions = (patch) => { + footIK.configure(patch); + }; + + footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled); + + gui = new GUI({ title: "Foot IK Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player.setColliderDebug(value); + }); + collisionFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => { + player.setPlayerCapsuleDebug(value); + }); + collisionFolder.open(); + + const footIKFolder = gui.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK.setEnabled(value); + footIK.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => { + footIK.configure({ predictivePlacement: value }); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + footIK.configure({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable(); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisRaise: value }); + }); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisDrop: value }); + }); + writePelvisOffsetController = pelvisFolder.add(params, "writePelvisOffset", -60, 60, 0.1) + .name("Write Offset") + .decimals(1) + .onChange((value) => { + footIK.setPelvisOffset(value); + }); + pelvisFolder.add(params, "clearPelvisOffset").name("Clear Offset"); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootRaise: value }); + }); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootDrop: value }); + }); + footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ plantedHeightSpeed: value }); + }); + footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ penetrationLiftSpeed: value }); + }); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHalfWidth: value }); + }); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleToeExtend: value }); + }); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHeelExtend: value }); + }); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => { + applyFootIKOptions({ soleSkinThickness: value }); + }); + soleFolder.close(); + + const predictionFolder = footIKFolder.addFolder("Prediction"); + predictionFolder.add(params, "maxPredictionClearance", 0, 100, 1).name("Max Clearance").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPredictionClearance: value }); + }); + predictionFolder.close(); + footIKFolder.open(); +} + +// 更新天空太阳方向。 +function updateSkySun(light) { + sky.material.uniforms["sunPosition"].value.copy(light.position).normalize(); +} + +// 创建测试场景 +function buildTestCourse() { + const root = new Group(); + const groundMat = new MeshStandardMaterial({ color: 0xd8d5cf, roughness: 0.9 }); + const stairMats = [ + new MeshStandardMaterial({ color: 0xb8b0a1, roughness: 0.75 }), + new MeshStandardMaterial({ color: 0xc3a28f, roughness: 0.75 }), + new MeshStandardMaterial({ color: 0xa8b4c4, roughness: 0.75 }), + ]; + const rampMats = [ + new MeshStandardMaterial({ color: 0xadbca8, roughness: 0.8 }), + new MeshStandardMaterial({ color: 0x9fb7b4, roughness: 0.8 }), + new MeshStandardMaterial({ color: 0xc0b48c, roughness: 0.8 }), + ]; + const speedBumpMats = [ + new MeshStandardMaterial({ color: 0xe5b73b, roughness: 0.72 }), + new MeshStandardMaterial({ color: 0x4b4a46, roughness: 0.82 }), + ]; + const wallMat = new MeshStandardMaterial({ color: 0x9a9691, roughness: 0.88 }); + + addBox(root, new Vector3(0, -0.06, 0), new Vector3(20, 0.12, 12), groundMat); + createBoundaryWalls(root, wallMat); + + createStairs(root, new Vector3(-6.5, 0, -3.5), 0.08, stairMats[0]); + createStairs(root, new Vector3(-1.2, 0, -3.5), 0.12, stairMats[1]); + createStairs(root, new Vector3(4.1, 0, -3.5), 0.16, stairMats[2]); + + createSpeedBumpCourse(root, -0.25, speedBumpMats); + + createRamp(root, new Vector3(-6.1, 0, 3), 10, rampMats[0]); + createRamp(root, new Vector3(-0.3, 0, 3), 18, rampMats[1]); + createRamp(root, new Vector3(5.8, 0, 3), 28, rampMats[2]); + + return root; +} + +// 创建地面网格 +function addGroundGrid(root) { + const width = 20; + const depth = 12; + const step = 1; + const points = []; + + for (let x = -width / 2; x <= width / 2 + 0.0001; x += step) { + points.push(new Vector3(x, 0, -depth / 2), new Vector3(x, 0, depth / 2)); + } + for (let z = -depth / 2; z <= depth / 2 + 0.0001; z += step) { + points.push(new Vector3(-width / 2, 0, z), new Vector3(width / 2, 0, z)); + } + + const geometry = new BufferGeometry().setFromPoints(points); + const material = new LineBasicMaterial({ color: 0xbcb7af }); + const grid = new LineSegments(geometry, material); + grid.position.y = 0.004; + root.add(grid); + return grid; +} + +// 创建一组边界墙 +function createBoundaryWalls(root, material) { + const wallHeight = 1.2; + const wallThickness = 0.16; + const halfWidth = 10; + const halfDepth = 6; + const centerY = wallHeight / 2; + + addBox(root, new Vector3(0, centerY, -halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material); + addBox(root, new Vector3(0, centerY, halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material); + addBox(root, new Vector3(-halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material); + addBox(root, new Vector3(halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material); +} + +// 创建一组楼梯 +function createStairs(root, start, stepHeight, material) { + const stepCount = 7; + const stepDepth = 0.42; + const width = 1.9; + + for (let i = 0; i < stepCount; i++) { + const height = stepHeight * (i + 1); + addBox( + root, + new Vector3(start.x + i * stepDepth, height / 2, start.z), + new Vector3(stepDepth, height, width), + material, + ); + } +} + +// 在楼梯和斜坡之间创建 10 个高度统一、宽度不超过 0.32 的长方体。 +function createSpeedBumpCourse(root, centerZ, materials) { + const height = 0.140; + const depth = 1.7; + const widths = [0.14, 0.16, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.32]; + const gaps = [0.32, 0.58, 0.41, 0.75, 0.36, 0.63, 0.48, 0.71, 0.54]; + const totalWidth = widths.reduce((sum, width) => sum + width, 0) + + gaps.reduce((sum, gap) => sum + gap, 0); + let cursorX = -totalWidth / 2; + + widths.forEach((width, index) => { + addBox( + root, + new Vector3(cursorX + width / 2, height / 2, centerZ), + new Vector3(width, height, depth), + materials[index % materials.length], + ); + cursorX += width + (gaps[index] ?? 0); + }); +} + +// 创建一个斜坡 +function createRamp(root, position, angleDeg, material) { + const length = 3.4; + const width = 1.8; + const height = Math.tan(angleDeg * Math.PI / 180) * length; + const ramp = new Mesh(createRampGeometry(length, height, width), material); + ramp.position.copy(position); + ramp.castShadow = true; + ramp.receiveShadow = true; + root.add(ramp); + return ramp; +} + +// 创建斜坡碰撞几何体。 +function createRampGeometry(length, height, width) { + const halfLength = length / 2; + const halfWidth = width / 2; + const vertices = [ + -halfLength, 0, -halfWidth, + halfLength, 0, -halfWidth, + halfLength, height, -halfWidth, + -halfLength, 0, halfWidth, + halfLength, 0, halfWidth, + halfLength, height, halfWidth, + ]; + const indices = [ + 0, 1, 4, 0, 4, 3, + 1, 2, 5, 1, 5, 4, + 0, 3, 5, 0, 5, 2, + 0, 2, 1, + 3, 4, 5, + ]; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + const flatGeometry = geometry.toNonIndexed(); + flatGeometry.computeVertexNormals(); + return flatGeometry; +} + +function addBox(root, position, scale, material) { + const mesh = new Mesh(new BoxGeometry(1, 1, 1), material); + mesh.position.copy(position); + mesh.scale.copy(scale); + mesh.castShadow = true; + mesh.receiveShadow = true; + root.add(mesh); + return mesh; +} + +// 启用模型阴影 +function enableModelShadows(root) { + root?.traverse(obj => { + if (!obj.isMesh) return; + obj.castShadow = true; + obj.receiveShadow = true; + }); +} + +// 每帧调用 +function animate() { + if (player) { + player.update(); + } else { + controls.update(); + } + updateFootIKDebugPanel(); + renderer.render(scene, camera); + stats?.update(); +} + +// 更新 Foot IK 运行状态只读字段。 +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.pelvisOffset = footIK.getPelvisOffset(); + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left"); + footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left")); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); + footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right"); + footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right")); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} + +function formatPredictionScore(value) { + return Number.isFinite(value) ? value.toFixed(2) : "--"; +} + +// 鼠标右键慢动作 +function onMouseDown(event) { + if (!player) return; + if (event.button == 2) { + rightMouseSlowMotion = true; + } + if (event.button == 0) { + leftMouseSlowMotion = true; + } + updatePlayerTimeScale(); +} + +// 鼠标右键慢动作释放 +function onMouseUp(event) { + if (event.button == 2) { + rightMouseSlowMotion = false; + } + if (event.button == 0) { + leftMouseSlowMotion = false; + } + updatePlayerTimeScale(); + +} + +// 集中更新 player.timeScale +function releaseRightMouseSlowMotion() { + rightMouseSlowMotion = false; + leftMouseSlowMotion = false; + updatePlayerTimeScale(); +} + +// 更新 player.timeScale +function updatePlayerTimeScale() { + if (!player) return; + + if (leftMouseSlowMotion) { + player.timeScale = 0; + } else if (rightMouseSlowMotion) { + player.timeScale = 0.1; + } else { + player.timeScale = savedPlayerTimeScale; + } +} + +// 窗口尺寸变化 +function onResize() { + const container = document.querySelector("#container"); + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); +} diff --git a/three-player-controller/example/glTF.html b/three-player-controller/example/glTF.html new file mode 100644 index 0000000..8cc2209 --- /dev/null +++ b/three-player-controller/example/glTF.html @@ -0,0 +1,133 @@ + + + + + + three-player-controller + + + + + +
+ +
+
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ E + Enter +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+
+
+
+
+ W + A + S + D +
+ Drive +
+
+ + Look +
+
+
+
+ Shift + Drift +
+
+ Space + Brake +
+
+ E + Exit +
+
+ V + Toggle View +
+
+ R + Reset Vehicle +
+
+
+
+ +
+ Scene model: + Sketchfab +
+ Vehicle model: + Generic Sedan Car +
+ + + + + + + + + diff --git a/three-player-controller/example/glTF.js b/three-player-controller/example/glTF.js new file mode 100644 index 0000000..ddb00c1 --- /dev/null +++ b/three-player-controller/example/glTF.js @@ -0,0 +1,1128 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + CircleGeometry, + DoubleSide, + Float32BufferAttribute, + Timer, + EquirectangularReflectionMapping, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + Raycaster, + Scene, + SphereGeometry, + Vector2, + Vector3, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; +import { bindVehicleHintMode } from "./control-hints.js"; +import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; +import Stats from 'three/examples/jsm/libs/stats.module.js'; +import { CSM } from "three/examples/jsm/csm/CSM.js"; +import { createVolumeCloud, disposeVolumeCloud, updateVolumeCloud } from "./volumeCloud.js"; + +let player; +const scene = new Scene(); + +let camera; +let renderer; +let controls; +let gltfLoader; +let guiParams = null; +let footIK = null; +let footIKDebugParams = null; +let gui = null; + +let stats = null; +let csm = null; +let currentBlobUrl = null; +let platform = null; +let platformCloud = null; +let dynamicPlatforms = []; +const dynamicPlatformXPath = [ + new Vector3(20.94, 3.70, 14.89), + new Vector3(-1.32, 7.65, 14.83), + new Vector3(-19.85, 14.38, 8.77), +]; +const dynamicPlatformXSegments = dynamicPlatformXPath.slice(0, -1).map((point, index) => ({ + from: point, + to: dynamicPlatformXPath[index + 1], + length: point.distanceTo(dynamicPlatformXPath[index + 1]), +})); +const dynamicPlatformXLength = dynamicPlatformXSegments.reduce((sum, segment) => sum + segment.length, 0); +const animTimer = new Timer(); + +let globalScale = 1; +let previewMesh = null; +let previewMode = false; +let previewHintEl = null; + +// CSM 基准参数 +const CSM_BASE = { maxFar: 30, lightNear: 0.1, lightFar: 50, lightMargin: 30, lightIntensity: 10 }; +const previewRaycaster = new Raycaster(); +const previewMouse = new Vector2(); + +const modelUrl = "./glb/crash_junction.glb"; +const pos = new Vector3(21.5, 4, 15); + +// 人物模型配置 +function easeEndsLinearMiddle(progress, easeRatio = 0.18) { + const ease = Math.min(Math.max(easeRatio, 0.001), 0.49); + const maxSpeed = 1 / (1 - ease); + if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease); + if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease); + return maxSpeed * (progress - ease / 2); +} + +// 根据整条路径的总长度定位平台 +function setPositionOnXPath(mesh, progress) { + if (!dynamicPlatformXSegments.length || dynamicPlatformXLength <= 0) return; + + let targetDistance = progress * dynamicPlatformXLength; + for (const segment of dynamicPlatformXSegments) { + if (targetDistance <= segment.length) { + mesh.position.lerpVectors(segment.from, segment.to, targetDistance / segment.length); + return; + } + targetDistance -= segment.length; + } + + mesh.position.copy(dynamicPlatformXPath[dynamicPlatformXPath.length - 1]); +} + +const PLAYER_MODELS = { + josh: { + url: "./glb/josh.glb", + scale: 0.001, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: ["jump", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + }, + maw: { + url: "./glb/maw.glb", + scale: 0.005, + idleAnim: "idle", + walkAnim: "walk", + runAnim: "run", + jumpAnim: "jump", + flyAnim: "flying", + flyIdleAnim: "flyidle", + headBoneName: "mixamorigHead", + rotateY: Math.PI, + }, + ual: { + url: "./glb/ual.glb", + scale: 0.001, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "Head", + rotateY: Math.PI, + firstPersonCameraOffset: [0, 0.15, 0.12], + }, +}; + +// 车辆配置 +const VEHICLE_TUNING = { + followVehicleDirection: true, + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, + chassis: { + density: 1, + linearDamping: 0.05, + angularDamping: 0.5, + clearance: 0.25, + sizeScale: { x: 0.9, y: 0.65, z: 0.9 }, + }, + suspension: { + restLength: 0.15, + maxTravel: 0.15, + stiffness: 25, + compression: 2.1, + relaxation: 2.5, + maxForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, + }, + steering: { + maxSteerAngle: Math.PI / 5, + steerTime: 0.45, + steerReturnTimeSlow: 0.55, + steerReturnTimeFast: 0.4, + highSpeedSteerScale: 0.3, + }, + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, + power: { + maxSpeed: 100, + acceleration: 5, + deceleration: 5, + }, +}; + +const VEHICLE_CONFIG = { + url: "./glb/sedan.glb", + scale: 0.1, + wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"], + followVehicleDirection: VEHICLE_TUNING.followVehicleDirection, + debug: VEHICLE_TUNING.debug, + chassis: VEHICLE_TUNING.chassis, + suspension: VEHICLE_TUNING.suspension, + steering: VEHICLE_TUNING.steering, + grip: VEHICLE_TUNING.grip, + power: VEHICLE_TUNING.power, + driverSeatPosition: new Vector3(-0.6, 1.0, 0.4), + driverSeatRotation: -Math.PI / 2, +}; + +init(); + +function setupCSMMaterial(material) { + if (!material || !csm) return; + const mats = Array.isArray(material) ? material : [material]; + mats.forEach((m) => { + csm.setupMaterial(m); + // 材质可能已在异步加载期间编译,注入 CSM 后必须刷新 shader。 + m.needsUpdate = true; + }); +} + +function createCSM(shadowMapSize, scale) { + const c = new CSM({ + maxFar: CSM_BASE.maxFar * scale, + cascades: 3, + mode: "practical", + parent: scene, + shadowMapSize, + shadowBias: -0.00001, + lightDirection: new Vector3(-1, -2, -1).normalize(), + lightIntensity: CSM_BASE.lightIntensity, + lightNear: CSM_BASE.lightNear * scale, + lightFar: CSM_BASE.lightFar * scale, + camera, + fade: true, + lightMargin: CSM_BASE.lightMargin * scale, + }); + c.lights.forEach((light, index) => { + const biasMult = Math.pow(2, index); + light.shadow.bias = -0.0001 * biasMult; + light.shadow.normalBias = 0.002 * biasMult; + }); + return c; +} + +function recreateCSM(scale) { + csm.remove(); + csm.dispose(); + const maxTextureSize = renderer.capabilities.maxTextureSize; + csm = createCSM(Math.min(2048, maxTextureSize), scale); + // 重新绑定场景内所有网格的材质 + scene.traverse((child) => { + if (child.isMesh) setupCSMMaterial(child.material); + }); +} + +function getModelScale(key) { + return PLAYER_MODELS[key].scale * globalScale; +} + +async function loadPlayerModelConfig(key) { + const m = PLAYER_MODELS[key]; + const gltf = await gltfLoader.loadAsync(m.url); + const { url, ...rest } = m; + return { + ...rest, + model: gltf.scene, + animations: gltf.animations, + scale: rest.scale * globalScale, + }; +} + +async function loadVehicleConfig(extra = {}) { + const gltf = await gltfLoader.loadAsync(VEHICLE_CONFIG.url); + const { url, ...rest } = VEHICLE_CONFIG; + return { + ...rest, + model: gltf.scene, + ...extra, + }; +} + +// 加载车辆。 +async function spawnSceneSedan() { + await player.loadVehicleModel(await loadVehicleConfig({ + scale: VEHICLE_CONFIG.scale * globalScale, + position: pos.clone().add(new Vector3(0, -0.3, -0.5)), + })); + + const sedan = player.getAllVehicles().at(-1); + sedan?.vehicleGroup?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (!material) return; + material.metalness = 0.8; + material.roughness = 0; + }); + }); + applyVehicleTuning(); +} + +// Foot IK 使用通用骨骼名识别,模型切换后由插件自动重新绑定。 +function attachFootIK() { + const p = guiParams; + footIK = new FootIK({ + enabled: p?.footIKEnabled ?? true, + debug: (p?.footIKEnabled ?? true) && (p?.footIKDebug ?? false), + maxPelvisRaise: p?.maxPelvisRaise ?? 36, + maxPelvisDrop: p?.maxPelvisDrop ?? 36, + maxFootRaise: p?.maxFootRaise ?? 60, + maxFootDrop: p?.maxFootDrop ?? 36, + soleHalfWidth: p?.soleHalfWidth ?? 7, + soleToeExtend: p?.soleToeExtend ?? 7, + soleHeelExtend: p?.soleHeelExtend ?? 3, + soleSkinThickness: p?.soleSkinThickness ?? 1.6, + straightPoleEnabled: p?.straightPoleEnabled ?? true, + }); + player.use(footIK); + if (p) footIKDebugParams = p; +} + +/** 动态球视觉 */ +function createDemoSphereMesh(color = 0x88c0d0) { + const geo = new SphereGeometry(1, 24, 16); + const pos = geo.attributes.position; + const colors = new Float32Array(pos.count * 3); + const r = ((color >> 16) & 255) / 255; + const g = ((color >> 8) & 255) / 255; + const b = (color & 255) / 255; + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i); + const y = pos.getY(i); + const band = Math.sin(x * 8 + y * 2) > 0; + const dark = band ? 0.35 : 1; + colors[i * 3] = r * dark; + colors[i * 3 + 1] = g * dark; + colors[i * 3 + 2] = b * dark; + } + geo.setAttribute("color", new Float32BufferAttribute(colors, 3)); + const mesh = new Mesh( + geo, + new MeshStandardMaterial({ vertexColors: true, roughness: 0.55, metalness: 0.05 }), + ); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +// 创建动态平台 +function createDynamicPlatform({ + position, + radius = 0.16, + cloudScale = [0.32, 0.15, 0.32], + motion = null, +}) { + // 网格 + const mesh = new Mesh( + new CircleGeometry(radius, 32), + new MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.7, metalness: 0, roughness: 0.5, side: DoubleSide }), + ); + mesh.position.copy(position); + mesh.rotation.x = -Math.PI / 2; + mesh.material.visible = false; + scene.add(mesh); + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: mesh }, + follow: mesh, + }); + + // 体积云 + const cloud = createVolumeCloud({ + scale: cloudScale, + opacity: 0.28, + steps: 80, + }); + cloud.position.set(0, 0, 0); + cloud.rotation.x = Math.PI / 2; + mesh.add(cloud); + + // 缓存数据 + const entry = { + mesh, + cloud, + basePosition: position.clone(), + motion, + }; + dynamicPlatforms.push(entry); + return entry; +} + +// 更新动态平台 +function updateDynamicPlatforms() { + const t = animTimer.getElapsed(); + dynamicPlatforms.forEach((entry) => { + const { mesh, basePosition, motion, cloud } = entry; + if (motion) { + // 还原基点 + if (motion.axis === "y") { + mesh.position.copy(basePosition); + const amount = Math.sin(t * motion.speed) * motion.distance; + // 纵向往返 + mesh.position.y = basePosition.y + amount + motion.distance; + } else if (motion.axis === "x") { + if (player.getActiveKinematicCollider()?.source === mesh && player.getIsOnGround()) { + entry.motionElapsed = (entry.motionElapsed ?? 0) + player.getCurrentDelta(); + + const phase = (entry.motionElapsed * motion.speed / Math.PI) % 2; + const rawProgress = phase <= 1 ? phase : 2 - phase; + const progress = easeEndsLinearMiddle(rawProgress); + + setPositionOnXPath(mesh, progress); + } + } + } + // 更新体积云 + updateVolumeCloud(cloud, camera); + }); +} + +// 移除平台 +function removeDynamicPlatforms() { + dynamicPlatforms.forEach(({ mesh, cloud }) => { + // 清理碰撞 + player?.removeKinematicCollider(mesh); + disposeVolumeCloud(cloud); + scene.remove(mesh); + }); + dynamicPlatforms = []; + platform = null; + platformCloud = null; +} + +async function init() { + const cont = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setSize(cont.clientWidth, cont.clientHeight); + renderer.shadowMap.enabled = false; + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.setAnimationLoop(animate); + cont.appendChild(renderer.domElement); + + // 相机 + camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000); + camera.position.copy(pos); + camera.lookAt(pos.x, pos.y, pos.z + 1); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 2000; + controls.dampingFactor = 0.1; + controls.rotateSpeed = 1; + controls.maxPolarAngle = Math.PI / 2; + controls.target.set(pos.x, pos.y, pos.z + 1); + + const maxTextureSize = renderer.capabilities.maxTextureSize; + const shadowMapSize = Math.min(2048, maxTextureSize); + // 级联阴影 + csm = createCSM(shadowMapSize, 1); + csm.lights.forEach((light, index) => { + const biasMult = Math.pow(2, index); + light.shadow.bias = -0.0001 * biasMult; + light.shadow.normalBias = 0.002 * biasMult; + }); + + // 环境光 + const ambient = new AmbientLight(0xffffff, 5); + scene.add(ambient); + + // 背景 + new HDRLoader().load( + "./img/env.hdr", + (texture) => { + texture.mapping = EquirectangularReflectionMapping; + scene.background = texture; + }, + undefined, + (err) => console.warn("HDR 加载失败:", err) + ); + + // 帧率显示 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0", + left: "0", + top: "auto", + zIndex: "9998", + }); + document.body.appendChild(stats.dom); + + // 加载场景 + initGltfLoader(); + const sceneModel = await initGLBScene(modelUrl); + renderer.render(scene, camera); + + // 人物控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + ...(await loadPlayerModelConfig("josh")), + speed: 150, + runSpeed: 600, + }, + initPos: pos, + minCamDistance: 8, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableSpringCamera: true, + springCameraTime: 0.1, + enableOverShoulderView: true, + camOverShoulderOffsetRatio: 0, + enableZoom: true, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: sceneModel } }, + ], + }); + bindVehicleHintMode(player); + attachFootIK(); + + // 先完成角色材质的 CSM 注入,避免等待车辆时用非 CSM shader 提前编译。 + player.getPlayerModel()?.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); + + await spawnSceneSedan(); + + // 创建动态平台 + const liftPlatform = createDynamicPlatform({ + position: new Vector3(22, 2.76, 9.7), + motion: { axis: "y", distance: 4, speed: 0.5 }, + }); + platform = liftPlatform.mesh; + platformCloud = liftPlatform.cloud; + + createDynamicPlatform({ + position: dynamicPlatformXPath[0], + motion: { axis: "x", distance: 3, speed: 0.1 }, + }); + + // 调试 + initGUI(); + + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyR" || e.repeat) return; + player.resetVehicle(); + }); + + window.addEventListener("resize", onWindowResize, false); + + // 关闭加载页面 + window.hideLoader(); +} + +// 初始化glb加载器 +function initGltfLoader() { + gltfLoader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(dracoLoader); + const ktx2Loader = new KTX2Loader(); + ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2Loader.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2Loader); +} + +// 加载场景 +async function initGLBScene(url, modelScale = [10, 10, 10]) { + try { + const gltf = await gltfLoader.loadAsync(url); + const model = gltf.scene; + model.name = "sceneGLB"; + model.scale.set(...modelScale); + model.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); + scene.add(model); + return model; + } catch (e) { + console.error("GLB 加载失败:", e); + return null; + } +} + +// 更换场景 +async function replaceScene(file) { + const blobUrl = URL.createObjectURL(file); + + // 退出指针锁定 + if (document.pointerLockElement) { + await new Promise((resolve) => { + document.addEventListener("pointerlockchange", resolve, { once: true }); + document.exitPointerLock(); + }); + } + + // 移除旧场景并释放 GPU 资源 + const old = scene.getObjectByName("sceneGLB"); + if (old) { + old.traverse((child) => { + if (child.isMesh) { + child.geometry?.dispose(); + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => m?.dispose()); + } + }); + scene.remove(old); + } + + // 销毁旧玩家 + removeDynamicPlatforms(); + player?.destroy(); + player = null; + footIK = null; + footIKDebugParams = null; + + // 加载新场景 + await initGLBScene(blobUrl, [1, 1, 1]); + + // 释放上一个 blob URL,保存新的 + if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); + currentBlobUrl = blobUrl; + + // 进入预览模式 + await enterPreviewMode(guiParams?.playerModel ?? "josh"); +} + +// 进入预览模式 +async function enterPreviewMode(playerModelKey) { + previewMode = true; + + const modelConfig = PLAYER_MODELS[playerModelKey]; + const gltf = await gltfLoader.loadAsync(modelConfig.url); + previewMesh = gltf.scene; + previewMesh.scale.setScalar(modelConfig.scale * globalScale); + previewMesh.visible = false; + previewMesh.traverse((child) => { + if (child.isMesh) { + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => { + m.transparent = true; + m.opacity = 0.5; + m.depthWrite = false; + }); + } + }); + scene.add(previewMesh); + + if (!previewHintEl) { + previewHintEl = document.createElement("div"); + Object.assign(previewHintEl.style, { + position: "fixed", bottom: "20px", left: "50%", + transform: "translateX(-50%)", background: "rgba(0,0,0,0.65)", + color: "#fff", padding: "12px 24px", borderRadius: "8px", + fontSize: "14px", zIndex: "9999", textAlign: "center", + display: "flex", flexDirection: "column", alignItems: "center", gap: "8px", + }); + const tip = document.createElement("span"); + tip.textContent = "移动鼠标预览人物位置 · 双击确认放置"; + const sliderRow = document.createElement("div"); + Object.assign(sliderRow.style, { display: "flex", alignItems: "center", gap: "8px" }); + const label = document.createElement("span"); + label.textContent = "人物比例:"; + // 对数刻度:slider在log10空间线性滑动,0.01~1000 各数量级间距相同 + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "-2"; // log10(0.01) + slider.max = "3"; // log10(1000) + slider.step = "0.01"; + slider.value = String(Math.log10(globalScale)); + slider.style.width = "160px"; + const sliderVal = document.createElement("span"); + sliderVal.textContent = globalScale.toFixed(2); + slider.addEventListener("input", () => { + globalScale = Math.pow(10, parseFloat(slider.value)); + sliderVal.textContent = globalScale.toFixed(2); + if (previewMesh) { + previewMesh.scale.setScalar(modelConfig.scale * globalScale); + } + }); + sliderRow.append(label, slider, sliderVal); + previewHintEl.append(tip, sliderRow); + document.body.appendChild(previewHintEl); + } else { + // 重置滑块值 + const slider = previewHintEl.querySelector("input[type=range]"); + const sliderVal = previewHintEl.querySelector("span:last-child"); + if (slider) { slider.value = String(Math.log10(globalScale)); } + if (sliderVal) { sliderVal.textContent = globalScale.toFixed(2); } + } + previewHintEl.style.display = "flex"; + + renderer.domElement.addEventListener("mousemove", onPreviewMouseMove); + renderer.domElement.addEventListener("dblclick", onPreviewDblClick); +} + +// 退出预览模式 +function exitPreviewMode() { + previewMode = false; + controls.enableZoom = true; + + if (previewMesh) { + previewMesh.traverse((child) => { + if (child.isMesh) { + child.geometry?.dispose(); + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => m?.dispose()); + } + }); + scene.remove(previewMesh); + previewMesh = null; + } + + if (previewHintEl) previewHintEl.style.display = "none"; + + renderer.domElement.removeEventListener("mousemove", onPreviewMouseMove); + renderer.domElement.removeEventListener("dblclick", onPreviewDblClick); +} + +// 预览:鼠标移动 → 射线交点跟随 +function onPreviewMouseMove(e) { + if (!previewMode || !previewMesh) return; + previewMouse.set( + (e.clientX / window.innerWidth) * 2 - 1, + -(e.clientY / window.innerHeight) * 2 + 1, + ); + previewRaycaster.setFromCamera(previewMouse, camera); + const sceneModel = scene.getObjectByName("sceneGLB"); + if (!sceneModel) return; + const hits = previewRaycaster.intersectObject(sceneModel, true); + if (hits.length > 0) { + previewMesh.position.copy(hits[0].point); + previewMesh.visible = true; + } else { + previewMesh.visible = false; + } +} + +// 预览:双击 → 确认位置,初始化玩家 +async function onPreviewDblClick() { + if (!previewMode || !previewMesh?.visible) return; + const initPos = previewMesh.position.clone(); + const spawnKey = guiParams?.playerModel ?? "josh"; + const spawnScale = getModelScale(spawnKey); + initPos.y += 180 * spawnScale * 0.75; + exitPreviewMode(); + + recreateCSM(globalScale); + + player = new playerController(); + const sceneModel = scene.getObjectByName("sceneGLB"); + await player.init({ + scene, + camera, + controls, + playerModelConfig: { + ...(await loadPlayerModelConfig(spawnKey)), + speed: guiParams?.playerSpeed ?? 150, + runSpeed: guiParams?.playerRunSpeed ?? 600, + }, + initPos, + minCamDistance: guiParams?.minCamDistance ?? 8, + maxCamDistance: guiParams?.maxCamDistance ?? 300, + camLookAtHeightRatio: guiParams?.camLookAtHeightRatio ?? 0.5, + enableSpringCamera: guiParams?.enableSpringCamera ?? true, + springCameraTime: guiParams?.springCameraTime ?? 0.1, + enableOverShoulderView: guiParams?.enableOverShoulderView ?? true, + camOverShoulderOffsetRatio: guiParams?.camOverShoulderOffsetRatio ?? 0, + thirdMouseMode: guiParams?.thirdMouseMode ?? 1, + enableZoom: guiParams?.enableZoom ?? true, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: sceneModel } }, + ], + }); + bindVehicleHintMode(player); + attachFootIK(); + + player.getPlayerModel()?.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + } + }); +} + +// 每帧调用 +function animate(timestamp) { + animTimer.update(timestamp); + + if (player) { + player.update(); + } else { + controls.update(); + } + + updateDynamicPlatforms(); + + csm?.update(); + updateFootIKDebugPanel(); + + renderer.render(scene, camera); + + stats?.update(); +} + +function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + renderer.setSize(window.innerWidth, window.innerHeight); + camera.updateProjectionMatrix(); + renderer.setPixelRatio(window.devicePixelRatio * 1); +} + +// 调试 +function applyVehicleTuning() { + const t = VEHICLE_TUNING; + const vehicles = player?.getAllVehicles?.() ?? []; + for (const v of vehicles) { + player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale); + player?.setVehicleClearance?.(v, t.chassis.clearance); + const he = v.halfExtents; + const volume = 8 * he.x * he.y * he.z; + v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density)); + v.chassisBody.linearDamping = t.chassis.linearDamping; + v.chassisBody.angularDamping = t.chassis.angularDamping; + + Object.assign(v.steering, t.steering); + Object.assign(v.grip, t.grip); + v.maxSpeed = t.power.maxSpeed * v.scale; + v.acceleration = t.power.acceleration * v.scale; + v.deceleration = t.power.deceleration * v.scale; + v.followVehicleDirection = t.followVehicleDirection; + + const sus = t.suspension; + const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness)); + const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2); + v.sideFrictionStiffness = sus.sideFrictionStiffness; + + const wheelCount = v.vehicleController.numWheels(); + for (let i = 0; i < wheelCount; i++) { + v.vehicleController.setWheelSuspensionRestLength(i, rest); + v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale); + v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness); + v.vehicleController.setWheelSuspensionCompression(i, sus.compression); + v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation); + v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce); + v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip); + v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness); + v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence); + } + + if (v.physicsBoxMesh) { + if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh); + else v.vehicleGroup.remove(v.physicsBoxMesh); + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel; + if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres; + } + if (player?.vehicle?.params?.debug) { + Object.assign(player.vehicle.params.debug, t.debug); + } +} + +function initGUI() { + const options = footIK.getOptions(); + const playerScale = player.playerModelConfig.scale; + const toBasePlayerValue = (value) => value / playerScale; + const params = { + playerModel: "josh", + showShadow: renderer.shadowMap.enabled, + colliderDebug: false, + playerCapsuleDebug: false, + mouseSensitivity: player.cam.sensitivity, + gravity: toBasePlayerValue(player.gravity), + jumpHeight: toBasePlayerValue(player.jumpHeight), + playerSpeed: toBasePlayerValue(player.playerSpeed), + playerRunSpeed: toBasePlayerValue(player.playerRunSpeed), + flySpeed: toBasePlayerValue(player.playerFlySpeed), + playerAcceleration: player.playerAcceleration, + playerDeceleration: player.playerDeceleration, + timeScale: player.timeScale, + minCamDistance: toBasePlayerValue(player.cam.minDist), + maxCamDistance: toBasePlayerValue(player.cam.maxDist), + camLookAtHeightRatio: player.cam.lookAtHeightRatio, + enableOverShoulderView: player.enableOverShoulderView, + camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio, + enableSpringCamera: player.cam.enableSpringCamera, + springCameraTime: player.cam.springCameraTime, + thirdMouseMode: player.cam.mouseMode, + enableZoom: player.cam.zoomEnabled, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + straightPoleEnabled: options.straightPoleEnabled, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI, + }; + guiParams = params; + footIKDebugParams = params; + + const applyFootIKOptions = (options) => footIK?.configure(options); + + gui = new GUI({ title: "Showcase Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const sceneFolder = gui.addFolder("Scene"); + sceneFolder.add(params, "playerModel", Object.keys(PLAYER_MODELS)) + .name("Character Model") + .onChange(async (modelKey) => { + await player?.switchPlayerModel(await loadPlayerModelConfig(modelKey)); + player?.getPlayerModel()?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + setupCSMMaterial(child.material); + if (modelKey !== "ual") return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (!material) return; + material.metalness = 0.8; + material.roughness = 0; + }); + }); + }); + sceneFolder.add(params, "showShadow").name("Shadows").onChange((value) => { + renderer.shadowMap.enabled = value; + scene.traverse((child) => { + if (!child.isMesh) return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => { + if (material) material.needsUpdate = true; + }); + }); + }); + sceneFolder.open(); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player?.setColliderDebug(value); + }); + collisionFolder.open(); + + const characterFolder = gui.addFolder("Character"); + characterFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => { + player?.setPlayerCapsuleDebug?.(value); + }); + + const movementFolder = characterFolder.addFolder("Movement"); + movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => player?.setGravity(value)); + movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => player?.setJumpHeight(value)); + movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => player?.setPlayerSpeed(value)); + movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => player?.setPlayerRunSpeed(value)); + movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => player?.setPlayerFlySpeed(value)); + movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => { + if (player) player.playerAcceleration = value; + }); + movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => { + if (player) player.playerDeceleration = value; + }); + movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => { + if (player) player.timeScale = value; + }); + movementFolder.close(); + + const characterCameraFolder = characterFolder.addFolder("Camera"); + characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => player?.setMouseSensitivity(value)); + characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => player?.setMinCamDistance(value)); + characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => player?.setMaxCamDistance(value)); + characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => player?.setCamLookAtHeightRatio(value)); + characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => player?.setOverShoulderView(value)); + characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => player?.setCamOverShoulderOffsetRatio(value)); + characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => { + if (player) player.cam.enableSpringCamera = value; + }); + characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => { + if (player) player.cam.springCameraTime = value; + }); + characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => player?.setThirdMouseMode(Number(value))); + characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => player?.setEnableZoom(value)); + characterCameraFolder.close(); + + const footIKFolder = characterFolder.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK?.setEnabled(value); + footIK?.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK?.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + applyFootIKOptions({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisRaise: value })); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxPelvisDrop: value })); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => applyFootIKOptions({ maxFootRaise: value })); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => applyFootIKOptions({ maxFootDrop: value })); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => applyFootIKOptions({ soleHalfWidth: value })); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleToeExtend: value })); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => applyFootIKOptions({ soleHeelExtend: value })); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => applyFootIKOptions({ soleSkinThickness: value })); + soleFolder.close(); + footIKFolder.close(); + characterFolder.open(); + + const t = VEHICLE_TUNING; + const vehicleFolder = gui.addFolder("Vehicle"); + + const chassisFolder = vehicleFolder.addFolder("Chassis"); + chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning); + chassisFolder.close(); + + const vehicleCameraFolder = vehicleFolder.addFolder("Camera"); + vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning); + vehicleCameraFolder.close(); + + const vehicleDebugFolder = vehicleFolder.addFolder("Debug"); + vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning); + vehicleDebugFolder.close(); + + const drivingFolder = vehicleFolder.addFolder("Driving"); + drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning); + drivingFolder.close(); + + const handlingFolder = vehicleFolder.addFolder("Handling"); + handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((degrees) => { + t.steering.maxSteerAngle = degrees * Math.PI / 180; + applyVehicleTuning(); + }); + handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning); + handlingFolder.close(); + + const suspensionFolder = vehicleFolder.addFolder("Suspension"); + suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning); + suspensionFolder.close(); + + vehicleFolder.open(); +} + +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} diff --git a/three-player-controller/example/index.html b/three-player-controller/example/index.html new file mode 100644 index 0000000..e9d47b2 --- /dev/null +++ b/three-player-controller/example/index.html @@ -0,0 +1,383 @@ + + + + + + three-player-controller + + + + + + + + + + + +
+ Three.js + CesiumJS +
+ +

three-player-controller

+

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

+ +
+
+
+
Demo
+
+ +
+ +
+
+
Model Formats
+
+ +
+
+ + diff --git a/three-player-controller/example/public/3DGS/3DGS.sog b/three-player-controller/example/public/3DGS/3DGS.sog new file mode 100644 index 0000000..348faaf Binary files /dev/null and b/three-player-controller/example/public/3DGS/3DGS.sog differ diff --git a/three-player-controller/example/public/audio/gun_shot.mp3 b/three-player-controller/example/public/audio/gun_shot.mp3 new file mode 100644 index 0000000..8cf9774 Binary files /dev/null and b/three-player-controller/example/public/audio/gun_shot.mp3 differ diff --git a/three-player-controller/example/public/audio/reload.mp3 b/three-player-controller/example/public/audio/reload.mp3 new file mode 100644 index 0000000..f90f2a9 Binary files /dev/null and b/three-player-controller/example/public/audio/reload.mp3 differ diff --git a/three-player-controller/example/public/glb/3dgs_collider.glb b/three-player-controller/example/public/glb/3dgs_collider.glb new file mode 100644 index 0000000..48c31d5 Binary files /dev/null and b/three-player-controller/example/public/glb/3dgs_collider.glb differ diff --git a/three-player-controller/example/public/glb/ak47.glb b/three-player-controller/example/public/glb/ak47.glb new file mode 100644 index 0000000..92768bb Binary files /dev/null and b/three-player-controller/example/public/glb/ak47.glb differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin b/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin new file mode 100644 index 0000000..5d8f9e3 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Eyebrows_Female.bin differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf b/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf new file mode 100644 index 0000000..8766ced --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Eyebrows_Female.gltf @@ -0,0 +1,1626 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Eyebrows_Female", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Eyebrows_Female", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "TEXCOORD_1":3, + "TEXCOORD_2":4, + "TEXCOORD_3":5, + "COLOR_0":6, + "COLOR_1":7, + "COLOR_2":8, + "JOINTS_0":9, + "WEIGHTS_0":10 + }, + "indices":11, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":12, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":912, + "max":[ + 0.06299881637096405, + 1.6869006156921387, + 0.0915706604719162 + ], + "min":[ + -0.06299881637096405, + 1.6492890119552612, + 0.051284290850162506 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":912, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":4, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":5, + "componentType":5126, + "count":912, + "type":"VEC2" + }, + { + "bufferView":6, + "componentType":5121, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":7, + "componentType":5123, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":8, + "componentType":5123, + "count":912, + "normalized":true, + "type":"VEC4" + }, + { + "bufferView":9, + "componentType":5121, + "count":912, + "type":"VEC4" + }, + { + "bufferView":10, + "componentType":5126, + "count":912, + "type":"VEC4" + }, + { + "bufferView":11, + "componentType":5123, + "count":4440, + "type":"SCALAR" + }, + { + "bufferView":12, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":10944, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":10944, + "byteOffset":10944, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":21888, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":29184, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":36480, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":43776, + "target":34962 + }, + { + "buffer":0, + "byteLength":3648, + "byteOffset":51072, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":54720, + "target":34962 + }, + { + "buffer":0, + "byteLength":7296, + "byteOffset":62016, + "target":34962 + }, + { + "buffer":0, + "byteLength":3648, + "byteOffset":69312, + "target":34962 + }, + { + "buffer":0, + "byteLength":14592, + "byteOffset":72960, + "target":34962 + }, + { + "buffer":0, + "byteLength":8880, + "byteOffset":87552, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":96432 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":100592, + "uri":"Eyebrows_Female.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin new file mode 100644 index 0000000..93c9302 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.bin differ diff --git a/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf new file mode 100644 index 0000000..35a9469 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Eyebrows_Regular.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Eyebrows_Regular", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Cube.001", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":646, + "max":[ + 0.062340568751096725, + 1.7254674434661865, + 0.09447739273309708 + ], + "min":[ + -0.062340568751096725, + 1.6948596239089966, + 0.0569210946559906 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":646, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":646, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":646, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":646, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2952, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":7752, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":7752, + "byteOffset":7752, + "target":34962 + }, + { + "buffer":0, + "byteLength":5168, + "byteOffset":15504, + "target":34962 + }, + { + "buffer":0, + "byteLength":2584, + "byteOffset":20672, + "target":34962 + }, + { + "buffer":0, + "byteLength":10336, + "byteOffset":23256, + "target":34962 + }, + { + "buffer":0, + "byteLength":5904, + "byteOffset":33592, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":39496 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":43656, + "uri":"Eyebrows_Regular.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Beard.bin b/three-player-controller/example/public/glb/characters/Hair_Beard.bin new file mode 100644 index 0000000..dd57092 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Beard.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Beard.gltf b/three-player-controller/example/public/glb/characters/Hair_Beard.gltf new file mode 100644 index 0000000..ca79e6c --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Beard.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Beard", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Plane.006", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":596, + "max":[ + 0.08075305074453354, + 1.6893515586853027, + 0.0968303382396698 + ], + "min":[ + -0.08075305074453354, + 1.549536108970642, + -0.008104397915303707 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":596, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":596, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":596, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":596, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":3102, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":7152, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":7152, + "byteOffset":7152, + "target":34962 + }, + { + "buffer":0, + "byteLength":4768, + "byteOffset":14304, + "target":34962 + }, + { + "buffer":0, + "byteLength":2384, + "byteOffset":19072, + "target":34962 + }, + { + "buffer":0, + "byteLength":9536, + "byteOffset":21456, + "target":34962 + }, + { + "buffer":0, + "byteLength":6204, + "byteOffset":30992, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":37196 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":41356, + "uri":"Hair_Beard.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Buns.bin b/three-player-controller/example/public/glb/characters/Hair_Buns.bin new file mode 100644 index 0000000..edf82d5 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Buns.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Buns.gltf b/three-player-controller/example/public/glb/characters/Hair_Buns.gltf new file mode 100644 index 0000000..eb63768 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Buns.gltf @@ -0,0 +1,1545 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Buns", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Hair_Buns", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":3501, + "max":[ + 0.1507568359375, + 1.7791515588760376, + 0.10265952348709106 + ], + "min":[ + -0.1507568359375, + 1.5300521850585938, + -0.13409538567066193 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":3501, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":3501, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":3501, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":3501, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":9852, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":42012, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":42012, + "byteOffset":42012, + "target":34962 + }, + { + "buffer":0, + "byteLength":28008, + "byteOffset":84024, + "target":34962 + }, + { + "buffer":0, + "byteLength":14004, + "byteOffset":112032, + "target":34962 + }, + { + "buffer":0, + "byteLength":56016, + "byteOffset":126036, + "target":34962 + }, + { + "buffer":0, + "byteLength":19704, + "byteOffset":182052, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":201756 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":205916, + "uri":"Hair_Buns.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin b/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin new file mode 100644 index 0000000..9d617b8 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Buzzed.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf b/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf new file mode 100644 index 0000000..41b3436 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Buzzed.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Buzzed", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Cylinder.002", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":466, + "max":[ + 0.0772952064871788, + 1.813513159751892, + 0.08289127051830292 + ], + "min":[ + -0.07729528844356537, + 1.6441489458084106, + -0.10803250223398209 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":466, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":466, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":466, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":466, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2490, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":5592, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":5592, + "byteOffset":5592, + "target":34962 + }, + { + "buffer":0, + "byteLength":3728, + "byteOffset":11184, + "target":34962 + }, + { + "buffer":0, + "byteLength":1864, + "byteOffset":14912, + "target":34962 + }, + { + "buffer":0, + "byteLength":7456, + "byteOffset":16776, + "target":34962 + }, + { + "buffer":0, + "byteLength":4980, + "byteOffset":24232, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":29212 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":33372, + "uri":"Hair_Buzzed.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin new file mode 100644 index 0000000..70c8b95 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf new file mode 100644 index 0000000..3ab8630 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_BuzzedFemale.gltf @@ -0,0 +1,1544 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_BuzzedFemale", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Hair_BuzzedFemale", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":466, + "max":[ + 0.07933114469051361, + 1.7709248065948486, + 0.08289240300655365 + ], + "min":[ + -0.07933114469051361, + 1.600769281387329, + -0.11473648250102997 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":466, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":466, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":466, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":466, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":2490, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":5592, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":5592, + "byteOffset":5592, + "target":34962 + }, + { + "buffer":0, + "byteLength":3728, + "byteOffset":11184, + "target":34962 + }, + { + "buffer":0, + "byteLength":1864, + "byteOffset":14912, + "target":34962 + }, + { + "buffer":0, + "byteLength":7456, + "byteOffset":16776, + "target":34962 + }, + { + "buffer":0, + "byteLength":4980, + "byteOffset":24232, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":29212 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":33372, + "uri":"Hair_BuzzedFemale.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_Long.bin b/three-player-controller/example/public/glb/characters/Hair_Long.bin new file mode 100644 index 0000000..6dd35df Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_Long.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_Long.gltf b/three-player-controller/example/public/glb/characters/Hair_Long.gltf new file mode 100644 index 0000000..cbe0388 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_Long.gltf @@ -0,0 +1,1545 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.20467473566532135, + -4.759437705400649e-21, + 1.2439442348830942e-21, + 0.9788300395011902 + ], + "translation":[ + -1.5903435849255598e-27, + 0.0711912140250206, + 2.0489096641540527e-08 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.3088374435901642, + 8.337463586651673e-21, + -5.849849968646638e-21, + 0.95111483335495 + ], + "translation":[ + -1.0141478263557337e-20, + 0.19815585017204285, + 2.60770320892334e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -8.381903171539307e-09, + 1, + -1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + 1.0023382124302316e-09, + 7.603918037758783e-13, + -0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + 1.8987577732332284e-08, + 1.4024364247688936e-07, + -0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.0033091865479946136, + 0.7071018815040588, + -0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + 0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.0489096641540527e-08, + 1, + 6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + 2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + 2.420814304571195e-08, + 1.4173854798182894e-11, + -0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + -4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + -2.949975197452659e-08, + 1.402399334438087e-07, + 0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + 1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.007979890331625938, + 0.7070646286010742, + -0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + 0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + 8.381903171539307e-09, + 1, + 5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + -1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + -2.1800540039862426e-08, + 5.25581791629981e-12, + 0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + 1.03155857189563e-08, + 1.4017835781032773e-07, + -0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.01044775452464819, + 0.7070325016975403, + -0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 2.7474015951156616e-08, + 1, + 1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + 9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + 2.6234243222234e-08, + 4.3393351412124304e-11, + -0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + 7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + -1.8197205520209536e-08, + 1.4009778226409253e-07, + 0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + -9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + 0.0025809321086853743, + 0.7071049213409424, + 0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + 0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + -1.2782692238033633e-08, + 0.48539119958877563, + 2.0375746601075662e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -1.2670352589339018e-07, + 0.03127264976501465, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0012816648231819272, + 5.811178561998531e-05, + 0.0002541383437346667, + 0.9999991655349731 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 2.2436142899096012e-07, + 0.035620346665382385, + 5.122274160385132e-09 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0019128327257931232, + 0.00019216042710468173, + -0.0004484721284825355, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + 1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.263997346162796, + 0.9450139999389648, + 0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + 0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.02237604185938835, + -2.264418526465306e-06, + -3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + 1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.023802155628800392, + 8.208974031731486e-05, + -1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + -4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.17232586443424225, + 0.6858593225479126, + -0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + -0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5471324324607849, + -0.3204275965690613, + -0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -8.381903171539307e-09, + -1, + 1.5432005174599583e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -9.076757123693824e-10, + 0.023510519415140152, + -3.8285477899080433e-08 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + 1.0023382124302316e-09, + -7.603918037758783e-13, + 0.00013328436762094498, + 1 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -4.420144250616431e-10, + 0.027111778035759926, + -3.670398029953503e-08 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + 1.8987577732332284e-08, + -1.4024364247688936e-07, + 0.002140256343409419, + 0.9999977350234985 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -8.094502845779061e-11, + 0.03171013668179512, + -6.863018597869086e-09 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.0033091865479946136, + -0.7071018815040588, + 0.003309202380478382, + 0.7070961594581604 + ], + "translation":[ + -0.004199977032840252, + 0.08823654055595398, + 0.03620496019721031 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.0489096641540527e-08, + -1, + -6.335320587363621e-14, + 3.6517765238386346e-06 + ], + "translation":[ + -2.193701220676303e-09, + 0.029620492830872536, + 1.1362611473941797e-07 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + 2.420814304571195e-08, + -1.4173854798182894e-11, + 0.0032189602497965097, + 0.9999948143959045 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 1 + ], + "translation":[ + 4.32646629633382e-09, + 0.02931377850472927, + 3.287611605173879e-08 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + -2.949975197452659e-08, + -1.402399334438087e-07, + -0.0038676888216286898, + 0.9999924898147583 + ], + "translation":[ + -1.6816557035781443e-09, + 0.03642695024609566, + 1.0210540324351314e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.007979890331625938, + -0.7070646286010742, + 0.007979941554367542, + 0.7070589065551758 + ], + "scale":[ + 1, + 0.9999998807907104, + 1 + ], + "translation":[ + -0.001899929717183113, + 0.08634158223867416, + 0.010671664960682392 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + 8.381903171539307e-09, + -1, + -5.647658583201112e-14, + 3.65177675121231e-06 + ], + "translation":[ + 1.317857822868973e-09, + 0.020919334143400192, + -1.727942162688123e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + -2.1800540039862426e-08, + -5.25581791629981e-12, + -0.0028988486155867577, + 0.9999958276748657 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 1.318767317570746e-09, + 0.026631725952029228, + -8.721048061488545e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.03155857189563e-08, + -1.4017835781032773e-07, + 0.001705002854578197, + 0.9999985694885254 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.967681211885065e-09, + 0.03083186410367489, + -8.672896001371555e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.01044775452464819, + -0.7070325016975403, + 0.010447824373841286, + 0.7070267200469971 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.003799965837970376, + 0.06994835287332535, + -0.03399413824081421 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 2.7474015951156616e-08, + -1, + -1.0721373739979931e-13, + 3.65177675121231e-06 + ], + "translation":[ + -9.5042196335271e-10, + 0.028301579877734184, + -3.474425369631717e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + 2.6234243222234e-08, + -4.3393351412124304e-11, + 0.0034884933847934008, + 0.9999939203262329 + ], + "translation":[ + -7.703420124016702e-09, + 0.027600184082984924, + -1.1280478418029816e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + -1.8197205520209536e-08, + -1.4009778226409253e-07, + -0.002474082401022315, + 0.9999969601631165 + ], + "translation":[ + 9.877112461254e-10, + 0.035001225769519806, + -1.5782489981575054e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + 0.0025809321086853743, + -0.7071049213409424, + -0.0025809479411691427, + 0.707099199295044 + ], + "scale":[ + 0.9999999403953552, + 0.9999997615814209, + 0.9999999403953552 + ], + "translation":[ + -0.0021999571472406387, + 0.08042963594198227, + -0.013825255446135998 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + -2.5565388028780944e-08, + -0.48539119958877563, + -1.9393742434203887e-07, + 0.8742970824241638 + ], + "scale":[ + 0.9999998807907104, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.2622331269085407e-07, + 0.031272709369659424, + 3.073364496231079e-08 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0012816853122785687, + -5.809385038446635e-05, + -0.00025414914125576615, + 0.9999991655349731 + ], + "scale":[ + 1.0000001192092896, + 1.0000001192092896, + 1 + ], + "translation":[ + -1.0542134987190366e-07, + 0.03562036156654358, + -8.847564458847046e-09 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.001912830863147974, + -0.00019218510715290904, + 0.0004484682867769152, + 0.9999980330467224 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999999403953552 + ], + "translation":[ + -1.453081495128572e-07, + 0.03582108020782471, + -1.30385160446167e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.263997346162796, + -0.9450139999389648, + -0.1882738322019577, + 0.04250776767730713 + ], + "scale":[ + 1, + 1.0000004768371582, + 1 + ], + "translation":[ + -0.009999980218708515, + -0.0028757674153894186, + 0.029958141967654228 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.02237604185938835, + 2.264418526465306e-06, + 3.032003448311116e-08, + 0.9997496008872986 + ], + "translation":[ + -1.532122695380167e-08, + 0.24930401146411896, + 5.277343007037416e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.023802155628800392, + -8.208974031731486e-05, + 1.8556972918304382e-06, + 0.9997166991233826 + ], + "translation":[ + 4.793889729626244e-08, + 0.2403421401977539, + -4.220055416226387e-09 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.17232586443424225, + -0.6858593225479126, + 0.1717943400144577, + 0.68584805727005 + ], + "scale":[ + 1, + 1, + 1.0000001192092896 + ], + "translation":[ + 0.0016045584343373775, + 0.1410660296678543, + -0.01228591613471508 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5471324324607849, + 0.3204275965690613, + 0.39127784967422485, + 0.666988730430603 + ], + "scale":[ + 1, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -0.02930000051856041, + 0.13370320200920105, + 0.0448194220662117 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.10099761933088303, + 4.5928396448426806e-14, + -4.5242200827702583e-13, + 0.9948866963386536 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 4.6735704609246064e-20, + 0.12640145421028137, + 3.4924596548080444e-10 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.055728573352098465, + -4.992229150957428e-05, + -2.7864282401424134e-06, + 0.998445987701416 + ], + "scale":[ + 1, + 0.9999998211860657, + 0.9999998807907104 + ], + "translation":[ + -4.263256414560601e-13, + 0.11034487932920456, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.06393160670995712, + 4.989771332475357e-05, + -3.19658010994317e-06, + 0.9979543089866638 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 0, + 0.12655510008335114, + -9.313225746154785e-10 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.204170985147357e-09 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + 1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + 0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 0, + 0.00017269056115765125, + 5.421010862427522e-20, + 1 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 5.525180313270539e-10, + 0.06809987872838974, + -1.5897967386990786e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -3.296312570455484e-05, + 0.9816133975982666, + -0.1908797174692154, + 0.00016951537691056728 + ], + "scale":[ + 1, + 1.0000001192092896, + 0.9999998807907104 + ], + "translation":[ + -1.4901161193847656e-08, + 0.14916980266571045, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5978578329086304, + 0, + 0, + 0.8016021847724915 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4661003053188324, + 2.7939677238464355e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.0712229460477829, + 0, + 0, + 0.9974604249000549 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 7.450580596923828e-09, + 0.4098641872406006, + 1.3969838619232178e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9893749952316284, + 0, + 0, + 0.1453862488269806 + ], + "scale":[ + 1, + 1, + 1.0000014305114746 + ], + "translation":[ + -0.11140000075101852, + 0.010460092686116695, + -0.008893024176359177 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.7879695892333984, + 0, + 0, + 0.6157141923904419 + ], + "translation":[ + 0, + 0.045700013637542725, + 0.9318000078201294 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_Long", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_2", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0, + "roughnessFactor":0.7121212482452393 + } + } + ], + "meshes":[ + { + "name":"Hair_Long", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_2_Normal", + "uri":"T_Hair_2_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_2_BaseColor", + "uri":"T_Hair_2_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":3080, + "max":[ + 0.12235528230667114, + 1.7772293090820312, + 0.10885592550039291 + ], + "min":[ + -0.12235528230667114, + 1.5008693933486938, + -0.1615893542766571 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":3080, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":3080, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":3080, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":3080, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":8718, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":36960, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":36960, + "byteOffset":36960, + "target":34962 + }, + { + "buffer":0, + "byteLength":24640, + "byteOffset":73920, + "target":34962 + }, + { + "buffer":0, + "byteLength":12320, + "byteOffset":98560, + "target":34962 + }, + { + "buffer":0, + "byteLength":49280, + "byteOffset":110880, + "target":34962 + }, + { + "buffer":0, + "byteLength":17436, + "byteOffset":160160, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":177596 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":181756, + "uri":"Hair_Long.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin new file mode 100644 index 0000000..7acd443 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.bin differ diff --git a/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf new file mode 100644 index 0000000..1f62944 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Hair_SimpleParted.gltf @@ -0,0 +1,1519 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.3.47", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 66 + ] + } + ], + "nodes":[ + { + "name":"Head", + "rotation":[ + -0.13207007944583893, + 0, + 0, + 0.991240382194519 + ], + "translation":[ + 0, + 0.08285234868526459, + 9.313225746154785e-09 + ] + }, + { + "children":[ + 0 + ], + "name":"neck_01", + "rotation":[ + 0.25725916028022766, + 0, + 0, + 0.9663424491882324 + ], + "translation":[ + 0, + 0.21509313583374023, + 1.862645149230957e-08 + ] + }, + { + "name":"index_04_leaf_l", + "rotation":[ + -4.656613761255812e-09, + -1, + -1.8664633572962157e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 1.9281287677586079e-10, + 0.02411014772951603, + 1.6830044557991641e-07 + ] + }, + { + "children":[ + 2 + ], + "name":"index_03_l", + "rotation":[ + -1.348770428677426e-09, + 3.689715200039245e-12, + -0.00018570991232991219, + 1 + ], + "translation":[ + -1.6689227777533233e-09, + 0.027911463752388954, + 1.6598225727193494e-07 + ] + }, + { + "children":[ + 3 + ], + "name":"index_02_l", + "rotation":[ + -1.721623910100334e-08, + -3.9419649766614384e-08, + -0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -3.374225343577564e-10, + 0.0326123908162117, + 1.3817563626616902e-07 + ] + }, + { + "children":[ + 4 + ], + "name":"index_01_l", + "rotation":[ + -0.009757923893630505, + 0.7070369124412537, + -0.0097578726708889, + 0.7070420384407043 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.009400011971592903, + 0.11500009149312973, + 0.03249999135732651 + ] + }, + { + "name":"middle_04_leaf_l", + "rotation":[ + 2.421438694000244e-08, + -1, + -9.97962698806934e-14, + 3.65177675121231e-06 + ], + "translation":[ + 5.168658390175551e-09, + 0.030519818887114525, + 2.3142732175074343e-08 + ] + }, + { + "children":[ + 6 + ], + "name":"middle_03_l", + "rotation":[ + -1.0311108411542591e-08, + 2.617175920427428e-11, + -0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 3.3051037462428212e-09, + 0.03011666052043438, + 1.2451241104827204e-07 + ] + }, + { + "children":[ + 7 + ], + "name":"middle_02_l", + "rotation":[ + 1.5643285777855453e-08, + -3.913748614081669e-08, + 0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + 1.090484147425741e-09, + 0.03752619028091431, + 1.5301162648029276e-07 + ] + }, + { + "children":[ + 8 + ], + "name":"middle_01_l", + "rotation":[ + -0.013192507438361645, + 0.7069811820983887, + -0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + 0.0069999718107283115, + 0.11260004341602325, + 0.0069999960251152515 + ] + }, + { + "name":"pinky_04_leaf_l", + "rotation":[ + -2.2351745343485163e-08, + -1, + 1.2562023090582214e-13, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.033640728564933e-08, + 0.021523205563426018, + -2.814113031490706e-07 + ] + }, + { + "children":[ + 10 + ], + "name":"pinky_03_l", + "rotation":[ + 3.383408886392658e-09, + -9.627411715062983e-12, + 0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 2.8849171940237284e-09, + 0.02743077464401722, + -3.729670083885139e-08 + ] + }, + { + "children":[ + 11 + ], + "name":"pinky_02_l", + "rotation":[ + -1.035459185061427e-08, + -3.897551081877282e-08, + -0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + -1.0922121873591095e-08, + 0.031630996614694595, + 7.067205842758995e-08 + ] + }, + { + "children":[ + 12 + ], + "name":"pinky_01_l", + "rotation":[ + -0.015652360394597054, + 0.706930935382843, + -0.01565224677324295, + 0.7069361209869385 + ], + "translation":[ + 0.009000067599117756, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_l", + "rotation":[ + 1.1641533959050321e-08, + -1, + -4.1919619902033856e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -3.835339157376438e-09, + 0.029101530089974403, + 3.391749459069615e-08 + ] + }, + { + "children":[ + 14 + ], + "name":"ring_03_l", + "rotation":[ + -2.4604773329883756e-08, + 6.235185284753086e-11, + -0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -8.026290743146092e-09, + 0.028300149366259575, + 1.1107826480838412e-07 + ] + }, + { + "children":[ + 15 + ], + "name":"ring_02_l", + "rotation":[ + 1.7517091777108362e-08, + -3.9449915334444086e-08, + 0.0023997032549232244, + 0.9999971389770508 + ], + "translation":[ + -5.185938789509237e-09, + 0.0360012948513031, + 1.464196230926973e-07 + ] + }, + { + "children":[ + 16 + ], + "name":"ring_01_l", + "rotation":[ + -0.0029462252277880907, + 0.7070980072021484, + -0.0029462038073688745, + 0.7071032524108887 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + 0.00730002298951149, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_l", + "rotation":[ + 2.2672955068969713e-08, + 0.4282083213329315, + -1.3036950008427084e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999998807907104, + 1, + 1 + ], + "translation":[ + -1.0710209608078003e-07, + 0.03459959849715233, + 6.51925802230835e-09 + ] + }, + { + "children":[ + 18 + ], + "name":"thumb_03_l", + "rotation":[ + -0.0003726967261172831, + 2.6525931389187463e-05, + 0.00011462579277576879, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 1, + 1 + ], + "translation":[ + 3.5608536563813686e-08, + 0.03922773152589798, + -5.168840289115906e-08 + ] + }, + { + "children":[ + 19 + ], + "name":"thumb_02_l", + "rotation":[ + 0.0008555729873478413, + 3.4565124224172905e-05, + -0.0004891895223408937, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.83063093572855e-07, + 0.039565373212099075, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 20 + ], + "name":"thumb_01_l", + "rotation":[ + 0.30099350214004517, + 0.9291350841522217, + 0.21020177006721497, + 0.04388770833611488 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + 0.021399976685643196, + 0.027700087055563927, + 0.02740001305937767 + ] + }, + { + "children":[ + 5, + 9, + 13, + 17, + 21 + ], + "name":"hand_l", + "rotation":[ + -0.015807554125785828, + -1.1323515991534805e-06, + -1.859292275696589e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + 8.156474962106586e-08, + 0.24042010307312012, + -3.2514435588382185e-09 + ] + }, + { + "children":[ + 22 + ], + "name":"lowerarm_l", + "rotation":[ + 0.03093818761408329, + 4.2500738345552236e-05, + -1.2440405043889768e-06, + 0.9995213150978088 + ], + "translation":[ + -4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 23 + ], + "name":"upperarm_l", + "rotation":[ + 0.16515187919139862, + 0.6876330971717834, + -0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 24 + ], + "name":"clavicle_l", + "rotation":[ + -0.5359424352645874, + -0.3172530233860016, + -0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + 0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "name":"index_04_leaf_r", + "rotation":[ + -4.656613761255812e-09, + 1, + -9.069184307350635e-15, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + -1.9281287677586079e-10, + 0.02411014772951603, + 1.6830023241709569e-07 + ] + }, + { + "children":[ + 26 + ], + "name":"index_03_r", + "rotation":[ + -1.3487808647738575e-09, + -3.968936290732472e-12, + 0.00018570991232991219, + 1 + ], + "translation":[ + 1.6707417671568692e-09, + 0.027911463752388954, + 1.659820298982595e-07 + ] + }, + { + "children":[ + 27 + ], + "name":"index_02_r", + "rotation":[ + -4.891389959738035e-09, + 3.925104508084587e-08, + 0.0005329284467734396, + 0.9999998807907104 + ], + "translation":[ + -1.2369127944111824e-10, + 0.032612428069114685, + 1.5866616820403578e-07 + ] + }, + { + "children":[ + 28 + ], + "name":"index_01_r", + "rotation":[ + -0.00975793320685625, + -0.7070369720458984, + 0.009757863357663155, + 0.7070419788360596 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.00940000731498003, + 0.11500009149312973, + 0.032499995082616806 + ] + }, + { + "name":"middle_04_leaf_r", + "rotation":[ + 2.421438694000244e-08, + 1, + 8.419702652098793e-14, + 3.65177675121231e-06 + ], + "translation":[ + -5.168658390175551e-09, + 0.030519818887114525, + 2.3141225824474532e-08 + ] + }, + { + "children":[ + 30 + ], + "name":"middle_03_r", + "rotation":[ + -1.0311155484998835e-08, + -2.69988770923435e-11, + 0.001419748761691153, + 0.9999989867210388 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + -3.306013240944594e-09, + 0.03011666052043438, + 1.2451081943254394e-07 + ] + }, + { + "children":[ + 31 + ], + "name":"middle_02_r", + "rotation":[ + 1.3419549915738571e-08, + 3.920036917293146e-08, + -0.0020527271553874016, + 0.9999979138374329 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 0.9999999403953552 + ], + "translation":[ + -1.2823875294998288e-09, + 0.03752618655562401, + 1.4930063230167434e-07 + ] + }, + { + "children":[ + 32 + ], + "name":"middle_01_r", + "rotation":[ + -0.013192505575716496, + -0.7069811820983887, + 0.013192412443459034, + 0.7069862484931946 + ], + "scale":[ + 0.9999999403953552, + 0.9999998807907104, + 0.9999999403953552 + ], + "translation":[ + -0.006999965291470289, + 0.11260004341602325, + 0.0069999974220991135 + ] + }, + { + "name":"pinky_04_leaf_r", + "rotation":[ + -2.2351745343485163e-08, + 1, + -9.381222605383058e-14, + 3.651776296464959e-06 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -1.0335497790947556e-08, + 0.02152320370078087, + -2.814119568483875e-07 + ] + }, + { + "children":[ + 34 + ], + "name":"pinky_03_r", + "rotation":[ + 3.383400670742276e-09, + 9.110491007435773e-12, + -0.0004658793332055211, + 0.9999998807907104 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + -2.8858266887255013e-09, + 0.02743077091872692, + -3.729741138158715e-08 + ] + }, + { + "children":[ + 35 + ], + "name":"pinky_02_r", + "rotation":[ + 1.908002644768203e-09, + 3.8693851678317515e-08, + 0.0015674028545618057, + 0.9999988079071045 + ], + "translation":[ + 4.2391548049636185e-09, + 0.03163096681237221, + 9.059564831659372e-08 + ] + }, + { + "children":[ + 36 + ], + "name":"pinky_01_r", + "rotation":[ + -0.0156523697078228, + -0.7069309949874878, + 0.015652237460017204, + 0.7069360613822937 + ], + "translation":[ + -0.009000054560601711, + 0.0951000526547432, + -0.042500000447034836 + ] + }, + { + "name":"ring_04_leaf_r", + "rotation":[ + 1.1641533959050321e-08, + 1, + 3.932434822109636e-14, + 3.651776296464959e-06 + ], + "scale":[ + 1, + 0.9999997019767761, + 1 + ], + "translation":[ + 3.835339157376438e-09, + 0.029101528227329254, + 3.391586744783126e-08 + ] + }, + { + "children":[ + 38 + ], + "name":"ring_03_r", + "rotation":[ + -2.4604839055086813e-08, + -5.959530785526468e-11, + 0.003387601813301444, + 0.9999942779541016 + ], + "scale":[ + 1, + 0.9999999403953552, + 1 + ], + "translation":[ + 8.026290743146092e-09, + 0.028300149366259575, + 1.1107695030432296e-07 + ] + }, + { + "children":[ + 39 + ], + "name":"ring_02_r", + "rotation":[ + 1.7020420628455213e-08, + 3.94586443519529e-08, + -0.0023997037205845118, + 0.9999971389770508 + ], + "translation":[ + 6.081791070755571e-09, + 0.0360012948513031, + 1.4559928729340754e-07 + ] + }, + { + "children":[ + 40 + ], + "name":"ring_01_r", + "rotation":[ + -0.002946224994957447, + -0.7070980668067932, + 0.002946204040199518, + 0.7071031928062439 + ], + "scale":[ + 0.9999998807907104, + 0.9999997019767761, + 0.9999998807907104 + ], + "translation":[ + -0.007300012744963169, + 0.10620005428791046, + -0.020400000736117363 + ] + }, + { + "name":"thumb_04_leaf_r", + "rotation":[ + 2.2672949739899195e-08, + -0.4282083213329315, + 1.8189894035458565e-07, + 0.9036800265312195 + ], + "scale":[ + 0.9999999403953552, + 0.9999999403953552, + 1 + ], + "translation":[ + 1.671724021434784e-07, + 0.03459971770644188, + 8.381903171539307e-09 + ] + }, + { + "children":[ + 42 + ], + "name":"thumb_03_r", + "rotation":[ + -0.0003726781578734517, + -2.651103204698302e-05, + -0.00011462859401945025, + 0.9999999403953552 + ], + "scale":[ + 1.0000001192092896, + 0.9999999403953552, + 0.9999998807907104 + ], + "translation":[ + -1.8476566765457392e-07, + 0.03922785073518753, + -2.2351741790771484e-08 + ] + }, + { + "children":[ + 43 + ], + "name":"thumb_02_r", + "rotation":[ + 0.000855587946716696, + -3.4503656934248284e-05, + 0.0004891885910183191, + 0.9999995231628418 + ], + "scale":[ + 1, + 0.9999998807907104, + 0.9999998807907104 + ], + "translation":[ + -5.372567102313042e-08, + 0.03956543654203415, + -2.60770320892334e-08 + ] + }, + { + "children":[ + 44 + ], + "name":"thumb_01_r", + "rotation":[ + 0.30099350214004517, + -0.9291350841522217, + -0.21020175516605377, + 0.04388761147856712 + ], + "scale":[ + 1, + 1.0000001192092896, + 1 + ], + "translation":[ + -0.02139997109770775, + 0.027700087055563927, + 0.02740001119673252 + ] + }, + { + "children":[ + 29, + 33, + 37, + 41, + 45 + ], + "name":"hand_r", + "rotation":[ + -0.015807554125785828, + 1.2515496337073273e-06, + 1.8592521300320186e-08, + 0.9998750686645508 + ], + "scale":[ + 1, + 1, + 0.9999999403953552 + ], + "translation":[ + -9.193168182264344e-08, + 0.24042010307312012, + -5.398987923399545e-09 + ] + }, + { + "children":[ + 46 + ], + "name":"lowerarm_r", + "rotation":[ + 0.03093818761408329, + -4.256031024851836e-05, + 1.2421967312548077e-06, + 0.9995213150978088 + ], + "translation":[ + 4.025901034765411e-08, + 0.25111493468284607, + -8.731149137020111e-11 + ] + }, + { + "children":[ + 47 + ], + "name":"upperarm_r", + "rotation":[ + 0.16515187919139862, + -0.6876330971717834, + 0.16469810903072357, + 0.6875755786895752 + ], + "scale":[ + 0.9999999403953552, + 1, + 1 + ], + "translation":[ + 0.009641071781516075, + 0.1883048117160797, + -0.03967198729515076 + ] + }, + { + "children":[ + 48 + ], + "name":"clavicle_r", + "rotation":[ + -0.5359424352645874, + 0.3172530233860016, + 0.3989512026309967, + 0.673018753528595 + ], + "translation":[ + -0.03139999881386757, + 0.1737176477909088, + 0.06732845306396484 + ] + }, + { + "children":[ + 1, + 25, + 49 + ], + "name":"spine_03", + "rotation":[ + -0.12418898940086365, + 0, + 0, + 0.9922585487365723 + ], + "translation":[ + 0, + 0.1331336796283722, + 0 + ] + }, + { + "children":[ + 50 + ], + "name":"spine_02", + "rotation":[ + -0.03958535194396973, + 0, + 0, + 0.9992161989212036 + ], + "translation":[ + 0, + 0.10634974390268326, + -1.3969838619232178e-09 + ] + }, + { + "children":[ + 51 + ], + "name":"spine_01", + "rotation":[ + -0.09185619652271271, + 0, + 0, + 0.9957723021507263 + ], + "translation":[ + 0, + 0.1280922144651413, + -3.259629011154175e-08 + ] + }, + { + "name":"ball_leaf_l", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -2.1363266711205142e-10 + ] + }, + { + "children":[ + 53 + ], + "name":"ball_l", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 54 + ], + "name":"foot_l", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 55 + ], + "name":"calf_l", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + -7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 56 + ], + "name":"thigh_l", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + 0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "name":"ball_leaf_r", + "rotation":[ + 1.4901161193847656e-08, + -4.371139183945161e-08, + 6.513504428675462e-16, + 1 + ], + "translation":[ + -1.328826715329967e-09, + 0.07889983803033829, + -1.1364305052552481e-09 + ] + }, + { + "children":[ + 58 + ], + "name":"ball_r", + "rotation":[ + -1.0066750988357853e-08, + -0.9731196165084839, + 0.23030038177967072, + 4.2536413502602954e-08 + ], + "scale":[ + 1, + 1.000000238418579, + 1 + ], + "translation":[ + 0, + 0.15907371044158936, + -6.51925802230835e-09 + ] + }, + { + "children":[ + 59 + ], + "name":"foot_r", + "rotation":[ + -0.5721680521965027, + 0, + 0, + 0.8201364278793335 + ], + "translation":[ + 0, + 0.4587883949279785, + 5.587935447692871e-09 + ] + }, + { + "children":[ + 60 + ], + "name":"calf_r", + "rotation":[ + 0.05598893761634827, + 0, + 0, + 0.998431384563446 + ], + "translation":[ + 7.450580596923828e-09, + 0.4288000166416168, + 3.1723175197839737e-09 + ] + }, + { + "children":[ + 61 + ], + "name":"thigh_r", + "rotation":[ + 0.9898310303688049, + 0, + 0, + 0.14224845170974731 + ], + "scale":[ + 1, + 1, + 1.0000019073486328 + ], + "translation":[ + -0.09059999883174896, + 0.023176943883299828, + 0.0004878304898738861 + ] + }, + { + "children":[ + 52, + 57, + 62 + ], + "name":"pelvis", + "rotation":[ + 0.8005710244178772, + 0, + 0, + 0.5992379188537598 + ], + "translation":[ + 0, + 0.042999982833862305, + 0.9491000175476074 + ] + }, + { + "children":[ + 63 + ], + "name":"root", + "rotation":[ + -0.7071067690849304, + 0, + 0, + 0.7071067690849304 + ] + }, + { + "mesh":0, + "name":"Hair_SimpleParted", + "skin":0 + }, + { + "children":[ + 65, + 64 + ], + "name":"Armature" + } + ], + "materials":[ + { + "doubleSided":true, + "name":"MI_Hair_1", + "normalTexture":{ + "index":0 + }, + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":1 + }, + "metallicFactor":0 + } + } + ], + "meshes":[ + { + "name":"Plane.002", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2, + "JOINTS_0":3, + "WEIGHTS_0":4 + }, + "indices":5, + "material":0 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + }, + { + "sampler":0, + "source":1 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"T_Hair_1_Normal", + "uri":"T_Hair_1_Normal.png" + }, + { + "mimeType":"image/png", + "name":"T_Hair_1_BaseColor", + "uri":"T_Hair_1_BaseColor.png" + } + ], + "skins":[ + { + "inverseBindMatrices":6, + "joints":[ + 64, + 63, + 52, + 51, + 50, + 1, + 0, + 25, + 24, + 23, + 22, + 5, + 4, + 3, + 2, + 9, + 8, + 7, + 6, + 13, + 12, + 11, + 10, + 17, + 16, + 15, + 14, + 21, + 20, + 19, + 18, + 49, + 48, + 47, + 46, + 29, + 28, + 27, + 26, + 33, + 32, + 31, + 30, + 37, + 36, + 35, + 34, + 41, + 40, + 39, + 38, + 45, + 44, + 43, + 42, + 57, + 56, + 55, + 54, + 53, + 62, + 61, + 60, + 59, + 58 + ], + "name":"Armature" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":757, + "max":[ + 0.089996337890625, + 1.8397216796875, + 0.07879609614610672 + ], + "min":[ + -0.08542495965957642, + 1.660560131072998, + -0.10815342515707016 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":757, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":757, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5121, + "count":757, + "type":"VEC4" + }, + { + "bufferView":4, + "componentType":5126, + "count":757, + "type":"VEC4" + }, + { + "bufferView":5, + "componentType":5123, + "count":3903, + "type":"SCALAR" + }, + { + "bufferView":6, + "componentType":5126, + "count":65, + "type":"MAT4" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":9084, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":9084, + "byteOffset":9084, + "target":34962 + }, + { + "buffer":0, + "byteLength":6056, + "byteOffset":18168, + "target":34962 + }, + { + "buffer":0, + "byteLength":3028, + "byteOffset":24224, + "target":34962 + }, + { + "buffer":0, + "byteLength":12112, + "byteOffset":27252, + "target":34962 + }, + { + "buffer":0, + "byteLength":7806, + "byteOffset":39364, + "target":34963 + }, + { + "buffer":0, + "byteLength":4160, + "byteOffset":47172 + } + ], + "samplers":[ + { + "magFilter":9729, + "minFilter":9987 + } + ], + "buffers":[ + { + "byteLength":51332, + "uri":"Hair_SimpleParted.bin" + } + ] +} diff --git a/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin new file mode 100644 index 0000000..6e6507c Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.bin differ diff --git a/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf new file mode 100644 index 0000000..0b6b8d1 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Superhero_Female_FullBody.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.20467473566532135,-4.759437705400649e-21,1.2439442348830942e-21,0.9788300395011902],"translation":[-1.5903435849255598e-27,0.0711912140250206,2.0489096641540527e-08]},{"children":[0],"name":"neck_01","rotation":[0.3088374435901642,8.337463586651673e-21,-5.849849968646638e-21,0.95111483335495],"translation":[-1.0141478263557337e-20,0.19815585017204285,2.60770320892334e-08]},{"name":"index_04_leaf_l","rotation":[-8.381903171539307e-09,1,-1.5432005174599583e-14,3.6517765238386346e-06],"translation":[9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[2],"name":"index_03_l","rotation":[1.0023382124302316e-09,7.603918037758783e-13,-0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[3],"name":"index_02_l","rotation":[1.8987577732332284e-08,1.4024364247688936e-07,-0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[4],"name":"index_01_l","rotation":[-0.0033091865479946136,0.7071018815040588,-0.003309202380478382,0.7070961594581604],"translation":[0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_l","rotation":[2.0489096641540527e-08,1,6.335320587363621e-14,3.6517765238386346e-06],"translation":[2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[6],"name":"middle_03_l","rotation":[2.420814304571195e-08,1.4173854798182894e-11,-0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[-4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[7],"name":"middle_02_l","rotation":[-2.949975197452659e-08,1.402399334438087e-07,0.0038676888216286898,0.9999924898147583],"translation":[1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[8],"name":"middle_01_l","rotation":[-0.007979890331625938,0.7070646286010742,-0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_l","rotation":[8.381903171539307e-09,1,5.647658583201112e-14,3.65177675121231e-06],"translation":[-1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[10],"name":"pinky_03_l","rotation":[-2.1800540039862426e-08,5.25581791629981e-12,0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[-1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[11],"name":"pinky_02_l","rotation":[1.03155857189563e-08,1.4017835781032773e-07,-0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[-2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[12],"name":"pinky_01_l","rotation":[-0.01044775452464819,0.7070325016975403,-0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_l","rotation":[2.7474015951156616e-08,1,1.0721373739979931e-13,3.65177675121231e-06],"translation":[9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[14],"name":"ring_03_l","rotation":[2.6234243222234e-08,4.3393351412124304e-11,-0.0034884933847934008,0.9999939203262329],"translation":[7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[15],"name":"ring_02_l","rotation":[-1.8197205520209536e-08,1.4009778226409253e-07,0.002474082401022315,0.9999969601631165],"translation":[-9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[16],"name":"ring_01_l","rotation":[0.0025809321086853743,0.7071049213409424,0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_l","rotation":[-1.2782692238033633e-08,0.48539119958877563,2.0375746601075662e-07,0.8742970824241638],"scale":[0.9999999403953552,1,1],"translation":[-1.2670352589339018e-07,0.03127264976501465,3.073364496231079e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-0.0012816648231819272,5.811178561998531e-05,0.0002541383437346667,0.9999991655349731],"scale":[1,1,0.9999999403953552],"translation":[2.2436142899096012e-07,0.035620346665382385,5.122274160385132e-09]},{"children":[19],"name":"thumb_02_l","rotation":[0.0019128327257931232,0.00019216042710468173,-0.0004484721284825355,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[1.453081495128572e-07,0.03582108020782471,-1.30385160446167e-08]},{"children":[20],"name":"thumb_01_l","rotation":[0.263997346162796,0.9450139999389648,0.1882738322019577,0.04250776767730713],"scale":[1,1.0000004768371582,1],"translation":[0.009999980218708515,-0.0028757674153894186,0.029958141967654228]},{"children":[5,9,13,17,21],"name":"hand_l","rotation":[-0.02237604185938835,-2.264418526465306e-06,-3.032003448311116e-08,0.9997496008872986],"translation":[1.532122695380167e-08,0.24930401146411896,5.277343007037416e-09]},{"children":[22],"name":"lowerarm_l","rotation":[0.023802155628800392,8.208974031731486e-05,-1.8556972918304382e-06,0.9997166991233826],"translation":[-4.793889729626244e-08,0.2403421401977539,-4.220055416226387e-09]},{"children":[23],"name":"upperarm_l","rotation":[0.17232586443424225,0.6858593225479126,-0.1717943400144577,0.68584805727005],"scale":[1,1,1.0000001192092896],"translation":[-0.0016045584343373775,0.1410660296678543,-0.01228591613471508]},{"children":[24],"name":"clavicle_l","rotation":[-0.5471324324607849,-0.3204275965690613,-0.39127784967422485,0.666988730430603],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.02930000051856041,0.13370320200920105,0.0448194220662117]},{"name":"index_04_leaf_r","rotation":[-8.381903171539307e-09,-1,1.5432005174599583e-14,3.6517765238386346e-06],"translation":[-9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[26],"name":"index_03_r","rotation":[1.0023382124302316e-09,-7.603918037758783e-13,0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[-4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[27],"name":"index_02_r","rotation":[1.8987577732332284e-08,-1.4024364247688936e-07,0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[-8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[28],"name":"index_01_r","rotation":[-0.0033091865479946136,-0.7071018815040588,0.003309202380478382,0.7070961594581604],"translation":[-0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_r","rotation":[2.0489096641540527e-08,-1,-6.335320587363621e-14,3.6517765238386346e-06],"translation":[-2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[30],"name":"middle_03_r","rotation":[2.420814304571195e-08,-1.4173854798182894e-11,0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[31],"name":"middle_02_r","rotation":[-2.949975197452659e-08,-1.402399334438087e-07,-0.0038676888216286898,0.9999924898147583],"translation":[-1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[32],"name":"middle_01_r","rotation":[-0.007979890331625938,-0.7070646286010742,0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[-0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_r","rotation":[8.381903171539307e-09,-1,-5.647658583201112e-14,3.65177675121231e-06],"translation":[1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[34],"name":"pinky_03_r","rotation":[-2.1800540039862426e-08,-5.25581791629981e-12,-0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[35],"name":"pinky_02_r","rotation":[1.03155857189563e-08,-1.4017835781032773e-07,0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[36],"name":"pinky_01_r","rotation":[-0.01044775452464819,-0.7070325016975403,0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[-0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_r","rotation":[2.7474015951156616e-08,-1,-1.0721373739979931e-13,3.65177675121231e-06],"translation":[-9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[38],"name":"ring_03_r","rotation":[2.6234243222234e-08,-4.3393351412124304e-11,0.0034884933847934008,0.9999939203262329],"translation":[-7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[39],"name":"ring_02_r","rotation":[-1.8197205520209536e-08,-1.4009778226409253e-07,-0.002474082401022315,0.9999969601631165],"translation":[9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[40],"name":"ring_01_r","rotation":[0.0025809321086853743,-0.7071049213409424,-0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[-0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_r","rotation":[-2.5565388028780944e-08,-0.48539119958877563,-1.9393742434203887e-07,0.8742970824241638],"scale":[0.9999998807907104,0.9999999403953552,0.9999999403953552],"translation":[1.2622331269085407e-07,0.031272709369659424,3.073364496231079e-08]},{"children":[42],"name":"thumb_03_r","rotation":[-0.0012816853122785687,-5.809385038446635e-05,-0.00025414914125576615,0.9999991655349731],"scale":[1.0000001192092896,1.0000001192092896,1],"translation":[-1.0542134987190366e-07,0.03562036156654358,-8.847564458847046e-09]},{"children":[43],"name":"thumb_02_r","rotation":[0.001912830863147974,-0.00019218510715290904,0.0004484682867769152,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[-1.453081495128572e-07,0.03582108020782471,-1.30385160446167e-08]},{"children":[44],"name":"thumb_01_r","rotation":[0.263997346162796,-0.9450139999389648,-0.1882738322019577,0.04250776767730713],"scale":[1,1.0000004768371582,1],"translation":[-0.009999980218708515,-0.0028757674153894186,0.029958141967654228]},{"children":[29,33,37,41,45],"name":"hand_r","rotation":[-0.02237604185938835,2.264418526465306e-06,3.032003448311116e-08,0.9997496008872986],"translation":[-1.532122695380167e-08,0.24930401146411896,5.277343007037416e-09]},{"children":[46],"name":"lowerarm_r","rotation":[0.023802155628800392,-8.208974031731486e-05,1.8556972918304382e-06,0.9997166991233826],"translation":[4.793889729626244e-08,0.2403421401977539,-4.220055416226387e-09]},{"children":[47],"name":"upperarm_r","rotation":[0.17232586443424225,-0.6858593225479126,0.1717943400144577,0.68584805727005],"scale":[1,1,1.0000001192092896],"translation":[0.0016045584343373775,0.1410660296678543,-0.01228591613471508]},{"children":[48],"name":"clavicle_r","rotation":[-0.5471324324607849,0.3204275965690613,0.39127784967422485,0.666988730430603],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-0.02930000051856041,0.13370320200920105,0.0448194220662117]},{"children":[1,25,49],"name":"spine_03","rotation":[-0.10099761933088303,4.5928396448426806e-14,-4.5242200827702583e-13,0.9948866963386536],"scale":[1,1,0.9999999403953552],"translation":[4.6735704609246064e-20,0.12640145421028137,3.4924596548080444e-10]},{"children":[50],"name":"spine_02","rotation":[-0.055728573352098465,-4.992229150957428e-05,-2.7864282401424134e-06,0.998445987701416],"scale":[1,0.9999998211860657,0.9999998807907104],"translation":[-4.263256414560601e-13,0.11034487932920456,8.381903171539307e-09]},{"children":[51],"name":"spine_01","rotation":[-0.06393160670995712,4.989771332475357e-05,-3.19658010994317e-06,0.9979543089866638],"scale":[1,0.9999999403953552,1],"translation":[0,0.12655510008335114,-9.313225746154785e-10]},{"name":"ball_leaf_l","rotation":[0,0.00017269056115765125,5.421010862427522e-20,1],"scale":[1,1.0000001192092896,1],"translation":[5.525180313270539e-10,0.06809987872838974,-1.204170985147357e-09]},{"children":[53],"name":"ball_l","rotation":[-3.296312570455484e-05,0.9816133975982666,-0.1908797174692154,0.00016951537691056728],"scale":[1,1.0000001192092896,0.9999998807907104],"translation":[1.4901161193847656e-08,0.14916980266571045,-6.51925802230835e-09]},{"children":[54],"name":"foot_l","rotation":[-0.5978578329086304,0,0,0.8016021847724915],"scale":[1,1.0000001192092896,1],"translation":[-7.450580596923828e-09,0.4661003053188324,2.7939677238464355e-09]},{"children":[55],"name":"calf_l","rotation":[0.0712229460477829,0,0,0.9974604249000549],"scale":[1,0.9999999403953552,1],"translation":[-7.450580596923828e-09,0.4098641872406006,1.3969838619232178e-09]},{"children":[56],"name":"thigh_l","rotation":[0.9893749952316284,0,0,0.1453862488269806],"scale":[1,1,1.0000014305114746],"translation":[0.11140000075101852,0.010460092686116695,-0.008893024176359177]},{"name":"ball_leaf_r","rotation":[0,0.00017269056115765125,5.421010862427522e-20,1],"scale":[1,1.0000001192092896,1],"translation":[5.525180313270539e-10,0.06809987872838974,-1.5897967386990786e-09]},{"children":[58],"name":"ball_r","rotation":[-3.296312570455484e-05,0.9816133975982666,-0.1908797174692154,0.00016951537691056728],"scale":[1,1.0000001192092896,0.9999998807907104],"translation":[-1.4901161193847656e-08,0.14916980266571045,-6.51925802230835e-09]},{"children":[59],"name":"foot_r","rotation":[-0.5978578329086304,0,0,0.8016021847724915],"scale":[1,1.0000001192092896,1],"translation":[7.450580596923828e-09,0.4661003053188324,2.7939677238464355e-09]},{"children":[60],"name":"calf_r","rotation":[0.0712229460477829,0,0,0.9974604249000549],"scale":[1,0.9999999403953552,1],"translation":[7.450580596923828e-09,0.4098641872406006,1.3969838619232178e-09]},{"children":[61],"name":"thigh_r","rotation":[0.9893749952316284,0,0,0.1453862488269806],"scale":[1,1,1.0000014305114746],"translation":[-0.11140000075101852,0.010460092686116695,-0.008893024176359177]},{"children":[52,57,62],"name":"pelvis","rotation":[0.7879695892333984,0,0,0.6157141923904419],"translation":[0,0.045700013637542725,0.9318000078201294]},{"children":[63],"name":"root","rotation":[-0.7071067690849304,0,0,0.7071067690849304]},{"mesh":0,"name":"Eyebrows","skin":0},{"mesh":1,"name":"Eyes","skin":0},{"mesh":2,"name":"Superhero_Female","skin":0},{"children":[65,66,67,64],"name":"Armature"}],"materials":[{"doubleSided":true,"name":"MI_Hair_2","normalTexture":{"index":0},"pbrMetallicRoughness":{"baseColorTexture":{"index":1},"metallicFactor":0,"roughnessFactor":0.8227272629737854}},{"doubleSided":true,"name":"MI_Eyes","normalTexture":{"index":2},"pbrMetallicRoughness":{"baseColorTexture":{"index":3},"metallicFactor":0,"roughnessFactor":0.34545454382896423}},{"doubleSided":true,"name":"MI_Superhero_Female","normalTexture":{"index":4},"pbrMetallicRoughness":{"baseColorTexture":{"index":5},"metallicFactor":0,"metallicRoughnessTexture":{"index":6}}}],"meshes":[{"name":"Eyebrows","primitives":[{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2,"TEXCOORD_1":3,"TEXCOORD_2":4,"TEXCOORD_3":5,"COLOR_0":6,"COLOR_1":7,"JOINTS_0":8,"WEIGHTS_0":9},"indices":10,"material":0}]},{"name":"Eyes","primitives":[{"attributes":{"POSITION":12,"NORMAL":13,"TEXCOORD_0":14,"TEXCOORD_1":15,"TEXCOORD_2":16,"TEXCOORD_3":17,"COLOR_0":18,"COLOR_1":19,"JOINTS_0":20,"WEIGHTS_0":21},"indices":22,"material":1}]},{"name":"Superhero_Female","primitives":[{"attributes":{"POSITION":23,"NORMAL":24,"TEXCOORD_0":25,"TEXCOORD_1":26,"TEXCOORD_2":27,"TEXCOORD_3":28,"TEXCOORD_4":29,"COLOR_0":30,"COLOR_1":31,"COLOR_2":32,"JOINTS_0":33,"WEIGHTS_0":34},"indices":35,"material":2}]}],"textures":[{"sampler":0,"source":0},{"sampler":0,"source":1},{"sampler":0,"source":2},{"sampler":0,"source":3},{"sampler":0,"source":4},{"sampler":0,"source":5},{"sampler":0,"source":6}],"images":[{"mimeType":"image/png","name":"T_Hair_2_Normal","uri":"T_Hair_2_Normal.png"},{"mimeType":"image/png","name":"T_Hair_2_BaseColor","uri":"T_Hair_2_BaseColor.png"},{"mimeType":"image/png","name":"T_Eye_Normal.png","uri":"T_Eye_Normal.png"},{"mimeType":"image/png","name":"T_Eye_Brown","uri":"T_Eye_Brown.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Normal","uri":"T_Superhero_Female_Normal.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Dark_BaseColor","uri":"T_Superhero_Female_Dark_BaseColor.png"},{"mimeType":"image/png","name":"T_Superhero_Female_Roughness","uri":"T_Superhero_Female_Roughness.png"}],"skins":[{"inverseBindMatrices":11,"joints":[64,63,52,51,50,1,0,25,24,23,22,5,4,3,2,9,8,7,6,13,12,11,10,17,16,15,14,21,20,19,18,49,48,47,46,29,28,27,26,33,32,31,30,37,36,35,34,41,40,39,38,45,44,43,42,57,56,55,54,53,62,61,60,59,58],"name":"Armature"}],"accessors":[{"bufferView":0,"componentType":5126,"count":912,"max":[0.06299881637096405,1.6869006156921387,0.0915711298584938],"min":[-0.06299881637096405,1.6492890119552612,0.05128476396203041],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":912,"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":3,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":4,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":5,"componentType":5126,"count":912,"type":"VEC2"},{"bufferView":6,"componentType":5121,"count":912,"normalized":true,"type":"VEC4"},{"bufferView":7,"componentType":5123,"count":912,"normalized":true,"type":"VEC4"},{"bufferView":8,"componentType":5121,"count":912,"type":"VEC4"},{"bufferView":9,"componentType":5126,"count":912,"type":"VEC4"},{"bufferView":10,"componentType":5123,"count":4440,"type":"SCALAR"},{"bufferView":11,"componentType":5126,"count":65,"type":"MAT4"},{"bufferView":12,"componentType":5126,"count":556,"max":[0.05105403810739517,1.672553300857544,0.07533310353755951],"min":[-0.05105403810739517,1.6395376920700073,0.04291451722383499],"type":"VEC3"},{"bufferView":13,"componentType":5126,"count":556,"type":"VEC3"},{"bufferView":14,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":15,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":16,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":17,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":18,"componentType":5121,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":19,"componentType":5123,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":20,"componentType":5121,"count":556,"type":"VEC4"},{"bufferView":21,"componentType":5126,"count":556,"type":"VEC4"},{"bufferView":22,"componentType":5123,"count":2304,"type":"SCALAR"},{"bufferView":23,"componentType":5126,"count":7376,"max":[0.83443284034729,1.7666438817977905,0.1402275115251541],"min":[-0.8344329595565796,-0.008407175540924072,-0.1624695360660553],"type":"VEC3"},{"bufferView":24,"componentType":5126,"count":7376,"type":"VEC3"},{"bufferView":25,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":26,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":27,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":28,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":29,"componentType":5126,"count":7376,"type":"VEC2"},{"bufferView":30,"componentType":5121,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":31,"componentType":5123,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":32,"componentType":5123,"count":7376,"normalized":true,"type":"VEC4"},{"bufferView":33,"componentType":5121,"count":7376,"type":"VEC4"},{"bufferView":34,"componentType":5126,"count":7376,"type":"VEC4"},{"bufferView":35,"componentType":5123,"count":38436,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteLength":10944,"byteOffset":0,"target":34962},{"buffer":0,"byteLength":10944,"byteOffset":10944,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":21888,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":29184,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":36480,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":43776,"target":34962},{"buffer":0,"byteLength":3648,"byteOffset":51072,"target":34962},{"buffer":0,"byteLength":7296,"byteOffset":54720,"target":34962},{"buffer":0,"byteLength":3648,"byteOffset":62016,"target":34962},{"buffer":0,"byteLength":14592,"byteOffset":65664,"target":34962},{"buffer":0,"byteLength":8880,"byteOffset":80256,"target":34963},{"buffer":0,"byteLength":4160,"byteOffset":89136},{"buffer":0,"byteLength":6672,"byteOffset":93296,"target":34962},{"buffer":0,"byteLength":6672,"byteOffset":99968,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":106640,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":111088,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":115536,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":119984,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":124432,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":126656,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":131104,"target":34962},{"buffer":0,"byteLength":8896,"byteOffset":133328,"target":34962},{"buffer":0,"byteLength":4608,"byteOffset":142224,"target":34963},{"buffer":0,"byteLength":88512,"byteOffset":146832,"target":34962},{"buffer":0,"byteLength":88512,"byteOffset":235344,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":323856,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":382864,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":441872,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":500880,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":559888,"target":34962},{"buffer":0,"byteLength":29504,"byteOffset":618896,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":648400,"target":34962},{"buffer":0,"byteLength":59008,"byteOffset":707408,"target":34962},{"buffer":0,"byteLength":29504,"byteOffset":766416,"target":34962},{"buffer":0,"byteLength":118016,"byteOffset":795920,"target":34962},{"buffer":0,"byteLength":76872,"byteOffset":913936,"target":34963}],"samplers":[{"magFilter":9729,"minFilter":9987}],"buffers":[{"byteLength":990808,"uri":"Superhero_Female_FullBody.bin"}]} \ No newline at end of file diff --git a/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin new file mode 100644 index 0000000..54309de Binary files /dev/null and b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.bin differ diff --git a/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf new file mode 100644 index 0000000..472dd07 --- /dev/null +++ b/three-player-controller/example/public/glb/characters/Superhero_Male_FullBody.gltf @@ -0,0 +1 @@ +{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.13207007944583893,0,0,0.991240382194519],"translation":[0,0.08285234868526459,9.313225746154785e-09]},{"children":[0],"name":"neck_01","rotation":[0.25725916028022766,0,0,0.9663424491882324],"translation":[0,0.21509313583374023,1.862645149230957e-08]},{"name":"index_04_leaf_l","rotation":[-1.789221437675792e-09,0.9998229742050171,3.712300440383842e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[-1.1732481652870774e-09,0.024110054597258568,-1.3058706826996058e-07]},{"children":[2],"name":"index_03_l","rotation":[1.9416402210481465e-06,-7.145895324356388e-06,-0.00018566079961601645,1],"scale":[1,1,1.0000001192092896],"translation":[-5.757101462222636e-10,0.027911342680454254,5.889705789741129e-08]},{"children":[3],"name":"index_02_l","rotation":[3.8516853351211466e-07,4.9490859964862466e-05,-0.0005330402054823935,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[-8.440110832452774e-10,0.032612428069114685,1.2031887308694422e-07]},{"children":[4],"name":"index_01_l","rotation":[0.008505093865096569,0.7068116664886475,-0.01852414943277836,0.7071080803871155],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.007159475702792406,0.1174025684595108,0.040643345564603806]},{"name":"middle_04_leaf_l","rotation":[-1.5724443969133972e-08,0.9998255372047424,5.420716675530457e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[2.2610038286074996e-09,0.030519673600792885,1.955777406692505e-07]},{"children":[6],"name":"middle_03_l","rotation":[-4.686012857746391e-07,-5.432930265669711e-05,-0.0014198303688317537,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-5.793481250293553e-10,0.030116640031337738,-6.39629433862865e-08]},{"children":[7],"name":"middle_02_l","rotation":[5.612571953861334e-07,7.84045914770104e-05,0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[-4.098183126188815e-09,0.03752607852220535,-1.7083948478102684e-08]},{"children":[8],"name":"middle_01_l","rotation":[0.005067416001111269,0.7067627906799316,-0.02195817232131958,0.7070916295051575],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.004682549275457859,0.1154387816786766,0.015113381668925285]},{"name":"pinky_04_leaf_l","rotation":[-6.453817924523264e-09,0.9998291730880737,2.147510969052746e-07,0.018483733758330345],"translation":[2.8803697205148637e-09,0.021523315459489822,-3.95521055907011e-08]},{"children":[10],"name":"pinky_03_l","rotation":[3.3508222259115428e-06,-0.00013744289753958583,0.0004660093109123409,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[-4.249159246683121e-09,0.027430830523371696,1.7808633856475353e-07]},{"children":[11],"name":"pinky_02_l","rotation":[3.9539517615594377e-07,9.536039578961208e-05,-0.0015674401074647903,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[1.1932570487260818e-08,0.03163102641701698,-1.7530692275613546e-07]},{"children":[12],"name":"pinky_01_l","rotation":[0.002601056592538953,0.7066821455955505,-0.024414697661995888,0.7071049809455872],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.0060377283953130245,0.09854141622781754,-0.03461737558245659]},{"name":"ring_04_leaf_l","rotation":[8.940817863845041e-09,0.9998185634613037,5.402291662903735e-07,0.019049758091568947],"translation":[-9.167706593871117e-09,0.02910165674984455,5.82986103836447e-09]},{"children":[14],"name":"ring_03_l","rotation":[3.849756012641592e-06,-0.00025297756656073034,-0.003387551987543702,0.9999942183494568],"translation":[1.204170985147357e-09,0.028300179168581963,5.35583239980042e-08]},{"children":[15],"name":"ring_02_l","rotation":[-4.73642239740002e-06,0.00021853116049896926,0.00239976542070508,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[7.916241884231567e-09,0.03600123152136803,-8.07840478955768e-08]},{"children":[16],"name":"ring_01_l","rotation":[0.015323746018111706,0.7069170475006104,-0.011713176034390926,0.7070334553718567],"scale":[1,0.9999997615814209,1],"translation":[0.004751814063638449,0.10940060019493103,-0.012370164506137371]},{"name":"thumb_04_leaf_l","rotation":[1.285916511051255e-07,0.38270875811576843,-4.782022244853579e-08,0.9238690137863159],"scale":[1,0.9999999403953552,1],"translation":[-1.0035000741481781e-07,0.034558095037937164,2.1187588572502136e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-5.349284037947655e-08,-2.9730843380093575e-05,1.7080456018447876e-06,1],"translation":[-1.2048985809087753e-08,0.03549090400338173,-2.9336661100387573e-08]},{"children":[19],"name":"thumb_02_l","rotation":[6.149784894660115e-07,4.712928785011172e-06,-3.2112004646478454e-06,1],"scale":[1,0.9999998807907104,1],"translation":[-1.050066202878952e-07,0.039519939571619034,3.5390257835388184e-08]},{"children":[20],"name":"thumb_01_l","rotation":[-0.24446295201778412,-0.94356769323349,-0.21606169641017914,0.05687990039587021],"scale":[1,1.000000238418579,1],"translation":[0.0274813212454319,0.03381355106830597,0.04106312245130539]},{"children":[5,9,13,17,21],"name":"hand_l","rotation":[-0.015601660124957561,-4.1717584053913015e-07,-9.051257521264233e-09,0.9998782873153687],"scale":[1,1,0.9999999403953552],"translation":[-1.5123794128157897e-07,0.243594229221344,2.6994939616997726e-09]},{"children":[22],"name":"lowerarm_l","rotation":[0.030732227489352226,4.16175062127877e-05,-1.2497959005486337e-06,0.9995276927947998],"translation":[3.7845666156499647e-08,0.251115083694458,-5.1513779908418655e-09]},{"children":[23],"name":"upperarm_l","rotation":[0.149803027510643,0.6911307573318481,-0.14938844740390778,0.6910719275474548],"scale":[0.9999998211860657,0.999999463558197,0.9999998807907104],"translation":[-0.009135720320045948,0.20573900640010834,-0.03967121243476868]},{"children":[24],"name":"clavicle_l","rotation":[-0.5287374258041382,-0.32910192012786865,-0.41380804777145386,0.663996696472168],"scale":[1,0.9999998807907104,1],"translation":[0.03139999881386757,0.1737176477909088,0.06732845306396484]},{"name":"index_04_leaf_r","rotation":[-1.934766347133632e-09,-0.9998229742050171,-3.711606666456646e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[1.1732481652870774e-09,0.02411006949841976,-1.3124372344464064e-07]},{"children":[26],"name":"index_03_r","rotation":[1.943404186022235e-06,7.1459307946497574e-06,0.00018566078506410122,1],"scale":[1,1,1.0000001192092896],"translation":[4.911271389573812e-10,0.027911365032196045,6.19775164523162e-08]},{"children":[27],"name":"index_02_r","rotation":[3.823500378530298e-07,-4.935889955959283e-05,0.0005330423591658473,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[1.2951204553246498e-09,0.03261244669556618,1.2260898074600846e-07]},{"children":[28],"name":"index_01_r","rotation":[0.008505097590386868,-0.706811785697937,0.01852414757013321,0.7071079611778259],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[-0.007159472443163395,0.117402583360672,0.040643345564603806]},{"name":"middle_04_leaf_r","rotation":[-1.5782660511831637e-08,-0.9998255372047424,-5.4032970098205624e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[-2.2610038286074996e-09,0.030519699677824974,1.9423532648943365e-07]},{"children":[30],"name":"middle_03_r","rotation":[-4.687495334110281e-07,5.432928810478188e-05,0.001419831532984972,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-1.1996235116384923e-09,0.030116606503725052,-6.529444362968206e-08]},{"children":[31],"name":"middle_02_r","rotation":[5.584595896834799e-07,-7.839182944735512e-05,-0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[4.5683918870054185e-09,0.03752603381872177,-2.081651473417878e-08]},{"children":[32],"name":"middle_01_r","rotation":[0.005067416001111269,-0.7067628502845764,0.02195817045867443,0.7070915699005127],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-0.004682543221861124,0.1154387965798378,0.015113386325538158]},{"name":"pinky_04_leaf_r","rotation":[-9.396038613829205e-09,-0.9998291730880737,-2.1258703952753422e-07,0.018483733758330345],"translation":[-1.3542376109398901e-09,0.021523330360651016,-4.5925844460725784e-08]},{"children":[34],"name":"pinky_03_r","rotation":[3.3533979149069637e-06,0.0001374428829876706,-0.00046601047506555915,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[3.7834979593753815e-10,0.027430785819888115,1.7545244190841913e-07]},{"children":[35],"name":"pinky_02_r","rotation":[3.925948135474755e-07,-9.534749551676214e-05,0.0015674429014325142,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[-8.046299626585096e-09,0.03163103014230728,-5.98374754190445e-08]},{"children":[36],"name":"pinky_01_r","rotation":[0.0026010593865066767,-0.7066822052001953,0.024414699524641037,0.7071049213409424],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[-0.0060377162881195545,0.09854142367839813,-0.03461737558245659]},{"name":"ring_04_leaf_r","rotation":[8.93445140093263e-09,-0.9998185634613037,-5.40070118404401e-07,0.019049877300858498],"scale":[0.9999999403953552,1,0.9999999403953552],"translation":[6.257323548197746e-09,0.029101617634296417,2.222896000603214e-08]},{"children":[38],"name":"ring_03_r","rotation":[3.850367193081183e-06,0.00025285835727117956,0.0033875522203743458,0.9999942183494568],"translation":[-1.204170985147357e-09,0.028300197795033455,5.154106474947184e-08]},{"children":[39],"name":"ring_02_r","rotation":[-4.736421942652669e-06,-0.0002185181510867551,-0.002399763325229287,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[-4.307366907596588e-09,0.036001186817884445,-8.079041435848922e-08]},{"children":[40],"name":"ring_01_r","rotation":[0.01532374694943428,-0.7069171071052551,0.011713176965713501,0.7070333957672119],"scale":[1,0.9999997615814209,1],"translation":[-0.0047518047504127026,0.10940061509609222,-0.012370162643492222]},{"name":"thumb_04_leaf_r","rotation":[1.2106264080102846e-07,-0.3827088177204132,2.8808685215153673e-08,0.9238690137863159],"scale":[1.0000001192092896,1,1],"translation":[9.080395102500916e-09,0.03455810248851776,1.7695128917694092e-08]},{"children":[42],"name":"thumb_03_r","rotation":[-5.1935785450041294e-08,2.9705788620049134e-05,-1.7005951349347015e-06,1],"scale":[0.9999999403953552,1,0.9999999403953552],"translation":[1.2456439435482025e-08,0.03549079969525337,8.381903171539307e-09]},{"children":[43],"name":"thumb_02_r","rotation":[6.680493811472843e-07,-4.789792001247406e-06,3.1683596262155334e-06,1],"scale":[1,0.9999998807907104,0.9999999403953552],"translation":[-4.563480615615845e-08,0.039520014077425,2.8172507882118225e-08]},{"children":[44],"name":"thumb_01_r","rotation":[-0.24446293711662292,0.9435676336288452,0.21606166660785675,0.05687989294528961],"scale":[0.9999999403953552,1.0000001192092896,1.0000001192092896],"translation":[-0.02748131938278675,0.03381356596946716,0.04106312245130539]},{"children":[29,33,37,41,45],"name":"hand_r","rotation":[-0.015601660124957561,4.7677681891400425e-07,8.121838313002172e-09,0.9998782873153687],"scale":[1,1,0.9999999403953552],"translation":[1.5123667651550932e-07,0.2435941994190216,2.032038537436165e-09]},{"children":[46],"name":"lowerarm_r","rotation":[0.030732227489352226,-4.1677081753732637e-05,1.251685375791567e-06,0.9995276927947998],"translation":[-3.0872001843818e-08,0.251115083694458,-2.86672729998827e-09]},{"children":[47],"name":"upperarm_r","rotation":[0.1498030126094818,-0.6911307573318481,0.14938844740390778,0.6910719275474548],"scale":[0.9999998211860657,0.999999463558197,0.9999998807907104],"translation":[0.009135720320045948,0.20573900640010834,-0.03967121243476868]},{"children":[48],"name":"clavicle_r","rotation":[-0.5287374258041382,0.32910192012786865,0.41380804777145386,0.663996696472168],"scale":[1,0.9999998807907104,1],"translation":[-0.03139999881386757,0.1737176477909088,0.06732845306396484]},{"children":[1,25,49],"name":"spine_03","rotation":[-0.12418898940086365,0,0,0.9922585487365723],"translation":[0,0.1331336796283722,0]},{"children":[50],"name":"spine_02","rotation":[-0.03958535194396973,0,0,0.9992161989212036],"translation":[0,0.10634974390268326,-1.3969838619232178e-09]},{"children":[51],"name":"spine_01","rotation":[-0.09185619652271271,0,0,0.9957723021507263],"translation":[0,0.1280922144651413,-3.259629011154175e-08]},{"name":"ball_leaf_l","rotation":[1.4901161193847656e-08,-4.371149486814829e-08,-1.355950077832355e-14,1],"translation":[-1.3288261602184548e-09,0.07889983803033829,-4.233804418163345e-10]},{"children":[53],"name":"ball_l","rotation":[6.497470650401738e-08,0.9731196165084839,-0.23030038177967072,1.894733969720619e-07],"scale":[1,1.000000238418579,1],"translation":[-4.181756274590498e-09,0.15907371044158936,-7.893650177948075e-09]},{"children":[54],"name":"foot_l","rotation":[-0.5721680521965027,1.9553584706955007e-07,-1.3641555085541768e-07,0.8201364278793335],"translation":[0,0.4587883949279785,5.587935447692871e-09]},{"children":[55],"name":"calf_l","rotation":[0.05598893761634827,0,0,0.998431384563446],"translation":[-7.450580596923828e-09,0.4288000166416168,3.1723175197839737e-09]},{"children":[56],"name":"thigh_l","rotation":[0.9898310303688049,0,0,0.14224845170974731],"scale":[1,1,1.0000019073486328],"translation":[0.11430513858795166,0.023176943883299828,0.0004878304898738861]},{"name":"ball_leaf_r","rotation":[1.4901161193847656e-08,4.371149486814829e-08,1.355950077832355e-14,1],"translation":[1.3288261602184548e-09,0.07889983803033829,-4.233804418163345e-10]},{"children":[58],"name":"ball_r","rotation":[6.497470650401738e-08,-0.9731196165084839,0.23030038177967072,1.894733969720619e-07],"scale":[1,1.000000238418579,1],"translation":[4.181756274590498e-09,0.15907371044158936,-7.893650177948075e-09]},{"children":[59],"name":"foot_r","rotation":[-0.5721680521965027,-1.9553584706955007e-07,1.3641555085541768e-07,0.8201364278793335],"translation":[0,0.4587883949279785,5.587935447692871e-09]},{"children":[60],"name":"calf_r","rotation":[0.05598893761634827,0,0,0.998431384563446],"translation":[7.450580596923828e-09,0.4288000166416168,3.1723175197839737e-09]},{"children":[61],"name":"thigh_r","rotation":[0.9898310303688049,0,0,0.14224845170974731],"scale":[1,1,1.0000019073486328],"translation":[-0.11430513858795166,0.023176943883299828,0.0004878304898738861]},{"children":[52,57,62],"name":"pelvis","rotation":[0.8005710244178772,0,0,0.5992379188537598],"translation":[0,0.042999982833862305,0.9491000175476074]},{"children":[63],"name":"root","rotation":[-0.7071067690849304,0,0,0.7071067690849304]},{"mesh":0,"name":"Eyebrows","skin":0},{"mesh":1,"name":"Eyes","skin":0},{"mesh":2,"name":"SuperHero_Male","skin":0},{"children":[65,66,67,64],"name":"Armature"}],"materials":[{"doubleSided":true,"name":"MI_Hair_1","normalTexture":{"index":0},"pbrMetallicRoughness":{"baseColorTexture":{"index":1},"metallicFactor":0,"roughnessFactor":0.8227272629737854}},{"doubleSided":true,"name":"MI_Eyes","normalTexture":{"index":2},"pbrMetallicRoughness":{"baseColorTexture":{"index":3},"metallicFactor":0,"roughnessFactor":0.34545454382896423}},{"doubleSided":true,"name":"MI_Superhero_Male","normalTexture":{"index":4},"pbrMetallicRoughness":{"baseColorTexture":{"index":5},"metallicFactor":0,"metallicRoughnessTexture":{"index":6}}}],"meshes":[{"name":"Face","primitives":[{"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2,"TEXCOORD_1":3,"TEXCOORD_2":4,"TEXCOORD_3":5,"COLOR_0":6,"COLOR_1":7,"JOINTS_0":8,"WEIGHTS_0":9},"indices":10,"material":0}]},{"name":"Face.001","primitives":[{"attributes":{"POSITION":12,"NORMAL":13,"TEXCOORD_0":14,"TEXCOORD_1":15,"TEXCOORD_2":16,"TEXCOORD_3":17,"COLOR_0":18,"COLOR_1":19,"JOINTS_0":20,"WEIGHTS_0":21},"indices":22,"material":1}]},{"name":"Sphere.005_Retopology.004","primitives":[{"attributes":{"POSITION":23,"NORMAL":24,"TEXCOORD_0":25,"TEXCOORD_1":26,"COLOR_0":27,"COLOR_1":28,"JOINTS_0":29,"WEIGHTS_0":30},"indices":31,"material":2}]}],"textures":[{"sampler":0,"source":0},{"sampler":0,"source":1},{"sampler":0,"source":2},{"sampler":0,"source":3},{"sampler":0,"source":4},{"sampler":0,"source":5},{"sampler":0,"source":6}],"images":[{"mimeType":"image/png","name":"T_Hair_1_Normal.png","uri":"T_Hair_1_Normal.png"},{"mimeType":"image/png","name":"T_Hair_1_BaseColor","uri":"T_Hair_1_BaseColor.png"},{"mimeType":"image/png","name":"T_Eye_Normal.png","uri":"T_Eye_Normal.png"},{"mimeType":"image/png","name":"T_Eye_Brown","uri":"T_Eye_Brown.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Normal","uri":"T_Superhero_Male_Normal.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Dark","uri":"T_Superhero_Male_Dark.png"},{"mimeType":"image/png","name":"T_Superhero_Male_Roughness","uri":"T_Superhero_Male_Roughness.png"}],"skins":[{"inverseBindMatrices":11,"joints":[64,63,52,51,50,1,0,25,24,23,22,5,4,3,2,9,8,7,6,13,12,11,10,17,16,15,14,21,20,19,18,49,48,47,46,29,28,27,26,33,32,31,30,37,36,35,34,41,40,39,38,45,44,43,42,57,56,55,54,53,62,61,60,59,58],"name":"Armature"}],"accessors":[{"bufferView":0,"componentType":5126,"count":646,"max":[0.062340568751096725,1.7254674434661865,0.09447786957025528],"min":[-0.062340568751096725,1.6948596239089966,0.0569215789437294],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":646,"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":3,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":4,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":5,"componentType":5126,"count":646,"type":"VEC2"},{"bufferView":6,"componentType":5121,"count":646,"normalized":true,"type":"VEC4"},{"bufferView":7,"componentType":5123,"count":646,"normalized":true,"type":"VEC4"},{"bufferView":8,"componentType":5121,"count":646,"type":"VEC4"},{"bufferView":9,"componentType":5126,"count":646,"type":"VEC4"},{"bufferView":10,"componentType":5123,"count":2952,"type":"SCALAR"},{"bufferView":11,"componentType":5126,"count":65,"type":"MAT4"},{"bufferView":12,"componentType":5126,"count":556,"max":[0.04874879866838455,1.7135447263717651,0.08063791692256927],"min":[-0.04874879866838455,1.6835992336273193,0.05123402923345566],"type":"VEC3"},{"bufferView":13,"componentType":5126,"count":556,"type":"VEC3"},{"bufferView":14,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":15,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":16,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":17,"componentType":5126,"count":556,"type":"VEC2"},{"bufferView":18,"componentType":5121,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":19,"componentType":5123,"count":556,"normalized":true,"type":"VEC4"},{"bufferView":20,"componentType":5121,"count":556,"type":"VEC4"},{"bufferView":21,"componentType":5126,"count":556,"type":"VEC4"},{"bufferView":22,"componentType":5123,"count":2304,"type":"SCALAR"},{"bufferView":23,"componentType":5126,"count":7281,"max":[0.9294325113296509,1.8100767135620117,0.12803764641284943],"min":[-0.9294325113296509,-0.009509669616818428,-0.16345079243183136],"type":"VEC3"},{"bufferView":24,"componentType":5126,"count":7281,"type":"VEC3"},{"bufferView":25,"componentType":5126,"count":7281,"type":"VEC2"},{"bufferView":26,"componentType":5126,"count":7281,"type":"VEC2"},{"bufferView":27,"componentType":5121,"count":7281,"normalized":true,"type":"VEC4"},{"bufferView":28,"componentType":5123,"count":7281,"normalized":true,"type":"VEC4"},{"bufferView":29,"componentType":5121,"count":7281,"type":"VEC4"},{"bufferView":30,"componentType":5126,"count":7281,"type":"VEC4"},{"bufferView":31,"componentType":5123,"count":37698,"type":"SCALAR"}],"bufferViews":[{"buffer":0,"byteLength":7752,"byteOffset":0,"target":34962},{"buffer":0,"byteLength":7752,"byteOffset":7752,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":15504,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":20672,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":25840,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":31008,"target":34962},{"buffer":0,"byteLength":2584,"byteOffset":36176,"target":34962},{"buffer":0,"byteLength":5168,"byteOffset":38760,"target":34962},{"buffer":0,"byteLength":2584,"byteOffset":43928,"target":34962},{"buffer":0,"byteLength":10336,"byteOffset":46512,"target":34962},{"buffer":0,"byteLength":5904,"byteOffset":56848,"target":34963},{"buffer":0,"byteLength":4160,"byteOffset":62752},{"buffer":0,"byteLength":6672,"byteOffset":66912,"target":34962},{"buffer":0,"byteLength":6672,"byteOffset":73584,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":80256,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":84704,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":89152,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":93600,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":98048,"target":34962},{"buffer":0,"byteLength":4448,"byteOffset":100272,"target":34962},{"buffer":0,"byteLength":2224,"byteOffset":104720,"target":34962},{"buffer":0,"byteLength":8896,"byteOffset":106944,"target":34962},{"buffer":0,"byteLength":4608,"byteOffset":115840,"target":34963},{"buffer":0,"byteLength":87372,"byteOffset":120448,"target":34962},{"buffer":0,"byteLength":87372,"byteOffset":207820,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":295192,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":353440,"target":34962},{"buffer":0,"byteLength":29124,"byteOffset":411688,"target":34962},{"buffer":0,"byteLength":58248,"byteOffset":440812,"target":34962},{"buffer":0,"byteLength":29124,"byteOffset":499060,"target":34962},{"buffer":0,"byteLength":116496,"byteOffset":528184,"target":34962},{"buffer":0,"byteLength":75396,"byteOffset":644680,"target":34963}],"samplers":[{"magFilter":9729,"minFilter":9987}],"buffers":[{"byteLength":720076,"uri":"Superhero_Male_FullBody.bin"}]} \ No newline at end of file diff --git a/three-player-controller/example/public/glb/characters/T_Eye_Brown.png b/three-player-controller/example/public/glb/characters/T_Eye_Brown.png new file mode 100644 index 0000000..0d037fe Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Eye_Brown.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Eye_Normal.png b/three-player-controller/example/public/glb/characters/T_Eye_Normal.png new file mode 100644 index 0000000..2da5912 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Eye_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png new file mode 100644 index 0000000..c398ca7 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_1_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png b/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png new file mode 100644 index 0000000..5aa652e Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_1_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png new file mode 100644 index 0000000..bda8d95 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_2_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png b/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png new file mode 100644 index 0000000..2d77b1d Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Hair_2_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png new file mode 100644 index 0000000..06d49a5 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Dark_BaseColor.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png new file mode 100644 index 0000000..20af328 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png new file mode 100644 index 0000000..d496b53 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Female_Roughness.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png new file mode 100644 index 0000000..6391f45 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Dark.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png new file mode 100644 index 0000000..88303e2 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Normal.png differ diff --git a/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png new file mode 100644 index 0000000..2bbc618 Binary files /dev/null and b/three-player-controller/example/public/glb/characters/T_Superhero_Male_Roughness.png differ diff --git a/three-player-controller/example/public/glb/crash_junction.glb b/three-player-controller/example/public/glb/crash_junction.glb new file mode 100644 index 0000000..51f9da2 Binary files /dev/null and b/three-player-controller/example/public/glb/crash_junction.glb differ diff --git a/three-player-controller/example/public/glb/eiffel_collider.glb b/three-player-controller/example/public/glb/eiffel_collider.glb new file mode 100644 index 0000000..3e961b9 Binary files /dev/null and b/three-player-controller/example/public/glb/eiffel_collider.glb differ diff --git a/three-player-controller/example/public/glb/horror_corridor.glb b/three-player-controller/example/public/glb/horror_corridor.glb new file mode 100644 index 0000000..944fe07 Binary files /dev/null and b/three-player-controller/example/public/glb/horror_corridor.glb differ diff --git a/three-player-controller/example/public/glb/josh.glb b/three-player-controller/example/public/glb/josh.glb new file mode 100644 index 0000000..a528f2b Binary files /dev/null and b/three-player-controller/example/public/glb/josh.glb differ diff --git a/three-player-controller/example/public/glb/maw.glb b/three-player-controller/example/public/glb/maw.glb new file mode 100644 index 0000000..6f88d5e Binary files /dev/null and b/three-player-controller/example/public/glb/maw.glb differ diff --git a/three-player-controller/example/public/glb/sedan.glb b/three-player-controller/example/public/glb/sedan.glb new file mode 100644 index 0000000..274ddaf Binary files /dev/null and b/three-player-controller/example/public/glb/sedan.glb differ diff --git a/three-player-controller/example/public/glb/suv.glb b/three-player-controller/example/public/glb/suv.glb new file mode 100644 index 0000000..edfabfe Binary files /dev/null and b/three-player-controller/example/public/glb/suv.glb differ diff --git a/three-player-controller/example/public/glb/swat.glb b/three-player-controller/example/public/glb/swat.glb new file mode 100644 index 0000000..cebb3c5 Binary files /dev/null and b/three-player-controller/example/public/glb/swat.glb differ diff --git a/three-player-controller/example/public/glb/ual.glb b/three-player-controller/example/public/glb/ual.glb new file mode 100644 index 0000000..e236f34 Binary files /dev/null and b/three-player-controller/example/public/glb/ual.glb differ diff --git a/three-player-controller/example/public/glb/zombie.glb b/three-player-controller/example/public/glb/zombie.glb new file mode 100644 index 0000000..f80f565 Binary files /dev/null and b/three-player-controller/example/public/glb/zombie.glb differ diff --git a/three-player-controller/example/public/img/bullet_hole.png b/three-player-controller/example/public/img/bullet_hole.png new file mode 100644 index 0000000..aedd990 Binary files /dev/null and b/three-player-controller/example/public/img/bullet_hole.png differ diff --git a/three-player-controller/example/public/img/covers/3dgs.png b/three-player-controller/example/public/img/covers/3dgs.png new file mode 100644 index 0000000..93576ad Binary files /dev/null and b/three-player-controller/example/public/img/covers/3dgs.png differ diff --git a/three-player-controller/example/public/img/covers/3dtiles.png b/three-player-controller/example/public/img/covers/3dtiles.png new file mode 100644 index 0000000..e3b54fc Binary files /dev/null and b/three-player-controller/example/public/img/covers/3dtiles.png differ diff --git a/three-player-controller/example/public/img/covers/footik.png b/three-player-controller/example/public/img/covers/footik.png new file mode 100644 index 0000000..4e5e2ee Binary files /dev/null and b/three-player-controller/example/public/img/covers/footik.png differ diff --git a/three-player-controller/example/public/img/covers/glTF.png b/three-player-controller/example/public/img/covers/glTF.png new file mode 100644 index 0000000..67ad2bf Binary files /dev/null and b/three-player-controller/example/public/img/covers/glTF.png differ diff --git a/three-player-controller/example/public/img/covers/shooting.jpg b/three-player-controller/example/public/img/covers/shooting.jpg new file mode 100644 index 0000000..5753ef3 Binary files /dev/null and b/three-player-controller/example/public/img/covers/shooting.jpg differ diff --git a/three-player-controller/example/public/img/covers/showcase.png b/three-player-controller/example/public/img/covers/showcase.png new file mode 100644 index 0000000..b1fc3ae Binary files /dev/null and b/three-player-controller/example/public/img/covers/showcase.png differ diff --git a/three-player-controller/example/public/img/env.hdr b/three-player-controller/example/public/img/env.hdr new file mode 100644 index 0000000..cc1f3dd Binary files /dev/null and b/three-player-controller/example/public/img/env.hdr differ diff --git a/three-player-controller/example/public/img/muzzle_flash.png b/three-player-controller/example/public/img/muzzle_flash.png new file mode 100644 index 0000000..c826c44 Binary files /dev/null and b/three-player-controller/example/public/img/muzzle_flash.png differ diff --git a/three-player-controller/example/public/img/readme/preview.png b/three-player-controller/example/public/img/readme/preview.png new file mode 100644 index 0000000..928a49b Binary files /dev/null and b/three-player-controller/example/public/img/readme/preview.png differ diff --git a/three-player-controller/example/public/img/smoke.png b/three-player-controller/example/public/img/smoke.png new file mode 100644 index 0000000..02af04c Binary files /dev/null and b/three-player-controller/example/public/img/smoke.png differ diff --git a/three-player-controller/example/public/img/source.svg b/three-player-controller/example/public/img/source.svg new file mode 100644 index 0000000..2356081 --- /dev/null +++ b/three-player-controller/example/public/img/source.svg @@ -0,0 +1,4 @@ + + + + diff --git a/three-player-controller/example/public/loader.js b/three-player-controller/example/public/loader.js new file mode 100644 index 0000000..2a640ac --- /dev/null +++ b/three-player-controller/example/public/loader.js @@ -0,0 +1,221 @@ +(function () { + const script = document.currentScript || document.querySelector("script[src*='loader.js']"); + const demo = script?.dataset?.demo || ""; + const fade = 600; + + if (!document.querySelector('link[href*="Cinzel"]')) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap"; + document.head.appendChild(link); + } + + const style = document.createElement("style"); + style.id = "__loader-style__"; + style.textContent = ` + html { height: 100%; } + + #__loading-overlay__ { + position: fixed; + inset: 0; + z-index: 99999; + background: #ececec; + display: flex; + align-items: center; + justify-content: center; + transition: opacity ${fade}ms cubic-bezier(0.4, 0, 0.2, 1); + } + #__loading-overlay__.fade-out { + opacity: 0; + pointer-events: none; + } + .__ldr-content__ { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 1.5rem; + user-select: none; + } + .__ldr-play__ { + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: #e8a020; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.4rem; + box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35); + animation: __ldr-pulse__ 1.6s ease-in-out infinite; + } + .__ldr-play__ svg { + width: 14px; + height: 14px; + fill: #fff; + margin-left: 2px; + } + .__ldr-title__ { + font-family: "Cinzel", serif; + font-size: clamp(1.4rem, 3.5vw, 2.2rem); + font-weight: 900; + letter-spacing: 0.06em; + color: #1a1a2e; + text-align: center; + margin-bottom: 0.3rem; + } + .__ldr-meta__ { + display: flex; + align-items: baseline; + justify-content: center; + flex-wrap: wrap; + gap: 0.45em; + margin-bottom: 1.5rem; + } + .__ldr-kicker__ { + font-family: "Nunito", sans-serif; + font-size: 0.78rem; + font-weight: 700; + color: #999; + letter-spacing: 0.14em; + text-transform: uppercase; + } + .__ldr-status__ { + font-family: "Nunito", sans-serif; + font-size: 0.88rem; + font-weight: 700; + color: #555; + line-height: 1.4; + } + .__ldr-status__:empty { + display: none; + } + .__ldr-progress__ { + width: min(220px, 70vw); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .__ldr-track__ { + width: 100%; + height: 4px; + background: rgba(0, 0, 0, 0.12); + border-radius: 2px; + overflow: hidden; + } + .__ldr-fill__ { + height: 100%; + width: 40%; + background: #e8a020; + border-radius: 2px; + transform: translateX(-120%); + animation: __ldr-slide__ 1.35s ease-in-out infinite; + } + .__ldr-fill__.is-determinate { + width: 0%; + transform: none; + animation: none; + transition: width 0.25s ease; + } + .__ldr-pct__ { + font-family: "Nunito", system-ui, sans-serif; + font-size: 12px; + font-weight: 700; + color: #555; + letter-spacing: 0.05em; + min-height: 1em; + } + @keyframes __ldr-pulse__ { + 0%, 100% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35); + } + 50% { + transform: scale(1.08); + background: #f5b535; + box-shadow: 0 8px 24px rgba(232, 160, 32, 0.45); + } + } + @keyframes __ldr-slide__ { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(350%); } + } + @media (prefers-reduced-motion: reduce) { + .__ldr-play__, + .__ldr-fill__ { + animation: none; + } + .__ldr-fill__:not(.is-determinate) { + width: 100%; + transform: none; + } + #__loading-overlay__ { + transition: none; + } + } + `; + document.head.appendChild(style); + + const overlay = document.createElement("div"); + overlay.id = "__loading-overlay__"; + overlay.setAttribute("role", "status"); + overlay.setAttribute("aria-busy", "true"); + overlay.setAttribute("aria-live", "polite"); + overlay.innerHTML = ` +
+ +
three-player-controller
+
+
Loading
+
+
+
+
+
+
+
`; + document.documentElement.appendChild(overlay); + + const fill = overlay.querySelector("#__ldr-fill__"); + const pct = overlay.querySelector("#__ldr-pct__"); + const status = overlay.querySelector("#__ldr-status__"); + if (demo) status.textContent = demo; + + window.setLoaderStatus = function (text) { + if (status) status.textContent = text || ""; + }; + + window.setLoaderProgress = function (loaded, total) { + if (!fill) return; + if (total > 0) { + fill.classList.add("is-determinate"); + const p = Math.min(100, Math.round((loaded / total) * 100)); + fill.style.width = p + "%"; + if (pct) pct.textContent = p + "%"; + } else { + fill.classList.remove("is-determinate"); + fill.style.width = ""; + if (pct) pct.textContent = (loaded / 1048576).toFixed(1) + " MB"; + } + }; + + window.hideLoader = function () { + const el = document.getElementById("__loading-overlay__"); + if (!el || el.dataset.hiding) return; + el.dataset.hiding = "1"; + el.classList.add("fade-out"); + el.setAttribute("aria-busy", "false"); + + let cleaned = false; + const done = () => { + if (cleaned) return; + cleaned = true; + el.remove(); + document.getElementById("__loader-style__")?.remove(); + }; + el.addEventListener("transitionend", done, { once: true }); + setTimeout(done, fade + 80); + }; +})(); diff --git a/three-player-controller/example/public/textures/marble-floor.jpg b/three-player-controller/example/public/textures/marble-floor.jpg new file mode 100644 index 0000000..52eeb96 Binary files /dev/null and b/three-player-controller/example/public/textures/marble-floor.jpg differ diff --git a/three-player-controller/example/public/textures/showcase/lift.png b/three-player-controller/example/public/textures/showcase/lift.png new file mode 100644 index 0000000..3952bef Binary files /dev/null and b/three-player-controller/example/public/textures/showcase/lift.png differ diff --git a/three-player-controller/example/public/textures/showcase/prototype.png b/three-player-controller/example/public/textures/showcase/prototype.png new file mode 100644 index 0000000..02fd0ba Binary files /dev/null and b/three-player-controller/example/public/textures/showcase/prototype.png differ diff --git a/three-player-controller/example/shooting/core/SceneSetup.js b/three-player-controller/example/shooting/core/SceneSetup.js new file mode 100644 index 0000000..0fc8a76 --- /dev/null +++ b/three-player-controller/example/shooting/core/SceneSetup.js @@ -0,0 +1,124 @@ +import { + ACESFilmicToneMapping, + DirectionalLight, + MathUtils, + PerspectiveCamera, + Scene, + VSMShadowMap, + WebGLRenderer, +} from "three"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { LightProbeGrid } from "three/addons/lighting/LightProbeGrid.js"; +import { LightProbeGridHelper } from "three/addons/helpers/LightProbeGridHelper.js"; + + +export function createSceneSetup({ container }) { + // ==================== 渲染器 ==================== + const renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + container.appendChild(renderer.domElement); + + // ==================== 场景 & 相机 ==================== + const scene = new Scene(); + const camera = new PerspectiveCamera( + 60, + container.clientWidth / container.clientHeight, + 0.1, + 2000 + ); + + // ==================== 控制器 ==================== + const controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.maxDistance = 2000; + + // ==================== 参数 ==================== + const params = { + showLightHelper: true, + lightAzimuth: 45, + lightElevation: 45, + lightIntensity: 10, + shadows: true, + probesEnabled: true, + showProbes: false, + probeSize: 0.5, + boundsX: 8, boundsY: 3.2, boundsZ: -2, + sizeX: 40, sizeY: 3, sizeZ: 25, + countX: 20, countY: 4, countZ: 10, + }; + + // ==================== 平行光 ==================== + const dirLight = new DirectionalLight(0xfff2dc, params.lightIntensity); + dirLight.castShadow = true; + dirLight.shadow.mapSize.setScalar(2048); + dirLight.shadow.camera.left = -50; + dirLight.shadow.camera.right = 50; + dirLight.shadow.camera.top = 50; + dirLight.shadow.camera.bottom = -50; + dirLight.shadow.camera.near = 0.1; + dirLight.shadow.camera.far = 200; + dirLight.shadow.bias = -0.001; + scene.add(dirLight); + scene.add(dirLight.target); + + function updateLightPosition() { + const azimuth = MathUtils.degToRad(params.lightAzimuth); + const elevation = MathUtils.degToRad(params.lightElevation); + const radius = 100; + const h = Math.cos(elevation) * radius; + const v = Math.sin(elevation) * radius; + dirLight.position.set(Math.cos(azimuth) * h, v, Math.sin(azimuth) * h); + dirLight.target.position.set(0, 0, 0); + dirLight.target.updateMatrixWorld(); + } + + updateLightPosition(); + + // ==================== 光探针 ==================== + let probes = null; + let probesHelper = null; + let isBaking = false; + let bakeQueued = false; + + async function bakeProbes() { + if (isBaking) { bakeQueued = true; return; } + isBaking = true; + do { + bakeQueued = false; + if (probes) { scene.remove(probes); probes.dispose(); } + + probes = new LightProbeGrid( + params.sizeX, params.sizeY, params.sizeZ, + params.countX, params.countY, params.countZ + ); + probes.position.set(params.boundsX, params.boundsY, params.boundsZ); + probes.updateMatrixWorld(true); // 强制更新世界矩阵,否则 bake() 读到旧位置 + if (probesHelper) probesHelper.visible = false; + + const far = Math.max(params.sizeX, params.sizeY, params.sizeZ) * 2; + probes.bake(renderer, scene, { cubemapSize: 32, near: 0.05, far }); + probes.visible = params.probesEnabled; + scene.add(probes); + + if (!probesHelper) { + probesHelper = new LightProbeGridHelper(probes, params.probeSize); + scene.add(probesHelper); + } else { + probesHelper.probes = probes; + probesHelper.update(); + } + probesHelper.position.set(params.boundsX, params.boundsY, params.boundsZ); + probesHelper.visible = params.showProbes; + } while (bakeQueued); + isBaking = false; + } + + + return { scene, renderer, camera, controls, dirLight, bakeProbes }; +} diff --git a/three-player-controller/example/shooting/player/LocalPlayer.js b/three-player-controller/example/shooting/player/LocalPlayer.js new file mode 100644 index 0000000..407c88e --- /dev/null +++ b/three-player-controller/example/shooting/player/LocalPlayer.js @@ -0,0 +1,330 @@ +import { MathUtils, Quaternion, Vector3, AnimationMixer, AnimationClip, LoopOnce, LoopRepeat } from "three"; +import { playerController } from "../../../src/PlayerController"; +import { SpineIK } from "./spineIK.js"; + +const spineBoneNames = ["mixamorigSpine", "mixamorigSpine1", "mixamorigSpine2"]; + +// 运动状态集合(用于判断 isMoving) +const locomotionStates = new Set([ + "idle", + "walking", + "walking_backward", + "left_walking", + "right_walking", + "running", + "jumping", + "flyidle", + "flying", +]); + +// 允许执行战斗逻辑的运动状态(上半身分离后,奔跑时也可开火/换弹) +const combatAllowedLocomotion = new Set([ + "idle", "walking", "walking_backward", "left_walking", "right_walking", "running", +]); + +const minPitchAngle = -Math.PI * (60 / 180); // 俯仰最小角(向下) +const maxPitchAngle = Math.PI * (40 / 180); // 俯仰最大角(向上) + +export class LocalPlayer { + constructor({ scene, camera, controls }) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._camera = camera; + this._controls = controls; + + // ==================== 核心对象 ==================== + this._player = null; // playerController 实例 + this.spineIK = null; // 脊椎 IK 实例 + + // ==================== 运动状态 ==================== + this.pitchTarget1P = 0; // 第一人称俯仰累积值 + this.isMoving = false; // 当前帧是否处于移动状态(由 onAnimationChange 更新) + this._locomotionState = "idle"; // 当前运动动画状态名 + + // ==================== 配置 ==================== + this._mouseSensitivity = 5; + this._firstPersonPitchOffset = 0; // 第一人称相机俯仰初始偏移 + + // ==================== 外部注入 ==================== + this._isGunEngagedFn = null; // 由 WeaponController 注入,判断是否持枪 + + // ==================== 上半身动画层 ==================== + this._upperMixer = null; // 上半身专用 AnimationMixer(root = 模型根节点) + this._upperBodyBoneNames = null; // 脊椎以上所有骨骼名称集合,用于过滤 partial clip + this._upperBodyBones = null; // 脊椎以上骨骼引用数组(缓存,避免每帧 getObjectByName) + this._upperBoneSnapshots = null; // 主 mixer 骨骼值快照 + this._upperActions = new Map(); // key → AnimationAction + this._upperState = null; // 当前上半身动作 + + // ==================== 走路瞄准修正 ==================== + this._idleHipsQ = null; // 静止时保存的 hips 本地四元数,用于走路时抵消 hips 走路偏移 + } + + // ==================== 初始化 ==================== + + // 初始化 playerController、骨骼 IK、事件回调 + async init(config) { + const { mouseSensitivity = 5, ...rest } = config; + this._mouseSensitivity = mouseSensitivity; + this._firstPersonPitchOffset = config.playerModelConfig?.firstPersonPitchOffset + ?? this._firstPersonPitchOffset; + + this._player = new playerController(); + await this._player.init({ + scene: this._scene, + camera: this._camera, + controls: this._controls, + mouseSensitivity, + ...rest, + }); + + // 绑定脊椎 & 头部骨骼 + const model = this._player.getPlayerModel(); + const spineBones = spineBoneNames + .map((n) => model?.getObjectByName(n)) + .filter(Boolean); + const headBoneName = config.playerModelConfig?.headBoneName; + const headBone = model?.getObjectByName(headBoneName) ?? null; + this.spineIK = new SpineIK(spineBones, headBone); + + // 上半身 mixer:root = 模型根节点(与主 mixer 一致,路径解析最可靠) + // 通过 partial clip(只含脊椎以上骨骼的 track)来限制写入范围, + // 主 mixer 更新后再更新 upper mixer,后者覆写脊椎以上骨骼,下半身保持 locomotion 值。 + if (spineBones.length > 0) { + this._upperBodyBoneNames = new Set(); + this._upperBodyBones = []; + spineBones[0].traverse(b => { + this._upperBodyBoneNames.add(b.name); + this._upperBodyBones.push(b); + }); + // 预分配快照数组,避免每帧 GC + this._upperBoneSnapshots = this._upperBodyBones.map(() => new Quaternion()); + this._upperMixer = new AnimationMixer(model); + } + + // 监听动画切换,更新 isMoving + this._player.onAnimationChange = (name) => { + if (locomotionStates.has(name)) this._locomotionState = name; + this.isMoving = + name === "walking" || + name === "left_walking" || + name === "right_walking" || + name === "walking_backward" || + name === "running"; + }; + + // 接管第一人称鼠标移动 + this._player.onTowardChange = (dx, dy, speed) => { + if (!this._player.getIsFirstPerson()) return; + + // 水平朝向 + this._player.getPlayerCapsule().rotateY( + -dx * speed * this._mouseSensitivity + ); + + // 俯仰角累积 + this.pitchTarget1P = MathUtils.clamp( + this.pitchTarget1P + (-dy * speed * this._mouseSensitivity), + minPitchAngle, + maxPitchAngle + ); + + // 未持枪时直接驱动相机 + if (!this._isGunEngagedFn?.()) { + this._camera.rotation.x = MathUtils.clamp( + this._camera.rotation.x + (-dy * speed * this._mouseSensitivity), + minPitchAngle, + maxPitchAngle + ); + } + }; + + // 视角切换 + this._player.onViewChange = (isFirstPerson) => { + if (isFirstPerson) { + if (headBoneName) { + console.log(headBoneName); + this._camera.position.z = 8; + this._camera.position.x = 15; + } else { + this._camera.position.z = 0; + this._camera.position.x = 0; + } + this._camera.rotation.x = this._firstPersonPitchOffset; + this._player.setEnableToward(false); + // 同步控制器俯仰角 + const targetPolar = this._controls.getPolarAngle() - Math.PI / 2 + Math.PI * (7.5 / 180); + this.pitchTarget1P = targetPolar; + // 航向角偏移 + this._player.getPlayerCapsule().rotateY(-Math.PI * (17 / 180)); + } else { + this._player.setEnableToward(true); + // 持枪瞄准状态下刷新一次动画,保证骨骼复原 + if (this._player.getCurrentPlayerAnimationName().includes("rifle_idle_aim")) { + this._player.playAnimation("idle"); + } + // 同步第一人称俯仰角 + const targetPolar = Math.PI / 2 + this.pitchTarget1P - Math.PI * (7.5 / 180); + this._controls.minPolarAngle = targetPolar; + this._controls.maxPolarAngle = targetPolar; + this._controls.update(); + this._controls.minPolarAngle = minPitchAngle + Math.PI / 2; + this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2; + // 航向角偏移 + const delta = Math.PI * (17 / 180); + const offset = this._camera.position.clone().sub(this._controls.target); + offset.applyAxisAngle(new Vector3(0, 1, 0), delta); + this._camera.position.copy(this._controls.target).add(offset); + this._controls.update(); + } + }; + + // 限制第三人称俯仰角 + this._controls.minPolarAngle = minPitchAngle + Math.PI / 2; + this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2; + } + + // ==================== 外部注入 ==================== + + // 由 WeaponController 注入,让 1P 俯仰驱动能感知持枪状态 + setGunEngagedGetter(fn) { + this._isGunEngagedFn = fn; + } + + // ==================== 主循环 ==================== + + // 每帧驱动动画与物理 + // dt 须由主循环传入,上半身 mixer 在主 mixer 之后更新才能正确覆写骨骼 + update(dt) { + this._player?.update(dt); + + if (this._upperMixer && dt != null) { + const ua = this._upperState; + if (!ua) return; + + // Three.js PropertyMixer.apply() 有变更检测优化:仅当 accu0 ≠ accu1 时 + // 才调用 setValue() 写入骨骼。对于完全相同帧的动画(两帧值一致), + // 两个 accu buffer 永远相等,导致 setValue() 被跳过,主 mixer 的 + // locomotion 动画透过来。修复:每帧更新前把两个 accu buffer 填为 NaN, + // 使比较永远不相等,强制 setValue() 每帧执行,确保上半身覆盖生效。 + if (ua._propertyBindings) { + for (const pm of ua._propertyBindings) { + if (pm?.buffer) { + const s = pm.valueSize; + pm.buffer.fill(NaN, s, s * 3); // dirty accu0 + accu1 + } + } + } + this._upperMixer.update(dt); + } + } + + // ==================== 上半身动画层 ==================== + + // 从指定 clip 的 t=0 帧直接读取 hips 四元数 + initIdleHipsQ(clipName) { + const clip = this._player?.animation?.clips?.find(c => c.name === clipName); + if (!clip) { console.warn(`initIdleHipsQ: 找不到 "${clipName}"`); return; } + const track = clip.tracks.find(t => t.name === 'mixamorigHips.quaternion'); + if (!track || track.values.length < 4) return; + this._idleHipsQ = new Quaternion(track.values[0], track.values[1], track.values[2], track.values[3]); + } + + // 在上半身 mixer 上注册一个动画 + registerUpperAnimation(key, clipName, opts = {}) { + if (!this._upperMixer || !this._upperBodyBoneNames) return; + const clips = this._player?.animation?.clips; + if (!clips) return; + const clip = clips.find(c => c.name === clipName); + if (!clip) { console.warn(`registerUpperAnimation: 找不到 "${clipName}"`); return; } + + // 只保留脊椎以上骨骼的 track,其余 track(hips、腿部等)丢弃 + const upperTracks = clip.tracks.filter(t => { + const boneName = t.name.split('.')[0]; + return this._upperBodyBoneNames.has(boneName); + }); + const partialClip = new AnimationClip(clip.name + '_upper_' + key, clip.duration, upperTracks); + + const action = this._upperMixer.clipAction(partialClip); + action.setLoop(opts.loop === false ? LoopOnce : LoopRepeat, Infinity); + action.clampWhenFinished = opts.clampWhenFinished ?? false; + const ts = opts.duration ? clip.duration / opts.duration : (opts.timeScale ?? 1); + action.setEffectiveTimeScale(ts); + action.enabled = true; + action.setEffectiveWeight(0); + this._upperActions.set(key, action); + + if (opts.onFinished) { + this._upperMixer.addEventListener("finished", (ev) => { + if (ev.action === action) opts.onFinished(); + }); + } + } + + // 播放上半身动画(仅覆写脊椎以上骨骼) + playUpperBody(key, opts = {}) { + if (!this._upperMixer) return; + const next = this._upperActions.get(key); + if (!next) { console.warn(`playUpperBody: "${key}" 未注册`); return; } + + const fade = opts.fade ?? 0.18; + const prev = this._upperState; + + if (!opts.force && prev === next) return; + + // 直接设为 1,从第一帧起就以满权重覆盖主 mixer。 + if (prev && prev !== next) prev.fadeOut(fade); + + next.reset(); + next.setEffectiveWeight(1); + next.play(); + + this._upperState = next; + } + + // 走路/奔跑时修正 spine[0] 四元数,抵消 hips 走路旋转偏移 + // 使 spine0 的世界朝向等效于静止时(idle hips × spine_local), + applyHipsCorrection() { + if (!this._idleHipsQ || !this.spineIK?.spineBones?.length) return; + const hipsBone = this._player?.getPlayerModel()?.getObjectByName("mixamorigHips"); + if (!hipsBone) return; + + // correction = hips_walk_local⁻¹ × hips_idle_local + const correction = new Quaternion().copy(hipsBone.quaternion).invert().multiply(this._idleHipsQ); + const spine0 = this.spineIK.spineBones[0]; + spine0.quaternion.premultiply(correction); + spine0.updateWorldMatrix(false, false); + } + + // 停止上半身动画,让下半身(全身)动画完全接管 + stopUpperBody(fade = 0.18) { + if (!this._upperState) return; + this._upperState.fadeOut(fade); + this._upperState = null; + } + + // ==================== 工具方法 ==================== + + isCombatLocomotionAllowed() { return combatAllowedLocomotion.has(this._locomotionState); } + + // ==================== playerController 代理 ==================== + + getIsFirstPerson() { return this._player?.getIsFirstPerson() ?? false; } + getIsFlying() { return this._player?.getIsFlying() ?? false; } + getPosition() { return this._player?.getPosition?.() ?? null; } + getFirstPersonPitchOffset() { return this._firstPersonPitchOffset; } + getPlayerModel() { return this._player?.getPlayerModel(); } + getColliderMeshes() { return this._player?.getColliderMeshes?.() ?? []; } + getCenterScreenRaycastHit() { return this._player?.getCenterScreenRaycastHit() ?? null; } + playAnimation(name, opts) { return this._player?.playAnimation(name, opts); } + registerAnimation(key, clipName, opts) { return this._player?.registerAnimation(key, clipName, opts); } + registerLocomotionSet(...a) { return this._player?.registerLocomotionSet(...a); } + switchLocomotionSet(name) { return this._player?.switchLocomotionSet(name); } + setMaxCamDistance(d) { return this._player?.setMaxCamDistance(d); } + setPlayerSpeed(s) { return this._player?.setPlayerSpeed(s); } + setEnableToward(v) { return this._player?.setEnableToward(v); } + setThirdMouseMode(mode) { return this._player?.setThirdMouseMode(mode); } + onAllEvent() { return this._player?.onAllEvent(); } + offAllEvent() { return this._player?.offAllEvent(); } + onViewChange(isFirstPerson) { return this._player?.onViewChange(isFirstPerson); } +} diff --git a/three-player-controller/example/shooting/player/spineIK.js b/three-player-controller/example/shooting/player/spineIK.js new file mode 100644 index 0000000..cf2c860 --- /dev/null +++ b/three-player-controller/example/shooting/player/spineIK.js @@ -0,0 +1,105 @@ +import { Euler, Quaternion, Vector3 } from "three"; + +// 复用的临时量(避免每帧 GC) +const _aimQ = new Quaternion(); +const _yawQ = new Quaternion(); +const _localPitchQ = new Quaternion(); +const _parentWorldQ = new Quaternion(); +const _cameraWorldQ = new Quaternion(); +const _aimAxis = new Vector3(1, 0, 0); +const _yawAxis = new Vector3(0, 1, 0); + +const _headWorldQ = new Quaternion(); +const _headParentWorldQ = new Quaternion(); +const _headEuler = new Euler(); + +// 脊椎骨骼 IK +export class SpineIK { + constructor(spineBones, headBone) { + // ==================== 骨骼引用 ==================== + this.spineBones = spineBones; // 脊椎骨骼数组(从下到上) + this.headBone = headBone; // 头部骨骼 + + // 每帧 IK 应用前保存的"干净动画姿态",下一帧先恢复再驱动 + this.baseQuats = spineBones.map(() => new Quaternion()); + } + + // ==================== 骨骼保存 / 恢复 ==================== + + // 将脊椎骨骼恢复到上一帧保存的干净动画姿态 + // 在 player.update()(动画驱动)之前调用,防止 IK 残留影响动画混合 + restoreBones() { + for (let i = 0; i < this.spineBones.length; i++) { + this.spineBones[i].quaternion.copy(this.baseQuats[i]); + } + } + + // 清除头骨骼的横滚(roll),防止第一人称下相机随动画倾斜 + clearHeadRoll() { + const head = this.headBone; + if (!head) return; + head.updateWorldMatrix(true, false); + head.getWorldQuaternion(_headWorldQ); + _headEuler.setFromQuaternion(_headWorldQ, "YXZ"); + _headEuler.z = 0; + _headWorldQ.setFromEuler(_headEuler); + head.parent.getWorldQuaternion(_headParentWorldQ); + head.quaternion.copy(_headParentWorldQ).invert().multiply(_headWorldQ); + head.updateWorldMatrix(false, false); + } + + // ==================== IK 应用 ==================== + + // 第一人称脊椎俯仰 IK + // 将 pitchTarget 均分到每根脊椎骨骼,使持枪手臂跟随鼠标上下瞄准 + applyAim1P(camera, pitchTarget) { + if (!this.spineBones.length) return; + this.clearHeadRoll(); + + camera.getWorldQuaternion(_cameraWorldQ); + _aimAxis.set(1, 0, 0).applyQuaternion(_cameraWorldQ); + _aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length); + + this.spineBones[0].parent.updateWorldMatrix(true, false); + for (let i = 0; i < this.spineBones.length; i++) { + this.baseQuats[i].copy(this.spineBones[i].quaternion); // 保存干净姿态 + // 转到父骨骼局部空间后叠加 + this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ); + _localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ); + this.spineBones[i].quaternion.premultiply(_localPitchQ); + this.spineBones[i].updateWorldMatrix(false, false); + } + // 传播到头骨骼,让相机挂点跟随 + this.spineBones[this.spineBones.length - 1].updateWorldMatrix(false, true); + } + + // 第三人称脊椎俯仰 + 偏航 IK,同时对 camera.rotation.x 做视觉补偿偏移 + applyAim3P(camera, isGunEngaged) { + if (!this.spineBones.length) return; + + // 归一化俯仰(-1 ~ +1),用于二次曲线补偿 + const normalizedPitch = camera.rotation.x / (Math.PI * (50 / 180)); + const pitchSq = Math.pow(Math.abs(normalizedPitch), 2.0); + + const pitchTarget = isGunEngaged ? camera.rotation.x : 0; + // 相机视角补偿:向下看时额外抬高,向上看时额外压低,使瞄准更自然 + camera.rotation.x += normalizedPitch > 0 ? pitchSq * 0.35 : -pitchSq * 0.1; + + // 人物朝向补偿:需要往屏幕中心方向旋转 + const yawTarget = isGunEngaged ? -Math.PI * ((10 * (1 + pitchSq * 0.35)) / 180) : 0; + + _aimAxis.set(1, 0, 0).applyQuaternion(camera.quaternion); + _aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length); + _yawQ.setFromAxisAngle(_yawAxis, yawTarget); + _aimQ.premultiply(_yawQ); + + this.spineBones[0].parent.updateWorldMatrix(true, false); + for (let i = 0; i < this.spineBones.length; i++) { + this.baseQuats[i].copy(this.spineBones[i].quaternion); + this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ); + _localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ); + this.spineBones[i].quaternion.premultiply(_localPitchQ); + this.spineBones[i].updateWorldMatrix(false, false); + } + } +} diff --git a/three-player-controller/example/shooting/shooting.html b/three-player-controller/example/shooting/shooting.html new file mode 100644 index 0000000..3558a7c --- /dev/null +++ b/three-player-controller/example/shooting/shooting.html @@ -0,0 +1,214 @@ + + + + + + three-player-controller · FPS Game + + + + + +
+
+ +
+ +
+ +
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ V + Toggle View +
+
+ 14 + Switch Weapon / Scroll +
+
+ RMB + Aim (Hold) +
+
+ LMB + Shoot +
+
+ R + Reload +
+
+
+ +
+ + + + + +
+
+
+ + + + + + + + +
+
+ AMMO + 30 + / + 300 +
+
+
+ +
+ Scene model: + Sketchfab +
+
+ + + + + diff --git a/three-player-controller/example/shooting/shooting.js b/three-player-controller/example/shooting/shooting.js new file mode 100644 index 0000000..00b76be --- /dev/null +++ b/three-player-controller/example/shooting/shooting.js @@ -0,0 +1,294 @@ +import * as THREE from "three"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; + +import { createSceneSetup } from "./core/SceneSetup.js"; +import { LocalPlayer } from "./player/LocalPlayer.js"; +import { DecalSystem } from "./weapon/DecalSystem.js"; +import { WeaponController, MODE } from "./weapon/WeaponController.js"; +import { ShootingEffects } from "./weapon/effects.js"; +import { HUD } from "./ui/HUD.js"; +import { ZombieManager } from "./zombie/ZombieManager.js"; + +const base = import.meta.env.BASE_URL; + +const initPos = new THREE.Vector3(33.21, 4.6, 2.6); + +const NORMAL_MAX_CAM = 220; +const MOUSE_SENSITIVITY = 5; +const PLAYER_MODEL_URL = base + "./glb/swat.glb"; +const FIRST_PERSON_PITCH_OFFSET_BY_MODEL = { + [base + "./glb/swat.glb"]: Math.PI * (16 / 180), +}; + +async function init() { + const container = document.querySelector("#container"); + + let tiles, localPlayer, weapon, zombieManager; + + // ==================== 1. 场景基础 ==================== + const { scene, renderer, camera, controls, bakeProbes } = createSceneSetup({ container }); + + // 添加音频监听器 + const listener = new THREE.AudioListener(); + camera.add(listener); + + camera.position.copy(initPos); + camera.lookAt(initPos.x, initPos.y, initPos.z + 1); + controls.target.set(initPos.x, initPos.y, initPos.z + 1); + + const stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + const timer = new THREE.Timer(); + let _prevGunEngaged = false; + renderer.setAnimationLoop(() => { + timer.update(); + const elapsed = timer.getElapsed(); + const dt = timer.getDelta(); + + if (localPlayer && weapon && zombieManager) { + const spineIK = localPlayer.spineIK; + const gunEngaged = weapon.isGunEngaged(); + + // 开火/瞄准时切 mouseMode 5(锁定朝向跟相机),其余情况用 1 + if (gunEngaged !== _prevGunEngaged) { + localPlayer.setThirdMouseMode(gunEngaged ? 5 : 1); + _prevGunEngaged = gunEngaged; + } + + // 恢复脊椎骨骼到上一帧的干净动画姿态 + if (gunEngaged) spineIK.restoreBones(); + + // 驱动动画与物理(传入 dt,使上半身 mixer 与主 mixer 同步) + localPlayer ? localPlayer.update(dt) : controls.update(); + + // 应用脊椎 IK 与朝向(在 player.update() 后、weapon.update() 前) + if (gunEngaged) { + if (localPlayer.getIsFirstPerson()) { + localPlayer.applyHipsCorrection(); + spineIK.applyAim1P(camera, localPlayer.pitchTarget1P); + } else { + // console.log('应用 3P IK'); + localPlayer.applyHipsCorrection(); + spineIK.applyAim3P(camera, true); + } + } + + // 更新武器状态(特效、射线命中、连射节拍)——骨骼此时已含 IK + weapon.update(elapsed, dt); + + // 丧尸 AI + zombieManager.update(dt, localPlayer.getPosition()); + + } else { + controls?.update(); + } + + renderer.render(scene, camera); + stats.update(); + }); + + + // ==================== 2. 资源加载器 ==================== + const gltfLoader = new GLTFLoader(); + + const draco = new DRACOLoader(); + draco.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/"); + gltfLoader.setDRACOLoader(draco); + + const ktx2 = new KTX2Loader(); + ktx2.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/"); + ktx2.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2); + + // ==================== 3. 场景模型 ==================== + const { scene: mapScene } = await gltfLoader.loadAsync( + base + "./glb/horror_corridor.glb" + ); + mapScene.name = "sceneGLB"; + mapScene.scale.set(0.09, 0.09, 0.09); + scene.add(mapScene); + scene.updateMatrixWorld(true); + bakeProbes(); + + document.addEventListener("contextmenu", (e) => e.preventDefault()); + + const playerGltf = await gltfLoader.loadAsync(PLAYER_MODEL_URL); + + // ==================== 4. 本地玩家 ==================== + localPlayer = new LocalPlayer({ scene, camera, controls }); + await localPlayer.init({ + playerModelConfig: { + model: playerGltf.scene, + animations: playerGltf.animations, + scale: 0.01, + idleAnim: "idle1", + walkAnim: "walk", + runAnim: "run", + jumpAnim: "jump", + flyAnim: "flying", + flyIdleAnim: "flyidle", + headBoneName: "mixamorigHead", + firstPersonPitchOffset: FIRST_PERSON_PITCH_OFFSET_BY_MODEL[PLAYER_MODEL_URL] ?? 0, + rotateY: - Math.PI / 2, + }, + initPos: initPos, + minCamDistance: 10, + maxCamDistance: NORMAL_MAX_CAM, + enableOverShoulderView: true, + mouseSensitivity: MOUSE_SENSITIVITY, + camLookAtHeightRatio: 0.9, + keyMap: { toggleFly: null }, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: mapScene } }, + ], + }); + + localPlayer.getPlayerModel()?.traverse((c) => { + if (c.isMesh) { + c.frustumCulled = false; + c.castShadow = true; + c.receiveShadow = true; + } + }); + + // ==================== 5. HUD ==================== + const weaponSlots = [ + { key: "1", mode: "primary", label: "Rifle" }, + { key: "4", mode: "normal", label: "Fists" }, + ]; + const hud = new HUD(weaponSlots); + hud.build(); + + // ==================== 6. 特效 ==================== + let effects = new ShootingEffects(scene, { + listener, + flashScale: 0.15, + smokeSize: 0.8 + }); + await effects.load( + base + "./img/muzzle_flash.png", + base + "./img/smoke.png", + base + "./audio/gun_shot.mp3", + base + "./audio/reload.mp3", + ); + + // ==================== 7. 弹痕系统 ==================== + const decalSystem = new DecalSystem(scene, 60, 0.25); + await decalSystem.loadMaterials(["img/bullet_hole.png"], base); + + // ==================== 8. 丧尸管理器 ==================== + zombieManager = new ZombieManager(scene, { + loader: gltfLoader, + colliders: localPlayer.getColliderMeshes(), + modelUrl: base + "./glb/zombie.glb", + scale: 0.01, + walkAnim: "walking", + idleAnim: "Idle", + punchAnim: "punching", + runAnim: "running", + rotateY: Math.PI, + speed: 120, + }); + + // ==================== 9. 武器控制器 ==================== + weapon = new WeaponController({ + scene, + camera, + localPlayer, + decalSystem, + effects, + hud, + zombieManager, + }); + + await weapon.load(gltfLoader, base); + weapon.setupAnimations(); + weapon.bindInput(); + weapon.switchMode(MODE.PRIMARY); + + // 让 LocalPlayer 的 1P 俯仰驱动能感知武器状态 + localPlayer.setGunEngagedGetter(() => weapon.isGunEngaged()); + + // 初始刷新武器槽高亮 + hud.update(weapon.getMode()); + + // ==================== 10. 鼠标拾取 ==================== + const raycaster = new THREE.Raycaster(); + const mouse = new THREE.Vector2(); + + const getMousePickingPoint = (event) => { + const rect = container.getBoundingClientRect(); + // 将鼠标位置归一化为设备坐标 (NDC) + mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + // 检测场景中所有的物体(包含 3D Tiles 和玩家模型) + const intersects = raycaster.intersectObjects(scene.children, true); + + if (intersects.length > 0) { + return intersects[0].point; + } + return null; + }; + + container.addEventListener("mousedown", (e) => { + // 只有当鼠标没有被锁定(非第一人称控制状态)时才执行点击拾取 + if (document.pointerLockElement !== renderer.domElement) { + const point = getMousePickingPoint(e); + if (point) { + console.log("鼠标点击的 3D 坐标:", point); + // 你可以在这里添加逻辑,比如在点击位置生成丧尸: + // zombieManager.spawnZombie(point); + } + } + }); + + // ==================== 丧尸波次 ==================== + await Promise.all([ + zombieManager.startWave({ origin: new THREE.Vector3(2.012, 4.6, -0.78), count: 5, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(-3.731, 4.6, -1.824), count: 2, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(11.911, 4.6, -4.010), count: 2, radius: 1 }), + zombieManager.startWave({ origin: new THREE.Vector3(13.567, 4.6, 7.001), count: 2, radius: 1 }), + ]); + + window.hideLoader?.(); + + // ==================== Start Screen ==================== + await new Promise((resolve) => { + const screen = document.getElementById("start-screen"); + screen.addEventListener("mousedown", (e) => e.stopPropagation()); + screen.addEventListener("mouseup", (e) => e.stopPropagation()); + screen.addEventListener("click", (e) => e.stopPropagation()); + screen.style.display = "flex"; + document.getElementById("start-btn").addEventListener("click", () => { + screen.remove(); + document.body.style.cursor = "none"; + document.body.requestPointerLock?.(); + resolve(); + }, { once: true }); + }); + + // ==================== 窗口缩放 ==================== + window.addEventListener("resize", () => { + const cont = document.querySelector("#container"); + if (!cont) return; + camera.aspect = cont.clientWidth / cont.clientHeight; + renderer.setSize(cont.clientWidth, cont.clientHeight); + camera.updateProjectionMatrix(); + }); +} + +init(); diff --git a/three-player-controller/example/shooting/ui/HUD.js b/three-player-controller/example/shooting/ui/HUD.js new file mode 100644 index 0000000..e649630 --- /dev/null +++ b/three-player-controller/example/shooting/ui/HUD.js @@ -0,0 +1,65 @@ +// HUD:准星 + 武器选择栏 + 弹药显示 +// 不依赖任何 Three.js 对象,只操作 DOM +export class HUD { + constructor(weaponSlots) { + this._slots = weaponSlots; // [{ key, mode, label }] + + this._crosshair = document.getElementById("crosshair"); + this._weaponHud = document.getElementById("weapon-hud"); + this._ammoHud = document.getElementById("ammo-hud"); + this._ammoCurrent = document.getElementById("ammo-current-val"); + this._ammoTotal = document.getElementById("ammo-total-val"); + this._hitTimer = null; + } + + // ==================== 初始化 ==================== + + build() { + this._weaponHud.innerHTML = ""; + for (let i = this._slots.length - 1; i >= 0; i--) { + const slot = this._slots[i]; + const el = document.createElement("div"); + el.className = "weapon-slot"; + el.dataset.mode = slot.mode; + el.innerHTML = `${slot.key}${slot.label}`; + this._weaponHud.appendChild(el); + } + } + + // ==================== 武器槽 ==================== + + update(currentMode) { + this._weaponHud.querySelectorAll(".weapon-slot").forEach((el) => { + el.classList.toggle("active", el.dataset.mode === currentMode); + }); + } + + // ==================== 弹药 ==================== + + updateAmmo(current, max) { + if (this._ammoCurrent) this._ammoCurrent.textContent = current; + if (this._ammoTotal) this._ammoTotal.textContent = max; + } + + showAmmo() { + if (this._ammoHud) this._ammoHud.style.display = "flex"; + } + + hideAmmo() { + if (this._ammoHud) this._ammoHud.style.display = "none"; + } + + // ==================== 准星 ==================== + + showCrosshair() { this._crosshair.style.display = "block"; } + hideCrosshair() { this._crosshair.style.display = "none"; } + + flashHit() { + if (!this._crosshair) return; + if (this._hitTimer) clearTimeout(this._hitTimer); + this._crosshair.style.backgroundColor = "red"; + this._hitTimer = setTimeout(() => { + this._crosshair.style.backgroundColor = ""; + }, 100); + } +} diff --git a/three-player-controller/example/shooting/weapon/DecalSystem.js b/three-player-controller/example/shooting/weapon/DecalSystem.js new file mode 100644 index 0000000..05ad902 --- /dev/null +++ b/three-player-controller/example/shooting/weapon/DecalSystem.js @@ -0,0 +1,113 @@ +import { + Matrix3, + Mesh, + MeshBasicMaterial, + Object3D, + PlaneGeometry, + TextureLoader, +} from "three"; + +const _normalMatrix = new Matrix3(); // 法线矩阵 +const _decalHelper = new Object3D(); // 弹孔朝向辅助体 + +// 弹孔贴花系统(对象池) +export class DecalSystem { + constructor(scene, maxDecals = 60, decalSize = 0.02) { + // ==================== 场景引用 ==================== + this._scene = scene; + + // ==================== 对象池配置 ==================== + this._maxDecals = maxDecals; // 最大弹孔数量,超出后移除最旧的 + this._pool = []; // 场景中现存的弹孔 Mesh + this._mats = []; // 可用的弹痕材质(多张随机选取) + this._geo = new PlaneGeometry(decalSize, decalSize); // 弹孔平面几何体 + this.onSpawn = null; // (hitPoint, hitNormal) => void,多人模式注入广播回调 + } + + // ==================== 初始化 ==================== + + // 批量加载弹痕贴图,生成对应材质 + async loadMaterials(files, baseUrl) { + const loader = new TextureLoader(); + await Promise.all( + files.map(async (f) => { + const tex = await loader.loadAsync(baseUrl + f); + this._mats.push( + new MeshBasicMaterial({ + map: tex, + transparent: true, + depthTest: true, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -4, // 防止与墙面 Z-fighting + }) + ); + }) + ); + } + + // ==================== 生成 / 清除 ==================== + + // 在射线命中点生成弹孔,并触发命中硝烟特效 + spawn(hit, effects) { + if (!hit?.face || !hit?.object) return; + if (!this._mats.length) return; + + // 计算世界法线 + _normalMatrix.getNormalMatrix(hit.object.matrixWorld); + const hitNormal = hit.face.normal.clone().applyMatrix3(_normalMatrix).normalize(); + const hitPoint = hit.point.clone(); + + // 计算弹孔朝向(面向法线 + 随机旋转) + _decalHelper.position.copy(hitPoint); + _decalHelper.lookAt(hitPoint.clone().add(hitNormal)); + _decalHelper.rotation.z = Math.random() * Math.PI * 2; + + // 创建弹孔 Mesh,沿法线微偏移以避免 Z-fighting + const mat = this._mats[Math.floor(Math.random() * this._mats.length)]; + const decal = new Mesh(this._geo, mat); + decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001); + decal.rotation.copy(_decalHelper.rotation); + decal.renderOrder = 1; + this._scene.add(decal); + + // 超出上限时移除最旧弹孔 + this._pool.push(decal); + if (this._pool.length > this._maxDecals) { + this._scene.remove(this._pool.shift()); + } + + // 命中硝烟特效 + effects?.triggerHitSmoke(hitPoint, hitNormal); + + // 广播给其他客户端(多人模式注入) + this.onSpawn?.(hitPoint, hitNormal); + } + + // 根据世界坐标和法线直接生成弹孔(接收远程弹痕时使用,不触发硝烟) + spawnAtPoint(hitPoint, hitNormal) { + if (!this._mats.length) return; + + _decalHelper.position.copy(hitPoint); + _decalHelper.lookAt(hitPoint.clone().add(hitNormal)); + _decalHelper.rotation.z = Math.random() * Math.PI * 2; + + const mat = this._mats[Math.floor(Math.random() * this._mats.length)]; + const decal = new Mesh(this._geo, mat); + decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001); + decal.rotation.copy(_decalHelper.rotation); + decal.renderOrder = 1; + this._scene.add(decal); + + this._pool.push(decal); + if (this._pool.length > this._maxDecals) { + this._scene.remove(this._pool.shift()); + } + } + + // 清空所有弹孔 + clear() { + for (const d of this._pool) this._scene.remove(d); + this._pool.length = 0; + } +} diff --git a/three-player-controller/example/shooting/weapon/WeaponController.js b/three-player-controller/example/shooting/weapon/WeaponController.js new file mode 100644 index 0000000..9a53cca --- /dev/null +++ b/three-player-controller/example/shooting/weapon/WeaponController.js @@ -0,0 +1,590 @@ +import { AnimationMixer, LoopOnce, Object3D, Quaternion, Vector3 } from "three"; + +export const MODE = Object.freeze({ NORMAL: "normal", PRIMARY: "primary" }); + +const _muzzleWorldPos = new Vector3(); // 枪口世界坐标 + +export class WeaponController { + constructor({ scene, camera, localPlayer, decalSystem, effects, hud, zombieManager }) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._camera = camera; + this._player = localPlayer; + this._decalSystem = decalSystem; + this._effects = effects; + this._hud = hud; + this._zombieManager = zombieManager; + + // ==================== 武器配置 ==================== + this._gunScale = 0.1; // 枪模型缩放 + this._gunPos = [1, 26.5, 2]; // 枪挂载到右手骨骼的位置偏移 + this._gunBarrelDir = new Vector3(0, 0, -1); // 枪管沿模型局部 -Z + this._gunTargetDir = new Vector3(0, 1, 0); // 手骨 +Y = 手指方向 + this._gunRoll = Math.PI / 2; + this._gunMuzzleOffset = [0, 80, -480]; // 枪口偏移 [左, 上, 前] + + // ==================== 武器模型 ==================== + this._weaponModel = null; // 枪模型根节点 + this._muzzlePoint = null; // 枪口标记点(供特效定位) + this._weaponMixer = null; // 武器动画混合器 + this._weaponReloadAction = null; // 换弹动画 + this._weaponShootAction = null; // 射击动画 + this._shotSound = null; // 射击音效 + this._shakeIntensity = 0; // 当前镜头抖动强度 + this._shakeDecay = 10; // 抖动衰减系数 + + // ==================== 模式 & 武器槽 ==================== + this._currentMode = MODE.NORMAL; + this._weaponSlots = [ + { key: "1", mode: MODE.PRIMARY, label: "Rifle" }, + { key: "4", mode: MODE.NORMAL, label: "Fists" }, + ]; + + // ==================== 相机距离限制 ==================== + this._normalMaxCam = 220; // 徒手模式最远距离 + this._armedMaxCam = 100; // 瞄准模式最远距离 + + // ==================== 移速配置 ==================== + this._baseSpeed = 300; + this._armedSpeed = 240; // 持枪移速 = baseSpeed * 0.8 + + // ==================== 开火状态机 ==================== + this._isAiming = false; // 是否瞄准中(RMB 触发,含视角缩放) + this._isSoftAiming = false; // 是否软瞄准中(单击开火触发,不缩放视角) + this._isFiring = false; // 是否连射中 + this._isTriggerDown = false; // 鼠标左键是否按住 + this._isReloading = false; // 是否换弹中 + this._magSize = 30; // 弹夹容量 + this._currentAmmo = 30; // 当前子弹数 + this._totalAmmo = 300; // 总备用弹药量 + this._firstShotTimer = null; // 180ms 后必出一枪的计时器 + this._holdAimTimer = null; // 停火后 2s 才放枪的计时器 + this._lastFireTime = 0; + this._elapsed = 0; // 由 update() 每帧更新,供 setTimeout 回调读取 + + this._reloadTimer1 = null; // 换弹半程计时器 + this._reloadTimer2 = null; // 换弹完成计时器 + this._RELOAD_DURATION_MS = 2000; // 换弹动画时长,需根据实际模型动画调整 + this._FIRE_RATE_S = 0.1; // 连射间隔(秒) + this._FIRE_ANIM_FADE_MS = 180; // 与 playAnimation 默认 fade(0.18s) 对齐 + this._HOLD_AIM_DURATION = 2000; // 停火后保持瞄准状态时长(ms) + + // ==================== 射线命中缓存 ==================== + this._frameHit = null; // 每帧射线命中结果 + + // ==================== 多人扩展 ==================== + this.onHitPlayer = null; // (playerId, damage) => void,由外部注入 + } + + // ==================== 初始化 ==================== + + // 加载武器模型并挂载到右手骨骼 + async load(gltfLoader, baseUrl) { + const gltf = await gltfLoader.loadAsync(baseUrl + "./glb/ak47.glb"); + this._weaponModel = gltf.scene; + + const person = this._player.getPlayerModel(); + const rightHand = person?.getObjectByName("mixamorigRightHand"); + if (!rightHand) { + console.warn("[WeaponController] 未找到右手骨骼 mixamorigRightHand"); + return; + } + + this._weaponModel.scale.setScalar(this._gunScale); + this._weaponModel.position.set(...this._gunPos); + + this._magazineBone = null; + this._weaponModel.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; + } + }); + + // 找到备用弹夹骨骼,初始缩为 0 隐藏 + this._magazineBone = this._weaponModel.getObjectByName("Bone002_01"); + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + + // 对齐枪管方向并附加滚转 + const alignQ = new Quaternion().setFromUnitVectors(this._gunBarrelDir, this._gunTargetDir); + const rollQ = new Quaternion().setFromAxisAngle(this._gunTargetDir, this._gunRoll); + this._weaponModel.quaternion.copy(rollQ.multiply(alignQ)); + + this._weaponModel.visible = false; + rightHand.add(this._weaponModel); + + // 枪口标记点(供特效定位) + this._muzzlePoint = new Object3D(); + this._muzzlePoint.position.set(...this._gunMuzzleOffset); + this._weaponModel.add(this._muzzlePoint); + + // 初始化武器动画 + this._weaponMixer = new AnimationMixer(this._weaponModel); + const reloadClip = gltf.animations.find(a => a.name === "Armature.003|reload"); + if (reloadClip) { + this._weaponReloadAction = this._weaponMixer.clipAction(reloadClip); + this._weaponReloadAction.setLoop(LoopOnce); + // 关键修改:取消锁定在最后一帧。 + // 这样动画结束后,骨骼会自动通过 Mixer 的权重回归到 Bind Pose(初始插在枪上的位置)。 + this._weaponReloadAction.clampWhenFinished = false; + this._weaponReloadAction.timeScale = 1.3; + } + + } + + // 注册所有射击相关动画集 + setupAnimations() { + this._player.registerLocomotionSet("primary", { + idle: "rifle_idle", + walking: "rifle_walk", + running: "rifle_run", + jumping: "rifle_jump", + }); + this._player.registerLocomotionSet("primary_aim", { + idle: "rifle_idle_aim3", + walking: "rifle_walk", + running: "rifle_run", + jumping: "rifle_jump", + }); + + // 上半身专用动画:仅覆写脊椎以上骨骼,下半身继续播放移动动画 + this._player.registerUpperAnimation("upper_shoot", "rifle_shoot3", { loop: true, timeScale: 0.5 }); + this._player.registerUpperAnimation("upper_reload", "reload", { loop: false, timeScale: 1.5 }); + this._player.registerUpperAnimation("upper_aim", "rifle_idle_aim3", { loop: true, timeScale: 0.5 }); + + this._player.initIdleHipsQ("rifle_idle_aim3"); + } + + // 绑定键鼠输入 + bindInput() { + document.addEventListener("mousedown", (e) => { + if (!this._canRunCombatLogic()) return; + if (e.button === 2 && !this._player.getIsFirstPerson()) { + this.enterAim(); + } + if (e.button === 0) { + if (!this._firstShotTimer && !this._isFiring) this._triggerShootAnim(); + this._startFiring(); + } + }); + + document.addEventListener("mouseup", (e) => { + if (e.button === 2 && !this._player.getIsFirstPerson()) this.exitAim(); + if (e.button === 0) this._stopFiring(); + }); + + document.addEventListener("keydown", (e) => { + const k = e.key.toLowerCase(); + if (k === "1") this.switchMode(MODE.PRIMARY); + if (k === "4") this.switchMode(MODE.NORMAL); + if (k === "q") this.switchMode(this._currentMode === MODE.PRIMARY ? MODE.NORMAL : MODE.PRIMARY); + if (k === "r") this.reload(); + }); + + document.addEventListener("wheel", (e) => { + if (this._isReloading) return; + const idx = this._weaponSlots.findIndex((s) => s.mode === this._currentMode); + const next = (idx + (e.deltaY > 0 ? 1 : -1) + this._weaponSlots.length) % this._weaponSlots.length; + this.switchMode(this._weaponSlots[next].mode); + }, { passive: true }); + } + + // ==================== 主循环 ==================== + + // 每帧由 shooting.js 主循环调用 + update(elapsed, dt) { + this._elapsed = elapsed; + const canRunCombatLogic = this._canRunCombatLogic(); + + // 飞行时强制取消换弹 + if (this._currentMode === MODE.PRIMARY && this._isReloading && this._player.getIsFlying()) { + this._cancelReload(); + } + // 飞行/跳跃等不可战斗状态时强制中断武器逻辑(换弹中不打断,由换弹自己管) + if (this._currentMode === MODE.PRIMARY && !canRunCombatLogic && !this._isReloading) { + this._forceStopCombatLogic(); + } + + // 第一人称:静止时自动进入瞄准,移动时退出 + if (this._currentMode === MODE.PRIMARY && this._player.getIsFirstPerson()) { + if (canRunCombatLogic && !this._isAiming) this.enterAim(); + else if (!canRunCombatLogic && this._isAiming) this.exitAim(); + } + + // 更新粒子特效 + this._effects?.update(dt); + + // 更新武器动画混合器 + if (this._weaponMixer) this._weaponMixer.update(dt); + + // 非换弹时每帧强制隐藏备用弹夹骨骼(mixer 更新后覆写) + if (this._magazineBone && !this._isReloading) this._magazineBone.scale.setScalar(0.0001); + + // 镜头抖动(指数衰减) + if (this._shakeIntensity > 0.0001) { + this._camera.rotation.x += (Math.random() - 0.5) * this._shakeIntensity; + this._camera.rotation.y += (Math.random() - 0.5) * this._shakeIntensity * 0.4; + this._shakeIntensity *= Math.exp(-this._shakeDecay * dt); + } else { + this._shakeIntensity = 0; + } + + // 更新射线命中缓存 + this._frameHit = this._player.getCenterScreenRaycastHit(); + + // 连射节拍 + if (canRunCombatLogic && this._isFiring && elapsed - this._lastFireTime >= this._FIRE_RATE_S) { + if (this._currentAmmo <= 0) { + this._stopFiring(); + // 只有总弹药大于0时才触发自动换弹 + if (this._totalAmmo > 0) this.reload(); + } else { + this._lastFireTime = elapsed; + this._fireOnce(); + } + } + } + + // ==================== 状态查询 ==================== + + // 是否处于任意武器激活状态(等待首发 / 连射 / hold-aim 冷却 / 瞄准) + // SpineIK 和主循环通过此方法判断是否需要驱动 IK + isGunEngaged() { + return ( + this._isAiming || + this._isSoftAiming || + this._isFiring || + this._isReloading || + this._firstShotTimer !== null || + this._holdAimTimer !== null + ); + } + + getMode() { return this._currentMode; } + + resetAmmo() { + this._currentAmmo = this._magSize; + this._totalAmmo = 300; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + } + + _canRunCombatLogic() { + if (this._currentMode !== MODE.PRIMARY) return false; + if (this._isReloading) return false; + if (this._player.getIsFlying()) return false; + if (typeof this._player.isCombatLocomotionAllowed === "function") { + return this._player.isCombatLocomotionAllowed(); + } + return true; + } + + _forceStopCombatLogic() { + this._isTriggerDown = false; + if (this._firstShotTimer) { + clearTimeout(this._firstShotTimer); + this._firstShotTimer = null; + } + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + this._player.stopUpperBody(0.18); + this._cancelHoldAimTimer(); + if (this._isAiming) this.exitAim(); + else { + this._isSoftAiming = false; + this._hud.hideCrosshair(); + } + } + + // ==================== 模式切换 & 瞄准 ==================== + + switchMode(newMode) { + if (this._currentMode === newMode || this._isReloading) return; + + // 退出旧模式 + if (this._currentMode === MODE.PRIMARY) { + this.exitAim(); + this._isSoftAiming = false; + this._isTriggerDown = false; + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + this._player.stopUpperBody(0.18); + if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; } + this._cancelHoldAimTimer(); + this._hud.hideCrosshair(); + this._hud.hideAmmo?.(); + this._player.switchLocomotionSet("default"); + this._player.setMaxCamDistance(this._normalMaxCam); + this._player.setPlayerSpeed(this._baseSpeed); + } + + this._currentMode = newMode; + + // 进入新模式 + if (this._currentMode === MODE.PRIMARY) { + this._player.switchLocomotionSet("primary"); + this._player.setPlayerSpeed(this._armedSpeed); + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + this._hud.showAmmo?.(); + } + + if (this._weaponModel) this._weaponModel.visible = (this._currentMode === MODE.PRIMARY); + } + + // 进入瞄准模式;soft=true 时为软瞄准,跳过视角缩放 + enterAim(soft = false) { + if (soft) { + if (this._isSoftAiming) return; + this._isSoftAiming = true; + } else { + if (!this._canRunCombatLogic() || this._isAiming) return; + this._isAiming = true; + this._isSoftAiming = false; + this._player.setMaxCamDistance(this._armedMaxCam); + } + this._player.switchLocomotionSet("primary_aim"); + this._hud.showCrosshair(); + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + if (this._player.getIsFirstPerson()) { + this._camera.rotation.x = this._player.getFirstPersonPitchOffset(); + } + } + + // 退出瞄准模式;soft=true 时为软瞄准,跳过视角还原 + exitAim(soft = false) { + if (soft) { + if (!this._isSoftAiming) return; + this._isSoftAiming = false; + } else { + if (!this._isAiming) return; + this._isAiming = false; + this._player.setMaxCamDistance(this._normalMaxCam); + this._cancelHoldAimTimer(); + } + this._player.switchLocomotionSet( + this._currentMode === MODE.PRIMARY ? "primary" : "default" + ); + this._player.stopUpperBody(0.18); + this._hud.hideCrosshair(); + if (this._player.getIsFirstPerson()) { + this._camera.rotation.x = this._player.pitchTarget1P; + } + } + + // 换弹 + reload() { + if (this._currentMode !== MODE.PRIMARY || this._isReloading || this._totalAmmo <= 0 || this._currentAmmo === this._magSize) return; + if (this._player.getIsFlying()) return; + + this._isReloading = true; + + // 1. 中断当前所有攻击状态 + this._isTriggerDown = false; + this._isFiring = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; } + this._cancelHoldAimTimer(); + this.exitAim(); // 换弹时强制退出瞄准 + + // 2. 播放换弹动画(仅上半身,下半身继续播放移动动画) + this._player.playUpperBody("upper_reload", { force: true, fade: 0.18 }); + + // 换弹音效 + this._effects?.triggerReloadSound(); + + // 显示备用弹夹骨骼 + if (this._magazineBone) this._magazineBone.scale.setScalar(1); + + // 播放武器自身的换弹动画 + if (this._weaponReloadAction) { + this._weaponReloadAction.reset(); + this._weaponReloadAction.play(); + } + + // 动画播放到一半时停止,同时隐藏备用弹夹骨骼(避免 snap-back 到悬空位置时可见) + this._reloadTimer1 = setTimeout(() => { + this._reloadTimer1 = null; + if (this._isReloading && this._weaponReloadAction) { + this._weaponReloadAction.stop(); + } + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + }, this._RELOAD_DURATION_MS / 2); + + // 3. 锁定状态直到动画结束 + this._reloadTimer2 = setTimeout(() => { + this._reloadTimer2 = null; + // 此时 _isReloading 变回 false,允许开火 + this._isReloading = false; + + // 停止上半身换弹动画,让全身 locomotion 动画重新完整接管上半身 + this._player.stopUpperBody(0.18); + + // 确保动画彻底停止(以防上面的一半时间停止没触发成功) + if (this._weaponReloadAction) this._weaponReloadAction.stop(); + + // 计算需要补充的子弹数量 + const need = this._magSize - this._currentAmmo; + const transfer = Math.min(need, this._totalAmmo); + this._currentAmmo += transfer; + this._totalAmmo -= transfer; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + + // 换弹结束后,衔接回持枪状态 + if (this._currentMode === MODE.PRIMARY) { + this._player.switchLocomotionSet("primary_aim"); + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + }, this._RELOAD_DURATION_MS); + } + + // ==================== 开火状态机 ==================== + + _startFiring() { + if (!this._canRunCombatLogic()) return; + if (this._firstShotTimer || this._isFiring) return; + + // 没子弹时按左键,直接触发换弹 + if (this._currentAmmo <= 0) { + if (this._totalAmmo > 0) this.reload(); + return; + } + + // 切换到软瞄准状态 + if (!this._isAiming && !this._isSoftAiming) { + this.enterAim(true); + } + + this._cancelHoldAimTimer(); + this._isTriggerDown = true; + + // 无论鼠标是否松开,180ms 后必定打出第一枪 + this._firstShotTimer = setTimeout(() => { + this._firstShotTimer = null; + if (!this._canRunCombatLogic()) { + this._forceStopCombatLogic(); + return; + } + this._fireOnce(); + this._lastFireTime = this._elapsed; + + if (this._isTriggerDown) { + this._isFiring = true; // 持续按住 → 连射 + } else { + // 单击松开:打出第一枪后处理后续状态 + if (this._weaponShootAction) this._weaponShootAction.stop(); + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + }, this._FIRE_ANIM_FADE_MS); + } + + _stopFiring() { + this._isTriggerDown = false; + if (this._weaponShootAction) this._weaponShootAction.stop(); + + if (!this._isFiring) { + // 换弹中或 hold-aim 缓冲期内松开鼠标,不打断上半身动画 + if (this._isReloading || this._holdAimTimer) return; + + // 单发(鼠标在 180ms 定时器触发前就松开):恢复或停止上半身 + if (this._isAiming || this._isSoftAiming) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + } else { + this._player.stopUpperBody(0.18); + } + return; + } + + this._isFiring = false; + if (this._isAiming || this._isSoftAiming || !this._player.isMoving) { + this._player.playUpperBody("upper_aim", { fade: 0.18 }); + this._scheduleHoldAim(); + } else { + this._player.stopUpperBody(0.18); + } + } + + _fireOnce() { + if (!this._canRunCombatLogic()) return; + + // 播放音效 + if (this._shotSound && this._shotSound.buffer) { + if (this._shotSound.isPlaying) this._shotSound.stop(); + this._shotSound.play(); + } + + // 第三人称触发镜头抖动 + if (!this._player.getIsFirstPerson()) { + this._shakeIntensity = 0.01; // 弧度单位,0.02 约为 1.1 度 + } + + this._currentAmmo--; + this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo); + + // 枪口火焰 + 音效:无论命中什么都必须先触发 + if (this._effects && this._muzzlePoint) { + this._muzzlePoint.updateWorldMatrix(true, false); + this._muzzlePoint.getWorldPosition(_muzzleWorldPos); + this._effects.triggerMuzzleFlash(_muzzleWorldPos, this._camera); + } + + // 多人模式:检测是否命中远程玩家(dmgMult 由命中部位决定:头×2,躯干×1,四肢×0.75) + const hitPlayerId = this._frameHit?.object?.userData?.playerId; + if (hitPlayerId && this.onHitPlayer) { + const dmgMult = this._frameHit.object.userData.dmgMult ?? 1.0; + this.onHitPlayer(hitPlayerId, Math.round(30 * dmgMult)); + this._hud.flashHit(); + return; + } + + const zombieId = this._frameHit?.object?.userData?.zombieId; + if (zombieId && this._zombieManager) { + this._zombieManager.onHit(zombieId, 30); + this._hud.flashHit(); + } else { + this._decalSystem.spawn(this._frameHit, this._effects); + } + } + + _triggerShootAnim() { + if (!this._canRunCombatLogic()) return; + // 上半身播放开火动画,下半身继续播放当前移动动画 + this._player.playUpperBody("upper_shoot", { force: true, fade: 0.18 }); + if (this._weaponShootAction) this._weaponShootAction.play(); + } + + // 停火后延迟 2s 自动退出瞄准姿态 + _scheduleHoldAim() { + this._cancelHoldAimTimer(); + this._holdAimTimer = setTimeout(() => { + this._holdAimTimer = null; + if (!this._isAiming) { + this.exitAim(true); + } + }, this._HOLD_AIM_DURATION); + } + + _cancelReload() { + if (!this._isReloading) return; + this._isReloading = false; + if (this._reloadTimer1) { clearTimeout(this._reloadTimer1); this._reloadTimer1 = null; } + if (this._reloadTimer2) { clearTimeout(this._reloadTimer2); this._reloadTimer2 = null; } + if (this._weaponReloadAction) this._weaponReloadAction.stop(); + if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001); + this._effects?.stopReloadSound(); + this._player.stopUpperBody(0.18); + this._hud.hideCrosshair(); + } + + _cancelHoldAimTimer() { + if (this._holdAimTimer) { clearTimeout(this._holdAimTimer); this._holdAimTimer = null; } + } +} diff --git a/three-player-controller/example/shooting/weapon/effects.js b/three-player-controller/example/shooting/weapon/effects.js new file mode 100644 index 0000000..66de6f7 --- /dev/null +++ b/three-player-controller/example/shooting/weapon/effects.js @@ -0,0 +1,229 @@ +import { + AdditiveBlending, + Audio, + AudioLoader, + BufferAttribute, + BufferGeometry, + Mesh, + MeshBasicMaterial, + NormalBlending, + PlaneGeometry, + Points, + PointsMaterial, + Quaternion, + TextureLoader, + Vector3, +} from "three"; + +// 复用的临时量(避免每帧 GC) +const _rollQ = new Quaternion(); +const _rollAxis = new Vector3(0, 0, 1); +const _camWorldQ = new Quaternion(); + +const flashDuration = 0.06; // 枪口火焰持续时间(秒) +const smokeCount = 14; // 每次命中的粒子数 +const smokeLifetime = 0.7; // 硝烟存活时间(秒) +const smokePoolSize = 8; // 硝烟对象池大小 + +// ==================== 枪口火焰 ==================== + +class MuzzleFlash { + constructor(scene, texture, scale = 0.01) { + this._mesh = new Mesh( + new PlaneGeometry(1, 1), + new MeshBasicMaterial({ + map: texture, + transparent: true, + blending: AdditiveBlending, + depthWrite: false, + opacity: 0, + }) + ); + this._mesh.visible = false; + this._timer = 0; + this._scale = scale; + this._cam = null; + scene.add(this._mesh); + } + + // 在枪口位置触发火焰 + trigger(worldPos, camera) { + this._mesh.position.copy(worldPos); + this._mesh.scale.setScalar(this._scale); + camera.getWorldQuaternion(_camWorldQ); + this._mesh.quaternion.copy(_camWorldQ); + _rollQ.setFromAxisAngle(_rollAxis, Math.random() * Math.PI * 2); // 随机滚转,避免重复感 + this._mesh.quaternion.multiply(_rollQ); + this._mesh.material.opacity = 1; + this._mesh.visible = true; + this._timer = flashDuration; + this._cam = camera; + } + + update(dt) { + if (!this._mesh.visible) return; + this._timer -= dt; + if (this._timer <= 0) { + this._mesh.visible = false; + return; + } + // billboard:每帧对齐相机朝向 + if (this._cam) { this._cam.getWorldQuaternion(_camWorldQ); this._mesh.quaternion.copy(_camWorldQ); } + this._mesh.material.opacity = this._timer / flashDuration; // 线性淡出 + } +} + +// ==================== 命中硝烟 ==================== + +class SmokeEffect { + constructor(scene, texture, size = 0.06) { + this._active = false; + this._timer = 0; + this._pos = new Float32Array(smokeCount * 3); // 粒子位置缓冲 + this._vels = Array.from({ length: smokeCount }, () => new Vector3()); // 粒子速度 + + const geo = new BufferGeometry(); + geo.setAttribute("position", new BufferAttribute(this._pos, 3)); + + this._points = new Points(geo, new PointsMaterial({ + map: texture, + size: size, + sizeAttenuation: true, + transparent: true, + opacity: 0, + depthWrite: false, + blending: NormalBlending, + })); + this._points.visible = false; + scene.add(this._points); + } + + // 在命中点触发硝烟,沿法线方向散射 + trigger(worldPos, worldNormal) { + this._active = true; + this._timer = smokeLifetime; + this._points.visible = true; + + for (let i = 0; i < smokeCount; i++) { + const i3 = i * 3; + this._pos[i3] = worldPos.x + (Math.random() - 0.5) * 0.02; + this._pos[i3 + 1] = worldPos.y + (Math.random() - 0.5) * 0.02; + this._pos[i3 + 2] = worldPos.z + (Math.random() - 0.5) * 0.02; + this._vels[i] + .set( + (Math.random() - 0.5) * 0.5, + 0.15 + Math.random() * 0.25, + (Math.random() - 0.5) * 0.5 + ) + .addScaledVector(worldNormal, 0.3 + Math.random() * 0.4); + } + this._points.geometry.attributes.position.needsUpdate = true; + } + + update(dt) { + if (!this._active) return; + this._timer -= dt; + if (this._timer <= 0) { + this._active = false; + this._points.visible = false; + return; + } + this._points.material.opacity = (this._timer / smokeLifetime) * 0.7; + for (let i = 0; i < smokeCount; i++) { + const i3 = i * 3; + this._pos[i3] += this._vels[i].x * dt; + this._pos[i3 + 1] += this._vels[i].y * dt; + this._pos[i3 + 2] += this._vels[i].z * dt; + this._vels[i].multiplyScalar(0.92); // 阻力衰减 + } + this._points.geometry.attributes.position.needsUpdate = true; + } +} + +// ==================== 对外接口 ==================== + +export class ShootingEffects { + constructor(scene, { listener = null, flashScale = 0.01, smokeSize = 0.06 } = {}) { + // ==================== 场景引用 ==================== + this._scene = scene; + this._listener = listener; // THREE.AudioListener + + // ==================== 特效配置 ==================== + this._flashScale = flashScale; // 枪口火焰缩放 + this._smokeSize = smokeSize; // 硝烟粒子大小 + + // ==================== 特效对象 ==================== + this._flash = null; // MuzzleFlash 实例 + this._smokePool = []; // SmokeEffect 对象池 + this._smokeIdx = 0; // 轮转索引 + this._fireSound = null; // 开火音效 + this._reloadSound = null; // 换弹音效 + } + + // 加载贴图与音效,创建特效实例 + async load(flashTexPath, smokeTexPath, fireSoundPath = null, reloadSoundPath = null) { + const loader = new TextureLoader(); + const audioLoader = new AudioLoader(); + + const [flashTex, smokeTex, fireBuffer, reloadBuffer] = await Promise.all([ + loader.loadAsync(flashTexPath), + loader.loadAsync(smokeTexPath), + fireSoundPath ? audioLoader.loadAsync(fireSoundPath).catch(() => null) : Promise.resolve(null), + reloadSoundPath ? audioLoader.loadAsync(reloadSoundPath).catch(() => null) : Promise.resolve(null), + ]); + + this._flash = new MuzzleFlash(this._scene, flashTex, this._flashScale); + + if (fireBuffer && this._listener) { + this._fireSound = new Audio(this._listener); + this._fireSound.setBuffer(fireBuffer); + this._fireSound.setVolume(0.4); + } + + if (reloadBuffer && this._listener) { + this._reloadSound = new Audio(this._listener); + this._reloadSound.setBuffer(reloadBuffer); + this._reloadSound.setVolume(0.6); + } + + // 预创建硝烟对象池 + for (let i = 0; i < smokePoolSize; i++) { + this._smokePool.push(new SmokeEffect(this._scene, smokeTex, this._smokeSize)); + } + } + + // ==================== 触发接口 ==================== + + // 枪口火焰 + 开火音效 + triggerMuzzleFlash(worldPos, camera) { + this._flash?.trigger(worldPos, camera); + if (this._fireSound) { + if (this._fireSound.isPlaying) this._fireSound.stop(); + this._fireSound.play(); + } + } + + // 换弹音效 + triggerReloadSound() { + if (!this._reloadSound) return; + if (this._reloadSound.isPlaying) this._reloadSound.stop(); + this._reloadSound.play(); + } + + stopReloadSound() { + if (this._reloadSound?.isPlaying) this._reloadSound.stop(); + } + + // 命中硝烟(轮转对象池) + triggerHitSmoke(worldPos, worldNormal) { + const e = this._smokePool[this._smokeIdx % this._smokePool.length]; + this._smokeIdx++; + e.trigger(worldPos, worldNormal); + } + + // 每帧更新所有特效 + update(dt) { + this._flash?.update(dt); + for (const e of this._smokePool) e.update(dt); + } +} diff --git a/three-player-controller/example/shooting/zombie/ZombieEntity.js b/three-player-controller/example/shooting/zombie/ZombieEntity.js new file mode 100644 index 0000000..c6af816 --- /dev/null +++ b/three-player-controller/example/shooting/zombie/ZombieEntity.js @@ -0,0 +1,440 @@ +import * as THREE from "three"; +import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js"; + +export class ZombieEntity { + constructor(scene, id) { + // ==================== 场景引用 ==================== + this._scene = scene; + this.id = id; // 唯一标识 + + // ==================== 场景对象 ==================== + this._colliders = []; // 场景碰撞网格(CollisionWorld 查询结果) + this._model = null; // 模型根节点 + this._capsule = null; // 碰撞胶囊体 + this._capsuleInfo = null; // 胶囊描述(radius + segment) + this._mixer = null; // 动画混合器 + + // ==================== 动画动作 ==================== + this._walkAction = null; // 行走 + this._runAction = null; // 奔跑 + this._idleAction = null; // 待机 + this._punchAction = null; // 攻击 + this._deathAction = null; // 死亡 + this._currentAction = null; // 当前播放动作 + this._punchLoopCb = null; // 攻击循环回调(用于 removeEventListener) + + // ==================== 生命状态 ==================== + this.hp = 100; // 血量 + this.isDead = false; // 是否已死亡 + this._deathTime = 0; // 死亡后累计时间(用于定时清除) + this._isRunning = Math.random() > 0.5; // 随机决定是否为跑者 + this._state = "idle"; // 当前行为状态 + this._isPunching = false; // 是否正在攻击 + this._inAttackRange = false; // 是否在攻击范围内 + + // ==================== 物理参数 ==================== + this._gravity = -2.4; // 重力加速度(load 时按 scale 重算) + this._speed = 0.12; // 移速(load 时按 scale 重算) + this._modelScale = 0.001; // 模型缩放 + this._capsuleHeight = 180; // 胶囊高度(模型空间,load 时重算) + this._capsuleRadius = 45; // 胶囊半径(模型空间,load 时重算) + this._velocity = new THREE.Vector3(); // 当前速度(主要用于重力累积) + this._onGround = false; // 是否落地 + + // ==================== 复用向量 ==================== + this._tempBox = new THREE.Box3(); + this._tempMat = new THREE.Matrix4(); + this._tempSeg = new THREE.Line3(); + this._tempV1 = new THREE.Vector3(); + this._tempV2 = new THREE.Vector3(); + this._moveDir = new THREE.Vector3(); // 水平移动方向(单位向量) + this._lookTarget = new THREE.Vector3(); // 朝向目标(lookAt 复用) + this._raycaster = new THREE.Raycaster( + new THREE.Vector3(), + new THREE.Vector3(0, -1, 0), + ); + this._raycaster.firstHitOnly = true; // 只取最近地面交点 + } + + // 加载模型、绑定动画、构建胶囊体,挂入场景 + async load(gltfLoader, { + modelUrl, + colliders, + position, + scale = 0.001, + walkAnim, + runAnim, + idleAnim, + punchAnim, + deathAnim, + rotateY = Math.PI, + speed = 120, + }) { + this._colliders = colliders?.length ? colliders.slice() : []; + this._modelScale = scale; + this._gravity = -2400 * scale; + // 基础移速增加随机扰动 (±15%),使丧尸步频与步幅产生差异 + this._speed = speed * scale * (0.85 + Math.random() * 0.3); + + const gltf = await gltfLoader.loadAsync(modelUrl); + this._model = gltf.scene; + + this._mixer = new THREE.AnimationMixer(this._model); + const clips = gltf.animations ?? []; + + // 按名称或模糊匹配查找动画片段 + const findClip = (hint) => { + if (!hint) return null; + return ( + clips.find((clip) => clip.name === hint) ?? + clips.find((clip) => clip.name.toLowerCase().includes(hint.toLowerCase())) ?? + null + ); + }; + + const walkClip = findClip(walkAnim) ?? findClip("walk") ?? clips[0] ?? null; + const runClip = findClip(runAnim) ?? findClip("run") ?? clips[0] ?? null; + const idleClip = findClip(idleAnim) ?? findClip("idle") ?? clips[0] ?? null; + const punchClip = findClip(punchAnim) ?? findClip("punch") ?? null; + const deathClip = findClip(deathAnim) ?? findClip("dying") ?? findClip("death") ?? findClip("die") ?? null; + + // 创建循环动作并初始权重为 0(由 _playAnim 按需激活) + const makeAction = (clip) => { + if (!clip) return null; + const action = this._mixer.clipAction(clip); + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveWeight(0); + action.enabled = true; + return action; + }; + + this._walkAction = makeAction(walkClip); + if (this._walkAction) { + // 随机播放速度 (±10%) 和 随机起始进度 (0-100%) + this._walkAction.setEffectiveTimeScale(1.5 * (0.9 + Math.random() * 0.2)); + this._walkAction.time = Math.random() * walkClip.duration; + } + + this._runAction = makeAction(runClip); + if (this._runAction) { + this._runAction.setEffectiveTimeScale(1.2 * (0.9 + Math.random() * 0.2)); + this._runAction.time = Math.random() * runClip.duration; + } + + this._idleAction = idleClip !== walkClip ? makeAction(idleClip) : this._walkAction; + if (this._idleAction) { + // 即使是待机动作也打乱起始时间 + this._idleAction.time = Math.random() * (idleClip?.duration ?? 1); + } + + if (punchClip) { + this._punchAction = this._mixer.clipAction(punchClip); + this._punchAction.setLoop(THREE.LoopRepeat, Infinity); + this._punchAction.setEffectiveTimeScale(1.5); + this._punchAction.setEffectiveWeight(0); + this._punchAction.enabled = true; + + // 每次攻击循环结束时检查是否已离开攻击范围,若已离开则切回移动动画 + this._punchLoopCb = (event) => { + if (event.action !== this._punchAction || !this._isPunching) return; + if (!this._inAttackRange) { + this._isPunching = false; + this._playAnim(this._walkAction ?? this._idleAction); + } + }; + this._mixer.addEventListener("loop", this._punchLoopCb); + } + + this._deathAction = makeAction(deathClip); + if (this._deathAction) { + this._deathAction.setLoop(THREE.LoopOnce, 1); + this._deathAction.clampWhenFinished = true; // 停在最后一帧 + } + + // 驱动一帧使骨骼到位,再计算包围盒以得到准确尺寸 + this._mixer.update(0); + this._model.updateMatrixWorld(true); + + const bbox = new THREE.Box3().setFromObject(this._model); + const size = new THREE.Vector3(); + bbox.getSize(size); + + // 将模型归一化到参考高度 180,再乘以 scale 得到世界尺寸 + const refHeight = 180; + const modelScaleFactor = refHeight / size.y; + this._capsuleHeight = size.y * modelScaleFactor; + this._capsuleRadius = Math.min(size.x, size.z) * modelScaleFactor; + + const radius = this._capsuleRadius * scale; + const height = this._capsuleHeight * scale; + + // 胶囊体:透明网格,仅用于碰撞,不渲染 + this._capsule = new THREE.Mesh( + new RoundedBoxGeometry(radius * 2, height, radius * 2, 1, 0.75), + new THREE.MeshStandardMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + }), + ); + this._capsule.geometry.translate(0, -height * 0.25, 0); + this._capsuleInfo = { + radius, + segment: new THREE.Line3( + new THREE.Vector3(), + new THREE.Vector3(0, -height * 0.5, 0), + ), + }; + this._capsule.name = "zombie_capsule"; + this._capsule.userData.zombieId = this.id; + this._capsule.layers.enable(2); // layer 2:可被武器射线检测到 + this._scene.add(this._capsule); + this._capsule.position.copy(position); + + this._model.scale.setScalar(modelScaleFactor * scale); + this._model.position.set(0, -height * 0.75, 0); + this._model.rotation.y = rotateY; + this._model.traverse((child) => { + child.userData.zombieId = this.id; + child.castShadow = true; + child.receiveShadow = true; + }); + this._capsule.add(this._model); + + this._playAnim(this._idleAction ?? this._walkAction); + } + + // ==================== 主循环 ==================== + + update(delta, playerPos) { + if (!this._capsule || !this._colliders.length) return; + + delta = Math.min(delta, 1 / 40); // 防止帧间隔过大导致穿透 + + if (this.isDead) { + this._deathTime += delta; + this._mixer?.update(delta); + return; + } + + // 重力累积 + if (!this._onGround) { + this._velocity.y += delta * this._gravity; + } + this._capsule.position.addScaledVector(this._velocity, delta); + + // 计算与玩家的水平距离,判断是否进入攻击范围 + const pos = this._capsule.position; + const dx = playerPos.x - pos.x; + const dz = playerPos.z - pos.z; + const horizDist = Math.sqrt(dx * dx + dz * dz); + const stopRadius = this._capsuleInfo.radius * 2; + this._inAttackRange = horizDist <= stopRadius; + + // 朝向玩家(lookAt 反向,使模型正面对着玩家) + this._lookTarget.set( + 2 * pos.x - playerPos.x, + pos.y, + 2 * pos.z - playerPos.z, + ); + this._capsule.lookAt(this._lookTarget); + + if (this._inAttackRange) { + this._moveDir.set(0, 0, 0); + if (!this._isPunching) { + this._isPunching = true; + this._state = "attack"; + this._playAnim(this._punchAction ?? this._idleAction ?? this._walkAction); + } + } else { + // 只要不在攻击范围内,立即重置攻击标记以允许移动逻辑执行 + this._isPunching = false; + + if (horizDist > 1e-5) { + this._moveDir.set(dx / horizDist, 0, dz / horizDist); + this._state = this._isRunning ? "run" : "walk"; + const moveAnim = this._isRunning ? (this._runAction ?? this._walkAction) : this._walkAction; + this._playAnim(moveAnim ?? this._idleAction); + } else { + this._moveDir.set(0, 0, 0); + this._state = "idle"; + this._playAnim(this._idleAction ?? this._walkAction); + } + } + + const currentSpeed = this._isRunning ? this._speed * 1.5 : this._speed; + this._applyEnvironmentCollision(delta, currentSpeed); + this._applyGrounding(delta); + this._mixer?.update(delta); + } + + // ==================== 伤害 / 死亡 ==================== + + takeDamage(damage) { + if (this.isDead) return false; + this.hp -= damage; + if (this.hp <= 0) { + this.die(); + return true; // 本次命中导致死亡 + } + return false; + } + + die() { + if (this.isDead) return; + this.isDead = true; + this._state = "die"; + this._isPunching = false; + this._deathTime = 0; + this._velocity.set(0, 0, 0); + + if (this._deathAction) { + this._playAnim(this._deathAction, 0.1); + } + + // 禁用图层以便射线检测和自动瞄准忽略尸体 + this._capsule.layers.disable(2); + this._model.traverse((child) => { + if (child.isMesh) { + child.layers.disable(2); + } + }); + } + + // ==================== Getter ==================== + + getPosition() { return this._capsule?.position ?? null; } + getCapsule() { return this._capsule; } + getCapsuleInfo() { return this._capsuleInfo; } + getDeathTime() { return this._deathTime; } + + // ==================== 私有方法 ==================== + + // 切换动画,带淡入淡出 + _playAnim(action, fade = 0.2) { + if (!action || this._currentAction === action) return; + const prev = this._currentAction; + action.reset().setEffectiveWeight(1).play(); + if (prev) { + prev.fadeOut(fade); + action.fadeIn(fade); + } else { + action.fadeIn(fade); + } + this._currentAction = action; + } + + // 分步移动 + BVH 环境碰撞(只处理竖直面,水平面由 _applyGrounding 负责) + _applyEnvironmentCollision(delta, speed) { + const ci = this._capsuleInfo; + const totalDist = speed * delta; + const maxStep = ci.radius * 0.8; // 每步最大移动量,防止高速穿透 + const steps = Math.ceil(totalDist / maxStep) || 1; + const stepDist = totalDist / steps; + + for (let i = 0; i < steps; i++) { + this._capsule.position.addScaledVector(this._moveDir, stepDist); + this._capsule.updateMatrixWorld(); + + for (const collider of this._colliders) { + this._tempBox.makeEmpty(); + this._tempMat.copy(collider.matrixWorld).invert(); + this._tempSeg.copy(ci.segment); + this._tempSeg.start + .applyMatrix4(this._capsule.matrixWorld) + .applyMatrix4(this._tempMat); + this._tempSeg.end + .applyMatrix4(this._capsule.matrixWorld) + .applyMatrix4(this._tempMat); + this._tempBox.expandByPoint(this._tempSeg.start).expandByPoint(this._tempSeg.end); + this._tempBox.expandByScalar(ci.radius); + + collider.geometry?.boundsTree?.shapecast({ + intersectsBounds: (box) => box.intersectsBox(this._tempBox), + intersectsTriangle: (tri) => { + const dist = tri.closestPointToSegment( + this._tempSeg, + this._tempV1, + this._tempV2, + ); + if (dist >= ci.radius) return; + const normal = tri.getNormal(new THREE.Vector3()); + if (Math.abs(normal.y) > 0.5) return; // 水平面交给 _applyGrounding 处理 + const dir = this._tempV2.sub(this._tempV1).normalize(); + const depth = ci.radius - dist; + this._tempSeg.start.addScaledVector(dir, depth); + this._tempSeg.end.addScaledVector(dir, depth); + }, + }); + + const newPos = this._tempV1 + .copy(this._tempSeg.start) + .applyMatrix4(collider.matrixWorld); + const deltaVec = this._tempV2.subVectors(newPos, this._capsule.position); + const offset = Math.max(0, deltaVec.length() - 1e-5); + if (offset > 0) { + this._capsule.position.add(deltaVec.normalize().multiplyScalar(offset)); + this._capsule.updateMatrixWorld(); + } + } + } + } + + // 射线向下检测地面,吸附胶囊体到地面高度 + _applyGrounding(delta) { + this._raycaster.ray.origin.copy(this._capsule.position); + const hits = this._raycaster.intersectObjects(this._colliders, false); + + if (!hits.length) { + this._onGround = false; + return; + } + + const groundY = hits[0].point.y; + const scale = this._modelScale; + const snapHeight = this._capsuleHeight * scale * 0.75; // 胶囊悬停高度 + const maxHeight = this._capsuleHeight * scale * 0.9; // 超过此距离视为腾空 + const dist = this._capsule.position.y - groundY; + + if (dist >= maxHeight) { + this._onGround = false; + return; + } + + this._velocity.set(0, 0, 0); + this._onGround = true; + + if (dist >= snapHeight) { + // 平滑吸附(走上斜坡时避免抖动) + this._capsule.position.y = THREE.MathUtils.lerp( + this._capsule.position.y, + groundY + snapHeight, + Math.min(1, 15 * delta), + ); + } else { + this._capsule.position.y = groundY + snapHeight; + } + } + + // ==================== 销毁 ==================== + + destroy() { + if (this._mixer) { + if (this._punchLoopCb) { + this._mixer.removeEventListener("loop", this._punchLoopCb); + this._punchLoopCb = null; + } + this._mixer.stopAllAction(); + this._mixer.uncacheRoot(this._model); + this._mixer = null; + } + if (this._capsule) { + this._scene.remove(this._capsule); + this._capsule.geometry?.dispose(); + this._capsule.material?.dispose?.(); + this._capsule = null; + } + this._model = null; + this._colliders = []; + } +} diff --git a/three-player-controller/example/shooting/zombie/ZombieManager.js b/three-player-controller/example/shooting/zombie/ZombieManager.js new file mode 100644 index 0000000..106e4a8 --- /dev/null +++ b/three-player-controller/example/shooting/zombie/ZombieManager.js @@ -0,0 +1,150 @@ +import { Vector3 } from "three"; +import { ZombieEntity } from "./ZombieEntity.js"; + +export class ZombieManager { + constructor(scene, options = {}) { + // ==================== 场景引用 ==================== + this._scene = scene; + + // ==================== 丧尸配置 ==================== + this._loader = options.loader ?? null; // GLTF 加载器 + this._colliders = options.colliders ?? []; // 场景碰撞网格 + this._modelUrl = options.modelUrl ?? ""; // 模型路径 + this._scale = options.scale ?? 0.01; // 模型缩放 + this._rotateY = options.rotateY ?? Math.PI; // 模型初始朝向 + this._speed = options.speed ?? 120; // 移速基准值 + this._walkAnim = options.walkAnim ?? "walking"; // 行走动画名 + this._runAnim = options.runAnim ?? "running"; // 奔跑动画名 + this._idleAnim = options.idleAnim ?? "Idle"; // 待机动画名 + this._punchAnim = options.punchAnim ?? "punching"; // 攻击动画名 + this._deathAnim = options.deathAnim ?? "dying"; // 死亡动画名 + // ==================== 运行状态 ==================== + this._zombies = new Map(); // id → ZombieEntity + this._wave = 0; // 当前波次 + this._nextId = 0; // 自增 id 计数器 + } + + // ==================== 波次 / 生成 ==================== + + // 按波次配置批量生成丧尸 + async startWave(waveConfig = {}) { + if (!this._loader || !this._colliders.length || !this._modelUrl) { + console.warn("[ZombieManager] Missing loader, colliders or modelUrl"); + return; + } + + this._wave += 1; + + const origin = waveConfig.origin?.clone?.() ?? waveConfig.origin ?? new Vector3(); + const count = waveConfig.count ?? 5; + const radius = waveConfig.radius ?? 10; // 默认半径 10 + const spawnHeight = waveConfig.spawnHeight ?? 0.5; // 对应参数 y + + let spawnPoints = waveConfig.spawnPoints; + + // 如果没有预设点,则根据半径和高度生成随机点 + if (!spawnPoints) { + spawnPoints = []; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * radius; // 使用开方确保在圆盘内分布均匀 + const x = Math.cos(angle) * r; + const z = Math.sin(angle) * r; + spawnPoints.push(new Vector3(origin.x + x, origin.y + spawnHeight, origin.z + z)); + } + } + + await Promise.all(spawnPoints.map((point) => this.spawnZombie(point))); + } + + // 在指定位置生成单个丧尸,返回其 id + async spawnZombie(position) { + const id = `zombie_${this._nextId++}`; + const entity = new ZombieEntity(this._scene, id); + await entity.load(this._loader, { + modelUrl: this._modelUrl, + colliders: this._colliders, + position: position.clone?.() ?? new Vector3(position.x, position.y, position.z), + scale: this._scale, + walkAnim: this._walkAnim, + runAnim: this._runAnim, + idleAnim: this._idleAnim, + punchAnim: this._punchAnim, + deathAnim: this._deathAnim, + rotateY: this._rotateY, + speed: this._speed, + }); + this._zombies.set(id, entity); + return id; + } + + // ==================== 命中 ==================== + + // 武器命中回调,转发伤害给对应实体 + onHit(id, damage) { + const entity = this._zombies.get(id); + if (!entity || entity.isDead) return; + entity.takeDamage(damage); + } + + // ==================== 主循环 ==================== + + update(dt, playerPos) { + if (!playerPos) return; + + for (const [id, entity] of this._zombies.entries()) { + entity.update(dt, playerPos); + + // 如果丧尸已死亡且超过10秒,则彻底移除 + if (entity.isDead && entity.getDeathTime() >= 10) { + entity.destroy(); + this._zombies.delete(id); + } + } + + this._resolveZombieOverlaps(); + } + + // 解决丧尸之间的胶囊重叠(两两推开,仅处理水平方向) + _resolveZombieOverlaps() { + const entities = Array.from(this._zombies.values()).filter(e => !e.isDead); + for (let i = 0; i < entities.length; i++) { + const a = entities[i]; + const aCapsule = a.getCapsule(); + const aInfo = a.getCapsuleInfo(); + if (!aCapsule || !aInfo) continue; + + for (let j = i + 1; j < entities.length; j++) { + const b = entities[j]; + const bCapsule = b.getCapsule(); + const bInfo = b.getCapsuleInfo(); + if (!bCapsule || !bInfo) continue; + + const dx = bCapsule.position.x - aCapsule.position.x; + const dz = bCapsule.position.z - aCapsule.position.z; + const distSq = dx * dx + dz * dz; + const minDist = aInfo.radius + bInfo.radius; + + if (distSq >= minDist * minDist || distSq <= 1e-10) continue; + + const dist = Math.sqrt(distSq); + const half = (minDist - dist) * 0.5; // 各推开一半 + const nx = dx / dist; + const nz = dz / dist; + aCapsule.position.x -= nx * half; + aCapsule.position.z -= nz * half; + bCapsule.position.x += nx * half; + bCapsule.position.z += nz * half; + } + } + } + + // ==================== 销毁 ==================== + + destroy() { + for (const entity of this._zombies.values()) { + entity.destroy(); + } + this._zombies.clear(); + } +} diff --git a/three-player-controller/example/showcase.html b/three-player-controller/example/showcase.html new file mode 100644 index 0000000..d602acd --- /dev/null +++ b/three-player-controller/example/showcase.html @@ -0,0 +1,135 @@ + + + + + + Showcase + + + + + +
+ +
+
+ Character Model: + Universal Animation Library +
+
+ Vehicle: + Low-poly cyberpunk SUV +
+
+ +
+
+
+
+
+ W + A + S + D +
+ Move +
+
+ + Look +
+
+
+
+ Shift + Sprint +
+
+ Space + Jump +
+
+ E + Enter +
+
+ V + Toggle View +
+
+ F + Toggle Fly +
+
+
+
+
+
+
+ W + A + S + D +
+ Drive +
+
+ + Look +
+
+
+
+ Shift + Drift +
+
+ Space + Brake +
+
+ E + Exit +
+
+ V + Toggle View +
+
+ R + Reset Vehicle +
+
+
+
+ + + + + + + diff --git a/three-player-controller/example/showcase.js b/three-player-controller/example/showcase.js new file mode 100644 index 0000000..30d8e3d --- /dev/null +++ b/three-player-controller/example/showcase.js @@ -0,0 +1,2462 @@ +import { + ACESFilmicToneMapping, + AmbientLight, + AnimationClip, + AnimationMixer, + Box3, + BoxGeometry, + BufferGeometry, + Timer, + Color, + VectorKeyframeTrack, + CylinderGeometry, + DirectionalLight, + DoubleSide, + ExtrudeGeometry, + Float32BufferAttribute, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + PerspectiveCamera, + PlaneGeometry, + Quaternion, + QuaternionKeyframeTrack, + RepeatWrapping, + ShaderMaterial, + Shape, + SphereGeometry, + SRGBColorSpace, + Scene, + TextureLoader, + VSMShadowMap, + Vector3, + WebGLRenderer, +} from "three"; +import { Sky } from "three/addons/objects/Sky.js"; +import { MapControls } from "three/examples/jsm/Addons.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; +import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import { playerController } from "../src/PlayerController"; +import { FootIK } from "../src/foot-ik"; +import { bindVehicleHintMode } from "./control-hints.js"; + +const TILE_SIZE = 1; +const SLOPE_ANGLES = [20, 30, 40]; + +const DECK_Y = -0.06; +const DECK_THICK = 0.12; + +// 车辆停放(仅大场景) +const VEHICLE_SPAWNS = [ + new Vector3(-1, 0, -11), + new Vector3(-1, 0, 11), +]; + +// 车辆调参(加载与 GUI 共用) +const VEHICLE_TUNING = { + followVehicleDirection: true, + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, + chassis: { + density: 1, + linearDamping: 0.05, + angularDamping: 0.5, + clearance: 0.8, + sizeScale: { x: 0.9, y: 0.55, z: 0.9 }, + }, + suspension: { + restLength: 0.15, + maxTravel: 0.15, + stiffness: 25, + compression: 2.1, + relaxation: 2.5, + maxForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, + }, + steering: { + maxSteerAngle: Math.PI / 5, + steerTime: 0.45, + steerReturnTimeSlow: 0.55, + steerReturnTimeFast: 0.4, + highSpeedSteerScale: 0.3, + }, + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, + power: { + maxSpeed: 100, + acceleration: 5, + deceleration: 5, + }, +}; + +// 半埋障碍带 +const OBSTACLE_X0 = -9; +const OBSTACLE_COUNT = 6; +const OBSTACLE_SPACING = 1.8; + +const PROP_SPHERE_R = 0.2; +const ACCENT_GRAY = 0xc8c8c8; +const PROP_GRID = 4; +const PROP_BOX_SIZE = 0.38; +const PROP_ACCENT_COLOR = 0x7eb0d4; + +const KINE_SHUTTLE_SPEED = 0.9; +const KINE_SPIN_SPEED = 0.35; +const STATIC_STRIP_COUNT = 6; +const STATIC_STRIP_GAP_RADIUS_RATIO = 0.5; +const STATIC_STRIP_DEPTH_RADIUS_RATIO = 3; + +const MINI_SCALE = 0.1; // 小场景相对大场景的比例 +const PLAYER_SCALE_NORMAL = 0.005; // 大场景人物缩放 +const VEHICLE_SCALE_NORMAL = 0.5; // 大场景车辆缩放 + +// 东侧巡逻甲板 +const PATROL_GAP_WIDTH = 6; // 东墙豁口宽度 +const PATROL_BRIDGE_LEN = 2; // 豁口外栈桥进深 +const PATROL_CLIMB_ANGLE = 20; // 上甲板坡度 +const PATROL_STEP_COUNT = 7; +const PATROL_DECK_LEN = 12; // 甲板 X 向长度 +const PATROL_DECK_WIDTH = 18; // 甲板 Z 向宽度 +const PATROL_NPC_COUNT = 6; +const PATROL_LANE_GAP = 3; // 巡逻道间距 +const PATROL_END_MARGIN = 0.85; // 巡逻线距甲板两端留白 +const PATROL_TURN_TIME = 0.45; // 端点转身时长 +const PATROL_LANE_MARK_WIDTH = 0.12; +const PATROL_WALK_SPEED_RAW = 100; // 回退值:与玩家 playerModelConfig.speed 一致 + +// 可切换角色与发型(Universal Base Characters,CC0) +// 三者共用同一套 65 骨骼,动画恒取自 ual.glb,无需重定向 +const CHARACTER_DIR = "./glb/characters/"; +const HAIR_NONE = "None"; +const CHARACTERS = [ + { name: "Mannequin", url: "./glb/ual.glb", heightFactor: 1 }, + { name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 }, + // 成年女性平均身高约为男性的 93%;模型自带的 97.5% 差异过小,几乎看不出 + { name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 }, +]; +// 手臂姿态微调(角度制):hang 让手臂更垂,straighten 让肘部更直 +const ARM_ADJUST_DEFAULTS = { + "Mannequin": { hang: 0, straighten: 0 }, + "Superhero Male": { hang: 0, straighten: 0 }, + "Superhero Female": { hang: 0, straighten: 0 }, +}; +const ARM_SIDES = [ + { upper: "upperarm_l", lower: "lowerarm_l", hand: "hand_l" }, + { upper: "upperarm_r", lower: "lowerarm_r", hand: "hand_r" }, +]; +const ARM_DOWN = new Vector3(0, -1, 0); +// 量躯干宽度时只取这些骨骼的顶点,避免 T-Pose 的手臂把宽度撑大 +const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"]; +// 控制器默认 playerCapsuleHeight / playerCapsuleRadius = 180 / 30 +const CAPSULE_HEIGHT_TO_RADIUS = 6; +const HAIRSTYLES = [ + "Hair_Buzzed", + "Hair_BuzzedFemale", + "Hair_SimpleParted", + "Hair_Long", + "Hair_Buns", + "Hair_Beard", +]; +const DEFAULT_CHARACTER = "Superhero Male"; + +// 与网格无关的玩家模型配置,init 与角色切换共用 +const PLAYER_MODEL_BASE = { + scale: PLAYER_SCALE_NORMAL, + idleAnim: "Idle_Loop", + walkAnim: "Walk_Loop", + runAnim: "Sprint_Loop", + jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"], + flyAnim: "fly", + flyIdleAnim: "flyIdle", + flyHoverForwardAnim: "flyHoverForward", + flyHoverBackAnim: "flyHoverBack", + flyHoverLeftAnim: "flyHoverLeft", + flyHoverRightAnim: "flyHoverRight", + flyHoverUpAnim: "flyHoverUp", + flyHoverDownAnim: "flyHoverDown", + drivingAnim: "Driving_Loop", + headBoneName: "Head", + firstPersonCameraOffset: [0, 0.15, 0.12], + rotateY: -Math.PI / 2, + speed: 100, + runSpeed: 600, +}; +// 梯形连接段长度:相对宽端开口宽度(宽矩形 / 收腰) +const TRAP_WIDE_LEN_RATIO = 0.45; +const TRAP_SLOPE_LEN_RATIO = 0.4; +// 收腰段:青通道 tint +const TRAP_TINT_COLOR = 0x88c8e0; + +// 按比例生成场景布局 +function createShowcaseLayout(scale = 1) { + const s = scale; + return { + scale: s, + platform: { x: -5 * s, z: 0, sizeX: 30 * s, sizeZ: 28 * s }, + wallH: 2.0 * s, + wallT: 0.35 * s, + deckY: DECK_Y * s, + deckThick: DECK_THICK * s, + l1Top: 1.2 * s, + obstacleX0: OBSTACLE_X0 * s, + obstacleCount: OBSTACLE_COUNT, + obstacleSpacing: OBSTACLE_SPACING * s, + obstacleZ: 3.2 * s, + obstacleCylR: 0.15 * s, + obstacleCylLen: 2.5 * s, + obstacleBoxSize: new Vector3(0.25 * s, 0.275 * s, 2.5 * s), + stairWidth: 1 * s, + rampWidth: 1 * s, + kineThickness: 0.12 * s, + kineTopOffset: 0.4 * s, + tileSize: TILE_SIZE * s, + }; +} + +// 染色材质 +function tintMaterial(baseMat, hex) { + const mat = baseMat.clone(); + mat.color?.setHex?.(hex); + return mat; +} + +const scene = new Scene(); +const animTimer = new Timer(); +let scaledAnimTime = 0; + +let camera; +let renderer; +let controls; +let player; +let footIK; +let footIKDebugParams; +let stats; +const kinematicPlatforms = []; +const patrolNPCs = []; +let gltfLoader = null; +const gltfCache = new Map(); +let playerAnimations = null; // ual.glb 的 53 个动画,独立于角色网格 +let currentCharacterName = DEFAULT_CHARACTER; +let currentHairName = HAIR_NONE; +let currentHairMesh = null; +let characterBusy = false; +let currentHeightFactor = 1; +let currentHitboxScale = 1; +const characterHeights = new Map(CHARACTERS.map((c) => [c.name, c.heightFactor])); +let syncCharacterGui = null; +let sourceRestPose = null; // ual.glb 的静止姿态,用于重定基准 +const clipCache = new Map(); +const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() }; +let armChains = null; +const armAdjust = { hang: 0, straighten: 0 }; +const characterArms = new Map(Object.entries(ARM_ADJUST_DEFAULTS).map(([k, v]) => [k, { ...v }])); +const armTmp = { + a: new Vector3(), b: new Vector3(), c: new Vector3(), + dir: new Vector3(), fore: new Vector3(), axis: new Vector3(), + qWorld: new Quaternion(), qParent: new Quaternion(), qLocal: new Quaternion(), +}; +let trapScaleRange = null; // 收腰段:wideWestX 处 scale=1,westX 处 scale=0.1 +const glowPortals = []; +let zoneScale = 1; // 当前场景总缩放(1 → MINI_SCALE) +const scaleGateWorldPos = new Vector3(); // 缩放判定用的世界坐标缓冲 + +init(); + +// 初始化 +async function init() { + const container = document.querySelector("#container"); + + // 渲染器 + renderer = new WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + renderer.toneMapping = ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.05; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = VSMShadowMap; + renderer.setAnimationLoop(animate); + container.appendChild(renderer.domElement); + animTimer.connect(document); + + // 帧率 + stats = new Stats(); + Object.assign(stats.dom.style, { + position: "fixed", + bottom: "0px", + left: "0px", + top: "auto", + zIndex: "9998", + display: "block", + }); + document.body.appendChild(stats.dom); + + // 相机 + camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 800); + camera.position.set(14, 10, 10); + + // 控制器 + controls = new MapControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxDistance = 260; + controls.maxPolarAngle = Math.PI / 2.05; + controls.target.set(0, 1, 0); + + // 环境光 + scene.add(new AmbientLight(0xffffff, 1.5)); + + // 平行光 + const sun = new DirectionalLight(0xffffff, 2); + sun.position.set(16, 48, 24); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + sun.shadow.camera.near = 0.5; + sun.shadow.camera.far = 220; + sun.shadow.camera.left = -90; + sun.shadow.camera.right = 44; // 覆盖东侧巡逻甲板 + sun.shadow.camera.top = 70; + sun.shadow.camera.bottom = -70; + sun.shadow.bias = -0.0005; + scene.add(sun); + + const sky = new Sky(); + sky.scale.setScalar(450000); + scene.add(sky); + const skyUniforms = sky.material.uniforms; + skyUniforms["turbidity"].value = 10; + skyUniforms["rayleigh"].value = 2; + skyUniforms["mieCoefficient"].value = 0.001; + skyUniforms["mieDirectionalG"].value = 0.99; + skyUniforms["sunPosition"].value.copy(sun.position.clone().normalize()); + + // 测试场景 + const prototypeMat = await loadTiledMaterial("./textures/showcase/prototype.png"); + const mainLayout = createShowcaseLayout(); + const miniLayout = createShowcaseLayout(MINI_SCALE); + // 矩形西墙全开,开口贴合南北墙外缘,与梯形宽端相接 + const mainWestOpening = { + zCenter: 0, + width: mainLayout.platform.sizeZ + 2 * mainLayout.wallT, + }; + // 小场景南北墙外缘宽度:梯形窄端西口与小矩形东侧共用 + const miniOpening = { + zCenter: 0, + width: miniLayout.platform.sizeZ + 2 * miniLayout.wallT, + }; + + const world = new Group(); + world.name = "ShowcaseWorld"; + const mainWorld = buildShowcaseWorld(prototypeMat, mainLayout, { + westGap: mainWestOpening, + eastGap: { zCenter: 0, width: PATROL_GAP_WIDTH }, + }); + mainWorld.name = "MainWorld"; + world.add(mainWorld); + + const mainWestX = mainLayout.platform.x - mainLayout.platform.sizeX * 0.5; + const trapLayout = createTrapezoidLayout( + mainWestX, + mainLayout.platform.sizeZ, + miniLayout.platform.sizeZ, + ); + const trapWorld = buildTrapezoidArena(prototypeMat, mainLayout, trapLayout, miniLayout.wallH); + trapWorld.name = "TrapezoidWorld"; + world.add(trapWorld); + trapScaleRange = { eastX: trapLayout.wideWestX, westX: trapLayout.westX }; + + const miniWorld = buildShowcaseWorld(prototypeMat, miniLayout, { + omitEast: true, + }); + miniWorld.name = "MiniWorld"; + const miniLocalEast = miniLayout.platform.x + miniLayout.platform.sizeX * 0.5; + // 小场景台面东缘对齐梯形窄端西口 + miniWorld.position.set(trapLayout.westX - miniLocalEast, 0, 0); + world.add(miniWorld); + + // 东侧巡逻甲板:并入 world,复用静态碰撞源 + const patrol = buildPatrolDeck(prototypeMat, mainLayout); + world.add(patrol.root); + scene.add(patrol.decor); + scene.add(world); + + // 豁口发光门帘:宽矩形/收腰交界 + 梯形西口(进入小场景) + const slopeEastOpening = { zCenter: 0, width: trapLayout.halfWide * 2 }; + glowPortals.push( + createGlowGapPortal(mainLayout, slopeEastOpening, trapLayout.wideWestX), + createGlowGapPortal(miniLayout, miniOpening, trapLayout.westX), + ); + for (const portal of glowPortals) scene.add(portal); + + // 加载动画库与初始角色:动画恒取自 ual.glb,网格来自所选角色 + gltfLoader = new GLTFLoader(); + const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb"); + gltfCache.set(CHARACTERS[0].url, Promise.resolve(playerGltf)); + playerAnimations = playerGltf.animations; + sourceRestPose = collectRestPose(playerGltf.scene); + const startEntry = CHARACTERS.find((c) => c.name === DEFAULT_CHARACTER) ?? CHARACTERS[0]; + const startGltf = await loadGltfCached(startEntry.url); + currentCharacterName = startEntry.name; + currentHeightFactor = characterHeights.get(startEntry.name) ?? 1; + const startModel = cloneSkeleton(startGltf.scene); + const startRadiusRatio = measureTorsoRadiusRatio(startModel) * currentHitboxScale; + + // 初始化玩家控制器 + player = new playerController(); + await player.init({ + scene, + camera, + controls, + colliders: [ + { motion: "static", shape: { kind: "mesh", source: world } }, + ], + initPos: new Vector3(6.5, 0.4, 0.5), + minCamDistance: 8, + maxCamDistance: 300, + camLookAtHeightRatio: 0.5, + enableOverShoulderView: true, + camOverShoulderOffsetRatio: 0, + enableSpringCamera: true, + springCameraTime: 0.1, + enableZoom: true, + playerModelConfig: { + ...PLAYER_MODEL_BASE, + model: startModel, + animations: clipsForModel(startEntry.name, startModel), + scale: playerScaleForZone(), + capsuleRadiusRatio: startRadiusRatio, + }, + }); + Object.assign(armAdjust, characterArms.get(currentCharacterName) ?? { hang: 0, straighten: 0 }); + armChains = resolveArmChains(player.playerModel); + bindVehicleHintMode(player); + enableModelShadows(player.playerModel); + const staticStripBridge = createStaticStripPlatforms(prototypeMat, mainLayout, scene, { + centerX: getBalancedKinematicMeetCenterX(mainLayout, trapLayout.wideWestX), + }); + const miniStripBridge = createStaticStripPlatforms(prototypeMat, miniLayout, miniWorld, { + centerX: getBalancedKinematicMeetCenterX(miniLayout), + }); + + // 初始化脚部 IK + footIK = new FootIK({ + enabled: true, + soleHalfWidth: 4, + soleToeExtend: 4, + soleSkinThickness: 1.6, + plantedHeightSpeed: 200, + penetrationLiftSpeed: 200, + predictivePlacement: true, + straightPoleEnabled: true, + skeleton: { + hips: "pelvis", + legs: { + left: { + upper: "thigh_l", + lower: "calf_l", + foot: "foot_l", + toe: "ball_l", + }, + right: { + upper: "thigh_r", + lower: "calf_r", + foot: "foot_r", + toe: "ball_r", + }, + }, + }, + }); + player.use(footIK); + + await spawnShowcaseVehicles(gltfLoader); + spawnPatrolNPCs(playerGltf, patrol); + spawnVehiclePropBodies(prototypeMat, mainLayout); + spawnLayoutPropBodies(prototypeMat, miniLayout, miniWorld.position); + createKinematicPlatforms(prototypeMat, mainLayout, scene, { + meetCenterX: staticStripBridge.centerX, + middleStripSpan: staticStripBridge.span, + slopeMeetCenterX: trapLayout.wideWestX, + slopeWestX: trapLayout.westX, + }); + createKinematicPlatforms(prototypeMat, miniLayout, miniWorld, { + meetCenterX: miniStripBridge.centerX, + middleStripSpan: miniStripBridge.span, + }); + + createDebugPanel(); + + window.addEventListener("resize", onResize); + window.addEventListener("keydown", (e) => { + if (e.code !== "KeyR" || e.repeat) return; + player?.resetVehicle(); + }); + window["hideLoader"]?.(); +} + +// 加载 tiling 纹理材质 +async function loadTiledMaterial(url) { + const texture = await new TextureLoader().loadAsync(url); + texture.colorSpace = SRGBColorSpace; + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.repeat.set(1, 1); + texture.anisotropy = 8; + return new MeshStandardMaterial({ + map: texture, + roughness: 0.85, + metalness: 0.05, + }); +} + +// 按世界尺度写入 UV +function applyWorldTileUV(geometry, origin = { x: 0, y: 0, z: 0 }, tileSize = TILE_SIZE) { + const flat = geometry.index ? geometry.toNonIndexed() : geometry; + const pos = flat.getAttribute("position"); + const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2); + const tile = Math.max(tileSize, 1e-6); + + for (let t = 0; t < pos.count; t += 3) { + const ax = pos.getX(t); + const ay = pos.getY(t); + const az = pos.getZ(t); + const bx = pos.getX(t + 1); + const by = pos.getY(t + 1); + const bz = pos.getZ(t + 1); + const cx = pos.getX(t + 2); + const cy = pos.getY(t + 2); + const cz = pos.getZ(t + 2); + + const e1x = bx - ax; + const e1y = by - ay; + const e1z = bz - az; + const e2x = cx - ax; + const e2y = cy - ay; + const e2z = cz - az; + const nx = Math.abs(e1y * e2z - e1z * e2y); + const ny = Math.abs(e1z * e2x - e1x * e2z); + const nz = Math.abs(e1x * e2y - e1y * e2x); + + for (let i = 0; i < 3; i++) { + const idx = t + i; + const x = pos.getX(idx) + origin.x; + const y = pos.getY(idx) + origin.y; + const z = pos.getZ(idx) + origin.z; + let u = 0; + let v = 0; + if (ny >= nx && ny >= nz) { + u = x; + v = z; + } else if (nx >= ny && nx >= nz) { + u = z; + v = y; + } else { + u = x; + v = y; + } + uv.setXY(idx, u / tile, v / tile); + } + } + + flat.setAttribute("uv", uv); + flat.computeVertexNormals(); + return flat; +} + +// 创建展示场景 +function buildShowcaseWorld(baseMat, layout, options = {}) { + const root = new Group(); + const { platform, deckY, deckThick, tileSize } = layout; + + addBox( + root, + new Vector3(platform.x, deckY, platform.z), + new Vector3(platform.sizeX, deckThick, platform.sizeZ), + baseMat, + tileSize, + ); + createPlatformWalls(root, baseMat, layout, options); + createClimbGroups(root, baseMat, layout); + createHalfBuriedObstacles(root, baseMat, layout); + + return root; +} + +// 平台围墙 +function createPlatformWalls(root, baseMat, layout, options = {}) { + const { platform, wallH, wallT, tileSize } = layout; + const { x, z, sizeX, sizeZ } = platform; + const halfX = sizeX * 0.5; + const halfZ = sizeZ * 0.5; + const y = wallH * 0.5; + + // 北 / 南 + addBox(root, new Vector3(x, y, z + halfZ + wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(x, y, z - halfZ - wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize); + + // 东墙:可选中部豁口 + if (!options.omitEast) { + addWallWithGap(root, baseMat, layout, x + halfX + wallT * 0.5, options.eastGap); + } + + // 西墙:可选中部豁口 + addWallWithGap(root, baseMat, layout, x - halfX - wallT * 0.5, options.westGap); +} + +// 单面侧墙,可在中部留出豁口 +function addWallWithGap(root, baseMat, layout, wallX, gap) { + const { platform, wallH, wallT, tileSize } = layout; + const { z, sizeZ } = platform; + const halfZ = sizeZ * 0.5; + const y = wallH * 0.5; + + if (!gap || gap.width <= 0) { + addBox(root, new Vector3(wallX, y, z), new Vector3(wallT, wallH, sizeZ), baseMat, tileSize); + return; + } + + const gapHalf = gap.width * 0.5; + const southEnd = z - halfZ; + const northEnd = z + halfZ; + + const southLen = gap.zCenter - gapHalf - southEnd; + if (southLen > 1e-4) { + addBox( + root, + new Vector3(wallX, y, southEnd + southLen * 0.5), + new Vector3(wallT, wallH, southLen), + baseMat, + tileSize, + ); + } + const northLen = northEnd - (gap.zCenter + gapHalf); + if (northLen > 1e-4) { + addBox( + root, + new Vector3(wallX, y, northEnd - northLen * 0.5), + new Vector3(wallT, wallH, northLen), + baseMat, + tileSize, + ); + } +} + +// 梯形连接段尺寸(东贴大场景,西贴小场景) +function createTrapezoidLayout(eastX, eastWidth, westWidth) { + const wideLen = eastWidth * TRAP_WIDE_LEN_RATIO; + const slopeLen = eastWidth * TRAP_SLOPE_LEN_RATIO; + const wideWestX = eastX - wideLen; + const westX = wideWestX - slopeLen; + return { + eastX, + westX, + wideWestX, + halfWide: eastWidth * 0.5, + halfNarrow: westWidth * 0.5, + }; +} + +function getTrapezoidOutline(trap) { + const { eastX, westX, wideWestX, halfWide, halfNarrow } = trap; + return [ + { x: eastX, z: halfWide }, + { x: wideWestX, z: halfWide }, + { x: westX, z: halfNarrow }, + { x: westX, z: -halfNarrow }, + { x: wideWestX, z: -halfWide }, + { x: eastX, z: -halfWide }, + ]; +} + +function getWideSectionOutline(trap) { + const { eastX, wideWestX, halfWide } = trap; + return [ + { x: eastX, z: halfWide }, + { x: wideWestX, z: halfWide }, + { x: wideWestX, z: -halfWide }, + { x: eastX, z: -halfWide }, + ]; +} + +function getSlopeSectionOutline(trap) { + const { wideWestX, westX, halfWide, halfNarrow } = trap; + return [ + { x: wideWestX, z: halfWide }, + { x: westX, z: halfNarrow }, + { x: westX, z: -halfNarrow }, + { x: wideWestX, z: -halfWide }, + ]; +} + +function isSlopeSectionEdge(a, b) { + return Math.abs(b.x - a.x) > 1e-4 && Math.abs(b.z - a.z) > 1e-4; +} + +// 宽矩形 + 收腰梯形 +function buildTrapezoidArena(baseMat, layout, trap, westWallH) { + const root = new Group(); + const trapMat = tintMaterial(baseMat, TRAP_TINT_COLOR); + addPolygonFloor(root, getWideSectionOutline(trap), layout, baseMat); + addPolygonFloor(root, getSlopeSectionOutline(trap), layout, trapMat); + addOutlineWalls(root, getTrapezoidOutline(trap), layout, baseMat, trapMat, trap, westWallH); + return root; +} + +function addPolygonFloor(root, points, layout, material) { + const { deckY, deckThick, tileSize } = layout; + const shape = new Shape(); + shape.moveTo(points[0].x, -points[0].z); + for (let i = 1; i < points.length; i++) { + shape.lineTo(points[i].x, -points[i].z); + } + shape.closePath(); + + const geo = new ExtrudeGeometry(shape, { depth: deckThick, bevelEnabled: false, steps: 1 }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, deckY - deckThick * 0.5, 0); + const floor = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material); + floor.name = "TrapezoidFloor"; + return addShadowMesh(root, floor); +} + +// 沿多边形外缘砌墙,跳过东西两端开口;收腰段墙高从大场景线性降到小场景 +function addOutlineWalls(root, points, layout, wideMat, slopeMat, trap, westWallH) { + const n = points.length; + let cx = 0; + let cz = 0; + for (const p of points) { + cx += p.x; + cz += p.z; + } + cx /= n; + cz /= n; + + const heightAt = (p) => ( + Math.abs(p.x - trap.wideWestX) < 1e-4 || p.x > trap.wideWestX + ? layout.wallH + : westWallH + ); + + for (let i = 0; i < n; i++) { + const a = points[i]; + const b = points[(i + 1) % n]; + const isEastGap = Math.abs(a.x - trap.eastX) < 1e-4 && Math.abs(b.x - trap.eastX) < 1e-4; + const isWestGap = Math.abs(a.x - trap.westX) < 1e-4 && Math.abs(b.x - trap.westX) < 1e-4; + if (isEastGap || isWestGap) continue; + + const dx = b.x - a.x; + const dz = b.z - a.z; + const len = Math.hypot(dx, dz); + if (len < 1e-4) continue; + + const midX = (a.x + b.x) * 0.5; + const midZ = (a.z + b.z) * 0.5; + let nx = -dz / len; + let nz = dx / len; + if (nx * (cx - midX) + nz * (cz - midZ) > 0) { + nx = -nx; + nz = -nz; + } + + const hA = heightAt(a); + const hB = heightAt(b); + const material = isSlopeSectionEdge(a, b) ? slopeMat : wideMat; + if (Math.abs(hA - hB) > 1e-4) { + addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz); + } else { + addOrientedWall(root, layout, material, midX, midZ, len, nx, nz, dx, dz); + } + } +} + +function addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz) { + const { wallT, tileSize } = layout; + const len = Math.hypot(b.x - a.x, b.z - a.z); + const along = new Vector3((b.x - a.x) / len, 0, (b.z - a.z) / len); + const up = new Vector3(0, 1, 0); + const out = new Vector3(nx, 0, nz); + + // 外法线与 along×up 反向时会变成左手系,正面被剔掉;改为从另一端挤出 + let origin = a; + let startH = hA; + let endH = hB; + const xAxis = along.clone(); + if (xAxis.clone().cross(up).dot(out) < 0) { + xAxis.negate(); + origin = b; + startH = hB; + endH = hA; + } + + const shape = new Shape(); + shape.moveTo(0, 0); + shape.lineTo(len, 0); + shape.lineTo(len, endH); + shape.lineTo(0, startH); + shape.closePath(); + + const geo = new ExtrudeGeometry(shape, { depth: wallT, bevelEnabled: false, steps: 1 }); + const m = new Matrix4().makeBasis(xAxis, up, out); + m.setPosition(origin.x, 0, origin.z); + geo.applyMatrix4(m); + + const mesh = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material); + return addShadowMesh(root, mesh); +} + +function addOrientedWall(root, layout, material, midX, midZ, length, nx, nz, dx, dz) { + const { wallH, wallT, tileSize } = layout; + const pos = new Vector3(midX + nx * wallT * 0.5, wallH * 0.5, midZ + nz * wallT * 0.5); + const geo = new BoxGeometry(wallT, wallH, length); + geo.rotateY(Math.atan2(dx, dz)); + const mesh = new Mesh(applyWorldTileUV(geo, pos, tileSize), material); + mesh.position.copy(pos); + return addShadowMesh(root, mesh); +} + +// 创建豁口半透明发光门帘 +function createGlowGapPortal(layout, westGap, doorX) { + const group = new Group(); + group.name = "GlowGapPortal"; + + const mat = new ShaderMaterial({ + transparent: true, + depthWrite: false, + side: DoubleSide, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new Color(0x66e0ff) }, + uIntensity: { value: 1.35 }, + }, + vertexShader: /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform float uTime; + uniform vec3 uColor; + uniform float uIntensity; + varying vec2 vUv; + + void main() { + float edgeX = smoothstep(0.0, 0.12, vUv.x) * smoothstep(0.0, 0.12, 1.0 - vUv.x); + float edgeY = smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.1, 1.0 - vUv.y); + float veil = edgeX * edgeY; + float wave = 0.55 + 0.45 * sin(vUv.y * 14.0 - uTime * 2.4); + float scan = 0.35 + 0.65 * pow(abs(sin(vUv.x * 3.14159 + uTime * 1.6)), 2.0); + float alpha = veil * (0.22 + 0.38 * wave * scan); + vec3 col = uColor * (uIntensity + 0.55 * wave); + gl_FragColor = vec4(col, alpha); + } + `, + }); + + const mesh = new Mesh( + new PlaneGeometry(westGap.width, layout.wallH), + mat, + ); + mesh.name = "GlowGapCurtain"; + mesh.rotation.y = Math.PI * 0.5; + mesh.position.set(doorX, layout.wallH * 0.5, westGap.zCenter); + mesh.renderOrder = 2; + mesh.frustumCulled = false; + group.add(mesh); + group.userData.material = mat; + return group; +} + +// 更新发光门帘时间 +function updateGlowPortal(t) { + for (const portal of glowPortals) { + const mat = portal?.userData?.material; + if (mat?.uniforms?.uTime) mat.uniforms.uTime.value = t; + } +} + +// 两数是否视为同一缩放 +function isNearScale(a, b) { + return Math.abs(a - b) <= Math.max(Math.abs(b) * 1e-4, 1e-8); +} + +// 收腰段 X:wideWestX → 1,westX → MINI_SCALE;宽矩形段保持 1 +function zoneScaleFromX(x) { + if (!trapScaleRange) return 1; + const { eastX: narrowStartX, westX } = trapScaleRange; + if (x > narrowStartX) return 1; + const span = narrowStartX - westX; + if (span <= 1e-6) return x <= westX ? MINI_SCALE : 1; + const t = Math.min(1, Math.max(0, (narrowStartX - x) / span)); + return 1 + t * (MINI_SCALE - 1); +} + +function playerScaleForZone(z = zoneScale) { + // updateTrapScale 每帧调用这里,身高系数必须并入,否则会被区域缩放覆盖 + return PLAYER_SCALE_NORMAL * z * currentHeightFactor; +} + +function vehicleScaleForZone(z = zoneScale) { + return VEHICLE_SCALE_NORMAL * z; +} + +function isDriving() { + return player?.controllerMode === 1 && Boolean(player.getActiveVehicle?.()); +} + +function getScaleGatePosition() { + if (isDriving()) { + const v = player.getActiveVehicle(); + if (v?.vehicleGroup) return v.vehicleGroup.position; + } + const cap = player?.playerCapsule; + if (cap) return cap.getWorldPosition(scaleGateWorldPos); + return player?.getPosition?.() ?? null; +} + +function recomputePlayerGroundThresholds() { + const cap = player?.playerCapsule; + const info = cap?.capsuleInfo; + if (!info) return; + const sy = cap.scale.y || 1; + const ride = player.rideHeight * player.playerModelConfig.scale; + player.snapH = (-info.segment.end.y) * sy + info.radius + ride; + player.maxH = player.snapH + ride; +} + +// 应用人物缩放;驾驶中保持胶囊局部缩放,由车身缩放带动 +function applyPlayerScale(targetScale) { + if (!player?.setPlayerScale || isNearScale(player.playerModelConfig.scale, targetScale)) return; + if (!isDriving()) { + player.setPlayerScale(targetScale); + return; + } + const cap = player.playerCapsule; + const savedScale = cap.scale.clone(); + player.setPlayerScale(targetScale); + cap.scale.copy(savedScale); + recomputePlayerGroundThresholds(); +} + +// 按收腰段 X 连续缩放:越往 -X 越小 +function updateTrapScale() { + if (!player || !trapScaleRange) return; + const pos = getScaleGatePosition(); + if (!pos) return; + + zoneScale = zoneScaleFromX(pos.x); + applyPlayerScale(playerScaleForZone(zoneScale)); + + const vehicles = player.getAllVehicles?.() ?? []; + if (!vehicles.length || !player.setVehicleScale) return; + for (const v of vehicles) { + const target = vehicleScaleForZone(zoneScaleFromX(v.vehicleGroup.position.x)); + if (!isNearScale(v.scale, target)) player.setVehicleScale(v, target); + } +} + +// 半埋圆柱 + 半埋长方体 +function createHalfBuriedObstacles(root, baseMat, layout) { + const accentMat = tintMaterial(baseMat, ACCENT_GRAY); + const { + obstacleX0, + obstacleCount, + obstacleSpacing, + obstacleZ, + obstacleCylR, + obstacleCylLen, + obstacleBoxSize, + deckY, + deckThick, + tileSize, + } = layout; + const deckTop = deckY + deckThick * 0.5; + + for (let i = 0; i < obstacleCount; i++) { + const x = obstacleX0 - i * obstacleSpacing; + + const cylPos = new Vector3(x, deckTop, -obstacleZ); + const cylGeo = applyWorldTileUV( + new CylinderGeometry(obstacleCylR, obstacleCylR, obstacleCylLen, 24), + cylPos, + tileSize, + ); + cylGeo.rotateX(Math.PI / 2); + const cyl = new Mesh(cylGeo, accentMat); + cyl.position.copy(cylPos); + addShadowMesh(root, cyl); + + addBox(root, new Vector3(x, deckTop, obstacleZ), obstacleBoxSize.clone(), accentMat, tileSize); + } +} + +// 加载展示车辆 +async function spawnShowcaseVehicles(gltfLoader) { + const gltf = await gltfLoader.loadAsync("./glb/suv.glb"); + for (const spawnPos of VEHICLE_SPAWNS) { + const model = gltf.scene.clone(true); + await player.loadVehicleModel({ + model, + scale: VEHICLE_SCALE_NORMAL, + position: spawnPos.clone(), + wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"], + driverSeatPosition: new Vector3(-0.4, 1.0, 0.3), + driverSeatRotation: -Math.PI / 2, + modelRotation: -Math.PI / 2, + followVehicleDirection: VEHICLE_TUNING.followVehicleDirection, + debug: VEHICLE_TUNING.debug, + chassis: VEHICLE_TUNING.chassis, + suspension: VEHICLE_TUNING.suspension, + steering: VEHICLE_TUNING.steering, + grip: VEHICLE_TUNING.grip, + power: VEHICLE_TUNING.power, + }); + + const vehicle = player.getAllVehicles().at(-1); + vehicle?.vehicleGroup?.traverse((child) => { + if (!child.isMesh) return; + child.castShadow = true; + child.receiveShadow = true; + const mats = Array.isArray(child.material) ? child.material : [child.material]; + for (const mat of mats) { + if (!mat) continue; + mat.metalness = 0.8; + mat.roughness = 0.0; + } + }); + } +} + +// 车辆前方动态刚体 +function spawnVehiclePropBodies(baseMat, layout) { + const vehicles = player?.getAllVehicles?.() ?? []; + if (vehicles.length < 2) return; + const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + spawnSphereGrid(vehicles[0], propMat, layout); + spawnBoxWall(vehicles[1], propMat, layout); +} + +// 按布局生成动态球阵与箱墙 +function spawnLayoutPropBodies(baseMat, layout, worldOffset) { + const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const s = layout.scale; + const forward = new Vector3(-1, 0, 0); + const right = new Vector3(0, 0, 1); + const xMid = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5; + const deckTop = (worldOffset.y ?? 0) + layout.deckY + layout.deckThick * 0.5; + const ox = worldOffset.x; + const oz = worldOffset.z ?? 0; + + const gravity = player.gravity * s; + spawnSphereGridAt({ + mat: propMat, + center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[0].z * s), + forward, + right, + radius: PROP_SPHERE_R * s, + deckTop, + gravity, + }); + spawnBoxWallAt({ + mat: propMat, + center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[1].z * s), + right, + boxSize: PROP_BOX_SIZE * s, + deckTop, + gravity, + }); +} + +// 车辆前向 / 右向轴 +function getVehicleAxes(vehicle) { + vehicle.vehicleGroup.updateMatrixWorld(true); + const forward = vehicle.forwardLocal.clone().transformDirection(vehicle.vehicleGroup.matrixWorld).normalize(); + const right = new Vector3(0, 1, 0).cross(forward).normalize(); + return { origin: vehicle.vehicleGroup.position, forward, right }; +} + +// 障碍带中线处的放置中心 +function getPropCenter(vehicle, layout) { + const { origin, forward, right } = getVehicleAxes(vehicle); + const center = origin.clone(); + center.x = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5; + return { forward, right, center }; +} + +// 4×4 贴地球阵(相对车辆) +function spawnSphereGrid(vehicle, mat, layout) { + const { forward, right, center } = getPropCenter(vehicle, layout); + spawnSphereGridAt({ + mat, + center, + forward, + right, + radius: PROP_SPHERE_R * layout.scale, + deckTop: layout.deckY + layout.deckThick * 0.5, + }); +} + +// 4×4 贴地球阵 +function spawnSphereGridAt({ mat, center, forward, right, radius, deckTop, gravity = undefined }) { + const spacing = radius * 2; + const start = center.clone().addScaledVector(forward, -(PROP_GRID - 1) * spacing * 0.5); + const geo = new SphereGeometry(1, 24, 16); + + for (let row = 0; row < PROP_GRID; row++) { + for (let col = 0; col < PROP_GRID; col++) { + const pos = start.clone() + .addScaledVector(forward, row * spacing) + .addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * spacing); + pos.y = deckTop + radius; + const mesh = new Mesh(geo, mat); + mesh.scale.setScalar(radius); + addShadowMesh(scene, mesh); + player.addCollider({ + motion: "dynamic", + shape: { kind: "sphere", radius, position: pos }, + mesh, + restitution: 0.35, + friction: 0.55, + ...(gravity != null ? { gravity } : null), + }); + } + } +} + +// 4×4 箱墙(相对车辆) +function spawnBoxWall(vehicle, mat, layout) { + const { right, center } = getPropCenter(vehicle, layout); + spawnBoxWallAt({ + mat, + center, + right, + boxSize: PROP_BOX_SIZE * layout.scale, + deckTop: layout.deckY + layout.deckThick * 0.5, + }); +} + +// 4×4 箱墙 +function spawnBoxWallAt({ mat, center, right, boxSize, deckTop, gravity = undefined }) { + const half = boxSize * 0.5; + const start = center.clone(); + const geo = new BoxGeometry(1, 1, 1); + const halfExtents = new Vector3(half, half, half); + + for (let row = 0; row < PROP_GRID; row++) { + for (let col = 0; col < PROP_GRID; col++) { + const pos = start.clone() + .addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * boxSize); + pos.y = deckTop + boxSize * (row + 0.5); + const mesh = new Mesh(geo, mat); + mesh.scale.setScalar(boxSize); + addShadowMesh(scene, mesh); + player.addCollider({ + motion: "dynamic", + shape: { + kind: "box", + halfExtents: halfExtents.clone(), + position: pos, + }, + mesh, + restitution: 0.12, + friction: 0.7, + density: 1.2, + ...(gravity != null ? { gravity } : null), + }); + } + } +} + +// 六棱柱攀爬尺寸 +function getHexClimbMetrics(layout) { + const stairWidth = layout.stairWidth; + const rampWidth = layout.rampWidth; + const armSpan = stairWidth + rampWidth; + const platformSides = 6; + const platformApothem = armSpan / (2 * Math.tan(Math.PI / platformSides)); + const platformCircumRadius = platformApothem / Math.cos(Math.PI / platformSides); + const sideLength = 2 * platformApothem * Math.tan(Math.PI / platformSides); + return { + stairWidth, + rampWidth, + armSpan, + platformSides, + platformApothem, + platformCircumRadius, + sideLength, + topHeight: layout.l1Top, + }; +} + +// 悬空静态条板 +function createStaticStripPlatforms(baseMat, layout, parent, options = {}) { + const { sideLength, topHeight } = getHexClimbMetrics(layout); + const capsuleRadius = (player?.playerCapsule?.capsuleInfo?.radius + ?? 30 * PLAYER_SCALE_NORMAL) * layout.scale; + const gap = capsuleRadius * STATIC_STRIP_GAP_RADIUS_RATIO; + const stripDepth = capsuleRadius * STATIC_STRIP_DEPTH_RADIUS_RATIO; + const span = STATIC_STRIP_COUNT * stripDepth + + (STATIC_STRIP_COUNT - 1) * gap; + const centerX = options.centerX ?? 0; + const stripSize = new Vector3( + stripDepth, + layout.kineThickness, + sideLength, + ); + const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5; + const firstX = centerX + span * 0.5 - stripDepth * 0.5; + const root = new Group(); + root.name = "StaticStripPlatforms"; + const mat = tintMaterial(baseMat, ACCENT_GRAY); + + for (let i = 0; i < STATIC_STRIP_COUNT; i++) { + const x = firstX - i * (stripDepth + gap); + const strip = addBox( + root, + new Vector3(x, y, 0), + stripSize, + mat, + layout.tileSize, + ); + strip.name = `StaticStripPlatform${i + 1}`; + } + + parent.add(root); + player.addCollider({ + motion: "static", + shape: { kind: "mesh", source: root }, + }); + return { centerX, span }; +} + +/** 取两台运动学平台起点中心的中点。 */ +function getBalancedKinematicMeetCenterX(layout, spinStartBoundaryX) { + const { sideLength, platformApothem } = getHexClimbMetrics(layout); + const half = sideLength * 0.5; + const farEastX = -platformApothem - half; + const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5; + const farWestX = deckWestX + half; + const spinStartX = Number.isFinite(spinStartBoundaryX) + ? spinStartBoundaryX + half + : farWestX; + return (farEastX + spinStartX) * 0.5; +} + +// 运动学往返平台 +function createKinematicPlatforms(baseMat, layout, parent, options = {}) { + const { sideLength, topHeight, platformApothem } = getHexClimbMetrics(layout); + const size = new Vector3(sideLength, layout.kineThickness, sideLength); + const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5; + const half = sideLength * 0.5; + const mat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const prefix = layout.scale === 1 ? "Kine" : "MiniKine"; + + const farEastX = -platformApothem - half; + const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5; + const farWestX = deckWestX + half; + const slopeMeetCenterX = options.slopeMeetCenterX; + const slopeWestX = options.slopeWestX; + const defaultMeetX = getBalancedKinematicMeetCenterX(layout, slopeMeetCenterX); + const meetCenterX = options.meetCenterX ?? defaultMeetX; + const middleStripSpan = Math.max(0, options.middleStripSpan ?? 0); + + // 同一相位 p:p=1 时前两台分别停靠在静态条板两侧;p=0 时后两台在收腰起点衔接。 + const farEast = new Vector3(farEastX, y, 0); + // 有静态条板时,两台平台分别停靠在条板组东西两侧;否则保持原来的相邻停靠。 + const meetEast = new Vector3(meetCenterX + middleStripSpan * 0.5 + half, y, 0); + const spinFrom = Number.isFinite(slopeMeetCenterX) + ? new Vector3(slopeMeetCenterX + half, y, 0) + : new Vector3(farWestX, y, 0); + const spinTo = new Vector3(meetCenterX - middleStripSpan * 0.5 - half, y, 0); + + addKinematicShuttle({ + parent, + name: `${prefix}Shuttle`, + from: farEast, + to: meetEast, + size, + mat, + spinSpeed: 0, + tileSize: layout.tileSize, + }); + addKinematicShuttle({ + parent, + name: `${prefix}ShuttleSpin`, + from: spinFrom, + to: spinTo, + size, + mat, + spinSpeed: KINE_SPIN_SPEED, + tileSize: layout.tileSize, + }); + + if (Number.isFinite(slopeMeetCenterX) && Number.isFinite(slopeWestX)) { + const slopeScaleFrom = 1; + const slopeScaleTo = MINI_SCALE; + addKinematicShuttle({ + parent, + name: `${prefix}ShuttleSlope`, + // 起点东边缘贴收腰段,终点西边缘以缩小后的半宽贴小场景边界。 + from: new Vector3(slopeMeetCenterX - half * slopeScaleFrom, y, 0), + to: new Vector3( + slopeWestX + half * slopeScaleTo, + y * slopeScaleTo, + 0, + ), + size, + mat, + spinSpeed: 0, + tileSize: layout.tileSize, + zoneScale: true, + scaleFrom: slopeScaleFrom, + scaleTo: slopeScaleTo, + }); + } +} + +// 创建单个往返平台 +function addKinematicShuttle({ + parent, + name, + from, + to, + size, + mat, + spinSpeed, + tileSize, + zoneScale = false, + scaleFrom = 1, + scaleTo = scaleFrom, +}) { + const mesh = addBox(parent, from.clone(), size, mat, tileSize); + mesh.name = name; + mesh.scale.setScalar(scaleFrom); + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: mesh }, + follow: mesh, + }); + const entry = { + mesh, + from: from.clone(), + to: to.clone(), + spinSpeed, + zoneScale, + scaleFrom, + scaleTo, + lastZoneScale: scaleFrom, + }; + kinematicPlatforms.push(entry); +} + +/** 收腰段运动学平台。 */ +function applyKinematicZoneScale(entry, targetScale) { + if (!entry?.mesh || !Number.isFinite(targetScale)) return; + if (isNearScale(entry.lastZoneScale, targetScale)) return; + entry.mesh.scale.setScalar(targetScale); + entry.lastZoneScale = targetScale; +} + +// 两端缓入缓出 +function easeEndsLinearMiddle(progress, easeRatio = 0.18) { + const ease = Math.min(Math.max(easeRatio, 0.001), 0.49); + const maxSpeed = 1 / (1 - ease); + if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease); + if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease); + return maxSpeed * (progress - ease / 2); +} + +// 更新运动学平台 +function updateKinematicPlatforms(t) { + const phase = (t * KINE_SHUTTLE_SPEED / Math.PI) % 2; + const raw = phase <= 1 ? phase : 2 - phase; + const p = easeEndsLinearMiddle(raw); + + for (const entry of kinematicPlatforms) { + entry.mesh.position.lerpVectors(entry.from, entry.to, p); + if (entry.zoneScale) { + const targetScale = entry.scaleFrom + (entry.scaleTo - entry.scaleFrom) * p; + applyKinematicZoneScale(entry, targetScale); + } + if (entry.spinSpeed) entry.mesh.rotation.y = t * entry.spinSpeed; + } +} + +// ── 角色与发型切换 ── + +// glTF 缓存加载:来回切换时不重复下载 +function loadGltfCached(url) { + if (!gltfCache.has(url)) gltfCache.set(url, gltfLoader.loadAsync(url)); + return gltfCache.get(url); +} + +// 取模型下第一个蒙皮网格 +function findSkinnedMesh(root) { + let found = null; + root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; }); + return found; +} + +// 找出两条手臂的骨骼链 +function resolveArmChains(root) { + const chains = []; + for (const side of ARM_SIDES) { + const upper = root?.getObjectByName(side.upper); + const lower = root?.getObjectByName(side.lower); + const hand = root?.getObjectByName(side.hand); + if (upper && lower && hand) chains.push({ upper, lower, hand }); + } + return chains.length ? chains : null; +} + +/** + * 在骨骼的父空间里叠加一个世界空间旋转。 + * 世界朝向 W = P · L,要得到 W' = R · W,则 L' = (P⁻¹ · R · P) · L。 + */ +function rotateBoneWorld(bone, axisWorld, angle) { + if (!bone.parent || Math.abs(angle) < 1e-6 || axisWorld.lengthSq() < 1e-8) return; + armTmp.qWorld.setFromAxisAngle(axisWorld.normalize(), angle); + bone.parent.getWorldQuaternion(armTmp.qParent); + armTmp.qLocal.copy(armTmp.qParent).invert().multiply(armTmp.qWorld).multiply(armTmp.qParent); + bone.quaternion.premultiply(armTmp.qLocal); + bone.updateMatrixWorld(true); +} + +/** + * 动画更新之后微调手臂。旋转轴每帧从当前姿态现算, + * 因此与具体动作无关:手臂已经垂直或已经伸直时叉乘趋零,自动不再作用。 + */ +function applyArmAdjust() { + if (!armChains) return; + const hang = armAdjust.hang * Math.PI / 180; + const straighten = armAdjust.straighten * Math.PI / 180; + if (!hang && !straighten) return; + + for (const { upper, lower, hand } of armChains) { + if (hang) { + upper.getWorldPosition(armTmp.a); + lower.getWorldPosition(armTmp.b); + armTmp.dir.subVectors(armTmp.b, armTmp.a); + if (armTmp.dir.lengthSq() > 1e-8) { + armTmp.axis.crossVectors(armTmp.dir.normalize(), ARM_DOWN); + rotateBoneWorld(upper, armTmp.axis, hang); + } + } + if (straighten) { + upper.getWorldPosition(armTmp.a); + lower.getWorldPosition(armTmp.b); + hand.getWorldPosition(armTmp.c); + armTmp.dir.subVectors(armTmp.b, armTmp.a).normalize(); + armTmp.fore.subVectors(armTmp.c, armTmp.b).normalize(); + armTmp.axis.crossVectors(armTmp.dir, armTmp.fore); + if (armTmp.axis.lengthSq() > 1e-8) rotateBoneWorld(lower, armTmp.axis, -straighten); + } + } +} + +// 记录骨骼静止姿态(位移 + 旋转);必须在 mixer 跑过之前采集 +function collectRestPose(root) { + const rest = new Map(); + root?.traverse((obj) => { + if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() }); + }); + return rest; +} + +// 轨道是否随时间变化 +function trackVaries(track, stride) { + const v = track.values; + for (let i = stride; i < v.length; i++) { + if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true; + } + return false; +} + +/** + * 把 ual.glb 的动画适配到别的体型。 + * Blender 导出器给全部 65 根骨骼都烤了 translation / scale 轨道, + * 直接播放会把 mannequin 的骨骼位置强加给别的角色,肩宽和四肢比例随之被改写。 + * 这里只保留旋转,丢掉常量位移与全部缩放,唯一真正变化的 pelvis 位移重定到目标静止姿态。 + */ +function retargetClips(clips, targetRest) { + if (!sourceRestPose) return clips; + return clips.map((clip) => { + const tracks = []; + for (const track of clip.tracks) { + const dot = track.name.lastIndexOf("."); + const bone = track.name.slice(0, dot); + const prop = track.name.slice(dot + 1); + + const from = sourceRestPose.get(bone); + const to = targetRest.get(bone); + + if (prop === "quaternion") { + if (!from || !to) { tracks.push(track); continue; } + // 两套骨架静止朝向不同(锁骨差 9.5°、颈部差 23°), + // 直接套用绝对旋转会把目标骨架拧歪,故只搬运相对静止姿态的差量 + retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert()); + if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; } + const q = Float32Array.from(track.values); + for (let i = 0; i + 3 < q.length; i += 4) { + retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr); + q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y; + q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w; + } + tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q)); + continue; + } + + if (prop !== "position") continue; // scale 一律丢弃 + if (!trackVaries(track, 3)) continue; // 常量位移即静止姿态,丢弃 + if (!from || !to) { tracks.push(track); continue; } + + const values = Float32Array.from(track.values); + for (let i = 0; i + 2 < values.length; i += 3) { + values[i] += to.position.x - from.position.x; + values[i + 1] += to.position.y - from.position.y; + values[i + 2] += to.position.z - from.position.z; + } + tracks.push(new VectorKeyframeTrack(track.name, track.times, values)); + } + return new AnimationClip(clip.name, clip.duration, tracks); + }); +} + +// 按角色缓存适配后的动画 +function clipsForModel(name, model) { + if (!clipCache.has(name)) clipCache.set(name, retargetClips(playerAnimations, collectRestPose(model))); + return clipCache.get(name); +} + +// 量出躯干实际半宽,换算成贴合网格的 capsuleRadiusRatio +// 模型为 T-Pose,直接取包围盒会把手臂算进去,故按蒙皮权重只取躯干顶点 +function measureTorsoRadiusRatio(root) { + const mesh = findSkinnedMesh(root); + const pos = mesh?.geometry?.attributes?.position; + const skinIndex = mesh?.geometry?.attributes?.skinIndex; + const skinWeight = mesh?.geometry?.attributes?.skinWeight; + if (!mesh || !pos || !skinIndex || !skinWeight) return 1; + + const torso = new Set(); + mesh.skeleton?.bones.forEach((bone, i) => { + if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i); + }); + if (!torso.size) return 1; + + let halfWidth = 0; + for (let v = 0; v < pos.count; v++) { + let dominant = 0; + let best = -1; + for (let k = 0; k < 4; k++) { + const w = skinWeight.getComponent(v, k); + if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); } + } + if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v))); + } + if (halfWidth <= 0) return 1; + + root.updateMatrixWorld(true); + const height = new Box3().setFromObject(root).getSize(new Vector3()).y; + if (!(height > 0)) return 1; + return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height); +} + +// 换角色:网格来自角色文件,动画始终来自 ual.glb +async function applyCharacter(name) { + const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0]; + const gltf = await loadGltfCached(entry.url); + // 每次都用新副本:控制器会就地缩放模型,复用同一对象会叠加缩放 + const model = cloneSkeleton(gltf.scene); + const radiusRatio = measureTorsoRadiusRatio(model) * currentHitboxScale; + currentHeightFactor = characterHeights.get(entry.name) ?? entry.heightFactor ?? 1; + await player.switchPlayerModel({ + ...PLAYER_MODEL_BASE, + model, + animations: clipsForModel(entry.name, model), + scale: playerScaleForZone(), + capsuleRadiusRatio: radiusRatio, + }); + currentCharacterName = entry.name; + Object.assign(armAdjust, characterArms.get(entry.name) ?? { hang: 0, straighten: 0 }); + armChains = resolveArmChains(player.playerModel); + syncCharacterGui?.(); + enableModelShadows(player.playerModel); + currentHairMesh = null; // 旧发型随旧模型一并被移除 + await applyHair(currentHairName); +} + +// 换发型:与角色共用骨架,骨骼顺序一致且绑定矩阵均为单位阵,可直接绑定 +async function applyHair(name) { + currentHairName = name; + if (currentHairMesh) { + currentHairMesh.removeFromParent(); + currentHairMesh = null; + } + if (name === HAIR_NONE) return; + + const gltf = await loadGltfCached(`${CHARACTER_DIR}${name}.gltf`); + const hair = findSkinnedMesh(cloneSkeleton(gltf.scene)); + const target = findSkinnedMesh(player.playerModel); + if (!hair || !target) { + console.warn("[showcase] 发型或角色缺少蒙皮网格:", name); + return; + } + + hair.name = name; + hair.position.set(0, 0, 0); + hair.quaternion.identity(); + hair.scale.set(1, 1, 1); + target.parent.add(hair); + hair.bind(target.skeleton, hair.bindMatrix); + // 重绑骨架后包围盒失效,关闭视锥剔除避免发型被误剔除 + hair.frustumCulled = false; + enableModelShadows(hair); + currentHairMesh = hair; +} + +// GUI 入口:切换期间加锁,避免并发切换打架 +async function switchCharacter(name, force = false) { + if (characterBusy || (!force && name === currentCharacterName)) return; + characterBusy = true; + try { await applyCharacter(name); } + catch (e) { console.error("切换角色失败:", e); } + finally { characterBusy = false; } +} + +async function switchHair(name) { + if (characterBusy || name === currentHairName) return; + characterBusy = true; + try { await applyHair(name); } + catch (e) { console.error("切换发型失败:", e); } + finally { characterBusy = false; } +} + +// 东侧巡逻甲板:栈桥 + 楼梯 / 坡道 + 上层甲板 +function buildPatrolDeck(baseMat, layout) { + const { platform, wallH, wallT, l1Top, deckY, deckThick, tileSize } = layout; + const root = new Group(); + root.name = "PatrolDeck"; + const decor = new Group(); + decor.name = "PatrolDeckDecor"; + + const outerX = platform.x + platform.sizeX * 0.5 + wallT; // 东墙外缘 + const bridgeEndX = outerX + PATROL_BRIDGE_LEN; + const climbRun = l1Top / Math.tan(PATROL_CLIMB_ANGLE * Math.PI / 180); + const deckStartX = bridgeEndX + climbRun; + const deckEndX = deckStartX + PATROL_DECK_LEN; + const deckCenterX = (deckStartX + deckEndX) * 0.5; + const halfGap = PATROL_GAP_WIDTH * 0.5; + const halfDeckZ = PATROL_DECK_WIDTH * 0.5; + + // 豁口外栈桥,与主平台同高 + addBox( + root, + new Vector3((outerX + bridgeEndX) * 0.5, deckY, 0), + new Vector3(PATROL_BRIDGE_LEN, deckThick, PATROL_GAP_WIDTH), + baseMat, + tileSize, + ); + + // 南半侧楼梯 / 北半侧坡道 + addPatrolStairs(root, baseMat, layout, bridgeEndX, climbRun, halfGap, -halfGap * 0.5); + addPatrolRamp(root, baseMat, layout, bridgeEndX, climbRun, halfGap, halfGap * 0.5); + + // 上层甲板:实心块,避免悬空板 + addBox( + root, + new Vector3(deckCenterX, l1Top * 0.5, 0), + new Vector3(PATROL_DECK_LEN, l1Top, PATROL_DECK_WIDTH), + baseMat, + tileSize, + ); + + // 甲板围墙:北 / 南 / 东,西侧向坡道敞开 + const wallY = l1Top + wallH * 0.5; + addBox(root, new Vector3(deckCenterX, wallY, halfDeckZ + wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(deckCenterX, wallY, -halfDeckZ - wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize); + addBox(root, new Vector3(deckEndX + wallT * 0.5, wallY, 0), new Vector3(wallT, wallH, PATROL_DECK_WIDTH), baseMat, tileSize); + + // 巡逻道标线:只做视觉,不参与碰撞 + const fromX = deckStartX + PATROL_END_MARGIN; + const toX = deckEndX - PATROL_END_MARGIN; + const markMat = tintMaterial(baseMat, PROP_ACCENT_COLOR); + const lanes = []; + for (let i = 0; i < PATROL_NPC_COUNT; i++) { + const z = (i - (PATROL_NPC_COUNT - 1) * 0.5) * PATROL_LANE_GAP; + lanes.push(z); + const mark = addBox( + decor, + new Vector3((fromX + toX) * 0.5, l1Top + 0.005, z), + new Vector3(toX - fromX, 0.01, PATROL_LANE_MARK_WIDTH), + markMat, + tileSize, + ); + mark.castShadow = false; + } + + return { root, decor, deckTop: l1Top, fromX, toX, lanes }; +} + +// 栈桥尽头的上行楼梯 +function addPatrolStairs(root, baseMat, layout, startX, run, width, z) { + const { l1Top, tileSize } = layout; + const stepHeight = l1Top / PATROL_STEP_COUNT; + const stepDepth = run / PATROL_STEP_COUNT; + + for (let i = 0; i < PATROL_STEP_COUNT; i++) { + const height = stepHeight * (i + 1); + addBox( + root, + new Vector3(startX + stepDepth * (i + 0.5), height * 0.5, z), + new Vector3(stepDepth, height, width), + baseMat, + tileSize, + ); + } +} + +// 栈桥尽头的上行坡道 +function addPatrolRamp(root, baseMat, layout, startX, run, width, z) { + const { l1Top, tileSize } = layout; + const origin = new Vector3(startX + run * 0.5, 0, z); + const ramp = new Mesh(createRampGeometry(run, l1Top, width, origin, 0, tileSize), baseMat); + ramp.position.copy(origin); + return addShadowMesh(root, ramp); +} + +// 巡逻 NPC:玩家 rig 副本,在甲板上来回行走 +function spawnPatrolNPCs(playerGltf, patrol) { + const walkClip = AnimationClip.findByName(playerGltf.animations, "Walk_Loop"); + if (!walkClip || !player?.playerModel) { + console.warn("[showcase] 缺少 Walk_Loop 或玩家模型,跳过巡逻 NPC"); + return; + } + + // 尺寸与速度全部取自玩家,保证 NPC 与玩家同体型、同步幅 + const modelScale = player.playerModel.scale.x; + const radius = player.playerCapsule?.capsuleInfo?.radius ?? 30 * PLAYER_SCALE_NORMAL; + const speed = Number.isFinite(player.playerSpeed) + ? player.playerSpeed + : PATROL_WALK_SPEED_RAW * PLAYER_SCALE_NORMAL; + const span = patrol.toX - patrol.fromX; + const proxyMat = new MeshStandardMaterial(); + const bbox = new Box3(); + + for (let i = 0; i < patrol.lanes.length; i++) { + const group = new Group(); + group.name = `PatrolNPC${i + 1}`; + + // SkinnedMesh 必须用 SkeletonUtils.clone,Object3D.clone 会共用骨架 + const model = cloneSkeleton(playerGltf.scene); + model.scale.setScalar(modelScale); + model.updateMatrixWorld(true); + bbox.setFromObject(model); + const bodyHeight = bbox.max.y - bbox.min.y; + model.position.y = -bbox.min.y; // 脚底贴甲板 + group.add(model); + + // 不可见碰撞代理:与模型同级,不被模型缩放带走 + const proxy = new Mesh(new BoxGeometry(radius * 2, bodyHeight, radius * 2), proxyMat); + proxy.name = `PatrolNPCBody${i + 1}`; + proxy.position.y = bodyHeight * 0.5; + proxy.visible = false; + group.add(proxy); + + // 相位错开,避免整齐划一 + const phase = i / patrol.lanes.length; + group.position.set(patrol.fromX + span * phase, patrol.deckTop, patrol.lanes[i]); + group.rotation.y = Math.PI / 2; // 控制器约定:本地 +Z 指向移动方向 + scene.add(group); + group.updateMatrixWorld(true); + enableModelShadows(model); + + const mixer = new AnimationMixer(model); + const action = mixer.clipAction(walkClip); + action.time = walkClip.duration * phase; + action.play(); + + player.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: proxy }, + follow: proxy, + }); + + patrolNPCs.push({ + group, + mixer, + speed, + fromX: patrol.fromX, + toX: patrol.toX, + dir: 1, + turnLeft: 0, + turnFrom: 0, + turnTo: 0, + }); + } +} + +// 推进巡逻 NPC:匀速直行,到端点掉头 +function updatePatrolNPCs(delta) { + if (!(delta > 0)) return; + + for (const npc of patrolNPCs) { + npc.mixer.update(delta); + + if (npc.turnLeft > 0) { + npc.turnLeft = Math.max(0, npc.turnLeft - delta); + const p = 1 - npc.turnLeft / PATROL_TURN_TIME; + npc.group.rotation.y = npc.turnFrom + (npc.turnTo - npc.turnFrom) * easeEndsLinearMiddle(p, 0.4); + if (npc.turnLeft === 0) npc.group.rotation.y = npc.turnTo % (Math.PI * 2); + continue; + } + + npc.group.position.x += npc.dir * npc.speed * delta; + if (npc.dir > 0 && npc.group.position.x >= npc.toX) { + npc.group.position.x = npc.toX; + startPatrolTurn(npc, -1); + } else if (npc.dir < 0 && npc.group.position.x <= npc.fromX) { + npc.group.position.x = npc.fromX; + startPatrolTurn(npc, 1); + } + } +} + +// 端点掉头:绕 Y 轴转半圈 +function startPatrolTurn(npc, dir) { + npc.dir = dir; + npc.turnLeft = PATROL_TURN_TIME; + npc.turnFrom = npc.group.rotation.y; + npc.turnTo = npc.turnFrom + Math.PI; +} + +// 创建攀爬组与中心平台 +function createClimbGroups(root, baseMat, layout) { + const { + topHeight, + stairWidth, + rampWidth, + platformSides, + platformApothem, + platformCircumRadius, + } = getHexClimbMetrics(layout); + const stepCount = 7; + const stepHeight = topHeight / stepCount; + const tileSize = layout.tileSize; + + const platformPos = new Vector3(0, topHeight * 0.5, 0); + const platformGeo = applyWorldTileUV( + new CylinderGeometry(platformCircumRadius, platformCircumRadius, topHeight, platformSides), + platformPos, + tileSize, + ); + const platform = new Mesh(platformGeo, baseMat); + platform.position.copy(platformPos); + addShadowMesh(root, platform); + + for (let index = 0; index < 3; index++) { + const arm = new Group(); + arm.rotation.y = index * (Math.PI * 2 / 3); + root.add(arm); + + const angleDeg = SLOPE_ANGLES[index]; + const stairZ = -stairWidth * 0.5; + const rampZ = rampWidth * 0.5; + const runLength = topHeight / Math.tan(angleDeg * Math.PI / 180); + const stepDepth = runLength / stepCount; + + createStairsToPlatform(arm, { + platformRadius: platformApothem, + stepHeight, + stepCount, + stepDepth, + width: stairWidth, + z: stairZ, + baseMat, + tileSize, + }); + + createRampToPlatform(arm, { + platformRadius: platformApothem, + angleDeg, + length: runLength, + width: rampWidth, + z: rampZ, + baseMat, + tileSize, + }); + } +} + +// 创建一组楼梯 +function createStairsToPlatform(root, { platformRadius, stepHeight, stepCount, stepDepth, width, z, baseMat, tileSize }) { + const stairRun = stepCount * stepDepth; + const outer = platformRadius + stairRun; + + for (let i = 0; i < stepCount; i++) { + const height = stepHeight * (i + 1); + const x = outer - (i + 0.5) * stepDepth; + addBox( + root, + new Vector3(x, height / 2, z), + new Vector3(stepDepth, height, width), + baseMat, + tileSize, + ); + } +} + +// 创建一个斜坡 +function createRampToPlatform(root, { platformRadius, angleDeg, length, width, z, baseMat, tileSize }) { + const height = Math.tan(angleDeg * Math.PI / 180) * length; + const origin = new Vector3(platformRadius + length * 0.5, 0, z); + const rotY = Math.PI; + const ramp = new Mesh(createRampGeometry(length, height, width, origin, rotY, tileSize), baseMat); + ramp.position.copy(origin); + ramp.rotation.y = rotY; + return addShadowMesh(root, ramp); +} + +// 创建斜坡几何体。 +function createRampGeometry(length, height, width, origin = { x: 0, y: 0, z: 0 }, rotY = 0, tileSize = TILE_SIZE) { + const halfLength = length / 2; + const halfWidth = width / 2; + const cosY = Math.cos(rotY); + const sinY = Math.sin(rotY); + const tile = Math.max(tileSize, 1e-6); + const vertices = [ + -halfLength, 0, -halfWidth, + halfLength, 0, -halfWidth, + halfLength, height, -halfWidth, + -halfLength, 0, halfWidth, + halfLength, 0, halfWidth, + halfLength, height, halfWidth, + ]; + const indices = [ + 0, 1, 4, 0, 4, 3, + 1, 2, 5, 1, 5, 4, + 0, 3, 5, 0, 5, 2, + 0, 2, 1, + 3, 4, 5, + ]; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + const flat = geometry.toNonIndexed(); + const pos = flat.getAttribute("position"); + const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2); + + for (let t = 0; t < pos.count; t += 3) { + const ax = pos.getX(t); + const ay = pos.getY(t); + const az = pos.getZ(t); + const bx = pos.getX(t + 1); + const by = pos.getY(t + 1); + const bz = pos.getZ(t + 1); + const cx = pos.getX(t + 2); + const cy = pos.getY(t + 2); + const cz = pos.getZ(t + 2); + + const e1x = bx - ax; + const e1y = by - ay; + const e1z = bz - az; + const e2x = cx - ax; + const e2y = cy - ay; + const e2z = cz - az; + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + const nLen = Math.hypot(nx, ny, nz) || 1; + const nnx = nx / nLen; + const nny = ny / nLen; + const nnz = nz / nLen; + + for (let i = 0; i < 3; i++) { + const idx = t + i; + const lx = pos.getX(idx); + const ly = pos.getY(idx); + const lz = pos.getZ(idx); + const x = origin.x + lx * cosY + lz * sinY; + const y = origin.y + ly; + const z = origin.z - lx * sinY + lz * cosY; + let u = 0; + let v = 0; + + if (Math.abs(nnz) > 0.7) { + u = Math.hypot(lx + halfLength, ly); + v = y; + } else if (Math.abs(nnx) > 0.7) { + u = z; + v = y; + } else if (Math.abs(nny) > 0.7 && nny < 0) { + u = x; + v = z; + } else { + u = Math.hypot(lx + halfLength, ly); + v = z; + } + uv.setXY(idx, u / tile, v / tile); + } + } + + flat.setAttribute("uv", uv); + flat.computeVertexNormals(); + return flat; +} + +// 添加盒子 +function addBox(root, position, size, material, tileSize = TILE_SIZE) { + const geo = applyWorldTileUV(new BoxGeometry(size.x, size.y, size.z), position, tileSize); + const mesh = new Mesh(geo, material); + mesh.position.copy(position); + return addShadowMesh(root, mesh); +} + +// 统一启用网格阴影并挂到场景节点 +function addShadowMesh(root, mesh) { + mesh.castShadow = true; + mesh.receiveShadow = true; + root.add(mesh); + return mesh; +} + +// 启用模型阴影 +function enableModelShadows(root) { + root?.traverse((obj) => { + if (obj.isMesh) { + obj.castShadow = true; + obj.receiveShadow = true; + } + }); +} + +// 将调参写回场上全部车辆 +function applyVehicleTuning() { + const t = VEHICLE_TUNING; + const vehicles = player?.getAllVehicles?.() ?? []; + for (const v of vehicles) { + player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale); + player?.setVehicleClearance?.(v, t.chassis.clearance); + const he = v.halfExtents; + const volume = 8 * he.x * he.y * he.z; + v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density)); + v.chassisBody.linearDamping = t.chassis.linearDamping; + v.chassisBody.angularDamping = t.chassis.angularDamping; + + Object.assign(v.steering, t.steering); + Object.assign(v.grip, t.grip); + v.maxSpeed = t.power.maxSpeed * v.scale; + v.acceleration = t.power.acceleration * v.scale; + v.deceleration = t.power.deceleration * v.scale; + v.followVehicleDirection = t.followVehicleDirection; + + const sus = t.suspension; + const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness)); + const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2); + v.sideFrictionStiffness = sus.sideFrictionStiffness; + + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + v.vehicleController.setWheelSuspensionRestLength(i, rest); + v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale); + v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness); + v.vehicleController.setWheelSuspensionCompression(i, sus.compression); + v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation); + v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce); + v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip); + v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness); + v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence); + } + + if (v.physicsBoxMesh) { + if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh); + else v.vehicleGroup.remove(v.physicsBoxMesh); + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel; + if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres; + } + if (player?.vehicle?.params?.debug) { + Object.assign(player.vehicle.params.debug, t.debug); + } +} + +// 创建调试面板 +function createDebugPanel() { + const options = footIK.getOptions(); + const playerScale = player.playerModelConfig.scale; + const toBasePlayerValue = (value) => value / playerScale; + const params = { + colliderDebug: false, + playerCapsuleDebug: false, + character: currentCharacterName, + hair: currentHairName, + height: currentHeightFactor, + hitboxScale: currentHitboxScale, + armHang: armAdjust.hang, + armStraighten: armAdjust.straighten, + dynamicBodyDebug: false, + mouseSensitivity: player.cam.sensitivity, + gravity: toBasePlayerValue(player.gravity), + jumpHeight: toBasePlayerValue(player.jumpHeight), + playerSpeed: toBasePlayerValue(player.playerSpeed), + playerRunSpeed: toBasePlayerValue(player.playerRunSpeed), + flySpeed: toBasePlayerValue(player.playerFlySpeed), + playerAcceleration: player.playerAcceleration, + playerDeceleration: player.playerDeceleration, + timeScale: player.timeScale, + minCamDistance: toBasePlayerValue(player.cam.minDist), + maxCamDistance: toBasePlayerValue(player.cam.maxDist), + camLookAtHeightRatio: player.cam.lookAtHeightRatio, + enableOverShoulderView: player.enableOverShoulderView, + camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio, + enableSpringCamera: player.cam.enableSpringCamera, + springCameraTime: player.cam.springCameraTime, + thirdMouseMode: player.cam.mouseMode, + enableZoom: player.cam.zoomEnabled, + footIKEnabled: options.enabled, + footIKDebug: options.debug, + predictivePlacement: options.predictivePlacement, + straightPoleEnabled: options.straightPoleEnabled, + pelvisOffset: 0, + leftFootPhase: "", + leftFootLandIK: "-- / 0.00", + leftPrediction: "disabled", + leftPredictionScore: "--", + rightFootPhase: "", + rightFootLandIK: "-- / 0.00", + rightPrediction: "disabled", + rightPredictionScore: "--", + maxPelvisRaise: options.maxPelvisRaise, + maxPelvisDrop: options.maxPelvisDrop, + maxFootRaise: options.maxFootRaise, + maxFootDrop: options.maxFootDrop, + plantedHeightSpeed: options.plantedHeightSpeed, + penetrationLiftSpeed: options.penetrationLiftSpeed, + soleHalfWidth: options.soleHalfWidth, + soleToeExtend: options.soleToeExtend, + soleHeelExtend: options.soleHeelExtend, + soleSkinThickness: options.soleSkinThickness, + maxPredictionClearance: options.maxPredictionClearance, + maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI, + }; + footIKDebugParams = params; + + const applyFootIKOptions = (patch) => { + footIK.configure(patch); + }; + + footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled); + + const gui = new GUI({ title: "Showcase Controls", width: 320 }); + if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close(); + Object.assign(gui.domElement.style, { + position: "fixed", + top: "12px", + right: "12px", + maxHeight: "calc(100vh - 24px)", + overflowY: "auto", + }); + gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation()); + + const collisionFolder = gui.addFolder("Collision Debug"); + collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => { + player?.setColliderDebug(value); + }); + collisionFolder.add(params, "dynamicBodyDebug").name("Dynamic Bodies").onChange((value) => { + player?.setDynamicBodyDebug?.(value); + }); + collisionFolder.open(); + + const characterFolder = gui.addFolder("Character"); + const modelFolder = characterFolder.addFolder("Model"); + modelFolder.add(params, "character", CHARACTERS.map((c) => c.name)).name("Character") + .onChange((value) => { void switchCharacter(value); }); + modelFolder.add(params, "hair", [HAIR_NONE, ...HAIRSTYLES]).name("Hair") + .onChange((value) => { void switchHair(value); }); + const heightCtrl = modelFolder.add(params, "height", 0.7, 1.3, 0.01).name("Height") + .onChange((value) => { + characterHeights.set(currentCharacterName, value); + currentHeightFactor = value; + applyPlayerScale(playerScaleForZone()); + }); + // 半径只在加载模型时读取,故松手后再重载当前角色 + modelFolder.add(params, "hitboxScale", 0.5, 2, 0.05).name("Hitbox Width") + .onFinishChange((value) => { + currentHitboxScale = value; + void switchCharacter(currentCharacterName, true); + }); + modelFolder.add(params, "playerCapsuleDebug").name("Show Hitbox").onChange((value) => { + player?.setPlayerCapsuleDebug?.(value); + }); + const armHangCtrl = modelFolder.add(params, "armHang", -20, 20, 0.5).name("Arm Hang") + .onChange((value) => { + armAdjust.hang = value; + if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 }); + characterArms.get(currentCharacterName).hang = value; + }); + const armStraightenCtrl = modelFolder.add(params, "armStraighten", -20, 30, 0.5).name("Arm Straighten") + .onChange((value) => { + armAdjust.straighten = value; + if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 }); + characterArms.get(currentCharacterName).straighten = value; + }); + + // 换角色后把记住的数值写回滑杆 + syncCharacterGui = () => { + params.height = currentHeightFactor; + params.armHang = armAdjust.hang; + params.armStraighten = armAdjust.straighten; + heightCtrl.updateDisplay(); + armHangCtrl.updateDisplay(); + armStraightenCtrl.updateDisplay(); + }; + + const movementFolder = characterFolder.addFolder("Movement"); + movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => { + player?.setGravity(value); + }); + movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => { + player?.setJumpHeight(value); + }); + movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => { + player?.setPlayerSpeed(value); + }); + movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => { + player?.setPlayerRunSpeed(value); + }); + movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => { + player?.setPlayerFlySpeed(value); + }); + movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => { + if (player) player.playerAcceleration = value; + }); + movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => { + if (player) player.playerDeceleration = value; + }); + movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => { + if (player) player.timeScale = value; + }); + movementFolder.close(); + + const characterCameraFolder = characterFolder.addFolder("Camera"); + characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => { + player?.setMouseSensitivity(value); + }); + characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => { + player?.setMinCamDistance(value); + }); + characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => { + player?.setMaxCamDistance(value); + }); + characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => { + player?.setCamLookAtHeightRatio(value); + }); + characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => { + player?.setOverShoulderView(value); + }); + characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => { + player?.setCamOverShoulderOffsetRatio(value); + }); + characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => { + if (player) player.cam.enableSpringCamera = value; + }); + characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => { + if (player) player.cam.springCameraTime = value; + }); + characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => { + player?.setThirdMouseMode(Number(value)); + }); + characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => { + player?.setEnableZoom(value); + }); + characterCameraFolder.close(); + + const footIKFolder = characterFolder.addFolder("Foot IK"); + footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => { + footIK.setEnabled(value); + footIK.setDebugEnabled(value && params.footIKDebug); + }); + footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => { + footIK.setDebugEnabled(value && params.footIKEnabled); + }); + footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => { + footIK.configure({ predictivePlacement: value }); + }); + footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => { + footIK.configure({ straightPoleEnabled: value }); + }); + + const footIKRuntimeFolder = footIKFolder.addFolder("Runtime"); + footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable(); + footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable(); + footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable(); + footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable(); + footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable(); + footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable(); + footIKRuntimeFolder.close(); + + const pelvisFolder = footIKFolder.addFolder("Pelvis"); + pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisRaise: value }); + }); + pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPelvisDrop: value }); + }); + pelvisFolder.close(); + + const footReachFolder = footIKFolder.addFolder("Foot Reach"); + footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootRaise: value }); + }); + footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => { + applyFootIKOptions({ maxFootDrop: value }); + }); + footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ plantedHeightSpeed: value }); + }); + footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => { + applyFootIKOptions({ penetrationLiftSpeed: value }); + }); + footReachFolder.close(); + + const soleFolder = footIKFolder.addFolder("Sole Layout"); + soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHalfWidth: value }); + }); + soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleToeExtend: value }); + }); + soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => { + applyFootIKOptions({ soleHeelExtend: value }); + }); + soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => { + applyFootIKOptions({ soleSkinThickness: value }); + }); + soleFolder.close(); + + const predictionFolder = footIKFolder.addFolder("Prediction"); + predictionFolder.add(params, "maxPredictionClearance", 0, 80, 1).name("Max Clearance").decimals(0).onChange((value) => { + applyFootIKOptions({ maxPredictionClearance: value }); + }); + predictionFolder.close(); + footIKFolder.close(); + characterFolder.open(); + + const t = VEHICLE_TUNING; + const vehicleFolder = gui.addFolder("Vehicle"); + + const chassisFolder = vehicleFolder.addFolder("Chassis"); + chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning); + chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning); + chassisFolder.close(); + + const vehicleCameraFolder = vehicleFolder.addFolder("Camera"); + vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning); + vehicleCameraFolder.close(); + + const vehicleDebugFolder = vehicleFolder.addFolder("Debug"); + vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning); + vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning); + vehicleDebugFolder.close(); + + const drivingFolder = vehicleFolder.addFolder("Driving"); + drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning); + drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning); + drivingFolder.close(); + + const handlingFolder = vehicleFolder.addFolder("Handling"); + handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((deg) => { + t.steering.maxSteerAngle = deg * Math.PI / 180; + applyVehicleTuning(); + }); + handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning); + handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning); + handlingFolder.close(); + + const suspensionFolder = vehicleFolder.addFolder("Suspension"); + suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning); + suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning); + suspensionFolder.close(); + + vehicleFolder.open(); +} + +// 窗口尺寸变化 +function onResize() { + const container = document.querySelector("#container"); + if (!container || !camera || !renderer) return; + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); +} + +// 每帧调用 +function animate(timestamp) { + animTimer.update(timestamp); + const rawDelta = Math.min(animTimer.getDelta(), 1 / 40); + if (player) { + scaledAnimTime += rawDelta * player.timeScale; + updateGlowPortal(scaledAnimTime); + updateKinematicPlatforms(scaledAnimTime); + updatePatrolNPCs(rawDelta * player.timeScale); + updateTrapScale(); + player.update(rawDelta); + applyArmAdjust(); + } else { + updateGlowPortal(animTimer.getElapsed()); + controls?.update(); + } + updateFootIKDebugPanel(); + renderer.render(scene, camera); + stats?.update(); +} + +// 更新 Foot IK 运行状态只读字段。 +function updateFootIKDebugPanel() { + if (!footIK || !footIKDebugParams) return; + footIKDebugParams.pelvisOffset = footIK.getPelvisOffset(); + footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left"); + footIKDebugParams.leftFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("left"), + footIK.getFootIKWeight("left"), + ); + footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left"); + footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left")); + footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right"); + footIKDebugParams.rightFootLandIK = formatFootLandIK( + footIK.getFootTimeToLand("right"), + footIK.getFootIKWeight("right"), + ); + footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right"); + footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right")); +} + +function formatFootLandIK(landTime, ikWeight) { + const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--"; + const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--"; + return `${land} / ${weight}`; +} + +function formatPredictionScore(value) { + return Number.isFinite(value) ? value.toFixed(2) : "--"; +} diff --git a/three-player-controller/example/styles/common.css b/three-player-controller/example/styles/common.css new file mode 100644 index 0000000..d9d4979 --- /dev/null +++ b/three-player-controller/example/styles/common.css @@ -0,0 +1,343 @@ +.lil-gui.root, +.lil-gui.root .lil-gui { + --background-color: rgba(8, 12, 18, 0.8); + --text-color: #e8eef4; + --title-background-color: rgba(255, 255, 255, 0.055); + --title-text-color: #f4f7fb; + --widget-color: rgba(255, 255, 255, 0.09); + --hover-color: rgba(255, 255, 255, 0.15); + --focus-color: rgba(232, 160, 32, 0.28); + --number-color: #f0b64a; + --string-color: #79c0ff; + --font-size: 11px; + --input-font-size: 11px; + --font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + --font-family-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --padding: 6px; + --spacing: 5px; + --widget-height: 24px; + --name-width: 45%; + --folder-indent: 10px; + --widget-border-radius: 5px; +} + +.lil-gui.root { + color-scheme: dark; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: var(--background-color); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.lil-gui.root .lil-gui { + --background-color: transparent; +} + +.lil-gui.root > .title { + border-radius: 9px 9px 0 0; + color: var(--title-text-color); + letter-spacing: 0.02em; +} + +.lil-gui.root.closed > .title { + border-radius: 9px; +} + +.lil-gui.root .lil-gui > .title { + color: rgba(232, 238, 244, 0.88); +} + +.lil-gui.root .controller .name { + color: rgba(230, 238, 246, 0.78); + font-weight: 500; +} + +.lil-gui.root input, +.lil-gui.root .controller.option .display, +.lil-gui.root .controller.function button { + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.lil-gui.root input:focus-visible, +.lil-gui.root button:focus-visible, +.lil-gui.root select:focus-visible { + outline: 1px solid rgba(240, 192, 90, 0.9); + outline-offset: 1px; +} + +.lil-gui.root .controller.number .slider .fill { + background: linear-gradient(90deg, rgba(232, 160, 32, 0.18), rgba(240, 192, 90, 0.34)); +} + +.lil-gui.root input[type="checkbox"]:checked { + color: #1a1408; + background: linear-gradient(180deg, #f0c05a, #e8a020); + border-color: #f6d48a; +} + +.hud { + position: fixed; + left: 12px; + top: 12px; + z-index: 9999; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + color: #e8eef4; + background: rgba(8, 12, 18, 0.55); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(10px); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + pointer-events: none; + user-select: none; +} + +.hint-move { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-top: 1px; +} + +.hint-cluster { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.wasd { + display: grid; + grid-template-columns: repeat(3, 26px); + grid-template-rows: repeat(2, 26px); + gap: 4px; +} + +.wasd-w { + grid-column: 2; + grid-row: 1; +} + +.wasd-a { + grid-column: 1; + grid-row: 2; +} + +.wasd-s { + grid-column: 2; + grid-row: 2; +} + +.wasd-d { + grid-column: 3; + grid-row: 2; +} + +.hud kbd { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 26px; + height: 26px; + padding: 0 7px; + border-radius: 5px; + color: #f4f7fb; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)); + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom-color: rgba(0, 0, 0, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35); + font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; + transition: background 0.08s ease, border-color 0.08s ease, color 0.08s ease, box-shadow 0.08s ease, transform 0.08s ease; +} + +.hud kbd.is-down { + color: #1a1408; + background: linear-gradient(180deg, #f0c05a, #e8a020); + border-color: #f6d48a; + border-bottom-color: #b87a10; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); + transform: translateY(1px); +} + +.wasd kbd { + width: 26px; + padding: 0; +} + +.hint-actions { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 8px; + row-gap: 5px; + align-items: center; + border-left: 1px solid rgba(255, 255, 255, 0.08); + padding-left: 12px; +} + +.hint-group { + display: contents; +} + +.hint-keys { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.hint-text { + color: rgba(230, 238, 246, 0.78); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} + +.hint-mouse { + width: 20px; + height: 30px; + flex-shrink: 0; +} + +.hint-mouse-body { + position: relative; + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 10px 10px 11px 11px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)); + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom-color: rgba(0, 0, 0, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.hint-mouse-wheel { + position: absolute; + top: 6px; + left: 50%; + width: 3px; + height: 7px; + margin-left: -1.5px; + border-radius: 1px; + background: rgba(244, 247, 251, 0.35); +} + +.hint-mouse-nub { + position: absolute; + left: 50%; + top: 64%; + width: 6px; + height: 6px; + margin: -3px 0 0 -3px; + border-radius: 50%; + background: #e8a020; + box-shadow: 0 0 6px rgba(232, 160, 32, 0.55); + transform: translate(var(--look-x, 0px), var(--look-y, 0px)); + will-change: transform; +} + +.hint-mouse.is-active .hint-mouse-body { + border-color: rgba(232, 160, 32, 0.55); +} + +.hud[data-hint-mode] .hint-set { + display: none; +} + +.hud[data-hint-mode="on-foot"] .hint-set[data-for="on-foot"], +.hud[data-hint-mode="vehicle"] .hint-set[data-for="vehicle"] { + display: contents; +} + +.model-credit { + position: fixed; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + font-size: 16px; + color: #e6eef6; + background: rgba(0, 0, 0, 0.35); + padding: 6px 12px; + border-radius: 999px; + backdrop-filter: blur(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; +} + +.model-credit a { + color: #6ecbff; + text-decoration: none; +} + +.model-credit a:hover { + text-decoration: underline; +} + +.source { + position: fixed; + bottom: 16px; + right: 16px; + padding: 12px; + border-radius: 50%; + margin-bottom: 0; + background-color: #fff; + opacity: 0.9; + z-index: 999; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.15); +} + +.source img { + display: block; + width: 24px; + height: 24px; +} + +#top-notice { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + background: rgba(0, 0, 0, 0.45); + color: #e6eef6; + padding: 7px 16px; + border-radius: 8px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 13px; + line-height: 1.45; + text-align: center; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +#top-notice .credit-line { + white-space: nowrap; +} + +#top-notice a { + color: #79c0ff; + text-decoration: none; +} + +#top-notice a:hover { + text-decoration: underline; +} + +@media (hover: none) and (pointer: coarse), (max-width: 768px) { + .hud, + .source, + #top-notice, + .model-credit { + display: none !important; + } +} diff --git a/three-player-controller/example/volumeCloud.js b/three-player-controller/example/volumeCloud.js new file mode 100644 index 0000000..cc08f08 --- /dev/null +++ b/three-player-controller/example/volumeCloud.js @@ -0,0 +1,230 @@ +// 来源:https://threejs.org/examples/?q=cloud#webgl_volume_cloud +import { + BackSide, + BoxGeometry, + Color, + Data3DTexture, + GLSL3, + LinearFilter, + Mesh, + RawShaderMaterial, + RedFormat, + Vector3, +} from "three"; +import { ImprovedNoise } from "three/examples/jsm/math/ImprovedNoise.js"; + +const vertexShader = ` + in vec3 position; + + uniform mat4 modelMatrix; + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + uniform vec3 cameraPos; + + out vec3 vOrigin; + out vec3 vDirection; + + void main() { + vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 ); + + vOrigin = vec3( inverse( modelMatrix ) * vec4( cameraPos, 1.0 ) ).xyz; + vDirection = position - vOrigin; + + gl_Position = projectionMatrix * mvPosition; + } +`; + +const fragmentShader = ` + precision highp float; + precision highp sampler3D; + + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + + in vec3 vOrigin; + in vec3 vDirection; + + out vec4 color; + + uniform vec3 base; + uniform sampler3D map; + + uniform float threshold; + uniform float range; + uniform float opacity; + uniform float steps; + uniform float frame; + + uint wang_hash(uint seed) + { + seed = (seed ^ 61u) ^ (seed >> 16u); + seed *= 9u; + seed = seed ^ (seed >> 4u); + seed *= 0x27d4eb2du; + seed = seed ^ (seed >> 15u); + return seed; + } + + float randomFloat(inout uint seed) + { + return float(wang_hash(seed)) / 4294967296.; + } + + vec2 hitBox( vec3 orig, vec3 dir ) { + const vec3 box_min = vec3( - 0.5 ); + const vec3 box_max = vec3( 0.5 ); + vec3 inv_dir = 1.0 / dir; + vec3 tmin_tmp = ( box_min - orig ) * inv_dir; + vec3 tmax_tmp = ( box_max - orig ) * inv_dir; + vec3 tmin = min( tmin_tmp, tmax_tmp ); + vec3 tmax = max( tmin_tmp, tmax_tmp ); + float t0 = max( tmin.x, max( tmin.y, tmin.z ) ); + float t1 = min( tmax.x, min( tmax.y, tmax.z ) ); + return vec2( t0, t1 ); + } + + float sample1( vec3 p ) { + return texture( map, p ).r; + } + + float shading( vec3 coord ) { + float step = 0.01; + return sample1( coord + vec3( - step ) ) - sample1( coord + vec3( step ) ); + } + + vec4 linearToSRGB( in vec4 value ) { + return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a ); + } + + void main(){ + vec3 rayDir = normalize( vDirection ); + vec2 bounds = hitBox( vOrigin, rayDir ); + + if ( bounds.x > bounds.y ) discard; + + bounds.x = max( bounds.x, 0.0 ); + + float stepSize = ( bounds.y - bounds.x ) / steps; + + uint seed = uint( gl_FragCoord.x ) * uint( 1973 ) + uint( gl_FragCoord.y ) * uint( 9277 ) + uint( frame ) * uint( 26699 ); + vec3 size = vec3( textureSize( map, 0 ) ); + float randNum = randomFloat( seed ) * 2.0 - 1.0; + vec3 p = vOrigin + bounds.x * rayDir; + p += rayDir * randNum * ( 1.0 / size ); + + vec4 ac = vec4( base, 0.0 ); + + for ( float i = 0.0; i < steps; i += 1.0 ) { + float d = sample1( p + 0.5 ); + + d = smoothstep( threshold - range, threshold + range, d ) * opacity; + + float col = shading( p + 0.5 ) * 3.0 + ( ( p.x + p.y ) * 0.25 ) + 0.2; + + ac.rgb += ( 1.0 - ac.a ) * d * col; + ac.a += ( 1.0 - ac.a ) * d; + + if ( ac.a >= 0.95 ) break; + + p += rayDir * stepSize; + } + + color = linearToSRGB( ac ); + + if ( color.a == 0.0 ) discard; + } +`; + +// 生成云纹理 +function createCloudTexture(size, noiseScale) { + // 体素密度 + const data = new Uint8Array(size * size * size); + const perlin = new ImprovedNoise(); + const vector = new Vector3(); + + let i = 0; + for (let z = 0; z < size; z++) { + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const d = 1.0 - vector.set(x, y, z).subScalar(size / 2).divideScalar(size).length(); + data[i] = (128 + 128 * perlin.noise(x * noiseScale / 1.5, y * noiseScale, z * noiseScale / 1.5)) * d * d; + i++; + } + } + } + + // 上传纹理 + const texture = new Data3DTexture(data, size, size, size); + texture.format = RedFormat; + texture.minFilter = LinearFilter; + texture.magFilter = LinearFilter; + texture.unpackAlignment = 1; + texture.needsUpdate = true; + return texture; +} + +// 创建体积云 +export function createVolumeCloud(options = {}) { + // 默认参数 + const { + size = 96, + noiseScale = 0.05, + color = 0x798aa0, + threshold = 0.25, + opacity = 0.25, + range = 0.1, + steps = 80, + scale = [3, 1.5, 2.2], + } = options; + + // 构造材质 + const texture = createCloudTexture(size, noiseScale); + const material = new RawShaderMaterial({ + glslVersion: GLSL3, + uniforms: { + base: { value: new Color(color) }, + map: { value: texture }, + cameraPos: { value: new Vector3() }, + threshold: { value: threshold }, + opacity: { value: opacity }, + range: { value: range }, + steps: { value: steps }, + frame: { value: 0 }, + }, + vertexShader, + fragmentShader, + side: BackSide, + transparent: true, + depthWrite: true, + }); + + // 返回网格 + const cloud = new Mesh(new BoxGeometry(1, 1, 1), material); + cloud.name = "volumeCloud"; + cloud.scale.set(...scale); + cloud.frustumCulled = false; + return cloud; +} + +// 更新体积云 +export function updateVolumeCloud(cloud, camera, rotationSpeed = 0) { + if (!cloud?.material?.uniforms || !camera) return; + if (!cloud.material.visible) return; + + // 同步相机 + const uniforms = cloud.material.uniforms; + camera.getWorldPosition(uniforms.cameraPos.value); + uniforms.frame.value += 1; + // 可选旋转 + if (rotationSpeed !== 0) cloud.rotation.y += rotationSpeed / 60; +} + +// 释放体积云 +export function disposeVolumeCloud(cloud) { + if (!cloud) return; + + // 释放显存 + cloud.geometry?.dispose(); + cloud.material?.uniforms?.map?.value?.dispose(); + cloud.material?.dispose(); +} diff --git a/three-player-controller/package-lock.json b/three-player-controller/package-lock.json new file mode 100644 index 0000000..f3cf022 --- /dev/null +++ b/three-player-controller/package-lock.json @@ -0,0 +1,2560 @@ +{ + "name": "three-player-controller", + "version": "0.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "three-player-controller", + "version": "0.6.2", + "license": "MIT", + "devDependencies": { + "@sparkjsdev/spark": "^2.1.0", + "@types/node": "^25.0.6", + "@types/three": "^0.184.0", + "3d-tiles-renderer": "^0.4.24", + "rimraf": "^6.0.0", + "tellux": "^0.2.0", + "tsup": "^8.0.1", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "peerDependencies": { + "three": ">=0.159.0", + "three-mesh-bvh": ">=0.7.8" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dgreenheck/ez-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dgreenheck/ez-tree/-/ez-tree-1.1.0.tgz", + "integrity": "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">=0.167" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@react-three/fiber": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz", + "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sparkjsdev/spark": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sparkjsdev/spark/-/spark-2.1.0.tgz", + "integrity": "sha512-BRw+MuMzx0B3K8fDLQygt2OHEhYUV+41RX7btq9pZ3rCVrq42o57jW34VAIvC7JO/84DJh/1AutACV9ym6BfVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, + "peerDependencies": { + "three": ">=0.180.0" + } + }, + "node_modules/@takram/three-atmosphere": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@takram/three-atmosphere/-/three-atmosphere-0.19.1.tgz", + "integrity": "sha512-k/QAgED7bMkjVzHiBu2QD1Cqg5lcgHLc8vxVeiTTzUE+glZcZOXTjjvi74yjb+eKQHW5wh1WL/DjxVvluAQU0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-geospatial": "0.9.1", + "astronomy-engine": "^2.1.19", + "react-merge-refs": "^3.0.2", + "tiny-invariant": "^1.3.3", + "url-join": "^5.0.0" + }, + "peerDependencies": { + "@react-three/drei": ">=10.0.2", + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.7", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-clouds": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@takram/three-clouds/-/three-clouds-0.7.6.tgz", + "integrity": "sha512-cfoEI3W5NJuYUL1Pd4k5UXLlb7rLThh3dNIOO+/EPw8cbuRUgiIVEVtNPmA+v5KRJf87gPI+rjuxgK0huAajDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-atmosphere": "0.19.1", + "@takram/three-geospatial": "0.9.1", + "tiny-invariant": "^1.3.3", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.7", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-geospatial": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial/-/three-geospatial-0.9.1.tgz", + "integrity": "sha512-fB7DAW0afN87e0/CcgZYVkF76yI0SXaRcVuT7M6+5vLZ8ySfJ7br2ceni37GVyY7zO1sLMBtoaMCH1mal/Zf9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "tiny-invariant": "^1.3.3", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "react": ">=19.0", + "three": ">=0.170.0" + } + }, + "node_modules/@takram/three-geospatial-effects": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial-effects/-/three-geospatial-effects-0.6.4.tgz", + "integrity": "sha512-Ufgap/N30j4Go5fyI9qdax9RiVK69OY+UuGCsYeuvGyihbfN5zh8ChHfu1c8vjIkz1Czz8zP8+6OvjD9+QxkMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@takram/three-geospatial": "0.9.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.0.4", + "@react-three/postprocessing": ">=3.0.4", + "postprocessing": ">=6.36.4", + "react": ">=19.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/3d-tiles-renderer": { + "version": "0.4.28", + "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.4.28.tgz", + "integrity": "sha512-eDFg3fTgnFeoa6kU9oyAQIci/N/CJq6JUi/ZUrmDyqLzEDH9mWd9FBs+NqS+0eO8b7y4HjIWfUVnf8o03AFtSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mapbox/vector-tile": "^2.0.3", + "pbf": "^4.0.1", + "pmtiles": "^4.3.2" + }, + "peerDependencies": { + "@babylonjs/core": ">=8.0.0", + "@babylonjs/loaders": ">=8.0.0", + "@react-three/fiber": "^8.17.9 || ^9.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "three": ">=0.167.0" + }, + "peerDependenciesMeta": { + "@babylonjs/core": { + "optional": true + }, + "@babylonjs/loaders": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/astronomy-engine": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/astronomy-engine/-/astronomy-engine-2.1.19.tgz", + "integrity": "sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pmtiles": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz", + "integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postprocessing": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", + "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", + "dev": true, + "license": "Zlib", + "peer": true, + "peerDependencies": { + "three": ">= 0.168.0 < 0.185.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-merge-refs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-3.0.2.tgz", + "integrity": "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tellux": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tellux/-/tellux-0.2.0.tgz", + "integrity": "sha512-Ge9KYw+yKTb/leH/iwMr9YPnycODnmOcXTEwhDqHG7zAgj9UYwwt9EC+lb94sjRJ0k5oNyuRvpPg0M/aUC0wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "dotenv": "^17.4.2", + "three-mesh-bvh": "0.9.0" + }, + "peerDependencies": { + "@mapbox/vector-tile": "^2.0.3", + "@takram/three-atmosphere": "0.19.1", + "@takram/three-clouds": "0.7.6", + "@takram/three-geospatial": "0.9.1", + "@takram/three-geospatial-effects": "0.6.4", + "3d-tiles-renderer": "^0.4.27", + "pbf": "^4.0.1", + "postprocessing": "6.39.1", + "three": "^0.184.0" + }, + "peerDependenciesMeta": { + "@mapbox/vector-tile": { + "optional": true + }, + "pbf": { + "optional": true + } + } + }, + "node_modules/tellux/node_modules/three-mesh-bvh": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.0.tgz", + "integrity": "sha512-xAwZj0hZknpwVsdK5BBJTIAZDjDPZCRzURY1o+z/JHBON/jc2UetK1CzPeQZiiOVSfI4jV2z7sXnnGtgsgnjaA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.14", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.14.tgz", + "integrity": "sha512-xJzfUXnLYjgw5tRKGxjfJW3Pcx0IbwNskvpdwRryhK07SYrKrYB4W8zAaPxu784gK7G4lCBgG+vfG3YeYn95kg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/three-player-controller/package.json b/three-player-controller/package.json new file mode 100644 index 0000000..5d34e75 --- /dev/null +++ b/three-player-controller/package.json @@ -0,0 +1,52 @@ +{ + "name": "three-player-controller", + "version": "0.6.2", + "description": "Third-person / first-person player controller for three.js (three-mesh-bvh based)", + "author": "hh-hang", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./foot-ik": { + "types": "./dist/foot-ik.d.ts", + "import": "./dist/foot-ik.mjs", + "require": "./dist/foot-ik.js" + } + }, + "scripts": { + "dev": "vite", + "build": "tsup", + "clean": "rimraf dist", + "prepare": "npm run build", + "build:example": "vite build" + }, + "peerDependencies": { + "three": ">=0.159.0", + "three-mesh-bvh": ">=0.7.8" + }, + "devDependencies": { + "@sparkjsdev/spark": "^2.1.0", + "@types/node": "^25.0.6", + "@types/three": "^0.184.0", + "3d-tiles-renderer": "^0.4.24", + "rimraf": "^6.0.0", + "tellux": "^0.2.0", + "tsup": "^8.0.1", + "typescript": "^5.9.3", + "vite": "^7.3.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/hh-hang/three-player-controller" + }, + "homepage": "https://github.com/hh-hang/three-player-controller" +} \ No newline at end of file diff --git a/three-player-controller/src/PlayerController.ts b/three-player-controller/src/PlayerController.ts new file mode 100644 index 0000000..fb9564a --- /dev/null +++ b/three-player-controller/src/PlayerController.ts @@ -0,0 +1,1023 @@ +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; + +import { MobileControls } from "./utils/MobileControls"; +import type { PlayerControllerOptions, PlayerModelOptions, VehicleInstance, VehicleOptions, KinematicColliderEntry, KeyMap } from "./types"; +import type { PlayerPlugin } from "./plugins/types"; +import { AnimationSystem } from "./systems/AnimationSystem"; +import { CameraSystem } from "./systems/CameraSystem"; +import { InputSystem } from "./systems/InputSystem"; +import { VehicleSystem } from "./systems/VehicleSystem"; +import { DynamicBodySystem } from "./systems/DynamicBodySystem"; +import { ColliderRegistry } from "./systems/ColliderRegistry"; +import type { DynamicBody, DynamicBodyKind } from "./collision/DynamicBody"; +import type { ColliderDesc, ColliderHandle } from "./collision/colliderDesc"; +import type { MotionType } from "./collision/CollisionWorld"; +import { applyCapsuleCollision, createCollisionTemps, type CollisionTemps } from "./utils/capsuleCollision"; +import { getBbox } from "./utils/bbox"; +import { BvhWorkerPool } from "./utils/BvhWorkerPool"; +import { CollisionWorld } from "./collision/CollisionWorld"; +import { CHARACTER_QUERY_MASK } from "./collision/groups"; +import { CharacterMovement } from "./systems/CharacterMovement"; +import { PlayerDebug } from "./systems/PlayerDebug"; + +/** 未传入delta时计算帧间隔。 */ +let _lastUpdateTime = performance.now(); + +function isMobileDevice() { + return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); +} + +export class playerController { + + // ==================== 场景引用 ==================== + /** GLTF加载器缓存。 */ + private _loader: GLTFLoader | null = null; + /** GLTF加载器。 */ + get loader(): GLTFLoader { + return this.initLoader(); + } + set loader(loader: GLTFLoader) { + this._loader = loader; + } + /** 三维场景。 */ + scene!: THREE.Scene; + /** 透视相机。 */ + camera!: THREE.PerspectiveCamera; + /** 轨道控制器。 */ + controls!: OrbitControls; + + // ==================== 玩家配置 ==================== + /** 模型配置项。 */ + playerModelConfig!: PlayerModelOptions; + /** 初始出生位置。 */ + private initPos: THREE.Vector3 = new THREE.Vector3(0, 0, 0); + /** 重力加速度。 */ + gravity = -2400; + /** 跳跃初速度。 */ + jumpHeight = 600; + /** 行走速度。 */ + playerSpeed = 200; + /** 跑步速度。 */ + playerRunSpeed = 600; + /** 飞行速度。 */ + playerFlySpeed = 2100; + /** 当前实际速度。 */ + curPlayerSpeed = 0; + /** 越肩视角开关。 */ + enableOverShoulderView = false; + /** 显示移动端控件。 */ + isShowMobileControls = true; + + // ==================== 玩家胶囊体 ==================== + /** 胶囊体半径。 */ + private playerCapsuleRadius = 30; + /** 半径缩放比。 */ + private playerCapsuleRadiusRatio = 1; + /** 胶囊体高度。 */ + private playerCapsuleHeight = 180; + /** 第一人称状态。 */ + isFirstPerson = false; + + // ==================== 运行状态 ==================== + /** 0步行 1载具。 */ + controllerMode: 0 | 1 = 0; + /** 是否在地面。 */ + playerIsOnGround = false; + /** 帧更新开关。 */ + isupdate = true; + /** 时间缩放系数。 */ + timeScale = 1; + /** 本帧实际使用的 delta(已钳制 + timeScale)。 */ + currentDelta = 0; + /** 飞行状态。 */ + isFlying = false; + /** 临时跳过玩家胶囊碰撞检测。 */ + skipCapsuleCollision = false; + /** 启用朝向输入。 */ + enableToward = true; + + // ==================== 玩家物体 ==================== + /** 玩家碰撞胶囊。 */ + playerCapsule!: THREE.Mesh & { capsuleInfo?: any }; + /** 模型根节点。 */ + playerModel: THREE.Object3D | null = null; + /** 头骨节点。 */ + playerModelHead: THREE.Object3D | null = null; + + // ==================== 碰撞体 ==================== + /** 碰撞体注册表。 */ + collisionWorld = new CollisionWorld(); + /** 统一创建 / 运动学跟随。 */ + private colliders = new ColliderRegistry(this); + /** 人物胶囊与地面探测调试。 */ + private playerDebug = new PlayerDebug(this); + /** 人物移动(胶囊推出)。 */ + private character = new CharacterMovement(this, this.playerDebug); + /** 当前站立的运动学碰撞体。 */ + activeKinematicCollider: KinematicColliderEntry | null = null; + /** 当前站立的动态刚体。 */ + activeDynamicBody: DynamicBody | null = null; + /** 移动系统本帧最终采用的地面支撑;供 IK 等插件与角色接地逻辑保持一致。 */ + private hasGroundSupport = false; + private readonly groundSupportHit = { + point: new THREE.Vector3(), + normal: new THREE.Vector3(0, 1, 0), + }; + /** 异步 BVH 构建队列。 */ + private readonly bvhWorkerPool = new BvhWorkerPool(); + + // ==================== 碰撞阈值 ==================== + /** 悬空胶囊离地高度。 */ + readonly rideHeight = 40; + // 站立 / 落地阈值 + /** 站立时胶囊原点应离地的高度。 */ + snapH = 0; + /** 离地超过此值判为悬空、施加重力。 */ + maxH = 0; + + // ==================== 台阶视觉平滑 ==================== + /** 插值追赶速度,越大追得越快。 */ + private stepSmoothFactor = 10; + /** 模型相对胶囊的基准 Y。 */ + private modelBaseY = 0; + /** 最小法线 Y 分量,地面法线与竖直夹角 ≤ 8° 视为台阶/平地(注入平滑)。 */ + readonly minFloorNormalY = Math.cos(8 * Math.PI / 180); + + // ==================== 移动端 ==================== + /** 移动端控件。 */ + mobileControls: MobileControls | null = null; + /** 靠近车辆。 */ + isNearVehicle = false; + + // ==================== 方向常量 & 复用向量 ==================== + /** 朝向旋转速度。 */ + rotationSpeed = 10; + /** 世界上方向。 */ + upVector = new THREE.Vector3(0, 1, 0); + /** 前。 */ + DIR_FWD = new THREE.Vector3(0, 0, -1); + /** 右。 */ + DIR_RGT = new THREE.Vector3(1, 0, 0); + + /** XZ 加速响应速度。 */ + playerAcceleration = 30; + /** XZ 减速响应速度。 */ + playerDeceleration = 30; + /** 减速基准速度。 */ + decelBase = 300; + /** 玩家速度。 */ + playerVelocity = new THREE.Vector3(); + /** 相机方向缓存。 */ + camDir = new THREE.Vector3(); + /** 移动方向缓存。 */ + moveDir = new THREE.Vector3(); + /** 步进方向缓存。 */ + xzDir = new THREE.Vector3(); + /** 本步移动前的胶囊位置。 */ + moveStepOrigin = new THREE.Vector3(); + /** 单帧胶囊碰撞子步上限。 */ + maxMoveSteps = 8; + /** 目标四元数。 */ + targetQuat = new THREE.Quaternion(); + /** 目标变换矩阵。 */ + targetMat = new THREE.Matrix4(); + /** 静态碰撞临时对象。 */ + staticTemps: CollisionTemps = createCollisionTemps(); + /** 运动学碰撞临时对象。 */ + kinematicTemps: CollisionTemps = createCollisionTemps(); + /** 地面检测射线。 */ + groundRaycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3(0, -1, 0)); + + // ==================== 插件 ==================== + /** 后处理等可选插件。 */ + private plugins: PlayerPlugin[] = []; + + // ==================== 事件回调 ==================== + /** 动画切换回调。 */ + onAnimationChange?: (name: string, action: THREE.AnimationAction) => void; + /** 视角切换前回调。 */ + onBeforeViewChange?: (isFirstPerson: boolean) => void; + /** 视角切换后回调。 */ + onViewChange?: (isFirstPerson: boolean) => void; + /** 落地状态回调。 */ + onGroundChange?: (onGround: boolean) => void; + /** 上车回调。 */ + onVehicleEnter?: (vehicle: VehicleInstance) => void; + /** 下车回调。 */ + onVehicleExit?: (vehicle: VehicleInstance) => void; + /** 朝向变化回调。 */ + onTowardChange?: (dx: number, dy: number, speed: number) => void; + + // ==================== 子系统 ==================== + /** 动画系统。 */ + animation = new AnimationSystem(this); + /** 相机系统。 */ + cam = new CameraSystem(this); + /** 输入系统。 */ + input = new InputSystem(this); + /** 载具系统。 */ + vehicle = new VehicleSystem(this); + /** 动态刚体系统。 */ + dynamics = new DynamicBodySystem(this); + + constructor() { + (this.groundRaycaster as any).firstHitOnly = true; + } + + // ==================== 初始化 ==================== + + /** 主初始化入口。 */ + async init(opts: PlayerControllerOptions, callback?: () => void) { + const m = opts.playerModelConfig; + const s = m.scale ?? 1; + + this.scene = opts.scene; + this.camera = opts.camera; + this.camera.rotation.order = "YXZ"; + this.controls = opts.controls; + + this.playerModelConfig = m; + this.initPos = opts.initPos ? opts.initPos.clone() : this.initPos; + + // 应用玩家参数 + const pm = this.playerModelConfig; + this.gravity = (pm.gravity ?? this.gravity) * s; + this.jumpHeight = (pm.jumpHeight ?? this.jumpHeight) * s; + this.playerSpeed = (pm.speed ?? this.playerSpeed) * s; + this.playerRunSpeed = (pm.runSpeed ?? this.playerRunSpeed) * s; + this.playerFlySpeed = (pm.flySpeed ?? this.playerFlySpeed) * s; + this.curPlayerSpeed = this.playerSpeed; + this.playerCapsuleRadiusRatio = pm.capsuleRadiusRatio ?? this.playerCapsuleRadiusRatio; + this.playerAcceleration = pm.acceleration ?? this.playerAcceleration; + this.playerDeceleration = pm.deceleration ?? this.playerDeceleration; + this.decelBase = this.playerSpeed; + + // 应用相机参数 + this.cam.sensitivity = opts.mouseSensitivity ?? this.cam.sensitivity; + this.cam.mouseMode = opts.thirdMouseMode ?? this.cam.mouseMode; + this.cam.enableSpringCamera = opts.enableSpringCamera ?? this.cam.enableSpringCamera; + this.cam.springCameraTime = opts.springCameraTime ?? this.cam.springCameraTime; + this.cam.zoomEnabled = opts.enableZoom ?? this.cam.zoomEnabled; + this.cam.minDist = (opts.minCamDistance ?? this.cam.minDist) * s; + this.cam.maxDist = (opts.maxCamDistance ?? this.cam.maxDist) * s; + this.cam.lookAtHeightRatio = opts.camLookAtHeightRatio ?? this.cam.lookAtHeightRatio; + this.cam.overShoulderOffsetRatio = opts.camOverShoulderOffsetRatio ?? this.cam.overShoulderOffsetRatio; + this.cam.originMaxDist = this.cam.maxDist; + this.cam.epsilon = this.cam.epsilon * s; + + this.isShowMobileControls = (opts.isShowMobileControls ?? this.isShowMobileControls) && isMobileDevice(); + this.enableOverShoulderView = opts.enableOverShoulderView ?? this.enableOverShoulderView; + this.isFirstPerson = opts.isFirstPerson ?? this.isFirstPerson; + this.timeScale = opts.timeScale ?? this.timeScale; + + // 自定义键位 + if (opts.keyMap) this.input.buildKeyMap(opts.keyMap); + + // 初始化移动端控件 + if (this.isShowMobileControls) { + this.mobileControls = new MobileControls(i => this.input.setInput(i), this.controls); + await this.mobileControls.init(opts.mobileControls); + } + + if (opts.colliders?.length) { + for (const desc of opts.colliders) this.addCollider(desc); + } + await this.loadPlayerModel(); + + this.input.bindEvents(); + this.cam.setCamPos(); + this.cam.initControls(); + this.cam.setOverShoulder(this.isFirstPerson ? false : this.enableOverShoulderView); + callback?.(); + } + + /** 初始化加载器。 */ + private initLoader(): GLTFLoader { + if (this._loader) return this._loader; + + const loader = new GLTFLoader(); + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath("https://unpkg.com/three@0.182.0/examples/jsm/libs/draco/gltf/"); + loader.setDRACOLoader(dracoLoader); + this._loader = loader; + return loader; + } + + // ==================== 玩家模型 ==================== + + /** 加载模型与动画。 */ + private async loadPlayerModel() { + try { + const config = this.playerModelConfig; + let model: THREE.Object3D; + let animations: THREE.AnimationClip[]; + + if (config.model) { + model = config.model; + animations = config.animations; + } else { + const gltf = await this.loader.loadAsync(config.url); + model = gltf.scene; + animations = gltf.animations ?? []; + } + + this.playerModel = model; + + // 初始化动画混合器 + this.animation.mixer = new THREE.AnimationMixer(this.playerModel); + this.animation.clips = animations; + this.animation.actions = new Map(); + + // 构建动作映射表 + const mc = this.playerModelConfig; + const isThreePartJump = Array.isArray(mc.jumpAnim); + this.animation.hasThreePartJump = isThreePartJump; + const mappings: [string, string][] = [ + [mc.idleAnim, "idle"], + [mc.walkAnim, "walking"], + [mc.leftWalkAnim || mc.walkAnim, "left_walking"], + [mc.rightWalkAnim || mc.walkAnim, "right_walking"], + [mc.backwardAnim || mc.walkAnim, "walking_backward"], + ...(typeof mc.jumpAnim === "string" + ? [[mc.jumpAnim, "jumping"]] as [string, string][] + : [] as [string, string][]), + [mc.runAnim, "running"], + [mc.flyIdleAnim || mc.idleAnim, "flyidle"], + [mc.flyAnim || mc.idleAnim, "flying"], + [mc.flyHoverForwardAnim || mc.flyAnim || mc.idleAnim, "flyHoverForward"], + [mc.flyHoverBackAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverBack"], + [mc.flyHoverLeftAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverLeft"], + [mc.flyHoverRightAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverRight"], + [mc.flyHoverUpAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverUp"], + [mc.flyHoverDownAnim || mc.flyIdleAnim || mc.idleAnim, "flyHoverDown"], + [mc.drivingAnim || mc.idleAnim, "driving"], + ]; + + for (const [clipName, actionName] of mappings) { + const clip = animations.find(a => a.name === clipName); + if (!clip) continue; + const action = this.animation.mixer.clipAction(clip); + if (actionName === "jumping") { + action.setLoop(THREE.LoopOnce, 1); + action.clampWhenFinished = true; + action.setEffectiveTimeScale(1.2); + } else { + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveTimeScale(1); + } + action.enabled = true; + action.setEffectiveWeight(0); + this.animation.actions.set(actionName, action); + } + + // 注册三段跳跃动画 + if (Array.isArray(mc.jumpAnim)) { + const [startClip, loopClip, endClip] = mc.jumpAnim; + const jumpDefs: [string, string, number, boolean][] = [ + [startClip, "jumpStart", THREE.LoopOnce, true], + [loopClip, "jumpLoop", THREE.LoopRepeat, false], + [endClip, "jumpEnd", THREE.LoopOnce, true], + ]; + for (const [clipName, key, loop, clamp] of jumpDefs) { + const clip = animations.find(a => a.name === clipName); + if (!clip) { console.warn(`找不到跳跃动画 clip: "${clipName}"`); continue; } + const action = this.animation.mixer!.clipAction(clip); + action.setLoop(loop as THREE.AnimationActionLoopStyles, loop === THREE.LoopOnce ? 1 : Infinity); + action.clampWhenFinished = clamp; + action.setEffectiveTimeScale(key === "jumpStart" ? 1.2 : 1); + action.enabled = true; + action.setEffectiveWeight(0); + this.animation.actions.set(key, action); + } + } + + // 注册默认动作组 + const defaultSet = new Map(); + for (const key of ["idle", "walking", "walking_backward", "running", "jumping", "flyidle", "flying"]) { + const action = this.animation.actions.get(key); + if (action) defaultSet.set(key, action); + } + this.animation.sets.set("default", defaultSet); + + this.animation.actions.get("idle")?.setEffectiveWeight(1); + this.animation.actions.get("idle")?.play(); + this.animation.state = this.animation.actions.get("idle")!; + + // 监听动画完成事件 + this.animation.mixerCb = (ev: any) => { + const done: THREE.AnimationAction = ev.action; + const resolveGroundAnim = () => { + if (this.input.fwd) { this.animation.playByName(this.input.shift ? "running" : "walking"); return; } + if (this.input.bkd) { this.animation.playByName("walking_backward"); return; } + if (this.input.rgt || this.input.lft) { this.animation.playByName("walking"); return; } + this.animation.playByName("idle"); + }; + if (done === this.animation.actions?.get("jumping")) { + if (this.animation.state === done) resolveGroundAnim(); + return; + } + if (done === this.animation.actions?.get("jumpStart")) { + if (this.animation.state === done) this.animation.playByName("jumpLoop"); + return; + } + if (done === this.animation.actions?.get("jumpEnd")) { + if (this.animation.state === done) resolveGroundAnim(); + return; + } + }; + this.animation.mixer.addEventListener("finished", this.animation.mixerCb); + + this.animation.mixer.update(0); + this.playerModel.updateMatrixWorld(true); + + // 计算胶囊体尺寸 + const { size } = getBbox(this.playerModel); + const modelScale = this.playerCapsuleHeight / size.y; + + const s = this.playerModelConfig.scale; + const r = this.playerCapsuleRadius * s * this.playerCapsuleRadiusRatio; + const h = this.playerCapsuleHeight * s; + + // 悬空胶囊:碰撞椭球底部永久离脚 rideHeight + const rideHeightScaled = this.rideHeight * s; // 当前缩放下的实际悬空高度 + const colliderHeight = h - rideHeightScaled; // 缩短后胶囊总高 + + const segmentLength = colliderHeight - 2 * r; + + // 创建胶囊体网格(原点对齐 segment 上端;depthTest 与其它调试线框一致) + this.playerCapsule = new THREE.Mesh( + new THREE.CapsuleGeometry(r, Math.max(segmentLength, 1e-6), 4, 8), + new THREE.MeshBasicMaterial({ + color: 0xc45cff, + wireframe: true, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }), + ); + this.playerCapsule.geometry.translate(0, -segmentLength / 2, 0); + this.playerCapsule.capsuleInfo = { + radius: r, + segment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -segmentLength, 0)), + // 动态体用接到脚底的完整胶囊,避免球从悬空间隙钻过 + dynamicsSegment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -segmentLength - rideHeightScaled, 0)), + }; + this.recomputeGroundThresholds(); + this.playerCapsule.name = "capsule"; + (this.playerCapsule.material as THREE.Material).visible = false; + this.scene.add(this.playerCapsule); + this.reset(); + this.playerCapsule.rotateY(this.playerModelConfig.rotateY ?? 0); + + // 挂载模型到胶囊 + this.playerModel.scale.multiplyScalar(modelScale * s); + this.modelBaseY = -segmentLength - r - rideHeightScaled; + this.playerModel.position.set(0, this.modelBaseY, 0); + this.playerModel.traverse((child: any) => { + if (child.name === this.playerModelConfig?.headBoneName) this.playerModelHead = child; + }); + this.playerCapsule.add(this.playerModel); + this.reset(); + } catch (e) { + console.error("加载玩家模型失败:", e); + } + } + + /** 切换玩家模型。 */ + async switchPlayerModel(newPlayerModel: PlayerModelOptions) { + // 保存当前状态 + const savedPos = this.playerCapsule.position.clone(); + const savedQuat = this.playerCapsule.quaternion.clone(); + const wasFirstPerson = this.isFirstPerson; + + if (wasFirstPerson) this.scene.attach(this.camera); + if (this.playerCapsule) this.scene.remove(this.playerCapsule); + if (this.playerModel) { this.playerCapsule.remove(this.playerModel); this.playerModel = null; this.playerModelHead = null; } + + // 清除旧动画资源 + const anim = this.animation; + if (anim.mixer) { + if (anim.mixerCb) { anim.mixer.removeEventListener("finished", anim.mixerCb); anim.mixerCb = undefined; } + anim.mixer.stopAllAction(); + anim.mixer.uncacheRoot(anim.mixer.getRoot()); + anim.mixer = undefined; + anim.actions = undefined; + } + + // 更新比例相关参数 + const ratio = newPlayerModel.scale / this.playerModelConfig.scale; + this.playerModelConfig = { + ...this.playerModelConfig, + url: undefined, + model: undefined, + animations: undefined, + ...newPlayerModel, + } as PlayerModelOptions; + + this.applyGameplayScaleRatio(ratio); + + await this.loadPlayerModel(); + this.playerCapsule.position.copy(savedPos); + this.playerCapsule.quaternion.copy(savedQuat); + if (wasFirstPerson) this.cam.setFirstPerson(); + this.syncDebugVisibility(); + for (const plugin of this.plugins) plugin.onPlayerModelChange?.(); + } + + // ==================== 插件 ==================== + + /** 注册后处理插件。 */ + use(plugin: PlayerPlugin): this { + if (this.plugins.includes(plugin)) return this; + if (plugin.name) { + const existing = this.plugins.find(p => p.name === plugin.name); + if (existing) this.unuse(existing); + } + this.plugins.push(plugin); + plugin.onAttach?.(this); + return this; + } + + /** 卸载已注册的插件。 */ + unuse(plugin: PlayerPlugin): this { + const index = this.plugins.indexOf(plugin); + if (index < 0) return this; + this.plugins.splice(index, 1); + plugin.onDetach?.(); + return this; + } + + /** 获取当前插件列表的只读副本。 */ + getPlugins(): PlayerPlugin[] { + return this.plugins.slice(); + } + + // ==================== 碰撞体构建和查询 ==================== + + /** @internal BVH worker。 */ + getBvhWorkerPool() { return this.bvhWorkerPool; } + /** @internal 静态 / 运动学 mesh 线框开关。 */ + getDisplayCollider() { return this.colliders.getDebugVisible(); } + /** @internal 动态刚体线框开关。 */ + getDisplayDynamicBody() { return this.dynamics.getDebugVisible(); } + + /** 统一创建碰撞体。 */ + addCollider(desc: ColliderDesc): ColliderHandle { + return this.colliders.add(desc); + } + + /** 按句柄移除碰撞体。 */ + removeCollider(handle: ColliderHandle | number): void { + this.colliders.remove(handle); + } + + /** 运行时等比缩放已烘焙的运动学 mesh 碰撞(不重建 BVH)。 */ + scaleKinematicColliderContent(source: THREE.Object3D, ratio: number): void { + this.colliders.scaleKinematicContent(source, ratio); + } + + /** 运行时平移已烘焙的运动学 mesh 碰撞(本地空间,不重建 BVH)。 */ + translateKinematicColliderContent(source: THREE.Object3D, localOffset: THREE.Vector3): void { + this.colliders.translateKinematicContent(source, localOffset); + } + + /** 清除碰撞体;可按 motion 过滤。 */ + clearColliders(filter?: { motion?: MotionType }): void { + this.colliders.clear(filter); + } + + /** 人物胶囊 / 相机查询用的静态与运动学网格。 */ + getColliderMeshes(options?: { skipIds?: number[] }): THREE.Mesh[] { + return this.collisionWorld.queryMeshes({ mask: CHARACTER_QUERY_MASK }, options); + } + + /** 车辆轮射线 / 车身接触使用的网格。跳过全部车辆外观,车车改走底盘盒。 */ + getVehicleGroundMeshes(v: VehicleInstance): THREE.Mesh[] { + const chassis = v.chassisColliderId != null ? this.collisionWorld.get(v.chassisColliderId) : null; + const skipIds = this.vehicle.meshSkipIds; + skipIds.length = 0; + for (const other of this.vehicle.list) { + if (other.meshColliderId != null) skipIds.push(other.meshColliderId); + } + if (!chassis) return []; + return this.collisionWorld.queryMeshes(chassis, { skipIds }); + } + + /** 静态碰撞体是否已有至少一份就绪的 mesh。 */ + isStaticColliderUsable() { + return this.colliders.isStaticUsable(); + } + + /** 运动学碰撞体是否已就绪。 */ + isKinematicColliderUsable(entry: KinematicColliderEntry) { + return this.colliders.isKinematicUsable(entry); + } + + /** 更新运动学碰撞体网格,并计算本帧位移增量。 */ + private updateKinematicColliders() { + this.colliders.updateKinematicFollow(); + } + + /** 本帧碰撞用完后再提交平台矩阵,供车辆按 prev→current 带走。 */ + private commitKinematicPrevMatrices() { + this.colliders.commitKinematicPrev(); + } + + // ==================== 主循环 ==================== + + /** 主循环。 */ + async update(delta?: number) { + if (delta === undefined) { + const now = performance.now(); + delta = (now - _lastUpdateTime) / 1000; + _lastUpdateTime = now; + } + if (!this.isupdate || !this.playerCapsule) return; + delta = Math.min(delta, 1 / 40) * this.timeScale; + this.currentDelta = delta; + this.updateKinematicColliders(); + if (this.controllerMode === 1) { + this.vehicle.preparePhysics(delta); + this.vehicle.finishPhysics(delta); + if (!this.isFirstPerson) this.cam.updateThirdPersonVehicle(delta); + this.runAnimationPass(delta); + } else { + this.updatePlayer(delta); + // 停着的车也每帧步进,避免球砸/叠车冲量攒着、视觉冻住 + this.vehicle.finishPhysics(delta); + } + this.dynamics.step(delta); + this.character.applyDynamicSupportCarry(); + this.commitKinematicPrevMatrices(); + } + + /** 玩家帧更新。 */ + updatePlayer(delta: number) { + this.character.update(delta); + } + + /** 在插件的恢复与应用钩子之间更新动画混合器。 */ + runAnimationPass(delta: number): void { + for (const plugin of this.plugins) plugin.onBeforeAnimationUpdate?.(delta); + this.animation.updateMixers(delta); + for (const plugin of this.plugins) plugin.onAfterAnimationUpdate?.(delta); + } + + // ==================== 内部辅助 ==================== + + /** 同步全部 debug 可见性。 */ + syncDebugVisibility() { + this.playerDebug.syncVisibility(); + this.colliders.syncDebugVisibility(); + this.dynamics.syncDebugVisibility(); + this.vehicle.syncDebugVisibility(); + } + + /** 设置落地状态。 */ + setOnGround(val: boolean) { + if (!val) this.clearGroundSupport(); // 离地时立即作废上一帧支撑。 + if (this.playerIsOnGround === val) return; + this.playerIsOnGround = val; + this.onGroundChange?.(val); + if (val) this.animation.onLand(); + else this.animation.onBecomeAirborne(); + } + + /** 应用重力。 */ + applyGravity(delta: number) { + this.playerVelocity.y += delta * this.gravity; + this.setOnGround(false); + } + + /** 判断脚下地面是否为水平台面(法线接近竖直)。 */ + isFlatFloor(hit: THREE.Intersection): boolean { + const n = hit.face?.normal; + if (!n) return true; + return n.y >= this.minFloorNormalY; // 大于等于最小法线 Y 分量时为水平台面 + } + + /** 吸附到地面。 */ + snapToGround(groundY: number, smooth = false, delta = 0) { + this.playerVelocity.y = 0; + const dy = groundY - this.playerCapsule.position.y; + if (smooth && Math.abs(dy) <= this.rideHeight * this.playerModelConfig.scale) { + // 平滑吸附 + this.playerCapsule.position.y += dy * Math.min(1, this.stepSmoothFactor * delta); + } else { + // 瞬时吸附 + this.playerCapsule.position.y = groundY; + } + this.setOnGround(true); + } + + /** 按比例同步重力、速度、相机距离等玩法参数。 */ + private applyGameplayScaleRatio(ratio: number) { + this.gravity *= ratio; + this.jumpHeight *= ratio; + this.playerSpeed *= ratio; + this.playerRunSpeed *= ratio; + this.playerFlySpeed *= ratio; + this.curPlayerSpeed *= ratio; + this.decelBase *= ratio; + this.cam.epsilon *= ratio; + this.cam.minDist *= ratio; + this.cam.maxDist *= ratio; + this.cam.originMaxDist *= ratio; + this.controls.minDistance = this.cam.minDist; + } + + /** 重算站立 / 落地阈值(snapH / maxH)。仅在胶囊创建、缩放后调用。 */ + private recomputeGroundThresholds() { + const info = this.playerCapsule?.capsuleInfo; + if (!info) return; + const sy = this.playerCapsule.scale.y || 1; + const rideHeightScaled = this.rideHeight * this.playerModelConfig.scale; + this.snapH = (-info.segment.end.y) * sy + info.radius + rideHeightScaled; + this.maxH = this.snapH + rideHeightScaled; + } + + /** 动态修改玩家缩放。 */ + setPlayerScale(newScale: number) { + if (newScale <= 0) return; + const ratio = newScale / this.playerModelConfig.scale; + this.playerModelConfig.scale = newScale; + + // 更新比例相关参数 + this.applyGameplayScaleRatio(ratio); + + if (this.isFirstPerson) this.scene.attach(this.camera); + this.playerCapsule?.scale.multiplyScalar(ratio); + if (this.playerCapsule?.capsuleInfo) { + this.playerCapsule.capsuleInfo.radius *= ratio; + this.recomputeGroundThresholds(); + } + if (this.isFirstPerson) this.cam.setFirstPerson(); + } + + /** 动态修改单辆车的缩放(绝对 scale,与加载时 opts.scale 同一语义)。 */ + setVehicleScale(vehicle: VehicleInstance, newScale: number) { + this.vehicle.setScale(vehicle, newScale); + } + + /** 动态修改单辆车的底盘离地间隙(scale=1 基准值)。 */ + setVehicleClearance(vehicle: VehicleInstance, clearance: number) { + this.vehicle.setClearance(vehicle, clearance); + } + + /** 动态修改单辆车的底盘碰撞盒三轴尺寸比例。 */ + setVehicleChassisSizeScale( + vehicle: VehicleInstance, + sizeScale: { x?: number; y?: number; z?: number }, + ) { + this.vehicle.setChassisSizeScale(vehicle, sizeScale); + } + + /** 将全部已加载车辆缩放到同一绝对 scale。 */ + setAllVehiclesScale(newScale: number) { + this.vehicle.setScaleAll(newScale); + } + + /** 重置玩家位置。 */ + reset(position?: THREE.Vector3) { + if (!this.playerCapsule) return; + if (this.controllerMode === 1) { + this.vehicle.stopActive(); + this.controllerMode = 0; + this.mobileControls?.syncControllerModeBtn(0); + this.scene.attach(this.playerCapsule); + this.animation.playByName("idle"); + this.syncDebugVisibility(); + } + this.playerVelocity.set(0, 0, 0); + this.activeDynamicBody = null; + this.clearGroundSupport(); + this.playerCapsule.position.copy(position ?? this.initPos); + } + + /** 站立时胶囊原点应离地的高度。 */ + getCapsuleGroundHeight() { return this.snapH; } + + /** 运动学碰撞体列表(供车辆下车检测使用)。 */ + getKinematicColliderEntries() { return this.colliders.kinematicColliders; } + + /** 将人物模型挂载到车辆座位点。 */ + syncMountedPlayer(vehicle: VehicleInstance) { + const cap = this.playerCapsule; + if (!cap) return; + this.clearGroundSupport(); + if (cap.parent !== vehicle.vehicleGroup) vehicle.vehicleGroup.attach(cap); + cap.position.copy(vehicle.driverSeatPosition).multiplyScalar(vehicle.scale); + cap.quaternion.setFromAxisAngle(this.upVector, vehicle.driverSeatRotation); + } + + /** 在车辆下车位置恢复人物控制。 */ + leaveVehicleAt(position: THREE.Vector3, forward: THREE.Vector3) { + const cap = this.playerCapsule; + if (!cap) return; + this.scene.attach(cap); + cap.position.copy(position); + const lookTarget = position.clone().add(forward); + this.targetMat.lookAt(position, lookTarget, this.upVector); + cap.quaternion.setFromRotationMatrix(this.targetMat); + this.playerVelocity.set(0, 0, 0); + this.setOnGround(false); + if (this.isFirstPerson) this.cam.setFirstPerson(); + } + + // ==================== API ==================== + + /** 获取当前位置。 */ + getPosition() { return this.playerCapsule?.position; } + /** 获取速度。 */ + getVelocity() { return this.playerVelocity.clone(); } + /** 获取第一人称状态。 */ + getIsFirstPerson() { return this.isFirstPerson; } + /** 获取飞行状态。 */ + getIsFlying() { return this.isFlying; } + /** 获取落地状态。 */ + getIsOnGround() { return this.playerIsOnGround; } + /** 获取移动系统本帧最终采用的地面支撑。 */ + getGroundSupport() { return this.hasGroundSupport ? this.groundSupportHit : null; } + /** 获取本帧实际使用的 delta(已钳制 + timeScale)。 */ + getCurrentDelta() { return this.currentDelta; } + /** 获取控制器模式。 */ + getControllerMode() { return this.controllerMode; } + /** 获取玩家模型。 */ + getPlayerModel() { return this.playerModel; } + /** 获取胶囊体。 */ + getPlayerCapsule() { return this.playerCapsule; } + /** 获取当前载具。 */ + getActiveVehicle() { return this.vehicle.active; } + /** 获取所有载具。 */ + getAllVehicles() { return this.vehicle.list; } + /** 获取当前站立的运动学碰撞体。 */ + getActiveKinematicCollider() { return this.activeKinematicCollider; } + /** 获取当前站立的动态刚体。 */ + getActiveDynamicBody() { return this.activeDynamicBody; } + + /** 写入移动系统最终采用的地面点和世界空间法线。 */ + setGroundSupport(point: THREE.Vector3, normal: THREE.Vector3) { + this.groundSupportHit.point.copy(point); + this.groundSupportHit.normal.copy(normal).normalize(); + this.hasGroundSupport = true; + } + + /** 清除已经失效的地面支撑。 */ + clearGroundSupport() { this.hasGroundSupport = false; } + + /** 移除动态刚体。 */ + removeDynamicBody(body: DynamicBody) { this.dynamics.remove(body); } + /** 从指定世界坐标向下查询动态刚体表面。 */ + raycastDynamicGround( + origin: THREE.Vector3, + minNormalY?: number, + excludeKinds?: readonly DynamicBodyKind[], + ) { + return this.dynamics.raycastGround(origin, minNormalY, excludeKinds); + } + /** 全部动态刚体。 */ + getDynamicBodies(): DynamicBody[] { return this.dynamics.list; } + /** 清除全部动态刚体。 */ + clearDynamicBodies() { this.dynamics.clear(); } + + /** 设置鼠标灵敏度。 */ + setMouseSensitivity(value: number) { + this.cam.sensitivity = value; + this.controls.rotateSpeed = value * 0.05; + } + + // --- 玩家参数 --- + /** 设置重力。 */ + setGravity(gravity: number) { this.gravity = gravity * this.playerModelConfig.scale; } + /** 设置跳跃高度。 */ + setJumpHeight(jumpHeight: number) { this.jumpHeight = jumpHeight * this.playerModelConfig.scale; } + /** 设置行走速度。 */ + setPlayerSpeed(speed: number) { this.playerSpeed = speed * this.playerModelConfig.scale; this.curPlayerSpeed = this.playerSpeed; } + /** 设置跑步速度。 */ + setPlayerRunSpeed(runSpeed: number) { this.playerRunSpeed = runSpeed * this.playerModelConfig.scale; } + /** 设置飞行速度。 */ + setPlayerFlySpeed(flySpeed: number) { this.playerFlySpeed = flySpeed * this.playerModelConfig.scale; } + /** 设置朝向开关。 */ + setEnableToward(v: boolean) { this.enableToward = v; } + + // --- 相机参数 --- + /** 设置相机最近距。 */ + setMinCamDistance(dist: number) { + this.cam.minDist = dist * this.playerModelConfig.scale; + this.cam.originMaxDist = Math.max(this.cam.originMaxDist, this.cam.minDist); + this.cam.maxDist = Math.max(this.cam.maxDist, this.cam.minDist); + this.controls.minDistance = this.cam.minDist; + } + /** 设置相机最远距。 */ + setMaxCamDistance(dist: number) { + this.cam.originMaxDist = Math.max(this.cam.minDist, dist * this.playerModelConfig.scale); + this.cam.maxDist = this.cam.originMaxDist; + this.cam.clearFlySprintMaxDistBoost(); + } + /** 设置相机看向点高度比例。 */ + setCamLookAtHeightRatio(ratio: number) { this.cam.lookAtHeightRatio = ratio; } + /** 设置相机过肩视角横向偏移比例。 */ + setCamOverShoulderOffsetRatio(ratio: number) { this.cam.overShoulderOffsetRatio = ratio; this.cam.setOverShoulder(this.enableOverShoulderView && !this.isFirstPerson); } + + /** 设置鼠标模式。 */ + setThirdMouseMode(mode: 0 | 1 | 2 | 3 | 4 | 5) { this.cam.mouseMode = mode; this.cam.setPointerLock(); } + /** 设置缩放开关。 */ + setEnableZoom(enable: boolean) { this.cam.setZoomEnabled(enable); } + + // --- 调试 --- + /** 设置静态碰撞线框。 */ + setColliderDebug(debug: boolean) { + this.colliders.setDebugVisible(debug); + } + /** 设置玩家胶囊线框。 */ + setPlayerCapsuleDebug(debug: boolean) { + this.playerDebug.setVisible(debug); + } + /** 设置动态刚体碰撞线框。 */ + setDynamicBodyDebug(debug: boolean) { + this.dynamics.setDebugVisible(debug); + } + /** 设置车辆底盘物理盒。 */ + setVehiclePhysicsDebug(debug: boolean) { + this.vehicle.setPhysicsDebugVisible(debug); + } + /** 临时跳过玩家胶囊碰撞检测。 */ + setSkipCapsuleCollision(skip: boolean) { this.skipCapsuleCollision = skip; } + + // --- 动画 --- + /** 按名播放动画。 */ + playPlayerAnimationByName(name: string, fade?: number) { this.animation.playByName(name, fade); } + /** 注册自定义动画。 */ + registerAnimation(key: string, clipName: string, opts?: Parameters[2]) { this.animation.register(key, clipName, opts); } + /** 播放已注册动画。 */ + playAnimation(key: string, opts?: Parameters[1]) { this.animation.play(key, opts); } + /** 注册移动动作组。 */ + registerLocomotionSet(setName: string, map: Parameters[1]) { this.animation.registerLocomotionSet(setName, map); } + /** 切换移动动作组。 */ + switchLocomotionSet(setName: string, fade?: number) { this.animation.switchLocomotionSet(setName, fade); } + /** 获取当前动画名。 */ + getCurrentPlayerAnimationName() { return this.animation.getCurrentName(); } + /** 获取当前移动动作组名。 */ + getCurrentLocomotionSet() { return this.animation.currentLocomotionSet; } + + // --- 相机 --- + /** 切换视角模式。 */ + changeView() { this.cam.changeView(); } + /** 设置第一人称。 */ + setFirstPersonCamera(v = 0) { this.cam.setFirstPerson(v); } + /** 设置越肩视角。 */ + setOverShoulderView(v: boolean) { this.cam.setOverShoulder(v); this.enableOverShoulderView = v; } + /** 屏幕中心检测。 */ + getCenterScreenRaycastHit() { return this.cam.getCenterHit(); } + + // --- 输入 --- + /** 设置输入状态。 */ + setInput(input: Parameters[0]) { this.input.setInput(input); } + /** 运行时自定义键位。 */ + setKeyMap(map?: KeyMap) { this.input.buildKeyMap(map); } + /** 绑定输入事件。 */ + onAllEvent() { this.input.bindEvents(); } + /** 解绑输入事件。 */ + offAllEvent() { this.input.unbindEvents(); } + + // --- 载具 --- + /** 加载车辆模型。 */ + loadVehicleModel(opts: VehicleOptions) { return this.vehicle.load(opts); } + /** 将当前驾驶车辆翻正复位。 */ + resetVehicle() { this.vehicle.resetUpright(); } + + // --- 销毁 --- + /** 销毁控制器并释放资源。 */ + destroy() { + this.input.unbindEvents(); + + // 卸载插件 + for (const plugin of this.plugins.slice()) { + this.unuse(plugin); + plugin.dispose?.(); + } + this.plugins = []; + + this.playerDebug.dispose(); + + // 清除玩家对象 + if (this.playerCapsule) { this.playerCapsule.remove(this.camera); this.scene.remove(this.playerCapsule); } + (this.playerCapsule as any) = null; + if (this.playerModel) { this.scene.remove(this.playerModel); this.playerModel = null; } + + // 清除碰撞体和相机 + this.cam.resetControls(); + this.clearColliders(); + this.mobileControls?.destroy(); + this.mobileControls = null; + this.bvhWorkerPool.dispose(); + + // 清除所有车辆 + this.vehicle.destroy(); + this.dynamics.clear(); + this.collisionWorld.clear(); + } +} diff --git a/three-player-controller/src/collision/CollisionWorld.ts b/three-player-controller/src/collision/CollisionWorld.ts new file mode 100644 index 0000000..abae5ec --- /dev/null +++ b/three-player-controller/src/collision/CollisionWorld.ts @@ -0,0 +1,182 @@ +import type * as THREE from "three"; +import { CollisionGroup } from "./groups"; + +/** 碰撞体运动类型。 */ +export type MotionType = "static" | "kinematic" | "dynamic"; + +/** + * 碰撞形状。 + * mesh 挂 BVH 供射线 / shapecast;box / sphere 用于动态刚体登记与解析碰撞。 + */ +export type CollisionShape = + | { kind: "mesh"; mesh: THREE.Mesh } + | { kind: "box"; halfExtents: THREE.Vector3 } + | { kind: "sphere"; radius: number }; + +/** 世界中已登记的一条碰撞体。 */ +export type CollisionCollider = { + id: number; // 稳定标识 + motion: MotionType; // 运动类型 + shape: CollisionShape; // 形状 + groups: number; // 自己属于哪些业务组 + mask: number; // 自己要撞哪些组 + ready: boolean; // BVH / 资源是否可参与查询 + userData?: unknown; // 业务侧挂载数据 +}; + +/** 底层登记参数。未传的 groups / mask 按 motion 给默认值。 */ +export type AddColliderOptions = { + motion: MotionType; + shape: CollisionShape; + groups?: number; + mask?: number; + ready?: boolean; + userData?: unknown; +}; + +/** + * 查询过滤。 + * 传入完整 collider 时双方 groups/mask 都要匹配;只传 `{ mask }` 时单向过滤。 + */ +export type MeshQueryFilter = CollisionCollider | { mask: number }; + +/** query / queryMeshes 的可选过滤。 */ +export type MeshQueryOptions = { + skipIds?: number[]; // 额外排除的 id + kinds?: CollisionShape["kind"][]; // 只保留这些形状 + motion?: MotionType | MotionType[]; // 只保留这些运动类型 +}; + +/** 按运动类型给出默认业务分组与掩码。 */ +function defaultBits(motion: MotionType): { groups: number; mask: number } { + switch (motion) { + case "static": + return { + groups: CollisionGroup.DEFAULT, + mask: CollisionGroup.DEBRIS | CollisionGroup.VEHICLE, + }; + case "kinematic": + return { + groups: CollisionGroup.DEFAULT, + mask: CollisionGroup.DEBRIS | CollisionGroup.VEHICLE | CollisionGroup.CHARACTER, + }; + case "dynamic": + return { + groups: CollisionGroup.DEBRIS, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS, + }; + } +} + +/** 是否为完整 collider(相对仅含 mask 的单向查询)。 */ +function isCollider(filter: MeshQueryFilter): filter is CollisionCollider { + return "id" in filter && "motion" in filter && "groups" in filter; +} + +/** + * 碰撞体注册表。 + * 人物仍用胶囊推出,车辆仍用射线 / OBB;这里只统一「谁登记了、谁和谁该测」。 + */ +export class CollisionWorld { + private nextId = 1; // 递增 id + private readonly colliders = new Map(); // 已登记碰撞体 + + /** 登记一条碰撞体,返回带 id 的条目。 */ + add(options: AddColliderOptions): CollisionCollider { + const bits = defaultBits(options.motion); + const entry: CollisionCollider = { + id: this.nextId++, + motion: options.motion, + shape: options.shape, + groups: options.groups ?? bits.groups, + mask: options.mask ?? bits.mask, + ready: options.ready ?? true, + userData: options.userData, + }; + this.colliders.set(entry.id, entry); + return entry; + } + + /** 按 id 取碰撞体,不存在则返回 undefined。 */ + get(id: number): CollisionCollider | undefined { + return this.colliders.get(id); + } + + /** 注销碰撞体。 */ + remove(id: number): void { + this.colliders.delete(id); + } + + /** 标记 BVH / 资源是否可参与查询。 */ + setReady(id: number, ready: boolean): void { + const entry = this.colliders.get(id); + if (entry) entry.ready = ready; + } + + /** 覆盖碰撞掩码。 */ + setMask(id: number, mask: number): void { + const entry = this.colliders.get(id); + if (entry) entry.mask = mask; + } + + /** 覆盖业务分组。 */ + setGroups(id: number, groups: number): void { + const entry = this.colliders.get(id); + if (entry) entry.groups = groups; + } + + /** 覆盖 userData。 */ + setUserData(id: number, userData: unknown): void { + const entry = this.colliders.get(id); + if (entry) entry.userData = userData; + } + + /** + * 查询可参与检测的碰撞体。 + * 完整 collider:双向 groups/mask;仅 mask:单向。自动跳过未 ready 与自身 id。 + */ + query(filter: MeshQueryFilter, options?: MeshQueryOptions): CollisionCollider[] { + const skip = new Set(options?.skipIds); + const self = isCollider(filter) ? filter : null; + if (self) skip.add(self.id); + const mask = filter.mask; + const kinds = options?.kinds; + const motions = options?.motion + ? (Array.isArray(options.motion) ? options.motion : [options.motion]) + : null; + const out: CollisionCollider[] = []; + for (const col of this.colliders.values()) { + if (!col.ready || skip.has(col.id)) continue; + if (motions && !motions.includes(col.motion)) continue; + if (kinds && !kinds.includes(col.shape.kind)) continue; + if ((col.groups & mask) === 0) continue; + if (self && (self.groups & col.mask) === 0) continue; + out.push(col); + } + return out; + } + + /** 查询可做 BVH 射线 / shapecast 的网格。 */ + queryMeshes(filter: MeshQueryFilter, options?: MeshQueryOptions): THREE.Mesh[] { + const meshes: THREE.Mesh[] = []; + for (const col of this.query(filter, options)) { + if (col.shape.kind === "mesh") meshes.push(col.shape.mesh); + } + return meshes; + } + + /** 按运动类型列出 id(供 clearColliders)。 */ + idsByMotion(motion?: MotionType): number[] { + const out: number[] = []; + for (const col of this.colliders.values()) { + if (motion && col.motion !== motion) continue; + out.push(col.id); + } + return out; + } + + /** 清空全部登记。 */ + clear(): void { + this.colliders.clear(); + } +} diff --git a/three-player-controller/src/collision/DynamicBody.ts b/three-player-controller/src/collision/DynamicBody.ts new file mode 100644 index 0000000..6dfea0a --- /dev/null +++ b/three-player-controller/src/collision/DynamicBody.ts @@ -0,0 +1,187 @@ +import * as THREE from "three"; +import { integrateQuaternion } from "../utils/vehiclePhysics/vehicleMath"; + +/** 冲量唤醒:质心线速度增量 ‖Δv‖ = ‖J‖·invMass 超过该值则醒(世界单位/秒)。 */ +const WAKE_DELTA_V = 1e-4; +const WAKE_DELTA_V_SQ = WAKE_DELTA_V * WAKE_DELTA_V; + +/** 动态刚体形状判别。 */ +export type DynamicBodyKind = "sphere" | "box"; + +/** 动态刚体材质 / 初态可选字段(各形状 options 共用)。 */ +export type DynamicBodyMaterialOptions = { + /** 密度,质量 = 体积 × 密度;默认见 DYNAMIC_BODY_DEFAULTS。 */ + density?: number; + /** 弹性,0 不反弹,1 完全弹;默认见 DYNAMIC_BODY_DEFAULTS。 */ + restitution?: number; + /** 摩擦;默认见 DYNAMIC_BODY_DEFAULTS。 */ + friction?: number; + /** 线阻尼,越大滑行越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + linearDamping?: number; + /** 角阻尼,越大旋转越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + angularDamping?: number; + /** 重力,默认沿用控制器重力。 */ + gravity?: number; + /** 初速度。 */ + velocity?: THREE.Vector3; + /** 初角速度。 */ + angularVelocity?: THREE.Vector3; + /** 视觉网格。 */ + mesh?: THREE.Mesh; +}; + +/** 子类构造时传入的已解析材质与视觉(缺省已在上层填好)。 */ +export type DynamicBodyBaseInit = { + density: number; // 密度 + mass: number; // 质量(体积 × 密度) + restitution: number; // 弹性 + gravity: number; // 重力加速度 + position: THREE.Vector3; // 世界位置 + velocity: THREE.Vector3; // 线速度 + angularVelocity?: THREE.Vector3; // 角速度 + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + friction: number; // 摩擦 + mesh: THREE.Mesh; // 视觉网格 + debugMesh: THREE.Mesh; // 碰撞线框 +}; + +/** + * 动态刚体基类(ImpulseBody + 材质)。 + * 形状相关惯量 / 特征尺寸由子类提供;窄相算法按 kind 分派。 + */ +export abstract class DynamicBody { + abstract readonly kind: DynamicBodyKind; // 形状判别 + + density: number; // 密度 + /** 由 density × 体积得到,供冲量求解使用。 */ + mass: number; + restitution: number; // 弹性 [0, 1] + gravity: number; // 重力加速度(通常为负) + position = new THREE.Vector3(); // 世界位置 + quaternion = new THREE.Quaternion(); // 世界朝向 + /** 线速度(保留 velocity 命名以兼容现有调用)。 */ + velocity = new THREE.Vector3(); + angularVelocity = new THREE.Vector3(); // 角速度 + invMass = 0; // 质量倒数 + invInertia = new THREE.Vector3(); // 局部惯量倒数对角元 + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + friction: number; // 碰撞摩擦 + mesh: THREE.Mesh; // 视觉网格 + /** 碰撞形状线框,由 setDynamicBodyDebug 显隐。 */ + debugMesh: THREE.Mesh; + contactMesh: THREE.Mesh | null = null; // 本帧接触的网格(平台携带用) + colliderId = 0; // CollisionWorld 登记 id + /** 休眠中则跳过积分与窄相。 */ + sleeping = false; + /** 是否允许进入休眠。 */ + canSleep = true; + /** 连续低于休眠阈值的累计时间(秒)。 */ + sleepTimer = 0; + + private _r = new THREE.Vector3(); + private _torque = new THREE.Vector3(); + private _local = new THREE.Vector3(); + private _world = new THREE.Vector3(); + private _invQuat = new THREE.Quaternion(); + + /** 写入材质、位姿与视觉引用,并计算 invMass。 */ + protected constructor(opts: DynamicBodyBaseInit) { + this.density = Math.max(1e-8, opts.density); + this.mass = Math.max(1e-6, opts.mass); + this.restitution = Math.max(0, Math.min(1, opts.restitution)); + this.gravity = opts.gravity; + this.position.copy(opts.position); + this.velocity.copy(opts.velocity); + if (opts.angularVelocity) this.angularVelocity.copy(opts.angularVelocity); + this.linearDamping = opts.linearDamping; + this.angularDamping = opts.angularDamping; + this.friction = opts.friction; + this.mesh = opts.mesh; + this.debugMesh = opts.debugMesh; + this.invMass = 1 / this.mass; + } + + /** 用于速度钳制 / 携带阈值的特征半尺寸。 */ + abstract characteristicExtent(): number; + + /** 唤醒刚体。 */ + wakeUp(): void { + if (!this.sleeping) return; + this.sleeping = false; + this.sleepTimer = 0; + } + + /** 进入休眠并清零速度。 */ + putToSleep(): void { + if (!this.canSleep) return; + this.sleeping = true; + this.sleepTimer = 0; + this.velocity.set(0, 0, 0); + this.angularVelocity.set(0, 0, 0); + this.contactMesh = null; + } + + /** 线速度 / 角速度是否低于给定阈值。 */ + isRestingCandidate(linearThreshold: number, angularThreshold: number): boolean { + return ( + this.velocity.lengthSq() <= linearThreshold * linearThreshold + && this.angularVelocity.lengthSq() <= angularThreshold * angularThreshold + ); + } + + /** 读取世界点处的刚体速度:v + ω × r。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + this._r.subVectors(point, this.position); + return out.copy(this.velocity).add(this._world.copy(this.angularVelocity).cross(this._r)); + } + + /** + * 休眠唤醒:速度增量 ‖J‖·invMass 超过阈值则醒。 + */ + private wakeIfImpulseMeaningful(impulse: THREE.Vector3): void { + if (!this.sleeping) return; + const inv = this.invMass; + if (impulse.lengthSq() * inv * inv > WAKE_DELTA_V_SQ) this.wakeUp(); + } + + /** 在质心施加冲量。 */ + applyImpulse(impulse: THREE.Vector3): void { + this.wakeIfImpulseMeaningful(impulse); + this.velocity.addScaledVector(impulse, this.invMass); + } + + /** 在世界点施加冲量,同时产生角速度。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, contactPoint: THREE.Vector3): void { + this.wakeIfImpulseMeaningful(impulse); + this.velocity.addScaledVector(impulse, this.invMass); + this._r.subVectors(contactPoint, this.position); + this.angularVelocity.add(this.angularDeltaFromImpulse(impulse, this._r)); + } + + /** 将冲量转为世界角速度增量:ω += R I⁻¹ Rᵀ (r × J)。 */ + private angularDeltaFromImpulse(impulse: THREE.Vector3, r: THREE.Vector3): THREE.Vector3 { + this._torque.copy(r).cross(impulse); + this._invQuat.copy(this.quaternion).invert(); + this._local.copy(this._torque).applyQuaternion(this._invQuat); + this._local.x *= this.invInertia.x; + this._local.y *= this.invInertia.y; + this._local.z *= this.invInertia.z; + return this._world.copy(this._local).applyQuaternion(this.quaternion); + } + + /** 指数线 / 角阻尼。 */ + applyDamping(dt: number): void { + const lin = Math.exp(-this.linearDamping * dt); + const ang = Math.exp(-this.angularDamping * dt); + this.velocity.multiplyScalar(lin); + this.angularVelocity.multiplyScalar(ang); + } + + /** 用当前速度积分位置和朝向。 */ + integrate(dt: number): void { + this.position.addScaledVector(this.velocity, dt); + integrateQuaternion(this.quaternion, this.angularVelocity, dt); + } +} diff --git a/three-player-controller/src/collision/DynamicBox.ts b/three-player-controller/src/collision/DynamicBox.ts new file mode 100644 index 0000000..44ed968 --- /dev/null +++ b/three-player-controller/src/collision/DynamicBox.ts @@ -0,0 +1,171 @@ +import * as THREE from "three"; +import { DynamicBody } from "./DynamicBody"; +import type { CollisionTemps } from "../utils/capsuleCollision"; + +/** + * 动态盒刚体。 + * 惯量按实心长方体;与网格的接触冲量走 ContactImpulseSolver,盒–盒走 resolveObbObb。 + */ +export class DynamicBoxBody extends DynamicBody { + readonly kind = "box" as const; // 形状判别 + halfExtents = new THREE.Vector3(); // 碰撞盒半边长 + + /** 按体积 × 密度算质量,并写入长方体惯量。 */ + constructor(opts: { + halfExtents: THREE.Vector3; + density: number; + restitution: number; + gravity: number; + position: THREE.Vector3; + quaternion?: THREE.Quaternion; + velocity: THREE.Vector3; + angularVelocity?: THREE.Vector3; + linearDamping: number; + angularDamping: number; + friction: number; + mesh: THREE.Mesh; + debugMesh: THREE.Mesh; + }) { + const hx = Math.max(1e-4, opts.halfExtents.x); + const hy = Math.max(1e-4, opts.halfExtents.y); + const hz = Math.max(1e-4, opts.halfExtents.z); + const volume = 8 * hx * hy * hz; + super({ + density: opts.density, + mass: volume * Math.max(1e-8, opts.density), + restitution: opts.restitution, + gravity: opts.gravity, + position: opts.position, + velocity: opts.velocity, + angularVelocity: opts.angularVelocity, + linearDamping: opts.linearDamping, + angularDamping: opts.angularDamping, + friction: opts.friction, + mesh: opts.mesh, + debugMesh: opts.debugMesh, + }); + this.halfExtents.set(hx, hy, hz); + if (opts.quaternion) this.quaternion.copy(opts.quaternion); + this.setCuboidInertia(); + } + + /** 特征半尺寸取三边最大值。 */ + characteristicExtent(): number { + return Math.max(this.halfExtents.x, this.halfExtents.y, this.halfExtents.z); + } + + /** 实心长方体惯量 I = m/12 (h²+d²) 等,并写 invInertia。 */ + setCuboidInertia(): void { + const m = this.mass; + const w = this.halfExtents.x * 2; + const h = this.halfExtents.y * 2; + const d = this.halfExtents.z * 2; + const ix = Math.max(1e-12, m / 12 * (h * h + d * d)); + const iy = Math.max(1e-12, m / 12 * (w * w + d * d)); + const iz = Math.max(1e-12, m / 12 * (w * w + h * h)); + this.invInertia.set(1 / ix, 1 / iy, 1 / iz); + } +} + +/** 类型收窄:动态盒。 */ +export function isBoxBody(body: DynamicBody): body is DynamicBoxBody { + return body.kind === "box"; +} + +const CAPSULE_OBB_SAMPLES = 8; // 胶囊轴采样点数(含端点) + +const _invQuat = new THREE.Quaternion(); +const _local = new THREE.Vector3(); +const _sample = new THREE.Vector3(); +const _bestOnSeg = new THREE.Vector3(); +const _bestDirLocal = new THREE.Vector3(); +const _candDir = new THREE.Vector3(); + +/** + * 胶囊 vs OBB:沿胶囊段采样,取到 AABB 的最近距离。 + * 命中时 temps.closestTri 为**胶囊指向刚体**的单位法线(与 capsuleSphereOverlap 一致), + * temps.closestSeg 为段上点,返回穿透深度。 + */ +export function capsuleObbOverlap( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + boxPos: THREE.Vector3, + boxQuat: THREE.Quaternion, + half: THREE.Vector3, + temps: CollisionTemps, +): number { + const segment = capsuleInfo.dynamicsSegment ?? capsuleInfo.segment; + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld); + + _invQuat.copy(boxQuat).invert(); + const hx = half.x; + const hy = half.y; + const hz = half.z; + let bestDist = Infinity; + let insideAxis = -1; // 0/1/2 表示在盒内时选的推出轴 + let insideSign = 1; + let insideDepth = 0; + + for (let i = 0; i <= CAPSULE_OBB_SAMPLES; i++) { + const t = i / CAPSULE_OBB_SAMPLES; + _sample.lerpVectors(temps.localSeg.start, temps.localSeg.end, t); + _local.copy(_sample).sub(boxPos).applyQuaternion(_invQuat); + + const inside = + Math.abs(_local.x) <= hx && + Math.abs(_local.y) <= hy && + Math.abs(_local.z) <= hz; + + if (inside) { + const dx = hx - Math.abs(_local.x); + const dy = hy - Math.abs(_local.y); + const dz = hz - Math.abs(_local.z); + const depth = capsuleInfo.radius + Math.min(dx, dy, dz); + if (depth > insideDepth) { + insideDepth = depth; + _bestOnSeg.copy(_sample); + // 取穿透最小的面,法线先取盒心→外侧(稍后翻成胶囊→盒) + if (dx <= dy && dx <= dz) { + insideAxis = 0; + insideSign = Math.sign(_local.x || 1); + } else if (dy <= dz) { + insideAxis = 1; + insideSign = Math.sign(_local.y || 1); + } else { + insideAxis = 2; + insideSign = Math.sign(_local.z || 1); + } + } + continue; + } + + const cx = Math.min(hx, Math.max(-hx, _local.x)); + const cy = Math.min(hy, Math.max(-hy, _local.y)); + const cz = Math.min(hz, Math.max(-hz, _local.z)); + // 盒表面 → 采样点(盒→胶囊);返回前取反 + _candDir.set(_local.x - cx, _local.y - cy, _local.z - cz); + const dist = _candDir.length(); + if (dist >= bestDist) continue; + bestDist = dist; + _bestOnSeg.copy(_sample); + if (dist > 1e-8) _bestDirLocal.copy(_candDir).multiplyScalar(1 / dist); + else _bestDirLocal.set(0, 1, 0); + } + + if (insideDepth > 0) { + temps.closestSeg.copy(_bestOnSeg); + if (insideAxis === 0) temps.closestTri.set(insideSign, 0, 0); + else if (insideAxis === 1) temps.closestTri.set(0, insideSign, 0); + else temps.closestTri.set(0, 0, insideSign); + // 盒外法线 → 翻成胶囊指向刚体(进入盒的方向) + temps.closestTri.applyQuaternion(boxQuat).negate(); + return insideDepth; + } + + if (bestDist >= capsuleInfo.radius) return 0; + temps.closestSeg.copy(_bestOnSeg); + // 盒→胶囊 取反 → 胶囊→盒 + temps.closestTri.copy(_bestDirLocal).applyQuaternion(boxQuat).negate(); + return capsuleInfo.radius - bestDist; +} diff --git a/three-player-controller/src/collision/DynamicSphere.ts b/three-player-controller/src/collision/DynamicSphere.ts new file mode 100644 index 0000000..c20ce79 --- /dev/null +++ b/three-player-controller/src/collision/DynamicSphere.ts @@ -0,0 +1,247 @@ +import * as THREE from "three"; +import { contactSkinForExtent, type RawContact } from "./contacts/ContactPoint"; +import { DynamicBody } from "./DynamicBody"; + +/** + * 动态球刚体。 + * 球惯量 I = ⅖ m r²;与网格的接触冲量走 ContactImpulseSolver。 + */ +export class DynamicSphereBody extends DynamicBody { + readonly kind = "sphere" as const; // 形状判别 + radius: number; // 碰撞半径 + + /** 按体积 × 密度算质量,并写入球惯量。 */ + constructor(opts: { + radius: number; + density: number; + restitution: number; + gravity: number; + position: THREE.Vector3; + velocity: THREE.Vector3; + angularVelocity?: THREE.Vector3; + linearDamping: number; + angularDamping: number; + friction: number; + mesh: THREE.Mesh; + debugMesh: THREE.Mesh; + }) { + const volume = (4 / 3) * Math.PI * opts.radius * opts.radius * opts.radius; + super({ + density: opts.density, + mass: volume * Math.max(1e-8, opts.density), + restitution: opts.restitution, + gravity: opts.gravity, + position: opts.position, + velocity: opts.velocity, + angularVelocity: opts.angularVelocity, + linearDamping: opts.linearDamping, + angularDamping: opts.angularDamping, + friction: opts.friction, + mesh: opts.mesh, + debugMesh: opts.debugMesh, + }); + this.radius = opts.radius; + this.setSphereInertia(); + } + + /** 特征半尺寸即半径。 */ + characteristicExtent(): number { + return this.radius; + } + + /** 实心球惯量 I = ⅖ m r²(对角),并写 invInertia。 */ + setSphereInertia(): void { + // 小尺度物体 I 可低于旧地板 1e-8;置 0 会导致无法滚动、地面摩擦过粘 + const i = Math.max(1e-12, 0.4 * this.mass * this.radius * this.radius); + const inv = 1 / i; + this.invInertia.set(inv, inv, inv); + } +} + +/** 类型收窄:动态球。 */ +export function isSphereBody(body: DynamicBody): body is DynamicSphereBody { + return body.kind === "sphere"; +} + +/** 球窄相预分配临时量(避免每帧 new)。 */ +export type SphereCollisionTemps = { + invMat: THREE.Matrix4; // mesh.matrixWorld 的逆 + normalMat: THREE.Matrix3; // 法线变换矩阵 + invQuat: THREE.Quaternion; // 盒朝向的逆 + localSphere: THREE.Sphere; // 网格本地空间中的查询球 + closest: THREE.Vector3; // 三角面 / 盒上最近点(本地) + worldClosest: THREE.Vector3; // 最近点世界坐标 + offset: THREE.Vector3; // 球心到最近点的偏移 + before: THREE.Vector3; // 推出前位置备份 + normal: THREE.Vector3; // 接触法线(由对面指向球) + local: THREE.Vector3; // 盒局部坐标 + contact: THREE.Vector3; // 世界接触点 + boxVel: THREE.Vector3; // 盒上接触点速度 + impulse: THREE.Vector3; // 冲量缓冲 +}; + +/** 创建球碰撞临时量。 */ +export function createSphereCollisionTemps(): SphereCollisionTemps { + return { + invMat: new THREE.Matrix4(), + normalMat: new THREE.Matrix3(), + invQuat: new THREE.Quaternion(), + localSphere: new THREE.Sphere(), + closest: new THREE.Vector3(), + worldClosest: new THREE.Vector3(), + offset: new THREE.Vector3(), + before: new THREE.Vector3(), + normal: new THREE.Vector3(), + local: new THREE.Vector3(), + contact: new THREE.Vector3(), + boxVel: new THREE.Vector3(), + impulse: new THREE.Vector3(), + }; +} + +/** + * 收集球 vs 单个 BVH 网格的原始接触(不移动球心)。 + * 法线由表面指向球心;penetration = radius - 距离;返回写入后的 rawCount。 + */ +export function collectSphereVsMeshContacts( + position: THREE.Vector3, + radius: number, + mesh: THREE.Mesh, + temps: SphereCollisionTemps, + raw: RawContact[], + rawCount: number, + maxRaw: number, +): number { + const tree = (mesh.geometry as any)?.boundsTree; + if (!tree || rawCount >= maxRaw) return rawCount; + + // 球变到网格本地,均匀缩放用 scale.x 近似 + temps.invMat.copy(mesh.matrixWorld).invert(); + temps.localSphere.center.copy(position).applyMatrix4(temps.invMat); + const scale = mesh.scale.x || 1; + temps.localSphere.radius = radius / scale; + + temps.normalMat.getNormalMatrix(mesh.matrixWorld); + + tree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsSphere(temps.localSphere), + intersectsTriangle: (tri: any) => { + if (rawCount >= maxRaw) return true; // 触达上限则提前结束遍历 + tri.closestPointToPoint(temps.localSphere.center, temps.closest); + temps.offset.subVectors(temps.localSphere.center, temps.closest); + const dist = temps.offset.length(); + const penetration = temps.localSphere.radius - dist; + // 允许轻微皮肤重叠(按球半径缩放,本地空间与 mesh.scale 对齐) + const localSkin = contactSkinForExtent(radius) / Math.max(scale, 1e-8); + if (penetration < -localSkin) return; + + if (dist > 1e-8) temps.normal.copy(temps.offset).multiplyScalar(1 / dist); + else temps.normal.set(0, 1, 0); // 球心几乎落在面上时给默认上法线 + temps.normal.applyMatrix3(temps.normalMat).normalize(); + + temps.worldClosest.copy(temps.closest).applyMatrix4(mesh.matrixWorld); + const slot = raw[rawCount++]; + slot.point.copy(temps.worldClosest); + slot.normal.copy(temps.normal); + slot.penetration = penetration * scale; // 穿透深度还原到世界尺度 + }, + boundsTraverseOrder: (box: THREE.Box3) => box.distanceToPoint(temps.localSphere.center) - temps.localSphere.radius, + }); + + return rawCount; +} + +/** + * 球 vs OBB:重叠则推出球并写 temps.normal / contact。 + * 法线由盒指向球;会就地修改 position。 + */ +export function collideSphereVsObb( + position: THREE.Vector3, + radius: number, + boxPos: THREE.Vector3, + boxQuat: THREE.Quaternion, + half: THREE.Vector3, + temps: SphereCollisionTemps, +): boolean { + // 球心变到盒局部 + temps.invQuat.copy(boxQuat).invert(); + temps.local.copy(position).sub(boxPos).applyQuaternion(temps.invQuat); + + const hx = half.x; + const hy = half.y; + const hz = half.z; + const inside = + Math.abs(temps.local.x) <= hx && + Math.abs(temps.local.y) <= hy && + Math.abs(temps.local.z) <= hz; + + if (inside) { + // 球心已在盒内:沿穿透最小的轴推出到表面外 radius + const dx = hx - Math.abs(temps.local.x); + const dy = hy - Math.abs(temps.local.y); + const dz = hz - Math.abs(temps.local.z); + if (dx <= dy && dx <= dz) { + temps.local.x = Math.sign(temps.local.x || 1) * (hx + radius); + temps.normal.set(Math.sign(temps.local.x), 0, 0); + } else if (dy <= dz) { + temps.local.y = Math.sign(temps.local.y || 1) * (hy + radius); + temps.normal.set(0, Math.sign(temps.local.y), 0); + } else { + temps.local.z = Math.sign(temps.local.z || 1) * (hz + radius); + temps.normal.set(0, 0, Math.sign(temps.local.z)); + } + temps.normal.applyQuaternion(boxQuat); + position.copy(temps.local).applyQuaternion(boxQuat).add(boxPos); + temps.contact.copy(position).addScaledVector(temps.normal, -radius); + return true; + } + + // 盒外:钳到盒表面最近点,若距球心 < radius 则推出 + temps.closest.set( + Math.min(hx, Math.max(-hx, temps.local.x)), + Math.min(hy, Math.max(-hy, temps.local.y)), + Math.min(hz, Math.max(-hz, temps.local.z)), + ); + temps.offset.subVectors(temps.local, temps.closest); + const dist = temps.offset.length(); + if (dist >= radius || dist < 1e-8) return false; + temps.offset.multiplyScalar(1 / dist); + temps.local.addScaledVector(temps.offset, radius - dist); + temps.normal.copy(temps.offset).applyQuaternion(boxQuat); + position.copy(temps.local).applyQuaternion(boxQuat).add(boxPos); + temps.contact.copy(temps.closest).applyQuaternion(boxQuat).add(boxPos); + return true; +} + +/** + * 两球按质量推出并交换法向速度。 + * 暂不走双刚体 ContactImpulseSolver(无摩擦 / 无角冲量)。 + */ +export function resolveSphereSphere( + a: DynamicSphereBody, + b: DynamicSphereBody, + temps: SphereCollisionTemps, + restitution: number, +): void { + temps.offset.subVectors(a.position, b.position); + const dist = temps.offset.length(); + const minDist = a.radius + b.radius; + if (dist >= minDist) return; + if (dist > 1e-8) temps.normal.copy(temps.offset).multiplyScalar(1 / dist); + else temps.normal.set(0, 1, 0); // 完全重合时给默认法线 + + // 位置分离(按 invMass 分配) + const depth = minDist - dist; + const invA = a.invMass; + const invB = b.invMass; + const invSum = invA + invB; + a.position.addScaledVector(temps.normal, depth * (invA / invSum)); + b.position.addScaledVector(temps.normal, -depth * (invB / invSum)); + + // 仅处理接近中的法向相对速度 + const rel = a.velocity.dot(temps.normal) - b.velocity.dot(temps.normal); + if (rel >= 0) return; + const impulse = -(1 + restitution) * rel / invSum; + a.velocity.addScaledVector(temps.normal, impulse * invA); + b.velocity.addScaledVector(temps.normal, -impulse * invB); +} diff --git a/three-player-controller/src/collision/colliderBuild.ts b/three-player-controller/src/collision/colliderBuild.ts new file mode 100644 index 0000000..e1d3e77 --- /dev/null +++ b/three-player-controller/src/collision/colliderBuild.ts @@ -0,0 +1,242 @@ +import * as THREE from "three"; +import { MeshBVH, acceleratedRaycast } from "three-mesh-bvh"; +import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import type { BvhWorkerPool } from "../utils/BvhWorkerPool"; + +/** + * 碰撞网格构建工具。 + */ + +/** 调试辅助物及其子级不应被烘焙进 mesh collider。 */ +function isExcludedFromCollider(object: THREE.Object3D): boolean { + let current: THREE.Object3D | null = object; + while (current) { + if (current.userData.excludeFromCollider === true) return true; + current = current.parent; + } + return false; +} + +/** 校验并补全合并所需属性(position / normal / uv);含 NaN 或顶点数不足时返回 null。 */ +export function ensureAttributesMinimal(geom: THREE.BufferGeometry): THREE.BufferGeometry | null { + if (!geom.attributes.position) return null; + const position = geom.attributes.position.array; + for (let i = 0; i < position.length; i++) { + if (!Number.isFinite(position[i])) return null; + } + if (geom.attributes.position.count < 3) return null; + if (!geom.attributes.normal) geom.computeVertexNormals(); + if (!geom.attributes.uv) { + const count = geom.attributes.position.count; + geom.setAttribute("uv", new THREE.BufferAttribute(new Float32Array(count * 2), 2)); + } + return geom; +} + +/** + * 统一一组几何的属性布局,便于 BufferGeometryUtils.mergeGeometries。 + * 去掉非必需属性;冲突项删除;缺失项用零填充。 + */ +export function unifiedAttribute(collected: THREE.BufferGeometry[]): THREE.BufferGeometry[] { + type AttrMeta = { itemSize: number; arrayCtor: any; examples: number; normalized: boolean }; + const attrMap = new Map(); + const attrConflict = new Set(); + const required = new Set(["position", "normal", "uv"]); + + for (const g of collected) + for (const name of Object.keys(g.attributes)) + if (!required.has(name)) g.deleteAttribute(name); + + for (const g of collected) { + for (const name of Object.keys(g.attributes)) { + const attr = g.attributes[name] as THREE.BufferAttribute; + const ctor = (attr.array as any).constructor; + if (!attrMap.has(name)) { + attrMap.set(name, { itemSize: attr.itemSize, arrayCtor: ctor, examples: 1, normalized: attr.normalized }); + } else { + const m = attrMap.get(name)!; + if (m.itemSize !== attr.itemSize || m.arrayCtor !== ctor || m.normalized !== attr.normalized) attrConflict.add(name); + else m.examples++; + } + } + } + + for (const name of attrConflict) { + for (const g of collected) if (g.attributes[name]) g.deleteAttribute(name); + attrMap.delete(name); + } + + for (const [name, meta] of attrMap) { + for (const g of collected) { + if (!g.attributes[name]) { + const count = g.attributes.position.count; + g.setAttribute(name, new THREE.BufferAttribute(new meta.arrayCtor(count * meta.itemSize), meta.itemSize, meta.normalized)); + } + } + } + return collected; +} + +/** 遍历 sources,把 Mesh / LineSegments 克隆到世界空间并收集(跳过 name === "capsule")。 */ +function collectWorldSpaceGeometries(sources: THREE.Object3D[]): THREE.BufferGeometry[] { + const collected: THREE.BufferGeometry[] = []; + const collectMesh = (mesh: THREE.Mesh | THREE.LineSegments) => { + try { + let geom = mesh.geometry.clone(); + geom.applyMatrix4(mesh.matrixWorld); + if (geom.index) geom = geom.toNonIndexed(); + const safe = ensureAttributesMinimal(geom); + if (safe) collected.push(safe); + } catch (e) { + console.warn("处理网格时出错:", mesh, e); + } + }; + for (const obj of sources) { + obj.updateMatrixWorld(true); + obj.traverse(c => { + const a = c as any; + if ( + (a.isMesh || a.isLineSegments) + && a.geometry + && c.name !== "capsule" + && !isExcludedFromCollider(c) + ) collectMesh(a); + }); + } + return collected; +} + +const _rtcTranslation = new THREE.Matrix4(); +const _rtcCenter = new THREE.Vector3(); + +/** + * 把合并后的几何收到包围盒中心附近,避免大坐标留在 Float32 顶点里。 + * 返回平移到原点的中心,调用方需把它编进 bakeInverse。 + */ +function recenterGeometry(geometry: THREE.BufferGeometry, target: THREE.Vector3): THREE.Vector3 { + geometry.computeBoundingBox(); + const box = geometry.boundingBox; + if (!box || box.isEmpty()) { + target.set(0, 0, 0); + return target; + } + box.getCenter(target); + if (target.lengthSq() === 0) return target; + geometry.translate(-target.x, -target.y, -target.z); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return target; +} + +/** + * 用合并几何创建线框调试 Mesh,并挂上 acceleratedRaycast。 + * localSpace 时关闭 matrixAutoUpdate,由外部每帧对齐 follow。 + */ +function makeDebugMesh(geometry: THREE.BufferGeometry, localSpace: boolean): THREE.Mesh { + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ + opacity: 0.5, + transparent: true, + wireframe: true, + depthTest: true, + side: THREE.DoubleSide, + }), + ); + mesh.raycast = acceleratedRaycast; + if (localSpace) { + mesh.matrixAutoUpdate = false; + } else { + mesh.layers.enable(1); + } + return mesh; +} + +/** build*MergedMesh 的返回值:调试用 mesh + 待挂 BVH 的 geometry。 */ +export type BuiltMeshCollider = { + mesh: THREE.Mesh; + geometry: THREE.BufferGeometry; + /** 运动学烘焙:inv(source.matrixWorld) * T(rtcCenter)。*/ + bakeInverse?: THREE.Matrix4; +}; + +/** + * 合并 source 为世界空间静态碰撞网格。 + * 仅合并几何,不挂 BVH;调用方需再走 attachBoundsTree,否则不能参与碰撞查询。 + */ +export function buildStaticMergedMesh(sources: THREE.Object3D | THREE.Object3D[]): BuiltMeshCollider | null { + const list = Array.isArray(sources) ? sources : [sources]; + let collected = collectWorldSpaceGeometries(list); + if (!collected.length) return null; + collected = unifiedAttribute(collected); + const merged = BufferGeometryUtils.mergeGeometries(collected, false); + if (!merged) { + console.error("合并几何失败"); + return null; + } + return { mesh: makeDebugMesh(merged, false), geometry: merged }; +} + +/** + * 合并 source 为运动学碰撞网格。 + * 先乘 matrixWorld 落到当前世界 / 局部 ENU,再绕包围盒中心收成小坐标; + * 调用方需再走 attachBoundsTree,并每帧用 + * source.matrixWorld * bakeInverse 跟随。 + */ +export function buildKinematicMergedMesh(source: THREE.Object3D): BuiltMeshCollider | null { + source.updateMatrixWorld(true); + let collected = collectWorldSpaceGeometries([source]); + if (!collected.length) return null; + collected = unifiedAttribute(collected); + const merged = BufferGeometryUtils.mergeGeometries(collected, false); + if (!merged) { + console.error("合并运动学几何失败"); + return null; + } + const rtcCenter = recenterGeometry(merged, _rtcCenter); + const bakeInverse = new THREE.Matrix4().copy(source.matrixWorld).invert(); + bakeInverse.multiply(_rtcTranslation.makeTranslation(rtcCenter.x, rtcCenter.y, rtcCenter.z)); + const mesh = makeDebugMesh(merged, true); + mesh.matrix.copy(source.matrixWorld).multiply(bakeInverse); + mesh.updateMatrixWorld(true); + return { mesh, geometry: merged, bakeInverse }; +} + +/** + * 为几何挂上 MeshBVH(写入 geometry.boundsTree)。 + * useWorker + workerPool 时异步构建,失败回退主线程;onStale 为真则丢弃过期结果。 + */ +export function attachBoundsTree( + geometry: THREE.BufferGeometry, + options: { + useWorker?: boolean; + workerPool?: BvhWorkerPool; + maxDepth?: number; + onReady: (bvh: MeshBVH) => void; + onStale?: () => boolean; + }, +): void { + const maxDepth = options.maxDepth; + const sync = () => { + const bvh = maxDepth != null + ? new MeshBVH(geometry, { maxDepth }) + : new MeshBVH(geometry); + (geometry as any).boundsTree = bvh; + options.onReady(bvh); + }; + + if (!options.useWorker || !options.workerPool) { + sync(); + return; + } + + void options.workerPool.generate(geometry).then((bvh) => { + if (options.onStale?.()) return; + (geometry as any).boundsTree = bvh; + options.onReady(bvh); + }).catch((error) => { + if (options.onStale?.()) return; + console.warn("异步构建 BVH 失败,回退到主线程:", error); + sync(); + }); +} diff --git a/three-player-controller/src/collision/colliderDesc.ts b/three-player-controller/src/collision/colliderDesc.ts new file mode 100644 index 0000000..18fc413 --- /dev/null +++ b/three-player-controller/src/collision/colliderDesc.ts @@ -0,0 +1,111 @@ +import type * as THREE from "three"; +import type { MotionType } from "./CollisionWorld"; + +/** 从场景节点采集并合并的三角网格。 */ +export type MeshSourceShape = { + kind: "mesh"; + /** 采集几何的来源;必传。 */ + source: THREE.Object3D | THREE.Object3D[]; + mesh?: never; +}; + +/** 已有(可含 BVH)的碰撞网格。 */ +export type MeshReadyShape = { + kind: "mesh"; + mesh: THREE.Mesh; + source?: never; +}; + +export type MeshShape = MeshSourceShape | MeshReadyShape; + +export type BoxShape = { + kind: "box"; + halfExtents: THREE.Vector3; + /** 世界位置,或相对 follow 的局部偏移。 */ + position?: THREE.Vector3; + quaternion?: THREE.Quaternion; +}; + +export type SphereShape = { + kind: "sphere"; + radius: number; + /** 世界位置,或相对 follow 的局部偏移。 */ + position?: THREE.Vector3; +}; + +export type ColliderShape = MeshShape | BoxShape | SphereShape; + +type ColliderDescBase = { + groups?: number; + mask?: number; + useWorker?: boolean; + userData?: unknown; +}; + +export type StaticColliderDesc = ColliderDescBase & { + motion: "static"; + shape: ColliderShape; + follow?: never; +}; + +export type KinematicColliderDesc = ColliderDescBase & { + motion: "kinematic"; + shape: ColliderShape; + /** 每帧碰撞体对齐该节点的 matrixWorld;mesh+source 时默认跟 source。 */ + follow?: THREE.Object3D; +}; + +type DynamicColliderMaterialFields = { + /** 密度,质量 = 体积 × 密度;默认见 DYNAMIC_BODY_DEFAULTS。 */ + density?: number; + /** 弹性,0 不反弹,1 完全弹;默认见 DYNAMIC_BODY_DEFAULTS。 */ + restitution?: number; + /** 摩擦;默认见 DYNAMIC_BODY_DEFAULTS。 */ + friction?: number; + /** 线阻尼,越大滑行越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + linearDamping?: number; + /** 角阻尼,越大旋转越快停;默认见 DYNAMIC_BODY_DEFAULTS。 */ + angularDamping?: number; + gravity?: number; + velocity?: THREE.Vector3; + angularVelocity?: THREE.Vector3; +}; + +/** 动态碰撞体描述。 */ +export type DynamicColliderDesc = ColliderDescBase & DynamicColliderMaterialFields & { + motion: "dynamic"; + shape: ColliderShape; + follow?: never; + /** 可选视觉网格。 */ + mesh?: THREE.Mesh; + /** + * 是否由 DynamicBodySystem 创建并推进刚体。 + * - 默认 true(省略时):球 / 可推箱子等会创建 DynamicBody + * - false:只登记 CollisionWorld,不创建 DynamicBody(车辆底盘由 VehicleRigidBody 推进) + */ + simulate?: boolean; +}; + +/** 动态刚体材质默认值。 */ +export const DYNAMIC_BODY_DEFAULTS = { + density: 1, + restitution: 0.2, + friction: 0.6, + linearDamping: 0.4, + angularDamping: 0.6, +}; + +/** 碰撞体创建描述。 */ +export type ColliderDesc = StaticColliderDesc | KinematicColliderDesc | DynamicColliderDesc; + +/** addCollider 返回的句柄。 */ +export type ColliderHandle = { + id: number; + motion: MotionType; + /** 运动学跟随目标(若有)。 */ + follow: THREE.Object3D | null; + /** mesh 采集源(若有)。 */ + source: THREE.Object3D | null; + /** 用于 BVH 查询的网格(static/kinematic mesh)。 */ + collisionMesh: THREE.Mesh | null; +}; diff --git a/three-player-controller/src/collision/contacts/ContactCache.ts b/three-player-controller/src/collision/contacts/ContactCache.ts new file mode 100644 index 0000000..19f1ca8 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactCache.ts @@ -0,0 +1,85 @@ +import * as THREE from "three"; +import { ContactManifold } from "./ContactManifold"; +import { ContactPoint } from "./ContactPoint"; +import { normalClass } from "./contactReducer"; + +const MATCH_NORMAL = 0.95; // 法线点积低于此值视为不同流形 +const MATCH_DISTANCE = 0.08; // 局部接触点匹配距离上限 + +/** findMatch 的命中结果。 */ +type MatchResult = { + point: ContactPoint; // 上一帧匹配到的接触点 + normal: THREE.Vector3; // 上一帧流形法线 +}; + +/** + * 帧间接触点匹配,用于冲量热启动(warm start)。 + * 求解前 match 继承冲量;求解后 save 供下一帧使用。 + */ +export class ContactCache { + private prev: ContactManifold[] = []; // 上一帧流形快照 + + /** + * 将上一帧冲量拷到本帧对应接触点。 + * 无匹配、或支撑面 ↔ 墙面切换时清零,避免错误热启动。 + */ + match(manifolds: ContactManifold[]): void { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + const prev = this.findMatch(manifold.normal, point); + if (!prev) { + point.resetImpulses(); + continue; + } + // 法线类别突变(地面 ↔ 墙)时冲量不可复用 + const prevClass = normalClass(prev.normal); + const nextClass = normalClass(manifold.normal); + if ( + (prevClass === "support" && nextClass === "wall") || + (prevClass === "wall" && nextClass === "support") + ) { + point.resetImpulses(); + continue; + } + point.normalImpulse = prev.point.normalImpulse; + point.tangentImpulse1 = prev.point.tangentImpulse1; + point.tangentImpulse2 = prev.point.tangentImpulse2; + } + } + } + + /** 深拷贝本帧流形与接触冲量,供下一帧 match。 */ + save(manifolds: ContactManifold[]): void { + this.prev = manifolds.map((src) => { + const copy = new ContactManifold(); + copy.normal.copy(src.normal); + copy.normalSum.copy(src.normalSum); + copy.contacts = src.contacts.map((p) => { + const c = new ContactPoint(); + c.copyFrom(p); + return c; + }); + return copy; + }); + } + + /** + * 在上一帧中找法线接近且局部点最近的接触。 + * 用刚体局部坐标匹配,避免物体移动后面世界点对不上。 + */ + private findMatch(normal: THREE.Vector3, point: ContactPoint): MatchResult | null { + let best: MatchResult | null = null; + let bestDist = MATCH_DISTANCE * MATCH_DISTANCE; + for (const manifold of this.prev) { + if (manifold.normal.dot(normal) < MATCH_NORMAL) continue; + for (const prev of manifold.contacts) { + const d = prev.localPoint.distanceToSquared(point.localPoint); + if (d < bestDist) { + bestDist = d; + best = { point: prev, normal: manifold.normal }; + } + } + } + return best; + } +} diff --git a/three-player-controller/src/collision/contacts/ContactManifold.ts b/three-player-controller/src/collision/contacts/ContactManifold.ts new file mode 100644 index 0000000..53250e0 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactManifold.ts @@ -0,0 +1,15 @@ +import * as THREE from "three"; +import { ContactPoint } from "./ContactPoint"; + +/** 同一法线簇下的接触点集合。 */ +export class ContactManifold { + normal = new THREE.Vector3(); + normalSum = new THREE.Vector3(); + contacts: ContactPoint[] = []; + + clear(): void { + this.contacts.length = 0; + this.normal.set(0, 1, 0); + this.normalSum.set(0, 0, 0); + } +} diff --git a/three-player-controller/src/collision/contacts/ContactPoint.ts b/three-player-controller/src/collision/contacts/ContactPoint.ts new file mode 100644 index 0000000..89a0097 --- /dev/null +++ b/three-player-controller/src/collision/contacts/ContactPoint.ts @@ -0,0 +1,48 @@ +import * as THREE from "three"; + +/** 标定用特征半尺寸;skin/slop 按 extent 相对此值缩放。 */ +export const CONTACT_REF_EXTENT = 0.2; + +export const CONTACT_SKIN = 0.002; + +/** 按刚体特征尺寸缩放接触皮肤,避免小物体相对厚度过大。 */ +export function contactSkinForExtent(extent: number): number { + const s = Math.max(1e-4, extent) / CONTACT_REF_EXTENT; + return CONTACT_SKIN * s; +} + +export type RawContact = { + point: THREE.Vector3; + normal: THREE.Vector3; + penetration: number; +}; + +/** 约束求解用的单个接触点。 */ +export class ContactPoint { + worldPoint = new THREE.Vector3(); + localPoint = new THREE.Vector3(); + penetration = 0; + biasVelocity = 0; + normalImpulse = 0; + tangentImpulse1 = 0; + tangentImpulse2 = 0; + tangent1 = new THREE.Vector3(); + tangent2 = new THREE.Vector3(); + + copyFrom(other: ContactPoint): void { + this.worldPoint.copy(other.worldPoint); + this.localPoint.copy(other.localPoint); + this.penetration = other.penetration; + this.normalImpulse = other.normalImpulse; + this.tangentImpulse1 = other.tangentImpulse1; + this.tangentImpulse2 = other.tangentImpulse2; + this.tangent1.copy(other.tangent1); + this.tangent2.copy(other.tangent2); + } + + resetImpulses(): void { + this.normalImpulse = 0; + this.tangentImpulse1 = 0; + this.tangentImpulse2 = 0; + } +} diff --git a/three-player-controller/src/collision/contacts/contactReducer.ts b/three-player-controller/src/collision/contacts/contactReducer.ts new file mode 100644 index 0000000..cc58ad8 --- /dev/null +++ b/three-player-controller/src/collision/contacts/contactReducer.ts @@ -0,0 +1,201 @@ +import * as THREE from "three"; +import type { ImpulseBody } from "../solver/ImpulseBody"; +import { ContactManifold } from "./ContactManifold"; +import { ContactPoint, CONTACT_REF_EXTENT, contactSkinForExtent, type RawContact } from "./ContactPoint"; + +const NORMAL_MERGE = 0.98; // 法线点积高于此值并入同一流形 +const MAX_POINTS = 4; // 单流形最多保留的接触点 +const SUPPORT_Y = 0.7; // ny 以上视为支撑面(地面) +const WALL_Y = 0.35; // ny 以下视为墙面 + +const _invQuat = new THREE.Quaternion(); +const _ab = new THREE.Vector3(); +const _ac = new THREE.Vector3(); +const _ap = new THREE.Vector3(); +const _n = new THREE.Vector3(); + +/** + * 将原始接触按法线聚类,并裁成至多 4 个支撑点。 + * 返回写入后的 out(长度收束为有效流形数)。 + */ +export function reduceContacts( + raw: RawContact[], + count: number, + body: ImpulseBody, + out: ContactManifold[], +): ContactManifold[] { + for (const m of out) m.clear(); + let manifoldCount = 0; + + // 按法线合并进流形,并累计 normalSum + const skin = contactSkinForExtent(body.characteristicExtent?.() ?? CONTACT_REF_EXTENT); + for (let r = 0; r < count; r++) { + const contact = raw[r]; + if (contact.penetration < -skin || contact.normal.lengthSq() < 1e-8) continue; + _n.copy(contact.normal).normalize(); + let cluster = -1; + for (let i = 0; i < manifoldCount; i++) { + if (out[i].normal.dot(_n) > NORMAL_MERGE) { + cluster = i; + break; + } + } + if (cluster < 0) { + if (manifoldCount === out.length) { + out.push(new ContactManifold()); + } + cluster = manifoldCount; + manifoldCount += 1; + out[cluster].normalSum.copy(_n); + out[cluster].normal.copy(_n); + } else { + out[cluster].normalSum.add(_n); + out[cluster].normal.copy(out[cluster].normalSum).normalize(); + } + out[cluster].contacts.push(makePoint(contact, body)); + } + + // 定稿法线、裁点、建切向基 + for (let i = 0; i < manifoldCount; i++) { + const manifold = out[i]; + if (manifold.normalSum.lengthSq() < 1e-8) manifold.normal.set(0, 1, 0); + else manifold.normal.copy(manifold.normalSum).normalize(); + if (manifold.contacts.length > MAX_POINTS) { + manifold.contacts = pickSupportPoints(manifold.contacts); + } + for (const point of manifold.contacts) { + buildTangents(manifold.normal, point); + } + } + + out.length = manifoldCount; + stabilizeEdgeManifolds(out); + return out; +} + +/** 按法线竖直分量分类:支撑面 / 墙 / 斜面。 */ +export function normalClass(normal: THREE.Vector3): "support" | "wall" | "slant" { + if (normal.y >= SUPPORT_Y) return "support"; + if (normal.y <= WALL_Y) return "wall"; + return "slant"; +} + +/** + * 边角同时碰到地面与墙时丢掉斜面流形,避免错误推力。 + * 若没有支撑面,则把斜面法线抬到至少 SUPPORT_Y,减轻卡边抖动。 + */ +function stabilizeEdgeManifolds(manifolds: ContactManifold[]): void { + let hasSupport = false; + let hasWall = false; + for (const m of manifolds) { + const cls = normalClass(m.normal); + if (cls === "support") hasSupport = true; + else if (cls === "wall") hasWall = true; + } + + if (hasSupport && hasWall) { + let write = 0; + for (let i = 0; i < manifolds.length; i++) { + if (normalClass(manifolds[i].normal) === "slant") continue; + if (write !== i) manifolds[write] = manifolds[i]; + write += 1; + } + manifolds.length = write; + return; + } + + if (!hasSupport) { + for (const m of manifolds) { + if (normalClass(m.normal) !== "slant") continue; + m.normal.y = Math.max(m.normal.y, SUPPORT_Y); + m.normal.normalize(); + m.normalSum.copy(m.normal); + for (const point of m.contacts) { + buildTangents(m.normal, point); + } + } + } +} + +/** 由原始接触生成 ContactPoint,并写入刚体局部坐标(供帧间匹配)。 */ +function makePoint(raw: RawContact, body: ImpulseBody): ContactPoint { + const point = new ContactPoint(); + point.worldPoint.copy(raw.point); + point.penetration = raw.penetration; + _invQuat.copy(body.quaternion).invert(); + point.localPoint.subVectors(raw.point, body.position).applyQuaternion(_invQuat); + return point; +} + +/** + * 从过多接触点中挑最多 4 个:最深穿透 → 最远点 → 最大面积第三点 → 离平面最远第四点。 + * 用于稳定盒/车身 resting 接触。 + */ +function pickSupportPoints(points: ContactPoint[]): ContactPoint[] { + if (points.length <= MAX_POINTS) return points; + + // p0:穿透最深 + let i0 = 0; + for (let i = 1; i < points.length; i++) { + if (points[i].penetration > points[i0].penetration) i0 = i; + } + const p0 = points[i0]; + + // p1:离 p0 最远 + let i1 = i0; + let best = -1; + for (let i = 0; i < points.length; i++) { + const d = points[i].worldPoint.distanceToSquared(p0.worldPoint); + if (d > best) { + best = d; + i1 = i; + } + } + const p1 = points[i1]; + + // p2:与 p0p1 构成面积最大 + let i2 = i0; + best = -1; + _ab.subVectors(p1.worldPoint, p0.worldPoint); + for (let i = 0; i < points.length; i++) { + _ap.subVectors(points[i].worldPoint, p0.worldPoint); + const area = _ap.cross(_ab).lengthSq(); + if (area > best) { + best = area; + i2 = i; + } + } + const p2 = points[i2]; + + // p3:离 p0p1p2 平面最远(略加权穿透) + let i3 = i0; + best = -1; + _ab.subVectors(p1.worldPoint, p0.worldPoint); + _ac.subVectors(p2.worldPoint, p0.worldPoint); + _n.copy(_ab).cross(_ac); + for (let i = 0; i < points.length; i++) { + if (i === i0 || i === i1 || i === i2) continue; + _ap.subVectors(points[i].worldPoint, p0.worldPoint); + const d = Math.abs(_ap.dot(_n)); + const score = d + points[i].penetration * 0.01; + if (score > best) { + best = score; + i3 = i; + } + } + + const picked = [p0]; + if (i1 !== i0) picked.push(p1); + if (i2 !== i0 && i2 !== i1) picked.push(p2); + if (i3 !== i0 && i3 !== i1 && i3 !== i2 && picked.length < MAX_POINTS) picked.push(points[i3]); + return picked; +} + +/** 由流形法线构造正交切向基(摩擦两轴)。 */ +function buildTangents(normal: THREE.Vector3, point: ContactPoint): void { + if (Math.abs(normal.y) < 0.99) point.tangent1.set(0, 1, 0).cross(normal); + else point.tangent1.set(1, 0, 0).cross(normal); // 法线近竖直时换参考轴 + if (point.tangent1.lengthSq() < 1e-8) point.tangent1.set(0, 0, 1).cross(normal); + point.tangent1.normalize(); + point.tangent2.copy(normal).cross(point.tangent1).normalize(); +} diff --git a/three-player-controller/src/collision/groups.ts b/three-player-controller/src/collision/groups.ts new file mode 100644 index 0000000..0a5f495 --- /dev/null +++ b/three-player-controller/src/collision/groups.ts @@ -0,0 +1,20 @@ +/** 碰撞业务分组 bitmask(与 MotionType 正交)。 */ +export const CollisionGroup = { + ALL: 0xffff, + /** 场景静态 / 运动学网格等默认层。 */ + DEFAULT: 1 << 0, + /** 人物相关(预留)。 */ + CHARACTER: 1 << 1, + /** 车辆底盘盒。 */ + VEHICLE: 1 << 2, + /** 动态碎片(球等)。 */ + DEBRIS: 1 << 3, +} as const; + +/** 人物胶囊 / 相机射线的单向查询目标。 */ +export const CHARACTER_QUERY_MASK = CollisionGroup.DEFAULT; + +/** 全部分组掩码。 */ +export const ALL_COLLISION_MASK = CollisionGroup.ALL; + +export type CollisionGroupBits = (typeof CollisionGroup)[keyof typeof CollisionGroup]; diff --git a/three-player-controller/src/collision/index.ts b/three-player-controller/src/collision/index.ts new file mode 100644 index 0000000..609c741 --- /dev/null +++ b/three-player-controller/src/collision/index.ts @@ -0,0 +1,31 @@ +export type { MotionType, CollisionCollider, CollisionShape, AddColliderOptions, MeshQueryFilter, MeshQueryOptions } from "./CollisionWorld"; +export { CollisionWorld } from "./CollisionWorld"; +export { + CollisionGroup, + CHARACTER_QUERY_MASK, + ALL_COLLISION_MASK, + type CollisionGroupBits, +} from "./groups"; +export type { + ColliderDesc, + ColliderHandle, + ColliderShape, + MeshShape, + BoxShape, + SphereShape, + StaticColliderDesc, + KinematicColliderDesc, + DynamicColliderDesc, +} from "./colliderDesc"; +export { DYNAMIC_BODY_DEFAULTS } from "./colliderDesc"; +export { DynamicBody } from "./DynamicBody"; +export type { DynamicBodyKind, DynamicBodyMaterialOptions } from "./DynamicBody"; +export { DynamicSphereBody, isSphereBody } from "./DynamicSphere"; +export { DynamicBoxBody, isBoxBody } from "./DynamicBox"; +export { + buildStaticMergedMesh, + buildKinematicMergedMesh, + attachBoundsTree, + ensureAttributesMinimal, + unifiedAttribute, +} from "./colliderBuild"; diff --git a/three-player-controller/src/collision/obbObb.ts b/three-player-controller/src/collision/obbObb.ts new file mode 100644 index 0000000..fd63846 --- /dev/null +++ b/three-player-controller/src/collision/obbObb.ts @@ -0,0 +1,223 @@ +import * as THREE from "three"; +import { impulseDenominator } from "../utils/vehiclePhysics/vehicleMath"; +import type { ImpulseBody } from "./solver/ImpulseBody"; + +const EPS = 1e-8; // 退化轴 / 零长度下限 +const AXIS_EPS = 1e-10; // 平行边叉积视为退化 +const BAUMGARTE = 0.8; // 每帧推出重叠的比例 +const RESTITUTION = 0.2; // 车车法向弹性 +const FRICTION = 0.4; // 切向摩擦相对法向冲量的上限 + +/** OBB–OBB 解算所需的刚体字段(扩展共享 ImpulseBody)。 */ +export type ObbRigidBody = ImpulseBody & { + halfExtents: THREE.Vector3; // 碰撞盒半边长 +}; + +/** OBB SAT / 接触冲量的预分配临时量。 */ +export type ObbObbTemps = { + ax: THREE.Vector3; // A 盒世界轴 X + ay: THREE.Vector3; + az: THREE.Vector3; + bx: THREE.Vector3; // B 盒世界轴 X + by: THREE.Vector3; + bz: THREE.Vector3; + axis: THREE.Vector3; // 当前测试分离轴 + bestN: THREE.Vector3; // 最小平移法线(A → B) + tangent: THREE.Vector3; // 摩擦切向 + invQuatA: THREE.Quaternion; + invQuatB: THREE.Quaternion; + local: THREE.Vector3; // 支撑点局部缓冲 + supportA: THREE.Vector3; // A 沿法线的支撑点 + supportB: THREE.Vector3; // B 沿反法线的支撑点 + contact: THREE.Vector3; // 世界接触点(两支撑中点) + velA: THREE.Vector3; // 接触点处 A 速度 + velB: THREE.Vector3; + rel: THREE.Vector3; // 质心差 / 相对速度缓冲 + impulse: THREE.Vector3; + r: THREE.Vector3; // impulseDenominator 用 + gcross: THREE.Vector3; + localI: THREE.Vector3; + worldI: THREE.Vector3; + invQuat: THREE.Quaternion; +}; + +/** 创建 OBB 碰撞临时量。 */ +export function createObbObbTemps(): ObbObbTemps { + return { + ax: new THREE.Vector3(), + ay: new THREE.Vector3(), + az: new THREE.Vector3(), + bx: new THREE.Vector3(), + by: new THREE.Vector3(), + bz: new THREE.Vector3(), + axis: new THREE.Vector3(), + bestN: new THREE.Vector3(), + tangent: new THREE.Vector3(), + invQuatA: new THREE.Quaternion(), + invQuatB: new THREE.Quaternion(), + local: new THREE.Vector3(), + supportA: new THREE.Vector3(), + supportB: new THREE.Vector3(), + contact: new THREE.Vector3(), + velA: new THREE.Vector3(), + velB: new THREE.Vector3(), + rel: new THREE.Vector3(), + impulse: new THREE.Vector3(), + r: new THREE.Vector3(), + gcross: new THREE.Vector3(), + localI: new THREE.Vector3(), + worldI: new THREE.Vector3(), + invQuat: new THREE.Quaternion(), + }; +} + +/** 盒在轴 n 上的投影半径(半宽之和)。 */ +function radiusOnAxis(ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3, half: THREE.Vector3, n: THREE.Vector3): number { + return half.x * Math.abs(n.dot(ax)) + half.y * Math.abs(n.dot(ay)) + half.z * Math.abs(n.dot(az)); +} + +/** + * 盒在 dir 方向上的支撑点(取各轴符号 * 半边,面法向时落在对应面心附近)。 + */ +function supportPoint( + pos: THREE.Vector3, + quat: THREE.Quaternion, + invQuat: THREE.Quaternion, + half: THREE.Vector3, + dir: THREE.Vector3, + local: THREE.Vector3, + out: THREE.Vector3, +): THREE.Vector3 { + local.copy(dir).applyQuaternion(invQuat); + out.set( + Math.sign(local.x) * half.x, + Math.sign(local.y) * half.y, + Math.sign(local.z) * half.z, + ); + return out.applyQuaternion(quat).add(pos); +} + +/** + * 两车底盘 OBB:SAT 最小平移推出,再按质量在接触点施法向 / 摩擦冲量。 + * `temps.bestN` 指向从 A 到 B;有重叠并处理则返回 true。 + */ +export function resolveObbObb(a: ObbRigidBody, b: ObbRigidBody, temps: ObbObbTemps): boolean { + // 两盒局部轴变到世界 + temps.ax.set(1, 0, 0).applyQuaternion(a.quaternion); + temps.ay.set(0, 1, 0).applyQuaternion(a.quaternion); + temps.az.set(0, 0, 1).applyQuaternion(a.quaternion); + temps.bx.set(1, 0, 0).applyQuaternion(b.quaternion); + temps.by.set(0, 1, 0).applyQuaternion(b.quaternion); + temps.bz.set(0, 0, 1).applyQuaternion(b.quaternion); + + let minOverlap = Infinity; + /** 测试一根分离轴:无重叠返回 false;否则更新最小平移轴。 */ + const tryAxis = (x: number, y: number, z: number): boolean => { + temps.axis.set(x, y, z); + const lenSq = temps.axis.lengthSq(); + if (lenSq < AXIS_EPS) return true; // 平行边叉积退化,跳过 + temps.axis.multiplyScalar(1 / Math.sqrt(lenSq)); + // 重叠深度 = 两投影半径之和 - 质心差在轴上的投影 + const overlap = + radiusOnAxis(temps.ax, temps.ay, temps.az, a.halfExtents, temps.axis) + + radiusOnAxis(temps.bx, temps.by, temps.bz, b.halfExtents, temps.axis) + - Math.abs(temps.axis.dot(temps.rel.subVectors(b.position, a.position))); + if (overlap <= EPS) return false; // 分离 + if (overlap < minOverlap) { + minOverlap = overlap; + temps.bestN.copy(temps.axis); + // 法线统一为 A → B + if (temps.bestN.dot(temps.rel) < 0) temps.bestN.negate(); + } + return true; + }; + + // 面法线:A、B 各 3 轴 + if (!tryAxis(temps.ax.x, temps.ax.y, temps.ax.z)) return false; + if (!tryAxis(temps.ay.x, temps.ay.y, temps.ay.z)) return false; + if (!tryAxis(temps.az.x, temps.az.y, temps.az.z)) return false; + if (!tryAxis(temps.bx.x, temps.bx.y, temps.bx.z)) return false; + if (!tryAxis(temps.by.x, temps.by.y, temps.by.z)) return false; + if (!tryAxis(temps.bz.x, temps.bz.y, temps.bz.z)) return false; + + // 边叉积:A 的 3 边 × B 的 3 边 + temps.axis.copy(temps.ax).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ax).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ax).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.ay).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.bx); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.by); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + temps.axis.copy(temps.az).cross(temps.bz); + if (!tryAxis(temps.axis.x, temps.axis.y, temps.axis.z)) return false; + + if (!Number.isFinite(minOverlap)) return false; + + // 位置修正:深度钳制后按 invMass 分配,乘 Baumgarte + const minHalf = Math.min( + a.halfExtents.x, a.halfExtents.y, a.halfExtents.z, + b.halfExtents.x, b.halfExtents.y, b.halfExtents.z, + ); + const depth = Math.min(minOverlap, minHalf * 2) * BAUMGARTE; + const invSum = a.invMass + b.invMass; + if (invSum <= EPS || depth <= EPS) return false; + + // 接触点 ≈ 两盒沿法线方向支撑点的中点 + temps.invQuatA.copy(a.quaternion).invert(); + temps.invQuatB.copy(b.quaternion).invert(); + supportPoint(a.position, a.quaternion, temps.invQuatA, a.halfExtents, temps.bestN, temps.local, temps.supportA); + supportPoint(b.position, b.quaternion, temps.invQuatB, b.halfExtents, temps.rel.copy(temps.bestN).negate(), temps.local, temps.supportB); + temps.contact.copy(temps.supportA).add(temps.supportB).multiplyScalar(0.5); + + a.position.addScaledVector(temps.bestN, -depth * (a.invMass / invSum)); + b.position.addScaledVector(temps.bestN, depth * (b.invMass / invSum)); + + // 法向冲量:仅处理接近中的相对速度 + a.getVelocityAtPoint(temps.contact, temps.velA); + b.getVelocityAtPoint(temps.contact, temps.velB); + const relN = temps.velB.dot(temps.bestN) - temps.velA.dot(temps.bestN); + if (relN >= 0) return true; + + const denomN = + impulseDenominator(a, temps.contact, temps.bestN, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat) + + impulseDenominator(b, temps.contact, temps.bestN, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat); + if (denomN <= EPS) return true; + + const jn = -(1 + RESTITUTION) * relN / denomN; + temps.impulse.copy(temps.bestN).multiplyScalar(-jn); + a.applyImpulseAtPoint(temps.impulse, temps.contact); + temps.impulse.copy(temps.bestN).multiplyScalar(jn); + b.applyImpulseAtPoint(temps.impulse, temps.contact); + + // 库仑摩擦:切向相对速度,冲量钳在 μ|jn| + a.getVelocityAtPoint(temps.contact, temps.velA); + b.getVelocityAtPoint(temps.contact, temps.velB); + temps.rel.subVectors(temps.velA, temps.velB); + temps.rel.addScaledVector(temps.bestN, -temps.rel.dot(temps.bestN)); // 去掉法向分量 + const tLen = temps.rel.length(); + if (tLen <= EPS) return true; + temps.tangent.copy(temps.rel).multiplyScalar(1 / tLen); + + const denomT = + impulseDenominator(a, temps.contact, temps.tangent, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat) + + impulseDenominator(b, temps.contact, temps.tangent, temps.r, temps.gcross, temps.localI, temps.worldI, temps.invQuat); + if (denomT <= EPS) return true; + + let jt = -tLen / denomT; + const maxF = FRICTION * Math.abs(jn); + jt = Math.max(-maxF, Math.min(maxF, jt)); + temps.impulse.copy(temps.tangent).multiplyScalar(jt); + a.applyImpulseAtPoint(temps.impulse, temps.contact); + temps.impulse.negate(); + b.applyImpulseAtPoint(temps.impulse, temps.contact); + return true; +} diff --git a/three-player-controller/src/collision/solver/ContactImpulseSolver.ts b/three-player-controller/src/collision/solver/ContactImpulseSolver.ts new file mode 100644 index 0000000..eefb2d4 --- /dev/null +++ b/three-player-controller/src/collision/solver/ContactImpulseSolver.ts @@ -0,0 +1,56 @@ +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ImpulseBody } from "./ImpulseBody"; +import { solveFrictionConstraint, warmStartFriction } from "./frictionConstraint"; +import { + prepareNormalConstraint, + solveNormalConstraint, + warmStartNormal, +} from "./normalConstraint"; + +/** + * 共享接触冲量求解器:法向 + 摩擦锥,可选热启动。 + * 人物胶囊不走此核(位置推出);轮悬架 / 轮胎仍在车辆模块。 + */ +export class ContactImpulseSolver { + velocityIterations = 8; // 速度迭代次数 + warmStart = true; // 是否复用上一帧冲量做热启动 + + /** + * 对单个刚体与一组流形做速度级求解。 + * 顺序:准备 bias → 热启动 → 交替解法向 / 摩擦。 + */ + solveVelocity( + body: ImpulseBody, + manifolds: ContactManifold[], + dt: number, + ): void { + if (!manifolds.length) return; + + // 写入弹性与穿透修正用的 biasVelocity + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + prepareNormalConstraint(body, manifold, point, dt); + } + } + + // 先施加缓存冲量,减少 resting 抖动 + if (this.warmStart) { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + warmStartNormal(body, manifold, point); + warmStartFriction(body, point); + } + } + } + + // PGS:每轮先法向后摩擦 + for (let i = 0; i < this.velocityIterations; i++) { + for (const manifold of manifolds) { + for (const point of manifold.contacts) { + solveNormalConstraint(body, manifold, point); + solveFrictionConstraint(body, manifold, point); + } + } + } + } +} diff --git a/three-player-controller/src/collision/solver/ImpulseBody.ts b/three-player-controller/src/collision/solver/ImpulseBody.ts new file mode 100644 index 0000000..d4feef0 --- /dev/null +++ b/three-player-controller/src/collision/solver/ImpulseBody.ts @@ -0,0 +1,28 @@ +import type * as THREE from "three"; + +/** + * 接触冲量求解所需的刚体接口。 + */ +export type ImpulseBody = { + /** 世界质心位置。 */ + position: THREE.Vector3; + /** 世界朝向。 */ + quaternion: THREE.Quaternion; + /** 质量倒数。 */ + invMass: number; + /** 局部惯量倒数对角元。 */ + invInertia: THREE.Vector3; + /** 摩擦系数 μ;缺省由摩擦约束使用默认值。 */ + friction?: number; + /** 弹性,0 不反弹,1 完全弹;缺省 0。 */ + restitution?: number; + /** + * 特征半尺寸(用于缩放接触 skin / 穿透 slop)。 + * 缺省按 CONTACT_REF_EXTENT,不缩放。 + */ + characteristicExtent?: () => number; + /** 读取世界点处速度:v + ω × r。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3; + /** 在世界点施加冲量(线速度 + 角速度)。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, point: THREE.Vector3): void; +}; diff --git a/three-player-controller/src/collision/solver/frictionConstraint.ts b/three-player-controller/src/collision/solver/frictionConstraint.ts new file mode 100644 index 0000000..1c57e41 --- /dev/null +++ b/three-player-controller/src/collision/solver/frictionConstraint.ts @@ -0,0 +1,73 @@ +import * as THREE from "three"; +import { effectiveMass } from "../../utils/vehiclePhysics/vehicleMath"; +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ContactPoint } from "../contacts/ContactPoint"; +import type { ImpulseBody } from "./ImpulseBody"; + +const DEFAULT_FRICTION = 0.6; // body.friction 未设时的库仑系数 μ + +const _r = new THREE.Vector3(); +const _gcross = new THREE.Vector3(); +const _local = new THREE.Vector3(); +const _world = new THREE.Vector3(); +const _invQuat = new THREE.Quaternion(); +const _vel = new THREE.Vector3(); +const _impulse = new THREE.Vector3(); + +/** 热启动:施加上一帧累计的切向冲量。 */ +export function warmStartFriction( + body: ImpulseBody, + point: ContactPoint, +): void { + if (point.tangentImpulse1 === 0 && point.tangentImpulse2 === 0) return; + _impulse.copy(point.tangent1).multiplyScalar(point.tangentImpulse1); + _impulse.addScaledVector(point.tangent2, point.tangentImpulse2); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} + +/** + * 解切向摩擦约束(库仑锥)。 + * 上限 maxF = μ · 法向冲量;超出则等比缩放两切向冲量。 + */ +export function solveFrictionConstraint( + body: ImpulseBody, + _manifold: ContactManifold, + point: ContactPoint, +): void { + const mu = body.friction ?? DEFAULT_FRICTION; + const maxF = mu * point.normalImpulse; + if (maxF <= 1e-8) { + // 无法向压力则无摩擦 + point.tangentImpulse1 = 0; + point.tangentImpulse2 = 0; + return; + } + + body.getVelocityAtPoint(point.worldPoint, _vel); + const vt1 = _vel.dot(point.tangent1); + const vt2 = _vel.dot(point.tangent2); + const mass1 = effectiveMass(body, point.worldPoint, point.tangent1, _r, _gcross, _local, _world, _invQuat); + const mass2 = effectiveMass(body, point.worldPoint, point.tangent2, _r, _gcross, _local, _world, _invQuat); + + // 累计切向冲量,使切向相对速度趋近 0 + const old1 = point.tangentImpulse1; + const old2 = point.tangentImpulse2; + point.tangentImpulse1 += mass1 > 0 ? -vt1 * mass1 : 0; + point.tangentImpulse2 += mass2 > 0 ? -vt2 * mass2 : 0; + + // 钳到摩擦锥:‖λt‖ ≤ μ λn + const magSq = point.tangentImpulse1 * point.tangentImpulse1 + point.tangentImpulse2 * point.tangentImpulse2; + const maxSq = maxF * maxF; + if (magSq > maxSq && magSq > 1e-12) { + const scale = maxF / Math.sqrt(magSq); + point.tangentImpulse1 *= scale; + point.tangentImpulse2 *= scale; + } + + // 只施加本轮增量 + const d1 = point.tangentImpulse1 - old1; + const d2 = point.tangentImpulse2 - old2; + if (d1 === 0 && d2 === 0) return; + _impulse.copy(point.tangent1).multiplyScalar(d1).addScaledVector(point.tangent2, d2); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} diff --git a/three-player-controller/src/collision/solver/normalConstraint.ts b/three-player-controller/src/collision/solver/normalConstraint.ts new file mode 100644 index 0000000..0c31eef --- /dev/null +++ b/three-player-controller/src/collision/solver/normalConstraint.ts @@ -0,0 +1,92 @@ +import * as THREE from "three"; +import { effectiveMass } from "../../utils/vehiclePhysics/vehicleMath"; +import { CONTACT_REF_EXTENT } from "../contacts/ContactPoint"; +import type { ContactManifold } from "../contacts/ContactManifold"; +import type { ContactPoint } from "../contacts/ContactPoint"; +import type { ImpulseBody } from "./ImpulseBody"; + +/** 下列为 CONTACT_REF_EXTENT 下的基准值;求解时按特征尺寸缩放。 */ +const PENETRATION_SLOP = 0.003; // 允许的残留穿透,小于此不修正 +const PENETRATION_BIAS_FACTOR = 0.05; // Baumgarte 系数 +const MAX_CORRECTION_VELOCITY = 0.04; // 穿透修正速度上限,避免弹飞 +const SUPPORT_BIAS_Y = 0.5; // 仅对偏竖直的支撑面做法向 bias +const RESTITUTION_THRESHOLD = 0.1; // 接近静止时不再加弹性,避免微弹 + +const _r = new THREE.Vector3(); +const _gcross = new THREE.Vector3(); +const _local = new THREE.Vector3(); +const _world = new THREE.Vector3(); +const _invQuat = new THREE.Quaternion(); +const _vel = new THREE.Vector3(); +const _impulse = new THREE.Vector3(); + +function contactScale(body: ImpulseBody): number { + const extent = body.characteristicExtent?.() ?? CONTACT_REF_EXTENT; + return Math.max(1e-4, extent) / CONTACT_REF_EXTENT; +} + +/** + * 准备法向约束的 biasVelocity。 + * 含弹性目标分离速度,以及支撑面上的穿透位置修正。 + */ +export function prepareNormalConstraint( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, + dt: number, +): void { + point.biasVelocity = 0; + const scale = contactScale(body); + body.getVelocityAtPoint(point.worldPoint, _vel); + const vn = _vel.dot(manifold.normal); + const e = body.restitution ?? 0; + if (e > 0 && vn < -RESTITUTION_THRESHOLD * scale) { + // 目标分离速度 ≈ -e·vn;并入 bias 后 λ = -(vn - bias)·m ≈ (1+e)|vn|·m + point.biasVelocity += -e * vn; + } + if (dt <= 1e-8) return; + // 墙面等非支撑接触不做穿透 bias,减少侧向拉扯 + if (manifold.normal.y <= SUPPORT_BIAS_Y) return; + const error = point.penetration - PENETRATION_SLOP * scale; + if (error <= 0) return; + point.biasVelocity += Math.min( + error * PENETRATION_BIAS_FACTOR / dt, + MAX_CORRECTION_VELOCITY * scale, + ); +} + +/** 热启动:施加上一帧累计的法向冲量。 */ +export function warmStartNormal( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, +): void { + if (point.normalImpulse === 0) return; + _impulse.copy(manifold.normal).multiplyScalar(point.normalImpulse); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} + +/** + * 解法向非穿透约束。 + * 冲量累计且钳制为 ≥ 0(只推开、不拉回)。 + */ +export function solveNormalConstraint( + body: ImpulseBody, + manifold: ContactManifold, + point: ContactPoint, +): void { + const n = manifold.normal; + body.getVelocityAtPoint(point.worldPoint, _vel); + const vn = _vel.dot(n); + const mass = effectiveMass(body, point.worldPoint, n, _r, _gcross, _local, _world, _invQuat); + if (mass <= 0) return; + + // 使 vn 趋向 biasVelocity(弹性 + 穿透修正) + const lambda = -(vn - point.biasVelocity) * mass; + const old = point.normalImpulse; + point.normalImpulse = Math.max(0, old + lambda); + const applied = point.normalImpulse - old; + if (applied === 0) return; + _impulse.copy(n).multiplyScalar(applied); + body.applyImpulseAtPoint(_impulse, point.worldPoint); +} diff --git a/three-player-controller/src/foot-ik.ts b/three-player-controller/src/foot-ik.ts new file mode 100644 index 0000000..08084bc --- /dev/null +++ b/three-player-controller/src/foot-ik.ts @@ -0,0 +1,8 @@ +export { + FootIK, + type BoneRef, + type FootIKLegBoneConfig, + type FootIKOptions, + type FootIKSide, + type FootIKSkeletonConfig, +} from "./plugins/foot-ik"; diff --git a/three-player-controller/src/index.ts b/three-player-controller/src/index.ts new file mode 100644 index 0000000..b4f6d48 --- /dev/null +++ b/three-player-controller/src/index.ts @@ -0,0 +1,55 @@ +// 主入口 +export * from "./PlayerController"; +export type { + JumpButtonOptions, + KeyAction, + KeyMap, + LegacyPlayerModelSource, + LegacyVehicleModelSource, + LoadedPlayerModelSource, + LoadedVehicleModelSource, + MobileButtonOptions, + MobileControlsOptions, + PlayerControllerOptions, + PlayerModelOptions, + PlayerModelSource, + VehicleInstance, + VehicleModelSource, + VehicleOptions, + VehicleChassisOptions, + VehicleSuspensionOptions, + VehicleSteeringOptions, + VehicleGripOptions, + VehiclePowerOptions, + VehicleWheelColliderUserData, + KinematicColliderEntry, +} from "./types"; +export type { PlayerPlugin } from "./plugins/types"; + +// 碰撞公开 API +export { CollisionWorld } from "./collision/CollisionWorld"; +export type { CollisionCollider, CollisionShape, MotionType } from "./collision/CollisionWorld"; +export { CHARACTER_QUERY_MASK, ALL_COLLISION_MASK, CollisionGroup } from "./collision/groups"; +export type { + ColliderDesc, + ColliderHandle, + ColliderShape, + MeshShape, + BoxShape, + SphereShape, +} from "./collision/colliderDesc"; +export { DYNAMIC_BODY_DEFAULTS } from "./collision/colliderDesc"; +export { DynamicSphereBody, isSphereBody } from "./collision/DynamicSphere"; +export { DynamicBoxBody, isBoxBody } from "./collision/DynamicBox"; +export { DynamicBody } from "./collision/DynamicBody"; +export type { DynamicBodyKind, DynamicBodyMaterialOptions } from "./collision/DynamicBody"; + +// 接触 / 冲量求解 +export { ContactImpulseSolver } from "./collision/solver/ContactImpulseSolver"; +export type { ImpulseBody } from "./collision/solver/ImpulseBody"; +export { ContactManifold } from "./collision/contacts/ContactManifold"; +export { ContactPoint, CONTACT_SKIN, CONTACT_REF_EXTENT, contactSkinForExtent } from "./collision/contacts/ContactPoint"; + +// 载具底层 +export { BVHVehicleController } from "./utils/vehiclePhysics/BVHVehicleController"; +export { VehicleRigidBody } from "./utils/vehiclePhysics/VehicleRigidBody"; diff --git a/three-player-controller/src/plugins/foot-ik/FootIK.ts b/three-player-controller/src/plugins/foot-ik/FootIK.ts new file mode 100644 index 0000000..bb1a465 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/FootIK.ts @@ -0,0 +1,2409 @@ +import { + Matrix3, + MathUtils, + Quaternion, + Raycaster, + Vector3, + type Bone, + type Intersection, + type Mesh, + type Object3D, +} from "three"; +import { createTwoBoneIKScratch, solveTwoBoneIK } from "./internal/twoBoneIK"; +import { + buildFootPhaseDatabase, + createFootPhaseOptions, + createFootPhaseRuntimeState, + sampleFootPhaseRuntime, +} from "./internal/footPhase"; +import { + collectBones, + createLeg, + findHips, + isReadyLeg, + resolveConfiguredBone, +} from "./internal/skeleton"; +import { + createDebugObjects, + getFootPhaseDebugText, + setDebugVisible, + updateFootDebug, + disposeDebugObjects, +} from "./internal/debug"; +import { + createPredictiveScratch, + findPredictiveFootCandidates, + getPredictiveDemandThresholds, + getPredictivePlaneDistance, + getPredictiveTerrainDemand, + acceptPredictiveFootCandidate, + refreshPredictiveSupportAnchor, + resetPredictiveFootState, + selectPredictiveDebugCandidate, + shiftPredictiveDebugTrajectory, + type PredictiveScratch, +} from "./internal/predictivePlacement"; +import { + getPredictiveClearanceShape, + getPredictiveLocalProgress, + planPredictiveFootTrajectory, + PREDICTION_ROTATION_WARP_START, + type PredictiveTrajectoryContext, + type PredictiveTrajectoryPlan, +} from "./internal/predictiveTrajectory"; +import type { + FootIKBonePose, + FootIKGroundHit, + FootIKLeg, + FootIKLegs, + FootIKOptions, + FootIKPlayer, + FootIKSide, + FootIKSkeletonConfig, + FootPhaseControllerState, + FootPhaseDatabase, + FootPhaseOptions, + FootPhaseRuntimeState, + PredictiveFootState, + ReadyFootIKLeg, +} from "./types"; +import type { TwoBoneIKScratch } from "./internal/twoBoneIK"; + +/** Foot IK 不参与地面检测的动态刚体形状。 */ +const FOOT_IK_IGNORED_DYNAMIC_KINDS = ["sphere"] as const; +/** 摆腿进入末段后锁定地形结果,避免落地前继续切换支撑面。 */ +const PREDICTION_LOCK_PROGRESS = 0.88; +/** 支撑落点残差在新摆腿前段完成释放的相位比例。 */ +const PREDICTION_RELEASE_PROGRESS = 0.25; +/** 预测摆腿追目标时的阻尼系数。 */ +const PREDICTIVE_SWING_CHASE_DAMP = 40; +/** 预测摆腿把修正还给动画时的阻尼系数。 */ +const PREDICTIVE_SWING_FADE_DAMP = 40; + +function createGroundHit(): FootIKGroundHit { + return { + point: new Vector3(), + normal: new Vector3(0, 1, 0), + }; +} + +function copyGroundHit(source: FootIKGroundHit, target: FootIKGroundHit): FootIKGroundHit { + target.point.copy(source.point); + target.normal.copy(source.normal); + target.object = source.object; + return target; +} + +/** 根据角色动画和地面高度修正脚部姿态的控制器插件。 */ +export class FootIK { + /** 插件名称。 */ + readonly name = "foot-ik"; + + // 当前挂载的玩家控制器。 + private player: FootIKPlayer | null = null; + + // 生命周期与调试开关。 + private enabled: boolean; + private disposed = false; + private debug: boolean; + + // IK 距离、角度和脚掌贴合配置。 + private maxPelvisDrop = 50; + private maxPelvisRaise = 50; + private maxFootRaise = 50; + private maxFootDrop = 50; + private plantedHeightSpeed = Infinity; + private penetrationLiftSpeed = Infinity; + private straightPoleEnabled = false; + private soleHalfWidth = 7; + private soleToeExtend = 7; + private soleHeelExtend = 3; + private soleSkinThickness = 3; + private moveLiftThreshold = 0.1; + private footPhaseGroundThreshold = 5; + private fakeToeExtend = 24; + private sampleRayOriginY = 90; + private raycastFar = 440; + private snapEpsilon = 0.02; + // 预测落脚开关与搜索、线路和摆动修正配置。 + private predictivePlacement: boolean; + private predictionHorizon: number; + private predictionProbeFrames: number; + private predictionSearchRadius = 20; + private maxPredictionCorrection = 45; + private predictionMinNormalY: number; + private swingClearance = 8; + private maxPredictionClearance = 50; + // 当前距离和速度字段已烘焙进去的 scale;改 scale 时按 ratio 连乘。 + private appliedScale = 1; + + private footAlignWeight: number; + private maxFootTilt: number; + private minKneeBend: number; + private maxKneeBend: number; + private pelvisKneeBend: number; + + // 骨盆垂直补偿。 + private pelvisOffset = 0; + private pelvisOffsetOverride: number | null = null; + + // 地面检测与每帧复用的临时对象。 + private colliderMeshes: Mesh[] = []; + private readonly groundHit = createGroundHit(); + private readonly capsuleHit = createGroundHit(); + private readonly bestGroundHit = createGroundHit(); + private readonly sampleGroundHits = [ + createGroundHit(), + createGroundHit(), + createGroundHit(), + createGroundHit(), + ]; + private readonly sampleGroundHitSlots: Array = [ + null, + null, + null, + null, + ]; + private readonly raycaster: Raycaster; + private readonly up = new Vector3(0, 1, 0); + private readonly tmpV1 = new Vector3(); + private readonly tmpV2 = new Vector3(); + private readonly tmpV3 = new Vector3(); + private readonly tmpV4 = new Vector3(); + private readonly tmpV5 = new Vector3(); + private readonly tmpV6 = new Vector3(); + private readonly tmpV7 = new Vector3(); + private readonly tmpV8 = new Vector3(); + private readonly tmpV9 = new Vector3(); + private readonly tmpV10 = new Vector3(); + private readonly tmpQ1 = new Quaternion(); + private readonly tmpQ2 = new Quaternion(); + private readonly tmpQ3 = new Quaternion(); + private readonly tmpQ4 = new Quaternion(); + // IK 前的动画脚旋转,用于抵消腿链求解对子骨骼产生的被动旋转。 + private readonly savedFootWorldQ = new Quaternion(); + // 脚掌贴坡后的旋转,用于二次接触校正时保持已完成的倾斜结果。 + private readonly savedAlignedFootWorldQ = new Quaternion(); + // 四元数球面插值使用的单位旋转终点。 + private readonly identityQ = new Quaternion(); + // 把碰撞面局部法线转换到世界空间。 + private readonly normalMatrix = new Matrix3(); + // 双骨骼 IK 解算器跨帧复用的临时对象。 + private readonly twoBoneIKScratch: TwoBoneIKScratch; + // 预测落脚搜索和轨迹规划使用的专用临时对象。 + private readonly predictiveScratch: PredictiveScratch; + + // 胶囊实际位移速度比输入速度更能反映碰撞后的真实运动,用于预测未来根节点位置。 + private readonly previousCapsulePosition = new Vector3(); + private readonly predictionVelocity = new Vector3(); + private hasPreviousCapsulePosition = false; + + // 下一帧动画更新前需要恢复的骨骼及其动画原始姿态。 + private readonly adjusted = new Set(); + private readonly poseCache = new Map(); + + // 骨骼绑定和脚步相位配置。 + private readonly skeletonConfig: FootIKSkeletonConfig | null; + private footPhaseOptions: FootPhaseOptions; + + // 当前模型生成的脚步相位数据库和运行时状态。 + private footPhaseClips: FootPhaseDatabase = new Map(); + private footPhaseState: FootPhaseControllerState; + + // 当前模型解析出的骨盆与左右腿链。 + private hips: Bone | null = null; + private legs: FootIKLegs; + + /** 创建 FootIK 插件。 */ + constructor(options: FootIKOptions = {}) { + this.enabled = options.enabled ?? true; + this.debug = options.debug ?? false; + + // 距离和速度类参数先写入 scale=1 基准值,挂载后按 playerModelConfig.scale 连乘。 + this.maxPelvisDrop = Math.max(0, options.maxPelvisDrop ?? this.maxPelvisDrop); + this.maxPelvisRaise = Math.max(0, options.maxPelvisRaise ?? this.maxPelvisRaise); + this.maxFootRaise = Math.max(0, options.maxFootRaise ?? this.maxFootRaise); + this.maxFootDrop = Math.max(0, options.maxFootDrop ?? this.maxFootDrop); + this.plantedHeightSpeed = Math.max(0, options.plantedHeightSpeed ?? this.plantedHeightSpeed); + this.penetrationLiftSpeed = Math.max(0, options.penetrationLiftSpeed ?? this.penetrationLiftSpeed); + this.soleHalfWidth = Math.max(0, options.soleHalfWidth ?? this.soleHalfWidth); + this.soleToeExtend = Math.max(0, options.soleToeExtend ?? this.soleToeExtend); + this.soleHeelExtend = Math.max(0, options.soleHeelExtend ?? this.soleHeelExtend); + this.soleSkinThickness = Math.max(0, options.soleSkinThickness ?? this.soleSkinThickness); + this.moveLiftThreshold = Math.max(0, options.moveLiftThreshold ?? this.moveLiftThreshold); + this.footPhaseGroundThreshold = Math.max(0, options.footPhaseGroundThreshold ?? this.footPhaseGroundThreshold); + this.predictionSearchRadius = Math.max(0, options.predictionSearchRadius ?? this.predictionSearchRadius); + this.maxPredictionCorrection = Math.max(0, options.maxPredictionCorrection ?? this.maxPredictionCorrection); + this.swingClearance = Math.max(0, options.swingClearance ?? this.swingClearance); + this.maxPredictionClearance = Math.max( + 0, + options.maxPredictionClearance ?? this.maxFootRaise, + ); + + // 预测落脚默认关闭,确保未显式开启时不改变既有 Foot IK 行为。 + this.predictivePlacement = options.predictivePlacement ?? false; + this.predictionHorizon = Math.max(0, options.predictionHorizon ?? 0.45); + this.predictionProbeFrames = Math.max(0, options.predictionProbeFrames ?? 10); + this.predictionMinNormalY = MathUtils.clamp(options.predictionMinNormalY ?? 0.55, 0, 1); + this.footAlignWeight = MathUtils.clamp(options.footAlignWeight ?? 1, 0, 1); // 脚掌贴合地面法线的强度 + this.maxFootTilt = MathUtils.clamp(options.maxFootTilt ?? Math.PI / 2, 0, Math.PI); // 脚掌贴坡最大旋转角 + const configuredMinBend = MathUtils.clamp(options.minKneeBend ?? MathUtils.degToRad(2), 0, Math.PI); + const configuredMaxBend = MathUtils.clamp(options.maxKneeBend ?? MathUtils.degToRad(145), 0, Math.PI); + this.minKneeBend = Math.min(configuredMinBend, configuredMaxBend); + this.maxKneeBend = Math.max(configuredMinBend, configuredMaxBend); + this.pelvisKneeBend = MathUtils.clamp( + options.pelvisKneeBend ?? MathUtils.degToRad(0), + this.minKneeBend, + this.maxKneeBend, + ); + this.straightPoleEnabled = options.straightPoleEnabled ?? false; + + // 脚底检测射线;far 会在 rescaleDistances 中随 scale 更新。 + this.raycaster = new Raycaster(new Vector3(), new Vector3(0, -1, 0), 0, this.raycastFar); + (this.raycaster as Raycaster & { firstHitOnly?: boolean }).firstHitOnly = true; + + // 从动画中自动采样脚步相位,用于判断移动动画中的支撑脚。 + this.footPhaseOptions = createFootPhaseOptions({ + ...options, + footPhaseGroundThreshold: this.footPhaseGroundThreshold, + }); + this.footPhaseState = { + clipName: "", + normalizedTime: 0, + left: createFootPhaseRuntimeState(), + right: createFootPhaseRuntimeState(), + }; + + this.twoBoneIKScratch = createTwoBoneIKScratch(); + this.predictiveScratch = createPredictiveScratch(); + + // 未传配置时才回退到启发式匹配。 + this.skeletonConfig = options.skeleton ?? null; // 骨骼绑定配置,支持骨骼名或 Bone 对象 + this.legs = this.createEmptyLegs(); + } + + /** 挂载到玩家控制器并绑定当前模型骨骼。 */ + onAttach(player: FootIKPlayer): void { + if (this.disposed) { + throw new Error("[FootIK] 已销毁的插件不能再次挂载。"); + } + if (this.player && this.player !== player) this.detachPlayer(); + this.attachPlayer(player); + } + + /** 从玩家控制器卸载并恢复插件修改。 */ + onDetach(): void { + this.detachPlayer(); + } + + /** 在动画更新前恢复上一帧被 IK 修改的骨骼姿态。 */ + onBeforeAnimationUpdate(_delta: number): void { + if (!this.enabled || this.disposed) return; + this.restore(); + } + + /** 在动画更新后应用当前帧 Foot IK。 */ + onAfterAnimationUpdate(delta: number): void { + if (!this.enabled || this.disposed) return; + this.syncDistanceScale(); + this.update(delta); + } + + /** 玩家模型切换后重新绑定骨骼和脚步相位数据。 */ + onPlayerModelChange(): void { + if (this.disposed) return; + this.rebind(); + } + + /** 终止使用插件并释放调试资源。 */ + dispose(): void { + if (this.disposed) return; + this.detachPlayer(); + this.disposed = true; + this.footPhaseClips.clear(); + this.adjusted.clear(); + this.poseCache.clear(); + } + + // 模型切换后释放旧调试对象,并基于新模型重新绑定全部运行时数据。 + private rebind(): void { + if (!this.player?.playerModel) return; + this.restore(); + disposeDebugObjects(this.legs); + this.syncDistanceScale(); + this.bindSkeleton(); + } + + /** 读取当前角色 scale,未挂载时按 1 处理。 */ + private getPlayerScale(): number { + const scale = this.player?.playerModelConfig?.scale; + return typeof scale === "number" && scale > 0 ? scale : 1; + } + + /** 把 scale=1 基准距离写成当前世界值。 */ + private scaleDistance(value: number): number { + return Math.max(0, value) * this.getPlayerScale(); + } + + /** 把已缩放的世界距离还原为 scale=1 基准值(供 getOptions 使用)。 */ + private toBaseDistance(world: number): number { + return this.appliedScale > 0 ? world / this.appliedScale : world; + } + + /** 若角色 scale 变化,对距离字段做 ratio 连乘。 */ + private syncDistanceScale(): void { + const scale = this.getPlayerScale(); + if (scale === this.appliedScale) return; + this.rescaleDistances(scale); + } + + /** 将距离字段从 appliedScale 换算到 newScale。 */ + private rescaleDistances(newScale: number): void { + const next = newScale > 0 ? newScale : 1; + const ratio = next / this.appliedScale; + if (ratio === 1) { + this.appliedScale = next; + return; + } + + this.maxPelvisDrop *= ratio; + this.maxPelvisRaise *= ratio; + this.maxFootRaise *= ratio; + this.maxFootDrop *= ratio; + this.plantedHeightSpeed *= ratio; + this.penetrationLiftSpeed *= ratio; + this.soleHalfWidth *= ratio; + this.soleToeExtend *= ratio; + this.soleHeelExtend *= ratio; + this.soleSkinThickness *= ratio; + this.moveLiftThreshold *= ratio; + this.footPhaseGroundThreshold *= ratio; + this.fakeToeExtend *= ratio; + this.sampleRayOriginY *= ratio; + this.raycastFar *= ratio; + this.snapEpsilon *= ratio; + this.predictionSearchRadius *= ratio; + this.maxPredictionCorrection *= ratio; + this.swingClearance *= ratio; + this.maxPredictionClearance *= ratio; + this.pelvisOffset *= ratio; + for (const leg of Object.values(this.legs)) leg.offsetY *= ratio; + if (this.pelvisOffsetOverride !== null) this.pelvisOffsetOverride *= ratio; + this.raycaster.far = this.raycastFar; + this.footPhaseOptions.groundThreshold = this.footPhaseGroundThreshold; + this.appliedScale = next; + } + + /** 启用或停用 Foot IK;停用时立即恢复插件修改。 */ + setEnabled(enabled: boolean): void { + if (this.disposed) return; + this.enabled = enabled; + if (!enabled) { + this.restore(); + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + this.setDebugVisible(false); + } else if (this.debug) { + this.setDebugEnabled(true); + } + } + + // 保存玩家控制器引用并初始化模型相关状态。 + private attachPlayer(player: FootIKPlayer): void { + if (this.player === player && this.legs.left.ready) return; + this.player = player; + this.syncDistanceScale(); + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + this.bindSkeleton(); + } + + // 恢复骨骼,释放调试资源并清空所有控制器相关引用。 + private detachPlayer(): void { + this.restore(); + disposeDebugObjects(this.legs); + this.footPhaseClips.clear(); + this.adjusted.clear(); + this.poseCache.clear(); + this.hips = null; + this.legs = this.createEmptyLegs(); + // 还原到 scale=1 基准值,便于卸载后再挂载或单独 configure。 + this.rescaleDistances(1); + this.player = null; + this.pelvisOffset = 0; + this.resetPredictionRuntime(); + } + + // 创建尚未绑定骨骼的左右腿占位状态。 + private createEmptyLegs(): FootIKLegs { + return { + left: createLeg("left", []), + right: createLeg("right", []), + }; + } + + // 解析骨骼后初始化脚底采样、脚步相位数据库和调试对象。 + private bindSkeleton(): void { + const bones = collectBones(this.player?.playerModel); + this.hips = resolveConfiguredBone(this.skeletonConfig?.hips, bones, "hips") + ?? findHips(bones); + this.legs = { + left: createLeg("left", bones, this.skeletonConfig), + right: createLeg("right", bones, this.skeletonConfig), + }; + + if (!this.hips || !isReadyLeg(this.legs.left) || !isReadyLeg(this.legs.right)) { + console.warn("[FootIK] 骨骼识别不完整,当前模型骨骼名:", bones.map(b => b.name)); + } + this.player?.playerModel?.updateMatrixWorld(true); + this.initSoleLocalSamples(this.legs.left); + this.initSoleLocalSamples(this.legs.right); + this.buildFootPhaseDatabase(); + this.createDebugObjects(); + this.resetPredictionRuntime(); + } + + /** 清空胶囊速度历史和双脚预测目标,避免挂载、切模或开关切换后沿用旧落点。 */ + private resetPredictionRuntime(): void { + this.predictionVelocity.set(0, 0, 0); + this.hasPreviousCapsulePosition = false; + if (this.player?.playerCapsule) { + this.previousCapsulePosition.copy(this.player.playerCapsule.position); + this.hasPreviousCapsulePosition = true; + } + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + this.legs.left.heightSupport = null; + this.legs.right.heightSupport = null; + } + + /** 空中 / 停用贴地时丢掉反应式 IK 残留,避免落地从起飞前的世界高度往下坠。 */ + private resetReactiveIkState(): void { + this.pelvisOffset = 0; + for (const leg of Object.values(this.legs)) { + if (!isReadyLeg(leg)) continue; + leg.weight = 0; + leg.offsetY = 0; + leg.heightSupport = null; + leg.plantedWeight = 0; + leg.hasPelvisTarget = false; + leg.movePenetrating = false; + leg.foot.getWorldPosition(leg.smoothedTarget); + } + } + + // 恢复上一帧 IK 修改前的骨骼姿态 + private restore(): void { + // 先恢复上一帧的动画原始姿态,再让 mixer 推进当前帧 + // 调用顺序:footIK.restore() -> player.update() -> footIK.update() + for (const bone of this.adjusted) { + const pose = this.poseCache.get(bone); + if (!pose) continue; + bone.position.copy(pose.position); + bone.quaternion.copy(pose.quaternion); + } + this.adjusted.clear(); + } + + // 在动画更新后执行每帧腿部 IK 修正。 + private update(delta = 1 / 60): void { + if (!this.enabled || this.disposed) return; + this.updateFootPhaseRuntime(); + if (this.predictivePlacement) this.updatePredictionVelocity(delta); + this.refreshColliderMeshes(); + + const player = this.player; + // 不在地面、飞行或载具模式时不做腿部贴地 + if ( + !player + || (!this.colliderMeshes.length && !player.getDynamicBodies?.().length) + || !player.playerCapsule + || !player.playerIsOnGround + || player.isFlying + || player.getControllerMode?.() === 1 + ) { + if (this.predictivePlacement) { + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + } + player?.playerModel?.updateMatrixWorld(true); + this.resetReactiveIkState(); + this.setDebugVisible(false); + return; + } + + const model = player.playerModel; + model?.updateMatrixWorld(true); + + // moving 决定移动与静止 IK 策略:移动使用脚步相位和稳定直弯 pole,静止保留动画 pole。 + const moving = this.isLocomotion(); + if (this.predictivePlacement && !moving) { + // 离开移动状态后交还给静止 Foot IK,避免 idle 继续沿用旧摆动线路。 + resetPredictiveFootState(this.legs.left.predictive); + resetPredictiveFootState(this.legs.right.predictive); + } + + // 先算左右脚目标,再根据两只脚目标调整骨盆,最后解腿部 IK。 + this.updateFoot("left", delta, moving); + this.updateFoot("right", delta, moving); + this.applyPelvis(delta); + this.applyLeg("left", this.straightPoleEnabled && moving); + this.applyLeg("right", this.straightPoleEnabled && moving); + } + + /** 根据胶囊碰撞修正后的实际位移更新水平预测速度。 */ + private updatePredictionVelocity(delta: number): void { + const capsule = this.player?.playerCapsule; + if (!capsule) return; + + if (!this.hasPreviousCapsulePosition || delta <= 0) { + this.previousCapsulePosition.copy(capsule.position); + this.hasPreviousCapsulePosition = true; + return; + } + + // 实际位移包含墙体阻挡和台阶推出,比单独读取输入目标速度更适合作为落脚预测依据。 + const measured = this.tmpV6 + .subVectors(capsule.position, this.previousCapsulePosition) + .multiplyScalar(1 / delta) + .setY(0); + const controllerVelocity = this.player?.getVelocity?.(); + if (controllerVelocity) { + // 少量混入控制器速度,改善刚起步时实际位移样本过小造成的预测滞后。 + measured.lerp(this.tmpV7.copy(controllerVelocity).setY(0), 0.15); + } + + const alpha = 1 - Math.exp(-12 * delta); + this.predictionVelocity.lerp(measured, alpha); + this.previousCapsulePosition.copy(capsule.position); + } + + // 构建脚步相位库。具体采样和过滤逻辑在 footPhase.ts 中。 + private buildFootPhaseDatabase(): void { + const model = this.player?.playerModel; + const clips = this.player?.animation?.clips ?? []; + this.footPhaseClips = buildFootPhaseDatabase( + model, + clips, + this.legs, + this.footPhaseOptions, + name => this.isLocomotionClipName(name), + ); + + if (this.debug && this.footPhaseClips.size === 0) { + console.warn("[FootIK] 没有生成脚步相位数据,请确认移动动画和脚骨骼配置。"); + } + } + + // 根据当前动画播放时间更新左右脚相位。这里只同步状态,不直接驱动 IK。 + private updateFootPhaseRuntime(): void { + const action = this.player?.animation?.state; + if (!action) { + this.footPhaseState.clipName = ""; + return; + } + const clip = action.getClip(); + if (clip.duration <= 0) return; + + const data = this.footPhaseClips.get(clip.name); + this.footPhaseState.clipName = clip.name; + this.footPhaseState.normalizedTime = ((action.time / clip.duration) % 1 + 1) % 1; + + if (!data) { + this.footPhaseState.left = createFootPhaseRuntimeState(); + this.footPhaseState.right = createFootPhaseRuntimeState(); + return; + } + + const runtime = sampleFootPhaseRuntime(data, this.footPhaseState.normalizedTime, clip.duration); + this.footPhaseState.left = runtime.left; + this.footPhaseState.right = runtime.right; + } + + // 计算单脚 IK 目标、权重和骨盆偏移。 + private updateFoot(side: FootIKSide, delta: number, moving: boolean): void { + const leg = this.legs[side]; + if (!isReadyLeg(leg)) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV10); + leg.hasPelvisTarget = false; + const phase = this.footPhaseState?.[side]; + const phasePlanted = moving && !!phase?.planted; + const nextPlanted = !moving || phasePlanted; + const wasPlanted = leg.planted; + const startedSwing = wasPlanted && !nextPlanted; + const previousWeight = leg.weight; + const wasMovePenetrating = leg.movePenetrating; + leg.planted = nextPlanted; + + if (this.predictivePlacement) { + this.updatePredictiveFoot( + leg, + phase, + footWorld, + startedSwing, + delta, + ); + } else if (leg.predictive.mode !== "none") { + resetPredictiveFootState(leg.predictive); + } + + // 改变水平落点的预测结果在支撑阶段继续使用原落点,避免切回动画时跳变。 + if (leg.planted && this.resolvePredictivePlantedFoot(leg, footWorld, delta)) { + leg.heightSupport = null; + return; + } + + // 预测摆动自己做四角防穿透,并从上一帧反应式权重/高度阻尼过去。 + if (!leg.planted && this.resolvePredictiveSwingFoot(leg, footWorld, phase, delta)) { + leg.heightSupport = null; + return; + } + // 偏移落点留下的支撑残差在新摆腿前段连续归还给动画。 + if (!leg.planted && this.resolvePredictiveReleaseFoot(leg, footWorld)) { + leg.heightSupport = null; + return; + } + + const hit = this.castBestFootGround(leg); + this.transportFootHeight(leg, hit); + + // 脚底没有命中地面时,本帧完全交还给原动画。 + if (!hit) { + if (!leg.planted) { + this.releaseSwingFoot( + leg, + footWorld, + startedSwing || wasMovePenetrating, + previousWeight, + delta, + ); + this.updateFootDebug(leg, footWorld); + return; + } + leg.plantedWeight = 0; + leg.movePenetrating = false; + leg.weight = 0; + leg.offsetY = 0; + this.updateFootDebug(leg, footWorld); + return; + } + + leg.hitNormal.copy(hit.normal); + leg.hitPoint.copy(hit.point); + + const groundOffset = this.getFootGroundOffset(leg); + const liftAmount = Math.max(0, groundOffset); + const pelvisTargetOffset = this.getAlignedFootTargetOffset(leg, footWorld); + // 支撑脚下探超过最大范围时,本帧交还给原动画。 + if (leg.planted && pelvisTargetOffset < -this.maxFootDrop) { + leg.weight = 0; + leg.plantedWeight = 0; + leg.offsetY = 0; + leg.movePenetrating = false; + leg.smoothedTarget.copy(footWorld); + this.updateFootDebug(leg, hit.point); + return; + } + const targetOffset = leg.planted + ? pelvisTargetOffset + : Math.max(0, pelvisTargetOffset); + // 命中面超过脚部最大上抬范围时,本帧交还给原动画。 + if (targetOffset > this.maxFootRaise) { + if (!leg.planted) { + this.releaseSwingFoot( + leg, + footWorld, + startedSwing || wasMovePenetrating, + previousWeight, + delta, + ); + this.updateFootDebug(leg, hit.point); + return; + } + leg.weight = 0; + leg.plantedWeight = 0; + leg.offsetY = 0; + leg.movePenetrating = false; + leg.smoothedTarget.copy(footWorld); + this.updateFootDebug(leg, hit.point); + return; + } + if ( + pelvisTargetOffset <= this.maxFootRaise + && pelvisTargetOffset >= -this.maxFootDrop + ) { + leg.hasPelvisTarget = true; + leg.pelvisTarget + .copy(footWorld) + .addScaledVector(this.up, pelvisTargetOffset); + } + + // 支撑脚允许在限制范围内双向贴地;摆动脚只在穿透时上抬。 + // 支撑状态和防穿透独立触发:任意相位陷入地面都会立即抬起。 + // 防穿透只负责上抬,不参与脚步锁定状态。 + leg.movePenetrating = liftAmount > this.moveLiftThreshold; + + const wantedPlantedWeight = leg.planted ? 1 : 0; + if (startedSwing || (wasMovePenetrating && !leg.movePenetrating)) { + // 离地或结束防穿透的首帧继承上一帧实际输出,避免暴露较低的内部相位权重。 + leg.plantedWeight = previousWeight; + } else { + leg.plantedWeight = MathUtils.damp( + leg.plantedWeight ?? 0, + wantedPlantedWeight, + 10, + delta, + ); + } + + // 支撑状态短时间渐入/渐出;穿透修正仍然立即介入,避免脚陷入地面。 + if (leg.movePenetrating) { + leg.weight = 1; + } else if (leg.plantedWeight > 0.001) { + leg.weight = leg.plantedWeight; + } else { + // 移动 swing 脚听原动画,IK 不生效。 + leg.weight = 0; + } + + // 支撑脚按参数追贴地高度;摆动脚保留上一帧修正并渐出。 + // 摆动脚陷入地面时按参数上抬高度。 + if (leg.planted) { + this.moveFootHeightAlongUp( + leg, + footWorld, + targetOffset, + previousWeight, + delta, + this.getHeightSpeed(this.plantedHeightSpeed), + ); + } else if (leg.movePenetrating) { + this.moveFootHeightAlongUp( + leg, + footWorld, + targetOffset, + previousWeight, + delta, + this.getHeightSpeed(this.penetrationLiftSpeed), + ); + } else if (!startedSwing) { + leg.offsetY = MathUtils.damp(leg.offsetY, 0, 10, delta); + if (Math.abs(leg.offsetY) < this.snapEpsilon) leg.offsetY = 0; + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } else { + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + // 保存接触点的局部坐标,下帧只同步平台自身的移动、旋转和缩放。 + if (hit.object) { + leg.heightSupport = hit.object; + leg.heightSupportLocal.copy(hit.point); + hit.object.worldToLocal(leg.heightSupportLocal); + leg.heightSupportWorldY = hit.point.y; + leg.heightSupportScale = this.appliedScale; + } + this.updateFootDebug(leg, hit.point); + } + + /** 先把历史高度搬到本帧支撑面,再对剩余贴地误差限速。 */ + private transportFootHeight(leg: ReadyFootIKLeg, hit: FootIKGroundHit | null): void { + if (leg.heightSupport && leg.heightSupport === hit?.object) { + const anchor = leg.heightSupport.localToWorld(this.tmpV6.copy(leg.heightSupportLocal)); + // 绕接触点缩放脚离地的高度。 + const scaleRatio = this.appliedScale / leg.heightSupportScale; + leg.smoothedTarget.y = anchor.y + + (leg.smoothedTarget.y - leg.heightSupportWorldY) * scaleRatio; + } + // 无命中、超出范围或切到预测线路后,不继续沿用旧平台。 + leg.heightSupport = null; + } + + /** 当前是跑步动画时高度速度不限制。 */ + private getHeightSpeed(speed: number): number { + const clipName = this.player?.animation.state?.getClip().name ?? ""; + const runAnim = this.player?.playerModelConfig?.runAnim; + return runAnim && clipName === runAnim ? Infinity : speed; + } + + /** 从已同步支撑面变换的历史高度沿 up 限速移动;XZ 跟当前动画脚。 */ + private moveFootHeightAlongUp( + leg: ReadyFootIKLeg, + footWorld: Vector3, + wantedOffset: number, + previousWeight: number, + delta: number, + speed: number, + ): void { + const footAlongUp = footWorld.dot(this.up); + const wantedAlongUp = footAlongUp + wantedOffset; + if (!Number.isFinite(speed)) { + leg.offsetY = wantedOffset; + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + return; + } + + const hasPrevious = previousWeight > 0.001 + || Math.abs(leg.offsetY) > this.snapEpsilon; + const previousAlongUp = hasPrevious + ? leg.smoothedTarget.dot(this.up) + : footAlongUp; + const movedAlongUp = previousAlongUp + MathUtils.clamp( + wantedAlongUp - previousAlongUp, + -speed * delta, + speed * delta, + ); + // 目标有效不代表历史仍有效;缩放或根节点突变后也必须遵守当前脚部范围。 + leg.offsetY = MathUtils.clamp(movedAlongUp - footAlongUp, -this.maxFootDrop, this.maxFootRaise); + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + // 摆动阶段命中失效时继续释放已有支撑修正,避免无命中分支把权重和目标直接清零。 + private releaseSwingFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + inheritOutputWeight: boolean, + previousWeight: number, + delta: number, + ): void { + leg.movePenetrating = false; + leg.plantedWeight = inheritOutputWeight + ? previousWeight + : MathUtils.damp(leg.plantedWeight, 0, 10, delta); + if (leg.plantedWeight < 0.001) leg.plantedWeight = 0; + leg.weight = leg.plantedWeight; + + if (!inheritOutputWeight) { + leg.offsetY = MathUtils.damp(leg.offsetY, 0, 10, delta); + if (Math.abs(leg.offsetY) < this.snapEpsilon) leg.offsetY = 0; + } + leg.smoothedTarget.copy(footWorld).addScaledVector(this.up, leg.offsetY); + } + + /** 摆动期间持续更新预测线路,并在末段锁定最终落脚结果。 */ + private updatePredictiveFoot( + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState | undefined, + footWorld: Vector3, + startedSwing: boolean, + delta: number, + ): void { + const state = leg.predictive; + + // 偏移落点进入支撑后转为 planted 锚点;中心落点则交还给普通贴地 IK。 + if (leg.planted) { + if (state.mode === "active" && state.usesOffsetLanding) { + state.mode = "planted"; + state.predictionWeight = 1; + state.debugTrajectoryVisible = false; + } else if (state.mode !== "planted" && state.mode !== "none") { + resetPredictiveFootState(state); + } + return; + } + + if (startedSwing) { + // 上一支撑若改过水平落点,把残差带到新摆腿前段再还给动画。 + const preserveRelease = state.mode === "planted" + && state.usesOffsetLanding; + if (preserveRelease) { + this.tmpV1.subVectors( + state.trajectoryCurrentTarget, + footWorld, + ); + } + resetPredictiveFootState(state); + state.mode = "tracking"; + if ( + preserveRelease + && this.tmpV1.lengthSq() > this.snapEpsilon * this.snapEpsilon + ) { + state.releaseOffset.copy(this.tmpV1); + state.releaseStartProgress = phase?.progress ?? 0; + state.releaseActive = true; + } + } + + if (!phase?.nextLanding || !Number.isFinite(phase.timeToLand)) { + resetPredictiveFootState(state); + return; + } + + if (state.mode === "none") { + // 运行中开启预测开关时也允许从当前摆动相位接管,不必等待下一个完整步态周期。 + state.mode = "tracking"; + } + + state.trajectoryProgress = Math.max( + state.trajectoryProgress, + phase.progress, + ); + + const model = this.player?.playerModel; + if (!model) return; + // 用剩余落地时间和当前胶囊速度,把动画落点推到预计踩实时的世界位置。 + const predictionTime = Math.min(phase.timeToLand, this.predictionHorizon); + state.animatedLanding + .copy(phase.nextLanding.localPosition) + .applyMatrix4(model.matrixWorld) + .addScaledVector(this.predictionVelocity, predictionTime); + state.animatedLandingRotation + .copy(model.getWorldQuaternion(this.tmpQ4)) + .multiply(phase.nextLanding.localRotation); + + // 动画中心落点始终跟随当前预测结果,落地时水平修正自然收敛为零。 + if (state.mode === "active" && !state.usesOffsetLanding) { + this.followAnimatedLandingTarget(leg); + } + + // 摆腿末段锁定支撑面,避免落地前一帧换到另一级台阶。 + if (state.trajectoryProgress >= PREDICTION_LOCK_PROGRESS) { + return; + } + + state.probeElapsed += 1; + // 射线按帧间隔更新,间隔内继续用上一份落点和当前动画脚解目标。 + if ( + this.predictionProbeFrames <= 0 + || state.probeElapsed >= this.predictionProbeFrames + ) { + state.probeElapsed = 0; + const ctx = this.getPredictiveContext(model); + const { candidate, centerSupport } = findPredictiveFootCandidates( + ctx, + leg, + phase, + state.animatedLanding, + state.animatedLandingRotation, + ); + if (!centerSupport) { + this.suppressFlatPredictiveFoot(leg); + return; + } + + const trajectoryStart = state.mode === "active" + ? state.trajectoryCurrentTarget + : state.releaseActive + ? this.predictiveScratch.trajectoryStart + .copy(footWorld) + .addScaledVector( + state.releaseOffset, + this.getPredictiveReleaseWeight(state), + ) + : this.getPredictiveSwingStart(leg, footWorld); + const trajectoryStartProgress = state.mode === "active" + ? state.trajectoryStartProgress + : state.trajectoryProgress; + + const { enter: demandEnter, exit: demandExit } = + getPredictiveDemandThresholds( + this.appliedScale, + this.snapEpsilon, + this.predictiveScratch, + ); + // 已激活时用更低的退出阈值,减少台阶边缘反复进出预测。 + const demandThreshold = state.mode === "active" + ? demandExit + : demandEnter; + + if (candidate) { + const plan = planPredictiveFootTrajectory( + ctx, + leg, + phase, + footWorld, + trajectoryStart, + trajectoryStartProgress, + candidate, + ); + const planeLift = getPredictivePlaneDistance(ctx, candidate); + const swingLift = plan?.terrainDemand ?? 0; + state.debugPlaneLift = planeLift; + state.debugSwingLift = swingLift; + const terrainDemand = getPredictiveTerrainDemand( + planeLift, + swingLift, + ); + + // 落点上台和摆腿途中凸起都不够高时,保持动画和反应式 IK。 + if (terrainDemand < demandThreshold) { + this.suppressFlatPredictiveFoot(leg); + return; + } + + if (!plan) { + state.debugCandidates[0].valid = false; + this.suppressFlatPredictiveFoot(leg); + return; + } + + acceptPredictiveFootCandidate( + state, + candidate, + this.predictiveScratch, + ); + selectPredictiveDebugCandidate(state, 0); + plan.terrainDemand = terrainDemand; + this.updatePredictiveFootTrajectory( + leg, + footWorld, + trajectoryStart, + state.trajectoryProgress, + plan, + demandExit, + demandEnter, + ); + return; + } + + // 鞋底可站但脚骨目标未通过抬降/腿长约束时,不启用预测线路。 + const planeLift = getPredictivePlaneDistance(ctx, centerSupport); + state.debugPlaneLift = planeLift; + state.debugSwingLift = 0; + this.suppressFlatPredictiveFoot(leg); + } + } + + /** 组装预测候选搜索和轨迹规划共用的配置与回调。 */ + private getPredictiveContext(model: Object3D): PredictiveTrajectoryContext { + return { + up: this.up, + appliedScale: this.appliedScale, + snapEpsilon: this.snapEpsilon, + sampleRayOriginY: this.sampleRayOriginY, + maxPredictionCorrection: this.maxPredictionCorrection, + predictionMinNormalY: this.predictionMinNormalY, + maxFootRaise: this.maxFootRaise, + maxFootDrop: this.maxFootDrop, + pelvisKneeBend: this.pelvisKneeBend, + footAlignWeight: this.footAlignWeight, + predictionHorizon: this.predictionHorizon, + predictionVelocity: this.predictionVelocity, + scratch: this.predictiveScratch, + debug: this.debug, + castGroundFrom: (x, y, z) => this.castGroundFrom(x, y, z), + castCapsuleGround: () => this.castCapsuleGround(), + getConstrainedFootAlign: (normal, alignWeight, target) => + this.getConstrainedFootAlign(normal, alignWeight, target), + mergeCoplanarHits: (hits, highest, supportPoint, supportNormal) => + this.mergeCoplanarGroundHits(hits, highest, supportPoint, supportNormal), + getSafeLegReach: (upperLen, lowerLen, kneeBend) => + this.getSafeLegReach(upperLen, lowerLen, kneeBend), + model, + footPhaseClips: this.footPhaseClips, + clipName: this.footPhaseState.clipName, + normalizedTime: this.footPhaseState.normalizedTime, + swingClearance: this.swingClearance, + maxPredictionClearance: this.maxPredictionClearance, + }; + } + + // 地形需求退出阈值后立即交还动画和普通 Foot IK。 + private suppressFlatPredictiveFoot(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + state.mode = "tracking"; + state.score = Infinity; + state.supportObject = null; + state.trajectoryStartOffset.set(0, 0, 0); + state.trajectoryCurrentTarget.set(0, 0, 0); + state.trajectoryStartProgress = state.trajectoryProgress; + state.trajectoryClearance = 0; + state.predictionWeight = 0; + state.usesOffsetLanding = false; + state.debugTrajectoryVisible = false; + } + + /** 更新预测线路,并保持重规划首帧的世界空间目标连续。 */ + private updatePredictiveFootTrajectory( + leg: ReadyFootIKLeg, + animationFoot: Vector3, + trajectoryStart: Vector3, + progress: number, + plan: PredictiveTrajectoryPlan, + demandExit: number, + demandEnter: number, + ): void { + const state = leg.predictive; + const wasActive = state.mode === "active"; + + if (!wasActive) { + state.mode = "active"; + // 记下启用瞬间脚相对动画的偏移,后续按剩余摆腿把这份残差渐隐掉。 + state.trajectoryStartOffset.subVectors( + trajectoryStart, + animationFoot, + ); + state.trajectoryCurrentTarget.copy(trajectoryStart); + state.trajectoryStartProgress = MathUtils.clamp(progress, 0, 1); + } else { + const localProgress = getPredictiveLocalProgress( + progress, + state.trajectoryStartProgress, + ); + const warpAlpha = MathUtils.smoothstep(localProgress, 0, 1); + const remainingStartWeight = 1 - warpAlpha; + if (remainingStartWeight > 1e-4) { + const landingCorrection = this.tmpV1.subVectors( + state.landingTarget, + state.animatedLanding, + ); + // 用当前输出反推起点残差,使重规划后这一帧的世界目标不变。 + const resolvedWithoutStart = this.tmpV2 + .copy(animationFoot) + .addScaledVector(landingCorrection, warpAlpha) + .addScaledVector( + this.up, + plan.clearance + * getPredictiveClearanceShape(localProgress), + ); + state.trajectoryStartOffset + .subVectors( + state.trajectoryCurrentTarget, + resolvedWithoutStart, + ) + .multiplyScalar(1 / remainingStartWeight); + } else { + state.trajectoryStartOffset.set(0, 0, 0); + } + } + + state.trajectoryClearance = plan.clearance; + // 需求刚过退出阈值时权重接近 0,接近进入阈值时才满权。 + state.predictionWeight = MathUtils.smoothstep( + plan.terrainDemand, + demandExit, + demandEnter, + ); + if (this.debug) { + for (let i = 0; i < state.debugTrajectory.length; i++) { + state.debugTrajectory[i].copy(plan.points[i]); + } + } + this.rebuildPredictiveDebugTrajectory(leg); + } + + /** 根据当前融合状态刷新预测摆动轨迹的调试可见性。 */ + private rebuildPredictiveDebugTrajectory(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + state.debugTrajectoryVisible = this.debug + && Number.isFinite(state.score) + && state.mode === "active" + && state.predictionWeight > 0.001; + } + + /** 让动画中心落点持续跟随实际落地帧,并保持当前支撑平面的高度关系。 */ + private followAnimatedLandingTarget(leg: ReadyFootIKLeg): void { + const state = leg.predictive; + const previousTarget = this.debug + ? this.tmpV1.copy(state.landingTarget) + : null; + const deltaX = state.animatedLanding.x - state.landingTarget.x; + const deltaZ = state.animatedLanding.z - state.landingTarget.z; + state.landingTarget.x = state.animatedLanding.x; + state.landingTarget.z = state.animatedLanding.z; + // 水平跟着动画走时,沿支撑平面改 Y,避免脚骨离开已探测的地面。 + if (state.landingNormal.y > 0.18) { + state.landingTarget.y -= ( + state.landingNormal.x * deltaX + + state.landingNormal.z * deltaZ + ) / state.landingNormal.y; + } + + if (previousTarget) { + shiftPredictiveDebugTrajectory( + state.debugTrajectory, + this.tmpV2.subVectors(state.landingTarget, previousTarget), + ); + } + } + + /** 支撑阶段沿用发生水平修正的预测落点,直到下一次摆腿开始。 */ + private resolvePredictivePlantedFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + delta: number, + ): boolean { + const state = leg.predictive; + if (state.mode !== "planted" || !state.usesOffsetLanding) return false; + + refreshPredictiveSupportAnchor(leg, this.predictiveScratch, this.debug); + leg.smoothedTarget.copy(state.landingTarget); + // 骨盆使用未裁剪落点,与普通 Foot IK 一样先按可达性下拉/上抬,再限制脚目标。 + leg.hasPelvisTarget = true; + leg.pelvisTarget.copy(leg.smoothedTarget); + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + leg.hitPoint.copy(state.supportPoint); + leg.hitNormal.copy(state.landingNormal); + leg.supportPoint.copy(state.supportPoint); + leg.supportNormal.copy(state.landingNormal); + leg.movePenetrating = false; + leg.plantedWeight = MathUtils.damp( + Math.max(leg.plantedWeight, leg.weight), + 1, + 10, + delta, + ); + leg.weight = leg.plantedWeight; + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + this.updateFootDebug(leg, state.supportPoint); + return true; + } + + // 返回支撑残差在当前摆腿相位中尚未释放的权重。 + private getPredictiveReleaseWeight(state: PredictiveFootState): number { + if (!state.releaseActive) return 0; + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.releaseStartProgress, + ); + return 1 - MathUtils.smoothstep( + localProgress, + 0, + PREDICTION_RELEASE_PROGRESS, + ); + } + + /** 在新摆腿前段逐渐释放上一支撑落点相对动画脚的残差。 */ + private resolvePredictiveReleaseFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + ): boolean { + const state = leg.predictive; + const releaseWeight = this.getPredictiveReleaseWeight(state); + if (releaseWeight <= 0.001) { + state.releaseOffset.set(0, 0, 0); + state.releaseActive = false; + return false; + } + + leg.smoothedTarget + .copy(footWorld) + .addScaledVector(state.releaseOffset, releaseWeight); + leg.hasPelvisTarget = true; + leg.pelvisTarget.copy(leg.smoothedTarget); + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + leg.movePenetrating = false; + leg.plantedWeight = 0; + leg.weight = 1; + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + this.updateFootDebug(leg, leg.smoothedTarget); + return true; + } + + /** 预测摆腿起点:已有反应式输出时从抬过的脚开始。 */ + private getPredictiveSwingStart( + leg: ReadyFootIKLeg, + footWorld: Vector3, + ): Vector3 { + if (leg.weight > 0.001 || Math.abs(leg.offsetY) > this.snapEpsilon) { + return this.predictiveScratch.trajectoryStart.copy(leg.smoothedTarget); + } + return footWorld; + } + + /** 根据地形需求权重统一融合动画轨迹、预测落点和摆动净空。 */ + private resolvePredictiveSwingFoot( + leg: ReadyFootIKLeg, + footWorld: Vector3, + phase: FootPhaseRuntimeState | undefined, + delta: number, + ): boolean { + if ( + !this.predictivePlacement + || !phase + || leg.planted + ) { + return false; + } + + const state = leg.predictive; + + if ( + state.mode !== "active" + || !Number.isFinite(state.score) + || state.predictionWeight <= 0.001 + ) { + return false; + } + + // 移动平台每帧刷新当前预测线路的世界空间落点。 + refreshPredictiveSupportAnchor(leg, this.predictiveScratch, this.debug); + + // 当前预测落点相对动画预计落点的世界空间修正。 + const landingCorrection = this.tmpV1.subVectors( + state.landingTarget, + state.animatedLanding, + ); + + // 把启用后的剩余摆腿阶段重新映射到 0 到 1。 + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.trajectoryStartProgress, + ); + + // 平滑渐隐重规划起点偏移,并渐入落点修正。 + const warpAlpha = MathUtils.smoothstep(localProgress, 0, 1); + const previousAlongUp = ( + leg.weight > 0.001 || Math.abs(leg.offsetY) > this.snapEpsilon + ) + ? leg.smoothedTarget.dot(this.up) + : footWorld.dot(this.up); + + // 保留动画摆腿轨迹,只叠加连续起点、预测落点和地形净空修正。 + leg.smoothedTarget + .copy(footWorld) + .addScaledVector( + state.trajectoryStartOffset, + 1 - warpAlpha, + ) + .addScaledVector( + landingCorrection, + warpAlpha, + ) + .addScaledVector( + this.up, + state.trajectoryClearance + * getPredictiveClearanceShape(localProgress), + ); + + // 按当前脚掌旋转和四角实际位置上抬,和反应式摆动防穿透同一套。 + const hit = this.castBestFootGround(leg); + if (hit) { + const groundOffset = Math.max(0, this.getFootGroundOffset(leg)); + if (groundOffset <= this.maxFootRaise) { + leg.smoothedTarget.y = Math.max( + leg.smoothedTarget.y, + footWorld.y + groundOffset, + ); + } + } else { + leg.hitPoint.copy(state.supportPoint); + leg.hitNormal.copy(state.landingNormal); + leg.supportPoint.copy(state.supportPoint); + leg.supportNormal.copy(state.landingNormal); + } + + // 骨盆使用当前帧轨迹目标,不能传尚未到达的最终落点。 + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + this.dampPredictiveSwingOutput( + leg, + footWorld, + previousAlongUp, + state.predictionWeight, + delta, + ); + leg.pelvisTarget.copy(leg.smoothedTarget); + state.trajectoryCurrentTarget.copy(leg.smoothedTarget); + + if (state.releaseActive) { + state.releaseOffset.set(0, 0, 0); + state.releaseActive = false; + } + + leg.hasPelvisTarget = true; + leg.movePenetrating = false; + + this.rebuildPredictiveDebugTrajectory(leg); + this.updateFootDebug(leg, hit?.point ?? state.supportPoint); + return true; + } + + /** 从上一帧反应式输出阻尼到本帧预测目标。 */ + private dampPredictiveSwingOutput( + leg: ReadyFootIKLeg, + footWorld: Vector3, + previousAlongUp: number, + wantedWeight: number, + delta: number, + ): void { + const wantedAlongUp = leg.smoothedTarget.dot(this.up); + const chase = wantedWeight > 0.001; + const damp = chase ? PREDICTIVE_SWING_CHASE_DAMP : PREDICTIVE_SWING_FADE_DAMP; + let dampedAlongUp = MathUtils.damp( + previousAlongUp, + wantedAlongUp, + damp, + delta, + ); + if (Math.abs(dampedAlongUp - wantedAlongUp) < this.snapEpsilon) { + dampedAlongUp = wantedAlongUp; + } + leg.smoothedTarget.addScaledVector( + this.up, + dampedAlongUp - wantedAlongUp, + ); + leg.offsetY = leg.smoothedTarget.y - footWorld.y; + + leg.weight = MathUtils.damp(leg.weight, wantedWeight, damp, delta); + if (leg.weight < 0.001) leg.weight = 0; + + leg.plantedWeight = MathUtils.damp( + leg.plantedWeight, + 0, + PREDICTIVE_SWING_FADE_DAMP, + delta, + ); + if (leg.plantedWeight < 0.001) leg.plantedWeight = 0; + } + + /** 把脚的 up 部分旋到地面法线,并施加最大倾角和对齐权重。 */ + private getConstrainedFootAlign( + normal: Vector3, + alignWeight: number, + target: Quaternion, + ): Quaternion { + target.setFromUnitVectors(this.up, normal); + const realAngle = 2 * Math.acos(MathUtils.clamp(target.w, -1, 1)); + if (realAngle > this.maxFootTilt && realAngle > 1e-6) { + target.slerp(this.identityQ, 1 - this.maxFootTilt / realAngle); + } + target.slerp(this.identityQ, 1 - alignWeight); + return target; + } + + /** 只合并最高命中所在的近似共面点,避免跨台阶平均出不存在的斜面。 */ + private mergeCoplanarGroundHits( + hits: Array, + highest: FootIKGroundHit, + supportPoint: Vector3, + supportNormal: Vector3, + ): number { + const planeEpsilon = Math.max(1e-6, 2 * this.appliedScale); + supportPoint.set(0, 0, 0); + supportNormal.set(0, 0, 0); + let supportCount = 0; + for (const hit of hits) { + if (!hit) continue; + const planeDistance = Math.abs( + highest.normal.dot(this.tmpV5.copy(hit.point).sub(highest.point)), + ); + if (planeDistance > planeEpsilon || hit.normal.dot(highest.normal) < 0.95) continue; + supportPoint.add(hit.point); + supportNormal.add(hit.normal); + supportCount++; + } + if (supportCount > 0) { + supportPoint.multiplyScalar(1 / supportCount); + supportNormal.normalize(); + } + return supportCount; + } + + /** 按指定膝盖弯曲角反算双骨链的安全伸展距离。 */ + private getSafeLegReach(upperLen: number, lowerLen: number, kneeBend: number): number { + return Math.sqrt( + upperLen * upperLen + + lowerLen * lowerLen + + 2 * upperLen * lowerLen * Math.cos(kneeBend), + ); + } + + /** 将脚目标限制在当前髋部的安全可达区间;落地够不着时停在最大伸展上,避免硬踩。 */ + private clampPredictiveTargetToReach(leg: ReadyFootIKLeg, target: Vector3): void { + const hip = leg.upper.getWorldPosition(this.tmpV1); + const knee = leg.lower.getWorldPosition(this.tmpV2); + const foot = leg.foot.getWorldPosition(this.tmpV3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + const safeMaxReach = this.getSafeLegReach(upperLen, lowerLen, this.pelvisKneeBend); + const safeMinReach = this.getSafeLegReach(upperLen, lowerLen, this.maxKneeBend); + const hipToTarget = this.tmpV4.subVectors(target, hip); + const targetDistance = hipToTarget.length(); + if (targetDistance < 1e-6) return; + + const safeDistance = MathUtils.clamp(targetDistance, safeMinReach, safeMaxReach); + if (Math.abs(safeDistance - targetDistance) <= 1e-6) return; + target.copy(hip).addScaledVector(hipToTarget, safeDistance / targetDistance); + } + + // 对一只脚的四个虚拟脚底点分别向下射线,选择命中高度最高的点作为本帧调试/法线参考。 + // 平地浮点抖动时用滞回。 + private castBestFootGround(leg: ReadyFootIKLeg): FootIKGroundHit | null { + this.updateSoleSamples(leg); + + const samples = leg.soleSamples; + // 设计尺度约 1 个单位。 + const stickEpsilon = Math.max(1e-6, this.appliedScale); + const hits = this.sampleGroundHitSlots; + + let maxY = -Infinity; + let maxIndex = -1; + let maxHit: FootIKGroundHit | null = null; + + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + sample.hasHit = false; + hits[i] = null; + const hit = this.castGroundAtSample(sample.point); + if (!hit) continue; + sample.hasHit = true; + sample.hitPoint.copy(hit.point); + const stored = copyGroundHit(hit, this.sampleGroundHits[i]); + hits[i] = stored; + if (stored.point.y > maxY) { + maxY = stored.point.y; + maxIndex = i; + maxHit = stored; + } + } + + if (maxIndex < 0 || !maxHit) { + leg.bestGroundSampleIndex = -1; + return null; + } + + let bestIndex = maxIndex; + let bestHit = maxHit; + const prev = leg.bestGroundSampleIndex; + if (prev >= 0 && prev < samples.length) { + const prevHit = hits[prev]; + if (prevHit && prevHit.point.y >= maxY - stickEpsilon) { + bestIndex = prev; + bestHit = prevHit; + } + } + + // 只合并最高命中所在的近似共面点,避免跨台阶时生成不存在的中间斜面。 + const supportCount = this.mergeCoplanarGroundHits( + hits, + bestHit, + leg.supportPoint, + leg.supportNormal, + ); + if (supportCount <= 0) { + leg.supportPoint.copy(bestHit.point); + leg.supportNormal.copy(bestHit.normal); + } + + leg.bestGroundSampleIndex = bestIndex; + leg.footSamplePoint.copy(samples[bestIndex].point); + this.bestGroundHit.point.copy(bestHit.point); + this.bestGroundHit.normal.copy(leg.supportNormal); + this.bestGroundHit.object = bestHit.object; + return this.bestGroundHit; + } + + // 基于初始化姿态,把脚底四个采样点固定到 foot 骨骼本地空间。 + private initSoleLocalSamples(leg: FootIKLeg): void { + if (!isReadyLeg(leg)) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + const toeWorld = leg.toe + ? leg.toe.getWorldPosition(this.tmpV2) + : this.tmpV2.copy(footWorld).add(this.tmpV3.set(0, 0, 1).applyQuaternion(leg.foot.getWorldQuaternion(this.tmpQ1)).setY(0).normalize().multiplyScalar(this.fakeToeExtend)); + + const forward = this.tmpV3.copy(toeWorld).sub(footWorld).setY(0); + const minFwdSq = Math.max(1e-8, 1e-4 * this.appliedScale * this.appliedScale); + if (forward.lengthSq() < minFwdSq) { + forward.set(0, 0, 1).applyQuaternion(leg.foot.getWorldQuaternion(this.tmpQ1)).setY(0); + } + forward.normalize(); + + const side = this.tmpV4.crossVectors(forward, this.up).normalize(); + const heelCenter = this.tmpV5.copy(footWorld).addScaledVector(forward, -this.soleHeelExtend); + const toeCenter = this.tmpV2.copy(toeWorld).addScaledVector(forward, this.soleToeExtend); + + heelCenter.y = toeCenter.y; + + leg.foot.updateMatrixWorld(true); + const samples = leg.soleSamples; + samples[0].point.copy(heelCenter).addScaledVector(side, this.soleHalfWidth); + samples[1].point.copy(heelCenter).addScaledVector(side, -this.soleHalfWidth); + samples[2].point.copy(toeCenter).addScaledVector(side, this.soleHalfWidth); + samples[3].point.copy(toeCenter).addScaledVector(side, -this.soleHalfWidth); + + // 把脚骨→鞋底厚度打进 foot-local,之后随骨骼世界缩放。 + for (const sample of samples) { + sample.point.y -= this.soleSkinThickness; + sample.local.copy(sample.point); + leg.foot.worldToLocal(sample.local); + } + leg.footSamplePoint.copy(heelCenter); + } + + // 把 foot-local 脚底四点转换到世界空间,供射线、穿透检测和调试使用。 + private updateSoleSamples(leg: FootIKLeg): void { + if (!isReadyLeg(leg)) return; + + leg.foot.updateMatrixWorld(true); + const samples = leg.soleSamples; + for (const sample of samples) { + sample.point.copy(sample.local).applyMatrix4(leg.foot.matrixWorld); + } + + if (samples.length >= 2) { + leg.footSamplePoint.copy(samples[0].point).add(samples[1].point).multiplyScalar(0.5); + } + } + + // 四个脚底采样点中取最高地面相对脚底的垂直偏移。 + private getFootGroundOffset(leg: ReadyFootIKLeg): number { + let offset = -Infinity; + for (const sample of leg.soleSamples) { + if (!sample.hasHit) continue; + offset = Math.max(offset, sample.hitPoint.y - sample.point.y); + } + + return Number.isFinite(offset) ? offset : 0; + } + + // 先把脚掌旋到支撑面方向,再计算四个脚底点同时不穿透时所需的踝骨垂直偏移。 + private getAlignedFootTargetOffset(leg: ReadyFootIKLeg, footWorld: Vector3): number { + const supportNormal = leg.supportNormal; + if (supportNormal.y <= 0.18) return this.getFootGroundOffset(leg); + + const footWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + const alignQ = this.getConstrainedFootAlign( + supportNormal, + this.footAlignWeight, + this.tmpQ2, + ); + const targetWorldQ = alignQ.multiply(footWorldQ); + const worldScale = leg.foot.getWorldScale(this.tmpV4); + + let targetOffset = -Infinity; + for (const sample of leg.soleSamples) { + const alignedPoint = this.tmpV5 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(targetWorldQ) + .add(footWorld); + const planeOffset = supportNormal.dot( + this.tmpV3.copy(leg.supportPoint).sub(alignedPoint), + ) / supportNormal.y; + targetOffset = Math.max(targetOffset, planeOffset); + } + + return Number.isFinite(targetOffset) ? targetOffset : this.getFootGroundOffset(leg); + } + + // 判断当前动画是否属于移动类动画。 + private isLocomotion(): boolean { + const name = this.player?.animation.state?.getClip().name ?? ""; + return this.isLocomotionClipName(name); + } + + // 统一判断脚步相位采样和运行时移动状态:直接使用 playerModelConfig 中的移动动画名。 + private isLocomotionClipName(name: string): boolean { + if (!name) return false; + const config = this.player?.playerModelConfig; + if (!config) return false; + return [ + config.walkAnim, + config.runAnim, + config.leftWalkAnim, + config.rightWalkAnim, + config.backwardAnim, + ].filter((value): value is string => !!value).includes(name); + } + + // 从骨骼采样点上方发射射线。 + private castGroundAtSample(sampleWorld: Vector3): FootIKGroundHit | null { + return this.castGroundFrom(sampleWorld.x, sampleWorld.y + this.sampleRayOriginY, sampleWorld.z); + } + + // 读取本帧碰撞网格,供后续脚底射线共用。 + private refreshColliderMeshes(): void { + this.colliderMeshes = this.player?.getColliderMeshes() ?? this.colliderMeshes; + } + + // 优先读取移动系统最终采用的支撑。 + private castCapsuleGround(): FootIKGroundHit | null { + const capsule = this.player?.playerCapsule; + if (!capsule) return null; + + const activeBody = this.player?.getActiveDynamicBody?.(); + const ignoreSupport = activeBody?.kind === "sphere"; + + const getGroundSupport = this.player?.getGroundSupport; + if (getGroundSupport && !ignoreSupport) { + const support = getGroundSupport.call(this.player); + if (!support) return null; + this.capsuleHit.point.copy(support.point); + this.capsuleHit.normal.copy(support.normal); + this.capsuleHit.object = undefined; + return this.capsuleHit; + } + + const hit = this.castGroundFrom( + capsule.position.x, + capsule.position.y, + capsule.position.z, + ); + return hit ? copyGroundHit(hit, this.capsuleHit) : null; + } + + // 从指定世界坐标向下检测可踩踏地面,结果写入复用命中对象。 + private castGroundFrom(x: number, y: number, z: number): FootIKGroundHit | null { + this.raycaster.ray.origin.set(x, y, z); + const hits = this.raycaster.intersectObjects(this.colliderMeshes, false); + let foundMesh = false; + for (let i = 0; i < hits.length; i++) { + const meshHit = hits[i]; + if (this.getMeshWorldHitNormal(meshHit, this.groundHit.normal).y <= 0.18) continue; + this.groundHit.point.copy(meshHit.point); + this.groundHit.object = meshHit.object; + foundMesh = true; + break; + } + + const dynamicHit = this.player?.raycastDynamicGround?.( + this.raycaster.ray.origin, + 0.18, + FOOT_IK_IGNORED_DYNAMIC_KINDS, + ); + if (!dynamicHit) return foundMesh ? this.groundHit : null; + const distance = y - dynamicHit.point.y; + if (distance < this.raycaster.near || distance > this.raycaster.far) { + return foundMesh ? this.groundHit : null; + } + if (foundMesh && this.groundHit.point.y >= dynamicHit.point.y) return this.groundHit; + this.groundHit.point.copy(dynamicHit.point); + this.groundHit.normal.copy(dynamicHit.normal); + this.groundHit.object = dynamicHit.body?.mesh; + return this.groundHit; + } + + // 将 mesh 射线命中的局部法线转换为世界空间法线。 + private getMeshWorldHitNormal(hit: Intersection, target: Vector3): Vector3 { + target.copy(hit.face?.normal ?? this.up); + this.normalMatrix.getNormalMatrix(hit.object.matrixWorld); + target.applyMatrix3(this.normalMatrix).normalize(); + return target; + } + + // 根据双脚高台面和脚目标可达性对骨盆做平滑补偿。 + private applyPelvis(delta: number): void { + if (!this.hips) return; + + if (this.pelvisOffsetOverride !== null) { + this.pelvisOffset = this.pelvisOffsetOverride; + } else { + // 双脚都高于胶囊时按较低脚上抬;其余情况只补偿超出腿长的下沉距离。 + const raiseOffset = this.getRequiredPelvisRaise(); + const leftDrop = isReadyLeg(this.legs.left) + ? this.getRequiredPelvisDrop(this.legs.left) + : 0; + const rightDrop = isReadyLeg(this.legs.right) + ? this.getRequiredPelvisDrop(this.legs.right) + : 0; + const reachOffset = MathUtils.clamp( + Math.min(leftDrop, rightDrop, 0), + -this.maxPelvisDrop, + 0, + ); + const wantedWorldOffset = raiseOffset > 0 + ? raiseOffset + : Math.max(reachOffset, this.getSupportLimitedPelvisDrop()); + + this.pelvisOffset = MathUtils.damp(this.pelvisOffset, wantedWorldOffset, 12, delta); + } + if (Math.abs(this.pelvisOffset) < this.snapEpsilon) return; + + const parent = this.hips.parent; + if (!parent) return; + + this.capture(this.hips); + // 骨盆父级可能带有轴向旋转,先在世界 Y 轴生成目标,再转换回父级局部坐标。 + const targetWorld = this.hips + .getWorldPosition(this.tmpV1) + .addScaledVector(this.up, this.pelvisOffset); + parent.worldToLocal(targetWorld); + this.hips.position.copy(targetWorld); + this.hips.updateMatrixWorld(true); + } + + // 双脚都高于胶囊时,按较低脚与胶囊的高度差上抬骨盆。 + private getRequiredPelvisRaise(): number { + const left = this.legs.left; + const right = this.legs.right; + if (!isReadyLeg(left) || !isReadyLeg(right)) return 0; + + const leftSupportY = this.getLegSupportY(left); + const rightSupportY = this.getLegSupportY(right); + if (!Number.isFinite(leftSupportY) || !Number.isFinite(rightSupportY)) return 0; + + const capsuleHit = this.castCapsuleGround(); + if (!capsuleHit) return 0; + const heightDelta = Math.min(leftSupportY, rightSupportY) - capsuleHit.point.y; + if (heightDelta <= 0) return 0; + return MathUtils.clamp(heightDelta, 0, this.maxPelvisRaise); + } + + // 计算脚骨骼水平位置在当前支撑平面上对应的高度。 + private getLegSupportY(leg: ReadyFootIKLeg): number { + if (leg.supportNormal.y <= 0.18) return NaN; + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + return leg.supportPoint.y - ( + leg.supportNormal.x * (footWorld.x - leg.supportPoint.x) + + leg.supportNormal.z * (footWorld.z - leg.supportPoint.z) + ) / leg.supportNormal.y; + } + + // 脚下支撑面只限制骨盆最多下沉到哪里,不直接推动骨盆,避免中心射线命中错误表面时抬动模型。 + private getSupportLimitedPelvisDrop(): number { + const capsuleHit = this.castCapsuleGround(); + if (!capsuleHit) return -this.maxPelvisDrop; + + let lowestSupportDelta = 0; + let hasSupport = false; + for (const leg of Object.values(this.legs)) { + if (!isReadyLeg(leg) || !leg.hasPelvisTarget) continue; + const supportY = this.getLegSupportY(leg); + if (!Number.isFinite(supportY)) continue; + lowestSupportDelta = Math.min( + lowestSupportDelta, + supportY - capsuleHit.point.y, + ); + hasSupport = true; + } + + return hasSupport + ? MathUtils.clamp(lowestSupportDelta, -this.maxPelvisDrop, 0) + : -this.maxPelvisDrop; + } + + // 按双骨骼 IK 的最大伸展距离,反算脚目标进入可达范围所需的最小骨盆下沉量。 + private getRequiredPelvisDrop(leg: ReadyFootIKLeg): number { + if (!leg.hasPelvisTarget) return 0; + + const hip = leg.upper.getWorldPosition(this.tmpV1); + const knee = leg.lower.getWorldPosition(this.tmpV2); + const foot = leg.foot.getWorldPosition(this.tmpV3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + const maxReach = Math.sqrt( + upperLen * upperLen + + lowerLen * lowerLen + + 2 * upperLen * lowerLen * Math.cos(this.pelvisKneeBend), + ); + + const deltaX = leg.pelvisTarget.x - hip.x; + const deltaY = leg.pelvisTarget.y - hip.y; + const deltaZ = leg.pelvisTarget.z - hip.z; + const horizontalSq = deltaX * deltaX + deltaZ * deltaZ; + const maxReachSq = maxReach * maxReach; + if (horizontalSq + deltaY * deltaY <= maxReachSq || deltaY >= 0) return 0; + + // 水平距离已经超出腿长时,垂直下沉无法完全满足目标;仍下沉到与目标同高的位置以取得最短距离。 + if (horizontalSq >= maxReachSq) return deltaY; + + const verticalReach = Math.sqrt(maxReachSq - horizontalSq); + return Math.min(0, deltaY + verticalReach); + } + + /** 在预测摆腿末段逐渐把脚掌旋转到落点支撑面。 */ + private preAlignPredictiveLandingRotation( + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState | undefined, + ): void { + if (!phase) return; + + const state = leg.predictive; + + if ( + state.mode !== "active" + || leg.planted + || state.landingNormal.y <= 0.18 + ) { + return; + } + + const localProgress = getPredictiveLocalProgress( + state.trajectoryProgress, + state.trajectoryStartProgress, + ); + + // 启用后先保留动画旋转,进入末段后再逐渐贴合坡面。 + const rotationWeight = MathUtils.smoothstep( + localProgress, + PREDICTION_ROTATION_WARP_START, + 1, + ) * state.predictionWeight; + + if (rotationWeight <= 0.001) return; + + this.getConstrainedFootAlign( + state.landingNormal, + this.footAlignWeight, + this.tmpQ2, + ); + const targetWorldQ = this.tmpQ3 + .copy(this.tmpQ2) + .multiply(state.animatedLandingRotation); + + // 从当前动画旋转平滑过渡到预测落脚旋转。 + const currentWorldQ = + leg.foot.getWorldQuaternion( + this.tmpQ1, + ); + + currentWorldQ.slerp( + targetWorldQ, + rotationWeight, + ); + + const parent = leg.foot.parent; + if (!parent) return; + + const parentWorldQ = + parent.getWorldQuaternion( + this.tmpQ4, + ); + + this.capture(leg.foot); + + leg.foot.quaternion.copy( + parentWorldQ + .invert() + .multiply(currentWorldQ), + ); + + leg.foot.updateMatrixWorld(true); + } + + // 对指定腿执行 IK 求解并贴合脚掌。 + private applyLeg(side: FootIKSide, useStraightPole: boolean): void { + const leg = this.legs[side]; + if (!isReadyLeg(leg) || leg.weight <= 0.001) return; + + // 保存动画给出的 foot 世界旋转,普通 IK 解腿后按权重恢复该旋转。 + this.savedFootWorldQ.copy(leg.foot.getWorldQuaternion(this.tmpQ1)); + + const predictiveSwing = this.predictivePlacement + && !leg.planted + && leg.predictive.mode === "active"; + if (predictiveSwing) { + // 骨盆修正后再检查一次可达范围。 + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + leg.predictive.trajectoryCurrentTarget.copy(leg.smoothedTarget); + } else if (leg.planted && leg.hasPelvisTarget) { + // 落地目标用未裁剪的支撑面;髋更新后再裁到当前腿长,够不着时停在伸展球面上。 + if (!Number.isFinite(this.getHeightSpeed(this.plantedHeightSpeed))) { + leg.smoothedTarget.copy(leg.pelvisTarget); + } + this.clampPredictiveTargetToReach(leg, leg.smoothedTarget); + } + + // 腿部位置修正完成后恢复动画脚掌旋转,避免位置 IK 改变脚尖朝向。 + this.solveLeg( + leg, + leg.smoothedTarget, + leg.weight, + useStraightPole ? 1 : 0, + ); + this.preserveFootWorldRotation(leg, this.savedFootWorldQ, leg.weight); + // 预测摆动脚在接近落地时才逐渐旋到支撑面。 + if (predictiveSwing) { + this.preAlignPredictiveLandingRotation( + leg, + this.footPhaseState?.[side], + ); + } + // 支撑脚离地后按剩余相位权重逐渐释放坡面旋转。 + // 普通摆动脚只在防穿透时贴合坡面,预测线路不额外接管脚掌旋转。 + if ( + leg.planted + || leg.movePenetrating + || leg.plantedWeight > 0.001 + ) { + this.alignFootToGround(leg); + } + if (leg.planted) { + this.correctPostAlignSoleContact(leg); + } + } + + // 抵消腿部 IK 对 foot 子骨骼造成的被动旋转,保留动画本来的脚掌朝向。 + private preserveFootWorldRotation( + leg: ReadyFootIKLeg, + animatedWorldQ: Quaternion, + weight: number, + ): void { + this.capture(leg.foot); + + const currentWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + currentWorldQ.slerp(animatedWorldQ, MathUtils.clamp(weight, 0, 1)); + + const parentWorldQ = leg.foot.parent?.getWorldQuaternion(this.tmpQ2); + if (!parentWorldQ) return; + leg.foot.quaternion.copy(parentWorldQ.invert().multiply(currentWorldQ)); + leg.foot.updateMatrixWorld(true); + } + + // 使用受约束的双骨骼 IK,让脚靠近目标点。 + private solveLeg( + leg: ReadyFootIKLeg, + target: Vector3, + weight: number, + straightPoleWeight = 0, + ): void { + let kneePlaneNormal: Vector3 | undefined; + if (straightPoleWeight > 0.001) { + // 移动时用胶囊局部 +X 作为角色前后平面的法线。 + // 静止时不传此约束,让 IK 保留 idle 动画本身的膝盖 pole。 + const capsule = this.player?.playerCapsule; + if (!capsule) return; + kneePlaneNormal = this.tmpV4 + .set(1, 0, 0) + .applyQuaternion(capsule.getWorldQuaternion(this.tmpQ3)) + .normalize(); + } + + solveTwoBoneIK(leg, target, weight, { + capture: bone => this.capture(bone), + minKneeBend: this.minKneeBend, + maxKneeBend: this.maxKneeBend, + kneePlaneNormal, + kneePlaneWeight: straightPoleWeight, + scratch: this.twoBoneIKScratch, + }); + } + + // 脚掌贴坡会绕 foot 骨骼旋转,旋转后重新校正脚底接触。 + private correctPostAlignSoleContact( + leg: ReadyFootIKLeg, + ): void { + if (leg.weight <= 0.001) return; + + const footWorld = leg.foot.getWorldPosition(this.tmpV1); + this.updateSoleSamples(leg); + + let contactOffset = -Infinity; + let anyContactHit = false; + for (const sample of leg.soleSamples) { + sample.hasHit = false; + const hit = this.castGroundAtSample(sample.point); + if (!hit) continue; + + sample.hasHit = true; + sample.hitPoint.copy(hit.point); + const sampleOffset = hit.point.y - sample.point.y; + // 摆动脚只做防穿透上抬,不向下吸附。 + if (!leg.planted && sampleOffset < 0) continue; + // 超出脚部 IK 范围的点不参与二次接触校正。 + if (leg.planted && sampleOffset < -this.maxFootDrop) continue; + if (sampleOffset > this.maxFootRaise) continue; + + anyContactHit = true; + contactOffset = Math.max(contactOffset, sampleOffset); + } + + if (!anyContactHit) return; + if (leg.planted && contactOffset < -this.maxFootDrop) return; + if (contactOffset > this.maxFootRaise) return; + contactOffset = leg.planted + ? contactOffset + : MathUtils.clamp(contactOffset, 0, this.maxFootRaise); + if (Math.abs(contactOffset) <= this.snapEpsilon) return; + // 站住时较大的剩余高度交给下一帧的时间阻尼。 + if ( + leg.planted + && Math.abs(contactOffset) > Math.max(this.snapEpsilon, 4 * this.appliedScale) + ) { + return; + } + + this.savedAlignedFootWorldQ.copy(leg.foot.getWorldQuaternion(this.tmpQ1)); + const correctedTarget = this.tmpV2.copy(footWorld).addScaledVector(this.up, contactOffset); + + this.solveLeg(leg, correctedTarget, leg.weight); + this.preserveFootWorldRotation(leg, this.savedAlignedFootWorldQ, 1); + } + + // 根据地面法线轻微调整脚掌朝向。 + private alignFootToGround(leg: ReadyFootIKLeg): void { + // 脚掌贴坡:把脚的 up 方向部分旋到地面法线。 + // 用最大角度限制避免台阶边缘的法线让脚踝扭得过夸张。 + if (leg.hitNormal.y < 0.35 || leg.weight <= 0.001) return; + this.capture(leg.foot); + + const footWorldQ = leg.foot.getWorldQuaternion(this.tmpQ1); + const alignQ = this.getConstrainedFootAlign( + leg.hitNormal, + leg.weight * this.footAlignWeight, + this.tmpQ2, + ); + + const targetWorldQ = alignQ.multiply(footWorldQ); + const parentWorldQ = leg.foot.parent?.getWorldQuaternion(this.tmpQ3); + if (!parentWorldQ) return; + leg.foot.quaternion.copy(parentWorldQ.invert().multiply(targetWorldQ)); + leg.foot.updateMatrixWorld(true); + } + + // 保存骨骼原始姿态,便于下一帧恢复。 + private capture(bone: Object3D): void { + // 第一次修改骨骼前保存原姿态,下一帧 restore 会用它恢复。 + if (this.adjusted.has(bone)) return; + let pose = this.poseCache.get(bone); + if (!pose) { + pose = { + position: new Vector3(), + quaternion: new Quaternion(), + }; + this.poseCache.set(bone, pose); + } + pose.position.copy(bone.position); + pose.quaternion.copy(bone.quaternion); + this.adjusted.add(bone); + } + + // 创建统一调试对象:IK 目标、最高命中、脚底四点。 + private createDebugObjects(): void { + createDebugObjects(this.player?.scene ?? null, this.legs, this.debug); + } + + // 更新单脚调试标记(含脚底四点和最大上抬范围);超出范围的线框命中球标红。 + private updateFootDebug(leg: FootIKLeg, hitPoint: Vector3): void { + updateFootDebug( + this.debug, + leg, + hitPoint, + this.appliedScale, + this.maxFootRaise, + ); + } + + // 统一切换全部 Foot IK 调试对象的显示状态。 + private setDebugVisible(visible: boolean): void { + setDebugVisible(this.legs, visible); + } + + /** 控制 Foot IK 调试对象显隐(IK 目标、最高命中、脚底四点)。 */ + setDebugEnabled(enabled: boolean): void { + if (this.disposed) return; + this.debug = enabled; + if (enabled) { + this.createDebugObjects(); + } + this.setDebugVisible(enabled); + } + + /** 读取当前可调配置(不含 skeleton)。距离和速度类参数返回 scale=1 基准值。 */ + getOptions(): Required> { + return { + enabled: this.enabled, + debug: this.debug, + maxPelvisDrop: this.toBaseDistance(this.maxPelvisDrop), + maxPelvisRaise: this.toBaseDistance(this.maxPelvisRaise), + maxFootRaise: this.toBaseDistance(this.maxFootRaise), + maxFootDrop: this.toBaseDistance(this.maxFootDrop), + plantedHeightSpeed: this.toBaseDistance(this.plantedHeightSpeed), + penetrationLiftSpeed: this.toBaseDistance(this.penetrationLiftSpeed), + soleHalfWidth: this.toBaseDistance(this.soleHalfWidth), + soleToeExtend: this.toBaseDistance(this.soleToeExtend), + soleHeelExtend: this.toBaseDistance(this.soleHeelExtend), + soleSkinThickness: this.toBaseDistance(this.soleSkinThickness), + footAlignWeight: this.footAlignWeight, + maxFootTilt: this.maxFootTilt, + minKneeBend: this.minKneeBend, + maxKneeBend: this.maxKneeBend, + pelvisKneeBend: this.pelvisKneeBend, + straightPoleEnabled: this.straightPoleEnabled, + moveLiftThreshold: this.toBaseDistance(this.moveLiftThreshold), + footPhaseSampleCount: this.footPhaseOptions.sampleCount, + footPhaseGroundThreshold: this.toBaseDistance(this.footPhaseGroundThreshold), + footPhaseMinContactRatio: this.footPhaseOptions.minContactRatio, + footPhaseSpeedSlack: this.footPhaseOptions.speedSlack, + predictivePlacement: this.predictivePlacement, + predictionHorizon: this.predictionHorizon, + predictionProbeFrames: this.predictionProbeFrames, + predictionSearchRadius: this.toBaseDistance(this.predictionSearchRadius), + maxPredictionCorrection: this.toBaseDistance(this.maxPredictionCorrection), + predictionMinNormalY: this.predictionMinNormalY, + swingClearance: this.toBaseDistance(this.swingClearance), + maxPredictionClearance: this.toBaseDistance(this.maxPredictionClearance), + }; + } + + /** + * 运行时更新部分配置。 + * 距离和速度类参数传入 scale=1 基准值,内部会乘以当前 playerModelConfig.scale。 + * sole 尺寸变化会重建本地采样点,脚步相位相关参数变化会重建相位库。 + */ + configure(options: Partial): void { + if (this.disposed) return; + + if (options.enabled !== undefined) this.setEnabled(options.enabled); + if (options.debug !== undefined) this.setDebugEnabled(options.debug); + if ( + options.predictivePlacement !== undefined + && options.predictivePlacement !== this.predictivePlacement + ) { + this.predictivePlacement = options.predictivePlacement; + this.resetPredictionRuntime(); + } + + let soleDirty = false; + let phaseDirty = false; + + if (options.maxPelvisDrop !== undefined) { + this.maxPelvisDrop = this.scaleDistance(options.maxPelvisDrop); + } + if (options.maxPelvisRaise !== undefined) { + this.maxPelvisRaise = this.scaleDistance(options.maxPelvisRaise); + } + if (options.maxFootRaise !== undefined) { + this.maxFootRaise = this.scaleDistance(options.maxFootRaise); + } + if (options.maxFootDrop !== undefined) { + this.maxFootDrop = this.scaleDistance(options.maxFootDrop); + } + if (options.plantedHeightSpeed !== undefined) { + this.plantedHeightSpeed = this.scaleDistance(options.plantedHeightSpeed); + } + if (options.penetrationLiftSpeed !== undefined) { + this.penetrationLiftSpeed = this.scaleDistance(options.penetrationLiftSpeed); + } + if (options.soleHalfWidth !== undefined) { + this.soleHalfWidth = this.scaleDistance(options.soleHalfWidth); + soleDirty = true; + } + if (options.soleToeExtend !== undefined) { + this.soleToeExtend = this.scaleDistance(options.soleToeExtend); + soleDirty = true; + } + if (options.soleHeelExtend !== undefined) { + this.soleHeelExtend = this.scaleDistance(options.soleHeelExtend); + soleDirty = true; + } + if (options.soleSkinThickness !== undefined) { + this.soleSkinThickness = this.scaleDistance(options.soleSkinThickness); + soleDirty = true; + } + if (options.footAlignWeight !== undefined) { + this.footAlignWeight = MathUtils.clamp(options.footAlignWeight, 0, 1); + } + if (options.maxFootTilt !== undefined) { + this.maxFootTilt = MathUtils.clamp(options.maxFootTilt, 0, Math.PI); + } + if (options.minKneeBend !== undefined || options.maxKneeBend !== undefined) { + const minBend = MathUtils.clamp(options.minKneeBend ?? this.minKneeBend, 0, Math.PI); + const maxBend = MathUtils.clamp(options.maxKneeBend ?? this.maxKneeBend, 0, Math.PI); + this.minKneeBend = Math.min(minBend, maxBend); + this.maxKneeBend = Math.max(minBend, maxBend); + this.pelvisKneeBend = MathUtils.clamp( + this.pelvisKneeBend, + this.minKneeBend, + this.maxKneeBend, + ); + } + if (options.pelvisKneeBend !== undefined) { + this.pelvisKneeBend = MathUtils.clamp( + options.pelvisKneeBend, + this.minKneeBend, + this.maxKneeBend, + ); + } + if (options.straightPoleEnabled !== undefined) { + this.straightPoleEnabled = options.straightPoleEnabled; + } + if (options.moveLiftThreshold !== undefined) { + this.moveLiftThreshold = this.scaleDistance(options.moveLiftThreshold); + } + if (options.predictionHorizon !== undefined) { + this.predictionHorizon = Math.max(0, options.predictionHorizon); + } + if (options.predictionProbeFrames !== undefined) { + this.predictionProbeFrames = Math.max(0, options.predictionProbeFrames); + } + if (options.predictionSearchRadius !== undefined) { + this.predictionSearchRadius = this.scaleDistance(options.predictionSearchRadius); + } + if (options.maxPredictionCorrection !== undefined) { + this.maxPredictionCorrection = this.scaleDistance(options.maxPredictionCorrection); + } + if (options.predictionMinNormalY !== undefined) { + this.predictionMinNormalY = MathUtils.clamp(options.predictionMinNormalY, 0, 1); + } + if (options.swingClearance !== undefined) { + this.swingClearance = this.scaleDistance(options.swingClearance); + } + if (options.maxPredictionClearance !== undefined) { + this.maxPredictionClearance = this.scaleDistance(options.maxPredictionClearance); + } + if (options.footPhaseGroundThreshold !== undefined) { + this.footPhaseGroundThreshold = this.scaleDistance(options.footPhaseGroundThreshold); + this.footPhaseOptions.groundThreshold = this.footPhaseGroundThreshold; + phaseDirty = true; + } + + if ( + options.footPhaseSampleCount !== undefined + || options.footPhaseMinContactRatio !== undefined + || options.footPhaseSpeedSlack !== undefined + ) { + this.footPhaseOptions = createFootPhaseOptions({ + footPhaseSampleCount: options.footPhaseSampleCount ?? this.footPhaseOptions.sampleCount, + footPhaseGroundThreshold: this.footPhaseGroundThreshold, + footPhaseMinContactRatio: options.footPhaseMinContactRatio ?? this.footPhaseOptions.minContactRatio, + footPhaseSpeedSlack: options.footPhaseSpeedSlack ?? this.footPhaseOptions.speedSlack, + }); + phaseDirty = true; + } + + if (soleDirty) { + this.initSoleLocalSamples(this.legs.left); + this.initSoleLocalSamples(this.legs.right); + } + if (phaseDirty) { + this.buildFootPhaseDatabase(); + } + } + + /** 返回指定脚当前动画相位的调试文本。 */ + getFootPhaseDebugText(side: FootIKSide): string { + return getFootPhaseDebugText(this.footPhaseState, this.footPhaseClips, side); + } + + /** 返回指定脚当前的最终 IK 权重。 */ + getFootIKWeight(side: FootIKSide): number { + return this.legs[side].weight; + } + + /** 返回当前骨盆沿世界上方向的偏移,scale=1 基准值;上抬为正、下沉为负。 */ + getPelvisOffset(): number { + return this.toBaseDistance(this.pelvisOffset); + } + + /** + * 每帧写入骨盆沿世界上方向的偏移,scale=1 基准值;上抬为正、下沉为负。 + * 写入后内部 wanted 不再覆盖,直到 clearPelvisOffset()。 + */ + setPelvisOffset(value: number): void { + if (this.disposed) return; + this.pelvisOffsetOverride = Number.isFinite(value) + ? value * this.getPlayerScale() + : 0; + this.pelvisOffset = this.pelvisOffsetOverride; + } + + /** 取消外部骨盆偏移,恢复内部计算结果。 */ + clearPelvisOffset(): void { + if (this.disposed) return; + this.pelvisOffsetOverride = null; + } + + /** 返回指定脚距离下一次落地的时间(秒);无相位数据时为 Infinity。 */ + getFootTimeToLand(side: FootIKSide): number { + return this.footPhaseState[side].timeToLand; + } + + /** 返回指定脚的预测落脚调试状态;预测关闭时返回 disabled。 */ + getPredictiveFootDebugText(side: FootIKSide): string { + if (!this.predictivePlacement) return "disabled"; + const state = this.legs[side].predictive; + // d 为落点相对胶囊支撑面的上台高度,swing 为摆腿路径上的最大凸起。 + return `${state.mode} d=${state.debugPlaneLift.toFixed(2)} swing=${state.debugSwingLift.toFixed(2)}`; + } + + /** 返回指定脚当前预测候选的调试评分;分数越小越好,无有效候选或预测关闭时返回 Infinity。 */ + getPredictiveFootScore(side: FootIKSide): number { + if (!this.predictivePlacement) return Infinity; + return this.legs[side].predictive.score; + } + +} diff --git a/three-player-controller/src/plugins/foot-ik/index.ts b/three-player-controller/src/plugins/foot-ik/index.ts new file mode 100644 index 0000000..448cd12 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/index.ts @@ -0,0 +1,8 @@ +export { FootIK } from "./FootIK"; +export type { + BoneRef, + FootIKLegBoneConfig, + FootIKOptions, + FootIKSide, + FootIKSkeletonConfig, +} from "./types"; diff --git a/three-player-controller/src/plugins/foot-ik/internal/debug.ts b/three-player-controller/src/plugins/foot-ik/internal/debug.ts new file mode 100644 index 0000000..a748ca9 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/debug.ts @@ -0,0 +1,675 @@ +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + Line, + LineBasicMaterial, + LineLoop, + LineSegments, + Mesh, + MeshBasicMaterial, + SphereGeometry, + Vector3, + type Material, + type Scene, +} from "three"; +import type { + FootIKLeg, + FootIKLegs, + FootIKSide, + FootPhaseControllerState, + FootPhaseDatabase, +} from "../types"; + +const FOOT_IK_SIDES: readonly FootIKSide[] = ["left", "right"]; + +/** 设计尺度下的 IK 目标球半径;与脚底四点同级,世界半径 = 该值 × appliedScale。 */ +const IK_MARKER_RADIUS = 3; +/** 设计尺度下的最高地面命中球半径。 */ +const HIT_MARKER_RADIUS = 2.5; +/** 设计尺度下的脚底采样原点球半径。 */ +const SAMPLE_ORIGIN_RADIUS = 3; +/** 设计尺度下的脚底采样命中球半径。 */ +const SAMPLE_HIT_RADIUS = 2.5; +/** 支撑脚 IK 的目标球颜色。 */ +const DEBUG_IK_PLANTED_COLOR = 0x22c55e; +/** 摆动脚防穿透 IK 的目标球颜色。 */ +const DEBUG_IK_PENETRATION_COLOR = 0x3b82f6; +/** 未应用 IK 时的目标球颜色。 */ +const DEBUG_IK_NOT_APPLIED_COLOR = 0xef4444; +/** 脚底采样球 / 连线(左右腿共用)。 */ +const DEBUG_SOLID_COLOR = 0x2dd4bf; +/** 线框命中球(左右腿共用)。 */ +const DEBUG_HIT_COLOR = 0xffff66; +/** 命中超出脚部 IK 最大上抬范围时的线框球颜色。 */ +const DEBUG_HIT_OVER_RAISE_LIMIT_COLOR = 0xff3333; +/** 脚部 IK 最大上抬范围线框颜色。 */ +const DEBUG_RAISE_LIMIT_COLOR = 0x60a5fa; +/** 命中超出最大上抬范围时的线框颜色。 */ +const DEBUG_RAISE_LIMIT_EXCEEDED_COLOR = 0xff3333; +/** 预测目标仍在持续跟踪时的颜色。 */ +const DEBUG_PREDICTIVE_TRACKING_COLOR = 0x38bdf8; +/** 预测结果正在参与脚部融合时的颜色。 */ +const DEBUG_PREDICTIVE_ACTIVE_COLOR = 0xf59e0b; +/** 可用但未被选中的预测候选颜色。 */ +const DEBUG_PREDICTIVE_VALID_COLOR = 0xfde047; +/** 无法提供完整鞋底支撑的预测候选颜色。 */ +const DEBUG_PREDICTIVE_INVALID_COLOR = 0xef4444; +/** 设计尺度下的预测候选球半径。 */ +const PREDICTIVE_CANDIDATE_RADIUS = 2; +/** 每条腿的上抬范围由四条竖线和顶部四边组成。 */ +const RAISE_LIMIT_POINT_COUNT = 16; +const SOLE_PERIMETER = [0, 1, 3, 2] as const; +const PREDICTIVE_TRAJECTORY_POINT_COUNT = 13; + +/** 创建可每帧原地改顶点的折线几何。 */ +function createDebugLineGeometry(pointCount: number): BufferGeometry { + const geometry = new BufferGeometry(); + geometry.setAttribute( + "position", + new BufferAttribute(new Float32Array(pointCount * 3), 3), + ); + return geometry; +} + +function writeDebugLinePositions( + geometry: BufferGeometry, + points: Array<{ x: number; y: number; z: number }>, + count = points.length, +): void { + const attribute = geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute) return; + const array = attribute.array as Float32Array; + const pointCount = Math.min(count, (array.length / 3) | 0); + for (let i = 0; i < pointCount; i++) { + const point = points[i]; + const offset = i * 3; + array[offset] = point.x; + array[offset + 1] = point.y; + array[offset + 2] = point.z; + } + attribute.needsUpdate = true; +} + +function writeDebugLinePair( + geometry: BufferGeometry, + from: { x: number; y: number; z: number }, + to: { x: number; y: number; z: number }, +): void { + const attribute = geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute || attribute.array.length < 6) return; + const array = attribute.array as Float32Array; + array[0] = from.x; + array[1] = from.y; + array[2] = from.z; + array[3] = to.x; + array[4] = to.y; + array[5] = to.z; + attribute.needsUpdate = true; +} + +/** 返回当前预测落脚状态对应的调试颜色。 */ +function getPredictiveModeColor(leg: FootIKLeg): number { + if ( + leg.predictive.mode === "active" + || leg.predictive.mode === "planted" + ) { + return DEBUG_PREDICTIVE_ACTIVE_COLOR; + } + return DEBUG_PREDICTIVE_TRACKING_COLOR; +} + +function setHitMarkerColor(mesh: Mesh, overRaiseLimit: boolean): void { + const material = mesh.material; + if (Array.isArray(material)) return; + (material as MeshBasicMaterial).color.setHex( + overRaiseLimit ? DEBUG_HIT_OVER_RAISE_LIMIT_COLOR : DEBUG_HIT_COLOR, + ); +} + +function setIKMarkerColor(mesh: Mesh, leg: FootIKLeg, applied: boolean): void { + const material = mesh.material; + if (Array.isArray(material)) return; + + let color = DEBUG_IK_NOT_APPLIED_COLOR; + if (leg.predictive.mode !== "none" && Number.isFinite(leg.predictive.score)) { + color = getPredictiveModeColor(leg); + } else if (applied) { + if (leg.planted) color = DEBUG_IK_PLANTED_COLOR; + else if (leg.movePenetrating) color = DEBUG_IK_PENETRATION_COLOR; + else color = DEBUG_IK_PLANTED_COLOR; + } + (material as MeshBasicMaterial).color.setHex(color); +} + +// 创建统一 Foot IK 调试对象:IK 目标、最高命中、脚底四点(原点 + 命中 + 射线)。 +export function createDebugObjects( + scene: Scene | null, + legs: FootIKLegs, + enabled: boolean, +): void { + if (!enabled || !scene) return; + + let ikGeometry: SphereGeometry | null = null; + let hitGeometry: SphereGeometry | null = null; + let sampleOriginGeometry: SphereGeometry | null = null; + let sampleHitGeometry: SphereGeometry | null = null; + let predictiveCandidateGeometry: SphereGeometry | null = null; + + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + + if (!leg.marker) { + ikGeometry ??= new SphereGeometry(IK_MARKER_RADIUS, 10, 8); + leg.marker = new Mesh( + ikGeometry, + new MeshBasicMaterial({ + color: DEBUG_IK_NOT_APPLIED_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.marker.renderOrder = 30; + leg.marker.frustumCulled = false; + scene.add(leg.marker); + } + + if (!leg.hitMarker) { + hitGeometry ??= new SphereGeometry(HIT_MARKER_RADIUS, 10, 8); + leg.hitMarker = new Mesh( + hitGeometry, + new MeshBasicMaterial({ + color: DEBUG_HIT_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ); + leg.hitMarker.renderOrder = 29; + leg.hitMarker.frustumCulled = false; + scene.add(leg.hitMarker); + } + + if (!leg.rayLine) { + leg.rayLine = new Line( + createDebugLineGeometry(2), + new LineBasicMaterial({ + color: DEBUG_SOLID_COLOR, + depthTest: false, + transparent: true, + opacity: 0.85, + }), + ); + leg.rayLine.renderOrder = 28; + leg.rayLine.frustumCulled = false; + scene.add(leg.rayLine); + } + + if (!leg.raiseLimitLine) { + leg.raiseLimitLine = new LineSegments( + createDebugLineGeometry(RAISE_LIMIT_POINT_COUNT), + new LineBasicMaterial({ + color: DEBUG_RAISE_LIMIT_COLOR, + depthTest: false, + transparent: true, + opacity: 0.75, + }), + ); + leg.raiseLimitLine.renderOrder = 24; + leg.raiseLimitLine.frustumCulled = false; + scene.add(leg.raiseLimitLine); + } + + if (!leg.predictiveLine) { + leg.predictiveLine = new Line( + createDebugLineGeometry(PREDICTIVE_TRAJECTORY_POINT_COUNT), + new LineBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.predictiveLine.visible = false; + leg.predictiveLine.renderOrder = 32; + leg.predictiveLine.frustumCulled = false; + scene.add(leg.predictiveLine); + } + + if (leg.predictiveCandidateMarkers.length === 0) { + predictiveCandidateGeometry ??= new SphereGeometry(PREDICTIVE_CANDIDATE_RADIUS, 10, 8); + for (const _candidate of leg.predictive.debugCandidates) { + const marker = new Mesh( + predictiveCandidateGeometry, + new MeshBasicMaterial({ + color: DEBUG_PREDICTIVE_INVALID_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ); + marker.visible = false; + marker.renderOrder = 31; + marker.frustumCulled = false; + leg.predictiveCandidateMarkers.push(marker); + scene.add(marker); + } + } + + if (!leg.predictiveSupportMesh) { + const supportGeometry = new BufferGeometry(); + supportGeometry.setAttribute("position", new BufferAttribute(new Float32Array(12), 3)); + supportGeometry.setIndex([0, 1, 3, 0, 3, 2]); + leg.predictiveSupportMesh = new Mesh( + supportGeometry, + new MeshBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.28, + side: DoubleSide, + }), + ); + leg.predictiveSupportMesh.visible = false; + leg.predictiveSupportMesh.renderOrder = 22; + leg.predictiveSupportMesh.frustumCulled = false; + scene.add(leg.predictiveSupportMesh); + } + + if (!leg.predictiveSupportOutline) { + leg.predictiveSupportOutline = new LineLoop( + createDebugLineGeometry(4), + new LineBasicMaterial({ + color: DEBUG_PREDICTIVE_TRACKING_COLOR, + depthTest: false, + transparent: true, + opacity: 0.95, + }), + ); + leg.predictiveSupportOutline.visible = false; + leg.predictiveSupportOutline.renderOrder = 23; + leg.predictiveSupportOutline.frustumCulled = false; + scene.add(leg.predictiveSupportOutline); + } + + for (const sample of leg.soleSamples) { + // footMarker:脚底采样原点(跟骨骼) + if (!sample.footMarker) { + sampleOriginGeometry ??= new SphereGeometry(SAMPLE_ORIGIN_RADIUS, 10, 8); + sample.footMarker = new Mesh( + sampleOriginGeometry, + new MeshBasicMaterial({ + color: DEBUG_SOLID_COLOR, + depthTest: false, + transparent: true, + opacity: 0.85, + }), + ); + sample.footMarker.renderOrder = 27; + sample.footMarker.frustumCulled = false; + scene.add(sample.footMarker); + } + // marker:该采样点的地面命中 + if (!sample.marker) { + sampleHitGeometry ??= new SphereGeometry(SAMPLE_HIT_RADIUS, 10, 8); + sample.marker = new Mesh( + sampleHitGeometry, + new MeshBasicMaterial({ + color: DEBUG_HIT_COLOR, + wireframe: true, + depthTest: false, + transparent: true, + opacity: 0.8, + }), + ); + sample.marker.renderOrder = 26; + sample.marker.frustumCulled = false; + scene.add(sample.marker); + } + if (!sample.rayLine) { + sample.rayLine = new Line( + createDebugLineGeometry(2), + new LineBasicMaterial({ + color: DEBUG_SOLID_COLOR, + transparent: true, + opacity: 0.45, + depthTest: false, + }), + ); + sample.rayLine.renderOrder = 25; + sample.rayLine.frustumCulled = false; + scene.add(sample.rayLine); + } + } + } +} + +// 更新统一调试显示。 +export function updateFootDebug( + enabled: boolean, + leg: FootIKLeg, + hitPoint: Vector3, + scale = 1, + maxFootRaise = 0, +): void { + if (!enabled || !leg.marker || !leg.rayLine) return; + + const s = Math.max(1e-4, scale); + const bestSample = leg.soleSamples[leg.bestGroundSampleIndex]; + const bestHitOverRaiseLimit = !!bestSample?.hasHit + && hitPoint.y - bestSample.point.y > maxFootRaise; + const ikApplied = leg.weight > 0.001; + + // 腿级:IK 目标 + 最高命中 + 命中→目标 + leg.marker.visible = true; + leg.marker.position.copy(leg.smoothedTarget); + leg.marker.scale.setScalar(s); + setIKMarkerColor(leg.marker, leg, ikApplied); + + if (leg.hitMarker) { + leg.hitMarker.visible = true; + leg.hitMarker.position.copy(hitPoint); + leg.hitMarker.scale.setScalar(s); + setHitMarkerColor(leg.hitMarker, bestHitOverRaiseLimit); + } + + // 只有实际应用 IK 时才显示“最高地面命中 → IK 目标”连线。 + leg.rayLine.visible = ikApplied; + if (ikApplied) { + writeDebugLinePair(leg.rayLine.geometry, hitPoint, leg.smoothedTarget); + } + + updatePredictiveDebug(leg, s); + + // 预测摆腿只保留轨迹和落点面;脚底四点与抬高笼子仍表示反应式采样。 + if (!showReactiveSoleDebug(leg)) { + setReactiveSoleDebugVisible(leg, false); + return; + } + + updateRaiseLimitLine(leg, maxFootRaise); + + // 脚底四点:原点 + 命中 + 射线 + for (const sample of leg.soleSamples) { + if (sample.footMarker) { + sample.footMarker.visible = true; + sample.footMarker.position.copy(sample.point); + sample.footMarker.scale.setScalar(s); + } + if (sample.marker) { + sample.marker.visible = sample.hasHit; + if (sample.hasHit) { + sample.marker.position.copy(sample.hitPoint); + sample.marker.scale.setScalar(s); + setHitMarkerColor( + sample.marker, + sample.hitPoint.y - sample.point.y > maxFootRaise, + ); + } + } + if (sample.rayLine) { + sample.rayLine.visible = sample.hasHit; + if (sample.hasHit) { + writeDebugLinePair(sample.rayLine.geometry, sample.point, sample.hitPoint); + } + } + } +} + +/** 预测正在驱动摆腿时,反应式脚底采样调试不再跟当前脚骨。 */ +function showReactiveSoleDebug(leg: FootIKLeg): boolean { + return leg.predictive.mode !== "active"; +} + +function setReactiveSoleDebugVisible(leg: FootIKLeg, visible: boolean): void { + if (leg.raiseLimitLine) leg.raiseLimitLine.visible = visible; + for (const sample of leg.soleSamples) { + if (sample.footMarker) sample.footMarker.visible = visible; + if (sample.marker) sample.marker.visible = visible && sample.hasHit; + if (sample.rayLine) sample.rayLine.visible = visible && sample.hasHit; + } +} + +/** 更新预测摆动轨迹、动画中心候选点及其状态配色。 */ +function updatePredictiveDebug(leg: FootIKLeg, scale: number): void { + const state = leg.predictive; + // tracking 只显示中心候选;active 才画摆动线路和落点四边形配色。 + const showTrajectory = state.mode === "active"; + const showCandidates = state.mode === "tracking"; + + if (leg.predictiveLine) { + leg.predictiveLine.visible = showTrajectory && state.debugTrajectoryVisible; + if (leg.predictiveLine.visible) { + writeDebugLinePositions( + leg.predictiveLine.geometry, + state.debugTrajectory, + ); + const material = leg.predictiveLine.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex(getPredictiveModeColor(leg)); + } + } + } + + for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) { + const marker = leg.predictiveCandidateMarkers[i]; + const candidate = state.debugCandidates[i]; + marker.visible = showCandidates && !!candidate?.evaluated; + if (!marker.visible || !candidate) continue; + + marker.position.copy(candidate.point); + marker.scale.setScalar(scale * (candidate.selected ? 1.25 : 0.8)); + const material = marker.material; + if (Array.isArray(material)) continue; + const debugMaterial = material as MeshBasicMaterial; + debugMaterial.wireframe = !candidate.selected; + debugMaterial.color.setHex( + candidate.selected + ? candidate.valid + ? getPredictiveModeColor(leg) + : DEBUG_PREDICTIVE_INVALID_COLOR + : candidate.valid + ? DEBUG_PREDICTIVE_VALID_COLOR + : DEBUG_PREDICTIVE_INVALID_COLOR, + ); + } + + const landing = state.debugCandidates[0]; + const planeColor = landing?.valid + ? getPredictiveModeColor(leg) + : DEBUG_PREDICTIVE_INVALID_COLOR; + updatePredictiveSupportPlane(leg, planeColor); +} + +/** 用鞋底四点在合并支撑面上的投影绘制半透明四边形。 */ +function updatePredictiveSupportPlane(leg: FootIKLeg, color: number): void { + const state = leg.predictive; + const visible = state.debugSupportVisible + && (state.mode === "tracking" || state.mode === "active") + && state.debugSupportCorners.length >= 4; + if (leg.predictiveSupportMesh) { + leg.predictiveSupportMesh.visible = visible; + if (visible) { + const positions = leg.predictiveSupportMesh.geometry.getAttribute("position"); + for (let i = 0; i < 4; i++) { + const corner = state.debugSupportCorners[i]; + positions.setXYZ(i, corner.x, corner.y, corner.z); + } + positions.needsUpdate = true; + const material = leg.predictiveSupportMesh.material; + if (!Array.isArray(material)) { + (material as MeshBasicMaterial).color.setHex(color); + } + } + } + if (leg.predictiveSupportOutline) { + leg.predictiveSupportOutline.visible = visible; + if (visible) { + const attribute = leg.predictiveSupportOutline.geometry.getAttribute("position") as BufferAttribute; + const array = attribute.array as Float32Array; + for (let i = 0; i < SOLE_PERIMETER.length; i++) { + const corner = state.debugSupportCorners[SOLE_PERIMETER[i]]; + const offset = i * 3; + array[offset] = corner.x; + array[offset + 1] = corner.y; + array[offset + 2] = corner.z; + } + attribute.needsUpdate = true; + const material = leg.predictiveSupportOutline.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex(color); + } + } + } +} + +// 统一切换所有 Foot IK 调试对象的显隐。 +export function setDebugVisible(legs: FootIKLegs, visible: boolean): void { + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + if (leg.marker) leg.marker.visible = visible; + if (leg.hitMarker) leg.hitMarker.visible = visible; + if (leg.rayLine) leg.rayLine.visible = visible && leg.weight > 0.001; + if (leg.predictiveLine) { + leg.predictiveLine.visible = visible + && leg.predictive.debugTrajectoryVisible + && leg.predictive.mode === "active"; + } + for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) { + const candidate = leg.predictive.debugCandidates[i]; + leg.predictiveCandidateMarkers[i].visible = visible + && !!candidate?.evaluated + && leg.predictive.mode === "tracking"; + } + if (leg.predictiveSupportMesh) { + leg.predictiveSupportMesh.visible = visible + && leg.predictive.debugSupportVisible + && ( + leg.predictive.mode === "tracking" + || leg.predictive.mode === "active" + ); + } + if (leg.predictiveSupportOutline) { + leg.predictiveSupportOutline.visible = visible + && leg.predictive.debugSupportVisible + && ( + leg.predictive.mode === "tracking" + || leg.predictive.mode === "active" + ); + } + setReactiveSoleDebugVisible(leg, visible && showReactiveSoleDebug(leg)); + } +} + +// 返回当前动画脚步相位的简短调试文本。 +export function getFootPhaseDebugText( + state: FootPhaseControllerState, + clips: FootPhaseDatabase, + side: FootIKSide, +): string { + const sideState = state[side]; + const clipName = state.clipName || "none"; + const dataReady = clips.has(clipName); + if (!dataReady) return `${clipName}: no phase`; + + const phase = sideState.planted ? "plant" : "swing"; + const progress = Number.isFinite(sideState.progress) + ? `${Math.round(sideState.progress * 100)}%` + : "--"; + return `${clipName}: ${phase} ${progress}`; +} + +// 移除调试对象并释放对应的 GPU 资源。 +export function disposeDebugObjects(legs: FootIKLegs): void { + const geometries = new Set(); + const materials = new Set(); + + for (const side of FOOT_IK_SIDES) { + const leg = legs[side]; + collectDebugObject(leg.marker, geometries, materials); + collectDebugObject(leg.hitMarker, geometries, materials); + collectDebugObject(leg.rayLine, geometries, materials); + collectDebugObject(leg.raiseLimitLine, geometries, materials); + collectDebugObject(leg.predictiveLine, geometries, materials); + collectDebugObject(leg.predictiveSupportMesh, geometries, materials); + collectDebugObject(leg.predictiveSupportOutline, geometries, materials); + for (const marker of leg.predictiveCandidateMarkers) { + collectDebugObject(marker, geometries, materials); + } + leg.marker = null; + leg.hitMarker = null; + leg.rayLine = null; + leg.raiseLimitLine = null; + leg.predictiveLine = null; + leg.predictiveSupportMesh = null; + leg.predictiveSupportOutline = null; + leg.predictiveCandidateMarkers.length = 0; + + for (const sample of leg.soleSamples) { + collectDebugObject(sample.marker, geometries, materials); + collectDebugObject(sample.rayLine, geometries, materials); + collectDebugObject(sample.footMarker, geometries, materials); + sample.marker = null; + sample.rayLine = null; + sample.footMarker = null; + } + } + + for (const geometry of geometries) geometry.dispose(); + for (const material of materials) material.dispose(); +} + +// 以当前动画脚底四点为起点,画出每个采样点可上抬到的最高边界。 +function updateRaiseLimitLine(leg: FootIKLeg, maxFootRaise: number): void { + if (!leg.raiseLimitLine || leg.soleSamples.length < 4) return; + + const attribute = leg.raiseLimitLine.geometry.getAttribute("position") as BufferAttribute | undefined; + if (!attribute || attribute.array.length < RAISE_LIMIT_POINT_COUNT * 3) return; + const array = attribute.array as Float32Array; + let cursor = 0; + let exceeded = false; + const writePoint = (x: number, y: number, z: number): void => { + array[cursor++] = x; + array[cursor++] = y; + array[cursor++] = z; + }; + + for (const sample of leg.soleSamples) { + writePoint(sample.point.x, sample.point.y, sample.point.z); + writePoint(sample.point.x, sample.point.y + maxFootRaise, sample.point.z); + exceeded ||= sample.hasHit && sample.hitPoint.y - sample.point.y > maxFootRaise; + } + + for (let i = 0; i < SOLE_PERIMETER.length; i++) { + const from = leg.soleSamples[SOLE_PERIMETER[i]].point; + const to = leg.soleSamples[SOLE_PERIMETER[(i + 1) % SOLE_PERIMETER.length]].point; + writePoint(from.x, from.y + maxFootRaise, from.z); + writePoint(to.x, to.y + maxFootRaise, to.z); + } + + attribute.needsUpdate = true; + leg.raiseLimitLine.visible = true; + const material = leg.raiseLimitLine.material; + if (!Array.isArray(material)) { + (material as LineBasicMaterial).color.setHex( + exceeded ? DEBUG_RAISE_LIMIT_EXCEEDED_COLOR : DEBUG_RAISE_LIMIT_COLOR, + ); + } +} + +function collectDebugObject( + object: Mesh | Line | null, + geometries: Set, + materials: Set, +): void { + if (!object) return; + object.parent?.remove(object); + geometries.add(object.geometry); + const objectMaterials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of objectMaterials) materials.add(material); +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts b/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts new file mode 100644 index 0000000..7571e2f --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/footPhase.ts @@ -0,0 +1,414 @@ +import { + AnimationMixer, + MathUtils, + Quaternion, + Vector3, + type AnimationClip, + type Object3D, +} from "three"; +import { collectBones, isReadyLeg } from "./skeleton"; +import type { + FootIKLegs, + FootIKOptions, + FootIKSide, + FootPhaseClipData, + FootPhaseContactRun, + FootPhaseDatabase, + FootPhaseFrameSample, + FootPhaseLanding, + FootPhaseOptions, + FootPhasePoseSample, + FootPhaseRuntime, + FootPhaseRuntimeState, + FootPhaseSamplePoint, + FootPhaseSideData, + ReadyFootIKLeg, +} from "../types"; + +const tmpFoot = new Vector3(); +const tmpToe = new Vector3(); +const tmpFootRotation = new Quaternion(); +const tmpModelRotation = new Quaternion(); + +// 创建单脚相位的运行时默认状态。 +export function createFootPhaseRuntimeState(): FootPhaseRuntimeState { + return { + planted: false, + progress: 0, + timeToLand: Infinity, + nextLanding: null, + }; +} + +// 从控制器配置中提取脚步相位采样参数。 +export function createFootPhaseOptions(options: FootIKOptions = {}): FootPhaseOptions { + return { + sampleCount: Math.max(16, Math.round(options.footPhaseSampleCount ?? 96)), + groundThreshold: Math.max(0, options.footPhaseGroundThreshold ?? 5), + minContactRatio: MathUtils.clamp(options.footPhaseMinContactRatio ?? 0.04, 0, 1), + speedSlack: Math.max(0, options.footPhaseSpeedSlack ?? 1.35), + }; +} + +// 离线采样循环移动动画,生成左右脚落地和抬脚相位。 +export function buildFootPhaseDatabase( + model: Object3D | null | undefined, + clips: readonly AnimationClip[], + legs: FootIKLegs, + options: FootPhaseOptions, + isLocomotionClip: (clipName: string) => boolean, +): FootPhaseDatabase { + const phaseClips: FootPhaseDatabase = new Map(); + if (!model || !clips.length || !isReadyLeg(legs.left) || !isReadyLeg(legs.right)) return phaseClips; + + const bones = collectBones(model); + const savedPoses = bones.map(bone => ({ + bone, + position: bone.position.clone(), + quaternion: bone.quaternion.clone(), + scale: bone.scale.clone(), + })); + const savedModelMatrixNeedsUpdate = model.matrixWorldNeedsUpdate; + const mixer = new AnimationMixer(model); + + try { + for (const clip of clips) { + if (!shouldAnalyzeFootPhaseClip(clip, isLocomotionClip)) continue; + phaseClips.set(clip.name, sampleFootPhaseClip(model, mixer, clip, legs, options)); + } + } finally { + mixer.stopAllAction(); + mixer.uncacheRoot(model); + for (const pose of savedPoses) { + pose.bone.position.copy(pose.position); + pose.bone.quaternion.copy(pose.quaternion); + pose.bone.scale.copy(pose.scale); + } + model.matrixWorldNeedsUpdate = savedModelMatrixNeedsUpdate; + model.updateMatrixWorld(true); + } + + return phaseClips; +} + +// 根据当前动画时间采样左右脚运行时相位。 +export function sampleFootPhaseRuntime( + phaseClip: FootPhaseClipData | null | undefined, + normalizedTime: number, + duration: number, +): FootPhaseRuntime { + if (!phaseClip) { + return { + left: createFootPhaseRuntimeState(), + right: createFootPhaseRuntimeState(), + }; + } + + return { + left: sampleFootPhaseRuntimeSide(phaseClip.left, normalizedTime, duration), + right: sampleFootPhaseRuntimeSide(phaseClip.right, normalizedTime, duration), + }; +} + +// 只分析 playerModelConfig 中配置为移动状态的循环动画。 +function shouldAnalyzeFootPhaseClip( + clip: AnimationClip, + isLocomotionClip: (clipName: string) => boolean, +): boolean { + return clip.duration > 0 && isLocomotionClip(clip.name); +} + +// 对单个动画片段离线采样,生成左右脚相位数据。 +function sampleFootPhaseClip( + model: Object3D, + mixer: AnimationMixer, + clip: AnimationClip, + legs: FootIKLegs, + options: FootPhaseOptions, +): FootPhaseClipData { + if (!isReadyLeg(legs.left) || !isReadyLeg(legs.right)) { + throw new Error("[FootIK] 脚步相位采样时腿部骨骼不完整。"); + } + + const action = mixer.clipAction(clip); + action.reset(); + action.setEffectiveWeight(1); + action.play(); + + const samples: FootPhaseFrameSample[] = []; + for (let i = 0; i < options.sampleCount; i++) { + const normalizedTime = i / options.sampleCount; + mixer.setTime(normalizedTime * clip.duration); + model.updateMatrixWorld(true); + samples.push({ + time: normalizedTime, + left: getFootPhaseSample(model, legs.left), + right: getFootPhaseSample(model, legs.right), + }); + } + + action.stop(); + + return { + name: clip.name, + duration: clip.duration, + left: analyzeFootPhaseSide(samples, "left", options), + right: analyzeFootPhaseSide(samples, "right", options), + }; +} + +// 采样单只脚在动画中的高度、水平位置和相对模型旋转。 +function getFootPhaseSample(model: Object3D, leg: ReadyFootIKLeg): FootPhaseSamplePoint { + const foot = leg.foot.getWorldPosition(tmpFoot); + const toeY = leg.toe ? leg.toe.getWorldPosition(tmpToe).y : foot.y; + const contactY = Math.min(foot.y, toeY); + const footWorldRotation = leg.foot.getWorldQuaternion(tmpFootRotation); + const localRotation = model + .getWorldQuaternion(tmpModelRotation) + .invert() + .multiply(footWorldRotation) + .clone(); + model.worldToLocal(foot); + return { + y: contactY, + x: foot.x, + localY: foot.y, + z: foot.z, + localRotation, + }; +} + +// 按循环相位插值离线动画中的脚骨局部位姿。 +export function sampleFootPhasePose( + sideData: FootPhaseSideData, + normalizedTime: number, + position: Vector3, + rotation: Quaternion, +): boolean { + const poses = sideData.poses; + if (poses.length === 0) return false; + + const wrappedTime = ((normalizedTime % 1) + 1) % 1; + const sample = wrappedTime * poses.length; + const fromIndex = Math.floor(sample) % poses.length; + const toIndex = (fromIndex + 1) % poses.length; + const alpha = sample - Math.floor(sample); + position.lerpVectors( + poses[fromIndex].localPosition, + poses[toIndex].localPosition, + alpha, + ); + rotation + .copy(poses[fromIndex].localRotation) + .slerp(poses[toIndex].localRotation, alpha); + return true; +} + +// 分析单只脚的落地相位。 +function analyzeFootPhaseSide( + samples: readonly FootPhaseFrameSample[], + side: FootIKSide, + options: FootPhaseOptions, +): FootPhaseSideData { + let minY = Infinity; + for (const sample of samples) minY = Math.min(minY, sample[side].y); + + const speeds = calculateFootPhaseSpeeds(samples, side); + const heightContacts = samples.map(sample => sample[side].y <= minY + options.groundThreshold); + const speedContacts = filterFootPhaseBySpeed(heightContacts, speeds, options.speedSlack); + const contacts = keepMainFootContactRun(filterShortFootContacts(speedContacts, options.minContactRatio)); + const land: number[] = []; + const lift: number[] = []; + const landings: FootPhaseLanding[] = []; + const poses: FootPhasePoseSample[] = samples.map(sample => { + const point = sample[side]; + return { + localPosition: new Vector3(point.x, point.localY, point.z), + localRotation: point.localRotation.clone(), + }; + }); + + for (let i = 0; i < contacts.length; i++) { + const prev = contacts[(i - 1 + contacts.length) % contacts.length]; + const current = contacts[i]; + if (!prev && current) { + land.push(samples[i].time); + const point = samples[i][side]; + landings.push({ + phase: samples[i].time, + localPosition: new Vector3(point.x, point.localY, point.z), + localRotation: point.localRotation.clone(), + }); + } + if (prev && !current) lift.push(samples[i].time); + } + + return { + contacts, + land, + lift, + landings, + poses, + }; +} + +// 计算脚在动画相位中的水平速度。 +function calculateFootPhaseSpeeds( + samples: readonly FootPhaseFrameSample[], + side: FootIKSide, +): number[] { + const speeds: number[] = []; + for (let i = 0; i < samples.length; i++) { + const prev = samples[(i - 1 + samples.length) % samples.length][side]; + const next = samples[(i + 1) % samples.length][side]; + speeds.push(Math.hypot(next.x - prev.x, next.z - prev.z)); + } + return speeds; +} + +// 在低高度候选点中继续过滤水平速度。 +function filterFootPhaseBySpeed( + contacts: readonly boolean[], + speeds: readonly number[], + speedSlack: number, +): boolean[] { + const contactSpeeds = speeds + .filter((_speed, index) => contacts[index]) + .sort((a, b) => a - b); + if (contactSpeeds.length === 0) return contacts.slice(); + + const median = contactSpeeds[Math.floor(contactSpeeds.length * 0.5)]; + const p75 = contactSpeeds[Math.floor(contactSpeeds.length * 0.75)]; + const threshold = Math.max(median, p75) * speedSlack; + return contacts.map((contact, index) => contact && speeds[index] <= threshold); +} + +// 去掉过短的接触段,避免脚尖高度抖动产生零碎支撑相位。 +function filterShortFootContacts( + contacts: readonly boolean[], + minContactRatio: number, +): boolean[] { + const minLength = Math.max(1, Math.round(contacts.length * minContactRatio)); + const filtered = contacts.slice(); + + for (const run of getCircularContactRuns(contacts, true)) { + if (run.indices.length >= minLength) continue; + for (const index of run.indices) filtered[index] = false; + } + + return filtered; +} + +// 每只脚只保留最长的主要支撑段。 +function keepMainFootContactRun(contacts: readonly boolean[]): boolean[] { + const runs = getCircularContactRuns(contacts, true); + if (runs.length <= 1) return contacts.slice(); + + const mainRun = runs.sort((a, b) => b.indices.length - a.indices.length)[0]; + const filtered = new Array(contacts.length).fill(false); + for (const index of mainRun.indices) filtered[index] = true; + return filtered; +} + +// 提取循环数组中连续相同值的片段。 +function getCircularContactRuns( + values: readonly boolean[], + targetValue: boolean, +): FootPhaseContactRun[] { + const runs: FootPhaseContactRun[] = []; + if (values.length === 0) return runs; + + const visited = new Array(values.length).fill(false); + const start = values.findIndex((value, index) => { + const prev = values[(index - 1 + values.length) % values.length]; + return value === targetValue && prev !== targetValue; + }); + if (start < 0) { + return values[0] === targetValue + ? [{ indices: values.map((_value, index) => index) }] + : runs; + } + + for (let offset = 0; offset < values.length; offset++) { + const index = (start + offset) % values.length; + if (visited[index] || values[index] !== targetValue) continue; + + const indices: number[] = []; + let cursor = index; + while (!visited[cursor] && values[cursor] === targetValue) { + visited[cursor] = true; + indices.push(cursor); + cursor = (cursor + 1) % values.length; + if (cursor === index) break; + } + runs.push({ indices }); + } + + return runs; +} + +// 计算单脚是否支撑、摆腿进度和下一次落地时间。 +function sampleFootPhaseRuntimeSide( + sideData: FootPhaseSideData, + normalizedTime: number, + duration: number, +): FootPhaseRuntimeState { + const sampleCount = sideData.contacts.length; + if (sampleCount === 0) return createFootPhaseRuntimeState(); + + const sampleIndex = Math.floor(normalizedTime * sampleCount) % sampleCount; + const planted = !!sideData.contacts[sampleIndex]; + const nextLanding = nextLandingEvent(sideData.landings, normalizedTime); + const nextLand = nextLanding?.phase ?? null; + const prevLift = prevPhaseEvent(sideData.lift, normalizedTime); + const swingSpan = wrapPhaseDistance(prevLift, nextLand) || 1; + const swingDone = wrapPhaseDistance(prevLift, normalizedTime); + + return { + planted, + progress: planted ? 1 : MathUtils.clamp(swingDone / swingSpan, 0, 1), + timeToLand: nextLand === null + ? Infinity + : wrapPhaseDistance(normalizedTime, nextLand) * duration, + nextLanding, + }; +} + +/** 查找循环动画时间轴上的下一次落脚事件。 */ +function nextLandingEvent( + events: readonly FootPhaseLanding[], + normalizedTime: number, +): FootPhaseLanding | null { + if (!events.length) return null; + let best = events[0]; + let bestDistance = Infinity; + for (const event of events) { + const distance = wrapPhaseDistance(normalizedTime, event.phase); + if (distance < bestDistance) { + best = event; + bestDistance = distance; + } + } + return best; +} + +// 查找循环时间轴上的上一个事件。 +function prevPhaseEvent(events: readonly number[], normalizedTime: number): number { + if (!events.length) return normalizedTime; + let best = events[0]; + let bestDistance = Infinity; + for (const eventTime of events) { + const distance = wrapPhaseDistance(eventTime, normalizedTime); + if (distance < bestDistance) { + best = eventTime; + bestDistance = distance; + } + } + return best; +} + +// 计算归一化循环时间轴上的前向距离。 +function wrapPhaseDistance(from: number | null, to: number | null): number { + if (from === null || to === null) return 0; + return (to - from + 1) % 1; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts b/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts new file mode 100644 index 0000000..d0604b1 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/predictivePlacement.ts @@ -0,0 +1,615 @@ +import { Matrix3, Quaternion, Vector3, type Object3D } from "three"; +import type { + FootIKGroundHit, + FootIKSoleSample, + FootPhaseRuntimeState, + PredictiveFootState, + ReadyFootIKLeg, +} from "../types"; + +/** 最小踏步高度;低于该值不启用预测 IK。 */ +export const PREDICTION_DEMAND_ENTER = 8; +/** 已启用预测时,需求回落到该高度后退出。 */ +export const PREDICTION_DEMAND_EXIT = 4; + +/** 预测候选保留脚骨目标、实际支撑面和评分,用于生成本次摆动线路。 */ +export type PredictiveFootCandidate = { + /** 鞋底贴合支撑面后的脚骨目标。 */ + target: Vector3; + /** 四条鞋底射线共面合并后的地面接触。 */ + point: Vector3; + normal: Vector3; + object: Object3D | null; + score: number; + /** 0 为动画中心落点;非 0 表示相对动画做了水平偏移。 */ + debugIndex: number; +}; + +/** 预测落脚搜索、评估和轨迹规划共用的临时对象。 */ +export type PredictiveScratch = { + v1: Vector3; + v2: Vector3; + v4: Vector3; + v5: Vector3; + v6: Vector3; + v7: Vector3; + v8: Vector3; + v9: Vector3; + q1: Quaternion; + q2: Quaternion; + q3: Quaternion; + normalMatrix: Matrix3; + trajectoryStart: Vector3; + demandEnter: number; + demandExit: number; + candidates: PredictiveFootCandidate[]; + /** 仅确认可站立的中心支撑,尚未通过抬降和腿长约束。 */ + centerSupport: PredictiveFootCandidate; + plannedPoints: Vector3[]; + currentLocalPosition: Vector3; + currentLocalRotation: Quaternion; + /** 当前动画脚相对相位采样的偏差,规划起点与运行时脚对齐。 */ + animationBias: Vector3; + /** 预测脚骨目标相对动画预计落点的世界空间差。 */ + landingCorrection: Vector3; + /** 重规划时把线路起点钉在上一帧输出,避免轨迹跳变。 */ + continuityOffset: Vector3; + modelWorldRotation: Quaternion; + landingRotation: Quaternion; + worldScale: Vector3; + localPosition: Vector3; + localRotation: Quaternion; + pathPosition: Vector3; + pathRotation: Quaternion; + solePoint: Vector3; + soleOffset: Vector3; + supportHits: FootIKGroundHit[]; + supportHitList: FootIKGroundHit[]; + clearanceSamples: Array<{ progress: number; requiredLift: number }>; + clearanceSampleCount: number; +}; + +/** 候选搜索和落点评估所需的只读配置与回调。 */ +export type PredictivePlacementContext = { + up: Vector3; + appliedScale: number; + snapEpsilon: number; + sampleRayOriginY: number; + maxPredictionCorrection: number; + predictionMinNormalY: number; + maxFootRaise: number; + maxFootDrop: number; + pelvisKneeBend: number; + footAlignWeight: number; + predictionHorizon: number; + predictionVelocity: Vector3; + scratch: PredictiveScratch; + /** 关闭时跳过支撑面四边形和调试轨迹写入。 */ + debug: boolean; + castGroundFrom: (x: number, y: number, z: number) => FootIKGroundHit | null; + castCapsuleGround: () => FootIKGroundHit | null; + getConstrainedFootAlign: ( + normal: Vector3, + alignWeight: number, + target: Quaternion, + ) => Quaternion; + mergeCoplanarHits: ( + hits: Array, + highest: FootIKGroundHit, + supportPoint: Vector3, + supportNormal: Vector3, + ) => number; + getSafeLegReach: (upperLen: number, lowerLen: number, kneeBend: number) => number; +}; + +/** 创建预测落脚搜索和规划使用的临时对象。 */ +export function createPredictiveScratch(): PredictiveScratch { + return { + v1: new Vector3(), + v2: new Vector3(), + v4: new Vector3(), + v5: new Vector3(), + v6: new Vector3(), + v7: new Vector3(), + v8: new Vector3(), + v9: new Vector3(), + q1: new Quaternion(), + q2: new Quaternion(), + q3: new Quaternion(), + normalMatrix: new Matrix3(), + trajectoryStart: new Vector3(), + demandEnter: 0, + demandExit: 0, + candidates: Array.from({ length: 1 }, () => ({ + target: new Vector3(), + point: new Vector3(), + normal: new Vector3(), + object: null, + score: 0, + debugIndex: -1, + })), + centerSupport: { + target: new Vector3(), + point: new Vector3(), + normal: new Vector3(), + object: null, + score: 0, + debugIndex: 0, + }, + plannedPoints: Array.from({ length: 13 }, () => new Vector3()), + currentLocalPosition: new Vector3(), + currentLocalRotation: new Quaternion(), + animationBias: new Vector3(), + landingCorrection: new Vector3(), + continuityOffset: new Vector3(), + modelWorldRotation: new Quaternion(), + landingRotation: new Quaternion(), + worldScale: new Vector3(), + localPosition: new Vector3(), + localRotation: new Quaternion(), + pathPosition: new Vector3(), + pathRotation: new Quaternion(), + solePoint: new Vector3(), + soleOffset: new Vector3(), + supportHits: Array.from({ length: 4 }, () => ({ + point: new Vector3(), + normal: new Vector3(0, 1, 0), + })), + supportHitList: [], + clearanceSamples: Array.from({ length: 12 }, () => ({ + progress: 0, + requiredLift: 0, + })), + clearanceSampleCount: 0, + }; +} + +/** 创建单只脚的预测落脚运行时状态。 */ +export function createPredictiveFootState(): PredictiveFootState { + return { + mode: "none", + landingTarget: new Vector3(), + landingNormal: new Vector3(0, 1, 0), + supportPoint: new Vector3(), + animatedLanding: new Vector3(), + animatedLandingRotation: new Quaternion(), + trajectoryStartOffset: new Vector3(), + trajectoryCurrentTarget: new Vector3(), + trajectoryStartProgress: 0, + trajectoryProgress: 0, + trajectoryClearance: 0, + predictionWeight: 0, + usesOffsetLanding: false, + releaseOffset: new Vector3(), + releaseStartProgress: 0, + releaseActive: false, + supportObject: null, + supportLocalTarget: new Vector3(), + supportLocalPoint: new Vector3(), + supportLocalNormal: new Vector3(0, 1, 0), + probeElapsed: Infinity, + score: Infinity, + debugPlaneLift: 0, + debugSwingLift: 0, + debugTrajectory: Array.from({ length: 13 }, () => new Vector3()), + debugTrajectoryVisible: false, + debugSupportVisible: false, + debugSupportCorners: Array.from({ length: 4 }, () => new Vector3()), + debugCandidates: Array.from({ length: 1 }, () => ({ + point: new Vector3(), + evaluated: false, + valid: false, + selected: false, + })), + }; +} + +/** 清空预测目标并保留已分配的向量对象。 */ +export function resetPredictiveFootState(state: PredictiveFootState): void { + state.mode = "none"; + state.supportObject = null; + state.probeElapsed = Infinity; + state.score = Infinity; + state.debugPlaneLift = 0; + state.debugSwingLift = 0; + state.trajectoryStartOffset.set(0, 0, 0); + state.trajectoryCurrentTarget.set(0, 0, 0); + state.trajectoryStartProgress = 0; + state.trajectoryProgress = 0; + state.trajectoryClearance = 0; + state.predictionWeight = 0; + state.usesOffsetLanding = false; + state.releaseOffset.set(0, 0, 0); + state.releaseStartProgress = 0; + state.releaseActive = false; + state.debugTrajectoryVisible = false; + state.debugSupportVisible = false; + for (const candidate of state.debugCandidates) { + candidate.evaluated = false; + candidate.valid = false; + candidate.selected = false; + } +} + +/** 计算预测 IK 进入和退出的地形需求阈值。 */ +export function getPredictiveDemandThresholds( + appliedScale: number, + snapEpsilon: number, + scratch: PredictiveScratch, +): { enter: number; exit: number } { + // 退出阈值不高于进入阈值,避免需求在边界附近反复开关预测。 + scratch.demandEnter = Math.max( + snapEpsilon, + PREDICTION_DEMAND_ENTER * appliedScale, + ); + scratch.demandExit = Math.min( + scratch.demandEnter, + Math.max(snapEpsilon, PREDICTION_DEMAND_EXIT * appliedScale), + ); + return { enter: scratch.demandEnter, exit: scratch.demandExit }; +} + +/** 按终点权重把调试轨迹从起点平移到新的落点。 */ +export function shiftPredictiveDebugTrajectory( + points: Vector3[], + delta: Vector3, +): void { + if (delta.lengthSq() <= 1e-12 || points.length < 2) return; + for (let i = 0; i < points.length; i++) { + points[i].addScaledVector(delta, i / (points.length - 1)); + } +} + +// 把通过完整线路验证的候选同步为最终调试选中项。 +export function selectPredictiveDebugCandidate( + state: PredictiveFootState, + selectedIndex: number, +): void { + for (let i = 0; i < state.debugCandidates.length; i++) { + state.debugCandidates[i].selected = i === selectedIndex; + } +} + +/** 点高出支撑平面的距离;落在平面上或下方时为 0。 */ +export function getPointPlaneLift( + support: { point: Vector3; normal: Vector3 } | null, + point: Vector3, + fallbackY: number, + scratch: Vector3, +): number { + if (!support) return Math.max(0, point.y - fallbackY); + return Math.max(0, support.normal.dot(scratch.copy(point).sub(support.point))); +} + +/** 落点高出当前支撑平面的距离;落在平面上或下方时为 0。 */ +export function getPredictivePlaneDistance( + ctx: PredictivePlacementContext, + candidate: PredictiveFootCandidate, +): number { + return getPointPlaneLift( + ctx.castCapsuleGround(), + candidate.point, + candidate.point.y, + ctx.scratch.v5, + ); +} + +/** 预测需求取落点上台高度和摆腿途中高出支撑面的凸起。 */ +export function getPredictiveTerrainDemand( + planeLift: number, + swingLift: number, +): number { + return Math.max(planeLift, Math.max(0, swingLift)); +} + +/** 保存首次有效预测候选,并把目标转换为命中平台的局部锚点。 */ +export function acceptPredictiveFootCandidate( + state: PredictiveFootState, + candidate: PredictiveFootCandidate, + scratch: PredictiveScratch, +): void { + state.landingTarget.copy(candidate.target); + state.supportPoint.copy(candidate.point); + state.landingNormal.copy(candidate.normal); + state.score = candidate.score; + // 中心落点 debugIndex 为 0,落地后交还反应式 IK;偏移落点才进入 planted。 + state.usesOffsetLanding = candidate.debugIndex !== 0; + state.supportObject = candidate.object; + + if (state.supportObject) { + state.supportObject.updateMatrixWorld(true); + state.supportLocalTarget.copy(state.landingTarget); + state.supportObject.worldToLocal(state.supportLocalTarget); + state.supportLocalPoint.copy(state.supportPoint); + state.supportObject.worldToLocal(state.supportLocalPoint); + scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld).invert(); + state.supportLocalNormal + .copy(state.landingNormal) + .applyMatrix3(scratch.normalMatrix) + .normalize(); + } +} + +/** 根据已保存的平台局部锚点刷新预测目标、支撑点和世界法线。 */ +export function refreshPredictiveSupportAnchor( + leg: ReadyFootIKLeg, + scratch: PredictiveScratch, + debug = false, +): void { + const state = leg.predictive; + // 中心落点每帧跟随动画预计落点,不能用平台锚点覆盖水平位置。 + if ( + !state.supportObject + || (state.mode === "active" && !state.usesOffsetLanding) + ) { + return; + } + + const previousTarget = debug ? scratch.v1.copy(state.landingTarget) : null; + state.supportObject.updateMatrixWorld(true); + state.landingTarget.copy(state.supportLocalTarget); + state.supportObject.localToWorld(state.landingTarget); + state.supportPoint.copy(state.supportLocalPoint); + state.supportObject.localToWorld(state.supportPoint); + scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld); + state.landingNormal + .copy(state.supportLocalNormal) + .applyMatrix3(scratch.normalMatrix) + .normalize(); + + if (previousTarget) { + shiftPredictiveDebugTrajectory( + state.debugTrajectory, + scratch.v2.subVectors(state.landingTarget, previousTarget), + ); + } +} + +/** 把候选的落点和支撑面复制到另一份临时对象。 */ +function copyPredictiveFootCandidate( + source: PredictiveFootCandidate, + target: PredictiveFootCandidate, +): void { + target.target.copy(source.target); + target.point.copy(source.point); + target.normal.copy(source.normal); + target.object = source.object; + target.score = source.score; + target.debugIndex = source.debugIndex; +} + +/** 评估动画中心落点是否可站立,以及能否作为预测目标。 */ +export function findPredictiveFootCandidates( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState, + expected: Vector3, + expectedRotation: Quaternion, +): { + candidate: PredictiveFootCandidate | null; + centerSupport: PredictiveFootCandidate | null; +} { + const scratch = ctx.scratch; + // 用落地时刻的预计位移把髋部提前送到未来位置,再检查腿是否够得到落点。 + const futureRootDelta = scratch.v8 + .copy(ctx.predictionVelocity) + .multiplyScalar(Math.min(phase.timeToLand, ctx.predictionHorizon)); + + const debugCandidate = leg.predictive.debugCandidates[0]; + debugCandidate.evaluated = true; + debugCandidate.valid = false; + debugCandidate.selected = false; + debugCandidate.point.copy(expected); + const state = leg.predictive; + state.debugSupportVisible = false; + const probed = probePredictiveFootSupport( + ctx, + leg, + expected.x, + expected.z, + expected, + expectedRotation, + ); + if (!probed) { + return { candidate: null, centerSupport: null }; + } + + if (ctx.debug) { + fillDebugSupportCorners( + ctx, + leg, + expected.x, + expected.z, + expectedRotation, + probed.candidate, + ); + state.debugSupportVisible = true; + } + debugCandidate.point.copy(probed.candidate.point); + // 鞋底探到了地面但法线过陡或没有共面支撑时,不能当作预测目标。 + if (!probed.accepted) { + return { candidate: null, centerSupport: null }; + } + + copyPredictiveFootCandidate(probed.candidate, scratch.centerSupport); + const candidate = finalizePredictiveFootCandidate( + ctx, + leg, + probed.candidate, + probed.supportCount, + expected.x, + expected.z, + expected, + expectedRotation, + futureRootDelta, + ); + // 可站但超出抬降或腿长时仍返回支撑面,供调试四边形和需求显示使用。 + if (!candidate) { + return { candidate: null, centerSupport: scratch.centerSupport }; + } + + debugCandidate.valid = true; + debugCandidate.selected = true; + debugCandidate.point.copy(candidate.target); + return { candidate, centerSupport: scratch.centerSupport }; +} + +/** 把鞋底四点投影到合并后的落点支撑面上,供调试绘制。 */ +function fillDebugSupportCorners( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + centerX: number, + centerZ: number, + expectedRotation: Quaternion, + support: PredictiveFootCandidate, +): void { + const scratch = ctx.scratch; + const worldScale = leg.foot.getWorldScale(scratch.v9); + const corners = leg.predictive.debugSupportCorners; + // 略微沿法线抬起,避免四边形 z-fight 陷入地面。 + const lift = Math.max(ctx.snapEpsilon, ctx.appliedScale * 0.4); + for (let i = 0; i < corners.length; i++) { + const sample = leg.soleSamples[i]; + if (!sample) { + corners[i].copy(support.point).addScaledVector(support.normal, lift); + continue; + } + scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(expectedRotation); + const corner = corners[i]; + corner.set( + centerX + scratch.v4.x, + support.point.y, + centerZ + scratch.v4.z, + ); + corner.addScaledVector( + support.normal, + lift - support.normal.dot(scratch.v1.subVectors(corner, support.point)), + ); + } +} + +/** 只检查鞋底能否在该落点站稳,不考虑腿长和抬降限制。 */ +function probePredictiveFootSupport( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + centerX: number, + centerZ: number, + expected: Vector3, + expectedRotation: Quaternion, +): { candidate: PredictiveFootCandidate; supportCount: number; accepted: boolean } | null { + const scratch = ctx.scratch; + const footWorldQ = expectedRotation; + const worldScale = leg.foot.getWorldScale(scratch.v9); + const hits = scratch.supportHitList; + hits.length = 0; + let highestHit: FootIKGroundHit | null = null; + + // 只在动画中心 XZ 打鞋底四角;有一个共面命中即可作为支撑面。 + for (const sample of leg.soleSamples as FootIKSoleSample[]) { + const relative = scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(footWorldQ); + const hit = ctx.castGroundFrom( + centerX + relative.x, + expected.y + ctx.sampleRayOriginY, + centerZ + relative.z, + ); + if (!hit) continue; + const stored = scratch.supportHits[hits.length]; + stored.point.copy(hit.point); + stored.normal.copy(hit.normal); + stored.object = hit.object; + hits.push(stored); + if (!highestHit || stored.point.y > highestHit.point.y) highestHit = stored; + } + if (!highestHit) return null; + + const pooled = scratch.candidates[0]; + const supportCount = ctx.mergeCoplanarHits( + hits, + highestHit, + pooled.point, + pooled.normal, + ); + pooled.target.set(centerX, pooled.point.y, centerZ); + pooled.object = highestHit.object ?? null; + pooled.score = 0; + pooled.debugIndex = 0; + const accepted = supportCount >= 1 + && pooled.normal.y >= ctx.predictionMinNormalY; + return { candidate: pooled, supportCount, accepted }; +} + +/** 在可站立支撑之上再施加修正距离、抬降和腿长约束。 */ +function finalizePredictiveFootCandidate( + ctx: PredictivePlacementContext, + leg: ReadyFootIKLeg, + pooled: PredictiveFootCandidate, + supportCount: number, + centerX: number, + centerZ: number, + expected: Vector3, + expectedRotation: Quaternion, + futureRootDelta: Vector3, +): PredictiveFootCandidate | null { + const scratch = ctx.scratch; + const correctionSq = (centerX - expected.x) ** 2 + (centerZ - expected.z) ** 2; + const maxCorrectionSq = ctx.maxPredictionCorrection * ctx.maxPredictionCorrection; + if (correctionSq > maxCorrectionSq) return null; + + const worldScale = leg.foot.getWorldScale(scratch.v9); + const alignQ = ctx.getConstrainedFootAlign( + pooled.normal, + ctx.footAlignWeight, + scratch.q2, + ); + const targetWorldQ = scratch.q3.copy(alignQ).multiply(expectedRotation); + + // 按贴地后的脚掌旋转,把脚骨放到鞋底刚好坐在支撑平面上的高度。 + let targetY = -Infinity; + for (const sample of leg.soleSamples) { + const relative = scratch.v4 + .copy(sample.local) + .multiply(worldScale) + .applyQuaternion(targetWorldQ); + const sampleX = centerX + relative.x; + const sampleZ = centerZ + relative.z; + const planeY = pooled.point.y - ( + pooled.normal.x * (sampleX - pooled.point.x) + + pooled.normal.z * (sampleZ - pooled.point.z) + ) / pooled.normal.y; + targetY = Math.max(targetY, planeY - relative.y); + } + if (!Number.isFinite(targetY)) return null; + + pooled.target.set(centerX, targetY, centerZ); + const heightDelta = targetY - expected.y; + if (heightDelta > ctx.maxFootRaise || heightDelta < -ctx.maxFootDrop) return null; + + const hip = leg.upper.getWorldPosition(scratch.v5); + const knee = leg.lower.getWorldPosition(scratch.v6); + const foot = leg.foot.getWorldPosition(scratch.v7); + const upperLen = hip.distanceTo(knee); + const lowerLen = knee.distanceTo(foot); + const maxReach = ctx.getSafeLegReach(upperLen, lowerLen, ctx.pelvisKneeBend); + // 落地时髋会随角色根节点前移,用未来髋位判断当前落点是否仍在腿长内。 + hip.add(futureRootDelta); + const reachRatio = maxReach > 1e-6 ? hip.distanceTo(pooled.target) / maxReach : Infinity; + if (reachRatio > 1) return null; + + const correctionRatio = ctx.maxPredictionCorrection > 1e-6 + ? Math.sqrt(correctionSq) / ctx.maxPredictionCorrection + : 0; + const heightRange = Math.max(ctx.maxFootRaise, ctx.maxFootDrop, 1e-6); + // 分数仅用于调试显示;中心落点没有第二候选比较。 + pooled.score = correctionRatio + + (1 - pooled.normal.y) * 0.7 + + Math.abs(heightDelta) / heightRange * 0.4 + + (4 - supportCount) * 2 + + Math.max(0, reachRatio - 0.85) / 0.2 * 3; + return pooled; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts b/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts new file mode 100644 index 0000000..fe8aac8 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/predictiveTrajectory.ts @@ -0,0 +1,308 @@ +import { MathUtils, Quaternion, Vector3, type Object3D } from "three"; +import { sampleFootPhasePose } from "./footPhase"; +import { + PREDICTION_DEMAND_ENTER, + getPointPlaneLift, + type PredictiveFootCandidate, + type PredictivePlacementContext, +} from "./predictivePlacement"; +import type { + FootPhaseDatabase, + FootPhaseRuntimeState, + ReadyFootIKLeg, +} from "../types"; + +/** 完整线路验证使用的最小地形采样数。 */ +export const PREDICTION_MIN_TERRAIN_SAMPLE_COUNT = 4; +/** 完整线路验证使用的最大地形采样数。 */ +export const PREDICTION_MAX_TERRAIN_SAMPLE_COUNT = 12; +/** 相邻地形采样点的设计尺度间距。 */ +export const PREDICTION_TERRAIN_SAMPLE_SPACING = 10; +/** 预测启用后的剩余摆腿达到该比例时开始对齐支撑面。 */ +export const PREDICTION_ROTATION_WARP_START = 0.55; + +/** 单个地形采样点对摆动轨迹提出的上抬要求。 */ +export type PredictiveClearanceSample = { + progress: number; + requiredLift: number; +}; + +/** 已完成地形净空验证的预测摆动线路。 */ +export type PredictiveTrajectoryPlan = { + /** 整段摆腿需要追加的最大中段抬脚量。 */ + clearance: number; + /** 路径采样得到的最大上台高度,供进入/退出预测使用。 */ + terrainDemand: number; + points: Vector3[]; +}; + +/** 轨迹规划在候选评估配置之上额外需要的动画相位数据。 */ +export type PredictiveTrajectoryContext = PredictivePlacementContext & { + model: Object3D; + footPhaseClips: FootPhaseDatabase; + clipName: string; + normalizedTime: number; + swingClearance: number; + maxPredictionClearance: number; +}; + +// 返回两端为零、中段平滑抬高的净空权重。 +export function getPredictiveClearanceShape(progress: number): number { + const t = MathUtils.clamp(progress, 0, 1); + return 4 * t * (1 - t); +} + +/** 把启用后的剩余摆腿进度重新映射到 0 到 1。 */ +export function getPredictiveLocalProgress( + progress: number, + startProgress: number, +): number { + const remainingProgress = Math.max(1e-6, 1 - startProgress); + return MathUtils.clamp((progress - startProgress) / remainingProgress, 0, 1); +} + +// 根据整段地形采样求满足所有点的最小额外净空。 +export function solvePredictiveClearance( + samples: readonly PredictiveClearanceSample[], + margin: number, + maxClearance: number, + sampleCount = samples.length, +): number | null { + let requiredClearance = 0; + let obstructed = false; + const count = Math.min(sampleCount, samples.length); + + for (let i = 0; i < count; i++) { + const sample = samples[i]; + if (sample.requiredLift <= 0) continue; + const shape = getPredictiveClearanceShape(sample.progress); + // 净空曲线在两端为 0,若障碍落在两端则无法用中段抬脚躲开。 + if (shape <= 1e-6) return null; + obstructed = true; + requiredClearance = Math.max( + requiredClearance, + sample.requiredLift / shape, + ); + } + + if (!obstructed) return 0; + const clearance = requiredClearance + Math.max(0, margin); + return clearance <= Math.max(0, maxClearance) ? clearance : null; +} + +/** 沿剩余动画摆腿路径采样地形,并生成满足鞋底净空的预测线路。 */ +export function planPredictiveFootTrajectory( + ctx: PredictiveTrajectoryContext, + leg: ReadyFootIKLeg, + phase: FootPhaseRuntimeState, + footWorld: Vector3, + trajectoryStart: Vector3, + trajectoryStartProgress: number, + candidate: PredictiveFootCandidate, +): PredictiveTrajectoryPlan | null { + const phaseClip = ctx.footPhaseClips.get(ctx.clipName); + if (!phaseClip || phaseClip.duration <= 0) return null; + + const sideData = phaseClip[leg.side]; + const scratch = ctx.scratch; + if (!sampleFootPhasePose( + sideData, + ctx.normalizedTime, + scratch.currentLocalPosition, + scratch.currentLocalRotation, + )) { + return null; + } + + const currentSampleWorld = scratch.currentLocalPosition.applyMatrix4( + ctx.model.matrixWorld, + ); + scratch.animationBias.subVectors(footWorld, currentSampleWorld); + scratch.landingCorrection.subVectors( + candidate.target, + leg.predictive.animatedLanding, + ); + const currentTrajectoryProgress = getPredictiveLocalProgress( + leg.predictive.trajectoryProgress, + trajectoryStartProgress, + ); + const startWarpAlpha = MathUtils.smoothstep(currentTrajectoryProgress, 0, 1); + // 当前输出已经叠了部分落点修正,起点残差里要扣掉这部分,避免重规划把脚再推一次。 + scratch.continuityOffset + .subVectors(trajectoryStart, footWorld) + .addScaledVector(scratch.landingCorrection, -startWarpAlpha); + ctx.model.getWorldQuaternion(scratch.modelWorldRotation); + ctx.getConstrainedFootAlign( + candidate.normal, + ctx.footAlignWeight, + scratch.q1, + ); + scratch.landingRotation + .copy(scratch.q1) + .multiply(leg.predictive.animatedLandingRotation); + leg.foot.getWorldScale(scratch.worldScale); + const remainingPhase = phase.timeToLand / phaseClip.duration; + + // 沿剩余动画摆腿取点:底是相位姿势,只叠加速度、起点连续和落点差量。 + const samplePath = ( + progress: number, + position: Vector3, + rotation: Quaternion, + ): boolean => { + const t = MathUtils.clamp(progress, 0, 1); + const normalizedTime = ctx.normalizedTime + remainingPhase * t; + if (!sampleFootPhasePose( + sideData, + normalizedTime, + scratch.localPosition, + scratch.localRotation, + )) { + return false; + } + + const trajectoryProgress = MathUtils.lerp(currentTrajectoryProgress, 1, t); + const warpAlpha = MathUtils.smoothstep(trajectoryProgress, 0, 1); + position + .copy(scratch.localPosition) + .applyMatrix4(ctx.model.matrixWorld) + .addScaledVector( + ctx.predictionVelocity, + Math.min(phase.timeToLand * t, ctx.predictionHorizon), + ) + .addScaledVector(scratch.animationBias, 1 - t) + .addScaledVector(scratch.continuityOffset, 1 - t) + .addScaledVector(scratch.landingCorrection, warpAlpha); + // 末段才把脚掌旋到落点法线,避免摆腿中段提前贴坡。 + rotation + .copy(scratch.modelWorldRotation) + .multiply(scratch.localRotation) + .slerp( + scratch.landingRotation, + MathUtils.smoothstep( + trajectoryProgress, + PREDICTION_ROTATION_WARP_START, + 1, + ), + ); + return true; + }; + + if (!samplePath(0, scratch.pathPosition, scratch.pathRotation)) return null; + + let startSupportY = -Infinity; + const startRayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y) + + ctx.sampleRayOriginY; + for (const sample of leg.soleSamples) { + scratch.soleOffset + .copy(sample.local) + .multiply(scratch.worldScale) + .applyQuaternion(scratch.pathRotation); + scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset); + const hit = ctx.castGroundFrom( + scratch.solePoint.x, + startRayOriginY, + scratch.solePoint.z, + ); + if (hit) startSupportY = Math.max(startSupportY, hit.point.y); + } + const currentSupport = ctx.castCapsuleGround(); + if (!Number.isFinite(startSupportY)) { + startSupportY = currentSupport?.point.y ?? -Infinity; + } + if (!Number.isFinite(startSupportY)) startSupportY = candidate.point.y; + // 低于进入阈值的凸起当作平地,不计入摆腿净空需求。 + const terrainEpsilon = Math.max( + ctx.snapEpsilon, + PREDICTION_DEMAND_ENTER * ctx.appliedScale, + ); + const horizontalDistance = Math.hypot( + candidate.target.x - scratch.pathPosition.x, + candidate.target.z - scratch.pathPosition.z, + ); + const sampleSpacing = Math.max( + ctx.snapEpsilon, + PREDICTION_TERRAIN_SAMPLE_SPACING * ctx.appliedScale, + ); + const terrainSampleCount = MathUtils.clamp( + Math.ceil(horizontalDistance / sampleSpacing), + PREDICTION_MIN_TERRAIN_SAMPLE_COUNT, + PREDICTION_MAX_TERRAIN_SAMPLE_COUNT, + ); + let maxPlaneLift = 0; + scratch.clearanceSampleCount = 0; + + // 在起点和终点之间采样鞋底净空;两端不采,避免把落点本身当成中途障碍。 + for (let i = 1; i <= terrainSampleCount; i++) { + const progress = i / (terrainSampleCount + 1); + if (!samplePath(progress, scratch.pathPosition, scratch.pathRotation)) { + return null; + } + + let requiredLift = 0; + const rayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y) + + ctx.sampleRayOriginY; + for (const sample of leg.soleSamples) { + scratch.soleOffset + .copy(sample.local) + .multiply(scratch.worldScale) + .applyQuaternion(scratch.pathRotation); + scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset); + const hit = ctx.castGroundFrom( + scratch.solePoint.x, + rayOriginY, + scratch.solePoint.z, + ); + if (!hit) continue; + const planeLift = getPointPlaneLift( + currentSupport, + hit.point, + startSupportY, + scratch.v5, + ); + if (planeLift <= terrainEpsilon) continue; + maxPlaneLift = Math.max(maxPlaneLift, planeLift); + requiredLift = Math.max(requiredLift, hit.point.y - scratch.solePoint.y); + } + const clearanceSample = scratch.clearanceSamples[scratch.clearanceSampleCount++]; + clearanceSample.progress = MathUtils.lerp( + currentTrajectoryProgress, + 1, + progress, + ); + clearanceSample.requiredLift = Math.max(0, requiredLift); + } + + const clearance = solvePredictiveClearance( + scratch.clearanceSamples, + ctx.swingClearance, + ctx.maxPredictionClearance, + scratch.clearanceSampleCount, + ); + if (clearance === null) return null; + + const points = scratch.plannedPoints; + for (let i = 0; i < points.length; i++) { + const progress = i / (points.length - 1); + if (!samplePath(progress, points[i], scratch.pathRotation)) return null; + const trajectoryProgress = MathUtils.lerp( + currentTrajectoryProgress, + 1, + progress, + ); + // 重规划时当前帧已经带了一截净空,后面的点要扣掉这份,避免整条线再抬一次。 + const carriedClearance = clearance + * getPredictiveClearanceShape(currentTrajectoryProgress) + * (1 - progress); + points[i].addScaledVector( + ctx.up, + clearance * getPredictiveClearanceShape(trajectoryProgress) + - carriedClearance, + ); + } + + return { + clearance, + terrainDemand: Math.max(0, maxPlaneLift), + points, + }; +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts b/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts new file mode 100644 index 0000000..38b3371 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/skeleton.ts @@ -0,0 +1,155 @@ +import { Vector3, type Bone, type Object3D } from "three"; +import type { + BoneRef, + FootIKLeg, + FootIKSide, + FootIKSkeletonConfig, + ReadyFootIKLeg, +} from "../types"; +import { createPredictiveFootState } from "./predictivePlacement"; + +// 收集模型层级下的全部骨骼。 +export function collectBones(root: Object3D | null | undefined): Bone[] { + const bones: Bone[] = []; + root?.traverse(obj => { + if ((obj as Bone).isBone) bones.push(obj as Bone); + }); + return bones; +} + +// 查找常见命名里的骨盆/髋部骨骼。 +export function findHips(bones: readonly Bone[]): Bone | null { + return bones.find(bone => /hips|pelvis/i.test(bone.name)) ?? null; +} + +// 创建单条腿的骨骼引用和 IK 运行时状态。 +export function createLeg( + side: FootIKSide, + bones: readonly Bone[], + skeletonConfig: FootIKSkeletonConfig | null = null, +): FootIKLeg { + const legConfig = skeletonConfig?.legs?.[side] ?? null; + const upper = resolveConfiguredBone(legConfig?.upper, bones, `${side}.upper`) + ?? findBone(bones, side, "upper"); + const lower = resolveConfiguredBone(legConfig?.lower, bones, `${side}.lower`) + ?? findBone(bones, side, "lower"); + const foot = resolveConfiguredBone(legConfig?.foot, bones, `${side}.foot`) + ?? findBone(bones, side, "foot"); + const toe = resolveConfiguredBone(legConfig?.toe, bones, `${side}.toe`) + ?? findBone(bones, side, "toe"); + + return { + side, + upper, + lower, + foot, + toe, + ready: !!(upper && lower && foot), + + // IK 运行时目标和地面命中信息。 + smoothedTarget: new Vector3(), + heightSupport: null, + heightSupportLocal: new Vector3(), + heightSupportWorldY: 0, + heightSupportScale: 1, + pelvisTarget: new Vector3(), + hasPelvisTarget: false, + hitPoint: new Vector3(), + hitNormal: new Vector3(0, 1, 0), + supportPoint: new Vector3(), + supportNormal: new Vector3(0, 1, 0), + footSamplePoint: new Vector3(), + soleSamples: ["heelL", "heelR", "toeL", "toeR"].map(name => ({ + name, + local: new Vector3(), + point: new Vector3(), + hitPoint: new Vector3(), + hasHit: false, + marker: null, + rayLine: null, + footMarker: null, + })), + bestGroundSampleIndex: -1, + + // 本帧脚部目标对动画脚位的修正量。 + offsetY: 0, + movePenetrating: false, + + // weight 为最终 IK 权重,plantedWeight 只负责支撑脚渐入/渐出。 + weight: 0, + plantedWeight: 0, + planted: false, + predictive: createPredictiveFootState(), + + // 上一帧 pole 只在弯曲平面退化时使用。 + lastPole: new Vector3(), + hasLastPole: false, + + marker: null, + hitMarker: null, + rayLine: null, + raiseLimitLine: null, + predictiveLine: null, + predictiveCandidateMarkers: [], + predictiveSupportMesh: null, + predictiveSupportOutline: null, + }; +} + +// 判断腿链是否包含求解需要的三段骨骼。 +export function isReadyLeg(leg: FootIKLeg): leg is ReadyFootIKLeg { + return !!(leg.ready && leg.upper && leg.lower && leg.foot); +} + +// 优先使用外部配置的骨骼引用,并在缺失时给出提示。 +export function resolveConfiguredBone( + ref: BoneRef | undefined, + bones: readonly Bone[], + label: string, + warnMissing = true, +): Bone | null { + if (!ref) return null; + if (typeof ref !== "string" && ref.isBone) return ref; + if (typeof ref === "string") { + const bone = bones.find(item => item.name === ref) ?? null; + if (!bone && warnMissing) { + console.warn(`[FootIK] 配置骨骼未找到:${label} -> "${ref}"`); + } + return bone; + } + console.warn(`[FootIK] 无效骨骼配置:${label}`, ref); + return null; +} + +// 根据左右侧和骨骼类型,从模型骨骼名中启发式匹配目标骨骼。 +export function findBone( + bones: readonly Bone[], + side: FootIKSide, + type: "upper" | "lower" | "foot" | "toe", +): Bone | null { + const candidates = bones.filter(bone => matchesSide(bone.name, side)); + const score = (bone: Bone): number => { + const name = compactName(bone.name); + if (type === "upper") return Number(name.includes("upleg") || name.includes("thigh")); + if (type === "lower") return Number((name.includes("leg") || name.includes("calf") || name.includes("shin")) && !name.includes("upleg") && !name.includes("foot")); + if (type === "foot") return Number((name.includes("foot") || name.includes("ankle")) && !name.includes("toe") && !name.includes("ball")); + return Number(name.includes("toe") || (name.includes("ball") && !name.includes("leaf"))); + }; + return candidates + .map(bone => ({ bone, score: score(bone) })) + .filter(item => item.score > 0) + .sort((a, b) => b.score - a.score)[0]?.bone ?? null; +} + +// 判断骨骼名是否属于指定左右侧。 +export function matchesSide(name: string, side: FootIKSide): boolean { + const lower = name.toLowerCase(); + const compact = compactName(name); + if (side === "left") return compact.includes("left") || lower.includes("_l") || lower.includes(".l") || lower.includes(" l"); + return compact.includes("right") || lower.includes("_r") || lower.includes(".r") || lower.includes(" r"); +} + +// 把骨骼名压缩成便于规则匹配的形式。 +export function compactName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} diff --git a/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts b/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts new file mode 100644 index 0000000..fe2747d --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/internal/twoBoneIK.ts @@ -0,0 +1,260 @@ +import { MathUtils, Quaternion, Vector3, type Bone, type Object3D } from "three"; + +export type TwoBoneIKScratch = { + v1: Vector3; + v2: Vector3; + v3: Vector3; + v4: Vector3; + v5: Vector3; + v6: Vector3; + v7: Vector3; + v8: Vector3; + v9: Vector3; + v10: Vector3; + v11: Vector3; + v12: Vector3; + v13: Vector3; + q1: Quaternion; + q2: Quaternion; + q3: Quaternion; + identityQ: Quaternion; +}; + +/** 双骨骼 IK 解算所需的最小腿链。 */ +export type TwoBoneIKLeg = { + upper: Bone; + lower: Bone; + foot: Bone; + /** 上一帧的世界空间 pole,只在弯曲平面退化时用于保持连续。 */ + lastPole?: Vector3; + hasLastPole?: boolean; +}; + +export type SolveTwoBoneIKOptions = { + scratch?: TwoBoneIKScratch; + /** 修改骨骼前通知外部保存动画原姿态,下一帧 restore 会用到。 */ + capture?: (bone: Object3D) => void; + /** 最小膝盖弯曲角,避免腿完全锁死伸直。 */ + minKneeBend?: number; + /** 最大膝盖弯曲角,避免过度折叠。 */ + maxKneeBend?: number; + /** + * 膝盖弯曲平面的世界空间法线。 + * 角色的右方向作为法线时,膝盖只能在角色的前后平面内弯曲。 + */ + kneePlaneNormal?: Vector3; + /** kneePlaneNormal 对原动画 pole 的覆盖权重,范围 0 到 1,默认 1。 */ + kneePlaneWeight?: number; +}; + +// 创建双骨骼 IK 解算复用对象,避免每帧重复分配临时变量。 +export function createTwoBoneIKScratch(): TwoBoneIKScratch { + return { + v1: new Vector3(), + v2: new Vector3(), + v3: new Vector3(), + v4: new Vector3(), + v5: new Vector3(), + v6: new Vector3(), + v7: new Vector3(), + v8: new Vector3(), + v9: new Vector3(), + v10: new Vector3(), + v11: new Vector3(), + v12: new Vector3(), + v13: new Vector3(), + q1: new Quaternion(), + q2: new Quaternion(), + q3: new Quaternion(), + identityQ: new Quaternion(), + }; +} + +// 受膝盖角度约束的双骨骼 IK。 +// leg.upper / leg.lower / leg.foot 分别对应大腿、小腿、脚;target 是 foot 希望靠近的世界坐标。 +export function solveTwoBoneIK( + leg: TwoBoneIKLeg | null | undefined, + target: Vector3, + weight: number, + options: SolveTwoBoneIKOptions = {}, +): void { + if (!leg?.upper || !leg?.lower || !leg?.foot || weight <= 0.001) return; + + const scratch = options.scratch ?? createTwoBoneIKScratch(); + const capture = options.capture ?? (() => { }); + const minBend = options.minKneeBend ?? MathUtils.degToRad(2); + const maxBend = options.maxKneeBend ?? MathUtils.degToRad(145); + const solveWeight = MathUtils.clamp(weight, 0, 1); + + // 静止时动画 pole 是主要弯曲方向;移动时只在稳定弯曲平面退化后作为兜底。 + // 移动状态由角色前后平面决定膝盖朝向。 + const restPole = getRestPole(leg, scratch); + + const hip = leg.upper.getWorldPosition(scratch.v1); + const knee = leg.lower.getWorldPosition(scratch.v2); + const foot = leg.foot.getWorldPosition(scratch.v3); + const upperLen = Math.max(0.0001, hip.distanceTo(knee)); + const lowerLen = Math.max(0.0001, knee.distanceTo(foot)); + + const hipToTarget = scratch.v4.copy(target).sub(hip); + const targetDistance = hipToTarget.length(); + if (targetDistance < 0.0001) return; + + // 用膝盖最小/最大弯曲角反推 foot 能到达的距离范围。 + // target 太远会被夹到最大伸展距离,太近会被夹到最大弯曲距离。 + const maxReach = reachFromBend(upperLen, lowerLen, minBend); + const minReach = reachFromBend(upperLen, lowerLen, maxBend); + const clampedDistance = MathUtils.clamp(targetDistance, minReach, maxReach); + const clampedTarget = scratch.v5.copy(hip).addScaledVector(hipToTarget, clampedDistance / targetDistance); + + // 在 hip -> target 的链方向上,用两段骨骼长度求出 knee 所在圆的中心和半径。 + // pole 决定 knee 落在圆的哪一侧,也就是膝盖朝向。 + // chainDir × 角色右轴得到的方向始终位于角色前后平面内,因此是“笔直弯”。 + const chainDir = scratch.v6.copy(clampedTarget).sub(hip).normalize(); + const pole = getStablePole( + leg, + restPole, + options.kneePlaneNormal, + MathUtils.clamp(options.kneePlaneWeight ?? 1, 0, 1), + chainDir, + scratch.v7, + scratch.v4, + scratch.q1, + scratch.identityQ, + ); + const along = (upperLen * upperLen - lowerLen * lowerLen + clampedDistance * clampedDistance) / (2 * clampedDistance); + const height = Math.sqrt(Math.max(0, upperLen * upperLen - along * along)); + const desiredKnee = scratch.v8.copy(hip).addScaledVector(chainDir, along).addScaledVector(pole, height); + + // 第一步旋转大腿,让当前 knee 靠近解析出的 desiredKnee。 + rotateBoneToward(leg.upper, leg.lower.getWorldPosition(scratch.v9), desiredKnee, solveWeight, capture, scratch); + leg.upper.updateMatrixWorld(true); + leg.lower.updateMatrixWorld(true); + + // 第二步旋转小腿,让 foot 靠近被夹距后的目标点。 + rotateBoneToward(leg.lower, leg.foot.getWorldPosition(scratch.v9), clampedTarget, solveWeight, capture, scratch); + leg.lower.updateMatrixWorld(true); +} + +// 两段骨骼夹角为 bend 时,hip 到 foot 的理论距离。 +function reachFromBend(upperLen: number, lowerLen: number, bend: number): number { + return Math.sqrt(upperLen * upperLen + lowerLen * lowerLen + 2 * upperLen * lowerLen * Math.cos(bend)); +} + +// 从当前动画姿态提取膝盖弯曲方向。 +// 把 hip->knee 投影到 hip->foot 的垂直平面上,得到“膝盖偏离腿链”的方向。 +function getRestPole(leg: TwoBoneIKLeg, scratch: TwoBoneIKScratch): Vector3 { + const hip = leg.upper.getWorldPosition(scratch.v9); + const knee = leg.lower.getWorldPosition(scratch.v10); + const foot = leg.foot.getWorldPosition(scratch.v11); + const restChain = scratch.v12.copy(foot).sub(hip).normalize(); + const restPole = scratch.v13.copy(knee).sub(hip).addScaledVector(restChain, -scratch.v10.copy(knee).sub(hip).dot(restChain)); + if (restPole.lengthSq() < 0.000001) restPole.set(0, 0, 1); + return scratch.v13.normalize(); +} + +// 优先由角色弯曲平面直接求 pole。这样动画里的横向膝摆不会参与选边。 +function getStablePole( + leg: TwoBoneIKLeg, + restPole: Vector3, + kneePlaneNormal: Vector3 | undefined, + kneePlaneWeight: number, + chainDir: Vector3, + target: Vector3, + straightPole: Vector3, + poleRotation: Quaternion, + identityRotation: Quaternion, +): Vector3 { + const hasRestPole = projectPoleToChainPlane(restPole, chainDir, target); + + if (kneePlaneNormal && kneePlaneWeight > 0) { + straightPole.crossVectors(chainDir, kneePlaneNormal); + if (normalizePole(straightPole)) { + if (hasRestPole && kneePlaneWeight < 1) { + poleRotation + .setFromUnitVectors(target, straightPole) + .slerp(identityRotation, 1 - kneePlaneWeight); + target.applyQuaternion(poleRotation).normalize(); + } else { + target.copy(straightPole); + } + rememberPole(leg, target); + return target; + } + + // 移动时腿若几乎沿角色左右方向水平伸出,前后平面内没有唯一解。 + // 只在这个奇点沿用上一帧有效 pole,避免跨帧翻面。 + if (leg.hasLastPole && leg.lastPole + && projectPoleToChainPlane(leg.lastPole, chainDir, target)) { + rememberPole(leg, target); + return target; + } + } + + // 静止模式直接走这里,完整保留 idle 动画 pole,不读取上一帧的历史结果。 + if (hasRestPole) { + projectPoleToChainPlane(restPole, chainDir, target); + rememberPole(leg, target); + return target; + } + + // 极端共线姿态的最终兜底。 + target.set(0, chainDir.z, -chainDir.y); + if (!normalizePole(target)) { + target.set(-chainDir.y, chainDir.x, 0); + normalizePole(target); + } + rememberPole(leg, target); + return target; +} + +// 把方向投影到腿链垂直平面上,得到可用的膝盖 pole;投影退化时返回 false。 +function projectPoleToChainPlane(direction: Vector3, chainDir: Vector3, target: Vector3): boolean { + target.copy(direction).addScaledVector(chainDir, -direction.dot(chainDir)); + return normalizePole(target); +} + +// 归一化 pole;长度过小时视为无效方向。 +function normalizePole(pole: Vector3): boolean { + const lengthSq = pole.lengthSq(); + if (lengthSq < 0.000001) return false; + pole.multiplyScalar(1 / Math.sqrt(lengthSq)); + return true; +} + +// 缓存本帧有效 pole,供下一帧奇点兜底。 +function rememberPole(leg: TwoBoneIKLeg, pole: Vector3): void { + if (!leg.lastPole) return; + leg.lastPole.copy(pole); + leg.hasLastPole = true; +} + +// 把 bone 旋转到当前 effector 方向对齐目标方向。 +// deltaWorldQ 在世界空间计算,最后转换回 bone 的本地 quaternion 写入骨骼。 +function rotateBoneToward( + bone: Bone, + effectorWorld: Vector3, + targetWorld: Vector3, + weight: number, + capture: (bone: Object3D) => void, + scratch: TwoBoneIKScratch, +): void { + capture(bone); + + const jointWorld = bone.getWorldPosition(scratch.v10); + const from = scratch.v11.copy(effectorWorld).sub(jointWorld).normalize(); + const to = scratch.v12.copy(targetWorld).sub(jointWorld).normalize(); + if (from.lengthSq() < 0.0001 || to.lengthSq() < 0.0001) return; + + const deltaWorldQ = scratch.q1.setFromUnitVectors(from, to); + // weight < 1 时只应用部分旋转,保留一部分原动画姿态。 + deltaWorldQ.slerp(scratch.identityQ, 1 - weight); + + const jointWorldQ = bone.getWorldQuaternion(scratch.q2); + const targetWorldQ = deltaWorldQ.multiply(jointWorldQ); + const parent = bone.parent; + if (!parent) return; + const parentWorldQ = parent.getWorldQuaternion(scratch.q3); + bone.quaternion.copy(parentWorldQ.invert().multiply(targetWorldQ)); + bone.updateMatrixWorld(true); +} diff --git a/three-player-controller/src/plugins/foot-ik/types.ts b/three-player-controller/src/plugins/foot-ik/types.ts new file mode 100644 index 0000000..7734ac3 --- /dev/null +++ b/three-player-controller/src/plugins/foot-ik/types.ts @@ -0,0 +1,431 @@ +import type { + AnimationAction, + AnimationClip, + Bone, + Line3, + Line, + LineSegments, + Mesh, + Object3D, + Quaternion, + Scene, + Vector3, +} from "three"; + +/** 骨骼对象或用于查找骨骼的名称。 */ +export type BoneRef = Bone | string; + +/** 左右脚标识。 */ +export type FootIKSide = "left" | "right"; + +/** Foot IK 地面检测时忽略的动态刚体形状。 */ +export type FootIKIgnoredDynamicKind = "sphere" | "box"; + +/** FootIK 使用的控制器字段;实际传入对象仍是 playerController。 */ +export type FootIKPlayer = { + /** Three.js 场景。 */ + scene: Scene; + /** 返回可供脚底射线检测的静态与运动学碰撞网格。 */ + getColliderMeshes: () => Mesh[]; + /** 返回动态刚体列表;用于判断当前是否存在可参与脚底检测的动态地面。 */ + getDynamicBodies?: () => unknown[]; + /** 从世界坐标向下检测动态刚体,并过滤法线过陡、不可站立的表面。 */ + raycastDynamicGround?: ( + origin: Vector3, + minNormalY?: number, + excludeKinds?: readonly FootIKIgnoredDynamicKind[], + ) => { + /** 动态刚体表面的世界空间命中点。 */ + point: Vector3; + /** 命中面的世界空间单位法线。 */ + normal: Vector3; + /** 动态刚体及其视觉变换;预测线路使用其局部空间保存落点。 */ + body?: { mesh: Object3D }; + } | null; + /** 当前站立的动态刚体;站在球体上时 Foot IK 会忽略该支撑。 */ + getActiveDynamicBody?: () => { kind: FootIKIgnoredDynamicKind } | null; + /** 返回角色当前世界空间速度;预测落脚启用时用于补充胶囊实际位移速度。 */ + getVelocity?: () => Vector3; + /** 角色移动系统本帧最终采用的支撑点;可来自射线或体积探测。 */ + getGroundSupport?: () => { + /** 控制器实际用于贴地的世界空间支撑点。 */ + point: Vector3; + /** 实际支撑面的世界空间单位法线。 */ + normal: Vector3; + } | null; + /** 玩家碰撞胶囊;其世界位置同时作为角色根节点和骨盆支撑参考。 */ + playerCapsule: Mesh & { + /** 胶囊几何参数;尚未完成玩家初始化时可能不存在。 */ + capsuleInfo?: { + /** 胶囊半径,使用当前角色缩放后的世界尺度。 */ + radius: number; + /** 胶囊中心轴线段,保存于胶囊本地空间。 */ + segment: Line3; + }; + }; + /** 玩家可见模型根节点;Foot IK 从该节点中查找骨盆和腿部骨骼。 */ + playerModel: Object3D | null; + /** 角色当前是否接地;未接地时跳过脚底贴合与骨盆补偿。 */ + playerIsOnGround: boolean; + /** 角色是否处于飞行模式;飞行时不执行 Foot IK。 */ + isFlying?: boolean; + /** Foot IK 需要使用的玩家缩放和移动动画名称配置。 */ + playerModelConfig: { + /** 玩家当前整体缩放,用于同步射线长度、容差和脚底尺寸。 */ + scale: number; + /** 默认向前行走动画名称。 */ + walkAnim: string; + /** 默认向前奔跑动画名称。 */ + runAnim: string; + /** 可选的左向移动动画名称。 */ + leftWalkAnim?: string; + /** 可选的右向移动动画名称。 */ + rightWalkAnim?: string; + /** 可选的后退动画名称。 */ + backwardAnim?: string; + }; + /** 当前玩家动画资源与正在播放的动画动作。 */ + animation: { + /** 用于建立脚步着地相位数据库的全部动画片段。 */ + clips: AnimationClip[]; + /** 当前动画动作;用于读取动画名称、播放时间和脚步相位。 */ + state?: AnimationAction; + }; + /** 0 步行 / 1 载具;载具模式时跳过贴地 IK。 */ + getControllerMode?: () => number; +}; + +/** 单条腿参与 IK 的骨骼配置。 */ +export type FootIKLegBoneConfig = { + /** 大腿骨骼。 */ + upper?: BoneRef; + /** 小腿骨骼。 */ + lower?: BoneRef; + /** 脚部骨骼。 */ + foot?: BoneRef; + /** 脚趾骨骼。 */ + toe?: BoneRef; +}; + +/** FootIK 使用的骨盆和左右腿骨骼配置。 */ +export type FootIKSkeletonConfig = { + /** 骨盆骨骼。 */ + hips?: BoneRef; + /** 左右腿骨骼配置。 */ + legs?: { + /** 左腿骨骼配置。 */ + left?: FootIKLegBoneConfig; + /** 右腿骨骼配置。 */ + right?: FootIKLegBoneConfig; + }; +}; + +/** FootIK 插件初始化配置。 */ +export type FootIKOptions = { + /** 腿部骨骼绑定;未传时根据骨骼名自动匹配。 */ + skeleton?: FootIKSkeletonConfig; + /** + * 是否显示统一调试对象,默认 false。 + * 包含:IK 目标、最高地面命中、脚底四点(采样原点 + 命中 + 射线)。 + */ + debug?: boolean; + /** 是否启用插件,默认 true。 */ + enabled?: boolean; + /** 骨盆最大下沉距离基准值(按 scale 缩放),默认 50。 */ + maxPelvisDrop?: number; + /** 双脚都高于胶囊时,按较低脚上抬骨盆的最大距离基准值(按 scale 缩放),默认 50。 */ + maxPelvisRaise?: number; + /** 脚部 IK 最大上抬距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */ + maxFootRaise?: number; + /** 支撑脚 IK 最大下探距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */ + maxFootDrop?: number; + /** 支撑脚相对支撑面的贴地高度速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */ + plantedHeightSpeed?: number; + /** 摆动脚相对支撑面的陷入上抬速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */ + penetrationLiftSpeed?: number; + /** 虚拟脚底左右半宽基准值(按 scale 缩放),默认 7。 */ + soleHalfWidth?: number; + /** 脚尖采样点向前延伸距离基准值(按 scale 缩放),默认 7。 */ + soleToeExtend?: number; + /** 脚跟采样点向后延伸距离基准值(按 scale 缩放),默认 3。 */ + soleHeelExtend?: number; + /** + * 脚骨到脚底蒙皮的厚度补偿基准值(按 scale 缩放),默认 3。 + * 贴地校正时额外上抬,避免按骨骼贴地导致鞋底陷入地面;平地接近满值,斜坡按法线衰减。 + */ + soleSkinThickness?: number; + /** 脚掌贴合地面法线的权重,范围 0 到 1,默认 1。 */ + footAlignWeight?: number; + /** 脚掌最大倾斜角,单位为弧度,默认 Math.PI / 2。 */ + maxFootTilt?: number; + /** 膝盖最小弯曲角,单位为弧度,默认 2°。 */ + minKneeBend?: number; + /** 膝盖最大弯曲角,单位为弧度,默认 145°。 */ + maxKneeBend?: number; + /** 骨盆可达性计算保留的膝盖弯曲角,单位为弧度,默认 0°。 */ + pelvisKneeBend?: number; + /** 移动时是否使用直 pole(膝盖朝角色前方平面),默认 false,关闭则保留动画 pole。 */ + straightPoleEnabled?: boolean; + /** 移动时脚底穿透触发阈值基准值(按 scale 缩放),默认 0.1。 */ + moveLiftThreshold?: number; + /** 单个移动动画的脚步相位采样数,默认 96。 */ + footPhaseSampleCount?: number; + /** 脚步相位接地高度阈值基准值(按 scale 缩放),默认 5。 */ + footPhaseGroundThreshold?: number; + /** 最短接触段占动画周期的比例,默认 0.04。 */ + footPhaseMinContactRatio?: number; + /** 支撑脚水平速度过滤倍率,默认 1.35。 */ + footPhaseSpeedSlack?: number; + /** 是否启用预测落脚;默认 false,关闭时完全沿用反应式 Foot IK。 */ + predictivePlacement?: boolean; + /** 最长预测时间,单位为秒,默认 0.45。 */ + predictionHorizon?: number; + /** 预测地面候选重新探测间隔,单位为帧,默认 10;0 表示每帧探测。 */ + predictionProbeFrames?: number; + /** 预测落脚候选相对动画落点的搜索半径基准值(按 scale 缩放),默认 20。 */ + predictionSearchRadius?: number; + /** 允许预测目标偏离动画落点的最大水平距离基准值(按 scale 缩放),默认 45。 */ + maxPredictionCorrection?: number; + /** 预测落脚允许的最小地面法线 Y 分量,默认 0.55。 */ + predictionMinNormalY?: number; + /** 检测到摆动障碍后追加的净空余量(按 scale 缩放),默认 8。 */ + swingClearance?: number; + /** 预测摆动轨迹允许增加的最大净空(按 scale 缩放),默认跟随 maxFootRaise。 */ + maxPredictionClearance?: number; +}; + +/** 预测落脚运行时状态。 */ +export type PredictiveFootMode = "none" | "tracking" | "active" | "planted"; + +/** 单只脚的预测目标、支撑面和平台锚点。 */ +export type PredictiveFootState = { + /** none 未启用;tracking 探测中;active 摆腿走预测线路;planted 支撑期钉住偏移落点。 */ + mode: PredictiveFootMode; + /** 脚骨 IK 目标;鞋底贴在支撑面上时脚骨应到达的位置。 */ + landingTarget: Vector3; + /** 预测落点支撑面的世界空间单位法线。 */ + landingNormal: Vector3; + /** 鞋底四点共面合并后的地面接触点,与 landingTarget 不是同一点。 */ + supportPoint: Vector3; + /** 动画预计落地时的脚骨位置,已加上剩余落地时间的水平速度。 */ + animatedLanding: Vector3; + /** 动画预计落地时的脚骨世界旋转。 */ + animatedLandingRotation: Quaternion; + /** 启用预测时当前脚相对动画脚的起点残差,随 warp 渐隐。 */ + trajectoryStartOffset: Vector3; + /** 上一帧实际输出的预测脚目标,重规划时用来保持世界空间连续。 */ + trajectoryCurrentTarget: Vector3; + /** 预测线路启用瞬间的摆腿进度,用于把剩余摆腿重映射到 0 到 1。 */ + trajectoryStartProgress: number; + /** 当前摆腿进度;只增不减,供 warp 和末段锁面使用。 */ + trajectoryProgress: number; + /** 摆腿途中为越过凸起追加的净空高度。 */ + trajectoryClearance: number; + /** 由地形需求在进入/退出阈值之间平滑得到的预测 IK 权重。 */ + predictionWeight: number; + /** 落点相对动画中心做了水平偏移时为 true;中心落点落地后交还反应式 IK。 */ + usesOffsetLanding: boolean; + /** 上一支撑偏移落点相对新摆腿动画脚的残差,前段逐渐还给动画。 */ + releaseOffset: Vector3; + /** 开始释放残差时的摆腿进度。 */ + releaseStartProgress: number; + /** 本摆腿前段是否仍在释放上一支撑残差。 */ + releaseActive: boolean; + /** 落点所在网格;移动平台用局部锚点把目标跟到世界空间。 */ + supportObject: Object3D | null; + /** landingTarget 在 supportObject 局部空间中的锚点。 */ + supportLocalTarget: Vector3; + /** supportPoint 在 supportObject 局部空间中的锚点。 */ + supportLocalPoint: Vector3; + /** landingNormal 在 supportObject 局部空间中的方向。 */ + supportLocalNormal: Vector3; + /** 距上次地面探测的帧数;Infinity 表示本摆腿尚未探测。 */ + probeElapsed: number; + /** 当前预测候选评分;越小越好,无有效候选时为 Infinity。 */ + score: number; + /** 调试:落点高出胶囊支撑面的高度。 */ + debugPlaneLift: number; + /** 调试:摆腿路径上高出支撑面的最大凸起。 */ + debugSwingLift: number; + /** 调试:规划出的摆腿线路采样点。 */ + debugTrajectory: Vector3[]; + /** 调试:当前是否绘制预测摆腿线路。 */ + debugTrajectoryVisible: boolean; + /** 调试:当前是否绘制预测支撑面。 */ + debugSupportVisible: boolean; + /** 调试:预测支撑面四角的世界坐标。 */ + debugSupportCorners: Vector3[]; + /** 调试:本帧评估过的落点候选。 */ + debugCandidates: Array<{ + /** 候选支撑点的世界坐标。 */ + point: Vector3; + /** 本帧是否评估过该候选。 */ + evaluated: boolean; + /** 该候选是否通过抬降和腿长约束。 */ + valid: boolean; + /** 是否为最终采用的候选。 */ + selected: boolean; + }>; +}; + +/** 脚底查询统一使用世界空间命中点和法线。 */ +export type FootIKGroundHit = { + point: Vector3; + normal: Vector3; + object?: Object3D; +}; + +// 单个地面探测采样点的运行时数据。 +export type FootIKProbeSample = { + name: string; + point: Vector3; + hitPoint: Vector3; + hasHit: boolean; +}; + +// 固定在 foot 本地空间中的脚底采样点 +export type FootIKSoleSample = FootIKProbeSample & { + local: Vector3; + /** 地面命中调试球(线框)。 */ + marker: Mesh | null; + /** 采样原点 → 命中点。 */ + rayLine: Line | null; + /** 脚底采样原点调试球(实心)。 */ + footMarker: Mesh | null; +}; + +// 单条腿的骨骼引用、IK 状态和调试对象。 +export type FootIKLeg = { + side: FootIKSide; + upper: Bone | null; + lower: Bone | null; + foot: Bone | null; + toe: Bone | null; + ready: boolean; + smoothedTarget: Vector3; + heightSupport: Object3D | null; + heightSupportLocal: Vector3; + heightSupportWorldY: number; + heightSupportScale: number; + pelvisTarget: Vector3; + hasPelvisTarget: boolean; + hitPoint: Vector3; + hitNormal: Vector3; + supportPoint: Vector3; + supportNormal: Vector3; + footSamplePoint: Vector3; + soleSamples: FootIKSoleSample[]; + bestGroundSampleIndex: number; + offsetY: number; + movePenetrating: boolean; + weight: number; + plantedWeight: number; + planted: boolean; + predictive: PredictiveFootState; + lastPole: Vector3; + hasLastPole: boolean; + marker: Mesh | null; + hitMarker: Mesh | null; + rayLine: Line | null; + raiseLimitLine: LineSegments | null; + predictiveLine: Line | null; + predictiveCandidateMarkers: Mesh[]; + predictiveSupportMesh: Mesh | null; + predictiveSupportOutline: Line | null; +}; + +// 已完成必要骨骼绑定的腿链。 +export type ReadyFootIKLeg = FootIKLeg & { + upper: Bone; + lower: Bone; + foot: Bone; + ready: true; +}; + +// 左右腿运行时数据集合。 +export type FootIKLegs = Record; + +// IK 修改前保存的单个骨骼姿态。 +export type FootIKBonePose = { + position: Vector3; + quaternion: Quaternion; +}; + +// 脚步相位离线采样配置。 +export type FootPhaseOptions = { + sampleCount: number; + groundThreshold: number; + minContactRatio: number; + speedSlack: number; +}; + +// 单只脚在当前动画帧的相位状态。 +export type FootPhaseRuntimeState = { + planted: boolean; + progress: number; + timeToLand: number; + nextLanding: FootPhaseLanding | null; +}; + +/** 动画周期内一次落脚事件及其模型局部脚骨位姿。 */ +export type FootPhaseLanding = { + phase: number; + localPosition: Vector3; + localRotation: Quaternion; +}; + +// 左右脚在当前动画帧的相位状态。 +export type FootPhaseRuntime = Record; + +// 当前动画及其左右脚相位状态。 +export type FootPhaseControllerState = FootPhaseRuntime & { + clipName: string; + normalizedTime: number; +}; + +// 单只脚在一个动画周期内的接触区间数据。 +export type FootPhaseSideData = { + contacts: boolean[]; + land: number[]; + lift: number[]; + landings: FootPhaseLanding[]; + poses: FootPhasePoseSample[]; +}; + +// 单个移动动画的左右脚相位数据。 +export type FootPhaseClipData = { + name: string; + duration: number; + left: FootPhaseSideData; + right: FootPhaseSideData; +}; + +// 以动画名称索引的脚步相位数据库。 +export type FootPhaseDatabase = Map; + +// 单只脚在某个采样帧的接触高度和模型局部脚骨位姿。 +export type FootPhaseSamplePoint = { + y: number; + x: number; + localY: number; + z: number; + localRotation: Quaternion; +}; + +// 单只脚在离线采样相位中的模型局部位姿。 +export type FootPhasePoseSample = { + localPosition: Vector3; + localRotation: Quaternion; +}; + +// 左右脚在动画采样时刻的位置数据。 +export type FootPhaseFrameSample = { + time: number; + left: FootPhaseSamplePoint; + right: FootPhaseSamplePoint; +}; + +// 一个连续脚底接触区间包含的采样索引。 +export type FootPhaseContactRun = { + indices: number[]; +}; diff --git a/three-player-controller/src/plugins/types.ts b/three-player-controller/src/plugins/types.ts new file mode 100644 index 0000000..fdc14c0 --- /dev/null +++ b/three-player-controller/src/plugins/types.ts @@ -0,0 +1,25 @@ +import type { playerController } from "../PlayerController"; + +/** 控制器插件的生命周期接口。 */ +export interface PlayerPlugin { + /** 插件名称;注册同名插件时会替换已有插件。 */ + readonly name?: string; + + /** 注册到控制器后调用。 */ + onAttach?(player: playerController): void; + + /** 从控制器卸载后调用。 */ + onDetach?(): void; + + /** 动画混合器更新前调用,适合恢复上一帧修改。 */ + onBeforeAnimationUpdate?(delta: number): void; + + /** 动画混合器更新后调用,适合应用本帧后处理。 */ + onAfterAnimationUpdate?(delta: number): void; + + /** 玩家模型切换后调用,插件可在此重新绑定资源。 */ + onPlayerModelChange?(): void; + + /** 控制器销毁时调用。 */ + dispose?(): void; +} diff --git a/three-player-controller/src/systems/AnimationSystem.ts b/three-player-controller/src/systems/AnimationSystem.ts new file mode 100644 index 0000000..93d549d --- /dev/null +++ b/three-player-controller/src/systems/AnimationSystem.ts @@ -0,0 +1,315 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; + +export class AnimationSystem { + private ctrl: playerController; // 主控制器引用 + + mixer?: THREE.AnimationMixer; // 动画混合器 + mixerCb?: (ev: any) => void; // 完成事件回调 + actions?: Map; // 动作映射表 + state!: THREE.AnimationAction; // 当前播放状态 + sets = new Map>(); // 动作集合组 + currentLocomotionSet: string | null = null; // 当前激活的动作集合名 + clips: THREE.AnimationClip[] = []; // 原始动画片段 + hasThreePartJump = false; // 是否使用三段跳跃动画 + isOverrideAnimationPlaying = false; // 动画锁,用于防止覆盖型动画被移动动画打断 + private overrideInputSnapshot: Record | null = null; // 覆盖动画播放时的输入快照,用于检测打断 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + } + + // 按名切换动画 + playByName(name: string, fade = 0.18) { + if (!this.actions) return; + const next = this.actions.get(name); + // 如果动画不存在,或已在播放,则忽略 + if (!next || this.state === next) return; + + const prev = this.state; + next.reset(); + next.setEffectiveWeight(1); + next.paused = false; + + next.play(); + // 平滑过渡动画 + if (prev && prev !== next) { prev.fadeOut(fade); next.fadeIn(fade); } + else next.fadeIn(fade); + + this.state = next; + this.ctrl.onAnimationChange?.(name, next); + } + + // 注册自定义动画 + register(key: string, clipName: string, opts?: { + loop?: boolean; + timeScale?: number; + duration?: number; + clampWhenFinished?: boolean; + onFinished?: () => void; + }) { + if (!this.mixer || !this.actions) return; + const clip = this.clips.find(c => c.name === clipName); + if (!clip) { console.warn(`找不到 "${clipName}" 动画`); return; } + + const action = this.mixer.clipAction(clip); + // duration 优先于 timeScale,如果指定了 duration,则据此计算 timeScale + const timeScale = opts?.duration ? clip.duration / opts.duration : (opts?.timeScale ?? 1); + action.setLoop(opts?.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity); + action.clampWhenFinished = opts?.clampWhenFinished ?? false; + action.setEffectiveTimeScale(timeScale); + action.enabled = true; + action.setEffectiveWeight(0); + this.actions.set(key, action); + + // 如果有 onFinished 回调,则监听动画完成事件 + if (opts?.onFinished) { + this.mixer.addEventListener("finished", (ev: any) => { + if (ev.action === action) opts.onFinished!(); + }); + } + } + + // 注册移动动作组 + registerLocomotionSet(setName: string, map: Partial>) { + if (!this.mixer) return; + const set = new Map(); + for (const [key, clipName] of Object.entries(map) as [string, string][]) { + const clip = this.clips.find(c => c.name === clipName); + if (!clip) { console.warn(`registerLocomotionSet: 找不到 "${clipName}"`); continue; } + const action = this.mixer.clipAction(clip); + // 跳跃动画特殊处理:只播放一次 + if (key === "jumping") { + action.setLoop(THREE.LoopOnce, 1); + action.clampWhenFinished = true; + action.setEffectiveTimeScale(1.2); + } else { + action.setLoop(THREE.LoopRepeat, Infinity); + action.setEffectiveTimeScale(1); + } + action.enabled = true; + action.setEffectiveWeight(0); + set.set(key, action); + } + this.sets.set(setName, set); + } + + // 切换移动动作组 + switchLocomotionSet(setName: string, fade = 0.18) { + if (!this.actions) return; + const set = this.sets.get(setName); + if (!set) { console.warn(`switchLocomotionSet: 未找到集合 "${setName}"`); return; } + this.currentLocomotionSet = setName; + for (const [key, newAction] of set.entries()) { + const oldAction = this.actions.get(key); + if (oldAction === newAction) continue; + // 将旧动作淡出 + if (oldAction) oldAction.fadeOut(fade); + // 替换当前动作表中的动作为新集合中的动作 + this.actions.set(key, newAction); + // 如果正在播放的动画被替换,则立即切换到新动画 + if (this.state === oldAction) { + newAction.reset(); + newAction.setEffectiveWeight(1); + newAction.fadeIn(fade); + newAction.play(); + this.state = newAction; + this.ctrl.onAnimationChange?.(key, newAction); + } + } + } + + // 播放已注册动画 + play(key: string, opts?: { fade?: number; force?: boolean; returnToPrev?: boolean }) { + if (!this.actions) return; + const action = this.actions.get(key); + if (!action) { console.warn(`playAnimation: "${key}" 未注册`); return; } + + // 如果是播放一次的动画,则设置动画锁 + if (action.loop === THREE.LoopOnce) { + // 设置动画锁 + this.isOverrideAnimationPlaying = true; + + // 记录当前完整输入状态快照 + this.overrideInputSnapshot = { ...this.ctrl.input }; + + // 设置动画播放完毕后的自动解锁 + const onFinish = (e: any) => { + if (e.action === action) { + // 仅当锁仍然激活时才解锁 + if (this.isOverrideAnimationPlaying && this.overrideInputSnapshot) { + this.isOverrideAnimationPlaying = false; + this.overrideInputSnapshot = null; + } + this.mixer!.removeEventListener('finished', onFinish); + } + }; + this.mixer!.addEventListener('finished', onFinish); + } + + if (opts?.force) action.reset(); + + // 记录初始动画状态以便返回 + const prevState = opts?.returnToPrev ? this.state : null; + // 播放目标动画 + this.playByName(key, opts?.fade ?? 0.18); + + // 如果设置 returnToPrev,则在动画播放完毕后返回之前的动画状态 + if (opts?.returnToPrev && prevState && this.mixer) { + // 存储当前动画状态 + const action = this.actions.get(key)!; + const fade = opts?.fade ?? 0.18; + // 定义一个一次性的事件处理器 + const handler = (ev: any) => { + if (ev.action === action && this.state === action) { + this.mixer!.removeEventListener("finished", handler); + const cur = this.state; + // 停止当前动画 + cur.stop(); + // 重置前一个状态 + prevState.reset(); + // 将权重设置为1并播放前一个状态动画 + prevState.setEffectiveWeight(1); + prevState.play(); + // 更新状态 + this.state = prevState; + this.ctrl.onAnimationChange?.(prevState.getClip().name, prevState); + } + }; + // 监听动画完成事件 + this.mixer.addEventListener("finished", handler); + } + } + + // 触发跳跃动画(统一入口) + startJump(inAir = false) { + // 根据是否配置了三段跳跃,播放不同动画 + if (this.hasThreePartJump) { + this.playByName(inAir ? "jumpLoop" : "jumpStart"); + } else { + this.playByName("jumping"); + } + } + + // 离地时触发 jumpLoop(三段模式专用) + onBecomeAirborne() { + if (!this.hasThreePartJump) return; + const s = this.state; + const a = this.actions; + // 如果当前已经在跳跃动画中,则不打断 + if (s === a?.get("jumpStart") || s === a?.get("jumpLoop") || s === a?.get("jumpEnd")) return; + this.playByName("jumpLoop"); + } + + // 落地时触发 jumpEnd(三段模式专用) + onLand() { + if (!this.hasThreePartJump) return; + const s = this.state; + const a = this.actions; + // 只有在起跳或跳跃循环中才触发落地 + if (s === a?.get("jumpStart") || s === a?.get("jumpLoop")) { + const { fwd, bkd, lft, rgt } = this.ctrl.input; + if (fwd || bkd || lft || rgt) { + this.setAnimationByPressed(); + return; + } + this.playByName("jumpEnd"); + } + } + + // 是否处于任意跳跃动画中(用于防止在跳跃动画播放时重复起跳) + isJumping(): boolean { + const s = this.state; + const a = this.actions; + if (!a) return false; + return s === a.get("jumping") || s === a.get("jumpStart") || + s === a.get("jumpLoop") || s === a.get("jumpEnd"); + } + + // 获取当前动画名 + getCurrentName(): string | null { + return this.state?.getClip()?.name ?? null; + } + + // 更新所有混合器 + updateMixers(delta: number) { + this.mixer?.update(delta); + } + + // 按键状态触发动画 + setAnimationByPressed() { + // 检查并处理覆盖动画中断逻辑 + if (this.isOverrideAnimationPlaying) { + const currentInput = this.ctrl.input as Record; + const snapshot = this.overrideInputSnapshot; + let inputChanged = false; + + if (snapshot) { + // 遍历快照中的所有键,与当前输入状态进行比较 + for (const key in snapshot) { + if (snapshot[key] !== currentInput[key]) { + inputChanged = true; + break; // 不匹配,中断 + } + } + } + + // 如果输入状态发生变化,则解除动画锁,并继续执行后续的移动动画逻辑 + if (inputChanged) { + this.isOverrideAnimationPlaying = false; + this.overrideInputSnapshot = null; + } else { + // 如果输入状态未变,则保持覆盖动画,不执行移动动画 + return; + } + } + + // 未启用缩放时恢复默认距离;飞行加速时拉远;启用缩放后保留滚轮选择的 maxDist。 + this.ctrl.cam.applyFlySprintMaxDist(); + + if (this.ctrl.controllerMode === 1) return; + + const { fwd, bkd, lft, rgt, shift, space } = this.ctrl.input; + + // 飞行状态下的动画逻辑 + if (this.ctrl.isFlying) { + if (fwd) { + if (shift) { + this.playByName("flying"); + } else { + this.playByName("flyHoverForward"); + } + return; + } + if (bkd) { this.playByName("flyHoverBack"); return; } + if (lft) { this.playByName("flyHoverLeft"); return; } + if (rgt) { this.playByName("flyHoverRight"); return; } + if (space) { this.playByName("flyHoverUp"); return; } + // 无任何操作时,播放悬停动画 + this.playByName("flyidle"); + return; + } + + // 地面状态下的动画逻辑 + if (this.ctrl.playerIsOnGround) { + // 无移动输入时等待落地动画播完;有移动输入则立即切换移动动画 + if ( + this.hasThreePartJump && + this.state === this.actions?.get("jumpEnd") && + !fwd && !bkd && !lft && !rgt + ) return; + // 无方向键输入,播放站立动画 + if (!fwd && !bkd && !lft && !rgt) { this.playByName("idle"); return; } + // 向前走或跑 + if (fwd) { this.playByName(shift ? "running" : "walking"); return; } + // 第三人称下,左、右、后退也播放走/跑动画(模型会自动转向) + if (!this.ctrl.isFirstPerson && (lft || rgt || bkd)) { + this.playByName(shift ? "running" : "walking"); return; + } + // 第一人称下的平移和后退 + if (lft) { this.playByName("left_walking"); return; } + if (rgt) { this.playByName("right_walking"); return; } + if (bkd) { this.playByName("walking_backward"); return; } + } + } +} diff --git a/three-player-controller/src/systems/CameraSystem.ts b/three-player-controller/src/systems/CameraSystem.ts new file mode 100644 index 0000000..506a1f9 --- /dev/null +++ b/three-player-controller/src/systems/CameraSystem.ts @@ -0,0 +1,402 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; + +export class CameraSystem { + private ctrl: playerController; // 主控制器引用 + + collisionLerp = 0.18; // 碰撞插值速度 + epsilon = 35; // 安全距离偏移 + minDist = 100; // 最小相机距离 + maxDist = 440; // 最大相机距离 + originMaxDist = 440; // 配置的默认最远距离 + sensitivity = 5; // 鼠标灵敏度 + mouseMode: 0 | 1 | 2 | 3 | 4 | 5 = 1; // 鼠标控制模式 + zoomEnabled = false; // 是否允许缩放 + lookAtHeightRatio = 0.8; // 第三人称看向点高度比例(0=底部,1=顶部) + overShoulderOffsetRatio = 0.2; // 第三人称相机过肩视角横向偏移比例 + + private lookAtPoint = new THREE.Vector3(); // 预分配的看向点向量 + + enableSpringCamera = false; + springCameraTime = 0.05; + vehicleTurnLerp = 0.01; + private _springVelocity = new THREE.Vector3(); + private _springResult = new THREE.Vector3(); + private zoomWheelElement: HTMLElement | SVGElement | null = null; + private readonly boundZoomWheel: EventListener = (event) => this.onZoomWheel(event as WheelEvent); + private _flySprintMaxDistBoost = false; + private _flySprintSavedMaxDist = 0; + + raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3()); // 相机碰撞射线 + centerRay = new THREE.Raycaster(); // 屏幕中心射线 + centerMouse = new THREE.Vector2(); // 屏幕中心坐标 + playerToCam = new THREE.Vector3(); // 玩家到相机向量 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + (this.raycaster as any).firstHitOnly = true; + } + + // 通用弹簧阻尼:把 controls.target 朝 dest 平滑跟随,返回本帧的目标点 + springTarget(dest: THREE.Vector3, delta: number): THREE.Vector3 { + if (!this.enableSpringCamera) return dest; + const cur = this.ctrl.controls.target; + const v = this._springVelocity; + const out = this._springResult; + const smoothTime = Math.max(0.0001, this.springCameraTime); + const omega = 2 / smoothTime; + const x = omega * delta; + const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); + const axes = ['x', 'y', 'z'] as const; + for (const a of axes) { + const change = cur[a] - dest[a]; + const temp = (v[a] + omega * change) * delta; + v[a] = (v[a] - omega * temp) * exp; + let o = dest[a] + (change + temp) * exp; + if ((dest[a] - cur[a] > 0) === (o > dest[a])) { + o = dest[a]; + v[a] = 0; + } + out[a] = o; + } + return out; + } + + // 第三人称相机看向点 + getLookAtPoint(): THREE.Vector3 { + if (this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active) { + return this.lookAtPoint.copy(this.ctrl.vehicle.active.vehicleGroup.position); + } + const capsuleInfo = this.ctrl.playerCapsule.capsuleInfo; + const r = capsuleInfo.radius; + const sy = this.ctrl.playerCapsule.scale.y || 1; + const totalH = (-capsuleInfo.segment.end.y) * sy + 2 * r; + const y = this.ctrl.playerCapsule.position.y + r - totalH * (1 - this.lookAtHeightRatio); + return this.lookAtPoint.copy(this.ctrl.playerCapsule.position).setY(y); + } + + // 设置越肩视角 + setOverShoulder(enable: boolean) { + if (!enable || this.ctrl.controllerMode === 1) { this.ctrl.camera.clearViewOffset(); return; } + const w = window.innerWidth; + const h = window.innerHeight; + this.ctrl.camera.setViewOffset(w, h, w * this.overShoulderOffsetRatio, 0, w, h); + } + + // 切换视角模式 + changeView() { + this.ctrl.onBeforeViewChange?.(this.ctrl.isFirstPerson); + this.ctrl.isFirstPerson = !this.ctrl.isFirstPerson; + if (this.ctrl.isFirstPerson) { + // 切到第一人称,先对齐胶囊体朝向 + const playerFwd = new THREE.Vector3(0, 0, 1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const flatDir = new THREE.Vector3(playerFwd.x, 0, playerFwd.z).normalize(); + if (flatDir.lengthSq() > 0.001) { + const yAngle = Math.atan2(flatDir.x, flatDir.z); + this.ctrl.playerCapsule.rotation.set(0, yAngle, 0); + } + this.setFirstPerson(); + this.setOverShoulder(false); + } else { + // 切到第三人称,放置相机到玩家背后 + this.ctrl.controls.enabled = true; + this.ctrl.scene.attach(this.ctrl.camera); + const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const angle = Math.atan2(dir.z, dir.x); + const s = this.ctrl.playerModelConfig.scale; + const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s); + this.ctrl.controls.target.copy(this.getLookAtPoint()); + this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist)); + // 滚轮由 CameraSystem 改写 maxDist,禁用自带的直接位移缩放。 + this.ctrl.controls.enableZoom = false; + this.setOverShoulder(this.ctrl.enableOverShoulderView); + } + this.setPointerLock(); + this.ctrl.onViewChange?.(this.ctrl.isFirstPerson); + } + + // 进入第一人称 + setFirstPerson(vertAngle = 0) { + this.ctrl.controls.enabled = false; + const s = this.ctrl.playerModelConfig.scale; + const sharedOffset = this.ctrl.playerModelConfig.firstPersonCameraOffset; + // 有头骨节点挂到头骨,否则挂到胶囊体 + if (this.ctrl.playerModelHead) { + const [x, y, z] = sharedOffset ?? [0, 10, 20]; + this.ctrl.playerModelHead.attach(this.ctrl.camera); + this.ctrl.camera.position.set(x, y, z); + } else { + const [x, y, z] = sharedOffset ?? [0, 40, 30]; + this.ctrl.playerCapsule.attach(this.ctrl.camera); + this.ctrl.camera.position.set(x * s, y * s, z * s); + } + this.ctrl.camera.rotation.set( + THREE.MathUtils.clamp(vertAngle, -1.1, 1.4), + Math.PI, + 0, + ); + this.ctrl.controls.enableZoom = false; + } + + // 指针锁定控制 + setPointerLock() { + const lockTarget = this.ctrl.controls.domElement; + if (!lockTarget?.requestPointerLock) return; + if (((this.mouseMode === 0 || this.mouseMode === 1 || this.mouseMode === 5) && !this.ctrl.isFirstPerson) || this.ctrl.isFirstPerson) { + lockTarget.requestPointerLock(); + } else { + document.exitPointerLock(); + } + } + + // 初始相机位置 + setCamPos() { + requestAnimationFrame(() => { + if (!this.ctrl.isFirstPerson) { + const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(this.ctrl.playerCapsule.quaternion); + const angle = Math.atan2(dir.z, dir.x); + const s = this.ctrl.playerModelConfig.scale; + const rawOffset = new THREE.Vector3(Math.cos(angle) * 400 * s, 200 * s, Math.sin(angle) * 400 * s); + this.ctrl.controls.target.copy(this.getLookAtPoint()); + this.ctrl.camera.position.copy(this.ctrl.controls.target).add(rawOffset.normalize().multiplyScalar(this.maxDist)); + this.ctrl.controls.enableZoom = false; + } else { + this.setFirstPerson(); + } + this.ctrl.camera.updateProjectionMatrix(); + }); + } + + // 初始化轨道控制 + initControls() { + this.ctrl.controls.enableZoom = false; + this.ctrl.controls.rotateSpeed = this.sensitivity * 0.05; + this.ctrl.controls.maxPolarAngle = Math.PI; + this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + // 防止相机轨道半径归零穿越目标点 + this.ctrl.controls.minDistance = this.minDist; + this.connectZoomWheel(); + } + + /** 切换第三人称滚轮缩放;关闭时恢复配置的默认最远距离。 */ + setZoomEnabled(enabled: boolean) { + this.zoomEnabled = enabled; + this.ctrl.controls.enableZoom = false; + this._flySprintMaxDistBoost = false; + this.maxDist = enabled + ? Math.max(this.maxDist, this.minDist) + : this.originMaxDist; + } + + /** 非弹簧第三人称飞行加速时拉远相机,否则恢复默认最远距离。 */ + applyFlySprintMaxDist(): void { + const boost = !this.enableSpringCamera + && !this.ctrl.isFirstPerson + && this.ctrl.controllerMode === 0 + && this.ctrl.isFlying + && this.ctrl.input.shift; + + if (!this.zoomEnabled) { + this._flySprintMaxDistBoost = false; + this.maxDist = this.originMaxDist * (boost ? 2 : 1); + return; + } + + if (boost && !this._flySprintMaxDistBoost) { + this._flySprintSavedMaxDist = this.maxDist; + this.maxDist *= 2; + this._flySprintMaxDistBoost = true; + } else if (!boost && this._flySprintMaxDistBoost) { + this.maxDist = this._flySprintSavedMaxDist; + this._flySprintMaxDistBoost = false; + } + } + + clearFlySprintMaxDistBoost(): void { + this._flySprintMaxDistBoost = false; + } + + /** 绑定到渲染画布,使普通鼠标与 Pointer Lock 状态共用同一滚轮入口。 */ + private connectZoomWheel() { + const element = this.ctrl.controls.domElement; + if (!element) return; + if (this.zoomWheelElement === element) return; + this.disconnectZoomWheel(); + element.addEventListener("wheel", this.boundZoomWheel, { passive: false }); + this.zoomWheelElement = element; + } + + private disconnectZoomWheel() { + this.zoomWheelElement?.removeEventListener("wheel", this.boundZoomWheel); + this.zoomWheelElement = null; + } + + /** 滚轮改变目标最远距离。 */ + private onZoomWheel(event: WheelEvent) { + if (!this.zoomEnabled || this.ctrl.isFirstPerson || event.deltaY === 0) return; + event.preventDefault(); + + // 与 OrbitControls 一致地归一化像素、行和页三种 wheel delta。 + let deltaY = event.deltaY; + if (event.deltaMode === 1) deltaY *= 16; + else if (event.deltaMode === 2) deltaY *= 100; + if (event.ctrlKey) deltaY *= 10; + + const scale = Math.pow(0.95, Math.abs(deltaY * 0.01)); + const nextDistance = deltaY < 0 + ? this.maxDist * scale + : this.maxDist / scale; + this.maxDist = Math.max(nextDistance, this.minDist); + } + + // 重置轨道控制 + resetControls() { + if (!this.ctrl.controls) return; + this.disconnectZoomWheel(); + this.ctrl.controls.enabled = true; + this.ctrl.controls.enablePan = true; + this.ctrl.controls.maxPolarAngle = Math.PI / 2; + this.ctrl.controls.rotateSpeed = 1; + this.ctrl.controls.enableZoom = true; + this.ctrl.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + } + + // 处理鼠标朝向 + setToward(dx: number, dy: number, speed: number) { + this.ctrl.onTowardChange?.(dx, dy, speed); + if (!this.ctrl.enableToward) return; + const sens = this.sensitivity; + if (this.ctrl.controllerMode === 0) { + // 步行第一人称 + if (this.ctrl.isFirstPerson) { + this.ctrl.playerCapsule.rotateY(-dx * speed * sens); + this.ctrl.camera.rotation.x = THREE.MathUtils.clamp( + this.ctrl.camera.rotation.x + (-dy * speed * sens), + -Math.PI * (60 / 180), Math.PI * (80 / 180), + ); + } else { + // 步行第三人称 + this.orbit(this.getLookAtPoint(), -dx * speed * sens, -dy * speed * sens); + } + } else { + const v = this.ctrl.vehicle.active; + if (!v) return; + // 载具第一人称 + if (this.ctrl.isFirstPerson) { + this.ctrl.camera.rotation.y = THREE.MathUtils.clamp(this.ctrl.camera.rotation.y + (-dx * speed * sens), Math.PI * (3 / 4), Math.PI * (5 / 4)); + this.ctrl.camera.rotation.x = THREE.MathUtils.clamp(this.ctrl.camera.rotation.x + (-dy * speed * sens), 0, Math.PI * (1 / 3)); + } else { + // 载具第三人称 + this.orbit(v.vehicleGroup.position, -dx * speed * sens, -dy * speed * sens); + } + } + } + + // 手动轨道旋转 + private orbit(target: THREE.Vector3, deltaX: number, deltaY: number) { + const distance = this.ctrl.camera.position.distanceTo(target); + const cur = this.ctrl.camera.position.clone().sub(target); + let theta = Math.atan2(cur.x, cur.z) + deltaX; + let phi = Math.acos(THREE.MathUtils.clamp(cur.y / distance, -1, 1)) + deltaY; + // 限制仰角避免万向节死锁 + phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi)); + this.ctrl.camera.position.set( + target.x + distance * Math.sin(phi) * Math.sin(theta), + target.y + distance * Math.cos(phi), + target.z + distance * Math.sin(phi) * Math.cos(theta), + ); + this.ctrl.camera.lookAt(target); + } + + // 载具第三人称:弹簧跟随 + 射线避障 + 速度方向 + updateThirdPersonVehicle(delta: number) { + const v = this.ctrl.vehicle.active; + if (!v) return; + + const lookTarget = this.springTarget(v.vehicleGroup.position, delta).clone(); + this.ctrl.camera.position.sub(this.ctrl.controls.target); + this.ctrl.controls.target.copy(lookTarget); + this.ctrl.camera.position.add(lookTarget); + this.ctrl.controls.update(); + + const vehicleBaseDistance = Math.max(this.minDist, v.size.l * 0.8); + const desiredDist = Math.max(this.maxDist, vehicleBaseDistance); + this.updateWithRaycast(this.ctrl.controls.target, desiredDist); + + if ((this.ctrl.input.fwd || this.ctrl.input.bkd) && v.followVehicleDirection) { + const vel = v.chassisBody.linvel(); + if (Math.hypot(vel.x, vel.z) > 0.3) { + const targetAngle = Math.atan2(-vel.x, -vel.z); + const offX = this.ctrl.camera.position.x - this.ctrl.controls.target.x; + const offZ = this.ctrl.camera.position.z - this.ctrl.controls.target.z; + const radius = Math.hypot(offX, offZ); + const curAngle = Math.atan2(offX, offZ); + const diff = Math.atan2(Math.sin(targetAngle - curAngle), Math.cos(targetAngle - curAngle)); + const newAngle = curAngle + diff * this.vehicleTurnLerp; + this.ctrl.camera.position.x = this.ctrl.controls.target.x + Math.sin(newAngle) * radius; + this.ctrl.camera.position.z = this.ctrl.controls.target.z + Math.cos(newAngle) * radius; + this.ctrl.controls.update(); + } + } + } + + // 收集相机射线碰撞。开车时跳过当前车辆网格。 + private collectCameraHits() { + const skipIds = this.ctrl.controllerMode === 1 && this.ctrl.vehicle.active?.meshColliderId != null + ? [this.ctrl.vehicle.active.meshColliderId] + : undefined; + const hits: THREE.Intersection[] = []; + for (const mesh of this.ctrl.getColliderMeshes({ skipIds })) { + const meshHits = this.raycaster.intersectObject(mesh, false); + if (meshHits[0] && (!hits[0] || meshHits[0].distance < hits[0].distance)) { + hits[0] = meshHits[0]; + } + } + return hits; + } + + // 射线防穿墙 + updateWithRaycast(origin: THREE.Vector3, maxDist: number = this.maxDist, minDist = this.minDist) { + this.playerToCam.subVectors(this.ctrl.camera.position, origin); + const direction = this.playerToCam.clone().normalize(); + this.raycaster.set(origin, direction); + this.raycaster.far = maxDist; + + const hits = this.collectCameraHits(); + // 有遮挡:贴近安全距离 + if (hits.length > 0) { + const safeDist = Math.max(hits[0].distance - this.epsilon, minDist); + this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp); + } else { + // 无遮挡:尝试拉到最大距离 + this.raycaster.far = maxDist; + const maxHits = this.collectCameraHits(); + const safeDist = maxHits.length > 0 ? Math.min(maxDist, maxHits[0].distance - this.epsilon) : maxDist; + this.ctrl.camera.position.lerp(origin.clone().add(direction.multiplyScalar(safeDist)), this.collisionLerp); + } + } + + // 屏幕中心检测 + getCenterHit(): THREE.Intersection | undefined { + this.ctrl.camera.updateMatrixWorld(); + this.centerRay.setFromCamera(this.centerMouse, this.ctrl.camera); + this.centerRay.layers.set(1); + this.centerRay.layers.enable(2); + + const meshes = this.ctrl.getColliderMeshes(); + const checkTargets = meshes.length + ? [...meshes, ...this.ctrl.scene.children] + : this.ctrl.scene.children; + const hits = this.centerRay.intersectObjects(checkTargets, true); + hits.sort((a, b) => a.distance - b.distance); + if (hits[0]) return hits[0]; + + // 无命中返回1000距离的虚拟点 + const fallbackPoint = this.centerRay.ray.at(1000, new THREE.Vector3()); + return { + distance: 1000, point: fallbackPoint, object: this.ctrl.camera, + uv: null as any, normal: null as any, face: null as any, + faceIndex: null as any, instanceId: undefined, + } as THREE.Intersection; + } +} diff --git a/three-player-controller/src/systems/CharacterMovement.ts b/three-player-controller/src/systems/CharacterMovement.ts new file mode 100644 index 0000000..2934764 --- /dev/null +++ b/three-player-controller/src/systems/CharacterMovement.ts @@ -0,0 +1,436 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import type { KinematicColliderEntry } from "../types"; +import { CHARACTER_QUERY_MASK } from "../collision/groups"; +import type { DynamicBody } from "../collision/DynamicBody"; +import type { DynamicGroundHit } from "./DynamicBodySystem"; +import type { PlayerDebug } from "./PlayerDebug"; +import { + applyCapsuleCollision, + createGroundProbeTemps, + probeMeshGround, +} from "../utils/capsuleCollision"; + +/** 体积地面传感区半径相对玩家胶囊半径的比例。 */ +const GROUND_SENSOR_RADIUS_RATIO = 0.4; +/** 可作为支撑面的最小向上法线分量。 */ +const GROUND_SUPPORT_NORMAL_Y = 0.5; + +/** + * 人物胶囊移动:地面射线、分步推出、重力 snap、平台携带。 + * 不走刚体冲量核(位置推出式 CharacterController)。 + */ +export class CharacterMovement { + private dynamicSupportBody: DynamicBody | null = null; + private dynamicSupportPrevPosition = new THREE.Vector3(); + private dynamicSupportPrevQuaternion = new THREE.Quaternion(); + private dynamicSupportInvQuaternion = new THREE.Quaternion(); + private dynamicSupportLocal = new THREE.Vector3(); + private dynamicSupportBefore = new THREE.Vector3(); + private dynamicSupportDelta = new THREE.Vector3(); + private groundProbeTemps = createGroundProbeTemps(); + private groundProbeStart = new THREE.Vector3(); + private groundProbeDown = new THREE.Vector3(); + private groundProbeOffset = new THREE.Vector3(); + private shapeGroundPoint = new THREE.Vector3(); + private shapeGroundNormal = new THREE.Vector3(); + private rayGroundNormal = new THREE.Vector3(); + private rayGroundNormalMatrix = new THREE.Matrix3(); + + constructor( + private readonly ctrl: playerController, + private readonly debug: PlayerDebug, + ) { } + + /** 步行 / 飞行一帧:输入速度 → 落地 → 分步碰撞 → 朝向与相机。 */ + update(delta: number): void { + const c = this.ctrl; + // 载具模式只跑动画,移动由车辆系统负责 + if (c.controllerMode === 1) { + c.clearGroundSupport(); + this.debug.clearGroundProbe(); + c.runAnimationPass(delta); + return; + } + // —— 输入与目标速度 —— + c.camera.getWorldDirection(c.camDir); + const angle = 2 * Math.PI - (Math.atan2(c.camDir.z, c.camDir.x) + Math.PI / 2); + + const moveAxes = c.input.getMoveAxes(); + c.moveDir.copy(c.DIR_RGT).multiplyScalar(moveAxes.x).addScaledVector(c.DIR_FWD, moveAxes.y); + if (c.isFlying) { + if (c.input.fwd || moveAxes.isAnalog) c.moveDir.copy(c.camDir); + if (c.input.space) c.moveDir.y += 1; + c.curPlayerSpeed = c.input.shift ? c.playerFlySpeed * 2 : c.playerFlySpeed; + } else { + c.curPlayerSpeed = c.input.shift ? c.playerRunSpeed : c.playerSpeed; + } + + c.moveDir.normalize(); + // 步行:相对相机水平朝向;飞行且镜头前进时已用 camDir,不再绕 Y 转 + if (!c.isFlying || (!moveAxes.isAnalog && !c.input.fwd)) { + c.moveDir.applyAxisAngle(c.upVector, angle); + } + + // 水平速度向目标加速 / 无输入时减速 + const accelStep = c.playerAcceleration * c.decelBase * delta; + const decelStep = c.playerDeceleration * c.decelBase * delta; + const targetX = c.moveDir.x * c.curPlayerSpeed; + const targetZ = c.moveDir.z * c.curPlayerSpeed; + const diffX = targetX - c.playerVelocity.x; + const diffZ = targetZ - c.playerVelocity.z; + const hasXZInput = c.moveDir.x !== 0 || c.moveDir.z !== 0; + const xzDiffLen = Math.hypot(diffX, diffZ); + if (xzDiffLen > 0) { + const xzApplied = Math.min(xzDiffLen, hasXZInput ? accelStep : decelStep); + c.playerVelocity.x += (diffX / xzDiffLen) * xzApplied; + c.playerVelocity.z += (diffZ / xzDiffLen) * xzApplied; + } + if (c.isFlying) { + const targetY = c.moveDir.y * c.curPlayerSpeed; + const diffY = targetY - c.playerVelocity.y; + c.playerVelocity.y += Math.sign(diffY) * Math.min(Math.abs(diffY), c.moveDir.y !== 0 ? accelStep : decelStep); + } + + // —— 脚下射线:静态 / 运动学网格 / 动态体,取最高命中 —— + c.playerCapsule.updateMatrixWorld(true); + c.groundRaycaster.ray.origin.copy(c.playerCapsule.position); + let bestHit: THREE.Intersection | undefined; + let hitEntry: KinematicColliderEntry | null = null; + for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) { + if (col.shape.kind !== "mesh") continue; + const hits = c.groundRaycaster.intersectObject(col.shape.mesh, false); + if (hits.length > 0 && (!bestHit || hits[0].point.y > bestHit.point.y)) { + bestHit = hits[0]; + hitEntry = col.motion === "kinematic" ? col.userData as KinematicColliderEntry : null; + } + } + let dynamicHit = c.dynamics.raycastGround(c.playerCapsule.position); + if (dynamicHit && bestHit && dynamicHit.point.y <= bestHit.point.y) dynamicHit = null; + if (dynamicHit) { + bestHit = undefined; + hitEntry = null; + } + // 射线阶段已经在网格与动态体之间选出最高命中,体积探测只作为失效时的补充。 + let groundPoint: THREE.Vector3 | undefined = dynamicHit?.point ?? bestHit?.point; + let usedShapeGround = false; + + // 中心射线没有近距离支撑时,用带宽度的体积探测覆盖细缝两侧。 + const rayGroundDistance = groundPoint + ? c.playerCapsule.position.y - groundPoint.y + : Infinity; + const groundCapsuleInfo = c.playerCapsule.capsuleInfo; + + // 从胶囊下端球心开始向下探测。 + this.groundProbeStart.copy(groundCapsuleInfo.segment.end) + .applyMatrix4(c.playerCapsule.matrixWorld); + this.groundProbeDown.copy(c.upVector).negate(); + + const lowerCenterOffset = this.groundProbeOffset + .subVectors(c.playerCapsule.position, this.groundProbeStart) + .dot(c.upVector); + const maxProbeDistance = Math.max(0, c.maxH - lowerCenterOffset); + const sensorRadius = groundCapsuleInfo.radius * GROUND_SENSOR_RADIUS_RATIO; + + // 近距离中心射线仍是快速路径;只有射线错过或命中过远时才执行体积查询。 + const useShapeProbe = rayGroundDistance > c.maxH; + if (useShapeProbe) { + let shapeDistance = Infinity; + let shapeEntry: KinematicColliderEntry | null = null; + let shapeDynamicHit: DynamicGroundHit | null = null; + + // 静态、运动学及已登记的动态网格都通过 BVH 生成候选支撑面,保留最近命中。 + for (const col of c.collisionWorld.query({ mask: CHARACTER_QUERY_MASK })) { + if (col.shape.kind !== "mesh") continue; + const hit = probeMeshGround( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance, + sensorRadius, + GROUND_SUPPORT_NORMAL_Y, + col.shape.mesh, + this.groundProbeTemps, + ); + if (!hit || hit.distance >= shapeDistance) continue; + shapeDistance = hit.distance; + this.shapeGroundPoint.copy(hit.point); + this.shapeGroundNormal.copy(hit.normal); + + // 保存运动学平台归属。 + shapeEntry = col.motion === "kinematic" + ? col.userData as KinematicColliderEntry + : null; + shapeDynamicHit = null; + } + + // 动态球和动态盒使用解析形状查询,再与网格结果竞争最近支撑面。 + const dynamicVolumeHit = c.dynamics.probeGroundVolume( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance, + sensorRadius, + GROUND_SUPPORT_NORMAL_Y, + ); + if (dynamicVolumeHit) { + const dynamicDistance = this.groundProbeOffset + .subVectors(dynamicVolumeHit.point, this.groundProbeStart) + .dot(this.groundProbeDown); + if (dynamicDistance < shapeDistance) { + shapeDistance = dynamicDistance; + this.shapeGroundPoint.copy(dynamicVolumeHit.point); + this.shapeGroundNormal.copy(dynamicVolumeHit.normal); + shapeEntry = null; + + // 保存动态刚体归属。 + shapeDynamicHit = dynamicVolumeHit; + } + } + + if (Number.isFinite(shapeDistance)) { + // 用体积探测结果替换失效射线,同时保持原有贴地、坡度和平台逻辑的输入格式。 + groundPoint = this.shapeGroundPoint; + bestHit = undefined; + dynamicHit = shapeDynamicHit; + hitEntry = shapeEntry; + usedShapeGround = true; + } + } + + // 调试圆柱挂在 capsule 下,传局部尺寸,由父级 scale 变换到世界尺度。 + const capsuleScale = c.playerCapsule.scale; + const sy = capsuleScale.y || 1; + const sxz = Math.max(1e-6, Math.min(capsuleScale.x || 1, capsuleScale.z || 1)); + this.debug.updateGroundSensor( + this.groundProbeStart, + this.groundProbeDown, + maxProbeDistance / sy, + sensorRadius / sxz, + useShapeProbe, + usedShapeGround, + ); + this.debug.updateGroundProbe( + c.groundRaycaster.ray.origin, + groundPoint ?? null, + c.maxH, + c.groundRaycaster.near, + ); + // —— 重力 / 贴地 snap(飞行跳过) —— + if (!c.isFlying) { + if (groundPoint) { + const snapY = groundPoint.y + c.snapH; + const dist = c.playerCapsule.position.y - groundPoint.y; + if (dist > c.maxH) { + c.applyGravity(delta); // 离地过远当坠落 + } else if (c.playerVelocity.y <= 0) { + if (c.playerIsOnGround) { + c.snapToGround( + snapY, + usedShapeGround + ? this.shapeGroundNormal.y >= c.minFloorNormalY + : dynamicHit + ? dynamicHit.normal.y >= c.minFloorNormalY + : c.isFlatFloor(bestHit!), + delta, + ); + } else { + // 下落中:本帧会落到地面则贴地,否则继续重力 + const predictedY = c.playerCapsule.position.y + c.playerVelocity.y * delta; + if (predictedY <= snapY) c.snapToGround(snapY); + else c.applyGravity(delta); + } + } else { + c.applyGravity(delta); // 上升(跳跃) + } + } else { + c.applyGravity(delta); + } + c.playerCapsule.position.y += c.playerVelocity.y * delta; + } + + c.activeDynamicBody = c.playerIsOnGround ? dynamicHit?.body ?? null : null; + c.activeKinematicCollider = c.playerIsOnGround && !c.activeDynamicBody ? hitEntry : null; + this.setDynamicSupport(c.activeDynamicBody); + + // —— 水平(飞行则含竖直)分步移动 + 胶囊推出 —— + const capsuleInfo = c.playerCapsule.capsuleInfo; + const xzSpeed = Math.hypot(c.playerVelocity.x, c.playerVelocity.z); + const totalDist = c.isFlying ? c.playerVelocity.length() * delta : xzSpeed * delta; + c.xzDir.set(c.playerVelocity.x, c.isFlying ? c.playerVelocity.y : 0, c.playerVelocity.z).normalize(); + const maxStep = capsuleInfo.radius * 0.8; + const steps = Math.min(Math.max(1, Math.ceil(totalDist / maxStep)), c.maxMoveSteps); + const stepDist = totalDist / steps; + const blockedEpsSq = (maxStep * 0.05) ** 2; + for (let i = 0; i < steps; i++) { + c.moveStepOrigin.copy(c.playerCapsule.position); + c.playerCapsule.position.addScaledVector(c.xzDir, stepDist); + c.playerCapsule.updateMatrixWorld(); + + if (!c.skipCapsuleCollision) { + const kinMeshes = c.getKinematicColliderEntries(); + for (const mesh of c.getColliderMeshes()) { + c.playerCapsule.updateMatrixWorld(); + const isKin = kinMeshes.some(e => e.mesh === mesh); + // 飞行用接到脚底的段;步行用悬空段便于上台阶 + applyCapsuleCollision( + c.playerCapsule, + capsuleInfo, + mesh, + isKin ? c.kinematicTemps : c.staticTemps, + undefined, + c.isFlying, + ); + } + c.dynamics.collideWithCapsule(); + + // 本步几乎没走出去:卡住则提前结束剩余子步 + if (c.playerCapsule.position.distanceToSquared(c.moveStepOrigin) < blockedEpsSq) { + break; + } + } + } + + // 站在运动学平台上时跟随平台位移 / 偏航 + if (c.activeKinematicCollider && c.playerIsOnGround && !c.isFlying) { + c.playerCapsule.position.add(c.activeKinematicCollider.deltaPos); + if (c.activeKinematicCollider.deltaRotY !== 0) { + c.playerCapsule.rotateY(c.activeKinematicCollider.deltaRotY); + } + } + + // 在 IK 执行前缓存本帧最终接地结果。 + this.updateGroundSupport(groundPoint, bestHit, dynamicHit, usedShapeGround); + + // 移动结束后刷新调试体,使地面射线/体积传感区与胶囊同步。 + c.playerCapsule.updateMatrixWorld(true); + this.debug.refreshTransforms(); + + // —— 角色朝向(第三人称 / 飞行) —— + if (!c.isFirstPerson) { + const camDirFlat = c.camDir.clone().setY(0).normalize().negate(); + const moveDirFlat = c.moveDir.clone().normalize().negate(); + + if (!c.isFlying) { + if (c.cam.mouseMode === 4 || c.cam.mouseMode === 5) { + c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(camDirFlat), c.playerCapsule.up); + c.playerCapsule.quaternion.copy(c.targetQuat.setFromRotationMatrix(c.targetMat)); + } else if (c.cam.mouseMode === 0 || c.cam.mouseMode === 2) { + const lookTarget = c.playerCapsule.position.clone().add(moveDirFlat.lengthSq() > 0 ? moveDirFlat : camDirFlat); + c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } else if (moveDirFlat.lengthSq() > 0) { + c.targetMat.lookAt(c.playerCapsule.position, c.playerCapsule.position.clone().add(moveDirFlat), c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } + } else { + const lookTarget = c.playerCapsule.position.clone().add(c.input.fwd ? moveDirFlat : camDirFlat); + c.targetMat.lookAt(c.playerCapsule.position, lookTarget, c.playerCapsule.up); + c.playerCapsule.quaternion.slerp(c.targetQuat.setFromRotationMatrix(c.targetMat), Math.min(1, c.rotationSpeed * delta)); + } + } + + // —— 第三人称相机跟随 —— + if (!c.isFirstPerson) { + const lookTarget = c.cam.springTarget(c.cam.getLookAtPoint(), delta); + c.camera.position.sub(c.controls.target); + c.camera.position.add(lookTarget); + c.controls.target.copy(lookTarget); + c.controls.update(); + c.cam.applyFlySprintMaxDist(); + c.cam.updateWithRaycast(c.controls.target); + } + + // 靠近车辆时显示上车按钮 + if (c.isShowMobileControls && c.vehicle.list.length) { + let near = false; + for (const veh of c.vehicle.list) { + if (c.vehicle.isInBoardingRange(veh, c.playerCapsule.position)) { + near = true; + break; + } + } + if (near !== c.isNearVehicle) { + c.isNearVehicle = near; + c.mobileControls?.syncVehicleBtn(near); + } + } + + c.animation.setAnimationByPressed(); + c.runAnimationPass(delta); + } + + /** 将射线或体积探测最终采用的支撑点统一转换成世界空间结果。 */ + private updateGroundSupport( + point: THREE.Vector3 | undefined, + meshHit: THREE.Intersection | undefined, + dynamicHit: DynamicGroundHit | null, + usedShapeGround: boolean, + ): void { + const c = this.ctrl; + if (c.isFlying || !c.playerIsOnGround || !point) { + c.clearGroundSupport(); + return; + } + + if (usedShapeGround) { + // 体积探测已经返回世界空间法线。 + this.rayGroundNormal.copy(this.shapeGroundNormal); + } else if (dynamicHit) { + this.rayGroundNormal.copy(dynamicHit.normal); + } else if (meshHit?.face) { + // 射线命中网格法线转换到世界空间。 + this.rayGroundNormal.copy(meshHit.face.normal) + .applyMatrix3(this.rayGroundNormalMatrix.getNormalMatrix(meshHit.object.matrixWorld)) + .normalize(); + } else { + this.rayGroundNormal.copy(c.upVector); + } + + c.setGroundSupport(point, this.rayGroundNormal); + } + + /** 切换当前动态支撑体并记录位姿。 */ + private setDynamicSupport(body: DynamicBody | null): void { + if (this.dynamicSupportBody === body) return; + this.dynamicSupportBody = body; + if (!body) return; + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + } + + /** 动态支撑体推进后按前后位姿带走人物。 */ + applyDynamicSupportCarry(): void { + const c = this.ctrl; + const body = c.activeDynamicBody; + if (body !== this.dynamicSupportBody) this.setDynamicSupport(body); + if (!body) return; + if (!c.dynamics.list.includes(body)) { + c.activeDynamicBody = null; + this.setDynamicSupport(null); + return; + } + if (c.controllerMode === 1 || c.isFlying || !c.playerIsOnGround) { + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + return; + } + + this.dynamicSupportBefore.copy(c.playerCapsule.position); + this.dynamicSupportInvQuaternion.copy(this.dynamicSupportPrevQuaternion).invert(); + this.dynamicSupportLocal.subVectors( + c.playerCapsule.position, + this.dynamicSupportPrevPosition, + ).applyQuaternion(this.dynamicSupportInvQuaternion); + c.playerCapsule.position.copy(this.dynamicSupportLocal) + .applyQuaternion(body.quaternion) + .add(body.position); + c.playerCapsule.updateMatrixWorld(); + + this.dynamicSupportDelta.subVectors(c.playerCapsule.position, this.dynamicSupportBefore); + if (!c.isFirstPerson && this.dynamicSupportDelta.lengthSq() > 0) { + c.camera.position.add(this.dynamicSupportDelta); + c.controls.target.add(this.dynamicSupportDelta); + } + this.dynamicSupportPrevPosition.copy(body.position); + this.dynamicSupportPrevQuaternion.copy(body.quaternion); + } +} diff --git a/three-player-controller/src/systems/ColliderRegistry.ts b/three-player-controller/src/systems/ColliderRegistry.ts new file mode 100644 index 0000000..307d0fc --- /dev/null +++ b/three-player-controller/src/systems/ColliderRegistry.ts @@ -0,0 +1,488 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import type { ColliderDesc, ColliderHandle, DynamicColliderDesc } from "../collision/colliderDesc"; +import type { MotionType } from "../collision/CollisionWorld"; +import { + attachBoundsTree, + buildKinematicMergedMesh, + buildStaticMergedMesh, +} from "../collision/colliderBuild"; +import type { KinematicColliderEntry } from "../types"; + +let warnedDynamicMesh = false; + +/** + * 统一碰撞体注册:按 ColliderDesc 创建 / 移除,并维护运动学跟随。 + * 静态 / 运动学 mesh 走合并 + BVH;动态球 / 盒交给 DynamicBodySystem。 + */ +export class ColliderRegistry { + private ctrl: playerController; + kinematicColliders: KinematicColliderEntry[] = []; // 需每帧跟随的运动学项 + /** id → handle 元数据 */ + private handles = new Map(); + /** 静态构建代数:异步 BVH 完成时若代数变了则丢弃 */ + private buildGens = new Map(); + private _contentScale = new THREE.Vector3(); + private _contentOffsetMatrix = new THREE.Matrix4(); + /** 静态 / 运动学 mesh 碰撞线框开关。 */ + private debugVisible = false; + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + } + + /** 返回碰撞 mesh 调试开关。 */ + getDebugVisible(): boolean { + return this.debugVisible; + } + + /** 切换静态 / 运动学 mesh 碰撞线框。 */ + setDebugVisible(visible: boolean): void { + this.debugVisible = visible; + this.syncDebugVisibility(); + } + + /** 同步已就绪碰撞 mesh 的调试显隐。 */ + syncDebugVisibility(): void { + const synced = new Set(); + for (const handle of this.handles.values()) { + const mesh = handle.collisionMesh; + if (!mesh || synced.has(mesh)) continue; + synced.add(mesh); + this.syncDebugMesh(mesh); + } + } + + /** 按 desc 创建碰撞体;校验 motion/shape 约束后分派到 mesh/box/sphere。 */ + add(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "static" && (desc as { follow?: unknown }).follow != null) { + throw new Error("static collider 不能带 follow"); + } + if (desc.motion === "dynamic" && (desc as { follow?: unknown }).follow != null) { + throw new Error("dynamic collider 不能带 follow"); + } + if (desc.shape.kind === "mesh") { + const hasSource = "source" in desc.shape && desc.shape.source != null; + const hasMesh = "mesh" in desc.shape && desc.shape.mesh != null; + if (hasSource === hasMesh) { + throw new Error('mesh shape 必须提供 source 或 mesh 之一'); + } + if (desc.motion === "dynamic" && !warnedDynamicMesh) { + warnedDynamicMesh = true; + console.warn( + "[three-player-controller] dynamic + mesh 允许但不推荐:三角网格无体积,易卡顿/穿透;动态物请优先 box/sphere。", + ); + } + } + + switch (desc.shape.kind) { + case "mesh": + return this.addMesh(desc); + case "box": + return this.addBox(desc); + case "sphere": + return this.addSphere(desc); + default: + throw new Error("未知 shape.kind"); + } + } + + /** 按句柄或 id 移除;同步释放 mesh / 动态刚体与 CollisionWorld 登记。 */ + remove(handle: ColliderHandle | number): void { + const id = typeof handle === "number" ? handle : handle.id; + if (id < 0) return; + const meta = this.handles.get(id); + const kin = this.kinematicColliders.find(e => e.worldId === id); + if (kin) { + kin.buildId += 1; // 作废进行中的异步 BVH + this.ctrl.scene.remove(kin.mesh); + kin.mesh.geometry.dispose(); + (kin.mesh.material as THREE.Material).dispose(); + if (this.ctrl.activeKinematicCollider === kin) this.ctrl.activeKinematicCollider = null; + this.kinematicColliders = this.kinematicColliders.filter(e => e !== kin); + } else if (meta?.collisionMesh) { + this.ctrl.scene.remove(meta.collisionMesh); + meta.collisionMesh.geometry.dispose(); + (meta.collisionMesh.material as THREE.Material).dispose(); + } + + // 动态刚体:remove 内部会卸 CollisionWorld + const body = this.ctrl.dynamics.findByColliderId(id); + if (body) { + this.ctrl.dynamics.remove(body); + this.handles.delete(id); + this.buildGens.delete(id); + return; + } + + this.ctrl.collisionWorld.remove(id); + this.handles.delete(id); + this.buildGens.delete(id); + } + + /** 清除碰撞体;可按 motion 过滤。 */ + clear(filter?: { motion?: MotionType }): void { + for (const id of this.ctrl.collisionWorld.idsByMotion(filter?.motion)) { + this.remove(id); + } + } + + /** 按 follow/source Object3D 查找运动学项。 */ + findKinematicBySource(source: THREE.Object3D): KinematicColliderEntry | undefined { + return this.kinematicColliders.find(e => e.source === source); + } + + /** + * 每帧同步运动学碰撞网格到 follow 的 matrixWorld。 + * 若人物站在平台上,用 prev→current 算出本帧应带走的 deltaPos / deltaRotY。 + */ + updateKinematicFollow(): void { + const playerWorldPos = this.ctrl.playerCapsule?.position; + for (const entry of this.kinematicColliders) { + if (playerWorldPos) { + const prevInv = new THREE.Matrix4().copy(entry.prevWorldMatrix).invert(); + const playerInLocal = playerWorldPos.clone().applyMatrix4(prevInv); + this.writeKinematicMeshMatrix(entry); + const playerInNewWorld = playerInLocal.clone().applyMatrix4(entry.mesh.matrixWorld); + entry.deltaPos.subVectors(playerInNewWorld, playerWorldPos); + } else { + this.writeKinematicMeshMatrix(entry); + entry.deltaPos.set(0, 0, 0); + } + const prevEuler = new THREE.Euler().setFromRotationMatrix(entry.prevWorldMatrix, "YXZ"); + const curEuler = new THREE.Euler().setFromRotationMatrix(entry.mesh.matrixWorld, "YXZ"); + entry.deltaRotY = curEuler.y - prevEuler.y; + } + } + + /** 将 follow 世界矩阵(含 contentOffset / bakeInverse / contentScale)写入碰撞 mesh。 */ + private writeKinematicMeshMatrix(entry: KinematicColliderEntry): void { + entry.source.updateMatrixWorld(true); + entry.mesh.matrix.copy(entry.source.matrixWorld); + if (entry.contentOffset.lengthSq() > 0) { + entry.mesh.matrix.multiply( + this._contentOffsetMatrix.makeTranslation( + entry.contentOffset.x, + entry.contentOffset.y, + entry.contentOffset.z, + ), + ); + } + entry.mesh.matrix.multiply(entry.bakeInverse); + const s = entry.contentScale; + if (s !== 1) { + entry.mesh.matrix.scale(this._contentScale.set(s, s, s)); + } + entry.mesh.updateMatrixWorld(true); + } + + /** + * 运行时等比缩放已烘焙的运动学 mesh 碰撞(不重建 BVH)。 + * source 为注册时的 follow / vehicleGroup。 + */ + scaleKinematicContent(source: THREE.Object3D, ratio: number): void { + if (!source || !Number.isFinite(ratio) || ratio === 1) return; + const entry = this.kinematicColliders.find(e => e.source === source); + if (!entry) return; + entry.contentScale *= ratio; + entry.contentOffset.multiplyScalar(ratio); + this.writeKinematicMeshMatrix(entry); + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + + /** 运行时平移已烘焙的运动学 mesh 碰撞,不重建 BVH。 */ + translateKinematicContent(source: THREE.Object3D, localOffset: THREE.Vector3): void { + if (!source || !Number.isFinite(localOffset.lengthSq()) || localOffset.lengthSq() === 0) return; + const entry = this.kinematicColliders.find(e => e.source === source); + if (!entry) return; + entry.contentOffset.add(localOffset); + this.writeKinematicMeshMatrix(entry); + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + + /** 本帧物理用完后再提交平台矩阵,供下一帧算 delta。 */ + commitKinematicPrev(): void { + for (const entry of this.kinematicColliders) { + entry.prevWorldMatrix.copy(entry.mesh.matrixWorld); + } + } + + /** 是否已有至少一份带 BVH 的静态 mesh。 */ + isStaticUsable(): boolean { + for (const col of this.ctrl.collisionWorld.query( + { mask: 0xffff }, + { motion: "static", kinds: ["mesh"] }, + )) { + if (col.shape.kind === "mesh" && (col.shape.mesh.geometry as any).boundsTree) { + return true; + } + } + return false; + } + + /** 运动学项是否已就绪(BVH 可用)。 */ + isKinematicUsable(entry: KinematicColliderEntry): boolean { + return entry.ready && Boolean((entry.mesh.geometry as any).boundsTree); + } + + /** 写入 handles 并返回公开句柄。 */ + private makeHandle( + id: number, + motion: MotionType, + follow: THREE.Object3D | null, + source: THREE.Object3D | null, + collisionMesh: THREE.Mesh | null, + ): ColliderHandle { + const h: ColliderHandle = { id, motion, follow, source, collisionMesh }; + this.handles.set(id, h); + return h; + } + + /** mesh:按 motion 走动态登记 / 运动学 / 静态合并。 */ + private addMesh(desc: ColliderDesc): ColliderHandle { + const shape = desc.shape as Extract; + if (desc.motion === "dynamic") { + // 允许但不推荐:登记已有 mesh,不合并、不建刚体求解 + const mesh = "mesh" in shape && shape.mesh + ? shape.mesh + : (() => { + throw new Error("dynamic + mesh 请传入已有 mesh,或改用 box/sphere"); + })(); + const entry = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "mesh", mesh }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, "dynamic", null, null, mesh); + } + + if (desc.motion === "kinematic") { + return this.addKinematicMesh(desc); + } + + // static:已有 BVH mesh 直接登记,否则合并 source 再挂 BVH + const source = "source" in shape && shape.source + ? shape.source + : null; + const readyMesh = "mesh" in shape && shape.mesh ? shape.mesh : null; + + if (readyMesh) { + const entry = this.ctrl.collisionWorld.add({ + motion: "static", + shape: { kind: "mesh", mesh: readyMesh }, + groups: desc.groups, + mask: desc.mask, + ready: Boolean((readyMesh.geometry as any).boundsTree), + userData: desc.userData, + }); + this.syncDebugMesh(readyMesh); + return this.makeHandle(entry.id, "static", null, null, readyMesh); + } + + const built = buildStaticMergedMesh(source!); + if (!built) { + console.warn("静态 mesh 合并失败:无可用几何"); + return { id: -1, motion: "static", follow: null, source: null, collisionMesh: null }; + } + + const world = this.ctrl.collisionWorld.add({ + motion: "static", + shape: { kind: "mesh", mesh: built.mesh }, + groups: desc.groups, + mask: desc.mask, + ready: false, // BVH 完成前不参与查询 + userData: desc.userData, + }); + const handle = this.makeHandle( + world.id, + "static", + null, + Array.isArray(source) ? (source[0] ?? null) : source, + built.mesh, + ); + this.buildGens.set(world.id, 1); + const gen = 1; + + attachBoundsTree(built.geometry, { + useWorker: desc.useWorker, + workerPool: this.ctrl.getBvhWorkerPool(), + maxDepth: 100, + onStale: () => this.buildGens.get(world.id) !== gen || this.ctrl.collisionWorld.get(world.id) == null, + onReady: () => { + if (this.buildGens.get(world.id) !== gen) return; + this.ctrl.collisionWorld.setReady(world.id, true); + this.syncDebugMesh(built.mesh); + }, + }); + return handle; + } + + /** 运动学 mesh:已有 mesh 或从 source 合并,并加入 kinematicColliders 跟随列表。 */ + private addKinematicMesh(desc: ColliderDesc): ColliderHandle { + const shape = desc.shape as Extract; + const follow = + ("follow" in desc ? desc.follow : undefined) + ?? (("source" in shape && !Array.isArray(shape.source)) ? shape.source : undefined) + ?? null; + + if ("mesh" in shape && shape.mesh) { + const mesh = shape.mesh; + mesh.matrixAutoUpdate = false; + if (follow) { + follow.updateMatrixWorld(true); + mesh.matrix.copy(follow.matrixWorld); + mesh.updateMatrixWorld(true); + } + const entry: KinematicColliderEntry = { + source: follow ?? mesh, + mesh, + contentScale: 1, + contentOffset: new THREE.Vector3(), + bakeInverse: new THREE.Matrix4(), + prevWorldMatrix: new THREE.Matrix4().copy(follow?.matrixWorld ?? mesh.matrixWorld), + deltaPos: new THREE.Vector3(), + deltaRotY: 0, + ready: Boolean((mesh.geometry as any).boundsTree), + buildId: 1, + worldId: 0, + }; + const world = this.ctrl.collisionWorld.add({ + motion: "kinematic", + shape: { kind: "mesh", mesh }, + groups: desc.groups, + mask: desc.mask, + ready: entry.ready, + userData: entry, + }); + entry.worldId = world.id; + this.kinematicColliders.push(entry); + this.syncDebugMesh(mesh); + return this.makeHandle(world.id, "kinematic", follow, follow, mesh); + } + + const sourceObj = shape.source; + const root = Array.isArray(sourceObj) ? sourceObj[0] : sourceObj; + if (!root) throw new Error("kinematic mesh 需要 source"); + + // 同一 follow/source 已注册则复用,避免重复合并 + const existing = this.kinematicColliders.find(e => e.source === (follow ?? root)); + if (existing) { + return this.handles.get(existing.worldId) + ?? this.makeHandle(existing.worldId, "kinematic", follow ?? root, root, existing.mesh); + } + + const built = buildKinematicMergedMesh(root); + if (!built) { + console.warn("运动学 mesh 合并失败:无可用几何"); + return { id: -1, motion: "kinematic", follow: follow ?? root, source: root, collisionMesh: null }; + } + + const followTarget = follow ?? root; + followTarget.updateMatrixWorld(true); + // verts 已在世界空间收心 + const bakeInverse = new THREE.Matrix4() + .copy(followTarget.matrixWorld) + .invert() + .multiply(built.mesh.matrix); + const entry: KinematicColliderEntry = { + source: followTarget, + mesh: built.mesh, + contentScale: 1, + contentOffset: new THREE.Vector3(), + bakeInverse, + prevWorldMatrix: new THREE.Matrix4().copy(built.mesh.matrixWorld), + deltaPos: new THREE.Vector3(), + deltaRotY: 0, + ready: !desc.useWorker, // Worker 时先不可用,onReady 再打开 + buildId: 1, + worldId: 0, + }; + const world = this.ctrl.collisionWorld.add({ + motion: "kinematic", + shape: { kind: "mesh", mesh: built.mesh }, + groups: desc.groups, + mask: desc.mask, + ready: entry.ready, + userData: entry, + }); + entry.worldId = world.id; + this.kinematicColliders.push(entry); + const handle = this.makeHandle(world.id, "kinematic", followTarget, root, built.mesh); + const buildId = entry.buildId; + + attachBoundsTree(built.geometry, { + useWorker: desc.useWorker, + workerPool: this.ctrl.getBvhWorkerPool(), + onStale: () => entry.buildId !== buildId || !this.kinematicColliders.includes(entry), + onReady: () => { + if (entry.buildId !== buildId || !this.kinematicColliders.includes(entry)) return; + entry.ready = true; + this.ctrl.collisionWorld.setReady(entry.worldId, true); + this.syncDebugMesh(built.mesh); + }, + }); + return handle; + } + + /** 按当前开关同步单个碰撞 mesh。 */ + private syncDebugMesh(mesh: THREE.Mesh): void { + if (this.debugVisible && (mesh.geometry as any).boundsTree) { + if (!this.ctrl.scene.children.includes(mesh)) this.ctrl.scene.add(mesh); + } else { + this.ctrl.scene.remove(mesh); + } + } + + /** + * box: + * - dynamic 且 simulate 默认 true → 创建 DynamicBoxBody + * - dynamic 且 simulate: false → 仅登记(车辆底盘) + * - static / kinematic → 仅登记 + */ + private addBox(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "dynamic" && (desc as DynamicColliderDesc).simulate !== false) { + const body = this.ctrl.dynamics.addBox(desc as DynamicColliderDesc); + if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups); + if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask); + if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData); + return this.makeHandle(body.colliderId, "dynamic", null, null, null); + } + + const shape = desc.shape as Extract; + const half = shape.halfExtents.clone(); + const entry = this.ctrl.collisionWorld.add({ + motion: desc.motion, + shape: { kind: "box", halfExtents: half }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, desc.motion, null, null, null); + } + + /** + * sphere:dynamic 时创建 DynamicSphereBody; + * static/kinematic 仅登记形状(供查询矩阵,无球刚体步进)。 + */ + private addSphere(desc: ColliderDesc): ColliderHandle { + if (desc.motion === "dynamic") { + const body = this.ctrl.dynamics.addSphere(desc as DynamicColliderDesc); + if (desc.groups != null) this.ctrl.collisionWorld.setGroups(body.colliderId, desc.groups); + if (desc.mask != null) this.ctrl.collisionWorld.setMask(body.colliderId, desc.mask); + if (desc.userData != null) this.ctrl.collisionWorld.setUserData(body.colliderId, desc.userData); + return this.makeHandle(body.colliderId, "dynamic", null, null, null); + } + + const shape = desc.shape as Extract; + const entry = this.ctrl.collisionWorld.add({ + motion: desc.motion, + shape: { kind: "sphere", radius: shape.radius }, + groups: desc.groups, + mask: desc.mask, + userData: desc.userData, + }); + return this.makeHandle(entry.id, desc.motion, null, null, null); + } +} diff --git a/three-player-controller/src/systems/DynamicBodySystem.ts b/three-player-controller/src/systems/DynamicBodySystem.ts new file mode 100644 index 0000000..6403eb6 --- /dev/null +++ b/three-player-controller/src/systems/DynamicBodySystem.ts @@ -0,0 +1,1270 @@ +import * as THREE from "three"; +import { ExtendedTriangle } from "three-mesh-bvh"; +import type { playerController } from "../PlayerController"; +import { CollisionGroup } from "../collision/groups"; +import { ContactCache } from "../collision/contacts/ContactCache"; +import { ContactManifold } from "../collision/contacts/ContactManifold"; +import { contactSkinForExtent, type RawContact } from "../collision/contacts/ContactPoint"; +import { reduceContacts } from "../collision/contacts/contactReducer"; +import { ContactImpulseSolver } from "../collision/solver/ContactImpulseSolver"; +import { DynamicBody, type DynamicBodyKind } from "../collision/DynamicBody"; +import { + collectSphereVsMeshContacts, + collideSphereVsObb, + createSphereCollisionTemps, + resolveSphereSphere, + DynamicSphereBody, + isSphereBody, +} from "../collision/DynamicSphere"; +import { + capsuleObbOverlap, + DynamicBoxBody, + isBoxBody, +} from "../collision/DynamicBox"; +import { DYNAMIC_BODY_DEFAULTS, type DynamicColliderDesc } from "../collision/colliderDesc"; +import type { VehicleInstance, VehicleWheelColliderUserData } from "../types"; +import { + capsuleSphereOverlap, + createCollisionTemps, + createGroundProbeQuery, + resetGroundProbeQuery, + tryGroundProbeCandidate, + tryGroundProbeTriangle, +} from "../utils/capsuleCollision"; +import { VehicleCollision } from "../utils/vehiclePhysics/VehicleCollision"; +import { createObbObbTemps, resolveObbObb } from "../collision/obbObb"; + +const TARGET_SUB_DT = 1 / 1200; // 目标子步时长(秒) +const MAX_SUBSTEPS = 20; // 单帧最多子步数 +const MAX_PUSH_RATIO = 2; // 单次位置修正不超过特征尺寸的倍数 +const MAX_SPEED_GRAVITY = 2; // 速度上限相对 |gravity| 的倍数 +const CHARACTER_PUSH_MASS = 10; // 推动态体时用的人物假质量(scale = 1 时)。 +const MAX_RAW = 64; // 单体单帧原始接触上限 +const POS_CORRECT = 0.4; // 求解后残留穿透的位置修正比例 +const MAX_DEPENETRATION_EXTENTS = 20; // 位置修正每秒最多推出的特征尺寸倍数 +const ROLL_BLEND = 0.45; // 支撑面上向无滑滚动收敛的插值比例 +const SUPPORT_Y = 0.5; // 视为支撑面的法线 y 下限 +const DYNAMIC_MASK = CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS; +const KIN_CAPSULE_BAUMGARTE = 0.8; // 动态体撞运动学胶囊时的位置修正比例 +/** 人物单次相对动态体的最大位置修正(相对胶囊半径)。 */ +const CHAR_SEP_MAX_RADIUS = 1.25; +const SLEEP_LINEAR = 0.3; // 线速度低于该值可累计休眠 +const SLEEP_ANGULAR = 0.7; // 角速度低于该值可累计休眠 +const SLEEP_TIME = 0.35; // 连续满足阈值后进入休眠(秒) +const SLEEP_WAKE_FACTOR = 3; // 超过阈值该倍数才清零休眠计时(迟滞) +const BROADPHASE_CELL_SCALE = 2; // 动态体互撞宽相位:XZ 网格单元 = 最大特征尺寸 × 该系数(下限 1)。 +const BOX_TRIANGLES = [ + 0, 2, 3, 0, 3, 1, + 4, 5, 7, 4, 7, 6, + 0, 4, 6, 0, 6, 2, + 1, 3, 7, 1, 7, 5, + 0, 1, 5, 0, 5, 4, + 2, 6, 7, 2, 7, 3, +] as const; + +/** 人物向下查询命中的动态支撑面。 */ +export type DynamicGroundHit = { + body: DynamicBody; + point: THREE.Vector3; + normal: THREE.Vector3; + velocity: THREE.Vector3; +}; + +/** + * 动态刚体推进:按 kind 分派形状窄相(球 / 盒)。 + * 网格接触走 ContactImpulseSolver;球–球 / 盒–盒 / 与人 / 与车用简化或 OBB 冲量。 + */ +export class DynamicBodySystem { + private ctrl: playerController; + list: DynamicBody[] = []; // 全部动态刚体 + /** 动态刚体碰撞线框开关。 */ + private debugVisible = false; + private temps = createSphereCollisionTemps(); + private carryPrevInv = new THREE.Matrix4(); + private skipVehicleMeshes: number[] = []; // 跳过车辆外观,避免与底盘盒重复 + private capsuleTemps = createCollisionTemps(); + private contactSolver = new ContactImpulseSolver(); + private contactCaches = new Map(); + private raw: RawContact[] = Array.from({ length: MAX_RAW }, () => ({ + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + penetration: 0, + })); + private manifolds: ContactManifold[] = []; + private bodyVel = new THREE.Vector3(); + private rollTarget = new THREE.Vector3(); + private boxMeshCollision = new VehicleCollision(); + private obbTemps = createObbObbTemps(); + private contactPoint = new THREE.Vector3(); + private wheelCenter = new THREE.Vector3(); + private wheelDir = new THREE.Vector3(); + private wheelPosBefore = new THREE.Vector3(); + /** 动态体互撞宽相位(XZ 网格,每子步重建)。 */ + private broadphaseGrid = new Map(); + private broadphaseCellSize = 1; + private bodyAabbMin: THREE.Vector3[] = []; + private bodyAabbMax: THREE.Vector3[] = []; + private broadphaseAxisX = new THREE.Vector3(); + private broadphaseAxisY = new THREE.Vector3(); + private broadphaseAxisZ = new THREE.Vector3(); + private broadphasePairSeen = new Set(); + private groundRay = new THREE.Ray(); + private groundBox = new THREE.Box3(); + private groundSphere = new THREE.Sphere(); + private groundOriginLocal = new THREE.Vector3(); + private groundDirectionLocal = new THREE.Vector3(); + private groundPointLocal = new THREE.Vector3(); + private groundNormalLocal = new THREE.Vector3(); + private groundInvQuat = new THREE.Quaternion(); + private groundPoint = new THREE.Vector3(); + private groundNormal = new THREE.Vector3(); + private groundVolumeQuery = createGroundProbeQuery(); + private groundProbeSegment = new THREE.Line3(); + private groundProbeTriangle = new ExtendedTriangle(); + private groundProbeTrianglePoint = new THREE.Vector3(); + private groundProbeSegmentPoint = new THREE.Vector3(); + private groundProbeSphereDelta = new THREE.Vector3(); + private groundProbeSphereHorizontal = new THREE.Vector3(); + private groundProbeBoxVertices = Array.from({ length: 8 }, () => new THREE.Vector3()); + private groundHit: DynamicGroundHit = { + body: null as unknown as DynamicBody, + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + velocity: new THREE.Vector3(), + }; + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + this.contactSolver.velocityIterations = 12; + } + + /** 返回动态刚体调试开关。 */ + getDebugVisible(): boolean { + return this.debugVisible; + } + + /** 切换动态刚体碰撞线框。 */ + setDebugVisible(visible: boolean): void { + this.debugVisible = visible; + this.syncDebugVisibility(); + } + + /** 同步全部动态刚体碰撞线框。 */ + syncDebugVisibility(): void { + for (const body of this.list) { + this.syncDebugMesh(body); + if (this.debugVisible) { + if (!this.ctrl.scene.children.includes(body.debugMesh)) this.ctrl.scene.add(body.debugMesh); + } else { + this.ctrl.scene.remove(body.debugMesh); + } + } + } + + /** 当前人物推动假质量:CHARACTER_PUSH_MASS × scale。 */ + private characterPushMass(): number { + const s = this.ctrl.playerModelConfig.scale; + return CHARACTER_PUSH_MASS * s; + } + + /** 添加动态球。 */ + addSphere(desc: DynamicColliderDesc): DynamicSphereBody { + if (desc.shape.kind !== "sphere") { + throw new Error("[DynamicBodySystem] addSphere 需要 shape.kind === \"sphere\""); + } + const mesh = desc.mesh; + if (!mesh) { + throw new Error("[DynamicBodySystem] addSphere 需要 mesh"); + } + + const radius = Math.max(1e-4, desc.shape.radius); + const position = (desc.shape.position ?? new THREE.Vector3()).clone(); + const velocity = (desc.velocity ?? new THREE.Vector3()).clone(); + const density = desc.density ?? DYNAMIC_BODY_DEFAULTS.density; + const restitution = desc.restitution ?? DYNAMIC_BODY_DEFAULTS.restitution; + const friction = desc.friction ?? DYNAMIC_BODY_DEFAULTS.friction; + const linearDamping = desc.linearDamping ?? DYNAMIC_BODY_DEFAULTS.linearDamping; + const angularDamping = desc.angularDamping ?? DYNAMIC_BODY_DEFAULTS.angularDamping; + const gravity = desc.gravity ?? this.ctrl.gravity; + + mesh.scale.setScalar(radius); + mesh.position.copy(position); + + const debugMesh = new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 12), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.35 }), + ); + debugMesh.scale.setScalar(radius); + debugMesh.position.copy(position); + + const body = new DynamicSphereBody({ + radius, + density, + restitution, + gravity, + position, + velocity, + angularVelocity: desc.angularVelocity, + linearDamping, + angularDamping, + friction, + mesh, + debugMesh, + }); + const col = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "sphere", radius }, + mask: DYNAMIC_MASK, + userData: body, + }); + body.colliderId = col.id; + this.list.push(body); + this.contactCaches.set(body, new ContactCache()); + if (this.debugVisible) this.ctrl.scene.add(debugMesh); + return body; + } + + /** 添加动态盒。 */ + addBox(desc: DynamicColliderDesc): DynamicBoxBody { + if (desc.shape.kind !== "box") { + throw new Error("[DynamicBodySystem] addBox 需要 shape.kind === \"box\""); + } + const mesh = desc.mesh; + if (!mesh) { + throw new Error("[DynamicBodySystem] addBox 需要 mesh"); + } + + const half = desc.shape.halfExtents.clone(); + half.x = Math.max(1e-4, half.x); + half.y = Math.max(1e-4, half.y); + half.z = Math.max(1e-4, half.z); + const position = (desc.shape.position ?? new THREE.Vector3()).clone(); + const velocity = (desc.velocity ?? new THREE.Vector3()).clone(); + const density = desc.density ?? DYNAMIC_BODY_DEFAULTS.density; + const restitution = desc.restitution ?? DYNAMIC_BODY_DEFAULTS.restitution; + const friction = desc.friction ?? DYNAMIC_BODY_DEFAULTS.friction; + const linearDamping = desc.linearDamping ?? DYNAMIC_BODY_DEFAULTS.linearDamping; + const angularDamping = desc.angularDamping ?? DYNAMIC_BODY_DEFAULTS.angularDamping; + const gravity = desc.gravity ?? this.ctrl.gravity; + + mesh.scale.set(half.x * 2, half.y * 2, half.z * 2); + mesh.position.copy(position); + if (desc.shape.quaternion) mesh.quaternion.copy(desc.shape.quaternion); + + const debugMesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.35 }), + ); + debugMesh.scale.copy(mesh.scale); + debugMesh.position.copy(position); + if (desc.shape.quaternion) debugMesh.quaternion.copy(desc.shape.quaternion); + + const body = new DynamicBoxBody({ + halfExtents: half, + density, + restitution, + gravity, + position, + quaternion: desc.shape.quaternion, + velocity, + angularVelocity: desc.angularVelocity, + linearDamping, + angularDamping, + friction, + mesh, + debugMesh, + }); + const col = this.ctrl.collisionWorld.add({ + motion: "dynamic", + shape: { kind: "box", halfExtents: half.clone() }, + mask: DYNAMIC_MASK, + userData: body, + }); + body.colliderId = col.id; + this.list.push(body); + this.contactCaches.set(body, new ContactCache()); + if (this.debugVisible) this.ctrl.scene.add(debugMesh); + return body; + } + + /** 移除动态刚体。 */ + remove(body: DynamicBody): void { + const idx = this.list.indexOf(body); + if (idx === -1) return; + this.list.splice(idx, 1); + this.contactCaches.delete(body); + if (this.ctrl.activeDynamicBody === body) this.ctrl.activeDynamicBody = null; + this.ctrl.collisionWorld.remove(body.colliderId); + this.ctrl.scene.remove(body.debugMesh); + body.debugMesh.geometry.dispose(); + (body.debugMesh.material as THREE.Material).dispose(); + } + + /** 按 CollisionWorld id 查找动态刚体。 */ + findByColliderId(id: number): DynamicBody | undefined { + return this.list.find(b => b.colliderId === id); + } + + /** 清除全部动态刚体。 */ + clear(): void { + while (this.list.length) this.remove(this.list[this.list.length - 1]); + } + + /** 从指定世界坐标向下查询最高的动态表面。 */ + raycastGround( + origin: THREE.Vector3, + minNormalY = SUPPORT_Y, + excludeKinds?: readonly DynamicBodyKind[], + ): DynamicGroundHit | null { + let bestY = -Infinity; + let bestBody: DynamicBody | null = null; + + for (const body of this.list) { + if (excludeKinds?.includes(body.kind)) continue; + let hit = false; + if (isBoxBody(body)) hit = this.raycastBoxGround(origin, body, minNormalY); + else if (isSphereBody(body)) hit = this.raycastSphereGround(origin, body, minNormalY); + if (!hit || this.groundPoint.y <= bestY) continue; + bestY = this.groundPoint.y; + bestBody = body; + this.groundHit.point.copy(this.groundPoint); + this.groundHit.normal.copy(this.groundNormal); + } + + if (!bestBody) return null; + this.groundHit.body = bestBody; + bestBody.getVelocityAtPoint(this.groundHit.point, this.groundHit.velocity); + return this.groundHit; + } + + /** 用带半径的竖直传感区查询最近的动态支撑面。 */ + probeGroundVolume( + sensorStart: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + sensorRadius: number, + minNormalY = SUPPORT_Y, + ): DynamicGroundHit | null { + if (maxDistance <= 0 || sensorRadius <= 0 || down.lengthSq() === 0) return null; + + // 所有动态形状写入同一个查询状态。 + resetGroundProbeQuery( + this.groundVolumeQuery, + sensorStart, + down, + maxDistance, + sensorRadius, + minNormalY, + ); + this.groundProbeSegment.start.copy(this.groundVolumeQuery.start); + this.groundProbeSegment.end.copy(this.groundVolumeQuery.start) + .addScaledVector(this.groundVolumeQuery.down, this.groundVolumeQuery.maxDistance); + + let bestBody: DynamicBody | null = null; + for (const body of this.list) { + // 各形状只负责生成表面候选点;共用筛选器负责决定是否更新最近命中。 + const improved = isBoxBody(body) + ? this.probeBoxGroundVolume(body) + : isSphereBody(body) + ? this.probeSphereGroundVolume(body) + : false; + if (improved) bestBody = body; + } + + if (!bestBody) return null; + this.groundHit.point.copy(this.groundVolumeQuery.hit.point); + this.groundHit.normal.copy(this.groundVolumeQuery.hit.normal); + this.groundHit.body = bestBody; + bestBody.getVelocityAtPoint(this.groundHit.point, this.groundHit.velocity); + return this.groundHit; + } + + /** 圆柱传感区与动态 OBB 的表面三角形查询。 */ + private probeBoxGroundVolume(body: DynamicBoxBody): boolean { + const half = body.halfExtents; + // 碰撞盒只保存半尺寸和位姿;先把 8 个局部角点转换到世界空间。 + for (let i = 0; i < this.groundProbeBoxVertices.length; i++) { + this.groundProbeBoxVertices[i] + .set( + (i & 1) === 0 ? -half.x : half.x, + (i & 2) === 0 ? -half.y : half.y, + (i & 4) === 0 ? -half.z : half.z, + ) + .applyQuaternion(body.quaternion) + .add(body.position); + } + + let improved = false; + for (let i = 0; i < BOX_TRIANGLES.length; i += 3) { + // 将 OBB 的 6 个面拆成 12 个三角形,与 BVH 网格复用相同的候选点语义。 + this.groundProbeTriangle.set( + this.groundProbeBoxVertices[BOX_TRIANGLES[i]], + this.groundProbeBoxVertices[BOX_TRIANGLES[i + 1]], + this.groundProbeBoxVertices[BOX_TRIANGLES[i + 2]], + ); + if (tryGroundProbeTriangle( + this.groundVolumeQuery, + this.groundProbeSegment, + this.groundProbeTriangle, + this.groundProbeTrianglePoint, + this.groundProbeSegmentPoint, + this.groundNormal, + )) improved = true; + } + return improved; + } + + /** 圆柱传感区与动态球的解析查询。 */ + private probeSphereGroundVolume(body: DynamicSphereBody): boolean { + const query = this.groundVolumeQuery; + this.groundProbeSphereDelta.subVectors(body.position, query.start); + const centerDistance = this.groundProbeSphereDelta.dot(query.down); + this.groundProbeSphereHorizontal.copy(this.groundProbeSphereDelta) + .addScaledVector(query.down, -centerDistance); + + // 在探测圆柱的圆形截面内,选择横向最靠近球心的位置。 + const horizontalDistance = this.groundProbeSphereHorizontal.length(); + const surfaceOffset = Math.max(0, horizontalDistance - query.radius); + if (surfaceOffset > body.radius) return false; + + const upwardExtent = Math.sqrt(Math.max(0, body.radius * body.radius - surfaceOffset * surfaceOffset)); + this.groundPoint.copy(body.position); + if (horizontalDistance > 1e-8) { + this.groundPoint.addScaledVector( + this.groundProbeSphereHorizontal, + -surfaceOffset / horizontalDistance, + ); + } + this.groundPoint.addScaledVector(query.down, -upwardExtent); + this.groundNormal.subVectors(this.groundPoint, body.position).normalize(); + + // 球只负责解析求出点和法线,其余有效性检查仍交给共用筛选器。 + return tryGroundProbeCandidate(query, this.groundPoint, this.groundNormal); + } + + /** 向下射线与动态 OBB 求交。 */ + private raycastBoxGround(origin: THREE.Vector3, body: DynamicBoxBody, minNormalY: number): boolean { + this.groundInvQuat.copy(body.quaternion).invert(); + this.groundOriginLocal.copy(origin).sub(body.position).applyQuaternion(this.groundInvQuat); + this.groundDirectionLocal.set(0, -1, 0).applyQuaternion(this.groundInvQuat).normalize(); + this.groundRay.set(this.groundOriginLocal, this.groundDirectionLocal); + this.groundBox.min.copy(body.halfExtents).multiplyScalar(-1); + this.groundBox.max.copy(body.halfExtents); + if (!this.groundRay.intersectBox(this.groundBox, this.groundPointLocal)) return false; + + const dx = Math.abs(Math.abs(this.groundPointLocal.x) - body.halfExtents.x); + const dy = Math.abs(Math.abs(this.groundPointLocal.y) - body.halfExtents.y); + const dz = Math.abs(Math.abs(this.groundPointLocal.z) - body.halfExtents.z); + if (dx <= dy && dx <= dz) { + this.groundNormalLocal.set(Math.sign(this.groundPointLocal.x || 1), 0, 0); + } else if (dy <= dz) { + this.groundNormalLocal.set(0, Math.sign(this.groundPointLocal.y || 1), 0); + } else { + this.groundNormalLocal.set(0, 0, Math.sign(this.groundPointLocal.z || 1)); + } + + this.groundNormal.copy(this.groundNormalLocal).applyQuaternion(body.quaternion).normalize(); + if (this.groundNormal.y < minNormalY) return false; + this.groundPoint.copy(this.groundPointLocal).applyQuaternion(body.quaternion).add(body.position); + return this.groundPoint.y <= origin.y; + } + + /** 向下射线与动态球求交。 */ + private raycastSphereGround(origin: THREE.Vector3, body: DynamicSphereBody, minNormalY: number): boolean { + this.groundRay.set(origin, this.groundDirectionLocal.set(0, -1, 0)); + this.groundSphere.center.copy(body.position); + this.groundSphere.radius = body.radius; + if (!this.groundRay.intersectSphere(this.groundSphere, this.groundPoint)) return false; + this.groundNormal.subVectors(this.groundPoint, body.position).normalize(); + return this.groundNormal.y >= minNormalY && this.groundPoint.y <= origin.y; + } + + /** 推进全部动态刚体:子步求解 → 平台带走 → 休眠判定 → 同步视觉。 */ + step(delta: number): void { + if (!this.list.length) return; + this.skipVehicleMeshes.length = 0; + for (const v of this.ctrl.vehicle.list) { + if (v.meshColliderId != null) this.skipVehicleMeshes.push(v.meshColliderId); + } + const substeps = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(delta / TARGET_SUB_DT))); + const sub = delta / substeps; + for (let i = 0; i < substeps; i++) this.stepSub(sub); + for (const body of this.list) this.applyKinematicCarry(body); + for (const body of this.list) this.updateSleep(body, delta); + for (const body of this.list) { + body.mesh.position.copy(body.position); + body.mesh.quaternion.copy(body.quaternion); + if (this.debugVisible) this.syncDebugMesh(body); + } + } + + /** 将动态刚体当前形状与位姿写入调试 mesh。 */ + private syncDebugMesh(body: DynamicBody): void { + body.debugMesh.position.copy(body.position); + body.debugMesh.quaternion.copy(body.quaternion); + if (isSphereBody(body)) body.debugMesh.scale.setScalar(body.radius); + else if (isBoxBody(body)) { + body.debugMesh.scale.set( + body.halfExtents.x * 2, + body.halfExtents.y * 2, + body.halfExtents.z * 2, + ); + } + } + + /** 单次子步:重力积分 → 按形状窄相 → 阻尼与限速。 */ + private stepSub(dt: number): void { + for (const body of this.list) { + if (body.sleeping) { + // 休眠体仍检测车辆接触,命中则唤醒并解算 + this.collideSleepingBodyWithVehicles(body); + continue; + } + const col = this.ctrl.collisionWorld.get(body.colliderId); + if (!col) continue; + + body.velocity.y += body.gravity * dt; + body.integrate(dt); + body.contactMesh = null; + + if (isSphereBody(body)) { + this.collideSphereMeshes(body, dt); + this.collideSphereBoxes(body); + this.collideSphereVehicleWheels(body); + this.resolveBodyVsKinematicCapsule(body); // 胶囊=运动学:只推刚体 + } else if (isBoxBody(body)) { + this.collideBoxMeshes(body, dt); + this.collideBoxVehicleBoxes(body); + this.collideBoxVehicleWheels(body); + this.resolveBodyVsKinematicCapsule(body); + } + body.applyDamping(dt); + this.clampSpeed(body); + } + this.rebuildDynamicBroadphase(); + this.collideSpherePairs(); + this.collideBoxPairs(); + for (const body of this.list) { + if (!body.sleeping) this.clampSpeed(body); + } + } + + /** 休眠刚体与车辆底盘 / 车轮球接触检测。 */ + private collideSleepingBodyWithVehicles(body: DynamicBody): void { + if (isBoxBody(body)) { + this.collideBoxVehicleBoxes(body); + this.collideBoxVehicleWheels(body); + } else if (isSphereBody(body)) { + this.collideSphereVehicleBoxes(body); + this.collideSphereVehicleWheels(body); + } + } + + /** 按速度阈值累计 / 清除休眠计时。 */ + private updateSleep(body: DynamicBody, dt: number): void { + if (!body.canSleep) { + if (body.sleeping) body.wakeUp(); + return; + } + if (body.sleeping) return; + + const linSq = body.velocity.lengthSq(); + const angSq = body.angularVelocity.lengthSq(); + const linMax = SLEEP_LINEAR * SLEEP_LINEAR; + const angMax = SLEEP_ANGULAR * SLEEP_ANGULAR; + const wakeLin = linMax * SLEEP_WAKE_FACTOR * SLEEP_WAKE_FACTOR; + const wakeAng = angMax * SLEEP_WAKE_FACTOR * SLEEP_WAKE_FACTOR; + + if (linSq > wakeLin || angSq > wakeAng) { + body.sleepTimer = 0; + return; + } + if (linSq <= linMax && angSq <= angMax) { + body.sleepTimer += dt; + if (body.sleepTimer >= SLEEP_TIME) body.putToSleep(); + } + } + + /** 双方都休眠则跳过;一方休眠时按需唤醒后再解算。 */ + private shouldSolveDynamicPair(a: DynamicBody, b: DynamicBody): boolean { + if (a.sleeping && b.sleeping) return false; + if (a.sleeping || b.sleeping) { + const awake = a.sleeping ? b : a; + const sleeper = a.sleeping ? a : b; + if (awake.isRestingCandidate(SLEEP_LINEAR, SLEEP_ANGULAR)) return false; + sleeper.wakeUp(); + return true; + } + return true; + } + + /** + * 球 vs 静态/平台网格:收集接触 → 过深则硬推 → 否则冲量求解 + 位置修正。 + * 跳过车辆外观 mesh,改由底盘盒处理。 + */ + private collideSphereMeshes(body: DynamicSphereBody, dt: number): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const meshes = this.ctrl.collisionWorld.queryMeshes(worldCol, { skipIds: this.skipVehicleMeshes }); + + let rawCount = 0; + let deepest = 0; + let deepestMesh: THREE.Mesh | null = null; + for (const mesh of meshes) { + const before = rawCount; + rawCount = collectSphereVsMeshContacts( + body.position, body.radius, mesh, this.temps, this.raw, rawCount, MAX_RAW, + ); + for (let i = before; i < rawCount; i++) { + if (this.raw[i].penetration > deepest) { + deepest = this.raw[i].penetration; + deepestMesh = mesh; + } + } + } + if (rawCount === 0) return; + body.contactMesh = deepestMesh; + + // 卡进网格过深时先钳制推出,避免冲量爆炸 + const maxPush = body.radius * MAX_PUSH_RATIO; + if (deepest > maxPush) { + let nx = 0, ny = 0, nz = 0, w = 0; + for (let i = 0; i < rawCount; i++) { + const p = Math.max(this.raw[i].penetration, 0); + nx += this.raw[i].normal.x * p; + ny += this.raw[i].normal.y * p; + nz += this.raw[i].normal.z * p; + w += p; + } + if (w > 1e-8) { + this.temps.normal.set(nx / w, ny / w, nz / w); + if (this.temps.normal.lengthSq() > 1e-8) { + this.temps.normal.normalize(); + body.position.addScaledVector( + this.temps.normal, + Math.min(deepest, maxPush, this.maxPositionCorrection(body, dt)), + ); + } + } + return; + } + + this.solveMeshManifolds(body, rawCount, dt); + this.applySphereSupportRolling(body); + } + + /** 盒 vs 静态/平台网格:复用 VehicleCollision 收集接触后冲量求解。 */ + private collideBoxMeshes(body: DynamicBoxBody, dt: number): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const meshes = this.ctrl.collisionWorld.queryMeshes(worldCol, { skipIds: this.skipVehicleMeshes }); + if (!meshes.length) return; + + this.manifolds.length = 0; + let deepest = 0; + let deepestMesh: THREE.Mesh | null = null; + for (const mesh of meshes) { + const found = this.boxMeshCollision.detect(body, mesh); + if (!found.length) continue; + let meshDeep = 0; + for (const m of found) { + this.manifolds.push(m); + for (const p of m.contacts) { + if (p.penetration > meshDeep) meshDeep = p.penetration; + } + } + if (meshDeep > deepest) { + deepest = meshDeep; + deepestMesh = mesh; + } + } + if (!this.manifolds.length) return; + body.contactMesh = deepestMesh; + + const maxPush = body.characteristicExtent() * MAX_PUSH_RATIO; + if (deepest > maxPush) { + let nx = 0, ny = 0, nz = 0, w = 0; + for (const m of this.manifolds) { + for (const p of m.contacts) { + const pen = Math.max(p.penetration, 0); + nx += m.normal.x * pen; + ny += m.normal.y * pen; + nz += m.normal.z * pen; + w += pen; + } + } + if (w > 1e-8) { + this.temps.normal.set(nx / w, ny / w, nz / w); + if (this.temps.normal.lengthSq() > 1e-8) { + this.temps.normal.normalize(); + body.position.addScaledVector( + this.temps.normal, + Math.min(deepest, maxPush, this.maxPositionCorrection(body, dt)), + ); + } + } + return; + } + + const cache = this.contactCaches.get(body); + cache?.match(this.manifolds); + this.contactSolver.solveVelocity(body, this.manifolds, dt); + cache?.save(this.manifolds); + this.correctMeshPenetration(body, this.manifolds, dt); + } + + /** 球网格接触:归约 → 冲量 → 位置修正。 */ + private solveMeshManifolds(body: DynamicBody, rawCount: number, dt: number): void { + this.manifolds.length = 0; + reduceContacts(this.raw, rawCount, body, this.manifolds); + const cache = this.contactCaches.get(body); + cache?.match(this.manifolds); + this.contactSolver.solveVelocity(body, this.manifolds, dt); + cache?.save(this.manifolds); + this.correctMeshPenetration(body, this.manifolds, dt); + } + + /** 每个流形只按最深接触修正一次位置。 */ + private correctMeshPenetration(body: DynamicBody, manifolds: ContactManifold[], dt: number): void { + const skin = contactSkinForExtent(body.characteristicExtent()); + const maxCorrection = this.maxPositionCorrection(body, dt); + for (const manifold of manifolds) { + let deepest = 0; + for (const point of manifold.contacts) { + if (point.penetration > deepest) deepest = point.penetration; + } + const correction = Math.min((deepest - skin) * POS_CORRECT, maxCorrection); + if (correction > 0) body.position.addScaledVector(manifold.normal, correction); + } + } + + /** 按特征尺寸限制单子步的最大位置修正。 */ + private maxPositionCorrection(body: DynamicBody, dt: number): number { + return body.characteristicExtent() * MAX_DEPENETRATION_EXTENTS * dt; + } + + /** + * 在支撑面上将角速度拉向无滑滚动:ω → n × v_tang / R。 + * 补强摩擦迭代不足时的滚动手感。 + */ + private applySphereSupportRolling(body: DynamicSphereBody): void { + let bestY = SUPPORT_Y; + let support: ContactManifold | null = null; + for (const m of this.manifolds) { + if (m.normal.y > bestY) { + bestY = m.normal.y; + support = m; + } + } + if (!support) return; + + const n = support.normal; + this.temps.offset.copy(body.velocity).addScaledVector(n, -body.velocity.dot(n)); // v_tang + if (this.temps.offset.lengthSq() < 1e-10) return; + this.rollTarget.copy(n).cross(this.temps.offset).multiplyScalar(1 / Math.max(body.radius, 1e-6)); + body.angularVelocity.lerp(this.rollTarget, ROLL_BLEND); + } + + /** 球 vs 车辆底盘 / 动态盒 OBB。 */ + private collideSphereBoxes(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (chassis) { + this.resolveSphereVsObbBody(body, chassis); + continue; + } + const other = box.userData; + if (isBoxBody(other as DynamicBody) && other !== body) { + this.resolveSphereVsObbBody(body, other as DynamicBoxBody); + } + } + } + + /** 球 vs 车辆底盘(休眠体专用,不测其它动态盒)。 */ + private collideSphereVehicleBoxes(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (!chassis) continue; + if (this.resolveSphereVsObbBody(body, chassis)) body.wakeUp(); + } + } + + /** 球与单个 OBB 刚体:推出球并双方在接触点施冲量;有接触返回 true。 */ + private resolveSphereVsObbBody( + body: DynamicSphereBody, + other: { + position: THREE.Vector3; quaternion: THREE.Quaternion; halfExtents: THREE.Vector3; + getVelocityAtPoint(p: THREE.Vector3, o: THREE.Vector3): THREE.Vector3; + applyImpulseAtPoint(i: THREE.Vector3, p: THREE.Vector3): void + }, + ): boolean { + if (!collideSphereVsObb( + body.position, body.radius, + other.position, other.quaternion, other.halfExtents, + this.temps, + )) return false; + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + other.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + this.temps.offset.copy(this.bodyVel).sub(this.temps.boxVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + other.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** 动态盒 vs 车辆底盘 OBB;有接触时唤醒休眠盒。 */ + private collideBoxVehicleBoxes(body: DynamicBoxBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const boxes = this.ctrl.collisionWorld.query(worldCol, { kinds: ["box"] }); + for (const box of boxes) { + if (box.id === body.colliderId) continue; + const vehicle = box.userData as VehicleInstance | undefined; + const chassis = vehicle?.chassisBody; + if (!chassis) continue; + if (resolveObbObb(body, chassis, this.obbTemps)) body.wakeUp(); + } + } + + /** 动态球 vs 车轮运动学球。 */ + private collideSphereVehicleWheels(body: DynamicSphereBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const spheres = this.ctrl.collisionWorld.query(worldCol, { + kinds: ["sphere"], + motion: ["kinematic", "static"], + }); + for (const col of spheres) { + if (col.shape.kind !== "sphere") continue; + const wheel = this.asVehicleWheel(col.userData); + if (!wheel) continue; + if (this.resolveSphereVsVehicleWheel( + body, wheel.vehicle, wheel.wheelIndex, col.shape.radius, + )) { + body.wakeUp(); + } + } + } + + /** 动态盒 vs 车轮运动学球。 */ + private collideBoxVehicleWheels(body: DynamicBoxBody): void { + const worldCol = this.ctrl.collisionWorld.get(body.colliderId); + if (!worldCol) return; + const spheres = this.ctrl.collisionWorld.query(worldCol, { + kinds: ["sphere"], + motion: ["kinematic", "static"], + }); + for (const col of spheres) { + if (col.shape.kind !== "sphere") continue; + const wheel = this.asVehicleWheel(col.userData); + if (!wheel) continue; + if (this.resolveBoxVsVehicleWheel( + body, wheel.vehicle, wheel.wheelIndex, col.shape.radius, + )) { + body.wakeUp(); + } + } + } + + /** 识别车轮球 userData。 */ + private asVehicleWheel(userData: unknown): VehicleWheelColliderUserData | null { + if (!userData || typeof userData !== "object") return null; + const data = userData as VehicleWheelColliderUserData; + if (!data.vehicle?.chassisBody || typeof data.wheelIndex !== "number") return null; + return data; + } + + /** + * 由底盘位姿 + 悬挂长度推算轮心。 + * 成功时写入 wheelCenter,返回实际使用的半径。 + */ + private getWheelCenterWS( + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): number | null { + const chassis = vehicle.chassisBody; + const wheel = vehicle.vehicleController.wheelAt(wheelIndex); + if (!wheel) return null; + this.wheelCenter.copy(wheel.connectionPoint).applyQuaternion(chassis.quaternion).add(chassis.position); + this.wheelDir.copy(wheel.direction).applyQuaternion(chassis.quaternion); + const dirLen = this.wheelDir.length(); + if (dirLen > 1e-8) this.wheelCenter.addScaledVector(this.wheelDir, wheel.suspensionLength / dirLen); + return Math.max(1e-4, registeredRadius > 0 ? registeredRadius : wheel.radius); + } + + /** + * 动态球 vs 单轮:只推开球,冲量回写底盘。 + * 法线由轮心指向球。 + */ + private resolveSphereVsVehicleWheel( + body: DynamicSphereBody, + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): boolean { + const chassis = vehicle.chassisBody; + const radius = this.getWheelCenterWS(vehicle, wheelIndex, registeredRadius); + if (radius == null) return false; + + this.temps.offset.subVectors(body.position, this.wheelCenter); + const dist = this.temps.offset.length(); + const minDist = body.radius + radius; + if (dist >= minDist) return false; + if (dist > 1e-8) this.temps.normal.copy(this.temps.offset).multiplyScalar(1 / dist); + else this.temps.normal.set(0, 1, 0); + + const depth = minDist - dist; + body.position.addScaledVector(this.temps.normal, depth); + this.temps.contact.copy(this.wheelCenter).addScaledVector(this.temps.normal, radius); + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + chassis.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + this.temps.offset.copy(this.bodyVel).sub(this.temps.boxVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + chassis.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** + * 动态盒 vs 单轮:用球-OBB 测重叠,将本应推开轮球的位移反加到盒上,冲量回写底盘。 + * collideSphereVsObb 的法线由盒指向轮。 + */ + private resolveBoxVsVehicleWheel( + body: DynamicBoxBody, + vehicle: VehicleInstance, + wheelIndex: number, + registeredRadius: number, + ): boolean { + const chassis = vehicle.chassisBody; + const radius = this.getWheelCenterWS(vehicle, wheelIndex, registeredRadius); + if (radius == null) return false; + + this.wheelPosBefore.copy(this.wheelCenter); + if (!collideSphereVsObb( + this.wheelCenter, radius, + body.position, body.quaternion, body.halfExtents, + this.temps, + )) { + this.wheelCenter.copy(this.wheelPosBefore); + return false; + } + + // 轮球被推出的位移改为推开动态盒 + this.temps.offset.subVectors(this.wheelCenter, this.wheelPosBefore); + body.position.sub(this.temps.offset); + this.wheelCenter.copy(this.wheelPosBefore); + + body.getVelocityAtPoint(this.temps.contact, this.bodyVel); + chassis.getVelocityAtPoint(this.temps.contact, this.temps.boxVel); + // 将轮视为「球」、盒视为 OBB:相对速度 = 轮速 - 盒速 + this.temps.offset.copy(this.temps.boxVel).sub(this.bodyVel); + const vn = this.temps.offset.dot(this.temps.normal); + if (vn >= 0) return true; + const deltaVn = -vn * (1 + body.restitution); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVn * body.mass); + // 法线由盒→轮:冲量加到底盘(轮侧),反向加到盒 + chassis.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + this.temps.impulse.multiplyScalar(-1); + body.applyImpulseAtPoint(this.temps.impulse, this.temps.contact); + return true; + } + + /** 球与球两两求解(宽相位筛对后窄相)。 */ + private collideSpherePairs(): void { + for (const [i, j] of this.iterDynamicBroadphasePairs()) { + const a = this.list[i]; + const b = this.list[j]; + if (!isSphereBody(a) || !isSphereBody(b)) continue; + if (!this.shouldSolveDynamicPair(a, b)) continue; + resolveSphereSphere(a, b, this.temps, 0.5 * (a.restitution + b.restitution)); + } + } + + /** 盒与盒两两 OBB 求解(宽相位筛对后窄相)。 */ + private collideBoxPairs(): void { + for (const [i, j] of this.iterDynamicBroadphasePairs()) { + const a = this.list[i]; + const b = this.list[j]; + if (!isBoxBody(a) || !isBoxBody(b)) continue; + if (!this.shouldSolveDynamicPair(a, b)) continue; + resolveObbObb(a, b, this.obbTemps); + } + } + + /** 重建 XZ 网格宽相位;单元尺寸随最大特征体自适应。 */ + private rebuildDynamicBroadphase(): void { + const n = this.list.length; + this.broadphaseGrid.clear(); + if (n === 0) return; + + while (this.bodyAabbMin.length < n) { + this.bodyAabbMin.push(new THREE.Vector3()); + this.bodyAabbMax.push(new THREE.Vector3()); + } + + let maxExtent = 0.5; + for (let i = 0; i < n; i++) { + maxExtent = Math.max(maxExtent, this.list[i].characteristicExtent()); + this.setBodyAabb(this.list[i], this.bodyAabbMin[i], this.bodyAabbMax[i]); + } + this.broadphaseCellSize = Math.max(1, maxExtent * BROADPHASE_CELL_SCALE); + const invCell = 1 / this.broadphaseCellSize; + + for (let i = 0; i < n; i++) { + const min = this.bodyAabbMin[i]; + const max = this.bodyAabbMax[i]; + const ix0 = Math.floor(min.x * invCell); + const ix1 = Math.floor(max.x * invCell); + const iz0 = Math.floor(min.z * invCell); + const iz1 = Math.floor(max.z * invCell); + for (let ix = ix0; ix <= ix1; ix++) { + for (let iz = iz0; iz <= iz1; iz++) { + const key = `${ix},${iz}`; + let bucket = this.broadphaseGrid.get(key); + if (!bucket) { + bucket = []; + this.broadphaseGrid.set(key, bucket); + } + bucket.push(i); + } + } + } + } + + /** 遍历宽相位候选对(同格 + 相邻格,AABB 重叠,i < j 去重)。 */ + private *iterDynamicBroadphasePairs(): Generator<[number, number]> { + const n = this.list.length; + if (n < 2) return; + this.broadphasePairSeen.clear(); + + for (const [key, bucketA] of this.broadphaseGrid) { + const comma = key.indexOf(","); + const ix = Number(key.slice(0, comma)); + const iz = Number(key.slice(comma + 1)); + for (let dz = 0; dz <= 1; dz++) { + for (let dx = 0; dx <= 1; dx++) { + const bucketB = this.broadphaseGrid.get(`${ix + dx},${iz + dz}`); + if (!bucketB) continue; + for (const i of bucketA) { + for (const j of bucketB) { + if (i >= j) continue; + const pairKey = i * n + j; + if (this.broadphasePairSeen.has(pairKey)) continue; + this.broadphasePairSeen.add(pairKey); + if (!this.bodyAabbOverlap(i, j)) continue; + yield [i, j]; + } + } + } + } + } + } + + /** 写入刚体世界 AABB;盒体将 OBB 三轴投影后取包络(宽相位粗测用,偏保守)。 */ + private setBodyAabb(body: DynamicBody, min: THREE.Vector3, max: THREE.Vector3): void { + if (isSphereBody(body)) { + const r = body.radius; + min.set(body.position.x - r, body.position.y - r, body.position.z - r); + max.set(body.position.x + r, body.position.y + r, body.position.z + r); + return; + } + if (isBoxBody(body)) { + const hx = body.halfExtents.x; + const hy = body.halfExtents.y; + const hz = body.halfExtents.z; + this.broadphaseAxisX.set(hx, 0, 0).applyQuaternion(body.quaternion); + this.broadphaseAxisY.set(0, hy, 0).applyQuaternion(body.quaternion); + this.broadphaseAxisZ.set(0, 0, hz).applyQuaternion(body.quaternion); + const ex = Math.abs(this.broadphaseAxisX.x) + + Math.abs(this.broadphaseAxisY.x) + + Math.abs(this.broadphaseAxisZ.x); + const ey = Math.abs(this.broadphaseAxisX.y) + + Math.abs(this.broadphaseAxisY.y) + + Math.abs(this.broadphaseAxisZ.y); + const ez = Math.abs(this.broadphaseAxisX.z) + + Math.abs(this.broadphaseAxisY.z) + + Math.abs(this.broadphaseAxisZ.z); + min.set(body.position.x - ex, body.position.y - ey, body.position.z - ez); + max.set(body.position.x + ex, body.position.y + ey, body.position.z + ez); + } + } + + /** 宽相位候选对 AABB 三轴重叠检测;未通过则跳过窄相。 */ + private bodyAabbOverlap(i: number, j: number): boolean { + const aMin = this.bodyAabbMin[i]; + const aMax = this.bodyAabbMax[i]; + const bMin = this.bodyAabbMin[j]; + const bMax = this.bodyAabbMax[j]; + return ( + aMin.x <= bMax.x && aMax.x >= bMin.x + && aMin.y <= bMax.y && aMax.y >= bMin.y + && aMin.z <= bMax.z && aMax.z >= bMin.z + ); + } + + /** + * 人物分步移动时与动态体接触。 + * 胶囊只做位置推出;对刚体施水平单向冲量(不回推人物速度)。 + */ + collideWithCapsule(): void { + if (this.ctrl.controllerMode === 1 || this.ctrl.skipCapsuleCollision) return; + const cap = this.ctrl.playerCapsule; + const info = cap?.capsuleInfo; + if (!cap || !info) return; + + const maxSep = info.radius * CHAR_SEP_MAX_RADIUS; + const playerVel = this.ctrl.playerVelocity; + const charMass = this.characterPushMass(); + + for (const body of this.list) { + const hit = this.queryCapsuleBodyOverlap(cap, info, body); + if (!hit || hit.depth <= 0) continue; + + // 步行法线压到水平,避免竖直分量把人弹飞 + this.temps.normal.copy(hit.normal); + if (!this.ctrl.isFlying) { + this.temps.normal.y = 0; + if (this.temps.normal.lengthSq() < 1e-8) { + this.temps.normal.set( + body.position.x - cap.position.x, + 0, + body.position.z - cap.position.z, + ); + if (this.temps.normal.lengthSq() < 1e-8) this.temps.normal.set(1, 0, 0); + } + this.temps.normal.normalize(); + } + + const sep = Math.min(hit.depth, maxSep); + cap.position.addScaledVector(this.temps.normal, -sep); + cap.updateMatrixWorld(); + + body.getVelocityAtPoint(hit.contact, this.bodyVel); + this.temps.offset.set(playerVel.x - this.bodyVel.x, 0, playerVel.z - this.bodyVel.z); + const deltaVel = this.temps.offset.dot(this.temps.normal); + if (deltaVel <= 0) continue; + + const massRatio = (body.mass * charMass) / (body.mass + charMass); + this.temps.impulse.copy(this.temps.normal).multiplyScalar(deltaVel * massRatio); + this.temps.impulse.y = 0; + body.applyImpulseAtPoint(this.temps.impulse, hit.contact); + } + } + + /** 查询胶囊与单个动态体的重叠;normal 由胶囊指向刚体。 */ + private queryCapsuleBodyOverlap( + cap: THREE.Object3D, + info: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + body: DynamicBody, + ): { depth: number; normal: THREE.Vector3; contact: THREE.Vector3 } | null { + if (isSphereBody(body)) { + const depth = capsuleSphereOverlap(cap, info, body.position, body.radius, this.capsuleTemps); + if (depth <= 0) return null; + this.contactPoint.copy(this.capsuleTemps.closestSeg).addScaledVector( + this.capsuleTemps.closestTri, info.radius, + ); + return { + depth, + normal: this.capsuleTemps.closestTri, + contact: this.contactPoint, + }; + } + if (isBoxBody(body)) { + const depth = capsuleObbOverlap( + cap, info, body.position, body.quaternion, body.halfExtents, this.capsuleTemps, + ); + if (depth <= 0) return null; + this.contactPoint.copy(this.capsuleTemps.closestSeg).addScaledVector( + this.capsuleTemps.closestTri, info.radius, + ); + return { + depth, + normal: this.capsuleTemps.closestTri, + contact: this.contactPoint, + }; + } + return null; + } + + /** + * 物理子步:胶囊视为运动学障碍(无限质量),只推出刚体并去掉侵入法向速度。 + */ + private resolveBodyVsKinematicCapsule(body: DynamicBody): void { + const cap = this.ctrl.playerCapsule; + const info = cap?.capsuleInfo; + if (!cap || !info || this.ctrl.controllerMode === 1) return; + + const hit = this.queryCapsuleBodyOverlap(cap, info, body); + if (!hit || hit.depth <= 0) return; + + // 法线:胶囊→刚体;推出刚体。步行压低竖直分量,避免箱子被顶飞。 + this.temps.normal.copy(hit.normal); + if (!this.ctrl.isFlying && Math.abs(this.temps.normal.y) > 0.35) { + this.temps.normal.y *= 0.15; + if (this.temps.normal.lengthSq() > 1e-8) this.temps.normal.normalize(); + else { + this.temps.normal.set( + body.position.x - cap.position.x, + 0, + body.position.z - cap.position.z, + ); + if (this.temps.normal.lengthSq() < 1e-8) return; + this.temps.normal.normalize(); + } + } + + const sep = Math.min(hit.depth, info.radius * CHAR_SEP_MAX_RADIUS); + body.position.addScaledVector(this.temps.normal, sep * KIN_CAPSULE_BAUMGARTE); + + body.getVelocityAtPoint(hit.contact, this.bodyVel); + const vn = this.bodyVel.dot(this.temps.normal); + if (vn < 0) { + this.temps.impulse.copy(this.temps.normal).multiplyScalar(-vn * body.mass); + body.applyImpulseAtPoint(this.temps.impulse, hit.contact); + } + } + + /** 限制线速度 / 角速度,避免异常弹出。 */ + private clampSpeed(body: DynamicBody): void { + const extent = body.characteristicExtent(); + const maxSpeed = Math.max(Math.abs(body.gravity) * MAX_SPEED_GRAVITY, extent * 80); + const speed = body.velocity.length(); + if (speed > maxSpeed) body.velocity.multiplyScalar(maxSpeed / speed); + const maxAng = maxSpeed / Math.max(extent, 1e-4); + const ang = body.angularVelocity.length(); + if (ang > maxAng) body.angularVelocity.multiplyScalar(maxAng / ang); + } + + /** 刚体停在运动学网格上时按平台 prev→current 矩阵带走;位移过大则回退。 */ + private applyKinematicCarry(body: DynamicBody): void { + if (body.sleeping || !body.contactMesh) return; + const entry = this.ctrl.getKinematicColliderEntries().find(e => e.mesh === body.contactMesh); + if (!entry) return; + this.carryPrevInv.copy(entry.prevWorldMatrix).invert(); + this.temps.before.copy(body.position); + body.position.applyMatrix4(this.carryPrevInv).applyMatrix4(entry.source.matrixWorld); + const maxCarry = body.characteristicExtent() * 8; + if (body.position.distanceToSquared(this.temps.before) > maxCarry * maxCarry) { + body.position.copy(this.temps.before); + } + } +} diff --git a/three-player-controller/src/systems/InputSystem.ts b/three-player-controller/src/systems/InputSystem.ts new file mode 100644 index 0000000..1946023 --- /dev/null +++ b/three-player-controller/src/systems/InputSystem.ts @@ -0,0 +1,247 @@ +import type { playerController } from "../PlayerController"; +import type { KeyAction, KeyMap } from "../types"; + +// 默认键位表(动作 -> KeyboardEvent.code 列表) +const defaultKeyMap: Record = { + forward: ["KeyW", "ArrowUp"], + backward: ["KeyS", "ArrowDown"], + left: ["KeyA", "ArrowLeft"], + right: ["KeyD", "ArrowRight"], + sprint: ["ShiftLeft", "ShiftRight"], + jump: ["Space"], + toggleView: ["KeyV"], + toggleFly: ["KeyF"], + toggleVehicle: ["KeyE"], +}; + +export class InputSystem { + private ctrl: playerController; // 主控制器引用 + + fwd = false; // 前进键 + bkd = false; // 后退键 + lft = false; // 左移键 + rgt = false; // 右移键 + space = false; // 跳跃键 + shift = false; // 加速键 + + private keyFwd = false; + private keyBkd = false; + private keyLft = false; + private keyRgt = false; + private analogMoveX = 0; + private analogMoveY = 0; + + private boundKeydown = async (e: KeyboardEvent) => this.onKeydown(e); // 键盘按下绑定 + private boundKeyup = (e: KeyboardEvent) => this.onKeyup(e); // 键盘抬起绑定 + private boundMouseMove = (e: MouseEvent) => this.onMouseMove(e); // 鼠标移动绑定 + private boundMouseClick = (e: MouseEvent) => { + if (e.target === this.ctrl.controls.domElement) this.ctrl.cam.setPointerLock(); // 鼠标点击绑定 + }; + private boundBlur = () => this.resetKeys(); // 页面失焦时重置按键状态 + + private codeToAction = new Map(); // 键码 -> 动作 反查表 + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + this.buildKeyMap(); + } + + // 构建键码:动作 反查表:未传的动作用默认键,传 string/数组则覆盖,传 null 则禁用 + buildKeyMap(userMap?: KeyMap) { + this.codeToAction.clear(); // 清空旧表 + for (const action of Object.keys(defaultKeyMap) as KeyAction[]) { + let codes: string[]; + if (userMap && action in userMap) { + const v = userMap[action]; + if (v == null) continue; // null:禁用该动作 + codes = Array.isArray(v) ? v : [v]; // 覆盖默认键 + } else { + codes = defaultKeyMap[action]; // 未传:用默认键 + } + for (const code of codes) this.codeToAction.set(code, action); + } + } + + // 程序化输入接口 + setInput(input: Partial<{ + moveX: number; moveY: number; + lookDeltaX: number; lookDeltaY: number; + jump: boolean; shift: boolean; + toggleView: boolean; toggleFly: boolean; toggleVehicle: boolean; + }>) { + const c = this.ctrl; + + // 连续移动轴,供摇杆或手柄输入使用 + const prevFwd = this.fwd; + const prevBkd = this.bkd; + const prevLft = this.lft; + const prevRgt = this.rgt; + let moveChanged = false; + if (typeof input.moveX === "number") { + this.analogMoveX = Math.max(-1, Math.min(1, input.moveX)); + moveChanged = true; + } + if (typeof input.moveY === "number") { + this.analogMoveY = Math.max(-1, Math.min(1, input.moveY)); + moveChanged = true; + } + if (moveChanged) { + this.syncDirectionFlags(); + if (prevFwd !== this.fwd || prevBkd !== this.bkd || prevLft !== this.lft || prevRgt !== this.rgt) { + c.animation.setAnimationByPressed(); + } + } + + // 视角朝向 + if (typeof input.lookDeltaX === "number" && typeof input.lookDeltaY === "number") { + c.cam.setToward(input.lookDeltaX, input.lookDeltaY, 0.002); + } + + // 持续状态 + if (typeof input.jump === "boolean") this.applyAction("jump", input.jump); + if (typeof input.shift === "boolean") this.applyAction("sprint", input.shift); + + // 触发式切换 + if (input.toggleView) this.applyAction("toggleView", true); + if (input.toggleFly) this.applyAction("toggleFly", true); + if (input.toggleVehicle) this.applyAction("toggleVehicle", true); + } + + // 绑定输入事件 + bindEvents() { + this.ctrl.isupdate = true; + this.ctrl.cam.setPointerLock(); + window.addEventListener("keydown", this.boundKeydown); + window.addEventListener("keyup", this.boundKeyup); + window.addEventListener("mousemove", this.boundMouseMove); + window.addEventListener("click", this.boundMouseClick); + window.addEventListener("blur", this.boundBlur); + } + + // 解绑输入事件 + unbindEvents() { + this.ctrl.isupdate = false; + document.exitPointerLock(); + window.removeEventListener("keydown", this.boundKeydown); + window.removeEventListener("keyup", this.boundKeyup); + window.removeEventListener("mousemove", this.boundMouseMove); + window.removeEventListener("click", this.boundMouseClick); + window.removeEventListener("blur", this.boundBlur); + } + + // 重置所有按键状态 + private resetKeys() { + const c = this.ctrl; + this.keyFwd = false; + this.keyBkd = false; + this.keyLft = false; + this.keyRgt = false; + this.analogMoveX = 0; + this.analogMoveY = 0; + this.syncDirectionFlags(); + this.space = false; + this.shift = false; + c.controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + c.animation.setAnimationByPressed(); + } + + // 统一动作派发 + private applyAction(action: KeyAction, pressed: boolean) { + const c = this.ctrl; + switch (action) { + // 前进 + case "forward": this.keyFwd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 后退 + case "backward": this.keyBkd = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 左移 + case "left": this.keyLft = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 右移 + case "right": this.keyRgt = pressed; this.syncDirectionFlags(); c.animation.setAnimationByPressed(); break; + // 冲刺 + case "sprint": + this.shift = pressed; + c.animation.setAnimationByPressed(); + // 切换轨道拖拽键位 + c.controls.mouseButtons = pressed + ? { LEFT: 2, MIDDLE: 1, RIGHT: 0 } + : { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + break; + // 跳跃 + case "jump": + if (pressed) { + this.space = true; + if (c.controllerMode === 1) return; // 载具模式不跳跃 + if (c.isFlying) { c.animation.setAnimationByPressed(); return; } // 飞行中仅切动画 + if (!c.playerIsOnGround) return; // 不在地面不能跳 + if (c.animation.isJumping()) return; // 跳跃中不重复触发 + c.animation.startJump(); + c.playerVelocity.y = c.jumpHeight; + c.setOnGround(false); // 跳跃后设置为不在地面 + } else { + this.space = false; + if (c.isFlying) c.animation.setAnimationByPressed(); + } + break; + // 切换第一 / 第三人称视角 + case "toggleView": + if (pressed) c.cam.changeView(); + break; + // 切换飞行模式 + case "toggleFly": + if (pressed && c.controllerMode === 0) { + c.isFlying = !c.isFlying; + if (c.isFlying) c.playerVelocity.set(0, 0, 0); + c.animation.setAnimationByPressed(); + if (!c.isFlying && !c.playerIsOnGround) c.animation.startJump(true); + } + break; + // 上 / 下车 + case "toggleVehicle": + if (pressed) { + if (c.isFlying) return; + if (c.controllerMode === 0) c.vehicle.enter(); else c.vehicle.exit(); + } + break; + } + } + + // 获取最终移动轴:模拟输入优先,否则使用键盘八方向 + getMoveAxes() { + const hasAnalogInput = this.analogMoveX !== 0 || this.analogMoveY !== 0; + if (hasAnalogInput) return { x: this.analogMoveX, y: this.analogMoveY, isAnalog: true }; + return { + x: Number(this.keyRgt) - Number(this.keyLft), + y: Number(this.keyFwd) - Number(this.keyBkd), + isAnalog: false, + }; + } + + // 合并键盘与模拟输入,供动画和车辆等现有布尔逻辑使用 + private syncDirectionFlags() { + const threshold = 0.2; + this.fwd = this.keyFwd || this.analogMoveY > threshold; + this.bkd = this.keyBkd || this.analogMoveY < -threshold; + this.lft = this.keyLft || this.analogMoveX < -threshold; + this.rgt = this.keyRgt || this.analogMoveX > threshold; + } + + // 键盘按下处理 + private onKeydown(e: KeyboardEvent) { + const action = this.codeToAction.get(e.code); + if (action) this.applyAction(action, true); + } + + // 键盘抬起处理 + private onKeyup(e: KeyboardEvent) { + const action = this.codeToAction.get(e.code); + if (action) this.applyAction(action, false); + } + + // 鼠标移动处理 + private onMouseMove(e: MouseEvent) { + const lockTarget = this.ctrl.controls.domElement; + if (lockTarget && document.pointerLockElement === lockTarget) { + this.ctrl.cam.setToward(e.movementX, e.movementY, 0.0001); + } + } +} diff --git a/three-player-controller/src/systems/PlayerDebug.ts b/three-player-controller/src/systems/PlayerDebug.ts new file mode 100644 index 0000000..33e7877 --- /dev/null +++ b/three-player-controller/src/systems/PlayerDebug.ts @@ -0,0 +1,272 @@ +import * as THREE from "three"; + +import type { playerController } from "../PlayerController"; + +const HIT_COLOR = 0x66ff66; +const FAR_HIT_COLOR = 0xffcc33; +const MISS_COLOR = 0xff5555; +const SENSOR_IDLE_COLOR = 0x45d7ff; + +/** 绘制玩家胶囊与地面探测结果。 */ +export class PlayerDebug { + private visible = false; + private hasGroundProbe = false; + private groundProbeHasHit = false; + private groundProbeMaxDistance = 0; + private groundProbeNear = 0; + private groundRay: THREE.Line | null = null; + private groundHit: THREE.Mesh | null = null; + private groundProbePoint = new THREE.Vector3(); + private hasGroundSensor = false; + private groundSensorActive = false; + private groundSensorHasHit = false; + private groundSensorMaxDistance = 0; + private groundSensorRadius = 0; + private groundSensor: THREE.Mesh | null = null; + private readonly probeLocalPoint = new THREE.Vector3(); + private readonly capsuleLocalDown = new THREE.Vector3(0, -1, 0); + private debugRoot: THREE.Group | null = null; + + constructor(private readonly ctrl: playerController) { } + + /** 返回人物调试开关。 */ + isVisible(): boolean { + return this.visible; + } + + /** 切换人物胶囊与地面探测调试。 */ + setVisible(visible: boolean): void { + this.visible = visible; + this.syncVisibility(); + } + + /** 在玩家模型或控制模式变化后重新同步显隐。 */ + syncVisibility(): void { + const capsule = this.ctrl.playerCapsule; + if (capsule) { + const materials = Array.isArray(capsule.material) ? capsule.material : [capsule.material]; + for (const material of materials) material.visible = this.visible; + } + + const showProbe = this.visible && this.hasGroundProbe && this.ctrl.controllerMode === 0; + const showSensor = this.visible && this.hasGroundSensor && this.ctrl.controllerMode === 0; + if (this.groundRay) this.groundRay.visible = showProbe; + if (this.groundHit) this.groundHit.visible = showProbe && this.groundProbeHasHit; + if (this.groundSensor) this.groundSensor.visible = showSensor; + if (showProbe) this.renderGroundProbe(); + if (showSensor) this.renderGroundSensor(); + } + + /** + * 移动结束后刷新调试体姿态,使射线/体积传感区跟随胶囊实时位置。 + */ + refreshTransforms(): void { + if (!this.visible || this.ctrl.controllerMode !== 0) return; + if (this.hasGroundProbe) this.renderGroundProbe(); + if (this.hasGroundSensor) this.renderGroundSensor(); + } + + /** 写入移动系统本帧实际使用的中心地面探测结果。 */ + updateGroundProbe( + origin: THREE.Vector3, + hitPoint: THREE.Vector3 | null, + maxDistance: number, + near = 0, + ): void { + this.hasGroundProbe = true; + this.groundProbeHasHit = hitPoint !== null; + this.groundProbeMaxDistance = Math.max(0, maxDistance); + this.groundProbeNear = Math.max(0, near); + if (hitPoint) this.groundProbePoint.copy(hitPoint); + else this.groundProbePoint.copy(origin).y -= this.groundProbeMaxDistance; + } + + /** 写入胶囊下方体积地面传感区的当前状态。 */ + updateGroundSensor( + _start: THREE.Vector3, + _down: THREE.Vector3, + maxDistance: number, + radius: number, + active: boolean, + hasHit: boolean, + ): void { + this.hasGroundSensor = maxDistance > 0 && radius > 0; + this.groundSensorActive = active; + this.groundSensorHasHit = hasHit; + this.groundSensorMaxDistance = Math.max(0, maxDistance); + this.groundSensorRadius = Math.max(0, radius); + } + + /** 清除当前地面探测显示。 */ + clearGroundProbe(): void { + this.hasGroundProbe = false; + this.hasGroundSensor = false; + if (this.groundRay) this.groundRay.visible = false; + if (this.groundHit) this.groundHit.visible = false; + if (this.groundSensor) this.groundSensor.visible = false; + } + + /** 释放人物调试几何与材质。 */ + dispose(): void { + if (this.debugRoot) { + this.debugRoot.removeFromParent(); + this.debugRoot.traverse((obj) => { + const mesh = obj as THREE.Mesh; + mesh.geometry?.dispose(); + const mat = mesh.material; + if (mat) { + if (Array.isArray(mat)) mat.forEach(m => m.dispose()); + else mat.dispose(); + } + }); + this.debugRoot = null; + } + this.groundRay = null; + this.groundHit = null; + this.groundSensor = null; + this.hasGroundProbe = false; + this.hasGroundSensor = false; + } + + private ensureDebugRoot(): THREE.Group | null { + const capsule = this.ctrl.playerCapsule; + if (!capsule) return null; + + if (!this.debugRoot) { + this.debugRoot = new THREE.Group(); + this.debugRoot.name = "playerDebugRoot"; + capsule.add(this.debugRoot); + } else if (this.debugRoot.parent !== capsule) { + capsule.add(this.debugRoot); + } + return this.debugRoot; + } + + /** 按当前探测结果更新线段、命中点和颜色。 */ + private renderGroundProbe(): void { + const capsule = this.ctrl.playerCapsule; + const root = this.ensureDebugRoot(); + if (!capsule || !root) return; + + this.ensureGroundProbeObjects(root); + if (!this.groundRay || !this.groundHit) return; + + this.worldToCapsuleLocal(this.groundProbePoint, this.probeLocalPoint); + const positions = this.groundRay.geometry.getAttribute("position") as THREE.BufferAttribute; + positions.setXYZ(0, 0, 0, 0); + positions.setXYZ(1, this.probeLocalPoint.x, this.probeLocalPoint.y, this.probeLocalPoint.z); + positions.needsUpdate = true; + + const distance = capsule.position.y - this.groundProbePoint.y; + const hitInRange = this.groundProbeHasHit + && distance >= this.groundProbeNear + && distance <= this.groundProbeMaxDistance; + const color = hitInRange ? HIT_COLOR : this.groundProbeHasHit ? FAR_HIT_COLOR : MISS_COLOR; + (this.groundRay.material as THREE.LineBasicMaterial).color.setHex(color); + this.groundRay.visible = true; + + this.groundHit.visible = this.groundProbeHasHit; + if (this.groundProbeHasHit) { + this.groundHit.position.copy(this.probeLocalPoint); + const sy = Math.max(1e-6, capsule.scale.y || 1); + const localRadius = (capsule.capsuleInfo?.radius ?? 0) / sy; + this.groundHit.scale.setScalar(Math.max(1e-6, localRadius * 0.05)); + (this.groundHit.material as THREE.MeshBasicMaterial).color.setHex(color); + } + } + + /** 按当前状态更新体积地面传感区(锚定在胶囊 segment.end)。 */ + private renderGroundSensor(): void { + const capsule = this.ctrl.playerCapsule; + const root = this.ensureDebugRoot(); + const segEnd = capsule?.capsuleInfo?.segment.end; + if (!capsule || !root || !segEnd) return; + + this.ensureGroundSensorObject(root); + if (!this.groundSensor) return; + + const distance = Math.max(this.groundSensorMaxDistance, 1e-6); + this.groundSensor.position.copy(segEnd).addScaledVector(this.capsuleLocalDown, distance * 0.5); + this.groundSensor.quaternion.identity(); + this.groundSensor.scale.set(this.groundSensorRadius, distance, this.groundSensorRadius); + + const material = this.groundSensor.material as THREE.MeshBasicMaterial; + material.color.setHex( + this.groundSensorActive + ? this.groundSensorHasHit ? HIT_COLOR : MISS_COLOR + : SENSOR_IDLE_COLOR, + ); + material.opacity = this.groundSensorActive ? 0.75 : 0.4; + this.groundSensor.visible = this.hasGroundSensor; + } + + private worldToCapsuleLocal(world: THREE.Vector3, target: THREE.Vector3): THREE.Vector3 { + target.copy(world); + this.ctrl.playerCapsule.worldToLocal(target); + return target; + } + + /** 延迟创建地面射线和命中标记。 */ + private ensureGroundProbeObjects(root: THREE.Group): void { + if (!this.groundRay) { + this.groundRay = new THREE.Line( + new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), + new THREE.LineBasicMaterial({ + color: MISS_COLOR, + depthTest: true, + transparent: true, + opacity: 0.9, + }), + ); + this.groundRay.name = "playerGroundRayDebug"; + this.groundRay.renderOrder = 30; + this.groundRay.frustumCulled = false; + root.add(this.groundRay); + } else if (this.groundRay.parent !== root) { + root.add(this.groundRay); + } + + if (!this.groundHit) { + this.groundHit = new THREE.Mesh( + new THREE.SphereGeometry(2, 10, 8), + new THREE.MeshBasicMaterial({ + color: HIT_COLOR, + wireframe: true, + depthTest: true, + transparent: true, + opacity: 0.9, + }), + ); + this.groundHit.name = "playerGroundRayHitDebug"; + this.groundHit.renderOrder = 31; + this.groundHit.frustumCulled = false; + root.add(this.groundHit); + } else if (this.groundHit.parent !== root) { + root.add(this.groundHit); + } + } + + /** 延迟创建体积地面传感区线框。 */ + private ensureGroundSensorObject(root: THREE.Group): void { + if (this.groundSensor) { + if (this.groundSensor.parent !== root) root.add(this.groundSensor); + return; + } + this.groundSensor = new THREE.Mesh( + new THREE.CylinderGeometry(1, 1, 1, 16, 1, true), + new THREE.MeshBasicMaterial({ + color: SENSOR_IDLE_COLOR, + wireframe: true, + depthTest: true, + depthWrite: true, + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide, + }), + ); + this.groundSensor.name = "playerGroundVolumeDebug"; + this.groundSensor.renderOrder = 29; + this.groundSensor.frustumCulled = false; + root.add(this.groundSensor); + } +} diff --git a/three-player-controller/src/systems/VehicleSystem.ts b/three-player-controller/src/systems/VehicleSystem.ts new file mode 100644 index 0000000..714171a --- /dev/null +++ b/three-player-controller/src/systems/VehicleSystem.ts @@ -0,0 +1,833 @@ +import * as THREE from "three"; +import type { playerController } from "../PlayerController"; +import { loadVehicleModel as loadVehicleModelUtil } from "../utils/vehicleLoader"; +import { DEFAULT_MAX_SUSPENSION_TRAVEL, DEFAULT_WHEEL_PHYSICS } from "../utils/vehicleController"; +import { applyCapsuleCollision, createCollisionTemps } from "../utils/capsuleCollision"; +import type { VehicleInstance, VehicleOptions, KinematicColliderEntry, VehicleWheelColliderUserData } from "../types"; +import { CollisionGroup } from "../collision/groups"; +import { createObbObbTemps, resolveObbObb } from "../collision/obbObb"; + +export class VehicleSystem { + private ctrl: playerController; // 主控制器引用 + + list: VehicleInstance[] = []; // 车辆实例列表 + active: VehicleInstance | null = null; // 当前乘坐车辆 + meshSkipIds: number[] = []; // 全部车辆外观 collider id,网格查询时跳过 + vehicleLength = 6; // 车辆模型归一化后的最大边长度 + params = { + debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false }, // 调试显示 + chassis: { density: 1, linearDamping: 0.05, angularDamping: 0.5 }, // 车身参数 + model: { rotation: -Math.PI / 2 }, // 模型旋转 + power: { acceleration: 5, deceleration: 5, maxSpeed: 100 }, // 动力参数 + steering: { maxSteerAngle: Math.PI / 5, steerTime: 0.45, steerReturnTimeSlow: 0.55, steerReturnTimeFast: 0.4, highSpeedSteerScale: 0.3 }, // 转向:打满/回正时间(秒),高速收舵 + grip: { + maxG: 1.2, + sideFrictionIdle: 1, + sideFrictionFrontMin: 0.55, + sideFrictionRearMin: 0.45, + handbrakeRearFriction: 0.35, + handbrakeRearDriveScale: 0.65, + handbrakeReleaseTime: 0.15, + wheelbaseRatio: 0.55, + }, // 抓地预算 + suspension: { + maxTravel: DEFAULT_MAX_SUSPENSION_TRAVEL, + stiffness: DEFAULT_WHEEL_PHYSICS.suspensionStiffness, + compression: DEFAULT_WHEEL_PHYSICS.suspensionCompression, + relaxation: DEFAULT_WHEEL_PHYSICS.suspensionRelaxation, + maxForce: DEFAULT_WHEEL_PHYSICS.maxSuspensionForce, + frictionSlip: DEFAULT_WHEEL_PHYSICS.frictionSlip, + sideFrictionStiffness: DEFAULT_WHEEL_PHYSICS.sideFrictionStiffness, + rollInfluence: DEFAULT_WHEEL_PHYSICS.rollInfluence, + }, + followVehicleDirection: true, // 相机跟随方向 + }; + + boardingPadding = 0.25; // 上车/下车余量基准值(按车辆 scale 缩放) + parkingCreepThreshold = 0.05; // 驻车状态下清除低速蠕动的水平速度阈值 + private handbrakeBlend = 0; // 1 为手刹全开,松键后插值回 0 + + private scratchLocal = new THREE.Vector3(); + private scratchWorld = new THREE.Vector3(); + private scratchForward = new THREE.Vector3(); + private scratchUp = new THREE.Vector3(); + private scratchQuat = new THREE.Quaternion(); + private scratchDown = new THREE.Vector3(0, -1, 0); + private wheelSphereDir = new THREE.Vector3(); + private carryPrevInv = new THREE.Matrix4(); + private carryYaw = new THREE.Quaternion(); + private carryUp = new THREE.Vector3(0, 1, 0); + private raycaster = new THREE.Raycaster(); + private exitCheckTemps = createCollisionTemps(); + private obbTemps = createObbObbTemps(); + + constructor(ctrl: playerController) { + this.ctrl = ctrl; + (this.raycaster as any).firstHitOnly = true; + } + + /** 切换车辆底盘物理盒。 */ + setPhysicsDebugVisible(visible: boolean): void { + this.params.debug.showPhysicsBox = visible; + this.syncDebugVisibility(); + } + + /** 同步全部车辆调试对象的显隐。 */ + syncDebugVisibility(): void { + for (const v of this.list) { + if (v.physicsBoxMesh) { + if (this.params.debug.showPhysicsBox) { + if (!v.vehicleGroup.children.includes(v.physicsBoxMesh)) v.vehicleGroup.add(v.physicsBoxMesh); + } else { + v.vehicleGroup.remove(v.physicsBoxMesh); + } + } + if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel; + if (v.wheelSphereDebug) { + v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres; + if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v); + } + } + } + + /** 由车身水平包围圆和人物胶囊半径推算上车范围。 */ + boardingRadius(v: VehicleInstance): number { + return Math.hypot(v.halfExtents.x, v.halfExtents.z) + + this.capsuleRadius() + + this.boardingPadding * v.scale; + } + + /** 返回人物到车辆中心的水平距离,高度超出车辆邻域时返回 Infinity。 */ + boardingDistance(v: VehicleInstance, position: THREE.Vector3): number { + v.vehicleGroup.updateMatrixWorld(true); + this.scratchLocal.copy(position); + v.vehicleGroup.worldToLocal(this.scratchLocal); + const verticalLimit = v.halfExtents.y + this.capsuleHeight(); + if (Math.abs(this.scratchLocal.y) > verticalLimit) return Infinity; + return Math.hypot(this.scratchLocal.x, this.scratchLocal.z); + } + + /** 是否处于车辆中心的上车范围内。 */ + isInBoardingRange(v: VehicleInstance, position: THREE.Vector3): boolean { + return this.boardingDistance(v, position) <= this.boardingRadius(v); + } + + // 加载车辆模型 + async load(opts: VehicleOptions): Promise { + try { + const instance = await loadVehicleModelUtil(opts, { + loader: this.ctrl.loader, + scene: this.ctrl.scene, + vehicleParams: this.params, + vehicleLength: this.vehicleLength, + }); + + this.list.push(instance); + const kin = this.ctrl.addCollider({ + motion: "kinematic", + shape: { kind: "mesh", source: instance.vehicleGroup }, + follow: instance.vehicleGroup, + }); + instance.meshColliderId = kin.id; + const chassis = this.ctrl.addCollider({ + motion: "dynamic", + shape: { kind: "box", halfExtents: instance.halfExtents.clone() }, + groups: CollisionGroup.VEHICLE, + mask: CollisionGroup.DEFAULT | CollisionGroup.VEHICLE | CollisionGroup.DEBRIS, + userData: instance, + simulate: false, // 默认会建 DynamicBoxBody;底盘改由 VehicleRigidBody 推进,此处只登记 + }); + instance.chassisColliderId = chassis.id; + instance.wheelColliderIds = this.registerWheelSpheres(instance); + this.attachWheelSphereDebug(instance); + return instance; + } catch (e) { + console.error("加载车辆模型失败:", e); + return undefined; + } + } + + // 触发上车流程 + enter() { + if (!this.list.length || this.ctrl.controllerMode === 1) return; + + // 查找最近可上车的车辆 + let nearest: VehicleInstance | null = null; + let nearestDist = Infinity; + const pos = this.ctrl.playerCapsule.position; + for (const v of this.list) { + const dist = this.boardingDistance(v, pos); + if (dist <= this.boardingRadius(v) && dist < nearestDist) { + nearestDist = dist; + nearest = v; + } + } + + if (!nearest) return; + // 车辆移动中不允许上车 + const vel = nearest.chassisBody.linvel(); + if (Math.hypot(vel.x, vel.z) > 0.1) return; + this.releaseParkingBrake(nearest); + this.active = nearest; + const c = this.ctrl; + c.controllerMode = 1; + c.playerVelocity.set(0, 0, 0); + c.activeDynamicBody = null; + c.mobileControls?.syncControllerModeBtn(1); + c.cam.setOverShoulder(false); + c.animation.playByName("driving"); + c.syncMountedPlayer(nearest); + c.syncDebugVisibility(); + c.onVehicleEnter?.(nearest); + } + + // 触发下车流程 + exit() { + const c = this.ctrl; + const v = this.active; + if (!v) return; + + this.applyParkingBrake(v, c.getCurrentDelta() || 1 / 60); + this.findExitPosition(v, this.scratchWorld); + this.getDriverForward(v, this.scratchForward); + c.controllerMode = 0; + c.mobileControls?.syncControllerModeBtn(0); + c.cam.setOverShoulder(c.enableOverShoulderView); + c.leaveVehicleAt(this.scratchWorld, this.scratchForward); + c.animation.playByName("idle"); + c.syncDebugVisibility(); + this.handbrakeBlend = 0; + c.onVehicleExit?.(v); + } + + // 物理步进前更新车辆控制器 + preparePhysics(delta: number) { + if (this.ctrl.controllerMode === 1 && this.active) this.applyDriving(delta, this.active); + } + + // 物理步进后同步车辆视觉 + finishPhysics(delta: number) { + for (const v of this.list) { + const parked = this.ctrl.controllerMode !== 1 || this.active !== v; + if (parked) this.applyParkingBrake(v, delta); + v.vehicleController.updateVehicle(delta, this.ctrl.getVehicleGroundMeshes(v)); + this.applyKinematicCarry(v); + } + this.resolveVehiclePairs(); + for (const v of this.list) { + const parked = this.ctrl.controllerMode !== 1 || this.active !== v; + const vel = v.chassisBody.linvel(); + const speed = Math.hypot(vel.x, vel.z); + const max = v.maxSpeed / 3.6; + if (speed > max) { + const s = max / speed; + v.chassisBody.setLinvel({ x: vel.x * s, y: vel.y, z: vel.z * s }); + } else if (parked && speed > 0 && speed <= this.parkingCreepThreshold) { + let hasWheelContact = false; + for (let i = 0; i < v.vehicleController.numWheels(); i++) { + if (v.vehicleController.wheelIsInContact(i)) { hasWheelContact = true; break; } + } + if (hasWheelContact) v.chassisBody.setLinvel({ x: 0, y: vel.y, z: 0 }); + } + this.syncVehicleVisual(v, delta); + } + if (this.ctrl.controllerMode === 1 && this.active) this.ctrl.syncMountedPlayer(this.active); + } + + /** 两车底盘盒对盒:跳过自身,驻车中的车仍可被撞开。 */ + private resolveVehiclePairs() { + const n = this.list.length; + if (n < 2) return; + for (let iter = 0; iter < 2; iter++) { + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + resolveObbObb(this.list[i].chassisBody, this.list[j].chassisBody, this.obbTemps); + } + } + } + } + + // 更新车辆驾驶 + private applyDriving(delta: number, v: VehicleInstance) { + const c = this.ctrl; + const { vehicleController, chassisBody } = v; + + // 坡度补偿 + const rotation = chassisBody.rotation(); + this.scratchQuat.set(rotation.x, rotation.y, rotation.z, rotation.w); + const forward = this.scratchForward.copy(v.forwardLocal).applyQuaternion(this.scratchQuat); + const throttle = Number(c.input.fwd) - Number(c.input.bkd); + const sinTheta = Math.max(-1, Math.min(1, forward.y)); + const gEff = 9.81 * v.scale; + const extraAccel = (throttle * sinTheta > 0.05) ? Math.abs(sinTheta) * gEff : 0; + const wheelCount = Math.max(1, vehicleController.numWheels()); + const mass = chassisBody.mass(); + const linv = chassisBody.linvel(); + const speed01 = Math.min(1, Math.hypot(linv.x, linv.z) / Math.max(0.01, v.maxSpeed / 3.6)); + + // 转向:参数为走完满舵行程的时间(秒);车速越高可用转角越小 + const { maxSteerAngle, steerTime, steerReturnTimeSlow, steerReturnTimeFast, highSpeedSteerScale = 0.3 } = v.steering; + const currentSteering = vehicleController.wheelSteering(0) || 0; + const steerDir = Number(c.input.lft) - Number(c.input.rgt); + const speedSteer = 1 - (1 - highSpeedSteerScale) * speed01 * speed01; + const steerLimit = maxSteerAngle * speedSteer; + const targetSteering = steerLimit * steerDir; + const responseTime = steerDir === 0 + ? steerReturnTimeSlow + (steerReturnTimeFast - steerReturnTimeSlow) * speed01 + : steerTime; + const maxStep = Math.max(steerLimit, 1e-4) / Math.max(1e-4, responseTime) * delta; + const steeringDelta = targetSteering - currentSteering; + const steering = currentSteering + Math.sign(steeringDelta) * Math.min(Math.abs(steeringDelta), maxStep); + vehicleController.setWheelSteering(0, steering); + vehicleController.setWheelSteering(1, steering); + + // 抓地预算 + const { maxG, sideFrictionIdle, sideFrictionFrontMin, sideFrictionRearMin, handbrakeRearFriction, handbrakeRearDriveScale, handbrakeReleaseTime, wheelbaseRatio } = v.grip; + const vFwd = linv.x * forward.x + linv.y * forward.y + linv.z * forward.z; + const wheelbase = Math.max(0.01, v.size.l * wheelbaseRatio); + const latA = vFwd * vFwd * Math.tan(Math.min(1.5, Math.abs(steering))) / wheelbase; + const latG = Math.min(maxG, latA / gEff); + const engineForce = throttle * mass * (v.acceleration + extraAccel) / wheelCount; + + // Shift 漂移:降后轮侧向摩擦甩尾,保留部分后驱维持车速,不额外后轮制动 + if (c.input.shift) this.handbrakeBlend = 1; + else if (this.handbrakeBlend > 0) { + this.handbrakeBlend = Math.max(0, this.handbrakeBlend - delta / Math.max(1e-4, handbrakeReleaseTime)); + } + const rearDriveScale = 1 - this.handbrakeBlend * (1 - handbrakeRearDriveScale); + for (let i = 0; i < wheelCount; i++) { + vehicleController.setWheelEngineForce(i, i >= 2 ? engineForce * rearDriveScale : engineForce); + } + + const spaceBrake = Number(c.input.space) * mass * v.deceleration / wheelCount * delta; + for (let i = 0; i < wheelCount; i++) { + vehicleController.setWheelBrake(i, spaceBrake); + } + + const gripUsed = latG / maxG; + const frontFriction = sideFrictionIdle + (sideFrictionFrontMin - sideFrictionIdle) * gripUsed; + const gripRearFriction = sideFrictionIdle + (sideFrictionRearMin - sideFrictionIdle) * gripUsed; + const rearFriction = gripRearFriction + (handbrakeRearFriction - gripRearFriction) * this.handbrakeBlend; + vehicleController.setWheelSideFrictionStiffness(0, frontFriction); + vehicleController.setWheelSideFrictionStiffness(1, frontFriction); + vehicleController.setWheelSideFrictionStiffness(2, rearFriction); + vehicleController.setWheelSideFrictionStiffness(3, rearFriction); + } + + // 翻车复位 + resetUpright() { + const v = this.active; + if (!v || this.ctrl.controllerMode !== 1) return; + const { chassisBody } = v; + const t = chassisBody.translation(); + chassisBody.setTranslation({ x: t.x, y: t.y + v.size.h, z: t.z }); + chassisBody.setRotation({ x: 0, y: 0, z: 0, w: 1 }); + chassisBody.setLinvel({ x: 0, y: 0, z: 0 }); + chassisBody.setAngvel({ x: 0, y: 0, z: 0 }); + this.handbrakeBlend = 0; + } + + // 计算车身外侧的可用下车位置 + private findExitPosition(v: VehicleInstance, out: THREE.Vector3): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + this.scratchLocal.copy(v.driverSeatPosition).multiplyScalar(v.scale); + const seatX = Math.max(-v.halfExtents.x, Math.min(v.halfExtents.x, this.scratchLocal.x)); + const side = this.scratchLocal.z >= 0 ? 1 : -1; + const clearance = this.capsuleRadius() + this.boardingPadding * v.scale; + const sideOffset = v.halfExtents.z + clearance; + const endOffset = v.halfExtents.x + clearance; + const candidates = [ + { x: seatX, z: side * sideOffset }, + { x: seatX, z: -side * sideOffset }, + { x: endOffset, z: 0 }, + { x: -endOffset, z: 0 }, + ]; + let groundedFallback: THREE.Vector3 | null = null; + const maxDist = v.size.h + this.capsuleHeight() * 3 + 2; + this.raycaster.far = maxDist; + + for (const candidate of candidates) { + this.scratchLocal.set(candidate.x, v.halfExtents.y + this.capsuleHeight() + 0.5, candidate.z); + v.vehicleGroup.localToWorld(this.scratchLocal); + out.copy(this.scratchLocal); + this.raycaster.set(out, this.scratchDown); + let bestHit: THREE.Intersection | undefined; + for (const mesh of this.ctrl.getColliderMeshes()) { + const hits = this.raycaster.intersectObject(mesh, false); + if (hits.length && (!bestHit || hits[0].distance < bestHit.distance)) { + bestHit = hits[0]; + } + } + if (!bestHit) continue; + out.copy(bestHit.point); + out.y += this.ctrl.getCapsuleGroundHeight(); + groundedFallback ??= out.clone(); + if (this.isCharacterPositionFree(out, v)) return out; + } + + if (groundedFallback) return out.copy(groundedFallback); + this.scratchLocal.set(seatX, -v.halfExtents.y + this.ctrl.getCapsuleGroundHeight(), side * sideOffset); + v.vehicleGroup.localToWorld(this.scratchLocal); + return out.copy(this.scratchLocal); + } + + /** 检测下车点处胶囊是否与静态/其他运动学碰撞体重叠。 */ + private isCharacterPositionFree(position: THREE.Vector3, v: VehicleInstance): boolean { + const cap = this.ctrl.playerCapsule; + const info = cap.capsuleInfo; + if (!info || !this.ctrl.getColliderMeshes().length) return true; + + const origParent = cap.parent; + const origPos = cap.position.clone(); + const origQuat = cap.quaternion.clone(); + const skipMesh = this.ctrl.getKinematicColliderEntries().find(e => e.source === v.vehicleGroup)?.mesh; + this.ctrl.scene.attach(cap); + cap.position.copy(position); + cap.updateMatrixWorld(true); + const before = position.clone(); + for (const mesh of this.ctrl.getColliderMeshes()) { + if (mesh === skipMesh) continue; + cap.updateMatrixWorld(true); + applyCapsuleCollision(cap, info, mesh, this.exitCheckTemps); + } + const free = cap.position.distanceTo(before) < info.radius * 0.5; + origParent?.attach(cap); + cap.position.copy(origPos); + cap.quaternion.copy(origQuat); + cap.updateMatrixWorld(true); + return free; + } + + /** 有轮子打在运动学网格上时,按平台 prev→current 带走车身。 */ + private applyKinematicCarry(v: VehicleInstance) { + const votes = new Map(); + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const mesh = v.vehicleController.wheelContactMesh(i); + if (!mesh) continue; + const entry = this.ctrl.getKinematicColliderEntries().find(e => e.mesh === mesh); + if (!entry) continue; + votes.set(entry, (votes.get(entry) ?? 0) + 1); + } + + let best: KinematicColliderEntry | null = null; + let bestCount = 0; + for (const [entry, count] of votes) { + if (count > bestCount) { + best = entry; + bestCount = count; + } + } + if (!best) return; + + this.carryPrevInv.copy(best.prevWorldMatrix).invert(); + this.scratchWorld.copy(v.chassisBody.position).applyMatrix4(this.carryPrevInv); + v.chassisBody.position.copy(this.scratchWorld).applyMatrix4(best.source.matrixWorld); + if (best.deltaRotY !== 0) { + this.carryYaw.setFromAxisAngle(this.carryUp, best.deltaRotY); + v.chassisBody.quaternion.premultiply(this.carryYaw); + } + } + + // 同步单辆车的模型、车轮和调试盒 + private syncVehicleVisual(v: VehicleInstance, delta = 1 / 60) { + const t = v.chassisBody.translation(); + const r = v.chassisBody.rotation(); + v.vehicleGroup.position.set(t.x, t.y, t.z); + v.vehicleGroup.quaternion.set(r.x, r.y, r.z, r.w); + if (v.wheelRayDebug) v.wheelRayDebug.visible = this.params.debug.showWheelRays; + if (v.wheelTravelDebug) v.wheelTravelDebug.visible = this.params.debug.showWheelTravel; + if (v.wheelSphereDebug) { + v.wheelSphereDebug.visible = this.params.debug.showWheelSpheres; + if (v.wheelSphereDebug.visible) this.syncWheelSphereDebug(v); + } + v.updateWheelVisuals?.(delta); + } + + /** 取车辆世界前向。 */ + getForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + return out.copy(v.forwardLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** 取驾驶位世界前向。 */ + getDriverForward(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + this.scratchQuat.setFromAxisAngle(this.scratchUp.set(0, 1, 0), v.driverSeatRotation); + this.scratchLocal.set(0, 0, -1).applyQuaternion(this.scratchQuat); + v.vehicleGroup.updateMatrixWorld(true); + return out.copy(this.scratchLocal).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** 取车辆世界上向。 */ + getUp(v: VehicleInstance, out = new THREE.Vector3()): THREE.Vector3 { + v.vehicleGroup.updateMatrixWorld(true); + return out.set(0, 1, 0).transformDirection(v.vehicleGroup.matrixWorld).normalize(); + } + + /** + * 为每只车轮登记只碰 DEBRIS 的运动学球。 + * 球心每帧由底盘位姿 + 悬挂长度推算,不在此写位置。 + */ + private registerWheelSpheres(v: VehicleInstance): number[] { + const ids: number[] = []; + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + const userData: VehicleWheelColliderUserData = { vehicle: v, wheelIndex: i }; + const handle = this.ctrl.addCollider({ + motion: "kinematic", + shape: { kind: "sphere", radius: Math.max(1e-4, wheel.radius) }, + groups: CollisionGroup.VEHICLE, + mask: CollisionGroup.DEBRIS, + userData, + }); + ids.push(handle.id); + } + return ids; + } + + /** 创建车轮碰撞球线框,挂在 vehicleGroup 下(底盘局部坐标)。 */ + private attachWheelSphereDebug(v: VehicleInstance): void { + const group = new THREE.Group(); + group.name = "wheelSphereDebug"; + group.userData.excludeFromCollider = true; + const geo = new THREE.SphereGeometry(1, 16, 12); + const mat = new THREE.MeshBasicMaterial({ + color: 0x00e8a0, + wireframe: true, + transparent: true, + opacity: 0.45, + }); + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + const mesh = new THREE.Mesh(geo, mat); + mesh.name = `wheelSphereDebug_${i}`; + mesh.frustumCulled = false; + mesh.renderOrder = 22; + mesh.scale.setScalar(Math.max(1e-4, wheel.radius)); + group.add(mesh); + } + group.visible = this.params.debug.showWheelSpheres; + v.vehicleGroup.add(group); + v.wheelSphereDebug = group; + this.syncWheelSphereDebug(v); + } + + /** 按悬挂长度把轮球线框对齐到轮心(底盘局部)。 */ + private syncWheelSphereDebug(v: VehicleInstance): void { + const group = v.wheelSphereDebug; + if (!group) return; + let meshIndex = 0; + const n = v.vehicleController.numWheels(); + for (let i = 0; i < n; i++) { + const wheel = v.vehicleController.wheelAt(i); + const mesh = group.children[meshIndex] as THREE.Mesh | undefined; + if (!wheel || !mesh) continue; + meshIndex++; + this.wheelSphereDir.copy(wheel.direction); + const dirLen = this.wheelSphereDir.length(); + if (dirLen > 1e-8) this.wheelSphereDir.multiplyScalar(1 / dirLen); + else this.wheelSphereDir.set(0, -1, 0); + mesh.position.copy(wheel.connectionPoint).addScaledVector(this.wheelSphereDir, wheel.suspensionLength); + mesh.scale.setScalar(Math.max(1e-4, wheel.radius)); + } + } + + /** 移除车轮碰撞球调试线框并释放几何 / 材质。 */ + private disposeWheelSphereDebug(v: VehicleInstance): void { + const group = v.wheelSphereDebug; + if (!group) return; + group.removeFromParent(); + const geos = new Set(); + const mats = new Set(); + for (const child of group.children) { + const mesh = child as THREE.Mesh; + if (mesh.geometry) geos.add(mesh.geometry); + const mat = mesh.material; + if (Array.isArray(mat)) for (const m of mat) mats.add(m); + else if (mat) mats.add(mat); + } + for (const g of geos) g.dispose(); + for (const m of mats) m.dispose(); + v.wheelSphereDebug = undefined; + } + + // 销毁全部车辆 + destroy() { + for (const v of this.list) { + if (v.meshColliderId != null) this.ctrl.removeCollider(v.meshColliderId); + if (v.chassisColliderId != null) this.ctrl.removeCollider(v.chassisColliderId); + for (const id of v.wheelColliderIds ?? []) this.ctrl.removeCollider(id); + this.disposeWheelSphereDebug(v); + this.ctrl.scene.remove(v.vehicleGroup); + v.destroyVehicleController?.(); + } + this.list = []; + this.active = null; + } + + // 清除活动车辆的驱动力 + stopActive() { + if (this.active) { + this.applyParkingBrake(this.active, this.ctrl.getCurrentDelta() || 1 / 60); + } + } + + // 对无人车辆施加四轮驻车制动 + private applyParkingBrake(v: VehicleInstance, delta: number) { + const wheelCount = Math.max(1, v.vehicleController.numWheels()); + const brake = v.chassisBody.mass() * v.deceleration / wheelCount * delta; + for (let i = 0; i < wheelCount; i++) { + v.vehicleController.setWheelEngineForce(i, 0); + v.vehicleController.setWheelBrake(i, brake); + v.vehicleController.setWheelSideFrictionStiffness(i, v.sideFrictionStiffness); + } + } + + /** + * 运行时修改底盘盒底相对轮胎触地点的高度。 + * 重定底盘中心的同时反向平移车模和车轮,使车辆外观及轮胎触地点保持原位。 + */ + setClearance(v: VehicleInstance, clearance: number): void { + if (!v || !Number.isFinite(clearance) || v.scale <= 0) return; + + const sy = Math.max(1e-3, v.chassisSizeScaleY); + const minHeight = 1e-3; + const maxClearance = Math.max( + 0, + v.chassisTopClearance - minHeight / (v.scale * sy), + ); + const nextClearance = THREE.MathUtils.clamp(clearance, 0, maxClearance); + const previousClearance = v.chassisClearance; + if (Math.abs(nextClearance - previousClearance) < 1e-8) return; + + const oldHalfY = v.halfExtents.y; + const newHalfY = Math.max( + minHeight * 0.5, + (v.chassisTopClearance - nextClearance) * v.scale * sy * 0.5, + ); + // 底盘中心相对轮胎触地点的位置 = clearance + 碰撞盒半高。 + const centerShift = (nextClearance - previousClearance) * v.scale + + (newHalfY - oldHalfY); + + const nextHalfExtents = v.halfExtents.clone(); + nextHalfExtents.y = newHalfY; + v.chassisClearance = nextClearance; + this.applyChassisShape(v, nextHalfExtents, centerShift); + } + + /** 运行时修改底盘碰撞盒三轴尺寸比例;Y 轴仍以盒底为缩放锚点。 */ + setChassisSizeScale( + v: VehicleInstance, + sizeScale: { x?: number; y?: number; z?: number }, + ): void { + if (!v || v.scale <= 0) return; + + const oldSx = Math.max(1e-3, v.chassisSizeScaleX); + const oldSy = Math.max(1e-3, v.chassisSizeScaleY); + const oldSz = Math.max(1e-3, v.chassisSizeScaleZ); + const sx = Math.max(1e-3, sizeScale.x ?? oldSx); + const sy = Math.max(1e-3, sizeScale.y ?? oldSy); + const sz = Math.max(1e-3, sizeScale.z ?? oldSz); + if ( + Math.abs(sx - oldSx) < 1e-8 + && Math.abs(sy - oldSy) < 1e-8 + && Math.abs(sz - oldSz) < 1e-8 + ) return; + + const nextHalfExtents = new THREE.Vector3( + v.halfExtents.x / oldSx * sx, + Math.max( + 5e-4, + (v.chassisTopClearance - v.chassisClearance) * v.scale * sy * 0.5, + ), + v.halfExtents.z / oldSz * sz, + ); + const centerShift = nextHalfExtents.y - v.halfExtents.y; + v.chassisSizeScaleX = sx; + v.chassisSizeScaleY = sy; + v.chassisSizeScaleZ = sz; + this.applyChassisShape(v, nextHalfExtents, centerShift); + } + + /** 同步底盘尺寸、车轮硬点、视觉原点和两套碰撞表示。 */ + private applyChassisShape( + v: VehicleInstance, + nextHalfExtents: THREE.Vector3, + centerShift: number, + ): void { + const shiftsCenter = Math.abs(centerShift) >= 1e-8; + + if (shiftsCenter) { + for (const child of v.vehicleGroup.children) { + if ( + child === v.physicsBoxMesh + || child === v.wheelRayDebug + || child === v.wheelTravelDebug + || child === v.wheelSphereDebug + || child === this.ctrl.playerCapsule + ) continue; + child.position.y -= centerShift; + } + + const wheelCount = v.vehicleController.numWheels(); + for (let i = 0; i < wheelCount; i++) { + const wheel = v.vehicleController.wheelAt(i); + if (!wheel) continue; + this.scratchLocal.copy(wheel.connectionPoint); + this.scratchLocal.y -= centerShift; + v.vehicleController.setWheelChassisConnectionPointCs(i, this.scratchLocal); + } + + v.driverSeatPosition.y -= centerShift / v.scale; + + // 移动底盘中心以抵消本地内容位移,车身与轮胎在世界空间保持不动。 + this.scratchUp.set(0, centerShift, 0).applyQuaternion(v.chassisBody.quaternion); + v.chassisBody.position.add(this.scratchUp); + v.vehicleGroup.position.copy(v.chassisBody.position); + v.vehicleGroup.quaternion.copy(v.chassisBody.quaternion); + v.vehicleGroup.updateMatrixWorld(true); + + this.ctrl.translateKinematicColliderContent( + v.vehicleGroup, + this.scratchLocal.set(0, -centerShift, 0), + ); + } + + v.halfExtents.copy(nextHalfExtents); + v.chassisBody.setHalfExtents(v.halfExtents); + + const chassisCol = v.chassisColliderId != null + ? this.ctrl.collisionWorld.get(v.chassisColliderId) + : undefined; + if (chassisCol?.shape.kind === "box") { + chassisCol.shape.halfExtents.copy(v.halfExtents); + } + + if (v.physicsBoxMesh) { + v.physicsBoxMesh.geometry.dispose(); + v.physicsBoxMesh.geometry = new THREE.BoxGeometry( + v.halfExtents.x * 2, + v.halfExtents.y * 2, + v.halfExtents.z * 2, + ); + v.physicsBoxMesh.scale.set(1, 1, 1); + } + if (v.wheelSphereDebug) this.syncWheelSphereDebug(v); + if (this.active === v && this.ctrl.controllerMode === 1) { + this.ctrl.syncMountedPlayer(v); + } + } + + // 解除四轮驻车制动 + private releaseParkingBrake(v: VehicleInstance) { + for (let i = 0; i < v.vehicleController.numWheels(); i++) v.vehicleController.setWheelBrake(i, 0); + } + + /** + * 运行时等比缩放车辆。 + * 保持 vehicleGroup.scale = 1,只缩放子节点与物理尺寸,避免座椅/下车点与 halfExtents 双重缩放。 + * 悬挂刚度、阻尼、摩擦滑移不随 scale 变。 + */ + setScale(v: VehicleInstance, newScale: number): void { + if (!v || newScale <= 0) return; + const prev = v.scale > 0 ? v.scale : 1; + const ratio = newScale / prev; + if (Math.abs(ratio - 1) < 1e-12) { + v.scale = newScale; + return; + } + + const { chassisBody, vehicleController } = v; + const n = vehicleController.numWheels(); + + // 缩放前用局部轮底估算,缩放后抬/降底盘使胎底高度基本不变 + let localBottom = Infinity; + for (let i = 0; i < n; i++) { + const wheel = vehicleController.wheelAt(i); + if (!wheel) continue; + localBottom = Math.min(localBottom, wheel.connectionPoint.y - wheel.radius); + } + if (!Number.isFinite(localBottom)) localBottom = -v.halfExtents.y; + + for (const child of v.vehicleGroup.children) { + if (child === v.wheelRayDebug || child === v.wheelTravelDebug || child === v.wheelSphereDebug) continue; + child.position.multiplyScalar(ratio); + child.scale.multiplyScalar(ratio); + } + + v.halfExtents.multiplyScalar(ratio); + v.size.l *= ratio; + v.size.w *= ratio; + v.size.h *= ratio; + v.maxSpeed *= ratio; + v.acceleration *= ratio; + v.deceleration *= ratio; + v.scale = newScale; + + chassisBody.setHalfExtents(v.halfExtents); + chassisBody.setMass(chassisBody.mass() * ratio * ratio * ratio); + chassisBody.gravityScale = newScale; + chassisBody.linearVelocity.multiplyScalar(ratio); + + for (let i = 0; i < n; i++) { + const wheel = vehicleController.wheelAt(i); + if (!wheel) continue; + vehicleController.setWheelChassisConnectionPointCs(i, wheel.connectionPoint.multiplyScalar(ratio)); + vehicleController.setWheelRadius(i, wheel.radius * ratio); + vehicleController.setWheelSuspensionRestLength(i, wheel.restLength * ratio); + vehicleController.setWheelMaxSuspensionTravel(i, wheel.maxSuspensionTravel * ratio); + wheel.suspensionLength *= ratio; + wheel.visualLength *= ratio; + } + + const chassisCol = v.chassisColliderId != null + ? this.ctrl.collisionWorld.get(v.chassisColliderId) + : undefined; + if (chassisCol?.shape.kind === "box") { + chassisCol.shape.halfExtents.copy(v.halfExtents); + } + + for (const id of v.wheelColliderIds ?? []) { + const col = this.ctrl.collisionWorld.get(id); + if (col?.shape.kind !== "sphere") continue; + const data = col.userData as VehicleWheelColliderUserData | undefined; + const wheel = data != null ? v.vehicleController.wheelAt(data.wheelIndex) : undefined; + if (wheel) col.shape.radius = Math.max(1e-4, wheel.radius); + } + + if (v.wheelSphereDebug) this.syncWheelSphereDebug(v); + + // 外观 mesh 碰撞在加载时烘焙,用 contentScale 对齐子节点缩放 + this.ctrl.scaleKinematicColliderContent(v.vehicleGroup, ratio); + + chassisBody.position.y += localBottom * (1 - ratio); + v.vehicleGroup.position.copy(chassisBody.position); + v.vehicleGroup.quaternion.copy(chassisBody.quaternion); + v.vehicleGroup.updateMatrixWorld(true); + + if (this.active === v && this.ctrl.controllerMode === 1) { + this.ctrl.syncMountedPlayer(v); + } + } + + /** 将列表中全部车辆缩放到同一绝对 scale。 */ + setScaleAll(newScale: number): void { + for (const v of this.list) this.setScale(v, newScale); + } + + private capsuleRadius(): number { + return this.ctrl.playerCapsule?.capsuleInfo?.radius ?? 0; + } + + private capsuleHeight(): number { + const info = this.ctrl.playerCapsule?.capsuleInfo; + if (!info) return 0; + const sy = this.ctrl.playerCapsule.scale.y || 1; + return (-info.segment.end.y) * sy + 2 * info.radius; + } +} diff --git a/three-player-controller/src/types/index.ts b/three-player-controller/src/types/index.ts new file mode 100644 index 0000000..0473e24 --- /dev/null +++ b/three-player-controller/src/types/index.ts @@ -0,0 +1,455 @@ +import * as THREE from "three"; +import type { BVHVehicleController } from "../utils/vehiclePhysics/BVHVehicleController"; +import type { VehicleRigidBody } from "../utils/vehiclePhysics/VehicleRigidBody"; + +// ==================== 玩家配置 ==================== + +/** 已加载的模型来源(玩家使用)。 */ +export type LoadedModelSource = { + /** 已加载的模型根节点。 */ + model: THREE.Object3D; + /** 模型使用的动画片段。 */ + animations: THREE.AnimationClip[]; + /** 已加载模型与旧模型路径不能同时使用。 */ + url?: never; +}; + +/** 旧版 glTF 模型路径来源(玩家使用)。 */ +export type LegacyModelSource = { + /** + * 模型路径(GLB/GLTF)。 + * @deprecated 请在外部加载模型,并传入 model 和 animations。 + */ + url: string; + /** 模型路径与已加载模型不能同时使用。 */ + model?: never; + /** 模型路径与外部动画片段不能同时使用。 */ + animations?: never; +}; + +/** 已加载的玩家模型来源。 */ +export type LoadedPlayerModelSource = LoadedModelSource; + +/** 旧版 glTF 玩家模型来源。 */ +export type LegacyPlayerModelSource = LegacyModelSource; + +/** 玩家模型来源。 */ +export type PlayerModelSource = LoadedPlayerModelSource | LegacyPlayerModelSource; + +/** 玩家模型、动画和移动参数。 */ +export type PlayerModelOptions = PlayerModelSource & { + /** 模型缩放。 */ + scale: number; + /** 静止动画名。 */ + idleAnim: string; + /** 行走动画名。 */ + walkAnim: string; + /** 跑步动画名。 */ + runAnim: string; + /** 跳跃动画名;或 [起跳, 循环, 落地] 三段动画名。 */ + jumpAnim: string | [startAnim: string, loopAnim: string, endAnim: string]; + /** 左移动画,默认复用 walkAnim。 */ + leftWalkAnim?: string; + /** 右移动画,默认复用 walkAnim。 */ + rightWalkAnim?: string; + /** 后退动画,默认复用 walkAnim。 */ + backwardAnim?: string; + /** 飞行动画,默认复用 idleAnim。 */ + flyAnim?: string; + /** 飞行待机动画,默认复用 idleAnim。 */ + flyIdleAnim?: string; + /** 飞行前进悬停动画,默认复用 flyAnim。 */ + flyHoverForwardAnim?: string; + /** 飞行后退悬停动画,默认复用 flyIdleAnim。 */ + flyHoverBackAnim?: string; + /** 飞行左移悬停动画,默认复用 flyIdleAnim。 */ + flyHoverLeftAnim?: string; + /** 飞行右移悬停动画,默认复用 flyIdleAnim。 */ + flyHoverRightAnim?: string; + /** 飞行上升悬停动画,默认复用 flyIdleAnim。 */ + flyHoverUpAnim?: string; + /** 飞行下降悬停动画,默认复用 flyIdleAnim。 */ + flyHoverDownAnim?: string; + /** 驾驶循环动画名,默认复用 idleAnim。 */ + drivingAnim?: string; + /** 重力基准值(按 scale 缩放),默认 -2400。 */ + gravity?: number; + /** 跳跃高度基准值(按 scale 缩放),默认 600。 */ + jumpHeight?: number; + /** 移动速度基准值(按 scale 缩放),默认 200。 */ + speed?: number; + /** 跑步速度基准值(按 scale 缩放),默认 600。 */ + runSpeed?: number; + /** 飞行速度基准值(按 scale 缩放),默认 2100。 */ + flySpeed?: number; + /** 人物初始朝向,单位为弧度,默认 0。 */ + rotateY?: number; + /** 头部骨骼名,用于第一人称相机挂载。 */ + headBoneName?: string; + /** 第一人称相机局部偏移。 */ + firstPersonCameraOffset?: [number, number, number]; + /** 胶囊体半径倍率,默认 1。 */ + capsuleRadiusRatio?: number; + /** XZ 加速响应速度,默认 30。 */ + acceleration?: number; + /** XZ 减速响应速度,默认 30。 */ + deceleration?: number; +}; + +/** 移动端按钮的布局和图标配置。 */ +export type MobileButtonOptions = { + /** 左侧位置,单位为 px。 */ + left?: number; + /** 右侧位置,单位为 px。 */ + right?: number; + /** 顶部位置,单位为 px。 */ + top?: number; + /** 底部位置,单位为 px。 */ + bottom?: number; + /** 按钮直径,单位为 px,默认 56。 */ + size?: number; + /** 自定义图片 URL。 */ + icon?: string; +}; + +/** 跳跃按钮配置。 */ +export type JumpButtonOptions = MobileButtonOptions & { + /** 车辆模式下的刹车图片 URL。 */ + brakeIcon?: string; +}; + +/** 移动端虚拟控制 UI 配置。 */ +export type MobileControlsOptions = { + /** 是否显示摇杆,默认 true。 */ + joystick?: boolean; + /** 跳跃/刹车按钮;false 隐藏,对象可自定义图片和布局。 */ + jump?: boolean | JumpButtonOptions; + /** 飞行按钮;false 隐藏,对象可自定义图片和布局。 */ + fly?: boolean | MobileButtonOptions; + /** 视角切换按钮;false 隐藏,对象可自定义图片和布局。 */ + view?: boolean | MobileButtonOptions; + /** 上下车按钮;false 隐藏,对象可自定义图片和布局。 */ + vehicle?: boolean | MobileButtonOptions; +}; + +/** 可重映射的输入动作。 */ +export type KeyAction = + | "forward" | "backward" | "left" | "right" + | "sprint" | "jump" | "toggleView" | "toggleFly" | "toggleVehicle"; + +/** 自定义键位映射;未填使用默认键,传入 code 或数组覆盖,传入 null 禁用。 */ +export type KeyMap = Partial>; + +/** 玩家控制器初始化配置。 */ +export type PlayerControllerOptions = { + /** three.js 场景实例。 */ + scene: THREE.Scene; + /** three.js 相机实例。 */ + camera: THREE.PerspectiveCamera; + /** 外部相机控制器,通常为 OrbitControls。 */ + controls: any; + /** 角色模型与参数配置。 */ + playerModelConfig: PlayerModelOptions; + /** 初始出生点,默认 (0, 0, 0)。 */ + initPos?: THREE.Vector3; + /** 鼠标灵敏度,默认 5。 */ + mouseSensitivity?: number; + /** 第三人称最小镜头距离,默认 100。 */ + minCamDistance?: number; + /** 第三人称最大镜头距离,默认 440。 */ + maxCamDistance?: number; + /** 相机看向点高度比例(0 为底部,1 为顶部),默认 0.8。 */ + camLookAtHeightRatio?: number; + /** 初始化时批量创建的碰撞体;未传则不自动建碰撞,需事后 `addCollider`。 */ + colliders?: import("../collision/colliderDesc").ColliderDesc[]; + /** 移动端是否显示虚拟控制 UI,默认 true。 */ + isShowMobileControls?: boolean; + /** 移动端按钮显隐配置。 */ + mobileControls?: MobileControlsOptions; + /** 第三人称鼠标控制模式,默认 1。 */ + thirdMouseMode?: 0 | 1 | 2 | 3 | 4 | 5; + /** 是否允许滚轮缩放,默认 false。 */ + enableZoom?: boolean; + /** 是否启用过肩视角,默认 false。 */ + enableOverShoulderView?: boolean; + /** 第三人称相机过肩视角横向偏移比例,默认 0.2。 */ + camOverShoulderOffsetRatio?: number; + /** 初始是否进入第一人称,默认 false。 */ + isFirstPerson?: boolean; + /** 是否启用弹簧相机,默认 false。 */ + enableSpringCamera?: boolean; + /** 弹簧相机平滑时间,单位为秒,默认 0.05。 */ + springCameraTime?: number; + /** 时间缩放系数;小于 1 为慢动作,大于 1 为快进,默认 1。 */ + timeScale?: number; + /** 自定义键位映射。 */ + keyMap?: KeyMap; +}; + +// ==================== 车辆配置 ==================== + +/** 已加载的车辆模型来源(车辆不使用动画片段)。 */ +export type LoadedVehicleModelSource = { + /** 已加载的模型根节点。 */ + model: THREE.Object3D; + /** 已加载模型与旧模型路径不能同时使用。 */ + url?: never; +}; + +/** 旧版 glTF 车辆模型来源。 */ +export type LegacyVehicleModelSource = { + /** + * 模型路径(GLB/GLTF)。 + * @deprecated 请在外部加载模型,并传入 model。 + */ + url: string; + /** 模型路径与已加载模型不能同时使用。 */ + model?: never; +}; + +/** 车辆模型来源。 */ +export type VehicleModelSource = LoadedVehicleModelSource | LegacyVehicleModelSource; + +/** 底盘碰撞盒与阻尼。 */ +export type VehicleChassisOptions = { + /** 密度,质量 = 碰撞盒体积 × 密度,默认 1。 */ + density?: number; + /** 线阻尼,默认 0.05。 */ + linearDamping?: number; + /** 角阻尼,默认 0.5。 */ + angularDamping?: number; + /** 盒底相对轮胎触地点的高度(随 scale 缩放);不传则盒底至少罩到轮心。 */ + clearance?: number; + /** + * 相对自动 AABB 的尺寸比例,默认 1。 + * X/Z 绕水平中心缩放;Y 从盒底向上缩放,不改变底盘离地高度。 + */ + sizeScale?: { + x?: number; + y?: number; + z?: number; + }; +}; + +/** 悬挂与轮胎。 */ +export type VehicleSuspensionOptions = { + /** 静止长度(米,随 scale 缩放);不传则取 max(轮直径 × 0.2, 静载下沉 × 1.2)。 */ + restLength?: number; + /** 最大行程(米,随 scale 缩放),默认 0.35。 */ + maxTravel?: number; + /** 刚度(质量归一化),默认 18。 */ + stiffness?: number; + /** 压缩阻尼,默认 2.1。 */ + compression?: number; + /** 回弹阻尼,默认 2.5。 */ + relaxation?: number; + /** 单轮最大悬挂力,默认 6000。 */ + maxForce?: number; + /** 纵向抓地,默认 8。 */ + frictionSlip?: number; + /** 侧向摩擦刚度,默认 1。 */ + sideFrictionStiffness?: number; + /** 侧向力对侧倾的影响,默认 0.12。 */ + rollInfluence?: number; +}; + +/** 转向手感。 */ +export type VehicleSteeringOptions = { + /** 最大转向角(弧度),默认 π/5。 */ + maxSteerAngle?: number; + /** 打满舵时间(秒),默认 0.45。 */ + steerTime?: number; + /** 低速回正时间(秒),默认 0.55。 */ + steerReturnTimeSlow?: number; + /** 高速回正时间(秒),默认 0.4。 */ + steerReturnTimeFast?: number; + /** 最高车速时转向角相对满舵的比例,默认 0.3。 */ + highSpeedSteerScale?: number; +}; + +/** 过弯抓地与手刹。 */ +export type VehicleGripOptions = { + /** 侧向加速度上限(g),默认 1.2。 */ + maxG?: number; + /** 直线侧向摩擦,默认 1。 */ + sideFrictionIdle?: number; + /** 满载前轮侧向摩擦下限,默认 0.55。 */ + sideFrictionFrontMin?: number; + /** 满载后轮侧向摩擦下限,默认 0.45。 */ + sideFrictionRearMin?: number; + /** 手刹后轮侧向摩擦,默认 0.35。 */ + handbrakeRearFriction?: number; + /** 手刹时后驱比例,默认 0.65。 */ + handbrakeRearDriveScale?: number; + /** 松手刹恢复时间(秒),默认 0.15。 */ + handbrakeReleaseTime?: number; + /** 轴距相对车长比例,默认 0.55。 */ + wheelbaseRatio?: number; +}; + +/** 动力。 */ +export type VehiclePowerOptions = { + /** 最高速度基准(km/h,按 scale 缩放),默认 100。 */ + maxSpeed?: number; + /** 加速度基准(m/s²,按 scale 缩放),默认 5。 */ + acceleration?: number; + /** 制动减速度基准(m/s²,按 scale 缩放),默认 5。 */ + deceleration?: number; +}; + +/** 车辆模型、物理和驾驶参数。 */ +export type VehicleOptions = VehicleModelSource & { + /** 车辆初始世界坐标;y 为最低轮底对齐高度。 */ + position: THREE.Vector3; + /** 车轮节点名,顺序为左前、右前、左后、右后。 */ + wheelsNames: string[]; + /** 车辆模型缩放,默认 1。 */ + scale?: number; + /** 驾驶位胶囊中心,使用车辆底盘局部坐标。 */ + driverSeatPosition: THREE.Vector3; + /** 驾驶位相对底盘局部的水平旋转(弧度),默认 0。 */ + driverSeatRotation?: number; + /** 模型绕 Y 旋转(弧度),默认 -π/2。 */ + modelRotation?: number; + /** 驾驶时镜头是否跟随车辆朝向,默认 true。 */ + followVehicleDirection?: boolean; + /** 调试显示。 */ + debug?: { + /** 是否显示底盘物理盒,默认 false。 */ + showPhysicsBox?: boolean; + /** 是否显示车轮射线,默认 false。 */ + showWheelRays?: boolean; + /** 是否显示车轮 Y 向活动范围,默认 false。 */ + showWheelTravel?: boolean; + /** 是否显示车轮动态碰撞球,默认 false。 */ + showWheelSpheres?: boolean; + }; + /** 底盘碰撞盒与阻尼。 */ + chassis?: VehicleChassisOptions; + /** 悬挂与轮胎。 */ + suspension?: VehicleSuspensionOptions; + /** 转向手感。 */ + steering?: VehicleSteeringOptions; + /** 过弯抓地与手刹。 */ + grip?: VehicleGripOptions; + /** 动力。 */ + power?: VehiclePowerOptions; +}; + +/** 已加载车辆的运行时对象。 */ +export type VehicleInstance = { + /** 车辆模型组。 */ + vehicleGroup: THREE.Group; + /** 底盘刚体。 */ + chassisBody: VehicleRigidBody; + /** BVH 车辆控制器。 */ + vehicleController: BVHVehicleController; + /** 同步车轮视觉的回调。 */ + updateWheelVisuals: (delta?: number) => void; + /** 销毁车辆控制器的回调。 */ + destroyVehicleController: () => void; + /** 车辆缩放。 */ + scale: number; + /** 驾驶位胶囊中心,使用车辆底盘局部坐标。 */ + driverSeatPosition: THREE.Vector3; + /** 驾驶位相对车辆底盘局部的水平旋转(弧度)。 */ + driverSeatRotation: number; + /** 由前后轮中心推算的车辆底盘本地前向。 */ + forwardLocal: THREE.Vector3; + /** 驻车时侧向摩擦。 */ + sideFrictionStiffness: number; + /** 转向参数。 */ + steering: { + maxSteerAngle: number; + steerTime: number; + steerReturnTimeSlow: number; + steerReturnTimeFast: number; + highSpeedSteerScale: number; + }; + /** 抓地预算。 */ + grip: { + maxG: number; + sideFrictionIdle: number; + sideFrictionFrontMin: number; + sideFrictionRearMin: number; + handbrakeRearFriction: number; + handbrakeRearDriveScale: number; + handbrakeReleaseTime: number; + wheelbaseRatio: number; + }; + /** 车辆尺寸(长、宽、高)。 */ + size: { + l: number; + w: number; + h: number; + }; + /** 底盘碰撞盒半边长(底盘局部坐标)。 */ + halfExtents: THREE.Vector3; + /** 当前盒底相对轮胎触地点的高度,使用 scale=1 基准值。 */ + chassisClearance: number; + /** 车身顶面相对轮胎触地点的高度,使用 scale=1 基准值。 */ + chassisTopClearance: number; + /** 底盘碰撞盒的 X 轴尺寸比例。 */ + chassisSizeScaleX: number; + /** 底盘碰撞盒的 Y 轴尺寸比例。 */ + chassisSizeScaleY: number; + /** 底盘碰撞盒的 Z 轴尺寸比例。 */ + chassisSizeScaleZ: number; + /** 最高速度(km/h)。 */ + maxSpeed: number; + /** 加速度(m/s²)。 */ + acceleration: number; + /** 制动减速度(m/s²)。 */ + deceleration: number; + /** 相机是否跟随车辆方向。 */ + followVehicleDirection: boolean; + /** 物理盒体调试网格。 */ + physicsBoxMesh?: THREE.Mesh; + /** 车轮射线调试。 */ + wheelRayDebug?: THREE.Group; + /** 车轮 Y 向活动范围调试。 */ + wheelTravelDebug?: THREE.Group; + /** 车轮动态碰撞球线框调试。 */ + wheelSphereDebug?: THREE.Group; + /** CollisionWorld 中底盘动态碰撞体 id。 */ + chassisColliderId?: number; + /** CollisionWorld 中车模运动学网格 id。 */ + meshColliderId?: number; + /** + * 仅与动态刚体碰撞的车轮球 collider id(与车轮一一对应)。 + * 运动学登记,无独立质量;冲量回写到底盘。 + */ + wheelColliderIds?: number[]; +}; + +/** 车轮球在 CollisionWorld.userData 上的挂载数据。 */ +export type VehicleWheelColliderUserData = { + vehicle: VehicleInstance; + wheelIndex: number; +}; + +/** 运动学碰撞体的 BVH 与帧间变换数据。 */ +export type KinematicColliderEntry = { + /** 原始物体。 */ + source: THREE.Object3D; + /** BVH 网格;顶点是世界烘焙后绕 RTC 中心的小坐标。 */ + mesh: THREE.Mesh; + /**相对建造时几何的额外均匀缩放。 */ + contentScale: number; + /** 相对 source 本地空间的额外位移。 */ + contentOffset: THREE.Vector3; + /** inv(source.matrixWorld_bake) * T(rtcCenter);预置 mesh 时为单位阵。 */ + bakeInverse: THREE.Matrix4; + /** 上一帧世界矩阵。 */ + prevWorldMatrix: THREE.Matrix4; + /** 本帧位移增量。 */ + deltaPos: THREE.Vector3; + /** 本帧 Y 轴旋转增量,单位为弧度。 */ + deltaRotY: number; + /** Worker / 同步 BVH 是否已就绪。 */ + ready: boolean; + /** 用于作废过期的异步 BVH 构建结果。 */ + buildId: number; + /** CollisionWorld 中的运动学碰撞体 id。 */ + worldId: number; +}; diff --git a/three-player-controller/src/types/three-mesh-bvh-worker.d.ts b/three-player-controller/src/types/three-mesh-bvh-worker.d.ts new file mode 100644 index 0000000..505a099 --- /dev/null +++ b/three-player-controller/src/types/three-mesh-bvh-worker.d.ts @@ -0,0 +1,7 @@ +declare module "three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js" { + export class GenerateMeshBVHWorker { + generate(geometry: import("three").BufferGeometry, options?: object): Promise; + dispose(): void; + running: boolean; + } +} diff --git a/three-player-controller/src/utils/BvhWorkerPool.ts b/three-player-controller/src/utils/BvhWorkerPool.ts new file mode 100644 index 0000000..95d7dd9 --- /dev/null +++ b/three-player-controller/src/utils/BvhWorkerPool.ts @@ -0,0 +1,189 @@ +import type { BufferGeometry } from "three"; +import { MeshBVH } from "three-mesh-bvh"; + +/** three-mesh-bvh 的 GenerateMeshBVHWorker 最小接口。 */ +type GenerateMeshBVHWorkerLike = { + generate(geometry: BufferGeometry, options?: object): Promise; + dispose(): void; +}; + +/** 串行队列中的一项构建任务。 */ +type QueueItem = { + geometry: BufferGeometry; // 待构建的几何 + resolve: (bvh: MeshBVH) => void; // 构建成功 + reject: (error: unknown) => void; // 构建失败 +}; + +/** + * 串行复用单个 GenerateMeshBVHWorker。 + * Worker 不可用或失败时回退到原几何的同步 MeshBVH。 + */ +export class BvhWorkerPool { + private worker: GenerateMeshBVHWorkerLike | null = null; // 已创建的 Worker + private workerPromise: Promise | null = null; // 创建中的 Promise,避免重复加载 + private readonly queue: QueueItem[] = []; // 等待构建的任务 + private pumping = false; // 是否正在消费队列 + private disposed = false; // 是否已销毁 + private workerDisabled = false; // 失败后禁用 Worker,后续一律同步构建 + private workerBuildCount = 0; // Worker 构建次数 + private syncBuildCount = 0; // 同步构建次数 + private loggedWorkerReady = false; // 是否已打印 Worker 就绪日志 + + /** 将几何加入队列,返回构建完成的 MeshBVH。 */ + generate(geometry: BufferGeometry): Promise { + if (this.disposed) { + return Promise.reject(new Error("BvhWorkerPool has been disposed.")); + } + return new Promise((resolve, reject) => { + this.queue.push({ geometry, resolve, reject }); + void this.pump(); + }); + } + + /** 销毁 Worker 并清空队列。 */ + dispose() { + this.disposed = true; + this.queue.length = 0; + this.disableWorker(); + this.workerPromise = null; + } + + /** 关闭 Worker,后续任务改走同步构建。 */ + private disableWorker() { + if (!this.workerDisabled && !this.disposed) { + console.warn("[BVH] worker disabled, later builds use sync MeshBVH"); + } + this.workerDisabled = true; + this.worker?.dispose(); + this.worker = null; + this.workerPromise = null; + } + + /** 按间隔打印构建次数,避免每帧刷日志。 */ + private logBuild(mode: "worker" | "sync") { + if (mode === "worker") { + this.workerBuildCount += 1; + if (this.workerBuildCount === 1 || this.workerBuildCount % 20 === 0) { + console.info(`[BVH] worker build #${this.workerBuildCount}`); + } + return; + } + this.syncBuildCount += 1; + if (this.syncBuildCount === 1 || this.syncBuildCount % 20 === 0) { + console.info(`[BVH] sync build #${this.syncBuildCount}`); + } + } + + /** 懒加载并缓存 Worker;失败则返回 null。 */ + private async ensureWorker(): Promise { + if (this.workerDisabled) return null; + if (this.worker) return this.worker; + if (this.workerPromise) return this.workerPromise; + + this.workerPromise = this.loadWorkerCtor() + .then((GenerateMeshBVHWorker) => { + if (this.disposed || this.workerDisabled || !GenerateMeshBVHWorker) return null; + this.worker = new GenerateMeshBVHWorker(); + if (!this.loggedWorkerReady) { + this.loggedWorkerReady = true; + console.info("[BVH] worker ready"); + } + return this.worker; + }) + .catch((error) => { + console.warn("[BVH] worker create failed, sync fallback", error); + this.workerDisabled = true; + return null; + }); + + return this.workerPromise; + } + + /** 动态导入 GenerateMeshBVHWorker。 */ + private async loadWorkerCtor(): Promise<(new () => GenerateMeshBVHWorkerLike) | null> { + const mod = await import("three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js"); + return mod.GenerateMeshBVHWorker; + } + + /** 主线程同步构建。 */ + private buildSync(geometry: BufferGeometry) { + return new MeshBVH(geometry); + } + + /** + * 在克隆几何上交给 Worker 构建,避免 transfer 抽空原 buffer。 + * 构建后把 position / index 写回原几何,供后续射线与 shapecast 使用。 + */ + private async buildWithWorker( + worker: GenerateMeshBVHWorkerLike, + geometry: BufferGeometry + ) { + const clone = geometry.clone(); + const bvh = await worker.generate(clone); + const position = clone.getAttribute("position"); + if (position?.array) { + geometry.setAttribute("position", position); + } + if (clone.index) { + geometry.setIndex(clone.index); + } + return bvh; + } + + /** 让出一帧后再继续消费队列,避免连续同步构建卡住主线程。 */ + private schedulePump() { + const resume = () => { + void this.pump(); + }; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(resume); + return; + } + setTimeout(resume, 0); + } + + /** 串行消费队列:优先 Worker,失败则同步回退并禁用 Worker。 */ + private async pump() { + if (this.pumping || this.disposed) return; + this.pumping = true; + + try { + while (this.queue.length && !this.disposed) { + const item = this.queue.shift(); + if (!item) break; + + try { + const worker = await this.ensureWorker(); + if (this.disposed) { + item.reject(new Error("BvhWorkerPool has been disposed.")); + continue; + } + // Worker 不可用:本任务同步构建,剩余任务下一帧再跑 + if (!worker) { + this.logBuild("sync"); + item.resolve(this.buildSync(item.geometry)); + if (this.queue.length) this.schedulePump(); + break; + } + const bvh = await this.buildWithWorker(worker, item.geometry); + this.logBuild("worker"); + item.resolve(bvh); + } catch (error) { + // 本次 Worker 构建失败:禁用 Worker,本任务改同步,其余下一帧继续 + console.warn("[BVH] worker generate failed, switch to sync", error); + this.disableWorker(); + try { + this.logBuild("sync"); + item.resolve(this.buildSync(item.geometry)); + } catch (fallbackError) { + item.reject(fallbackError ?? error); + } + if (this.queue.length) this.schedulePump(); + break; + } + } + } finally { + this.pumping = false; + } + } +} diff --git a/three-player-controller/src/utils/MobileControls.ts b/three-player-controller/src/utils/MobileControls.ts new file mode 100644 index 0000000..2085ea1 --- /dev/null +++ b/three-player-controller/src/utils/MobileControls.ts @@ -0,0 +1,461 @@ +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { MobileButtonOptions, MobileControlsOptions } from "../types"; + +type MobileButtonName = "jump" | "fly" | "view" | "vehicle"; +type MobileIconName = MobileButtonName | "brake"; + +const iconLabels: Record = { + jump: "JUMP", + brake: "BRAKE", + fly: "FLY", + view: "VIEW", + vehicle: "CAR", +}; + +type SetInputFn = (input: Partial<{ + moveX: number; + moveY: number; + lookDeltaX: number; + lookDeltaY: number; + jump: boolean; + shift: boolean; + toggleView: boolean; + toggleFly: boolean; + toggleVehicle: boolean; +}>) => void; + +class VirtualJoystick { + private baseEl: HTMLDivElement; + private stickEl: HTMLDivElement; + private pointerId: number | null = null; + private center = { x: 0, y: 0 }; + private radius: number; + private onMove: (data: { vector: { x: number; y: number }; distance: number }) => void; + private onEnd: () => void; + + constructor( + zone: HTMLDivElement, + size: number, + onMove: (data: { vector: { x: number; y: number }; distance: number }) => void, + onEnd: () => void, + ) { + this.radius = size / 2; + this.onMove = onMove; + this.onEnd = onEnd; + + // 外环 + this.baseEl = document.createElement("div"); + Object.assign(this.baseEl.style, { + position: "absolute", + left: "50%", + bottom: "50%", + transform: "translate(-50%, 50%)", + width: `${size}px`, + height: `${size}px`, + borderRadius: "50%", + border: "2px solid rgba(0,0,0,0.5)", + backgroundColor: "rgba(0,0,0,0.2)", + boxSizing: "border-box", + pointerEvents: "none", + }); + zone.appendChild(this.baseEl); + + // 内点 + const stickSize = size * 0.2; + this.stickEl = document.createElement("div"); + Object.assign(this.stickEl.style, { + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + width: `${stickSize}px`, + height: `${stickSize}px`, + borderRadius: "50%", + backgroundColor: "rgba(255,255,255,0.7)", + pointerEvents: "none", + }); + this.baseEl.appendChild(this.stickEl); + + zone.addEventListener("pointerdown", this.onPointerDown, { passive: false }); + zone.addEventListener("pointermove", this.onPointerMove, { passive: false }); + zone.addEventListener("pointerup", this.onPointerUp, { passive: false }); + zone.addEventListener("pointercancel", this.onPointerUp, { passive: false }); + } + + private onPointerDown = (e: PointerEvent) => { + if (this.pointerId !== null) return; + this.pointerId = e.pointerId; + const rect = this.baseEl.parentElement!.getBoundingClientRect(); + this.center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + this.baseEl.parentElement!.setPointerCapture(e.pointerId); + e.preventDefault(); + this.updateStick(e.clientX, e.clientY); + }; + + private onPointerMove = (e: PointerEvent) => { + if (e.pointerId !== this.pointerId) return; + e.preventDefault(); + this.updateStick(e.clientX, e.clientY); + }; + + private onPointerUp = (e: PointerEvent) => { + if (e.pointerId !== this.pointerId) return; + this.pointerId = null; + this.stickEl.style.transform = "translate(-50%, -50%)"; + this.onEnd(); + }; + + private updateStick(clientX: number, clientY: number) { + const dx = clientX - this.center.x; + const dy = clientY - this.center.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const clampedDist = Math.min(dist, this.radius); + const angle = Math.atan2(dy, dx); + const ox = Math.cos(angle) * clampedDist; + const oy = Math.sin(angle) * clampedDist; + + this.stickEl.style.transform = `translate(calc(-50% + ${ox}px), calc(-50% + ${oy}px))`; + + // 归一化向量,y 轴取反 + const scale = dist > 0 ? clampedDist / this.radius / dist : 0; + this.onMove({ vector: { x: dx * scale, y: -dy * scale }, distance: clampedDist }); + } + + destroy() { + const zone = this.baseEl.parentElement; + if (zone) { + zone.removeEventListener("pointerdown", this.onPointerDown); + zone.removeEventListener("pointermove", this.onPointerMove); + zone.removeEventListener("pointerup", this.onPointerUp); + zone.removeEventListener("pointercancel", this.onPointerUp); + } + this.baseEl.remove(); + } +} + +export class MobileControls { + setInput: SetInputFn; + controls: OrbitControls; + private options: MobileControlsOptions = {}; + + // 摇杆状态 + joystick: VirtualJoystick | null = null; + prevJoyState = { moveX: 0, moveY: 0, shift: false }; + + // DOM 元素 + joystickZoneEl: HTMLDivElement | null = null; + lookAreaEl: HTMLDivElement | null = null; + jumpBtnEl: HTMLButtonElement | null = null; + flyBtnEl: HTMLButtonElement | null = null; + viewBtnEl: HTMLButtonElement | null = null; + vehicleBtnEl: HTMLButtonElement | null = null; + + // 触摸状态 + lookPointerId: number | null = null; + isLookDown = false; + lastTouchX = 0; + lastTouchY = 0; + + constructor(setInput: SetInputFn, controls: OrbitControls) { + this.setInput = setInput; + this.controls = controls; + } + + // 初始化移动端控制 + async init(opts: MobileControlsOptions = {}) { + this.options = opts; + const showJoystick = opts.joystick ?? true; + const showJump = opts.jump !== false; + const showFly = opts.fly !== false; + const showView = opts.view !== false; + const showVehicle = opts.vehicle !== false; + + this.controls.maxPolarAngle = Math.PI * (300 / 360); + this.controls.touches = { ONE: null as any, TWO: null as any }; + + const JOY_SIZE = 120; + const container = document.body; + + // 创建摇杆区域 + if (showJoystick) { + this.joystickZoneEl = document.createElement("div"); + this.joystickZoneEl.id = "joy-zone"; + Object.assign(this.joystickZoneEl.style, { + position: "absolute", + left: "16px", + bottom: "16px", + width: `${JOY_SIZE + 40}px`, + height: `${JOY_SIZE + 40}px`, + touchAction: "none", + zIndex: "999", + pointerEvents: "auto", + WebkitUserSelect: "none", + userSelect: "none", + }); + container.appendChild(this.joystickZoneEl); + this.blockTouch(this.joystickZoneEl); + + // 初始化自定义摇杆 + this.joystick = new VirtualJoystick( + this.joystickZoneEl, + JOY_SIZE, + (data) => { + const rawX = data.vector?.x ?? 0; + const rawY = data.vector?.y ?? 0; + const distance = data.distance ?? 0; + const deadzone = 0.2; + const magnitude = Math.hypot(rawX, rawY); + const moveX = magnitude > deadzone ? rawX / magnitude : 0; + const moveY = magnitude > deadzone ? rawY / magnitude : 0; + const isSprinting = distance >= JOY_SIZE / 2; + const prev = this.prevJoyState; + if (Math.abs(moveX - prev.moveX) < 0.0001 && Math.abs(moveY - prev.moveY) < 0.0001 && isSprinting === prev.shift) return; + this.prevJoyState = { moveX, moveY, shift: isSprinting }; + this.setInput({ moveX, moveY, shift: isSprinting }); + }, + () => { + const prev = this.prevJoyState; + if (prev.moveX !== 0 || prev.moveY !== 0 || prev.shift) { + this.prevJoyState = { moveX: 0, moveY: 0, shift: false }; + this.setInput({ moveX: 0, moveY: 0, shift: false }); + } + }, + ); + } + + // 创建视角区域 + this.lookAreaEl = document.createElement("div"); + Object.assign(this.lookAreaEl.style, { + position: "absolute", + right: "0", + bottom: "0", + width: "50%", + height: "100%", + zIndex: "998", + touchAction: "none", + WebkitUserSelect: "none", + userSelect: "none", + }); + container.appendChild(this.lookAreaEl); + this.blockTouch(this.lookAreaEl); + + // 绑定视角触摸事件 + this.lookAreaEl.addEventListener("pointerdown", this.onPointerDown, { passive: false }); + this.lookAreaEl.addEventListener("pointermove", this.onPointerMove, { passive: false }); + this.lookAreaEl.addEventListener("pointerup", this.onPointerUp, { passive: false }); + this.lookAreaEl.addEventListener("pointercancel", this.onPointerUp, { passive: false }); + + // 创建操作按钮 + if (showJump) { + this.jumpBtnEl = this.createBtn(container, "jump", 14, 14); + this.jumpBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ jump: true }); }, { passive: false }); + this.jumpBtnEl.addEventListener("touchend", (e) => { e.preventDefault(); this.setInput({ jump: false }); }, { passive: false }); + this.jumpBtnEl.addEventListener("touchcancel", (e) => { e.preventDefault(); this.setInput({ jump: false }); }, { passive: false }); + } + if (showFly) { + this.flyBtnEl = this.createBtn(container, "fly", 14, 14 + 80); + this.flyBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleFly: true }); }, { passive: false }); + } + if (showView) { + this.viewBtnEl = this.createBtn(container, "view", 14, 14 + 200); + this.viewBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleView: true }); }, { passive: false }); + } + if (showVehicle) { + this.vehicleBtnEl = this.createBtn(container, "vehicle", 14 + 100, 14 + 120); + this.vehicleBtnEl.style.display = "none"; + this.vehicleBtnEl.addEventListener("touchstart", (e) => { e.preventDefault(); this.setInput({ toggleVehicle: true }); }, { passive: false }); + } + } + + // 销毁移动端控制 + destroy() { + try { + this.joystick?.destroy(); + this.joystick = null; + + if (this.lookAreaEl) { + this.lookAreaEl.removeEventListener("pointerdown", this.onPointerDown); + this.lookAreaEl.removeEventListener("pointermove", this.onPointerMove); + this.lookAreaEl.removeEventListener("pointerup", this.onPointerUp); + this.lookAreaEl.removeEventListener("pointercancel", this.onPointerUp); + } + + // 移除所有 DOM 元素 + [this.joystickZoneEl, this.lookAreaEl, this.jumpBtnEl, this.flyBtnEl, this.viewBtnEl, this.vehicleBtnEl] + .forEach(el => el?.parentElement?.removeChild(el)); + + this.joystickZoneEl = this.lookAreaEl = this.jumpBtnEl = + this.flyBtnEl = this.viewBtnEl = this.vehicleBtnEl = null; + } catch (e) { + console.warn("销毁移动端控制时出错:", e); + } + } + + // 同步车辆按钮显隐 + syncVehicleBtn(show: boolean) { + if (this.vehicleBtnEl) this.vehicleBtnEl.style.display = show ? "flex" : "none"; + } + + // 同步控制模式按钮 + syncControllerModeBtn(mode: 0 | 1) { + if (!this.jumpBtnEl) return; + if (mode === 0) { + if (this.flyBtnEl) this.flyBtnEl.style.display = "flex"; + this.setButtonIcon(this.jumpBtnEl, "jump"); + } else { + if (this.flyBtnEl) this.flyBtnEl.style.display = "none"; + this.setButtonIcon(this.jumpBtnEl, "brake"); + } + } + + // 触摸按下 + private onPointerDown = (e: PointerEvent) => { + if (e.pointerType !== "touch") return; + this.isLookDown = true; + this.lookPointerId = e.pointerId; + this.lastTouchX = e.clientX; + this.lastTouchY = e.clientY; + this.lookAreaEl?.setPointerCapture?.(e.pointerId); + e.preventDefault(); + }; + + // 触摸移动 + private onPointerMove = (e: PointerEvent) => { + if (!this.isLookDown || e.pointerId !== this.lookPointerId) return; + const dx = e.clientX - this.lastTouchX; + const dy = e.clientY - this.lastTouchY; + this.lastTouchX = e.clientX; + this.lastTouchY = e.clientY; + this.setInput({ lookDeltaX: dx, lookDeltaY: dy }); + e.preventDefault(); + }; + + // 触摸抬起 + private onPointerUp = (e: PointerEvent) => { + if (e.pointerId !== this.lookPointerId) return; + this.isLookDown = false; + this.lookPointerId = null; + this.lookAreaEl?.releasePointerCapture?.(e.pointerId); + }; + + // 阻止默认触摸 + private blockTouch(el: HTMLElement) { + ["touchstart", "touchmove", "touchend", "touchcancel"].forEach(name => { + el.addEventListener(name, e => e.preventDefault(), { passive: false }); + }); + } + + private getButtonOptions(name: MobileButtonName): MobileButtonOptions | undefined { + const options = this.options[name]; + return typeof options === "object" ? options : undefined; + } + + private getIconUrl(iconName: MobileIconName): string | undefined { + if (iconName === "brake") { + const jumpOptions = this.options.jump; + return typeof jumpOptions === "object" ? jumpOptions.brakeIcon : undefined; + } + return this.getButtonOptions(iconName)?.icon; + } + + // 创建圆形按钮 + private createBtn(container: HTMLElement, name: MobileButtonName, defaultRight: number, defaultBottom: number): HTMLButtonElement { + const btn = document.createElement("button"); + const layout = this.getButtonOptions(name); + const size = layout?.size ?? 56; + const idleShadow = "0 6px 12px rgba(0,0,0,0.45), 0 2px 4px rgba(0,0,0,0.35), inset 0 2px 2px rgba(255,255,255,0.24), inset 0 -2px 3px rgba(0,0,0,0.5)"; + const pressedShadow = "0 2px 5px rgba(0,0,0,0.35), inset 0 2px 4px rgba(0,0,0,0.65), inset 0 -1px 1px rgba(255,255,255,0.12)"; + Object.assign(btn.style, { + position: "absolute", + right: `${layout?.right ?? defaultRight}px`, + bottom: `${layout?.bottom ?? defaultBottom}px`, + width: `${size}px`, + height: `${size}px`, + zIndex: "1000", + borderRadius: "50%", + border: "1px solid rgba(255,255,255,0.45)", + padding: "0", + opacity: "0.95", + touchAction: "none", + fontSize: "14px", + userSelect: "none", + overflow: "hidden", + boxSizing: "border-box", + appearance: "none", + WebkitAppearance: "none", + WebkitTapHighlightColor: "transparent", + background: "linear-gradient(145deg, rgba(82,82,82,0.95) 0%, rgba(34,34,34,0.96) 55%, rgba(10,10,10,0.98) 100%)", + boxShadow: idleShadow, + color: "white", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + transform: "translateY(0) scale(1)", + transition: "transform 80ms ease, box-shadow 80ms ease, background 80ms ease", + willChange: "transform", + }); + if (layout?.left !== undefined) { + btn.style.left = `${layout.left}px`; + if (layout.right === undefined) btn.style.right = "auto"; + } + if (layout?.top !== undefined) { + btn.style.top = `${layout.top}px`; + if (layout.bottom === undefined) btn.style.bottom = "auto"; + } + this.setButtonIcon(btn, name); + container.appendChild(btn); + + const setPressed = (pressed: boolean) => { + btn.style.transform = pressed ? "translateY(2px) scale(0.98)" : "translateY(0) scale(1)"; + btn.style.boxShadow = pressed ? pressedShadow : idleShadow; + btn.style.background = pressed + ? "linear-gradient(145deg, rgba(22,22,22,0.98), rgba(52,52,52,0.96))" + : "linear-gradient(145deg, rgba(82,82,82,0.95) 0%, rgba(34,34,34,0.96) 55%, rgba(10,10,10,0.98) 100%)"; + }; + btn.addEventListener("pointerdown", () => setPressed(true)); + ["pointerup", "pointercancel", "pointerleave"].forEach(eventName => { + btn.addEventListener(eventName, () => setPressed(false)); + }); + ["touchstart", "touchend", "touchcancel"].forEach(name => { + btn.addEventListener(name, e => e.preventDefault(), { passive: false }); + }); + return btn; + } + + // 设置按钮内容 + private setButtonIcon(btn: HTMLButtonElement, iconName: MobileIconName) { + const customIconUrl = this.getIconUrl(iconName); + let icon: HTMLImageElement | HTMLSpanElement; + + if (customIconUrl !== undefined) { + const image = document.createElement("img"); + image.src = customIconUrl; + image.alt = ""; + image.draggable = false; + icon = image; + } else { + const label = document.createElement("span"); + label.textContent = iconLabels[iconName]; + icon = label; + } + + Object.assign(icon.style, { + width: "80%", + height: "80%", + display: "flex", + alignItems: "center", + justifyContent: "center", + objectFit: "contain", + pointerEvents: "none", + flex: "none", + fontSize: "11px", + fontWeight: "700", + fontFamily: "system-ui, sans-serif", + lineHeight: "1", + letterSpacing: "0.04em", + textShadow: "0 1px 2px rgba(0,0,0,0.9)", + }); + btn.replaceChildren(icon); + } +} diff --git a/three-player-controller/src/utils/bbox.ts b/three-player-controller/src/utils/bbox.ts new file mode 100644 index 0000000..debc3c3 --- /dev/null +++ b/three-player-controller/src/utils/bbox.ts @@ -0,0 +1,23 @@ +import * as THREE from "three"; + +const _vertex = new THREE.Vector3(); +/** 获取物体世界包围盒及中心、尺寸。按世界坐标顶点扩盒子。 */ +export function getBbox(object: THREE.Object3D) { + object.updateMatrixWorld(true); + const bbox = new THREE.Box3(); + object.traverse(child => { + const geometry = (child as THREE.Mesh).geometry; + const position = geometry?.attributes?.position; + if (!position) return; + const matrixWorld = child.matrixWorld; + for (let i = 0, n = position.count; i < n; i++) { + _vertex.fromBufferAttribute(position, i).applyMatrix4(matrixWorld); + bbox.expandByPoint(_vertex); + } + }); + const center = new THREE.Vector3(); + const size = new THREE.Vector3(); + bbox.getCenter(center); + bbox.getSize(size); + return { bbox, center, size }; +} diff --git a/three-player-controller/src/utils/capsuleCollision.ts b/three-player-controller/src/utils/capsuleCollision.ts new file mode 100644 index 0000000..e7a4d35 --- /dev/null +++ b/three-player-controller/src/utils/capsuleCollision.ts @@ -0,0 +1,319 @@ +import * as THREE from "three"; + +// 胶囊碰撞所需的临时对象 +export interface CollisionTemps { + invMat: THREE.Matrix4; // 碰撞体世界矩阵的逆矩阵 + localSeg: THREE.Line3; // 胶囊段在碰撞体本地空间的副本 + localBox: THREE.Box3; // 胶囊段在碰撞体本地空间的包围盒 + closestSeg: THREE.Vector3; // 胶囊段最近点 + closestTri: THREE.Vector3; // 三角面最近点 +} + +/** 地面体积探测的通用命中结果。 */ +export interface GroundProbeHit { + point: THREE.Vector3; + normal: THREE.Vector3; + /** 从胶囊下端球心沿向下方向到支撑点的距离。 */ + distance: number; +} + +/** + * 一次圆柱地面探测的参数、临时向量和当前最近命中。 + */ +export interface GroundProbeQuery { + start: THREE.Vector3; + down: THREE.Vector3; + maxDistance: number; + radius: number; + radiusSq: number; + minNormalY: number; + delta: THREE.Vector3; + radialOffset: THREE.Vector3; + hit: GroundProbeHit; +} + +/** BVH 三角形和动态盒表面三角形共同具备的最小查询接口。 */ +export interface GroundProbeTriangle { + closestPointToSegment( + segment: THREE.Line3, + trianglePoint: THREE.Vector3, + segmentPoint: THREE.Vector3, + ): number; + getNormal(target: THREE.Vector3): THREE.Vector3; +} + +/** 地面体积探测所需的复用临时对象。 */ +export interface GroundProbeTemps { + invMat: THREE.Matrix4; + normalMat: THREE.Matrix3; + localSeg: THREE.Line3; + localBox: THREE.Box3; + colliderScale: THREE.Vector3; + localTriPoint: THREE.Vector3; + localSegPoint: THREE.Vector3; + localNormal: THREE.Vector3; + query: GroundProbeQuery; +} + +// 创建一组预分配的临时对象 +export function createCollisionTemps(): CollisionTemps { + return { + invMat: new THREE.Matrix4(), + localSeg: new THREE.Line3(), + localBox: new THREE.Box3(), + closestSeg: new THREE.Vector3(), + closestTri: new THREE.Vector3(), + }; +} + +/** 创建可跨帧复用的圆柱地面探测状态。 */ +export function createGroundProbeQuery(): GroundProbeQuery { + return { + start: new THREE.Vector3(), + down: new THREE.Vector3(0, -1, 0), + maxDistance: 0, + radius: 0, + radiusSq: 0, + minNormalY: 0, + delta: new THREE.Vector3(), + radialOffset: new THREE.Vector3(), + hit: { + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + distance: Infinity, + }, + }; +} + +/** 写入本次探测参数,并清空上一次留下的最近命中。 */ +export function resetGroundProbeQuery( + query: GroundProbeQuery, + start: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + radius: number, + minNormalY: number, +): void { + query.start.copy(start); + query.down.copy(down).normalize(); + query.maxDistance = Math.max(0, maxDistance); + query.radius = Math.max(0, radius); + query.radiusSq = query.radius * query.radius; + query.minNormalY = minNormalY; + query.hit.distance = Infinity; +} + +/** + * 将形状算法给出的表面点送入统一的圆柱筛选器。 + * 返回 true 表示该点有效,并且替换了当前最近命中。 + */ +export function tryGroundProbeCandidate( + query: GroundProbeQuery, + point: THREE.Vector3, + normal: THREE.Vector3, +): boolean { + query.delta.subVectors(point, query.start); + + // 投影到向下轴得到探测深度;轴起点上方和圆柱末端之外都不属于传感区。 + const distance = query.delta.dot(query.down); + if ( + distance < -1e-5 + || distance > query.maxDistance + 1e-5 + || distance >= query.hit.distance + ) return false; + + // 去掉轴向分量后只剩横向偏移,用它判断候选点是否落在圆柱半径内。 + query.radialOffset.copy(query.delta).addScaledVector(query.down, -distance); + if (query.radialOffset.lengthSq() > query.radiusSq) return false; + + // 只接受法线足够朝上的可站立表面。 + if (-normal.dot(query.down) < query.minNormalY) return false; + + query.hit.distance = distance; + query.hit.point.copy(point); + query.hit.normal.copy(normal); + return true; +} + +/** + * 从三角面生成表面候选点,再交给统一圆柱筛选器。 + * 网格可传入本地到世界的变换;动态盒的三角形已经在世界空间,无需传变换。 + */ +export function tryGroundProbeTriangle( + query: GroundProbeQuery, + segment: THREE.Line3, + triangle: GroundProbeTriangle, + trianglePoint: THREE.Vector3, + segmentPoint: THREE.Vector3, + normal: THREE.Vector3, + pointMatrix?: THREE.Matrix4, + normalMatrix?: THREE.Matrix3, +): boolean { + // 三角面到圆柱中心轴的最近点,就是该三角面最有可能落入圆柱的候选点。 + triangle.closestPointToSegment(segment, trianglePoint, segmentPoint); + if (pointMatrix) trianglePoint.applyMatrix4(pointMatrix); + + triangle.getNormal(normal); + if (normalMatrix) normal.applyMatrix3(normalMatrix); + normal.normalize(); + + return tryGroundProbeCandidate(query, trianglePoint, normal); +} + +/** 创建一组地面体积探测临时对象。 */ +export function createGroundProbeTemps(): GroundProbeTemps { + return { + invMat: new THREE.Matrix4(), + normalMat: new THREE.Matrix3(), + localSeg: new THREE.Line3(), + localBox: new THREE.Box3(), + colliderScale: new THREE.Vector3(), + localTriPoint: new THREE.Vector3(), + localSegPoint: new THREE.Vector3(), + localNormal: new THREE.Vector3(), + query: createGroundProbeQuery(), + }; +} + +/** + * 用带半径的竖直传感区查找胶囊脚下的可支撑网格。 + * 中心射线落在细缝中时,传感区仍能触及缝隙两侧的顶面。 + */ +export function probeMeshGround( + sensorStart: THREE.Vector3, + down: THREE.Vector3, + maxDistance: number, + sensorRadius: number, + minNormalY: number, + collider: THREE.Mesh, + temps: GroundProbeTemps, +): GroundProbeHit | null { + const boundsTree = (collider.geometry as any)?.boundsTree; + if (!boundsTree || maxDistance <= 0 || sensorRadius <= 0 || down.lengthSq() === 0) return null; + + resetGroundProbeQuery( + temps.query, + sensorStart, + down, + maxDistance, + sensorRadius, + minNormalY, + ); + temps.invMat.copy(collider.matrixWorld).invert(); + temps.normalMat.getNormalMatrix(collider.matrixWorld); + temps.localSeg.start.copy(temps.query.start).applyMatrix4(temps.invMat); + temps.localSeg.end.copy(temps.query.start) + .addScaledVector(temps.query.down, temps.query.maxDistance) + .applyMatrix4(temps.invMat); + + // BVH 宽相位:用包住整个探测圆柱的本地 AABB 排除远处节点。 + // 非等比缩放时取最小轴缩放计算半径,包围盒会偏大但不会漏掉候选面。 + temps.colliderScale.setFromMatrixScale(collider.matrixWorld); + const minScale = Math.max( + 1e-8, + Math.min(temps.colliderScale.x, temps.colliderScale.y, temps.colliderScale.z), + ); + const localRadius = sensorRadius / minScale; + temps.localBox.makeEmpty() + .expandByPoint(temps.localSeg.start) + .expandByPoint(temps.localSeg.end) + .expandByScalar(localRadius); + + boundsTree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(temps.localBox), + intersectsTriangle: (tri: any) => { + // 窄相位、坐标变换和候选过滤由网格与动态盒共用的函数完成。 + tryGroundProbeTriangle( + temps.query, + temps.localSeg, + tri, + temps.localTriPoint, + temps.localSegPoint, + temps.localNormal, + collider.matrixWorld, + temps.normalMat, + ); + }, + }); + + return Number.isFinite(temps.query.hit.distance) ? temps.query.hit : null; +} + +/** + * 将胶囊与单个网格做 BVH 碰撞检测。 + * `useDynamicsSegment` 时用接到脚底的段(飞行),否则用悬空段(步行上台阶)。 + * @param capsule 胶囊所在的 Object3D(位置会被直接修改) + * @param capsuleInfo 胶囊描述:局部空间线段 + 半径 + * @param collider 目标碰撞网格 + * @param temps 预分配临时对象 + * @param skipTri 可选:返回 true 则跳过该三角面 + */ +export function applyCapsuleCollision( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + collider: THREE.Mesh, + temps: CollisionTemps, + skipTri?: (tri: any, dir: THREE.Vector3) => boolean, + useDynamicsSegment = false, +): void { + const segment = useDynamicsSegment + ? (capsuleInfo.dynamicsSegment ?? capsuleInfo.segment) + : capsuleInfo.segment; + // 胶囊段变换到碰撞体本地空间 + temps.invMat.copy(collider.matrixWorld).invert(); + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld).applyMatrix4(temps.invMat); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld).applyMatrix4(temps.invMat); + + // 构建本地包围盒 + temps.localBox.makeEmpty(); + temps.localBox.expandByPoint(temps.localSeg.start).expandByPoint(temps.localSeg.end); + temps.localBox.expandByScalar(capsuleInfo.radius); + + // 碰撞查询 + (collider.geometry as any)?.boundsTree?.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(temps.localBox), + intersectsTriangle: (tri: any) => { + // 初步筛选 + const distance = tri.closestPointToSegment(temps.localSeg, temps.closestSeg, temps.closestTri); + if (distance >= capsuleInfo.radius) return; + + // 二次筛选 + const dir = temps.closestTri.clone().sub(temps.closestSeg).normalize(); + if (skipTri?.(tri, dir)) return; + + // 推开胶囊段 + temps.localSeg.start.addScaledVector(dir, capsuleInfo.radius - distance); + temps.localSeg.end.addScaledVector(dir, capsuleInfo.radius - distance); + }, + }); + + // 应用碰撞修正 + const newPos = temps.closestSeg.copy(temps.localSeg.start).applyMatrix4(collider.matrixWorld); + const delta = temps.closestTri.subVectors(newPos, capsule.position); + const offset = Math.max(0, delta.length() - 1e-5); + capsule.position.add(delta.normalize().multiplyScalar(offset)); +} + +/** + * 胶囊段与球的重叠。 + * 命中时 temps.closestSeg 为段上最近点,temps.closestTri 为胶囊指向球的单位法线,返回穿透深度。 + */ +export function capsuleSphereOverlap( + capsule: THREE.Object3D, + capsuleInfo: { segment: THREE.Line3; radius: number; dynamicsSegment?: THREE.Line3 }, + spherePos: THREE.Vector3, + sphereRadius: number, + temps: CollisionTemps, +): number { + const segment = capsuleInfo.dynamicsSegment ?? capsuleInfo.segment; + temps.localSeg.start.copy(segment.start).applyMatrix4(capsule.matrixWorld); + temps.localSeg.end.copy(segment.end).applyMatrix4(capsule.matrixWorld); + temps.localSeg.closestPointToPoint(spherePos, true, temps.closestSeg); + temps.closestTri.subVectors(spherePos, temps.closestSeg); + const dist = temps.closestTri.length(); + const minDist = capsuleInfo.radius + sphereRadius; + if (dist >= minDist) return 0; + if (dist > 1e-8) temps.closestTri.multiplyScalar(1 / dist); + else temps.closestTri.set(0, 1, 0); + return minDist - dist; +} \ No newline at end of file diff --git a/three-player-controller/src/utils/vehicleController.ts b/three-player-controller/src/utils/vehicleController.ts new file mode 100644 index 0000000..652ccb1 --- /dev/null +++ b/three-player-controller/src/utils/vehicleController.ts @@ -0,0 +1,244 @@ +import * as THREE from "three"; +import { BVHVehicleController } from "./vehiclePhysics/BVHVehicleController"; +import type { VehicleRigidBody } from "./vehiclePhysics/VehicleRigidBody"; + +/** 单个车轮的悬挂几何(底盘局部)。 */ +export type WheelInfo = { + axleCs: THREE.Vector3; + suspensionRestLength: number; + maxSuspensionTravel: number; + position: THREE.Vector3; + radius: number; +}; + +/** + * 车轮悬挂与抓地参数(质量归一化刚度/阻尼)。 + * stiffness / damping / friction 不随 scale 缩放。 + */ +export type WheelPhysicsParams = { + suspensionStiffness: number; + suspensionCompression: number; + suspensionRelaxation: number; + maxSuspensionForce: number; + frictionSlip: number; + sideFrictionStiffness: number; + rollInfluence: number; +}; + +/** 悬挂与抓地默认值。静止长度不传时按轮直径 × 0.2。 */ +export const DEFAULT_WHEEL_PHYSICS: WheelPhysicsParams = { + suspensionStiffness: 18, + suspensionCompression: 2.1, + suspensionRelaxation: 2.5, + maxSuspensionForce: 6000, + frictionSlip: 8, + sideFrictionStiffness: 1, + rollInfluence: 0.12, +}; + +/** 悬挂最大行程默认值(米,scale 1)。 */ +export const DEFAULT_MAX_SUSPENSION_TRAVEL = 0.35; + +/** 视觉悬挂跟随速度(越大越贴射线)。 */ +const VISUAL_FOLLOW = 14; +/** 接地标记球相对轮胎半径的比例。 */ +const DEBUG_HIT_RADIUS_RATIO = 0.12; +/** 行程刻度线半宽相对轮胎半径的比例。 */ +const DEBUG_TICK_RADIUS_RATIO = 0.25; + +/** 创建调试线段网格。 */ +function makeDebugLine(material: THREE.LineBasicMaterial, segments: number): THREE.LineSegments { + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(segments * 6), 3)); + const line = new THREE.LineSegments(geo, material); + line.frustumCulled = false; + line.renderOrder = 20; + return line; +} + +/** 写入第 i 段线段的两端点。 */ +function setSegment(attr: THREE.BufferAttribute, i: number, a: THREE.Vector3, b: THREE.Vector3): void { + const o = i * 6; + attr.array[o] = a.x; + attr.array[o + 1] = a.y; + attr.array[o + 2] = a.z; + attr.array[o + 3] = b.x; + attr.array[o + 4] = b.y; + attr.array[o + 5] = b.z; +} + +/** 创建 BVH 车辆控制器并同步轮子视觉。 */ +export function createVehicleController( + chassisBody: VehicleRigidBody, + wheels: (THREE.Object3D | null)[], + wheelsInfo: WheelInfo[], + physics: WheelPhysicsParams = DEFAULT_WHEEL_PHYSICS, +) { + const vehicle = new BVHVehicleController(chassisBody); + const suspensionDirection = new THREE.Vector3(0, -1, 0); + + // 注册每个轮子的物理参数 + wheelsInfo.forEach((wheel, index) => { + vehicle.addWheel(wheel.position, suspensionDirection, wheel.axleCs, wheel.suspensionRestLength, wheel.radius); + vehicle.setWheelChassisConnectionPointCs(index, wheel.position); // 连接点 + vehicle.setWheelDirectionCs(index, suspensionDirection); // 悬挂方向 + vehicle.setWheelAxleCs(index, wheel.axleCs); // 轮轴方向 + vehicle.setWheelSuspensionRestLength(index, wheel.suspensionRestLength); // 静止长度 + vehicle.setWheelRadius(index, wheel.radius); // 轮胎半径 + vehicle.setWheelMaxSuspensionTravel(index, wheel.maxSuspensionTravel); // 最大行程 + vehicle.setWheelSuspensionStiffness(index, physics.suspensionStiffness); // 悬挂刚度 + vehicle.setWheelSuspensionCompression(index, physics.suspensionCompression); // 压缩阻尼 + vehicle.setWheelSuspensionRelaxation(index, physics.suspensionRelaxation); // 回弹阻尼 + vehicle.setWheelMaxSuspensionForce(index, physics.maxSuspensionForce); // 最大作用力 + vehicle.setWheelBrake(index, 0); // 制动 + vehicle.setWheelSteering(index, 0); // 转向角 + vehicle.setWheelEngineForce(index, 0); // 驱动力 + vehicle.setWheelFrictionSlip(index, physics.frictionSlip); // 纵向抓地 + vehicle.setWheelSideFrictionStiffness(index, physics.sideFrictionStiffness); // 侧向摩擦 + vehicle.setWheelRollInfluence(index, physics.rollInfluence); // 侧倾影响 + }); + + const up = new THREE.Vector3(0, 1, 0); + const wheelSteeringQuat = new THREE.Quaternion(); + const wheelRotationQuat = new THREE.Quaternion(); + const visualLengthSmooth: number[] = new Array(wheels.length).fill(Number.NaN); + const _from = new THREE.Vector3(); + const _to = new THREE.Vector3(); + const _dir = new THREE.Vector3(); + const _tick = new THREE.Vector3(); + const _rest = new THREE.Vector3(); + const _a = new THREE.Vector3(); + const _b = new THREE.Vector3(); + + const wheelRayDebug = new THREE.Group(); + wheelRayDebug.name = "wheelRayDebug"; + wheelRayDebug.userData.excludeFromCollider = true; + const wheelTravelDebug = new THREE.Group(); + wheelTravelDebug.name = "wheelTravelDebug"; + wheelTravelDebug.userData.excludeFromCollider = true; + const rayLines: THREE.LineSegments[] = []; + const hitMarks: THREE.Mesh[] = []; + const travelLines: THREE.LineSegments[] = []; + const hitGeo = new THREE.SphereGeometry(1, 8, 6); + const hitMat = new THREE.MeshBasicMaterial({ color: 0xffff66, depthTest: false }); + const rayMatHit = new THREE.LineBasicMaterial({ color: 0x66ff66, depthTest: false }); + const rayMatMiss = new THREE.LineBasicMaterial({ color: 0xff5555, depthTest: false }); + const travelMat = new THREE.LineBasicMaterial({ color: 0x44ccff, depthTest: false, transparent: true, opacity: 0.95 }); + for (let i = 0; i < wheels.length; i++) { + const ray = makeDebugLine(rayMatHit, 1); + rayLines.push(ray); + wheelRayDebug.add(ray); + const hit = new THREE.Mesh(hitGeo, hitMat); + hit.frustumCulled = false; + hit.renderOrder = 21; + hit.visible = false; + hit.scale.setScalar(Math.max(1e-6, (wheelsInfo[i]?.radius ?? 0) * DEBUG_HIT_RADIUS_RATIO)); + hitMarks.push(hit); + wheelRayDebug.add(hit); + const travel = makeDebugLine(travelMat, 4); + travelLines.push(travel); + wheelTravelDebug.add(travel); + } + + /** 同步轮子视觉:高度按指数跟随射线,转向与自转直接写入。 */ + function updateWheelVisuals(delta = 1 / 60) { + const follow = 1 - Math.exp(-VISUAL_FOLLOW * Math.max(0, delta)); + const showRays = wheelRayDebug.visible; + const showTravel = wheelTravelDebug.visible; + for (const [index, wheelObj] of wheels.entries()) { + if (!wheelObj) continue; + const wheelAxleCs = vehicle.wheelAxleCs(index) ?? new THREE.Vector3(1, 0, 0); + const connection = vehicle.wheelChassisConnectionPointCs(index)?.y ?? 0; + const target = vehicle.wheelVisualLength(index) ?? vehicle.wheelSuspensionLength(index) ?? 0; + const steering = vehicle.wheelSteering(index) ?? 0; + const rotationRad = vehicle.wheelRotation(index) ?? 0; + + let length = target; + if (Number.isFinite(visualLengthSmooth[index])) { + length = visualLengthSmooth[index] + (target - visualLengthSmooth[index]) * follow; + } + visualLengthSmooth[index] = length; + wheelObj.position.y = connection - length; + wheelSteeringQuat.setFromAxisAngle(up, steering); + wheelRotationQuat.setFromAxisAngle(wheelAxleCs, rotationRad); + wheelObj.quaternion.copy(wheelSteeringQuat).multiply(wheelRotationQuat); + + if (showRays || showTravel) { + const wheel = vehicle.wheels[index]; + if (wheel) updateWheelDebug(index, wheel, showRays, showTravel); + } + } + } + + /** 更新单轮射线与行程调试线。 */ + function updateWheelDebug( + index: number, + wheel: (typeof vehicle.wheels)[number], + showRays: boolean, + showTravel: boolean, + ) { + _dir.copy(wheel.direction); + if (_dir.lengthSq() < 1e-12) _dir.set(0, -1, 0); + else _dir.normalize(); + const rest = wheel.restLength; + const travel = wheel.maxSuspensionTravel; + const minLen = Math.max(0, rest - travel); + const maxLen = rest + travel; + const rayLen = rest + travel + wheel.radius; + const conn = wheel.connectionPoint; + + if (showRays) { + const ray = rayLines[index]; + _from.copy(conn); + _to.copy(conn).addScaledVector(_dir, rayLen); + const rayAttr = ray.geometry.getAttribute("position") as THREE.BufferAttribute; + setSegment(rayAttr, 0, _from, _to); + rayAttr.needsUpdate = true; + ray.geometry.computeBoundingSphere(); + ray.material = wheel.isInContact ? rayMatHit : rayMatMiss; + const hit = hitMarks[index]; + if (wheel.isInContact) { + const hitDist = Math.max(0, wheel.visualLength + wheel.radius); + hit.position.copy(conn).addScaledVector(_dir, hitDist); + hit.scale.setScalar(Math.max(1e-6, wheel.radius * DEBUG_HIT_RADIUS_RATIO)); + hit.visible = true; + } else { + hit.visible = false; + } + } + + if (showTravel) { + const line = travelLines[index]; + _from.copy(conn).addScaledVector(_dir, minLen); + _to.copy(conn).addScaledVector(_dir, maxLen); + _tick.copy(wheel.axle); + if (_tick.lengthSq() < 1e-12) _tick.set(1, 0, 0); + else _tick.normalize(); + _tick.multiplyScalar(Math.max(1e-6, wheel.radius * DEBUG_TICK_RADIUS_RATIO)); + _rest.copy(conn).addScaledVector(_dir, rest); + const attr = line.geometry.getAttribute("position") as THREE.BufferAttribute; + setSegment(attr, 0, _from, _to); + setSegment(attr, 1, _a.copy(_from).sub(_tick), _b.copy(_from).add(_tick)); + setSegment(attr, 2, _a.copy(_to).sub(_tick), _b.copy(_to).add(_tick)); + setSegment(attr, 3, _a.copy(_rest).sub(_tick), _b.copy(_rest).add(_tick)); + attr.needsUpdate = true; + line.geometry.computeBoundingSphere(); + } + } + + /** 销毁车辆控制器与调试网格。 */ + function destroy() { + vehicle.wheels.length = 0; + wheelRayDebug.removeFromParent(); + wheelTravelDebug.removeFromParent(); + for (const line of rayLines) line.geometry.dispose(); + for (const line of travelLines) line.geometry.dispose(); + hitGeo.dispose(); + hitMat.dispose(); + rayMatHit.dispose(); + rayMatMiss.dispose(); + travelMat.dispose(); + } + + return { vehicle, updateWheelVisuals, destroy, wheelRayDebug, wheelTravelDebug }; +} diff --git a/three-player-controller/src/utils/vehicleLoader.ts b/three-player-controller/src/utils/vehicleLoader.ts new file mode 100644 index 0000000..e634c28 --- /dev/null +++ b/three-player-controller/src/utils/vehicleLoader.ts @@ -0,0 +1,322 @@ +import * as THREE from "three"; +import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { createVehicleController, DEFAULT_MAX_SUSPENSION_TRAVEL, DEFAULT_WHEEL_PHYSICS } from "./vehicleController"; +import { VehicleRigidBody } from "./vehiclePhysics/VehicleRigidBody"; +import { getBbox } from "./bbox"; +import type { VehicleOptions, VehicleInstance } from "../types"; + +export type VehicleLoaderContext = { + loader: GLTFLoader; + scene: THREE.Scene; + vehicleParams: { + debug: { showPhysicsBox: boolean; showWheelRays: boolean; showWheelTravel: boolean; showWheelSpheres: boolean }; + chassis: { density: number; linearDamping: number; angularDamping: number; sizeScale?: { x?: number; y?: number; z?: number } }; + model: { rotation: number }; + power: { acceleration: number; deceleration: number; maxSpeed: number }; + steering: { maxSteerAngle: number; steerTime: number; steerReturnTimeSlow: number; steerReturnTimeFast: number; highSpeedSteerScale: number }; + grip: { + maxG: number; + sideFrictionIdle: number; + sideFrictionFrontMin: number; + sideFrictionRearMin: number; + handbrakeRearFriction: number; + handbrakeRearDriveScale: number; + handbrakeReleaseTime: number; + wheelbaseRatio: number; + }; + suspension: { + restLength?: number; + maxTravel: number; + stiffness: number; + compression: number; + relaxation: number; + maxForce: number; + frictionSlip: number; + sideFrictionStiffness: number; + rollInfluence: number; + }; + followVehicleDirection: boolean; + }; + vehicleLength: number; +}; + +const MIN_BOX_HEIGHT = 1e-3; + +function mergeOpts(base: T, over?: Partial): T { + return over ? { ...base, ...over } : { ...base }; +} + +// ==================== 主函数 ==================== + +// 加载车辆模型 +export async function loadVehicleModel( + opts: VehicleOptions, + ctx: VehicleLoaderContext, +): Promise { + const { loader, scene, vehicleParams, vehicleLength } = ctx; + + const scale = opts.scale ?? 1; + const debug = mergeOpts(vehicleParams.debug, opts.debug); + const chassis = mergeOpts(vehicleParams.chassis, opts.chassis); + const power = mergeOpts(vehicleParams.power, opts.power); + const steering = mergeOpts(vehicleParams.steering, opts.steering); + const grip = mergeOpts(vehicleParams.grip, opts.grip); + const sus = mergeOpts(vehicleParams.suspension, opts.suspension); + const density = Math.max(1e-8, chassis.density); + const linearDamping = chassis.linearDamping; + const angularDamping = chassis.angularDamping; + const maxSpeed = power.maxSpeed * scale; + const acceleration = power.acceleration * scale; + const deceleration = power.deceleration * scale; + const followVehicleDirection = opts.followVehicleDirection ?? vehicleParams.followVehicleDirection; + + let model: THREE.Object3D; + if (opts.model) { + model = opts.model; + } else { + const gltf = await loader.loadAsync(opts.url); + model = gltf.scene; + } + + // 计算模型缩放比 + const { size: originalSize } = getBbox(model); + const modelScale = vehicleLength / Math.max(originalSize.x, originalSize.y, originalSize.z); + + // 查找轮子节点 + const wheelObjects: THREE.Object3D[] = []; + for (const name of opts.wheelsNames) { + let found = false; + model.traverse(child => { + if (child.name === name && !found) { wheelObjects.push(child); found = true; } + }); + if (!found) console.warn(`未找到轮子: ${name}`); + } + + // 临时挂载获取世界坐标 + const tempGroup = new THREE.Group(); + scene.add(tempGroup); + model.scale.multiplyScalar(modelScale * scale); + model.rotateY(opts.modelRotation ?? vehicleParams.model.rotation); + const { center } = getBbox(model); + model.position.set(-center.x, -center.y, -center.z); + tempGroup.add(model); + tempGroup.updateMatrixWorld(true); + + // 收集轮子世界变换信息 + let wheelRadius = 0, wheelSizeInit = false; + const wheelsInfo: any[] = []; + + for (const wheel of wheelObjects) { + const worldPos = new THREE.Vector3(); + const worldQuat = new THREE.Quaternion(); + const worldScale = new THREE.Vector3(); + wheel.getWorldPosition(worldPos); + wheel.getWorldQuaternion(worldQuat); + wheel.getWorldScale(worldScale); + + // 只计算一次轮子尺寸 + if (!wheelSizeInit) { + const { size: ws } = getBbox(wheel); + wheelRadius = Math.max(ws.x, ws.y, ws.z) / 2; + wheelSizeInit = true; + } + + wheelsInfo.push({ + axleCs: new THREE.Vector3(0, 0, -1), + position: worldPos, + quaternion: worldQuat, + scale: worldScale, + radius: wheelRadius, + object: wheel, + }); + } + + tempGroup.remove(model); + scene.remove(tempGroup); + + if (wheelsInfo.length !== 4) { + throw new Error(`车辆需要 4 个有效车轮节点,当前找到 ${wheelsInfo.length} 个`); + } + + // 创建车辆根节点 + const vehicleGroup = new THREE.Group(); + scene.add(vehicleGroup); + vehicleGroup.add(model); + vehicleGroup.updateMatrixWorld(true); + + // 轮子包装组(独立旋转) + const wheelWrappers: THREE.Group[] = []; + for (let i = 0; i < wheelsInfo.length; i++) { + const wheel = wheelsInfo[i]; + const wheelWrapper = new THREE.Group(); + wheelWrapper.position.copy(vehicleGroup.worldToLocal(wheel.position.clone())); + + const wheelObj = wheel.object; + wheelObj.parent?.remove(wheelObj); + wheelObj.position.set(0, 0, 0); + wheelObj.quaternion.copy(wheel.quaternion); + wheelObj.scale.copy(wheel.scale); + wheelObj.updateMatrixWorld(); + + wheelWrapper.add(wheelObj); + vehicleGroup.add(wheelWrapper); + wheelWrappers.push(wheelWrapper); + } + + const shiftContent = (offset: THREE.Vector3) => { + model.position.sub(offset); + for (const wrapper of wheelWrappers) wrapper.position.sub(offset); + }; + + // 拆轮后按车身包围盒重定原点,物理盒不含轮胎 + vehicleGroup.updateMatrixWorld(true); + const { center: bodyCenter, size: bodySize } = getBbox(model); + shiftContent(vehicleGroup.worldToLocal(bodyCenter.clone())); + vehicleGroup.updateMatrixWorld(true); + + const { bbox: bodyBbox } = getBbox(model); + const localMin = vehicleGroup.worldToLocal(bodyBbox.min.clone()); + const localMax = vehicleGroup.worldToLocal(bodyBbox.max.clone()); + + let minTireBottom = Infinity; + let minHubY = Infinity; + for (const wrapper of wheelWrappers) { + minHubY = Math.min(minHubY, wrapper.position.y); + minTireBottom = Math.min(minTireBottom, wrapper.position.y - wheelRadius); + } + + let boxTop = localMax.y; + const chassisTopClearance = (boxTop - minTireBottom) / Math.max(scale, 1e-8); + // 默认盒底罩到轮心,弹簧压死后由盒子托住,避免只靠轮网格撑地 + let boxBottom = Math.min(localMin.y, minHubY); + if (opts.chassis?.clearance != null) { + boxBottom = minTireBottom + opts.chassis.clearance * scale; + boxBottom = Math.max(boxBottom, minTireBottom); + } + if (boxBottom > boxTop - MIN_BOX_HEIGHT) boxBottom = boxTop - MIN_BOX_HEIGHT; + const chassisClearance = (boxBottom - minTireBottom) / Math.max(scale, 1e-8); + + // X/Z 绕水平中心;Y 从盒底向上缩,离地间隙不变 + const sizeScale = chassis.sizeScale; + const sx = Math.max(1e-3, sizeScale?.x ?? 1); + const sy = Math.max(1e-3, sizeScale?.y ?? 1); + const sz = Math.max(1e-3, sizeScale?.z ?? 1); + const boxHeight = Math.max(MIN_BOX_HEIGHT, (boxTop - boxBottom) * sy); + boxTop = boxBottom + boxHeight; + + const boxCenterY = (boxTop + boxBottom) * 0.5; + if (Math.abs(boxCenterY) > 1e-8) { + shiftContent(new THREE.Vector3(0, boxCenterY, 0)); + vehicleGroup.updateMatrixWorld(true); + } + + // 硬点用模型轮心。静止长度不传时按轮直径 × 0.2,且不低于静载下沉。 + const stiffness = sus.stiffness ?? DEFAULT_WHEEL_PHYSICS.suspensionStiffness; + const staticSag = 9.81 * scale / (4 * Math.max(1e-4, stiffness)); + const restLengthOpt = sus.restLength; + // 传入值按 scale 1 的米;未传时轮半径已含 scale,不再乘 + const restFromOptOrWheel = restLengthOpt != null ? restLengthOpt * scale : wheelRadius * 2 * 0.2; + const suspensionRestLength = Math.max(restFromOptOrWheel, staticSag * 1.2); + const maxSuspensionTravel = (sus.maxTravel ?? DEFAULT_MAX_SUSPENSION_TRAVEL) * scale; + const rollInfluence = sus.rollInfluence ?? DEFAULT_WHEEL_PHYSICS.rollInfluence; + for (let i = 0; i < wheelsInfo.length; i++) { + wheelsInfo[i].position = wheelWrappers[i].position.clone(); + wheelsInfo[i].suspensionRestLength = suspensionRestLength; + wheelsInfo[i].maxSuspensionTravel = maxSuspensionTravel; + wheelsInfo[i].radius = wheelRadius; + } + + // 按左前、右前、左后、右后轮中心推算车辆前向 + const frontCenter = wheelWrappers[0].position.clone().add(wheelWrappers[1].position).multiplyScalar(0.5); + const rearCenter = wheelWrappers[2].position.clone().add(wheelWrappers[3].position).multiplyScalar(0.5); + const forwardLocal = frontCenter.sub(rearCenter); + forwardLocal.y = 0; + if (forwardLocal.lengthSq() < 1e-8) forwardLocal.set(1, 0, 0); + else forwardLocal.normalize(); + + const halfExtents = new THREE.Vector3( + bodySize.x * 0.5 * sx, + boxHeight * 0.5, + bodySize.z * 0.5 * sz, + ); + const volume = 8 * halfExtents.x * halfExtents.y * halfExtents.z; + const mass = volume * density; + + // position.y 对齐最低轮底 + minTireBottom = Infinity; + for (const wrapper of wheelWrappers) { + minTireBottom = Math.min(minTireBottom, wrapper.position.y - wheelRadius); + } + const spawnPosition = opts.position.clone(); + spawnPosition.y -= minTireBottom; + + const chassisBody = new VehicleRigidBody({ + position: spawnPosition, + mass, + halfExtents, + linearDamping, + angularDamping, + gravityScale: scale, + }); + + // 物理调试盒 + const physicsBoxMesh = new THREE.Mesh( + new THREE.BoxGeometry(halfExtents.x * 2, halfExtents.y * 2, halfExtents.z * 2), + new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.3 }), + ); + physicsBoxMesh.userData.excludeFromCollider = true; + if (debug.showPhysicsBox) vehicleGroup.add(physicsBoxMesh); + + vehicleGroup.position.copy(spawnPosition); + vehicleGroup.updateMatrixWorld(true); + + const wheelPhysics = { + suspensionStiffness: sus.stiffness ?? DEFAULT_WHEEL_PHYSICS.suspensionStiffness, + suspensionCompression: sus.compression ?? DEFAULT_WHEEL_PHYSICS.suspensionCompression, + suspensionRelaxation: sus.relaxation ?? DEFAULT_WHEEL_PHYSICS.suspensionRelaxation, + maxSuspensionForce: sus.maxForce ?? DEFAULT_WHEEL_PHYSICS.maxSuspensionForce, + frictionSlip: sus.frictionSlip ?? DEFAULT_WHEEL_PHYSICS.frictionSlip, + sideFrictionStiffness: sus.sideFrictionStiffness ?? DEFAULT_WHEEL_PHYSICS.sideFrictionStiffness, + rollInfluence, + }; + + const { vehicle, updateWheelVisuals, destroy, wheelRayDebug, wheelTravelDebug } = createVehicleController( + chassisBody, + wheelWrappers, + wheelsInfo, + wheelPhysics, + ); + vehicleGroup.add(wheelRayDebug); + vehicleGroup.add(wheelTravelDebug); + wheelRayDebug.visible = debug.showWheelRays; + wheelTravelDebug.visible = debug.showWheelTravel; + + return { + vehicleGroup, + chassisBody, + vehicleController: vehicle, + updateWheelVisuals, + destroyVehicleController: destroy, + scale, + driverSeatPosition: opts.driverSeatPosition.clone(), + driverSeatRotation: opts.driverSeatRotation ?? 0, + forwardLocal, + sideFrictionStiffness: wheelPhysics.sideFrictionStiffness, + steering, + grip, + size: { l: Math.max(bodySize.x, bodySize.z), w: Math.min(bodySize.x, bodySize.z), h: bodySize.y }, + halfExtents, + chassisClearance, + chassisTopClearance, + chassisSizeScaleX: sx, + chassisSizeScaleY: sy, + chassisSizeScaleZ: sz, + maxSpeed, + acceleration, + deceleration, + followVehicleDirection, + physicsBoxMesh, + wheelRayDebug, + wheelTravelDebug, + }; +} diff --git a/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts b/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts new file mode 100644 index 0000000..a5d879d --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/BVHVehicleController.ts @@ -0,0 +1,485 @@ +import * as THREE from "three"; +import { VehicleCollision } from "./VehicleCollision"; +import { ContactCache } from "../../collision/contacts/ContactCache"; +import { ContactImpulseSolver } from "../../collision/solver/ContactImpulseSolver"; +import { effectiveMass } from "./vehicleMath"; +import type { VehicleRigidBody } from "./VehicleRigidBody"; +import { VehicleWheel } from "./VehicleWheel"; + +const SIDE_DAMPING_RATE = 13; // 侧向速度阻尼率(1/s) +const FWD_FACTOR = 0.5; // 摩擦椭圆纵向权重 +const SIDE_FACTOR = 1.0; // 摩擦椭圆侧向权重 + +/** 基于 three-mesh-bvh 射线与轻量刚体的 Raycast Vehicle 求解器。 */ +export class BVHVehicleController { + readonly chassis: VehicleRigidBody; // 车身刚体 + readonly wheels: VehicleWheel[] = []; // 车轮列表 + + private collider: THREE.Mesh[] = []; // 场景碰撞网格 + private collision = new VehicleCollision(); + private contactSolver = new ContactImpulseSolver(); + private contactCache = new ContactCache(); + private raycaster = new THREE.Raycaster(); + private prevPosition = new THREE.Vector3(); + private prevQuaternion = new THREE.Quaternion(); + private normalMat = new THREE.Matrix3(); + private steerAxis = new THREE.Vector3(); + private steerQuat = new THREE.Quaternion(); + private vel = new THREE.Vector3(); + private impulse = new THREE.Vector3(); + private r = new THREE.Vector3(); + private gcross = new THREE.Vector3(); + private local = new THREE.Vector3(); + private world = new THREE.Vector3(); + private invQuat = new THREE.Quaternion(); + private upWS = new THREE.Vector3(); + private sidePoint = new THREE.Vector3(); + + /** 绑定车身刚体,可选传入场景碰撞网格。 */ + constructor(chassis: VehicleRigidBody, collider?: THREE.Mesh | THREE.Mesh[] | null) { + this.chassis = chassis; + this.collider = this.toColliderList(collider); + (this.raycaster as any).firstHitOnly = true; + } + + /** 设置场景碰撞网格。 */ + setCollider(collider: THREE.Mesh | THREE.Mesh[] | null | undefined): void { + this.collider = this.toColliderList(collider); + } + + /** 返回车轮数量。 */ + numWheels(): number { + return this.wheels.length; + } + + // ==================== 轮子参数 ==================== + + /** 添加车轮并写入悬挂几何。 */ + addWheel( + chassisConnectionCs: THREE.Vector3, + directionCs: THREE.Vector3, + axleCs: THREE.Vector3, + suspensionRestLength: number, + radius: number, + ): VehicleWheel { + const wheel = new VehicleWheel(); + wheel.connectionPoint.copy(chassisConnectionCs); + wheel.direction.copy(directionCs); + wheel.axle.copy(axleCs); + wheel.restLength = suspensionRestLength; + wheel.suspensionLength = suspensionRestLength; + wheel.visualLength = suspensionRestLength; + wheel.maxSuspensionTravel = suspensionRestLength; + wheel.radius = radius; + this.wheels.push(wheel); + return wheel; + } + + /** 设置悬挂与底盘的局部连接点。 */ + setWheelChassisConnectionPointCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.connectionPoint.copy(value); + } + + /** 设置悬挂方向(底盘局部)。 */ + setWheelDirectionCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.direction.copy(value); + } + + /** 设置轮轴方向(底盘局部)。 */ + setWheelAxleCs(i: number, value: THREE.Vector3): void { + this.wheelAt(i)?.axle.copy(value); + } + + /** 设置悬挂静止长度。 */ + setWheelSuspensionRestLength(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.restLength = value; + } + + /** 设置轮胎半径。 */ + setWheelRadius(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.radius = value; + } + + /** 设置悬挂最大行程。 */ + setWheelMaxSuspensionTravel(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.maxSuspensionTravel = value; + } + + /** 设置悬挂刚度。 */ + setWheelSuspensionStiffness(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.stiffness = value; + } + + /** 设置压缩阻尼。 */ + setWheelSuspensionCompression(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.dampingCompression = value; + } + + /** 设置回弹阻尼。 */ + setWheelSuspensionRelaxation(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.dampingRelaxation = value; + } + + /** 设置最大悬挂力。 */ + setWheelMaxSuspensionForce(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.maxSuspensionForce = value; + } + + /** 设置制动力。 */ + setWheelBrake(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.brake = value; + } + + /** 设置转向角。 */ + setWheelSteering(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.steering = value; + } + + /** 设置驱动力。 */ + setWheelEngineForce(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.engineForce = value; + } + + /** 设置纵向抓地。 */ + setWheelFrictionSlip(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.frictionSlip = value; + } + + /** 设置侧向摩擦。 */ + setWheelSideFrictionStiffness(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.sideFrictionStiffness = value; + } + + /** 设置侧向力对侧倾的影响,0 为几乎不侧倾,1 为按真实触地点侧倾。 */ + setWheelRollInfluence(i: number, value: number): void { + const wheel = this.wheelAt(i); + if (wheel) wheel.rollInfluence = value; + } + + /** 读取转向角。 */ + wheelSteering(i: number): number | null { + return this.wheelAt(i)?.steering ?? null; + } + + /** 读取是否接地。 */ + wheelIsInContact(i: number): boolean { + return this.wheelAt(i)?.isInContact ?? false; + } + + /** 读取轮轴方向(底盘局部)。 */ + wheelAxleCs(i: number): THREE.Vector3 | null { + return this.wheelAt(i)?.axle ?? null; + } + + /** 读取悬挂与底盘的局部连接点。 */ + wheelChassisConnectionPointCs(i: number): THREE.Vector3 | null { + return this.wheelAt(i)?.connectionPoint ?? null; + } + + /** 读取当前悬挂长度。 */ + wheelSuspensionLength(i: number): number | null { + return this.wheelAt(i)?.suspensionLength ?? null; + } + + /** 读取视觉悬挂长度。 */ + wheelVisualLength(i: number): number | null { + return this.wheelAt(i)?.visualLength ?? null; + } + + /** 读取车轮自转角。 */ + wheelRotation(i: number): number | null { + return this.wheelAt(i)?.rotation ?? null; + } + + /** 读取接地网格。 */ + wheelContactMesh(i: number): THREE.Mesh | null { + return this.wheelAt(i)?.contactMesh ?? null; + } + + // ==================== 仿真步进 ==================== + + /** 推进一帧:悬挂与轮胎力 → 车身接触 → 积分 → CCD。 */ + updateVehicle(dt: number, collider?: THREE.Mesh | THREE.Mesh[] | null): void { + if (collider !== undefined) this.collider = this.toColliderList(collider); + const clampedDt = Math.max(0, dt); + if (clampedDt === 0) return; + + this.prevPosition.copy(this.chassis.position); + this.prevQuaternion.copy(this.chassis.quaternion); + this.updateWheelTransforms(); + this.chassis.applyGravity(clampedDt); // 重力 + this.raycastWheels(); // 轮子向下射线 + this.solveSuspension(); // 悬挂弹簧阻尼 + this.applySuspensionImpulses(clampedDt); + this.solveFriction(clampedDt); // 纵向驱动 / 侧向抓地 + + // 积分前解一次车身接触速度 + const manifolds = this.collision.detect(this.chassis, this.collider); + this.contactCache.match(manifolds); + this.contactSolver.solveVelocity(this.chassis, manifolds, clampedDt); + this.contactCache.save(manifolds); + + this.chassis.applyDamping(clampedDt); + this.chassis.integrate(clampedDt); // 积分位置和朝向 + + // 积分后再解一次,消化穿透 + const positionManifolds = this.collision.detect(this.chassis, this.collider); + this.contactCache.match(positionManifolds); + this.contactSolver.solveVelocity(this.chassis, positionManifolds, clampedDt); + this.contactCache.save(positionManifolds); + + // 高速时扫掠回退,避免穿模 + if (this.collision.needsSwept(this.chassis, clampedDt)) { + this.collision.solveSwept( + this.chassis, this.collider, + this.prevPosition, this.prevQuaternion, + ); + } + this.updateWheelRotation(clampedDt); + } + + /** 把单个网格或列表收成可遍历数组。 */ + private toColliderList(collider?: THREE.Mesh | THREE.Mesh[] | null): THREE.Mesh[] { + if (!collider) return []; + return Array.isArray(collider) ? collider : [collider]; + } + + /** 按索引取车轮,越界返回 undefined。 */ + wheelAt(i: number): VehicleWheel | undefined { + return this.wheels[i]; + } + + // 把悬挂连接点、方向和转向后的轮轴变到世界空间 + private updateWheelTransforms(): void { + const q = this.chassis.quaternion; + for (const wheel of this.wheels) { + wheel.hardPointWS.copy(wheel.connectionPoint).applyQuaternion(q).add(this.chassis.position); + wheel.directionWS.copy(wheel.direction).applyQuaternion(q); + this.steerAxis.copy(wheel.directionWS).negate(); + this.steerQuat.setFromAxisAngle(this.steerAxis, wheel.steering); + wheel.axleWS.copy(wheel.axle).applyQuaternion(q).applyQuaternion(this.steerQuat); + } + } + + // ==================== 悬挂与摩擦 ==================== + + // 沿悬挂方向射线检测接地 + private raycastWheels(): void { + const colliders = this.collider; + for (const wheel of this.wheels) { + wheel.isInContact = false; + wheel.contactMesh = null; + const rayLen = wheel.restLength + wheel.maxSuspensionTravel + wheel.radius; + if (!colliders.length || rayLen <= 1e-6) { + this.setWheelAirborne(wheel); + continue; + } + + const dirLen = wheel.directionWS.length(); + if (dirLen < 1e-8) { + this.setWheelAirborne(wheel); + continue; + } + this.impulse.copy(wheel.directionWS).multiplyScalar(1 / dirLen); + this.raycaster.set(wheel.hardPointWS, this.impulse); + this.raycaster.near = 0; + this.raycaster.far = rayLen; + const hits = this.raycaster.intersectObjects(colliders, false); + const hit = hits[0]; + if (!hit) { + this.setWheelAirborne(wheel); + continue; + } + + wheel.isInContact = true; + wheel.contactMesh = (hit.object as THREE.Mesh).isMesh ? hit.object as THREE.Mesh : null; + wheel.contactPoint.copy(hit.point); + if (hit.face) { + this.normalMat.getNormalMatrix(hit.object.matrixWorld); + wheel.contactNormal.copy(hit.face.normal).applyMatrix3(this.normalMat).normalize(); + } else { + wheel.contactNormal.copy(wheel.directionWS).negate().normalize(); + } + if (wheel.contactNormal.dot(wheel.directionWS) > 0) wheel.contactNormal.negate(); + + // 物理长度夹在行程内;视觉长度跟射线 + const rawLength = hit.distance - wheel.radius; + const minLen = Math.max(0, wheel.restLength - wheel.maxSuspensionTravel); + const maxLen = wheel.restLength + wheel.maxSuspensionTravel; + wheel.visualLength = rawLength; + wheel.suspensionLength = Math.min(maxLen, Math.max(minLen, rawLength)); + + const denom = wheel.contactNormal.dot(wheel.directionWS); + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const projVel = wheel.contactNormal.dot(this.vel); + if (denom >= -0.1) { + // 法线几乎与悬挂平行或反向,避免除零 + wheel.suspensionRelativeVelocity = 0; + wheel.clippedInvContactDotSuspension = 10; + } else { + const inv = -1 / denom; + wheel.suspensionRelativeVelocity = projVel * inv; + wheel.clippedInvContactDotSuspension = inv; + } + } + } + + // 离地时回到静止长度 + private setWheelAirborne(wheel: VehicleWheel): void { + wheel.isInContact = false; + wheel.contactMesh = null; + wheel.suspensionLength = wheel.restLength; + wheel.visualLength = wheel.restLength; + wheel.suspensionRelativeVelocity = 0; + wheel.contactNormal.copy(wheel.directionWS).negate().normalize(); + wheel.clippedInvContactDotSuspension = 1; + wheel.suspensionForce = 0; + } + + // 弹簧 + 压缩/回弹阻尼 + private solveSuspension(): void { + const chassisMass = this.chassis.mass(); + for (const wheel of this.wheels) { + if (!wheel.isInContact) { + wheel.suspensionForce = 0; + continue; + } + const lengthDiff = wheel.restLength - wheel.suspensionLength; + let force = wheel.stiffness * lengthDiff * wheel.clippedInvContactDotSuspension; + const damping = wheel.suspensionRelativeVelocity < 0 + ? wheel.dampingCompression + : wheel.dampingRelaxation; + force -= damping * wheel.suspensionRelativeVelocity; + wheel.suspensionForce = Math.max(0, force * chassisMass); // 悬挂只推不拉 + } + } + + // 沿接触法线施加悬挂冲量;作用点用轮心,减少转向时接地点把车顶歪 + private applySuspensionImpulses(dt: number): void { + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + const force = Math.min(wheel.suspensionForce, wheel.maxSuspensionForce); + this.impulse.copy(wheel.contactNormal).multiplyScalar(force * dt); + this.chassis.applyImpulseAtPoint(this.impulse, wheel.hardPointWS); + } + } + + // 侧向抓地、驱动/制动,超出摩擦椭圆则打滑缩放 + private solveFriction(dt: number): void { + let wheelsOnGround = 0; + for (const wheel of this.wheels) { + wheel.sideImpulse = 0; + wheel.forwardImpulse = 0; + wheel.skidInfo = 1; + if (!wheel.isInContact) continue; + wheelsOnGround += 1; + + wheel.sideWS.copy(wheel.axleWS); + const proj = wheel.sideWS.dot(wheel.contactNormal); + wheel.sideWS.addScaledVector(wheel.contactNormal, -proj); + if (wheel.sideWS.lengthSq() < 1e-8) continue; + wheel.sideWS.normalize(); + wheel.forwardWS.copy(wheel.contactNormal).cross(wheel.sideWS); + if (wheel.forwardWS.lengthSq() < 1e-8) continue; + wheel.forwardWS.normalize(); + + // 侧向速度阻尼,抓地由 sideFrictionStiffness 缩放 + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const sideSpeed = wheel.sideWS.dot(this.vel); + const mass = effectiveMass( + this.chassis, wheel.contactPoint, wheel.sideWS, + this.r, this.gcross, this.local, this.world, this.invQuat, + ); + const dampFactor = Math.min( + 1, + (1 - Math.exp(-SIDE_DAMPING_RATE * dt)) * wheel.sideFrictionStiffness, + ); + wheel.sideImpulse = -dampFactor * sideSpeed * mass; + } + + // 驱动或制动纵向冲量,并检测摩擦椭圆 + let sliding = false; + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + + if (wheel.engineForce !== 0) { + wheel.forwardImpulse = wheel.engineForce * dt; + } else { + // 无驱动时用制动冲量刹住纵向滑动 + const maxImpulse = wheel.brake; + this.chassis.getVelocityAtPoint(wheel.contactPoint, this.vel); + const fwdSpeed = wheel.forwardWS.dot(this.vel); + const mass = effectiveMass( + this.chassis, wheel.contactPoint, wheel.forwardWS, + this.r, this.gcross, this.local, this.world, this.invQuat, + ); + const denom = Math.max(1, wheelsOnGround); + wheel.forwardImpulse = Math.max(-maxImpulse, Math.min(maxImpulse, -fwdSpeed * mass / denom)); + } + + const maxImp = wheel.suspensionForce * dt * wheel.frictionSlip; + const x = wheel.forwardImpulse * FWD_FACTOR; + const y = wheel.sideImpulse * SIDE_FACTOR; + const impulseSq = x * x + y * y; + const maxImpSq = maxImp * maxImp; + if (impulseSq > maxImpSq && impulseSq > 1e-12) { + sliding = true; + wheel.skidInfo *= maxImp / Math.sqrt(impulseSq); + } + } + + if (sliding) { + // 超出椭圆的轮子按 skidInfo 同时缩小纵/侧向 + for (const wheel of this.wheels) { + if (wheel.skidInfo < 1) { + wheel.forwardImpulse *= wheel.skidInfo; + wheel.sideImpulse *= wheel.skidInfo; + } + } + } + + // 纵向冲量作用在轮心;侧向冲量作用在触地点,rollInfluence 收短侧倾力臂 + this.upWS.set(0, 1, 0).applyQuaternion(this.chassis.quaternion); + for (const wheel of this.wheels) { + if (!wheel.isInContact) continue; + if (wheel.forwardImpulse !== 0) { + this.impulse.copy(wheel.forwardWS).multiplyScalar(wheel.forwardImpulse); + this.chassis.applyImpulseAtPoint(this.impulse, wheel.hardPointWS); + } + if (wheel.sideImpulse !== 0) { + this.impulse.copy(wheel.sideWS).multiplyScalar(wheel.sideImpulse); + this.sidePoint.copy(wheel.contactPoint); + this.r.subVectors(wheel.contactPoint, this.chassis.position); + this.sidePoint.addScaledVector(this.upWS, -this.upWS.dot(this.r) * (1 - wheel.rollInfluence)); + this.chassis.applyImpulseAtPoint(this.impulse, this.sidePoint); + } + } + } + + // 按接地点前向速度更新车轮自转 + private updateWheelRotation(dt: number): void { + for (const wheel of this.wheels) { + if (wheel.isInContact) { + this.chassis.getVelocityAtPoint(wheel.hardPointWS, this.vel); + const proj = wheel.forwardWS.dot(this.vel); + wheel.deltaRotation = wheel.radius > 1e-6 ? -(proj * dt) / wheel.radius : 0; + wheel.rotation += wheel.deltaRotation; + } else { + wheel.rotation += wheel.deltaRotation; + } + wheel.deltaRotation *= 0.99; + } + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts new file mode 100644 index 0000000..c6a643c --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleCollision.ts @@ -0,0 +1,373 @@ +import * as THREE from "three"; +import type { ContactManifold } from "../../collision/contacts/ContactManifold"; +import { CONTACT_SKIN, contactSkinForExtent, type RawContact } from "../../collision/contacts/ContactPoint"; +import { reduceContacts } from "../../collision/contacts/contactReducer"; +import { closestPointOnTriangle } from "./vehicleMath"; +import type { ImpulseBody } from "../../collision/solver/ImpulseBody"; +import type { VehicleRigidBody } from "./VehicleRigidBody"; + +const MAX_RAW = 96; // 单帧原始接触上限 +const CCD_SIZE_RATIO = 0.12; // 位移超过最小边长该比例时启用扫掠 + +/** detect 所需的 OBB 刚体(车辆底盘 / 动态盒共用)。 */ +export type ObbMeshBody = ImpulseBody & { + halfExtents: THREE.Vector3; +}; + +/** 车身 OBB 与 BVH 三角网格:只收集接触,高速时做扫掠。 */ +export class VehicleCollision { + private raw: RawContact[] = Array.from({ length: MAX_RAW }, () => ({ + point: new THREE.Vector3(), + normal: new THREE.Vector3(), + penetration: 0, + })); + private rawCount = 0; + private manifolds: ContactManifold[] = []; + private toward = new THREE.Vector3(); + + private invMat = new THREE.Matrix4(); + private worldMat = new THREE.Matrix4(); + private normalMat = new THREE.Matrix3(); + private localAABB = new THREE.Box3(); + private worldAABB = new THREE.Box3(); + private corner = new THREE.Vector3(); + private prevCorner = new THREE.Vector3(); + private axisX = new THREE.Vector3(); + private axisY = new THREE.Vector3(); + private axisZ = new THREE.Vector3(); + private prevAxisX = new THREE.Vector3(); + private prevAxisY = new THREE.Vector3(); + private prevAxisZ = new THREE.Vector3(); + private toiQuat = new THREE.Quaternion(); + private localCenter = new THREE.Vector3(); + private localAxisX = new THREE.Vector3(); + private localAxisY = new THREE.Vector3(); + private localAxisZ = new THREE.Vector3(); + private triN = new THREE.Vector3(); + private edge = new THREE.Vector3(); + private testAxis = new THREE.Vector3(); + private bestAxis = new THREE.Vector3(); + private rangeA = { min: 0, max: 0 }; + private rangeB = { min: 0, max: 0 }; + private closest = new THREE.Vector3(); + private ab = new THREE.Vector3(); + private ac = new THREE.Vector3(); + private ap = new THREE.Vector3(); + private bp = new THREE.Vector3(); + private cp = new THREE.Vector3(); + private impulse = new THREE.Vector3(); + private support = new THREE.Vector3(); + private bodyHalf = new THREE.Vector3(); + private raycaster = new THREE.Raycaster(); + /** 当前 detect 按刚体尺寸缩放后的接触皮肤。 */ + private contactSkin = CONTACT_SKIN; + + /** 配置射线只取最近命中。 */ + constructor() { + (this.raycaster as any).firstHitOnly = true; + } + + /** 收集 OBB 与一组网格的接触流形。 */ + detect( + body: ObbMeshBody, + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + ): ContactManifold[] { + this.rawCount = 0; + this.manifolds.length = 0; + const extent = Math.max(body.halfExtents.x, body.halfExtents.y, body.halfExtents.z); + this.contactSkin = contactSkinForExtent(extent); + for (const collider of this.toList(colliders)) { + const tree = (collider.geometry as any)?.boundsTree; + if (!tree) continue; + this.collectRawContacts(body, collider, tree); + } + if (this.rawCount === 0) return this.manifolds; + return reduceContacts(this.raw, this.rawCount, body, this.manifolds); + } + + /** 本帧位移是否大到需要扫掠。 */ + needsSwept(body: VehicleRigidBody, dt: number): boolean { + const hx = body.halfExtents.x; + const hy = body.halfExtents.y; + const hz = body.halfExtents.z; + const minSize = 2 * Math.min(hx, hy, hz); + const linTravel = body.linearVelocity.length() * dt; + const angTravel = body.angularVelocity.length() * Math.hypot(hx, hy, hz) * dt; + return linTravel + angTravel > minSize * CCD_SIZE_RATIO; + } + + /** 高速穿模时按 TOI 回退位置和朝向。 */ + solveSwept( + body: VehicleRigidBody, + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + prevPosition: THREE.Vector3, + prevQuaternion: THREE.Quaternion, + ): void { + const list = this.toList(colliders); + if (!list.length) return; + this.solveSweptCollision(body, list, prevPosition, prevQuaternion); + } + + /** 对 8 个角点和中心扫过所有网格,按最早 TOI 回退。 */ + private solveSweptCollision( + body: VehicleRigidBody, + colliders: THREE.Mesh[], + prevPosition: THREE.Vector3, + prevQuaternion: THREE.Quaternion, + ): void { + this.bodyHalf.copy(body.halfExtents); + this.setAxes(prevQuaternion, this.prevAxisX, this.prevAxisY, this.prevAxisZ); + this.setAxes(body.quaternion, this.axisX, this.axisY, this.axisZ); + this.raycaster.near = 0; + let minToi = 1; + let hitNormal: THREE.Vector3 | null = null; + + // 8 角点 + 中心沿本帧位移射线,取最早命中 + for (let i = 0; i < 9; i++) { + if (i === 8) { + this.prevCorner.copy(prevPosition); + this.corner.copy(body.position); + } else { + const sx = (i & 1) ? 1 : -1; + const sy = (i & 2) ? 1 : -1; + const sz = (i & 4) ? 1 : -1; + this.cornerFrom(prevPosition, this.prevAxisX, this.prevAxisY, this.prevAxisZ, sx, sy, sz, this.prevCorner); + this.cornerFrom(body.position, this.axisX, this.axisY, this.axisZ, sx, sy, sz, this.corner); + } + this.impulse.subVectors(this.corner, this.prevCorner); + const len = this.impulse.length(); + if (len < 1e-6) continue; + this.impulse.multiplyScalar(1 / len); + this.raycaster.set(this.prevCorner, this.impulse); + this.raycaster.far = len; + const hits = this.raycaster.intersectObjects(colliders, false); + if (!hits.length) continue; + const hit = hits[0]; + const toi = hit.distance / len; + if (toi >= minToi || toi <= 1e-4) continue; + minToi = toi; + if (hit.face) { + this.normalMat.getNormalMatrix(hit.object.matrixWorld); + this.bestAxis.copy(hit.face.normal).applyMatrix3(this.normalMat).normalize(); + if (this.bestAxis.dot(this.impulse) > 0) this.bestAxis.negate(); + this.toward.copy(this.bestAxis); + hitNormal = this.toward; + } + } + + if (minToi >= 1) return; + const t = minToi * 0.95; // 略提前停在命中前 + body.position.lerpVectors(prevPosition, body.position, t); + this.toiQuat.slerpQuaternions(prevQuaternion, body.quaternion, t); + body.quaternion.copy(this.toiQuat); + // 去掉沿命中法线的入射速度 + if (!hitNormal) return; + const vn = body.linearVelocity.dot(hitNormal); + if (vn < 0) body.linearVelocity.addScaledVector(hitNormal, -vn); + } + + /** 把单个网格或列表收成可遍历数组。 */ + private toList( + colliders: THREE.Mesh[] | THREE.Mesh | null | undefined, + ): THREE.Mesh[] { + if (!colliders) return []; + return Array.isArray(colliders) ? colliders : [colliders]; + } + + /** 用 BVH shapecast 收集与 OBB 相交的三角。 */ + private collectRawContacts( + body: ObbMeshBody, + collider: THREE.Mesh, + tree: { shapecast: (opts: any) => void }, + ): void { + this.setWorldAxes(body); + this.worldMat.copy(collider.matrixWorld); + this.invMat.copy(this.worldMat).invert(); + this.normalMat.getNormalMatrix(this.worldMat); + + // OBB 变到网格本地,再用 AABB 做 BVH 粗测 + this.localCenter.copy(body.position).applyMatrix4(this.invMat); + this.localAxisX.copy(this.axisX).transformDirection(this.invMat); + this.localAxisY.copy(this.axisY).transformDirection(this.invMat); + this.localAxisZ.copy(this.axisZ).transformDirection(this.invMat); + + this.computeOBBWorldAABB(body.position, this.worldAABB); + this.localAABB.copy(this.worldAABB).applyMatrix4(this.invMat); + + const self = this; + tree.shapecast({ + intersectsBounds: (box: THREE.Box3) => box.intersectsBox(self.localAABB), + intersectsTriangle: (tri: { a: THREE.Vector3; b: THREE.Vector3; c: THREE.Vector3 }) => { + self.testTriangle(body, tri.a, tri.b, tri.c); + }, + }); + } + + /** SAT 分离则丢弃;否则用支撑点投影出接触。 */ + private testTriangle( + body: ObbMeshBody, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): void { + this.ab.subVectors(b, a); + this.ac.subVectors(c, a); + this.triN.copy(this.ab).cross(this.ac); + if (this.triN.lengthSq() < 1e-12) return; + this.triN.normalize(); + + // 面法线、OBB 三轴、边×轴,任一分离则不相交 + if (!this.tryAxis(this.triN, a, b, c)) return; + if (!this.tryAxis(this.localAxisX, a, b, c)) return; + if (!this.tryAxis(this.localAxisY, a, b, c)) return; + if (!this.tryAxis(this.localAxisZ, a, b, c)) return; + this.edge.subVectors(b, a); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + this.edge.subVectors(c, b); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + this.edge.subVectors(a, c); + if (!this.tryCrossAxes(this.edge, a, b, c)) return; + + const radius = + this.bodyHalf.x * Math.abs(this.triN.dot(this.localAxisX)) + + this.bodyHalf.y * Math.abs(this.triN.dot(this.localAxisY)) + + this.bodyHalf.z * Math.abs(this.triN.dot(this.localAxisZ)); + const plane = a.dot(this.triN); + const depth = plane - (this.localCenter.dot(this.triN) - radius); + if (depth <= -this.contactSkin) return; + + // 沿法线取盒子支撑点,再投影到三角得到接触 + this.support.copy(this.localCenter) + .addScaledVector(this.localAxisX, -this.bodyHalf.x * Math.sign(this.triN.dot(this.localAxisX) || 1)) + .addScaledVector(this.localAxisY, -this.bodyHalf.y * Math.sign(this.triN.dot(this.localAxisY) || 1)) + .addScaledVector(this.localAxisZ, -this.bodyHalf.z * Math.sign(this.triN.dot(this.localAxisZ) || 1)); + closestPointOnTriangle( + this.support, a, b, c, this.closest, + this.ab, this.ac, this.ap, this.bp, this.cp, + ); + this.addRawContact(this.closest, this.triN, depth, body); + } + + /** 用三角边与 OBB 轴的叉积做分离轴测试。 */ + private tryCrossAxes( + edge: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): boolean { + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisX), a, b, c)) return false; + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisY), a, b, c)) return false; + if (!this.tryAxis(this.testAxis.copy(edge).cross(this.localAxisZ), a, b, c)) return false; + return true; + } + + /** 沿一根轴投影 OBB 与三角,重叠则通过。 */ + private tryAxis( + axis: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + ): boolean { + const lenSq = axis.lengthSq(); + if (lenSq < 1e-10) { + this.rangeB.max = Infinity; + return true; // 退化轴视为不分离 + } + this.testAxis.copy(axis).multiplyScalar(1 / Math.sqrt(lenSq)); + this.projectOBB(this.testAxis, this.rangeA); + const p0 = a.dot(this.testAxis); + const p1 = b.dot(this.testAxis); + const p2 = c.dot(this.testAxis); + this.rangeB.min = Math.min(p0, p1, p2); + const triMax = Math.max(p0, p1, p2); + if (this.rangeA.max + this.contactSkin < this.rangeB.min || this.rangeA.min - this.contactSkin > triMax) return false; + this.rangeB.max = Math.min(this.rangeA.max, triMax) - Math.max(this.rangeA.min, this.rangeB.min); + return true; + } + + /** 把 OBB 投影到指定轴,写出 min/max。 */ + private projectOBB(axis: THREE.Vector3, out: { min: number; max: number }): void { + const r = + this.bodyHalf.x * Math.abs(axis.dot(this.localAxisX)) + + this.bodyHalf.y * Math.abs(axis.dot(this.localAxisY)) + + this.bodyHalf.z * Math.abs(axis.dot(this.localAxisZ)); + const c = this.localCenter.dot(axis); + out.min = c - r; + out.max = c + r; + } + + /** 写入原始接触;槽满时替换最浅的一个。 */ + private addRawContact( + localPoint: THREE.Vector3, + localNormal: THREE.Vector3, + depth: number, + body: ObbMeshBody, + ): void { + let slot = this.rawCount; + if (slot === MAX_RAW) { + let shallow = 0; + for (let i = 1; i < MAX_RAW; i++) { + if (this.raw[i].penetration < this.raw[shallow].penetration) shallow = i; + } + if (depth <= this.raw[shallow].penetration) return; + slot = shallow; + } else { + this.rawCount += 1; + } + const contact = this.raw[slot]; + contact.point.copy(localPoint).applyMatrix4(this.worldMat); + contact.normal.copy(localNormal).applyMatrix3(this.normalMat).normalize(); + this.toward.subVectors(body.position, contact.point); + if (contact.normal.dot(this.toward) < 0) contact.normal.negate(); // 法线指向盒子 + contact.penetration = depth; + } + + /** 从朝向取出世界轴,并缓存半边长。 */ + private setWorldAxes(body: ObbMeshBody): void { + this.setAxes(body.quaternion, this.axisX, this.axisY, this.axisZ); + this.bodyHalf.copy(body.halfExtents); + } + + /** 由四元数写出三个正交轴。 */ + private setAxes( + quat: THREE.Quaternion, + x: THREE.Vector3, + y: THREE.Vector3, + z: THREE.Vector3, + ): void { + x.set(1, 0, 0).applyQuaternion(quat); + y.set(0, 1, 0).applyQuaternion(quat); + z.set(0, 0, 1).applyQuaternion(quat); + } + + /** 由 8 个角点计算 OBB 世界 AABB。 */ + private computeOBBWorldAABB(center: THREE.Vector3, out: THREE.Box3): void { + out.makeEmpty(); + for (let i = 0; i < 8; i++) { + this.cornerFrom( + center, this.axisX, this.axisY, this.axisZ, + (i & 1) ? 1 : -1, (i & 2) ? 1 : -1, (i & 4) ? 1 : -1, + this.corner, + ); + out.expandByPoint(this.corner); + } + out.expandByScalar(0.08); // 略放大,避免贴边漏三角 + } + + /** 按轴向符号组合出 OBB 角点。 */ + private cornerFrom( + center: THREE.Vector3, + axisX: THREE.Vector3, + axisY: THREE.Vector3, + axisZ: THREE.Vector3, + sx: number, + sy: number, + sz: number, + out: THREE.Vector3, + ): THREE.Vector3 { + return out.copy(center) + .addScaledVector(axisX, sx * this.bodyHalf.x) + .addScaledVector(axisY, sy * this.bodyHalf.y) + .addScaledVector(axisZ, sz * this.bodyHalf.z); + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts new file mode 100644 index 0000000..cf562df --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleRigidBody.ts @@ -0,0 +1,174 @@ +import * as THREE from "three"; +import { integrateQuaternion } from "./vehicleMath"; + +export type VehicleRigidBodyOptions = { + position: THREE.Vector3; // 初始世界位置 + mass: number; // 质量(kg) + halfExtents: THREE.Vector3; // 碰撞盒半边长 + linearDamping?: number; // 线阻尼 + angularDamping?: number; // 角阻尼 + gravityScale?: number; // 重力缩放 +}; + +const GRAVITY = 9.81; // 重力加速度 + +/** 车辆专用轻量刚体。 */ +export class VehicleRigidBody { + position = new THREE.Vector3(); // 世界位置 + quaternion = new THREE.Quaternion(); // 世界朝向 + + linearVelocity = new THREE.Vector3(); // 线速度 + angularVelocity = new THREE.Vector3(); // 角速度 + + private _mass: number; // 质量 + invMass: number; // 质量倒数 + + inertia = new THREE.Vector3(); // 局部惯量对角元 + invInertia = new THREE.Vector3(); // 惯量倒数 + + linearDamping: number; // 线阻尼 + angularDamping: number; // 角阻尼 + gravityScale: number; // 重力缩放 + halfExtents: THREE.Vector3; // 碰撞盒半边长 + + private _r = new THREE.Vector3(); + private _torque = new THREE.Vector3(); + private _local = new THREE.Vector3(); + private _world = new THREE.Vector3(); + private _invQuat = new THREE.Quaternion(); + + constructor(opts: VehicleRigidBodyOptions) { + this.position.copy(opts.position); + this.halfExtents = opts.halfExtents.clone(); + this._mass = Math.max(1e-6, opts.mass); + this.invMass = 1 / this._mass; + this.linearDamping = opts.linearDamping ?? 0.05; + this.angularDamping = opts.angularDamping ?? 0.5; + this.gravityScale = opts.gravityScale ?? 1; + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 按长方体质量分布设置惯量。 */ + setCuboidInertia(mass: number, half: THREE.Vector3): void { + const w = half.x * 2; + const h = half.y * 2; + const d = half.z * 2; + this.inertia.set( + mass / 12 * (h * h + d * d), + mass / 12 * (w * w + d * d), + mass / 12 * (w * w + h * h), + ); + this.invInertia.set( + this.inertia.x > 1e-8 ? 1 / this.inertia.x : 0, + this.inertia.y > 1e-8 ? 1 / this.inertia.y : 0, + this.inertia.z > 1e-8 ? 1 / this.inertia.z : 0, + ); + } + + /** 读取线速度。 */ + linvel(): THREE.Vector3 { + return this.linearVelocity; + } + + /** 读取角速度。 */ + angvel(): THREE.Vector3 { + return this.angularVelocity; + } + + /** 设置线速度。 */ + setLinvel(v: { x: number; y: number; z: number }): void { + this.linearVelocity.set(v.x, v.y, v.z); + } + + /** 设置角速度。 */ + setAngvel(v: { x: number; y: number; z: number }): void { + this.angularVelocity.set(v.x, v.y, v.z); + } + + /** 读取世界位置。 */ + translation(): THREE.Vector3 { + return this.position; + } + + /** 读取世界朝向。 */ + rotation(): THREE.Quaternion { + return this.quaternion; + } + + /** 设置世界位置。 */ + setTranslation(v: { x: number; y: number; z: number }): void { + this.position.set(v.x, v.y, v.z); + } + + /** 设置世界朝向。 */ + setRotation(q: { x: number; y: number; z: number; w: number }): void { + this.quaternion.set(q.x, q.y, q.z, q.w).normalize(); + } + + /** 读取质量。 */ + mass(): number { + return this._mass; + } + + /** 设置质量并按当前碰撞盒重算惯量。 */ + setMass(mass: number): void { + this._mass = Math.max(1e-6, mass); + this.invMass = 1 / this._mass; + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 设置碰撞盒半边长并按当前质量重算惯量。 */ + setHalfExtents(half: THREE.Vector3): void { + this.halfExtents.copy(half); + this.setCuboidInertia(this._mass, this.halfExtents); + } + + /** 在质心施加冲量。 */ + applyImpulse(impulse: THREE.Vector3): void { + this.linearVelocity.addScaledVector(impulse, this.invMass); + } + + /** 在世界点施加冲量,同时产生角速度。 */ + applyImpulseAtPoint(impulse: THREE.Vector3, contactPoint: THREE.Vector3): void { + this.linearVelocity.addScaledVector(impulse, this.invMass); + this._r.subVectors(contactPoint, this.position); + this.angularVelocity.add(this.angularDeltaFromImpulse(impulse, this._r)); + } + + // 将冲量转为世界角速度增量:ω += R I⁻¹ Rᵀ (r × J) + private angularDeltaFromImpulse(impulse: THREE.Vector3, r: THREE.Vector3): THREE.Vector3 { + this._torque.copy(r).cross(impulse); + this._invQuat.copy(this.quaternion).invert(); + this._local.copy(this._torque).applyQuaternion(this._invQuat); + this._local.x *= this.invInertia.x; + this._local.y *= this.invInertia.y; + this._local.z *= this.invInertia.z; + return this._world.copy(this._local).applyQuaternion(this.quaternion); + } + + /** 读取世界点处的刚体速度。 */ + getVelocityAtPoint(point: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + this._r.subVectors(point, this.position); + return out.copy(this.linearVelocity).add(this._world.copy(this.angularVelocity).cross(this._r)); // v + ω × r + } + + /** 施加重力。 */ + applyGravity(dt: number): void { + this.linearVelocity.y -= GRAVITY * this.gravityScale * dt; + } + + /** 施加线/角阻尼。 */ + applyDamping(dt: number): void { + // 指数阻尼,与步长无关的衰减 + const lin = Math.exp(-this.linearDamping * dt); + const ang = Math.exp(-this.angularDamping * dt); + this.linearVelocity.multiplyScalar(lin); + this.angularVelocity.multiplyScalar(ang); + } + + /** 用当前速度积分位置和朝向。 */ + integrate(dt: number): void { + this.position.addScaledVector(this.linearVelocity, dt); + integrateQuaternion(this.quaternion, this.angularVelocity, dt); + } +} diff --git a/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts b/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts new file mode 100644 index 0000000..ead1181 --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/VehicleWheel.ts @@ -0,0 +1,48 @@ +import * as THREE from "three"; + +/** 单个车轮的悬挂、接地与轮胎力状态。 */ +export class VehicleWheel { + connectionPoint = new THREE.Vector3(); // 悬挂与底盘的局部连接点 + direction = new THREE.Vector3(0, -1, 0); // 悬挂方向(底盘局部) + axle = new THREE.Vector3(0, 0, -1); // 轮轴方向(底盘局部) + + radius = 0.3; // 轮胎半径 + restLength = 0.1; // 悬挂静止长度 + suspensionLength = 0.1; // 当前悬挂长度(夹在行程内) + visualLength = 0.1; // 视觉悬挂长度,跟射线 + maxSuspensionTravel = 0.1; // 最大行程 + + stiffness = 18; // 悬挂刚度(质量归一化) + dampingCompression = 2.1; // 压缩阻尼 + dampingRelaxation = 2.5; // 回弹阻尼 + maxSuspensionForce = 6000; // 最大悬挂力 + + steering = 0; // 转向角 + engineForce = 0; // 驱动力 + brake = 0; // 制动力 + + frictionSlip = 8; // 纵向抓地 + sideFrictionStiffness = 1; // 侧向摩擦 + rollInfluence = 0.12; // 侧向力对侧倾的影响 + + isInContact = false; // 是否接地 + contactPoint = new THREE.Vector3(); // 接地点 + contactNormal = new THREE.Vector3(0, 1, 0); // 接触法线 + contactMesh: THREE.Mesh | null = null; // 本帧接地网格 + + suspensionForce = 0; // 本帧悬挂力 + forwardImpulse = 0; // 纵向冲量 + sideImpulse = 0; // 侧向冲量 + rotation = 0; // 车轮自转角 + deltaRotation = 0; // 本帧自转增量 + + hardPointWS = new THREE.Vector3(); // 连接点世界坐标 + directionWS = new THREE.Vector3(0, -1, 0); // 悬挂方向(世界) + axleWS = new THREE.Vector3(0, 0, -1); // 轮轴(世界) + forwardWS = new THREE.Vector3(1, 0, 0); // 滚动前向(世界) + sideWS = new THREE.Vector3(0, 0, 1); // 侧向(世界) + + clippedInvContactDotSuspension = 1; // 法线与悬挂方向夹角修正 + suspensionRelativeVelocity = 0; // 沿悬挂方向的相对速度 + skidInfo = 1; // 打滑系数,1 为未打滑 +} diff --git a/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts b/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts new file mode 100644 index 0000000..27d195c --- /dev/null +++ b/three-player-controller/src/utils/vehiclePhysics/vehicleMath.ts @@ -0,0 +1,115 @@ +import * as THREE from "three"; + +const EPS = 1e-8; // 有效质量分母下限 + +/** 冲量公式所需的刚体字段。 */ +type RigidBodyMassProps = { + position: THREE.Vector3; + quaternion: THREE.Quaternion; + invMass: number; + invInertia: THREE.Vector3; +}; + +/** 冲量分母:invMass + n · (I⁻¹ (r × n) × r) */ +export function impulseDenominator( + body: RigidBodyMassProps, + point: THREE.Vector3, + normal: THREE.Vector3, + r: THREE.Vector3, + gcross: THREE.Vector3, + local: THREE.Vector3, + world: THREE.Vector3, + invQuat: THREE.Quaternion, +): number { + r.subVectors(point, body.position); + gcross.copy(r).cross(normal); + // 力矩变到局部惯量,再变回世界:I⁻¹ (r × n) + invQuat.copy(body.quaternion).invert(); + local.copy(gcross).applyQuaternion(invQuat); + local.x *= body.invInertia.x; + local.y *= body.invInertia.y; + local.z *= body.invInertia.z; + world.copy(local).applyQuaternion(body.quaternion); + world.cross(r); + return body.invMass + normal.dot(world); +} + +/** 沿方向约束的有效质量(冲量分母的倒数)。 */ +export function effectiveMass( + body: RigidBodyMassProps, + point: THREE.Vector3, + normal: THREE.Vector3, + r: THREE.Vector3, + gcross: THREE.Vector3, + local: THREE.Vector3, + world: THREE.Vector3, + invQuat: THREE.Quaternion, +): number { + const denom = impulseDenominator(body, point, normal, r, gcross, local, world, invQuat); + return denom > EPS ? 1 / denom : 0; +} + +/** 用世界角速度积分四元数:q += 0.5 * ω * q * dt */ +export function integrateQuaternion(q: THREE.Quaternion, omega: THREE.Vector3, dt: number): void { + const halfDt = 0.5 * dt; + const qx = q.x, qy = q.y, qz = q.z, qw = q.w; + const wx = omega.x, wy = omega.y, wz = omega.z; + q.x += halfDt * (wx * qw + wy * qz - wz * qy); + q.y += halfDt * (wy * qw + wz * qx - wx * qz); + q.z += halfDt * (wz * qw + wx * qy - wy * qx); + q.w += halfDt * (-wx * qx - wy * qy - wz * qz); + q.normalize(); +} + +/** 三角形上距点 p 最近的点。 */ +export function closestPointOnTriangle( + p: THREE.Vector3, + a: THREE.Vector3, + b: THREE.Vector3, + c: THREE.Vector3, + out: THREE.Vector3, + ab: THREE.Vector3, + ac: THREE.Vector3, + ap: THREE.Vector3, + bp: THREE.Vector3, + cp: THREE.Vector3, +): THREE.Vector3 { + ab.subVectors(b, a); + ac.subVectors(c, a); + ap.subVectors(p, a); + + const d1 = ab.dot(ap); + const d2 = ac.dot(ap); + if (d1 <= 0 && d2 <= 0) return out.copy(a); // 顶点 A + + bp.subVectors(p, b); + const d3 = ab.dot(bp); + const d4 = ac.dot(bp); + if (d3 >= 0 && d4 <= d3) return out.copy(b); // 顶点 B + + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + return out.copy(a).addScaledVector(ab, v); // 边 AB + } + + cp.subVectors(p, c); + const d5 = ab.dot(cp); + const d6 = ac.dot(cp); + if (d6 >= 0 && d5 <= d6) return out.copy(c); // 顶点 C + + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return out.copy(a).addScaledVector(ac, w); // 边 AC + } + + const va = d3 * d6 - d5 * d4; + if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) { + const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return out.copy(b).addScaledVector(ab.subVectors(c, b), w); // 边 BC + } + + const denom = 1 / (va + vb + vc); + return out.copy(a).addScaledVector(ab, vb * denom).addScaledVector(ac, vc * denom); // 面内 +} diff --git a/three-player-controller/tsconfig.json b/three-player-controller/tsconfig.json new file mode 100644 index 0000000..ec34ad1 --- /dev/null +++ b/three-player-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2020", + "declaration": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/three-player-controller/tsup.config.ts b/three-player-controller/tsup.config.ts new file mode 100644 index 0000000..5095176 --- /dev/null +++ b/three-player-controller/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + "foot-ik": "src/foot-ik.ts", + }, + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, + external: [ + "three", + "three-mesh-bvh", + "three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js", + ], + target: "es2020", + minify: false, +}); diff --git a/three-player-controller/vite.config.ts b/three-player-controller/vite.config.ts new file mode 100644 index 0000000..bac8922 --- /dev/null +++ b/three-player-controller/vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from "path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "/three-player-controller/", + root: resolve(__dirname, "example"), + server: { host: true }, + optimizeDeps: { + exclude: ["tellux", "three-mesh-bvh"], + }, + build: { + outDir: resolve(__dirname, "docs"), + emptyOutDir: true, + rollupOptions: { + input: { + main: resolve(__dirname, "example", "index.html"), + gltf: resolve(__dirname, "example", "glTF.html"), + tiles: resolve(__dirname, "example", "3dtilesScene.html"), + dgs: resolve(__dirname, "example", "3dgs.html"), + shooting: resolve(__dirname, "example", "shooting", "shooting.html"), + footik: resolve(__dirname, "example", "footIK.html"), + showcase: resolve(__dirname, "example", "showcase.html"), + }, + }, + }, +});