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:
knsoandClaude Opus 5 committed 2026-09-15 21:27:03 +02:00
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();
}