How to Build Disaster Simulations on a Real Map: Animated Canvas Layers and Collapsing 3D Buildings with Mapbox GL JS and Three.js

TL;DR · AI 摘要
本文详解如何用Mapbox GL JS和Three.js构建真实地图灾难模拟,涵盖动画画布层与3D建筑坍塌技术。
核心要点
- 使用Mapbox GL JS处理地面层时需对齐地图瓦片坐标系
- Three.js实现建筑坍塌需在WebGL上下文中创建自定义图层
- 地震模拟中建筑倒塌需根据震级和距离计算触发条件
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 灾难模拟技术
- 地面层技术
- 栅格化地形处理
- 视频纹理合成
- 3D建筑模拟
- Three.js集成
- 物理破坏算法
金句 / Highlights
值得收藏与分享的关键句。
作者通过12岁儿子的测试发现动画必须使用地图坐标系而非屏幕坐标系
Mapbox的3D渲染是视觉欺骗而非真实场景,无法直接操作建筑模型
地震模拟中建筑倒塌需根据震级和距离计算触发条件
如何在真实地图上构建灾难模拟:使用 Mapbox GL JS 和 Three.js 实现动画画布图层与坍塌的3D建筑
2026年10月15日
/
#JavaScript
Alex Oliinyk
在某些时候,地图项目需要的不仅仅是标记和多边形。你希望地图上发生一些事情:水沿着街道上涨,烟雾随风飘散,或者冲击波从某一点扩散出去。
我第一次尝试实现这个功能时,采取了在我看来显而易见的方式。我在地图上覆盖了一个 <canvas> 元素,并在其上绘制动画。
但这种做法存在无法通过优化解决的缺陷。效果看起来有边缘感,悬浮在地面上方。一旦倾斜或旋转地图,它就不再与下方的任何内容对齐。
我在开发 HazardSim(一个在真实地图上运行自然灾害的浏览器模拟器)时遇到了这个问题。海啸场景经历了大约十几次重写,才不再看起来像被粘贴在城市上的剪贴画。
大部分测试和相当一部分的错误报告都来自我12岁的儿子 Tim,他最终证明比我更耐心。这些重写过程从不同角度教会了我同一件事:将模拟绘制在地图坐标中,而不是屏幕坐标中。
这一规则帮助我度过了海啸模拟。当我开始开发地震场景时,却遇到了相反的问题。Mapbox GL JS 可以为你绘制3D城市:开启 fill-extrusion 图层后,矢量瓦片中的每个地块都会按照真实高度隆起。
这种效果在你尝试让其中一些建筑坍塌时就会失效,你会发现地图的3D效果只是一种渲染技巧,而非真实场景。你无法抓取、旋转或破坏任何物体。地面物体和具有高度的物体需要两种不同的技术,本文将涵盖这两种技术。
第一部分将构建地面图层。你将设置与地图瓦片对齐的网格,加载真实高程数据,运行尊重地形起伏的洪水填充,将画布作为栅格源提交给地图,每帧向其中写入像素,通过画布合成模式在上方叠加视频纹理,并将相机移动到场景中。
第二部分将添加高度。你将从地图瓦片中提取建筑轮廓和高度,使拉伸效果看起来像真实建筑,根据震级和距离决定哪些建筑会坍塌,无需重建任何图层即可动画化坍塌过程,在地图自身的 WebGL 上下文中设置 three.js 自定义图层,将轮廓转化为倾倒的立方体,抛掷碎片和尘土,并摇晃相机。
最后我们将回顾两个部分中出现的错误,其中一些错误不会产生任何错误提示,但每个都耗费了我一整天的时间。
先决条件
你应该熟悉 JavaScript、requestAnimationFrame 以及 2D 画布 API 的基础知识:getContext("2d")、drawImage 和 ImageData。
你不需要任何网络地图的背景知识。所有与地图相关的特定内容都会在出现时进行解释。
第二部分还假设你至少使用过一次 three.js:网格、材质、场景和相机。如果你没有使用过,掌握 three.js 的基础知识就足以跟上进度,与地图交互的部分也将在文中完整解释。
示例使用了 Mapbox GL JS,这也是 HazardSim 所采用的技术。你需要一个免费的 Mapbox 访问令牌来加载底图和矢量瓦片。所有在此处使用的 API 在开源分支 MapLibre GL JS 中也存在,名称相同。两者的唯一差异将在第二部分中特别说明。
目录
- 第1部分:在地面上绘制
- 核心思想:使用地图空间,而非屏幕空间
- 步骤1:选择与地图瓦片对齐的网格
- 步骤2:将真实高程加载到网格中
- 步骤3:将画布作为地图数据源附加
- 步骤4:模拟符合地形的洪水
- 步骤5:通过像素绘制实现动画
- 步骤6:使用画布合成叠加视频纹理
- 步骤7:将相机移入场景
- 地面层的终点
- 第2部分:具有高度的物体
- 两种3D形式及其必要性
- 步骤8:将建筑物从地图瓦片中分离
- 步骤9:使拉伸效果呈现建筑物形态
- 步骤10:确定坠落物体
- 步骤11:无需重建图层实现坍塌动画
- 步骤12:在地图中嵌入three.js场景
- 步骤13:从轮廓到倾倒的立方体
- 步骤14:碎片与尘埃
- 步骤15:相机必须产生震动
- 保持性能
- 问题分析与原因
- 结论
第1部分:在地面上绘制
本指南的前半部分涉及所有属于地图表面的元素:水、火势蔓延、烟雾痕迹或冲击波。该技术基于一个核心理念的精确应用,接下来的七个步骤将从网格构建到相机移动逐步实现。
核心思想:使用地图空间,而非屏幕空间
网络地图是一个观察表面的相机。当用户拖动、缩放、倾斜或旋转时,相机移动而表面保持原位。道路、建筑物和洪水都属于表面,因此相机必须将它们一起转换。
覆盖地图DOM元素的画布与相机绑定,而非与表面绑定。可以想象成镜头前方的一块玻璃板。无论世界如何移动,你在画布上绘制的内容都会保持在绘制的位置。
在无旋转的俯视视角下,可以通过每帧重新投影点坐标来模拟对齐,这几乎能奏效。但加入倾斜角度后就会失效:平面精灵没有透视效果,没有近景缩短,也没有前后层次。
解决方案是将画布作为数据源提供给地图。Mapbox GL JS提供了画布数据源类型。你只需传入画布元素和四个地理坐标角,之后地图会像覆盖卫星影像一样将画布覆盖在矩形区域上。旋转地图时画布同步旋转,倾斜时画布产生近景缩短,开启3D地形后画布会随地形起伏。
当绘制表面存在于地图空间后,多个难题迎刃而解。本手册的其余部分将探讨由此带来的可能性。
步骤1:选择与地图瓦片对齐的网格
你的模拟需要一个网格:一个二维单元格数组,每个单元格存储高程、水深或模型追踪的任何数据。使后续所有步骤更简单的关键决策是将该网格与地图自身的瓦片金字塔对齐。
网络地图使用Web墨卡托投影,按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 = (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;
}确定缩放级别后,将边界框向外对齐到完整的瓦片。这个矩形就是你的画布覆盖范围,这些瓦片就是你需要下载的:
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米/单元格的精度。
第二步:将真实高程数据加载到网格中
忽略地形的洪水模拟只是个蓝色色块。让真实地图上的模拟显得真实的是真实地形,而地形数据是免费的。
AWS开放数据计划托管了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" 是必需的。缺少它会导致画布被标记为 "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 作为地图源附加
现在是关键步骤。创建与网格大小相同的画布,并将其注册为地图源,其四个角是步骤 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 每帧都将画布重新上传为纹理。如果没有它,动画将在第一帧冻结。
- "raster-resampling": "linear" 在用户放大超过网格分辨率时在单元格之间进行混合。在城市缩放级别使用 105 米单元格时,这决定了是显示水还是像素艺术游戏。
- "raster-fade-duration": 0 关闭 Mapbox GL 对栅格瓦片应用的交叉淡入效果。你每帧都在重绘,淡入只会造成模糊。
画布设置为 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 步:通过像素绘制实现动画
帧循环只做一件事。对于每个被淹没的陆地单元格,判断水是否已到达,如果到达则确定其颜色。
"已到达"是通过单元格的 reach 值与随时间增长的前沿位置 F 进行比较得出的。对 reach 值添加一些单元格级别的噪声,使前沿不再是完美的等高线,水的视觉效果会更真实:
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 是一个在 [-0.5, 0.5] 范围内生成的 Float32Array 随机值数组,每个运行周期只生成一次。maxReach 是所有被淹没单元格中最大的 reach 值。
有两个性能优化点:700×500 的网格包含 350,000 个单元格,我的笔记本电脑以 60 帧/秒的速度运行这个循环,中端手机约为 30 帧/秒。如果需要更高的性能,可以只遍历被淹没的陆地单元格列表(在 floodFill 后收集一次索引),而不是整个网格。在沿海城市,通常只有四分之一的单元格会被淹没。同时要保持 fimg 在帧之间复用。每帧都调用 createImageData 是性能分析工具会首先指出的问题。
如果在此处停止,你已经获得了一个功能正常且考虑地形因素的模拟。其余内容主要是让模拟看起来更像视频素材,而不是示意图。
步骤6:使用画布合成叠加视频纹理
纯色看起来像是示意图。真实的水有动态的纹理。最经济的方法是将一段循环播放的真实水视频覆盖在被淹没的区域上,而"仅覆盖被淹没区域"这一限制条件可以通过两种画布合成模式免费实现:
- source-atop 仅在目标已有像素的位置绘制新图像。使用 source-atop 将视频绘制到洪水画布上时,视频只会显示在水面上。干燥的陆地保持原样,因为干燥陆地是透明的。
- destination-in 仅保留新图像有像素的位置的目标内容。使用 destination-in 将黑白透明蒙版绘制到缓冲区时,缓冲区会被裁剪成蒙版的形状。
你需要一个隐藏的 <video> 元素(静音、循环播放且 playsInline),必须同时满足这三个条件,否则移动版 Safari 会拒绝自动播放。还需要一个与网格大小相同的画布:
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 (vid.paused) vid.play().catch(() => {});
// 1. 将视频绘制到临时缓冲区,拉伸至网格尺寸
bctx.globalCompositeOperation = "source-over";
bctx.clearRect(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. 仅在已有水的区域绘制到洪水画布
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 的时间渐变。海啸的海水最初是海水,当它从陆地上卷起沉积物时会变成深棕色。在登陆后几秒钟内,将色调从海蓝色渐变到泥棕色,可以免费实现这种效果。
我第一次构建这个功能时,曾将棕色色调直接烘焙到视频文件中,无论怎么调整色调都无法让水在开始时呈现蓝色。保持源素材的中性色调,然后在代码中进行颜色处理是更好的做法。
相同的“目标点”技巧可以实现基于深度的暗化效果(一个alpha值与深度[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(() => map.easeTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000);以下是这两个调用产生的效果。摄像机已降至敖德萨海岸上方的陡峭角度,并转向面向海湾。海水首先退去,露出一片宽阔的沙滩,随后浪潮涌上岸并进入港口。你所看到的一切移动物体都是来自第3步的同一洪水画布,因摄像机视角和地形而被压缩变形。
请注意,画面循环中的任何内容都不知道也不关心摄像机的移动。循环持续将相同的像素写入相同的画布,而地图负责其余部分。这就是第3步为你带来的优势。
地面层的终点
到目前为止,所有内容都位于地面上。洪水画布是地图自身的表面,这正是其有效的原因:透视、旋转和地形都是免费提供的。当你需要具有高度的元素时,这种技巧就失效了。你无法将塔楼覆盖到栅格图像上。
我以昂贵的方式学会了这条界限。我曾用three.js构建了一个真实的3D水墙自定义图层,它确实有效。但我也曾将其移除,在你投入时间之前,了解背后的原因很重要。
从250米远处看到的30米高波浪,会覆盖37度视野中的约7度,即屏幕的五分之一。要让其填满画面,你必须将摄像机放在距离它100米处,几乎贴近地面,此时卫星图像会变得模糊不清。几何结构是正确的,但画面效果很差。在倾斜摄像机下,覆盖的水体反而看起来比正确比例的墙更多。
因此地面层始终保持为地面层。但地震场景没有这样的逃避余地:倒塌的建筑必须是一个物体。这就是第二部分。
第二部分:具有高度的物体
对于地震场景,我需要让建筑倒塌。这部分是我经过大约十轮重建后最终得到的方案:对于只需矗立在那的数千栋建筑,使用地图原生的拉伸层;而对于需要倒塌的建筑,则使用three.js场景渲染到地图自身的WebGL上下文中。
两种路径读取相同的矢量瓦片,共享相同的深度缓冲区,并在每次摄像机移动时同步移动。
两种3D技术及其必要性
填充拉伸层是地图引擎自身的3D技术。它运行速度快,可处理数以万计的建筑,与地图其他部分保持一致的光照效果,并由数据表达式驱动:高度来自属性,颜色来自属性,最重要的是,高度乘以可在运行时更改的每要素状态。它无法实现移动。拉伸层始终是垂直立于地面的棱柱。
自定义图层每帧都会提供地图的WebGL上下文和投影矩阵,允许你绘制任何想要的内容。在three.js端,这意味着真正的物体:具有位置、旋转和缩放的网格,可批量实例化,写入与地图相同的深度缓冲区,从而让下落的方块正确消失在仍在矗立的建筑之后。
有效的分割方案很简单。每栋建筑都是一个拉伸层。当一栋建筑受损时,其拉伸层会收缩(这就是每要素状态的作用)。当一栋建筑倒塌到足够接近摄像机,使你能够看到差异时,拉伸层收缩至消失,three.js物体在倒塌的两秒内取而代之。随后,物体被替换为一堆碎石(同样是three.js实现),帧率不会察觉任何变化。
第8步:将建筑移出地图自身的瓦片
你不需要建筑数据集。地图已经在下载一个。Mapbox Streets矢量瓦片包含一个建筑源图层,其中包含多边形轮廓、高度、地面以上建筑的最小高度以及一个拉伸标志。querySourceFeatures会读取当前加载的内容:
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 会从样式中的精灵图中获取图像,并将其平铺在墙面之上,而且你可以通过 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");接下来是图层配置。高度和基底都是表达式,且都乘以一个默认值为1的要素状态值 k。这个乘数就是整个坍塌机制的核心,现在将其添加进去:
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 应该是样式中第一个符号图层的ID,这样建筑会显示在标签下方。可以通过遍历 map.getStyle().layers 找到这个图层。
由于图案无法按要素单独旋转或缩放,因此窗户不会与楼层完全对齐。在当前相机高度下,这种差异肉眼不可见。
第10步:确定倒塌范围
现在进入模拟部分,这部分内容较短。对于每个建筑,你需要获取其位置的震动强度,以及该强度下该类型建筑倒塌的概率。
地震的震动强度以修正麦加利烈度表(I至XII级)表示。标准的衰减关系可根据震级M和距离r(单位:公里)计算强度(震源深度为浅层地壳地震的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需要特征ID,而Mapbox Streets的建筑数据没有这个字段。对矢量图层调用该方法时会静默失效。
解决方案是将收集的建筑数据复制到自己的GeoJSON源中,并指定一个可控的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: { // 逐层坍塌: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 毫秒。如果标签卡顿导致一帧耗时四分之一秒,正在下落的建筑会突然跳跃而不是平滑下落,人眼会立刻察觉到这种异常。
第12步:在地图中嵌入Three.js场景
远处的建筑使用缩放的拉伸效果处理。对于靠近摄像机的建筑,你希望看到结构破坏的细节:塔楼倾倒并碎裂成块,碎片从基座飞出,或废墟散落在街道上。这需要3D对象,因此这时就需要使用three.js。
自定义图层获得两个回调:onAdd 仅调用一次,传入地图及其GL上下文;render 每帧调用一次,传入上下文和地图的投影矩阵。不明显的是坐标系统。你希望以原点为基准使用米制单位,而不是墨卡托单位,因此构建一个转换矩阵,并将其乘入地图的投影矩阵中每帧:
function makeScene(map, origin, before) {
const mc = mapboxgl.MercatorCoordinate.fromLngLat([origin.lng, origin.lat], 0);
const s = mc.meterInMercatorCoordinateUnits();
// 原点处的东/北/上方向对应Mercator坐标系。Mercator坐标系的y轴向南增长,因此取负值
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"的作用是让地图与你共享深度缓冲区。当混凝土块落在立起的拉伸体后面时,会被其遮挡,这正是要将渲染操作集成到地图上下文中而非单独在顶层画布上进行的全部原因。
数千个碎片意味着需要使用实例化。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), };
三万个板看起来很多,但只是一次绘制调用。
地图还需要你提供另一个信息:它仅在发生变化时才会重绘。从地图的角度来看,场景变化并不算作变化。在每次移动物体的帧结束时,调用 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中,Group的原点在建筑将绕其旋转的底部边缘。物理模拟是刚体倾倒:角加速度与 g / h · sin θ 成正比,并有一个小的初始推动力使其开始运动。当倾斜超过阈值时,上层的块会脱离并以当时的速度进行抛物线运动:
function startTip(scene, box, h, onLand) { const n = Math.max(2, Math.min(4, Math.round(h / 9))), hs = h / n; const geo = new THREE.BoxGeometry(box.L1, box.L2, hs); const group = new THREE.Group(); group.position.set(box.x + Math.cos(box.ang) * box.L1 / 2, box.y + Math.sin(box.ang) * box.L1 / 2, 0); group.rotation.z = box.ang; scene.add(group);
const segs = []; for (let i = 0; i < n; i++) { const m = new THREE.Mesh(geo, wallMaterial); m.frustumCulled = false; group.add(m); segs.push({ m, free: false, gone: false, x: -box.L1 / 2, z: hs * (i + 0.5), th: 0, px: 0, pz: 0 }); }
const tip = { th: 0.03, w: 0.22, broke: false, bend: 0.1 + Math.random() * 0.14, brk: 0.4 + Math.random() * 0.3 };
function pose() {
let bx = -box.L1 / 2, bz = 0;
for (let j = 0; j < n; j++) {
const s = segs[j];
if (s.free) continue;
const th = tip.th * (1 + tip.bend * j); // 上部块体弯曲角度略大
const sn = Math.sin(th), cs = Math.cos(th);
if (j === 0) { bx = -box.L1 / 2 * cs; bz = box.L1 / 2 * sn; }
s.px = s.x; s.pz = s.z;
s.x = bx + sn * hs / 2; s.z = bz + cs * hs / 2; s.th = th;
bx += sn * hs; bz += cs * hs;
s.m.position.set(s.x, 0, s.z);
s.m.rotation.y = th;
}
}
return function tick(dt) {
tip.w = Math.min(2.2, tip.w + 1.5 * 9.8 / h * Math.sin(tip.th) * dt);
tip.th += Math.min(0.09, tip.w * dt); // 每帧最大偏转角度约5度
pose();
if (!tip.broke && tip.th * (1 + tip.bend * (n - 1)) > tip.brk && n > 1) {
tip.broke = true;
for (let j = 1; j < n; j++) {
const s = segs[j];
s.free = true;
s.vx = Math.max(-18, Math.min(18, (s.x - s.px) / Math.max(dt, 0.008)));
s.vz = Math.max(-18, Math.min(18, (s.z - s.pz) / Math.max(dt, 0.008)));
s.w = tip.w * (1 + tip.bend * j) * (1.05 + Math.random() * 0.25);
}
}
let left = 0;
for (const s of segs) {
if (s.gone) continue;
if (s.free) {
s.vz -= 9.8 * dt; s.x += s.vx * dt; s.z += s.vz * dt; s.th += s.w * dt;
s.m.position.set(s.x, 0, s.z);
s.m.rotation.y = s.th;
}
const landed = s.free ? s.z < Math.min(hs, box.L1) * 0.5 + 0.3 : tip.th >= 1.25;
if (landed) {
s.gone = true;
group.remove(s.m);
onLand(group.position.x + Math.cos(box.ang) * s.x, group.position.y + Math.sin(box.ang) * s.x);
} else left++;
}
if (!left) { scene.remove(group); geo.dispose(); return true; } // 完成
return false;
};
这些限制条件并非装饰。脱离时的速度限制为18 m/s,帧间隔dt最小为8 ms,因为速度是通过位置差计算得出的,否则一个卡顿帧可能会将块体发射到轨道上。出于相同原因,角速度限制为2.5 rad/s,倾斜角度每帧不超过0.09 rad。所有这些数值都是在目睹建筑做出建筑无法做到的事情后添加的。
onLand是崩塌效果开始呈现的关键:每个撞击地面的块体都会在该位置生成碎片和尘埃,并留下堆积物。
## 第14步:碎片与尘埃
碎片是第12步的实例化池加上重力效果。当崩塌建筑达到某个里程碑(首次下落、逐层模式中的每层楼、块体着陆)时,从建筑轮廓边缘抛出若干碎片,速度方向指向远离中心:
function throwDebris(pools, b, origin, kx, ky, top, n) {
const ring = b.polys[0][0];
const cx = (b.lng - origin.lng) * kx, cy = (b.lat - origin.lat) * ky;
for (let i = 0; i < n; i++) {
// 在足迹的随机边上选择一个随机点
const e = 1 + Math.floor(Math.random() * (ring.length - 1)), t = Math.random();
const a = ring[e - 1], z = ring[e];
const x = ((a[0] + (z[0] - a[0]) * t) - origin.lng) * kx;
const y = ((a[1] + (z[1] - a[1]) * t) - origin.lat) * ky;
// 向外抛射,带有一点扩散
const ox = x - cx, oy = y - cy, ol = Math.hypot(ox, oy) || 1, v = 2 + Math.random() * 6;
const size = 0.3 + Math.random() * 1.1;
const idx = pools.rock.add({
x, y, z: top * (0.35 + Math.random() * 0.65),
yaw: Math.random() * 6, tx: Math.random() * 6, ty: Math.random() * 6,
sx: size, sy: size * 0.8, sz: size * 0.6, rgb: concreteColour(),
});
if (idx >= 0) flying.push({ idx, x, y, z: top, vx: ox / ol * v, vy: oy / ol * v, vz: Math.random() * 3, spin: (Math.random() - 0.5) * 8 });
}
}
function tickDebris(pools, dt) {
for (let i = flying.length - 1; i >= 0; i--) {
const p = flying[i];
p.vz -= 9.8 * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
p.tx += p.spin * dt;
if (p.z <= p.sz * 0.35) { p.z = p.sz * 0.35; flying.splice(i, 1); } // 着陆后保持为碎石
pools.rock.set(p.idx, p);
}
}落地的碎片将停止更新并保留在池中。这就是你免费获得的碎石效果。
HazardSim在此基础上增加了更多细节:在9米网格上叠加的楼板,建筑原址的墙碎片带窗户,弯曲的钢筋,堆下略微隆起的填充物以避免地面完全平坦,以及对最近建筑使用通过摄影测量扫描的碎石模型。这些属于细节优化,而池和重力系统已经完成了大部分工作。
灰尘是我唯一在屏幕空间中绘制的元素,有必要说明为什么在这里使用屏幕空间绘制是可行的,而第一部分中水的屏幕空间绘制却导致了致命问题。灰尘粒子体积小,数量可达数百个,每个粒子存活3到8秒。它们还以软精灵形式绘制,每个帧从地图点投影出每个粒子的位置、尺寸和旋转。没有可见的边缘,也没有需要对齐的元素。屏幕空间规则适用于具有形状的大而持久的对象。
关键决策在于灰尘的来源。早期版本在城市上空有持续的雾霾和燃烧的火焰,看起来像屏幕保护程序。真正逼真的效果是让灰尘只来自事件:楼层坠落时的尘雾,建筑落地时沿地面的尘浪,以及软层建筑倒塌时从地面喷出的尘柱。每个事件都会释放一个与建筑占地面积成比例、垂直速度与下落高度成比例的尘埃爆发,直到下一个事件发生前不再产生任何灰尘。
function emitDust(b, level, n, surge) {
for (let i = 0; i < n && dust.length < MAX_DUST; i++) {
const a = Math.random() * Math.PI * 2;
const speed = surge ? 5 + Math.random() * 8 : 1 + Math.random() * 3;
dust.push({
lng: b.lng, lat: b.lat, // anchor on the map
x: Math.cos(a) * b.rf * 0.6, y: Math.sin(a) * b.rf * 0.6, // offset, metres
z: surge ? Math.random() * 5 : level * (0.2 + Math.random() * 0.8),
vx: Math.cos(a) * speed, vy: Math.sin(a) * speed,
vz: surge ? 0.3 + Math.random() * 1.2 : 1.5 + Math.random() * 3,
size: (surge ? b.rf * 0.5 + 6 : b.rf * 0.6 + 4) * (0.7 + Math.random() * 0.6),
grow: 3 + Math.random() * 5,
t: 0, life: (surge ? 5 : 3.5) + Math.random() * 2.5,
sprite: sprites[Math.floor(Math.random() * sprites.length)],
alpha: 0.5 + Math.random() * 0.3,
});
}
}每一帧中,每个粒子都会减速、随风漂移、变大并被投射到屏幕上:map.project([lng + x / kx, lat + y / ky]) 会给出其地面点的屏幕位置,高度则通过 y -= z * pixelsPerMetre * sin(pitch) 应用。按屏幕深度排序,使用 drawImage 从后往前绘制,并随时间推移逐渐淡出。在 2D 画布上,每帧对地图进行几百次 drawImage 调用,几乎不消耗资源。
第 15 步:相机必须震动
到目前为止的所有内容都可以在屏幕角落安静地运行。让观众感受到地震的是相机。
有两个关键点。首先是下潜:从概览视角,平滑过渡到 45 度角的 14.6 级缩放,保持直到瓦片加载完成,然后平滑过渡到震中上方 62 度角的 16.2 级缩放。保持阶段是强制性的。如果你直接下潜到 16 级缩放,对应层级的矢量瓦片尚未到达,querySourceFeatures 返回的内容几乎为空,地震就会落在一座空城里。
function diveToCity(map, center, then) {
const bearing = map.getBearing();
map.easeTo({ center, zoom: 14.6, pitch: 45, bearing: bearing - 10, duration: 1100, essential: true });
const t1 = Date.now();
(function hold() {
const loaded = !map.isMoving() && map.areTilesLoaded();
if (Date.now() - t1 > 1000 && (loaded || Date.now() - t1 > 2000)) {
map.easeTo({ center, zoom: 16.2, pitch: 62, bearing: bearing - 25, duration: 1500, essential: true });
map.once("moveend", then);
} else setTimeout(hold, 150);
})();
}其次,是震动。不要移动地图相机。通过 easeTo 实现的抖动相机将与动画系统冲突,并为每次抖动加载瓦片。使用 CSS 变换震动容器。地图不会察觉到移动:
function shake(durationMs, magnitude) {
const el = map.getContainer();
const w = window.innerWidth, h = window.innerHeight;
const amp = Math.min(28, w * 0.02, Math.max(6, (magnitude - 4) * 4.2));
const dir = Math.random() * Math.PI;
const t0 = performance.now();
let last = t0, phase = 0;
function tick(now) {
const k = (now - t0) / durationMs;
if (k >= 1) { el.style.transform = ""; return; }
const dt = Math.min(0.05, (now - last) / 1000); last = now;
// 前三分之一时间逐渐增强,随后衰减;频率随之上升
let env, omega;
if (k < 0.35) { const u = k / 0.35; env = 0.15 + 0.85 * u * u * (3 - 2 * u); omega = 7 + 39 * u * u; }
else { const v = (k - 0.35) / 0.65; env = Math.pow(1 - v, 1.4); omega = 46 - 18 * v; }
phase += omega * dt;
</think>const a = amp * env;
const osc = Math.sin(phase) * 0.78 + (Math.random() - 0.5) * 0.44 * Math.min(1, omega / 30);
const x = Math.cos(dir) * osc * a, y = Math.sin(dir) * osc * a * 0.7;
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(osc * a * 0.012).toFixed(3)}deg) scale(${(1 + 2.4 * amp / Math.min(w, h) * env).toFixed(4)})`;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}包络线比振幅更重要。真实的震动会持续一两秒后达到峰值,随后在数秒内逐渐减弱,且频率会随着震动增强而上升。恒定的抖动会让人觉得视频损坏了。主震后约半振幅的余震在三到四秒后出现,能完整呈现效果。
当震动停止后,让镜头缓缓下移至附近坍塌区域中心并停留数秒,让废墟获得展示时刻,随后再拉回至强度环和伤亡数据区域。
保持性能
一些关键决策让该方案在笔记本上保持60fps,且在手机上也可用。
1. 固定容量的对象池
每个实例化对象池都有容量上限,当池满时add方法返回-1。低功耗设备的容量会降低:9000块而不是30000块,3000块石头而不是9000块。场景质量会降级为更少碎片而不是变成幻灯片。
2. 一次性确定设备等级
移动设备用户代理、navigator.deviceMemory < 6、hardwareConcurrency <= 4,或屏幕短边小于700px的设备会被归为低等级。低等级设备完全跳过three.js的倾斜效果,仅使用收缩外推,将灰尘上限降低至三分之一,并移除主角模型。
3. 每1.2秒最多重建一次废墟堆
每个废墟堆下方的低填充外推堆是GeoJSON源,对GeoJSON源执行setData会重新解析数据。早期版本中,每次建筑坍塌时执行一次setData是卡顿的主要原因。现在已坍塌的建筑会被加入待处理列表,通过定时器批量处理。
4. 永远不要从数据源移除坍塌建筑
它们会保留在bld中,但k值设为0.001。移除要素意味着执行setData,而setData会导致停顿。
5. 按需加载资源
全细节模式的纹理和模型总共有24MB。它们会在用户点击地震标签时开始下载,而非页面加载时,且仅在高等级设备上加载。
出现的问题及原因
这些问题都耗费了我大量时间,前两个问题的存在正是第一部分存在的原因。
粘贴图问题
我最初实现的"水墙"是将视频片段绘制在屏幕空间画布上,通过每帧投影地图点来定位。在13英寸笔记本上几乎可行,但在4K显示器上却变成一个悬浮在城市上方的模糊矩形,明显不属于场景的一部分。
无论怎样调整透明度、羽化或尺寸都无法解决这个问题,因为问题不在于精灵图本身。精灵图在三维世界中没有容身之地。第3到第6步的所有内容都是替代方案。
如果你确实需要屏幕空间元素,比如锚定某点的飞溅精灵图,要保持它们小巧、短暂且数量众多,并接受它们永远无法主导画面。
我两次找不到我的图层
第1部分结尾处的3D水墙渲染没有错误,网格移动到了调试探针指示的位置,但屏幕上什么也没显示。我使用了map.addLayer(layer, "flood")将其添加,这会将它插入到洪水栅格下方,而不透明的水隐藏了我的墙。在检查其他内容之前,请先检查map.getStyle().layers。
第二次丢失图层时,问题正好相反:图层根本不存在。map.getStyle().layers不会列出自定义图层。请改用map.getLayer("debris3d")进行检查。我花了一段时间确信图层没有被添加。
24级透明度
我使用带有柔和alpha边缘的精灵表,并通过PIL.Image.quantize()将其压缩为更小的PNG文件。调色板压缩同时对alpha通道和颜色进行了量化。256级透明度变成了24级,平滑的衰减变成了阶梯状,每一步都是硬直边,看起来就像瓦片边界。我花了两轮时间寻找一个并不存在的瓦片错误。
解决方法是停止量化,并将RGB通道设为常量(这些精灵本来就是白色的),这样PNG压缩就无事可做了。文件体积缩小了三倍。现在我改用直方图来检查alpha通道,而不是依赖眼睛判断。
相同文件名,不同文件
我用不同质量重新编码了flood.webm并重新部署。访客持续数小时仍获取的是旧文件。CDN通过URL进行缓存,而对已存在的文件添加?v=2查询字符串并未打破缓存。
将文件重命名为flood2.webm后,一次部署就解决了问题。从那以后,我更新二进制资源时都会重命名文件,而不是对URL进行版本控制。
无头截图欺骗了我两次
我在无头Chrome中使用软件GL渲染器进行测试。每次截图需要一两秒时间,因此所谓的“帧序列”根本不是连续的,我花了一个下午在噪音中调整时间参数。
两个方法解决了问题:将浏览器会话录制为视频并用ffmpeg裁剪帧,以及为了颜色信息,直接通过getImageData读取模拟画布而不是测量合成后的屏幕。第二个方法写出来似乎显而易见,但花了我一个下午才想到。
同样的无头设置在地震测试中再次欺骗了我。在无头环境中卫星瓦片加载缓慢,我长时间以为建筑图层损坏了,因为截图显示的是黑色城市。实际上是因为底图尚未加载完成。如果在无头测试中图层显示异常,请在信任截图前等待map.areTilesLoaded()返回true。
设置要素状态时什么也没发生
对瓦片源调用setFeatureState没有产生任何效果也没有任何报错,因为要素没有ID。解决方法是使用第11步中的GeoJSON复制并启用promoteId。我花了令人尴尬的长时间才找到这个问题,因为API不会报错,只是什么也不做。
每个实例都是黑色
在three r128中,InstancedMesh.setColorAt首次调用时会懒加载创建颜色缓冲区,大小为当前网格数量。我的对象池初始数量设为0,因此缓冲区创建时没有条目,所有渲染实例都显示为黑色。在首次调用setColorAt之前,请自行分配完整容量的instanceColor。这就是makePool中的关键代码行。
平移时一半碎片消失
three.js 使用视锥体裁剪技术对网格进行裁剪。我们的相机是一个没有额外功能的 THREE.Camera,每帧都会被强制填充投影矩阵,且无法感知地图的朝向,因此它对视锥体的判断毫无意义。需要将自定义图层中每个网格的 frustumCulled 属性设置为 false。
场景更新但屏幕未变化
地图仅在检测到变化时才会重绘。移动对象后需要调用 map.triggerRepaint() 强制刷新。若不执行此操作,动画将依赖地图因其他原因重绘的频率运行,在静态场景中这个频率会降为零。
天空层导致场景消失
在另一个使用相同架构的项目中,map.setSky(...) 会隐藏整个 three.js 场景。天空层会向深度缓冲区写入数据,导致自定义图层后续绘制的内容全部通过深度测试失败。若要在地图中渲染3D内容,请勿启用天空层。
背景标签页中 idle 事件未触发
我曾通过 map.on("idle") 预加载模型。在后台标签页中,地图可能会长时间处于非空闲状态,导致用户在关闭页面后重新打开时遭遇无模型的地震场景。预加载逻辑已迁移至用户选择灾害类型时触发。
结论
整个指南可归纳为两种架构模式。
在地面层:
- 将网格对齐到地图的瓦片金字塔结构,使所有瓦片数据集无需重采样即可直接叠加。
- 将自由高程瓦片解码到该网格中。
- 注册一个画布作为地图源覆盖在网格矩形区域上,设置 animate: true。
- 在网格上运行一次模拟,通过每帧写入像素实现动画。
- 使用 source-atop 和 destination-in 操作将纹理精确贴合在模拟形状内部。
- 移动相机时,绘制内容会同步更新。
在高度层:
- 从地图已加载的矢量瓦片中提取建筑,去重处理(因为矢量瓦片没有唯一ID)。
- 通过带有 promoteId 的 GeoJSON 复制,将所有建筑以填充拉伸方式绘制,每个要素使用独立高度倍增因子。
- 根据衰减关系和脆弱性曲线计算损害,每个建筑单独添加随机性。
- 通过每帧写入倍增因子实现坍塌动画。
- 对于靠近相机的建筑,在地图自身的 GL 上下文中使用 renderingMode: "3d" 渲染 three.js 场景,共享深度缓冲区。
- 将建筑轮廓转换为定向方块,倾倒并吸附到方块结构中,生成实例化碎片。仅从事件中生成灰尘效果。震动容器而非相机。
贯穿所有技术的核心理念是:属于世界的一切都必须用世界坐标系绘制。地图上方的画布是玻璃板,地图内部的画布是地面,地图 GL 上下文中的 three.js 场景则是上方的空气。
所有让模拟效果更真实的改进,都来自于将某些元素从第一类(独立画布)转移到第二类(地图坐标系)。
如果能再告诉初学者一件事:地图引擎和 three.js 并非竞争对手。地图在处理十万座静态建筑时比任何自定义代码都更高效,而 three.js 在处理十座动态建筑时比任何表达式语言都更强大。
让各司其职,将精力集中在两者之间的衔接上——这几乎完全取决于第12步的坐标转换和第11步的要素状态技巧。
您可以在 hazardsim.com 上看到这两种场景。选择一个沿海城市并引发海啸,或者选择任意城市,设置震级后按下按钮。本指南中的代码是运行在该网站上的代码的精简版。感谢 Tim 以我从未想到的方式对代码进行了测试。
合作伙伴推荐课程
(在新标签页中打开)
广告
- Coursera 上的课程:Unity:设计与构建程序化六边形地图 EDUCBA 6周,每周2小时 高级证书 查看课程 (在新标签页中打开)
- edX 上的课程:3D GIS Santosh Panda、Chris Smith 和 Anushree Badola 4.2(满分5分)(21人评价)来自21个评分 4周,每周3-5小时 中级证书 查看课程 (在新标签页中打开)
- Skillshare 上的课程:用 HTML5 Canvas 发挥创意 Chris Dixon 3.4(满分5分)(13人评价)来自13个评分 3小时 初级 查看课程 (在新标签页中打开)
- Skillshare 上的课程:在大型画布上逐步绘制松散花朵的丙烯画技法 Elle Byers 4.6(满分5分)(28人评价)来自28个评分 1小时16分钟 初级 查看课程 (在新标签页中打开)
更多 Procreate 课程请查看 Class Central
→
独立网页开发者和出版人。自2016年以来一直从事浏览器游戏和参考网站的开发——包括 Chrome Dino 粉丝项目 chromedino.com。我撰写关于浏览器游戏、隐藏功能以及简单软件开发技巧的内容。
如果本文对您有帮助,请分享它。
免费学习编程。freeCodeCamp 的开源课程体系已帮助超过40,000人成为开发者。立即开始学习