大多数 Three.js 教程都把所有逻辑写在一个
index.js里:创建场景、相机、渲染器、添加网格、动画循环......Demo 阶段没问题,但当项目里有几十个对象、复杂交互和状态管理时,单文件就会变成难以维护的 "屎山"。本文带你用经典的 MVC 模式重构 Three.js 项目,让数据、渲染和交互各司其职。
一、为什么 Three.js 需要架构
Three.js 本身不强制任何代码组织方式。一个典型的入门项目长这样:
typescript
运行
ini
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, w / h, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
);
scene.add(cube);
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
简单、直观,但问题也很明显:
- 数据和渲染耦合:立方体的颜色、尺寸、旋转速度直接写在创建 Mesh 的代码里,想改属性就得碰渲染逻辑。
- 难以测试:要验证 "鼠标移动时物体旋转" 的逻辑,必须启动浏览器、模拟 WebGL 环境。
- 扩展困难:新增一种对象类型,就得在同一个文件里继续堆代码,很快就失控了。
MVC(Model-View-Controller)的核心思想是分离关注点:
表格
| 层级 | 职责 | 依赖 |
|---|---|---|
| Model(模型) | 持有应用状态和业务数据,不依赖 Three.js | 无 |
| View(视图) | 把 Model 数据转成 Three.js 可渲染的对象 | 依赖 Model 和 Three.js |
| Controller(控制器) | 接收用户输入,更新 Model,协调各部分 | 依赖 Model |
数据流是单向的:用户输入 → Controller → Model(触发事件) → View 更新渲染。
二、项目骨架搭建
2.1 基础模型类
所有 Model 都需要事件分发能力,我们先做一个基类:
typescript
运行
typescript
// src/models/BaseModel.ts
export class BaseModel<Events = Record<string, unknown>> {
private listeners: Map<keyof Events, Set<(e: any) => void>> = new Map();
addEventListener<K extends keyof Events>(type: K, callback: (e: Events[K]) => void) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(callback);
}
dispatchEvent<K extends keyof Events>(event: Events[K] & { type: K }) {
this.listeners.get(event.type)?.forEach(cb => cb(event));
}
}
2.2 场景模型
SceneModel 管理场景中所有 3D 对象的集合:
typescript
运行
typescript
// src/models/SceneModel.ts
import { BaseModel } from './BaseModel';
import type { SceneObjectModel } from './SceneObjectModel';
export class SceneModel extends BaseModel {
private _objects = new Map<string, SceneObjectModel>();
get(id: string) { return this._objects.get(id); }
getAll() { return Array.from(this._objects.values()); }
add(object: SceneObjectModel, parent?: SceneObjectModel) {
this._objects.set(object.id, object);
this.dispatchEvent({ type: 'object-added', object, parent });
}
}
2.3 相机、光照、环境模型
typescript
运行
scala
// src/models/CameraModel.ts
import { BaseModel } from './BaseModel';
export class CameraModel extends BaseModel {
fov = 50;
near = 0.1;
far = 1000;
position = { x: 0, y: 0, z: 5 };
}
typescript
运行
ini
// src/models/LightsModel.ts
import { BaseModel } from './BaseModel';
export class LightsModel extends BaseModel {
ambientColor = '#ffffff';
ambientIntensity = 0.5;
directionalColor = '#ffffff';
directionalIntensity = 1;
}
typescript
运行
scala
// src/models/EnvironmentModel.ts
import { BaseModel } from './BaseModel';
export class EnvironmentModel extends BaseModel {
background = '#1a1a2e';
}
2.4 视图层
SceneView 负责创建 Three.js 的 Scene、WebGLRenderer,并把光照、环境、相机都初始化好:
typescript
运行
typescript
// src/views/SceneView.ts
import * as THREE from 'three';
import type { SceneModel } from '../models/SceneModel';
import type { LightsModel } from '../models/LightsModel';
import type { EnvironmentModel } from '../models/EnvironmentModel';
import type { CameraView } from './CameraView';
export class SceneView {
private scene: THREE.Scene;
private renderer: THREE.WebGLRenderer;
private sceneModel: SceneModel;
private cameraView: CameraView;
constructor(
sceneModel: SceneModel,
lightsModel: LightsModel,
environmentModel: EnvironmentModel,
cameraView: CameraView
) {
this.sceneModel = sceneModel;
this.cameraView = cameraView;
this.setupRenderer();
this.setupScene(environmentModel);
this.setupLights(lightsModel);
}
private setupRenderer() {
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(this.renderer.domElement);
}
private setupScene(env: EnvironmentModel) {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(env.background);
}
private setupLights(lights: LightsModel) {
const ambient = new THREE.AmbientLight(lights.ambientColor, lights.ambientIntensity);
const directional = new THREE.DirectionalLight(lights.directionalColor, lights.directionalIntensity);
directional.position.set(5, 5, 5);
this.scene.add(ambient, directional);
}
get domElement() { return this.renderer.domElement; }
update() {
this.renderer.render(this.scene, this.cameraView.camera);
}
}
CameraView 把 CameraModel 的数据转成实际的 PerspectiveCamera:
typescript
运行
typescript
// src/views/CameraView.ts
import * as THREE from 'three';
import type { CameraModel } from '../models/CameraModel';
export class CameraView {
public camera: THREE.PerspectiveCamera;
constructor(model: CameraModel) {
this.camera = new THREE.PerspectiveCamera(
model.fov, window.innerWidth / window.innerHeight, model.near, model.far
);
this.camera.position.set(model.position.x, model.position.y, model.position.z);
}
}
2.5 渲染控制器
RenderController 封装 requestAnimationFrame 循环,每帧调用传入的更新函数:
typescript
运行
typescript
// src/controllers/RenderController.ts
export class RenderController {
private update: () => void;
private running = false;
constructor(update: () => void) {
this.update = update;
}
start() {
this.running = true;
this.loop();
}
private loop = () => {
if (!this.running) return;
this.update();
requestAnimationFrame(this.loop);
};
}
2.6 应用入口
把所有部分组装起来:
typescript
运行
kotlin
// 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';
class Application {
private sceneModel: SceneModel;
private cameraModel: CameraModel;
private lightsModel: LightsModel;
private environmentModel: EnvironmentModel;
private sceneView: SceneView;
private cameraView: CameraView;
private renderController: RenderController;
constructor() {
// 1. 创建所有 Model
this.sceneModel = new SceneModel();
this.cameraModel = new CameraModel();
this.lightsModel = new LightsModel();
this.environmentModel = new EnvironmentModel();
// 2. 创建所有 View(依赖 Model)
this.cameraView = new CameraView(this.cameraModel);
this.sceneView = new SceneView(
this.sceneModel, this.lightsModel, this.environmentModel, this.cameraView
);
// 3. 创建 Controller
this.renderController = new RenderController(this.update);
}
private update = () => this.sceneView.update();
start() { this.renderController.start(); }
}
new Application().start();
三、添加 3D 对象:模型与视图分离
3.1 事件定义
Model 变化时通过事件通知 View,先定义事件类型:
typescript
运行
typescript
// src/types/events.ts
import type { Euler, Vector2 } from 'three';
import type { SceneObjectModel } from '../models/SceneObjectModel';
export type SceneModelEvents = {
'object-added': { object: SceneObjectModel; parent?: SceneObjectModel };
};
export type SceneObjectEvents = {
'rotation-changed': { rotation: Euler };
};
export type InputModelEvents = {
'input-changed': { position: Vector2 };
};
3.2 场景对象基类模型
所有 3D 对象都有位置、旋转、缩放和材质属性,抽到基类里:
typescript
运行
typescript
// src/models/SceneObjectModel.ts
import { Euler, Vector3 } from 'three';
import { BaseModel } from './BaseModel';
import type { SceneObjectEvents } from '../types/events';
import type { MapColorPropertiesToColorRepresentations } from 'three/src/materials/Material';
export class SceneObjectModel<T = Partial<MapColorPropertiesToColorRepresentations<{}>>>
extends BaseModel<SceneObjectEvents> {
public readonly id: string;
_position = new Vector3(0, 0, 0);
_rotation = new Euler(0, 0, 0);
_scale = new Vector3(1, 1, 1);
_material: Partial<MapColorPropertiesToColorRepresentations<T>>;
constructor(id: string, material: Partial<MapColorPropertiesToColorRepresentations<T>> = {}) {
super();
this.id = id;
this._material = { ...material };
}
get position() { return this._position; }
get rotation() { return this._rotation; }
get scale() { return this._scale; }
get material() { return this._material; }
}
3.3 具体模型
立方体模型 ------ 线框材质,初始绕 Y 轴旋转 45°:
typescript
运行
typescript
// src/models/CubeModel.ts
import { MathUtils } 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; }
}
环面纽结模型 ------ 金属材质,会反射 HDR 环境光:
typescript
运行
typescript
// src/models/TorusModel.ts
import type { MeshStandardMaterial } from 'three';
import { SceneObjectModel } from './SceneObjectModel';
export class TorusModel extends SceneObjectModel<MeshStandardMaterial> {
private _radius = 0.3;
private _tube = 0.08;
private _tubularSegments = 128;
private _radialSegments = 16;
constructor(id: string) {
super(id, { color: '#4e8ab3', metalness: 1, roughness: 0.1 });
}
get radius() { return this._radius; }
get tube() { return this._tube; }
get tubularSegments() { return this._tubularSegments; }
get radialSegments() { return this._radialSegments; }
}
3.4 视图基类
SceneObjectView 是抽象基类,子类实现 createMesh,基类负责同步变换:
typescript
运行
kotlin
// src/views/SceneObjectView.ts
import type { Mesh } from 'three';
import type { SceneObjectModel } from '../models/SceneObjectModel';
export abstract class SceneObjectView<T extends SceneObjectModel = SceneObjectModel> {
private _mesh!: Mesh;
protected _model: T;
constructor(model: T) {
this._model = model;
this.createMesh();
this.syncTransform();
this.addEventListeners();
}
protected abstract createMesh(): void;
get mesh() { return this._mesh; }
set mesh(mesh: Mesh) { this._mesh = mesh; }
get model() { 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', ({ rotation }) => {
this.mesh.rotation.set(rotation.x, rotation.y, rotation.z, rotation.order);
});
}
}
3.5 具体视图
立方体视图 ------ 从 Model 读取尺寸和材质,创建 Mesh:
typescript
运行
scala
// src/views/CubeView.ts
import { BoxGeometry, MeshBasicMaterial, Mesh } from 'three';
import { SceneObjectView } from './SceneObjectView';
import type { CubeModel } from '../models/CubeModel';
export class CubeView extends SceneObjectView<CubeModel> {
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);
}
}
环面纽结视图 ------ 同样的模式,不同的几何体和材质:
typescript
运行
scala
// src/views/TorusView.ts
import { TorusKnotGeometry, MeshStandardMaterial, Mesh } from 'three';
import { SceneObjectView } from './SceneObjectView';
import type { TorusModel } from '../models/TorusModel';
export class TorusView extends SceneObjectView<TorusModel> {
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);
}
}
3.6 串联:SceneView 监听对象添加
SceneView 监听 SceneModel 的 object-added 事件,根据 Model 类型创建对应的 View,并处理父子层级:
typescript
运行
csharp
// src/views/SceneView.ts(补充部分)
import { CubeView } from './CubeView';
import { TorusView } from './TorusView';
import { CubeModel } from '../models/CubeModel';
import { TorusModel } from '../models/TorusModel';
import type { SceneObjectView } from './SceneObjectView';
export class SceneView {
private objectViews = new Map<string, SceneObjectView>();
constructor(/* ... */) {
// ... 原有初始化
this.addEventListeners();
}
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);
});
}
}
最后在 Application 里创建对象:
typescript
运行
typescript
// src/index.ts(补充)
import { CubeModel } from './models/CubeModel';
import { TorusModel } from './models/TorusModel';
class Application {
constructor() {
// ... 原有初始化
this.spawnInitialObjects();
}
private spawnInitialObjects() {
const cube = new CubeModel('cube-1');
const torus = new TorusModel('torus-1');
this.sceneModel.add(cube);
this.sceneModel.add(torus, cube); // torus 作为 cube 的子对象
}
}
此时场景中应该能看到一个线框立方体,里面嵌套着一个金属质感的环面纽结。
数据流 :Model 持有数据 → SceneModel.add() 触发 object-added 事件 → SceneView 收到事件,创建对应 View → View 从 Model 读取数据生成 Mesh → 添加到场景。Model 完全不知道 View 的存在。
四、加入交互:让物体动起来
MVC 的真正威力在于:用户输入不直接操作 View,而是通过 Controller 更新 Model,Model 触发事件,View 响应事件更新渲染。
4.1 输入模型
InputModel 存储归一化的指针坐标(-1 到 1),不涉及任何 DOM 操作:
typescript
运行
scala
// src/models/InputModel.ts
import { Vector2 } from 'three';
import { BaseModel } from './BaseModel';
import type { InputModelEvents } from '../types/events';
export class InputModel extends BaseModel<InputModelEvents> {
private _position = new Vector2(0, 0);
update(x: number, y: number) {
this._position.set(x, y);
this.dispatchEvent({ type: 'input-changed', position: this._position });
}
}
4.2 输入控制器
InputController 监听画布上的指针事件,把坐标归一化后写入 InputModel:
typescript
运行
kotlin
// 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);
}
}
4.3 变换控制器
TransformController 把输入坐标翻译成物体旋转 ------ 水平移动控制立方体 Y 轴旋转,垂直移动控制环面 X 轴旋转:
typescript
运行
typescript
// 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 {
constructor(sceneModel: SceneModel, inputModel: InputModel) {
inputModel.addEventListener('input-changed', ({ position }) => {
sceneModel.getAll().forEach((obj) => {
const rotation = obj.rotation;
if (obj instanceof CubeModel) {
rotation.y = position.x * Math.PI;
} else if (obj instanceof TorusModel) {
rotation.x = position.y * Math.PI;
}
obj.dispatchEvent({ type: 'rotation-changed', rotation });
});
});
}
}
4.4 完整组装
在 Application 中把三个控制器都接上:
typescript
运行
kotlin
// src/index.ts(最终版)
import { InputModel } from './models/InputModel';
import { InputController } from './controllers/InputController';
import { TransformController } from './controllers/TransformController';
class Application {
private inputModel: InputModel;
private inputController: InputController;
private transformController: TransformController;
constructor() {
// ... Model 创建
this.inputModel = new InputModel();
// ... View 创建
// Controller 创建
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();
}
}
一次鼠标移动的完整数据流:
plaintext
css
PointerEvent → InputController → InputModel(触发 input-changed)
→ TransformController → CubeModel/TorusModel(触发 rotation-changed)
→ SceneObjectView(更新 mesh.rotation)
每一环只知道自己对接的接口,不关心整条链路。旋转逻辑里没有任何 Three.js 导入,渲染代码里也看不到指针事件。
五、最终文件结构
plaintext
bash
src/
├── controllers/
│ ├── InputController.ts # 指针输入 → InputModel
│ ├── RenderController.ts # 动画循环
│ └── TransformController.ts # 输入 → 物体旋转
├── models/
│ ├── BaseModel.ts # 事件基类
│ ├── CameraModel.ts
│ ├── CubeModel.ts
│ ├── EnvironmentModel.ts
│ ├── InputModel.ts
│ ├── LightsModel.ts
│ ├── SceneModel.ts
│ ├── SceneObjectModel.ts # 3D 对象基类
│ └── TorusModel.ts
├── types/
│ └── events.ts # 事件类型定义
├── views/
│ ├── CameraView.ts
│ ├── CubeView.ts
│ ├── SceneObjectView.ts # 对象视图基类
│ ├── SceneView.ts
│ └── TorusView.ts
└── index.ts # 应用入口
六、性能基准测试
在 Steam Deck(SteamOS 3.7.19,Chrome 146,WebGL 2)上,使用 InstancedMesh 对不同数量的对象进行 10 秒性能采样:
表格
| 指标 | 1000 对象 | 25000 对象 | 50000 对象 | 100000 对象 |
|---|---|---|---|---|
| FPS(平均 / 最低 / 最高) | 120 | 59 | 33 | 19 |
| 输入→渲染延迟(平均) | 0.47ms | 0.87ms | 1.45ms | 2.2ms |
| 输入→渲染延迟(峰值) | 1.1ms | 9.9ms | 18.6ms | 23.2ms |
| 内存占用 | 17 MB | 18 MB | 19 MB | 21 MB |
结论:MVC 抽象层带来的性能开销可以忽略不计。即使到 10 万个对象,平均输入延迟仍低于 2.2ms,内存从 17MB 增长到 21MB,扩展性良好。
七、超越基础 MVC
7.1 集中式事件总线
当前 Model 和 View 直接绑定事件监听器。项目变大后,可以引入一个中央事件分发器(Event Bus) ,所有消息都走这个枢纽。Controller 和 Model 向总线发送更新,View 只订阅自己需要的数据。这样避免了组件之间直接订阅形成的复杂关系网,调试起来更清晰。
7.2 MVVM:当你需要 React/Vue 面板
如果 3D 场景上方有大量 HTML 配置面板(用 React 或 Vue 构建),传统 MVC 的 Controller 和响应式 UI 之间容易出现状态重复。此时可以转向 MVVM(Model-View-ViewModel) :ViewModel 把 3D 数据转换成 DOM 容易消费的格式,天然适配响应式框架。
7.3 MVP:当性能是第一优先级
如果场景中有成千上万个对象,每帧都重新计算位置可能导致掉帧。MVP(Model-View-Presenter) 让 View 完全被动,Presenter 统一计算所有实体的变换,然后批量更新 WebGL。当场景极其庞大、稳定帧率是绝对优先事项时,这个模式是必要的。
7.4 SOLID 原则的重要性
MVC 只是分层,SOLID 原则保证分层不被破坏。看上面的代码,有两处明显的耦合:
typescript
运行
csharp
// TransformController 里
if (obj instanceof CubeModel) { /* ... */ }
else if (obj instanceof TorusModel) { /* ... */ }
// SceneView 里
if (event.object instanceof CubeModel) { view = new CubeView(event.object); }
else if (event.object instanceof TorusModel) { view = new TorusView(event.object); }
这些 instanceof 链违反了开闭原则 (新增对象类型必须修改这两个类)和依赖倒置原则(高层模块依赖了具体实现)。Demo 里这样写是为了让数据流更直观,但实际项目中应该:
- 把旋转逻辑放到各自的 Model 里(多态替代条件判断)
- 用视图工厂(View Factory) 根据 Model 类型创建对应 View
八、总结
优点
- 可预测性:数据与渲染循环严格隔离,修改应用状态不会意外破坏 WebGL 上下文。
- 可测试性:Controller 和 Model 可以在纯 Node 环境里跑单元测试,不需要 mock Canvas 或 WebGL。
- 性能:事件驱动架构开销极低,从 1000 到 10 万个实体,内存和延迟都保持在很低水平。
缺点
- 样板代码多 :让第一个物体出现在屏幕上,需要写 Model、View、事件监听器,比直接
scene.add(mesh)慢得多。 - 数据重复:位置、旋转等属性需要在 Model 和 Three.js 对象之间同步,维护两份数据。
- 需要纪律:随着应用增长,必须严格遵守 MVC 和 SOLID。一旦让逻辑溜进 View,或者让 Controller 绕过 Model 直接操作渲染,整个架构就失去了意义。
MVC 只是组织 Three.js 代码的一种方式。它适合中大型项目、需要可测试性和团队协作的场景。如果只是做一个简单的交互动效,单文件可能更高效 ------ 架构永远是为需求服务的,不是为了架构而架构。