摄像机

本文是关于 three.js 系列文章的一部分。第一篇文章是 three.js 基础。如果你还没看过而且对three.js 还不熟悉,那应该从那里开始,并且了解如何设置开发环境。上一篇文章介绍了 three.js 中的 纹理

我们开始谈谈three.js中的摄像机. 我们已经在第一篇文章 中涉及到了摄像机的一些知识, 这里我们要更深入一些.

在three.js中最常用的摄像机并且之前我们一直用的摄像机是透视摄像机 PerspectiveCamera, 它可以提供一个近大远小的3D视觉效果.

PerspectiveCamera 定义了一个 视锥frustum. frustum 是一个切掉顶的三角锥或者说实心金字塔型. 说到实心体solid, 在这里通常是指一个立方体, 一个圆锥, 一个球, 一个圆柱或锥台.

立方体
圆锥
圆柱
锥台

重新讲一遍这些东西是因为我好久没有在意过了. 很多书或者文章提到锥台这个东西的时候我扫一眼就过去了. 再了解一下不同几何体会让下面的一些表述变得更为感性...吧😅

PerspectiveCamera通过四个属性来定义一个视锥. near定义了视锥的前端, far定义了后端, fov是视野, 通过计算正确的高度来从摄像机的位置获得指定的以near为单位的视野, 定义的是视锥的前端和后端的高度. aspect间接地定义了视锥前端和后端的宽度, 实际上视锥的宽度是通过高度乘以aspect来得到的.

我们借用上一篇文章的场景. 其中包含一个地平面, 一个球和一个立方体, 我们可以在其中调整摄像机的设置. · 我们通过MinMaxGUIHelper来调整near, far的设置. 显然near应该总是比far要小. lil-gui有minmax两个属性可调, 然后这两个属性将决定摄像机的设置.

class MinMaxGUIHelper {
  constructor(obj, minProp, maxProp, minDif) {
    this.obj = obj;
    this.minProp = minProp;
    this.maxProp = maxProp;
    this.minDif = minDif;
  }
  get min() {
    return this.obj[this.minProp];
  }
  set min(v) {
    this.obj[this.minProp] = v;
    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
  }
  get max() {
    return this.obj[this.maxProp];
  }
  set max(v) {
    this.obj[this.maxProp] = v;
    this.min = this.min;  // 这将调用min的setter
  }
}

现在我们可以将GUI设置为

function updateCamera() {
  camera.updateProjectionMatrix();
}

const gui = new GUI();
gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);

任何时候摄像机的设置变动, 我们需要调用摄像机的updateProjectionMatrix来更新设置. 我们写一个函数updataCamera, 当lil-gui改变了属性的时候会调用它来更新参数.

现在可以调整这些数值来观察这些参数是如何影响摄像机的. 注意我们并没有改变aspect, 因为这个参数来自于窗口的大小. 如果想调整aspect, 只需要开个新窗口然后调整窗口大小就可以了.

即便是这样, 观察参数对视野的影响还是挺麻烦的. 所以我们来设置两台摄像机吧! 一台是跟上面一样展现出摄像机中看到的实际场景, 另一个则是用来观察这个实际工作的摄像机, 然后画出摄像机的视锥.

我们需要用到three.js的剪函数(scissor function)来画两个场景和两个摄像机.

首先让我们用HTML和CSS来定义两个肩并肩的元素. 这也将帮助我们将两个摄像机赋予不同的OrbitControls.

<body>
  <canvas id="c"></canvas>
+  <div class="split">
+     <div id="view1" tabindex="1"></div>
+     <div id="view2" tabindex="2"></div>
+  </div>
</body>

CSS将控制两个视窗并排显示在canvas中

.split {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
}
.split>div {
  width: 100%;
  height: 100%;
}

接下来将添加一个CameraHelper, 它可以把摄像机的视锥画出来

const cameraHelper = new THREE.CameraHelper(camera);

...

scene.add(cameraHelper);

我们现在需要查找到刚刚定义的两个元素

const view1Elem = document.querySelector('#view1');
const view2Elem = document.querySelector('#view2');

现在只给第一个视窗中的摄像机分配OrbitControls

-const controls = new OrbitControls(camera, canvas);
+const controls = new OrbitControls(camera, view1Elem);

我们定义第二个PerspectiveCameraOrbitControls.

const camera2 = new THREE.PerspectiveCamera(
  60,  // fov
  2,   // aspect
  0.1, // near
  500, // far
);
camera2.position.set(40, 10, 30);
camera2.lookAt(0, 5, 0);

const controls2 = new OrbitControls(camera2, view2Elem);
controls2.target.set(0, 5, 0);
controls2.update();

最后我们需要

最后,我们需要使用剪刀功能从每个摄影机的视角渲染场景,以仅渲染画布的一部分。 这个函数接受一个元素, 计算这个元素在canvas上的重叠面积, 这将设置剪刀函数和视角长宽并返回aspect

function setScissorForElement(elem) {
  const canvasRect = canvas.getBoundingClientRect();
  const elemRect = elem.getBoundingClientRect();

  // 计算canvas的尺寸
  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
  const left = Math.max(0, elemRect.left - canvasRect.left);
  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
  const top = Math.max(0, elemRect.top - canvasRect.top);

  const width = Math.min(canvasRect.width, right - left);
  const height = Math.min(canvasRect.height, bottom - top);

  // 设置剪函数以仅渲染一部分场景
  const positiveYUpBottom = canvasRect.height - bottom;
  renderer.setScissor(left, positiveYUpBottom, width, height);
  renderer.setViewport(left, positiveYUpBottom, width, height);

  // 返回aspect
  return width / height;
}

我们用这个函数在render中绘制两遍场景

  function render() {

-    if (resizeRendererToDisplaySize(renderer)) {
-      const canvas = renderer.domElement;
-      camera.aspect = canvas.clientWidth / canvas.clientHeight;
-      camera.updateProjectionMatrix();
-    }

+    resizeRendererToDisplaySize(renderer);
+
+    // 启用剪刀函数
+    renderer.setScissorTest(true);
+
+    // 渲染主视野
+    {
+      const aspect = setScissorForElement(view1Elem);
+
+      // 用计算出的aspect修改摄像机参数
+      camera.aspect = aspect;
+      camera.updateProjectionMatrix();
+      cameraHelper.update();
+
+      // 来原视野中不要绘制cameraHelper
+      cameraHelper.visible = false;
+
+      scene.background.set(0x000000);
+
+      // 渲染
+      renderer.render(scene, camera);
+    }
+
+    // 渲染第二台摄像机
+    {
+      const aspect = setScissorForElement(view2Elem);
+
+      // 调整aspect
+      camera2.aspect = aspect;
+      camera2.updateProjectionMatrix();
+
+      // 在第二台摄像机中绘制cameraHelper
+      cameraHelper.visible = true;
+
+      scene.background.set(0x000040);
+
+      renderer.render(scene, camera2);
+    }

-    renderer.render(scene, camera);

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

上面的代码还将主辅摄像机的背景色区分开以利观察.

我们可以移除updateCamera了, 因为所有的东西在render中更新过了.

-function updateCamera() {
-  camera.updateProjectionMatrix();
-}

const gui = new GUI();
-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
+gui.add(camera, 'fov', 1, 180);
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');

现在我们就可以在辅摄像机中观察到主摄像机的视锥轮廓了.

左侧可以看到主摄像机的视角, 右侧则是辅摄像机观察主摄像机和主摄像机的视锥轮廓. 可以调整near, far, fov和用鼠标移动摄像机来观察视锥轮廓和场景之间的关系.

near调整到大概20左右, 前景就会在视锥中消失. far低于35时, 远景也不复存在.

这带来一个问题, 为什么不把near设置到0.0000000001然后将far设置成100000000, 使得一切都可以尽收眼底? 原因是你的GPU 8太行, 没有足够的精度来决定什么在前什么在后. 更糟的是, 在默认情况下, 离摄像机近的将会更清晰, 远的模糊, 从nearfar逐渐过渡.

从上面的例子出发, 我们向场景中添加20个球

{
  const sphereRadius = 3;
  const sphereWidthDivisions = 32;
  const sphereHeightDivisions = 16;
  const sphereGeo = new THREE.SphereBufferGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
  const numSpheres = 20;
  for (let i = 0; i < numSpheres; ++i) {
    const sphereMat = new THREE.MeshPhongMaterial();
    sphereMat.color.setHSL(i * .73, 1, 0.5);
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
    scene.add(mesh);
  }
}

near 设置成0.00001

const fov = 45;
const aspect = 2;  // canvas 默认
-const near = 0.1;
+const near = 0.00001;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

调整一下GUI使得能设置到0.00001

-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);

你觉得会发生什么?

这就是一个典型的z冲突的例子. GPU没有足够的精度来决定哪个像素在前哪个在后.

以防你的机器太好出现不了我说的情况, 我把我看到的截图放在这

解决的方法之一是告诉three.js使用不同的方法计算像素的前后关系. 我们可以在创建WebGLRenderer时开启logarithmicDepthBuffer

-const renderer = new THREE.WebGLRenderer({canvas});
+const renderer = new THREE.WebGLRenderer({
+  canvas,
+  logarithmicDepthBuffer: true,
+});

这看起来就行了

如果这不行的话, 那你就遇到了为什么不能无脑使用这种解决方案的情况了. 到2018年9月, 绝大多数台式机可以但是几乎没有移动设备支持这个功能.

另一个最好别用这种解决方案的原因是这会大大降低运行速度.

即便是现在跑得好好地, 选择太小的near和太大的far最终也会遇到同样的问题.

所以说你需要选择好好抉择nearfar的设置, 来和你的场景配合. 既不丢失重要的近景, 也不让远处的东西消失不见. 如果你想渲染一个巨大的场景, 不但能看清面前的人的眼睫毛又想看到50公里以外的玩意, 你得自己想一个厉害的方案, 这里就不涉及了. 现在, 好好地选个需要的参数就行.

第二种常见的摄像机是正交摄像机 OrthographicCamera, 和指定一个视锥不同的是, 它需要设置left, right top, bottom, near, 和far指定一个长方体, 使得视野是平行的而不是透视的.

