普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月27日首页

学习Three.js--轨道+火车自动行进

2026年1月27日 14:17

学习Three.js--轨道+火车自动行进

前置核心说明

开发目标

基于Three.js实现带真实纹理的火车轨道 + 沿轨道自动循环行进的简易火车,核心能力包括:

  1. 轨道模块化构建(钢轨分段创建+枕木间隔排布,支持参数化配置);
  2. 简易火车模型搭建(车身+4个车轮,车轮精准贴合钢轨);
  3. 火车沿Z轴自动行进,到达轨道末端后循环重置;
  4. 自然光照系统+纹理贴图(金属钢轨、木质枕木);
  5. 基础交互(拖拽旋转视角、滚轮缩放)。

在这里插入图片描述

核心技术栈(关键知识点)

技术点 作用
THREE.Group 分组管理轨道/火车的子组件(钢轨/枕木/车身/车轮),方便整体控制
BoxGeometry/CylinderGeometry 分别创建轨道(立方体)、车轮(圆柱体)的基础几何形状
MeshStandardMaterial PBR物理材质,支持纹理、金属度、粗糙度,模拟真实钢轨/车轮质感
纹理重复配置(wrapS/wrapT/repeat 避免纹理拉伸,适配轨道分段尺寸,提升视觉真实度
动画循环(requestAnimationFrame 实时更新火车位置,实现自动行进+循环逻辑
OrbitControls 基础视角交互(旋转/缩放/阻尼)
几何体旋转/定位 车轮旋转适配钢轨方向、轨道/火车精准贴地/居中

核心开发流程

A[初始化场景/相机/渲染器/控制器] --> B[配置轨道+火车参数(可复用)]
B --> C[加载纹理并配置重复规则]
C --> D[创建轨道(钢轨分段+枕木间隔)]
D --> E[创建火车模型(车身+车轮精准定位)]
E --> F[创建地面+添加所有元素到场景]
F --> G[窗口适配+动画循环(火车行进+循环)]

分步开发详解

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

1.1 核心代码
// 1. 场景初始化(所有元素的容器)
const scene = new THREE.Scene();

// 2. 透视相机(模拟人眼视角,适合3D场景)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV)
  window.innerWidth / window.innerHeight, // 宽高比
  1, // 近裁切面
  3000 // 远裁切面
);
camera.position.set(5, 3, 10); // 调整视角,俯视轨道

// 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.enableDamping = true; // 阻尼(惯性),交互更顺滑
controls.dampingFactor = 0.05; // 阻尼系数

// 5. 光照系统(模拟自然光照)
// 环境光:补充暗部,避免纯黑
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
// 方向光:模拟太阳光,提升立体感
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
dirLight.position.set(200, 400, 300); // 高位斜向光照
dirLight.castShadow = false; // 简化场景,禁用阴影
scene.add(dirLight);
1.2 关键说明
  • 相机视角position.set(5, 3, 10) 采用俯视视角,既能看到轨道延伸,又能清晰观察火车行进;
  • 光照组合AmbientLight + DirectionalLight 是Three.js基础光照方案,兼顾暗部细节和主体立体感;
  • 控制器阻尼:启用阻尼后,视角拖拽/缩放有惯性,交互体验更自然。

步骤2:核心参数配置

2.1 核心代码
// 轨道参数配置(集中管理,方便修改)
const TRACK_PARAMS = {
  railWidth: 0.2,       // 钢轨宽度
  railHeight: 0.1,      // 钢轨高度
  railSpacing: 1.0,     // 两条钢轨中心距
  sleeperWidth: 0.1,    // 枕木宽度(沿Z轴)
  sleeperHeight: 0.05,  // 枕木高度
  sleeperLength: 1.5,   // 枕木长度(超过钢轨间距,覆盖两侧)
  sleeperGap: 1.0,      // 枕木间距
  trackLength: 50,      // 轨道总长度
  segmentLength: 1.0    // 钢轨分段长度(避免单个几何体过长)
};

// 火车参数配置(集中管理)
const TRAIN_PARAMS = {
  speed: 0.01,          // 火车行进速度(每帧移动距离)
  wheelRadius: 0.1,     // 车轮半径(刚好贴合钢轨高度)
  wheelWidth: 0.1,      // 车轮宽度
  bodyLength: 2.0,      // 车身长度
  bodyWidth: 0.8,       // 车身宽度(小于钢轨间距,居中)
  bodyHeight: 0.5       // 车身高度
};
2.2 关键设计思路
  • 参数集中化:将轨道/火车的尺寸、间距、速度等参数集中定义,后续修改无需遍历代码,符合「高内聚低耦合」原则;
  • 尺寸匹配wheelRadius = railHeight 保证车轮刚好贴合钢轨上表面,bodyWidth < railSpacing 保证车身居中在两条钢轨之间。

步骤3:纹理加载与配置(避免拉伸,提升真实度)

3.1 核心代码
const texLoader = new THREE.TextureLoader();

// 钢轨纹理(生锈金属,适配轨道质感)
const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
  renderer.render(scene, camera); // 纹理加载完成后重绘
});
// 纹理关键配置:避免拉伸
railTexture.colorSpace = THREE.SRGBColorSpace; // 正确的颜色空间
railTexture.wrapS = THREE.RepeatWrapping;      // 水平(X轴)重复
railTexture.wrapT = THREE.RepeatWrapping;      // 垂直(Y轴)重复
railTexture.repeat.set(2, 10);                 // 重复次数(适配钢轨尺寸)
railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度

