阅读视图

发现新文章,点击刷新页面。

Three.js 色彩空间的正确使用方式

three中色彩空间常见用处

// 给材质设置色彩空间
material1.map.colorSpace = THREE.SRGBColorSpace;

// 给渲染器的输出色彩空间, 不设置的话默认值也是SRGBColorSpace
new THREE.WebGLRenderer( { outputColorSpace: THREE.SRGBColorSpace } );

three.js r152+ 之后默认就是 SRGBColorSpace,老版本(outputEncoding 时代)行为不同

色彩空间的选项

  • SRGBColorSpace-sRGB 色彩空间

  • LinearSRGBColorSpace-线性sRGB色彩空间

区别?

SRGBColorSpace进行了伽马校正

为什么会有伽马校正?

  1. 纠正硬件的问题

在液晶显示器普及之前,使用的是笨重的 CRT (阴影 栅格 显像管 电视。CRT 的工作原理是用电子枪射出电子束轰击屏幕,科学家发现,电子枪的电压值和屏幕产生的亮度之间并不是 1:1 的线性关系,而是一个幂函数关系:

也就是如下图中红色曲线所示,跟原本蓝色虚线比较,亮度是偏低的

所以为了还原真实效果,抵消调 CRT压低的亮度,那就把真实亮度数据提高,提高成绿色曲线那样,这样一抵消,显示就正常了,这个提高的过程就是伽马校正

  1. 也能满足存储空间合理分配

  • 人眼特性:我们对暗部的变化非常敏感,而对亮部的变化比较迟钝。

  • 数据分配的矛盾:如果我们在电脑里用“线性”方式存储亮度(比如 0 代表黑,128 代表半亮,255 代表全亮):

    • 在 0 到 10 之间(暗部),只有 10 个档位。因为我们眼睛太敏感,这 10 个档位之间的跳变看起来会像阶梯一样,非常不自然(这就是“色彩断层”)。
    • 在 200 到 250 之间(亮部),虽然有 50 个档位,但我们的眼睛根本分不出这 50 种亮度的区别。这部分昂贵的存储空间(位深)就被浪费了。
  • 解决方案(伽马编码) : 故意把 256 个档位中的大部分都分给“暗部”,只留少部分给“亮部”。这样既照顾了人眼的敏感度,又没有浪费存储空间。这样我们可以在 8 位(0-255)的空间里,把更多数值分配给敏感的暗部,让有限的资源发挥最大效用。如图所示(随便找一个以前的暗部区域值,映射后占居的区域明显变多)

为什么现在屏幕“正常”了,还需要它?

现在的液晶(LCD)或 OLED 屏幕完全可以做到“给 128 就亮 50%”,为什么还要折腾?

行业标准的惯性

全球互联网上 99% 的图片(JPEG)、视频(MP4)和网页标准(HTML/CSS)都是基于 sRGB 色彩空间存储的

  • 如果显示器突然改为“线性显示”,那么所有的互联网内容看起来都会变得非常亮

  • 并且图片大多还是如图所示8位,需要上面说过的满足存储空间并合理分配

正确色彩空间处理的流程

  1. 原始图片 默认是 sRGB 色彩空间,它自带一条 “上翘” 的伽马曲线

  2. 转成 线性空间 :在进入 GPU 运算前,需要先把图片从 sRGB 非线性空间转换为 Linear(线性)sRGB 空间。这一步会把上翘的曲线 “拉平” 成一条直线,让亮度数据恢复成物理上均匀的数值,确保后续的光照、混合等计算结果是准确的。

  3. 程序运算 :在线性空间里进行渲染计算,比如光影追踪、材质混合、特效合成等。因为线性空间的亮度是均匀的,所以计算出来的光影效果才符合物理规律,不会出现颜色偏差或暗部丢失。

  4. 渲染结果 :对计算结果,经过伽马校正后,得到的就是最终的 sRGB 格式渲染结果,它的亮度曲线和原始图片的格式是一致的。

  5. 显示器显示 🖥️显示器接收到 sRGB 信号后,会用它自带的伽马曲线(通常 γ≈2.2)来显示,这个过程会把信号 “压暗”。因为我们已经提前做了伽马校正,所以两次曲线变化刚好抵消,最终显示在屏幕上的亮度就和我们计算的结果完全一致。

  6. 人眼感知 👀最终画面被人眼看到,色彩和亮度都保持了设计和计算时的真实效果,不会出现过暗或过亮的问题。

总结

也就是我们要保证用来计算的时候是 Linear( 线性空间,用来渲染的时候是sRGB 空间,那在three中如何做到?

Three.js从输入的角度

Three.js中我们只需要指定 色彩空间 类型即可,程序会帮我们转成线性,所以我们要做的就是把应该指定为SRGBColorSpace的纹理,指定为SRGBColorSpace

举几种常见加载器对加载后的图片色彩空间的处理逻辑

TextureLoader

TextureLoader 不设置 colorSpace,保持默认 NoColorSpace,需要手动设置:

注意!颜色纹理需要手动指定色彩空间为SRGBColorSpace,像下文GLTFLoader中的逻辑一样,

例如

const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' );
texture.colorSpace = THREE.SRGBColorSpace;

CubeTextureLoader

CubeTextureLoader 固定设置为 SRGBColorSpace:

GLTFLoader

只有颜色纹理会被设置为 SRGBColorSpace,其他纹理保持 NoColorSpace:

设置为 SRGBColorSpace 的纹理:

  • baseColorTexture (map) → SRGBColorSpace

  • emissiveTexture (emissiveMap) → SRGBColorSpace

  • sheenColorTexture (sheenColorMap) → SRGBColorSpace

  • specularColorTexture (specularColorMap) → SRGBColorSpace

这几种色彩空间标记的处理逻辑

exture.colorSpace 内部格式 sRGB → Linear 转换
NoColorSpace(默认) RGBA8 不转换,原样上传
SRGBColorSpace SRGB8_ALPHA8 GPU 采样时自动转 SRGB8_ALPHA8 是 WebGL 2.0 的 sRGB 纹理格式 GPU 在采样时自动应用 sRGB EOTF(Electro-Optical Transfer Function)将 sRGB 转为线性
LinearSRGBColorSpace RGBA8 不转换,已是线性

也提供了方法可以手动转化,THREE.Color 类下即可调用

src/math/ColorManagement.js

export function SRGBToLinear( c ) {

    return ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );

}

export function LinearToSRGB( c ) {

    return ( c < 0.0031308 ) ? c * 12.92 : 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;

}

Three.js中-从输出的角度

threejs会在

  • MeshBasicMaterial
  • MeshPhysicalMaterial
  • MeshPhongMaterial
  • MeshLambertMaterial
  • MeshToonMaterial
  • MeshMatcapMaterial
  • SpriteMaterial
  • PointsMaterial 等等

材质输出的时候增加这样一段代码

src/renderers/shaders/ShaderChunk/colorspace_fragment.glsl.js

 gl_FragColor = linearToOutputTexel( gl_FragColor );

linearToOutputTexel函数会根据outputColorSpace来动态配置

  getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),

