航点 09 技术笔记 天气 · 转晴

这张地图是怎么画出来的

关于这台仪器的技术笔记:单纯形噪声、被扭弯的地形、着色器画出的等高线,以及构建时运行的行进方块算法。

这个网站上的每一张地图都是虚构的。没有卫星数据,线条背后也没有真实的山——只有几百行代码,从数字里长出地形。下面是关于它怎么运作的技术笔记,写给喜欢知道仪器原理的人。

地面:一层层叠起来的噪声

一切都从单纯形噪声(simplex noise)开始:一个函数,给它一个位置,它返回一个 −1 到 1 之间、平滑而看似随机的高度。相邻的位置会返回相近的值,所以看起来像起伏的地面,而不是电视雪花。

只有一层噪声太平滑了——看起来像沙丘。真实的地形是大山上有小山,小山上还有石头。所以把噪声和自己叠加四次,每一层频率翻倍、强度减半。这叫做”分形布朗运动”(fBm):

float fbm(vec2 p) {
  float amp = 0.5, sum = 0.0, norm = 0.0;
  for (int i = 0; i < 4; i++) {
    sum  += amp * snoise(p + vec2(17.3, -9.1) * float(i));
    norm += amp;
    amp  *= 0.5;   // 每一层强度减半……
    p    *= 2.03;  // ……细节大约翻倍
  }
  return 0.5 + 0.5 * sum / norm;  // 0..1
}

那些奇怪的偏移量,以及用 2.03 而不是正好 2,是为了让各层不要彼此对齐——对齐了就会露出网格状的破绽。

把地面扭弯

单纯的分形噪声看起来还是有点太规整,像一座耙得很整齐的枯山水庭院。办法是域扭曲(domain warping):在某个点采样地形之前,先用另一个噪声场把这个点往旁边推一推。山脊被拖成弧线,山谷被拖成蜿蜒的河道。

float terrain(vec2 p, float t) {
  vec2 warp = vec2(snoise(p * 0.55 + vec2(t * 0.01, 3.1)),
                   snoise(p * 0.55 + vec2(7.7, -t * 0.012)));
  return fbm(p + 0.22 * warp);
}

里面的 t 是时间。它变得非常慢,所以背景里的大地一直在漂移——慢到察觉不到,像山上的天气。

用着色器画等高线

背景是由片元着色器画的,它在每一帧里,为每一个像素各运行一次。每个像素知道自己的高度,却对邻居一无所知,所以没法像用笔那样描一条线。诀窍是换一个问题:我离等高线有多近?

把高度乘以等高线的条数。每当这个值接近一个整数,你就在某条线附近。fwidth() 告诉你这个值在相邻像素之间变化得多快,于是可以把”接近”换算成以像素为单位的精确距离——这样,无论陡坡还是缓坡,线条的宽度都一样。

float v  = h * uLevels;             // 以"等高距"为单位的高度
float fw = max(fwidth(v), 1e-4);    // v 每个像素变化多少
float d  = min(fract(v), 1.0 - fract(v)) / fw;  // 到最近一条线的距离(像素)
float isIndex = 1.0 - step(0.5, mod(floor(v + 0.5), 5.0));  // 每第五条
float halfW   = mix(0.5, 0.95, isIndex) * uDpr;
float line    = 1.0 - smoothstep(halfW, halfW + 1.0, d);    // 抗锯齿

每第五条线是计曲线,画得粗一点,和印刷的地图一模一样。

光标下的那盏灯

当你移动鼠标,着色器会收到它的位置,并在周围加上一圈柔和的高斯光晕。光晕里的线会变亮,并偏向琥珀色,就像你提着一盏灯照在图纸上。光晕跟着光标走时会稍微慢半拍(指数缓动),所以感觉是被”提着”的,而不是粘在光标上。

天气

每一篇手记都设定了一种天气,背景会在几秒内慢慢过渡过去:

  • 雾是第二个、更慢的噪声场。它高的地方,线条变淡,地面被洗成一片浅灰。
  • 雨是一组细细的斜线。屏幕被分成很窄的竖条,由一个哈希函数决定:每一条、每一刻,那里有没有一滴雨在落。
  • 暮色和转晴改变的是灯光的暖度和线条的浓淡。

落在地面上的光

等高线只是描述地面,明暗才让你感觉到它。着色器会看相邻像素之间高度变化得有多陡(又是 dFdx 和 dFdy),把这个坡度变成一个表面法线,再拿它和一束从西北方向照来的光作比较——这是印刷地图沿用了几百年的惯例。朝着光的坡亮一点点,背着光的坡暗一点点。它非常淡,应该读起来像地势,而不是像天气。

投下一颗石子

点击空白的地面,会激起一圈涟漪:一个往外扩散的环,在经过的地方短暂地抬高、压低地面本身,于是等高线会绕着它弯曲,像水塘里的倒影那样。最多可以有四圈涟漪同时叠在一起。你点下去的地方会出现一个小小的测点,写着那里的海拔——这个数字是 JavaScript 用和着色器同一套噪声算出来的。

第一次来到这里时,整张地图也是这样“测绘”出来的:线条从一个点向外铺开,前面是一道很淡的琥珀色边缘。

折起来的地图

按下 M,会展开一张画在 canvas 上的路线图。它的地形不是随便来的:每一个航点都会把附近的地面拉向自己的海拔(反距离加权插值),所以这条路真的会一路下到一片洼地,再爬出来。地图按高度上色,用同一束西北方向的光来打明暗;它还记得你读过哪些手记,走过的那段路会画成实线。

构建时运行的行进方块

卡片上的小地图不一样:它们只在网站构建时画一次,然后作为普通的 SVG 发布。每一张,构建程序都会在网格上采样一块新的地形,然后为每一个高度运行行进方块算法——也就是那篇可视化手记里讲的那个。小线段被缝合成线,再被简化(几乎落在一条直线上的点会被去掉),然后平滑成曲线。

每一块地形的随机种子,都来自这篇手记网址的哈希值。所以每篇手记都有自己的地貌,而且每次构建出来,都是同一片地貌。

对设备温柔一点

一个全屏着色器,可能会让笔记本的风扇转起来。有几件事让它保持安静:

  • 分辨率有上限;如果帧开始变慢,会自动再降低一些。
  • 当除了缓慢漂移以外没有任何东西在动时,它每秒只画大约 24 帧,而不是 60 帧。
  • 标签页被隐藏时,它完全停下。
  • 如果你在系统设置里打开了”减少动态效果”,地图会静止,雨会消失,文字也不再逐字浮现。
  • 没有 WebGL 时,会改用普通的 canvas,用和卡片相同的行进方块算法,画出静止的等高线。

这里没有的东西

没有统计,没有 Cookie,没有追踪像素,也不从别人的服务器加载任何字体或脚本。这个网站唯一会记住的东西——你的主题、语言、天气记录,以及你读过哪些航点——都保存在你自己浏览器的存储里,你随时可以清除。环境声音和那篇关于雨的手记一样,是当场合成的,不下载任何东西。