两节骨骼 IK 够不到目标时,先保住骨长
让一条上臂长 2、前臂长 1 的骨链去抓距离肩部 4 个单位的目标,手当然够不到。但一个常见修补会制造更隐蔽的错误:把余弦值夹到合法范围,肘部顺利算到 (2,0),手腕仍写入目标 (4,0)。所有数值都是有限的,前臂却从 1 变成了 2。没有 NaN,并不等于得到了一副满足骨长的姿态。

主视觉只说明“实际手腕与期望目标可以分离”。下面用独立 C# 平面位置求解器给出精确坐标,没有接入引擎 IK 组件,也不把它当作完整的人体关节模型。抓取目标属于玩法输入,求解器返回肘部与实际手腕;这两个位置共同满足骨长之后,动画系统才能继续决定如何驱动骨骼旋转。
手腕能到达的是环域
把肩部固定为原点,使用 X 向右、Y 向上的平面坐标,两段长度分别为 a、b。末端离原点最远为 a+b,最近为 |a-b|。后一个条件容易被遗漏:两段不等长时,即便完全折叠,手腕也无法回到肩部。这里的单位是统一长度单位,不把资产缩放暗藏进任一段。
设目标为 T、距离为 d。对非零方向,保留目标朝向,把距离夹到可达区间,得到实际端点 E。它也是固定根部、无限制平面关节下离 T 最近的可达点。
u = T / d
r = clamp(d, |a-b|, a+b)
E = r * u
x = (a*a - b*b + r*r) / (2*r)
h = sqrt(max(0, a*a - x*x))
J = x*u + bend*h*(-u.y, u.x)
这里 J 是肘部,bend 取 +1 或 -1,选择目标轴两侧的镜像解。式子来自 |J|²=a² 和 |E-J|²=b² 相减;相减后消掉高度项,得到沿目标轴的投影 x。求解前已经选择可达的 r,根号夹零只负责吸收边界舍入,不能拿来替代目标可达性判断。
T=(2,1) 时,正向弯曲得到 J=(1.2,1.6),E 与 T 相同。T=(4,0) 时,E 停在 (3,0),残差为 1;T=(0.25,0) 时,E 停在 (1,0),残差为 0.75,前臂从肘部向肩部折回。近端和远端使用同一条规则,不需要为了“尽量贴目标”偷偷改变骨长。

