Skip to content

Code Structuring for Bigger Three.js Projects

A Three.js guide for coding agents. Also covers structuring bigger three.js projects, three.js architecture, experience class, experience singleton, singleton pattern three.js, how do I organize three.js code, and 20 more.

Show all 26 aliases

structuring bigger three.js projects, three.js architecture, experience class, experience singleton, singleton pattern three.js, how do I organize three.js code, split three.js into classes, three.js modules, event emitter three.js, sizes class, time class, resources class, asset loading manager, centralized loaders, resources ready event, world class, environment class, debug ui lil-gui class, #debug hash, destroy three.js scene, dispose geometries materials textures, memory leak three.js, resize propagation, update loop propagation, requestAnimationFrame class, crossFadeFrom animations

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 propagates resize(), update(), and destroy() 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 in node_modules
  • Named exports (export { a, b } / import { a } from './x.js') exist and are how Three.js itself allows tree-shaken imports like import { 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.js needs import Robot from './Robot.js' before extends 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 = this for console debugging. Handy (window.experience.world.fox.animation.play('walking')) but a trick: it pollutes window and 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 instance is 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 EventEmitter base class with on(name, callback), off(name), and trigger(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)): this inside 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 what destroy() 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.pixelRatio instead of repeating Math.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 window resize 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() })
    }
}
  • delta defaults to 16 (roughly one 60fps frame in ms) and the first tick is deferred with requestAnimationFrame instead 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 tick and calls update() 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 instance property: camera.instance is the PerspectiveCamera, renderer.instance is the WebGLRenderer. Remember to render with this.camera.instance, not the wrapper
  • Camera: setInstance() builds the PerspectiveCamera from sizes.width / sizes.height and adds it to the scene, setControls() builds OrbitControls on the canvas with enableDamping = true. resize() updates aspect + updateProjectionMatrix(), update() calls controls.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() does this.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 Resources class owns all loaders, loads a declared list, and triggers ready when everything is in
  • Describe sources as data in a separate sources.js file, each entry { name, type, path }. name is the retrieval key, type picks the loader, path is 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.foxModel by name. A glTF item exposes .scene (the model) and .animations
  • You are free to show partial content before ready fires (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 Environment LAST. Its setEnvironmentMap() traverses the scene and patches envMap, envMapIntensity, and needsUpdate on every MeshStandardMaterial, 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(). Set colorSpace = THREE.SRGBColorSpace on 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 the ready callback, so the update loop runs before they exist

Animation with the mixer

  • Store the AnimationMixer and every clipAction in an animation object, with actions.current tracking what plays. Crossfade by calling crossFadeFrom(oldAction, duration) on the NEW action after reset() and play()
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') } then this.debugFolder.add(debugObject, 'playWalking')
  • Wire .onChange(...) to re-run derived work, for example calling updateMaterials when 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:

  1. Stop the loops: this.sizes.off('resize') and this.time.off('tick'). No tick, no updates, no renders
  2. 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()
        }
    }
})
  1. Dispose the extras: this.camera.controls.dispose(), this.renderer.instance.dispose(), and if(this.debug.active) this.debug.ui.destroy(). Cameras themselves need no disposal
  2. 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 window listeners after off(). 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 like resize() and update()

Common mistakes

  • Passing a method reference to an emitter (on('resize', this.resize)) and losing this. 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 of this.camera.instance
  • Instantiating Environment before 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "code-structuring" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems