はじめに
勢いで、ブラウザで動くインタラクティブなWebGL作品を一気に4つ作った。せっかくなので、それぞれで踏んだ罠と「効いた一手」をまとめておく。方向性が全部違う(スクロール演出 / GPUシミュレーション / 物理エンジンありのゲーム / 物理エンジンなしのゲーム)ので、つまずきポイントもバラバラで面白い。
| 作品 | 何か | 技術 | リンク |
|---|---|---|---|
| 🌌 VOYAGE(航宙) | スクロールで宇宙を旅するシネマティック3D | Three.js + GLSL + Lenis | サイト / GitHub |
| 🎨 INK(墨流し) | ドラッグでかき混ぜるフル画面GPU流体 | 純WebGL2 + GLSL(ライブラリ不使用) | サイト / GitHub |
| 🚗 DRIVE(走) | 低ポリの島をミニカーで走り回る物理ワールド | Three.js + Rapier(WASM物理) | サイト / GitHub |
| 🌊 WAKE(航跡) | スピードボートで自作の海を走る(物理エンジンなし) | Three.js + GLSL(Gerstner波) | サイト / GitHub |
インタラクティブものは実物が一番なので、まず触ってから読むのがおすすめ。ソースは全部 Public にしてある。
作品1: VOYAGE — スクロール量で「宇宙の旅」を演出する
やったこと
Immersive Garden や EXP by Lusion みたいな「スクロールするとカメラが動いて、1ページが映像作品になる」系。
ポイントは、スクロール位置を正規化した進捗 p(0..1) に変換して、これ一つで全部を駆動すること。カメラのz軌道も、星雲/銀河の不透明度も、ワープ量も、bloomの強さも、章キャプションの表示も、全部 p から出す。
// スクロール量 → p(Lenisのスムーススクロールを利用)
const p = lenis.limit > 0 ? clamp(lenis.scroll / lenis.limit, 0, 1) : 0;
// pでカメラを軌道に沿わせる(piecewise-linear track: z=16 → -138)
const camZ = track(p, Z_TRACK);
// ワープ量も星雲/銀河の不透明度も、全部pから
const warp = smoothstep(0.64, 0.72, p) * (1 - smoothstep(0.82, 0.90, p));
5章構成:大気圏 → 軌道 → 星雲 → ワープ → 銀河。
ハマり1:加算合成パーティクルの中を飛ぶと真っ白に飛ぶ
星雲を「数千の加算合成(additive)パーティクル」で作り、その中をカメラで通り抜ける演出にしたら、近づいたパーティクルが巨大スプライト化して画面が真っ白になった。additive は重なるほど明るくなるので、近接した点が累積して飽和する。
効いた対策はシェーダー側の2つ:
// ① 点サイズに上限を付ける
gl_PointSize = min(uSize * aScale * uPixelRatio * (300.0 / dist), 150.0 * uPixelRatio);
// ② 近接フェード:レンズに近すぎる点を溶かして「顔面に巨大スプライト」を防ぐ
vFade = smoothstep(1.5, 14.0, dist);
加えて、雲ごとに不透明度を下げ、星雲の中ではbloomを少しだけ下げると色が残るようになった。
ハマり2:銀河が画面外へ吹き飛ぶ(地味に1サイクル溶かした)
渦巻銀河を作って points.rotation で傾けたら、銀河が画面のどこにもいない。
原因は、銀河の頂点を中心 (0, 6, -160) にベイクしたままオブジェクト回転やシェーダー内スピンをかけたこと。回転はオブジェクトのローカル原点周りに効くので、原点から離れた場所にある頂点群が原点を中心に「公転」して、画面外へ飛んでいってしまう。
直し方は「頂点は原点中心で作って、配置は position で行う」:
// ❌ 頂点に中心をベイク → rotationで原点周りに公転して飛ぶ
positions[i*3] = center.x + Math.cos(a) * r;
// ✅ 原点中心で作り、配置はpositionで(spin/tiltが自分の中心周りに効く)
positions[i*3] = Math.cos(a) * r;
points.position.set(0, 6, -240);
points.rotation.x = -0.5;
言われてみれば当たり前なんだけど、「メッシュは原点付近に作って position で置く」を徹底していないと普通に踏む。
作品2: INK — ライブラリ不使用でGPU流体を書く
やったこと
Active Theory / Lusion 系の「フル画面でカーソルに即反応する」やつ。コンセプトはリアルタイム流体にした。Three.js すら使わず、生のWebGL2 + GLSL だけで Jos Stam の stable-fluids(Navier–Stokes)を実装している。やっていることは、フラグメントシェーダーのパスを float の FBO 間で ping-pong させるだけ。
毎フレームのステップはこの順番:
curl → 渦confinement → divergence → 圧力ソルバ(Jacobi ×20) → 勾配を引く
→ velocityを移流 → dyeを移流 → display
依存ライブラリ0、JSは約16KB(gzip 5KB)。生で書くと中身が全部見えて気持ちいい。
ハマり:かき混ぜると画面が真っ黒になる
速い・長い・交差するストロークで撹拌すると、対向する速度場で染料(dye)が分散して値が ~0 になり、画面が真っ黒に。DENSITY_DISSIPATION(拡散)や SPLAT_FORCE を調整してもイマイチ安定しない。
**決定打は「アンビエント自動スプラット」**だった。0.5秒ごとに、穏やかな色を1点だけ自動で注入する。
function ambientSplat() {
const c = HSVtoRGB(autoHue, 1.0, 1.0);
const a = Math.random() * Math.PI * 2;
splat(rand(), rand(), Math.cos(a) * 180, Math.sin(a) * 180,
{ r: c.r * 4, g: c.g * 4, b: c.b * 4 });
}
// メインループ内
ambientTimer += dt;
if (ambientTimer > 0.5) { ambientTimer = 0; ambientSplat(); }
これで「無操作でも常時カラフルに流れている」状態になり、第一印象も安定性も一気に良くなった。ロード直後から絵があるのは正義。
ホバー時は色相 autoHue を時間で巡回させて、ただのマウス移動でも虹色に反応するようにしている。
作品3: DRIVE — Rapierで物理カーゲームを作る
やったこと
Bruno Simon 系の「操作して探索する3Dワールド」。Three.js でレンダリングして、物理は Rapier(Rust製、WASMでブラウザ動作)。車は Rapier の DynamicRayCastVehicleController(レイキャスト車両=後輪駆動・前輪ステア・サスペンション付き)。低ポリの島に、坂・ぶつかると散らばるクレート・木・岩・コインを置いた。
あとからタイムアタック(光るゲートを順に通る周回+ベストラップを localStorage 保存)、ニトロ、全コイン収集で紙吹雪、も足した。
Rapier vehicle controller で踏んだ罠まとめ
ここが一番ネットに情報が少なくてハマったので、未来の自分のために残す。
① controller.indexForwardAxis は getter 専用
代入すると Cannot set property indexForwardAxis ... which has only a getter で例外が飛んで初期化が止まる。Rapierのデフォルト(up=Y, forward=Z)が普通の車のレイアウトに合うので、そもそも触らないのが正解。
② body.setAdditionalMassProperties(...) で車が動かなくなる
重心を下げようとこれを呼んだら、車がピクリとも動かず、currentVehicleSpeed も NaN になった。撤去して、重心はシャシーの collider を下にオフセットして下げるだけにした。
// 追加質量APIは使わず、colliderを下げて重心を下げる(転倒対策)
RAPIER.ColliderDesc.cuboid(HX, HY, HZ).setTranslation(0, -0.22, 0)
③ controller.currentVehicleSpeed が不安定(0 / NaN を返す)
HUDの速度・最高速度キャップ・車輪の回転を全部これに頼ると壊れる。シャシーの body.linvel()(線速度)から出すのが確実だった。
speed: () => { const v = body.linvel(); return Math.hypot(v.x, v.z); }
④「速すぎ&アクセルを離すと前にひっくり返る」
テストで指摘されて、コーストブレーキ(自動ブレーキ)を廃止+最高速度キャップ+低重心+角ダンピングで安定化。減速時の前のめり転倒は、結局「重心の高さ × 急減速」なので、その両方を潰すと収まる。
おまけ:ニトロ
Spaceをブレーキからニトロ(ブースト)に変えた。エンジン力と速度上限を一時的に上げて、ゲージを消費/回復させるだけ。
boosting = !!input.nitro && nitroFuel > 0;
if (boosting && sp2 < NITRO_SPEED_2) engine = ENGINE_FORCE * NITRO_MULT;
排気炎のメッシュを出して、ブースト中だけカメラのFOVを少し上げると、一気に「速い感」が出る。地味だけどFOVの演出は効く。
作品4: WAKE — 物理エンジンを捨てて「海そのもの」を自作する
やったこと
DRIVE が気持ちよかったので、勢いで船バージョンも作った。ミニカーを海に浮かべる…のではなく、今度は Rapier(物理エンジン)をあえて捨てて、海面と船の挙動を全部自前で書いた。海は Gerstner 波(複数のサイン波+横方向の変位を重ねた、いわゆる「波っぽい」やつ)。
操作感は DRIVE と同じ(スロットル / 操舵 / Space ブースト / タイムアタック / 収集)だけど、Rapier を外したのでバンドルは 約2.5MB → gzip 146KB に激減した。
キモ:同じ波を「GPU(見える海)」と「CPU(船が乗る海)」で共有する
波を2か所で別々に計算すると、船が水面から浮いたりめり込んだりする。なので波のパラメータは1か所だけで定義して、GPU頂点シェーダーにベイクする式と、JS側の高さ/法線サンプラーを同じ係数から生成する。これで船は「描かれているまさにその水面」に乗る。
// 同じ WAVES 配列から CPU 側の高さを出す(GPU頂点シェーダーも同じ式をベイク)
function sampleHeight(x, z, t) {
let y = 0;
for (const w of WAVES) y += w.amp * Math.sin(w.k * (w.dx*x + w.dz*z) + w.omega * t);
return y;
}
// 船は y = 水面の高さ + 喫水。姿勢は波の法線に向けて傾ける
boat.position.y = water.height(x, z) + RIDE;
物理エンジンなしで「それっぽい」船挙動を作る
舵は速度(=舵に当たる水の流れ)に比例して効くようにして、後進では反転させる。これだけで「止まっているとハンドルが効かない、走ると曲がる」という船らしさが出る。
const flow = Math.min(1, Math.abs(vel) / TURN_FLOW); // 水流=舵の効き
yaw += steer * TURN_RATE * flow * Math.sign(vel || 1) * dt;
罠:海を広く取ると重い → メッシュを船に追従+波グリッドにスナップ
巨大な平面を細かく分割すると重い。なので海面メッシュは船を中心に追従させて狭く保ち、波の位相はワールド座標基準(uOffset で渡す)にして「メッシュは動くけど波模様は世界に固定」にする。さらに頂点が視覚的に這うのを防ぐため、メッシュ位置を波のセル幅にスナップする。これで“無限の海”の見た目になる。
航跡(船尾から伸びる泡のリボン)は、船尾の点を毎フレーム履歴に積んで、頂点ごとに alpha をフェードさせた細い帯を張っているだけ。物理エンジンがないぶん、こういう「演出」に手を回せる。
まとめ
- 4作品とも DNS登録 → Vite静的ビルド → nginx + Let's Encrypt → GitHub Public という同じ型で量産できた。静的サイトなら本当に軽い。
- ブラウザのWebGLだけで、スクロール演出も、自作の流体シミュレーションも、WASM物理のゲームも、物理エンジンなしの海ゲームも、十分に動く。
- 物理は「使う / 使わない」どちらもアリ。DRIVE は Rapier に任せて速く形にできたし、WAKE は逆に物理エンジンを捨てたことで挙動も海も自分のコントロール下に置けて、バンドルも軽くなった。題材しだい。
- そして一番のポイントは、ハマりどころは大体ドキュメントに載っていない細部だったこと。原点周りの公転、加算合成の白飛び、vehicle controller の getter 罠、GPUとCPUで波を共有する……。同じ轍を踏む人の時短になれば嬉しい。
気になった実装があれば、ソースは全部覗けます:




