普通视图

发现新文章,点击刷新页面。
昨天以前首页

AI 解密大厂 Three.js 三维引擎开发 03|从经纬度到三维世界的坐标解码

2026年4月7日 09:10

Three.js 地理坐标系:从经纬度到三维世界的坐标解码

“一切3D图形的壮美,如果不落于地理坐标系上,终究只是漂浮在虚空之中。”

引言:两个宇宙的对话

在三维可视化开发中,我们常常面临一个根本性的问题:Three.js 默认的笛卡尔坐标系(原点在 (0,0,0))对“北京天安门”或“纽约自由女神”一无所知。而真实世界的数据——无论是 GPS 轨迹、建筑轮廓还是城市三维模型——都以经纬度来定义位置。

如何让 Three.js 理解“东经 116.3913°,北纬 39.9075°”?答案就藏在坐标系之中。

本文将沿着“地球 → 数学面 → 平面 → 屏幕”这条路径,系统讲解 GIS 坐标系的核心概念,并重点解决 Three.js 开发中最关键的问题:如何将地理坐标转换为三维空间坐标

一、从地球到平面:坐标系的“三级逼近”

地球是一个不规则的球体,表面有高山也有海沟,无法直接用数学公式描述。为了能用数学表达地球上的位置,科学家们设计了三级逼近体系:

1.1 大地水准面(第一级逼近)

大地水准面是地球的物理模型——假设海水完全静止时延伸至大陆下方的重力等位面。由于地球质量分布不均,大地水准面的形状是不规则的,无法用简单数学公式表达。

1.2 地球椭球体(第二级逼近)

为了能用数学表达,我们用一个规则的椭球体来逼近大地水准面。不同的国家和地区会选择不同的椭球体来最佳拟合本地地形。例如:

  • WGS84 椭球体:长半轴 6378137 米,扁率 1/298.257223563
  • CGCS2000 椭球体:我国现行坐标系采用的椭球体

1.3 基准面(第三级逼近)

确定了椭球体后,还需要将它定位到地球上的具体位置——这就是基准面的作用。基准面决定了椭球体与地球表面的相对位置关系。

根据原点位置,基准面分为两类:

类型 原点 代表坐标系
地心基准面 地球质心 WGS84、CGCS2000
区域基准面 特定区域的大地原点 北京54、西安80

关键理解:同一个椭球体可以配合不同的基准面。就像同一件衣服穿在不同人身上——椭球体是“衣服”,基准面决定了它“穿”在地球的哪个位置。

二、坐标系的两大阵营:地理 vs 投影

2.1 地理坐标系(Geographic Coordinate System)

本质:三维球面坐标系,用角度(经纬度)表示位置。

组成:角度单位(度/分/秒)+ 本初子午线 + 基准面(含椭球体)。

经典案例——WGS84(EPSG:4326)

  • 全球通用的 GPS 坐标系,原点为地球质心
  • 坐标单位:十进制度(如 116.3913°, 39.9075°)
  • Z轴指向 BIH 1984.0 定义的协议地极方向

特点:适合存储交换数据,但不适合直接用于平面测量和 Web 展示。

2.2 投影坐标系(Projected Coordinate System)

本质:二维平面坐标系,用线性单位(米、英尺)表示位置。

组成:地理坐标系 + 投影方法 + 线性单位。

为什么需要投影? 因为地图是平的,而地球是曲的。投影的本质是用数学方法将球面“摊平”到平面上——但任何投影都会带来变形(面积、角度、距离三者不可兼得)。

三大经典投影

投影名称 特点 典型应用
墨卡托投影 等角,航线为直线,两极变形极大 航海图
Web Mercator(EPSG:3857) 墨卡托的“黑客版”,将地球近似为正球体 Google Maps、OpenStreetMap
高斯-克吕格 等角横切椭圆柱投影,中央经线无变形 中国1:1万~1:50万地形图

💡 Web Mercator 的由来:Google 工程师为了简化计算,将地球近似为正球体(半径 = 椭球体长半轴),并将 Y 轴范围截断与 X 轴相同,形成正方形地图——这就是“懒惰工程师”的智慧。

三、Three.js 坐标转换实战:经纬度 → 笛卡尔坐标

3.1 核心问题

