Three.js实战:打造三维图表与粒子动画特效的完整指南

发布时间:2026/9/9 15:33:57
Three.js实战:打造三维图表与粒子动画特效的完整指南 简介面向前端开发者的Three.js三维图形图表特效源码包聚焦浏览器中的交互式三维数据可视化。整合场景、相机、几何体、材质与光源等核心概念演示柱状图、饼图、散点图、线图等常见图表的三维化呈现适合数据可视化爱好者、前端工程师及希望扩展WebGL技能的开发者初学者与进阶者都能从中提取可复用的实现思路。压缩包共九个文件以七个JS脚本为主辅以一个HTML页面和一个HTM说明整体大小约500KB。脚本覆盖三维渲染、轨道控制、图表库、调试工具与交互组件HTML页面展示效果说明文档介绍运行流程核心封装模块可直接迁移复用。已有九百七十余人学习下载。通过说明可快速上手既能查看三维图表效果也能拆解逻辑融入项目打造更具视觉冲击力的数据看板或大屏展示页面。1. 为什么我会用Three.js去做图表而不是ECharts先说说我踩坑的起点。去年接了一个数据可视化的需求客户原话是要大屏、要酷炫、要别人一看就觉得我们技术很强。用ECharts的常规图表方案做了一版3D柱状图、地图、关系图都上了但客户看完之后说不够震撼看起来像报表。当时我就意识到一个问题——ECharts可以做3D效果它还内置了gl插件但它的逻辑始终是图表优先所有效果的边界都被约束在图表坐标系里。一旦你想让数据飞起来、让柱子从地面破土而出、让粒子沿着数据曲线流动ECharts的定制成本反而很高。Three.js是另一套思路。它先给你一个完整的3D渲染环境——场景、相机、光源、物体、后期特效然后你在里面画图表。同样是一个柱状图ECharts给你的是柱子的样式配置而Three.js给你的是创建一个柱子几何体、设置材质、放到指定坐标的全部自由度。它可以做成火箭发射动画也可以做成粒子玫瑰本质区别在于图表是Three.js里的一种玩法而不是它的全部。这篇文章就是围绕用Three.js做三维图形图表特效这个方向把从零搭建、粒子系统、动画设计、性能优化到工程集成的完整链路拆一遍。适合已经会基础HTML/JavaScript、想往数据可视化或Web3D方向进阶的前端开发者也适合做智慧大屏、数字孪生、营销活动页的人参考。2. 三维图表的底层逻辑把数据变成几何体2.1 坐标映射是三维图表的核心做ECharts图表的时候你不需要关心坐标系怎么建。但用Three.js做图表第一课就是怎么把业务数据变成3D场景里的坐标。举个最典型的圆柱柱状图例子。假设你有一组数据const data [ { name: 一月, value: 120 }, { name: 二月, value: 90 }, { name: 三月, value: 160 }, { name: 四月, value: 140 }, ];在Three.js里你要做的是给每个月的柱子指定一个x轴位置可以是等间距的 -6, -2, 2, 6间距大小取决于场景规模把value映射到柱子的高度比如一个单位代表10那么120就对应12个单位的柱高用CylinderGeometry圆柱或BoxGeometry方柱创建几何体放到对应的(x, 高度/2, 0)位置// 创建圆柱几何体高度由数据值决定 const geometry new THREE.CylinderGeometry(0.6, 0.6, height, 32); const material new THREE.MeshPhongMaterial({ color }); const cylinder new THREE.Mesh(geometry, material); cylinder.position.set(x, height / 2, 0); scene.add(cylinder);这里有个容易踩的坑柱子几何体的原点在它自己身体的中心不是底部。如果你想让它从地面y0往上升position.y必须设置为高度的一半否则柱子会一半在地上、一半在地下。这个细节在2D图表里完全不存在但3D图表里它直接决定效果的生死。2.2 颜色映射从数据到视觉的翻译柱子的颜色也不能随便定。一套好看的数据可视化颜色和数据值之间应该有关系。最简单的方案是做一个插值函数数值从小到大颜色从冷色蓝渐变到暖色红。你可以用THREE.Color自带的lerpColors方法const colorLow new THREE.Color(0x00aaff); // 蓝色 const colorHigh new THREE.Color(0xff3366); // 玫红 function mapValueToColor(value, minVal, maxVal) { const t (value - minVal) / (maxVal - minVal); const color new THREE.Color(); return color.lerpColors(colorLow, colorHigh, t); }在没有任何光源的情况下用MeshBasicMaterial也能看到颜色。但它看起来是平的、没有立体感。想要柱子有高光、有阴影、有体积感就得用MeshPhongMaterial或MeshStandardMaterial并且给场景加DirectionalLight方向光和AmbientLight环境光。我的习惯是环境光强度控制在0.4~0.6方向光强度0.8~1.2这样既能看清暗部细节又不会让整体发白。光照强度是纯经验值不同显示器看起来有差异最好的办法是调一个参考值后多屏检查。2.3 坐标轴和标签没有它们只是一堆形状很多初学者做到上一步就停了结果场景里就是一堆彩色的柱子外人根本看不出它代表什么。三维图表的专业程度很大程度上取决于辅助信息是否完整。Three.js没有原生的坐标轴和文字标签组件这是它和图表库最大的区别。常规做法有三种用THREE.AxesHelper快速加一个调试用坐标轴只适合开发环境用CSS2DRenderer把HTML标签叠加在3D场景上标签始终面向相机文字清晰用Canvas把文字画成纹理贴到Sprite上跟随3D场景移动实际做项目我推荐CSS2DRenderer的方案。原因是文字清晰度最高样式可以用CSS完全控制而且不用等待字体纹理加载。代价是你得维护两套渲染器每帧都要同步渲染。伪代码如下const labelRenderer new CSS2DRenderer(); labelRenderer.setSize(window.innerWidth, window.innerHeight); document.getElementById(container).appendChild(labelRenderer.domElement); // 动画循环里同时渲染 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); labelRenderer.render(scene, camera); }标签内容的创建很简单就是一个DOM元素包一层CSS2DObject再放到柱子顶部const div document.createElement(div); div.className bar-label; div.textContent 一月 120; const label new CSS2DObject(div); label.position.set(x, height 0.5, 0); scene.add(label);注意CSS2DObject的位置是跟随3D坐标的但元素本身是2D覆盖层所以不存在标签被柱子挡住的情况所有标签永远在最上层。这个特性在某些需要穿透展示的场景里是优点但在需要真实遮挡关系的场景里反而是缺点要心里有数。3. 粒子特效从静态图表到动态视觉的核心手段3.1 粒子系统的基础认知如果要给三维图表加特效粒子系统是最常用也最容易出效果的手段。火箭发射的尾焰、数据流的光点、星空背景、粒子玫瑰本质都是同一个东西一大批粒子按照某种规则运动。Three.js里实现粒子系统有两条路老方案THREE.Points PointsMaterial每个粒子就是一个点材质用贴图控制形状新方案THREE.InstancedMesh每个粒子用一个小几何体球体、立方体代替可以单独控制位置、旋转、缩放Points方案性能极高几十万个粒子也能跑得动但每个粒子只是一个点没有真实体积。InstancedMesh的粒子数量要少一些一两万以内没问题但每个粒子都是真实几何体可以有光影、可以看体积。做数据可视化大屏我一般这么区分纯装饰性粒子星空、流动背景用Points需要交互拾取或表现体积感的粒子代表数据点的球体、柱子的堆积单元用InstancedMesh。3.2 数据驱动的粒子动画火箭发射的拆解热搜里有一条three.js 3D火箭发射动画特效很多人以为是模型动画其实在数据可视化场景里火箭发射动画通常是粒子沿轨迹运动的变体。核心思路是这样的定义一条运动轨迹直线、贝塞尔曲线或极坐标曲线粒子沿着轨迹前进每个粒子的位置 轨迹上的点 随机偏移粒子的生命周期分为出生、飞行、消亡三个阶段用透明度或大小变化来表达用一组粒子加上时间偏移形成连续不断的流代码如下简化版// 假设有500个粒子 const count 500; const positions new Float32Array(count * 3); const speeds new Float32Array(count); const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); for (let i 0; i count; i) { speeds[i] 0.5 Math.random() * 0.5; // 给每个粒子随机的速度 } const material new THREE.PointsMaterial({ color: 0xff6633, size: 0.15, transparent: true, blending: THREE.AdditiveBlending, // 加法混合产生发光感 }); const points new THREE.Points(geometry, material); scene.add(points); function animateParticles(time) { for (let i 0; i count; i) { const progress (time * speeds[i] i / count) % 1; // 0~1循环 const t progress * 10; // 轨迹长度10个单位 positions[i * 3] Math.sin(t) * 2; // x positions[i * 3 1] t; // y向上飞 positions[i * 3 2] (Math.random() - 0.5) * 0.5; // z 随机偏移 } geometry.attributes.position.needsUpdate true; }这里最关键的三个地方粒子速度控制speed、进度取模循环% 1、每次更新后要设置needsUpdate true。第三个点几乎是所有Three.js初学者的第一个真正意义上的性能坑——如果你改了position数据但忘了告诉GPU去更新缓冲区画面会纹丝不动而你还在那拼命调参数怀疑人生。3.3 粒子玫瑰用数学公式制造高级感粒子玫瑰是我个人很喜欢的案例也是效果最能打的展示型项目之一。它的核心公式是极坐标玫瑰线r a * cos(k * θ)。当k是奇数时花瓣数量就是k当k是偶数时花瓣数量是2k。比如k5就有5瓣花k6的时候则是12瓣。这个数学规律直接决定了整体轮廓。实现方式非常简单先生成几千个随机角度和半径偏移代入公式算出x和z坐标y坐标可以用正弦函数做一点起伏让花朵有立体波浪感const particlesCount 8000; const positions new Float32Array(particlesCount * 3); const k 5; // 花瓣数 for (let i 0; i particlesCount; i) { const theta Math.random() * Math.PI * 2; const r Math.cos(k * theta) * 3 (Math.random() - 0.5) * 1.2; const x r * Math.cos(theta); const z r * Math.sin(theta); const y Math.sin(r * 2 theta) * 0.8; // 波浪上下起伏 positions[i * 3] x; positions[i * 3 1] y; positions[i * 3 2] z; }如果把theta的时间维度和r做关联再加上粒子颜色的渐变就能实现花朵绽放或呼吸旋转的动态效果。粒子玫瑰这类项目特别适合用来理解一个核心观念三维特效的本质不是复杂的代码而是找到合适的数学规则让大量简单元素在统一规则下产生整体美感。4. 避坑指南Three.js做图表最有价值的几条经验4.1 坐标系方向搞反是最常见的翻车现场Three.js默认使用右手坐标系x向右y向上z指向屏幕外朝向观察者。但很多人做数据可视化时会把它和2D Canvas坐标系搞混——Canvas的y轴是向下的。如果你把一个原本用于Canvas的坐标计算代码直接搬进Three.js图表会上下颠倒。而且这个问题发生在数据量大的时候特别难排查因为你可能会怀疑是相机角度问题、模型旋转问题调试半天最后发现只是y坐标忘取反了。我的建议是一开始就在代码顶部注释写明场景使用右手坐标系z轴朝外高度方向为y轴正方向并在创建所有对象时都遵循这个约定。这个习惯能在后续几个月省下大量时间。4.2 光照和颜色还原做图表和做游戏有个重要区别图表里的颜色承载着信息含义红涨绿跌、颜色深浅代表数值大小所以颜色保真度很重要。如果你用MeshLambertMaterial或MeshStandardMaterial它们都会受光照影响同一个颜色在不同角度、不同光照下看起来不一样。有时候客户盯着屏幕说这个颜色不对其实不是你的色值写错了而是光照太强或者材质反光把颜色冲淡了。两个解决办法数据代表类物体柱子、球体、路径用MeshBasicMaterial或MeshLambertMaterial把光照影响降到最低装饰类物体背景、粒子、地面用MeshStandardMaterial承载光和影增强立体感这样信息主体清晰环境氛围也有是视觉和功能平衡的常见做法。4.3 不要合并所有几何体我第一次做3D图表时把几千个圆柱体全部add进scene渲染帧率直接掉到20fps以下。后来才知道原因场景中每个Mesh都是一个独立的绘制调用draw call数量超过几百个后CPU和GPU的通信会成为瓶颈。后来改用InstancedMesh几千个柱子在同一个批次里绘制帧率一下回到60fps。代码结构大概是const count data.length; const mesh new THREE.InstancedMesh(geometry, material, count); for (let i 0; i count; i) { const matrix new THREE.Matrix4(); matrix.compose(position, quaternion, scale); mesh.setMatrixAt(i, matrix); } scene.add(mesh);InstancedMesh用起来有几个微妙之处每个实例的颜色可以通过setColorAt设置要让这个颜色生效材质必须设置vertexColors: true新版本是material.color初始为白色。另外如果你需要动某根柱子也不是改position属性而是重新计算矩阵再setMatrixAt。4.4 窗口自适应和销毁做图表类应用免不了遇到浏览器窗口缩放。Three.js的Canvas尺寸变了如果不更新渲染器和相机画面会被拉伸变形。常规处理是监听resize事件window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });但还有一个更隐蔽的问题如果你在Vue或React里用Three.js做组件组件销毁时必须手动释放WebGL上下文否则页面切换几次后会报Too many active WebGL contexts。function disposeScene() { renderer.dispose(); // 释放渲染器 scene.traverse((obj) { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { // 注意材质可能是数组 Array.isArray(obj.material) ? obj.material.forEach((m) m.dispose()) : obj.material.dispose(); } }); }这个资源释放函数是我在项目里吃过亏之后总结出来的。WebGL上下文是有数量上限的通常8~16个不释放的话用户多刷新几次页面整个3D功能就会白屏报错。5. 工程化集成Vue2和低代码场景下的落地经验5.1 Vue2项目里怎么优雅地管理Three.js热搜里有vue2 three.js说明这是非常现实的需求。Vue2的生命周期和Three.js其实没什么奇怪的配合问题只要遵守一个原则Three.js只做渲染Vue只做数据和UI控制两者通过ref和事件通信互不干扰。我在Vue2项目里的典型模板是这样的template div refchartContainer classthree-chart/div /template script import * as THREE from three; export default { data() { return { renderer: null, scene: null, camera: null, animationId: null, }; }, mounted() { this.initThree(); this.createChart(); this.animate(); }, beforeDestroy() { cancelAnimationFrame(this.animationId); this.disposeScene(); }, methods: { initThree() { const container this.$refs.chartContainer; this.scene new THREE.Scene(); this.camera new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 1000); this.renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(this.renderer.domElement); }, }, }; /script两个需要注意的细节第一画布尺寸应该从父容器取而不是直接用window.innerWidth。大屏项目里图表组件经常是页面局部区域如果用整屏尺寸画布和容器会失真。容器还可能涉及flex/grid布局需要等布局稳定后再初始化必要时在this.$nextTick里执行。第二动画循环用requestAnimationFrame每次循环渲染前应该判断this.scene和this.renderer是否仍然存在。我自己就遇到过组件销毁后动画循环还在跑导致渲染报错的情况。5.2 低代码平台的边界什么时候不折腾Three.js热搜里有一条低代码可编辑echarts图表这个方向和我做Three.js图表其实是一体两面。现在的低代码平台里配置ECharts已经很成熟了但Three.js的3D场景几乎不可能做成通用可视化配置项——每个项目的场景布局太个性化配置的复杂度会超过直接写代码。所以我的建议是分场景处理需要快速交付、图表类型常规、非核心展示位用ECharts配好样式就是最高效率核心大屏、需要品牌感和记忆点、允许前端花3~5天定制用Three.js做核心场景外围辅助数据仍然可以用ECharts两者结合是最优解Three.js负责主体3D场景ECharts图表以Texture方式贴上去或者用iframe嵌入这里再补一个实操技巧Three.js场景里的图表不一定要用Three.js自己写。你可以先用ECharts渲染出一个Canvas再把这个Canvas作为Texture贴到Three.js的平面上。这样既能享受ECharts的数据处理能力又能在3D空间里摆放多个悬浮图表面板。做法分四步创建ECharts实例渲染出Canvas用new THREE.CanvasTexture(canvas)创建纹理创建PlaneGeometry把纹理贴上去数据更新时调用ECharts的setOption后设置texture.needsUpdate true我在一个数字孪生项目里用这个方案把折线图、饼图、柱状图全部做成了三维空间里漂浮的数据面板效果很出众而且开发成本比全用Three.js原语低得多。5.3 性能预算给Three.js图表设一条及格线三维图表的性能优化不是等卡了再去调而是在开发前就设定好预算。我自己常用的经验数值总顶点数建议控制在10万以内普通PC独立Mesh数量控制在100以内超出就考虑InstancedMeshDraw call数量控制在200以内粒子数量Points方案不超过5万InstancedMesh方案不超过1.5万场景中同时存在的光源不超过3个多余的用贴图或材质模拟超过这个量级代码层面能优化的空间就很有限了必须考虑模型压缩、纹理压缩、LOD多级细节等渲染架构层面的手段。对于大多数数据可视化项目来说只要在这个预算内主流办公笔记本也能跑稳定在30fps以上。6. 从能跑到好看三维图表的四个进阶方向三维图表特效做到能看只是第一步真正拉开差距的是以下四个方向。这些也是我自己做项目时反复打磨的地方。第一动效节奏。物体入场动画和离场动画一定要快进快出不要让用户等待。柱状图的升起动画我认为400~800ms是合适的区间太短会闪眼睛太长会烦躁。所有运动的缓动函数用easeOutCubic这类先快后慢的曲线不要用线性运动线性运动会给人一种机械感而缓动会让人感觉自然舒适。Three.js没有内置缓动可以自己写一个也可以用Tween.js这类库。第二相机运镜。3D图表的优势是视角自由。入场时让相机从远处拉近到目标位置然后缓慢自动旋转配合OrbitControls让用户手动拖拽观感会提升一个档次。注意自动旋转的时候要设置controls.autoRotate true和controls.autoRotateSpeed 1.0左右转速不要太快否则用户会觉得头晕。第三交互反馈。鼠标悬停到柱子上时柱子应该有一点点反应——变亮、变大、或者显示数据标签。Three.js里做这个需要Raycaster射线检测const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); window.addEventListener(mousemove, (event) { mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObjects(chartMeshes); if (intersects.length 0) { // 高亮当前柱子 intersects[0].object.material.emissive.set(0x333333); } else { // 恢复所有柱子 } });Raycaster的性能也要注意不要每帧对几千个物体做检测那样也会卡。常规做法是只在鼠标移动事件里做检测并且检测目标只限制在场景中的Mesh列表而不是整个scene。第四后处理特效。如果是做游戏级特效方向Three.js的后处理EffectComposer几乎是绕不过去的。发光特效UnrealBloomPass能给粒子、路径、发光物体加上一层真实的辉光让画面瞬间有3A游戏的感觉。import { EffectComposer } from three/examples/jsm/postprocessing/EffectComposer.js; import { RenderPass } from three/examples/jsm/postprocessing/RenderPass.js; import { UnrealBloomPass } from three/examples/jsm/postprocessing/UnrealBloomPass.js; const composer new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloomPass new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, // strength 强度 0.4, // radius 半径 0.85 // threshold 阈值 ); composer.addPass(bloomPass); // 动画循环里用 composer.render() 而不是 renderer.render()后处理的坑是开启之后所有非发光物体也会被提亮所以关键参数strength和threshold至少要在好几个不同色调的场景里测试。另外后处理会增加GPU负担移动端谨慎使用。我在实际项目里的体会是很多人做Three.js图表特效最大的障碍不是不会写API而是不知道每一步调参的手感。比如Bloom强度1.5配threshold 0.85在深色背景大屏上是接近万金油的存在粒子数量8000到10000是一个视觉密度和性能的最佳平衡区相机fov默认45°在大多数办公显示器上不会显得畸形。这些数值没有官方文档会告诉你都是在实际项目中一次次调出来的肌肉记忆。三维图表特效这个方向说难也难——它需要你同时懂图形学基础、JavaScript工程化、设计审美和数据思维但说简单也真的简单——Three.js已经把渲染层完全封装好了你只需要理解场景、相机、几何体、材质、光源这五个核心概念就能做出远超平面图表上限的作品。如果这篇文章能帮你少走几个弯路让你在第一个Three.js项目里少加两小时的班去排查一个忘了设置的needsUpdate那我写这些就算值了。本文还有配套的精品资源点击获取

相关新闻