火焰蔓延系统设计:基于网格热扩散与状态机的性能优化实践
1. 项目概述火焰蔓延系统的核心需求与整体思路接手火焰蔓延系统这个需求时我第一反应不是去调粒子参数而是先想明白一个基础问题火焰究竟是怎么从A点跑到B点的。这个系统挂的名字叫火焰蔓延系统设计与实现但实际做起来你会发现场景里那些飘动的火焰只是最后的视觉表现层底盘其实是一套网格数据、一张热力图和每个可燃物身上的状态机。这个系统的核心使用场景很明确某个开放世界项目里需要实现火灾在建筑、植被和物资间连续扩散的效果。玩家扔一颗燃烧瓶火焰从落点开始按照可燃物分布和热传导逻辑逐步吞噬周围物体。单纯在每个物件上挂一个被点燃就播放燃烧动画的脚本显然不够因为火焰蔓延的路径、速度、范围都是动态变化的而且还要保证同一时刻有大量物件燃烧时帧率不掉得太难看。我最终确定的技术路线是把场景空间均匀划分成网格用一张热力图记录每个网格的热量累积值再为每个网格维护一个燃烧状态机。蔓延逻辑每帧做一次热点离散点燃判定而不是基于物理的精确热传导模拟。这套方案的合理性在于对于游戏表现来说真实物理模拟的精度远超需求但开销却高一个数量级网格热扩散方案在视觉真实度和性能之间取得了最优平衡。这个系统适合两类人来参考一是需要在项目里实现火焰、毒雾、水流等区域扩散型玩法的开发二是想理解网格状态机在游戏逻辑中怎么落地的人。看完以后你至少能回答三个问题网格尺寸怎么定、火焰蔓延速度怎么调、大面积燃烧时怎么保证性能。2. 三种蔓延实现方案的选型对比2.1 射线检测方案为什么被我排除了最先想到的方案肯定是射线检测每隔一段时间从已经燃烧的物件向周围发射射线射线打到哪个物件就让哪个物件点燃。这个方案直白写起来也快但它有两个硬伤。第一个是射线密度难以控制射线太密性能扛不住太疏又会出现火焰跳过中间物体直接点燃远处物体的情况第二个是射线无法表达热量累积这个概念——实际火灾里一个物体通常不是瞬间被点着的而是持续受热一段时间才达到燃点。射线方案只能做概率点燃表现非常生硬。2.2 碰撞体驱动的方案适合什么情况还有一种做法是用碰撞体驱动给燃烧中的物件加一个球形触发区进入触发区的可燃物被点燃。这个方案在物件数量少的场景里表现不错实现也简单。但在我这个项目里燃烧场景可能有几百个物件同时处于燃烧状态每个物件挂一个碰撞体还要实时计算触发器重叠物理引擎的压力会非常大。而且火焰形状是不规则扩散的球形触发区会让蔓延路径呈现肉眼可见的圆形膨胀视觉上很假。2.3 网格邻域扩散方案到底好在哪最终选定了网格方案核心优势有三个。第一是结构简单一个二维数组就能表达整个场景的燃烧状态。第二是热量累积模型天然成立每个网格的热量值是连续浮点数到达阈值才点燃表现上会有受热—冒烟—起火的层次感。第三是性能可控每帧只处理有限数量的网格然后分帧遍历不会因为物件数量膨胀而出现物理引擎那样的连锁开销。网格方案需要处理的最核心问题是火焰传播的方向性和火焰燃烧的时间节奏。前者用邻域遍历方向加权来解决后者用独立燃烧计时器来控制。听起来简单实际操作里坑不少下面几章详细展开。3. 网格数据结构设计与状态机实现细节3.1 空间网格化与坐标映射所有逻辑都要落在网格坐标上。我在实现里定义了一个固定尺寸的二维网格CellSize网格边长取0.5米。这个值怎么定的第一项目里最小的可燃物是木箱占地约1米见方0.5米的格子能保证任何物件至少覆盖两个格子火焰扩散时格子间过渡比较细腻第二0.5米在视觉上足够表现火焰边缘的不规则性如果格子取1米蔓延路径会呈现比较明显的方块感。世界坐标和网格坐标的映射很简单gridCoord Mathf.FloorToInt(worldPos / cellSize)。由于网格数组的下标计算在每帧要执行成千上万次这里我建议全部用整数运算而不是Vector3减少隐式的浮点向量操作。我在项目中维护的类结构大致是这样的public enum FireState : byte { Idle 0, Burning 1, Burnt 2 } public class FireGridData { public Vector2Int Size; public float[] HeatMap; // 热量累积值 public FireState[] StateMap; // 燃烧状态 public float[] BurnTimer; // 燃烧剩余时间 public bool[] ObstacleMask; // 不可燃或阻挡标记 public void Resize(Vector2Int size) { Size size; int count size.x * size.y; HeatMap new float[count]; StateMap new FireState[count]; BurnTimer new float[count]; ObstacleMask new bool[count]; } }这里有一个容易被忽视的细节我不用二维数组而是用一维数组存储然后自己算index y * sizeX x。原因纯粹是性能——一维数组在内存中是连续布局遍历时CPU缓存命中性更好在载入大规模场景时GC压力也比交错数组float[,]小得多。3.2 可燃物的三种状态与计时器设计每个网格的状态机只有三个状态Idle未燃、Burning燃烧中、Burnt烧完。很多新手做火焰系统时会想加更多的状态比如烟熏即将点燃余烬但我的经验是这三个基础状态配合热量值就够了。关键点在于BurnTimer。进入Burning状态时计时器初始化为burnDuration燃烧持续时间每帧递减。计时器到零后才切到Burnt。这个中间状态决定了火焰不会像开关一样突然消失——燃烧过程中的表现、粒子发射、光照闪烁都由计时器驱动。这是火焰表现节奏的核心比单纯用bool切换状态能多出很多操作空间比如在燃烧后半段可以调低粒子的发射速率模拟火焰变弱。3.3 热量累积与点燃阈值这是整套逻辑里最值得细讲的地方。HeatMap不是布尔值而是浮点热量。每一帧处于燃烧状态的格子的邻域格子会获得增量热量数值等于spreadRate * deltaTime。网格的HeatMap达到igniteThreshold后状态机才会尝试切换为Burning。为什么要用累积而不是直接点燃因为累积过程天然产生了火焰边缘爬行感——离火源近的格子先到阈值先点燃热量继续向更远方向传播形成连续扩散波。如果直接点燃火焰蔓延就退化成了光速传染完全没有层次。随机的引入也很重要。我在点燃判定里加了一个随机因子热量达到阈值后并不是必然点燃而是有一定概率延迟或者跳过。这样火焰边缘会呈现锯齿状而不是完美的圆形扩散。实际调试时随机因子稍大一点就能模拟出草地在不同湿度下的差异化燃烧效果。4. 火焰蔓延主循环与关键代码拆解4.1 分帧批处理避免全量遍历的性能陷阱如果你直接在Update里写一个双层for循环遍历整个网格那么在300×300的网格上每帧要做9万次状态查询帧率直接崩塌。我的做法是分帧批处理一个FirePropagationSystem类维护当前遍历到的游标currentGridIndex每帧最多只处理batchCount个网格。处理完一批就yield return null或者说简单地将游标推进下一帧接着处理。这个时间切片的思路不只适用于火焰任何大规模网格逻辑都可以参考。关键是batchCount怎么确定——我使用的方法是在编辑器里分别测试200/500/1000帧处理上限下的实际帧耗找出成本线性上升的拐点。对于我的项目峰值同时燃烧的格子数约1500个batchCount取400能在60帧下保留充足余量给其他系统。4.2 主循环里的扩散与计时下面是主循环里单个格子处理的伪代码实现private void StepCell(int index) { FireState state grid.StateMap[index]; if (state FireState.Burning) { // 向8邻域扩散热量 Int2 center IndexToCoord(index); for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { if (dx 0 dy 0) continue; Int2 n new Int2(center.x dx, center.y dy); if (!InBounds(n)) continue; int nIndex CoordToIndex(n); if (grid.ObstacleMask[nIndex]) continue; float weight (dx ! 0 dy ! 0) ? 0.707f : 1f; grid.HeatMap[nIndex] spreadRate * weight * Time.deltaTime; } } // 燃烧计时归零后熄灭 grid.BurnTimer[index] - Time.deltaTime; if (grid.BurnTimer[index] 0f) { grid.StateMap[index] FireState.Burnt; grid.HeatMap[index] 0f; // 通知表现层熄灭粒子与光照 OnCellBurnout(index); } } else if (state FireState.Idle) { TryIgniteCell(index); } }扩散逻辑里的weight系数是一个容易看漏但极其重要的细节。对角方向的格子距离中心是sqrt(2)倍如果直接按同样的速率加热火焰会明显偏向斜向蔓延扩散形状变成菱形。乘上0.707即1/sqrt(2)之后各方向的扩散速率在欧氏距离上趋于一致火焰形状更接近圆形。没有这个修正你调参数的时候会发现火焰无限趋向于米字形而不是自然的片状扩散。4.3 点燃判定与随机扩散细节TryIgniteCell这段其实是整个系统里最容易改来改去的地方。基本逻辑如下private void TryIgniteCell(int index) { if (grid.HeatMap[index] igniteThreshold) return; float randomFactor Random.Range(0f, 1f); if (randomFactor igniteProbability) return; // 进入燃烧状态的瞬间要把热量保留还是清零 grid.StateMap[index] FireState.Burning; grid.BurnTimer[index] Random.Range(burnDuration * 0.8f, burnDuration * 1.2f); OnCellIgnite(index); }这里有个我踩过的坑如果你在进入Burning时把HeatMap清零那么燃烧格子本身立刻不再向邻域供热火焰传播会明显减速甚至中断因为热量的接力棒落在了邻域格子——但邻域格子的热量累积才刚刚开始往往不足以维持连续扩散。正确做法是让点燃发生后的格子继续保持一段时间的供热能力或者更简单进入Burning时不清零热量让它自然衰减让拥有余热的新燃烧格子继续加热下一圈邻域。叠加一点风的效果会让火焰形状更真实。我的实现方式是给每个格子的扩散方向加权加一个Perlin噪声场的偏移。每帧采样噪声太贵我是每0.5秒采样一次并缓存到方向偏移数组里效果上足以让火焰的蔓延方向出现轻微的偏移漂移视觉上不再完美对称。4.4 事件系统让逻辑层感知燃烧状态网格状态的切换必须通知给游戏逻辑层。比如燃烧区域里有爆炸物要在格子进入Burning的瞬间触发爆炸燃烧的建筑物门窗在温度到达阈值后自动打开这些都需要事件驱动。我定义了一个简单的静态事件中心public static class FireGridEvents { public static event Actionint OnCellIgnited; public static event Actionint OnCellBurntOut; }OnCellIgnite和OnCellBurnout就是在上面的代码里调用的。事件参数传的是网格索引而不是世界坐标因为这可以避免在事件回调里立刻做逆映射带来的GC。需要在回调里获取世界坐标的系统自己调用IndexToWorld(index)。这里注意事件系统的注册与反注册特别是场景卸载时如果在一个常驻的单例里注册了事件而没有反注册会导致场景卸载后回调仍然触发轻则报错重则逻辑错乱。我在项目的MonoBehaviour.OnDestroy里统一做-。5. 火焰表现层与网格逻辑的桥接5.1 粒子、光照与网格索引的映射逻辑层处理完蔓延接下来要解决一个网格燃烧时粒子在哪生成的问题。我维护了一个对象池池子里预置了粒子系统和点光源组件。当OnCellIgnite事件触发时从池子里取一个对象设置位置到该网格的世界坐标中心然后Play()。OnCellBurnout触发时停止发射并归还对象池。有个细节是光照数量不能无脑和燃烧格子数一一对应。100个格子同时燃烧就有100个点光源移动端是扛不住的。我的做法是根据网格世界坐标按一定距离间隔来放点光源每4~8个格子共享一个光源位置点通过光源范围覆盖相邻格子。光照对帧耗的影响往往比粒子大得多这一步优化收益极高。5.2 Shader表现热力对模型的染色与变形除了粒子火焰还有一个常见的表现需求被烧到的物体慢慢变黑甚至收缩变形。这里我把热力值传给材质Shader在Shader里采样之前烘焙好的物体蒙版贴图再用热力影响蒙版混合颜色float heat _HeatMapValue; float mask tex2D(_BurnMask, uv).r; float burnProgress saturate(heat mask);实际上我是用一个全局数组把网格热量传递给材质但全局数组在URP里的支持比较麻烦。更简单可靠的做法是在格子状态切换时以格子世界坐标为原点对一定范围内所有带Burnable组件的Renderer发出接口调用让每个渲染器自己决定采样哪个网格的热量。这个方案的性能消耗可控因为真正被烧到的物体通常只占场景的一小部分。5.3 风场与蔓延速度的视觉统一前面提到Perlin噪声给蔓延方向加了扰动实际表现上还需要让粒子本身也受同样的风场影响。否则会出现逻辑上火焰往左飘、粒子却直直往上冒的割裂感。我为每个粒子系统根据其网格位置取样同一个风场方向设置粒子的初始速度。这样逻辑层的蔓延方向和表现层的烟雾飘向是一致的玩家不会觉得着火了火苗却纹丝不动。6. 参数调优实测让火焰在真实和可玩之间找到平衡6.1 核心参数速查表以下是我项目里最终敲定的参数和它们的实际作用这些都是反复调出来的经验值在不同项目里需要按比例缩放参数名项目中使用值作用说明调参经验CellSize0.5米网格边长决定火焰边缘细腻度但也决定总网格数不能盲目调小spreadRate2.2每帧向邻域添加的热量调大加快火焰蔓延速度同时会让热量的扩散波更锋利igniteThreshold1.0点燃所需热量阈值调大延长受热点燃时间表现上出现明显的冒烟—起火延迟burnDuration6秒单个格子燃烧时间调大让火焰停留更久配合粒子持续发射燃烧区域更富有层次igniteProbability0.08点燃判定随机性调大让火焰蔓延路径不规则模拟湿度和可燃物差异batchCount400每帧处理格子数量按目标帧率调整宁小勿大处理不完的帧会增加一帧耗时6.2 温度传递的动力学节奏调参时最容易出现的困惑是火焰蔓延到底应该快还是慢。我的经验是玩家感受的火焰蔓延速度不取决于spreadRate本身而取决于热量传导节奏——也就是热量从燃烧格子传递到点燃邻域格子的时间间隔。这个间隔由igniteThreshold和spreadRate共同决定igniteThreshold / spreadRate约等于火焰横向扩展一格所需的秒数。我项目里的数值计算如下1.0 / 2.2 ≈ 0.45秒也就是每0.45秒左右火焰向外扩展一个格子。结合0.5米的CellSize火焰的横向蔓延速度约为1.1米/秒。这个速度在游戏里看起来是稳步吞噬而不是缓慢蠕动。如果你希望火焰在空旷的草原上快速推进可以把igniteThreshold降到0.8或者把spreadRate提升到3.0以上但注意不要让蔓延速度快于粒子效果的可感知粒度。6.3 随机因子的边界与失控防范igniteProbability这个参数是有边界的。取值太大比如0.5火焰就会在蔓延路径上出现大量空洞——某些格子明明温度够了却不点燃火焰路径被截断视觉上像一块块孤立的小火堆。取值太小0.01则火焰边缘会异常整齐。我最终取的0.08在当前网格尺寸下表现最好。另外还有一个容易被忽略的燃尽冷却参数。格子从Burnt状态变为完全不可燃时如果场景里还有大范围余热邻域格子会被不断加热。这时候如果余热高于点燃阈值已烧完的区域会二次燃烧造成逻辑混乱。我的处理方法是在格子进入Burnt后强制把周围格子的HeatMap减去一个冷却量模拟烧完区域的吸热降温。这个操作相当于给热扩散系统加了一个负反馈从根上避免燃烧—熄灭—再燃烧的死循环。7. 实战中遇到的典型问题与排查记录7.1 火焰从中心熄灭而不是向外扩散这是我调试早期遇到的最诡异的问题一堆干草点燃后火焰只烧了中心一圈就停了外圈始终没有着火。排查后发现是进入Burning时把该格子的HeatMap清零了——如上文所述这会导致火焰的热度接力中断。排查这个现象的建议是可视化调试比猜代码高效得多。我在OnDrawGizmos里把HeatMap用颜色画在场景中颜色越红表示热量越高。这样一眼就能看见热量扩散的波纹在哪里断了。如果你发现热量只集中在中心而无法向外传播就先检查燃烧格子的邻域扩散代码如果热量明明到了外圈格子但没点燃再检查点燃判定逻辑。7.2 火焰绕过障碍物的穿墙问题另一个高频问题是火焰会透过薄墙体烧到背面。根源在于网格的ObstacleMask虽然阻断了热量扩散但我没有阻断粒子表现和视觉穿透。在墙体比较薄的场景里玩家能看到火焰粒子从墙体另一侧冒出来。处理方法是双层的逻辑上保证阻挡格的ObstacleMask为true后完全跳过热扩散表现上粒子系统的发射位置在生成时检查网格状态如果目标位置被标记为阻挡则把粒子发射点偏移到燃烧格子的中心而非被阻挡的邻接边缘。这样视觉和逻辑就对齐了。7.3 大规模燃烧时帧率直线下降在大地图实测时火焰扩散到约300个格子时帧率从60掉到40。用Profiler查看后发现耗时的元凶不是网格遍历而是事件触发的对象池取放操作——每个格子进入Burning时都要实例化一个粒子系统加一个光源Transform的SetParent和position赋值在大量触发时有明显CPU开销。优化手段有三步。第一把对象池的GameObject预激活改为手动SetActive避免OnEnable里重复执行昂贵的初始化。第二光照池的大小限制在32个超出后不新增加光源而是让现有光照对象动态移动到最近的新燃烧格子上相当于光源跟随最大热量区域。第三粒子系统在远离相机一定距离后直接不发射用着色器模拟远处火焰的闪烁即可。7.4 热量扩散数值的精度问题当网格数特别大、燃烧持续超过60秒后我发现有些格子的热量值变成了NaN排查发现是持续的浮点累加导致数值溢出。这个问题在PC上可能不明显但在移动端低精度浮点下很容易触发。我在每次累加后加了一个if (float.IsNaN(HeatMap[index]))保护同时限制每帧热量增加上限为Time.deltaTime * 5从源头避免了极端值出现。7.5 分帧批处理导致火焰跳动分帧处理虽然保证帧率但带来了新的问题火焰的边缘不再平滑推进而是以批次节奏一顿一顿地扩展视觉上能察觉到卡顿感。原因很简单每帧只处理400个格子那么热点扩散到边缘的时间被拉长了。缓解方案是预热扩散——当某个网格被点燃时立刻连续计算若干次热扩散不等下一帧把这个格子的热量传播到邻域。这样做相当于用局部计算替代了全局帧循环的延迟让点燃动作后的第一秒内火焰边缘就有足够的前进趋势后面的分帧处理只是补足剩余部分。我把这个预热扩散次数设为5次实测下来火焰推进明显更平滑。7.6 事件回调内的性能陷阱最后单独提醒一个很容易踩的坑事件回调里不要直接做重量级操作。我最初在OnCellIgnite里直接播放音效、查找范围内的所有敌人结果性能全部堆在回调里。正确处理方式是事件里只标记脏区把后续工作放到一个队列里由专门的系统在Update末尾按批次处理。这样即使在极端规模燃烧下单帧的GC和CPU开销也是可控的。8. 进阶扩展方向从二维网格到更真实的燃烧模型这个系统做到现在这个程度基础的火焰蔓延已经相当能打了。如果你想让它在真实性和玩法深度上再上一个台阶我有几个下一步的扩展建议这些方案我都整理过但没有全部落地。第一个方向是给网格增加燃料量属性。不同物体燃料值不同燃烧过程中每帧消耗燃料燃料耗尽才进入Burnt状态。这能天然模拟干草之类的低燃料物体快速烧完而建筑框架之类的重燃料物体持续燃烧更久。燃料值可以让火焰蔓延速度因为沿途消耗而自然减缓不需要额外调参。第二个方向是加入高度维度的蔓延。目前的二维网格只能模拟平面扩散遇到多层结构的建筑时火焰应该能通过地板缝隙向上层蔓延。实现方式是增加一层垂直邻域关系表当底层燃烧格子的热量积聚到阈值后可以给上层网格添加热量。在开放世界的多层建筑里这种上下蔓延的破坏效果特别有视觉冲击力。第三个方向是火焰对物体状态的可逆或半可逆反馈。我目前只有Idle/Burning/Burnt三种终态但在一些沙盒玩法里玩家可能希望用水灭火后恢复部分可燃烧状态。这需要状态机增加一个Cooling状态把燃烧后的残骸与冷却后可再次燃烧的物体做区分。设计上没有太大难度但状态转换矩阵会多出一倍排查时需要额外小心。第四个方向是通过GPU加速热扩散计算。如果网格规模上万CPU分帧遍历仍是瓶颈可以尝试把扩散逻辑用Compute Shader或者Unity.Jobs的并行化来处理。热量扩散本质上是邻域均值迭代天然适合并行。但要注意Unity的Job System里Random不支持IJobParallelFor需要在每个Job里单独生成随机种子这也是一个我现在还没彻底解决的细节。针对实际项目的落地我建议先把二维网格框架跑通、把参数调顺再按需叠加高度和燃料模型。如果一开始就把目标定得太物理正确很容易陷入模拟精度的泥潭反而忽略了火焰系统在玩法层面最核心的价值——它是一个能同时影响场景破坏、敌人AI、玩家决策的区域控制型机制而不是一个纯粹的视觉装饰。我在实际开发里还有一个体会比较深这个系统一半的时间在写蔓延逻辑另一半的时间其实是在处理状态切换的边界情况。火焰蔓延的代码如果你拿掉所有边界检查和防呆逻辑大概能缩短三分之一但运行时你会被各种异常燃烧路径折腾到怀疑人生。所以如果你在使用这套网格方案请务必在开发初期就把调试绘制工具做好——把热力图、状态颜色、网格坐标全部可视化成Gizmos相信我这个投入的回报远比你想象的要多。最后再分享一个实操小技巧参数调优阶段千万不要在Inspector面板里手拖数值一定要把关键参数暴露到ScriptableObject配置里然后写一个简单的热重载逻辑。我早期每次改参数都要进Play模式一遍遍点燃测试来回浪费了大量时间。后来改成运行时改配置就能生效一天的调参量顶过去三天。这个习惯也适用于其他任何带数值系统的游戏逻辑属于那种前期花十分钟、后期省十小时的投入。