ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

游戏物理建模与粒子系统全解析:从核心公式到性能优化

游戏物理建模与粒子系统全解析:从核心公式到性能优化

做Windows游戏,物理建模和粒子系统是绕不开的两个模块,它们直接决定游戏的“手感”和“观感”。物理让角色、子弹、箱子动得合理,粒子让火焰、爆炸、烟尘这些效果看着过瘾。这篇文章是我在某横版闯关Demo里把这两套系统从零搭到稳定运行后的完整复盘,包含核心公式、工程取舍、踩坑记录,适合正在写游戏逻辑、想搞懂物理模块怎么设计,或者被粒子效果卡帧率卡到怀疑人生的开发者。

先说一个总的判断:游戏里的物理,不需要像学术仿真那样精确,但必须稳定、可控、够快。粒子系统也没多玄乎,本质上是一堆带生命周期的小数据在循环里跑。把这两个认知立住了,后面的实现就不会走偏。

1. 先理清楚:游戏里的“物理”到底在算什么

1.1 游戏物理不等于教科书物理

很多人第一次接触游戏物理,容易陷进一个误区:把真实世界的力学公式原封不动搬进来。真这样做,结果多半是“看起来反而不真实”。

物理引擎追求的不是精确,而是“可信”。物体的运动要符合直觉,碰撞要看起来扎实,弹跳要有一点合理的衰减。至于能量是否严格守恒、摩擦系数是否和真实材料一致,游戏里没人关心。玩家只会觉得“这箱子踢一脚滚得挺自然”,而不会拿游标卡尺去量它滚了多远。

再说实时性。游戏跑60帧,一帧只有16毫秒左右,物理计算通常只能分到2到3毫秒的预算,场景里还可能有上百个动态物体。这意味着数学模型必须足够简单,简单到能在这几毫秒内跑完所有物体的更新。所以我从一开始就选用了刚体模型,而不是柔性体或者流体模型。刚体把物体简化成有质量、有速度、有形状的刚性集合,忽略形变,只算平动和转动,这是游戏物理的绝对主流,也是性能和稳定性的双重保障。

1.2 三个核心模型:刚体、碰撞、力

我当时写这个Demo,把物理模块拆成三块:刚体运动、碰撞检测、碰撞响应。

刚体运动的核心是牛顿第二定律。每个物体保存位置、速度、质量,每帧根据受力算出加速度,再更新速度和位置。公式不复杂,但顺序有讲究:

加速度 = 合力 / 质量
速度 += 加速度 * 时间步长
位置 += 速度 * 时间步长

这个积分方法叫半隐式欧拉,先更新速度,再用新速度更新位置。别小看这个顺序,我后面踩过一个“弹簧越弹越高”的坑,就是因为积分顺序写反,导致系统能量不断被注入,物体越蹦越离谱。真实物理里能量应该缓慢衰减,而错误的积分会让它凭空增加。

碰撞检测解决“两个物体有没有碰到”的问题。全场景两两检测是O(n²)复杂度,物体一多就完蛋。所以要做两阶段:宽相先用AABB包围盒粗筛,窄相再用圆形、多边形等精确形状测定相交。AABB的相交判断很简单,就是两个轴对齐矩形检查重叠。但我要提醒一句:矩形检测本身便宜,真正的成本在调用次数上。100个物体全量两两比较就是4950次检测,一帧的预算几下就顶光了,所以宽相筛选不是优化,是必须。

碰撞响应解决“碰了之后怎么动”。最常用的是冲量法:碰撞瞬间根据两个物体的质量和相对速度,计算一个冲量改变双方速度。关键参数叫恢复系数,范围0到1,决定弹跳大小:0是完全不弹,1是完全没有能量损失。我Demo里地面用了0.3,金属箱用了0.6,手感差别非常大。这个参数建议做成可配置项,别写死在代码里,后面调手感会频繁用到。

2. 粒子系统:一组参数就能变出火焰、烟尘和爆炸

2.1 粒子的本质是一份“会过期的状态”

粒子系统最初进游戏,是为了模拟那些没法用刚体模型描述的效果:火焰、烟雾、爆炸、火花、雨雪、拖尾。这些东西的共同特点是数量多、单个无意义、整体有形状。你不会关心某一个小火星飞到了哪里,但一整片火星从爆炸点喷出来,观感一下子就来了。

