《一根不断的线》是一件交互作品:一个 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 多项检查:

最后两条是我觉得最有意思的:

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 科技风」

这条是设计约束,但它是代码级的决定:


回头看

写完之后最有用的一段代码不是渲染,是 scripts/audit.ts。

几何作品的问题是「看起来对」和「真的是对的」差距极大。一条断掉的线在视觉上只是一段比较直的线;一次不精确的接缝要累积五章才看得出来。把命题翻译成可以fail的断言——最大间距 < 12px、一个 M、加法严格相等——这些性质才真正被守住了。

如果这个作品有一个中心句,那大概就是那句注释:

// the join is exact, or it is not a join
ZERO · 用 GitHub Issues 写的博客