Three.js学习指南(一)

1,903 阅读9分钟

Three.js是一个3D库,能让我们尽可能轻松地在网页中使用3D内容。

Three.js经常被人们和WebGL混淆,然而通常情况下(不完全是),Three.js是使用WebGL去绘制3D的。WebGL是一个非常底层的系统,它只用来绘制点、线、三角形。要使用WebGL去做一些有意思的东西的话,需要写很多很多代码,于是Three.js就诞生了。它能直接处理很多你在WebGL中需要自己去写代码的东西,比如:场景(Scenes)、灯光(Lights)、阴影(Shadows)、材质(Materials)、贴图(Textures)、3d计算(3d Math)等。

本系列默认你已经掌握JavaScript,很大部分会使用ES6的语法。现在大部分支持Three.js的浏览器都会自动更新,所以很大部分的用户都能够运行这些代码。如果你实在想在比较老的浏览器运行,你可以查询一些编译器,比如babel。事实上使用很老的浏览器的电脑应该也不能运行Three.js。

学一门新的语言的时候,人们都喜欢让电脑打印出"Hello World"。而学习3D,通常第一件事就是去制造一个3D立方体。我们就从"Hello Cube"开始吧!

在开始之前,我们了解一下Three.js应用的大致结构。一个Three.js应用需要你去创建一些对象,然后将它们连接在一起。下面这张图就代表了一个小的Three.js应用。 对于上面这张图,我们需要注意:

  • Renderer(渲染器)。这是在Three.js中最重要的对象。你传递一个Scene和一个Camera给一个Renderer,然后它会将在Camera的截锥内的部分3D场景作为2D图像渲染在canvas中。

  • 整个场景图就像一个树形结构,包括了多种对象:Scene对象、多种Mesh对象、Light对象、组、3D对象、Camera对象。一个Scene对象定义了整个场景图的根,包含了像background、fog这样的属性。这些对象定义了一个具有父子层级的树状结构,并且表示了这些对象的位置以及其方向。子节点的位置和方向都是相对于父节点的。就像轮胎与汽车的关系,当改变汽车的位置和方向时,轮胎的位置和方向也会自动改变。了解更多场景图的信息,可以查看the article on scenegraphs

  • 注意在图中,Camera是一半在图中一半在图外的。这表示在Three.js中,Camera不像其他对象,他不一定需要在场景图中才能工作。但Camera的位置和方向同样是相对于其父对象的。在the article on scenegraphs中的最后有放置多个Camera对象的例子。

  • Mesh对象表示用特定的Material去绘制特定的Geometry。Material对象和Geometry对象都可以被多个Mesh对象使用。比如在不同的位置去绘制两个蓝色的立方体,我们可以用两个Mesh对象去表示每个立方体的位置和方向。但我们只需要一个Geometry对象存放立方体的顶点数据,我们也只需要一个Material对象去确定蓝色。两个Mesh对象都可以去引用同一个Geometry对象和同一个Material对象。

  • Geometry对象代表了一些几何体的顶点数据,比如sphere、cube、plane、dog、cat、human、tree、building等等。Three.js提供了多种内置的基本几何体。你也可以创建自定义的几何体,同样也可以从文件加载一个。

  • Material对象表示用来绘制几何体的表面属性,包括像颜色以及光泽。一个Material同样可以引用一个或多个Texture对象,用来把图像裹在几何体的表面。

  • Texture对象通常表示从图像文件加载的图像、从canvas生成的图像、从另一个场景渲染的图像。

  • Light对象就表示多种灯光。

接下来我们就开始制作一个最小的"Hello Cube",如下图所示

首先我们加载Three.js

<script type="module">
import * as THREE from './resources/threejs/r119/build/three.module.js';
</script>

给script标签添加type="module"是很重要的。它让我们能使用import关键字去加载Three.js。也有其他的方式去加载Three.js但这种比较推荐。模块化的方式可以允许引入其他需要的模块。

下一步我们需要一个canvas标签。

<body>
  <canvas id="c"></canvas>
</body>

我们要让Three.js去找到那个canvas。

<script type="module">
import * as THREE from './resources/threejs/r119/build/three.module.js';
 
function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({canvas});
  ...
</script>

我们找到canvas之后,创建一个WebGLRenderer。这个renderer就负责获取你提供的所有数据并且将其渲染到canvas上。在过去有其他的renderer比如CSSRenderer、CanvasRenderer,并且在以后将会有WebGL2Renderer或者WebGPURenderer。但现在我们使用WebGLRenderer,使用WebGL在canvas上去渲染3d。

注意这里有些细节。如果你不传一个canvas给Three.js,它就会自己创建一个,但你需要将其加到document中。在哪里添加取决于你的需求,如果你改变了需求,你需要去改动你的代码。所以我发现直接传给three.js一个canvas是更灵活的。我可以将canvas放在任何地方,但是这里代码不用去改。

接下来我们需要一个Camera。我们创建一个PerspectiveCamera。

const fov = 75;
const aspect = 2;  // the canvas default
const near = 0.1;
const far = 5;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

fov是field of view的缩写。在这个case里面,垂直方向是75度。

注意,Three.js中大部分是用弧度表示的,但perspectiveCamera是用角度。

aspect就是canvas的显示部分。后续会详细讲解。默认情况下,canvas是300*150的像素比,所有aspect就是 300 / 150 也就是2。

