lil-gui w praktyce - budujemy panel sterowania sceną Three.js

W pierwszej części poznaliśmy podstawy biblioteki lil-gui. Dowiedzieliśmy się, jak tworzyć suwaki, pola wyboru, listy rozwijane, selektory kolorów oraz grupować kontrolki za pomocą folderów. To jednak dopiero początek możliwości tej biblioteki. W rzeczywistych aplikacjach lil-gui nie służy wyłącznie do zmiany pojedynczych liczb. Najczęściej pełni rolę kompletnego panelu sterowania, umożliwiającego modyfikację parametrów sceny 3D, materiałów, świateł, animacji czy kamer bez konieczności edycji kodu źródłowego. W tej części tutoriala zbudujemy niewielką aplikację demonstracyjną, która pozwoli sterować sceną Three.js w czasie rzeczywistym. Nasza aplikacja będzie umożliwiała:

  • zmianę geometrii obiektu,
  • zmianę koloru materiału,
  • przełączanie trybu Wireframe,
  • regulację parametrów materiału,
  • sterowanie animacją,
  • zmianę intensywności świateł,
  • pokazywanie i ukrywanie pomocników sceny,
  • przywracanie ustawień domyślnych jednym kliknięciem.
Choć projekt jest stosunkowo niewielki, pokazuje większość funkcji lil-gui, z których korzysta się podczas codziennej pracy z Three.js. Three.js + lil-gui - Interactive Material Editor Cała aplikacja znajduje się w jednym pliku HTML. W większych projektach kod warto podzielić na osobne moduły JavaScript, jednak na potrzeby tutoriala pozostaniemy przy pojedynczym pliku. Dzięki temu łatwiej będzie prześledzić zależności pomiędzy sceną Three.js i panelem GUI. Zaczynamy.

Import bibliotek

Na początku dokumentu wykorzystujemy Import Maps, które pozwalają nadawać krótkie nazwy importowanym modułom.
<script type="importmap">
{
    "imports":{
        "three":"https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
        "three/addons/":"https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
        "lil-gui":"https://cdn.jsdelivr.net/npm/[email protected]/+esm"
    }
}
</script>
Następnie importujemy potrzebne moduły.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import GUI from "lil-gui";
Dzięki Import Maps możemy korzystać z prostych nazw modułów (three, lil-gui), zamiast podawać pełne adresy URL przy każdym imporcie.

Tworzenie renderera

Każda aplikacja Three.js rozpoczyna się od utworzenia renderera.
const renderer = new THREE.WebGLRenderer({
    antialias:true
});

renderer.setPixelRatio(window.devicePixelRatio);

renderer.setSize(
    window.innerWidth,
    window.innerHeight
);

renderer.shadowMap.enabled = true;

document.body.appendChild(renderer.domElement);
Przyjrzyjmy się poszczególnym elementom. Renderer odpowiada za wyświetlenie całej sceny. To właśnie on tłumaczy obiekty Three.js na polecenia wykonywane przez WebGL. Antyaliasing wygładza postrzępione krawędzie modeli. Bez niego cienkie linie oraz ostre narożniki mogłyby wyglądać na poszarpane. Ustawiamy rozmiar renderera aby dopasowywał się do rozmiaru okna przeglądarki. Ustawiamy ponadto pixel ratio. Dzięki temu na monitorach Retina oraz ekranach o wysokiej gęstości pikseli obraz będzie znacznie ostrzejszy. Choć w naszej aplikacji nie generujemy jeszcze cieni, warto od początku pozostawić opcję shadowMap włączoną. Dzięki temu w kolejnych wersjach programu wystarczy jedynie odpowiednio skonfigurować światła i obiekty, aby uzyskać realistyczne cieniowanie.

Tworzenie sceny

Scena jest kontenerem przechowującym wszystkie obiekty 3D. Obecnie scena zawiera jedynie jednolite tło.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x30343f);

Kamera