// 枕木纹理(木质纹理)
const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
sleeperTexture.colorSpace = THREE.SRGBColorSpace;
sleeperTexture.wrapS = THREE.RepeatWrapping;
sleeperTexture.wrapT = THREE.RepeatWrapping;
sleeperTexture.repeat.set(1, 1); // 单个枕木单次纹理
3.2 核心技术点
  • 纹理重复(RepeatWrapping):默认纹理是ClampToEdgeWrapping(拉伸),设置为RepeatWrapping后,纹理会按repeat值重复排列,避免长钢轨纹理拉伸模糊;
  • 各向异性(anisotropy):提升纹理在斜向视角下的清晰度,尤其适合轨道这种长条状物体;
  • 颜色空间SRGBColorSpace 是纹理的标准颜色空间,保证纹理颜色显示正确。

步骤4:轨道创建(核心逻辑:分段钢轨+间隔枕木)

4.1 核心代码
function createTrainTrack() {
  const trackGroup = new THREE.Group(); // 轨道组,统一管理钢轨/枕木

  // 1. 创建钢轨(两条平行,分段构建)
  const railMaterial = new THREE.MeshStandardMaterial({ 
    map: railTexture,
    metalness: 0.8, // 高金属度,模拟钢轨质感
    roughness: 0.2  // 低粗糙度,有反光
  });

  // 左侧钢轨(分段创建)
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
    const railGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.railWidth,
      TRACK_PARAMS.railHeight,
      TRACK_PARAMS.segmentLength
    );
    const rail = new THREE.Mesh(railGeometry, railMaterial);
    // 定位:左侧钢轨(X负方向),底部贴地
    rail.position.set(
      -TRACK_PARAMS.railSpacing / 2, // 左侧钢轨X坐标
      TRACK_PARAMS.railHeight / 2,   // Y轴:底部贴地(高度/2)
      z + TRACK_PARAMS.segmentLength / 2 // Z轴:分段居中
    );
    trackGroup.add(rail);
  }

  // 右侧钢轨(分段创建,逻辑同左侧)
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
    const railGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.railWidth,
      TRACK_PARAMS.railHeight,
      TRACK_PARAMS.segmentLength
    );
    const rail = new THREE.Mesh(railGeometry, railMaterial);
    rail.position.set(
      TRACK_PARAMS.railSpacing / 2, // 右侧钢轨X坐标
      TRACK_PARAMS.railHeight / 2,
      z + TRACK_PARAMS.segmentLength / 2
    );
    trackGroup.add(rail);
  }

  // 2. 创建枕木(横向间隔排布)
  const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
    const sleeperGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.sleeperLength, // 长度(X轴)覆盖两条钢轨
      TRACK_PARAMS.sleeperHeight,
      TRACK_PARAMS.sleeperWidth
    );
    const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
    // 定位:居中,底部贴地,沿Z轴间隔排布
    sleeper.position.set(
      0, // X轴居中
      TRACK_PARAMS.sleeperHeight / 2, // Y轴贴地
      z // Z轴间隔排布
    );
    trackGroup.add(sleeper);
  }

  return trackGroup;
}
4.2 核心技术点解析
  • 钢轨分段创建:若直接创建长度为50的钢轨几何体,顶点数过多且纹理拉伸严重;分段(segmentLength=1.0)后,每个钢轨段尺寸小、纹理重复合理,性能更优;
  • Group分组管理:将所有钢轨、枕木添加到trackGroup,后续可通过操作trackGroup整体移动/旋转轨道,便于扩展;
  • 定位逻辑:所有几何体的Y轴位置为「高度/2」,保证底部贴地(Y=0),避免悬浮/埋地。

