ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Three.js粒子系统实战:动态星空3D球体与性能优化

Three.js粒子系统实战:动态星空3D球体与性能优化 最近帮人调一个落地页的视觉方案对方提了个很有意思的需求页面背景要一个“动态星空 3D 球体”星星密密麻麻地绕着一个球缓缓转动最好还能跟着鼠标有点互动。我第一反应就是用 Three.js浏览器里渲染这种粒子效果它几乎是首选方案。题目看起来简单真正动手时会发现里面藏着不少细节粒子在球面上怎么均匀分布、怎么做出“星空”而不是“白点团”、转起来怎样才不僵硬、移动端怎么保证不掉帧。这篇文章就按我实际踩坑的顺序把整个实现思路、关键代码、常见问题一次讲清楚。适合刚开始接触 Three.js 的前端同学也适合想在可视化项目里加点氛围感但不知道从哪下手的开发者。1. 项目拆解与核心思路1.1 这个需求本质上要解决什么“动态星空 3D 球体”听起来是一个整体拆开看其实是同一件事的四个约束得有 3D 球体球表面得有大量星星整体要有动态动态得流畅。你可以在场景里直接放一个球体几何体贴上星星纹理然后让球转起来。但真这么干视觉上会露怯贴图放大后星星是扁平的转起来像贴纸在表面滑动没有一点纵深和光感。换成 Three.js 的粒子系统后问题会变成一个个可以量化的点。每个星星对应一个三维坐标几千上万个点组成球壳通过 Points 和 PointsMaterial 一次性渲染。这种方案本质上是用类似批量绘制的机制替代逐物体渲染性能上限高得多。所以这个项目的核心不是“画一个球”而是“生成一堆球面坐标再喂给一个高效的粒子渲染管线”。另一个容易被忽略的点是需求里那个“动态”。动态并不只是旋转。我后来在实现里加了三种动态整体绕轴旋转、球体轻微的呼吸缩放、粒子大小随时间的柔和闪烁。三层叠在一起画面才不那么死板。如果你还想要交互再加上 OrbitControls 或者鼠标跟随那就是另一个故事了。后面我会把每一层怎么做的都写清楚。1.2 为什么选 Three.js 而不是别的方案在浏览器里做 3D绕不开 WebGL。你可以直接用原生 WebGL 自己写顶点着色器也可以选择 Three.js、Babylon.js、react-three-fiber 这类封装。对于“星空粒子球”这种中等复杂度的效果Three.js 是性价比最高的选择。文档和案例极多遇到问题随便一搜就有答案对新手友好。Points / PointsMaterial 这套粒子 API 非常成熟不需要自己管理着色器和缓冲。生态里有现成的 OrbitControls、后期特效、加载器扩展交互很容易。和原生 WebGL 相比学习曲线缓和很多和 Babylon.js 相比国内社区和案例更丰富。如果只是做一个项目内的小组件甚至可以直接用 CDN 引入模块不需要搭建复杂的工程化环境。这点很重要很多人卡在第一步不是因为代码不会写而是被 Node、打包工具吓退了。我在下一节会给出一个既能快速跑通、又能支撑后续工程化开发的过渡方案。1.3 粒子系统的运行原理初探在动手前有必要理解 Three.js 粒子系统的底层逻辑。粒子系统并不是在每帧绘制几千个子对象而是把顶点数据打包到 BufferGeometry 的 position 属性里然后调用 GPU 批量处理。GPU 拿到的是一个个点坐标再通过顶点着色器把它们投影到屏幕上最后用片元着色器决定每个点的颜色和透明度。所以优化粒子数量的本质是优化 GPU 的一次绘制调用。PointsMaterial 虽然好用但它每个粒子只是一个小方块或者纹理。如果你想做更高级的渐变、发光、动态大小通常需要自定义 ShaderMaterial。本项目的难度控制在“还不需要写复杂着色器”的范围用 PointsMaterial CanvasTexture 就能做出带光晕效果的星星。不过这里有个必须提前说清的坑PointsMaterial 不受场景灯光影响。它不是普通 Mesh光照系统对它没用。所以别指望加一个 PointLight 让星星亮起来星星的“亮”要依靠纹理自身的渐变、叠加模式AdditiveBlending以及透明度来模拟。这一点后面还会提到。2. 从零搭建 Three.js 开发环境2.1 用 CDN 快速跑通还是用 Vite 工程化这是我每次带新手都会强调的选择题。如果只是几分钟内想看到效果用 HTML 文件 importmap 引 CDN 是最省事的。直接在浏览器打开或挂个静态服务器就能运行。但如果后续想长期维护项目、依赖 npm 包、引入构建压缩建议一开始就用 Vite 建一个空前端项目然后 npm install three代码里通过 ESModule 导入。我的习惯是临时 Demo 用 CDN产品项目用工程化。下文的完整代码先以 CDN importmap 方式展示因为它的每一步都透明不需要额外配置文件。如果你准备做成真实项目只需要把 import 语句保留其余代码一模一样。具体讲Vite 项目初始化命令npm create vitelatest star-sphere -- --template vanilla cd star-sphere npm install npm install three npm run dev在 Vite 项目里引入 Three.js 的方式就是import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js;Vite 开发服务器会把 node_modules 里的模块依赖关系自动处理好浏览器访问 localhost 地址即可。两种方式我都很常用关键不是选哪个而是理解当前场景需要什么。2.2 最小场景场景、相机、渲染器无论多复杂的三维效果Three.js 的第一步永远是老三样Scene、Camera、Renderer。Scene 是舞台所有物体放进去Camera 决定从哪个位置、什么角度观察Renderer 把场景最终画到 canvas 上。下面这段代码是最小可运行框架直接复制到上面提到的 Vite 项目入口文件里也没问题import * as THREE from three; // 1. 场景 const scene new THREE.Scene(); scene.background new THREE.Color(0x050510); // 2. 相机 const camera new THREE.PerspectiveCamera( 60, // 视角角度越大看到的范围越广 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近裁剪面 1000 // 远裁剪面 ); camera.position.set(0, 0, 5); // 3. 渲染器 const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); // 4. 窗口变化自适应 window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // 5. 动画循环 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();这段代码里的每一个参数都有意义。相机 position 的 z 设为 5而球体半径大概会在 2 左右这样球体不会填满整个视线背景能露出更多星空氛围。远裁剪面 1000 对当前项目绰绰有余。renderer.setPixelRatio 限制到 2是为了防止高分屏像素比过高导致性能浪费尤其手机端这是很多页面卡顿的隐藏原因。2.3 为什么相机要放这个位置宽高比又怎么算PerspectiveCamera 的宽高比字段传 window.innerWidth / window.innerHeight。这是最容易理解也最容易忽视的参数。相机成像区域是一个四棱锥画面宽高比必须和 canvas 实际宽高比一致否则画面会被拉伸。尤其移动端横竖屏切换如果不监听 resize 并重新设置 camera.aspect球体会变形。关于相机位置有一点经验星空球体不要贴得太近也不要推得太远。太近的话粒子密布在视口边缘会让压迫感很强太远的话星星会小到失去细节。radius 2、camera z 5 只是一个起点实际项目中可以根据文本内容做微调。如果后面加了 OrbitControls用户能自己拖动视角初始距离也可以留得稍远一点。这里补充一个技巧当你调试相机位置时可以临时在场景里加一个辅助轴或者一个线框球const helper new THREE.AxesHelper(3); scene.add(helper);看到坐标系方向后你对粒子分布的观察会直观很多。做完调试记得删掉。3. 动态星空 3D 球体的核心实现3.1 生成均匀分布在球面上的粒子坐标这是整个项目技术含量最高的部分也是新手最容易写错的地方。很多人会直接写这种三段随机// 错误示范 const x (Math.random() - 0.5) * diameter; const y (Math.random() - 0.5) * diameter; const z (Math.random() - 0.5) * diameter;然后把所有点扔进 BufferGeometry。这样得到的不是球体而是一个实心立方体内部的点云。即使你以为自己在脑海中有“球形筛选”不加距离判断也完全不对。正确做法是用球坐标参数。球面上任意一个点有三个参数半径 r、极角 phi、方位角 theta。极角的范围是 0 到 π方位角的范围是 0 到 2π。从球坐标换算到直角坐标的公式是x r * sin(phi) * cos(theta) y r * cos(phi) z r * sin(phi) * sin(theta)这里还有一个数学陷阱如果直接写phi Math.random() * Math.PI点会大量聚集在南北极附近。因为球面的纬度越高同样 phi 间距对应的圆周长越小但均匀随机生成的 phi 不会自动补偿这种几何密度差异。解决方案是反余弦采样const phi Math.acos(2 * Math.random() - 1);这样输出的 phi 在 0 到 π 之间但分布会偏向赤道区域正好抵消球面极地收缩效应。完整生成粒子坐标的核心代码const radius 2; const count 12000; const positions new Float32Array(count * 3); for (let i 0; i count; i) { const theta Math.random() * Math.PI * 2; const phi Math.acos(2 * Math.random() - 1); // 让每一颗星星距离球心有一个微小随机偏移形成球壳厚度 const r radius (Math.random() - 0.5) * 0.15; const x r * Math.sin(phi) * Math.cos(theta); const y r * Math.cos(phi); const z r * Math.sin(phi) * Math.sin(theta); positions[i * 3] x; positions[i * 3 1] y; positions[i * 3 2] z; } const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3));那个r多加的 0.15 偏移是我特别喜欢的一个细节。如果所有星星严格贴在一个数学球面上转起来会像一层薄壳缺少“深度”。稍微让半径在 2 上下浮动一点远看是球近看每个粒子都有自己的前深层级立体感立刻不一样。3.2 用发光纹理代替纯白方块默认的 PointsMaterial 每个粒子就是一个实心小方块像素边缘非常生硬。把这种粒子铺满球面星空感会大打折扣。我的做法是在 Canvas 上画一个径向渐变圆创建成 CanvasTexture 作为粒子的 map。径向渐变的逻辑很简单圆心最亮向外逐渐透明。这样每个点都会呈现圆形的光晕多个粒子重叠的地方因为亮度叠加还有点星云的味道。function createStarTexture() { const canvas document.createElement(canvas); canvas.width 64; canvas.height 64; const ctx canvas.getContext(2d); const gradient ctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, rgba(255,255,255,1)); gradient.addColorStop(0.4, rgba(255,255,255,0.8)); gradient.addColorStop(1, rgba(255,255,255,0)); ctx.fillStyle gradient; ctx.fillRect(0, 0, 64, 64); return new THREE.CanvasTexture(canvas); }创建完纹理后配合 PointsMaterial 使用const material new THREE.PointsMaterial({ color: 0xffffff, size: 0.025, map: createStarTexture(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });这里的blending: THREE.AdditiveBlending是关键。加法混合会让重叠区域的亮度叠加暗色区域则透出背景的深蓝星星之间既不会互相遮挡也不会出现脏乱的黑边。depthWrite: false告诉 GPU 粒子的深度不需要写入缓冲这能避免粒子之间的排序问题还能提升性能。关于 size 参数很多人会问为什么粒子大小设定要那么小。这里要考虑实际投影相机距离 5半径 2视角 60 度屏幕上球体大约占 2 单位长度粒子 size 0.025 乘以这种投影关系后在视觉上刚好是米粒大小多个粒子组合起来饱满但不臃肿。你可以按自己屏幕微调我建议以 0.02 为起点不要一上来就给 0.1那样会糊成一片。3.3 让球体真正“活起来”动态是区分“demo”和“作品”的分水岭。我给星空球体叠加了三层动态代码不复杂但效果提升非常明显。第一层是整体旋转。把粒子对象放进场景后在动画循环里累加 rotationconst stars new THREE.Points(geometry, material); scene.add(stars); function animate() { requestAnimationFrame(animate); stars.rotation.y 0.001; stars.rotation.x 0.0002; renderer.render(scene, camera); }这个速度我一般用 0.001 到 0.002 之间转速太快容易让用户晕。stars.rotation.x加个很小的递增球体会有一个非常缓慢的轴倾变化看起来更自然。第二层是呼吸缩放。也就是让球体半径周期性地轻微放大缩小模拟呼吸感const time Date.now() * 0.001; const breath 1 Math.sin(time * 0.8) * 0.02; stars.scale.set(breath, breath, breath);0.02 的幅度意味着球体只放大缩小 2%肉眼能感知到但又不会太夸张。如果担心运动幅度过大可以把 sin 的系数降到 0.01。第三层是粒子闪烁。想要所有星星同时闪烁最简单的方式是动态改材质 size。但全局动画容易显得机械。我会用第二个 Points 对象让两组星星使用不同的材质 size 和透明度然后在动画里交错缩放视觉上就有了多层次的闪烁const starFieldA new THREE.Points(geometryA, materialA); const starFieldB new THREE.Points(geometryB, materialB); // 动画循环中 materialA.size 0.025 Math.sin(time * 1.5) * 0.005; materialB.size 0.035 Math.sin(time * 1.5 Math.PI) * 0.005;这两组星星位置完全一样但因为尺寸和相位错开整体看会有一种星群呼吸、此起彼伏的效果。真正做产品时这种细节最能体现质感。3.4 鼠标拖拽与点击识别可选扩展星空球体如果只能看、不能交互多少有点浪费。Three.js 官方提供 OrbitControls可以一行代码接入鼠标拖拽旋转import { OrbitControls } from three/addons/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.05; controls.minDistance 3; controls.maxDistance 8; // 动画循环里调用 controls.update();enableDamping打开后松手时视角会有轻微惯性手感好很多。minDistance和maxDistance限制缩放范围防止用户把相机怼进粒子球里面造成视觉穿帮。如果还想点击某颗星星触发事件需要用到 Raycaster。不过粒子系统的射线检测和 Mesh 不同Raycaster 对 Points 的支持粒度比较粗它只能知道点在哪个 draw range 上无法精确到单一粒子。如果你确实需要点击星点触发详细内容更可靠的方式是把球体分成若干个碎块区域或者用辅助 Mesh 做隐式碰撞这个复杂度已经超出本文范围先留个提示避免大家踩不必要的坑。4. 常见问题与性能调优实录4.1 黑屏、星星太小、球体变形问题速查我把做这个效果期间遇到的高频问题整理成一个表排查时对照着看能省不少时间。现象原因解决方案页面只有背景色没有星星相机没有对准球体检查 camera.position确认球体坐标在相机视野内星星几乎看不见size 太小或纹理透明度过高把 size 从 0.025 调到 0.05 试一下星星像方块没设置 map 或纹理比例不对使用 Canvas 渐变纹理并开启 transparent球体变形窗口尺寸变化没重新设置宽高比监听 resize重设 camera.aspect星星叠在一起发糊粒子数太多size 过大降低 count 或调小 size球体内部也看到大量星星可能生成坐标时没做球面采样用球坐标公式重新生成坐标这些问题大多不是代码逻辑错而是参数调优的问题。调 Three.js 和调 CSS 很像需要一点耐心每次只改一个变量。4.2 性能优化十万粒子如何在移动端不掉帧粒子数量决定了视觉上限也决定了性能风险。桌面端 10 万粒子通常在独显上能流畅运行但手机端受限于 GPU 和内存建议控制在 1 万到 3 万之间。这个项目的视觉效果在 8000 到 15000 个粒子之间表现最好数量再多也不会让画面明显更美但性能会陡然下降。几个实战优化点限制像素比。全局写renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))高像素手机不会因为 3 倍分辨率把 GPU 压垮。使用 BufferGeometry不要用旧版 Geometry。Three.js 也对 Geometry 做了移除现在用new THREE.BufferGeometry()就是标准写法。避免在动画循环里频繁创建对象。比如每帧 new THREE.Vector3垃圾回收会让帧率不稳定。需要临时变量就在循环外声明。不要把粒子系统里所有点都放到同一个 BufferAttribute 后频繁修改。如果你要做的是位置偏移动画修改 attribute 会触发大量数据上传这个开销通常比旋转整个 Points 对象大得多。简单动态优先旋转、缩放。如果粒子非常多可以把material.fog相关功能关掉或者不添加任何灯光减少不必要的着色器分支。我用 Chrome DevTools 的 Performance 面板实测过1.2 万粒子加加法混合加 CanvasTexture在 iPhone 类设备上稳定在 60 fps如果粒子数翻到 5 万就会出现明显发热和掉帧。做真实项目时可在初始化时根据设备屏幕宽度决定 countconst isMobile /Android|iPhone|iPad/i.test(navigator.userAgent); const count isMobile ? 8000 : 15000;这种方式简单粗暴但非常有效。4.3 发布部署与文件体积控制导出工程化项目时Three.js 本身的体积不小完整包大约 600KB 起步经过压缩后也会接近 150KB 左右。如果只是用 PointsMaterial 和几个核心模块可以依赖 tree shaking 把没有用到的部分剔除。Vite 默认构建就会做这个事但前提是代码里不要用import * as THREE from three这种全量导入方式。更好的写法是按需引入import { Scene, PerspectiveCamera, WebGLRenderer, Points, PointsMaterial, BufferGeometry, BufferAttribute, CanvasTexture } from three;如果你确实需要紧凑的包体还可以考虑只引 three.module.min.js。但一般来说这个效果本身不大主要体积都来自 three 核心库不必过度焦虑。发布前还有一个细节背景色。如果星球是要嵌入某个页面而非全屏独占可以把scene.background去掉让 renderer 的 canvas 透明再在页面里放一张宇宙背景图或渐变色。透明开启方式是const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setClearColor(0x000000, 0);这时球体之外的区域会透明页面底层的 CSS 渐变透过来融合得很自然。这也是很多官网星空背景的实现思路。4.4 本地调试的一个关键习惯必须起服务Three.js 模块化写法、纹理加载和很多外部资源请求都需要通过 HTTP 协议直接双击 HTML 文件用 file:// 协议打开大概率会遇到模块导入跨域或资源加载失败的问题。所以无论用 Vite 还是任何静态服务器都要开一个本地服务。我个人比较推荐 Vite 的另一个原因是热更新。你调节完粒子 size、颜色页面会立刻刷新这对反复试参数极其友好。若是只写了单个 HTML 文件团队里不常接触 Node 的同事可以用一行命令起服务npx serve .这就够了。收尾前的一点私货做完这个动态星空 3D 球体我自己最大的体会是Three.js 学习的核心不是记住 API而是建立“三维坐标系直觉”。项目里最绕的一关是球坐标采样一旦你理解了为什么要用phi acos(2 * random - 1)以后做螺旋点云、做粒子星系、做环形粒子流思路都是一通百通。最后再分享一个小技巧当你完成基础版本后把球体半径换成更小的值比如 0.5粒子数量不变再把相机拉远就能得到一个类似“微观星空”的雾状云团把粒子坐标换成围绕一条上升螺旋线分布就成了别人常说的“粒子星云隧道”。同一个框架换一组坐标生成逻辑就是完全不同的视觉。所以别急着追求花哨效果把这一块吃透比背十个案例有用得多。
返回列表