说白了就是输出的时候会跟你设置的outputColorSpace来判断需不需要转成SRGBColorSpace,默认是转成SRGBColorSpace

我们自己写的ShaderMaterial输出的时候怎么办

我们也可以调用linearToOutputTexel

因为linearToOutputTexel

  • 注入时机:在 WebGLProgram 构造函数中构建 prefixFragment 时

  • 注入方式:通过 getTexelEncodingFunction 动态生成函数代码,添加到 prefixFragment

  • 可用性:所有非 RawShaderMaterial 的材质(包括 ShaderMaterial)都会自动注入

总结

在 three.js 中,默认的色彩空间配置已经覆盖了大多数使用场景。只要遵循颜色纹理使用 sRGB、渲染计算在 线性空间 、输出再转回 sRGB这一基本原则,画面通常就是正确的。但是理解色彩空间与伽马校正的原理,才能在自定义 Shader、特殊纹理或渲染需求出现时,有意识地手动调整配置,而不是盲目试参数

学习Three.js--基于GeoJSON绘制2D矢量地图

学习Three.js--基于GeoJSON绘制2D矢量地图

前置核心说明

开发目标

基于Three.js实现纯矢量2D地图,核心能力包括:

  1. 将GeoJSON地理数据(经纬度)转换为Three.js可渲染的平面图形;
  2. 支持墨卡托投影(经纬度→平面坐标)、地图居中缩放适配视口;
  3. 实现鼠标点击省份高亮(填充+边框变色);
  4. 纯2D交互(仅平移/缩放,禁用旋转),模拟传统地图体验。
  5. 开发效果如下

