WebGLで線を描画する方法
最近、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しかないことに気づく。最初はバグかと思ったが、改めてドキュメントをよく読んでみると、こう書かれていた:
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.
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_STRIP や gl.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 で無理やり作るのはどうだろうか?
できなくはないが、組み込みの Geometry は頂点が事前定義されていることが多く、調整がやや難しい。それに単に線を描くだけなら、頂点数は最小限に抑えたい。
Three.jsのドキュメントを探してみたが、lineWidth をカスタマイズできる Line は存在しないようだった。サポートされていないことにはかなり驚いた。みんな、シンプルな線を描きたいというニーズはないのだろうか?
他のライブラリ
公式には標準対応していないものの、Three.jsの fat-line example にはサンプルがあり、widthの調整はもちろん、dashed(破線)の調整までできる。最初はそのまま組み込もうと思ったが、僕の現在の実装に統合するには少々オーバーキルだった。
もう1つは THREE.Meshline だ。機能も充実しているようで実装もシンプルだが、書き方がやや古いのと、僕が必要としている機能はそこまで多くない。

僕の要件を整理すると、必要なのはこれだけだ:
- 点のペアを渡すと線が描画される
- 太さを調整できる
- 色を調整できる
そこで、自分で実装することに決めた。
解決策:点で面を形成する
3つの点が与えられた場合、1本の線にする方法は簡単で、3つの点を結ぶだけでいい。実際、gl.LINE_STRIP や gl.LINE を使うときはそうやって描画される。しかし先ほど述べたように、lineWidth は1に制限されてしまう。そのため、何とかして幅を広げる必要があり、必然的に gl.TRIANGLE を使って描画しなければ幅を調整できないということになる。
2点ごとに作られるベクトルの法線ベクトルを求め、その上下にそれぞれ点を取ることができる:

ここで、灰色の点が僕たちの欲しい頂点位置であり、この4つの点を vertex として vertex shader に渡す。

こうすることで、頂点数を最小限に抑えつつ、太さを制御する効果も得られる。
実装
2つの点 と が与えられたとき、直線の式 で表すことができる。僕たちがやるべきことは、この方程式に垂直で、P1 と P2 を通るベクトル、つまり法線ベクトルを見つけることだ。
まず P1 から始めよう。まずベクトル を求めると、法線ベクトルは になる。上下(または正負)方向にそれぞれ1点ずつ求めることができ、点の距離は lineWidth / 2 となる。次に P2 でも同様のことを行い、正負方向にそれぞれ1点を求める。これで合計4つの点が得られ、1つの面(2つの三角形)を構成できる。
余談だが、vertex shader が3回呼び出されて初めて1つの三角形が描画されるため、2つの三角形を描くには6つの頂点座標が必要になる。頂点の重複保存によるスペースの無駄を避けるため、インデックス配列を使って頂点座標へのアクセス順序を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:各点に対して法線を求める
2点ごとにベクトルを求め、法線ベクトルを見つけたら上下それぞれに対して線分の長さを取る。結果は以下の通りだ:

このシンプルな方法でも割とうまく機能することが分かる。しかし、問題は角(屈折)が現れる部分で発生する。線を曲げてみると、角の部分の処理が正しくないことが分かる:

色をつけるとさらに顕著になる。

角の部分で線がすぼまってしまっているのが見て取れる。
角の処理
問題の原因は、現在の実装が現在の点と次の点のベクトルから構成される法線ベクトルだけに依存していることにある。この方法では上下の2点が垂直になることしか保証されず、よく考えてみるとこれは僕たちが求めている結果ではない。

ここではすべてのベクトルを単位ベクトル、つまり長さが1であると仮定する。
この図において、僕たちが点を取りたい位置はベクトルAの上下2点だが、先ほどの実装ではベクトルBの2点になってしまっている。これにより、後続で計算される頂点位置が実際には誤ったものになる。
角の部分では、次のベクトルの法線ベクトルだけを取るのではなく、前後の2つのベクトルを足し合わせた法線ベクトルを基準にしなければならない。

元々は線の幅は垂直成分に対して直接掛け合わせるだけでよかったが、現在の角度は A+B の法線ベクトルが基準になっているため、垂直成分を法線ベクトルに投影した長さを別途計算する必要がある。

投影の長さの計算は、2つのベクトルの内積を取るだけだ。ここまで来れば、線を描く戦略を構築できる。
- この点と前の点のベクトル、および次の点のベクトルを求め、それぞれベクトルAとBとする
- Aの法線ベクトルと A+B の法線ベクトルを計算する
- A+B の法線ベクトルをベクトルAに投影した長さを計算する
- 線分の長さを投影後の長さで割る
- 上下にそれぞれ1点取り、その長さは線分の長さを2で割ったものとする
Vertex shader の作成
投影の長さの計算はJavaScript側で直接行うこともできるが、せっかくWebGLが使えるのだからシェーダーとして直接書いてしまおう:
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 を呼び直す必要があるからだ。しかし本来は position と lineNormal を更新するだけで済むはずなので、次の最適化の方向性としては、geometryのインスタンスを再生成しなくても更新できるようにすることだ。
次の課題:アンチエイリアス(anti-aliasing)
線をよく観察してみると、ジャギー(エイリアシング)がやや目立つことに気づく(線の形状にもよるが)。ブラウザのサポートがないため、WebGLでは自前でアンチエイリアスを実装するしかない。
ジャギーが発生する理由は、一部の線のエッジが1ピクセルを満たせないにもかかわらず、画面における最小のレンダリング単位が1ピクセルであるため、こうしたギザギザのエッジが発生してしまうからだ。

今回の線描画のケースにおいて、アンチエイリアスを処理する方法はいくつかある:
- fragment shader を使って線の輪郭をなめらかにぼかす
- テクスチャマッピングを使って直接処理する
- Prefiltered line を実装して処理する
現在の僕の実装では fragment shader を利用している。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 側で何か別の処理が入っているのかどうかは定かではない。
まとめ
思い通りに1本の線を描くことは、想像していたよりもずっと面倒だった。既存の実装をそのまま流用できると思っていたが、それらは複雑すぎた。自分で実際に手を動かしてみて、想像以上に闇が深い世界だと実感した。
他にも考慮できる実装の詳細はあるが、現在の実装で僕の要件はすでに満たせている。メモとしてここに記録を残しておく:
- 線分を丸める(ラウンドキャップ/ジョイント)→ 角で頂点座標を別途設計する必要がある
- 線分ごとに異なる色をつける
- 線分の太さに変化をつける
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。