目录
前言
近年来,开放世界游戏热度持续攀升,自由攀爬系统已成为这类游戏的核心特色之一。攀爬机制不仅为玩家提供了更多探索路径的选择,也为地图设计拓展了新的可能性。接下来,我们将通过在Unity引擎中实现人形角色的攀爬功能,来深入了解这一系统的实现原理。

注:攀爬是角色完整动作系统中的一部分。本文仅聚焦于攀爬逻辑的实现,暂不讨论其他动作及动画相关内容。
主要实现
需要明确的是,游戏中的攀爬行为已经脱离了传统物理系统的范畴。当角色攀爬时,实际上是进入了一种特殊的"吸附"状态,沿着墙面进行移动。攀爬系统的核心就在于如何实现这种墙面吸附效果。
说到这里,你可能会想到各种复杂的攀爬表面。但无论多么复杂的表面,只要掌握了其法线方向,问题就能迎刃而解:
我们可以先编写一个辅助函数,将向量投影到特定法线所在的平面上(输出结果就是图中所示的深蓝色向量):

csharp
/// <summary>
/// 获取向量在某一平面的投影
/// </summary>
/// <param name="vector">要投影的向量</param>
/// <param name="planeNormal">平面的法线(需归一化)</param>
/// <returns>在平面的归一化投影向量</returns>
public static Vector3 GetProjectOnPlane(Vector3 vector, Vector3 planeNormal)
{
return (vector - planeNormal * Vector3.Dot(vector, planeNormal)).normalized;
}
-
如果能获取攀爬面的法线,我们就可以将角色的运动方向转化为在攀爬面上的运动方向:
csharp
var newXAxis = GetProjectOnPlane(xAxis, contactNormal); var newZAxis = GetProjectOnPlane(zAxis, contactNormal);
那现在的问题就在于如何准确获取攀爬面的法线?或许你会想到用射线检测,但遇到内角与外角的情况又该如何检测呢:


难不成要换成其它形状的碰撞检测?其实没必要这么麻烦,我们完全可以利用角色自身的碰撞体接触 来判断。大多数情况下,人形角色都是使用Capsule Collider(胶囊体碰撞盒)
,能较"均匀"地触碰接触面,比如在接触到内直角的情况下,将所有接触点的法线累加再求平均,得到的平均法线向量是接近45度,这是胶囊体曲面性质导致的。
csharp
private void OnCollisionEnter (Collision collision)
{
EvaluateCollision(collision);
}
private void OnCollisionStay(Collision collision)
{
EvaluateCollision(collision);
}
/// <summary>
/// 在OnCollisionEnter和OnCollisionStay中调用,用于获取接触到的碰撞有效信息
/// </summary>
/// <param name="other">接触的碰撞体</param>
public void EvaluateCollision(Collision collision)
{
int layer = collision.gameObject.layer;
for(int i = 0; i < collision.contactCount; ++i) //检查接触点类型并记录对应类型的法线
{
Vector3 normal = collision.GetContact(i).normal;
float upDot = Vector3.Dot(upAxis, normal);
//如果当前可以攀爬、攀爬面层级为可攀爬层级、攀爬面的倾斜角度未超过最大攀爬角度
if(isAllowedClimb && ((1<<layer) & climbMask) != 0 && upDot >= minClimbDot)
{
++climbContactCnt; //统计接触点数量,便于后续求平均
climbNormal += normal; //累加攀爬法线,便于后续求平均
lastClimbNormal = normal;
}
}
}
/// <summary>
/// 检测攀爬并更新、归一化攀爬法线,攀爬时调用
/// </summary>
/// <returns>true为可攀爬,false为不可攀爬</returns>
public bool CheckClimb()
{
if(IsClimbing)
{
if(climbContactCnt > 1)
{
climbNormal.Normalize();
//如果处于裂缝中(四周攀爬面法线和为地面),就取检测到的最后一个面为攀爬面
var upDot = Vector3.Dot(upAxis, climbNormal);
if(upDot >= minGroundDot)
{
climbNormal = lastClimbNormal;
}
}
contactNormal = climbNormal;
return true;
}
return false;
}
这里提两点:
-
为什么
upDot >= minClimbDot
可以用来判断是否更倾斜?在往期文章中,我有提到过:假设法线的长度都为1,可以发现当地面越来越陡峭时,法线在竖直方向上的投影,也就是它的cos值会越来越小,直到地面完全垂直(变成墙壁)时,这个值会变成0。所以,只要事先将「可攀爬的最大角度」的cos值算出,我们就能将角度的比较转为数值的比较。
csharp
[SerializeField, Range(90, 180), Tooltip("最大攀爬角度")] private float maxClimbAngle = 140f; minClimbDot = Mathf.Cos(maxClimbAngle * Mathf.Deg2Rad);
-
为什么要记录
lastClimbNormal
?这是为了防止类似下图这种情况,角色接触面法线的平均为
Vector3.zero
,此时角色将爬不动,故将「最后接触到的那条法线」作为攀爬面法线。
上述这些就已经能接近内角的问题了,但外角还需要一点额外处理------挤压 ,攀爬时向角色持续施加一个沿着法线向墙面的力:
csharp
float maxClimbAcceleration = 40f;//攀爬时的加速度
//用于攀爬外墙角时贴紧墙面
Velocity -= contactNormal * (0.9f * maxClimbAcceleration * Time.fixedDeltaTime);
取90%的攀爬运动的加速度作为这个力的大小,可以保证挤压力不让角色动弹不得。现在,内外角的攀爬就没有太大问题了(红色的是接触面法线):

