Three.jsパフォーマンス最適化についてのまとめ

目次
  1. はじめに
  2. スムーズな動作を確保するためのパフォーマンス監視
    1. stats.js
    2. Spector.js
    3. renderer.info
  3. パフォーマンスを最適化するための対策
    1. リーソースの破棄
    2. テクスチャの解像度について
    3. ライトやシャドウの使用について
    4. ジオメトリやマテリアルの共有
    5. 複数のジオメトリを一つにマージ
    6. シェーダーのパフォーマンス最適化
    7. その他

はじめに

インタラクティブなウェブサイトのパフォーマンス最適化について

スムーズな体験とデバイスの負担のバランス
ユーザーにスムーズで快適な体験を提供すること
デバイスに過度の負担をかけないようにすること
これらは時に相反する目的となることがありますが、両者のバランスを適切に取る

  • スムーズな体験
    高いフレームレートや迅速な応答性は、特にインタラクティブな要素やアニメーションを多用するサイトにおいて、不可欠ですがこれを実現するためには、高いCPUやGPUの処理能力が必要となり、これがデバイスに負担をかける原因となることがあります
  • デバイスの負担
    サイトが多くのリソースを消費しすぎると、特に性能が低いデバイスでは処理速度が低下し、結果としてユーザー体験が損なわれることがあります
    サイトの負担が重すぎると、ユーザーはサイトを閉じ、利用を避けたりすることがあります
    特にモバイルユーザーにとって大きな問題です
  • バランスの取り方
    画像やアセットのサイズを最適化し、不要なスクリプトを削減することで、ロード時間を短縮します
    基本的な機能をすべてのユーザーに提供し、高性能デバイスには追加機能を提供することも一つの方法です

スムーズな動作を確保するためのパフォーマンス監視

注意
開発者が高性能なコンピュータを使用し、高いフレームレート(例えば60fps)でスムーズに動作しても、性能が低いコンピュータやモバイルデバイスでは同じように高いパフォーマンスを維持できるとは限らない
*開発者が高性能なコンピュータを使用している場合は、Chromeのフレームレート制限を解除することで、開発者は自分のコンピュータでウェブサイトがどの程度のパフォーマンスを発揮できるかを正確に評価できる(高性能なコンピュータでウェブサイトが70~80fpsでしか動作しない場合、性能が低いデバイスでは60fpsを下回る可能性がある)

stats.js

「stats.js」はウェブページやウェブアプリケーションのパフォーマンスをリアルタイムでモニタリングするためのJavaScriptライブラリです
主にフレームレート(FPS)やレンダリングにかかる時間(ミリ秒)などを確認するために使用します

stats.jsのセットアップ

npm install stats.js
import Stats from 'stats.js';

const stats = new Stats();
//初期表示するためのインデックスを指定
stats.showPanel(0); // 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild(stats.dom);

//アニメーションループ内
const tick = () => {
    stats.begin();

    // ここにアニメーションやレンダリングのコードを記述

    stats.end();
    requestAnimationFrame(tick);
};

tick(); 

stats.jsのパネルには、フレームレート(FPS)、レンダリング時間(MS)、使用メモリ(MB)、カスタムパネルの表示オプションがあります
実行中にパネルをクリックすることで、切り替えることが可能

Spector.js

Draw Calls」について

例えば、three.jsのコンテキストでの「Draw calls」は、GPUに対して行う「描画命令(CPUからGPUへ送信される命令)」のことを指します
一般的に、1つの「draw call」は1つのオブジェクトやメッシュを描画するのに使用されます
多くのオブジェクトや複雑なシーンがある場合、それに伴い「Draw calls」の数も増加して、CPUとGPU間の通信が頻繁になり、パフォーマンスに影響を与える可能性があります
「Draw Calls」の数を減らすことは、パフォーマンスを向上させるために重要です

Spector.jsのGoogle Chrome拡張機能

特定のフレームのためのWebGLの「Draw Calls」などをキャプチャし、それらをステップバイステップで表示します
Spector.jsはGoogle Chromeの拡張機能として利用可能です
Spector.jsは、どの描画命令がパフォーマンスに影響を与えているかを特定するのに役立ちます

Spector.jsの使用方法

  • WebGLコンテンツが含まれるウェブページを開き、Spector.jsの拡張機能アイコンをクリックします
    これにより拡張機能がアクティベートされます
  • 拡張機能パネルの開始
    拡張機能アイコンをもう一度クリックして、Spector.jsのメインパネルを開きます
  • フレームの録画
    Spector.jsのパネル内にある赤い円(レコードボタン)をクリックします
    これにより、次にレンダリングされるフレームの録画が開始されます
  • 待機と結果の表示
    録画を開始した後、少し待ちます
    新しいタブまたはウィンドウが開き、録画されたフレームに関する詳細な情報が表示さる
  • Commandsタブ
    「Commands」タブは「draw calls」の数を表示
    ここには、フレームがどのように描画されたか、ステップバイステップで表示されます。
    青いステップは「draw calls」を示し、それ以外のステップはGPUへ送信されるデータ(行列、属性、ユニフォームなど)を表します

renderer.info

three.jsを使用して作成した3Dシーンのパフォーマンスとリソース使用状況をリアルタイムで監視
renderer.infoに含まれる情報

const renderer = new THREE.WebGLRenderer();
// シーンをレンダリングした後
renderer.render(scene, camera);
// レンダリング情報をコンソールに出力
console.log(renderer.info);

パフォーマンスを最適化するための対策

リーソースの破棄

https://threejs.org/docs/#manual/ja/introduction/How-to-dispose-of-objects

three.jsで生成されたジオメトリやマテリアルなどのリソースは、使用後に適切に破棄する
*不要になったリソースを破棄しないと、メモリリークが発生
Geometry, Material, Textureなどのオブジェクトは自動的には解放されないので、不要になったタイミングで対応するdispose()メソッドを呼び出す必要がある
アプリケーション側でオブジェクトのdispose()のタイミングを決める
破棄するタイミングはアプリケーション次第だが、複数のシーンやレベルを持つアプリケーションでは、古いシーンやレベルから次のシーンへの移行時にリソースを破棄することが重要

1:シーンからメッシュを削除しても、ジオメトリやマテリアルは自動で解放されな

scene.remove(cube)
cube.geometry.dispose()
cube.material.dispose()

2:マテリアルを廃棄してもテクスチャは影響を受けず、別々に管理されるべき

  • TextureはTexture.dispose()
  • WebGLRenderTargetはWebGLRenderTarget.dispose()
    *WebGLRenderTargetはオフスクリーンレンダリング(画面外での描画)を行う際に使用されるオブジェクト

3:dispose() メソッドを使用した後でも、オブジェクトは再利用可能

しかし、three.jsエンジンは必要なリソースを再作成します。特にシェーダプログラムのコンパイルなどが必要な場合、パフォーマンスに影響します
再利用する際はパフォーマンス低下のリスクを考慮する必要がある

テクスチャの解像度について

テクスチャの解像度は可能な限り低く設定する

テクスチャの解像度がGPUメモリの使用量に大きく影響
特にミップマップ(小さい解像度のバージョンを自動生成する機能)を使うと、さらにメモリ使用量が増加します
画質とパフォーマンスのバランスを考えて、必要最小限の解像度のテクスチャ(小さい画像)を選択することが重要です
注意
*正方形である必要はなく、幅と高さが異なっていいが、2のべき乗の解像度を保つ(ミップマップ生成のため)
*ファイルサイズを減らす(画像ファイルの圧縮等)はGPUメモリの節約には直接的な影響を与えませんが、ファイルサイズを減らすこと(.jpgと.pngを使い分けや圧縮)でローディング時間を短縮できます

