mapboxgl 互聯網地圖糾偏插件(二)

前段時間寫的mapboxgl 互聯網地圖糾偏插件(一)存在地圖旋轉時瓦片錯位的問題。

這次沒有再跟 mapboxgl 的變換矩陣較勁,而是另闢蹊徑使用 mapboxgl 的自定義圖層,重新寫了一套加載瓦片的方法來實現地圖糾偏。

下面把我這次打怪升級的心路歷程分享一下,或許對你也有啟發。

文中涉及一些 webgl 的知識細節,沒有接觸過 webgl 的同學,可以參考看上一次給大家推薦的電子書 《WebGL編程指南》,這次再附上一個包含書中所有示例的 github 庫,會很有幫助。

書接上回

在研究偏移矩陣問題一籌莫展時,發現用天地圖的柵格瓦片沒有偏移的問題,因為天地圖是大地2000坐標,可以直接在 wgs84 坐標地圖上使用,基本沒有誤差。

嘗試後覺得,可以倒是可以,但就是配色有點丑,可以先作為一個保底方案,高德瓦片的糾偏還要繼續研究。

話說《WebGL編程指南》這本書看完後,一直想寫個讀書筆記,但又覺得光寫筆記太枯燥,就想着結合地圖看能幹點啥。

mapboxgl 通過自定圖層接口支持 webgl 的擴展,這個接口的好處是,對複雜的變換矩陣進行了封裝,對外使用大家熟悉的 web 墨卡托坐標,並提供了經緯度坐標和 web墨卡托坐標轉換的接口 。

查看 mapboxgl 的官方示例時,突然來了靈感,可以用這個接口自己寫個加載柵格瓦片的程序,這樣就能繞開 mapboxgl 複雜的框架,更容易實現對瓦片糾偏,出現問題也更好解決,對整體更有掌控感。

技術路線分析:

用這個思路來實現糾偏,要搞定兩大問題,一個是如何用 webgl 實現顯示瓦片的功能,另一個是如何計算瓦片在屏幕上的顯示位置。

如何用 webgl 顯示瓦片

在 webgl 中,圖形的基礎是三角形,要繪製正方形的瓦片,需要用兩個三角形拼成一個正方形,再把圖片貼到這個正方形上,就能實現地圖瓦片的顯示。這個過程中,圖片被稱為紋理,貼圖被稱為紋理貼圖。實現效果如下(圖片位置是隨便寫的):

這裡有兩點要注意:

1、要注意圖片的跨域問題,需要通過設置圖片的跨域屬性來解決。

2、要注意頂點坐標的順序,正確的順序為:左上、左下、右上、右下,不然圖片會像穿衣服一樣,各種穿反,前後反,左右反

核心代碼如下:

    var picLoad = false;
    var tileLayer = {
        id: 'tileLayer',
        type: 'custom',

        //添加圖層時調用
        onAdd: function (map, gl) {
            var vertexSource = "" +
                "uniform mat4 u_matrix;" +
                "attribute vec2 a_pos;" +
                "attribute vec2 a_TextCoord;" +
                "varying vec2 v_TextCoord;" +
                "void main() {" +
                "   gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);" +
                "   v_TextCoord = a_TextCoord;" +
                "}";

            var fragmentSource = "" +
                "precision mediump float;" +
                "uniform sampler2D u_Sampler; " +
                "varying vec2 v_TextCoord; " +
                "void main() {" +
                "    gl_FragColor = texture2D(u_Sampler, v_TextCoord);" +
                "}";

            //初始化頂點着色器
            var vertexShader = gl.createShader(gl.VERTEX_SHADER);
            gl.shaderSource(vertexShader, vertexSource);
            gl.compileShader(vertexShader);
            //初始化片元着色器
            var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
            gl.shaderSource(fragmentShader, fragmentSource);
            gl.compileShader(fragmentShader);
            //初始化着色器程序
            var program = this.program = gl.createProgram();
            gl.attachShader(this.program, vertexShader);
            gl.attachShader(this.program, fragmentShader);
            gl.linkProgram(this.program);

            
            //獲取頂點位置變量
            var a_Pos = gl.getAttribLocation(this.program, "a_pos");
            var a_TextCoord = gl.getAttribLocation(this.program, 'a_TextCoord');
            //設置圖形頂點坐標
            var leftTop = mapboxgl.MercatorCoordinate.fromLngLat({lng: 110,lat: 40});
            var rightTop = mapboxgl.MercatorCoordinate.fromLngLat({lng: 120,lat: 40});
            var leftBottom = mapboxgl.MercatorCoordinate.fromLngLat({lng: 110,lat: 30});
            var rightBottom = mapboxgl.MercatorCoordinate.fromLngLat({lng: 120,lat: 30});
            //頂點坐標放入webgl緩衝區中
            var attrData = new Float32Array([
                leftTop.x, leftTop.y, 0.0, 1.0,
                leftBottom.x, leftBottom.y, 0.0, 0.0,
                rightTop.x, rightTop.y, 1.0, 1.0,
                rightBottom.x, rightBottom.y, 1.0, 0.0
            ])
            var FSIZE = attrData.BYTES_PER_ELEMENT;
            this.buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.bufferData(gl.ARRAY_BUFFER, attrData, gl.STATIC_DRAW);
            //設置從緩衝區獲取頂點數據的規則
            gl.vertexAttribPointer(a_Pos, 2, gl.FLOAT, false, FSIZE * 4, 0);
            gl.vertexAttribPointer(a_TextCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
            //激活頂點數據緩衝區
            gl.enableVertexAttribArray(a_Pos);
            gl.enableVertexAttribArray(a_TextCoord);

            var _this = this;
            var img = this.img = new Image();
            img.onload = () => {
                 // 創建紋理對象
                 _this.texture = gl.createTexture();
                //向target綁定紋理對象
                gl.bindTexture(gl.TEXTURE_2D, _this.texture);
                //對紋理進行Y軸反轉
                gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
                //配置紋理圖像
                gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.img);

                picLoad = true;
            };
            img.crossOrigin = true;	//設置允許跨域
            img.src = "//webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=843&y=386&z=10";
        },

        //渲染,地圖界面變化時會調用這個方法,會調用若干次(變化時的每一幀都調用)
        render: function (gl, matrix) {
            if(picLoad){
                //應用着色程序
                //必須寫到這裡,不能寫到onAdd中,不然gl中的着色程序可能不是上面寫的,會導致下面的變量獲取不到
                gl.useProgram(this.program);

                //向target綁定紋理對象
                gl.bindTexture(gl.TEXTURE_2D, this.texture);
                //開啟0號紋理單元
                gl.activeTexture(gl.TEXTURE0);
                //配置紋理參數
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
                // 獲取紋理的存儲位置
                var u_Sampler = gl.getUniformLocation(this.program, 'u_Sampler');
                //將0號紋理傳遞給着色器
                gl.uniform1i(u_Sampler, 0);                

                //給位置變換矩陣賦值
                gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "u_matrix"), false, matrix);
                //繪製圖形
                gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
            }            
        }
    };

    map.on('load', function () {
        map.addLayer(tileLayer);
    });

上面是加載一個瓦片,下面看一下如何加載多個瓦片,這個問題看似簡單,但對於webgl不熟悉的同學有可能會走彎路,我自己在研究時,就遇到了下面幾個問題:

第一個問題:

自定義圖層必須要有onAdd方法和render方法,onadd方法在加載圖層時會被調用一次,render方法在地圖平移、縮放、旋轉時會被調用若干次,來實現平滑過渡的效果。

那麼問題來了,哪些 webgl 代碼應該放在onadd中,哪些應該放在render中?

下面是 webglfundamentals 網站給出的解釋,在這裡,onadd方法就是初始化階段,render方法就是渲染階段。

第二個問題:

頂點坐標是一個瓦片用一個緩衝區,還是所有坐標都放在一個緩衝區中,然後定義規則來取?

答案是,一個瓦片就是一個獨立的圖形,一個圖形對應一套自己的頂點坐標,坐標後面可以跟渲染相關的屬性,如顏色、紋理坐標等,多個圖形的頂點坐標最好不要放在一起,推薦使用多個緩衝區對象分別存儲圖形的頂點坐標,這樣分開放會更清晰,實現也更簡單。

第三個問題:

如何使用多個緩衝區?

webgl 是面向過程式的,平時用慣了面向對象的開發語言,剛接觸這個時有點不適應,後來就慢慢熟悉了。

我們可以把 webgl 的想像成一台老式的機械印刷機,它根據模板印刷,一次只能只使用一個模板,如果想要印刷出多個不同的圖案,就需要準備多個不同圖案的模板,然後在印刷時不斷的更換模板。

webgl 中的着色器、緩衝區對象、紋理對象三者的組合就像是這個模板 ,模板和它們都包含了繪製圖形的參數。更換模板,就是在更換着色器、緩衝區對象和紋理對象,不同的是,相比印刷機,電腦中切換這些只是一瞬間的事情,時間可以忽略不計。

webgl 在實際工作時就是像上面的印刷機一樣在不停的更換模板然後印刷,再更換模板再印刷,直到全部圖像繪製完成,整個過程也是一瞬間的事情。

在 webgl 中,」印刷的機器「只有一個,但「模板」你可以創建很多,它的上限取決於你的電腦性能。

我們要做的就是為每一個瓦片創建一個「模板」,然後在繪製時動態切換這些「模板」。

上面三個問題搞明白以後,我成功的加載了2個瓦片。效果圖:

如何計算瓦片在屏幕上的顯示位置

核心還是用的上篇文章中提到的經緯度和瓦片編號互轉算法

原理是:先獲取當前顯示範圍四個角的經緯度,再根據互轉算法計算出四個角對應的瓦片編號,這樣就能統計出當前地圖範圍所有瓦片的瓦片編號。

然後遍歷當前範圍內的所有瓦片編號。

遍歷時,根據互轉算法,將遍歷到的每個瓦片編號轉為瓦片左上角的經緯度,再用它相鄰的右方、下方、右下方3個瓦片的左上角經緯度,組成瓦片的4個頂點坐標。

在這一步加入對頂點坐標的糾偏算法,實現對瓦片的糾偏。

最後再去監聽地圖改變的事件,當地圖發生平移、縮放、旋轉時都要重複上面的計算,更新瓦片。

這裡遇到個問題:糾偏後也出現了上一篇中邊緣空白的情況。於是對上面的算法優化了一下,在獲取到當前顯示範圍的四個角經緯度坐標後,對這4個坐標也進行糾偏,這樣問題就解決了。

現在瓦片的地圖的框架搭起來了,也能夠瀏覽查看瓦片地圖了,這一刻還真有點小興奮的呢

但和最終想要的效果還有些差距,還有很多細節需要優化

細節優化

1、緩存瓦片

把請求過的瓦片放到存到變量中,這樣請求過的瓦片可以避免重複請求,顯示速度會更快,體驗更好。

2、緩存網格經緯度

統一計算瓦片網格的經緯度並緩存起來,以免每次都進行重複計算。

3、瓦片加載的順序從中間向四周

現在的順序是從左到右,有種刷屏的感覺,需要對瓦片編號排一下序,讓靠近中間的先加載,靠近邊緣的後加載。

4、個別瓦片不顯示問題

每次地圖範圍變換時,為了實現平滑的效果render方法會被執行幾十次,時間大概在1秒左右,

如果瓦片不能在這期間加載完成,就會被落下,導致不顯示。

需要把最後一次執行render方法時的matrix變換矩陣記錄下來,在瓦片加載完成後主動調用render方法繪製。

5、影像圖註記白底的問題

在加載影像圖時,影像和註記是分開的,需要疊加顯示,註記層在沒有文字的地方是透明的。

但疊加到一起以後註記層在本該是透明的地方卻是不透明的白色

原因一,因為在讀取紋理像素數據時的配置有問題,要使用gl.RGBA,如果使用的是gl.RGB丟掉了透明度A,就會缺失透明度信息,導致不透明。

原因二,因為在繪製前沒有對 webgl 開啟阿爾法混合(阿爾法在這裡可以理解為透明度),在 webgl 中如果要實現透明效果,這個選項是必須要開啟的。

解決後的效果:

6、影像圖註記白底的問題還是會偶爾出現

按上一條修改後,白底問題出現的頻率明顯降低,但偶爾還是會出現。

研究規律,當註記瓦片加載的時間稍長時就會出現,出現後,只要稍稍拖動一下地圖就會正常,已經瀏覽過的區域沒有這個問題。

推測,影像和註記是分圖層繪製,當個別註記瓦片加載的時間長,去主動調用render方法重新繪製時,註記的圖層會全部重繪,但影像圖層不會繪製,這可能就導致兩個圖層無法動態的混合。

目前的解決方法是,對於註記圖層如果加載慢了,就不主動調用render方法重新繪製了。因為缺一小塊註記不影響大局,而且下一步操作時它也會自動變正常。

