mapbox-gl 在 2021年的v2.6.0 带来了自适应地图投影后于 mapbox 本身来说是一个很惊艳的提升,毕竟很多人苦 cesium 久已
,但是于社区来说其实带来了很多的困扰,比如 deck.gl 和其他一些基于自定义图层的可视化库;毕竟 mapbox 设计之初并没有过多的考虑第三方开发者,所以现在很多第三方库还是以墨卡托投影为主。但是关于自定义图层支持地形和球的需求呼声其实还挺多,mapbox 官方也做出了一些改变,相关讨论可以查看以下issues
并且在 2.13.0 这个版本 mapbox 官方已经支持了自定义图层在地形和球的渲染,新增了 shouldRerenderTiles
、renderToTile
方法,并且扩充了 render
和 prerender
的入参:
js
if (painter.transform.projection.name === "globe") {
const center = painter.transform.pointMerc;
prerender.call(implementation, context.gl, painter.transform.customLayerMatrix(), painter.transform.getProjection(), painter.transform.globeToMercatorMatrix(), globeToMercatorTransition(painter.transform.zoom), [center.x, center.y], painter.transform.pixelsPerMeterRatio);
} else {
prerender.call(implementation, context.gl, painter.transform.customLayerMatrix());
}
官方也提供了一个简单的示例,但是我们通过这两个示例其实也不难发现,点类型我们需要计算 ecef 顶点和普通墨卡托顶点,这样我们能通过不同的 projection
切换不同的渲染方式,虽然能实现 Globe 模式的渲染但是却并没有解决如何将点类型渲染到地形上。另外一个示例借助了renderToTile
方法,这个方法最终是将渲染结果合成在了 RTT 的代理瓦片上ProxyTile,具体细节参考mapbox地形渲染流程,这样我们只需要关注我们的渲染流程,球和地形的渲染逻辑将由 mapbox 内部进行处理,这样看起来似乎是简单了,但是我们需要注意的是我们的渲染应该落到哪张代理瓦片上,这是一个很复杂的问题。
实践
初次尝试
mapbox 关于自定义图层最简单的实例是绘制一个三角形,但是这个示例明确说了仅支持墨卡托投影,我们如何用新增的 API 实现贴球和地形呢?我们先来尝试一下。
我们用以下代码来创建一个地图和一个自定义图层,这里也用到了自定义图层新增的shouldRerenderTiles
、renderToTile
方法:
html
<script src="https://cdn.jsdelivr.net/npm/mapbox-gl/dist/mapbox-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/regl/dist/regl.js"></script>
<script type="module">
import { mat4 } from 'https://cdn.skypack.dev/gl-matrix';
mapboxgl.accessToken = ""; // eslint-disable-line
const length = 1;
function random(min, max) {
return (Math.random() * (max - min)) + min;
}
function generateColors (count) {
const data = [];
for (let i = 0; i < count; i++) {
// data.push(random(0, 1), random(0, 1), random(0, 1));
data.push(1, 0, 0);
}
return data;
}
const coordinates = [
[-180, 85.051129],
[180, 85.051129],
[180, -85.051129],
[-180, -85.051129],
];
const colors = generateColors(length * 3);
... 工具函数
class CustomLayer {
constructor () {
this.id = 'custom';
this.type = 'custom';
this.renderingMode === '3d';
this.options = {
opacity: 0.9,
};
}
onAdd(map, gl) {
this.map = map;
if (!this.regl) {
this.regl = createREGL({
gl: gl,
// extensions: ['OES_texture_float', 'OES_element_index_uint'],
attributes: {
antialias: true,
preserveDrawingBuffer: false,
}
});
let i = 0;
const len = coordinates.length;
const cornerCoords = [];
for (; i < len; i++) {
const coords = coordinates[i];
const mc = mapboxgl.MercatorCoordinate.fromLngLat([coords[0], coords[1]], coords[2]);
cornerCoords.push(mc);
}
this.tileID = {
canonical: {
x: 0,
y: 0,
z: 0,
},
wrap: 0
}
const anchor = new mapboxgl.Point(this.tileID.canonical.x, this.tileID.canonical.y)._div(1 << this.tileID.canonical.z);
this.aabb = cornerCoords.reduce((acc, coord) => {
acc.min.x = Math.min(acc.min.x, coord.x - anchor.x);
acc.min.y = Math.min(acc.min.y, coord.y - anchor.y);
acc.max.x = Math.max(acc.max.x, coord.x - anchor.x);
acc.max.y = Math.max(acc.max.y, coord.y - anchor.y);
return acc;
}, {
min: new mapboxgl.Point(Number.MAX_VALUE, Number.MAX_VALUE),
max: new mapboxgl.Point(-Number.MAX_VALUE, -Number.MAX_VALUE),
});
this.tileTransform = tileTransform(this.tileID.canonical, this.map.transform.projection);
const cs = [
[-120, 45, 0],
[120, 45, 0],
[0, -45, 0]
];
const tileCoords = cs.map((coord) => {
const projectedCoord = this.tileTransform.projection.project(coord[0], coord[1]);
return getTilePoint(this.tileTransform, projectedCoord)._round();
});
const instancePositions = new Float32Array(tileCoords.length * 3);
for (let j = 0; j < tileCoords.length; j++) {
const tileCoord = tileCoords[j];
instancePositions[j * 3] = tileCoord.x;
instancePositions[j * 3 + 1] = tileCoord.y;
instancePositions[j * 3 + 2] = 0;
}
this.drawTriangle = this.regl({
// Shaders in regl are just strings. You can use glslify or whatever you want
// to define them. No need to manually create shader objects.
frag: `precision highp float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 0.5);
}
`,
vert: `attribute vec3 a_pos;
attribute vec3 a_colors;
uniform mat4 u_matrix;
varying vec3 vColor;
void main(void) {
gl_Position = u_matrix * vec4(a_pos, 1.0);
vColor = a_colors;
}`,
// Here we define the vertex attributes for the above shader
attributes: {
a_pos: this.regl.buffer(instancePositions),
a_colors: this.regl.buffer(colors),
},
uniforms: {
u_matrix: this.regl.prop('u_matrix'),
},
// This tells regl the number of vertices to draw in this command
count: length * 3,
primitive: 'triangles',
blend: {
enable: true,
func: {
src: 'src alpha',
dst: 'one minus src alpha',
},
equation: {
rgb: 'add',
alpha: 'add',
},
color: [0, 0, 0, 0],
},
});
}
}
shouldRerenderTiles() {
// 最好只在图层变化时返回 true,因为他会清空关联的 rtt 的缓存造成性能问题
return true;
}
renderToTile (gl, tileId) {
const flag = tileOutsideImage(tileId, this.tileID, this.aabb);
if (flag) return;
const scale = tileId.z - this.tileID.canonical.z;
const matrix = mat4.create();
let size, xOffset, yOffset;
const wrap = (this.tileID.wrap - 0/*proxyTileID.wrap*/) << tileId.z;
if (scale > 0) {
size = EXTENT >> scale;
xOffset = size * ((this.tileID.canonical.x << scale) - tileId.x + wrap);
yOffset = size * ((this.tileID.canonical.y << scale) - tileId.y);
} else {
size = EXTENT << -scale;
xOffset = EXTENT * (this.tileID.canonical.x - ((tileId.x + wrap) << -scale));
yOffset = EXTENT * (this.tileID.canonical.y - (tileId.y << -scale));
}
mat4.ortho(matrix, 0, size, 0, size, 0, 1);
mat4.translate(matrix, matrix, [xOffset, yOffset, 0]);
this.drawTriangle({
u_matrix: matrix,
});
this.regl._refresh();
}
render() {
// if (this.matrix) {
// this.drawTriangle({
// u_matrix: this.matrix,
// });
// this.regl._refresh();
// }
}
}
this.map.on('load', () => {
this.map.setFog({
range: [-0.5, 2],
color: 'white',
'horizon-blend': 0.2,
});
// Add a sky layer over the horizon
this.map.addLayer({
id: 'sky',
// @ts-ignore
type: 'sky',
paint: {
// @ts-ignore
'sky-type': 'atmosphere',
'sky-atmosphere-color': 'rgba(85, 151, 210, 0.5)',
},
});
// Add terrain source, with slight exaggeration
this.map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.terrain-rgb',
tileSize: 512,
// maxzoom: 16,
});
this.map.setTerrain({ source: 'mapbox-dem', exaggeration: 2.5 });
this.map.showTerrainWireframe = false;
this.map.showTileBoundaries = true;
map.addLayer(new CustomLayer());
});
</script>
具体渲染流程如下:
我们查看一下图层渲染状态如下,我们通过Spector也能发现其实最终我们的图形其实是切割到了不同的 fbo 上渲染,我们虽然将图层绑定在 0-0-0 的 TileID 之上,但是在大层级下 ProxyTileID 是动态计算出来的 1024*1024 的瓦片,所以我们的图形各部分会渲染到期对应的 fbo之上。
实现的效果:
- 球视角:
- 地形视角:
以上方法有个局限就是我们的数据最终都是绑定 0-0-0 的瓦片之上的,我们并没有动态去计算,在大层级下瓦片投影和顶点坐标的精度会不够在大概 14 级之后就会出现渲染问题。
进阶版
这次我们考虑到直接使用自定义图层的 renderToTile 方法局限性太大,我们这次暂时先不考虑地形,有没有可能将 mapbox 中的 globeRaster 这套渲染逻辑移植出来呢,答案当然是可行的,但是工作量似乎有点大,其实核心的内容都在globe_util、drawTerrainForGlobe以及着色器globe_raster.vertex.glsl和globe_raster.fragment.glsl。这里改造(复制到外部改造)的主要内容如下:
-
globe_util中的
_createGrid
和getGridBuffers
方法:主要目的是不再自动创建VertexBuffer和IndexBuffer,我们会交给其他渲染引擎去做
ts_createGrid() { const gridWithLods = this._fillGridMeshWithLods(GLOBE_VERTEX_GRID_SIZE, GLOBE_LATITUDINAL_GRID_LOD_TABLE); this._gridSegments = gridWithLods.segments; this.vertices = gridWithLods.vertices; this._gridIndexBuffer = gridWithLods.indices; } getGridBuffers(latitudinalLod: number, withSkirts: boolean): any { return [ this.vertices, this._gridIndexBuffer, withSkirts ? this._gridSegments[latitudinalLod].withSkirts : this._gridSegments[latitudinalLod].withoutSkirts, ]; }
-
渲染层
onAdd
:在这里我们需要创建
GlobeSharedBuffers
和创建ProgramManager
并加载其他的一些资源,核心代码如下:tsimport { ProgramManager } from '@luma.gl/engine'; import type { Program } from '@luma.gl/webgl'; import { Texture2D, VertexArray, Buffer } from '@luma.gl/webgl'; import { globeMetersToEcef, calculateGlobeMercatorMatrix, globeToMercatorTransition, getGridMatrix, tileCornersToBounds, globeNormalizeECEF, globeTileBounds, getLatitudinalLod, GlobeSharedBuffers, globeUseCustomAntiAliasing, } from './utils/globe-util'; this.globeSharedBuffers = new GlobeSharedBuffers(); const image = new Image(); image.crossOrigin = '*'; image.onload = () => { this.texture = new Texture2D( gl as unknown as any, { data: image, mipmaps: true, width: image.width, height: image.height, pixelStore: { [gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL]: false, }, parameters: { [gl.TEXTURE_MAG_FILTER]: gl.LINEAR, [gl.TEXTURE_MIN_FILTER]: gl.LINEAR, [gl.TEXTURE_WRAP_S]: gl.CLAMP_TO_EDGE, [gl.TEXTURE_WRAP_T]: gl.CLAMP_TO_EDGE, }, type: gl.UNSIGNED_BYTE, format: gl.RGBA, } as any, ); }; image.src = 'https://a.basemaps.cartocdn.com/light_all/1/0/0.png'; this.programManager = new ProgramManager(gl);
-
Render:
渲染层的流程基本上和 mapbox 内部逻辑保持一致,但是引擎因为无法使用 mapbox 内部类,改为使用 luma.gl
tsif (!this.map || !this.programManager || !this.texture) return; const map = this.map as any; const tr = map.painter.transform; const useDenormalizedUpVectorScale = true; const globeMercatorMatrix = calculateGlobeMercatorMatrix(tr); const mercatorCenter: [number, number] = [mercatorXfromLng(tr.center.lng), mercatorYfromLat(tr.center.lat)]; const viewport: [number, number] = [tr.width * devicePixelRatio, tr.height * devicePixelRatio]; const globeMatrix = Float32Array.from(tr.globeMatrix); const skirtHeightValue = 0; // 获取经纬度 bounds const tileBounds = tileCornersToBounds({ x: 0, y: 0, z: 1 }); // 获取瓦片中心点位置的 lod const latitudinalLod = getLatitudinalLod(tileBounds.getCenter().lat); // 计算当前瓦片的网格矩阵 // 注意这里必要要瓦片行列号,局限性很大 const gridMatrix = getGridMatrix( { x: 0, y: 0, z: 1 }, tileBounds, latitudinalLod, tr.worldSize / tr._pixelsPerMercatorPixel, ); const normalizeMatrix = globeNormalizeECEF(globeTileBounds({ x: 0, y: 0, z: 1 })); const [buffer, indexBuffer, segments] = this.globeSharedBuffers.getGridBuffers( latitudinalLod, skirtHeightValue !== 0, ); const useCustomAntialiasing = globeUseCustomAntiAliasing(map.painter, map.painter.context, tr); const defines: { [key: string]: string } = { PROJECTION_GLOBE_VIEW: '' }; if (useCustomAntialiasing) { // 关于抗锯齿: https://github.com/mapbox/mapbox-gl-js/pull/11827 defines.CUSTOM_ANTIALIASING = ''; } this.program = this.programManager.get({ vs: vs, fs: fs, defines, modules: [], }); // const vertexAttribDivisorValue = undefined; this.program.setUniforms({ u_image0: this.texture, u_proj_matrix: tr.projMatrix, u_globe_matrix: globeMatrix, u_merc_matrix: globeMercatorMatrix, u_normalize_matrix: normalizeMatrix, u_zoom_transition: globeToMercatorTransition(tr.zoom), u_merc_center: mercatorCenter, u_frustum_tl: tr.frustumCorners.TL, u_frustum_tr: tr.frustumCorners.TR, u_frustum_br: tr.frustumCorners.BR, u_frustum_bl: tr.frustumCorners.BL, u_globe_pos: tr.globeCenterInViewSpace, u_globe_radius: tr.globeRadius, u_viewport: viewport, u_grid_matrix: gridMatrix, u_skirt_height: skirtHeightValue, u_tile_tl_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, 0, 0), u_tile_tr_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, EXTENT, 0), u_tile_br_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, EXTENT, EXTENT), u_tile_bl_up: tr.projection.upVector({ x: 0, y: 0, z: 1 }, 0, EXTENT), u_tile_up_scale: useDenormalizedUpVectorScale ? globeMetersToEcef(1) : tr.projection.upVectorScale({ x: 0, y: 0, z: 1 }, tr.center.lat, tr.worldSize).metersToTile, }); for (const segment of segments) { const vaos = segment.vaos || (segment.vaos = {}); const vao: VertexArray = vaos[this.id] || (vaos[this.id] = new VertexArray(gl, { attributes: { a_pos: new Buffer(gl, { data: buffer.int16, target: gl.ARRAY_BUFFER, usage: gl.STATIC_DRAW, accessor: { size: 2, stride: 4, type: gl.SHORT, offset: segment.vertexOffset + (4 * (0)), }, }), }, elements: new Buffer(gl, { data: indexBuffer.uint8, target: gl.ELEMENT_ARRAY_BUFFER, usage: gl.DYNAMIC_DRAW, // accessor: { // type: gl.UNSIGNED_SHORT, // size: 3, // stride: 6, // }, }), program: this.program, })); this.program.draw({ vertexArray: vao, drawMode: gl.TRIANGLES, vertexCount: segment.primitiveLength * 3, indexType: gl.UNSIGNED_SHORT, offset: segment.primitiveOffset * 3 * 2, isIndexed: true, }); }
-
顶点着色器
这里我们主要关于顶点着色器,主流程中首先根据
u_grid_matrix
取出的三个值(分别是 3*3 矩阵的最后一列的瓦片数量,瓦片列号 x、行号 y)和瓦片坐标a_pos
计算latLng
坐标;再通过latLng坐标反算mercator坐标并根据行列号反算 uv,然后再计算 ecef 坐标;得到 ecef 坐标后再通过u_globe_matrix * vec4(globe_pos, 1.0)
计算globe_world_pos
。glsl#define EPSILON 0.0000001 #define PI 3.141592653589793 #define EXTENT 8192.0 #define HALF_PI PI / 2.0 #define QUARTER_PI PI / 4.0 #define RAD_TO_DEG 180.0 / PI #define DEG_TO_RAD PI / 180.0 #define GLOBE_RADIUS EXTENT / PI / 2.0 uniform mat4 u_proj_matrix; uniform mat4 u_normalize_matrix; uniform mat4 u_globe_matrix; uniform mat4 u_merc_matrix; uniform float u_zoom_transition; uniform vec2 u_merc_center; uniform mat3 u_grid_matrix; uniform float u_skirt_height; uniform float u_tile_up_scale; #ifdef PROJECTION_GLOBE_VIEW uniform vec3 u_tile_tl_up; uniform vec3 u_tile_tr_up; uniform vec3 u_tile_br_up; uniform vec3 u_tile_bl_up; vec3 elevationVector(vec2 pos) { return vec3(0, 0, 1); } #else vec3 elevationVector(vec2 pos) { return vec3(0, 0, 1); } #endif attribute vec2 a_pos; // .xy - grid coords, .z - 1 - skirt, 0 - grid varying vec2 v_pos0; ...其他工具函数 void main() { // The 3rd row of u_grid_matrix is only used as a spare space to // pass the following 3 uniforms to avoid explicitly introducing new ones. float tiles = u_grid_matrix[0][2]; float idx = u_grid_matrix[1][2]; float idy = u_grid_matrix[2][2]; vec3 decomposed_pos_and_skirt = decomposeToPosAndSkirt(a_pos); vec3 latLng = u_grid_matrix * vec3(decomposed_pos_and_skirt.xy, 1.0); float mercatorY = mercatorYfromLat(latLng[0]); float uvY = mercatorY * tiles - idy; float mercatorX = mercatorXfromLng(latLng[1]); float uvX = mercatorX * tiles - idx; vec3 globe_pos = latLngToECEF(latLng.xy); vec2 merc_pos = vec2(mercatorX, mercatorY); vec2 uv = vec2(uvX, uvY); v_pos0 = uv; vec2 tile_pos = uv * EXTENT; // Used for poles and skirts vec3 globe_derived_up_vector = normalize(globe_pos) * u_tile_up_scale; vec3 up_vector = elevationVector(tile_pos); float height = 0.0; globe_pos += up_vector * height; vec4 globe_world_pos = u_globe_matrix * vec4(globe_pos, 1.0); vec4 merc_world_pos = vec4(0.0); if (u_zoom_transition > 0.0) { merc_world_pos = vec4(merc_pos, height - u_skirt_height * decomposed_pos_and_skirt.z, 1.0); merc_world_pos.xy -= u_merc_center; merc_world_pos.x = wrap(merc_world_pos.x, -0.5, 0.5); merc_world_pos = u_merc_matrix * merc_world_pos; } vec4 interpolated_pos = vec4(mix(globe_world_pos.xyz, merc_world_pos.xyz, u_zoom_transition), 1.0); gl_Position = u_proj_matrix * interpolated_pos; }
渲染结果如下:
通过以上代码我们也能看到 TileID
的影子,其实在 js 逻辑中我们也完全可以替换掉,只是需要在计算 GlobeSharedBuffers
的时候直接得到 ecef 坐标,但是这样我们每张瓦片都需要重新计算,顶点数据无法复用,这也是 mapbox 为什么是在着色器中反算 ecef 坐标的缘故,优化无处不在。
总结
其实通过以上尝试我们也能感受到目前自定义图层想完美的和 mapbox 的地形和球融合其实还没有很好的办法,我们期待的当然是我们只需要在自定义图层做渲染不管是 ecef 坐标和墨卡托坐标,mapbox 能自动为我们处理地形贴图和球面贴图;但是目前来看路还很长,短时间内似乎没有希望,当然 mapbox 也在积极改进。
我们大部分人在使用 mapbox 的自定义图层实际上是背离了 mapbox 的初衷,其实也背离了 mapbox-gl 渲染的基础-瓦片,我们在翻阅 mapbox-gl 源码也能看到除了特殊的 background 图层,其他的各类图层都是依托 Tile
这个核心来设计的,包括我们使用 GeoJSON 数据也在内部转换为了 VT。如果不从瓦片的概念去考虑我们其实很难和 mapbox 的渲染流程结合在一起,但是如果都从瓦片的角度考虑,很多场景我们就没法适用,这个暂时是个无解的问题。把代码搬出来在外部结合自定义图层也不是很好的办法,一个是 mapbox 变更会很快,我们不可能经常性的去同步代码;另一个是我们也没办法与 mapbox 的 style 概念融合,当然也有性能问题。
以上~。