波浪进度条由两部分组成:
WaveProgressFill.effect:顶点与片元 shader,负责绘制动态液面;WaveProgressComponent.ts:加载材质、更新进度,并持续把时间传给 shader。
它不依赖节点裁切或序列帧,而是直接在 Sprite 的 UV 坐标中计算液面,因此可以适配不同形状和尺寸的进度图。
核心思路
普通的从底部填充进度条,可以理解为:
glsl
fillY <= progress
其中 progress 范围为 0 到 1。
为了让水平液面变成波浪,只要给进度值叠加一个随横坐标和时间变化的正弦函数:
glsl
float wave = sin(uv0.x * waveFreq + time * waveSpeed) * waveAmp;
float fillLine = clamp(progress + wave, 0.0, 1.0);
参数含义:
uv0.x:当前像素的横向 UV 坐标;waveFreq:波浪频率,越大波峰越密;time:累计时间;waveSpeed:波浪移动速度;waveAmp:波浪上下摆动幅度;progress:当前进度;fillLine:当前横向位置上的液面高度。
项目中使用:
glsl
fillY = 1.0 - uv0.y;
这样 Sprite 底部为 0、顶部为 1,更符合"从下向上填充"的进度语义。
Effect 参数设计
Shader 中定义了这些可调参数:
| 参数 | 默认值 | 作用 |
|---|---|---|
progress |
0.0 | 填充进度,范围 0~1 |
time |
0.0 | 波浪动画时间 |
waveAmp |
0.02 | 波浪振幅 |
waveFreq |
21.0 | 波浪频率 |
waveSpeed |
5.0 | 波浪滚动速度 |
edgeSoftness |
0.005 | 液面边缘柔和程度 |
crestWidth |
0.028 | 液面高光带宽度 |
crestStrength |
0.18 | 高光强度 |
vertexWaveAmp |
3.0 | 顶点起伏幅度 |
这些属性写在 Cocos Creator Effect 的 properties 中,因此可以直接在材质面板调节。
使用 smoothstep 制作柔和液面
如果直接判断:
glsl
fillY < fillLine
液面边缘会很生硬,容易产生锯齿。
因此使用 smoothstep 生成平滑遮罩:
glsl
float fillMask =
(1.0 - smoothstep(
fillLine - edgeSoftness,
fillLine + edgeSoftness,
fillY
)) * fillStart;
结果是:
- 液面下方:
fillMask接近 1; - 液面上方:
fillMask接近 0; - 液面附近:透明度平滑过渡。
最终把遮罩乘到纹理 alpha 上:
glsl
vec4 outputColor = vec4(
texColor.rgb,
texColor.a * fillMask
);
这样即使 Sprite 本身带有透明区域或异形轮廓,也能保持原始形状。
给液面增加高光
仅有波浪边缘时,视觉上会比较平。可以在液面附近增加一条高光带:
glsl
float crestMask =
smoothstep(fillLine - crestWidth, fillLine, fillY) * fillMask;
vec3 crestColor =
texColor.rgb + vec3(crestStrength * crestMask);
vec4 outputColor = vec4(
mix(texColor.rgb, crestColor, crestMask),
texColor.a * fillMask
);
这种做法不是替换纹理颜色,而是在原颜色基础上轻微增亮,因此不会破坏原有美术资源。
顶点阶段的轻微起伏
片元 shader 已经能完成波浪裁切。不过项目中还在顶点 shader 中,对液面附近的顶点做了轻微偏移:
glsl
float surfaceBand =
smoothstep(
fillLine - crestWidth * 2.0,
fillLine - edgeSoftness,
fillY
) * fillMask;
float vertexOffset =
sin(uv0.x * waveFreq + time * waveSpeed)
* vertexWaveAmp
* surfaceBand;
pos.y += vertexOffset;
这里的关键是 surfaceBand:它把位移限制在液面附近,避免整个填充区域一起上下抖动。
需要注意,顶点位移依赖 Sprite 网格的顶点数量。普通四边形 Sprite 的顶点较少,因此主要效果仍然由片元阶段的透明度遮罩提供;顶点位移只是额外增强液面的动态感。
TypeScript 组件如何驱动 Shader
组件挂在带有 Sprite 的节点上,负责加载材质并设置参数。
加载材质后,不应直接共用材质资源,而是创建独立副本:
ts
const clonedMaterial = new Material();
clonedMaterial.copy(material);
this.sprite.customMaterial = clonedMaterial;
这样每个进度条都能拥有独立的 progress 和 time,不会出现多个进度条同步变化的问题。
更新进度时:
ts
public setProgress(progress: number): void {
this.currentProgress = Math.max(0, Math.min(1, progress));
this.waveMaterial.setProperty('progress', this.currentProgress);
}
每帧更新波浪时间:
ts
protected update(): void {
if (!this.waveMaterial) {
return;
}
const time = director.getTotalTime() * 0.001 - this.waveStartTime;
this.waveMaterial.setProperty('time', Math.max(0, time));
}
使用时只需要:
ts
const wave = node.getComponent(WaveProgressComponent)
|| node.addComponent(WaveProgressComponent);
wave.setup({
bundleName: GameDef.base_common,
materialPath: 'material/WaveProgressFill',
progress: 0.35,
});
wave.setProgress(0.72);
业务层只负责传入 0~1 的进度值,波浪的持续运动完全交给 shader。
常见问题
进度方向反了
重点检查:
glsl
fillY = 1.0 - uv0.y;
如果你的 UV 坐标已经是"底部为 0",则可能应直接使用:
glsl
fillY = uv0.y;
另外,RenderTexture 采样时也要注意 UV 翻转。
多个进度条一起动
通常是多个 Sprite 共用了同一个材质实例。解决办法是加载后执行:
ts
const clonedMaterial = new Material();
clonedMaterial.copy(material);
每个组件使用独立材质。
进度为 0 时还残留波纹
不要只写硬判断。应通过 fillStart 和 smoothstep 控制零进度边界,避免波浪在空状态下漏出。
波浪不动
检查:
- 材质是否已异步加载完成;
- 节点是否激活,组件的
update()是否执行; time是否持续传入;waveSpeed是否太小。
可扩展方向
当前效果只有一层正弦波。还可以继续扩展:
- 叠加两组不同频率和速度的波浪,获得更自然的液体效果;
- 使用独立颜色制作毒液、魔法能量或海水效果;
- 用 tween 平滑驱动
setProgress(),让数值增长也更自然; - 根据进度动态修改波浪振幅,例如低进度时平稳、满进度时更活跃。
总结
波浪进度条的关键,是把普通的进度线转换为 UV 空间中的动态正弦液面:
glsl
fillLine = progress + sin(x + time) * amplitude;
再通过 smoothstep 制作柔和边缘,用原纹理 alpha 与填充遮罩相乘实现异形裁切,并由组件持续更新 progress 和 time。
这种方案渲染逻辑集中在 shader 内,业务层只维护进度值,适合在 Cocos Creator 中复用到经验条、等级图标、体力条或各类能量填充 UI。