Jan 8

用gl的思维进行地图栅格数据绘制

Lrdcq , 2019/01/08 22:12 , 程序 , 閱讀(3417) , Via 本站原創
最近内部讨论有一个议题,是在web地图上绘制六边形栅格化的数据,量极大(10w+)。如果按照普通的在地图sdk上添加path或者shape的方式,添加10w级别的shape无论是哪家地图sdk无奈是普通版本还是webgl版本都是难以承受的,页面打开后电脑风扇就起飞了。
因此我们应该意思到,目前我们尝试的这些方案的思路出发点,可以进行转换。采用全新的思路——这里我们采用标准的gl绘制图形的思路来完成这个任务,说不定有奇效。
先看demo:http://lrdcq.com/test/mapwebgltiles/

温故

我们先看看为啥既有方案(webgl地图,shape绘制)会这么烧电脑:

1. webgl地图的shape绘制的实现方式,检查元素后发现shape确实是在canvas里绘制的。考虑到shape通用性,应该每个六边形就是往opengl的buffer中推入了7个点(6个三角形)。那么10w个六边形就是至少70万个点的超级大模型了——大部分3d游戏的同屏幕场景也不会有这么多点的。这个量显卡必然会吃不消。(主要原因)

2. 由于地图投影方式(Web Mercator)的缘故,不同位置的相同大小shape其实是完全不同的,靠近高纬度拉伸非常严重,因此相同shape的绘制也无法进行渲染复用,很难做优化。

3.  在缩放之后,明显可以注意到在大尺寸地图下,shape之间的绘制已经出现重叠的情况,大概率是line绘制引起来的,这在本来就糟糕的绘制条件下额外增加负担。

4. 我们的原始数据json不仅大,而且还是散点形式的数据,很难用。

核心的痛点还是在于渲染对象过多,数据量大。

gl思维

一般我们所说的绘制图形的思维,是我在画布上画了一个啥,画了一个啥,组合起来是啥,重叠起来是啥,最后组成一个完整的像素区域。
而开发opengl程序时的思维,是以逆向的:我这个像素,上面该是什么颜色——哦,我上面什么都没有——或者我正好在一个六边形区域内,这个六边形区域应该是什么颜色的,那我就是什么颜色,done。这样的思考方式。

那么解决我们地图栅格数据绘制的问题,我们的思维方式应该是:

1. 以像素为单位,首先看我这个像素的x,y对应的经纬度应该是多少。

2. 我这个经纬度在地图上,落到了一个六边形区域内了么?如何判断一个经纬度是否在一个六边形区域内?

2.1 这里用到一个旧方案中没有用到的特性,即我们的数据是栅格化的,我们所有的数据可以排列在一个栅格里。

2.2 假设我的栅格是矩形的,那就很好办,当前的经纬度floor(x / width),floor(y / height)就能得到当前的这个像素是在几-几的栅格里。

2.3 六边形可以做到么?当然可以:
點擊在新視窗中瀏覽此圖片
并且可以直接从矩形栅格通过运算转换而来:
點擊在新視窗中瀏覽此圖片

3. 目前已知道每一个像素是一个x-y的六边形中了,相当于目前绘制区域已经被分割成无数个六边形了。但是我们怎么知道这个六边形是否存在数据呢。

4. 我们原是数据是经纬度的散点。那我们对原始数据按2过程做预处理,我们就能预处理出一个填充好哪些格子有数据,哪些格子没数据的栅格了。提供给3过程实时运算时使用就行了。

以上就是以像素为单位gl思维下的地图栅格数据绘制方法了。明显以上绘制流程的性能瓶颈点在于:好像没有。。

1. 会对每一个像素进行一次运算和渲染,但是并没有多余的渲染(离屏渲染,重叠渲染),这应该是最小的像素处理量了。

2. 查找在哪个栅格的过程就是(floor(x / width),floor(y / height))这样的运算的变体,明显和栅格大小关系不大。

3. 栅格本身的大小和里面填充了多少数据无关,而和数据范围有关。比如如上的demo在地图上准备的栅格有这么大:
點擊在新視窗中瀏覽此圖片
而这个栅格放满的数据量是2300x1600=368w,反正肯定够用。

4. 把散点数据变成这么大的栅格不会更大么?首先我们把经纬度转换为栅格中的某个具体位置了,散点的核心数据已经不需要了,并且这个栅格只是给绘制用的,假设不需要什么特殊信息只是表示那个格子要画/不画,一个格子0/1就可以解决。这个数据反正比json是小多了。

在实际demo测试中,这个方案和原始方案的性能指标(cpu/gpu/内存/fps)上都有绝对的优势。

Code

我们把以上思维过程依次转换为代码方案吧。

总体:

1. 既然是gl方案,实现方案肯定是webgl走起了。不过demo的选形有一个特殊点,因为我们实现的gl层有可能消耗大量的gpu资源(其实没有),因此地图本身反其道而行之选择了普通地图而不是webgl版地图。

2. 既然是这种方案,旧浏览器和移动端兼容就没有的事儿了——反正不兼容。

