สร้างไปป์ไลน์การเรนเดอร์ของ Lulucat Notes ใหม่ด้วย Kimi K3
ไปป์ไลน์ไทล์ของ 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
![]()
เมื่อเว้นระยะหนึ่ง 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 (
ตอนนี้ 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 ไม่มีสีเพี้ยน และไม่มีความแตกต่างขนาดใหญ่ที่ใดก็ตามซึ่งอยู่ห่างจากหมึก
![]()
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 จึงเหมาะกับลูปนี้พอดี: เร็วพอที่จะทำให้ลูปกระชับ และฉลาดพอที่จะทำให้แต่ละขั้นเล็กและสะอาด
คลื่นหายไปแล้ว ไปป์ไลน์มีแนวคิดเดียวแทนที่จะเป็นสอง และกระบวนการที่พาเรามาถึงจุดนี้จะยังคงอยู่