Three.js 相机系统简介

引言

在 Three.js 的三维世界中,相机(Camera)是连接用户与三维场景的桥梁,它决定了我们如何观察和呈现虚拟世界。理解相机的原理和参数配置,对于创建沉浸式的 3D 体验至关重要。本文将深入剖析 Three.js 中的相机系统,涵盖透视相机、正交相机的工作原理、参数配置、使用场景以及最佳实践。

1. 相机基础概念

1.1 什么是相机?

在 Three.js 中,相机定义了三维空间到二维屏幕的投影方式。它决定了哪些对象可见、如何可见,以及观察者的视角。

1.2 相机类型概览

Three.js 提供了多种相机类型,最常用的是:

  • PerspectiveCamera(透视相机):模拟人眼视觉效果
  • OrthographicCamera(正交相机):无透视效果的平行投影
  • CubeCamera(立方体相机):用于环境映射
  • ArrayCamera(阵列相机):用于多视口渲染

2. 透视相机 (PerspectiveCamera)

2.1 构造函数与参数

new THREE.PerspectiveCamera(fov: number, aspect: number, near: number, far: number)

参数详解

fov (Field of View) - 视野角度

  • 类型: number
  • 单位: 度(degrees)
  • 范围: 通常 30° - 120°
  • 作用: 控制垂直方向的视野范围
  • 视觉效果:
    • 小角度(30°-50°): 望远镜效果,视野狭窄
    • 标准角度(60°-75°): 接近人眼自然视野
    • 大角度(90°-120°): 鱼眼效果,视野宽广

aspect (Aspect Ratio) - 纵横比

  • 类型: number
  • 计算: 渲染区域宽度 / 高度
  • 作用: 保持画面比例,防止拉伸变形
  • 最佳实践: 动态响应窗口尺寸变化
function updateCameraAspect(): void {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix(); // 必须调用!
}

near (Near Clipping Plane) - 近裁剪面

  • 类型: number
  • 作用: 定义距离相机多近的物体开始被渲染
  • 取值范围: 根据场景规模调整
  • 注意事项:
    • 值过小可能导致深度缓冲精度问题
    • 值过大会裁剪掉近处物体

far (Far Clipping Plane) - 远裁剪面

  • 类型: number
  • 作用: 定义距离相机多远的物体停止被渲染
  • 性能影响:
    • 值过大会影响深度精度,可能导致 Z-fighting
    • 值过小会裁剪掉远处物体
  • 优化建议: 根据实际可见范围设置合理值

2.2 透视相机示例代码

import * as THREE from 'three';

// 创建透视相机
const camera: THREE.PerspectiveCamera = new THREE.PerspectiveCamera(
    75,                                   // fov: 75度视野
    window.innerWidth / window.innerHeight, // aspect: 窗口比例
    0.1,                                  // near: 最近可见距离 0.1单位
    1000                                  // far: 最远可见距离 1000单位
);

// 设置相机位置和朝向
camera.position.set(0, 2, 5);
camera.lookAt(0, 0, 0);

// 响应窗口尺寸变化
const handleResize = (): void => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
};

window.addEventListener('resize', handleResize);

2.3 透视相机的使用场景

  • 3D 游戏和交互应用
  • 虚拟现实和增强现实
  • 建筑可视化和室内设计
  • 产品展示和电子商务
  • 数据可视化和科学模拟

3. 正交相机 (OrthographicCamera)

3.1 构造函数与参数

new THREE.OrthographicCamera(
    left: number, 
    right: number, 
    top: number, 
    bottom: number, 
    near: number, 
    far: number
)

参数详解

left, right, top, bottom - 边界参数

  • 类型: number
  • 作用: 定义视锥体的左右上下边界
  • 特点: 保持物体尺寸不随距离变化
  • 坐标系统: 原点通常在视口中心

near, far - 裁剪面

  • 作用: 与透视相机相同,定义渲染范围
  • 区别: 在正交投影中,距离不影响物体大小

3.2 正交相机配置方法

基于窗口尺寸的配置

const aspect: number = window.innerWidth / window.innerHeight;
const frustumSize: number = 10; // 可视区域高度

const camera: THREE.OrthographicCamera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,  // left
    frustumSize * aspect / 2,   // right
    frustumSize / 2,            // top
    -frustumSize / 2,           // bottom
    0.1,                        // near
    1000                        // far
);

基于像素坐标的配置

