Unity 制作KinematicCharacterController
本篇博客为游戏开发记录,博主只是想自己做个移动组件给自己做游戏用,此间产生的一些经验也做一个分享。
简介
为了在3D世界中自由的控制我们的角色,引擎一般会提供一些基础的移动组件,上层用户做提供一些每帧的速度输入,移动组件应该返还一个正确的位置,一般来说就是保证不会穿模和沿着墙面滑行。
为了达成这个目的 常规思路有两种,一种是直接使用动力学 rigidbody,另一种是基于运动学rigidbody,或者你的世界完全没有物理交互 那么也可以无rigidbody。
动力学刚体组件
第一种动力学rigidbody,相信大家都不陌生,就是让物理引擎去接管我们的运动,我们提供力 速度写入,物理引擎会自动判断周围有哪些物体,然后使用碰撞检测、碰撞处理算法去解动力学求交,并且各种速度、加速度、角速度都会因为动力学求解而非常的自然。但是对于很多游戏而言想要驾驭动力学其实也是一件难事,比如我们的主角 通常都要保持一个直立的站立、稳定的旋转量,于是一般这类基于动力学刚体的移动组件都会锁住旋转量,或者纯动力学利用joint或者其他机制来保证主角的平衡。
动力学刚体我找到了一个案例,来自CatCoding的 movement实现:https://catlikecoding.com/unity/tutorials/movement/
运动学刚体组件
第二种则是运动学刚体,Kinematic,这类刚体和前面的刚体的主要区别在于,运动学刚体,物理引擎是将其当成质量无限大的物体来处理的,这样各种碰撞交叉产生的挤出便不会对此类刚体生效,此类刚体可以实现想去哪就去哪,可以被上层Gameplay逻辑精准控制位置,同时PhysX也会对这类刚体的运动有所记录,运动学刚体可以挤开各种动力学刚体。
如果要基于此类刚体做移动组件,主要就是解决和其他碰撞体的交叉问题,让运动学刚体运动不穿模,动力学刚体的挤开则是完全根据质量等进行物理计算,运动学其实在这块把这类问题暴露给上层,理论上交给上层的控制性更高。
其实Unity有提供一个现成的组件,名字叫CharacterController,他其实是PhysX基于Capsule的一个移动封装,很多项目也都在使用。
网络上也有一些开源的KCC插件,比如说
OpenKCC
UnityAsset Store的Kinematic Character Controller
其实Unreal提供的移动组件也是一个KCC的实现,实现的功能很多,提供了状态机的思路,所以大家用起来才这么方便,可以参考一下知乎的移动组件剖析文章: 《Exploring in UE4》移动组件详解[原理分析]
这一些移动组件我都大体有看过,我这边参考UnityAssetStore的框架结构结合一些UE的思想做了一个简单的移动组件。
移动组件设计
设计原则
这里先说一下移动组件的设计目的,解决什么问题。
上层只用输入一个速度值,速度值传入移动组件,移动组件根据速度计算当帧的位移量,然后根据此位移量做移动预测,在预测位置做位置校准,保证最后输出的位置是不穿模的位置。并且提供一些状态量给上层,包括有没有踩到地面,踩到的地面是不是一个斜面,当帧有没有发生碰撞的事件,什么时候落地的事件,以及帮助上层处理上台阶的问题。
至于你想实现某些地形要做什么特殊处理,都可以基于提供的状态量做拓展,比如在斜面上的行为完全在上层写,或者自己再做一些额外的场景查询功能,自己维护状态。
运行流程
以下是我FixedUpdate里会跑的运行流程。
void Simulate()
{
characterController.BeforeCharacterUpdate(Time.fixedDeltaTime);
TimeIntegration();
InitPositionOverlapTest();
GroundDetection();
MovementDetection();
PendingLeaveGroundLoop();
ApplayDeltaPos();
characterController.AfterCharacterUpdate(Time.fixedDeltaTime);
}
玩家首先在每帧Update进行Input输入,我们在update里把这些数据记录下来,传入移动组件,然后移动组件在FixedUpdate里对这些记录的输入状态进行速度改变,注意一定是FixedUpdate里做这些速度改变,因为unity涉及物理的tick都跑在FixedUpdate。因此我们移动组件可以提供一个UpdateVelocity接口,让上层所有的运动速度修改都走这个接口,这样就可以保证不会在错误的时机写入速度。
TimeIntegration 与 ApplayDeltaPos
整个运行流程一开始试一次时间积分,用于记录一下当帧的移动组件位置targetPos,跑UpdateVelocity的逻辑改变速度,改变后的速度进行积分得到造成的当帧位移量deltaPos,targetPos会在最后和deltaPos相加然后走Kinematic 刚体的MovePosition和MoveRotation接口来应用。
GroundDetection
地面检测要处理是否踩到地面,是否踩到斜面,对于很低的小碰撞体要可以跨过去(其实也就是支持上台阶)。
这里给出一句简要总结: 斜面是特殊的地面,台阶是特殊的斜面。
地面检测的流程:
1、从上往下做CapsuleCast 寻找潜在的地面碰撞体,取最近的
2、如果cast中了地面就根据预测位置进行Ovelap,检测是否和地面碰撞体相交
3、如果检测到了相交就ComputePenetration计算怎样解相交,将计算出的解相交的位移量进行应用
当我们真的完成了一次解相交 便可以做一个抛出一个落地了的事件。
这里画了一幅图 ,
黑色 原始位置
绿色 预测位置
蓝色 校正位置
红色是cast 用来确定是和哪个碰撞解穿插
黄色使我们一次计算得到实际位移量。
到底什么算地面
碰撞点在capsule的下半身的位置,这里可以定义一个最大的地面碰撞点高度,排除上半身cast到东西的情况。
需要注意的是Cast的起点应该向上提一些,这样避免胶囊体贴住地面、有一定交叉的时候Cast不到地面。
另外CapsuleCast得到的RaycastHit的Normal有一些波动,不是正确的Normal,需要进行Normal修正,特别是在斜面判断上。
斜面如何判断
根据cast出的地面的法线,计算夹角,给出一个最大的能上斜面的角度,夹角大于这个度数则判断为斜面,需要指出正对的属于90度的斜面。
我们每帧的即将产生的位移量应该在这个法向量所在的平面进行投影,这样就能实现沿着斜面运动。
台阶如何判断
首先碰撞点低于下半身球的碰撞点,已经过滤了一波,说明你面对的是一个很低的阻挡物,接下来我们通过一些额外的Raycast可以判断他是否是台阶。
我这里就是通过两层射线,下层射线和上层射线,下层射线比上层射线短一点,如果下层射线打中了上层射线没打到则说明这是个台阶,很简单粗暴,但是很有效。
然后台阶 = 角度很大的斜面,此时经过一次ComputePenetration,我们的状态会在不稳定斜面上,此时我们就可以判断,加入此时的输入的deltaPos是朝向着这个很大的斜面的。就可以判断能不能上台阶了,如果能上就执行上台阶的功能。
具体上台阶就是让deltaPos 向上方和原来的速度方向做一个调整,同时因为是上台阶,可以将当前速度的y方向改成0,等于你踩在了台阶上。
跳跃如何实现
我们在地面上运动 如果要起跳就是把y轴速度置为一个值,然后传进去,此时我们的地面检测应该先关闭一小段时间,并且把movement在地面上的状态置为false,来保证上层按下跳跃之后的下一帧就能起跳,避免下一帧还是movement在地面上状态为true,影响上层逻辑PendingLeaveGroundLoop就是做了简单定时器。
当需要跳跃的时候就RequestJump,这样定时器就会启动保证落地状态正确。
MovementDetection
移动检测 其实非常简单
包含两个部分
1、初始位置错误
比如我们提供了直接SetPosition这样的传送接口,传送之后的位置如果有和其他东西相交,那么先解这个相交,这个过程在InitPositionOverlapTest里做了。
2、当帧速度应用后位置错误
将targetPos+deltaPos得到当帧预测位置,对预测位置进行OverlapTest,然后按顺序解各个overlap的相交量。
当然我们也会过滤一些碰撞体,比如纯动力学物体我就不进行解相交,这样我们的运动组件就可以挤开一些动力学的物体,不过这样其实也不是很好,会遇到各种问题,比如把一些动力学的东西挤到墙里去,其实计算一下动力学物体的渗透深度然后改速度应用给这些动力学物体会是一个好的解法。
如果我们真的解掉了相交,那么就可以抛出一个碰到了东西的事件。
总结
好的,完成了以上步骤我们就基本能得到一个简单的,可以在地面上跑来跑去可以跨越台阶的可以跳以及移动不会穿模的简单移动组件了。
为什么要自己做一个移动组件呢,主要身为游戏开发者,还是我认为对于移动这种最基本的东西还是应该有绝对的把握。把很多的运动交给物理引擎是很方便,但是当我们觉得不满的时候想改起来在上层还是困难比较多,自己实现一个简单的再根据项目检验来完善自己的移动组件会是一件有价值的事情。
我同时也在B站上传一个视频,可以点击链接来看看效果。
想明白了,开始做了,做的有反馈,想的愈明白。
感谢你的阅读,我是飞翔的子明,期待我们都做出我们心中的游戏。
2023.6.30