毛玻璃与液态玻璃是怎么做出来的
毛玻璃和 Liquid Glass 不是同一个东西,也不是改了名。前者靠模糊「抹平」背景,后者靠 feDisplacementMap「弯折」背景。这篇从最小实现讲到两类折射方案,每种都有可以拖动、可以调参的现场演示。
这两个词经常被当成同义词用,列出对比表
| 毛玻璃 | 液态玻璃 | |
|---|---|---|
| 英文 | Frosted Glass / Glassmorphism | Liquid Glass |
| 出处 | iOS 7(2013) | WWDC 2025 |
| 做什么 | 抹平背景:模糊掉细节,只留颜色 | 弯折背景:细节还在,在边缘被掰弯 |
| CSS | backdrop-filter: blur() | backdrop-filter: url(#svg滤镜) |
| 代价 | 便宜 | 贵,且只有 Chromium 认 |
液态玻璃不是毛玻璃的新叫法,是另一种材质。毛玻璃也没被废弃——大多数场景它依然是更合适的那个。
一、毛玻璃
最小实现
只要两条 CSS:半透明底色 + 背景模糊。
.card {
background: rgba(255, 255, 255, 0.7);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}backdrop-filter 处理的是元素背后那一层,元素自身的内容不受影响——所以卡片里的字始终清晰,只有透过来的背景被糊掉。
网上绝大多数教程到这里就结束了。但这样做出来的东西,离「材质感」还差三步。
第一步:saturate
这是基础教程里几乎都缺的一项,也是差别最大的一项。模糊的本质是把邻近像素做加权平均。颜色一平均就会互相抵消,于是背景透过来时饱和度被冲淡——玻璃发灰、发闷,像蒙了一层雾,而不像玻璃。
补回来的办法是在模糊后面串一个 saturate():
backdrop-filter: blur(20px) saturate(180%);下面这块可以拖动。把「饱和度」从 180 拉到 100,就是「有没有这一步」的差别:
拉到 100 时它是一块灰扑扑的塑料板;拉回 180,背后的颜色重新透出来,才像玻璃。
差的第二步:不透明度和模糊要配着调
教程里常见的 rgba(255,255,255,.7) + blur(10px),两个值都偏保守:70% 的白已经基本不透,再加模糊,结果更接近「一张白卡片」而不是玻璃。
这两个值是此消彼长的:
| 底色浓度低 | 底色浓度高 | |
|---|---|---|
| 模糊小 | 太透,背后花,字读不了 | 像一层薄膜 |
| 模糊大 | 玻璃感最好 | 白卡片,玻璃感尽失 |
上面那个演示里把「底色浓度」拉到 20、「模糊」拉到 30 试试——那才是常见的玻璃观感。真要落地时再往回收一点,保证文字对比度。
差的第三步:高光边
玻璃是有厚度的,边缘会有一道极细的反光。少了它,卡片看着像贴纸。
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);后面讲液态玻璃时会看到,这道高光还能做得更讲究——左上和右下强度不同,光才有方向。
评价
如果你要的只是「透而不糊、内容能读」,毛玻璃就够了。
它便宜、兼容性好(全球支持率 95.69%)、没有降级噩梦。下面那套折射方案贵得多。
二、液态玻璃 = 折射
为什么 backdrop-filter 本身做不到
blur、saturate、brightness 这些都是逐像素处理:每个像素只看自己,算出一个新颜色。
而折射是另一回事——它要回答「这个位置该显示的,其实是背后别处的像素」。这叫位移采样,普通的 backdrop-filter 函数做不到。
能做到的是 SVG 滤镜里的 feDisplacementMap。
先搞清楚:它同时吃两张图
这是理解全篇的地基,绕过去后面全看不懂:
| 是什么 | 谁来当 | |
|---|---|---|
in | 要被弄弯的画面 | 背景。所有实现都一样 |
in2 | 「往哪弯、弯多少」的说明书 | 各家实现的区别全在这一张 |
第二张就是所谓的位移图。它是一张和元素等大的真图片,但不给人看——是给滤镜查表用的。每个像素的颜色就是一条指令:
(128,128) → 原地不动
(255,128) → 往右偏最大值
(0,128) → 往左偏最大值R 通道管横向、G 通道管纵向(也可以指定别的通道),128 是「不动」的中点,偏多远由 scale 决定。
滤镜工作时逐像素查这张表:「我在 (x,y),查到说偏 10px,那我就去背景的 (x+10,y) 把颜色取过来。」
所以后面说的「造图」,造的是第二张——那张说明书,不是背景。
有一点容易绕:取样方向和看到的方向是相反的。 让某个位置去右下取样,等于把右下的内容搬到这儿来,看起来就是画面整体往左上挪了。
它有五个属性,各家实现的差别几乎全在这五个上:
| 属性 | 作用 | 默认值 |
|---|---|---|
in | 被位移的图源 | 上一个滤镜的结果,没有则 SourceGraphic |
in2 | 当位移图用的图源 | 同上 |
scale | 位移的最大像素数 | 0(不位移) |
xChannelSelector | 用 in2 的哪个通道当 X 位移 | A |
yChannelSelector | 用哪个通道当 Y 位移 | A |
注意后两个的默认值是 A(alpha),不是 R/G。 这条看着不起眼,下一节就靠它。
再把 backdrop-filter 指向这个滤镜,折射就作用在背景上:
.glass { backdrop-filter: url(#my-filter); }试一下:不给位移图会怎样
既然所有参数都有默认值,那一个都不写能跑吗?
<filter id="bare" filterUnits="objectBoundingBox">
<feDisplacementMap scale="150" />
</filter>能跑,但结果值得看一眼——in 和 in2 双双落到背景本身,两个方向又都读默认的 A 通道。而背景是全不透明的,alpha 处处相同。于是每个像素算出来的位移完全一样:
拖动「位移量」一下就明白了:拖动的元素内画面朝左上方向斜着平移,仅此而已。
它不是透镜。方向锁死在一个 45° 的对角(横竖读同一个通道、拿到同一个数,位移量必然相等),距离处处相同(它压根不知道边缘在哪儿)。放大位移量只会让整块挪得更远,不会产生任何弯曲。
这个演示的意义是划清界限:feDisplacementMap 本身不提供任何「玻璃感」,它只是个查表搬运工。想让位移随位置变化——中心不动、边缘弯——那张说明书就必须逐像素都不同。
两类实现
一、造一张位移图
想让折射「准」,就得自己画那张说明书:每个像素明确写上该往哪偏、偏多少。
「造图」具体就是这四步,全在 JS 里做:
- 建一个和元素等大的
<canvas> - 逐像素算「这里该偏多少」,把结果写成 R/G 通道的颜色值
canvas.toDataURL()→ 得到一个 PNG 的 data URL- 把它塞进
<feImage href="…">
注意这四步不是每帧都跑——只在组件挂载、尺寸变化、参数调整时跑一次。之后浏览器每帧做的是「拿着这张现成的说明书去查表采样」。这两件事发生在完全不同的时刻,后面算性能账时要分开看。
最顺的写法是把它当成 fragment shader——每个像素回答一个问题:「我该从哪儿取样?」
// 到圆角矩形边缘的有符号距离
const d = roundRectSDF(ix, iy, hw, hh, radius);
// 越靠近边缘,t 越接近 1
const t = smoothStep(0, soft, d + inset);
// 坐标向中心收缩 —— 这一步就是透镜
const k = 1 - t * 0.14;
return { x: ix * k, y: iy * k };中心 k = 1,原样取样,所以中间清晰;边缘 k < 1,从更靠内的位置取样,于是产生放大和畸变。
smoothStep 那两个端点的顺序极易写反,而且反了不报错、看着也还是「玻璃」——只是效果整个里外翻面:中间那片字被拉弯,最外一圈反而纹丝不动。判断方法不用看代码,看结果就行:彩边和畸变出现在哪儿,位移就在哪儿。 出现在字上就是写反了。
然后编码成图片。这里有个容易做反的地方:
// ① 先跑一遍,记下真实的最大位移
let maxScale = 0;
for (…) { maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy)); }
maxScale *= 0.5;
// ② 用它归一化,把 0–255 用满
R = dx / maxScale + 0.5;
G = dy / maxScale + 0.5;
// ③ 最后把这个值写回滤镜
feDisplacementMap.setAttribute('scale', maxScale);如果反过来——先定死一个 scale,再算位移幅度——当实际位移远小于 scale 时,位移图只用到 0–255 中间很窄的一段,量化台阶会肉眼可见,边缘出现一圈一圈的色阶。
「折射范围」控制弯光的宽度,「过渡柔和」控制从弯到平的过渡有多陡。中间那块字始终清晰——这就是它和上一节那个均匀平移的根本差别:位移随位置变化了。
代价是尺寸一变就要重算位移图。
这两个滑块到底在雕什么
上面那张卡片只能看到结果。把玻璃沿短边剖开、再把光路画出来,两个参数各管什么就一目了然了:
先看形状。折射范围是斜面有多宽——从边缘往里吃掉多少,是一道硬边界。过渡柔和是内侧那个肩磨掉多少——它只磨内侧,卡片真正的边缘那一头永远是最陡的,这也符合直觉:真玻璃就该在最边上弯得最厉害。
注意两件容易搞反的事:
一、柔和不会加宽斜面,它是往里啃。sf 从折射范围的内边界往外量,调大柔和是在吃掉「弯到满」的那段平台。啃过头就会出现下面这种情况:
二、柔和 > 2 × 折射范围时,肩还没磨完就顶到边了,边缘根本弯不满。此时最外缘的 t 不到 1,整条斜面被整体压低。把柔和拉到 99,你会看到光线几乎不再偏折,红色那条「看不到的背景」也跟着缩短——折射被削弱了,不是被移走了。
再看光路。等间距射出的光线,落到背景上就不再等间距了:靠边缘那几根挤成一堆,这就是放大。挤没的部分是红色那条——玻璃边缘把最外圈的背景吞掉了,那圈内容在屏幕上不存在。
过渡柔和有个功能下限,不只是审美
把柔和拖到很小,会看到光线开始交叉。
交叉意味着采样映射不再单调:同一段背景被取样了两次,于是在屏幕上被画了两遍。那不是模糊也不是错位,是一道复制出来的接缝——图里那条琥珀色的带子。
原因不难理解:t 在肩部上升得越快,取样坐标 x·k 往回缩得就越猛,猛到超过 x 自身的增长时,映射就掉头了。所以柔和有一个必须达到的下限:
过渡柔和的实际宽度要大于「中心平坦区半宽」的约 1/4。 换算到滑块上是
柔和 ≳ 25 − 折射范围 ÷ 2。
| 折射范围 | 5 | 15 | 25 | 35 | 45 |
|---|---|---|---|---|---|
| 不重影所需的最小柔和 | 22.5 | 18.5 | 13 | 8 | 3 |
图里的读数是实时搜出来的,不是套这条近似公式,拖动时可以直接看当前配置够不够。
这道接缝在纯色背景上几乎看不出来,压到文字上才现形——所以调参时一定要拿正文当底衬,这也是本文所有演示都压着一段文字的原因。
二、同一张图跑三遍(色散)
真玻璃不同波长的光折射率不同,所以边缘会泛出极淡的彩边。做法是拿上面那张位移图跑三遍,scale 依次差一点点,每遍只留一个颜色通道,最后合成:
<feDisplacementMap in="SourceGraphic" in2="map" scale="31" result="dR" />
<feColorMatrix in="dR" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="cR" />
<feDisplacementMap in="SourceGraphic" in2="map" scale="30" result="dG" />
<feColorMatrix in="dG" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="cG" />
<feDisplacementMap in="SourceGraphic" in2="map" scale="29" result="dB" />
<feColorMatrix in="dB" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="cB" />
<feBlend in="cR" in2="cG" mode="screen" result="rg" />
<feBlend in="rg" in2="cB" mode="screen" />把「色散」从 0 拉到 40,边缘会渐渐出现红青分离。过头会显得廉价——真实玻璃的色散很含蓄,10 左右就够。
滤镜开销大约是单张位移图的三倍。
比算法更要紧的一件事:只让边缘弯
早期几个流传较广的实现被评价为「浑浊」「像拉丝」,根因都不是位移算得不准,而是整块都在折射。
真玻璃中间是平的,只在边缘弯光。上面那份位移场之所以看着对,关键就在中心 k = 1——那一片原样取样,字才保持清晰。「折射范围」这个滑块调的就是这条界线。
先把这件事做对,比在位移算法上抠细节收益大得多。 一个粗糙但只作用于边缘的实现,观感会好过一个精确却铺满整块的。
光:折射之外的另一半
只有折射,玻璃会显得「薄」,像一层膜。真正让它有厚度的是光,而光要分层做,每层职责单一:
| 层 | 做什么 |
|---|---|
| 折射层 | backdrop-filter: url(#…) |
| 雾化层 | 轻微 blur + 一点点底色,压住背景的高频噪声 |
| 软反光 | inset 大扩散阴影,给体积 |
| 硬高光 | inset 1px 锐边,勾出玻璃的边界 |
硬高光那层有个细节:左上和右下的强度要不一样。
box-shadow:
inset 1px 1px 0 0 rgba(255,255,255,.55),
inset -1px -1px 0 0 rgba(255,255,255,.66);两边给同样的值,看着就是一圈描边;给不同的值,才读得出「光是从某个方向来的」。这是廉价感和质感之间很小但很关键的一步。
三、用之前必须知道
折射只有 Chromium 系认
backdrop-filter 引用 SVG 滤镜,目前只有 Chromium 系支持。 Safari 和 Firefox 会接受这条属性,却静默丢掉 SVG 部分。
注意后果不是「退化成模糊」——是什么都没有,玻璃直接变成一块裸的半透明。
而且 @supports 在这里帮不上忙:Safari 和 Firefox 对 backdrop-filter: url() 同样报告支持。只能运行时判断内核,检测不通过就切回毛玻璃那套。
它比模糊贵得多
开销要拆成两块看,混在一起会得出错误结论:
| 准备成本(偶尔一次) | 每帧成本(滚动、合成时) | |
|---|---|---|
| 毛玻璃 | 0 | 一次 blur —— GPU 高度优化过 |
| 造位移图 | canvas 逐像素算 + toDataURL | 一次全区域位移采样 |
| 加色散 | 同上(一张图复用) | 三次位移采样 + 色矩阵 + 混合 |
准备成本只在挂载、尺寸变化、调参时付,不是每帧。所以真正影响滚动流畅度的是右边那列——而右边那列里,毛玻璃和折射差的是数量级,不在一张表上比。
响应式布局、内容会撑高的卡片、窗口频繁缩放的场景要留意左边那列:每变一次尺寸就要重画一整张位移图,卡顿发生在这些时刻。
适合卡片、按钮、弹层这类小面积的常驻元素;不适合整屏遮罩,更不适合长列表里成百上千个实例。
底衬决定成败
最后一条不是技术,但比技术更能决定效果好坏:玻璃好不好看,几乎完全取决于背后有什么。
那些教程的示例之所以好看,靠的是底下那条饱和度不低、色相跨度明显的渐变。同一套 CSS 压在纯白或浅灰上,立刻变成一块看不出所以然的白卡片。
所以决定用玻璃之前,先看看你的页面背后有没有值得透出来的东西。没有的话,先做背景,再做玻璃。
怎么选
| 需求 | 选哪个 |
|---|---|
| 只想要「透而不糊」 | 毛玻璃,别上折射 |
| 想要玻璃的「体积感」,但不想碰兼容性 | 毛玻璃 + 那四层光效(纯 CSS,全浏览器) |
| 要真的边缘弯光 | 造一张位移图 |
| 追求真玻璃质感,且元素不多 | 再加色散 |
第二行值得单独说:「像玻璃」这件事,光效层的贡献远大于折射。 圆角、小半径模糊、软反光加硬高光,这套全是标准 CSS,没有 Chromium 限制、没有位移图要维护、开销约等于毛玻璃。折射是点睛,不是主体。
所以我的取向是:先把光做好,再看要不要上折射;真上了折射,先保证只作用在边缘。 前两步决定像不像玻璃,最后一步只决定像得有多精确。
评论
评论加载中……