☰
Cocos Creator 水波纹效果:Shader 扰动与交互涟漪实现
2026/10/14 5:10:56 网站建设 项目流程

简介:这份资源是面向Cocos Creator开发者的一套水波纹特效项目源码,适合具备一定Shader与脚本基础、希望在游戏中实现动态水面、魔法或雨滴等视觉效果的开发者参考。压缩包共3个文件,以inscode工程配置、html页面与gitignore为主,整体约7KB,体量轻巧,便于直接导入项目对照研究。核心内容围绕自定义Shader展开,涵盖顶点着色器与片元着色器如何协作、通过扭曲纹理坐标模拟波纹,以及创建材质、将Shader应用到Sprite或Mesh Renderer组件的完整流程。配套脚本演示了如何实时设置时间、波纹速度与比例等uniforms参数,驱动波纹动态变化,并涉及测试调试与性能优化思路。目前已有85人学习,读者可借此掌握从Shader编写到脚本控制、再到效果集成的完整链路,理解图形学中纹理映射与着色器编程的实践方法,为后续开发更复杂的视觉特效打下基础。

1. 水波纹效果在 Cocos Creator 里到底难在哪

很多做 2D 游戏或互动展示的开发者,第一次想在 Cocos Creator 里加个水波纹效果,直觉都是“找张水纹贴图,做个帧动画或者 UV 滚动就完事”。真上手才发现,静态贴图滚动出来的波纹是死的——没有折射、没有随点击扩散的涟漪、没有和背景内容的交互,放在水面、雨夜地面、魔法护盾这些场景里一眼假。水波纹效果的核心诉求其实是“扰动”:让底下的画面跟着波形发生位移,产生液体折射的错觉。Cocos Creator 从 2.x 到 3.x 都支持自定义材质和 Shader,配合一张法线贴图或者噪声图,就能在 2D 精灵上做出实时扰动。这篇笔记面向的是已经会建场景、挂组件,但没怎么碰过自定义 Effect 的 Cocos Creator 开发者,我会把从原理到可复现的 Shader 写法、参数调节、以及几个必踩的坑讲清楚,代码可以直接抄进你的项目里改。

2. 水波纹的两种实现路线:帧动画还是 Shader 扰动

2.1 先想清楚你要的是“看起来像水”还是“真的在扰动画面”

在动手写代码之前,选型决定了后面 80% 的工作量。常见做法有两类:一类是序列帧或 UV 滚动,成本低、性能好,但只能表现水面自身的纹理流动,无法让水下的物体产生扭曲;另一类是 Shader 扰动,用一张噪声图或法线图去偏移采样坐标,让精灵底下的内容跟着波形动起来,视觉上才有“透过水看东西”的感觉。如果你的场景是池塘、雨地积水、屏幕被水淹没的特效,选第二类;如果只是装饰性的水纹边框,第一类就够了。我一般会先问自己一句:这个水波纹需不需要影响它背后的画面?需要,就上 Shader。

2.2 用自定义 Effect 做扰动的最小结构

Cocos Creator 3.x 里自定义材质依赖.effect文件,它本质是一段带 YAML 头的 GLSL。一个最小的水波纹 Effect 需要三个部分:顶点着色器负责把精灵的顶点坐标传出去,片元着色器负责根据时间、噪声图和振幅去偏移 UV,最后用偏移后的 UV 去采样精灵本身的纹理。下面是一个可以直接用的骨架,我把它拆成带注释的代码块,你新建一个waterRipple.effect贴进去即可。