Następnie tworzymy kamerę.
const camera =
new THREE.PerspectiveCamera(
    60,
    window.innerWidth / window.innerHeight,
    0.1,
    100
);
Parametry kamery oznaczają odpowiednio: kąt widzenia, proporcja okna, płaszczyzna Near (obiekty znajdujące się bliżej kamery niż ta wartość nie są renderowane na scenie), płaszczyzna Far (maksymalna odległość od kamery, do której renderowane są obiekty 3D). Następnie ustawiamy jej położenie.
camera.position.set(
    4,
    3,
    5
);
Dzięki temu kamera patrzy na środek sceny pod niewielkim kątem, co daje lepszy efekt przestrzenny niż widok ustawiony dokładnie na osi Z.

OrbitControls

Na koniec konfigurujemy sterowanie kamerą.
const controls =
new OrbitControls(
    camera,
    renderer.domElement
);

controls.enableDamping = true;
Biblioteka OrbitControls umożliwia użytkownikowi obracanie kamery wokół obiektu za pomocą myszy oraz przybliżanie i oddalanie widoku przy użyciu rolki. Opcja enableDamping powoduje wygładzenie ruchu kamery. Zamiast zatrzymywać się natychmiast po puszczeniu przycisku myszy, kamera delikatnie wyhamowuje, co sprawia, że interakcja jest bardziej naturalna. Pamiętajmy jednak, że po włączeniu tłumienia konieczne jest wywoływanie metody:
controls.update();
w każdej iteracji pętli renderującej. W przeciwnym razie efekt wygładzania nie będzie działał. Teraz przyszedł czas na dodanie elementów, które będą widoczne na ekranie. Zbudujemy prostą scenę zawierającą oświetlenie, pomocników oraz pierwszy model 3D.

Dodawanie pomocników sceny

Podczas pracy nad aplikacjami 3D bardzo łatwo stracić orientację w przestrzeni. Trudno ocenić, gdzie znajduje się środek sceny, w którą stronę skierowane są osie świata oraz jak daleko od siebie znajdują się poszczególne obiekty. Z tego powodu Three.js udostępnia zestaw klas pomocniczych (helpers), które znacznie ułatwiają pracę podczas tworzenia aplikacji. W naszym przykładzie wykorzystamy dwa z nich GridHelper i AxesHelper. GridHelper rysuje siatkę na płaszczyźnie XZ, która pełni rolę wirtualnej podłogi. Dodanie siatki wymaga zaledwie jednej instrukcji.
const grid = new THREE.GridHelper(
    12,
    12
);

scene.add(grid);
Pierwszy parametr określa rozmiar siatki. Drugi określa liczbę podziałów. Im większa wartość, tym więcej linii zostanie narysowanych. Drugim pomocnikiem będzie wskaźnik osi układu współrzędnych - AxesHelper.
const axes = new THREE.AxesHelper(2);

scene.add(axes);
Parametr określa długość każdej osi. Po uruchomieniu programu zobaczymy trzy kolorowe osie: X, Y, Z. To bardzo przydatny element podczas ustawiania kamer, świateł oraz animacji.

Dodawanie światła

Bez oświetlenia większość materiałów Three.js pozostaje całkowicie czarna. Nasza scena będzie wykorzystywała dwa źródła światła:
  • światło otoczenia (Ambient Light),
  • światło kierunkowe (Directional Light).
Takie połączenie jest bardzo często stosowane w prostych aplikacjach demonstracyjnych. Najpierw dodajemy światło otoczenia.
const ambientLight =
new THREE.AmbientLight(
    0xffffff,
    1
);

scene.add(ambientLight);
Pierwszy parametr określa kolor światła, a drugi jego intensywność. Światło otoczenia działa jednakowo na wszystkie powierzchnie sceny. Nie tworzy cieni. Nie posiada kierunku. Jego zadaniem jest jedynie delikatne rozjaśnienie całej sceny. Drugim źródłem światła będzie światło kierunkowe.
const directionalLight =
new THREE.DirectionalLight(
    0xffffff,
    2
);