所以粒子的数据结构非常轻。每个粒子就是一份状态记录:位置、速度、剩余生命、颜色、大小。生命值每帧递减,减到0就回收。发射器负责持续或间歇地往系统里“吐”新粒子,指定它们的初始位置、速度方向、颜色和寿命。粒子之间互不通信,独立更新,这是粒子系统天然的并行基础,也让它特别好调试。

我Demo里的爆炸效果用了200个粒子:50个亮的橙色大颗粒负责火球,150个灰色小颗粒负责烟。火焰的粒子初始速度高、衰减快,烟的粒子速度低、寿命长。同一个发射器,把初始速度和寿命分布调整一下,就能从“爆炸”变成“烟花”,再变成“喷泉”。这些参数组合起来的效果,我建议做一张速查表:

效果粒子数初始速度寿命颜色渐变
爆炸火球50高短橙→红→透明
爆炸烟尘150低中灰→深灰→透明
火焰100-200中短黄→橙→透明
落雨300+极高短浅蓝→透明
火花80极高极短亮白→黄→透明

2.2 发射器与粒子生命周期

发射器不是一个复杂的实体,它更像是“生成参数的配方”。调发射器时,四个参数值得反复试:

初始速度。决定粒子飞多远,火焰的初始速度明显高于烟。

速度衰减。模拟空气阻力,让粒子慢下来,没有衰减的粒子会匀速飞出去,观感很假。

寿命。决定粒子存在时间,烟的寿命通常比火长,所以爆炸结束还能看到烟慢慢散开。

颜色渐变。控制粒子从出生到消亡的颜色和透明度变化,这是效果不生硬的关键。

更新粒子的代码逻辑,我一般只做三件事:先减生命,再按加速度更新速度(比如重力让烟往上飘、让火花往下落),最后更新位置。透明度跟随剩余生命比例线性衰减。如果漏掉透明度渐变,粒子到点瞬间消失,画面会一帧一帧地“闪没”,非常出戏。

粒子系统内部有三个状态:发射器激活持续生成、激活停止但剩余粒子继续飞行并消亡、粒子清空后系统休眠。很多新手只处理前两种,忘了第三种休眠态,导致场景里挂着一堆永远不更新的空系统,白白占CPU。

2.3 渲染端的配合

粒子渲染也有讲究。在Windows游戏里,最常见的做法是把粒子模块放在独立渲染层,用带纹理的四边形来表现单个粒子,四边形始终面向摄像机,这就是常说的billboard。这样粒子不管在哪个角度,看起来都是“正面”对着玩家。

贴图选择上,圆形模糊渐变图比方形图效果明显更好。同一张贴图配上不同颜色,就能渲染火焰、烟、光晕好几种效果,不用每套效果单独准备美术资源,省事还统一。

渲染顺序是容易翻车的地方。粒子是半透明的,需要和不透明物体做深度排序:先画所有不透明物体,再画粒子层。粒子之间如果叠加很多,还得按距离从远到近绘制。不排序的后果是,某些视角下粒子遮挡关系错乱,火焰明明在墙后面,反而盖住了墙,一眼假。

再一个重点是合并绘制批次。我最初一个粒子一次绘制调用,500个粒子就是500次调用,帧率直接掉到30以下。改成把相同贴图的粒子合并到同一批次以后,同样500个粒子只需要几次调用,帧率马上回到60。这一步不做,粒子数量稍微上去必卡。

3. 实战搭建:Windows环境下的物理+粒子框架

3.1 整体架构与模块拆分

我最早写这套系统时,把物理和粒子全堆在一个update函数里,几百行代码互相纠缠,改一个地方崩一片。后来重构成三个独立模块,各自职责清晰:

物理世界(PhysicsWorld)负责管理所有刚体、检测碰撞、更新运动。粒子系统(ParticleSystem)负责管理发射器和粒子池。渲染层(Renderer)只负责把物理世界和粒子系统的结果画出来,不参与逻辑计算。

模块之间不直接调用,通过接口和回调交互。比如角色扔出一枚手雷:物理世界负责手雷飞行的抛物线,落到地面通过回调告诉游戏逻辑“手雷落点了”,逻辑层再在落点创建爆炸粒子发射器。这个事件驱动设计让后面加功能变得很省事,火箭、冰霜、传送门这些新玩法,本质都只是换了一组粒子的参数和一段碰撞回调。

物理世界内部,我按“动态/静态”把碰撞体分了两类。动态体每帧做完整物理更新,静态体只参与碰撞检测,不更新位置。这个区分非常重要,场景里的平台、墙体数量多且永远不动,如果全都当动态对象做整套积分和响应,性能浪费非常明显。

3.2 物理模块的关键实现

