简要分析Mapbox中的两种变换:

  1. 相机变换: 姿态属性、操作及变换矩阵
  2. 坐标变换: 经纬度、3D墨卡托与矢量瓦片

本文所参考的mapbox-gl版本为1.11.0

系列文章

相机变换

主要从常用属性、属性方法及原理的角度来看相机的变换。

相机属性

相机变换主要指的是在3D空间中对相机的各种操作引起的变换,主要属性包含四种:

  1. center - 指向的中心点位置
  2. zoom - 缩放级别
  3. pitch - 俯仰角度,也叫倾斜度
  4. bearing - 上方向,类似指南针的方向

相机姿态属性会直接影响渲染在屏幕上的效果,本质上是视椎体与地图平面的向

实际上不管对哪种姿态属性进行修改,最终都会在进入一类处理属性变换的方法中操作,并修改Camera.transform对象上的对应属性值。

相机操作

mapbox的camera提供了一些方便操作相机的方法,如:

  1. zoomTo/zoomIn
  2. panBy/panTo
  3. rotateTo/resetNorth

这些方法的本质都是改变四种与相机姿态有关的属性值,而内部提供了三种不同的途径来让这些属性达到最终结果值:

  1. jumpTo: 直接赋值(without animation transition)
  2. easeTo: 渐变赋值(with animation transition)
  3. 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中包含以下几步:

  1. 经纬度(Longitude and latitude) -> Web墨卡托投影(Global projected coordinate)
  2. Web墨卡托投影 -> 二维屏幕坐标(Pixel coordinate)
  3. 二维屏幕坐标 -> 矢量瓦片坐标(Tile coordinate)

Transform中提供了用于不同坐标切换的方法:

  • coordinateLocation/locationCoordinate: 经纬度与墨卡托互转
  • pointCoordinate/coordinatePoint 墨卡托与屏幕坐标系互转

对于上述的每一步转换都使用了封装好的对象及其方法来计算:

  1. 经纬度转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));
        }
    }
    
  2. 墨卡托投影转屏幕坐标

    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]);
        }
    }
    
  3. 经纬度转矢量瓦片索引

    这个在上一篇简析的最后有介绍,mapbox使用一个coveringTiles方法,通过传入的经纬度位置,结合相机姿态等数据,来计算所覆盖的矢量瓦片信息。

参考