Shapes nobody drew: recursion, displacement and polar coordinates没人画过的形状:递归、位移与极坐标
Everything else on this site is a loop over particles. These four are a loop over structure: a bolt that subdivides itself, a tree that calls itself, a spiral that is one equation, and a pulse that is one phase offset.本站其它东西都是对粒子的循环。这四个是对结构的循环:一棵自己分裂的闪电、一棵调用自己的树、一条只有一个方程的螺旋、一圈只差一个相位的脉冲。
Lightning: split the line, then split the halves
A bolt from top to bottom is a straight line. The algorithm that makes it look like lightning is called midpoint displacement, and it is three steps: take a segment, find its midpoint, push that midpoint sideways by a random amount, then recurse into both halves with a smaller push.
Six to seven levels gives 64 to 128 segments — enough detail to read as a bolt, few enough to redraw every frame. Each level halves the displacement, which is what gives the characteristic "big kinks near the top, fine jitter near the bottom".
Two things separate a convincing bolt from a jagged line. First, the displacement should be scaled by the segment length, not by a constant — otherwise long segments get the same wobble as short ones and the result looks uniform rather than fractal. Second, the branches: pick two or three midpoints along the main path and run the same recursion from them at a steeper angle and a fraction of the length. Without branches it is a squiggle; with them it is lightning.
The flash is a separate matter and is mostly timing. A real strike is two or three frames of full brightness, then a dimmer re-strike, then nothing. Drawing the bolt at constant brightness for 200ms reads as a static graphic, not a discharge.
function bolt(x1, y1, x2, y2, depth, displace) {
if (depth === 0) { line(x1, y1, x2, y2); return; }
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * displace;
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * displace;
bolt(x1, y1, mx, my, depth - 1, displace * 0.55);
bolt(mx, my, x2, y2, depth - 1, displace * 0.55);
}
displace * 0.55 per level is the roughness constant. Above 0.7 the bolt becomes noise; below 0.4 it is almost a straight line.
A tree is a function that calls itself twice
The fractal tree is the cleanest recursion on the site: draw a branch, turn left and draw a smaller one, turn right and draw a smaller one, stop at depth zero.
The three parameters are length, angle and depth, and they are not independent. A 0.75 length ratio with a 25° spread and depth 9 fills a square nicely. Change the ratio to 0.85 and it becomes a spindly tower that runs off the top of the canvas. Change the angle to 45° and the branches collide with each other.
The randomness is what makes it look grown rather than drawn. Each branch gets ±6° of jitter and ±10% of length. Without it the tree is perfectly symmetric and reads as a diagram; with too much it loses its species and reads as a scribble. Six degrees is roughly the range where a tree still looks like a particular kind of tree.
The growth animation is worth noting because it is not a redraw. Each branch is drawn progressively — the recursion runs once at setup with a per-branch delay, and each branch animates its own length from 0 to full. That is why the tree appears to grow from the trunk outward rather than fading in as a whole.
function branch(x, y, len, angle, depth) {
if (depth === 0) return;
const x2 = x + Math.cos(angle) * len;
const y2 = y + Math.sin(angle) * len;
line(x, y, x2, y2);
const jitter = (Math.random() - 0.5) * 0.21; // ±6°
branch(x2, y2, len * 0.74, angle - 0.44 + jitter, depth - 1);
branch(x2, y2, len * 0.74, angle + 0.44 + jitter, depth - 1);
}
A spiral is one equation, and the parameters are the style
The hypnotic spiral is not a curve fitted to points. It is r = a + b·θ — the Archimedean spiral — evaluated at a dense set of angles:
b is the spacing between turns. Large b gives a loose, open spiral; small b gives a tight coil that reads as concentric rings. The animation is a single phase added to θ, and because the spiral is self-similar under rotation only for particular b values, a phase shift produces the illusion of the whole thing rotating without any element moving.
The colour is the other half. Mapping hue to θ rather than to the turn index means the gradient runs continuously along the curve, so the eye follows it around. Mapping hue to the turn index gives concentric bands instead — a completely different, much less hypnotic image, from the same geometry.
Why the phase animation works: at any instant, the spiral looks identical to its own rotation by a fraction of a turn only if the phase and the spiral step are commensurate. Pick a step that is not a neat fraction of 2π, and the pattern appears to flow inward or outward instead of rotating — which is the more interesting of the two motions.
const turns = 14, step = 0.06;
for (let a = 0; a < turns * Math.PI * 2; a += step) {
const r = 4 + (a / (Math.PI * 2)) * 14; // b = 14 per turn
const x = cx + Math.cos(a + phase) * r;
const y = cy + Math.sin(a + phase) * r;
dot(x, y, 2.4, 'hsl(' + (a * 22 % 360) + ',85%,62%)');
}
Pulse: rings on a shared clock, offset by phase
The concentric pulse is the simplest structure here and the one people get wrong most often. The naive version spawns a new ring every n frames and moves each one outward — which produces rings that appear suddenly at the centre and overlap badly when the spawn rate drifts.
The version that looks right does not spawn anything. It keeps a fixed number of rings, and every ring computes its radius from the same clock with a different phase offset:
The % 1 wraps each ring back to the centre when it finishes, so the ring count never changes and there is no allocation. The stagger is i/n, which spaces the rings evenly through their cycle — change the number of rings and the spacing adjusts itself, with no other parameter to tune.
The easing on the radius is the last touch. Linear expansion reads mechanical; 1 - (1-t)² makes rings shoot out fast and slow down as they fade, which is what a physical disturbance does. And the alpha must fade to zero exactly as t reaches 1, or you see rings pop out of existence at the canvas edge.
const RINGS = 6;
function frame(now) {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < RINGS; i++) {
const t = ((now / 2200) + i / RINGS) % 1; // shared clock, phase offset
const r = 240 * (1 - (1 - t) * (1 - t)); // fast out, slow fade
ctx.globalAlpha = (1 - t) * 0.7; // zero exactly at t = 1
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 7);
ctx.stroke();
}
requestAnimationFrame(frame);
}
The common thread: generate, do not store
None of these four has a data structure describing its shape. The tree is not an array of branches, the bolt is not a list of points, the spiral is not a polyline — they are all recomputed each frame from a handful of numbers.
That is what makes them cheap to animate. Because shape is a function of parameters, changing a parameter changes the shape smoothly and instantly: rotate the spiral by changing the phase, grow the tree by changing the length ratio, make the lightning more violent by raising the roughness constant.
It also means they cost nothing to keep alive. There is no allocation, no garbage, no cleanup — the only state is the clock. Which is why these can run for hours without drifting, while a particle system with a leak will slow to a crawl in three minutes.
The trade-off is control: a generated shape is hard to art-direct. You cannot place one particular branch where you want it. That is the point at which you either accept the algorithm's taste or switch to hand-placed geometry — and knowing which of those you need is most of the design decision.
Where to take this
Four algorithms, four parameters each: a bolt that subdivides, a tree that recurses, a spiral that is one equation, a pulse that is one phase offset. All of them are cheaper to compute than to store, and all of them look organic only because of a small amount of randomness applied at the right scale.
闪电:把线段劈开,再把两半各劈一次
一道从天到地的闪电本来是一条直线。让它像闪电的算法叫中点位移,三步:取一段、找中点、把中点往侧向随机推一点、然后带着更小的推力递归进两半。
六到七层就是 64 到 128 段——细节足够读作闪电,数量也足够每帧重画。每层位移减半,这才产生那种特征:顶部大折角、底部细抖动。
两件事把「像闪电」和「一条锯齿线」分开。第一,位移要按线段长度缩放,不能按常数——否则长段和短段得到一样的抖动,结果看起来均匀而不分形。第二,分支:在主路径上挑两三个中点,用同样的递归、更陡的角度、更短的长度跑一遍。没有分支它是一条歪线,有了分支才是闪电。
闪光是另一件事,基本上是个时间问题。真实的落雷是两三帧的全亮、一次较暗的回击、然后什么都没有。以恒定亮度画 200 毫秒,读起来是一张静态图,不是一次放电。
function bolt(x1, y1, x2, y2, depth, displace) {
if (depth === 0) { line(x1, y1, x2, y2); return; }
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * displace;
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * displace;
bolt(x1, y1, mx, my, depth - 1, displace * 0.55);
bolt(mx, my, x2, y2, depth - 1, displace * 0.55);
}
每层乘 0.55 是粗糙度常数。高于 0.7 就成噪声了,低于 0.4 几乎是一条直线。
一棵树,就是一个调用自己两次的函数
分形树是本站最干净的递归:画一根枝、左转画一根更短的、右转画一根更短的、深度到零就停。
三个参数是长度、角度、深度,而它们不独立。长度比 0.75、张开 25°、深度 9,刚好填满一个正方形。长度比改成 0.85,它变成一根瘦长的塔,直接顶出画布。角度改成 45°,枝条开始互相撞。
随机性才是它像长出来的而不是画出来的原因。每根枝有 ±6° 抖动、±10% 长度。没有抖动,树完美对称,读起来像示意图;抖动过头,物种感消失,读起来像乱涂。六度左右,是树还能被认出「是某种树」的区间。
生长动画值得说一句,因为它不是「重画」。每根枝是渐进画出来的——递归在初始化时跑一次,给每根枝一个延迟,然后每根枝自己从 0 长到全长。所以树看起来是从树干往外长的,而不是整体淡入。
function branch(x, y, len, angle, depth) {
if (depth === 0) return;
const x2 = x + Math.cos(angle) * len;
const y2 = y + Math.sin(angle) * len;
line(x, y, x2, y2);
const jitter = (Math.random() - 0.5) * 0.21; // ±6°
branch(x2, y2, len * 0.74, angle - 0.44 + jitter, depth - 1);
branch(x2, y2, len * 0.74, angle + 0.44 + jitter, depth - 1);
}
一条螺旋就是一个方程,参数就是风格
催眠螺旋不是拟合出来的曲线。它是 r = a + b·θ——阿基米德螺旋——在一组密集角度上求值:
b 是每圈的间距。b 大,螺旋疏松;b 小,卷得紧,读起来像同心圆。动画只是在 θ 上加一个相位,而因为螺旋只在特定的 b 下才在旋转下自相似,一个相位偏移就能骗出「整体在转」而没有任何元素真的移动。
颜色是另一半。把色相映射到 θ 而不是「第几圈」,渐变就会沿曲线连续流动,眼睛于是会顺着它绕。映射到圈号则得到一圈一圈的同心色带——同样的几何,完全不同的画面,而且远没那么催眠。
相位动画为什么成立:只有在相位和螺距可公度时,螺旋看起来才和它自己转过一个分数圈之后一样。挑一个不是 2π 整数分之一的步长,图案看起来就会向内或向外流动,而不是单纯旋转——那是两种运动里更有意思的一种。
const turns = 14, step = 0.06;
for (let a = 0; a < turns * Math.PI * 2; a += step) {
const r = 4 + (a / (Math.PI * 2)) * 14; // 每圈 b = 14
const x = cx + Math.cos(a + phase) * r;
const y = cy + Math.sin(a + phase) * r;
dot(x, y, 2.4, 'hsl(' + (a * 22 % 360) + ',85%,62%)');
}
脉冲:共用一只钟的环,靠相位错开
同心脉冲是这里最简单的结构,也是大家写得最不对的一个。直觉版本每隔 n 帧生成一个新环、每个往外走——结果是环总是在中心突然出现,而且生成节奏一漂,环就叠得很糟。
好看的版本根本不生成。它保持固定数量的环,每个环从同一只钟、带不同的相位偏移,算出自己的半径:
% 1 让每个环走完一圈就回到中心,于是环的数量永不变、也没有内存分配。错峰是 i/n,把环均匀地铺在一个周期里——改环数,间距自己就对了,没有别的参数要调。
半径上的缓动是最后一笔。线性扩张读起来机械;1 - (1-t)² 让环冲出去、在淡出时慢下来,这正是物理扰动会做的事。而透明度必须在 t 正好到 1 时降到 0,否则你会看到环在画布边缘「啪」地消失。
const RINGS = 6;
function frame(now) {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < RINGS; i++) {
const t = ((now / 2200) + i / RINGS) % 1; // 同一只钟,相位错开
const r = 240 * (1 - (1 - t) * (1 - t)); // 先快后慢
ctx.globalAlpha = (1 - t) * 0.7; // t = 1 时正好归零
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 7);
ctx.stroke();
}
requestAnimationFrame(frame);
}
共同的线索:算出来,而不是存起来
这四个里没有一个用数据结构描述自己的形状。树不是枝条数组,闪电不是点列表,螺旋不是折线——它们每帧都用几个数字重新算出来。
这就是它们动起来便宜的原因。因为形状是参数的函数,改一个参数,形状立刻平滑地变:改相位就转螺旋,改长度比就长树,提高粗糙度常数闪电就更暴。
这也意味着维护它们不花钱。没有分配、没有垃圾、没有清理——唯一的状态是那只钟。所以它们能连着跑几个小时不漂移,而一个漏了内存的粒子系统三分钟就卡成幻灯片。
代价是控制力:生成的形状很难美术指导。你没法指定某根枝出现在某个位置。到这一步,你要么接受算法的品味,要么换成手工摆放的几何——判断自己需要哪一种,才是设计决定的绝大部分。
接下来可以怎么用
四个算法,各四个参数:会分裂的闪电、会递归的树、只有一个方程的螺旋、只差一个相位的脉冲。它们算起来都比存起来便宜,而它们之所以像有机体,只是因为在正确的尺度上加了一点点随机。
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 个特效