dc087832-635b-4da4-85d7-1d09b70b1e73.png

核心技术栈

技术点 作用
OrthographicCamera(正交相机) 实现无透视的2D效果(无近大远小),是2D地图的核心相机类型
墨卡托投影函数 将地理经纬度(lon/lat)转换为平面笛卡尔坐标(x/y)
Shape/ShapeGeometry 将GeoJSON的多边形坐标转换为Three.js可渲染的几何形状
Raycaster(射线检测) 实现鼠标点击与省份图形的交互(命中检测)
OrbitControls(轨道控制器) 自定义交互规则(禁用旋转,仅保留平移/缩放)
GeoJSON 行业标准地理数据格式,存储省份的多边形坐标信息

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 存储所有省份Mesh,用于射线检测
const provinceMeshes = []; 

// 1. 场景初始化(地图容器)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

// 2. 正交相机(核心!2D地图必须用正交相机)
const aspect = window.innerWidth / window.innerHeight; // 窗口宽高比
const frustumSize = 800; // 相机视口高度(控制地图初始显示范围)
const camera = new THREE.OrthographicCamera(
  -frustumSize * aspect / 2, // 左边界
  frustumSize * aspect / 2,  // 右边界
  frustumSize / 2,           // 上边界
  -frustumSize / 2,          // 下边界
  1,                         // 近裁切面(最近可见距离)
  1000                       // 远裁切面(最远可见距离)
);
camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
camera.lookAt(0, 0, 0);

// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(自定义交互规则)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false; // 禁用旋转(纯2D地图)
controls.enablePan = true;     // 启用平移(拖拽地图)
controls.enableZoom = true;    // 启用缩放(滚轮)
controls.zoomSpeed = 1.5;      // 缩放速度(适配体验)
1.2 关键参数解析
  • 正交相机参数:区别于透视相机(PerspectiveCamera),正交相机的视口是矩形,所有物体无论距离远近大小一致,完美适配2D地图;
  • frustumSize:控制相机视口高度,值越大地图初始显示范围越大;
  • 控制器配置:禁用旋转是2D地图的核心要求,避免视角倾斜。

步骤2:墨卡托投影函数(经纬度→平面坐标)

2.1 核心代码
// 墨卡托投影:将经纬度(lon/lat)转换为平面坐标(x/y)
function mercator(lon, lat) {
  const R = 6378137; // WGS84坐标系地球半径(米)
  const x = (lon * Math.PI / 180) * R; // 经度转弧度 × 地球半径
  const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R; // 纬度投影公式
  return { x, y }; // y轴北为正,x轴东为正
}
2.2 原理说明
  • 墨卡托投影是地图领域的标准投影方式,将球形地球的经纬度转换为平面矩形坐标;
  • 经度(lon)范围:-180°180°,纬度(lat)范围:-90°90°;
  • 核心公式:
    • 经度转换:直接将角度转为弧度后乘以地球半径;
    • 纬度转换:通过正切+对数函数,解决纬度越靠近极点拉伸越大的问题。

步骤3:GeoJSON加载与全局边界计算

3.1 核心代码
let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

// 异步加载GeoJSON并绘制地图
async function loadAndDrawMap() {
  // 1. 加载GeoJSON数据(本地文件)
  const response = await fetch('./china.json');
  const geojson = await response.json();

  // 2. 手动校正港澳位置(墨卡托投影后的偏移,单位:米)
  const SPECIAL_REGION_OFFSETS = {
    '香港特别行政区': { dx: 80000, dy: -60000 },
    '澳门特别行政区': { dx: 100000, dy: -80000 }
  };

  // 3. 遍历所有坐标,计算全局边界(用于地图居中缩放)
  geojson.features.forEach(feature => {
    traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
      const { x, y } = mercator(lon, lat);
      // 更新边界值(最小/最大x/y)
      bounds.minX = Math.min(bounds.minX, x);
      bounds.maxX = Math.max(bounds.maxX, x);
      bounds.minY = Math.min(bounds.minY, y);
      bounds.maxY = Math.max(bounds.maxY, y);
    });
  });

  // 4. 计算地图中心和缩放比例(适配视口)
  const centerX = (bounds.minX + bounds.maxX) / 2; // 地图中心X
  const centerY = (bounds.minY + bounds.maxY) / 2; // 地图中心Y
  const width = bounds.maxX - bounds.minX;         // 地图宽度
  const height = bounds.maxY - bounds.minY;        // 地图高度
  const scale = 700 / Math.max(width, height);     // 缩放比例(使地图适配视口)

  // 后续创建省份Shape...
}