物理模块的核心逻辑,在我这里就三件事:运动积分、碰撞检测、碰撞响应。代码不复杂,但每一步都有容易踩的细节。

运动积分用半隐式欧拉,前面说过顺序问题。这里再补一点:角速度也要更新。每个刚体保存角度和角速度,碰撞时根据碰撞点偏离质心的程度,给物体一个旋转增量。这个模型虽然不精确,但在2D视角下观感非常自然,箱子被斜着踢一脚会旋转着飞出去,真实感比纯平动强一个量级。

碰撞检测是“宽相AABB + 窄相圆/矩形”组合。宽相粗筛时,每个物体维护AABB包围盒,盒与盒不相交的直接跳过。窄相精确检测,因为Demo里大部分物体是圆形角色和矩形箱子,所以我分别处理圆-圆、圆-AABB、AABB-AABB三种情况,用坐标轴分离法判定。这里有个建议:AABB包围盒要比物体实际轮廓大一点点,留出误差余量,不然高速物体边缘擦过时检测会漏。

碰撞响应用的冲量法,核心步骤:

计算两物体在碰撞法线方向上的相对速度分量。
根据恢复系数算出目标冲量大小。
按质量比例把冲量分配给两个物体。

质量分配这块必须小心。很轻的物体撞上很重的物体,轻的应该被弹飞,重的几乎不动;反过来也一样。如果没按质量分配冲量,就会出现“轻物体把重物体推着走”或者“重物体被轻物体轻松挡住”的怪异表现。

穿透问题我在Demo里也碰到了。物体移速快、时间步长又大时,上一帧还在墙外,下一帧已经穿过墙体。我用的解法是位置修正:一旦检测到穿透,就把物体沿碰撞法线推回到接触面,再继续计算冲量。这个方法简单、稳定,物理表现上是最划算的选择。

3.3 粒子模块的关键实现

粒子模块我用了固定数组池,预分配2048个粒子空间,用一个索引在数组里循环复用。生成粒子时从池里取一个空闲项,粒子死亡后标回空闲,下次生成直接复用。整场游戏跑下来,粒子相关的堆内存分配次数几乎为0,内存碎片问题从根上就避开了。粒子的结构长这样:

struct Particle { bool alive; // 是否存活 float x, y; // 位置 float vx, vy; // 速度 float life; // 剩余生命 float maxLife; // 初始生命 float size; // 尺寸 float r, g, b, a; // 颜色 }; class ParticlePool { std::vector<Particle> pool; int capacity; // 预分配数量 int cursor; // 循环索引 };

发射器有一个生成间隔,每隔固定毫秒数生成一个新粒子。爆炸这种一次性效果,发射器在触发瞬间一次性生成全部粒子,随后进入“只更新不生成”状态。火焰这种持续效果,发射器常驻,按间隔持续生成。这里提醒一下:持续发射器要控制每秒生成总数。150个火焰粒子按60帧算,每秒就是9000次单个粒子更新,单看不算多,但屏幕上多个火焰叠加,压力会迅速上升。

粒子更新函数里,我把计算压到最简:只做位置平移、速度衰减、生命递减、颜色计算。不做碰撞检测,粒子和场景碰撞的代价高、收益低,火苗钻过门缝和火花落地弹跳这类需求,宁可单独写轻量逻辑,也不硬塞进通用粒子系统。

3.4 时间步长与游戏循环

物理和粒子的稳定性,很大程度取决于时间步长怎么处理。我有段时间发现,窗口不聚焦时帧率掉到20,恢复聚焦后物理表现完全不同。原因很直接:我用了可变时间步长,每帧更新的物理量不一样,碰撞响应和粒子速度全都不稳定。

后来改成固定时间步长:物理固定按1/60秒步长计算,每帧用累积器累加真实流逝时间,再循环跑物理更新,直到剩余时间不足一个步长。渲染部分则对两个物理状态之间做插值,保证画面平滑。这个方案稳定之后,窗口无论怎么切,物理表现都完全一致。核心逻辑就是下面这段:

accumulator += frameTime; int steps = 0; while (accumulator >= FIXED_DT && steps < MAX_STEPS) { physicsWorld.update(FIXED_DT); accumulator -= FIXED_DT; steps++; } if (steps >= MAX_STEPS) accumulator = 0; // 防止积压

固定步长循环有个必须防的坑:后台挂机久了,累积器可能累加几秒钟的时间,恢复时物理一下子追算成百上千步,等于瞬间把整个物理世界“重放”一遍,表现会乱套。我加了上限,一帧最多执行5次物理更新,超出部分直接丢弃。宁可掉帧,也不能让物理世界失控。

4. 性能与稳定性:帧率掉下去的坑都在这里

4.1 内存与DrawCall是两大隐形杀手

粒子系统最容易拖垮帧率的是内存分配和绘制调用,这两个坑新手最容易踩。每帧新建粒子、每帧释放粒子,500个粒子就是500次堆分配,不管用不用自动回收,卡顿都在所难免。对象池把分配次数归零;批量绘制把绘制调用从几百次压到几次。两个都做了,粒子模块基本不会成为性能瓶颈。

物理模块的性能大头则在碰撞检测。前面说的宽相粗筛是第一步,空间分区是第二步。把场景切成均匀网格,物体按位置放进对应格子,检测时只查自己所在格子和相邻格子。100个物体分布在100个格子里,每格的检测对象数量大幅减少,整个物理更新的耗时能降到原来的十分之一甚至更低。

4.2 粒子数量与绘制密度的平衡

粒子数量不是越多越好看。我实测下来,效果观感在粒子数从几十增加到两三百时提升最猛,再往上翻倍,画面差别不大,性能却在直线下滑。所以我把单个效果默认上限设为256个粒子,持续效果单独限制,防止场景里多个火焰叠加时失控。

距离裁剪也是一招。粒子离摄像机太远,肉眼根本看不清细节,这时候降低生成速率或者直接不画,省下的开销很可观。我是在发射器层级做这件事:发射器距摄像机超过设定距离,生成速率自动降到原来的5%,观感上完全察觉不到,帧率却稳住了。

绘制批次还可以按贴图归类。同一张粒子贴图的所有粒子合并到一次绘制调用,不同贴图分成不同批次。加上动态合批,粒子系统在Windows平台上的表现已经很接近极限。以我Demo的经验,同时运行三个火焰、一个爆炸、同步的雨滴,帧率依然稳在60。

5. 我踩过的坑:常见问题与排查方法

5.1 现象、原因、解法速查表

把我在调试中遇到的典型问题整理成表,方便直接对照:

现象根因解法
物体高速穿过墙体单步位移超过碰撞体厚度启动位置修正,按法线推回接触面
弹簧/滚动物体越动越快积分顺序错误导致能量注入改成半隐式欧拉,先速度后位置
物体原地抖动可变时间步长导致速度波动固定步长加累积器,渲染插值
粒子到点突然消失没有透明度渐变透明度按剩余生命比例插值
粒子遮挡关系错乱半透明物体没做深度排序先画不透明,再按距离画粒子
帧率骤降每个粒子独立绘制或频繁分配内存对象池加批量绘制
切窗口回来物理异常累积器积压太多时间限制一帧最大物理更新次数

5.2 调试物理世界的几个技巧

调试物理系统,我最大的体会是:一定要可视化。物理数据是数字,肉眼看不到,出问题没法靠猜。我在调试版本里开了两个开关,一个把碰撞体画成线框,一个把粒子生命值映射成颜色。这样看一眼画面,就知道碰撞盒是不是偏大、粒子是不是早衰。

第二个技巧是暂停单步。物理出问题时,暂停游戏,手动执行一帧物理更新,逐步观察位置和速度的变化。在监视窗口里看速度是不是爆炸、位置是不是穿模,比凭感觉乱调参数高效太多。

第三个技巧是给关键物体打印运动日志。刚体飞出场景这类问题,日志能告诉我它是在第几帧、被哪个碰撞点以多少速度弹出去的,排查路径一下子就清晰了。日志要精简,只记录坐标、速度、碰撞对象ID三个字段,刷屏反而降低效率。

还有一条经验:调参时一次只改一个。恢复系数、粒子寿命、发射间隔这些参数互相影响,同时改多个,出了问题根本定位不到元凶。我有一次连续调了重力、恢复系数、粒子初速三个参数,结果火焰忽上忽下,排查了半小时才发现是重力被误改大了。这种低级错误,靠“一次只动一个”的习惯完全可以避免。

最后再分享一个习惯:物理和粒子模块做完后,一定加一个压力测试模式。在Demo里一次性生成上千个物体、几千个粒子,先把性能压垮再优化,等扛住压力了,再回到正常游戏量级。这个习惯帮我提前发现了批量绘制和对象池的必要性,也帮我验证了固定时间步长的稳定性。如果你也在做类似的功能,建议把压力测试当成上线前的固定步骤,而不是可选项。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进