ライトやシャドウの使用について

ライトやシャドウの使用は出来るだけ避ける
可能であれば、ライトの使用を避けるか、最小限に抑えることが推奨されます
パフォーマンスの低下が少ないライト「AmbientLight」や「DirectionalLight」
影を直接テクスチャに焼き付ける(baked shadows)などの代替手段を検討する

シャドウを使用する場合の最適化

  • シャドウカメラの範囲を最小化する
    CameraHelperを使用して、シャドウマップカメラによってレンダリングされるエリアを視覚化して可能な限り小さなエリアに制限
  • シャドウマップの解像度をできるだけ小さく設定(許容できる品質を保つ)
    例えば、directionalLight.shadow.mapSize.set(1024, 1024) のように設定
    *通常シャドウマップの解像度は2のべき乗(例:256, 512, 1024, 2048など)に設定
  • 「castShadow」と「receiveShadow」プロパティの使い方
    少数のオブジェクトに対してこれらのプロパティを有効にする
    例:床は影を受けることができますが、影を投げる必要はない
    floor.castShadow = false
    floor.receiveShadow = true
  • シャドウマップの自動更新を無効化する
    enderer.shadowMap.autoUpdate = falseに設定してシャドウマップの自動更新を無効にすることができます
    その後、シャドウマップを更新する必要があるとき(例えば、シーン内のライトやオブジェクトが動いた後)にrenderer.shadowMap.needsUpdate = trueに設定することで、シャドウマップを手動で更新できる

ジオメトリやマテリアルの共有

複数のメッシュが同じジオメトリ形状を使用する場合、一つのジオメトリを作成し、すべてのメッシュでそれを使用
マテリアルも同様に、複数のメッシュで同じタイプのマテリアルを使用する場合、一つのマテリアルを作成し、それを複数のメッシュで共有する
個々のメッシュごとに異なるマテリアルを使用することも可能です

また可能な限りリソースを少なく消費するマテリアルを使用する
「MeshBasicMaterial」「MeshLambertMaterial」「MeshPhongMaterial」

複数のジオメトリを一つにマージ

動かない場合
BufferGeometryUtilsのmergeBufferGeometriesメソッドを使用
https://koro-koro.com/threejs-no3/#chapter-7

「draw call」を減らし、パフォーマンスを向上させる
ジオメトリの変換(回転、平行移動など)はマージする前に各ジオメトリに適用する

import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'

const geometries = []
for(let i = 0; i < 50; i++){
    const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
    geometry.rotateX((Math.random() - 0.5) * Math.PI * 2)
    geometry.rotateY((Math.random() - 0.5) * Math.PI * 2)
    geometry.translate(
        (Math.random() - 0.5) * 10,
        (Math.random() - 0.5) * 10,
        (Math.random() - 0.5) * 10
    )
    geometries.push(geometry)
}

const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries)

const material = new THREE.MeshNormalMaterial()

const mesh = new THREE.Mesh(mergedGeometry, material)
scene.add(mesh)

InstancedMeshの使用
マージするのとほぼ同等の効果が得られ、変換行列を変更することでメッシュを動かすことができます

InstancedMeshを一つ作成し、それぞれのインスタンスに対して変換行列(Matrix4)を適用します

アニメーションループ内でこれらの行列を変更する場合は、mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)を設定することで、パフォーマンスを最適化できる

const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);

const material = new THREE.MeshNormalMaterial();
/*
インスタンス化されたメッシュを作成
new THREE.InstancedMesh(geometry, material, count)
geometryとmaterialを使ってcountの数だけインスタンス化されたメッシュを作成
*/
const mesh = new THREE.InstancedMesh(geometry, material, 50);
/*
アニメーションループで行列(matrix)を動的に変更する場合
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)を使用することで、
パフォーマンスを最適化できます
*/
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
scene.add(mesh);

