WebGLを使って線を描くことは、私が想像していたよりも難しいです。

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

最近、canvas のインタラクティブな効果を作りたいと思い、できるだけ生き生きとした効果を出すために GPU アクセラレーションを利用しようとすると、自然に WebGL と Three.js が選択肢となりました。Three.js でのコーディングは非常に楽しいのですが、すぐに大きな問題に直面しました。Three.js では自由に線を描くのが難しいのです。

lineWidth が効かない

Three.js では LineBasicMaterial を使って線を描くことができ、BufferGeometry を通じてポイントの位置を渡すことで、思い通りの効果を得ることができます。

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 しかありませんでした。最初はバグだと思っていたのですが、ドキュメントを詳しく確認すると、以下のように書かれていました:

一部のプラットフォームでの WebGL レンダラーにおける OpenGL Core Profile の制約により、linewidth は設定された値に関わらず常に 1 になります。

つまり、OpenGL には lineWidth API が存在しますが、ほとんどのプラットフォーム(主要なブラウザではほぼ無理です)ではこの関数が無視され、1 に固定されてしまうのです。

自分の認識が正しいか確認するために、こちらに純粋な WebGL バージョンでサンプルを書いてみました:

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

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 の ドキュメント を調べると、同様の説明がありました:

最大の最小幅は 1.0 とされています。最小の最大幅も 1.0 とされています。これらの実装に定義された制限のため、1.0 以外のライン幅を使用することは推奨されません。ユーザーのブラウザが他の幅を表示する保証はありません。

lineWidth を実装している唯一のプラットフォームが IE11 だとは、なんとも悲しいことです。

他の Geometry を使う

Line が使えないなら、PlaneGeometryShapeGeometry を使って無理やり描けるでしょうか?

可能ではありますが、組み込みの Geometry の頂点は事前に設定されていることが多く、調整が難しいです。また、線を描くことだけが目的の場合、頂点数を最小限に抑えたいと思っています。

Three.js のドキュメントを確認したところ、lineWidth をカスタマイズできる Line がないようで、ちょっと驚きました。みんな、簡単な線を描くニーズはないのでしょうか?

他のライブラリ

公式には内蔵サポートはありませんが、Three.js の fat-line example に実際に幅を調整でき、さらにはダッシュも調整できる例があります。これを直接使おうと思ったのですが、現在の実装に統合するには少し過剰でした。

もう一つは THREE.Meshline というライブラリで、機能が豊富で実装も簡単ですが、書き方が少し古いのと、私が求める機能があまり多くありません。

GitHub THREE MeshLine

私のニーズを整理すると、必要なものは以下の通りです:

  • ポイントを送ったら線を描く
  • 幅を調整できる
  • 色を調整できる

そこで、自分で書くことに決めました。

解決方法:点で面を形成する

三つの点があれば、線を結ぶのは非常に簡単です。この三つの点をつなげるだけです。実際には gl.LINE_STRIPgl.LINE の時もこのように描きますが、先ほど述べたように、lineWidth は 1 に制限されるため、幅を広げる必要があります。これはつまり、gl.TRIANGLE を使って描画しなければならないということです。

二つの点から形成されるベクトルの法線ベクトルを求め、それぞれ上下に二つの点を取ります:

灰色の点が私たちが求める頂点の位置で、これらの四つの点を 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 に頂点座標へのアクセス方法を教えます。

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 に書いてしまいましょう:

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

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

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 の実装:

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 を更新するだけで済むため、次の最適化の方向性は、ジオメトリを再インスタンス化せずに更新できるようにすることです。

次の問題:アンチエイリアス(anti-aliasing)

線をよく観察すると、線の鋸歯状が目立つことがわかります(線の形状によりますが)。ブラウザの助けがないため、WebGL では自分でアンチエイリアスを実装する必要があります。

鋸歯状が発生する理由は、一部の線のエッジが 1 ピクセルを満たすことができないからです。しかし、画面の最小レンダリング単位は 1 ピクセルなので、このようなエッジの鋸歯状が発生します。

私たちの線描画シーンでは、アンチエイリアスを処理する方法はいくつかあります:

  • フラグメントシェーダーを使用して線に滑らかなエッジを描く
  • テクスチャマッピングを直接使用して処理する
  • Prefiltered line 処理を実装する

現在の実装はフラグメントシェーダーを通じて行っています。Three.js がすでに UV を処理してくれているため、非常に簡単に頂点から境界を計算できます。

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 で他に処理が行われているかどうかは不明です。

まとめ

自由に線を描くことは、私の想像以上に面倒でした。他の実装がそのまま使えると思っていましたが、他の実装はあまりにも複雑で、実装してみて初めてその深さに気づきました。

他の実装の細かい部分も考慮に入れることができますが、現在の実装はすでに私のニーズを満たしています。ここでメモとして記録しておきます:

  • 線分は丸くすることができる → 折れ曲がりの部分には別途頂点座標を設計する必要があります。
  • 各線分は異なる色を持つことができます。
  • 線分の太さに変化をつけることができます。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee