用Three.js实现3D圆环图的思路及实例代码

 更新时间:2026年02月06日 08:30:31   作者:Iam0830  
作为一个数据可视化中经久不衰的经典图表,3D饼图不仅能更直观地展示数据比例关系,还能通过立体的视觉效果增强整体的视觉冲击力,这篇文章主要介绍了用Three.js实现3D圆环图的思路及实例代码,需要的朋友可以参考下

最近做大屏,碰到个挺烦的问题:ECharts 和highCharts的 3D 圆环图在特定角度下会有透视错位,在网上找了多个例子 基本都有这个问题。

折腾了一下午,突然想到three.js这个3d库,于是干脆用 Three.js 实现,效果还不错,简单记录下思路。

【实现思路】

其实核心逻辑就几步,没想象中那么复杂:

1. 搞定几何体 (Geometry)

核心是利用ExtrudeGeometry将二维圆环平面挤压成三维实体。具体代码步骤如下:

(1) 绘制二维圆环面 (THREE.Shape):

  • 实例化一个Shape对象。 利用.absarc(0, 0, outerRadius, startAngle, endAngle,
    false) 方法绘制外圆弧(逆时针)。
  • 创建一个 Path 对象,同样利用 .absarc(0, 0, innerRadius, startAngle, endAngle, true) 绘制内圆弧(顺时针),这代表圆环中间的“洞”。
  • 将内圆弧 Path 加入到Shape.holes 数组中,这就构成了一个封闭的二维圆环面。

(2) 挤压成型 (ExtrudeGeometry):

  • 配置挤压参数 settings:核心是 depth (高度)。我们将数据数值映射为 depth,数值越大挤压越高,形成阶梯视觉。
  • 关键设置:必须设置 bevelEnabled: false。ECharts 的 3D 饼图通常带倒角 (Bevel),导致拼接处有缝隙。关闭倒角后,扇区之间是纯粹的几何体贴合,严丝合缝。
  • 最后调用 new THREE.ExtrudeGeometry(shape, settings) 生成三维几何体。

2. 解决“遮挡”问题

刚做完发现个坑:大扇面把小扇面挡住了。

因为 3D 视角通常是俯视+侧视,如果一个很高的扇形在正前方 (Camera 看来),后面的数据如果较小根本就看不到。

解决办法:简单粗暴,把数据排个序。渲染前先把数据按数值 从大到小 (Desc) 排序。

原理:Three.js 逆时针绘制。第一块最大的数据会占据 0° (右侧) 到 90°+ (后方) 的区域。

结果:最高的“墙”被甩到了最后面,最矮的小扇区最后绘制,刚好落在 270° (前方)。形成了“前低后高”的剧院式布局,完美解决遮挡。

3. 标签 (Label) 怎么搞?

Three.js 自带的 TextGeometry 生成汉字不仅包大,还容易有锯齿。推荐用 CSS2DRenderer。简单说就是把 DOM 节点映射到 3D 坐标上。

优势:直接用 CSS 写样式,文字永远正对屏幕,不会因为旋转而变形。

细节:图表是深色的,文字也是深色的,容易看不清。我给文字加了一圈白色的 Halo (光晕) 描边 (text-shadow),看起来就清晰了。

计算一下扇区的中心点坐标 (Math.cos/sin),把 div 定位过去就行。

【总结】

虽然代码量比配置 ECharts / HighCharts 多,但是效果很好,完全没有错位问题,并且十分流畅。

代码放到下方,需要用的朋友自取

<template>
  <div class="three-container" ref="container">
    <div id="three-tooltip" class="three-tooltip" :style="tooltipStyle" v-show="tooltipVisible" v-html="tooltipContent"></div>
  </div>
