0%

重拾Threee.js

最近业务需要,重新开始整起了three.js,之前的three.js的使用还是在2018年,现在重新使用感觉有了区别,所以现在算是重新拾起,做个记录。

安装

照旧,我们是在Angular项目中使用,所以需要使用npm管理的方式。

使用npm的方式安装:

1
$ npm i three

同时安装three的type:

1
$ npm i --save-dev @types/three

这样我们可以在ts中有很好的类型提示。

基本场景

使用three.js构建场景,那么我们需要:

  • 场景scene是一个容器,用来保存和跟踪我们想渲染的物体对象(Object3D),我们想要渲染的各种物体都需要放入到场景中。
  • 相机camera定义了我们能在渲染好的场景中能看到什么。
  • 渲染器renderer负责在指定相机角度下,浏览器中场景应该渲染成什么样子。

我们在组件的ngAfterViewInit()生命周期中来初始化这些变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 场景
scene: Scene;
// 像机
camera: PerspectiveCamera;
// 渲染器
renderer: WebGLRenderer;

ngAfterViewInit() {
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
this.renderer = new WebGLRenderer();
this.renderer.setClearColor(0xAAAAAA, 1.0);
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(this.renderer.domElement);
const axes = new AxesHelper(20);
this.scene.add(axes);
this.renderer.render(this.scene, this.camera)
}

这里我们渲染器使用了WebGLRenderer()。相比于基于CanvasSVG的渲染器,WebGL渲染器有更好的性能。

我们将渲染的窗口设置为浏览器的窗口,并将this.renderer.domElement加入到了document.body元素里面。默认情况下这里会创建一个canvas的元素,我们审查元素可以看到在根节点这里插入了一个canvascanvas的宽高是浏览器视窗的大小。

我们为了方便观察,创建了一个辅助坐标系axes,最后通过渲染器,以场景和相机为参数渲染。我们得到的页面:
angular three

关于坐标系的基本知识可以参照之前的一篇文章:three.js 系列一 创建第一个3d场景

加入几何体

现在我们的场景只有一个辅助坐标系,我们往里面加入一些简单的几何体。

在three.js里面,添加立体对象需要两个元素:几何体(geometry)和材质(material)。

内置的几何体有基本的立方体(cube)、球体(sphere)、平面(plane),以及其他不规则体。材质有网格基础材质、网格朗伯材质(感光材质)、着色器材质、直线基础材质等。

可以这样理解,几何体相当于骨头,材质相当于皮肤。一个物体必须要几何体结合材质才能创造出来。

方体

创建方体我们需要几何体BoxGeometry,创建一个4x4x4的立方体。

材质的话用网格朗伯材质MeshLambertMaterial,传递一个颜色初始化材质,颜色可以用16进制的数字0x0076ff,也可以直接传递一个表示颜色的字符串#0076ff,是一样的效果。

然后使用Mesh方法将几何体和材质结合创造出立方体的3d对象。

我们可以使用position属性设置3d对象在场景中的坐标位置,可以使用rotation来旋转,使用scale来缩放大小。

来看具体代码:

1
2
3
4
5
6
7
8
const cubeGeometry = new BoxGeometry(4, 4, 4);
const cubeMaterial = new MeshLambertMaterial({
color: 0x0076ff,
});
const cube = new Mesh(cubeGeometry, cubeMaterial);
cube.castShadow = true;
cube.position.set(-4, 5, 0);
this.scene.add(cube);

需要注意的是,创建好3d对象后,需要将其加入到场景scene中,并重新渲染this.renderer.render(this.scence, this.camera)

球体

我们再加入一个球体。

球体的几何体是SphereGeometry,它接受参数:

SphereGeometry构造函数接受的参数:

  • radius 球体半径,默认为1
  • widthSegments 水平分段数,沿着经线分段,最小值为3, 默认值为32.
  • heightSegments 垂直分段数,沿着纬线分段,最小值为2,默认值为16.
  • phiStart 指定水平(经线)起始角度,默认值为0
  • phiLength 指定水平(经线)扫描角度的大小,默认值为Math.PI * 2
  • thetaStart 指定垂直(纬线)起始角度,默认值为0
  • thetaLength 指定垂直(纬线)扫描角度的大小,默认值为Math.PI

我们创建一个半径为4,垂直段数为20,横向段数为20。垂直段数和横向段数的意义是啥?这个需要后续研究下。

1
2
3
4
5
6
7
const sphereGeometry = new SphereGeometry(4, 20, 20);
const sphereMaterial = new MeshBasicMaterial({color: 0x777ff, wireframe: true})
const sphere = new Mesh(sphereGeometry, sphereMaterial);
sphere.position.x = 20;
sphere.position.y = 4;
sphere.position.z = 2;
this.scene.add(sphere);

球体是通过扫描并计算围绕着Y轴(水平扫描)和X轴(垂直扫描)的顶点来创建的。因此,我们可以利用phiStartphiLengththetaStartthetaLength来创建不完整的球形切片。

平面体

平面几何体,在3d世界中一个平面可以想象成一个玻璃,和立方体相比,它有长宽,但是没有高度(厚度)。

创建平面体我们使用PlaneGeometry,它接受参数:

  • width 平面沿着X轴的宽度
  • height 平面沿着y轴的长度
  • widthSegments 平面宽度的分段数,默认值是1
  • heightSegments 平面长度的分段数,默认值是1

我们这里创建一个60X20的平面:

1
2
3
4
5
6
7
8
9
const planeGeometry = new PlaneGeometry(60, 20, 1, 1);
const planeMaterial = new MeshLambertMaterial({color: 0xCCCCCC})
const plane = new Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -0.5 * Math.PI;
plane.position.x = 15;
plane.position.y = 0;
plane.position.x = 0;
plane.receiveShadow = true;
this.scene.add(plane);

这里receiveShadow是表示这个3D对象接受光影的投射。

PlaneGeometry只能创建长方形的平面,如果需要创建不规则的平面可以使用ShapeGeometry

添加灯光

如果在添加灯光之前看上面的代码运行结果,我们会在页面上看到大致的形状,但都是黑暗一片,这时候,就需要,光!!!

光源也分很多种,我们这里用一个具有锥形效果的光源SpotLight

1
2
3
4
const spotLight = new SpotLight(0xffffff)
spotLight.position.set(-40, 60, -10);
spotLight.castShadow = true;
this.scene.add(spotLight)

SpotLight接受一个颜色参数,用来决定发出光的颜色,castShadow表示在它的照射下可以产生阴影。

基于上面几个,我们来看下程序渲染的情况:
angular threejs

加入动画

我们想一个场景:需要方体一直旋转。

思路是这样,我们需要先获得这个方体的对象,然后通过设置rotation来让它旋转。

我们怎么获取这个方体对象?

这个问题其实就是怎么获取添加在场景里面的3D对象?

我们可以简单粗暴点,直接声明一个组件变量,将创建的方体对象放入这个变量里面。但这样不好,如果有十几个甚至于几十个3D对象,难道我们要写几十个变量?

我们可以在创建的时候将对象的id放入一个字典中,然后在需要用的时候,通过this.scence.getObjectById(id)来获取到目标对象。

所以我们需要创建一个字典对象,我们可以在组件中随时访问:

1
objectMap: Map<string, number> = new Map<string, number>()

然后在创建cube的时候将id存起来:

1
2
3
// 保存对象的id
this.objectMap.set('cube', cube.id);
this.scene.add(cube);

这样我们可以写一个方法来控制旋转:

1
2
3
4
5
6
7
8
9
10
11
rotationCube() {
const cubeId = this.objectMap.get('cube');
if (!cubeId) {
return;
}
const cube = this.scene.getObjectById(cubeId)
if (cube) {
// 沿着x轴旋转
cube.rotation.x += 0.02;
}
}

开始动画

requestAnimationFrame来控制动画:

1
2
3
4
5
6
renderScene() {
// 旋转方块
this.rotationCube();
window.requestAnimationFrame(this.renderScene.bind(this));
this.renderer.render(this.scene, this.camera);
}

注意这里requestAnimationFrame调用本身的函数的时候,需要绑定this,否则会报错。

然后需要调用一次renderScene()就可以启动动画, 我们将调用加在ngAfterViewInit()方法的尾部:

1
2
3
ngAfterViewInit() {
this.renderScene();
}

再刷新页面,即可看到方体在随着X轴旋转:
angular three animation

监控动画的FPS

我们希望可以直观的看到动画每秒显示的帧数(FPS),我们可以使用Stats这个库来做这个工作。

我们需要在模板里面放一个显示FPS的区域:

1
<div id="Stats-output"></div>

引入Stats

1
import Stats from 'three/examples/jsm/libs/stats.module';

然后增加一个方法来初始化:

1
2
3
4
5
6
7
8
9
10
11
12
initStats() {
// @ts-ignore
const stats = new Stats();
stats.setMode(0); // 0: fps, 1: ms
//统计信息显示在左上角
stats.domElement.style.position = 'absolute';
stats.domElement.style.left = '0px';
stats.domElement.style.top = '0px';
//将统计对象添加到对应的<div>元素中
document.getElementById('Stats-output')?.appendChild(stats.domElement);
return stats;
}

我们需要把初始化后得到的Stats实例也放在组件变量里面,需要在每次渲染的时候调用stats.update()方法更新fps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

ngAfterViewInit() {
this.stats = this.initStats();
this.renderScene();
}

renderScene() {
// 更新fps
this.stats.update();
// 旋转方块
this.rotationCube();
window.requestAnimationFrame(this.renderScene.bind(this));
this.renderer.render(this.scene, this.camera);
}

然后运行即可看到浏览器左上角出现了fps监控:
angular three fps


回首看18年的那篇文章,没有用到three.js的类型定义库,没办法全部引入的,而且有些不可考了,基本概念是通用的,但是没法顺畅的走下来,所以这次重拾three.js,记录下历程。

码字辛苦,打赏个咖啡☕️可好?💘