頂点シェーダ(Vertex Shader)

各頂点について 1 回呼ばれ、最終的なクリップ空間座標 gl_Positionを計算するのが仕事。 ついでに各頂点ごとの値をfragment へ varying(out)で渡すのも担当する。

役割

  1. 入力: 頂点1つ分の position / normal / uv
  2. 処理: model行列 × view行列 × projection行列でクリップ空間に変換
  3. 出力1: gl_Position に書き込む(必須)
  4. 出力2: fragment 用の値を out で書く(任意、必要なら)

最小コード

Three.js の ShaderMaterial で動く最小例:

vertex.glsl(GLSL ES 3.00)
#version 300 es
in vec3 position;
in vec3 normal;
in vec2 uv;

uniform mat4 modelMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

out vec2 vUv;
out vec3 vNormal;

void main() {
  vUv = uv;
  vNormal = normalize(normalMatrix * normal);

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Three.js はこれらの uniforms / attributes を自動で渡すので、 実際にコードに書くのは main() 中身と out の宣言だけ。

Three.js での書き方(自動 uniform 任せ)
varying vec2 vUv;
varying vec3 vNormal;

void main() {
  vUv = uv;
  vNormal = normalize(normalMatrix * normal);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

4つの座標空間

頂点シェーダの世界では位置が4つの段階を経る。混乱しがちなので意識すると整理しやすい。

空間意味得る方法
1. ローカル空間そのメッシュ内の座標position(attribute)
2. ワールド空間シーン全体の座標modelMatrix * vec4(position, 1.0)
3. ビュー空間カメラから見た座標viewMatrix * worldPos または modelViewMatrix * vec4(position, 1.0)
4. クリップ空間正規化された出力空間projectionMatrix * viewPos

多くのライティング計算はワールド空間 or ビュー空間で行う。 どちらでやってもいいが、混在させると壊れるので統一する。

ワールド座標を得る

vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPos;

ビュー座標を得る

vec4 viewPos = modelViewMatrix * vec4(position, 1.0);
vViewPos = viewPos.xyz;
gl_Position = projectionMatrix * viewPos;

法線の変換

法線は位置と同じ行列で変換してはいけない。スケールが入ると法線が崩れる。 normalMatrix(Three.js が自動で渡す mat3)を使う。

vec3 worldNormal = normalize(mat3(modelMatrix) * normal);
vec3 viewNormal  = normalize(normalMatrix * normal);   // Three.js

normalMatrixtranspose(inverse(modelViewMatrix)) の上 3×3。 非等方スケールがある時に正しく法線を変換できる。

varying(fragment へ渡す値)

fragment shader で使う値を頂点シェーダで書き出す。三角形の中で線形補間されて届く。

// vertex
out vec2 vUv;
out vec3 vNormal;
out vec3 vWorldPos;

void main() {
  vUv = uv;
  vNormal = normalize(mat3(modelMatrix) * normal);
  vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

// fragment
in vec2 vUv;
in vec3 vNormal;
in vec3 vWorldPos;

名前と型を一致させるのが必須。typo するとリンクエラー。

補間の注意

varying は三角形の中で線形補間される。だが法線などは補間で「縮む」。 fragment 側で再度 normalize() するのが定石。

// fragment 側
in vec3 vNormal;
void main() {
  vec3 N = normalize(vNormal);   // 補間で縮んだのを戻す
  ...
}

flat / smooth / centroid(ES 3.00+)

補間方法を制御できる:

頂点を変形する

頂点シェーダで頂点位置を加工すると、波・歪み・揺らぎが作れる。

波打つ平面
uniform float uTime;

void main() {
  vec3 pos = position;
  pos.z += sin(pos.x * 4.0 + uTime) * 0.2;
  pos.z += sin(pos.y * 4.0 + uTime * 0.7) * 0.1;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

ジオメトリのセグメント数が頂点の細かさを決める。PlaneGeometry(2, 2, 64, 64) で 64×64 のグリッドになる。 頂点が少ないと滑らかな波にならない。

パーティクル的なバラ撒き

in float aRandom;   // 頂点ごとのランダム値(attribute で渡す)

void main() {
  vec3 pos = position;
  pos.y += sin(uTime + aRandom * 6.28) * 0.5;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

頂点ごとの色(Vertex Color)

頂点に色 attribute を持たせて、fragment で受ける:

// vertex
in vec3 color;     // attribute(geometry に setAttribute で設定)
out vec3 vColor;
void main() {
  vColor = color;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

// fragment
in vec3 vColor;
out vec4 outColor;
void main() {
  outColor = vec4(vColor, 1.0);
}

InstancedMesh / Instancing

InstancedMesh では各インスタンスに行列・色・データを持たせられる。gl_InstanceID も使える。

in vec3 position;
in mat4 instanceMatrix;     // Three.js の InstancedMesh が渡す

void main() {
  vec4 worldPos = modelMatrix * instanceMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * viewMatrix * worldPos;
}

gl_PointSize(Points 描画時)

THREE.Points で点群を描く時、頂点シェーダで点のピクセルサイズを指定する。

void main() {
  vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * mvPos;
  gl_PointSize = 200.0 / -mvPos.z;   // カメラに近いほど大きく
}

Skinning(スキンメッシュ)

ボーンのある GLTF モデルでは、各頂点が複数のボーンに重みで紐づく。 Three.js の SkinnedMesh専用の chunkでスキニング計算を提供する(自前で全部書くのは大変)。

詳しくは onBeforeCompile パターンで必要な chunk を #include する。

頂点シェーダの典型パターン集

position をそのままワールドに(モデル行列なし)

gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0);

カメラと同じ向きを維持(ビルボード)

// modelViewMatrix の回転成分を消す
mat4 mv = modelViewMatrix;
mv[0].xyz = vec3(1.0, 0.0, 0.0) * length(mv[0].xyz);
mv[1].xyz = vec3(0.0, 1.0, 0.0) * length(mv[1].xyz);
mv[2].xyz = vec3(0.0, 0.0, 1.0) * length(mv[2].xyz);
gl_Position = projectionMatrix * mv * vec4(position, 1.0);

変形してから法線を再計算(高度)

頂点を動かしたら法線も合わせないと正しいライティングにならない。簡易的には2方向の差分から法線を求める:

vec3 displaced(vec3 p) {
  return p + vec3(0.0, sin(p.x * 4.0 + uTime) * 0.2, 0.0);
}

void main() {
  vec3 p  = displaced(position);
  vec3 px = displaced(position + vec3(0.001, 0.0, 0.0));
  vec3 py = displaced(position + vec3(0.0, 0.0, 0.001));
  vNormal = normalize(cross(px - p, py - p));

  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}

よくあるバグ

頂点シェーダで「やらないほうがいい」こと

GPU は頂点ごとの並列計算が得意だが、頂点間で連携する処理は不得意。 隣接頂点を見て何かする、頂点同士で値を共有する、といった処理は無理(compute shader / FBO 経由なら可能だが大がかり)。