WebGL & Three.js

Three.jsでlil-guiとstats.jsを使う方法|GUI調整・FPS計測

Three.jsへlil-guiとstats.jsを導入し、位置・Material・色・uniformをGUIで調整する方法と、FPS・MS・draw callを計測する方法を解説します。dat.GUIからの移行、controllersRecursive、save/load、破棄まで対応します。

この記事の目次
  1. lil-guiとstats.jsをinstallする
  2. 最小構成でlil-guiとstats.jsを表示する
  3. lil-guiはobjectのpropertyを操作する
  4. 数値sliderのmin・max・stepを設定する
  5. folderでcontrollerを整理する
  6. label名をnameで変更する
  7. color pickerでThree.jsの色を変更する
  8. dropdownで候補を選択する
  9. onChangeとonFinishChangeを使い分ける
  10. 外部で変わる値をlisten・updateDisplayで同期する
  11. listenで毎frame同期する
  12. updateDisplayで必要時だけ同期する
  13. ShaderMaterialのuniformを操作する
  14. controllerをenable・disable・show・hideする
  15. controllersRecursiveで全controllerを取得する
  16. 設定をsave・load・resetする
  17. GUIの表示位置とstyleを変更する
  18. dat.GUIからlil-guiへ移行する
  19. stats.jsを初期化する
  20. stats.begin・endをanimation loopへ入れる
  21. stats.updateとの違い
  22. FPSとMSの読み方
  23. renderer.infoでdraw callとresource数を見る
  24. 複数render passではautoResetを管理する
  25. lil-guiへrenderer.infoを表示する
  26. performanceを切り分ける手順
  27. production buildからdebug toolを外す
  28. GUI・stats・Three.js resourceを破棄する
  29. lil-guiが表示・更新されない原因
  30. GUIがcanvasの後ろへ隠れる
  31. sliderを動かしてもThree.jsが変わらない
  32. 外部animationの値がGUIへ反映されない
  33. colorが明るすぎる・違って見える
  34. controllerが重複して増える
  35. saveでname collision errorになる
  36. stats.jsのMB panelが表示されない
  37. FPSは低いがdraw callは少ない
  38. stats.jsを入れたら数値が少し変わる
  39. lil-gui・stats.jsチェックリスト
  40. まとめ

Three.jsの値を実行中に調整するならlil-gui、frame rateを常時確認するならstats.jsが便利です。2つは似たdebug panelに見えますが、役割が異なります。

tool 役割 確認できること
lil-gui runtime parameterの変更 position、color、roughness、uniformなど
stats.js 簡易performance monitor FPS、1 frameのMS、条件付きmemory
renderer.info Three.js renderer統計 draw call、triangle、texture、geometryなど

この記事ではViteなどのbundlerを使う構成を前提に、lil-gui 0.21系とstats.jsの導入、controller、folder、color、event、uniform、controllersRecursive()、save/load、FPS計測、renderer.info、後片付けまで解説します。

lil-guiとstats.jsをinstallする

debug toolとしてだけ使う場合はdevDependenciesへ追加できます。

npm install --save-dev lil-gui stats.js

JavaScriptからdefault importします。

import GUI from "lil-gui";
import Stats from "stats.js";

lil-guiのdefault stylesheetは最初のGUI作成時にinjectされるため、基本導入で別CSS importは不要です。自作stylesheetだけを使う場合はinjectStyles: falseを指定します。

Three.js自体のScene・Camera・Renderer・Meshの作り方が未確認なら、先にThree.js入門チュートリアルを参照してください。

スポンサーリンク

最小構成でlil-guiとstats.jsを表示する

回転するcubeの速度・色・roughnessをlil-guiで変更し、stats.jsでFPSを表示する最小例です。

import * as THREE from "three";
import GUI from "lil-gui";
import Stats from "stats.js";

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111827);

const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(3, 2, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(800, 600);
document.body.appendChild(renderer.domElement);

const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshStandardMaterial({
  color: 0x38bdf8,
  roughness: 0.45,
  metalness: 0.1,
});

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

const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
const directionalLight = new THREE.DirectionalLight(0xffffff, 3);
directionalLight.position.set(4, 5, 3);
scene.add(ambientLight, directionalLight);

const params = {
  spin: true,
  speed: 0.8,
  color: "#38bdf8",
};

const gui = new GUI({ title: "Scene controls" });
gui.add(params, "spin").name("回転する");
gui.add(params, "speed", 0, 3, 0.01).name("回転速度 rad/s");
gui.add(material, "roughness", 0, 1, 0.01).name("roughness");
gui.addColor(params, "color").name("色").onChange((value) => {
  material.color.set(value);
});

const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);

const timer = new THREE.Timer();
timer.connect(document);

renderer.setAnimationLoop((timestamp) => {
  stats.begin();
  timer.update(timestamp);

  if (params.spin) {
    mesh.rotation.y += params.speed * timer.getDelta();
  }

  renderer.render(scene, camera);
  stats.end();
});

Three.js公式はanimation loopにrenderer.setAnimationLoop()を使う方法を案内しています。Timerのdelta timeを掛けてrefresh rateによる回転速度差を避けています。lil-guiは値を変更し、stats.jsはloopの結果を観察します。

lil-guiはobjectのpropertyを操作する

gui.add(object, property)へ対象objectとproperty名を渡すと、現在のdata typeに合うcontrollerが生成されます。

const params = {
  enabled: true,
  label: "Cube",
  speed: 0.01,
  reset() {
    mesh.position.set(0, 0, 0);
  },
};

gui.add(params, "enabled"); // checkbox
gui.add(params, "label");   // text input
gui.add(params, "speed");   // number input
gui.add(params, "reset");   // button

function propertyを追加するとbuttonになります。UI用の状態はparamsへまとめ、Three.js objectのpropertyを直接bindできる場合は直接渡すと構成が明確になります。

数値sliderのmin・max・stepを設定する

数値controllerへmin・maxを渡すとsliderになります。

gui.add(mesh.position, "x", -5, 5, 0.01);

gui.add(mesh.rotation, "y")
  .min(-Math.PI)
  .max(Math.PI)
  .step(0.01)
  .name("rotation.y");

mesh.positionはVector3、mesh.rotationはEulerで、どちらもx・y・z propertyを持ちます。回転はdegreeではなくradianです。position・rotation・scaleの違いはThree.jsの回転・移動・拡大縮小で解説しています。

folderでcontrollerを整理する

項目が増えたらaddFolder()で目的別にまとめます。folderもGUI instanceなので、rootと同じようにaddできます。

const transformFolder = gui.addFolder("Transform");
transformFolder.add(mesh.position, "x", -5, 5, 0.01);
transformFolder.add(mesh.position, "y", -5, 5, 0.01);
transformFolder.add(mesh.position, "z", -5, 5, 0.01);

const materialFolder = gui.addFolder("Material");
materialFolder.add(material, "roughness", 0, 1, 0.01);
materialFolder.add(material, "metalness", 0, 1, 0.01);

materialFolder.close();

lil-guiのfolderはdefaultでopenです。初期状態で閉じたいfolderはclose()を呼ぶか、root GUI作成時にcloseFolders: trueを指定します。

label名をnameで変更する

object property名をそのまま見せず、意味の分かるlabelへ変更できます。

gui.add(directionalLight, "intensity", 0, 10, 0.1)
  .name("主光源の明るさ");

GUIの表示名だけが変わり、元objectのproperty名は変わりません。save/loadを使う場合、同じGUI内に同名controller・folderがあるとname collisionになるため、一意な名前を付けます。

Lightの種類・position・intensityはThree.js Lightの使い方を参照してください。

color pickerでThree.jsの色を変更する

最も分かりやすい方法はCSS color stringをparamsへ保持し、onChangeでThree.js Colorへ反映する構成です。

const params = {
  color: "#38bdf8",
};

gui.addColor(params, "color").onChange((value) => {
  material.color.set(value);
});

lil-guiはRGB object・arrayのchannel rangeをdefault 0〜1として扱います。dat.GUIは0〜255を前提にする点が移行時の違いです。0〜255のRGB dataなら第3引数でrangeを指定できます。

