ReactとWebGLを組み合わせて使う際のフレームドロップを防ぐレンダリングループ分離法
TuBrief 편집팀
2026년 8월 7일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
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つの効果がすぐに得られます。
next.config.mjs にパッケージを登録し、不要なモジュールの省略を誘導します。next/dynamic に { ssr: false } を指定し、サーバーレンダリング時点でのランタイムエラーを遮断します。Tailwind CSSを使用している場合は、ポインターイベントも確認する必要があります。キャンバスラッパー全体に pointer-events-none を設定してイベントが背面のDOMに抜けるようにし、実際の操作が必要なオブジェクトレイヤーにのみ pointer-events-auto を再度付与します。
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の状態を更新すると、ガベージコレクションが短時間に集中して発生し、ラグを引き起こします。
構造を改善します。
useRef の current に上書きします。useAnimationFrame フックを使用して固有の requestAnimationFrame ループを作成します。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 (
return (
<div className="relative w-full h-full min-h-[400px] overflow-hidden">
{this.props.children}
</div>
);
}
}
`
安全網を構築する方法は以下の通りです。
min-h-[400px] などの最小の高さを固定し、フォールバックUI(通常のHTML/SVGチャートなど)に切り替わる際に画面の比率が崩れる現象を防ぎます。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で直接確認する手順です。
Detached HTMLCanvasElement や WebGLBuffer の数が残らず0に低下していることを確認します。ReactとWebGLを組み合わせて使用する際は、2つのライブラリがそれぞれ得意とする領域を阻害しないように切り分けることが核心です。バンドルを分割し、レンダリングループからReactの状態を排除し、コンポーネントが破棄される際にGPUメモリを確実に解放すること。この3つを抑えるだけでも、フレームドロップのないスムーズに動作するダッシュボードを構築することができます。