ウェブで高パフォーマンスな3D体験を構築するには、APIの知識だけでは不十分です。ブラウザ、GPU、JavaScriptがどのように連携し、どこにボトルネックが潜んでいるかを理解する必要があります。
本記事では、Three.js開発における100の実践的なベストプラクティスをまとめました。新しいWebGPUレンダラーに重点を置きながら、最適化の全範囲をカバーしています。既存プロジェクトの最適化でも、新規開発でも、これらのヒントがより速く、スムーズな体験の実現に役立ちます。掲載しているすべてのAPIとコードサンプルは、three.js r186(2026年9月)時点の最新情報に基づいています。
対象読者: Three.jsを使用するウェブ開発者で、パフォーマンスとコード品質を向上させたい方。これから始める方にはThree.js Journeyをお勧めします。
重要ポイント
- WebGPUは本番環境対応済み—r171以降、ゼロコンフィグでインポート可能。WebGL 2への自動フォールバック付きで、世界のブラウザの約87%がサポート
- ドローコールがパフォーマンスの鍵—モバイルではフレームあたり約100を目安に
- インスタンシングとバッチングで数百のドローコールを1つに集約可能
- 不要なリソースはすべてdispose—ジオメトリ、マテリアル、テクスチャ、レンダーターゲット
- TSL(Three Shader Language)が未来—一度書けばWebGPUでもWebGLでも動作
- 可能な限りベイク—ライトマップ、シャドウ、アンビエントオクルージョン
- 最適化の前にプロファイル—組み込みのInspector、stats-gl、renderer.infoを活用
WebGPUレンダラー
WebGPUレンダラーは、Three.jsのグラフィックス処理における根本的な変革です。2025年9月にSafari 26がサポートを開始して以降、すべての主要ブラウザでWebGPUを使用できるようになりました。変更点の詳細はThree.js 2026年の変化をご覧ください。
1. ゼロコンフィグのWebGPUインポートを使い、初期化はsetAnimationLoopに任せる
r171以降、WebGPUの導入はインポート1行で済みます。GPUデバイスの作成は非同期ですが、setAnimationLoop()がそれを処理します—最初のフレームの前にrenderer.init()をawaitしてくれます:
import { WebGPURenderer } from 'three/webgpu';
const renderer = new WebGPURenderer();
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});
ループの開始前にレンダリングやコンピュートを実行する場合—事前計算パスや、サムネイル用の1フレームなど—は、先にawait renderer.init()を呼び出してください。初期化されていないレンダラーでレンダリングすると、まさにそれを促すエラーが発生します。バンドラー設定やポリフィルは不要です。公式のWebGPURendererマニュアルも参照してください。
2. 自動WebGL 2フォールバックを信頼する
ブラウザがWebGPUをサポートしていない場合、WebGPURendererは自動的にWebGL 2にフォールバックします。別々のコードパスは不要—1つのレンダラーを出荷すれば、Three.jsが互換性を処理します。
3. TSL(Three Shader Language)を習得する
TSLはThree.jsのノードベースマテリアルシステムで、WGSL(WebGPU)またはGLSL(WebGL)にコンパイルされます。シェーダーコードを2回書く代わりに、TSLで一度だけ書きます:
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color, sin, time } from 'three/tsl';
const material = new MeshStandardNodeMaterial();
material.colorNode = color(1, 0, 0).mul(sin(time).mul(0.5).add(0.5));
TSLはカスタムシェーダーの推奨アプローチです。MeshStandardMaterialなどの組み込みマテリアルはWebGPURendererでもそのまま動作します—対応するノードマテリアルに自動でマッピングされます—が、ShaderMaterial、RawShaderMaterial、onBeforeCompileによるパッチは動作しません。移行前にこれらをTSLで書き直してください。
4. パーティクルシステムをコンピュートシェーダーに移行
CPUで更新するパーティクルは、パーティクルごとの処理量にもよりますが、一般に数万から多くても数十万個程度が上限です。毎フレーム、位置データがJavaScriptからGPUへ転送されるためです。コンピュートシェーダーならデータがGPUの外に出ることはありません—公式のコンピュートパーティクルのサンプルは、この方法で200,000個のパーティクルをシミュレーションしています:
import { Timer } from 'three/webgpu';
import { Fn, instancedArray, instanceIndex, uniform } from 'three/tsl';
const count = 200000;
const positions = instancedArray(count, 'vec3'); // GPU常駐、フレーム間で持続
const velocities = instancedArray(count, 'vec3');
const delta = uniform(0);
const update = Fn(() => {
const position = positions.element(instanceIndex);
const velocity = velocities.element(instanceIndex);
position.addAssign(velocity.mul(delta));
})().compute(count);
const timer = new Timer();
renderer.setAnimationLoop((timestamp) => {
delta.value = timer.update(timestamp).getDelta();
renderer.compute(update);
renderer.render(scene, camera);
});
マテリアルでも同じバッファを読み込めば(例:material.positionNode = positions.toAttribute())、レンダリングがシミュレーション結果を直接使用します。
5. compileAsyncでシェーダーをウォームアップ
シェーダーはオブジェクトが初めてレンダリングされるときにコンパイルされるため、新しいものが視界に入ったりメニューが開いたりした瞬間のカクつきとして現れます。ローディング画面を表示している間に、すべてをコンパイルしておきましょう:
await renderer.compileAsync(scene, camera);
// ここでローダーを隠してループを開始
r184以降、WebGPURendererのcompileAsync()は処理中にレンダリングをブロックしなくなりました。まだシーンに追加されていないオブジェクトも、追加先のシーンを渡せば事前コンパイルできます:renderer.compileAsync(object, camera, scene)。WebGLRendererにも独自のcompileAsync()があり、利用可能な環境では並列シェーダーコンパイル(KHR_parallel_shader_compile)を使用します。
6. パフォーマンス限界に達したらWebGPUに移行
現在のWebGLプロジェクトがスムーズに動作しているなら、急いで移行する必要はありません。以下の場合に移行を検討してください:
- ドローコールが多いシーンでフレームが落ちる
- 物理シミュレーション用のコンピュートシェーダーが必要
- 複雑なポストプロセッシングでカクつく
7. ブラウザサポートマトリックスを把握する
| ブラウザ | WebGPUがデフォルトで有効 |
|---|---|
| Chrome/Edge(デスクトップ) | Windows、macOS、ChromeOSはv113以降。Linuxはv144以降(Intel)とv147以降(Wayland上のNVIDIA) |
| Chrome(Android) | v121以降(Android 12以上) |
| Firefox | v141以降(Windows)とv145以降(macOS 26、Apple Silicon)。v147以降はApple Silicon上のすべてのmacOSバージョン。LinuxとAndroidは引き続きフラグが必要 |
| Safari | v26以降(2025年9月)。macOS、iOS、iPadOS、visionOS |
すべての主要ブラウザエンジンがWebGPUを搭載し、caniuseによると世界全体のサポート率は約87%です—残りは自動WebGL 2フォールバック(ヒント2)がカバーします。(出典:caniuse.com/webgpu、gpuweb implementation status)
8. forceWebGLを戦略的に使用
forceWebGL: trueオプションはWebGPURendererでWebGLモードを強制します。これは以下の場合に有用です:
- WebGPU対応マシンでWebGLフォールバック動作をテスト
- バックエンド間のシェーダーコンパイル差異をデバッグ
- WebGPUでまだ利用できない特定のWebGL拡張をサポート
WebGLのみを対象とするプロジェクトであれば、WebGLRendererは現在も積極的にメンテナンスされています—レガシーではなく正当な選択肢であり、ノードマテリアルシステムも取り込みません。
9. 大きな効果が期待できるのは特定のシナリオのみ
WebGPUが威力を発揮するのは:
- ドローコールが多いシーン(数百のオブジェクト)
- 計算集約型エフェクト(パーティクル、物理)
- 複雑なシェーダーパイプライン
Chromeチームは、WebGLと比較して「JavaScriptの作業負荷が大幅に削減される」と説明しています(出典:Chrome Developers)。またBabylon.jsは、WebGPUのレンダーバンドルを基盤とするスナップショットレンダリングにより、ほぼ静的なシーンの送信にかかるJavaScript側のコストが桁違いに小さくなると報告しています(出典:Babylon.js docs)。私たちのExpo 2025のインスタレーションでも、パーティクルシミュレーションにWebGPUコンピュートを活用しました。ただし、普遍的に高速というわけではありません—three.jsフォーラムでは、WebGPURendererがWebGLRendererより遅いシーンも報告されています(スレッド例)。速度だけを目的に移行する前に、具体的なユースケースでプロファイルしてください。
10. ノードマテリアルで動的カスタマイズ
ノードマテリアルはpositionNode、colorNode、normalNodeなどのプロパティを受け取り、通常のコードのように合成できます:
import { MeshStandardNodeMaterial } from 'three/webgpu';
import { positionLocal, normalLocal, mx_noise_float, vertexColor } from 'three/tsl';
const material = new MeshStandardNodeMaterial();
const noise = mx_noise_float(positionLocal);
material.positionNode = positionLocal.add(normalLocal.mul(noise));
material.colorNode = vertexColor();
WebGLではカスタムシェーダーが必要だったエフェクトを実現できます。
11. *Async系のrender/computeメソッドは使わない
以前のサンプルでは、GPU作業を同期するためにawait renderer.renderAsync()やcomputeAsync()を使っていました。r181以降これらは非推奨です(renderAsync()は警告を出力します):render()とcompute()が実行順序を処理してくれるので、setAnimationLoop内で直接呼び出してください:
renderer.setAnimationLoop(() => {
renderer.compute(simulation); // その出力を読むレンダーパスより前に送信される
renderer.render(scene, camera);
});
残っている非同期APIは、本当に待つ必要があるものだけです:init()、compileAsync()、そしてr186で新たに追加された、コンピュートシェーダーを事前コンパイルするためのcompileComputeAsync()です。
12. オクルージョンクエリで隠れたオブジェクトをスキップ
フラスタムカリング(ヒント36)が除外するのは、カメラの外にあるものだけです。室内や都市のシーンでは、フラスタム内のオブジェクトの大半が壁の向こうに隠れています。WebGPURendererは両方のバックエンドでハードウェアオクルージョンクエリをサポートしています(WebGL 2ではANY_SAMPLES_PASSEDを使用)—オブジェクトにフラグを立て、可視だったかどうかを問い合わせます:
building.occlusionTest = true;
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
// 結果は以前のフレームのもの
buildingInterior.visible = !renderer.isOccluded(building);
});
公式のオクルージョンのサンプルを参照してください。結果は1〜2フレーム遅れて届くため、安価な遮蔽物の背後にある高コストなディテールをスキップする用途に使い、メインのジオメトリを表示・非表示で切り替える用途には使わないでください。
13. 読み書き可能なコンピュートにはストレージテクスチャを使用
通常のテクスチャと異なり、ストレージテクスチャはコンピュートシェーダーから書き込めます—さらにr183以降は、同じパス内で読み書きの両方が可能です:
import { StorageTexture } from 'three/webgpu';
import { Fn, instanceIndex, textureStore, uvec2, vec4 } from 'three/tsl';
const size = 512;
const outputTexture = new StorageTexture(size, size);
const fill = Fn(() => {
const x = instanceIndex.mod(size);
const y = instanceIndex.div(size);
const uv = uvec2(x, y);
textureStore(outputTexture, uv, vec4(x.toFloat().div(size), y.toFloat().div(size), 0, 1)).toWriteOnly();
})().compute(size * size);
renderer.compute(fill);
結果は他のテクスチャと同様に使えます(例:material.colorNode = texture(outputTexture))。流体シミュレーション、画像処理、GPU駆動レンダリングなどのエフェクトに不可欠です。
14. WebGPU機能検出を適切に処理
すべてのWebGPU機能が普遍的に利用可能とは限りません。使用前に確認してください:
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) {
// WebGLにフォールバックまたはエラーを表示
return;
}
// 特定の機能を確認
const hasFloat32Filtering = adapter.features.has('float32-filterable');
const hasTimestamps = adapter.features.has('timestamp-query');
15. 組み込みInspectorとWebGPU DevToolsでデバッグ
r181以降、three.jsはWebGPURenderer用の独自のInspectorを同梱しています(WebGL 2フォールバック時にも動作します)。1行で、パフォーマンス、メモリ、タイムライン、コンソールの各パネルを追加できます:
import { Inspector } from 'three/addons/inspector/Inspector.js';
renderer.inspector = new Inspector();
バリデーションエラーは、問題のある呼び出しを指すスタックトレースと共にコンソールに表示されます。WebGPU APIレベルでのフレームキャプチャ、バッファの検査、シェーダーのデバッグには、Brendan Duncan氏のWebGPU Inspectorブラウザ拡張機能を追加してください。chrome://gpuでは、そのマシンでWebGPUがハードウェアアクセラレーションされているかを確認できます。
16. フレームごとのデータはGPU上に保持
GPUへのバッファのアップロードは高コストです。最悪のパターンは、毎フレームJavaScriptで配列を再構築して再アップロードすることです:
// 悪い例:CPUでシミュレーションし、毎フレーム全体をアップロード
particles.forEach((p, i) => positionArray.set(p.position, i * 3));
geometry.attributes.position.needsUpdate = true;
// 良い例:GPUでシミュレーションし、アップロード不要
renderer.compute(update); // ヒント4を参照
CPUでの更新が避けられない場合は、バッファ全体ではなく、attribute.addUpdateRange(start, count)で変更された範囲だけをアップロードしてください。
17. 物理シミュレーションにコンピュートシェーダーを使用
パーティクル以外にも、コンピュートシェーダーは物理シミュレーションに優れています:
import { Fn, If, instancedArray, instanceIndex, uniform, vec3 } from 'three/tsl';
const positions = instancedArray(count, 'vec3');
const velocities = instancedArray(count, 'vec3');
const delta = uniform(0); // 毎フレームTimerから設定(ヒント100)
const gravity = uniform(-9.8);
const physics = Fn(() => {
const position = positions.element(instanceIndex);
const velocity = velocities.element(instanceIndex);
velocity.addAssign(vec3(0, gravity.mul(delta), 0));
position.addAssign(velocity.mul(delta));
// 床との衝突
If(position.y.lessThan(0), () => {
position.y = 0;
velocity.y = velocity.y.negate().mul(0.8);
});
})().compute(count);
renderer.compute(physics);
18. コンピュートシェーダーで地形を生成
GPU上でのプロシージャル地形生成により、リアルタイム編集と大規模スケールが可能になります:
import { StorageTexture } from 'three/webgpu';
import { Fn, instanceIndex, mx_noise_float, textureStore, uvec2, vec2, vec4 } from 'three/tsl';
const resolution = 1024;
const heightmap = new StorageTexture(resolution, resolution);
const generateTerrain = Fn(() => {
const x = instanceIndex.mod(resolution);
const y = instanceIndex.div(resolution);
const uv = vec2(x, y).div(resolution);
const height = mx_noise_float(uv.mul(8)).mul(0.5).add(0.5);
textureStore(heightmap, uvec2(x, y), vec4(height, 0, 0, 1)).toWriteOnly();
})().compute(resolution * resolution);
renderer.compute(generateTerrain);
positionNodeでハイトマップを使ってプレーンを変位させ、パラメータが変わるたびにコンピュートパスを再実行すれば、リアルタイム編集が可能になります。
19. ワークグループ共有メモリを活用
スレッド間でデータ共有が必要なコンピュートシェーダーには、ワークグループ変数を使用:
import { Fn, instancedArray, instanceIndex, invocationLocalIndex, workgroupArray, workgroupBarrier } from 'three/tsl';
const input = instancedArray(count, 'float');
const sharedData = workgroupArray('float', 64); // ワークグループ内のスレッドごとに1スロット
const kernel = Fn(() => {
// 各スレッドが共有メモリに値を1つロード
sharedData.element(invocationLocalIndex).assign(input.element(instanceIndex));
workgroupBarrier(); // 全スレッドが書き込むまで待機
// これで任意のスレッドがsharedDataから隣接スレッドの値を読み取り可能
})().compute(count, [64]);
ワークグループメモリはオンチップにあります:NVIDIAは、キャッシュされていないグローバルメモリと比べてレイテンシが約100倍低いとしています(出典:NVIDIA)。ただし実際には、キャッシュによってその差は縮まります。効果を発揮するのは、スレッドが互いのデータを再利用する処理—ブラー、リダクション、タイル型アルゴリズム—です。
20. GPU駆動レンダリングにインダイレクトドローを使用
何をレンダリングするかをGPUに決定させます:コンピュートパスがフラスタムカリングやLOD選択を行い、描画引数を自ら書き込むため、CPUが可視性を読み戻す必要がありません:
import { IndirectStorageBufferAttribute } from 'three/webgpu';
// 毎フレームコンピュートシェーダーが書き込む描画引数:
// インデックス付きジオメトリは5つのuint(インデックスなしは4つ)
const drawBuffer = new IndirectStorageBufferAttribute(new Uint32Array(5), 5);
geometry.setIndirect(drawBuffer);
geometry.setIndirect()はWebGPUバックエンド専用です。公式のインダイレクトドローのサンプルでは、アトミックなインスタンスカウンターを含むコンピュート側の全体像を確認できます。フレームごとのGPUカリングで数百万のインスタンスをレンダリングするのに不可欠です。
アセット最適化
3Dアセットは最大のパフォーマンスボトルネックになりがちです。50MBのGLTFファイルは、レンダリングコードがどれだけ最適化されていても読み込み時間を破壊します。
21. ジオメトリが大半を占める場合はDracoで圧縮
Dracoは多くの場合、ジオメトリを約95%縮小できます(出典:gltf-transform docs)。Cesiumのサンプルモデルでは、ジオメトリバッファが87〜89%縮小しました。圧縮されるのはジオメトリのみで—テクスチャはそのままです—小さなモデルでは、WASMデコーダーのコストが削減効果を上回ることもあります:
gltf-transform draco model.glb compressed.glb
Edgebreakerはすでにデフォルトのメソッドです。DRACOLoaderはWeb Workerでデコードするため、メインスレッドをブロックしません。
22. テクスチャ圧縮にKTX2を使用
PNGやJPEGが圧縮されているのはディスク上だけです—GPUには完全にデコードされた状態で渡されます。2048×2048の200KBのPNGは、ミップマップ生成後に約21MiBのVRAMを占有します。KTX2とBasis UniversalはGPUネイティブの形式にトランスコードされ、メモリ上でも圧縮されたまま維持されるため、GPUメモリの使用量は通常4〜8分の1になります(出典:Don McCurdy):
# 法線マップとORMマップにはUASTC(高品質)、それ以外にはETC1S(小サイズ)
gltf-transform uastc model.glb step1.glb \
--slots "{normalTexture,occlusionTexture,metallicRoughnessTexture}"
gltf-transform etc1s step1.glb optimized.glb
- UASTC: 高品質、大きいファイル。法線マップや主要テクスチャに最適
- ETC1S: はるかに小さいファイル、多少のアーティファクトあり。ベースカラーやセカンダリテクスチャに最適
どちらのコマンドも、KTX-Software 4.4以降のインストールが必要です—CLIがktxバイナリを呼び出します。
23. ファイルサイズだけでなくジオメトリのメモリも削減
DracoやMeshoptはダウンロードサイズを縮小しますが、デコードされたジオメトリは、RAMとVRAM上でfloat32の完全な精度のまま保持されます。大規模なモデル—CAD、3Dスキャン、AI生成メッシュ—では、メモリ上のフットプリントも削減しましょう:
# 位置・法線・UVを16ビット整数で格納(KHR_mesh_quantization)
gltf-transform quantize model.glb quantized.glb
# 重複頂点をマージし、未使用データを削除
gltf-transform weld quantized.glb welded.glb
gltf-transform prune welded.glb optimized.glb
float32の位置・法線・UVを持つメッシュは1頂点あたり32バイトを使いますが、デフォルト設定で量子化すると約20バイトに減ります(--quantize-normal 8で法線を8ビットにすれば約16バイト)—100万頂点のモデルなら35〜50%の削減です。GLTFLoaderは量子化メッシュをネイティブにサポートしています。ランタイムでは、マテリアルが読まない属性も削除し(geometry.deleteAttribute('tangent')、'color'、'uv1')、頂点数が65,536未満のメッシュではインデックスバッファを16ビットに保ってください。
24. gltf-transform CLIをマスターする
gltf-transformはglTF最適化のスイスアーミーナイフです。optimizeは、重複排除、インスタンシング、ウェルド、単純化、テクスチャのリサイズ(デフォルトは2048px)、圧縮を1つのコマンドで実行します:
# Meshoptによるジオメトリ圧縮がデフォルト
gltf-transform optimize model.glb output.glb --texture-compress ktx2
# またはDracoとWebPを明示的に指定
gltf-transform optimize model.glb output.glb --compress draco --texture-compress webp
WebPとAVIFは外部ツール不要です。KTX2にはKTX-Software(ヒント22)が必要です。
25. 出荷前に圧縮結果を視覚的に比較
ファイルサイズだけでは、テクスチャの見た目がいつ劣化し始めるかはわかりません。視覚的なツールで確認しましょう:
- glTF Report: モデルのサイズ内訳を確認し、ブラウザ上でgltf-transformスクリプトを実行
- Shopify製のgltf-compressor: テクスチャをインタラクティブに圧縮—「C」キーを押し続けるとオリジナルと比較
- Khronos glTF Compressor: KTX2、WebP、Draco、Meshoptの設定を並べて比較
「品質が悪く見え始めるまでどこまで圧縮できるか」がわかります。
26. LOD(Level of Detail)を実装
距離に応じてハイポリモデルをローポリバージョンに切り替えます。React Three FiberではDreiの<Detailed />が便利です:
<Detailed distances={[0, 50, 100]}>
<HighPolyModel />
<MediumPolyModel />
<LowPolyModel />
</Detailed>
素のthree.jsでは、組み込みのLODオブジェクトが同じ役割を果たします。LODは遠くのオブジェクトの頂点処理とフラグメント処理を削減します。効果の大きさはシーンによって異なるため、導入前後でフレーム時間を計測してください。
27. テクスチャをアトラス化し、適切なサイズに
複数テクスチャ = 複数テクスチャバインド = レンダリング遅延。テクスチャをアトラスに統合し、UV座標を更新してください。モバイルGPUでは特にオーバーヘッドが大きく軽減されます。
数と同じくらいサイズも重要です:主要なアセットでない限りテクスチャは2048px以下に抑え、遠くから見えるものにはミップマップを有効のままにし、床や斜めの面では解像度を上げる代わりにtexture.anisotropyを上げてください。
28. デコーダーパスを正しく設定
DracoとKTX2にはデコーダーが必要です。一度セットアップしてください:
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // three/examples/jsm/libs/draco/ からコピー
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('/basis/'); // three/examples/jsm/libs/basis/ からコピー
ktx2Loader.detectSupport(renderer); // 必須:GPUテクスチャ形式を選択する
デコーダーファイルをnode_modules/three/examples/jsm/libs/からpublicフォルダやCDNにコピーし、アプリと一緒にキャッシュされるようにしてください。detectSupport()の呼び忘れは、KTX2で最も多いエラーです—これがないと、ローダーはトランスコード先を選択できません。WebGLRendererとWebGPURendererの両方で動作します。WebGPUの場合はawait renderer.init()の後に呼び出してください。
29. Dracoの代替としてMeshoptを検討
MeshoptはDracoよりかなり高速にデコードでき、アニメーションやモーフターゲットも圧縮でき、現在はgltf-transform optimizeのデフォルトになっています(出典:gltf-transform docs)。最高の圧縮率に達するのは、サーバー側でもgzipまたはBrotliを適用した場合のみです(ヒント89):
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
自分のモデルで両方をテストしてください。
ドローコール最適化
シーン内の各メッシュは通常1つのドローコールを生成します。各ドローコールにはCPUオーバーヘッドがあります。重要な洞察:三角形数よりドローコール数が重要。
30. モバイルではフレームあたり約100ドローコールを目標に
three.jsメンテナーのDon McCurdy氏による、広く引用される目安は「できれば100ドローコール未満、100,000頂点未満程度」です(出典:three.jsフォーラム)。これはモバイル向けの目標と考えてください:デスクトップマシンは数百〜数千程度まで余裕で処理できます。実際のコストはGPU処理ではなく、CPU側の送信にあるためです。WebGPUは1回あたりのオーバーヘッドを下げますが、なくすわけではありません。renderer.info.render.calls(WebGPURendererではrenderer.info.render.drawCalls)で確認してください。
31. 繰り返しオブジェクトにはInstancedMeshを使用
1,000本の木を個別メッシュでレンダリング = 1,000ドローコール。InstancedMeshを使えば = 1ドローコール:
const mesh = new InstancedMesh(geometry, material, 1000);
for (let i = 0; i < 1000; i++) {
matrix.setPosition(positions[i]);
mesh.setMatrixAt(i, matrix);
}
行列を変更した後はmesh.instanceMatrix.needsUpdate = trueを呼び出してください。CodropsのSINGULARITYの解説記事は、同じ考え方を大規模に示しています:シーン内のすべてのCDケースのプラスチック部分が、単一のドローコールでレンダリングされています。
32. 異なるジオメトリにはBatchedMeshを使用
BatchedMesh(r156以降)は、同じマテリアルを共有する複数のジオメトリを単一のドローコールに統合します。InstancedMeshと異なり、各インスタンスが異なるジオメトリを使用できます:
const batched = new BatchedMesh(maxInstances, maxVertices, maxIndices, material);
const chairId = batched.addGeometry(chairGeometry);
const tableId = batched.addGeometry(tableGeometry);
const chair = batched.addInstance(chairId);
batched.setMatrixAt(chair, matrix);
オブジェクト単位のフラスタムカリング(perObjectFrustumCulled)、ソート、そしてr183以降はインスタンスごとの不透明度をサポートしており、WebGLRendererとWebGPURendererの両方で動作します。多数の固有パーツで構成されるCADや建築のシーンに最適です。
33. メッシュ間でマテリアルを共有
Three.jsは同一マテリアルのメッシュをバッチ処理します。オブジェクトごとに新しいマテリアルを作成すると、この最適化が機能しません:
// 悪い例:メッシュごとに新しいマテリアル
meshes.forEach(m => m.material = new MeshStandardMaterial({ color: 'red' }));
// 良い例:マテリアルを共有
const sharedMaterial = new MeshStandardMaterial({ color: 'red' });
meshes.forEach(m => m.material = sharedMaterial);
34. BufferGeometryUtilsで静的ジオメトリをマージ
静的シーンでは、読み込み時にメッシュをマージします:
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const merged = mergeGeometries([geo1, geo2, geo3]);
const mesh = new Mesh(merged, sharedMaterial);
複数の代わりに1つのドローコールで済みます。
マージせずに個別に保持する静的オブジェクトには、object.matrixAutoUpdate = falseを設定し、object.updateMatrix()を一度だけ呼び出してください—three.jsは毎フレームの行列の再計算をやめます。公式マニュアルのOptimize Lots of Objectsで、マージの詳細が解説されています。
35. モダンブラウザではアレイテクスチャを使用
アレイテクスチャは複数のテクスチャをレイヤーに統合し、シェーダーでインデックスアクセスします。BatchedMeshと組み合わせることで、最小限のドローコールで多様な外観を実現できます。
36. フラスタムカリングを理解する
Three.jsはカメラのビュー外のオブジェクトを自動的にカリングします—ドローコールを生成しません。この動作は制御できます:
// デフォルト:ビュー外のオブジェクトはカリング
mesh.frustumCulled = true;
// 常にレンダリングすべきオブジェクトでは無効化(スカイボックス、パーティクルシステム)
skybox.frustumCulled = false;
// 複雑なロジックで手動カリング:
const frustum = new Frustum();
const matrix = new Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(matrix);
if (frustum.intersectsObject(mesh)) {
// オブジェクトは可視
}
フラスタムカリングは無料の最適化です—正しく機能するためにバウンディングボックスが正確であることを確認してください。
メモリ管理
Three.jsはGPUリソースを自動的にガベージコレクトしません。使用が終わったジオメトリ、マテリアル、テクスチャは明示的にdisposeする必要があります。
37. 完了時にすべてのGPUリソースをdispose
シーンからオブジェクトを削除しても、GPUメモリは解放されません。ジオメトリ、マテリアル、テクスチャを明示的にdisposeしてください(公式ガイドHow to dispose of objectsを参照):
function cleanupMesh(mesh) {
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
mesh.material.forEach(mat => {
Object.values(mat).forEach(prop => {
if (prop?.isTexture) prop.dispose();
});
mat.dispose();
});
} else {
Object.values(mesh.material).forEach(prop => {
if (prop?.isTexture) prop.dispose();
});
mesh.material.dispose();
}
scene.remove(mesh);
}
4096×4096のRGBAテクスチャ1枚で64 MiBのVRAMを使用します—ミップマップ込みでは約85 MiBです。ジオメトリとシェーダープログラムも持続します。renderer.info.memoryを監視してください—カウントが増え続ける場合、リークがあります。
38. GLTFからのImageBitmapテクスチャを特別に処理
GLTFテクスチャはImageBitmapとして読み込まれ、明示的なクローズが必要です:
texture.source.data.close?.();
texture.dispose();
close()がないと、ImageBitmapオブジェクトがリークします。
39. スポーンされるエンティティにオブジェクトプーリングを使用
頻繁に作成・破棄されるオブジェクト(弾丸、パーティクル、敵)には、新規作成ではなくプールを使用。アロケーションオーバーヘッドとGCポーズを回避できます:
class ObjectPool {
constructor(factory, reset, initialSize = 20) {
this.factory = factory;
this.reset = reset;
this.pool = [];
// プールを事前準備
for (let i = 0; i < initialSize; i++) {
const obj = factory();
obj.visible = false;
this.pool.push(obj);
}
}
acquire() {
const obj = this.pool.pop() || this.factory();
obj.visible = true;
return obj;
}
release(obj) {
this.reset(obj);
obj.visible = false;
this.pool.push(obj);
}
}
// 使用例
const bulletPool = new ObjectPool(
() => new Mesh(bulletGeometry, bulletMaterial),
(bullet) => bullet.position.set(0, 0, 0),
50
);
// スポーン
const bullet = bulletPool.acquire();
scene.add(bullet);
// デスポーン
bulletPool.release(bullet);
ローディング中にプールを事前準備して、ランタイムでのアロケーションスパイクを回避してください。
40. テクスチャをキャッシュして再利用
各テクスチャを一度だけ読み込み、どこでも参照:
const textureCache = new Map();
function getTexture(url) {
if (!textureCache.has(url)) {
textureCache.set(url, textureLoader.load(url));
}
return textureCache.get(url);
}
41. レンダーターゲットもdispose
ポストプロセッシングのレンダーターゲットもdisposeが必要です:
renderTarget.dispose();
各レンダーターゲットはフレームバッファメモリを確保します。
42. コンポーネントのアンマウント時にクリーンアップ(React)
React Three Fiberでは、クリーンアップ関数を使用:
useEffect(() => {
return () => {
geometry.dispose();
material.dispose();
texture.dispose();
};
}, []);
シェーダーとマテリアル
シェーダー最適化は、初心者と上級者を分ける領域です。小さな変更で2倍のパフォーマンス向上が得られることもあります。特にモバイルで顕著です。
43. モバイルではmediump精度を使用
多くのモバイルGPUでは、半精度の方が大幅に低コストです。Qualcommによると、Adrenoではmediumpのフラグメントシェーダーが最大2倍高速かつ2倍の電力効率になり得ます(出典:Qualcomm)。Armも同じ理由で、Maliでは16ビット精度を推奨しています(出典:Arm GPU Best Practices)。Appleも、精度が許す範囲で16ビット型を使うことを推奨しています。主な目的はレジスタ負荷の削減です(出典:Apple)。デスクトップGPUはmediumpを完全に無視します:
precision mediump float;
highpが必要なのは、深度計算、位置計算、広いUV範囲など特定の場合のみです。WGSLで半精度(f16)を使うには、shader-f16機能が必要です。
44. varying変数を最小化
varyingは頂点シェーダーとフラグメントシェーダー間でデータを転送します。1つごとに帯域幅と補間処理のコストがかかるため、数を少なくし、密にパックし、精度が許す場合はmediumpを使用してください(Arm GPU Best Practices):
// 悪い例:varyingが多い
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec4 vColor;
// 良い例:データをパック
varying vec4 vData1; // xy = uv, zw = packed normal
varying vec4 vData2; // xyz = position, w = unused
45. ダイバージェントな分岐をmix()とstep()に置き換え
GPUはピクセルをグループ単位でシェーディングします。同じグループ内のピクセルがifの異なる側を通ると、そのグループは両方の側を実行します。uniformに基づく分岐—全ピクセルで同じ値—は低コストで、片方の処理が重い場合、ブランチレスのコードが自動的に速くなるわけではありません。しかし、データに依存する短い選択であれば、mix()とstep()でダイバージェンスを完全に回避できます(出典:Unityのシェーダー分岐ガイド):
// 悪い例:分岐
if (value > 0.5) {
color = colorA;
} else {
color = colorB;
}
// 良い例:ブランチレス
color = mix(colorB, colorA, step(0.5, value));
46. RGBAチャンネルにデータをパック
テクセルあたり1つではなく4つの値を格納:
vec4 data = texture2D(dataTex, uv);
float value1 = data.r;
float value2 = data.g;
float value3 = data.b;
float value4 = data.a;
テクスチャフェッチが75%削減されます。
47. 動的ループを避ける
動的境界のループは最適化を妨げます:
// 悪い例:動的
for (int i = 0; i < count; i++) { ... }
// 良い例:固定
for (int i = 0; i < 16; i++) { ... }
または短いループは完全に展開してください。
48. 大きな座標を扱うシーンでは精度を管理
都市モデル、CADアセンブリ、地理空間データでは、数十万単位の座標がよく使われます。float32の有効桁数は約7桁しかないため、原点から離れると頂点がジッターを起こし、サーフェスがZファイティングを起こします。根本から対処しましょう:
- フローティングオリジン: 読み込み時にジオメトリを(0, 0, 0)付近に再配置して実世界のオフセットを別途保持するか、カメラが原点付近にとどまるよう定期的にワールドをシフトする
- 狭い深度範囲: シーンが許す限り
camera.nearを遠くに設定する—深度精度はfarプレーンよりもnearプレーンに大きく依存する - より優れた深度バッファ:
reversedDepthBuffer(WebGLRendererはr178以降でEXT_clip_control拡張が必要—それ以前のリリースではreverseDepthBufferという名前—、WebGPURendererはr183以降)、またはどちらのレンダラーでも使えるlogarithmicDepthBuffer
const renderer = new WebGPURenderer({ reversedDepthBuffer: true });
サポートされている環境ではリバース深度を優先してください:対数深度バッファはフラグメントシェーダーから深度を書き込むため、アーリー深度テストが無効になり、フィルレートを消費します。
49. 半透明のオーバードローを最小化
半透明オブジェクトは深度バッファを使って隠れたピクセルをスキップできないため、毎フレームすべてのレイヤーがシェーディング・ブレンドされ、奥から手前へソートされます。重なったパーティクル、葉のカード、ガラスパネルはフィルレートのコストを急速に増大させます—特に高DPIのモバイル画面では顕著です:
// 切り抜きの葉:ブレンドの代わりにアルファテスト
leafMaterial.transparent = false;
leafMaterial.alphaTest = 0.5;
// ソート不要のソフトなエッジ:アルファトゥカバレッジ(MSAAが必要)
leafMaterial.alphaToCoverage = true;
// またはソート不要のディザ半透明
glassMaterial.alphaHash = true;
本当に半透明が必要なサーフェスは少なく大きく保ち、不透明度が1のマテリアルではtransparentをオフにしてください。
50. FnでTSL関数を再利用可能に作成
Fnパターンで再利用可能なシェーダーロジックを作成:
import { Fn, color, float, normalView, positionViewDirection } from 'three/tsl';
const fresnel = Fn(([normal, viewDir, power]) => {
const dotNV = normal.dot(viewDir).saturate();
return float(1).sub(dotNV).pow(power);
});
// 使用
material.emissiveNode = fresnel(normalView, positionViewDirection, 3.0).mul(color(0x66ccff));
関数は一度コンパイルされ、マテリアル間で再利用できます。
51. TSL組み込みノイズ関数を使用
TSLにはMaterialXノイズ関数が含まれています—外部ライブラリは不要:
import { mx_noise_float, mx_noise_vec3, mx_fractal_noise_float } from 'three/tsl';
// シンプルなノイズ
const n = mx_noise_float(positionLocal.mul(scale));
// オクターブ付きフラクタルノイズ
const fbm = mx_fractal_noise_float(positionLocal, octaves, lacunarity, gain);
// カラーバリエーション用3Dノイズ
const colorNoise = mx_noise_vec3(uv.mul(10));
52. シェーダープログラムを再利用
Three.jsは同一シェーダーのプログラムを再利用します。uniformを同じ方法で定義すれば、プログラムは共有されます。不要なバリエーションはプログラムの増殖を引き起こします。
ライティングとシャドウ
ライティングは高コストです。シャドウはさらに高コストです。シャドウ付きのリアルタイムライティングは、他のすべてを合わせたよりも多くのGPU時間を消費する可能性があります。
53. アクティブライトは最小限に
厳密な上限はありませんが、ライトが1つ増えるごとに、ライティングされるすべてのマテリアルでピクセルごとの処理が増え、シャドウを落とすライトごとにレンダーパスが丸ごと1つ追加されます。現実的な出発点は動的ライト3つ以下です。それを超える場合は、ライティングをベイクするか環境マップに頼ってください。
54. PointLightシャドウのコストを理解
PointLightシャドウは6回のシャドウマップレンダリングが必要です(キューブの各面に1回):
ドローコール = オブジェクト × 6 × ポイントライト数
シャドウ付き2つのPointLightで10オブジェクト = 120の追加ドローコール。
55. 静的シーンではライトマップをベイク
ライティングが変わらない場合、テクスチャにベイク:
- Blender(Cycles)で、ライトマップとアンビエントオクルージョンを2つ目のUVセットにベイク
- それらを
material.lightMapとmaterial.aoMapに割り当て、2つ目のUVセットを読むようtexture.channel = 1を設定 - ベイクしたテクスチャをKTX2で圧縮(ヒント22)
ベイクされたライティングはレンダリング時にほぼ無料です。かつて人気だったランタイムベイカーの@react-three/lightmapは2022年以降リリースがないため、新規プロジェクトでは使わないでください。
56. 大規模シーンにはカスケードシャドウマップを使用
CSMはカメラ近くで高品質、遠くで低品質のシャドウを提供:
import { CSM } from 'three/addons/csm/CSM.js';
const csm = new CSM({
camera,
parent: scene,
maxFar: camera.far,
cascades: 4, // デスクトップ: 4, モバイル: 2
shadowMapSize: 2048
});
// シャドウを受ける各マテリアルでcsm.setupMaterial(material)を呼び出し、
// 毎フレームcsm.update()を呼び出す
WebGPURendererでは、ノードベース版を使い、ライトのシャドウにアタッチします:
import { CSMShadowNode } from 'three/addons/csm/CSMShadowNode.js';
const csm = new CSMShadowNode(directionalLight, { cascades: 4, maxFar: camera.far });
directionalLight.shadow.shadowNode = csm;
r186で新たに追加されたSunLight(three/addons/lights/SunLight.js)は、カスケードシャドウ(2段固定)を内蔵した太陽光を両レンダラー向けにパッケージ化したものです。WebGPUでは、three/addons/lights/SunLightNode.jsからSunLightNodeをインポートし、renderer.library.addLight(SunLightNode, SunLight)で一度だけ登録してください。
57. シャドウマップサイズを適切に設定
- モバイル: 512-1024
- デスクトップ: 1024-2048
- 品質重視: 4096
大きなシャドウマップはメモリを二次的に消費します。
58. castShadowとreceiveShadowは選択的に
シャドウを落とすライトはそれぞれ、自身の視点からシーンを再度レンダリングし、castShadowフラグの付いたすべてのオブジェクトがそのパスで描画されます。シャドウは、見る人が気づく場所だけで有効にしてください:
renderer.shadowMap.enabled = true;
sun.castShadow = true;
hero.castShadow = true; // 人が注目するオブジェクト
ground.receiveShadow = true; // シャドウが落ちるサーフェス
smallProps.forEach((prop) => {
prop.castShadow = false; // 小さい・遠いオブジェクト:両方スキップ
prop.receiveShadow = false;
});
上に何もないオブジェクトではreceiveShadowをオフにしておきましょう—レシーバーはすべて、フラグメントシェーダーでシャドウのサンプリングコストを支払います。
59. アンビエントライトに環境マップを使用
環境マップ(HDRI)はライトごとの計算なしでリアルなライティングを提供:
const envMap = pmremGenerator.fromScene(scene).texture;
scene.environment = envMap;
60. シャドウカメラのフラスタムを調整
タイトなフラスタムはシャドウ品質を向上:
directionalLight.shadow.camera.left = -10;
directionalLight.shadow.camera.right = 10;
directionalLight.shadow.camera.top = 10;
directionalLight.shadow.camera.bottom = -10;
デフォルトを使わず、シーンに合わせてください。
61. 静的シーンではシャドウの自動更新を無効化
ライトやシャドウキャストオブジェクトが動かない場合は、毎フレームのシャドウマップの再レンダリングをやめましょう。ライト単位の制御は、WebGLRendererとWebGPURendererの両方で動作します:
light.shadow.autoUpdate = false;
// ライトやオブジェクトが動いたときは、1回だけ更新をリクエスト:
light.shadow.needsUpdate = true;
毎フレームのシャドウパスを節約できます。WebGLRendererでは、renderer.shadowMap.autoUpdate = falseですべてのシャドウを一括で固定することもできます。three.jsをアップグレードする場合は、最近のシャドウ関連の変更に注意してください:PCFSoftShadowMapはr182で非推奨となり、r186で両レンダラーから削除されました(定数は警告を出してPCFShadowMapにフォールバックし、PCFShadowMapはデフォルトでソフトです)—更新後はshadow.biasを再調整してください。
62. シンプルなケースにはフェイクシャドウを使用
ラジアルグラデーションの半透明プレーンでコンタクトシャドウを安価にフェイクできます。リアルシャドウのコストなしで十分な場合も多いです。
React Three Fiber
React Three Fiber(R3F)はReactのメンタルモデルをThree.jsに追加します。同時に、Reactのレンダリングパラダイム特有のパフォーマンスの落とし穴も追加されます。
R3F 9は、非同期のglファクトリーでWebGPUをサポートしています(v10アルファでは、専用の@react-three/fiber/webgpuエントリーポイントが追加されています):
import * as THREE from 'three/webgpu';
<Canvas
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props);
await renderer.init();
return renderer;
}}
>
<Scene />
</Canvas>
63. useFrame内で変更、setStateは使わない
核心ルール:Three.jsの変更はuseFrame内で行い、Reactステートは使わない:
// 悪い例:Reactの再レンダリングをトリガー
const [rotation, setRotation] = useState(0);
useFrame(() => setRotation(r => r + 0.01));
// 良い例:直接変更
const meshRef = useRef();
useFrame(() => {
meshRef.current.rotation.x += 0.01;
});
64. 静的シーンにはframeloop="demand"を使用
何もアニメーションしない場合、毎フレームレンダリングしない:
<Canvas frameloop="demand">
<Scene />
</Canvas>
モバイルデバイスのバッテリーを節約できます。
65. 手動更新にはinvalidate()を呼び出す
オンデマンドレンダリングでは、必要なときに再レンダリングをトリガー:
const invalidate = useThree(state => state.invalidate);
// 変更後に
invalidate();
66. useFrame内でオブジェクトを作成しない
オブジェクト作成はガベージコレクションをトリガー:
// 悪い例:毎フレーム新しいVector3を作成
useFrame(() => {
mesh.position.copy(new Vector3(1, 2, 3));
});
// 良い例:再利用
const targetPos = useMemo(() => new Vector3(1, 2, 3), []);
useFrame(() => {
mesh.position.copy(targetPos);
});
67. フレームレート独立にはdeltaを使用
デバイスによってリフレッシュレートが異なります:
useFrame((state, delta) => {
// 悪い例:速度がフレームレートで変わる
mesh.rotation.x += 0.1;
// 良い例:一定の速度
mesh.rotation.x += delta * speed;
});
68. PerformanceMonitorでデバイスに合わせて品質を調整
Dreiの<PerformanceMonitor>はフレームレートを監視し、デバイスが苦戦しているか、余裕があるかを知らせてくれます。Canvasのピクセル比と連動させましょう:
import { PerformanceMonitor } from '@react-three/drei';
function App() {
const [dpr, setDpr] = useState(1.5);
return (
<Canvas dpr={dpr}>
<PerformanceMonitor
onIncline={() => setDpr(2)}
onDecline={() => setDpr(1)}
flipflops={3}
onFallback={() => setDpr(1)}
/>
<Scene />
</Canvas>
);
}
flipflopsは設定の行ったり来たりを防ぎます:3回切り替わった後は、onFallbackで安全な設定に落ち着きます。同じパターンは、シャドウマップのサイズ、ポストプロセッシング、LODの距離(<Detailed />はヒント26で解説)にも使えます。
69. useGLTF.preloadでモデルをプリロード
必要になる前にモデルを読み込む:
useGLTF.preload('/model.glb');
// 後でコンポーネント内で
const { scene } = useGLTF('/model.glb');
70. 重いコンポーネントはReact.memoでラップ
不要な再レンダリングを防止:
const ExpensiveModel = React.memo(({ url }) => {
const { scene } = useGLTF(url);
return <primitive object={scene} />;
});
71. 再マウントではなく表示/非表示を切り替え
再マウントはバッファの再作成とシェーダーの再コンパイルを伴います:
// 悪い例:アンマウント/マウント
{showModel && <Model />}
// 良い例:表示切り替え
<Model visible={showModel} />
72. r3f-perfでモニタリング(WebGL)
R3F用のドロップインパフォーマンスモニタリング:
import { Perf } from 'r3f-perf';
<Canvas>
<Perf position="top-left" />
<Scene />
</Canvas>
r3f-perfはWebGLRendererのみをサポートしており、最後のリリースは2024年11月です。WebGPUのシーンには、r3f-webgpu-perfまたはthree.js組み込みのInspector(ヒント15)を使用してください。
ポストプロセッシングとエフェクト
ポストプロセッシングはレンダリングされたシーンに追加のGPUパスを実行します。各エフェクトにコストがありますが、賢い設定で影響を最小化できます。
73. WebGLプロジェクトにはpmndrs/postprocessingを使用
pmndrsポストプロセッシングライブラリはエフェクトを自動的にマージしてパス数を削減:
import { EffectComposer, RenderPass, EffectPass, BloomEffect, VignetteEffect } from 'postprocessing';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new EffectPass(camera, new BloomEffect(), new VignetteEffect()));
このライブラリはWebGLRendererでのみ動作します。WebGPUには、three.jsの組み込みパイプライン(ヒント82)を使用してください。
74. ポストプロセッシング用にレンダラーを設定
EffectComposer使用時の最適設定:
// WebGL
const renderer = new WebGLRenderer({
powerPreference: 'high-performance',
antialias: false, // AAはポストプロセッシングで処理
stencil: false,
depth: false
});
// WebGPU
const renderer = new WebGPURenderer({
antialias: false,
powerPreference: 'high-performance'
});
await renderer.init();
WebGPUは深度/ステンシルバッファを自動処理します。ポストプロセッシングでSMAA/FXAAを追加する場合、両レンダラーでネイティブAAを無効にすると効果的です。
75. パフォーマンスのためにマルチサンプリングを無効化
不要な場合:
<EffectComposer multisampling={0}>
<Bloom />
</EffectComposer>
76. トーンマッピングはパイプラインの最後に
ポストプロセッシングでは、レンダラーのトーンマッピングを無効化:
renderer.toneMapping = NoToneMapping;
代わりにToneMappingEffectを最後のエフェクトとして追加。
77. セレクティブブルームを実装
すべてをブルームさせる必要はありません。レイヤーまたはしきい値を使用:
const bloom = new SelectiveBloomEffect(scene, camera, {
luminanceThreshold: 0.9,
luminanceSmoothing: 0.3
});
78. 最後にアンチエイリアシングを追加
ポストプロセッシングはWebGL組み込みのAAをバイパスします。SMAAまたはFXAAを最終パスとして追加:
composer.addPass(new EffectPass(camera, new SMAAEffect()));
79. ブルームパラメータを慎重に調整
- intensity: 全体の強度(通常0.5-2.0)
- luminanceThreshold: ブルームする最小輝度(0.8-1.0)
- radius: 広がりサイズ(0.5-1.0)
低解像度のブルームは安価で、見栄えも良いことが多いです。
80. ピクセル比を制限し、解像度とフレームレートをトレードオフ
高DPIのスマートフォンはdevicePixelRatioが3以上になります—1倍の画面の9倍のピクセル数です。上限を設けましょう。2を超えると違いがわかる人はほとんどいません:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
重いエフェクトチェーンでは、さらに踏み込みましょう。ポストプロセッシングを半解像度でレンダリングしてアップスケールすれば、フィルレートがボトルネックのシーンでは、フレームレートがおよそ2倍になります:
composer.setSize(window.innerWidth / 2, window.innerHeight / 2);
81. 互換性のあるエフェクトをマージ
一部のエフェクトはシェーダーパスを統合できます:
// 複数エフェクトを単一パスで
const effects = new EffectPass(camera, bloom, vignette, chromaticAberration);
82. WebGPU用にはThree.jsネイティブポストプロセッシングを使用
WebGPUプロジェクトでは、pmndrs/postprocessingの代わりに、three.js組み込みのRenderPipelineとTSLノードを使用してください。このクラスはr183までPostProcessingという名前でした。旧名も引き続き動作しますが、非推奨の警告が出力されます:
import * as THREE from 'three/webgpu';
import { pass } from 'three/tsl';
import { bloom } from 'three/addons/tsl/display/BloomNode.js';
const renderPipeline = new THREE.RenderPipeline(renderer);
const scenePass = pass(scene, camera);
const scenePassColor = scenePass.getTextureNode('output');
renderPipeline.outputNode = scenePassColor.add(bloom(scenePassColor));
renderer.setAnimationLoop(() => {
renderPipeline.render();
});
FXAAとSMAAのノードも同じフォルダにあります(FXAANode.js、SMAANode.js)。pmndrsライブラリはWebGLプロジェクトに引き続き優れていますが、TSLベースのポストプロセッシングはフルコンピュートシェーダーサポートを持つWebGPUのネイティブソリューションです。
ローディングとCore Web Vitals
重い3D体験は、注意しないとCore Web Vitalsを破壊する可能性があります。リッチな体験を提供しながら良好なLCP、INP、CLSを維持する方法を紹介します(2024年3月、INPはFIDに代わってCore Web Vitalsの指標になりました)。
83. ファーストビュー外の3DコンテンツはLazy Load
3Dがすぐに見えない場合は、読み込みを遅延:
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadThreeJsScene();
observer.disconnect();
}
});
observer.observe(canvasContainer);
84. Three.jsモジュールをコード分割
すべてを最初からバンドルしない:
const Three = await import('three');
const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js');
85. 重要なアセットをプリロード
ファーストビューの3Dには積極的にプリロード:
<link rel="preload" href="/model.glb" as="fetch" crossorigin>
<link rel="preload" href="/texture.ktx2" as="fetch" crossorigin>
<link rel="modulepreload" href="/assets/scene.js">
同一オリジンのファイルであっても、fetchのプリロードにはcrossoriginを付けたままにしてください—リクエストのCORSモードと一致させる必要があり(出典:MDN)、一致しないとブラウザはプリロードしたレスポンスを再利用できず、ファイルを2回ダウンロードしてしまいます(出典:web.dev)。シーンを構築するJavaScriptにはmodulepreloadを使用してください。
86. プログレッシブローディングを実装
低解像度を最初に表示し、高解像度をバックグラウンドでロード:
// 低解像度を即座にロード
const lowRes = await loadModel('low.glb');
scene.add(lowRes);
// 高解像度を非同期でロード
loadModel('high.glb').then(highRes => {
scene.remove(lowRes);
scene.add(highRes);
});
87. 重い処理をWeb Workerにオフロード
物理、プロシージャル生成、アセット処理はメインスレッド外で実行可能:
const worker = new Worker('/physics-worker.js');
worker.postMessage({ positions, velocities });
レンダリング自体をメインスレッドから移すこともできます:canvas.transferControlToOffscreen()でキャンバスを転送し、Worker内でthree.jsを実行すれば、重いフレームがスクロールや入力をブロックすることはなくなります。
88. 大規模シーンをストリーミング
巨大な環境では、セクションを動的にロード:
function updateVisibleChunks(cameraPosition) {
const visibleChunks = getChunksNear(cameraPosition);
visibleChunks.forEach(chunk => {
if (!chunk.loaded) loadChunk(chunk);
});
}
89. 3Dアセットを圧縮・キャッシュして配信
.glbや.binファイルは転送時によく圧縮でき、Meshoptでエンコードしたジオメトリは、その上からgzipやBrotliで圧縮される前提で設計されています(ヒント29)。サーバーやCDNが実際に圧縮しているかを確認し、コンテンツハッシュ付きのファイル名でアセットを積極的にキャッシュしてください:
# /models/scene.3f9a2c.glb のレスポンスヘッダー
Content-Encoding: br
Cache-Control: public, max-age=31536000, immutable
多くのCDNは既知のMIMEタイプしか圧縮しません—model/gltf-binaryがリストに含まれているか確認してください。含まれていないと、モデルが非圧縮のまま配信されます。Zstandardスーパー圧縮を使ったKTX2ファイルはすでに圧縮済みなので、効果はほとんどありません。
90. R3FでSuspenseを使用
R3FはReact Suspenseと統合:
<Suspense fallback={<Loader />}>
<Model />
</Suspense>
開発とデバッグ
最高の最適化は、問題を早期に発見して不要になる最適化です。これらのツールとテクニックは、問題が本番環境での問題になる前に特定するのに役立ちます。
91. stats-glでWebGL/WebGPUモニタリング
stats-glはリアルタイムのFPS、CPU、GPUメトリクスを提供します。WebGLRendererとWebGPURendererの両方で動作します:
import Stats from 'stats-gl';
const stats = new Stats({ trackGPU: true });
document.body.appendChild(stats.dom);
stats.init(renderer);
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
stats.update();
});
trackCPT: trueを追加すると、WebGPUのコンピュートパスの時間も計測できます。
92. lil-guiでライブ調整をセットアップ
lil-guiはあらゆるJavaScriptオブジェクトのデバッグパネルを作成:
import GUI from 'lil-gui';
const gui = new GUI();
gui.add(camera.position, 'x', -10, 10);
gui.add(camera.position, 'y', -10, 10);
gui.add(light, 'intensity', 0, 2);
開発中に正しい値を見つけるために必須です。WebGPURendererでは、組み込みのInspector(ヒント15)に同じAPIのパラメータパネルが含まれています:
const gui = renderer.inspector.createParameters('Settings');
gui.add(light, 'intensity', 0, 2);
93. Spector.jsでプロファイル
Spector.jsはWebGLフレームをキャプチャするブラウザ拡張機能です。すべてのドローコール、テクスチャバインド、シェーダープログラムを確認できます。実際に何が起こっているかを理解するのに非常に役立ちます。
Spector.jsはWebGL専用です。WebGPUには、Brendan Duncan氏のWebGPU Inspector拡張機能(ChromeとFirefox)を使って、フレームのキャプチャ、バッファやテクスチャの検査、シェーダーのデバッグを行ってください。
94. renderer.infoを定期的にチェック
setInterval(() => {
console.log('Calls:', renderer.info.render.calls);
console.log('Triangles:', renderer.info.render.triangles);
console.log('Geometries:', renderer.info.memory.geometries);
console.log('Textures:', renderer.info.memory.textures);
}, 1000);
これらの数値を監視してください。安定して、増加しないはずです。WebGPURendererでは、renderer.info.render.drawCallsがドローコール数(render.callsはrender()の呼び出し回数)、renderer.info.compute.callsがコンピュートのディスパッチ数をカウントし、renderer.info.memoryはリソースの種類別に使用量を示します。
95. three-mesh-bvhで高速レイキャスティング
three-mesh-bvhはジオメトリのバウンディングボリューム階層(BVH)を構築します—READMEのデモでは、80,000ポリゴンのモデルに対して500本のレイを60fpsでキャストしています:
import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh';
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
Mesh.prototype.raycast = acceleratedRaycast;
mesh.geometry.computeBoundsTree();
複雑なジオメトリを持つインタラクティブシーンには必須です。
96. ブラウザDevToolsのPerformanceタブを使用
Chrome/Edge DevToolsは時間がどこで使われているかを表示:
- 長いフレーム
- ガベージコレクションの一時停止
- ブロッキングJavaScript
合成テストだけでなく、実際のセッションをプロファイルしてください。
97. タイムスタンプクエリでGPU時間を計測
CPUのタイマーではGPUの処理は見えませんが、timestamp-query機能をサポートするアダプターであれば、WebGPUのタイムスタンプクエリで計測できます。three.jsでは生のAPIは不要です—レンダラーでトラッキングを有効にし、毎フレーム結果を解決してください:
const renderer = new WebGPURenderer({ trackTimestamp: true });
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
renderer.resolveTimestampsAsync('render'); // 毎フレーム解決しないとクエリが溜まる
});
setInterval(() => console.log('GPU ms:', renderer.info.render.timestamp), 1000);
trackTimestampはまだ公式ドキュメントに記載されていないため、実験的な機能として扱ってください。ほとんどのプロファイリング用途では、Inspector(ヒント15)とstats-gl(ヒント91)がこれをラップしてくれます。
98. コンテキストとデバイスの喪失を適切に処理
GPUはコンテキストを失うことがあります—モバイルでアプリがバックグラウンドに回ったときや、ドライバーのリセット後などです。WebGPURendererでは、デバイス喪失ハンドラーを設定してください(WebGL 2フォールバックで動作している場合にも発火します):
renderer.onDeviceLost = (info) => {
console.warn('GPU device lost:', info.reason, info.message);
// ループの停止、フォールバックの表示、またはレンダラーの再作成
};
WebGLRendererでは、キャンバスでリッスンします:
renderer.domElement.addEventListener('webglcontextlost', (event) => {
event.preventDefault();
// アニメーションループを停止
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
// 再初期化
});
Chromeで回復処理をテストするには、別のタブでchrome://gpucrashを開きます。
99. アニメーションループをプロファイル
各フレームで何が起こっているかを測定:
function animate() {
const t0 = performance.now();
physics.update();
const t1 = performance.now();
controls.update();
const t2 = performance.now();
renderer.render(scene, camera);
const t3 = performance.now();
console.log(`Physics: ${t1-t0}ms, Controls: ${t2-t1}ms, Render: ${t3-t2}ms`);
requestAnimationFrame(animate);
}
100. setAnimationLoopでクリーンなレンダーループ
手動のrequestAnimationFrameの代わりに、Three.js組み込みのアニメーションループを使用:
// 代わりに:
function animate() {
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
// 使用:
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});
// 必要なときに停止
renderer.setAnimationLoop(null);
XRセッションを自動処理し、よりクリーンな開始/停止制御を提供します。WebXRアプリケーションには必須です。WebGPURendererでは、最初のフレームの前にrenderer.init()をawaitする処理も担います(ヒント1)。
フレームのタイミングには、r183以降非推奨のClockではなく、Timer(r179以降コアに搭載)を使用してください:
import { Timer } from 'three';
const timer = new Timer();
timer.connect(document); // 非表示タブでは一時停止するため、復帰時に巨大なdeltaが発生しない
renderer.setAnimationLoop((timestamp) => {
timer.update(timestamp);
const delta = timer.getDelta();
// ...deltaでアニメーション
renderer.render(scene, camera);
});
Utsuboについて
Utsuboは、ブランドウェブサイトから物理インスタレーションまで、Three.js開発を専門とするインタラクティブクリエイティブスタジオです。
私たちは2024年初頭に2024.utsubo.comで最初期の本番WebGPU Three.js体験をリリースしました。Three.jsエコシステムに積極的に貢献しており、WebGPUパフォーマンスモニタリング用のstats-glもその一環です。
私たちの実績:
- utsubo.com: 受賞歴のある3Dヘビーな体験
- 北斎インスタレーション: 大阪・関西万博2025での100万パーティクル流体シミュレーション
- Segments.ai: WebGPU移行による100倍のパフォーマンス向上
私たちはブランド、美術館、テック企業と共に次世代のウェブ体験を構築しています。
一緒に創りましょう
次の3Dウェブ体験を作るチームをお探しですか?無料ディスカバリーコールをご予約ください。
関連記事
- Three.js 2026年の変化 — WebGPU普及、バイブコーディング革命、Three.jsエコシステムの拡大
- WebGPU Three.js移行ガイド — 既存WebGLプロジェクト向けステップバイステップ移行チェックリスト
まとめ
以上の100のヒントは、2026年のThree.js本番開発における必須プラクティスをカバーしています:WebGPUレンダラーの導入、DracoとKTX2によるアセット最適化、インスタンシングとバッチングによるドローコール削減、適切なメモリ管理、効果的なデバッグワークフロー。以下では、最適化時によく寄せられる質問にお答えします。
よくある質問
Three.jsのパフォーマンスを最適化するには?
まず測定から始めてください:stats-glとrenderer.infoを使用してボトルネックを特定します。最も一般的な問題は、ドローコールが多すぎる(インスタンシングとバッチングで解決)、最適化されていないアセット(DracoとKTX2圧縮を使用)、メモリリーク(未使用リソースを常にdispose)です。モバイルではフレームあたり約100ドローコールを目安にしてください。デスクトップなら数百程度まで処理できます。
Three.jsでのWebGPUのベストプラクティスは?
r171以降、import { WebGPURenderer } from 'three/webgpu'でゼロコンフィグセットアップと自動WebGL 2フォールバックが利用可能です。レンダリングはsetAnimationLoop()で開始すれば、レンダラーの初期化も自動で行われます。クロスプラットフォームシェーダーにはTSL(Three Shader Language)を習得してください。パーティクルシステムや物理にはコンピュートシェーダーを使用。WebGPUはドローコールが多いシーンや計算集約型エフェクトで威力を発揮しますが、普遍的に高速というわけではありません—速度だけを目的に移行する前にプロファイルしてください。
Three.jsでドローコールを減らすには?
繰り返しオブジェクト(木、パーティクル、小道具)にはInstancedMeshを使用。同じマテリアルだが異なるジオメトリのオブジェクトにはBatchedMeshを使用。メッシュ間でマテリアルを共有。BufferGeometryUtilsで静的ジオメトリをマージ。マテリアルのバリエーションを減らすためにテクスチャアトラスを使用。renderer.info.render.calls(WebGPURendererではrender.drawCalls)で進捗を確認してください。
Three.jsアプリケーションのデバッグに役立つツールは?
必須ツール:WebGPUにはthree.js組み込みのInspector、FPS/CPU/GPUモニタリングにはstats-gl、パラメータのライブ調整にはlil-gui、フレームキャプチャにはSpector.js(WebGL)またはWebGPU Inspector、高速レイキャスティングにはthree-mesh-bvh、メモリとドローコール統計にはrenderer.info、フレームタイミング分析にはブラウザDevToolsのPerformanceタブ。
WebGLからWebGPUに移行すべき?
パフォーマンス限界に達している場合は移行してください—特にドローコールが多いシーン、複雑なパーティクルシステム、計算集約型エフェクトで。新規プロジェクトではWebGPUから始めてください。現在のWebGLプロジェクトがスムーズに動作していてパフォーマンスに制限がない場合、急いで移行する必要はありません。Three.jsは自動フォールバックを提供するので、互換性を壊すことなくWebGPUを導入できます。
Three.jsでのメモリリークを防ぐには?
使用が終わったリソースは常にdispose:geometry.dispose()、material.dispose()、texture.dispose()を呼び出してください。ImageBitmapとして読み込まれたGLTFテクスチャの場合は、texture.source.data.close?.()も呼び出してください。renderer.info.memoryを監視—ジオメトリとテクスチャが増え続ける場合、リークがあります。頻繁に作成・破棄されるオブジェクトにはリソースプーリングを実装してください。

大阪・心斎橋発。記憶に残るWeb体験を。