const rgb255 = { color: [56, 189, 248] };
gui.addColor(rgb255, "color", 255);

Three.js Color、CSS string、linear color spaceの違いはThree.jsでMaterialの色を設定する方法で扱っています。

dropdownで候補を選択する

arrayまたはobjectを第3引数へ渡すとdropdownになります。

const params = {
  quality: "medium",
  speed: 1,
};

gui.add(params, "quality", ["low", "medium", "high"]);

gui.add(params, "speed", {
  遅い: 0.25,
  通常: 1,
  速い: 2,
});

既存controllerの候補を後から更新する場合はcontroller.options(newOptions)を使います。option controllerでは候補を更新しますが、通常controllerを初めてoptions化すると古いcontroller referenceが破棄され、新controllerが作られる点に注意してください。

onChangeとonFinishChangeを使い分ける

onChange()はcontroller操作中に継続して発火します。見た目を即時更新する軽い処理に向きます。

gui.add(material, "roughness", 0, 1, 0.01)
  .onChange((value) => {
    console.log("changing", value);
  });

onFinishChange()は変更操作が終わりfocusを失ったあとに発火します。texture再生成、network request、大量Geometryの再構築など重い処理は、毎stepではなく終了時に実行します。

gui.add(params, "segments", 3, 128, 1)
  .onFinishChange((segments) => {
    rebuildGeometry(segments);
  });

folderまたはroot GUIへgui.onChange(event)を設定すると、子controllerの変更をまとめて受け取れます。eventにはobject・property・value・controllerが入ります。

外部で変わる値をlisten・updateDisplayで同期する

GUI以外のanimationやeventで値が変わっても、controller表示は自動更新されません。

listenで毎frame同期する

gui.add(mesh.rotation, "y", -Math.PI, Math.PI)
  .listen()
  .disable();

listen()はcontroller表示を毎frame更新します。monitor用途には便利ですが、多数のcontrollerへ無条件に付けるとdebug UI自体のcostが増えるため、必要な値だけにします。

updateDisplayで必要時だけ同期する

const rotationController = gui.add(mesh.rotation, "y");

mesh.rotation.y = Math.PI / 2;
rotationController.updateDisplay();

値がevent時だけ変わるなら、明示的なupdateDisplay()のほうが余分な毎frame処理を避けられます。

ShaderMaterialのuniformを操作する

Three.jsのuniformは一般に{ value: ... }の形を持つため、数値ならvalueへbindできます。

const material = new THREE.ShaderMaterial({
  uniforms: {
    uProgress: { value: 0 },
    uScale: { value: new THREE.Vector2(1, 1) },
  },
  vertexShader,
  fragmentShader,
});

const uniformFolder = gui.addFolder("Uniforms");

uniformFolder
  .add(material.uniforms.uProgress, "value", 0, 1, 0.01)
  .name("uProgress");

uniformFolder.add(material.uniforms.uScale.value, "x", 0, 4, 0.01);
uniformFolder.add(material.uniforms.uScale.value, "y", 0, 4, 0.01);

数値uniformやVector componentの値変更では通常Materialの再compileは不要です。textureの有無やshader defineなどprogram構成が変わる場合は別途needsUpdateを検討します。Materialの変更規則はThree.js Materialの種類と使い方で整理しています。

controllerをenable・disable・show・hideする

状態に応じて操作可否や表示を切り替えられます。

const speedController = gui.add(params, "speed", 0, 0.05, 0.001);

speedController.disable();
speedController.enable();
speedController.hide();
speedController.show();

disableは値をmonitor表示したいがuserには操作させたくない場合にも使えます。

controllersRecursiveで全controllerを取得する

gui.controllersはそのGUI直下だけ、gui.controllersRecursive()はnested folder内を含むすべてのcontrollerを配列で返します。

const allControllers = gui.controllersRecursive();

allControllers.forEach((controller) => {
  controller.disable();
});

一括disable、show / hide、updateDisplayなどへ使えます。folder一覧はgui.foldersRecursive()で取得できます。

gui.foldersRecursive().forEach((folder) => {
  folder.close();
});