directionalLight.position.set(
    4,
    6,
    3
);

scene.add(directionalLight);
W przeciwieństwie do światła otoczenia posiada ono określony kierunek padania promieni. Można je porównać do światła słonecznego. W naszym przykładzie ustawiliśmy źródło światła nieco powyżej sceny. Dzięki temu model będzie odpowiednio oświetlony z góry oraz z prawej strony. Już wcześniej aktywowaliśmy obsługę map cieni w rendererze. Teraz konfigurujemy światło.
directionalLight.castShadow = true;
W obecnej wersji programu nie zobaczymy jeszcze cieni. Dlaczego? Ponieważ sam renderer oraz światło to dopiero połowa konfiguracji. Aby obiekt rzeczywiście rzucał cień, należy jeszcze ustawić odpowiednie właściwości siatek (castShadow oraz receiveShadow) oraz dodać powierzchnię odbierającą cień. Ponieważ nasz przykład skupia się na bibliotece lil-gui, pozostawimy tę konfigurację na osobny artykuł poświęcony oświetleniu.

Tworzenie geometrii

Przyszedł czas na najważniejszy element sceny - obiekt 3D. Zamiast tworzyć geometrię bezpośrednio, przygotujemy niewielką funkcję.
function createGeometry(type) {
    switch(type){
        case "Sphere":
            return new THREE.SphereGeometry(
                1,
                48,
                32
            );
        case "Torus":
            return new THREE.TorusGeometry(
                0.8,
                0.3,
                24,
                120
            );
        case "Torus Knot":
            return new THREE.TorusKnotGeometry(
                0.7,
                0.25,
                160,
                24
            );
        default:
            return new THREE.BoxGeometry(
                1.8,
                1.8,
                1.8
            );
    }
}

Dlaczego funkcja zamiast jednej geometrii?

Na pierwszy rzut oka mogłoby się wydawać, że wystarczy utworzyć pojedynczy obiekt.
const geometry = new THREE.BoxGeometry();
Takie rozwiązanie byłoby jednak mało elastyczne. Już za chwilę użytkownik będzie mógł wybierać geometrię z listy rozwijanej w panelu lil-gui. Zamiast pisać osobny kod dla każdego przypadku, wystarczy wywołać na przykład:
mesh.geometry = createGeometry("Sphere");
Cała logika tworzenia geometrii znajduje się w jednym miejscu. Takie podejście znacząco upraszcza późniejszą rozbudowę programu.

Tworzenie materiału

Każda geometria wymaga materiału. W naszym przykładzie wykorzystamy MeshStandardMaterial.
const material =
new THREE.MeshStandardMaterial({
    color:"#44aaff",
    metalness:0.3,
    roughness:0.4,
    transparent:true,
    opacity:1
});
Jest to materiał oparty na fizycznym modelu oświetlenia (Physically Based Rendering). Dzięki temu bardzo dobrze współpracuje ze światłami dodanymi wcześniej. Na razie ustawiamy jedynie kilka najważniejszych parametrów.
  • color - kolor powierzchni
  • metalness - stopień metaliczności materiału
  • roughness - chropowatość powierzchni
  • transparent - umożliwia zmianę przezroczystości
  • opacity - poziom przezroczystości
Wszystkie te właściwości będą sterowane z poziomu panelu GUI.

Tworzenie siatki (Mesh)

Pozostał ostatni krok. Łączymy geometrię z materiałem.
const mesh = new THREE.Mesh(
    createGeometry("Cube"),
    material
);

mesh.castShadow = true;

scene.add(mesh);
Klasa THREE.Mesh reprezentuje renderowalny obiekt sceny. Pierwszy parametr określa geometrię. Drugi parametr określa materiał. Od tej chwili obiekt staje się częścią sceny i będzie renderowany w każdej klatce animacji.

