1. 从零开始:为什么选择Three.js + Mapbox来玩转3D地形?

如果你正在做一个智慧园区、数字孪生工厂,或者一个游戏里的某个小地图场景,需要一块看起来真实、能交互的3D地面,你可能会第一时间想到那些庞大的三维GIS引擎。它们功能确实强大,但就像为了喝杯牛奶而养一头奶牛——太重了。启动慢、学习曲线陡峭、对小型项目来说完全是性能过剩。这时候,Three.js 加上 Mapbox 的地形瓦片,就成了我工具箱里的“黄金搭档”。我做过不少这类项目,实测下来,这个组合在小场景里又快又稳,效果还非常专业。

简单来说,Three.js 让我们能在网页里轻松创建和操控3D物体,而 Mapbox 则提供了现成的、覆盖全球的高精度地形数据。我们的核心任务,就是当好这个“翻译官”:把 Mapbox 提供的、用颜色编码了高度信息的图片(地形瓦片),翻译成 Three.js 里一个由无数个三角形构成的、有起伏的3D网格。这个过程听起来复杂,但拆解后每一步都很清晰。你不需要是图形学专家,只要会点 JavaScript,跟着步骤走,就能在自己的网页里“造”出一座山。接下来,我会把我踩过的坑、试过的好方法,毫无保留地分享给你,保证你从获取数据到看到动态地形,一路畅通。

2. 核心原理揭秘:一张彩色图片如何变成起伏的山峦?

在动手写代码之前,我们得先搞明白 Mapbox 给我们的“地形瓦片”到底是什么,以及 Three.js 是如何理解这个世界的。这能帮你避开很多后续的迷惑。

2.1 Mapbox地形瓦片:RGB颜色里的高度密码

Mapbox 的 mapbox.terrain-rgb 服务非常巧妙。它返回的并不是一张普通的地形图片,而是一张 PNG 格式的“高度编码图”。这张图的每一个像素点的红(R)、绿(G)、蓝(B)通道的值,都不是为了表示颜色,而是共同存储了一个数字——该点的海拔高度。

具体的计算公式,Mapbox 官方有提供,也是我们后面一切计算的基石: 地形高度 = -10000.0 + ((R * 256.0 * 256.0 + G * 256.0 + B) * 0.1)

我来给你拆解一下这个“密码本”:

  • -10000.0:这是一个基准偏移量,为了让所有计算出的高度值都是正数,方便处理。
  • R * 256.0 * 256.0:红色通道代表高度的最高位。想象成256进制,红色是“百位”。
  • G * 256.0:绿色通道代表中间位,相当于“十位”。
  • B:蓝色通道代表最低位,相当于“个位”。
  • * 0.1:最后乘以0.1是将结果单位转换为米。所以,一个像素的RGB值 (255, 255, 255) 解码后的高度大约是 (-10000 + (255*65536 + 255*256 + 255)*0.1) ≈ 1690米。

这意味着,一张512x512像素的地形瓦片,实际上包含了 262,144 个独立的海拔采样点!数据密度非常高。我们获取瓦片后,核心任务就是遍历每个我们需要的位置,用这个公式解算出高度。

2.2 Three.js的几何世界:从平面到山脉

在Three.js中,一切3D物体都是由**几何体(Geometry)和材质(Material)**构成的。对于地形,我们通常从一个平面几何体(THREE.PlaneGeometry)开始。

创建这个平面时,我们需要定义几个关键参数:

const width = 1000; // 平面在三维世界中的宽度(单位:米)
const height = 1000; // 平面在三维世界中的高度(米)
const widthSegments = 256; // 宽度分段数
const heightSegments = 256; // 高度分段数

const planeGeometry = new THREE.PlaneGeometry(width, height, widthSegments, heightSegments);

这里的 widthSegments 和 heightSegments 至关重要。它们决定了这个平面被细分成多少个小格子。分段数越多,格子就越小,顶点就越多,最终地形的细节就越丰富(当然,计算量也越大)。你可以把它想象成一张渔网,分段数就是网眼的数量。网眼越密,你能用这张网塑造的形状就越精细。这个“渔网”的每一个交叉点,就是一个顶点(Vertex)。我们后续修改地形高度,本质上就是根据计算出的海拔,去上下移动每一个顶点的Z轴坐标。

