← 返回开发日志← Back to devlog

UNREAL ENGINE / PROCEDURAL ANIMATION

Limb by Limb
Climbing System

高度程序化的攀爬系统,全程不依赖任何动画资产。

A fully procedural climbing system, built without animation assets.

这篇 Devlog 主要分享程序化动作的实现:怎样计算手脚目标,把数据传给动画系统,再通过 Control Rig 求出身体和四肢的姿态。我会结合节点图,详细讲这些计算为什么需要、具体怎么做,以及它们之间怎样配合。蓝图部分负责的选择肢体、确认抓握和切换状态这些流程,就不在这篇 Devlog 里赘述了。

This devlog focuses on the procedural animation: calculating targets for the hands and feet, passing that data to the animation system, and solving the body and limbs in Control Rig. I explain why each calculation is needed, how it works, and how the parts fit together. I keep the basic Blueprint flows for selecting limbs, confirming grips and switching states brief.

01 / 07

灵感与操作Inspiration and controls

我想试着做一套攀爬系统:玩家决定每只手、每只脚怎么移动,身体和四肢的姿态则交给程序化求解。

I wanted to build a climbing system where the player chooses how each hand and foot moves, while procedural solving handles the body and limb poses.

1.1

为什么想做这样的攀爬Why I wanted to build this kind of climbing

#

在很多游戏里,攀爬更像是仅仅换了一个平面,本质还是按住方向键移动。后来,我陆续玩了很多攀岩相关的游戏,比如 PEAK、Jusant。它们对这个机制做了尝试,但在抓握这一步,仍然是用按下某个按键来代替抓牢。

后来我发现了 Cairn:每一只手、每一只脚都可以单独控制,而我需要在每次移动的时候做好思考、做好规划。我一直很喜欢这种体验,这也是我所追求的游戏哲学——即使是简单直接的操作,也能让人投入其中。受到它的启发,我开始制作自己的 Limb by Limb Climbing System。

In many games, climbing feels like moving onto a different surface while still holding the directional controls. I later played more climbing games, including PEAK and Jusant. They explore this mechanic further, but the act of gripping is still represented by a button press.

Then I found Cairn. Each hand and foot can be controlled individually, and I have to think and plan before every move. I have always enjoyed this kind of experience. It reflects what I look for in games: even simple, direct controls can make me feel involved. Inspired by it, I started building my own Limb by Limb Climbing System.

Jusant 的抓握与攀爬Jusant: gripping and climbing
Cairn 的逐肢体操作Cairn: individual limb control

这同时也是我作为 TA 在动作方向的一次尝试。整个系统高度程序化,完全不依赖任何外部动画资产,整体依靠 Control Rig 求解,做出自然、符合人体运动方式的攀爬姿势。但这也意味着,需要解决的不只是“目标地点在哪里”,还包括:移动一只手脚时,其他已经抓牢或踩稳的手脚怎么办,身体还能移动多远,手掌和膝盖应该朝哪里,等等。

This was also an opportunity for me to explore animation as a technical artist. The system is highly procedural and uses no external animation assets. It relies on Control Rig to produce natural climbing poses that follow the way the human body moves. That means there is more to solve than just where a target should be: when one hand or foot moves, what happens to the others that are already holding on? How far can the body move? Which way should the palms and knees face?

1.2

一次实际操作A complete move

#

先选中一只手或脚,用 WASD 或手柄摇杆沿墙调整位置,再确认抓握或踩稳。接着移动另一只肢体,已经确认的手脚继续留在原来的落点。

操作中我调整的是手脚的目标;肩、髋、肘和膝盖怎样跟着移动,需要由后面的求解决定。接下来先看这两部分怎样通过蓝图、动画蓝图和 Control Rig 配合起来。

First, select a hand or foot. Use WASD or the controller stick to move its target along the wall, then confirm the grip or foothold. You can then move another limb while the confirmed hands and feet stay at their existing contact points.

What I move through the controls is a hand or foot target. The solver determines how the shoulders, hips, elbows and knees follow. The Blueprint component, Animation Blueprint and Control Rig connect those two parts of the system.

攀爬系统:一次完整操作A complete move in my climbing system
02 / 07

系统结构System structure

如何把玩家通过手柄或键盘给出的输入,转化成一只手或脚的目标移动,乃至整个身体的响应和调整?我把这条链分开处理:蓝图部分计算接触位置和状态,动画蓝图把数据转换到 Mesh 的坐标系里,Control Rig 再用它们求解姿态。下面以移动一次左手为例,说明各部分怎样配合。

How does keyboard or controller input become a moving hand or foot target, and then an adjustment of the whole body? I split that work between three parts: Blueprint calculates contact positions and states, the Animation Blueprint converts the data into Mesh space, and Control Rig solves the pose. A left-hand move shows how they work together.

2.1

蓝图、动画蓝图和 Control Rig 各做什么What the Blueprint, Animation Blueprint and Control Rig each do

#

玩家移动左手时,首先改变的是左手的目标位置。蓝图负责沿墙寻找这个目标,动画蓝图处理好坐标和权重,再把结果交给 Control Rig。最终手臂怎样伸展、身体怎样跟随,以及手掌朝哪里,由 Control Rig 求解处理。

所在部分负责什么交给下一步的数据
BPC_Climbing
蓝图组件
沿墙移动目标、检测表面,管理手脚的接触状态。世界空间目标、墙面接触点和法线、接触状态。
ABP_Climber
动画蓝图
读取组件数据,转换坐标,准备求解权重,通过 AnimGraph 传给 Rig。Mesh 空间目标、表面点与法线,以及锁定标志和两类权重。
CR_Climber
Control Rig
求解全身与局部骨链,再修正身体位置、手部朝向和腿部姿态。角色最终的骨骼姿态。

BPC 保存的是世界空间目标,ABP 会以当前 Mesh 的变换为参照,把目标转换到组件空间后再交给 Rig。位置转换包含平移,法线这类方向的转换不带入平移;后面的骨骼、目标和法线都要在同一空间内计算。

When the player moves the left hand, the first thing that changes is its target position. Blueprint finds that target along the wall. The Animation Blueprint prepares its coordinates and weights, then passes the result to Control Rig. Control Rig solves how the arm extends, how the body follows, and which way the palm faces.

PartResponsibilityData passed on
BPC_Climbing
Blueprint Component
Move targets along the wall, detect the surface, and manage hand and foot contact states.World-space targets, wall contact points and normals, and contact states.
ABP_Climber
Animation Blueprint
Read component data, convert coordinates, prepare solve weights, and pass them to the rig through AnimGraph.Mesh-space targets, surface points and normals, lock flags, and two types of weight.
CR_Climber
Control Rig
Solve the full body and local bone chains, then correct body position, hand orientation and leg pose.The character's final skeletal pose.

BPC stores targets in world space. ABP uses the current Mesh transform to convert them into component space before they enter the rig. Position conversion includes translation; converting a direction such as a normal does not. The bone positions, targets and normals used later must all be calculated in the same space.

左手数据在 ABP 中的处理:目标坐标、锁定标志和求解权重Left-hand data in ABP: target coordinates, lock flag and solve weights
动画图到 CR_Climber 的变量绑定Variable binding from AnimGraph to CR_Climber
2.2

将状态转换为权重,传入 Control RigConvert contact states into weights for Control Rig

#

ABP 会把接触状态转换成求解需要的权重数值,再传入 Control Rig。以左手为例,LeftHandIKAlpha 用于局部手臂 IK,LeftHandBodyAlpha 用于 Full Body IK 中左手目标的影响程度。

LeftHandIKAlpha 进入左手 Basic IK 的 Weight。当前实现中,Free 状态不参与这一局部求解,其他状态继续求解。锁定后也不关闭 IK,因为身体移动时,肩到锁点的相对位置仍会改变。

Calculate Limb Body Alpha 根据接触状态、可达中心、目标位置和最大可达距离给出期望身体权重。结果通过 FInterp To 平滑后写入 LeftHandBodyAlpha,再接到 Full Body IK 中左手 Effector 的 Position Alpha。

LeftHandIsLocked 另行传入,供旋转缓存等判断使用。它与这两类 Alpha 分工不同;FBIK 与 Basic IK 怎样配合,以及权重变化带来的问题,放到身体求解部分继续讲。

ABP converts contact states into the numerical weights needed by Control Rig. For the left hand, LeftHandIKAlpha controls the local arm IK, while LeftHandBodyAlpha controls the left-hand target's influence in Full Body IK.

LeftHandIKAlpha connects to Weight on the left-hand Basic IK. In the current implementation, the Free state does not take part in this local solve; the other states do. Locking a hand does not turn off its IK, because the shoulder's position relative to the locked point still changes as the body moves.

Calculate Limb Body Alpha uses the contact state, reach center, target position and maximum reach to calculate the desired body weight. FInterp To smooths that result before it is stored in LeftHandBodyAlpha and connected to Position Alpha on the left-hand Full Body IK Effector.

LeftHandIsLocked is passed separately for decisions such as rotation caching. It serves a different purpose from either Alpha. The body-solving section explains how FBIK and Basic IK work together, including the problems I encountered when those weights changed.

CalculateLimbBodyAlpha 函数体CalculateLimbBodyAlpha function
03 / 07

计算手脚沿墙移动的目标Calculating hand and foot targets along the wall

要让手跟着输入沿墙移动,先得算出一个可用的目标。这里以 MoveLeftHandTarget 为例,讲清沿墙方向、手臂可达范围、表面检测和目标生成。确认抓握只交代它与目标的关系,不展开状态机和 Timeline 的接线。

Before a hand can follow the input along the wall, it needs a usable target. MoveLeftHandTarget handles the movement directions, arm reach limit, surface detection and target generation. I only touch on how confirming a grip changes that target, without going through the state machine and Timeline wiring.

3.1

把玩家输入转换成沿墙移动Turn player input into movement along the wall

#
输入形成沿墙位移,再进入可达范围限制Wall movement from input, followed by the reach limit

玩家按“上”,应该让手沿着墙面向上寻找。直接使用世界 Z 方向,在倾斜表面上就可能让目标离开墙面。因此先把输入转换成墙面上的两个方向。

Make Vector(0,0,1) 提供世界上方向,接入 Project Vector on to Plane.V;Plane Normal 接 LeftHandSurfaceNormal。投影去掉上方向中垂直墙面的分量,再经过 Normalize,得到图中的 surface up。

再用 Cross(A = LeftHandSurfaceNormal, B = surface up) 构造沿墙横向,归一化后得到 SurfaceRight。叉乘的先后顺序决定横向的正负,交换 A、B 会把这个方向反过来。

