用WebGL 畫線比我想像中地還難 - 半熟前端

文章推薦指數: 80 %
投票人數:10人

Due to limitations of the OpenGL Core Profile with the WebGL renderer on most platforms linewidth will always be 1 regardless of the set ... 目錄 lineWidth無法作用 使用其他Geometry 其他Library 解決方法:用點形成面 實作 方法一:對每個點求法線 轉折處處理 撰寫Vertexshader 實作:BufferGeometry+ShaderMaterial BufferGeometry 展示成果 有轉折的線條 正弦 下一個問題:去鋸齒化(anti-aliasing) 總結 前端關閉目錄用WebGL畫線比我想像中地還難2021/11/18最近想做一些canvas的互動效果,為了盡可能讓效果更加生動及透過GPU加速,很自然地WebGL與Three.js成為了首選。

用Three.js寫起來相當寫意,不過很快地也遇到一個大問題,用Three.js很難隨心所欲地畫出一條線。

lineWidth無法作用 在Three.js當中可以用LineBasicMaterial畫線,透過BufferGeometry把點的位置傳入後就可以得到想要的效果。

jsconstpoints=[p1,p2,p3];constmaterial=newTHREE.LineBasicMaterial({color:0x0000ff});constgeometry=newTHREE.BufferGeometry().setFromPoints(points);constline=newTHREE.Line(geometry,material); 聽起來相當簡單對吧?然而畫面渲染出來之後會發現線條的粗度比想像中細很多,事實上只有1px。

原本以為是bug,後來再仔細翻翻文件之後發現有寫到: DuetolimitationsoftheOpenGLCoreProfilewiththeWebGLrendereronmostplatformslinewidthwillalwaysbe1regardlessofthesetvalue. 意思是儘管在OpenGL當中有提供lineWidthAPI可以使用,但在大部分的平台(各大瀏覽器幾乎都不行)上都會忽略這個函數並設定為1。

為了測試我的認知是否正確,這裏用純WebGL版本寫了一個範例: SeethePen webGL-lineby愷開(@kjj6198) onCodePen. javascriptfunctionmain(){vargl=initGL();varshaderProgram=initShaders(gl);varvertices=createPoints(gl,shaderProgram);gl.lineWidth(100.0);draw(gl,vertices);} 可以發現儘管呼叫了gl.lineWidth(100.0),顯示出來的線條寬度仍然只有1。

現代人的螢幕都是4K起跳,1px的線真的很醜又難看,因此用gl.LINE_STRIP或gl.LINE這條路很明顯走不通(除非你就是要這種效果)。

後來查了MDN的文件說明也是同樣的描述: Themaximumminimumwidthisallowedtobe1.0.Theminimummaximumwidthisalsoallowedtobe1.0.Becauseoftheseimplementationdefinedlimitsitisnotrecommendedtouselinewidthsotherthan1.0sincethereisnoguaranteeanyuser'sbrowserwilldisplayanyotherwidth. 唯一有實作lineWidth的平台竟然還是IE11,真是不勝唏噓。

使用其他Geometry 竟然用Line不行,那麼用PlaneGeometry或是ShapeGeometry硬做出來可不可以? 雖然可以是可以,但built-in的Geometry的頂點往往都已經事先設定好了,要調整比較困難一些,而且如果只是畫線的話,我希望能夠限制頂點數量在最小範圍內。

找了一下Three.js的文件看起來似乎沒有可以客製化lineWidth的Line,沒有支援讓我還蠻驚訝的,大家都沒有簡單的畫線需求嗎? 其他Library 雖然官方上沒有內建支援,但Three.js的fat-lineexample上其實有範例,可以調整width,甚至還可以調整dashed。

原本想要直接套入使用,但要整合到我目前的實作似乎有點overkill了。

另外一個則是THREE.Meshline,功能看起來也很齊全,實作也很簡單,然而除了寫法比較古老之外,我想要的功能也沒有那麼多。

整理一下我的需求,我要的只有: 送點對進去後畫出線條 可以調整寬度 可以調整顏色 於是決定自己動手寫一個。

解決方法:用點形成面 給定三個點,要連成一條線的方式很簡單,將三個點連接起來就可以了,事實上使用gl.LINE_STRIP或是gl.LINE的時候就是這樣子畫的,不過如同剛剛所說的,lineWidth會被限制在1,因此我們要想辦法將寬度撐開,也就意味著我們勢必要用gl.TRIANGLE來畫才能調整寬度。

我們可以將每兩點形成的向量求法向量後分別在上下取兩點: 其中,灰色的點就是我們想要的頂點位置,然後把這四個點當作vertex送給vertexshader。

這樣一來可以確保點的數量最小,也能達到控制寬度的效果。

實作 給定兩個點P∗1=(0,0)P*1=(0,0)P∗1=(0,0)與P∗2=(1,1)P*{2}=(1,1)P∗2=(1,1),可以透過一個直線方程式來表示:x−y=0x-y=0x−y=0。

那麼我們要做的事是在直線方程式中尋找垂直於此方程式的並通過P1與P2的向量,也就是法向量。

先從P1開始好了,我們先求得向量P12=(1,1)P_{12}=(1,1)P12​=(1,1),法向量則為(−1,1)(-1,1)(−1,1)。

我們可以分別向上、下(或正負)各求得一點,點的距離會是lineWidth/2;接下來到P2也是做一樣的事,分別求正負方向的一點。

這樣一來總共會有四個點,可以組成一個面(兩個三角形)。

額外一提,只有在vertexshader被呼叫三次後才會繪製一個三角形,因此需要6個頂點座標才能繪製兩個三角形,為了避免重複頂點儲存浪費空間,我們會使用index陣列告訴WebGL如何存取頂點座標。

javascriptconstindices=[0,1,2,2,1,3];//告訴webGL存取頂點的順序constindexBuffer=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,indexBuffer);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,newUint8Array(indices),gl.STATIC_DRAW); 方法一:對每個點求法線 每兩個點找向量,找到法向量後分別對上下求線段長度。

效果如下: 可以發現,這個簡單的方法還挺有效的,然而問題會發生在有轉折出現的地方,我們讓線條轉折後可以發現在轉折處的部分處理似乎不正確: 上色之後會更加明顯。

可以看到轉折處的部分整個線條萎縮起來了。

轉折處處理 問題出在於目前的實作只仰賴當前的點與下一個點的向量構成的法向量,這個方法只保證了上下兩個點會垂直,然而仔細想一下會發現這不是我們想要的結果。

在這邊我們皆假設所有向量都是單位向量,也就是長度為1。

在這張圖當中,我們想要取點的位置是A向量中的上下兩點,然而剛剛的實作則是B向量的兩點,這導致了後面計算的頂點位置其實是錯誤的。

在轉折處的部分,我們不能只取下一個向量的法向量,而是要根據前後兩個向量加起來的法向量為主。

原本線條寬度在垂直分量下只要直接相乘就好,但因為現在角度是以A+B的法向量為主,所以要另外計算垂直分量投影到法向量的長度。

投影的長度計算就是取兩個向量的內積。

到這邊,我們已經可以建構出畫線的策略。

求這個點與上個點的向量與下一個點的向量,假設分別為向量A與B 計算A的法向量與A+B的法向量 計算A+B的法向量投影在A向量的長度 將線段長度除掉投影後的長度 在上下分別取一點,長度會是線段長度除2 撰寫Vertexshader 投影長度的計算可以直接在JavaScript算,不過既然有WebGL可以用就直接寫成shader吧: glsluniformvec3uColor;uniformfloatuLineWidth;attributevec3lineNormal;varyingvec3vColor;voidmain(){floatwidth=(uLineWidth/lineNormal.z/2.0);vec3pos=vec3(position+vec3(lineNormal.xy,0.0)*width);gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);vColor=uColor;} 這個範例使用Three.js實作,有些參數是由three.js內建的WebGL程式提供的,可以到官方文件WebGLProgram查看更多細節。

