
1. 项目概述为什么要在Web上重建真实地形作为一名长期在三维可视化领域摸爬滚打的开发者我见过太多项目对“真实感”的追求止步于漂亮的模型和光影。直到我们需要在一个智慧城市管理平台中将一片真实的山区地形搬上网页让决策者能直观地看到道路规划、信号塔选址与地形的关系时我才深刻体会到一个基于真实地理数据、可交互、高性能的地形场景其价值远超一个静态的3D模型。这正是Three.js结合WebGL技术大显身手的地方。简单来说这个教程要解决的问题是如何将现实中起伏的山脉、蜿蜒的河谷变成浏览器里一个可以360度旋转、缩放、甚至在上面“行走”的三维地形。它不仅仅是把一张高度图变成一堆三角形更涉及到数据获取、处理、渲染优化以及视觉增强等一系列环环相扣的步骤。无论你是想做一个地理信息系统GIS的轻量级前端展示还是开发一款具有真实地貌的网页游戏或者是进行建筑规划的沙盘模拟这套流程都能为你提供一个坚实的起点。整个过程会用到Three.js这个强大的3D库触及一些GLSL着色器知识来提升视觉效果并最终在WebGL环境中流畅运行。2. 核心思路与数据管道设计2.1 从现实到数字地形数据的来源与选择重建地形的第一步是获取数据。你不能凭空想象一座山的高度必须依赖真实的地理高程数据。目前主流的数据源有几类SRTM航天飞机雷达地形测绘任务数据这是最常用、免费且全球覆盖除极地的高程数据源精度有1角秒约30米和3角秒约90米两种。对于大多数Web可视化场景90米精度已经足够数据量也更容易处理。ASTER GDEM先进星载热发射和反射辐射仪全球数字高程模型同样免费全球覆盖精度约为30米。有时在特定区域比SRTM有更好的细节。本地高精度数据通过无人机航测或激光雷达LiDAR获取的数据精度可以达到厘米级。但这通常用于小范围、高要求的专业项目数据量大处理复杂。对于本教程我们将以SRTM 90米分辨率数据为例。你可以在美国地质调查局USGS的EarthExplorer或NASA的网站找到并下载你感兴趣区域的.hgt格式文件。这个格式本质上是将经纬度网格点的高程值以米为单位存储为二进制整数。注意选择数据源时务必考虑你的应用场景。如果是展示一个国家或省份的全貌90米甚至更低精度即可如果是聚焦一个山谷或建筑工地则需要寻求更高精度的数据。精度越高数据文件越大后续生成的地形网格顶点数也越多对浏览器性能的挑战就越大。2.2 数据处理流水线从原始数据到Three.js网格拿到原始的.hgt文件后它还不能直接被Three.js使用。我们需要一个处理流水线将其转化为Three.js的BufferGeometry。核心流程如下原始HGT文件 - 高程矩阵Height Matrix - 顶点位置缓冲Vertex Position Buffer - 几何体Geometry - 网格Mesh解析HGT文件读取二进制数据根据其规格如1201x1201个采样点解析出一个二维的JavaScript数组矩阵每个元素代表一个点的高程值。归一化与缩放原始高程值的范围可能很大例如从海拔-100米到4000米。为了在三维场景中有合理的尺寸我们需要将其归一化到0到1的范围或者根据场景尺寸进行线性缩放。同时经纬度网格需要映射到XZ平面Three.js中通常使用Y轴表示高度。生成几何体Three.js提供了THREE.PlaneGeometry我们可以通过修改其顶点的高度Y坐标来生成地形。更高效的方式是直接使用THREE.BufferGeometry手动创建顶点数组、索引数组和UV数组。顶点数组的生成规则是遍历高程矩阵为每个点创建一个(x, height, z)坐标。计算法线为了让光照效果正确必须为每个顶点计算法线向量。法线决定了光线如何从表面反射是地形看起来“立体”而非“扁平”的关键。Three.js的BufferGeometry有computeVertexNormals()方法可以根据几何体面自动计算但对于大型地形在数据处理阶段预计算法线并传入有时性能更优。这个流水线的设计核心考量是性能与可控性。在Web环境中我们必须时刻警惕内存和GPU的占用。手动操作BufferGeometry虽然代码量稍大但避免了中间对象的创建能更精细地控制数据流是处理大规模地形数据的推荐方式。3. 核心实现代码级拆解与优化3.1 使用Three.js内置地形生成器快速入门Three.js其实自带一个简单的地形生成功能适合快速原型验证。它基于THREE.PlaneGeometry和一张高度图Heightmap。// 1. 加载高度图纹理一张灰度图白色代表高黑色代表低 const textureLoader new THREE.TextureLoader(); const heightTexture textureLoader.load(path/to/heightmap.jpg); // 2. 等待纹理加载完成获取其图像数据 heightTexture.onLoad function() { const width 64; // 地形网格宽度分段数 const depth 64; // 地形网格深度分段数 const geometry new THREE.PlaneGeometry(100, 100, width-1, depth-1); // 3. 获取顶点数组并修改Y坐标 const positionAttribute geometry.attributes.position; const heightData getHeightData(heightTexture.image, width, depth); // 需要自定义函数从图像中读取灰度值 for (let i 0; i positionAttribute.count; i) { const x i % width; const z Math.floor(i / width); // 根据高度图数据设置Y坐标scale是高度缩放系数 positionAttribute.setY(i, heightData[x][z] * scale); } // 4. 更新几何体 positionAttribute.needsUpdate true; geometry.computeVertexNormals(); // 重新计算法线 // 5. 创建材质和网格 const material new THREE.MeshStandardMaterial({ color: 0x669922, wireframe: false }); const terrain new THREE.Mesh(geometry, material); terrain.rotation.x -Math.PI / 2; // 让平面立起来可选取决于你的坐标系 scene.add(terrain); }; // 辅助函数从图像中提取高度数据 function getHeightData(image, width, depth) { const canvas document.createElement(canvas); canvas.width width; canvas.height depth; const context canvas.getContext(2d); context.drawImage(image, 0, 0, width, depth); const imageData context.getImageData(0, 0, width, depth).data; const data new Array(width); for (let x 0; x width; x) { data[x] new Array(depth); for (let z 0; z depth; z) { // 读取灰度值R通道即可因为灰度图RGB const idx 4 * (z * width x); data[x][z] imageData[idx] / 255.0; // 归一化到0-1 } } return data; }实操心得这种方法简单直观但性能瓶颈明显。PlaneGeometry在分段数很高时比如512x512顶点数量会爆炸26万computeVertexNormals()计算量巨大可能导致页面卡顿。它仅适用于小范围或低精度地形的演示。3.2 手动构建BufferGeometry生产级方案对于真实地形数据我们必须追求更高的效率。下面是手动构建的核心步骤import * as THREE from three; function createTerrainFromHeightMatrix(heightMatrix, widthSegments, depthSegments, maxHeight) { const width heightMatrix.length; const depth heightMatrix[0].length; const totalVertices width * depth; const totalIndices (width - 1) * (depth - 1) * 6; // 每个四边形由两个三角形6个索引构成 // 1. 初始化数组 const positions new Float32Array(totalVertices * 3); // x, y, z const normals new Float32Array(totalVertices * 3); // nx, ny, nz const uvs new Float32Array(totalVertices * 2); // u, v const indices new Uint32Array(totalIndices); // 2. 填充顶点位置和UV let vertexIndex 0; for (let z 0; z depth; z) { for (let x 0; x width; x) { const idx vertexIndex * 3; // X, Z坐标在平面内均匀分布Y坐标来自高程数据 positions[idx] (x - width/2) * (widthSegments / (width - 1)); // X positions[idx 1] heightMatrix[x][z] * maxHeight; // Y (高度) positions[idx 2] (z - depth/2) * (depthSegments / (depth - 1)); // Z // UV坐标用于贴图映射 uvs[vertexIndex * 2] x / (width - 1); uvs[vertexIndex * 2 1] z / (depth - 1); vertexIndex; } } // 3. 填充索引构建三角形 let i 0; for (let z 0; z depth - 1; z) { for (let x 0; x width - 1; x) { const a z * width x; const b z * width (x 1); const c (z 1) * width x; const d (z 1) * width (x 1); // 第一个三角形a, b, c indices[i] a; indices[i] b; indices[i] c; // 第二个三角形b, d, c indices[i] b; indices[i] d; indices[i] c; } } // 4. 计算顶点法线简化方法实际生产可能需更精确算法 // 先全部设为(0, 1, 0) for (let j 0; j normals.length; j 3) { normals[j] 0; normals[j 1] 1; normals[j 2] 0; } // 根据相邻面法线累加平均此处为示意省略详细循环代码 // 更优做法在生成索引的循环中实时计算每个面的法线并累加到其三个顶点上。 // 5. 创建BufferGeometry并设置属性 const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); geometry.setAttribute(normal, new THREE.BufferAttribute(normals, 3)); geometry.setAttribute(uv, new THREE.BufferAttribute(uvs, 2)); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); // 6. 计算边界框和边界球用于视锥体裁剪 geometry.computeBoundingBox(); geometry.computeBoundingSphere(); return geometry; } // 假设heightMatrix是从HGT文件解析出的二维数组 const terrainGeometry createTerrainFromHeightMatrix(heightMatrix, 1000, 1000, 200); const material new THREE.MeshStandardMaterial({ color: 0x88aa55, roughness: 0.8, metalness: 0.2 }); const terrainMesh new THREE.Mesh(terrainGeometry, material); scene.add(terrainMesh);为什么这么做性能Float32Array和Uint32Array是类型化数组数据在内存中连续排列传输到GPU的速度极快。控制力我们精确控制了每一个顶点和每一个三角形没有冗余数据。可扩展性这个结构很容易与后续的LOD细节层次算法、分块加载结合。你可以将大地形分成多个这样的BufferGeometry块。3.3 使用灰度图高度图的进阶技巧有时我们可能已经有一张处理好的灰度图如PNG或JPEG而不是原始的HGT数据。这时我们可以用Canvas或ImageDataAPI来读取像素灰度值作为高度数据。async function loadHeightmap(url, widthSegments, depthSegments) { const image await new Promise((resolve) { const img new Image(); img.onload () resolve(img); img.src url; }); const canvas document.createElement(canvas); const ctx canvas.getContext(2d); // 建议将图像缩放到你需要的网格分辨率以减少计算量 const targetWidth widthSegments 1; const targetDepth depthSegments 1; canvas.width targetWidth; canvas.height targetDepth; ctx.drawImage(image, 0, 0, targetWidth, targetDepth); const imageData ctx.getImageData(0, 0, targetWidth, targetDepth); const heightMatrix new Array(targetWidth); for (let x 0; x targetWidth; x) { heightMatrix[x] new Array(targetDepth); for (let z 0; z targetDepth; z) { const idx (z * targetWidth x) * 4; // 每个像素4个值(R,G,B,A) // 使用红色通道值作为高度灰度图RGB const heightValue imageData.data[idx] / 255.0; // 归一化到0-1 // 可选应用一些曲线函数来调整地形起伏的视觉效果 // heightValue Math.pow(heightValue, 2.0); // 让高地更高低谷更平 heightMatrix[x][z] heightValue; } } return heightMatrix; }注意事项使用JPEG等有损压缩格式存储高度图时压缩伪影可能会导致地形表面出现不自然的“波纹”或“条带”。对于质量要求高的项目建议使用无损的PNG格式或者直接使用原始的二进制高程数据。4. 视觉增强材质、着色与光照一个只有单一颜色的地形是枯燥的。真实感来自于丰富的视觉细节。4.1 基于高度的多纹理混合最常见的需求是根据海拔或坡度混合不同的纹理比如雪线以上是雪山腰是岩石山谷是草地。实现思路在片元着色器Fragment Shader中根据当前像素的世界坐标Y值高度或法线Y值坡度计算一个混合权重。准备多张纹理如草地、岩石、雪地。在着色器中根据权重对这几张纹理的颜色进行混合。// GLSL 片元着色器代码片段 (Three.js ShaderMaterial) uniform sampler2D grassTexture; uniform sampler2D rockTexture; uniform sampler2D snowTexture; uniform float snowHeight; uniform float rockHeight; varying vec3 vWorldPosition; varying vec3 vNormal; void main() { float height vWorldPosition.y; float slope 1.0 - dot(vNormal, vec3(0.0, 1.0, 0.0)); // 计算坡度1.0为垂直 vec4 grassColor texture2D(grassTexture, vUv); vec4 rockColor texture2D(rockTexture, vUv); vec4 snowColor texture2D(snowTexture, vUv); // 基于高度的混合 float snowFactor smoothstep(snowHeight - 50.0, snowHeight 50.0, height); // 基于坡度的混合越陡峭岩石越多 float rockFactor smoothstep(0.3, 0.6, slope); vec4 color mix(grassColor, rockColor, rockFactor); color mix(color, snowColor, snowFactor); gl_FragColor color; }实操心得smoothstep函数是进行平滑混合的神器它能避免在不同材质交界处出现生硬的线条。你需要反复调整rockHeight、snowHeight以及smoothstep的阈值参数才能达到最自然的效果。最好在场景中暴露这些参数为GUI控件方便实时调试。4.2 法线贴图与细节贴图即使有几十万个顶点地形的微观细节依然不足。这时就需要法线贴图Normal Map和细节贴图Detail Map。法线贴图一张编码了表面微观法线方向的RGB贴图。它不改变模型的几何形状但通过改变光照计算让表面看起来有复杂的凹凸细节如小石头、裂缝。细节贴图一张高频率、可平铺的灰度或颜色贴图以不同的UV缩放通常很大叠加在基础颜色上用于表现沙砾、土壤纹理等微小细节。在Three.js的MeshStandardMaterial中可以方便地设置这些贴图const material new THREE.MeshStandardMaterial({ map: baseColorTexture, // 基础颜色贴图来自4.1的混合结果或单张图 normalMap: normalTexture, // 法线贴图 normalScale: new THREE.Vector2(1, 1), // 法线强度 aoMap: ambientOcclusionTexture, // 环境光遮蔽贴图增加缝隙阴影 roughnessMap: roughnessTexture, // 粗糙度贴图 metalness: 0.0, roughness: 0.8 }); // 细节贴图通常需要在自定义着色器中实现或者通过混合第二层UV实现。4.3 光照与雾效正确的光照是三维场景的灵魂。对于地形我推荐使用DirectionalLight平行光模拟太阳光能产生清晰的阴影很好地表现地形的起伏。记得开启阴影castShadow: true并为地形和接收阴影的物体设置castShadow和receiveShadow。AmbientLight环境光提供基础的整体亮度避免背光面完全死黑。HemisphereLight半球光这是模拟户外天光的利器它从上到下有一个颜色渐变通常天空色到地面色能让阴影部分呈现出环境色的反射比纯AmbientLight自然得多。const sunLight new THREE.DirectionalLight(0xffffff, 1.0); sunLight.position.set(100, 200, 50); sunLight.castShadow true; // 优化阴影质量 sunLight.shadow.mapSize.width 2048; sunLight.shadow.mapSize.height 2048; sunLight.shadow.camera.near 0.5; sunLight.shadow.camera.far 500; scene.add(sunLight); const hemisphereLight new THREE.HemisphereLight(0x87ceeb, 0x8b7355, 0.6); // 天空色地面色强度 scene.add(hemisphereLight);雾效能极大地增强场景的纵深感。Three.js有线性雾Fog和指数雾FogExp2。对于广阔地形指数雾更自然。scene.fog new THREE.FogExp2(0xcccccc, 0.002); // 颜色密度记得在材质中启用fog: true这样物体才会受雾的影响。5. 性能优化让大地形流畅运行当你的地形网格顶点数超过10万性能问题就会凸显。以下是几个关键的优化方向。5.1 细节层次LODLOD的核心思想是距离摄像机远的物体用更少的细节更少的三角形来渲染。对于地形可以将其分割成多个区块Chunk并为每个区块准备多个不同精度的几何体版本例如高模、中模、低模。根据区块与相机的距离动态切换对应的几何体。Three.js社区有成熟的LOD库如THREE.LOD类但对于动态生成的地形需要自己管理不同层级的几何体生成和切换逻辑。这是一个高级话题实现起来较复杂但对于超大规模地形是必须的。5.2 视锥体裁剪Frustum Culling这是Three.js默认开启的优化。它检查物体的边界球boundingSphere或边界框boundingBox是否在摄像机的视锥体内如果完全不在则跳过该物体的渲染。这就是为什么我们在创建几何体后要调用computeBoundingSphere()的原因。确保你的地形网格或其分块拥有正确的边界体积否则裁剪会失效。5.3 减少绘制调用与状态切换合并几何体如果有多块静态且材质相同的地形可以考虑使用BufferGeometryUtils.mergeBufferGeometries()将它们合并成一个。这能将多次绘制调用减少为一次大幅提升性能。使用纹理图集如果不同区域需要不同的颜色纹理尽量将这些小纹理合并到一张大图图集中通过UV坐标来选取。避免为不同区块切换不同的纹理因为纹理切换是GPU状态切换开销很大。简化材质在保证效果的前提下使用更简单的着色器。MeshBasicMaterial比MeshStandardMaterial快得多。如果不需要动态光影考虑使用烘焙光照贴图。5.4 使用顶点着色器进行动态变形可选如果你需要实现动态的海面、雪地融化等效果可以在顶点着色器中修改顶点位置。这比在JavaScript中不断更新position属性需要将数据从CPU传到GPU要高效得多。// 顶点着色器示例简单的正弦波模拟水面 uniform float time; varying vec3 vPosition; void main() { vec3 pos position; // 在Y轴上添加基于XZ坐标和时间的正弦波 float wave sin(pos.x * 0.1 time) * cos(pos.z * 0.1 time) * 2.0; pos.y wave; vPosition pos; // 传递给片元着色器 gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }6. 常见问题与调试实录在开发过程中我踩过不少坑这里记录几个典型问题及其解决方案。6.1 地形渲染出现“裂缝”或“接缝”现象当地形被分成多个区块Chunk时区块边缘连接处出现不连续的裂缝。原因相邻区块在边界上的顶点高度值不一致。可能是因为数据采样时边界点处理不当或者生成几何体时相邻区块使用了各自独立的高度图边缘数据存在精度误差。解决方案共享边界数据在生成地形区块时确保相邻区块共享同一条边界线上的顶点数据。在数据预处理阶段就按最终拼接的网格来划分数据块。使用重叠边界生成区块时在边缘多取一行/列数据但在渲染时只使用内部区域。这需要更复杂的索引计算。在着色器中平滑如果裂缝不明显可以在片元着色器中根据像素靠近边界的程度对高度或颜色进行微小的平滑插值。但这治标不治本。6.2 性能突然下降帧率暴跌现象场景在某个角度或位置运行时很流畅但相机移动到某些区域时突然卡顿。排查步骤打开浏览器开发者工具的“性能Performance”面板录制几秒卡顿时的操作查看是哪部分JavaScript函数或渲染任务耗时最长。检查Three.js的info面板在控制台输入console.log(renderer.info)查看render.calls绘制调用次数和render.triangles三角形数量是否在卡顿时激增。这可能是由于LOD切换失灵或视锥体裁剪失效导致大量不可见物体被提交渲染。检查内存在“内存Memory”面板拍摄堆快照查看BufferGeometry或Texture对象是否被重复创建且未被释放导致内存泄漏。6.3 光照或阴影显示不正常现象地形表面一片漆黑或者阴影缺失、破碎。排查法线问题这是最常见的原因。确认几何体的法线已正确计算geometry.computeVertexNormals()。如果手动提供了法线数据检查数据是否正确。可以在材质中设置wireframe: true查看网格结构或者使用MeshNormalMaterial来直观显示法线方向法线方向会显示为颜色。光源位置和强度检查DirectionalLight的位置和intensity。平行光的位置只代表方向但太远可能影响阴影相机范围。阴影配置确认renderer.shadowMap.enabled true。确认地形网格terrainMesh.castShadow true; terrainMesh.receiveShadow true。确认光源light.castShadow true。检查阴影相机的范围shadow.camera.near/far和视口shadow.camera.left/right/top/bottom是否包含了需要投射阴影的物体。范围太小会裁掉阴影太大会降低阴影质量。材质类型MeshBasicMaterial不支持光影。确保使用的是MeshStandardMaterial、MeshPhongMaterial等支持光照的材质。6.4 WebGL上下文丢失或创建失败错误信息THREE.WebGLRenderer: A WebGL context could not be created.原因与解决浏览器不支持或禁用用户浏览器过于老旧或手动禁用了WebGL。提示用户升级浏览器或检查设置。可以通过if (!WEBGL.isWebGLAvailable()) { // 显示错误提示 }进行检测。GPU内存耗尽地形纹理或几何体数据太大。必须实施分块加载、纹理压缩如使用.basis或.ktx2格式、LOD等优化策略。抗锯齿采样过高在创建WebGLRenderer时antialias: true和过高的pixelRatio会显著增加显存占用。对于复杂场景可以尝试关闭抗锯齿或降低pixelRatio。6.5 地形UV错乱贴图拉伸严重现象贴图在地形表面重复得不自然在某些斜坡上被极度拉伸。原因我们之前用的UV是简单按照顶点在网格中的索引比例生成的(x/width, z/depth)。这在平坦区域没问题但在陡峭的斜坡上同一个三角形在3D空间中是倾斜的但它在UV平面上仍对应一个规则矩形导致纹理采样失真。解决方案投影映射 对于地形这种大型物体更常用的方法是三平面投影Tri-planar Projection。它在片元着色器中根据顶点法线方向分别从世界坐标的X、Y、Z三个轴向投影纹理然后根据法线分量进行混合。// GLSL 三平面投影混合代码片段 vec4 triPlanarBlend(sampler2D tex, vec3 worldPos, vec3 normal, float scale) { // 分别从三个轴向采样 vec4 xAxis texture2D(tex, worldPos.yz * scale); vec4 yAxis texture2D(tex, worldPos.xz * scale); vec4 zAxis texture2D(tex, worldPos.xy * scale); // 使用法线绝对值的幂作为混合权重使过渡更平滑 vec3 weights pow(abs(normal), vec3(4.0)); // 4.0是锐度因子可调 weights weights / (weights.x weights.y weights.z); // 归一化 // 混合 return xAxis * weights.x yAxis * weights.y zAxis * weights.z; } // 在main函数中调用 vec4 color triPlanarBlend(rockTexture, vWorldPosition, normalizedNormal, 0.05);这种方法能彻底解决陡坡上的纹理拉伸问题使纹理仿佛“喷涂”在物体表面一样自然是地形和岩石类材质处理的常用高级技巧。