关于梳理封装Threejs工具类这档事

4,366 阅读10分钟

前言

前端的世界那么大,与其探讨javascript的一百种写法与计算机网络七层模型中的各个字段,不如来搞搞新意思。

在数据可视化的世界里能体会到线性代数的用处与图形学的乐趣,甚至在不务正业时还能写一些小游戏。一行行的数据陈列在数据库中或许只有专业人员才能明白这些数据表达的是什么,如何有效呈现这些数据就是数据可视化的意义,能让更多的人了解这些数据的价值。

如果对3D场景的基本要素如相机,灯光,场景,渲染动画循环等有大概的概念的话,会更好地了解这一系列的文章,不过问题也不大,看完之后再去了解相关的细节也是ok的。

工具封装

这篇主要讲的是对threejs的梳理封装,但不是说threejs已经是对webgl的一层封装了吗,那为什么要又封装一下?实际上threejs对webgl的封装已经很好,很多效果都不需要再手写向量矩阵运算了。但是进入实际开发的时候就会发现,直接上手使用的话代码会显得有点凌乱不好维护,毕竟谁也不想每次一进去就先看到几十上百行初始化代码。而且也不符合正常的使用习惯(比如一个交互里常用的拾取操作,就要用到光线追踪或者颜色映射相关的类等等,很不直观)。为了更好的组织代码,更加愉快的开发,对一些常用的操作进行一些归纳整理是很有必要的,同时也能更好的了解threejs。

达到的效果及如何使用

const threetool = new ThreeTool({
    canvas: document.getElementById("canvasFrame") as HTMLCanvasElement,
    container: document.getElementById("canvasWrap") as HTMLElement,
});

const geometry = new THREE.BoxGeometry(100, 100, 100);
const material = new THREE.MeshPhongMaterial({ color:0x33bb77 });
const cube = new THREE.Mesh(geometry, material);

threetool.scene.add(cube);

threetool.continuousRender((time)=>{
    cube.rotation.x = time;
});
<div id="canvasWrap">
    <canvas id="canvasFrame" />
</div>

直接开箱即用,不再需要关注灯光,相机,图形尺寸等等这些东西,简简单单几行代码,这样就可以看到一个正方体在场景中以x轴旋转了。需要注意的是工具类的初始化需要在dom加载完成之后进行。

image

环境配置

npx create-react-app my-app --typescript
cd my-app
npm i three @types/three @tweenjs/tween.js
npm start
import * as THREE from 'three';
//...

接下来看看这个threejs工具类需要有哪些内容。

要素

image 这是threejs一个场景下的结构图例子,归纳出各种要素之间的关系。里面的关系在官方文档,或者其他什么地方也是一搜一大堆,这里就不介绍了。

可以看出,相机,场景,光照,渲染器,画布这些都是必备的要素。可以先定义一下相关的属性,然后在构造函数中初始化

// ThreeTool.ts
class ThreeTool {
    // 相机
	public camera: PerspectiveCamera;
	// 方向光
	public directionalLight: DirectionalLight;
	// 场景
	public scene: Scene;
	// 光栅化
	public renderer: WebGLRenderer;
	// 画布
	public canvas: HTMLCanvasElement;
	// 画布容器
	public container: HTMLElement;
	
	//...
}

初始化

这里需要解释一下的是canvas对象上挂载着webgl的api,所以我们需要canvas的实例。

// ...
constructor(threeToolParams: {
		canvas: HTMLCanvasElement;
		container: HTMLElement;
	}) {
		const { canvas, container } = threeToolParams;
		this.canvas = canvas;
		this.container = container;
		this.camera = this.initCamera();
		this.scene = this.initScene();
		this.directionalLight = this.initDirectionalLight();
		this.renderer = this.initRenderer({canvas});
		this.scene.add(this.directionalLight);

		this.renderer.render(this.scene, this.camera);
	}
// ...

除了camera这里其他都是普通的初始化。

例如

public initScene(): Scene {
	const scene = new THREE.Scene();
	return scene;
}

public initDirectionalLight(color: Color = new Color(0xffffff), intensity = 1): DirectionalLight {
	const light = new THREE.DirectionalLight(color, intensity);
	light.position.set(1000, 1000, 1000);
	return light;
}

public initRenderer(rendererParams: { canvas: HTMLCanvasElement; clearColor?: Color }): WebGLRenderer {
	const { canvas, clearColor = new Color(0xffffff) } = rendererParams;
	const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
	renderer.setClearColor(clearColor);
	return renderer;
}

布局尺寸

在threejs中模型的尺度单位是相对的,比如说要设置一个边长是100的正方体const geometry = new THREE.BoxGeometry(100, 100, 100),那这个100的单位可以是厘米/米/千米等长度单位。但很多时候为了方便使用,这个100最好要与屏幕的尺寸对应起来,如100就是100像素,这样就能更加直观调整场景中几何图的尺寸。

我们可以调整透视投影相机的视角Fov达到这个效果。通过简单的三角形计算可推算出对应的fov视角大小。

image

