使用相机控制插件扩展three.js
three.js核心是一个功能强大、轻量级且专注的渲染框架,具有故意限制的功能。它拥有创建和渲染物理上正确的场景所需的一切,但是,它不具备创建游戏或产品配置器所需的一切。即使在构建相对简单的应用程序时,您也会经常发现自己需要的功能不在核心库中。发生这种情况时,在您自己编写任何代码之前,请检查是否有可用的插件。three.js仓库包含数百个扩展,位于 examples/jsm文件夹中。对于那些使用包管理器的人,这些也包含在 NPM 包中。
还有大量的插件散布在网络上。但是,这些有时维护不善,可能无法与最新的three.js版本一起使用,因此在本书中,我们将限制自己使用来自仓库的官方插件。在那里,我们会找到各种插件,其中大部分都在其中某一个 示例中展示。这些插件添加了各种功能,例如镜面:
或者,Lego LDraw格式的加载器怎么样:
这里还有一些:
每个扩展都存储在 examples/jsm 中的一个单独模块中,要使用它们,我们只需将它们导入我们的应用程序,就像任何其他three.js类一样。
我们的第一个插件:OrbitControls
最受欢迎的扩展之一是
OrbitControls
相机控制插件,它允许您使用触摸、鼠标或键盘来环绕、平移和缩放相机。通过这些控件,我们可以从各个角度查看场景,放大以检查微小细节,或缩小以鸟瞰概览。轨道控制允许我们以三种方式控制相机:
- 使用鼠标左键或单指轻扫,围绕固定点旋转。
- 使用鼠标右键、箭头键或两指滑动来平移相机。
- 使用滚轮或捏合手势缩放相机。
您可以在three.js仓库中的 examples/jsm/controls/ 文件夹中的名为
OrbitControls.js 的文件中找到包含OrbitControls
的模块。还有一个
官方示例展示OrbitControls
。 要快速参考所有控件的设置和功能,请转到
OrbitControls
文档页面。
导入插件
由于插件是three.js仓库的一部分并包含在NPM包中,因此导入它们的工作方式与从 three.js核心导入类的方式大致相同,只是每个插件都在一个单独的模块中。请参阅 0.5:如何在您的项目中包含three.js以提醒您如何在您的应用程序中包含three.js文件,或转到 A.4:JavaScript模块以更深入地探索JavaScript模块的工作原理。
在编辑器中,我们将 OrbitControls.js 文件放在repo的等效目录中,在 vendor/ 下。继续并立即找到该文件。由于编辑器使用NPM模式导入,我们可以像这样从代码中的任何位置导入OrbitControls
,如下所示:
同样的,如果您在本地开发而不使用捆绑程序,则必须更改导入路径。例如,您可以改为从skypack.dev导入。
重要提示:确保从 examples/jsm/ 导入插件,而不是从 examples/js/ 导入旧插件!
controls.js 模块
像往常一样,我们将在我们的应用程序中创建一个新模块来处理设置控件。由于控件在相机上运行,因此它们将进入
系统分类。打开或创建模块 systems/controls.js 来处理设置相机控件。这个新模块与我们大多数其他模块具有相同的结构。首先导入OrbitControls
类,然后添加createControls
函数,最后导出函数:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
function createControls() {}
export { createControls };
回到World中,将新函数添加到导入列表中:
import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createLights } from './components/lights.js';
import { createScene } from './components/scene.js';
import { createControls } from './systems/controls.js';
import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';
import { Loop } from './systems/Loop.js';
接下来,调用函数并将结果存储在名为controls
的变量中。当你在这里时,注释掉添加cube
到updatables
数组中的行。这将阻止立方体旋转并使控件的效果更容易看到:
constructor() {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
const controls = createControls();
const cube = createCube();
const light = createLights();
// disabled mesh rotation
// updatables.push(cube);
scene.add(cube, light);
this.canvas = renderer.domElement;
}
初始化控件
如果您查看
OrbitControls
文档页面,您会看到构造函数有两个参数:Camera
和
HTMLDOMElement
。我们将使用相机作为第一个参数,使用存储在renderer.domElement
中的画布作为第二个参数。
在内部,OrbitControls
使用addEventListener
监听用户输入。控件将侦听诸如click
、wheel
、touchmove
和keydown
等事件,并使用这些事件来移动相机。我们之前在设置自动调整大小时使用此方法来
监听resize
事件。在那里,我们在整个window
上监听resize
事件。而在这里,控件将监听我们作为第二个参数传入的元素上的用户输入。页面的其余部分将不受影响。换句话说,在我们传入画布后,当鼠标/触摸在画布上时控件将起作用,但页面的其余部分将继续正常工作而不受影响。
将相机和画布传递给createControls
函数,然后创建控件controls:
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas);
return controls;
}
回到world模块,传入camera
和renderer.domElement
:
constructor(container) {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
// ...
}
有了这个,控件controls应该开始工作。带他们去兜风吧!
您会立即注意到 立方体没有从背面照亮。我们将在下一章解释为什么以及如何解决这个问题。
使用控件Controls
手动设置目标
默认情况下,控件围绕场景中心旋转,即点$(0,0,0)$。 这存储在controls.target
属性中,即Vector3
。我们可以将这个目标移动到一个新的位置:
我们还可以通过复制对象的位置来将控件指向对象。
每当您平移控件(使用鼠标右键)时,目标也会平移。如果需要固定目标,可以使用controls.enablePan = false
禁用平移。
启用阻尼以增加真实感
一旦用户停止与场景交互,相机就会突然停止。现实世界中的物体是有惯性的,永远不会像这样突然停止,所以我们可以通过启用 阻尼来使控制感觉更真实。
启用阻尼后,控件将在几帧后减速停止,这给它们一种重量感。您可以调整
.dampingFactor
以控制相机停止的速度。但是,为了使阻尼起作用,我们必须在动画循环中的每一帧都调用controls.update
。如果我们是
按需渲染帧而不是使用循环,我们就不能使用阻尼。
更新动画循环中的控件
每当我们需要在循环中更新一个对象时,我们将使用我们在创建
立方体动画时设计的技术。换句话说,我们将给控件一个.tick
方法,然后将它们添加到loop.updatables
数组中。首先是.tick
方法:
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas);
// damping and auto rotation require
// the controls to be updated each frame
// this.controls.autoRotate = true;
controls.enableDamping = true;
controls.tick = () => controls.update();
return controls;
}
在这里,.tick
只需调用controls.update
。接下来,将控件添加到updatables
数组中:
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
const cube = createCube();
const light = createLights();
loop.updatables.push(controls);
// stop the cube's animation
// loop.updatables.push(cube);
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
}
现在,controls.tick
将在
更新循环中每帧调用一次,并且阻尼将起作用。测试一下。你能看到区别么?
在使用OrbitControls
时让相机工作
控件controls就位后,我们将相机的控制权交给了他们。但是,有时您需要收回控制权以手动定位相机。有两种方法可以解决这个问题:
- 剪切/跳转到新的摄像机位置
- 平滑动画到新的相机位置
我们将简要介绍一下您将如何处理这两个问题,但我们不会将代码添加到我们的应用程序中。
剪切到新的摄像机位置
要执行相机剪切,请照常更新相机的变换,然后调用controls.update
:
如果您在循环中调用.update
,则无需手动操作,只需移动相机即可。如果你 不 调用.update
就移动相机,会发生奇怪的事情,所以要小心!
这里需要注意一件重要的事情:当您移动相机时,controls.target
不会移动。如果您没有移动它,它将保持在场景的中心。当您将相机移动到新位置但保持目标不变时,相机不仅会移动,还会旋转,以便继续指向目标。这意味着在使用控件时,相机移动可能无法按预期工作。通常,您需要同时移动相机和目标以获得所需的结果。
平滑过渡到新的相机位置
如果您想将相机平滑地动画移动到一个新位置,您可能需要同时转换相机和目标,而最好的做这件事的地方就是controls.tick
方法中。但是,您需要在动画期间禁用控件,否则,如果用户在动画完成之前尝试移动相机,您最终会遇到与动画冲突的控件,通常会导致灾难性的后果。
保存和恢复视图状态
您可以使用
.saveState
保存当前视图,然后使用
.reset
恢复它:
如果我们在没有先调用.saveState
的情况下调用.reset
,相机将跳回到我们创建控件时的位置。
销毁控件Controls
如果不再需要控件,可以使用 .dispose清理它们,这将从画布中删除控件创建的所有事件侦听器。
使用OrbitControls
按需渲染
几章前我们设置了 动画循环,这是一个强大的工具,可以让我们轻松创建漂亮的动画。另一方面,正如我们在那几章末尾所讨论的那样, 循环确实有一些缺点,例如增加移动设备上的电池耗电量。因此,有时我们会选择按需渲染帧,而不是使用循环生成恒定的帧流。
现在我们的应用有了轨道控件,每当用户与你的场景交互时,控件都会将相机移动到一个新的位置,当这种情况发生时你必须绘制一个新的帧,否则你将无法看到相机已移动。如果您使用的是动画循环,那不是问题。但是,如果我们是按需渲染,我们将不得不想出其他办法来解决这个问题。
幸运的是,OrbitControls
提供了一种在相机移动时生成新帧的简单方法。控件有一个自定义事件change
,我们可以使用
addEventListener
来监听。每当用户交互导致控件移动相机时,都会触发此事件。
要使用轨道控件按需渲染,您必须在此事件触发时渲染一帧:
要在 World.js 中进行设置,您将使用this.render
:
接下来,在 main.js 中,确保我们不再启动循环。相反,渲染初始帧:
// render the inital frame
world.render();
如果您在应用程序中进行这些更改,您会发现这会导致一个小问题。当我们在 main.js 中渲染初始帧时,纹理还没有加载,所以立方体看起来是黑色的。如果我们运行循环,则在纹理加载后,这一帧几乎会立即被新帧替换,因此只有在几毫秒内立方体是黑色的甚至可能都不会引起注意。然而,通过按需渲染,我们现在只在用户与场景交互和移动相机时生成新帧。一旦您移动控件,果然,将创建一个新帧并显示纹理。
因此,您还需要在纹理加载后生成一个新帧。我们不会在这里介绍如何做到这一点,但希望它能强调为什么按需渲染比使用循环更棘手。您必须考虑需要新帧的所有情况(例如,不要忘记您还需要在 resize时渲染一帧)。
OrbitControls
配置
控件有很多选项,可让我们根据需要进行调整。其中大部分 在docs中有很好的解释,所以我们不会在这里详尽地介绍它们。以下是一些最重要的。
启用或禁用控件
我们可以完全 启用或禁用控件:
或者,我们可以单独禁用三种控制模式中的任何一种:
您可以选择监听按键事件并使用箭头键平移相机:
自动旋转
.autoRotate
将使相机自动围绕.target
旋转,然后
.autoRotateSpeed
控制速度:
与.enableDamping
一样,您必须在每一帧都调用controls.update
才能使其正常工作。请注意,如果控件被禁用,.autoRotate
仍然可以工作。
限制缩放
我们可以限制控件放大或缩小的距离:
确保minDistance
不小于
相机的近剪裁平面且maxDistance
不大于
相机的远剪裁平面。此外,minDistance
必须小于maxDistance
。
限制旋转
我们可以限制控件的水平旋转(方位角):
…和垂直(极角)
请记住, 旋转是使用弧度指定的,而不是度数,并且$\pi$弧度等于$180^{\circ}$。
一个明显的问题!
一旦我们使用我们花哨的新轨道控件旋转相机,我们就会看到一个明显的问题。相机旋转,但光线是固定的,只从一个方向照射。立方体的背面完全没有光线!
在现实世界中,光线会从每个表面反弹并反射掉,因此立方体的后部会昏暗。在这个简单的场景中,除了立方体之外什么都没有,所以光线不会反弹。但是,即使有,实时执行这些计算对于我们来说也太昂贵了。在下一章中,我们将研究一种用于克服这个问题的技术,即环境光。