// waterRipple.effect CCEffect %{ techniques: - passes: - vert: vs frag: fs blendState: targets: - blend: true rasterizerState: cullMode: none properties: texture: { value: white } // 精灵自身纹理 noiseTex: { value: white } // 噪声/法线图,驱动波形 rippleStrength: { value: 0.02 } // 扰动强度,0.01~0.05 比较自然 rippleSpeed: { value: 1.0 } // 波纹流动速度 rippleScale: { value: 4.0 } // 噪声采样密度,越大波纹越密 }% CCProgram vs %{ precision highp float; #include <cc-global> in vec3 a_position; in vec2 a_texCoord; out vec2 v_uv; vec4 vert() { vec4 pos = cc_matViewProj * vec4(a_position, 1.0); v_uv = a_texCoord; return pos; } }% CCProgram fs %{ precision highp float; #include <cc-global> in vec2 v_uv; uniform sampler2D texture; uniform sampler2D noiseTex; uniform Ripple { float rippleStrength; float rippleSpeed; float rippleScale; }; vec4 frag() { // 用时间驱动噪声图滚动,得到动态波形 vec2 noiseUV = v_uv * rippleScale + vec2(cc_time.x * rippleSpeed, 0.0); vec4 noise = texture(noiseTex, noiseUV); // 把噪声的 R/G 通道映射到 [-1,1],作为 UV 偏移量 vec2 offset = (noise.rg - 0.5) * 2.0 * rippleStrength; vec4 color = texture(texture, v_uv + offset); return color; } }%

这段代码的逻辑很直白:cc_time.x是引擎全局时间,乘上rippleSpeed让噪声图横向滚动,rippleScale控制采样密度,rippleStrength决定偏移幅度。噪声图建议用一张无缝的 Perlin 噪声或水面法线图,R/G 通道分别代表 X/Y 方向的梯度。参数上,rippleStrength超过 0.05 画面会明显撕裂,低于 0.005 又几乎看不出效果,0.02 是多数场景的甜点值。rippleScale在 2 到 8 之间调,数值越大波纹越细碎,适合小水面;数值小则波纹舒缓,适合大场景。

2.3 把 Effect 挂到精灵上并暴露参数

Effect 写好后,在 Cocos Creator 资源管理器里右键新建材质,把waterRipple.effect拖进材质的 Effect 栏,然后把材质赋给精灵的Sprite组件所在节点的cc.Sprite材质槽。注意 3.x 里精灵的材质是通过CustomMaterial覆盖的,不是直接改 Sprite 组件。挂上之后,在属性检查器里就能看到rippleStrength、rippleSpeed、rippleScale三个参数,运行时可以用脚本动态改:

// WaterRippleCtrl.ts import { _decorator, Component, Sprite, Material } from 'cc'; const { ccclass, property } = _decorator; @ccclass('WaterRippleCtrl') export class WaterRippleCtrl extends Component { @property(Sprite) sprite: Sprite = null!; private _mat: Material = null!; start() { // 拿到精灵实例化后的材质,避免改到共享材质影响其他节点 this._mat = this.sprite.getMaterialInstance(0)!; } update(dt: number) { if (!this._mat) return; // 让扰动强度随时间轻微呼吸,水面更活 const t = performance.now() / 1000; this._mat.setProperty('rippleStrength', 0.02 + Math.sin(t) * 0.005); } }

这里的关键是getMaterialInstance,它返回的是该节点独有的材质副本。如果你直接用sprite.getMaterial(0)去setProperty,改的是共享材质,场景里所有用同一材质的精灵会一起变,这个坑我在早期项目里踩过,排查了半天才发现是材质共享导致的“玄学联动”。

3. 让水波纹跟着点击扩散:从静态扰动到交互涟漪

3.1 涟漪扩散的数学:用距离场驱动相位

静态扰动只是让画面“晃”,真正的涟漪需要从某个点向外扩散。做法是在片元着色器里传入一个点击点center,计算当前像素到该点的距离dist,然后用sin(dist * frequency - time * speed)生成环形波。这个公式是水波模拟里最常用的距离场相位法,成本低、效果直观。下面在原有 Effect 基础上加一个涟漪通道,把点击点作为 uniform 传进去。

// 在 fs 的 uniform 块里追加 uniform RippleCenter { vec2 center; // 点击点,UV 空间 0~1 float radius; // 当前扩散半径 float ringWidth; // 环宽度 }; // frag 里在原有 offset 基础上叠加涟漪偏移 vec2 toCenter = v_uv - center; float dist = length(toCenter); // 环形波:只在 radius 附近有值,向外衰减 float ring = smoothstep(radius - ringWidth, radius, dist) * (1.0 - smoothstep(radius, radius + ringWidth, dist)); vec2 rippleOffset = normalize(toCenter) * ring * 0.03; vec4 color = texture(texture, v_uv + offset + rippleOffset);

