做2D项目最让人头疼的需求之一,就是战争迷雾。尤其在unity2D开发里,如果你打算让Spine角色跑在迷雾中,还要让灯光、遮挡、探索区域都自然联动,传统思路往往会翻车。我最近在一个俯视角2D项目里,用FunkyCode光照系统配合Spine骨骼动画,把战争迷雾效果完整串了起来,踩了不少坑,也积累了一套能直接复用的做法。这里把方案取舍、具体配置、代码逻辑和排查经验完整写出来,给正在搞同类效果的朋友一点参考。
1. 战争迷雾需求拆解与方案选型
1.1 迷雾到底要表现什么
在动手之前,先得想清楚“战争迷雾”这四个字在不同项目里代表的东西不完全一样。最常见的需求是三类:完全未知区域显示为全黑,无法预览地形和敌人;已探索过的区域变暗但保留轮廓,能看到走过的路,但看不到动态物体;角色视野范围内的区域正常照亮,可以识别敌人、机关和道具。还有一类策略游戏会需要视野分阵营,己方可见和敌方可见互相独立。不同需求对应的技术路径差别很大。
我的项目需求是前两种的组合:全图有迷雾遮罩,玩家角色周围有一个半径不规则的可见区域,角色移动后走过的路径保留半亮状态的“记忆迷雾”,但依然看不清前方的敌人。同时单位身上用的是Spine动画,地图上还有大量动态光照物件,比如火把、发光植物,这些光照需要能穿透已探索区域,但不能穿透未探索区域,否则就会提前暴露敌人位置。
如果只做最简单的一版,可以用一张黑色半透明贴图盖在场景上面,然后用Stencil或Mask挖洞,把可见区域“挖空”。这么做逻辑简单,性能开销也不高。但问题在于:它只能得到一个硬边或软化后的光圈,和光照系统完全没关系,火把照亮的范围没法单独参与迷雾计算,Spine动画的蒙皮边缘也无法做精细的遮挡关系,整体效果会很生硬。
1.2 常规方案对比:Mask、Shader、光照系统
既然要动态光照和迷雾联动,就不能闭着眼睛乱选。我把常见的几种方案拉出来对比了一下,按“能否与Spine兼容、能否动态光照联动、移动端性能、实现成本”几个维度打分:
| 方案 | 动态光照联动 | Spine兼容性 | 移动端性能 | 实现成本 |
|---|---|---|---|---|
| 纯Texture Mask + 摄像机遮挡 | 差 | 一般(需同步位置) | 好 | 低 |
| 自定义Shader(视差遮挡算法) | 中 | 差(Spine网格不走标准Sprite渲染) | 中 | 高 |
| 自定义Mesh多边形视野 | 中 | 中 | 中 | 高 |
| FunkyCode光照系统 + 迷雾层 | 好 | 好(通过碰撞体与图层) | 中上 | 中 |
纯Mask方案最大的问题是它不“懂”场景里谁是遮挡物。比如一个箱子挡住了一束光,但Mask是根据角色位置画的圆,不会考虑箱子遮挡,视觉上就会穿帮。自定义Shader的话,如果场景全是普通Sprite还好,一旦引入Spine,问题就来了:Spine动画是一套网格变形渲染体系,它的蒙皮顶点数据是动态的,标准Sprite的UV和顶点着色器路径不完全适用,要让Shader同时处理Spine动画和迷雾遮挡,需要自己写Unity引擎的Spine管线适配,成本非常高。
FunkyCode这个插件的优势在于,它的核心思路就是“2D动态光源 + 阴影”,它天然支持把普通Sprite、碰撞体当成遮挡物,生成实时的2D阴影。而Spine角色身上挂的碰撞体可以随骨骼移动,所以它能成为光源的遮挡物,也能成为迷雾边界的参与者。再配合灯光本身的“只照亮指定图层”的设置,就能让光照和迷雾走同一条计算管线,不用额外维护两套数据。
1.3 最终技术路径
我最终确定的技术路径是:用FunkyCode的2DLight作为玩家的视野光源,把场景中的地形、墙体等静态遮挡物挂在特定的“遮挡层”上;用一张专门的迷雾Sprite层作为全屏覆盖,通过混合模式把未探索区域显示为深色;在角色移动时,持续把视野光源覆盖过的区域写入一层半透明的“探索层”,让已探索区域永久保留低亮度。这样本质上是一个“光照决定迷雾轮廓,迷雾层决定最终输出”的组合方案。
这个路径下,Spine角色不是迷雾的计算核心,它是遮挡物和被看见的目标。Spine角色的阴影可以由FunkyCode的碰撞体自动生成,这样当角色走到墙后面时,它的轮廓会从视野中消失,而角色本身又是光源覆盖范围内的活动物体,可以让它不遮挡光线,避免自己把自己挡住。
2. FunkyCode光照系统与Spine角色的适配细节
2.1 FunkyCode工作原理解析
FunkyCode是一个基于Unity的2D光照插件,早期叫“2D Dynamic Lights and Shadows”。它的工作方式是把光照区域当作管理对象,通过给场景里的物体挂上LightCollider组件来标记阴影遮挡体,然后用自定义渲染管线把阴影和光照混合到相机画面上。
理解它最关键的一点是:它的阴影不是“实时光线追踪”,而是基于遮挡物轮廓和多边形求交计算的。它会把每个遮挡体在光源视角下投射出的阴影多边形算出来,再叠加到涂层上。所以你不需要为每个像素写复杂的算法,只需要在场景中安排好遮挡物层级。
和传统像素级Mask相比,这种方式的最大优势是:遮挡关系是动态的。也就是说,角色手里拿的火把会跟着骨骼动,它发出来的光遇到柱子会被柱子挡住,遇到其他角色也会被挡住,甚至角色的手臂横在光源面前都会形成阴影。这是静态Mask永远做不到的细节。
2.2 Spine动画与光照碰撞体的关系
很多人第一次接触Spine时,以为动画角色就是一个Sprite,挂个BoxCollider2D就够了。但Spine在Unity里是通过Spine.Unity组件驱动网格渲染的,同一个GameObject上可能挂有MeshRenderer、BoneFollower等组件,碰撞体和动画里的“根节点”往往是分离的。
如果直接把碰撞体挂在Spine角色的根节点上,你会发现角色走路时上下摆动的身体和碰撞体不匹配,光照阴影穿帮。正确的方式是给Spine角色单独设置一个“阴影碰撞体物体”,这个物体可以挂一个或多个胶囊碰撞体或者复合碰撞体,位置和大小要跟随角色的主要骨骼。更精细的做法是直接用Spine的BoundingBox碰撞体来生成阴影,比如把角色的身体骨骼绑定一个BoundingBox事件区域,然后把这些碰撞体加到FunkyCode的遮挡层里。这样当角色弯腰、转身、抬手时,阴影会跟着Spine骨骼动态变化,效果非常自然。
不过BoundingBox有一个问题:它的计算复杂度比普通碰撞体高。如果你场景里同时有几十个角色在走,每帧更新阴影多边形会消耗不少CPU。我的建议是:普通的小怪用简单的胶囊碰撞体假装人形轮廓,只有主要角色或BOSS级单位才用BoundingBox。这样在画质和性能之间取一个平衡。
2.3 图层和灯光参数设置
FunkyCode的灯光系统是依赖于分类层的。你需要在项目里规划好Layer:
- 背景层:地形、植被、墙体,作为遮挡物。
- 道具层:可以遮挡光线的物品。
- 角色层:玩家和NPC,这里需要选择“是否要成为遮挡物”。玩家通常不希望自己挡住自己的视野,所以我会把玩家身上的LightCollider设为“只产生阴影,不接收阴影”,或者直接移除它的遮挡标签。
- 迷雾层:一个专门盖在全图上的Sprite,不受光照影响,用于输出迷雾颜色。
具体到灯光参数,我建议把玩家的“视野灯光”类型设置为Spotlight(2D)或者自定义多边形光,半径要覆盖玩家的可视范围。Collision参数选“Collider”模式,这样灯光只和带了LightCollider的物体发生阴影运算。颜色可以用淡蓝色或淡黄色,配合迷雾层的深色,能制造出探索氛围。
还需要注意一点:FunkyCode的灯光渲染支持“只作用于指定Layer”的Mask选项。我可以让火把光只照亮角色层和地形层,把迷雾层排除在灯光影响范围外,这样火把光就不会直接把整块迷雾层照亮,而是只点亮迷雾之下的物体。逻辑上就是:迷雾层是独立的全屏遮罩,真正决定“看得见什么”的是灯光和物体之间的阴影关系。
2.4 一个容易忽视的细节:灯光与相机层级
Unity 2D项目的相机往往不只一台。如果项目里有多层Canvas、UI相机、场景相机,FunkyCode需要指定它要照亮哪一个相机。如果你的场景相机带了后处理、或者使用了不同的清除标记,很容易出现一盏灯在Scene视图中亮得刺眼,切到Game视图却什么都看不到的情况。
我一般会单独建一个“LightCam”标记,或者直接使用默认主相机,把相机的Clear Flags设成SolidColor,背景色设为纯黑色。然后让FunkyCode的LightManager只绑定这一个相机。这样所有迷雾相关元素都在同一套渲染体系里,不会出现UI被光照打扰的尴尬。
3. 战争迷雾效果完整实现流程
3.1 场景搭建与基础配置
先说步骤。第一步是把场景基础搭出来,不用管任何代码,先把图层分好。
我习惯这样建目录:
- 场景层:名字叫Floor,放所有固定地面。
- 墙障层:名字叫Wall,放所有会遮挡视野的墙体、树木、大石块。
- 可交互道具层:叫Item,道具如果体积大也参与遮挡。
- 角色层:叫Unit,放玩家和敌人。
- 特效层:叫FX,放粒子、飘浮物。
- 迷雾层:单独建一个空节点,给它加一个足够大的SpriteRenderer,显示一张全黑的纹理,Sprite类型设为“Sliced”也无所谓,反正会被拉伸到全屏。
把玩家的相机Tag设为主相机,背景色设为接近黑的深蓝色,这样在视野外不至于死黑一片,雾的过渡会更柔和。
然后接入FunkyCode:在菜单里打开Window -> FunkyCode -> Setup,它会自动添加LightManager等必要组件。如果项目里已经有别的后处理栈,要注意LightManager的排序和相机事件绑定。
3.2 设置全屏迷雾遮罩
全屏迷雾遮罩是我用的“深色覆盖层”思路。这里不用Shader,就用一个普通的SpriteRenderer,材质换成一个半透明的黑色材质。只要保证这个Sprite的Sorting Order在所有场景物体之上,就可以盖住整张地图。
但这里有个关键:如果你直接盖全屏黑色,玩家视野区域也会被盖住,那就什么都看不见了。所以我们需要在迷雾层上“挖洞”。FunkyCode并没有直接提供“用灯光挖洞”的功能,但它提供了一些辅助纹理和混合选项。我当时的做法是:把迷雾层做成一个RenderTexture遮罩,在玩家的视野灯光范围内,把灯光照亮区域画到这个遮罩上,产生一个alpha为0的透明区域,其余地方保持黑色。
具体实现可以写一个很简单的OnRenderImage或者用CommandBuffer来操作。为了不把复杂度拉太高,我选了一个更省事的替代方案:把迷雾层拆成两层叠加。
第一层叫“未探索层”,全黑。第二层叫“已探索层”,是一个由灯光实时写入的透明遮罩。玩家的灯光把周围照亮,已探索层的alpha就会降低。运行时,最终显示的迷雾 = 未探索层(alpha=1)与已探索层(alpha=已探索度)的叠加结果。如果玩家走到一个地方,附近的alpha降到0.3,那就说明这个地方已经探索过但不在当前视野里。
让灯光直接影响材质的alpha,在Unity里可以用一个极简的自写Shader,或者直接用FunkyCode自带的“Light Texture”输出通道。我更推荐使用FunkyCode的LightTexture功能,它能生成一张灯光照射区域的纹理,我们只需要在Update里把这张纹理的亮部转换成已探索度并写入已探索层即可。
3.3 编写视野记录逻辑
光靠灯光还不行,因为灯光只表示“当前可见区域”,不会自动记录“曾探索区域”。需要我写一个记录工具,定期把灯光照射过的像素信息累积到一张持续变亮的纹理中。
我写的核心逻辑类似这样:
using System.Collections; using System.Collections.Generic; using UnityEngine; using FunkyCode; public class FogOfWarController : MonoBehaviour { public Light2D playerLight; // 玩家视野灯光 public Material exploredMat; // 已探索层材质 public RenderTexture exploredRT; // 已探索区域累积纹理 private Texture2D fogTexture; private Color[] fogPixels; void Start() { // 初始化纹理,尺寸根据迷雾精度调整 fogTexture = new Texture2D(512, 512, TextureFormat.RGBA32, false); exploredRT = new RenderTexture(512, 512, 0); Graphics.Blit(fogTexture, exploredRT); } void Update() { // 用相机把灯光的可见区域转到底层纹理 // 这里lightTexture是灯光渲染出来的可视化通道 Texture lightTex = playerLight.GetLightTexture(); if (lightTex != null) { Graphics.Blit(lightTex, exploredRT); // 把累积结果回读到Texture2D中,用于记录 RenderTexture.active = exploredRT; fogTexture.ReadPixels(new Rect(0, 0, 512, 512), 0, 0); fogTexture.Apply(); RenderTexture.active = null; } } }这段代码只是骨架,真正项目里不要每帧都ReadPixels,很消耗性能。更好的做法是:每隔0.2秒或0.5秒采样一次,或者只在角色移动一定距离后采样。还有一个更巧的办法,就是用双RenderTexture交替Blit,利用Alpha Blend把当前灯光可见区域逐渐累积到一张已探索纹理中,这样就不需要从GPU回读像素到CPU,性能友好得多。
我个人最终的方案是:维护两张RenderTexture,一张A存“已探索累积”,一张B做临时缓冲。每帧把A和“当前可见灯光纹理”做一次加法混合写入B,然后交换A和B。叠加的系数越小,迷雾消散得越慢,回访旧区域时不会一瞬间变透。
3.4 Spine角色在迷雾中的正确显示
Spine角色本身不是迷雾层的一部分,但它必须和光照系统联动。我建议在Spine角色的骨骼层级中增加一个“LightAnchor”空物体,把它绑定到角色重心骨骼上。然后在LightAnchor下挂一个LightCollider2D组件,碰撞体类型选“SpriteRenderer”或“Collider”。如果你用的是简单人形,可以直接用PolygonCollider2D描出角色轮廓。
这里有几个常见错误:
第一,不要直接在Spine的root节点上挂LightCollider。虽然能工作,但动画播放时,root节点可能会包含大量的位移和缩放信息,导致阴影漂移或大小抖动。
第二,Spine角色的材质需要和迷雾层的材质隔离。Spine动画默认用的材质是SpineShader,如果你把它的材质替换成FunkyCode的Light材质,角色会直接变透明或变黑。要保持Spine材质不变,只让碰撞体参与阴影计算。
第三,敌人单位和玩家的配置不同。玩家角色需要“不挡住自己的视野”,所以它的LightCollider要设置为“ShadowOnly”且“IgnoreLayer”不包含自身层;敌人则应该正常遮挡,这样当敌人走到墙后时,它在玩家视野内消失,很有策略感。
3.5 动态光照、火把和探索联动
好了,迷雾遮罩的动态化搞定后,接着要让场景里的火把、荧光草等光源也参与进这个系统。
我给场景中的发光物挂上Light2D组件,类型可以是PointLight或SpriteLight,然后为发光物添加一个ShadowCollider,让它的光被周围墙体遮挡。这里有一个联动点:未探索区域的发光物不能被玩家提前看到,但因为迷雾层盖在上面,自然就看不见了。可是当玩家靠近时,发光物的光会穿透迷雾,照亮周围区域。如果你直接用灯光亮度直接输出到已探索层,那么玩家在很远的地方就能看到光照亮了一圈,相当于暴露了发光物位置,这就不符合“迷雾不能提前透出信息”的原则。
解决办法是:把火把等发光物的Light2D设置成“不对迷雾层产生光照”。具体操作是在Light2D的EventSystem或LayerMask设置中,把迷雾层从光照明亮对象中去掉。这样火把光只能照亮地面和人物,不会把黑色迷雾照成半透明,玩家就看不到火把的位置。当玩家走近时,由于视野灯光已经把这片区域标记为“已探索”,迷雾层变透明,火把光才自然显露出来。
这一步在视觉体验上非常关键。很多新手做迷雾时把光源全开,结果地图上到处是光团提前暴露,玩起来像开挂。用我这个配置,可以把“光源可见性”完全交给探索状态控制。
4. 常见问题排查与优化心得
4.1 光源边缘锯齿严重怎么处理
FunkyCode生成的2D阴影边缘默认比较硬,放大了看会有明显锯齿。这个问题的根源是阴影多边形没有抗锯齿,且光源纹理分辨率不够高。我的处理办法有三板斧:
一是提高光源的TextureSize,在FunkyCode的Light2D属性里把Texture Size从默认的512调到2048。缺点是内存占用会上升,但对于单机场景来说完全能接受。
二是开启光源的“Soft Shadow”选项。这个选项会在阴影边缘做模糊处理,能极大缓解锯齿感。实测下来,开启后边缘过渡柔和很多,配合雾的渐变,不至于出现一圈“硬纸板”剪影。
三是给迷雾层材质加一个椒盐噪声或扰动的UV动画Shader。当然这是偏美术优化的路子,如果你不想动Shader,就建议把迷雾层的SpriteTexture画得模糊一些,也能掩盖锯齿。
4.2 Spine角色阴影抖动、错位
这个问题几乎人人都会遇到。表现是:角色走路时,墙上的影子会忽大忽小,或者影子位置一直和脚底对不上。
排查思路先看碰撞体是不是挂在根节点上。如果是,而且动画中根节点有上下浮动的变化,影子就会跟着跳动。解决方法是把LightCollider挂到一个固定的空物体上,这个空物体的世界坐标每帧由脚本设置为Spine角色的根骨骼位置,但过滤掉Y轴浮动,或者干脆只追踪脚下位置。
如果影子错位,还可以检查Light2D的“EventLayer”设置,确保所有遮挡物都在同一LayerMask中。有时候影子内容被其他Canvas或UI相机干扰,也会出现错位。
另外,Spine动画中的某些骨骼会瞬间翻转,如果用BoundingBox作为碰撞体,翻转瞬间阴影会闪一下。这个一般通过增加阴影多边形的边缘余量来解决,或者在动画切换时用两个碰撞体做交叉融合。
4.3 DrawCall和性能优化
FunkyCode的实时阴影计算量大头在CPU多边形计算和GPU叠加渲染。如果一张地图上有几十个光源和上百个遮挡物,不优化的话,移动端会卡到怀疑人生。
我的调优建议列表如下:
- 只对视野范围内的遮挡物启用LightCollider组件。超出玩家视野一定距离的物体会被脚本禁用碰撞体,回来时再启用。
- 把相同材质、相同尺寸的地面碎片合并成一个大Sprite或图集,减少动态阴影多边形的数量。
- 火把等小光源使用低分辨率纹理,玩家主光使用高质量纹理。
- 关闭不必要的阴影投射开关。不是所有障碍物都需要每一帧投射阴影,例如很细小的野草,不参与遮挡就好。
- 将“已探索层”的更新频率降到每秒1-2次,并且用双缓冲RenderTexture,而不是每帧ReadPixels。
我用这个策略后,在普通中端安卓机上能做到同时4个角色、12个光源、150个静态遮挡物稳定运行在60帧。当然,如果你的项目需要大量单位,建议把Spine的BoundingBox全部换成近似胶囊体,否则每一帧的物理采样和碰撞体会成为新的瓶颈。
4.4 实战中积累的三条独家经验
第一,千万别把所有图层都放到同一层上让灯光自动处理。要让灯光能正确区分地面和障碍物,最关键的是“未被迷雾覆盖时,地面可以被光照亮,但地面本身不能被当成遮挡物”。这个可以通过给地面加上一个LightCollider并设置它“不产生阴影”来解决。你在编辑器里会看到一个叫做“Shadow”的下拉选项,默认会在LightCollider的Inspector面板中,把它调成“None”,地面就不会挡光了。
第二,已探索层的颜色不要在Shader里直接写死为黑色。我建议用脚本控制在多个颜色之间插值,比如白天模式淡蓝色,夜晚模式深褐色。这样既保留了迷雾的功能性,又可以融入整体氛围,不会让人觉得一整片地图永远是黑压压的。
第三,涉及到Spine的敌人和玩家时,需要手动设置一个“看不见但可以被光照亮”的中间态。我经常会看到有人把敌人设置成完全不被灯光照亮,那就出错觉了,玩家走到面前发现敌人永远是黑的。正确的方式是让敌人受光照影响,但从迷雾遮罩的角度来讲,只有可见区域内的敌人才会显示出来。这个本质上是两套系统:一套是光照渲染,一套是迷雾遮罩。不要试图用单独一个系统的极限功能去覆盖另一个系统的需求。
4.5 当动态加载资源导致迷雾失效怎么办
项目里如果有 AssetBundle 或 Addressables,动态加载的Spine角色和地图资源,很容易造成FunkyCode灯光不识别新加载的遮挡物。原因是LightCollider的Collider引用可能还没绑定到Resource加载出来的GameObject上。
解决办法是在加载完成后,调用一次:
lightCollider.Initialize();重新初始化碰撞体。同时要检查加载出来的GameObject是否还挂在错误的Layer下,如果Layer是Default,而光照系统只处理“World”层,那么再多光源也照不亮它。这个坑我当时花了整整半天才发现,结果就是新加载的墙壁Layer是0,被灯光系统自动忽略了。
另外,动态生成的地图如果带有瞬移功能,或者地图块有缩放动画,也会让阴影多边形错位。建议在迷雾区域附近不要使用大规模的Scale动画,尤其是遮挡物,缩放过程会让阴影多边形的轮廓忽大忽小,产生闪烁。
最后分享一点私家经验
这套方案跑下来,我最大的感受是:不要试图让FunkyCode包办一切,也不要让Spine去硬适配迷雾。核心思路其实就是“职责分离”:灯光负责照亮,碰撞体负责遮挡,迷雾层负责记录和展示探索进度。三套系统各管各的,通过图层和材质把它们串联起来,效果反而最稳定、最好调。
以后如果遇到更复杂的迷雾需求,比如多阵营视野、战争迷雾上的法术范围预览,我打算在这个基础上扩展,把每一帧的视野数据序列化为一张独立的Texture,交给策略系统做逻辑判断,而不是只做视觉表现。这样视觉和玩法逻辑都共用同一套数据,以后做AI寻路时也能直接用迷雾数据来限制敌方AI的感知范围,对策略玩法来说,价值会更大。