three.js のフレーム落ち:シェーダーコンパイル、アップロード、GC
three.js の典型的なカクつきはこうです。シーンはなめらかに動いているのに、何か新しいものが現れた瞬間に止まります。新しいマテリアル、新しいライト、エフェクト、ストリーミングで読み込まれるモデルなどです。数十から数百ミリ秒固まったあと、またなめらかに戻ります。
この停止の原因は、ほぼ必ず次の 3 つのどれかです。シェーダーのコンパイル、GPU へのテクスチャのアップロード、そしてガベージコレクション。Myaku は、それがどれなのかをフレームごとに教えてくれます。
シーンに追加する
npm install -D myaku// main.ts の 1 行目
import "myaku/auto";
import * as THREE from "three";Myaku は three.js 自身の devtools フック(__THREE_DEVTOOLS__)を監視しています。WebGLRenderer を作ると Myaku がそれを見つけ、renderer.render() の呼び出しをすべて render スパンとして計測します。WebGL コンテキストもラップするので、three.js が毎フレーム GPU に送っているもの、つまりドローコール、テクスチャとバッファのアップロード量(バイト)、シェーダーのコンパイルも数えます。
コンテキストを捕まえられるよう、レンダラーを作る前に読み込んでください。そうしなかった場合、Myaku は黙って何も報告しないのではなく、canvas のコンテキストが見つからなかったと警告してくれます。
シェーダーのコンパイル
three.js は、まだ見たことのないマテリアルとライティングの組み合わせを初めて描くとき、プログラムをコンパイルします。遅い GPU ドライバーでは、これに 1 フレームよりはるかに長い時間のかかることがあります。Myaku はフレームごとに compileShader と linkProgram の呼び出しを数えるので、判定はこうなります。
Frame 2,031 took 140 ms (budget 25 ms). 2 shader compiles or program links happened during play. Compile shaders while loading (warm up materials and effects before the level starts), not the first time they're used.
(フレーム 2,031 に 140 ms かかりました(予算 25 ms)。プレイ中にシェーダーのコンパイルまたはプログラムのリンクが 2 回発生しました。シェーダーは初めて使うときではなく、読み込み中にコンパイルしましょう)
対策:すべてをシーンへ追加し終えたら、プレイヤーが見る前に renderer.compile(scene, camera)(または compileAsync)でコンパイルを済ませておきましょう。
テクスチャのアップロード
テクスチャを読み込んでも、それだけではアップロードされません。three.js はそのテクスチャを使う最初のフレームでアップロードします。4K テクスチャは GPU 上で 64 MB あり、そのアップロードが 1 フレームの中で起きるのです。Myaku はフレームごとのバイト数を数えます。
Frame 3,377 took 58 ms (budget 25 ms). 3 uploads sent 48 MB to the GPU this frame.
(フレーム 3,377 に 58 ms かかりました(予算 25 ms)。このフレームで 3 回のアップロードにより 48 MB が GPU に送られました)
対策:読み込み中に renderer.initTexture(texture) を呼ぶ、テクスチャを小さくするか圧縮する(Basis を使った KTX2)、新しいモデルを少しずつ表示する、などです。
ガベージコレクション
レンダーループ内の new THREE.Vector3()、毎フレームの clone()、毎フレーム作り直す配列。どれも積み重なってガベージコレクションを招きます。Myaku は、あなたのコードが何もしていない間にヒープが何 MB も縮んだことを検知し、それを gc と判定します。ベクトルや行列はループの外で宣言して使い回しましょう。
Myaku は、ブラウザがこれを見分けられるほど細かくヒープを報告しているかも確認します。そうでない場合は、推測せずに「GC の可能性は否定できない」と伝えます。
自分のシステム
render は three.js が描画にかけた時間を教えてくれます。それ以外のものには、スパンを追加しましょう。
import { myaku } from "myaku";
const profiler = myaku();
renderer.setAnimationLoop(() => {
profiler.span("physics", () => world.step(1 / 60));
profiler.span("animation", () => mixer.update(clock.getDelta()));
renderer.render(scene, camera); // 自動的に "render" として計測される
});Myaku に見えないもの
Myaku はまだ GPU そのものの時間は計測しません。あなたのコードが速く、ほかに遅いフレームを説明できるものがない場合、判定は確信度の低い unknown になります。ドローコールが数千に達しているときは、GPU がボトルネックかもしれないというヒントを添えます。推測はせず、わからないことはわからないと伝えます。