- 贴合在地面上运动
- 使用射线(Raycast)检测场景内物体
- 设置Layer之间的相互作用
- 在楼梯地形引导球体运动
- 在陡峭的墙面进行球体的运动或跳跃
(警告 : 跟着教程做的时候, 一定要把每一行代码都加上注释, 如果你不能加注释, 说明你没有很明白作者在干什么, 那么当教程代码越来越多, 你可能会蒙圈, 本教程将近300行代码, 作者的代码也很骚, 每句都加注释会减少一些不必要的理解成本)
(本教程内容, 有良好的线性代数基础会更易理解, 基础差话倒是不影响照着做, 只是可能会费解一些代码到底做了什么. 我为了可以完全亲自实践并理解教程中的每一步, 又跑去学线性代数和ProBuilder文档)
这是移动控制系列教程的第三部分(原文写的是”第二部分”, 我做了更正). 本篇教程将带着你深入的研究如何让球体与表面进行更多的互动.(本教程)
本教程使用Unity2019.2.14f1版本创建, 并且使用了Probuilder包(本文将在上一篇教程中完成的代码基础上继续编写本教程代码, 我也在上一篇教程的开始部分介绍了如何安装Probuilder包)
跑酷之球
贴合地面
我们的球体经过斜坡的最高点后会飞出去. 这是符合现实的, 但是并不是我们在这个教程中想看到的结果.
球体经过凹凸不平的地面时也会发生类似的情况. 我用Probuilder做了个测试场景来演示说明这个情况,(场景中是十一个相邻的平台, 第一个平台是纯平面, 其余每个平台自身都有两处高地落差位置, 并且一个平台比一个平台的落差要大, 如果懒得自己做这个场景, 可以下载作者的源代码, 里面叫做Steps的场景就是这个场景)
台阶测试场景
如果台阶不是特别高, 那么当球体获得足够的速度行进到高地落差位置时, 可以发射弹跳. 甚至在测试场景的平整跑道删也会发生球体的弹跳(我自己用ProBuilder做的场景没出现这种情况, 作者的源码的会出现, 奇怪了, 我也没进行面合并操作啊), 这是因为我将台阶的高度设置为0后并没有合并顶点. 这种情况也叫幽灵碰撞. 场景几何模型应该通过设计来规避幽灵碰撞, 不过在本例中这样就可以, 我在这里把它明确的告诉你. 在台阶落差处弹跳
现实生活中有很多技术保持球体贴靠地面. 比如F1赛车比赛的车辆通过设计可以将气流转换为向下的作用力. 因此我们也可以使用一些基于现实的类似方法去处理我们的球体.
碰撞时机
让我们思考下球体从斜坡上发射出来的时刻. 为了保持球体可以贴靠地面, 我们必须调整它的速度, 使得它可以与地表重新对齐. 让我们准确的分析下什么时候我们将会得到我们需要的信息. 当球体离开地面时, 我通过在Update方法中根据OnGround属性的值将它设置为白色 :
void Update () {
…
GetComponent<Renderer>().material.SetColor("_Color", OnGround ? Color.black : Color.white);
}
为了清晰的观察到球体弹跳的细节, 我们需要临时减慢物理系统更新的速度和时间缩放系数 :
上图对应调整了时间设置的三次物理更新, 此时FixedUpdate间隔为0.2秒, TIme.Scale为0.5
(此处用的是上个教程中的斜坡场景)
上图我们看到, 在图2那一步物理更新, 球体已经离开了地面, 但是到了下一帧才按照我们的代码逻辑变成白色, 也就是球体刚刚离开地面时, 依然被标记为处于地面, 这之后, 这之后才会不再得到与地面的碰撞数据. 所以我们判断球体是否不处于地面的逻辑总是会比实际迟一点, 但是在本例中, 这并不影响我们要做的事情, 可以不用在意.
(如果你给实际系数调整了自己试验效果, 记得再调整回去)
最后一次接触地面后的物理更新
让我们持续跟踪下, 在判断球体离开地面后, 到底进行了多少次物理更新. 添加一个新的整型字段, 并且在UpdateState方法开始时将其增加1. 随后, 如果判断球体处于地面, 则将其设置为0. 这样我们就可以观察这个字段的值的变化来得到我们想要的信息 :
//新增字段
int stepsSinceLastGrounded;
…
void UpdateState () {
//设置新增字段每次+1
stepsSinceLastGrounded += 1;
velocity = body.velocity;
if (OnGround) {
//如果球体落地, 将新字段设置为0
stepsSinceLastGrounded = 0;
jumpPhase = 0;
if (groundContactCount > 1) {
contactNormal.Normalize();
}
}
else {
contactNormal = Vector3.up;
}
}
我们不用防范整型数据溢出吗?
不需要担心这个问题, 按照我们的逻辑, 程序需要运行很久很久才会达到整型溢出的条件
贴合
添加一个新方法SnapToGround来保持球体贴合在地面上. 它返回一个布尔值, 用来表示它是否正在产生作用, 我们先临时让它返回false :
bool SnapToGround () {
return false;
}
这样我们可以通过”逻辑或”计算符”||”将该方法的返回值与OnGround的值组合在一起作为逻辑条件进行判断 :
void UpdateState () {
stepsSinceLastGrounded += 1;
velocity = body.velocity;
//if (OnGround) {
if (OnGround || SnapToGround()) {
…
}
…
}
将SnapToGround方法写在”||”操作符的右边, 就可以只在OnGround为false时才执行SnapToGround方法, 所以当SnapToGround执行时, stepsSinceLastGrounded字段还没有被重置为0. 另外, SnapToGround方法应该只在球体刚刚离开地面时将其贴合地面, 所以当stepsSinceLastGrounded大于1时, 也就是如果球体已经不是刚刚离开地面了, 依然返回false不进行贴合操作 :
bool SnapToGround () {
if (stepsSinceLastGrounded > 1) {
return false;
}
return false;
}
发射射线
我们只希望球体下面存在地面时才执行贴合地面的操作. 我们可以通过从球体位置垂直向下发射一条射线来检查是否在下方存在地面, 这需要使用Physics.Raycast方法, 并为该方法提供球体位置以及垂直向下的向量作为参数. 物理引擎会发射这条射线, 并会用返回值告诉我它是否击中了什么东西, 如果射线没有击中任何东西, 那么我们就认为球体下方不存在地面, 那么就终止方法, 不进行贴合操作 :
bool SnapToGround () {
if (stepsSinceLastGrounded > 1) {
return false;
}
//使用射线判断在球体位置正下方是否击中了什么东西, 如果没有, 表示没有地面, 那么就返回false,终止贴合方法
if (!Physics.Raycast(body.position, Vector3.down)) {
return false;
}
return false;
}
如果射线击中了什么东西, 那么我们必须要先确认这个东西是不是地面. 射线击中物体的信息可以通过第三个RaycastHit类型的参数获取, 使用out关键字修饰该参数, 使该参数可以带有返回数据 :
//if (!Physics.Raycast(body.position, Vector3.down)) {
if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit)) {
return false;
}
上面那段新代码如何工作的?
RaycastHit是结构类型, 因此它是一个值类型. 我们可以通过RaycastHit hit定义一个变量hit, 然后将hit作为第三个参数传入Physics.Raycast方法. 但是要注意, hit是一个输出参数, 这意味着它如果是一个引用类型则会传递它的引用(reference )到方法内. 你必须明确的为输出参数添加out关键字进行修饰, 这样方法就会为它分配值.
另外, 我们此处的写法中, 虽然在方法的参数列表中声明的hit, 不过由于它是一个输出参数, 所以这等同于单独使用一行对它进行声明, 也就是说, Physics.Raycast方法执行后, 我们依然可以访问到hit变量.
射线击中获取的数据包括了一个法线向量, 我们可以通过它来检查射线击中的表面是否算作是地面. 如果不是地面, 那么依然终止执行贴合地面的方法 :
if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit)) {
return false;
}
//如果击中表面的法线y小于设定的临界点乘数, 表示表面的倾斜角度过大, 不被视作地面, 终止贴合方法
if (hit.normal.y < minGroundDotProduct) {
return false;
}
与地面对齐
如果目前为止SnapToGround方法中的代码都没有执行任何一个if语句内的代码终止代码, 那么就说明目前球体已经离开了地面, 并且其正下方存在地面. 让我们先将与地面的接触点数量groundContactCount设置为1, 并让接触法线contactNormal等于射线击中的表面的法线, 然后让方法返回ture, 代替此前返回false的代码 :
if (hit.normal.y < minGroundDotProduct) {
return false;
}
groundContactCount = 1;
contactNormal = hit.normal;
//return false;
return true;
现在我们希望空中的球体应该贴合在地面上. 那么下一步就要调整球体速度, 从而让球体与地面对齐. 这类似于上个教程中与目标速度对齐所用的方法, 只不过我们需要在这时保持球体的当前速度, 而且也不使用ProjectOnContactPlane方法而是直接计算球体在下方地面上的速度分量 :
groundContactCount = 1;
contactNormal = hit.normal;
//取到当前速度的大小值
float speed = velocity.magnitude;
//当前速度与射线击中的表面的法线进行点积, 也就是得到它们二者长度之积再乘二者夹角余弦
float dot = Vector3.Dot(velocity, hit.normal);
//计算当前运动速度在下方地面平面上的投影分量
velocity = (velocity - hit.normal * dot).normalized * speed;
return true;
此时我们的球体依然没有贴合地面, 不过重力会将球体向下拉向地面. 实际上, 在上述代码设置速度之前, 运动速度可能已经有点向下方偏转了, 这时我们再调整速度会减慢球体靠近地面的过程. 因此我们应该只在速度与表面法线的点积为负时才调整速度(此处应该是作者笔误, 应该是不为负时候才调整速度):
if (dot > 0f) {
velocity = (velocity - hit.normal * dot).normalized * speed;
}
目前我们已经可以让球体在经过斜坡最高点时贴合到地面, 它们会短暂地出现不易察觉的微小浮空现象. 即使球体会在某一帧变为白色, 我们依然在FixedUpdate方法中始终认为球体是在地面平面上的. 球体变白是因为球体浮空后, 还未在下一帧执行FixedUpdate方法将球体贴到地面之前, 执行了Update中的颜色设置代码而导致的.
贴着地面运动的球体
(此处用的是上个教程中的斜坡场景)
同样的, 现在的代码也会防止球体在我们的台阶场景中发生弹跳 : 贴合台阶的平面进行运动
注意, 我们只考虑了球体下的单独一个点来判断球体是否位于地面上空. 这适合于判断平整的几何平面, 加入平面包含复杂的细节, 比如说存在凹凸不平的微小平面, 那么射线检测就可能判断错误.
最大贴合速度
更为合理的规则是, 在球体以很高的速度运动时, 才让其发生弹跳, 我们可以添加一个用来这是最大贴合速度的字段maxSnapSpeed, 设置其默认值为最大值 :
[SerializeField, Range(0f, 100f)]
float maxSnapSpeed = 100f;
最大贴合速度Max Snap speed
当球体发生弹跳时的当前速度大于maxSnapSpeed的值时, 也将终止SnapToGround方法的执行. 我们可以在使用射线检测地面的代码之前书写这种情况的判断代码 :
bool SnapToGround () {
if (stepsSinceLastGrounded > 1) {
return false;
}
float speed = velocity.magnitude;
//新增if判断当前速度是否大于贴合方法的临界速度
if (speed > maxSnapSpeed) {
return false;
}
if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit)) {
return false;
}
if (hit.normal.y < minGroundDotProduct) {
return false;
}
groundContactCount = 1;
contactNormal = hit.normal;
//下面被注释的代码移动到上面第五行处
//float speed = velocity.magnitude;
float dot = Vector3.Dot(velocity, hit.normal);
if (dot > 0f) {
velocity = (velocity - hit.normal * dot).normalized * speed;
}
return true;
}
注意, 如果你将maxSpeed和maxSnapSpeed设置为相同的值, 会获得不稳定的运行结果, 这是由于速度值的精确度所导致的. 所以你最好让maxSnapSpeed与maxSpeed之间的差距大一些, 不要过于接近
maxSpeed和maxSnapSpeed相同时出现了不稳定的运行结果
探测距离
目前, 只要在空中时下方存在地面, 我们就会将球体贴合地面运动, 无论地面距离球体有多远. 更为合理的方式是, 只检测一定距离以内是否存在地面. 我们可以设置一个探测范围, 只在该范围内检测是否存在地面. 该范围如果设置的过小, 就容易导致频繁的检测失败, 而如果设置的过大, 也会使范围限制失去意义. 让我们设置其最小值为0, 并设置默认值为1. 因为我们的球体半径是0.5. 所以该范围会检测球体下方0.5高度范围内是否存在地面 :
[SerializeField, Min(0f)]
float probeDistance = 1f;
探测距离
将probeDistance作为第四个参数传递给Physics.Raycast方法 :
if (!Physics.Raycast( body.position, Vector3.down, out RaycastHit hit, probeDistance )) {
return false;
}
忽略代理
当确认可以贴合的地面时, 要意识到我们只考虑可以代表地面的表面. 默认情况下射线会检测Ignore Raycast层外的任何物体. 另外, 用来发射射线的球体也不会被射线检测, 这是因为我们从球体的位置处向球体外面发射射线, 不过多个球体之间是可以被对方的射线所检测的, 为了避免其他球体被检测为地面, 一种可选的方法就是将所有球体的Layer都设置为Ignore Raycast, 第二种方法是自己创建一个新的Layer, 用该层忽略射线检测.
我们接下来使用第二种方法, 首先, 在任意物体的Inspector右上角, 点击Layer下拉列表, 选择”Add Layer…”, 打开Layer设置. 然后定义一个新的Layer, 命名为Agent :
在Tags&Layers设置中新增一个Layer : Agent
将球体的预制体的Layer设置为Agent, 那么所有使用预制体创建的球体也都会被设置到这一层Layer
_
下一步, 添加一个LayerMask类型的字段probeMask , 初始值设置为-1, 表示对应所有Layer :
[SerializeField]
LayerMask probeMask = -1;
之后, 在预制体中的Inspector中设置它, 将除了Ignore Raycast和Agent之外的选项全部选择(选完一个, 列表会自动关闭, 点开再选其他的即可) :
probeMask字段设置
将probeMask作为Physics.Raycast方法的第五个参数, 这样射线就只会检测它所指定的Layer内的物体 :
if (!Physics.Raycast(body.position, Vector3.down, out RaycastHit hit,probeDistance, probeMask)) {
return false;
}
跳跃与贴合
现在贴合操作可以按照Lyaer配置进行工作了, 不过还存在另一个问题, 那就是当我们按空格键主动进行跳跃时, 不应该让球体进行贴合操作, 否则我们就跳不起来了.
为了解决这个问题, 我们可以通过一个字段来记录球体在跳跃后经历了多少次物理更新, 就像是我们之前记录球体离开地面后经过了多少次物理更新一样. 之后我们可以在UpdateState中不断增加该字段的值, 同时在Jump方法中将计数清零 :
//新增字段stepsSinceLastJump
int stepsSinceLastGrounded, stepsSinceLastJump;
…
void UpdateState () {
stepsSinceLastGrounded += 1;
//UpdateState中增加跳跃之后的计数
stepsSinceLastJump += 1;
…
}
…
void Jump () {
if (OnGround || jumpPhase < maxAirJumps) {
//Jump中将stepsSinceLastJump清零
stepsSinceLastJump = 0;
jumpPhase += 1;
…
}
}
然后我们就可以在SnapToGround方法中, 判断stepsSinceLastJump的值, 来决定是否进行贴合操作. 并且由于碰撞数据存在延迟, 所以当跳跃刚刚开始时我们需要依然将球体当做处于地面上(此处逻辑挺绕…想不明白就绕过了吧, 我想的头疼, 另外说下, 原作者的代码目前给我个人的感觉是, 精于代码的简洁与灵活性, 但是可读性略差, 不同方法之间互相关联的变量非常多, 代码多了回顾逻辑会有点蒙圈) :
if (stepsSinceLastGrounded > 1 || stepsSinceLastJump <= 2) {
return false;
}
楼梯
接下来让我们考虑下更加复杂的表面 : 楼梯. 现实中的球体基本难以顺利的滚上楼梯, 但是我们可以在程序中做到. 我新制作了以下场景, 包含五个上下楼梯表面的斜坡, 每个斜坡的楼梯高度落差从一次为0.1到0.5.(如果不会或是懒得自己做这个场景, 可以下载作者的源代码, 里面叫做Stairs的场景就是这个场景)
楼梯场景
目前如果球体在爬坡时速度较慢, 则很难爬上楼梯斜坡. 将maxAcceleration设置为最大值, 将使得部分球体可以爬楼梯, 但是爬楼梯的过程也充满了颠簸, 并不是平滑的运动. maxAcceleration为100时, 充满坎坷的爬楼梯
简化碰撞体
落差较大的楼梯阻碍了球体的运动. 而低一些的楼梯虽然允许球体爬上去, 却会让球体颠颠跳跳.
为了改善球体的爬楼梯运动, 我们希望球体可以平滑的, 连贯的, 可控的, 在楼梯上运动. 我们可以将楼梯形状的碰撞体替换为平面斜坡状的碰撞体
斜坡与楼梯略微近似, 使其斜面恰好如下图所示一样切过每个楼梯中部.
简化楼梯思路图
我创建了十个楼梯尺寸相同, 形状接近的斜坡, 首先使用ProBuilder制作出它们的外形, 然后通过ProBuilder窗口的Set Collider功能将它们转换为碰撞体. 并按照上图那样, 设置每个碰撞体与楼梯的位置重叠在一起.
使用近似的碰撞体来简化楼梯的碰撞
然后我们禁用原本的楼梯物体的Mesh Collider, 不过不需要移除它们, 禁用即可, 并临时的将maxGroundAngle设置为46度, 这样球体就可以在45度的碰撞体斜坡上运动了.
将楼梯碰撞体禁用, 并使用简化的斜碰撞体代替, maxGroundAngle设置为46度
虽然制作额外的斜坡碰撞体增加了一些额外工作, 但是这是使用物理系统在楼梯斜面上控制小球的最好方式. 一般来说, 尽可能的使用近似的碰撞体来简化碰撞形状是一个好主意, 这避免了不必要的碰撞细节影响物理运动的稳定性. 所以我强烈建议使用这样的方式来处理. 由于我们使用了近似的平整斜坡代替了楼梯形状的碰撞体, 所以当近距离观察时我们会发现球体运动时已经与楼梯台阶的部分区域互相穿透了, 不过当在一定距离外观察球体运动时, 这并不很明显.
近似的碰撞斜坡, 楼梯与球体发生了部分的互相穿透
细节Layer和楼梯Layer
使用一个简化的斜坡碰撞体并不代表我们不能保留楼梯原有的碰撞体用于与球体之外的物体进行碰撞. 比如我们可能希望一些微小的残骸落在楼梯上, 而不是顺着斜坡滑下去. 这就需要我们添加两个新的Layer : 一个叫Detailed, 用于原本的楼梯物体; 一个叫做Stairs, 用于平整的斜坡碰撞体
新增的两个Layer : Detailed和Stairs
然后需要在Inspector中设置probeMask, 将Stairs选中, 不需要选中Detailed, 如下图 :
下一步, 前往菜单位置 : Edit > Project Settings… > Physics, 打开物理设置窗口, 在Layer Collision Matrix设置部分, 进行不同Layer物体之间物理碰撞关系的设置, Stairs只与Agent发生碰撞, 而Detailed则不会与Stairs和Agent发生碰撞, 如下图所示进行勾选设置 :
只有行列交叉点的复选框打勾的两个Layer, 其所属物体之间才会触发物理碰撞事件
现在, 重新启用之前禁用掉的楼梯物体的Mesh Collider组件, 并将楼梯物体的Layer设置为Detailed. 之后在楼梯上方一定高度处, 增加若干小的带有刚体和碰撞体的游戏物体作为落在楼梯上的碎屑, 你可以将它们刚体组件的质量属性Mass设置的小一些, 比如0.05, 这样球体就可以在运动时将它们推到一边. 最后, 不要忘记将平滑的斜坡碰撞体的Layer都设置为Stairs. 球体与掉落的小物体可以发生碰撞, 但是不会与楼梯物体的碰撞体发生碰撞
最大楼梯角度
如果我们能够爬上楼梯, 那么应该让楼梯也像地面一样设置一个最大的判定角度 :
//新增maxStairsAngle设置楼梯角度, minStairsDotProduct用来存储根据角度计算出的点积
[SerializeField, Range(0, 90)]
maxStairsAngle = 50f;
float minStairsDotProduct;
…
void OnValidate () {
minGroundDotProduct = Mathf.Cos(maxGroundAngle * Mathf.Deg2Rad);
minStairsDotProduct = Mathf.Cos(maxStairsAngle * Mathf.Deg2Rad);
}
将maxGroundAngle改回40, maxStairsAngle设置为50
现在使用哪个最小点积进行判断将取决于球体位于哪种类型的表面之上. 我们可以像之前添加probeMask一样再计入一个针对楼梯的Layer过滤字段stairsMask :
[SerializeField]
stairsMask = -1;
stairsMask只选择Stairs
为什么不使用LayerMask.NameToLayer(“Stairs”)指定射线的Layer?
我们用一个可配置的字段来指定射线的Layer将具有更大的灵活性, 要改变Layer设置时不需要修改代码, 只要在Inspector中进行相应设置即可, 这为调试效果带来很大方便
创建一个新的方法GetMinDot, 用来返回适合给定Layer的最小点积, 该方法接受一个代表给定Layer序号的整型参数. 假设我们可以直接使用这个参数与stairsMask进行对比, 那么如果它们不相等, 意味着需要返回判断地面用的点积, 否则需要返回判断楼梯用的点积 :
float GetMinDot (int layer) {
return stairsMask != layer ? minGroundDotProduct : minStairsDotProduct;
}
然而, stairMask的数据是二进制bit形式的, 如果代表楼梯的Layer是第十一个, 那么stairMask数据的第十一位就是1. 我们可以通过 1 << layer 来获得一个二进制值, 该语句使用按位操作符<<将1向左移动layer次, 比如假设layer是10, 那么该语句就会得到二进制数字10000000000, (layer的索引是从0开始的, 所以第十一个layer的索引就是10, 数字1左移十位得到的十一位二进制数字就代表了stairMask这个Layer, 此处原作者使用的方式有点不那么直观, 如果不能理解原理也不用太纠结, 能明白原作者在干什么就行了) :
float GetMinDot (int layer) {
//return stairsMask != layer ? minGroundDotProduct : minStairsDotProduct;
//如果1 << layer的结果等于stairsMask, 也就是10000000000, 那么说明Layer是Stairs, 否则不是
return stairsMask != (1 << layer) ? minGroundDotProduct : minStairsDotProduct;
}
上述代码修改后, 只适用于stairsMask只设置了单个Layer的判断, 不适合多个Layer组合的情况. 我们可以对判断条件进一步修改, 使用逻辑与操作符”&”, 将stairsMask和1<<layer进行计算, 得到的结果如果是0, 说明layer不存在于stairsMask的设置中 : (此处原作者使用的方式有点不那么直观, 如果不能理解原理也不用太纠结, 能明白原作者在干什么就行了)
float GetMinDot (int layer) {
//return stairsMask != (1 << layer) ? minGroundDotProduct : minStairsDotProduct;
return (stairsMask & (1 << layer)) == 0 ? minGroundDotProduct : minStairsDotProduct;
}
我们要在EvaluateCollision方法中使用GetMinDot方法获取到正确的最小点积值, 然后用来当前碰撞的表面是否是地面或楼梯 :
void EvaluateCollision (Collision collision) {
//新增minDot变量, 存储GetMinDot方法返回的值
float minDot = GetMinDot(collision.gameObject.layer);
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
//if (normal.y >= minGroundDotProduct) {
//使用minDot代替之前的minGroundDotProduct进行if判断
if (normal.y >= minDot) {
groundContactCount += 1;
contactNormal += normal;
}
}
}
同时, 在SnapToGround方法中使用GetMinDot方法来检查球体是否位于地面或楼梯之上 :
//if (hit.normal.y < minGroundDotProduct)
//如果击中表面的法线y小于计算出的最小点积数, 表示表面的倾斜角度过大, 不被视作地面或楼梯
if (hit.normal.y < GetMinDot(hit.collider.gameObject.layer)) {
return false;
}
(经过以上代码修改后, 即便maxGroundAngle设置为40, 只要你的maxStairsAngle设置的大于45度, 球体都可以贴合楼梯运动, 也就是不再依赖于使用maxGroundAngle去判断是否处于楼梯上了)
陡峭接触面 Steep Contacts
除了与地面或楼梯的接触之外, 还存在一些其他的接触面. 地面或楼梯接触面用于球体的运动, 但是有时我们只接触到了墙壁. 或是球体可以被卡在墙壁的裂缝中. 如果我们设置了空中加速度, 那么在这种情况下就也能够控制球体运动, 不过我们还可以做一些额外工作来实现更好的效果
检测陡峭接触面 Detecting Steep Contacts
陡峭接触面指的那些由于与水平面夹角过大而不被视作地面或楼梯表面. 比如垂直与地面的墙壁. 我们可以地面一样为这些陡峭表面提供一些跟踪法线以及接触数量的字段和属性 :
public class MovingSphere : MonoBehaviour
{
//存储陡峭表面的法线
Vector3 steepNormal;
//存储接触到的陡峭表面的数量
int steepContactCount;
//标志当前是否处于陡峭表面之上
bool OnSteep => steepContactCount > 0;
在ClearState方法中初始化新增的字段 :
void ClearState () {
groundContactCount = 0;
contactNormal = Vector3.zero;
//初始化新增的字段
steepContactCount = 0;
steepNormal = Vector3.zero;
}
在EvaluateCollision方法中, 如果判断法线Y分量后发现接触的不是地面或楼梯, 那么就继续判断下是不是陡峭表面. 对于完全垂直于地面的墙壁来说, 其与水平面的点积是0, 不过我们可以略微放宽这个限制, 使用-0.01来判断 :
if (normal.y >= minDot) {
groundContactCount += 1;
contactNormal += normal;
}
//使用-0.01代替0来判断是否属于陡峭表面
else if (normal.y > -0.01f) {
steepContactCount += 1;
steepNormal += normal;
}
缝隙
缝隙的存在会造成问题, 因为一旦球体在没有空中二次跳跃次数时卡在缝隙内, 就出不来了, 除非空中加速度maxAirAcceleration的值很大. 我创建了一个如下图所示的测试场景, 在平面上有一个缝隙, 用来演示说明这种情况 :
(如果不会或是懒得自己做这个场景, 可以下载作者的源代码, 里面叫做Crevasse的场景就是这个场景)
如果球体的maxAirAcceleration很小, 甚至为0, 那么当球体被卡在上图所示的陡峭平面之间时, 由于检测不到可以贴合运动的地面或楼梯面, 球体就无法进行运动了. 为了解决这种问题, 我们可以在这时退而求其次, 可以检查是否存在陡峭接触面可以用来接触并运动. 如果球体被卡在一个狭窄的缝隙空间内, 接触到了一个以上的陡峭表面, 那么我们或许可以通过向接触点的反方向推动球体来进行移动.
创建一个新的方法CheckSteepContact, 该方法将返回布尔值, 如果存在多个陡峭接触面, 那么需要将steepNormal归一化再进行比较. 如果接触法线的Y分量大于等于minGroundDotProduct, 则返回true, 否则返回false; 这种情况下不需要判断minStairsDotProduct :
bool CheckSteepContacts () {
if (steepContactCount > 1) {
steepNormal.Normalize();
if (steepNormal.y >= minGroundDotProduct) {
groundContactCount = 1;
contactNormal = steepNormal;
return true;
}
}
return false;
}
之后在下方所示的UpdateState方法的if判断条件中增加该方法, 作为第三个判断值 :
//if (OnGround || SnapToGround()) {
if (OnGround || SnapToGround() || CheckSteepContacts()) {
现在, 即便是球体被卡在陡峭面构成的缝隙中, 依然可以进行移动, 通过移动中进行跳跃, 就可以逃离缝隙.
逃离缝隙
墙壁跳跃
接下来我们要尝试让球体可以在接触墙壁时进行跳跃. 我们之前的代码只能在球体处于地面上进行跳跃, 或是在空中时进行二段跳. 现在我们要让球体可以在接触墙壁时进行跳跃, 只不过跳跃的方向将使用steepNormal来确定 :
首先我们要在Jump方法中增加代表跳跃方向的变量jumpDirection, 使用它来代替代码中的contactNormal, 并删除方法中第一个if判断条件 :
void Jump () {
//新增变量jumpDirection
Vector3 jumpDirection;
//删除第一个if判断
//if (OnGround || jumpPhase < maxAirJumps) {
stepsSinceLastJump = 0;
jumpPhase += 1;
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
//float alignedSpeed = Vector3.Dot(velocity, contactNormal);
//使用jumpDirection来代替代码中的contactNormal
float alignedSpeed = Vector3.Dot(velocity, jumpDirection);
if (alignedSpeed > 0f) {
jumpSpeed = Mathf.Max(jumpSpeed - alignedSpeed, 0f);
}
//velocity += contactNormal * jumpSpeed;
//使用jumpDirection来代替代码中的contactNormal
velocity += jumpDirection * jumpSpeed;
//}
}
接下来, 我们要增加新的if判断条件, 来为jumpDirection赋值, 从而根据不同情况决定跳跃的方向. 如果我们处于地面之上或是处于空中并可以进行二段跳, 那么跳跃方向就是contactNormal, 否则如果球体在陡峭面上, 则跳跃方向就是steepNormal, 如果上述情况均不满足, 表示球体目前无法进行跳跃, 所以直接使用return语句结束方法 :
Vector3 jumpDirection;
if (OnGround) {
jumpDirection = contactNormal;
}
else if (OnSteep) {
jumpDirection = steepNormal;
}
else if (jumpPhase < maxAirJumps) {
jumpDirection = contactNormal;
}
else {
return;
}
墙壁跳跃
(可以在墙壁前一段距离就起跳, 在空中”顶”到墙壁上之后按空格键, 就能比较明显的观察到类似上图的跳跃效果)
空中跳跃
现在我们需要重新审视之前的跳跃逻辑. 当在地面平面上进行跳跃时, 我们应该只在球体由空中再次接触任意平面后才重置jumpPhase, 而现在的代码逻辑, 在按下空格键后, 会先执行Jump方法将jumpPhase设置为+1, 在下一次物理更新时, 将先执行UpdateState方法, 此时由于groundContactCount在上一帧的OnCollisionStay方法中被处理为1, 也就是此时依然认为球体与地面有接触, 则会在UpdateState方法再次将jumpPhase设置为0, 因此我们应该输入跳跃指令后至少经过一次物理更新后才在UpdateState方法中重置jumpPhase, 从而避免错误的落地判断 : (未按原文字面翻译, 原文中对于此处逻辑的描述简直烧脑, 我用自己的话把此处代码的逻辑描述了一遍)
stepsSinceLastGrounded = 0;
//使用if语句进行判断, 至少经过1次以上的物理更新才重置jumpPhase
if (stepsSinceLastJump > 1) {
jumpPhase = 0;
}
为了保持空中二段跳依然有效, 我们要修改之前在Jump方法中检查jumpPhase的判断语句, 将”小于”修改为”小于等于”maxAirJumps :
//else if (jumpPhase < maxAirJumps) {
else if (jumpPhase <= maxAirJumps) {
jumpDirection = contactNormal;
}
然而这种做法会导致球体在没有进行跳跃的情况下落到表面后, 能在空中额外跳跃一次. 为了防止这个问题, 我们需要在这时设置jumpPhase至少为1 : (比如球体不进行跳跃, 从平面边缘滚出去进行自由落体, 原作者的意思是, 应该认为这已经算是一次空中跳跃了)
else if (jumpPhase <= maxAirJumps) {
if (jumpPhase == 0) {
jumpPhase = 1;
}
jumpDirection = contactNormal;
}
不过严格来说, 只有maxAirJumps大于0时, 这一段逻辑才有意义, 所以继续修改代码如下 :
//else if (jumpPhase <= maxAirJumps) {
else if (maxAirJumps > 0 && jumpPhase <= maxAirJumps) {
if (jumpPhase == 0) {
jumpPhase = 1;
}
jumpDirection = contactNormal;
}
最后, 让我们在发生从墙壁表面的跳跃时, 将jumpPhase重置为0, 这样就可以通过墙壁跳跃来重新进行一系列空中二段跳 :
else if (OnSteep) {
jumpDirection = steepNormal;
jumpPhase = 0;
}
空中连跳, 接触陡峭墙面, 继续连跳
偏上跳跃
现在从垂直的墙壁表面起跳后, 不会增加垂直向上的速度. 所以球体在这时起跳后会因为重力影响而偏向下跳跃, 我制作了如下的测试场景演示这个情况, 场景中包含两个间隔不远距离的墙壁物体 :
Stuck at the bottom; jump height 3.
(如果不会或是懒得自己做这个场景, 可以下载作者的源代码, 里面叫做Wall Jump的场景就是这个场景)
我们也许玩过一些带有跳跃元素的游戏, 它们在处理这种情况时, 会让跳跃对象进行偏上的跳跃. 我们也可以让球体在墙壁上的起跳偏向上方. 最简单的办法就是在Jump方法中, 对跳跃方向jumpDirection增加一个向上的向量, 然后在对相加的结果归一化, 作为最终的跳跃方向. 由于以上计算结果是两个向量的平均方向, 所以平地上的跳跃不会受到影响, 而垂直墙壁的起跳受到影响最大, 最终跳跃方向会向上偏折45度 :
//在Jump方法中赋值alignedSpeed之前, 添加下面这行代码, 将跳跃方向加上一个Vector3.up, 然后将结果归一化
jumpDirection = (jumpDirection + Vector3.up).normalized;
float alignedSpeed = Vector3.Dot(velocity, jumpDirection);
球体现在的墙壁跳跃会偏向上方
(自己运行测试时可以适当增加jumpHeight, 使得跳跃更明显)
This affects all jump trajectories that aren’t on perfectly flat ground or in the air, which is the most obvious when jumping while moving up a slope.
如果你再回到上个教程制作的斜坡场景下尝试进行在斜坡上的球体跳跃, 也会明显的发现此时的跳跃轨迹与之前没有进行偏上处理时有所不同 :
左图 : 跳跃未偏上 ; 右图 : 跳跃偏向上方
最后, 我们可以删除在Update方法中用来观察球体状态的颜色设置代码(不删也没关系)
//GetComponent<Renderer>().material.SetColor("_Color", OnGround ? Color.black : Color.white);
下个教程是环游摄像机, 将会实现更有趣的运动控制.