WebGLシェーダーの基本とテクスチャー表現

目次
月末にセールをするのはやめてほしいです。
この記事は、社内イベント「お茶会」での発表内容をもとにまとめたものです。
今回は、弊社の morisaki が「シェーダー」について発表しました。
フロントエンドの実装で使われる WebGL や Three.js を例に、シェーダーがどのように画面上の表現を作っているのか、基本的な仕組みから実演を交えて紹介したいと思います。
01 シェーダーとは
シェーダーは、GPU 上で動作する描画用のプログラムです。
フロントエンドの実装で使う場合は、WebGL 上で動かすことが多く、GLSL(OpenGL Shading Language)という言語で記述します。ほかにも、DirectX で使われる HLSL や、WebGPU で採用されている WGSL など、用途に応じたシェーディング言語があります。
通常、コンピューターの処理は CPU が担当します。
CPU はひとつひとつの計算は得意ですが、大量の処理を同時に行うことにはあまり向いていません。
一方で GPU は、たくさんの計算を並列で処理することに向いており、何千、何万というパーティクルを動かしたり、画面全体に複雑な表現を加えたりするような場合に力を発揮します。
WebGLで使うシェーダーは、主に 2 種類です。
- Vertex Shader:ジオメトリの各頂点の位置を計算する。
- Fragment Shader:各フラグメントの色を計算する。
処理は、Vertex Shader → Fragment Shader の順に実行されます。
Vertex Shader で計算した値を Fragment Shader へ渡すことはできますが、逆方向には渡せません。
シェーダーを使うことで、Three.js の組み込みマテリアルだけでは難しい質感や動き、細かなエフェクトも制御しやすくなります。もともとは 3D オブジェクトの影や陰影を計算するためのものでしたが、現在では Web 上のさまざまな表現に使われています。
シェーダーで扱う値と関数
シェーダーでは、頂点の位置や色、時間、テクスチャーなどの値を受け取り、計算した結果を描画に反映していきます。
変数の種類(修飾子)
値の渡し方は、修飾子で指定します。代表的なものが、attribute、uniform、varying です。
attribute:頂点ごとに異なる値を渡すための変数。頂点の位置、法線、色、UV座標などに使われる。uniform:オブジェクト全体で共通する値を渡すための変数。光の向き、カメラの視点、時間、変換行列などを扱う。varying:Vertex Shader で計算した値を、Fragment Shader へ渡すための変数。
型
扱う値には型があります。数値、ベクトル、行列、テクスチャーなど、用途に応じて使いわけます。
float:小数。int:整数。bool:真偽値。vec2/vec3/vec4:2〜4次元のベクトル。mat2/mat3/mat4:2〜4次の正方行列。sampler2D:テクスチャー。void:何も返さないことを示す型。
組み込み変数
用途が決まっている組み込み変数もあります。
gl_Position:Vertex Shader で使用。頂点の最終的な位置を指定する。gl_FragCoord:Fragment Shader で使用。現在処理しているフラグメントの画面上の座標を取得する。gl_FragColor:Fragment Shader で使用。出力する色を指定する。
関数
GLSL でも、他のプログラミング言語と同じように関数を作成できます。
float hoge() {
float a = 1.0;
float b = 2.0;
return a + b;
}
float hogehoge(float a, float b) {
return a + b;
}シェーダー内には main 関数が必須で、この関数が自動的に呼び出されます。
Vertex Shader では gl_Position に頂点の位置を入れることで、描画に使う最終的な座標を決めます。
attribute vec3 position;
void main() {
gl_Position = vec4(position, 1.0);
}また、sin、cos、max、min、pow、exp、mod、clamp、cross、dot、mix、step、smoothstep、length、distance、reflect、refract、normalize などの組み込み関数も用意されています。
02 Vertex Shader の基本
Vertex Shader の役割は、3D 空間にあるジオメトリの各頂点を、2D のキャンバス座標に変換することです。
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
attribute vec3 position;
void main() {
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}ここで使われている position は、ジオメトリが持つ頂点のローカル座標です。vec3 なので、値は x、y、z の3つ。最終的に gl_Position に入れるため、vec4(position, 1.0) として4次元の値に変換します。
座標変換の流れ
頂点の座標は、行列を掛けることで段階的に変換されます。
modelMatrix:ローカル座標を、ワールド座標に変換する。viewMatrix:ワールド座標を、カメラから見たビュー座標に変換する。projectionMatrix:ビュー座標を、画面に描画できるクリップ座標に変換する。
つまり、オブジェクト自身の中での位置を、3D空間全体の中での位置へ変換し、さらにカメラから見た位置へ移す。最後に、画面上に表示できる形へ投影していく流れです。
3 つの空間
座標変換では、いくつかの空間を通ります。
- ワールド空間
3D シーン全体の中で、オブジェクトがどこにあるかを表す空間。 - ビュー空間
カメラから見たときに、物体がどの方向にあり、どれくらい離れているかを扱う空間。 - クリップ空間
画面に映る範囲かどうかを判断するための空間。範囲外にあるものはクリッピングされ、描画対象から外れる。
ビュー空間からクリップ空間への変換では、カメラから見た視錐台の空間を、GPU が扱いやすい立方体の空間へ変換します。文章だけだとイメージしづらい部分なので、図で整理すると次のようになります。

