经纬志
首页 / three.js / 教程

相机与控制器

先看结论

相机决定看到什么,控制器决定怎么移动。三维 GIS 场景里, 这两者的默认值几乎都要改:俯仰角、缩放范围、拾取射线都依赖它们。

最后验证 2026-11-03 · three.js 0.160.0 · 难度:入门 · 核验 2026-10-29 · 引用本文

四个参数决定画面

参数含义常见取值影响
fov垂直视野角度45–60越大透视变形越明显
aspect画布宽高比宽 / 高窗口变化时必须更新
near最近可见距离0.1–1 米过小会损失深度精度
far最远可见距离按场景尺度,如 20000过大会导致远处闪烁

near 与 far 的比值越大,深度缓冲的有效精度越低。 城市级场景常见的做法是把 far 压到刚好覆盖可视范围, 而不是直接写 1e9。

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const camera = new THREE.PerspectiveCamera(
  50,                                       // fov
  window.innerWidth / window.innerHeight,   // aspect
  0.5,                                      // near
  20000                                     // far
);
camera.position.set(320, 180, 420);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(320, 20, 300);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 40;
controls.maxDistance = 6000;
controls.maxPolarAngle = THREE.MathUtils.degToRad(85);   // 不允许转到地下
controls.update();

enableDamping 开启后必须每帧调用 controls.update(), 否则阻尼不生效,画面在松手后立即停住。

OrbitControls 与 MapControls

两者内部是同一个类,只是默认手势映射不同。GIS 场景通常更接近 MapControls:左键平移、右键旋转。

import { MapControls } from 'three/addons/controls/MapControls.js';

const mapControls = new MapControls(camera, renderer.domElement);

mapControls.enableRotate = true;
mapControls.screenSpacePanning = false;   // 平移贴着地面,而不是屏幕平面
mapControls.zoomSpeed = 0.8;
mapControls.rotateSpeed = 0.5;
mapControls.keyPanSpeed = 12;

// 限制在某个投影范围内
mapControls.minTargetRadius = 0;
mapControls.maxTargetRadius = 2500;

平移方向反了先看 screenSpacePanning。设为 true 时平移在屏幕平面内进行,设为 false 时沿地面移动。 地图类场景一般用后者。

屏幕点击转成三维坐标

Raycaster 用归一化设备坐标构造射线,与场景中的对象求交。 拿到的 point 是世界坐标,可以直接在上面放标记。

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

renderer.domElement.addEventListener('pointerdown', function (event) {
  const rect = renderer.domElement.getBoundingClientRect();

  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);

  const hits = raycaster.intersectObjects(pickables, false);
  if (hits.length === 0) {
    // 没打中模型时,与地面平面求交
    const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), -40);
    const point = new THREE.Vector3();
    if (raycaster.ray.intersectPlane(ground, point)) {
      console.log('地面点', point);
    }
    return;
  }

  console.log('命中', hits[0].object.name, hits[0].point);
});

射线求交依赖对象的 matrixWorld 与几何的包围体。 刚创建还没渲染的对象要先调用 scene.updateMatrixWorld(), 否则射线会按旧位置计算。

相机补间

没有内置的相机动画类,最直接的做法是按帧做线性插值或缓动, 在接近目标时停止。

const camTween = {
  active: false,
  fromPos: new THREE.Vector3(),
  toPos: new THREE.Vector3(),
  fromTarget: new THREE.Vector3(),
  toTarget: new THREE.Vector3(),
  t: 0,
  duration: 1.2
};

function flyTo(position, target) {
  camTween.fromPos.copy(camera.position);
  camTween.toPos.copy(position);
  camTween.fromTarget.copy(controls.target);
  camTween.toTarget.copy(target);
  camTween.t = 0;
  camTween.active = true;
}

function tickTween(dt) {
  if (!camTween.active) return;

  camTween.t = Math.min(camTween.t + dt / camTween.duration, 1);
  const k = 1 - Math.pow(1 - camTween.t, 3);   // easeOutCubic

  camera.position.lerpVectors(camTween.fromPos, camTween.toPos, k);
  controls.target.lerpVectors(camTween.fromTarget, camTween.toTarget, k);

  if (camTween.t >= 1) camTween.active = false;
}

注意事项

  • 控制器接管后会修改相机位置,手动设置 camera.position 前先关掉或暂停控制器。
  • 窗口尺寸变化时同时更新 camera.aspect 与 camera.updateProjectionMatrix()。
  • raycaster.intersectObjects 的第二个参数为 true 时会递归子对象,性能开销更大。
  • 拾取对象过多时,先用包围盒粗筛,或把不参与拾取的对象放进独立的组并跳过。
  • 正交相机用于二维视图或示意图,不参与透视变形,尺寸用 left/right/top/bottom 控制。
引用本文

经纬志(2026)。《相机与控制器》。https://www.jingweizhi.com/three/tutorial/camera-controls.html(访问日期 2026-11-03)。