
简介这是一份基于Three.js的火焰烟雾交互动画示例代码面向前端开发者与WebGL动画入门者重点演示如何利用粒子系统、纹理映射和requestAnimationFrame逐帧刷新营造火焰从亮黄到暗蓝的温度渐变以及烟雾升腾、扩散与消散的视觉特效。压缩包包含13个文件核心逻辑集中在9个JavaScript文件中包括Fire.js粒子逻辑、three.min.js核心库、OrbitControls相机控制、tween简易补间、Stats性能统计等另有一个HTML入口、两张PNG贴图和一张JPG背景图整包仅230KB非常轻量适合直接运行和学习。目前已有4785人学习或下载。资源可直接在浏览器中打开通过Three.js的Points与TextureLoader加载纹理配合粒子生命周期属性控制速度、重力和透明度让火焰和烟雾效果具备真实动态感同时提供轨迹球控制和性能监测模块方便观察参数调整对渲染的影响很适合作为Three.js动画与粒子效果学习的实战范本。1. 基于Threejs的火焰烟雾动画效果代码解决的第一个问题让“燃烧”动起来在数字孪生产线、工厂事故模拟、智慧园区大屏里最常被要求“加一点氛围感”的场景就是火焰和烟雾。用视频贴图当背景的做法在固定视角下能糊弄过去但镜头稍微一旋转、一拉近平面贴片的破绽马上就露出来拿模型做形变动画又很难做出火焰那种从底部爆发、上升、卷曲、消散的连续感。真正能在浏览器里稳定跑、又能随意换视角的方案是让几百上千个粒子各自按一套“出生—上升—变色—消亡”的规律运动用点的集合在视觉上合成火焰和烟雾。这篇文章就围绕如何用 Three.js 的 Points 系统、BufferGeometry 和自定义 ShaderMaterial从零搭出一套可运行、可调参的火焰烟雾动画效果代码覆盖粒子数据结构、着色器写法、纹理生成、参数组合与性能收尾。2. 粒子系统与点精灵把火焰烟雾数据流拆清楚火焰烟雾这类效果视觉上天然是“群体运动”单独看一个颗粒没有意义合在一起才像火。Three.js 的Points专门为这种情况设计它不渲染三角面而是把每个顶点渲染成一个始终面向相机的点精灵也就是 billboard。这个特性意味着火焰烟雾粒子不需要做任何朝向计算投影矩阵算完位置屏幕上的四边形自动正对镜头非常适合做大数量粒子。2.1 为什么是 Points 而不是 Mesh 实例化从 GPU 的渲染路径看Points和InstancedMesh都能画大量对象。区别在于Points的每个顶点只能携带少量属性没有法线、没有 UV也不参与逐像素光照片元着色器拿到的只有gl_PointCoord点在屏幕上的局部坐标和你在顶点着色器里传下去的 varying。这张“极其朴素”的渲染管线恰恰是粒子算得快的原因不需要顶点索引、不需要包围盒裁剪、不需要光照插值GPU 一次 draw call 就能把上千个粒子全部画完。火焰烟雾系统用到的属性通常控制在几个BufferAttribute里位置、随机种子、生命周期。随机种子在粒子出生时一次性写入之后不变位置和生命周期每帧更新。为了避免在循环里频繁扩容数组必须要在一开始就分配好最大粒子数然后用一个游标循环复用槽位让死亡的粒子被新出生的粒子覆盖。2.2 纹理、透明与混合模式决定粒子“软不软”把gl_PointCoord采样到一张圆形渐变贴图上粒子的边缘就不再是锋利的小方块而是柔和的球状光斑。这个技巧是所有粒子烟雾效果的基础一片区域里大量柔边粒子叠加自然就有了体积感。贴图可以先用 Canvas 动态生成径向渐变省去外链图片的加载环节也让效果代码可以直接丢进任何项目里跑。材质端有两个关键开关depthWrite: false。粒子是半透明的如果让它们写深度缓冲后画的粒子会被先画的粒子挡住形成乱序反而要关掉深度写入让它们全按透明混合叠加。混合模式按效果选择。火焰是自发光体用AdditiveBlending做加法混合红色和黄色叠加以后会越来越亮非常贴近火焰的亮度烟雾是遮挡物要用NormalBlending配合低透明度压暗背景才能表现出烟雾的厚重感。2.3 火焰烟雾系统的数据流设计CPU 控制轨迹GPU 控制外观实际项目里火焰烟雾系统通常拆成两层CPU 负责更新粒子的位置、速度和寿命把结果写进BufferAttributeGPU 再用这些属性算出颜色、透明度和大小。这种划分的好处是灵活想调整粒子轨迹改的是 JavaScript 循环想调颜色梯度改的是着色器。可以先把属性结构定下来。一张表就能看出哪些属性需要动、哪些只出生时写一次。属性名类型更新时机用途positionFloat32Array × 3每帧粒子世界坐标aRandFloat32Array × 3出生时一次随机方向、大小扰动种子aLifeFloat32Array × 1每帧0 到 1剩余生命比例velocityFloat32Array × 3每帧CPU 侧速度不传给 GPUaLife应该是剩余生命还是已流逝生命这里有一个容易忽略的细节。建议存剩余生命比例remaining / maxLife这样在片元着色器里直接拿它做衰减透明新粒子剩余生命是 1越接近死亡越趋近 0不需要再单独准备一个随时间递增的累计值。你可以按这个结构先把数据层建好再来写渲染层后面扩展风场、浮力、旋涡扰动都只是往 CPU 更新循环里加力的方向而已。3. 从空白页面到第一团火焰Threejs 火焰粒子系统的最小可运行代码这一章给出一套能直接打开的完整实现。这里以 Three.js r160 左右的语法为例用 importmap 引入模块避免一开始就引入打包工具在正经工程里你可以换成 npm 引入。核心思路是生成一个软圆纹理创建一个固定大小的粒子 BufferGeometry用 ShaderMaterial 控制颜色和透明度然后在动画循环里更新粒子的生命期和位置。3.1 场景、相机与渲染器的初始化script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js } } /script script typemodule import * as THREE from three; const scene new THREE.Scene(); scene.background new THREE.Color(0x111122); const camera new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100); camera.position.set(0, 1.5, 5); 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); /script这段初始化里值得注意的不是场景尺寸而是setPixelRatio(Math.min(devicePixelRatio, 2))。火焰粒子在屏幕上是大量半透明叠加分辨率越高GPU 填充率消耗越大手机浏览器上经常是 3 倍像素比把帧率拖垮限制到 2 可以在视觉损失很小的情况下保住帧率。3.2 用 Canvas 生成柔和粒子纹理function createSoftCircleTexture(size 256) { const canvas document.createElement(canvas); canvas.width size; canvas.height size; const ctx canvas.getContext(2d); const half size / 2; const gradient ctx.createRadialGradient(half, half, 0, half, half, half); gradient.addColorStop(0.0, rgba(255, 255, 255, 1.0)); gradient.addColorStop(0.3, rgba(255, 255, 255, 0.8)); gradient.addColorStop(0.7, rgba(255, 255, 255, 0.3)); gradient.addColorStop(1.0, rgba(255, 255, 255, 0.0)); ctx.fillStyle gradient; ctx.fillRect(0, 0, size, size); return new THREE.CanvasTexture(canvas); }纹理的本质是一张 alpha 渐变图中央不透明边缘完全透明。ShaderMaterial 里采样这张图后用gl_PointCoord去取当前像素在点精灵中的相对坐标越靠近边缘纹理颜色越透明粒子就变成了一个柔和的圆形光斑。这里还有个生成细节半径方向的渐变不要用一条直线从不透明到透明中间多留一个 0.3 位置的高值段会让粒子中心亮度更高、边缘衰减得更自然多个粒子叠在一起时不会出现明显的“气泡感”。3.3 粒子池与着色器材质const MAX_PARTICLES 1200; const positions new Float32Array(MAX_PARTICLES * 3); const randoms new Float32Array(MAX_PARTICLES * 3); const lives new Float32Array(MAX_PARTICLES); const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage)); geometry.setAttribute(aRand, new THREE.BufferAttribute(randoms, 3).setUsage(THREE.StaticDrawUsage)); geometry.setAttribute(aLife, new THREE.BufferAttribute(lives, 1).setUsage(THREE.DynamicDrawUsage)); const material new THREE.ShaderMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, uniforms: { uTexture: { value: createSoftCircleTexture() }, uTime: { value: 0 } }, vertexShader: attribute vec3 aRand; attribute float aLife; uniform float uTime; varying float vLife; varying vec3 vRand; void main() { vLife aLife; vRand aRand; vec4 mvPosition modelViewMatrix * vec4(position, 1.0); // 用剩余生命控制粒子尺寸新出生大接近死亡时收缩 float size 1.2 * aLife; size * 180.0 / -mvPosition.z; gl_PointSize size; gl_Position projectionMatrix * mvPosition; } , fragmentShader: uniform sampler2D uTexture; varying float vLife; varying vec3 vRand; void main() { vec4 tex texture2D(uTexture, gl_PointCoord); if (tex.a 0.05) discard; float t 1.0 - vLife; // 0 新生1 接近死亡 vec3 white vec3(1.0, 0.95, 0.6); vec3 yellow vec3(1.0, 0.7, 0.15); vec3 orange vec3(1.0, 0.35, 0.02); vec3 red vec3(0.4, 0.02, 0.0); vec3 color; if (t 0.3) { color mix(white, yellow, t / 0.3); } else if (t 0.7) { color mix(yellow, orange, (t - 0.3) / 0.4); } else { color mix(orange, red, (t - 0.7) / 0.3); } float alpha tex.a * smoothstep(0.0, 0.15, vLife) * vLife; gl_FragColor vec4(color, alpha); } }); const particles new THREE.Points(geometry, material); particles.frustumCulled false; scene.add(particles);这段代码里有几个参数值得逐一解释。while 循环里的smoothstep(0.0, 0.15, vLife)是为了防止粒子刚出生时在 0.15 秒内过于突兀地“啪”一下出现在场景里。vLife是剩余生命刚出生是 1smoothstep返回 1粒子立即完整显示如果希望火焰是一个逐渐燃起的过程可以把 0.15 调大到 0.3但那样会导致每次新粒子的进入过于平滑视觉上像慢放。color渐变里的三个节点对应火焰的温度分布焰心偏白、外焰偏黄、飘散部分偏红这是把真实火焰照片的色带压缩成三段线性插值的结果已经足够在视觉上以假乱真。gl_PointSize缩放到180.0 / -mvPosition.z是为了让粒子大小随相机距离变化远离镜头时缩小靠近镜头时放大。这里 180 是经验系数数值越大粒子看起来越大可以根据场景尺度调整。3.4 每帧更新粒子位置与生命const lifeTime 0.9; const velocity new Float32Array(MAX_PARTICLES * 3); let cursor 0; let lastSpawn 0; function updateParticles(time, delta) { // 控制发射速率每秒平均生成 100 个粒子 const spawnAccumulator time * 100; const spawnCount Math.floor(spawnAccumulator - lastSpawn); lastSpawn spawnAccumulator; for (let i 0; i spawnCount; i) { cursor (cursor 1) % MAX_PARTICLES; const idx cursor * 3; positions[idx] 0; positions[idx 1] 0; positions[idx 2] 0; lives[cursor] 1.0; velocity[idx] (Math.random() - 0.5) * 1.0; velocity[idx 1] 1.8 Math.random() * 1.2; velocity[idx 2] (Math.random() - 0.5) * 1.0; randoms[idx] Math.random(); randoms[idx 1] Math.random(); randoms[idx 2] Math.random(); } for (let i 0; i MAX_PARTICLES; i) { if (lives[i] 0) continue; lives[i] - delta / lifeTime; if (lives[i] 0) { positions[i * 3 1] -9999; continue; } const idx i * 3; positions[idx] velocity[idx] * delta; positions[idx 1] velocity[idx 1] * delta; positions[idx 2] velocity[idx 2] * delta; velocity[idx 1] 0.8 * delta; velocity[idx] * 0.98; velocity[idx 2] * 0.98; } geometry.attributes.position.needsUpdate true; geometry.attributes.aLife.needsUpdate true; } function animate(time) { const delta Math.min(clock.getDelta(), 0.05); material.uniforms.uTime.value time; updateParticles(time, delta); renderer.render(scene, camera); requestAnimationFrame(animate); }updateParticles里第一个循环负责“生成”第二个循环负责“演化”。注意lerp生成数量用的是时间累积time * 100减去上一次的生成计数这样粒子不是每帧固定生成几个而是按时间均匀分布。帧率高时每帧生成 12 个帧率低时会一次补齐缺失的粒子避免火焰出现一阵一阵的断档。生命值归零后把y坐标强制拉到 -9999是为了把粒子移出视野。真实场景中大量死亡粒子如果还留在原地会在火焰底部堆成一团暗色影响后续粒子的叠加效果。需要说明的是这里把lifeTime设成 0.9 秒是火焰的合理区间烟雾系统这个值往往要放大到 34 秒。4. 火焰与烟雾的参数分型颜色曲线、生命周期与混合模式的差异火焰和烟雾在视觉上有一个明显区别火焰亮度高、尺寸小、持续时间短烟雾亮度低、尺寸大、持续时间长。共用一套粒子代码没问题但参数必须分成两套否则做出来的效果不是“着火的烟囱”而是“一根发光柱子”。4.1 火焰的颜色渐变曲线和透明度曲线火焰的从下到上大约可以用四层颜色描述白、黄、橙、红。第 3 章代码里的mix(white, yellow, t / 0.3)是按粒子剩余生命做的线性分层但现实火焰并不是线性过渡黄色区域占的比例比白色和红色更宽。调参时记住一个经验值白色占前 20%黄色占中间 50%橙色和红色占最后 30%这样火焰主体看起来最“燃”。如果想让火焰更偏卡通渲染把白色比例提高到 30%黄色压缩到 35%其余走橙色想要偏写实则降低白色比例让红色区域拉长。透明度曲线用的是tex.a * smoothstep(0.0, 0.15, vLife) * vLife。smoothstep负责压掉出生瞬间的闪烁vLife本身就是一个从 1 到 0 的线性衰减。线性衰减的问题是火焰粒子死亡时是“啪”地消失而不是慢慢变淡。更好的做法是在片元着色器里用alpha tex.a * pow(vLife, 1.5)这样尾部衰减更柔和整体会有一种“烧尽”的感觉。这个pow的指数也可以调指数越大尾部保留时间越短。4.2 火焰与烟雾的材质参数对照把火焰粒子系统复制一份当烟雾系统时下面这些参数是必须改的参数火焰烟雾粒子数量8001500200500生命周期0.61.2 秒2.54 秒粒子大小0.30.825初速度 Y1.53.50.31.0速度衰减0.950.990.981.0几乎不衰减混合模式AdditiveBlendingNormalBlending深度写入关闭关闭颜色白→黄→橙→红浅灰→灰蓝→透明烟雾的关键在于“漫”。速度衰减太大会让烟雾粒子急剧减速肉眼看起来是悬停后消失衰减太小烟雾会一直向上飘像是刚出锅的蒸汽。烟雾粒子还需要设一个高于火焰系统的depthWrite: false但它用NormalBlending否则叠加后烟雾会越积越白失去遮挡感。烟雾的透明度曲线应该和火焰相反是一个“慢起慢落”的过程。烟雾刚产生时很浓但随着扩散会逐渐稀释。在片元着色器中可以用alpha tex.a * sin(vLife * PI)来模拟这种先增后减的形态。sin曲线在vLife 1时接近 0在中间段达到峰值非常适合烟雾的扩散感。4.3 用噪声扰动粒子轨迹打破整齐的上升感如果只靠velocity[idx 1] 0.8 * delta的直线上升火焰会是一根圆柱缺乏真实感。真实火焰在上升过程中会有横向摆动。可以用sin(uTime * 3 vRand.x * 10)对粒子的 x、z 方向做扰动vRand是每个粒子出生时写入的随机种子保证同一个时间点不同粒子的摆动不同步。const swayX Math.sin(time * 3 randoms[idx] * 10) * 0.4; const swayZ Math.cos(time * 2.5 randoms[idx 1] * 10) * 0.35; positions[idx] (velocity[idx] swayX) * delta; positions[idx 2] (velocity[idx 2] swayZ) * delta;扰动幅度 0.4 表示粒子每秒钟在水平方向最大偏移 0.4 个单位。真实火焰的根部偏直、上部偏散这里可以进一步让swayX和swayZ的幅度与粒子的当前高度挂钩const heightFactor Math.max(positions[idx 1], 0); const swayX Math.sin(time * 3 randoms[idx] * 10) * heightFactor * 0.15;这样粒子离出生点越远横向摆动越剧烈视觉上就出现了火焰顶部四处飘散的感觉。烟雾系统也可以参考这个写法只是幅度系数要更小一些因为烟雾的飘动更多是整体平移而不是剧烈抖动。4.4 火焰和烟雾叠加时要注意渲染顺序两个粒子系统同时在场景里时必须把烟雾的renderOrder设为 0、火焰设为 1确保先画烟雾、后画火焰。虽然两个系统都有transparent: trueThree.js 会按距离做排序但半透明物体的深度排序本质上是近似解显式控制两个系统的顺序能减少一类常见的“火焰被烟雾挡住”的错乱问题。如果你想做一个篝火效果可以把两个粒子系统的位置都设在同一个原点如果是烟囱冒烟则把火焰放在烟囱顶部、烟雾比火焰位置略高一点并且在烟雾更新循环里把初始 y 坐标 0.3避免火焰和烟雾在发射点互相重叠。5. 粒子数量到渲染顺序提升火焰烟雾效果性能的三个隐蔽开关最后一章不谈大而全的优化方案只讲几个在火焰烟雾系统上最容易见效、也最容易出问题的点。5.1 用内存池代替每帧创建新对象火焰和烟雾的粒子更新循环里最容易拖垮性能的写法是在循环体内new Vector3()或push进数组。JavaScript 的垃圾回收在遇到大量临时对象时会有明显的卡顿表现就是火焰动画每隔几秒顿一下。正确做法是像第 3 章那样预先分配好positions、velocity、randoms、lives四个定型数组所有计算都基于索引修改已有数据不在循环里创建任何新对象。如果你在上面代码的基础上发现火焰粒子超过 3000 个就掉帧先用浏览器的 Performance 面板看 GC 时间。如果 GC 占用了大量时间说明某个循环里存在隐式对象分配排查toArray、clone、Vector3运算这些容易产生临时对象的地方。5.2 attribute.needsUpdate 与关闭剪辑的判断开关Three.js 的BufferAttribute在初始化后不会自动感知数据变化必须在每帧更新位置和 aLife 后手动把needsUpdate置为 true。这个标志相当于是告诉 GPU“这份数据变了需要重新上传”。但是上传也有开销如果粒子位置没变、只有寿命在变就不要更新位置属性分开管理aLife和position两个属性的上传频率可以省掉一部分带宽。另一个隐蔽问题是frustumCulled。粒子系统的包围盒是 Three.js 根据几何体数据自动计算的粒子向四周散开后包围盒可能很小一旦相机稍微转开一点整个粒子系统会被判定为“不可见”而裁掉。做火焰烟雾这种大范围动态粒子时直接设置particles.frustumCulled false交给 GPU 自己去画省掉包围盒判断的同时也避免火焰镜头一拉就整个消失的诡异现象。5.3 一张常见的排查表现象原因解法粒子是个凸出的正方形忘了采样纹理检查片元着色器texture2D(uTexture, gl_PointCoord)是否在粒子闪烁、后半透明物体乱叠多个混合模式混用确保所有粒子材质depthWrite: false并设置renderOrder粒子全部挤在原点不动忘记更新attribute.needsUpdate每次写完positions后置 true粒子突然全部消失frustumCulled自动裁剪设为 false火焰颜色亮得死白Additive 叠加过重降低粒子 alpha或把粒子数减少 30%火焰成了半透明纸片材质transparent未开启ShaderMaterial 中必须显式声明transparent: true最后补充一个可复用的验尸技巧调出renderer.info.render.calls和renderer.info.render.points来观察 draw call 数和粒子数。火焰烟雾两个系统加起来不超过 4 个 draw call如果数字异常大优先检查是不是不小心给每个粒子单独创建了Points对象而不是用一个系统管理全部粒子。把粒子数控制在 2000 以内绝大多数桌面浏览器都能稳定 60 帧移动端则建议减半并且把纹理尺寸从 256 降到 128效果差距很小帧率提升明显。本文还有配套的精品资源点击获取