简介:本资源是一个基于Three.js实现的轻量级火焰视觉效果Demo,面向Web前端开发者、图形学初学者及Three.js实践者,用于快速掌握粒子系统在WebGL场景中的基础应用与调色技巧。通过调整粒子颜色与运动参数,该Demo可灵活复用为火焰或烟雾模拟效果,适用于游戏开发、数据可视化、网页交互动画等实际场景。压缩包共3个文件(2个JS脚本+1个HTML主页面),总大小191KB,其中three.js提供核心渲染能力,OrbitControls.js支持视角交互,HTML文件封装完整运行入口,结构简洁、开箱即用。已有5036人学习下载,读者可直接运行查看动态火焰效果,获取可修改的粒子着色逻辑、基础光照配置及相机控制方案,便于快速迁移至自有项目并拓展为更复杂的流体模拟原型。
1. 这不是炫技动画,而是一套可嵌入工业监控界面的实时火焰模拟器:three.js 火焰 demo 解决的是「热源可视化」最后一公里问题
你有没有遇到过这样的场景:在燃气站远程监控大屏上,温度传感器数值跳变,但操作员只能盯着数字发呆——没人知道火焰形态是否异常、燃烧是否偏移、火苗是否即将舔舐设备边缘;又或者在消防训练系统里,学员反复点击“灭火”按钮,却始终缺乏对火焰扩散逻辑、风向扰动、燃料耗尽过程的直观感知。这个基于 three.js 的火焰 demo,恰恰不是为网页特效而生,它是一套轻量、可配置、带物理感的火焰建模最小可行单元(MVP),核心价值在于:用纯前端 WebGL 实现毫秒级更新的火焰形态演化,不依赖后端渲染服务,能直接挂载到已有 React/Vue 工程中作为状态反馈组件。它不追求影视级流体仿真,但把粒子生命周期、颜色梯度映射、UV 动态偏移、alpha 衰减曲线这些关键参数全部暴露为 JS 可控变量——这意味着你能把它塞进 PLC 数据看板、嵌入 AR 巡检 App 的 Webview、甚至作为数字孪生体的局部热源标识。适合需要快速验证热场交互逻辑的前端工程师、工业可视化方案实施者,以及不想被 Unity/Unreal 学习成本卡住脖子的中小型项目技术负责人。
2. 从零启动:three.js 火焰 demo 的四层结构拆解与初始化逻辑
2.1 核心架构:为什么不用 Cannon.js 或 PhysX?——火焰粒子系统的轻量化选型逻辑
这个 demo 没有引入任何物理引擎,原因很实际:火焰在工业可视化中本质是状态指示器,而非力学实体。真实火焰受浮力、湍流、辐射传热影响,但监控系统真正关心的是「是否着火」「火势是否扩大」「位置是否偏移」三个布尔+趋势判断。因此,demo 采用GPU 粒子系统 + CPU 控制器混合架构:
- GPU 层:用
THREE.Points渲染 8000~15000 个粒子,每个粒子通过自定义 ShaderMaterial 控制顶点位移(模拟上升)、颜色(RGB 基于生命周期映射)、透明度(alpha 随时间衰减); - CPU 层:每帧调用
updateParticles()函数,仅计算粒子出生位置(围绕热源中心随机偏移)、初始速度(向上为主,叠加微小横向扰动)、生命周期计时器(控制颜色过渡节奏); - 规避点:不模拟空气阻力、不计算粒子间碰撞、不处理热辐射衰减——这些在 60fps 下会吃掉 30%+ GPU 时间,且对状态判断无增益。
提示:如果你的场景需要火焰与障碍物交互(如火焰被管道遮挡),必须自行扩展
raycaster检测逻辑,原 demo 不包含此能力。
2.2 初始化三步走:加载资源、构建场景、注入控制器
// step1: 创建基础场景(省略 camera/light 初始化) const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a12); // 深蓝底色增强火焰对比度 // step2: 加载火焰纹理贴图(关键!原 demo 使用单张 gradient.png) const textureLoader = new THREE.TextureLoader(); const flameTexture = textureLoader.load('textures/flame-gradient.png'); flameTexture.wrapS = flameTexture.wrapT = THREE.RepeatWrapping; flameTexture.repeat.set(1, 1); // step3: 构建粒子系统(核心参数见下表) const particleCount = 12000; const particles = new THREE.BufferGeometry(); const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); const sizes = new Float32Array(particleCount); // 初始化粒子属性(位置/颜色/大小) for (let i = 0; i < particleCount; i++) { const i3 = i * 3; // 初始位置:以 (0,0,0) 为中心,半径 0.5 的球体内随机分布 const radius = Math.random() * 0.5; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i3] = radius * Math.sin(phi) * Math.cos(theta); positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta); positions[i3 + 2] = radius * Math.cos(phi); // 初始颜色:全为橙红色(RGB: 255,100,20),后续由 shader 动态调整 colors[i3] = 1.0; // R colors[i3 + 1] = 0.4; // G colors[i3 + 2] = 0.08;// B sizes[i] = 0.01 + Math.random() * 0.02; // 0.01~0.03 单位尺寸 } particles.setAttribute('position', new THREE.BufferAttribute(positions, 3)); particles.setAttribute('color', new THREE.BufferAttribute(colors, 3)); particles.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); // step4: 创建 ShaderMaterial(重点!原 demo 的 shader 代码在此注入) const material = new THREE.ShaderMaterial({ uniforms: { time: { value: 0 }, resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }, flameTexture: { value: flameTexture } }, vertexShader: document.getElementById('vertexShader').textContent, fragmentShader: document.getElementById('fragmentShader').textContent, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, vertexColors: true }); const points = new THREE.Points(particles, material); scene.add(points);参数说明表:关键可调变量及其业务含义
| 参数名 | 默认值 | 可调范围 | 业务影响 | 修改建议 |
|---|---|---|---|---|
particleCount | 12000 | 3000~30000 | 粒子数量决定火焰“饱满度”和 GPU 负载 | 监控大屏建议 ≥10000;移动端建议 ≤6000 |
flameTexture.repeat | set(1,1) | set(x,y) | 控制火焰纹理拉伸方向,影响火苗走向感 | 模拟竖直燃烧设set(1,2);水平蔓延设set(2,1) |
uniforms.time.value | 0 | Date.now() * 0.001 | 驱动火焰动态变化的核心时间戳 | 必须每帧更新:material.uniforms.time.value = clock.getElapsedTime(); |
blending | THREE.AdditiveBlending | THREE.NormalBlending | 决定粒子叠加方式,Additive 产生辉光效果 | 若需火焰透出背景(如玻璃罩内燃烧),改用NormalBlending |
2.3 粒子生命周期控制器:让火焰“呼吸”起来的 3 个关键函数
火焰的真实感不来自粒子数量,而来自生命周期节奏。demo 中updateParticles()函数承担三项任务:
- 重置死亡粒子:当粒子
age > lifetime(默认 2.0 秒),将其重置为新生状态; - 更新位置:应用速度向量
velocity(含上升分量 + 微小随机扰动); - 驱动颜色渐变:根据
age / lifetime计算当前阶段,映射到 RGB 值(冷→暖→熄灭)。
function updateParticles() { const positions = particles.attributes.position.array; const colors = particles.attributes.color.array; const ages = particles.userData.ages || (particles.userData.ages = new Float32Array(particleCount)); const lifetimes = particles.userData.lifetimes || (particles.userData.lifetimes = new Float32Array(particleCount).fill(2.0)); for (let i = 0; i < particleCount; i++) { const i3 = i * 3; ages[i] += 0.016; // 假设 60fps,每帧增加 16ms if (ages[i] > lifetimes[i]) { // 重置:新位置(热源附近)、新年龄、新生命周期 const radius = Math.random() * 0.3; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i3] = radius * Math.sin(phi) * Math.cos(theta); positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta); positions[i3 + 2] = radius * Math.cos(phi); ages[i] = 0; lifetimes[i] = 1.5 + Math.random() * 1.0; // 生命周期 1.5~2.5 秒,避免同步熄灭 } // 更新位置:上升 + 水平扰动 positions[i3 + 1] += 0.02; // 主升力 positions[i3] += (Math.random() - 0.5) * 0.005; // X 方向微扰 positions[i3 + 2] += (Math.random() - 0.5) * 0.005; // Z 方向微扰 // 颜色映射:0~0.3(红橙) → 0.3~0.7(亮黄) → 0.7~1.0(灰白) const phase = Math.min(ages[i] / lifetimes[i], 1.0); let r, g, b; if (phase < 0.3) { r = 1.0; g = 0.3 + phase * 2.33; b = 0.0; // 红→橙 } else if (phase < 0.7) { r = 1.0; g = 1.0; b = 0.0 + (phase - 0.3) * 2.5; // 橙→黄→白 } else { r = 0.8 - (phase - 0.7) * 2.0; g = 0.8 - (phase - 0.7) * 2.0; b = 0.8 - (phase - 0.7) * 2.0; // 白→灰 } colors[i3] = r; colors[i3 + 1] = g; colors[i3 + 2] = b; } particles.attributes.position.needsUpdate = true; particles.attributes.color.needsUpdate = true; }逻辑说明:
ages和lifetimes存储在particles.userData中,避免每次创建新数组;- 颜色分段映射比线性插值更符合人眼对火焰的认知(底部红橙、中部亮黄、顶部灰白);
lifetimes[i]随机化防止所有粒子同步重生,造成“脉冲式”闪烁——这是工业场景最忌讳的视觉干扰。
3. Shader 深度解析:vertex/fragment 两段代码如何协同实现火焰的“呼吸感”
3.1 Vertex Shader:不只是移动位置,更要制造“热空气扭曲”错觉
原 demo 的 vertex shader 并非简单平移,而是通过UV 坐标扰动 + 顶点高度偏移模拟热空气上升导致的视觉畸变:
// vertexShader.glsl uniform float time; uniform vec2 resolution; attribute vec3 color; varying vec3 vColor; varying vec2 vUv; void main() { vColor = color; vUv = uv; // 步骤1:基础位置偏移(模拟上升) vec3 newPosition = position; newPosition.y += time * 0.5; // 全局上升速度 // 步骤2:局部热扰动(关键!制造“空气抖动”) float distortion = sin(newPosition.x * 10.0 + time * 3.0) * cos(newPosition.z * 10.0 + time * 2.0) * 0.02; newPosition.x += distortion; newPosition.z += distortion * 0.5; // 步骤3:距离衰减(越远粒子越小,增强纵深感) float dist = length(newPosition.xy); float scale = 1.0 - dist * 0.3; gl_PointSize = size * scale * (1.0 + sin(time * 5.0) * 0.1); // 微幅脉动 gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); }参数说明:
distortion计算使用sin/cos组合,频率(*10.0)决定扰动细腻度,振幅(*0.02)控制抖动强度;gl_PointSize的脉动项sin(time * 5.0)模拟火焰“心跳”,频率 5Hz 符合真实火焰明暗变化节律;scale基于dist实现近大远小,避免粒子在远处堆叠成糊点。
3.2 Fragment Shader:用纹理采样+Alpha混合实现火焰的“通透感”
火焰不是实心体,其边缘必须半透明且带辉光。fragment shader 通过双纹理采样 + Alpha 阈值裁剪 + Additive Blending实现:
// fragmentShader.glsl uniform sampler2D flameTexture; uniform float time; varying vec3 vColor; varying vec2 vUv; void main() { // 步骤1:采样火焰纹理(gradient.png 是垂直渐变:黑→红→黄→白→透明) vec4 texColor = texture2D(flameTexture, vUv); // 步骤2:UV 动态偏移(制造火焰“流动”感) vec2 animatedUv = vUv + vec2(sin(time * 2.0) * 0.05, cos(time * 1.5) * 0.03); // 步骤3:二次采样并混合(主纹理 + 动态偏移纹理) vec4 tex1 = texture2D(flameTexture, vUv); vec4 tex2 = texture2D(flameTexture, animatedUv); vec4 finalColor = mix(tex1, tex2, 0.3); // 30% 动态混合 // 步骤4:Alpha 裁剪(剔除纹理中 alpha<0.1 的像素,避免毛边) if (finalColor.a < 0.1) discard; // 步骤5:颜色叠加(vColor 是 CPU 传入的生命周期颜色,覆盖纹理基础色) gl_FragColor = vec4(vColor * finalColor.rgb, finalColor.a * 0.8); }关键设计点:
animatedUv的偏移方向(sin用 X,cos用 Y)确保火焰呈现“向上翻卷”趋势;mix(tex1, tex2, 0.3)的 0.3 权重是经验值:过高导致流动感过强像水流,过低则静止;discard语句比gl_FragColor.a = 0更高效,直接跳过片元计算,减少 GPU 压力;- 最终
vColor * finalColor.rgb实现 CPU 控制的生命周期色温与 GPU 纹理细节的叠加。
3.3 纹理文件flame-gradient.png的制作规范:为什么不能随便找一张火焰图?
原 demo 依赖一张128x128 像素的垂直渐变 PNG,其 Alpha 通道必须满足:
- Y=0(顶部):Alpha=0(完全透明,模拟火焰尖端消散);
- Y=32:Alpha=0.3(淡红,火焰中上部);
- Y=64:Alpha=0.8(亮黄,火焰主体);
- Y=96:Alpha=0.6(橙红,火焰根部);
- Y=127:Alpha=0.2(暗红,燃料区);
RGB 通道对应色温:顶部白→中部黄→根部红。若用摄影火焰图,边缘硬边会导致discard后出现锯齿,且 Alpha 渐变不匹配生命周期模型。
注意:
flame-gradient.png必须启用Premultiplied Alpha(预乘 Alpha),否则AdditiveBlending下会出现黑色镶边。用 Photoshop 导出时勾选“透明度:杂边:无”。
4. 避坑指南:three.js 火焰 demo 在真实项目中踩过的 5 个深坑
4.1 现象:火焰在 Chrome 115+ 上严重卡顿,但 Firefox 正常
原因:Chrome 新版对THREE.Points的depthWrite: false处理存在渲染管线优化缺陷,导致 GPU 纹理缓存失效,每帧强制重绘。
解决:在ShaderMaterial中显式关闭深度测试,并添加alphaTest: 0.05强制剔除低 Alpha 片元:
const material = new THREE.ShaderMaterial({ // ... 其他配置 depthTest: false, // 关键!替代 depthWrite: false alphaTest: 0.05, // 配合 fragment shader 的 discard,提升 GPU 吞吐 transparent: true });4.2 现象:火焰贴图在移动端(iOS Safari)显示为纯黑
原因:Safari 对texture2D采样坐标超出[0,1]范围时行为不一致,vUv因扰动可能轻微越界,触发纹理采样失败返回黑。
解决:在 fragment shader 开头添加 UV 安全钳制:
vec2 safeUv = clamp(vUv, 0.0, 1.0); vec4 tex1 = texture2D(flameTexture, safeUv); // 后续 animatedUv 同理处理4.3 现象:集成到 Vue 3 Composition API 后,clock.getElapsedTime()返回 NaN
原因:THREE.Clock实例在onMounted中创建,但updateParticles()被watchEffect响应式调用时,clock尚未开始start()。
解决:在onMounted中显式启动时钟,并确保updateParticles在clock.running === true时执行:
const clock = new THREE.Clock(); onMounted(() => { clock.start(); // 必须显式 start() const animate = () => { if (clock.running) { // 防御性检查 updateParticles(); material.uniforms.time.value = clock.getElapsedTime(); renderer.render(scene, camera); } requestAnimationFrame(animate); }; animate(); });4.4 现象:火焰在 WebGL2 环境下颜色失真(偏紫)
原因:WebGL2 默认启用sRGBEncoding,但ShaderMaterial输出的 RGB 未经过 gamma 校正,导致颜色空间错配。
解决:在renderer初始化时禁用自动编码,或手动校正输出:
// 方案1:全局禁用(推荐,简化流程) const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.outputEncoding = THREE.LinearEncoding; // 关键! // 方案2:shader 内手动校正(兼容旧项目) // fragmentShader 中:gl_FragColor.rgb = pow(gl_FragColor.rgb, vec3(1.0/2.2));4.5 现象:多实例火焰(如 3 个燃烧点)时 GPU 内存暴涨崩溃
原因:每个THREE.Points实例独立占用 GPU 显存,12000 粒子 × 3 实例 ≈ 12MB 显存,低端显卡(如 Intel HD 4000)直接 OOM。
解决:合并为单个 Geometry,用instancedMesh或BufferGeometry.drawRange分区域控制:
// 改造思路:将 3 个热源的位置存入 attributes particles.setAttribute('sourceId', new THREE.BufferAttribute(new Uint8Array(particleCount), 1)); // 0/1/2 标识热源 // vertex shader 中根据 sourceId 添加不同偏移 // 这样 1 个 Geometry 承载全部粒子,显存降低 60%+5. 进阶技巧:把火焰 demo 改造成“可交互热源”——绑定传感器数据与动态响应
5.1 从静态演示到动态反馈:用 MQTT 数据流驱动火焰参数
工业现场的火焰 demo 绝不能是循环动画。真正的价值在于将 PLC 采集的温度、气压、O2 浓度映射为火焰形态参数。我们以某燃气锅炉监控为例,建立三组映射关系:
| 传感器数据 | 映射目标 | 调整逻辑 | 触发条件 |
|---|---|---|---|
| 炉膛温度(℃) | particleCount | 温度>800℃ → 粒子数 ×1.5;<500℃ → ×0.5 | 防止低温误报时火焰过大 |
| 天然气压力(kPa) | lifetimes[i] | 压力↑ → 生命周期延长(燃烧更稳);压力↓ → 缩短(易熄火) | lifetimes[i] = baseLifetime * (pressure / 300) |
| 烟气 O2 含量(%) | colors渐变区间 | O2>12% → 增加蓝色分量(充分燃烧);O2<3% → 增加黄色(不完全燃烧) | 修改updateParticles()中的颜色映射函数 |
// MQTT 消息处理(使用 mqtt.js) client.on('message', (topic, payload) => { const data = JSON.parse(payload.toString()); if (topic === 'boiler/sensor') { // 动态调整粒子系统参数 particles.userData.temperature = data.temp; particles.userData.pressure = data.pressure; particles.userData.o2 = data.o2; // 立即触发一次粒子重置(避免等待下一帧) updateParticles(); } }); // 在 updateParticles() 中读取实时数据 function updateParticles() { // ... 原有逻辑 for (let i = 0; i < particleCount; i++) { // 根据温度调整粒子总数(需重建 geometry,此处简化为缩放) const tempFactor = Math.max(0.3, Math.min(2.0, particles.userData.temperature / 600)); // 根据压力调整生命周期 const pressureBase = 2.0; lifetimes[i] = pressureBase * (particles.userData.pressure / 300); // 根据 O2 调整颜色倾向 const o2Factor = particles.userData.o2 > 12 ? 1.2 : particles.userData.o2 < 3 ? 0.5 : 1.0; // 修改颜色计算逻辑... } }5.2 火焰作为 UI 事件触发器:点击火焰区域弹出设备详情面板
THREE.Points默认不支持 raycast,需手动实现“粒子命中检测”。核心思路:将粒子视为球体,用距离公式代替射线相交:
// 鼠标点击事件处理 window.addEventListener('click', (event) => { const mouse = new THREE.Vector2(); mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera); // 获取粒子位置数组(需提前缓存) const positions = particles.geometry.attributes.position.array; let hitParticleIndex = -1; let minDistance = 0.5; // 0.5 单位距离阈值 for (let i = 0; i < particleCount; i++) { const i3 = i * 3; const particlePos = new THREE.Vector3( positions[i3], positions[i3 + 1], positions[i3 + 2] ).applyMatrix4(particles.matrixWorld); // 转换到世界坐标 const distance = raycaster.ray.origin.distanceTo(particlePos); if (distance < minDistance && distance < 2.0) { // 2.0 为最大检测距离 minDistance = distance; hitParticleIndex = i; } } if (hitParticleIndex !== -1) { // 触发业务逻辑:弹出该热源的设备面板 openEquipmentPanel(particles.userData.sourceId[hitParticleIndex]); } });性能优化点:
positions数组在updateParticles()后已更新,此处直接复用,避免重复获取;minDistance = 0.5是经验值:太小导致点击困难,太大则误触;distance < 2.0限制检测范围,防止远处粒子干扰。
5.3 火焰状态持久化:导出当前火焰快照为 PNG 用于报告存档
监控系统常需生成带火焰状态的日报图表。THREE.WebGLRenderer提供render()后读取像素的能力:
function exportFlameSnapshot() { // 临时切换 renderer 尺寸为 A4 比例(2480x3508 px) const originalSize = { w: renderer.getSize(new THREE.Vector2()).x, h: renderer.getSize(new THREE.Vector2()).y }; renderer.setSize(2480, 3508, false); // 渲染到离屏 buffer const renderTarget = new THREE.WebGLRenderTarget(2480, 3508); renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); // 读取像素数据 const pixelBuffer = new Uint8Array(2480 * 3508 * 4); renderer.readRenderTargetPixels(renderTarget, 0, 0, 2480, 3508, pixelBuffer); // 转为 PNG(使用 browser-native canvas) const canvas = document.createElement('canvas'); canvas.width = 2480; canvas.height = 3508; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(2480, 3508); imageData.data.set(pixelBuffer); ctx.putImageData(imageData, 0, 0); // 触发下载 const link = document.createElement('a'); link.download = `flame-report-${new Date().toISOString().slice(0,10)}.png`; link.href = canvas.toDataURL('image/png'); link.click(); // 恢复 renderer 尺寸 renderer.setSize(originalSize.w, originalSize.h, false); renderer.render(scene, camera); }注意:readRenderTargetPixels是异步操作,需确保render()完成后再调用;生产环境建议添加 loading 提示,因 2480×3508 渲染耗时约 800ms。
从那以后我每次交付工业可视化项目,都会在updateParticles()函数开头加一行console.timeLog('flame-update'),并用performance.now()记录每帧耗时。不是为了炫技,而是当客户说“火焰卡顿”时,我能立刻打开控制台,看到是particleCount超限、还是MQTT数据解析阻塞了主线程、抑或是exportSnapshot的离屏渲染没清理干净——这些细节才是让 demo 从“能跑”变成“敢用”的分水岭。希望帮到你。
本文还有配套的精品资源,点击获取