Unity Shader入门:用一张贴图实现逼真的动态焦散效果

发布时间:2026/9/18 3:21:17

Unity Shader入门:用一张贴图实现逼真的动态焦散效果 写学习笔记写到第一百篇我越来越觉得一个道理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 左右配合蓝青色染色反而更接近真实泳池底部的观感。做焦散效果最考验的往往不是技术而是取舍。参数宁可收敛也别夸张毕竟它是氛围的一部分不是主角。
延伸阅读

更多相关文章

2026/9/18 3:21:17

微软文本建议与自动更正完全指南:从配置到排错

前几天帮一位朋友远程调电脑,他卡在一个问题上:明明买的是预装正版Windows的笔记本,但打字时下方的候选词条怎么调都出不来了。他一边翻搜索引擎一边念关键词,我隔着屏幕都听到他在搜“Microsoft text suggestion”。后来我发现&a…

2026/9/18 3:21:17

JSON.stringify深入指南:序列化规则、replacer参数与常见坑

在实际开发里,JSON.stringify大概是每个前端每天都离不开、却又很少真正读懂它的一个方法。多数人用它的场景就是JSON.stringify(obj)一把梭,用来调试日志、存localStorage、深拷贝数据,偶尔遇到循环引用报错、BigInt报错、undefined被丢掉&a…

2026/9/18 5:06:21

B站API与视频下载全解析:从URL参数到接口调用实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/18 5:06:21

回归实战全攻略:从线性回归到XGBoost与边缘端量化

1. 为什么是回归:从问题定义到实战思路1.1 回归到底在解决什么如果你翻开任何一本机器学习的实战教程,前几章多半是线性回归、逻辑回归、决策树这些基础模型,而到了第四章这种节点,通常就开始“动真格”了。回归在机器学习里的地位…

2026/9/18 5:01:21

COMSOL自由落体模拟实战:全局ODE建模与瞬态求解全解析

晚上十一点,一个朋友突然在微信上找我:他用Comsol做自由落体模型,一个直径10mm的小球从10米高度落下,结果算了半天,小球纹丝不动。他贴了设置截图:组件选的是三维、固体力学接口,研究类型用的稳…

2026/9/16 12:52:37

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/18 0:01:09

Google Colab 实战:运行模型、数据加载与报错排查

1. 为什么我劝你先搞懂 Colab 的运行模型1.1 Colab 到底是什么,跟本地跑代码差在哪Google Colab 简单说就是一台跑在浏览器里的 Linux 虚拟机,你打开一个 Notebook,背后就连上了一台带 GPU 的远程机器。你在单元格里敲的每一行 Python&#x…

2026/9/18 0:01:09

C语言数据类型与表达式详解

1. C语言数据与数据类型概述在C语言编程中,数据是程序处理的核心对象。理解数据的分类和特性是掌握C语言的基础。C语言中的数据主要分为四大类:常量、变量、表达式和函数。这些数据类型构成了C语言程序的基本元素,每种类型都有其独特的特性和…

2026/9/18 0:01:09

SQL时间字段指定时间段查询:区间语义、索引与时区避坑

上周排查一个线上问题&#xff0c;用户反馈"昨天的订单一条都没查到"&#xff0c;但数据库里明明躺着两千多条。最后定位下来&#xff0c;不是数据丢了&#xff0c;也不是接口挂了&#xff0c;而是那个查询条件把时间段写成了> 2024-05-20 00:00:00 AND < 2024…

2026/9/16 22:55:57

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行&#xff0c;Type-C接口算是典型的“看着简单&#xff0c;做起来全坑”的东西。光引脚就24个&#xff0c;高低速信号、电源、控制线全部塞在一个小小的连接器里&#xff0c;如果PCB布局不做规划&#xff0c;打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/16 22:56:09

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/16 22:56:16

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码