Core Philosophy
- A single spaghetti file stops scaling fast: hard to find things, hard to reuse, variable collisions, constant merge conflicts. Split into ES modules where each file exports exactly one class
- Everything starts from one main class (commonly named
Experience) that creates everything else and propagatesresize(),update(), anddestroy()down to its children. Parent calls children, never the reverse - The structure is a personal template, not a law. Build your own once, reuse it across projects. Most of the Utils classes (Sizes, Time, EventEmitter, Resources, Debug) transfer between projects unchanged
Modules
- One export per file:
export default class Foo { ... }. Local imports must start with./or the bundler looks innode_modules - Named exports (
export { a, b }/import { a } from './x.js') exist and are how Three.js itself allows tree-shaken imports likeimport { SphereGeometry } from 'three', but for your own code one default export per file keeps things simple - A subclass file must import its base class itself:
FlyingRobot.jsneedsimport Robot from './Robot.js'beforeextends Robot
The Experience class
- Wrap the whole WebGL experience in one main class so it stays separate from the rest of the site but reachable through its properties and methods
- Take the canvas as a constructor parameter instead of querying inside, so consumers choose their own
<canvas>
// script.js
const experience = new Experience(document.querySelector('canvas.webgl'))
- Optionally expose it globally with
window.experience = thisfor console debugging. Handy (window.experience.world.fox.animation.play('walking')) but a trick: it polluteswindowand a second experience overwrites the first
Singleton pattern
Three ways for children to reach the Experience: a global variable (fragile, not recommended), passing this as a parameter to every child (fine but verbose through deep trees), or a singleton. The singleton: first new Experience() builds the real instance, every later new Experience() returns it.
let instance = null
export default class Experience
{
constructor(canvas)
{
if(instance) return instance
instance = this
this.canvas = canvas
this.debug = new Debug()
this.sizes = new Sizes()
this.time = new Time()
this.scene = new THREE.Scene()
this.resources = new Resources(sources)
this.camera = new Camera()
this.renderer = new Renderer()
this.world = new World()
this.sizes.on('resize', () => { this.resize() })
this.time.on('tick', () => { this.update() })
}
}
- Any child then does
this.experience = new Experience()and pulls what it needs:this.scene = this.experience.scene,this.sizes,this.canvas,this.resources,this.time,this.debug - Returning early from a constructor with
return instanceis what makes it work: the rest of the constructor never runs on subsequent instantiations - Instantiation order in the constructor matters because children read
this.experience.<prop>at construction time. Debug and Sizes first, scene and resources before camera, camera before renderer, world last
EventEmitter
- A tiny custom
EventEmitterbase class withon(name, callback),off(name), andtrigger(name, args)decouples utilities from the classes that react to them. Any class that needs to announce something (resize happened, resources loaded, animation finished, object clicked) extends it - Subclasses must call
super()first in their constructor - Do not pass a method reference directly as the callback (
this.sizes.on('resize', this.resize)):thisinside will be the emitter, not your class. Wrap in an arrow function:this.sizes.on('resize', () => { this.resize() }) off('resize')removes every listener for that event, which is whatdestroy()uses
Sizes utility
Owns viewport width, height, and pixel ratio, updates them on resize, and announces the change.
import EventEmitter from './EventEmitter.js'
export default class Sizes extends EventEmitter
{
constructor()
{
super()
this.width = window.innerWidth
this.height = window.innerHeight
this.pixelRatio = Math.min(window.devicePixelRatio, 2)
window.addEventListener('resize', () =>
{
this.width = window.innerWidth
this.height = window.innerHeight
this.pixelRatio = Math.min(window.devicePixelRatio, 2)
this.trigger('resize')
})
}
}
- Clamp pixel ratio to 2 once here, then everyone reads
sizes.pixelRatioinstead of repeatingMath.min(window.devicePixelRatio, 2) - Assumes the experience fills the viewport. If it lives in a smaller container, measure that container instead
- The Experience listens to the emitter event, not to
windowresize directly, then calls children top-down (this.camera.resize(),this.renderer.resize()). Parent-driven propagation gives you control over the order of resizes, which matters once many classes react
Time utility
A Clock-like class that runs the requestAnimationFrame loop and triggers a tick event every frame.
export default class Time extends EventEmitter
{
constructor()
{
super()
this.start = Date.now()
this.current = this.start
this.elapsed = 0
this.delta = 16
window.requestAnimationFrame(() => { this.tick() })
}
tick()
{
const currentTime = Date.now()
this.delta = currentTime - this.current
this.current = currentTime
this.elapsed = this.current - this.start
this.trigger('tick')
window.requestAnimationFrame(() => { this.tick() })
}
}
deltadefaults to 16 (roughly one 60fps frame in ms) and the first tick is deferred withrequestAnimationFrameinstead of called directly in the constructor, so the first frame never gets a delta of 0- Values are milliseconds. Consumers that need seconds convert:
mixer.update(this.time.delta * 0.001) - The Experience listens to
tickand callsupdate()on children in a deliberate order: camera (OrbitControls damping), then world, then renderer last so the render sees everything already updated
Camera and Renderer classes
- Each wraps its Three.js object in an
instanceproperty:camera.instanceis thePerspectiveCamera,renderer.instanceis theWebGLRenderer. Remember to render withthis.camera.instance, not the wrapper - Camera:
setInstance()builds the PerspectiveCamera fromsizes.width / sizes.heightand adds it to the scene,setControls()builds OrbitControls on the canvas withenableDamping = true.resize()updatesaspect+updateProjectionMatrix(),update()callscontrols.update() - Renderer:
setInstance()builds the WebGLRenderer with the canvas, then sets tone mapping, shadow map,setSize(sizes.width, sizes.height),setPixelRatio(sizes.pixelRatio).resize()repeats size + pixel ratio,update()doesthis.instance.render(this.scene, this.camera.instance)
Resources: centralized asset loading
- Never let each class run its own loaders. You end up not knowing what is ready and assets pop in as they arrive. One
Resourcesclass owns all loaders, loads a declared list, and triggersreadywhen everything is in - Describe sources as data in a separate
sources.jsfile, each entry{ name, type, path }.nameis the retrieval key,typepicks the loader,pathis a string or an array (cube textures)
export default [
{ name: 'foxModel', type: 'gltfModel', path: 'models/Fox/glTF/Fox.gltf' },
{ name: 'grassColorTexture', type: 'texture', path: 'textures/dirt/color.jpg' },
{
name: 'environmentMapTexture',
type: 'cubeTexture',
path: ['px.jpg', 'nx.jpg', 'py.jpg', 'ny.jpg', 'pz.jpg', 'nz.jpg']
}
]
export default class Resources extends EventEmitter
{
constructor(sources)
{
super()
this.sources = sources
this.items = {}
this.toLoad = this.sources.length
this.loaded = 0
this.setLoaders() // gltfLoader, textureLoader, cubeTextureLoader, DRACOLoader if needed
this.startLoading() // loop sources, pick loader by type, call sourceLoaded per file
}
sourceLoaded(source, file)
{
this.items[source.name] = file
this.loaded++
if(this.loaded === this.toLoad)
this.trigger('ready')
}
}
- Consumers read
this.resources.items.foxModelby name. A glTF item exposes.scene(the model) and.animations - You are free to show partial content before
readyfires (a 3D loader screen, for example) and add the rest once loaded
World and Environment
- Keep everything visible in a
World/folder:World,Environment,Floor,Fox, one class per thing. World waits for resources before building anything that needs them
this.resources.on('ready', () =>
{
this.floor = new Floor()
this.fox = new Fox()
this.environment = new Environment()
})
- Instantiate
EnvironmentLAST. ItssetEnvironmentMap()traverses the scene and patchesenvMap,envMapIntensity, andneedsUpdateon everyMeshStandardMaterial, so meshes added after it miss the environment map - Keep the traversal in a reusable closure (
this.environmentMap.updateMaterials) so debug tweaks can re-run it on change - Each world object follows the same method breakdown:
setGeometry(),setTextures(),setMaterial(),setMesh(). SetcolorSpace = THREE.SRGBColorSpaceon color textures, wrap and repeat as needed - Guard updates against not-yet-loaded objects:
update() { if(this.fox) this.fox.update() }. World objects are created asynchronously inside thereadycallback, so the update loop runs before they exist
Animation with the mixer
- Store the
AnimationMixerand everyclipActionin ananimationobject, withactions.currenttracking what plays. Crossfade by callingcrossFadeFrom(oldAction, duration)on the NEW action afterreset()andplay()
this.animation.play = (name) =>
{
const newAction = this.animation.actions[name]
const oldAction = this.animation.actions.current
newAction.reset()
newAction.play()
newAction.crossFadeFrom(oldAction, 1)
this.animation.actions.current = newAction
}
- Update the mixer every frame in seconds:
this.animation.mixer.update(this.time.delta * 0.001)
Debug integration
- Gate the debug UI behind the URL hash so users never see it but you always can, without a rebuild
import GUI from 'lil-gui'
export default class Debug
{
constructor()
{
this.active = window.location.hash === '#debug'
if(this.active)
this.ui = new GUI()
}
}
- Browsers do not reload when only the hash changes: after adding
#debug, refresh manually - Every class guards its own tweaks with
if(this.debug.active)and creates its own folder:this.debugFolder = this.debug.ui.addFolder('fox') - lil-gui needs a property to bind, so functions with arguments go through a small
debugObject:{ playWalking: () => this.animation.play('walking') }thenthis.debugFolder.add(debugObject, 'playWalking') - Wire
.onChange(...)to re-run derived work, for example callingupdateMaterialswhen environment map intensity changes
Destroy and dispose
Leaving a dead experience running costs performance: per-frame callbacks, GPU textures, listeners. A destroy() on Experience should:
- Stop the loops:
this.sizes.off('resize')andthis.time.off('tick'). No tick, no updates, no renders - Traverse the scene and dispose GPU resources. Generic pattern that catches every material map without listing them:
this.scene.traverse((child) =>
{
if(child instanceof THREE.Mesh)
{
child.geometry.dispose()
for(const key in child.material)
{
const value = child.material[key]
if(value && typeof value.dispose === 'function')
value.dispose()
}
}
})
- Dispose the extras:
this.camera.controls.dispose(),this.renderer.instance.dispose(), andif(this.debug.active) this.debug.ui.destroy(). Cameras themselves need no disposal - If using post-processing, also dispose the EffectComposer, its WebGLRenderTarget, and every pass
Gotchas:
- The reference is the Three.js "How to dispose of objects" manual page; some classes have their own quirks beyond this minimal sweep
- Sizes and Time still hold their native
windowlisteners afteroff(). Remove those too if you are thorough - The canvas keeps showing the last rendered frame; remove it from the DOM if needed
- For bigger projects, give each class its own
destroy()and propagate top-down, exactly likeresize()andupdate()
Common mistakes
- Passing a method reference to an emitter (
on('resize', this.resize)) and losingthis. Wrap in an arrow function - Forgetting
super()at the top of a subclass constructor, or forgetting the base class import in the subclass file - Rendering with
this.camera(the wrapper) instead ofthis.camera.instance - Instantiating
Environmentbefore other world objects, so they never receive the environment map - Updating world objects without existence checks while resources are still loading
- Each class loading its own assets instead of going through Resources, so nothing knows when the scene is ready
- Calling the first
tick()synchronously in the Time constructor, producing a zero delta on frame one - Destroying by only stopping the loop and leaving geometries, materials, textures, controls, and the renderer undisposed