介绍
最近圈子里有好几个朋友在问“带厚度的地理区块如何实现?如何给立体的区块添加边界线样式?” 高德地图本身提供了Loca.PolygonLayer立体面API,但翻阅开发文档后发现可配置的样式有限,为满足高度定制的需求,还是尝试实现了一下。
需求说明
基于GIS数据创建看高度定制化的立体区块:
1.传入标准的GeoJSON数据即可自动生成立体区块;
2.支持创建一个到多个多边形区块,每个区块相互独立,可自由定制高度;
3.支持可自由配置材质和颜色,比如地形、冰块、金属等;
4.支持可配置的边界线,可以调整线宽、颜色、材质、动态效果。
实现思路
1.先分析单个立体区块是怎么实现的,只要实现我们能够看得到的部分,那么把模型拆成顶部平面、侧面墙体、顶部边界线3大块分别实现即可。
2.顶部平面的实现,先用坐标数据绘制一个形状THREE.Shape,再使用Shape生成多边形平面THREE.ShapeGeometry,如下图可以看到这是一个被三角剖分的平面,三角面是模型的最小面单位。
3.接着给面添加材质THREE.MeshPhongMaterial
,选择这种材质的原因是可支持光照后光泽和高光效果,也支持普通纹理、法线纹理、凹凸纹理,方便做出较多中质感效果。
4.绘制侧边墙体的实现方案在之前的文章中讲过了,这里就不赘述,有兴趣看看文末链接《在高德地图中进行THREE开发-边界墙图层》。
5.绘制顶部边界线这块遇到点麻烦,原本使用了THREE.Line做绘制,这个Line本身是没有宽度的,无论如何设置width,最终展示只有1像素的线。事实上我们想要的不是线,而是平行于地面的可调节宽度的带状几何体,这里用到了第三方THREE类 meshline
6.基本模型创建完毕,给顶部的边界线配置图片材质,通过调整材质的offset.x值创造动画效果。
代码实现
1.提前准备好GeoJSON数据,文末提供了链接。
//数据示例
{
"features": [
{
"type": "Feature",
"properties": {
"name": "广东省",
"height": 1e5,
"center": [
113.47219628177336,
22.80488376361114
]
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
109.77970254852386,
21.398842621700037
],
[
109.77971427545347,
21.61849239587771
],
[
109.86746375606249,
21.662234196126587
]...
]
]
}
}
]
}
2.并使用高德地图提供的map.customCoords
转成当前Three.js直角坐标系上的坐标数据
/**
* @description 转换地理坐标数据为three坐标数据
*/
initData (geoJSON) {
const { features } = geoJSON
features.forEach(item => {
item.geometry.coordinates.forEach(v => {
const target = v[0][0] instanceof Array ? v[0] : v
const path = this.customCoords.lngLatsToCoords(target)
this._paths.push(path) //缓存起来,供多处使用
})
})
}
3.绘制顶部平面
/**
*@description绘制单个区块
*@param{Array} path区块边界数据
*@param{Object} option配置项
*@param{Number} option.height区块高度
*@param{String} option.name区块名称
*/
drawOneArea (path, { height, name }) {
const { scene } = this
const shape = new THREE.Shape()
path.forEach(([x, y], index) => {
if (index === 0) {
shape.moveTo(x, y)
} else {
shape.lineTo(x, y)
}
})
// 顶部面
const geometry = new THREE.ShapeGeometry(shape)
// 使用自定义材质,创建模型对象
const mesh = new THREE.Mesh(geometry, this.getMaterial())
// 调整模型海拔高度
mesh.position.z = height || 0
// 加入到场景
scene.add(mesh)
}
4.绘制侧面
/**
* @description 创建单个区块侧面
* @param {Array} path 区块边界数据
* @param {Object} option 配置项
* @param {Number} option.height 区块高度
* @param {String} option.name 区块名称
*/
drawSide (path, { height = 0, name }) {
if (height <= 0) {
return
}
const arr = path
// 保持闭合路线
if (arr[0].toString() !== arr[arr.length - 1].toString()) {
arr.push(arr[0])
}
const vec3List = [] // 顶点数组
let faceList = [] // 三角面数组
let faceVertexUvs = [] // 面的UV层队列,用于纹理和几何信息映射
const t0 = [0, 0]
const t1 = [1, 0]
const t2 = [1, 1]
const t3 = [0, 1]
for (let i = 0; i < arr.length; i++) {
const [x1, y1] = arr[i]
vec3List.push([x1, y1, 0])
vec3List.push([x1, y1, height])
}
for (let i = 0; i < vec3List.length - 2; i++) {
if (i % 2 === 0) {
// 下三角
faceList = [...faceList, ...vec3List[i], ...vec3List[i + 2], ...vec3List[i + 1]]
// UV
faceVertexUvs = [...faceVertexUvs, ...t0, ...t1, ...t3]
} else {
// 上三角
faceList = [...faceList, ...vec3List[i], ...vec3List[i + 1], ...vec3List[i + 2]]
// UV
faceVertexUvs = [...faceVertexUvs, ...t3, ...t1, ...t2]
}
}
const geometry = new THREE.BufferGeometry()
// 顶点三角面
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(faceList), 3))
// UV面
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(faceVertexUvs), 2))
const material = new THREE.MeshBasicMaterial({
// color: 'rgb(5,38,56)',
side: THREE.DoubleSide,
transparent: true,
depthWrite: true
// needsUpdate: true,
})
if (this._conf.sideMap) {
const texture = new THREE.TextureLoader().load(this._conf.sideMap, () => {}, null, () => {
console.error('sideMap load error')
})
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.offset.set(0, 1)
material.map = texture
}
const sideMesh = new THREE.Mesh(geometry, material)
this.scene.add(sideMesh)
}
5.绘制顶部边界线
/**
*@description创建单个区块的高亮边界线
*@param{Array} path区块边界数据
*@param{Object} option配置项
*@param{Number} option.height区块高度
*@param{String} option.name区块名称
*/
drawBorder (path, { height, name }) {
const points = path.map(([x, y]) => {
return new THREE.Vector3(x, y, height * 1.01)
})
const line = new MeshLineGeometry()
line.setPoints(points)
const mesh = new THREE.Mesh(line, this.getBorderMaterial())
this.scene.add(mesh)
}
// 获取边界线材质
getBorderMaterial () {
if (this._borderMaterial == null) {
let texture
const { borderWidth, borderColor, borderMap } = this._conf
// texture.repeat.set(0.1, 0.1)
const material = new MeshLineMaterial({
lineWidth: borderWidth,
sizeAttenuation: 1,
useMap: borderMap ? 1 : 0,
opacity: 1,
transparent: false
})
if (borderMap) {
// 有材质,则使用材质
texture = new THREE.TextureLoader().load(this._conf.borderMap, () => {}, null, () => {
console.error('borderMap load error')
})
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
material.map = texture
} else {
// 无材质,直接填色
material.color = new THREE.Color(borderColor)
}
this._borderMaterial = material
}
return this._borderMaterial
}
6.逐帧更新边界线材质的offset.x ,实现动画效果
/**
* @private
* @description 逐帧动画更新内容
*/
update () {
if (this._borderMaterial) {
this._borderMaterial.uniforms.offset.value.x += 0.005
}
}
7.做组件封装,增加一些配置项,这个根据个人需求而定。
/**
* 创建一个实例
* @param {Object} config
* @param {GeoJSON} config.data 显示区域的坐标集
* @param {Number} config.opacity 图层透明度,支持(0,1)浮点数,默认值1.0
* @param {Number} config.altitude 图层海拔高度,默认值0.0
* @param {String} config.topMap 顶部纹理路径,默认值'./static/texture/rock1.jpg'
* @param {String} config.topMapRepeat 顶部纹理平铺UV配置,默认值 [0.0001, 0.0001]
* @param {String} config.topMapNormal 顶部法向量纹理路径,默认值'./static/texture/rock1-normal.jpg'
* @param {String} config.topColor 顶部颜色,可以与topMap叠加,默认值#0d8ce7
* @param {String} config.sideMap 侧面纹理路径,默认值'./static/texture/texture_cake_1.png'
* @param {String} config.borderColor 描边纹理颜色,与borderMap互斥,默认值 '#10ecda'
* @param {String} config.borderWidth 描边宽度,默认值300
* @param {String} config.borderMap 描边纹理路径,默认值 undefined
* @param {Boolean} config.animate 是否支持边界线动画,指定了borderMap才会生效,默认值 false
*/
constructor (config) {
const conf = {
data: null,
opacity: 1.0,
altitude: 0.0,
animate: false,
speed: 2,
topMap: './static/texture/rock1.jpg',
topMapNormal: './static/texture/rock1-normal.jpg',
topColor: '#0d8ce7',
topMapRepeat: [0.0001, 0.0001],
sideMap: './static/texture/texture_cake_1.png',
borderColor: '#10ecda',
borderWidth: 300,
borderMap: undefined,
// borderMap: './static/texture/cake_border1.png',
...config
}...
还能做哪些优化
1.活学活用,立体区块和建筑高度模型是同一个实现原理。
立体区块的本质其实还是1个简化的挤压缓冲几何体(ExtrudeGeometry),保留了顶部和侧面。只要有场景,它可以是数据区块、行政区块,也可以是建筑高度模型,因此我们只要保留同样的建模方法,再更换顶部和侧边的材质就可以得到一大块区域的配角建筑白模(样子还过得去,能够充实场景且可视性不会受高德本身地图缩放级别限制)
2.生成真实地形
其实最终希望实现的是这样的,顶部材质能够与真实地形高度一致,像一块蛋糕(CakeLayer),有地形高低起伏的外观,有山有水,而不仅仅是法线材质做出来的凹凸效果。
彩蛋
针对“生成真实地形”这个需求我还专门去问了GPT-3,它给我的答复是这样的:
初次看到这个答案的时候我是嗤之以鼻的,根据chat-GPT总是一本正经胡说八道的特性,我觉得它肯定是编造了不存在的类忽悠我。接着我去搜了关键词“THREE.TerrainLoader”,居然真的在github上找到了相关的代码和技术文章,算是提供了思路吧。给AI小助手点赞,了不起了不起。 github.com/sermonis/th…
参考链接
在高德地图中进行THREE开发-边界墙图层
THREE.Meshline
中国geojson数据