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ならフォールバック表示へ。 - コンテキストロスト:
webglcontextlostをpreventDefault()し、webglcontextrestoredでループを再開。
実際に動くものは Particles Field で確認できます。次はこの土台の上にアプリを増やしていきます。