动机

一直觉得静态博客的纯色背景有点单调,想给它加一点"地图感":经典地形图上那种一层层的等高线,作为背景若隐若现。同时有几个硬性要求:

  • 矢量图(SVG),任意分辨率都清晰
  • 每次访问都随机,每张地图都不一样
  • 低对比度,只做氛围,不能影响正文阅读
  • 尽量零依赖,单文件搞定

最终效果可以在本站直接看到——你现在的这个页面背后,就是一张刚刚为你随机生成的等高线图。

方案选择

“每次访问都随机"是关键约束。静态站点没有服务端,可选的路只有两条:

方案 做法 随机性
客户端实时生成 浏览器里用 JS 生成噪声地形,实时提取等高线 无限变化,真·每访问一次换一张
预生成 SVG 池 构建时生成 N 张 SVG,访问时随机挑一张 只在固定几张里轮换

我选了客户端实时生成。听起来重,其实核心算法只有两个:噪声场 + marching squares,加起来一百多行。

核心原理

整个流程分四步:

随机种子 → 噪声高度场 → 等高线提取 → SVG 渲染

1. 种子随机数:mulberry32

先造一个可复现的随机数生成器。等高线背景在窗口缩放时需要重绘,但不应该变成另一张图,所以随机性必须来自一个种子,而不是散装调用 Math.random()

1
2
3
4
5
6
7
8
9
function mulberry32(seed) {
  var s = seed | 0;
  return function () {
    s = (s + 0x6d2b79f5) | 0;
    var t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

页面加载时取一个新种子(Date.now() ^ Math.random()),刷新即换图;resize 重绘时沿用同一种子,地图保持不变。

2. 地形高度场:simplex 噪声 + fBm

地形起伏不能是真随机噪点,要有"连绵的丘陵"感。simplex 噪声正好输出这种平滑的连续值。再把 4 个倍频叠加(fBm),大尺度起伏叠上小尺度细节,就很像真实地貌:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function fbm(noise, x, y) {
  var v = 0, amp = 0.5, freq = 1, sum = 0;
  for (var o = 0; o < 4; o++) {
    v += amp * noise(x * freq, y * freq);
    sum += amp;
    amp *= 0.5;
    freq *= 2;
  }
  return v / sum;
}

在按 9px 间距划分的网格上逐点采样,得到一个高度场数组。最后做两轮 3×3 均值模糊,等高线会更圆润。

3. 等高线提取:marching squares

这是最有趣的一步。对某个海拔值 iso(比如 0.12),逐格检查网格四个角的高度:高于 iso 记 1,低于记 0,四个角组成 16 种情况。每种情况对应等高线穿过这个格子的方式——比如只有左下角高,线就从左边穿入、底边穿出:

1
2
3
4
5
6
7
// 边编号: 0=上 1=右 2=下 3=左
var CASES = {
  1:  [[3, 2]],  2:  [[2, 1]],  3:  [[3, 1]],
  4:  [[0, 1]],  6:  [[0, 2]],  7:  [[0, 3]],
  8:  [[0, 3]],  9:  [[0, 2]],  11: [[0, 1]],
  12: [[3, 1]],  13: [[2, 1]],  14: [[3, 2]]
};

交点的具体位置用线性插值求出,比如上边缘:

1
2
3
if ((tl > iso) !== (tr > iso)) {
  pt[0] = [x + (iso - tl) / (tr - tl), y];
}

5 和 10 两个"对角"情况存在歧义(两个高点在斜对角),用格子中心值决定连线方式即可。把所有格子里的线段拼成一个 path,一级等高线就出来了。取 11 个海拔值重复这个过程,得到 11 层嵌套的等高线。

4. SVG 渲染

每级等高线一个 <path>,全部塞进一个固定定位的 <svg>

1
2
3
4
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + data.width + ' ' + data.height);
svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
svg.setAttribute('aria-hidden', 'true');

slice 模式保证任何窗口比例下地图都铺满屏幕。

样式:低对比度的关键

背景层定位与配色都很简单,但有个 CSS 陷阱:如果 body 有不透明背景,z-index: -1 的子元素会被盖住。好在 body 的背景在 HTML 规范里会传播到画布(canvas)上,所以负 z-index 实际是可见的——这也是很多"背景滤镜"技巧能成立的原因。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#contour-bg {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  pointer-events: none;
}

#contour-bg path {
  fill: none;
  stroke: var(--secondary);
  stroke-width: 0.7;
  opacity: 0.35;
}

#contour-bg path.contour-index {
  stroke-width: 1;
  stroke-dasharray: 5 4;
  opacity: 0.45;
}

两个细节:

  • 颜色直接用主题的 var(--secondary),PaperMod 在亮/暗模式下会重定义这个变量,深浅主题无需额外适配。35% 的透明度让线与背景的对比度远低于正文文字,阅读不受干扰
  • 模仿真实地形图的习惯,每隔 5 级画一条加粗虚线的"计曲线”,其余是细实线

成本

  • 代码量:单文件约 230 行,压缩后 3.5KB,零依赖
  • 耗时:1440×800 视口生成 12 条等高线约 15ms,手机端无压力
  • 无网络请求、无运行时库,构建产物只多一个 JS 文件

小结

噪声场 + marching squares 是一对被游戏和可视化领域用了几十年的老搭档,几十行代码就能做出各种"生成艺术"效果。等高线只是最直接的用法——同样的高度场换个渲染方式,还能做流场图、岛屿生成、地形光照。

本文背景的完整实现在仓库的 assets/js/contour-bg.js,欢迎自取魔改。