// 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套结构)
function traverseCoordinates(coords, callback) {
  if (typeof coords[0] === 'number') {
    // 基础情况:[lon, lat] 数组,执行回调
    callback(coords[0], coords[1]);
  } else {
    // 递归情况:嵌套数组,继续遍历
    coords.forEach(c => traverseCoordinates(c, callback));
  }
}
3.2 关键逻辑解析
  • 边界计算:遍历所有省份的所有坐标,得到地图的最小/最大x/y,用于后续居中;
  • 港澳偏移:解决GeoJSON中港澳坐标投影后位置偏差的问题;
  • 缩放比例700 / Math.max(width, height) 保证地图的最大维度适配视口(700为经验值,可调整);
  • 递归遍历坐标:GeoJSON的Polygon是单层数组,MultiPolygon是双层数组,需递归处理所有嵌套坐标。

步骤4:创建Shape与省份Mesh

4.1 核心代码
// 在loadAndDrawMap函数内,边界计算后执行:
geojson.features.forEach(feature => {
  const shapes = [];
  const provinceName = feature.properties.name;
  const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

  // 处理单个Polygon(如大多数省份)
  if (feature.geometry.type === 'Polygon') {
    const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
    if (shape) shapes.push(shape);
  } 
  // 处理MultiPolygon(如包含岛屿的省份:海南、浙江等)
  else if (feature.geometry.type === 'MultiPolygon') {
    feature.geometry.coordinates.forEach(polygon => {
      const shape = createShape(polygon[0], centerX, centerY, scale, offset);
      if (shape) shapes.push(shape);
    });
  }

  // 为每个Shape创建Mesh(填充)和Line(边框)
  shapes.forEach(shape => {
    // 1. 创建填充几何体
    const geometry = new THREE.ShapeGeometry(shape);
    // 2. 填充材质(蓝色,双面渲染)
    const material = new THREE.MeshBasicMaterial({
      color: 0x3b82f6,
      side: THREE.DoubleSide
    });
    // 3. 创建省份Mesh
    const mesh = new THREE.Mesh(geometry, material);
    
    // 关键:绑定省份信息到userData(交互时使用)
    mesh.userData = {
      provinceName: feature.properties.name,
      originalColor: 0x3b82f6,
      isHighlighted: false
    };

    scene.add(mesh);
    provinceMeshes.push(mesh); // 加入数组,用于射线检测

    // 4. 创建白色边框
    const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
    const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
    const border = new THREE.Line(borderGeo, borderMat);
    mesh.border = border; // 边框绑定到Mesh,方便后续修改颜色
    scene.add(border);
  });
});

// 创建Shape:将GeoJSON多边形转换为Three.js Shape
function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
  if (ring.length < 3) return null; // 少于3个点无法构成多边形
  const shape = new THREE.Shape();
  // 转换所有点为平面坐标,并应用居中+缩放+偏移
  const points = ring.map(([lon, lat]) => {
    const { x, y } = mercator(lon, lat);
    const shiftedX = x + offset.dx;
    const shiftedY = y + offset.dy;
    return {
      x: (shiftedX - centerX) * scale, // 居中后缩放
      y: (shiftedY - centerY) * scale
    };
  });
  // 绘制Shape:移动到第一个点,依次连线
  shape.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) {
    shape.lineTo(points[i].x, points[i].y);
  }
  return shape;
}
4.2 关键逻辑解析
  • Shape创建THREE.Shape是Three.js的2D形状对象,通过moveTo+lineTo绘制多边形;
  • MultiPolygon处理:包含多个多边形的省份(如海南=海南岛+南海诸岛),需为每个多边形创建独立Shape;
  • userData绑定:Three.js的Object3D对象可通过userData存储自定义数据,这里绑定省份名称/原始颜色,是交互的核心;
  • 边框创建:通过shape.getPoints()获取Shape的顶点,创建Line实现边框效果。