沿墙输入 = SurfaceRight × MoveInputX + surface up × MoveInputY
本帧位移 = 沿墙输入 × LeftHandMoveSpeed × DeltaSeconds
待移动引导点 = LeftHandGuidePointWorld + 本帧位移

这里把速度乘以帧间隔,得到这一帧应该移动的距离。更新的是用来找落点的引导点,手骨还没有被直接移动。已锁定的肢体不再接受这条移动输入;目标怎样限制在够得到的范围内,接着往下看。

Pressing up should move the hand upward along the wall. Using world Z directly could move the target away from an inclined surface, so I first turn the input into two directions on the wall.

Make Vector(0,0,1) supplies world up to Project Vector on to Plane.V, with LeftHandSurfaceNormal connected to Plane Normal. The projection removes the component of up that is perpendicular to the wall. Normalize then gives the direction labelled surface up in the graph.

Next, Cross(A = LeftHandSurfaceNormal, B = surface up) constructs the horizontal direction along the wall. Normalizing it gives SurfaceRight. Cross-product order determines its sign: swapping A and B reverses the direction.

Wall input = SurfaceRight × MoveInputX + surface up × MoveInputY
Frame displacement = Wall input × LeftHandMoveSpeed × DeltaSeconds
Proposed guide point = LeftHandGuidePointWorld + Frame displacement

Multiplying speed by the frame interval gives the distance to move during this frame. This updates a guide point used to find a contact; it does not directly move the hand bone. A locked limb no longer accepts this movement input. The next step keeps the guide point within reach.

3.2

按手臂长度限制引导点Limit the guide point using arm length

#

目标不能跑到手臂够不到的地方。我起初尝试在超出范围时拒绝更新,但这样会让移动卡在边缘。后来改成在检测墙面之前,连续把目标限制在可达范围内,到了边界仍然可以沿边界调整。

先用 Mesh 上 upperarm_l、lowerarm_l、hand_l 三个 Socket 的世界位置,分别测量上臂与前臂长度。两个 Distance (Vector) 相加,减去 0.5 预留余量,再与 LeftHandMaxReach 取 MIN。得到的 EffectiveReach 同时服从实际骨链长度和设定的上限,不会只因为固定半径设得大,就允许目标走得过远。

相对位移 = 待移动引导点 − LeftHandReachCenterWorld
限制后位移 = Clamp Vector Size(相对位移, Min = 0, Max = EffectiveReach)
新引导点 = LeftHandReachCenterWorld + 限制后位移

先减去可达中心,是为了限制目标离这个中心的距离。若直接 Clamp 世界坐标,限制的就会变成离世界原点的距离。长度限制完成后,再加回中心,写入 LeftHandGuidePointWorld。

在范围内,Clamp 保留输入结果;超过上限,就保留方向、缩短长度。玩家继续改变输入,边界上的目标也能跟着调整。这解释了为什么连续限制比“这帧超出了,直接不要这次更新”更适合这里的操作。

这一段约束的是用于检测的引导点。后面根据锁定肢体限制身体,则约束肩或髋与锁点的距离,两者作用在不同阶段。

The target must stay somewhere the arm can reach. At first, I tried rejecting updates that went beyond the limit, but that made movement stick at the edge. I changed this to continuously clamp the target before tracing the wall, so the player can still adjust it along the boundary.

I first read the world positions of the Mesh sockets upperarm_l, lowerarm_l and hand_l to measure the upper arm and forearm lengths separately. The two Distance (Vector) results are added together, reduced by a safety margin of 0.5, then compared with LeftHandMaxReach using MIN. The resulting EffectiveReach respects both the actual bone-chain length and the configured limit. An oversized fixed radius cannot let the target travel beyond the arm's reach.

Relative displacement = Proposed guide point − LeftHandReachCenterWorld
Clamped displacement = Clamp Vector Size(Relative displacement, Min = 0, Max = EffectiveReach)
New guide point = LeftHandReachCenterWorld + Clamped displacement

Subtracting the reach center first lets me limit the target's distance from that center. Clamping the world position directly would instead limit its distance from the world origin. After the length is clamped, I add the center back and write the result to LeftHandGuidePointWorld.

Inside the allowed range, Clamp keeps the input result unchanged. Beyond the limit, it keeps the direction but shortens the length. As the player changes the input, the target can still move along the boundary. That is why continuous clamping suits this interaction better than discarding every update that goes too far.

This step constrains the guide point used for surface detection. The later locked-limb body constraint limits the distance between the shoulder or hip and a locked point. They operate at different stages.

限制相对于 ReachCenter 的引导点位移Clamping guide-point displacement relative to ReachCenter
边界上调整并确认Adjusting and confirming at the reach boundary
RefreshLimbReachCenter 的左手实现RefreshLimbReachCenter: left-hand branch
3.3

从墙面命中点生成手部目标Build the hand target from the wall hit

#

引导点只是寻找落点的参考,还需要射线确认墙面在哪里。以它为中心,沿 WallNormal 向墙外留出起点,再向墙内延伸终点,交给 Line Trace By Channel 检测 Climbable 表面。图中的 25 和 200 是检测线段的外、内延伸距离,不是手掌的离墙间隙。

命中后,Break Hit Result.Impact Point 保存到 LeftHandCandidatePointWorld;Impact Normal 保存为候选法线并更新 LeftHandSurfaceNormal。前者记录真实墙面位置,后者提供后续沿墙方向和离墙偏移的依据。未命中时,这次检测不产生新的有效落点。

在寻找阶段,手与墙之间需要留出一点间隙,所以动画目标由墙面点沿法线外移得到:

SearchHandLocation = LeftHandCandidatePointWorld
                   + LeftHandSurfaceNormal × LeftHandSearchSurfaceOffset

VInterp To Constant 让当前 LeftHandTargetWorld.Location 向这个预览位置移动。引导点继续接收玩家输入,动画目标则平滑追随;因此输入速度和手的追随速度可以分别调整。

目标旋转也从表面关系计算:SearchHandLocation − LeftHandReachCenterWorld 投影到墙面后接 Make Rot from XY.X,表面法线接 Y,建立预览朝向。Compose Transforms 的 A 接 LeftHandSearchRotationOffset,B 接刚得到的 SurfaceRotation,再用 RInterp To Constant 平滑到目标 Rotation。这是蓝图准备的预览方向;手指沿墙对齐和掌面扭转,还会在手部求解中进一步处理。

确认抓握时,仍使用同一个墙面点,只换成锁定时的表面偏移,并让目标过渡过去。状态管理和 Timeline 负责推进这个过程。到这里,蓝图已经准备好目标 Transform、真实表面点和法线,接下来由 ABP 转换坐标,再交给 Rig。

The guide point is only a reference for finding a hold. A trace still needs to locate the wall. Starting from the guide point, I offset the trace start outward along WallNormal and extend its end inward, then use Line Trace By Channel to test Climbable surfaces. The values 25 and 200 in the graph are the outward and inward trace distances, not the gap between the palm and the wall.

On a hit, Break Hit Result.Impact Point is saved to LeftHandCandidatePointWorld. Impact Normal is saved as the candidate normal and updates LeftHandSurfaceNormal. The point records the actual wall location; the normal defines later movement directions and offsets away from the wall. A missed trace does not produce a new valid contact.

While searching, the hand needs a small gap from the wall. I create its animation target by moving outward from the surface point along the normal:

SearchHandLocation = LeftHandCandidatePointWorld
                   + LeftHandSurfaceNormal × LeftHandSearchSurfaceOffset

VInterp To Constant moves the current LeftHandTargetWorld.Location toward this preview position. The guide point continues to receive player input while the animation target follows smoothly, so input movement speed and hand-following speed can be adjusted separately.

The target rotation also comes from the surface relationship. I project SearchHandLocation − LeftHandReachCenterWorld onto the wall and connect it to Make Rot from XY.X, with the surface normal connected to Y. This establishes the preview orientation. On Compose Transforms, A receives LeftHandSearchRotationOffset and B receives the resulting SurfaceRotation. RInterp To Constant then smooths the target Rotation. This is the preview direction prepared in Blueprint; finger alignment along the wall and palm twist are refined later in the hand solve.

Confirming a grip uses the same surface point with the locked surface offset, and transitions the target to that position. State management and a Timeline advance that transition. At this point, Blueprint has prepared the target Transform, actual surface point and normal. ABP converts their coordinates and passes them to the rig.

墙面点与手部预览间隙Surface contact and hand preview gap
04 / 07

身体与四肢:从 IK 到骨盆约束Body and limbs: from IK to pelvis constraints

手脚的目标已经算好了,接下来要解决的是:一条肢体去够新落点时,怎样让整个人跟着调整,同时照顾其他已经抓牢或踩稳的手脚。

Once the hand and foot targets are calculated, the next question is how to let the body adjust as one limb reaches for a new hold, while keeping the other hands and feet planted.

4.1

先看只使用 Full Body IK 能做到什么What Full Body IK can do on its own

#

这套系统的全身姿态求解建立在 Full Body IK 上。它接收四肢的目标,以 pelvis 为 Root,让目标带动身体和四肢一起调整。只用这一层,就已经能让角色随着手脚目标向上攀爬。

这一阶段是后续改进的对比起点。Basic IK、骨盆限制,以及手脚朝向的修正,都是在它的基础上继续加入的。

The full-body pose solve is built on Full Body IK. It receives the four limb targets and uses pelvis as its Root, allowing those targets to move the body and limbs together. This alone is enough to make the character climb upward as the hand and foot targets move.

This first version is the starting point for the later comparisons. Basic IK, pelvis constraints, and hand and foot orientation corrections were added on top of it.

Full Body IK:四个手脚目标与骨盆 RootFull Body IK: four limb targets and the pelvis Root
仅使用 Full Body IK 的实机效果Gameplay using only Full Body IK
4.2

为什么给骨盆再加一层限制Why I added another constraint at the pelvis

#

其实在 v1 中,我只用 Full Body IK,就能让手脚目标带动身体上移。但是在测试时,我发现了 FBIK 权重带来的一个问题:为了让高权重的左手靠近新落点,求解也会把本该留在原位的右手带走。而我们的攀爬,四肢已经建立的接触都必须留在原地,不能只顾正在移动的那一只。

因此在 v2 中,我为四肢分别加入 Basic IK,在 FBIK 求解后,再将它们约束到各自原来的目标位置。但新问题又出现了:从 Candidate 进入确认抓握的过程中,权重发生变化,肢体会被突然拉过去,产生不自然的“瞬移感”。锁住手脚之后,身体的位置也需要一个明确的限制。

