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つずつ固定して原因候補を絞ります。
- 同じdevice・browser・viewportでbaselineを記録する
- stats.jsでFPSとMSの変動・spikeを見る
- renderer.infoでdraw call・triangle・texture・geometryを確認する
- pixel ratio・canvas sizeを下げてfill rateの影響を比較する
- shadow・post-processing・transparent Materialを1つずつ切る
- object数を減らし、InstancedMeshやresource共有を比較する
- browser Performance profilerでmain thread taskを記録する
- 必要なら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まで行います。