当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。
尘言头像尘言 · CHNYNNYA

液态玻璃实现解析:用 SVG 位移折射替代毛玻璃模糊

以本站代码为据,拆解液态玻璃效果的三层实现:feDisplacementMap 位移折射滤镜、玻璃材质的光学建模、指针追踪与拖拽交互,以及不支持环境下的降级纪律。

液态玻璃实现解析:用 SVG 位移折射替代毛玻璃模糊

引言:从毛玻璃到液态玻璃

“玻璃拟态”(glassmorphism)在 CSS 里的标准做法是 backdrop-filter: blur()——把背景高斯模糊一层,再叠半透明填充。效果稳定、代码短,所以到处都是。但它有个视觉问题:模糊让背景”看不清”,而真实的玻璃让背景”看错”。隔着毛玻璃你只能看到一个模糊的色块;隔着清水玻璃,你能看清后面的东西,只是边缘会随玻璃表面微微扭曲。

本站首页的”液态玻璃”就是这个思路的激进版本:顶栏、按钮、主页那颗可以拖动的镜片,背后的图案都会发生位移折射——像透过一块真实的玻璃去看。这不是模糊的替代品,而是对 backdrop-filter 的完全不同的用法:用 SVG 的 feDisplacementMap 做像素位移,而不是用高斯模糊抹平背景。

本文以本站代码为据(src/components/LiquidFilters.astrosrc/styles/global.csssrc/components/SiteScript.astro),逐层拆解这套实现的三个部分:SVG 折射滤镜、玻璃材质建模、交互与降级。

一、核心原理:feDisplacementMap 位移折射

滤镜是怎么”折射”的

CSS 的 backdrop-filter 接受 url(#filterId) 引用页面上定义的 SVG 滤镜。本站的滤镜全部定义在 LiquidFilters.astro 的一个 0 尺寸 SVG 里(LiquidFilters.astro:5):

<svg width="0" height="0" aria-hidden="true" style="position:absolute;overflow:hidden">
 <defs>
  <filter id="liquid-button" primitiveUnits="objectBoundingBox" x="-8%" y="-20%" width="116%" height="140%" color-interpolation-filters="sRGB">
   <feImage href="data:image/png;base64,..." x="0" y="0" width="1" height="1" preserveAspectRatio="none" result="mapButton"/>
   <feGaussianBlur in="SourceGraphic" stdDeviation="0.0012" result="softButton"/>
   <feDisplacementMap id="disp-button" in="softButton" in2="mapButton" scale="0.044" xChannelSelector="R" yChannelSelector="G" result="refractedButton"/>
   <feColorMatrix in="refractedButton" type="saturate" values="1.035"/>
  </filter>
 </defs>
</svg>

管道很短,四个节点各司其职:

  1. feImage:把一张 base64 编码的 PNG 作为位移贴图载入。PNG 的 R 通道与 G 通道就是 x 轴、y 轴的位移向量场——每个像素的颜色值决定了该处背景被推多远、往哪推。随机噪点贴图 = 随机方向的折射。
  2. feGaussianBlurstdDeviation="0.0012"):注意这个值小到可以忽略——它不是毛玻璃的模糊,只是给原始图像做亚像素级的柔化,防止位移重采样时出现锯齿。整个实现里没有任何一处用高斯模糊来制造”磨砂”。
  3. feDisplacementMap:核心节点。in 是被折射的背景,in2 是位移贴图,scale 是位移幅度。primitiveUnits="objectBoundingBox" 下 scale 是相对滤镜区域的归一化比例,0.044 意味着像素最多被推离原位约 2%。
  4. feColorMatrixsaturate 1.035):折射后轻微提饱和——玻璃透过的光颜色更”纯”,这个微调抵消了半透明叠加带来的发灰。

为什么不用 feTurbulence

SVG 自带的 feTurbulence 也能生成噪点贴图,两行就能造出随机折射。本站选择把贴图做成静态 PNG 内嵌 data URI,有三个理由:其一,feTurbulence 每次渲染都要生成噪声场,对 backdrop-filter 这种每帧都在跑的场景是持续的 CPU/GPU 开销;而 PNG 贴图是预计算的常量。其二,base64 内嵌意味着零网络请求,且渲染结果完全可预期——颜色、尺度、密度在制图时就能精确控制。其三,color-interpolation-filters="sRGB"LiquidFilters.astro:7)确保贴图色值在 sRGB 空间解读,与 CSS 颜色一致,不会出现线性空间的意外偏差。