Three.js 使用右手笛卡尔坐标系

  • X 轴:右
  • Y 轴:上
  • Z 轴:前(相机默认朝向)

而地理数据是经纬度(角度)——需要翻译官。

3.2 转换方案一:Web Mercator 投影法(适合城市级场景)

这是最常用的方法。Web Mercator 将地球投影为平面,公式如下:

// 经纬度转 Web Mercator 坐标(单位:米)
function lngLatToMercator(lng, lat) {
  const earthRad = 6378137.0;  // 地球赤道半径(米)
  const x = lng * Math.PI / 180 * earthRad;
  const latRad = lat * Math.PI / 180;
  const y = earthRad / 2 * Math.log((1 + Math.sin(latRad)) / (1 - Math.sin(latRad)));
  return { x, y };
}

// 示例:北京天安门
const beijing = lngLatToMercator(116.3913, 39.9075);
// 输出:{ x: 12957495.23, y: 4840818.93 }

得到的 (x, y) 即为 Three.js 场景中可用的平面坐标(Z 轴可设高度)。

3.3 转换方案二:Mapbox 风格转换法(适合与地图 SDK 集成)

如果使用 Mapbox 作为底图,可利用其内置的 MercatorCoordinate 类:

// 经纬度转 Mapbox 世界坐标(范围 0-1)
const mercator = mapboxgl.MercatorCoordinate.fromLngLat(
  { lng: 116.3913, lat: 39.9075 },
  0  // 海拔高度(米)
);

// 输出:{ x: 0.91576, y: 0.48563, z: 0 }

// 获取米到世界坐标单位的换算比例
const scale = mercator.meterInMercatorCoordinateUnits();

// 在 Three.js 中使用
cube.position.set(mercator.x, mercator.y, mercator.z);
cube.scale.set(scale, scale, scale);  // 保证模型大小正确

⚠️ 注意:Web Mercator 在局部范围内精度很高,但跨区域(如从赤道到两极)会出现严重变形——因为球面无法完美摊平。

四、中国的“偏移坐标系”:不得不说的坑

在国内做地图开发,坐标系还有一层特殊加密。

4.1 三类坐标的“三国演义”

名称 代码 说明 使用方
WGS84 4326 真实 GPS 坐标,无偏移 国际服务、硬件设备
GCJ-02 - 国测局加密,“火星坐标” 高德、腾讯、Google 中国
BD-09 - 百度二次加密 百度地图

4.2 为什么会有偏移?

出于国家安全考虑,国内所有导航电子地图必须使用加密坐标系统——将真实经纬度按非线性算法“偏移”一定距离。这就是为什么用 GPS 设备采集的点直接叠加在高德地图上会“飘”出几条街。

4.3 转换方案(coordtransform)

import coordtransform from 'coordtransform';

// WGS84 → GCJ-02
const gcj = coordtransform.wgs84togcj02(lng, lat);

// GCJ-02 → BD-09
const bd = coordtransform.gcj02tobd09(lng, lat);

// 反向转换同理

开发建议

  • 统一后端存储为 WGS84
  • 前端根据底图类型动态转换
  • 跨地图服务商时务必转换,否则数据对不上

五、坐标系选型速查表

使用场景 推荐坐标系 原因
GPS 数据存储 WGS84(EPSG:4326) 国际标准,无偏移
国际地图底图 Web Mercator(EPSG:3857) 瓦片服务标准
中国官方测绘 CGCS2000(EPSG:4490) 法定坐标系
高德/腾讯地图 GCJ-02 服务商原生支持
百度地图 BD-09 百度专用
精确面积/长度测量 高斯-克吕格(当地中央经线) 区域变形最小

六、常见问题 FAQ

Q1:为什么我的 GPS 点在中国地图上显示偏移? A:中国地图服务(高德、百度等)使用加密坐标系(GCJ-02/BD-09),而 GPS 输出的是 WGS84。需要先做坐标转换。

Q2:Three.js 场景中模型位置总是不对? A:检查是否将经纬度正确转换为了 Web Mercator 坐标。记住:Three.js 不认识经纬度,只认数字。

Q3:CGCS2000 和 WGS84 可以通用吗? A:对于一般工程测量(精度要求厘米级以下),两者差异可忽略;但高精度场景(如测绘)需要严格区分。

