✨Effect Lab
Motion运动

One force, six pictures一个力,六张画面

Particles falling in a straight line are boring. But you do not need six different systems for six different looks — you need one force, applied with different geometry. These six demos differ mostly in what they divide by.按直线掉落的粒子很无聊。但六种观感不需要六套系统——你只需要一个力,配上不同的几何。这几个示例的差别,主要在于它们除以了什么。

A force is a vector you add to velocity, once per frame

There is no physics engine here and no mass. A force is a direction and a magnitude that gets added to vx and vy:

That is the entire interface. Everything visual — orbit, spiral, suction, repulsion — comes from how fx and fy are computed. Six demos on this site are six different answers to that one question.

The first decision is whether the force depends on distance. A constant downward force is gravity-as-we-know-it and gives you the firework arc. A force that grows as particles get closer gives you the black hole. A force that stays constant regardless of distance gives you something that feels mechanical rather than physical — which is exactly right for the shockwave.

per particle, per frame
// the only line that matters
p.vx += fx;
p.vy += fy;

Normalise first, then scale

The naive attractor computes the vector to the target and multiplies by a strength:

This is wrong, and the wrongness is visible. Particles far away get a huge raw vector (large dx, dy), so the force scales with distance — the opposite of gravity. Everything snaps toward the centre and you get a single implosion instead of an orbit.

The fix is to divide by the distance, which turns the raw difference into a unit vector, then multiply by whatever falloff you want:

Now the magnitude is under your control. Use 1 / d and it behaves like gravity. Use 1 / (d*d) and it becomes a hard vacuum that only bites when close — this is what the black hole does, and it is why particles there fall in slowly at first and then all at once.

There is a guard worth adding: d < 1 returns early. Without it, a particle that lands exactly on the attractor divides by near-zero and its velocity becomes NaN, which silently poisons every subsequent frame and makes the particle vanish without an error.

attractor with 1/d² falloff
const dx = tx - p.x, dy = ty - p.y;
const d  = Math.hypot(dx, dy) || 1;   // never zero
const f  = strength / (d * d);        // falloff lives here
p.vx += dx / d * f;
p.vy += dy / d * f;

The difference between a tornado and a nebula is one coefficient

An inward force alone produces a collapse. To get rotation you add a perpendicular component — the normal of the direction vector, which in 2D is just (-dy, dx):

Set swirl to zero and you have the black hole. Set it to a value comparable to the inward pull and particles settle into orbit, which is the nebula. Push it higher than the inward pull and they escape outward while spinning — the tornado, where the column is held together by a strong inward term and the spin does the visual work.

Because the tangential force is perpendicular to the radius, it never does work along the radius — it changes direction without changing speed. That is why a swirl force alone makes a stable ring instead of a growing spiral. If you want particles to spiral inward while rotating, you need both terms, and the inward one slightly stronger than the energy the spiral bleeds off.

This is the whole difference between the three demos. Same loop, same particle struct, one extra line.

radial + tangential
// inward
p.vx += dx / d * inward;
p.vy += dy / d * inward;
// tangential: perpendicular to the radius
p.vx += -dy / d * swirl;
p.vy +=  dx / d * swirl;

A shockwave is an impulse, not a field

The shockwave looks like the same kind of thing and is not. It has no attractor and no falloff over time — it fires once, on click:

The key difference: a field is evaluated every frame for every particle, and its strength is a function of position. An impulse is applied once, and its strength is a function of how far the particle was from the click at that instant. Particles outside the radius get nothing, which is what produces the clean expanding ring rather than a general nudge.

The 1 - d/r term is worth staring at. It gives maximum push to particles at the centre of the blast and zero push at the edge, so the ring boundary is continuous — no visible seam where particles suddenly stop being affected. Without it you get a hard disc that pops outward as a unit, which reads as a UI glitch rather than an explosion.

one-shot radial impulse
function shockwave(cx, cy, radius, power) {
  for (const p of ps) {
    const dx = p.x - cx, dy = p.y - cy;
    const d = Math.hypot(dx, dy) || 1;
    if (d > radius) continue;             // outside the blast
    const k = (1 - d / radius) * power;   // strongest at centre
    p.vx += dx / d * k;
    p.vy += dy / d * k;
  }
}

Collision is the one that does not fit

Bouncing balls look like they belong in this family and are implemented completely differently. There is no field — each ball only cares about other balls, and the cost is O(n²) unless you index them spatially.

For a handful of balls, the direct approach is fine and readable. Test every pair, and on overlap resolve it by exchanging velocity along the collision normal:

Two details decide whether it looks right. The position correction overlap/2 — without it, balls sink into each other and stay there, because once they overlap the velocity exchange alone never separates them. And the equal-mass simplification: in a real elastic collision you would weight by mass, but here every ball is the same, so the exchange is symmetric and you can drop the mass terms entirely.

