💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
一、液态玻璃效果介绍
苹果在 2025 年 6 月的 WWDC 上首次推出了液态玻璃(Liquid Glass) 效果,让界面元素呈现出仿佛由弯曲、可折射的玻璃构成的质感:

自此,这种同时兼具绚丽与复杂的视觉特效迅速在设计与动效圈走红,成为新一代界面风格的潮流象征。
对于 H5 前端而言,可以借用 SVG 的 <filter> 滤镜中的 <feImage>、<feDisplacementMap>、<feBlend>、<feGaussianBlur>和 <feComposite> 等滤镜基元,对背景色进行色值偏移、柔化和遮罩叠合,从而实现类似的画面扭曲效果。
不久前 Trae Meetup 发布在 Luma 平台上的邀请页,便已应用了这一技术:

本文则会以 Cocos Creator 为技术栈,介绍液态玻璃效果的原理,及其着色器实现。
二、液态玻璃原理
💡 本节部分内容引用自《kube.io - Liquid Glass in the Browser》一文。
2.1 折射
折射是指光从一种介质进入另一种介质(如从空气进入玻璃)时改变传播方向的现象。这种偏折之所以发生,是因为光在不同介质中的传播速度不同。
其中我们需要了解的是,入射光与出射光角度的关系由斯涅尔--笛卡尔定律描述:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> n 1 sin ( θ 1 ) = n 2 sin ( θ 2 ) n_1 \sin(\theta_1) = n_2 \sin(\theta_2) </math>n1sin(θ1)=n2sin(θ2)
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> n 1 n_1 </math>n1 表示介质 1 的折射率;
- <math xmlns="http://www.w3.org/1998/Math/MathML"> n 2 n_2 </math>n2 表示介质 2 的折射率;
- <math xmlns="http://www.w3.org/1998/Math/MathML"> θ 1 \theta_1 </math>θ1 表示入射角(光线与法线的夹角);
- <math xmlns="http://www.w3.org/1998/Math/MathML"> θ 2 \theta_2 </math>θ2 表示折射角(折射光线与法线的夹角)。

其表现为:
- 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> n 1 = n 2 n_1 = n_2 </math>n1=n2 时,光线会直线穿过介质;
- 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> n 1 < n 2 n_1 < n_2 </math>n1<n2 时,光线会穿过介质且往法线(上图 Normal 虚线)方向发生偏折;
- 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> n 1 > n 2 n_1 > n_2 </math>n1>n2 时,存在一个临界角 <math xmlns="http://www.w3.org/1998/Math/MathML"> θ c = arcsin ( n 2 n 1 ) \theta_c = \arcsin\left(\frac{n_2}{n_1}\right) </math>θc=arcsin(n1n2),若 <math xmlns="http://www.w3.org/1998/Math/MathML"> θ 1 > θ c \theta_1 > \theta_c </math>θ1>θc 则光线不会发生折射,而是被完全反射回去;
- 当入射光线与表面正交时,无论折射率如何,它都会(沿着法线方向)直线穿过。
2.2 位移向量场
当光线穿入玻璃介质产生折射后,会在底部产生位移向量(见下图的紫色箭头):

可以看到,光线产生折射越大时,其位移向量就越大;直到靠近玻璃中心位置时,由于光线与玻璃表面正交,不再产生折射,其位移向量长度归零。
假设光线射入的是一块正圆形的玻璃凸透镜,则所有射入镜内的光线所产生的位移向量场如下:

留意上图的箭头都为了可见性而进行了归一化处理,进而缩小了比例,因此它们不会重叠。
和法线向量的「归一化」不同,此处的「归一化」并非将每个向量除以自身的长度,而是统一除以向量场内最长向量的长度。这般处理后的向量才能保留彼此之间的长度比例信息。
我们在之前《法线贴图和高度贴图》一文已学习过「如何将归一化向量转化为贴图色值」,今天也将异曲同工地对位移向量做类似的事情。
由于光线折射后的位移向量仅处于玻璃最底部的平面,因此每个向量仅在 X 轴和 Y 轴存在走向,且归一化后的值被限定在 [-1, 1] 区间,因此仅使用 RGBA 中的 R 和 G 两个通道来存储这两个轴向值即可:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> R G = 128 + N o r m a l ∗ 127 RG = 128 + Normal * 127 </math>RG=128+Normal∗127
假设 B 通道值固定为 0,A 通道固定为 255,则每个归一化向量的色值相当于在「零向量色」 RGBA(127, 127, 0, 255) 上增减相应的 R 和 G 值:

那么一个正圆形凸透镜,其归一化位移向量场贴图(displacement map),可以是这样的:

💡 我制作了一个工具页面,可以在页面上轻松生成、下载各种形状的位移向量场贴图:
拥有了信息化贴图,我们便可以通过着色器捕获、逆向这些信息,再进一步做 UV 偏移,来实现光线折射、画面被扭曲的效果。
2.3 色散与轮廓高光
不同颜色的光,在玻璃里传播的速度不同,因此折射程度也不同,折射偏移越大的区域越容易产生「色散」现象,从而呈现出一种色彩分层的视觉效果:

在《滤镜的实现》一文中我们已经实现了一个 glitchEffect 故障效果方法,可以参考此方法,根据位移向量场贴图的 R、G 通道值的大小,来决定不同程度的色值偏移,从而达成玻璃边缘色散的视觉效果。
另外,当光线以特定角度照射时,会在玻璃物体看到一些明亮的边缘反射:

苹果公司对此的实现,是给液态玻璃图标添加一圈简单的轮廓高光,令其质感更贴近玻璃的同时,也更好地让图标与外界做边界区分:

三、着色器实现
3.1 折射的实现
光线的折射在着色器中通常表现为 UV 的偏移(这也是《UV 扰动动画》的实现原理),借助位移向量场贴图,可以高效地获得 UV 的偏移值。
我们先把背景和前置的玻璃物体位移向量场贴图,分别捕获为两个离屏的 Render Texture:

接着新建一个 Sprite 组件节点 Composition,绑定着色器(liquid-glass.effect)来获取这两个 Render Texture(用于后面的合成处理):
js
/** liquid-glass.effect 片元着色器示例 **/
CCProgram fs %{
precision highp float;
in vec2 uv;
uniform sampler2D bgRT; // 背景 RenderTexture
uniform sampler2D displacementMapRT; // 位移向量场贴图 RenderTexture
vec4 frag () {
vec4 bgColor = texture(bgRT, uv);
vec4 dispColor = texture(displacementMapRT, uv);
return bgColor; // 直接返回背景色(暂无任何扭曲处理)
}
}%
在获取到位移向量场贴图的色值后(取值区间 [0.0, 1.0]),我们需要将其「逆向」为原本的归一化向量(取值区间 [-1.0, 1.0]):
js
vec2 offsetNormal = dispColor.xy * 2.0 - 1.0; // RG 映射到 [-1, 1]
由于逆向后的归一化向量的取值为 [-1, 1],对于 UV 而言是一个较大的数值,直接使用 offsetNormal 作为 UV 偏移值会导致非常夸张的背景扭曲,因此需要新增一个 strengthUV 参数(默认值为 0.03)来缩小 UV 偏移值到合理的范畴:
js
uniform UBO {
float strengthUV; // 默认值 0.03
};
vec2 offsetNormal = dispColor.xy * 2.0 - 1.0;
float canvasScale = 1280.0 / 720.0; // 1280x720 是 Canvas、bgRT 和 displacementMapRT 的宽高
vec2 offsetUV = offsetNormal * strengthUV * vec2(1.0, -canvasScale); // UV 需要偏移的量
vec2 baseUV = uv + offsetUV; // 偏移 UV
vec4 bgColor = texture(bgRT, baseUV);
return bgColor;
留意第 8 行给 Y 轴的偏移乘以了画布的宽高比
canvasScale,以此确保 X 轴和 Y 轴的偏移幅度不会产生拉伸(毕竟本案例的画布及其对应的 UV 并非正方形)。同时 Y 轴的偏移还乘以了
-1,确保位移矢量在垂直方位能符合正确的 UV 方位(指向中心)。
此时执行效果如下:

3.2 色散的实现
色散的实现其实比想象的简单,只需要根据 UV 偏移量 offsetUV,进一步左右偏移背景色的 R 和 B 通道即可,同时新增 dispersionStrength 参数(默认值为 0.03)来控制色散偏移的强度:
js
// 色散效果,把 R 和 B 色值分别左右偏移
vec4 rC = texture(bgRT, baseUV + offsetUV * dispersionStrength);
vec4 gC = texture(bgRT, baseUV);
vec4 bC = texture(bgRT, baseUV - offsetUV * dispersionStrength);
return vec4(rC.r, gC.g, bC.b, 1.0);
执行效果如下:

为了提升性能,我们可以仅在需要色散的时候才对 R 和 B 通道进行采样:
js
// 色散效果,把 R 和 B 色值分别左右偏移
vec4 gC = texture(bgRT, baseUV);
vec4 rC = gC;
vec4 bC = gC;
if (dispersionStrength > 0.0) {
// 需要开启色散效果再采样 R B 通道,提升性能
rC = texture(bgRT, baseUV + offsetUV * dispersionStrength);
bC = texture(bgRT, baseUV - offsetUV * dispersionStrength);
}
vec4 dispersionColor = vec4(rC.r, gC.g, bC.b, 1.0);
3.3 轮廓光的实现
实现轮廓光最简单粗暴的方案,是在形状节点之上叠加一个 PNG 图层节点,但本文将更加高效的、直接在着色器内部实现。
由于光线折射产生的位移向量,在越靠近边缘的部分其矢量长度是越大的,那我们可以先通过 length 函数获得位移矢量长度,然后判断其值是否大于指定的阈值,若是则返回纯白色的轮廓光:
js
uniform UBO {
float strengthUV;
float dispersionStrength;
float highlightStrength; // 新增轮廓光强度参数(默认值为 0.1,范围 `[0.0, 0.2]`)
};
// 轮廓光实现
float normalLen = length(offsetNormal); // 获得归一化位移矢量长度,区间为 [0.0, 1.0]
if (normalLen < 1.0 - highlightStrength) {
// 位移向量长度低于指定阈值,无需轮廓光
return dispersionColor;
}
float highlightOpacity = 1.0;
return vec4(1.0, 1.0, 1.0, highlightOpacity);
由于 offsetNormal 是归一化后的向量,其长度 normalLen 注定落在 [0.0, 1.0] 区间中,因此可以设定一个阈值(highlightStrength)来圈定产生轮廓光的区域。
执行上述代码,可以获得一个相较粗糙的实心轮廓光:

受限于位移向量场贴图精度,计算获得的轮廓光粗细可能不均,且可能存在些许偏移。
然而我们需要的并非一个完整且实心的轮廓光,我们需要的是一个对称的、透明度线性渐变的轮廓光,而这块的处理可以从位移向量场贴图上的色值获取线索:

可以看到位移向量场贴图的 R 或 G 通道值,均是线性渐变的,例如 G 通道的值,会从最顶部的 255 沿着边缘线性递减到最左侧的 128。
因此我们可以用位于左上角的 G 通道值 191 来作为左半球轮廓光的中点(完全不透明度),其左右两侧 G 通道值 ±50 的点为轮廓光的边缘(完全透明度),进而实现一个位于左上角的渐变轮廓光:
js
float highlightOpacity = 0.0;
float leftCenterG = 191.0 / 255.0; // 玻璃物体左侧轮廓光中心对应的 G 值,要先转换到 [0.0, 1.0] 区间,才能与区间为 [0.0, 1.0] 的 dispColor.g 进行对比
float rightCenterG = 1.0 - leftCenterG; // 玻璃物体右侧轮廓光中心对应的 G 值
float spanG = 50.0 / 255.0; // 轮廓光范围对应的 G 值,当 leftCenterG ± spanG 时,轮廓光透明度为 0
if ((offsetNormal.x > 0.0) && (dispColor.g < leftCenterG + spanG) && (dispColor.g > leftCenterG - spanG)) {
// 位移向量在水平方位指向右侧,说明处于玻璃物体左侧
highlightOpacity = clamp(1.0 - abs(dispColor.g - leftCenterG) / spanG, 0.0, 1.0);
}
vec4 highlightColor = vec4(1.0, 1.0, 1.0, 1.0);
return mix(dispersionColor, highlightColor, highlightOpacity);
执行效果如下:

同理,我们可以算出右下角的渐变轮廓光:
js
if ((offsetNormal.x > 0.0) && (dispColor.g < leftCenterG + spanG) && (dispColor.g > leftCenterG - spanG)) {
// 位移向量在水平方位指向右侧,说明处于玻璃物体左侧
highlightOpacity = clamp(1.0 - abs(dispColor.g - leftCenterG) / spanG, 0.0, 1.0);
} else if ((offsetNormal.x < 0.0) && (dispColor.g < rightCenterG + spanG) && (dispColor.g > rightCenterG - spanG)) {
// 位移向量在水平方位指向左侧,说明处于玻璃物体右侧
highlightOpacity = clamp(1.0 - abs(dispColor.g - rightCenterG) / spanG, 0.0, 1.0);
}
执行效果如下:

至此,我们借助位移向量场贴图的能力,在单个 DrawCall 中仅使用了最多 4 次采样,便高效地实现了一个带有扭曲、色散和轮廓光的液态玻璃效果。
该着色器也适用于其它形状,读者可以在线上案例演示页查看完整的动态效果和获取源码:

