地形
这篇文章我们来介绍下maptalks里的地形功能和一些高级玩法
当前webgis里的地形的现状
常见格式
目前开源社区里地形的格式主要分为:
- mapbox为代表的地形颜色瓦片,把高程数据编码成颜色的瓦片,mapbox格式的地形的三角化是在前端里动态生成的, 且可以调节三角网的精细度
- cesium为代表的 terrain格式,将高程数据构造成三角网,cesium的格式,前端无需进行三角网的计算, 直接就可以用,但是无法动态的去调节三角网的精细度,所以显得很糙
- 其他的一些格式:arcgis,mapzen等格式,因为这些格式大家不常用,故我也没有仔细的研究
制作工具
目前社区我还没有发现免费好用的地形制作工具,不管是mapbox的还是cesium的,各有各的问题,当然也可能是有好用的工具而是我 没有发现,可能有些商业的工具好用因为我没有试过不知道而已。
这篇文章里我会介绍用QGIS来制作地形瓦片让maptalks加载
maptalks里的地形
maptalks里目前也支持了地形系统,因为maptalks是后起之秀,所以在地形格式上也只能采用目前社区的常见的格式
- mapbox
- cesium
- 天地图(本质也是ceiums格式的,自己在格式上稍微动了下手脚而已)
maptalks默认支持这三种格式的地形数据:
mapbox
const map = new maptalks.Map("map", {
// "center": [119.09557457, 30.14442343, 339.73126220703125], "zoom": 11.856275713521464, "pitch": 61.80000000000011, "bearing": -64.07337236948052,
center: [108.95986733, 34.21997952, 430.3062438964844],
zoom: 12.698416480987284,
pitch: 0,
bearing: 1.8437368186266667,
// cameraInfiniteFar: true,
// heightFactor: 4.2,
zoomControl: true,
// baseLayer: new maptalks.TileLayer('base', {
// urlTemplate: "https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}",
// subdomains: ["a", "b", "c", "d"],
// })
});
const sceneConfig = {
postProcess: {
enable: true,
antialias: {
enable: true,
},
},
};
const colors4 = [
[0, "#F0F9E9"],
[200, "#D7EFD1"],
[400, "#A6DCB6"],
[650, "#8FD4BD"],
[880, "#67C1CB"],
[1100, "#3C9FC8"],
[1300, "#1171B1"],
[1450, "#085799"],
[1600, "#084586"],
];
const terrain = {
type: "mapbox",
// tileSize: 256,
maxAvailableZoom: 14,
requireSkuToken: false,
urlTemplate: "mapbox terrain server url",
subdomains: ["a", "b", "c", "d"],
colors: colors4,
exaggeration: 4,
};
const group = new maptalks.GroupGLLayer("group", [], {
terrain,
});
group.addTo(map);
// map.setView({
// "center": [120.06864866, 30.12911788, 430.3062438964844], "zoom": 12.814714048170666, "pitch": 56.45000000000009, "bearing": 2.7148818684635216
// })
map.setView({
center: [118.25079334, 30.1210681, 430.3062438964844],
zoom: 11.892086520379873,
pitch: 66.64999999999992,
bearing: 112.90705084326646,
});
天地图
WARNING
天地图地形需要设置地图投影为 EPSG:4326
map = new maptalks.Map("map", {
center: [114.3404041441181, 30.548730054693106],
zoom: 10,
spatialReference: {
projection: "EPSG:4326",
},
});
const sceneConfig = {
postProcess: {
enable: true,
antialias: {
enable: true,
},
},
};
const skinLayers = [
// baseLayer
];
const colors4 = [
[0, "#F0F9E9"],
[200, "#D7EFD1"],
[400, "#A6DCB6"],
[650, "#8FD4BD"],
[880, "#67C1CB"],
[1100, "#3C9FC8"],
[1300, "#1171B1"],
[1450, "#085799"],
[1600, "#084586"],
];
const terrain = {
type: "tianditu",
// tileSize: 256,
// terrainWidth: 65,
shader: "lit",
maxAvailableZoom: 12,
// tileSystem: [1, -1, -180, 90],
urlTemplate:
"https://t{s}.tianditu.gov.cn/mapservice/swdx?T=elv_c&tk=your key&x={x}&y={y}&l={z}",
subdomains: ["1", "2", "3", "4", "5"],
colors: colors4,
exaggeration: 4,
material: {
baseColorFactor: [1, 1, 1, 1],
outputSRGB: 1,
roughnessFactor: 0.69,
metallicFactor: 0,
},
};
const group = new maptalks.GroupGLLayer("group", skinLayers, {
terrain,
});
group.addTo(map);
map.setView({
center: [118.25079334, 30.1210681, 430.3062438964844],
zoom: 9.892086520379873,
pitch: 66.64999999999992,
bearing: 112.90705084326646,
});
cesium/cesiumlab等cesium系
WARNING
cesium地形需要设置地图投影为 EPSG:4326
map = new maptalks.Map("map", {
center: [114.3404041441181, 30.548730054693106],
zoom: 10,
spatialReference: {
projection: "EPSG:4326",
},
});
const sceneConfig = {
postProcess: {
enable: true,
antialias: {
enable: true,
},
},
};
const skinLayers = [
// baseLayer
];
const colors4 = [
[0, "#F0F9E9"],
[200, "#D7EFD1"],
[400, "#A6DCB6"],
[650, "#8FD4BD"],
[880, "#67C1CB"],
[1100, "#3C9FC8"],
[1300, "#1171B1"],
[1450, "#085799"],
[1600, "#084586"],
];
const terrain = {
// tileSystem: [1, 1, -180, -90],
// zoomOffset: -1,
maxAvailableZoom: 14,
type: "cesium-ion", //or cisium 本地部署的服务就cesium
// terrainWidth: 65,
accessToken: "your token",
urlTemplate:
"https://assets.ion.cesium.com/asset_depot/1/CesiumWorldTerrain/v1.2/{z}/{x}/{y}.terrain",
shader: "lit",
colors: colors6,
exaggeration: 4,
};
const group = new maptalks.GroupGLLayer("group", skinLayers, {
terrain,
});
group.addTo(map);
map.setView({
center: [118.25079334, 30.1210681, 430.3062438964844],
zoom: 11.892086520379873,
pitch: 66.64999999999992,
bearing: 112.90705084326646,
});
使用感受
通过我的使用感受发现还是mapbox的地形格式使用体验最好,天地图和cesium的地形格式精度太低了,比较糙.
所以推荐使用mapbox的地形
tileclip插件
maptalks.tileclip 作为一个瓦片处理工具插件了, 为了方便大家接入其他格式的地形提供了常见的地形服务的转码工作,待会我们演示地形上皮肤和地形数据的剪裁等都会 用到它的,故这里特地介绍下它
地形自定义
WARNING
该功能要求maptalks-gl
version>=0.112.0
目前maptalks内置了mapbox
,天地图
,ceisum
的数据解码,但是用户侧仍然希望加载一些其他格式的地形数据 (例如mapzen
的地形瓦片),针对这个需求我们将会:
- 地形支持用户自定义加载瓦片和地形解码
- 针对用户自定义返回的数据格式将支持mapbox rgb tile(这个将是内部支持自定义的唯一格式)
- 任何其他任何格式的地形数据需要用户侧把数据转换成mapbox rgb tile
- 针对自定义地形格式是唯一的可以理解成: maptalks仅仅支持
GeoJSON
格式的,其他的格式(kml/osm/gpx等)请转换成GeoJSON
架构图
mapbox 地形数据编码函数
export function encodeMapBox(height: number, out?: [number, number, number]) {
const value = Math.floor((height + 10000) * 10);
const r = value >> 16;
const g = (value >> 8) & 0x0000ff;
const b = value & 0x0000ff;
if (out) {
out[0] = r;
out[1] = g;
out[2] = b;
return out;
} else {
return [r, g, b];
}
}
任何其他的地形格式需要你解码后再编码成mapbox格式的,你会用到这个函数的
自定义套路
和TileLayer
一样,地形里也提供了loadTileBitmap
自定义方法,方面用户对地形来进行各种自定义需求。
你可以在loadTileBitmap
里进行:
- 地形数据的请求/取消
- 地形数据的解码
- 地形数据的重新编码
- 地形噪点处理等
- 地形瓦片的剪裁等
- ...
要求:
- 返回的对象必须是
ImageBitMap
对象 - 地形数据编码必须是
mapbox
格式的,mapbox
地形编码请参考上面的代码, - 任何其他非
mapbox
编码的格式自己编码成mapbox
的格式返回给地形调度系统即可 - 理论上任何其他格式的地形数据都可以转换成
mapbox
的
group.on("terrainlayercreated", (e) => {
const terrainLayer = group.getTerrainLayer();
console.log(terrainLayer);
terrainLayer.getRenderer().loadTileBitmap = function (
url,
tile,
callback,
options,
) {
console.log(url);
//do some things
callback(null, imagebitmap);
};
});
tileclip里的地形编码
maptalks.tileclip 作为一个瓦片处理工具插件了, 为了方便大家接入其他格式的地形提供了常见的地形服务的转码工作,包括:
group.on("terrainlayercreated", (e) => {
const terrainLayer = group.getTerrainLayer();
console.log(terrainLayer);
terrainLayer.getRenderer().loadTileBitmap = function (
url,
tile,
callback,
options,
) {
console.log(url);
tileActor
.encodeTerrainTile({
url: maptalks.Util.getAbsoluteURL(url),
terrainType: "mapzen", //arcgis qgis-gray etc
// timeout: 5000
})
.then((imagebitmap) => {
callback(null, imagebitmap);
})
.catch((error) => {
//do some things
console.error(error);
});
};
});
如果你的地形数据编码是自定义的,那么就需要你自己在业务自行解码地形数据,然后从新编码成mapbox
格式的
QGIS灰度图
maptalks.tileclip 里也提供了GQIS
灰度图灰度图 的重编码功能,这里我将着重介绍下它
至于mapzen
和arcgis
的,因为数据格式特定的而且是在线服务,直接调用即可,这里不做介绍了, 请查看 maptalks.tileclip的文档
动机
有好多群友遇到制作mapbox
地形服务没有好的工具,制作cesium
地形的工具可能要收费等,故我捣鼓了下看看能否 把 QGIS
里简单的灰度图也接入进去,经过尝试证明是可行的
制作
WARNING
注意我这里用的数据集是墨卡托投影的(EPSG:3857),有原始数据是 EPSG:32649转换来的的, 至于你的数据如果不是常规的投影的自己进行转换(转成EPSG:4326/EPSG:3857啥的)即可
- 将我们的地形数据(dem)拖到
GQIS
即可
WARNING
切记不要做任何修改,GQIS默认会自动根据地形数据里最小值和最大值进行黑白着色的
- 记录地形数据的最小值和最大值
图层属性里会显示的,待会我们进行地形的解码是会用到
- 切片
将图层切成普通的瓦片即可,这个是个漫长的过程,和你的数据覆盖范围和层级有关,一般我们可以先尝试切个0-14层级 这样,在地形测试中确认没有问题后,我们再切全量的层级的,以免出错了浪费大量的时间在切片上
WARNING
注意我这里采用的墨卡托的常见的互联网的切片规则(Google的那一套), 如果你需要其他的切片规则(比如EPSG:4326啥的)请自行设置切片规则
- 加载灰度图服务
group.on("terrainlayercreated", (e) => {
const terrainLayer = group.getTerrainLayer();
console.log(terrainLayer);
let min = 210,
max = 1980;
terrainLayer.getRenderer().loadTileBitmap = function (
url,
tile,
callback,
options,
) {
console.log(url);
tileActor
.encodeTerrainTile({
url: maptalks.Util.getAbsoluteURL(url),
terrainType: "qgis-gray",
minHeight: min,
maxHeight: max,
// timeout: 5000
})
.then((imagebitmap) => {
callback(null, imagebitmap);
})
.catch((error) => {
//do some things
console.error(error);
callback(null, maptalks.getBlankTile());
});
};
});
适用对象
- 如果你使用在线的地形服务即可满足你的需求,你不需要这些
- 如果你是有自己的dem数据想发布成地形瓦片服务的,就可以使用这个方式了,一般是有一个市/县啥的自己的数据
价值和意义
简化了我们制作地形瓦片的难度和成本,再也不用去找各种地形制作工具了,GQIS
足够了。
警告
WARNING
目前我测试下来没有发现问题,如果你使用过程中发现有问题请给 maptalks.tileclip 插件报告issue
地形瓦片作为皮肤并配色
maptalks.tileclip 也提供了地形瓦片的配色功能:colorTerrainTile
这样我们就可以把地形瓦片进行配色后作为皮肤加到地形了,从而可以做出一些精美的山河图效果啥的
const baseLayer = new maptalks.TileLayer("base", {
// debug: true,
urlTemplate:
"https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer/tile/{z}/{y}/{x}",
subdomains: ["a", "b", "c", "d"],
zIndex: -1,
// bufferPixel: 1
});
const colors = [
[0, "rgb(246,251,255)"],
[369, "rgb(224,234,244)"],
[738, "rgb(198,219,240)"],
[1107, "rgb(157,202,225)"],
[1476, "rgb(106,175,214)"],
[1845, "rgb(66,146,197)"],
[2214, "rgb(33,114,180)"],
[2583, "rgb(11,80,157)"],
[2952, "rgb(5,49,112)"],
[3322, "rgb(5,49,112)"],
];
baseLayer.on("renderercreate", function (e) {
//load tile image
// img(Image): an Image object
// url(String): the url of the tile
e.renderer.loadTileBitmap = function (url, tile, callback) {
// console.log(url);
tileActor
.encodeTerrainTile({
url: maptalks.Util.getAbsoluteURL(url),
terrainType: "arcgis",
// timeout: 5000
})
.then((imagebitmap) => {
tileActor
.colorTerrainTile({
tile: imagebitmap,
colors,
})
.then((image) => {
callback(image);
})
.catch((error) => {
console.error(error);
});
})
.catch((error) => {
//do some things
console.error(error);
});
};
});
皮肤瓦片剪裁
有时我们需要皮肤瓦片只显示特定范围的内容,这时我们就可以对皮肤进行剪裁:clipTile
const baseLayer = new maptalks.TileLayer("base", {
// debug: true,
urlTemplate:
"https://webst01.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
// urlTemplate: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
subdomains: ["a", "b", "c", "d"],
// bufferPixel: 1
});
baseLayer.on("renderercreate", function (e) {
e.renderer.loadTileBitmap = function (url, tile, callback) {
const promise = tileActor.getTile({
url: maptalks.Util.getAbsoluteURL(url),
// mosaicSize: tile.z > 10 ? 2 : 0
// gaussianBlurRadius: 4,
// filter:'blur(4px)'
// url: 'https://khms3.google.com/kh/v=995?x=52&y=27&z=6',
// timeout: 2000
});
promise
.then((imagebitmap) => {
tileActor
.clipTile({
tile: imagebitmap,
tileBBOX: baseLayer._getTileBBox(tile),
projection: baseLayer.getProjection().code,
maskId,
})
.then((image) => {
callback(image);
})
.catch((error) => {
console.error(error);
});
})
.catch((error) => {
//do some things
console.error(error);
});
};
});
单张图片作为皮肤添加到地形
maptalks.tileclip 里也提供了将单张图片自动切图的功能,即可以用TileLayer来加载单张图片 getImageTile
const baseLayer = new maptalks.TileLayer("base", {
// debug: true,
urlTemplate:
"https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer/tile/{z}/{y}/{x}",
subdomains: ["a", "b", "c", "d"],
zIndex: -1,
// bufferPixel: 1
});
const colors = [
[0, "rgb(246,251,255)"],
[369, "rgb(224,234,244)"],
[738, "rgb(198,219,240)"],
[1107, "rgb(157,202,225)"],
[1476, "rgb(106,175,214)"],
[1845, "rgb(66,146,197)"],
[2214, "rgb(33,114,180)"],
[2583, "rgb(11,80,157)"],
[2952, "rgb(5,49,112)"],
[3322, "rgb(5,49,112)"],
];
baseLayer.on("renderercreate", function (e) {
//load tile image
// img(Image): an Image object
// url(String): the url of the tile
e.renderer.loadTileBitmap = function (url, tile, callback) {
tileActor
.getImageTile({
imageId,
projection: baseLayer.getProjection().code,
tileSize: baseLayer.getTileSize().width,
tileBBOX: baseLayer._getTileBBox(tile),
})
.then((imagebitmap) => {
// console.log(imagebitmap);
callback(imagebitmap);
})
.catch((error) => {
//do some things
console.error(error);
});
};
});
WARNING
在注入一张图片资源时,imageBBOX
参数要保持和图片的投影一致,比如图片是墨卡托的,那么就采用墨卡托坐标的范围
地形瓦片的剪裁
不仅仅普通的瓦片图层可以剪裁,地形瓦片我们同样可以剪裁clipTile
group.on("terrainlayercreated", (e) => {
const terrainLayer = group.getTerrainLayer();
terrainLayer.getRenderer().loadTileBitmap = function (
url,
tile,
callback,
options,
) {
// console.log(url);
tileActor
.encodeTerrainTile({
url: maptalks.Util.getAbsoluteURL(url),
terrainType: "arcgis",
// timeout: 5000
})
.then((imagebitmap) => {
tileActor
.clipTile({
tile: imagebitmap,
tileBBOX: terrainLayer._getTileBBox(tile),
projection: terrainLayer.getProjection().code,
tileSize: terrainLayer.getTileSize().width,
maskId,
})
.then((image) => {
callback(null, image);
})
.catch((error) => {
//do some things
console.error(error);
});
})
.catch((error) => {
//do some things
console.error(error);
});
};
});
地形瓦片和皮肤瓦片同时剪裁
当地形和皮肤瓦片同时剪裁时我们就可以做出常见的沙盘效果了
选择哪个地形数据?
推荐使用使用arcgis/mapzen的地形数据,理由:
- cesium地形精度太低了,效果不好
- mapbox的数据服务要收费的,所以选择arcgis/mapzen来代替
地形瓦片加载慢?
arcgis/mapzen的在线服务比较慢,解决的方法有:
- 离线arcgis/mapzen 服务的数据,自己托管部署
- tileclip 瓦片加载时可以配置
indexedDBCache
选项的,可以用indexedDB来缓存地形瓦片的,因为地形瓦片 数据一般不会频繁更新的,也是一个解决问题的方式,当然自己离线是最好的选择的
group.on("terrainlayercreated", (e) => {
const terrainLayer = group.getTerrainLayer();
terrainLayer.getRenderer().loadTileBitmap = function (
url,
tile,
callback,
options,
) {
// console.log(url);
tileActor
.encodeTerrainTile({
url: maptalks.Util.getAbsoluteURL(url),
terrainType: "arcgis",
indexedDBCache: true,
// timeout: 5000
})
.then((imagebitmap) => {
tileActor
.clipTile({
tile: imagebitmap,
tileBBOX: terrainLayer._getTileBBox(tile),
projection: terrainLayer.getProjection().code,
tileSize: terrainLayer.getTileSize().width,
maskId,
})
.then((image) => {
callback(null, image);
})
.catch((error) => {
//do some things
console.error(error);
});
})
.catch((error) => {
//do some things
console.error(error);
});
};
});