Q4:如何在 Three.js 中渲染全球范围的地理数据? A:需要使用地心坐标系(ECEF),配合球体模型和相机控制。Web Mercator 只适合局部区域。

结语

坐标系的本质,是用数学模型描述物理世界。从大地水准面到椭球体,从地理坐标系到投影坐标系,再到 Three.js 中的笛卡尔坐标——每一次转换,都是对地球的一次“翻译”。

理解这些概念,不仅是为了解决“点飘到哪里去了”的技术问题,更是为了在三维数字世界中,精准地锚定现实。

“图形的浪漫在于幻想,地图的浪漫在于现实。而当你让三维图形落地在真实世界,那是浪漫与理性最优雅的交融。”


相关资源:

【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感

作者 叶智辽
2026年4月6日 11:42

前言

调色预设用腻了?那就自己写一个滤镜。不就是给画面加特效吗,GPU 说它很乐意帮忙。

上一篇文章我们聊了电影级调色,用现成的 Bloom 和 LUT 把画面整得挺像回事。但有读者留言:“这些预设是挺好,但我想要那种赛博朋克的色彩偏移效果,或者复古胶片的感觉,LUT 找不到合适的。”

这确实是个问题。LUT 再丰富也有限,真正的自定义还得靠 Shader。

其实 Three.js 的后期处理流水线本来就是用 Shader 搭起来的。BloomPassFilmPass 这些内置效果,源码里都是一堆 GLSL 代码。既然官方能写,我们也能写。

今天我就带你写三个自定义后期滤镜:一个简单的灰度,一个边缘发光,一个炫酷的色彩偏移(RGB Split)。全部手写 Shader,跑起来的那一刻,你会发现原来自己也能造轮子。


一、后期处理流水线回顾

Three.js 的后期处理核心是 EffectComposer。它就像一条传送带,上面挂着一个个 Pass,每个 Pass 可以对画面做一次加工。

const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

const customPass = new ShaderPass({
  uniforms: {},
  vertexShader: '',
  fragmentShader: ''
});
composer.addPass(customPass);

ShaderPass 需要两个关键东西:顶点着色器和片元着色器。顶点着色器几乎不用动,片元着色器里写的就是像素级别的滤镜逻辑。


二、第一个滤镜:灰度

先从最简单的开始。把画面变成黑白的,感受一下 ShaderPass 的工作流程。

import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';

// 基础场景(随便放几个物体)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 添加一些好看的物体
const geometry = new THREE.SphereGeometry(1.2, 64, 64);
const material = new THREE.MeshStandardMaterial({ color: 0xffaa44, roughness: 0.3, metalness: 0.1 });
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(-1.5, 0.5, 0);
scene.add(sphere);

const boxGeometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const boxMaterial = new THREE.MeshStandardMaterial({ color: 0x44aaff, roughness: 0.2, metalness: 0.3 });
const box = new THREE.Mesh(boxGeometry, boxMaterial);
box.position.set(1.5, 0.5, 0);
scene.add(box);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(2, 5, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0x404060));

// 后期合成器
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 自定义灰度滤镜
const grayscalePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null } // 这是 ShaderPass 约定的输入纹理
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    varying vec2 vUv;
    void main() {
      vec4 color = texture2D(tDiffuse, vUv);
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      gl_FragColor = vec4(vec3(gray), color.a);
    }
  `
});

composer.addPass(grayscalePass);
// 确保最后一个 Pass 输出到屏幕
grayscalePass.renderToScreen = true;

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  sphere.rotation.y += 0.01;
  box.rotation.x += 0.01;
  composer.render();
}
animate();

运行这段代码,画面就变成黑白的了。关键点:

  • tDiffuse 是 ShaderPass 内置的 uniform,代表上一个 Pass 渲染好的纹理。
  • 顶点着色器只需要传递 UV 坐标。
  • 片元着色器里用 texture2D 采样,然后算灰度值。

三、第二个滤镜:边缘发光

这个效果其实是用 Sobel 算子检测边缘,然后在边缘处叠加发光颜色。

const edgePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec2 resolution;
    varying vec2 vUv;

    void main() {
      vec2 texel = vec2(1.0 / resolution.x, 1.0 / resolution.y);
      
      // Sobel 算子
      float gx = 0.0;
      float gy = 0.0;
      
      // 采样周围8个点
      for (int i = -1; i <= 1; i++) {
        for (int j = -1; j <= 1; j++) {
          vec2 offset = vec2(float(i), float(j)) * texel;
          vec4 c = texture2D(tDiffuse, vUv + offset);
          float gray = dot(c.rgb, vec3(0.299, 0.587, 0.114));
          
          // Sobel 权重
          float wx = float(j) * (1.0 - abs(float(i)));
          float wy = float(i) * (1.0 - abs(float(j)));
          
          gx += gray * wx;
          gy += gray * wy;
        }
      }
      
      float edge = sqrt(gx * gx + gy * gy);
      edge = clamp(edge * 2.0, 0.0, 1.0);
      
      vec4 original = texture2D(tDiffuse, vUv);
      vec3 edgeColor = vec3(0.2, 0.6, 1.0); // 蓝色边缘
      
      vec3 finalColor = mix(original.rgb, edgeColor, edge);
      gl_FragColor = vec4(finalColor, original.a);
    }
  `
});