所以我想,limb 再怎么移动,真要拽走别的 limb,也得先牵动身体,也就是得经过骨盆这一关。只要对骨盆的位置进行限制,就可以曲线救国。到了 v3,我加入 ConstrainBodyToLockedLimb,在 Control Rig 中按每条肢体的长度约束身体:保持已锁定的接触点,只在肩或髋离锁点过远时修正骨盆位置。这样,身体的调整就能兼顾已经建立的支撑。

也就是说,在四肢限制之外,我又给骨盆上了一层保险。

In v1, Full Body IK alone let the hand and foot targets pull the body upward. Testing exposed a problem with the FBIK weights: moving the higher-weight left hand toward a new hold could also pull the right hand away from the place where it was meant to stay. For this climbing system, established contacts need to remain in place. I cannot only account for the limb being moved.

So in v2, I added Basic IK to each limb after the FBIK solve, constraining it back to its own target. That brought up another problem. As a limb moved from Candidate into a confirmed grip, the weights changed and the limb could suddenly snap across, creating an unnatural teleporting effect. Locking the hands and feet also meant that the body needed a clear movement limit.

My thinking was: however a limb moves, pulling another limb away first requires the body to move. It has to go through the pelvis. Limiting the pelvis could therefore solve this indirectly. In v3, I added ConstrainBodyToLockedLimb in Control Rig to constrain the body using each limb's length. It keeps the locked contact point unchanged and only corrects the pelvis when a shoulder or hip gets too far from that point. The body can then adjust while respecting the support already in place.

In other words, alongside the limb constraints, I added another safeguard at the pelvis.

4.3

Full Body IK 带动身体,Basic IK 处理四肢Full Body IK moves the body; Basic IK solves each limb

#

Full Body IK 的 Root 是 pelvis,四个 Effector 分别接 hand_l、hand_r、foot_l、foot_r。以左手为例,LeftHandTargetInputCS 提供目标 Transform,LeftHandBodyAlpha 接 Position Alpha,控制这个目标对全身求解的影响。这里四个 Effector 的 Rotation Alpha 都为 0,手脚的贴墙朝向会在后面的局部计算里处理。

左手的 Basic IK 处理 upperarm_l → lowerarm_l → hand_l 这条两段骨链。Effector 的 Translation 接 LeftHandTargetInputCS.Translation,Weight 接 LeftHandIKAlpha。所以,BodyAlpha 控制目标怎样带动全身,IKAlpha 控制局部骨链怎样追随目标,两者不能当作同一个开关。

锁定后仍然需要 Basic IK。身体在动,肩和髋的位置也在动,局部骨链必须继续求解,末端才有机会留在墙上的目标位置。左手的 LeftHandIsLocked 在这张局部图里控制的是 Rotation 的 If:未锁定时读取当前手骨旋转,锁定时沿用缓存旋转;位置始终来自独立的目标输入。手部旋转怎样得到、何时缓存,在手部朝向一章继续讲。

Full Body IK uses pelvis as its Root, with four Effectors on hand_l, hand_r, foot_l and foot_r. For the left hand, LeftHandTargetInputCS supplies the target Transform, while LeftHandBodyAlpha connects to Position Alpha and controls that target's influence on the full-body solve. Rotation Alpha is 0 for all four Effectors here. The local calculations later in the rig handle how the hands and feet face the wall.

The left-hand Basic IK solves the two-segment chain upperarm_l → lowerarm_l → hand_l. Its Effector Translation receives LeftHandTargetInputCS.Translation, and Weight receives LeftHandIKAlpha. So BodyAlpha controls how the target influences the whole body, while IKAlpha controls how the local bone chain follows the target. They are not interchangeable switches.

Basic IK still needs to run when a limb is locked. As the body moves, so do the shoulders and hips. The local bone chain has to keep solving for its end to stay at the wall target. In this graph, LeftHandIsLocked controls the If for Rotation: when unlocked, it reads the hand bone's current rotation; when locked, it uses the cached rotation. Position always comes from the separate target input. The hand orientation section explains how that rotation is calculated and cached.

左手 Basic IK:目标位置、缓存旋转与局部 IK 权重Left-hand Basic IK: target position, cached rotation and IK weight
4.4

ConstrainBodyToLockedLimb:算出骨盆该移多少ConstrainBodyToLockedLimb: calculating the pelvis correction

#

这个函数每次处理一条肢体:用它的骨长判断肩或髋离锁点是否太远;如果超出,就把骨盆向锁点方向移回一段距离。传入手臂时,BoneA 是上臂骨、BoneB 是小臂骨、EndBone 是手骨;换成腿部,就是大腿、小腿和脚。它们分别提供骨链起点、中间关节和末端的位置。

LockedTargetCS 是锁点。它必须和骨骼位置处于同一个 Rig 空间,才能直接相减;这里节点上的 Global Space 指 Rig 内的全局空间,不能把世界坐标原样接进来。前面的坐标转换就是为这一步准备数据。

先用骨长确定能伸到多远

下方三个 Get Transform - Bone 勾选 Initial,读取这条骨链的初始位置。两个 Distance Between 分别量 A 到 B、B 到 End 的距离,用 Add 相加,再用 Subtract 扣掉 SafetyMargin,得到允许距离 AllowedReach。

AllowedReach = Distance(Initial A, Initial B)
             + Distance(Initial B, Initial End) − SafetyMargin

这里要取两段骨长之和,不能直接量肩到手的直线距离。屈肘时肩和手会靠近,但手臂本身没有变短。SafetyMargin 则让允许距离比完全伸直时稍短一点,留出余量。

再看当前肩或髋超出了多少

上方的 Get Transform 读取 BoneA 的当前位置,Initial 不勾选。Subtract 的 A 接锁点,B 接当前骨链起点,得到从肩或髋指向锁点的 ToTarget。一条输出进入 Length 得到当前距离,另一条进入 Unit 留下修正方向。

ToTarget = LockedTargetCS − CurrentRootLocation
CurrentDistance = Length(ToTarget)
ExcessDistance = CurrentDistance − AllowedReach

ExcessDistance 是多出来的距离:小于零还有余量,等于零刚好到边界,大于零才需要收回身体。这里的 CurrentRootLocation 是这条骨链当前的起点,例如肩或髋,并不是 pelvis 的位置。

方向乘超出量,得到这次身体位移

BodyCorrection = Unit(ToTarget) × ExcessDistance

Unit 去掉原向量的长度,保留“朝锁点走”的方向;Multiply 再用超出量决定走多远。例如当前距离为 100、允许距离为 90,就只需要向锁点收回 10。这些数字只是解释用的例子。若直接拿 ToTarget 乘 10,原来的长度 100 也会参与放大,修正量就会变成 1000。

Greater 检查 ExcessDistance 是否大于 0,再与 ShouldConstrainReach 进入 And。这个结果控制向量 If:True 输出修正位移,False 输出零向量。这样身体在允许范围内可以自由调整,不会一直被吸向锁点,也不会因为负的超出量被反向推开。

把位移加到骨盆,不改锁点

启用约束 且 ExcessDistance > 0:
    NewPelvisTranslation = CurrentPelvisTranslation + BodyCorrection
其他情况:
    NewPelvisTranslation = CurrentPelvisTranslation

最后读取 pelvis 的当前 Transform,把 Translation 与 If 输出相加,送到 Set Transform - Bone 的 Translation;Rotation 和 Scale 沿用原值。写入对象仍是 pelvis,Propagate to Children 让子骨骼跟随这次平移,肩或髋就随之向锁点靠近。条件不满足时,白色执行线仍会走到 Set,但加进去的是零位移。

这一计算只收回超出长度的部分,没有在函数内部做时间插值。它也不是碰撞检测:身体与墙面的距离由后面的防穿模计算处理。

This function handles one limb at a time. It uses the limb's length to check whether the shoulder or hip is too far from the locked point. If it is, the pelvis moves back toward that point by the excess distance. For an arm, BoneA is the upper arm, BoneB is the forearm, and EndBone is the hand. For a leg, these become the thigh, calf and foot. They supply the positions at the start, middle and end of the chain.

LockedTargetCS is the locked contact point. It must be in the same Rig space as the bone positions before they can be subtracted. Global Space on these nodes means the rig's global space; world coordinates cannot simply be connected unchanged. The earlier coordinate conversion prepares the data for this calculation.

Use bone lengths to establish the reach limit

The three lower Get Transform - Bone nodes have Initial enabled and read the chain's initial positions. Two Distance Between nodes measure A to B and B to End. Add combines them, then Subtract removes SafetyMargin to give AllowedReach.

AllowedReach = Distance(Initial A, Initial B)
             + Distance(Initial B, Initial End) − SafetyMargin

I need the sum of the two bone lengths here, rather than the straight-line distance from shoulder to hand. Bending the elbow brings the hand closer to the shoulder without making the arm shorter. SafetyMargin keeps the allowed distance slightly below full extension.

Measure how far the shoulder or hip exceeds that limit

The upper Get Transform reads BoneA's current position, with Initial disabled. On Subtract, A receives the locked point and B receives the current chain root. The result, ToTarget, points from the shoulder or hip toward the lock. One branch goes into Length for the current distance; the other goes into Unit for the correction direction.

ToTarget = LockedTargetCS − CurrentRootLocation
CurrentDistance = Length(ToTarget)
ExcessDistance = CurrentDistance − AllowedReach

ExcessDistance is the distance beyond the limit. A negative value means there is still room to move; zero is the boundary; a positive value means the body needs to move back. CurrentRootLocation here is the current start of this limb chain, such as the shoulder or hip, rather than the pelvis position.

Multiply direction by excess distance

BodyCorrection = Unit(ToTarget) × ExcessDistance

Unit removes the vector's original length and keeps the direction toward the lock. Multiply then uses the excess distance to determine how far to move. For example, if the current distance is 100 and the allowed distance is 90, the correction only needs to be 10 units toward the lock. These numbers are illustrative. Multiplying the unnormalized ToTarget by 10 would also multiply its original length of 100, producing a correction of 1000.

Greater checks whether ExcessDistance is above 0. And combines that result with ShouldConstrainReach, and drives a vector If: True returns the correction, while False returns a zero vector. The body can therefore move freely inside the allowed range. It is not constantly pulled toward the lock, and a negative excess cannot push it farther away.

Move the pelvis without moving the lock point

If the constraint is enabled and ExcessDistance > 0:
    NewPelvisTranslation = CurrentPelvisTranslation + BodyCorrection
Otherwise:
    NewPelvisTranslation = CurrentPelvisTranslation