行列の中身まで細かく追うと難しくなりますが、まずは「3D 空間の頂点を、画面に映すために段階的に変換している」と捉えておくとわかりやすいです。
行列をわける理由
modelMatrix、viewMatrix、projectionMatrix がわかれているのは、途中で制御を入れられるようにするためです。
たとえば、modelMatrix を掛けたあとの座標を modelPosition として保持しておけば、その時点で頂点の位置を動かせます。
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
attribute vec3 position;
void main() {
vec4 modelPosition = vec4(position, 1.0) * modelMatrix;
// 各頂点座標の移動などの操作はここで行う
modelPosition.x += 1.0; // オブジェクトを右に移動(各頂点の X 座標を 1.0 右に移動)
vec4 viewPosition = modelPosition * viewMatrix;
vec4 projectionPosition = viewPosition * projectionMatrix;
gl_Position = projectionPosition;
}
途中の座標を操作することで、オブジェクト全体を動かしたり、頂点ごとに位置を変えたりできます。
次はこの考え方を使って、平面の頂点を動かしていきます。
03 Fragment Shader の基本
Vertex Shader が頂点の位置を計算するのに対して、Fragment Shader は画面に描画される色を計算します。
具体的には、色、明るさ、透明度などをフラグメントごとに決定するものです。テクスチャーを貼り付けたり、光の反射を計算したり、画面全体にエフェクトをかけたりする処理にも使われます。
基本形は以下です。
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 不透明な赤
}
precision mediump float;
float 型の計算精度を指定するための記述です。
精度には、主に次の 3 種類があります。
highp:最高精度。複雑な計算には向いているが、モバイル端末ではパフォーマンスに影響する場合も。mediump:中精度。速度と画質のバランスがよく、通常はこれを使う。lowp:低精度。一部のモバイル端末では高速に動作する場合があるが、見た目に違和感が出ることも。
基本的には、特別な理由がなければ mediump を使います。
gl_FragColor
Fragment Shader から出力する色を指定するための組み込み変数です。
値は vec4 型で、r・g・b・a(透明度) の 4 つの要素を持ちます。
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);この場合は、赤を最大、緑と青をゼロ、透明度を最大にしているため、不透明な赤になります。
それぞれの値は、基本的に 0.0 から 1.0 の範囲で指定。
たとえば vec4(1.0, 1.0, 1.0, 1.0) なら不透明な白、vec4(0.0, 0.0, 0.0, 1.0) なら不透明な黒になります。
このように Fragment Shader では、各フラグメントに対してどの色を出力するかを計算します。
04 実演してみる
Three.js で表示する準備
実演では、Three.js 上にオブジェクトを表示しながら、少しずつシェーダーを書き換えていきます。
オブジェクトを作る基本は、次の 3 つです。
- Geometry:形を決めるもの。
- Material:見た目を決めるもの。
- Mesh:Geometry と Material を組み合わせたもの。
geometry = new PlaneGeometry(1, 1, 32, 32);
material = new RawShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
side: DoubleSide,
});
mesh = new Mesh(geometry, material);
if (scene) {
scene.add(mesh);
}ここでは、PlaneGeometry で平面を作り、RawShaderMaterial に Vertex Shader と Fragment Shader を指定。
最後に Geometry と Material を組み合わせて Mesh を作り、Scene に追加します。

