☰
用Surface Shader实现逼真流动效果:从UV偏移到光照融合
2026/10/13 7:50:51 网站建设 项目流程

1. 为什么是 Surface Shader:流动效果不该和光照"脱钩"

1.1 Surface Shader 帮我省掉了什么工作量

流动效果大概是 shader 学习里最有"性价比"的一个方向:代码量少、反馈即时,调一下速度向量整个材质就活过来了。我先后在好几个类型的场景里用过它——水面、岩浆、传送门、能量管路,甚至过场里那种"符文从墙面上淌下来"的特效,底层都是同一套 UV 位移逻辑。但如果你让我给这类效果选一个最稳的实现方式,我会毫不犹豫选 Surface Shader,而不是从零抄一段顶点/片元着色器。

很多人第一次学流动效果,照着教程写完一个 Fragment Shader,把采样结果直接输出到屏幕,demo 跑起来很带感。但一旦放进实际关卡,问题就来了:模型不受光,或者亮得很假,高光、阴影、环境光和周围场景完全脱节。出现这个问题的根源在于,你没有处理光照计算。而 Surface Shader 的核心价值恰恰在这里:Unity 会把 shader 里的 surf 函数自动编译进完整的光照管线,你只需要告诉它"这个点的颜色是多少、法线朝向哪、粗糙度大概什么水平",剩下的漫反射、高光、阴影接收、全局光照采样,引擎替你打包处理。

对一个要频繁调整颜色和法线的流动效果来说,这个抽象层省掉的不是几行代码,而是一整类容易出错的胶水逻辑。你在surf函数里改一个归一化法线,灯光立刻正确地偏折,不需要自己手写光照模型,更不用关心平台之间的光照差异。对我们这种又要出效果、又要赶进度的团队来说,这个取舍很现实。

1.2 什么时候别用 Surface Shader

先泼一盆冷水:Surface Shader 不是所有流动需求的答案。如果你要的是完全不依赖场景光照的纯屏幕特效,比如 UI 上的流光扫过、全屏水波扭曲、后期处理类的动态扰动,那用后处理或 UI 专用着色器反而更合适。Surface Shader 天生绑定在光照管线里,哪怕它内部开销不高,只要挂在场景物体上,就会参与渲染队列、阴影、深度测试这些环节,没必要为"屏幕空间里一个几帧的过渡特效"背上这些包袱。

我的选型标准很直接:凡是"存在于世界空间、需要被灯光照亮"的流体材质,水面、岩浆、全息门、能量墙,优先用 Surface Shader;凡是"不受光影响、只是贴在图上的装饰动画",用顶点/片元着色器或 Shader Graph 更轻盈。这第一道选择题做对了,后面能省掉大量处理光照和渲染顺序的时间。

2. 流动的本质:UV 偏移、_Time 和第一个可运行材质

2.1 UV 滚动是怎么骗过眼睛的

流动效果的原理其实特别朴素:UV 平移动画。模型表面每个顶点都有一套 UV 坐标,用来决定"这个位置采样贴图的哪个像素"。贴图本身是死的,但如果你在 shader 里不断改变采样坐标,让每个采样点每一帧都去取纹理的不同位置,人眼就会觉得整张图在朝某个方向移动。

我习惯用一个广告灯箱来类比:一张长海报在灯箱后面匀速拖动,观众透过固定窗口看到图案不断变化。UV 坐标就是这个窗口,速度向量是海报移动的方向和快慢,而_Time就是那个一直在转的电机。理解了这个模型,后面再去看扰动、多层叠加、法线流动,只是在"电机"和"窗口"之间加了更多齿轮。

在 CG/HLSL 里,这个操作落实到代码上就一句话:采样前把 UV 加上一个随时间增长的偏移量。偏移量越大,流动越快;偏移方向决定流体往哪走。需要注意的是,UV 是一个无量纲的坐标空间,和模型的实际大小没有直接关系。同一个速度向量,放在几千米的海面上和放在一只杯子里,视觉上的流速感受是完全不同的,调参时要有心理预期。

2.2 _Time 变量:shader 里的秒表

Unity 的 Surface Shader 里可以直接用_Time,它是个float4,四个分量分别是t/20、t、t*2、t*3,单位是秒。最常用的是_Time.y,它从 shader 开始工作起一直累加,本身就是个天然驱动动画的秒表。用它乘以一个速度值,就能得到"当前应该把 UV 偏移多少"。

