【Shader 教程】液态玻璃原理及其高性能实现

:bulb: 本教程为 Cocos Shader 入门系列文章的第 20 篇,全系列教程和源码可在 Cocos 商城中获取。

一、液态玻璃效果介绍

苹果在 2025 年 6 月的 WWDC 上首次推出了液态玻璃(Liquid Glass) 效果,让界面元素呈现出仿佛由弯曲、可折射的玻璃构成的质感:

1

自此,这种同时兼具绚丽与复杂的视觉特效迅速在设计与动效圈走红,成为新一代界面风格的潮流象征。

对于 WEB 前端而言,可以借用 SVG 的 <filter> 滤镜中的 <feImage><feDisplacementMap><feBlend><feGaussianBlur><feComposite> 等滤镜基元,对背景色进行色值偏移、柔化和遮罩叠合,从而实现类似的画面扭曲效果。

不久前 Trae Meetup 发布在 Luma 平台上的邀请页,便已应用了这一技术:

2

本文则会以 Cocos Creator 为技术栈,介绍液态玻璃效果的原理,及其着色器的高性能实现。

二、液态玻璃原理

:bulb: 本节部分内容引用自《kube.io - Liquid Glass in the Browser》一文。

2.1 折射

折射是指光从一种介质进入另一种介质(如从空气进入玻璃)时改变传播方向的现象。这种偏折之所以发生,是因为光在不同介质中的传播速度不同。

其中我们需要了解的是,入射光与出射光角度的关系由斯涅尔–笛卡尔定律描述:

n_1 \sin(\theta_1) = n_2 \sin(\theta_2)

其中:

  • n_1 表示介质 1 的折射率;
  • n_2 表示介质 2 的折射率;
  • \theta_1 表示入射角(光线与法线的夹角);
  • \theta_2 表示折射角(折射光线与法线的夹角)。

其表现为:

  • n_1 = n_2 时,光线会直线穿过介质;
  • n_1 < n_2 时,光线会穿过介质且往法线(上图 Normal 虚线)方向发生偏折;
  • n_1 > n_2 时,存在一个临界角 \theta_c = \arcsin\left(\frac{n_2}{n_1}\right) ,若 \theta_1 > \theta_c 则光线不会发生折射,而是被完全反射回去;
  • 当入射光线与表面正交时,无论折射率如何,它都会(沿着法线方向)直线穿过。

2.2 位移向量场

当光线穿入玻璃介质产生折射后,会在底部产生位移向量(见下图的紫色箭头):

4

可以看到,光线产生折射越大时,其位移向量就越大;直到靠近玻璃中心位置时,由于光线与玻璃表面正交,不再产生折射,其位移向量长度归零。

假设光线射入的是一块正圆形的玻璃凸透镜,则所有射入镜内的光线所产生的位移向量场如下:

留意上图的箭头都为了可见性而进行了归一化处理,进而缩小了比例,因此它们不会重叠。

和法线向量的「归一化」不同,此处的「归一化」并非将每个向量除以自身的长度,而是统一除以向量场内最长向量的长度。这般处理后的向量才能保留彼此之间的长度比例信息。

我们在之前《法线贴图和高度贴图》一文已学习过「如何将归一化向量转化为贴图色值」,今天也将异曲同工地对位移向量做类似的事情。

由于光线折射后的位移向量仅处于玻璃最底部的平面,因此每个向量仅在 X 轴和 Y 轴存在走向,且归一化后的值被限定在 [-1, 1] 区间,因此仅使用 RGBA 中的 R 和 G 两个通道来存储这两个轴向值即可:

RG = 128 + Normal * 127

假设 B 通道值固定为 0,A 通道固定为 255,则每个归一化向量的色值相当于在「零向量色」 RGBA(127, 127, 0, 255) 上增减相应的 R 和 G 值:

6

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

:bulb: 我制作了一个工具页面,可以在页面上轻松生成、下载各种形状的位移向量场贴图:

8

拥有了信息化贴图,我们便可以通过着色器捕获、逆向这些信息,再进一步做 UV 偏移,来实现光线折射、画面被扭曲的效果。

2.3 色散与轮廓高光

不同颜色的光,在玻璃里传播的速度不同,因此折射程度也不同,折射偏移越大的区域越容易产生「色散」现象,从而呈现出一种色彩分层的视觉效果:

在《滤镜的实现》一文中我们已经实现了一个 glitchEffect 故障效果方法,可以参考此方法,根据位移向量场贴图的 R、G 通道值的大小,来决定不同程度的色值偏移,从而达成玻璃边缘色散的视觉效果。

另外,当光线以特定角度照射时,会在玻璃物体看到一些明亮的边缘反射:

10

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

11

三、着色器实现

3.1 折射的实现

光线的折射在着色器中通常表现为 UV 的偏移(这也是《UV 扰动动画》的实现原理),借助位移向量场贴图,可以高效地获得 UV 的偏移值。

我们先把背景和前置的玻璃物体位移向量场贴图,分别捕获为两个离屏的 Render Texture:

接着新建一个 Sprite 组件节点 Composition,绑定着色器(liquid-glass.effect)来获取这两个 Render Texture(用于后面的合成处理):

