Three.js 开发实战教程(四):相机系统全解析与多视角控制
在前几篇教程中,我们通过灯光与阴影提升了场景真实感,但如何 “观察” 场景同样关键 —— 这正是相机系统的核心作用。Three.js 提供了多种相机类型,不同相机决定了场景的呈现视角(如人眼透视、工程图纸正交)。本篇将系统讲解相机的工作原理、参数配置,并通过实战实现 “多视角切换”“第一人称漫游” 等常见需求,帮助开发者灵活控制场景观察方式。
一、相机系统核心知识
相机是 3D 场景的 “眼睛”,其类型和参数直接决定渲染结果。Three.js 中最常用的是透视相机和正交相机,二者的核心区别在于是否遵循 “近大远小” 的透视规律。
1. 透视相机(PerspectiveCamera)
模拟人眼观察世界的方式,物体近大远小,适合创建真实感场景(如游戏、产品展示)。
核心参数
new THREE.PerspectiveCamera(fov, aspect, near, far)
fov
(视场角):垂直方向的视野角度(单位:度),值越小视野越窄(类似望远镜),越大视野越宽(类似广角镜头),常用 60-75 度。aspect
(宽高比):相机视口的宽高比(通常为容器宽 / 高),比例失调会导致场景拉伸变形。near
(近裁剪面):相机能看到的最近距离,小于此值的物体不会被渲染。far
(远裁剪面):相机能看到的最远距离,大于此值的物体不会被渲染。
关键特性
- 必须保持
aspect = 容器宽/高
,否则画面会拉伸(如宽屏显示正方形变成矩形)。 near
和far
的差值不宜过大(如 1-10000),否则会导致 “深度冲突”(物体边缘出现闪烁)。
2. 正交相机(OrthographicCamera)
物体大小与距离无关,适合工程图纸、2D 游戏、UI 界面等场景(如 CAD 软件、俯视地图)。
核心参数
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/
目录下新建组件,代码含详细注释:
<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.clientWidthconst 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, // fovwidth / height, // aspect0.1, // near1000 // far)state.cameras.perspective.position.set(8, 8, 8) // 斜上方俯视state.cameras.perspective.lookAt(0, 0, 0)// 2.2 正交相机(顶视视角)const aspect = width / heightconst orthoSize = 10 // 正交相机视口大小state.cameras.orthographic = new THREE.OrthographicCamera(-orthoSize * aspect, // leftorthoSize * aspect, // rightorthoSize, // top-orthoSize, // bottom0.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 / 2platform.receiveShadow = truescene.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 = truescene.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 = truescene.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 = truescene.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; breakcase 'KeyA': state.moveLeft = true; breakcase 'KeyS': state.moveBackward = true; breakcase 'KeyD': state.moveRight = true; break}}const onKeyUp = (event) => {switch (event.code) {case 'KeyW': state.moveForward = false; breakcase 'KeyA': state.moveLeft = false; breakcase 'KeyS': state.moveBackward = false; breakcase '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.01state.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) returnconst width = container.value.clientWidthconst height = container.value.clientHeight// 更新透视相机state.cameras.perspective.aspect = width / heightstate.cameras.perspective.updateProjectionMatrix()// 更新第一人称相机state.cameras.firstPerson.aspect = width / heightstate.cameras.firstPerson.updateProjectionMatrix()// 更新正交相机(保持宽高比)const orthoSize = 10const aspect = width / heightstate.cameras.orthographic.left = -orthoSize * aspectstate.cameras.orthographic.right = orthoSize * aspectstate.cameras.orthographic.top = orthoSizestate.cameras.orthographic.bottom = -orthoSizestate.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.near
和far
是否覆盖物体坐标。 - 确保
camera.lookAt()
指向场景内物体(而非空坐标)。
- 检查
3. 第一人称控制器视角抖动?
- 原因:移动速度过快或未限制 Y 轴位置。
- 解决:
- 降低移动速度(如
*0.1
缩放)。 - 固定 Y 轴高度(模拟地面行走,防止上下浮动)。
- 降低移动速度(如
4. 正交相机物体显示不全?
- 原因:
left/right/top/bottom
范围未覆盖场景物体。 - 解决:根据场景大小调整正交相机边界(如
orthoSize
值),确保包含所有物体。
四、专栏预告
下一篇将讲解 Three.js 的外部模型加载与优化,内容包括:
- 常见 3D 模型格式(glTF、OBJ、FBX)的加载方法。
- 模型压缩、纹理优化、层级管理等实战技巧。
- 实战:加载并控制一个带骨骼动画的人物模型。