← 返回蜂巢洞察

如何在实际地图上构建灾难模拟场景:利用 Mapbox GL JS 和 Three.js 技术实现动画效果的画布层以及可坍塌的 3D 建筑模型

在某些情况下,地图项目所需要的不仅仅是标记和多边形。你希望地图上能够出现一些动态效果:水沿着街道流动、烟雾随风飘散,或者冲击波从某个点扩散开来。 我第一次尝试实现这些效果时,做了我觉得理所当然的事情——我在地图上方添加了一个 元素,然后在上面绘制动画效果。 但结果看起来很不对劲,无论我怎么调整也无法改善这个问题。那些动画效果边缘分明,而且似乎“漂浮”在地面之上;每当我想倾斜或旋转地图时,这些动画就会与下方的地图内容对不上位。 我在开发 HazardSim 这个浏览器模拟器时遇到了这个问题。在这个模拟器中,自然灾害会在真实的地图上发生。海啸场景经历了大约十几次的修改,才终于不再看起来像是一张被贴

在某些情况下,地图项目所需要的不仅仅是标记和多边形。你希望地图上能够出现一些动态效果:水沿着街道流动、烟雾随风飘散,或者冲击波从某个点扩散开来。

我第一次尝试实现这些效果时,做了我觉得理所当然的事情——我在地图上方添加了一个元素,然后在上面绘制动画效果。

但结果看起来很不对劲,无论我怎么调整也无法改善这个问题。那些动画效果边缘分明,而且似乎“漂浮”在地面之上;每当我想倾斜或旋转地图时,这些动画就会与下方的地图内容对不上位。

我在开发HazardSim这个浏览器模拟器时遇到了这个问题。在这个模拟器中,自然灾害会在真实的地图上发生。海啸场景经历了大约十几次的修改,才终于不再看起来像是一张被贴在城市地图上的剪裁图。

很多测试工作,以及大部分错误报告,都来自我12岁的儿子蒂姆。事实证明,他比我更有耐心地进行测试。那些修改过程让我明白了同一个道理:必须用地图坐标来绘制模拟效果,而不是屏幕坐标。

左图:在地图上叠加的<canvas>元素中绘制的海浪动画。这个矩形图像漂浮在敖德萨的上空,边缘显得很不自然。右图:同一时刻的模拟场景,其中水的部分是用<canvas>元素绘制的,而地图将其视为栅格图像来处理。因此水会沿着海岸线流动,并且会随着相机的倾斜角度而变化。

这个规则帮助我成功完成了海啸模拟的实现。但在处理地震效果时,我又遇到了另一个问题: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),
    },
  };
}
一张4×3排列的地图瓦片图,瓦片边缘为橙色。内部用虚线蓝色矩形标出了城市的边界范围。其中一块瓦片被细分为多个小单元,以此说明一个网格单元相当于一个瓦片的像素大小。侧边栏解释了为何将数据对齐到瓦片格式会有帮助,并展示了相关的转换公式。

请保留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开始。

奥德萨周边海岸地区的高程数据被渲染成灰度地图,海域呈现深蓝色,地势较高的区域颜色较浅。橙色线条表示那些由4×4大小的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米的地方,并且几乎贴近地面进行拍摄——因为在这种高度下,卫星图像会变得模糊不清。虽然几何模型是正确的,但这样的拍摄效果却非常糟糕。使用倾斜角度的摄像机来展示水面的效果,实际上看起来比使用正确比例绘制的水墙效果还要差。

因此,“地面层”始终只能用来表示地面的景象。但在模拟地震的场景中,就没有这样的限制了——倒塌的建筑物必须被视作一个具有三维结构的物体。这就是第二部分的内容。

第二部分:具有高度的物体

在模拟7.8级地震时,基辅市中心的街道景象。前景中矗立着带有纹理的高层建筑,其中一些建筑物还发生了倾斜;灰色的尘埃云在奥林匹克体育场上空飘动。

在模拟地震效果的场景中,我需要让建筑物倒塌。经过大约十轮的重建后,最终得到了这样的结果:对于那些只需要保持原位的数千座建筑物,我使用了地图系统自带的建模功能来创建它们的模型;而对于那些会倒塌的建筑物,则使用three.js在地图自身的WebGL环境中渲染它们。

这两种处理方式都会读取相同的矢量瓦片数据,共享同一个深度缓冲区,并且在任何摄像机移动的情况下都会同步更新显示效果。

两种类型的3D模型,以及为什么你需要它们

fill-extrusion层是地图引擎自带的3D模型。这种模型的处理速度很快,能够渲染数以万计的建筑物;它的光照效果会与地图的其他部分保持一致,并且其外观是由数据决定的:建筑物的高度由某个属性决定,颜色也由该属性决定;更重要的是,建筑物的高度还会乘以一个可以在运行时更改的、与每个建筑物相关的状态值。不过,这种模型无法移动——因为它的结构始终是垂直的棱柱形状。

custom层则允许你在每一帧中使用地图系统的WebGL环境和投影矩阵,在其中绘制任何你想要的内容。结合three.js,这就意味着你可以创建真实的3D对象:这些对象具有位置、旋转和缩放等属性,数量可以达到数千个;它们会与地图共享同一个深度缓冲区,因此当有建筑物倒塌时,倒塌的物体能够正确地隐藏在仍然矗立的建筑物后面。

这种分工方式非常简单:所有的建筑物都采用extrusion模型来表示。当某座建筑物受到损坏时,它的模型尺寸会缩小(这就是那个与每个建筑物相关的“状态值”在起作用);当这座建筑物倒塌的位置离摄像机足够近、以至于用户能够看到变化时,它的模型就会完全消失,取而代之的是一个由three.js创建的废墟模型,这个过程仅持续两秒钟。之后,这个废墟模型又会被替换回原来的建筑物模型,而帧率根本不会因此受到影响。

地震发生前的基辅市中心景象。数千座建筑物的模型都是从地图的矢量瓦片中生成的,这些建筑物的墙壁上带有窗格图案纹理;中间距离处矗立着奥林匹克体育场的3D模型。

步骤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()方法,这样才能确保你的网格模型能够在屏幕上正确显示出来。

基辅街道的倾斜特写镜头。几座经过纹理处理的高层建筑以three.js模型的形式出现在其他凸出物之间,仿佛随时都会开始震动;背景中是奥林匹克体育场模型。

