关于 particle-system
particle-system 是用于构建粒子效果的技能,通过逐粒子运动规则实现动画。内容涵盖 2D canvas 和 GPU 渲染,并指导生成可复现的独立 HTML 文件。
主要功能
- 01用力学规则推进粒子运动
- 02创建爆发式和连续发射器
- 03制作流场与 curl-noise 效果
- 04用空间网格连接邻近粒子
- 05用 Three.js Points 渲染大量粒子
- 06生成确定性的独立 HTML
适用场景
- 01制作彩纸、雪或烟雾效果
- 02构建星点连线背景
- 03制作流场动画视觉
- 04生成可复现的粒子效果预览
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ iart-ai/ webgl-animation-skills --skill particle-system安装后在 Claude Code 中输入/particle-system确认已加载。
附带文件README.mdreferences/particle-recipes.md
详细介绍
粒子系统
通过简单的逐粒子规则驱动大量小元素,产生涌现式的自然运动。数百个粒子使用 2D 画布,数千个粒子使用 GPU Points。
适用场景
- 粒子/星座背景和氛围动效。
- 庆祝效果:彩纸、火花。天气效果:雪、雨。体积效果:烟雾。
- 流场/旋度噪声漩涡和数据驱动的点云。
- 互连点网络(连接彼此邻近粒子的线条)。
核心循环:逐粒子积分
每个粒子都保存状态,并在每一帧推进:将力累加到加速度上,对速度和位置进行积分,更新粒子年龄,并在粒子死亡时重新生成。按 dt 缩放,以确保与帧率无关。
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W; this.y = Math.random() * H;
this.vx = 0; this.vy = 0;
this.life = 1; this.size = 1 + Math.random() * 2;
}
step(dt, forces) {
let ax = 0, ay = 0;
for (const f of forces) { const [fx, fy] = f(this); ax += fx; ay += fy; }
this.vx += ax * dt; this.vy += ay * dt;
this.vx *= 0.99; this.vy *= 0.99; // drag
this.x += this.vx * dt; this.y += this.vy * dt;
this.life -= dt * 0.2;
if (this.life <= 0) this.reset();
}
}
优先使用半隐式欧拉法(如上所示,先更新速度,再使用新速度更新位置)——它对粒子所用的弹簧力/阻力具有较好的稳定性。使用固定或限制后的 dt(Math.min(dt, 1/30)),避免标签页卡顿时模拟失控。
力
力是一个返回加速度 [fx, fy] 的函数。将多个力组合成列表。
const gravity = () => [0, 400]; // constant downward
const drag = (p) => [-p.vx * 0.5, -p.vy * 0.5]; // proportional resistance
function attract(tx, ty, strength) { // pull toward a point (e.g. mouse)
return (p) => {
const dx = tx - p.x, dy = ty - p.y;
const d2 = dx*dx + dy*dy + 100; // +100 softens the singularity
const f = strength / d2;
return [dx * f, dy * f];
};
}
排斥力就是强度为负值的 attract。朝向初始位置的弹簧力可以产生“回归原位”的效果。
流场/旋度噪声(自然漩涡)
对噪声场进行采样,为每个粒子计算速度方向。将噪声值用作角度:
// `noise2D` from a library (e.g. simplex-noise's createNoise2D), range -1..1
function flowField(noise2D, scale = 0.002, speed = 60) {
return (p) => {
const angle = noise2D(p.x * scale, p.y * scale) * Math.PI * 2;
return [Math.cos(angle) * speed - p.vx, Math.sin(angle) * speed - p.vy];
};
}
真正的旋度噪声是无散的(没有源/汇,因此呈现类似流体的效果)。通过有限差分计算势函数的旋度:
function curl(noise2D, x, y, eps = 1e-2) {
const n1 = noise2D(x, y + eps), n2 = noise2D(x, y - eps);
const n3 = noise2D(x + eps, y), n4 = noise2D(x - eps, y);
return [ (n1 - n2) / (2*eps), -(n3 - n4) / (2*eps) ]; // (dN/dy, -dN/dx)
}
在噪声输入中加入时间(noise2D(x*scale, y*scale + t)),让场随时间演变。
发射:爆发式与连续式
- 爆发式(彩纸、火花):从某个点一次性生成 N 个粒子,让它们在一个锥形范围内随机选取角度/速度,然后由重力和阻力接管。不要重新生成——粒子死亡后将其移除。
- 连续式(雪、烟雾):以稳定速率生成粒子;在粒子死亡时于顶部/源点重新生成。
在一个范围内随机取值,让分布更自然:angle = base + (Math.random()-0.5)*spread; speed = min + Math.random()*(max-min)。
function burst(x, y, n = 120) {
const out = [];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 200 + Math.random() * 400;
out.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s - 200, // upward bias
life: 1, size: 4 + Math.random()*4,
color: `hsl(${Math.random()*360},90%,60%)`,
rot: Math.random()*6.28, vr: (Math.random()-0.5)*10 });
}
return out;
}
彩纸效果之所以像彩纸,是因为旋转 + 扁平矩形 + 重力 + 空气阻力,而不是圆点。雪花效果则来自缓慢下落 + 轻柔的水平正弦摆动 + 大小各异的深度变化。
避免 O(n²) 的互连点网络
直接检查所有粒子对的复杂度为 O(n²),粒子超过约 300 个时就会变得很慢。使用均匀空间网格:按网格单元对粒子分箱,只比较相邻的 8 个单元。
function connect(ctx, parts, radius) {
const cell = radius, cols = Math.ceil(W / cell);
const grid = new Map();
const key = (cx, cy) => cx + cy * cols;
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
(grid.get(key(cx, cy)) ?? grid.set(key(cx, cy), []).get(key(cx, cy))).push(p);
}
for (const p of parts) {
const cx = (p.x / cell) | 0, cy = (p.y / cell) | 0;
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
const bucket = grid.get(key(cx+ox, cy+oy)); if (!bucket) continue;
for (const q of bucket) {
if (q === p) continue;
const dx = p.x - q.x, dy = p.y - q.y, d = Math.hypot(dx, dy);
if (d < radius) {
ctx.globalAlpha = 1 - d / radius; // fade line with distance
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
}
}
}
}
ctx.globalAlpha = 1;
}
对于均匀分布的粒子,这种方法的复杂度为 O(n)。每对粒子都会被找到两次;如有需要,只检查前向邻居即可将工作量减半。
GPU 粒子:Three.js Points + 着色器
对于数千个或更多粒子,将所有位置推送到 BufferGeometry,并以 Points 进行渲染。在顶点着色器中添加动画,实现真正的 GPU 级扩展能力。
const N = 50000;
const pos = new Float32Array(N * 3);
for (let i = 0; i < N * 3; i++) pos[i] = (Math.random() - 0.5) * 20;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.ShaderMaterial({
uniforms: { u_time: { value: 0 }, u_size: { value: 6 } },
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: `
uniform float u_time, u_size;
void main(){
vec3 p = position;
p.y += sin(u_time + position.x) * 0.5; // animate on GPU
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = u_size * (10.0 / -mv.z); // perspective size
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
void main(){
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard; // round, soft points
gl_FragColor = vec4(1.0, 0.8, 0.4, smoothstep(0.5, 0.0, d));
}`,
});
scene.add(new THREE.Points(geo, mat));
// loop: mat.uniforms.u_time.value = clock.getElapsedTime();
AdditiveBlending + depthWrite: false 可以呈现发光粒子的效果。通过对 gl_PointCoord 的距离使用 discard,可将方形点变为圆形。对于粒子数据(生命值、种子),添加自定义属性并在着色器中读取。
交付与验证(独立 HTML)
打包的辅助脚本(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3会冻结?t=N测试框架并截取各个时刻的屏幕截图;scripts/contact-sheet.sh sheet.png frame-*.png会将它们拼成一张图,方便快速查看。参见scripts/README.md。
对于自包含的粒子效果(星座背景、纸屑爆发、流场、GPU 点),交付物应是一个可直接在浏览器中打开的 HTML 文件——将 Canvas 2D 内联,或通过 importmap 从 CDN 加载 Three.js 来渲染 GPU Points;使用一个渲染循环,无需构建步骤。单文件是合适的方案;一个文件就能完成的事,不要用打包工具。
输出要求:
- 一个
.html:对于 Canvas,将模拟和 2D 绘制循环放在同一个内联<script>中;对于 GPU 点,使用 importmap 将three固定到 CDN,并内联Points设置。 - 使用单一累积
time驱动模拟(累加经过钳制的dt,或 GPU 使用clock.getElapsedTime()/u_time)。不要在各个粒子中分散调用Date.now()。 - 为 RNG 设置种子——用带种子的 PRNG(例如 mulberry32)替换直接调用的
Math.random(),以便粒子的生成位置、角度和爆发效果都能逐帧复现。
跳转/冻结测试框架——推进至固定时间,为截图渲染一帧。 ?t=N 会重置随机数种子,以固定时间步确定性地将模拟推进至 N 秒,渲染一次后停止循环。
<script>
let rng = mulberry32(1234); // fixed seed → reproducible
const particles = spawn(() => rng());
function render() { /* draw particles to canvas / renderer.render(...) */ }
const t = new URLSearchParams(location.search).get("t");
if (t !== null) {
const step = 1 / 60, end = parseFloat(t);
for (let s = 0; s < end; s += step) update(step); // fixed-step to t
render(); // one frozen frame
window.__ready = true;
} else {
let prev = performance.now();
(function loop(now){ update(Math.min((now-prev)/1000, 1/30)); prev = now;
render(); requestAnimationFrame(loop); })(prev);
}
</script>
验证循环——渲染 → 冻结 → 截图 → 检查:在三个时刻打开页面——开始、中间、稳定(?t=0、?t=<mid>、?t=<end>;对于爆发效果,t≈0 生成 / t≈0.5 扩散 / t≈1.5 稳定)——分别截取屏幕截图,并检查保真度(是否符合需求)和异常:画布空白 = 解析/初始化错误(检查控制台);粒子跑出画面(缺少钳制/环绕逻辑);位置为 NaN(所有粒子都会消失);所有粒子挤在原点(RNG 未正确接入)。GPU 点需要 GPU 上下文;Playwright/Chromium 在无头模式下会提供一个(swiftshader)。
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/particles.html?t=1.0" frame-mid.png
完成前:
- Canvas 能渲染粒子——画面不为空白,没有控制台/WebGL 错误,也没有 CDN 404。
?t=N能冻结可复现的帧(带种子的 RNG + 固定时间步 → 相同的 N → 相同的像素)。- 已对开始/中间/稳定时刻截取屏幕截图——符合需求,没有跑出画面、位置为 NaN 或挤在原点的粒子。
- 嵌入 SPA 时已清理且不会泄漏(取消 rAF 循环;对于 GPU,释放几何体/材质/渲染器)。
- 遵循
prefers-reduced-motion设置——减少粒子数量,或在运动仅用于装饰时改用静态场。
快速参考
| 效果 | 配方 |
|---|---|
| 纸屑 | 爆发 + 重力 + 阻力 + 旋转矩形 |
| 雪 | 持续从顶部生成 + 缓慢下落 + 正弦摆动 |
| 烟雾 | 持续生成 + 向上移动 + 增大尺寸 + 淡出透明度 |
| 火花 | 短寿命爆发 + 叠加 + 快速淡出 |
| 流场 | 噪声角度 → 速度,随时间演变 |
| Curl 噪声 | 噪声势函数的 Curl(无散度) |
| 星座 | 空间网格,在半径范围内连线,按距离淡出 |
| 1000+ 个 | Three.js Points + ShaderMaterial,在顶点着色器中制作动画 |
参考文件
references/particle-recipes.md— 完整的 Canvas 纸屑、雪和烟雾系统;鼠标吸引/排斥;完整的 simplex 流场与 Curl 噪声场,呈现流动效果;从头到尾实现的空间网格连接点背景;以及带有逐粒子生命期/种子属性、叠加辉光并在着色器中重新生成粒子的 GPUPoints系统。