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

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
export { FootIK } from "./FootIK";
export type {
BoneRef,
FootIKLegBoneConfig,
FootIKOptions,
FootIKSide,
FootIKSkeletonConfig,
} from "./types";
@@ -0,0 +1,675 @@
import {
BufferAttribute,
BufferGeometry,
DoubleSide,
Line,
LineBasicMaterial,
LineLoop,
LineSegments,
Mesh,
MeshBasicMaterial,
SphereGeometry,
Vector3,
type Material,
type Scene,
} from "three";
import type {
FootIKLeg,
FootIKLegs,
FootIKSide,
FootPhaseControllerState,
FootPhaseDatabase,
} from "../types";
const FOOT_IK_SIDES: readonly FootIKSide[] = ["left", "right"];
/** 设计尺度下的 IK 目标球半径;与脚底四点同级,世界半径 = 该值 × appliedScale。 */
const IK_MARKER_RADIUS = 3;
/** 设计尺度下的最高地面命中球半径。 */
const HIT_MARKER_RADIUS = 2.5;
/** 设计尺度下的脚底采样原点球半径。 */
const SAMPLE_ORIGIN_RADIUS = 3;
/** 设计尺度下的脚底采样命中球半径。 */
const SAMPLE_HIT_RADIUS = 2.5;
/** 支撑脚 IK 的目标球颜色。 */
const DEBUG_IK_PLANTED_COLOR = 0x22c55e;
/** 摆动脚防穿透 IK 的目标球颜色。 */
const DEBUG_IK_PENETRATION_COLOR = 0x3b82f6;
/** 未应用 IK 时的目标球颜色。 */
const DEBUG_IK_NOT_APPLIED_COLOR = 0xef4444;
/** 脚底采样球 / 连线(左右腿共用)。 */
const DEBUG_SOLID_COLOR = 0x2dd4bf;
/** 线框命中球(左右腿共用)。 */
const DEBUG_HIT_COLOR = 0xffff66;
/** 命中超出脚部 IK 最大上抬范围时的线框球颜色。 */
const DEBUG_HIT_OVER_RAISE_LIMIT_COLOR = 0xff3333;
/** 脚部 IK 最大上抬范围线框颜色。 */
const DEBUG_RAISE_LIMIT_COLOR = 0x60a5fa;
/** 命中超出最大上抬范围时的线框颜色。 */
const DEBUG_RAISE_LIMIT_EXCEEDED_COLOR = 0xff3333;
/** 预测目标仍在持续跟踪时的颜色。 */
const DEBUG_PREDICTIVE_TRACKING_COLOR = 0x38bdf8;
/** 预测结果正在参与脚部融合时的颜色。 */
const DEBUG_PREDICTIVE_ACTIVE_COLOR = 0xf59e0b;
/** 可用但未被选中的预测候选颜色。 */
const DEBUG_PREDICTIVE_VALID_COLOR = 0xfde047;
/** 无法提供完整鞋底支撑的预测候选颜色。 */
const DEBUG_PREDICTIVE_INVALID_COLOR = 0xef4444;
/** 设计尺度下的预测候选球半径。 */
const PREDICTIVE_CANDIDATE_RADIUS = 2;
/** 每条腿的上抬范围由四条竖线和顶部四边组成。 */
const RAISE_LIMIT_POINT_COUNT = 16;
const SOLE_PERIMETER = [0, 1, 3, 2] as const;
const PREDICTIVE_TRAJECTORY_POINT_COUNT = 13;
/** 创建可每帧原地改顶点的折线几何。 */
function createDebugLineGeometry(pointCount: number): BufferGeometry {
const geometry = new BufferGeometry();
geometry.setAttribute(
"position",
new BufferAttribute(new Float32Array(pointCount * 3), 3),
);
return geometry;
}
function writeDebugLinePositions(
geometry: BufferGeometry,
points: Array<{ x: number; y: number; z: number }>,
count = points.length,
): void {
const attribute = geometry.getAttribute("position") as BufferAttribute | undefined;
if (!attribute) return;
const array = attribute.array as Float32Array;
const pointCount = Math.min(count, (array.length / 3) | 0);
for (let i = 0; i < pointCount; i++) {
const point = points[i];
const offset = i * 3;
array[offset] = point.x;
array[offset + 1] = point.y;
array[offset + 2] = point.z;
}
attribute.needsUpdate = true;
}
function writeDebugLinePair(
geometry: BufferGeometry,
from: { x: number; y: number; z: number },
to: { x: number; y: number; z: number },
): void {
const attribute = geometry.getAttribute("position") as BufferAttribute | undefined;
if (!attribute || attribute.array.length < 6) return;
const array = attribute.array as Float32Array;
array[0] = from.x;
array[1] = from.y;
array[2] = from.z;
array[3] = to.x;
array[4] = to.y;
array[5] = to.z;
attribute.needsUpdate = true;
}
/** 返回当前预测落脚状态对应的调试颜色。 */
function getPredictiveModeColor(leg: FootIKLeg): number {
if (
leg.predictive.mode === "active"
|| leg.predictive.mode === "planted"
) {
return DEBUG_PREDICTIVE_ACTIVE_COLOR;
}
return DEBUG_PREDICTIVE_TRACKING_COLOR;
}
function setHitMarkerColor(mesh: Mesh, overRaiseLimit: boolean): void {
const material = mesh.material;
if (Array.isArray(material)) return;
(material as MeshBasicMaterial).color.setHex(
overRaiseLimit ? DEBUG_HIT_OVER_RAISE_LIMIT_COLOR : DEBUG_HIT_COLOR,
);
}
function setIKMarkerColor(mesh: Mesh, leg: FootIKLeg, applied: boolean): void {
const material = mesh.material;
if (Array.isArray(material)) return;
let color = DEBUG_IK_NOT_APPLIED_COLOR;
if (leg.predictive.mode !== "none" && Number.isFinite(leg.predictive.score)) {
color = getPredictiveModeColor(leg);
} else if (applied) {
if (leg.planted) color = DEBUG_IK_PLANTED_COLOR;
else if (leg.movePenetrating) color = DEBUG_IK_PENETRATION_COLOR;
else color = DEBUG_IK_PLANTED_COLOR;
}
(material as MeshBasicMaterial).color.setHex(color);
}
// 创建统一 Foot IK 调试对象:IK 目标、最高命中、脚底四点(原点 + 命中 + 射线)。
export function createDebugObjects(
scene: Scene | null,
legs: FootIKLegs,
enabled: boolean,
): void {
if (!enabled || !scene) return;
let ikGeometry: SphereGeometry | null = null;
let hitGeometry: SphereGeometry | null = null;
let sampleOriginGeometry: SphereGeometry | null = null;
let sampleHitGeometry: SphereGeometry | null = null;
let predictiveCandidateGeometry: SphereGeometry | null = null;
for (const side of FOOT_IK_SIDES) {
const leg = legs[side];
if (!leg.marker) {
ikGeometry ??= new SphereGeometry(IK_MARKER_RADIUS, 10, 8);
leg.marker = new Mesh(
ikGeometry,
new MeshBasicMaterial({
color: DEBUG_IK_NOT_APPLIED_COLOR,
depthTest: false,
transparent: true,
opacity: 0.95,
}),
);
leg.marker.renderOrder = 30;
leg.marker.frustumCulled = false;
scene.add(leg.marker);
}
if (!leg.hitMarker) {
hitGeometry ??= new SphereGeometry(HIT_MARKER_RADIUS, 10, 8);
leg.hitMarker = new Mesh(
hitGeometry,
new MeshBasicMaterial({
color: DEBUG_HIT_COLOR,
wireframe: true,
depthTest: false,
transparent: true,
opacity: 0.9,
}),
);
leg.hitMarker.renderOrder = 29;
leg.hitMarker.frustumCulled = false;
scene.add(leg.hitMarker);
}
if (!leg.rayLine) {
leg.rayLine = new Line(
createDebugLineGeometry(2),
new LineBasicMaterial({
color: DEBUG_SOLID_COLOR,
depthTest: false,
transparent: true,
opacity: 0.85,
}),
);
leg.rayLine.renderOrder = 28;
leg.rayLine.frustumCulled = false;
scene.add(leg.rayLine);
}
if (!leg.raiseLimitLine) {
leg.raiseLimitLine = new LineSegments(
createDebugLineGeometry(RAISE_LIMIT_POINT_COUNT),
new LineBasicMaterial({
color: DEBUG_RAISE_LIMIT_COLOR,
depthTest: false,
transparent: true,
opacity: 0.75,
}),
);
leg.raiseLimitLine.renderOrder = 24;
leg.raiseLimitLine.frustumCulled = false;
scene.add(leg.raiseLimitLine);
}
if (!leg.predictiveLine) {
leg.predictiveLine = new Line(
createDebugLineGeometry(PREDICTIVE_TRAJECTORY_POINT_COUNT),
new LineBasicMaterial({
color: DEBUG_PREDICTIVE_TRACKING_COLOR,
depthTest: false,
transparent: true,
opacity: 0.95,
}),
);
leg.predictiveLine.visible = false;
leg.predictiveLine.renderOrder = 32;
leg.predictiveLine.frustumCulled = false;
scene.add(leg.predictiveLine);
}
if (leg.predictiveCandidateMarkers.length === 0) {
predictiveCandidateGeometry ??= new SphereGeometry(PREDICTIVE_CANDIDATE_RADIUS, 10, 8);
for (const _candidate of leg.predictive.debugCandidates) {
const marker = new Mesh(
predictiveCandidateGeometry,
new MeshBasicMaterial({
color: DEBUG_PREDICTIVE_INVALID_COLOR,
wireframe: true,
depthTest: false,
transparent: true,
opacity: 0.9,
}),
);
marker.visible = false;
marker.renderOrder = 31;
marker.frustumCulled = false;
leg.predictiveCandidateMarkers.push(marker);
scene.add(marker);
}
}
if (!leg.predictiveSupportMesh) {
const supportGeometry = new BufferGeometry();
supportGeometry.setAttribute("position", new BufferAttribute(new Float32Array(12), 3));
supportGeometry.setIndex([0, 1, 3, 0, 3, 2]);
leg.predictiveSupportMesh = new Mesh(
supportGeometry,
new MeshBasicMaterial({
color: DEBUG_PREDICTIVE_TRACKING_COLOR,
depthTest: false,
transparent: true,
opacity: 0.28,
side: DoubleSide,
}),
);
leg.predictiveSupportMesh.visible = false;
leg.predictiveSupportMesh.renderOrder = 22;
leg.predictiveSupportMesh.frustumCulled = false;
scene.add(leg.predictiveSupportMesh);
}
if (!leg.predictiveSupportOutline) {
leg.predictiveSupportOutline = new LineLoop(
createDebugLineGeometry(4),
new LineBasicMaterial({
color: DEBUG_PREDICTIVE_TRACKING_COLOR,
depthTest: false,
transparent: true,
opacity: 0.95,
}),
);
leg.predictiveSupportOutline.visible = false;
leg.predictiveSupportOutline.renderOrder = 23;
leg.predictiveSupportOutline.frustumCulled = false;
scene.add(leg.predictiveSupportOutline);
}
for (const sample of leg.soleSamples) {
// footMarker:脚底采样原点(跟骨骼)
if (!sample.footMarker) {
sampleOriginGeometry ??= new SphereGeometry(SAMPLE_ORIGIN_RADIUS, 10, 8);
sample.footMarker = new Mesh(
sampleOriginGeometry,
new MeshBasicMaterial({
color: DEBUG_SOLID_COLOR,
depthTest: false,
transparent: true,
opacity: 0.85,
}),
);
sample.footMarker.renderOrder = 27;
sample.footMarker.frustumCulled = false;
scene.add(sample.footMarker);
}
// marker:该采样点的地面命中
if (!sample.marker) {
sampleHitGeometry ??= new SphereGeometry(SAMPLE_HIT_RADIUS, 10, 8);
sample.marker = new Mesh(
sampleHitGeometry,
new MeshBasicMaterial({
color: DEBUG_HIT_COLOR,
wireframe: true,
depthTest: false,
transparent: true,
opacity: 0.8,
}),
);
sample.marker.renderOrder = 26;
sample.marker.frustumCulled = false;
scene.add(sample.marker);
}
if (!sample.rayLine) {
sample.rayLine = new Line(
createDebugLineGeometry(2),
new LineBasicMaterial({
color: DEBUG_SOLID_COLOR,
transparent: true,
opacity: 0.45,
depthTest: false,
}),
);
sample.rayLine.renderOrder = 25;
sample.rayLine.frustumCulled = false;
scene.add(sample.rayLine);
}
}
}
}
// 更新统一调试显示。
export function updateFootDebug(
enabled: boolean,
leg: FootIKLeg,
hitPoint: Vector3,
scale = 1,
maxFootRaise = 0,
): void {
if (!enabled || !leg.marker || !leg.rayLine) return;
const s = Math.max(1e-4, scale);
const bestSample = leg.soleSamples[leg.bestGroundSampleIndex];
const bestHitOverRaiseLimit = !!bestSample?.hasHit
&& hitPoint.y - bestSample.point.y > maxFootRaise;
const ikApplied = leg.weight > 0.001;
// 腿级:IK 目标 + 最高命中 + 命中→目标
leg.marker.visible = true;
leg.marker.position.copy(leg.smoothedTarget);
leg.marker.scale.setScalar(s);
setIKMarkerColor(leg.marker, leg, ikApplied);
if (leg.hitMarker) {
leg.hitMarker.visible = true;
leg.hitMarker.position.copy(hitPoint);
leg.hitMarker.scale.setScalar(s);
setHitMarkerColor(leg.hitMarker, bestHitOverRaiseLimit);
}
// 只有实际应用 IK 时才显示“最高地面命中 → IK 目标”连线。
leg.rayLine.visible = ikApplied;
if (ikApplied) {
writeDebugLinePair(leg.rayLine.geometry, hitPoint, leg.smoothedTarget);
}
updatePredictiveDebug(leg, s);
// 预测摆腿只保留轨迹和落点面;脚底四点与抬高笼子仍表示反应式采样。
if (!showReactiveSoleDebug(leg)) {
setReactiveSoleDebugVisible(leg, false);
return;
}
updateRaiseLimitLine(leg, maxFootRaise);
// 脚底四点:原点 + 命中 + 射线
for (const sample of leg.soleSamples) {
if (sample.footMarker) {
sample.footMarker.visible = true;
sample.footMarker.position.copy(sample.point);
sample.footMarker.scale.setScalar(s);
}
if (sample.marker) {
sample.marker.visible = sample.hasHit;
if (sample.hasHit) {
sample.marker.position.copy(sample.hitPoint);
sample.marker.scale.setScalar(s);
setHitMarkerColor(
sample.marker,
sample.hitPoint.y - sample.point.y > maxFootRaise,
);
}
}
if (sample.rayLine) {
sample.rayLine.visible = sample.hasHit;
if (sample.hasHit) {
writeDebugLinePair(sample.rayLine.geometry, sample.point, sample.hitPoint);
}
}
}
}
/** 预测正在驱动摆腿时,反应式脚底采样调试不再跟当前脚骨。 */
function showReactiveSoleDebug(leg: FootIKLeg): boolean {
return leg.predictive.mode !== "active";
}
function setReactiveSoleDebugVisible(leg: FootIKLeg, visible: boolean): void {
if (leg.raiseLimitLine) leg.raiseLimitLine.visible = visible;
for (const sample of leg.soleSamples) {
if (sample.footMarker) sample.footMarker.visible = visible;
if (sample.marker) sample.marker.visible = visible && sample.hasHit;
if (sample.rayLine) sample.rayLine.visible = visible && sample.hasHit;
}
}
/** 更新预测摆动轨迹、动画中心候选点及其状态配色。 */
function updatePredictiveDebug(leg: FootIKLeg, scale: number): void {
const state = leg.predictive;
// tracking 只显示中心候选;active 才画摆动线路和落点四边形配色。
const showTrajectory = state.mode === "active";
const showCandidates = state.mode === "tracking";
if (leg.predictiveLine) {
leg.predictiveLine.visible = showTrajectory && state.debugTrajectoryVisible;
if (leg.predictiveLine.visible) {
writeDebugLinePositions(
leg.predictiveLine.geometry,
state.debugTrajectory,
);
const material = leg.predictiveLine.material;
if (!Array.isArray(material)) {
(material as LineBasicMaterial).color.setHex(getPredictiveModeColor(leg));
}
}
}
for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) {
const marker = leg.predictiveCandidateMarkers[i];
const candidate = state.debugCandidates[i];
marker.visible = showCandidates && !!candidate?.evaluated;
if (!marker.visible || !candidate) continue;
marker.position.copy(candidate.point);
marker.scale.setScalar(scale * (candidate.selected ? 1.25 : 0.8));
const material = marker.material;
if (Array.isArray(material)) continue;
const debugMaterial = material as MeshBasicMaterial;
debugMaterial.wireframe = !candidate.selected;
debugMaterial.color.setHex(
candidate.selected
? candidate.valid
? getPredictiveModeColor(leg)
: DEBUG_PREDICTIVE_INVALID_COLOR
: candidate.valid
? DEBUG_PREDICTIVE_VALID_COLOR
: DEBUG_PREDICTIVE_INVALID_COLOR,
);
}
const landing = state.debugCandidates[0];
const planeColor = landing?.valid
? getPredictiveModeColor(leg)
: DEBUG_PREDICTIVE_INVALID_COLOR;
updatePredictiveSupportPlane(leg, planeColor);
}
/** 用鞋底四点在合并支撑面上的投影绘制半透明四边形。 */
function updatePredictiveSupportPlane(leg: FootIKLeg, color: number): void {
const state = leg.predictive;
const visible = state.debugSupportVisible
&& (state.mode === "tracking" || state.mode === "active")
&& state.debugSupportCorners.length >= 4;
if (leg.predictiveSupportMesh) {
leg.predictiveSupportMesh.visible = visible;
if (visible) {
const positions = leg.predictiveSupportMesh.geometry.getAttribute("position");
for (let i = 0; i < 4; i++) {
const corner = state.debugSupportCorners[i];
positions.setXYZ(i, corner.x, corner.y, corner.z);
}
positions.needsUpdate = true;
const material = leg.predictiveSupportMesh.material;
if (!Array.isArray(material)) {
(material as MeshBasicMaterial).color.setHex(color);
}
}
}
if (leg.predictiveSupportOutline) {
leg.predictiveSupportOutline.visible = visible;
if (visible) {
const attribute = leg.predictiveSupportOutline.geometry.getAttribute("position") as BufferAttribute;
const array = attribute.array as Float32Array;
for (let i = 0; i < SOLE_PERIMETER.length; i++) {
const corner = state.debugSupportCorners[SOLE_PERIMETER[i]];
const offset = i * 3;
array[offset] = corner.x;
array[offset + 1] = corner.y;
array[offset + 2] = corner.z;
}
attribute.needsUpdate = true;
const material = leg.predictiveSupportOutline.material;
if (!Array.isArray(material)) {
(material as LineBasicMaterial).color.setHex(color);
}
}
}
}
// 统一切换所有 Foot IK 调试对象的显隐。
export function setDebugVisible(legs: FootIKLegs, visible: boolean): void {
for (const side of FOOT_IK_SIDES) {
const leg = legs[side];
if (leg.marker) leg.marker.visible = visible;
if (leg.hitMarker) leg.hitMarker.visible = visible;
if (leg.rayLine) leg.rayLine.visible = visible && leg.weight > 0.001;
if (leg.predictiveLine) {
leg.predictiveLine.visible = visible
&& leg.predictive.debugTrajectoryVisible
&& leg.predictive.mode === "active";
}
for (let i = 0; i < leg.predictiveCandidateMarkers.length; i++) {
const candidate = leg.predictive.debugCandidates[i];
leg.predictiveCandidateMarkers[i].visible = visible
&& !!candidate?.evaluated
&& leg.predictive.mode === "tracking";
}
if (leg.predictiveSupportMesh) {
leg.predictiveSupportMesh.visible = visible
&& leg.predictive.debugSupportVisible
&& (
leg.predictive.mode === "tracking"
|| leg.predictive.mode === "active"
);
}
if (leg.predictiveSupportOutline) {
leg.predictiveSupportOutline.visible = visible
&& leg.predictive.debugSupportVisible
&& (
leg.predictive.mode === "tracking"
|| leg.predictive.mode === "active"
);
}
setReactiveSoleDebugVisible(leg, visible && showReactiveSoleDebug(leg));
}
}
// 返回当前动画脚步相位的简短调试文本。
export function getFootPhaseDebugText(
state: FootPhaseControllerState,
clips: FootPhaseDatabase,
side: FootIKSide,
): string {
const sideState = state[side];
const clipName = state.clipName || "none";
const dataReady = clips.has(clipName);
if (!dataReady) return `${clipName}: no phase`;
const phase = sideState.planted ? "plant" : "swing";
const progress = Number.isFinite(sideState.progress)
? `${Math.round(sideState.progress * 100)}%`
: "--";
return `${clipName}: ${phase} ${progress}`;
}
// 移除调试对象并释放对应的 GPU 资源。
export function disposeDebugObjects(legs: FootIKLegs): void {
const geometries = new Set<BufferGeometry>();
const materials = new Set<Material>();
for (const side of FOOT_IK_SIDES) {
const leg = legs[side];
collectDebugObject(leg.marker, geometries, materials);
collectDebugObject(leg.hitMarker, geometries, materials);
collectDebugObject(leg.rayLine, geometries, materials);
collectDebugObject(leg.raiseLimitLine, geometries, materials);
collectDebugObject(leg.predictiveLine, geometries, materials);
collectDebugObject(leg.predictiveSupportMesh, geometries, materials);
collectDebugObject(leg.predictiveSupportOutline, geometries, materials);
for (const marker of leg.predictiveCandidateMarkers) {
collectDebugObject(marker, geometries, materials);
}
leg.marker = null;
leg.hitMarker = null;
leg.rayLine = null;
leg.raiseLimitLine = null;
leg.predictiveLine = null;
leg.predictiveSupportMesh = null;
leg.predictiveSupportOutline = null;
leg.predictiveCandidateMarkers.length = 0;
for (const sample of leg.soleSamples) {
collectDebugObject(sample.marker, geometries, materials);
collectDebugObject(sample.rayLine, geometries, materials);
collectDebugObject(sample.footMarker, geometries, materials);
sample.marker = null;
sample.rayLine = null;
sample.footMarker = null;
}
}
for (const geometry of geometries) geometry.dispose();
for (const material of materials) material.dispose();
}
// 以当前动画脚底四点为起点,画出每个采样点可上抬到的最高边界。
function updateRaiseLimitLine(leg: FootIKLeg, maxFootRaise: number): void {
if (!leg.raiseLimitLine || leg.soleSamples.length < 4) return;
const attribute = leg.raiseLimitLine.geometry.getAttribute("position") as BufferAttribute | undefined;
if (!attribute || attribute.array.length < RAISE_LIMIT_POINT_COUNT * 3) return;
const array = attribute.array as Float32Array;
let cursor = 0;
let exceeded = false;
const writePoint = (x: number, y: number, z: number): void => {
array[cursor++] = x;
array[cursor++] = y;
array[cursor++] = z;
};
for (const sample of leg.soleSamples) {
writePoint(sample.point.x, sample.point.y, sample.point.z);
writePoint(sample.point.x, sample.point.y + maxFootRaise, sample.point.z);
exceeded ||= sample.hasHit && sample.hitPoint.y - sample.point.y > maxFootRaise;
}
for (let i = 0; i < SOLE_PERIMETER.length; i++) {
const from = leg.soleSamples[SOLE_PERIMETER[i]].point;
const to = leg.soleSamples[SOLE_PERIMETER[(i + 1) % SOLE_PERIMETER.length]].point;
writePoint(from.x, from.y + maxFootRaise, from.z);
writePoint(to.x, to.y + maxFootRaise, to.z);
}
attribute.needsUpdate = true;
leg.raiseLimitLine.visible = true;
const material = leg.raiseLimitLine.material;
if (!Array.isArray(material)) {
(material as LineBasicMaterial).color.setHex(
exceeded ? DEBUG_RAISE_LIMIT_EXCEEDED_COLOR : DEBUG_RAISE_LIMIT_COLOR,
);
}
}
function collectDebugObject(
object: Mesh | Line | null,
geometries: Set<BufferGeometry>,
materials: Set<Material>,
): void {
if (!object) return;
object.parent?.remove(object);
geometries.add(object.geometry);
const objectMaterials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of objectMaterials) materials.add(material);
}
@@ -0,0 +1,414 @@
import {
AnimationMixer,
MathUtils,
Quaternion,
Vector3,
type AnimationClip,
type Object3D,
} from "three";
import { collectBones, isReadyLeg } from "./skeleton";
import type {
FootIKLegs,
FootIKOptions,
FootIKSide,
FootPhaseClipData,
FootPhaseContactRun,
FootPhaseDatabase,
FootPhaseFrameSample,
FootPhaseLanding,
FootPhaseOptions,
FootPhasePoseSample,
FootPhaseRuntime,
FootPhaseRuntimeState,
FootPhaseSamplePoint,
FootPhaseSideData,
ReadyFootIKLeg,
} from "../types";
const tmpFoot = new Vector3();
const tmpToe = new Vector3();
const tmpFootRotation = new Quaternion();
const tmpModelRotation = new Quaternion();
// 创建单脚相位的运行时默认状态。
export function createFootPhaseRuntimeState(): FootPhaseRuntimeState {
return {
planted: false,
progress: 0,
timeToLand: Infinity,
nextLanding: null,
};
}
// 从控制器配置中提取脚步相位采样参数。
export function createFootPhaseOptions(options: FootIKOptions = {}): FootPhaseOptions {
return {
sampleCount: Math.max(16, Math.round(options.footPhaseSampleCount ?? 96)),
groundThreshold: Math.max(0, options.footPhaseGroundThreshold ?? 5),
minContactRatio: MathUtils.clamp(options.footPhaseMinContactRatio ?? 0.04, 0, 1),
speedSlack: Math.max(0, options.footPhaseSpeedSlack ?? 1.35),
};
}
// 离线采样循环移动动画,生成左右脚落地和抬脚相位。
export function buildFootPhaseDatabase(
model: Object3D | null | undefined,
clips: readonly AnimationClip[],
legs: FootIKLegs,
options: FootPhaseOptions,
isLocomotionClip: (clipName: string) => boolean,
): FootPhaseDatabase {
const phaseClips: FootPhaseDatabase = new Map();
if (!model || !clips.length || !isReadyLeg(legs.left) || !isReadyLeg(legs.right)) return phaseClips;
const bones = collectBones(model);
const savedPoses = bones.map(bone => ({
bone,
position: bone.position.clone(),
quaternion: bone.quaternion.clone(),
scale: bone.scale.clone(),
}));
const savedModelMatrixNeedsUpdate = model.matrixWorldNeedsUpdate;
const mixer = new AnimationMixer(model);
try {
for (const clip of clips) {
if (!shouldAnalyzeFootPhaseClip(clip, isLocomotionClip)) continue;
phaseClips.set(clip.name, sampleFootPhaseClip(model, mixer, clip, legs, options));
}
} finally {
mixer.stopAllAction();
mixer.uncacheRoot(model);
for (const pose of savedPoses) {
pose.bone.position.copy(pose.position);
pose.bone.quaternion.copy(pose.quaternion);
pose.bone.scale.copy(pose.scale);
}
model.matrixWorldNeedsUpdate = savedModelMatrixNeedsUpdate;
model.updateMatrixWorld(true);
}
return phaseClips;
}
// 根据当前动画时间采样左右脚运行时相位。
export function sampleFootPhaseRuntime(
phaseClip: FootPhaseClipData | null | undefined,
normalizedTime: number,
duration: number,
): FootPhaseRuntime {
if (!phaseClip) {
return {
left: createFootPhaseRuntimeState(),
right: createFootPhaseRuntimeState(),
};
}
return {
left: sampleFootPhaseRuntimeSide(phaseClip.left, normalizedTime, duration),
right: sampleFootPhaseRuntimeSide(phaseClip.right, normalizedTime, duration),
};
}
// 只分析 playerModelConfig 中配置为移动状态的循环动画。
function shouldAnalyzeFootPhaseClip(
clip: AnimationClip,
isLocomotionClip: (clipName: string) => boolean,
): boolean {
return clip.duration > 0 && isLocomotionClip(clip.name);
}
// 对单个动画片段离线采样,生成左右脚相位数据。
function sampleFootPhaseClip(
model: Object3D,
mixer: AnimationMixer,
clip: AnimationClip,
legs: FootIKLegs,
options: FootPhaseOptions,
): FootPhaseClipData {
if (!isReadyLeg(legs.left) || !isReadyLeg(legs.right)) {
throw new Error("[FootIK] 脚步相位采样时腿部骨骼不完整。");
}
const action = mixer.clipAction(clip);
action.reset();
action.setEffectiveWeight(1);
action.play();
const samples: FootPhaseFrameSample[] = [];
for (let i = 0; i < options.sampleCount; i++) {
const normalizedTime = i / options.sampleCount;
mixer.setTime(normalizedTime * clip.duration);
model.updateMatrixWorld(true);
samples.push({
time: normalizedTime,
left: getFootPhaseSample(model, legs.left),
right: getFootPhaseSample(model, legs.right),
});
}
action.stop();
return {
name: clip.name,
duration: clip.duration,
left: analyzeFootPhaseSide(samples, "left", options),
right: analyzeFootPhaseSide(samples, "right", options),
};
}
// 采样单只脚在动画中的高度、水平位置和相对模型旋转。
function getFootPhaseSample(model: Object3D, leg: ReadyFootIKLeg): FootPhaseSamplePoint {
const foot = leg.foot.getWorldPosition(tmpFoot);
const toeY = leg.toe ? leg.toe.getWorldPosition(tmpToe).y : foot.y;
const contactY = Math.min(foot.y, toeY);
const footWorldRotation = leg.foot.getWorldQuaternion(tmpFootRotation);
const localRotation = model
.getWorldQuaternion(tmpModelRotation)
.invert()
.multiply(footWorldRotation)
.clone();
model.worldToLocal(foot);
return {
y: contactY,
x: foot.x,
localY: foot.y,
z: foot.z,
localRotation,
};
}
// 按循环相位插值离线动画中的脚骨局部位姿。
export function sampleFootPhasePose(
sideData: FootPhaseSideData,
normalizedTime: number,
position: Vector3,
rotation: Quaternion,
): boolean {
const poses = sideData.poses;
if (poses.length === 0) return false;
const wrappedTime = ((normalizedTime % 1) + 1) % 1;
const sample = wrappedTime * poses.length;
const fromIndex = Math.floor(sample) % poses.length;
const toIndex = (fromIndex + 1) % poses.length;
const alpha = sample - Math.floor(sample);
position.lerpVectors(
poses[fromIndex].localPosition,
poses[toIndex].localPosition,
alpha,
);
rotation
.copy(poses[fromIndex].localRotation)
.slerp(poses[toIndex].localRotation, alpha);
return true;
}
// 分析单只脚的落地相位。
function analyzeFootPhaseSide(
samples: readonly FootPhaseFrameSample[],
side: FootIKSide,
options: FootPhaseOptions,
): FootPhaseSideData {
let minY = Infinity;
for (const sample of samples) minY = Math.min(minY, sample[side].y);
const speeds = calculateFootPhaseSpeeds(samples, side);
const heightContacts = samples.map(sample => sample[side].y <= minY + options.groundThreshold);
const speedContacts = filterFootPhaseBySpeed(heightContacts, speeds, options.speedSlack);
const contacts = keepMainFootContactRun(filterShortFootContacts(speedContacts, options.minContactRatio));
const land: number[] = [];
const lift: number[] = [];
const landings: FootPhaseLanding[] = [];
const poses: FootPhasePoseSample[] = samples.map(sample => {
const point = sample[side];
return {
localPosition: new Vector3(point.x, point.localY, point.z),
localRotation: point.localRotation.clone(),
};
});
for (let i = 0; i < contacts.length; i++) {
const prev = contacts[(i - 1 + contacts.length) % contacts.length];
const current = contacts[i];
if (!prev && current) {
land.push(samples[i].time);
const point = samples[i][side];
landings.push({
phase: samples[i].time,
localPosition: new Vector3(point.x, point.localY, point.z),
localRotation: point.localRotation.clone(),
});
}
if (prev && !current) lift.push(samples[i].time);
}
return {
contacts,
land,
lift,
landings,
poses,
};
}
// 计算脚在动画相位中的水平速度。
function calculateFootPhaseSpeeds(
samples: readonly FootPhaseFrameSample[],
side: FootIKSide,
): number[] {
const speeds: number[] = [];
for (let i = 0; i < samples.length; i++) {
const prev = samples[(i - 1 + samples.length) % samples.length][side];
const next = samples[(i + 1) % samples.length][side];
speeds.push(Math.hypot(next.x - prev.x, next.z - prev.z));
}
return speeds;
}
// 在低高度候选点中继续过滤水平速度。
function filterFootPhaseBySpeed(
contacts: readonly boolean[],
speeds: readonly number[],
speedSlack: number,
): boolean[] {
const contactSpeeds = speeds
.filter((_speed, index) => contacts[index])
.sort((a, b) => a - b);
if (contactSpeeds.length === 0) return contacts.slice();
const median = contactSpeeds[Math.floor(contactSpeeds.length * 0.5)];
const p75 = contactSpeeds[Math.floor(contactSpeeds.length * 0.75)];
const threshold = Math.max(median, p75) * speedSlack;
return contacts.map((contact, index) => contact && speeds[index] <= threshold);
}
// 去掉过短的接触段,避免脚尖高度抖动产生零碎支撑相位。
function filterShortFootContacts(
contacts: readonly boolean[],
minContactRatio: number,
): boolean[] {
const minLength = Math.max(1, Math.round(contacts.length * minContactRatio));
const filtered = contacts.slice();
for (const run of getCircularContactRuns(contacts, true)) {
if (run.indices.length >= minLength) continue;
for (const index of run.indices) filtered[index] = false;
}
return filtered;
}
// 每只脚只保留最长的主要支撑段。
function keepMainFootContactRun(contacts: readonly boolean[]): boolean[] {
const runs = getCircularContactRuns(contacts, true);
if (runs.length <= 1) return contacts.slice();
const mainRun = runs.sort((a, b) => b.indices.length - a.indices.length)[0];
const filtered = new Array<boolean>(contacts.length).fill(false);
for (const index of mainRun.indices) filtered[index] = true;
return filtered;
}
// 提取循环数组中连续相同值的片段。
function getCircularContactRuns(
values: readonly boolean[],
targetValue: boolean,
): FootPhaseContactRun[] {
const runs: FootPhaseContactRun[] = [];
if (values.length === 0) return runs;
const visited = new Array<boolean>(values.length).fill(false);
const start = values.findIndex((value, index) => {
const prev = values[(index - 1 + values.length) % values.length];
return value === targetValue && prev !== targetValue;
});
if (start < 0) {
return values[0] === targetValue
? [{ indices: values.map((_value, index) => index) }]
: runs;
}
for (let offset = 0; offset < values.length; offset++) {
const index = (start + offset) % values.length;
if (visited[index] || values[index] !== targetValue) continue;
const indices: number[] = [];
let cursor = index;
while (!visited[cursor] && values[cursor] === targetValue) {
visited[cursor] = true;
indices.push(cursor);
cursor = (cursor + 1) % values.length;
if (cursor === index) break;
}
runs.push({ indices });
}
return runs;
}
// 计算单脚是否支撑、摆腿进度和下一次落地时间。
function sampleFootPhaseRuntimeSide(
sideData: FootPhaseSideData,
normalizedTime: number,
duration: number,
): FootPhaseRuntimeState {
const sampleCount = sideData.contacts.length;
if (sampleCount === 0) return createFootPhaseRuntimeState();
const sampleIndex = Math.floor(normalizedTime * sampleCount) % sampleCount;
const planted = !!sideData.contacts[sampleIndex];
const nextLanding = nextLandingEvent(sideData.landings, normalizedTime);
const nextLand = nextLanding?.phase ?? null;
const prevLift = prevPhaseEvent(sideData.lift, normalizedTime);
const swingSpan = wrapPhaseDistance(prevLift, nextLand) || 1;
const swingDone = wrapPhaseDistance(prevLift, normalizedTime);
return {
planted,
progress: planted ? 1 : MathUtils.clamp(swingDone / swingSpan, 0, 1),
timeToLand: nextLand === null
? Infinity
: wrapPhaseDistance(normalizedTime, nextLand) * duration,
nextLanding,
};
}
/** 查找循环动画时间轴上的下一次落脚事件。 */
function nextLandingEvent(
events: readonly FootPhaseLanding[],
normalizedTime: number,
): FootPhaseLanding | null {
if (!events.length) return null;
let best = events[0];
let bestDistance = Infinity;
for (const event of events) {
const distance = wrapPhaseDistance(normalizedTime, event.phase);
if (distance < bestDistance) {
best = event;
bestDistance = distance;
}
}
return best;
}
// 查找循环时间轴上的上一个事件。
function prevPhaseEvent(events: readonly number[], normalizedTime: number): number {
if (!events.length) return normalizedTime;
let best = events[0];
let bestDistance = Infinity;
for (const eventTime of events) {
const distance = wrapPhaseDistance(eventTime, normalizedTime);
if (distance < bestDistance) {
best = eventTime;
bestDistance = distance;
}
}
return best;
}
// 计算归一化循环时间轴上的前向距离。
function wrapPhaseDistance(from: number | null, to: number | null): number {
if (from === null || to === null) return 0;
return (to - from + 1) % 1;
}
@@ -0,0 +1,615 @@
import { Matrix3, Quaternion, Vector3, type Object3D } from "three";
import type {
FootIKGroundHit,
FootIKSoleSample,
FootPhaseRuntimeState,
PredictiveFootState,
ReadyFootIKLeg,
} from "../types";
/** 最小踏步高度;低于该值不启用预测 IK。 */
export const PREDICTION_DEMAND_ENTER = 8;
/** 已启用预测时,需求回落到该高度后退出。 */
export const PREDICTION_DEMAND_EXIT = 4;
/** 预测候选保留脚骨目标、实际支撑面和评分,用于生成本次摆动线路。 */
export type PredictiveFootCandidate = {
/** 鞋底贴合支撑面后的脚骨目标。 */
target: Vector3;
/** 四条鞋底射线共面合并后的地面接触。 */
point: Vector3;
normal: Vector3;
object: Object3D | null;
score: number;
/** 0 为动画中心落点;非 0 表示相对动画做了水平偏移。 */
debugIndex: number;
};
/** 预测落脚搜索、评估和轨迹规划共用的临时对象。 */
export type PredictiveScratch = {
v1: Vector3;
v2: Vector3;
v4: Vector3;
v5: Vector3;
v6: Vector3;
v7: Vector3;
v8: Vector3;
v9: Vector3;
q1: Quaternion;
q2: Quaternion;
q3: Quaternion;
normalMatrix: Matrix3;
trajectoryStart: Vector3;
demandEnter: number;
demandExit: number;
candidates: PredictiveFootCandidate[];
/** 仅确认可站立的中心支撑,尚未通过抬降和腿长约束。 */
centerSupport: PredictiveFootCandidate;
plannedPoints: Vector3[];
currentLocalPosition: Vector3;
currentLocalRotation: Quaternion;
/** 当前动画脚相对相位采样的偏差,规划起点与运行时脚对齐。 */
animationBias: Vector3;
/** 预测脚骨目标相对动画预计落点的世界空间差。 */
landingCorrection: Vector3;
/** 重规划时把线路起点钉在上一帧输出,避免轨迹跳变。 */
continuityOffset: Vector3;
modelWorldRotation: Quaternion;
landingRotation: Quaternion;
worldScale: Vector3;
localPosition: Vector3;
localRotation: Quaternion;
pathPosition: Vector3;
pathRotation: Quaternion;
solePoint: Vector3;
soleOffset: Vector3;
supportHits: FootIKGroundHit[];
supportHitList: FootIKGroundHit[];
clearanceSamples: Array<{ progress: number; requiredLift: number }>;
clearanceSampleCount: number;
};
/** 候选搜索和落点评估所需的只读配置与回调。 */
export type PredictivePlacementContext = {
up: Vector3;
appliedScale: number;
snapEpsilon: number;
sampleRayOriginY: number;
maxPredictionCorrection: number;
predictionMinNormalY: number;
maxFootRaise: number;
maxFootDrop: number;
pelvisKneeBend: number;
footAlignWeight: number;
predictionHorizon: number;
predictionVelocity: Vector3;
scratch: PredictiveScratch;
/** 关闭时跳过支撑面四边形和调试轨迹写入。 */
debug: boolean;
castGroundFrom: (x: number, y: number, z: number) => FootIKGroundHit | null;
castCapsuleGround: () => FootIKGroundHit | null;
getConstrainedFootAlign: (
normal: Vector3,
alignWeight: number,
target: Quaternion,
) => Quaternion;
mergeCoplanarHits: (
hits: Array<FootIKGroundHit | null>,
highest: FootIKGroundHit,
supportPoint: Vector3,
supportNormal: Vector3,
) => number;
getSafeLegReach: (upperLen: number, lowerLen: number, kneeBend: number) => number;
};
/** 创建预测落脚搜索和规划使用的临时对象。 */
export function createPredictiveScratch(): PredictiveScratch {
return {
v1: new Vector3(),
v2: new Vector3(),
v4: new Vector3(),
v5: new Vector3(),
v6: new Vector3(),
v7: new Vector3(),
v8: new Vector3(),
v9: new Vector3(),
q1: new Quaternion(),
q2: new Quaternion(),
q3: new Quaternion(),
normalMatrix: new Matrix3(),
trajectoryStart: new Vector3(),
demandEnter: 0,
demandExit: 0,
candidates: Array.from({ length: 1 }, () => ({
target: new Vector3(),
point: new Vector3(),
normal: new Vector3(),
object: null,
score: 0,
debugIndex: -1,
})),
centerSupport: {
target: new Vector3(),
point: new Vector3(),
normal: new Vector3(),
object: null,
score: 0,
debugIndex: 0,
},
plannedPoints: Array.from({ length: 13 }, () => new Vector3()),
currentLocalPosition: new Vector3(),
currentLocalRotation: new Quaternion(),
animationBias: new Vector3(),
landingCorrection: new Vector3(),
continuityOffset: new Vector3(),
modelWorldRotation: new Quaternion(),
landingRotation: new Quaternion(),
worldScale: new Vector3(),
localPosition: new Vector3(),
localRotation: new Quaternion(),
pathPosition: new Vector3(),
pathRotation: new Quaternion(),
solePoint: new Vector3(),
soleOffset: new Vector3(),
supportHits: Array.from({ length: 4 }, () => ({
point: new Vector3(),
normal: new Vector3(0, 1, 0),
})),
supportHitList: [],
clearanceSamples: Array.from({ length: 12 }, () => ({
progress: 0,
requiredLift: 0,
})),
clearanceSampleCount: 0,
};
}
/** 创建单只脚的预测落脚运行时状态。 */
export function createPredictiveFootState(): PredictiveFootState {
return {
mode: "none",
landingTarget: new Vector3(),
landingNormal: new Vector3(0, 1, 0),
supportPoint: new Vector3(),
animatedLanding: new Vector3(),
animatedLandingRotation: new Quaternion(),
trajectoryStartOffset: new Vector3(),
trajectoryCurrentTarget: new Vector3(),
trajectoryStartProgress: 0,
trajectoryProgress: 0,
trajectoryClearance: 0,
predictionWeight: 0,
usesOffsetLanding: false,
releaseOffset: new Vector3(),
releaseStartProgress: 0,
releaseActive: false,
supportObject: null,
supportLocalTarget: new Vector3(),
supportLocalPoint: new Vector3(),
supportLocalNormal: new Vector3(0, 1, 0),
probeElapsed: Infinity,
score: Infinity,
debugPlaneLift: 0,
debugSwingLift: 0,
debugTrajectory: Array.from({ length: 13 }, () => new Vector3()),
debugTrajectoryVisible: false,
debugSupportVisible: false,
debugSupportCorners: Array.from({ length: 4 }, () => new Vector3()),
debugCandidates: Array.from({ length: 1 }, () => ({
point: new Vector3(),
evaluated: false,
valid: false,
selected: false,
})),
};
}
/** 清空预测目标并保留已分配的向量对象。 */
export function resetPredictiveFootState(state: PredictiveFootState): void {
state.mode = "none";
state.supportObject = null;
state.probeElapsed = Infinity;
state.score = Infinity;
state.debugPlaneLift = 0;
state.debugSwingLift = 0;
state.trajectoryStartOffset.set(0, 0, 0);
state.trajectoryCurrentTarget.set(0, 0, 0);
state.trajectoryStartProgress = 0;
state.trajectoryProgress = 0;
state.trajectoryClearance = 0;
state.predictionWeight = 0;
state.usesOffsetLanding = false;
state.releaseOffset.set(0, 0, 0);
state.releaseStartProgress = 0;
state.releaseActive = false;
state.debugTrajectoryVisible = false;
state.debugSupportVisible = false;
for (const candidate of state.debugCandidates) {
candidate.evaluated = false;
candidate.valid = false;
candidate.selected = false;
}
}
/** 计算预测 IK 进入和退出的地形需求阈值。 */
export function getPredictiveDemandThresholds(
appliedScale: number,
snapEpsilon: number,
scratch: PredictiveScratch,
): { enter: number; exit: number } {
// 退出阈值不高于进入阈值,避免需求在边界附近反复开关预测。
scratch.demandEnter = Math.max(
snapEpsilon,
PREDICTION_DEMAND_ENTER * appliedScale,
);
scratch.demandExit = Math.min(
scratch.demandEnter,
Math.max(snapEpsilon, PREDICTION_DEMAND_EXIT * appliedScale),
);
return { enter: scratch.demandEnter, exit: scratch.demandExit };
}
/** 按终点权重把调试轨迹从起点平移到新的落点。 */
export function shiftPredictiveDebugTrajectory(
points: Vector3[],
delta: Vector3,
): void {
if (delta.lengthSq() <= 1e-12 || points.length < 2) return;
for (let i = 0; i < points.length; i++) {
points[i].addScaledVector(delta, i / (points.length - 1));
}
}
// 把通过完整线路验证的候选同步为最终调试选中项。
export function selectPredictiveDebugCandidate(
state: PredictiveFootState,
selectedIndex: number,
): void {
for (let i = 0; i < state.debugCandidates.length; i++) {
state.debugCandidates[i].selected = i === selectedIndex;
}
}
/** 点高出支撑平面的距离;落在平面上或下方时为 0。 */
export function getPointPlaneLift(
support: { point: Vector3; normal: Vector3 } | null,
point: Vector3,
fallbackY: number,
scratch: Vector3,
): number {
if (!support) return Math.max(0, point.y - fallbackY);
return Math.max(0, support.normal.dot(scratch.copy(point).sub(support.point)));
}
/** 落点高出当前支撑平面的距离;落在平面上或下方时为 0。 */
export function getPredictivePlaneDistance(
ctx: PredictivePlacementContext,
candidate: PredictiveFootCandidate,
): number {
return getPointPlaneLift(
ctx.castCapsuleGround(),
candidate.point,
candidate.point.y,
ctx.scratch.v5,
);
}
/** 预测需求取落点上台高度和摆腿途中高出支撑面的凸起。 */
export function getPredictiveTerrainDemand(
planeLift: number,
swingLift: number,
): number {
return Math.max(planeLift, Math.max(0, swingLift));
}
/** 保存首次有效预测候选,并把目标转换为命中平台的局部锚点。 */
export function acceptPredictiveFootCandidate(
state: PredictiveFootState,
candidate: PredictiveFootCandidate,
scratch: PredictiveScratch,
): void {
state.landingTarget.copy(candidate.target);
state.supportPoint.copy(candidate.point);
state.landingNormal.copy(candidate.normal);
state.score = candidate.score;
// 中心落点 debugIndex 为 0,落地后交还反应式 IK;偏移落点才进入 planted。
state.usesOffsetLanding = candidate.debugIndex !== 0;
state.supportObject = candidate.object;
if (state.supportObject) {
state.supportObject.updateMatrixWorld(true);
state.supportLocalTarget.copy(state.landingTarget);
state.supportObject.worldToLocal(state.supportLocalTarget);
state.supportLocalPoint.copy(state.supportPoint);
state.supportObject.worldToLocal(state.supportLocalPoint);
scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld).invert();
state.supportLocalNormal
.copy(state.landingNormal)
.applyMatrix3(scratch.normalMatrix)
.normalize();
}
}
/** 根据已保存的平台局部锚点刷新预测目标、支撑点和世界法线。 */
export function refreshPredictiveSupportAnchor(
leg: ReadyFootIKLeg,
scratch: PredictiveScratch,
debug = false,
): void {
const state = leg.predictive;
// 中心落点每帧跟随动画预计落点,不能用平台锚点覆盖水平位置。
if (
!state.supportObject
|| (state.mode === "active" && !state.usesOffsetLanding)
) {
return;
}
const previousTarget = debug ? scratch.v1.copy(state.landingTarget) : null;
state.supportObject.updateMatrixWorld(true);
state.landingTarget.copy(state.supportLocalTarget);
state.supportObject.localToWorld(state.landingTarget);
state.supportPoint.copy(state.supportLocalPoint);
state.supportObject.localToWorld(state.supportPoint);
scratch.normalMatrix.getNormalMatrix(state.supportObject.matrixWorld);
state.landingNormal
.copy(state.supportLocalNormal)
.applyMatrix3(scratch.normalMatrix)
.normalize();
if (previousTarget) {
shiftPredictiveDebugTrajectory(
state.debugTrajectory,
scratch.v2.subVectors(state.landingTarget, previousTarget),
);
}
}
/** 把候选的落点和支撑面复制到另一份临时对象。 */
function copyPredictiveFootCandidate(
source: PredictiveFootCandidate,
target: PredictiveFootCandidate,
): void {
target.target.copy(source.target);
target.point.copy(source.point);
target.normal.copy(source.normal);
target.object = source.object;
target.score = source.score;
target.debugIndex = source.debugIndex;
}
/** 评估动画中心落点是否可站立,以及能否作为预测目标。 */
export function findPredictiveFootCandidates(
ctx: PredictivePlacementContext,
leg: ReadyFootIKLeg,
phase: FootPhaseRuntimeState,
expected: Vector3,
expectedRotation: Quaternion,
): {
candidate: PredictiveFootCandidate | null;
centerSupport: PredictiveFootCandidate | null;
} {
const scratch = ctx.scratch;
// 用落地时刻的预计位移把髋部提前送到未来位置,再检查腿是否够得到落点。
const futureRootDelta = scratch.v8
.copy(ctx.predictionVelocity)
.multiplyScalar(Math.min(phase.timeToLand, ctx.predictionHorizon));
const debugCandidate = leg.predictive.debugCandidates[0];
debugCandidate.evaluated = true;
debugCandidate.valid = false;
debugCandidate.selected = false;
debugCandidate.point.copy(expected);
const state = leg.predictive;
state.debugSupportVisible = false;
const probed = probePredictiveFootSupport(
ctx,
leg,
expected.x,
expected.z,
expected,
expectedRotation,
);
if (!probed) {
return { candidate: null, centerSupport: null };
}
if (ctx.debug) {
fillDebugSupportCorners(
ctx,
leg,
expected.x,
expected.z,
expectedRotation,
probed.candidate,
);
state.debugSupportVisible = true;
}
debugCandidate.point.copy(probed.candidate.point);
// 鞋底探到了地面但法线过陡或没有共面支撑时,不能当作预测目标。
if (!probed.accepted) {
return { candidate: null, centerSupport: null };
}
copyPredictiveFootCandidate(probed.candidate, scratch.centerSupport);
const candidate = finalizePredictiveFootCandidate(
ctx,
leg,
probed.candidate,
probed.supportCount,
expected.x,
expected.z,
expected,
expectedRotation,
futureRootDelta,
);
// 可站但超出抬降或腿长时仍返回支撑面,供调试四边形和需求显示使用。
if (!candidate) {
return { candidate: null, centerSupport: scratch.centerSupport };
}
debugCandidate.valid = true;
debugCandidate.selected = true;
debugCandidate.point.copy(candidate.target);
return { candidate, centerSupport: scratch.centerSupport };
}
/** 把鞋底四点投影到合并后的落点支撑面上,供调试绘制。 */
function fillDebugSupportCorners(
ctx: PredictivePlacementContext,
leg: ReadyFootIKLeg,
centerX: number,
centerZ: number,
expectedRotation: Quaternion,
support: PredictiveFootCandidate,
): void {
const scratch = ctx.scratch;
const worldScale = leg.foot.getWorldScale(scratch.v9);
const corners = leg.predictive.debugSupportCorners;
// 略微沿法线抬起,避免四边形 z-fight 陷入地面。
const lift = Math.max(ctx.snapEpsilon, ctx.appliedScale * 0.4);
for (let i = 0; i < corners.length; i++) {
const sample = leg.soleSamples[i];
if (!sample) {
corners[i].copy(support.point).addScaledVector(support.normal, lift);
continue;
}
scratch.v4
.copy(sample.local)
.multiply(worldScale)
.applyQuaternion(expectedRotation);
const corner = corners[i];
corner.set(
centerX + scratch.v4.x,
support.point.y,
centerZ + scratch.v4.z,
);
corner.addScaledVector(
support.normal,
lift - support.normal.dot(scratch.v1.subVectors(corner, support.point)),
);
}
}
/** 只检查鞋底能否在该落点站稳,不考虑腿长和抬降限制。 */
function probePredictiveFootSupport(
ctx: PredictivePlacementContext,
leg: ReadyFootIKLeg,
centerX: number,
centerZ: number,
expected: Vector3,
expectedRotation: Quaternion,
): { candidate: PredictiveFootCandidate; supportCount: number; accepted: boolean } | null {
const scratch = ctx.scratch;
const footWorldQ = expectedRotation;
const worldScale = leg.foot.getWorldScale(scratch.v9);
const hits = scratch.supportHitList;
hits.length = 0;
let highestHit: FootIKGroundHit | null = null;
// 只在动画中心 XZ 打鞋底四角;有一个共面命中即可作为支撑面。
for (const sample of leg.soleSamples as FootIKSoleSample[]) {
const relative = scratch.v4
.copy(sample.local)
.multiply(worldScale)
.applyQuaternion(footWorldQ);
const hit = ctx.castGroundFrom(
centerX + relative.x,
expected.y + ctx.sampleRayOriginY,
centerZ + relative.z,
);
if (!hit) continue;
const stored = scratch.supportHits[hits.length];
stored.point.copy(hit.point);
stored.normal.copy(hit.normal);
stored.object = hit.object;
hits.push(stored);
if (!highestHit || stored.point.y > highestHit.point.y) highestHit = stored;
}
if (!highestHit) return null;
const pooled = scratch.candidates[0];
const supportCount = ctx.mergeCoplanarHits(
hits,
highestHit,
pooled.point,
pooled.normal,
);
pooled.target.set(centerX, pooled.point.y, centerZ);
pooled.object = highestHit.object ?? null;
pooled.score = 0;
pooled.debugIndex = 0;
const accepted = supportCount >= 1
&& pooled.normal.y >= ctx.predictionMinNormalY;
return { candidate: pooled, supportCount, accepted };
}
/** 在可站立支撑之上再施加修正距离、抬降和腿长约束。 */
function finalizePredictiveFootCandidate(
ctx: PredictivePlacementContext,
leg: ReadyFootIKLeg,
pooled: PredictiveFootCandidate,
supportCount: number,
centerX: number,
centerZ: number,
expected: Vector3,
expectedRotation: Quaternion,
futureRootDelta: Vector3,
): PredictiveFootCandidate | null {
const scratch = ctx.scratch;
const correctionSq = (centerX - expected.x) ** 2 + (centerZ - expected.z) ** 2;
const maxCorrectionSq = ctx.maxPredictionCorrection * ctx.maxPredictionCorrection;
if (correctionSq > maxCorrectionSq) return null;
const worldScale = leg.foot.getWorldScale(scratch.v9);
const alignQ = ctx.getConstrainedFootAlign(
pooled.normal,
ctx.footAlignWeight,
scratch.q2,
);
const targetWorldQ = scratch.q3.copy(alignQ).multiply(expectedRotation);
// 按贴地后的脚掌旋转,把脚骨放到鞋底刚好坐在支撑平面上的高度。
let targetY = -Infinity;
for (const sample of leg.soleSamples) {
const relative = scratch.v4
.copy(sample.local)
.multiply(worldScale)
.applyQuaternion(targetWorldQ);
const sampleX = centerX + relative.x;
const sampleZ = centerZ + relative.z;
const planeY = pooled.point.y - (
pooled.normal.x * (sampleX - pooled.point.x)
+ pooled.normal.z * (sampleZ - pooled.point.z)
) / pooled.normal.y;
targetY = Math.max(targetY, planeY - relative.y);
}
if (!Number.isFinite(targetY)) return null;
pooled.target.set(centerX, targetY, centerZ);
const heightDelta = targetY - expected.y;
if (heightDelta > ctx.maxFootRaise || heightDelta < -ctx.maxFootDrop) return null;
const hip = leg.upper.getWorldPosition(scratch.v5);
const knee = leg.lower.getWorldPosition(scratch.v6);
const foot = leg.foot.getWorldPosition(scratch.v7);
const upperLen = hip.distanceTo(knee);
const lowerLen = knee.distanceTo(foot);
const maxReach = ctx.getSafeLegReach(upperLen, lowerLen, ctx.pelvisKneeBend);
// 落地时髋会随角色根节点前移,用未来髋位判断当前落点是否仍在腿长内。
hip.add(futureRootDelta);
const reachRatio = maxReach > 1e-6 ? hip.distanceTo(pooled.target) / maxReach : Infinity;
if (reachRatio > 1) return null;
const correctionRatio = ctx.maxPredictionCorrection > 1e-6
? Math.sqrt(correctionSq) / ctx.maxPredictionCorrection
: 0;
const heightRange = Math.max(ctx.maxFootRaise, ctx.maxFootDrop, 1e-6);
// 分数仅用于调试显示;中心落点没有第二候选比较。
pooled.score = correctionRatio
+ (1 - pooled.normal.y) * 0.7
+ Math.abs(heightDelta) / heightRange * 0.4
+ (4 - supportCount) * 2
+ Math.max(0, reachRatio - 0.85) / 0.2 * 3;
return pooled;
}
@@ -0,0 +1,308 @@
import { MathUtils, Quaternion, Vector3, type Object3D } from "three";
import { sampleFootPhasePose } from "./footPhase";
import {
PREDICTION_DEMAND_ENTER,
getPointPlaneLift,
type PredictiveFootCandidate,
type PredictivePlacementContext,
} from "./predictivePlacement";
import type {
FootPhaseDatabase,
FootPhaseRuntimeState,
ReadyFootIKLeg,
} from "../types";
/** 完整线路验证使用的最小地形采样数。 */
export const PREDICTION_MIN_TERRAIN_SAMPLE_COUNT = 4;
/** 完整线路验证使用的最大地形采样数。 */
export const PREDICTION_MAX_TERRAIN_SAMPLE_COUNT = 12;
/** 相邻地形采样点的设计尺度间距。 */
export const PREDICTION_TERRAIN_SAMPLE_SPACING = 10;
/** 预测启用后的剩余摆腿达到该比例时开始对齐支撑面。 */
export const PREDICTION_ROTATION_WARP_START = 0.55;
/** 单个地形采样点对摆动轨迹提出的上抬要求。 */
export type PredictiveClearanceSample = {
progress: number;
requiredLift: number;
};
/** 已完成地形净空验证的预测摆动线路。 */
export type PredictiveTrajectoryPlan = {
/** 整段摆腿需要追加的最大中段抬脚量。 */
clearance: number;
/** 路径采样得到的最大上台高度,供进入/退出预测使用。 */
terrainDemand: number;
points: Vector3[];
};
/** 轨迹规划在候选评估配置之上额外需要的动画相位数据。 */
export type PredictiveTrajectoryContext = PredictivePlacementContext & {
model: Object3D;
footPhaseClips: FootPhaseDatabase;
clipName: string;
normalizedTime: number;
swingClearance: number;
maxPredictionClearance: number;
};
// 返回两端为零、中段平滑抬高的净空权重。
export function getPredictiveClearanceShape(progress: number): number {
const t = MathUtils.clamp(progress, 0, 1);
return 4 * t * (1 - t);
}
/** 把启用后的剩余摆腿进度重新映射到 0 到 1。 */
export function getPredictiveLocalProgress(
progress: number,
startProgress: number,
): number {
const remainingProgress = Math.max(1e-6, 1 - startProgress);
return MathUtils.clamp((progress - startProgress) / remainingProgress, 0, 1);
}
// 根据整段地形采样求满足所有点的最小额外净空。
export function solvePredictiveClearance(
samples: readonly PredictiveClearanceSample[],
margin: number,
maxClearance: number,
sampleCount = samples.length,
): number | null {
let requiredClearance = 0;
let obstructed = false;
const count = Math.min(sampleCount, samples.length);
for (let i = 0; i < count; i++) {
const sample = samples[i];
if (sample.requiredLift <= 0) continue;
const shape = getPredictiveClearanceShape(sample.progress);
// 净空曲线在两端为 0,若障碍落在两端则无法用中段抬脚躲开。
if (shape <= 1e-6) return null;
obstructed = true;
requiredClearance = Math.max(
requiredClearance,
sample.requiredLift / shape,
);
}
if (!obstructed) return 0;
const clearance = requiredClearance + Math.max(0, margin);
return clearance <= Math.max(0, maxClearance) ? clearance : null;
}
/** 沿剩余动画摆腿路径采样地形,并生成满足鞋底净空的预测线路。 */
export function planPredictiveFootTrajectory(
ctx: PredictiveTrajectoryContext,
leg: ReadyFootIKLeg,
phase: FootPhaseRuntimeState,
footWorld: Vector3,
trajectoryStart: Vector3,
trajectoryStartProgress: number,
candidate: PredictiveFootCandidate,
): PredictiveTrajectoryPlan | null {
const phaseClip = ctx.footPhaseClips.get(ctx.clipName);
if (!phaseClip || phaseClip.duration <= 0) return null;
const sideData = phaseClip[leg.side];
const scratch = ctx.scratch;
if (!sampleFootPhasePose(
sideData,
ctx.normalizedTime,
scratch.currentLocalPosition,
scratch.currentLocalRotation,
)) {
return null;
}
const currentSampleWorld = scratch.currentLocalPosition.applyMatrix4(
ctx.model.matrixWorld,
);
scratch.animationBias.subVectors(footWorld, currentSampleWorld);
scratch.landingCorrection.subVectors(
candidate.target,
leg.predictive.animatedLanding,
);
const currentTrajectoryProgress = getPredictiveLocalProgress(
leg.predictive.trajectoryProgress,
trajectoryStartProgress,
);
const startWarpAlpha = MathUtils.smoothstep(currentTrajectoryProgress, 0, 1);
// 当前输出已经叠了部分落点修正,起点残差里要扣掉这部分,避免重规划把脚再推一次。
scratch.continuityOffset
.subVectors(trajectoryStart, footWorld)
.addScaledVector(scratch.landingCorrection, -startWarpAlpha);
ctx.model.getWorldQuaternion(scratch.modelWorldRotation);
ctx.getConstrainedFootAlign(
candidate.normal,
ctx.footAlignWeight,
scratch.q1,
);
scratch.landingRotation
.copy(scratch.q1)
.multiply(leg.predictive.animatedLandingRotation);
leg.foot.getWorldScale(scratch.worldScale);
const remainingPhase = phase.timeToLand / phaseClip.duration;
// 沿剩余动画摆腿取点:底是相位姿势,只叠加速度、起点连续和落点差量。
const samplePath = (
progress: number,
position: Vector3,
rotation: Quaternion,
): boolean => {
const t = MathUtils.clamp(progress, 0, 1);
const normalizedTime = ctx.normalizedTime + remainingPhase * t;
if (!sampleFootPhasePose(
sideData,
normalizedTime,
scratch.localPosition,
scratch.localRotation,
)) {
return false;
}
const trajectoryProgress = MathUtils.lerp(currentTrajectoryProgress, 1, t);
const warpAlpha = MathUtils.smoothstep(trajectoryProgress, 0, 1);
position
.copy(scratch.localPosition)
.applyMatrix4(ctx.model.matrixWorld)
.addScaledVector(
ctx.predictionVelocity,
Math.min(phase.timeToLand * t, ctx.predictionHorizon),
)
.addScaledVector(scratch.animationBias, 1 - t)
.addScaledVector(scratch.continuityOffset, 1 - t)
.addScaledVector(scratch.landingCorrection, warpAlpha);
// 末段才把脚掌旋到落点法线,避免摆腿中段提前贴坡。
rotation
.copy(scratch.modelWorldRotation)
.multiply(scratch.localRotation)
.slerp(
scratch.landingRotation,
MathUtils.smoothstep(
trajectoryProgress,
PREDICTION_ROTATION_WARP_START,
1,
),
);
return true;
};
if (!samplePath(0, scratch.pathPosition, scratch.pathRotation)) return null;
let startSupportY = -Infinity;
const startRayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y)
+ ctx.sampleRayOriginY;
for (const sample of leg.soleSamples) {
scratch.soleOffset
.copy(sample.local)
.multiply(scratch.worldScale)
.applyQuaternion(scratch.pathRotation);
scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset);
const hit = ctx.castGroundFrom(
scratch.solePoint.x,
startRayOriginY,
scratch.solePoint.z,
);
if (hit) startSupportY = Math.max(startSupportY, hit.point.y);
}
const currentSupport = ctx.castCapsuleGround();
if (!Number.isFinite(startSupportY)) {
startSupportY = currentSupport?.point.y ?? -Infinity;
}
if (!Number.isFinite(startSupportY)) startSupportY = candidate.point.y;
// 低于进入阈值的凸起当作平地,不计入摆腿净空需求。
const terrainEpsilon = Math.max(
ctx.snapEpsilon,
PREDICTION_DEMAND_ENTER * ctx.appliedScale,
);
const horizontalDistance = Math.hypot(
candidate.target.x - scratch.pathPosition.x,
candidate.target.z - scratch.pathPosition.z,
);
const sampleSpacing = Math.max(
ctx.snapEpsilon,
PREDICTION_TERRAIN_SAMPLE_SPACING * ctx.appliedScale,
);
const terrainSampleCount = MathUtils.clamp(
Math.ceil(horizontalDistance / sampleSpacing),
PREDICTION_MIN_TERRAIN_SAMPLE_COUNT,
PREDICTION_MAX_TERRAIN_SAMPLE_COUNT,
);
let maxPlaneLift = 0;
scratch.clearanceSampleCount = 0;
// 在起点和终点之间采样鞋底净空;两端不采,避免把落点本身当成中途障碍。
for (let i = 1; i <= terrainSampleCount; i++) {
const progress = i / (terrainSampleCount + 1);
if (!samplePath(progress, scratch.pathPosition, scratch.pathRotation)) {
return null;
}
let requiredLift = 0;
const rayOriginY = Math.max(scratch.pathPosition.y, candidate.target.y)
+ ctx.sampleRayOriginY;
for (const sample of leg.soleSamples) {
scratch.soleOffset
.copy(sample.local)
.multiply(scratch.worldScale)
.applyQuaternion(scratch.pathRotation);
scratch.solePoint.copy(scratch.pathPosition).add(scratch.soleOffset);
const hit = ctx.castGroundFrom(
scratch.solePoint.x,
rayOriginY,
scratch.solePoint.z,
);
if (!hit) continue;
const planeLift = getPointPlaneLift(
currentSupport,
hit.point,
startSupportY,
scratch.v5,
);
if (planeLift <= terrainEpsilon) continue;
maxPlaneLift = Math.max(maxPlaneLift, planeLift);
requiredLift = Math.max(requiredLift, hit.point.y - scratch.solePoint.y);
}
const clearanceSample = scratch.clearanceSamples[scratch.clearanceSampleCount++];
clearanceSample.progress = MathUtils.lerp(
currentTrajectoryProgress,
1,
progress,
);
clearanceSample.requiredLift = Math.max(0, requiredLift);
}
const clearance = solvePredictiveClearance(
scratch.clearanceSamples,
ctx.swingClearance,
ctx.maxPredictionClearance,
scratch.clearanceSampleCount,
);
if (clearance === null) return null;
const points = scratch.plannedPoints;
for (let i = 0; i < points.length; i++) {
const progress = i / (points.length - 1);
if (!samplePath(progress, points[i], scratch.pathRotation)) return null;
const trajectoryProgress = MathUtils.lerp(
currentTrajectoryProgress,
1,
progress,
);
// 重规划时当前帧已经带了一截净空,后面的点要扣掉这份,避免整条线再抬一次。
const carriedClearance = clearance
* getPredictiveClearanceShape(currentTrajectoryProgress)
* (1 - progress);
points[i].addScaledVector(
ctx.up,
clearance * getPredictiveClearanceShape(trajectoryProgress)
- carriedClearance,
);
}
return {
clearance,
terrainDemand: Math.max(0, maxPlaneLift),
points,
};
}
@@ -0,0 +1,155 @@
import { Vector3, type Bone, type Object3D } from "three";
import type {
BoneRef,
FootIKLeg,
FootIKSide,
FootIKSkeletonConfig,
ReadyFootIKLeg,
} from "../types";
import { createPredictiveFootState } from "./predictivePlacement";
// 收集模型层级下的全部骨骼。
export function collectBones(root: Object3D | null | undefined): Bone[] {
const bones: Bone[] = [];
root?.traverse(obj => {
if ((obj as Bone).isBone) bones.push(obj as Bone);
});
return bones;
}
// 查找常见命名里的骨盆/髋部骨骼。
export function findHips(bones: readonly Bone[]): Bone | null {
return bones.find(bone => /hips|pelvis/i.test(bone.name)) ?? null;
}
// 创建单条腿的骨骼引用和 IK 运行时状态。
export function createLeg(
side: FootIKSide,
bones: readonly Bone[],
skeletonConfig: FootIKSkeletonConfig | null = null,
): FootIKLeg {
const legConfig = skeletonConfig?.legs?.[side] ?? null;
const upper = resolveConfiguredBone(legConfig?.upper, bones, `${side}.upper`)
?? findBone(bones, side, "upper");
const lower = resolveConfiguredBone(legConfig?.lower, bones, `${side}.lower`)
?? findBone(bones, side, "lower");
const foot = resolveConfiguredBone(legConfig?.foot, bones, `${side}.foot`)
?? findBone(bones, side, "foot");
const toe = resolveConfiguredBone(legConfig?.toe, bones, `${side}.toe`)
?? findBone(bones, side, "toe");
return {
side,
upper,
lower,
foot,
toe,
ready: !!(upper && lower && foot),
// IK 运行时目标和地面命中信息。
smoothedTarget: new Vector3(),
heightSupport: null,
heightSupportLocal: new Vector3(),
heightSupportWorldY: 0,
heightSupportScale: 1,
pelvisTarget: new Vector3(),
hasPelvisTarget: false,
hitPoint: new Vector3(),
hitNormal: new Vector3(0, 1, 0),
supportPoint: new Vector3(),
supportNormal: new Vector3(0, 1, 0),
footSamplePoint: new Vector3(),
soleSamples: ["heelL", "heelR", "toeL", "toeR"].map(name => ({
name,
local: new Vector3(),
point: new Vector3(),
hitPoint: new Vector3(),
hasHit: false,
marker: null,
rayLine: null,
footMarker: null,
})),
bestGroundSampleIndex: -1,
// 本帧脚部目标对动画脚位的修正量。
offsetY: 0,
movePenetrating: false,
// weight 为最终 IK 权重,plantedWeight 只负责支撑脚渐入/渐出。
weight: 0,
plantedWeight: 0,
planted: false,
predictive: createPredictiveFootState(),
// 上一帧 pole 只在弯曲平面退化时使用。
lastPole: new Vector3(),
hasLastPole: false,
marker: null,
hitMarker: null,
rayLine: null,
raiseLimitLine: null,
predictiveLine: null,
predictiveCandidateMarkers: [],
predictiveSupportMesh: null,
predictiveSupportOutline: null,
};
}
// 判断腿链是否包含求解需要的三段骨骼。
export function isReadyLeg(leg: FootIKLeg): leg is ReadyFootIKLeg {
return !!(leg.ready && leg.upper && leg.lower && leg.foot);
}
// 优先使用外部配置的骨骼引用,并在缺失时给出提示。
export function resolveConfiguredBone(
ref: BoneRef | undefined,
bones: readonly Bone[],
label: string,
warnMissing = true,
): Bone | null {
if (!ref) return null;
if (typeof ref !== "string" && ref.isBone) return ref;
if (typeof ref === "string") {
const bone = bones.find(item => item.name === ref) ?? null;
if (!bone && warnMissing) {
console.warn(`[FootIK] 配置骨骼未找到:${label} -> "${ref}"`);
}
return bone;
}
console.warn(`[FootIK] 无效骨骼配置:${label}`, ref);
return null;
}
// 根据左右侧和骨骼类型,从模型骨骼名中启发式匹配目标骨骼。
export function findBone(
bones: readonly Bone[],
side: FootIKSide,
type: "upper" | "lower" | "foot" | "toe",
): Bone | null {
const candidates = bones.filter(bone => matchesSide(bone.name, side));
const score = (bone: Bone): number => {
const name = compactName(bone.name);
if (type === "upper") return Number(name.includes("upleg") || name.includes("thigh"));
if (type === "lower") return Number((name.includes("leg") || name.includes("calf") || name.includes("shin")) && !name.includes("upleg") && !name.includes("foot"));
if (type === "foot") return Number((name.includes("foot") || name.includes("ankle")) && !name.includes("toe") && !name.includes("ball"));
return Number(name.includes("toe") || (name.includes("ball") && !name.includes("leaf")));
};
return candidates
.map(bone => ({ bone, score: score(bone) }))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)[0]?.bone ?? null;
}
// 判断骨骼名是否属于指定左右侧。
export function matchesSide(name: string, side: FootIKSide): boolean {
const lower = name.toLowerCase();
const compact = compactName(name);
if (side === "left") return compact.includes("left") || lower.includes("_l") || lower.includes(".l") || lower.includes(" l");
return compact.includes("right") || lower.includes("_r") || lower.includes(".r") || lower.includes(" r");
}
// 把骨骼名压缩成便于规则匹配的形式。
export function compactName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
@@ -0,0 +1,260 @@
import { MathUtils, Quaternion, Vector3, type Bone, type Object3D } from "three";
export type TwoBoneIKScratch = {
v1: Vector3;
v2: Vector3;
v3: Vector3;
v4: Vector3;
v5: Vector3;
v6: Vector3;
v7: Vector3;
v8: Vector3;
v9: Vector3;
v10: Vector3;
v11: Vector3;
v12: Vector3;
v13: Vector3;
q1: Quaternion;
q2: Quaternion;
q3: Quaternion;
identityQ: Quaternion;
};
/** 双骨骼 IK 解算所需的最小腿链。 */
export type TwoBoneIKLeg = {
upper: Bone;
lower: Bone;
foot: Bone;
/** 上一帧的世界空间 pole,只在弯曲平面退化时用于保持连续。 */
lastPole?: Vector3;
hasLastPole?: boolean;
};
export type SolveTwoBoneIKOptions = {
scratch?: TwoBoneIKScratch;
/** 修改骨骼前通知外部保存动画原姿态,下一帧 restore 会用到。 */
capture?: (bone: Object3D) => void;
/** 最小膝盖弯曲角,避免腿完全锁死伸直。 */
minKneeBend?: number;
/** 最大膝盖弯曲角,避免过度折叠。 */
maxKneeBend?: number;
/**
* 膝盖弯曲平面的世界空间法线。
* 角色的右方向作为法线时,膝盖只能在角色的前后平面内弯曲。
*/
kneePlaneNormal?: Vector3;
/** kneePlaneNormal 对原动画 pole 的覆盖权重,范围 0 到 1,默认 1。 */
kneePlaneWeight?: number;
};
// 创建双骨骼 IK 解算复用对象,避免每帧重复分配临时变量。
export function createTwoBoneIKScratch(): TwoBoneIKScratch {
return {
v1: new Vector3(),
v2: new Vector3(),
v3: new Vector3(),
v4: new Vector3(),
v5: new Vector3(),
v6: new Vector3(),
v7: new Vector3(),
v8: new Vector3(),
v9: new Vector3(),
v10: new Vector3(),
v11: new Vector3(),
v12: new Vector3(),
v13: new Vector3(),
q1: new Quaternion(),
q2: new Quaternion(),
q3: new Quaternion(),
identityQ: new Quaternion(),
};
}
// 受膝盖角度约束的双骨骼 IK。
// leg.upper / leg.lower / leg.foot 分别对应大腿、小腿、脚;target 是 foot 希望靠近的世界坐标。
export function solveTwoBoneIK(
leg: TwoBoneIKLeg | null | undefined,
target: Vector3,
weight: number,
options: SolveTwoBoneIKOptions = {},
): void {
if (!leg?.upper || !leg?.lower || !leg?.foot || weight <= 0.001) return;
const scratch = options.scratch ?? createTwoBoneIKScratch();
const capture = options.capture ?? (() => { });
const minBend = options.minKneeBend ?? MathUtils.degToRad(2);
const maxBend = options.maxKneeBend ?? MathUtils.degToRad(145);
const solveWeight = MathUtils.clamp(weight, 0, 1);
// 静止时动画 pole 是主要弯曲方向;移动时只在稳定弯曲平面退化后作为兜底。
// 移动状态由角色前后平面决定膝盖朝向。
const restPole = getRestPole(leg, scratch);
const hip = leg.upper.getWorldPosition(scratch.v1);
const knee = leg.lower.getWorldPosition(scratch.v2);
const foot = leg.foot.getWorldPosition(scratch.v3);
const upperLen = Math.max(0.0001, hip.distanceTo(knee));
const lowerLen = Math.max(0.0001, knee.distanceTo(foot));
const hipToTarget = scratch.v4.copy(target).sub(hip);
const targetDistance = hipToTarget.length();
if (targetDistance < 0.0001) return;
// 用膝盖最小/最大弯曲角反推 foot 能到达的距离范围。
// target 太远会被夹到最大伸展距离,太近会被夹到最大弯曲距离。
const maxReach = reachFromBend(upperLen, lowerLen, minBend);
const minReach = reachFromBend(upperLen, lowerLen, maxBend);
const clampedDistance = MathUtils.clamp(targetDistance, minReach, maxReach);
const clampedTarget = scratch.v5.copy(hip).addScaledVector(hipToTarget, clampedDistance / targetDistance);
// 在 hip -> target 的链方向上,用两段骨骼长度求出 knee 所在圆的中心和半径。
// pole 决定 knee 落在圆的哪一侧,也就是膝盖朝向。
// chainDir × 角色右轴得到的方向始终位于角色前后平面内,因此是“笔直弯”。
const chainDir = scratch.v6.copy(clampedTarget).sub(hip).normalize();
const pole = getStablePole(
leg,
restPole,
options.kneePlaneNormal,
MathUtils.clamp(options.kneePlaneWeight ?? 1, 0, 1),
chainDir,
scratch.v7,
scratch.v4,
scratch.q1,
scratch.identityQ,
);
const along = (upperLen * upperLen - lowerLen * lowerLen + clampedDistance * clampedDistance) / (2 * clampedDistance);
const height = Math.sqrt(Math.max(0, upperLen * upperLen - along * along));
const desiredKnee = scratch.v8.copy(hip).addScaledVector(chainDir, along).addScaledVector(pole, height);
// 第一步旋转大腿,让当前 knee 靠近解析出的 desiredKnee。
rotateBoneToward(leg.upper, leg.lower.getWorldPosition(scratch.v9), desiredKnee, solveWeight, capture, scratch);
leg.upper.updateMatrixWorld(true);
leg.lower.updateMatrixWorld(true);
// 第二步旋转小腿,让 foot 靠近被夹距后的目标点。
rotateBoneToward(leg.lower, leg.foot.getWorldPosition(scratch.v9), clampedTarget, solveWeight, capture, scratch);
leg.lower.updateMatrixWorld(true);
}
// 两段骨骼夹角为 bend 时,hip 到 foot 的理论距离。
function reachFromBend(upperLen: number, lowerLen: number, bend: number): number {
return Math.sqrt(upperLen * upperLen + lowerLen * lowerLen + 2 * upperLen * lowerLen * Math.cos(bend));
}
// 从当前动画姿态提取膝盖弯曲方向。
// 把 hip->knee 投影到 hip->foot 的垂直平面上,得到“膝盖偏离腿链”的方向。
function getRestPole(leg: TwoBoneIKLeg, scratch: TwoBoneIKScratch): Vector3 {
const hip = leg.upper.getWorldPosition(scratch.v9);
const knee = leg.lower.getWorldPosition(scratch.v10);
const foot = leg.foot.getWorldPosition(scratch.v11);
const restChain = scratch.v12.copy(foot).sub(hip).normalize();
const restPole = scratch.v13.copy(knee).sub(hip).addScaledVector(restChain, -scratch.v10.copy(knee).sub(hip).dot(restChain));
if (restPole.lengthSq() < 0.000001) restPole.set(0, 0, 1);
return scratch.v13.normalize();
}
// 优先由角色弯曲平面直接求 pole。这样动画里的横向膝摆不会参与选边。
function getStablePole(
leg: TwoBoneIKLeg,
restPole: Vector3,
kneePlaneNormal: Vector3 | undefined,
kneePlaneWeight: number,
chainDir: Vector3,
target: Vector3,
straightPole: Vector3,
poleRotation: Quaternion,
identityRotation: Quaternion,
): Vector3 {
const hasRestPole = projectPoleToChainPlane(restPole, chainDir, target);
if (kneePlaneNormal && kneePlaneWeight > 0) {
straightPole.crossVectors(chainDir, kneePlaneNormal);
if (normalizePole(straightPole)) {
if (hasRestPole && kneePlaneWeight < 1) {
poleRotation
.setFromUnitVectors(target, straightPole)
.slerp(identityRotation, 1 - kneePlaneWeight);
target.applyQuaternion(poleRotation).normalize();
} else {
target.copy(straightPole);
}
rememberPole(leg, target);
return target;
}
// 移动时腿若几乎沿角色左右方向水平伸出,前后平面内没有唯一解。
// 只在这个奇点沿用上一帧有效 pole,避免跨帧翻面。
if (leg.hasLastPole && leg.lastPole
&& projectPoleToChainPlane(leg.lastPole, chainDir, target)) {
rememberPole(leg, target);
return target;
}
}
// 静止模式直接走这里,完整保留 idle 动画 pole,不读取上一帧的历史结果。
if (hasRestPole) {
projectPoleToChainPlane(restPole, chainDir, target);
rememberPole(leg, target);
return target;
}
// 极端共线姿态的最终兜底。
target.set(0, chainDir.z, -chainDir.y);
if (!normalizePole(target)) {
target.set(-chainDir.y, chainDir.x, 0);
normalizePole(target);
}
rememberPole(leg, target);
return target;
}
// 把方向投影到腿链垂直平面上,得到可用的膝盖 pole;投影退化时返回 false。
function projectPoleToChainPlane(direction: Vector3, chainDir: Vector3, target: Vector3): boolean {
target.copy(direction).addScaledVector(chainDir, -direction.dot(chainDir));
return normalizePole(target);
}
// 归一化 pole;长度过小时视为无效方向。
function normalizePole(pole: Vector3): boolean {
const lengthSq = pole.lengthSq();
if (lengthSq < 0.000001) return false;
pole.multiplyScalar(1 / Math.sqrt(lengthSq));
return true;
}
// 缓存本帧有效 pole,供下一帧奇点兜底。
function rememberPole(leg: TwoBoneIKLeg, pole: Vector3): void {
if (!leg.lastPole) return;
leg.lastPole.copy(pole);
leg.hasLastPole = true;
}
// 把 bone 旋转到当前 effector 方向对齐目标方向。
// deltaWorldQ 在世界空间计算,最后转换回 bone 的本地 quaternion 写入骨骼。
function rotateBoneToward(
bone: Bone,
effectorWorld: Vector3,
targetWorld: Vector3,
weight: number,
capture: (bone: Object3D) => void,
scratch: TwoBoneIKScratch,
): void {
capture(bone);
const jointWorld = bone.getWorldPosition(scratch.v10);
const from = scratch.v11.copy(effectorWorld).sub(jointWorld).normalize();
const to = scratch.v12.copy(targetWorld).sub(jointWorld).normalize();
if (from.lengthSq() < 0.0001 || to.lengthSq() < 0.0001) return;
const deltaWorldQ = scratch.q1.setFromUnitVectors(from, to);
// weight < 1 时只应用部分旋转,保留一部分原动画姿态。
deltaWorldQ.slerp(scratch.identityQ, 1 - weight);
const jointWorldQ = bone.getWorldQuaternion(scratch.q2);
const targetWorldQ = deltaWorldQ.multiply(jointWorldQ);
const parent = bone.parent;
if (!parent) return;
const parentWorldQ = parent.getWorldQuaternion(scratch.q3);
bone.quaternion.copy(parentWorldQ.invert().multiply(targetWorldQ));
bone.updateMatrixWorld(true);
}
@@ -0,0 +1,431 @@
import type {
AnimationAction,
AnimationClip,
Bone,
Line3,
Line,
LineSegments,
Mesh,
Object3D,
Quaternion,
Scene,
Vector3,
} from "three";
/** 骨骼对象或用于查找骨骼的名称。 */
export type BoneRef = Bone | string;
/** 左右脚标识。 */
export type FootIKSide = "left" | "right";
/** Foot IK 地面检测时忽略的动态刚体形状。 */
export type FootIKIgnoredDynamicKind = "sphere" | "box";
/** FootIK 使用的控制器字段;实际传入对象仍是 playerController。 */
export type FootIKPlayer = {
/** Three.js 场景。 */
scene: Scene;
/** 返回可供脚底射线检测的静态与运动学碰撞网格。 */
getColliderMeshes: () => Mesh[];
/** 返回动态刚体列表;用于判断当前是否存在可参与脚底检测的动态地面。 */
getDynamicBodies?: () => unknown[];
/** 从世界坐标向下检测动态刚体,并过滤法线过陡、不可站立的表面。 */
raycastDynamicGround?: (
origin: Vector3,
minNormalY?: number,
excludeKinds?: readonly FootIKIgnoredDynamicKind[],
) => {
/** 动态刚体表面的世界空间命中点。 */
point: Vector3;
/** 命中面的世界空间单位法线。 */
normal: Vector3;
/** 动态刚体及其视觉变换;预测线路使用其局部空间保存落点。 */
body?: { mesh: Object3D };
} | null;
/** 当前站立的动态刚体;站在球体上时 Foot IK 会忽略该支撑。 */
getActiveDynamicBody?: () => { kind: FootIKIgnoredDynamicKind } | null;
/** 返回角色当前世界空间速度;预测落脚启用时用于补充胶囊实际位移速度。 */
getVelocity?: () => Vector3;
/** 角色移动系统本帧最终采用的支撑点;可来自射线或体积探测。 */
getGroundSupport?: () => {
/** 控制器实际用于贴地的世界空间支撑点。 */
point: Vector3;
/** 实际支撑面的世界空间单位法线。 */
normal: Vector3;
} | null;
/** 玩家碰撞胶囊;其世界位置同时作为角色根节点和骨盆支撑参考。 */
playerCapsule: Mesh & {
/** 胶囊几何参数;尚未完成玩家初始化时可能不存在。 */
capsuleInfo?: {
/** 胶囊半径,使用当前角色缩放后的世界尺度。 */
radius: number;
/** 胶囊中心轴线段,保存于胶囊本地空间。 */
segment: Line3;
};
};
/** 玩家可见模型根节点;Foot IK 从该节点中查找骨盆和腿部骨骼。 */
playerModel: Object3D | null;
/** 角色当前是否接地;未接地时跳过脚底贴合与骨盆补偿。 */
playerIsOnGround: boolean;
/** 角色是否处于飞行模式;飞行时不执行 Foot IK。 */
isFlying?: boolean;
/** Foot IK 需要使用的玩家缩放和移动动画名称配置。 */
playerModelConfig: {
/** 玩家当前整体缩放,用于同步射线长度、容差和脚底尺寸。 */
scale: number;
/** 默认向前行走动画名称。 */
walkAnim: string;
/** 默认向前奔跑动画名称。 */
runAnim: string;
/** 可选的左向移动动画名称。 */
leftWalkAnim?: string;
/** 可选的右向移动动画名称。 */
rightWalkAnim?: string;
/** 可选的后退动画名称。 */
backwardAnim?: string;
};
/** 当前玩家动画资源与正在播放的动画动作。 */
animation: {
/** 用于建立脚步着地相位数据库的全部动画片段。 */
clips: AnimationClip[];
/** 当前动画动作;用于读取动画名称、播放时间和脚步相位。 */
state?: AnimationAction;
};
/** 0 步行 / 1 载具;载具模式时跳过贴地 IK。 */
getControllerMode?: () => number;
};
/** 单条腿参与 IK 的骨骼配置。 */
export type FootIKLegBoneConfig = {
/** 大腿骨骼。 */
upper?: BoneRef;
/** 小腿骨骼。 */
lower?: BoneRef;
/** 脚部骨骼。 */
foot?: BoneRef;
/** 脚趾骨骼。 */
toe?: BoneRef;
};
/** FootIK 使用的骨盆和左右腿骨骼配置。 */
export type FootIKSkeletonConfig = {
/** 骨盆骨骼。 */
hips?: BoneRef;
/** 左右腿骨骼配置。 */
legs?: {
/** 左腿骨骼配置。 */
left?: FootIKLegBoneConfig;
/** 右腿骨骼配置。 */
right?: FootIKLegBoneConfig;
};
};
/** FootIK 插件初始化配置。 */
export type FootIKOptions = {
/** 腿部骨骼绑定;未传时根据骨骼名自动匹配。 */
skeleton?: FootIKSkeletonConfig;
/**
* 是否显示统一调试对象,默认 false。
* 包含:IK 目标、最高地面命中、脚底四点(采样原点 + 命中 + 射线)。
*/
debug?: boolean;
/** 是否启用插件,默认 true。 */
enabled?: boolean;
/** 骨盆最大下沉距离基准值(按 scale 缩放),默认 50。 */
maxPelvisDrop?: number;
/** 双脚都高于胶囊时,按较低脚上抬骨盆的最大距离基准值(按 scale 缩放),默认 50。 */
maxPelvisRaise?: number;
/** 脚部 IK 最大上抬距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */
maxFootRaise?: number;
/** 支撑脚 IK 最大下探距离基准值(按 scale 缩放);超出时放弃 IK,默认 50。 */
maxFootDrop?: number;
/** 支撑脚相对支撑面的贴地高度速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */
plantedHeightSpeed?: number;
/** 摆动脚相对支撑面的陷入上抬速度基准值(按 scale 缩放,单位/秒),默认 Infinity;0 为不追随,平台运动另行同步。 */
penetrationLiftSpeed?: number;
/** 虚拟脚底左右半宽基准值(按 scale 缩放),默认 7。 */
soleHalfWidth?: number;
/** 脚尖采样点向前延伸距离基准值(按 scale 缩放),默认 7。 */
soleToeExtend?: number;
/** 脚跟采样点向后延伸距离基准值(按 scale 缩放),默认 3。 */
soleHeelExtend?: number;
/**
* 脚骨到脚底蒙皮的厚度补偿基准值(按 scale 缩放),默认 3。
* 贴地校正时额外上抬,避免按骨骼贴地导致鞋底陷入地面;平地接近满值,斜坡按法线衰减。
*/
soleSkinThickness?: number;
/** 脚掌贴合地面法线的权重,范围 0 到 1,默认 1。 */
footAlignWeight?: number;
/** 脚掌最大倾斜角,单位为弧度,默认 Math.PI / 2。 */
maxFootTilt?: number;
/** 膝盖最小弯曲角,单位为弧度,默认 2°。 */
minKneeBend?: number;
/** 膝盖最大弯曲角,单位为弧度,默认 145°。 */
maxKneeBend?: number;
/** 骨盆可达性计算保留的膝盖弯曲角,单位为弧度,默认 0°。 */
pelvisKneeBend?: number;
/** 移动时是否使用直 pole(膝盖朝角色前方平面),默认 false,关闭则保留动画 pole。 */
straightPoleEnabled?: boolean;
/** 移动时脚底穿透触发阈值基准值(按 scale 缩放),默认 0.1。 */
moveLiftThreshold?: number;
/** 单个移动动画的脚步相位采样数,默认 96。 */
footPhaseSampleCount?: number;
/** 脚步相位接地高度阈值基准值(按 scale 缩放),默认 5。 */
footPhaseGroundThreshold?: number;
/** 最短接触段占动画周期的比例,默认 0.04。 */
footPhaseMinContactRatio?: number;
/** 支撑脚水平速度过滤倍率,默认 1.35。 */
footPhaseSpeedSlack?: number;
/** 是否启用预测落脚;默认 false,关闭时完全沿用反应式 Foot IK。 */
predictivePlacement?: boolean;
/** 最长预测时间,单位为秒,默认 0.45。 */
predictionHorizon?: number;
/** 预测地面候选重新探测间隔,单位为帧,默认 10;0 表示每帧探测。 */
predictionProbeFrames?: number;
/** 预测落脚候选相对动画落点的搜索半径基准值(按 scale 缩放),默认 20。 */
predictionSearchRadius?: number;
/** 允许预测目标偏离动画落点的最大水平距离基准值(按 scale 缩放),默认 45。 */
maxPredictionCorrection?: number;
/** 预测落脚允许的最小地面法线 Y 分量,默认 0.55。 */
predictionMinNormalY?: number;
/** 检测到摆动障碍后追加的净空余量(按 scale 缩放),默认 8。 */
swingClearance?: number;
/** 预测摆动轨迹允许增加的最大净空(按 scale 缩放),默认跟随 maxFootRaise。 */
maxPredictionClearance?: number;
};
/** 预测落脚运行时状态。 */
export type PredictiveFootMode = "none" | "tracking" | "active" | "planted";
/** 单只脚的预测目标、支撑面和平台锚点。 */
export type PredictiveFootState = {
/** none 未启用;tracking 探测中;active 摆腿走预测线路;planted 支撑期钉住偏移落点。 */
mode: PredictiveFootMode;
/** 脚骨 IK 目标;鞋底贴在支撑面上时脚骨应到达的位置。 */
landingTarget: Vector3;
/** 预测落点支撑面的世界空间单位法线。 */
landingNormal: Vector3;
/** 鞋底四点共面合并后的地面接触点,与 landingTarget 不是同一点。 */
supportPoint: Vector3;
/** 动画预计落地时的脚骨位置,已加上剩余落地时间的水平速度。 */
animatedLanding: Vector3;
/** 动画预计落地时的脚骨世界旋转。 */
animatedLandingRotation: Quaternion;
/** 启用预测时当前脚相对动画脚的起点残差,随 warp 渐隐。 */
trajectoryStartOffset: Vector3;
/** 上一帧实际输出的预测脚目标,重规划时用来保持世界空间连续。 */
trajectoryCurrentTarget: Vector3;
/** 预测线路启用瞬间的摆腿进度,用于把剩余摆腿重映射到 0 到 1。 */
trajectoryStartProgress: number;
/** 当前摆腿进度;只增不减,供 warp 和末段锁面使用。 */
trajectoryProgress: number;
/** 摆腿途中为越过凸起追加的净空高度。 */
trajectoryClearance: number;
/** 由地形需求在进入/退出阈值之间平滑得到的预测 IK 权重。 */
predictionWeight: number;
/** 落点相对动画中心做了水平偏移时为 true;中心落点落地后交还反应式 IK。 */
usesOffsetLanding: boolean;
/** 上一支撑偏移落点相对新摆腿动画脚的残差,前段逐渐还给动画。 */
releaseOffset: Vector3;
/** 开始释放残差时的摆腿进度。 */
releaseStartProgress: number;
/** 本摆腿前段是否仍在释放上一支撑残差。 */
releaseActive: boolean;
/** 落点所在网格;移动平台用局部锚点把目标跟到世界空间。 */
supportObject: Object3D | null;
/** landingTarget 在 supportObject 局部空间中的锚点。 */
supportLocalTarget: Vector3;
/** supportPoint 在 supportObject 局部空间中的锚点。 */
supportLocalPoint: Vector3;
/** landingNormal 在 supportObject 局部空间中的方向。 */
supportLocalNormal: Vector3;
/** 距上次地面探测的帧数;Infinity 表示本摆腿尚未探测。 */
probeElapsed: number;
/** 当前预测候选评分;越小越好,无有效候选时为 Infinity。 */
score: number;
/** 调试:落点高出胶囊支撑面的高度。 */
debugPlaneLift: number;
/** 调试:摆腿路径上高出支撑面的最大凸起。 */
debugSwingLift: number;
/** 调试:规划出的摆腿线路采样点。 */
debugTrajectory: Vector3[];
/** 调试:当前是否绘制预测摆腿线路。 */
debugTrajectoryVisible: boolean;
/** 调试:当前是否绘制预测支撑面。 */
debugSupportVisible: boolean;
/** 调试:预测支撑面四角的世界坐标。 */
debugSupportCorners: Vector3[];
/** 调试:本帧评估过的落点候选。 */
debugCandidates: Array<{
/** 候选支撑点的世界坐标。 */
point: Vector3;
/** 本帧是否评估过该候选。 */
evaluated: boolean;
/** 该候选是否通过抬降和腿长约束。 */
valid: boolean;
/** 是否为最终采用的候选。 */
selected: boolean;
}>;
};
/** 脚底查询统一使用世界空间命中点和法线。 */
export type FootIKGroundHit = {
point: Vector3;
normal: Vector3;
object?: Object3D;
};
// 单个地面探测采样点的运行时数据。
export type FootIKProbeSample = {
name: string;
point: Vector3;
hitPoint: Vector3;
hasHit: boolean;
};
// 固定在 foot 本地空间中的脚底采样点
export type FootIKSoleSample = FootIKProbeSample & {
local: Vector3;
/** 地面命中调试球(线框)。 */
marker: Mesh | null;
/** 采样原点 → 命中点。 */
rayLine: Line | null;
/** 脚底采样原点调试球(实心)。 */
footMarker: Mesh | null;
};
// 单条腿的骨骼引用、IK 状态和调试对象。
export type FootIKLeg = {
side: FootIKSide;
upper: Bone | null;
lower: Bone | null;
foot: Bone | null;
toe: Bone | null;
ready: boolean;
smoothedTarget: Vector3;
heightSupport: Object3D | null;
heightSupportLocal: Vector3;
heightSupportWorldY: number;
heightSupportScale: number;
pelvisTarget: Vector3;
hasPelvisTarget: boolean;
hitPoint: Vector3;
hitNormal: Vector3;
supportPoint: Vector3;
supportNormal: Vector3;
footSamplePoint: Vector3;
soleSamples: FootIKSoleSample[];
bestGroundSampleIndex: number;
offsetY: number;
movePenetrating: boolean;
weight: number;
plantedWeight: number;
planted: boolean;
predictive: PredictiveFootState;
lastPole: Vector3;
hasLastPole: boolean;
marker: Mesh | null;
hitMarker: Mesh | null;
rayLine: Line | null;
raiseLimitLine: LineSegments | null;
predictiveLine: Line | null;
predictiveCandidateMarkers: Mesh[];
predictiveSupportMesh: Mesh | null;
predictiveSupportOutline: Line | null;
};
// 已完成必要骨骼绑定的腿链。
export type ReadyFootIKLeg = FootIKLeg & {
upper: Bone;
lower: Bone;
foot: Bone;
ready: true;
};
// 左右腿运行时数据集合。
export type FootIKLegs = Record<FootIKSide, FootIKLeg>;
// IK 修改前保存的单个骨骼姿态。
export type FootIKBonePose = {
position: Vector3;
quaternion: Quaternion;
};
// 脚步相位离线采样配置。
export type FootPhaseOptions = {
sampleCount: number;
groundThreshold: number;
minContactRatio: number;
speedSlack: number;
};
// 单只脚在当前动画帧的相位状态。
export type FootPhaseRuntimeState = {
planted: boolean;
progress: number;
timeToLand: number;
nextLanding: FootPhaseLanding | null;
};
/** 动画周期内一次落脚事件及其模型局部脚骨位姿。 */
export type FootPhaseLanding = {
phase: number;
localPosition: Vector3;
localRotation: Quaternion;
};
// 左右脚在当前动画帧的相位状态。
export type FootPhaseRuntime = Record<FootIKSide, FootPhaseRuntimeState>;
// 当前动画及其左右脚相位状态。
export type FootPhaseControllerState = FootPhaseRuntime & {
clipName: string;
normalizedTime: number;
};
// 单只脚在一个动画周期内的接触区间数据。
export type FootPhaseSideData = {
contacts: boolean[];
land: number[];
lift: number[];
landings: FootPhaseLanding[];
poses: FootPhasePoseSample[];
};
// 单个移动动画的左右脚相位数据。
export type FootPhaseClipData = {
name: string;
duration: number;
left: FootPhaseSideData;
right: FootPhaseSideData;
};
// 以动画名称索引的脚步相位数据库。
export type FootPhaseDatabase = Map<string, FootPhaseClipData>;
// 单只脚在某个采样帧的接触高度和模型局部脚骨位姿。
export type FootPhaseSamplePoint = {
y: number;
x: number;
localY: number;
z: number;
localRotation: Quaternion;
};
// 单只脚在离线采样相位中的模型局部位姿。
export type FootPhasePoseSample = {
localPosition: Vector3;
localRotation: Quaternion;
};
// 左右脚在动画采样时刻的位置数据。
export type FootPhaseFrameSample = {
time: number;
left: FootPhaseSamplePoint;
right: FootPhaseSamplePoint;
};
// 一个连续脚底接触区间包含的采样索引。
export type FootPhaseContactRun = {
indices: number[];
};
@@ -0,0 +1,25 @@
import type { playerController } from "../PlayerController";
/** 控制器插件的生命周期接口。 */
export interface PlayerPlugin {
/** 插件名称;注册同名插件时会替换已有插件。 */
readonly name?: string;
/** 注册到控制器后调用。 */
onAttach?(player: playerController): void;
/** 从控制器卸载后调用。 */
onDetach?(): void;
/** 动画混合器更新前调用,适合恢复上一帧修改。 */
onBeforeAnimationUpdate?(delta: number): void;
/** 动画混合器更新后调用,适合应用本帧后处理。 */
onAfterAnimationUpdate?(delta: number): void;
/** 玩家模型切换后调用,插件可在此重新绑定资源。 */
onPlayerModelChange?(): void;
/** 控制器销毁时调用。 */
dispose?(): void;
}