【WebGL入門】ミミズの行進をシェーダーで再現する!
〜SDFとレイマーチングで作るリアルタイムレンダリング解説〜
こんにちは、現役エンジニアの@YushiYamamotoです。
今回は、HTML、CSS、そしてJavascript(WebGL2)を駆使して「ミミズの行進」を再現するシェーダーコードをご紹介します。
一見非現実的に見えるこのコードですが、実はシンプルなSDF(Signed Distance Function)とレイマーチングのテクニックを組み合わせることで実現しています。
プログラミング初心者の方にも分かりやすいよう、コードの構造や各処理の役割を丁寧に解説していきます。😊
See the Pen 蚯蚓の行進 by Yushi Yamamoto (@yamamotoyushi) on CodePen.
目次
はじめに
WebGL2は、ブラウザ上でハードウェアアクセラレーションを利用した3Dグラフィックスを実現する強力なAPIです。
中でも今回解説するシェーダーコードは、SDF と呼ばれる技法を用い、シーン内の物体(ここではミミズのような生物)を数学的に定義し、さらにレイマーチングという手法でレンダリングしています。
まるで小さなミミズたちが行進しているかのような動きを実現するこのコードを、実際の実装を通して理解していきましょう!
プロジェクト概要
今回のプロジェクトは、以下の3つのパートで構成されています。
-
HTML
キャンバス要素を用意し、WebGLレンダリングの土台を作ります。 -
CSS
シンプルなスタイルで余白をリセットし、キャンバスが画面全体に広がるように設定します。 -
Javascript(WebGL2)
WebGL2コンテキストの作成、シェーダーのコンパイルとリンク、そしてレンダリングループの実装を行っています。
下記のシンプルなフローチャートで全体の流れを確認してみましょう。
[HTML]
│
▼
<canvas id="myCanvas">
│
▼
[CSS]
(余白リセット・フルスクリーン表示)
│
▼
[Javascript]
│
├── WebGL2コンテキストの取得
├── シェーダー(頂点/フラグメント)の準備
├── 頂点バッファ(全画面クワッド)の設定
├── uniform変数(解像度、時間、マウス位置)の更新
└── レンダリングループによる描画
HTMLとCSSによる準備
まずは、WebGL描画に必要なキャンバスと、全体レイアウトのシンプルなスタイルから始めます。
HTML
<!-- index.html -->
<canvas id="myCanvas"></canvas>
-
ポイント
-
<canvas>
要素にid="myCanvas"
を付与し、Javascript側から簡単にアクセスできるようにしています。
-
CSS
/* styles.css */
body {
margin: 0;
overflow: hidden;
}
-
ポイント
-
margin: 0;
により、ブラウザのデフォルト余白をリセットします。 -
overflow: hidden;
でウィンドウサイズを超える部分を非表示にし、キャンバスが画面全体にフィットするようにしています。
-
JavascriptでWebGL2をセットアップする
Javascript側では、以下の処理を順次実行します。
-
WebGL2コンテキストの取得
キャンバスからWebGL2コンテキストを取得し、ハードウェアアクセラレーションを利用できるようにします。 -
シェーダーの作成・コンパイル・リンク
頂点シェーダーとフラグメントシェーダーを用意し、レンダリングプログラムを組み立てます。 -
頂点バッファの設定
画面全体を覆うための四角形(クワッド)の頂点データを用意し、シェーダーに送ります。 -
uniform変数のアップデート
キャンバスの解像度、マウス位置、経過時間などのパラメータをシェーダー側に渡します。 -
レンダリングループの開始
requestAnimationFrame
を用いて、毎フレームシェーダーによる描画を更新します。
以下は、Javascriptの主要なコード例です。
// myScript.js
const canvas = document.getElementById('myCanvas');
const gl = canvas.getContext('webgl2');
// シェーダーソースコード(頂点シェーダーとフラグメントシェーダー)を変数に定義
// ※ 詳細は下記「シェーダーコードで実現するミミズの行進」を参照
const vertexShaderSource = `#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const fragmentShaderSource = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform vec2 iMouse;
uniform float iTime;
out vec4 fragColor;
// ここからシェーダートイ風のコードでシーン(ミミズの行進)の定義開始
// ・・・ SDF関数、レイマーチング、ライティング処理 ・・・
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// フラグメント座標の正規化
vec2 uv = (fragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
// マウス位置や時間に応じたカメラの角度計算など…
// ここでミミズの各パーツ(鼻、胴体、尻尾、目など)のSDFを定義し、
// smin関数でブレンドして一体の生物(ミミズ)を構築します。
// レイマーチングにより、シーンとの交点を探索
// ライティング、シャドウ、反射計算を行い最終色を決定
fragColor = vec4(uv, 0.5 + 0.5 * sin(iTime), 1.0);
}
void main() {
mainImage(fragColor, gl_FragCoord.xy);
}
`;
//
// シェーダーコンパイルとプログラム作成
//
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
//
// フルスクリーン四角形の頂点データを設定
//
const positions = [
-1.0, -1.0,
1.0, -1.0,
-1.0, 1.0,
1.0, 1.0,
];
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
//
// uniform変数のロケーション取得
//
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution');
const iMouseLocation = gl.getUniformLocation(program, 'iMouse');
const iTimeLocation = gl.getUniformLocation(program, 'iTime');
//
// キャンバスリサイズとマウスイベントの設定
//
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = canvas.height - (event.clientY - rect.top);
gl.uniform2f(iMouseLocation, x, y);
});
//
// レンダリングループ(毎フレームシェーダーを実行)
//
function render() {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height);
gl.uniform1f(iTimeLocation, performance.now() * 0.001);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
}
render();
シェーダーコードで実現するミミズの行進
シェーダー内のコードは、単なるピクセル処理ではなく、ミミズのような生物を数学的に定義し、その動きを表現するために作られています。
ここでは、主要な要素について詳しく解説します。
1. シーン定義とSDF(Signed Distance Function)
SDFの基本概念
SDFは、ある点から図形の境界までの最短距離を返す関数です。正の値なら外部、負の値なら内部、ゼロに近い値は境界上にいることを意味します。
今回のコードでは、以下の関数を用いて各パーツを定義しています。
-
sdSphere
球状の部分(ミミズの鼻や目、体の断片)を表現します。 -
sdSeg
線分のSDFを定義し、胴体や尻尾などの連続したパーツを描くのに使用します。 -
sdPlane
地面のSDFで、ミミズが歩く床の表現に利用します。
また、smoothmin や smoothmax 関数を使って、各パーツ同士の境界を滑らかにブレンドしています。
2. レイマーチングで表面を探索する
レイマーチングは、レイを一定のステップで前進させながら、各ステップでSDFを評価し、物体の表面に到達するまで探索する手法です。
【レイマーチングの流れ】
[カメラ位置]
│
▼
[レイの発射]─────────────────► [各ステップで SDF計算]
│
SDFの値 < 閾値 なら → 表面に到達!
│
SDFの値が大きい → 次のステップへ進む
今回のコードでは、raymarch
関数がこの処理を担当し、レイが各パーツに衝突するまでループ処理を行っています。
3. ライティングとエフェクトの適用
シェーダー内では、表面でのライティング計算やシャドウ、反射の効果を加えることで、ミミズの各パーツに立体感や動きの表現を与えています。
-
法線の計算
norm
関数で、SDFを微小な値でサンプリングして法線ベクトルを求め、ライティング計算に利用します。 -
ライティング
環境光、拡散反射、鏡面反射など各種計算を行い、パーツごとに色のブレンドを変化させています。
たとえば、partID
という変数で、鼻、胴体、目、尻尾、さらには床かどうかを識別し、それぞれに違った色味や明るさを付与しています。
これにより、ミミズの各パーツが独自の質感を持ち、動きに合わせた陰影効果で「行進している」ような印象を与えます。
実装例・全コード
以下に、今回のミミズの行進を実現する全コード例を掲載します。
ファイルを作成して、ブラウザで実行すれば、リアルタイムにミミズの行進が楽しめます!
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ミミズの行進</title>
<style>
body { margin: 0; overflow: hidden; }
</style>
</head>
<body>
<canvas id="myCanvas"></canvas>
<script>
// WebGL2コンテキスト初期化
const canvas = document.getElementById('myCanvas');
const gl = canvas.getContext('webgl2');
// 頂点シェーダー
const vertexShaderSource = `#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
// フラグメントシェーダー(ミミズの行進を再現するSDF・レイマーチングコード)
const fragmentShaderSource = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform vec2 iMouse;
uniform float iTime;
out vec4 fragColor;
// SDF, ノイズ生成, smoothmin/smoothmax 関数 などの定義
// 例:
float sdSphere(in vec3 p, in vec3 center, float radius) {
return length(p - center) - radius;
}
float sdSeg(in vec3 p, in vec3 a, in vec3 b) {
vec3 pa = p - a;
vec3 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
// smoothmin: 複数のSDFを滑らかにブレンド
float smoothmin(float d1, float d2, float k) {
float h = max(k - abs(d1 - d2), 0.0) / k;
return min(d1, d2) - h * h * k * 0.25;
}
// シーンのSDFを定義する map 関数
// ミミズの各パーツ(鼻、胴体、尻尾、目・瞳、床)を
// 適宜ブレンドして1体の生物を構成
int partID = 1;
float map(vec3 p) {
// 胴体や尻尾の周期的な動きを表現するために mod() 関数を利用して座標を折り返す
float mx = 0.33, mz = 1.05;
p.x = mod(p.x - mx, 2.0 * mx) - mx;
p.z = mod((p.z + iTime/4.0) - mz, 2.0 * mz) - mz;
// 時間依存の変形
float m = 0.05 * p.y * (cos(iTime * 5.0) - 1.0);
// ミミズの鼻部分
vec3 n = vec3(0.0, 0.25, 0.0);
n.z += m;
float nose = sdSphere(p, n, 0.125);
// ミミズの胴体(胴体セグメントは sdSeg で表現)
p.x /= 1.5;
vec3 ta = vec3(0.0, 0.5, 0.2 + m);
vec3 tb = vec3(0.0, -0.5, 0.2);
float tR = 0.125;
float torso = sdSeg(p, ta, tb) - tR;
// 尻尾部分
vec3 tc = vec3(0.0, -0.5, 0.35);
vec3 td = vec3(0.0, -0.5, 0.95 - m * 4.0);
float tail = sdSeg(p, tc, td) - tR;
// 各種ブレンド処理
float body = smoothmin(smoothmin(torso, tail, 0.1), nose, 0.025);
// 床
float ground = p.y + 0.6;
// シーン内で最も近い表面を求める
float result = min(min(body, ground), 0.0);
if(result == body)
partID = 1;
else
partID = 4;
return result;
}
// 法線計算(微小な差分を用いて近似)
vec3 norm(vec3 p) {
float h = 1e-3;
vec2 k = vec2(-1.0, 1.0);
return normalize(
k.xyy * map(p + k.xyy * h) +
k.yxy * map(p + k.yxy * h) +
k.yyx * map(p + k.yyx * h) +
k.xxx * map(p + k.xxx * h)
);
}
// レイマーチングループ
float raymarch(inout vec3 p, vec3 rd) {
float dd = 0.0;
for (float i = 0.0; i < 100.0; i++) {
float d = map(p);
if(d < 1e-3 || dd > 25.0) break;
p += rd * d;
dd += d;
}
return dd;
}
// シンプルなライティング計算
vec3 render(vec3 p, vec3 rd) {
float d = raymarch(p, rd);
vec3 col = vec3(0.0);
if(d < 25.0) {
vec3 n = norm(p);
float diffuse = clamp(dot(normalize(vec3(-1.0, 2.0, -5.0) - p), n), 0.0, 1.0);
col = mix(vec3(0.2, 0.3, 0.4), vec3(1.0, 0.0, 0.0), diffuse);
} else {
col = vec3(0.25, 0.6, 0.6);
}
return col;
}
// メインイメージ生成
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution) / iResolution.y;
// カメラ設定(マウスで角度を操作可能)
float angle = 3.14 * (iMouse.x > 0.0 ? 1.0 + cos(iTime/6.0) : 0.825);
vec3 ro = vec3(sin(angle), 0.5, cos(angle)) * 5.0;
vec3 target = vec3(0.0);
vec3 fwd = normalize(target - ro);
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), fwd));
vec3 up = cross(fwd, right);
vec3 rd = normalize(fwd + uv.x * right + uv.y * up);
vec3 col = render(ro, rd);
fragColor = vec4(col, 1.0);
}
void main() {
mainImage(fragColor, gl_FragCoord.xy);
}`;
// シェーダーのコンパイルとプログラム生成
function compileShader(source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
}
return shader;
}
const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// 全画面クワッドの頂点設定
const positions = [
-1.0, -1.0,
1.0, -1.0,
-1.0, 1.0,
1.0, 1.0,
];
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
// uniform変数のロケーション取得
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution');
const iMouseLocation = gl.getUniformLocation(program, 'iMouse');
const iTimeLocation = gl.getUniformLocation(program, 'iTime');
// キャンバスサイズの調整
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// マウスイベントで iMouse 更新
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = canvas.height - (event.clientY - rect.top);
gl.uniform2f(iMouseLocation, x, y);
});
// レンダリングループ
function render() {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height);
gl.uniform1f(iTimeLocation, performance.now() * 0.001);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
まとめ
今回の記事では、HTML・CSSでシンプルなキャンバスの土台を作成し、JavaScriptを利用してWebGL2コンテキストを初期化、頂点シェーダーとフラグメントシェーダーを組み合わせることで、SDFとレイマーチングを応用した「ミミズの行進」のリアルタイムレンダリングを実現する方法を解説しました。
- SDFとsmooth blend によって、ミミズの各パーツ(鼻、胴体、尻尾など)を数学的に定義
- レイマーチング を利用し、シーン内の交点を探査し表面を描出
- ライティング処理 で奥行きと陰影を加え、動きのある表現を実現
この技法は、シェーダーを用いたグラフィックス表現の基礎として非常に有用です。
ぜひ、実際にコードを動かして、各パラメータを変更することで、どのようにレンダリング結果やミミズの動きが変化するか確かめてみてください。
パラメータを変えてみよう!
-
SDF関数のパラメータ
たとえば、sdSphere
の半径値や、sdSeg
で使用する線分の両端の座標を変更することで、ミミズの体の太さやシルエットが変わります。-
例:
float nose = sdSphere(p, n, 0.125);
の0.125
を0.15
に変えると、鼻の大きさが増し、全体の印象が変わります。
-
例:
-
smoothmin / smoothmax のブレンド係数
各パーツの境界をどれだけ滑らかに繋ぐかは、smoothmin
の係数に依存します。-
例:
float body = smoothmin(smoothmin(torso, tail, 0.1), nose, 0.025);
ここで0.1
や0.025
の値を調整することで、パーツ同士の繋がり具合が変化し、より柔らかなまたはシャープな境界が実現できます。
-
例:
-
レイマーチングのステップ数と閾値
raymarch
関数内のループ回数や、終了条件(例:1e-3
や25.0
といった値)は、描画の精度とパフォーマンスに大きく影響します。-
例:
ステップ数を増やせば、細かなディテールが描写されますが、その分計算負荷が高くなるため、実行環境に応じて調整してください。
-
例:
実験とカスタマイズのすすめ
シェーダープログラミングは、パラメータ次第で挙動が大きく変わるため、実際に値を変えて試行錯誤することが理解への近道です。以下のような変更点を試してみると良いでしょう。
調整対象 | 変更例 | 期待される効果 |
---|---|---|
球の半径 |
sdSphere() の第3引数変更 |
ミミズの鼻や目のサイズが変わり、表情が変化 |
線分の位置 |
sdSeg() で使用する座標変更 |
胴体や尻尾の曲がり方、位置が微調整され動きが変わる |
ブレンド係数 |
smoothmin() の値変更 |
各パーツ間の境界の滑らかさが調整でき、柔らかい印象に |
レイマーチング | ループ回数や閾値の変更 | レンダリング精度とパフォーマンスのバランスが変化 |
下記は、コード中の一部を変更して様子を確かめる簡単な例です。
// 変更前: 鼻の大きさ
float nose = sdSphere(p, n, 0.125);
// 変更後: 鼻の大きさを大きく
float nose = sdSphere(p, n, 0.2);
これらの変更により、シーン内のミミズがどのように見えるか、動きがどのように変わるかを観察してみましょう。自作のグラフィックス実験は、日々のプログラミング力向上にも直結します。ぜひ、このコードをベースにして、あなた独自のエフェクトや動きを探求してください。
まとめ
この技法は、シェーダーを用いたグラフィックス表現の基礎として非常に有用です。実際にコードを動かしながら、各パラメータを変更してみることで、SDFやレイマーチングの理解が深まるだけでなく、新たな表現方法の発見にも繋がります。皆さんもぜひ、自分だけのエフェクトを作り出すための実験にチャレンジしてみてください。Happy coding!
See the Pen 蚯蚓の行進 by Yushi Yamamoto (@yamamotoyushi) on CodePen.