前言
相信大家开发在三维数字孪生、智慧城市看板等前沿可视化场景中,都会遇到这样一个问题如何在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 对象渲染
代码实现
- 将用于渲染和创建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图表模块功能

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