基于物理玩法的思考(一)—— 碰撞和移动
一、前言
温馨提示,全文5k字,阅读需要一定程序基础,欢迎大家关注后慢慢阅读——
上一篇探讨了物理玩法中的操控和移动实现,本篇进行下一步,研究一下如何实现同屏大数量单位的移动和控制,大数量是指几千数量级,且要支持物理影响这些单位。
我们的难点如下:
- 难点1,数量级要足够大,手机支持数千到上万级别
- 难点2,这些单位还要可以受物理影响,有物理效果
- 难点3,不想要基于代理的方式来简化,也就是每个单位有独立逻辑
- 难点4,基于组装,目标多变,并不固定,并且会有多目标
当然,从设计目的出发,我们也有一些简化问题的机会点:
- 机会点1,限定2D平面上的多单位,不是3D
- 机会点2,不需要过于精确的寻路结果,不追求最短距离的寻路最优解
- 机会点3,底层不需要基于物理,只要和玩家操控的物理底层能够衔接,表现有物理的感受即可
- 机会点4,不追求精确避障,可以接受偶尔重叠
接下来,我们先来简单总结一下现有的实现方式都有哪些:
1.基于NavMesh + 局部避障的思路
这是最容易想到的思路,unity本身就支持,比较成熟,优点是寻路的质量比较好,缺点就是性能要求比较高,适合几百数量级的实体,不符合我们的需求。
2.VO / RVO / ORCA:基于速度空间的避障(Velocity Obstacle)
这个三个方法一脉相承,主要用于避障,和一般基于位置进行避障的思路不同,简单来说,这一系列方法的核心就是:如果我现在选这个速度,未来一小段时间会不会撞到别人?把所有“会撞的速度”圈成一个区域,然后在区域外选一个尽可能接近期望速度的安全速度。
这一类方法虽然避障效果很好,但对于数学计算有一定要求,因此从性能上考虑也不是最佳的选型,研究了一下也pass了,unity里也有现成的实现,大家想要学习,可以上git搜,链接在这https://github.com/Nebukam/com.nebukam.orca?tab=readme-ov-file
3.流场寻路(Flow Field)
传统寻路(A* / NavMesh)是:对每一个单位,从起点到终点求一条离散路径(拐点列表),单位沿路径走。
Flow Field 则是以“目标点”为源头,在整个地图上铺一张“势能场”,每个格子都存一个“朝目标前进的最佳方向”。单位只需要:
- 找到自己所在格子
- 读取这个格子的“方向向量”
- 朝这个方向加速/移动
换个形象的比喻,把目标点看成一个低洼水坑,整张地图做一次“地势计算”,每个格子有一个“高度”(离坑有多远)。单位就是水滴,只要沿着坡度下降方向走,就会自然流进水坑。
流场寻路关键特点:
- “路径”不是存在于每个单位身上,而是存在于“地图格子”上;
- “路径规划”是一次全局计算,之后所有单位都廉价复用;
理论上O(1),复杂度跟单位数量无关,只跟流场构建成本有关,配合一些优化流场计算的手段,可以实现极大数量的单位共同运动。
缺点也很明显,需要目标尽量固定且少量,需要地图尽量固定,因为“地势”一变,就得更新流场地图。所以很适合塔防之类的游戏,我们游戏主角到处移动,未来还考虑多人联机,因此不太适合。但流场寻路确实很优雅,某些场景可以考虑混合使用。
4.Steering + 射线 + 空间分区避障
由于前面说的,我们不追求完全最优解的寻路,且未来需要结合物理,对性能要求更高,所以最后这是最适合我们的一个方案,足够简单高性能,也可以很好地拓展。
整体思路是每个单位不做复杂的寻路,而是:
- 每个单位只知道:想去的“目标方向”(一般就是指向玩家,或者任意目标位置)
- 前面有没有墙/障碍(射线检测)
- 如果前方是通的 → 笃定往前冲
- 如果前方有障碍 → 在附近扇形范围内“试探”几个方向,找一个能通过的方向
- 加一点点“单位间排斥力”,避免全挤成一团
二、移动具体实现思路
以“单个单位”为例,每一帧大致流程是:
- 计算“理想前进方向”
- 用射线检测这条方向是否被障碍挡住
- 如果被挡,扇形范围内寻找替代方向
- 在避障方向基础上,叠加“和周围单位保持距离”的偏移
- 在最终方向上做平滑处理,并加一点点随机扰动防止卡死
- 通过刚体/位移沿这个方向移动
整个过程只决定“方向”,移动本身依然交给刚体物理和碰撞系统处理 [来源]。
下面把每一步展开讲。
第一步:理想方向——它本来想往哪儿走?
先只考虑“目标”,不考虑障碍和友方。目标可以是玩家位置、某个路径点、玩家周围的某个随机点等。理想前进方向就是:从自己指向目标的单位向量。这一方向是后面所有计算的“基准”,避障和群体行为都只是对这个基准做修正。如图:
第二步:射线检测——这条路通不通?
有了理想方向之后,下一步是判断:“如果沿着这个方向往前走,近期内会不会撞到墙或障碍?”
方法很简单,从单位中心以及单位的边缘,如果是圆形碰撞器,就是对应方向间隔半径的两条射线(可以再略大一点,留点余裕比较好),沿理想方向发射三条射线,这样可以检测出去往目标的路径上有没有能挡住自己这个体积物体的障碍。射线注意只检测“墙/障碍”所在的 Layer,优化性能。如图:
如果射线前方没有命中任何障碍,这一帧就可以直接沿理想方向移动,不需要额外绕路。如果射线撞到了障碍,说明正前方有墙或阻挡,进入“扇形扫描”阶段:
第三步:扇形扫描 + 记住绕哪边——选出“次优方向”
当前方被挡住时,单位不会立刻停下,而是在理想方向附近的一个扇形区域里,尝试若干个稍微偏左、偏右的方向,这个范围和精度可配,决定了扫描的精度和性能代价,例如配置了120度范围,每10度发一个射线。顺序就是从理想方向开始左右扩散来试探,如图:
每试一个方向,都像第二步一样用射线检测前方是否有障碍,一旦找到“前方一段距离内都没有障碍”的那个方向,就把它作为本帧的避障方向。
这里,为避免单位在某些墙角来回左右摇摆,可以给每个单位记录一个“上次绕障偏向”(例如优先偏右),下一次再遇到障碍时,优先从这一侧开始扫描,这样就能避免在墙角附近“一会儿想从左绕、一会儿又改从右绕”,导致方向抖动。
如果扇形范围内所有候选方向都被判定为“前方有障碍”,则进入下一步:
第四步:墙滑动——被挡住时别硬顶,沿墙溜过去
当理想方向被障碍挡住时,可以沿着墙的边缘走,既然正对着墙走不通,那就沿着墙面切线方向滑过去,只要不是封闭空间,总能走出去。
具体做法:
障碍碰撞检测时能得到一个法线方向(即墙面朝向单位的那一侧)
把当前想走的方向拆解成两部分:
- 垂直墙面的分量(正顶着墙)
- 平行墙面的分量(沿着墙滑)
去掉“垂直墙面、撞墙那一部分”,只保留沿墙的方向
效果上看就是:
单位不会呆呆地顶在墙上不动,而是自动“贴着墙边擦过去”。
第五步:多单位避让,空间分区方法
这一步是防止单位间重叠,肯定不能用碰撞器,性能消耗过大,同时也不能直接两两检测距离,那样的复杂度就是 O(N²),这里就要用到一个比较通用的方法:空间分区。
空间分区的核心思路就是把地图划分成网格,每个单位每帧通过自己的坐标,加入自身所属网格,每个网格里的单位,只关心自身所在格子和附近网格的单位,例如附近八格,如此,每次计算量就大大缩小。然后对过于靠近自己的邻居,施加一个推开的倾向,可以越近,力度越大。
这一步的结果是得到一个“分离向量”:它代表“为了不挤到别人,应该稍微偏移的方向”。
第六步:方向融合 + 平滑 + 随机扰动——最终行走方向
到这一步为止,我们手里已经有几种方向信息:
- 理想前进方向(只考虑目标)
- 避障/绕墙后的方向(考虑了墙和障碍)
- 来自邻居的分离方向(防止挤成一团)
- 现在要把它们合成一个最终移动方向,并做好“防抖动”处理:
这里可以进行一个加权处理,权重更高的是“避障方向”,因为不撞墙是底线。“分离方向”是其次,让单位轻微远离邻居。两者合成就是当前的新方向,但是不能直接应用,因为和理想前进方向间,会有一个跳变,就会很奇怪地抖动。为了自然,可以记住一个“当前移动方向”,每帧向新的目标方向缓慢靠近,可以用各种插值方法,不赘述。
最后得出一个行走方向后,再给一个小的随机扰动,来打破一些极端情况下的僵局,例如两堵墙对称,单位在其中尬住。
第七步:生效移动
最终方向确定后,真正的移动可以交给物理系统,也可以直接赋位置,我们选择后者,因为更省性能,完全不让多单位参与unity的物理,都由我们自己计算,也为后面的性能深度优化做铺垫。
三、加入物理影响
整体架构
整体思路是用虚拟的力,来统一衔接物理和非物理部分,对于多单位来说,所有的物理效果,都通过抽象的“力场 ForceData”来实现。我们以两个典型的物理效果,爆炸冲击波,以及黑洞引力来举例:
- 所有爆炸、黑洞等效果,不再直接去改单位,而是统一抽象成力场
- 场景中所有力场由一个ForceManager集中管理和更新
- 每个单位自己持有“受力状态”(外力速度、是否击晕等),每帧由 ForceManager 计算它应该受到多少外力
这样一来:
- 技能/道具只负责“创建某种力场”(比如在某点创建一个爆炸力场)
- ForceManager 负责“这个力场到底对哪些单位、造成多大力”
- 单位只需要把“自己的受力结果”加进最终移动速度即可
架构干净、可扩展,也便于以后加新类型的力如风场、磁场等,力的数据结构统一抽象成 ForceData后,也可以为后续ECS的深度优化做准备。
力场 ForceData
ForceData 主要字段包括:
- 位置:力场中心(如爆炸点、黑洞中心)
- 类型:爆炸、吸引(黑洞)、定向推力(冲击波)、漩涡……
- 半径:影响范围
- 强度:力的基础大小
- 持续时间:是瞬时爆炸还是持续吸引
- 时间进度:已经过了多久,用来做时间上的衰减
- 距离衰减指数:力随距离衰减的曲线(线性、平方等)
- 是否影响“可控单位”:可以控制某些力只作用于怪物,不影响玩家
单位
而每个单位自己,还会有一份“受力状态”:
- externalVelocity:由外力产生的速度(和自身移动速度区分开)
- stunTime:当前剩余的击晕时间
- mass:质量,用来控制“同样爆炸,对重单位击飞更小,对轻单位更大”
力场管理器ForceManager
ForceManager 是整个系统的“中枢”:
- 内部维护一个活动力场列表:所有仍在生效的爆炸、黑洞等等
每帧会做两件事:
- 更新所有力场的时间进度,过期的自动移除
- 提供接口给单位/UnitManager:计算某个单位当前受到的总外力
力场的创建非常简单:
- 爆炸:在某点创建一个“爆炸型力场”,半径 + 强度 + 持续时间很短
- 黑洞:在某点创建一个“吸引型力场”,半径大、持续时间长
然后计算单位受力时的逻辑,遍历所有活动力场,对每个力场:
- 算出单位与力场中心的距离
- 如果超出半径 → 这个力场对该单位无效,跳过
- 在半径内 → 按距离衰减、时间衰减计算出当前力的大小
- 根据力场类型,决定方向,爆炸就是从中心向外,黑洞就是指向中心
- 把所有力场对该单位的力向量相加,得到总外力,这样外力是线性可叠加的,多个爆炸叠在一起会更猛,一个爆炸叠加一个黑洞会产生复杂但可预期的效果。
拿到 ForceManager 给出的“总外力”之后,单位要做的事只有三步:
步骤一:把力转换成外力速度(击飞)
- 力 → 冲量 → 速度变化:力越大、单位越轻,速度变化越大
- 把这一部分速度写入 externalVelocity 中
- 为避免数值过大,给外力速度设一个最大值上限(比如 maxExternalSpeed)
步骤二:判断是否进入击晕/失控状态
- 击晕单位不能进行自主移动(不能追玩家、不能执行 AI 行走)
- 只剩下“外力产生的惯性速度”和模拟阻力在起作用
- 击晕时间随着时间减小,到 0 后恢复正常控制
- 击晕状态可以防止出现炸飞还在移动的情况,可以让被动的运动更加自然。
步骤三:外力速度的自然衰减(模拟阻力)
- 每一帧,都对 externalVelocity 施加一个全局阻力系数
- 当外力速度很小(近似 0)时,直接归零,避免永远留着一个极小的残余值
与原有移动 / 避障系统的融合
如果单位处于击晕状态,直接用externalVelocity。如果单位处于正常状态,则根据之前计算出来的移动速度向量,和externalVelocity进行相加。
四、总结和性能优化
性能优化总体有几个非常通用的思路,不同工程其实差不多。
- 通过四叉树八叉树等空间分区方法,让多单位间的查询,从 O(N²) 到 O(N)
- 通过多线程和数据管理来优化,unity里可以用DOTS,甚至放GPU去做复杂的并行计算
- 分帧,平滑每帧的消耗
- LOD思路,重要的单位算细一点,不重要的粗一点,屏幕外看不到的,更粗糙等等
- 能近似就近似,复杂运算简化,例如这个工程里就会用到的距离的平方来判断距离,减少开方,复杂函数读表等等技巧
对于我们上述的基本实现,还没进行任何优化,可以支持2000个单位60帧率运行,而且是在编辑器下,这还远远没到我们的要求,通过profiler分析(忘记截图了),不出所料,最耗性能的是射线检测部分。那我们先进行一波射线检测放JobSystem多线程处理的快速优化,一通改造代码,性能提升很明显,可以做到5000个单位,带物理效果,100帧左右。最终结果如下:
到这里,初步验证方案的可行性已经通过,可以给程序去实现了,就不再深入优化了,我只是个可怜的小策划,理论上按我之前经验,全套用ECS实现,再加上各种优化,最终能支持数万个单位同屏,考虑到最后效果复杂之后,缩到几千个也足够用了。
来源:鱼塘游戏制作工坊