适用读者:想在网页上做出「真玻璃」质感的前端开发者。本篇讲原理:为什么 backdrop-filter: blur() 做不出玻璃,以及怎么用距离场和 SVG 滤镜把真正的折射做出来。下一篇讲怎么把它装进 Hexo + Butterfly。


从一个失败的起点说起

想在网页上做玻璃,几乎所有教程都会给你这三行:

1
2
3
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);

这套东西做出来的是毛玻璃(frosted glass),不是玻璃。

去找一块真的玻璃,比如手机屏幕的边缘、一个玻璃杯的杯壁,你会发现:

  • 玻璃中间几乎是完全透明的,不模糊
  • 所有的「玻璃感」都集中在边缘:那里的背景是扭曲的,不是模糊的
  • 边缘还会有一圈亮边,越接近边缘越亮
  • 仔细看,扭曲的地方带一点彩边

上面那三行 CSS 里,没有一条在描述这四件事。

模糊是浴室玻璃,扭曲才是眼镜片。


玻璃的四个光学分量

要在网页上重建玻璃,得先知道要重建什么。拆开来是四个分量。

① 折射

光从空气进入玻璃时会拐弯。但关键不在折射率这个数字,而在表面的斜率:

1
2
3
4
   平的地方              弯的地方
─────────────── ╱‾‾‾╲
↓ ↓ ↓ ↙ ↘
光直着穿过 光被折向两边

一块两面平行的玻璃板,正着看几乎没有畸变——光进去拐一次、出来拐回来,抵消了。只有表面弯曲的地方,光才会净偏折。

这正好解释了开头的观察:玻璃中间是平的,所以透明;边缘是圆弧倒角,所以扭曲。

所以想让一个 div 像玻璃,要算的不是「模糊多少」,而是「每个像素处的表面斜率是多少」。

② 色散

不同波长的光折射率不一样,红光偏折少,蓝光偏折多。所以畸变强烈的地方会出现彩边。它是「这是一块真的透明介质」的强信号——纯粹的模糊永远不会产生颜色分离。

③ 菲涅尔反射

这是最容易被忽略、但对「立体感」贡献最大的一项:看一个表面的角度越斜,它就越像镜子。 站在湖边低头看,能看见水底;望向远处的湖面,看到的是天空的倒影。

玻璃的倒角处表面是斜的,你几乎是擦着它看过去的,于是那一圈反射率飙升——整个轮廓亮起来,并向内衰减。我参考的 WebGL 实现(ybouane/liquidglass)里,菲涅尔的默认权重是所有光学参数里最高的。我最初的实现里完全没有这一项,加上之后,「厚度」是肉眼可见地出现的。

④ 高光

点光源在弯曲表面上的镜面反射,相对次要,box-shadow 就能近似。


边缘形状就是距离场

问题变成了:怎么算出每个像素处的表面斜率?

如果玻璃是个圆角矩形,它的倒角在哪、朝哪个方向弯,完全由「这个像素离最近的边有多远」决定。这正是有符号距离场(Signed Distance Field,SDF)的定义:内部为负,边界为 0,外部为正。

1
2
3
4
5
6
7
8
9
           外部(正值)
┌ ─ ─ ─ ─ ─ ─ ─ ┐
╱ +5 +5 +5 ╲
│ 0 ┌───────────┐ 0 │ ← 边界,距离 = 0
│ -5 │ -20 -20 │ -5 │
│ 0 └───────────┘ 0 │
╲ +5 +5 +5 ╱
└ ─ ─ ─ ─ ─ ─ ─ ┘
内部(负值)

圆角矩形的 SDF 有闭式解:

1
2
3
4
5
6
7
8
// hw, hh = 半宽、半高;r = 圆角半径;x, y 以矩形中心为原点
function roundedRectSDF(x, y, hw, hh, r) {
var qx = Math.abs(x) - hw + r;
var qy = Math.abs(y) - hh + r;
var mx = Math.max(qx, 0);
var my = Math.max(qy, 0);
return Math.min(Math.max(qx, qy), 0) + Math.sqrt(mx * mx + my * my) - r;
}

有了距离场,两件事同时解决了:

1
2
3
4
5
距离场 d
│
├── 梯度 ∇d ──→ 偏折方向(表面法线)
│
└── 剖面 t(d) ──→ 偏折强度(在倒角的哪个位置)

方向:梯度

在一个点的左右、上下各采样一次,差值指向的就是「离边界更远」的方向,也就是表面法线:

1
2
var gx = roundedRectSDF(px + e, py, hw, hh, r) - roundedRectSDF(px - e, py, hw, hh, r);
var gy = roundedRectSDF(px, py + e, hw, hh, r) - roundedRectSDF(px, py - e, hw, hh, r);

圆角矩形 SDF 的梯度其实有解析解,一次求值就够,理论上快 3.8 倍。我试过,然后撤回了:解析梯度在每个圆角向内 45° 的角平分线上会从「指向上」瞬间翻转成「指向左」——那里是距离场真实的尖点,不可导。中心差分因为采样 ±1px,会平滑地转过去。换成解析解,每个圆角都会多一道折痕,而省下的只是页面加载时的 1–2ms(贴图是缓存的,不是每帧算)。不值。

强度:剖面函数

t(d) 这个「距离 → 强度」的映射,就是玻璃倒角的横截面形状。它比看起来重要得多。

我第一版用的是最直觉的写法:

1
var t = smoothstep(-bezel, 0, d);   // 0 在折射带内侧,1 在边缘

做出来的效果一直「硬」,像切出来的斜面而不是弯过去的圆角。

