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

0 阅读5分钟

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

image.png

本系列用 Vue 3 + Three.js 搭一座能转、能亮的小城。
这一篇只认零件、把空世界跑起来。 天空、昼夜、城市模型放到(一下)再装。

读完你应该能回答:场景、相机、渲染器、控制器、灯光、主循环各自干什么,以及它们怎样接到 Vue 页面上。


1. 先认零件

把 3D 世界想成拍电影:

零件干什么生活比喻
场景 Scene装所有东西摄影棚
相机 Camera从哪看、看多远摄像机
渲染器 Renderer每帧画到网页上把画面打到屏幕
控制器 Controls鼠标拖、滚轮拉摄影师走位
灯光 Lights物体亮不亮太阳灯 + 补光
主循环不停「算一帧、画一帧」摄像机一直开着

少任意一块:要么全黑,要么卡住不动。

用一个 World 类按固定顺序装配。先有棚和摄像机,再有灯和地面,最后才往里面塞城。

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 上。页面只做三件事:

  1. 放一个全屏 div
  2. 进来时创建世界并 start()
  3. 离开时 dispose(),不然 WebGL 会泄漏
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(...)

_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 必须够远
_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) 往原点看,相当于站高一点俯瞰广场。

窗口一变,宽高比也要改:

_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>,塞进我们给的盒子。

_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;
}

先记住三句:

  1. antialias: true:边缘不锯齿
  2. ACESFilmicToneMapping:后面 HDR 才好看
  3. setPixelRatio 要封顶:手机和 4K 屏否则容易卡

6. 轨道控制器:用鼠标当摄影师

相机自己不会动。OrbitControls 绑在画布上:

  • 拖:绕目标转
  • 滚轮:拉近拉远
  • 右键拖:平移
_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从另一侧填暗部
_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 帧:高刷屏不必空转
  • 切走标签页就停:省电,回来再开
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 速度不一样。

切走浏览器标签时暂停:

_onVisibilityChange() {
  if (document.hidden) this.stop();
  else this.start();
}

9. 地板:没有城时也要有地

城市场景自带马路,常常关掉灰地板。
想看坐标时打开:一张平躺的平面 + 红 X / 青 Z 网格。

平面默认是竖着的,绕 X 转 -90° 才躺到地面上:

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. 离开页面:把零件拆干净

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. 本篇验收

到这里,你已经有一个能转的空世界

  1. 页面上出现画布,能拖、能滚轮缩放
  2. 有明暗,不是一片黑
  3. 窗口拉扯后画面不变形
  4. 切走标签页再回来,还能继续转

还没有:真实天空、昼夜滑条、城市大楼。那些是下一篇的零件。


本篇小结

盒子(DOM)
  → 场景
  → 相机
  → 渲染器
  → 轨道控制器
  → 三盏灯
  → 主循环(限 60fps)
  → 地板(可选)

壳子立住了。下一篇用同一套零件,把天空和城「亮」起来。


下篇预告:《让天空和城亮起来》

  • 一张 HDR 同时当天空和环境光
  • 0~24 点时钟混合白天 / 黑夜
  • 城市模型:缩放、居中、贴地