Vue + Three.js 实战:从零搭一座可交互 3D 世界(一)------世界由哪些零件构成

本系列用 Vue 3 + Three.js 搭一座能转、能亮的小城。
这一篇只认零件、把空世界跑起来。 天空、昼夜、城市模型放到(一下)再装。
读完你应该能回答:场景、相机、渲染器、控制器、灯光、主循环各自干什么,以及它们怎样接到 Vue 页面上。
1. 先认零件
把 3D 世界想成拍电影:
| 零件 | 干什么 | 生活比喻 |
|---|---|---|
| 场景 Scene | 装所有东西 | 摄影棚 |
| 相机 Camera | 从哪看、看多远 | 摄像机 |
| 渲染器 Renderer | 每帧画到网页上 | 把画面打到屏幕 |
| 控制器 Controls | 鼠标拖、滚轮拉 | 摄影师走位 |
| 灯光 Lights | 物体亮不亮 | 太阳灯 + 补光 |
| 主循环 | 不停「算一帧、画一帧」 | 摄像机一直开着 |
少任意一块:要么全黑,要么卡住不动。
用一个 World 类按固定顺序装配。先有棚和摄像机,再有灯和地面,最后才往里面塞城。
js
constructor(container, options = {}) {
this.container = container;
this.options = options;
this.scene = this._createScene();
this.camera = this._createCamera();
this.renderer = this._createRenderer();
this.controls = this._createControls();
this._createLights();
this._createFloor();
this._createAxes();
this._createEnvironment(); // HDR:下一篇
this._createPhysics(); // 第二篇
this._createCity(); // 下一篇
this._bindEvents();
}
2. Vue 只负责给一个盒子
Three.js 必须画在真实 DOM 上。页面只做三件事:
- 放一个全屏
div - 进来时创建世界并
start() - 离开时
dispose(),不然 WebGL 会泄漏
js
world = createWorld(containerRef.value, {
citySize: 1000,
city: true,
floor: false,
grid: false,
clockHour: clockHour.value,
});
world.onUpdate((dt, elapsed) => {
// 每帧回调,本篇先空着
});
world.start();
createWorld 就是 new World(container, options)。
分工: Vue 管按钮和键盘;World 管三维零件。不要把灯光、相机写进一堆按钮回调里。
3. 场景:一个空盒子
场景先给一个底色。以后灯光、地板、城市、角色全部 scene.add(...)。
js
_createScene() {
const scene = new THREE.Scene();
scene.background = new THREE.Color(this.options.backgroundColor || 0x1b2027);
return scene;
}
不想看见某样东西:从场景里拿掉,或设 visible = false。
HDR 加载完后,这个纯色底会被天空贴图换掉(下一篇)。
4. 相机:你的眼睛
透视相机和人眼一样:近大远小。
记住三个数:
- fov :视野张角,这里用
60 - aspect:画布宽 ÷ 高。不对就会被拉扁
- near / far :太近、太远的东西会被裁掉。城很大,
far必须够远
js
_createCamera() {
const w = this.container.clientWidth || 800;
const h = this.container.clientHeight || 600;
// 大城需要更远视距,否则远处楼会被切掉
const far = this.options.far ?? Math.max(2000, this._resolveMoveBounds() * 3);
const camera = new THREE.PerspectiveCamera(this.options.fov || 60, w / h, 0.1, far);
const camPos = this.options.cameraPosition || { x: 0, y: 18, z: 40 };
const lookAt = this.options.lookAt || { x: 0, y: 1, z: 0 };
camera.position.set(camPos.x, camPos.y, camPos.z);
camera.lookAt(lookAt.x, lookAt.y, lookAt.z);
this._lookAt = lookAt;
return camera;
}
默认站在 (0, 18, 40) 往原点看,相当于站高一点俯瞰广场。
窗口一变,宽高比也要改:
js
_onResize() {
const w = this.container.clientWidth;
const h = this.container.clientHeight;
if (!w || !h) return;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
:::tip 远处楼突然消失?先查 camera.far。很多教程写 far = 1000,这座城不够用。 :::
5. 渲染器:把 3D 画到网页上
渲染器会创建一块 <canvas>,塞进我们给的盒子。
js
_createRenderer() {
const w = this.container.clientWidth || 800;
const h = this.container.clientHeight || 600;
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
});
// 高分屏像素比封顶,太高会把 GPU 打满
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(w, h);
// HDR 必须开电影级色调映射,否则天空过曝、材质发灰
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = this.options.toneMappingExposure ?? 1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
this.container.appendChild(renderer.domElement);
return renderer;
}
先记住三句:
antialias: true:边缘不锯齿ACESFilmicToneMapping:后面 HDR 才好看setPixelRatio要封顶:手机和 4K 屏否则容易卡
6. 轨道控制器:用鼠标当摄影师
相机自己不会动。OrbitControls 绑在画布上:
- 拖:绕目标转
- 滚轮:拉近拉远
- 右键拖:平移
js
_createControls() {
const controls = new OrbitControls(this.camera, this.renderer.domElement);
controls.enableDamping = true; // 松手后还有一点惯性
controls.dampingFactor = 0.08;
controls.minDistance = this.options.minDistance ?? 2;
controls.maxDistance = this.options.maxDistance ?? Math.max(200, this._resolveMoveBounds() * 0.85);
controls.maxPolarAngle = Math.PI * 0.495; // 限制俯仰,避免钻进地里
const lookAt = this._lookAt || { x: 0, y: 1, z: 0 };
controls.target.set(lookAt.x, lookAt.y, lookAt.z);
controls.update();
return controls;
}
开了阻尼,必须在主循环里每帧 controls.update(),惯性才存在。
7. 灯光:没有灯,模型就是黑的
HDR(下一篇)负责环境反射。角色和建筑还需要几盏「真灯」打出明暗。
| 灯 | 作用 |
|---|---|
| 半球光 Hemisphere | 天光 + 地面反弹,整体不死黑 |
| 主方向光 Directional | 太阳 / 月亮,有明确方向 |
| 补光 Directional | 从另一侧填暗部 |
js
_createLights() {
const o = this.options;
const hemi = new THREE.HemisphereLight(0xffffff, 0x333333, o.hemiIntensity ?? 0.7);
const dir = new THREE.DirectionalLight(0xffffff, o.dirIntensity ?? 1.1);
dir.position.set(10, 12, 10);
const fill = new THREE.DirectionalLight(0xffffff, 0.3);
fill.position.set(-8, 6, -8);
this.scene.add(hemi, dir, fill);
this.lights = { hemi, dir, fill };
}
引用要存进 this.lights。下一篇改昼夜时,改的就是这三盏灯的颜色、强度和位置。
本篇先当永远白天:灯放在右上方,场景能看清即可。
8. 主循环:世界要活着
零件装完不会自己动。用 requestAnimationFrame 不停画。
两个小约束:
- 最多 60 帧:高刷屏不必空转
- 切走标签页就停:省电,回来再开
js
const FRAME_INTERVAL = 1000 / 60;
start() {
if (this._animating) return;
this._animating = true;
this._lastFrame = 0;
this._clock.getDelta(); // 避免切回来第一帧 dt 爆掉
this._animate();
}
_animate() {
if (this._disposed) return;
this._rafId = requestAnimationFrame(() => this._animate());
const now = performance.now();
if (now - this._lastFrame < FRAME_INTERVAL) return;
const dt = (now - this._lastFrame) / 1000;
this._lastFrame = now;
const elapsed = this._clock.getElapsedTime();
for (const cb of this._callbacks) cb(dt, elapsed);
if (this.controls.enabled) this.controls.update();
this.renderer.render(this.scene, this.camera);
}
页面用 world.onUpdate((dt, elapsed) => { ... }) 挂钩子。
dt 是这一帧过了几秒。移动、动画都要用它,不要写死「每帧走 1 米」,否则 60Hz 和 30Hz 速度不一样。
切走浏览器标签时暂停:
js
_onVisibilityChange() {
if (document.hidden) this.stop();
else this.start();
}
9. 地板:没有城时也要有地
城市场景自带马路,常常关掉灰地板。
想看坐标时打开:一张平躺的平面 + 红 X / 青 Z 网格。
平面默认是竖着的,绕 X 转 -90° 才躺到地面上:
js
this.floorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(size, size),
new THREE.MeshStandardMaterial({
color: 0x1e2834,
roughness: 0.92,
metalness: 0.04,
side: THREE.FrontSide,
}),
);
this.floorMesh.rotation.x = -Math.PI / 2;
this.floorMesh.position.y = 0;
this.scene.add(this.floorMesh);
原点再放坐标轴:红 X、绿 Y、蓝 Z,避免「我到底面朝哪」搞混。
10. 离开页面:把零件拆干净
js
dispose() {
this._disposed = true;
this.stop();
window.removeEventListener("resize", this._onResize);
this.controls.dispose();
this.renderer.dispose();
this.renderer.domElement.parentNode?.removeChild(this.renderer.domElement);
}
路由切走不拆,显卡内存和监听器会一直占着。
11. 本篇验收
到这里,你已经有一个能转的空世界:
- 页面上出现画布,能拖、能滚轮缩放
- 有明暗,不是一片黑
- 窗口拉扯后画面不变形
- 切走标签页再回来,还能继续转
还没有:真实天空、昼夜滑条、城市大楼。那些是下一篇的零件。
本篇小结
text
盒子(DOM)
→ 场景
→ 相机
→ 渲染器
→ 轨道控制器
→ 三盏灯
→ 主循环(限 60fps)
→ 地板(可选)
壳子立住了。下一篇用同一套零件,把天空和城「亮」起来。
下篇预告:《让天空和城亮起来》
- 一张 HDR 同时当天空和环境光
- 0~24 点时钟混合白天 / 黑夜
- 城市模型:缩放、居中、贴地