// 适用于 2D UI 和 HUD
const camera: THREE.OrthographicCamera = new THREE.OrthographicCamera(
    0,                    // left: 从画布左边缘开始
    window.innerWidth,    // right: 到画布右边缘
    window.innerHeight,   // top: 从画布上边缘开始
    0,                    // bottom: 到画布下边缘
    -100,                 // near
    100                   // far
);

3.3 正交相机示例代码

import * as THREE from 'three';

// 创建正交相机
const aspect: number = window.innerWidth / window.innerHeight;
const frustumSize: number = 5;

const camera: THREE.OrthographicCamera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,
    frustumSize * aspect / 2,
    frustumSize / 2,
    -frustumSize / 2,
    0.1,
    100
);

camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);

// 响应式更新
const handleResize = (): void => {
    const aspect: number = window.innerWidth / window.innerHeight;
    
    camera.left = -frustumSize * aspect / 2;
    camera.right = frustumSize * aspect / 2;
    camera.top = frustumSize / 2;
    camera.bottom = -frustumSize / 2;
    
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
};

window.addEventListener('resize', handleResize);

3.4 正交相机的使用场景

  • 2D 游戏和用户界面
  • CAD 和工程制图应用
  • 等距投影游戏
  • 数据图表和信息可视化
  • 文字渲染和 HUD 系统

4. 相机控制与动画

4.1 相机位置和朝向控制

基本位置设置

// 设置相机位置
camera.position.set(x, y, z);

// 设置相机朝向的三种方法

// 方法1: lookAt() 函数
camera.lookAt(targetPosition);

// 方法2: 设置 up 向量 + lookAt
camera.up.set(0, 1, 0);  // Y轴向上(默认)
camera.lookAt(targetPosition);

// 方法3: 手动设置旋转
camera.rotation.x = Math.PI / 4;  // 45度
camera.rotation.y = Math.PI / 6;  // 30度

相机动画示例

import * as THREE from 'three';

// 相机路径动画
const animateCamera = (): void => {
    const time: number = Date.now() * 0.001;
    
    // 圆周运动
    camera.position.x = Math.cos(time) * 8;
    camera.position.z = Math.sin(time) * 8;
    camera.position.y = 2 + Math.sin(time * 2) * 1;
    
    camera.lookAt(0, 0, 0);
};

// 平滑相机移动
class CameraController {
    private camera: THREE.Camera;
    private targetPosition: THREE.Vector3;
    private currentPosition: THREE.Vector3;

    constructor(camera: THREE.Camera) {
        this.camera = camera;
        this.targetPosition = new THREE.Vector3();
        this.currentPosition = camera.position.clone();
    }
    
    setTarget(x: number, y: number, z: number): void {
        this.targetPosition.set(x, y, z);
    }
    
    update(deltaTime: number): void {
        // 线性插值
        this.currentPosition.lerp(this.targetPosition, 0.1);
        this.camera.position.copy(this.currentPosition);
    }
}

4.2 相机控制系统

Three.js 提供了多种相机控制器:

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// OrbitControls - 轨道控制器
const controls: OrbitControls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;      // 启用阻尼效果
controls.dampingFactor = 0.05;     // 阻尼系数
controls.screenSpacePanning = false; // 限制在平面内平移
controls.minDistance = 5;          // 最小缩放距离
controls.maxDistance = 50;         // 最大缩放距离
controls.maxPolarAngle = Math.PI;  // 最大垂直角度

// FirstPersonControls - 第一人称控制器
import { FirstPersonControls } from 'three/examples/jsm/controls/FirstPersonControls';

const controls: FirstPersonControls = new FirstPersonControls(camera, renderer.domElement);
controls.movementSpeed = 100;      // 移动速度
controls.lookSpeed = 0.1;          // 视角移动速度

5. 高级相机技术

5.1 多相机系统

相机切换

import * as THREE from 'three';

interface CameraCollection {
    [key: string]: THREE.Camera;
}

class CameraManager {
    private cameras: CameraCollection;
    private activeCamera: THREE.Camera;

    constructor() {
        this.cameras = {
            main: new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000),
            top: new THREE.OrthographicCamera(-10, 10, 10, -10, 0.1, 1000),
            side: new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
        };
        
