TuBrief
구독 채널
비디오
커뮤니티

ReactとWebGLを組み合わせて使う際のフレームドロップを防ぐレンダリングループ分離法

TuBrief 편집팀
2026년 8월 7일
0
Computing/Software

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

日本語한국어EnglishEspañol中文हिन्दीDeutschFrançaisPortuguêsРусскийBahasa Indonesiaالعربية

관련 영상

このUIコンポーネントライブラリが最高すぎる件(Canvas UI)7:27

このUIコンポーネントライブラリが最高すぎる件(Canvas UI)

Better Stack

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

React와 WebGL을 함께 쓸 때 프레임 드랍을 막는 렌더링 루프 분리법

Next.jsのダッシュボードにWebGLキャンバスを配置する作業は、思った以上に厄介です。華やかなチャートや重いグラフィック要素を組み込むと、画面がカクカクしがちです。マウスを動かすだけでもフレームレートが低下し、苦労して作ったダッシュボードがフリーズすると、開発者としてはもどかしい思いをするしかありません。

この問題は通常、Reactの状態管理方式とWebGLのレンダリング方式が衝突することで発生します。2つのエンジンの動作方式を分離しないと、いくら良いライブラリを使っても画面のカクつきは収まりません。

メインバンドルにグラフィックライブラリが丸ごと含まれてしまう問題

Three.jsやDeck.glのようなライブラリは、内部に数式演算エンジンのシェーダーコンパイラを抱えているためサイズが大きくなります。深く考えずに上部でパッケージをインポートすると、使用しないコードまでメインバンドルにすべて混ざってしまいます。

Next.js環境であれば、サブパスの直接インポートとダイナミックロードの設定を組み合わせてこの負担を軽減する必要があります。サーバーサイドレンダリング中に window is not defined エラーが発生するのも同時に防ぐことができます。

`javascript
// next.config.mjs
import withBundleAnalyzer from '@next/bundle-analyzer';

const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'three',
'deck.gl'
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
};

export default bundleAnalyzer(nextConfig);

`

この設定を行うことで、初期ページロード時にグラフィックリソースを一度にすべて受け取らず、必要なタイミングで分割して受け取るようになります。バンドルアナライザーで確認すると、メインJSファイルに縛られていたキャンバス関連コードが独立したチャンクとして切り離されていることが確認できます。

クライアントコンポーネントラッパーではSSRを無効にして取得します。

`typescript
// components/dashboard/CanvasDashboardWrapper.tsx
'use client';

import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';

const DynamicCanvasRenderer = dynamic(
() => import('./CanvasRenderer').then((mod) => mod.CanvasRenderer),
{
ssr: false,
loading: () => (




),
}
);

export function CanvasDashboardWrapper() {
return (




);
}

`

このように構成することで、3つの効果がすぐに得られます。

  1. next.config.mjs にパッケージを登録し、不要なモジュールの省略を誘導します。
  2. next/dynamic に { ssr: false } を指定し、サーバーレンダリング時点でのランタイムエラーを遮断します。
  3. 固定の高さを持つSkeletonを表示し、データが読み込まれる前のレイアウトのガタつきを抑えます。

Tailwind CSSを使用している場合は、ポインターイベントも確認する必要があります。キャンバスラッパー全体に pointer-events-none を設定してイベントが背面のDOMに抜けるようにし、実際の操作が必要なオブジェクトレイヤーにのみ pointer-events-auto を再度付与します。

useStateをキャンバスループから排除する

Reactは状態(useState、useContext)が変更されると、仮想DOMを再描画します。一方、WebGLは requestAnimationFrame を利用して1秒間に60回画面を再描画します。

1秒間に60回実行されるループの中でReactの状態変更関数を呼び出すとどうなるでしょうか?フレームごとにReactのReconciliation演算が実行され、メインスレッドが固まってしまいます。これが画面のカクつきの本当の原因です。

変化する値は useRef に格納し、画面を描画する処理は requestAnimationFrame 単独のループに任せる必要があります。

`typescript
// hooks/useAnimationFrame.ts
import { useEffect, useRef } from 'react';

type AnimationCallback = (deltaTime: number, timestamp: number) => void;

export const useAnimationFrame = (callback: AnimationCallback, isPaused: boolean = false) => {
const requestRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const callbackRef = useRef(callback);

useEffect(() => {
callbackRef.current = callback;
}, [callback]);

useEffect(() => {
if (isPaused) {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
return;
}

const animate = (timestamp: number) => {
  if (previousTimeRef.current !== null) {
    const deltaTime = timestamp - previousTimeRef.current;
    callbackRef.current(deltaTime, timestamp);
  }
  previousTimeRef.current = timestamp;
  requestRef.current = requestAnimationFrame(animate);
};

requestRef.current = requestAnimationFrame(animate);

return () => {
  if (requestRef.current !== null) {
    cancelAnimationFrame(requestRef.current);
  }
};

}, [isPaused]);
};

`

マウスの移動やドラッグのように頻繁に発生するイベントも同様です。イベントハンドラー内でReactの状態を更新すると、ガベージコレクションが短時間に集中して発生し、ラグを引き起こします。

構造を改善します。

  1. マウス位置や回転値などの頻繁に変更されるデータは、useRef の current に上書きします。
  2. useAnimationFrame フックを使用して固有の requestAnimationFrame ループを作成します。
  3. レンダリング関数はこのRef値を読み取ってキャンバスに描画するだけです。Reactの状態を変更しないため、再描画自体が発生しません。

サポートされていないブラウザのためのフォールバック処理

WICGで議論されているHTML in Canvas仕様のように、drawElementImage などのメソッドでDOMをキャンバスのビットマップに直接描画しようとする試みがあります。最新のChromeフラグ環境では正常に動作しますが、実務でこれをそのまま信じて使用すると、レガシーブラウザや一部のモバイル機器で画面全体がクラッシュします。

機能のサポート有無を事前に確認し、エラーバウンダリーで囲むことで例外状況に備えます。

`typescript
// components/canvas/CanvasErrorBoundary.tsx
'use client';

import React, { Component, ErrorInfo, ReactNode } from 'react';
import { detectCanvasCapabilities } from '@/utils/canvasFeatureDetection';

interface Props {
children: ReactNode;
fallbackUI: ReactNode;
}

interface State {
hasError: boolean;
isSupported: boolean;
}

export class CanvasErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
isSupported: true,
};

public componentDidMount() {
const capabilities = detectCanvasCapabilities();
if (!capabilities.webgl2) {
this.setState({ isSupported: false });
}
}

public static getDerivedStateFromError(_: Error): Partial {
return { hasError: true };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Canvas UI Rendering Engine Crashed:', error, errorInfo);
}

public render() {
if (this.state.hasError || !this.state.isSupported) {
return (



{this.props.fallbackUI}


);
}

return (
  <div className="relative w-full h-full min-h-[400px] overflow-hidden">
    {this.props.children}
  </div>
);

}
}

`

安全網を構築する方法は以下の通りです。

  1. キャンバスコンテキストの確認ロジックを通じて、WebGL2のサポート状態を検証します。
  2. Reactのクラス型エラーバウンダリーでレンダリング中に発生するランタイムエラーをキャッチします。
  3. ラッパーボックスに min-h-[400px] などの最小の高さを固定し、フォールバックUI(通常のHTML/SVGチャートなど)に切り替わる際に画面の比率が崩れる現象を防ぎます。

GPUメモリはJavaScriptのガベージコレクターでは解放されない

V8エンジンはJavaScriptのヒープ内にあるオブジェクトは適切にクリーンアップしてくれます。しかし、WebGLバッファ、テクスチャ、シェーダープログラムなどのGPUメモリに配置されたデータについては認識しません。

ページを移動したりダッシュボードのタブを切り替えたりする際に、これらのリソースを明示的に解放しないと、GPUメモリが蓄積し、最終的に Context lost エラーとともにブラウザタブがクラッシュします。

`typescript
// hooks/useWebGLCleanUp.ts
import { useEffect, useRef } from 'react';

export const useWebGLCleanUp = () => {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const resourcesRef = useRef<{
buffers: WebGLBuffer[];
textures: WebGLTexture[];
programs: WebGLProgram[];
}>({
buffers: [],
textures: [],
programs: [],
});

const registerBuffer = (buffer: WebGLBuffer) => resourcesRef.current.buffers.push(buffer);
const registerTexture = (texture: WebGLTexture) => resourcesRef.current.textures.push(texture);
const registerProgram = (program: WebGLProgram) => resourcesRef.current.programs.push(program);

useEffect(() => {
return () => {
const gl = glRef.current;
if (!gl) return;

  resourcesRef.current.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
  resourcesRef.current.textures.forEach((texture) => gl.deleteTexture(texture));

  resourcesRef.current.programs.forEach((program) => {
    const shaders = gl.getAttachedShaders(program);
    if (shaders) {
      shaders.forEach((shader) => {
        gl.detachShader(program, shader);
        gl.deleteShader(shader);
      });
    }
    gl.deleteProgram(program);
  });

  const loseContextExt = gl.getExtension('WEBGL_lose_context');
  if (loseContextExt) {
    loseContextExt.loseContext();
  }

  resourcesRef.current = { buffers: [], textures: [], programs: [] };
  glRef.current = null;
};

}, []);

return { glRef, registerBuffer, registerTexture, registerProgram };
};

`

メモリが正しく解放されているかをChrome Developer Toolsで直接確認する手順です。

  1. ダッシュボードコンポーネントを開き、Memoryタブで1回目のヒープスナップショット(S1)を取得します。
  2. コンポーネントのマウントとアンマウント(別のタブへの移動など)を10回以上繰り返します。
  3. 手動のガベージコレクション(ゴミ箱アイコン)をクリックした後、2回目のヒープスナップショット(S2)を取得します。
  4. S2の比較対象をS1に設定し、Detached HTMLCanvasElement や WebGLBuffer の数が残らず0に低下していることを確認します。

ReactとWebGLを組み合わせて使用する際は、2つのライブラリがそれぞれ得意とする領域を阻害しないように切り分けることが核心です。バンドルを分割し、レンダリングループからReactの状態を排除し、コンポーネントが破棄される際にGPUメモリを確実に解放すること。この3つを抑えるだけでも、フレームドロップのないスムーズに動作するダッシュボードを構築することができます。