问题出在 smoothstep 是 S 形曲线,两端的导数都是零——包括边缘那一端。而折射跟随的是斜率。一个在边缘处斜率归零的剖面,描述的是这个东西:

1
2
3
4
5
6
7
smoothstep 剖面            真实圆弧倒角

────╮ ────╮
╰──── ← 平顶 ╲
│ ╲ ← 斜率在边缘最大
════╧════ ══════╲═
平切角 圆弧倒角

平切角(chamfer),不是圆角(fillet)。 改成圆弧截面:

$$t(u) = u \sqrt{\frac{1-K}{1-u^2K}}$$

u 从折射带内侧的 0 线性走到边缘的 1,K 控制倒角转折的锐度。端点值不变,但边缘处的斜率从 0 变成了最大。

这里还有个陷阱:u 必须线性取自距离,不能再套一层 smoothstep,否则链式法则会把那个零斜率原样送回边缘,改动就白做了。


把斜率场交给浏览器:feDisplacementMap

算出了每个像素的偏移量,但 CSS 没有「按数组偏移像素」这种功能。SVG 有一个滤镜刚好能做这件事:用一张图的颜色,去决定另一张图每个像素从哪里采样。

1
2
<feDisplacementMap in="SourceGraphic" in2="map" scale="20"
xChannelSelector="R" yChannelSelector="G"/>
1
2
偏移X = (R/255 - 0.5) × scale
偏移Y = (G/255 - 0.5) × scale

所以 128 是中性值(不偏移)。思路就变成了:把偏移量编码进一张图片的红绿通道,交给 SVG 去执行。

核心循环,对每个像素:求距离 → 求剖面 → 求梯度 → 记下偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for (var j = 0; j < mh; j++) {
var py = ((j + 0.5) / mh) * h - hh;
for (var i = 0; i < mw; i++) {
var px = ((i + 0.5) / mw) * w - hw;
var d = roundedRectSDF(px, py, hw, hh, r);

var u = (d + bezel) / bezel; // 线性,内侧 0 → 边缘 1
if (u <= 0) continue; // 玻璃中间:不偏移,直接跳过
if (u > 1) u = 1;
var t = u * lensNorm / Math.sqrt(1 - u * u * lensK); // 圆弧剖面

var gx = roundedRectSDF(px + e, py, hw, hh, r) - roundedRectSDF(px - e, py, hw, hh, r);
var gy = roundedRectSDF(px, py + e, hw, hh, r) - roundedRectSDF(px, py - e, hw, hh, r);
var len = Math.sqrt(gx * gx + gy * gy);
if (len < 1e-6) continue;

// 负号:从更靠内的地方采样,边缘会把内部的像素「拉」过来
offs[p] = -(gx / len) * t * strength;
offs[p + 1] = -(gy / len) * t * strength;
}
}

然后编码成像素:

1
2
3
var scale = peak * 2;   // peak 是所有偏移的最大绝对值
data[n] = (0.5 + offs[q] / scale) * 255; // R → X
data[n + 1] = (0.5 + offs[q + 1] / scale) * 255; // G → Y

scale 取 2 × peak,这样 0.5 ± off/(2·peak) 正好能被 feDisplacementMap 的 (C - 0.5) × scale 精确还原。贴图存归一化的方向和相对强度,绝对强度交给 scale 属性。

距离场是平滑的,所以贴图不需要按元素的真实分辨率生成:最长边 192px 生成,再由 feImage 拉伸回去,肉眼看不出区别。


色散:三路位移,和一个让整页变绿的坑

色散的做法是把背景用三个略微不同的强度位移三次,每一路只保留一个颜色通道,最后加起来:

1
2
3
4
5
var scales = [
map.scale * (1 + spread), // 红:偏折最多
map.scale, // 绿
map.scale * (1 - spread) // 蓝:最少
];

每一路之后用 feColorMatrix 只留一个通道,再用两个 feComposite(arithmetic,k2 = k3 = 1)相加。

我第一版的通道矩阵是这样写的——只保留红色,alpha 也归零,理由是「三路的 alpha 加起来会变成 3,得防着点」:

1
2
3
4
1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0 ← alpha 归零

结果:整个页面所有玻璃面渲染成纯绿色。

SVG 滤镜在预乘 alpha 空间里运算。alpha = 0 意味着这一路的颜色在相加之前就已经被乘成了 0,于是红、蓝两路被彻底删除,只剩没动 alpha 的那一路。正确写法是保留 alpha:

1
2
3
4
1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0 ← 保留

背景不透明,三个 alpha 相加得 3,然后被截断回 1,既正确又无害。

在预乘 alpha 空间里,把 alpha 置零 = 把颜色也置零。


菲涅尔:用 box-shadow 做

菲涅尔不需要滤镜,box-shadow 的 inset 天生就沿着 border-radius 走:

1
2
3
4
5
6
box-shadow:
inset 1px 1px 1.5px -0.5px rgba(255, 255, 255, 0.50), /* 左上:光打过来的高光 */
inset -1px -1px 2px -1px rgba(255, 255, 255, 0.30), /* 右下:远侧的反弹,更暗 */
inset 0 0 0 1px rgba(255, 255, 255, 0.07), /* 一圈极淡的定形线 */
inset 0 0 22px -11px rgba(255, 255, 255, 0.44), /* 菲涅尔:整圈提亮,向内衰减 */
inset 0 0 12px -7px rgba(120, 62, 24, 0.18); /* 厚度感的内阴影 */

我一开始写错的地方是前两层的模糊半径原本是 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 上实现,附完整源码。