Why a debug UI at all
- Every creative project needs tweakability: colors, speeds, positions, quantities. Designers and clients cannot edit code, so expose parameters in a panel
- Add tweaks as you build, not at the end. Deferring them means you never add them and miss unexpected good-looking results
- lil-gui is the standard choice: maintained, popular, drop-in replacement for the abandoned dat.GUI with better features. Course code that says
new dat.GUI()works identically with lil-gui - A common production pattern: only show the panel when the URL contains
#debug, so the same build serves users and tweakers
Setup
// npm install lil-gui
import GUI from 'lil-gui'
const gui = new GUI({
width: 300,
title: 'Nice debug UI',
closeFolders: false
})
import * as dat from 'lil-gui'thennew dat.GUI()is the same thing, seen in older course codegui.close()collapses the panel by default,gui.hide()removes it entirely- Toggle visibility with a key, using the internal
_hiddenflag:
window.addEventListener('keydown', (event) => {
if (event.key == 'h')
gui.show(gui._hidden)
})
Tweak types
lil-gui infers the control from the property type: number gives a range or input, boolean gives a checkbox, string gives a text field, function gives a button. Colors need the dedicated addColor().
Range (numbers)
gui.add(mesh.position, 'y', -3, 3, 0.01)
// or chained, same result
gui.add(mesh.position, 'y').min(-3).max(3).step(0.01).name('elevation')
- Signature is
gui.add(object, 'propertyName'): the object first, the property name as a string second - Call
gui.add(...)after the object and property exist, otherwise lil-gui errors on a missing property name()sets the panel label independently of the property name
Checkbox (booleans)
gui.add(mesh, 'visible')
gui.add(material, 'wireframe')
Button (functions)
lil-gui can only bind object properties, so put the function on an object:
debugObject.spin = () => {
gsap.to(mesh.rotation, { duration: 1, y: mesh.rotation.y + Math.PI * 2 })
}
gui.add(debugObject, 'spin')
The debugObject pattern for non-properties
- lil-gui cannot tweak a standalone variable, only a property of an object. Wrap loose values in a holder object, commonly named
debugObject,parameters, orglobal
const gui = new GUI()
const debugObject = {}
debugObject.myVariable = 1337
gui.add(debugObject, 'myVariable')
- This same object is the home for colors, functions, and constructor-only parameters (see geometry rebuild below)
Colors: the wrong-value trap
- Use
addColor(), notadd(), becausematerial.coloris aTHREE.Colorinstance, not a primitive - Gotcha: Three.js applies color management internally, so the hex shown in the picker is NOT the value Three.js uses. Copying the picker value into code gives a different color on screen
Two fixes:
- Read the managed value with
getHexString()inonChange, then copy that from the console:
gui.addColor(material, 'color').onChange((value) => {
console.log(value.getHexString())
})
- Better: keep the color as a plain string on
debugObject, tweak that, and push it into the material withset(). Nobody needs the console open, and the initial color lives in exactly one place:
debugObject.color = '#a778d8'
const material = new THREE.MeshBasicMaterial({ color: debugObject.color })
gui.addColor(debugObject, 'color').onChange(() => {
material.color.set(debugObject.color)
})
Rebuilding geometry from a tweak
- Constructor parameters like
widthSegmentsare NOT live properties of the geometry. They are consumed once at construction, sogui.add(geometry, 'widthSegments')throws - Store the parameter on
debugObject, then rebuild the geometry when the tweak changes:
debugObject.subdivision = 2
gui
.add(debugObject, 'subdivision')
.min(1).max(20).step(1)
.onFinishChange(() => {
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(
1, 1, 1,
debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
)
})
Two gotchas encoded there:
- Use
onFinishChange, notonChange. Building geometry is CPU-heavy, andonChangefires continuously while dragging the slider.onFinishChangefires once when the drag stops - Call
dispose()on the old geometry before replacing it. Assigning a new geometry does not free the old one from GPU memory, so skipping dispose is a memory leak
Folders
const cubeTweaks = gui.addFolder('Awesome cube')
cubeTweaks.add(mesh.position, 'y').min(-3).max(3).step(0.01)
cubeTweaks.add(material, 'wireframe')
cubeTweaks.close() // collapsed by default
- Create the folder first, then call
add/addColoron the folder instead of ongui - Folders nest inside folders, so a crowded panel has no excuse
closeFolders: truein the GUI constructor collapses all folders at once
Deployment: the build step
- Never upload the raw project (with
node_modules/and the Vite config) to a host. Browsers need built HTML, CSS, JS, and assets npm run buildruns thebuildscript frompackage.jsonand outputs to/dist/. Upload that folder's contents- Re-run
npm run buildbefore every upload; the old/dist/is not refreshed automatically
Deployment: Vercel
- Vercel (and alternatives Netlify, GitHub Pages) gives continuous integration: connect a Git repo and every push deploys automatically, optionally per branch
- Install as a project dependency (
npm install vercel) rather than globally, so collaborators need no machine setup - A dependency binary is not available directly in the terminal, but npm scripts can call it. Add a deploy script:
{
"scripts": {
"deploy": "vercel --prod"
}
}
vercelwithout--prodpublishes to a preview URL for testing;--prodgoes to production- First
npm run deploywalks through login and project setup. Vercel auto-detects the build settings fromvite.config.js, so answer no to overriding settings - Every later deploy is just
npm run deployagain - Pricing: the free Hobby plan covers non-commercial projects with unlimited projects, but has bandwidth and build-time limits. Commercial use, teams, or password-protected previews need a paid plan