3. 实战第一步:获取并准备你的地形数据

理论清楚了,我们开始动手。第一步是把我们需要的地形瓦片从Mapbox服务器上“拿”下来,并准备好让Three.js使用。

3.1 构建瓦片URL与获取数据

Mapbox使用标准的瓦片金字塔模型,通过缩放级别(zoom)、列号(x)和行号(y)来定位一张瓦片。你需要一个Mapbox的访问令牌(Access Token),可以在其官网免费注册获取。

下面是一个获取地形RGB瓦片的函数示例:

function getTerrainTileUrl(zoom, x, y) {
    const accessToken = '你的Mapbox访问令牌';
    // 模板URL,{z}/{x}/{y} 会被替换
    const urlTemplate = `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${accessToken}`;
    return urlTemplate.replace('{z}', zoom).replace('{x}', x).replace('{y}', y);
}

// 例如,获取缩放级别为14,列号为8192,行号为8192的瓦片
const tileUrl = getTerrainTileUrl(14, 8192, 8192);

获取到瓦片URL后,我们可以使用 Image 对象或 fetch API 来加载图片。这里我更喜欢用 fetch 配合 createImageBitmap,因为它更现代,对控制加载流程更友好:

async function loadTerrainTile(url) {
    try {
        const response = await fetch(url);
        const blob = await response.blob();
        const imageBitmap = await createImageBitmap(blob);
        return imageBitmap;
    } catch (error) {
        console.error('Failed to load terrain tile:', error);
        return null;
    }
}

加载成功后,我们就得到了一个 ImageBitmap 对象,它包含了那张编码了高度信息的RGB图片。为了能在着色器中采样或CPU端读取像素,我们需要将它转换为Three.js的纹理(Texture):

const terrainTexture = new THREE.CanvasTexture(imageBitmap);
terrainTexture.minFilter = THREE.LinearFilter; // 设置滤波方式,使采样更平滑
terrainTexture.magFilter = THREE.LinearFilter;

现在,terrainTexture 就准备好了,它既可以被送入着色器,也可以被画到Canvas上供我们读取像素数据。

3.2 坐标转换:经纬度到三维坐标的桥梁

这里有一个关键的坑点:Mapbox瓦片使用的是Web墨卡托投影(EPSG:3857),其坐标单位是米,但原点在赤道和本初子午线交点。而我们的Three.js场景有自己的三维坐标系(单位也是米)。我们需要建立一个转换关系。

通常,对于一个小范围场景,我们可以采用局部原点法。假设我们关注的区域中心点经纬度为 [lngCenter, latCenter],我们可以用墨卡托投影库(如 proj4)将其转换为米制坐标 [xCenter, yCenter]。然后,在创建Three.js平面时,将平面的中心置于 (0, 0),并确保平面的 width 和 height 对应现实世界该瓦片覆盖的米数。这样,平面上的任意一点 (localX, localY),其对应的真实世界墨卡托坐标就是 (xCenter + localX, yCenter + localY)。我们需要根据这个真实坐标,去地形纹理上找到对应的像素点来采样高度。这个映射计算是后续高度解算正确的基础,务必理清。

4. 构建动态地形网格的两种核心技术路径

这是最核心的部分,如何把高度数据“刻”到平面网格上?我实践过两种主流方法,各有优劣,适合不同场景。

4.1 CPU端计算:逐顶点修改几何体

这种方法思路直接:在JavaScript(CPU)端,遍历平面几何体的每一个顶点,根据其位置映射到地形纹理的某个像素,读取该像素的RGB值,用公式算出高度,然后直接修改该顶点的 z 坐标。

首先,我们需要从纹理中读取像素数据:

function getPixelDataFromTexture(texture) {
    // 创建一个临时的Canvas来读取纹理数据
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = texture.image.width;
    canvas.height = texture.image.height;
    ctx.drawImage(texture.image, 0, 0);
    return ctx.getImageData(0, 0, canvas.width, canvas.height);
}

const imageData = getPixelDataFromTexture(terrainTexture);
const data = imageData.data; // 这是一个Uint8ClampedArray,结构是[R,G,B,A, R,G,B,A, ...]

然后,遍历几何体顶点进行计算:

const positions = planeGeometry.attributes.position.array; // 获取顶点位置数组 [x1,y1,z1, x2,y2,z2, ...]

for (let i = 0; i < positions.length; i += 3) {
    const localX = positions[i];     // 顶点局部X坐标
    const localY = positions[i + 1]; // 顶点局部Y坐标

    // 1. 将局部坐标(-0.5到0.5)归一化到UV坐标(0到1)
    const u = (localX / width) + 0.5;
    const v = 1.0 - ((localY / height) + 0.5); // 注意V方向可能翻转

    // 2. 将UV坐标映射到纹理像素坐标
    const pixelX = Math.floor(u * (imageData.width - 1));
    const pixelY = Math.floor(v * (imageData.height - 1));

    // 3. 计算像素在data数组中的索引
    const idx = (pixelY * imageData.width + pixelX) * 4;

    // 4. 读取RGB值
    const r = data[idx] / 255.0;
    const g = data[idx + 1] / 255.0;
    const b = data[idx + 2] / 255.0;

    // 5. 计算高度
    const terrainHeight = -10000.0 + ((r * 256.0 * 256.0 + g * 256.0 + b) * 0.1);

    // 6. 修改顶点Z坐标
    positions[i + 2] = terrainHeight;
}

// 重要!更新几何体,告诉Three.js顶点数据已改变
planeGeometry.attributes.position.needsUpdate = true;
planeGeometry.computeVertexNormals(); // 重新计算法线,光照才会正确

优点:逻辑清晰,易于调试,可以方便地基于顶点高度做后续的CPU端逻辑(如碰撞检测初始点)。 缺点:性能瓶颈。顶点数一多(比如256x256细分就有6万多个顶点),逐顶点计算和庞大的数组操作会明显卡顿,不适合需要实时更新或超大网格的场景。

4.2 GPU端计算:顶点着色器魔法

为了追求极致性能,我们必须把高度计算的工作丢给GPU,也就是使用自定义着色器。我们创建一个特殊的着色器材质(THREE.ShaderMaterial),在顶点着色器(Vertex Shader)中完成所有工作。

原理是:我们不再预先修改CPU端的顶点数据,而是将原始平面几何体(Z坐标全为0)和地形纹理一并传给GPU。在GPU运行顶点着色器时,对每个顶点,根据其UV坐标去采样地形纹理,实时解码出高度,然后叠加到顶点的最终位置坐标上。所有计算在GPU中并行完成,速度极快。

下面是一个基础的顶点着色器代码示例:

// 顶点着色器
uniform sampler2D uTerrainRGB; // 传入的地形RGB纹理
varying vec2 vUv; // 将UV坐标传递给片元着色器(可选,用于后续贴图)

void main() {
    vUv = uv; // uv是Three.js传入的默认顶点UV坐标

    // 采样地形纹理,texture2D返回值的范围是[0,1]
    vec4 terrainPixel = texture2D(uTerrainRGB, uv);

    // Mapbox RGB高度解码公式
    float terrainHeight = -10000.0 + ((terrainPixel.r * 256.0 * 256.0 + terrainPixel.g * 256.0 + terrainPixel.b) * 0.1);

    // 构造新的顶点位置
    vec3 displacedPosition = position + vec3(0.0, 0.0, terrainHeight);

    // 输出最终裁剪空间坐标
    gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
}

对应的JavaScript材质创建代码:

const terrainShaderMaterial = new THREE.ShaderMaterial({
    uniforms: {
        uTerrainRGB: { value: terrainTexture } // 传入我们加载好的纹理
    },
    vertexShader: document.getElementById('vertexShader').textContent, // 假设着色器代码写在script标签里
    fragmentShader: document.getElementById('fragmentShader').textContent, // 片元着色器可以先简单输出颜色
});

