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
@@ -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