LOADING

加载过慢请开启缓存 浏览器默认开启

主页放飞自我的美化 — 液态玻璃卡片 + 3D 倾斜交互

一直觉得博客主页的卡片不够好看了,没有任何交互体验。于是决定把液态玻璃效果移植到个人资料卡片和文章列表上,再加点 3D 倾斜交互,让整页"活"起来。

效果概述

  • 液态玻璃质感:毛玻璃模糊 + WebGL 渲染纹理 + SVG 三层边框

  • 3D 倾斜交互:鼠标在卡片内移动,卡片跟随旋转,光照追踪鼠标位置

  • 内容悬浮:文字与卡片表面产生视差,动态投影强化深度感

  • 模块化封装:JS 自动扫描 data-glass-card 属性初始化

  • 独立参数配置glass-card-config.js 按选择器分别设置参数

文件结构

themes/particlex/
├── source/
│   ├── js/
│   │   ├── glass-card.js          # 核心逻辑(WebGL / SVG 边框 / 动画循环)
│   │   └── glass-card-config.js   # 参数配置(按选择器独立调参)
│   └── css/
│       └── main.css               # 玻璃层样式 + 卡片 3D 变换
└── layout/
    ├── layout.ejs                 # 引入脚本
    ├── card.ejs                   # 个人资料卡片 HTML 结构
    └── posts.ejs                  # 文章卡片 HTML 结构

核心实现

1. HTML 四层结构

每个卡片由四个层叠组成,从下到上依次是:

第一层 — WebGL 玻璃纹理<canvas> 通过 WebGL 片段着色器渲染噪声纹理,使用 mix-blend-mode: screen 叠加到卡片背景上,产生类似液态玻璃的折射效果。Canvas 尺寸比卡片大 40px,避免旋转时边缘露白。

第二层 — SVG 三层边框:用三次贝塞尔曲线(K = 0.5522847498)绘制完美圆角矩形路径,三层分别是:基底描边(rgba(255,255,255,.12))、高光描边(rgba(255,255,255,.35) 带模糊)、发光描边(rgba(255,255,255,.18) 带更大模糊)。SVG 使用 preserveAspectRatio="none" 自适应卡片尺寸。

第三层 — 表面光照glass-card-surface 使用径向渐变模拟光源追踪,中心点通过 CSS 变量 --light-x--light-y 动态移动,配合线性渐变模拟玻璃的斜向光泽。

第四层 — 内容glass-content 包裹原有内容,设置 z-index: 30 确保在最上层。

<div id="card-style" class="glass-card" data-glass-card>
    <!-- 第一层:WebGL Canvas + 表面光 -->
    <div class="glass-card-inner">
        <canvas class="glass-card-canvas"></canvas>
        <div class="glass-card-surface"></div>
    </div>

    <!-- 第二层:SVG 三层边框 -->
    <svg class="glass-card-border" viewBox="0 0 300 500" preserveAspectRatio="none">
        <path class="glass-card-border-base"></path>
        <path class="glass-card-border-highlight"></path>
        <path class="glass-card-border-glow"></path>
    </svg>

    <!-- 第四层:内容 -->
    <div id="card-div" class="glass-content">
        <div class="avatar">...</div>
        <div class="name">...</div>
        ...
    </div>
</div>

所有玻璃层(第一、二、三层)都使用 position: absolute; pointer-events: none,不占用文档流也不拦截鼠标事件。只需给任意元素加 data-glass-card 属性,JS 自动扫描初始化。

2. 初始化流程

页面加载时,glass-card.js 自动扫描所有 [data-glass-card] 元素,对每个执行 initCard

  1. 读取 glass-card-config.js 中的配置,按 CSS 选择器匹配当前卡片,获取对应的 tilt(倾斜幅度)和 lerp(平滑速度)。如果 HTML 元素上有 data-tiltdata-lerp 属性,优先使用 HTML 属性值作为覆盖。

  2. 初始化 CSS 变量 --rx: 0deg--ry: 0deg--light-x: 50%--light-y: 50%,确保卡片初始状态水平。

  3. 绑定三个事件:mouseenter 激活倾斜、mousemove 更新鼠标坐标、mouseleave 关闭倾斜并回弹。进入时清除 CSS transition 让 JS 实时控制,离开时恢复 transition: transform 0.5s ease 让卡片平滑回到原位。

  4. 调用 updateBorder 根据卡片实际尺寸计算 SVG 边框路径,调用 initWebGL 初始化 WebGL 着色器。

3. 倾斜计算

全局鼠标位置经过平滑处理(mouseX += (targetMouseX - mouseX) * 0.12),避免抖动。每帧动画中,计算鼠标在卡片内的相对坐标(-1 到 1),映射为旋转角度:

var centerX = rect.left + rect.width / 2;
var centerY = rect.top + rect.height / 2;

var nx = (mouseX - centerX) / (rect.width / 2);
var ny = (mouseY - centerY) / (rect.height / 2);

nx = Math.max(-1, Math.min(1, nx));
ny = Math.max(-1, Math.min(1, ny));

