一行代码的两片海:10,000 个采样点如何长成 19 只水母与 2 株海扇
拆解两条 258 字符的 code-golf 点函数:一条把 10,000 个索引按 19 取模分成 19 只独立水母,另一条按 2 取模分成两株海扇。附可逐字运行原始代码、可按余数类分解的全屏交互可视化。
下面两条公式,各自只有两行、259 与 258 个字符:
1
2
a=(m,d=mag(k=9*cos(i*5)*sin(i),e=cos(i*7)*cos(i)*9)**3/999+4.6-cos(t/4+m)**3/3)=>e>0&&point(99*sin(c=d/8-t/32+m)+k/3**(o=sin(d*d-t+m))+200,99*cos(c/3)+d*39+e**o)
t=0,draw=$=>{t||createCanvas(w=400,w);background(9).stroke(w,96);for(t+=PI/30,i=1e4;i--;)a(i%19)}//
1
2
a=(y,d=mag(k=(4+sin(y))*cos(i/7),e=y/5-11)-6)=>point((99+k*k)*sin(c=d/4-t/4+i%2*9)+200,119*sin(c/2)+200+d**3/9*sin(t*3-d/.7)+3*sin(k*2)+y/23*k*(e+sin(e-d*4)))
t=0,draw=$=>{t||createCanvas(w=400,w);background(9).stroke(w,166);for(t+=PI/60,i=1e4;i--;)a(i/299)}/
第一条画出一群游动的发光水母,第二条画出两株带卷须的海扇。它们和之前那篇 用 20,000 个点画一只会呼吸的水母 是同一个家族:都用 mag / point / createCanvas / stroke 这一套 p5 原语,都把 i 从 1e4 递减到 0 逐点求值,都把时间塞进一个不断累加的 t。
但真正决定画面的不是那些三角函数,而是一个几乎没人注意的细节——a(i%19) 和 a(i%2)。取模运算符在这里不是”取余数”,而是一个分类器:它把 10,000 个采样点切成 19 组或 2 组,每一组各自画成一个独立的生物。
下面这个页面把两条公式都跑起来,可以逐字执行原始代码,也可以按余数类把画面拆开:
进去以后先点「分解视图 → 按余数着色」:19 只水母会立刻变成 19 种颜色,每一只都是独立的一笔。再点「单只高亮」把其余 18 只压暗,就能看清单独一只的结构。按 H 隐藏面板。
一、先把高尔夫球杆放下:两条公式的可读版本
code-golf 的写法把变量声明、初始化、循环全部压缩进了表达式,展开之后其实平平无奇。
公式 A(19 只水母):
1
2
3
4
5
6
7
8
9
10
const m = i % 19; // 分组号:这一笔属于第几只水母
const k = 9 * cos(i * 5) * sin(i);
const e = cos(i * 7) * cos(i) * 9;
const d = mag(k, e) ** 3 / 999 + 4.6 - cos(t / 4 + m) ** 3 / 3;
const c = d / 8 - t / 32 + m;
const o = sin(d * d - t + m);
if (e > 0) { // 原始代码用短路 && 当模板测试
point(99 * sin(c) + k / 3 ** o + 200,
99 * cos(c / 3) + d * 39 + e ** o);
}
公式 B(2 株海扇):
1
2
3
4
5
6
7
8
9
10
const y = i / 299;
const k = (4 + sin(y)) * cos(i / 7);
const e = y / 5 - 11;
const d = mag(k, e) - 6;
const c = d / 4 - t / 4 + (i % 2) * 9;
point((99 + k * k) * sin(c) + 200,
119 * sin(c / 2) + 200
+ d ** 3 / 9 * sin(t * 3 - d / 0.7)
+ 3 * sin(k * 2)
+ y / 23 * k * (e + sin(e - d * 4)));
两条公式里都有三个共同的”压缩技巧”,展开后一眼就能看懂:
| 写法 | 含义 |
|---|---|
a=(m,d=…)=>… | 用默认参数执行赋值:调用 a(i%19) 时 d 被顺带算出来 |
t=0,draw=$=>{…} | 逗号表达式:同一个语句里同时初始化 t 和定义 draw |
mag(k=…,e=…) | 参数求值顺序保证 k、e 先被赋值,再算模长 |
3**(o=sin(…)) | 幂运算的指数位置塞进赋值,o 只算一次 |
e>0&&point(…) | 用逻辑短路当 if:这是公式 A 的”模板测试” |
i=1e4;i--; | 从 9999 倒数到 0,倒序决定后画的点压住先画的点 |
二、解剖公式 A:一只水母的六个变量
把 A 拆成六个中间量,各自的取值范围都是可以算出来的(下表是把 10,000 个索引在完整时间周期上跑一遍得到的实测值):
| 量 | 定义 | 实测范围 | 在画面里干什么 |
|---|---|---|---|
| $m$ | i % 19 | 0 … 18 | 分组号,决定这一笔属于第几只水母 |
| $k$ | $9\cos(5i)\sin i$ | $-8.58 \dots 8.58$ | 横向载波:$x$ 主要由它驱动 |
| $e$ | $9\cos(7i)\cos i$ | $-8.13 \dots 9.00$ | 模板测试 + 纵向微调(配合 $e^{o}$) |
| $d$ | $\operatorname{mag}(k,e)^{3}/999+4.6-\cos^{3}(t/4+m)/3$ | $4.27 \dots 5.66$ | 径向项:$39d\in[166,221]$ 提供纵向基线 |
| $c$ | $d/8-t/32+m$ | 类内仅变化 $0.09$ rad | 类内近似常数,$99\sin c$ 只贡献约 9 px |
| $o$ | $\sin(d^{2}-t+m)$ | $-1 \dots 1$ | 相位调制器:$3^{o}\in[1/3,3]$、$e^{o}\in[1/9,9]$ |
| $k$ 的上界值得单独说一句:它写作 $9\cos(5i)\sin i$,两次乘法的幅角不同($5i$ 与 $i$),所以 $ | k | $ 可以逼近 9,而不是 $\tfrac{9}{2}$。同理 $d$ 里的 $\operatorname{mag}(k,e)$ 理论上可达 $\sqrt{9^2+9^2}\approx12.73$,于是 $\operatorname{mag}^3/999$ 落在 $[0,0.73]$。 |
真正的机制藏在类内。以第 7 只为例,它总共 226 个点:
- $c$ 在整只水母内部只变了 $0.091$ 弧度,所以 $99\sin c$ 几乎是常数,横向形状几乎全部来自 $k/3^{o}$(类内跨度 $\pm11$ px,与 $k$ 的相关系数 $0.90$);
- $39d$ 在类内从 $176.6$ 变到 $205.0$(相关性 $0.98$),纵向形状几乎全部来自 $d$,再叠加 $e^{o}\in[0.14,8.59]$ 的次级漂移。
也就是说,一只水母其实是 $\big(k,\ \operatorname{mag}(k,e)\big)$ 这对变量在平面上的函数图像:横向读 $k$,纵向读 $d$。而 $\operatorname{mag}$ 是径向的、$o$ 是非线性的,两者叠加就把一条普通的参数曲线卷成了”伞盖 + 拖尾”。
这是涌现,不是设计。公式里没有任何一处写着”画一只水母”——它只是把一个点函数迭代一万次。
三、19 与 2:取模就是分类器
现在来看最关键的一张表。公式 A 把 10,000 个索引按 19 取模:
| 量 | 数值 |
|---|---|
| 总采样 | $i = 0 \dots 9999$,共 10,000 个 |
| 组数 | 19(每类 526 或 527 个) |
通过 e>0 模板测试的 | 4,259 个(42.59%) |
| 每类实际落点 | 222 … 226 个,19 类全部非空 |
| 单只水母在原画布上的尺寸 | 约 $22\times33$ px(400×400 画布内) |
于是”一万个点”其实是这样分配的:$4259 \div 19 \approx 224$,每只水母只有两百多个点,剩下的 5,741 个点被 e>0 拦掉了。
按下「按余数着色」,19 个等价类就被染成 19 种颜色,画面立刻”散架”成 19 个独立的个体:
把 10,000 个索引按 19 取模分成 19 组,每组分别着色。每一只水母恰好是一种颜色——这不是”画了 19 只水母”,而是”一个公式 × 19 个余数类”。
再进一步,只点亮第 8 只(i % 19 = 7),其余压暗成幽灵:
单只高亮。第 7 类的 226 个点独立构成一只完整的水母;它的横向位置由 $99\sin c$ 固定,形态由 $k/3^o$ 与 $39d$ 决定。
19 只的中心坐标也各不相同($m$ 从 0 走到 18,中心点从 $(102,196)$ 一路扫到 $(299,274)$),所以它们看起来像一群散开的水母,而不是 19 个重叠的副本。
公式 B 用同一招,只是取模 2:
公式 B 的 i % 2 把一万个点均分为两组(各 5,000 个),加上 c 里那个 (i%2)*9 的相位偏移,两株海扇相差 9 弧度(≈1.43 个周期)——这正是两条丝带互相缠绕、却又不会完全重合的原因。
四、两株海扇:五个纵向项叠出来的叶脉
公式 B 的结构比 A 更”直白”一些,因为它的采样轴是明写的 $y=i/299\in[0,33.44]$:
| 项 | 表达式 | 作用 |
|---|---|---|
| 横向 | $(99+k^{2})\sin c+200$ | 振幅 $99 \dots 124$(由 $k^2$ 调制),相位 $c$ 与 $x$ 的相关性达 $0.996$ |
| 相位 | $c=d/4-t/4+(i\%2)\cdot 9$ | 随 $d$ 漂移、随时间流动,$i\%2$ 决定两株的错位 |
| 纵向骨架 | $119\sin(c/2)+200$ | 一条缓慢的正弦带,$c/2$ 让纵向频率只有横向的一半 |
| 大尺度起伏 | $d^{3}/9\cdot\sin(3t-d/0.7)$ | $d\in[-1.69,5.84]$,$d^3/9$ 最大约 $22.6$,负责整株的弯折 |
| 细波纹 | $3\sin(2k)$ | 幅度仅 $\pm3$,是叶缘的锯齿 |
| 叶脉 | $y/23\cdot k\cdot(e+\sin(e-4d))$ | $e\in[-11,-4.31]$ 恒为负,这一项把点推向外侧,形成一根根分支 |
其中 $e$ 恒为负是个有意思的细节:$y\in[0,33.44]$,所以 $e=y/5-11\in[-11,-4.31]$,永远到不了 0。这意味着 $\operatorname{mag}(k,e)$ 有下界 $4.31$,于是 $d=\operatorname{mag}-6$ 可以取负值(最小 $-1.69$)——$d$ 为负时 $d^{3}$ 为负,整株海扇会向内反折,这就是画面里那些回卷的钩状卷须的来源。
还有一个对照:公式 B 没有模板测试。A 用 e>0&& 丢掉了 57% 的点,B 一万个点全部落笔。所以 B 的线条明显比 A 更密、更亮。
五、为什么它看起来”活着”
两条曲线都是静止的点集,动起来靠的是时间 $t$ 进入多个相位。除此之外还有三个来源:
1. 载波 × 包络的拍频。 公式 A 的 $k=9\cos(5i)\sin i$ 是两个不同频率相乘:$5i$ 提供快速振荡,$i$ 提供慢速包络。两者相乘形成拍,所以水母的伞缘不会像规则圆环那样机械,而是有粗有细。
2. 径向幂造成的层次。 $d$ 用的是 $\operatorname{mag}^3$ 而不是 $\operatorname{mag}$:平方(三次)会让远处的点增长更快。虽然在这个参数下 $d$ 只落在 $[4.27,5.66]$ 这个窄带里,但窄带的好处恰是”秩序”——19 只水母的纵向基线都被锁在 $39d\in[166,221]$,所以整群水母齐刷刷地朝向同一个方向。
3. 采样顺序。 把公式 B 的点按 $i$ 的大小上色(从青到红),就能看见它是怎么被”画”出来的:
沿 $i$ 的彩虹渐变。每条曲线都是一笔连成的——两个嵌套的渐变说明 $i$ 的奇偶两类各自走了一整条路径,正是因为相位差 9 弧度,两条路径互相缠绕。
六、两条公式的逐项对照
| 公式 A(19 只水母) | 公式 B(2 株海扇) | |
|---|---|---|
| 字符数 | 259 | 258 |
| 时间步长 | t += PI/30 → 60 fps 下 1.0 秒一个周期 | t += PI/60 → 2.0 秒一个周期 |
| 笔触透明度 | stroke(w,96) → $\alpha\approx0.38$ | stroke(w,166) → $\alpha\approx0.65$ |
| 取模 | i % 19 → 19 组 | i % 2 → 2 组 |
| 采样轴 | 不显式定义($i$ 直接进入三角函数) | $y=i/299\in[0,33.44]$ |
| 模板测试 | e>0,保留 42.59% | 无,100% 落笔 |
| 纵向主项 | $39d$(基线)+ $e^{o}$ | $119\sin(c/2)$ + $d^{3}/9\cdot\sin(3t-d/0.7)$ |
| 原画布上占位 | $x\in[83,317]$,$y\in[68,\sim330]$ | $x\in[76,324]$,$y\in[43,292]$ |
两条公式合起来看,A 更淡、更慢、更”深水”,B 更亮、更快、更像近景。也正因为 A 的透明度只有 B 的六成、并且丢了 57% 的点,它才有那种”远处一群发光小生物”的空气感。
七、交互页面做了什么
把两条公式搬进一个可交互页面,关键是要不改动数学。所以:
- 页面默认用展开版计算出与原始代码逐点等价的点集(在 Node 里把两条路径各跑一帧比对,最大偏差 $2\times10^{-5}$,纯浮点舍入);
- 勾选「逐字直跑原始一行代码」后,页面会原样执行帖子里的源码——它自带一个只实现
mag/createCanvas/background/stroke/point的极小 p5 替身。这里有个真实的坑:源码会隐式创建k、e、d、o、t、draw等未声明的变量,而 JavaScript 的with(env)只拦截 env 上已存在的属性,所以必须先给这些名字占位,否则它们会漏到globalThis上,画面永远出不来; - 源码结尾的
//和/是原帖的残留符号(第二条那个孤立的/单独出现其实是语法错误),页面会按”原样 → 去尾部符号 → 只保留到}“的顺序依次尝试编译,成功后顺带提示一句。
其余参数:
| 参数 | 默认 | 作用 |
|---|---|---|
| 作品 | 19 只水母 | 切换 A / B / 两片海(并置对照) |
| 配色 | 荧光 | 荧光 / 原味白 / 深海金 / 霓虹紫 / 极光绿 |
| 分解视图 | 关闭 | 关闭 / 按余数着色 / 单只高亮 |
| 曝光遍数 | 2 | 同一批点重复叠加,用 screen 混合累积亮度 |
| 点尺寸 | 1.00 px | 单个采样点的边长 |
| 拖尾 | 62 % | 每帧不清屏,而是盖一层半透明底色 |
| 发光 | 46 % | 把放大的点烘到 1/4 分辨率的离屏画布,再放大贴回形成柔光 |
| 速度 | 1.00× | 时间流速 |
| 缩放 | 1.00× | 滚轮以指针为锚点缩放,拖动平移,双击复位 |
快捷键:Space 暂停 · H 隐藏面板 · R 重置 · S 存 PNG · F 全屏 · 1/2/3 切作品 · D 切分解视图 · V 直跑原码 · [/] 曝光 · T 拖尾 · G 发光 · ↑/↓ 速度 · ←/→ 色相。所有状态都会写进 URL 的 hash,可以直接分享某个特定的画面。
实现上没有用 p5,而是直接用 Canvas 2D 重写了绘制层:每帧把 10,000 个点按 (层, 余数类) 分桶塞进 Path2D,每桶只调一次 fill();平移和缩放只改变换矩阵,因此拖拽时不需要重新计算任何数学。帧率连续两个采样窗口低于 40 fps 时会自动降载:先关发光,再降曝光,最后把渲染分辨率降到 1×。
八、结语
这两条公式最迷人的地方,是它们没有画任何东西。没有模型、没有贴图、没有骨骼动画,只有一个返回 undefined 的点函数被调用了一万次。生物感全部来自数学结构本身:取模把索引分成族群,载波与包络做出拍频,径向幂建立层次,相位让时间流动。
而”19 只”和”2 株”这种数量,从一开始就写在代码里那个不起眼的 % 上。
同一系列的其他作品:用复几何级数的 partial sums 做触手、用辛积分器积分双势阱哈密顿量做伞体、以及最初那篇用 20,000 个点画一只会呼吸的水母。它们都不是”用代码画生物”,而是让某个数学对象恰好长成了生物的样子。