步骤5:火车模型创建(车身+车轮,精准贴合轨道)

5.1 核心代码
function createTrain() {
  const trainGroup = new THREE.Group(); // 火车组,统一管理车身/车轮

  // 1. 创建车身(立方体)
  const bodyMaterial = new THREE.MeshStandardMaterial({ 
    color: 0xff4444, // 红色车身
    metalness: 0.1,
    roughness: 0.8
  });
  const bodyGeometry = new THREE.BoxGeometry(
    TRAIN_PARAMS.bodyWidth,
    TRAIN_PARAMS.bodyHeight,
    TRAIN_PARAMS.bodyLength
  );
  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
  // 车身定位:居中在钢轨之间,Y轴=车轮半径+车身高度/2
  body.position.set(
    0, // X轴居中
    TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2, // Y轴高度
    0 // Z轴初始位置
  );
  trainGroup.add(body);

  // 2. 创建车轮(圆柱体,共4个:前左/前右/后左/后右)
  const wheelMaterial = new THREE.MeshStandardMaterial({ 
    color: 0x333333, // 黑色车轮
    metalness: 0.8,
    roughness: 0.2
  });
  const wheelGeometry = new THREE.CylinderGeometry(
    TRAIN_PARAMS.wheelRadius, // 顶部半径
    TRAIN_PARAMS.wheelRadius, // 底部半径
    TRAIN_PARAMS.wheelWidth,  // 圆柱体高度(车轮宽度)
    16 // 分段数,越多越圆
  );

  // 前左车轮
  const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  frontLeftWheel.rotation.z = Math.PI / 2; // 旋转90°,横向贴合钢轨
  frontLeftWheel.position.set(
    -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2, // 对齐左侧钢轨
    TRAIN_PARAMS.wheelRadius, // Y轴=车轮半径(贴钢轨)
    TRAIN_PARAMS.bodyLength / 2 - 0.2 // 车身前部位置
  );
  trainGroup.add(frontLeftWheel);

  // 前右车轮(逻辑同前左,X轴反向)
  const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  frontRightWheel.rotation.z = Math.PI / 2;
  frontRightWheel.position.set(
    TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    TRAIN_PARAMS.bodyLength / 2 - 0.2
  );
  trainGroup.add(frontRightWheel);

  // 后左车轮(Z轴反向)
  const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  backLeftWheel.rotation.z = Math.PI / 2;
  backLeftWheel.position.set(
    -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    -TRAIN_PARAMS.bodyLength / 2 + 0.2
  );
  trainGroup.add(backLeftWheel);

  // 后右车轮(X/Z轴反向)
  const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  backRightWheel.rotation.z = Math.PI / 2;
  backRightWheel.position.set(
    TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    -TRAIN_PARAMS.bodyLength / 2 + 0.2
  );
  trainGroup.add(backRightWheel);

  // 初始化火车位置(轨道起点)
  trainGroup.position.z = 0;

  return trainGroup;
}
5.2 核心技术点解析
  • 车轮旋转CylinderGeometry 默认是垂直方向(Y轴),通过rotation.z = Math.PI / 2 旋转为水平方向(X轴),贴合钢轨延伸方向;
  • 车轮定位-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2 精准对齐钢轨内侧,避免车轮偏移;
  • 车身高度wheelRadius + bodyHeight / 2 保证车身底部与车轮顶部贴合,模拟真实火车结构。

步骤6:地面创建+元素组装

6.1 核心代码
// 创建地面(大平面,衬托轨道)
const groundGeometry = new THREE.PlaneGeometry(100, 100); // 足够大的地面
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // 旋转90°,从垂直变为水平(贴地)
ground.position.y = 0;
scene.add(ground);

// 创建轨道和火车,添加到场景
const track = createTrainTrack();
const train = createTrain();
scene.add(track);
scene.add(train);
6.2 关键说明
  • 地面旋转PlaneGeometry 默认是XY平面(垂直),rotation.x = -Math.PI / 2 旋转为XZ平面(水平),作为场景地面;
  • 地面尺寸100x100 远大于轨道长度(50),保证轨道完全覆盖在地面上。

步骤7:窗口适配+动画循环(火车行进核心)

7.1 核心代码
// 窗口适配(响应式)
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// 渲染循环(火车行进+循环逻辑)
function animate() {
  requestAnimationFrame(animate);
  
  // 核心:火车沿Z轴行进
  train.position.z += TRAIN_PARAMS.speed;
  
  // 循环逻辑:到达轨道末端后重置到起点
  if (train.position.z > TRACK_PARAMS.trackLength) {
    train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
  }

  controls.update(); // 更新控制器(阻尼)
  renderer.render(scene, camera); // 渲染场景
}
animate();
7.2 核心技术点解析
  • 火车行进逻辑:通过每帧修改train.position.z 实现沿轨道(Z轴)行进,speed 控制行进速度;
  • 循环重置train.position.z > TRACK_PARAMS.trackLength 时,重置为-TRAIN_PARAMS.bodyLength(车身长度负值),保证火车从轨道起点外进入,循环更自然;
  • 窗口适配:相机宽高比修改后,必须调用updateProjectionMatrix() 使修改生效。

核心技术点深度解析

1. 钢轨分段的必要性

  • 性能层面:单个长度为50的钢轨几何体,顶点数=(宽度分段×高度分段×长度分段),远多于50个长度为1的分段几何体总和;
  • 纹理层面:分段后每个钢轨段的纹理可通过repeat 精准控制,避免长钢轨纹理拉伸模糊;
  • 扩展层面:分段轨道更容易实现弯曲轨道(后续可通过修改每个分段的旋转/位置实现曲线)。

2. 车轮与钢轨的精准贴合

参数匹配 效果
wheelRadius = railHeight 车轮半径等于钢轨高度,车轮底部刚好落在钢轨上表面
车轮X坐标 = ±railSpacing/2 ± railWidth/2 车轮内侧对齐钢轨外侧,避免偏移
车轮Y坐标 = wheelRadius 车轮中心Y轴高度=半径,底部贴钢轨上表面

3. 动画循环的核心逻辑

A[每帧执行animate] --> B[train.position.z += speed]
B --> C{z > trackLength?}
C -- 是 --> D[z = -bodyLength(重置)]
C -- 否 --> E[继续行进]
D --> F[渲染场景]
E --> F[渲染场景]

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 轨道+火车自动行进</title>
  <style>
    body { margin: 0; overflow: hidden; }
    #info {
      position: absolute;
      top: 10px;
      width: 100%;
      text-align: center;
      color: white;
      font-family: Arial, sans-serif;
      text-shadow: 0 0 5px rgba(0,0,0,0.8);
      pointer-events: none;
      z-index: 10;
    }
  </style>
