半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

本部落格使用 Gatsby 製作

本部落格有使用 Google Analytic 及 Cookie

前端

用 WebGL 畫線比我想像中地還難

用 WebGL 畫線比我想像中地還難

最近想做一些 canvas 的互動效果,為了盡可能讓效果更加生動及透過 GPU 加速,很自然地 WebGL 與 Three.js 成為了首選。用 Three.js 寫起來相當寫意,不過很快地也遇到一個大問題,用 Three.js 很難隨心所欲地畫出一條線

lineWidth 無法作用

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

js
const points = [p1, p2, p3];
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const line = new THREE.Line(geometry, material);

聽起來相當簡單對吧?然而畫面渲染出來之後會發現線條的粗度比想像中細很多,事實上只有 1px。原本以為是 bug,後來再仔細翻翻文件之後發現有寫到:

Due to limitations of the OpenGL Core Profile with the WebGL renderer on most platforms linewidth will always be 1 regardless of the set value.

意思是儘管在 OpenGL 當中有提供 lineWidth API 可以使用,但在大部分的平台(各大瀏覽器幾乎都不行)上都會忽略這個函數並設定為 1。

為了測試我的認知是否正確,這裏用純 WebGL 版本寫了一個範例:

See the Pen webGL-line by 愷開 (@kjj6198) on CodePen.

javascript
function main() {
var gl = initGL();
var shaderProgram = initShaders(gl);
var vertices = createPoints(gl, shaderProgram);
gl.lineWidth(100.0);
draw(gl, vertices);
}

可以發現儘管呼叫了 gl.lineWidth(100.0),顯示出來的線條寬度仍然只有 1。現代人的螢幕都是 4K 起跳,1px 的線真的很醜又難看,因此用 gl.LINE_STRIPgl.LINE 這條路很明顯走不通(除非你就是要這種效果)。

後來查了 MDN 的文件說明也是同樣的描述:

The maximum minimum width is allowed to be 1.0. The minimum maximum width is also allowed to be 1.0. Because of these implementation defined limits it is not recommended to use line widths other than 1.0 since there is no guarantee any user's browser will display any other width.

唯一有實作 lineWidth 的平台竟然還是 IE11,真是不勝唏噓。

使用其他 Geometry

竟然用 Line 不行,那麼用 PlaneGeometry 或是 ShapeGeometry 硬做出來可不可以?

雖然可以是可以,但 built-in 的 Geometry 的頂點往往都已經事先設定好了,要調整比較困難一些,而且如果只是畫線的話,我希望能夠限制頂點數量在最小範圍內。

找了一下 Three.js 的文件看起來似乎沒有可以客製化 lineWidth 的 Line,沒有支援讓我還蠻驚訝的,大家都沒有簡單的畫線需求嗎?

其他 Library

雖然官方上沒有內建支援,但 Three.js 的 fat-line example 上其實有範例,可以調整 width,甚至還可以調整 dashed 。原本想要直接套入使用,但要整合到我目前的實作似乎有點 overkill 了。

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

GitHub THREE MeshLine

整理一下我的需求,我要的只有:

  • 送點對進去後畫出線條
  • 可以調整寬度
  • 可以調整顏色

於是決定自己動手寫一個。

解決方法:用點形成面

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

我們可以將每兩點形成的向量求法向量後分別在上下取兩點:

其中,灰色的點就是我們想要的頂點位置,然後把這四個點當作 vertex 送給 vertex shader。

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

實作

給定兩個點 P1=(0,0)P*1=(0,0)P2=(1,1)P*{2}=(1,1),可以透過一個直線方程式來表示:xy=0x-y=0。那麼我們要做的事是在直線方程式中尋找垂直於此方程式的並通過 P1 與 P2 的向量,也就是法向量。

先從 P1 開始好了,我們先求得向量 P12=(1,1)P_{12}=(1, 1),法向量則為 (1,1)(-1,1)。我們可以分別向上、下(或正負)各求得一點,點的距離會是 lineWidth / 2;接下來到 P2 也是做一樣的事,分別求正負方向的一點。這樣一來總共會有四個點,可以組成一個面(兩個三角形)。

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

javascript
const indices = [0, 1, 2, 2, 1, 3]; // 告訴 webGL 存取頂點的順序
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(indices), gl.STATIC_DRAW);

方法一:對每個點求法線

每兩個點找向量,找到法向量後分別對上下求線段長度。效果如下:

可以發現,這個簡單的方法還挺有效的,然而問題會發生在有轉折出現的地方,我們讓線條轉折後可以發現在轉折處的部分處理似乎不正確:

上色之後會更加明顯。

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

轉折處處理

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

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

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

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

A+B的法向量

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

投影的長度計算就是取兩個向量的內積。到這邊,我們已經可以建構出畫線的策略。

  • 求這個點與上個點的向量與下一個點的向量,假設分別為向量 A 與 B
  • 計算 A 的法向量與 A+B 的法向量
  • 計算 A+B 的法向量投影在 A 向量的長度
  • 將線段長度除掉投影後的長度
  • 在上下分別取一點,長度會是線段長度除 2

撰寫 Vertex shader

投影長度的計算可以直接在 JavaScript 算,不過既然有 WebGL 可以用就直接寫成 shader 吧:

glsl
uniform vec3 uColor;
uniform float uLineWidth;
attribute vec3 lineNormal;
varying vec3 vColor;
void main() {
float width = (uLineWidth / lineNormal.z / 2.0);
vec3 pos = 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

js
export default class MyLineGeometry extends BufferGeometry {
constructor(points) {
super()
const lineNormal = []
const vertices = []
const indices = []
const last = points[points.length - 1]
let currentIdx = 0
points.forEach((p, index) => {
if (index <= points.length - 2) {
indices[index * 6 + 0] = currentIdx
indices[index * 6 + 1] = currentIdx + 1
indices[index * 6 + 2] = currentIdx + 2
indices[index * 6 + 3] = currentIdx + 2
indices[index * 6 + 4] = currentIdx + 1
indices[index * 6 + 5] = currentIdx + 3
currentIdx += 2
} else if (points.length === 2 && index === 0) {
indices[index * 6 + 0] = currentIdx
indices[index * 6 + 1] = currentIdx + 1
indices[index * 6 + 2] = currentIdx + 2
indices[index * 6 + 3] = currentIdx + 2
indices[index * 6 + 4] = currentIdx + 1
indices[index * 6 + 5] = currentIdx + 3
currentIdx += 2
}
vertices.push(p[0], p[1], 0)
vertices.push(p[0], p[1], 0)
})
for (let i = 1; i < points.length; i++) {
const point = points[i]
const prev = points[i - 1]
const next = points[i + 1] || null
const a = new Vector2(point[0] - prev[0], point[1] - prev[1]).normalize()
if (i === 1) { // first point
lineNormal.push(-a.y, a.x, 1)
lineNormal.push(-a.y, a.x, -1)
}
if (!next) {
lineNormal.push(-a.y, a.x, 1)
lineNormal.push(-a.y, a.x, -1)
} else {
const b = new Vector2(next[0] - point[0], next[1] - point[1]).normalize().add(a)
const c = new Vector2(-b.y, b.x)
const projection = c.clone().dot(new Vector2(-a.y, a.x).normalize())
lineNormal.push(c.x, c.y, projection)
lineNormal.push(c.x, c.y, -projection)
}
}
this.setAttribute('position', new BufferAttribute(new Float32Array(vertices), 3));
this.setAttribute('lineNormal', new BufferAttribute(new Float32Array(lineNormal), 3)); // [x, y, projection]
this.setIndex(new BufferAttribute(new Uint16Array(indices), 1));
const indexAttr = this.getIndex()
this.position.needsUpdate = true;
indexAttr.needsUpdate = true;
}
}

fragment shader

glsl
varying vec3 vColor;
varying vec2 vUv;
void main() {
float dist = length(vUv - 0.5);
vec3 color = vColor;
if (dist > 0.1) {
color = smoothstep(dist - 0.02, dist, vUv.y) * color;
}
gl_FragColor = vec4(color, 1.0);
}

ShaderMaterial 的實作:

js
import { shaderMaterial } from "@react-three/drei";
import { Color, DoubleSide } from "three";
const MyLineShaderMaterial = shaderMaterial(
{
uColor: new Color(0.0, 0.0, 0.0, 1.0),
uLineWidth: 10,
},
vertexShader,
fragmentShader,
(material) => material.side = DoubleSide
)
export default MyLineShaderMaterial;

這邊使用了 react-three-fiber 當作包裝,一般的 three.js 也能達到同樣效果。

展示成果

有轉折的線條

正弦

成功了!能夠自訂寬度的 WebGL 線條終於誕生了。

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

下一個問題:去鋸齒化(anti-aliasing)

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

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

在我們的畫線場景下,要處理去鋸齒化有幾個方法:

  • 用 fragment shader 幫線條畫出一個柔滑的邊緣
  • 直接用 texture mapping 處理
  • 實作 Prefiltered line 處理

目前我的實作是透過 fragment shader,three.js 已經幫我們處理 uv 了,因此很容易透過頂點算出邊界。

glsl
varying vec3 vColor;
varying vec2 vUv;
void main() {
float dist = length(vUv - 0.5);
vec3 color = vColor;
float progress = smoothstep(1.0 - 0.03, 1.0, 1.0 - dist);
if (dist > 0.9) {
vec3 col = mix(color, vec3(1, 1, 1), progress);
gl_FragColor = vec4(col, 1.0);
} else {
gl_FragColor = vec4(color, 1.0);
}
}

不過看起來效果似乎沒什麼不同,不確定在 three.js 當中有沒有做其他處理。

總結

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

有些其他實作細節是可以一併考慮的,然而目前的實作已經達成我要的需求,在這邊當作筆記記錄一下:

  • 線段可以做 round → 在轉折處需要另外設計頂點座標
  • 每個線段有不同顏色
  • 線段當中的粗細變化

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️
可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee