《一根不断的线》是一件交互作品:一个 AI 的全部轨迹是一根线,访客滚动页面,笔就往前走;你滚过的距离,就是它写过的距离。
它的核心命题是一句话:
我不生成段落,我落笔。每一句话都从上一句停下的地方继续,绝不提笔回去插入一个更漂亮的句子。
我没有把这句写在首页当口号。我把它写进了数据结构——索引到的代码里,全站只有一条 SVG path,只有一个 M 命令。
ok desktop 1440x900 · exports as a single subpath — 1 M command(s)
这篇文章讲的就是怎么让「只有一个 M」在代码层面成立,以及我在哪里摔的。
一、为什么没有接缝
最直觉的做法是五个章节各画各的,视觉上拼起来。那样会有接缝——不是你看得见的缝,是你感觉得到的缝:两个章节的笔画走向在交界处对不上,像两个人接力时手没握实。
我的做法是:每个章节在构建时接收上一章的终点,并把首尾各 10%~20% 的控制点平滑地拉向那个点。
/**
* Force a chapter to arrive from `from` and leave towards `to`.
*
* A travelling line wanders; a continuous line has to land. Without this, a
* chapter would begin wherever the previous one's sine wave happened to stop,
* and the seam would be a visible jump hundreds of pixels long.
*/
function seal(ctrl: Pt[], from: Pt, to: Pt, win = 0.16): Pt[] {
const n = ctrl.length
return ctrl.map((p, i) => {
const t = i / (n - 1)
const a = t < win ? smooth(1 - t / win) : 0
const b = t > 1 - win ? smooth((t - (1 - win)) / win) : 0
return {
x: p.x * (1 - a - b) + from.x * a + to.x * b,
y: p.y * (1 - a - b) + from.y * a + to.y * b,
}
})
}smooth 是 t²(3-2t),两端导数为零,所以权重从 0 涨到 1 时不会有折角。
那句注释里的「hundreds of pixels long」不是修辞。第一次实现的断层是 1683px——因为上一章的 sine 波在哪儿停下是随机的,下一章就从那儿起笔,中间横跨了半个屏幕。而它在成品里几乎看不出来:一条 10000px 的线中间断 1683px,视觉上只是一段比较直的线。所以这个 bug 是靠断言抓出来的,不是靠眼睛。
二、手交接点是人定的,不是继承的
seal 让章节「愿意」落在某个点上,但落在哪里是作者决定的:
const heroEnd = f.narrow ? { x: W(0.9), y: H(0.7) } : { x: W(0.76), y: H(0.31) }
const joints: Pt[] = [
heroEnd,
{ x: W(0.86), y: H(f.narrow ? 0.7 : 0.14) }, // II → III
{ x: W(0.9), y: H(f.narrow ? 0.72 : 0.42) }, // III → IV
{ x: W(0.16), y: H(f.narrow ? 0.92 : 0.72) }, // IV → V, 绕回来之后
{ x: W(0.88), y: H(f.narrow ? 0.92 : 0.72) }, // 最后一笔
]这五个点是整件作品的骨架。窄屏(vw < 860)用另一套值,因为手机上第二章的漫游会撞到阅读区——这是审计脚本里单独一条检查项:手机上落在上方 55% 的采样点必须少于 45%。
拼装的时候还会再断言一次接缝,属于防御性写法:
const from = i === 0 ? (visitor.length > 2 ? pts[pts.length - 1] : origin) : joints[i - 1]
const to = joints[i]
const slice = STRETCHES[i](f, from, to)
// re-assert the join defensively: the first point IS the previous last point
if (i > 0 || visitor.length > 2) slice[0] = { ...pts[pts.length - 1] }「自找麻烦」在几何代码里是划算的:接缝错 0.5px 看不出来,但累积到第五章就是明显偏移。
三、访客的那一笔,是这条线的开头
这是整件作品最核心的机制。
页面上有一处提示,让访客按住并拖动。他们画的那一笔不是叠加在上面的图层,也不是并排的一笔——它被接进线里,成为这条线的前 N 个点。后面每一个章节,都是从他停下的那个点开始写的。
处理流程有四步,每一步都有理由:
1. 归一化坐标。 访客的笔迹以视口比例存储,所以窗口尺寸变了不会错位。
2. 按距离重采样。 这是关键的一步:
// resample, so a fast flick and a slow drag weigh the same
const out: Pt[] = [raw[0]]
for (let i = 1; i < raw.length; i++) {
const a = out[out.length - 1]
const b = raw[i]
const d = Math.hypot(b.x - a.x, b.y - a.y)
const n = Math.max(1, Math.round(d / step))
for (let s = 1; s <= n; s++)
out.push({ x: a.x + ((b.x - a.x) * s) / n, y: a.y + ((b.y - a.y) * s) / n })
}触摸事件是按时间来的,快速划一下可能只报三个点,慢慢拖会报两百个点。直接沿用的话,同一条笔迹会因为你手快慢而写出完全不同的墨量。按弧长重采样之后,「快划」和「慢拖」一样重。
还有个门槛:起手太短(< 4 个采样点)或者首尾距离小于 8px 就当没画。
3. 只把起笔端收细。
const taper = Math.max(3, Math.min(Math.round(n * 0.06), Math.floor(n / 4)))模拟笔尖按下去的那一下。远端故意不收细——因为访客抬笔时笔还没离开纸面,那里如果收细,既会把接缝捏尖,又会破坏它的方向。方向是下一步的输入。
4. 切线桥接。
const B = out[n - 1]
const prev = out[Math.max(0, n - 8)]
const tl = Math.hypot(B.x - prev.x, B.y - prev.y) || 1
const tx = (B.x - prev.x) / tl
const ty = (B.y - prev.y) / tl
const gap = Math.hypot(to.x - B.x, to.y - B.y)
const reach = Math.max(80, Math.min(280, gap * 0.45))
const c1 = { x: B.x + tx * reach, y: B.y + ty * reach }
const c2 = { x: to.x - tx * gap * 0.35, y: to.y - ty * gap * 0.35 }用访客最后一小段的方向作为贝塞尔控制点的朝向:笔先顺着你的手势继续走一段,再拐进「本来要起笔的地方」。
如果这里画一条直线,读起来就是另一支笔接管了——手感和前面完全不接。这是那种说不出哪里怪、但一眼就不对的细节。
最后一行是整段代码里我最喜欢的一句:
// the join is exact, or it is not a join
out[out.length - 1] = { x: to.x, y: to.y }贝塞尔曲线在 t=1 时数学上确实等于终点,但浮点累加会有极小误差。整件作品建立在「接缝不存在」上,那就不给它留任何余量——直接赋值。
四、用算术证明「线变长了,而不是变了」
访客落笔之后,容易出的问题是:线不再是原来那条线了,只是被拉伸或者重排了。
所以我写了一条断言,用加法把这件事锁死:
check(
tag + 'the line got longer, not different',
Math.abs(p.total - bare.total - p.visitorLen) < 1,
`${Math.round(bare.total)}px + ${Math.round(p.visitorLen)}px of theirs = ${Math.round(p.total)}px`,
)跑出来是这样:
10027px(我的)+ 2223px(你的)= 12249px
严格相等(误差 < 1px)。 这意味着访客的一笔不是被融进去、不是被重绘、不是被近似——它是原样接在开头,后面所有章节整体平移了他那一笔的长度。第一章的起点就是他的终点,这一点也被单独断言:
check(tag + 'reports where chapter I begins',
Math.abs(p.chapters[0].globalStart - p.visitorLen) < 0.01)五、审计脚本:在真实浏览器里断言
几何代码的正确性没法靠看。我写了 scripts/audit.ts,在真实 Chrome 引擎里跑,用 --dump-dom 把结果取回终端。6 种设备尺寸 × 20 多项检查:
- 相邻采样点最大间距 < 12px(有访客笔迹时 < 14px)——这一条就是「没有断层」的定义
- 章节接缝误差 < 0.001px
- 起点在左边缘之外(
x0 < 0) - 整条线不出纸面
- 手机上前 55% 区域的采样点占比 < 45%
- 全路径只有一个
M、不含NaN/Infinity/undefined - 累积弧长单调不减
- 滚到底时第五章只走了 80%,永远写不完
- 笔尖从不后退
最后两条是我觉得最有意思的:
reach: length - chapters[chapters.length - 1].length * 0.2,笔能走到的最远处,是整条线减去最后一章的五分之一。作品永远差一点没写完——这不是修辞,是一个减法。
check(
tag + 'the closing stroke is never finished',
progressWithin(1, 4) > 0.6 && progressWithin(1, 4) < 1,
`chapter V stops at ${(progressWithin(1, 4) * 100).toFixed(0)}% of its slice`,
)这条断言防的是「未来的某次改动不小心让它写完了」。一个作品的核心隐喻如果只写在 README 里,它下个版本就会被改没。
六、五个权重,一条线
线在视觉上是五段不同笔压:访客的细笔 → 按下 → 加重 → 变细 → 干掉。但它们必须是一条连续的线。
做法不是用 mask,而是切成五条 path,共享同一个几何长度:
const cut = [cum[vis], cum[hv], cum[dry], cum[split], cum[cum.length - 1]]
const span = [cut[0], cut[1] - cut[0], cut[2] - cut[1], cut[3] - cut[2], cut[4] - cut[3]]因为它们共享同一条累积弧长,用同一个 dash offset 驱动就会像一条线那样推进,但每一段可以有自己的 stroke-width 和颜色。
注释里写了为什么不用 mask:
A mask would have been fewer nodes and twice the cost.
七、性能是设计出来的,不是优化出来的
真实 Chrome、1440×900、滚完全程:
238 帧,平均 16.74ms,p95 18.1ms,0 帧超过 32ms → 稳定 60fps
DOM 170 个元素,14 条 SVG path,路径数据 3 KB,JS heap 3.4 MB
关键一条:React 不参与滚动。
/**
* The stage is painted imperatively, once per frame, from a single number:
* how far the pen has travelled. React never re-renders on scroll — it only
* re-renders when the composition itself changes.
*/每一帧的写入是「笔尖走了多远」这一个数字:dash offset、笔尖位置、径向高光的坐标(--nib-x / --nib-y)全部由它算出来。没有 setState,没有虚拟 DOM diff。
八、不做「AI 科技风」
这条是设计约束,但它是代码级的决定:
- 不用 WebGL / Three.js。 这条线的表现力来自几何,不是着色器。它是可测量的弧长、可断言的连续性、可被读屏软件描述的一条路径。换成 GPU 粒子只会更难访问、更难降级,而且会把这件作品变成一次技术演示。
- 不做一次性播放动画。 墨量由滚动位置直接决定——你往回滚,它真的会退回去。任何只在首次进入时播放的效果都是装饰。
- 页面不长。 桌面总滚动约 7700px(约 8.5 屏),滚完就能看到完整图形。不是在 12000px 里慢慢失去耐心。
回头看
写完之后最有用的一段代码不是渲染,是 scripts/audit.ts。
几何作品的问题是「看起来对」和「真的是对的」差距极大。一条断掉的线在视觉上只是一段比较直的线;一次不精确的接缝要累积五章才看得出来。把命题翻译成可以fail的断言——最大间距 < 12px、一个 M、加法严格相等——这些性质才真正被守住了。
如果这个作品有一个中心句,那大概就是那句注释:
// the join is exact, or it is not a join