二、三档滤镜:一处核心,三种强度

同一套折射手法,本站做了三个变体,强度的差异本身就是设计:

滤镜用途位移 scale提饱和额外处理
liquid-circle主页镜片0.102(放大)+ 0.118(折射)1.11双段位移:先放大再折射
liquid-pill顶栏0.0401.035
liquid-button按钮0.0441.035

面积越大,折射越收敛。顶栏横跨整个视口,0.040 的位移让它保持克制,不至于整屏扭动;小按钮敢用 0.044,因为面积小,视觉上只是”边缘透出背景形状”。

liquid-circle 是唯一用双段位移的(LiquidFilters.astro:7-14):

<feDisplacementMap id="zoom-circle" in="softCircle" in2="mapZoomCircle" scale="0.102" xChannelSelector="R" yChannelSelector="G" result="magnifiedCircle"/>
<feDisplacementMap id="disp-circle" in="magnifiedCircle" in2="mapCircle" scale="0.118" xChannelSelector="R" yChannelSelector="G" result="refractedCircle"/>

第一段(zoom-circle)的贴图是专门做的”放大镜”向量场:让背景从圆心向四周外推,产生玻璃凸透镜放大的效果;第二段(disp-circle)再叠加随机噪声折射,模拟玻璃表面的细微起伏。两个 idzoom-circle/disp-circle)还被 JS 引用——拖动镜片时动态调大这两段的 scale(见第四节),折射强度就成了可以实时调的音量旋钮。

三、材质层:玻璃的五层光学

滤镜处理的是”玻璃后面”的背景,材质层处理的是”玻璃本身”长什么样。.liquid 基础类(global.css:37)一次性定义了三块内容:

.liquid{
 --glass-rgb:248 247 244;--glass-fill:.52;
 background:linear-gradient(180deg,rgb(255 255 255/.10),transparent 50%),rgb(var(--glass-rgb)/var(--glass-fill));
 border:1px solid rgb(255 255 255/.56);
 box-shadow:0 18px 48px rgb(42 35 25/.10),0 2px 8px rgb(42 35 25/.045),
   inset 0 1px 0 rgb(255 255 255/.78),inset 0 -1px 0 rgb(30 26 22/.07);
 backdrop-filter:url(#liquid-card) saturate(1.055) contrast(1.02);
}
  • 填充:顶部一抹白色渐变(rgb(255 255 255/.10))模拟玻璃的顶部受光,--glass-fill 变量控制整体透明度——.liquid-pill.56.liquid-button.43,镜片是 .15。控件越小、透明度越低,因为小面积需要更高实度才能保持边缘可读。
  • 边框与阴影1px 半透明白描边是玻璃的切面;外层两组阴影给”悬浮”深度,内阴影才是玻璃感的关键——inset 0 1px 0 的高光边是玻璃的”受光棱边”,inset 0 -1px 0 的暗边是底部的”反光棱边”,一明一暗把 1px 的厚度感立起来。
  • ::before 光学高光global.css:38):radial-gradient(180px circle at var(--lx,22%) var(--ly,8%),...)——一个跟随指针位置的高光斑。--lx/--ly 由 JS 实时写入(见第四节),默认值让高光落在左上角,即使没有 JS 也有静态受光。这就是”液态”感的主要来源:光斑随着指针在玻璃表面滑动
  • ::after 边缘虹彩global.css:39):一圈 conic-gradient(从 205° 起白、亮、暗、亮循环)做玻璃切面的反光,再用双重 mask 抠成 1px 描边:
-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
-webkit-mask-composite:xor;mask-composite:exclude;

两层全黑 mask 分别盖在 content-box 和整块上,xor 相减,只剩 padding 那 1px 是可见的——比 box-shadow: 0 0 0 1px 精确,因为它是跟随 border-radius 曲线走的虹彩描边,圆角处不会变形。悬停时 ::before.42 提到 .68::after.72 提到 .92global.css:40),玻璃”迎向”指针。

光学 pass:一次成体系的覆盖

