基于three.js开发可视化应用经验总结

文章目录

  • 背景简介
  • 技术方案介绍及对比
    • Webgl
    • Three.js
    • WEBGL和Three.js的关系
  • 具体应用过程
    • 星空背景
    • 流线汇聚效果
    • 行政分区环形选择器
    • Three.js 中使用TWEEN插件实现动画
    • 使用orbit controls插件(轨道控制)来控制模型交互动作
    • 通过THREE.RAYCASTER给模型绑定鼠标事件
  • 疑难问题分析及解决方案
    • Canvas纹理贴图模糊
    • 场景中渲染顺序错误
      • z-fighting问题
      • 透明渲染
  • Three.js性能优化

背景简介

越来越多的应用已经不再满足于二维的数据可视化,追求效果更好的3D数据可视化去打造“数字孪生”的虚实动态结合。而3D可视化页面则也能够在更多维度去管理、展示数据,让用户直观去认知数据,实现更有效的决策管理。前端涉及到3d页面的开发,这里结合之前项目的3d页面开发实践经验,分享一些应用过程中的实现方案及疑难问题的解决思路,希望对其他前端同事提供一些帮助。

技术方案介绍及对比

Webgl

WebGL (Web图形库) 是一种JavaScript API,用于在任何兼容的Web浏览器中呈现交互式3D和2D图形,而无需使用插件。WebGL通过引入一个与OpenGL ES 2.0紧密相符合的API,可以在HTML5 元素中使用。(MDN简介)
在我的理解,WebGL给我们提供了一系列的图形接口,能够让我们通过js去使用GPU来进行浏览器图形渲染的工具。

Three.js

