如何用 Three.js 做 3D 饼形图

看下最终效果

最近接了个大屏需求,需要将页面的相关组件做的更加炫酷一点,饼状图用了 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

  1. 均匀照明:环境光会均匀地照亮场景中的所有物体,无论它们的位置和方向如何。
  2. 无阴影:环境光不会产生阴影,因为它没有特定的方向。
  3. 颜色和强度:环境光的颜色和强度可以通过构造函数进行设置,默认颜色为白色(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 代码,感兴趣可以去查阅。

参考其他博主文章

# three.js画3d饼图和环形图

# 4个大屏常见组件

# 精致的3D饼图

# 粒子Shader入门与基础雪花效果

# 正投影相机(OrthographicCamera)与透视投影相机(PerspectiveCamera)

Github

代码下载地址

相关推荐
凡大来啦2 分钟前
Element UI实现表格全选、半选
前端·javascript·vue.js
冰凉小脚7 分钟前
vue3 数据监听(watch、watchEffect)
前端·javascript·vue.js
GUIQU.31 分钟前
【HTML】验证与调试工具
前端·html
前端菜鸟来报道34 分钟前
react + css 实现 椭圆布局
前端·css·椭圆布局
bin915338 分钟前
DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之添加行拖拽排序功能示例4,TableView16_04 跨表格拖拽示例
前端·javascript·vue.js·ecmascript·deepseek
玄魂39 分钟前
报表优化实战:组件库Table升级VTable
前端·开源·数据可视化
琹箐1 小时前
js文字两端对齐
前端·javascript·css
摆烂工程师1 小时前
炸裂了~兄弟们,GPT4o出图效果太好了
前端·后端·程序员
开心小老虎1 小时前
用HTML和CSS生成炫光动画卡片
前端·css·html
米粒宝的爸爸1 小时前
vue3 vue-router 传递路由参数
前端·javascript·vue.js