看下最终效果
最近接了个大屏需求,需要将页面的相关组件做的更加炫酷一点,饼状图用了 3D 版本,其实用 echarts 也是可以做的,但是,echarts 3D 饼状图的代码也是挺复杂的,而且,如果想在饼状图基础上再加上其他一些特效,那么用 three.js 可能更加合适。
主要思路
- 确定饼形图的容器,根据容器的宽高,设置饼形图内半径、外半径
- 利用shape画出内、外弧线,并通过 ExtrudeGeometry 挤压出一定的厚度,成为3D物品
- 在每一个弧线的中间外部,添加数字标注
- 靠不断地绕Z轴旋转,形成动画
- 利用 ShaderMaterial 不断调整粒子的位置、大小,形成发光粒子动画
一、创建 Scene、Camera、Renderer、OrbitControls
js
/*
* 创建 threejs 四大天王
* 场景、相机、渲染器、控制器
*/
import * as THREE from 'three';
import {
OrbitControls
} from "three/examples/jsm/controls/OrbitControls";
export class Basic {
public dom: HTMLElement;
public scene: THREE.Scene;
public camera: THREE.OrthographicCamera;
public renderer: THREE.WebGLRenderer
public controls: OrbitControls;
private width: number;
private height: number;
constructor(dom: HTMLElement, width: number, height: number) {
this.dom = dom;
this.width = width;
this.height = height;
this.initScenes();
this.setControls();
}
initScenes() {
//注解:第1步,Scene,初始化场景
this.scene = new THREE.Scene();
//注解:第2步,Camera,初始化照相机,并摆好照相机的位置
this.camera = new THREE.OrthographicCamera(-this.width / 2, this.width / 2, this.height / 2, -this.height / 2, -1000, 1000);
this.camera.position.set(0, -1300, 1000);
this.camera.lookAt(this.scene.position);
//注解:第3步,设置好渲染器
this.renderer = new THREE.WebGLRenderer({
//注解:透明,设置整个canvas是否透明,true的话,会显示大背景颜色,false的话,会覆盖大背景颜色
alpha: true,
//注解:抗锯齿,true的话,放大缩小后,线条更加圆润
antialias: true,
});
//注解:设置屏幕像素比
this.renderer.setPixelRatio(window.devicePixelRatio);
//注解:设置渲染器宽高
this.renderer.setSize(this.width, this.height);
//注解:添加到dom中
this.dom.appendChild(this.renderer.domElement);
}
//注解:设置轨道控制器,主要目的是实现放大缩小、拖拽、点击, 原理是控制照相机的运行轨迹
setControls() {
//注解:初始化轨道控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
//注解:这个是用来干什么的,暂时不是很清楚
this.controls.autoRotateSpeed = 3
//注解:使动画循环使用时阻尼或自转,意思是否有惯性,设置为true,拖拽有惯性,更加丝滑
this.controls.enableDamping = true;
//注解:动态阻尼系数 就是鼠标拖拽旋转灵敏度(设置为0.05就可以,具体效果也不是很清楚)
this.controls.dampingFactor = 0.05;
//注解:是否可以缩放
this.controls.enableZoom = false;
//注解:设置相机距离原点的最近距离(如果想要放大,这个值可以缩小)
this.controls.minDistance = 100;
//注解:设置相机距离原点的最远距离(如果想要缩小,这个值可以放大)
this.controls.maxDistance = 300;
//注解:是否开启右键拖拽(设置为true时,动画效果不好控制,所以还是不要右键操作了)
this.controls.enablePan = false;
}
}
为什么选择正交相机 OrthographicCamera
Three.js 相机,主要分两种: 正交相机 OrthographicCamera 、 透视相机 PerspectiveCamera
正交相机
正交相机有个特点,就是不会因为相机跟目标物体的距离而呈现近大远小 的效果,而透视相机却会因为跟目标物体的距离,呈现近大远小的视觉效果。
透视相机
上面的代码中,选择了用正交相机,而不是用透视相机,这是因为饼状图的大小,要根据容器的大小来决定,如果用透视相机,数字文本标签,可能会呈现近大远小的效果,而且自适应也不是很好控制,所以选用了正交相机。
上面的解释,可能还不够专业,可以参考这个博主的文章: 正交相机与透视相机的区别
二、设置光照
之前在掘金上看到了这个博主的实现方式:# three.js画3d饼图和环形图
这个博主,在饼形图的边沿,加上了白色弧线,让整体看起来更加立体,可是,白色线条的固定粗度是1,不方便调整,而且所有的弧形柱体都用上白色边沿的话,其实也不够美观。
考虑之后,我决定用感光材质,加上光源,使圆弧在边界上有个棱角的阴影效果,而不是整个都是一片同样的颜色,看起来不够像 3D 世界的物体。
MeshPhongMaterial
MeshPhongMaterial是 Three 中的一个材质类型,主要用于渲染具有光泽表面的物体,如金属、塑料等。它基于Phong光照模型,能够提供镜面反射效果和镜面高光。
在创建饼状图中,饼状图用 MeshPhongMaterial 材质,效果更加逼真。
Three.js 环境光 AmbientLight
- 均匀照明:环境光会均匀地照亮场景中的所有物体,无论它们的位置和方向如何。
- 无阴影:环境光不会产生阴影,因为它没有特定的方向。
- 颜色和强度:环境光的颜色和强度可以通过构造函数进行设置,默认颜色为白色(0xffffff),强度默认为1。
Three.js 平行光 DirectionalLight
是一种模拟远处光源的光照效果,通常用于模拟太阳光或其他远距离光源。与聚光灯不同,DirectionalLight的光照强度在空间中是均匀的,不会随着距离的增加而减弱。这种光源的特点是只有方向和颜色,没有具体的形状或大小。
在创建饼状图中,用平行光使得棱角更加鲜明。
代码如下
js
//添加灯光效果
const ambientLight = new AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight)
//添加一个平行光
const directionalLight = new DirectionalLight(0xffffff, 1);
directionalLight.position.set(-200, 200, 200);
this.scene.add(directionalLight);
三、创建弧形柱体
思路:首先通过 Shape 画出 2D 平面的圆弧,包括外半径的圆弧和内半径的圆弧, 再通过 ExtrudeGeometry 将 2D 的平面挤压出一定的厚度,形成一个 3D 的圆弧柱体, 再在每一个圆弧柱体的中心外侧,添加文字标注。
创建圆弧柱体
js
const shape = new Shape();
shape.moveTo(outRadius, 0);
shape.absarc(0, 0, outRadius, endAngle - startAngle, 0, true);
shape.absarc(0, 0, innerRadius, 0, endAngle - startAngle, false);
const extrudeSettings = {
//曲线分段数,数值越高曲线越平滑
curveSegments: 60,
depth: depth,
bevelEnabled: false,
bevelSegments: 9,
steps: 2,
bevelSize: 0,
bevelThickness: 0
};
//创建扇形的几何体
const geometry = new ExtrudeGeometry(shape, extrudeSettings);
const material = new MeshPhongMaterial({ color: new Color(color), opacity: 0.96, transparent: true });
const mesh = new Mesh(geometry, material);
mesh.position.set(0, 0, 0);
//旋转扇形以对齐其角度
mesh.rotateZ(startAngle);
//旋转90度,使第一个扇形从下边的中点开始
mesh.rotateZ(-Math.PI / 2);
圆弧柱体外侧添加文本标注
这里的实现方式,通过 html2canvas 将 html 标签转化为 canvas, canvas 再转化为某个精灵的贴图,从而使得文本标注可以添加到三维空间里面去。
js
//生成html
const div = `<div class="category"><span class="${className}"></span><div>${value}</div></div>`;
const shareContent = document.getElementById("html2canvas");
shareContent.innerHTML = div;
//将以上的 html 转化为 canvas,再将 canvas 转化为贴图
const opts = {
//注解:这样表示背景透明
backgroundColor: null,
dpi: window.devicePixelRatio
};
const canvas = await html2canvas(document.getElementById("html2canvas"), opts)
const dataURL = canvas.toDataURL("image/png");
const map = new TextureLoader().load(dataURL);
//根据精灵材质,生成精灵
const materials = new SpriteMaterial({
map: map,
transparent: true,
});
const sprite = new Sprite(materials);
//把这个标签放在这个弧线的中心
const beishu = 1.68;
sprite.position.set(outRadius * beishu * Math.cos((endAngle - startAngle) / 2), outRadius * beishu * Math.sin((endAngle - startAngle) / 2), depth);
//根据文字长度,动态设置精灵的大小
const scaleX = 27 + (value + '').length * 13.5;
const scaleY = 33;
sprite.scale.set(scaleX, scaleY, 1);
//给精灵自定义一些数据,方便后面放大缩小
sprite.userData['scale'] = [scaleX, scaleY];
//spriteList方便后面遍历3d世界中的精灵
this.spriteList.push(sprite);
//加入各自的组织当中
mesh.add(sprite);
this.group.add(mesh);
添加动画
只需要每一帧绕Z轴旋转一定的角度即可。
js
//渲染函数
public render() {
requestAnimationFrame(this.render.bind(this))
this.renderer.render(this.scene, this.camera);
this.controls && this.controls.update();
//让整个饼状图转动起来
this.group.rotation.z += 0.01;
//时间参数动起来
this.uniforms.iTime.value += 0.2
}
四、萤火虫漂浮光点
为了使饼形图旋转看起来不那么单调,这里做一个画蛇添足的效果,就是从饼形图的中间,不断地飞出小光点,小光点往四周扩散,直至消失,循环不断。
思路: 首先通过 BufferGeometry 创建一个随机顶点组成的几何体, 其次,通过 ShaderMaterial 编程,控制几何体的每个顶点的位置,位置随时间逐步变化,最后,通过贴图控制每个顶点的样式。
1、创建随机顶点几何体
js
//注解:保存顶点坐标,3个一组
const vertices = [];
//注解:往上面填充数据
for (let i = 0; i < 20; i++) {
//注解:范围是 -300 至 500
const x = 600 * Math.random() - 300;;
const y = 600 * Math.random() - 300;
const z = 800 * Math.random() - 400;
vertices.push(new Vector3(x, y, z));
}
//注解:星空效果,首先需要创建一个缓冲几何体
const around: BufferGeometry = new BufferGeometry();
//注解:每3个数字构成一个缓冲几何体的一个顶点
around.setFromPoints(vertices);
2、使用 ShaderMaterial 控住每一个顶点的位置
从上面的代码可知,每个顶点的 z 分量,取值范围是 -400 至 400, 所以随机几何体每个顶点,在 z 轴占据的范围是 400 - (-400) = 800 ;
每个粒子在这个范围上的占比是:
float p1 = (u_position.z - (-400.0)) / (400.0 - (-400.0));
这样就可以控制下一帧这个顶点在 z 轴的位置,假设控制下一帧的 z 轴位置是:
float z = fract(p1 + iTime * 0.01) * 800.0 - 400.0;
这样,粒子就是不断上升的, 上面代码是 glsl 的语法, fract 只会取小数部分,所以,这个z 轴的值是不断地从下到上,不断循环上升的动作。
但是, 这样随机粒子,并不会从饼状图的中间飘出来,怎么控制粒子从饼状图的中间飘出来呢? 答案就是,当 z 小于 0 时, x 和 y 的位置设置为 0; 当 z 大于 0 时, x 和 y 的位置,设置为与 z 分量一样的比例。
所以 ShaderMaterial 的顶点着色器的代码如下:
js
varying vec2 vUv;
uniform float iTime;
void main(){
vUv = vec2(uv.x, uv.y);
vec3 u_position = position;
//当前的粒子位置在高度上的百分比 = (粒子高度 - 最低高度)/(最高高度 - 最低高度)
float p1 = (u_position.z - (-400.0)) / (400.0 - (-400.0));
//下一帧的粒子位置在高度上的百分比 = 当前粒子高度百分比 - 时间 * 下落速度百分比,此百分比不能超过1,所以使用fract只取小数部分
float z = fract(p1 + iTime * 0.01) * 800.0 - 400.0;
u_position.z = z;
if(u_position.z <= 0.0){
gl_PointSize = 0.0;
}else{
float p2 = z / 400.0;
float size = 25.0 * sin((p2 + 0.1) * 3.1415926);
gl_PointSize = size;
u_position.x = u_position.x * p2;
u_position.y = u_position.y * p2;
}
vec4 mvPosition = modelViewMatrix * vec4( u_position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}
3、控制每一个顶点的贴图样式
每个顶点用到的贴图如下
这个贴图有个明显的缺点,就是中间部分占比太小,透明部分占比太大,中间部分,大概占了整个贴图的 0.3 倍,要将贴图方法,需要将 u、v 坐标点进行重新映射,这里涉及到几何相关知识点,大家自己意会一下, 片元着色器的代码如下:
js
varying vec2 vUv;
uniform sampler2D pointMap;
uniform vec3 uColor;
void main(){
vec2 gpc = gl_PointCoord;
//将纹理集正在uv的正中央,正中央的占比大概是1/3
float h = 0.3;
float new_x = (1.0 - h) / 2.0 + gpc.x * h;
float new_y = (1.0 - h) / 2.0 + gpc.y * h;
vec4 color = texture2D(pointMap, vec2(new_x, new_y));
gl_FragColor = color;
}
4、整体代码
js
const material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader: `
varying vec2 vUv;
uniform float iTime;
void main(){
vUv = vec2(uv.x, uv.y);
vec3 u_position = position;
//当前的粒子位置在高度上的百分比 = (粒子高度 - 最低高度)/(最高高度 - 最低高度)
float p1 = (u_position.z - (-400.0)) / (400.0 - (-400.0));
//下一帧的粒子位置在高度上的百分比 = 当前粒子高度百分比 - 时间 * 下落速度百分比,此百分比不能超过1,所以使用fract只取小数部分
float z = fract(p1 + iTime * 0.01) * 800.0 - 400.0;
u_position.z = z;
if(u_position.z <= 0.0){
gl_PointSize = 0.0;
}else{
float p2 = z / 400.0;
float size = 25.0 * sin((p2 + 0.1) * 3.1415926);
gl_PointSize = size;
u_position.x = u_position.x * p2;
u_position.y = u_position.y * p2;
}
vec4 mvPosition = modelViewMatrix * vec4( u_position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D pointMap;
uniform vec3 uColor;
void main(){
vec2 gpc = gl_PointCoord;
//将纹理集正在uv的正中央,正中央的占比大概是1/3
float h = 0.3;
float new_x = (1.0 - h) / 2.0 + gpc.x * h;
float new_y = (1.0 - h) / 2.0 + gpc.y * h;
vec4 color = texture2D(pointMap, vec2(new_x, new_y));
gl_FragColor = color;
}
`,
transparent:true,
})
const points = new Points(around,material);
this.scene.add(points);
最后呈现的效果就是,小光点不断地从饼状图的中央飞出。
五、自适应
由于正交相机的 width 和 height 分别对应容器的长和宽,代码如下:
js
this.camera = new THREE.OrthographicCamera(-this.width / 2, this.width / 2, this.height / 2, -this.height / 2, -1000, 1000);
this.camera.position.set(0, -1300, 1000);
this.camera.lookAt(this.scene.position);
同时, 饼形图的半径也是根据容器的大小来设置半径的,所以,当容器的大小变化时, 相机的视觉范围需要更新下, 饼状图的大小,也需要同等缩放,代码如下:
js
//监听可视范围的尺寸
this.sizes = new Sizes({ dom: option.dom })
this.sizes.$on('resize', () => {
const width = Number(this.sizes.viewport.width);
const height = Number(this.sizes.viewport.height);
const newSize = Math.min(width, height);
//第1步,渲染器改变下长度、宽度,这样就不会被遮挡,会充满整个父容器
this.renderer.setSize(width, height);
//第2步,相机重新设置下长宽比, 否则成相会被压缩或者拉长,就会很难看
this.camera.left = -width / 2;
this.camera.right = width / 2;
this.camera.top = height / 2;
this.camera.bottom = - height / 2;
this.camera.updateProjectionMatrix();
//第3步,将整个世界同步放大
const scale = newSize / this.group.userData['size'] * this.group.userData['scale'];
this.group.scale.set(scale, scale, scale)
this.group.userData['scale'] = scale;
this.group.userData['size'] = Math.min(width, height);
//第4步,由于整个group被放大了scale倍数,但是精灵的标注,不应该跟随界面缩放,所以group被放大的同时,精灵要同倍数缩小
if(this.spriteList && this.spriteList.length > 0){
this.spriteList.forEach(s => {
const newScaleX = s.userData['scale'][0] / scale;
const newScaleY = s.userData['scale'][1] / scale;
s.scale.set(newScaleX, newScaleY, 1);
})
}
})
在文章最后,会提供 github 代码,感兴趣可以去查阅。
参考其他博主文章
# 正投影相机(OrthographicCamera)与透视投影相机(PerspectiveCamera)