Lulucat

สร้างไปป์ไลน์การเรนเดอร์ของ Lulucat Notes ใหม่ด้วย Kimi K3

Gaoge ZhangGaoge Zhang

ไปป์ไลน์ไทล์ของ Core Graphics ของเรากลายเป็นไปป์ไลน์ point-sprite ของ Metal ในหกขั้นตอนเล็ก ๆ โดยแต่ละขั้นตอนได้รับการตรวจสอบบน iPad จริง โค้ดเขียนขึ้นผ่าน pair programming กับ Kimi K3 บน Fireworks

อัปเดตเมื่อ

สัปดาห์ที่แล้ว การลาก lasso selection ข้าม Lulucat Notes ทำให้เกิดคลื่นที่มองเห็นได้: ภายในเฟรมเดียวกัน ไทล์บนหน้าจอบางส่วนแสดง selection ที่ตำแหน่งใหม่ ขณะที่ไทล์อื่นยังแสดงตำแหน่งเดิม เราแทนที่ไปป์ไลน์การเรนเดอร์ทั้งหมด — บิตแมปของ Core Graphics บวกกับ CATiledLayer — ด้วย Metal ในหกขั้นตอนเล็ก ๆ โดยตรวจสอบแต่ละขั้นบน iPad จริงก่อนเริ่มขั้นถัดไป

โค้ดเขียนขึ้นผ่าน pair programming กับ Kimi K3 ซึ่งเป็น open model ของ Moonshot ที่ทำงานบน Fireworks มนุษย์เป็นผู้ขับเคลื่อน ตัดสินใจ และทดสอบ ส่วนโมเดลเขียนเกือบทุกบรรทัด

ไปป์ไลน์

ไปป์ไลน์เดิมมีการวาดอยู่สองแบบที่ค่อย ๆ แยกออกจากกัน: stroke ถูก bake ลงในบิตแมปเพื่อการแสดงผลต้นทุนต่ำ และถูกวาดใหม่เป็นเวกเตอร์ทุกครั้งที่ไทล์ต้องการรายละเอียดมากขึ้น ไปป์ไลน์ใหม่มีแนวคิดเดียวอย่างแท้จริง: ทุกสิ่งที่มีลักษณะเหมือนหมึกคือ point sprite stroke จากปากกา การกวาด highlighter และ eraser dab ล้วนเป็น vertex 32-byte เดียวกัน — position, diameter, color — ซึ่งวาดด้วย shader คู่เดียวกันเป็นวงกลมที่ rasterize บน GPU และวางห่างกันหนึ่ง point ตาม arc length ของ stroke

สามแผงของ stroke โค้งเดียวกัน: input touch points, แสตมป์วงกลมที่เว้นระยะตาม arc length ไปตามเส้นโค้ง และ stroke ทึบที่ composite แล้ว

เมื่อเว้นระยะหนึ่ง point สายโซ่ของวงกลมจะเบี่ยงเบนจากแคปซูลที่สมบูรณ์แบบในทางคณิตศาสตร์ประมาณ 0.075 points — หนึ่งในห้าพิกเซลที่ความหนาแน่นของแคนวาสเรา แลกกับการที่เครื่องมือทั้งสามรวมเป็น code path เดียว และ GPU ได้ทำสิ่งที่มันถนัดที่สุด

สถาปัตยกรรมรอบแนวคิดนี้เรียบง่าย หมึกที่ commit แล้วอยู่ใน texture เดียวขนาด 4096² UIScrollView ยังคงอยู่ แต่ถูกลดบทบาทเป็น gesture engine ล้วน ๆ: contentOffset และ zoomScale ของมันป้อนค่าให้ 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

การแก้ไขเขียนลง texture โดยตรง การ commit stroke คือการ append stamps ของมัน การ erase, delete, move และ undo จะ replay บริเวณที่ได้รับผลกระทบภายใต้ scissor rectangle: เคลียร์บริเวณนั้น วาด stroke ที่ตัดผ่านบริเวณนั้นใหม่ แล้วจบ การลบบางส่วนยังคงรักษา ownership semantics จากบทความก่อนหน้า — erasure เป็นของ stroke ที่มันเอาหมึกออก — ด้วยการวาด stroke ที่ถูกลบแต่ละเส้นลงใน scratch texture ลบ erasure paths ของตัวเองออกด้วย destination-out blending () แล้ว composite ผลลัพธ์กลับมา การแยกด้วย scratch texture ทำให้ eraser ไม่กัดทะลุกระดาษหรือ stroke ข้างเคียง