/** 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]):

vec2 offsetNormal = dispColor.xy * 2.0 - 1.0;  // RG 映射到 [-1, 1]

由于逆向后的归一化向量的取值为 [-1, 1],对于 UV 而言是一个较大的数值,直接使用 offsetNormal 作为 UV 偏移值会导致非常夸张的背景扭曲,因此需要新增一个 strengthUV 参数(默认值为 0.03)来缩小 UV 偏移值到合理的范畴:

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 方位(指向中心)。

此时执行效果如下:

12

3.2 色散的实现

色散的实现其实比想象的简单,只需要根据 UV 偏移量 offsetUV,进一步左右偏移背景色的 R 和 B 通道即可,同时新增 dispersionStrength 参数(默认值为 0.03)来控制色散偏移的强度:

    // 色散效果,把 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);

执行效果如下:

13

为了提升性能,我们可以仅在需要色散的时候才对 R 和 B 通道进行采样:

    // 色散效果,把 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 函数获得位移矢量长度,然后判断其值是否大于指定的阈值,若是则返回纯白色的轮廓光:

  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)来圈定产生轮廓光的区域。

执行上述代码,可以获得一个相较粗糙的实心轮廓光:

14

受限于位移向量场贴图精度,计算获得的轮廓光粗细可能不均,且可能存在些许偏移。

然而我们需要的并非一个完整且实心的轮廓光,我们需要的是一个对称的、透明度线性渐变的轮廓光,而这块的处理可以从位移向量场贴图上的色值获取线索:

可以看到位移向量场贴图的 R 或 G 通道值,均是线性渐变的,例如 G 通道的值,会从最顶部的 255 沿着边缘线性递减到最左侧的 128

因此我们可以用位于左上角的 G 通道值 191 来作为左半球轮廓光的中点(完全不透明度),其左右两侧 G 通道值 ±50 的点为轮廓光的边缘(完全透明度),进而实现一个位于左上角的渐变轮廓光:

    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);

执行效果如下:

16

同理,我们可以算出右下角的渐变轮廓光:

    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);
    }

执行效果如下:

17

至此,我们借助位移向量场贴图的能力,在单个 DrawCall 中仅使用了最多 4 次采样,便高效地实现了一个带有扭曲、色散和轮廓光的液态玻璃效果。

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

18

27赞

牛蛙,火速收藏

太舒服,好看

专业,非常牛逼~~

一如既往的高质量!

image

mark, 666

6到飞起啊

你适合去编教材 :nerd_face:

牛比,加入KFC豪华午餐了没

以我的智商 不要试图教会我 :rofl:

:+1:太牛了~

这个喷不了

Amzing work.
I try to make the effect but it’s not working.
Is this my work correct?

CCEffect %{
  techniques:
    - passes:
        - vert: sprite-vs:vert
          frag: sprite-fs:frag
          properties:
            bgRT: { value: white }
            displacementMapRT: { value: white }
            strengthUV: { value: 0.03 }
            dispersionStrength: { value: 0.03 }
            highlightStrength: { value: 0.1 }
          depthStencilState:
            depthTest: false
            depthWrite: false
          blendState:
            targets:
              - blend: false
}%


CCProgram sprite-vs %{
  precision highp float;

  #include <builtin/uniforms/cc-global>
  #if USE_LOCAL
    #include <builtin/uniforms/cc-local>
  #endif

  in vec3 a_position;
  in vec2 a_texCoord;

  out vec2 uv;

  vec4 vert() {
    vec4 pos = vec4(a_position, 1.0);

    #if USE_LOCAL
      pos = cc_matWorld * pos;
    #endif

    pos = cc_matViewProj * pos;

    uv = a_texCoord;
    uv = (cc_cameraPos.w > 1.0) ? vec2(uv.x, 1.0 - uv.y) : uv;

    return pos;
  }
}%


CCProgram sprite-fs %{
  precision highp float;
  #include <builtin/uniforms/cc-global> 

  in vec2 uv;

 uniform UBO {
    float strengthUV; 
    float dispersionStrength; 
    float highlightStrength;
  };

  uniform sampler2D bgRT;
  uniform sampler2D displacementMapRT;

  const float canvasScale = 1280.0 / 720.0;

  vec4 frag() {
    vec4 dispColor = texture(displacementMapRT, uv);
    if (dispColor.a < 0.01) {
      return texture(bgRT, uv);
    }

    vec2 offsetNormal = dispColor.xy * 2.0 - 1.0;
    vec2 offsetUV = offsetNormal * strengthUV * vec2(1.0, -canvasScale);

    vec2 baseUV = uv + offsetUV;

    vec4 gC = texture(bgRT, baseUV);
    vec4 rC = gC;
    vec4 bC = gC;

    if (dispersionStrength > 0.0) {
      rC = texture(bgRT, baseUV + offsetUV * dispersionStrength);
      bC = texture(bgRT, baseUV - offsetUV * dispersionStrength);
    }

    vec4 dispersionColor = vec4(rC.r, gC.g, bC.b, 1.0);

    float normalLen = length(offsetNormal);
    if (normalLen < 1.0 - highlightStrength) {
      return dispersionColor;
    }

    float opacity = 0.0;
    float leftG = 191.0 / 255.0;
    float rightG = 1.0 - leftG;
    float spanG = 50.0 / 255.0;

    if (offsetNormal.x > 0.0 &&
        dispColor.g > leftG - spanG &&
        dispColor.g < leftG + spanG) {
      opacity = clamp(1.0 - abs(dispColor.g - leftG) / spanG, 0.0, 1.0);
    }
    else if (offsetNormal.x < 0.0 &&
             dispColor.g > rightG - spanG &&
             dispColor.g < rightG + spanG) {
      opacity = clamp(1.0 - abs(dispColor.g - rightG) / spanG, 0.0, 1.0);
    }

    return mix(dispersionColor, vec4(1.0), opacity);
  }
}%

Hello :slight_smile:

You can purchase the complete case source code in the store for debugging.

BTW you’ll also receive my after-sales support (including help with locating and resolving issues).

像楼主这样的大佬不多了 :+1: