电量燃烧的声音、森林在数十秒内化为灰烬的画面往往能直接点燃玩家的情绪。但真正动手去做的时候会发现火焰蔓延和普通的技能特效完全是两码事。它不像扔一个火球爆炸特效也不像给某个物体挂一个持续燃烧的粒子而是一个由点到面、由面到片的持续扩散过程。我最近在做某生存游戏Demo时就被这个需求结结实实教育了一轮玩家往草地扔一根火把火焰会顺着可燃物扩散点燃附近草丛、树干也可能烧掉玩家刚搭好的木屋。起初我以为粒子系统加碰撞检测就能应付实际开发完才发现这个系统的难点根本不在火焰而在蔓延——用什么数据结构描述燃烧状态怎么判断热量是否足以点燃邻居如何保证几十个物体同时燃烧时游戏不卡成PPT都是需要提前想清楚的问题。这篇开发记录就把我完整的设计与实现过程拆开来讲包含状态机设计、蔓延算法、视觉表现和性能优化几个部分做生存类游戏或需要火焰扩散机制的独立项目都可以直接参考。1. 需求拆解火焰蔓延系统到底要管哪些事在写第一行代码前我把需求往细里拆了一遍。玩家场景中可被点燃的物体主要分成三类低矮的草地、高一点的树干、还有玩家建造的木制结构。火焰蔓延的完整链路应该是点燃 - 小范围起火 - 向周围可燃物扩散 - 持续燃烧 - 燃尽 - 留下灰烬并逐渐熄灭。这条链路上每一个环节都要变成可计算、可调参的规则否则做出来的火势要么乱来要么完全不受控。我把需求收敛成五个核心问题第一一个物体要满足什么条件才会被点燃第二点燃之后燃烧多久、强度多大第三火焰怎么判定附近还有其他可燃物第四蔓延速度是匀速还是有随机性第五当多个物体同时燃烧时性能怎么保证。针对这五个问题我定下了一个总的设计方向把场景里的每个可燃物都抽象成一个带有状态和燃料属性的节点火焰蔓延的本质就是状态在邻居节点之间持续传递。视觉效果上的火焰粒子只是这个状态的外在表现不是系统本体。1.1 为什么不用粒子碰撞来判定燃烧这是最容易踩的坑我第一版原型就差点掉进去。最直接的想法是给火焰粒子加上碰撞体粒子碰到哪个Collider就点燃哪个听起来很物理实际上问题很多。粒子系统的碰撞检测开销不小粒子数量一大帧率立刻受影响粒子运动带随机性碰到哪里完全不可控经常出现隔着一个格子点不燃或根本没碰到却点燃了的诡异情况更关键的是粒子碰撞没有办法表达热量积累的概念——火源一撤走之前被烤热的物体立刻没事了这不符合火焰会自己旺起来的直觉。所以我从一开始就定了原则火焰的蔓延判定和视觉表现完全分离。逻辑层用一套独立计算来决定某个物体是否进入燃烧状态视觉层只是负责把这套状态渲染得好看。这样做的最大好处是逻辑可以被暂停、被逐帧检查、被参数化调优而不是依赖一堆粒子偶然碰撞出来的结果。1.2 网格方案还是物体挂载方案火焰蔓延的实现大致有两种流派。一种是把整个场景划分成规则的网格每个格子记录是否可燃、燃烧状态如何适合大范围野火、沙盒类游戏。另一种是直接在每个物体上挂点燃组件物体之间靠距离判断蔓延适合房间级或物体数量不太大的场景。我最后选的是物体组件 空间网格加速的混合方案。每个可燃烧的物体挂一个BurnableObject组件系统内部用一个均匀网格去快速查找某个位置附近有哪些可燃物。为什么要加网格因为如果全场有两百个可燃物每个燃烧中的物体都要遍历所有物体来判断邻居距离那就是两百乘两百的运算量场景一复杂就完了。有了空间网格之后只需要查当前燃烧物体附近的一小撮对象效率高很多。这个折中方案在项目后期表现很稳两千个可燃物同时存在时仍然流畅。2. 燃烧状态机与燃料配置把着火变成可计算的数据要让火焰蔓延系统可预测、可调优第一步是把着火了这件事建模成状态机。我在这个项目里把每个可燃物的状态分成四个阶段未燃烧、闷燃预热、燃烧中、燃尽。表面上看是从0到3的正常流程但每个状态转换条件都经过仔细设计尤其是闷燃这个过渡态它比直接进入燃烧更贴近现实也给玩家留出了反应时间。2.1 四段式状态机的设计逻辑状态含义进入条件表现Intact未燃烧初始状态正常材质Smoldering闷燃/预热累计热量超过点燃阈值冒烟、表面变暗Burning燃烧中闷燃计时结束火焰粒子、光照、持续放热BurnedOut燃尽燃烧持续时间耗尽灰烬材质、烟雾渐弱我把闷燃单独提出来是有原因的。木材虽然最终会着火但表面要先被火焰烤热这个过程有几十秒的过渡草地可能很快就烧起来但也需要一点预热时间让效果更真实。状态定义用枚举写在代码里public enum BurnState { Intact 0, Smoldering 1, Burning 2, BurnedOut 3 }每个可燃物组件保存当前状态和燃料数据状态转换逻辑全部集中在私有方法里比如EnterSmoldering、EnterBurning、EnterBurnedOut这样调试断点的时候能清楚看到是从哪个条件触发的转换不至于靠猜。public class BurnableObject : MonoBehaviour { public FuelProfile fuel; public BurnState State { get; private set; } public float CurrentHeat { get; private set; } private float _remainBurnTime; public void AddHeat(float amount) { if (State ! BurnState.Intact) return; CurrentHeat amount; if (CurrentHeat fuel.maxHeat) { EnterSmoldering(); } } private void EnterSmoldering() { State BurnState.Smoldering; // 播放烟雾粒子、切换材质变暗 Invoke(nameof(EnterBurning), fuel.smolderingTime); } private void EnterBurning() { State BurnState.Burning; _remainBurnTime fuel.burnDuration; // 播放火焰粒子、点亮光照、进入活跃燃烧队列 } private void TickBurning(float deltaTime) { _remainBurnTime - deltaTime; if (_remainBurnTime 0f) { EnterBurnedOut(); } } }2.2 燃料配置用ScriptableObject做数据资产不同燃料的燃烧特性差异很大。草地烧得快、蔓延快树干烧得慢、持续久油桶几乎是碰到就炸。如果这些数值全部写死在代码里每次调参都要改代码重新编译非常痛苦。我把燃料相关参数封装成了一个ScriptableObject资产[CreateAssetMenu(fileName FuelProfile, menuName FireSim/FuelProfile)] public class FuelProfile : ScriptableObject { public string fuelName; [Header(燃烧参数)] public float maxHeat 100f; // 需要积累多少热量才会被点燃 public float smolderingTime 0.5f; // 闷燃阶段时长 public float burnDuration 5f; // 燃烧状态持续时长 public float heatOutput 30f; // 燃烧时每秒向周围输出的热量 public float spreadRadius 1.2f; // 热影响半径 [Header(表现参数)] public float flameSize 1f; public Color emberColor Color.white; public ParticleSystem flamePrefab; public ParticleSystem smokePrefab; public float lightRadius 5f; public float lightIntensity 3f; }我为项目配置了四套典型燃料作为平衡性基准燃料点燃阈值闷燃时间燃烧时长每秒热量设定思路草地300.2s2s20火苗矮、扩散快、烧完快树干1001.5s8s35点燃慢、持续久、热量高木栅栏801.0s12s25持久火墙、蔓延稳定油桶100.0s3s80极易点燃、高温爆发这张表是后续所有调参的依据。美术或策划调整数值以后我都会回到表格上核对每种燃料在整条链路里的位置避免出现草地比木头还难烧这种反直觉设定。数据驱动带来的一个额外好处是不同玩法模式可以复用同一套系统只是换配置而已代码层面一次都不用动。3. 蔓延算法邻域热传递、风向与障碍物状态机和数据配置搭好之后核心计算量集中在蔓延算法上燃烧中的物体如何把热量传给邻居谁先被点燃风向怎么影响扩散方向这些才是系统真正运转的引擎。3.1 邻居查询与热传递的主循环热传播的关键不在于每帧把所有可燃物都算一遍而在于只算正在燃烧的那一部分。每个燃烧中的物体每秒都向自己的热影响半径内输出热量位于范围内的邻居如果处于未燃烧状态就持续积累热量private void SpreadHeat(BurnableObject source, float deltaTime) { ListBurnableObject neighbors fireGrid.GetNearby( source.transform.position, Mathf.CeilToInt(source.fuel.spreadRadius / gridCellSize) ); foreach (var neighbor in neighbors) { if (neighbor source || neighbor.State ! BurnState.Intact) continue; float dist Vector3.Distance(source.transform.position, neighbor.transform.position); if (dist source.fuel.spreadRadius) continue; neighbor.AddHeat(source.fuel.heatOutput * deltaTime); } }这里有个非常重要的小细节热量不应该无限累积。物体在没有持续热源的时候热量会缓慢衰减回室温否则火源经过后热度一直残留整片区域很快就会变成一片热汤锅二次点燃的判断会乱套。我在AddHeat的接口里加了一个每帧的热量冷却逻辑没有热源时CurrentHeat以每秒约10%的速度下降。这个衰减速度也需要根据燃料类型微调木材保温能力强衰减慢草地的热量散得快衰减快。3.2 热量累计模型而不是纯概率火焰蔓延有两种常见的算法模型。第一种是概率模型每次检查时roll一个0到1的随机数如果小于蔓延概率就点燃。优点是写法简单有随机性缺点是太玄学没法解释为什么这棵树烧了三秒还不着也没法精确预估一片区域烧完的时间。第二种是我最终采用的热量累计模型每一帧给邻居累加热量热量达到阈值才点燃。热量累计模型让所有参数都带有物理含义想让火势慢一点要么降低heatOutput要么提高maxHeat调参直觉非常清晰。它还能天然支持很多玩法扩展比如雨天给所有物体加一个湿度字段抬高maxHeat火焰就很难烧起来阻燃剂可以把maxHeat临时拉到一个极高的值风力会改变热量的传递方向。这些都是在数据层做修改不需要回炉改逻辑。3.3 风向和风速怎么加进算法野火的蔓延并不是各方向均匀的风向影响是必须考虑的一个因素。我把全局风场做成了一个单独的WindField组件它保存当前的风向和风速燃烧中的物体在传热给每个邻居时通过点积运算计算风的影响Vector2 toNeighbor (neighbor.position - source.position).normalized; float alongWind Vector2.Dot(toNeighbor, windField.CurrentWindDirection); float heatEfficiency 1f alongWind * windField.CurrentWindSpeed * 0.3f; heatEfficiency Mathf.Max(0f, heatEfficiency); neighbor.AddHeat(source.fuel.heatOutput * heatEfficiency * deltaTime);点积的结果在顺风方向是正数逆风方向是负数。这样实现之后起风时顺风一侧的火头蔓延得特别快逆风方向可能只是升温但够不到点燃阈值玩家体验就会形成顺风逃生、逆风围堵的有趣局面。系数0.3是调试出来的手感值不追求通用正确不同游戏需要按自己的节奏调整。3.4 障碍物与防火隔离火焰不应该穿过墙和石头地面。最简单也最稳妥的方案是在两个物体之间做一次直线射线检测检测到不可燃物体就阻断热传递。场景物数量不多时完全够用但当同一个燃烧物体同时向周围多个邻居传热时逐对射线检测会变成一个隐形开销。我后来把空间网格升级成了带阻挡标记的地图场景中不可燃烧的地形、墙体在初始化时同步进网格的Blocked标记热传递只在两个物体的连线不经过阻挡格时才生效。这个调整不仅解决了技术问题还直接带来一个玩法设计玩家可以在火势蔓延过来之前砍倒一排树当阻燃带或者清空一块空地作为隔离带。火焰系统从特效变成了游戏机制对于生存类游戏来说价值很大。4. 场景接入让静态场景快速变成可燃世界算法说得再多如果让美术或策划手动给每个草丛挂组件项目根本推不下去。我在工程上做了一个FireArea组件策划只需要拖进场景、设定一个范围运行时自动扫描范围内的所有物体并动态挂上BurnableObject再根据命名规则分配燃料配置。4.1 FireArea区域扫描与自动注册最初的前期演示场景里我试过手挂组件草丛数量一多就放弃了。于是做了FireArea这个区域组件原理很简单用Physics.OverlapBox取范围内的物体筛选出指定Layer为没有挂BurnableObject的物体动态添加组件。public class FireArea : MonoBehaviour { public Vector3 boxSize new Vector3(10f, 3f, 10f); public LayerMask burnableLayer; public FuelProfile defaultFuel; public FuelProfile[] fuelProfiles; void Start() { var cols Physics.OverlapBox(transform.position, boxSize * 0.5f, transform.rotation, burnableLayer); foreach (var col in cols) { if (col.TryGetComponentBurnableObject(out _)) continue; var burnable col.gameObject.AddComponentBurnableObject(); burnable.AssignFuel(FindFuelByObjectName(col.gameObject.name)); } } private FuelProfile FindFuelByObjectName(string objectName) { foreach (var profile in fuelProfiles) { if (objectName.StartsWith(profile.namePrefix)) return profile; } return defaultFuel; } }运行时挂载组件的开销只在初始化时执行一次对启动性能影响很小。策划只需要把场景里的地面、水体、石头设为不可燃Layer其余可燃物放进专门的Layer再用FireArea框起来场景就具备了燃烧能力不需要在编辑器里逐个配置。4.2 统一点火入口与初始热量整个系统的对外入口我收敛成了一个方法的调用比如FireManager.StartIgnite传入点火位置和初始热量。玩家扔火把、闪电劈中树木、爆炸连锁最终都走这一个入口。一开始我觉得入口太单一可能不够灵活后来发现所有复杂玩法都可以拆成多个点火点按次序触发所以入口保持单一反而利于维护。点火时初始热量会以点火点为中心做半径衰减越靠近中心初始热度越高边缘物体可能只是先热身。这个细节让同一次点火的行为不是简单的一个全点燃圆而是一个有先后顺序的扩散扇面看起来自然非常多。5. 视觉层实施火焰粒子、光照闪烁与音频管理逻辑层稳定后接下来的工程量集中在表现层。很多人把火焰粒子做出来就草草收工但火焰在游戏里是一种环境状态它不仅要好看还要跟逻辑状态时刻对齐否则会出现物体已经烧完了粒子还在烧或物体已经在冒烟但火焰还在跳的割裂感。5.1 粒子系统的挂载与状态切换策略我给每种燃料配置了独立的火焰粒子和烟雾粒子预制体状态切换时用跨帧的方式控制播放和停止。闷燃阶段只播放烟雾粒子速率低一些进入燃烧后烟雾粒子减小发射量火焰粒子启动并叠加燃尽后再切换成余烬烟雾淡出结束后彻底停止。粒子挂点的选择比大多数教程提到的都重要。火焰粒子如果挂在物体中心草地的火焰会在半空中飘木屋的点火点在墙壁底部粒子可能在墙体外面。我让BurnableObject在Start时记录自己的MeshRenderer包围盒计算出顶部中心作为火焰挂点、底部中心作为烟雾挂点。用Bounds而不是Transform坐标来定位可以适配不同尺寸的物体木栅栏和高树的火焰高度都能自动贴合实际几何体。5.2 火光闪烁要用噪声而不是固定参数一个静态的点光源放在燃烧物中心并不会让人感觉到火焰在跳动。光亮需要不稳定的感觉我用Perlin噪声扰动点光源的强度和范围void Update() { if (!isBurning) return; float noise Mathf.PerlinNoise1D(_noiseSeed Time.time * flickerSpeed); pointLight.intensity baseIntensity * (0.8f 0.2f * noise); pointLight.range baseRange * (0.9f 0.1f * noise); }频率和振幅都要克制。flickerSpeed我最后取在每秒8到12次振幅控制在正负20%左右。频率太低火看起来像呼吸灯太高又变成灯管频闪都出戏。振幅设在0.5时夜里的火堆区域一明一暗十分明显玩家看了眼疼。合理的火光应该是稳定中的微晃而不是刺眼的脉冲。5.3 音效不能每个火堆各挂一个如果每一团燃烧的火焰都挂一个AudioSource音效数量会迅速爆炸多音源叠加的浑浊噪音还会破坏沉浸感。我做第二轮优化时统一了策略场景里放一个全局火焰音效管理器维护最近的可听见火源列表根据燃烧物体的数量和距离动态调整背景火焰音的音量和低通滤波只保留一到两个真正的AudioSource。另外循环音效的起始点最好加一点随机偏移否则多段循环之间能听到明显的节点切换咔嗒声。这类细节很细小但在长镜头或暂停画面里非常容易暴露属于玩家说不出哪里不对但就是感觉假的一类问题。5.4 燃尽后的灰烬表现燃尽并不等于物体从场景里消失。我做了灰烬材质加淡出动画物体进入BurnedOut状态后材质换成灰烬色透明度随几秒钟逐渐降低然后关闭MeshRenderer但Collider暂时保留防止角色一脚踩空或穿过残骸。再等更久才真正销毁物体。这样视觉上是留下了被烧过的痕迹逻辑上也不存在灰烬被二次点燃的bug窗口。6. 性能优化活跃队列、对象池与光照降载火焰蔓延系统最容易出问题的地方就是性能。设计阶段如果没留优化余地一旦点燃二三十个物体帧率可能直接崩盘。这部分的优化我分了几轮来做每一轮都解决一个实际瓶颈。6.1 用活跃燃烧队列替代全量遍历第一版实现里我让FireManager每帧遍历场景里所有可燃物先过滤出燃烧中的再计算热传播。场景里一千个可燃物时每帧多出来的遍历和List操作开销已经明显到肉眼可见。后来我改成维护一个活跃燃烧集合只在进入Burning状态时加入集合烧完移出集合private readonly HashSetBurnableObject _burningSet new HashSetBurnableObject(); void LateUpdate() { ListBurnableObject finished null; foreach (var burnable in _burningSet) { burnable.Tick(deltaTime); if (burnable.State ! BurnState.Burning) { finished ?? new ListBurnableObject(); finished.Add(burnable); } } if (finished ! null) { foreach (var burnable in finished) { _burningSet.Remove(burnable); } } }为什么不用Set直接在里面Remove因为一边遍历HashSet一边移除会报运行时错误我用了一个临时List收集要移出的对象。这个优化配合空间网格把每帧开销从O(所有物体)降到了O(正在燃烧物体的邻居数)效果立竿见影。两千个物体同时在场时只要同屏燃烧团不超过几十个帧率一直很稳。6.2 粒子对象池与GC控制火势开始蔓延后可能出现瞬间二三十个物体进入燃烧状态的情况。如果每个都用Instantiate创建粒子、烧完DestroyGC压力会非常大Instantiate本身在大量发生时也会造成明显卡顿。我改成了对象池模式public class ParticlePool { private readonly StackParticleSystem _pool new(); private readonly ParticleSystem _template; public ParticleSystem Spawn(Vector3 pos, Quaternion rot) { var ps _pool.Count 0 ? _pool.Pop() : Object.Instantiate(_template); ps.transform.SetPositionAndRotation(pos, rot); ps.Play(); return ps; } public void Despawn(ParticleSystem ps) { ps.Stop(true, ParticleSystemStopBehavior.StopEmitting); _pool.Push(ps); } }注意Stop(true, StopEmitting)表示立即停止发射新粒子但已经发出的粒子还会继续播放完所以不能马上回池并复用需要额外处理。我实际做的时候是把回池动作延迟到粒子系统所有存活粒子全部播放完毕才执行通过一个协程或ParticleSystem的duration回调来做。这一步要是偷懒多弹几次复用就会看到火焰突然被截断或位置错乱的怪异表现。6.3 实时灯光数量必须降载实时点光源的渲染成本相当高同一个区域里十个燃烧物十个点光源场景会又亮又卡。降载策略分两层距离玩家超过一定距离的燃烧物只保留火焰粒子不点亮光同一小范围内的多个燃烧物灯光合并成一个位置取均值强度按数量加权。合并策略尤其适合烧一排木栅栏的情况视觉上玩家并不会察觉少了几个灯省下的性能却非常可观。这个优化的收益在夜晚场景里特别明显因为点光源多时帧率会直接掉一大截。6.4 等量级上来了再谈Job System最后给一个经验性的取舍不要一上来就上Job System。火焰蔓延的纯计算部分天然适合JobsBurst但Unity的Job System无法直接引用MonoBehaviour和粒子实例必须把可燃物的数据拆成纯数据结构数组再做一层映射复杂度会明显上升。我认为只有当同屏可燃物超过三千个、燃烧对象超过一百个时才值得走向这个方向。我的项目里活跃队列加对象池已经解决了当时的性能问题后续要扩展更大的地图时再把热量传播那段纯逻辑迁移到Job里属于量变推动质变而不是设计之初就把自己架在重型武器上。7. 实机调试与踩坑记录系统功能写完之后真正的挑战才开始。火焰系统不像普通UI功能摆在那里就结束了它跟场景里所有物体都可能发生关系。我记录几个印象深刻的坑给同样做这个方向的朋友省点时间。7.1 火焰穿墙和粒子位置漂移火焰粒子挂在物体中心时遇到细长物体比如木栏杆粒子会穿模到外面。原因是粒子系统的Shape模块使用球形发射半径按固定值设置而没有参考物体本身的包围盒。最终解法是BurnableObject挂载粒子时动态计算一个局部偏移把发射点平移到物体顶面同时把Shape的半径压缩到物体尺寸的一半以内。这样草地火焰从草叶之间冒出来木屋火焰从屋顶上方涌出贴合度好了很多。7.2 空间网格边界漏判空间网格的CellSize如果设置得比蔓延半径大两个非常近的可燃物会被分到两个不相邻的格子查询邻近格时就会漏掉表现成明明贴在旁边却不烧过去。我实际踩过这个坑有一棵树紧贴着木屋火墙烧了一整排唯独那棵树一直不燃排查了半天发现是网格问题。解决办法是让CellSize不大于蔓延半径的一半查询时再向外多查一圈保证跨格邻居覆盖完整。网格参数看起来只是一个数字但它决定了整个系统的覆盖可靠性。7.3 可视化调试工具是火焰系统的刚需火焰蔓延的debug过程只看代码基本没有抓手因为问题可能发生在几百个物体的状态转换之间。我给系统加了两个基础工具一个是用Gizmos按状态着色Intact绿色、Smoldering黄色、Burning红色、BurnedOut灰色场景视图里一眼能看出哪片区域正在蔓延、哪里已经被烧穿另一个是编辑器快捷键选中物体按组合键直接让物体进入Smoldering状态用来快速测试蔓延路径和风向影响而不需要每次都扔火把。后来我还加了风场可视化在Scene视图用箭头画出当前风向和风速调整风参数时能直接看到风朝哪边吹、对热传递的影响范围在哪里。这套调试工具的开发时间不短但它决定了后续调参效率有条件建议尽早做。7.4 参数平衡表和天气系统联动燃料参数最好维护一张平衡表同时预留和其他系统联动的口子。我前期把草地的maxHeat调低点火速度明显变快结果整个草地瞬间烧完玩家根本没有反应时间。后来我在表格上做了标注同时把潮湿作为天气系统的字段传给FireManager雨天所有可燃物的maxHeat临时提高40%点燃难度显著上升火势在雨中也更容易被遏制。这类联动的价值在于改的是数据配置而不是逻辑代码恰好验证了前面这套架构的扩展性。我自己在实际开发中最深的体会有两点。第一火焰蔓延系统的成败不在视觉而在蔓延模型是否稳定可控状态机、燃料数据、热量传递这三个地基打好之后视觉、音效、玩法扩展都能稳稳叠上去。第二一定要先用一个小场景做灰盒验证摆上不同燃料的物体点燃一发观察蔓延的速度、方向和边界是否符合直觉调好再往大场景里搬。这套流程走完之后火焰系统不仅能让画面变得好看也能真正成为玩法规则的一部分。
阅读完成 · 觉得有帮助?