ตอนนี้ selection ที่เป็นจุดเริ่มต้นของเรื่องทั้งหมดก็วาดเป็น point sprites เช่นกัน การลาก selection อัปเดตเพียง uniform offset เดียว ไม่มีการเขียน texture ไม่มีการ invalidate ไทล์ — คลื่นหายไปในระดับโครงสร้าง ไม่ใช่แค่ได้รับการบรรเทา

ผู้คุมด่าน: pixel diff

เราไม่ได้ลบ Core Graphics renderer แต่ลดบทบาทให้เป็น offline reference implementation และการเปลี่ยนแปลง Metal ทุกครั้งต้องผ่าน pixel comparison กับมันบนข้อมูล stroke จริงที่จับจากอุปกรณ์ เกณฑ์การยอมรับไม่ใช่ “identical pixels” — rasterizer ที่ถูกต้องสองตัวอาจแตกต่างกันอย่างสมเหตุสมผลไม่กี่ gray levels ตามขอบ anti-aliased เกณฑ์นี้ดูที่โครงสร้าง: ไม่มีหมึกหาย ไม่มี offset ไม่มีสีเพี้ยน และไม่มีความแตกต่างขนาดใหญ่ที่ใดก็ตามซึ่งอยู่ห่างจากหมึก

ภาพครอบตัดสามภาพของโน้ตที่เขียนด้วยมือชุดเดียวกัน: เรนเดอร์ด้วย Core Graphics เรนเดอร์ด้วย Metal point sprites และ pixel difference ของทั้งสองที่ขยายหกเท่า ซึ่งแสดงเพียงโครงร่างจาง ๆ ตามขอบ stroke

harness นี้จับบั๊กได้สี่จากห้าตัวที่เราเจอขณะสร้าง offline renderer โดยทั้งหมดวินิจฉัยด้วยการครอบ output ถึงระดับพิกเซล: Swift/Metal struct stride ไม่ตรงกัน (28 bytes เทียบกับ 32 เพราะ Metal จัด alignment ของ float4 ไว้ที่ 16 — หน้าจอเต็มไปด้วยบล็อกสี), [[point_size]] ไม่สามารถอ่านเป็น varying ใน fragment shader ได้ (แสตมป์ทุกอันออกมาเป็นสี่เหลี่ยม), render encoder สองตัวอยู่ร่วมกันบน command buffer เดียว (ทุกอย่างเป็นสีดำ) และ start dab ที่หายไปทำให้มิลลิเมตรแรกของ stroke ที่วาดเร็ว ๆ มองไม่เห็น

หกขั้น ไม่ใช่การเขียนใหม่ครั้งเดียว

แผนการย้ายระบบมีหกขั้นตอนที่สามารถ ship แยกกันได้: offline renderer ที่ผ่าน pixel diff; display shell ที่ไม่มีการเปลี่ยนแปลงทางภาพ; live stroke บน GPU; selection บน GPU; mutation ที่เขียน texture โดยตรง; และ vector re-drawing ที่ zoom สูง (รวมไว้ในขั้นตอนที่สอง เพราะ “zero visual change” ต้องการเช่นนั้น) แต่ละขั้นจบลงด้วยการที่มนุษย์เขียน ลบ ซูม และลากบน iPad Pro ที่วางอยู่บนโต๊ะ ไม่ใช่ simulator ไม่ใช่ screenshot diff

อุปกรณ์จับบั๊กได้สามตัวที่ automated check ทุกตัวพลาด เมื่อซูมเกิน 100% stroke ถูกวาดซ้ำสองครั้ง — texture นุ่มอยู่ข้างใต้ sprite คมอยู่ด้านบน — จนเห็นเป็นความเบลอจาง ๆ ที่มนุษย์สังเกตได้ในไม่กี่วินาที การ commit stroke ทำให้กระพริบหนึ่งเฟรม เพราะ overlay เดิม cross-fade ออกไม่ตรงกับการอัปเดต texture และเมื่อซูมเกิน 170% โน้ตทุกตัวก็หายไป: visibility-culling rectangle ใช้ contentOffset ใน scaled coordinate space ของตัวเอง จึงค่อย ๆ ลอยห่างจาก stroke เมื่อซูม ทั้งสามเป็นการแก้ตั้งแต่หนึ่งบรรทัดถึงหนึ่งฟังก์ชัน และไม่มีสักอย่างอยู่ใน test ที่เราอาจเขียนไว้ล่วงหน้าได้ เพราะเราไม่รู้ว่าจะต้องมองหาอะไร สำหรับ UI-first consumer app นี่คือเหตุผลที่มนุษย์ยังต้องอยู่ใน loop

