液态玻璃实现解析:用 SVG 位移折射替代毛玻璃模糊
引言:从毛玻璃到液态玻璃
“玻璃拟态”(glassmorphism)在 CSS 里的标准做法是 backdrop-filter: blur()——把背景高斯模糊一层,再叠半透明填充。效果稳定、代码短,所以到处都是。但它有个视觉问题:模糊让背景”看不清”,而真实的玻璃让背景”看错”。隔着毛玻璃你只能看到一个模糊的色块;隔着清水玻璃,你能看清后面的东西,只是边缘会随玻璃表面微微扭曲。
本站首页的”液态玻璃”就是这个思路的激进版本:顶栏、按钮、主页那颗可以拖动的镜片,背后的图案都会发生位移折射——像透过一块真实的玻璃去看。这不是模糊的替代品,而是对 backdrop-filter 的完全不同的用法:用 SVG 的 feDisplacementMap 做像素位移,而不是用高斯模糊抹平背景。
本文以本站代码为据(src/components/LiquidFilters.astro、src/styles/global.css、src/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>
管道很短,四个节点各司其职:
feImage:把一张 base64 编码的 PNG 作为位移贴图载入。PNG 的 R 通道与 G 通道就是 x 轴、y 轴的位移向量场——每个像素的颜色值决定了该处背景被推多远、往哪推。随机噪点贴图 = 随机方向的折射。feGaussianBlur(stdDeviation="0.0012"):注意这个值小到可以忽略——它不是毛玻璃的模糊,只是给原始图像做亚像素级的柔化,防止位移重采样时出现锯齿。整个实现里没有任何一处用高斯模糊来制造”磨砂”。feDisplacementMap:核心节点。in是被折射的背景,in2是位移贴图,scale是位移幅度。primitiveUnits="objectBoundingBox"下 scale 是相对滤镜区域的归一化比例,0.044意味着像素最多被推离原位约 2%。feColorMatrix(saturate 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.040 | 1.035 | 无 |
liquid-button | 按钮 | 0.044 | 1.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)再叠加随机噪声折射,模拟玻璃表面的细微起伏。两个 id(zoom-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 提到 .92(global.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-card 在 LiquidFilters.astro 里并不存在——浏览器对无效滤镜引用会静默按”无滤镜”处理,然后被光学 pass 显式覆盖为 none。它不是 bug,而是分层设计的产物:基础类定义材质,光学 pass 接管渲染策略。
四、交互层:让玻璃”活”起来
指针光学追踪
SiteScript.astro 的 optical 循环(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 记录起点并 setPointerCapture(SiteScript.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-filter 的 url() 引用至今不是所有浏览器都支持(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:9、global.css:64):“当前浏览器不支持 SVG backdrop-filter 位移折射;页面会自动使用较实的半透明材质。“降级对用户是透明的,但不是无声的。
性能纪律
- 渲染范围最小化:折射只施加于三个悬浮控件;内容卡片用
contain:layout style(global.css:99)切掉布局与样式重算;文章区块content-visibility:auto(:147)跳过视口外的渲染。 - 动画预算:背景色斑(
.blob)带will-change与translate,并全部在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%(再宽就吃掉折射效果)。位移折射对参数高度敏感,且参数之间相互耦合——这正是它很少被当作默认方案的原因,也正因为它难,做好之后才有”液态”的质感。
结语:何时该用位移折射
回到开头的问题:毛玻璃与液态玻璃是两种不同的”玻璃”。模糊牺牲信息换干净,位移折射保留信息换真实。本站的选择是:
- 信息承载面(文章卡片、区块)用实心半透明——干净优先;
- 悬浮控件(顶栏、按钮)用低强度折射——克制的”玻璃感”;
- 交互玩具(镜片)用最强折射 + 拖拽 + 回弹——把折射本身变成可玩的对象。
这套实现的成本也很诚实:三个 SVG 滤镜、两张手工贴图、一套指针追踪与拖拽逻辑、一整段调参史,换来的是首页第一眼”这不是毛玻璃”的差异感。如果你的页面只需要一块干净的玻璃,blur(4px) 依然是对的;但如果你想让人注意到玻璃本身,位移折射是值得的——它让背景的每一处细节都变成玻璃的材质证明。