// 使用原始平面几何体和着色器材质创建网格
const terrainMesh = new THREE.Mesh(planeGeometry, terrainShaderMaterial);
scene.add(terrainMesh);

优点:性能极高,无论网格多复杂,计算开销几乎恒定,且能实现地形动态变化(只需更新纹理)。 缺点:调试困难,着色器编程有门槛。而且,因为顶点位置是在着色器中动态计算的,Three.js的射线拾取(Raycasting) 和某些依赖精确几何体的后期处理效果可能会出问题,因为CPU端知道的几何体仍然是一个平面。

5. 让地形栩栩如生:贴图、光照与性能调优

有了地形网格,我们还要给它“化妆”和“健身”,让它看起来更真实,跑起来更流畅。

5.1 为地形穿上“外衣”:卫星影像贴图

只有起伏的地形是苍白的,我们需要把Mapbox的卫星影像贴上去。方法和获取地形瓦片类似:

function getSatelliteTileUrl(zoom, x, y) {
    const accessToken = '你的Mapbox访问令牌';
    return `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}?access_token=${accessToken}`;
}

加载卫星影像纹理后,我们需要在着色器中进行多重纹理采样。修改上面的片元着色器,同时采样地形纹理(如果需要)和卫星影像纹理,并将影像颜色作为最终输出。

// 片元着色器
uniform sampler2D uSatellite; // 卫星影像纹理
varying vec2 vUv;

void main() {
    vec4 satelliteColor = texture2D(uSatellite, vUv);
    gl_FragColor = satelliteColor; // 直接输出卫星影像颜色
}

更高级的做法,可以根据高度(从地形纹理再次解码或从顶点着色器传递过来)进行分层设色,比如低海拔是绿色植被,高海拔是白色雪线,这需要更复杂的片元着色器逻辑。

5.2 计算正确的法线与光照

光照是体现3D物体体积感的关键。光照计算依赖于法线向量。对于CPU方法,我们在修改顶点后调用 geometry.computeVertexNormals() 可以自动生成。但对于GPU着色器方法,我们需要在顶点着色器中计算变换后的法线。

然而,由于我们的地形是顶点着色器动态位移生成的,传统的顶点法线不再准确。我们需要在着色器中基于相邻顶点的高度差来实时计算法线。这通常通过在当前UV点的周围进行多次纹理采样,估算出高度场在X和Y方向上的梯度(导数),然后用叉积得到法线向量。这是一个进阶话题,实现起来代码量会增多,但能带来极其准确的光照效果,特别是在低细分网格但高精度纹理的情况下。

5.3 性能优化实战技巧

  • 细节层次(LOD):当摄像机远离地形时,渲染高细分度的网格是浪费。可以准备多个不同细分程度的几何体,根据距离切换。
  • 视锥体剔除:Three.js默认会进行,确保相机看不到的物体不被渲染。
  • 纹理压缩与复用:对于卫星影像,可以使用 THREE.CompressedTexture 或调整纹理的 anisotropy 属性来改善性能和外观。同时,注意纹理的重复使用,避免重复加载相同瓦片。
  • 控制网格密度:不是越密越好。根据场景大小和摄像机最近距离,选择一个合理的分段数。通常128x128或256x256已经能提供很好的视觉效果。
  • 使用BufferGeometry:始终使用 THREE.BufferGeometry 而不是旧的 Geometry 类,它是为性能而设计的。
  • 合并网格:如果你需要加载多块相邻的瓦片地形,可以考虑在CPU端将它们的高度数据合并计算后,生成一个更大的单一几何体,这比渲染多个小网格对象更高效。

在我最近的一个数字厂区项目中,我采用了着色器方案,并实现了简单的四叉树LOD。在核心区域保持256细分,边缘区域降至64细分。同时,我将地形渲染和卫星贴图渲染分离,允许用户开关贴图以提升帧率。这些策略使得在普通笔记本上也能流畅交互。记住,优化是一个权衡的过程,关键是找到适合你具体场景的平衡点。

更多推荐