Three.js教程
Cesium
world-geojson
world-geojson

不用 Cesium,Three.js 也能做 3D 地球可视化

最近用世界地图的 GeoJSON 数据 + Three.js 做了几个地球可视化效果:人口柱体地球、线框星空地球、粒子世界。

核心思路是一样的——把经纬度坐标先在平面上处理,再"弯曲"贴到球面。搞清楚这一步,三个效果都能套用。

GeoJSON 是什么

GeoJSON 是一种描述地理数据的 JSON 格式,世界各国的边界、行政区划、城市坐标都可以用它来表示。

一份典型的世界地图 GeoJSON 大概长这样:

{
  "type": "FeatureCollection",
  "features": [{
    "properties": {
      "name": "China",
      "pop_est": 1402112000
    },
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[lng, lat], [lng, lat], ...]]
    }
  }]
}

每个国家是一个 feature,properties 存属性数据(人口、GDP 等),geometry 存边界坐标。边界就是一堆经纬度点围成的多边形。

怎么把 GeoJSON 变成 Three.js 几何体

这是整个项目最关键的部分。

加载数据

fetch 直接读 JSON 文件,然后遍历每个 feature:

const response = await fetch('world.geojson');
const data = await response.json();
 
for (const feature of data.features) {
    const meshes = createCountry(feature, radius);
    meshes.forEach(mesh => scene.add(mesh));
}

从坐标创建 THREE.Shape

THREE.Shape 是 Three.js 里的二维形状,支持内嵌空洞(比如南非包裹着莱索托)。

GeoJSON 里国家边界分两种类型:

  • Polygon:普通国家,单块领土
  • MultiPolygon:岛国或有飞地的国家(日本、印尼等),多块领土
function createCountryShapes(geometry) {
    const shapes = [];
 
    const processPolygon = (polygonCoords) => {
        const shape = new THREE.Shape();
        // 外部轮廓
        const outerPts = polygonCoords[0].map(p => new THREE.Vector2(p[0], p[1]));
        shape.setFromPoints(outerPts);
        // 内部空洞
        for (let i = 1; i < polygonCoords.length; i++) {
            const hole = new THREE.Path();
            hole.setFromPoints(polygonCoords[i].map(p => new THREE.Vector2(p[0], p[1])));
            shape.holes.push(hole);
        }
        return shape;
    };
 
    if (geometry.type === 'Polygon') {
        shapes.push(processPolygon(geometry.coordinates));
    } else if (geometry.type === 'MultiPolygon') {
        geometry.coordinates.forEach(polygonCoords => {
            shapes.push(processPolygon(polygonCoords));
        });
    }
 
    return shapes;
}

这里的坐标 [lng, lat] 被当成二维平面的 [x, y] 存进 Shape,还没有投影到球面。

ExtrudeGeometry 拉伸成 3D,再"弯曲"贴到球面

这是整个方案最巧妙的地方。

直觉上可能先想把经纬度转成 3D 坐标再建网格,但构建三角网格很麻烦。实际做法反过来:先在平面建形状、拉伸,再把每个顶点"掰弯"到球面上。

// 先在平面拉伸
const geometry = new THREE.ExtrudeGeometry(shape, {
    depth: height,
    bevelEnabled: false
});
 
// 再把每个顶点从平面坐标转换到球面坐标
const positions = geometry.attributes.position;
for (let i = 0; i < positions.count; i++) {
    const lng = positions.getX(i);  // 平面 x = 经度
    const lat = positions.getY(i);  // 平面 y = 纬度
    const h   = positions.getZ(i);  // 平面 z = 拉伸高度(0~depth)
 
    const pos = latLngToVec3(lat, lng, radius + h);
    positions.setXYZ(i, pos.x, pos.y, pos.z);
}
 
// 弯曲后法线失效,必须重新计算
geometry.computeVertexNormals();

弯曲之后,平面形状自然贴合到球面,拉伸方向也变成了径向朝外。

坐标转换公式

球坐标系转笛卡尔,高中数学:

function latLngToVec3(lat, lng, radius) {
    const phi   = (90 - lat) * (Math.PI / 180);
    const theta = (lng + 180) * (Math.PI / 180);
 
    return new THREE.Vector3(
        -radius * Math.sin(phi) * Math.cos(theta),
         radius * Math.cos(phi),
         radius * Math.sin(phi) * Math.sin(theta)
    );
}

纬度决定 Y 轴高度,经度决定 XZ 平面方向,乘以半径就是球面上的点。

案例一:3D 人口地球

每个国家根据人口拉伸成不同高度的柱体。

人口跨度从 10 万到 14 亿,差了 1 万倍,直接按比例大多数国家几乎看不见。用 Math.pow 压缩差距:

const height = Math.pow((pop - minPop) / (maxPop - minPop), 0.4) * 15;

指数 0.4 是反复调试后视觉上最均匀的值——0.5 小国还是太矮,0.3 大国之间差异又太小。

颜色也映射人口,用多段渐变做色阶:

const sqrtT = Math.pow(t, 0.35);
const idx = Math.min(Math.floor(sqrtT * 5), 4);
const localT = (sqrtT * 5) - idx;
return colors[idx].clone().lerp(colors[idx + 1], localT);

3D Population Globe

案例二:线框地球 + 星空

换一种思路——不填充面,只画国家边界线,配上星空和大气层光晕。

边界线用 THREE.Line + LineBasicMaterial,把 GeoJSON 坐标转成球面上的点连线:

function createEarthOutline(coords, radius, color) {
    const points = createCountryPoints(coords, radius);
    const geometry = new THREE.BufferGeometry().setFromPoints(points);
    const material = new THREE.LineBasicMaterial({
        color: color,
        transparent: true,
        opacity: 0.6
    });
    return new THREE.Line(geometry, material);
}

不同国家轮流用不同颜色,动画里每帧用 sin 函数让透明度起伏,边界线像在"呼吸":

countryLines.forEach((line, i) => {
    line.material.opacity = 0.4 + Math.sin(time * 0.002 + i * 0.1) * 0.3;
});

大气层用 ShaderMaterial 实现边缘发光效果——根据法线和视角的夹角控制亮度,越边缘越亮:

fragmentShader: `
    varying vec3 vNormal;
    void main() {
        float intensity = pow(0.7 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
        gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * intensity;
    }
`

Particle World

案例三:粒子世界

用 15 万个粒子点"拼"出世界地图,颜色按大洲区分,加上波浪动画。

核心是 THREE.Points + BufferGeometry。BufferGeometry 直接操作底层 Float32Array,性能比普通 Geometry 高很多:

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
 
const material = new THREE.PointsMaterial({
    size: 1.5,
    vertexColors: true,
    blending: THREE.AdditiveBlending  // 叠加混合,粒子密集处更亮
});

波浪动画每帧更新顶点坐标,sin 函数让粒子上下起伏:

posAttr.array[i3] = originalPositions[i3] + Math.sin(time + i) * 0.5;

Flythrough

两个加分项

UnrealBloomPass 发光

默认渲染画面偏"平"。加后处理发光,视觉提升明显:

const bloomPass = new UnrealBloomPass(
    new THREE.Vector2(width, height),
    1.5,  // 强度,建议从 0.8 开始调,太大容易过曝
    0.5,  // 扩散范围
    0.2   // 阈值,越低发光范围越广
);
composer.addPass(bloomPass);

OrbitControls 轨道控制

拖拽旋转、滚轮缩放、惯性阻尼:

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;

开启 enableDamping 后,松手地球会缓缓减速停下,体验好很多。

总结

三个案例用的是同一份 GeoJSON 数据,改变的只是渲染方式:挤出填充体、线条、粒子点。

地理可视化有意思的地方就在这——数据是死的,表现形式可以差很多。GeoJSON 里还有 GDP、碳排放等字段,换个映射逻辑就是另一个效果,有兴趣可以自己接着玩。