如何在实际地图上构建灾难模拟场景:利用 Mapbox GL JS 和 Three.js 技术实现动画效果的画布层以及可坍塌的 3D 建筑模型
在某些情况下,地图项目所需要的不仅仅是标记和多边形。你希望地图上能够出现一些动态效果:水沿着街道流动、烟雾随风飘散,或者冲击波从某个点扩散开来。 我第一次尝试实现这些效果时,做了我觉得理所当然的事情——我在地图上方添加了一个 元素,然后在上面绘制动画效果。 但结果看起来很不对劲,无论我怎么调整也无法改善这个问题。那些动画效果边缘分明,而且似乎“漂浮”在地面之上;每当我想倾斜或旋转地图时,这些动画就会与下方的地图内容对不上位。 我在开发 HazardSim 这个浏览器模拟器时遇到了这个问题。在这个模拟器中,自然灾害会在真实的地图上发生。海啸场景经历了大约十几次的修改,才终于不再看起来像是一张被贴
在某些情况下,地图项目所需要的不仅仅是标记和多边形。你希望地图上能够出现一些动态效果:水沿着街道流动、烟雾随风飘散,或者冲击波从某个点扩散开来。
我第一次尝试实现这些效果时,做了我觉得理所当然的事情——我在地图上方添加了一个元素,然后在上面绘制动画效果。
但结果看起来很不对劲,无论我怎么调整也无法改善这个问题。那些动画效果边缘分明,而且似乎“漂浮”在地面之上;每当我想倾斜或旋转地图时,这些动画就会与下方的地图内容对不上位。
我在开发HazardSim这个浏览器模拟器时遇到了这个问题。在这个模拟器中,自然灾害会在真实的地图上发生。海啸场景经历了大约十几次的修改,才终于不再看起来像是一张被贴在城市地图上的剪裁图。
很多测试工作,以及大部分错误报告,都来自我12岁的儿子蒂姆。事实证明,他比我更有耐心地进行测试。那些修改过程让我明白了同一个道理:必须用地图坐标来绘制模拟效果,而不是屏幕坐标。
这个规则帮助我成功完成了海啸模拟的实现。但在处理地震效果时,我又遇到了另一个问题:Mapbox GL JS会自动为城市生成3D模型——只要开启fill-extrusion层,所有矢量地图中的建筑物都会显示出它们的实际高度。
这种效果看起来很棒,但当你试图让某座建筑物倒塌时,你就会发现地图的3D渲染功能其实只是一种视觉效果,并不是真正的可交互场景。那些位于地面上的物体以及具有高度的物体,需要使用两种不同的技术来处理,而这份指南正好涵盖了这两种方法。
第一部分主要负责构建地面层。你需要设置一个与地图瓦片对齐的网格,加载真实的海拔数据,执行能够尊重地形起伏的填充操作,然后将
第二部分则用于添加物体的高度信息。你需要从地图瓦片中提取建筑物的轮廓和高度数据,使这些3D模型看起来像真正的建筑物,根据地震的震级和距离来判断哪些建筑物会倒塌,实现倒塌效果的动画渲染,而无需重新构建任何图层。此外,你还需要在地图的WebGL环境中设置一个three.js自定义层,将建筑物的轮廓转换成可倾斜的立方体模型,模拟碎屑和灰尘的飞溅效果,并让相机发生震动。
最后,我们会梳理一下前半部分和后半部分中出现的各种错误,其中有些错误并不会引发任何错误提示,但却让我分别耗费了一整天的时间来解决它们。
先决条件
你应当对JavaScript、`requestAnimationFrame`以及2D画布API的基础知识有充分的了解,包括`getContext("2d")`、`drawImage`和`ImageData`这些函数。
你不需要具备任何关于Web地图的背景知识。所有与地图相关的内容都会在相关章节中得到详细解释。
第二部分还假设你至少使用过一次`three.js`:了解网格、材质、场景以及摄像机的概念。如果你没有使用过`three.js`,那么掌握其基础知识也足以帮助你理解后续内容,而且那些与地图交互的部分也会被详细说明。
这些示例中使用了Mapbox GL JS,这个技术也被用于HazardSim项目中。你需要一个免费的Mapbox访问令牌才能使用底图和矢量瓦片功能。这里使用的所有API在MapLibre GL JS这个开源版本中也存在,名称都是一样的。只有在第二部分中会提到这两者之间的区别。
目录
第1部分:在地图表面上进行绘图
本指南的前半部分主要介绍与地图表面相关的各种元素:水、火势蔓延、烟雾痕迹或冲击波等。所采用的技术其实只是一个基本理念,通过后续的七个步骤,这一技术会从简单的网格结构逐渐发展成能够实现相机移动功能的系统。
核心理念:地图空间,而非屏幕空间
网页地图实际上就是一台摄像机,它观察的是地图的表面。当用户进行拖动、缩放、倾斜或旋转操作时,摄像机会随之移动,但地图表面本身保持不变。道路、建筑物以及洪水等元素都属于地图的表面,因此摄像机必须同时对这些元素进行相应的处理。
覆盖在地图DOM元素上的画布实际上是与摄像机相连的,而不是与地图表面直接关联的。可以把这个画布想象成镜头前的一块玻璃板:无论你在上面绘制什么内容,这些内容都会保持原来的位置不变,而地图背后的场景则会继续移动。
在进行自上而下的缩放操作且不进行旋转时,你可以通过在每一帧中重新计算点的坐标来模拟对齐效果,这种方法基本上是可行的。但一旦加入俯仰角度,这种效果就会失效——因为平面的图像没有透视效果,也不会出现近大远小的视觉现象,更不会体现出物体前后关系的变化。
解决这个问题的方法是让画布成为地图的数据源。Mapbox GL JS提供了canvas这种数据源类型——你只需要提供一个画布元素以及四个地理坐标点,地图就会像处理卫星图像一样,将这个画布覆盖在相应的区域上。当地图旋转时,画布也会跟着旋转;当地图倾斜时,画布的形状会发生变化;如果开启了3D地形显示功能,画布还会跟随地形的起伏进行移动。
一旦绘图表面被置于地图空间中,许多原本棘手的问题就会迎刃而解。本手册的后续内容将详细介绍你可以利用这一技术来做什么。
步骤1:选择与地图瓦片对齐的网格
你的模拟系统需要一个网格结构——这个网格是由多个单元格组成的二维数组,每个单元格中存储着高程、水深或其他模型所需跟踪的数据。让整个系统运行更加顺畅的关键在于,要将这个网格与地图本身的瓦片结构对齐。
网页地图通常使用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)));
}
接下来,需要选择合适的缩放级别。你需要确保感兴趣的区域能够被包含在一个分辨率足够高、看起来效果流畅,同时又足够小、能够每秒处理60次迭代的网格中。在HazardSim中,我通常从缩放级别15开始逐渐减小缩放比例,直到边界框的宽度约为780像素、高度约为620,000个网格单元时为止:
function chooseZoom(bbox) {
for (let z = 15; z >= 8; z--) {
const pw = (lon2xbbox.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;
}
确定缩放级别后,需要将边界框调整到能够覆盖完整的瓦片范围。这个矩形区域就是你的画布将会显示的内容,也就是你将要下载的数据。
function gridFor(bbox) {
const z = chooseZoombbox);
const x0 = Math.floor(lon2x bbox.w, z)), x1 = Math.floor(lon2xbbox.e, z));
const y0 = Math.floor(lat2ybbox.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"`,否则在使用`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的中值处理,就可以消除这些异常值;而对于那些数值低于0的单元格,通常会被视为海域,但某些地区除外,比如荷兰、新奥尔良或死亡谷。只有当某个单元格通过其他海域单元格与地图边缘相连时,才会将其视为海域——这种处理方式可以覆盖大多数常见情况。
步骤3:将Canvas作为地图数据源添加到地图中
现在需要进行的关键操作是创建一个与你的网格大小相同的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
在这里有三个重要的设置选项:
animate: true这个选项告诉Mapbox GL在每一帧中都重新加载Canvas作为纹理数据。如果不启用这个选项,动画效果会在第一帧后就停止更新。"raster-resampling": "linear"当用户将地图缩放得超过你的网格分辨率时,这个选项决定了如何处理网格中的像素数据。如果使用105米为单位的网格间距进行城市级别的渲染,这个设置会直接影响到动画效果的质量——没有它,水面的渲染效果就会与像素艺术风格的图像相差甚远。"raster-fade-duration": 0这个选项会关闭Mapbox GL对栅格图块应用的淡入淡出效果。由于每一帧都会重新绘制图像,因此不需要使用淡入淡出效果来平滑过渡画面。
之所以将Canvas的显示属性设置为display:none,是因为地图直接读取这些数据,它并不需要在DOM中可见。
从现在开始,无论你向fimg>变量中写入什么内容,然后通过fctx.putImageData(fimg, 0, 0)方法将其绘制到地图上,这些内容都会出现在正确的位置上,并且会根据地图的倾斜角度和方位进行显示;如果地图上有地形信息,这些图像也会根据地形的实际形状进行渲染。这样一来,原本复杂的渲染问题就转化成了一个简单的像素数组处理问题。
步骤4:模拟一种能够尊重地形特征的洪水效果
海啸并不会淹没所有位于波高以下的区域。水必须先从海洋流入陆地,然后流过低洼地带,在地面高度高于水面的地方停止流动——即使山脊的另一侧还有更低洼的地区也是如此。这就是一个与“连通性”相关的问题,而广度优先搜索算法正是解决这类问题的理想工具。
首先需要创建一个距离场,用来计算每个网格点距离海岸的距离。初始时,将所有与陆地相邻的海洋网格点加入队列,然后逐步扩展到更远的海洋区域。利用这个距离场,就可以模拟海浪的逼近过程;而对于陆地部分,则可以通过类似的算法来模拟洪水向内陆蔓延的情景。
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;
// 初始化:将那些与陆地相邻的海水单元格加入队列
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; }
}
// 扩展洪水范围
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公里波高衰减多少
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的网格上,整个过程只需要几毫秒的时间。程序会生成每個单元格的深度值,以及表示水势蔓延至内陆多远的范围值,这些数据就足以用来制作动画了。
由于水的填充是按照实际的地形高度来进行的,因此水会流入河谷,但在堤坝处停止流动。将被淹没的单元格数量乘以cellM²,就可以得到被淹没区域的面积,单位为平方公里——这个数值就代表了你可以站在安全区域之外的范围。
步骤5:通过绘制像素来制作动画
帧循环的主要功能是判断每个被淹没的单元格是否已经被水覆盖,如果被覆盖了,那么这些单元格应该显示什么颜色。
“是否被水覆盖”这一判断是通过将该单元格的范围值与一个会随时间逐渐变化的临界位置F进行比较来确定的。在计算范围值时加入一些随机误差,这样前沿线就不会是一条完美的等高线,从而使得模拟效果更加真实:
let t0 = null;
const T_LANDFALL = 3.9; // 海浪到达岸边所需的时间(秒)
const T_FULL = 10.9; // 海浪达到最大范围所需的时间(秒)
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;
// 表示水势蔓延至内陆多远,单位为米
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米的随机误差
if (arrival > F) { d[o + 3] = 0; continue; }
const age = (F - arrival) / 150; // 该单元格被水覆盖的时间长度
const dp = depth[i];
// 基本颜色:水域较浅的地方颜色较亮,较深的地方颜色较暗
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;
// 最前沿的水域呈泡沫状,因此在最初一段时间里颜色应接近白色
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是指所有被淹没的单元格中范围值最大的那个数值。
有两个与性能相关的重要点:一个700×500网格包含350,000个单元格,在我的笔记本电脑上运行这个循环时,帧率可以达到60帧每秒;而在一款中端手机上,帧率约为30帧每秒。如果你需要更高的性能,可以只遍历被淹没区域的单元格列表(在调用floodFill方法后收集一次这些单元格的索引),而无需遍历整个网格。在沿海城市中,被淹没的区域通常只占所有单元格的四分之一。此外,在连续绘制帧时,要确保imageData变量能够保留前一幅画的内容——如果每一帧都重新调用createImageData方法,性能分析工具肯定会指出这一点。
如果你到这一步为止,就已经得到了一个可以正常运行的、能够模拟地形变化的系统了。接下来的步骤就是让这个系统的显示效果更像真实的视频片段,而不是仅仅是一张图表而已。
步骤6:使用Canvas合成技术为视频添加纹理层
纯色图像看起来就像一张图表;而真实的水面是具有动态质感的。最简单的方法就是在被淹没的区域上播放一段真实的海洋视频,而“仅在被淹没的区域显示”这一要求可以通过两种Canvas合成模式轻松实现:
source-atop这种模式只会在目标区域已经有像素的地方绘制新的图像。使用source-atop在表示被淹没区域的Canvas上播放视频,那么视频只会显示在水面上,而陆地部分则保持原样不变,因为陆地是透明的。destination-in这种模式只会在新图像有像素的地方保留目标区域的原有内容。首先使用destination-in在缓冲区上绘制一个黑色透明掩码,然后缓冲区的显示范围就会被裁剪成这个掩码的形状。
你需要创建一个隐藏的元素(设置为其静音、循环播放,并启用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; // 数据尚未准备齐全,跳过此步骤,避免阻塞程序执行
if (vidpaused) vid.play().catch(() => {});
// 1. 将视频内容复制到Canvas缓冲区中,并将其尺寸调整为与网格相同
bctx.globalCompositeOperation = "source-over";
bctx.clear(0, 0, grid.W, grid.H);
bctx.drawImage(vid, 0, 0, grid.W, grid.H);
// 2. 仅为视频可见的区域添加颜色效果
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. 将缓冲区的内容裁剪成仅包含陆地区域的图像,这样开阔的海域就能保持原有的颜色
bctx.globalCompositeOperation = "destination-in";
bctx.drawImage(landMask, 0, 0);
// 4. 最后将处理后的图像显示在表示被淹没区域的Canvas上,只有有水的地方才会显示这个视频效果
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时,该区域会被设置为白色;其他地方则保持透明状态。如果没有使用这张画布,纹理就会覆盖整个开阔海域,导致整个海湾都会变成洪水的颜色。我在发布这个版本的程序后大约过了一个小时才注意到这个问题。
mud这个参数表示一个从0到1的时间渐变值。海啸带来的海水在最初是蓝色的,当它携带陆地上的沉积物后,颜色会变成深棕色。通过在海水登陆后的几秒钟内让颜色逐渐从蓝色过渡到棕褐色,就可以轻松实现这种效果。
当我第一次制作这个效果时,我是直接将棕色色调嵌入到视频文件中去的,因此无论如何调整颜色,水在开始时的颜色始终无法变成蓝色。建议保持原始视频素材的颜色为中性,然后通过代码来添加色彩效果。
同样的destination-in技术还可以用来实现两种效果:一种是根据深度值来调节透明度的效果(即alpha值与depth[i]成正比,使用深色填充的掩码),另一种则是在移动的海浪前沿生成泡沫带的效果(即alpha值为reach[i] - F的高斯分布值,使用另一段表示泡沫翻涌的视频进行渲染)。这两种效果都只需要几行代码就能实现,而且它们都能让海水看起来更具立体感,同时减少色彩带来的视觉干扰。
步骤7:将摄像机移入场景中
从顶部俯视的地图只是一种示意图。当改变摄像机的拍摄角度时,同样的像素所代表的区域也会发生变化。由于洪水效果使用的画布是铺设在地面上的,因此它的透视效果会正确地反映地形特征。你只需要把摄像机对准那些值得观看的地方即可。
对于海啸场景来说,这意味着你需要站在靠近城市的海岸线上,朝着大海的方向观察海啸的推进过程。“靠近城市”的位置是指地图中心附近的海岸线网格;“朝向大海”的方向则是从该点出发指向附近海域的平均方向。建议在局部范围内计算这个方向,而不是针对整个海岸线进行计算,否则如果海岸线呈弯曲形状,摄像机就会指向错误的地平线。我在敖德萨的场景中就发现了这个问题——由于整体计算的结果,摄像机最终指向了机场旁边的空地。
function shoreCamera(grid, sea, center) {
const { W, H, z, x0, y0, cellM } = grid;
// 1. 离地图中心最近的海岸线位置
let best = -1, bestD = Infinity;
for (let i = 0; i < W * H; i++) {
if (!sea[i]) continue;
const x = i % W, y = (i / W) | 0;
const coast = (x > 0 && !sea[i - 1]) || (x < W - 1 && !sea[i + 1]) ||
(y > 0 && !sea[i - W]) || (y < H - 1 && !sea[i + W]);
if (!coast) continue;
const lng = x2lon(x0 + (x + 0.5) / 256, z), lat = y2lat(y0 + (y + 0.5) / 256, z);
const d = (lng - center.lng) ** 2 + (lat - center.lat) ** 2;
if (d < bestD) { bestD = d; best = i; }
}
const sx = best % W, sy = (best / W) | 0;
// 2. 当地的朝向海洋的方向:距离周围2.5公里范围内的海岛位置的平均偏移量
const r = Math.max(6, Math.round(2500 / cellM));
let vx = 0, vy = 0;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = sx + dx, y = sy + dy;
if (x < 0 || y < 0 || x >= W || y >= H || !sea[y * W + x]) continue;
const len = Math.hypot(dx, dy);
if (len < 1 || len > r) continue;
vx += dx / len; vy += dy / len;
}
}
const len = Math.hypot(vx, vy) || 1;
const east = vx / len, north = -vy / len; // 在这个网格中,y轴的方向是向南的
const shoreLng = x2lon(x0 + (sx + 0.5) / 256, z);
const shoreLat = y2lat(y0 + (sy + 0.5) / 256, z);
const mLng = 111320 * Math.cos(shoreLat * Math.PI / 180), mLat = 111320;
return {
// 位置设置在距离海岸线900米的内陆处...
center: [shoreLng - east * 900 / mLng, shoreLat - north * 900 / mLat],
// ...并且朝向海洋
bearing: Math.atan2(east, north) * 180 / Math.PI,
};
}
因此,这里需要调用两次 `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(() => mapeaseTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000);
这两次调用所产生的效果是:摄像机首先向下移动,飞到敖德萨海岸上空,然后转向海湾方向。海水先退去,露出一片广阔的沙滩,随后海浪又涌向海滩并进入港口。你所看到的所有动态场景,其实都与第三步中使用的那幅“洪水画面”相同,只是由于摄像机的视角关系,这些景象在屏幕上呈现出了不同的形态。
需要注意的是,画面中的任何元素都不会察觉到摄像机发生了移动。循环过程只是不断地将相同的像素数据写入同一个“画布”中,而地图系统会自动完成后续的处理工作。这就是第三步所实现的效果。
地面层的限制
到目前为止,所有的视觉效果都发生在了地面上。那幅“洪水画面”其实就是地图本身的表面,正是因为这样,各种透视、旋转和地形效果才能得以完美呈现。但一旦需要模拟具有高度的物体,这种做法就无法继续使用了——你无法将一座高楼“悬挂”在栅格化的地图表面上。
我是通过惨痛的经验才了解到这一点的。我曾经尝试使用 `three.js` 创建一个真实的3D水墙作为自定义效果层,结果确实可行。但后来我也发现了一个问题,在花费时间进行开发之前,了解这个问题的原因是非常重要的。
如果从250米的距离观察一个30米高的海浪,那么这个海浪在37度的视野范围内只会占据大约7度的范围,也就是屏幕的五分之一。要想让整个画面都被这股海浪覆盖,就必须将摄像机放在距离海浪100米的地方,并且几乎贴近地面进行拍摄——因为在这种高度下,卫星图像会变得模糊不清。虽然几何模型是正确的,但这样的拍摄效果却非常糟糕。使用倾斜角度的摄像机来展示水面的效果,实际上看起来比使用正确比例绘制的水墙效果还要差。
因此,“地面层”始终只能用来表示地面的景象。但在模拟地震的场景中,就没有这样的限制了——倒塌的建筑物必须被视作一个具有三维结构的物体。这就是第二部分的内容。
第二部分:具有高度的物体

在模拟地震效果的场景中,我需要让建筑物倒塌。经过大约十轮的重建后,最终得到了这样的结果:对于那些只需要保持原位的数千座建筑物,我使用了地图系统自带的建模功能来创建它们的模型;而对于那些会倒塌的建筑物,则使用three.js在地图自身的WebGL环境中渲染它们。
这两种处理方式都会读取相同的矢量瓦片数据,共享同一个深度缓冲区,并且在任何摄像机移动的情况下都会同步更新显示效果。
两种类型的3D模型,以及为什么你需要它们
fill-extrusion层是地图引擎自带的3D模型。这种模型的处理速度很快,能够渲染数以万计的建筑物;它的光照效果会与地图的其他部分保持一致,并且其外观是由数据决定的:建筑物的高度由某个属性决定,颜色也由该属性决定;更重要的是,建筑物的高度还会乘以一个可以在运行时更改的、与每个建筑物相关的状态值。不过,这种模型无法移动——因为它的结构始终是垂直的棱柱形状。
custom层则允许你在每一帧中使用地图系统的WebGL环境和投影矩阵,在其中绘制任何你想要的内容。结合three.js>,这就意味着你可以创建真实的3D对象:这些对象具有位置、旋转和缩放等属性,数量可以达到数千个;它们会与地图共享同一个深度缓冲区,因此当有建筑物倒塌时,倒塌的物体能够正确地隐藏在仍然矗立的建筑物后面。
这种分工方式非常简单:所有的建筑物都采用extrusion模型来表示。当某座建筑物受到损坏时,它的模型尺寸会缩小(这就是那个与每个建筑物相关的“状态值”在起作用);当这座建筑物倒塌的位置离摄像机足够近、以至于用户能够看到变化时,它的模型就会完全消失,取而代之的是一个由three.js>创建的废墟模型,这个过程仅持续两秒钟。之后,这个废墟模型又会被替换回原来的建筑物模型,而帧率根本不会因此受到影响。
步骤8:将建筑物模型从地图自带的矢量瓦片中分离出来
你其实并不需要专门的建筑物数据集——因为地图本身已经在下载这样的数据了。Mapbox Streets的矢量瓦片包含了building这个源层,其中记录了建筑物的多边形轮廓、高度、最低高度以及是否需要进行三维建模等信息。querySourceFeatures函数可以读取当前已加载的所有这些数据。
function buildingSource(map) {
const sources = map.style().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" ? gcoordinates : 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 - centerlng) * 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函数可以从样式文件中获取图像,并将其覆盖在建筑物的墙壁上;你还可以在程序运行时使用map.addImage函数为这些图像添加新的内容。
可以在画布上绘制一些立面纹理效果(例如,墙上排列成行的窗户,颜色稍作变化并加入一些随机噪声,这样就不会显得像是计算机生成的图像),将这些纹理注册到系统中,然后根据每栋建筑的高度为其选择合适的纹理:
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);
// 绘制四层窗户的图案,每层有16个窗口
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);
}
}
// 添加颗粒质感
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");
接下来需要设置图层的属性。建筑的高度和基底宽度都是通过表达式计算得出的,这两个数值都会乘以一个名为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);
// 在建筑物顶部添加一层较薄的拉伸效果,以形成屋顶的纹理
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.style().layers来找到它。
由于模式无法针对每个特征进行旋转或缩放,因此窗户的位置与楼层的对齐情况并不完全一致。但在当前设置的摄像机高度下,这种差异根本看不出来。
步骤10:确定哪些建筑物会倒塌
现在来看模拟部分,这部分内容相对简短。对于每一栋建筑物来说,都需要知道该位置处的震动强度,以及在这种震动强度下该类型建筑物发生倒塌的概率。
震动强度是用修正的麦加利震级来表示的,范围从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 - epicentrelng) * 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表示倒塌,1表示受损,0表示完好无损
b.start = 0.8 + Math.random() * 2.8; // 地震发生后的秒数
}
}
每栋建筑物在模拟中的随机性正是这种模拟方法的核心所在。两座完全相同的建筑并排矗立,其中一座可能会倒塌,而另一座则完好无损。现实中的地震也是如此。步骤11:实现层的折叠动画效果而不重新构建这些层
这就是步骤9中采用的机制所带来的实际效果。在每一帧中,对于每一个正在倒塌的建筑物,都会将其特征状态k设置为一个介于1与其最终残骸高度之间的数值。这样,建筑物的形状就会逐渐缩小,但不会重新构建任何层,也不会重新生成GeoJSON数据,更不会重新解析地图瓦片。
map.setFeatureState({ source: "bld", id: b.id }, { k: 0.62 });
不过这里有一个问题,也正是这个问题让我在这个项目中花费了最多的时间:setFeatureState方法需要一个特征ID,而Mapbox Streets提供的建筑物数据并没有这样的ID。如果直接使用这个方法来处理这些数据,那么根本不会产生任何效果。
解决办法是将你收集到的这些建筑物数据复制到自己创建的GeoJSON数据源中,并为这些建筑物指定一个由你自己控制的id,然后让系统将这个id设置为特征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栋建筑物了。在GeoJSON数据源报告isSourceLoaded之前,请保留原有的地图瓦片层;之后再删除这些层,这样就不会有任何帧出现城市画面闪烁的情况了。
k这个数值随时间变化的变化方式,决定了建筑物的倒塌效果是真正的倒塌,还是只是看起来像建筑物滑入了地面。最终我设计了五种不同的变化模式,每种模式都是针对特定的建筑物随机选择的,并且其变化幅度会受到建筑物高度的限制:
// k0表示起始高度倍数(默认为1),kf表示最终残骸高度,u表示变化进度(0到1之间)
function collapseK(b, u) {
const k0 = b.k0, sp = k0 - b.kf;
switch (b.mode) {
case 1: { // 逐层倒塌:先快速下降一段时间,然后暂停
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;
}
}
对于那些虽然受损但仍然矗立在原处的建筑物(cat === 1),kf的值接近1,因此这些建筑物的倒塌过程会表现为短暂的下降:它们会明显下降半米然后停止移动。这个细节虽然很小,但却能让整条街道看起来都受到了影响,而不仅仅是一些建筑物发生了倒塌。
请将dt的值限制在40毫秒以内。如果动画播放过程中某个帧的渲染时间超过了25毫秒,那么正在下降中的建筑物就会突然停止移动,这种变化是肉眼可以立刻察觉到的。
步骤12:地图中的Three.js场景
对于远处的建筑物,可以使用缩放技术来处理它们的显示效果;而对于靠近相机的建筑物,则需要让观众能够看到它们结构分解的细节——比如塔楼倾斜后断裂成碎片、碎屑从建筑物底部飞溅出来,或者废墟散落在街道上。为了实现这些效果,就需要使用three.js这个库。
自定义图层会接收两个回调函数:onAdd在图层被添加到地图中时被调用,此时会传入地图对象及其GL渲染上下文;render则会在每一帧绘制时被调用,此时会传入相应的渲染上下文以及地图的投影矩阵。需要注意的是坐标系统的转换:我们应该使用以米为单位的坐标系,而不是墨卡托坐标系,因此需要构建一个转换矩阵,并在每一帧绘制时将其应用到地图的投影计算中:
function makeScene(map, origin, before) {
const mc = mapboxgl.MercatorCoordinate.fromLngLat([originlng, origin.lat], 0);
const s = mc.meterInMercatorCoordinateUnits();
// 原点处向东/向北/向上的距离(以米为单位)→ 转换为墨卡托坐标系的距离。在墨卡托坐标系中,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(); // 由于地图的更新,GL渲染状态可能发生了变化
renderer.render(scene, camera);
},
};
map.addLayer(layer, before);
return { scene, camera, layer };
}
在MapLibre中,render函数的第二个参数是一个对象,而矩阵数据存储在args.defaultProjectionData.mainMatrix中。其他方面都是一样的。
renderingMode: "3d"这个设置使得地图能够与你共享其深度缓冲区。例如,当一块混凝土结构位于某个凸出物之后时,这块混凝土就会被该凸出物遮挡而无法显示出来——这就是为什么需要将渲染结果直接放入地图的上下文中,而不是在另一个独立的画布上显示的原因。
当有成千上万的碎片需要被模拟时,就可以使用实例化技术。 HazardSim会维护一个InstancedMesh对象池,每种类型的碎片都会对应一个这样的对象(比如板块、墙块碎片、钢筋、岩石或屋顶结构),每个对象的容量是固定的。每当新的碎片被创建时,系统就会为这些对象分配相应的索引。
function makePool(scene, geometry, material, capacity) {
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
// 提前为所有对象分配颜色缓冲区(具体实现可参考相关文档)
mesh.instanceColor = new THREEInstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
mesh.count = 0;
mesh.frustumCulled = false; // 地图使用的相机并非three.js默认的相机类型
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.addmesh);
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),
};
虽然有三万块板块需要被模拟,但实际上只需要进行一次渲染操作而已。
地图还需要你做一件事:只有当某些内容发生变化时,地图才会重新绘制。从地图的角度来看,你的场景中的元素发生变化并不等同于地图本身需要更新。因此,在每一帧中,当你移动了任何对象之后,都应该调用map.triggerRepaint()方法,这样才能确保你的网格模型能够在屏幕上正确显示出来。

步骤13:从建筑物轮廓到会倾倒的盒子
矢量贴图可以生成建筑物的轮廓多边形。但要让建筑物倾倒,就需要一个“盒子”模型——这个盒子需要有一个中心点、一个方向以及两个边长。这种有方向的边界盒可以用以下代码表示:
function orientedBox(ring, kx, ky, origin) {
// 将经纬度坐标转换为局部米坐标
const loc = ring.map(([lng, lat]) => [(lng - origin.lng) * kx, (lat - origin.lat) * ky]);
// 最长的边决定了盒子的方向
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); }
}
// 将每个顶点投影到该轴及其法线上,从而得到盒子的边界范围
const ca = Math.cos(ang), sa = Math.sin(angle);
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, // 中心点,以原点为基准的坐标
ang,
L1: Math.max(2, u1 - u0), L2: Math.max(2, v1 - v0),
};
}