Lulucat

ביצועי כלי הגיר: ממעברים על כל המסך למלבני scissor

Gaoge ZhangGaoge Zhang

כלי הגיר של Lulucat Notes האט באזורים עם כתיבה צפופה. צוואר הבקבוק לא היה 3,571 דגימות הקלט, אלא 70 מעברי scratch על כל המסך בכל פריים. מטמון ברזולוציה נמוכה שנדחה ומלבן scissor לכל משיכה מספרים את המשך הסיפור.

תצוגה חתוכה של Lulucat Notes ב-iPad בזום של 255%, המציגה כתב יד בגיר אדום וכחול של קטע קליגרפיה סינית קלאסית, כשחלקים מסרגל הכלים של האפליקציה גלויים.

גיר אדום וכחול בבניית המכשיר הסופית, 155 משיכות בזום של 255%.

לכלי הגיר ב-Lulucat Notes הייתה בעיית ביצועים מסוג מסוים: כתיבה באזור ריק הרגישה חלקה, אבל כשנכנסו לאזור שכבר היה מלא במשיכות גיר, קצה העט התחיל לפגר. המשך הכתיבה באותו אזור האט בהדרגה גם את הזזת הקנבס.

עמוד אחד של כתב יד רגיל הספיק כדי להפעיל את הבעיה: זום של 300%, 70 משיכות גיר גלויות באזור המקומי, ובסך הכול 3,571 נקודות דגימת קלט. אזורים ריקים נשארו חלקים; רק האזור שבו המשיכות התרכזו נעשה איטי.

אחרי התיקון, אותו עמוד יכול להמשיך לקבל כתיבה חדשה בזום של 255%, והמשיכות הקיימות שומרות על מלוא החדות שלהן גם בזמן הנחת העט וגם בזמן הזזת הקנבס.

Lulucat Notes ב-iPad בזום של 255%, מציג כתב יד בגיר אדום וכחול. הטקסט אומר "天行健,君子以自强不息;地势坤,君子以厚德载物" — קטע סיני קלאסי. קמע כחול של Lulucat נמצא בפינה הימנית העליונה. סרגל הכלים התחתון מציג ספירה של 155 משיכות, Save, Clear ומחוון זום של 255%.

צילום המסך הסופי מהמכשיר, 155 משיכות בסך הכול. ברמת הזום הזו, החדות של המשיכות הקיימות אינה מתחלפת זמנית לא בזמן הנחת העט ולא בזמן הזזת הקנבס.

למה גיר צריך טקסטורת scratch

עט רגיל יכול לבצע קומפוזיציה של כל חותמת עגולה ישירות על טקסטורת הדיו באמצעות ערבוב source-over. הגיר מוסיף שכבת grain-gating: המרנדר צובר תחילה את כיסוי הגוף והעומק של משיכה שלמה, אחר כך משתמש בטקסטורת grain קבועה כדי לקבוע אילו מיקומים יקבלו אבק גיר, ולבסוף מבצע קומפוזיציה של התוצאה על הדיו הקיים.

טקסטורת scratch זו מבודדת משיכת גיר אחת. הבידוד חשוב מפני שהחותמות בתוך אותה משיכה חופפות מאוד; אילו כל חותמת הייתה עוברת grain-gating בנפרד, קו המרכז של המשיכה היה צובר צבע חוזר, ונקבוביות הגיר היו זזות עם צפיפות הדגימה.

ברמות זום גבוהות Lulucat Notes מצייר מחדש את משיכות הווקטור הגלויות ב-viewport הנוכחי. המימוש הישן ביצע את הצעדים הבאים עבור כל משיכת גיר גלויה:

  1. לסיים את ה-render encoder הראשי;
  2. לנקות את טקסטורת ה-scratch;
  3. לצייר את משיכת הגיר היחידה הזו לתוך ה-scratch;
  4. לפתוח מחדש את ה-render encoder הראשי;
  5. לבצע קומפוזיציה של ה-scratch בחזרה אל ה-drawable באמצעות משולש על כל המסך.