The reason this demo is the odd one out is worth naming: fields are global and stateless — any particle can be evaluated alone. Collisions are pairwise and stateful — the result depends on the set of particles and on the previous frame. That is the line where hand-written motion stops scaling, and where you either add a spatial grid or admit that you need a library.

elastic collision, equal mass
for (let i = 0; i < balls.length; i++) {
  for (let j = i + 1; j < balls.length; j++) {
    const a = balls[i], b = balls[j];
    const dx = b.x - a.x, dy = b.y - a.y;
    const d  = Math.hypot(dx, dy);
    const min = a.r + b.r;
    if (d === 0 || d >= min) continue;

    const nx = dx / d, ny = dy / d;
    // separate them so they stop sinking
    const push = (min - d) / 2;
    a.x -= nx * push;  a.y -= ny * push;
    b.x += nx * push;  b.y += ny * push;
    // equal mass: swap the normal components
    const rel = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
    if (rel > 0) continue;              // already separating
    a.vx += rel * nx;  a.vy += rel * ny;
    b.vx -= rel * nx;  b.vy -= rel * ny;
  }
}

The rel > 0 check prevents balls that are already moving apart from being pulled back together by a second resolution in the same frame.

Springs: when you want the force to depend on position, not on time

The magnet button is a spring, not an attractor, and the difference is practical: an attractor accelerates forever, a spring pulls toward a rest point and then fights you when you overshoot.

The -k * offset is negative so it always points back toward the target. Add damping (v *= 0.85) or the thing oscillates forever and never settles — which is occasionally what you want, but usually reads as a bug.

That is the same equation as the drag-and-release ball in the draggable demo. Once you have written it twice you stop thinking of it as animation and start thinking of it as a constraint you are solving every frame.

spring with damping
const dx = targetX - x;
const dy = targetY - y;
vx += dx * 0.12;      // spring: pull toward the rest point
vy += dy * 0.12;
vx *= 0.85;           // damping: otherwise it rings forever
vy *= 0.85;
x += vx;  y += vy;

Where to take this

Six effects, two primitives: a per-frame field and a one-shot impulse. If you are stuck making a particle scene feel wrong, the first thing to check is whether the force is normalised — an unnormalised difference vector is the single most common reason a swarm collapses into a dot instead of orbiting.

力就是一个每帧加到速度上的向量

这里没有物理引擎,也没有质量。力就是一个方向和一个大小,加到 vx 和 vy 上:

接口就这一个。所有观感——环绕、螺旋、吸入、排斥——都来自 fx 和 fy 怎么算出来。本站六个示例,就是这一个问题的六个答案。

第一个决定是:力要不要跟距离有关。恒定的向下力就是我们熟悉的重力,给出烟花的抛物线。距离越近越强的力给出黑洞。与距离无关、恒定大小的力,会让人感觉「机械」而不是「物理」——而这恰好是冲击波需要的。

每个粒子,每一帧
// 唯一重要的一行
p.vx += fx;
p.vy += fy;

先归一化,再乘系数

最直觉的吸引写法是:算目标向量,乘一个强度:

这是错的,而且错得看得见。远处的粒子原始向量很大(dx、dy 大),于是力跟着距离增长——正好和重力相反。结果所有东西「啪」地一下缩到中心,你得到的是一次内爆,不是一个轨道。

修法是先除以距离,把原始差值变成单位向量,再乘你要的衰减:

现在大小在你自己手里。用 1 / d 像重力。用 1 / (d*d) 就变成一个只有在靠近时才发力的硬真空——黑洞就是这么写的,也是为什么那里的粒子先是慢慢掉,然后一下子全进去了。

有一个必须加的护栏:d < 1 直接返回。少了它,正好落在吸引点上的粒子会除以接近零的数,速度变成 NaN,然后静默地污染后面每一帧,粒子不报错就消失了。

1/d² 衰减的吸引子
const dx = tx - p.x, dy = ty - p.y;
const d  = Math.hypot(dx, dy) || 1;   // 永远不为零
const f  = strength / (d * d);        // 衰减在这里
p.vx += dx / d * f;
p.vy += dy / d * f;

龙卷和星云的差别,只是一个系数

只有向内的力,结果是塌缩。要旋转就得再加一个垂直分量——方向向量的法线,在 2D 里就是 (-dy, dx):

把 swirl 设成 0,你得到黑洞。设成和向内拉力相当的值,粒子会稳定在轨道上,这就是星云。设得比向内拉力更大,它们在旋转中被甩出去——龙卷就是靠一个很强的向内项把柱子拢住,让自旋去完成视觉工作。

因为切向力垂直于半径,它在半径方向上永远不做功——只改方向,不改速度。这也解释了为什么单独的旋涡力会做出一个稳定的环,而不是越来越大的螺旋。想让粒子边转边往里卷,两项都要有,而且内向那一项要比螺旋耗掉的那点能量略强。

这就是这几个示例之间的全部差别。同一个循环,同一个粒子结构,多一行。

径向 + 切向
// 向内
p.vx += dx / d * inward;
p.vy += dy / d * inward;
// 切向:垂直于半径
p.vx += -dy / d * swirl;
p.vy +=  dx / d * swirl;

冲击波是一次冲量,不是一个场

冲击波看起来是同一类东西,其实不是。它没有吸引点,也不随时间衰减——它在点击的那一刻发动一次:

关键区别:场是每帧对每个粒子求值,强度是位置的函数。冲量只施加一次,强度是在那一刻粒子离点击处多远。半径外的粒子什么都不受影响,这才产生了干净扩散的环,而不是整体被推一下。

1 - d/r 这一项值得多看两眼。它让爆心处推力最大、边缘处推力为零,所以环的边界是连续的——没有一条「粒子突然不再受力」的可见缝。去掉它,你会得到一个整体向外弹开的硬圆盘,那读起来像 UI 卡了一下,不像爆炸。

一次性径向冲量
function shockwave(cx, cy, radius, power) {
  for (const p of ps) {
    const dx = p.x - cx, dy = p.y - cy;
    const d = Math.hypot(dx, dy) || 1;
    if (d > radius) continue;             // 半径外不管
    const k = (1 - d / radius) * power;   // 中心最强
    p.vx += dx / d * k;
    p.vy += dy / d * k;
  }
}

碰撞是不合群的那个

弹跳球看起来属于这个家族,实现方式却完全不同。这里没有场——每个球只关心别的球,而且除非你做空间索引,否则开销是 O(n²)。

球不多的时候,直接两两比较就够,也好读。检测重叠,重叠了就在碰撞法线方向上交换速度:

两个细节决定它像不像:位置修正 overlap/2——没有它,球会陷进彼此然后卡在那儿,因为一旦重叠,光靠速度交换永远没法把它们分开。以及等质量简化:真正的弹性碰撞要按质量加权,但这里每个球一样重,交换是对称的,质量项可以整个省掉。

这个示例为什么是异类,值得说清:场是全局且无状态的——任何一个粒子都可以单独求值。碰撞是成对且有状态的——结果取决于粒子集合和上一帧。这就是手写动效开始不 scale 的那条线,过了这条线,你要么加空间网格,要么承认自己需要一个库。

等质量弹性碰撞
for (let i = 0; i < balls.length; i++) {
  for (let j = i + 1; j < balls.length; j++) {
    const a = balls[i], b = balls[j];
    const dx = b.x - a.x, dy = b.y - a.y;
    const d  = Math.hypot(dx, dy);
    const min = a.r + b.r;
    if (d === 0 || d >= min) continue;

    const nx = dx / d, ny = dy / d;
    // 先把它们分开,否则会一直互相陷进去
    const push = (min - d) / 2;
    a.x -= nx * push;  a.y -= ny * push;
    b.x += nx * push;  b.y += ny * push;
    // 等质量:交换法线方向的分量
    const rel = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
    if (rel > 0) continue;              // 已经在分开,别再拉回来
    a.vx += rel * nx;  a.vy += rel * ny;
    b.vx -= rel * nx;  b.vy -= rel * ny;
  }
}

rel > 0 这一判很关键:同一帧里重复求解时,已经分开的球会被再次拉近。

弹簧:当你想让力取决于位置,而不是时间

磁吸按钮是弹簧,不是吸引子,差别很实际:吸引子会永远加速,弹簧把它拉向一个静止点,你冲过头了它还会跟你对着干。

-k * offset 取负号,所以它永远指回目标。必须加阻尼(v *= 0.85),否则它会一直振荡、永不落地——偶尔这正是你要的,但多数时候读起来像 bug。

这和拖拽球松手回弹是同一个方程。写过两次之后,你就不会把它当「动画」看,而是当成一个每帧求解一次的约束。

带阻尼的弹簧
const dx = targetX - x;
const dy = targetY - y;
vx += dx * 0.12;      // 弹簧:拉向静止点
vy += dy * 0.12;
vx *= 0.85;           // 阻尼:否则会一直回荡
vy *= 0.85;
x += vx;  y += vy;

接下来可以怎么用

六个效果,两个原语:一个逐帧的场,一个一次的冲量。如果你的粒子场景「哪里不对」又说不上来,先检查力有没有归一化——没归一化的差值向量,是一群粒子塌成一个点而不是绕起来的头号原因。

Related demos相关示例

Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。

Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。

Browse all 131 effects浏览全部 131 个特效