Dodajemy lil-gui

Do tej pory przygotowaliśmy kompletną scenę Three.js. Program działa, jednak wszystkie parametry są na stałe zapisane w kodzie. To właśnie tutaj do gry wkracza lil-gui. Teraz zbudujemy panel sterowania oraz przygotujemy architekturę aplikacji w taki sposób, aby każda zmiana dokonana przez użytkownika natychmiast była widoczna na ekranie.

Dlaczego potrzebujemy obiektu settings?

Początkujący programiści często tworzą kontrolki w taki sposób:
gui.add(material, "metalness", 0, 1);
gui.add(material, "roughness", 0, 1);
gui.add(mesh.rotation, "y", 0, Math.PI * 2);
gui.add(ambientLight, "intensity", 0, 5);
Kod działa poprawnie, jednak ma pewną wadę. Po kilku tygodniach rozwoju projektu ustawienia zaczynają być rozproszone. Odnalezienie wszystkich parametrów staje się coraz trudniejsze. Znacznie lepszym rozwiązaniem jest utworzenie jednego obiektu przechowującego całą konfigurację aplikacji.

Tworzymy obiekt settings

W naszym przykładzie wszystkie parametry umieszczamy w jednym miejscu.
const settings = {
    geometry: "Cube",
    color: "#44aaff",
    wireframe: false,
    metalness: 0.3,
    roughness: 0.4,
    opacity: 1,
    rotate: true,
    rotationSpeed: 1,
    ambientIntensity: 1,
    directionalIntensity: 2,
    showGrid: true,
    showAxes: true,
    reset() {}
};
Na pierwszy rzut oka może wydawać się, że jest to jedynie zwykły obiekt JavaScript. W rzeczywistości stanie się on centralnym miejscem przechowywania stanu całej aplikacji. Od tej chwili wszystkie kontrolki GUI będą odczytywały i modyfikowały właśnie ten obiekt.

Tworzymy panel GUI

Kolejnym krokiem jest utworzenie panelu.
const gui =
new GUI({
    title: "Material Editor"
});
Po uruchomieniu programu w prawym górnym rogu pojawi się niewielkie okno. Domyślnie biblioteka umieszcza panel właśnie w tym miejscu. Parametr title ustawia tekst widoczny na górze panelu.

Dlaczego warto korzystać z folderów?

Wyobraźmy sobie panel zawierający trzydzieści kontrolek. Już po kilku minutach trudno byłoby odnaleźć odpowiednią opcję. Znacznie wygodniej pogrupować ustawienia według ich przeznaczenia.

Tworzenie pierwszego folderu

W naszym przykładzie zaczniemy od ustawień obiektu.
const objectFolder = gui.addFolder("Obiekt");
Na ekranie pojawi się rozwijana sekcja zatytułowana Obiekt. Każdy folder działa jak osobny kontener dla kontrolek. Możemy go rozwinąć lub zwinąć bez wpływu na pozostałe elementy panelu.

Lista rozwijana

Pierwszą kontrolką będzie wybór geometrii.
objectFolder
.add(
    settings,
    "geometry",
    [
        "Cube",
        "Sphere",
        "Torus",
        "Torus Knot"
    ]
)
.name("Geometria");
Przyjrzyjmy się tej instrukcji. Metoda add() otrzymuje trzy argumenty. Pierwszy to obiekt settings. Drugi to nazwa właściwości. Trzeci argument jest tablicą dostępnych wartości. Ponieważ przekazaliśmy tablicę, lil-gui automatycznie utworzy listę rozwijaną. Zauważmy jeszcze jedną metodę .name("Geometria"). Domyślnie panel wyświetliłby nazwę właściwości geometry. Dzięki tej metodzie możemy zastąpić ją dowolnym tekstem.

Reagowanie na zmianę wartości