ה-drawable של ה-iPad היה בגודל 2732×2048 — בערך 5.6 מיליון פיקסלים. כל משיכת גיר הפעילה מעבר scratch אחד וקומפוזיציה אחת על כל המסך. שבעים משיכות גיר משמעותן הייתה בערך 141 render encoders ו-70 קומפוזיציות על כל המסך.

יהיו מספר משיכות הגיר הגלויות ו- מספר הפיקסלים של ה-drawable. אם מתחשבים רק בעבודה שגדלה עם כיסוי הפיקסלים, המימוש הישן היה קרוב ל-

כל משיכת גיר נשאה גם תקורה קבועה של render-pass, ולכן גם העלות הזו גדלה ליניארית עם . 3,571 נקודות הקלט תרמו רק עלות משנית. מה שגדל עם מספר המשיכות המקומי היה העבודה על כל המסך שהופעלה על ידי כל משיכה.

המדידות נעשו על iPad Pro בגודל 12.9 אינץ’ (דור 5, M1) עם iPadOS 18.6.2. השווינו חותמות זמן של GPU מאותו viewport לפני השינוי ואחריו, באמצעות חותמות זמן של command buffer באותה בניית Debug למכשיר, ב-iPad המסוים הזה — שנקרא להלן LucasPad. הטווחים הבאים הם תנודות אופייניות בלוגים של פריימים רבים, ולא התחייבויות לקצב פריימים של גרסה שנשלחה. עם 70 משיכות גיר גלויות, פריים יחיד דרש בדרך כלל 52–60 ms של זמן GPU; באזור עם בערך 120 משיכות, זמן ה-GPU עלה ל-77–80 ms.

בהערכה לפי שטח המלבן על כל המסך של מעברי ה-scratch וה-composite, היקף העבודה התאורטי לפריים גדל מכ-783 מיליון פיקסלים ל-1.34 מיליארד פיקסלים. נתון זה הוא סכום שטחי המלבנים ואינו שקול למספר קריאות ה-fragment, לבייטים של קריאה/כתיבה בזיכרון הווידאו או למוני החומרה של ה-GPU. ה-fast clear של Metal, טעינת/שמירת ה-attachments ומעבר בין passes נשארים בשליטת ה-GPU וה-driver.

זה מסביר גם למה אזורים ריקים נשארו חלקים. סינון הנראות מדלג על משיכות שמחוץ ל-viewport; באזור ריק קרוב לאפס, ואילו באזור צפוף ממשיך לעלות.

תשובה שגויה ב-0.85 ms

לאפליקציה כבר הייתה טקסטורת דיו של עמוד שלם שנאפתה בשני פיקסלים לנקודה. ניסינו להציג את הטקסטורה הזו ישירות בזמן כתיבה, הזזת הקנבס וזום, ולהשאיר רק את משיכת ה-Apple Pencil הנוכחית כווקטור חי; אחרי שהאינטראקציה הסתיימה, פריים נוסף היה מצייר מחדש את תוצאת הווקטור ברזולוציה גבוהה.

הגישה הזו ביצעה היטב מאוד. באותו אזור צפוף בזום של 300%, זמן ה-GPU ירד ל-0.84–0.85 ms וכבר לא גדל עם מספר משיכות הגיר הקיימות.

הבעיה במכשיר האמיתי הייתה ברורה באותה מידה. בזום של 300% נדרשו בערך שישה פיקסלים של מסך לכל נקודה, אבל המטמון סיפק רק שניים. ברגע שה-Apple Pencil נגע במסך, כל המשיכות הקיימות הפכו לתמונה רכה ברזולוציה נמוכה; כשה-Pencil הורם, הן קפצו בחזרה לחדות מלאה.

הבודק אמר דבר אחד: “כשאני כותב, כל הקנבס נהיה מטושטש. הוא מתחדד ברגע שאני מרים את העיפרון.”

