如何在three.js三维场景中添加echarts图表组件

前言

相信大家开发在三维数字孪生、智慧城市看板等前沿可视化场景中,都会遇到这样一个问题如何在Three.js 三维场景中去动态展示echarts图表吧

本文将以three.js+ECharts技术融合 为核心,分享一下作者如何实现在three.js三维场景中如何通过拖拽自定义的方式加载不同的echarts图表组件

使用 CSS3DRenderer 和 CSS3DObject

CSS3DRenderer文档链接:threejs.org/docs/#examp...

three.js提供了一个可将DOM 元素渲染应用在3D场景中的API CSS3DRenderer

注意:

1.CSS3DRenderer 渲染的内容无法像3D模型材质一样进行导入导出

2.仅支持基础 3D 变换(位移/旋转/缩放),无法实现:复杂光照、阴影、自定义材质、粒子系统

3.原生支持 DOM 元素:可直接将 HTML/CSS 元素(如 div、svg、ECharts 画布)作为 3D 对象渲染

代码实现
  1. 将用于渲染和创建echarts 模块的代码内容用 class 类函数进行抽离封装 css3DRendererModules
js 复制代码
export default class css3DRendererModules {
  css3DRenderer: CSS3DRenderer | null;
  css3DControls: OrbitControls | null;
  raycaster = new THREE.Raycaster();
  mouse = new THREE.Vector2();
  scene: THREE.Scene | null;
  camera: THREE.PerspectiveCamera | null;
  renderer: THREE.WebGLRenderer | null;
  container: HTMLElement | null;
  constructor() {
    this.css3DRenderer = null;
    this.css3DControls = null;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.scene = null;
    this.camera = null
    this.renderer = null
    this.container = document.querySelector('#echarts');
  }
}

2.创建 css3DRenderer 渲染器并使其和 WebGLRenderer 渲染器位置重叠,并给元素添加pointerEvents属性使其不影响WebGLRenderer渲染器中场景交互功能功能

js 复制代码
  init() {
    // 创建场景
    this.scene = new THREE.Scene();
    const rgbeLoader = new RGBELoader();
    const texture = await rgbeLoader.loadAsync('hdr/view-hdr-11.hdr');
    texture.mapping = THREE.EquirectangularReflectionMapping;
    // 创建相机
    const { offsetWidth, offsetHeight } = this.container;
    const aspectRatio = offsetWidth / offsetHeight;
    this.camera = new THREE.PerspectiveCamera(45, aspectRatio, 1, 20000);
    this.camera.position.set(0, 2, 6);
    this.camera.name = 'Camera';
    this.camera.updateProjectionMatrix();
    // 创建渲染器
    this.renderer = new THREE.WebGLRenderer({
      antialias: true, // 开启硬件抗锯齿
      alpha: true,
      preserveDrawingBuffer: true,
      powerPreference: 'high-performance', // 优先使用高性能GPU
    });
    this.renderer.setClearColor(0xcccccc);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制最大像素比为2
    // 创建css3d渲染器
    this.css3DRenderer = new CSS3DRenderer();
    this.css3DRenderer.setSize(offsetWidth, offsetHeight);
    this.css3DRenderer.domElement.style.position = 'absolute';
    this.css3DRenderer.domElement.style.pointerEvents = 'none';
    this.css3DRenderer.domElement.style.top = '0';
    this.css3DRenderer.domElement.style.zIndex = '0';
    this.css3DControls = new OrbitControls(
      this.camera,
      this.css3DRenderer.domElement
    );
  }

3.使用 Vue3 createApp 方法动态创建一个DOM元素内容, Raycaster 射线检测 和 THREE.Vector2() 方法获取当前鼠标在三维场景中的相对位置

在options中获取到 echarts 图表相关参数信息,并通过 myChart.setOption() 设置图表数据和类型

在通过 CSS3DObject 将其元素节点转换为three.js可渲染内容

最后通过 CSS3DObject 创建内容通过几何体材质添加到场景中去

