💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
在游戏视觉表现中,UV 扰动是一种常见却强大的渲染技巧,它通过对纹理坐标进行动态采样与扭曲,模拟出水面波动、热浪蒸腾、空间扭曲等实时视觉效果。
无论是战斗技能释放时引发的空间变形,还是环境中流动的液态表面,UV 扰动都能显著增强画面的动态质感与沉浸感,让视觉叙事更富表现力。
水面波动
空间变形
本文将介绍如何在 Cocos Creator 着色器中实现 UV 扰动动画。
一、噪声纹理扰动
我们先来实现了一个基于噪声纹理的 UV 扰动动画,该方案非常适合用于模拟水面波动、热浪扭曲、技能特效等视觉效果。
其实现的核心是 ------ 通过采样一张噪声纹理(见下图),利用其 R 和 G 通道分别控制 UV 在 X 和 Y 方向上的偏移量,再结合时间和强度参数,构造出一个动态的扭曲效果。

留意这张纹理图在 Y 方向上是可平铺的,而且它不是纯黑白色阶的图片(而是带有一点淡蓝色),这意味着其 R 和 G 通道的值会略有不同。
我们先初始化相关参数,采样噪声纹理的 R、G 通道值,乘以强度系数 strength
后叠加到 UV 上进行扰动:
js
CCProgram fs %{
precision highp float;
in vec2 uv;
uniform UBO {
float strength; // 控制扰动强度,默认为 0.02
};
#pragma rate inputTexture pass
uniform sampler2D inputTexture; // 源纹理
uniform sampler2D noiseTexture; // 噪声纹理
vec4 frag () {
// 读取噪声图,后续用 R 通道控制 X 方向扰动,G 通道控制 Y 方向扰动
vec2 noise = texture(noiseTexture, uv).rg;
// 扰动后的 UV
vec2 distortedUV = uv + noise * strength;
// 使用扰动后的 UV 采样源纹理
return texture(inputTexture, distortedUV);
}
}%
💡 本案例使用的后处理管线方案(而非 Render Texture),在摄像头离屏画面上做的扰动处理,因此这里使用的是
inputTexture
来作为源纹理采样器(而非cc_spriteTexture
)。
此时的画面会出现轻微的静态扭曲,但没有任何动态的视觉效果。
这是因为我们在噪声图上采样的 UV 坐标,使用的是源纹理 UV,相当于仅是把噪声纹理拉伸到整个画面,每个画面上的像素,都是被一一对应的、固定的噪声像素干扰。
因此我们需要打破这种「一一对应」的映射关系,其关键就是让噪声纹理采样的 UV 动起来:
js
// 用时间滚动噪声
vec2 noiseUV = fract(uv + vec2(0.0, time));
// 读取噪声图,后续用 R 通道控制 X 方向扰动,G 通道控制 Y 方向扰动
vec2 noise = texture(noiseTexture, noiseUV).rg;
其中 time
是由外部组件脚本传入的时间累加变量(留意后处理管线无法使用 cc_time
),它会动态修改噪声纹理的 V 值,搭配 fract
函数会将 V 值限定在 [0, 1]
的范围内不断变化,视觉表现为「噪声纹理在 Y 轴平铺,且不断地向上运动」。
这样噪声纹理对 UV 的扰动就是动态的了。
另外鉴于 noise
的区间在 [0, 1]
,即 noise
的取值为非负数,会导致对 UV 的扰动实是在单一方向上的。我们可以将它的区间调整到 [-1, 1]
,使得扰动可以是任意方向(正/负):
js
// 映射 [-1,1],使得扰动可以是任意方向(正/负)
noise = noise * 2.0 - 1.0;
此时执行项目,可以获得一个动态的热浪扭动空间的效果:

二、VFX 帧动图扰动
2.1 VFX 介绍
VFX 是 Visual effects(视觉特效)的缩写,该术语用于描述在各类电影、游戏或其他媒体所创建、增强的图像(或动画)。
在游戏领域,角色施展技能时伴随的酷炫光效,便是一种 VFX(一般由 AfterEffect、Spine 等专业工具制作):

本文案例需要使用单张的 VFX 大图(俗称雪碧图,即把所有帧合到一张大图上),读者可以自行在网上搜索 "VFX sheet" 获取相关资源。
笔者在某平台上获取了一张 VFX 雪碧图,其原图体积达到了惊人的 6 MB。通过大幅减少其尺寸和压缩(使用 TinyPNG),最终将其控制在 22 KB:

我们会利用这张雪碧图来实现 VFX 扰动 UV 的动画,来实现战斗过程中挥动的武器扭曲周围空间的类似效果:

2.2 着色器实现
VFX 帧动画的核心实现主要有两个:
- 将序列帧动画的当前帧作为扭曲图,这意味着要想办法拆解雪碧图,并按需抽出当前帧;
- 利用该帧的颜色数据(RGB)来计算 UV 偏移量,从而对原始画面进行动态的扭曲。
2.2.1 获取当前帧
我们逐步来实现相关的功能,首先是初始化参数并获取当前帧的索引:
js
CCProgram fs %{
precision highp float;
in vec2 uv;
uniform UBO {
vec2 cells; // 默认为 [6.0, 3.0],表示雪碧图有 3 行 6 列,共 18 帧
float fps; // 默认为 30.0,动画帧率
float time; // 后处理管线无法使用内置的 cc_time,需要从外部传入
};
#pragma rate inputTexture pass
uniform sampler2D inputTexture;
uniform sampler2D vfxTexture; // VFX 帧动画雪碧图
vec4 frag () {
float row = cells.x; // 雪碧图行数
float col = cells.y; // 雪碧图列数
float index = floor(time * fps); // 计算当前帧的索引
}
}%
鉴于 time
表示当前游戏执行的累计时间,单位为秒,因此乘以一个「每秒执行多少帧」的 fps
变量,就能获得 VFX 帧动画当前帧的索引 index
。
假设我们的 VFX 动画是要平铺在摄像机离屏画面上的(即无需缩放),那么即算出了帧索引,又知道雪碧图是分为几行几列,就可以算出当前像素在雪碧图上的 UV 坐标是多少:
js
// 计算当前像素在雪碧图上的 UV 坐标
vec2 animOffset = vec2(mod(index, col) / col, floor(index / col) / row);
vec2 animUV = uv / cells.yx + animOffset;
其中 uv / cells.yx
将整个屏幕 UV 映射到雪碧图中的一个单元格内,再通过 vec2(mod(index, col) / col, floor(index / col) / row)
计算当前帧在雪碧图中的偏移量:
mod(index, col)
表示当前帧在第几列;floor(index/col)
表示当前帧在第几行。

此时如果直接返回对雪碧图的采样结果,可以获得当前帧的动画:
js
// 采样当前帧
vec4 vfxColor = texture(vfxTexture, animUV);
return vfxColor;

2.2.2 计算 UV 偏移量
与上一节噪音纹理扭曲 UV 的方案类似,我们依旧可以通过 VFX 纹理的 R、G 通道值,来作为 UV 偏移量的基准:
js
vec2 offset = (vfxColor.rg - 0.5) * 2.0 * strength;
留意 (vfxColor.rg - 0.5) * 2.0
依旧是将通道值的区间从 [0, 1]
映射到 [-1, 1]
,使得扰动可以是任意方向(正/负)。
为了不让偏移量过大,我们额外加上了一个强度系数 strength
(默认值为 0.05
)进行调整。
此时 VFX 动画帧纯黑的像素偏移值为 0
,越亮的的像素偏移值越高,我们利用这个偏移值对源纹理进行采样:
js
vec2 offset = (vfxColor.rg - 0.5) * 2.0 * strength;
return texture(inputTexture, uv + offset);
返回的画面如下:

可以看到 VFX 明亮的光效部分确实扭曲了画面,但扭曲的部分过于厚重,缺乏 VFX 特效的纹理和轻盈感。
我们可以进一步优化偏移值 offset
,让它乘以 VFX 特效的灰度值,来让特效中明亮的地方扭曲的更强烈、暗的地方扭曲的较弱,更符合能量强度的视觉直觉:
js
// 计算灰度值
float gray = vfxColor.r * 0.299 + vfxColor.g * 0.587 + vfxColor.b * 0.114;
vec2 offset = (vfxColor.rg - 0.5) * 2.0 * strength * gray; // 乘以灰度值
另外叠加上 40% 的 VFX 特效色值,可以在扭曲空间的基础上附加发光、能量的视觉效果:
js
return texture(inputTexture, uv + offset) + vfxColor * 0.4;
最终效果:

💡 读者可以在线上演示页查看更多动画细节。