Finally, I read the current pelvis Transform, add the If output to its Translation, and connect the result to Translation on Set Transform - Bone. Rotation and Scale keep their original values. The write still targets pelvis. Propagate to Children moves its child bones with it, bringing the shoulder or hip closer to the lock. When the condition is false, the white execution wire still reaches Set, but the added displacement is zero.

This function only corrects the distance beyond the reach limit. It does not perform time interpolation internally, and it is not a collision test. The later wall-clearance calculation handles the gap between the body and the wall.

骨盆距离约束的实际效果Pelvis reach constraint: off and on

交互原理演示

锁点不动,身体继续移动。

拖动肩部,观察身体如何被限制在可达范围内。

需要修正身体
肢体可达范围已锁定的接触点期望肩部位置修正后的肩部

肩部将超出肢体的可达范围。此时向锁点方向修正身体,让接触点保持在原位。

这是单条肢体可达范围约束的简化示意,并非 Unreal Engine 中的实时求解。

4.5

骨盆移动之后,四肢还要继续求解The limbs still need to solve after the pelvis moves

#

把 pelvis 移近锁点,会同时移动它下面的骨骼。Propagate to Children 负责传递骨盆变换,并不负责让手脚留在墙上,因此局部 IK 仍要根据目标重新求解。只改变骨盆、停掉四肢 IK,手脚会跟着身体一起走。

还有一个限制:这个函数一次只检查一条肢体。修正骨盆以满足一只手,会改变其他肩、髋到各自锁点的距离。它不能仅凭一次计算保证四个锁点同时满足;四肢的调用参数、各次修正与后续 IK 的先后关系,必须放回完整执行链来看。

Moving pelvis toward a lock also moves the bones below it. Propagate to Children passes on the pelvis transform; it does not keep the hands and feet planted. Local IK still needs to solve toward the targets. If I move only the pelvis and stop the limb IK, the hands and feet move along with the body.

There is another limitation: this function checks only one limb at a time. Correcting the pelvis for one hand changes the distances from the other shoulders and hips to their locks. A single calculation cannot guarantee that all four contacts are satisfied at once. The limb arguments, successive corrections and subsequent IK solves have to be considered together in the full execution chain.

身体约束与各肢体 IK 的完整执行图Body constraints and limb IK: full execution graph
05 / 07

手部朝向:让手掌贴住墙面Hand Orientation: Bringing the Palm onto the Wall

现在,手的位置已经被约束到正确的地方,但旋转仍然跟着小臂走。攀岩时,手需要扒住墙面,所以还要处理手指往哪里伸、掌面朝向哪里,以及小臂怎样参与旋转。下面以左手为例,把这些计算拆开讲。

The hand is now constrained to the right position, but its rotation still follows the forearm. When climbing, the hand needs to grip the wall. I still need to decide where the fingers point, where the palm faces, and how the forearm shares the rotation. Here I use the left hand to explain those calculations.

5.1

从小臂方向求沿墙切线Projecting the Forearm Direction onto the Wall

#

手的朝向要和实际小臂姿态对应,因此先读取 lowerarm_l 与 hand_l 当前的 Global Space 位置,二者都不勾选 Initial。手腕位置减去小臂骨位置,再经过 Unit,得到 ForearmDirection,也就是肘部朝手腕的方向。

但小臂不一定平行于墙面,不能直接让手指沿着它伸出去。我用 Dot 算出小臂沿墙面法线的分量,经过 Scale 还原为向量,再用 Subtract 扣掉它。剩下的 HandTangentRaw 就是小臂在墙面上的投影。

F = Unit(hand_l.Translation − lowerarm_l.Translation)
N = Unit(LeftHandSurfaceNormalCS)
HandTangentRaw = F − N × Dot(F, N)

公式中的 F 是小臂单位方向,N 是墙面单位法线,都处于同一 Rig 空间。这一减法保留小臂沿墙的走向,同时去掉朝墙里或墙外伸出的部分。

小臂正对墙面时,投影会变得很短。此时直接取单位方向,微小的位置变化就可能造成明显转向。因此我保留投影的 Length,用 Remap 把 0.15–0.35 映射到 0–1,并开启 Clamp,得到 OrientationReliability:投影越长,越多采用小臂给出的方向;投影太短,就采用备用方向。

备用的 WallUpTangent 来自 Rig 空间的向上方向 (0,0,1),同样扣掉它沿墙面法线的分量,再单位化。Interpolate 的 A 接备用方向、B 接小臂投影的单位方向、T 接可靠度,结果再经过 Unit,成为 FinalHandTangent。这样,手指仍有一个沿墙的参考。这是随姿态变化的方向混合,没有使用上一帧方向。如果墙面法线也接近向上方向,这个备用投影仍可能退化,目前没有再加第二层备用方向。

The hand should follow the actual forearm pose, so I first read the current Global Space positions of lowerarm_l and hand_l, with Initial unchecked on both. Subtracting the forearm bone position from the wrist position, then applying Unit, gives ForearmDirection: the direction from the elbow towards the wrist.

The forearm is not necessarily parallel to the wall, so I cannot simply point the fingers along it. I use Dot to find its component along the wall normal, turn that component back into a vector with Scale, and remove it with Subtract. What remains, HandTangentRaw, is the forearm's projection onto the wall.

F = Unit(hand_l.Translation − lowerarm_l.Translation)
N = Unit(LeftHandSurfaceNormalCS)
HandTangentRaw = F − N × Dot(F, N)

Here, F is the unit forearm direction and N is the unit wall normal, both in the same Rig space. The subtraction keeps the part of the forearm direction that runs along the wall and removes the part pointing into or away from it.

When the forearm points straight at the wall, its projection becomes very short.Taking a unit direction at this point can turn tiny position changes into large changes in orientation. I therefore keep the projection's Length and use Remap, with Clamp enabled, to map 0.15–0.35 to 0–1. This gives OrientationReliability. A longer projection contributes more of the forearm direction; a very short one gives way to a fallback.

The fallback, WallUpTangent, starts with the Rig-space up direction, (0,0,1). I remove its component along the wall normal and turn the result into a unit vector. On Interpolate, A receives the fallback, B receives the unit forearm projection, and T receives the reliability value. Applying Unit once more gives FinalHandTangent, so the fingers still have a direction along the wall. This blend depends on the current pose; it does not use the previous frame's direction. If the wall normal is also close to the up direction, the fallback projection can still degenerate. I have not added a second fallback for that case.

小臂方向、墙面投影与备用方向Forearm Direction, Wall Projection and Fallback Tangent
小臂接近正对墙面时的效果The Forearm Approaching Perpendicular to the Wall
5.2

把手指对齐到沿墙方向Aligning the Fingers with the Wall Tangent

#

有了 FinalHandTangent,接下来要算手骨需要转多少。先用 hand_l 当前 Rotation 通过 Rotate Vector 旋转局部 (1,0,0),得到 CurrentHandDirection。这里使用 X 轴,是因为当前左手骨架用它作为手指方向参考;换骨架或处理另一只手时,应先确认本地轴。

From Two Vectors 的 A 接当前手指方向,B 接目标切线,求出两者之间的旋转差 HandDirectionDelta。随后四元数 Multiply 的 A 接这个差值,B 接手骨原旋转,生成 HandTangentRotation。这样既知道手指应朝哪里,也保留了下一步计算掌面朝向所需的完整旋转。

Set Transform - Bone 把结果写入 hand_l 的 Rotation,Translation 与 Scale 仍取当前手骨值。Weight 在未锁定时使用 LeftHandIKAlpha,锁定时为零。

到这里,手指已经沿墙,但掌心仍可能绕手指方向翻转。所以下一步还要单独计算掌面扭转。前面的可靠度负责选定沿墙方向,并不直接降低这里的方向对齐权重。

With FinalHandTangent established, I can calculate how far the hand bone needs to rotate. I use its current Rotation in Rotate Vector to rotate the local vector (1,0,0), giving CurrentHandDirection. I use X because it is the finger-direction reference on this left-hand skeleton. The local axes need checking when using another skeleton or the other hand.

On From Two Vectors, A receives the current finger direction and B receives the target tangent. The result, HandDirectionDelta, is the rotation between them. I then connect that delta to A of a quaternion Multiply, and the hand's original rotation to B, producing HandTangentRotation. This aligns the fingers while retaining a complete rotation for the palm calculation that follows.

Set Transform - Bone writes the result into hand_l's Rotation. Translation and Scale keep their current values. Weight uses LeftHandIKAlpha while the hand is unlocked and becomes zero when it is locked.

The fingers now run along the wall, but the palm can still rotate around that direction. I handle that twist separately in the next step. The reliability value above chooses the direction along the wall; it does not directly reduce this alignment's Weight.

5.3

计算掌面扭转,让小臂一起参与Turning the Palm and Sharing the Twist with the Forearm

#

手指方向确定以后,还剩下绕这个方向旋转的自由度。用 HandTangentRotation 旋转左手本地 (0,1,0),得到 PalmAfterAlignment,也就是手指对齐以后,掌面参考轴朝向哪里。X 轴用于手指,Y 轴用于掌面,这两个参考不能混用。

只知道掌面与墙面法线的夹角大小还不够,还要决定往哪边转。我把掌面参考 P 和墙面法线 N 做 Dot,作为 TwistCos;再把 Cross(P, N) 与手指目标方向 T 做 Dot,作为 TwistSin。Cross 的顺序和 T 一起确定旋转正负,交换 Cross 的输入会改变符号。

P = PalmAfterAlignment
T = FinalHandTangent
TwistSin = Dot(T, Cross(P, WallNormal))
TwistCos = Dot(P, WallNormal)
TwistRadians = Atan2(TwistSin, TwistCos)
SafeTwistRadians = TwistRadians × OrientationReliability

Atan2 的 A 接 TwistSin,B 接 TwistCos,得到带方向的弧度角。最后乘上可靠度:小臂投影接近退化时,掌面扭转逐渐减弱,而手指方向仍沿用上一节算出的备用切线。这里把方向对齐与掌面扭转分开,才能分别控制它们。

这段扭转也要让小臂参与。From Axis And Angle 以局部 X 轴 (1,0,0) 和 SafeTwistRadians 生成 LocalTwistQuat,送入 Distribute Rotation。Items 按 lowerarm_twist_02_l → lowerarm_twist_01_l → hand_l 排列;旋转端点从 Ratio 0 的单位四元数,过渡到 Ratio 1 的 LocalTwistQuat,使用 Linear 分布。新增的局部扭转沿这组骨骼分配,小臂扭转骨与手部一起完成掌面调整。

