Unity Shader入门:用一张贴图实现逼真的动态焦散效果
发布时间:2026/9/18 3:21:14来源:尧图网络
写学习笔记写到第一百篇我越来越觉得一个道理Shader 里最出效果的东西往往不是那些看起来特别“高大上”的算法而是把一个光学现象拆到足够简单之后再用巧妙的方式骗过眼睛。焦散Caustic就是非常典型的例子。你肯定见过泳池底部那种一层层网状光斑随着水面晃动不停扭动、闪烁偶尔还会在池壁上留下几道亮线。只要你做水下场景、泳池、浴室瓷砖、玻璃器皿展示甚至数字孪生里的水面投影迟早会遇到这个效果。这篇笔记我打算聊一个不需要光线追踪、不需要光子映射、也不依赖复杂后处理的“简单焦散”方案。核心思路就三步用一张焦散纹理代表光斑分布用时间驱动纹理动画让光斑流动起来再用 Additive 混合把光斑叠加到水底或表面上。这套方案在 PC 端能跑在移动端、微信小游戏这类性能敏感的环境里也能顶住非常适合 Shader 入门者、技术美术、以及想快速给场景提升质感的 Unity 开发者。1. 焦散效果到底在模拟什么为什么它能提升画面质感1.1 从物理现象说起焦散不是“贴图”而是“光线聚焦”焦散这个词听起来很专业但实际上用放大镜就见过。把阳光通过放大镜照到纸上纸上那个特别亮的点就是光线经过透镜折射后汇聚形成的能量集中区。水面上的焦散也是同一个原理只是水面不是一个规则的透镜而是一大块不停波动的透明介质。光线穿过水面时不同位置、不同角度的折射会让光线在水底重新分布有些区域光线密集就形成了亮斑和亮线有些区域光线稀疏就显得暗一些。所以你在泳池底部看到的并不是一张简单的“光斑图片”而是一张由折射光线实时生成的亮度分布图。焦散的视觉特征很鲜明高频、动态、局部高亮而且分布非常随机像是一张会呼吸的网状脉络。理解这一点很重要因为我们做 Shader 时要抓的就是这几个视觉特征而不是真的去计算每根光线的折射路径。1.2 两条路线物理级真实 vs 视觉近似如果你追求物理上的绝对正确那就要走光子映射或路径追踪路线。实时渲染里也可以用光线追踪做一些焦散Unity 的 HDRP 在高配 PC 上是可以跑类似效果的但它的计算开销非常大而且移动端完全不可能实时跑。手机游戏、微信小游戏、数字孪生展示项目里我们需要的是另一条路视觉近似。视觉近似的思路很简单与其计算光线怎么折射不如直接准备一张“焦散纹理”来模拟光线汇聚后的亮度分布然后把这个分布通过动画投射到水底表面。这张纹理上是密密麻麻的亮斑和亮线就像静态的焦散图案。再让纹理坐标随时间变化让亮斑流动起来就能模拟出水波晃动时焦散不断变化的效果。这套方案的优点是性能开销非常低一个普通的 Unlit Shader 就能搞定不需要额外相机、不需要 RenderTexture、不依赖物理引擎。缺点也很明显就是它不精确焦散不会因为物体遮挡而产生正确变化也没有真正的光线聚焦过程。但绝大多数游戏场景里玩家压根不会盯着水面和光斑做物理实验只要动态、色彩、分布都像那么回事效果就已经非常能打了。2. 简单焦散 Shader 的核心设计思路2.1 把纹理“印”到表面上世界坐标采样和投影采样开始写焦散 Shader 之前必须先想清楚一个问题焦散的纹理坐标从哪来。最直接的做法是把焦散纹理当作普通贴图用模型的 UV 坐标采样。但这样做有个明显问题就是焦散会跟着模型 UV 走如果模型是一个平面还好换到其他物体上纹理分布就会乱掉而且跟“光从上方射下来”的直觉不符合。回看真实焦散的来源它是从水面方向投射下来的所以焦散应该更像是一张从光源方向打下来的“投影”。实现这个效果有两种常见做法。第一种是直接拿世界坐标的 XZ 分量当采样坐标对平面场景来说非常合适因为世界坐标是稳定的焦散光斑会老老实实地铺在地板上不会有拉伸或错位。第二种是投影纹理技术需要把世界坐标转换到光源空间得到类似投影仪的 UV再采样焦散纹理适合石墙、台阶、泳池内壁这些倾斜表面。我这篇笔记重点讲的是第一种也就是基于世界坐标 XZ 的平面采样方案。为什么选它因为水底、地板、泳池底这些最常见的焦散应用场景基本都是平面。用世界坐标 XZ 采样Shader 里不需要额外传矩阵也不需要脚本预算变换直接i.worldPos.xz * _Scale就能得到 UV简单、干净、容易理解。后面如果项目需要投射到复杂表面再往投影纹理方向扩展也不难。2.2 用时间驱动光斑流动让画面“活”起来如果只是把焦散纹理贴在水底效果就是一张静止的污渍图看起来非常死板。真实焦散的核心魅力在于动态变化所以必须让纹理坐标随时间移动。Unity Shader 里有一个内置变量_Time.y它表示从场景开始运行到当前帧经过的时间。把它加到 UV 上焦散图案就会沿着指定方向持续移动。但单纯的平移还是有问题所有光斑都朝着同一个方向匀速移动仔细看会觉得很机械。这也是很多初学焦散的人做出来效果“假”的原因。我的做法是至少叠加两层第一层用较慢的速度朝一个方向漂移第二层用更快的速度、不同的方向甚至不同的缩放比例漂移然后在 Fragment Shader 里把两次采样结果相加。两层图案相互错开、相互叠加亮斑之间会出现“此消彼长”的感觉动态随机感一下就出来了。想要效果更好可以在采样 UV 之前用一张噪声贴图对坐标做一次扭曲扰动让焦散光斑的轨迹不再是一条直线而是弯弯扭扭的。不过做简单焦散时那属于进阶操作先不加也行。两层漂移已经能骗过大多数眼睛笔者的经验是先让光斑动起来再考虑怎么让它动得更“乱”。2.3 混合模式、遮罩与作用范围的控制焦散本质上是一种“增强光照”不是普通贴图。它应该在原有表面颜色的基础上叠加亮斑而不是直接覆盖掉表面。所以混合模式一般选加法混合常见写法是Blend SrcAlpha One或者Blend One One。区别在于SrcAlpha One可以用 Alpha 通道调节焦散强度调参更方便One One则是直接叠加光的能量感更强。我习惯用SrcAlpha One因为材质面板上可以直接通过颜色的 Alpha 控制到处程度。另一个容易被忽略的问题是焦散的作用范围。如果直接把 Shader 挂到整个场景的大平面上可能所有地方都有亮斑显得很脏。实践中最简单的方法是把焦散材质单独放在一个平面或 Quad 上放在水底表面上让焦散只出现在这个范围内。这比写一堆深度检测、遮罩判断要省事得多。比如泳池场景里在底部铺一个略大于池底的透明面片焦散就只在这个区域出现完全不会污染水面以外的场景。3. 从零手写一个可用的简单焦散 Shader3.1 准备一张焦散纹理下载、自制、程序化生成写 Shader 之前得先有一张能用的焦散纹理。网上有很多免费的焦散纹理包搜索“caustic texture seamless”能找到不少注意一定要选“seamless”也就是无缝平铺的否则采样到边缘会有明显接缝。如果没有现成资源也可以自己在 Photoshop 或 GIMP 里做新建一张 512x512 的灰度图用“云彩”滤镜打底再用“玻璃”或“纤维”滤镜生成网状纹理最后提高对比度让亮斑和暗部对比更明显。如果连美术软件都不想打开还可以用 Unity 脚本临时生成一张凑合用的纹理。原理是把多个频率的正弦波叠加模拟光线汇聚形成的高频网络。下面这段代码是我调试时应急用的效果自然比不上专门制作的纹理但至少能让焦散效果先跑起来。using UnityEngine; public static class CausticTextureGenerator { public static Texture2D Generate(int size 256) { Texture2D tex new Texture2D(size, size, TextureFormat.RGBA32, false); Color[] pixels new Color[size * size]; float scale 12f; for (int y 0; y size; y) { for (int x 0; x size; x) { float nx x / (float)size; float ny y / (float)size; float v 0f; for (int i 1; i 5; i) { float f scale * i; float sx Mathf.Sin(nx * f i * 0.7f); float cy Mathf.Cos(ny * f i * 0.3f); v Mathf.Abs(sx * cy) / i; } v Mathf.Clamp01(v * 1.4f); pixels[y * size x] new Color(v, v, v, 1f); } } tex.SetPixels(pixels); tex.Apply(); tex.wrapMode TextureWrapMode.Repeat; tex.filterMode FilterMode.Bilinear; return tex; } }这段代码生成出来的纹理是一种高频的网格状图案跟焦散的“网状亮线”还是比较接近的。用的时候记得把纹理的 Wrap Mode 设为 RepeatFilter Mode 设为 Bilinear否则平铺边缘会有硬切动态变化时也会出现闪烁。3.2 Shader 完整实现逐行拆解一个可复用的焦散 Shader下面给出一个完整的内置渲染管线 Unlit Shader。它不依赖 Surface Shader直接走 Vertex/Fragment 流程混合模式用SrcAlpha One两层纹理采样叠加适合直接挂在水底平面或任何需要焦散效果的面片上。Shader Unlit/SimpleCaustic { Properties { _MainTex (Caustic Texture, 2D) white {} _Intensity (Intensity, Range(0, 3)) 1.2 _Speed (Speed, Range(0, 2)) 0.4 _Scale (Scale, Range(0.5, 6)) 2 _TintColor (Tint Color, Color) (0.5, 0.8, 1, 1) } SubShader { Tags { QueueTransparent RenderTypeTransparent } Blend SrcAlpha One ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; half _Intensity; half _Speed; half _Scale; fixed4 _TintColor; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { // 第一层主焦散速度较慢 float2 uv0 i.worldPos.xz * _Scale; uv0 _Time.y * _Speed * float2(1.0, 0.7); // 第二层次焦散频率更高速度较快方向相反 float2 uv1 i.worldPos.xz * _Scale * 1.8 float2(0.35, 0.72); uv1 - _Time.y * _Speed * 0.8 * float2(1.0, -0.6); fixed c0 tex2D(_MainTex, uv0).r; fixed c1 tex2D(_MainTex, uv1).g; half strength saturate((c0 c1) * _Intensity); return fixed4(_TintColor.rgb * strength, strength * _TintColor.a); } ENDCG } } }逐个说说关键点。Blend SrcAlpha One决定了焦散是叠加光ZWrite Off避免透明物体写入深度缓冲导致其他透明物体排序异常Cull Off保证面片翻过来也能看到焦散。worldPos.xz是核心它把世界坐标当成 UV让焦散稳定铺在水平表面上。uv0和uv1的方向、速度、缩放都不一样这样叠加起来才有自然的随机感。为什么要从 R 通道和 G 通道分别采样因为同一张纹理的 R 和 G 通道是一样的灰度数据但采样坐标不同相当于用两套坐标采样同一张纹理省了一张额外贴图。最后用saturate把结果限制在 0 到 1 之间防止过曝。3.3 场景搭建与参数调整贴上去还要调得像Shader 写完在 Unity 里的验证流程也值得说一说。新建一个 Plane 作为水底把材质拖上去。场景里放一个方向光模拟太阳光太阳光的方向尽量朝下这样焦散看起来会跟光照方向一致。运行后如果效果太亮或太暗优先调_Intensity一般 1.0 到 1.5 之间比较合适超过 2 就容易白成一片。_Scale控制光斑大小数值越大光斑越小越密。_Speed控制流动速度建议不要超过 1.0否则光斑快速乱闪眼睛看着非常累。色调上水下的焦散通常偏蓝青色我用_TintColor默认 (0.5, 0.8, 1, 1) 就是这个目的。如果是泳池边或室内装饰场景可以把颜色换成暖白 (1, 0.95, 0.8, 1)亮度也会显得更柔和。需要注意的是这个 Shader 是叠加式的一定要放在平面上方一点点避免跟底面 Z-fighting。实战中我习惯把焦散平面抬高约 0.02 到 0.05 个单位防止闪烁。3.4 性能优化与移动端注意事项简单焦散虽然已经足够轻量但真要拿到移动端、微信小游戏这类环境还是有几个优化点要注意。第一采样次数要克制两层刚刚好三层以上就会明显增加 fillrate 压力。第二Shader 里能省就省变量精度优先用 half 和 fixed不要什么都用 float。第三焦散纹理尺寸控制在 512 以内贴图压缩格式用 ASTC 或 ETC2过滤模式 Bilinear 就好不要用 Trilinear。第四也是最容易被忽略的焦散平面属于透明物体叠加混合会增加 Overdraw所以焦散面片范围一定要控制得精准能覆盖水底就好不要铺满整个场景。另外提一个很多人踩过的坑如果是跑在微信小游戏环境Shader 里尽量不要用过于复杂的循环或者依赖平台差异大的内置函数。上面这个 Shader 都是基础操作兼容性很好直接放到项目里就能用。如果必须用 URP那就要换一种写法而且内置管线的 Shader 是不能直接兼容的。4. 踩坑记录与排查技巧4.1 焦散纹理看起来是“死”的完全不流动这个问题最常见的原因是_Time.y没有被加进 UV 计算或者加进去之后乘的系数太小导致变化速度肉眼看不出来。排查时先确认 Shader 代码里确实用到了_Time.y然后检查_Speed数值不要低于 0.1。另一个原因就是只有一层采样单一方向的平移从视觉上容易被当成“贴图在滑”而不是“光斑在流动”。解决办法就是再加一层不同频率不同方向的采样两层之间会形成明暗交替动态感自然就出来了。4.2 焦散效果闪烁、跳变、有接缝闪烁通常出现在纹理平铺但不满足无缝条件时可能是 Wrap Mode 没有设置成 Repeat也可能是纹理边缘不连续。另外如果两层焦散的速度向量太接近比如都是 (1, 0.7) 和 (1, 0.65)就会产生低频的摩尔纹看起来像闪烁。我习惯把第二层的频率缩放跟第一层错开 1.8 倍左右方向向量尽量不同比如一个朝右上、一个朝左下这样叠加出来的图案才不会有规律性的干涉条纹。4.3 焦散颜色发灰或者一片惨白加法混合模式下叠加次数越多亮度越容易溢出。如果_Intensity拉得过高或者两层采样结果相加后没有做saturate就会出现一片死白。解决办法是把_Intensity控制在 0.5 到 1.5 之间并且确保最终输出前有saturate。如果是颜色发灰多半是_TintColor的亮度太低或者 RGB 太平均试着把蓝色通道往上提改成 (0.3, 0.7, 1, 1) 这种带色彩倾向的颜色。4.4 URP 环境下完全黑屏或者显示报错这个问题很典型。内置渲染管线的 Shader 直接拿到 URP 下通常不能正常显示因为 URP 的关键词、光照函数和宏定义都不一样。解决方案有三个一是直接在 URP 下用 Shader Graph 平铺一个焦散纹理节点逻辑跟上面一样二是手写 HLSL把UnityCG.cginc换成 URP 对应的库变量用CBUFFER包裹三是在项目设置里把材质转换一下但 URP 下的最佳实践还是单独维护一份 URP 版本。如果没有必要不要强行复用内置管线的 Shader。4.5 常见问题速查表现象可能原因处理方法焦散不流动没加_Time.y或_Speed太小确认 UV 中加入了时间_Speed调到 0.3 以上画面闪烁或摩尔纹两层频率太接近、纹理不可平铺缩放错开 1.8 倍纹理设为 Repeat方向相反焦散过曝成白板_Intensity过高或没做 clamp控制强度在 1.0-1.5输出前用saturate焦散影响全场景焦散面片覆盖范围太大单独用一个小平面控制作用范围URP 下黑屏内置管线 Shader 不兼容改 Shader Graph 或手写 URP HLSL颜色太灰Tint 色 RGB 太平均增加蓝色通道分量降低亮度5. 让简单焦散更好看的几个升级方向5.1 从两层到三层的动画叠加两层焦散的效果已经能看但如果想让画面更丰富可以考虑叠加第三层。第三层的核心思路仍然是“不同频率 不同速度 不同方向”但还可以加一个随时间旋转 UV 的操作。旋转可以用二维旋转矩阵实现采样之前先把 UV 绕中心点旋转一个角度然后再采样。float angle _Time.y * 0.1; float ca cos(angle); float sa sin(angle); float2 uv2 i.worldPos.xz * _Scale * 2.6 float2(0.1, 0.5); uv2 - float2(0.5, 0.5); uv2 float2(uv2.x * ca - uv2.y * sa, uv2.x * sa uv2.y * ca); uv2 float2(0.5, 0.5); uv2 _Time.y * _Speed * float2(0.3, 0.2);第三层的亮度要压得低一些比如采样结果乘 0.4否则三层叠加之后会亮到没法看。旋转速度 0.1 只是参考实际操作中可以慢一点让第三层成为背景里若有若无的动态细节。5.2 用水面法线扰动焦散落点焦散是光线穿过水面折射产生的所以水面波动的法线方向会直接影响焦散在水底的位置。这个效果可以在 Fragment Shader 里近似地做出来先采水面法线贴图把法线 XY 作为 UV 偏移加到焦散采样坐标上。这样焦散图案会跟着水波方向产生扭动比单纯平移细腻得多。fixed3 normal UnpackNormal(tex2D(_NormalTex, uv0 * 0.5 _Time.y * 0.1)); uv0 normal.xy * _Distort; uv1 normal.yx * _Distort * 0.6;这里_Distort是扭曲强度一般 0.02 到 0.05 就足够。太大会导致焦散完全变形看不出原本的网状结构。需要提醒的是法线贴图采样会增加一次纹理采样开销如果项目对性能要求很苛刻可以省略这一步仅靠多层平移也能出效果。5.3 接一个全局强度参数方便策划调节焦散效果在项目里不是静态的白天、夜晚、室内、室外光线条件不一样焦散强度也应该不同。我一个比较习惯的做法是在材质属性面板留好_Intensity然后用 C# 脚本在特定时机动态调整数值。比如白天 1.2夜晚 0.3角色走进室内就归零。代码非常简单material.SetFloat(_Intensity, 0.8f);用材质属性面板直接调已经很方便但如果你有多份材质、多份焦散面片统一用脚本管理会更不容易漏改。我自己在实际项目里做水下场景的时候开始会把_Intensity拉到 1.8 自嗨后来放到场景里才发现太抢眼整个水底亮得像开了闪光灯。最后收敛到 1.0 左右配合蓝青色染色反而更接近真实泳池底部的观感。做焦散效果最考验的往往不是技术而是取舍。参数宁可收敛也别夸张毕竟它是氛围的一部分不是主角。
网站建设高端定制企业官网