简介:本资源是一套基于Three.js实现Web端3D地形可视化的核心代码工程,面向前端开发者、WebGL初学者及三维地理信息应用实践者,解决在浏览器中高效构建、渲染与交互式展示真实感地形的典型问题。压缩包共31个文件,包含12个JavaScript核心脚本(如TerrainLoader.js、OculusControls.js、WcsTerrainLoader.js等)、8个HTML示例页面(覆盖jotunheimen、besseggen等多地形场景)、4个二进制高度数据(.bin)及纹理资源(PNG/JPG/XML),辅以GPX轨迹数据、README说明与D3辅助库,完整呈现从地形加载、光照阴影、相机控制到VR兼容的全链路实现。包体仅2.28MB,轻量易部署。目前已有695人学习下载,读者可直接运行HTML示例,快速掌握高度图驱动地形生成、多层级纹理映射、GIS高程数据集成及Orbit/FirstPerson/VR多模式交互等关键技术,具备即学即用的工程参考价值。

1. three.js 3D地形不是“加载一个模型就完事”,而是用高度图+着色器实时生成可交互的WebGL地形系统

很多人看到“three.js 3D地形.zip”第一反应是:解压、打开HTML、地形就出来了。但实际打开后常遇到黑屏、卡顿、地形塌陷或纹理错位——根本原因在于,这个压缩包里大概率不包含预烘焙的.glb模型,而是一套基于 灰度高度图(heightmap)驱动顶点位移 + 自定义ShaderMaterial实现光照与坡度着色 的轻量级地形生成方案。它不依赖Unity或Blender导出流程,也不需要Node.js构建环境,核心逻辑全部在单个HTML文件中用three.js原生API完成。适合前端工程师快速嵌入地理可视化、游戏原型、建筑漫游等场景,尤其当你要在微信小程序、企业内网或离线演示环境中运行时,这种纯客户端、零依赖的实现方式比Unity WebGL发布更可控。本文将带你从解压后的 index.html 出发,逐层拆解高度图如何映射到顶点Y坐标、为什么必须用 BufferGeometry 而非 PlaneGeometry 、 onBeforeCompile 如何注入地形法线计算逻辑,以及如何绕过常见WebGL上下文丢失导致的地形闪烁问题。

2. 用高度图+BufferGeometry生成可细分的3D地形网格

2.1 高度图不是贴图,而是顶点位移的数据源

在 three.js 3D地形.zip 中, assets/heightmap.png (或类似命名)并非用于 MeshStandardMaterial.map 的表面纹理,而是作为 灰度值数组 被读取后,直接修改 BufferGeometry.attributes.position.array 中的Y分量。其原理是:PNG每个像素的R通道值(0–255)经归一化(÷255)后,乘以预设的 heightScale (如100),即为该顶点在世界坐标系中的海拔高度。

提示:不要用 TextureLoader 直接加载高度图后赋给材质。必须用 ImageBitmap 或 canvas.getContext('2d') 读取像素数据,否则无法同步获取原始灰度值。 three.js 本身不提供像素读取API,需手动桥接。

2.1.1 用Canvas读取高度图像素并生成顶点数组
// 假设 heightmap.png 已通过 <img> 加载完成
function loadHeightmapAsArray(img, width = 256, height = 256) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(img, 0, 0, width, height);
  
  const imageData = ctx.getImageData(0, 0, width, height);
  const data = imageData.data; // RGBA四通道,每像素4字节
  const heightArray = new Float32Array(width * height);
  
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const idx = (y * width + x) * 4; // RGBA起始索引
      // 取R通道(灰度图R=G=B,取任一即可),归一化后缩放
      const normalized = data[idx] / 255;
      heightArray[y * width + x] = normalized * 100; // heightScale = 100
    }
  }
  return heightArray;
}

这段代码的关键在于: heightArray[y * width + x] 的索引顺序必须与后续 BufferGeometry 顶点布局严格一致。若高度图宽高非2的幂(如513×513),需在 drawImage 时明确指定目标尺寸,避免浏览器自动缩放引入插值误差。

2.2 为什么必须用BufferGeometry而非PlaneGeometry?

PlaneGeometry 生成的是固定顶点数的平面(如64×64=4096顶点),无法动态响应高度图分辨率变化;更重要的是,它不支持 顶点属性的细粒度写入 。而 BufferGeometry 允许你直接操作 position 、 normal 、 uv 等TypedArray缓冲区,且能通过 attributes.position.needsUpdate = true 触发GPU内存同步。

2.2.1 构建可细分的地形网格:从顶点坐标到索引
function createTerrainGeometry(width = 256, height = 256, segment = 256) {
  const geometry = new THREE.BufferGeometry();
  
  // 顶点总数 = (segment+1) × (segment+1)
  const vertexCount = (segment + 1) * (segment + 1);
  const positions = new Float32Array(vertexCount * 3);
  const uvs = new Float32Array(vertexCount * 2);
  const indices = new Uint16Array(segment * segment * 6); // 每个四边形拆为2个三角形
  
  let vertexIndex = 0;
  for (let y = 0; y <= segment; y++) {
    for (let x = 0; x <= segment; x++) {
      // XZ平面坐标:-width/2 到 width/2,保证地形居中
      const xPos = (x / segment) * width - width / 2;
      const zPos = (y / segment) * height - height / 2;
      // Y坐标暂置0,后续由高度图填充
      positions[vertexIndex * 3] = xPos;
      positions[vertexIndex * 3 + 1] = 0;
      positions[vertexIndex * 3 + 2] = zPos;
      
      // UV坐标:0-1范围,适配高度图采样
      uvs[vertexIndex * 2] = x / segment;
      uvs[vertexIndex * 2 + 1] = y / segment;
      vertexIndex++;
    }
  }
  
  // 生成索引(顺时针三角形)
  let index = 0;
  for (let y = 0; y < segment; y++) {
    for (let x = 0; x < segment; x++) {
      const a = y * (segment + 1) + x;
      const b = y * (segment + 1) + x + 1;
      const c = (y + 1) * (segment + 1) + x;
      const d = (y + 1) * (segment + 1) + x + 1;
      // 第一个三角形:a-b-c
      indices[index++] = a;
      indices[index++] = b;
      indices[index++] = c;
      // 第二个三角形:b-d-c
      indices[index++] = b;
      indices[index++] = d;
      indices[index++] = c;
    }
  }
  
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
  geometry.setIndex(new THREE.BufferAttribute(indices, 1));
  geometry.computeVertexNormals(); // 初始法线(后续会被Shader重写)
  
  return geometry;
}

注意 segment 参数:它决定地形网格密度,而非高度图分辨率。 segment=256 生成257×257顶点,但高度图仍可用512×512提供更高精度的海拔采样——二者解耦,这是实现“高保真地形”的关键设计。

2.3 将高度图数据注入顶点Y坐标并更新法线

仅设置Y坐标会导致光照异常(法线未随地形起伏更新)。 three.js 默认 computeVertexNormals() 基于几何拓扑,但对大幅起伏地形效果差。更可靠的方式是在Shader中实时计算世界空间法线,或在CPU端用邻域差分重算。

2.3.1 CPU端重算法线:用高度图梯度近似法线方向
function updateTerrainGeometryWithHeightmap(geometry, heightArray, width, height, segment) {
  const positions = geometry.attributes.position.array;
  const normals = new Float32Array(positions.length); // 初始化法线数组
  
  // 遍历每个顶点,根据邻域高度差计算法线
  for (let y = 0; y <= segment; y++) {
    for (let x = 0; x <= segment; x++) {
      const idx = y * (segment + 1) + x;
      const baseIdx = idx * 3;
      
      // 获取当前点及周围8点的高度(边界做clamp)
      const hCenter = getHeightAt(x, y, heightArray, segment);
      const hLeft = getHeightAt(Math.max(0, x - 1), y, heightArray, segment);
      const hRight = getHeightAt(Math.min(segment, x + 1), y, heightArray, segment);
      const hTop = getHeightAt(x, Math.max(0, y - 1), heightArray, segment);
      const hBottom = getHeightAt(x, Math.min(segment, y + 1), heightArray, segment);
      
      // 计算XZ方向梯度(dx, dz)
      const dx = (hRight - hLeft) / (2 * width / segment);
      const dz = (hBottom - hTop) / (2 * height / segment);
      
      // 法线 = (-dx, 1, -dz) 归一化
      const nx = -dx;
      const ny = 1;
      const nz = -dz;
      const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
      normals[baseIdx] = nx / len;
      normals[baseIdx + 1] = ny / len;
      normals[baseIdx + 2] = nz / len;
      
      // 同时更新Y坐标
      positions[baseIdx + 1] = hCenter;
    }
  }
  
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
  geometry.attributes.position.needsUpdate = true;
  geometry.attributes.normal.needsUpdate = true;
}

function getHeightAt(x, y, heightArray, segment) {
  // 将网格坐标(x,y)映射到高度图像素坐标(双线性插值更佳,此处简化)
  const u = x / segment;
  const v = y / segment;
  const px = Math.floor(u * (heightArray.length ** 0.5));
  const py = Math.floor(v * (heightArray.length ** 0.5));
  return heightArray[py * Math.round(Math.sqrt(heightArray.length)) + px] || 0;
}

此方法比 computeVertexNormals() 更准确,但性能开销大。生产环境建议在初始化时计算一次,或改用Shader内联计算(见第4章)。

3. 用ShaderMaterial实现地形光照、坡度着色与LOD切换

3.1 为什么不用MeshStandardMaterial?——地形需要自定义光照模型

MeshStandardMaterial 假设表面是微表面(microfacet),适用于金属/粗糙度材质,但地形是宏观几何体:山脊需强高光、山谷需环境光衰减、不同坡度需不同纹理(如岩石/草地/雪地)。这些必须通过 自定义ShaderMaterial 控制。

3.1.1 地形基础Shader:高度驱动的漫反射与法线扰动
const terrainShader = {
  uniforms: {
    heightMap: { value: null }, // 高度图纹理(用于Shader内采样)
    heightScale: { value: 100 },
    terrainColor: { value: new THREE.Color(0x88aa55) },
    lightPosition: { value: new THREE.Vector3(100, 200, 100) }
  },
  vertexShader: `
    uniform sampler2D heightMap;
    uniform float heightScale;
    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vPosition;
    
    void main() {
      vUv = uv;
      // 在顶点着色器中采样高度图,位移Y坐标
      float h = texture2D(heightMap, uv).r;
      vec3 pos = position;
      pos.y = h * heightScale;
      
      // 计算世界空间位置与法线(简化版,实际应传入TBN矩阵)
      vPosition = (modelMatrix * vec4(pos, 1.0)).xyz;
      vNormal = normalize(normalMatrix * normal);
      
      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D heightMap;
    uniform vec3 terrainColor;
    uniform vec3 lightPosition;
    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vPosition;
    
    void main() {
      // 基础漫反射光照
      vec3 lightDir = normalize(lightPosition - vPosition);
      float diff = max(dot(vNormal, lightDir), 0.0);
      vec3 diffuse = diff * terrainColor;
      
      // 添加高度图细节:用高度值调制环境光
      float h = texture2D(heightMap, vUv).r;
      vec3 ambient = vec3(0.1 + h * 0.2) * terrainColor;
      
      gl_FragColor = vec4(diffuse + ambient, 1.0);
    }
  `
};

const material = new THREE.ShaderMaterial({
  uniforms: terrainShader.uniforms,
  vertexShader: terrainShader.vertexShader,
  fragmentShader: terrainShader.fragmentShader,
  side: THREE.DoubleSide,
  transparent: false,
  depthWrite: true
});

注意: vertexShader 中 texture2D(heightMap, uv) 要求 heightMap 是 THREE.Texture 对象,且 wrapS/wrapT 设为 THREE.ClampToEdgeWrapping ,避免边缘重复导致地形撕裂。

3.2 坡度着色:用法线Y分量实现“越陡越亮”的视觉反馈

真实地形中,陡坡接收更多直射光,视觉上更“硬朗”。可在Fragment Shader中加入坡度因子:

// 在fragmentShader中追加
float slope = 1.0 - abs(vNormal.y); // vNormal.y=1为水平面,0为垂直面
vec3 slopeColor = mix(vec3(0.3, 0.3, 0.3), vec3(0.9, 0.9, 0.9), slope);
diffuse = mix(diffuse, slopeColor, 0.3); // 30%坡度权重混合

此技巧无需额外纹理,仅用法线即可区分平原、山坡、悬崖,是 three.js 地形项目中最常用的低成本增强手段。

3.3 LOD(Level of Detail)切换:根据摄像机距离动态降低网格精度

当摄像机远离地形时,渲染257×257顶点是浪费。 three.js 原生不支持LOD,需手动管理多个 BufferGeometry 实例。

3.3.1 实现两级LOD:远距用65×65,近距用257×257
class TerrainLOD {
  constructor() {
    this.geometries = {};
    this.material = null;
    this.mesh = null;
  }
  
  init(heightArray, width, height) {
    // 高精度网格(近距)
    const highGeo = createTerrainGeometry(width, height, 256);
    updateTerrainGeometryWithHeightmap(highGeo, heightArray, width, height, 256);
    
    // 低精度网格(远距)
    const lowGeo = createTerrainGeometry(width, height, 64);
    updateTerrainGeometryWithHeightmap(lowGeo, heightArray, width, height, 64);
    
    this.geometries.high = highGeo;
    this.geometries.low = lowGeo;
    
    this.material = new THREE.ShaderMaterial({ /* 同上 */ });
    this.mesh = new THREE.Mesh(highGeo, this.material);
  }
  
  update(camera) {
    const distance = camera.position.distanceTo(this.mesh.position);
    const targetGeo = distance > 300 ? this.geometries.low : this.geometries.high;
    
    if (this.mesh.geometry !== targetGeo) {
      // 切换geometry(需确保material兼容)
      this.mesh.geometry.dispose();
      this.mesh.geometry = targetGeo;
      this.mesh.geometry.boundsTree = null; // 清除旧BVH
    }
  }
}

// 在render循环中调用
const terrainLOD = new TerrainLOD();
terrainLOD.init(heightArray, 1024, 1024);
scene.add(terrainLOD.mesh);

function animate() {
  requestAnimationFrame(animate);
  terrainLOD.update(camera);
  renderer.render(scene, camera);
}

LOD切换的阈值(300)需根据地形尺寸和摄像机FOV调整。实测中, distance > width * 0.3 是较稳妥的起点。

4. 解决three.js地形常见崩溃与性能瓶颈

4.1 WebGL上下文丢失:地形闪烁或黑屏的根因与恢复

在移动端或低性能PC上, three.js 地形常因内存超限触发WebGL上下文丢失( webglcontextlost ),表现为地形突然变黑或纹理消失。 three.js 虽有自动恢复机制,但 BufferGeometry 和 Texture 需手动重建。

4.1.1 注册上下文丢失事件并完整重建地形
renderer.context.addEventListener('webglcontextlost', (event) => {
  event.preventDefault();
  console.warn('WebGL context lost. Preparing recovery...');
  
  // 保存关键状态
  const savedHeightArray = heightArray;
  const savedWidth = terrainWidth;
  const savedHeight = terrainHeight;
  
  // 销毁所有GPU资源
  terrainMesh.geometry.dispose();
  terrainMesh.material.dispose();
  heightMapTexture.dispose();
  
  // 触发恢复
  renderer.forceContextLoss();
});

renderer.context.addEventListener('webglcontextrestored', () => {
  console.log('WebGL context restored.');
  
  // 重建高度图纹理
  heightMapTexture = new THREE.Texture(heightMapImage);
  heightMapTexture.wrapS = THREE.ClampToEdgeWrapping;
  heightMapTexture.wrapT = THREE.ClampToEdgeWrapping;
  heightMapTexture.needsUpdate = true;
  
  // 重建几何体(复用heightArray)
  const newGeo = createTerrainGeometry(savedWidth, savedHeight, 256);
  updateTerrainGeometryWithHeightmap(newGeo, savedHeightArray, savedWidth, savedHeight, 256);
  
  // 重建材质与网格
  const newMaterial = new THREE.ShaderMaterial({
    uniforms: {
      heightMap: { value: heightMapTexture },
      heightScale: { value: 100 }
      // ...其他uniforms
    },
    vertexShader: terrainShader.vertexShader,
    fragmentShader: terrainShader.fragmentShader,
    side: THREE.DoubleSide
  });
  
  terrainMesh.geometry = newGeo;
  terrainMesh.material = newMaterial;
});

此方案确保上下文恢复后地形完全可用,但需注意: heightMapImage ( <img> 元素)必须在页面生命周期内持续存在,不可被GC回收。

4.2 内存爆炸:高度图过大导致GPU OOM

three.js 3D地形.zip 若包含4096×4096高度图,加载后纹理内存占用超100MB(RGBA×4096²),极易触发移动端OOM。解决方案是 服务端预处理+客户端Mipmap降采样 。

4.2.1 用Canvas动态生成Mipmap链
function generateHeightmapMipmaps(img, levels = 4) {
  const mipmaps = [];
  let currentImg = img;
  
  for (let i = 0; i < levels; i++) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    const size = Math.max(1, Math.floor(currentImg.width / Math.pow(2, i)));
    canvas.width = size;
    canvas.height = size;
    ctx.drawImage(currentImg, 0, 0, size, size);
    
    // 转为Texture
    const texture = new THREE.CanvasTexture(canvas);
    texture.wrapS = THREE.ClampToEdgeWrapping;
    texture.wrapT = THREE.ClampToEdgeWrapping;
    mipmaps.push(texture);
    
    // 下一级输入为当前canvas
    currentImg = canvas;
  }
  
  return mipmaps;
}