Sama lista rozwijana jeszcze niczego nie zmienia. Potrzebujemy informacji o tym, że użytkownik wybrał nową opcję. Służy do tego metoda onChange().
.onChange(value => {
    mesh.geometry.dispose();
    mesh.geometry = createGeometry(value);
});
Jest to jedna z najważniejszych metod w całej bibliotece. Za każdym razem, gdy użytkownik wybierze nową geometrię, funkcja zostanie automatycznie wywołana. Argument value zawiera aktualnie wybraną wartość.

Dlaczego wywołujemy dispose()?

Zmiana geometrii nie powoduje automatycznego zwolnienia pamięci GPU. Jeżeli będziemy wielokrotnie wykonywać:
mesh.geometry = new THREE.SphereGeometry(...);
stare geometrie pozostaną w pamięci karty graficznej. Po pewnym czasie może to prowadzić do niepotrzebnego zużycia pamięci, a w dużych projektach nawet do spadku wydajności. Dlatego przed przypisaniem nowej geometrii zawsze zwalniamy poprzednią wywołując mesh.geometry.dispose(). Następnie wywołujemy przygotowaną wcześniej funkcję.
mesh.geometry = createGeometry(value);
W zależności od wybranej opcji funkcja zwróci odpowiedni obiekt:
  • BoxGeometry,
  • SphereGeometry,
  • TorusGeometry,
  • TorusKnotGeometry.
Cała logika tworzenia geometrii znajduje się w jednym miejscu, dzięki czemu kod jest prosty i łatwy do rozbudowy.

Dodajemy wybór koloru

Drugą kontrolką będzie selektor koloru.
objectFolder
.addColor(settings, "color")
.name("Kolor")
.onChange(value => {
    material.color.set(value);
});
W przeciwieństwie do zwykłej metody add() wykorzystujemy tutaj addColor(). Biblioteka automatycznie rozpoznaje, że dana właściwość reprezentuje kolor i wyświetla graficzny próbnik. Po kliknięciu kontrolki pojawi się standardowy selektor kolorów przeglądarki. Zmiana koloru jest bardzo prosta.
material.color.set(value);
Metoda set() potrafi przyjmować różne formaty zapisu koloru. W naszym przykładzie korzystamy z zapisu szesnastkowego.

Dodajemy przełącznik Wireframe

Ostatnią kontrolką w folderze Obiekt będzie pole wyboru.
objectFolder
.add(settings, "wireframe")
.name("Wireframe")
.onChange(value => {
    material.wireframe = value;
});
Ponieważ właściwość wireframe jest typu boolean, biblioteka automatycznie tworzy checkbox. Po zaznaczeniu opcji model zostanie wyświetlony jako siatka krawędzi. Jest to bardzo przydatny tryb podczas analizowania geometrii lub debugowania modeli.

Sterowanie właściwościami materiału

Jedną z największych zalet biblioteki lil-gui jest możliwość zmiany parametrów materiału w czasie rzeczywistym. Dzięki temu możemy natychmiast zobaczyć, jak dana właściwość wpływa na wygląd modelu. W naszym przykładzie utworzymy osobny folder o nazwie Materiał.
const materialFolder = gui.addFolder("Materiał");
Po rozwinięciu folderu użytkownik zobaczy trzy suwaki sterujące materiałem. Pierwszy suwak odpowiada za właściwość metalness.
materialFolder
.add(settings, "metalness", 0, 1, 0.01)
.name("Metalness")
.onChange(value => {
    material.metalness = value;
});
Przyjrzyjmy się parametrom przekazywanym do metody add(). Pierwszy parametr to minimalna wartość, drugi maksymalna wartość, a trzeci krok zmiany. Na tej podstawie lil-gui automatycznie tworzy suwak. Parametr metalness określa, jak bardzo powierzchnia zachowuje się jak metal. Wartość 1 oznacza powierzchnię całkowicie metaliczną. Drugim parametrem materiału PBR (Physically-Based Rendering) jest chropowatość.
materialFolder
.add(settings, "roughness", 0, 1, 0.01)
.name("Roughness")
.onChange(value => {
    material.roughness = value;
});
roughness kontroluje rozpraszanie odbitego światła. Dla niewielkich wartości powierzchnia wydaje się gładka i błyszcząca. Dla wartości bliskich jedności światło zostaje rozproszone, a materiał wygląda matowo. To właśnie współpraca parametrów metalness i roughness pozwala uzyskać bardzo realistyczne efekty w materiałach PBR. Ostatnim parametrem będzie przezroczystość.
materialFolder
.add(settings, "opacity", 0, 1, 0.01)
.name("Opacity")
.onChange(value => {
    material.opacity = value;
});
Warto zauważyć, że zmiana opacity działa tylko dlatego, że podczas tworzenia materiału ustawiliśmy wcześniej transparent: true. Bez tej właściwości materiał pozostałby całkowicie nieprzezroczysty niezależnie od wartości opacity.

