It's easy to start a Three.js project, but keeping it maintainable as features grow is a different story. Many developers find themselves with massive files where input, physics, and rendering are all mixed together. When your logic is this tangled, even small changes can break parts of the app that seem unrelated.
We often use structured patterns like MVC or Clean Architecture in web and mobile apps, but for some reason, we forget them once we enter a 3D scene. Instead of clean separation, we default to global variables or deeply nested classes. In my previous article on ECS, we explored how a data-driven approach can help. However, if you are more comfortable with traditional patterns, MVC is an excellent way to organize a Three.js codebase.
In this article, we'll build a Three.js application using the Model-View-Controller pattern. We'll separate our application state from the 3D visuals. This will keep the logic predictable, maintainable, and easier to test.
π The Final Result: GitHub β’ Demo
Three.js Architecture Series
The MVC Pattern
MVC splits the application into three roles. This separation keeps the app state independent from the 3D visuals.
β’ Controllers: They process the logic. A Controller listens for user input and updates the Model. It doesn't touch the View directly. It just changes the data, which then triggers a View update.
β’ Models: They hold the data. A Model stores properties like position or color, but knows nothing about Three.js meshes. It maintains the state and notifies other parts when it changes.
β’ Views: They handle the visual side. A View creates Three.js objects (meshes, lights, or cameras) and adds them to the scene. It watches the Model and updates the 3D objects to match the current data.
This keeps the scene graph relatively clean. You don't have to hunt for logic buried inside 3D objects. You can check if your logic works by looking at Model values, even without a rendering loop. So it simplifies testing as well as maintenance.
Bootstrapping our app
We start with a standard Three.js boilerplate, but instead of putting everything into one function, we split it across models, views, and a controller from the start. This gives us a rendering loop, a camera, some lights, and an environment texture. I won't go too deep into Three.js itself because we are here to learn about MVC.
Controllers
First, we need a rendering loop. The RenderController takes a callback and calls it every frame using requestAnimationFrame. It just makes sure we keep rendering.
// src/controllers/RenderController.ts
export class RenderController {
private onRender: () => void;
constructor(onRender: () => void) {
this.onRender = onRender;
}
private update = () => {
this.onRender();
requestAnimationFrame(this.update);
}
public start() {
this.update();
}
}
Models
All of our models extend a common BaseModel. It uses Three.js's EventDispatcher, which gives us a way to notify other parts of the app when data changes.
// src/models/BaseModel.ts
import { EventDispatcher } from 'three';
export class BaseModel<TEventMap extends {} = {}> extends EventDispatcher<TEventMap> {
constructor() { super(); }
}
The SceneModel keeps track of objects in the scene. It stores them in a private _objects map and exposes add, get, and getAll methods. We use a private map instead of a public array to prevent outside code from mutating the collection directly. This ensures that all modifications happen through controlled methods. Later, when we add events, the add method is where we'll notify the rest of the app that a new object appeared in the scene.
// src/models/SceneModel.ts
import type { SceneObjectModel } from './SceneObjectModel';
import { BaseModel } from './BaseModel';
export class SceneModel extends BaseModel {
private _objects: Map<string, SceneObjectModel> = new Map();
constructor() { super(); }
add(object: SceneObjectModel, parent?: SceneObjectModel) {
this._objects.set(object.id, object);
}
get(id: string): SceneObjectModel | undefined {
return this._objects.get(id);
}
getAll(): SceneObjectModel[] {
return Array.from(this._objects.values());
}
}
Now we need models for the camera, lights, and environment. Same idea as _objects above: private fields, public getters. No outside code can silently overwrite a camera's FOV or a light's color. When we need to change those values later, we'll add setter methods that validate input and dispatch change events. For now, the getters are read-only, which is all our boilerplate needs.
// src/models/CameraModel.ts
import { Vector3 } from 'three';
import { BaseModel } from './BaseModel';
export class CameraModel extends BaseModel {
private _position: Vector3 = new Vector3(0, 0, 5);
private _fov: number = 70;
private _near: number = 0.1;
private _far: number = 10;
constructor() { super(); }
public get position() { return this._position; }
public get fov() { return this._fov; }
public get near() { return this._near; }
public get far() { return this._far; }
}
// src/models/LightsModel.ts
import { Vector3 } from 'three';
import { BaseModel } from './BaseModel';
export class LightsModel extends BaseModel {
private _ambientLightColor = '#ffffff';
private _ambientLightIntensity = 2;
private _directionalLightColor = '#ffffff';
private _directionalLightIntensity = 2;
private _directionalLightPosition = new Vector3(2, 2, 2);
constructor() { super(); }
public get ambientLightColor() {
return this._ambientLightColor;
}
public get ambientLightIntensity() {
return this._ambientLightIntensity;
}
public get directionalLightColor() {
return this._directionalLightColor;
}
public get directionalLightIntensity() {
return this._directionalLightIntensity;
}
public get directionalLightPosition() {
return this._directionalLightPosition;
}
}
// src/models/EnvironmentModel.ts
import { BaseModel } from './BaseModel';
export class EnvironmentModel extends BaseModel {
private _backgroundColor = '#0d1117';
private _textureURL = new URL(
'../assets/env_1k.hdr',
import.meta.url,
);
constructor() { super(); }
public get backgroundColor() { return this._backgroundColor; }
public get textureURL() { return this._textureURL.toString(); }
}
Storing these values in models rather than hardcoding them in the Three.js setup allows us to update the application state at runtime. The views can then react to these changes automatically. The models act as the source of truth, and the views read from them.
Views
Now we can create our first view to represent the data we store in the models. Wrapping the PerspectiveCamera in a CameraView keeps the Three.js rendering logic separate from our application data. PerspectiveCamera is a Three.js object that belongs to the rendering side. The CameraModel holds the data (FOV, position, clipping planes), while CameraView turns that data into an actual camera. This setup makes it easier to change how the camera is built or to react to model updates without searching through the whole project.
// src/views/CameraView.ts
import { PerspectiveCamera } from 'three';
import type { CameraModel } from '../models/CameraModel';
export class CameraView {
private _camera: PerspectiveCamera;
private _model: CameraModel
constructor(model: CameraModel) {
this._model = model;
this._camera = new PerspectiveCamera(
this._model.fov,
window.innerWidth / window.innerHeight,
this._model.near,
this._model.far
);
this._camera.position.copy(this._model.position);
}
public getCamera(): PerspectiveCamera {
return this._camera;
}
}
SceneView is where the actual 3D scene lives. It owns the WebGLRenderer and the Three.js Scene. The constructor reads from LightsModel and EnvironmentModel to set up lights and the HDR environment texture, and it keeps a reference to CameraView for rendering. It also appends the renderer's canvas to the DOM. The update method gets called every frame and tells the renderer to draw the scene from the camera's point of view.
// src/views/SceneView.ts
import {
Scene,
WebGLRenderer,
Color,
AmbientLight,
DirectionalLight,
EquirectangularReflectionMapping,
} from 'three';
import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader';
import type { CameraView } from './CameraView';
import type { SceneModel } from '../models/SceneModel';
import type { LightsModel } from '../models/LightsModel';
import type { EnvironmentModel } from '../models/EnvironmentModel';
export class SceneView {
scene: Scene;
private renderer: WebGLRenderer;
private sceneModel: SceneModel;
private cameraView: CameraView;
constructor(
sceneModel: SceneModel,
lightsModel: LightsModel,
environmentModel: EnvironmentModel,
cameraView: CameraView
) {
this.sceneModel = sceneModel;
this.scene = new Scene();
this.cameraView = cameraView;
this.renderer = this.setupRenderer();
this.scene = this.setupScene(environmentModel);
this.setupLights(lightsModel);
this.setupEnvironment(environmentModel);
document
.querySelector('#app')!
.appendChild(this.renderer.domElement);
}
private setupRenderer(): WebGLRenderer {
const renderer = new WebGLRenderer({
antialias: true,
powerPreference: 'high-performance'
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
return renderer;
}
private setupScene(environmentModel: EnvironmentModel): Scene {
const scene = new Scene();
scene.background = new Color(environmentModel.backgroundColor);
return scene;
}
private setupLights(lightsModel: LightsModel) {
const ambient = new AmbientLight(
lightsModel.ambientLightColor,
lightsModel.ambientLightIntensity,
);
this.scene.add(ambient);
const directional = new DirectionalLight(
lightsModel.directionalLightColor,
lightsModel.directionalLightIntensity,
);
directional.position.copy(lightsModel.directionalLightPosition);
this.scene.add(directional);
}
private setupEnvironment(environmentModel: EnvironmentModel) {
new HDRLoader().load(environmentModel.textureURL, (texture) => {
texture.mapping = EquirectangularReflectionMapping;
this.scene.environment = texture;
});
}
public update() {
this.renderer.render(this.scene, this.cameraView.getCamera());
}
public get domElement() { return this.renderer.domElement; }
}
Now the Application class in our entry point wires everything together. The constructor first creates all the models: SceneModel, CameraModel, LightsModel, EnvironmentModel. At this point, there are no Three.js meshes yet, just data. Then it creates the views. CameraView gets the CameraModel and builds a PerspectiveCamera from it. SceneView gets the rest and sets up the renderer, scene, lights, and environment. Last, it creates the RenderController and passes it the update method as a per-frame callback. update calls this.sceneView.update(), which does a single render pass. start kicks off the loop through renderController.start().
// src/index.ts
import { SceneModel } from './models/SceneModel';
import { CameraModel } from './models/CameraModel';
import { LightsModel } from './models/LightsModel';
import { EnvironmentModel } from './models/EnvironmentModel';
import { SceneView } from './views/SceneView';
import { CameraView } from './views/CameraView';
import { RenderController } from './controllers/RenderController';
export class Application {
private sceneModel: SceneModel;
private cameraModel: CameraModel;
private lightsModel: LightsModel;
private environmentModel: EnvironmentModel;
private sceneView: SceneView;
private cameraView: CameraView;
private renderController: RenderController;
constructor() {
// Create models
this.sceneModel = new SceneModel();
this.cameraModel = new CameraModel();
this.lightsModel = new LightsModel();
this.environmentModel = new EnvironmentModel();
// Create views
this.cameraView = new CameraView(this.cameraModel);
this.sceneView = new SceneView(
this.sceneModel,
this.lightsModel,
this.environmentModel,
this.cameraView,
);
// Create controllers
this.renderController = new RenderController(this.update);
}
private update = () => {
this.sceneView.update();
}
public start() {
this.renderController.start();
}
}
const app = new Application();
app.start();
Adding 3D objects
In a typical Three.js project, you'd create a mesh, set its properties, and add it to the scene all in one place. With MVC, we split that process: models define what the object is, and views turn that definition into something Three.js can render. More boilerplate? Yes. But once the project has dozens of objects, you'll be glad the data and the rendering code don't live in the same place.
Events
Before we add any objects, we need a way for models to tell views that something happened. When a new object gets added to the scene model, the scene view needs to know about it so it can create the matching mesh. We'll define an event type for this. The parent field is optional because not every object has a parent in the hierarchy.
// src/types/events.ts
import type { SceneObjectModel } from '../models/SceneObjectModel';
export type SceneModelEvents = {
'object-added': {
object: SceneObjectModel,
parent?: SceneObjectModel,
};
}
Models
Every 3D object in our scene needs position, rotation, scale, and material data. Instead of duplicating these fields across every model, we put them in a SceneObjectModel base class. It takes an id string and an optional material config. The concrete models will extend it with their own geometry parameters.
// src/models/SceneObjectModel.ts
import { Euler, Vector3 } from 'three';
import { BaseModel } from './BaseModel';
import type { MapColorPropertiesToColorRepresentations } from 'three/src/materials/Material';
export class SceneObjectModel<T = Partial<MapColorPropertiesToColorRepresentations<{}>>> extends BaseModel {
public readonly id;
_position: Vector3;
_rotation: Euler;
_scale: Vector3;
_material: Partial<MapColorPropertiesToColorRepresentations<T>>;
constructor(
id: string,
material: Partial<MapColorPropertiesToColorRepresentations<T>> = {}
) {
super();
this.id = id;
this._position = new Vector3(0, 0, 0);
this._rotation = new Euler(0, 0, 0);
this._scale = new Vector3(1, 1, 1);
this._material = { ...material };
}
public get position(): Vector3 { return this._position; }
public get rotation(): Euler { return this._rotation; }
public get scale(): Vector3 { return this._scale; }
public get material(): Partial<MapColorPropertiesToColorRepresentations<T>> {
return this._material;
}
}
CubeModel extends SceneObjectModel with a size field and sets up a wireframe material. All the geometry and material data live here in the model, not in the view.
// src/models/CubeModel.ts
import {MathUtils, Vector3} from 'three';
import type { MeshBasicMaterialProperties } from 'three';
import { SceneObjectModel } from './SceneObjectModel';
export class CubeModel extends SceneObjectModel<MeshBasicMaterialProperties> {
private _size = 1.5;
constructor(id: string) {
super(id, {
color: '#44aaff',
wireframe: true
});
this.rotation.y = MathUtils.degToRad(45);
}
get size() { return this._size; }
}
TorusModel follows the same idea but with parameters specific to a torus knot geometry: radius, tube thickness, and segment counts. The material here is a MeshStandardMaterial with metalness and roughness, so we'll get reflections from the HDR environment we set up earlier.
// src/models/TorusModel.ts
import type { MeshStandardMaterial } from 'three';
import { SceneObjectModel } from './SceneObjectModel';
export class TorusModel extends SceneObjectModel<MeshStandardMaterial> {
private _radius: number = 0.3;
private _tube: number = 0.08;
private _tubularSegments: number = 128;
private _radialSegments: number = 16;
constructor(id: string) {
super(id, {
color: '#4e8ab3',
metalness: 1,
roughness: 0.1
});
}
public get radius() { return this._radius; }
public get tube() { return this._tube; }
public get tubularSegments() { return this._tubularSegments; }
public get radialSegments() { return this._radialSegments; }
}
Views
On the view side, we need the same kind of base class. SceneObjectView is abstract. It takes a model, calls createMesh (which subclasses implement), and syncs the mesh's transform to match the model's position, rotation, and scale. This way, every object view starts in the right place without repeating the same copy logic.
// src/views/SceneObjectView.ts
import type { Mesh } from 'three';
import { SceneObjectModel } from '../models/SceneObjectModel';
export abstract class SceneObjectView<T extends SceneObjectModel = SceneObjectModel> {
private _mesh!: Mesh;
private _model: T;
constructor(model: T) {
this._model = model;
this.createMesh();
this.syncTransform();
}
protected abstract createMesh(): void;
get mesh(): Mesh { return this._mesh; }
set mesh(mesh: Mesh) { this._mesh = mesh; }
get model(): T { return this._model; }
private syncTransform() {
this.mesh.position.copy(this._model.position);
this.mesh.rotation.copy(this._model.rotation);
this.mesh.scale.copy(this._model.scale);
}
}
CubeView implements createMesh by reading the model's size and material properties to build a BoxGeometry and MeshBasicMaterial. The view doesn't decide what color the cube is or how big it should be. It just reads from the model.
// src/views/CubeView.ts
import { BoxGeometry, MeshBasicMaterial, Mesh } from 'three';
import { SceneObjectView } from './SceneObjectView';
import { CubeModel } from '../models/CubeModel';
export class CubeView extends SceneObjectView<CubeModel> {
constructor(model: CubeModel) {
super(model);
}
protected createMesh() {
const geometry = new BoxGeometry(
this.model.size,
this.model.size,
this.model.size,
);
const material = new MeshBasicMaterial({
color: this.model.material.color,
wireframe: this.model.material.wireframe,
});
this.mesh = new Mesh(geometry, material);
}
}
TorusView does the same thing for a torus knot. Different geometry, different material type, but the same pattern: read from the model, build the mesh.
// src/views/TorusView.ts
import { TorusKnotGeometry, MeshStandardMaterial, Mesh } from 'three';
import { SceneObjectView } from './SceneObjectView';
import { TorusModel } from '../models/TorusModel';
export class TorusView extends SceneObjectView<TorusModel> {
constructor(model: TorusModel) {
super(model);
}
protected createMesh() {
const geometry = new TorusKnotGeometry(
this.model.radius,
this.model.tube,
this.model.tubularSegments,
this.model.radialSegments,
);
const material = new MeshStandardMaterial({
color: this.model.material.color,
metalness: this.model.material.metalness,
roughness: this.model.material.roughness
});
this.mesh = new Mesh(geometry, material);
}
}
Wiring it up
Now we need to connect these pieces. First, SceneModel gets updated to dispatch an 'object-added' event whenever a new object is registered. This is the bridge between models and views: the model doesn't know about views at all, it just announces that something changed.
// src/models/SceneModel.ts
import type { SceneObjectModel } from './SceneObjectModel';
import { BaseModel } from './BaseModel';
import type { SceneModelEvents } from '../types/events';
export class SceneModel extends BaseModel<SceneModelEvents> {
// ... _objects map, get, and getAll remain the same
add(object: SceneObjectModel, parent?: SceneObjectModel) {
this._objects.set(object.id, object);
this.dispatchEvent({ type: 'object-added', object, parent });
}
}
SceneView listens for that event. When it fires, the view checks what kind of model it got and creates the right view class for it. It also handles parent-child hierarchy: if the object has a parent, the mesh gets added to the parent's mesh instead of the scene root. The objectViews map keeps track of all created views so we can look up parents.
// src/views/SceneView.ts
// ... existing three.js imports remain the same
import { CubeView } from './CubeView';
import { TorusView } from './TorusView';
import type { CameraView } from './CameraView';
import type { SceneObjectView } from './SceneObjectView';
import type { SceneModel } from '../models/SceneModel';
import type { LightsModel } from '../models/LightsModel';
import type { EnvironmentModel } from '../models/EnvironmentModel';
import { CubeModel } from '../models/CubeModel';
import { TorusModel } from '../models/TorusModel';
export class SceneView {
// ... scene, renderer, sceneModel, cameraView remain the same
private objectViews: Map<string, SceneObjectView> = new Map();
constructor(
sceneModel: SceneModel,
lightsModel: LightsModel,
environmentModel: EnvironmentModel,
cameraView: CameraView
) {
// ... existing setup remains the same
this.addEventListeners();
}
// ... setupRenderer, setupScene, setupLights,
// setupEnvironment, update, domElement remain the same
private addEventListeners() {
this.sceneModel.addEventListener('object-added', (event) => {
let view: SceneObjectView;
if (event.object instanceof CubeModel) {
view = new CubeView(event.object);
} else if (event.object instanceof TorusModel) {
view = new TorusView(event.object);
} else {
return;
}
this.objectViews.set(event.object.id, view);
const parentView = event.parent
&& this.objectViews.get(event.parent.id);
const parent = parentView?.mesh || this.scene;
parent.add(view.mesh);
});
}
}
The last piece is spawning the actual objects. We add a spawnInitialObjects method to the Application class. It creates a CubeModel and a TorusModel, then adds them to the SceneModel. Because we pass cube as the parent of the torus, the torus mesh will be nested inside the cube mesh in the Three.js scene graph. The event system takes care of the rest: SceneModel.add fires 'object-added', SceneView picks it up, and the meshes appear.
// src/index.ts
// ... existing imports remain the same
import { CubeModel } from './models/CubeModel';
import { TorusModel } from './models/TorusModel';
export class Application {
// ... existing model, view, and controller fields remain the same
constructor() {
// ... existing model, view,
// and controller creation remains the same
this.spawnInitialObjects();
}
// ... update and start methods remain the same
private spawnInitialObjects() {
const cube = new CubeModel('cube-1');
const torus = new TorusModel('torus-1');
this.sceneModel.add(cube);
this.sceneModel.add(torus, cube);
}
}
// ... app creation and start remain the same
At this point, the wireframe cube and the metallic torus knot are visible in the scene:

Data flows in one direction: models hold the state, views read from it. Neither side knows about the other's internals because events are the only thing connecting them.
Bringing Interactivity
We have objects on screen, but they sit there. The whole point of splitting models and views is that we can change data at runtime and have the visuals follow. In MVC, user input doesn't touch the views directly. It goes through a controller, which updates a model, which fires an event, which the view picks up. That sounds like a long chain for "move the mouse and rotate a cube," but the rotation logic never imports anything from Three.js, and the rendering code never sees a pointer event.
Events
We need two more event types. SceneObjectEvents carries a rotation-changed payload so views can react when a model's rotation updates. InputModelEvents carries normalized pointer coordinates whenever the user moves the mouse.
// src/types/events.ts
import type { Vector2, Euler } from 'three';
import type { SceneObjectModel } from '../models/SceneObjectModel';
export type SceneObjectEvents = {
'rotation-changed': { rotation: Euler };
}
export type InputModelEvents = {
'input-changed': { position: Vector2 };
}
// ... SceneModelEvents remains the same
Models
SceneObjectModel needs to use the new SceneObjectEvents type so it can dispatch rotation-changed. The only change from before is the generic parameter passed to BaseModel.
// src/models/SceneObjectModel.ts
// ... existing imports remain the same
import type { SceneObjectEvents } from '../types/events';
export class SceneObjectModel<T = Partial<MapColorPropertiesToColorRepresentations<{}>>> extends BaseModel<SceneObjectEvents> {
// ... everything else remains the same
}
InputModel is small. It stores a 2D position vector and exposes an update method. When called, it sets the new coordinates and fires 'input-changed'. That's it. No DOM references and no knowledge of what will consume the event.
// src/models/InputModel.ts
import { Vector2 } from 'three';
import { BaseModel } from './BaseModel';
import { InputModelEvents } from '../types/events';
export class InputModel extends BaseModel<InputModelEvents> {
private _position: Vector2;
constructor() {
super();
this._position = new Vector2(0, 0);
}
public update(x: number, y: number) {
this._position.set(x, y);
this.dispatchEvent({
type: 'input-changed',
position: this._position,
});
}
}
Views
SceneObjectView gets one new method: addEventListeners. It subscribes to 'rotation-changed' on its model and copies the new rotation onto the mesh. The constructor now calls this method after syncTransform.
// src/views/SceneObjectView.ts
import type { Mesh } from 'three';
import { SceneObjectModel } from '../models/SceneObjectModel';
export abstract class SceneObjectView<T extends SceneObjectModel = SceneObjectModel> {
private _mesh!: Mesh;
private _model: T;
constructor(model: T) {
this._model = model;
this.createMesh();
this.syncTransform();
this.addEventListeners();
}
protected abstract createMesh(): void;
get mesh(): Mesh { return this._mesh; }
set mesh(mesh: Mesh) { this._mesh = mesh; }
get model(): T { return this._model; }
private syncTransform() {
this.mesh.position.copy(this._model.position);
this.mesh.rotation.copy(this._model.rotation);
this.mesh.scale.copy(this._model.scale);
}
private addEventListeners() {
this._model.addEventListener('rotation-changed', (event) => {
this.mesh.rotation.set(
event.rotation.x,
event.rotation.y,
event.rotation.z,
event.rotation.order,
);
});
}
}
Controllers
Controllers sit between user input and models. They read events from one model and write changes to another. No rendering code, no DOM manipulation beyond the initial listener setup.
InputController listens for pointermove and pointerdown on the renderer's canvas. When a pointer event fires, it normalizes the coordinates to a -1 to 1 range and calls inputModel.update. The controller doesn't care what happens after that.
// src/controllers/InputController.ts
import type { InputModel } from '../models/InputModel';
export class InputController {
private inputModel: InputModel;
private domElement: HTMLElement;
constructor(inputModel: InputModel, domElement: HTMLElement) {
this.inputModel = inputModel;
this.domElement = domElement;
this.setupListeners();
}
private handlePointer = (e: PointerEvent) => {
if (
!e.isPrimary ||
!(e.currentTarget instanceof HTMLElement)
) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
this.inputModel.update(x, y);
}
private setupListeners() {
this.domElement.addEventListener('pointermove', this.handlePointer);
this.domElement.addEventListener('pointerdown', this.handlePointer);
}
}
TransformController connects the input to the scene objects. It listens for 'input-changed' on the InputModel, iterates over all objects in the SceneModel, and dispatches 'rotation-changed' on each one. The cube rotates on the Y axis based on horizontal pointer position, and the torus rotates on the X axis based on vertical position. The controller decides the mapping, but the model owns the data, and the view owns the rendering.
// src/controllers/TransformController.ts
import type { SceneModel } from '../models/SceneModel';
import type { InputModel } from '../models/InputModel';
import { CubeModel } from '../models/CubeModel';
import { TorusModel } from '../models/TorusModel';
export class TransformController {
private sceneModel: SceneModel;
private inputModel: InputModel;
constructor(sceneModel: SceneModel, inputModel: InputModel) {
this.sceneModel = sceneModel;
this.inputModel = inputModel;
this.setupListeners();
}
private setupListeners() {
this.inputModel.addEventListener(
'input-changed',
({ position }) => {
const objects = this.sceneModel.getAll();
objects.forEach((obj) => {
const rotation = obj.rotation;
if (obj instanceof CubeModel) {
rotation.y = position.x * Math.PI;
obj.dispatchEvent({
type: 'rotation-changed',
rotation,
});
} else if (obj instanceof TorusModel) {
rotation.x = position.y * Math.PI;
obj.dispatchEvent({
type: 'rotation-changed',
rotation,
});
}
});
}
);
}
}
Wiring it all together
The Application class now creates all three controllers and wires them to the right models and views. InputController needs the canvas element from SceneView for pointer events. TransformController sits between SceneModel and InputModel, translating pointer coordinates into rotation updates. RenderController still calls SceneView.update() every frame.
// src/index.ts
import { SceneModel } from './models/SceneModel';
import { CubeModel } from './models/CubeModel';
import { TorusModel } from './models/TorusModel';
import { CameraModel } from './models/CameraModel';
import { InputModel } from './models/InputModel';
import { LightsModel } from './models/LightsModel';
import { SceneView } from './views/SceneView';
import { CameraView } from './views/CameraView';
import { InputController } from './controllers/InputController';
import { TransformController } from './controllers/TransformController';
import { RenderController } from './controllers/RenderController';
import { EnvironmentModel } from './models/EnvironmentModel';
export class Application {
private sceneModel: SceneModel;
private cameraModel: CameraModel;
private inputModel: InputModel;
private lightsModel: LightsModel;
private environmentModel: EnvironmentModel;
private sceneView: SceneView;
private cameraView: CameraView;
private inputController: InputController;
private transformController: TransformController;
private renderController: RenderController;
constructor() {
// Create models
this.sceneModel = new SceneModel();
this.cameraModel = new CameraModel();
this.inputModel = new InputModel();
this.lightsModel = new LightsModel();
this.environmentModel = new EnvironmentModel();
// Create views
this.cameraView = new CameraView(this.cameraModel);
this.sceneView = new SceneView(
this.sceneModel,
this.lightsModel,
this.environmentModel,
this.cameraView,
);
// Create controllers
this.renderController = new RenderController(this.update);
this.inputController = new InputController(
this.inputModel,
this.sceneView.domElement
);
this.transformController = new TransformController(
this.sceneModel,
this.inputModel,
);
this.spawnInitialObjects();
}
private update = () => {
this.sceneView.update();
}
private spawnInitialObjects() {
const cube = new CubeModel('cube-1');
const torus = new TorusModel('torus-1');
this.sceneModel.add(cube);
this.sceneModel.add(torus, cube);
}
public start() {
this.renderController.start();
}
}
const app = new Application();
app.start();
The data flow for a single pointer move looks like this: PointerEvent β InputController β InputModel (fires 'input-changed') β TransformController β CubeModel/TorusModel (fires 'rotation-changed') β SceneObjectView (updates mesh rotation). Every link in the chain only knows about the interface it talks to, not the full pipeline.
Now, let's check the final result:
Final File Structure
src/
βββ controllers/
β βββ InputController.ts
β βββ RenderController.ts
β βββ TransformController.ts
βββ models/
β βββ CameraModel.ts
β βββ CubeModel.ts
β βββ EnvironmentModel.ts
β βββ InputModel.ts
β βββ LightsModel.ts
β βββ SceneModel.ts
β βββ SceneObjectModel.ts
β βββ TorusModel.ts
βββ types/
β βββ events.ts
βββ views/
β βββ CameraView.ts
β βββ CubeView.ts
β βββ SceneObjectView.ts
β βββ SceneView.ts
β βββ TorusView.ts
βββ index.ts
Benchmarks
Now let's validate the results for different numbers of objects. For this purpose, I have to use an InstancedMesh instead of the regular Mesh,
which is outside the scope of the article. The architecture remains the same. Performance profiling over a 10-second interval on a Mac M1 Pro (Safari) yields the following results:
| Metric | 1Β torusΒ +Β 1Β cube |
10Β toriΒ +Β 10Β cubes |
100Β toriΒ +Β 100Β cubes |
|---|---|---|---|
| FPS (Avg/Min/Max) | 120 | 120 | 120 |
| Input > Render Latency (Avg) | 0.67 ms | 0.69 ms | 0.71 |
| Input > Render Latency (Max) | 2.2 ms | 2.4ms | 2.8ms |
| Memory Footprint | 16 MB | 16 MB | 16 MB |
The results confirm that the overhead associated with the MVC abstraction layer is negligible at this scale. The application maintains a stable 120 FPS with sub-millisecond input latency. The memory footprint remains a flat 16 MB across 1, 20, and 200 entities due to InstancedMesh implementation.
Beyond Basic MVC
Centralizing event communication
Our models and views currently rely on direct event attachments. We can replace this by routing all messages through a single central dispatcher. Controllers and models send updates to this central hub, and views subscribe only to the specific data they need. You should do this when your app gets large. It prevents you from having to debug complex webs of direct component subscriptions.
Choosing between MVVM and MVP
Many of us need to build a React or Vue interface on top of a 3D scene. Passing data between traditional MVC controllers and a reactive UI often creates duplicate state. We can shift to a Model-View-ViewModel pattern to solve this. The view model transforms the 3D data into a format the DOM easily understands. We should use this pattern when the application relies heavily on HTML configuration panels.
Alternatively, MVC and MVVM can cause frame drops if thousands of objects recalculate positions every frame. We can restructure into a Model-View-Presenter layout to protect performance. The view becomes completely passive in this pattern. The presenter calculates the math for all entities and manually batches the WebGL updates. This approach is necessary when the scene is massive and a stable frame rate is our absolute top priority.
SOLID
While MVC organizes the app, SOLID principles keep it extensible. Without them, controllers often end up importing concrete classes, and views begin branching on object types. This eventually breaks the clean layering we established.
In TransformController, we see this tight coupling:
// src/controllers/TransformController.ts
if (obj instanceof CubeModel) {
rotation.y = position.x * Math.PI;
obj.dispatchEvent({ type: 'rotation-changed', rotation });
} else if (obj instanceof TorusModel) {
rotation.x = position.y * Math.PI;
obj.dispatchEvent({ type: 'rotation-changed', rotation });
}
And SceneView.addEventListeners:
// src/views/SceneView.ts
if (event.object instanceof CubeModel) {
view = new CubeView(event.object);
} else if (event.object instanceof TorusModel) {
view = new TorusView(event.object);
}
These instanceof chains demonstrate exactly where the coupling occurs. I used them here to keep the data flow obvious, but they violate the Open/Closed Principle, adding a new object type would require modifying both classes. They also break the Dependency Inversion Principle because high-level controllers and views should not depend on concrete model implementations. In a larger project, the rotation logic should be moved into the models, and a view factory should be used to keep the architecture extensible and decoupled.
Conclusion
Using an MVC architecture separates core logic from rendering, making both easier to test and maintain. The benchmarks show that this structure has minimal impact on performance, with stable frame rates and low latency.
Pros
Predictability: The strict separation means your data is isolated from the rendering loop. You can modify your application state without accidentally breaking the WebGL context.
Testability: You can run unit tests on your controllers and models in a standard Node environment. You do not need to mock a canvas or a WebGL renderer to verify your logic.
Performance: The event-driven architecture has a very low footprint. Our tests maintained a stable frame rate and a flat 16 MB memory usage profile across different entity counts.
Cons
Boilerplate: You have to write significantly more code to get the first object on the screen. Creating a model, a view, and event listeners takes more time than simply adding a mesh to a scene.
Data duplication: You often need to keep properties synchronized between your models and the actual Three.js objects. This means maintaining position and rotation data in two places at once.
Strict discipline: You have to strictly maintain the MVC pattern and SOLID principles as the application grows. Letting logic slip into views or allowing controllers to bypass models will break the entire architecture and defeat the purpose of the setup.
What's Next
We proved MVC works well for our immediate needs, but it is just one way to organize a Three.js codebase. In upcoming posts, I'll walk through a few other patterns, so we can look at the practical trade-offs they bring to web-based 3D.
About the Author
Ivan Babkov - 2D/3D Graphics Software Engineer from Canada, building interactive web experiences | 3D, WebGL, GLTF, Three.js


Top comments (0)