global.css:70 起有一大段注释为 Optical pass: crisp refraction on controls, no blur-based frosted glass. 的覆盖层,它修正了三个基础类的遗留问题,也定下了全站的光学原则:

  • 内容表面(文章卡片、功能卡、通讯栏)一律关闭折射global.css:87-101):backdrop-filter:none,换成更实的三段渐变。理由写在注释里——“Content surfaces stay crisp. They are translucent panels, not frosted glass.”卡片是信息的载体,背景扭曲会干扰阅读;折射只保留给悬浮控件。
  • 顶栏与按钮保留折射,但在 SVG 滤镜之后追加极小的高斯模糊url(#liquid-pill) blur(2px) saturate(1.075) contrast(1.07) brightness(1.025)global.css:119)。这里的 blur(2px) 不是毛玻璃——它是位移之后的光学后处理,把折射产生的硬边缘柔化半像素。对比本站 changelog 里 #9/#10/#11 的记录(镜片模糊 2px → 0.5px → 0.7px → 1.1px),最终停在 1.1px:模糊值小到”看不清”不可见,大到”柔和折射”刚好,这是整套设计里最敏感的一个参数。
  • 镜片边缘抗锯齿global.css:141-142):mask: radial-gradient(circle,#000 98%,rgba(0,0,0,.55) 99.3%,transparent 100%)——在镜片最外圈做一段 1-2% 的羽化过渡,否则位移折射会在圆形边缘撕出锯齿(对应 changelog #16/#17 的”边缘圆环柔化”与”边缘抗锯齿”)。

还有一个值得注意的容错细节:基础类里 backdrop-filter:url(#liquid-card)global.css:37)引用的 #liquid-cardLiquidFilters.astro并不存在——浏览器对无效滤镜引用会静默按”无滤镜”处理,然后被光学 pass 显式覆盖为 none。它不是 bug,而是分层设计的产物:基础类定义材质,光学 pass 接管渲染策略。

四、交互层:让玻璃”活”起来

指针光学追踪

SiteScript.astrooptical 循环(SiteScript.astro:14-21)给每个液态控件绑定 pointermove,把指针相对元素的坐标写进 CSS 变量:

optical.forEach(el=>{
 let frame=0,px=0,py=0;
 el.addEventListener('pointermove',e=>{
  const r=el.getBoundingClientRect();px=e.clientX-r.left;py=e.clientY-r.top;
  if(!frame)frame=requestAnimationFrame(()=>{el.style.setProperty('--lx',px+'px');el.style.setProperty('--ly',py+'px');frame=0});
 },{passive:true});
});

两个细节:坐标是相对元素的(getBoundingClientRect 后减去左上角),这样 radial-gradient 的光斑中心永远对准指针;写入放在 requestAnimationFrame 里节流,指针事件比帧率高,直接写样式变量会做大量无效的样式重算。

镜片拖拽:按住玻璃,折射变强

镜片的拖拽逻辑在 SiteScript.astro:34-99,是全站交互最复杂的一段,拆开看有三个层次:

第一层是标准拖拽骨架pointerdown 记录起点并 setPointerCaptureSiteScript.astro:62),pointermove 里用 clamp 把位移限制在边界内,pointerup/pointercancel 释放。边界计算(bounds():47-58)值得看:左右限制在视口内,下边界是跑马灯的底边——镜片可以在整个 hero 区自由移动,但不会滚进下面的内容区。

第二层是”压力反馈”:按下瞬间把两段位移的 scale 调大(:63-64):

if(circleDisplacement)circleDisplacement.setAttribute('scale','0.138');  // 0.118 → 0.138
if(circleMagnification)circleMagnification.setAttribute('scale','0.118'); // 0.102 → 0.118

注意它直接改的是滤镜节点的 scale 属性——拖拽时折射强度提高约 17%,像真的用手按住玻璃、让它贴得更紧。松手时恢复(:75-76)。折射强度成了拖拽状态的可视化信号,这比改透明度、改颜色高级得多,因为它改变的是玻璃的本质属性。

第三层是阻尼弹簧回弹:86-96)。双击镜片(dblclick:98)或松手后,镜片回到初始位置,但动画曲线不是常见的 easeOutBack,而是一段手写的阻尼弹簧:

const ease=t=>t>=1?1:1-Math.exp(-5.2*t)*Math.cos(8.2*t);

1 - e^(-5.2t)·cos(8.2t) 是欠阻尼弹簧的解析解:首摆大幅过冲、回落、再来一次微摆,560ms 内收敛。注释写得很直白——“比 easeOutBack 更非线性”。配合 prefers-reduced-motion 分支(:85:直接瞬移,不做动画),无障碍也覆盖到了。

