越来越多的应用已经不再满足于二维的数据可视化,追求效果更好的3D数据可视化去打造“数字孪生”的虚实动态结合。而3D可视化页面则也能够在更多维度去管理、展示数据,让用户直观去认知数据,实现更有效的决策管理。前端涉及到3d页面的开发,这里结合之前项目的3d页面开发实践经验,分享一些应用过程中的实现方案及疑难问题的解决思路,希望对其他前端同事提供一些帮助。
WebGL (Web图形库) 是一种JavaScript API,用于在任何兼容的Web浏览器中呈现交互式3D和2D图形,而无需使用插件。WebGL通过引入一个与OpenGL ES 2.0紧密相符合的API,可以在HTML5 元素中使用。(MDN简介)
在我的理解,WebGL给我们提供了一系列的图形接口,能够让我们通过js去使用GPU来进行浏览器图形渲染的工具。
Three.js是一款webGL框架,由于其易用性被广泛应用。Three.js在WebGL的api接口基础上,又进行的一层封装。Three.js以简单、直观的方式封装了3D图形编程中常用的对象。Three.js在开发中使用了很多图形引擎的高级技巧,极大地提高了性能。另外,由于内置了很多常用对象和极易上手的工具,Three.js的功能也非常强大。Three.js作为WebGL框架中的佼佼者,由于它的易用性和扩展性,使得它能够满足大部分的开发需求,Three.js的具体功能如下:
WebGL原生的api是一种非常低级的接口,而且还需要一些数学和图形学的相关技术。对于没有相关基础的人来说,入门难度较大,Three.js将入门的门槛降低了整整的一大截,对WebGL进行封装,简化我们创建三维动画场景的过程。
用最简单的一句话概括:WebGL和Three.js的关系,相当于JavaScript和Jquery的关系。
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;
},
页面中存在上图所示流线效果,需要生成若干条从一个平面高度的某一半径的圆环上出发并汇聚到另一平面高度的某一小半径的圆环上的虚线簇,并沿着曲线路径形成光点跑动效果。
如图所示的曲线需要借助三次贝塞尔曲线函数生成。为保持曲线的错乱感,使得不同角度上的曲线有略微的区别,小范围内随机一个下层半径和一个上层半径;随机一个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.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;
}
},
Tween.js是一个轻量级的JavaScript库,通过这个库可以很容易地实现某个属性在两个值之间的进行过渡,而且起始值和结束值之间的所有中间值都会自动计算出来,这个过程叫作tweening(补间)。Three中的动画,除非是需要对动画效果进行精准控制的(例如前文涉及到的流线光点动画效果),都可以配合Tween缓动库实现动画效果。
例如数据弹框的收起和展开动画:点击代表平台的球体时,如果数据弹框是收起状态则动画展开,如果是展开状态则动画收起。
这里我们通过改变弹框的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);
},
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。我们一般都会设置三维场景的显示区域,如果,指明当前显示的2d坐标给Three.Raycaster的话,它将生成一条从显示的起点到终点的一条射线。也就是说,我们在屏幕上点击了一个点,在three.js里面获取的则是一条直线,然后通过intersectObjects方法检查射线和物体之间的所有交叉点(包含或不包含后代)。交叉点返回按距离由近到远排序,返回一个交叉点对象数组。当计算这条射线是否和物体相交的时候,Raycaster将传入的对象委托给raycast方法。这将可以让mesh对于光线投射的响应不同于lines和pointclouds。
需要注意的是:对于网格来说,面必须朝向射线的原点,以便其能够被检测到。用于交互的射线穿过面的背侧时,将不会被检测到。如果需要对物体中面的两侧进行光线投射,则需要将material中的side属性设置为THREE.DoubleSide。
上图中需要对模型进行的鼠标操作有:行政区划环形选择器左右箭头点击和环形选择器中省份点击切换选中的省份;轨道中平台球体点击收起和展开数据面板,鼠标悬浮时将数据面板正对相机显示,下面是代码实现:
首先实例化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不像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、2和5、6都是准备和善后工作,2中为物体分为不透明、透明进行排序,原因有二:
当场景中的两个模型在同一个像素生成的渲染结果对应到一个相同的深度值时,渲染器就不知道该使用哪个模型的渲染结果了,或者说,不知道哪个面在前,哪个面在后,于是便开始“胡作非为”,这次让这个面在前面,下次让那个面在前面,于是模型的重叠部位便不停的闪烁起来。这便是深度冲突(z-fighting),是图形渲染中一个非常常见的现象。
要解决Z-Fighting问题,有两个思路:
所以我们在编写代码的时候采取如下的几种措施:
关于透明物体的渲染,我们经常会碰到各种问题。最常见的问题就是多个透明物体在渲染的时候,会跳过后面一个透明物体直接显示了之后的物体,从而形成非常诡异的现象。
对于透明物体的渲染,简单处理方式是对透明物体按如下步骤渲染:
在基于three.js进行vue组件开发时,当3d页面出现内存占用持续增加、掉帧严重、渲染卡顿等性能问题时,可以从如下几个方面去进行代码的性能优化: