简单的地形沙盘效果
准备数据
地形数据
这里我直接下载了mapbox的地形瓦片服务?之所以这么做:
- maptalks的地形功能也是加载的瓦片服务
- maptalks.three插件里也提供了简单的地形瓦片功能用来做简单的沙盘等效果使用
- 我不想去制作地形数据瓦片
- 没有在线直接使用mapbox的地形服务,因为怕密钥暴露,被嫖怕了
数据是杭州西湖这里的,数据层级是13层级,大概是30个瓦片这样,这里只是做测试用途,自己业务里需要:
- 自行下载对应区域的瓦片数据
- 或者自行生产地形数据瓦片
- 或者直接使用在线服务
准备等高线数据
例子里的数据大概是我三年前从地理空间数据云下载的数据,具体时间我也不记得,然后用的QGIS从下载的 tif里提取的等高线数据
怎样从地理空间数据云里下载数据和用QGIS提取等高线请自行网上查阅相关资料
因为当时做示例时是挂在在github上的,这个数据有点大,故做了压缩,github的速度你懂的,所以在示例代码里你会看到数据decode的过程 这里只是想强调数据不是加密的,对数据压缩只是为了加快数据请求速度而已,你提取等高线的数据可以直接用geojson格式,然后直接加载的
fetch('./../assets/data/hangzhoudem').then(res => res.text()).then(evadata => {
//数据解压
evadata = LZString.decompressFromBase64(evadata);
evadata = JSON.parse(evadata);
lines = evadata.map(element => {
let coordinates = element.l;
if (coordinates) {
const elevation = element.h;
const height = elevation + 45;
coordinates.forEach(c => {
c[2] = height;
});
const line = threeLayer.toLine(new maptalks.LineString(coordinates), { altitude }, getLineMaterila(height));
return line;
}
});
threeLayer.addMesh(lines);
});
加载地形瓦片数据
function addTerrainTile() {
//瓦片行列号的范围
const minx = 6826, maxx = 6830, miny = 3372, maxy = 3377;
const tiles = [];
for (let col = minx; col <= maxx; col++) {
for (let row = miny; row <= maxy; row++) {
tiles.push([col, row, 13]);
}
}
const TILESIZE = 256;
tiles.forEach(tile => {
const [x, y, z] = tile;
//cal tile bbox
const bbox = tilebelt.tileToBBOX(tile);
const texture = `./../assets/data/tile-image/${z}/${x}/${y}.png`;
// const texture = `https://a.basemaps.cartocdn.com/light_all/${z}/${x}/${y}.png`;
// const texture = `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${x}&y=${y}&z=${z}`;
const image = `./../assets/data/tile-rgb/${z}/${x}/${y}.png`;
const terrain = threeLayer.toTerrain(bbox, {
flaserBoundary: false,
bufferPixel: 0.2,
image,
texture,
imageWidth: TILESIZE,
imageHeight: TILESIZE,
altitude
}, new THREE.MeshPhongMaterial({ color: terrainColor }));
terrain.on('textureload', () => {
//hide texture
terrain._map = terrain.getObject3d().material.map;
terrain.getObject3d().material.map = null;
terrain.getObject3d().material.needsUpdate = true;
});
terrain.on('dataload', () => {
//update normal
const geometry = terrain.getObject3d().geometry;
const index = geometry.index.array;
const position = geometry.attributes.position.array;
const normal = generateNormal(index, position);
geometry.setAttribute('normal', new THREE.BufferAttribute(normal, 3));
});
threeLayer.addMesh(terrain);
terrains.push(terrain);
});
}
这里有几个注意点:
使用一个第三库 tilebelt 用来计算每个瓦片的经纬度包围盒
当地形瓦片加载结束后,自动重构了地形图形的
normal
,默认的地形的geometry是plane
,光照质感比较差flaserBoundary 参数表示是否压扁地形裙边
因为要重构normal
,开启后因为法线的原因会导致瓦片直接的缝隙显得特别大
但是如果不重构normal
默认的plane的发现光照不强烈,质感比较差,所以为了normal
的重构需要关闭这个功能
- bufferPixel 参数表示地形的大小buffer几个像素,内部会自动将其转换为gl的长度和宽度的
法向量计算函数
function generateNormal(indices, position) {
function v3Sub(out, v1, v2) {
out[0] = v1[0] - v2[0];
out[1] = v1[1] - v2[1];
out[2] = v1[2] - v2[2];
return out;
}
function v3Normalize(out, v) {
const x = v[0];
const y = v[1];
const z = v[2];
const d = Math.sqrt(x * x + y * y + z * z) || 1;
out[0] = x / d;
out[1] = y / d;
out[2] = z / d;
return out;
}
function v3Cross(out, v1, v2) {
const ax = v1[0], ay = v1[1], az = v1[2],
bx = v2[0], by = v2[1], bz = v2[2];
out[0] = ay * bz - az * by;
out[1] = az * bx - ax * bz;
out[2] = ax * by - ay * bx;
return out;
}
function v3Set(p, a, b, c) {
p[0] = a; p[1] = b; p[2] = c;
}
const p1 = [];
const p2 = [];
const p3 = [];
const v21 = [];
const v32 = [];
const n = [];
const len = indices.length;
const normals = new Float32Array(position.length);
let f = 0;
while (f < len) {
// const i1 = indices[f++] * 3;
// const i2 = indices[f++] * 3;
// const i3 = indices[f++] * 3;
// const i1 = indices[f];
// const i2 = indices[f + 1];
// const i3 = indices[f + 2];
const a = indices[f], b = indices[f + 1], c = indices[f + 2];
const i1 = a * 3, i2 = b * 3, i3 = c * 3;
v3Set(p1, position[i1], position[i1 + 1], position[i1 + 2]);
v3Set(p2, position[i2], position[i2 + 1], position[i2 + 2]);
v3Set(p3, position[i3], position[i3 + 1], position[i3 + 2]);
v3Sub(v32, p3, p2);
v3Sub(v21, p1, p2);
v3Cross(n, v32, v21);
// Already be weighted by the triangle area
for (let i = 0; i < 3; i++) {
normals[i1 + i] += n[i];
normals[i2 + i] += n[i];
normals[i3 + i] += n[i];
}
f += 3;
}
let i = 0;
const l = normals.length;
while (i < l) {
v3Set(n, normals[i], normals[i + 1], normals[i + 2]);
v3Normalize(n, n);
normals[i] = n[0] || 0;
normals[i + 1] = n[1] || 0;
normals[i + 2] = n[2] || 0;
i += 3;
}
return normals;
}
WARNING
这几个参数要求maptalks.three的版本 >=0.37.3 ,这个功能是我刚加的
加载等高线数据
function addDemLines() {
const colors = [
[0, 'white'],
[100, 'blue'],
[300, 'yellow'],
[400, 'red']
];
const ci = new colorin.ColorIn(colors);
const lineMaterialMap = {};
function getLineMaterila(height) {
const [r, g, b] = ci.getColor(height);
const color = `rgb(${r},${g},${b})`;
if (lineMaterialMap[color]) {
return lineMaterialMap[color];
}
lineMaterialMap[color] = new THREE.LineBasicMaterial({
linewidth: 1,
color,
opacity: 0.6,
transparent: true
});
return lineMaterialMap[color];
}
fetch('./../assets/data/hangzhoudem').then(res => res.text()).then(evadata => {
evadata = LZString.decompressFromBase64(evadata);
evadata = JSON.parse(evadata);
lines = evadata.map(element => {
let coordinates = element.l;
if (coordinates) {
const elevation = element.h;
const height = elevation + 45;
coordinates.forEach(c => {
c[2] = height;
});
const line = threeLayer.toLine(new maptalks.LineString(coordinates), { altitude }, getLineMaterila(height));
return line;
}
});
threeLayer.addMesh(lines);
});
}
等高线的数据比较简单,这里我使用maptalks.three加载了
- 数据里要携带海拔数据,这里我是手动赋值进去的
- 根据不同的高度赋值了不同的颜色
- 如果你使用
LineStringLayer
的话,你可以直接对LineColor
进行颜色插值的
// LineStringLayer test
function addDemLines1() {
const colors = [
[0, 'white'],
[100, 'blue'],
[300, 'yellow'],
[400, 'red']
];
fetch('./../assets/data/hangzhoudem').then(res => res.text()).then(evadata => {
evadata = LZString.decompressFromBase64(evadata);
evadata = JSON.parse(evadata);
lines = evadata.map(element => {
let coordinates = element.l;
if (coordinates) {
const elevation = element.h;
const height = elevation + 45 + altitude;
coordinates.forEach(c => {
c[2] = height;
});
const line = new maptalks.LineString(coordinates, {
symbol: {
lineColor: {
type: 'color-interpolate',
property: 'height',
stops: colors
}
},
properties: {
height
}
});
return line;
}
});
lineLayer.addGeometry(lines);
});
}
突显山顶的效果
为了突显山的效果,需要把开启ground
const sceneConfig = {
postProcess: {
enable: true,
antialias: { enable: true }
},
ground: {
enable: true,
renderPlugin: {
type: "lit"
},
symbol: {
polygonOpacity: 1,
material: {
baseColorFactor: [0.48235, 0.48235, 0.48235, 1],
hsv: [0, 0, -0.532],
roughnessFactor: 0.22,
metallicFactor: 0.58
}
}
}
};
const groupLayer = new maptalks.GroupGLLayer('group', [threeLayer, lineLayer], { sceneConfig });
groupLayer.addTo(map);
layer.addTo(map);
为了不让低海拔的地形显示出来,需要把地形瓦片和等高线等数据向地下平移一定的距离,利用ground
把低海拔的给遮住, 使只能看到一定高度的地形数据效果,这样可以是山体效果更加明显和突出
示例里把所有的图形都向下平移了90米
var lines = [];
var terrains = [];
const altitude = -90;
const terrainColor = '#0a6142';
代码层面就是调整图层或者图形的海拔而已,
山顶上插几个旗子
这个比较简单,获取山顶上几个坐标和海拔值即可,然后用图层加载这个点的数据即可
function testPoints() {
const coordinates = [
[120.01456037029766, 30.165785577748437, 380],
[120.02020512029901, 30.189324563181998, 380],
[120.03532742521247, 30.20363465918578, 400],
[120.00723025626826, 30.140722352969505, 330],
[120.01522412598683, 30.107399355979993, 500]
];
const points = coordinates.map(c => {
return new maptalks.Marker(c, {
symbol: {
markerWidth: 35,
markerHeight: 35,
markerFile: './../assets/image/poi.png'
}
})
});
layer.addGeometry(points);
}