边界与窗口的守护

拖拽途中窗口缩放是经典的 bug 来源:resize 时重新计算 baseCenterX/baseCenterY 并把当前位置重新 clamp 进新边界(:99),镜片不会因为窗口变小而”飞出”屏幕。

五、降级与性能纪律

特性检测:不支持就明确降级

backdrop-filterurl() 引用至今不是所有浏览器都支持(Chrome/Edge 支持,Firefox 部分版本不支持)。降级链在 SiteScript.astro:11-12

const supportsSvg=CSS.supports('backdrop-filter','url(#liquid-pill)')||CSS.supports('-webkit-backdrop-filter','url(#liquid-pill)');
if(!supportsSvg)root.classList.add('no-svg-backdrop');

检测的是语法能力而非用户代理——比 navigator.userAgent 判断可靠得多。不支持时给 <html>no-svg-backdrop 类,CSS 侧(global.css:166-172)统一回退:

html.no-svg-backdrop .topbar.liquid-pill,
html.no-svg-backdrop .liquid-button,
html.no-svg-backdrop .lens.liquid-circle{
 backdrop-filter:none;
 background:rgba(247,245,239,.72);
}

注释点明了设计立场:“Unsupported browsers get a crisp translucent control, never a blur fallback”——降级到较实的半透明,绝不退化为高斯模糊。因为模糊正是这套设计要避免的东西,退回去等于承认毛玻璃可行。同时在右下角浮出一条提示条(Backdrop.astro:9global.css:64):“当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。“降级对用户是透明的,但不是无声的。

性能纪律

  • 渲染范围最小化:折射只施加于三个悬浮控件;内容卡片用 contain:layout styleglobal.css:99)切掉布局与样式重算;文章区块 content-visibility:auto:147)跳过视口外的渲染。
  • 动画预算:背景色斑(.blob)带 will-changetranslate,并全部在 prefers-reduced-motion 下关闭(:163);指针追踪只写 CSS 变量,由浏览器决定何时重绘。
  • 静态贴图:位移贴图是内嵌 PNG,没有运行时噪声生成,滤镜的每帧成本只是两次位移重采样。

调参史:从 changelog 里读出的教训

这套效果最昂贵的部分不是代码,是参数。本站 changelog 忠实记录了调参过程,本身就是最好的参数文档:

  • #6 镜片边缘模糊与回弹加强(“回弹”即阻尼弹簧的引入)
  • #7 修复边缘模糊遮罩生成 bug → #8 回退边缘渐变模糊改为统一 2px 模糊
  • #9 镜片模糊 2px → 0.5px,→ #10 0.7px,→ #11 1.1px 定稿
  • #16 镜片边缘圆环柔化、#17 镜片边缘抗锯齿(即 mask 羽化)

三组参数各有一个”甜点”:镜片模糊 1.1px(软化不糊化)、位移 scale 与贴图配套(不同贴图下同样的 scale 观感完全不同,所以贴图要先定、scale 后调)、边缘羽化 1-2%(再宽就吃掉折射效果)。位移折射对参数高度敏感,且参数之间相互耦合——这正是它很少被当作默认方案的原因,也正因为它难,做好之后才有”液态”的质感。

结语:何时该用位移折射

回到开头的问题:毛玻璃与液态玻璃是两种不同的”玻璃”。模糊牺牲信息换干净,位移折射保留信息换真实。本站的选择是:

  1. 信息承载面(文章卡片、区块)用实心半透明——干净优先;
  2. 悬浮控件(顶栏、按钮)用低强度折射——克制的”玻璃感”;
  3. 交互玩具(镜片)用最强折射 + 拖拽 + 回弹——把折射本身变成可玩的对象。

这套实现的成本也很诚实:三个 SVG 滤镜、两张手工贴图、一套指针追踪与拖拽逻辑、一整段调参史,换来的是首页第一眼”这不是毛玻璃”的差异感。如果你的页面只需要一块干净的玻璃,blur(4px) 依然是对的;但如果你想让人注意到玻璃本身,位移折射是值得的——它让背景的每一处细节都变成玻璃的材质证明。

上一篇rRanker 总体架构解析:一个多音游数据聚合应用的设计下一篇渲染测试:字体、排版与 Markdown 元素