Three.js 开发实战教程(四):相机系统全解析与多视角控制

在前几篇教程中,我们通过灯光与阴影提升了场景真实感,但如何 "观察" 场景同样关键 ------ 这正是相机系统的核心作用。Three.js 提供了多种相机类型,不同相机决定了场景的呈现视角(如人眼透视、工程图纸正交)。本篇将系统讲解相机的工作原理、参数配置,并通过实战实现 "多视角切换""第一人称漫游" 等常见需求,帮助开发者灵活控制场景观察方式。

一、相机系统核心知识

相机是 3D 场景的 "眼睛",其类型和参数直接决定渲染结果。Three.js 中最常用的是透视相机正交相机,二者的核心区别在于是否遵循 "近大远小" 的透视规律。

1. 透视相机(PerspectiveCamera)

模拟人眼观察世界的方式,物体近大远小,适合创建真实感场景(如游戏、产品展示)。

核心参数
javascript 复制代码
new THREE.PerspectiveCamera(fov, aspect, near, far)
  • fov(视场角):垂直方向的视野角度(单位:度),值越小视野越窄(类似望远镜),越大视野越宽(类似广角镜头),常用 60-75 度。
  • aspect(宽高比):相机视口的宽高比(通常为容器宽 / 高),比例失调会导致场景拉伸变形。
  • near(近裁剪面):相机能看到的最近距离,小于此值的物体不会被渲染。
  • far(远裁剪面):相机能看到的最远距离,大于此值的物体不会被渲染。
关键特性
  • 必须保持aspect = 容器宽/高,否则画面会拉伸(如宽屏显示正方形变成矩形)。
  • nearfar的差值不宜过大(如 1-10000),否则会导致 "深度冲突"(物体边缘出现闪烁)。

2. 正交相机(OrthographicCamera)

物体大小与距离无关,适合工程图纸、2D 游戏、UI 界面等场景(如 CAD 软件、俯视地图)。

核心参数
javascript 复制代码
new THREE.OrthographicCamera(left, right, top, bottom, near, far)
  • left/right:视口左 / 右边界坐标。
  • top/bottom:视口上 / 下边界坐标。
  • near/far:近 / 远裁剪面(同透视相机)。
关键特性
  • 宽高比由(right-left)/(top-bottom)决定,需与容器比例一致。
  • 物体尺寸在任何距离下保持不变(如 10 单位的立方体,在远处仍显示 10 单位大小)。

3. 相机位置与朝向控制

无论哪种相机,都需要通过以下方法调整观察视角:

  • camera.position.set(x, y, z):设置相机位置(三维坐标)。
  • camera.lookAt(x, y, z):设置相机朝向(指向目标点)。
  • camera.up.set(x, y, z):设置相机 "上方向"(默认 (0,1,0),即 Y 轴为上)。

二、实战:多相机切换与第一人称控制

本次实战目标:创建一个包含 "产品展示台" 的场景,实现三种视角切换(透视全局、正交顶视、第一人称漫游),并支持键盘控制移动。

1. 前置准备

  • 基于前序项目,无需额外依赖。
  • 准备 1 张展示台纹理图,放入public/images/目录:platform-texture.jpg

2. 完整代码实现(创建 CameraSystemDemo.vue)

src/components/目录下新建组件,代码含详细注释:

javascript 复制代码
<template>
  <div class="container">
    <div class="three-container" ref="container"></div>
    <!-- 视角控制按钮 -->
    <div class="control-panel">
      <button @click="switchCamera('perspective')">透视全局</button>
      <button @click="switchCamera('orthographic')">正交顶视</button>
      <button @click="switchCamera('firstPerson')">第一人称</button>
      <p>第一人称控制:WASD移动,鼠标拖动旋转视角</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, reactive } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js' // 第一人称控制器

// 核心变量
const container = ref(null)
const state = reactive({
  currentCamera: 'perspective', // 当前相机类型
  cameras: {}, // 存储所有相机
  velocity: new THREE.Vector3(), // 第一人称移动速度
  direction: new THREE.Vector3(), // 第一人称移动方向
  moveForward: false,
  moveBackward: false,
  moveLeft: false,
  moveRight: false,
  canJump: false // 跳跃开关(本例暂不实现跳跃)
})

let scene, renderer, controls, firstPersonControls, animationId
let platform, box, sphere // 场景物体