d.targetRx = -ny * d.tilt;  // 鼠标在上方,卡片向后仰
d.targetRy =  nx * d.tilt;  // 鼠标在右侧,卡片向右转

targetRxtargetRy 是目标角度,通过平滑插值逐步逼近:

d.rx += (d.targetRx - d.rx) * d.lerp;
d.ry += (d.targetRy - d.ry) * d.lerp;

lerp 越大响应越快,越小越迟缓。计算出的角度写入 CSS 变量:

d.el.style.setProperty("--rx", d.rx + "deg");
d.el.style.setProperty("--ry", d.ry + "deg");

CSS 消费这些变量驱动 3D 旋转变换:

.card {
  --rx: 0deg;
  --ry: 0deg;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
  transform-style: preserve-3d;
  will-change: transform;
}

4. 光照追踪

光照坐标同样映射到 0~100% 范围,写入 --light-x--light-yglass-card-surface 的径向渐变中心点由这两个变量控制:

d.targetLx = ((mouseX - rect.left) / rect.width) * 100;
d.targetLy = ((mouseY - rect.top)  / rect.height) * 100;

d.lx += (d.targetLx - d.lx) * d.lerp;
d.ly += (d.targetLy - d.ly) * d.lerp;

d.el.style.setProperty("--light-x", d.lx + "%");
d.el.style.setProperty("--light-y", d.ly + "%");

CSS 中径向渐变实时跟随:

.glass-card-surface {
  background: radial-gradient(
    140px circle at var(--light-x) var(--light-y),
    rgba(255,255,255,.14),
    rgba(255,255,255,.025) 30%,
    transparent 65%
  );
}

5. 内容悬浮

这是调试最久的部分。最初尝试用 translateZ 把文字层推离卡片表面,利用 preserve-3d 实现真正的 3D 深度。但 backdrop-filter 会强制扁平化 3D 子元素,translateZ 完全不生效。filter: drop-shadow() 也试过,同样会破坏 3D 渲染上下文。

最终方案:放弃 Z 轴,改用 X/Y 平面偏移 + 动态 textShadow 来模拟悬浮。文字随卡片倾斜反向滑动(卡片向右倾,文字向左滑),阴影也跟随偏移方向:

// 按卡片宽度等比缩放,sqrt 避免宽卡片偏移过大
var scale = Math.sqrt(rect.width / 300);

// 水平偏移:卡片绕 Y 轴旋转,文字反向移动
var px = -d.ry * 1.0 * scale;
// 垂直偏移:卡片绕 X 轴旋转,文字反向移动
var py =  d.rx * 0.9 * scale;

// 阴影偏移:跟随文字位移方向
var sx = d.ry * 0.6 * scale;
var sy = -d.rx * 0.6 * scale;

d.content.style.transform =
    "translate(" + px + "px, " + py + "px)";
d.content.style.textShadow =
    sx + "px " + sy + "px 4px rgba(0,0,0,0.22)";

300px 宽的侧边栏 scale = 1.0,800px 宽的卡片 scale ≈ 1.63,视觉效果统一。

6. 配置文件

window.GlassCardConfig = {
    tilt: 18,          // 默认倾斜幅度(度)
    lerp: 0.18,        // 默认平滑插值速度(0~1)
    mouseSmooth: 0.12, // 全局鼠标平滑

    cards: {
        "#card-style": { tilt: 18, lerp: 0.18 },  // 个人资料
        ".post":       { tilt: 25, lerp: 0.15 }   // 文章卡片
    }
};

优先级:HTML data-tilt / data-lerp 属性 > 配置文件 cards 匹配 > 配置文件全局默认值 > 硬编码兜底值。新增卡片只需在 cards 里加一条选择器 + HTML 加 data-glass-card 即可。

踩坑记录

问题 原因 解决
卡片内容消失 玻璃层没有绝对定位,在正常文档流中把内容挤出去了 所有玻璃层设 position: absolute; pointer-events: none
倾斜效果不明显 初始角度只有 4°,CSS transition: transform 0.3s 拖慢 JS 动画 增大到 18°~25°,去掉 transform 的 transition
文章卡片倾斜无效 .post 缺少 --rx --ry 变量和 transform 声明 补全 CSS 变量和 rotateX/rotateY 变换
文字悬浮完全无效 filter: drop-shadow()translateZ 都被 backdrop-filter 扁平化 改用 X/Y 偏移 + JS 动态 textShadow
文章卡片悬浮弱 宽卡片上像素偏移占比太小 引入 sqrt(宽度/300) 按宽度等比放大
四角文字被裁切 overflow: hidden + 位移过大 平方根缩放替代线性缩放,控制位移范围

总结

从一张 2500 行的参考 HTML 出发,提取核心逻辑封装为可复用模块。遇到的主要问题都是 CSS 属性互相打架:backdrop-filter 扁平化 3D、transition 和 JS 动画冲突、filter 破坏渲染上下文。最终效果:鼠标划过卡片时有液态玻璃的折射光泽 + 3D 倾斜 + 文字悬浮,交互感拉满。

PS: 懒得写文章,就用 AI 写了一篇记录一下