给博客做一套液态玻璃(上):玻璃到底是什么
适用读者:想在网页上做出「真玻璃」质感的前端开发者。本篇讲原理:为什么
backdrop-filter: blur()做不出玻璃,以及怎么用距离场和 SVG 滤镜把真正的折射做出来。下一篇讲怎么把它装进 Hexo + Butterfly。
从一个失败的起点说起
想在网页上做玻璃,几乎所有教程都会给你这三行:
1 | background: rgba(255, 255, 255, 0.1); |
这套东西做出来的是毛玻璃(frosted glass),不是玻璃。
去找一块真的玻璃,比如手机屏幕的边缘、一个玻璃杯的杯壁,你会发现:
- 玻璃中间几乎是完全透明的,不模糊
- 所有的「玻璃感」都集中在边缘:那里的背景是扭曲的,不是模糊的
- 边缘还会有一圈亮边,越接近边缘越亮
- 仔细看,扭曲的地方带一点彩边
上面那三行 CSS 里,没有一条在描述这四件事。
模糊是浴室玻璃,扭曲才是眼镜片。
玻璃的四个光学分量
要在网页上重建玻璃,得先知道要重建什么。拆开来是四个分量。
① 折射
光从空气进入玻璃时会拐弯。但关键不在折射率这个数字,而在表面的斜率:
1 | 平的地方 弯的地方 |
一块两面平行的玻璃板,正着看几乎没有畸变——光进去拐一次、出来拐回来,抵消了。只有表面弯曲的地方,光才会净偏折。
这正好解释了开头的观察:玻璃中间是平的,所以透明;边缘是圆弧倒角,所以扭曲。
所以想让一个 div 像玻璃,要算的不是「模糊多少」,而是「每个像素处的表面斜率是多少」。
② 色散
不同波长的光折射率不一样,红光偏折少,蓝光偏折多。所以畸变强烈的地方会出现彩边。它是「这是一块真的透明介质」的强信号——纯粹的模糊永远不会产生颜色分离。
③ 菲涅尔反射
这是最容易被忽略、但对「立体感」贡献最大的一项:看一个表面的角度越斜,它就越像镜子。 站在湖边低头看,能看见水底;望向远处的湖面,看到的是天空的倒影。
玻璃的倒角处表面是斜的,你几乎是擦着它看过去的,于是那一圈反射率飙升——整个轮廓亮起来,并向内衰减。我参考的 WebGL 实现(ybouane/liquidglass)里,菲涅尔的默认权重是所有光学参数里最高的。我最初的实现里完全没有这一项,加上之后,「厚度」是肉眼可见地出现的。
④ 高光
点光源在弯曲表面上的镜面反射,相对次要,box-shadow 就能近似。
边缘形状就是距离场
问题变成了:怎么算出每个像素处的表面斜率?
如果玻璃是个圆角矩形,它的倒角在哪、朝哪个方向弯,完全由「这个像素离最近的边有多远」决定。这正是有符号距离场(Signed Distance Field,SDF)的定义:内部为负,边界为 0,外部为正。
1 | 外部(正值) |
圆角矩形的 SDF 有闭式解:
1 | // hw, hh = 半宽、半高;r = 圆角半径;x, y 以矩形中心为原点 |
有了距离场,两件事同时解决了:
1 | 距离场 d |
方向:梯度
在一个点的左右、上下各采样一次,差值指向的就是「离边界更远」的方向,也就是表面法线:
1 | var gx = roundedRectSDF(px + e, py, hw, hh, r) - roundedRectSDF(px - e, py, hw, hh, r); |
圆角矩形 SDF 的梯度其实有解析解,一次求值就够,理论上快 3.8 倍。我试过,然后撤回了:解析梯度在每个圆角向内 45° 的角平分线上会从「指向上」瞬间翻转成「指向左」——那里是距离场真实的尖点,不可导。中心差分因为采样 ±1px,会平滑地转过去。换成解析解,每个圆角都会多一道折痕,而省下的只是页面加载时的 1–2ms(贴图是缓存的,不是每帧算)。不值。
强度:剖面函数
t(d) 这个「距离 → 强度」的映射,就是玻璃倒角的横截面形状。它比看起来重要得多。
我第一版用的是最直觉的写法:
1 | var t = smoothstep(-bezel, 0, d); // 0 在折射带内侧,1 在边缘 |
做出来的效果一直「硬」,像切出来的斜面而不是弯过去的圆角。
问题出在 smoothstep 是 S 形曲线,两端的导数都是零——包括边缘那一端。而折射跟随的是斜率。一个在边缘处斜率归零的剖面,描述的是这个东西:
1 | smoothstep 剖面 真实圆弧倒角 |
平切角(chamfer),不是圆角(fillet)。 改成圆弧截面:
$$t(u) = u \sqrt{\frac{1-K}{1-u^2K}}$$
u 从折射带内侧的 0 线性走到边缘的 1,K 控制倒角转折的锐度。端点值不变,但边缘处的斜率从 0 变成了最大。
这里还有个陷阱:u 必须线性取自距离,不能再套一层 smoothstep,否则链式法则会把那个零斜率原样送回边缘,改动就白做了。
把斜率场交给浏览器:feDisplacementMap
算出了每个像素的偏移量,但 CSS 没有「按数组偏移像素」这种功能。SVG 有一个滤镜刚好能做这件事:用一张图的颜色,去决定另一张图每个像素从哪里采样。
1 | <feDisplacementMap in="SourceGraphic" in2="map" scale="20" |
1 | 偏移X = (R/255 - 0.5) × scale |
所以 128 是中性值(不偏移)。思路就变成了:把偏移量编码进一张图片的红绿通道,交给 SVG 去执行。
核心循环,对每个像素:求距离 → 求剖面 → 求梯度 → 记下偏移:
1 | for (var j = 0; j < mh; j++) { |
然后编码成像素:
1 | var scale = peak * 2; // peak 是所有偏移的最大绝对值 |
scale 取 2 × peak,这样 0.5 ± off/(2·peak) 正好能被 feDisplacementMap 的 (C - 0.5) × scale 精确还原。贴图存归一化的方向和相对强度,绝对强度交给 scale 属性。
距离场是平滑的,所以贴图不需要按元素的真实分辨率生成:最长边 192px 生成,再由 feImage 拉伸回去,肉眼看不出区别。
色散:三路位移,和一个让整页变绿的坑
色散的做法是把背景用三个略微不同的强度位移三次,每一路只保留一个颜色通道,最后加起来:
1 | var scales = [ |
每一路之后用 feColorMatrix 只留一个通道,再用两个 feComposite(arithmetic,k2 = k3 = 1)相加。
我第一版的通道矩阵是这样写的——只保留红色,alpha 也归零,理由是「三路的 alpha 加起来会变成 3,得防着点」:
1 | 1 0 0 0 0 |
结果:整个页面所有玻璃面渲染成纯绿色。
SVG 滤镜在预乘 alpha 空间里运算。alpha = 0 意味着这一路的颜色在相加之前就已经被乘成了 0,于是红、蓝两路被彻底删除,只剩没动 alpha 的那一路。正确写法是保留 alpha:
1 | 1 0 0 0 0 |
背景不透明,三个 alpha 相加得 3,然后被截断回 1,既正确又无害。
在预乘 alpha 空间里,把 alpha 置零 = 把颜色也置零。
菲涅尔:用 box-shadow 做
菲涅尔不需要滤镜,box-shadow 的 inset 天生就沿着 border-radius 走:
1 | box-shadow: |
我一开始写错的地方是前两层的模糊半径原本是 0。模糊为零的 box-shadow 是一条硬线,绕着四条边跑一圈,那是画出来的边框,不是光。这两层本质是方向性光照:inset 1px 1px 只会出现在上边和左边(光从左上打来)。加上小模糊和负扩散之后,每一条变成贴着自己那条边的柔和光带,亮边和暗边的不对称才回来了。
为什么不用 WebGL
到这一步,很自然的想法是「上 WebGL 写 shader」。我没有,因为场景不匹配:
- 一篇文章页上有几十个玻璃面——卡片、侧栏、导航、代码块、表格、引用块。浏览器对同时存在的 WebGL context 数量有硬限制,通常 8–16 个。
- 这些面是静态的。卡片不会自己动,动的是背景。为一个不变的形状每帧重跑 shader,是纯粹的浪费。
所以路线定在 SVG feDisplacementMap:贴图按几何尺寸生成一次、缓存复用,浏览器自己合成。代价是拿不到实时的 shader 效果,收益是几十个面的成本约等于几张贴图。
它唯一的限制是:backdrop-filter 里引用 SVG 滤镜,只有 Chromium 能合成。 Safari 和 Firefox 会解析它,然后静默丢弃整条声明——连 blur 都没了。所以必须做能力检测,给它们一条退路。这些放到下一篇。
小结
| 你以为的玻璃 | 实际的玻璃 |
|---|---|
blur() |
中间不模糊,边缘扭曲 |
一条 border |
一圈由内向外衰减的菲涅尔辉光 |
| 均匀的效果 | 全部集中在倒角那几十个像素里 |
| 纯灰度 | 畸变处有色散彩边 |
一句话:做玻璃不是做模糊,是做一个表面斜率场。
下一篇:给博客做一套液态玻璃(下):在 Hexo + Butterfly 上实现,附完整源码。