用 Kimi K3 重建 Lulucat Notes 的渲染流水线

张高歌张高歌

我们的 Core Graphics 图块流水线在六个小步骤中变成了 Metal 点精灵流水线,每一步都在真实 iPad 上验证过。代码是在 Fireworks 上与 Kimi K3 结对编程完成的。

更新于

上周,在 Lulucat Notes 中拖动套索选区时,画面出现了一道可见的波浪:同一帧里,一些屏幕图块显示选区的新位置,另一些仍显示旧位置。我们用 Metal 替换了整条渲染流水线——Core Graphics 位图加上 CATiledLayer——分六个小步骤完成;每一步都在真实 iPad 上验证通过后,才开始下一步。

代码是与运行在 Fireworks 上的 Moonshot 开源模型 Kimi K3 结对编程完成的。人负责驱动、决策和测试,模型写了几乎每一行代码。

流水线

旧流水线有两种逐渐分离的绘制方式:为了低成本显示,笔画会被烘进位图;图块需要更多细节时,又会把笔画作为矢量重新绘制。新流水线只有一个想法:所有类似墨迹的东西都是点精灵。钢笔笔画、荧光笔扫痕和橡皮擦点涂都是同一个 32 字节的顶点——position、diameter、color——由同一对着色器绘制成 GPU 光栅化的圆形,这些圆形沿笔画弧长每隔一个 point 排列。

同一条弯曲笔画的三个画面:输入触摸点、沿曲线按弧长间距排列的圆形印记,以及合成后的实心笔画。

在每隔一个 point 放置一个圆形的情况下,一串圆形与数学上完美的胶囊体约有 0.075 points 的偏差——在我们的画布密度下相当于五分之一像素。作为交换,三种工具合并成一条代码路径,GPU 做它最擅长的事。

围绕这个想法,架构很简单。已提交的墨迹存放在一张 4096² 的纹理中。UIScrollView 仍然保留,但降级为纯粹的手势引擎:它的 contentOffsetzoomScale 每帧都会送入一个 viewport uniform,因此平移和缩放不会写入任何东西。每一帧是五次绘制:

flowchart TB
    subgraph frame["Every frame: five draws"]
        direction TB
        paper["1 · paper blit"] --> ink["2 · committed ink"]
        ink --> live["3 · live stroke"]
        live --> sel["4 · selection"]
        sel --> dash["5 · lasso dashes"]
    end
    commit["stroke commit<br/>append stamps"] --> tex[("ink texture<br/>4096² render target")]
    replay["regional replay<br/>erase · delete · move · undo"] --> tex
    tex -. "sampled or re-drawn" .-> ink

编辑操作直接写入纹理。提交一条笔画就是追加它的印记。擦除、删除、移动和撤销会以 scissor rectangle 为边界重放受影响的区域:清空该区域,重新绘制与其相交的笔画,完成。局部擦除保留了 上一篇 中的归属语义——一次擦除归属于它移除墨迹的那条笔画——做法是把每条被擦除的笔画绘制到一张暂存纹理,用 destination-out 混合()减去它自身的擦除路径,再把结果合成回去。暂存隔离使橡皮擦不会咬穿纸张或邻近笔画。

引发这一切的选区现在也用点精灵绘制。拖动它只更新一个 uniform 偏移量。它不会写入纹理,也不会使图块失效——波浪从结构上消失了,而不是被缓解了。

守门人:像素差异

我们没有删除 Core Graphics 渲染器,而是把它降级为离线参考实现。每次修改 Metal,都必须使用设备上捕获的真实笔画数据,与它进行像素比较并通过。验收标准不是“像素完全一致”——两个正确的光栅化器在抗锯齿边缘处相差几个灰度级是合理的。这道门槛关注的是结构:没有缺失墨迹、没有偏移、没有颜色漂移,远离墨迹的任何地方也不能有大的差异。

同一份手写笔记的三处裁剪:分别由 Core Graphics 渲染、由 Metal 点精灵渲染,以及两者放大六倍的像素差异,只显示笔画边缘淡淡的轮廓。