private propertyを直接たどらず、現行のpublic APIを使います。

設定をsave・load・resetする

gui.save()は現在値をJSON互換objectとして返し、gui.load()で復元できます。folderもdefaultで再帰的に含みます。

const preset = gui.save();

// 値を変更したあとに復元
gui.load(preset);

// controller作成時の初期値へ戻す
gui.reset();

browserをまたいで保存する場合はapplication側でJSON化し、localStorageやserverへ保存します。

localStorage.setItem("scene-preset", JSON.stringify(gui.save()));

const saved = localStorage.getItem("scene-preset");
if (saved) {
  gui.load(JSON.parse(saved));
}

schema変更後の古いpreset、破損JSON、保存容量を考慮し、実用codeではversionとtry / catchを追加します。dat.GUIのremember()はlil-guiでは削除され、save/loadへ置き換えられています。

GUIの表示位置とstyleを変更する

defaultではGUIがdocument.bodyへ追加され、画面右上にfixed表示されます。特定elementへ入れる場合はcontainerを指定します。

const guiContainer = document.querySelector("#debug-panel");

const gui = new GUI({
  container: guiContainer,
  title: "Three.js controls",
  width: 320,
  closeFolders: true,
});

CSS custom propertyでも見た目を変更できます。

.lil-gui {
  --width: 320px;
  --name-width: 55%;
  --background-color: #111827;
  --widget-color: #334155;
  --text-color: #f8fafc;
}

modalを開くたびにnew GUIするとpanelとevent listenerが増えます。1回だけ作る、再利用する、close時にdestroyする、のどれかを決めます。

dat.GUIからlil-guiへ移行する

lil-guiはdat.GUIのmodernなdrop-in replacementとして設計されていますが、完全互換ではありません。

dat.GUI lil-gui
gui.__controllers gui.controllers
gui.__folders gui.folders。array
gui.remove(controller) controller.destroy()
gui.removeFolder(folder) folder.destroy()
remember()・preset save()load()
RGB object / arrayは0〜255 default 0〜1
folderは閉じた状態 folderはdefault open

dat.GUI内部DOM classを直接操作するCSS・JavaScriptは壊れます。lil-guiの.lil-gui、public API、CSS custom propertyへ移行します。dat.GUIのH keyによるglobal hideもlil-guiにはないため、必要ならapplication側のshortcutからgui.show()gui.hide()を呼びます。

stats.jsを初期化する

Statsを作成し、表示panelを選んでDOMへ追加します。

import Stats from "stats.js";

const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);

panel番号は次の意味です。

番号 panel 意味
0 FPS 直近1秒にrenderされたframe数
1 MS 1 frameに要したmillisecond
2 MB 確保memory。browser条件あり
3以降 custom user定義panel

MB panelはChromeをprecise memory情報が取れる条件で実行した場合に限られ、通常browserでは利用できないことがあります。memory leakの判断をこのpanelだけへ依存しないでください。

stats.begin・endをanimation loopへ入れる

測定したい処理の直前でbegin()、直後でend()を呼びます。

renderer.setAnimationLoop(() => {
  stats.begin();

  updateScene();
  renderer.render(scene, camera);

  stats.end();
});

scene updateからrenderまでを囲めばapplication loop全体に近い範囲を測れます。renderだけを囲めば測定範囲が変わるため、比較時はbegin / endの位置を揃えます。

stats.updateとの違い

stats.update()を1 frameに1回呼ぶ簡易方法もあります。begin / endは測定範囲を明示でき、updateは前回updateからの周期を更新します。1つのloop内で両方を重ねて呼ばず、目的に合う片方を使います。

FPSとMSの読み方

FPSは高いほど滑らか、MSは低いほど1 frameを短時間で処理できています。ただし数値は画面refresh rate、requestAnimationFrame、background tab、power saving、device性能の影響を受けます。

目安となるframe budgetは次のとおりです。

目標 1 frameのbudget
60 FPS 約16.7ms
90 FPS 約11.1ms
120 FPS 約8.3ms