        this.activeCamera = this.cameras.main;
        this.setupCameras();
    }
    
    private setupCameras(): void {
        // 主相机
        this.cameras.main.position.set(0, 2, 5);
        (this.cameras.main as THREE.PerspectiveCamera).lookAt(0, 0, 0);
        
        // 顶视图相机
        this.cameras.top.position.set(0, 10, 0);
        (this.cameras.top as THREE.OrthographicCamera).lookAt(0, 0, 0);
        
        // 侧视图相机
        this.cameras.side.position.set(10, 2, 0);
        (this.cameras.side as THREE.PerspectiveCamera).lookAt(0, 0, 0);
    }
    
    switchCamera(name: string): void {
        if (this.cameras[name]) {
            this.activeCamera = this.cameras[name];
        }
    }
    
    getActiveCamera(): THREE.Camera {
        return this.activeCamera;
    }
}

画中画效果

import * as THREE from 'three';

function renderPictureInPicture(
    renderer: THREE.WebGLRenderer,
    scene: THREE.Scene,
    mainCamera: THREE.Camera,
    pipCamera: THREE.Camera
): void {
    // 清除画布
    renderer.clear();
    
    // 渲染主场景(全屏)
    renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
    renderer.render(scene, mainCamera);
    
    // 渲染小窗口场景
    const pipWidth: number = 300;
    const pipHeight: number = 200;
    const pipX: number = window.innerWidth - pipWidth - 20;
    const pipY: number = 20;
    
    renderer.setViewport(pipX, pipY, pipWidth, pipHeight);
    renderer.setScissor(pipX, pipY, pipWidth, pipHeight);
    renderer.setScissorTest(true);
    
    // 添加边框效果
    renderer.clearDepth(); // 只清除深度缓冲区
    
    renderer.render(scene, pipCamera);
    
    // 重置视口
    renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
    renderer.setScissorTest(false);
}

5.2 后处理与相机效果

import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';

// 使用 EffectComposer 添加后处理效果
const composer: EffectComposer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

const bloomPass: UnrealBloomPass = new UnrealBloomPass(
    new THREE.Vector2(window.innerWidth, window.innerHeight),
    1.5,   // 强度
    0.4,   // 半径
    0.85   // 阈值
);
composer.addPass(bloomPass);

// 在动画循环中使用 composer
const animate = (): void => {
    requestAnimationFrame(animate);
    composer.render();
};

6. 性能优化与最佳实践

6.1 相机参数优化

合理的裁剪面设置

import * as THREE from 'three';

// 不好的做法 - 范围过大
const badCamera: THREE.PerspectiveCamera = new THREE.PerspectiveCamera(75, aspect, 0.001, 1000000);

// 好的做法 - 根据场景需要设置
const goodCamera: THREE.PerspectiveCamera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);

// 动态调整裁剪面
function updateClippingPlanes(
    camera: THREE.PerspectiveCamera, 
    sceneBoundingBox: THREE.Box3
): void {
    const size: THREE.Vector3 = new THREE.Vector3();
    const center: THREE.Vector3 = new THREE.Vector3();
    
    sceneBoundingBox.getSize(size);
    sceneBoundingBox.getCenter(center);
    
    const maxDim: number = Math.max(size.x, size.y, size.z);
    const far: number = maxDim * 10;  // 适当的安全边际
    const near: number = maxDim / 1000;
    
    camera.near = Math.max(0.1, near);
    camera.far = Math.min(10000, far);
    camera.updateProjectionMatrix();
}

视锥体剔除

import * as THREE from 'three';

// 启用视锥体剔除
const frustum: THREE.Frustum = new THREE.Frustum();
const cameraViewProjectionMatrix: THREE.Matrix4 = new THREE.Matrix4();

function isInView(camera: THREE.Camera, mesh: THREE.Mesh): boolean {
    camera.updateMatrixWorld();
    cameraViewProjectionMatrix.multiplyMatrices(
        camera.projectionMatrix,
        camera.matrixWorldInverse
    );
    frustum.setFromProjectionMatrix(cameraViewProjectionMatrix);
    
    if (mesh.geometry.boundingBox) {
        return frustum.intersectsBox(mesh.geometry.boundingBox);
    }
    return true;
}

6.2 内存管理与清理

import * as THREE from 'three';

class CameraLifecycle {
    static disposeCamera(camera: THREE.Camera): void {
        // 移除所有事件监听器
        if ((camera as any).controls) {
            (camera as any).controls.dispose();
        }
        
        // 清除相机属性
        camera.position.set(0, 0, 0);
        camera.rotation.set(0, 0, 0);
        camera.scale.set(1, 1, 1);
        
        // 如果有自定义属性,也需要清理
        for (let key in camera) {
            if (camera.hasOwnProperty(key) && 
                !['uuid', 'type', 'id', 'parent', 'children'].includes(key)) {
                delete (camera as any)[key];
            }
        }
    }
}

7. 实战案例:第一人称相机系统

import * as THREE from 'three';

interface MoveState {
    forward: boolean;
    backward: boolean;
    left: boolean;
    right: boolean;
    up: boolean;
    down: boolean;
}

class FirstPersonCamera {
    private camera: THREE.Camera;
    private domElement: HTMLElement;
    
    private moveSpeed: number;
    private lookSpeed: number;
    
    private moveState: MoveState;
    private euler: THREE.Euler;
    private velocity: THREE.Vector3;
    
    constructor(camera: THREE.Camera, domElement: HTMLElement) {
        this.camera = camera;
        this.domElement = domElement;
        
        this.moveSpeed = 5;
        this.lookSpeed = 0.002;
        
        this.moveState = {
            forward: false,
            backward: false,
            left: false,
            right: false,
            up: false,
            down: false
        };
        
        this.euler = new THREE.Euler(0, 0, 0, 'YXZ');
        this.velocity = new THREE.Vector3();
        
        this.bindEvents();
    }
    
    private bindEvents(): void {
        document.addEventListener('keydown', this.onKeyDown.bind(this));
        document.addEventListener('keyup', this.onKeyUp.bind(this));
        document.addEventListener('mousemove', this.onMouseMove.bind(this));
        document.addEventListener('click', this.lockPointer.bind(this));
    }
    
    private onKeyDown(event: KeyboardEvent): void {
        switch (event.code) {
            case 'KeyW': this.moveState.forward = true; break;
            case 'KeyS': this.moveState.backward = true; break;
            case 'KeyA': this.moveState.left = true; break;
            case 'KeyD': this.moveState.right = true; break;
            case 'Space': this.moveState.up = true; break;
            case 'ShiftLeft': this.moveState.down = true; break;
        }
    }
    
    private onKeyUp(event: KeyboardEvent): void {
        switch (event.code) {
            case 'KeyW': this.moveState.forward = false; break;
            case 'KeyS': this.moveState.backward = false; break;
            case 'KeyA': this.moveState.left = false; break;
            case 'KeyD': this.moveState.right = false; break;
            case 'Space': this.moveState.up = false; break;
            case 'ShiftLeft': this.moveState.down = false; break;
        }
    }
    
    private onMouseMove(event: MouseEvent): void {
        if (!document.pointerLockElement) return;
        
        this.euler.y -= event.movementX * this.lookSpeed;
        this.euler.x -= event.movementY * this.lookSpeed;
        
        this.euler.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, this.euler.x));
    }
    
    private lockPointer(): void {
        (this.domElement as any).requestPointerLock();
    }
    
    update(deltaTime: number): void {
        // 更新相机旋转
        this.camera.quaternion.setFromEuler(this.euler);
        
        // 计算移动方向
        this.velocity.set(0, 0, 0);
        
        if (this.moveState.forward) this.velocity.z -= 1;
        if (this.moveState.backward) this.velocity.z += 1;
        if (this.moveState.left) this.velocity.x -= 1;
        if (this.moveState.right) this.velocity.x += 1;
        if (this.moveState.up) this.velocity.y += 1;
        if (this.moveState.down) this.velocity.y -= 1;
        
        // 应用相机朝向
        this.velocity.applyQuaternion(this.camera.quaternion);
        this.velocity.normalize().multiplyScalar(this.moveSpeed * deltaTime);
        
        this.camera.position.add(this.velocity);
    }
}

// 使用示例
const fpsCamera: FirstPersonCamera = new FirstPersonCamera(camera, renderer.domElement);
const clock: THREE.Clock = new THREE.Clock();

const animate = (): void => {
    requestAnimationFrame(animate);
    
    const deltaTime: number = clock.getDelta();
    fpsCamera.update(deltaTime);
    
    renderer.render(scene, camera);
};

animate();

8. 总结

Three.js 的相机系统提供了强大而灵活的工具来创建各种 3D 视觉效果。通过深入理解透视相机和正交相机的原理、参数配置和使用场景,开发者可以:

  1. 创建逼真的 3D 体验:通过合理配置透视相机参数
  2. 实现精确的 2D 渲染:利用正交相机的无透视特性
  3. 优化性能:通过合理的裁剪面和视锥体设置
  4. 增强交互性:结合相机控制器和自定义相机系统