这套 harness 捕获了我们在构建离线渲染器时遇到的五个 bug 中的四个;所有问题都是把输出裁剪到像素级后诊断出来的:Swift/Metal 结构体的 stride 不匹配(28 bytes 对 32 bytes,因为 Metal 会把 float4 按 16 对齐——屏幕被色块填满);[[point_size]] 无法在 fragment shader 中作为 varying 读取(每个印记都变成了方形);同一个 command buffer 上同时存在两个 render encoder(所有内容都是黑的);以及缺少 start dab,导致快速笔画开头的一毫米不可见。

六个步骤,而不是一次重写

迁移计划由六个可独立交付的步骤组成:通过像素差异比较的离线渲染器;零视觉变化的显示外壳;GPU 上的实时笔画;GPU 上的选区;直接写入纹理的变更操作;高倍缩放下的矢量重绘(并入第二步,因为“零视觉变化”要求如此)。每一步都以人在桌上的 iPad Pro 上书写、擦除、缩放和拖动结束——不是模拟器,也不是截图差异。

设备捕获了所有自动化检查都漏掉的三个 bug。超过 100% 缩放时,笔画被画了两次——底下是柔和的纹理,上面是锐利的点精灵——看上去像一层淡淡的模糊,人几秒内就注意到了。提交笔画时会闪烁一帧,因为旧的 overlay 与纹理更新不同步地交叉淡出。超过 170% 缩放时,所有笔记都消失了:可见性剔除矩形在它的缩放坐标系中使用 contentOffset,所以缩放时会逐渐偏离笔画。这三个问题的修复规模从一行代码到一个函数不等,而且没有任何一个存在于我们事先能写出的测试中,因为我们不知道要去找它们。对于 UI 优先的消费级应用,人必须留在闭环中,原因就在这里。

用 Kimi K3 工作是什么感受

首先是快。“讨论、编写、构建、安装、查看”这个循环只需几分钟就能跑完,而一个响应迅速的模型会改变你一天能负担多少轮这样的循环。

其次,它不会过度工程化。这套代码库遵循明确的内部规则:发布前不搭建向后兼容的脚手架,只有设备证明有必要时才增加复杂性。K3 无需提醒就会遵守这些规则。它没有“留到以后”去加空间索引,没有把每次调用都包进防御性检查,也没有投机性抽象。给它写提示词,就像在和一位读过内部规则、并且真正认同这些规则的能干同事合作。

第三,给它工具,它就会积极使用。我们接入了图像工具——查看、裁剪到像素区域、缩放——模型便主动裁剪自己的渲染器输出,以诊断上面的五个 harness bug。这些工具也提醒模型去查看输出。

另一面是:这个故事里大多数 bug 都是 K3 写出来的,包括那个让笔记消失的坐标空间 bug。它的局限是真实的。保障这项工作安全的,从来不是模型总是正确,而是 harness 捕获了渲染漂移,人捕捉到了手感。不过,在日常使用中,我很难可靠地将它与我们也使用的前沿闭源模型——Opus 级系统——区分开。在某些方面它明显更好:更快,也远不容易用防御性设计给代码库塞入额外复杂度。

我们今后想怎么构建

我们已经不再做大规格驱动的智能体开发——也就是那种把一份大规格交给模型,然后接受它产出的一切的做法。它的失败模式不是糟糕的代码,而是没人能理解的代码。

这里行之有效、我们会保留下来的是小步骤:每一步都在开始前讨论,在构建前由人理解,并在实际运行的设备上验证。模型的职责是快速、精确,并对不确定性坦诚。人的职责是判断、品味和端到端验证——尤其对于 UI 优先的消费级软件,规格说明无法描述“正确”的手感。事实证明,运行在 Fireworks 上的 Kimi K3 恰好适合这个闭环:快到能让闭环保持紧凑,又聪明到能让每一步都小而干净。

那道波浪消失了,流水线从两个想法合为一个,而带我们走到这里的过程也会保留下来。