这里的 Linear 描述沿骨骼列表的分布,不是跨帧插值;也不是给每根骨骼写入相同的全局旋转。节点的 Weight 未锁定时取 LeftHandIKAlpha,锁定时为零。子图中的执行顺序是先写回手指方向,再分配扭转,最后更新旋转缓存。

Once the finger direction is fixed, the hand still has one degree of freedom: rotation around that direction. I use HandTangentRotation to rotate the left hand's local vector (0,1,0). The result, PalmAfterAlignment, tells me where the palm's reference axis points after aligning the fingers. X is the finger reference and Y is the palm reference; they serve different purposes.

The size of the angle between the palm and the wall normal is not enough. I also need to know which way to turn. I take the Dot of the palm reference P and wall normal N as TwistCos, then take the Dot of Cross(P, N) with the target finger direction T as TwistSin. The order of the Cross inputs, together with T, determines the rotation's sign. Swapping those inputs reverses it.

P = PalmAfterAlignment
T = FinalHandTangent
TwistSin = Dot(T, Cross(P, WallNormal))
TwistCos = Dot(P, WallNormal)
TwistRadians = Atan2(TwistSin, TwistCos)
SafeTwistRadians = TwistRadians × OrientationReliability

Atan2 receives TwistSin on A and TwistCos on B, producing a signed angle in radians. I multiply this by the reliability value. As the forearm projection approaches a degenerate direction, the palm twist fades out, while the fingers still use the fallback tangent from the previous section. Separating direction alignment from palm twist lets me control them independently.

The forearm needs to share this twist.From Axis And Angle uses local X, (1,0,0), and SafeTwistRadians to create LocalTwistQuat, which feeds Distribute Rotation. The Items list is ordered lowerarm_twist_02_l → lowerarm_twist_01_l → hand_l. The rotation endpoints run from an identity quaternion at Ratio 0 to LocalTwistQuat at Ratio 1, with Linear distribution. The added local twist is distributed along these bones, so the forearm twist bones and hand take part in turning the palm.

Linear here describes the distribution along the bone list, not interpolation between frames. It also does not mean writing the same global rotation to every bone. The node's Weight uses LeftHandIKAlpha while unlocked and zero while locked. Within the subgraph, I first write the finger alignment, then distribute the twist, and finally update the rotation cache.

小臂与手腕的旋转Forearm and Wrist Rotation
5.4

锁定后使用缓存旋转,手臂继续求解Keeping the Cached Rotation while the Arm Continues Solving

#

手确认抓握以后,要沿用已经算好的朝向。子图末尾的 If 根据 LeftHandIsLocked 选择:未锁定时,把 hand_l 当前 Rotation 写入 LeftCachedHandTransformCS.Rotation;锁定时取缓存原值,所以它不再被当前手骨旋转覆盖。

后续 Basic IK 的 Effector Rotation 也按同一个锁定标志选择缓存或当前旋转,但 Effector.Translation 一直来自 LeftHandTargetInputCS.Translation。因此,缓存保存的是手的朝向,手臂的位置仍然朝目标求解,不能把整套目标 Transform 都替换为缓存。

锁定时,手指对齐和 Distribute Rotation 的 Weight 归零,避免重新调整这只手的朝向;整条手臂的 Basic IK 仍需要继续工作。身体移动以后,肩和肘会改变位置,仍要通过 IK 让手追到原来的落点。

After confirming a grip, I keep the orientation already calculated for that hand. The If at the end of the subgraph checks LeftHandIsLocked. While unlocked, it writes hand_l's current Rotation into LeftCachedHandTransformCS.Rotation. While locked, it keeps the existing cached value, so the current bone rotation no longer overwrites it.

The following Basic IK uses the same lock flag to choose the cached or current Effector Rotation, but Effector.Translation always comes from LeftHandTargetInputCS.Translation. The cache stores the hand's orientation; the arm still solves towards its position target. Replacing the whole target Transform with the cache would mix up these two jobs.

When locked, finger alignment and Distribute Rotation both receive zero Weight, avoiding further orientation adjustments. Basic IK for the arm must keep running. As the body moves, the shoulder and elbow change position, and IK still needs to bring the hand back to its original hold.

锁定时的 Basic IK 旋转输入Basic IK Rotation Input while Locked
手部朝向与 Basic IK 的执行顺序Hand Orientation and Basic IK Execution Order
06 / 07

身体防穿:把挤进墙里的身体推出来Body Clearance: Pushing the Body out of the Wall

手脚能抓住落点,身体也能跟着移动以后,还有一个直接影响姿态的问题:胸口和骨盆会挤进墙里。我在 BPC_Climbing 的 UpdateBodyWallClearance 中检测这两处需要的空间,再把外推位移交给 Control Rig,实际加到骨盆位置上。

Once the hands and feet can hold their contacts and move the body, another problem becomes obvious: the chest and pelvis can sink into the wall. In BPC_Climbing, UpdateBodyWallClearance measures how much space these two areas need. I then pass the resulting offset to Control Rig and apply it to the pelvis.

6.1

从骨盆和胸部检测离墙空间Checking Clearance at the Pelvis and Chest

#

只检查骨盆,不能反映胸部朝墙靠近的情况。因此这里分别读取 Mesh 上 pelvis 和 spine_03 的世界位置,用两个有体积的探针近似骨盆和上躯干。

探测之前,先扣掉已经施加的外推。Get Socket Location 的结果减去当前 BodyWallOffsetWorld,成为 PelvisProbeWorld 和 SpineProbeWorld。否则,上一轮把身体推远之后,下一轮又用被推远的位置测量,修正量本身就会干扰新的判断。

PelvisProbeWorld = SocketWorld(pelvis) − BodyWallOffsetWorld
SpineProbeWorld  = SocketWorld(spine_03) − BodyWallOffsetWorld

墙面法线经 Normalize 得到向墙外的单位方向 N,两路共同使用它。每个 Sphere Trace By Channel 从探针外侧开始,沿相反方向扫向墙内;半径给探针一个简化体积,所以检测得到的安全位置会为身体留出间隙。图中的起止偏移决定扫多远,球半径决定这处身体需要多少空间,它们不是同一项距离。

两路都使用 Climbable 通道并忽略自身,检测依赖墙面在该通道上的碰撞响应。这里是沿当前法线的两次球扫,没有扫描整个身体,也没有同时求解多个墙面的约束。

Checking only the pelvis would miss cases where the chest leans into the wall. I therefore read the world positions of pelvis and spine_03 on the Mesh, and use two probes with volume to approximate the pelvis and upper torso.

Before probing, I subtract the outward offset already applied.The result of Get Socket Location minus the current BodyWallOffsetWorld gives PelvisProbeWorld and SpineProbeWorld. Otherwise, once the body has been pushed away, the next measurement would start from that corrected position. The correction itself would then interfere with the next decision.

PelvisProbeWorld = SocketWorld(pelvis) − BodyWallOffsetWorld
SpineProbeWorld  = SocketWorld(spine_03) − BodyWallOffsetWorld

Normalize turns the wall normal into the outward unit direction N, shared by both traces. Each Sphere Trace By Channel starts outside its probe and sweeps back towards the wall. The sphere radius gives the probe a simplified volume, so the safe position found by the trace leaves room for the body. The start and end offsets determine how far the sweep travels; the radius determines how much room that part of the body needs. These are different distances.

Both traces use the Climbable channel and ignore the character itself, so detection depends on the wall's collision response on that channel. This is a pair of sphere sweeps along the current normal. It does not scan the entire body or solve constraints from several walls at once.

6.2

计算外推距离,取较大需求并平滑Taking the Larger Clearance Offset and Smoothing It

#

命中以后,接出的是 Break Hit Result.Location,即球扫命中时的球心位置。球与墙真正接触的位置则是 Impact Point。这里需要前者,因为球半径已经把身体所需的间隙算进了球心位置,不需要再额外加一次半径。

用 Location 减去探针原位置,再与单位法线 N 做 Dot,得到沿墙外方向还需要移动多远。MAX 把负值归零,身体已经有足够间隙时就不再外推;Select Float 则由球扫的 Return Value 控制,只有命中才使用这次计算,否则取零。

PushOnHit = Max(Dot(Location − ProbeWorld, N), 0)
Push = SelectFloat(A = PushOnHit, B = 0, PickA = Trace.ReturnValue)
BodyRequiredPush = Max(PelvisPush, ChestPush)
RawOffset = N × BodyRequiredPush

骨盆与胸部的需求取较大值,不相加。两路要求的都是沿 N 的同一次身体平移。例如胸部比骨盆需要更多空间,采用胸部的外推距离就同时覆盖了较小的需求;相加反而会重复计算,把身体推出去太多。BodyRequiredPush 表示这次需要的距离,乘上 N 后的 RawOffset 才是实际位移向量。

接着用 VInterp To 平滑这个目标:Current 接已有 BodyWallOffsetWorld,Target 接 RawOffset,Delta Time 接 Get World Delta Seconds,结果写回 BodyWallOffsetWorld。这样,外推量变化时,骨盆不会直接跳到新的位置。两路都不需要外推时,RawOffset 回到零,同一段插值也就把已有偏移逐渐收回。

插值速度控制向目标收敛的快慢,不是固定的每秒移动距离。它让动作变化柔和,也意味着修正可能滞后于当前帧的测量;这套检测没有单独处理深度初始重叠,不能当作完整的身体碰撞求解。

After a hit, I use Break Hit Result.Location: the sphere centre at the hit. Impact Point is the actual contact point between the sphere and the wall. I need the centre because the sphere radius has already accounted for the body's clearance. There is no need to add that radius again.

I subtract the original probe position from Location, then take the Dot with the unit normal N. This gives the remaining distance to move away from the wall. MAX clamps negative values to zero, so there is no outward push when there is already enough room. Select Float uses the trace's Return Value to accept this calculation only on a hit; otherwise, it returns zero.

PushOnHit = Max(Dot(Location − ProbeWorld, N), 0)
Push = SelectFloat(A = PushOnHit, B = 0, PickA = Trace.ReturnValue)
BodyRequiredPush = Max(PelvisPush, ChestPush)
RawOffset = N × BodyRequiredPush

I take the larger of the pelvis and chest offsets rather than adding them.Both ask for the same body translation along N. If the chest needs more room than the pelvis, its offset already covers the smaller requirement. Adding the two would count that movement twice and push the body too far. BodyRequiredPush is a distance; multiplying it by N produces the actual displacement vector, RawOffset.

I then smooth this target with VInterp To. Current receives the existing BodyWallOffsetWorld, Target receives RawOffset, and Delta Time receives Get World Delta Seconds. The result is written back to BodyWallOffsetWorld, so the pelvis does not jump directly to each new correction. When neither probe needs an outward push, RawOffset returns to zero, and the same interpolation gradually removes the existing offset.

