WebGL中有寬度的線一直都是初學者的一道門檻,因為在windows系統中底層的渲染接口都是D3D提供的,所以無論你的lineWidth設置為多少,最終繪制出來的只有一像素。即使在移動端可以設置有寬度的線,但是在拐彎處原生api沒有做任何處理,所以往往達不到項目需求,再者比如對於虛線、導航線的繪制,原生api是無能為力。差不多從事WebGL開發已經一周年,總結一下繪制線的方法和踩過的坑,聊以慰藉后來者。

寬度線繪制原理
寬度線的繪制最核心的思想就是利用三角形來繪制線,將一根有寬度的線,看成是多個三角形的拼接

將線剖分成三角形的過程是一個計算密集型的過程,如果放在主線程中會阻塞渲染造成卡頓,通常來講都是放到頂點着色器中來處理,利用GPU並行計算來處理。通常來着色中,將頂點沿着法線方向平移lineWidth/2的距離。對於一個頂點只能平移一次,所以在cpu中我們需要把一個頂點復制兩份傳給gpu同時提前確定好剖分出來的三角形的頂點索引順序。

對於拐彎處,需要做一系列的計算來確定拐角的距離,比如:

但這幅圖過於復雜,我比較喜歡下面這個比較簡單的圖

假設dir1為向量last->current的單位向量,dir2為向量current->next的單位向量,根據這兩個向量求出avg向量,avg向量 = normalize(dir1 + dir2);將avg向量旋轉九十度即可求出在拐角處的偏移向量,當然這個向量可向下,也可以向上,所以一般對上文中重復的頂點還有對應的一個side變量,來告訴着色器應該向下還是向上偏移,同樣上面圖中的last和next也要傳入對應上一個和下一個頂點的坐標值。對應的着色器代碼:
// ios11下直接使用==判斷會有精度問題導致兩個數字不相同引出bug ' if( abs(nextP.x - currentP.x)<=0.000001 && abs(nextP.y - currentP.y)<=0.000001) dir = normalize( currentP - prevP );', ' else if( abs(prevP.x - currentP.x)<=0.000001 && abs(prevP.y - currentP.y) <=0.000001) dir = normalize( nextP - currentP );', // ' if( nextP.x == currentP.x && nextP.y == currentP.y) dir = normalize( currentP - prevP );', // ' else if( prevP.x == currentP.x && prevP.y == currentP.y ) dir = normalize( nextP - currentP );', ' else {', ' vec2 dir1 = normalize( currentP - prevP );', ' vec2 dir2 = normalize( nextP - currentP );', ' dir = normalize( dir1 + dir2 );', '', '', ' }', '', ' vec2 normal = vec2( -dir.y, dir.x );',
着色器中的實踐
原理上面已經實現,那么在具體的繪制中,我們還要明白一個問題,lineWidth的單位是什么,如果你需要繪制的是以像素為單位,那么我們就需要將3d坐標映射到屏幕坐標來進行計算,這樣繪制出來的線不會有明顯的透視效果,即不會受相機距離遠近的影響。
我們需要幾個函數來幫忙,第一個是transform函數,用來將3D坐標轉換成透視坐標系下的坐標:
'vec4 transform(vec3 coord) {',
' return projectionMatrix * modelViewMatrix * vec4(coord, 1.0);',
'}',
接下來是project函數,這個函數傳入的是透視坐標,也就是經過transform函數返回的坐標;
'vec2 project(vec4 device) {',
' vec3 device_normal = device.xyz / device.w;',
' vec2 clip_pos = (device_normal * 0.5 + 0.5).xy;',
' return clip_pos * resolution;',
'}',
其中第一步device.xyz / device.w將坐標轉化成ndc坐標系下的坐標,這個坐標下,xyz的范圍全部都是-1~1之間。
第二步device_normal * 0.5后所有坐標的取值范圍在-0.5~0.5之間,后面在加上0.5后坐標范圍變為0~1之間,由於我們繪線在屏幕空間,所以z值無用可以丟棄,這里我們只取xy坐標。
第三部resolution是一個vec2類型,代表最終展示canvas的寬高。將clip_pos * resolution完全轉化成屏幕坐標,這時候x取值范圍在0~width之間,y取值范圍在0~height之間,單位像素。
接下來的unproject函數,這個函數的作用是當我們在屏幕空間中計算好最終頂點位置后,將該屏幕坐標重新轉化成透視空間下的坐標。是project的逆向過程。
'vec4 unproject(vec2 screen, float z, float w) {',
' vec2 clip_pos = screen / resolution;',
' vec2 device_normal = clip_pos * 2.0 - 1.0;',
' return vec4(device_normal * w, z, w);',
'}',
由於屏幕空間的坐標沒有z值和w值,所以需要外界傳入。
最終着色器代碼:
虛線以及箭頭的繪制原理
上面介紹了有寬度線的繪制,但是在一些地圖場景中,往往需要繪制虛線、地鐵線以及導航路線等有一定規則的路線。這里主要介紹導航線的繪制,明白這個后虛線以及地鐵的線繪制就很簡單了。首先介紹一下導航線的核心原理,要繪制導航線我們有幾個問題需要解決,比如:
- 箭頭的間隔
- 一個箭頭應該繪制在幾米的范圍內(范圍計算不准圖片會失真)
- 如何讓線區域范圍內的每個像素取的紋理重對應像素
- 以及一些各個機型上兼容性問題