// 使用时,在Shader中根据distance选择mipmap层级
// uniform sampler2D heightMapLod0; // 4096x4096
// uniform sampler2D heightMapLod1; // 2048x2048
// ...

虽然 three.js 不原生支持多级纹理采样( texture2DLod ),但可通过JS计算LOD层级后,用 uniform 切换 Sampler2D ,实现等效效果。

4.3 移动端触摸旋转卡顿:用requestIdleCallback优化地形更新

地形初始化时计算法线可能阻塞主线程,导致iOS Safari白屏。将耗时计算放入空闲时段:

function updateTerrainAsync(heightArray, geometry, width, height, segment) {
  const chunkSize = 100; // 每次处理100个顶点
  let processed = 0;
  
  function processChunk() {
    const start = processed;
    const end = Math.min(processed + chunkSize, (segment + 1) * (segment + 1));
    
    for (let i = start; i < end; i++) {
      // 执行单个顶点的法线计算...
      const y = Math.floor(i / (segment + 1));
      const x = i % (segment + 1);
      // ...同2.3.1节逻辑
    }
    
    processed = end;
    
    if (processed < (segment + 1) * (segment + 1)) {
      requestIdleCallback(processChunk, { timeout: 1 });
    } else {
      geometry.attributes.position.needsUpdate = true;
      geometry.attributes.normal.needsUpdate = true;
      console.log('Terrain update completed.');
    }
  }
  
  requestIdleCallback(processChunk, { timeout: 1 });
}

timeout: 1 确保即使无空闲时间也强制执行,避免无限等待。

5. 在微信小游戏与团结引擎中部署three.js地形的避坑要点

5.1 微信小游戏:禁用WebGL 2.0,强制使用WebGL 1.0上下文

微信小游戏基础库2.27.0+默认启用WebGL 2.0,但 three.js 地形Shader中若使用 #version 300 es 或 texture2D 以外的采样函数(如 texture ),会编译失败。必须显式降级:

// 创建renderer时指定
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  powerPreference: 'high-performance',
  // 关键:强制WebGL 1.0
  context: canvas.getContext('webgl', { 
    alpha: true, 
    antialias: true,
    premultipliedAlpha: true,
    stencil: true,
    preserveDrawingBuffer: false,
    powerPreference: 'high-performance'
  })
});

同时,Shader中必须用 #version 100 ,且所有纹理采样用 texture2D 而非 texture 。

5.2 团结引擎(Unity)打包WebGL时,地形资源路径需匹配idbfs虚拟文件系统

若你用Unity导出地形高度图并集成到 three.js 项目,Unity WebGL构建后会将 Assets/Resources/heightmap.png 写入 idbfs (IndexedDB文件系统)。 three.js 需通过 fetch 从 /idbfs/heightmap.png 加载,而非相对路径:

// 在Unity WebGL构建后,three.js中这样加载
async function loadHeightmapFromIDBFS() {
  try {
    const response = await fetch('/idbfs/heightmap.png');
    const blob = await response.blob();
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
      // 处理图像...
      URL.revokeObjectURL(url);
    };
    img.src = url;
  } catch (e) {
    console.error('Failed to load heightmap from idbfs:', e);
  }
}

注意: /idbfs/ 是Unity WebGL模板的默认挂载点,若修改了 index.html 中的 Module.locateFile ,需同步调整路径。

5.3 Oculus Quest 2等VR设备:开启stereo rendering并适配IPD

three.js 地形在Oculus浏览器中需启用立体渲染,否则显示为单眼画面:

// 初始化时
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);

// 启用WebXR(替代已废弃的StereoCamera)
if (renderer.xr.enabled === false) {
  renderer.xr.enabled = true;
}

// 在animate中
function animate() {
  requestAnimationFrame(animate);
  if (renderer.xr.isPresenting) {
    // VR模式下,renderer自动处理双目渲染
    renderer.render(scene, camera);
  } else {
    // 普通模式
    renderer.render(scene, camera);
  }
}

地形网格的 scale 需按真实世界比例设置(如1单位=1米),否则VR中会产生眩晕。实测 heightScale=50 (50米海拔差)在Quest 2中观感最自然。

最后,检查 three.min.js 版本: three.js 3D地形.zip 若含 three.min.js ,确认其版本≥0.152.0(支持WebXR),低于此版本需升级,否则Oculus设备无法进入VR模式。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