הסרנו את האופטימיזציה. 0.85 ms הייתה התוצאה הנמוכה ביותר שנמדדה, אבל זה לא היה כלי גיר מקובל. המשיכות הקיימות הן חלק ממשוב הכתיבה; החדות שלהן אינה יכולה להשתנות בזמן הנחת העט.

הגבלת כל משיכת גיר למלבן שלה

התיקון הסופי שמר על scratch לכל משיכה ועל קומפוזיציה לכל משיכה, והקטין רק את היקף עבודת הפיקסלים שלהם. לכל משיכה כבר הייתה תיבה תוחמת בקנבס, שנגזרה מאיחוד כל רדיוסי החותמות שלה. המרנדר הופך את התיבה התוחמת הזו לקואורדינטות ה-drawable של ה-viewport הנוכחי ומוסיף לה שני פיקסלים כשוליים ל-antialiasing:

אותו מלבן scissor משמש לאחר מכן לשלושה דברים: ניקוי ה-scratch, ציור המשיכה וקומפוזיציה של התוצאה בחזרה אל המשטח הראשי.

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

אותו היגיון משמש גם לאפייה ול-replay חלקי על טקסטורת הדיו 4096², כך שהתצוגה בזום גבוה ושכבת הדיו שהתייצבה אינן יוצרות שתי התנהגויות גיר שונות.

קל לפספס כאן שני פרטים.

ראשית, loadAction = .clear של render pass מתרחש בשלב טעינת ה-attachment ואינו מוגבל על ידי scissor הרסטריזציה. המשך השימוש בו עדיין היה מנקה את כל טקסטורת ה-scratch. ה-pass הקבוע משתמש ב-.dontCare, ואז מצייר clear_fragment בתוך ה-scissor. המלבן הזה נכתב במלואו, וה-composite קורא רק את אותו מלבן, ולכן אין צורך לטעון את תוכן ה-attachment הישן.

שנית, אחרי שהקומפוזיציה של כל משיכת גיר מסתיימת, חייבים לשחזר את ה-scissor החיצוני. אם משמיטים את שורת שחזור המצב הזו, עטים, תמונות או בחירות שבאים אחר כך ימשיכו להיות חתוכים לפי הגבולות של משיכת הגיר הקודמת וייראו כמו משיכות חסרות או תמונות חסרות.

גרעין הגיר עדיין נדגם מקואורדינטות מוחלטות של הקנבס ולא מ-UV מקומיים בתוך המלבן. הזזת ה-scissor משנה רק אילו פיקסלים ה-GPU מעבד; היא אינה משנה מאיזה מיקום בטקסטורת ה-grain כל פיקסל קורא. לכן מלבנים סמוכים אינם יוצרים תפרי טקסטורה, וגרירת הקנבס אינה גורמת לגרעין לנדוד.

אם מתחשבים רק בעומס הפיקסלים, היקף העבודה החדש קרוב ל-

כאשר הוא השטח של התיבה התוחמת המיושרת לצירים של משיכת הגיר ה- במסך הנוכחי. מספר ה-render encoders לא ירד, אבל כל ניקוי וכל composite מוגבלים כעת לתיבה התוחמת של המשיכה על המסך.

למה לא אגדנו משיכות גיר באותו צבע

רוב משיכות הגיר בדף חולקות את אותו צבע ואת אותה צפיפות, ומפתה לצייר עשרות משיכות לתוך ה-scratch בבת אחת ולבצע קומפוזיציה פעם אחת בלבד. כך אפשר היה לצמצם עוד את מספר ה-render passes, אבל זה משנה את סמנטיקת הצבע וה-grain באזורים חופפים.

נבחן מקרה מפושט במכוון: שתי משיכות חולקות בדיוק את אותו ערך grain-gate, , בפיקסל מסוים, עם כיסויי גוף ו-. ב-shader בפועל, ה-gate תלוי גם בעומק הלחץ של כל משיכה; המקרה הפשוט יותר הזה מספיק כדי להראות ש-batching אינו שקול בדרך כלל. הקומפוזיציה הנוכחית לכל משיכה מפיקה