Dlaczego wykorzystujemy opacityonChange()?

Każdy z trzech suwaków kończy się identycznym fragmentem kodu.
.onChange(value => {
    material.roughness = value;
});
Można zadać pytanie dlaczego nie zapisujemy od razu:
gui.add(material, "roughness", 0, 1);
Takie rozwiązanie rzeczywiście byłoby krótsze. W naszym przykładzie świadomie wykorzystujemy jednak obiekt settings. Dzięki temu:
  • wszystkie ustawienia aplikacji znajdują się w jednym miejscu,
  • łatwo je zapisać do pliku,
  • łatwo przywrócić wartości domyślne,
  • można później zaimplementować eksport i import konfiguracji.
To rozwiązanie znacznie lepiej skaluje się wraz z rozwojem projektu. Kolejną sekcją panelu będzie sterowanie animacją. Tworzymy nowy folder.
const animationFolder = gui.addFolder("Animacja");
Pierwszą kontrolką będzie pole wyboru.
animationFolder.add(settings, "rotate").name("Obrót");
Nie dodajemy tutaj onChange(), ponieważ wartość zostanie odczytana bezpośrednio podczas wykonywania pętli animacji.

Pętla renderująca

Przypomnijmy fragment kodu z funkcji animate().
if (settings.rotate) {
    mesh.rotation.y += delta * settings.rotationSpeed;
}
Za każdym razem, gdy wykonywana jest nowa klatka animacji, program sprawdza wartość settings.rotate. Jeżeli użytkownik odznaczy pole wyboru, obrót zostanie natychmiast zatrzymany. Druga kontrolka odpowiada za prędkość obrotu.
animationFolder
.add(
    settings,
    "rotationSpeed",
    0,
    5,
    0.1
)
.name("Prędkość");
Wartość jest wykorzystywana bezpośrednio podczas każdej iteracji pętli animacji.
mesh.rotation.y += delta * settings.rotationSpeed;
Każda zmiana suwaka natychmiast wpływa na szybkość obrotu modelu.

Dlaczego wykorzystujemy Clock?

Warto zwrócić uwagę na jeszcze jeden fragment programu.
const delta = clock.getDelta();
Zamiast zwiększać obrót o stałą wartość wykorzystujemy czas pomiędzy kolejnymi klatkami. Takie rozwiązanie ma bardzo ważną zaletę. Animacja będzie działała z taką samą prędkością niezależnie od liczby klatek na sekundę. Na komputerze pracującym z szybkością 144 FPS obiekt nie będzie obracał się szybciej niż na urządzeniu osiągającym 60 FPS. Jest to jedna z podstawowych zasad tworzenia płynnych animacji.

Folder Światła

