- 控制一个刚体球(Rigidbody sphere)的速度
- 通过跳跃实现垂直方向的移动
- 检测地面及其角度
- 使用ProBuilder创建测试场景
- 沿着斜坡移动
这是移动控制系列教程的第二部分. 这次我们将使用物理引擎模拟更加真实的运动效果, 并且创建更加复杂的场景.
本教程使用Unity2019.2.11f1版本创建, 并且使用了Probuilder包
(本文也将在上一篇教程滑动球体中完成的代码基础上继续编写本教程代码)
球体在跌宕起伏的赛道上放荡不羁的移动
===========翻译者补充内容开始↓↓↓========
安装Probuilder包的步骤如下 :
1) 前往菜单位置 : Window > AssetStore, 这样会打开Unity官方的资源商城窗口
2) 在顶部的搜索栏内输入ProBuilder后回车, 等待搜出下图的这个结果, 点击它的图片
3) 在打开的页面中点击 Download 按钮, 会开始下载
4) 下载完毕后这个原来的下载按钮会变成Import按钮, 点击, 进行导入, 确认若干弹窗和进度条之后安装完毕.
5) 顶部出现这个就说明安装成功了
===========翻译者补充内容结束↑↑↑========
刚体
之前的教程中我们将球体的运动限制在了一个矩形区域内, 因为我们模拟的运动效果非常简单, 所以可以完全使用代码来实现. 但是如果希望球体能够在复杂的3D场景中移动, 那我们就必须让它可以与任意的场景物体发发生互动. 此时我们不需要再自己来实现这样的运动规则, 而是可以使用Unity中已有NVIDIA物理引擎PhysX来模拟运动规则
有两种常用的方式控制一个结合了物理引擎的游戏角色. 第一种该方法通过刚体(Rigidbody)做到, 刚体可以让游戏角色表现的像是一个常规的物理物体, 此时不需要直接控制角色, 只需要对角色的刚体施加作用力来改变角色的速度即可. 第二种方法通过运动学(Kinematic)做到, 这种方法仅会查询物理引擎来执行自定义碰撞检测, 并且需要你直接控制物体.
刚体组件
我们将使用上面提到的第一种方法, 即通过刚体来实现物体的运动. 这就需要我们为球体添加刚体组件RIgidbody. 点击球体Inspector窗口下方的AddComponent按钮, 在弹出的小窗口顶部的搜索框内输入Rigidbody, 然后单击下方搜素出的同名内容即可完成刚体组件的添加, 保留刚体组件的默认属性设置即可.
刚体组件
这个组件为什么叫”刚体”?
该组件用来模拟理想刚体之间的互相作用, 所谓”理想刚体”的意思是在进行物理作用后不需要考虑形变带来的影响, 极大的简化了物理运算量.
在物理学上, 与之相对的, 也存在着软体(soft-body)这个概念, 其物理过程非常复杂也极不稳定
加入刚体组件后, 我们的球体就变成了一个物理物体, 前提是球体上依然保留着SpherCollider组件(球体默认自带的, 你不删它就一定在). 从现在开始, 球体也将按照物理引擎规则进行碰撞检测, 首先我们需要在Update方法中移除检测矩形边界的代码 :
//if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {
// if (newPosition.x < allowedArea.xMin) {
// newPosition.x = allowedArea.xMin;
// velocity.x = -velocity.x * bounciness;
// }
// else if (newPosition.x > allowedArea.xMax) {
// newPosition.x = allowedArea.xMax;
// velocity.x = -velocity.x * bounciness;
// }
// if (newPosition.z < allowedArea.yMin) {
// newPosition.z = allowedArea.yMin;
// velocity.z = -velocity.z * bounciness;
// }
// else if (newPosition.z > allowedArea.yMax) {
// newPosition.z = allowedArea.yMax;
// velocity.z = -velocity.z * bounciness;
// }
//}
随着我们移除了矩形区域边界的检测代码, 运行程序后球体再一次可以穿过边界自由移动, 并且移动给到平面之外后会因为受到重力而做自由落体运动. 这是因为我们没有重写过球体的Y坐标位置.
坠落的球体
我们不在需要配置移动范围的代码了. 我们的自定义弹性也不在需要, 删除如下代码 :
//[SerializeField, Range(0f, 1f)]
//float bounciness = 0.5f;
//[SerializeField]
//Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);
如果我们依然希望将球体的移动限制在平面范围内, 我们可以通过增加其他物体来阻挡球体的移动路径. 比如, 添加四个立方体, 缩放它们的尺寸, 设置它们的位置, 使它们成为平面四周的墙. 这样就可以防止球体移动出平面范围之外, 也就不再回发生坠落, 尽管它撞到墙的时候看起来有点怪.
此时我们还可以开启阴影, 获得更好的3D场景视觉深度.(上一课关闭了光源阴影, 如果你的关了去开启即可, 场景有阴影的话就不需要做什么了)
===========翻译者补充内容开始↓↓↓========
原文没有设置墙壁的过程, 萌新可以按照我的步骤来 :
1) Hierarchy窗口点右键, 选择菜单 : 3D > Cube, 这样就添加了一个立方体, 一共添加四个.
2) 四个立方体分别起名叫 ForwardWall, BackWall, RightWall, LeftWall
3) 按照下图设置四个立方体的Transform属性的Position和Scale :
===========翻译者补充内容结束↑↑↑========
自己添加四个内置的Cube物体, 然后通过设置它们的大小和位置将平面包围起来, 类似视频中的效果
运行游戏, 会发现球体接触到墙壁时, 会发生短促的抖动, 这书因为物理引擎和我们的代码正在”厮杀”, 都想要争夺球体的控制权. 我们之前的代码会将球体穿入墙壁内部, 但是物理引擎检测到了物理碰撞, 想要将球体推开. 如果我们在接触墙壁后停止输入移动命令, 物理引擎将因为动量的存在而保持球体的运动.
控制刚体速度
如果想解决上面的问题, 那么就应该完全由物理引擎来控制球体的运动. 直接设置物体的位置差不多相当于瞬间移动, 这并不是一种真实的物理效果, 我们也不希望如此. 所以我们应该间接的控制球体的位置, 也就是前面提到的, 对其施加作用力, 进而改变它的移动速度.
那么我们现在需要做的就是修改代码, 去设置Rigidbody组件的速度来代替设置球体的位置. 那么就需要在代码中可以访问组件数据, 让我们添加一个Rigidbody类型的字段body, 并且在Awake方法中用该字段获取到球体的Rigidbody组件 :
Rigidbody body;
void Awake () {
body = GetComponent<Rigidbody>();
}
在Update方法中删除直接设置球体位置的相关代码, 并且添加设置刚体速度的代码来取代它们 :
//Vector3 displacement = velocity * Time.deltaTime;
//Vector3 newPosition = transform.localPosition + displacement;
//transform.localPosition = newPosition;
body.velocity = velocity;
但是要注意的是, 发生碰撞后, 物理系统也会影响运动速度, 所以我们需要在每次Update时都先取出球体当前的速度(因为小球的速度可能在代码之外被物理系统更改了), 然后在当前速度基础之上再用代码去调整速度(可以自己对比下如果不这么做球体撞墙后的运动问题) :
//在设置velocity的x和z值之前, 添加下面这句代码
velocity = body.velocity;
velocity.x = Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);
velocity.z = Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);
body.velocity = velocity;
好像说过不建议直接控制速度?
控制刚体的运动时, 不建议直接设置速度是因为, 现实世界中速度的变化并不是瞬时的.
不过对于我们的教程而言, 我们是通过加速度来调整速度的, 这就已经是在模拟现实世界中速度的变化规则, 只不过是使用代码来控制这个过程. 我们很清楚的知道自己在做什么, 所以直接调整速度没有问题.
无摩擦移动
物理引擎通过代码中设置的球体速度来移动球体. 发生碰撞后, 速度会被碰撞影响, 我们的代码会随之继续调整速度, 以此类推不断进行. 现在球体的运动很像没有使用刚体时的运动情况, 不同的是速度显得有些慢, 没有达到我们设置的最大速度(实际上是达到的比较慢, 还是可以达到的). 这是因为物理引擎会对球体施加摩擦力. 摩擦力的存在虽然更符合现实, 但是却让我们的球体不容易按照我们的想法进行控制了, 所以我们需要消除摩擦力. 要消除摩擦力需要使用物理材质, 新建物理材质的菜单位置 : Asset / Create / Physic Material, 创建好后命名为Sphere Physics Material, 选中它, 在Inspector中设置它所有的属性为0, 并设置下面的两个下拉选项都为Minimum, 如下图所示 :
然后将该物理材质文件拖拽到球体的Sphere Collider组件的Material属性, 如下图所示 :
现在运行游戏, 球体不在受到任何摩擦力, 也不会被墙壁反弹(并且可以较快的达到我们设置的最大速度)
球体现在可能依然会在撞到墙时发生一点点的反弹. 会发生这种情况是因为物理引擎会在碰撞发生后移动刚体以防止发生碰撞的球体和墙壁互相穿透. 在移动速度特别快的情况下, 物理引擎在检测到碰撞发生时球体的一部分可能已经穿入了墙壁内部, 物理引擎此时需要将穿透的部分球体与墙壁分离而对球体进行移动, 所以此时会发现比较明显的类似反弹的物体移动.
如果移动速度再快一些, 就可能导致在物理引擎进行干预之前完全穿越到墙壁的另一边, 就好像墙壁很薄一样. 你可以通过设置Rigidbody组件的Collision Detection属性来避免这种问题, 但是通常只有移动速度很快时才需要设置.
同时, 由于没有了摩擦力, 球体现在是进行滑动而不是滚动, 所以我们不妨将球体在所有方向上的旋转行为全部锁定, 你可以在Rigidbody组件的Constraints属性中勾选三个坐标轴的Freeze Rotation选项来实现这个效果, 如下图所示 :
锁定三个轴的旋转
Fixed Update
物理引擎每隔固定时间进行物理模拟计算, 这与运行时的帧率无关. 尽管我们已经通过物理引擎控制了球体, 但是我们依然会影响球体的速度. 那么就最好是可以在固定的时间间隔来调整它的速度. 我们可以将Update方法的代码分为两部分. 一部分代码保留在Update方法中, 用来检测输入指令并设置目标速度. 另一部分设置速速的代码, 可以移动到新增的FixedUpdate方法中. 此外我们还需要新增一个字段用来存储和传递目标速度的值 :
//新增字段用来存储目标速度
Vector3 desiredVelocity;
void Update () {
Vector2 playerInput;
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
playerInput = Vector2.ClampMagnitude(playerInput, 1f);
//Vector3 desiredVelocity =
desiredVelocity = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
//设置目标速度之后的代码全部移动到FixedUpdat方法之中, 如下所示
}
void FixedUpdate () {
velocity = body.velocity;
float maxSpeedChange = maxAcceleration * Time.deltaTime;
velocity.x = Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);
velocity.z = Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);
body.velocity = velocity;
}
FixedUpdate方法会在每一次物理模拟开始时被执行. 物理模拟的频率取决于每两次物理模拟之间的时间间隔, 默认是0.02, 也就是每秒五十次, 你可以通过菜单 : Edit / Project Settings / Time或是Time.fixedDeltaTIme来设置该时间间隔.
可以在FixedUpdate方法中使用Time.deltaTime吗?
可以.
当FixedUpdate方法被调用时, TIme.deltaTime的值与Time.fixedDeltaTime的值相等
每调用一次Update方法, FixedUpdate方法可能会被调用0次, 或1次, 或多次, 这取决于你的运行时帧率. 每一帧会先进行一系列FixedUpdate方法调用检查, 然后调用Update, 然后才将本帧画面进行渲染呈现. 当物理模拟的间隔时间远远大于每帧的时时, 这可能导致物理模拟结果显得非常离散(如果听不懂”离散”, 你可以简单理解为物理运动显得非常不流畅), 下面的视频展示了物理模拟间隔设置为0.2秒时的情况, 也就是一秒进行五次物理模拟计算 : 每0.2秒一次物理模拟的运行效果, 运动非常不流畅
你可以通过减少物理模拟时间间隔或是启用刚体组件的插值模拟计算属性Interpolate来解决该问题. 设置插值模拟计算为Interpolate选项, 会使得球体在上次位置和当前位置之间进行线性插值计算, 所以这样会导致它的位置变化会比物理引擎实际计算的位置变化滞后延迟一些; 如果设置插值模拟计算为Extrapolate, 那么则会根据球体的当前位置与当前速度来插值推算出它应该运动到的位置, 该种插值模式只推荐在物体拥有比较固定的运动速度时使用.
依然是0.2秒的物理模拟间隔, 但是刚体设置了interpolate插值模式
注意, 增加时间间隔意味着球体会在每次物理模拟计算时移动更远的距离, 这就更可能导致它在使用离散型碰撞检测时候发生穿墙情况.
跳跃
此时我们的球体已经可以在3D物理世界中移动, 记下来让我们赐予它跳跃的能力
跳跃指令
我们可以使用Input.GetButtonDown(“Jump”)来检测玩家在本帧是否按下了跳跃键, 默认情况下跳跃键是键盘的空格键. 我们可以在Update方法中检测按键, 但是就像是调整球体的速度一样, 我们需要在检测到按键后的下一次FixedUpdate方法中进行球体的跳跃运动, 所以需要通过一个布尔类型的字段desiredJump来监测球体是否需要进行跳跃 :
//新增字段用来指示跳跃键的按键状态
bool desiredJump;
void Update () {
//在Update中设置该字段的值为是否按下了跳跃键
desiredJump = Input.GetButtonDown("Jump");
但是上述代码存在一个问题, 那就是下一帧不一定会调用FixedUpdate方法, 这就会导致下一帧再次执行Update时该字段又会变成false, 导致我们错误的丢失了跳跃按键指令. 我们可以通过对desiredJupm字段的当前值和跳跃按键的状态值进行”逻辑或”操作, 将操作结果再赋值给desiredJump, 从而避免该问题. 这样操作后, 如果我们不明确设置desiredJump为false, 它将不会由true变成false, 具体代码如下 :
//desiredJump = Input.GetButtonDown("Jump");
//如果desiredJump的值为true, 那么无论是否按下了跳跃键, 以下代码将始终设置desiredJump为true
desiredJump |= Input.GetButtonDown("Jump");
接下来在FixedUpdate方法中, 在为球体赋值计算出的运动速度之前, 检查球体是否应该进行跳跃运动. 如果应该进行跳跃, 首先将desiredJump字段重置为false, 然后调用一个新的方法Jump, 该方法会在球体的Y轴上设置5的速度, 模拟一次突然向上的加速运动 :
void FixedUpdate () {
…
//球体赋值运动速度之前, 判断下是否需要进行跳跃
if (desiredJump) {
desiredJump = false;
Jump();
}
body.velocity = velocity;
}
void Jump() {
velocity.y += 5f;
}
上述代码会让球体发生向上的跳跃移动, 并且会最终因为重力而在此掉回地面.
跳跃高度
让我们限制下球体可以跳跃的最大高度. 我们可以直接控制跳跃速度从而限制其跳跃高度, 但是这样做并不能直观的感受到限制了多少跳跃高度. 更方便的一种方法是直接控制跳跃高度本身, 首先添加以下字段 :
[SerializeField, Range(0f, 10f)]
float jumpHeight = 2f;
Inspector中显示出了新增的这个字段
跳跃需要克服重力, 所以垂直方向需要多少速度取决于重力. 计算公式为 其中g是重力系数, h是限制的跳跃高度. 符号是因为重力加速度g的方向是负的, 如果没有负号会导致开方负数出现错误. 我们可以通过Physics.gravity.y来得到重力系数, 该值同样可以在项目设置的中进行配置. 本教程使用默认的重力值-9.81即可, 重力垂直向下, 与地球的平均重力方向一致, 根据上述速度计算公式, 修改Jump方法的代码如下 :
void Jump () {
//velocity.y += 5f;
velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
达到目标高度所需的速度公式是怎么得出来的?
设目标高度为h, 重力加速度为g, 运动时间为t
首先, 球体运动到h后速度变为0, 花费了时间t, 将该过程看成从h高度落到地面的自由落地, 则可以得出
又已知速度等于加速度乘时间, 即从而可以推到出
将带入到中可得, 将该导出式进行化简可得进而可以推导出
最后, 由于在Unity中取出的重力加速度是负值, 所以还需要对根号内的公式加个符号使其变为正值, 这样就得到了最红的速度公式
注意, 由于物理计算的离散性, 球体很最终高度很可能比设置的高度低一点点(只要你的物理模拟间隔时间不长就不易察觉), 这是因为可能在某次时间间隔内是球体达到最高值的时刻.
在地面时才允许跳跃
现在球体可以在任意状态进行跳跃, 即便是它已经跳起来了, 这回导致不断按跳跃键它就会远走高飞. 一个合理的行为应该是只有球体接触地面时才可以进行跳跃. 刚体不能直接告诉我们它是否接触地面, 但是我们可以监视它是否与地面发生了碰撞, 进而得知球体是否位于地面上.
如果MovingSphere脚本中添加了OnCollisionEnter方法, 那么就会在物理引擎监测到新的碰撞事件时调用该方法. 如果物理引擎发现发生碰撞的物体分离开了, 并且此时脚本中添加了OnCollisionExit方法, 那么就会调用这个方法.
让我们在MovingSphere脚本中添加上面提到的两个方法, 并且新增一个叫做onGround的布尔类型字段, 让它在发生碰撞时变为true, 离开被碰撞物体时设置为false :
bool onGround;
void OnCollisionEnter () {
onGround = true;
}
void OnCollisionExit () {
onGround = false;
}
现在我们可以让球体只在onGround为true时才执行跳跃代码 :
void Jump () {
//只有onGround为true时候才执行跳跃代码
if (onGround) {
velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
}
上述代码存在一个小问题, 那就是如果球体在地面时接触墙壁然后再离开, 就无法进行跳跃了. 因为离开墙壁时触发了OnCollisionExit方法, 将onGround设置为了false, 但是此时球体实际是接触着地面的. 这个问题的解决方法就是不再通过OnCollisionExit方法判断球体是否进行了跳跃, 并且新增OnCollisionStay方法, 该方法会在每次物理模拟时检测是否有持续的碰撞接触, 如果有, 则调用该方法, 我们在该方法内设置onGround字段为true :
//void OnCollisionExit () {
// onGround = false;
//}
void OnCollisionStay () {
onGround = true;
}
每次物理模拟都会先调用所有的FixedUpdate方法, 之后才会调用与碰撞相关的方法. 因此, 当FixedUpdate方法执行完毕后, 如果存在任何碰撞接触, 都会调用OnCollisionStay方法从而将onGround设置为ture. 根据以上情况, 我们需要做的就是在FixedUpdate方法的末尾处设置onGround为false, 从而在球体跳跃到空中时关闭对跳跃指令的响应 :
void FixedUpdate () {
…
//在FixedUpdate方法的最后添加下面的代码
onGround = false;
}
现在球体就可以始终能够在于其他物体接触的情况下进行跳跃
不因接触墙壁而允许跳跃
使球体可以通过接触任何物体而被允许跳跃意味着我么可以在空中接触墙壁来代替接触地面进行跳跃. 如果我们不希望发生这个情况, 就需要可以区分球体是否接触的是地面.
我们可以发现, 我们设置的地面是水平的一个平面. 那么就可以检查被球体所碰撞的物体的接触点的法线向量(normal vector)是否满足这个条件.
什么是法线向量?
它是一个表示方向的单位长度向量. 通常指向远离物体的方向. 比如, 一个平面中所有点的法线向量全部相同, 而一个球体上的每个点的法线向量方向都指向远离球心的方向, 各不相同.
球体发生碰撞后, 与被碰撞物体之间存在一个接触点. 有时球体会穿透一丢丢被碰撞物体, 不过物理引擎随后会将球体推离被碰撞的物体以解决穿透问题. 此时这个推离球体的方向, 就是接触点在屏幕上的法线向量方向. 对于我们使用的球体, 这个推离的方向由接触点指向它的球心.
接触点与法线(normal)
实际上在物理系统中发生的碰撞可能会比上面描述的情况要复杂混乱一些, 接触点也可能不止一个, 甚至穿透情况也会持续多个物理模拟过程, 但是我们此时不需要担心这些. 因为对于我们的球体与平面发生的碰撞, 只会有一个碰撞接触点. 不过如果是带有凹面的网格碰撞器, 就可能会存在多个接触点(所以后面与碰撞点检测有关的代码还是考虑了这种可能情况, 会假设不确定有多少个接触点, 而进行接触点的遍历)
我们可以向OnCollisionEnter方法和OnCollisionStay方法添加一个Collision类型的参数来获得碰撞信息. 并且我们还将使用一个新增的方法EvaluateCollision来取代之前设置onGround字段的代码, 还要将碰撞信息传递给这个方法 :
void OnCollisionEnter (Collision collision) {
//onGround = true;
EvaluateCollision(collision);
}
void OnCollisionStay (Collision collision) {
//onGround = true;
EvaluateCollision(collision);
}
void EvaluateCollision (Collision collision) {
//方法暂时为空
}
碰撞时的接触点总数可以通过Collision.contactCount属性获得. 我们可以通过该属性与GetContact方法对碰撞发生的所有接触点进行遍历, 进可以获取到每个接触点的法线属性normal, 修改EvaluateCollision方法如下 :
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
}
}
法线代表了球体将被推动的方向, 也就是直接远离碰撞表面的方向. 假设碰撞的是水平平面, 那么它的法线方向就是垂直指向上方向, 也就是该法线代表的向量就是(0, 1, 0). 所以如果我们发现接触点的法线方向是这样的, 也就说明球体碰撞的是地面. 不过我们还是要考虑到一些微小的误差, 所以让我们设置检查向量Y值的标准为大于等于0.9, 而不是必须等于1, 代码如下 :
Vector3 normal = collision.GetContact(i).normal;
//如果接触点的法线y值大于等于0.9, 则认为球体接触的是地面
onGround |= normal.y >= 0.9f;
空中跳跃
到此为止, 我们只能在球体接触地面时进行跳跃, 但是很多游戏都允许在空中进行二段甚至三段跳. 让我们支持可配置的空中多段跳功能, 首先添加字段maxAirJumps字段代表可以进行的空中连跳次数 :
[SerializeField, Range(0, 5)]
int maxAirJumps = 0;
Inspector中的Max air jumps属性
接下来我们还需要跟踪跳跃状态处于什么阶段, 以便于判断是否还能进行空中多段跳. 我们可以通过一个整数字段jumpPhase来记录以及进行的跳跃次数, 如果球体接触地面则设置该字段为0. 并且我们还要将FixedUpdate中获取球体速度的代码与设置jumpPhase的代码都放到一个新增的方法UpdateState中, 保持FixedUpdate方法代码的简洁性 :
//新增jumpPhase字段
int jumpPhase;
…
void FixedUpdate () {
//velocity = body.velocity;
//使用下面的代码代替上面这句代码
UpdateState();
…
}
void UpdateState () {
velocity = body.velocity;
//如果球体位于地面, 则设置jumpPhase为0, 表示一次空中连跳也没跳过
if (onGround) {
jumpPhase = 0;
}
}
我们还需要在每次跳跃的时候增加jumpPhase的值, 同时将跳跃的条件修改为处于地面或jumpPhase小于最大的空中连跳次数 :
void Jump () {
//if (onGround) {
if (onGround || jumpPhase < maxAirJumps) {
//每次跳跃时jumpPhase加1
jumpPhase += 1;
velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
}
为什么是”小于”, 而不是”小于等于”maxAirJumps?
每次重新接触地面的时候都会将jumpPhase设置为0, 后面的教程会解释为什么要这么做.
限制向上速度
快速连续的空中连跳会给予球体比较大的向上速度从而比一次跳跃跳的更高. 我们接下来要控制跳跃速度不会超过单词跳跃可以达到的速度. 第一步需要在Jump方法中将跳跃速度的计算结果存储到一个变量jumpSpeed中, 单独的计算跳跃速度 :
void Jump() {
if (onGround || jumpPhase < maxAirJumps) {
jumpPhase += 1;
//velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
//上面的代码被拆分为下面的两部分代码
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
velocity.y += jumpSpeed;
}
}
如果球体已经具有了一个向上的速度, 那么在将其新的跳跃速度加个球体之前, 需要先减去球体当前的向上速度, 这样球体就绝不会超过我们设定的向上速度了 :
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
//新增下列if语句块, 当球体的y速度大于0时, 在计算出的jumpSpeed中减去当前y速度
if (velocity.y > 0f) {
jumpSpeed = jumpSpeed - velocity.y;
}
velocity.y += jumpSpeed;
但是如果我们向上的运动速度已经比计算出来的跳跃速度还要大, 我们不希望会导致球体在跳跃过程中被二段跳减速, 所以我们可以在需要增加的速度与0之间取较大的那个值(感觉这一步多此一举, 经过上面代码的限制, 垂直速度应该是不可能超过根据计算得出的跳跃速度的) :
if (velocity.y > 0f) {
//jumpSpeed = jumpSpeed - velocity.y;
jumpSpeed = Mathf.Max(jumpSpeed - velocity.y, 0f);
}
空中移动
我们现在并不关心控制球体时它是在地面上还是在空中, 不过让球体在空中时更难操控会显得更合理. 让我们通过添加一个单独的, 默认值为1的最大空中加速度, 将球体在空中时的操控性变得可以配置. 它可以将球体在空中时的操控性大幅降低, 但是又不会完全消失, 首先添加一个代表最大空中加速度的字段maxAirAcceleration :
[SerializeField, Range(0f, 100f)]
float maxAirAcceleration = 1f;
现在有了两个加速度 : 一个最大加速度, 一个最大空中加速度
那么现在我们在FixedUpdate方法中计算最大速度时到的加速度值将取决于球体是在地面上还是在空中 :
//float maxSpeedChange = maxAcceleration * Time.deltaTime;
//使用问号表达式来设置加速度值, onGround如果为true, 则使用maxAcceleration赋值, 否则使用maxAirAcceleration赋值
float acceleration = onGround ? maxAcceleration : maxAirAcceleration;
float maxSpeedChange = acceleration * Time.deltaTime;
斜坡
我们已经使用物理系统在一小块平面上移动我们的球体, 并可以与墙壁发生碰撞, 而且还可以跳跃. 是时候考虑制作更复杂的场景环境了. 在剩余的教程内容中, 我们要研究研究斜坡上的运动
ProBuilder测试场景
(如果准备自己制作, 新建一个场景吧, 这样不乱)
你可以通过旋转一个平面或是立方体来制作一个斜坡, 但是用这种方式制作我们需要用到的场景并不方便, 所以我们需要导入ProBuilder包并用它来制作一些斜坡. ProBuilder使用起来非常简单, 单也需要一些时间来适应, 本教程中将不会详细如何使用ProBuilder(以下模型制作是通过ProBuilder + ProGrid这俩包完成的, 使用ProBuilder制作简单的模型的教程有兴趣的自行搜索吧, 弄不明白的建议直接下载原作者提供的项目源码, 自己打开看现成的吧. 依次点击下图红圈所示按钮进行下载, 项目中的Slopes场景就是本例场景).
如何下载原作者的源代码
我使用ProBuilder cube制作了一个斜坡, 其设置其尺寸为(20, 5, 3,), 然后将其顶部平行于Z轴的两条边合并为一条边, 并居中, 这样就得到了如下图所示的带有两个斜面的斜坡物体
再复制九个上述斜坡, 将它们十个相邻的放置在一个尺寸为(40, 0, 70)的平面上, 并依次将它们的高度由1设置到10, 即尺寸由(20, 1, 3)变化为(20, 10, 3);
然后添加十个(20,10,3)尺寸的斜坡, 与已经防止的斜坡继续相邻防止, 并且将新的十个斜坡顶部的边依次向左侧偏移一个Unity单位长度, 使得第十个斜坡的侧面变成一个直角三角形.
最后一步, 使用之前的球体制作一个预制体, 然后在该场景内放置21个预制体的实例, 沿着斜坡的队列排成一条直线, 并且依次与每个斜坡对齐, 除了第一个球体面对的是彻底的水平面.(预制体脚本的各个属性使用下图所示的初始值)
球体预制体脚本的属性初始值
最终场景完成样子可以参考下图(图中Z轴正方向向左), 图中为了便于识别, 进行了一些颜色设置 :
斜坡场景搭建完毕
测试坡度
因为所有的球体在前半部分教程中都已经与我们的输入指令相关联, 所以可以同时控制所有的球体进行运动, 这样我们就可以一次性的测试球体与不同坡度互动时的行为. 我们测试的方法就是在运行程序后, 持续按住右箭头按键
运行后我们发现, 前五个球体很顺利的滚过了它们面前的斜坡, 而第六个球体则比较吃力的缓慢通过了它面前的斜坡, 其他球体则完全被它们面前陡峭的坡度所阻挡, 没有滚到斜坡的另一侧.
由于我们之前的设置中, 球体在空中也能通过输入得到一定的加速度, 所以让我们修改一下, 把球体预制体的maxAirAcceleration设置为0. 这样所有的球体就只有在接触水平地面时才能进行获得加速度了.
上图: 空中加速度为1, 滚过去六个球; 下图: 空中加速度为0, 只滚过去5个球
我们会发现将maxAirAcceleration设置为0后再次运行, 结果前五个球的运动结果几乎不受影响.
但是第六个球却不能像之前一样移动到斜坡的另一侧了, 在之前它本来可以通过空中加速度来获取足够的通过斜坡的速度.
而剩下的其他球体也比上一次更早的因重力而停止了向前的移动, 这是因为它们面前的斜坡都太陡峭了, 它们的速度不足以使它们通过这样的坡度.
地面角度 Ground Angle
目前我们是通过判断一个平面的法线y方向是否大于等于0.9来确定它是不是地面的, 不过这个判断值可以任意更改, 在0到1的范围内变化判断地面的法线条件值, 会得到截然不同的运行结果, 如下图, 将0.9依次更改为0和1进行测试 :
上图 : 使用1检测是否是地面; 下图 : 用0检测是否是地面
在代码中添加一个新的字段maxGroundAngle来代表地面的最大倾斜角度, 之所用角度而不是类似0.9这样的法线Y轴值, 是因为角度会更易理解. 设置其默认值为25度 :
[SerializeField, Range(0, 90)]
float maxGroundAngle = 25f;
最大地面角度
水平面的法线的Y分量是1. 对于完美的垂直于地面的墙壁表面, 其法线Y分量为0. 法线Y分量的值实际上就是地面与水平面夹角的余弦值. 此处我们使用单位圆来打比方的话, 圆内某直径代表地面, 那么圆心处的法线Y分量实际上就是法线向量与(0, 1, 0)向量的点积
倾斜角度的余弦值
什么是点积(dot product)?
两个向量之间的点积, 其几何学定义是 . 意思就是点积就是两个向量夹角的余弦 再乘以它们各自的长度. 所以对于单位长度向量来说 .
其代数定义是 . 也就是将两个向量对应轴上的值两两相乘再求和 : float dotProduct = a.x b.x + a.y b.y + a.z * b.z;
在视觉上, 点积操作将一个向量直接投影到另一个向量上, 就好像是在它上面产生影子一样. 投影后可以得到一个直角三角形, 其下方直角边的长度就是点积的结果. 如果两个向量都是单位长度, 那么这就是它们夹角的余弦值
点积
我们的角度配置定义了可以被视作地面的最小余弦结果. 我们在新增的OnValidate方法中使用Mathf.Cos来计算它, 并将结果存储到一个新的字段minGroundDotProduct中. 如果我们在运行时通过Inspector修改了配置的角度值, 就会自动的调用OnValidate方法, 来更新角度变化后的余弦值, 同时, 在Awake方法中也调用一下OnValidate方法, 这样在构建中也能够对它进行计算(原文 : Also invoke it in [Awake](http://docs.unity3d.com/Documentation/ScriptReference/MonoBehaviour.Awake.html)
so it gets calculated in builds, 有点含糊, 估计指的Build之后的程序) :
float minGroundDotProduct;
void OnValidate () {
minGroundDotProduct = Mathf.Cos(maxGroundAngle);
}
void Awake () {
body = GetComponent<Rigidbody>();
OnValidate();
}
我们指定了一个角度, 但是Mathf.Cos方法需要的是一个弧度值, 所以我们可以将角度值乘以Mathf.Deg2Rad来得到角度所对应的弧度 :
//minGroundDotProduct = Mathf.Cos(maxGroundAngle);
minGroundDotProduct = Mathf.Cos(maxGroundAngle * Mathf.Deg2Rad);
现在我们可以调整最大地面夹角来看看会对球体的运动产生什么影响. 现在我要把maxGroundAngle设置为40, 修改EvaluateCollision方法中的代码如下 :
//onGround |= normal.y >= 0.9f;
onGround |= normal.y >= minGroundDotProduct;
上图 : 最大地面夹角为25度; 下图 : 最大地面夹角为40度
为什么运行时改变预制体属性不会影响球体的行为?
在运行模式下预制体不会与其实例进行属性的同步
在斜坡上跳跃
目前我们的球体总是向正上方跳跃, 无论此时所处的地面角度是多大
总是直直的跳跃
其实我们可以让球体沿着表面法线的方向进行远离表面的跳跃, 这样做之后, 不同的斜坡上球体的跳跃会显得不太一样
我们需要在球体通过EvaluateCollision方法中判断接触任意地面时, 将地面法线方向存储到一个新增的字段contactCollision中 :
Vector3 contactNormal;
…
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
//onGround |= normal.y >= minGroundDotProduct;
//注释上方代码, 新下方的if语句块
if (normal.y >= minGroundDotProduct) {
onGround = true;
contactNormal = normal;
}
}
}
但是我们可能会在不接触地面的情况下进行空中跳跃. 这种情况下我们需要使用上方向作为法线方向, 所以空中跳跃依然会垂直向上. 在UpdateState方法中修改代码如下 :
void UpdateState () {
velocity = body.velocity;
if (onGround) {
jumpPhase = 0;
}
//新增else语句块
else {
contactNormal = Vector3.up;
}
}
现在我们需要在跳跃时, 将被跳跃速度缩放过的法线方向向量添加到球体的运动速度中, 代替之前直接为Y方向运动速度直接赋值的做法. 之前我们设置的最大跳跃高度只能代表在水平地面上或是在空中跳跃时的最大高度, 在斜坡上的跳跃由于不再是垂直向上, 所以无法达到最大跳跃高度, 修改Jump方法的代码如下 :
void Jump () {
if (onGround || jumpPhase < maxAirJumps) {
…
//velocity.y += jumpSpeed;
//跳跃速度将不再是简单的赋值Y方向, 而是根据地面的法线方向进行计算
velocity += contactNormal * jumpSpeed;
}
}
同时, Jump方法中检查球体y轴方向速度的代码也不再适用, 因为现在跳跃的方向不一定是垂直的. 所以我们需要检查球体在法线方向上的速度分量. 也就是我们需要得到球体运动速度在法线上的投影向量, 我们在前面解释过, 点积其实就是一种向量的投影结果, 所以我们可以使用Vector3.Dot方法来计算运动速度与法线的点积, 得到球体在法线方向上的运动分量, 也就是代表当前的跳跃速度, 然后进行与之前处理垂直跳跃速度类似的速度限制逻辑 :
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
//if (velocity.y > 0f) {
// jumpSpeed = Mathf.Max(jumpSpeed - velocity.y, 0f);
//}
//新增变量alignedSpeed存储计算出来的法线方向运动分量
float alignedSpeed = Vector3.Dot(velocity, contactNormal);
//新增if判断, 如果法线方向运动分量不为0, 对本次的跳跃速度值进行调整
if (alignedSpeed > 0f) {
jumpSpeed = Mathf.Max(jumpSpeed - alignedSpeed, 0f);
}
velocity += contactNormal * jumpSpeed;
沿着法线方向远离表面的跳跃
现在球体将根据所处斜坡的坡度而进行跳跃, 产生各自独特的跳跃轨迹. 那些较为陡峭斜坡上的球体跳跃时不再是向着移动方向跳跃过去, 而是在反方向上进行了移动. 把maxSpeed设置为1会非常明显的观察到这一现象 :
maxSpeed设置为1时, 部分球体在斜坡上会向运动的反方向跳跃
沿着斜坡移动
目前位置, 无论球体所处的地面角度如何, 我们都是基于XZ屏幕来为球体设置运动速度. 球体之所以可以沿着斜坡运动, 是因为物理引擎在球体与斜坡发生碰撞时向上推动了球体. 虽然我们通过这个方式让球体爬坡, 但是当球体下坡时, 如果它们的加速度足够大, 就会离开斜坡表面, 并最终因重力而掉落, 这导致我们对球体的控制性降低. 如果你设置maxAcceleration设置为最大值100, 你会清晰的观察到这个现象 :
maxAcceleration设置为100
(这里不是持续按住右箭头模拟出来的, 而是按住右箭头等小球运动到斜坡一半没有过顶峰时, 按左箭头, 为的就是观察球体从斜坡下降时的行为, 建议设置maxSpeed为10)
我们可以通过让速度方向沿着地面来避免这种情况. 这与运动速度投影到法线获得跳跃速度的原理类似, 区别是我们现在要将水平速度投影到地面平面来获得一个新的速度. 创建一个新的方法ProjectOnContactPlane来处理这个计算过程, 该方法需要一个Vector3类型的参数 :
地面速度投影
Vector3 ProjectOnContactPlane (Vector3 vector) {
return vector - contactNormal * Vector3.Dot(vector, contactNormal);
}
为什么不直接使用Vector3.ProjectOnPlane方法?
该方法的作用相同, 但它不假定提供的法线向量是单位长度. 它会将结果除以法线长度的平方, 法线长度总是1, 所以不用.(无奈 看不懂这一段儿到底是在说啥, 我用Vector3.ProjectOnPlane代替了ProjectOnContactPlane方法发运行效果也是一样的, 有点懵)
让我们在创建一个新的方法AdjustVelocity用来调整运动速度. 根据在接触平面向右和向前的向量投影来确定投影的X轴和Z轴 :
void AdjustVelocity () {
Vector3 xAxis = ProjectOnContactPlane(Vector3.right);
Vector3 zAxis = ProjectOnContactPlane(Vector3.forward);
}
这样我们就得到了与地面对齐的向量, 不过只有当地面完全水平时它才是单位长度. 通常我们需要归一化向量来获得恰当的方向 :
//Vector3 xAxis = ProjectOnContactPlane(Vector3.right);
Vector3 xAxis = ProjectOnContactPlane(Vector3.right).normalized;
//Vector3 zAxis = ProjectOnContactPlane(Vector3.forward);
Vector3 zAxis = ProjectOnContactPlane(Vector3.forward).normalized;
现在我们可以将当前速度投影到投影平面的两个轴来获得对应的投影X轴速度和投影Z轴速度 :
Vector3 xAxis = ProjectOnContactPlane(Vector3.right).normalized;
Vector3 zAxis = ProjectOnContactPlane(Vector3.forward).normalized;
//新增下面两行代码
float currentX = Vector3.Dot(velocity, xAxis);
float currentZ = Vector3.Dot(velocity, zAxis);
接着我们就可以想之前一样计算速度变化, 只不过现在是计算相对于地面平面的速度而不是相对于XZ平面 :
float currentX = Vector3.Dot(velocity, xAxis);
float currentZ = Vector3.Dot(velocity, zAxis);
//新增以下代码
float acceleration = onGround ? maxAcceleration : maxAirAcceleration;
float maxSpeedChange = acceleration * Time.deltaTime;
float newX = Mathf.MoveTowards(currentX, desiredVelocity.x, maxSpeedChange);
float newZ = Mathf.MoveTowards(currentZ, desiredVelocity.z, maxSpeedChange);
最后, 沿着相对的轴添加新速度和旧速度之间的差异来调整运动速度 :
float newX = Mathf.MoveTowards(currentX, desiredVelocity.x, maxSpeedChange);
float newZ = Mathf.MoveTowards(currentZ, desiredVelocity.z, maxSpeedChange);
//调整运动速度
velocity += xAxis * (newX - currentX) + zAxis * (newZ - currentZ);
接着我们在FixedUpdate方法中调用这个新增的方法, 替代之前的速度调整代码 :
void FixedUpdate () {
UpdateState();
//float acceleration = onGround ? maxAcceleration : maxAirAcceleration;
//float maxSpeedChange = acceleration * Time.deltaTime;
//velocity.x = Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);
//velocity.z = Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);
//调用AdjustVelocity方法来代替上面删掉的四行代码
AdjustVelocity();
if (desiredJump) {
desiredJump = false;
Jump();
}
body.velocity = velocity;
onGround = false;
}
maxAcceleration为100时, 球体也会保持与坡面对齐进行移动
现在我们的球体不会再因为速度变化过快而离开斜坡表面了. 除此之外, 现在的目标速度会根据球体所在斜坡调整其方向, 所以选择不同斜坡上球体的水平运动速度各不相同.
上图绝对目标速度; 下图相对目标速度(我把原文的第一张图换了一张对比更明显的图)
多地面法线
在只有一个单一平面接触点时, 使用接触法线调整目标速度和跳跃方向会应付自如. 但是如果同时存在多个地面接触点时上述方法的运行效果会变的非常奇怪和无法预测. 为了说明这一点我创建了领一个测试场景, 里面包含了一些洼地, 最多可以同时存在四个与球体的接触点(上个例子开始时提到的项目源码中的Jump场景就是本例场景)
新的跳跃试场景
在上述场景状态下进行跳跃, 球体会向哪个方向运动呢? 在我搭建的场景中, 那些有四个接触点的球体会倾向于某个方向, 但是它们各自最终的跳跃方向并不相同. 同样的, 那些有两个接触点的球体也会随意的旋转一个方向进行跳跃. 对于有三个接触点的球体, 它们与旁边的只有一个接触点的球体一样总是跳向同一个方向
(这个场景运行后最好不要马上跳, 你得确定那些多个接触点的球体已经稳定了, 与多个面都接触好了, 比如图中那两个有三个接触面的球体, 你很难精确的放置在恰好接触三个面的位置, 所以这个球会在上方一点放置, 运行后靠重力掉下来砸到这个”角儿”里, 另外我自己试验的话, 跳跃高度设置高一点会更易观察, 我用的4)
瞎跳
这种行为表现是因为我们在EvaluateCollision方法中每发现一个地面, 就设置一次法线. 所以在有多个接触面时, 我们只会按照最后一个被遍历到的面来设置法线. 遍历多个面的顺序要么因为球体的运动而任意选择, 要么由于物理系统的碰撞顺序而总是相同的.
最合理的规则应该是将周围的所有接触面信息综合起来得到一个代表平均地面平面的单独的法线向量. 要这样做的话需要累加每个平面的法线向量, 这就需要在FixedUpdate方法结束时设置contactNormal为0. 让我们将它与设置onGround的代码一同放在一个新方法ClearState中 :
void FixedUpdate () {
…
body.velocity = velocity;
//onGround = false;
//注释上面的代码, 并添加下面的代码
ClearState();
}
void ClearState () {
onGround = false;
contactNormal = Vector3.zero;
}
现在在EvaluateCollision方法中累积法线, 取代之前重写法线的代码 :
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
if (normal.y >= minGroundDotProduct) {
onGround = true;
//contactNormal = normal;
//将重新赋值改成每次累加
contactNormal += normal;
}
}
}
最终, 当球体位于地面上时, 在UpdateState方法中归一化得到的累积法线 使其变成一个恰当的法线向量 :
void UpdateState () {
velocity = body.velocity;
if (onGround) {
jumpPhase = 0;
//新增下面这行代码
contactNormal.Normalize();
}
else {
contactNormal = Vector3.up;
}
}
一致的跳跃方向
计数地面接触
我们还可以统计球体一共与地面有多少个接触点. 我们可以使用一个整型字段groundContactCount来存储有多少个接触点, 同时我们引入一个只读的属性OnGround, 注意它的名字首字母是大写的, 不是被替代的onGround. 该新增属性可以检查接触点的数量是否大于0, 代替onGround的作用 :
//bool onGround;
int groundContactCount;
bool OnGround => groundContactCount > 0;
“OnGround => groundContactCount > 0;”做了什么?
这是定义单语句只读属性的简写方式, 它的作用等同于下面的代码 :
bool OnGround {
get { return groundContactCount > 0; }
}
现在ClearState方法中需要设置接触点数量为零, 代替值设置是否接触地面的代码 :
void ClearState () {
//onGround = false;
groundContactCount = 0;
contactNormal = Vector3.zero;
}
而UpdateState方法中需要将之前使用onGround字段做判断的部分使用OnGround属性来替代. 此外我们还可以对代码进行一些优化, 判断下接触点的数量, 如果大于1, 那么就将接触法线归一化, 否则不需处理, 因为只有一个接触点的话, 接触法线就已经是单位长度了
void UpdateState () {
velocity = body.velocity;
//if (onGround) {
if (OnGround) {
jumpPhase = 0;
//增加if判断决定是否将接触法线归一化
if (groundContactCount > 1) {
contactNormal.Normalize();
}
}
…
}
EvaluateCollision方法中, 用增加接触点技术的代码代替之前设置onGround的代码 :
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
if (normal.y >= minGroundDotProduct) {
//onGround = true;
groundContactCount += 1;
contactNormal += normal;
}
}
}
最后, 将AdjustVelocity方法和Jump方法中的onGround都用OnGround来代替.
初次之外, 还可以尝试根据基础点数量的不同来设置球体的颜色, 从而更好的观察其与平面的接触状态.
代表不同接触点数量的球体颜色
你怎么改变颜色的?
我在Update方法中添加了如下代码 :
GetComponent
这需要球体的材质包含_Color属性, 标准Shader的默认渲染管线即有该属性. 如果你使用的是Lightweight/Universal管线的默认Shader, 那么你可以将代码中的”_Color”使用”_BaseColor”代替
下一篇教程是 表面接触.