chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json. Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser Stand als normaler Ordner im Repo, ohne Verweis auf GitHub. dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher lokal gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
780e246fe5
commit
fb38652e7f
153 files changed
+42561
-4
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in new issue
Block a user