如果你需要做暂停功能,比如对话框弹出时场景水流也要冻结,直接依赖_Time.y就不太好控制。更稳妥的做法是写一个 C# 脚本,把累计时间变量通过全局属性传入 shader。这样做无非是让 shader 里的时间源可暂停、可回溯,实现成本很低,但对交互类项目的体验影响很大。这个细节我在后面讲脚本驱动时还会再提。

2.3 最小可运行版本:20 行让贴图跑起来

先给一个最基础的版本,去掉一切花哨功能,只保留"一张贴图朝一个方向滚动"。

Shader "Custom/BasicFlow" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _FlowSpeed ("Flow Speed (XY)", Vector) = (0.2, 0, 0, 0) } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; float4 _FlowSpeed; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { float2 uv = IN.uv_MainTex + _FlowSpeed.xy * _Time.y; fixed4 c = tex2D(_MainTex, uv); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } Fallback "Diffuse" }

这段代码里有几个关键点需要解释。

struct Input里的uv_MainTex是 Unity 自动注入的变量,它会读材质面板上_MainTex的 Tiling 和 Offset 设置,把模型的原始 UV 转换成采样坐标。如果你的贴图属性叫其他名字,需要对应修改这个字段名,比如_BaseTex对应uv_BaseTex,命不上的话tex2D会直接用原始 UV 采样,Tiling 和 Offset 就失效了。

surf函数里,_FlowSpeed.xy是每秒 UV 偏移量。举个例子,设成(0.2, 0, 0, 0),意味着每过一秒,采样坐标整体向 U 方向移动 0.2。如果你的贴图是平铺的,视觉上大约每 5 秒完成一个整周期,这个速度偏慢,适合大范围水面。想快就加大数值,X 和 Y 可以配合出斜向流动。

使用起来很简单:在材质面板新建材质,挂到任意有 UV 的 Mesh 上,把_MainTex换成你的贴图,调一下_FlowSpeed就能看到效果。我通常会先建一个平面做测试,确认方向对了再换到真实模型上,避免在复杂的 UV 包裹里误判方向。

3. 从"贴图滚动"到"流体材质":扰动、双层叠加与透明度控制

3.1 用噪声扰动 UV,打破"印刷品感"

基础版本跑通之后,很容易发现一个问题:整张贴图像一张被匀速拖动的海报,没有流体该有的"局部分碎感"。真正的河水、岩浆、能量流,表面永远有細小的涡旋和流速差。要打破这种印刷品感,最有效的办法是引入噪声扰动。

思路是这样的:先用_Time.y生成基础滚动 UV,再采样一张噪声图,把灰度值转成小范围的偏移量,叠加到采样坐标上。因为噪声图每个位置的灰度都不同,主贴图被采样到的位置就会一张一缩,像水面上被风吹皱的倒影。

float2 noiseUV = IN.uv_MainTex + _Time.y * _NoiseSpeed; float noise = tex2D(_NoiseTex, noiseUV).r; float2 finalUV = IN.uv_MainTex + _FlowSpeed.xy * _Time.y; finalUV += (noise - 0.5) * _DistortStrength; fixed4 c = tex2D(_MainTex, finalUV);

这里noise - 0.5是为了把噪声值从 [0,1] 映射到 [-0.5,0.5],这样扰动方向不会整体偏向一侧。_DistortStrength一定要控制在小范围,一般 0.03 到 0.1 就够。调大了纹理就会开始抖动、糊成一团。如果你用的是 Perlin 噪声或者湍流贴图,扰动曲线会更自然;普通噪点图也能用,但需要把 Tiling 调大一些,避免出现明显的网格感。

3.2 双层纹理滚动,把循环周期拉长

单张贴图不管多好看,滚动几个周期后一定会被眼睛识别出"又开始了"。尤其是光带、条纹这类特征明显的贴图,循环感会非常出戏。解决这个问题的经典方法是双层纹理滚动:主贴图一个方向,细节贴图另一个方向,两者以不同速度运动,最后在 shader 里混合。两个不同周期的循环叠在一起,人眼实际感知到的循环周期会被拉长到两者的最小公倍数级别,基本看不穿。

