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

מבוא

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

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

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

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

עיבוד מראש להדפסות על קנבס שלא מופיעות במסך

אם אתם מציירים מחדש פרימיטיבים דומים במסך במספר פריימים, כמו שקורה לעיתים קרובות כשכותבים משחק, תוכלו לשפר את הביצועים באופן משמעותי על ידי עיבוד מראש של חלקים גדולים מהסצנה. עיבוד מראש (pre-rendering) הוא שימוש בקנבס (או בקנבסים) נפרדים מחוץ למסך, שבהם מעבדים תמונות זמניות, ולאחר מכן מעבדים את הקנבסים מחוץ למסך בחזרה לקנבס הגלוי. לדוגמה, נניח שאתם מציירים מחדש את מריו בריצה ב-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;

איך מקבצים שיחות ב-Canvas

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

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

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 canvas. לדוגמה, כשמציירים נתיב מורכב, עדיף להוסיף את כל הנקודות לנתיב במקום להריץ רינדור של הקטעים בנפרד (jsperf).

עם זאת, חשוב לזכור שיש ל-Canvas חריג חשוב לכלל הזה: אם למבנים הבסיסיים שמשמשים לציור האובייקט הרצוי יש תיבות מלבניות מסגרת קטנות (לדוגמה, קווים אופקיים ואנכיים), יכול להיות שיהיה יעיל יותר ליצור להם רינדור בנפרד (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);

אם כבר יצא לכם להשתמש בגרפיקה ממוחשבת, יכול להיות שאתם מכירים את השיטה הזו גם כ-'reraw מוסיפים' (אזורים גיאוגרפיים לסימון מחדש), שבהם נשמרת התיבה התוחמת (bounding box) שעבר רינדור, ואז היא נמחקת בכל רינדור. הטכניקה הזו רלוונטית גם להקשרי רינדור מבוססי פיקסלים, כפי שממחיש שיחת האמולטור של 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. בנוסף, כדאי לשים לב שהגישה הזו כוללת היטב כל מספר של קנבסים מורכבים, אם האפליקציה עובדת טוב יותר עם מבנה כזה.

הימנעות משימוש ב-shadowBlur

בדומה לסביבות גרפיות רבות אחרות, בדף הקנבס של 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.

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

הימנעות משימוש בקואורדינטות של נקודה צפה

לקנבס ב-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).

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

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

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

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, לכן צריך להשתמש ב-shim הזה.

רוב ההטמעות של קנבס בנייד הן איטיות

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

סיכום

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

קובצי עזר