stats.jsの数値だけでは、JavaScript、layout、texture upload、shader compile、GPU fill rateのどれが原因か分かりません。同じdevice・viewport・pixel ratio・camera・sceneでbefore / afterを比較します。

renderer.infoでdraw callとresource数を見る

Three.jsのrenderer.infoにはrenderとGPU resourceに関する統計があります。

renderer.render(scene, camera);

console.table({
  calls: renderer.info.render.calls,
  triangles: renderer.info.render.triangles,
  points: renderer.info.render.points,
  lines: renderer.info.render.lines,
  geometries: renderer.info.memory.geometries,
  textures: renderer.info.memory.textures,
  programs: renderer.info.programs.length,
});
  • render.calls: draw call数
  • render.triangles: 描画triangle数
  • memory.geometries: active Geometry数
  • memory.textures: active Texture数
  • programs.length: rendererが保持するprogram数

memory値はresource数であり、VRAM byte数ではありません。stats.jsのFPS低下とrenderer.infoの変化を組み合わせると、draw call・polygon・resource増加の仮説を立てやすくなります。

複数render passではautoResetを管理する

renderer.infoはdefaultでrender callごとにresetされます。post-processingなど複数passを1 frameとして合算する場合は、autoResetをfalseにしてframe末尾でresetします。

renderer.info.autoReset = false;

renderer.setAnimationLoop(() => {
  stats.begin();

  composer.render();
  readRendererInfo(renderer.info);
  renderer.info.reset();

  stats.end();
});

reset前に値を読み取ります。

lil-guiへrenderer.infoを表示する

stats.jsと同じ画面でdraw callなどを確認したい場合は、monitor用objectへ値をcopyしてlistenします。

const metrics = {
  drawCalls: 0,
  triangles: 0,
  geometries: 0,
  textures: 0,
};

const metricsFolder = gui.addFolder("Renderer info");
metricsFolder.add(metrics, "drawCalls").listen().disable();
metricsFolder.add(metrics, "triangles").listen().disable();
metricsFolder.add(metrics, "geometries").listen().disable();
metricsFolder.add(metrics, "textures").listen().disable();

renderer.setAnimationLoop(() => {
  stats.begin();

  renderer.render(scene, camera);

  metrics.drawCalls = renderer.info.render.calls;
  metrics.triangles = renderer.info.render.triangles;
  metrics.geometries = renderer.info.memory.geometries;
  metrics.textures = renderer.info.memory.textures;

  stats.end();
});

monitor controllerを大量に増やすとUI更新costが増えます。常時必要な指標だけ表示します。

performanceを切り分ける手順

「遅い」ことを確認するだけでなく、条件を1つずつ固定して原因候補を絞ります。

  1. 同じdevice・browser・viewportでbaselineを記録する
  2. stats.jsでFPSとMSの変動・spikeを見る
  3. renderer.infoでdraw call・triangle・texture・geometryを確認する
  4. pixel ratio・canvas sizeを下げてfill rateの影響を比較する
  5. shadow・post-processing・transparent Materialを1つずつ切る
  6. object数を減らし、InstancedMeshやresource共有を比較する
  7. browser Performance profilerでmain thread taskを記録する
  8. 必要ならWebGL frame captureでGPU側を確認する

旧記事のように250 Meshを作り、各Meshごとに複数Geometry・Materialを生成すると、lil-guiとstats.jsの使い方よりscene構築costが結果を支配します。toolの最小sceneで動作確認してから、実projectへ追加します。複数Meshの設計はThree.jsで複数Meshを配置する方法も参照してください。

production buildからdebug toolを外す

lil-guiとstats.jsを開発時だけ使うなら、environment flagでdynamic importします。

if (import.meta.env.DEV) {
  const [{ default: GUI }, { default: Stats }] = await Promise.all([
    import("lil-gui"),
    import("stats.js"),
  ]);

  initDebugTools({ GUI, Stats, scene, renderer });
}

Viteのproduction buildではfalse branchを除去しやすくなります。debug UIへ機密値・管理actionを載せず、environment flagが実際のbuildで期待どおり評価されることをbundle analyzerやnetwork panelで確認します。

