Day 113 · 粒子系统 — 扫描线、粉尘与火花:让车间"活"起来
前两周的车间"真实但静止"——设备在转,但空气是空的。今天的主题粒子系统:用成千上万个"点"营造工业氛围——扫描线扫过车间、粉尘在光柱里浮动、告警火花迸溅。粒子是"氛围"最便宜的工具:一个
Points对象就能承载几千个点,成本却远低于同数量的 Mesh。做完,你的车间从"模型展示"变成"有空气感的工厂"。
目录
一、粒子是什么:Points vs Mesh 的本质区别
1.1 渲染差异
记忆钩子:Mesh 是"立体几何",Points 是"空间里的一撮点"。粒子系统 = 用一个对象管理几千个点的位置——这是"千物体不卡"的答案(Day 106 实例化的另一种形态)。
1.2 为什么工业场景离不开粒子
扫描线(氛围):一束光带扫过车间,体现"数据在流动"
粉尘(氛围): 空气中悬浮的颗粒,增加纵深与空气感
火花(告警): 设备故障时迸溅的粒子,状态反馈
三种粒子共同点:数量多、体积小、要动——天生为 Points 而生。
二、Points / PointsMaterial 基本用法
import * as THREE from "three";
// 1. 几何:只有 position 数组(点不需要面/法线)
const count = 2000;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3); // 2000 点 × xyz
for (let i = 0; i < count; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 20; // x:车间范围
positions[i * 3 + 1] = Math.random() * 8; // y:高度(0~8)
positions[i * 3 + 2] = (Math.random() - 0.5) * 20; // z
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
// 2. 材质:PointsMaterial(无光照,纯颜色/贴图)
const material = new THREE.PointsMaterial({
color: 0x00c6ff,
size: 0.05, // 点的大小(世界单位)
sizeAttenuation: true, // 远处点变小(有透视感)
transparent: true,
opacity: 0.6,
});
// 3. 网格:Points(一个对象,一次 draw call)
const particles = new THREE.Points(geometry, material);
scene.add(particles);
观察点:
Points用的还是BufferGeometry(Day 106)——只是它的 attribute 只有 position。所有几何知识在粒子系统里原样复用。
三、粒子贴图:让"点"有形状
3.1 问题:默认粒子是"方块"
不贴图的粒子是实心小方块——工业感全无。解法:给粒子贴一张圆形渐变贴图(中心亮、边缘透明),配合透明度,粒子就变成"柔和的圆点":
// 生成一张圆形渐变贴图(代码生成,无需下载图片资产)
function createParticleTexture(): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 64;
const ctx = canvas.getContext("2d")!;
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, "rgba(255,255,255,1)");
grad.addColorStop(1, "rgba(255,255,255,0)"); // 边缘透明
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
// 材质用贴图(替换默认方块)
const material = new THREE.PointsMaterial({
map: particleTex, // ← 关键:贴图让粒子"有形状"
size: 0.05,
transparent: true,
depthWrite: false, // 关闭深度写入,避免半透明粒子互相遮挡黑块(坑 2)
blending: THREE.AdditiveBlending, // 加法混合:粒子重叠时变亮(辉光感)
});
两个关键开关:
depthWrite: false(半透明粒子不遮挡)+AdditiveBlending(重叠变亮,工业粒子标配)。这两行决定了粒子"像光点"还是"像脏点"。
四、三种工业粒子:扫描线 / 粉尘 / 火花
4.1 扫描线:一束光带"扫过"车间
// 扫描线 = 一个平面 + 沿 Y 轴往复移动
const scanPlane = new THREE.Mesh(
new THREE.PlaneGeometry(24, 0.2), // 细长光带
new THREE.MeshBasicMaterial({
color: 0x00c6ff,
transparent: true,
opacity: 0.35,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
);
scanPlane.rotation.x = -Math.PI / 2; // 躺平
scanPlane.position.y = 0;
scene.add(scanPlane);
// 循环里:光带在 0~8 高度往返扫(时间纪律,Day 100)
scanPlane.position.y = (Math.sin(clock.getElapsedTime() * 0.5) * 0.5 + 0.5) * 8;
4.2 粉尘:悬浮颗粒浮动
// 粉尘 = 随机位置粒子 + 缓慢上浮/下落(存一份"初始 y"做往返基准)
const dust = createParticles({ count: 3000, color: 0xffffff, size: 0.03, opacity: 0.5 });
scene.add(dust);
// 循环里:每个粒子缓慢上下浮动(可用 sin 基于索引错开相位,避免整齐划一)
const posAttr = dust.geometry.getAttribute("position") as THREE.BufferAttribute;
// 每帧更新大量粒子 position 时,改完要置 needsUpdate(见第五节)
4.3 火花:告警迸溅
// 火花 = 从告警点向外迸溅 + 快速下落 + 变暗消失
// 本周先做"循环迸溅"的静态演示;真正的"告警触发"留第 6 个月接 WS
const spark = createParticles({ count: 500, color: 0xff6b35, size: 0.06, opacity: 0.9 });
spark.position.set(2, 1, -2); // 火花源:某台设备位置
scene.add(spark);
五、粒子的动画:时间纪律与数量预算
5.1 更新粒子位置的正确姿势
粒子动画 = 每帧改 position attribute。改完必须标记上传(Day 106 的 needsUpdate 纪律):
// 循环里更新粒子(以粉尘上浮为例)
const pos = dust.geometry.getAttribute("position") as THREE.BufferAttribute;
const t = clock.getElapsedTime();
for (let i = 0; i < pos.count; i++) {
// 用 sin 让粒子"上浮-下沉"往复(相位按 i 错开,避免整齐)
pos.setY(i, baseY[i] + Math.sin(t + i * 0.1) * 0.3);
}
pos.needsUpdate = true; // ← 关键:position 变了必须标记,GPU 才重新上传
5.2 数量预算(性能纪律)
记忆钩子:粒子是"几行代码的便宜氛围",但也不是免费——每帧更新几千个 position 要 CPU 算。够氛围即可,别上十万(那是 Day 117 着色器 GPU 粒子的领域)。
5.3 与渲染循环集成
// 引擎循环里:粒子动画属于"更新段"(Day 100 三段式)
const tick = () => {
const delta = clock.getDelta();
const t = clock.getElapsedTime();
// 更新段:粒子 + 动画 + 相机
updateDust(t);
scanPlane.position.y = (Math.sin(t * 0.5) * 0.5 + 0.5) * 8;
mixers.forEach((m) => m.update(delta));
controls.update();
// 渲染段:composer(Day 109)
atmosphere.composer.render();
rafId = requestAnimationFrame(tick);
};
六、常见坑点
坑 1:粒子是"方块"不是"光点"
原因:没贴图。修法:圆形渐变贴图 + transparent(第三节)。
坑 2:半透明粒子黑块/串色
原因:默认深度写入,半透明粒子互相遮挡成黑块。修法:depthWrite: false + AdditiveBlending(第三节)。
坑 3:粒子动了画面不动
原因:改了 position 没置 needsUpdate。修法:批量更新后 pos.needsUpdate = true(第五节)。
坑 4:粒子数量失控
症状:几千变几万,FPS 骤降。原因:为了效果盲目加量。修法:按预算表控制(粉尘 3K/火花 1K)(第五节)。
坑 5:扫描线"穿墙"穿帮
原因:扫描线平面穿越设备模型,看着假。修法:控制扫描范围只在车间内部,或加透明度让它"透过去"(第 4 节的 depthWrite:false 部分缓解)。
七、自测挑战
T1 · 粉尘粒子(60 分钟)
实现粉尘:3000 粒子 + 圆形贴图 + 上下浮动。用 sin(t + i*0.1) 错开相位,观察"空气感"。对比贴图前后两版截图。
T2 · 扫描线(40 分钟)
实现扫描线:光带在 0~8 高度往复扫过车间,AdditiveBlending 半透明。与 Day 68 的 2D 扫描线对照——同一个效果,两个世界的实现。
T3 · 火花迸溅(50 分钟)
实现火花:从一台设备向外迸溅 + 下落 + 变暗。本周做循环演示版,写清"第 6 个月接告警 WS 时怎么触发"的注释。
T4 · 预算记录(20 分钟)
把三类粒子的数量/FPS/帧耗时记录成表。这是 Day 118 FPS 预算表的原始数据。
八、总结
车间有"空气感"了。明天给画面调"工业色"——辉光与色彩分级。
明日预告:Day 114 后期处理进阶——UnrealBloomPass 辉光(工业最出效果的后处理)、色彩分级(把画面统一成工业深色风)、以及"辉光是性能大头"的预算意识。