Skip to content

54. 实战:3D 饼图

Published:

这节我们来实现一个 3D 饼图的效果。

类似这样:

image.png

首先来分析下思路:

饼图的每个部分可以先用曲线 Curve 画出来,然后用 ExtrudeGeometry 来拉伸成几何体。

具体的曲线是一条圆弧曲线 EllipseCurve,搭配两条 LineCurve 直线,用 CurvePath 连接起来。

类似这样:

image.png

然后拉伸一下就好了。

点击每个部分用 RayCaster 实现,点击的时候让那个部分来做位移。

具体怎么移动呢?

这个与角度有关系,拿到角度之后通过 cos、sin 算出来移动到的目标位置,用 tweenjs 来做动画。

上面的数字用 canvas 画好,然后设置到 Sprite 的颜色贴图就好了。

这就是 3D 饼图的实现思路。

下面我们来写一下:

npx create-vite 3d-pie-chart

image.png 进入项目,安装依赖:

npm install
npm install --save three
npm install --save-dev @types/three

改下 src/main.js

import './style.css';
import * as THREE from 'three';
import {
    OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import mesh from './mesh.js';

const scene = new THREE.Scene();
scene.add(mesh);

const directionLight = new THREE.DirectionalLight(0xffffff, 2);
directionLight.position.set(500, 400, 300);
scene.add(directionLight);

const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);

const width = window.innerWidth;
const height = window.innerHeight;

const helper = new THREE.AxesHelper(1000);
// scene.add(helper);

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 10000);
camera.position.set(500, 600, 800);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height)

function render() {
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

render();

document.body.append(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

创建 Scene、Light、Camera、Renderer。

改下 style.css

body {
  margin: 0;
}

写下 src/mesh.js

import * as THREE from 'three';
import { LineMaterial } from 'three/examples/jsm/Addons.js';

const curvePath = new THREE.CurvePath();

const v1 = new THREE.Vector2(0, 0);
const v2 = new THREE.Vector2(0, 300);
const v3 = new THREE.Vector2(300, 0);

const line1 = new THREE.LineCurve(v1, v3);
curvePath.add(line1);

const arc = new THREE.EllipseCurve(0, 0, 300, 300, 0, Math.PI / 2);
curvePath.add(arc);

const line2 = new THREE.LineCurve(v1, v2);
curvePath.add(line2);

const points = curvePath.getPoints(100);
const shape = new THREE.Shape(points);

const geometry = new THREE.ExtrudeGeometry(shape, {
    depth: 100
})
const material = new THREE.MeshPhongMaterial({
    color: 'orange'
});

const mesh = new THREE.Mesh(geometry, material);

export default mesh;

就像前面分析的,我们先画了 1 条直线 LineCurve,然后画了一条曲线 EllipseCurve,之后再画一条直线,用 CurvePath 连接起来(顺序很重要)。

之后从上面取 100 个点来生成 Shape。

用这个 Shape 经过 ExtrudeGeometry 拉伸,形成几何体,创建网格模型。

看下效果:

npm run dev

image.png

2025-04-19 21.48.29.gif

这样,饼图的一个部分就完成了。

然后我们指定数据,根据数据来画:

比如数据是这样的:

const data = [
    {
        name: '春节销售额',
        value: 1000
    },
    {
        name: '夏节销售额',
        value: 3000
    },
    {
        name: '秋节销售额',
        value: 800
    },
    {
        name: '冬节销售额',
        value: 500
    }
];

首先我们写下基础代码:

import * as THREE from 'three';

const group = new THREE.Group();

const R = 300;
function createPieChart(data) {
    let total = 0;
    data.forEach(item => {
        total += item.value;
    });

    const angles = data.map(item => {
        return item.value / total * 360;
    });
    console.log(angles);
}

const data = [
    {
        name: '春节销售额',
        value: 1000
    },
    {
        name: '夏节销售额',
        value: 3000
    },
    {
        name: '秋节销售额',
        value: 800
    },
    {
        name: '冬节销售额',
        value: 500
    }
];
createPieChart(data);

export default group;

createPieChart 方法里根据传入的数据计算出总数 total,然后计算出每个 part 的角度。

打印看下角度:

image.png

加起来正好 360

然后继续根据这个角度来画饼图的每个部分:

先来写一个生成随机颜色的函数:

let usedColor = [];
let colors = ['red', 'pink', 'blue', 'purple', 'orange', 'lightblue', 'green', 'lightgreen']
function getRandomColor() {
    let index = Math.floor(Math.random() * colors.length);
    while(usedColor.includes(index)) {
        index = Math.floor(Math.random() * colors.length);
    }
    usedColor.push(index);
    return colors[index];
}

就是从 colors 数组里随机取一个下标的颜色返回,用过的颜色记录下来,如果随机到用过的就重新生成。

试下效果:

image.png

image.png

没啥问题。

然后就行来画饼图:

image.png

let startAngle = 0;
angles.map((angle, i) => {
    const curvePath = new THREE.CurvePath();

    const rad = THREE.MathUtils.degToRad(angle);
    const endAngle = startAngle + rad;

    const x1 = R * Math.cos(startAngle);
    const y1 = R * Math.sin(startAngle);

    const x2 = R * Math.cos(endAngle);
    const y2 = R * Math.sin(endAngle);

    const v1 = new THREE.Vector2(0, 0);
    const v2 = new THREE.Vector2(x1, y1);
    const v3 = new THREE.Vector2(x2, y2);

    const line1 = new THREE.LineCurve(v1, v2);
    curvePath.add(line1);

    const arc = new THREE.EllipseCurve(0, 0, R, R, startAngle, endAngle);
    curvePath.add(arc);

    const line2 = new THREE.LineCurve(v1, v3);
    curvePath.add(line2);

    const points = curvePath.getPoints(100);
    const shape = new THREE.Shape(points);

    const geometry = new THREE.ExtrudeGeometry(shape, {
        depth: 100
    })
    const material = new THREE.MeshPhongMaterial({
        color: getRandomColor()
    });

    const mesh = new THREE.Mesh(geometry, material);
    group.add(mesh);

    startAngle += rad;
})

有了角度,只需要按照这个角度把饼图的每个部分画出来就行。

首先用 MathUtils.degToRad 把角度转为弧度制。

开始角度从 0 开始,结束角度就是加上当前的角度。

然后分别通过两条直线一条弧线把形状画出来之后,用 ExtrudeGeometry 拉伸下。

两条直线的 x、y 是通过 cos、sin 算出来的。

下个饼图的 part 的开始角度要加上已经画过的角度。

试下效果:

image.png

没啥问题。

然后把它旋转一下:

image.png

group.rotateX(- Math.PI / 2);

image.png

这样,饼图的形状就画好了。

案例代码上传了小册仓库

总结

这节我们把饼图画了出来。

用 CurvePath 来组合曲线路径,用到了两个 LineCurve 和一个 EllipseCurve 来画形状,曲线取点构造 Shape,之后用 ExtrudeGeometry 拉伸成几何体。

CurvePath 曲线连接的顺序很重要,要从一个点开始,最后回到原点,顺序不能错。

难点在于角度的计算,需要根据当前值和 total 的比例计算角度,然后用 MathUtils.degToRad 转为弧度制,这样就确定了每个 part 的旋转角度和大小。

形状画出来了,下节我们给它加上交互。

评论