Skip to content

把玻璃做清透:一次 Liquid Glass UI 试做 ​

Serein 是一次 Apple 风格玻璃 UI 试做:中心零模糊,让山景清晰透过;边缘用局部折射、细虹边和柔光表现厚度。

效果与示例 ​

展开交互示例(点击后加载)

新窗口打开 · 下载 HTML 源码

HTML 包含 CSS、JavaScript、SVG 山景和图标,无外部依赖。默认日光、清透、曲面开启,支持切换磨砂、关闭曲面和调整染色。

「玻璃染色 38%」指滑块位置,对应表面渐变的白色 alpha 为 3.9% 和 0.65%。清透模式下,滑块只调整染色,中心模糊始终为零。文字和图标独立绘制,不参与背景处理。

网页实现 ​

材质由 CSS 基础层和每个面板独立的 SVG 滤镜组成,是面向 UI 的视觉近似:

  1. 边缘折射:根据圆角矩形的边缘距离和外法线生成位移图,方向编码到红、绿通道,由 feDisplacementMap 采样背景。折射带宽最多 18px,向中心平滑归零;位移幅度另算。
  2. 微色散:RGB 分别使用略有差异的位移强度,再合成,形成细微的颜色错位。
  3. 柔边与高光:渐变遮罩连接边缘采样和中心背景,外侧叠加约 1.5px 的渐变虹边、内侧高光与阴影。

面板尺寸变化时重建位移图;鼠标移动只更新高光。中心模糊单独控制:

js
function getBlur() {
  return scene.classList.contains('clear')
    ? 0
    : 5 + Number(density.value) * 0.4;
}

清透模式同时降低表面染色和大面积高光,保留边缘效果。参数如下。

参数与位移公式 ​

以下长度以面板的逻辑坐标计算。网页使用 CSS px;Flutter 使用对应的逻辑像素,图像物理像素比在采样时另外处理。w、h 是面板尺寸,r 是限制在 min(w, h) / 2 以内的圆角,v 是 0 到 100 的滑块值。

项目当前原型的计算方式
默认状态日光、清透、曲面开启、v = 38
常规面板圆角26;手机主要卡片 23;其他控件见源码各自的圆角
染色基数g = 0.035 + 0.0025 * v
清透表面渐变145°,白色 alpha 从 0.3 * g 到 0.05 * g
磨砂表面渐变145°,白色 alpha 从 g + 0.12 到 0.35 * g
中心模糊 sigma清透为 0;磨砂为 5 + 0.4 * v
背景饱和度清透 1.15;磨砂 1.55
折射带宽band = min(18, min(w, h) * 0.22)
位移缩放基数s = min(30, min(w, h) * 0.45)
RGB 位移缩放红 s - 2、绿 s、蓝 s + 2
边缘采样输入背景经过 sigma 0.65 的微模糊
绘制式虹边宽 1.5、模糊 0.35、整体 opacity 0.85;颜色停靠点见源码

位移图保留像素中心取样和 8 位通道量化,x、y 为整数像素索引:

js
function lensField(x, y, w, h, r) {
  const px = x + 0.5 - w / 2;
  const py = y + 0.5 - h / 2;
  const qx = Math.abs(px) - (w / 2 - r);
  const qy = Math.abs(py) - (h / 2 - r);
  const cx = Math.max(qx, 0), cy = Math.max(qy, 0);
  const len = Math.hypot(cx, cy);
  const depth = r - len - Math.min(Math.max(qx, qy), 0);
  const band = Math.min(18, Math.min(w, h) * 0.22);
  let nx = 0, ny = 0, bend = 0, mask = 0;
  if (depth >= 0 && depth < band) {
    if (len > 0) {
      nx = Math.sign(px) * cx / len;
      ny = Math.sign(py) * cy / len;
    } else if (qx > qy) nx = Math.sign(px);
    else ny = Math.sign(py);
    const t = depth / band;
    bend = Math.sin(Math.PI * t) * Math.sqrt(1 - t) * 0.72;
    const f = Math.min(1, Math.max(0, (1 - t) * 1.35));
    mask = f * f * (3 - 2 * f);
  }
  return {
    rg: [Math.round(128 + nx * bend * 127) / 255,
         Math.round(128 + ny * bend * 127) / 255],
    mask: Math.round(mask * 255) / 255,
  };
}

+ 0.5 把整数索引移到像素中心。Flutter 的 FlutterFragCoord() 已提供连续局部坐标,应直接用 local - uPanelSize / 2。

各通道采样位置为 localPosition + channelScale * (rg - 0.5),合成为 rim 后,按 result = rim * mask + center * (1 - mask) 混合。center 在清透时取清晰背景,磨砂时取模糊背景。

HTML 用 in / out 分离区域,再以 arithmetic 的 k2 = 1、k3 = 1 相加,避免羽化处重复叠加 alpha;透明输入须保持预乘 alpha 一致。之后处理饱和度、表面染色、柔光和轮廓,最上层绘制文字与控件。

Flutter 移植要点 ​

Flutter 尚未实现,以下是方案,没有真机性能数据。基础层可用 ClipRRect、BackdropFilter 和透明渐变,高光与轮廓用 CustomPainter;局部折射和 RGB 位移交给片元着色器。背景输入有两条路线。

引擎提供背景 ​

ui.ImageFilter.shader(shader) 可配合 BackdropFilter 过滤已绘制的背景,要求 Impeller;其他后端会抛出 UnsupportedError。SDK 需包含相关 API,并检查 ui.ImageFilter.isShaderFilterSupported。

第一个浮点 uniform 必须是 vec2(输入纹理尺寸),第一个 sampler2D 是输入图像,均由引擎填写。使用 FlutterFragCoord(),在 IMPELLER_TARGET_OPENGLES 下按官方约定翻转 UV 的 Y 轴。

外层用 ClipRRect 裁切。输入纹理的尺寸、原点与面板局部坐标需在目标后端验证,不能直接把纹理尺寸当作面板尺寸。

应用提供背景纹理 ​

固定山景适合先绘制成纹理,再供多个面板共享。通过 FragmentProgram.fromAsset() 加载 shader,setImageSampler() 传入 ui.Image,由 CustomPainter 绘制;资源登记在 pubspec.yaml 的 flutter.shaders 下。

这条路线的输入全部由应用填写。三个 sampler 覆盖完整场景,共用尺寸与坐标映射:

glsl
#include <flutter/runtime_effect.glsl>
uniform vec2 uSceneSize;    // float 0、1:整幅场景的逻辑宽高
uniform vec2 uPanelOrigin; // float 2、3:面板左上角在场景中的逻辑坐标
uniform vec2 uPanelSize;   // float 4、5:面板的逻辑宽高
uniform float uRadius;    // float 6:圆角
uniform float uSaturation;// float 7:1.15 或 1.55
uniform float uFrostMix;  // float 8:清透 0,磨砂 1
uniform sampler2D uSharp; // sampler 0:清晰背景
uniform sampler2D uFrost; // sampler 1:按当前 sigma 预模糊的背景
uniform sampler2D uRim;   // sampler 2:sigma 0.65 的边缘采样背景
out vec4 fragColor;

shader 主体按前面的位移公式、三通道采样和遮罩合成补齐。setFloat() 按注释中的索引赋值,sampler 独立绑定。共享 FragmentProgram,各面板使用独立的 FragmentShader,防止 uniform 相互覆盖。

Canvas 平移到面板左上角,在 Rect.fromLTWH(0, 0, w, h) 内绘制,令 FlutterFragCoord().xy 对应面板局部逻辑坐标:

glsl
vec2 local = FlutterFragCoord().xy;
vec2 uv = (uPanelOrigin + local) / uSceneSize;

边缘采样先给 local 加对应通道位移,UV 按纹理边界钳位。背景若用 BoxFit.cover 或裁切,显示和采样须共用变换。

背景纹理只包含玻璃后面的内容。 用 RepaintBoundary.toImage(pixelRatio: dpr) 时,等待背景层绘制完成再采集。纹理尺寸约为逻辑尺寸乘以 DPR;UV 已归一化,不再乘 DPR,模糊 sigma 则须换算到图像像素尺度。

饱和度、渐变、阴影和色彩空间需与网页做视觉对齐。轮廓颜色和样式数值见 HTML 的 .glass、.glass:before、.glass:after 和 .clear。

静态缓存 ​

固定背景下,可缓存折射、色散和柔边合成后的材质图像,文字与控件另行叠加。背景、面板位置、尺寸、DPR 或材质参数改变时使缓存失效;多个面板共享背景纹理,及时释放旧图像,滑块更新可合并处理。

RepaintBoundary 只隔离重绘,图像缓存及失效逻辑仍需单独实现。背景滚动、视频和跟手高光都会引起更新;帧耗时与内存需在目标设备的 profile 模式实测。

已验证范围 ​

原型已在 Chromium 预览环境检查:

  • 手机与桌面宽度下的布局、清透/磨砂切换、曲面开关和染色滑块。
  • 清透模式染色拉满后,中心模糊仍为零。
  • 换成黑白网格并关闭染色、高光与轮廓装饰后,曲面开关只改变边缘带内的采样,中心保持清晰。

其他浏览器需实际检查 backdrop-filter: url(#filter) 的渲染;CSS.supports() 通过只代表声明被接受。可用同样的网格对比确认折射,未支持时降级为基础透明面板。Flutter 版还需检查不同 DPR 下的背景对齐、缓存失效及实际性能。

相关文档 ​

评论