2026-10-04 更新:本站背景已换成 SPlayer-Next 风格的「噪声毛玻璃」效果,见《给博客换一个 SPlayer 风格的噪声毛玻璃背景》。等高线版本功成身退,完整实现保留在仓库 backup/contour-bg 分支的 assets/js/contour-bg.js,检出即可直接使用。下文按当时的站点状态描述。

动机

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

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

本文背景的完整实现见仓库 backup/contour-bg 分支的 assets/js/contour-bg.js,欢迎自取魔改;现役的毛玻璃背景则是 assets/js/noise-glass-bg.js。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
(function () {
  'use strict';

  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;
    };
  }

  function makeNoise2D(rand) {
    var grad = [[1, 1], [-1, 1], [1, -1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]];
    var p = new Uint8Array(256);
    for (var i = 0; i < 256; i++) p[i] = i;
    for (i = 255; i > 0; i--) {
      var n = Math.floor(rand() * (i + 1));
      var q = p[i];
      p[i] = p[n];
      p[n] = q;
    }
    var perm = new Uint8Array(512);
    for (i = 0; i < 512; i++) perm[i] = p[i & 255];
    var F2 = 0.5 * (Math.sqrt(3) - 1);
    var G2 = (3 - Math.sqrt(3)) / 6;
    return function (xin, yin) {
      var s = (xin + yin) * F2;
      var i0 = Math.floor(xin + s);
      var j0 = Math.floor(yin + s);
      var t = (i0 + j0) * G2;
      var x0 = xin - (i0 - t);
      var y0 = yin - (j0 - t);
      var i1, j1;
      if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; }
      var x1 = x0 - i1 + G2;
      var y1 = y0 - j1 + G2;
      var x2 = x0 - 1 + 2 * G2;
      var y2 = y0 - 1 + 2 * G2;
      var ii = i0 & 255;
      var jj = j0 & 255;
      var n0 = 0, n1 = 0, n2 = 0;
      var t0 = 0.5 - x0 * x0 - y0 * y0;
      if (t0 >= 0) {
        var g0 = grad[perm[ii + perm[jj]] & 7];
        t0 *= t0;
        n0 = t0 * t0 * (g0[0] * x0 + g0[1] * y0);
      }
      var t1 = 0.5 - x1 * x1 - y1 * y1;
      if (t1 >= 0) {
        var g1 = grad[perm[ii + i1 + perm[jj + j1]] & 7];
        t1 *= t1;
        n1 = t1 * t1 * (g1[0] * x1 + g1[1] * y1);
      }
      var t2 = 0.5 - x2 * x2 - y2 * y2;
      if (t2 >= 0) {
        var g2 = grad[perm[ii + 1 + perm[jj + 1]] & 7];
        t2 *= t2;
        n2 = t2 * t2 * (g2[0] * x2 + g2[1] * y2);
      }
      return 70 * (n0 + n1 + n2);
    };
  }

  function fbm(noise, x, y) {
    var v = 0;
    var amp = 0.5;
    var freq = 1;
    var 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;
  }

  function smooth(src, cols, rows, passes) {
    var dst = new Float32Array(src.length);
    for (var p = 0; p < passes; p++) {
      for (var y = 0; y < rows; y++) {
        for (var x = 0; x < cols; x++) {
          var sum = 0;
          var n = 0;
          for (var dy = -1; dy <= 1; dy++) {
            for (var dx = -1; dx <= 1; dx++) {
              var nx = x + dx;
              var ny = y + dy;
              if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
                sum += src[ny * cols + nx];
                n++;
              }
            }
          }
          dst[y * cols + x] = sum / n;
        }
      }
      var tmp = src;
      src = dst;
      dst = tmp;
    }
    return src;
  }

  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]]
  };

  function contourSegments(field, cols, rows, iso) {
    var segs = [];
    for (var y = 0; y < rows - 1; y++) {
      for (var x = 0; x < cols - 1; x++) {
        var tl = field[y * cols + x];
        var tr = field[y * cols + x + 1];
        var br = field[(y + 1) * cols + x + 1];
        var bl = field[(y + 1) * cols + x];
        var idx = (tl > iso ? 8 : 0) | (tr > iso ? 4 : 0) | (br > iso ? 2 : 0) | (bl > iso ? 1 : 0);
        if (idx === 0 || idx === 15) continue;
        var pt = [null, null, null, null];
        if ((tl > iso) !== (tr > iso)) pt[0] = [x + (iso - tl) / (tr - tl), y];
        if ((tr > iso) !== (br > iso)) pt[1] = [x + 1, y + (iso - tr) / (br - tr)];
        if ((bl > iso) !== (br > iso)) pt[2] = [x + (iso - bl) / (br - bl), y + 1];
        if ((tl > iso) !== (bl > iso)) pt[3] = [x, y + (iso - tl) / (bl - tl)];
        var pairs;
        if (idx === 5 || idx === 10) {
          var center = (tl + tr + br + bl) / 4;
          if (idx === 5) pairs = (center > iso) ? [[0, 3], [1, 2]] : [[0, 1], [2, 3]];
          else pairs = (center > iso) ? [[0, 1], [2, 3]] : [[0, 3], [1, 2]];
        } else {
          pairs = CASES[idx];
        }
        for (var k = 0; k < pairs.length; k++) {
          var a = pt[pairs[k][0]];
          var b = pt[pairs[k][1]];
          if (a && b) segs.push(a, b);
        }
      }
    }
    return segs;
  }

  function generateContours(width, height, seed) {
    var CELL = 9;
    var cols = Math.max(2, Math.ceil(width / CELL) + 1);
    var rows = Math.max(2, Math.ceil(height / CELL) + 1);
    var rand = mulberry32(seed);
    var noise = makeNoise2D(rand);
    var field = new Float32Array(cols * rows);
    var freq = 0.0016;
    for (var y = 0; y < rows; y++) {
      for (var x = 0; x < cols; x++) {
        field[y * cols + x] = fbm(noise, x * CELL * freq, y * CELL * freq);
      }
    }
    field = smooth(field, cols, rows, 2);
    var levels = [];
    for (var l = -6; l <= 6; l++) {
      if (l === 0) continue;
      levels.push(l * 0.09);
    }
    var paths = [];
    for (var i = 0; i < levels.length; i++) {
      var segs = contourSegments(field, cols, rows, levels[i]);
      if (segs.length < 8) continue;
      var d = '';
      for (var s = 0; s < segs.length; s += 2) {
        d += 'M' + (segs[s][0] * CELL).toFixed(1) + ' ' + (segs[s][1] * CELL).toFixed(1) +
          'L' + (segs[s + 1][0] * CELL).toFixed(1) + ' ' + (segs[s + 1][1] * CELL).toFixed(1);
      }
      paths.push({ d: d, index: i % 5 === 0 });
    }
    return { width: cols * CELL, height: rows * CELL, paths: paths };
  }

  var SVG_NS = 'http://www.w3.org/2000/svg';

  function buildSVG(data) {
    var svg = document.createElementNS(SVG_NS, 'svg');
    svg.id = 'contour-bg';
    svg.setAttribute('viewBox', '0 0 ' + data.width + ' ' + data.height);
    svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('focusable', 'false');
    for (var i = 0; i < data.paths.length; i++) {
      var p = document.createElementNS(SVG_NS, 'path');
      p.setAttribute('d', data.paths[i].d);
      if (data.paths[i].index) p.setAttribute('class', 'contour-index');
      svg.appendChild(p);
    }
    return svg;
  }

  function init() {
    if (document.getElementById('contour-bg')) return;
    var seed = (Date.now() ^ ((Math.random() * 0xffffffff) | 0)) >>> 0;
    var svg = null;
    var timer = 0;
    function draw() {
      var data = generateContours(window.innerWidth, window.innerHeight, seed);
      var next = buildSVG(data);
      if (svg && svg.parentNode) svg.parentNode.replaceChild(next, svg);
      svg = next;
      document.body.appendChild(svg);
    }
    draw();
    window.addEventListener('resize', function () {
      clearTimeout(timer);
      timer = setTimeout(draw, 200);
    });
  }

  if (typeof document !== 'undefined' && typeof window !== 'undefined') {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  }

  if (typeof module !== 'undefined' && module.exports) {
    module.exports = { generateContours: generateContours };
  }
})();