很多童鞋没有系统的Unity3D游戏开发基础,也不知道从何开始学。为此我们精选了一套国外优秀的Unity3D游戏开发教程,翻译整理后放送给大家,教您从零开始一步一步掌握Unity3D游戏开发。 本文不是广告,不是推广,是免费的纯干货!本文全名:喵的Unity游戏开发之路 - 移动 - 推球:游戏中的物理
控制刚体球体的速度。
通过跳跃支持垂直运动。
检测地面及其角度。
使用ProBuilder创建测试场景。
沿斜坡移动。
这是有关控制角色移动的教程系列的第二部分。这次,我们将使用物理引擎创建更逼真的运动并支持更复杂的环境。
本教程使用Unity 2019.2.11f1制作。它还使用ProBuilder软件包。
最终效果之一
在不公平的赛道上不受约束的球体。
刚体
在上一教程中,我们将球体约束为保留在矩形区域内。显式地编程这样的限制很有意义,因为它很简单。但是,如果我们希望球体在复杂的3D环境中移动,则必须支持与任意几何图形的交互。我们将使用Unity现有的物理引擎,即NVIDIA的PhysX,而不是自己实现。
与物理引擎结合使用,有两种通用的方法来控制角色。首先是刚体方法,即通过施加力或改变其速度,使角色的行为像常规物理对象一样,而间接控制它。第二种是运动学方法,即在仅查询物理引擎执行自定义碰撞检测的同时进行直接控制。
刚体组件
我们将使用第一种方法来控制球体,这意味着我们必须向其中添加一个Rigidbody
组件。我们可以使用刚体的默认配置。
添加该分量足以将我们的球体变成一个物理对象,只要它仍然具有其SphereCollider
分量即可。从现在开始,我们推迟到物理引擎进行碰撞,因此从中删除区号Update
。
Vector3 newPosition = transform.localPosition + displacement;
//if (newPosition.x < allowedArea.xMin) {
// newPosition.x = allowedArea.xMin;
// velocity.x = -velocity.x * bounciness;
//}
//…
transform.localPosition = newPosition;
消除了我们自己的约束后,球体再次可以自由移动经过平面的边缘,在此点,球体由于重力而直线下降。发生这种情况是因为我们从不覆盖球体的Y位置。
我们不再需要允许区域的配置选项。我们的自定义跳动也不再需要。
//[SerializeField, Range(0f, 1f)]
//float bounciness = 0.5f;
//[SerializeField]
//Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);
如果我们仍然想约束球体保留在平面上,则可以通过添加其他对象来阻止其路径来实现。例如,创建四个立方体,对其进行缩放和定位,以便它们围绕平面形成一堵墙。这将防止球体掉落,尽管它在与墙壁碰撞时表现得很怪异。由于此时我们具有3D几何形状,因此再次启用阴影以更好地了解深度也是一个好主意。
物理怪异。
当试图移动到一个角落时,由于物理引擎和我们自己的代码争夺球形的位置,因此球形变得不稳定。我们将其移入墙壁,然后PhysX通过将其向后推来解决碰撞。如果我们停止将其推入墙壁,则PhysX将使球由于动量而保持运动。
控制刚体速度
如果要使用物理引擎,则应让它控制球体的位置。直接调整位置将有效地传送,这不是我们想要的。相反,我们必须通过对球施加力或调整其速度来间接控制球。
我们已经对位置进行了间接控制,因为我们会影响速度。我们要做的就是更改代码,使其覆盖Rigidbody
组件的速度,而不是自己调整位置。我们需要为此访问组件,因此通过body
在Awake
方法中初始化的字段来跟踪它。
Rigidbody body;
void Awake () {
body = GetComponent
(); }
从Update中删除位移代码,而是将我们的速度分配给body的速度。
//Vector3 displacement = velocity * Time.deltaTime;
//Vector3 newPosition = transform.localPosition + displacement;
//transform.localPosition = newPosition;
body.velocity = velocity;
但是物理碰撞等也会影响速度,因此请先将其从body中检索出来,然后再对其进行调整以匹配所需的速度。
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;
控制body的速度。
无摩擦运动
现在,我们调整球体的速度,PhysX用来移动它。然后解决冲突,可以调整速度,然后再次调整速度,依此类推。尽管球体更加缓慢并且没有达到其最大速度,但最终的运动看起来像我们以前的运动。那是因为PhysX会产生摩擦。尽管这更现实,但它使配置球体变得更加困难,因此让我们消除摩擦和反弹。这是通过“ 资产/创建/物理材质”创建新的物理材质(是的,在菜单中拼写为“ Physic”),然后将所有值设置为零,将“ 合并”模式设置为“ 最小”。
将此物理材质分配给球体的对撞机。
现在,它不再受到任何摩擦或反弹。
不建议不要直接调节速度吗?
这是基于速度瞬时变化是不现实的想法的通用建议。我们正在做的是有效地施加加速度,只是以一种受控的方式来达到目标速度。如果您知道自己在做什么,直接调整速度就可以了。
无摩擦运动。
与球体碰撞时,球体似乎仍会反弹一点。发生这种情况是因为PhysX不会阻止碰撞,而是会在碰撞发生后检测到它们,然后移动刚体以使它们不再相交。在快速运动的情况下,这可能需要一个以上的物理模拟步骤,因此我们可以看到这种穿透现象的发生。
如果运动确实非常快,那么球体可能最终会完全穿过壁或朝另一侧穿透,这对于较薄的壁来说更可能发生。您可以通过更改的Rigidbody碰撞检测模式来避免这种情况,但这通常仅在移动非常快时才需要。
而且,球体现在可以滑动而不是滚动,因此我们也可以冻结其在所有尺寸上的旋转,这可以通过组件的“ 约束”复选框来完成Rigidbody
。
固定更新
物理引擎使用固定的时间步长,而不管帧速率如何。尽管我们已经将球的控制权交给了PhysX,但我们仍然会影响其速度。为了获得最佳结果,我们应该以固定的时间步长同步调整速度。为此,我们将Update
方法分为两部分。我们检查输入并设置所需速度的部分可以保留在Update中,而速度的调整应移至新FixedUpdate
方法。为了完成这项工作,我们必须将所需的速度存储在一个场中。
Vector3 velocity, 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;
}
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(每秒50次),但是您可以通过“ 时间”项目设置或通过更改时间步长Time.fixedDeltaTime
。
根据您的帧速率FixedUpdate
,每次调用可以调用0次,一次或多次Update
。每个框架都会发生一系列FixedUpdate
调用,然后Update
被调用,然后呈现框架。当物理时间步长相对于帧时间太大时,这可以使物理仿真的离散性质变得明显。
0.2物理时间步。
您可以通过减少固定时间步长或启用的Rigidbody插值模式来解决此问题。将其设置为Interpolate可使它在其最后位置和当前位置之间线性插值,因此根据PhysX,它会稍微落后于其实际位置。另一个选项是Extrapolate,它根据其速度插值到其猜测的位置,这仅对于速度基本恒定的对象才真正可接受。
带插值的0.2物理时间步长。
请注意,增加时间步长意味着球体在每次物理更新时覆盖的距离更大,这可能导致使用离散碰撞检测时球体穿过壁隧穿。
跳跃
由于我们的球体现在可以在3D物理世界中导航,因此我们可以使其具有跳跃的能力。
根据指令跳跃
我们可以用Input.GetButtonDown("Jump")来检测玩家是否按下了该帧的跳转按钮,默认情况下是空格键。我们在Update中这样做,但是就像调整速度一样,我们会将实际的跳跃延迟到FixedUpdate的下次调用。因此,请通过布尔字段desiredJump跟踪是否需要跳转。
bool desiredJump;
…
void Update () {
…
desiredJump = Input.GetButtonDown("Jump");
}
但是,我们可能最终不调用FixedUpdate下一帧,在这种情况下desiredJump将其调回false原定位置,而desiredJump 将被遗忘。我们可以通过布尔“或”运算或“或”分配将检查与其先前的值相结合来防止这种情况。这样,它将保持true启用状态,直到我们将其显式设置回false。
desiredJump|=Input.GetButtonDown("Jump");
在调整速度之后和在FixedUpdate中应用速度之前,请检查是否需要跳跃。如果是这样,请重置desiredJump
并调用一个新Jump
方法,该方法最初仅将5添加到速度的Y分量,以模拟突然的向上加速度。
void FixedUpdate () {
…
if (desiredJump) {
desiredJump = false;
Jump();
}
body.velocity = velocity;
}
void Jump() {
velocity.y += 5f;
}
这将使球体向上移动,直到由于重力不可避免地回落。
跳。
跳跃高度
让我们对其范围进行配置是可配置的。我们可以通过直接控制跳跃速度来做到这一点,但这并不直观,因为初始跳跃速度和跳跃高度之间的关系并不微不足道。直接控制跳跃高度更方便,所以让我们开始吧。
[SerializeField, Range(0f, 10f)]
float jumpHeight = 2f;
跳跃需要克服重力,因此所需的垂直速度取决于重力。特别,vÿ=--2GHv_y = sqrt(-2gh) 那里 GG 是重力, HH是所需的高度。负号在那里,因为GG假定为负。我们可以通过检索它Physics.gravity.y
,也可以通过Physics项目设置进行配置。我们正在使用默认的重力矢量,该矢量向下垂直为9.81,与地球的平均重力匹配。
void Jump () {
velocity.y +=Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
如何得出所需的速度?
我们从初始跳跃速度开始 Ĵ,它会因重力而减小,直到达到零,然后我们开始回落。重力G是一个持续不断的加速度,将我们拉倒,为此我们在此推导中使用正数,因为这使我们免于编写大量负号。所以在任何时候Ť 因为跳跃的垂直速度是 v = jg t。什么时候v达到零,我们位于跳跃的顶部,因此正好位于所需的高度。这发生在jg t = 0,所以什么时候 j = gt。因此,当t = j /克。
因为 G 恒定,任何时候的平均速度为 v_(av)= j-(gt)/ 2,因此随时的高度为 h = v_(av)t = jt-(gt ^ 2)/ 2。这意味着在跳跃的顶端h = j(j / g)-(g(j / g)^ 2)/ 2,我们可以重写为 h = j ^ 2 / g-(j ^ 2 / g)/ 2 = j ^ 2 / g-j ^ 2 /(2g)= j ^ 2 /(2g)
现在我们知道 h = j ^ 2 /(2g) 在顶部,因此 j ^ 2 = 2gh 和 j = sqrt(2gh)。什么时候G 是负数而不是 j = sqrt(-2gh)。
请注意,由于物理模拟的离散性,我们很可能无法达到所需的高度。在时间步长之间的某个地方将达到最大值。
在地面的跳跃
目前,我们可以随时跳下,即使已经在空中,也可以永远保持空中飞行。仅当球体在地面上时才能启动适当的跳跃。我们无法直接询问Rigidbody
它当前是否正在接触地面,但是当它与某些物体碰撞时我们会得到通知,因此我们将使用它。
如果MovingSphere
有一个OnCollisionEnter
方法,那么它将在PhysX检测到新的碰撞后被调用。只要物体保持彼此接触,碰撞就仍然存在。之后,OnCollisionExit
将调用一个方法(如果存在)。将两种方法都添加到MovingSphere中,将第一个 onGround
boolean字段设置为true
,并将后者 boolean字段设置为false
。
bool onGround;
…
void OnCollisionEnter () {
onGround = true;
}
void OnCollisionExit () {
onGround = false;
}
现在我们只能在地面上跳跃,现在我们假设在触摸某物时就是这种情况。如果我们不接触任何东西,则应忽略期望的跳跃。
void Jump () {
if (onGround) {
velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
}
当球体仅接触地面时,此方法有效,但如果它也短暂接触墙,则跳跃将变得不可能。之所以发生这种情况,是因为OnCollisionExit
在我们仍与地面保持接触的同时,它被作为墙壁使用。解决方案是不依赖OnCollisionExit
而是添加一种OnCollisionStay
方法,只要碰撞仍然存在,就可以在每个物理步骤中调用该方法。设置onGround
于true
在该方法中。
//void OnCollisionExit () {
// onGround = false;
//}
void OnCollisionStay () {
onGround = true;
}
每个物理步骤都从调用所有FixedUpdate
方法开始,然后PhysX完成其工作,最后调用碰撞方法。因此,如果存在任何活动冲突,则在最后一步FixedUpdate期间将设置何时调用gets 。为了保持onGround有效,我们要做的就是在FixedUpdate末尾将其onGround设置为false。
void FixedUpdate () {
…
onGround = false;
}
现在,只要我们接触到某物,我们就可以跳跃。
无墙跳跃
当触摸任何东西时都允许跳跃意味着我们也可以在空中但触摸墙壁而不是地面时跳跃。如果要防止这种情况,我们必须能够区分地面和其他东西。
将地面定义为主要是水平面是有意义的。我们可以通过检查碰撞接触点的法线向量来检查我们所碰撞的物体是否满足此条件。
什么是法向量?
它是指示方向的单位长度向量。通常是远离某物的方向。因此,一个平面只有一个法向量,而球体上的每个点都有一个指向其中心的不同法线向量。
一个简单的碰撞只有两个形状接触的单个点,例如,当我们的球体接触地面时。通常,球体会稍微穿透平面,而PhysX通过将球体直接推离平面而解决了。推动的方向是接触点的法线向量。因为我们使用的是球体,所以矢量始终从球体表面上的接触点指向其中心。
实际上,它可能比这更混乱,因为可能存在多个碰撞,并且穿透可能会持续一个以上的仿真步骤,但是我们现在不必真正担心这一点。我们确实需要认识到的是,一次碰撞可以包含多个接触。对于平面-球体碰撞,这是不可能的,但是当涉及到凹形网格对撞机时,这是可能的。
我们可以通过向和Collision
都添加一个参数来获取碰撞信息。与其直接设置onGround 为true,我们不如将责任转交给一种新方法EvaluateCollision ,并将数据给它。
void OnCollisionEnter (Collision collision) {
//onGround = true;
EvaluateCollision(collision);
}
void OnCollisionStay (Collision collision) {
//onGround = true;
EvaluateCollision(collision);
}
void EvaluateCollision (Collision collision) {}
可以通过Collision的contactCount
属性找到接触点的数量。我们可以使用它通过该GetContact
方法遍历所有点,并为其传递索引。然后,我们可以访问该点的normal
属性。
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
}
}
法线是球应被推动的方向,该方向直接远离碰撞表面。假设它是一个平面,则矢量与平面的法向矢量匹配。如果平面是水平的,则其法线将指向垂直,因此其Y分量应正好为1。如果是这种情况,则我们正在接触地面。但是,我们要宽容一些,接受0.9或更大的Y分量。
Vector3 normal = collision.GetContact(i).normal;
onGround |= normal.y >= 0.9f;
空中跳跃
在这一点上,我们只能在地面上跳,但是游戏通常允许空中跳两次甚至三跳。让我们对此进行支持,并使其可配置为允许多少次空气跳跃。
[SerializeField, Range(0, 5)]
int maxAirJumps = 0;
现在,我们必须跟踪跳转阶段,以便知道是否允许再次跳转。如果我们在地面上,我们可以通过在FixedUpdate开始时将其设置为零的整数字段来执行此操作。但是,让我们将代码与速度检索一起移动到单独的UpdateState
方法中,以保持FixedUpdate
简短。
int jumpPhase;
…
void FixedUpdate () {
//velocity = body.velocity;
UpdateState();
…
}
void UpdateState () {
velocity = body.velocity;
if (onGround) {
jumpPhase = 0;
}
}
从现在开始,每次跳跃时,我们都会增加跳跃阶段。我们可以在地面上或尚未达到允许的最大空中跳跃时跳跃。
void Jump () {
if (onGround|| jumpPhase < maxAirJumps) {
jumpPhase += 1;
velocity.y += Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
}
}
应该<= maxAirJumps不是吗?
跳转后,跳转阶段立即设置回零。在下一个教程中,我们将找到原因。
限制向上速度
快速连续跳跃的空气使向上的速度比单次跳跃的速度高得多。我们将进行更改,以使我们不能超过单跳即可达到所需高度的跳速。第一步是隔离计算出的跳跃速度Jump
。
jumpPhase += 1;
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
velocity.y +=jumpSpeed;
如果我们已经有向上的速度,则在将其添加到速度的Y分量之前,将其从跳跃速度中减去。这样,我们将永远不会超过跳跃速度。
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
if (velocity.y > 0f) {
jumpSpeed = jumpSpeed - velocity.y;
}
velocity.y += jumpSpeed;
但是,如果我们已经快于跳跃速度,那么我们不希望跳跃使我们减速。我们可以通过确保修改后的跳转速度永远不会变为负值来避免这种情况。通过采用修改后的最大跳跃速度和零来实现。
if (velocity.y > 0f) {
jumpSpeed =Mathf.Max(jumpSpeed - velocity.y, 0f);
}
空中运动
目前,我们在控制球体时不在乎球体是在地面上还是在空中,但可以理解,空中球体更难控制。控制的数量可以在完全控制和完全控制之间变化。这取决于游戏。因此,通过添加单独的最大空气加速度(默认设置为1),使它可配置。这样可以大大减少空中控制,但不能完全将其删除。
[SerializeField, Range(0f, 100f)]
float maxAcceleration = 10f, maxAirAcceleration = 1f;
现在,我们在FixedUpdate计算最大速度变化时使用哪种加速度取决于我们是否在地面上。
float acceleration = onGround ? maxAcceleration : maxAirAcceleration;
float maxSpeedChange =acceleration* Time.deltaTime;
连续下坡
我们正在使用物理学在一个小的平面上移动球体,与墙碰撞并四处跳跃。一切都很好,因此是时候考虑更复杂的环境了。在本教程的其余部分中,我们将研究涉及坡度时的基本运动。
ProBuilder测试场景
您可以通过旋转平面或立方体来创建坡度,但这是创建关卡的不便方法。因此,我们将导入ProBuilder程序包,并使用该程序包创建一些坡度。该ProGrids包也得心应手栅格捕捉,但如果你碰巧使用,它不是在统一2019.3需要。ProBuilder使用起来相当简单,但是可能需要一些时间来适应。我不会解释如何使用它,只是要记住,它主要是关于脸的,而边缘和顶点是次要的。
我从ProBuilder立方体开始创建了一个坡度,将其拉伸到10×5×3,在X维度上将其拉伸了10个单位,然后将X面折叠到其底部边缘。这将产生一个三角形的双斜面,其两侧的斜率长为10个单位,高为5个单位。
我将其中十个放置在一个平面上,并将它们的高度从一单位更改为十个单位。包括平坦的地面在内,我们获得的倾斜角度大约为0.0°,5.7°,11.3°,16.7°,21.8°,26.6°,31.0°,35.0°,38.7°,42.0°和45.0°。
之后,我又放置了十个斜坡,这次是从45°版本开始,然后将笔尖向每个倾斜的角度向左拉一个单位,直到最后得到一面垂直墙。这给我们提供了大约48.0°,51.3°,55.0°,59.0°,63.4°,68.2°,73.3°,78.7°,84.3°和90.0°的角度。
通过将球体变成预制件并添加21个实例(从每个水平到完全垂直),每个坡度一个实例,我完成了测试场景。
如果您不想自己设计关卡,可以从本教程的资源库中获取它。
资源库(Repository)
https://bitbucket.org/catlikecodingunitytutorials/movement-02-physics/
斜率测试
因为所有球体实例都响应用户输入,所以我们可以同时控制它们。这样就可以立即测试与多个倾斜角度相互作用时球体的行为。对于大多数这些测试,我将进入播放模式,然后连续按向右键。
斜率测试。
使用默认球体配置,我们可以看到前五个球体以几乎完全相同的水平速度移动,而与倾斜角无关。第六个几乎没有经过,而其余的则回滚或被陡峭的斜坡完全挡住了。
因为大多数球体都有效地结束了飞行,所以我们将最大空气加速度设置为零。这样,我们只有在考虑到基础上才考虑加速。
空气加速与零空气加速之间的差异并不重要,因为它们飞出了斜坡。但是第六球现在不再到达另一侧,其他球也由于重力而提前停止。发生这种情况是因为它们的坡度太陡而无法保持足够的动力。在第六球的情况下,其空气加速度足以将其推向上方。
接地角
目前,我们使用0.9作为阈值来将某物归类为不归类,但这是任意的。我们可以使用0–1范围内的任何阈值。尝试两个极端会产生非常不同的结果。
让我们通过控制最大地面角度使阈值可配置,因为最大地面角度比坡度法线向量的Y分量更直观。让我们使用25°作为默认值。
[SerializeField, Range(0f, 90f)]
float maxGroundAngle = 25f;
当表面水平时,其法线向量的Y分量为1。对于完全垂直的墙,Y分量为零。Y分量根据倾斜角度在这些极端之间变化:它是该角度的余弦。我们在这里处理单位圆,其中Y是垂直轴,水平轴位于XZ平面中的某个位置。另一种说法是,我们正在查看向上矢量和表面法线的点积。
组态的角度定义了仍算作地面的最小结果。让我们的门槛存储在一个领域,并通过Mathf.Cos计算它的一个OnValidate
方法。这样,当我们在播放模式下通过检查器更改角度时,它将保持与角度同步。同时Awake调用它,以便在构建中对其进行计算。
float minGroundDotProduct;
void OnValidate () {
minGroundDotProduct = Mathf.Cos(maxGroundAngle);
}
void Awake () {
body = GetComponent
(); OnValidate();
}
我们以度为单位指定角度,但Mathf.Cos希望将其表示为弧度。我们可以通过乘以Mathf.Deg2Rad将其转换。
minGroundDotProduct = Mathf.Cos(maxGroundAngle* Mathf.Deg2Rad);
现在我们可以调整最大地面角度,看看它如何影响球体的运动。从现在开始,我将角度设置为40°。
void EvaluateCollision (Collision collision) {
for (int i = 0; i < collision.contactCount; i++) {
Vector3 normal = collision.GetContact(i).normal;
onGround |= normal.y >=minGroundDotProduct;
}
}
在斜坡上跳跃
无论当前球面的角度如何,我们的球体始终会直线向上跳跃。
另一种方法是沿法线向量的方向跳离地面。每个坡度测试车道都会产生不同的跳跃,所以让我们这样做。
我们需要跟踪一个领域中的当前接触法线,并在遇到地面接触EvaluateCollision时将其存储起来。
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 (normal.y >= minGroundDotProduct) {
onGround = true;
contactNormal = normal;
}
}
}
但是,我们最终可能没有触及地面。在这种情况下,我们将使用up向量作为接触法线,因此空气跳跃仍然会直线上升。如果需要,将其在UpdateState中设置。
void UpdateState () {
velocity = body.velocity;
if (onGround) {
jumpPhase = 0;
}
else {
contactNormal = Vector3.up;
}
}
现在,我们必须将按跳跃速度缩放的跳跃接触法线添加到跳跃时的速度上,而不是始终仅增加Y分量。这意味着跳跃高度表示我们在平坦地面或仅在空中时跳跃的距离。在斜坡上跳跃不会达到很高,但会影响水平速度。
void Jump () {
if (onGround || jumpPhase < maxAirJumps) {
…
//velocity.y += jumpSpeed;
velocity += contactNormal * jumpSpeed;
}
}
但这意味着对垂直速度为正的检查也不再正确。它必须成为检查与接触法线对齐速度的方法。我们可以通过将速度投影到接触法线上并通过计算它们的点积Vector3.Dot来找到该速度。
float jumpSpeed = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
float alignedSpeed = Vector3.Dot(velocity, contactNormal);
if (alignedSpeed> 0f) {
jumpSpeed = Mathf.Max(jumpSpeed -alignedSpeed, 0f);
}
velocity += contactNormal * jumpSpeed;
现在,这些跳跃与坡度对齐,我们的测试场景中的每个球体都具有唯一的跳跃轨迹。陡峭的斜坡上的球不再直接跳入其斜坡,而是随着跳跃将球朝与运动相反的方向推动而变慢。您可以通过大幅降低最大速度来尝试在所有斜坡上更清楚地看到这一点。
沿着斜坡移动
到目前为止,无论倾斜角度如何,我们始终在水平XZ平面中定义所需的速度。如果球体沿坡度上升,那是因为PhysX将球向上推以解决发生的碰撞,因为我们给它指定了指向坡度的水平速度。在上坡时,这可以很好地工作,但是在下坡时,球体会远离地面移动,并且当它们的加速度足够高时最终会掉落。结果是难以控制的弹性运动。在上坡时反转方向时,尤其是在将最大加速度设置为较高值时,您可以清楚地看到这一点。
失去接地;最大加速度100。
我们可以通过将所需速度与地面对齐来避免这种情况。它的工作方式与我们在法线上投影速度以获得跳跃速度的方式类似,只是现在我们必须在平面上投影速度才能获取新速度。我们通过像以前一样取向量和法线的点积,然后从原始速度向量中减去由该法线缩放的法线来做到这一点。让我们为使用任意矢量参数的方法创建一个方法ProjectOnContactPlane。
Vector3 ProjectOnContactPlane (Vector3 vector) {
return vector - contactNormal * Vector3.Dot(vector, contactNormal);
}
为什么不使用Vector3.ProjectOnPlane?
该方法执行相同的操作,但不假定提供的法向向量具有单位长度。它将结果除以法线的平方长度(通常为1,因此不需要)。
让我们创建一个新方法AdjustVelocity来调整速度。首先通过在接触平面上投影右向向量和向前向量来确定投影的X轴和Z轴。
void AdjustVelocity () {
Vector3 xAxis = ProjectOnContactPlane(Vector3.right);
Vector3 zAxis = ProjectOnContactPlane(Vector3.forward);
}
这使我们的向量与地面对齐,但是当地面完全平坦时,它们只有单位长度。通常,我们必须对向量进行归一化以获得正确的方向。
Vector3 xAxis = ProjectOnContactPlane(Vector3.right).normalized;
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);
我们可以像以前一样使用它们来计算新的X和Z速度,但是现在相对于地面。
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();
AdjustVelocity();
//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);
if (desiredJump) {
desiredJump = false;
Jump();
}
body.velocity = velocity;
onGround = false;
}
与地面保持一致;最大加速度100。
使用我们新的速度调整方法,当在斜坡上突然突然反转方向时,球不再与地面失去接触。除此之外,由于期望速度会调整其方向以匹配斜率,因此现在每个车道都会改变绝对期望水平速度。
请注意,如果坡度未与X轴或Z轴对齐,则相对投影轴之间的角度将不为90°。除非斜坡非常陡峭,否则这并不是很明显。您仍然可以在所有方向上移动,但是要精确地在某些方向上进行导航比在其他方向上更难。这在某种程度上模仿了试图穿越但不与陡坡对齐的尴尬。
多个地面法线
当只有一个地面接触点时,使用接触法线来调整所需的速度和跳跃方向效果很好,但是当同时存在多个地面接触时,行为可能会变得奇怪且不可预测。为了说明这一点,我创建了另一个测试场景,该测试场景的地面有些凹陷,一次最多可以有四个接触点。
跳跃时,球体会朝哪个方向前进?就我而言,拥有四个联系人的人倾向于偏向一个方向,但最终会朝四个不同方向前进。同样,具有两个接触的球体在两个方向之间任意拾取。具有三个接触的球始终以相同的方式跳跃,以匹配仅接触单个坡度的附近球。
出现这种现象的原因是,只要我们发现地面接触点,便将法线设置为EvaluateCollision。因此,如果我们发现多个,则最后一个赢。由于移动的顺序是任意的,或者由于PhysX计算碰撞的顺序,顺序总是相同的。
哪个方向最好?没有一个。将它们全部组合成一个代表平均接地平面的法线是最有意义的。为此,我们必须累积法线向量。这就要求我们在FixedUpdate的末尾将接触法线设置为零。让我们将代码与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;
}
}
}
最后,将UpdateState中在地面上的接触法线归一化以使其成为适当的法线向量。
void UpdateState () {
velocity = body.velocity;
if (onGround) {
jumpPhase = 0;
contactNormal.Normalize();
}
else {
contactNormal = Vector3.up;
}
}
地面接触点计算
虽然不是必需的,但我们可以算出我们有多少个地面接触点,而不仅仅是跟踪是否至少有一个。我们通过将布尔字段替换为整数来做到这一点。然后,我们引入一个布尔型只读属性OnGround(注意大小写),该属性检查计数是否大于零,并替换该onGround
字段。
//bool onGround;
int groundContactCount;
bool OnGround => groundContactCount > 0;
该代码如何工作?
这是定义单语句只读属性的一种简便方法。与以下内容相同:
bool
OnGround {
get
{
return
groundContactCount > 0;
}
}
ClearState
现在必须将计数设置为零。
void ClearState () {
//onGround = false;
groundContactCount = 0;
contactNormal = Vector3.zero;
}
并且UpdateState
必须依靠属性而不是字段。除此之外,我们还可以通过仅对接触法线进行归一化(如果是聚合的话)进行归一化来进行一些优化,否则它已经是单位长度了。
void UpdateState () {
velocity = body.velocity;
if (OnGround) {
jumpPhase = 0;
if (groundContactCount > 1) {
contactNormal.Normalize();
}
}
…
}
还要在Evaluate
适当的时候增加计数。
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;
}
}
}
最后,用OnGround
的AdjustVelocity
和Jump
更换onGround
。
除了UpdateState中地面接触数量的优化,对调试也很有用。例如,您可以记录计数或根据计数调整球体的颜色,以更好地了解其状态。
您是如何改变颜色的?
我将以下代码添加到Update:
GetComponent
"_Color", Color.white * (groundContactCount * 0.25f)
);
假定球体的材质具有_Color属性,默认渲染管线的标准着色器就是这种情况。如果您使用的是Lightweight / Universal管道的默认着色器,则需要使用_BaseColor。
下一个教程是表面接触(Surface Contact)。
资源库(Repository)
https://bitbucket.org/catlikecodingunitytutorials/movement-02-physics/
往期精选
Unity3D游戏开发中100+效果的实现和源码大全 - 收藏起来肯定用得着
Shader学习应该如何切入?
UE4 开发从入门到入土
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
原作者:Jasper Flick
原文:
https://catlikecoding.com/unity/tutorials/movement/physics/
翻译、编辑、整理:MarsZhou
More:【微信公众号】 u3dnotes