Particles Field の作り方 — 差し替え可能なWebGL埋め込み層

最初のアプリ Particles Field は、マウスや指の動きに反応する 1 万個のパーティクルです。見た目はシンプルですが、これ以降のアプリを増やしても破綻しないよう、埋め込み層を抽象化する設計を最初に固めました。

ライブラリ非依存の共通インターフェース

アプリ本体(src/apps/<app>/)と、ページへの埋め込み層(WebGLCanvas)を分離します。アプリ側はたった 2 つのメソッドを満たすだけです。

export interface WebGLApp {
  init(canvas: HTMLCanvasElement): void | Promise<void>;
  dispose(): void;
}

この規約さえ守れば、Three.js でも生 WebGL でも、埋め込み側を変えずに差し替えられます。WebGLCanvas は Preact の島として client:only で動き、マウント時に init、アンマウント時に dispose を呼びます。

dispose を最初に書く

WebGL で一番こわいのはリソースリークと多重起動です。ページを離れるたびに GPU リソースが残ると、回遊するうちに重くなって落ちます。そこで dispose() では必ず次をやります。

dispose(): void {
  this.running = false;
  cancelAnimationFrame(this.rafId);        // RAFループ停止
  window.removeEventListener('resize', this.onResize);
  this.geometry.dispose();                  // buffer
  this.material.dispose();                  // program/texture
  this.renderer.dispose();
  this.renderer.forceContextLoss();         // contextを明示解放
}

60fps を守る小さな約束

requestAnimationFrame のループ内で new や配列確保をしないこと。これだけで GC 由来のカクつきがかなり減ります。追従に使うベクトルなどは事前に確保して使い回します。

// ループの外で確保
private readonly target = { x: 0, y: 0 };

private tick = () => {
  // ループ内では new しない・既存オブジェクトを更新するだけ
  this.target.x += (this.pointer.x * 0.6 - this.target.x) * 0.05;
  this.points.rotation.y += 0.001;
  this.renderer.render(this.scene, this.camera);
  this.rafId = requestAnimationFrame(this.tick);
};

取りこぼしがちな対応

  • リサイズ + DPR 上限: devicePixelRatio をそのまま使うと高解像度端末で重いので、上限を 2 に。
  • タッチ対応: pointermove で統一し、touch-action: none でブラウザのスクロールと競合させない。
  • WebGL2 非対応: getContext('webgl2')null ならフォールバック表示へ。
  • コンテキストロスト: webglcontextlostpreventDefault() し、webglcontextrestored でループを再開。

実際に動くものは Particles Field で確認できます。次はこの土台の上にアプリを増やしていきます。