步骤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),
  };
}
三部分组成的图解。第一部分:标出了建筑物最长边的轮廓多边形,并注明“最长边决定了倾斜角度”;第二部分:将该多边形旋转到指定角度,在其周围画了一个橙色的边界矩形,标注有“L1、L2、中心点、倾斜角度”;第三部分:展示了一组由四块积木组成的结构,这些积木的底部是铰接的,公式为“θ双撇号等于1.5乘以g除以h再乘以sin θ”。 height="1120" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/28644ba8-6900-4885-98f6-ca163d50aa6c.png" style="display: block;" width="2800"/>
<p>这个“盒子”实际上是由两到四块积木组成的结构,每块积木都是一个<code>Mesh</code>对象,它们被放在一个<code>Group</code>对象中,而这个<code>Group</code>的对象中心点位于建筑物将会围绕旋转的那条底部边缘上。从物理学的角度来看,这个结构就是一块会倾倒的刚体:其角加速度与<code>g / h · sin θ</code>成正比,在开始移动时需要一个小的初始推力。当建筑物的倾斜角度超过某个阈值时,上面的积木就会脱落,并以它们在那一刻所具有的速度进行弹道运动:</p>
<pre><code class="language-javascript">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 && amp; tip.th * (1 + tip.bend * (n - 1)) > tip.brk && amp; n > 1) {
      tip.broken = true;
      for (let j = 1; j < n; j++) {
        const s = segs[j];
        s.free = true;
        svx = 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 += svx * 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;
  };
}
</code></pre><p>这些夹具并非用于装饰。物体脱离时的速度被限制在18米/秒以内,帧间隔<code>dt</code>也至少为8毫秒——因为速度是通过对位置变化进行计算得出的,如果帧间隔过短,可能会导致某个物体被突然抛射到轨道上。出于同样的原因,角速度也被限制在2.5弧度/秒以内,每帧的倾斜角度也被控制在0.09弧度之内。所有这些数值都是在我观察到某栋建筑物做出了它本来不可能做到的事情之后才确定下来的。</p>
<p><code>onLand</code>阶段是建筑物倒塌过程开始真正呈现出“倒塌”景象的阶段:每一个掉落到地面的建筑块都会在落地瞬间产生碎片和灰尘,并在地面留下堆积物。</p>
<h2 id="heading-step-14-debris-and-dust">步骤14:碎片与灰尘</h2>
<p>碎片实际上就是步骤12中生成的那些物体,再加上重力的作用而形成的。当倒塌中的建筑物经过某个关键节点时(比如第一块建筑块落地、逐层倒塌的过程中每一层都落地等等),就会从建筑物的边缘向外抛出一些碎片,这些碎片的运动方向是远离建筑物中心的。</p>
<pre><code class="language-javascript">function throwDebris(pools, b, origin, kx, ky, top, n) {
  const ring = b.polys[0][0];
  const cx = (b.lng - originlng) * 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) - originlng) * 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 += pvx * 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);
  }
}
</code></pre>
<p>那些落地的碎片就会停止被更新,从而一直留在碎片池中。这样,你就免费得到了这些“建筑废墟”了。</p>
<p>HazardSim还为这个模型添加了一些额外的元素:在建筑物原本所在的位置上,会模拟出9米网格状的堆叠楼板结构;会有带有窗户的墙体碎片、弯曲的钢筋;在碎片堆积的地方下方,还会生成一些低矮的填充物,使得地面并不平坦;对于距离倒塌建筑物较近的其他建筑,还会使用摄影测量技术扫描得到的废墟模型来模拟这些建筑的外观。虽然这些额外的元素使模型更加真实,但基本上来说,仅仅使用碎片池和重力的效果就已经能够满足大部分需求了。</p>
<img alt="倒塌后的街道景象:一片灰尘笼罩着几栋建筑物,地面散落着灰色的建筑碎片,天空中弥漫着烟雾。” height="790" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/1544d432-3f78-480b-9412-fa4d0fa73e82.jpg" style="display: block;" width="1095"/><p>灰尘是我在屏幕空间中模拟的对象之一,有必要解释为什么在这里使用这种处理方式是可行的——而在第一部分中,这种方式对水这种对象的渲染却会导致严重的问题。灰尘颗粒体积很小,数量众多,每个颗粒的存活时间仅为3到8秒。这些颗粒被绘制成柔软的精灵对象,每一帧都会根据地图上的位置信息来计算出每个颗粒的具体位置、大小和旋转角度。由于这些颗粒没有明显的边缘,也没有什么可以用来进行对齐的操作,因此这种处理方式在屏幕空间中是完全可行的。而那些体积较大、持续时间较长的物体,则需要遵循不同的渲染规则。</p>
<p>一个重要的决策是<strong>灰尘从哪里产生</strong>。早期版本的游戏中,城市上空会持续弥漫着雾气,同时还有火焰在燃烧,这种效果看起来就像是一个屏保程序。而在当前的版本中,灰尘只有通过特定的事件才会产生:比如地面塌陷时会产生一阵灰尘,建筑物坠落时灰尘会沿着地面扩散,多层建筑倒塌时也会有灰尘从底层喷出。每次事件产生的灰尘量与建筑物的规模成正比,其垂直下落的速度也与建筑物的高度差成正比;在下一个事件发生之前,这些灰尘就不会再出现了。</p>
<code class="language-javascript">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,                                        // 确定颗粒在地图上的位置
      x: Math.cos(a) * b.rf * 0.6, y: Math.sin(a) * b_RF * 0.6,     // 计算颗粒在屏幕上的坐标
      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,
    });
  }
}
</code>
<p>每一帧中,每个灰尘颗粒都会减速运动、随风飘动、逐渐变大,然后被投影到屏幕上。具体来说,其屏幕位置是通过<code>map.project([lng + x / kx, lat + y / ky])</code>计算得出的;而高度则通过<code>y -= z * pixelsPerMetre * sin(pitch)</code>来确定。这些颗粒会按照在屏幕上的深度顺序进行排序,然后使用<code>drawImage</code>方法从后往前绘制出来,并且颜色会随着时间逐渐变淡。在一张表示地图的2D画布上,每一帧需要执行几百次<code>drawImage</code>操作,但这样的处理方式所消耗的资源几乎可以忽略不计。</p>
<h2 id="heading-step-15-the-camera-has-to-shake">步骤15:摄像机必须进行摇晃渲染</h2>
<p>到目前为止,所有这些效果都可以默默地在屏幕的某个角落中显示出来。但是,真正能让观众感受到地震带来的冲击力的,是摄像机的运动方式。</p>
<p>具体来说,有两大关键点。首先,在从整体视角切换到详细视图时,需要先以45度的角度缓慢放大14.6倍,等到地图数据加载完成后再继续以62度的角度缓慢放大16.2倍。这个“保持当前缩放状态”的步骤是必不可少的。如果直接跳到16倍的缩放比例,那么对应这个比例的矢量地图数据可能还没有加载完毕,<code>querySourceFeatures</code>方法返回的数据也会非常少,这样一来,地震效果就会显示在一个空荡荡的城市上。</p><pre><code class="language-javascript">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);
  })();
}
</code></pre>
<p>其次,实现震动效果。此时不要移动地图的摄像头。如果使用<code>easeTo</code>方法来使摄像头产生抖动,就会与动画系统发生冲突,并且每次抖动都会导致地图重新加载图块。因此,应该通过CSS的变换效果来让<em>容器</em>产生震动,这样地图本身并不会察觉到有任何移动:</p>
<pre><code class="language-javascript">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;

    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);
}
</code></pre>
<p>实际上,振动的“幅度”并不如“变化节奏”重要。真正的震动效果会在一两秒钟内逐渐增强,达到峰值后,在接下来的几秒钟内逐渐减弱,而且震动的频率会随着效果的增强而增加。如果震动效果过于稳定、没有明显的波动,就会让人感觉就像是在观看一段画面不连贯的视频。在主震动结束后三到四秒,再发生一次幅度约为原来一半的“余震”,就可以完成整个震动效果。</p>
<p>当震动停止后,应该将摄像头缓缓移动到最近发生坍塌区域的中心位置,并保持这个状态几秒钟,这样残骸才会开始移动;之后再将摄像头拉回到显示强度等级和伤亡人数的位置。</p>
<h2 id="heading-keeping-it-fast">保持高速运行</h2>
<p>通过一些设计决策,我们使得该应用程序在笔记本电脑上能够以60帧每秒的速度流畅运行,在手机上也同样可以使用。</p>
<h3 id="heading-1-pools-with-fixed-capacity">1. 容量固定的池子</h3>
<p>每个实例化的池子都有一定的容量限制,当池子满时,<code>add</code>方法会返回<code>-1</code>。对于低性能设备,这些容量限制也会相应降低:例如,储藏石块的池子的容量从30,000个减少到9,000个,储藏砖块的池子从9,000个减少到3,000个。这样一来,虽然场景中的物品数量减少了,但应用程序仍然可以正常使用,而不会变成简单的幻灯片展示效果。</p><h3 id="heading-2-a-device-tier-decided-once">2. 设备等级的确定方式</h3>
<p>如果移动设备的用户代理满足<code>navigator.deviceMemory < 6</code>、<code>hardwareConcurrency <= 4</code>,或者屏幕宽度小于700像素,那么该设备就会被归为低等级。对于这类设备,系统会完全跳过对<code>three.js</code>技术的使用,仅采用缩小的模型效果来呈现场景,并将一些关键模型去除。</p>
<h3 id="heading-3-rebuild-the-rubble-mound-at-most-every-12-seconds">3. 最多每1.2秒重建一次“废墟堆”</h3>
<p>每一处倒塌的建筑下方的那些由<code>fill-extrusion</code>生成的模型数据实际上都是GeoJSON格式的源数据,而通过<code>setData</code>操作对这些数据进行重新解析。在早期版本中,每次对每栋倒塌的建筑都进行这样的处理是导致程序运行出现卡顿现象的主要原因。现在,新建完成的建筑会被放入一个待处理列表中,系统会按照预设的时间间隔来更新这些数据。</p>
<h3 id="heading-4-never-remove-a-collapsed-building-from-the-source">4. 绝不要从数据源中删除倒塌的建筑</h3>
<p>这些倒塌的建筑仍然会保留在<code>bld</code>数据结构中,其中<code>k</code>的值被设置为0.001。如果删除了这些数据,就需要再次执行<code>setData</code>操作,而这种操作反而会导致程序运行出现延迟。</p>
<h3 id="heading-5-load-assets-on-intent">5>根据用户需求动态加载资源</h3>
<p>全细节显示模式所需的纹理和模型文件总大小为24MB。这些文件只有在访问者点击“地震”选项卡时才会开始下载,而且只有在高等级设备上才会被加载。</p>
<h2 id="heading-what-went-wrong-and-why">出了什么问题,原因是什么</h2>
<p>实施这些改进确实耗费了我大量的开发时间,而前两项改进也是第一部分代码能够成功完成的基础。</p>
<h3 id="heading-the-sticker-problem">贴纸效果的问题</h3>
<p>我最初设计的“水墙”效果是通过在屏幕空间中绘制视频片段来实现的,每一帧都会通过投影地图坐标来确定这些片段的位置。在13英寸的笔记本电脑上,这种效果还算可以接受;但在4K显示器上,这些画面就变成了一个边缘模糊的矩形,看起来完全不属于场景的一部分。</p>
<p>无论如何调整不透明度、添加渐变效果或改变尺寸,都无法解决这个问题,因为问题的根源并不在于这些贴纸图像本身。在3D世界中,使用贴纸图像是不合适的。第3步到第6步中所采用的方法才是正确的解决方案。</p>
<p>如果确实需要使用屏幕空间中的元素,比如将某个贴纸图像固定在某个特定位置上,那么应该保持这些元素的尺寸较小、存在时间较短,并且数量较多;同时也要接受这样一个事实:它们永远无法成为整个场景的核心部分。</p>
<h3 id="heading-i-couldnt-find-my-layer-twice">我有两次找不到我需要的图层</h3>
<p>在第一部分代码的最后,我尝试渲染3D水墙效果时,程序并没有出现任何错误,模型的位置也完全符合我的预期;但是屏幕上却什么都没有显示出来。原来是我使用了<code>map.addLayer(layer, "flood")</code>这个命令来添加这个图层,而这个命令实际上是将它放在了“洪水”图层的<em>下方</em>,因此不透明的水层覆盖住了我的水墙效果。在检查其他问题之前,首先应该查看<code>map.style().layers</code>这个列表。</p>
<p>第二次遇到这个问题时,情况却完全相反:那个图层根本就没有被添加进去。<code>map.style().layers</code>列表中并没有显示自定义图层的存在,因此我尝试使用<code>map.getLayer("debris3d")</code>来查看这个图层,结果发现它确实没有被添加。后来我才意识到,可能是我在添加图层时出现了操作错误。</p><h3 id="heading-24-levels-of-transparency">24个透明等级</h3>
<p>我制作了具有柔和Alpha边缘的精灵图,然后使用<code>PIL.Image.quantize()</code>函数对它们进行压缩处理。这种压缩方式会同时量化Alpha通道和颜色信息,因此256个透明等级被缩减为24个;原本平滑的过渡效果变成了阶梯状,而每个阶梯都变成了明显的硬边,看起来完全像瓷砖之间的边界线。我花了整整两天的时间去寻找根本不存在的排版问题。</p>
<img alt="在深色背景上显示的同一烟雾精灵图的两个版本。左图为经过颜色量化处理后的效果:放大查看边缘部分会发现,这些边缘仅由少数几种色调的像素组成,Alpha值直方图中也出现了8个孤立的高峰;右图为使用8位Alpha通道渲染的效果:边缘过渡非常平滑,Alpha值直方图呈现出连续的253个等级分布。" height="1000" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/bba497b2-fe0e-4398-a7af-40a4245674f1.png" style="display: block;" width="1400"/>
<p>为了解决这个问题,我停止了量化处理,并将RGB通道的值设置为固定值(反正这些精灵图本来就是白色的),这样PNG压缩就没有任何作用了。文件的大小因此减少了三倍。现在,我会通过Alpha值直方图来检查图像质量,而不再仅仅依靠肉眼观察。</p>
<h3 id="heading-same-filename-different-file">相同的文件名,不同的文件内容</h3>
<p>我重新编码了<code>flood.webm</>文件,并使用了不同的压缩参数重新部署它,但用户们仍然在数小时内不断收到旧版本的文件。由于内容分发网络会根据URL地址进行缓存,再加上<code>?v=2</code>这样的查询字符串也无法区分已经存在的文件和新的文件。</p>
<p>将文件名改为<code>flood2.webm</>后,问题在一次部署中就得到了解决。从那以后,每当更新任何二进制资源时,我都会直接更改其文件名,而不再通过修改URL版本号来区分不同版本。</p>
<h3 id="heading-headless-screenshots-lied-twice">无头截图两次误导了我</h3>
<p>我在使用无头Chrome浏览器和软件GL渲染器进行测试时,发现每次截取屏幕截图都需要一两秒钟的时间,因此所谓的“连续帧序列”实际上并不存在。我花了一个下午的时间来调整各种参数,以消除这些干扰因素。</p>
<p>最终有两点方法解决了这个问题:一是将浏览器会话记录为视频文件,然后使用ffmpeg工具裁剪出所需的帧;二是对于颜色处理,直接使用<code>getImageData</>方法读取模拟画布上的颜色数据,而不是测量合成后的屏幕显示效果。后一种方法听起来很简单,但实际上我花了很长时间才想出来。</p>
<p>在地震相关的测试中,同样的无头环境再次让我陷入了误区。由于卫星地图数据的加载速度很慢,有很长一段时间我都以为自己的建筑层数据出错了,因为截图上显示的竟然是一片黑色的城市。实际上问题出在基础地图数据还没有加载上来。如果在进行无头测试时发现某层数据显示异常,一定要等到<code>map.areTilesLoaded()</>方法返回true之后,再相信截图的结果。</p>
<h3 id="heading-nothing-happened-when-i-set-feature-state">当我设置功能状态时,什么都没有发生</h3>
<p>针对瓷砖源对象使用<code>setFeatureState</>方法并没有产生任何效果,也没有任何反馈信息,因为这些功能对象根本没有唯一的ID。正确的解决方法是在第11步中为GeoJSON数据添加<code>promoteId</>字段。我花了很长时间才找到这个解决办法,因为这个API并不会报错,只是根本不会执行任何操作而已。</p><h3 id="heading-every-instance-was-black">所有实例都是黑色的</h3>
<p><code>InstancedMesh.setColorAt</code> 这个方法在第一次被调用时,会根据网格的<strong>当前</strong><code>数量</code>来创建相应的颜色缓冲区。我的数据池最初的状态是<code>数量 = 0</code>,因此创建的缓冲区内没有任何数据,导致所有渲染出的内容都是黑色的。在第一次使用<code>setColorAt</code>方法之前,你需要自己为<code>instanceColor</code>分配足够的内存空间,确保其具有足够的容量。这个操作需要在<code>makePool</code>方法中完成。</p>
<h3 id="heading-half-the-debris-vanished-when-i-panned">当我平移视角时,有一半的碎片消失了</h3>
<p><code>three.js</code>会使用自身的摄像机来裁剪网格。我们的摄像机只是一个普通的<code>THREE.Camera</code>对象,每一帧都会被赋予一个投影矩阵,但它并不知道地图的实际朝向,因此它所计算的裁剪范围其实是没有意义的。因此,你需要为自定义图层中的所有网格设置<code>frustumCulled = false</code>,这样这些网格就不会被裁剪掉。</p>
<h3 id="heading-the-scene-updated-but-the-screen-didnt">场景已经更新了,但屏幕上却没有显示变化</h3>
<p>地图只有当它认为有东西发生了变化时,才会重新绘制画面。在移动物体之后,需要调用<code>map.triggerRepaint()</code>方法来强制地图重新绘制。如果不执行这个操作,动画就会以地图因其他原因而重新绘制的频率来运行,在一个静止的场景中,这个频率自然就是零。</p>
<h3 id="heading-a-sky-made-everything-disappear">天空效果导致所有内容都消失了</h3>
<p>在另一个使用相同设置的项目中,<code>map.setSky(...)</code>这个方法使得整个<code>three.js</code>场景都无法被显示出来。因为天空层会覆盖深度缓冲区,所以之后自定义图层绘制的所有内容都会无法通过深度测试。如果你要在地图中渲染3D对象,就不要启用天空层。</p>
<h3 id="heading-the-idle-event-never-fired-in-a-background-tab">在背景标签页中,<code>idle</code>事件从未被触发过</h3>
<p>我之前在<code>map.on("idle")</code>这个回调函数中预加载了模型。但在背景标签页中,地图可能会很长时间都不会进入空闲状态,因此那些后来再次访问该网站的用户会看到没有模型的地震场景。现在,预加载操作已经被移到了选择危险类型的时候才执行。</p>
<h2 id="heading-conclusion">结论</h2>
<p>整个指南归根结底就是围绕两个核心结构来展开的。</p>
<p>对于地面元素的处理:</p>
<ol>
<li><p>将网格与地图的瓦片排列方式对齐,这样所有的数据集在加载时都不会需要重新采样。</li>
<li><p>将免费的高程瓦片解码并插入到这个网格中。</li>
<li><p>在一个矩形区域内注册一个canvas作为地图的显示源,并设置<code>animate: true</code>以使其能够动画播放。</li>
<li><p>在网格上运行一次模拟过程,然后通过每一帧绘制像素来实现动画效果。</li>
<li><p>使用<code>source-atop</code>和<code>destination-in</code>方法,将纹理精确地应用到模拟出的形状内部。</li>
<li><p>移动摄像机,画面也会随之发生变化。</li>
</ol>
<p>对于具有高度的物体处理:</p>
<ol>
<li><p>从地图已经加载的矢量瓦片中提取建筑物模型,并去除重复项,因为这些模型没有唯一的ID。</li>
<li><p>将所有建筑物以<code>fill-extrusion</>方式绘制出来,同时为每个建筑物设置不同的高度倍数值。这些数据可以从包含<code>promoteId</code>字段的GeoJSON文件中获取,这样建筑物的状态信息才能被正确处理。</li>
<li><p>根据衰减关系和脆弱性曲线来计算每栋建筑物的受损程度,并为每一栋建筑物添加随机性因素。</li>
<li><p>通过每一帧更新倍数值来模拟建筑物的倒塌过程。</li>
<li><p>对于靠近摄像机的建筑物,使用<code>renderingMode: "3d"</code>在地图自身的GL上下文中进行渲染,这样深度缓冲区就可以被共享了。</li>
<li><p>将建筑物的轮廓转换为定向矩形,然后使其倾斜并组合成块状结构,最后生成实例化的碎片。只有当有特定事件发生时才释放尘埃效果。移动的是容器本身,而不是摄像机。</li>
</ol><p>所有这些设计理念的核心,其实就是我一直反复强调的一点:任何属于这个世界的物体,都必须用该世界自身的坐标系来表示。位于地图表面的画布就像一块玻璃;而位于地图内部的区域则代表地面;而在地图所对应的GL环境中运行的<code>three.js</code>场景,则象征着上方的空气。</p>
<p>任何能让这两种模拟效果看起来更真实的改进,其实都是通过将某些元素从第一类转移到第二类中才得以实现的。</p>
<p>在刚开始的时候,我就告诉自己:地图引擎和<code>three.js</code>并不是竞争对手。对于处理上万座静态建筑物而言,地图引擎的优势是任何自定义程序都无法比拟的;而当需要模拟移动物体时,<code>three.js</code>的表现也远超其他编程语言。</p>
<p>让它们各自发挥自己的优势,然后将精力集中在它们之间的衔接处——也就是第12步中的坐标转换处理,以及第11步中使用的那些功能状态控制机制上。</p>
<img alt="最终呈现的场景:从上方俯瞰敖德萨发生的30米级海啸,棕色的洪水淹没了沿海地区,左侧的显示面板列出了遇难人数、受淹面积、海水内陆蔓延范围以及最大水深。" height="790" loading="lazy" src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/3ed70355-abc0-4a98-8285-8012ad46aff9.jpg" style="display: block;" width="1440"/>
<p>你可以在<a href="https://hazardsim.com/">hazardsim.com</a>上查看这两种模拟效果。选择一座沿海城市并触发海啸实验,或者任意选择一座城市、设定地震震级,然后按下按钮即可开始测试。本指南中提供的代码其实就是该平台上实际运行程序的简化版本。感谢Tim帮我分析了那些我本来永远想不出来的问题。</p> </div> <section class="mt-12 border-t border-slate-200 pt-10 dark:border-slate-800"><h2 class="text-xl font-bold text-slate-900 dark:text-white">相关文章</h2><div class="mt-6 grid gap-6 md:grid-cols-2"><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/How-to-Build-Your-Own-AI-App-Builder-Like-Lovable-with-Next.js%2C-AWS-and-Sandboxes" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div>   <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span>  </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar">   <use href="#ai:tabler:calendar"></use>  </svg> <time datetime="Sat Oct 03 2026 04:20:53 GMT+0800 (China Standard Time)">2026年10月2日</time>  </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/How-to-Build-Your-Own-AI-App-Builder-Like-Lovable-with-Next.js%2C-AWS-and-Sandboxes" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何使用Next.js、AWS以及沙箱环境来构建属于自己的、类似于Lovable这样的AI应用开发工具</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">像Lovable、Bolt这样的工具,第一次使用的时候真的会让人觉得像是魔法一样。说实话,我第一次看到这类东西的时候简直惊呆了……而且这一切都是通过聊天窗口来完成的!真是太不可思议了。第一次尝试使用Lovable的时候,我甚至产生了一场关于“存在意义”的哲学思考。 但你们有没有想过,其实这些工具内部到底发生了什么?比如说,究竟是怎么让一个应用程序能够生成另一个可以用于测试、分享或下载的应用程序的呢? 显然,在某个地方有一个AI模型在编写代码。但这个过程具体是如何进行的呢?那些代码需要被安装、编译并运行才行。而我有点怀疑的是,现在似乎没有人会在这些代码运行之前去仔细检查它们。这些代码可能会出现错</p> <a href="/articles/How-to-Build-Your-Own-AI-App-Builder-Like-Lovable-with-Next.js%2C-AWS-and-Sandboxes" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300">
阅读全文
<svg width="1em" height="1em" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right">   <symbol id="ai:tabler:arrow-right" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6 6l6-6m-6-6l6 6"/></symbol><use href="#ai:tabler:arrow-right"></use>  </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/How-to-Migrate-an-Application-from-AWS-to-GKE-%5BFull-Book%5D" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div>   <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span>  </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar">   <use href="#ai:tabler:calendar"></use>  </svg> <time datetime="Sat Oct 17 2026 04:26:45 GMT+0800 (China Standard Time)">2026年10月16日</time>  </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/How-to-Migrate-an-Application-from-AWS-to-GKE-%5BFull-Book%5D" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何将应用程序从AWS迁移到GKE [完整指南]</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">这个指导性实验将带你了解如何迁移一个小型支付应用。该应用包含使用React开发的用户界面以及运行在AWS EC2实例上的Express API,而数据库则仍保存在AWS上。 你需要将用户界面和API迁移到Google Kubernetes Engine上,但无需更改客户目前使用的公共URL地址,而是逐步将流量从AWS转移至GKE,每次转移的比例为几百分比。 完成整个迁移过程后,你将能够在本地运行该应用,在AWS上搭建基础设施,通过八个Terraform步骤在GKE上构建环境,测试新环境是否正常工作,然后将生产环境的流量全部转移到GKE上,完成切换流程,并拆除不再需要的资源。 下图展示了迁移过程</p> <a href="/articles/How-to-Migrate-an-Application-from-AWS-to-GKE-%5BFull-Book%5D" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300">
阅读全文
<svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right">   <use href="#ai:tabler:arrow-right"></use>  </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/Secure-by-Design%3A-How-to-Engineer-Security-into-Architecture-Before-Writing-Any-Code" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div>   <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span>  </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar">   <use href="#ai:tabler:calendar"></use>  </svg> <time datetime="Fri Oct 16 2026 04:23:27 GMT+0800 (China Standard Time)">2026年10月15日</time>  </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/Secure-by-Design%3A-How-to-Engineer-Security-into-Architecture-Before-Writing-Any-Code" class="transition hover:text-violet-600 dark:hover:text-violet-300">“从设计层面确保安全性”:在编写任何代码之前,如何将安全机制纳入系统架构之中</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">在大多数工程团队中,都会进行这样的对话——不过往往为时已晚。 想象一下:某个功能被部署到生产环境中之后,才会进行安全审查;或者在进行渗透测试时发现了问题;更糟糕的是,还会收到事故报告。 这些漏洞确实是存在的,修复它们也需要投入大量成本。而在这些讨论中,总会有人说:“我们需要为这个功能添加安全措施。” 但这个问题就出在这里:安全并不是事后才添加到软件中的东西,而应该从项目一开始就被纳入设计之中。一旦开始考虑为已经存在的东西添加安全功能,那就意味着你已经进入了“事故应对模式”。 “安全驱动设计”是一种完全不同的思维方式——在制定架构决策之前,就应该明确安全要求;在编写任何代码之前,就必须了解潜在的</p> <a href="/articles/Secure-by-Design%3A-How-to-Engineer-Security-into-Architecture-Before-Writing-Any-Code" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300">
阅读全文
<svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right">   <use href="#ai:tabler:arrow-right"></use>  </svg> </a> </div> </article><article class="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"> <a href="/articles/How-to-Give-an-AI-Agent-Safe-Access-to-an-Internal-API-with-MCP" class="relative block aspect-[16/10] overflow-hidden bg-slate-900"> <div class="absolute inset-0 bg-gradient-to-br from-blue-600 via-cyan-700 to-slate-900"> <div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_80%,rgba(255,255,255,0.2),transparent_45%)]"></div> </div>   <div class="absolute inset-0 flex flex-col justify-end p-5"> <span class="inline-flex w-fit rounded-full px-3 py-1 text-xs font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200">技术实践</span>  </div> </a> <div class="flex flex-1 flex-col p-5"> <p class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4" data-icon="tabler:calendar">   <use href="#ai:tabler:calendar"></use>  </svg> <time datetime="Fri Oct 16 2026 04:21:59 GMT+0800 (China Standard Time)">2026年10月15日</time>  </p> <h3 class="mt-3 text-lg font-bold leading-snug text-slate-900 dark:text-white"> <a href="/articles/How-to-Give-an-AI-Agent-Safe-Access-to-an-Internal-API-with-MCP" class="transition hover:text-violet-600 dark:hover:text-violet-300">如何利用MCP让AI代理安全地访问内部API</a> </h3> <p class="mt-3 flex-1 text-sm leading-7 text-slate-600 dark:text-slate-300">我们的值班团队使用了一个小型内部笔记API。与之配套的网页应用可以显示当前谁正在负责处理紧急事务、哪些日期被安排为部署禁用期,以及运行脚本的位置。该团队中的一位开发人员希望能够在不打开网页应用的情况下,通过类似“Cursor”这样的工具进行搜索。 他们按照大多数人在周五下午会采取的方式设置了这个系统:将API的基本URL复制到助手设置中,将从共享操作库中获取的长期有效令牌粘贴到笔记本电脑的环境变量中,然后在助手的使用说明中添加了一条提示:“仅用于读取笔记内容,不得进行任何修改。” 在他们的机器上,这个系统确实能够正常工作。当他们询问下一次部署禁用期是什么时候时,助手会调用相应的搜索接口,从而返</p> <a href="/articles/How-to-Give-an-AI-Agent-Safe-Access-to-an-Internal-API-with-MCP" class="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-violet-600 transition hover:text-violet-700 dark:text-violet-300">
阅读全文
<svg width="1em" height="1em" viewBox="0 0 24 24" class="h-4 w-4 transition group-hover:translate-x-0.5" data-icon="tabler:arrow-right">   <use href="#ai:tabler:arrow-right"></use>  </svg> </a> </div> </article></div></section> </article> <div class="w-full shrink-0 lg:w-56"> <aside class="w-full min-w-0 space-y-8 lg:sticky lg:top-24"> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">内容栏目</h2> <ul class="mt-3 flex flex-col gap-1"> <li> <a href="/articles/category/industry" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300"> 行业洞察 </a> </li><li> <a href="/articles/category/technology" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition bg-violet-50 text-violet-700 dark:bg-violet-950/50 dark:text-violet-300"> 技术实践 </a> </li><li> <a href="/articles/category/product" class="block truncate rounded-lg px-2.5 py-1.5 text-sm font-medium transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300"> 产品动态 </a> </li> </ul> </section> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">文章分类</h2> <ul class="mt-3 flex flex-col gap-1"> <li> <a href="/articles/categories/%E5%AE%89%E5%85%A8" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="安全"> 安全 </a> </li><li> <a href="/articles/categories/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="操作系统"> 操作系统 </a> </li><li> <a href="/articles/categories/product" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="产品方案"> 产品方案 </a> </li><li> <a href="/articles/categories/%E4%BA%A7%E5%93%81%E8%BF%90%E8%90%A5" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="产品运营"> 产品运营 </a> </li><li> <a href="/articles/categories/%E5%A4%A7%E6%95%B0%E6%8D%AE" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="大数据"> 大数据 </a> </li><li> <a href="/articles/categories/da-shu-ju" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition bg-violet-50 font-medium text-violet-700 dark:bg-violet-950/50 dark:text-violet-300" title="大数据"> 大数据 </a> </li><li> <a href="/articles/categories/printorder" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="电商打单"> 电商打单 </a> </li><li> <a href="/articles/categories/%E6%95%85%E9%9A%9C%E9%A2%84%E8%AD%A6" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="故障预警"> 故障预警 </a> </li><li> <a href="/articles/categories/%E6%9E%B6%E6%9E%84" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="架构"> 架构 </a> </li><li> <a href="/articles/categories/openapi" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="开放平台"> 开放平台 </a> </li><li> <a href="/articles/categories/uncategorized" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="默认"> 默认 </a> </li><li> <a href="/articles/categories/allsite" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="全渠道OMS+WMS"> 全渠道OMS+WMS </a> </li><li> <a href="/articles/categories/hotnews" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="热点关注"> 热点关注 </a> </li><li> <a href="/articles/categories/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="人工智能"> 人工智能 </a> </li><li> <a href="/articles/categories/%E8%BD%AF%E4%BB%B6%E5%BC%80%E5%8F%91" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="软件开发"> 软件开发 </a> </li><li> <a href="/articles/categories/%E6%95%B0%E6%8D%AE%E5%BA%93" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="数据库"> 数据库 </a> </li><li> <a href="/articles/categories/%E7%A7%BB%E5%8A%A8" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="移动"> 移动 </a> </li><li> <a href="/articles/categories/%E4%B8%93%E5%AE%B6%E8%A7%82" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="专家观"> 专家观 </a> </li><li> <a href="/articles/categories/expertpoint" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="专家观点"> 专家观点 </a> </li><li> <a href="/articles/categories/iot" class="block truncate rounded-lg px-2.5 py-1.5 text-sm transition text-slate-600 hover:bg-slate-50 hover:text-violet-700 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-violet-300" title="IoT"> IoT </a> </li> </ul> </section> <section> <h2 class="text-sm font-semibold tracking-wide text-slate-900 dark:text-white">文章标签</h2> <ul class="mt-3 flex flex-wrap gap-1.5"> <li class="max-w-full"> <a href="/articles/tags/%E6%95%B0%E6%8D%AE" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="数据"> 数据 </a> </li><li class="max-w-full"> <a href="/articles/tags/strong" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="strong"> strong </a> </li><li class="max-w-full"> <a href="/articles/tags/data" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="data"> data </a> </li><li class="max-w-full"> <a href="/articles/tags/code" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="code"> code </a> </li><li class="max-w-full"> <a href="/articles/tags/span" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="span"> span </a> </li><li class="max-w-full"> <a href="/articles/tags/ai" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="AI"> AI </a> </li><li class="max-w-full"> <a href="/articles/tags/li" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="li"> li </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%95%B0%E6%8D%AE%E5%BA%93" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="数据库"> 数据库 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%A8%A1%E5%9E%8B" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="模型"> 模型 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BD%BF%E7%94%A8" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="使用"> 使用 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="人工智能"> 人工智能 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%B8%AD" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="中"> 中 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%9C%8D%E5%8A%A1" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="服务"> 服务 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BC%9A" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="会"> 会 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E6%8A%80%E6%9C%AF" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="技术"> 技术 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E7%B3%BB%E7%BB%9F" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="系统"> 系统 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E5%AE%89%E5%85%A8" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="安全"> 安全 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E7%94%A8%E6%88%B7" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="用户"> 用户 </a> </li><li class="max-w-full"> <a href="/articles/tags/em" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="em"> em </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BB%A3%E7%A0%81" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="代码"> 代码 </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BB%A3%E7%A0%81%EF%BC%8C%E4%BC%9A%E7%9A%84%E3%80%82" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="代码,会的。"> 代码,会的。 </a> </li><li class="max-w-full"> <a href="/articles/tags/nbsp" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="nbsp"> nbsp </a> </li><li class="max-w-full"> <a href="/articles/tags/ltr" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="ltr"> ltr </a> </li><li class="max-w-full"> <a href="/articles/tags/variable" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="variable"> variable </a> </li><li class="max-w-full"> <a href="/articles/tags/iot" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="iot"> iot </a> </li><li class="max-w-full"> <a href="/articles/tags/%E8%AE%BE%E5%A4%87" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="设备"> 设备 </a> </li><li class="max-w-full"> <a href="/articles/tags/href" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="href"> href </a> </li><li class="max-w-full"> <a href="/articles/tags/kafka" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="kafka"> kafka </a> </li><li class="max-w-full"> <a href="/articles/tags/python" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="python"> python </a> </li><li class="max-w-full"> <a href="/articles/tags/%E4%BA%91" class="inline-flex max-w-full truncate rounded-full px-2.5 py-1 text-xs font-medium transition bg-slate-100 text-slate-600 hover:bg-violet-50 hover:text-violet-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-violet-950/40 dark:hover:text-violet-300" title="云"> 云 </a> </li> </ul> </section> </aside> </div> </div> </div>  </main>  <footer class="relative not-prose border-t border-slate-200/80 bg-gradient-to-b from-slate-50 to-white dark:border-slate-800 dark:from-slate-950 dark:to-slate-900"> <div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 intersect-once intersect-quarter intersect-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> <div class="grid grid-cols-12 gap-10 py-12 md:gap-8 md:py-14 lg:gap-12"> <!-- Brand + WeChat --> <div class="col-span-12 lg:col-span-4"> <a class="inline-block" href="/" aria-label="返回首页"> <span class="inline-flex items-end gap-3 self-center whitespace-nowrap"> <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGwAAAAgCAYAAAD3/J3aAAAIrElEQVRoge2afYzURxnHP3e3vJSjQEckYOnZ6attg4hbbMGCqYHGsk1tI1tbjUKsev2JVqSpb60GYl9oBZtG7PRK+qYpLa5UVLZvtCqNwYIsVqlCw+nvPIGm2E5paTk44M4/nllu9re799tdCHck90022Xlm5plnfs/MPC8zdd3d3eRhjWYA/Q7fBLYCzwEk+laWAcTgFuA24H/Ax4C2utlrPnqktqXd9pFcA4igHvgp8DWP9h9gSn3fyDOACtAVKb8CdAworJ+guUnVNzepEa7YBdwMPOnKq4DZwJ4BhfUDNDepOuBB4JnmJjXMkfcD1wPfBq5xZQYU1sdwu+oRYC4wBbivuUkNd9V7gB/jHY8DCut7fAf4oleeA1xRrvGAwvoey4HQK98L/LZc4wGF9TFa2m0bcBWwA1jS0m7nt7TbfeXaxwbOKghPBT4OTAfGAkOBIYiyhwLdQAfQCvwZ2GiN3ub1HwKcBkwGTnftARqAXcCvrNF7q5lkf4EKwpOBc4GLgZMpnNtmYK01+lAcn5Z2+/fmJnVRS7vdFde2rMJUEE4A5gOfB/YhLuYy4FXgLcQQDkaUeBnwVeAG13cjsrVXAU3ApUAj8D03MRAlXwK8FydkP8YHgXOQb3CLR28FHiylLKfkx4C7rdF/ytMrURZAUaZDBeEg4HbgW4hC7wHusEa/EcdMBeHlwAPAeEfaBsy2Rv/D1b8CXODqfmONvqoSIfs7VBCeDmynZwMssUbfXKJdE7ACObFagVnW6O3VjFVgw1QQngI8iwRtbwNXWKMXVKIsAGv000jOa6sjPQfsdrwHI0doHifyzopiNIWn1ZvRBioIxwHrEWUBnAWscBukYhwZJJPKjQJ+5xh2AJ+xRq+rTm4AhiHJyrus0Y969HrkCM2jrGE9ATE0Un6tTJvGCO0FINbG+UgAZFK5BuAherS/sEZlAbwBpKzR75YYq8ErH6yRf39ErMKs0aEKwvORnOAoYJ41+v5qB8rvsDRwtfu/G7FDNcEa/XYvY/kK6y7T7kTEkEj5nVKNrNGvqSA8C9DW6M21DJTIpHLDgR95tJXW6D21MIsbi0KFHfArVRCOAMYBJyEeqLVG76h2EBWEwwGNrOJO4HVgRyXutcdjLBKKnAS8C+y0Rr/eS5fBkXLZsazRbyFeNioIG6zRhyuVC+QjTkcMYB4bq2FQ5Vi+wt4BUEGYQsKHCcD76XGE9qogfBlYZI1+IY65CsJJwI3ASCQIHYoc8ecB21QQrkHsapFD4PG4DrgOsE6+McAMYJgLVR6zRi8v0dVXWDfece/c+CuBWcAZ3vz2I2motri5+UggCUcfu6thUOVYvsJGqiA0SAz3KLAYmeiFSLw2GpgGrFZBeLU1+vlSTN3O/CGS2b4XWJb3at3HmgfciSjuShWEc63RL0V4fBi4G1k0NwJPWqO7Xd3ZwE+Q/N4nVBBeBlxvjfaPvThP75/utwwJskHCgPaYfkVIULi7oEqvpQrUUxhGzAc2AZdao33BX1RBuB5YCwx3v+8DRQpzClkJfAq40xq90K93GZTFKghHAzchWYlVKggnekqdjOTuxgBTrdEbIjy2qyD8LJADPoTcS3WoIJyTVyqFLn03cNjrvxf4qxvLP1Z3W6Ojl5SxqEc+iI9R1TKpEIMo3GGbgBkRZQHgdsB6j3RqmXhlEaKsncAdpQZVQViHHHFHSMAkV9eI7O6xiO3eUMwBnNz+yTPV8fHr8+ii+LY4D3/B1pVp0ysSFAd5F9Bz03ksUUehkE/04lGCvGHII+F+vm0YBXzBFduiYYQKwjOBryDe7weAPyL3TuuA/7pmn0OOShB32++fQFJnX0fsfB3wCyRTsRHnOHjyVYuawpoEsCVCu1YF4V3W6M5aGPaCBgpXWNwk98fUT0TsHLgjyLnM1wCfRo6/LUAL8JSfkPYwzft/0ClpKqLIGcAI4PfAN4A/WKOPpX2vKaxJICvPx/nAJ4FnjlKgKKI2LG6F+Z7XQTy74HCe93+8CsJ5iAOlgDVAUEGsM877fwnyPS5GQo7bgWet0RUlZWtATb5CAngZMfAzPfptKgjXWaM7joVkZRAXf/jBaBfFK9L30k4Dctbon1Upg7+LxyNJ2+OVgakq/sqjPp1NHgZ+EKEnge8etUiRsajuwtR3Mg5RvCI3ebRBSPxULbZ6/yciu+x4oTaFAaSzyQ3AwkjdrSoI5xylUD6iNizuDC9QmOdC59GKc5cd5jpHoxr8kp4P14B4nccLVbv04Bn+dDa5KJPKjaHntWk90KKCcHA6mywV3VeFTDHpQHOTKqb2tC+wYUVts8muTCq3FHjCUUYAGRWEM9LZZGVPmLPJTZlUbiXiZABMU0G4NJ1N3lRR/x5Zi1BqbpF2vc7/SJ9UrqBccESls8l5yM1p3kUeAjyQSeWWZ1K5c2O5945o3BF3JPheZElbms4mVwLGI00C1mZSucm9Mc6kco3uhgLk7s/3lBdkUrn7Mqnc++J4eEV/LnVIDrMU/F1V07v4ghtnT5hJiA2bRU9g3Qk8jniPW5D3GPvS2eQB12cQcApwNpI92QL8zdlIMqncFAqD4ceBBYjz0JnOJgtsVCaVewm4yBVfBS5H3ul1unG7vXGXIPFSfgF2AfcDq5EU0D4kkXsO4u5vBv6SziYPOh5nAg9T6Oa3IamkF5HA/BCSVPgIspufT2eTba7/rRQm0G9Adn6nm9tht0DWIxe8IJmba5GL3K50NllSydEdVlJhXuMzkDdzM91ERyKrySKXlG/mB0SSrQeQ+7DVwNPeB6lHnhx8yWNfjyRp/wU87OxoftwJyP1c/hVsHaKsnYjLviLP2+szHXnjdyFyMjQiO9M6uQ4hyrsnnU0W3fVlUrkE8GX3047HYGRB7XH930MeGi1OZ5M7XL8xwM8RTzWPLsTGtgImnU3+O5PKzQSW0pMVqUOet+0Cfp3OJp+KyuT4F5T/D7baxqfw3/Z8AAAAAElFTkSuQmCC" alt="Cheeli" width="108" height="32" loading="eager" decoding="async" class="block h-8 w-auto shrink-0 invert hue-rotate-180 dark:invert-0 dark:hue-rotate-0"> <span class="relative pb-px pl-3 text-[15px] font-semibold leading-none tracking-[0.14em] text-slate-800 before:absolute before:bottom-0 before:left-0 before:h-3.5 before:w-px before:bg-slate-300/80 dark:text-slate-100 dark:before:bg-slate-600/80 md:text-base">
知力科技
</span> </span> </a> <p class="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">蜂巢数字商业平台</p> <p class="mt-2 max-w-sm text-sm leading-6 text-slate-600 dark:text-slate-400">连接商业生态,赋能企业智能经营。</p> <div class="mt-6 inline-flex items-start gap-4 rounded-2xl border border-slate-200/80 bg-white p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/80"> <img src="/_astro/wechat-cheeli-tech-qrcode.CYWhZFv0.jpg" alt="知力科技官方微信公众号二维码" width="88" height="88" class="block h-[88px] w-[88px] shrink-0 rounded-xl bg-white object-contain" loading="lazy" decoding="async"> <div class="pt-1"> <p class="text-sm font-semibold text-slate-900 dark:text-white">官方公众号</p> <p class="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">扫码关注,获取产品动态与行业洞察</p> </div> </div> <div class="mt-6 flex flex-wrap gap-x-4 gap-y-2 text-sm"> <a class="text-slate-500 transition hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-300" href="/privacy"> 隐私政策 </a><a class="text-slate-500 transition hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-300" href="/terms"> 服务条款 </a> </div> </div> <!-- Navigation columns --> <div class="col-span-12 grid grid-cols-2 gap-8 sm:grid-cols-3 lg:col-span-8 lg:gap-10"> <div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 产品 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/open-platform"> 蜂巢开放平台 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/omnichannel"> 蜂巢全渠道 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/products/ai-agent"> AI智能应用 </a> </li> </ul> </div><div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 解决方案 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/ecommerce-omnichannel"> 电商全渠道解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/digital-goods"> 虚拟商品与数字权益解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/order-fulfillment"> 企业订单履约解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/private-mall"> 私域商城解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/digital-connection"> 企业数字化连接解决方案 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/solutions/ai-operations"> AI智能运营解决方案 </a> </li> </ul> </div><div> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500"> 生态与内容 </p> <ul class="mt-4 space-y-3"> <li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/developers"> 开发者中心 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/cases"> 客户案例 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/articles"> 蜂巢洞察 </a> </li><li> <a class="text-sm text-slate-600 transition hover:text-blue-600 dark:text-slate-300 dark:hover:text-blue-300" href="/about"> 关于我们 </a> </li> </ul> </div> </div> </div> <!-- Legal bar --> <div class="flex flex-col gap-4 border-t border-slate-200/80 py-6 md:flex-row md:items-center md:justify-between dark:border-slate-800 md:py-7"> <div class="text-xs leading-6 text-slate-500 dark:text-slate-400"> <p class="text-slate-600 dark:text-slate-300">2026 Copyright © 上海知力信息科技有限公司 · All Rights Reserved</p> <p class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1"> <a class="transition hover:text-blue-600 dark:hover:text-blue-300" href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank"> 沪ICP备09012674号 </a> <span class="text-slate-300 dark:text-slate-600" aria-hidden="true">|</span> <a class="inline-flex items-center gap-1 transition hover:text-blue-600 dark:hover:text-blue-300" href="https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=31011502018658" rel="noopener noreferrer" target="_blank"> <img src="/_image?href=%2F_astro%2Fbeian-logo.hBd0lRJE.png&w=16&h=16&f=webp" loading="lazy" decoding="async" alt style="object-fit:cover;object-position:center;width:16px;height:16px;object-position:top left" width="16" height="16" class="block h-4 w-4 shrink-0"> 沪公网安备 31011502018658号 </a> <span class="text-slate-300 dark:text-slate-600" aria-hidden="true">|</span> <span>增值电信业务许可证:沪B2-20220251</span> </p> </div> <ul class="flex shrink-0 gap-1 md:justify-end"> <li> <a class="inline-flex items-center rounded-lg p-2.5 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100" aria-label="RSS" href="/rss.xml"> <svg width="1em" height="1em" viewBox="0 0 24 24" class="h-5 w-5" data-icon="tabler:rss">   <use href="#ai:tabler:rss"></use>  </svg>  </a> </li> </ul> </div> </div> </footer>   <aside id="cheeli-float-panel" class="cheeli-float" data-collapsed="false" aria-label="客服联系方式" data-astro-cid-lduetl6a> <button type="button" class="cheeli-float__toggle" data-float-toggle aria-expanded="true" aria-controls="cheeli-float-panel-body" data-astro-cid-lduetl6a> <span class="cheeli-float__toggle-open" aria-hidden="true" data-astro-cid-lduetl6a>收起</span> <span class="cheeli-float__toggle-closed" aria-hidden="true" data-astro-cid-lduetl6a>客服</span> </button> <div id="cheeli-float-panel-body" class="cheeli-float__body" data-astro-cid-lduetl6a> <div class="cf-card cf-card--avatar" data-astro-cid-lduetl6a> <div class="cf-avatar" data-astro-cid-lduetl6a> <img src="/_astro/ocpm.2osPe16p.png" alt="蜂巢客服顾问" width="120" height="130" loading="lazy" decoding="async" data-astro-cid-lduetl6a> </div> <a class="cf-phone" href="tel:400-002-5767" data-astro-cid-lduetl6a>☎ 400-002-5767</a> </div> <div class="cf-card" data-astro-cid-lduetl6a> <img class="cf-qrcode" src="/_astro/wechat.BcEd01Bh.png" alt="客服微信二维码" width="130" height="140" loading="lazy" decoding="async" data-astro-cid-lduetl6a> <div class="cf-title" data-astro-cid-lduetl6a>扫码添加客服微信</div> </div> <a class="cf-btn white" href="https://wpa.qq.com/msgrd?v=3&uin=273785004&site=qq&menu=yes" target="_blank" rel="noopener noreferrer" aria-label="QQ 客服 273785004" data-astro-cid-lduetl6a> <img src="/_astro/qq.Bq_2h58v.png" alt="" width="16" height="18" loading="lazy" decoding="async" data-astro-cid-lduetl6a> <span data-astro-cid-lduetl6a>:273785004</span> </a> </div> </aside>  <script type="module">const c="cheeli-float-contact-collapsed";function d(t){if(t.dataset.bound==="true")return;t.dataset.bound="true";const e=t.querySelector("[data-float-toggle]");if(!e)return;const l=n=>{t.dataset.collapsed=n?"true":"false",e.setAttribute("aria-expanded",n?"false":"true");try{localStorage.setItem(c,n?"1":"0")}catch{}};let a=!1;try{a=localStorage.getItem(c)==="1"}catch{a=!1}l(a),e.addEventListener("click",()=>{l(t.dataset.collapsed!=="true")})}function o(){const t=document.getElementById("cheeli-float-panel");t instanceof HTMLElement&&d(t)}o();document.addEventListener("astro:page-load",o);</script> <script>(function(){const defaultTheme = "system";

  if (window.basic_script) {
    return;
  }

  window.basic_script = true;

  function applyTheme(theme) {
    if (theme === { elem.addEventListener(event, (e) => fn(e, elem), false); }); } } const onLoad = function () { let lastKnownScrollPosition = window.scrollY; let ticking = true; attachEvent('#header nav', 'click', function () { document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.getElementById('header')?.classList.remove('bg-page'); document.querySelector('#header nav')?.classList.add('hidden'); document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); }); attachEvent('[data-aw-toggle-menu]', 'click', function (_, elem) { elem.classList.toggle('expanded'); document.body.classList.toggle('overflow-hidden'); document.getElementById('header')?.classList.toggle('h-screen'); document.getElementById('header')?.classList.toggle('expanded'); document.getElementById('header')?.classList.toggle('bg-page'); document.querySelector('#header nav')?.classList.toggle('hidden'); document.querySelector('#header > div > div:last-child')?.classList.toggle('hidden'); }); attachEvent('[data-aw-toggle-color-scheme]', 'click', function () { if (defaultTheme.endsWith(':only')) { return; } Observer.removeAnimationDelay(); document.documentElement.classList.toggle('dark'); localStorage.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }); attachEvent('[data-aw-social-share]', 'click', function (_, elem) { const network = elem.getAttribute('data-aw-social-share'); const url = encodeURIComponent(elem.getAttribute('data-aw-url')); const text = encodeURIComponent(elem.getAttribute('data-aw-text')); let href; switch (network) { case 'facebook': href = `https://www.facebook.com/sharer.php?u=${url}`; break; case 'twitter': href = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; break; case 'linkedin': href = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}`; break; case 'whatsapp': href = `https://wa.me/?text=${text}%20${url}`; break; case 'mail': href = `mailto:?subject=%22${text}%22&body=${text}%20${url}`; break; default: return; } const newlink = document.createElement('a'); newlink.target = '_blank'; newlink.href = href; newlink.click(); }); const screenSize = window.matchMedia('(max-width: 767px)'); screenSize.addEventListener('change', function () { document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.getElementById('header')?.classList.remove('bg-page'); document.querySelector('#header nav')?.classList.add('hidden'); document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); }); function applyHeaderStylesOnScroll() { const header = document.querySelector('#header[data-aw-sticky-header]'); if (!header) return; if (lastKnownScrollPosition > 60 && !header.classList.contains('scroll')) { header.classList.add('scroll'); } else if (lastKnownScrollPosition <= 60 && header.classList.contains('scroll')) { header.classList.remove('scroll'); } ticking = false; } applyHeaderStylesOnScroll(); attachEvent([document], 'scroll', function () { lastKnownScrollPosition = window.scrollY; if (!ticking) { window.requestAnimationFrame(() => { applyHeaderStylesOnScroll(); }); ticking = true; } }); }; const onPageShow = function () { document.documentElement.classList.add('motion-safe:scroll-smooth'); const elem = document.querySelector('[data-aw-toggle-menu]'); if (elem) { elem.classList.remove('expanded'); } document.body.classList.remove('overflow-hidden'); document.getElementById('header')?.classList.remove('h-screen'); document.getElementById('header')?.classList.remove('expanded'); document.querySelector('#header nav')?.classList.add('hidden'); }; window.onload = onLoad; window.onpageshow = onPageShow; document.addEventListener('astro:after-swap', () => { initTheme(); onLoad(); onPageShow(); }); })();