Unity Shader Flow Map技术:实现岩浆流动效果全解析
1. 项目概述当岩浆在Shader中“流动”起来最近在做一个火山场景需要让岩浆表面呈现出那种缓慢、粘稠、热量涌动的真实感。静态贴图肯定不行用顶点动画做整体位移又太假缺乏表面细节的扭曲和能量感。这时候一个经典且强大的技术方案就浮出水面了Flow Map流向图。这个项目就是围绕如何在Unity Shader中利用Flow Map技术来模拟岩浆流动效果的一次完整实践。它解决的不仅仅是“动起来”的问题更是如何“动得真实”、“动得有细节”。简单来说Flow Map是一张编码了方向信息的纹理通常RG通道存储XY方向向量它告诉Shader表面每个像素点上的“流动方向”。通过让纹理坐标沿着这个方向随时间偏移并对多层纹理进行混合就能创造出极其复杂且自然的流动效果。这种方法性能可控效果出众非常适合用于河流、熔岩、魔法能量流等需要表现动态表面的场景。无论你是刚接触Shader编程的新手还是想为你的场景增添一抹动态细节的老手这个案例都能提供一套从原理到实现的完整“配方”。我们会从最基础的Flow Map原理讲起一步步拆解Shader的每一个模块并分享在制作过程中遇到的坑和优化技巧。你会发现实现一个看起来复杂的特效其核心逻辑可能比你想象的要清晰得多。2. 核心原理与设计思路拆解2.1 Flow Map技术内核为静态纹理注入“方向”Flow Map的核心思想是为一张原本静态的纹理比如岩浆的漫反射贴图赋予一个二维的、每像素级别的运动矢量场。你可以把它想象成一张地图上的风向图箭头指明了每个位置风往哪里吹。在我们的Shader里这个“风向”决定了纹理像素的移动方向。技术上我们通常将Flow Map存储为一张RGB纹理。其中R通道代表X轴U方向即纹理坐标的水平方向的流动分量。值域通常在[-1, 1]或[0, 1]之间通过映射调整0.5表示无流动大于0.5表示正向流动小于0.5表示反向流动。G通道代表Y轴V方向即纹理坐标的垂直方向的流动分量。B/A通道可能闲置或用于存储其他信息如流动速度系数、高度图等。在Shader中我们采样这张Flow Map解码出每个片元的流动向量flowVector (r*2-1, g*2-1)使其值域映射到[-1, 1]。然后这个向量会与时间变量_Time.y和一个控制整体速度的_Speed参数相结合生成一个动态的纹理坐标偏移量uvOffset flowVector * _Time.y * _Speed。如果直接把这个偏移量加到基础纹理坐标上我们会得到一个简单的平移效果。但这还不够真实因为真实的流动尤其是岩浆会有一种“持续不断”、“新旧交融”的视觉感受。2.2 双层纹理混合破解循环与持续感难题单一层的纹理偏移会导致一个严重问题当纹理坐标偏移超过一个周期后会出现明显的“接缝”或“拉伸”断层因为纹理的一端移出了屏幕另一端留下了空白。解决方案是使用两层Phase A和Phase B相同的纹理但让它们的流动相位错开半个周期。这是整个Flow Map Shader设计中最精妙的一环。定义周期与相位我们设定一个流动周期_FlowCycle。Layer A的相位直接由时间驱动phaseA frac(_Time.y * _Speed / _FlowCycle)。Layer B的相位则比A落后半个周期phaseB frac(phaseA 0.5)。frac函数用于取小数部分保证相位永远在[0, 1]之间循环。分别偏移用同一个Flow Vector分别乘以phaseA和phaseB得到两个不同的UV偏移量去采样纹理。动态混合混合权重lerpWeight也需要根据相位动态变化。一个常见的技巧是使用abs((0.5 - phaseA) * 2)这样的函数使得当phaseA为0.5时纹理流动到中间状态lerpWeight为0此时完全显示Layer B当phaseA接近0或1时纹理流动到起始或结束状态lerpWeight为1此时完全显示Layer A。这样视觉上永远是“渐入渐出”一层淡出时另一层淡入完美掩盖了纹理重置的瞬间形成了无限循环、无接缝的流动幻觉。对于岩浆我们通常不止使用一张纹理。常见的配置是一张基础颜色贴图Albedo提供静态的色彩和斑驳细节一张流动扰动贴图Distortion通常也是Flow Map或简单的噪声图用来驱动颜色纹理的UV偏移还有一张自发光贴图Emission或高度图来增强热浪扭曲和明暗变化。多层纹理通过Flow Map驱动混合在一起才能产生丰富的细节。2.3 针对岩浆特性的特殊设计岩浆不是水它的流动更粘滞视觉上更有“厚度”和“热量”。粘滞感可以通过对Flow Vector进行轻微的模糊采样或者使用一张低频率的噪声图对Flow Vector本身进行扰动来实现让流动方向不是完全一致有些许的拉扯和迟滞感。热量表现这是关键。除了Emission贴图高亮发光区域我们通常还会结合顶点偏移来模拟表面的缓慢起伏以及使用UV扭曲来模拟热空气上升造成的视觉扭曲。后者可以通过用Flow Map或时间变化的噪声图去扰动屏幕空间坐标或背景纹理的UV来实现。边缘处理岩浆通常存在于河道或坑洞中。我们需要让Flow Map在河道边缘的流动向量逐渐减弱为零避免岩浆“流到”岩石外面。这需要在制作Flow Map时就处理好或者在Shader中根据一张遮罩贴图来衰减流动强度。3. 核心Shader模块解析与实操要点下面我们以一个相对完整的Unity Surface Shader或URP/Lit Shader框架为例拆解实现岩浆Flow Map效果的核心模块。这里以Built-in RP的Surface Shader为例进行说明因其结构清晰易于理解原理。3.1 属性与变量声明首先在Shader的Properties块和CGPROGRAM中声明所需的变量。Properties { // 基础颜色纹理 _MainTex (Albedo (RGB), 2D) white {} _Color (Color, Color) (1,1,1,1) // 流动图RG通道存储方向B通道可存速度/强度 _FlowMap (Flow Map (RG direction, B speed), 2D) gray {} _FlowSpeed (Flow Speed, Range(0, 2)) 0.5 _FlowCycle (Flow Cycle, Range(0.1, 5)) 1.0 _FlowStrength (Flow Strength, Range(0, 0.2)) 0.05 // 用于流动的颜色纹理/细节纹理 _FlowTexture (Flow Texture, 2D) gray {} // 自发光纹理与强度 _EmissionMap (Emission Map, 2D) black {} _EmissionColor (Emission Color, Color) (1,1,1,1) _EmissionStrength (Emission Strength, Range(0, 10)) 1.0 // 法线贴图 _BumpMap (Normal Map, 2D) bump {} _BumpScale (Normal Scale, Range(-2, 2)) 1.0 // 遮挡/高度等其他贴图 _OcclusionMap (Occlusion Map, 2D) white {} }在SubShader中需要定义对应的变量sampler2D _MainTex, _FlowMap, _FlowTexture, _EmissionMap, _BumpMap, _OcclusionMap; float4 _MainTex_ST, _FlowMap_ST, _FlowTexture_ST; // 各纹理的Tiling和Offset float _FlowSpeed, _FlowCycle, _FlowStrength; float4 _Color, _EmissionColor; float _EmissionStrength, _BumpScale;注意纹理的_ST变量Scale-Transform至关重要它包含了该纹理在模型UV上的平铺(xy)和偏移(zw)参数。在计算UV时务必使用TRANSFORM_TEX(uv, _TextureName)宏来应用这些设置否则你在材质球面板调整Tiling和Offset会无效。3.2 流动UV计算函数这是Shader的“心脏”。我们将复杂的双层混合逻辑封装成一个函数。// 输入基础UV流动图UV时间速度周期强度 // 输出经过流动偏移后的UV坐标 float2 CalculateFlowUV(float2 baseUV, float2 flowMapUV, float time, float speed, float cycle, float strength) { // 1. 采样流动图解码方向向量 float4 flowSample tex2D(_FlowMap, flowMapUV); // 将[0,1]映射到[-1,1] float2 flowVector (flowSample.rg * 2.0 - 1.0) * flowSample.b; // 这里假设B通道存了每像素强度 // 如果B通道未用则直接float2 flowVector flowSample.rg * 2.0 - 1.0; // 2. 计算两个图层的相位 float phase frac(time * speed / cycle); float phaseA phase; float phaseB frac(phase 0.5); // 错开半个周期 // 3. 计算两个图层的UV偏移 float2 offsetA flowVector * phaseA * strength; float2 offsetB flowVector * phaseB * strength; // 4. 计算动态混合权重 // 使用三角波函数使得在phase0和1时权重为1phase0.5时权重为0 float lerpWeight abs((0.5 - phaseA) * 2.0); // 范围[0,1] // 5. 分别采样两个相位下的纹理这里先返回偏移和权重实际采样在surf函数中 // 为了函数返回清晰我们返回一个包含四个分量的值offsetA.xy, offsetB.xy, lerpWeight // 但HLSL函数不便返回复杂结构实践中常在surf内联计算。此处为演示我们简化。 // 实际应用时这个函数可能只返回flowVector和phase后续步骤在surf中展开。 // 下面是一种返回复合信息的方式使用float4 // return float4(offsetA.x, offsetA.y, offsetB.x, lerpWeight); // 更清晰的做法是分成多个步骤在surf中写。 // 为了教程清晰我们假设这个函数只计算并返回用于最终混合的UV float2 finalUV baseUV; // 但这并不准确。更好的方式是理解步骤在surf中实现。 // 因此我们换一种方式这个函数返回经过流动偏移的UV以及混合权重。 // 但由于需要两个UV我们可以通过inout参数或返回结构体。对于初学者直接在surf中写明白每一步更易懂。 }鉴于函数返回复杂结构的麻烦在实际编写时我强烈建议将核心计算过程直接内联到表面着色器surf函数中这样逻辑更直观。上面的函数主要用于理解分步步骤。3.3 表面着色器中的集成在surf函数中我们将所有步骤串联起来。void surf (Input IN, inout SurfaceOutputStandard o) { // 应用Tiling和Offset到各纹理UV float2 mainUV TRANSFORM_TEX(IN.uv_MainTex, _MainTex); float2 flowMapUV TRANSFORM_TEX(IN.uv_MainTex, _FlowMap); // 通常FlowMap使用一套UV float2 flowTexUV TRANSFORM_TEX(IN.uv_MainTex, _FlowTexture); float2 emissionUV TRANSFORM_TEX(IN.uv_MainTex, _EmissionMap); float2 bumpUV TRANSFORM_TEX(IN.uv_MainTex, _BumpMap); // --- 核心Flow Map UV计算 --- // 1. 采样Flow Map解码方向 float4 flowSample tex2D(_FlowMap, flowMapUV); float2 flowVector (flowSample.rg * 2.0 - 1.0); // 可选使用B通道调制强度 flowVector * flowSample.b; // 2. 计算相位 float time _Time.y; float phase frac(time * _FlowSpeed / _FlowCycle); float phaseA phase; float phaseB frac(phase 0.5); // 3. 计算偏移量 float2 offsetA flowVector * phaseA * _FlowStrength; float2 offsetB flowVector * phaseB * _FlowStrength; // 4. 计算混合权重 float lerpWeight abs((0.5 - phaseA) * 2.0); // 5. 采样流动纹理例如细节纹理 fixed4 texA tex2D(_FlowTexture, flowTexUV offsetA); fixed4 texB tex2D(_FlowTexture, flowTexUV offsetB); fixed4 flowColor lerp(texB, texA, lerpWeight); // 混合两层 // --- 基础颜色计算 --- // 将流动细节与基础颜色结合这里使用叠加(Multiply)或插值(Add) fixed4 mainColor tex2D(_MainTex, mainUV) * _Color; fixed4 finalAlbedo mainColor * flowColor; // 乘法混合使流动处颜色变化 // --- 自发光计算 --- // 让自发光贴图也参与流动增强热点移动效果 fixed4 emissionA tex2D(_EmissionMap, emissionUV offsetA * 0.5); // 发射光流动可以慢一些 fixed4 emissionB tex2D(_EmissionMap, emissionUV offsetB * 0.5); fixed4 flowEmission lerp(emissionB, emissionA, lerpWeight); float3 emission flowEmission.rgb * _EmissionColor.rgb * _EmissionStrength; // --- 法线计算 --- // 法线贴图也可以轻微流动增加表面细节动态 float2 normalOffsetA flowVector * phaseA * _FlowStrength * 0.3; float2 normalOffsetB flowVector * phaseB * _FlowStrength * 0.3; float lerpWeightNormal abs((0.5 - frac(time * _FlowSpeed / (_FlowCycle * 1.2))) * 2.0); // 法线流动周期可略有不同 float3 normalA UnpackNormal(tex2D(_BumpMap, bumpUV normalOffsetA)); float3 normalB UnpackNormal(tex2D(_BumpMap, bumpUV normalOffsetB)); float3 finalNormal lerp(normalB, normalA, lerpWeightNormal); finalNormal.xy * _BumpScale; finalNormal normalize(finalNormal); // --- 输出到表面结构体 --- o.Albedo finalAlbedo.rgb; o.Emission emission; o.Normal finalNormal; o.Metallic 0.0; o.Smoothness 0.1; // 岩浆通常粗糙 o.Occlusion tex2D(_OcclusionMap, mainUV).r; o.Alpha mainColor.a; }实操心得在混合两层纹理时lerp函数的参数顺序lerp(a, b, t)表示当t0时返回bt1时返回a。我们之前计算的lerpWeight在 phaseA0 或 1 时为1此时应显示A层。因此使用lerp(texB, texA, lerpWeight)是正确的。务必理清这个逻辑否则流动会出现反向或跳变。3.4 制作与处理Flow Map纹理Shader写好了但效果的灵魂在于Flow Map纹理本身。一张制作精良的Flow Map是效果逼真的关键。制作方法手绘在Photoshop等软件中使用RGB(128,128,0)表示静止向量(0,0)。用颜色变化表示方向。例如向右流动可能是RGB(255,128,0)向左是RGB(0,128,0)向上是RGB(128,255,0)向下是RGB(128,0,0)。需要自己建立方向到颜色的映射关系。这种方法灵活但费时且需要精确。专用软件/插件使用如FlowMap Painter、Houdini、World Machine等工具可以直观地绘制向量场并导出为纹理。这是推荐的方式。程序生成编写脚本根据地形高度图生成沿斜坡方向的向量图。适用于大规模地形河流。处理技巧模糊导出的Flow Map通常需要经过轻微的高斯模糊以使向量场过渡平滑避免流动出现生硬的边界和锯齿。归一化确保向量长度即颜色亮度不要超过1。过长的向量会导致UV偏移过大产生严重的纹理拉伸。通常我们会在Shader中用一个统一的_FlowStrength参数来控制整体强度而Flow Map本身只存储方向单位向量或近似单位向量和局部强度变化。边缘衰减在河道边缘手动或通过遮罩将Flow Map的向量渐变为0RGB(128,128)防止流动“溢出”。这可以在绘图时完成也可以在Shader中通过额外的一张遮罩贴图Mask Map与Flow Vector相乘来实现。4. 完整实现流程与参数调试4.1 在Unity中的搭建步骤创建材质与Shader在Project窗口右键创建新的Material。然后创建新的ShaderUnlit Shader或Standard Surface Shader为基础修改将上一节的代码复制进去保存。将Shader赋给Material。准备纹理_MainTex: 基础的岩浆颜色/岩石贴图。_FlowMap: 绘制好的流向图。_FlowTexture: 用于流动的细节纹理可以是岩浆的高频细节、泡沫、灼热纹理等。有时直接使用_MainTex也可以。_EmissionMap: 发光的裂纹、高温区域贴图。_BumpMap: 法线贴图增强立体感。配置材质球将准备好的纹理拖拽到材质球对应的属性槽中。应用到模型将材质球拖到场景中的网格模型上如一个平面或地形。开始调试进入Play模式观察效果并开始调整参数。4.2 关键参数调试指南调试是一个艺术与科学结合的过程。以下参数需要联动调整参数作用调试建议与经验值_FlowSpeed整体流动速度岩浆宜慢建议0.1~0.5。值太大会像水。_FlowCycle单次循环周期控制纹理“重置”的快慢。与_FlowStrength相关。强度大时周期应调小避免纹理被拉得太长。建议0.5~2.0。_FlowStrength流动强度/UV偏移幅度这是最重要的参数之一。控制视觉上纹理的形变程度。通常很小0.01~0.1。需要与_FlowCycle配合强度 * 周期 ≈ 纹理偏移一个完整Tiling所需的时间。可以先设一个较小的强度如0.02慢慢增加。_EmissionStrength自发光强度根据HDRP/URP或Built-in RP的亮度范围调整。Built-in下1-5通常足够HDRP下可能需要几十甚至上百。_BumpScale法线强度岩浆表面起伏不宜太强0.1~0.5即可主要用来捕捉高频细节。调试流程先将_FlowStrength设为0确保所有基础纹理显示正常。缓慢增加_FlowStrength到0.02左右观察是否有细微的纹理滑动。此时可能还看不出双层混合效果。调整_FlowCycle到1.0然后微调_FlowSpeed观察流动节奏。目标是看到平滑、持续的移动没有卡顿或跳跃。仔细观察纹理接缝。如果看到明显的“复位”闪烁说明两层纹理的混合没做好。检查lerpWeight的计算公式确保它在0和1之间平滑过渡。也可以尝试调整混合函数例如使用smoothstep让过渡更柔和。加入自发光和法线效果并调整其流动强度系数如代码中的*0.5让不同视觉层次的流动速度略有差异增加真实感。4.3 性能优化与进阶技巧纹理压缩Flow Map作为方向信息对精度有一定要求。避免使用压缩格式如DXT1导致颜色阶跃从而引起流动方向跳变。建议使用RGBA32无压缩格式或者BC5DX11/RGTC格式专门压缩两个通道的无损格式。纹理尺寸Flow Map不需要特别高的分辨率因为向量场变化通常是平滑的。512x512甚至256x256对于中小型物体通常足够。Shader变体与关键字如果你的Shader有很多可选的特性如是否使用法线、是否使用发射光请使用#pragma shader_feature来生成变体避免不必要的计算被带入所有材质。屏幕空间热扭曲为了模拟岩浆上方的热空气扭曲可以额外增加一个屏幕空间UV扰动的Pass。在Image Effect或后处理中采样当前屏幕颜色时用Flow Map或噪声图对采样UV进行轻微偏移。这能极大地增强热量感。顶点动画结合对于大型岩浆河单纯纹理流动可能显得单薄。可以结合简单的顶点动画让河道中央的顶点沿流动方向轻微起伏形成波浪感。在顶点着色器中根据Flow Map方向需要从顶点传递到片元和时间对y轴坐标进行正弦波扰动。5. 常见问题排查与实战技巧在实际操作中你几乎一定会遇到下面这些问题。这里是我踩过坑后的经验总结。5.1 问题排查速查表现象可能原因解决方案纹理完全不动1._FlowStrength为0。2._FlowSpeed为0。3. Flow Map纹理全是灰色RGB~128向量为0。4. Shader中UV计算错误偏移量未应用到采样UV。1. 检查材质参数。2. 检查材质参数。3. 用图片查看器检查Flow Map确保有颜色变化。4. 在Shader中使用clip(flowVector.r);等语句调试或输出调试颜色如o.Albedo float3(flowVector.r*0.50.5, 0, 0);来可视化Flow Map。流动方向不对Flow Map的RG通道与Shader解码方式不匹配。可能制作软件与Shader的坐标系Y轴向上/向下不同。在Shader中尝试交换flowVector.rg或对其中一个取负。例如float2 flowVector float2(flowSample.r*2-1, -(flowSample.g*2-1));。出现明显闪烁或“复位”痕迹双层纹理混合失效。lerpWeight计算错误或两层纹理的相位差不是0.5。1. 确保phaseB frac(phaseA 0.5)。2. 可视化lerpWeighto.Albedo lerpWeight.xxx;它应该是平滑的、周期性的黑白渐变没有突变。3. 尝试使用更平滑的混合函数如smoothstep(0.0, 1.0, lerpWeight)。纹理拉伸严重像橡皮筋_FlowStrength值太大导致单次偏移量超过纹理一个平铺的距离。大幅降低_FlowStrength尝试0.01。同时可以适当减小_FlowCycle让循环更快避免单次偏移累积过长。边缘流动溢出模型Flow Map在模型边缘的向量不为零。1. 返工Flow Map在边缘绘制径向向内的向量或零向量。2. 在Shader中采样一张边缘遮罩贴图与flowVector相乘在边缘将其衰减为0。移动设备上效果差或卡顿1. Shader计算复杂片元指令数高。2. 纹理格式未压缩带宽压力大。3. 使用了高分辨率纹理。1. 简化Shader减少纹理采样次数例如法线不参与流动。2. 为Flow Map使用合适的压缩格式如ASTC。3. 降低纹理分辨率特别是Flow Map。5.2 实战技巧与心得从简到繁不要一开始就试图把所有特性颜色、法线、发射、扭曲都加上。先实现最核心的单层纹理流动确保Flow Map采样、解码、偏移、动画循环全部正确。然后再加入双层混合。等基础流动完美无瑕后再依次叠加发射光、法线等效果。每一步都单独测试能极大降低调试难度。善用调试输出Shader调试不像脚本不能设断点。最有效的方法是把中间变量如flowVector,phase,lerpWeight直接输出到o.Albedo或o.Emission上通过颜色来观察它们的值是否正确、变化是否平滑。这是定位问题的黄金法则。时间变量的选择_Time是一个float4其中_Time.y是自场景加载以来的时间秒。对于循环动画_Time.y就足够了。如果你需要与系统时间无关的、可暂停的动画可以自己定义一个_CustomTime并通过脚本控制。让美术参与进来把_FlowSpeed,_FlowStrength,_FlowCycle这些关键参数暴露给美术同学调整。他们对于“感觉”的把握通常比程序员更准。提供一个直观的材质球让他们能实时调节出最符合视觉需求的流动效果。性能考量每多采样一次纹理就多一份性能开销。在移动端要严格控制。如果性能吃紧可以考虑让法线贴图不参与流动保持静态。使用更简单的混合函数。将颜色和自发光贴图合并到一张纹理的RGB和A通道。对于远处物体使用简化的Shader变体甚至替换为序列帧动画。最后别忘了灯光和环境反射。将岩浆材质设置为高自发光的同时也可以给它一点粗糙度让环境光能产生微弱的高光这会让它在场景中融合得更好而不是一个孤立的发光片。