// 初始化场景
const initScene = () => {
  // --------------------------
  // 1. 基础配置(场景、渲染器)
  // --------------------------
  scene = new THREE.Scene()
  scene.background = new THREE.Color(0xf0f0f0)

  const width = container.value.clientWidth
  const height = container.value.clientHeight

  // 渲染器
  renderer = new THREE.WebGLRenderer({ antialias: true })
  renderer.setSize(width, height)
  container.value.appendChild(renderer.domElement)

  // --------------------------
  // 2. 创建相机(三种类型)
  // --------------------------
  // 2.1 透视相机(全局视角)
  state.cameras.perspective = new THREE.PerspectiveCamera(
    60, // fov
    width / height, // aspect
    0.1, // near
    1000 // far
  )
  state.cameras.perspective.position.set(8, 8, 8) // 斜上方俯视
  state.cameras.perspective.lookAt(0, 0, 0)

  // 2.2 正交相机(顶视视角)
  const aspect = width / height
  const orthoSize = 10 // 正交相机视口大小
  state.cameras.orthographic = new THREE.OrthographicCamera(
    -orthoSize * aspect, // left
    orthoSize * aspect, // right
    orthoSize, // top
    -orthoSize, // bottom
    0.1,
    1000
  )
  state.cameras.orthographic.position.set(0, 20, 0) // 正上方
  state.cameras.orthographic.lookAt(0, 0, 0)

  // 2.3 第一人称相机(漫游视角)
  state.cameras.firstPerson = new THREE.PerspectiveCamera(
    75,
    width / height,
    0.1,
    1000
  )
  state.cameras.firstPerson.position.set(0, 1.6, 5) // 模拟人眼高度(y=1.6)

  // --------------------------
  // 3. 创建场景物体
  // --------------------------
  const textureLoader = new THREE.TextureLoader()

  // 3.1 展示台(地面)
  const platformGeometry = new THREE.PlaneGeometry(15, 15)
  const platformMaterial = new THREE.MeshLambertMaterial({
    map: textureLoader.load('/images/platform-texture.jpg')
  })
  platform = new THREE.Mesh(platformGeometry, platformMaterial)
  platform.rotation.x = -Math.PI / 2
  platform.receiveShadow = true
  scene.add(platform)

  // 3.2 立方体
  const boxGeometry = new THREE.BoxGeometry(2, 2, 2)
  const boxMaterial = new THREE.MeshPhongMaterial({ color: 0x409eff })
  box = new THREE.Mesh(boxGeometry, boxMaterial)
  box.position.set(-3, 1, 0)
  box.castShadow = true
  scene.add(box)

  // 3.3 球体
  const sphereGeometry = new THREE.SphereGeometry(1.2, 32, 32)
  const sphereMaterial = new THREE.MeshPhongMaterial({ color: 0xff7d00 })
  sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
  sphere.position.set(3, 1.2, 0)
  sphere.castShadow = true
  scene.add(sphere)

  // --------------------------
  // 4. 灯光(复用前序配置)
  // --------------------------
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
  scene.add(ambientLight)

  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
  directionalLight.position.set(10, 10, 10)
  directionalLight.castShadow = true
  scene.add(directionalLight)

  // --------------------------
  // 5. 控制器配置
  // --------------------------
  // 5.1 轨道控制器(用于透视/正交相机)
  controls = new OrbitControls(
    state.cameras.perspective,
    renderer.domElement
  )
  controls.enableDamping = true

  // 5.2 第一人称控制器(基于PointerLockControls)
  firstPersonControls = new PointerLockControls(
    state.cameras.firstPerson,
    document.body
  )
  // 点击场景时进入第一人称锁定状态
  container.value.addEventListener('click', () => {
    if (state.currentCamera === 'firstPerson') {
      firstPersonControls.lock()
    }
  })

  // --------------------------
  // 6. 键盘事件监听(第一人称移动)
  // --------------------------
  const onKeyDown = (event) => {
    switch (event.code) {
      case 'KeyW': state.moveForward = true; break
      case 'KeyA': state.moveLeft = true; break
      case 'KeyS': state.moveBackward = true; break
      case 'KeyD': state.moveRight = true; break
    }
  }

  const onKeyUp = (event) => {
    switch (event.code) {
      case 'KeyW': state.moveForward = false; break
      case 'KeyA': state.moveLeft = false; break
      case 'KeyS': state.moveBackward = false; break
      case 'KeyD': state.moveRight = false; break
    }
  }

  document.addEventListener('keydown', onKeyDown)
  document.addEventListener('keyup', onKeyUp)

  // --------------------------
  // 7. 动画循环
  // --------------------------
  const animate = () => {
    animationId = requestAnimationFrame(animate)

    // 第一人称移动逻辑
    if (state.currentCamera === 'firstPerson' && firstPersonControls.isLocked) {
      // 重置速度
      state.velocity.x -= state.velocity.x * 10.0 * 0.01
      state.velocity.z -= state.velocity.z * 10.0 * 0.01

      // 计算方向(基于相机朝向)
      state.direction.z = Number(state.moveForward) - Number(state.moveBackward)
      state.direction.x = Number(state.moveRight) - Number(state.moveLeft)
      state.direction.normalize() // 确保斜向移动速度与轴向一致

      // 应用速度
      if (state.moveForward || state.moveBackward) {
        state.velocity.z -= state.direction.z * 20.0 * 0.01
      }
      if (state.moveLeft || state.moveRight) {
        state.velocity.x -= state.direction.x * 20.0 * 0.01
      }

      // 更新位置(限制Y轴防止飞行)
      firstPersonControls.moveRight(-state.velocity.x * 0.1)
      firstPersonControls.moveForward(-state.velocity.z * 0.1)
      state.cameras.firstPerson.position.y = 1.6 // 固定高度
    } else {
      // 非第一人称时更新轨道控制器
      controls.update()
    }

    // 渲染当前激活的相机
    renderer.render(scene, state.cameras[state.currentCamera])
  }

  animate()
}

