简介:这是一份基于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 负责更新粒子的位置、速度和寿命,把结果写进BufferAttribute;GPU 再用这些属性算出颜色、透明度和大小。这种划分的好处是灵活:想调整粒子轨迹,改的是 JavaScript 循环;想调颜色梯度,改的是着色器。
可以先把属性结构定下来。一张表就能看出哪些属性需要动、哪些只出生时写一次。
| 属性名 | 类型 | 更新时机 | 用途 |
|---|---|---|---|
| position | Float32Array × 3 | 每帧 | 粒子世界坐标 |
| aRand | Float32Array × 3 | 出生时一次 | 随机方向、大小扰动种子 |
| aLife | Float32Array × 1 | 每帧 | 0 到 1,剩余生命比例 |
| velocity | Float32Array × 3 | 每帧 | CPU 侧速度,不传给 GPU |
aLife应该是剩余生命还是已流逝生命,这里有一个容易忽略的细节。建议存剩余生命比例remaining / maxLife,这样在片元着色器里直接拿它做衰减透明,新粒子剩余生命是 1,越接近死亡越趋近 0,不需要再单独准备一个随时间递增的累计值。你可以按这个结构先把数据层建好,再来写渲染层,后面扩展风场、浮力、旋涡扰动都只是往 CPU 更新循环里加力的方向而已。
3. 从空白页面到第一团火焰:Threejs 火焰粒子系统的最小可运行代码
这一章给出一套能直接打开的完整实现。这里以 Three.js r160 左右的语法为例,用 importmap 引入模块,避免一开始就引入打包工具;在正经工程里你可以换成 npm 引入。核心思路是:生成一个软圆纹理,创建一个固定大小的粒子 BufferGeometry,用 ShaderMaterial 控制颜色和透明度,然后在动画循环里更新粒子的生命期和位置。
3.1 场景、相机与渲染器的初始化
<script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } } </script> <script type="module"> 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是剩余生命,刚出生是 1,smoothstep返回 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减去上一次的生成计数,这样粒子不是每帧固定生成几个,而是按时间均匀分布。帧率高时每帧生成 1~2 个,帧率低时会一次补齐缺失的粒子,避免火焰出现一阵一阵的断档。
生命值归零后把y坐标强制拉到 -9999,是为了把粒子移出视野。真实场景中大量死亡粒子如果还留在原地,会在火焰底部堆成一团暗色,影响后续粒子的叠加效果。需要说明的是,这里把lifeTime设成 0.9 秒是火焰的合理区间;烟雾系统这个值往往要放大到 3~4 秒。
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 火焰与烟雾的材质参数对照
把火焰粒子系统复制一份当烟雾系统时,下面这些参数是必须改的:
| 参数 | 火焰 | 烟雾 |
|---|---|---|
| 粒子数量 | 800~1500 | 200~500 |
| 生命周期 | 0.6~1.2 秒 | 2.5~4 秒 |
| 粒子大小 | 0.3~0.8 | 2~5 |
| 初速度 Y | 1.5~3.5 | 0.3~1.0 |
| 速度衰减 | 0.95~0.99 | 0.98~1.0(几乎不衰减) |
| 混合模式 | AdditiveBlending | NormalBlending |
| 深度写入 | 关闭 | 关闭 |
| 颜色 | 白→黄→橙→红 | 浅灰→灰蓝→透明 |
烟雾的关键在于“漫”。速度衰减太大会让烟雾粒子急剧减速,肉眼看起来是悬停后消失;衰减太小烟雾会一直向上飘,像是刚出锅的蒸汽。烟雾粒子还需要设一个高于火焰系统的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: true,Three.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,效果差距很小,帧率提升明显。
本文还有配套的精品资源,点击获取