</template>
<script>
import * as THREE from 'three'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { formatterAmount, floatAdd, floatSub, floatDiv, floatMul } from '@/utils/NumberFormat'
const chartColors = [
  'rgba(240, 134, 63, 0.8)',
  'rgba(55, 162, 179, 0.8)',
  'rgba(31, 91, 170, 0.8)',
  'rgba(140, 205, 241, 0.8)',
  'rgba(246, 192, 84, 0.8)',
  'rgba(255, 169, 206, 0.8)',
  'rgba(162, 133, 210, 0.8)',
  'rgba(235, 126, 101, 0.8)'
]
export default {
  props: {
    chartData: {
      type: Array,
      default: () => []
    },
    showType: {
      type: String, // 'amount' or 'rate' 用于控制标签显示格式
      default: 'amount'
    }
  },
  data() {
    return {
      camera: null,
      scene: null,
      renderer: null,
      labelRenderer: null,
      controls: null,
      meshGroup: null,
      raycaster: new THREE.Raycaster(),
      mouse: new THREE.Vector2(),
      hoveredIndex: -1,
      tooltipVisible: false,
      tooltipContent: '',
      tooltipStyle: {
        left: '0px',
        top: '0px'
      }
    }
  },
  watch: {
    chartData: {
      handler(val) {
        if (val && val.length) {
          this.$nextTick(() => {
            this.rebuildChart()
          })
        }
      },
      deep: true
    },
    showType() {
      // 类型切换时更新标签,不需要完全重建几何体,但为了简单起见,这里重建标签或整体
      this.rebuildChart()
    }
  },
  mounted() {
    this.initThree()
    this.rebuildChart()
    window.addEventListener('resize', this.onWindowResize)
    this.$refs.container.addEventListener('mousemove', this.onMouseMove)
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.onWindowResize)
    this.$refs.container.removeEventListener('mousemove', this.onMouseMove)
    this.cleanUp()
  },
  methods: {
    cleanUp() {
      if (this.renderer) {
        this.renderer.dispose()
      }
      if (this.scene) {
        this.scene.traverse((object) => {
          if (object.geometry) object.geometry.dispose()
          if (object.material) {
            if (Array.isArray(object.material)) {
              object.material.forEach(m => m.dispose())
            } else {
              object.material.dispose()
            }
          }
        })
      }
    },
    initThree() {
      const container = this.$refs.container
      const width = container.clientWidth
      const height = container.clientHeight
      // Scene
      this.scene = new THREE.Scene()
      this.scene.background = null // 透明背景
      // Camera
      this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
      this.camera.position.set(0, 30, 40) // 调整相机位置以获得良好的俯视 3D 视角
      this.camera.lookAt(0, 0, 0)
      // Lights - 柔和光照
      const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) // 降低环境光,避免过曝
      this.scene.add(ambientLight)
      const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
      mainLight.position.set(10, 20, 20)
      this.scene.add(mainLight)
      
      const fillLight = new THREE.DirectionalLight(0xffffff, 0.5)
      fillLight.position.set(-20, 10, -10)
      this.scene.add(fillLight)
      
      const topLight = new THREE.DirectionalLight(0xffffff, 0.3)
      topLight.position.set(0, 50, 0)
      this.scene.add(topLight)
      // WebGL Renderer
      this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
      this.renderer.setSize(width, height)
      this.renderer.setPixelRatio(window.devicePixelRatio)
      container.appendChild(this.renderer.domElement)
      // CSS2D Renderer (Labels)
      this.labelRenderer = new CSS2DRenderer()
      this.labelRenderer.setSize(width, height)
      this.labelRenderer.domElement.style.position = 'absolute'
      this.labelRenderer.domElement.style.top = '0px'
      this.labelRenderer.domElement.style.pointerEvents = 'none' // 允许鼠标穿透到下方 Canvas
      container.appendChild(this.labelRenderer.domElement)
      this.meshGroup = new THREE.Group()
      this.scene.add(this.meshGroup)
      // OrbitControls 用于交互
      this.controls = new OrbitControls(this.camera, this.renderer.domElement)
      this.controls.enableDamping = true // 阻尼感
      this.controls.dampingFactor = 0.05
      this.controls.enableZoom = false // 禁用缩放,避免穿模或太远
      this.controls.autoRotate = false // 不自动旋转,由用户控制
      this.controls.minPolarAngle = 0 // 限制垂直旋转角度,避免看穿底部
      this.controls.maxPolarAngle = Math.PI / 2 // 限制只能从上方看
      this.animate()
    },
    rebuildChart() {
      if (!this.meshGroup) return
      
      // 清空旧物体
      while(this.meshGroup.children.length > 0){ 
        const child = this.meshGroup.children[0]
        this.meshGroup.remove(child)
        if (child.geometry) child.geometry.dispose()
        if (child.material) child.material.dispose()
      }
      if (!this.chartData || this.chartData.length === 0) return
      const total = this.chartData.reduce((acc, item) => floatAdd(acc, item.amount), 0)
      let startAngle = 0
      let accumulatedPercent = 0
      
      const CONFIG = {
        innerRadius: 8,
        outerRadius: 15,
        baseHeight: 2,
        heightScale: 6 // 高度差异倍数
      }
      // 找到最大值用于归一化高度
      const maxAmount = Math.max(...this.chartData.map(d => d.amount))
      this.chartData.forEach((item, index) => {
        const isLast = index === this.chartData.length - 1
        let percentVal
        
        if (isLast) {
          // 最后一个扇形:100 - 前面的总和
          // floatSub 返回的是字符串,需要转为数字
          percentVal = Number(floatSub(100, accumulatedPercent))
          // 防止浮点数误差出现负数极小值
          if (percentVal < 0) percentVal = 0
        } else {
          // 计算占比:(amount / total) * 100
          // 保留2位小数,避免精度问题导致 gap
          const ratio = floatDiv(item.amount, total)
          const p = floatMul(ratio, 100)
          percentVal = Number(p.toFixed(2))
          accumulatedPercent = floatAdd(accumulatedPercent, percentVal)
        }
        // 根据占比计算角度 ( percentVal / 100 * 2PI )
        const angleLength = (percentVal / 100) * Math.PI * 2
        
        // 最后一个扇形强制闭合到 2PI
        const endAngle = isLast ? Math.PI * 2 : startAngle + angleLength
        // 1. 创建 Shape
        const shape = new THREE.Shape()
        
        // 绘制圆环截面
        shape.absarc(0, 0, CONFIG.outerRadius, startAngle, endAngle, false)
        shape.absarc(0, 0, CONFIG.innerRadius, endAngle, startAngle, true) // 内圆反向
        // 自动闭合 shape.closePath() 被 ExtrudeGeometry 处理
        // 2. 计算挤压设置
        const itemRatio = item.amount / maxAmount
        const extrusionDepth = CONFIG.baseHeight + (itemRatio * CONFIG.heightScale)
        const extrudeSettings = {
          steps: 1,
          depth: extrusionDepth,
          bevelEnabled: false, // 禁用倒角,解决扇区交界处的重叠问题
          bevelThickness: 0.2, // 减小倒角使其看起来更锐利,像ECharts
          bevelSize: 0.2,
          bevelSegments: 2,
          curveSegments: 32 // 平滑度
        }
        const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
        geometry.computeBoundingBox()
        
        // 使用 PhysicalMaterial 增加质感
        // 移除反光,回归 ECharts 风格的哑光质感
        const material = new THREE.MeshPhongMaterial({
          color: chartColors[index % chartColors.length],
          shininess: 5, // 极低光泽度
          specular: 0x222222, // 弱高光
          flatShading: false, // 平滑着色
          transparent: true,
          opacity: 0.8,
          side: THREE.DoubleSide
        })
        const mesh = new THREE.Mesh(geometry, material)
        
        // 旋转 Mesh 使其平躺,高度方向变为 Y 轴 (原 Extrude 方向为 Z)
        mesh.rotation.x = -Math.PI / 2
        // 调整位置,使其底面位于 Y=0 (原 Z=0 变为 Y=0)
        // 此时扇形中心在 (0,0,0)
        
        mesh.userData = { 
          name: item.name, 
          value: item.amount,
          ratio: percentVal.toFixed(2) + '%',
          originalColor: material.color.getHex(),
          index: index
        }
        this.meshGroup.add(mesh)
        // 4. 添加标签
        this.addLabel(startAngle, endAngle, CONFIG.outerRadius, extrusionDepth, item, percentVal.toFixed(2) + '%')
        startAngle = endAngle
      })
      
      // 整体居中一点
      this.meshGroup.position.set(0, -5, 0)
    },
    addLabel(startAngle, endAngle, radius, height, item, ratioText) {
      // 计算角度中点
      const midAngle = startAngle + (endAngle - startAngle) / 2
      
      // 计算标签在 XZ 平面上的位置 ( Mesh 旋转前是 XY,旋转后对应 XZ )
      // 因为我们把 Mesh 绕 X 旋转了 -90度,所以原 Mesh 的 (x, y, z) -> 新的 (x, z, -y)
      // Extrude 的 Z 变成了 场景的 Y
      
      // 标签半径稍微大一点
      const labelRadius = radius + 4
      
      const x = Math.cos(midAngle) * labelRadius
      const z = Math.sin(midAngle) * labelRadius // 对应原系的 y
      const y = height + 2 // 标签高度浮在柱体上方
      // 创建 DOM
      const div = document.createElement('div')
      div.className = 'chart-label'
      
      const valueText = this.showType === 'amount' ? formatterAmount(item.amount) : ratioText
      
      // 字体颜色仿照 FundSourceCard (资金来源分布图)
      // name: rgba(44, 53, 65, 1)
      // value: rgba(2, 2, 2, 1)
      div.innerHTML = `<span class="label-name" style="color: rgba(44, 53, 65, 1)">${item.name}</span>
<span class="label-value" style="color: rgba(2, 2, 2, 1)">${valueText}</span>`
      
      div.style.textAlign = 'center'
      div.style.fontSize = '12px'
      div.style.fontFamily = 'Microsoft YaHei'
      // 增加描边效果 (halo) 以防止背景干扰
      div.style.textShadow = '1px 1px 0 #fff, -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 0 1px 0 #fff, 0 -1px 0 #fff'
      const label = new CSS2DObject(div)
      label.position.set(
         Math.cos(midAngle) * labelRadius,
         height * 0.8, // 稍微低一点,不要浮太高
         -Math.sin(midAngle) * labelRadius
      )
      
      this.meshGroup.add(label)
      
      // 绘制引导线 (Line)
      // 从柱体中心点连到标签点
      const points = []
      // 起点:柱体顶部边缘
      const startP = new THREE.Vector3(
        Math.cos(midAngle) * radius,
        height,
        -Math.sin(midAngle) * radius
      )
      // 终点:标签位置
      const endP = label.position.clone()
      
      points.push(startP)
      points.push(endP)
      
      const lineGeo = new THREE.BufferGeometry().setFromPoints(points)
      const lineMat = new THREE.LineBasicMaterial({ color: 0x999999, transparent: true, opacity: 0.5 })
      const line = new THREE.Line(lineGeo, lineMat)
      this.meshGroup.add(line)
    },
    totalAmount() {
      return this.chartData.reduce((t, i) => t + i.amount, 0)
    },
    onWindowResize() {
      if (!this.$refs.container) return
      const width = this.$refs.container.clientWidth
      const height = this.$refs.container.clientHeight
      this.camera.aspect = width / height
      this.camera.updateProjectionMatrix()
      this.renderer.setSize(width, height)
      this.labelRenderer.setSize(width, height)
    },
    onMouseMove(event) {
      event.preventDefault()
      const rect = this.$refs.container.getBoundingClientRect()
      this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
      this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
      // 更新 Tooltip 位置
      this.tooltipStyle = {
        left: (event.clientX - rect.left + 15) + 'px',
        top: (event.clientY - rect.top + 15) + 'px'
      }
    },
    animate() {
      requestAnimationFrame(this.animate)
      if (this.controls) this.controls.update()
      // Raycaster
      this.raycaster.setFromCamera(this.mouse, this.camera)
      
      // 只检测 Mesh,排除 Line 和 CSS2DObject
      const intersects = this.raycaster.intersectObjects(
        this.meshGroup.children.filter(obj => obj.type === 'Mesh')
      )
      if (intersects.length > 0) {
        const object = intersects[0].object
        if (this.hoveredIndex !== object.userData.index) {
          // 恢复上一个
          if (this.hoveredIndex !== -1) {
             this.resetHighlight()
          }
          
          this.hoveredIndex = object.userData.index
          // 高亮当前
          object.material.emissive.setHex(0x333333)
          object.material.opacity = 0.8
          
          // 显示 Tooltip
          this.tooltipVisible = true
          const d = object.userData
          this.tooltipContent = `<div class="tooltip-title">${d.name}</div><div class="tooltip-item"><span class="marker" style="background:${this.getHexColor(object.material.color)}"></span><span class="label">金额:</span><span class="value">${formatterAmount(d.value)}元</span></div><div class="tooltip-item"><span class="marker" style="background:${this.getHexColor(object.material.color)}"></span><span class="label">占比:</span><span class="value">${d.ratio}</span></div>`
        }
      } else {
        if (this.hoveredIndex !== -1) {
          this.resetHighlight()
          this.hoveredIndex = -1
          this.tooltipVisible = false
        }
      }
      this.renderer.render(this.scene, this.camera)
      this.labelRenderer.render(this.scene, this.camera)
    },
    resetHighlight() {
      this.meshGroup.children.forEach(child => {
        if (child.type === 'Mesh') {
          // 重置时恢复原始透明度
          child.material.emissive.setHex(0x000000)
          child.material.opacity = 0.8
        }
      })
    },
    getHexColor(color) {
      return '#' + color.getHexString()
    }
  }
}
</script>
<style lang="less" scoped>
.three-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
.three-tooltip {
  position: absolute;
  background-color: rgba(50, 50, 50, 0.7);
  color: #fff;
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 14px;
  line-height: 1.2;
  pointer-events: none;
  z-index: 100;
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
  transition: opacity 0.2s;
  
  .tooltip-title {
    font-weight: bold;
    margin: 0 0 6px 0;
  }
  
  .tooltip-item {
    display: flex;
    align-items: center;
    margin: 0 0 4px 0;
    
    .marker {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      margin-right: 6px;
    }
    .label {
      margin-right: 8px;
    }
    .value {
      font-weight: 500;
    }
  }
}
</style>
<style>
/* CSS2D Object 样式 */
.chart-label {
  pointer-events: none;
  font-size: 12px;
  line-height: 1.2;
}
.label-name {
  color: #333;
  font-weight: bold;
}
.label-value {
  color: #666;
}
</style>

