动机

等高线背景(上一篇)用了一阵子,想换个气质。SPlayer-Next 这个播放器的界面一直让我印象很深:专辑封面铺开当背景,大片模糊,卡片浮在毛玻璃上。博客没有封面可以借,那就自己生成一张。

这次的需求清单:

  • 每次访问随机,窗口缩放时保持同一张图(沿用种子方案)
  • 色调限定深灰、藏青,气质要沉
  • 正文对比度有硬指标:文字落在画布上最差也要过 AA(4.5:1)
  • 亮暗主题都要成立,切换时背景整幅跟着换
  • 光标要有反馈,但要克制
  • 依旧零依赖、单文件

方案:自己生成一张「封面」

播放器的模糊背景 = 封面大图 + blur。博客没有现成大图,逐像素生成一整屏再模糊又太重。实际用的是一条廉价的「模糊链」:

低分辨率 canvas(视口的 1/4)→ CSS 拉伸放大 → filter: blur(48px) → 固定底层
                                   ↑ 上采样自带柔焦      ↑ 抹平最后的痕迹
做法像素量模糊代价
逐像素生成整屏 + 模糊~130 万每帧全屏运算
1/4 分辨率生成 + 拉伸 + blur~8 万低一个数量级

拉伸放大本身就是一次免费的模糊,云雾感反而比精确计算更好。

生成:种子 → 噪声 → 光斑

随机部分原封不动搬自等高线那篇:mulberry32 种子随机 + simplex 噪声,Date.now() ^ Math.random() 取种子,缩放重绘不换图。

先离线生成一张静态「底纹」:每个像素上采 4 层 fBm,存进 Float32Array,之后每帧只查表不再重算。然后铺 7 团光斑,圆心、半径、摆动幅度、相位全部随机,每团再抽一个 0~1 的「色相倾向」:

1
2
3
4
5
6
7
var hue = Math.max(0, Math.min(1, rand() * 1.4 - 0.2)); // 0 偏灰、1 偏藏青
blobs.push({
  cx: rand() * w,  cy: rand() * h,
  r: (0.16 + 0.10 * rand()) * Math.max(w, h),
  str: 0.7 + 0.6 * rand(), // 强度
  hue: hue
});

绘制时每像素对 7 团光斑做二次曲线衰减的加权平均,颜色在「灰底 ↔ 藏青」之间按各团倾向调和,再用静态噪声场调制亮度,叠出云絮纹理:

1
2
var m = 1 + field[yy * w + xx] * (variant === 'light' ? 0.35 : 0.55);
col = [col[0] * m, col[1] * m, col[2] * m];

亮色模式下调制幅度几乎减半(0.35):亮色下正文是深色字、直接压在画布上,云不能太深。

最后把「边缘压暗」直接画进像素里——从中心向外线性混向深色,给玻璃卡留出高对比区域,省掉一层独立的遮罩 DOM。

光标跟随:目标点 + 帧率无关缓动

pointermove 只更新目标点,真正绘制放在 rAF 里做指数趋近:

1
2
3
4
5
6
7
8
9
function frame(now) {
  var dt = Math.min(64, now - lastT);
  var k = 1 - Math.pow(0.0002, dt / 1000); // 帧率无关,60Hz 和 144Hz 手感一致
  cur.x += (ack.x - cur.x) * k;
  if (moving) {
    paint(now / 1000, cur.x - 0.5, cur.y - 0.5, variantNow());
    raf = requestAnimationFrame(frame);
  }
}

三段行为:鼠标在窗口内,光斑向光标方向缓慢牵引,叠一层贴着光标走的柔光晕,玻璃卡背后有淡淡的蓝光流动;鼠标离开窗口,回到一个很慢的自动漂移(两个不同频率的正弦叠加);prefers-reduced-motion 只关掉自动漂移和初始漂移这类自发动画,用户自己移动光标触发的跟随保留——不少桌面环境关掉系统动画后这个媒体查询就是 reduce,全关掉的话效果会完全消失。

一个值得记下来的坑:第一版的首次绘制依赖 rAF 循环,而循环要等第一次 pointermove 才启动——触屏设备和没有鼠标事件的浏览器里,背景就是一张透明图。修法是 init 里无条件先画一帧,动画才交给循环。

主题切换:监听 data-theme

PaperMod 切主题只是改 html[data-theme],CSS 变量立即生效,但烤进 canvas 里的颜色不会自己变。补一个观察器:

1
2
new MutationObserver(function () { repaint(); })
  .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

这里有个真实的翻车记录:观察器最初挂在动效初始化的后面,而 prefers-reduced-motion 的用户会提前 return——结果开了「减少动效」的人一换主题,面板全变了、背景纹丝不动。教训是:无论动效偏好如何,「背景跟随主题」这件事都必须先挂上。现在主题观察器和 resize 监听都在动效分支之前。

玻璃层与降级链

CSS 端定义两套变量(:root 默认暗色,:root[data-theme="light"] 覆盖),玻璃容器三件套:半透明底 + backdrop-filter: blur() + 内外阴影。

玻璃只给该给的东西:首页列表卡、顶栏、页脚、目录卡、标签 chip、上下篇导航;文章正文、归档、标签、搜索页保持透明,直接浮在噪声场上。范围用一条 :has() 收窄——首页 main 的特征是有一个 .first-entry:

1
body.list .main:has(> .first-entry) > *:not(.first-entry) { /* 玻璃 */ }

对比度用数字验收:对生成结果做像素采样,取最差情况下的对比度——暗色约 6:1,亮色约 5.4:1,都过了 AA。

降级链三层兜底:

场景处理
浏览器不支持 backdrop-filter底色不透明度提到 0.94
用户偏好减少透明度同上,并关掉 blur
打印玻璃全部还原成纸面

成本

  • 代码量:单文件约 355 行,压缩后 5KB,零依赖
  • 首次生成:1440×900 视口下噪声场约 16ms,之后每帧光斑循环约 3ms,动画无压力
  • 触屏设备没有指针事件,永远只有一帧静态图,几乎零开销
  • 无网络请求,构建产物只多一个 JS 文件

小结

和等高线那篇用的是同一套噪声工具箱(mulberry32 + simplex + fBm),只是渲染方式换了:等高线是「从场里抽线」,这次是「把场直接画出来」。生成艺术的底层数学就那几个,变的只是最后一步的呈现。

本文背景的完整实现是 assets/js/noise-glass-bg.js(噪声场与交互)和 assets/css/extended/noise-glass-bg.css(玻璃层样式),欢迎自取魔改。