步骤5:Raycaster射线检测(鼠标点击高亮)

5.1 核心代码
let highlightedProvince = null; // 记录当前高亮的省份

// 鼠标点击事件处理
window.addEventListener('click', onDocumentMouseDown, false);

function onDocumentMouseDown(event) {
  // 1. 将鼠标屏幕坐标转换为NDC(归一化设备坐标,范围-1~1)
  const mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 创建Raycaster(射线检测)
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera); // 设置射线:从相机到鼠标位置

  // 3. 检测射线与所有省份Mesh的相交
  const intersects = raycaster.intersectObjects(provinceMeshes);

  if (intersects.length > 0) {
    // 点击到省份:切换高亮
    const clickedMesh = intersects[0].object;

    // 取消之前的高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
    }

    // 高亮当前点击的省份
    clickedMesh.material.color.set(0xffd700); // 金色填充
    if (clickedMesh.border) {
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
    }
    clickedMesh.userData.isHighlighted = true;
    highlightedProvince = clickedMesh;
    console.log('点击了:', clickedMesh.userData.provinceName);
  } else {
    // 点击空白处:取消所有高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
      highlightedProvince = null;
    }
  }
}
5.2 射线检测核心原理
  • NDC坐标转换:屏幕坐标(clientX/clientY)转换为归一化设备坐标(-1~1),是Raycaster的标准输入;
  • 射线创建raycaster.setFromCamera(mouse, camera) 生成从相机位置指向鼠标位置的射线;
  • 相交检测raycaster.intersectObjects(provinceMeshes) 返回射线与Mesh的相交结果,优先返回最近的Mesh;
  • 高亮逻辑:通过修改材质颜色实现高亮,利用userData存储原始颜色,保证切换回退。

步骤6:窗口适配与渲染循环

6.1 核心代码
// 窗口大小适配
window.addEventListener('resize', () => {
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800;
  // 更新正交相机边界
  camera.left = -frustumSize * aspect / 2;
  camera.right = frustumSize * aspect / 2;
  camera.top = frustumSize / 2;
  camera.bottom = -frustumSize / 2;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight); // 更新渲染器尺寸
});

// 渲染循环(持续渲染场景)
function animate() {
  requestAnimationFrame(animate); // 浏览器刷新率同步
  controls.update(); // 更新控制器(阻尼/平移/缩放)
  renderer.render(scene, camera); // 渲染场景
}
animate();

// 启动地图加载
loadAndDrawMap().catch(err => console.error('加载失败:', err));
6.2 关键注意点
  • 投影矩阵更新:正交相机参数修改后,必须调用camera.updateProjectionMatrix()使修改生效;
  • 渲染循环:Three.js需要持续调用renderer.render()才能显示画面,requestAnimationFrame保证帧率稳定。

核心方法/参数速查表

1. 核心类/函数参数

类/函数 关键参数 说明
OrthographicCamera left/right/top/bottom/near/far 正交相机边界,near/far控制可见距离
mercator(lon, lat) lon(经度)、lat(纬度) 返回{x,y}平面坐标,R=6378137(地球半径)
traverseCoordinates(coords, callback) coords(GeoJSON坐标)、callback(遍历回调) 递归处理嵌套坐标,回调参数为lon/lat
createShape(ring, centerX, centerY, scale, offset) ring(多边形坐标环)、centerX/Y(地图中心)、scale(缩放)、offset(偏移) 返回THREE.Shape,实现坐标居中+缩放
Raycaster.setFromCamera(mouse, camera) mouse(NDC坐标)、camera(相机) 创建从相机到鼠标的射线

2. 交互核心参数

参数 作用
mesh.userData 存储省份名称/原始颜色/高亮状态,交互时读取
intersects[0].object 射线检测命中的第一个Mesh(最近的省份)
controls.enableRotate 禁用旋转(false),保证2D地图体验

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 中国地图 - 2D矢量版</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background: #f8fafc;
    }
    /* 可选:添加加载提示 */
    .loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 18px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="loading">加载地图中...</div>