radius由脚本每帧递增,模拟扩散;ringWidth控制环的粗细,一般取 0.05 到 0.1。smoothstep两次相减得到一个平滑的环带,避免硬边。这个方案的好处是不需要额外的 RenderTexture,纯片元计算,移动端也能跑。

3.2 脚本侧管理点击与半径生命周期

Shader 只负责画,扩散逻辑得靠脚本驱动。下面这段代码监听触摸事件,把屏幕坐标转成 UV,然后启动一个半径从 0 到 1 的动画,结束后复位。

// RippleSpawner.ts import { _decorator, Component, Node, EventTouch, UITransform, Vec3, Material } from 'cc'; const { ccclass, property } = _decorator; @ccclass('RippleSpawner') export class RippleSpawner extends Component { @property(Node) target: Node = null!; // 挂水波纹材质的节点 private _mat: Material = null!; private _radius = 0; private _active = false; start() { this._mat = this.target.getComponent(UITransform)!.getComponent('cc.Sprite')['getMaterialInstance'](0); this.node.on(Node.EventType.TOUCH_START, this.onTouch, this); } onTouch(e: EventTouch) { const ui = this.target.getComponent(UITransform)!; const local = ui.convertToNodeSpaceAR(new Vec3(e.getUILocation().x, e.getUILocation().y, 0)); // 转成 0~1 的 UV,注意 Y 轴翻转 const uvX = (local.x / ui.width) + 0.5; const uvY = 0.5 - (local.y / ui.height); this._mat.setProperty('center', { x: uvX, y: uvY }); this._radius = 0; this._active = true; } update(dt: number) { if (!this._active) return; this._radius += dt * 0.8; // 扩散速度 this._mat.setProperty('radius', this._radius); if (this._radius > 1.2) { this._active = false; this._mat.setProperty('radius', -1); // 移出屏幕,隐藏环 } } }

坐标转换是这里最容易翻车的地方。Cocos Creator 的 UI 坐标原点在节点中心,而 UV 原点在左下角,Y 轴方向相反,所以uvY要用0.5 - local.y / height。如果点下去涟漪出现在反方向,八成是这里没翻。另外radius超出 1 之后要把环移走,否则会在边缘留下一个静止的亮环。

3.3 多涟漪叠加的取舍

单个涟漪用一组 uniform 就够了,但如果你想要雨滴落在水面的密集效果,就得同时存在多个环。常见做法有两种:一是用数组 uniform 传最多 8 个涟漪中心,片元里循环累加;二是用一张 RenderTexture 做涟漪图,每帧把新涟漪画上去再衰减。前者实现简单但循环有开销,后者灵活但要多一个相机和 RT。我的经验是移动端优先用数组方案,限制 4 个环,桌面端可以上 RT。数组方案在 GLSL 里写成uniform vec2 centers[4]; uniform float radii[4];,循环里对每个环算一次ring再累加偏移,注意循环次数要写成常量,否则部分机型编译不过。

4. 避坑与排查:水波纹效果最常见的 5 个翻车点

4.1 波纹不动,画面像被冻住

现象是材质挂上了,参数也调了,但精灵纹丝不动。原因通常是 Effect 里没有引用cc_time,或者引用了但没在CCEffect的 properties 里声明。Cocos Creator 3.x 的cc_time是内置全局变量,但如果你在片元里用了它却没在#include <cc-global>里引入,编译会静默失败,材质回退到默认。解决方法是确认 fs 顶部有#include <cc-global>,并且cc_time.x拼写正确。另一个可能是rippleSpeed设成了 0,检查属性面板。

4.2 波纹只在编辑器里动,真机不动

这个坑很典型。编辑器预览用的是 WebGL,真机可能是 GLES 或 Metal,精度声明不一致会导致cc_time在某些设备上被优化掉。解决方法是把precision highp float;显式写在 fs 开头,并且避免在cc_time参与的计算里用mediump。另外,如果用了performance.now()在脚本里算时间,真机上这个值可能和引擎时间不同步,建议统一用cc_time或director.getTotalTime()。

4.3 扰动方向反了或者画面整体偏移

现象是水波纹让画面往一个固定方向偏,而不是来回晃。原因是噪声图的 R/G 通道没有做-0.5的中心化,或者噪声图本身不是无缝的。Perlin 噪声的取值范围是 0 到 1,直接当偏移量用会让 UV 整体往正方向偏。必须(noise.rg - 0.5) * 2.0映射到 -1 到 1。另外噪声图要在 Photoshop 或 Substance 里做成无缝平铺,否则滚动到边缘会出现接缝,看起来像画面被撕开。

