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,85 @@
|
||||
import * as THREE from "three";
|
||||
import { ContactManifold } from "./ContactManifold";
|
||||
import { ContactPoint } from "./ContactPoint";
|
||||
import { normalClass } from "./contactReducer";
|
||||
|
||||
const MATCH_NORMAL = 0.95; // 法线点积低于此值视为不同流形
|
||||
const MATCH_DISTANCE = 0.08; // 局部接触点匹配距离上限
|
||||
|
||||
/** findMatch 的命中结果。 */
|
||||
type MatchResult = {
|
||||
point: ContactPoint; // 上一帧匹配到的接触点
|
||||
normal: THREE.Vector3; // 上一帧流形法线
|
||||
};
|
||||
|
||||
/**
|
||||
* 帧间接触点匹配,用于冲量热启动(warm start)。
|
||||
* 求解前 match 继承冲量;求解后 save 供下一帧使用。
|
||||
*/
|
||||
export class ContactCache {
|
||||
private prev: ContactManifold[] = []; // 上一帧流形快照
|
||||
|
||||
/**
|
||||
* 将上一帧冲量拷到本帧对应接触点。
|
||||
* 无匹配、或支撑面 ↔ 墙面切换时清零,避免错误热启动。
|
||||
*/
|
||||
match(manifolds: ContactManifold[]): void {
|
||||
for (const manifold of manifolds) {
|
||||
for (const point of manifold.contacts) {
|
||||
const prev = this.findMatch(manifold.normal, point);
|
||||
if (!prev) {
|
||||
point.resetImpulses();
|
||||
continue;
|
||||
}
|
||||
// 法线类别突变(地面 ↔ 墙)时冲量不可复用
|
||||
const prevClass = normalClass(prev.normal);
|
||||
const nextClass = normalClass(manifold.normal);
|
||||
if (
|
||||
(prevClass === "support" && nextClass === "wall") ||
|
||||
(prevClass === "wall" && nextClass === "support")
|
||||
) {
|
||||
point.resetImpulses();
|
||||
continue;
|
||||
}
|
||||
point.normalImpulse = prev.point.normalImpulse;
|
||||
point.tangentImpulse1 = prev.point.tangentImpulse1;
|
||||
point.tangentImpulse2 = prev.point.tangentImpulse2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 深拷贝本帧流形与接触冲量,供下一帧 match。 */
|
||||
save(manifolds: ContactManifold[]): void {
|
||||
this.prev = manifolds.map((src) => {
|
||||
const copy = new ContactManifold();
|
||||
copy.normal.copy(src.normal);
|
||||
copy.normalSum.copy(src.normalSum);
|
||||
copy.contacts = src.contacts.map((p) => {
|
||||
const c = new ContactPoint();
|
||||
c.copyFrom(p);
|
||||
return c;
|
||||
});
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在上一帧中找法线接近且局部点最近的接触。
|
||||
* 用刚体局部坐标匹配,避免物体移动后面世界点对不上。
|
||||
*/
|
||||
private findMatch(normal: THREE.Vector3, point: ContactPoint): MatchResult | null {
|
||||
let best: MatchResult | null = null;
|
||||
let bestDist = MATCH_DISTANCE * MATCH_DISTANCE;
|
||||
for (const manifold of this.prev) {
|
||||
if (manifold.normal.dot(normal) < MATCH_NORMAL) continue;
|
||||
for (const prev of manifold.contacts) {
|
||||
const d = prev.localPoint.distanceToSquared(point.localPoint);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = { point: prev, normal: manifold.normal };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as THREE from "three";
|
||||
import { ContactPoint } from "./ContactPoint";
|
||||
|
||||
/** 同一法线簇下的接触点集合。 */
|
||||
export class ContactManifold {
|
||||
normal = new THREE.Vector3();
|
||||
normalSum = new THREE.Vector3();
|
||||
contacts: ContactPoint[] = [];
|
||||
|
||||
clear(): void {
|
||||
this.contacts.length = 0;
|
||||
this.normal.set(0, 1, 0);
|
||||
this.normalSum.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
/** 标定用特征半尺寸;skin/slop 按 extent 相对此值缩放。 */
|
||||
export const CONTACT_REF_EXTENT = 0.2;
|
||||
|
||||
export const CONTACT_SKIN = 0.002;
|
||||
|
||||
/** 按刚体特征尺寸缩放接触皮肤,避免小物体相对厚度过大。 */
|
||||
export function contactSkinForExtent(extent: number): number {
|
||||
const s = Math.max(1e-4, extent) / CONTACT_REF_EXTENT;
|
||||
return CONTACT_SKIN * s;
|
||||
}
|
||||
|
||||
export type RawContact = {
|
||||
point: THREE.Vector3;
|
||||
normal: THREE.Vector3;
|
||||
penetration: number;
|
||||
};
|
||||
|
||||
/** 约束求解用的单个接触点。 */
|
||||
export class ContactPoint {
|
||||
worldPoint = new THREE.Vector3();
|
||||
localPoint = new THREE.Vector3();
|
||||
penetration = 0;
|
||||
biasVelocity = 0;
|
||||
normalImpulse = 0;
|
||||
tangentImpulse1 = 0;
|
||||
tangentImpulse2 = 0;
|
||||
tangent1 = new THREE.Vector3();
|
||||
tangent2 = new THREE.Vector3();
|
||||
|
||||
copyFrom(other: ContactPoint): void {
|
||||
this.worldPoint.copy(other.worldPoint);
|
||||
this.localPoint.copy(other.localPoint);
|
||||
this.penetration = other.penetration;
|
||||
this.normalImpulse = other.normalImpulse;
|
||||
this.tangentImpulse1 = other.tangentImpulse1;
|
||||
this.tangentImpulse2 = other.tangentImpulse2;
|
||||
this.tangent1.copy(other.tangent1);
|
||||
this.tangent2.copy(other.tangent2);
|
||||
}
|
||||
|
||||
resetImpulses(): void {
|
||||
this.normalImpulse = 0;
|
||||
this.tangentImpulse1 = 0;
|
||||
this.tangentImpulse2 = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as THREE from "three";
|
||||
import type { ImpulseBody } from "../solver/ImpulseBody";
|
||||
import { ContactManifold } from "./ContactManifold";
|
||||
import { ContactPoint, CONTACT_REF_EXTENT, contactSkinForExtent, type RawContact } from "./ContactPoint";
|
||||
|
||||
const NORMAL_MERGE = 0.98; // 法线点积高于此值并入同一流形
|
||||
const MAX_POINTS = 4; // 单流形最多保留的接触点
|
||||
const SUPPORT_Y = 0.7; // ny 以上视为支撑面(地面)
|
||||
const WALL_Y = 0.35; // ny 以下视为墙面
|
||||
|
||||
const _invQuat = new THREE.Quaternion();
|
||||
const _ab = new THREE.Vector3();
|
||||
const _ac = new THREE.Vector3();
|
||||
const _ap = new THREE.Vector3();
|
||||
const _n = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* 将原始接触按法线聚类,并裁成至多 4 个支撑点。
|
||||
* 返回写入后的 out(长度收束为有效流形数)。
|
||||
*/
|
||||
export function reduceContacts(
|
||||
raw: RawContact[],
|
||||
count: number,
|
||||
body: ImpulseBody,
|
||||
out: ContactManifold[],
|
||||
): ContactManifold[] {
|
||||
for (const m of out) m.clear();
|
||||
let manifoldCount = 0;
|
||||
|
||||
// 按法线合并进流形,并累计 normalSum
|
||||
const skin = contactSkinForExtent(body.characteristicExtent?.() ?? CONTACT_REF_EXTENT);
|
||||
for (let r = 0; r < count; r++) {
|
||||
const contact = raw[r];
|
||||
if (contact.penetration < -skin || contact.normal.lengthSq() < 1e-8) continue;
|
||||
_n.copy(contact.normal).normalize();
|
||||
let cluster = -1;
|
||||
for (let i = 0; i < manifoldCount; i++) {
|
||||
if (out[i].normal.dot(_n) > NORMAL_MERGE) {
|
||||
cluster = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cluster < 0) {
|
||||
if (manifoldCount === out.length) {
|
||||
out.push(new ContactManifold());
|
||||
}
|
||||
cluster = manifoldCount;
|
||||
manifoldCount += 1;
|
||||
out[cluster].normalSum.copy(_n);
|
||||
out[cluster].normal.copy(_n);
|
||||
} else {
|
||||
out[cluster].normalSum.add(_n);
|
||||
out[cluster].normal.copy(out[cluster].normalSum).normalize();
|
||||
}
|
||||
out[cluster].contacts.push(makePoint(contact, body));
|
||||
}
|
||||
|
||||
// 定稿法线、裁点、建切向基
|
||||
for (let i = 0; i < manifoldCount; i++) {
|
||||
const manifold = out[i];
|
||||
if (manifold.normalSum.lengthSq() < 1e-8) manifold.normal.set(0, 1, 0);
|
||||
else manifold.normal.copy(manifold.normalSum).normalize();
|
||||
if (manifold.contacts.length > MAX_POINTS) {
|
||||
manifold.contacts = pickSupportPoints(manifold.contacts);
|
||||
}
|
||||
for (const point of manifold.contacts) {
|
||||
buildTangents(manifold.normal, point);
|
||||
}
|
||||
}
|
||||
|
||||
out.length = manifoldCount;
|
||||
stabilizeEdgeManifolds(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按法线竖直分量分类:支撑面 / 墙 / 斜面。 */
|
||||
export function normalClass(normal: THREE.Vector3): "support" | "wall" | "slant" {
|
||||
if (normal.y >= SUPPORT_Y) return "support";
|
||||
if (normal.y <= WALL_Y) return "wall";
|
||||
return "slant";
|
||||
}
|
||||
|
||||
/**
|
||||
* 边角同时碰到地面与墙时丢掉斜面流形,避免错误推力。
|
||||
* 若没有支撑面,则把斜面法线抬到至少 SUPPORT_Y,减轻卡边抖动。
|
||||
*/
|
||||
function stabilizeEdgeManifolds(manifolds: ContactManifold[]): void {
|
||||
let hasSupport = false;
|
||||
let hasWall = false;
|
||||
for (const m of manifolds) {
|
||||
const cls = normalClass(m.normal);
|
||||
if (cls === "support") hasSupport = true;
|
||||
else if (cls === "wall") hasWall = true;
|
||||
}
|
||||
|
||||
if (hasSupport && hasWall) {
|
||||
let write = 0;
|
||||
for (let i = 0; i < manifolds.length; i++) {
|
||||
if (normalClass(manifolds[i].normal) === "slant") continue;
|
||||
if (write !== i) manifolds[write] = manifolds[i];
|
||||
write += 1;
|
||||
}
|
||||
manifolds.length = write;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasSupport) {
|
||||
for (const m of manifolds) {
|
||||
if (normalClass(m.normal) !== "slant") continue;
|
||||
m.normal.y = Math.max(m.normal.y, SUPPORT_Y);
|
||||
m.normal.normalize();
|
||||
m.normalSum.copy(m.normal);
|
||||
for (const point of m.contacts) {
|
||||
buildTangents(m.normal, point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 由原始接触生成 ContactPoint,并写入刚体局部坐标(供帧间匹配)。 */
|
||||
function makePoint(raw: RawContact, body: ImpulseBody): ContactPoint {
|
||||
const point = new ContactPoint();
|
||||
point.worldPoint.copy(raw.point);
|
||||
point.penetration = raw.penetration;
|
||||
_invQuat.copy(body.quaternion).invert();
|
||||
point.localPoint.subVectors(raw.point, body.position).applyQuaternion(_invQuat);
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从过多接触点中挑最多 4 个:最深穿透 → 最远点 → 最大面积第三点 → 离平面最远第四点。
|
||||
* 用于稳定盒/车身 resting 接触。
|
||||
*/
|
||||
function pickSupportPoints(points: ContactPoint[]): ContactPoint[] {
|
||||
if (points.length <= MAX_POINTS) return points;
|
||||
|
||||
// p0:穿透最深
|
||||
let i0 = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (points[i].penetration > points[i0].penetration) i0 = i;
|
||||
}
|
||||
const p0 = points[i0];
|
||||
|
||||
// p1:离 p0 最远
|
||||
let i1 = i0;
|
||||
let best = -1;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = points[i].worldPoint.distanceToSquared(p0.worldPoint);
|
||||
if (d > best) {
|
||||
best = d;
|
||||
i1 = i;
|
||||
}
|
||||
}
|
||||
const p1 = points[i1];
|
||||
|
||||
// p2:与 p0p1 构成面积最大
|
||||
let i2 = i0;
|
||||
best = -1;
|
||||
_ab.subVectors(p1.worldPoint, p0.worldPoint);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
_ap.subVectors(points[i].worldPoint, p0.worldPoint);
|
||||
const area = _ap.cross(_ab).lengthSq();
|
||||
if (area > best) {
|
||||
best = area;
|
||||
i2 = i;
|
||||
}
|
||||
}
|
||||
const p2 = points[i2];
|
||||
|
||||
// p3:离 p0p1p2 平面最远(略加权穿透)
|
||||
let i3 = i0;
|
||||
best = -1;
|
||||
_ab.subVectors(p1.worldPoint, p0.worldPoint);
|
||||
_ac.subVectors(p2.worldPoint, p0.worldPoint);
|
||||
_n.copy(_ab).cross(_ac);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i === i0 || i === i1 || i === i2) continue;
|
||||
_ap.subVectors(points[i].worldPoint, p0.worldPoint);
|
||||
const d = Math.abs(_ap.dot(_n));
|
||||
const score = d + points[i].penetration * 0.01;
|
||||
if (score > best) {
|
||||
best = score;
|
||||
i3 = i;
|
||||
}
|
||||
}
|
||||
|
||||
const picked = [p0];
|
||||
if (i1 !== i0) picked.push(p1);
|
||||
if (i2 !== i0 && i2 !== i1) picked.push(p2);
|
||||
if (i3 !== i0 && i3 !== i1 && i3 !== i2 && picked.length < MAX_POINTS) picked.push(points[i3]);
|
||||
return picked;
|
||||
}
|
||||
|
||||
/** 由流形法线构造正交切向基(摩擦两轴)。 */
|
||||
function buildTangents(normal: THREE.Vector3, point: ContactPoint): void {
|
||||
if (Math.abs(normal.y) < 0.99) point.tangent1.set(0, 1, 0).cross(normal);
|
||||
else point.tangent1.set(1, 0, 0).cross(normal); // 法线近竖直时换参考轴
|
||||
if (point.tangent1.lengthSq() < 1e-8) point.tangent1.set(0, 0, 1).cross(normal);
|
||||
point.tangent1.normalize();
|
||||
point.tangent2.copy(normal).cross(point.tangent1).normalize();
|
||||
}
|
||||
Reference in new issue
Block a user