// 各インスタンスの位置
for(let i = 0; i < 50; i++) {
    // ランダムな位置ベクトルを生成
    const position = new THREE.Vector3(
        (Math.random() - 0.5) * 10,
        (Math.random() - 0.5) * 10,
        (Math.random() - 0.5) * 10
    );
/*
各インスタンスの回転
回転を表すクォータニオンを生成
クォータニオンは、4つの要素( x, y, z, w)を持つ数学的オブジェクト
*/
    const quaternion = new THREE.Quaternion();
/*
オイラー角をクォータニオンに変換し、オブジェクトの回転を表現
THREE.Eulerオブジェクトは3つの回転角度(x, y, z軸周りの回転)生成
*/
    quaternion.setFromEuler(new THREE.Euler((Math.random() - 0.5) * Math.PI * 2, (Math.random() - 0.5) * Math.PI * 2, 0));
/*
変換行列を作成
THREE.Matrix4
4x4行列
*/
    const matrix = new THREE.Matrix4();
/*
クォータニオンで定義された回転をTHREE.Matrix4オブジェクトに適用する(行列形式に変換)
その行列をオブジェクトに適用する
*/
    matrix.makeRotationFromQuaternion(quaternion);
//Matrix4を使用して各インスタンスの位置を設定
    matrix.setPosition(position);

    // 作成した行列をメッシュに適用
    mesh.setMatrixAt(i, matrix);
}

*オイラー角(3つの角度によって定義され、通常はオブジェクトのx軸、y軸、z軸周りの回転を指定)は、ジンバルロックという問題を引き起こす可能性があるため、3Dアニメーションではクォータニオン( x, y, z, w)を使用する

シェーダーのパフォーマンス最適化

1:シェーダーの精度を低く設定してパフォーマンスを向上させる

const shaderMaterial = new THREE.ShaderMaterial({
    precision: 'lowp',
    // ...
})

2:definesの使用
Uniformsは動的に変更可能な値ですが、パフォーマンスコストがあります
一方、definesは静的な値です

シェーダーコード内で直接定義

#define uStrength 1.5

ShaderMaterialのdefinesプロパティを使って設定できます

const shaderMaterial = new THREE.ShaderMaterial({
    // ...
    defines:{
        uStrength: 1.5
    },
    // ...
}

3:条件分岐(if文)を避ける

  • clamp関数:値を特定の範囲内に制限します
    例えば、clamp(value, min, max)は、valueをminとmaxの間に保持します
    値が特定の範囲を超えないようにすることができます
  • mix関数:2つの値を混合するために使用
    例えば、mix(x, y, a)は、xとyの間でaの割合で混合した値を返します
    aが0の場合はx、1の場合はyとなります

4:可能であれば、頂点シェーダーで計算を実行し、結果をフラグメント シェーダーに送信する

フラグメントシェーダーで行う計算はピクセルごとに実行されるため

その他

1:良いJavaScriptコードの記述

特に重要なのは、フレームごとに実行される関数などの、頻繁に実行される部分のコードです
不要な計算や重複する処理を避ける

2:カメラの調整

  • Field of View
    カメラの視野を狭めることで、画面上に表示されるオブジェクトの数を減らし、レンダリングする必要のあるポリゴン数を削減できます
    *視野外のオブジェクトはレンダリングされません
  • Near and Far
    カメラが捉えることができる距離を制限し、詳細が不要なオブジェクトをレンダリングから除外できます

3:レンダラーのピクセル比を 2 程度に制限
*高いピクセル比を持つデバイスは、より多くのピクセルをレンダリングする必要があるため制限しておく

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

4:ポストプロセシングパスの数を制限し、複数のカスタムパスを一つにまとめる

5:アンチエイリアスはパフォーマンスに影響を与えます。エイリアス(ジャギー)が目立たない場合は、アンチエイリアスを無効にしてパフォーマンスを向上させることができます