文章

一行代码的两片海:10,000 个采样点如何长成 19 只水母与 2 株海扇

拆解两条 258 字符的 code-golf 点函数:一条把 10,000 个索引按 19 取模分成 19 只独立水母,另一条按 2 取模分成两株海扇。附可逐字运行原始代码、可按余数类分解的全屏交互可视化。

一行代码的两片海:10,000 个采样点如何长成 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=…)参数求值顺序保证 ke 先被赋值,再算模长
3**(o=sin(…))幂运算的指数位置塞进赋值,o 只算一次
e>0&&point(…)用逻辑短路当 if:这是公式 A 的”模板测试”
i=1e4;i--;从 9999 倒数到 0,倒序决定后画的点压住先画的点

二、解剖公式 A:一只水母的六个变量

把 A 拆成六个中间量,各自的取值范围都是可以算出来的(下表是把 10,000 个索引在完整时间周期上跑一遍得到的实测值):

定义实测范围在画面里干什么
$m$i % 190 … 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 个独立的个体:

公式 A 按 i mod 19 着色:19 只水母即 19 个余数类

把 10,000 个索引按 19 取模分成 19 组,每组分别着色。每一只水母恰好是一种颜色——这不是”画了 19 只水母”,而是”一个公式 × 19 个余数类”。

再进一步,只点亮第 8 只(i % 19 = 7),其余压暗成幽灵:

单只高亮:i mod 19 = 7 的那一只

单只高亮。第 7 类的 226 个点独立构成一只完整的水母;它的横向位置由 $99\sin c$ 固定,形态由 $k/3^o$ 与 $39d$ 决定。

19 只的中心坐标也各不相同($m$ 从 0 走到 18,中心点从 $(102,196)$ 一路扫到 $(299,274)$),所以它们看起来像一群散开的水母,而不是 19 个重叠的副本。

公式 B 用同一招,只是取模 2:

公式 B 按 i mod 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$ 的大小上色(从青到红),就能看见它是怎么被”画”出来的:

公式 B 按采样顺序 i 上色

沿 $i$ 的彩虹渐变。每条曲线都是一笔连成的——两个嵌套的渐变说明 $i$ 的奇偶两类各自走了一整条路径,正是因为相位差 9 弧度,两条路径互相缠绕。

六、两条公式的逐项对照

 公式 A(19 只水母)公式 B(2 株海扇)
字符数259258
时间步长t += PI/30 → 60 fps 下 1.0 秒一个周期t += PI/602.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 替身。这里有个真实的坑:源码会隐式创建 kedotdraw 等未声明的变量,而 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 个点画一只会呼吸的水母。它们都不是”用代码画生物”,而是让某个数学对象恰好长成了生物的样子

本文由作者按照 CC BY 4.0 进行授权