js 复制代码
  /**
   * 创建echarts
   * @param options - 选项
   */
  createEcharts(options: unknown) {
    const { modelData, clientY, clientX } = options as EchartsType;
    
    if (!this?.container || !this?.camera || !this.scene?.children)
      return;

    const rect = this?.container.getBoundingClientRect();
    this.mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
    this.raycaster.setFromCamera(this.mouse, this?.camera);

    const intersects = this.raycaster
      .intersectObjects(this.scene?.children, true)
      .slice(0, 1);

    if (intersects.length == 0) return;

    const element = document.createElement('div');

    const tagsMode = createApp({
      mounted() {
        const chartDom = this.$refs.chart as HTMLElement;
        const myChart = echarts.init(chartDom);
        myChart.setOption(modelData?.options);
      },
      render() {
        return (
          <div
            ref="chart"
            id="echarts"
            style={{
              width: `${modelData.width}px`,
              height: `${modelData.height}px`,
              backgroundColor: 'rgba(0,0,0,0.6)',
              borderRadius: '4px',
              pointerEvents: 'auto',
            }}
          ></div>
        );
      },
    });

    const vNode = tagsMode.mount(document.createElement('div'));
    element.appendChild(vNode.$el);
    const cssObject = new CSS3DObject(element);
    cssObject.position.set(0, 1.5, 0);
    cssObject.scale.set(0.004, 0.004, 0.004);
    const boxGeometry = new THREE.BoxGeometry(3, 0.5, 0.5);
    const boxMaterial = new THREE.MeshBasicMaterial({
      color: 0x00ff00,
      wireframe: true,
      visible: false,
    });
    const helperBox = new THREE.Mesh(boxGeometry, boxMaterial);
    helperBox.add(cssObject);
    helperBox.userData = {
      isTransformControls: true,
      ...options,
    };
    const { x, y, z } = intersects[0].point;
    helperBox.position.set(x, y, z);
    helperBox.name = modelData.name;
    this.scene?.add(helperBox);
  }

注意:这里使用的是jsx语法,需要添加对应的插件代码才不会报错,

安装:pnpm i @vitejs/plugin-vue-jsx

同时vite.config.ts 添加jsx相关配置的代码 vueJsx()

js 复制代码
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig((mode) => {
  return {
    plugins: [vue(),  vueJsx()],
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        vue: 'vue/dist/vue.esm-bundler.js',
      },
    },
  };
});

实现效果创建一个饼图

js 复制代码
const css3DRendererModules = new css3DRendererModules();
css3DRendererModules.init()
const config =  {
    options: {
      title: {
        text: '今日访客',
        left: 'center',
        textStyle: {
          color: '#fff',
        },
      },
      tooltip: {
        trigger: 'item',
      },
      legend: {
        orient: 'vertical',
        left: 'left',
        textStyle: {
          color: '#fff',
        },
      },
      series: [
        {
          name: '今日访客',
          type: 'pie',
          radius: '50%',
          data: [
            { value: 1048, name: '北京' },
            { value: 735, name: '上海' },
            { value: 580, name: '广州' },
            { value: 484, name: '深圳' },
            { value: 300, name: '成都' },
          ],
          emphasis: {
            itemStyle: {
              shadowBlur: 10,
              shadowOffsetX: 0,
              shadowColor: 'rgba(0, 0, 0, 0.5)',
              color: '#fff',
            },
          },
        },
      ],
    },
    height: 500,
    width: 850,
    type: 'pie',
    name: '饼图',
  }
  
css3DRendererModules.createEcharts(config);

完整的代码封装

js 复制代码
import * as THREE from 'three';
import { CSS3DRenderer } from 'three/addons/renderers/CSS3DRenderer.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
import * as echarts from 'echarts';
import { createApp } from 'vue';
import { useModelStore } from '@/store/modelEditStore';
import type { EchartsType } from '@/types/renderModelTypes';

export default class css3DRendererModules {
  css3DRenderer: CSS3DRenderer | null;
  css3DControls: OrbitControls | null;
  raycaster = new THREE.Raycaster();
  mouse = new THREE.Vector2();
  scene: THREE.Scene | null;
  camera: THREE.PerspectiveCamera | null;
  renderer: THREE.WebGLRenderer | null;
  container: HTMLElement | null;
  constructor() {
 this.css3DRenderer = null;
    this.css3DControls = null;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.scene = null;
    this.camera = null
    this.renderer = null
    this.container = document.querySelector('#echarts');
  }
  init() {
    // 创建场景
    this.scene = new THREE.Scene();
    const rgbeLoader = new RGBELoader();
    const texture = await rgbeLoader.loadAsync('hdr/view-hdr-11.hdr');
    texture.mapping = THREE.EquirectangularReflectionMapping;
    // 创建相机
    const { offsetWidth, offsetHeight } = this.container;
    const aspectRatio = offsetWidth / offsetHeight;
    this.camera = new THREE.PerspectiveCamera(45, aspectRatio, 1, 20000);
    this.camera.position.set(0, 2, 6);
    this.camera.name = 'Camera';
    this.camera.updateProjectionMatrix();
    // 创建渲染器
    this.renderer = new THREE.WebGLRenderer({
      antialias: true, // 开启硬件抗锯齿
      alpha: true,
      preserveDrawingBuffer: true,
      powerPreference: 'high-performance', // 优先使用高性能GPU
    });
    this.renderer.setClearColor(0xcccccc);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制最大像素比为2
    // 创建css3d渲染器
    this.css3DRenderer = new CSS3DRenderer();
    this.css3DRenderer.setSize(offsetWidth, offsetHeight);
    this.css3DRenderer.domElement.style.position = 'absolute';
    this.css3DRenderer.domElement.style.pointerEvents = 'none';
    this.css3DRenderer.domElement.style.top = '0';
    this.css3DRenderer.domElement.style.zIndex = '0';
    this.css3DControls = new OrbitControls(
      this.camera,
      this.css3DRenderer.domElement
    );
  }
  render(scene: THREE.Scene, camera: THREE.PerspectiveCamera) {
    this.css3DRenderer?.render(scene, camera);
    this.css3DControls?.update();
    this.renderer.render(this.scene, this.camera);
  }
  /**
   * 创建echarts
   * @param options - 选项
   */
  createEcharts(options: unknown) {
    const { modelData, clientY, clientX } = options as EchartsType;
    const { sceneApi } = store;
    if (!sceneApi?.container || !sceneApi?.camera || !sceneApi.scene?.children)
      return;

    const rect = sceneApi?.container.getBoundingClientRect();
    this.mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
    this.raycaster.setFromCamera(this.mouse, sceneApi?.camera);

    const intersects = this.raycaster
      .intersectObjects(sceneApi.scene?.children, true)
      .slice(0, 1);

    if (intersects.length == 0) return;

    const element = document.createElement('div');

    const tagsMode = createApp({
      mounted() {
        const chartDom = this.$refs.chart as HTMLElement;
        const myChart = echarts.init(chartDom);
        myChart.setOption(modelData?.options);
      },
      render() {
        return (
          <div
            ref="chart"
            id="echarts"
            style={{
              width: `${modelData.width}px`,
              height: `${modelData.height}px`,
              backgroundColor: 'rgba(0,0,0,0.6)',
              borderRadius: '4px',
              pointerEvents: 'auto',
            }}
          ></div>
        );
      },
    });

    const vNode = tagsMode.mount(document.createElement('div'));
    element.appendChild(vNode.$el);
    const cssObject = new CSS3DObject(element);
    cssObject.position.set(0, 1.5, 0);
    cssObject.scale.set(0.004, 0.004, 0.004);

    const boxGeometry = new THREE.BoxGeometry(3, 0.5, 0.5);
    const boxMaterial = new THREE.MeshBasicMaterial({
      color: 0x00ff00,
      wireframe: true,
      visible: false,
    });
    const helperBox = new THREE.Mesh(boxGeometry, boxMaterial);

    helperBox.add(cssObject);
    helperBox.userData = {
      isTransformControls: true,
      ...options,
    };
    const { x, y, z } = intersects[0].point;
    helperBox.position.set(x, y, z);
    helperBox.name = modelData.name;
    this.scene?.add(helperBox);
  }
}

结语

ok这样一个three.js+echarts的动态创建图表功能就完成了

完整的效果案例可查看作者的非开源 项目(3D模型场景编辑器):three3d-0gte3eg619c78ffd-1301256746.tcloudbaseapp.com/threejs-mod...

echarts图表模块功能

或者在开源项目中也实现了类似完整的功能

gitee.com/ZHANG_6666/...

相关推荐
Kx…………3 分钟前
Day3:个人中心页面布局前端项目uniapp壁纸实战
前端·学习·uni-app·实战·项目
肠胃炎6 分钟前
认识Vue
前端·javascript·vue.js
七月丶9 分钟前
🛠 用 Node.js 和 commander 快速搭建一个 CLI 工具骨架(gix 实战)
前端·后端·github
砖吐筷筷11 分钟前
我理想的房间是什么样的丨去明日方舟 Only 玩 - 筷筷月报#18
前端·github
七月丶11 分钟前
🔀 打造更智能的 Git 提交合并命令:gix merge 实战
前端·后端·github
iguang12 分钟前
通过实现一个mcp-server来理解mcp
前端
Lafar12 分钟前
OC-runtime使用场景
前端
三原17 分钟前
实现多选树形组件,我把递归用明白了
前端·数据结构·vue.js
爱上大树的小猪19 分钟前
【前端样式】用 aspect-ratio 实现等比容器:视频封面与图片占位的终极解决方案
前端·css·面试
我血条呢81019 分钟前
一文带你入门 Nuxt 【俺是怎么学习一个框架的be like】
前端