chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json. Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser Stand als normaler Ordner im Repo, ohne Verweis auf GitHub. dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher lokal gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
780e246fe5
commit
fb38652e7f
153 files changed
+42561
-4
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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<number, CollisionCollider>(); // 已登记碰撞体
|
||||
|
||||
/** 登记一条碰撞体,返回带 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, AttrMeta>();
|
||||
const attrConflict = new Set<string>();
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
FootIK,
|
||||
type BoneRef,
|
||||
type FootIKLegBoneConfig,
|
||||
type FootIKOptions,
|
||||
type FootIKSide,
|
||||
type FootIKSkeletonConfig,
|
||||
} from "./plugins/foot-ik";
|
||||
@@ -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";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
export { FootIK } from "./FootIK";
|
||||
export type {
|
||||
BoneRef,
|
||||
FootIKLegBoneConfig,
|
||||
FootIKOptions,
|
||||
FootIKSide,
|
||||
FootIKSkeletonConfig,
|
||||
} from "./types";
|
||||
@@ -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<BufferGeometry>();
|
||||
const materials = new Set<Material>();
|
||||
|
||||
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<BufferGeometry>,
|
||||
materials: Set<Material>,
|
||||
): 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);
|
||||
}
|
||||
@@ -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<boolean>(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<boolean>(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;
|
||||
}
|
||||
@@ -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<FootIKGroundHit | null>,
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<FootIKSide, FootIKLeg>;
|
||||
|
||||
// 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<FootIKSide, FootPhaseRuntimeState>;
|
||||
|
||||
// 当前动画及其左右脚相位状态。
|
||||
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<string, FootPhaseClipData>;
|
||||
|
||||
// 单只脚在某个采样帧的接触高度和模型局部脚骨位姿。
|
||||
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[];
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, THREE.AnimationAction>; // 动作映射表
|
||||
state!: THREE.AnimationAction; // 当前播放状态
|
||||
sets = new Map<string, Map<string, THREE.AnimationAction>>(); // 动作集合组
|
||||
currentLocomotionSet: string | null = null; // 当前激活的动作集合名
|
||||
clips: THREE.AnimationClip[] = []; // 原始动画片段
|
||||
hasThreePartJump = false; // 是否使用三段跳跃动画
|
||||
isOverrideAnimationPlaying = false; // 动画锁,用于防止覆盖型动画被移动动画打断
|
||||
private overrideInputSnapshot: Record<string, any> | 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<Record<"idle" | "walking" | "walking_backward" | "running" | "jumping" | "flyidle" | "flying", string>>) {
|
||||
if (!this.mixer) return;
|
||||
const set = new Map<string, THREE.AnimationAction>();
|
||||
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<string, any>;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<number, ColliderHandle>();
|
||||
/** 静态构建代数:异步 BVH 完成时若代数变了则丢弃 */
|
||||
private buildGens = new Map<number, number>();
|
||||
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<THREE.Mesh>();
|
||||
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<ColliderDesc["shape"], { kind: "mesh" }>;
|
||||
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<ColliderDesc["shape"], { kind: "mesh" }>;
|
||||
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<ColliderDesc["shape"], { kind: "box" }>;
|
||||
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<ColliderDesc["shape"], { kind: "sphere" }>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
import type { playerController } from "../PlayerController";
|
||||
import type { KeyAction, KeyMap } from "../types";
|
||||
|
||||
// 默认键位表(动作 -> KeyboardEvent.code 列表)
|
||||
const defaultKeyMap: Record<KeyAction, string[]> = {
|
||||
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<string, KeyAction>(); // 键码 -> 动作 反查表
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<VehicleInstance | undefined> {
|
||||
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<KinematicColliderEntry, number>();
|
||||
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<THREE.BufferGeometry>();
|
||||
const mats = new Set<THREE.Material>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Record<KeyAction, string | string[] | null>>;
|
||||
|
||||
/** 玩家控制器初始化配置。 */
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module "three-mesh-bvh/src/workers/GenerateMeshBVHWorker.js" {
|
||||
export class GenerateMeshBVHWorker {
|
||||
generate(geometry: import("three").BufferGeometry, options?: object): Promise<import("three-mesh-bvh").MeshBVH>;
|
||||
dispose(): void;
|
||||
running: boolean;
|
||||
}
|
||||
}
|
||||
@@ -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<MeshBVH>;
|
||||
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<GenerateMeshBVHWorkerLike | null> | 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<MeshBVH> {
|
||||
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<GenerateMeshBVHWorkerLike | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MobileIconName, string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<T extends object>(base: T, over?: Partial<T>): T {
|
||||
return over ? { ...base, ...over } : { ...base };
|
||||
}
|
||||
|
||||
// ==================== 主函数 ====================
|
||||
|
||||
// 加载车辆模型
|
||||
export async function loadVehicleModel(
|
||||
opts: VehicleOptions,
|
||||
ctx: VehicleLoaderContext,
|
||||
): Promise<VehicleInstance> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 为未打滑
|
||||
}
|
||||
@@ -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); // 面内
|
||||
}
|
||||
Reference in new issue
Block a user