Lulucat

用 Kimi K3 重建 Lulucat Notes 的渲染管線

張高歌張高歌

我們的 Core Graphics 圖塊管線在六個小步驟中變成了 Metal 點精靈管線,每一步都在真實的 iPad 上驗證過。程式碼是在 Fireworks 上與 Kimi K3 結對開發完成的。

更新於

上週,在 Lulucat Notes 中拖曳套索選取範圍時,畫面出現一道肉眼可見的波浪:同一幀裡,有些螢幕圖塊顯示選取範圍的新位置,其他圖塊卻還顯示舊位置。我們用 Metal 取代了整條渲染管線——Core Graphics 點陣圖加上 CATiledLayer——分成六個小步驟完成;每一步都在真實的 iPad 上驗證後,才開始下一步。

程式碼是與在 Fireworks 上執行的 Moonshot 開源模型 Kimi K3 結對開發完成的。人負責主導、決策和測試;模型寫了幾乎每一行程式碼。

管線

舊管線有兩種逐漸分離的繪圖方式:為了低成本顯示,筆畫會被烘進點陣圖;每當圖塊需要更多細節時,又會重新以向量繪製。新管線只有一個想法:所有像墨跡的東西都是點精靈。筆畫、螢光筆掃痕和橡皮擦點塗都是同一個 32-byte 頂點——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 為界,重播受影響的區域:清除該區域,重繪與它相交的筆畫,完成。局部擦除保留了上一篇文章的擁有權語意——一次擦除歸屬於它移除墨跡的那道筆畫——方法是把每一道被擦除的筆畫畫進 scratch texture,用 destination-out blending()扣除它自己的擦除路徑,再把結果合成回去。scratch isolation 阻止橡皮擦咬穿紙張或鄰近筆畫。

引發這一切的選取範圍現在也用點精靈繪製。拖曳它只更新一個 uniform 偏移量。它不會寫入紋理,也不會讓圖塊失效——波浪從結構上消失了,而不是被緩解了。

守門人:一個 pixel diff

我們沒有刪除 Core Graphics 渲染器,而是把它降級為離線參考實作。每次變更 Metal,都必須使用裝置上擷取的真實筆畫資料,與它進行像素比較,並通過這項比較。驗收標準不是「像素完全相同」——兩個正確的光柵化器沿著反鋸齒邊緣相差幾個灰階,是合理的差異。這道門檻關注的是結構:沒有缺失墨跡、沒有偏移、沒有色彩飄移,遠離墨跡的任何地方也不能有大差異。

同一份手寫筆記的三個裁切:分別由 Core Graphics 渲染、由 Metal 點精靈渲染,以及兩者放大六倍的像素差分,只顯示筆畫邊緣的淡淡輪廓。

這套 harness 在我們打造離線渲染器時遇到的五個 bug 中,抓到了四個;全部都是把輸出裁切到像素層級後診斷出來的:Swift/Metal struct 的 stride 不一致(28 bytes 對 32,因為 Metal 會把 float4 對齊到 16——整個螢幕被色塊填滿);[[point_size]] 在 fragment shader 中無法作為 varying 讀取(每個印章都變成方形);同一個 command buffer 上同時存在兩個 render encoder(全部變黑);以及缺少 start dab,讓快速筆畫開頭的一毫米看不見。

六個步驟,而不是一次大改寫

遷移計畫分為六個可獨立出貨的步驟:通過 pixel diff 的離線渲染器;零視覺變化的顯示外層;GPU 上的即時筆畫;GPU 上的選取範圍;直接寫入紋理的變更操作;高倍率縮放下的向量重繪(併入第二步,因為「零視覺變化」要求如此)。每一步都以人在桌上的 iPad Pro 上書寫、擦除、縮放和拖曳結束——不是模擬器,也不是螢幕截圖差異。

裝置抓到三個所有自動化檢查都漏掉的 bug。超過 100% 縮放時,筆畫被畫了兩次——下方是柔和紋理,上方是銳利點精靈——看起來像一層淡淡的模糊,人幾秒內就注意到了。提交筆畫時會閃爍一幀,因為舊的 overlay 與紋理更新不同步地交叉淡出。超過 170% 縮放時,所有筆記都消失了:可見性剔除矩形在自己的縮放座標空間裡使用 contentOffset,所以你縮放時它會逐漸飄離筆畫。三個問題的修復規模從一行程式碼到一個函式不等,而且沒有任何一個存在於我們事前能寫出的測試裡,因為我們不知道要去找它們。對 UI 優先的消費級 app 來說,這就是人要留在迴圈裡的原因。

與 Kimi K3 合作是什麼感覺

首先是快。「討論、寫、建置、安裝、查看」這個迴圈幾分鐘就跑完一輪,而一個回答得快的模型,會改變你一天能負擔多少輪迴圈。

第二,它不會過度工程化。這個程式碼庫遵循一套明確的內部規則:上線前不建立向後相容的鷹架,只有在裝置證明必要時才加入複雜度。K3 不必提醒就會遵守這些規則。它沒有「為了以後」加入 spatial indexes,沒有把每次呼叫都包在防禦性檢查裡,也沒有推測性地抽象化。與它協作,感覺像在和一個讀過內部規則、而且真心相信這些規則的稱職同事共事。

第三,給它工具,它就會熱切地使用。我們接上了影像工具——檢視、裁切到某個像素區域、縮放——而模型就開始主動裁切自己的渲染器輸出,來診斷上面那五個 harness bug。這些工具也提醒模型去查看輸出。

另一半是:K3 寫出了這個故事裡大部分的 bug,包括那個讓筆記消失的座標空間 bug。它的限制是真的。保障這項工作安全的,從來不是模型總是正確,而是 harness 捕捉到渲染飄移,人捕捉到了手感。然而,在日常使用上,我無法可靠地把它和我們也在用的前沿閉源模型——Opus 等級的系統——區分開來。在某些面向上,它明顯更好:更快,而且遠不那麼傾向於用防禦性設計去給程式碼庫塞入多餘複雜度。

往後我們想怎麼打造軟體

我們不再做大規格的代理式開發了——也就是那種把一大份規格交給模型,然後接受它產出的所有東西的做法。它的失敗模式不是程式碼不好,而是沒人理解的程式碼。

這裡行得通、而我們會保留的是小步驟:每一個都在開始前討論,在建造前由人理解,並在實際運行的裝置上驗證。模型的工作是快速、精準,並且對不確定性誠實。人的工作是判斷、品味和端到端驗證——尤其是對 UI 優先的消費級軟體來說,規格無法描述「正確」是什麼手感。Kimi K3 在 Fireworks 上,恰好很適合這個迴圈:快到能把迴圈維持得很緊,又聰明到能把每一步保持小而乾淨。

那道波浪消失了,管線從兩個想法合為一個,而把我們帶到這裡的流程也會保留下來。