额外调整
然而,事情并没有结束。攀爬时我们通常还会让角色始终面向攀爬面,这就需要我们在攀爬时适时旋转角色,这也不困难:
csharp
public void ClimbForward() //旋转以面向攀爬墙面
{
if(sensor.climbNormal != Vector3.zero)
{
var forwardQ = Quaternion.LookRotation(-climbNormal, upAxis);
transform.rotation = forwardQ;
}
}
而一旦这么做了,那么当你尝试攀爬以下形状的面,会发现角色频频抽搐、退出攀爬状态:

为什么会这样?我们来分析下这个过程:

- 角色沿着墙面向上爬,一切正常
- 当角色顶部接触到上斜面时,计算出的法线发生了改变,角色也要进行旋转以面向新法线
- 问题就发生在这里,角色是胶囊体,在旋转后可能就与墙面冲突了。而且旋转后接触墙面的区域也变了,法线又发生了变化,而法线一旦变化,角色又得旋转,而一旦旋转后......
不难看出,罪魁祸首其实是胶囊体(攀爬的实现逻辑不改变的话(。・ω・。))!胶囊体横向旋转时势必会影响接触区域,导致计算出的法线变化,从而带来一系列问题。除非你的角色是个球形的,这样,随便旋转都不会影响接触区域了......

是啊!不妨仅在攀爬时将角色的碰撞体进行「变形」,从胶囊体变为球体:
csharp
private void SetClimbCollider(bool isClimbing)
{
if(isClimbing)//正在攀爬时,将胶囊体的高度设为0,就变球体了
{
playerCollider.height = 0;
playerCollider.radius = climbColliderRadius;
playerCollider.center = climbColliderCenter;
}
else //退出攀爬时,将参数还原以变回原本的胶囊体
{
playerCollider.height = colliderHeight;
playerCollider.radius = colliderRadius;
playerCollider.center = colliderCenter;
}
}
除了将胶囊体高度设为0,我们还适度增加了胶囊体半径,以及将中心偏移,通常是改成能覆盖角色上半身 的情况 攀爬时,腿真的不重要了 :

万事大吉了吗?还差一步,仍是旋转的问题。

一般的人形角色的根物体位置只是角色底部中心处,平时涉及的运动也是围绕这个点进行的,但如今我们想要角色能绕调整后的球形碰撞体的球心旋转,因为只是绕根位置旋转,很可能让角色失去碰撞接触(示意图中,将角色模型简化成了棒棒糖形状):

这就相当于将根物体位置改为球心了呀,这还有点麻烦,毕竟会干扰原本的运动逻辑。除非有什么巧妙的旋转策略,鄙人倒是有个想法,定然不是最好的,大家如果自己有思路,也可以跳过这段,代码如下:

csharp
//绕攀爬时的球心(攀爬时胶囊体会变成球体)旋转,以面向攀爬墙面
public void ClimbForward()
{
/*总体思路: 先绕根位置旋转以调整面朝的方向,但旋转点并不是球心
只是这样旋转的话,必定会让球心偏离原来位置,
所以要让playerTransform补回那段距离,以让球心回到旋转前的位置
这样就实现了:既朝向了攀爬法线,球心位置又不改变 = 绕球心旋转*/
if(sensor.climbNormal != Vector3.zero)
{
var originCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
var forwardQ = Quaternion.LookRotation(-sensor.climbNormal, sensor.upAxis);
playerTransform.rotation = forwardQ;
var newCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
playerTransform.position += originCenter - newCenter;
}
}
尾声
以下是攀爬系统的核心实现说明。需要强调的是,这仅是整个动作系统的一部分,我将其从已完成项目中单独提取出来展示。由于剥离了与其他动作联动的相关变量(保留这些会使演示过于复杂),当前版本可能显得不够完整。这次分享主要是抛砖引玉,欢迎各位提出改进建议或分享自己的想法。如有不足之处,也请不吝指教(。・・)ノ