Cocos Creator Shader 入门 ⒇ —— 液态玻璃效果

💡 本系列文章收录于个人专栏 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 次采样,便高效地实现了一个带有扭曲、色散和轮廓光的液态玻璃效果。

该着色器也适用于其它形状,读者可以在线上案例演示页查看完整的动态效果和获取源码:

相关推荐
一名普通的程序员7 分钟前
Design Tokens的设计与使用详解:构建高效设计系统的核心技术
前端
suke9 分钟前
听说前端又死了?
前端·人工智能·程序员
肠胃炎14 分钟前
Flutter 线性组件详解
前端·flutter
肠胃炎17 分钟前
Flutter 布局组件详解
前端·flutter
Jing_Rainbow29 分钟前
【AI-5 全栈-1 /Lesson9(2025-10-29)】构建一个现代前端 AI 图标生成器:从零到完整实现 (含 AIGC 与后端工程详解)🧠
前端·后端
阿明Drift36 分钟前
用 RAG 搭建一个 AI 小说问答系统
前端·人工智能
1***s63242 分钟前
React区块链开发
前端·react.js·区块链
wordbaby42 分钟前
赋值即响应:深入剖析 Riverpod 的“核心引擎”
前端·flutter
南山安42 分钟前
HTML5 自定义属性 data-*:别再把数据塞进 class 里了!
前端·javascript·代码规范