無論是虛線、地鐵線、導航線都可以用這個圖來表達。我們可以規定每個markerDelta米在halfd(halfd = markerDelta/2)到uvDelta長的距離里繪制一個標識(虛線的空白區域,地鐵線的黑色區域、導航線的箭頭)。那么問題來了如何讓每一個像素都清楚的知道自己應該成為線的哪一部分?這個時候我的方案是求出每個頂點距離起始坐標點的 ~距離/路線總長度~,將這個距離存入紋理坐標中,利用紋理坐標的插值保證每個像素都能均勻的知道自己的長度占比;在着色器中乘以路線總長度,算出這個像素距離起始點距離uvx。uvx對markerDelta取模運算得muvx,求出在本間隔中的長度,在根據規則(if(muvx >= halfd && muvx <= halfd + uvDelta))計算這個像素是否在uvDelta中。對於導航線,我們需要從箭頭圖片的紋理中取紋素,所以該像素對應的真正的紋理坐標是float s = (muvx - halfd) / uvDelta;對應着色器代碼為
float uvx = vUV.x * repeat.x;', ' float muvx = mod(uvx, markerDelta);', ' float halfd = markerDelta / 2.0;', ' if(muvx >= halfd && muvx <= halfd + uvDelta) {', ' float s = (muvx - halfd) / uvDelta;', ' tc = texture2D( map, vec2(s, vUV.y));', ' c.xyzw = tc.w >= 0.5 ? tc.xyzw : c.xyzw;', ' }',
最終完整着色器代碼為:
關於markerDelta和uvDelta來說,則需要跟相機距離、紋理圖片性質等因素來綜合計算,比如在我的項目中的計算法方式:
let meterPerPixel = this._getPixelMeterRatio(); let radio = meterPerPixel / 0.0746455; // 當前比例尺與21級比例尺相比 let mDelta = Math.min(30, Math.max(radio * 10, 1)); // 最大間隔為10米 let uvDelta = 8 * meterPerPixel;// 8是經驗值,實際要根據線實際像素寬度、紋理圖片寬高比來計算 uvDelta = /*isIOSPlatform() ? 8 * meterPerPixel : */parseFloat(uvDelta.toFixed(2)); this.routes.forEach(r => { if (r._isVirtual) { return; } r._material.uniforms.uvDelta = {type: 'f', value: uvDelta};// 暫時取一米 r._material.uniforms.markerDelta = {type: 'f', value: mDelta}; });
另一個問題如何繪制有邊框的線,可以在着色器中來控制,比如設定一個閾值,超過這個閾值的就繪制成border的顏色;或者簡單點也可以把一條線繪制兩遍,寬的使用border的顏色,窄的使用主線的顏色,同時控制兩條線的繪制順序,讓主線壓住border線。
兼容性的坑
首先發現在iphone6p 10.3.3中紋理失真;
紋理失真肯定是設備像素與紋理紋素沒有對應,但是為什么沒有對應呢?紋理失真就是uv方向上對應問題,為了排查這個過程我把只要落在紋理區域的范圍都設置成紅色,發現在縱向方向上不管紋理在什么尺度下紅色區域范圍都是一樣的,而且結合圖片發現縱向上基本覆蓋了整個紋理圖片,所以縱向沒有問題。

那么就是橫向上的取值,問題,但是橫向是通過紋理坐標產生的,沒有計算的內容;最后懷疑到數字精度問題;將其中的mediump改成highp;這個問題得到解決;iphone6上能畫出完美的箭頭
'precision mediump float;',
然而又碰到了另一個非常棘手的問題,iphone7以上的設備箭頭周圍有碎點。。。

首先要搞清楚這些碎點是什么,發現不論換那張圖片都有碎點,一開始我以為這些碎點是紋理坐標計算時的精度問題,后來發現不論怎么調整紋理u的取值范圍都無法做到在任何時刻完全避免這個問題。

最后偶然發現改變一下這個等式就能解決問題。


所以肯定這個些碎點肯定是從紋理中取得的,有可能在這個區域內,Linear過濾模式剛好取得了幾個像素的平均值,導致這里的alpha通道非是0.0同時取到了一定的平均顏色才會顯示這些碎點;最后懷疑這是因為mipmap方式導致這個設備像素剛好落到前后兩章圖片的像素上,綜合差值后得到一個碎點;至於是否是跟mipmap有關還需要后續驗證,由於項目時間關系先往下解決。解決完這個問題已經是凌晨四點多

然而又出現了另一個問題,iphone6下在某些角度下,紋理會消失,發現是因為上面的判斷引起的
將閾值范圍改成能夠解決問題,后續這塊需要梳理一下,作為一個外部可傳入的變量來處理

現在的線並沒有對端頭做處理,也就是沒有沒有實現lineCap效果,如果想知道lineCap的實現原理可以看我的這篇文章:WebGL繪制有端頭的線。
參考文章
http://codeflow.org/entries/2012/aug/05/webgl-rendering-of-solid-trails/
https://forum.libcinder.org/topic/smooth-thick-lines-using-geometry-shader
Drawing Antialiased Lines with OpenGLhttps://www.mapbox.com/blog/drawing-antialiased-lines/
Smooth thick lines using geometry shader