混合方式可以按场景选。水面类需求我常用乘法叠加,主贴图乘细节贴图,能够在保留底色的同时加入高频碎纹;能量类材质则更适合加法或简单 lerp,明亮的部分会更突出。还有一种比较讲究的做法是用第二层的灰度值去 modulate 第一层的透明度或者颜色浓度,做出来的流动层次更丰富。

在实际项目里,我一般把两层纹理的滚动方向做成近似垂直,比如主贴图水平流、细节贴图轻微斜向流。这样回字形循环感被破坏得更彻底,水流的"编织感"也更强。如果你用 Surface Shader 来做岩浆,主层用深色裂纹,细节层用亮色熔岩,让亮色层流速快一些,出来的效果会非常接近真实岩浆那种"外皮慢、核心快"的观感。

3.3 透明流体与边缘融合:alpha:blend 的正确姿势

传送门、能量墙这类效果,需要让材质透明,并且边缘逐渐淡化。Surface Shader 处理透明有几个固定的组合:#pragma surface surf Standard alpha:blend,加上 SubShader 的渲染队列设置为 Transparent。只写了 alpha 却忘了在 pragma 里声明alpha:blend,是最常见的问题——o.Alpha赋值半天,材质依然完全不透明。

#pragma surface surf Standard alpha:blend fullforwardshadows Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" } ZWrite Off

透明的边缘融合我习惯用菲涅尔(Fresnel)来做。原理说穿了很简单:视线越贴近表面切线方向,反射越多、透射越明显,边缘自然显得更亮或者更透明。在 Surface Shader 里,Input 结构里声明float3 viewDir就能拿到视线方向,再用它跟法线做点积,得到的就是边缘强度。

half fresnel = 1 - saturate(dot(normalize(IN.viewDir), o.Normal)); o.Alpha = _BaseAlpha + fresnel * _EdgeStrength;

这套写法不仅能让边缘变透明,还能配合 Emission 做"边缘发光"的传送门效果,属于一鱼两吃。需要注意ZWrite Off一定要显式声明,否则透明物体相互遮挡时会出现半透明区域把后面的东西"吃掉"的诡异情况。

4. 光照模型的取舍:Lambert、BlinnPhong 还是 Standard

4.1 三种光照模型在流动场景下的表现差异

#pragma surface后面那个光照模型名称,决定了材质和灯光互动的方式。很多人从教程里复制Lambert就一直用到底,其实三种常用选项的差别在流动效果上非常明显。

Lambert 是最廉价的漫反射模型,没有高光、没有镜面反射,适合"布料、泥土、石头"这类完全不反光的材质。如果你的水流是动画里那种纯色卡通水,或者只需要大概的颜色流动,Lambert 够了。但真实感水面靠它很难出效果,卷起的水波上一点高光都没有,看起来会像沥青。

BlinnPhong 是在 Lambert 基础上加了半角向量高光计算,能在流动表面产生可移动的光斑,视觉上立刻有了"湿润感"。做岩浆、粘液、大面积平静水面,BlinnPhong 是性价比很高的选择。它的高光形状可控,代码也便宜,老一点的中端机型跑起来压力不大。

Standard 是 Unity 的 PBR 光照模型,配合SurfaceOutputStandard结构体使用,可以输出金属度、光滑度、AO 这些参数。流动水面上,光滑度高的地方反射更强,低的地方更粗糙,光照信息丰富,和场景里的环境反射也能统一。代价是计算量更高,低端手机上要慎重。我的习惯是:主角附近、玩家一定会凑近看的流体用 Standard,远景和氛围流体用 BlinnPhong,肉眼很难看出差别但帧率能差出一截。

4.2 把流动效果接到 PBR 输出上:四个通道的分工

如果用 Standard 模型,我们手上其实有四个可以"流动"的输出通道:Albedo、Normal、Emission、Smoothness。它们对流动效果的贡献不同,各干各的活。

Albedo 负责基底色彩。流体本身的颜色、图案、污渍都在这一层。我的经验是,Albedo 贴图的滚速不要设太快,快了对眼睛压力很大。法线 Normal 负责微小起伏,它才是"流动感"真正的主角。水流表面的细碎波纹基本靠法线贴图的高速流动实现,甚至 Albedo 是一张纯色图,只要法线在动,看起来也像真的有水在流。这一步一定要用UnpackNormal解包法线贴图,直接tex2D采样出来的值是压缩格式,直接赋值会给光照一个错误的法线方向。

Emission 是能量类流体最爱的通道。想让岩浆亮起来,就把采样颜色乘一个大于 1 的强度值赋给o.Emission,再配合边缘菲涅尔,就会有内部发光的效果。Smoothness 可以跟着噪声图走,灰度值高的地方光滑,低的地方粗糙,这样流动表面会有干湿交错的真实感。一个常见的错误是把 Smoothness 设成常量 0.9 塞给整个水面,结果整块水面像抛了光,高光一片糊。让它在 0.3 到 0.8 之间波动,效果会自然非常多。

5. 落地时踩过的坑:透明排序、指令精度和无缝贴图

5.1 透明物体的渲染顺序为什么一错再错

写到第三个项目时,我栽在透明排序上整整一天。表面没问题,一个流动的透明能量墙,放在角色面前时一切正常,但镜头一转,角色走到墙后面,墙体反而把角色遮住了。

原因其实不复杂。透明物体要正确混合,必须先让所有不透明物体画完,再按深度远近距离排序画透明物体。Unity 里的Queue=Transparent就是干这个的,我确认自己没写错。真正出问题的是渲染队列之外的细节:当我从另一个镜头角度观察时,能量墙和另一个透明特效在同一个队列内,两者之间的排序取决于距离和引擎的排序策略,并不绝对稳定。解决方式有几个:优先把半透明物体数量压到最少;必须多层透明叠加时,用脚本手动控制每个材质的renderQueue;让特效美术在制作时尽量避免大面积透明重叠。

另一个高频问题是阴影。透明材质开了alpha:blend,但 SubShader 里没关阴影投射,物体在半透明区域依然投影,就会出现"明明看穿了物体,地面上却有一块影子"的怪事。排查时才意识到需要一个专门的ShadowCaster通道或者把 Cast Shadows 关掉。这块不处理,后面的美术提 bug 能烦死你。

5.2 移动端跑流动 shader 的指令预算

流动效果的代码看着不长,实际计算量比普通材质高,因为它至少有两个额外负担:时间变量每帧驱动所有 UV 采样,噪声扰动需要多采一张贴图。在移动端,尤其是中低端 GPU,这些都要计入指令预算。

我的建议是尽量用 half 精度运算处理颜色和 UV 偏移,不是所有数据都需要 float 的全精度。_Time.y这种全局时间按 float 没问题,但噪声偏移量、混色权重、Alpha 浓度这些用 half 就够了。然后是采样次数,一张主贴图加一张噪声加一张法线,已经是移动端比较舒服的水平,不要为了效果无限叠加采样。如果需要多种扰动,优先考虑把多通道信息烘焙进一张贴图的 RGBA,而不是再加载一张新纹理。

还有一个很容易被忽略的坑:sin、cos、pow这类复杂数学函数在移动端 GPU 上开销不小。如果你用它们来做扰动,尽量改成采样预烘焙的渐变贴图,或者用简单的乘法模拟。我见过一个半成品项目,水面流动每帧做三次sin叠加,中端手机直接掉到 40 帧以内,优化成噪声贴图采样后恢复到 60 帧,效果肉眼几乎看不出区别。

5.3 无缝贴图:一张明显重复的贴图毁掉所有努力

流动效果对贴图的无缝性要求比普通材质严格得多。普通静态贴图有接缝还能靠光影藏一下,流动起来接缝会沿着滚动方向拉出一条条的痕迹,特别显眼。判断一张贴图是否适合流动,不要看你在编辑器里单帧预览的样子,要看它左右边缘和上下边缘相接时是否能够连续。

制作无缝流动贴图,最标准的做法是用图像编辑软件里的偏移工具:把画布整体水平偏移一半,然后用仿制图章把中间出现的接缝修掉,再偏移回来,反复处理直到四个边缘都能衔接。这个过程比较枯燥,但值得花时间,因为一张合格的无缝贴图可以反复用在多个项目里。网络素材库里的"tileable"贴图很多名不副实,拿回来最好实际跑一下再确认。

另外注意 UV 缩放的联动关系。材质面板上的 Tiling 值会改变贴图在空间里的重复频率,同一组_FlowSpeed数值,在 Tiling 为 1 和 Tiling 为 8 时,视觉流速完全不同。我的习惯是先定好 Tiling,再调速度,不然每次都像在猜谜。

