WebGLを使えば、ブラウザ上でインタラクティブな3Dコンテンツを実装できます。
ただ、実際に3D表示を扱うとなると、単にオブジェクトを表示するだけでは考慮しきれない部分があります。
特に注意したいのが、描画パフォーマンスやGPUリソースの管理、端末ごとの動作の違いです。
PCでは問題なく動いていても、スマートフォンでは動作が重くなったり、描画が停止したりする可能性があります。
今回はWebGLによる3D表示を実装する際に、知っておきたい注意点を7つにまとめてみます。
コード例にはThree.jsを使用しますが、基本的な考え方はWebGLを直接扱う場合にも共通しています。
なお、掲載するコードは説明用のサンプルであり、特定のアプリケーションにおける実測結果ではありません。
WebGLとは?
WebGL(Web Graphics Library)は、JavaScriptからGPUを利用して2D・3Dグラフィックスを描画するためのWeb APIです。
ブラウザ上で動作する3Dビューアーやゲーム、データの可視化などに利用されています。
WebGLを直接扱う場合、シェーダーやバッファーなどを管理する必要があり、実装は比較的複雑になります。
そのため、実際の開発ではThree.jsなどのライブラリを利用することも多いです。
Three.jsでは、SceneやCamera、Meshなどを通じて3Dコンテンツを扱えるため、WebGLの低レベルな処理を意識せずに実装を進められます。
ただし、Three.jsを使用していても、描画負荷やGPUリソースの管理を考慮する必要がなくなるわけではありません。
描画パフォーマンスで注意したいこと
まずは描画負荷に関する3つのポイントです。
WebGLではオブジェクトの数だけでなく、描画回数や解像度によってもパフォーマンスが変わります。
1. オブジェクト数だけでなくDraw Call数を確認する
3D表示のパフォーマンスを考える際、ポリゴン数やオブジェクト数に注目しがちですが、Draw Call数も重要な指標です。
Draw Callとは、CPU側からGPUに対して描画を要求する処理のことです。
同じ形状のオブジェクトであっても、個別に描画していれば、その分Draw Callが増える可能性があります。
例えば、同じBoxを100個表示する場合を考えてみます。
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshNormalMaterial();
for (let i = 0; i < 100; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
(i % 10) * 1.5,
Math.floor(i / 10) * 1.5,
0
);
scene.add(mesh);
}
GeometryとMaterialは共有していますが、Meshは100個生成しています。
このような実装では、基本的にMeshごとに描画処理が発生します。
そこで利用できるのがInstancedMeshです。
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshNormalMaterial();
const count = 100;
const mesh = new THREE.InstancedMesh(
geometry,
material,
count
);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
dummy.position.set(
(i % 10) * 1.5,
Math.floor(i / 10) * 1.5,
0
);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
InstancedMeshは、同じGeometryとMaterialを使用する複数のオブジェクトをまとめて描画するための仕組みです。
今回のように、同じBoxを100個表示するだけであれば、個別のMeshでは約100回発生するDraw Callを、InstancedMeshでは1回にまとめられます。
※上記は追加の描画パスなどが存在しない単純な条件での目安であり、実測値ではありません。
Draw Call数はrenderer.info.render.callsから確認できます。
renderer.render(scene, camera);
console.log(renderer.info.render.calls);
ただし、InstancedMeshを使えば必ず高速になるというわけではありません。
ポリゴン数やシェーダーの処理負荷、オブジェクトの配置などによってもパフォーマンスは変わります。
また、オブジェクトごとに異なるGeometryやMaterialを使用する場合には、そのままInstancedMeshでまとめられません。
同じ形状のオブジェクトを大量に表示する場合は有力な選択肢ですが、実際の描画状況に合わせて判断する必要があります。
2. 画面が変化していないのに描画し続けない
Three.jsでアニメーションを実装する際、よく使われるのがrequestAnimationFrameです。
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
このコードでは、ブラウザの描画タイミングに合わせて継続的にレンダリングを実行します。
常にアニメーションしているコンテンツであれば自然な実装ですが、すべての3D表示で必要というわけではありません。
例えば、ユーザーが操作したときだけ3Dモデルを回転させるビューアーの場合、何も操作していない間まで描画し続ける必要はないことがあります。
こうしたケースでは、変更が発生したタイミングでのみ描画する方法が考えられます。
let renderRequested = false;
function requestRender() {
if (renderRequested) return;
renderRequested = true;
requestAnimationFrame(() => {
renderRequested = false;
renderer.render(scene, camera);
});
}
controls.addEventListener("change", requestRender);
requestRender();
この例では、OrbitControlsによるカメラ操作が発生したタイミングで描画を要求しています。
renderRequestedを使うことで、同じフレームに対して何度も描画要求が登録されることを防いでいます。
ただし、画面サイズの変更や非同期でのモデル読み込みなど、カメラ操作以外でも再描画が必要になるケースがあります。
また、OrbitControlsのenableDampingやautoRotateを有効にしている場合は、継続的なcontrols.update()が必要です。
その場合は、動きが続いている間の描画更新も考慮する必要があります。
常にレンダリングするのか、必要なときだけレンダリングするのかは、コンテンツの性質に応じて使い分けるのがよさそうです。
参考:Rendering on Demand | Three.js
3. devicePixelRatioによる描画負荷を考慮する
スマートフォンでの描画性能を考える際、注意したいのがdevicePixelRatioです。
devicePixelRatioは、CSS上の1ピクセルに対して、端末の物理ピクセルがどの程度対応するかを表す値です。
例えば、500×500ピクセルの描画領域で比較すると、次のようになります。
| devicePixelRatio | 描画サイズ | ピクセル数 |
|---|---|---|
| 1 | 500×500 | 250,000 |
| 2 | 1000×1000 | 1,000,000 |
| 3 | 1500×1500 | 2,250,000 |
devicePixelRatioが2の場合、描画対象のピクセル数は4倍になります。
3の場合は9倍です。
もちろん、ピクセル数に比例して処理時間が増えるわけではありませんが、フラグメントシェーダーやポストプロセスなどの負荷が高い場合、大きな影響を受ける可能性があります。
Three.jsではsetPixelRatio()で描画解像度の倍率を指定できます。
renderer.setPixelRatio(
Math.min(window.devicePixelRatio || 1, 1.5)
);
この例では、最大倍率を1.5に制限しています。
ただし、1.5という値がすべての端末に適しているわけではありません。
描画品質とのバランスを確認し、必要に応じて端末ごとの調整を検討する必要があります。
また、画面サイズが変化した場合は、RendererやCameraのサイズ更新も必要です。
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
上記はPerspectiveCameraを使用した場合の例です。
widthとheightには描画領域のCSSピクセル単位のサイズを指定する想定です。
高解像度で描画すれば見た目は滑らかになりますが、その分GPU負荷も増加します。
特にスマートフォンでは、最初から最大解像度を前提としない方がよさそうです。
GPUリソースの管理で注意したいこと
4. Sceneから削除するだけではメモリは解放されない
JavaScriptでは、不要になったオブジェクトはガベージコレクションによって回収されます。
しかし、WebGLではGPU側にもリソースが確保されるため、JavaScriptのオブジェクトを削除するだけでは十分ではありません。
例えば、Three.jsでMeshをSceneから削除する場合です。
scene.remove(mesh);
この処理ではMeshがSceneから取り除かれますが、GeometryやMaterialなどのGPUリソースがすべて解放されるわけではありません。
Three.jsでは、不要になったリソースを明示的に解放するためのdispose()が用意されています。
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
Textureを使用している場合は、Textureも別途解放します。
texture.dispose();
ただし、ここで気を付けたいのがリソースの共有です。
例えば、複数のMeshで同じGeometryやMaterialを使用している場合、1つのMeshを削除しただけで共有リソースまで解放するのは適切ではありません。
そのリソースを使用するオブジェクトがなくなったタイミングで解放するなど、利用状況に応じた管理が必要です。
また、Material.dispose()を呼び出しても、関連するTextureが自動的に解放されるわけではありません。
TextureやRenderTargetなどは、それぞれ適切なタイミングで解放する必要があります。
リソースの状態を確認する
Three.jsでは、renderer.info.memoryからGeometryやTextureの数を確認できます。
console.log(renderer.info.memory);
例えば、オブジェクトを追加・削除する操作を繰り返した際に、GeometryやTextureの数が増え続けている場合、リソースの解放漏れを疑うきっかけになります。
ただし、renderer.info.memoryはGPUメモリの使用量をバイト単位で取得するAPIではありません。
また、Three.jsが内部で保持するリソースもあるため、Sceneを空にしたとしても、すべての値が0になるとは限りません。
特に3Dモデルの切り替えや画面遷移によってオブジェクトを頻繁に生成・削除する場合は、リソースの管理方法を事前に考えておきたいところです。
参考:How to dispose of Objects | Three.js
端末やブラウザによる違い
描画負荷やメモリ管理だけでなく、動作する環境による違いも考慮する必要があります。
特にスマートフォンでは、GPU性能やメモリの制約、タッチ操作など、PCとは異なる問題が発生する可能性があります。
5. WebGL Context Lossを考慮する
WebGLには、Context Lossという仕組みがあります。
これは、WebGLの描画コンテキストが失われる状態です。
例えば、GPUリソースの不足やドライバーのリセットなどが原因で発生する可能性があります。
Context Lossが発生すると、それまで利用していたWebGLリソースは使用できなくなります。
コンテキストが復元された場合でも、以前のTextureやBufferなどをそのまま利用できるわけではありません。
WebGLでは、この状態を検知するためのイベントが用意されています。
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
console.warn("WebGL context lost");
});
canvas.addEventListener("webglcontextrestored", () => {
console.log("WebGL context restored");
});
このコードはイベントを確認するための最小例です。
実際には描画処理の停止や再開、必要なリソースの再作成なども考慮する必要があります。
Three.jsでは一部の復旧処理が内部で実装されていますが、独自に管理しているWebGLリソースや外部ライブラリの状態まで、必ず復旧できるとは限りません。
開発中にContext Lossを再現する
WebGLには、意図的にContext Lossを発生させるWEBGL_lose_contextという拡張機能があります。
const gl = renderer.getContext();
const extension = gl.getExtension("WEBGL_lose_context");
extension?.loseContext();
この機能を使えば、コンテキストが失われた際の動作を確認できます。
復元を試す場合は、restoreContext()を使用します。
ただし、これは開発・テスト用に利用するものであり、本番環境で意図的にContext Lossを発生させるためのものではありません。
長時間利用する3Dコンテンツでは、Context Lossから正常に復旧できるかも確認しておきたいところです。
6. タッチ操作とマウス操作の違いを考慮する
PCで3Dコンテンツを操作する場合、マウスを利用することが多いと思います。
一方、スマートフォンではタッチ操作が中心になります。
例えば、3Dモデルを回転させたり、拡大・縮小したりする場合、PCではドラッグやマウスホイールを使いますが、スマートフォンでは指によるドラッグやピンチ操作を利用します。
Three.jsのOrbitControlsでは、こうした操作の多くが標準でサポートされています。
ただし、実装上はブラウザ自体の操作と競合する可能性があります。
例えば、3Dモデルを回転させようとして指を動かした際に、ページ全体がスクロールしてしまうケースです。
こうした場合、CSSのtouch-actionを設定することで、ブラウザ側のタッチ操作との競合を制御できます。
canvas {
touch-action: none;
}
ただし、touch-action: noneを指定すると、その領域ではブラウザ標準のスクロールやピンチズームなどが抑制されます。
そのため、ページ全体に適用するのではなく、3D操作が必要な領域に限定することが重要です。
また、スマートフォンにはマウスのホバーに相当する操作がないため、ホバーを前提としたUIにも注意する必要があります。
PCで問題なく操作できたとしても、スマートフォンで同じように操作できるとは限りません。
実際の端末で操作性を確認しておきたいところです。
7. WebGLが利用できない環境も想定する
現在の主要ブラウザではWebGLが広くサポートされていますが、すべての環境で利用できるとは限りません。
例えば、ブラウザの設定やGPU・ドライバーの制限などによって、WebGLコンテキストを生成できない場合があります。
WebGL2を前提とする場合、次のように利用可否を確認できます。
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2");
if (!gl) {
console.warn("WebGL2 is not available");
}
これはWebGL2の利用可否を確認する最小例です。
実際のアプリケーションでは、Rendererの初期化結果や必要な拡張機能の対応状況も確認する必要があります。
また、WebGLを利用できない環境では、エラーを表示するだけでなく、静的な画像などへの切り替えも考えられます。
3D表示がコンテンツの一部である場合、WebGLが使えない環境でも必要な情報を確認できるようにしておくと安心です。
パフォーマンスは何を基準に確認するべきか?
ここまで7つの注意点を紹介しましたが、実際の実装では、どこに負荷が発生しているのかを把握することも重要です。
例えば、FPSが低いからといって、必ずしもDraw Call数が原因とは限りません。
ポリゴン数やシェーダーの処理、描画解像度、JavaScript側の計算など、さまざまな要因が考えられます。
Three.jsではrenderer.infoから描画に関する情報を確認できます。
renderer.render(scene, camera);
console.log(renderer.info.render.calls);
console.log(renderer.info.render.triangles);
console.log(renderer.info.memory.geometries);
console.log(renderer.info.memory.textures);
それぞれの項目は以下のような意味を持ちます。
| 項目 | 内容 |
|---|---|
render.calls |
Draw Call数 |
render.triangles |
描画した三角形の数 |
memory.geometries |
管理されているGeometry数 |
memory.textures |
管理されているTexture数 |
これらを確認することで、描画負荷やリソース管理の問題を調べる手掛かりになります。
ただし、これらはすべてのGPU負荷を直接表す指標ではありません。
Draw Call数が少なくても、シェーダーの処理が複雑であれば負荷が高くなる可能性があります。
また、Texture数だけでGPUメモリの使用量を正確に把握することもできません。
必要に応じてブラウザの開発者ツールやプロファイリング機能を利用し、実際の処理時間を確認する必要があります。
実装時に確認したいチェックリスト
最後に、今回の内容をチェックリストとしてまとめておきます。
描画パフォーマンス
- Draw Call数が過剰になっていないか
- GeometryやMaterialを共有できる箇所はないか
- InstancedMeshを利用できる箇所はないか
- 画面が変化していないのに描画し続けていないか
- devicePixelRatioによる負荷を考慮しているか
GPUリソース管理
- 不要なGeometryやMaterialを適切に解放しているか
- TextureやRenderTargetの解放漏れがないか
- 共有リソースを誤って解放していないか
- オブジェクトの追加・削除を繰り返してもリソース数が増え続けないか
動作環境・操作性
- PCとスマートフォンの両方で動作確認しているか
- タッチ操作とページスクロールが競合していないか
- 画面サイズ変更時に正常に描画されるか
- Context Loss発生後に復旧できるか
- WebGL非対応環境での表示を考慮しているか
すべての項目を最初から最適化する必要はありませんが、少なくとも問題が発生したときに原因を調べられる状態にはしておきたいところです。
まとめ
今回は、WebGLで3D表示を実装する際に注意したい7つのポイントを整理しました。
WebGLを利用すればブラウザ上で3Dコンテンツを描画できますが、表示できることと、快適に動作することは別の問題です。
特にDraw Call数やGPUリソースの解放、描画解像度などは、実装方法によってパフォーマンスに差が出やすい部分です。
また、スマートフォンでは描画性能だけでなく、タッチ操作やContext Lossなども考慮する必要があります。
個人的には最初からすべてを最適化しようとするより、何が原因で負荷が発生しているのかを確認できる状態にしておくことが重要だと思います。
Three.jsなどのライブラリを利用することで実装は簡単になりますが、GPUリソースやレンダリングの仕組みを理解しておくことは、パフォーマンス改善や問題の調査にも役立ちます。
これからWebGLを利用する場合は描画処理だけでなく、メモリ管理や端末ごとの動作も含めて設計しておきたいところです。