在真实地图上构建灾害模拟:用 Mapbox GL JS 与 Three.js 实现动画 Canvas 图层与 3D 建筑倒塌
做到一定程度,地图项目光靠标注和多边形就不够了。你希望地图上发生点什么:水流漫上街道,烟雾随风飘散,或者冲击波从某一点向外扩散。
第一次尝试时,我做了件当时看来再自然不过的事:在地图上方铺一个 <canvas>,把动画画上去。
但效果就是不对劲,怎么打磨都对不上。动画有明显的边界,悬浮在地面之上。一旦倾斜或旋转地图,它就和底下的内容彻底错位。
我是在做 HazardSim 时遇到这个问题的——一个能在真实地图上模拟自然灾害的浏览器应用。海啸场景前后改了一二十次,才终于摆脱那种贴纸贴在城市上般的廉价感。
大量的测试和不少 bug 反馈来自我 12 岁的儿子 Tim,他比我更有耐心。那些反复重写让我从不同角度悟到同一件事:模拟要在地图坐标系里画,别用屏幕坐标系。
这条原则让我顺利搞定了海啸。接着做地震时,我撞上了反向的问题。Mapbox GL JS 能自动把城市渲染成 3D:打开一个 fill-extrusion 图层,矢量瓦片里每栋建筑的 footprint 都会按真实高度升起。
看起来很棒,直到你想让其中一栋楼倒掉,然后发现地图的 3D 只是一种渲染技巧,并非一个场景。这里没有可抓取、旋转或破坏的对象。贴地和立体需要两种不同的技术,本文会覆盖这两种做法。
第一部分构建地面图层。你会学到:搭建一个与地图瓦片对齐的网格,加载真实高程数据,运行一个能识别山丘和低谷的 flood fill 算法,把 canvas 作为 raster source 交给地图,每帧写入像素,用 canvas 合成模式叠加视频纹理,以及把镜头推进场景。
第二部分加入高度。你会从地图瓦片中提取建筑轮廓和高度,让拉伸出的体块看起来像真正的建筑,根据震级和距离判断哪些建筑会倒塌,在不重建任何图层的前提下做倒塌动画,在地图自身的 WebGL 上下文里搭建 three.js 自定义图层,把建筑轮廓变成一个会倾倒的盒子,抛出碎块和扬尘,并让镜头晃动。
最后我们会复盘两部分的踩坑经历,其中好几个错误没有任何报错提示,每一个都让我耗时一整天。
前置知识
你需要熟悉 JavaScript、requestAnimationFrame,以及 2D canvas API 的基础:getContext("2d")、drawImage 和 ImageData。
不需要任何 Web 地图背景,所有地图相关的知识都会在用到时讲解。
第二部分还假设你至少用过一次 three.js:mesh、material、scene 和 camera。如果没有,了解 three.js 基础概念即可跟上,与地图交互的部分会完整讲解。
示例使用 Mapbox GL JS,也就是 HazardSim 实际运行的框架。你需要一个免费的 Mapbox access token 来加载底图和矢量瓦片。文中用到的所有 API 在开源分支 MapLibre GL JS 中也同样存在,名称完全一致。两者唯一的差异会在第二部分特别指出。
目录
第一部分:在地表绘制
本指南的前半部分涉及所有属于地图表面的元素:水、火势蔓延、烟雾痕迹或冲击波。核心技法是将一个概念精细地应用,接下来的七个步骤将从网格逐步构建至镜头移动。
核心理念:地图空间而非屏幕空间
Web 地图本质上就是相机对着一个表面进行拍摄。当用户拖动、缩放、倾斜或旋转视角时,相机随之移动,而表面保持静止。道路、建筑以及洪水区域都位于这个表面上,因此相机必须同步变换这些元素。
放置在地图 DOM 元素上方的 Canvas 是绑定在相机上的,而非绑定在表面之上。你可以把它想象成镜头前的一块玻璃板。无论你在上面绘制什么,它都会停留在原处,而背后的世界则在不断移动。
在俯视且无旋转的缩放状态下,你可以通过每帧重新投影坐标来勉强保持对齐,这种方式在大多数情况下有效。但一旦加入倾斜角度,问题就暴露了:扁平的图形(sprite)没有透视效果,没有近大远小的形变,也没有遮挡关系。
解决方案是将 Canvas 作为数据源交给地图。Mapbox GL JS 提供了一种 canvas 源类型。你只需传入一个 Canvas 元素和四个地理角点,此后地图就会像处理卫星图像一样,将该 Canvas 铺设在这个矩形区域上。旋转地图时,Canvas 随之旋转;倾斜地图时,Canvas 会产生透视缩短;启用 3D 地形后,Canvas 还会贴合山丘起伏。
一旦绘图表面存在于地图坐标系中,许多棘手的问题便迎刃而解。接下来的章节将详细讲解如何利用这一特性。
第一步:选择与地图瓦片对齐的网格
模拟需要一个网格:一个由单元格组成的二维数组,每个单元格存储高程、水深或模型追踪的其他数据。为了让后续工作更简单,关键在于让该网格与地图自身的瓦片金字塔结构对齐。
Web 地图采用 Web Mercator 投影,在缩放级别 0 到 22 下被切分成 256×256 像素的瓦片。在缩放级别 z,整个世界宽度为 2^z 个瓦片。如果让网格的一个格子恰好等于某个缩放级别下的一个瓦片像素,那么网格索引与经纬度之间的相互转换只需四个简短的函数,而且所有以瓦片形式发布的数据集(高程、人口、影像)都能直接落进你的网格,无需重采样。
下面就是这四个转换函数。lon2x 和 lat2y 返回小数形式的瓦片坐标,乘以 256 就得到像素坐标。
function lon2x(lon, z) {
return (lon + 180) / 360 * Math.pow(2, z);
}
function lat2y(lat, z) {
const rad = lat * Math.PI / 180;
return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, z);
}
function x2lon(x, z) {
return x / Math.pow(2, z) * 360 - 180;
}
function y2lat(y, z) {
const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
接下来是选择缩放级别。目标是让感兴趣的区域落在一个足够细腻、看起来平滑,同时又足够小、能每秒遍历六十次的网格里。在 HazardSim 中,我从缩放级别 15 开始逐步下调,直到边界框横向约 780 像素以内、格子总数不超过 62 万左右:
function chooseZoom(bbox) {
for (let z = 15; z >= 8; z--) {
const pw = (lon2x(bbox.e, z) - lon2x(bbox.w, z)) * 256;
const ph = (lat2y(bbox.s, z) - lat2y(bbox.n, z)) * 256;
if (pw <= 780 && pw * ph <= 620000) return z;
}
return 8;
}
确定缩放级别后,把边界框向外对齐到完整的瓦片边界。这个矩形就是 canvas 要覆盖的范围,也是你要下载的瓦片范围:
function gridFor(bbox) {
const z = chooseZoom(bbox);
const x0 = Math.floor(lon2x(bbox.w, z)), x1 = Math.floor(lon2x(bbox.e, z));
const y0 = Math.floor(lat2y(bbox.n, z)), y1 = Math.floor(lat2y(bbox.s, z));
const nx = x1 - x0 + 1, ny = y1 - y0 + 1;
const latMid = (bbox.n + bbox.s) / 2;
return {
z, x0, y0, nx, ny,
W: nx * 256,
H: ny * 256,
// 该纬度和缩放级别下每个格子的实际尺寸(米)
cellM: 156543.03392 * Math.cos(latMid * Math.PI / 180) / Math.pow(2, z),
// 对齐后矩形的精确地理角点
corners: {
w: x2lon(x0, z), e: x2lon(x1 + 1, z),
n: y2lat(y0, z), s: y2lat(y1 + 1, z),
},
};
}
保留 cellM 变量。后续将其用于将“波前已传播 400 米”转换为“波前已传播 3.8 个单元”。对于本文截图中展示的主要城市敖德萨,网格在缩放级别 10 下由 105 米的单元组成。
步骤 2:将真实高程加载到网格中
若洪水仿真忽略丘陵,其结果便只是一个蓝色色块。让真实地图上的仿真具备现实感的要素是真实地形,而地形数据是免费获取的。
AWS Open Data 计划托管了 Mapzen Terrarium 高程瓦片。这些瓦片是普通 PNG 文件,像素颜色编码了高程信息:
height_m = R * 256 + G + B / 256 - 32768
由于网格与瓦片对齐,加载高程数据只需获取瓦片、将其绘制到单个大画布的自然位置、回读像素并进行解码。整个过程无需重采样,因为网格单元与高程采样点即为同一像素。
function terrariumUrl(z, x, y) {
return `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`;
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
async function loadElevation(grid) {
const cv = document.createElement("canvas");
cv.width = grid.W;
cv.height = grid.H;
const cx = cv.getContext("2d", { willReadFrequently: true });
const jobs = [];
for (let iy = 0; iy < grid.ny; iy++) {
for (let ix = 0; ix < grid.nx; ix++) {
jobs.push(
loadImage(terrariumUrl(grid.z, grid.x0 + ix, grid.y0 + iy))
.then(img => cx.drawImage(img, ix * 256, iy * 256))
);
}
}
await Promise.all(jobs);
const d = cx.getImageData(0, 0, grid.W, grid.H).data;
const elev = new Float32Array(grid.W * grid.H);
for (let i = 0; i < elev.length; i++) {
const o = i * 4;
elev[i] = d[o] * 256 + d[o + 1] + d[o + 2] / 256 - 32768;
}
return elev;
}
crossOrigin = "anonymous" 是必需的。如果不设置,Canvas 会被标记为“受污染”(tainted),导致 getImageData 抛出异常。Terrarium 的数据源已正确配置了 CORS 响应头,因此代码可直接运行。
既然你已经有了 Mapbox 令牌,也可以改用 Mapbox 官方的 Terrain-RGB 瓦片。URL 格式为 https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=…,其解码公式有所不同:
// Mapbox Terrain-RGB
elev[i] = -10000 + (d[o] * 65536 + d[o + 1] * 256 + d[o + 2]) * 0.1;
HazardSim 同时支持两种数据源,并通过配置标志切换。Terrain-RGB 的数据更干净,噪声尖峰更少;而 Terrarium 免收按瓦片计费的费用。作为教程起点,建议优先使用 Terrarium。
补充两点实用细节。Terrarium 数据偶尔会出现单像素尖峰,对与相邻单元格差异超过几米的网格执行 3×3 中值滤波即可消除。海拔零米或以下的单元格通常代表海洋,但荷兰、新奥尔良或死亡谷等地除外。仅当某个单元格通过其他海洋单元格与地图边缘连通时,才将 elev <= 0 视为海洋,以此覆盖大多数常见场景。
第 3 步:将 Canvas 添加为地图数据源
现在是核心步骤。创建一个与网格尺寸匹配的 Canvas,并将其注册为地图的 canvas 数据源,其四个角点对应第 1 步中对齐后的矩形区域。
<div id="map"></div>
<canvas id="floodcanvas" width="10" height="10" style="display:none"></canvas>
mapboxgl.accessToken = "YOUR_MAPBOX_TOKEN";
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/satellite-streets-v12",
center: [30.73, 46.48],
zoom: 11,
});
const fc = document.getElementById("floodcanvas");
const fctx = fc.getContext("2d");
let fimg = null;
function attachCanvas(grid) {
fc.width = grid.W;
fc.height = grid.H;
fimg = fctx.createImageData(grid.W, grid.H);
const c = grid.corners;
const coords = [[c.w, c.n], [c.e, c.n], [c.e, c.s], [c.w, c.s]];
if (map.getSource("flood")) {
map.getSource("flood").setCoordinates(coords);
return;
}
map.addSource("flood", {
type: "canvas",
canvas: "floodcanvas",
coordinates: coords,
animate: true,
});
map.addLayer({
id: "flood",
type: "raster",
source: "flood",
paint: {
"raster-opacity": 0.94,
"raster-resampling": "linear",
"raster-fade-duration": 0,
},
});
}
这里有几个关键选项:
animate: true让 Mapbox GL 每帧都把 canvas 重新作为纹理上传。不加的话,动画会卡在第一帧。"raster-resampling": "linear"让用户放大到超过网格分辨率时在相邻格点之间做插值。城市级别的缩放配上 105 米的单元格,有它水面才平滑,没它就像像素游戏。"raster-fade-duration": 0关闭 Mapbox GL 对 raster 图层的交叉淡入效果。既然每帧都在重绘,淡入只会把画面糊掉。
canvas 设置为 display:none,因为地图是直接读取它的,完全不需要在 DOM 里显示出来。
从这一步开始,你往 fimg 里写入的任何内容,只要通过 fctx.putImageData(fimg, 0, 0) 推送上去,就会出现在地图的对应位置——无论怎么倾斜、旋转视角都行,开启地形后还会贴合地表。渲染问题就此转化成了一个像素数组问题。
第四步:模拟尊重地形的洪水
海啸并不会淹没所有低于浪高的地方——水得能流过去才行。海水从海上涌入,漫过低地,在地势高于水位的地方停下,哪怕山脊另一侧有更低的洼地也淹不到。这本质上是一个连通性问题,而广度优先搜索(BFS)正是解决它的工具。
首先构建距离场:计算每个单元格距离海岸有多远。将所有与陆地相邻的海水单元格作为种子加入队列,然后向海域内部扩展。之后,利用这个距离场来动画化海浪逼近的过程,并使用其在陆地一侧的对应值来表现洪水向内陆扩散的效果。
function seaDistance(grid, sea) {
const { W, H, cellM } = grid;
const N = W * H;
const dist = new Float32Array(N).fill(-1);
const q = new Int32Array(N);
let head = 0, tail = 0;
// seed: sea cells that touch land
for (let i = 0; i < N; i++) {
if (!sea[i]) continue;
const x = i % W, y = (i / W) | 0;
const touchesLand =
(x > 0 && !sea[i - 1]) || (x < W - 1 && !sea[i + 1]) ||
(y > 0 && !sea[i - W]) || (y < H - 1 && !sea[i + W]);
if (touchesLand) { dist[i] = 0; q[tail++] = i; }
}
// expand
while (head < tail) {
const c = q[head++];
const cx = c % W, cy = (c / W) | 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const n = ny * W + nx;
if (!sea[n] || dist[n] >= 0) continue;
dist[n] = dist[c] + (dx && dy ? cellM * Math.SQRT2 : cellM);
q[tail++] = n;
}
}
}
return dist;
}
洪水漫灌的搜索方向相反,但多了一条规则:只有当水面高于地面时,单元格才可被到达。waveH 表示岸边处波高,单位为米。随着洪水向内陆推进,水位逐渐衰减,采用线性衰减即可达到良好的视觉效果:
function floodFill(grid, elev, sea, waveH) {
const { W, H, cellM } = grid;
const N = W * H;
const depth = new Float32Array(N); // 地表积水深度(米)
const reach = new Float32Array(N).fill(-1); // 距海岸线的水平距离(米)
const q = new Int32Array(N);
let head = 0, tail = 0;
for (let i = 0; i < N; i++) {
if (!sea[i]) continue;
const x = i % W, y = (i / W) | 0;
const nb = [i - 1, i + 1, i - W, i + W];
for (const n of nb) {
if (n < 0 || n >= N || sea[n] || reach[n] >= 0) continue;
if ((n === i - 1 && x === 0) || (n === i + 1 && x === W - 1)) continue;
reach[n] = 0;
q[tail++] = n;
}
}
const decayPerM = waveH / 12000; // 浪高随距离衰减,约 12 km 后衰减殆尽
while (head < tail) {
const c = q[head++];
const level = waveH - reach[c] * decayPerM;
const d = level - elev[c];
if (d <= 0) continue; // 此处地面高于水面,停止扩散
depth[c] = d;
const cx = c % W, cy = (c / W) | 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const n = ny * W + nx;
if (sea[n] || reach[n] >= 0) continue;
reach[n] = reach[c] + (dx && dy ? cellM * Math.SQRT2 : cellM);
q[tail++] = n;
}
}
}
return { depth, reach };
}
每个场景只需运行一次该函数,无需逐帧计算。在 700×500 的网格上,耗时仅几毫秒。输出结果包含每个单元的 depth(积水深度)和 reach(深入内陆的距离),这正是动画所需的全部数据。
由于填充算法基于真实高程数据,水流会顺着河谷向上游推进,并在堤坝处停下。将淹没单元数乘以 cellM²,即可得到以平方公里为单位的淹没面积,让模拟结果更具说服力。
步骤 5:通过写入像素实现动画
帧循环只做一件事:对每个被淹没的陆地格子,判断水是否已经到达,如果到了,该显示什么颜色。
"到达"就是把格子的 reach 和一个随时间增长的前沿位置 F 做比较。给 reach 加一点逐格噪声,让前沿不是一条完美的等高线,水看起来就真的像水了:
let t0 = null;
const T_LANDFALL = 3.9; // seconds until the wave reaches the shore
const T_FULL = 10.9; // seconds until it reaches its maximum extent
function ease(k) {
return k <= 0 ? 0 : k >= 1 ? 1 : 1 - Math.pow(1 - k, 2.2);
}
function frame(now) {
if (t0 === null) t0 = now;
const t = (now - t0) / 1000;
const d = fimg.data;
const { W, H } = grid;
// how far inland the front has got, in metres
const F = t < T_LANDFALL
? -1e9
: maxReach * ease((t - T_LANDFALL) / (T_FULL - T_LANDFALL));
for (let i = 0; i < W * H; i++) {
const o = i * 4;
if (sea[i] || depth[i] <= 0) { d[o + 3] = 0; continue; }
const arrival = reach[i] + noise[i] * 180; // ±90 m of jitter
if (arrival > F) { d[o + 3] = 0; continue; }
const age = (F - arrival) / 150; // how long the cell has been under
const dp = depth[i];
// base colour: shallow is lighter, deep is darker
let r = 40 + Math.max(0, 60 - dp * 10);
let g = 70 + Math.max(0, 50 - dp * 8);
let b = 95 + Math.max(0, 40 - dp * 6);
let a = dp < 0.5 ? 215 + dp * 60 : 245;
// the leading edge is foam: push toward white for the first moments
if (age < 1.4) {
const k = Math.pow(1 - age / 1.4, 2) * 0.8;
r += (240 - r) * k; g += (246 - g) * k; b += (246 - b) * k;
}
d[o] = r; d[o + 1] = g; d[o + 2] = b; d[o + 3] = a;
}
fctx.putImageData(fimg, 0, 0);
if (t < T_FULL + 3) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
noise 是一个 Float32Array,存放 [-0.5, 0.5] 范围内的随机值,每次运行只生成一次。maxReach 是所有被淹没格子中最大的 reach 值。
这里涉及两个性能要点:700×500 的网格包含 350,000 个单元格,在我的笔记本上该循环能跑满 60 fps,在中端手机上约 30 fps。如果需要更高性能,不要遍历整个网格,而是只遍历被洪水淹没的陆地单元格列表(在 floodFill 执行完后收集一次索引即可)。对于沿海城市,这通常只占全部单元格的四分之一。另外,要在帧间保留 fimg。每帧都调用 createImageData 是性能分析工具首要指出的问题。
如果止步于此,你已经有了一个具备地形感知的可用模拟。接下来的工作,则是让画面看起来像实拍视频而非示意图。
第 6 步:使用 Canvas 合成叠加视频纹理
纯色填充看起来像示意图。真实的水面是带有动态纹理的。最省事的办法,是将一段真实水面的循环视频覆盖在洪水区域上。而“仅覆盖洪水区域”这一限制,可以直接通过两种 Canvas 合成模式实现:
source-atop模式只将新图像绘制在目标图层已有像素的地方。用source-atop将视频绘制到洪水图层上,画面就只会出现在水面上。干燥的陆地保持原样,因为该区域在洪水图层上是透明的。destination-in模式只保留目标图层与新图像都有像素的区域。用destination-in在缓冲区上绘制一个由黑色和透明组成的遮罩,缓冲区就会被裁剪成遮罩的形状。
你需要一个隐藏的 <video>(需静音、循环播放并设置 playsInline。这三个条件缺一不可,否则移动端 Safari 会拒绝自动播放),以及一个与网格尺寸相同的临时 Canvas:
const vid = document.createElement("video");
vid.muted = true; vid.loop = true; vid.playsInline = true;
vid.setAttribute("muted", ""); vid.setAttribute("playsinline", "");
vid.src = "/fx/water.webm";
vid.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none";
document.body.appendChild(vid);
vid.load();
const buf = document.createElement("canvas");
buf.width = grid.W; buf.height = grid.H;
const bctx = buf.getContext("2d");
function drawWaterTexture(mud) {
if (vid.readyState < 2) return; // not enough data yet: skip, don't block
if (vid.paused) vid.play().catch(() => {});
// 1. video into the scratch buffer, stretched to the grid
bctx.globalCompositeOperation = "source-over";
bctx.clearRect(0, 0, grid.W, grid.H);
bctx.drawImage(vid, 0, 0, grid.W, grid.H);
// 2. tint it, only where the video is (everywhere, at this point)
bctx.globalCompositeOperation = "source-atop";
bctx.globalAlpha = 0.5 + 0.38 * mud;
bctx.fillStyle = `rgb(${28 + 108 * mud | 0}, ${112 - 10 * mud | 0}, ${178 - 126 * mud | 0})`;
bctx.fillRect(0, 0, grid.W, grid.H);
bctx.globalAlpha = 1;
// 3. crop the buffer to land cells, so the open sea keeps its own colour
bctx.globalCompositeOperation = "destination-in";
bctx.drawImage(landMask, 0, 0);
// 4. onto the flood canvas, only where there's already water
fctx.save();
fctx.globalCompositeOperation = "source-atop";
fctx.globalAlpha = 0.8;
fctx.drawImage(buf, 0, 0);
fctx.restore();
}
在帧循环中,紧跟在 fctx.putImageData(fimg, 0, 0) 之后调用此函数。第 4 步是关键所在。由于洪水画布在无水的区域是透明的,使用 source-atop 可以将纹理精准地填充在洪水轮廓内部,完美贴合 BFS 算法找到的每一处海湾和山谷,且无需进行逐像素计算。
landMask 是一个每次运行前构建一次的画布:在 sea[i] 为 false 的区域显示白色,其余区域为透明。如果不加这一步,纹理也会覆盖在开阔海域上,导致整个海湾变成洪水的颜色。我在发布带这个 bug 的版本后,大约过了一小时才发现这个问题。
mud 参数是一个从 0 到 1 的时间渐变。海啸的海水登陆时会带走陆地上的泥沙,颜色逐渐变成深棕色。只要在登陆后的几秒内,把色调从海蓝色插值到泥棕色,这个效果就自然有了。
我第一次做的时候,直接把棕色调烘焙进了视频文件本身,结果无论怎么调色,都没法让海水在开头呈现蓝色。所以源视频要保持中性,颜色全部交给代码来处理。
同样的 destination-in 技巧还能实现基于深度的变暗效果(一个 alpha 值与 depth[i] 成正比的蒙版,填充深色),以及沿移动前沿的泡沫带(一个 alpha 值为 reach[i] - F 高斯函数的蒙版,填充另一段翻涌泡沫的视频)。每种效果只需几行代码,都能让水体看起来更像一个有体积的实体,而不只是一层染色。
第 7 步:把镜头移入场景
俯视地图只是一张示意图,而倾斜镜头之后,同样的像素就变成了一个真实的地方。洪水画布是贴在地面上的,透视缩短效果自然正确,你要做的只是把镜头对准值得看的位置。
对海啸来说,就是站在城市附近的海岸上,面朝大海,看着海浪涌来。"城市附近"取的是离地图中心最近的海岸线网格,"面朝大海"取的是从该点指向周边海域网格的平均方向。这个方向要在局部计算,而不是对整条海岸线算,否则在弯曲的海湾里,镜头可能会指向错误的地平线。这是我在敖德萨踩过的坑:全城平均方向把镜头对准了机场旁的空地。
之后就是两次 easeTo 调用:在风暴登陆前先俯冲拉近视角,在结果呈现前再拉远视角。
const before = {
center: map.getCenter(), zoom: map.getZoom(),
pitch: map.getPitch(), bearing: map.getBearing(),
};
const cam = shoreCamera(grid, sea, map.getCenter());
map.easeTo({
center: cam.center,
zoom: Math.min(13.6, before.zoom + 2.8),
pitch: 72,
bearing: cam.bearing,
duration: 1200,
});
setTimeout(() => map.easeTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000);
下面就是这两次调用的实际效果。相机俯冲至敖德萨海岸上方,以陡峭的俯角朝向海湾。海水先向后退去,露出一片宽阔的沙带;随后浪头冲上海滩,涌入港口。画面中所有动态元素,本质上都是第 3 步中的那张洪水 Canvas 图层,只是被相机透视压缩并贴覆在地形之上。
注意,主循环里的代码既不知道也不关心相机动了。循环持续向同一块 Canvas 写入相同的像素,剩下的事交给地图处理。这正是第 3 步为你换来的便利。
地面图层的边界
到目前为止,所有元素都贴在地面上。洪水 Canvas 就是地图自身的表面,这正是它有效的原因:透视、旋转和地形高度都是现成的。可一旦需要立体高度,这套技巧就失效了——你没法把一座塔吊在栅格图上。
我是花了代价才摸清这条边界的。我曾用 three.js 自定义图层搭建了一堵真实的三维水墙,效果确实能跑通。但我后来把它砍掉了,原因值得你在投入时间前先了解。
一道 30 米高的巨浪,从 250 米外观看,仅覆盖 37° 视场的 7°,也就是屏幕的五分之一。若想让水浪充满整个画面,相机就得拉近到 100 米内且几乎贴近地面,此时卫星影像会糊成一团。几何形状固然正确,但取景效果很差。在倾斜相机下,贴地渲染的水层反而比按比例缩放的三维水墙看起来更具冲击力。
于是,地面图层就留在了地面上。但地震场景可没这种退路:倒塌的建筑必须作为独立三维对象呈现。这正是第 2 部分的内容。
第 2 部分:有高度的物体
做地震场景时,我需要让建筑物倒塌。经过大约十轮重构,最终的方案是:成千上万栋只需静静立着的建筑用地图原生的拉伸体渲染,而要倒塌的那些建筑,则通过渲染进地图自身 WebGL 上下文的 three.js 场景来处理。
两条渲染路径读取同一批矢量瓦片,共享同一个深度缓冲区,并在每次镜头移动时保持同步。
两种 3D 方案,缺一不可
fill-extrusion 图层是地图引擎自带的 3D 能力。它速度快,能轻松处理上万栋建筑,光照与地图其余部分保持一致,而且由数据表达式驱动:高度取自属性,颜色取自属性,还有关键的一点——高度可以乘以一个运行时可修改的 per-feature 状态值。但它做不到的是移动:拉伸体永远是一个立在地面上的垂直棱柱。
custom 图层则每一帧都把地图的 WebGL 上下文和投影矩阵交给你,想画什么都可以。配合 three.js,就能渲染真正的物体:带位置、旋转和缩放的 mesh,成千上万个实例化,并写入与地图相同的深度缓冲区——这样一栋倒塌的楼会正确地消失在仍矗立的建筑背后。
可行的分工很简单。所有建筑都是拉伸体。建筑受损时,拉伸体收缩(就是那个 per-feature 状态)。当某栋建筑倒塌的位置离镜头足够近、肉眼能看出差别时,拉伸体先收缩到零,然后由一个 three.js 物体接管,完成两秒钟的倒塌动画。之后这个物体再换成一堆瓦砾(同样是 three.js),帧率完全不受影响。
Step 8: Get Buildings Out of the Map's Own Tiles
You don't need a building dataset. The map is already downloading one. Mapbox Streets vector tiles carry a building source layer with a polygon footprint, a height, a min_height for buildings that start above ground, and an extrude flag. querySourceFeatures reads whatever is currently loaded:
function buildingSource(map) {
const sources = map.getStyle().sources;
if (sources.composite) return "composite"; // Mapbox 样式
if (sources.openmaptiles) return "openmaptiles"; // MapLibre + OpenMapTiles
for (const id in sources) if (sources[id].type === "vector") return id;
return null;
}
function collectBuildings(map, center, kx, ky) {
const src = buildingSource(map);
if (!src) return [];
const feats = map.querySourceFeatures(src, { sourceLayer: "building" });
const out = [], seen = new Set();
for (const f of feats) {
const p = f.properties || {}, g = f.geometry;
if (!g || p.extrude === "false" || p.underground === "true") continue;
const polys = g.type === "Polygon" ? [g.coordinates]
: g.type === "MultiPolygon" ? g.coordinates : null;
if (!polys || !polys[0] || polys[0][0].length < 4) continue;
// 外环的质心和边界框
const ring = polys[0][0], n = ring.length - 1;
let sx = 0, sy = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (let j = 0; j < n; j++) {
const [x, y] = ring[j];
sx += x; sy += y;
if (x < x0) x0 = x; if (x > x1) x1 = x;
if (y < y0) y0 = y; if (y > y1) y1 = y;
}
sx /= n; sy /= n;
// 同一栋建筑会在每个相交的瓦片中各出现一次
const key = sx.toFixed(6) + "," + sy.toFixed(6) + "," + n;
if (seen.has(key)) continue;
seen.add(key);
let h = +(p.height ?? p.render_height);
if (!(h > 0)) h = 8; // 无数据时:假定为两层楼
const mh = Math.min(h, +(p.min_height ?? p.render_min_height) || 0);
out.push({
geometry: g, polys, lng: sx, lat: sy, h, mh,
d: Math.hypot((sx - center.lng) * kx, (sy - center.lat) * ky), // 距震中的米数
rf: 0.5 * Math.sqrt(Math.max(4, (x1 - x0) * kx * (y1 - y0) * ky)), // 粗略半径,单位:米
});
}
out.sort((a, b) => a.d - b.d);
return out.slice(0, 9000);
}
kx 和 ky 是该城市纬度下每度经度和纬度对应的米数:111320 * cos(lat) 和 110574。后续所有计算均以距震中的米数为单位。
关于这份数据有三点需要注意。首先,querySourceFeatures 会对每个相交瓦片分别返回一栋建筑,因此必须去重。使用四舍五入后的质心坐标加顶点数量作为键是一个可行的去重方案。
其次,这些要素在 Mapbox Streets 中没有稳定的 ID。这一点在第 11 步非常关键。
第三,你只能拿到已经加载的要素,也就是说必须先把镜头拉近到建筑物所在的位置,等瓦片加载完毕,才能去收集。
HazardSim 的做法是:相机分两步俯冲,然后等待 map.areTilesLoaded() 返回 true,且多次轮询之间要素数量不再变化,才开始执行 collectBuildings。如果收集得太早,就只能拿到第一块瓦片里恰好存在的那十栋建筑。
第 9 步:让拉伸体看起来像真正的建筑
默认的拉伸体只是一个单色平涂的盒子,看上去像示意图。修复方法几乎零成本:fill-extrusion-pattern 可以从样式的 sprite 里取一张图片平铺到墙面上,而且可以通过 map.addImage 在运行时往 sprite 里添加图片。
在 canvas 上画几张立面纹理(在墙面底色上画一排排窗户,再加一点噪点,免得太像电脑生成的),注册进 sprite,然后根据建筑高度为每栋建筑选择一张:
function makeFacade(map, id, wall, glass) {
const cv = document.createElement("canvas");
cv.width = 128; cv.height = 64;
const c = cv.getContext("2d");
c.fillStyle = wall; c.fillRect(0, 0, 128, 64);
// four floors of windows, 16px per floor
for (let f = 0; f < 4; f++) {
for (let x = 3; x + 6 <= 128; x += 12) {
const lit = Math.random() < 0.07;
c.fillStyle = lit ? "#c6b68c" : glass;
c.fillRect(x, f * 16 + 3, 6, 9);
}
}
// film grain
const im = c.getImageData(0, 0, 128, 64), d = im.data;
for (let i = 0; i < d.length; i += 4) {
const n = (Math.random() - 0.5) * 12;
d[i] += n; d[i + 1] += n; d[i + 2] += n;
}
map.addImage(id, im);
}
makeFacade(map, "fac-glass", "#566a78", "#3c4e5c");
makeFacade(map, "fac-light", "#cdc8be", "#364048");
makeFacade(map, "fac-brick", "#8a5a44", "#2e2a26");
接着是图层。高度和底部都是表达式,且都乘以一个 feature-state 值 k,默认为 1。这个乘数正是整个倒塌机制的核心,所以现在就把它加进去:
const H = ["coalesce", ["get", "height"], ["get", "render_height"], 8];
const MH = ["coalesce", ["get", "min_height"], ["get", "render_min_height"], 0];
const K = ["coalesce", ["feature-state", "k"], 1];
function addBuildingLayers(map, source, sourceLayer, before) {
const common = { type: "fill-extrusion", source, minzoom: 13 };
if (sourceLayer) {
common["source-layer"] = sourceLayer;
common.filter = ["all", ["!=", ["get", "extrude"], "false"], ["!=", ["get", "underground"], "true"]];
}
map.addLayer({
...common, id: "bld-walls",
paint: {
"fill-extrusion-pattern": ["case",
[">=", H, 45], "fac-glass",
[">=", H, 14], "fac-light",
"fac-brick"],
"fill-extrusion-height": ["*", H, K],
"fill-extrusion-base": ["*", MH, K],
"fill-extrusion-opacity": 1,
},
}, before);
// a thin second extrusion on top gives the roof its own texture
map.addLayer({
...common, id: "bld-roof",
paint: {
"fill-extrusion-pattern": "fac-roof",
"fill-extrusion-height": ["+", ["*", H, K], 0.05],
"fill-extrusion-base": ["max", ["*", MH, K], ["-", ["*", H, K], 0.45]],
"fill-extrusion-opacity": 1,
},
}, before);
}
参数 before 应设为样式中第一个 symbol 图层的 ID,这样建筑就会渲染在标注下方。可以通过循环遍历 map.getStyle().layers 来找到该图层。
纹理无法针对单个要素单独旋转或缩放,因此窗户和楼层不会完全对齐。但在该地图的相机高度下,这种偏差肉眼几乎不可见。
第 10 步:决定哪些建筑倒塌
接下来是模拟部分,代码很简短。对于每栋建筑,需要计算其所在位置的震级强度,以及该类型建筑在此强度下倒塌的概率。
震感强度使用修订版麦卡尔尼(Modified Mercalli)烈度表,范围为 I 至 XII。标准的衰减公式可根据震级 M 和以公里为单位的距离 r 计算烈度(depth 为震源深度,浅层地壳地震通常为 10 公里):
const DEPTH_KM = 10;
function mmiAt(M, rKm) {
const R = Math.sqrt(rKm * rKm + DEPTH_KM * DEPTH_KM);
const v = 1.5 * M - 3.0 * Math.log10(R) - 0.0018 * R + 1.6;
return Math.max(0, Math.min(12, v));
}
震级强度对应的倒塌概率曲线就是易损性曲线。公开文献中的版本按建筑类型划分,细节远比模拟所需更为精细。这里采用的分段线性版本经过调整,既在视觉上看起来合理,也能给出符合常识的伤亡数字。在此基础上,再分别乘以一个代表施工质量、一个代表楼层高度的系数。中高层建筑受损最严重,超高层建筑由于结构经过专业设计,反而相对安全:
const FRAG = [[6.5, 0], [7, 0.004], [8, 0.025], [9, 0.08], [10, 0.2], [11, 0.38], [12, 0.55]];
const QUALITY = { strict: 0.2, avg: 1, poor: 3 };
function collapseProbability(mmi) {
if (mmi <= FRAG[0][0]) return 0;
for (let i = 1; i < FRAG.length; i++) {
if (mmi <= FRAG[i][0]) {
const [m0, p0] = FRAG[i - 1], [m1, p1] = FRAG[i];
return p0 + (p1 - p0) * (mmi - m0) / (m1 - m0);
}
}
return FRAG[FRAG.length - 1][1];
}
function assignDamage(buildings, M, epicentre, quality) {
const kx = 111320 * Math.cos(epicentre.lat * Math.PI / 180), ky = 110574;
const q = QUALITY[quality] || 1;
for (const b of buildings) {
const rKm = Math.hypot((b.lng - epicentre.lng) * kx, (b.lat - epicentre.lat) * ky) / 1000;
const mmi = mmiAt(M, rKm);
const hf = b.h < 5 ? 0.7 : b.h < 35 ? 1.15 : b.h < 70 ? 0.8 : 0.45;
const pCollapse = Math.min(0.9, collapseProbability(mmi) * q * hf);
const pDamage = mmi >= 6 ? Math.min(0.6, pCollapse * 2.5 + 0.004) : 0;
const r = Math.random();
b.cat = r < pCollapse ? 2 : r < pCollapse + pDamage ? 1 : 0; // 2 collapse, 1 damaged, 0 fine
b.start = 0.8 + Math.random() * 2.8; // seconds after the shock
}
}
建筑之间的随机性正是关键所在。两栋参数完全相同的塔楼紧挨着,一栋倒塌而另一栋屹立不倒,这恰好还原了真实地震中的景象。
第 11 步:在不重建图层的情况下实现倒塌动画
这里正是第 9 步中机制发挥作用的时候。每一帧,对于正在倒塌的每栋建筑,将其 feature-state 中的 k 设置为 1 与最终瓦砾高度之间的数值,从而让拉伸高度逐渐缩小。全程无需重建图层、重置 GeoJSON 或重新解析瓦片。
map.setFeatureState({ source: "bld", id: b.id }, { k: 0.62 });
有一个坑,也是整个项目里耗费我最多时间的地方:setFeatureState 需要一个 feature ID,而 Mapbox Streets 的建筑数据根本没有。直接对矢量源调用它,什么都不会发生,也不会报错。
解决办法是把你收集到的建筑数据复制到自己的 GeoJSON 源里,用一个由你控制的 id,并让源把这个属性提升为 feature ID:
const feats = buildings.map((b, i) => {
b.id = i;
return { type: "Feature", properties: { i, height: b.h, min_height: b.mh }, geometry: b.geometry };
});
map.addSource("bld", {
type: "geojson",
data: { type: "FeatureCollection", features: feats },
promoteId: "i",
maxzoom: 16,
});
addBuildingLayers(map, "bld", null, firstSymbolLayer);
这样一来,setFeatureState({ source: "bld", id: 17 }, ...) 就能正确定位到 17 号建筑了。原来的 tile 图层先保留,等 GeoJSON 源的 isSourceLoaded 返回 true 之后再移除,这样画面上就不会出现城市闪烁的瞬间。
k 随时间变化的曲线,决定了倒塌看起来是真正的坍塌,还是建筑平移陷进地下。我最终设计了五种模式,每栋建筑随机选择一种,并受高度约束。
// k0 = 初始高度系数(1),kf = 最终废墟高度,u = 进度 0..1
function collapseK(b, u) {
const k0 = b.k0, sp = k0 - b.kf;
switch (b.mode) {
case 1: { // 逐层坍塌:ns 步,每步快速下坠后短暂停顿
const s = u * b.ns, i = Math.min(b.ns - 1, Math.floor(s));
let f = s - i;
f = f < 0.45 ? f * f / 0.45 * 1.1 : 0.55 + (f - 0.45) * 0.45 / 0.55;
return k0 - sp * Math.min(1, (i + Math.min(1, f)) / b.ns);
}
case 2: { // 软弱层:底层先垮,整栋建筑下沉一层,停顿后继续下落
const sd = Math.min(sp * 0.5, 3.2 / b.h);
if (u < 0.12) return k0 - sd * (u / 0.12) ** 2;
if (u < 0.5) return k0 - sd;
const v = (u - 0.5) / 0.5;
return k0 - sd - (sp - sd) * v * v;
}
case 3: { // 顶部压溃:上部结构先失效
const pt = sp * 0.45;
if (u < 0.3) return k0 - pt * (u / 0.3) ** 2;
if (u < 0.55) return k0 - pt;
const v = (u - 0.55) / 0.45;
return k0 - pt - (sp - pt) * v * v;
}
case 4: // 侧倾:平稳且略有加速
return k0 - sp * Math.pow(u, 1.6);
default: // 煎饼式坍塌:加速自由落体
return k0 - sp * u * u;
}
}
每栋建筑的帧循环只需几行代码:
function frame(now) {
const el = (now - T0) / 1000, dt = Math.min(0.04, (now - last) / 1000);
last = now;
for (const b of active) {
if (b.done || el < b.start) continue;
const u = Math.min(1, (el - b.start) / b.dur);
const k = collapseK(b, u);
map.setFeatureState({ source: "bld", id: b.id }, { k });
if (u >= 1) { b.done = true; onLanded(b); }
}
requestAnimationFrame(frame);
}
对于受损但依旧矗立的建筑(cat === 1),kf 接近 1,模式表现为短暂沉降:建筑明显下沉半米后静止。这是个小细节,却能让整条街道看起来像是遭受过冲击,而不仅仅是那些倒塌的建筑受损。
将 dt 限制在 40 ms。如果标签页卡顿导致单帧耗时四分之一秒,正处于下落过程中的建筑会瞬间跳跃而非自然坠落,这种违和感人眼能立刻察觉。
步骤 12:地图中的 Three.js 场景
收缩式拉伸几何体足以处理远处的建筑。但对于相机附近的建筑,你需要看到结构的破坏细节:塔楼倾斜后断裂成块,碎片从底部弧线飞散,或者街道上留下的废墟。这需要真正的三维对象,因此这里用到了 three.js。
自定义图层接收两个回调:onAdd 仅在初始化时触发一次,提供地图及其 GL 上下文;render 每帧触发,提供上下文和地图的投影矩阵。非直觉之处在于坐标系统。你希望以原点为基准的米制单位进行计算,而非 Mercator 单位,因此需要构建一个转换矩阵,并每帧将其乘以地图的投影矩阵:
function makeScene(map, origin, before) {
const mc = mapboxgl.MercatorCoordinate.fromLngLat([origin.lng, origin.lat], 0);
const s = mc.meterInMercatorCoordinateUnits();
// 原点处东 / 北 / 上方向的米数 -> Mercator。Mercator 的 y 轴向南增长,所以要取 -s。
const toMercator = new THREE.Matrix4()
.makeTranslation(mc.x, mc.y, mc.z)
.scale(new THREE.Vector3(s, -s, s));
const scene = new THREE.Scene();
const camera = new THREE.Camera();
scene.add(new THREE.HemisphereLight(0xfff4e4, 0x3c342d, 0.55));
scene.add(new THREE.AmbientLight(0xffffff, 0.1));
const sun = new THREE.DirectionalLight(0xfff8ee, 0.8);
sun.position.set(-0.45, -0.6, 1);
scene.add(sun);
let renderer = null;
const layer = {
id: "debris3d",
type: "custom",
renderingMode: "3d",
onAdd(m, gl) {
renderer = new THREE.WebGLRenderer({ canvas: m.getCanvas(), context: gl, antialias: true });
renderer.autoClear = false; // 地图这一帧已经绘制过了
renderer.localClippingEnabled = true; // 用于让建筑下沉到地面以下
},
render(gl, matrix) {
camera.projectionMatrix.fromArray(matrix).multiply(toMercator);
renderer.resetState(); // 上次绘制后 Mapbox 改动了 GL 状态
renderer.render(scene, camera);
},
};
map.addLayer(layer, before);
return { scene, camera, layer };
}
在 MapLibre 上,render 的第二个参数是一个对象,矩阵位于 args.defaultProjectionData.mainMatrix,其余部分完全一样。
renderingMode: "3d" 的作用是让地图与你共享深度缓冲。落在某栋已挤出建筑后面的混凝土块会被它遮挡——这正是把 Three.js 渲染进地图自身的 context、而不是叠一个单独 canvas 的全部理由。
成千上万块碎片就意味着要用 instancing。HazardSim 维护了一个 InstancedMesh 对象池,每种碎片(楼板、墙体碎块、钢筋、石块、屋顶棱柱)各一个,每个都有固定容量,碎片生成时就分配实例索引:
function makePool(scene, geometry, material, capacity) {
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
// 预先为整个容量分配颜色缓冲区(详见「注意事项」)
mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
mesh.count = 0;
mesh.frustumCulled = false; // 地图相机不是 three.js 的相机
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(mesh);
const dummy = new THREE.Object3D(), color = new THREE.Color();
dummy.rotation.order = "ZXY";
return {
n: 0, capacity,
add(p) {
if (this.n >= capacity) return -1;
const i = this.n++;
this.set(i, p);
color.setRGB(p.rgb[0], p.rgb[1], p.rgb[2]);
mesh.setColorAt(i, color);
mesh.instanceColor.needsUpdate = true;
mesh.count = this.n;
return i;
},
set(i, p) {
dummy.position.set(p.x, p.y, p.z);
dummy.rotation.set(p.tx, p.ty, p.yaw);
dummy.scale.set(p.sx, p.sy, p.sz);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.instanceMatrix.needsUpdate = true;
},
};
}
const concrete = new THREE.MeshStandardMaterial({ map: concreteTexture, roughness: 1 });
const pools = {
slab: makePool(scene, new THREE.BoxGeometry(1, 1, 1), concrete, 30000),
wall: makePool(scene, new THREE.BoxGeometry(1, 1, 1), facadeMaterial, 1500),
bar: makePool(scene, new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x6e4c38, metalness: 0.25 }), 20000),
rock: makePool(scene, rockGeometry(), concrete, 9000),
};
三万个楼板听起来很多,但只需要一次 draw call。
地图还有一项额外要求:只有发生变化时它才会重绘。从地图的视角来看,你的场景变化并不算「变化」。因此在每帧移动了任何物体后,都要调用 map.triggerRepaint(),否则网格数据会在内存中更新,却永远无法显示在屏幕上。
步骤 13:从轮廓到可倾倒的方块
矢量瓦片提供的是地块多边形。要让建筑倾倒,你需要一个盒体:中心点、朝向和两个边长。计算有向包围盒只需几行代码:
function orientedBox(ring, kx, ky, origin) {
// ring in lng/lat -> local metres
const loc = ring.map(([lng, lat]) => [(lng - origin.lng) * kx, (lat - origin.lat) * ky]);
// the longest edge sets the orientation
let ang = 0, best = 0;
for (let i = 1; i < loc.length; i++) {
const ex = loc[i][0] - loc[i - 1][0], ey = loc[i][1] - loc[i - 1][1];
if (ex * ex + ey * ey > best) { best = ex * ex + ey * ey; ang = Math.atan2(ey, ex); }
}
// project every vertex onto that axis and its normal, take the extents
const ca = Math.cos(ang), sa = Math.sin(ang);
let u0 = Infinity, u1 = -Infinity, v0 = Infinity, v1 = -Infinity;
for (const [x, y] of loc) {
const u = x * ca + y * sa, v = -x * sa + y * ca;
if (u < u0) u0 = u; if (u > u1) u1 = u;
if (v < v0) v0 = v; if (v > v1) v1 = v;
}
const cu = (u0 + u1) / 2, cv = (v0 + v1) / 2;
return {
x: cu * ca - cv * sa, y: cu * sa + cv * ca, // centre, metres from origin
ang,
L1: Math.max(2, u1 - u0), L2: Math.max(2, v1 - v0),
};
}
该盒体被拆分为 2 到 4 个方块堆叠,每个方块对应一个 Mesh,共同置于一个 Group 中,其原点位于建筑翻转的底边轴线上。物理模拟采用刚体倾倒模型:角加速度与 g / h · sin θ 成正比,并施加一个微小初值使其开始运动。当倾斜角度超过阈值时,上方方块脱离主体,以脱离瞬间的初速度做抛体运动:
