阅读视图

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

Three.js 收藏吃灰系列:一套代码,把你的 GLB 模型变成漫威级全息投影

Three.js 进阶实战:打造“锐利边缘”的赛博朋克特效

20260127_113057.gif

在 Three.js 的 3D 开发中,我们经常需要制作“扫描”、“全息”或者“能量护盾”的效果。最常用的手段是使用 菲涅尔(Fresnel) 效应。

但是,默认的菲涅尔效果往往呈现出一种“软绵绵”的雾化感,中心区域总是有一层洗不掉的朦胧。如果你想要一种硬核、锐利、仅保留物体轮廓的赛博朋克风格(Hard-surface Edge),就需要对 Shader 进行一些特殊的数学运算截断。

今天,我们就来拆解一套**“锐利边缘能量体”**的代码实现。

前排提示:本文涉及的特效逻辑,源自我正在开发的 3D 编辑器项目 Meteor3DEditor。如果你对 Web3D 引擎开发感兴趣,欢迎 Star 和体验!


核心难点:如何让菲涅尔变得“锐利”?

普通的菲涅尔公式通常是 1.0 - dot(viewDir, normal)。这会产生一个从边缘向中心平滑过渡的渐变。

为了得到锐利的边缘,我们需要在 Fragment Shader 中做三件事:

  1. 极高次幂压缩:把边缘压扁。
  2. 阈值截断:把中心的“雾气”彻底切除。
  3. 亮度重映射:把剩下的边缘提亮。

1. Shader 代码解析

这是核心的 ShaderMaterial 实现。请注意看 fragmentShader 中的注释部分:

JavaScript

const sharpFresnelShader = {
    uniforms: {
        color: { value: new THREE.Color(0x00ffff) }, // 赛博青色
        opacityMultiplier: { value: 1.0 }
    },
    vertexShader: `
        varying vec3 vWorldNormal;
        varying vec3 vViewDirection;
        void main() {
            // 获取世界坐标系下的法线
            vWorldNormal = normalize(mat3(modelMatrix) * normal);
            vec4 worldPosition = modelMatrix * vec4(position, 1.0);
            // 计算视线方向
            vViewDirection = normalize(cameraPosition - worldPosition.xyz);
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        uniform vec3 color;
        uniform float opacityMultiplier;
        varying vec3 vWorldNormal;
        varying vec3 vViewDirection;
        
        void main() {
            vec3 normal = normalize(vWorldNormal);
            vec3 viewDir = normalize(vViewDirection);

            // --- 基础菲涅尔 ---
            float dotProduct = dot(viewDir, normal);
            // 1.0 - abs(dot) 确保双面渲染时背面也能正确发光
            float fresnel = 1.0 - abs(dotProduct); 
            fresnel = clamp(fresnel, 0.0, 1.0);

            // --- 关键魔法区域 ---
            
            // 技巧1:使用 pow(6.0) 甚至更高。
            // 默认的菲涅尔可能是 pow(2.0),看起来很软。
            // 6.0 的指数会让函数曲线极速陡峭,只有最边缘的地方保留数值,其余迅速归零。
            fresnel = pow(fresnel, 6.0); 

            // 技巧2:阈值截断 (Threshold)
            // 即使是 pow(6.0),中心正对相机的地方可能还有 0.01 的微弱值。
            // 这种微弱值在叠加模式下会导致模型看起来脏脏的。
            // 我们减去 0.1,强制把这一部分变成纯黑(透明)。
            fresnel = max(fresnel - 0.1, 0.0); 
            
            // 技巧3:亮度补偿
            // 因为刚才减去了 0.1,最大值只剩 0.9 了,而且整体变暗。
            // 乘一个系数把边缘亮度拉爆。
            fresnel *= 2.5; 

            // ------------------

            float finalAlpha = fresnel * opacityMultiplier;
            
            // 颜色也乘以 alpha,配合 AdditiveBlending 食用效果更佳
            gl_FragColor = vec4(color * finalAlpha, finalAlpha);
        }
    `
};

2. 材质配置的关键点

Shader 写好了,Material 的配置同样重要。为了达到“全息投影”那种通透感,我们需要关闭深度写入,并开启叠加混合。

JavaScript

