視覺化學習:使用WebGL繪製圓形,實現色盤

beckyye發表於2024-04-12

前言

在Canvas2D中實現圓形的繪製比較簡單,只要呼叫arc指令就能在Canvas畫布上繪製出一個圓形,類似的,在SVG中我們也只需要一個<circle>標籤就能在頁面上繪製一個圓形。那麼在WebGL中我們要怎麼去繪製呢?WebGL只能繪製三種形狀:點、線段和三角形,它沒有提供直接繪製圓形的功能,當然也無法像SVG一樣使用標籤,所以我們是無法直接繪製圓形曲線的,這個時候我們可以藉助相關的數學知識,來實現圓形的繪製。

引數方程

相信數學基礎好的小夥伴一定能很快想到,我們可以使用引數方程去獲取圓形曲線上的點的座標,只要我們收集足夠多的點,再透過繪製線段的方式將這些點連線起來,就能得到接近圓的圖形,從視覺上看就是一個圓形了。其實圓形就是曲線中的一個特例,所以也就是說我們還可以透過引數方程繪製其他常見的曲線,比如圓、橢圓、拋物線、正餘弦曲線等等。

以下是圓的引數方程:

\[\begin{cases} x = x0 + r * cos(θ)\\ y = y0 + r * sin(θ)\\ \end{cases} \]

在圓的引數方程中,可以使用圓心座標、半徑和夾角的正餘弦值來表示橫縱座標的值。

具體實現

按照這個思路,我們就可以編寫程式碼來繪製圓形曲線了。

在正式實現之前,在HTML中準備一個Canvas:

<canvas ref="webglRef" width="256" height="256"></canvas>

在之後的程式碼中會用到我自己之前簡單封裝的一個WebGL的類,只是封裝了一些繁瑣的建立著色器程式的步驟,封裝的比較粗糙。下面就開始具體的實現。

  • 首先,定義函式獲取圓形曲線的頂點集合。

    const TAU_SEGMENTS = 60;
    const TAU = Math.PI * 2;
    // 獲得圓形曲線頂點集合
    function arc(x0, y0, radius, startAng = 0, endAng = Math.PI * 2) {
      const ang = Math.min(TAU, endAng - startAng);
      const ret = ang === TAU ? []: [[x0, y0]];
      const segments = Math.round(TAU_SEGMENTS * ang / TAU);
      for (let i = 0; i <= segments; i ++) {
        const x = x0 + radius * Math.cos(startAng + ang * i / segments);
        const y = y0 + radius * Math.sin(startAng + ang * i / segments);
        ret.push([x, y]);
      }
      return ret;
    }
    

    x0和y0是圓心座標,radius是半徑,startAng和endAng表示圓弧的起始角度和結束角度,對於整個圓來說,就是從0到2π,這些引數都比較好理解。

    再來看arc這個函式的內部變數,ang好理解,就是結束角度和起始角度的差值;segments表示要在圓弧上取的點的總數,如果是整個圓就取60個點。

    接著就是遍歷,獲取segments數量的點的座標,並儲存在ret陣列中。

  • 這樣,我們就可以呼叫arc函式來獲取頂點集合了。

    const vertices = arc(0, 0, 0.8);
    

    因為在WebGL中座標系在視口的座標範圍預設是-1到1,要在視口中看到整個圓,這個圓的半徑不能超過1,所以這裡半徑我取0.8,圓心為(0, 0),然後獲取到頂點集合。

  • 建立WebGL程式並繪製。

    WebGL部分的程式碼就比較簡單了,首先是兩段GLSL程式碼,和常見的實現三角形的GLSL程式碼沒什麼太大區別:

    const vertex = `
      attribute vec2 position;
    
      void main() {
        gl_PointSize = 1.0;
        gl_Position = vec4(position, 1, 1);
      }
    `;
    const fragment = `
      precision mediump float;
    
      void main() {
        gl_FragColor = vec4(0, 0, 0, 1);
      }
    `;
    

    因為透過引數方程獲取到的是連續的點,所以我們可以透過gl.LINE_LOOP的繪圖模式,將所有的點串聯起來,這樣就得到了一個視覺上的圓形曲線。

    const gl = webglRef.value.getContext('webgl');
    const webgl = new WebGL(gl, vertex, fragment);
    webgl.drawSimple(vertices.flat(), 2, gl.LINE_LOOP);
    

    具體在封裝的drawSimple方法中我呼叫了gl.drawArrays來繪製圖形。

    gl.drawArrays(gl.LINE_LOOP, 0, points.length / size);
    

實際操作下來能發現,其實繪製圓形曲線還比較簡單,所以我們還可以嘗試去實現色盤。

色盤是一個實心的圓,就不能透過線條的方式去繪製了,之前在《利用向量判斷多邊形邊界》中我們有提到過,對於多邊形我們可以把它們看做是由多個三角形組合而成的圖形,因此我們可以對多邊形進行三角剖分,也就是使用多個三角形的組合來表示一個多邊形,把這些三角形都繪製到畫布上就組成了多邊形,而圓形我們就可以把它看做是一種特殊的多邊形。

因為三角剖分演算法比較複雜,我們可以直接呼叫現有的庫來完成這個操作,之前使用的是earcut這個庫,現在我們換一個叫TESS2的庫,更詳細的介紹可以檢視它的github倉庫,下面我們就呼叫TESS2的API來完成三角剖分操作。

webgl.drawPolygonTess2(vertices);
// ↓↓ 
drawPolygonTess2(points, {
    color,
    rule = WINDING_ODD/*WINDING_NONZERO*/
} = {}) {
    const triangles = tess2Triangulation(points, rule);
    triangles.forEach(t => this.drawTriangle(t, {color}));
}
// ↓↓
function tess2Triangulation(points, rule = WINDING_ODD) {
    const res = tesselate({
        contours: [points.flat()],
        windingRule: rule,
        elementType: POLYGONS,
        polySize: 3,
        vertexSize: 2,
        strict: false
    });
    const triangles = [];
    for (let i = 0; i < res.elements.length; i += 3) {
        const a = res.elements[i];
        const b = res.elements[i + 1];
        const c = res.elements[i + 2];
        triangles.push([
            [res.vertices[a * 2], res.vertices[a * 2 + 1]],
            [res.vertices[b * 2], res.vertices[b * 2 + 1]],
            [res.vertices[c * 2], res.vertices[c * 2 + 1]],
        ])
    }
    return triangles;
}

這樣我們就繪製了一個黑色的實心圓。

要實現色盤,我們需要使用HSV或者HSL的顏色表示形式,因為色相Hue的取值範圍是0到360度,所以這兩種顏色表示形式可以讓我們直接把色值和角度關聯起來,因此我們可以透過varying變數將座標資訊傳遞給片元著色器,然後在片元著色器中使用座標資訊計算hsv形式的畫素色值。

// vertex
attribute vec2 position;
varying vec2 vP;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(position, 1, 1);
  vP = position;
}

但是WebGL中還無法直接處理HSV的顏色表示形式,所以我們需要使用hsv2rgb函式來完成顏色向量的轉換,這其中具體的轉換演算法我也並不是很懂,感興趣的小夥伴可以自行研究。

// fragment
#define PI 3.1415926535897932384626433832795
precision mediump float;

varying vec2 vP;

// hsv -> rgb
// 引數的取值範圍都是 (0, 1)
vec3 hsv2rgb(vec3 c) {
  vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

void main() {
  float x0 = 0.0;
  float y0 = 0.0;
  float h = atan(vP.y - y0, vP.x - x0);
  h = h / (PI * 2.0); // 歸一化處理
  vec3 hsv_color = vec3(h, 1.0, 1.0);
  vec3 rgb_color = hsv2rgb(hsv_color);
  gl_FragColor = vec4(rgb_color, 1.0);
}

在上述程式碼中,我們呼叫atan函式計算得到以(0,0)為圓心的弧度值,再除以得到一個歸一化的值,然後將這個歸一化的值透過hsv2rgb函式轉化RGB顏色向量。

這樣我們就使用WebGL實現了一個色盤。如果我們想要顏色的過渡顯得更自然,還可以設定使飽和度隨著半徑增大而增大。

void main() {
  // ...
  float r = sqrt((vP.x - x0) * (vP.x - x0) + (vP.y - y0) * (vP.y - y0)); // 計算半徑

  vec3 hsv_color = vec3(h, r * 1.2, 1.0);
  // ...
}

好啦,那看到這裡的小夥伴應該都知道如何繪製圓形,如何實現色盤了吧,可以自己動手實踐一下。

相關文章