在前几篇教程中,我们通过灯光与阴影提升了场景真实感,但如何 "观察" 场景同样关键 ------ 这正是相机系统的核心作用。Three.js 提供了多种相机类型,不同相机决定了场景的呈现视角(如人眼透视、工程图纸正交)。本篇将系统讲解相机的工作原理、参数配置,并通过实战实现 "多视角切换""第一人称漫游" 等常见需求,帮助开发者灵活控制场景观察方式。
一、相机系统核心知识
相机是 3D 场景的 "眼睛",其类型和参数直接决定渲染结果。Three.js 中最常用的是透视相机 和正交相机,二者的核心区别在于是否遵循 "近大远小" 的透视规律。
1. 透视相机(PerspectiveCamera)
模拟人眼观察世界的方式,物体近大远小,适合创建真实感场景(如游戏、产品展示)。
核心参数
javascript
new THREE.PerspectiveCamera(fov, aspect, near, far)
fov
(视场角):垂直方向的视野角度(单位:度),值越小视野越窄(类似望远镜),越大视野越宽(类似广角镜头),常用 60-75 度。aspect
(宽高比):相机视口的宽高比(通常为容器宽 / 高),比例失调会导致场景拉伸变形。near
(近裁剪面):相机能看到的最近距离,小于此值的物体不会被渲染。far
(远裁剪面):相机能看到的最远距离,大于此值的物体不会被渲染。
关键特性
- 必须保持
aspect = 容器宽/高
,否则画面会拉伸(如宽屏显示正方形变成矩形)。 near
和far
的差值不宜过大(如 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.near
和far
是否覆盖物体坐标。 - 确保
camera.lookAt()
指向场景内物体(而非空坐标)。
- 检查
3. 第一人称控制器视角抖动?
- 原因:移动速度过快或未限制 Y 轴位置。
- 解决:
- 降低移动速度(如
*0.1
缩放)。 - 固定 Y 轴高度(模拟地面行走,防止上下浮动)。
- 降低移动速度(如
4. 正交相机物体显示不全?
- 原因:
left/right/top/bottom
范围未覆盖场景物体。 - 解决:根据场景大小调整正交相机边界(如
orthoSize
值),确保包含所有物体。
四、专栏预告
下一篇将讲解 Three.js 的外部模型加载与优化,内容包括:
- 常见 3D 模型格式(glTF、OBJ、FBX)的加载方法。
- 模型压缩、纹理优化、层级管理等实战技巧。
- 实战:加载并控制一个带骨骼动画的人物模型。