总结 

到此这篇关于用Three.js实现3D圆环图的文章就介绍到这了,更多相关Three.js实现3D圆环图内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • JavaScript函数及其prototype详解

    JavaScript函数及其prototype详解

    这篇文章主要介绍了JavaScript函数及其prototype详解的相关资料,需要的朋友可以参考下
    2023-03-03
  • JS中async/await实现异步调用的方法

    JS中async/await实现异步调用的方法

    这篇文章主要介绍了async/await实现异步调用的方法,本文给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友可以参考下
    2019-08-08
  • 使用原生JS实现拍照功能

    使用原生JS实现拍照功能

    今天我们聊一聊,一个非常有趣且重要的问题,如何用原生js实现拍照功能?这时候,有的朋友会说,为什么要用原生js实现呀,这么麻烦还要自己动脑子,直接用第三方库多好呀,但是,你难道不好奇它的底层js实现吗?感兴趣的同学跟着小编一起来瞧瞧吧
    2023-12-12
  • layui实现动态和静态分页

    layui实现动态和静态分页

    本篇文章通过实例给大家分享了layui实现动态和静态分页的详细方法,以及效果展示,有需要的朋友可以跟着参考学习下。
    2018-04-04
  • 详解如何使用JavaScript获取自动消失的联想词

    详解如何使用JavaScript获取自动消失的联想词

    前几天在做数据分析时,我尝试获取某网站上输入搜索词后的联想词,输入搜索词后会弹出一个显示联想词的框,有趣的是,输入框失去焦点后,联想词弹框就自动消失了,这种情况下该怎么办呢,所以本文给大家介绍了如何使用JavaScript获取自动消失的联想词,需要的朋友可以参考下
    2024-06-06
  • 深入理解JavaScript系列(26):设计模式之构造函数模式详解

    深入理解JavaScript系列(26):设计模式之构造函数模式详解

    这篇文章主要介绍了深入理解JavaScript系列(26):设计模式之构造函数模式详解,本文讲解了基本用法、构造函数与原型、只能用new吗?、强制使用new、原始包装函数等内容,需要的朋友可以参考下
    2015-03-03
  • JavaScript实现网页对象拖放功能的方法

    JavaScript实现网页对象拖放功能的方法

    这篇文章主要介绍了JavaScript实现网页对象拖放功能的方法,涉及javascript针对浏览器的判断、事件爱你的添加与移除等相关操作技巧,非常具有实用价值,需要的朋友可以参考下
    2015-04-04
  • Javascript中的apply()方法浅析

    Javascript中的apply()方法浅析

    这篇文章主要介绍了Javascript中的apply()方法浅析,本文讲解了apply vs call、Javascript apply 方法等内容,需要的朋友可以参考下
    2015-03-03
  • PHP实现基于Redis的MessageQueue队列封装操作示例

    PHP实现基于Redis的MessageQueue队列封装操作示例

    这篇文章主要介绍了PHP实现基于Redis的MessageQueue队列封装操作,结合实例形式分析了Redis的PHP消息队列封装与使用相关操作技巧,需要的朋友可以参考下
    2019-02-02
  • 小程序实现展开/收起的效果示例

    小程序实现展开/收起的效果示例

    这篇文章主要介绍了小程序实现展开/收起的效果示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-09-09

最新评论