The interpolation speed controls how quickly the value approaches its target, not a fixed distance travelled per second. It softens the movement, but the correction can consequently lag behind the current measurement. This setup also does not separately resolve deep initial overlaps; it is not a complete body collision solver.

命中筛选、两路 Max 与 VInterp ToHit Selection, Maximum Offset and VInterp To
身体靠近凸起时外推,离开后收回Body Clearance while Approaching and Leaving a Protrusion
6.3

把外推位移加到骨盆Applying the Offset to the Pelvis

#

BPC 输出的是世界空间位移 BodyWallOffsetWorld,Control Rig 使用的是 BodyWallOffsetCS,中间由动画蓝图转换到 Mesh 空间。这里转换的是一段位移:需要处理参考坐标的方向与尺度,不能把它当成世界坐标点,再减一次 Mesh 的世界位置。

进入 Rig 后,Get Transform - Bone 读取 pelvis 当前的 Global Space 变换。NewPelvisTranslation 中的 Add 把 BodyWallOffsetCS 加到原 Translation,Set Transform - Bone 再写回 pelvis。Rotation 和 Scale 原样传递,Weight 为 1,并开启 Propagate to Children。

NewPelvisTranslation = pelvis.CurrentTranslation + BodyWallOffsetCS

外推到这里才真正改变骨骼姿态。子层级会跟着骨盆移动,所以它必须与手脚的 IK 求解配合:身体需要离墙空间,已经抓住的手脚也仍有自己的位置目标。

BPC outputs a world-space displacement, BodyWallOffsetWorld, while Control Rig uses BodyWallOffsetCS. The Animation Blueprint converts it into Mesh space between the two. This is a displacement, so the conversion needs the reference frame's orientation and scale. Treating it as a world-space point and subtracting the Mesh's world position again would be incorrect.

In the Rig, Get Transform - Bone reads the current Global Space transform of pelvis. The Add inside NewPelvisTranslation adds BodyWallOffsetCS to its original Translation, and Set Transform - Bone writes the result back. Rotation and Scale pass through unchanged, Weight is 1, and Propagate to Children is enabled.

NewPelvisTranslation = pelvis.CurrentTranslation + BodyWallOffsetCS

This is where the offset actually changes the skeleton's pose. The pelvis moves its child hierarchy with it, so the adjustment needs to work with the hand and foot IK. The body needs clearance from the wall, while the hands and feet still have their own contact targets.

骨盆当前位置 + BodyWallOffsetCS → Set TransformCurrent Pelvis Position + BodyWallOffsetCS → Set Transform
外推位移:动画蓝图到 Control RigBody Offset: Animation Blueprint to Control Rig
07 / 07

腿部侧贴:膝盖、脚尖与脚掌接触Leg positioning: knees, toes and foot contact

腿部的重点是侧贴方向:脚跨到身体另一侧时,膝盖和脚尖应怎样跟着转过去,中间又怎样过渡。这里从脚相对髋的位置开始,讲到膝盖引导、脚掌旋转,以及最后的接触位置补偿。

The main challenge here is deciding which way the leg should turn against the wall. As the foot crosses the body, the knee and toes need to turn together, with a smooth transition between sides. I start with the foot’s position relative to the hips, then work through knee guidance, foot rotation and the final contact correction.

7.1

腿要往哪边侧,脚尖怎么一起跟随Choosing a side for the leg and toes

#

脚已经放到了正确的位置,膝盖却不一定弯向合适的一侧;脚尖也可能仍顺着默认骨骼朝向。我希望脚跨到身体另一边时,腿能换一种侧贴姿态,而且膝盖和脚尖要一起变化。这里需要算清楚三件事:往哪边侧、侧多少,以及脚转过来以后怎样继续踩住原来的点。

下面这张是 CR_Climber → Forwards Solve Graph → 右腿朝向 的完整节点图。先算侧贴值和膝盖引导点,用第一次 Basic IK 得到腿部姿态;再根据这个姿态求脚尖方向、脚掌旋转和补偿后的脚踝位置,交给第二次 Basic IK。两次求解各有用途:后半段需要前半段已经求出的当前小腿方向。

图中的 HipCenter、LegAxis 等灰色标题标记计算区域,不全是独立变量。

Putting the foot in the right place does not guarantee that the knee bends the right way. The toes may also keep following the skeleton’s default orientation. I wanted the leg to turn against the wall differently when the foot crossed to the other side of the body, with the knee and toes changing together. That means working out which way to turn, how much to turn, and how to keep the foot on its contact after rotating it.

The full graph below is CR_Climber → Forwards Solve Graph → 右腿朝向. It first calculates the side blend and knee pole, then runs Basic IK to establish the leg pose. From that pose, it calculates toe direction, foot rotation and a corrected ankle position for a second Basic IK pass. The two passes serve different purposes: the second part needs the current lower-leg direction produced by the first.

Grey labels such as HipCenter and LegAxis mark calculation regions in the graph; they are not all separate variables.

整段右腿朝向 OFF / ONRight-leg orientation: off / on
7.2

从脚的位置,计算侧贴方向与幅度Calculating the direction and amount of sideways turning

#

首先要有一个跟着身体走的左右参考。只看右髋,脚是否跨过身体中间就不够直观,所以这里先取两髋的中点,再测脚相对这个中点沿墙偏了多远。

两个 Get Transform 读取当前 thigh_r、thigh_l 的 Translation,经 Add → Scale(0.5) 得到 HipCenter。脚的位置来自 RightFootTargetInputCS.Translation。这些位置都在 Rig 求解空间里比较;骨骼节点的 Global Space 是 Rig 的全局空间,不是关卡 World Space。

HipCenter = (右髋位置 + 左髋位置) × 0.5
WallHorizontalDir = Unit(Cross((0,0,1), RightFootSurfaceNormalCS))
d = Dot(脚目标位置 − HipCenter, WallHorizontalDir)

Cross 用向上基准与表面法线求沿墙的横向,再经过 Unit 保留方向。Subtract 得到髋中心到脚的向量,Dot 则只取它沿墙横向的分量。这里的 d 就是已有 Dot.Result:符号区分哪一侧,大小表示偏了多远。它依据叉乘的方向约定,不随相机翻转,不能直接叫作画面的左或右。

接着把距离变成侧贴程度。两侧不必使用同一个起效距离:一侧在离中间更远后才开始侧贴,另一侧可以更早进入。因此图里用了两组 Remap。

  1. 负侧先将 Dot.Result 乘 −1,再用 Remap 将 20…40 映射到 0…1。这对应 d 从 −20 移到 −40,该侧从不参与逐渐变为完整侧贴。
  2. 正侧直接使用 Dot.Result,将 10…30 映射到 0…1。脚在这一侧超过 10 后开始起效,到 30 达到完整幅度。
  3. 每路结果都接 Maximum(结果,0) → Minimum(结果,1)。这里 Remap 自身没有勾选 Clamp,这两个节点防止距离继续增大时贡献超过 1。
  4. Subtract 用负侧贡献减去正侧贡献,得到候选侧贴值。因此负侧最终对应 +1,正侧对应 −1。
负侧贡献 = Clamp((-d − 20) / 20, 0, 1)
正侧贡献 = Clamp(( d − 10) / 20, 0, 1)
候选侧贴值 = 负侧贡献 − 正侧贡献

公式里的 Clamp 表示图中那组 Maximum → Minimum。在 −20 ≤ d ≤ +10 内,两侧贡献都为零。脚从一侧跨到另一侧时,先逐渐减弱原来的侧贴,经过这个中间区,再增加另一侧。这个连续值随后同时用于膝盖和脚尖;它随位置变化,尚未加入按时间插值。

First, I need a left–right reference that moves with the body. Measuring from the right hip alone makes it less clear when the foot has crossed the body’s centre, so I take the midpoint between both hips and measure how far the foot has moved sideways along the wall from there.

Two Get Transform nodes read the current Translation of thigh_r and thigh_l. Add → Scale(0.5) gives HipCenter. The foot position comes from RightFootTargetInputCS.Translation. These positions are compared in Rig space: Global Space on the bone nodes means the Rig’s global space, not the level’s World Space.

HipCenter = (right hip position + left hip position) × 0.5
WallHorizontalDir = Unit(Cross((0,0,1), RightFootSurfaceNormalCS))
d = Dot(foot target position − HipCenter, WallHorizontalDir)

Cross combines the up reference and surface normal to find a horizontal direction along the wall; Unit keeps only its direction. Subtract gives the vector from the hip centre to the foot, and Dot extracts its component along that wall direction. Here, d refers to the existing Dot.Result: its sign identifies the side, and its magnitude tells me how far the foot has moved. The sign follows the cross-product convention, not the camera, so it does not simply mean screen-left or screen-right.

I then turn that distance into a side blend. The two sides do not have to start turning at the same distance: one can wait until the foot is farther from the centre, while the other starts earlier. That is why I use two Remap branches.

  1. The negative side first multiplies Dot.Result by −1, then uses Remap to map 20…40 to 0…1. As d moves from −20 to −40, this side gradually goes from no influence to a full turn.
  2. The positive side uses Dot.Result directly, mapping 10…30 to 0…1. It starts contributing beyond 10 and reaches its full influence at 30.
  3. Both results pass through Maximum(result,0) → Minimum(result,1). Clamp is disabled on these Remap nodes, so these two nodes stop the contribution from exceeding 1 as the distance increases.
  4. Subtract takes the negative-side contribution minus the positive-side contribution to produce the candidate side blend. The negative side therefore ends at +1, and the positive side at −1.
Negative-side contribution = Clamp((-d − 20) / 20, 0, 1)
Positive-side contribution = Clamp(( d − 10) / 20, 0, 1)
Candidate side blend = negative-side contribution − positive-side contribution

Clamp in this formula stands for the Maximum → Minimum pair in the graph. Both contributions are zero within −20 ≤ d ≤ +10. When the foot crosses from one side to the other, the first turn fades out, passes through this neutral region, and then builds in the other direction. The resulting continuous value drives both the knee and toes. It changes with position; no time-based interpolation has been added at this stage.

用髋中心与墙面横向,测量脚在身体哪一侧Measuring the foot’s side relative to the hip centre
两路 Remap 相减,让换侧经过中间区域Subtracting two Remap results to pass through a neutral region
脚跨过身体时,膝盖和脚尖怎样换侧The knee and toes turning as the foot crosses the body

脚的位置怎样决定侧贴方向How foot position changes the side blend