</head>
<body>
  <div id="info">火车沿轨道自动行进 | 拖拽旋转视角 | 滚轮缩放</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';

  // ========== 1. 基础环境初始化 ==========
  // 场景:所有元素容器
  const scene = new THREE.Scene();

  // 透视相机:俯视视角观察轨道+火车
  const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 3000);
  camera.position.set(5, 3, 10); // 俯视视角参数

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

  // 轨道控制器:启用阻尼,交互更顺滑
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.05;

  // 光照系统:环境光+方向光,兼顾暗部和立体感
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
  scene.add(ambientLight);
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
  dirLight.position.set(200, 400, 300);
  dirLight.castShadow = false; // 简化场景,禁用阴影
  scene.add(dirLight);

  // ========== 2. 核心参数配置(集中管理,方便修改) ==========
  // 轨道参数
  const TRACK_PARAMS = {
    railWidth: 0.2,       // 钢轨宽度
    railHeight: 0.1,      // 钢轨高度
    railSpacing: 1.0,     // 两条钢轨中心距
    sleeperWidth: 0.1,    // 枕木宽度(沿Z轴)
    sleeperHeight: 0.05,  // 枕木高度
    sleeperLength: 1.5,   // 枕木长度(覆盖钢轨两侧)
    sleeperGap: 1.0,      // 枕木间距
    trackLength: 50,      // 轨道总长度
    segmentLength: 1.0    // 钢轨分段长度(避免单个几何体过长)
  };

  // 火车参数
  const TRAIN_PARAMS = {
    speed: 0.01,          // 火车行进速度(每帧移动距离)
    wheelRadius: 0.1,     // 车轮半径(贴合钢轨高度)
    wheelWidth: 0.1,      // 车轮宽度
    bodyLength: 2.0,      // 车身长度
    bodyWidth: 0.8,       // 车身宽度(小于钢轨间距,居中)
    bodyHeight: 0.5       // 车身高度
  };

  // ========== 3. 纹理加载与配置(避免拉伸,提升真实度) ==========
  const texLoader = new THREE.TextureLoader();

  // 钢轨纹理:生锈金属,配置重复规则
  const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
    renderer.render(scene, camera); // 纹理加载完成后重绘
  });
  railTexture.colorSpace = THREE.SRGBColorSpace;
  railTexture.wrapS = THREE.RepeatWrapping;
  railTexture.wrapT = THREE.RepeatWrapping;
  railTexture.repeat.set(2, 10); // 纹理重复次数
  railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度

  // 枕木纹理:木质纹理
  const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
  sleeperTexture.colorSpace = THREE.SRGBColorSpace;
  sleeperTexture.wrapS = THREE.RepeatWrapping;
  sleeperTexture.wrapT = THREE.RepeatWrapping;
  sleeperTexture.repeat.set(1, 1);

  // ========== 4. 轨道创建(分段钢轨+间隔枕木) ==========
  function createTrainTrack() {
    const trackGroup = new THREE.Group(); // 轨道组,统一管理

    // 钢轨材质:高金属度,模拟钢轨质感
    const railMaterial = new THREE.MeshStandardMaterial({ 
      map: railTexture,
      metalness: 0.8,
      roughness: 0.2
    });

    // 左侧钢轨:分段创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
      const railGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.railWidth,
        TRACK_PARAMS.railHeight,
        TRACK_PARAMS.segmentLength
      );
      const rail = new THREE.Mesh(railGeometry, railMaterial);
      // 定位:左侧+贴地+分段居中
      rail.position.set(
        -TRACK_PARAMS.railSpacing / 2,
        TRACK_PARAMS.railHeight / 2,
        z + TRACK_PARAMS.segmentLength / 2
      );
      trackGroup.add(rail);
    }

    // 右侧钢轨:分段创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
      const railGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.railWidth,
        TRACK_PARAMS.railHeight,
        TRACK_PARAMS.segmentLength
      );
      const rail = new THREE.Mesh(railGeometry, railMaterial);
      // 定位:右侧+贴地+分段居中
      rail.position.set(
        TRACK_PARAMS.railSpacing / 2,
        TRACK_PARAMS.railHeight / 2,
        z + TRACK_PARAMS.segmentLength / 2
      );
      trackGroup.add(rail);
    }

    // 枕木材质:木质纹理
    const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
    // 枕木:间隔创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
      const sleeperGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.sleeperLength,
        TRACK_PARAMS.sleeperHeight,
        TRACK_PARAMS.sleeperWidth
      );
      const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
      // 定位:居中+贴地+间隔排布
      sleeper.position.set(
        0,
        TRACK_PARAMS.sleeperHeight / 2,
        z
      );
      trackGroup.add(sleeper);
    }

    return trackGroup;
  }

  // ========== 5. 火车创建(车身+车轮,精准贴合轨道) ==========
  function createTrain() {
    const trainGroup = new THREE.Group(); // 火车组,统一管理

    // 车身材质:红色,低金属度
    const bodyMaterial = new THREE.MeshStandardMaterial({ 
      color: 0xff4444,
      metalness: 0.1,
      roughness: 0.8
    });
    // 车身几何体
    const bodyGeometry = new THREE.BoxGeometry(
      TRAIN_PARAMS.bodyWidth,
      TRAIN_PARAMS.bodyHeight,
      TRAIN_PARAMS.bodyLength
    );
    const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    // 车身定位:居中+贴合车轮顶部
    body.position.set(
      0,
      TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2,
      0
    );
    trainGroup.add(body);

    // 车轮材质:黑色,高金属度
    const wheelMaterial = new THREE.MeshStandardMaterial({ 
      color: 0x333333,
      metalness: 0.8,
      roughness: 0.2
    });
    // 车轮几何体:圆柱体
    const wheelGeometry = new THREE.CylinderGeometry(
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.wheelWidth,
      16
    );

    // 前左车轮
    const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    frontLeftWheel.rotation.z = Math.PI / 2; // 旋转为横向
    frontLeftWheel.position.set(
      -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.bodyLength / 2 - 0.2
    );
    trainGroup.add(frontLeftWheel);

    // 前右车轮
    const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    frontRightWheel.rotation.z = Math.PI / 2;
    frontRightWheel.position.set(
      TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.bodyLength / 2 - 0.2
    );
    trainGroup.add(frontRightWheel);

    // 后左车轮
    const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    backLeftWheel.rotation.z = Math.PI / 2;
    backLeftWheel.position.set(
      -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      -TRAIN_PARAMS.bodyLength / 2 + 0.2
    );
    trainGroup.add(backLeftWheel);

    // 后右车轮
    const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    backRightWheel.rotation.z = Math.PI / 2;
    backRightWheel.position.set(
      TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      -TRAIN_PARAMS.bodyLength / 2 + 0.2
    );
    trainGroup.add(backRightWheel);

    // 初始化火车位置(轨道起点)
    trainGroup.position.z = 0;

    return trainGroup;
  }

  // ========== 6. 地面创建+元素组装 ==========
  // 地面:大平面,衬托轨道
  const groundGeometry = new THREE.PlaneGeometry(100, 100);
  const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
  const ground = new THREE.Mesh(groundGeometry, groundMaterial);
  ground.rotation.x = -Math.PI / 2; // 旋转为水平地面
  ground.position.y = 0;
  scene.add(ground);

  // 创建轨道和火车,添加到场景
  const track = createTrainTrack();
  const train = createTrain();
  scene.add(track);
  scene.add(train);

  // ========== 7. 窗口适配 ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // ========== 8. 动画循环(火车行进+循环) ==========
  function animate() {
    requestAnimationFrame(animate);
    
    // 火车沿Z轴行进
    train.position.z += TRAIN_PARAMS.speed;
    
    // 循环逻辑:到达末端后重置到起点
    if (train.position.z > TRACK_PARAMS.trackLength) {
      train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
    }

    controls.update(); // 更新控制器阻尼
    renderer.render(scene, camera); // 渲染场景
  }
  animate();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 模块化设计:通过Group分组管理轨道/火车的子组件,参数集中化配置,便于维护和扩展;
  2. 几何体精准定位:所有元素的Y轴位置为「高度/2」保证贴地,车轮通过旋转+坐标计算精准贴合钢轨;
  3. 纹理优化:使用RepeatWrapping避免纹理拉伸,anisotropy提升斜向清晰度,PBR材质(MeshStandardMaterial)模拟真实质感;
  4. 动画核心:通过requestAnimationFrame每帧更新火车position.z实现行进,结合边界判断实现循环逻辑。

扩展建议

  1. 车轮旋转动画:在animate中添加wheel.rotation.x += 0.1,让车轮随行进旋转,更真实;
  2. 弯曲轨道:修改钢轨分段的rotation.yposition,实现曲线轨道(需计算圆弧坐标);
  3. 火车细节增强:添加车窗、烟囱、车头等细节,或加载3D模型替代简易几何体;
  4. 轨道材质优化:启用阴影(castShadow/receiveShadow),添加钢轨反光、枕木磨损效果;
  5. 交互增强:添加速度控制(滑块调整TRAIN_PARAMS.speed)、轨道开关、火车启停按钮;
  6. 性能优化:使用InstancedMesh替代重复创建的钢轨/枕木,减少DrawCall。
昨天以前首页

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

2026年1月26日 14:59

学习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. 地图交互增强:添加缩放限制(最小/最大缩放)、地图复位按钮。
❌
❌