Tworzymy kolejny folder.
const lightsFolder = gui.addFolder("Światła");
Pierwszy suwak kontroluje intensywność światła otoczenia (Ambient Light).
lightsFolder
.add(
    settings,
    "ambientIntensity",
    0,
    5,
    0.1
)
.name("Ambient")
.onChange(value => {
    ambientLight.intensity = value;
});
Zmiana wartości natychmiast wpływa na jasność całej sceny. Ponieważ AmbientLight oświetla wszystkie powierzchnie jednakowo, różnica jest widoczna od razu. Drugim suwakiem sterujemy światłem kierunkowym (Directional Light).
lightsFolder
.add(
    settings,
    "directionalIntensity",
    0,
    5,
    0.1
)
.name("Directional")
.onChange(value => {
    directionalLight.intensity = value;
});
To właśnie to światło odpowiada za większość kontrastu widocznego na modelu. Zmniejszenie jego intensywności powoduje bardziej płaski wygląd obiektu. Zwiększenie sprawia, że powierzchnia staje się bardziej wyrazista i lepiej eksponuje właściwości materiału. Można byłoby sterować jednocześnie wszystkimi źródłami światła. Znacznie bardziej praktyczne jest jednak niezależne regulowanie każdego z nich. Takie rozwiązanie pozwala łatwo eksperymentować z oświetleniem sceny i obserwować wpływ poszczególnych źródeł światła na wygląd materiałów. W bardziej rozbudowanych projektach podobne podejście stosuje się dla każdego rodzaju światła osobno (PointLight, SpotLight, HemisphereLight czy RectAreaLight).

Sterowanie elementami pomocniczymi

Podczas programowania aplikacji 3D bardzo często korzystamy z różnego rodzaju pomocników (helpers). W naszym przykładzie są to:
  • GridHelper,
  • AxesHelper.
Na etapie tworzenia programu są niezwykle przydatne, ponieważ ułatwiają orientację w przestrzeni. Po zakończeniu prac zwykle jednak chcemy je ukryć. Najwygodniej zrobić to za pomocą dwóch pól wyboru. Tworzymy folder "Pomocnicy".
const helpersFolder = gui.addFolder("Pomocnicy");

GridHelper

Pierwsza kontrolka steruje widocznością siatki.
helpersFolder
.add(settings,"showGrid")
.name("Grid")
.onChange(value => {
    grid.visible = value;
});
Kod jest bardzo prosty. Pole wyboru modyfikuje właściwość settings.showGrid. Następnie metoda onChange() synchronizuje ją z właściwością grid.visible. Każdy obiekt dziedziczący po THREE.Object3D posiada właściwość visible. Jeżeli ustawimy
grid.visible = false;
obiekt nadal pozostaje częścią sceny. Nie jest jednak renderowany. To bardzo ważna różnica. Ukrycie obiektu:
  • nie usuwa go ze sceny,
  • nie zwalnia pamięci,
  • nie niszczy geometrii.
Zmienia jedynie to, czy renderer ma go narysować.

AxesHelper

Druga kontrolka wygląda niemal identycznie.
helpersFolder
.add(settings,"showAxes")
.name("Axes")
.onChange(value => {
    axes.visible = value;
});
Tutaj również korzystamy z właściwości axes.visible.

Folder "Akcje"

Dotychczas wszystkie elementy GUI sterowały pojedynczymi właściwościami. Biblioteka lil-gui potrafi jednak uruchamiać również funkcje. To bardzo przydatna możliwość. Możemy w ten sposób tworzyć przyciski:
  • Reset,
  • Randomize,
  • Export,
  • Save,
  • Screenshot,
  • Download.
W naszym przykładzie przygotujemy funkcję przywracającą ustawienia domyślne. W obiekcie settings wcześniej umieściliśmy pustą metodę reset(). Teraz zastąpimy ją właściwą implementacją.

Przywracanie ustawień

