共计 5794 个字符,预计需要花费 15 分钟才能阅读完成。
引言:热力求为什么在抖动呢?
Deck.GL 是 Uber 开源的天文数据渲染框架,在应用 Deck.GL 绘制热力求的时候,发现一直放大地图时,地图层显著地抖动,且热力的聚合后果也有问题。上面的 demo 展现了这个景象,黄色图层是热力求图层,黑点代表原始数据,显然一直放大地图时,热力求的点并没有和原始数据点对应,且在一直抖动。
代码地址
但官网的 demo 却并没有呈现这个景象,那么问题出在了哪里呢?
如果非要说两个 demo 之间有何不同,就是数据不一样。测试 demo 的地图数据是室内地图级别的,而官网 demo 数据是在城市级别的。室内地图数据到小数点后五六位才呈现不同,城市级别的数据在小数点后一两位,那么很有可能是数据精度损失导致的。
基于这个猜测,上网一搜的确有不少文章介绍因为 WebGL 的精度损失带来的问题,看来是个共性的问题,那么是如何解决的呢?咱们先从数据开始剖析,理解不同天文渲染框架是如何解决这个问题的。
前置背景
Web 墨卡托投影
因为地球是圆的,要将地图展现在立体上,须要通过肯定的投影变换绘制到立体上。墨卡托投影又称“等角正轴圆柱投影”,其等角的个性能够保障对象形态不变性,也能够保障方向和互相地位的正确性。具体的原理不在此赘述,有趣味的能够自行理解。Web 墨卡托投影则将地球的椭圆球体简化为原型球体,坐标转换的公式如下:
从公式能够看出,纬度坐标到 Y 轴坐标的转换是非线性的,计算不仅依赖于三角函数和对数运算,且必须在每一帧的渲染中对每个坐标都进行计算,显然会带来大量的计算开销。
精度损失
天文渲染框架中都须要将元素的经纬度坐标转换为屏幕的像素坐标,随着地图一直放大,经纬度坐标转换到像素坐标的变换矩阵的位移值越来越大,即像素坐标值越来越大。因为 JS 数据是 64 位双精度浮点数,而着色器程序 GLSL 的数据只能是 32 位双精度浮点数,因而从 JS 往着色器外部传数据时,必然会呈现精度失落景象。如果不对精度失落问题做解决,那么当放大到肯定水平当前,挪动地图时就会发现这些元素呈现抖动景象。
Math.fround
办法能够将数据从 64 位转换到 32 位:
Math.fround(-122.4000588); // -122.40006256103516
假如坐标是 [-122.4000588, 37.7900699]
,将其转换为 32 位浮点数是 [-122.40006256103516, 37.790069580078125]
,这两个点之间的间隔在真实世界的差值有 0.3325 米。
那么不同的天文渲染框架是如何解决大量计算和精度损失的问题呢?
Mapbox 的做法
Mapbox 采纳了瓦片坐标系。地理信息的展现因素通常是静止的,因而能够事后将地图分成若干瓦片,每个瓦片蕴含了理论的地理信息因素。这样每次相机发生变化时,只须要以视口内的瓦片为单位渲染数据就行了。瓦片坐标系也很好了解,下图是缩放等级 z 为 2 下的瓦片坐标系:
Mapbox 所有的过程都是在立体坐标系下进行,因而首先通过墨卡托投影将因素的经纬度坐标投影至平面坐标,在每次渲染过程中都从新实时计算瓦片绝对中心点的偏移矩阵,将数据变换到瓦片坐标系中:
function pixelsToTileUnits(tile: {tileID: OverscaledTileID, tileSize: number},
pixelValue: number, z: number): number {
return pixelValue * (EXTENT / (tile.tileSize * Math.pow(2,
z - tile.tileID.overscaledZ)));
}
const translation = [inViewportPixelUnitsUnits ? translate[0] : pixelsToTileUnits(tile, translate[0], this.transform.zoom),
inViewportPixelUnitsUnits ? translate[1] : pixelsToTileUnits(tile, translate[1], this.transform.zoom),
0
];
const translatedMatrix = new Float32Array(16);
mat4.translate(translatedMatrix, matrix, translation);
在失去平面坐标后,为了缩小数据量,Mapbox 对某些因素进行简化,并依据瓦片信息剪裁因素,再获取以后视口内蕴含的瓦片,最初以瓦片为单位,渲染其蕴含的因素。具体的过程能够参考 Mapbox 的文章。
在渲染的这一步,每个瓦片的因素传入着色器中的坐标不是经纬度,也不是墨卡托的相对坐标,而是绝对于以后瓦片的坐标:
// 墨卡托坐标 -> 绝对瓦片坐标 [0, 8192]
function transformPoint(x, y, extent, z2, tx, ty) {
return [Math.round(extent * (x * z2 - tx)),
Math.round(extent * (y * z2 - ty))];
}
因为应用的是绝对瓦片坐标,GLSL 的 32 位精度足够用,因而精度问题也就不存在了。然而相应的,每次相机投影矩阵发生变化时,每个瓦片的投影矩阵也都须要从新计算。
个别缩放等级到 18 级当前,比方室内地图个别在 22 级,网格十分小,会导致切分工夫十分长。思考到用户体验,面对一组待渲染瓦片,Mapbox 依照间隔屏幕核心的间隔进行了排序,优先渲染核心瓦片:
// 屏幕中点坐标
const centerCoord = MercatorCoordinate.fromLngLat(this.center);
const centerPoint = new Point(numTiles * centerCoord.x - 0.5, numTiles * centerCoord.y - 0.5);
// 笼罩瓦片数组按屏幕核心间隔排序
tiles.sort((a, b) => centerPoint.dist(a.canonical) - centerPoint.dist(b.canonical));
概括而言,Mapbox 将墨卡托投影计算放在 CPU 中,传入着色器程序中的坐标是绝对瓦片坐标,防止了 GLSL 的精度损失,并且通过因素简化、分片剪裁等操作大幅缩小数据,无效管制了在 CPU 中的运算量。
Deck.GL 的做法
Deck.GL 自身的定位是解决百万级别频繁变动的数据点,在 CPU 上进行墨卡托投影会重大影响性能。Deck.GL 将经纬度坐标间接传递给 GPU,在顶点着色器中进行转换。这样,必然会带来 JS 往 GLSL 中传数据时的精度损失问题。这些误差可能在地图范畴大时还无奈感知,在高缩放等级下就会造成肉眼可见的偏移,即“抖动”景象,并且随着缩放等级晋升,误差将越来越大。
Deck.GL 曾测试过不同缩放等级在不同纬度下 Y 轴像素误差:
数据拆分为高位和低位
为了解决这个问题,Deck.GL v3 版本中,引入了一种在 GLSL 中模仿 64 位双精度浮点数的办法,将数据拆分为高位和低位,每个数字的高位和低位都将在 GPU 中计算:
highPart = Math.fround(x)
lowPart = x - highPart
而后通过应用 32 位浮点数的级联运算来模仿 64 位的浮点运算。显然代价是微小的 GPU 耗费。例如一个 64 位除法运算须要映射到 11 个 32 位运算,64 位的矩阵运算 (mat4 to vec4) 须要 1952 个 32 位运算。
应用这种计划的的确解决了精度损失引起的抖动问题,但模仿 64 位矩阵运算重大影响了着色器编译和解析的性能,同时也会减少 CPU 向 GPU 传递的数据带宽。一些性能低的显卡驱动程序无奈兼容,就算兼容可能也要几秒钟的工夫来编译它,导致显示卡顿。
偏移坐标系
既想要保留高精度,又想防止过高的计算性能,在 v6.2 版本当前,Deck.GL 应用了一种以屏幕核心作为动静坐标原点的 “Offset Coords” 计划,解决了这个问题。
偏移坐标系的根本想法是,相近的两个坐标之差正好能够将高位抹去,只须要应用 32 位来存储差值,精度就齐全足够了。因而咱们须要选取一个固定点,用来计算差值。固定点抉择视口中心点,计算偏移局部的过程应该在着色器中实现。因为每一帧的视口中心点的经纬度坐标都可能在扭转,如果在 CPU 中每帧都反复进行偏移局部的矩阵运算,性能无奈撑持。
上面的代码是依据以后坐标系,抉择是进行解决失常经纬度还是解决经纬度的差值。
// deck.gl/shaders/project.glsl
vec4 project_position(vec4 position) {
// 解决经纬度 offset
if (project_uCoordinateSystem == COORDINATE_SYSTEM_LNGLAT_AUTO_OFFSET) {
// 与视口中心点的偏移,在经纬度坐标下保留低位,32 位足够用
float X = position.x - project_coordinate_origin.x;
float Y = position.y - project_coordinate_origin.y;
return project_offset_(vec4(X, Y, position.z, position.w));
} else {
// 省略失常解决经纬度 -> 世界坐标
return vec4(project_mercator(position.xy) * WORLD_SCALE * u_project_scale,
project_scale(position.z),
position.w
);
}
}
那么怎么确定采纳哪种计算形式呢?Deck.GL 设定了缩放等级的阈值,在失常和 Offset 两种坐标系之间切换,如果缩放等级大于 12,则须要计算出视口核心在经纬度坐标系下的坐标:
const LNGLAT_AUTO_OFFSET_ZOOM_THRESHOLD = 12;
if (coordinateZoom < LNGLAT_AUTO_OFFSET_ZOOM_THRESHOLD) {
} else {
// 应用 Offset 坐标,传入经纬度坐标系下的视口中心点
const lng = Math.fround(viewport.longitude);
const lat = Math.fround(viewport.latitude);
shaderCoordinateOrigin = [lng, lat];
}
因而在顶点着色器中,最终像素空间坐标的计算结果能够拆分为两局部:世界坐标系偏移局部的矩阵运算和视口核心的投影后果:
// 解决 offset 和失常经纬度到世界坐标系转换
vec4 project_pos = project_position(vec4(a_pos, 0.0, 1.0));
gl_Position = u_mvp_matrix * project_pos + u_viewport_center_projection;
视口中心点的投影后果能够在 CPU 中的每一渲染帧中计算,偏移局部的矩阵运算则在着色器中实现,因而每一帧的计算只须要更新大量的 uniform,简直能够在 CPU 或 GPU 上以零老本实现。
测试后果如下图,新的混合坐标系(黄色)具备与 64 位模式(红色)相当的精度,即便只应用了 32 位,而原先 32 位模式(蓝色)在雷同缩放级别下呈现了抖动。
计算差值 —— 泰勒开展
上述计算过程中,还有一个细节值得注意。如何依据世界坐标系下(经纬度)的差值,来预计墨卡托投影坐标系下的差值呢?在偏移坐标系场景下, 是动静的屏幕核心坐标,其余点与中心点的差值是 , 函数是世界坐标系到偏移坐标系的转换函数。泰勒开展做的就是依据 处的值,联合 函数的导数,能够对 函数任意点的值进行预计。泰勒开展的级数越多,代表模仿值的误差越小。
Web 墨卡托投影公式:
因为 X 轴方向是线性的,能够应用线性预计:
Y 轴方向是非线性的,能够应用泰勒二级开展:
在 GLSL 中应用向量运算能够疾速实现上述转换公式:其中 u_pixels_per_degree
对应 ,而 u_pixels_per_degree2
对应 , 的值通过 导数失去。
// offset:[delta lng, delta lat]
vec4 project_offset(vec4 offset) {
float dy = offset.y;
dy = clamp(dy, -1., 1.);
vec3 pixels_per_unit = u_pixels_per_degree + u_pixels_per_degree2 * dy;
return vec4(offset.xyz * pixels_per_unit, offset.w);
}
总结
那么回到本文最开始的问题,去看热力求的源码就会发现起因。Deck.GL 的热力求模块在传递坐标时没有转换,传入着色器中的坐标精度呈现了损失。通过上一节能够晓得,Deck.GL 在不同版本中对精度损失问题有不同的策略,所以可能是在策略迁徙过程中没有测试笼罩,导致了热力求模块仍存在问题。所以我被动提了 issue,并很快失去了解决。
应用新版本的热力求之后,问题失去了解决:
代码地址
总结一下,WebGL 渲染高缩放等级下地理信息的抖动问题,有以下几个解决办法:
- 应用绝对于瓦片的坐标系,能够无效解决精度问题。然而当缩放水平越来越大时,瓦片宰割的工夫越来越长,而且如果要解决非瓦片数据的精度问题,还须要将其换算到相应的瓦片中。
- 将数据拆分为高位和低位,将一个 Float64Array 拆分为两个 Float32Array,尽管能够解决精度问题,然而代价是显著减少了内存开销和 GPU 计算。
- 偏移坐标系,相近的两个坐标之差正好能够将高位抹去,只须要应用 32 位来存储差值,精度就齐全足够了。
除了数据抖动之外,因为 WebGL 精度损失带来的景象还有 z-fighting,Z 缓冲区精度失落问题,本文就不再赘述,有趣味能够网上搜寻相干材料理解。
参考资料
- How (sometimes) assuming the Earth is “flat” helps speed up rendering in deck.gl
- Rendering big geodata on the fly with GeoJSON-VT
作者:ES2049 | timeless
文章可随便转载,但请保留此原文链接。
十分欢送有激情的你退出 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com。