拖动滑块,查看脚目标沿墙面横移时,两路 Remap 怎样一起决定侧贴值。Move the slider to see how the two Remap branches respond as the foot target moves sideways along the wall.

脚的横向位置与侧贴值Lateral foot position and side blend+10−1−40−200+10+30零响应区域Neutral zoneDot.Result

这里显示候选侧贴值,不是膝盖角度。实际姿态还受锁定状态与腿部伸展衰减影响。正负方向按墙面横向定义。This is the candidate side-blend value, not a knee angle. The final pose also depends on the lock state and reach fade. Positive and negative follow the wall’s horizontal axis.
7.3

保留支撑侧向,在伸直和折叠时减弱侧贴Keeping the chosen side and reducing the turn near full extension or folding

#

脚踩稳以后,身体还会移动。如果每帧都重新按脚与髋的相对位置选侧,已经建立的支撑姿态也会跟着换侧。所以图里保存 RightLegSideBlend;与此同时,腿伸直或深度折叠时,侧贴幅度仍需要减弱。这是两个分开的判断。

先计算伸展比例 ReachRatio。一只 Distance 读取髋到脚目标的距离;另两只读取 thigh_r → calf_r 和 calf_r → foot_r,经 Add 得到两段总长。前者接 Divide.A,后者接 B:

ReachRatio = 髋到脚目标的距离 /(大腿长度 + 小腿长度)

这份图的长度节点没有勾选 Initial,使用当前骨骼位置。比例接近 1,意味着髋脚距离接近整条腿的长度;比例较小,则表示脚更靠近髋。

只有 ReachRatio > 0.35 且 < 0.94、Not(RightFootIsLocked)、RightFootIKAlpha > 0.1 同时成立,才更新缓存。这些比较经 And 接到 If.Condition;True 接上一节的候选值,False 读回当前 RightLegSideBlend,最后写回同名变量。这样在锁定、IK 几乎不参与或伸展接近边界时,不重新选择侧向。

缓存确定了方向,下面再决定它现在能起多大作用。伸直时膝盖弯曲余量已经很少,不能仍保持完整侧贴;深度折叠时也逐渐退回基础引导。图中分别用两条 Remap 计算:

伸直衰减:ReachRatio 0.90 → 0.97,对应 1 → 0
折叠衰减:ReachRatio 0.30 → 0.40,对应 0 → 1
实际侧贴值 s = RightLegSideBlend × Min(伸直衰减, 折叠衰减)

两条衰减也各经 Maximum(结果,0) → Minimum(结果,1),再取较小值。因此 0.40…0.90 内保留完整幅度,靠近两端时逐渐减到零。后文的 s 就是“右腿侧贴值”框中 Multiply.Result,不是另建变量。锁定时保留的是侧向缓存,伸展衰减、膝盖引导和两次 IK 都仍继续计算。

The body keeps moving after a foot has been planted. If I recalculate the chosen side from the foot’s position relative to the hips every frame, a planted leg can also change sides. I therefore store RightLegSideBlend. At the same time, I still need to reduce the turn when the leg is nearly straight or deeply folded. These are two separate decisions.

First, I calculate the extension ratio, ReachRatio. One Distance node measures from the hip to the foot target. Two more measure thigh_r → calf_r and calf_r → foot_r, then Add combines them into the total leg length. The hip-to-target distance goes into Divide.A, and the combined length into B:

ReachRatio = hip-to-foot-target distance / (thigh length + lower-leg length)

The length-reading nodes have Initial unchecked, so they use the current bone positions. A ratio near 1 means the hip-to-foot distance is close to the length of the whole leg; a smaller ratio means the foot is closer to the hip.

The cache updates only when ReachRatio > 0.35 and < 0.94, Not(RightFootIsLocked), and RightFootIKAlpha > 0.1 are all true. These comparisons feed an And connected to If.Condition. True takes the candidate value from the previous section; False reads the current RightLegSideBlend. The result is written back to that variable. This prevents a new side choice while locked, while IK has almost no influence, or near the extension limits.

The cached value establishes the side. Next, I decide how strongly it should affect the current pose. A nearly straight knee has little room left to bend, so a full sideways turn is no longer appropriate. A deeply folded leg also gradually returns to the base guidance. Two Remap branches calculate these fades:

Extension fade: ReachRatio 0.90 → 0.97 maps to 1 → 0
Fold fade: ReachRatio 0.30 → 0.40 maps to 0 → 1
Effective side blend s = RightLegSideBlend × Min(extension fade, fold fade)

Each fade also passes through Maximum(result,0) → Minimum(result,1), then I take the smaller result. Full influence is retained between 0.40…0.90 and gradually falls to zero near either end. In the following sections, s refers to Multiply.Result in the effective right-leg side-blend region (右腿侧贴值), not a new variable. Locking preserves the cached side choice; the reach fades, knee guidance and both IK passes continue to run.

锁定保留侧向选择,伸直与折叠时减弱侧贴Holding the side choice while reducing its influence at the reach limits
7.4

把侧贴值变成膝盖的弯曲引导Turning the side blend into knee guidance

#

有了侧贴值,还要给它一个实际的空间方向。这里围绕髋到脚的轴建立基准,让髋和脚移动时,膝盖引导也能随它们更新。下文 Hip 指当前右髋 thigh_r.Translation,不是前面两髋平均的 HipCenter;Foot 指脚目标位置。

LegAxis = Unit(Foot − Hip)
WallN = Unit(WallNormalCS)
OutDir = Unit(WallN − LegAxis × Dot(WallN, LegAxis))
SideDir = Unit(Cross(LegAxis, OutDir))
Center = (Hip + Foot) × 0.5
PoleBase = Center + SideDir × 25

LegAxis 是腿的纵向。Dot → Scale → Subtract 从墙面法线中去掉沿腿轴的分量,剩下的 OutDir 指向腿轴外侧;再用 Cross(LegAxis, OutDir) → Unit 得到绕腿轴的侧向 SideDir。这样侧贴有了随腿更新的方向,而非固定朝世界某一轴偏移。这组基准使用 WallNormalCS,脚横向测量和后面的贴墙计算则使用 RightFootSurfaceNormalCS。

PoleBase 在髋脚中点上再加 SideDir × 25,其中 25 是基础引导的侧向偏移距离。实际连线必须保留这一点:它加的是 SideDir,不是 OutDir。

不过中间区的 s = 0 不应让膝盖失去引导。另一支先计算一个带离墙余量的膝盖参考:进入这段图时保存当前 calf_r 位置到 right foot正常膝盖位置,再减去 RightFootSurfacePointCS,与单位表面法线做 Dot,得到它相对接触平面的离墙距离。

N = Unit(RightFootSurfaceNormalCS)
离墙距离 = Dot(原膝盖位置 − RightFootSurfacePointCS, N)
不足量 = Max(6 − 离墙距离, 0)
避墙引导 = 原膝盖位置 + N × 不足量
侧贴引导 = PoleBase + SideDir × (s × 60)
最终 Pole = Interpolate(避墙引导, 侧贴引导, Abs(s))

6 是要补足的离墙余量;Maximum 确保距离已经足够时不反向把引导点拉回墙里。60 控制完整侧贴时,在 PoleBase 上追加多少侧向距离。s = +1 时沿 SideDir 加 60;s = −1 时沿反方向减 60;s = 0 时使用避墙引导。符号决定往哪边侧,Absolute(s) 决定两种引导混合多少。

Interpolate.Result 写入 right foot pole vector,接第一次 Basic IK.Pole Vector,Kind = Location。IK 使用 thigh_r / calf_r / foot_r,脚目标接 Effector,RightFootIKAlpha 接 Weight,不启用 Stretch。Pole 指定弯曲平面的参考,不直接把膝盖移动到该点;这里也只是基于接触平面的引导,最终膝盖位置仍由 IK 求解。

The side blend needs a direction in space. I build a reference around the axis from the hip to the foot, so the knee guidance updates as both move. Here, Hip means the current right hip position, thigh_r.Translation, rather than the two-hip average HipCenter. Foot means the foot target position.

LegAxis = Unit(Foot − Hip)
WallN = Unit(WallNormalCS)
OutDir = Unit(WallN − LegAxis × Dot(WallN, LegAxis))
SideDir = Unit(Cross(LegAxis, OutDir))
Center = (Hip + Foot) × 0.5
PoleBase = Center + SideDir × 25

LegAxis runs along the leg. Dot → Scale → Subtract removes the wall normal’s component along that axis, leaving OutDir perpendicular to the leg axis. Cross(LegAxis, OutDir) → Unit then gives the sideways direction, SideDir. The turn now has a reference that follows the leg instead of always offsetting along a fixed world axis. This basis uses WallNormalCS; the lateral foot measurement and later wall-contact calculations use RightFootSurfaceNormalCS.

PoleBase adds SideDir × 25 to the hip–foot midpoint. The value 25 sets the base pole’s sideways offset. The direction used here is SideDir, not OutDir.

In the neutral region, s = 0 should not leave the knee without guidance. Another branch builds a knee reference with some clearance from the wall. At the start of this graph, it stores the current calf_r position as the original knee reference, right foot正常膝盖位置. It subtracts RightFootSurfacePointCS and takes the Dot product with the unit surface normal to measure the knee’s distance from the contact plane.

N = Unit(RightFootSurfaceNormalCS)
Wall distance = Dot(original knee position − RightFootSurfacePointCS, N)
Missing clearance = Max(6 − wall distance, 0)
Wall-clearance pole = original knee position + N × missing clearance
Side pole = PoleBase + SideDir × (s × 60)
Final pole = Interpolate(wall-clearance pole, side pole, Abs(s))

6 is the minimum clearance this reference tries to provide. Maximum prevents it from pulling the guide point back towards the wall when there is already enough space. 60 controls the additional sideways distance from PoleBase at a full turn. At s = +1, it adds 60 along SideDir; at s = −1, it subtracts 60 in the opposite direction; at s = 0, it uses the wall-clearance pole. The sign chooses the side, while Absolute(s) determines how much the two pole positions blend.

Interpolate.Result is written to right foot pole vector and connected to the first Basic IK.Pole Vector, with Kind = Location. IK uses thigh_r / calf_r / foot_r, the foot target as its Effector, and RightFootIKAlpha as its Weight, with Stretch disabled. The pole guides the bending plane; it does not place the knee directly at that point. This guidance is based on the contact plane, while IK still determines the final knee position.

围绕髋到脚的轴,建立离墙与侧向基准Building outward and sideways directions around the leg axis
把避墙引导与侧向引导连续混合,再交给 Basic IKBlending wall-clearance and sideways guidance before Basic IK
7.5