// 切换相机
const switchCamera = (type) => {
  state.currentCamera = type
  // 切换控制器目标相机
  if (type !== 'firstPerson') {
    firstPersonControls.unlock() // 退出第一人称锁定
    controls.object = state.cameras[type] // 轨道控制器绑定到当前相机
    controls.enableDamping = true
  }
}

// 窗口自适应
const handleResize = () => {
  if (!container.value || !renderer) return
  const width = container.value.clientWidth
  const height = container.value.clientHeight

  // 更新透视相机
  state.cameras.perspective.aspect = width / height
  state.cameras.perspective.updateProjectionMatrix()

  // 更新第一人称相机
  state.cameras.firstPerson.aspect = width / height
  state.cameras.firstPerson.updateProjectionMatrix()

  // 更新正交相机(保持宽高比)
  const orthoSize = 10
  const aspect = width / height
  state.cameras.orthographic.left = -orthoSize * aspect
  state.cameras.orthographic.right = orthoSize * aspect
  state.cameras.orthographic.top = orthoSize
  state.cameras.orthographic.bottom = -orthoSize
  state.cameras.orthographic.updateProjectionMatrix()

  // 更新渲染器
  renderer.setSize(width, height)
}

// 生命周期
onMounted(() => {
  initScene()
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
  cancelAnimationFrame(animationId)
  renderer.dispose()
  controls.dispose()
  firstPersonControls.dispose()
  document.removeEventListener('keydown', () => {})
  document.removeEventListener('keyup', () => {})
})
</script>

<style scoped>
.container {
  position: relative;
}

.three-container {
  width: 100vw;
  height: 80vh;
  margin-top: 20px;
}

.control-panel {
  position: absolute;
  top: 20px;
  left: 20px;
  display: flex;
  gap: 10px;
  flex-direction: column;
  z-index: 100;
}

button {
  padding: 8px 12px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background: #66b1ff;
}

p {
  margin: 0;
  font-size: 14px;
  color: #333;
}
</style>

3. 运行效果

启动项目后,可体验:

  • 三种视角切换
    • 透视全局:斜上方视角,支持轨道控制器旋转 / 缩放。
    • 正交顶视:正上方俯视,物体大小与距离无关(类似 2D 俯视图)。
    • 第一人称:点击场景进入鼠标锁定模式,WASD 键控制移动,鼠标拖动旋转视角(模拟人在场景中漫游)。
  • 自适应窗口:调整浏览器窗口大小,三种相机均能保持画面比例正确。

三、相机使用常见问题与解决方案

1. 画面拉伸变形?

  • 原因:相机aspect参数与容器宽高比不一致。
  • 解决:在窗口resize事件中同步更新aspect并调用camera.updateProjectionMatrix()

2. 物体突然消失?

  • 原因:物体位置超出相机near-far范围,或相机lookAt指向错误。
  • 解决:
    • 检查camera.nearfar是否覆盖物体坐标。
    • 确保camera.lookAt()指向场景内物体(而非空坐标)。

3. 第一人称控制器视角抖动?

  • 原因:移动速度过快或未限制 Y 轴位置。
  • 解决:
    • 降低移动速度(如*0.1缩放)。
    • 固定 Y 轴高度(模拟地面行走,防止上下浮动)。

4. 正交相机物体显示不全?

  • 原因:left/right/top/bottom范围未覆盖场景物体。
  • 解决:根据场景大小调整正交相机边界(如orthoSize值),确保包含所有物体。

四、专栏预告

下一篇将讲解 Three.js 的外部模型加载与优化,内容包括:

  • 常见 3D 模型格式(glTF、OBJ、FBX)的加载方法。
  • 模型压缩、纹理优化、层级管理等实战技巧。
  • 实战:加载并控制一个带骨骼动画的人物模型。
相关推荐
IT_陈寒3 小时前
Redis性能提升30%的秘密:5个被低估的高级命令实战解析
前端·人工智能·后端
EndingCoder3 小时前
中间件详解与自定义
服务器·javascript·中间件·node.js
测试者家园3 小时前
Midscene.js为什么能通过大语言模型成功定位页面元素
javascript·自动化测试·人工智能·大语言模型·智能化测试·软件开发和测试·midscene
爱吃小胖橘3 小时前
Unity-动画IK控制
3d·unity·c#·游戏引擎
excel3 小时前
全面解析 JavaScript 内置 Symbol 方法(含示例)
前端
excel3 小时前
一文搞懂 Vue 的双向绑定
前端
卡布叻_星星8 小时前
前端JavaScript笔记之父子组件数据传递,watch用法之对象形式监听器的核心handler函数
前端·javascript·笔记
开发加微信:hedian1169 小时前
短剧小程序开发全攻略:从技术选型到核心实现(前端+后端+运营干货)
前端·微信·小程序
农场主er9 小时前
Metal - 5.深入剖析 3D 变换
3d·opengl·transform·matrix·metal