文章

FIELD NOTE

给静态博客做液态玻璃:换了三次方案,最后留下两条路线

记录本站液态玻璃从 DOM 垫底板、SVG 位移滤镜到 WebGL 折射的三次方案更替,以及超长面板、嵌套卡片和 Firefox 降级这些具体边界。

8 分钟读完建站技术

这个博客底栏右端有个「实验室」入口,里面挂着几个还没定稿的外观开关,其中一个叫「液态玻璃」。它默认关闭,打开后页面上的圆角面板会变成一块会折射背景的玻璃。

这篇文章记录它是怎么变成现在这样的。前后换过三次实现方案,中间有一次是因为面板上出现了一条本不该有的对角线,才发现问题。如果你也想在静态站点上加类似效果,下面这些约束和数字大概能省你一些时间。

先把边界划出来:玻璃不能接管页面

动手之前有一条线要先划:玻璃只是外观,页面本身仍然是一份普通的 HTML 文档。

液态玻璃的现成实现里,有一类要求把整个界面描述成一份画布元素列表——文字、按钮、滚动都在它自己的 canvas 里绘制。照那条路走,本站的 MDX 正文、目录、评论和搜索都得重写,还会丢掉搜索引擎能读到的文本、浏览器的页面内查找,以及文字的选中和复制。

本站选的是另一条路:只借渲染器,把它当成铺在内容下面的一层玻璃画布。

text
canvas(position: fixed,铺在内容下面)
  └ 画「背景图 + 每块面板位置上的玻璃」
DOM 层(正常文档流)
  └ 面板半透明,文字、图片、链接仍是活的 DOM,压在玻璃上

渲染器会把背景图画进画布,所以玻璃铺好之后,body 上的 CSS 背景图要收起来,否则同一张图会叠两层。这个收起的动作挂在渲染成功之后:拿不到 WebGL 时画布上没有壁纸,这时候把 CSS 背景图藏掉,整页就只剩白底。

第一版:给每块面板垫一块底板

第一版用的是 @ybouane/liquidglass(MIT),它有个硬性要求:玻璃元素必须是根元素的直接子元素。

本站的面板是层层嵌套的——正文面板里有代码块、卡片和评论区,满足不了这个条件。当时的做法是给每块面板在同位置垫一块同尺寸的玻璃底板,再把面板自己的底色转成透明,文字仍旧留在原来的 DOM 里。为了底板对得齐,面板只能改颜色,不能动边框宽度和内边距,尺寸一变底板就会错位。

三个坑是照着现象一个个填的:

  • 底板要加 isolation: isolate,否则注入的 canvas 带 z-index: -1,会被背景图整个盖住。
  • 倒角半径要按面板厚度缩放,薄条上两条倒角会撞在一起,接缝处看得见。
  • 背景图用的是 background-attachment: fixed,滚动时底板相对背景在移动,库自己发现不了,只能主动重画,顺带做了滚动节流和视口剔除。

这一版能看出玻璃的样子,但观感和性能都不好:滚动会掉帧,玻璃块一多还得按屏幕预算往下砍。实验的说明里直接把「不保证体验和性能」写了进去。

第二版:用 SVG 位移滤镜,交给合成器

第二版换了条完全不同的路:不用画布,也不碰 WebGL,用一个 SVG 位移滤镜做折射。

先用 canvas 生成一张位移贴图。圆角矩形内部按有向距离场算「离边缘多近」,只有最外圈一条折射带把采样点往内偏折,中段是中性灰、不动。贴图交给 SVG 滤镜的 feImage,再让 feDisplacementMap 按贴图的红绿通道偏移背景。面板自己的 backdrop-filter 引用这个滤镜:

css
backdrop-filter: url(#lab-glass-cheap-0) blur(0.25px) contrast(1.2) saturate(1.1);

这条路比第一版轻很多。没有根元素的约束,滤镜可以直接贴在面板上;位移由浏览器合成器执行,滚动、换页和动画都不用跑 JavaScript,也没有 GPU 上下文数量和帧缓冲的限制。

代价是它只有边缘折射。色散、边缘高光、厚度感都没有,观感和 WebGL 那套差一截——这句判断直接写进了实验室页面,和另一条路线并排显示。

这里还试过一次「把背景真正模糊起来」:把模糊半径换成亚克力那档的 14px。结果边缘那圈折射跟着一起糊掉,剩下的只是一块磨砂板,于是退回接近原版的 0.25px。

第三版:把 WebGL 渲染器搬进仓库

第三版换成了 liquid-glass-webgl 的 WebGL 渲染器。它是对 Kyant0/AndroidLiquidGlass 的 Web 移植,核心是更平滑的连续曲率圆角、可分离高斯与 Kawase 模糊,以及边缘把 RGB 分开折射的色差。这些是前两版都没有的厚度和层次。

搬进来的只有它的渲染器和 shader 两个目录,而且定下「不改动、只标注」:它的命名、注释语言和本站完全不一样,改掉之后就没法和上游对比、升级。ESLint 排除了这个目录,类型检查仍然覆盖它。上游发布时带着 56 个类型错误、靠 ignoreBuildErrors 关掉了类型检查,这里逐条修掉并记了清单。

接线上只实例化它的渲染器,不用它自带的宿主组件。元素用文档坐标,渲染器内部按 y = rect.y - scrollY 换算成视口坐标,页面滚动仍由浏览器负责;开发期还把渲染器挂到 window 上,方便验证滚动同步。

这个改动还有一个不轻的代价。liquid-glass-webgl 是 AGPL-3.0,按协议,通过网络提供服务时要向使用者提供完整源码,而这个义务覆盖整个服务,不能只标注引入的那一个组件。所以从这一版起,整站源码改成了 AGPL-3.0 发布。

两条路线怎么分工

WebGL 那套不是到处都能用,现在按面板分成几类:

面板走哪条原因
常规面板WebGL 画布折射、厚度和色差只有这条有
正文面板便宜版 SVG 滤镜它可能高到几千甚至几万像素,WebGL 的几何会退化
嵌在玻璃里的卡片便宜版 SVG 滤镜画布在最底层,走 WebGL 会被父面板的薄纱压住
顶栏、底栏保持原本的毛玻璃一个图层没法同时待在正文下面、又盖住正文
短边小于 24px 的碎块不上玻璃分隔条这类元素根本画不出内容

「嵌在玻璃里的卡片」指的是文章末尾的上一篇/下一篇,以及评论区的每条留言。它们必须换路线,理由有一个具体数字:同一个位置,父面板的薄纱在时,卡片内部平均亮度是 33.2,把父面板去掉之后是 44.1,被压掉了大约四分之一。原因在层级——画布固定在最底层(z-index: -1),DOM 里的一切都压在它上面,包括父面板自己那层半透明底。便宜版是面板自己的 backdrop-filter,天然画在父面板之上,层级才是对的。

顶栏和底栏不参与,有两层原因,任何一层单独都够。第一是层级:画布铺在正文下面,正文才能压在玻璃上;而吸顶的顶栏要盖住滚上来的内容,必须画在正文之上,一个图层做不到两件事。第二是不划算:顶栏只有 57px 高,底栏 76px,本来就带 blur(14px) 的毛玻璃,为这两条窄边再养一层渲染器不值。

判断「谁不参与」用的是「在不在 <main> 里」,而不是标签名。一开始按标签名排除 <header>,结果页面的标题区也是 <header>,它们本该和别的面板一样有玻璃,却一直没有。

面板被画成三角形的那次

超长面板的问题,是在用户环境的 dpr 1.5 下才暴露出来的。

渲染器给每块玻璃生成一张方形遮罩纹理,把整个元素归一化到 texSize × texSize 的图里,圆角在这张图里能占到的像素数是 radius × (texSize / 元素最长边)。一块 832×10080 的正文面板,圆角只占 0.71 个像素。

面板尺寸圆角在遮罩里占的像素结果
832×318960.2圆角被量化成直角
832×100800.71整块玻璃画成三角形,面板上横着一条对角边
832×63811.12正常

门槛原来定在 0.5,只考虑过「圆角变方」。三角形那个更难看的表现落在 0.5 之上,所以没被拦住。现在门槛提到 1:圆角连一个纹素都占不到,这套几何就不该再用,交给便宜版——它用元素像素算距离,没有这个问题。

这个现象只在 dpr 1.5 下出现,本地无头浏览器一直是 dpr 1,怎么都复现不出来。最后是按用户的 dpr 加生产构建(静态导出,也就是公网跑的那份)才确认的。

滚动、画质和降级

玻璃每帧的开销正比于画布像素数乘模糊开销,而滚动恰恰是最在意帧率、最不在意画质的时候。所以连续变化超过 80ms 就切到低画质,停手 320ms 再切回来。低画质是按比例降 dpr(0.65 倍)再加模糊降采样 2 倍,没有写死成 1——写死 1 在 dpr 1 的屏幕上等于没降,档位形同虚设。

滚动同步有一处延迟绕不开:正文由合成器线程滚动,画布必须回主线程重画,主线程一忙就会落在后面,玻璃会比正文慢一两帧。最早是在 scroll 事件里同步偏移,那会慢到两帧,现在改成常驻 rAF 每帧读一次滚动量,只慢一帧。

降级统一朝「看起来还是一块正常的板子」这个方向走:

  • 拿不到 WebGL 上下文,或者壁纸解码失败:什么都不做,面板保持原本的亚克力外观。
  • Firefox 不支持 backdrop-filter 里的 url():正文和卡片退回亚克力,实验室那一行会写明原因。

第二道判断要多说一句。CSS.supports 只看语法,而 Firefox 的 backdrop-filter 语法里恰好收 url(),却不会执行 SVG 滤镜——它能返回 true,实际却不生效。所以除了语法检测,还得补一道引擎判断。这里是少见的按引擎判断合理的场合,因为差别就在实现有没有做。

现在的样子

写这篇文章时,实验室里这个开关默认还是关的。已知的问题照实写:玻璃跟不上滚动和动画,会滞后一两帧;超长正文走的是另一套便宜实现;老设备上会掉帧。

留着的理由是它值得先占住这个位置。观感和性能都还不够好,但两条路线的边界已经摸清楚了——哪类面板适合实时折射、哪类只能交给合成器、从什么尺寸开始退化。这些结论换实现也成立,等以后再换更省的做法时可以接着往下走。

想自己看看的话,底栏右端那个「实验室」入口,打开「液态玻璃」就能试,关掉立刻回到默认样子。