three.js高度图地形生成原理与实战
简介:本资源是一套基于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模式。
更多推荐

所有评论(0)