拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity 2D水体Shader实战:顶点波浪与GrabPass折射全解析

Unity 2D水体Shader实战:顶点波浪与GrabPass折射全解析

简介:面向Unity 2D游戏开发者的一份水体浮力效果实现包,借助内置BuoyancyEffector2D物理组件模拟物体在水中的漂浮、沉没与波浪扰动,适合需要快速为横版或俯视2D场景加入真实水交互的游戏项目。压缩包体积仅38KB,包含37个文件,主要涵盖Unity场景与工程设置asset、水面Shader、材质mat、控制脚本cs以及png贴图、json配置等,结构精简,便于直接导入工程对照调整。已有1158人学习下载。资源内附完整示例场景与工程设置,开发者可查看BuoyancyEffector2D的参数配置、水面渲染Shader及物体浮力计算逻辑,理解液体密度、浮力系数、接触权重等关键属性如何影响物理表现;同时通过ProjectSettings下的Physics2D配置可快速复用现成方案,免去从零搭建复杂模拟的步骤。整体来看,这份小体积资源适合初中级Unity开发者学习2D水体物理交互与水面表现,也适合作为项目迭代时的参考模板。

1. 2dwater 资源是什么:一份让水面“动起来”的 Unity2d 水体 Shader 资源

做 2D 横版项目时,水面是最容易露怯的地方:贴一张蓝色半透明图,动都不动,玩家走过去完全没有“踩到水”的反馈。2dwater 这类 Unity2d 水体资源解决的正是这个环节——它用一套 Shader 在渲染层同时处理波浪顶点偏移、GrabPass 背景折射、UV 扰动和深浅水混色,C# 脚本只负责透传参数,不需要逐帧计算。适合横版动作、俯视角生存、塔防这些需要水面反光但美术没精力单独维护水体动画的团队。我按拆包的顺序把渲染管线、场景搭建、正交相机适配和移动端踩坑都过了一遍,后面直接给可抄的参数。

2. 渲染管线拆解:顶点波浪、GrabPass 折射与 UV 扰动

2.1 顶点波浪:正弦叠加怎么做才不重复

水面最容易露馅的地方,是波浪看起来像机械钟摆,一个周期扫过去观众立刻发现这是假的。多数 2dwater 类资源不会用单条正弦波,而是至少叠两条频率、速度、相位都错开的波。第二波的频率乘一个无理数(比如 1.7),速度乘 0.6,波形包络的重复周期会被拉长到肉眼几乎察觉不到的程度。

下面是 Shader 里顶点阶段的典型写法:

v2f vert (appdata v) { v2f o; // 第一波:主波,沿 UV.x 方向传播 float speed1 = _Time.y * _WaveSpeed; float wave1 = sin(v.uv.x * _WaveFreq + speed1) * _WaveAmp; // 第二波:副波,频率错开 1.7 倍,速度错开 0.6 倍 float wave2 = sin(v.uv.x * _WaveFreq * 1.7 + speed1 * 0.6) * _WaveAmp * 0.5; // 叠加到顶点 Y 上,形成水面起伏 v.vertex.y += wave1 + wave2; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.screenPos = ComputeGrabScreenPos(o.pos); return o; }

两个波叠加之后,整体波形不会像单条正弦波那样每几秒原样重复一遍,而是出现类似“两个不同频率的波互相干涉”的效果,波峰和波谷的位置一直在缓慢变化。注意_WaveFreq控制的是空间频率,数值越大波越密,不是越快;速度快慢由_WaveSpeed决定,两者容易混淆,调参时先分清再动手。

如果你的水面不是横版长条,而是岛屿四周一圈水,只靠uv.x做相位输入是不够的,四周的波浪会同时向同一个方向传播,看起来像整圈水在朝一边倒。常见做法是用atan2(worldPos.x, worldPos.z)算出每个顶点相对水面的角度,把角度当相位输入,波浪就会以水面中心为圆心向外扩散。

2.2 片段着色:GrabPass 背板采样与深浅水混色

顶点波浪只负责“形”,水面看起来像不像水,关键在片段着色器对背景的折射采样。2dwater 类资源里最常见的做法是用GrabPass抓取当前屏幕内容,然后用水面法线的 XY 分量去偏移屏幕 UV,模拟“透过水面看下面物体”的折射效果。

fixed4 frag (v2f i) : SV_Target { // 噪声贴图随时间滚动,产生波纹扰动 float2 noiseUV = i.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw + _Time.y * 0.05; float3 noise = UnpackNormal(tex2D(_NoiseTex, noiseUV)); // 用扰动值偏移屏幕坐标,模拟折射 float2 refrOffset = noise.xy * _RefrStrength * 0.1; float2 backUV = i.screenPos.xy / i.screenPos.w + refrOffset; fixed3 backColor = tex2D(_WaterBackTex, backUV).rgb; // 深浅水颜色按扰动强度混合,扰动强的地方水色更浅 float waveMask = saturate(noise.z + 0.5); fixed3 waterColor = lerp(_WaterColorDeep, _WaterColorShallow, waveMask); // 折射背景与水色按比例混合,避免完全盖住水下物体 fixed3 col = lerp(backColor, waterColor, 0.7); return fixed4(col, 1.0); }

片段前的GrabPass { "_WaterBackTex" }是整段逻辑的前提:这句话告诉 Unity 先把当前画面抓进_WaterBackTex,之后在这个 Pass 里采样。抓屏是一次全屏拷贝,消耗不小,后面避坑章节会专门说这个事的代价。

refrOffset是这段代码的核心边界参数:它把噪声法线的 XY 分量乘_RefrStrength,再缩小 10 倍作为屏幕 UV 偏移量。_RefrStrength的合理区间在 0.02 到 0.1 之间。低于 0.02 折射几乎看不见,水下物体会被水色完全盖住;高于 0.1 背景会被搅成一团糊影,玩家会以为画面花屏。这是个典型的“玄学参数”,必须结合你的噪声贴图对比度来调。

2.3 参数表与调参思路

以下是这套 Shader 的核心参数表,按我调试时的优先级排列:

参数作用建议区间常见误用
_WaveAmp波浪顶点振幅0.005 ~ 0.02调太大导致水面网格穿插周围物体
_WaveFreq波浪空间频率1.0 ~ 3.0调太大变成高频抖动而非水波
_WaveSpeed流速0.5 ~ 1.5超过 2.0 波浪像快进
_RefrStrength折射强度0.02 ~ 0.1超过 0.1 背景糊成一片
_NoiseTex_ST.zw扰动贴图滚动偏移0.01 ~ 0.1为 0 时水波静止不动
_WaterColorDeep深水色深蓝/深青与浅水色亮度差过小会失去立体感

调参时有个快速定位问题的方法:如果整面水是纯色、一点波纹都没有,不要先去动波幅,先检查_NoiseTex的 Wrap Mode 是不是 Repeat。很多下载的噪声贴图默认是 Clamp 模式,UV 滚动到边缘就停住,画面里会出现一条明显的硬边,波纹也只滚动一次就卡死。这是新手最容易忽略的一步,基本上 80% 的“水面没反应”都是贴图平铺设置的问题,不是 Shader 代码的问题。

3. 场景搭建与参数驱动:把 Shader 变成可控的水面组件

3.1 场景搭建步骤与材质设置

Shader 到位之后,场景搭建的顺序会影响你后面排错的难度。我一般按下面这个顺序走:

  1. 在场景中新建一个 Quad 或 Plane,旋转成你需要的朝向。横版游戏一般把 Quad 立起来放在背景层,俯视角游戏则让它平躺在 Y 轴上。
  2. 创建材质,把 Shader 指定为上一步写好的2dwater/WaterSurface。
  3. 给材质分配两张贴图:主纹理给一张水面颜色渐变图,扰动贴图给一张法线或噪声图。
  4. 将材质拖到 Quad 的 MeshRenderer 上。
  5. 给水面物体挂上PolygonCollider2D并勾选isTrigger,用于角色踩水的触水反馈。

第 5 步容易被忽略,因为很多人以为“水面只是视觉,不需要碰撞”。如果你的游戏有子弹入水、角色踩水花这类交互,没有碰撞体这些逻辑全都要在别的系统里绕路实现。水面本身的碰撞体不需要和视觉波浪完全贴合,大致覆盖矩形区域即可,精度问题后面避坑章节再说。

3.2 C# 控制脚本:用 MaterialPropertyBlock 透传参数

Shader 里的参数不能直接裸露在 Inspector 上让策划乱调,最好用一个 C# 脚本统一管理、统一驱动。我拆这个资源包时,发现它自带的控制脚本核心逻辑可以用下面这段代码概括:

using UnityEngine; [RequireComponent(typeof(Renderer))] public class WaterSurface : MonoBehaviour { [Range(0.001f, 0.05f)] public float waveAmp = 0.01f; [Range(0.5f, 3.0f)] public float waveFreq = 1.5f; [Range(0.0f, 2.0f)] public float waveSpeed = 0.8f; [Range(0.0f, 0.15f)] public float refrStrength = 0.05f; private MaterialPropertyBlock block; private Renderer mRenderer; void Awake() { mRenderer = GetComponent<Renderer>(); block = new MaterialPropertyBlock(); } void Update() { // 从 Renderer 上取现有 PropertyBlock,避免覆盖其它人设置的属性 mRenderer.GetPropertyBlock(block); // 把脚本参数写入 Block,运行时不会生成材质实例 block.SetFloat("_WaveAmp", waveAmp); block.SetFloat("_WaveFreq", waveFreq); block.SetFloat("_WaveSpeed", waveSpeed); block.SetFloat("_RefrStrength", refrStrength); // 写回 Renderer,Shader 会在下一帧读到这些参数 mRenderer.SetPropertyBlock(block); } }

这段代码有两个关键点。第一,用的不是material.SetFloat,而是MaterialPropertyBlock。直接改 material 会在运行时生成一个材质实例,场景里如果有十面水,就会多出十份材质内存,而且美术在 Inspector 上做的调色会全部失效。用 PropertyBlock 只是覆盖这一份 Renderer 上的参数,材质本身的默认值仍然保留。

第二,参数全部加了Range特性,Inspector 面板上会直接出现滑动条,策划和美术不需要看懂 Shader 就能调出水波效果。注意参数名要和 Shader 里的Properties块名称严格一致,拼错一个字母不会报错,但属性会一直使用默认值,肉眼很难发现。

3.3 触水碰撞与互动事件的挂接

控制脚本只解决“水面动起来”的问题,游戏逻辑里通常还需要知道“角色有没有碰到水”。最常见的做法是给水面物体的 Collider2D 挂一个专门的监听脚本:

private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { // 通知角色进入水面:减速、改变移动方式、播放入水音效 other.GetComponent<PlayerMovement>().EnterWater(); } } private void OnTriggerExit2D(Collider2D other) { if (other.CompareTag("Player")) { other.GetComponent<PlayerMovement>().ExitWater(); } }

这个脚本本身很简单,但有一个容易忽略的边界:如果角色快速下坠穿过水面,一帧之内可能同时触发 Enter 和 Exit,导致逻辑漏掉入水状态。我一般会在 EnterWater 里记一个进入时间戳,Exit 时判断与进入时间的间隔,小于 0.1 秒就忽略这次退出,避免高速物体“穿过”水面造成的状态抖动。

4. 正交相机适配:为什么波浪看不见,以及 UV 方案补救

4.1 现象:横版项目里波浪几乎不存在

很多 Unity2d 项目用的是正交相机,把 2dwater 资源包拖进场景后,最常翻车的现象是:透视相机下明明有起伏的波浪,一切到正交相机就变成一条静止的线,不管把_WaveAmp拉到多大都没有明显改善。我第一次遇到时一度怀疑是 Shader 没编译成功,在 Inspector 里把所有参数拖了一遍才确认代码没问题。

4.2 原因:正交投影对顶点位移不敏感

问题出在投影方式上。透视相机的特点是近大远小,靠近镜头的顶点位移会被放大,所以顶点波浪在透视视角下非常明显。正交相机没有这个放大机制,画面里所有物体的大小不随距离变化,顶点在 Y 轴上的那点位移,在侧视角下叠加上整个水面网格,看起来就像一条完全没有起伏的线。

更麻烦的是,片段着色器里的折射扰动依赖法线方向变化。法线扰动产生的屏幕 UV 偏移量,在透视投影下会按深度产生视差效果;正交投影下视差为 0,同样的_RefrStrength值,折射偏移在屏幕上几乎看不出变化。这就是为什么“参数没变效果却没了”的直接原因——不是 Shader 失效,而是投影矩阵改变了扰动量的视觉放大比例。

4.3 适配方案与参数区间

针对正交相机,我一般会把重心从顶点波浪转移到 UV 扰动上,让“波纹感”来自贴图滚动而不是顶点位移。具体参数要按下面的区间重新标定:

参数透视相机正交相机调整原因
_WaveAmp0.005 ~ 0.020.001 ~ 0.003振幅再大也看不出,反而会穿帮
_WaveFreq1.0 ~ 3.01.0 ~ 2.0频率太高正交视角下变成抖动
_RefrStrength0.02 ~ 0.10.05 ~ 0.2需要更大的偏移量补偿视差缺失
_NoiseTex滚动速度0.01 ~ 0.050.05 ~ 0.1加快滚动弥补顶点动感不足

按这个表调整后,水面在正交相机下会呈现一种“流动的波纹质感”,虽然没有透视视角下那种明显的立体起伏,但动态感完全够用。如果你的美术坚持要波浪形状,我见过的一个做法是直接用 Sprites 序列帧替换 Shader 里的主纹理,把水波做成逐帧动画,Shader 只负责颜色混合和折射。缺点是内存占用增大,但 2D 项目里水面范围通常不大,一张 512x512 的序列帧纹理完全可控。

5. 避坑实录:透明排序、碰撞同步与移动端闪屏

5.1 GrabPass 掉帧:每帧抓屏的代价

现象:水面范围铺大之后,帧率从 60 掉到 40,尤其是在水面占屏幕一半以上的场景里。

原因:GrabPass每帧做一次全屏纹理拷贝,分辨率越高拷贝耗时越大。这个开销在 PC 上不明显,但在移动端是肉眼可见的掉帧来源。

解决:如果项目只在一两个关卡用到水面,保留GrabPass没问题;如果水面是全局永久资源,我建议把抓屏次数压到最低。常见做法是把GrabPass替换为RenderTexture,只渲染背景相机到纹理,每帧只更新一次;或者直接关掉折射,用噪声贴图的扰动伪造一个背景混合效果。后者在正交相机下画质损失很小,性能提升却最直接。

5.2 碰撞与视觉不同步:子弹打在水面上方

现象:子弹在水面视觉上方飞过时已经触发入水判定,玩家看起来子弹没有接触水,却暴起水花。

原因:水面 Collider 是平面,视觉波浪在 Y 轴上的起伏只是渲染效果,碰撞检测用的还是那个平面。加上正交相机下视觉误差会被放大,碰撞范围看起来总比视觉大。

解决:不要追求 Collider 和视觉完全同步,那不现实。把 Collider 的 Y 轴高度往下压 0.2 到 0.5 个单位,让判定点略低于视觉水面。玩家主观感受是“接触到水面才触发”,比“提前触发”自然得多。如果一定要高精度碰撞,给水面做一个细分网格的 Collider 并逐帧更新顶点,性能开销很大,2D 项目一般不划算。

5.3 透明物体渲染顺序闪屏

现象:角色站在水面后面时,角色模型时而被水面遮住,时而显示在水面之上,画面闪烁。

原因:水面材质队列是 Transparent,角色和其它透明物体之间的渲染顺序没有显式排序。Unity 对透明物体的排序依赖材质队列和 Sorting Layer,如果两者的设置不一致,就可能出现这一帧水面先画、下一帧角色先画的抖动。

解决:第一,水面材质标签里必须保留"Queue"="Transparent";第二,在 Inspector 上把水面的 Sorting Order 设为负值(比如 -1),让角色(默认 0)永远排在前面。这个设置看起来不起眼,但能根治闪屏。注意 Sorting Order 只对 SpriteRenderer 的物体生效,如果你的角色是 MeshRenderer,需要在渲染管线上另行处理。

5.4 部分安卓机型水面黑屏

现象:同一套 Shader,在 PC 上正常,打包到某台安卓机上,水面区域直接变成黑色或完全看不见。

原因:旧款安卓机的 GPU 对UnpackNormal的支持不一致。UnpackNormal内部按压缩格式法线贴图的编码方式解码,如果贴图压缩成 DXT 格式后解码路径异常,采样出来全是噪声,颜色就崩了。另外一部分设备对GrabPass的支持也有限,抓屏采样返回空值。

解决:第一,把扰动贴图格式强制设为 RGBA 32 bit,不做贴图压缩;第二,在 Shader 末尾加一个Fallback "Unlit/Color",遇到不支持复杂特性的设备时自动退化成纯色水面。虽然难看,但至少不会黑屏。这是我拿真机跑出来的血泪经验,模拟器上看不到这个问题。

6. 噪波贴图驱动风格化:让波光更自然的两种验证手法

到了这一步,水面基本功能已经能跑,剩下的是让它更像“水”。用纯正弦波做的水面,波峰和波谷的分布过于均匀,一眼就能看出规律。真正的自然水面,波光位置是随机的,明暗变化是不规则的。

我一般会引入一张 RGBA 噪波贴图,把四个通道拆成不同的信息分别驱动 Shader 的不同效果:

通道用途建议数值
R波光强度扰动高斯噪波,明暗对比强
G波长偏移低频噪波,变化缓慢
B泡沫遮罩只在岸边或漩涡区域有值
A透明度扰动用于浅水边缘渐隐

实现上,核心思路不是用噪波直接做颜色,而是把噪波值作为阈值,超过阈值的区域才显示波光亮点,类似真实水面碎光的分布:

// 波光阈值:噪波值超过阈值的区域才显示亮斑 float waveGlint = smoothstep(_GlintThreshold, _GlintThreshold + 0.1, noise.r); col.rgb += waveGlint * _GlintColor * _GlintStrength;

_GlintThreshold是关键:太低的话整面水都在闪,太高的话几乎没有亮斑。我的习惯是取 0.55 到 0.75 之间,在运行时用 Debug 面板滑动这个值,看到水面出现自然分布的碎光点就停。验证还有一个笨办法:把相机固定在水面上方 45 度角,观察波光是否在 30 秒内没有明显的循环规律,如果有规律地重复,说明噪波的频率过低或滚动速度太一致,两张噪声叠着滚动能破掉这个规律。

从那以后我每次拿到水体资源,第一件事不是调颜色,而是先测两件事:把相机切到正交模式看效果,再打包到一台低端安卓机上看帧率。两关都过了才继续调参。这个习惯帮我避掉了大半的水面资源翻车现场,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表