lil-gui自体をuser向け設定panelとしてproduction提供する場合は、debug toolではなくproduct UIとしてaccessibility、mobile layout、validation、state保存、権限を設計します。

GUI・stats・Three.js resourceを破棄する

page transition、modal close、component unmount時は作成したDOM・event listener・GPU resourceを解放します。

renderer.setAnimationLoop(null);

gui.destroy();
stats.dom.remove();

geometry.dispose();
material.dispose();
renderer.dispose();

gui.destroy()はGUIに関連するDOMとevent listenerを破棄します。controllerやfolder単位ならそれぞれdestroy()を使います。stats.jsは追加したDOMを自分でremoveします。

共有Geometry・Material・TextureをまだほかのMeshが使っている場合はdisposeしません。resourceの所有関係を確認します。

lil-guiが表示・更新されない原因

GUIがcanvasの後ろへ隠れる

containerのposition、z-index、overflowを確認します。default auto placementではbody右上、custom containerでは親要素のlayoutとclippingが影響します。

sliderを動かしてもThree.jsが変わらない

GUIが変更しているobjectとrenderで参照しているobjectが同じか確認します。proxy paramsを使う場合はonChangeでMaterial・Light・uniformへ反映します。

外部animationの値がGUIへ反映されない

controllerへlisten()を付けるか、値変更後にupdateDisplay()を呼びます。

colorが明るすぎる・違って見える

RGB object / arrayのchannel rangeが0〜1か0〜255か確認します。Three.js側ではcolor space、Light、tone mapping、Materialも見え方へ影響します。

controllerが重複して増える

render loopやmodal openごとにnew GUIしていないか確認します。初期化は1回にし、不要になったinstanceはdestroyします。

saveでname collision errorになる

同じGUI・folder内のcontroller名やfolder名が重複しています。name()で一意にし、save objectの構造も確認します。

stats.jsのMB panelが表示されない

precise memory情報を提供するbrowser条件が満たされていません。Chrome DevToolsのMemory・Performance、resourceの作成数とdispose、renderer.infoを併用します。

FPSは低いがdraw callは少ない

高いpixel ratio、大きなcanvas、重いfragment shader、shadow、post-processing、texture upload、main thread処理など別原因を確認します。draw callだけでperformanceは決まりません。

stats.jsを入れたら数値が少し変わる

monitor自身にもDOM更新costがあります。絶対値だけで判断せず、同じ計測条件でbefore / afterを比較し、最終確認ではdebug toolを外したproduction buildも測定します。

lil-gui・stats.jsチェックリスト

  • lil-guiは値の調整、stats.jsは簡易計測と役割を分けた
  • 小さなsceneでinstall・import・表示を確認した
  • 数値controllerへmin・max・stepを設定した
  • folderと一意なnameで項目を整理した
  • color channel rangeとThree.js Color反映を確認した
  • 軽い即時更新はonChange、重い処理はonFinishChangeにした
  • listenは必要なmonitorだけに付けた
  • controllersRecursiveはpublic APIとして利用した
  • save/loadのname collision・version・例外を考慮した
  • stats.begin / endの測定範囲を固定した
  • FPS・MSだけでなくrenderer.infoを確認した
  • 同じdevice・viewport・pixel ratioで比較した
  • production buildからdebug codeを外すか明示的に管理した
  • GUI・stats DOM・Three.js resourceを適切に破棄した

まとめ

lil-guiはJavaScript objectのpropertyをruntimeで変更し、Three.jsのposition、Material、Light、uniformなどを直感的に調整するtoolです。stats.jsはFPS・MSを小さなpanelで継続表示し、performance変化を早く検知できます。

controllerが増えたらfolder、name、controllersRecursiveで整理し、外部変更はlistenまたはupdateDisplay、設定はsave/loadで管理します。dat.GUIから移行するときは内部property、destroy、folder初期状態、color rangeの違いを確認してください。

performance原因の特定ではstats.jsだけに依存せず、renderer.info、browser profiler、実機比較を組み合わせます。debug終了時はgui.destroy()、stats DOMのremove、Three.js resourceのdisposeまで行います。

スポンサーリンク