地圖抖動問題

一些列優化完成後,現在地圖也糾偏了,旋轉時也不再錯位了,本來以為程序已經很完美了,但當我疊上項目真實數據後,發現了一個很要命的問題,自定義圖層在大比例尺時會出現抖動的問題。

這個問題最開始就注意到了,但沒太在意,以為影響不大,但疊加上業務數據後,發現根本沒法用,那種感覺就像是,坐到了行駛在鄉間小路的拖拉機上, ~ 顛 ~ 顛 ~ 顛 ~ 顛 ~ 顛 ~ 顛 ~ 顛 ~

起初還以為是瓦片編號和經緯度互轉導致的問題,後來發現 mapboxgl 官網的自定義圖層示例也有這個問題,看來是 mapboxgl 的 bug 無疑了。

幫 mapboxgl 找問題,最終定位在了render方法的matrix變換矩陣上,這個參數是 mapboxgl 傳來的,用於將 web 墨卡托坐標轉為 webgl 坐標,並對瓦片進行縮放和旋轉。

當只對地圖進行微小的平移時,地圖會動,matrix矩陣卻沒有變,matrix矩陣不變,自定義圖層也就不會變,當地圖平移的範圍加大時,matrix矩陣才會跟着變。

翻看 mapboxgl 的源碼,自定義圖層和底圖用的不是一個變換矩陣,所以只有自定義圖層有問題。

嘗試了 mapboxgl 的最新版本 v2.3.1 也有這個問題。

唉! 本來以為糾偏這事兒要翻篇兒了,這麼看來還要再研究一陣子了。

啟發、思路、感受

在使用自定義圖層的過程中有了一些啟發,上篇文章中糾偏寫在了變換矩陣中,這種寫法在地圖旋轉時會出現瓦片錯位的問題。

本篇文章中糾偏是對a_pos變量 web 墨卡托坐標進行糾偏,在旋轉時就沒有出現錯位的情況。

按這個思路,是不是在上篇文章中,也對a_pos變量糾偏,地圖旋轉時就不會出現錯位問題了?值得一試。

所以,接下來兩個思路:一、研究如何提高自定義圖層變換矩陣的精度,讓它不再抖動。二、研究如何對 mapboxgl 源碼中的a_pos變量進行糾偏。

最後說一下使用 mapboxgl 自定義圖層的感受,使用 mapboxgl 自定義圖層 + webgl 擴展,就感覺打開了GIS世界的另一扇窗戶,自己可以去實現各種炫酷高大上的功能了,感覺有了無限可能。

代碼、示例

在線示例://gisarmory.xyz/blog/index.html?demo=mapboxglMapCorrection2

插件代碼://gisarmory.xyz/blog/index.html?source=mapboxglMapCorrection2

總結

  1. 這次嘗試用 maboxgl 的自定義圖層功能,自己寫了一個加載互聯網瓦片的程序,來實現瓦片糾偏
  2. 自己寫加載瓦片的程序要搞定兩大問題,一個是如何用 webgl 實現顯示瓦片的功能,二個是如何計算瓦片在屏幕上的顯示位置
  3. webgl 顯示瓦片的原理就是繪製個正方形再給正方形貼圖片紋理
  4. 計算瓦片在屏幕上的顯示位置,核心是使用瓦片號和經緯度的互轉算法,在這個過程中對瓦片進行糾偏
  5. 還要進行一些細節優化,比如瓦片的加載順序等
  6. 最終實現了對高德瓦片進行糾偏,並且旋轉時也不會出現錯位的情況
  7. 但這種方式有個問題,mapboxgl 的render方法中傳過來的變換矩陣的精度不夠,在大比例尺時會出現瓦片抖動的情況,這應該是mapboxgl 的 bug
  8. 在使用自定義圖層的過程中有了一些啟發,接下來兩個思路:一、研究如何提高自定義圖層變換矩陣的精度。二、研究如何對mapboxgl 源碼中的a_pos變量進行糾偏。
  9. 目前的保底方案是使用天地圖的瓦片,高德地圖的瓦片還要繼續研究。


原文地址://gisarmory.xyz/blog/index.html?blog=mapboxglMapCorrection2

關注《GIS兵器庫》, 只給你網上搜不到的GIS知識技能。

本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發佈,但務必保留文章署名《GIS兵器庫》(包含鏈接:  //gisarmory.xyz/blog/),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。