技术图来自本次 run.json。右侧把两段共线骨骼的线条略作平行错开,便于区分折叠方向;所有圆点仍锚定真实 X 坐标,橙色虚线表示端点残差。
返回肘部时,也返回实际端点
TwoBoneIk.cs 中的 TwoBoneIk.Solve 是唯一求解入口,Program.Main → Verify → Solve 用返回位置独立量出两段长度。完整核心实现如下;代码放在同一个文件即可与测试入口共同构建。
public readonly record struct Point(double X, double Y)
{
public double Length => Math.Sqrt(X * X + Y * Y);
public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
public static Point operator *(Point a, double s) => new(a.X * s, a.Y * s);
}
public readonly record struct IkResult(Point Joint, Point End, bool Clamped);
public static class TwoBoneIk
{
public static IkResult Solve(double upper, double lower, Point target, int bend)
{
// 输入位于根节点为原点的平面,长度单位保持一致。
if (!double.IsFinite(upper) || !double.IsFinite(lower) ||
upper < 1e-3 || lower < 1e-3 || upper > 1e3 || lower > 1e3)
throw new ArgumentOutOfRangeException(nameof(upper));
if (!double.IsFinite(target.X) || !double.IsFinite(target.Y) ||
Math.Abs(target.X) > 1e4 || Math.Abs(target.Y) > 1e4)
throw new ArgumentOutOfRangeException(nameof(target));
if (bend != 1 && bend != -1)
throw new ArgumentOutOfRangeException(nameof(bend));
double distance = target.Length;
if (distance < 1e-6)
throw new ArgumentException("目标过近,无法按当前合同确定朝向", nameof(target));
Point forward = target * (1 / distance);
Point side = new(-forward.Y, forward.X);
double radius = Math.Clamp(distance, Math.Abs(upper - lower), upper + lower);
double along = (upper * upper - lower * lower + radius * radius) / (2 * radius);
// 夹紧只吸收可达三角形边界的浮点舍入。
double height = Math.Sqrt(Math.Max(0, upper * upper - along * along));
Point joint = forward * along + side * (bend * height);
return new(joint, forward * radius, radius != distance);
}
}
Clamped 表示本次径向目标是否被修改,不表示整个人体姿态已经合法,也不是跨帧稳定信号。调用方应保留原始目标用于残差计算,同时消费返回的 End。如果只采用 Joint,随后又从玩法目标覆盖手腕,开篇的前臂拉长会在接口之外重新出现。
零方向另有歧义。两段等长、目标在肩部时,确实存在完全折叠的解,但肘部可以朝任意方向;两段不等长时,内圆上也没有唯一最近方向。本实现拒绝距离小于 1e-6 的输入,不擅自固定一个世界轴。若产品要求目标穿过肩部时连续运动,应让姿态节点提供上一有效方向或显式弯曲参考,并补上它失效时的策略。当前无状态函数没有保存这种历史。
长度范围 [1e-3,1e3]、目标各分量绝对值上限 1e4 是本实现的数值合同。计算使用 double;范围限制避免平方溢出,但并不宣称覆盖所有长短比和近退化组合的统一误差界。移植到 float、带非均匀缩放的层级或三维极向量求解时,都应重新验收,不能只换向量类型。
用量出的骨长检查修复
失败回归保留了只夹余弦的旧写法。下面的断言确认它确实把前臂算成 2,然后由正常求解断言确认修复后回到 1;测试通过意味着成功识别反例。
double d = 4;
double along = 2 * Math.Clamp((4 + 16 - 1) / (2.0 * 2 * d), -1, 1);
double wrongLower = (new Point(d, 0) - new Point(along, 0)).Length;
if (wrongLower != 2) throw new Exception("反例未复现");
var fixedPose = TwoBoneIk.Solve(2, 1, new Point(4, 0), 1);
if (Math.Abs((fixedPose.End - fixedPose.Joint).Length - 1) > 1e-7)
throw new Exception("第二段骨长发生变化");
2026-09-21 在 .NET 9 Release 下执行。$LAB_DIR 指向本篇研究工程目录,命令中的工程名与现存文件一致:
dotnet build "$LAB_DIR/TwoBoneIkLab.csproj" -c Release
dotnet run --project "$LAB_DIR/TwoBoneIkLab.csproj" -c Release --no-build
build exit=0 warnings=0 errors=0 elapsed=00:00:07.80
run exit=0 passed=14 failed=0 skipped=0
assertion_duration_ms=43.6033
sweep_samples=12960 max_length_error=3.410605131648481E-13
normal joint=(1.2,1.6) end=(2,1) clamped=false
far joint=(2,0) end=(3,0) clamped=true
near joint=(2,0) end=(1,0) clamped=true
cosineOnlyLowerLength=2
扫查使用六组骨长、每三度一个方向、九种距离比例和两种弯曲符号。除两段长度外,还检查端点半径、目标方向和弯曲侧;固定用例另行覆盖内外边界、非有限输入、非法长度和零方向。最大骨长误差是这些离散样本的观测值,耗时只覆盖断言段,不含构建、进程启动与 JSON 序列化,更不是动画帧性能结论。
抓取系统现在可以清楚区分“求解成功”与“触及目标”:骨链有效但残差仍为 1 时,可以移动肩部、降低抓取权重或允许另一套明确的拉伸规则。关节角限制、障碍和三维弯曲平面都没有进入当前环域;加入它们后,径向投影未必仍是最近可行解。先把目标、实际端点和固定骨长分开交付,后续策略才不会把一个够不到的目标悄悄变成资产变形。