ทำงานกับ Kimi K3 เป็นอย่างไร

เร็ว ก่อนอื่นเลย ลูป “discuss, write, build, install, look” ทำงานจบภายในไม่กี่นาที และโมเดลที่ตอบเร็วจะเปลี่ยนจำนวนลูปที่คุณสามารถรับไหวในหนึ่งวัน

ประการที่สอง มันไม่ over-engineer โค้ดเบสนี้ทำงานตาม house rules ที่ชัดเจน — ไม่มี backward-compatibility scaffolding ก่อนเปิดตัว และเพิ่มความซับซ้อนก็ต่อเมื่ออุปกรณ์พิสูจน์ว่าจำเป็น — K3 ทำตามกฎเหล่านี้โดยไม่ต้องเตือน มันไม่เพิ่ม spatial indexes “ไว้ทีหลัง” ไม่ห่อทุก call ด้วย defensive checks และไม่ abstract แบบคาดเดา การ prompt มันให้ความรู้สึกเหมือนทำงานกับเพื่อนร่วมงานที่มีความสามารถ ซึ่งอ่าน house rules แล้วเชื่อในกฎเหล่านั้นจริง ๆ

ประการที่สาม ให้เครื่องมือมัน แล้วมันจะใช้ทันที เราเชื่อมต่อ image utilities — view, crop ไปยัง pixel region, resize — และโมเดลก็เริ่ม crop output ของ renderer ตัวเองเชิงรุกเพื่อวินิจฉัยห้า harness bugs ข้างต้น การมีเครื่องมือเตือนให้มันมอง

อีกด้านหนึ่งคือ K3 เขียนบั๊กส่วนใหญ่ในเรื่องนี้ รวมถึงบั๊ก coordinate-space ที่ทำให้โน้ตหายไป ข้อจำกัดของมันมีอยู่จริง สิ่งที่ทำให้งานปลอดภัยไม่ใช่การที่โมเดลถูกต้องเสมอ แต่เป็น harness ที่จับ rendering drift และมนุษย์ที่จับ feel ถึงอย่างนั้น ในการใช้งานแต่ละวัน ผมก็ไม่สามารถแยกมันออกอย่างน่าเชื่อถือจาก frontier closed models ที่เราใช้อยู่เช่นกัน — ระบบระดับ Opus-class ในบางด้านมันดีกว่าอย่างชัดเจน: เร็วกว่า และมีแนวโน้มน้อยกว่ามากที่จะเติม codebase ด้วย defensive design

จากนี้เราอยากสร้างอย่างไร

เราเลิกทำ big-spec agentic development แล้ว — รูปแบบที่ยื่น specification ขนาดใหญ่ให้โมเดลแล้วรับทุกสิ่งที่ได้กลับมา โหมดล้มเหลวไม่ใช่โค้ดที่แย่ แต่คือโค้ดที่ไม่มีใครเข้าใจ

สิ่งที่ได้ผลที่นี่และเราจะเก็บไว้คือขั้นตอนเล็ก ๆ แต่ละขั้นถูกคุยกันก่อนเริ่ม แต่ละขั้นมนุษย์เข้าใจก่อนสร้าง และแต่ละขั้นตรวจสอบบนอุปกรณ์ที่จะใช้งานจริง หน้าที่ของโมเดลคือรวดเร็ว แม่นยำ และซื่อสัตย์ต่อความไม่แน่นอน หน้าที่ของมนุษย์คือวิจารณญาณ รสนิยม และการตรวจสอบ e2e — โดยเฉพาะสำหรับซอฟต์แวร์ผู้บริโภคที่ UI เป็นหลัก ซึ่งสเปกไม่สามารถบอกได้ว่า “ถูกต้อง” แล้วรู้สึกอย่างไร Kimi K3 บน Fireworks จึงเหมาะกับลูปนี้พอดี: เร็วพอที่จะทำให้ลูปกระชับ และฉลาดพอที่จะทำให้แต่ละขั้นเล็กและสะอาด

คลื่นหายไปแล้ว ไปป์ไลน์มีแนวคิดเดียวแทนที่จะเป็นสอง และกระบวนการที่พาเรามาถึงจุดนี้จะยังคงอยู่