把这个 Pass 添加到 composer 里,替换掉灰度滤镜,你会看到物体边缘有一圈蓝色光晕,很有科技感。


四、第三个滤镜:色彩偏移(RGB Split)

故障艺术里常见的 RGB 分裂效果,把红、绿、蓝三个通道稍微错开一点。

const rgbSplitPass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    amount: { value: 0.01 } // 偏移量
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float amount;
    varying vec2 vUv;

    void main() {
      vec2 offset = vec2(amount, 0.0);
      
      float r = texture2D(tDiffuse, vUv + offset).r;
      float g = texture2D(tDiffuse, vUv).g;
      float b = texture2D(tDiffuse, vUv - offset).b;
      
      gl_FragColor = vec4(r, g, b, 1.0);
    }
  `
});

为了让效果更动态,可以在动画里让 amount 随机变化:

let time = 0;
function animate() {
  requestAnimationFrame(animate);
  
  time += 0.01;
  rgbSplitPass.uniforms.amount.value = 0.01 + Math.sin(time * 5) * 0.005;
  
  composer.render();
}

运行起来,画面边缘会出现彩色错位,像老式 CRT 显示器故障的感觉。


五、组合多个滤镜

后期处理的魅力在于可以组合。先做 RGB 分裂,再做边缘检测,最后加一点噪点。

composer.addPass(rgbSplitPass);
composer.addPass(edgePass);
composer.addPass(grayscalePass); // 注意顺序会影响结果
edgePass.renderToScreen = false;
grayscalePass.renderToScreen = true; // 最后一个

不同的顺序会产生完全不同的视觉效果,可以多试试。


六、坑点汇总

  1. 纹理坐标vUv 的范围是 0~1,采样时要小心边缘溢出。可以用 clamp 或者 repeat 模式,但一般默认就是 clampToEdgeWrapping
  2. 分辨率:有些滤镜需要知道纹理的实际像素尺寸(比如边缘检测),需要传入 resolution uniform,并在窗口变化时更新。
  3. 性能:每个 Pass 都是一次全屏渲染,Pass 越多越耗性能。可以用 composer.setSize() 降低内部分辨率来优化。
  4. 最后一个 Pass:必须设置 pass.renderToScreen = true,否则画面会消失。
  5. uniform 更新:记得在动画循环里更新自定义 uniform。
  6. 调试技巧:可以在片元着色器里直接返回固定颜色,比如 gl_FragColor = vec4(1.0,0.0,0.0,1.0); 来确认 Pass 是否生效。

七、总结

今天我们手写了三个自定义后期滤镜:灰度、边缘检测、RGB 分裂。掌握了 ShaderPass 的基本用法,你就可以创造出无限种视觉效果。

Three.js 内置了几十个 Shader 例子,在 examples/jsm/shaders/ 目录下。下次需要什么奇怪的效果,不妨先去看看源码,说不定就能改出一个自己的版本。


以下是全部的效果对比图:

image.png

互动

你打算用自定义 Shader 实现什么效果?或者你在写 Shader 时遇到过什么诡异的问题?评论区分享出来,咱们一起解决 😏

下篇预告:【Three.js 项目复盘】一个智慧工厂监控大屏的踩坑实录

❌
❌