过程1: 第一步“首先看我这个像素的x,y对应的经纬度应该是多少”就遇到困难了,由于地图投影的缘故,这个变换的纬度不能线性的计算出来,那怎么计算呢。疼讯地图sdk本身提供了Projection.fromPointToLatLng方法,可以将地图dom元素offset坐标转换为经纬度。

1. 由于疼讯地图sdk闭源的混淆挺厉害的,反正把Projection.fromPointToLatLng的实现扒出来煞费苦心,最终移植到glsl如下:
vec2 fromPointToLatLng(vec2 point) {
    return vec2((point.x - 128.0) / 0.711111111111111, (2.0 * atan(exp((point.y - 128.0) / -40.74366543152521)) - 1.57079632675) / 0.0174532925194);
}

2. 由于入参是地图dom元素offset坐标,因此还需要把当前展示区域的dom坐标uniform进shader中,坐标是线性的,可以算出对应像素的dom坐标再计算得到经纬度。
vec2 point = vec2((topRight.x - bottomLeft.x) * (vCoordinates.x + 1.0) / 2.0 + bottomLeft.x, (topRight.y - bottomLeft.y) * (vCoordinates.y + 1.0) / 2.0 + bottomLeft.y);
vec2 loc = fromPointToLatLng(point);

过程2: 矩形栅格计算到六边形栅格x-y,就全是ifelse的逻辑了,关注demo吧。

过程4: 预处理数据,需要和过程2一模一样的逻辑,不过不是在shader而是其他语言/环境。这里用js来了一份:
var getAnswer = function(now) {
  var offset = vec2(now.x  - L_standardLeft, now.y - L_standardBottom);
    var width = mod(offset.x, L_tileWidth);
    var height = mod(offset.y, L_tileHeigth);
    var org_x = floor(offset.x / L_tileWidth);
    var org_y = floor(offset.y / L_tileHeigth);

    var answer = vec2(org_x, org_y);

    if (mod(org_y, 2.0) < 1.0) {
        if (height < L_tileHeigth / 3.0) {
            if (height > width * (-2.0 * L_tileHeigth) / (3.0 * L_tileWidth) + L_tileHeigth / 3.0 && height > width * (2.0 * L_tileHeigth) / (3.0 * L_tileWidth) - L_tileHeigth / 3.0) {
            } else {
                answer.y = answer.y - 1.0;
                if (width < L_tileWidth / 2.0) {
                    answer.x = answer.x - 1.0;
                }
            }
        }
    } else {
        if (height < L_tileHeigth / 3.0) {
            if (height < width * (2.0 * L_tileHeigth) / (3.0 * L_tileWidth) && height < width * (-2.0 * L_tileHeigth) / (3.0 * L_tileWidth) + L_tileHeigth * 2.0 / 3.0) {
                answer.y = answer.y - 1.0;
            } else {
                if (width < L_tileWidth / 2.0) {
                    answer.x = answer.x - 1.0;
                }
            }
        } else {
            if (width < L_tileWidth / 2.0) {
                answer.x = answer.x - 1.0;
            }
        }
    }
    return answer;
}

过程4->3: 考虑到需要将栅格数据完整传输到片元着色器,来得最快的方法就是图片了,所以过程4的结果直接开了一个1600x2300的canvas绘制上。并且canvas输出为图片丢给过程3。图片大概这样:
點擊在新視窗中瀏覽此圖片

过程3: 既然是图片,那就简单了,直接采样就可以拿到对应像素的颜色数据,就可以继续展示逻辑了。
vec4 data = texture2D(dataImage, vec2(answer.x / 1600.0, 1.0 - answer.y / 2300.0));

其他

整个过程中还有很多可以顺带优化的点:

1. 以图片保存的栅格数据(无损png8)很小的,实际demo中原本22.4mb的json数据在图片栅格中就31kb,那不是小了一点点。测试数据中对原始数据复制40份的大数据栅格图片也就414kb。应该都是可以接受的范围了。这样的话,让后端直接运算好下发给前端栅格图片就不存在大数据同时带来的传输问题了。当然,图片储存的数据有限(一般储存的数据足够着色器展示就行),因此比如点击查看详细数据得通过别的接口支持,而接口的入参统一为栅格坐标就好了。这样前端就只有栅格的概念了,散点不关心了,轻松很多。

2. 这个方案仅限的性能瓶颈是绘制区域像素大小,如果是高清屏全屏的地图,确实也有可能有点大。不过没关系,真好由于canvas的绘制像素和dom大小是分别设置的,如果真有性能问题直接把canvas渲染大小/2就好了,性能提升4倍,画面其实就看起来糊一丢丢而已,影响并不大。

当然,这个方案也有一些根本性的问题:

1. 强依赖稳定的栅格化规则,如果栅格化规则并没那么容易通过代码表述出来。那判断是否在某个“xxx”中那一步就要做货真价实的碰撞测试了(虽然也有优化方案),做碰撞测试的话无论如何性能还是会和数据量有关了,大数据还是有问题。

2. 直接通过shader绘制的图形毛刺会非常明显,如果要做优化的话又是一大坨工作量并且性能消耗继续上升。

3. 由于核心逻辑写到shader中的,本身这个方案是一个为了极限性能体验,定制性非常强的方案,可维护性拓展性之类的问题非常大。

关键词:webgl
logo