Material の設定を変えることで、色を変えたり、wireframe で構成線を表示したり、map で画像を貼り付けたりできます。
また、表示したオブジェクトは、OrbitControls を使ってマウス操作で回転・拡大縮小しながら確認することも可能です。
Geometry の分割数を増やすと頂点数が増えるため、変形したときの見た目はなめらかになります。ただし、その分処理は重くなるので注意が必要です。
頂点の座標を変更してみる
ここからは、Vertex Shader の中で頂点の座標を変更してみましょう。
まず使うのは、modelMatrix を掛けたあとの座標です。この座標を modelPosition として変数に入れておくと、x、y、z の値を変更できます。
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += 1.0;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
}この例では、modelPosition.y に 1.0 を足しています。y は上下方向の座標なので、頂点は上方向へ移動します。
ただし、Vertex Shader はオブジェクト全体に一度だけ実行されるものではありません。Geometry を構成する各頂点ごとに実行されます。
そのため、すべての頂点に同じ処理がかかり、結果としてオブジェクト全体が上に移動するという仕組みです。
次は、すべての頂点を同じ方向へ動かすのではなく、頂点ごとに移動量を変えてみます。
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
}
modelPosition.x は、各頂点が持っている X 座標です。左側の頂点と右側の頂点では X 座標が異なるため、sin の計算結果も変わります。
その結果、頂点ごとに Z 座標の移動量が変わり、平面に波のような起伏が生まれます。
modelPosition.x * 10.0 は、波の細かさを調整するための値です。値を大きくすると波の間隔は細かくなり、小さくするとゆるやかな変化になります。
最後の * 0.1 は、Z 方向への動きが大きくなりすぎないようにするための調整です。
ここではまず、X 座標を使って Z 座標を変化させています。
uniform で値を渡して動かす
先ほどのコードでは、波の細かさを 10.0 という固定値で指定していました。
modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;このままだと、波の細かさを変えるたびにシェーダーのコードを書き換える必要があります。
そこで使うのが uniform です。
uniform を使うと、JavaScript 側からシェーダーへ値を渡せます。
material = new RawShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uFrequency: new Uniform(new Vector2(10, 5)),
},
});Vertex Shader 側では、渡された uFrequency を使って計算します。
uniform vec2 uFrequency;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.z += sin(modelPosition.x * uFrequency.x) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y) * 0.1;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
}uFrequency.x は X 方向、uFrequency.y は Y 方向の波の細かさを調整する値です。
固定値を直接書くのではなく uniform にしておくことで、外側から値を変更できるようになります。
値の調整には、GUI のコントローラーも使えます。
gui.add(material.uniforms.uFrequency.value, 'x')
.min(0)
.max(20)
.step(0.1)
.name('Frequency x');
gui.add(material.uniforms.uFrequency.value, 'y')
.min(0)
.max(20)
.step(0.1)
.name('Frequency y');これにより、コードを書き換えなくても、画面上のコントローラーから波の細かさを調整できます。
時間を渡してアニメーションさせる
ここまでの波は、あくまで「波打った形」になっているだけです。
時間に合わせて動かすには、経過時間をシェーダーに渡します。
まず、uTime を uniform として追加します。
material = new RawShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uFrequency: new Uniform(new Vector2(10, 5)),
uTime: new Uniform(0),
},
});Three.js 側では、Clock を使って経過時間を取得します。
その値を描画ループの中で uTime に入れることで、フレームごとに新しい時間をシェーダーへ渡せます。
const elapsedTime = clock.getElapsedTime();
if (material) {
material.uniforms.uTime.value = elapsedTime;
}Vertex Shader 側では、sin の計算に uTime を加えます。
uniform vec2 uFrequency;
uniform float uTime;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.z += sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
}uTime の値が少しずつ変わることで、sin の計算結果も変化します。
その結果、平面が時間に合わせてゆらゆらと波打つように動きます。
波の高さを色に使う
次に、波の高さを色の計算にも使ってみましょう。
今は、sin で計算した値をそのまま modelPosition.z に足しています。
modelPosition.z += sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
modelPosition.z += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;このままだと、波の高さは Vertex Shader の中だけで使われます。
Fragment Shader 側でも同じ値を使いたいので、まずは高さの計算結果を elevation という変数に切り出します。
varying float vElevation;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
float elevation = sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
elevation += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;
modelPosition.z += elevation;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
vElevation = elevation;
}elevation に入れているのは、Z 座標を動かすために使っていた値です。
この値を modelPosition.z に足せば、これまでと同じように平面が波打ちます。
さらに、vElevation に代入することで、Vertex Shader で計算した高さを Fragment Shader に渡せるようになります。
ここで使っている varying は、Vertex Shader から Fragment Shader へ値を渡すための変数です。Vertex Shader 側で代入し、Fragment Shader 側で同じ名前と型を宣言して受け取ります。
precision mediump float;
varying float vElevation;
void main() {
float red = vElevation + 0.5;
gl_FragColor = vec4(red, 0.0, 0.0, 1.0);
}gl_FragColor は、r、g、b、a の4つの値で色を指定します。
ここでは、vElevation を赤の値に使っています。
ただし、elevation はマイナスの値になることもあります。色の値としてそのまま使うと暗くなりすぎるため、0.5 を足して、色の変化が見えるようにしています。
これで、波の高い部分と低い部分で赤の強さが変わるようになります。
頂点の位置を動かすだけでなく、その計算結果を色にも使うことで、立体感のある表現につなげられます。
テクスチャーを貼る
次に、平面に画像をテクスチャーとして貼り付けてみます。
まず、JavaScript 側で画像を読み込み、uTexture という uniform としてシェーダーに渡します。
textureLoader = new TextureLoader();
const texture = textureLoader.load('/flag.jpg');
material = new RawShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uFrequency: new Uniform(new Vector2(10, 5)),
uTime: new Uniform(0),
uTexture: new Uniform(texture),
},
});uTexture は、シェーダー内で使うテクスチャー画像です。
ただし、画像を渡すだけでは、どの位置に画像のどの部分を貼ればよいかわかりません。
そこで使うのが uv です。
uv は、Geometry が持っているテクスチャー用の座標です。
各頂点に対して、画像のどの位置を対応させるかを表しています。
Vertex Shader 側では、Geometry が持つ uv を attribute として受け取り、vUv として Fragment Shader に渡します。
attribute vec2 uv;
varying float vElevation;
varying vec2 vUv;
void main() {
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
float elevation = sin(modelPosition.x * uFrequency.x + uTime) * 0.1;
elevation += sin(modelPosition.y * uFrequency.y + uTime) * 0.1;
modelPosition.z += elevation;
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
vElevation = elevation;
vUv = uv;
}uv は Vertex Shader では使えますが、そのまま Fragment Shader から参照することはできません。
そのため、varying の vUv に代入して、Fragment Shader 側へ渡します。
Fragment Shader 側では、同じ vUv を宣言して受け取ります。
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
vec4 textureColor = texture2D(uTexture, vUv);
gl_FragColor = textureColor;
}texture2D(uTexture, vUv) は、uTexture の中から vUv の座標に対応する色を取得する処理です。
取得した色を gl_FragColor に入れることで、平面に画像が表示されます。
補足として、vUv をそのまま gl_FragColor に入れると、UV 座標の変化をグラデーションとして確認できます。
これで、Fragment Shader 側で画像の色を取得し、各フラグメントの色として出力できるようになります。
次は、このテクスチャーの色に vElevation を掛けて、波の高さに応じた明暗を加えていきます。
テクスチャーに明暗を加える
テクスチャーの色に vElevation を反映して、波の高さに応じた明暗を加えてみます。
先ほどテクスチャーを貼ったときは、texture2D(uTexture, vUv) で取得した色を、そのまま gl_FragColor に渡していました。
vec4 textureColor = texture2D(uTexture, vUv);
gl_FragColor = textureColor;
ここに、波の高さとして受け取っている vElevation を掛けます。
precision mediump float;
uniform sampler2D uTexture;
varying float vElevation;
varying vec2 vUv;
void main() {
vec4 textureColor = texture2D(uTexture, vUv);
textureColor.rgb *= vElevation * 1.5 + 0.5;
gl_FragColor = textureColor;
}
textureColor.rgb は、テクスチャーの r、g、b の値です。
ここに vElevation を使った値を掛けることで、波の高い部分と低い部分で明るさが変わります。
vElevation * 1.5 + 0.5 のうち、1.5 は明暗の差を強めるための値です。0.5 は、全体が暗くなりすぎないようにするための調整です。
これで、ただ画像を貼るだけでなく、波の動きに合わせてテクスチャーの見え方も変化するようになります。
Vertex Shader で計算した高さを Fragment Shader の色計算にも使うことで、より立体感のある表現に近づけられます。
応用例:GPGPU と Flow Field
シェーダーを応用すると、大量のパーティクルを動かすような表現にもつなげられます。
その一例が、GPGPU と Flow Field を使った表現です。
GPGPU は、描画だけでなく計算処理にも GPU を活用する手法です。
Flow Field は、空間上に「どの方向へ進むか」という流れを作り、パーティクルをその流れに沿って動かす考え方です。
このようなパーティクル表現は、以前ご紹介したピエール・エルメ・パリ「NOËL」特設サイトの演出にも使われています。
05 まとめ
シェーダーを使うと、頂点の位置やフラグメントの色を自分で計算し、Three.js の標準 Material だけでは難しい表現を作ることができます。
今回は、Vertex Shader で平面の頂点を動かし、uniform で時間を渡してアニメーションさせました。
さらに、その高さ情報を Fragment Shader に渡すことで、色やテクスチャーの見え方にも反映しています。
GLSL の記述や座標変換など、最初は難しく感じる部分もあります。
一方で、少ないコードでも見た目に大きな変化を出せるのが、シェーダーの面白いところです。
さらに発展させると、今回最後に触れたような大量のパーティクル表現にもつながります。
このあたりは奥が深いので、また別の機会に詳しく紹介できればと思います。

S2ファクトリー株式会社
様々な分野のスペシャリストが集まり、Webサイトやスマートフォンアプリの企画・設計から制作、システム開発、インフラ構築・運用などの業務を行っているウェブ制作会社です。
実績
案件のご依頼、ご相談、その他ご質問はこちらからお問い合わせください。