Three.js是一款webGL框架,由于其易用性被广泛应用。Three.js在WebGL的api接口基础上,又进行的一层封装。Three.js以简单、直观的方式封装了3D图形编程中常用的对象。Three.js在开发中使用了很多图形引擎的高级技巧,极大地提高了性能。另外,由于内置了很多常用对象和极易上手的工具,Three.js的功能也非常强大。Three.js作为WebGL框架中的佼佼者,由于它的易用性和扩展性,使得它能够满足大部分的开发需求,Three.js的具体功能如下:

  1. Three.js掩盖了3D渲染的细节:Three.js将WebGL原生API的细节抽象化,将3D场景拆解为网格、材质和光源(即它内置了图形编程常用的一些对象种类。
  2. 面向对象:开发者可以使用上层的JavaScript对象,而不是仅仅调用JavaScript函数。
    功能非常丰富:Three.js除了封装了WebGL原始API之外,Three.js还包含了许多实用的内置对象,可以方便地应用于游戏开发、动画制作、幻灯片制作、髙分辨率模型和一些特殊的视觉效果制作。
  3. 速度很快:Three.js采用了3D图形最佳实践来保证在不失可用性的前提下,保持极高的性能。
  4. 支持交互:WebGL本身并不提供拾取(picking)功能(即是否知道鼠标正处于某个物体上)。而Three.js则固化了拾取支持,这就使得你可以轻松为你的应用添加交互功能。
  5. 包含数学库:Three.js拥有一个强大易用的数学库,你可以在其中进行矩阵、投影和矢量运算。
  6. 内置文件格式支持:你可以使用流行的3D建模软件导出文本格式的文件,然后使用Three.js加载;也可以使用Three.js自己的JSON格式或二进制格式。
  7. 扩展性很强:为Three.js添加新的特性或进行自定义优化是很容易的事情。如果你需要某个特殊的数据结构,那么只需要封装到Three.js即可。
  8. 支持HTML5 canvas:Three.js不但支持WebGL,而且还支持使用Canvas2D、Css3D和SVG进行渲染。在未兼容WebGL的环境中可以回退到其它的解决方案。

WEBGL和Three.js的关系

WebGL原生的api是一种非常低级的接口,而且还需要一些数学和图形学的相关技术。对于没有相关基础的人来说,入门难度较大,Three.js将入门的门槛降低了整整的一大截,对WebGL进行封装,简化我们创建三维动画场景的过程。
用最简单的一句话概括:WebGL和Three.js的关系,相当于JavaScript和Jquery的关系。

具体应用过程

基于three.js开发可视化应用经验总结_第1张图片

星空背景

Three中有两种方式实现给3d场景添加背景:

  • 设置scene.background属性,但是使用这种方法,其实设置的是个2d背景,画布上的背景不会随着相机的移动而产生跟随变化,背景效果不够真实。

  • 更多的情况下,我们需要使用一个3D环境背景,也就是所谓的天空盒(Skybox)。
    skybox其实就是一个box模型(这个box一般可以是cube或者sphere),把sky图像绘制在上面。我们把相机放在box里面,这样看起来就像是在一个环境背景中。

    实现skybox最常见的方法是制作一个立方体,对其应用纹理,从内部绘制它。在立方体的每一侧放置一个纹理(使用纹理坐标),看起来像地平线上的某个图像。使用天空球体(sky sphere)或天空圆顶(sky dome)也是比较常见的。只需创建一个立方体或球体,应用一个纹理,将其标记为THREE.BackSide,这样我们渲染的是内部而不是外部,然后直接或类似地将其放置在场景中,或者,创建两个场景,一个特殊的场景来绘制skybox/sphere/dome,另一个普通的场景来绘制其他所有内容。使用普通的远景相机来绘制,不需要正交照相机。

    实现天空盒的另外一个方法是使用Cubemap,立方体贴图是一种特殊的纹理,它有六个面。它不使用标准纹理坐标,而是使用从中心向外的方向来决定如何获得颜色。要使用它们,我们使用THREE.CubeTextureLoader()加载它们,然后将其用作场景的背景。

这里根据视觉提供的单张宽高为2:1的背景图,采用了天空球体实现3d背景效果,该方法无论是对于视觉切图输出还是前端代码编写都更为简洁,代码如下:

let texture = this.getTexture("sky-bg.png");
let backgroundBall = new Three.Mesh(
	new Three.SphereGeometry(800, 128, 128),
	new Three.MeshBasicMaterial({
		map: texture,
	})
);
backgroundBall.geometry.scale(-1, 1, 1);
this.scene.add(backgroundBall);
getTexture(picName) {
    let texture = this.textureMap.get(picName);
    if (!texture) {
        texture = new Three.TextureLoader().load(
            require(`../assets/images/three-dimension/${picName}`)
        );
        this.textureMap.set(picName, texture);
    }
    return texture;
},

流线汇聚效果

基于three.js开发可视化应用经验总结_第2张图片

页面中存在上图所示流线效果,需要生成若干条从一个平面高度的某一半径的圆环上出发并汇聚到另一平面高度的某一小半径的圆环上的虚线簇,并沿着曲线路径形成光点跑动效果。

如图所示的曲线需要借助三次贝塞尔曲线函数生成。为保持曲线的错乱感,使得不同角度上的曲线有略微的区别,小范围内随机一个下层半径和一个上层半径;随机一个0到2π之间的角度theta,由此可以得到曲线的起点和终点数据,再在此基础上加入两个控制点,就可以得到一个三次贝塞尔曲线,根据页面效果对两个控制点数据进行调整验证即可。

//初始化行政分区圆台到市局图标的飞线和跑动光点
initFlyLines() {
	const group = new Three.Group();
	const lineMaterial = new Three.LineDashedMaterial({
		color: 0x00ff7f,
		side: Three.DoubleSide,
		dashSize: 1,
		gapSize: 1,
		transparent: true,
		opacity: 0.6,
	});
	const startMinRadius = RADIUS.LEVEL_2_RADIUS - 60;
	const startMaxRadius = RADIUS.LEVEL_2_RADIUS - 30;
	const endMinRadius = RADIUS.LEVEL_1_RADIUS / 2;
	const endMaxRadius = RADIUS.LEVEL_1_RADIUS;
	for (let i = 0; i < 50; i++) {
		const startRadius = this.getRandomInRange(
			startMinRadius,
			startMaxRadius
		);
		const endRadius = this.getRandomInRange(
			endMinRadius,
			endMaxRadius
		);
		const theta = Math.random() * Math.PI * 2;
		const curve = new Three.CubicBezierCurve3(
			new Three.Vector3(
				startRadius * Math.sin(theta),
				0,
				startRadius * Math.cos(theta)
			),
			new Three.Vector3(
				startRadius * Math.sin(theta),
				80,
				startRadius * Math.cos(theta)
			),
			new Three.Vector3(
				endRadius * Math.sin(theta),
				HEIGHT.LEVEL_1_HEIGHT - 70,
				endRadius * Math.cos(theta)
			),
			new Three.Vector3(
				endRadius * Math.sin(theta),
				HEIGHT.LEVEL_1_HEIGHT,
				endRadius * Math.cos(theta)
			)
		);
		const points = curve.getPoints(50);
		const lineGeo = new Three.Geometry();
		lineGeo.vertices = points;
		const line = new Three.Line(lineGeo, lineMaterial);
		line.computeLineDistances();
		const lightPoint = this.getSprite("light-point.png");
		lightPoint.scale.set(3, 3);
		lightPoint.position.set(
			startRadius * Math.sin(theta),
			0,
			startRadius * Math.cos(theta)
		);
		this.runLightPoint(lightPoint, points);
		group.add(line, lightPoint);
	}
	this.scene.add(group);
},

接下来处理曲线的光点跑动效果,由于光点需要按照固定路径跑动,故只能选用在每一帧中动态更新光点位置的方式实现动效。取流线上起点到终点间的50个点作为光点的跑动坐标,调整fac的值控制光点跑动速度,定时器随机一个延迟,使得不同角度对应流线上的光点跑动稍微错乱开。

runLightPoint(object, arr) {
	let index = 0;
	let fac = 2;
	const animationFrameId = Symbol();
	let vm = this;
	let timer = null;
	function step() {
		if (timer) {
			clearTimeout(timer);
			timer = null;
		}
		index++;
		if (index == arr.length * fac) {
			index = 0;
		}
		if (index % fac == 0) {
			object.position.set(
				arr[index / fac].x,
				arr[index / fac].y,
				arr[index / fac].z
			);
		}
		vm.lightPointAfids[animationFrameId] = requestAnimationFrame(
			step
		);
	}
	timer = setTimeout(step, Math.random() * 1000 * fac);
},

行政分区环形选择器

基于three.js开发可视化应用经验总结_第3张图片

显示省份的环形选择器使用Three.CylinderGeometry(圆台)构建,构建一个圆心角为2π/3的圆台并绘制canvas进行纹理贴图实现。设置texure的wrapS为Three.RepeatWrapping实现省份的收尾相接环形显示,通过控制texture的offsetX实现选择器的左右切换显示。

//行政分区圆台
async createAreaCone(parent) {
	const radiusTop = RADIUS.LEVEL_2_RADIUS - 21;
	const radiusBottom = RADIUS.LEVEL_2_RADIUS;
	const height = 30;
	const num = this.provinces.length;
	if (!num) {
		return;
	}
	//如果数组总长小于5 则取最大的奇数作为展示的个数
	if (num < this.displayRegionNum) {
		this.displayRegionNum = Math.floor((num - 1) / 2) * 2 + 1;
	}
	let curIndex = (this.displayRegionNum - 1) / 2;
	this.selectedRegion = this.provinces[curIndex];
	const theta = (2 * Math.PI) / 3;
	const coneGeo = new Three.CylinderGeometry(
		radiusTop,
		radiusBottom,
		height,
		64,
		1,
		true,
		0,
		theta
	);
	const canvasDom = await this.redrawSelectorCanvas();
	const regionSelectorTexture = new Three.CanvasTexture(canvasDom);
	regionSelectorTexture.anisotropy = 16;
	regionSelectorTexture.wrapS = Three.RepeatWrapping;
	regionSelectorTexture.repeat.set(this.displayRegionNum / num, 1);
	this.regionSelector = new Three.Mesh(
		coneGeo,
		new Three.MeshBasicMaterial({
			map: regionSelectorTexture,
			transparent: true,
			side: Three.DoubleSide,
		})
	);
	this.regionSelector.position.y = height / 2;
	this.regionSelector.rotation.y = (-theta - Math.PI) / 2;
	this.regionSelector.name = "regionSelector";
	parent.add(this.regionSelector);
},
async redrawSelectorCanvas(canvas) {
	if (!this.buttonImg) {
		this.buttonImg = await this.createCanvasImg("button-bg.png");
	}
	const scaleNum = window.devicePixelRatio;
	if (!canvas) {
		canvas = document.createElement("canvas");
		//将canvas放大一定倍数,改善canvas绘到场景中出现模糊失真的问题
		canvas.width =
			this.buttonImg.naturalWidth *
			this.provinces.length *
			scaleNum;
		canvas.height = this.buttonImg.naturalHeight * scaleNum;
	}
	const ctx = canvas.getContext("2d");
	ctx.scale(scaleNum, scaleNum);
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	const pat = ctx.createPattern(this.buttonImg, "repeat-x");
	ctx.fillStyle = pat;
	ctx.fillRect(0, 0, canvas.width, canvas.height);
	ctx.font = "bold 36px Mcrosoft YaHei";
	ctx.textAlign = "center";
	ctx.textBaseline = "middle";
	ctx.shadowColor = "#000000";
	ctx.shadowBlur = 8;
	ctx.shadowOffsetX = 5;
	ctx.shadowOffsetY = 5;
	ctx.fillStyle = "#ffffff";
	this.provinces.forEach((item, i) => {
		item.id == this.selectedRegion.id
			? (ctx.fillStyle = "#FFA22C")
			: (ctx.fillStyle = "#ffffff");
		ctx.fillText(
			item.name,
			(i + 0.5) * this.buttonImg.naturalWidth,
			this.buttonImg.naturalHeight / 2,
			200
		);
	});
	return canvas;
},
//切换选中行政区域
async slideRegionSelector(offsetFac) {
	if (offsetFac) {
		let len = this.provinces.length;
		let texture = this.regionSelector.material.map;
		let preIndex = this.provinces.findIndex(
			(item) => item.id == this.selectedRegion.id
		);
		let curIndex = (preIndex + offsetFac) % len;
		curIndex < 0 && (curIndex += len);
		this.selectedRegion = this.provinces[curIndex];
		await this.redrawSelectorCanvas(texture.image);
		texture.needsUpdate = true;
		texture.offset.x += offsetFac / len;
	}
},

Three.js 中使用TWEEN插件实现动画

Tween.js是一个轻量级的JavaScript库,通过这个库可以很容易地实现某个属性在两个值之间的进行过渡,而且起始值和结束值之间的所有中间值都会自动计算出来,这个过程叫作tweening(补间)。Three中的动画,除非是需要对动画效果进行精准控制的(例如前文涉及到的流线光点动画效果),都可以配合Tween缓动库实现动画效果。
例如数据弹框的收起和展开动画:点击代表平台的球体时,如果数据弹框是收起状态则动画展开,如果是展开状态则动画收起。

基于three.js开发可视化应用经验总结_第4张图片

这里我们通过改变弹框的scale值来达到弹框的伸缩效果,根据弹框的显隐状态设定动画的起始值和终止值,设定动画时间并传入Tween内置缓动函数,这样start启动动画后我们能在onUpdate函数中拿到生成的补间值,将scale设置成补间值即可实现动画的平滑过渡效果。

//弹框显隐及其动画控制
handleLabelVisibilityChange(label) {
	let scale = [
		{ x: 1, y: 1, z: 1 },
		{ x: 0, y: 0, z: 0 },
	];
	let [start, end] = !label.visible ? scale.reverse() : scale;
	const tween = new Tween.Tween(start)
		.to(end, 1000)
		.easing(Tween.Easing.Quadratic.InOut)
		.onUpdate(() => {
			//将模型尺寸缩放到0的时候,帧率低页面卡顿,将没有尺寸的模型隐藏解决帧率突然降低的问题
			//模型尺寸缩放到零后将visible置为false,模型尺寸开始放大前将visible置为true
			if (label.visible != Boolean(start.x)) {
				label.visible = Boolean(start.x);
			}
			label.scale.set(start.x, start.y, start.z);
		})
		.start();
},

当补间启动后,我们还需要告知Tween.js库什么时候来更新所有的补间,所以我们在每次刷新时,调用Tween.update()方法:

animate() {
	this.render();
	this.control.update();
	Tween.update();
	this.renderer.render(this.scene, this.camera);
	this.animationFrameId = requestAnimationFrame(this.animate);
},

使用orbit controls插件(轨道控制)来控制模型交互动作

Three 中提供了一系列用于鼠标操作模型的控制器插件,如设备朝向控制器、拖放控制器、第一人称控制器、飞行控制器、轨道控制器、指针锁定控制器、轨迹球控制器、变换控制器等,这里我们使用了轨道控制器来实现对模型的鼠标操作:鼠标按住左键可以旋转模型,
鼠标按住右键拖拽可以移动模型,鼠标滚轮可以缩放模型,轨迹球控制器能实现的效果与轨道控制器类似,不同点在于轨迹球控制器不能恒定保持摄像机的up向量。 这意味着,如果摄像机绕过“北极”和“南极”,则不会翻转以保持“右侧朝上”,容易分辨不清楚上下左右的关系导致混乱,适合调试,而轨道控制器则适合客户使用,操作不会产生混乱效果。

首先需要引入插件,文件地址在官方案例的examples/js/controls/OrbitControls.js。

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

然后实例化函数,把相机和渲染器的dom传入,并设置相关设置。

//初始化轨道控制器,实现拖拽、缩放、平移等鼠标操作
initControl() {
	this.control = new OrbitControls(
		this.camera,
		this.renderer.domElement
	);
	this.control.enableDamping = true;
	this.control.enableZoom = true;
	this.control.autoRotate = false;
	this.control.minDistance = 100;
	this.control.maxDistance = 2500;
	this.control.enablePan = true;
},

最后,在animate函数内调用orbit的update()更新。

animate() {
	this.render();
	this.control.update();
	Tween.update();
	this.renderer.render(this.scene, this.camera);
	this.animationFrameId = requestAnimationFrame(this.animate);
},

通过THREE.RAYCASTER给模型绑定鼠标事件

在很多场景下需要通过给模型绑定鼠标事件并处理响应,这就需要用到Three. Raycaster。我们一般都会设置三维场景的显示区域,如果,指明当前显示的2d坐标给Three.Raycaster的话,它将生成一条从显示的起点到终点的一条射线。也就是说,我们在屏幕上点击了一个点,在three.js里面获取的则是一条直线,然后通过intersectObjects方法检查射线和物体之间的所有交叉点(包含或不包含后代)。交叉点返回按距离由近到远排序,返回一个交叉点对象数组。当计算这条射线是否和物体相交的时候,Raycaster将传入的对象委托给raycast方法。这将可以让mesh对于光线投射的响应不同于lines和pointclouds。

需要注意的是:对于网格来说,面必须朝向射线的原点,以便其能够被检测到。用于交互的射线穿过面的背侧时,将不会被检测到。如果需要对物体中面的两侧进行光线投射,则需要将material中的side属性设置为THREE.DoubleSide。

基于three.js开发可视化应用经验总结_第5张图片

上图中需要对模型进行的鼠标操作有:行政区划环形选择器左右箭头点击和环形选择器中省份点击切换选中的省份;轨道中平台球体点击收起和展开数据面板,鼠标悬浮时将数据面板正对相机显示,下面是代码实现:

首先实例化Raycaster并注册window上的click和hover事件:

this.raycaster = new Three.Raycaster();
window.addEventListener("click", this.onMouseClick);
window.addEventListener("mousemove", this.onMouseMove);

通过event拿到鼠标点击位置并调用raycaster.intersectObjects方法获取到鼠标点击的模型,并根据点击的不同模型作出不同响应

calcIntersect(event) {
	//通过鼠标点击的位置计算出raycaster所需要的点的位置,以屏幕中心为原点,值的范围为-1到1.
	let x = (event.clientX / window.innerWidth) * 2 - 1;
	let y = -(event.clientY / window.innerHeight) * 2 + 1;
	// 通过鼠标点的位置和当前相机的矩阵计算出raycaster
	this.raycaster.setFromCamera(new Three.Vector2(x, y), this.camera);
	let objects = [
		this.preButton,
		this.nextButton,
		this.regionSelector,
		...this.platformArray,
	].filter((val) => Boolean(val));
	let intersects = this.raycaster.intersectObjects(objects, true);
	return intersects.length ? intersects[0] : null;
},

onMouseClick(event) {
	let intersect = this.calcIntersect(event);
	if (intersect) {
		if (intersect.object.name == "preButton") {
			this.slideRegionSelector(-1);
		}
		if (intersect.object.name == "nextButton") {
			this.slideRegionSelector(1);
		}
		if (intersect.object.name == "regionSelector") {
			let uv = intersect.uv;
			let offsetFac = Math.floor(uv.x / 0.2) - 2;
			this.slideRegionSelector(offsetFac);
		}
		if (intersect.object.parent.name == "platformBall") {
			let platformGroup = intersect.object.parent.parent;
			let platformLabel = platformGroup.getObjectByName("label");
			this.handleLabelVisibilityChange(platformLabel);
		}
	}
},

onMouseMove(event) {
	let intersect = this.calcIntersect(event);
	//设置鼠标cursor样式
	if (
		intersect &&
		(intersect.object.name == "preButton" ||
			intersect.object.name == "nextButton" ||
			intersect.object.name == "regionSelector" ||
			intersect.object.parent.name == "platformBall")
	) {
		this.isCursorPointer = true;
	} else {
		this.isCursorPointer = false;
	}
	if (intersect && intersect.object.parent.name == "platformBall") {
		if (this.hoverPlatform) {
			this.hoverPlatform.object.rotation.y = this.hoverPlatform.rotationY;
		}
		let platformGroup = intersect.object.parent.parent;
		let platform = platformGroup.getObjectByName("platform");
		this.hoverPlatform = {
			object: platform,
			rotationY: platform.rotation.y,
		};
		platform.rotation.y = -Math.PI / 2;
	} else {
		if (this.hoverPlatform) {
			this.hoverPlatform.object.rotation.y = this.hoverPlatform.rotationY;
			this.hoverPlatform = null;
		}
	}
},

疑难问题分析及解决方案

Canvas纹理贴图模糊

在将图片和文字绘制到canvas上并对模型进行纹理贴图时,会发现渲染出来的文字和图片都会模糊,这一现象在高分屏上尤其严重。

因为canvas不像svg这样,canvas不是矢量图,而是像我们像常见图片一样是位图模式的。高 dpi 显示设备意味着每平方英寸有更多的像素。也就是说二倍屏,浏览器就会以2个像素点的宽度来渲染一个像素,该 canvas 在 Retina 屏幕下相当于占据了2倍的空间,相当于图片被放大了一倍,因此绘制出来的图片文字等会变模糊。因此,要做 Retina 屏适配,关键是知道当前屏幕的设备像素比,然后将 canvas 放大到该设备像素比来绘制,然后将 canvas 压缩到一倍来展示。

在浏览器的window对象中有一个devicePixelRatio 的属性,该属性表示了屏幕的设备像素比,即用几个(通常是2个)像素点宽度来渲染1个像素。假设devicePixelRatio的值为 2 ,一张 100×100 像素大小的图片,在 Retina 屏幕下,会用 2 个像素点的宽度去渲染图片的 1 个像素点,因此该图片在 Retina 屏幕上实际会占据 200×200 像素的空间,但是我们总的像素点只有100×100,这就相当于图片被放大了一倍,因此图片会变得模糊。

代码中需要将 canvas 放大到设备像素比来绘制,然后将 canvas 压缩到一倍展示来解决该问题,代码如下:

//canvas绘制平台信息弹框
async createLabelTexture(info) {
	if (!this.labelImg) {
		const img = await this.createCanvasImg("label.png");
		this.labelImg = img;
	}
	let canvasDom = document.createElement("canvas");
	//将canvas放大一定倍数,改善canvas绘到场景中出现模糊失真的问题
	let ctx = canvasDom.getContext("2d");
	const scaleNum = window.devicePixelRatio;
	canvasDom.width = this.labelImg.naturalWidth * scaleNum;
	canvasDom.height = this.labelImg.naturalHeight * scaleNum;
	ctx.scale(scaleNum, scaleNum);
	const pat = ctx.createPattern(this.labelImg, "no-repeat");
	ctx.fillStyle = pat;
	ctx.fillRect(0, 0, canvasDom.width, canvasDom.height);
	ctx.font = "500 16px Mcrosoft YaHei";
	ctx.textAlign = "center";
	ctx.shadowColor = "#000000";
	ctx.shadowBlur = 5;
	ctx.shadowOffsetX = 3;
	ctx.shadowOffsetY = 3;
	// ctx.fillStyle = '#daffe8';
	ctx.fillStyle = "#ffffff";
	ctx.fillText(info.name, 166, 29, 170);
	ctx.fillText("视频总数", 90, 62);
	ctx.fillText("共享平台总数", 198, 62);
	ctx.fillText(info.totalCount, 90, 85);
	ctx.fillText(info.platformCount, 198, 85);
	const canvasTexture = new Three.CanvasTexture(canvasDom);
	//当相机离模型远时,该数值设置使得材质模糊程度降低
	canvasTexture.anisotropy = 128;
	return canvasTexture;
},

场景中渲染顺序错误

在开发过程中会遇到部分场景中透明的物体渲染出现闪烁,渲染顺序错误等问题,这就需要开发者对Three.js中的渲染机制有一定的了解。

three的渲染器是基于webGL的。它的渲染机制是根据物体离照相机的距离来控制和进行渲染的。对于透明的物体,则是按照从最远到最近的顺序进行渲染。也就是说,它根据物体的空间位置进行排序,然后根据这个顺序来渲染物体。

在Three.js的渲染中,大概可以分为以下几步:

  1. 清空当前帧缓冲区,更新MVP矩阵;
  2. 将物体分为透明和不透明两类,按照离摄像机从近到远排序(也可在Object3D单独设置renderOrder);
  3. 根据灯光信息,阴影计算,如果有开启平面裁剪就对进行剪裁;
  4. 开始逐个渲染物体,按以下顺序,背景、不透明物体、透明物体;
  5. 渲染前后还有两个类似于生命周期的回调函数,scene.onBeforeRender和scene.onAfterRender;
  6. 最后将深度、模版测试、多边形偏移恢复默认。

其中1、2和5、6都是准备和善后工作,2中为物体分为不透明、透明进行排序,原因有二:

  • 为了最大限度地避免overdraw,一个重要的优化策略就是控制绘制顺序。由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么就可以很大程度上减少overdraw。这是因为,在后面绘制的物体由于无法通过深度测试,因此,就不会再进行后面的渲染处理。
  • 把透明物体抽出来最后渲染,是为了和不透明物体进行颜色混合。

z-fighting问题

当场景中的两个模型在同一个像素生成的渲染结果对应到一个相同的深度值时,渲染器就不知道该使用哪个模型的渲染结果了,或者说,不知道哪个面在前,哪个面在后,于是便开始“胡作非为”,这次让这个面在前面,下次让那个面在前面,于是模型的重叠部位便不停的闪烁起来。这便是深度冲突(z-fighting),是图形渲染中一个非常常见的现象。

要解决Z-Fighting问题,有两个思路:

  1. 让各模型渲染结果不要在同一个像素出现相同深度值;
  2. 人为设置渲染顺序,这样即使出现相同深度值,也能正确渲染。

所以我们在编写代码的时候采取如下的几种措施:

  • 简单直接的把两个靠近的面挪开一点
    想要避免深度冲突,最简单直接的办法就是手动将两个靠的非常近的面挪开一点距离,这样他们就不会争抢渲染,从而避免闪烁。但是这个办法比较局限,不是所有模型都能手动更改,且工作量比较大。
  • 调整 camera 的 near、far 参数
    调整 camera 的 near、far 属性的值也能一定程度的缓解深度冲突,其原理我们可以在上面的深度原理中窥探出一些端倪。当我们把裁剪空间转到 ndc 空间的时候,会归一化 z 值,z 值的范围越大,就会导致归一化后的位置相对值越靠近,就更加容易产生 z-fighting 的现象。
  • 使用对数缓冲
    在 threejs 的 renderer 中可以启用 logarithmicDepthBuffer 属性,从而使用对数深度缓冲。虽然可以在一定程度上避免 z-fighting 的现象,但是它会使 early-z 的测试失效,从而造成一定程度的性能浪费,使用时应慎重。
  • 使用 polygonoffset
    polygonoffset 是一个比较常见的消除 z-fighting 的设置项。在 threejs 中我们可以设置 material 的 polygonoffset 属性来达到启用的目的。其原理是在渲染的时候,将模型的顶点稍微向靠近或远离相机的方向做一定的偏移,从而错开两个靠近的面的目的。
  • 设置 render order
    threejs的Object3D对象定义了一个renderOrder属性,可以指定对象的渲染顺序,按renderOrder从小到大排列,小的先渲染,大的后渲染。设置完renderOrder之后,就算两个面有同样的深度,但是因为有渲染顺序,后渲染的面会覆盖掉先渲染的面。也因为这样,设置正确的渲染顺序很重要。

透明渲染

关于透明物体的渲染,我们经常会碰到各种问题。最常见的问题就是多个透明物体在渲染的时候,会跳过后面一个透明物体直接显示了之后的物体,从而形成非常诡异的现象。

对于透明物体的渲染,简单处理方式是对透明物体按如下步骤渲染:

  1. 先绘制不透明物体(threejs从近向远排序绘制)
  2. 关闭透明材质的深度写入(depthwrite=false)
  3. 打开透明材质的深度测试(depthTest=true)
  4. 透明物体根据相机距离进行排序(从远向近的顺序绘制)

Three.js性能优化

在基于three.js进行vue组件开发时,当3d页面出现内存占用持续增加、掉帧严重、渲染卡顿等性能问题时,可以从如下几个方面去进行代码的性能优化:

  1. 不将生成的three实例等诸多不需要与vue模板进行响应式绑定的数据放入data中,减少vue中遍历字段的每个属性为其添加get set的开销。
  2. 尽量共用几何体和材质;如果在场景中需要创建大量相同的模型或重复的材质,尽量对它们进行复用,不去多次重复创建。
  3. 删除模型时,将材质和几何体从内存中清除:使用remove()将模型从场景内删除掉,大家会发现内存基本上没有怎么降低。因为几何体和材质还保存在内存当中,我们需要手动调用dispose()方法将其从内存中删除。
  4. 使用merge方法合并不需要单独操作的模型:这个方法主要应用场景为大量几何体相同材质的模型。我们可以通过将多个几何体拼接成一个单个整体的几何体来节约性能,缺点就是将缺少对单个模型的控制。
  5. 在循环渲染中避免使用更新:这里的更新指的是当前的几何体、材质、纹理等发生了修改,需要Three.js重新更新显存的数据,具体包括:
    a) geometry.verticesNeedUpdate = true; //顶点发生了修改
    b) geometry.elementsNeedUpdate = true; //面发生了修改
    c) geometry.morphTargetsNeedUpdate = true; //变形目标发生了修改
    d) geometry.uvsNeedUpdate = true; //uv映射发生了修改
    e) geometry.normalsNeedUpdate = true; //法向发生了修改
    f) geometry.colorsNeedUpdate = true; //顶点颜色发生的修改
    g) material.needsUpdate = true
    h) texture.needsUpdate = true
    如果它们发生更新,则将其设置为true,Three.js会通过判断,将数据重新传输到显存当中,并将配置项重新修改为false。这是一个很耗运行效率的过程,所以我们尽量只在需要的时候修改,不要放到render()方法当中循环设置。
  6. 在组件销毁前调用cancelAnimationFrame取消注册的动画函数并dispose掉场景中的所有模型、材质和渲染器等。

你可能感兴趣的:(three.js)