<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // 全局变量:存储所有省份Mesh(用于射线检测)
  const provinceMeshes = [];
  // 全局变量:记录当前高亮的省份
  let highlightedProvince = null;
  // 全局变量:地图边界(用于居中缩放)
  let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

  // ========== 1. 初始化基础环境 ==========
  // 场景:所有元素的容器
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

  // 正交相机:2D地图核心(无透视)
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800; // 相机视口高度(控制初始显示范围)
  const camera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,
    frustumSize * aspect / 2,
    frustumSize / 2,
    -frustumSize / 2,
    1, 1000
  );
  camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
  camera.lookAt(0, 0, 0);

  // 渲染器:抗锯齿+高清适配
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:自定义2D交互规则
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableRotate = false; // 禁用旋转(纯2D)
  controls.enablePan = true;     // 启用平移(拖拽)
  controls.enableZoom = true;    // 启用缩放(滚轮)
  controls.zoomSpeed = 1.5;      // 缩放速度适配

  // ========== 2. 墨卡托投影函数 ==========
  /**
   * 墨卡托投影:经纬度转平面坐标
   * @param {Number} lon - 经度(-180~180)
   * @param {Number} lat - 纬度(-90~90)
   * @returns {Object} {x, y} 平面坐标(米)
   */
  function mercator(lon, lat) {
    const R = 6378137; // WGS84地球半径
    const x = (lon * Math.PI / 180) * R;
    const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R;
    return { x, y };
  }

  // ========== 3. GeoJSON加载与绘制 ==========
  /**
   * 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套)
   * @param {Array} coords - GeoJSON坐标数组
   * @param {Function} callback - 遍历回调(lon, lat)
   */
  function traverseCoordinates(coords, callback) {
    if (typeof coords[0] === 'number') {
      callback(coords[0], coords[1]);
    } else {
      coords.forEach(c => traverseCoordinates(c, callback));
    }
  }

  /**
   * 创建Three.js Shape(多边形)
   * @param {Array} ring - 单个多边形坐标环
   * @param {Number} centerX - 地图中心X
   * @param {Number} centerY - 地图中心Y
   * @param {Number} scale - 缩放比例
   * @param {Object} offset - 位置偏移(dx, dy)
   * @returns {THREE.Shape|null} 形状对象
   */
  function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
    if (ring.length < 3) return null; // 最少3个点构成多边形
    const shape = new THREE.Shape();
    const points = ring.map(([lon, lat]) => {
      const { x, y } = mercator(lon, lat);
      const shiftedX = x + offset.dx;
      const shiftedY = y + offset.dy;
      // 居中+缩放:转换为相机视口坐标
      return {
        x: (shiftedX - centerX) * scale,
        y: (shiftedY - centerY) * scale
      };
    });
    // 绘制Shape
    shape.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      shape.lineTo(points[i].x, points[i].y);
    }
    return shape;
  }

  /**
   * 加载GeoJSON并绘制地图
   */
  async function loadAndDrawMap() {
    try {
      // 1. 加载GeoJSON数据
      const response = await fetch('./china.json');
      const geojson = await response.json();

      // 2. 港澳位置偏移(校正投影偏差)
      const SPECIAL_REGION_OFFSETS = {
        '香港特别行政区': { dx: 80000, dy: -60000 },
        '澳门特别行政区': { dx: 100000, dy: -80000 }
      };

      // 3. 遍历所有坐标,计算全局边界
      geojson.features.forEach(feature => {
        traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
          const { x, y } = mercator(lon, lat);
          bounds.minX = Math.min(bounds.minX, x);
          bounds.maxX = Math.max(bounds.maxX, x);
          bounds.minY = Math.min(bounds.minY, y);
          bounds.maxY = Math.max(bounds.maxY, y);
        });
      });

      // 4. 计算地图中心和缩放比例
      const centerX = (bounds.minX + bounds.maxX) / 2;
      const centerY = (bounds.minY + bounds.maxY) / 2;
      const width = bounds.maxX - bounds.minX;
      const height = bounds.maxY - bounds.minY;
      const scale = 700 / Math.max(width, height); // 适配视口

      // 5. 遍历每个省份,创建Shape和Mesh
      geojson.features.forEach(feature => {
        const shapes = [];
        const provinceName = feature.properties.name;
        const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

        // 处理Polygon(单多边形)
        if (feature.geometry.type === 'Polygon') {
          const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
          if (shape) shapes.push(shape);
        }
        // 处理MultiPolygon(多多边形,如海南)
        else if (feature.geometry.type === 'MultiPolygon') {
          feature.geometry.coordinates.forEach(polygon => {
            const shape = createShape(polygon[0], centerX, centerY, scale, offset);
            if (shape) shapes.push(shape);
          });
        }

        // 为每个Shape创建填充和边框
        shapes.forEach(shape => {
          // 填充Mesh
          const geometry = new THREE.ShapeGeometry(shape);
          const material = new THREE.MeshBasicMaterial({
            color: 0x3b82f6,
            side: THREE.DoubleSide
          });
          const mesh = new THREE.Mesh(geometry, material);

          // 绑定自定义数据(交互用)
          mesh.userData = {
            provinceName: provinceName,
            originalColor: 0x3b82f6,
            isHighlighted: false
          };

          scene.add(mesh);
          provinceMeshes.push(mesh);

          // 白色边框
          const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
          const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
          const border = new THREE.Line(borderGeo, borderMat);
          mesh.border = border;
          scene.add(border);
        });
      });

      // 隐藏加载提示
      document.querySelector('.loading').style.display = 'none';
    } catch (err) {
      console.error('地图加载失败:', err);
      document.querySelector('.loading').textContent = '加载失败,请检查GeoJSON文件';
    }
  }

  // ========== 4. 鼠标点击交互(Raycaster) ==========
  /**
   * 鼠标点击事件处理:省份高亮
   * @param {MouseEvent} event - 鼠标事件
   */
  function onDocumentMouseDown(event) {
    // 1. 转换鼠标坐标为NDC(-1~1)
    const mouse = new THREE.Vector2();
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    // 2. 创建射线检测
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    // 3. 检测与省份Mesh的相交
    const intersects = raycaster.intersectObjects(provinceMeshes);

    if (intersects.length > 0) {
      // 点击到省份:切换高亮
      const clickedMesh = intersects[0].object;

      // 取消之前的高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
      }

      // 高亮当前省份
      clickedMesh.material.color.set(0xffd700); // 金色填充
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
      clickedMesh.userData.isHighlighted = true;
      highlightedProvince = clickedMesh;
      console.log('点击省份:', clickedMesh.userData.provinceName);
    } else {
      // 点击空白处:取消所有高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
        highlightedProvince = null;
      }
    }
  }
  window.addEventListener('click', onDocumentMouseDown, false);

  // ========== 5. 窗口适配与渲染循环 ==========
  // 窗口大小变化适配
  window.addEventListener('resize', () => {
    const aspect = window.innerWidth / window.innerHeight;
    camera.left = -frustumSize * aspect / 2;
    camera.right = frustumSize * aspect / 2;
    camera.top = frustumSize / 2;
    camera.bottom = -frustumSize / 2;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // 渲染循环
  function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 更新控制器
    renderer.render(scene, camera); // 渲染场景
  }
  animate();

  // 启动地图加载
  loadAndDrawMap();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 2D地图核心:正交相机(OrthographicCamera)是实现无透视2D效果的关键,区别于透视相机;
  2. 坐标转换:墨卡托投影是地理数据可视化的基础,需掌握经纬度→平面坐标的转换逻辑;
  3. GeoJSON处理:递归遍历嵌套坐标、计算全局边界是地图居中缩放的核心;
  4. 交互实现Raycaster射线检测是Three.js实现鼠标点击交互的标准方式,userData是存储自定义数据的最佳实践;
  5. 性能优化:复用几何体/材质、减少不必要的顶点数,可提升大地图的渲染性能。

扩展建议

  1. 添加省份标签:基于省份中心坐标创建CSS2DLabel,显示省份名称;
  2. hover高亮:监听mousemove事件,实现鼠标悬浮高亮;
  3. 数据可视化:根据省份数据(如GDP、人口)动态修改填充颜色;
  4. 层级优化:为南海诸岛等小区域单独缩放,提升显示效果;
  5. 性能优化:使用BufferGeometry替代ShapeGeometry,减少内存占用;
  6. 地图交互增强:添加缩放限制(最小/最大缩放)、地图复位按钮。
❌