我们来把上面的例子改成OrthographicCamera, 首先来设置摄像机

const left = -1;
const right = 1;
const top = 1;
const bottom = -1;
const near = 5;
const far = 50;
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
camera.zoom = 0.2;

我们将leftbottom设置成 -1 righttop设成 1, 这样就使盒子宽为两个单位, 高两个单位. 我们接下来通过调整lefttop来选择其aspect. 我们将用zoom属性来调整相机到底展现多少的单位大小.

给GUI添加zoom设置

const gui = new GUI();
+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();

listen调用告诉lil-gui去监视属性的变化. 写在这里是因为OrbitControls同样可以控制缩放. 在这个例子中, 鼠标滚轮将会通过OrbitControls控件来控制缩放.

最后更改aspect然后更新摄像机

{
  const aspect = setScissorForElement(view1Elem);

  // 使用aspect更新摄像机
-  camera.aspect = aspect;
+  camera.left   = -aspect;
+  camera.right  =  aspect;
  camera.updateProjectionMatrix();
  cameraHelper.update();

  // 在主摄像机中不绘制视野辅助线
  cameraHelper.visible = false;

  scene.background.set(0x000000);
  renderer.render(scene, camera);
}

现在就可以看到OrthographicCamera工作了.

大多数情况下, 绘制2D图像的时候会用到OrthographicCamera. 你可以自己决定摄像机的视野大小. 比如说你想让canvas的一个像素匹配摄像机的一个单位, 你可以这么做

将原点置于中心, 令一个像素等于一个单位

camera.left = -canvas.width / 2;
camera.right = canvas.width / 2;
camera.top = canvas.height / 2;
camera.bottom = -canvas.height / 2;
camera.near = -1;
camera.far = 1;
camera.zoom = 1;

或者如果我们想让原点在左上, 就像是2D canvas

camera.left = 0;
camera.right = canvas.width;
camera.top = 0;
camera.bottom = canvas.height;
camera.near = -1;
camera.far = 1;
camera.zoom = 1;

这样左上角就成了0,0

试试, 这样设置摄像机

const left = 0;
const right = 300;  // 默认的canvas大小
const top = 0;
const bottom = 150;  // 默认的canvas大小
const near = -1;
const far = 1;
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
camera.zoom = 1;

然后我们载入六个材质, 生成六个平面, 一一对应. 把每一个平面绑定到父对象THREE.Object3D上, 以便调整每个平面和左上角原点的相对关系

const loader = new THREE.TextureLoader();
const textures = [
  loader.load('resources/images/flower-1.jpg'),
  loader.load('resources/images/flower-2.jpg'),
  loader.load('resources/images/flower-3.jpg'),
  loader.load('resources/images/flower-4.jpg'),
  loader.load('resources/images/flower-5.jpg'),
  loader.load('resources/images/flower-6.jpg'),
];
const planeSize = 256;
const planeGeo = new THREE.PlaneBufferGeometry(planeSize, planeSize);
const planes = textures.map((texture) => {
  const planePivot = new THREE.Object3D();
  scene.add(planePivot);
  texture.magFilter = THREE.NearestFilter;
  const planeMat = new THREE.MeshBasicMaterial({
    map: texture,
    side: THREE.DoubleSide,
  });
  const mesh = new THREE.Mesh(planeGeo, planeMat);
  planePivot.add(mesh);
  // 调整平面使得左上角为原点
  mesh.position.set(planeSize / 2, planeSize / 2, 0);
  return planePivot;
});

然后当canvas更新后我们更新摄像机设置

function render() {

  if (resizeRendererToDisplaySize(renderer)) {
    camera.right = canvas.width;
    camera.bottom = canvas.height;
    camera.updateProjectionMatrix();
  }

  ...

planesTHREE.Mesh的数组, 每一个对应一个平面. 现在让它随着时间移动

function render(time) {
  time *= 0.001;  // 转换为秒;

  ...

  const distAcross = Math.max(20, canvas.width - planeSize);
  const distDown = Math.max(20, canvas.height - planeSize);

  // 来回运动的总距离
  const xRange = distAcross * 2;
  const yRange = distDown * 2;
  const speed = 180;

  planes.forEach((plane, ndx) => {
    // 为每个平面单独计算时间
    const t = time * speed + ndx * 300;

    // 在0到最远距离之间获取一个值
    const xt = t % xRange;
    const yt = t % yRange;

    // 0到距离的一半, 向前运动
    // 另一半的时候往回运动
    const x = xt < distAcross ? xt : xRange - xt;
    const y = yt < distDown   ? yt : yRange - yt;

    plane.position.set(x, y, 0);
  });

  renderer.render(scene, camera);

你可以看到图片在其中弹跳, 和边际完美契合, 就是2D canvas的效果一样

另一个常见的用途是用OrthographicCamera来展示模型的三视图.

上面的截图展示了一个透视图和三个正交视角.

这就是摄像机的基础. 我们在其他的文章中会介绍另外的一些摄像机用法. 现在, 我们移步到阴影.