MapboxGL简析(二):变换
简要分析Mapbox中的两种变换:
- 相机变换: 姿态属性、操作及变换矩阵
- 坐标变换: 经纬度、3D墨卡托与矢量瓦片
本文所参考的mapbox-gl版本为1.11.0
系列文章
- MapboxGL简析(一):渲染
- MapboxGL简析(二):变换
相机变换
主要从常用属性、属性方法及原理的角度来看相机的变换。
相机属性
相机变换主要指的是在3D空间中对相机的各种操作引起的变换,主要属性包含四种:
- center - 指向的中心点位置
- zoom - 缩放级别
- pitch - 俯仰角度,也叫倾斜度
- bearing - 上方向,类似指南针的方向
相机姿态属性会直接影响渲染在屏幕上的效果,本质上是视椎体与地图平面的向
实际上不管对哪种姿态属性进行修改,最终都会在进入一类处理属性变换的方法中操作,并修改Camera.transform对象上的对应属性值。
相机操作
mapbox的camera提供了一些方便操作相机的方法,如:
- zoomTo/zoomIn
- panBy/panTo
- rotateTo/resetNorth
这些方法的本质都是改变四种与相机姿态有关的属性值,而内部提供了三种不同的途径来让这些属性达到最终结果值:
- jumpTo: 直接赋值(without animation transition)
- easeTo: 渐变赋值(with animation transition)
- flyTo: 巡航渐变赋值(with animaton transiton along a curve that evokes flight)
直接赋值
// 无需动画渐变的姿态改变
jumpTo(options: CameraOptions, eventData?: Object) {
// 处理四种相机属性,更新transform对象
const tr = this.transform;
// 修改标记
let zoomChanged = false,
...
// 直接修改transform属性,其它三种属性同理
if ('zoom' in options && tr.zoom !== +options.zoom) {
zoomChanged = true;
tr.zoom = +options.zoom;
}
this.fire(new Event('movestart', eventData))
.fire(new Event('move', eventData));
// 触发属性改变时机事件,由于无渐变因此同时触发。其它三种属性同理
if (zoomChanged) {
this.fire(new Event('zoomstart', eventData))
.fire(new Event('zoom', eventData))
.fire(new Event('zoomend', eventData));
}
}
渐变赋值
// 带有动画渐变的姿态改变
easeTo() {
const tr = this.transform,
// 获取当前各属性的初始值
startZoom = this.getZoom(),
zoom = 'zoom' in options ? +options.zoom : startZoom,
// 触发属性开始变化事件
this._prepareEase(eventData, options.noMoveStart, currently);
// 执行封装的类rAF方法,在回调方法中进行姿态属性的插值,其中k为利用now计算的时间差除以设置中经过的时间duration得到的步进值t
this._ease((k) => {
// 对各属性进行插值,步进值k根据经过的时段比例计算
if (this._zooming) {
tr.zoom = interpolate(startZoom, zoom, k);
}
...
}, (interruptingEaseId?: string) => {
// 触发属性结束变化事件
this._afterEase(eventData, interruptingEaseId);
}, options)
// 封装对于渐变属性的事件触发
this._prepareEase(eventData, options.noMoveStart, currently);
}
巡航赋值
// 带有巡航效果的姿态改变
flyTo() {
/**
* 该方法的核心是flight path的计算,代码中参考了一片论文中的方法来计算最优路径
* Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and panning.” INFOVIS
* ’03. pp. 15–22. <https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5>.
* 简单的说就是该方法实现了在视图切换时的一种平滑路径,在uw空间中计算
*/
// 整条巡航路线的长度
let S = (r(1) - r0) / rho;
// 返回地平面的可见范围
let w: (_: number) => number = function (s) {
return (cosh(r0) / cosh(r0 + rho * s));
};
// 返回巡航路线与地平面的距离
let u: (_: number) => number = function (s) {
return w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2) / u1;
};
// S、w与u参与属性插值
this._ease((k) => {
const s = k * S;
const scale = 1 / w(s);
tr.zoom = k === 1 ? zoom : startZoom + tr.scaleZoom(scale);
if (this._rotating) {
tr.bearing = interpolate(startBearing, bearing, k);
}
...
const newCenter = k === 1 ? center : tr.unproject(from.add(delta.mult(u(s))).mult(scale));
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
}, () => this._afterEase(eventData), options);
}
相机变换
可以看到,Camera类中对于任何改变姿态的操作都是通过修改transform对象上的属性实现,而transform对象是负责存储相机在3D空间中的各种变换矩阵与其他属性的一个对象。
在Transform类中,设置了多种姿态属性的setter/getter拦截器,并且在每种属性的setter拦截器中,除了属性值的更新以外,都调用了 _calcMatrices() 来更新所有相关的变换矩阵。
如pitch与center的setter属性拦截器:
set pitch(pitch: number) {
const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI;
if (this._pitch === p) return;
this._unmodified = false;
this._pitch = p;
this._calcMatrices();
}
set center(center: LngLat) {
if (center.lat === this._center.lat && center.lng === this._center.lng) return;
this._unmodified = false;
this._center = center;
this._constrain();
this._calcMatrices();
}
下面来看看计算各种变换相关矩阵信息的函数 _calcMatrices() :
_calcMatrices() {
/*
* 计算投影矩阵
*/
// 计算相机的近裁面与远裁面距离
const furthestDistance = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + this.cameraToCenterDistance;
const farZ = furthestDistance * 1.01;
const nearZ = this.height / 50;
// 本地坐标系到GL坐标系的变换矩阵
let m = new Float64Array(16);
mat4.perspective(m, this._fov, this.width / this.height, nearZ, farZ);
m[8] = -offset.x * 2 / this.width;
m[9] = offset.y * 2 / this.height;
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [0, 0, -this.cameraToCenterDistance]);
mat4.rotateX(m, m, this._pitch);
mat4.rotateZ(m, m, this.angle);
mat4.translate(m, m, [-x, -y, 0]);
/*
* 计算3D墨卡托变换矩阵,用于将点从墨卡托坐标系([0, 0] nw, [1, 1] se)转换到GL坐标系
*/
this.mercatorMatrix = mat4.scale([], m, [this.worldSize, this.worldSize, this.worldSize]);
// 处理z轴变量并保存投影矩阵及其逆阵
mat4.scale(m, m, [1, 1, mercatorZfromAltitude(1, this.center.lat) * this.worldSize, 1]);
this.projMatrix = m;
this.invProjMatrix = mat4.invert([], this.projMatrix);
// 计算轴向投影矩阵,为渲染光栅瓦片所准备
const xShift = (this.width % 2) / 2, yShift = (this.height % 2) / 2,
angleCos = Math.cos(this.angle), angleSin = Math.sin(this.angle),
dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift,
dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift;
const alignedM = new Float64Array(m);
mat4.translate(alignedM, alignedM, [ dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0 ]);
this.alignedProjMatrix = alignedM;
// 标签平面在GL中的初始mv矩阵,无姿态变换,亦作为symbol的默认mv矩阵
m = mat4.create();
mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
this.labelPlaneMatrix = m;
// 本地坐标系到屏幕坐标系的变换矩阵及其逆阵,即mvp矩阵,用于墨卡托与屏幕坐标互转等方法
this.pixelMatrix = mat4.multiply(new Float64Array(16), this.labelPlaneMatrix, this.projMatrix);
m = mat4.invert(new Float64Array(16), this.pixelMatrix);
if (!m) throw new Error("failed to invert matrix");
this.pixelMatrixInverse = m;
}
地理坐标变换
首先简单了解下mapbox中的地理坐标变换。
一般在使用时会给地图传入一个经纬度的位置信息,这个信息经过内部的层层格式转换,会得到一个瓦片信息,即为该位置所在的矢量瓦片。这个过程在mapbox中包含以下几步:
- 经纬度(Longitude and latitude) -> Web墨卡托投影(Global projected coordinate)
- Web墨卡托投影 -> 二维屏幕坐标(Pixel coordinate)
- 二维屏幕坐标 -> 矢量瓦片坐标(Tile coordinate)
Transform中提供了用于不同坐标切换的方法:
- coordinateLocation/locationCoordinate: 经纬度与墨卡托互转
- pointCoordinate/coordinatePoint 墨卡托与屏幕坐标系互转
对于上述的每一步转换都使用了封装好的对象及其方法来计算:
-
经纬度转web墨卡托
mapbox将Web墨卡托坐标扩展到了三维空间,将海拔作为z值
export function mercatorXfromLng(lng: number) { return (180 + lng) / 360; } export function mercatorYfromLat(lat: number) { return (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360; } class MercatorCoordinate { x: number; y: number; z: number; ... static fromLngLat(lngLatLike: LngLatLike, altitude: number = 0) { const lngLat = LngLat.convert(lngLatLike); return new MercatorCoordinate( mercatorXfromLng(lngLat.lng), mercatorYfromLat(lngLat.lat), mercatorZfromAltitude(altitude, lngLat.lat)); } }
-
墨卡托投影转屏幕坐标
class Transform { ... // 计算屏幕坐标时,无需z轴数据 coordinatePoint(coord: MercatorCoordinate) { const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1]; // pixelMatrix即为前面_calcMatrices()得到的本地空间到GL空间的mvp矩阵 vec4.transformMat4(p, p, this.pixelMatrix); // 将w分量转换为1 return new Point(p[0] / p[3], p[1] / p[3]); } }
-
经纬度转矢量瓦片索引
这个在上一篇简析的最后有介绍,mapbox使用一个coveringTiles方法,通过传入的经纬度位置,结合相机姿态等数据,来计算所覆盖的矢量瓦片信息。
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/mapboxgl-ii-transform/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。