在講述本文內容之前,我希望讀者先具備以下知識點:
- 瞭解WebGL的基本知識,懂得調用自定義的Shader程序;
- 基本的數學基礎和空間幾何知識;
- 明白GPU的渲染管線流程;
因爲,本文內容主要講述繪製的核心思路和注意事項,所以對於基本知識只能簡單描述,請諒解;
前言
首先,先附上一篇至今我看到思路十分正確,圖文並茂(圖片真的很好看)的講述WebGL繪製有寬度的線和箭頭貼圖的文章:WebGL繪製有寬度的線 https://www.cnblogs.com/dojo-lzz/p/9219290.html
鏈接博客的作者基本講述本文想要講的主要思路,但是他並沒有過多的講述其他細節。我就在上述的博文中補充一些關鍵的細節。
在繪製箭頭線之前,我們必須掌握如何繪製有寬度的線條,因爲,箭頭圖片的貼圖座標與此有關。
繪製有寬度的線
WebGL提供繪製線條的接口中是提供了線條寬度的屬性,但是在windows系統中不管設置線寬的屬性爲多少,都是一條只有一像素寬度的線。因此,我們需要利用繪製三角形的方式去擬合帶寬度的線。
從上述兩張圖片可以簡單的理解,如何從一條線去構造寬帶;假設我們有以下線的頂點數據 Line = [p0, p1, p2 ...],那麼我們需要將頂點沿着直線方向的法線方向(垂直方向上)的正方向和反方向各平移 lineWidth / 2 的距離,這就剛好實現寬度爲lineWidth的線條。原理十分的簡單。
而我們的頂點數據的在每個頂點處只有一個點,若需要在法線方向上的正反方向上都平移,則我們需要提前把一個頂點複製兩份,同時做上標識是正方向還是負方向(因爲需要區分三角形的頂點索引順序),然後傳給Shader程序。一般上述的步驟需要在Js裏面的準備GLSL頂點數據時進行。
// g 是line線條的每個頂點的數據
for (var j = 0; j < g.vertices.length; j++) {
var v = g.vertices[j]
// positions 是需要傳到頂點着色器的頂點座標,這裏需要把原有
// 頂點複製兩份
this.positions.push(v.x, v.y, v.z)
this.positions.push(v.x, v.y, v.z)
// side是表示頂點平移的方向
this.side.push(1)
this.side.push(-1)
}
上述的思路,還需要計算法線方向,因此,我們在準備頂點數據時,把當前的頂點的上一個節點和後一個節點的數據也存到頂點的Attribute屬性中。
var l = this.positions.length / 6 // 這裏得到爲線的原有長度,未複製頂點前的
var v
// 判斷線的起點和終點是否重合,是閉環的話,終點前一個點是起點的前一個頂點,否則前一個點就是起點自己
// compareV3 是根據輸入的兩個數組中索引的位置,判斷是否是同一個vector3向量
// copyV3 是複製點的索引爲a的向量
if (this.compareV3(0, l - 1)) {
v = this.copyV3(l - 2)
} else {
v = this.copyV3(0)
}
this.previous.push(v[0], v[1], v[2])
this.previous.push(v[0], v[1], v[2])
// 已經得到起點的前一個點,這個得到後面的其他點的前一個點的位置
for (var j = 0; j < l - 1; j++) {
v = this.copyV3(j)
this.previous.push(v[0], v[1], v[2])
this.previous.push(v[0], v[1], v[2])
}
// 這個得到除終點外的其他點的下一個點的位置
for (var j = 1; j < l; j++) {
v = this.copyV3(j)
this.next.push(v[0], v[1], v[2])
this.next.push(v[0], v[1], v[2])
}
// 判斷線的終點和起點是否重合,是閉環的話,終點的後一點是起點的後一個頂點,否則後一個點就是終點自己
if (this.compareV3(l - 1, 0)) {
v = this.copyV3(1)
} else {
v = this.copyV3(l - 1)
}
this.next.push(v[0], v[1], v[2])
this.next.push(v[0], v[1], v[2])
// 設置矩形分割的三角形的頂點索引順序,已經貼圖的順序集合,記得是逆時針的順序
for (var j = 0; j < l - 1; j++) {
var n = j * 2
this.indices_array.push(n, n + 1, n + 2)
this.indices_array.push(n + 2, n + 1, n + 3)
}
至此,我們已經得到了計算帶寬度的線的所有必須的attributes屬性
this._attributes = {
position: new BufferAttribute(new Float32Array(this.positions), 3),
previous: new BufferAttribute(new Float32Array(this.previous), 3),
next: new BufferAttribute(new Float32Array(this.next), 3),
side: new BufferAttribute(new Float32Array(this.side), 1),
width: new BufferAttribute(new Float32Array(this.width), 1),
// uv: new BufferAttribute(new Float32Array(this.uvs), 2), // uvs是一個二元組
index: new BufferAttribute(new Uint16Array(this.indices_array), 1)
}
網上關於繪製有寬度的Shader程序的代碼很多,這裏就不貼出來了,可以參考Three.js的插件MeshLine,參考鏈接,https://github.com/spite/THREE.MeshLine
箭頭貼圖
WebGL的UV貼圖(參考文章鏈接)可以參考上述的鏈接,這裏不做詳細介紹。在有寬度的線上繪製箭頭紋理,其實十分的簡單。只需要解決以下幾個問題:
- 箭頭圖片
- 箭頭的間隔(分靜態和動態的)
- 貼圖座標與頂點座標對應
箭頭圖片的選取要思考箭頭的方向與貼圖座標相呼應,不然導致箭頭方向不準確。
上述計算紋素大概如下,我們可以規定每隔markerDelta米在halfd(halfd = markerDelta/2)處,已uvDelta長的距離裏繪製一個箭頭。
因此,計算紋素座標的方法如下:
1. 求出線段的每個頂點與線段的起始座標點的距離,將這個距離存入紋理UV的X座標中(利用在頂點着色器中varying變量在片段着色器的插值保證每個片段都能知道自己在當前線上的長度);
2. uvx對markerDelta取模運算得muvx,求出其所在間隔中的所佔的長度,在根據規則(if(muvx >= halfd && muvx <= halfd + uvDelta))計算這個像素是否在uvDelta中,若在表示箭頭在此區域,則計算紋素座標。
3. 接下來就是計算紋素座標,因爲,箭頭的長度肯定爲整幅圖片,因此,uvy爲直接取0或者1.而uvx則可以根據muxv - half 與 uvDelta的比例獲取。具體看下面的代碼。
從上述內容可以得到,需要傳遞markerDelta和uvDelta作爲uniform參數到片段着色器中參與運算,以及傳遞Uvs頂點座標作爲attributes參數到頂點着色器中計算;
以下是計算UVS座標的JS代碼:
var l = this.positions.length / 6 // 這裏得到爲線的原有長度,未複製頂點前的
// 設置uv映射的紋理映射數組,根據貼圖圖片的總長度爲1,把貼圖的寬度分割爲等分的份額,高度不變,所以這裏會把圖片給拉長
// uvs數組是記錄線段中每個頂點到線段第一個頂點的距離數據
for (var j = 0; j < l; j++) {
this.uvs.push(uvs[j], 0)
this.uvs.push(uvs[j], 1)
}
以下是渲染箭頭貼圖的着色器代碼:
// uniforms 參數
this.uniforms = {
lineWidth: { type: 'f', value: this.lineWidth },
map: { type: 't', value: this.map },
useMap: { type: 'f', value: this.useMap },
color: { type: 'c', value: this.color },
resolution: { type: 'v2', value: this.resolution }, // 處理頂點的
sizeAttenuation: { type: 'f', value: this.sizeAttenuation },// 是否設置稀疏處理
near: { type: 'f', value: this.near },
far: { type: 'f', value: this.far },
repeat: { type: 'v2', value: this.repeat },
// 自定義貼圖的間隔和總長
// 如果是想要靜態的效果,可以寫死爲某個常量
uvdelta: { type: 'f', value: this.uvdelta },
markerdelta: { type: 'f', value: this.markerdelta }
}
var fragmentShaderSource =
'#extension GL_OES_standard_derivatives : enable',
'precision mediump float;',
'',
'uniform sampler2D map;',
'uniform float useMap;',
'uniform float uvdelta;', // 代表應該繪製箭頭的長度
'uniform float markerdelta;', // 代表線段分割的平均距離
'',
'varying vec2 vUV;',
'varying vec4 vColor;',
'',
'void main() {',
'',
' vec4 c = vColor;',
' if (useMap > 0.0) {',
' vec4 tc = vec4(1.0, 1.0, 1.0, 0.0);',
' float uvx = vUV.x', // 獲取uv映射的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;', // 如果tc的景深超過0.5就不再變化了,這裏可以發揮現象去拓展一下
' }',
' }',
' gl_FragColor = c;',
'}'
總結
文章只講述了文章開頭給出的博客沒有提及的部分,建議先閱讀其他博客,最後再來閱讀本文。
在實際的應用中,因爲上述的代碼及思路都是以最簡單的形式給出,會存在以下需要優化的地方:
1. 線條在拐角處,需要進行插值去使拐角更光滑。參考文獻中也給出了一種常見的方法。也可以使用樣條曲線去擬合;
2. 箭頭圖片的使用建議考慮去鋸齒,方法百度有很多;
3. glsl代碼裏有一處計算屏幕寬高比的aspect,這一步不要省略;
關於後面線條描邊或者光滑曲線的文章,後面有時間再更新。
參考文獻
https://www.cnblogs.com/dojo-lzz/p/9219290.html
https://www.cnblogs.com/dojo-lzz/p/9461506.html
https://blog.csdn.net/a23366192007/article/details/51264454