4.4 涟漪环出现硬边或锯齿

ring的计算如果只用step或者smoothstep只做一次,环的内外边缘会是硬的,放大看有锯齿。正确做法是两次smoothstep相减,形成平滑的钟形。如果还有锯齿,把ringWidth调大一点,或者开启材质的抗锯齿。移动端如果性能吃紧,可以在环的外侧加一个1.0 - dist的衰减,让远处自然淡出,减少高频细节。

4.5 多个精灵共用材质导致参数串台

前面提过,getMaterial返回共享材质,getMaterialInstance返回实例。如果你在场景里放了三个水面精灵,都用同一个材质,然后脚本里对其中一个setProperty,另外两个也会跟着变。解决方法是每个精灵在start里调getMaterialInstance(0)拿到自己的副本,或者干脆给每个精灵建独立材质。这个坑的隐蔽性在于编辑器里看不出来,只有运行时才暴露,血泪经验是:凡是运行时改参数的材质,一律用实例。

5. 进阶:用噪声图叠加和屏幕后处理做全屏水淹

5.1 两层噪声叠加让波纹更自然

单层噪声的波纹规律性太强,看起来像机械波。我一般会叠两层:一层低频大波浪,一层高频细纹,两层用不同的速度和方向滚动,最后按权重混合。代码上就是把offset算两次再相加:

vec2 uv1 = v_uv * rippleScale + vec2(cc_time.x * rippleSpeed, 0.0); vec2 uv2 = v_uv * rippleScale * 2.3 + vec2(0.0, cc_time.x * rippleSpeed * 1.7); vec2 n1 = (texture(noiseTex, uv1).rg - 0.5) * 2.0; vec2 n2 = (texture(noiseTex, uv2).rg - 0.5) * 2.0; vec2 offset = (n1 * 0.7 + n2 * 0.3) * rippleStrength;

两层噪声的采样密度比建议是 1:2.3 左右,避免出现明显的重复图案。速度上第二层比第一层快 1.5 到 2 倍,方向错开 90 度,出来的水面就有层次感。这个技巧在雨夜地面、湖面、甚至魔法护盾的扰动上都通用。

5.2 全屏水淹:把水波纹做成屏幕后处理

如果你想让整个游戏画面像被水淹没一样扭曲,就不能只挂在单个精灵上了,得用相机后处理。Cocos Creator 3.x 支持自定义 RenderPipeline 或者用Camera的postProcess材质。做法是新建一个全屏 Quad,把相机的 RenderTexture 作为输入,在片元里对这张 RT 做 UV 扰动,再输出到屏幕。核心代码和精灵版几乎一样,只是texture换成了相机 RT,v_uv换成了全屏 UV。注意全屏后处理的性能开销比单精灵大一个量级,移动端要控制分辨率,或者只在特定剧情触发时开启。

5.3 参数速查与调优习惯

下面这张表是我调水波纹时最常改的几个参数和它们的经验区间,你可以直接照着试:

参数作用推荐区间过大后果过小后果
rippleStrengthUV 偏移幅度0.01~0.05画面撕裂看不出效果
rippleSpeed噪声滚动速度0.5~2.0波纹闪烁像静止
rippleScale噪声采样密度2.0~8.0细碎噪点波纹过大
ringWidth涟漪环宽度0.05~0.1环太粗像光晕环太细有锯齿
radius 增速涟漪扩散速度0.6~1.2一闪而过慢到像静止

调参的习惯是:先把rippleStrength设到 0.05 让效果夸张,确认波形方向对了,再往回调到自然值。rippleScale和rippleSpeed一起调,密度高的波纹配快速度,密度低的配慢速度,否则会不协调。最后记得在目标机型上跑一遍,编辑器里流畅不代表真机流畅,尤其是全屏后处理方案。

我自己的习惯是每做一个水波纹效果,都会在场景里放一个纯色背景加一个带文字的精灵,先看文字有没有被扭曲、扭曲方向对不对,再换到实际美术资源上。这个“文字测试法”帮我省了很多来回改 Shader 的时间。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询