ואילו מיזוג הגופים תחילה והחלת gate יחיד לאחר מכן מפיקים

ההבדל הוא . בכל פעם ששתי משיכות חופפות וערך ה-grain gate אינו אפס טהור ואינו אחד טהור, התוצאות שונות. batching ישיר ישנה את האופן שבו אבק הגיר נוחת במפגשים.

batching מדויק דורש להוכיח שפיקסלי המשיכות זרים זה לזה, או להקצות לכל משיכה אזור atlas עצמאי ולבצע קומפוזיציה בסדר המקורי. האישור הסופי על המכשיר שמר על גישת ה-scissor, ולכן בסבב הזה לא הוספנו atlas או את מורכבות הניהול שלו.

ממיליארד פיקסלים בחזרה לכמה מיליונים

המדידות הסופיות במכשיר:

תרחישלפני התיקוןscissor מדויק
70 משיכות גיר גלויות, זום של 300%, כתיבהGPU 52–60 ms≈ 9–10 ms
≈ 121 משיכות גיר גלויות, זום של 300%GPU 77–80 ms13.7–15.6 ms
היקף מלבנים תאורטי לפריים (scratch + composite)783 M–1.34 B פיקסלים≈ 1.7 M–3 M פיקסלים

בדקנו גם את גבולות החיתוך באמצעות 3,452 משיכות ו-202,710 נקודות דגימה ממסמך במכשיר. בזומים של 0.5×, 1×, 2×, 3×, 5× ו-8× נוצרו 186,408 מקרי viewport; כל point sprite שהיה יכול לייצר כיסוי שאינו אפס נכלל בתוך ה-scissor שחושב. הבדיקה כיסתה את שולי הקנבס, את שולי ה-viewport וצירופים שונים של offsets.

הקוד הסופי אינו עובר ל-LOD ברזולוציה נמוכה לפי מצב האינטראקציה. רמות זום נמוכות עדיין מציגות את טקסטורת הדיו של העמוד המלא; רמות זום גבוהות עדיין מציירות מחדש את המשיכות הגלויות כווקטורים. באותו צד של הסף, הנחת העט והזזת הקנבס אינן מחליפות את המשיכות הקיימות ברמת חדות אחרת. במהלך ציור הווקטורים מחדש בזום גבוה, ניקוי ה-scratch והקומפוזיציה של כל משיכת גיר מכסים רק את התיבה התוחמת שלה על המסך.

זמן GPU לא תפס את החדות

בעיות ביצועי GPU לא בהכרח גדלות עם הכמות הבולטת ביותר במבנה הנתונים. במקרה הזה 3,571 נקודות הקלט היו חשודות באופן טבעי; מה שקבע את זמן הפריים היה העבודה על כל המסך שהופעלה על ידי כל אחת מ-70 משיכות הגיר, יחד עם החלפת ה-render passes שלהן.

גם הסמנטיקה החזותית הגבילה את האופטימיזציות האפשריות. אי אפשר היה להסיר כרצוננו את ה-scratch לכל משיכה, את סדר הקומפוזיציה המקורי ואת קואורדינטות ה-grain המוחלטות של הקנבס. צבע זהה וצפיפות זהה רק אומרים שהפרמטרים תואמים — הם לא מוכיחים שאפשר למזג תוצאות חופפות.

המשוב מהמכשיר האמיתי דחה את הגרסה עם זמן ה-GPU הנמוך ביותר. ההערה “כל הקנבס נהיה מטושטש” סיפקה את אילוץ המוצר שהמדידה לבדה לא ביטאה: כש-Apple Pencil נוגע במסך, המשתמשים גם מסתכלים על המשיכות הקיימות.

המימוש שנבדק לבסוף אינו מוסיף שכבת מטמון אינטראקציה חדשה ואינו מפחית את החדות. ציור הווקטורים מחדש בזום גבוה פשוט מגביל את העבודה של כל משיכת גיר לתיבה התוחמת שלה על המסך. אחרי שהוא נטען שוב ל-LucasPad, המשוב הפך ל: “נראה מצוין.”