Three.js粒子系统实战:火焰特效从着色器到参数调优

📅 发布时间:2026/10/21 0:29:53
Three.js粒子系统实战:火焰特效从着色器到参数调优
简介一份基于three.js构建的火焰效果演示源码包面向Web前端与三维可视化学习者。项目通过粒子系统模拟火焰动态核心逻辑清晰调整粒子颜色参数后还可呈现烟雾效果适合新手理解粒子属性与着色器配合的基本思路。资源共包含3个文件以js脚本和html页面为主两个js文件分别负责场景控制与轨道交互html文件用于直接运行展示整体压缩后约191KB轻量易部署。目前已有5036人浏览学习。打开演示页面即可观察火焰的粒子运动轨迹参考配套的three.js与OrbitControls用法可快速迁移到粒子特效、动态纹理等场景通过修改颜色和透明度相关参数能扩展出不同形态的火焰或烟雾效果为后续自定义视觉特效提供可直接改用的代码基础。1. 火焰demo是什么以及它解决的真实问题在Web端做火焰效果很多人第一反应是找一段现成的shader贴上去或者挂一个序列帧循环播放。但一旦遇到背景不是纯黑、需要跟场景里其他物体产生遮挡关系、或者要在大屏上长时间跑的场景这两种方案都会翻车——序列帧容易发灰shader贴图则完全没有体积感。基于three.js的火焰demo本质上是把火焰拆成大量带生命周期的粒子用GPU去更新位置和颜色在浏览器里实时算出一团可交互、可旋转、可被光照影响的火焰。它能解决的场景很具体数据大屏的火焰主题背景、产品页的燃烧效果展示、游戏里的技能特效、甚至工业仿真里的火焰示意。这套方案的核心不是“画出火焰”而是“用可控制的参数模拟燃烧过程”适合需要把火焰跟三维场景融合、又要保证帧率稳定的从业者。下面直接从实现思路开始拆。2. 三条路线怎么选以及粒子方案的原理支点2.1 三种实时火焰方案的对比为什么粒子系统胜出做火焰demo业内常见路线有三条。第一条是序列帧动画把预渲染的火焰帧贴在平面上循环播放优点是效果上限高、可控性好缺点是文件体积大、视角受限——平面一旦旋转就会穿帮。第二条是体积烟雾模拟用ray marching或3D纹理实时计算光线穿过火焰的散射效果最真实但计算量在Web端很难压到60帧移动端基本不用考虑。第三条就是粒子系统加自定义着色器用几百个带生命周期的粒子组成火焰形状每个粒子独立上升、缩放、变色最终靠叠加形成视觉上的连续燃烧体。这条路线的核心优势在于粒子数量可控、GPU负担小、视角自由、颜色和形态完全靠一组参数驱动改一个上升速度就能让火焰从温和变成剧烈。我一般会直接选第三条因为three.js里Points对象本身就是为这种场景准备的。它不像Mesh那样有面片概念每个粒子只是一组坐标和属性值渲染时全部交给GPU处理。火焰的“体积感”靠的是粒子在空间里的密度分布和半透明叠加而不是真正的几何体积。换句话说你看到的一团火焰其实是几十上百个半透明圆形光点在共同演出。2.2 粒子系统的关键结构从数据到可视化的完整链路要理解火焰demo的代码先记住一条链路数据在JavaScript里生成属性在顶点着色器里演化颜色在片元着色器里决定。具体来说每个粒子需要记录三类信息初始位置、运动参数、生命周期状态。初始位置决定火焰在空间里的分布范围通常是一个圆形底面加上高度偏移运动参数包含上升速度、湍流幅度、随机种子这些决定了粒子飞行的“性格”生命周期状态则由一个从0到1不断循环的时间变量控制驱动粒子从出生、燃烧到熄灭的整个过程。数据准备好之后交给BufferGeometrythree.js会把属性数组绑定到GPU缓冲。这里有个容易忽略的细节粒子数量一旦确定属性数组长度就固定了我们不能在运行时要增删粒子只能靠每个粒子的“当前状态”来伪装出生和死亡。最常见的手法是为每个粒子分配一个随机的初始相位让它们在不同时间点出生这样整体看起来就像源源不断的火焰。顶点着色器每帧根据时间推进相位把对应的粒子高度、偏移量算出来片元着色器再根据相位查颜色和透明度。整个过程不涉及cpu逐帧计算每个粒子的位置性能核心就在这里。2.3 为什么用ShaderMaterial而不是PointsMaterialthree.js内置的PointsMaterial只支持设置单个颜色和大小没法做火焰这种“粒子内部有渐变、透明度随生命期变化”的效果。所以必须用ShaderMaterial自己接管顶点变换和片元着色。很多新手在这里有一个误解以为ShaderMaterial意味着要自己处理投影矩阵、模型视图矩阵这些底层的玩意。其实three.js预置了projectionMatrix、modelViewMatrix这些uniform在顶点着色器里直接用就可以了不需要从零搭建矩阵管线。选择ShaderMaterial还有个附带好处渲染队列可以手动控制。Points内置材质默认的深度测试和混合模式不太适合火焰这种半透明叠加ShaderMaterial里我们可以把blending设成AdditiveBlending让每个粒子叠加发光而不是互相遮挡变暗。这个设置在火焰demo里几乎决定了成败稍后避坑章节会专门说。3. 手写火焰粒子系统从零到能跑的完整实现3.1 初始化场景、相机与渲染器骨架先搭一个最简的three.js骨架不引入任何外部插件。用vite初始化一个项目装好three后在main.js里写如下基础结构import * as THREE from three; // 场景、相机、渲染器 const scene new THREE.Scene(); scene.background new THREE.Color(0x0a0a14); // 深色背景便于观察火焰 const camera new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100); camera.position.set(0, 2.5, 6); camera.lookAt(0, 1.5, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); // 火焰粒子系统挂载到这个group上方便整体位移 const flameGroup new THREE.Group(); scene.add(flameGroup); // 动画循环 function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();这段代码的核心是渲染器配置。setPixelRatio限制最大像素比到2是为了防止Retina屏上粒子尺寸计算失真火焰边缘出现锯齿。camera的位置和lookAt目标决定了火焰出现在画面中心偏下给粒子上升留出空间。flameGroup作为容器后续如果需要把火焰放在某个物体上只需调整group的position而不动粒子内部数据。3.2 用BufferGeometry存放粒子的位置、速度与随机种子火焰粒子最朴素的实现是每个粒子从底部生成随着时间向上运动同时向四周扩散。因此在BufferGeometry中设置三类固定属性初始位置、随机种子、生命周期起始相位。const PARTICLE_COUNT 200; const positions new Float32Array(PARTICLE_COUNT * 3); const seeds new Float32Array(PARTICLE_COUNT); const phases new Float32Array(PARTICLE_COUNT); for (let i 0; i PARTICLE_COUNT; i) { // 粒子出生位置底部半径1.2的圆内高度0附近 const radius Math.random() * 1.2; const angle Math.random() * Math.PI * 2; positions[i * 3] Math.cos(angle) * radius; positions[i * 3 1] Math.random() * 0.3; positions[i * 3 2] Math.sin(angle) * radius; // 随机种子用于让每个粒子的偏移和运动节奏不同 seeds[i] Math.random() * Math.PI * 2; // 生命周期起始相位错开出生时间避免所有粒子同时喷发 phases[i] Math.random(); } const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); geometry.setAttribute(seed, new THREE.BufferAttribute(seeds, 1)); geometry.setAttribute(phase, new THREE.BufferAttribute(phases, 1));属性命名要跟着色器里的attribute变量一一对应。这里position是内置的seed和phase是自定义的。注意phase的取值范围是0到1代表整个生命周期的进度。position的y坐标初始只有0到0.3说明粒子出生在火焰根部之后靠着色器里的time变量把它推上去。radius控制在1.2以内是为了让火焰底部不会太宽视觉上呈现“下窄上宽”的喷射形态这和真实火焰的形态是一致的。3.3 顶点着色器上升、湍流与循环生命周期顶点着色器是整个火焰demo里最核心的计算单元每个粒子每帧都会经过它。传入的时间uniform从动画循环里更新粒子根据phase和time算出自己当前的生命周期进度然后决定高度、水平偏移和可见性。// vertex shader attribute float seed; attribute float phase; uniform float uTime; uniform float uSpeed; uniform float uTurbulence; uniform float uSize; varying float vProgress; varying float vSeed; void main() { // 生命周期进度时间驱动相位错开实现循环 float progress fract(uTime * uSpeed phase); // 粒子上升0到1映射到0到火焰高度的区间 vec3 pos position; float height progress * 3.5; pos.y height; // 湍流偏移用种子和高度做sin扰动越高越晃 float wobble sin(uTime * 2.0 seed) * uTurbulence; float wobble2 cos(uTime * 1.8 seed * 2.0) * uTurbulence; pos.x wobble * progress; pos.z wobble2 * progress; // 随高度增加粒子逐渐变大模拟火焰扩散 float sizeScale 0.8 progress * 1.2; vProgress progress; vSeed seed; vec4 mvPosition modelViewMatrix * vec4(pos, 1.0); gl_PointSize uSize * sizeScale * (300.0 / -mvPosition.z); gl_Position projectionMatrix * mvPosition; }这里的逻辑有几个关键设计。首先progress是fract后的小数天然循环不需要额外的出生和死亡标记。其次湍流的振幅乘以progress意味着粒子在低处时摆动小越往上晃得越厉害符合火焰上升时空气扰动加剧的直觉。gl_PointSize用300除以视角距离这是three.js里常用的点大小缩放方式保证粒子离相机远时不会小到消失近时不会大到糊屏。uSize是全局缩放系数调它可以把整团火焰放大缩小而不影响粒子数量。如果希望火焰在强风环境下形态更乱调大uTurbulence即可不需要改代码逻辑。3.4 片元着色器从金黄到暗红的渐变与透明度衰减粒子在屏幕上呈现为圆形光斑。片元着色器要做两件事画出圆形边缘并做抗锯齿以及按生命周期进度从亮黄过渡到橙红再到暗色。这里我用距离场判断片元是否在圆内用smoothstep做边缘柔化。// fragment shader varying float vProgress; varying float vSeed; void main() { // 计算当前片元到粒子中心的距离gl_PointCoord范围0到1 vec2 uv gl_PointCoord - 0.5; float dist length(uv); // 圆形粒子smoothstep柔化边缘避免锯齿 float alpha 1.0 - smoothstep(0.3, 0.5, dist); // 火焰颜色三段渐变从亮黄白到橙红到暗红 vec3 colorA vec3(1.0, 0.95, 0.6); // 火焰核心亮黄白 vec3 colorB vec3(1.0, 0.45, 0.1); // 中层橙红 vec3 colorC vec3(0.5, 0.1, 0.02); // 外层暗红 vec3 color; if (vProgress 0.4) { // 出生阶段亮到黄的过渡 color mix(colorA, colorB, vProgress / 0.4); } else if (vProgress 0.8) { // 燃烧中段从橙红向暗红走 color mix(colorB, colorC, (vProgress - 0.4) / 0.4); } else { // 熄灭阶段快速变暗变透明 color colorC * (1.0 - (vProgress - 0.8) / 0.2); } // 透明度出生和熄灭阶段都压低中段最实 float lifeAlpha sin(vProgress * 3.14159) * 0.85; float finalAlpha alpha * lifeAlpha; // 叠加到场景做常见的火焰混合 gl_FragColor vec4(color * finalAlpha, finalAlpha); }片元着色器里最值得留意的是分段渐变逻辑。如果用单一的mix线性插值从黄到红中间会经过一段浑浊的棕色火焰看起来像被污染了。这里拆成两段每段只混合两种相近颜色视觉过渡就干净很多。透明度曲线用sin函数的原因在于粒子出生和熄灭都应该是渐进过程sin在0和π两端都是0中间接近1天然匹配生命周期的平滑性。0.85这个系数不是拍脑袋定的如果太接近1粒子叠加时中心会过曝成纯白失去层次感。3.5 合入场景并更新uniform着色器写完之后需要把它挂到ShaderMaterial上再创建Points加入场景并在动画循环里更新uTime。const material new THREE.ShaderMaterial({ vertexShader: vertexShader, fragmentShader: fragmentShader, uniforms: { uTime: { value: 0 }, uSpeed: { value: 0.4 }, uTurbulence: { value: 0.35 }, uSize: { value: 28 }, }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, }); const flame new THREE.Points(geometry, material); flameGroup.add(flame); // 动画循环里更新uTime function animate() { requestAnimationFrame(animate); material.uniforms.uTime.value 0.016; // 约60fps推进 renderer.render(scene, camera); } animate();注意uniforms里的uTime没有用时钟计算delta而是固定加0.016。这样一个缺点是帧率不稳时速度会偏慢或偏快但火焰demo通常只做展示固定步长可以让粒子运动速度跟帧率无关视觉更稳定。实际项目中如果需要精确计时应该用clock.getDelta()但要注意乘以一个倍率否则火焰会显得迟缓。depthWrite设为false是半透明渲染的黄金法则稍后避坑章节专门展开。4. 参数调优与避坑让火焰从“冒烟”到“燃烧”4.1 核心参数的推荐区间与手感方向同样的代码参数设置不同看起来可能完全是两个东西。火焰demo里最影响观感的四个参数是粒子数量、uSpeed、uTurbulence和uSize。我整理了一份常用的参考区间按从“温和”到“剧烈”的方向排列。参数温和值推荐值剧烈值调整效果粒子数量80200400越多越密实但超过350后边际收益递减uSpeed0.250.40.7决定火焰上窜速度过大变成喷射uTurbulence0.150.350.6决定火焰左右晃动幅度过大变成烟雾uSize182840粒子光斑大小过大会糊成一团新手最容易犯的错是直接调大粒子数量来追求密实感。实际上把uSize从28提到40视觉密度提升比粒子数翻倍更直观。但uSize过大的副作用是火焰边缘出现明显的方形点阵轮廓因为gl_PointSize的粒子本质上是正方形的点精灵圆的观感全靠片元着色器里的距离场裁剪。我的习惯是先定uSize再定粒子数量最后用uTurbulence微调形态。uSpeed要跟场景中的相机距离联动相机拉远时火焰在屏幕上的位移速度不变会显得拖沓可以按相机距离的平方根做缩放。4.2 避坑记录一半透明粒子互相遮挡导致火焰变黑现象火焰内部出现不自然的黑色斑块粒子重叠的地方不是变亮而是变暗。原因three.js默认的NormalBlending会按深度混合半透明物体粒子位置交错时后面的粒子会部分盖住前面的粒子叠加处反而显得脏。火焰本质上是发光体不是反射光线的实体不应该互相遮挡。解决把blending切换成THREE.AdditiveBlending同时把depthWrite设为false。加法混合的结果是粒子颜色直接相加重叠区域越来越亮正好符合火焰发光的感觉。另外记得把material.transparent设为true否则shader里的alpha不会被用于混合计算。4.3 避坑记录二火焰颜色发棕渐变断层明显现象火焰不是从亮黄到橙红而是中间有一段很脏的棕褐色像烧焦的塑料。原因直接用mix从亮黄插值到深红时RGB三个分量不是同步变化黄色和红色的中间值会经过一个低饱和度的棕色区域。这在颜色数学上是必然的跟火焰物理无关。解决把渐变拆成多段每段只混合相邻的两个目标色。同时把颜色空间的gamma校正考虑进去。three.js默认输出到sRGB但shader里计算的颜色如果在线性空间直接混合结果会偏亮。实际处理时可以在片元着色器末尾做一个近似校正color pow(color, vec3(0.9))压一压高光让火焰核心保留细节而不是一片惨白。4.4 避坑记录三粒子闪烁或突然消失现象火焰跳动很不稳定部分粒子在生命周期中途直接消失还有的粒子角上出现裁切正方形。原因最常见的是gl_PointCoord在点精灵边缘处采样错误或者gl_PointSize小于1像素时被GPU直接丢弃。另一个隐蔽原因是没有设置gl_PointSize的最小值粒子在远处时尺寸趋近于0片元着色器里smoothstep(0.3, 0.5, dist)会因为采样点太少出现黑点。解决在顶点着色器里对gl_PointSize做一个下限保护gl_PointSize max(gl_PointSize, 2.0)保证最近的粒子也至少有2像素大小。同时把片元着色器的alpha计算改为alpha 1.0 - smoothstep(0.28, 0.48, dist)缩短柔化区间减少边缘的锯齿区域。4.5 避坑记录四火焰底部悬空与地面没有衔接现象火焰粒子从半空中凭空生成底部有一个明显的空心截面。原因粒子出生时y坐标随机分布在0到0.3但所有粒子一出生就开始上升没有被“压在底部”的粒子所以底部看起来稀疏且悬空。解决在片元着色器的lifeAlpha里压低出生阶段的透明度还不够更有效的手段是让粒子在低progress时水平偏移也很小制造“从根部长出”的视觉效果。可以在顶点着色器里让wobble效果乘上progress的平方而不是progress本身这样底部的粒子几乎只在垂直方向运动视觉上火焰是从一个点向上延伸的。5. 进阶把Demo变成能用的火焰组件火焰demo一旦跑通下一步就是让它具备真实项目的可用性。三个最实用的进阶方向热浪扭曲、多层叠加、火焰与场景的交互反馈。热浪扭曲推荐用后处理Pass实现常见的做法是单独渲染一张火焰的亮度贴图然后对主场景进行uv偏移采样。编辑器里调uv偏移量强度在0.002到0.01之间比较合适过高会让整个画面像泡在水里。如果不想引入后处理库也可以做一个廉价版在火焰粒子里额外增加一个呼吸scale参数让火焰整体大小按sin(uTime * 1.2)波动同时火焰周围的近景物体做一个反向形变。这种做法扛不住近距离特写但大屏场景完全够用。多层叠加是提升火焰质感最直接的路径。用同样一套代码创建两组粒子一组粒子数量多、尺寸小、速度慢颜色偏暗红作为火焰的底层体积另一组粒子数量少、尺寸大、速度快颜色偏亮黄作为高温核心。两组粒子共享同一个flameGroup转速和湍流方向略微错开。这种分层的视觉逻辑模仿的是真实火焰内部高温区和外部低温区的结构叠加后火焰的立体感会明显上升。注意两组粒子的uniform要分别实例化不能共享同一个ShaderMaterial。光照交互方面火焰通常是场景中的光源而不是被光照物体。如果场景里有地面或者其他几何体可以在火焰中心放一个PointLight颜色设为橙色强度设置为2到3距离3到5。这样一来火焰粒子本身是自发光材质不受光照影响但它能照亮周围的地面视觉上火焰和场景就融合了。最后说一个自己的血泪教训火焰demo做出来很容易调好很难而且调试时眼睛很容易疲劳。盯着火焰看十分钟以上判断力就会下降改出来的参数往往越调越怪。我的习惯是每次只改一个参数改完截一张图标注参数值隔一段时间再回头对比。这样攒下来一组自己项目的参数预设换场景时直接套用比反复试错效率高得多。three.js的火焰方案做到这个程度已经可以支撑实际项目了剩下的无非是换配色、调数值、加交互接口。希望帮到你。本文还有配套的精品资源点击获取