25

MapboxGL简析(二):变换 | ¥ЯႭ1I0

 4 years ago
source link: https://yrq110.me/post/front-end/mapboxgl-ii-transform/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

MapboxGL简析(二):变换

2020年5月19日

简要分析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方法,通过传入的经纬度位置,结合相机姿态等数据,来计算所覆盖的矢量瓦片信息。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK