【Three.js】day113-particle-system

作者:mario 发布时间: 2026-09-07 阅读量:3 评论数:0

Day 113 · 粒子系统 — 扫描线、粉尘与火花:让车间"活"起来

前两周的车间"真实但静止"——设备在转,但空气是空的。今天的主题粒子系统:用成千上万个"点"营造工业氛围——扫描线扫过车间、粉尘在光柱里浮动、告警火花迸溅。粒子是"氛围"最便宜的工具:一个 Points 对象就能承载几千个点,成本却远低于同数量的 Mesh。做完,你的车间从"模型展示"变成"有空气感的工厂"。


目录


一、粒子是什么:Points vs Mesh 的本质区别

1.1 渲染差异

维度

Mesh(模型/几何)

Points(粒子)

最小单位

三角形(有面)

一个点(无面,屏幕上的像素点/小方块)

变换

position/rotation/scale

只有 position(点在空间中的位置)

光照

参与光照计算

不参与(PointsMaterial 无光照)

成本

每个 Mesh 一次 draw call

整个 Points 一次 draw call(几千点一调)

记忆钩子: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 数量预算(性能纪律)

粒子类型

合理数量

备注

粉尘

2000~5000

少而分散,加氛围即可

扫描线

1 个平面

不是粒子,几乎零成本

火花

200~1000

告警时才需要多

记忆钩子:粒子是"几行代码的便宜氛围",但也不是免费——每帧更新几千个 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 预算表的原始数据


八、总结

环节

要点

粒子本质

Points 只存 position,一次 draw call 管几千点

关键贴图

圆形渐变贴图 + transparent——粒子从"方块"变"光点"

两个开关

depthWrite:false + AdditiveBlending——工业粒子标配

三类粒子

扫描线(平面往复)/ 粉尘(浮动)/ 火花(迸溅)

动画纪律

position 更新 + needsUpdate;用 delta/elapsed 驱动

数量预算

粉尘 3K / 火花 1K——够氛围即可

车间有"空气感"了。明天给画面调"工业色"——辉光与色彩分级。


明日预告:Day 114 后期处理进阶——UnrealBloomPass 辉光(工业最出效果的后处理)、色彩分级(把画面统一成工业深色风)、以及"辉光是性能大头"的预算意识。

评论