這邊我傳入了幾個attribute分別為position(原本的點)、lineNormal(求到的法向量)、vColor(顏色)以及uLineWidth(線寬度)。

實作:BufferGeometry+ShaderMaterial 這次的實作直接採用three.js。

不過背後原理都是相同的,用純canvas+WebGL來寫應該也沒問題。

BufferGeometry jsexportdefaultclassMyLineGeometryextendsBufferGeometry{constructor(points){super()constlineNormal=[]constvertices=[]constindices=[]constlast=points[points.length-1]letcurrentIdx=0points.forEach((p,index)=>{if(index<=points.length-2){indices[index*6+0]=currentIdxindices[index*6+1]=currentIdx+1indices[index*6+2]=currentIdx+2indices[index*6+3]=currentIdx+2indices[index*6+4]=currentIdx+1indices[index*6+5]=currentIdx+3currentIdx+=2}elseif(points.length===2&&index===0){indices[index*6+0]=currentIdxindices[index*6+1]=currentIdx+1indices[index*6+2]=currentIdx+2indices[index*6+3]=currentIdx+2indices[index*6+4]=currentIdx+1indices[index*6+5]=currentIdx+3currentIdx+=2}vertices.push(p[0],p[1],0)vertices.push(p[0],p[1],0)})for(leti=1;i0.1){color=smoothstep(dist-0.02,dist,vUv.y)*color;} gl_FragColor=vec4(color,1.0);} ShaderMaterial的實作: jsimport{shaderMaterial}from"@react-three/drei";import{Color,DoubleSide}from"three";constMyLineShaderMaterial=shaderMaterial({uColor:newColor(0.0,0.0,0.0,1.0),uLineWidth:10,},vertexShader,fragmentShader,(material)=>material.side=DoubleSide)exportdefaultMyLineShaderMaterial; 這邊使用了react-three-fiber當作包裝,一般的three.js也能達到同樣效果。

展示成果 有轉折的線條 正弦 成功了!能夠自訂寬度的WebGL線條終於誕生了。

其實現在的寫法不太好,因為只要改變點的位置就要重新呼叫一次newBufferGeometry,然而我們只需要更新position與lineNormal即可,因此下一個優化方向是讓geometry不需要重新建立實例也可以更新。

下一個問題:去鋸齒化(anti-aliasing) 如果仔細觀察線條,會發現線條的鋸齒化有些明顯(看線條的形狀如何),由於沒有瀏覽器的幫助,在WebGL當中只能靠自己實作去鋸齒化。

產生鋸齒化的原因在於有些線條的邊緣並沒有辦法填滿一個pixel,但是在螢幕當中最小渲染單位就是1pixel,因此就會產生這種邊緣鋸齒化。

在我們的畫線場景下,要處理去鋸齒化有幾個方法: 用fragmentshader幫線條畫出一個柔滑的邊緣 直接用texturemapping處理 實作Prefilteredline處理 目前我的實作是透過fragmentshader,three.js已經幫我們處理uv了,因此很容易透過頂點算出邊界。

glslvaryingvec3vColor;varyingvec2vUv;voidmain(){floatdist=length(vUv-0.5);vec3color=vColor;floatprogress=smoothstep(1.0-0.03,1.0,1.0-dist);if(dist>0.9){vec3col=mix(color,vec3(1,1,1),progress);gl_FragColor=vec4(col,1.0);}else{gl_FragColor=vec4(color,1.0);}} 不過看起來效果似乎沒什麼不同,不確定在three.js當中有沒有做其他處理。

總結 想要隨心所欲地畫一條線比我想像中得還要麻煩,原本以為已經有其他實作可以直接採用,但其他實作又太複雜,自己跳下來實作後才發現原來水比想像中地還深。

有些其他實作細節是可以一併考慮的,然而目前的實作已經達成我要的需求,在這邊當作筆記記錄一下: 線段可以做round→在轉折處需要另外設計頂點座標 每個線段有不同顏色 線段當中的粗細變化 如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯☕️可以讓我平凡的一天變得閃閃發光✨Buymeacoffee



請為這篇文章評分?