שיפור הביצועים של לוח הציור של HTML5

בוריס סמוס
בוריס סמוס

מבוא

בד קנבס HTML5, שהתחיל כניסוי של Apple, הוא התקן הנתמך ביותר לגרפיקה במצב מיידי בדו-ממד באינטרנט. מפתחים רבים מסתמכים על כך כיום במגוון רחב של פרויקטים, תצוגות חזותיות ומשחקים. אבל ככל שהאפליקציות שאנחנו מפתחים יותר מורכבות, המפתחים מגיעים בטעות לחומת הביצועים. יש הרבה חוכמת הסכמה לגבי אופטימיזציה של ביצועי לוח הציור. במאמר הזה נאחד חלק מהגוף למשאב למפתחים, שיהיה קל לעיכול. המאמר הזה כולל אופטימיזציות בסיסיות שחלות על כל סביבות הגרפיקה הממוחשבת, וכן טכניקות ספציפיות לקנבס שעשויות להשתנות ככל שההטמעות של הקנבס ישתפרו. באופן ספציפי, כשספקי דפדפנים מטמיעים האצה של ה-GPU ב-canvas, סביר להניח שחלק משיטות הביצועים שפורטו כאן יהיו פחות משפיעות. הדבר יצוין במקרים המתאימים. לתשומת לבך, מאמר זה לא עוסק בשימוש בבד ציור HTML5. לשם כך, קרא את המאמרים הקשורים ללוח הציור ב-HTML5Rocks, הפרק הזה באתר Dive to HTML5 או ב-MDN Canvas.

בדיקת ביצועים

כדי לטפל בעולם המשתנה במהירות של בד קנבס HTML5, בדיקות JSPerf (jsperf.com) מאמתות שכל האופטימיזציה המוצעת עדיין פועלת. JSPerf היא אפליקציית אינטרנט שמאפשרת למפתחים לכתוב בדיקות ביצועים של JavaScript. כל בדיקה מתמקדת בתוצאה שמנסים להשיג (למשל, ניקוי לוח הציור), והיא כוללת מספר גישות שמשיגות את אותה תוצאה. JSPerf מפעיל כל גישה כמה שיותר פעמים בפרק זמן קצר, ומקצה מספר בעל משמעות סטטיסטית של איטרציות בשנייה. ניקוד גבוה תמיד טוב יותר! מבקרים בדף בדיקת הביצועים של JSPerf יכולים להריץ את הבדיקה בדפדפן, ולאפשר ל-JSPerf לאחסן את תוצאות הבדיקה המנורמלות ב-Browserscope (browserscope.org). מכיוון ששיטות האופטימיזציה במאמר הזה מגובות בתוצאת JSPerf, אפשר לחזור כדי לראות מידע עדכני אם השיטה עדיין רלוונטית או לא. כתבתי אפליקציית עזרה קטנה שמציגה את התוצאות האלה כגרפים, המוטמעת במאמר הזה.

כל תוצאות הביצועים במאמר הזה קשורות לגרסת הדפדפן. מסתבר שיש הגבלה כי אנחנו לא יודעים על איזו מערכת הפעלה הדפדפן פעל, ואפילו חשוב יותר - אם בד הציור של HTML5 הואץ באמצעות חומרה כשבדיקת הביצועים פעלה. כדי לבדוק אם בד הציור של HTML5 ב-Chrome מואץ באמצעות חומרה, תוכלו להיכנס לכתובת about:gpu בסרגל הכתובות.

עיבוד מראש לאזור העריכה שלא מופיע במסך

אם אתם משרטטים מחדש פרימיטיבים דומים למסך במספר פריימים, כפי שקורה בדרך כלל בכתיבת משחק, תוכלו להשיג שיפורי ביצועים על ידי עיבוד מראש של חלקים גדולים מהסצנה. לעיבוד מראש יש להשתמש בקנבס נפרד מחוץ למסך (או בקנבס) שממנו אפשר לעבד תמונות זמניות, ואז לעבד את הקנבס מתוך התמונה שלא מופיע במסך. לדוגמה, נניח שאתם מציירים מחדש את מריו שפועל במהירות של 60 פריימים לשנייה. תוכלו לצייר מחדש את הכובע, את השפם ואת האות M בכל פריים, או לעבד מראש את מריו לפני הרצת האנימציה. ללא עיבוד מראש:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

מעבד מראש:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

שימו לב לשימוש ב-requestAnimationFrame, שעליו נדון בהרחבה בחלק מאוחר יותר.

השיטה הזו יעילה במיוחד כשפעולת הרינדור (drawMario בדוגמה שלמעלה) היא יקרה. דוגמה טובה לכך היא עיבוד טקסט, פעולה יקרה מאוד.

עם זאת, יש ביצועים נמוכים של מקרה הבדיקה "עיבוד מראש". כשמבצעים עיבוד מראש, חשוב לוודא שהקנבס הזמני מתאים היטב לתמונה שאתם משרטטים, אחרת העלייה בביצועים של רינדור מחוץ למסך נלקחת בחשבון כנגד אובדן הביצועים של העתקת קנבס גדול אחד לאחר (המשתנה בהתאם לגודל יעד המקור). הקנבס הנעים שבבדיקה שלמעלה הוא פשוט קטן יותר:

can2.width = 100;
can2.height = 40;

בהשוואה לזה הרגיל שמניב ביצועים נמוכים:

can3.width = 300;
can3.height = 100;

קיבוץ שיחות על קנבס יחד

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

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

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

אנו משיגים ביצועים טובים יותר מציור קו פוליגוני יחיד:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

כלל זה חל גם על עולם הקנבס ב-HTML5. לדוגמה, כאשר משרטטים נתיב מורכב, עדיף להציב את כל הנקודות בנתיב ולא לעבד את הקטעים בנפרד (jsperf).

עם זאת, שימו לב שבאזור העריכה יש יוצא מן הכלל חשוב לכלל הזה: אם לפרימיטיבים שמעורבים בציור האובייקט הרצוי יש תיבות תוחמות קטנות (למשל קווים אופקיים ואנכיים), יכול להיות שעדיף להציג אותם בנפרד (jsperf).

יש להימנע משינויים מיותרים במצב העריכה

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

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

לחלופין, עיבוד כל הפסים האי-זוגיים ולאחר מכן את כל הפסים זוגיים:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

כמו כן, הגישה של הפעולות המשולבות היא איטית יותר, כי החלפה של המכשיר במצב יקר.

עיבוד של הבדלים במסך בלבד, ולא את המצב החדש כולו

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

context.fillRect(0, 0, canvas.width, canvas.height);

עוקבים אחרי התיבה התוחמת ששורטטה, ומנקים רק אותה.

context.fillRect(last.x, last.y, last.width, last.height);

אם אתם מכירים גרפיקה ממוחשבת, יכול להיות שהשיטה הזאת מוכרת לכם גם כ'שרטוט אזורים מחדש', שבה התיבה המתוחכמת שעברה רינדור נשמרת, ומוסרת בכל רינדור בכל רינדור. השיטה הזו רלוונטית גם להקשרי רינדור המבוססים על פיקסלים, כפי שמתואר בדיבור על אמולטור Nintendo של JavaScript.

שימוש במספר שכבות על קנבס כדי ליצור סצנות מורכבות

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

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

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

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

הימנעות מטשטוש צללית

כמו סביבות גרפיות רבות אחרות, בד קנבס של HTML5 מאפשר למפתחים לטשטש פרימיטיבים, אבל הפעולה הזו יכולה להיות יקרה מאוד:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

הכרת הדרכים השונות לניקוי הקנבס

מאחר שהקנבס של HTML5 הוא פרדיגמת ציור של מצב מיידי, יש לצייר מחדש את הסצנה באופן מפורש בכל מסגרת. לכן ניקוי בד הציור הוא פעולה חשובה מהותית באפליקציות ובמשחקים של לוח הציור של HTML5. כפי שצוין בקטע הימנעות משינויים במצב קנבס, לא רצוי לנקות את כל הקנבס, אבל אם חייבים לעשות זאת, יש שתי אפשרויות: לקרוא ל-context.clearRect(0, 0, width, height) או להשתמש בפריצה ספציפית לקנבס כדי לעשות זאת: canvas.width = canvas.width;.בזמן הכתיבה, clearRect מניב בדרך כלל ביצועים טובים יותר מגרסת איפוס הרוחב, אבל במקרים מסוימים השימוש בפעולת האיפוס canvas.width מהיר יותר באופן משמעותי ב-Chrome 14.

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

הימנעות מקואורדינטות של נקודה צפה (floating-point)

בד קנבס HTML5 תומך בעיבוד תת-פיקסלי, ואין דרך להשבית אותו. אם משרטטים קואורדינטות שהן לא מספרים שלמים, המערכת משתמשת באופן אוטומטי בקו ביטול נעילה כדי לנסות ליישר את הקווים. זהו האפקט החזותי, שמקורו במאמר הזה על ביצועי הבד עם הפיקסלים המשניים מאת Seb Lee-Delisle:

פיקסל משני

אם ה-Sprite המוחלק הוא לא האפקט שאתם מחפשים, יכול להיות הרבה יותר מהר להמיר את הקואורדינטות למספרים שלמים באמצעות Math.floor או Math.round (jsperf):

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

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

פירוט מלא של הביצועים מופיע כאן (jsperf).

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

אופטימיזציה של האנימציות באמצעות requestAnimationFrame

הדרך המומלצת להטמיע אפליקציות אינטראקטיביות בדפדפן היא ה-API החדש יחסית של requestAnimationFrame. במקום לפקוד את הדפדפן לעבד בקצב קבוע מסוים, כדאי לבקש מהדפדפן לקרוא לתרחיש הרינדור שלכם ולקבל קריאה כשהדפדפן יהיה זמין. יתרון נחמד: אם הדף לא בחזית, הדפדפן חכם מספיק כדי לא לעבד אותו. המטרה של הקריאה החוזרת (callback) של requestAnimationFrame היא בשיעור של 60 FPS, אבל היא לא מבטיחה זאת. לכן, עליכם לעקוב אחרי משך הזמן שחלף מאז העיבוד האחרון. זה יכול להיראות כך:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

שימו לב שהשימוש הזה ב-requestAnimationFrame חל על קנבס ועל טכנולוגיות רינדור אחרות, כמו WebGL. נכון לעכשיו, ה-API הזה זמין רק ב-Chrome, ב-Safari וב-Firefox, ולכן עליכם להשתמש ב-this shim.

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

בואו נדבר על ניידים. לצערנו, בזמן כתיבת הנתונים, רק גרסת בטא של iOS 5.0 שפועלת ב-Safari 5.1 האיצה את הטמעת לוח הציור של הנייד באמצעות GPU. ללא האצה של GPU, בדרך כלל לדפדפנים לנייד אין מעבדים חזקים מספיק לאפליקציות מודרניות שמבוססות על בד ציור. כמה מבדיקות JSPerf שמתוארות למעלה מניבות ביצועים נמוכים משמעותית בניידים בהשוואה למחשב, ומגבילות מאוד את סוגי האפליקציות חוצות-המכשירים שניתן לצפות להן לפעול בהצלחה.

סיכום

לסיכום, מאמר זה מכסה קבוצה מקיפה של טכניקות אופטימיזציה שימושיות שיעזרו לכם לפתח פרויקטים יעילים של HTML5 מבוססי בד ציור. עכשיו, אחרי שלמדתם משהו חדש, תוכלו להתחיל לשפר את היצירות המדהימות שלכם. לחלופין, אם אין לכם כרגע משחק או אפליקציה לביצוע אופטימיזציה, תוכלו לקבל השראה מהניסויים של Chrome ומ-Creative JS.

קובצי עזר