
简介这是一份面向 Cocos Creator 开发者的水波纹特效项目源码包适合需要在 2D/3D 场景中快速实现水面、雨滴、魔法涟漪等效果的初中级开发者。资源围绕自定义 Shader 方案展开包含顶点着色器与片元着色器实现、材质挂载流程以及脚本控制 uniforms 参数时间、速度、波纹比例的完整示例可直接对照集成到现有工程中。压缩包共 3 个文件涵盖 HTML 预览页面和 .inscode 在线项目文件便于先查看效果再移植代码整体仅 7KB轻量易用。已有 83 人浏览学习对于想理解纹理坐标扭曲与动态波纹原理的读者来说是一份简洁且有参考价值的代码范例。通过查看源码目录与关键文件开发者可复现水波纹生成逻辑并进一步调整参数或优化 Shader 性能用于自己的游戏或交互动效项目。1. 水波纹效果不只是特效一个 Cocos Creator 源码工程能带来的实际价值水波纹效果在 Cocos Creator 游戏里出现的频率比想象中高关卡选择、按钮反馈、道具掉落、Boss 出场都常用一圈扩散波纹制造“这里刚刚发生了事情”的感知。不少人用序列帧动画对付但帧多、包体大、换节奏就要重做而 Shader 方案只用一个 effect 文件加百来行脚本运行时能随时调振幅、波长和扩散速度。这份项目源码的核心就是这样一个水波纹效果基于片元着色器 UV 重映射让一张普通纹理以任意点为中心向外扩散波形。适合正在用 Cocos Creator 3.x 做 UI 或 2D 玩法的开发者新手能跟着改参数熟手可以直接把组件抽到自己工程里复用。2. 波形原理先讲透UV 变换、正弦叠加与三个关键参数的取值范围2.1 UV 坐标就是画布波纹本质上是重映射采样点Cocos Creator 的 2D 精灵最终渲染时GPU 靠纹理采样拿颜色。纹理上的每个像素对应一组 UV 坐标范围是 0 到 1。常规渲染流程是给什么 UV 就从纹理上取什么颜色画面自然不变。水波纹的做法是打破这个对应关系采样前先按规则把 UV 偏移掉再去取色。偏移规则不同波纹形态就不同但底层思路完全一样——不是纹理在动而是采样位置在动。一段不依赖引擎的核心片段是这样的vec2 delta uv - center; float dist length(delta); vec2 offset normalize(delta) * sin(dist * k) * magnitude; vec3 color texture2D(tex, uv offset).rgb;center是波纹中心dist是当前像素到中心的距离sin(dist * k)让偏移量随距离呈周期性变化magnitude控制整体偏移强度。如果每帧让k包含时间项波纹就开始扩散了。这套逻辑推演下来你会得到一个重要结论水波纹效果对纹理本身没有特殊要求任何一张 Sprite 图片都能被改造成波动的样子这才是它比序列帧更省资源的原因。但这个方案存在两个天然边界。第一偏移幅度不能太大UV 是有周期的坐标空间偏移一旦超过相邻周期的长度图像会出现拉伸和镜像视觉表现就是“糊成一团”。第二波纹中心的normalize(delta)在距离趋近零时方向向量不稳定需要加除零保护。这两个问题几乎会在每个工程里出现第 4 章避坑部分会专门展开。2.2 三种波型同心圆波、方向波与径向散波游戏里常说的“水波纹”其实包含至少三种形态选型错了效果会很怪。同心圆波是以一点为圆心向外扩散的环适合点击反馈、道具出现这类需要“聚焦中心”的场景方向波是整个画面沿着某个方向流动如水面缓流、传送带效果径向散波则是从中心向外发散的叶片状带旋转感适合技能释放、剑气蔓延。它们的数学形式有清晰区别。同心圆波计算的是 UV 与中心点的距离float dist length(uv - center); vec2 offset normalize(uv - center) * sin(dist * k - time * speed) * magnitude;方向波计算的是 UV 沿着指定方向投影的标量float prog dot(uv, direction); vec2 offset direction * sin(prog * k - time * speed) * magnitude;径向散波多一步角度计算把直线传播改成绕中心旋转vec2 delta uv - center; float angle atan(delta.y, delta.x) time * speed; float dist length(delta); vec2 offset vec2(cos(angle), sin(angle)) * sin(dist * k - phase) * magnitude;三种波型在效果上的差异可以用下面这张表概括波型计算依赖典型手感常见应用点同心圆波距离 方向柔软、扩散感明显按钮点击、任务完成提示方向波投影距离整体流动、单调水面背景、滚动公告径向散波角度 距离尖锐、带有旋转技能冲击、武器挥砍选型时我会先问一个问题这个波纹是要把玩家视线“拽”到某个点上还是让一整块区域“活”起来。前者选同心圆波后者选方向波。径向散波最炫但也最容易打扰阅读用在 UI 上要克制。2.3 参数边界振幅、波长、波速与游戏手感先给出一组我从多个工程里总结出的起点参数避免一上来就凭感觉乱调magnitude: 0.01 ~ 0.05 wavelength: 60.0 ~ 120.0 speed: 1.0 ~ 3.0magnitude是 UV 偏移幅度。0.03 在 1080p 渲染目标上大约产生 20 个像素的视觉偏移直观感受是“轻微揉了一下”。超过 0.05 就会让画面内容出现明显扭曲长时间看会晕。如果波纹要叠到文字上我一般压到 0.015 以下宁可让玩家刚好看出来也不抢内容主体。wavelength是波峰到波峰在 UV 空间的距离。60 左右波纹变密适合水面、涟漪这类需要“碎”的质感120 以上波纹变宽看起来像慢镜头里的雾气。Cocos 的 UI 节点宽高不固定统一按纹理的 UV 归一化坐标算所以这个参数不受节点实际像素尺寸影响。speed是相位推进速度。1.0 每秒只走一个波长视觉上很温和3.0 就会有明显“抖”的感觉一般只在需要急促反馈时使用。它和时间系数直接乘在相位项里改起来最直觉但也最容易和衰减时间搅在一起后面避坑章节会提到这个坑。调参还有一个判断习惯如果波纹看起来像水面很自然说明当前参数是对的如果看起来像玻璃折射说明振幅偏大如果像果冻晃动说明波速太慢且波形太长。这个主观判断比任何参数表都准。3. 把源码跑起来直接放进工程的水波纹 effect 与触发脚本3.1 水波纹 effect 文件可直接复制的 Cocos Creator 3.x 版本源码包里最核心的就是这个 effect 文件用 Cocos Creator 3.x 的 CCProgram 语法编写。新建一个.effect文件把下面内容整段粘进去它就是一个可以挂到材质上的完整 ShaderCCEffect %{ techniques: - name: default passes: - vert: water-ripple-vs frag: water-ripple-fs properties: mainTexture: { value: white } magnitude: { value: 0.03, editor: { displayName: 振幅 } } wavelength: { value: 90.0, editor: { displayName: 波长 } } speed: { value: 1.5, editor: { displayName: 波速 } } center: { value: [0.5, 0.5], editor: { displayName: 波纹中心 } } time: { value: 0.0, editor: { displayName: 时间 } } }% CCProgram water-ripple-vs %{ precision highp float; #include cc-global in vec3 a_position; in vec2 a_uv0; out vec2 v_uv; void main() { v_uv a_uv0; gl_Position cc_matViewProj * vec4(a_position, 1.0); } }% CCProgram water-ripple-fs %{ precision highp float; in vec2 v_uv; uniform sampler2D mainTexture; uniform WaterRipple { float magnitude; float wavelength; float speed; vec2 center; float time; }; out vec4 fragColor; void main() { vec2 delta v_uv - center; float dist length(delta); float phase dist * (6.2831853 / wavelength) - time * speed; float wave sin(phase) * magnitude; float fade 1.0 - smoothstep(0.0, 0.8, dist); vec2 dir dist 0.0001 ? delta / dist : vec2(0.0, 0.0); vec2 targetUv v_uv dir * wave * fade; fragColor texture2D(mainTexture, targetUv); } }%顶点着色器做的是常规的坐标变换和 UV 透传没有特殊逻辑。重点在片元着色器uniform WaterRipple这个块对应 properties 里声明的五个参数Cocos 会根据 properties 配置为它们生成 Inspector 面板也允许代码通过setProperty动态写入。fade这段是很多新手容易漏掉的衰减项。它用smoothstep把 0 到 0.8 距离范围内的波纹强度逐渐降到零否则波纹会一直冲到画布边缘视觉上像整张图被反复拉扯。dir的写法比直接normalize(delta)多了一步距离判断避免中心点附近出现除零导致的黑线或异常拉伸。采样时把偏移后的targetUv传给texture2D取到的颜色就是被波纹扭曲后的效果。注意这里的mainTexture会默认使用材质上设置的贴图如果你在 Sprite 上用自定义材质Cocos 会自动把 SpriteFrame 传进来不需要手动指定。3.2 点击屏幕任意位置触发一次波纹Effect 文件本身只负责“怎么扭曲”触发逻辑要单独用脚本控制。源码包里附带了一个控制组件核心思路是监听触摸事件把点击位置转换到节点本地坐标归一化后写入材质参数同时重置时间并推进它。import { _decorator, Component, Material, Vec2, UITransform, input, Input, EventTouch } from cc; const { ccclass, property } _decorator; ccclass(WaterRippleCtrl) export class WaterRippleCtrl extends Component { property({ type: Material }) public waterMat: Material null; property public duration 1.2; private _elapsed 0; private _active false; onEnable() { input.on(Input.EventType.TOUCH_START, this.onTouch, this); } onDisable() { input.off(Input.EventType.TOUCH_START, this.onTouch, this); } onTouch(e: EventTouch) { const ui this.getComponent(UITransform); const local ui.convertToNodeSpaceAR(e.getUILocation()); const w ui.width; const h ui.height; const cx (local.x w / 2) / w; const cy (local.y h / 2) / h; this.waterMat.setProperty(center, new Vec2(cx, cy)); this.waterMat.setProperty(time, 0); this._active true; this._elapsed 0; } update(dt: number) { if (!this._active) return; this._elapsed dt; if (this._elapsed this.duration) { this._active false; this.waterMat.setProperty(time, this.duration); return; } const intensity 1.0 - this._elapsed / this.duration; this.waterMat.setProperty(time, this._elapsed * intensity); } }convertToNodeSpaceAR把屏幕坐标转成节点本地坐标接着用节点宽高归一化到 0 到 1正好对上 effect 里center的坐标空间。注意这里算的cx和cy是相对节点锚点中心偏移后归一化的结果如果你的节点锚点不在中心这个公式要相应调整。time的推进方式是最关键的一处设计它不是线性累加而是乘了一个intensity衰减系数。这样波纹在触发瞬间最强随后被压回零画面最终恢复到没有波纹的状态。如果你直接把time设为_elapsed波纹会变成一个永远在振动的波源永远停不下来。3.3 绑定材质与组件从零到出效果的六步操作从新建场景到看到涟漪扩散完整路径是这样的在资源管理器里新建一个 Material把Effect属性选为刚才创建的 water-ripple effect然后双击打开它确认属性面板出现了振幅、波长、波速等参数。在场景里创建一个 Sprite 节点拖入任意一张要展示的图片作为 SpriteFrame。把材质拖到 Sprite 组件的CustomMaterial槽位上注意不是SharedMaterial。这一步决定了只有这个节点会被波纹影响。创建一个空节点挂上WaterRippleCtrl脚本把刚才的材质赋给脚本的waterMat属性。把 Sprite 的节点大小设置成 200 乘 200 左右然后进入预览。此时点击任意位置应该能看到一个以点击点为圆心向外扩散的波纹。如果效果太弱或太强调节材质面板里的振幅magnitude0.03 偏中等0.01 几乎看不见0.08 会明显拉扯图片。有一个细节需要留意脚本里通过setProperty改的是材质实例参数。如果你在场景里多个节点共享同一个材质所有节点会被一起影响。想只影响单个节点在代码里用material属性复制一份const mat sprite.material; sprite.setMaterial(mat, 0)。这个坑在正式项目里很常见尤其是美术把同一个材质拖给了多个按钮。4. 水波纹避坑手册五个真机与编辑器不一致的典型问题4.1 波纹扩散方向是反向的波峰变波谷现象点击中心后图片没有向外鼓起反而向内凹陷看起来像被吸进一个洞。如果用同一个 effect 在编辑器里正常到某个平台就反了那基本可以确定是 UV 坐标系差异。原因Cocos Creator 的 UI 渲染管线在部分平台上的 v 轴方向和纹理采样坐标系相反。编辑器预览和真机跑的是两套渲染后端对 UV 的处理不一致导致同样的center换算结果不同。解决在片元着色器里对 UV 做一次显式归一化统一以左下角为原点。常见做法是采样前加一行保护逻辑vec2 uv vec2(v_uv.x, 1.0 - v_uv.y)。如果你发现反了把它换成v_uv.y即可。这个翻转只影响视觉效果不会改动节点实际位置属于低成本修复。4.2 波纹中心出现黑色十字或放射性裂纹现象波纹扩散的一瞬间中心点位置出现明显的放射状拉扯有时伴随一条黑线像玻璃被砸了个裂纹。原因normalize(delta)在 delta 的模长接近零时会返回一个不稳定甚至无穷大的方向向量UV 偏移被放大到不可控画面就会撕裂。这是纯数学边界不是平台差异。解决给距离加一个保护阈值写成三目运算vec2 dir dist 0.0001 ? delta / dist : vec2(0.0, 0.0);。另一种常见做法是给 delta 加一个很小的常数vec2 dir delta / (dist 0.0001);。两种都能消除中心撕裂前者更干净后者会有轻微的“软中心”效果看你想要哪种手感。4.3 编辑器正常但打包 APK 后波纹变成马赛克现象在编辑器里波纹是平滑的打包成 Android APK 安装到手机上之后波纹边缘出现明显锯齿颜色像打了马赛克。原因APK 包里的纹理默认会被压缩常见格式是 ETC2 或 ASTC这类格式对高频信息不友好。波纹做的是亚像素偏移压缩后相邻像素颜色丢失原来平滑的渐变就变成了色块。另一个常见情况是纹理过滤模式被设成了 Point采样时不做插值。解决在资源管理器里选中纹理把FilterMode改成Bilinear或Trilinear。如果你的波纹要作用于大尺寸背景图关掉UseMipmap避免 mip 层级切换导致波纹出现边界跳变。纹理压缩格式可以暂时保留但要把GenerateMipmaps关掉否则波纹在缩小时会闪。4.4 快速连续点击后波纹频率越来越快现象玩家快速连点按钮波纹没有按预期从第一次点击自然淡出反而叠加在一起看起来像频率越来越快的抖动。原因每次点击都会把time重置回 0而time又是统一累加的。多个波纹同时存在时后面的点击把前面的相位覆盖了相位跳变造成抖动感。这个问题的根源是组件把“波纹强度”和“时间推进”混在了一起。解决把时间衰减改成强度钳制。我的做法是维护一个waveStrength变量每次点击时置为 1.0在update里让它随时间递减并钳制在 0 到 1 之间传给 Shader 的time则保持单调递增。这样新点击只是把强度拉回满值不会影响已经产生的波面视觉上连续点击也只会产生干净的叠加。4.5 波纹挂在父节点上时子节点的文字跟着扭曲现象把波纹材质挂在 Button 或布局容器上之后里面所有子节点尤其是 Label 文字都被波纹拉扯得看不清。原因材质作用在整个渲染节点上Cocos 的 2D 渲染是按节点树来提交渲染指令的父节点的 Shader 效果会影响所有携带纹理的子节点。解决不要让波纹材质直接挂在容器节点上。常见做法是单独创建一个全屏的 Sprite 作为背景层把波纹材质挂给它文字和按钮放在上层节点。如果你必须让文字和水波一起动把文字单独做成纹理烘焙到背景图里或者用 Mask 组件把波纹显示区域约束在一个范围内让文字层在 Mask 之外。我在正式项目里都是把水波隔离在纯背景层绝不碰内容层。5. 打包与性能微信小游戏、APK 真机的兼容性处理5.1 片元计算量与全屏波纹的取舍水波纹的扭曲计算全部发生在片元着色器里也就是每一个像素都要执行距离、三角函数、除法这些操作。全屏 1080p 意味着超过两百万个片元在低端 Android 手机上这足以让帧率掉到 30 以下。这是水波纹选型时最需要权衡的点。一个常见做法是让波纹只作用在局部区域而不是全屏背景。比如把波纹挂在一个 256 乘 256 的小节点上片元数量只有全屏的几十分之一性能压力可以忽略。另一个做法是降低渲染分辨率创建一个低分辨率的 RenderTexture把波纹画在上面再拉伸显示视觉差异不大但片元计算量能减少到四分之一。如果你确实需要全屏波纹我会把衰减范围从 0.8 收缩到 0.5让波纹集中在中心附近忽略边缘像素的计算。smoothstep虽然是平滑过渡的标准写法但它内部包含三次多项式计算成本比clamp高。在低端机上可以换成fade 1.0 - clamp(dist / 0.5, 0.0, 1.0);性能提升不多但聊胜于无。5.2 draw call 与材质实例少几个批次就是少几毫秒Cocos Creator 的 2D 渲染按材质实例提交批次每个不同的材质实例都意味着一个额外的 draw call。水波纹材质如果被赋值到多个节点而每个节点又由代码单独setProperty动态改参数场景里出现五次波纹就意味着五个批次。要减少批次最简单的方式是让所有水波纹节点共享同一个材质实例参数只由中心节点控制其他节点只做位置偏移。如果必须多个波纹同时出现且参数不同我一般会写一个静态缓存表按参数组合复用材质实例而不是每次点击都new Material()。微信小游戏对 draw call 尤其敏感少三五个批次低端机的性能表现会明显提升。5.3 与粒子、MotionStreak 等效果的分工水波纹不是万能的它擅长扭曲现有画面不擅长生成新内容。如果你要表现的一滴墨在水里散开、一道划痕带过用粒子系统或 MotionStreak 拖尾组件更合适。MotionStreak 和波纹的典型差异在于波纹做的是一次性的动态扭曲MotionStreak 做的是持续轨迹的残影两者的受众效果完全不同。我在实际项目里常用 MotionStreak 做滑动轨迹用水波纹做触摸落点反馈两者互不干扰。粒子系统虽然也能模拟涟漪但用几十个粒子加透明渐变图也可以不过面对尺寸变化频繁的 UI 时粒子要做大量跟随计算而 Shader 波纹只要改一个center参数就够了。我的经验是静态反馈用 Shader动态轨迹用 MotionStreak大量随机碎片用粒子三种效果各管一摊不要在波纹里硬塞粒子逻辑。打包 APK 时还需要注意 Shader 的精度声明。微信小游戏中部分低端机对highp在片元着色器的支持不稳定如果真机出现波纹闪烁或者颜色断层尝试把precision highp float改成precision mediump float观察差异。这个玄学问题不属于逻辑错误但实测中确实是 Android 平台最常见的兼容性来源之一。6. 进阶把正弦波改成衰减脉冲让波纹手感更像真实的物理水波前面用的sin(phase)会产生一个永不停歇的波动虽然time * intensity能在脚本侧把强度压下来但波形的衰减仍然不是物理的。真实的石头入水涟漪幅度会随半径增大而衰减并且波纹末端平滑回到零振幅。我最后会把片元着色器里的波函数替换成带半径衰减的脉冲形式float wave sin(phase) * magnitude * exp(-3.0 * dist / wavelength);exp(-3.0 * dist / wavelength)表示振幅随着距离呈指数衰减距离越远波纹越弱。相比在脚本里统一乘一个衰减系数这种方式让波纹在空间上自然展开中心最强边缘逐渐消失视觉上更接近真实水波。如果你觉得指数衰减太猛可以用pow(1.0 - dist, 2.0)替代效果更温和。调参上我有一个血泪教训最开始做波纹效果时我直接把time当波纹强度用线性累加推进结果波纹永远亮着玩家一直觉得有个圆圈在闪。后来我发现time只负责相位传播强度必须单独控制。从那以后我每次做水波纹效果都会强制走一遍自查第一time是否单调递增而不是随点击重置第二UV 是否做了平台一致性翻转第三中心点是否有除零保护。这三个点只要漏一个真机上大概率翻车。另一个值得养成的习惯是每次调完magnitude和wavelength都在打包预览里跑一次真机而不是只看编辑器预览。编辑器里的颜色空间和后处理管线跟真机有差异波纹这种对像素级偏移敏感的效果最容易在最后一步暴露问题。希望这份源码里的 effect 和脚本加上上面这些踩坑记录能帮你少走这段弯路。本文还有配套的精品资源点击获取