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

מבוא

HTML5 canvas, שהתחיל כניסוי של Apple, הוא התקן הנתמך ביותר לגרפיקה במצב מיידי ב-2D באינטרנט. מפתחים רבים משתמשים בו כיום במגוון רחב של פרויקטים, תצוגות חזותיות ומשחקים של מדיה מעורבת. עם זאת, ככל שהאפליקציות שאנחנו מפתחים נעשות מורכבות יותר, המפתחים נתקלים בטעות בקיר הביצועים. יש הרבה ידע לא קשור בנושא אופטימיזציה של ביצועי מודעות על קנבס. המטרה של המאמר הזה היא לרכז חלק מהמידע הזה במקור מידע שקל יותר לעכל עבור מפתחים. המאמר הזה כולל אופטימיזציות בסיסיות שחלות על כל סביבות הגרפיקה הממוחשבת, וגם טכניקות ספציפיות לקנבס שעשויות להשתנות ככל ששיפורים יתבצעו בהטמעות של קנבס. באופן ספציפי, ככל שספקי הדפדפנים יטמיעו האצה של GPU בקנבס, סביר להניח שחלק מהשיטות לשיפור הביצועים שתוארו כאן יהפכו לפחות יעילות. נציין זאת במקומות הרלוונטיים. חשוב לזכור שהמאמר הזה לא עוסק בשימוש ב-HTML5 canvas. לצורך כך, תוכלו לעיין במאמרים הקשורים ל-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

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

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

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);

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

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

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

סיכום

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

קובצי עזר