Unity 2D水效果实战:顶点网格+着色器+浮力模拟全流程
简介Unity2dWater_2dwater_Unity2d水_ 是一份针对Unity引擎2D游戏开发的水效果资源包主要面向需要快速实现水面浮力、波浪与物体交互的中初级开发者。包内基于BuoyancyEffector2D组件构建包含完整场景、自定义着色器、材质、C#脚本及项目配置文件可帮助使用者无需从零编写物理模拟即可还原真实水感。资源共37个文件其中以asset、meta、shader、cs、mat等类型为主压缩包仅38KB结构轻量、便于直接导入现有项目。该资源已有1154人学习下载适合希望掌握2D浮力参数调节、波浪范围控制及水体交互逻辑的开发者参考。通过调节浮力系数、接触检测权重与波动幅度可以让角色或物体自然漂浮、沉没并产生动态波纹有效提升2D场景的沉浸体验。 做 2D 游戏做到中后期我几乎每次都会跟“水”过不去。上个月在做一个横板冒险项目时为了在关卡里放一条贯穿场景的河最开始直接拖了一张水面贴图进去远处看还行角色一踩上去画面直接出戏既没有起伏也没有反馈活像一块贴在地上的反光膜。趁着周末我把 Unity 2D 水从零到可用完整做了一遍——顶点网格负责水面形变着色器负责透明、颜色和泡沫再加上浮力脚本和粒子系统让角色真正“入水”。这篇文章就记录这次完整的实现过程以及我在实测中踩到的坑和取舍思路给正被 2D 水难住的朋友一份可以照着抄的作业。1. 动手之前先想清楚2D 水需要做到什么程度1.1 三种主流实现的取舍做任何效果之前别急着写代码先想清楚你要的是哪种水。同样是“水面”背景装饰、可玩水域、像素风关卡对方案的要求完全不一样。我把常见的需求整理成一张表你可以自己对号入座需求场景推荐方案复杂度性能压力纯背景、不参与玩法水面着色器UV扰动 简单波浪低极低角色/物件可入水、需要反馈网格顶点动画 物理交互中一般像素风、复古风水面多帧贴图交替播放低最低俯视角大面积水域多层噪声贴图平铺叠加中低低我之前那个项目属于第二类角色会过河、会潜水后期还要加“站在浮板上”的机关所以绝不能只做一张会动的贴图。但如果你的需求只是场景氛围我建议直接选第一类省下来的时间去做玩法不香吗。1.2 我为什么选“顶点动画 着色器”组合我最终选的是“网格顶点动画 着色器上色”的混合方案核心原因有两个。第一交互需要“形”的反馈。物体落水水面要有凹陷物体浮起来水面要有起伏。这些反馈必须通过改变网格顶点的实际位置来实现单纯给贴图做 UV 扰动骗不过眼睛。第二美观需要“色”的分层。水面的蓝色、透明度、岸边泡沫、阳光高光这些如果用代码硬画会很麻烦丢给着色器反而又灵活又高效。逻辑上我把它拆成两层C# 脚本负责生成网格、更新顶点提供一个GetHeightAt(x)接口给物理系统用着色器只负责根据 UV、世界坐标和噪声决定像素颜色。这样“形”和“色”互不干扰后面加泡沫、加倒影、加折射都不需要动主逻辑。2. 水面为什么能“动”网格、波形与透明排序2.1 把水面做成一条可形变的网格2D 水面其实就是一个扁平的长方形面片只是顶部边缘不是直线而是一串可以上下浮动的顶点。换句话说我们要手动生成一个网格顶部一排顶点沿 X 轴均匀分布底部一排顶点固定不动中间用三角形填满。顶点组织方式并不复杂。假设分段数是segments那么顶部就有segments 1个顶点底部同样有segments 1个顶点。我习惯把底部顶点存在数组的后半段这样生成三角形索引时思路更清晰。每帧更新时我只修改顶部那排顶点的 Y 坐标底部顶点保持不变。这样水面看起来是一条在上下波动的带子而不会整体跟着漂。实际操作中还要注意一点三角形的绕序要一致否则会出现“面片正面朝下”的情况从默认相机方向看过去就是一片黑。2.2 波形参数正弦波叠加 噪声微调水下波动用什么算法网上方案很多我的经验是2D 游戏完全不需要上复杂的流体模拟正弦波叠加就够用性价比最高。单个正弦波长这样y A * sin(x * frequency time * speed)太规整一眼假。但把两三个不同频率、不同振幅的正弦波叠加之后波形就会自然很多y baseY A1 * sin(x * f1 time * s1) A2 * sin(x * f2 time * s2)最后再叠加一个柏林噪声做微调用来打散正弦波叠加后可能残留的规律感。这里有个细节柏林噪声的输入参数不一样效果完全不同。我通常把x * noiseScale作为第一个参数把time * 0.6作为第二个参数这样波形既沿 X 轴有变化又会随时间推移缓缓流动不会出现整个水面横向平移的机械感。2.3 透明排序为什么水体要单独占一个层级在 2D 项目里水面通常是半透明的。只要涉及透明就绕不开渲染排序。Unity 2D 的排序主要由 Sorting Layer 和 Order in Layer 控制但水面这种东西比较特殊它既要在背景前面又要在角色半身前、半身后还经常要跟 Tilemap 交叉。我的建议是给水体单独建一个 Sorting Layer比如叫Water放在Background之后、Character之前。这样角色入水后角色下半身能被水面盖住上半身露在外面视觉上才像“站在水里”。如果你有水下场景可以把水下背景单独放在Background层让水面变成一个半透明的“遮罩层”看起来更有层次。3. 实操从空场景到一套完整水系统3.1 基础水面网格生成与顶点更新下面直接放核心脚本。这是一个继承 MonoBehaviour 的水体组件挂在空物体上会自动创建网格并驱动顶点波动。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class Water2D : MonoBehaviour { [Header(网格参数)] public float width 20f; public float height 4f; public int segments 60; [Header(波浪参数)] public float baseY 0f; public float amplitude 0.25f; public float frequency 1.2f; public float speed 2f; public float noiseStrength 0.08f; public float noiseScale 2.5f; private Mesh mesh; private Vector3[] vertices; private Vector3[] baseVertices; void Start() { CreateMesh(); mesh.MarkDynamic(); } void Update() { UpdateWater(); } void CreateMesh() { int vertexCount (segments 1) * 2; vertices new Vector3[vertexCount]; baseVertices new Vector3[vertexCount]; Vector2[] uvs new Vector2[vertexCount]; int[] triangles new int[segments * 6]; float step width / segments; for (int i 0; i segments; i) { float x -width * 0.5f step * i; // 顶部顶点 baseVertices[i] new Vector3(x, baseY, 0f); // 底部顶点 baseVertices[segments 1 i] new Vector3(x, baseY - height, 0f); uvs[i] new Vector2((float)i / segments, 0f); uvs[segments 1 i] new Vector2((float)i / segments, 1f); } int idx 0; for (int i 0; i segments; i) { int tl i; int tr i 1; int bl segments 1 i; int br segments 1 i 1; triangles[idx] tl; triangles[idx] bl; triangles[idx] tr; triangles[idx] tr; triangles[idx] bl; triangles[idx] br; } mesh new Mesh(); mesh.name Water2D Mesh; mesh.SetVertices(baseVertices); mesh.SetUVs(0, uvs); mesh.triangles triangles; GetComponentMeshFilter().mesh mesh; } void UpdateWater() { if (mesh null) return; for (int i 0; i segments; i) { float x baseVertices[i].x; float y GetHeightAt(x); vertices[i] new Vector3(x, y, 0f); vertices[segments 1 i] baseVertices[segments 1 i]; } mesh.SetVertices(vertices); mesh.RecalculateBounds(); } public float GetHeightAt(float x) { float y baseY; y amplitude * Mathf.Sin(x * frequency Time.time * speed); y amplitude * 0.4f * Mathf.Sin(x * frequency * 2.3f Time.time * speed * 1.7f); y (Mathf.PerlinNoise(x * noiseScale, Time.time * 0.6f) - 0.5f) * 2f * noiseStrength; return y; } }拿到项目里直接挂上就能看到一条会动的带子。有几个点你可能会踩坑一是segments别贪多一个宽 20 单位的场景60 个分段已经足够平滑调到 200 以上视觉效果没什么提升性能却白白消耗二是UpdateWater里我特意去调用GetHeightAt让物理交互和渲染用同一套波形逻辑否则水面画出来是一个样、物体浮起来又是另一个样非常割裂。3.2 给水体加一层着色器网格动起来之后默认的 Diffuse 材质没法看需要自定义一个 Shader。我用的是内置渲染管线下的 ShaderLab核心功能是半透明、顶部泡沫、UV 高度渐变色。实际项目中你可以根据风格继续扩展高光、倒影扭曲等效果。Shader Custom/Water2D Surface { Properties { _WaterColor (Water Color, Color) (0.1, 0.45, 0.6, 0.65) _FoamColor (Foam Color, Color) (1, 1, 1, 0.9) _FoamOffset (Foam Offset, Range(0, 0.3)) 0.12 } SubShader { Tags { QueueTransparent RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float worldY : TEXCOORD1; }; fixed4 _WaterColor; fixed4 _FoamColor; float _FoamOffset; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; o.worldY mul(unity_ObjectToWorld, v.vertex).y; return o; } fixed4 frag(v2f i) : SV_Target { // uv.y 越接近 0 越靠近水面顶部泡沫越明显 float foamMask smoothstep(0.0, _FoamOffset, i.uv.y); // 加一点噪声抖动让泡沫边缘不整齐 float ripple sin(i.worldY * 8.0 i.uv.x * 20.0) * 0.25 0.75; foamMask * ripple; fixed3 color lerp(_WaterColor.rgb, _FoamColor.rgb, foamMask); fixed alpha lerp(_WaterColor.a, _FoamColor.a, foamMask); return fixed4(color, alpha); } ENDCG } } }注意我用了ZWrite Off因为水面是半透明物体如果写了深度水后面的角色和背景会被遮挡掉画面会变成一块不透明白板。Cull Off则是为了避免绕序问题导致的面片翻转后看不见。3.3 让物体真正“入水”浮力、阻力与水花网格和颜色都做好了观众已经能认可“这是一条河”。但角色走进去没有任何反应还是很出戏。所以下一步给物体加浮力。Unity 的 2D 物理本身没有“水”的概念我们要手动模拟。简化思路是检查物体的 Y 位置与水面高度的关系如果物体下半部分没过水面就施加向上的力并且力的大小与浸入深度成正比。using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class Buoyancy2D : MonoBehaviour { public Water2D water; public float buoyancyFactor 12f; public float dragInWater 2f; private Rigidbody2D rb; private bool inWater; void Start() { rb GetComponentRigidbody2D(); } void FixedUpdate() { float surfaceY water.GetHeightAt(transform.position.x); float bottomY transform.position.y - 0.5f; // 粗略取物体中心偏下 float diff surfaceY - bottomY; if (diff 0f) { inWater true; Vector2 force Vector2.up * diff * buoyancyFactor * rb.mass; rb.AddForce(force, ForceMode2D.Force); // 水的阻力 rb.linearDamping rb.linearDamping 0.1f ? rb.linearDamping : dragInWater; } else { inWater false; } } void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Player) rb.linearVelocity.magnitude 4f) { // 入水水花可以在这里生成粒子 } } }水花部分我用的是 Particle System预置一个下落的粒子簇在物体触水瞬间生成到接触点。关键参数是粒子的初始速度、生命周期和颜色建议颜色跟随水体颜色透明度开到 0.7 左右就行。实际测试中发现水花粒子不要用太多十几个碎片就足够传递“溅起来”的信息太多反而显得脏。3.4 水体高度查询给玩法留接口在上面脚本里我多次用到了GetHeightAt(x)。这个接口是整个系统的灵魂。它让物理、敌人 AI、摄像机逻辑都能随时知道当前某个 X 坐标处的水位高度而不依赖渲染顶点的具体状态。比如你要做“角色溺水”判断角色 Y 是否低于水面高度持续 3 秒掉血。又比如你要做“木筏漂流”木筏位置总是被水面托住。直接调用water.GetHeightAt(mover.position.x)就能拿到高度不用去解析网格也不用发射线。唯一要注意的是这个方法内部复用了和渲染相同的波形公式如果波形参数改了两边同步更新。每次调用会计算两次正弦和一次柏林噪声性能完全没问题哪怕一帧调用几百次也扛得住。4. 实战排查清单与性能优化记录4.1 常见的“假水”问题排查整个系统跑起来之后真正费时间的其实是查问题和调参数。我把这次踩过的坑整理成一张排查表遇到问题可以直接对号入座。现象可能原因解决办法水面是黑色/暗色三角形绕序反了面片背面朝前调整索引生成顺序或 Shader 里加 Cull Off角色走到水里被完全遮挡渲染排序不对检查 Sorting Layer角色层必须在水面层之上水面整体横向平移不像原地起伏正弦波参数里漏了x * frequency只用了time确认每个波形都包含基于 X 的项浮力一直往上弹停不下来浮力系数过大且没有阻尼降低buoyancyFactor并给 Rigidbody2D 设置 Angular/Drag泡沫边缘太整齐像贴纸泡沫 mask 没有加噪声抖动在 Shader 里叠加一个 sin 或噪声项打散边缘水花粒子死板全部一个方向粒子初始速度固定没有随机范围给 Particle System 的 Start Speed、Force 加随机值4.2 性能优化顶点数、动态网格与物理频率2D 水体最容易出现性能问题的地方不在着色器而在 C# 每帧改网格顶点。第一个优化点是网格缓冲。new Mesh只调用一次之后每帧只调用mesh.SetVertices并且在Start里调用mesh.MarkDynamic()告诉渲染器“这个网格会频繁变动”让 Unity 使用更合适的 GPU 缓冲区分配策略。亲测在移动端能减少明显的卡顿。第二个优化点是 Update 频率。如果水面上没有任何物体在交互可以隔一帧再更新波形或者干脆在OnBecameInvisible时停止更新脚本屏幕外别做无用功。这招对长关卡特别有效远处那段河看起来和近处一样流畅但完全不消耗性能。第三个优化点是物理频率。浮力计算一定要放FixedUpdate而不是Update。因为 Rigidbody2D 的物理模拟发生在固定时间步长如果你在 Update 里AddForce力会在渲染帧率波动时变得不稳定导致物体在水中一抖一抖的。实测放错地方后浮萍一样的小物件会出现肉眼可见的颤抖。4.3 URP/SRP 项目的适配思路如果你的项目用的是 URP前面那段 ShaderLab 不能直接用需要换成 Shader Graph 或 URP 下的 HLSL。其实思路完全一致顶点阶段用正弦波重新计算顶点 Y片元阶段混合水体颜色与泡沫。用 Shader Graph 操作更直观核心节点就三类Position节点的Y通道加上正弦结果Tint控制水体颜色Smoothstep生成泡沫遮罩。这里有个 URP 特有的坑URP 的透明物体默认不支持自定义排序水面如果和大量半透明特效混在一起可能会出现穿插闪烁。我的方案是所有特效统一放到TransparentFX队列在 URP 里关掉深度写入再手动调 Sorting Order 来控制先后关系。4.4 别在 Update 里做的三件事浮力计算放进 FixedUpdate 只是其一还有两个我犯过的错值得单独说。第一不要在Update里直接改transform.position来模拟浮力。这样会绕过物理系统导致碰撞检测失灵、物体穿模。正确的做法永远是AddForce让物理引擎自己去算。第二不要每帧生成新 Mesh。有些人图省事在 Update 里new Mesh()然后赋值给 MeshFilter帧率直接掉一半不止。重复创建网格会导致大量 GC 分配即使次数不多在移动端也会造成持续的内存抖动。第三不要在主线程里频繁读取 Mesh 顶点数据。mesh.vertices的 getter 会触发 CPU 到 GPU 的同步拷贝开销比 setter 大得多。要拿高度信息直接用GetHeightAt(x)计算绝对别反过来从 Mesh 里读。做完这套水系统我最大的感受是2D 水效果的价值不在于“像不像真实的水”而在于“玩家能不能感知到水的存在”。顶点动画提供起伏着色器提供颜色和泡沫物理反馈提供沉浸感——这三层缺一不可但每一层都不需要做到极致。如果让我重新做一次我会先把GetHeightAt接口写好把浮力、水花这些交互做到位再去打磨倒影和折射。毕竟对玩家来说一滩能跳进去、能溅起水花、能托住木筏的水比一张精致的贴图更重要。本文还有配套的精品资源点击获取