nearfar表示了camera前将渲染的空间。在这个范围之外的就会被剪切,不会被绘制。

以上四个设置决定了一个"截锥"。

nearfar平面的高度是由fov来决定的。而宽度则是由fovaspect共同决定的。

所有在"截锥"中的东西都会被绘制,在之外的不会被绘制。

camera默认是看向-Z轴和+Y轴。立方体是放在原点,所有我们需要把camera移动到后面一点才能看到所有东西。

如图,我们可看到camera在z=2的位置。它看向-Z轴。我们的"截锥"是camera前面的0.1开始到5结束。

接下来我们创造一个Scene。一个Scene在Three.js中就是一种场景图的根。所有你想让Three.js绘制的都需要加到Scene中。更多细节在how scenes work in a future articl

const scene = new THREE.Scene()

接着我们创建一个BoxGeometry,它含有一个box的数据。几乎所有想要展示在Three.js中的东西都需要一个定义了定点信息的Geometry去组成我们的3D对象。

const boxWidth = 1;
const boxHeight = 1;
const boxDepth = 1;
const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

然后我们创建一个基本的Material并附上color。Colors可以使用标准CSS的样式6位16进制颜色值。

const material = new THREE.MeshBasicMaterial({color: 0x44aa88});

紧接着我们创建一个Mesh。一个Mesh表示以下三个东西的组合:

  1. Geometry(对象的形状)
  2. Material(怎么去绘制对象,光泽度,平坦度,什么颜色,应用什么材质等)
  3. 在Scene中相对于其父对象的位置、方向、比例。下面的代码中,父对象就是Scene。
const cube = new THREE.Mesh(geometry, material);

最后,我们将mesh加入到Scene中。

scene.add(cube);

我们现在可以在Scene中渲染了。调用renderer的render方法,把Scene和Camera传给它。

renderer.render(scene, camera);

现在我们就可以看到如下

我们现在还看不出来它是一个3D立方体,因为我们直接沿着-Z轴去观察的,我们只能看到一个面。

我们可以让它旋转起来。使用requestAnimationFrame来进行循环渲染。

function render(time) {
  time *= 0.001;  // convert time to seconds
 
  cube.rotation.x = time;
  cube.rotation.y = time;
 
  renderer.render(scene, camera);
 
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

requestAnimationFrame是想要动画化东西的使用对浏览器的请求。你传给它一个需要被调用的函数。在这个case里,这个函数是render。浏览器将在你更新任何与页面显示相关的内容的时候,就会调用这个函数,浏览也会重新渲染网页。

requestAnimationFrame会传递从页面加载到我们的函数的时间。这个时间是以毫秒为单位的。将其变为秒比较方便。

接着我们同时设置一下立方体的x、y方向的旋转值。这些旋转值就是弧度制了。一个圆是2π,所以我们的立方体在各自轴上转一圈大概需要6.28秒。

接着我们渲染这个Scene并且请求另一个动画帧去继续我们的循环。

在循环之外我们调用一次requestAnimationFrame去启动循环。 // TODO GIF

现在看上去好点了,但还是有点难分辨3d。我们需要加上一些灯光。Three.js中有许多类型的灯光,现在我们先创建一个directional light。

{
  const color = 0xFFFFFF;
  const intensity = 1;
  const light = new THREE.DirectionalLight(color, intensity);
  light.position.set(-1, 2, 4);
  scene.add(light);
}

Directional lights有一个position和一个target。两个默认值都是0,0,0。在我们的例子中将position设到了-1,2,4。就在Camera后面稍微左边上面一点。target还是0,0,0,指向原点。

我们同样需要改变一下Material。现在的MeshBasicMaterial不会被灯光影响。把它改为会被灯光影响的MeshPhongMaterial。

const material = new THREE.MeshPhongMaterial({color: 0x44aa88});  // greenish blue

以下是我们新的场景图:

现在看上去就比较像3d的了。

做点更有意思的,我们再添加两个立方体。

我们使用同一个Geometry给每个立方体,但我们给不同的Material,让他们有不同的颜色。

我们新建一个函数生成特定颜色的Material。它来创建使用特定的Geometry的mesh,然后把它添加到scene中并设置它的x大小。

function makeInstance(geometry, color, x) {
  const material = new THREE.MeshPhongMaterial({color});
 
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
 
  cube.position.x = x;
 
  return cube;
}

接着我们用不同颜色和x位置调用它三次,将这些Mesh实例放入一个数组里。

const cubes = [
  makeInstance(geometry, 0x44aa88,  0),
  makeInstance(geometry, 0x8844aa, -2),
  makeInstance(geometry, 0xaa8844,  2),
];

最后我们把三个立方体在我们的render函数中旋转起来。我们给每一个算不同的旋转值。

function render(time) {
  time *= 0.001;  // convert time to seconds
 
  cubes.forEach((cube, ndx) => {
    const speed = 1 + ndx * .1;
    const rot = time * speed;
    cube.rotation.x = rot;
    cube.rotation.y = rot;
  });
 
  ...

左右两边的立方体有部分是在"截锥"外面了。并且因为fov太极端了,它们也有点变扭曲了。

现在的场景图:

如图所示,我们有三个Mesh对象,每个都引用了同一个BoxGeometry,引用了不同的MeshPhongMaterial使其有不同的颜色。