在数字孪生、智慧城市、GIS 可视化项目中,经常需要将:
- DEM 高程数据
- GeoJSON 行政区
- 卫星影像
融合成一个真实三维区域模型。
本文将完整介绍如何使用 Three.js 实现:
真实地形
+ 行政区裁切
+ 贴地边界线
+ 卫星影像
+ 三维区域模型
最终效果类似:
- Cesium 区域裁切
- GISBox 区域模型
- 数字孪生地图
一、最终效果


鼠标移入区域地块抬升
1. DEM 地形
通过高程数据抬升网格:平面 -> 山地地形
2. GeoJSON 行政区裁切
支持:
- 多边形
- 多区域
- 洞(holes)
3. 影像贴图
支持:
- 卫星影像
- 正射影像
- 自定义底图
4. 贴地边界高亮
实现:真正贴地行政区边界
避免:
- 漂浮
- 穿模
- 深度闪烁
二、实现代码
1.主入口代码
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { MOUSE, TOUCH } from 'three'
import { loadDemTiles } from './lib/dem/demLoader.js'
import { createGlobalBounds, getWorldSize } from './lib/geo/world.js'
import {
getTerrainBaseZ,
normalizeRegions,
} from './lib/three/boundary.js'
import { createResourceTracker } from './lib/three/resourceTracker.js'
import { buildRegionModel } from './lib/three/regionMesh.js'
import gsap from 'gsap'
const TERRAIN_CONFIG = {
segments: 160,
worldWidth: 12,
heightScale: 0.0001,
solidThickness: 0.18,
outlineOffset: 0.01,
outlineColor: 0x00ffff,
outlineOpacity: 0.92,
boundaryMaskSize: 2048,
colorMinElevation: 0,
colorMaxElevation: 4200,
}
const DATA_URLS = {
geoJson: '/data/json/bj.json',
imagery: '/data/bj-map.png',
demTiles: [
'/data/tif/ASTGTM2_N33E106_dem.tif',
'/data/tif/ASTGTM2_N33E107_dem.tif',
'/data/tif/ASTGTM2_N34E106_dem.tif',
'/data/tif/ASTGTM2_N34E107_dem.tif',
'/data/tif/ASTGTM2_N34E108_dem.tif',
'/data/tif/ASTGTM2_N35E106_dem.tif',
'/data/tif/ASTGTM2_N35E107_dem.tif',
],
}
const IMAGERY_TRANSFORM = {
scaleX: 1,
scaleY: 1,
offsetX: 0,
offsetY: 0,
repeat: false,
}
const sceneContainerRef = ref(null)
const loadingText = ref('准备加载数据...')
const errorText = ref('')
const tileCount = ref(0)
const imageryScaleX = ref(IMAGERY_TRANSFORM.scaleX)
const imageryScaleY = ref(IMAGERY_TRANSFORM.scaleY)
const imageryOffsetX = ref(IMAGERY_TRANSFORM.offsetX)
const imageryOffsetY = ref(IMAGERY_TRANSFORM.offsetY)
const imageryRepeat = ref(IMAGERY_TRANSFORM.repeat)
let scene
let camera
let renderer
let controls
let animationFrameId = 0
let resources = createResourceTracker()
let imageryTextureRef = null
const terrainUvRecords = []
const hitMeshes = []
let raycaster
let mouse
let regionsGroup
const handlePointerMove = (event) => {
if (!sceneContainerRef.value || !camera || !regionsGroup) return
const rect = sceneContainerRef.value.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(hitMeshes, false)
let hoveredGroup = null
if (intersects.length > 0) {
hoveredGroup = intersects[0].object.userData.regionGroup
}
regionsGroup.children.forEach(group => {
if (group.userData.isRegionGroup) {
const isHovered = (group === hoveredGroup)
const targetZ = isHovered ? 0.15 : 0
if (group.position.z !== targetZ) {
gsap.to(group.position, {
z: targetZ,
duration: 0.3,
ease: "power2.out"
})
}
}
})
}
const applyCurrentImageryTransform = () => {
if (!imageryTextureRef) {
return
}
imageryTextureRef.wrapS = imageryRepeat.value ? THREE.RepeatWrapping : THREE.ClampToEdgeWrapping
imageryTextureRef.wrapT = imageryRepeat.value ? THREE.RepeatWrapping : THREE.ClampToEdgeWrapping
imageryTextureRef.needsUpdate = true
for (const record of terrainUvRecords) {
const uvAttribute = record.geometry.getAttribute('uv1')
const target = uvAttribute.array
const source = record.globalUvBase
for (let index = 0; index < source.length; index += 2) {
const u = source[index]
const v = source[index + 1]
target[index] = (u - 0.5) * imageryScaleX.value + 0.5 + imageryOffsetX.value
target[index + 1] = (v - 0.5) * imageryScaleY.value + 0.5 + imageryOffsetY.value
}
uvAttribute.needsUpdate = true
}
}
watch([imageryScaleX, imageryScaleY, imageryOffsetX, imageryOffsetY, imageryRepeat], () => {
applyCurrentImageryTransform()
})
const disposeScene = () => {
cancelAnimationFrame(animationFrameId)
animationFrameId = 0
if (sceneContainerRef.value) {
sceneContainerRef.value.removeEventListener('pointermove', handlePointerMove)
}
if (controls) {
controls.dispose()
controls = null
}
resources.dispose()
resources = createResourceTracker()
imageryTextureRef = null
terrainUvRecords.splice(0, terrainUvRecords.length)
hitMeshes.splice(0, hitMeshes.length)
regionsGroup = null
if (renderer) {
renderer.dispose()
renderer.forceContextLoss?.()
renderer.domElement.remove()
renderer = null
}
if (scene) {
scene.clear()
scene = null
}
}
const handleResize = () => {
if (!renderer || !camera || !sceneContainerRef.value) {
return
}
const { clientWidth, clientHeight } = sceneContainerRef.value
camera.aspect = clientWidth / clientHeight
camera.updateProjectionMatrix()
renderer.setSize(clientWidth, clientHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}
const animate = () => {
animationFrameId = window.requestAnimationFrame(animate)
controls?.update()
renderer?.render(scene, camera)
}
const initScene = () => {
const container = sceneContainerRef.value
if (!container) {
throw new Error('未找到渲染容器')
}
scene = new THREE.Scene()
scene.background = new THREE.Color('#07111d')
scene.fog = new THREE.Fog('#07111d', 12, 28)
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 200)
camera.up.set(0, 0, 1)
camera.position.set(-7.5, -9.5, 8.5)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.clientWidth, container.clientHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.enableRotate = true
controls.screenSpacePanning = true
controls.target.set(0, 0, 0.8)
controls.minDistance = 4
controls.maxDistance = 30
controls.minPolarAngle = 0.18
controls.maxPolarAngle = Math.PI / 2.02
controls.mouseButtons = {
LEFT: MOUSE.ROTATE,
MIDDLE: MOUSE.DOLLY,
RIGHT: MOUSE.PAN,
}
controls.touches = {
ONE: TOUCH.ROTATE,
TWO: TOUCH.DOLLY_PAN,
}
raycaster = new THREE.Raycaster()
mouse = new THREE.Vector2()
container.addEventListener('pointermove', handlePointerMove)
scene.add(new THREE.AmbientLight(0xffffff, 1.65))
const keyLight = new THREE.DirectionalLight(0xffffff, 2.6)
keyLight.position.set(7, -6, 10)
scene.add(keyLight)
const rimLight = new THREE.DirectionalLight(0x9cc7ff, 1.2)
rimLight.position.set(-8, 10, 7)
scene.add(rimLight)
}
const renderTerrain = async () => {
errorText.value = ''
tileCount.value = 0
disposeScene()
initScene()
loadingText.value = '正在加载行政区划...'
const geoResponse = await fetch(DATA_URLS.geoJson)
if (!geoResponse.ok) {
throw new Error('加载 GeoJSON 失败')
}
const geoJson = await geoResponse.json()
const regions = normalizeRegions(geoJson)
loadingText.value = '正在加载 DEM 数据...'
const demTiles = await loadDemTiles(DATA_URLS.demTiles, {
onProgress: ({ phase, name, index, total }) => {
const label = phase === 'fetch' ? '下载' : phase === 'raster' ? '解析' : '完成'
loadingText.value = `正在${label} ${name} (${index + 1}/${total})`
},
})
const sceneBounds = createGlobalBounds(demTiles)
const textureBounds = createGlobalBounds(regions)
const worldSize = getWorldSize(sceneBounds, TERRAIN_CONFIG.worldWidth)
loadingText.value = '正在加载影像...'
const imageryTexture = resources.track(await new THREE.TextureLoader().loadAsync(DATA_URLS.imagery))
imageryTexture.colorSpace = THREE.SRGBColorSpace
imageryTexture.flipY = false
imageryTexture.channel = 1
imageryTexture.needsUpdate = true
imageryTextureRef = imageryTexture
regionsGroup = new THREE.Group()
const terrainTiles = demTiles
const baseZ = getTerrainBaseZ(terrainTiles, TERRAIN_CONFIG.heightScale, TERRAIN_CONFIG.solidThickness)
for (const region of regions) {
const { group, hitMesh, uvRecord } = buildRegionModel(region, {
globalBounds: textureBounds,
worldSize,
terrainTiles,
imageryTexture,
imageryTransform: {
scaleX: imageryScaleX.value,
scaleY: imageryScaleY.value,
offsetX: imageryOffsetX.value,
offsetY: imageryOffsetY.value,
},
heightScale: TERRAIN_CONFIG.heightScale,
solidThickness: TERRAIN_CONFIG.solidThickness,
outlineOffset: TERRAIN_CONFIG.outlineOffset,
outlineColor: TERRAIN_CONFIG.outlineColor,
outlineOpacity: TERRAIN_CONFIG.outlineOpacity,
baseZ,
segments: TERRAIN_CONFIG.segments,
track: resources.track,
})
regionsGroup.add(group)
hitMeshes.push(hitMesh)
terrainUvRecords.push(uvRecord)
tileCount.value += 1
loadingText.value = `已完成 ${tileCount.value}/${regions.length} 个区域`
}
applyCurrentImageryTransform()
scene.add(regionsGroup)
loadingText.value = `地形加载完成,共渲染 ${regions.length} 个区域`
animate()
}
onMounted(async () => {
try {
await renderTerrain()
window.addEventListener('resize', handleResize)
} catch (error) {
errorText.value = error instanceof Error ? error.message : '地形初始化失败'
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
disposeScene()
})
</script>
<template>
<main class="app-shell">
<section class="viewer-panel">
<div ref="sceneContainerRef" class="scene-container"></div>
<div class="status-card">
<p class="status-card__label">加载状态</p>
<p>{{ errorText || loadingText }}</p>
</div>
<div v-if="errorText" class="error-card">
<strong>场景初始化失败</strong>
<p>{{ errorText }}</p>
</div>
</section>
</main>
</template>
2.dem高程
import { fromArrayBuffer } from 'geotiff'
export async function loadDemTiles(paths, { onProgress } = {}) {
const tiles = []
for (let index = 0; index < paths.length; index += 1) {
const path = paths[index]
const name = path.split('/').pop()
onProgress?.({ phase: 'fetch', name, index, total: paths.length })
const response = await fetch(path)
if (!response.ok) {
throw new Error(`加载 DEM 失败: ${name}`)
}
const arrayBuffer = await response.arrayBuffer()
const tiff = await fromArrayBuffer(arrayBuffer)
const image = await tiff.getImage()
const width = image.getWidth()
const height = image.getHeight()
const imageBounds = image.getBoundingBox()
const pixelWidth = (imageBounds[2] - imageBounds[0]) / width
const pixelHeight = (imageBounds[3] - imageBounds[1]) / height
const bounds = {
// GeoTIFF bbox describes the raster's outer extent. Shrink by half a pixel
// so adjacent tiles meet at pixel centers instead of overlapping.
minLon: imageBounds[0] + pixelWidth / 2,
minLat: imageBounds[1] + pixelHeight / 2,
maxLon: imageBounds[2] - pixelWidth / 2,
maxLat: imageBounds[3] - pixelHeight / 2,
}
onProgress?.({ phase: 'raster', name, index, total: paths.length })
const raster = await image.readRasters({ interleave: true, samples: [0] })
const elevation = raster instanceof Float32Array ? raster : new Float32Array(raster)
tiles.push({
path,
name,
bounds,
width,
height,
elevation,
})
onProgress?.({ phase: 'done', name, index, total: paths.length })
}
return tiles
}
3.代码太多自己上github看吧https://github.com/hzc12345678/MountainRiver3D.git。记得自己找相应的tif高程数据、json行政区数据和影像贴图,代码中没有高程数据其他都有。
6549



被折叠的 条评论
为什么被折叠?