让脚尖跟随侧贴,再求出脚掌旋转Turning the toes with the leg, then solving foot rotation

#

膝盖侧过去,脚尖也应跟着改变方向。我让它们共享实际侧贴值 s。侧贴很弱时,脚尖还可以参考第一次 IK 求出的当前小腿方向;侧贴增强后,再逐渐让侧贴方向主导。

先用 Cross((0,0,1), Unit(RightFootSurfaceNormalCS)) → Unit → Scale(−1) 得到图中的“右方向”,下文记为 R。负号规定这部分计算使用的横向正向。将 SideDir 与 R 做 Dot,就能把膝盖侧向基准表达为墙面横向的贡献:

侧贴贡献 = Dot(SideDir, R) × s × 1.0
小腿贡献 = Dot(随小腿变化的脚尖参考, R) × (1 − Abs(s)) × 0.35
脚尖水平指向 = Unit(−N + R × (侧贴贡献 + 小腿贡献))

N 是单位脚部表面法线,−N 让脚尖基本朝向墙面,横向贡献负责让它偏向侧面。当前增益 1.0 保留侧贴贡献,0.35 给小腿跟随一个较弱的补充幅度。1 − Absolute(s) 使小腿补充在完整侧贴时降为零,避免两套方向一直以同样强度同时作用。

“随小腿变化的脚尖参考”来自三次 Rotate Vector:先用初始 foot_r.Rotation 把 right foot 脚尖本地轴 旋到 Rig 空间,再用初始 calf_r.Rotation 的 Inverse 转进小腿本地,最后用当前 calf_r.Rotation 旋回 Rig 空间。这样保留初始脚与小腿的方向关系,同时让参考方向跟随当前小腿。

接下来用 Aim Math 求脚旋转。除了目标方向,它还需要知道模型本地哪根轴代表脚尖、哪根代表脚背,所以这两根轴从初始骨骼关系取得,而非直接假定模型的 X 或 Y 轴。

  1. 初始 ball_r.Translation − foot_r.Translation 给出脚踝到前脚掌的方向;以初始 foot_r.Rotation 的逆旋转它,再 Unit,得到原始脚尖本地方向。
  2. 同一个逆旋转作用于向上基准 (0,0,1),经 Unit 得到脚背本地轴。用 Dot → Scale → Subtract → Unit 从原始脚尖方向中去掉沿脚背轴的分量,得到交给 Aim 的脚尖本地轴。
  3. 单位表面法线与墙面横向经 Cross → Unit 得到墙面向上方向;取它的 0.5 倍,加到脚尖水平指向,得到“脚尖最终朝向”。这给脚尖加入向上的分量。
  4. 现有脚背目标链将墙面向上与脚尖最终朝向做 Dot,用结果缩放脚尖最终朝向,再从墙面向上中减去该向量并 Unit。这一路的输出接脚背目标方向。

Aim Math.Input Transform 接当前 foot_r;Primary.Axis / Target 分别接脚尖本地轴和最终朝向,Secondary.Axis / Target 接脚背本地轴和目标方向。两路使用 Direction,权重为 1。Result.Rotation 写入 right foot 最终脚旋转。这就得到下一节接触补偿所需的旋转。

When the knee turns sideways, the toes should turn with it. I use the same effective side blend, s, for both. With little side influence, the toes can still follow the current lower-leg direction from the first IK pass. As the side blend increases, that sideways direction gradually takes over.

First, Cross((0,0,1), Unit(RightFootSurfaceNormalCS)) → Unit → Scale(−1) produces a rightward reference along the wall, labelled 右方向 in the graph and referred to here as R. The negative sign sets the horizontal direction convention for this calculation. Taking the Dot product of SideDir and R expresses the knee’s sideways basis as a contribution along the wall:

Side contribution = Dot(SideDir, R) × s × 1.0
Lower-leg contribution = Dot(toe reference following the lower leg, R) × (1 − Abs(s)) × 0.35
Horizontal toe direction = Unit(−N + R × (side contribution + lower-leg contribution))

N is the unit foot surface normal. −N points the toes towards the wall, while the horizontal contributions turn them sideways. The gain of 1.0 keeps the side contribution unchanged; 0.35 gives the lower-leg reference a smaller supporting influence. 1 − Absolute(s) reduces that supporting influence to zero at a full side turn, so the two directions do not keep competing at full strength.

The toe reference that follows the lower leg comes from three Rotate Vector nodes. First, the initial foot_r.Rotation rotates the local toe axis, right foot 脚尖本地轴, into Rig space. The Inverse of the initial calf_r.Rotation then takes it into the lower leg’s local space. Finally, the current calf_r.Rotation brings it back into Rig space. This preserves the initial directional relationship between the foot and lower leg while allowing the reference to follow the current lower-leg pose.

I then use Aim Math to solve foot rotation. Besides the target directions, it needs to know which local axes represent the toes and the top of the foot. I derive those axes from the initial skeleton instead of assuming that the model uses X or Y.

  1. The initial ball_r.Translation − foot_r.Translation gives the direction from the ankle to the ball of the foot. I rotate it by the inverse of the initial foot_r.Rotation, then apply Unit to get the original local toe direction.
  2. The same inverse rotation transforms the up reference (0,0,1). Unit gives the local axis for the top of the foot. Dot → Scale → Subtract → Unit removes the original toe direction’s component along this axis, giving the local toe axis passed to Aim.
  3. Cross → Unit combines the unit surface normal and horizontal wall direction to produce a direction upwards along the wall. I add 0.5 times this direction to the horizontal toe direction, giving the final toe direction, 脚尖最终朝向. This adds an upward component to the toe direction.
  4. The top-of-foot target branch takes the Dot product of the wall-up direction and the final toe direction. It scales the final toe direction by that result, subtracts the vector from the wall-up direction, then applies Unit. This output becomes the target direction for the top of the foot.

Aim Math.Input Transform takes the current foot_r transform. Primary.Axis / Target take the local toe axis and final toe direction; Secondary.Axis / Target take the local top-of-foot axis and its target direction. Both use Direction, with a weight of 1. Result.Rotation is stored as the final foot rotation in right foot 最终脚旋转, ready for the contact correction in the next section.

同一个侧贴值,也决定脚尖沿墙偏向多少Using the same blend to turn the toes sideways along the wall
从真实骨骼取本地轴,再用 Aim Math 求最终脚旋转Deriving local axes from the skeleton and solving rotation with Aim Math
7.6

旋转脚掌后,反算脚踝以保持触点Repositioning the ankle to keep the foot on its contact

#

脚旋转以后,前脚掌会绕脚踝移动。即使脚踝仍在原来的位置,实际踩墙的地方也可能偏开。因此最后还要根据新旋转反算脚踝:让旋转后的前脚掌落回 RightFootSurfacePointCS。

这次仍从初始 ball_r 与 foot_r 的位置差出发,用初始脚踝旋转的 Inverse 和 Rotate Vector 得到脚踝本地空间的脚掌偏移。这里保留真实长度,不经过 Unit。上一节只需要方向,可以单位化;这一步需要脚踝到前脚掌的实际距离,单位化就会丢掉脚长。

本地脚掌偏移 = Rotate(Inverse(初始脚踝旋转), 初始前脚掌位置 − 初始脚踝位置)
旋转后的偏移 = Rotate(最终脚旋转, 本地脚掌偏移)
脚踝目标位置 = RightFootSurfacePointCS − 旋转后的偏移

第二只 Rotate Vector 的 Transform 接 right foot 最终脚旋转,Vector 接本地脚掌偏移。图中这一步的输出虽标为“脚掌世界偏移”,实际仍与 RightFootSurfacePointCS 在同一 Rig 求解空间里使用。Subtract.A 接墙面触点,B 接旋转后的偏移;从触点减去这段距离,就是脚踝应该去的位置。

把这个结果送进第二次 Basic IK.Effector.Translation,最终脚旋转送进 Effector.Rotation。这次仍使用 thigh_r / calf_r / foot_r、同一个 right foot pole vector 和 RightFootIKAlpha,因此腿按补偿后的脚踝位置重新求解,同时继续沿用前面算好的弯曲方向。

两次 IK 的 Primary Axis 都来自初始 calf_r 相对 thigh_r 的 Make Relative.Local.Translation → Unit,对应骨链实际的本地延伸方向。最后的 Set Transform 保留求解后的脚踝位置与缩放,将最终旋转写回 foot_r。

位置关系可以直接检查:脚踝目标位置 + 旋转后的偏移 = 墙面触点。这保证构造出的目标对应同一个前脚掌触点;实际骨骼能否到达,还取决于腿的可达范围和 IK 权重。

Rotating the foot moves the ball of the foot around the ankle. Even if the ankle stays in place, the point touching the wall can shift. The last step therefore works backwards from the new rotation to find an ankle position that returns the ball of the foot to RightFootSurfacePointCS.

I again start with the initial position difference between ball_r and foot_r. The Inverse of the initial ankle rotation and Rotate Vector turn this into a local ankle-to-ball offset. This time, I keep its actual length and do not apply Unit. The previous section only needed a direction, but this step needs the real ankle-to-ball distance. Normalising it would discard the foot length.

Local foot offset = Rotate(Inverse(initial ankle rotation), initial ball position − initial ankle position)
Rotated offset = Rotate(final foot rotation, local foot offset)
Ankle target position = RightFootSurfacePointCS − rotated offset

The second Rotate Vector takes the final foot rotation, right foot 最终脚旋转, as Transform and the local foot offset as Vector. Although its output is labelled as a world-space foot offset (脚掌世界偏移) in the graph, it is used in the same Rig space as RightFootSurfacePointCS. Subtract.A takes the wall contact point, and B takes the rotated offset. Subtracting that offset from the contact gives the required ankle position.

This result goes into the second Basic IK.Effector.Translation, with the final foot rotation in Effector.Rotation. This pass still uses thigh_r / calf_r / foot_r, the same right foot pole vector, and RightFootIKAlpha. It solves the leg again for the corrected ankle position while keeping the bending direction calculated earlier.

In both IK passes, Primary Axis comes from the initial calf_r transform relative to thigh_r, through Make Relative.Local.Translation → Unit. This follows the bone chain’s actual local direction. The final Set Transform preserves the solved ankle position and scale, then writes the final rotation to foot_r.

The relationship can be checked directly: ankle target position + rotated offset = wall contact point. This constructs a target for the same ball-of-foot contact. Whether the actual bones can reach it still depends on leg reach and IK weight.

保留真实脚长,反算脚踝,再执行第二次 IKUsing the actual foot length to reposition the ankle before the second IK pass
查看攀爬项目View the climbing project ↗