5.4 一次"边缘发黑"问题的排查链路

挑一个我印象最深的 bug 记录一下排查过程,这类思路以后遇到类似问题可以复用。

当时做一个半透明流动表面,模型边缘总有一圈明显发黑,而且只在动起来之后出现,静止 UV 时看不出。最开始我以为是混合模式,反复看了alpha:blend和队列设置,正常;然后怀疑是菲涅尔范围太大,把透明度压没了,试着调小_EdgeStrength,边缘还是黑的,只是范围变小了。继续追,最后定位到:噪声扰动采样坐标时,模型边缘的 UV 已经接近 0 或 1,扰动值把它推出了 [0,1] 区间,而那张噪声贴图的 Wrap Mode 设置成了 Clamp。Clamp 模式下的采样会固定取边缘像素,我的噪声贴图边缘恰好是黑色,导致我一直采到黑色边框。

把噪声贴图的 Wrap Mode 改成 Repeat,问题立刻消失。随后我还在扰动权重上做了一层 smoothstep 限制,让边缘区域的扰动幅度自然收敛到 0,从根上避免越界采样。这个 bug 让我养成了一个习惯:凡是需要在 shader 里做非线性的 UV 偏移,第一件事去检查所有参与采样的贴图的 Wrap Mode,这是 Flowmap、扰动、扭曲类 shader 最常见的脏数据来源。

6. 用顶点色和 C# 脚本把"流动"变成关卡叙事工具

6.1 顶点色控制流速和方向

前面说的流动都是整个材质同一个方向、同一个速度。实际项目里常常需要局部变化:某段管道能量流动快,旁边那段慢;地面上的符文要有不同方向的流动轨迹。如果不做细分,就得拆模型、拆材质,工作量很大。

这时候顶点色是最好用的通道。模型美术在 DCC 软件里把流速信息烘焙到顶点色,shader 里读取它并调制_FlowSpeed。比如红色通道表示流速倍率,蓝色通道表示方向混合权重,就能实现一个模型上完全不同的流动节奏。

float speedScale = IN.color.r; // 顶点色 R 通道控制流速倍率 float2 flowDir = lerp(_FlowSpeed.xy, _FlowDir2.xy, IN.color.b); // B 通道控制方向混合 float2 uv = IN.uv_MainTex + flowDir * speedScale * _Time.y;

这套方案对中低多边形模型特别友好,因为顶点色本身就是逐顶点数据,插值后平滑过渡,很适合做渐变流动。唯一要注意的是顶点色数据有时会被美术在建模阶段忽视,导入前确认模型带上了顶点色通道,不然 shader 里读到的全是默认值,效果和没做一样。

6.2 用脚本驱动时间变量,做出"突然加速"的演出效果

最后聊聊脚本侧。流动效果如果始终匀速,时间一长玩家会视觉疲劳。把流速控制权交到 C# 手里,可以在关键节点制造非常强的演出效果:机关触发时能量流突然加速、地面符文从缓慢流淌变为剧烈喷涌、剧情播报时水流暂停冻结。

实现方式很简单:不要在 shader 里直接用_Time.y,改用一个自定义属性,由脚本每帧把它和目标帧率下的 deltaTime 累加传入。脚本还可以对速度值做曲线插值,让加速过程有缓动,而不是生硬的跳变。

public class FlowTimeDriver : MonoBehaviour { public float flowTime; void Update() { float targetSpeed = GameState.IsPlotPaused ? 0f : 1f; flowTime += Time.deltaTime * targetSpeed; Shader.SetGlobalFloat("_FlowTime", flowTime); } }

shader 侧对应地改成_FlowTime代替_Time.y。注意用全局 Shader 属性传时间有个好处,场景里所有同类材质自动同步,不会出现水面、传送门、魔法阵各自跳到不同时间点的情况,做全局暂停和统一节奏非常方便。

我自己在实际项目里最常用的扩展是把这套逻辑封装成一个简单的 FlowDriver 组件,所有需要动态材质的物件挂上去就能用,后期再变成按镜头切换的演出管理器,让流动效果真正成为关卡叙事的一部分,而不是一个纯粹的贴图动画。这种从"材质技巧"往"系统能力"演进的思路,我认为才是这类 shader 实践最有价值的落地方式。

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

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

立即咨询