☰
火焰蔓延系统设计:基于网格热扩散与状态机的性能优化实践
2026/10/11 16:12:35 网站建设 项目流程

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 Action<int> OnCellIgnited; public static event Action<int> 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模式,一遍遍点燃测试,来回浪费了大量时间。后来改成运行时改配置就能生效,一天的调参量顶过去三天。这个习惯也适用于其他任何带数值系统的游戏逻辑,属于那种"前期花十分钟、后期省十小时"的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询