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

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

  1. 放一个全屏 div
  2. 进来时创建世界并 start()
  3. 离开时 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;
}

先记住三句:

  1. antialias: true:边缘不锯齿
  2. ACESFilmicToneMapping:后面 HDR 才好看
  3. 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. 本篇验收

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

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

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


本篇小结

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

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


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

  • 一张 HDR 同时当天空和环境光
  • 0~24 点时钟混合白天 / 黑夜
  • 城市模型:缩放、居中、贴地
相关推荐
lerhxx11 天前
为什么你的Three.js物体总是乱转?一文彻底搞懂“万向锁”与四元数
前端·three.js
lerhxx12 天前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
xiaominlaopodaren12 天前
three.js地图视口瓦片(二):如何形成完整视口多边形
javascript·gis·three.js
lerhxx13 天前
我用 R3F 手搓了一个能走进去的 3D 迷宫简历(上):从选型架构到迷宫生成算法
前端·javascript·three.js
张人玉14 天前
基于 Python 机器学习 与 PyQt5 桌面框架开发的可视化分析系统——基于大数据的网上购物消费行为分析可视化大屏
python·qt·机器学习·echarts·three.js
xiaominlaopodaren14 天前
three.js地图视口瓦片(一):屏幕角点射线
javascript·gis·three.js
xiaominlaopodaren16 天前
three.js最小地图运行时(十):接入离线逻辑瓦片剖面
javascript·gis·three.js
xiaominlaopodaren17 天前
three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针
javascript·gis·three.js
像我这样帅的人丶你还17 天前
🚀苹果的液态玻璃咋做?🚀
前端·webgl·three.js