this.energyMaterial = new THREE.ShaderMaterial({
    uniforms: THREE.UniformsUtils.clone(sharpFresnelShader.uniforms),
    vertexShader: sharpFresnelShader.vertexShader,
    fragmentShader: sharpFresnelShader.fragmentShader,
    transparent: true,
    side: THREE.DoubleSide,      // 双面渲染:让模型内部的结构线条也能透出来,增加复杂度
    depthWrite: false,           // 关闭深度写入:这是实现“透明重叠”不穿帮的关键
    blending: THREE.AdditiveBlending // 叠加混合:重叠的部分会变亮(变白),模拟光的物理叠加
});

动态系统:上升的能量残影

光有一个静止的边缘是不够帅的。我们需要让它动起来。这里我们实现了一个简单的粒子系统思想:不断克隆模型,向上发射,然后消失。

系统逻辑 (RisingEnergySystem)

这个类负责管理所有的“残影(Ghost)”。

  1. Spawn (生成) :每隔几百毫秒,克隆一次目标模型。

  2. Update (更新)

    • 将所有残影沿 Y 轴向上移动。
    • 随着时间流逝,减小 opacityMultiplier (透明度)。
    • 当生命周期结束,从场景移除并销毁内存。

JavaScript

spawn(originPosition, originRotation, scale) {
    if (this.sourceGeoGroups.length === 0) return;

    const ghostGroup = new THREE.Group();
    
    // 技巧:每次生成时微调颜色,让能量场色彩更丰富
    const instanceMat = this.energyMaterial.clone();
    instanceMat.uniforms.color.value.setHSL(0.5 + Math.random() * 0.1, 1.0, 0.5);

    this.sourceGeoGroups.forEach(template => {
        const mesh = template.clone();
        mesh.material = instanceMat;
        ghostGroup.add(mesh);
    });

    // ...设置位置、旋转、缩放...
    
    // 稍微向上偏移一点出生点,避免和实体模型完全重叠导致 Z-Fighting 闪烁
    ghostGroup.position.y += 0.05;

    this.scene.add(ghostGroup);
    this.ghosts.push({
        mesh: ghostGroup,
        materialBtn: instanceMat, 
        life: 1.0,
        speed: 1.5
    });
}

为什么会有“扫描”的感觉?

因为我们使用了 AdditiveBlending(叠加混合)。

当新的残影生成时,它离实体模型很近。实体模型本身可能有一个基础亮度,加上残影的亮度,重叠部分会爆亮。随着残影上升,它与本体分离,光线被“拉”开了。这种高亮 -> 分离 -> 消失的过程,视觉上就形成了强烈的能量扫描感。

![效果图占位:建议放一张局部特写,展示边缘重叠变亮的效果]


性能优化小贴士

在代码中,有一个细节处理:

JavaScript

// update 循环中
ghost.materialBtn.dispose(); // 清理内存

由于我们不断地 new ShaderMaterial(或者 clone),这会创建大量的 WebGL Program。如果不手动 dispose,Three.js 可能会保留这些材质引用,导致内存泄漏。虽然在 Demo 级别无所谓,但在生产环境中,建议使用材质池(Material Pool)或者InstancedMesh来复用资源,避免频繁创建销毁。

完整体验与源码

这个特效非常适合用于展示高科技感的 3D 模型,比如无人机、芯片、或者飞船内部结构。

如果你想看完整的运行效果,或者想直接 Copy 源码运行,我已经整理在文章开头了。而如果你想在一个可视化的环境中直接调整这些 Shader 参数,而不用每次都改代码,欢迎尝试我开发的 Web3D 编辑器:

🌌 Meteor3DEditor

Meteor3D 是一个基于 Web 的轻量级 3D 编辑器,旨在简化 Three.js 的场景搭建和特效调试。

如果你觉得这个特效对你有帮助,欢迎来 GitHub 点个 Star ⭐️,也欢迎提 Issue 交流更多的 Shader 技巧!


总结

  1. Pow(6.0) 制造锐利边缘。
  2. Minus Offset 去除中心雾化。
  3. AdditiveBlending 制造光的叠加感。
  4. DoubleSide 增加模型内部细节的透视感。

希望这篇文章能给你的 WebGL 开发带来一点灵感!

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

学习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 色彩空间的正确使用方式

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