Na początku funkcji ustawiamy wartości zapisane w obiekcie settings.
settings.geometry = "Cube";
settings.color = "#44aaff";
settings.wireframe = false;
settings.metalness = 0.3;
settings.roughness = 0.4;
settings.opacity = 1;
settings.rotate = true;
settings.rotationSpeed = 1;
settings.ambientIntensity = 1;
settings.directionalIntensity = 2;
settings.showGrid = true;
settings.showAxes = true;
W tym momencie zmieniliśmy jedynie dane zapisane w obiekcie. To jeszcze nie wystarczy. Musimy teraz zaktualizować wszystkie elementy Three.js. Najpierw geometria.
mesh.geometry.dispose();
mesh.geometry = createGeometry("Cube");
Następnie materiał.
material.color.set(settings.color);
material.wireframe = false;
material.metalness = settings.metalness;
material.roughness = settings.roughness;
material.opacity = settings.opacity;
Później światła.
ambientLight.intensity = settings.ambientIntensity;
directionalLight.intensity = settings.directionalIntensity;
Na końcu helpery.
grid.visible = true;
axes.visible = true;
Wszystkie elementy sceny wracają do stanu początkowego. Po wykonaniu funkcji zauważymy pewien problem. Model rzeczywiście wraca do ustawień początkowych. Jednak suwaki nadal pokazują stare wartości. To zachowanie jest całkowicie normalne. Kontrolki GUI nie obserwują automatycznie zmian wykonywanych programowo. Biblioteka udostępnia bardzo wygodną metodę.
gui.controllersRecursive()
Zwraca ona wszystkie kontrolki znajdujące się w panelu, również te umieszczone w folderach. Możemy przejść po nich pętlą.
gui.controllersRecursive().forEach(controller => {
    controller.updateDisplay();
});
Metoda controller.updateDisplay() odczytuje aktualną wartość z obiektu settings i odświeża wygląd kontrolki. Dzięki temu panel zawsze pokazuje rzeczywisty stan aplikacji. Pozostał ostatni krok - utworzenie folderu Akcje i przycisku.
const actionsFolder = gui.addFolder("Akcje");
Następnie dodajemy funkcję.
actionsFolder
.add(settings,"reset")
.name("Reset Settings");
To jedna z ciekawszych cech biblioteki. Metoda gui.add(...) rozpoznaje typ właściwości. Jeżeli jest to:
  • liczba → tworzy suwak,
  • boolean → tworzy checkbox,
  • tablica → tworzy listę rozwijaną,
  • kolor → tworzy selektor koloru,
  • funkcja → tworzy przycisk.
Nie musimy tworzyć osobnego komponentu dla każdego typu danych. Po kliknięciu przycisku biblioteka automatycznie wykona settings.reset(). Nie jest potrzebne żadne dodatkowe wywołanie. To bardzo wygodne rozwiązanie.

Dobre praktyki

Na zakończenie warto zapamiętać kilka zasad, które dobrze sprawdzają się podczas pracy z lil-gui.
Grupuj kontrolki tematycznie
To lepsze niż kilkanaście kontrolek umieszczonych bez żadnego porządku.
Nie modyfikuj sceny bezpośrednio z wielu miejsc
Znacznie wygodniej przechowywać wartości w jednym obiekcie settings. Dzięki temu łatwo:
  • zapisać konfigurację,
  • odtworzyć ją,
  • zresetować,
  • eksportować do pliku.
Pamiętaj o dispose()
Jeżeli tworzysz nowe geometrie lub materiały w czasie działania programu, zawsze zwalniaj poprzednie zasoby. Jest to dobra praktyka, która pozwala uniknąć niepotrzebnego zajmowania pamięci.
Aktualizuj kontrolki po zmianach programowych
Jeżeli wartości zmienia kod aplikacji, a nie użytkownik, pamiętaj o wywołaniu:
controller.updateDisplay();
lub - jak w naszym przykładzie - zaktualizowaniu wszystkich kontrolek jednocześnie:
gui.controllersRecursive().forEach(controller => {
    controller.updateDisplay();
});
Dzięki temu panel zawsze odzwierciedla rzeczywisty stan programu.