בנייה מחדש של צינור הרינדור של Lulucat Notes בעזרת Kimi K3
צינור האריחים של Core Graphics הפך לצינור point-sprite של Metal בשישה צעדים קטנים, שכל אחד מהם אומת על iPad אמיתי. הקוד נכתב בתכנות בזוג עם Kimi K3 ב-Fireworks.
בשבוע שעבר, גרירת בחירת לאסו ב-Lulucat Notes יצרה גל נראה לעין: חלק מאריחי המסך הציגו את הבחירה במיקומה החדש, בעוד אחרים עדיין הציגו את הישן באותה מסגרת. החלפנו את כל צינור הרינדור — מפת סיביות של Core Graphics בתוספת CATiledLayer — ב-Metal בשישה צעדים קטנים, שכל אחד מהם אומת על iPad אמיתי לפני שהתחלנו את הבא.
הקוד נכתב בתכנות בזוג עם Kimi K3, המודל הפתוח של Moonshot, שרץ על Fireworks. האדם הוביל, קיבל החלטות ובדק; המודל כתב כמעט כל שורה.
הצינור
לצינור הישן היו שני סוגי ציור שהתרחקו זה מזה בהדרגה: משיכות הוטמעו במפת סיביות לתצוגה זולה, ונצבעו מחדש כווקטורים בכל פעם שהאריחים נזקקו לפרטים נוספים. לצינור החדש יש רעיון אחד בדיוק: כל מה שנראה כמו דיו הוא point sprite. משיכת עט, משיכת מרקר ונגיעת מחק הם אותו 32-byte vertex — position, diameter, color — שמצויר על ידי אותו זוג shaders כמעגלים שרוסטרו ב-GPU, במרווח של נקודה אחת לאורך אורך הקשת של המשיכה.
![]()
במרווח של נקודה אחת, שרשרת מעגלים חורגת מקפסולה מתמטית מושלמת בכ-0.075 נקודות — חמישית פיקסל בצפיפות הקנבס שלנו. בתמורה, שלושה כלים מתמזגים לנתיב קוד אחד, וה-GPU עושה את מה שהוא יודע לעשות הכי טוב.
סביב הרעיון הזה הארכיטקטורה פשוטה. הדיו שאושר חי בטקסטורה יחידה בגודל 4096². ה-UIScrollView נשאר, אך הורד לדרגת מנוע מחוות טהור: ה-contentOffset וה-zoomScale שלו מזינים uniform של viewport בכל פריים, כך שפנורמה וזום אינם כותבים דבר. כל פריים כולל חמש פעולות ציור:
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: מנקים את האזור, מציירים מחדש את המשיכות שחוצות אותו, וזהו. המחיקה החלקית שומרת על סמנטיקת הבעלות מהפוסט הקודם — מחיקה שייכת למשיכה שממנה היא מסירה דיו — על ידי ציור כל משיכה שנמחקה לתוך scratch texture, חיסור נתיבי המחיקה שלה עצמה באמצעות destination-out blending (
הבחירה שהתחילה את כל זה מצוירת כעת גם כ-point sprites. גרירתה מעדכנת offset יחיד של uniform. אפס כתיבות לטקסטורה, אפס invalidations של אריחים — הגל נעלם מבחינה מבנית, לא רק הוקל.
שומר הסף: הפרש פיקסלים
לא מחקנו את המרנדר של Core Graphics. הורדנו אותו למימוש ייחוס לא-מקוון, וכל שינוי ב-Metal חייב לעבור השוואת פיקסלים מולו על נתוני משיכות אמיתיים שנלכדו במכשיר. קריטריון הקבלה אינו “פיקסלים זהים” — שני rasterizers נכונים עשויים להיות שונים בצדק בכמה רמות אפור לאורך קצוות anti-aliased. הסף מבני: אין דיו חסר, אין היסט, אין סטיית צבע, ואין הבדל גדול בשום מקום מחוץ לדיו.
![]()
מערכת הבדיקה הזו תפסה ארבעה מתוך חמשת הבאגים שנתקלנו בהם בעת בניית המרנדר הלא-מקוון, כולם אובחנו באמצעות חיתוך הפלט לרמת הפיקסל: אי-התאמת stride של struct בין Swift ל-Metal (28 בתים מול 32, כי Metal מיישר float4 ל-16 — המסך התמלא בבלוקי צבע), [[point_size]] שאינו קריא כ-varying ב-fragment shader (כל חותמת יצאה מרובעת), שני render encoders המתקיימים יחד על command buffer אחד (הכול שחור), וטפיחה התחלתית חסרה שהותירה את המילימטר הראשון של משיכות מהירות בלתי-נראה.
שישה צעדים, לא כתיבה-מחדש אחת
תוכנית המעבר כללה שישה צעדים שניתן לספק בנפרד: מרנדר לא-מקוון שעובר את השוואת הפיקסלים; מעטפת תצוגה ללא שום שינוי חזותי; משיכה חיה ב-GPU; בחירה ב-GPU; מוטציות שכותבות ישירות לטקסטורה; וציור מחדש וקטורי בזום גבוה (שולב בצעד השני, כי “אפס שינוי חזותי” דרש זאת). כל צעד הסתיים בכך שאדם — לא סימולטור ולא השוואת צילומי מסך — כתב, מחק, ביצע זום וגרר על iPad Pro שהיה על השולחן.
המכשיר תפס שלושה באגים שכל הבדיקות האוטומטיות פספסו. מעל 100% זום, משיכות צוירו פעמיים — טקסטורה רכה מתחת, sprites חדים מעל — וזה נראה כטשטוש קל שהאדם הבחין בו תוך שניות. קיבוע משיכה הבהב למשך פריים אחד, כי שכבת-העל הישנה ביצעה cross-fade לא מסונכרן עם עדכון הטקסטורה. ומעל 170% זום, כל ההערות נעלמו: מלבן סינון הנראות השתמש ב-contentOffset במרחב הקואורדינטות המותאם לקנה המידה שלו, ולכן נסחף הרחק מהמשיכות ככל שהתקרבתם בזום. כל שלושת הבאגים תוקנו בשורה אחת בפונקציה אחת, ואף אחד מהם לא הופיע בשום בדיקה שיכולנו לכתוב מראש, כי לא ידענו לחפש אותם. באפליקציה צרכנית שמתוכננת קודם כול סביב ממשק המשתמש, זו הסיבה שהאדם נשאר בלולאה.
איך זה לעבוד עם Kimi K3
מהיר, קודם כול. הלולאה של “לדון, לכתוב, לבנות, להתקין, להסתכל” רצה בתוך דקות, ומודל שעונה מהר משנה כמה לולאות אפשר להרשות לעצמנו ביום.
שנית, הוא לא מסבך את ההנדסה מעבר לנדרש. בסיס הקוד הזה פועל לפי כללי בית מפורשים — אין תשתית תאימות לאחור לפני ההשקה, ומורכבות מתווספת רק כשמכשיר מוכיח שהיא נחוצה — ו-K3 פועל לפיהם בלי שצריך להזכיר לו. הוא לא הוסיף אינדקסים מרחביים “למועד מאוחר יותר”, לא עטף כל קריאה בבדיקות הגנתיות ולא ביצע הפשטה ספקולטיבית. לתת לו הנחיות מרגיש כמו לעבוד עם עמית מוכשר שקרא את כללי הבית ובאמת מאמין בהם.
שלישית, תנו לו כלים והוא משתמש בהם בהתלהבות. חיברנו כלי תמונה — תצוגה, חיתוך לאזור פיקסלים, שינוי גודל — והמודל התחיל לחתוך באופן יזום את פלט המרנדר שלו כדי לאבחן את חמשת הבאגים של מערכת הבדיקה שהוזכרו למעלה. קיומו של הכלי הזכיר לו להסתכל.
הצד השני: K3 כתב את רוב הבאגים בסיפור הזה, כולל באג מרחב הקואורדינטות שגרם להערות להיעלם. גבולותיו אמיתיים. מה שהפך את העבודה לבטוחה לא היה שהמודל תמיד צדק; זו הייתה מערכת הבדיקה שתפסה סטיית רינדור והאדם שתפס את התחושה. ובכל זאת, ביום-יום, לא יכולתי להבדיל בינו לבין המודלים הסגורים המובילים שגם בהם אנחנו משתמשים — מערכות ברמת Opus. בכמה היבטים הוא היה בבירור טוב יותר: מהיר יותר, ובעל נטייה נמוכה בהרבה לנפח את בסיס הקוד בתכנון הגנתי.
איך אנחנו רוצים לבנות מעתה
סיימנו עם פיתוח אג’נטי המבוסס על מפרטים גדולים — הסגנון שבו מוסרים למודל מפרט גדול ומקבלים כל מה שנוצר. נקודת הכשל אינה קוד גרוע; זהו קוד שאיש אינו מבין.
מה שעבד כאן, ומה שנשמור: צעדים קטנים, שכל אחד מהם נדון לפני שהוא מתחיל, מובן על ידי האדם לפני שהוא נבנה ומאומת על המכשיר שעליו יחיה. תפקיד המודל הוא להיות מהיר, מדויק וכן לגבי אי-ודאות. תפקיד האדם הוא שיפוט, טעם ואימות e2e — במיוחד בתוכנת צריכה שמתוכננת קודם כול סביב ממשק המשתמש, שבה המפרט אינו יכול לתאר איך מרגיש משהו כשהוא “נכון”. Kimi K3 ב-Fireworks מתגלה כמתאים בדיוק ללולאה הזו: מהיר מספיק כדי לשמור על הלולאה הדוקה, חכם מספיק כדי לשמור על הצעדים קטנים ונקיים.
הגל נעלם, הצינור הוא רעיון אחד במקום שניים, והתהליך שהביא אותנו לכאן נשאר.