//...
public initCamera(cameraParams = { aspect: 2, near: 0.1, far: 2000 }): PerspectiveCamera {
	const { aspect, near, far } = cameraParams;
	const position = new THREE.Vector3(100, 100, 600);
	const Rag2Deg = 360 / (Math.PI * 2);
	// 反三角函数返回弧度值,视角高度为画布高度,为了与屏幕像素单位等同
	const fovRad = 2 * Math.atan(this.canvas.clientHeight / 2 / position.z);
	// 转为角度值
	const fovDeg = fovRad * Rag2Deg;
	const camera = new THREE.PerspectiveCamera(fovDeg, aspect, near, far);
	camera.position.set(position.x, position.y, position.z);
	return camera;
}
//...

这里的意思是根据画布的高度(像素值),相机z轴的距离,利用三角函数关系算出视角 Fov的大小。由于宽高比aspect可能随屏幕大小改变,这里先用默认值,之后render时再设置宽高比。

调试

image

需要用到stats.js这个挺方便的性能监控工具。当然,不想用个这工具的话,在chrome浏览器调出控制台之后,快捷键command/window+shift+p,输入fps命令,即可使用浏览器自带的性能监控工具。

//...
if (mode === 'dev') {
	this.stats = this.initStats(container);
}
//...
public initStats(container: HTMLElement): Stats {
	const stats = new Stats();
	// 将性能监控屏区显示在左上角
	stats.dom.style.position = 'absolute';
	stats.dom.style.bottom = '0px';
	stats.dom.style.zIndex = '100';
	container.appendChild(stats.dom);
	return stats;
}
//...

交互

image

上面这个效果后几篇会详细讲

拾取操作是最常用的交互操作,clickhoverdrag等事件的触发都需要先拾取到某个物体,再执行物体上的相关回调。虽然说changeinput等输入操作一般是在弹出框里完成,但也有很多时候需要知道是哪个物体触发了这个弹出框。可以说拾取是3d交互的基石。

思路大概是:设置相关事件代理(监听鼠标移动事件)->找到拾取的物体->调用物体上的相关事件

public initEvent() {
	// 监听容器中的hover事件
	this.container.addEventListener('pointermove', (event) => this.throttleTriggerByPointer(event, 'hover'));
	// 监听容器中的click事件
	this.container.addEventListener('click', (event) => this.throttleTriggerByPointer(event, 'click'));
}

这里使用了throttle节了一下流,不让鼠标事件过于频繁的触发。

//...
// 事件缓存
public _PointerMoveEventCacheObj = new Map<string | number, THREE.Object3D>();
//...
public triggerByPointer(
	event: PointerEvent | MouseEvent,
	type: 'hover' | 'click',
) {
	const object3D = this.getObject3D(event);
	if (object3D) {
		// 触发相关事件
		if (type === 'hover') {
			object3D.dispatchEvent({ type: 'mouseenter' });
		} else {
			object3D.dispatchEvent({ type });
		}
		this._PointerMoveEventCacheObj.set(object3D.id, object3D);
	} else {
		this._PointerMoveEventCacheObj.forEach((item) => {
			if (type === 'hover') {
				item.dispatchEvent({ type: 'mouseleave' });
			}
		});
		this._PointerMoveEventCacheObj.clear();
	}
}
//...

使用时给物体加上相关回调就好

mesh.addEventListener('click', () => {
	//do something
});

物体的拾取大概有两种实现方式raycaster pick,gpu pick,我们选一种就可以了。

基于光线追踪的拾取(raycaster pick)

光线追踪是渲染画面的一种方法,能渲染出接近现实世界的光影效果。不过在这个场景下的话,就相当于在画布内,过鼠标所在的位置,沿着照相机camera视角所看向的方向(lookat)打出一条光线。这样我们想要拾取的物体就是光线沿直线前进时遇到的第一个物体。用数学的语言去描述就是,给出一条过照相机,方向与照相机视角向量相同的直线,求出直线前进路线上,与直线夹角大于0的物体(三角形面),这里只是粗略描述。上面的的过程其实就是光线追踪算法的一部分,光线追踪渲染还涉及到了光线的递归,而且光线也不仅仅只有一条,还有包围盒等优化。

还好我们有raycaster这个类,这个类相当于把这些判断都封装好了,直接拿结果就行。

public getObject3D(event: PointerEvent): THREE.Object3D | null {
	const pointer = new THREE.Vector2();
	pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
	pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
	this.raycaster.setFromCamera(pointer, this.camera);
	const intersects = this.raycaster.intersectObject(this.scene, true);
	if (intersects.length > 0) {
		const res = intersects.filter((item) => {
			return item && item.object;
		})[0];
		if (res && res.object) {
			return res.object;
		}
		return null;
	} else {
		return null;
	}
}

基于颜色映射的拾取(gpu pick)

实现思路是在渲染场景的时候,在背后多生成一层颜色映射层(隐藏起来),这一层的颜色值与位置跟场景中的物体一一对应,在场景改变的同时颜色层也要跟着变,这样当鼠标拾取到某个颜色时,通过颜色就找到对应的物体,从而触发相应的事件。这里的思路与深度测试的DepthMap技术,生成阴影的ShaderMap技术异曲同工。一个维度不行,就多加一个维度,都是多加一层辅助层存储判断一些信息。

具体实现可以查阅相关资料

模型加载

各种模型文件的解析加载threejs都有比较好的封装,模型解析就是生成点的关系与点所构成的面的关系

连续渲染与按需渲染

适用于不同的两个场景,也没啥优劣之分。连续渲染对应的场景直接的来讲就是物体会不停的运动或者着色器有动效的场景,按需渲染就是需要用户输入操作,物体才会动的场景。但无论哪种渲染的关键都是用requestAnimationFrame这个函数来驱动渲染循环。

为了方便使用,这里用了OrbitControls这个相机轨道控制工具。这个工具类封装了相机的位移旋转操作。

连续渲染

image

//...
public resizeRendererToDisplaySize(renderer: WebGLRenderer, isUseScreenRatio = true) {
	const canvas = renderer.domElement;
	// 设备物理像素与设备独立像素的比例,即设备独立像素*devicePixelRatio=设备真实的物理物理像素
	const pixelRatio = isUseScreenRatio ? window.devicePixelRatio : 1;
	// 以屏幕的分辨率渲染
	const width = (canvas.clientWidth * pixelRatio) | 0;
	const height = (canvas.clientHeight * pixelRatio) | 0;
	const needResize = canvas.width !== width || canvas.height !== height;
	if (needResize) {
		renderer.setSize(width, height, false);
	}
	return needResize;
}

// 连续渲染模式
public continuousRender(callback?: (time: number) => void) {
	const render = (time: number) => {
		if (this.resizeRendererToDisplaySize(this.renderer)) {
			const canvas = this.renderer.domElement;
			this.camera.aspect = canvas.clientWidth / canvas.clientHeight;
			this.camera.updateProjectionMatrix();
		}
		this.renderer.render(this.scene, this.camera);
		// 时间单位规整到秒
		const t = time * 0.001;
		callback && callback(t);
		if (this.mode === 'dev') {
			this.stats?.update();
		}
		requestAnimationFrame(render);
	};
	render(0);
}
//...

需要特别说明一下的是resizeRendererToDisplaySize这个方法,屏幕的大小有可能会改变,所以需要在渲染时对canvas的绘图缓冲区(drawingbuffer)尺寸和照相机的宽高比进行调整。

按需渲染

image

//...
// 按需渲染模式
public ondemandRender(callback?: () => void) {
	let renderRequested = false;
	const render = () => {
		renderRequested = false;
		if (this.resizeRendererToDisplaySize(this.renderer)) {
			const canvas = this.renderer.domElement;
			this.camera.aspect = canvas.clientWidth / canvas.clientHeight;
			this.camera.updateProjectionMatrix();
		}
		this.controls.enableDamping = true;
		this.controls.update();
		callback && callback();
		this.renderer.render(this.scene, this.camera);
	};
	render();
	const requestRenderIfNotRequested = () => {
		if (!renderRequested) {
			renderRequested = true;
			requestAnimationFrame(render);
		}
	};
	this.controls.addEventListener('change', requestRenderIfNotRequested);
	window.addEventListener('resize', requestRenderIfNotRequested);
}
//...

仔细观察两种渲染左上角FPS指示器的变化,会发现连续渲染是一开始就渲染的,而按需渲染是有变化才渲染。

按需渲染为什么要多加个renderRequested去判断呢?这是因为this.controls.enableDamping = true;控制器加了缓动效果,不去判断的话会造成changerender互相触发,所以需要规定由事件单方面去触发。

结束

到此为止我们就可以愉快地用threejs开发了,接下来会继续更新所涉及到的几何学与怎么使用着色器相关的内容,还有酷炫的特效。

源码

原创不易,转载请联系作者。小伙伴们要不点个赞意思意思,点赞是作者开源代码继续更新的动力,这个工具类及之后相关代码的开源地址正在路上。

准备更新的系列

  • 《关于Threejs中使用着色器这档事》(最后整理中)
    • CPU渲染
    • GPU渲染
  • 《关于使用着色器内置函数与相关特效这档事》(草稿整理中)
    • 各种函数及特效
  • 《关于着色器光照效果这档事》(草稿中)
    • 逐平面着色
    • 逐顶点着色(Gouraud着色)
    • 逐像素着色(Phong光照模型)
    • BlinnPhong光照模型
    • 菲涅尔效应
    • 卡通着色
    • image
  • 《关于局部坐标世界坐标投影坐标这档事》(草稿中)
    • 局部坐标
    • 世界坐标
    • 投影坐标
    • 矩阵变换
  • 《关于写一个粒子效果变换插件这档事》(草稿中)
    • image
  • 《关于github首页地球特效这档事》
  • 《关于D3js这档事》
  • 《关于一个数据关系图可视化这档事》
  • 《关于写一个跳一跳小游戏这档事》
    • 场景生成
    • 碰撞检测
    • 游戏逻辑