World Wide Maze הוא משחק שבו משתמשים בסמארטפון כדי לנווט בכדור גלילי במבוכים תלת-ממדיים שנוצרו מאתרים, בניסיון להגיע לנקודות היעד שלהם.
במשחק נעשה שימוש נרחב בתכונות של HTML5. לדוגמה, האירוע DeviceOrientation מאחזר נתוני הטיה מהסמארטפון, שנשלחים לאחר מכן למחשב באמצעות WebSocket, שבו השחקנים מוצאים את דרכם במרחבים תלת-ממדיים שנוצרו על ידי WebGL ו-Web Workers.
במאמר הזה אסביר בדיוק איך משתמשים בתכונות האלה, את תהליך הפיתוח הכולל ואת הנקודות העיקריות לאופטימיזציה.
DeviceOrientation
האירוע DeviceOrientation (דוגמה) משמש לאחזור נתוני הטיה מהסמארטפון. כשמשתמשים ב-addEventListener
עם האירוע DeviceOrientation
, פונקציית ה-callback עם האובייקט DeviceOrientationEvent
מופעלת כארגומנטים במרווחי זמן קבועים. המרווחים עצמם משתנים בהתאם למכשיר שבו נעשה שימוש. לדוגמה, ב-iOS + Chrome וב-iOS + Safari, קריאת החזרה (callback) מופעלת בערך כל 1/20 שנייה, ואילו ב-Android 4 + Chrome היא מופעלת בערך כל 1/10 שנייה.
window.addEventListener('deviceorientation', function (e) {
// do something here..
});
האובייקט DeviceOrientationEvent
מכיל נתוני הטיה לכל אחד מהצירים X
, Y
ו-Z
במדידה ב-degrees (לא ב-radians) (מידע נוסף ב-HTML5Rocks). עם זאת, ערכי ההחזרה משתנים גם בהתאם לשילוב של המכשיר והדפדפן שבהם נעשה שימוש. הטבלה הבאה מציגה את טווחי ערכי ההחזרה בפועל:
הערכים בחלק העליון שמודגשים בכחול הם אלה שהוגדרו במפרטי W3C. המודגשים בירוק תואמים למפרטים האלה, והמודגשים באדום חורגים מהם. באופן מפתיע, רק השילוב של Android ו-Firefox החזיר ערכים שתואמים למפרטים. עם זאת, כשמדובר בהטמעה, עדיף להתאים את המערכת לערכים שמופיעים בתדירות גבוהה. לכן, ב-World Wide Maze נעשה שימוש בערכים המוחזרים של iOS כסטנדרט, והם מותאמים בהתאם למכשירי Android.
if android and event.gamma > 180 then event.gamma -= 360
עם זאת, עדיין אין תמיכה ב-Nexus 10. למרות ש-Nexus 10 מחזיר את אותו טווח ערכים כמו מכשירי Android אחרים, יש באג שמשנה את הערכים של בטא ושל גמא. אנחנו מטפלים בבעיה הזו בנפרד. (אולי ברירת המחדל היא כיוון לרוחב?)
כפי שרואים, גם אם לממשקי API שכוללים מכשירים פיזיים יש מפרטים מוגדרים, אין ערובה שהערכים שיוחזרו יהיו תואמים למפרטים האלה. לכן חשוב מאוד לבדוק אותם בכל המכשירים הפוטנציאליים. פירוש הדבר גם הוא שעשויים להיכנס ערכים בלתי צפויים, ולכן צריך ליצור פתרונות חלופיים. במשחק World Wide Maze, שחקנים שמשחקים בפעם הראשונה מתבקשים לבצע כיול של המכשירים שלהם בשלב 1 של המדריך, אבל הוא לא יתבצע כראוי למיקום האפס אם המכשיר יקבל ערכים לא צפויים של הטיה. לכן יש לו מגבלת זמן פנימית, והוא מבקש מהנגן לעבור לפקדים במקלדת אם הוא לא מצליח לבצע את ההתאמה תוך מגבלת הזמן הזו.
WebSocket
במשחק World Wide Maze, הסמארטפון והמחשב מחוברים באמצעות WebSocket. ליתר דיוק, הם מחוברים דרך שרת ממסר ביניהם, כלומר מהסמארטפון לשרת ולאחר מכן למחשב. הסיבה לכך היא של-WebSocket אין יכולת לחבר דפדפנים ישירות זה לזה. (שימוש בערוצי נתונים של WebRTC מאפשר קישוריות מקצה לקצה ומבטל את הצורך בשרתי ממסר, אבל בזמן ההטמעה אפשר היה להשתמש בשיטה הזו רק ב-Chrome Canary וב-Firefox Nightly).
בחרתי להטמיע באמצעות ספרייה שנקראת Socket.IO (v0.9.11), שכוללת תכונות לחיבור מחדש במקרה של זמן קצוב לתפוגה של החיבור או ניתוק. השתמשתי בו יחד עם NodeJS, כי השילוב הזה של NodeJS + Socket.IO הראה את הביצועים הטובים ביותר בצד השרת בכמה בדיקות הטמעה של WebSocket.
התאמה לפי מספרים
- המחשב מתחבר לשרת.
- השרת מקצה למחשב מספר שנוצר באופן אקראי ומזכור את השילוב של המספר והמחשב.
- במכשיר הנייד, מציינים מספר ומתחברים לשרת.
- אם המספר שצוין זהה למספר של מחשב מחובר, המכשיר הנייד שלכם מותאם למחשב הזה.
- אם אין מחשב ייעודי, מתרחשת שגיאה.
- כשנתונים מגיעים מהמכשיר הנייד, הם נשלחים למחשב שאליו הוא מותאם, ולהפך.
אפשר גם לבצע את החיבור הראשוני מהמכשיר הנייד. במקרה כזה, המכשירים פשוט מוחלפים.
סנכרון הכרטיסיות
תכונת סנכרון הכרטיסיות הספציפית ל-Chrome מאפשרת לבצע את תהליך ההתאמה בקלות רבה יותר. בעזרתו אפשר לפתוח בקלות דפים שנפתחים במחשב בנייד (ולהפך). המחשב מקבל את מספר החיבור שהונפק על ידי השרת ומצרף אותו לכתובת ה-URL של הדף באמצעות history.replaceState
.
history.replaceState(null, null, '/maze/' + connectionNumber)
אם סנכרון הכרטיסיות מופעל, כתובת ה-URL מסתנכרנת אחרי כמה שניות וניתן לפתוח את אותו הדף במכשיר הנייד. המכשיר הנייד בודק את כתובת ה-URL של הדף הפתוח, ואם מצורף מספר, הוא מתחיל להתחבר באופן מיידי. כך אין צורך להזין מספרים באופן ידני או לסרוק קודי QR באמצעות מצלמה.
זמן אחזור
שרת הממסר נמצא בארה"ב, ולכן הגישה אליו מיפן גורמת לעיכוב של כ-200 אלפיות השנייה עד שנתוני הטיה של הסמארטפון מגיעים למחשב. זמני התגובה היו איטיים בבירור בהשוואה לזמני התגובה בסביבה המקומית שבה השתמשתי במהלך הפיתוח, אבל הוספת משהו כמו מסנן מסוג 'פס נמוך' (השתמשתי ב-EMA) שיפרה את המצב לרמה לא מפריעה. (בפועל, היה צורך במסנן מסוג 'מסנן תדר נמוך' גם למטרות הצגה. ערכי ההחזרה של חיישן הטיה כללו כמות ניכרת של רעש, והחלה של הערכים האלה על המסך כפי שהם גרמה לתנודות רבות). הפתרון הזה לא עבד עם קפיצות, שהיו איטיות באופן ברור, אבל לא ניתן היה לעשות דבר כדי לפתור את הבעיה הזו.
מכיוון שציפיתי לבעיות זמן אחזור כבר מההתחלה, העליתי בחשבון להגדיר שרתי ממסר ברחבי העולם כדי שהלקוחות יוכלו להתחבר לשרת הקרוב ביותר שזמין (כך ניתן לצמצם את זמן האחזור). עם זאת, בסופו של דבר השתמשתי ב-Google Compute Engine (GCE), שהיה קיים רק בארה"ב באותו זמן, ולכן לא הייתה לי אפשרות לעשות זאת.
הבעיה של אלגוריתם Nagle
אלגוריתם Nagle משמש בדרך כלל לצורך תקשורת יעילה באמצעות מאגרים ברמת ה-TCP, אבל גיליתי שלא הצלחתי לשלוח נתונים בזמן אמת כשהאלגוריתם הזה היה מופעל. (במיוחד בשילוב עם אישור מאחר של TCP. גם אם אין עיכוב ב-ACK
, אותה בעיה מתרחשת אם יש עיכוב מסוים ב-ACK
בגלל גורמים כמו מיקום השרת בחו"ל).
בעיית זמן האחזור של Nagle לא התרחשה ב-WebSocket ב-Chrome ל-Android, שכולל את האפשרות TCP_NODELAY
להשבתת Nagle, אבל היא התרחשה ב-WebKit WebSocket שמשמש ב-Chrome ל-iOS, שבו האפשרות הזו לא מופעלת. (הבעיה הזו הייתה גם ב-Safari, שבו נעשה שימוש באותו WebKit). הבעיה דווחה ל-Apple דרך Google ונראה שהיא נפתרה בגרסה הפיתוח של WebKit.
כשהבעיה הזו מתרחשת, נתוני הטיה שנשלחים כל 100 אלפיות השנייה משולבים בקטעים שמגיעים למחשב רק כל 500 אלפיות השנייה. המשחק לא יכול לפעול בתנאים האלה, ולכן כדי למנוע את זמן האחזור הזה, הנתונים נשלחים מצד השרת במרווחי זמן קצרים (כל 50 אלפיות השנייה בערך). לדעתי, קבלת ACK
במרווחי זמן קצרים מטעה את אלגוריתם Nagle לחשוב שאפשר לשלוח נתונים.
בתרשים שלמעלה מוצגים המרווחים בין הנתונים בפועל שהתקבלו. הוא מציין את מרווחי הזמן בין החבילות. הירוק מייצג את מרווחי הפלט והאדום מייצג את מרווחי הקלט. הערך המינימלי הוא 54ms, הערך המקסימלי הוא 158ms והערך האמצעי הוא קרוב ל-100ms. כאן השתמשתי ב-iPhone עם שרת העברה שנמצא ביפן. זמן הפלט והקלט הוא כ-100 אלפיות השנייה, והפעולה חלקה.
לעומת זאת, בתרשים הזה מוצגות התוצאות של השימוש בשרת בארה"ב. מרווחי הפלט הירוקים נשארים יציבים ב-100 אלפיות השנייה, אבל מרווחי הקלט משתנים בין 0 אלפיות השנייה ל-500 אלפיות השנייה, מה שמצביע על כך שהמחשב מקבל נתונים בקטעים.
לבסוף, בתרשים הזה מוצגות התוצאות של הימנעות מעיכוב על ידי שליחת נתוני placeholder מהשרת. הביצועים לא טובים כמו כשמשתמשים בשרת היפני, אבל ברור שמרווחי הקלט נשארים יציבים יחסית, סביב 100ms.
באג?
למרות שלדפדפן ברירת המחדל ב-Android 4 (ICS) יש WebSocket API, הוא לא יכול להתחבר, וכתוצאה מכך מתרחש אירוע connect_failed ב-Socket.IO. פג התוקף הפנימי, וגם בצד השרת לא ניתן לאמת חיבור. (לא בדקתי את זה עם WebSocket בלבד, לכן יכול להיות שזו בעיה ב-Socket.IO).
התאמת עומסים של שרתי ממסר
מכיוון שהתפקיד של שרת הממסר לא מורכב במיוחד, לא אמורה להיות בעיה בהרחבת היקף הפעילות והגדלת מספר השרתים, כל עוד מוודאים שאותו מחשב ואותו מכשיר נייד תמיד מחוברים לאותו שרת.
פיזיקה
תנועת הכדור במשחק (גלישה במורד, התנגשות עם הקרקע, התנגשות עם קירות, איסוף פריטים וכו') מתבצעת באמצעות סימולטור פיזיקה תלת-ממדי. השתמשתי ב-Ammo.js – גרסת JavaScript של מנוע הפיזיקה Bullet, שנמצא בשימוש נרחב, באמצעות Emscripten – יחד עם Physijs כדי להשתמש בו כ-'Web Worker'.
Web Workers
Web Workers הוא ממשק API להרצת JavaScript בשרשור נפרד. JavaScript שהושק כ-Web Worker פועל כחוט נפרד מהחוט שבו הוא הופעל במקור, כך שאפשר לבצע משימות כבדות בלי לפגוע בתגובה של הדף. ב-Physijs נעשה שימוש יעיל ב-Web Workers כדי לעזור למנוע הפיזיקה התלת-ממדי האינטנסיבי לפעול בצורה חלקה. ב-World Wide Maze, מנוע הפיזיקה והעיבוד של תמונות WebGL פועלים בשיעורי פריימים שונים לגמרי, כך שאפילו אם שיעור הפריימים יורד במכונה עם מפרט נמוך בגלל עומס עיבוד כבד של WebGL, מנוע הפיזיקה עצמו ימשיך לפעול בקצב של כ-60fps ולא יפריע לפקדים של המשחק.
בתמונה הזו מוצגות שיעורי הפריימים שהתקבלו ב-Lenovo G570. בתיבה העליונה מוצג קצב הפריימים של WebGL (עיבוד תמונות), ובתיבה התחתונה מוצג קצב הפריימים של מנוע הפיזיקה. ה-GPU הוא שבב משולב של Intel HD Graphics 3000, ולכן קצב הפריימים של עיבוד התמונות לא הגיע ל-60fps הצפוי. עם זאת, מאחר שמנוע הפיזיקה הגיע לשיעור הפריימים הצפוי, חוויית המשחק לא שונה בהרבה מהביצועים במכונה עם מפרט גבוה.
מאחר שלשרשראות עם Web Workers פעילים אין אובייקטים של מסוף, צריך לשלוח נתונים לשרשור הראשי באמצעות postMessage כדי ליצור יומני ניפוי באגים. באמצעות console4Worker נוצר אובייקט מקביל של מסוף ב-Worker, וכך תהליך ניפוי הבאגים קל יותר באופן משמעותי.
בגרסאות האחרונות של Chrome אפשר להגדיר נקודות עצירה בזמן ההפעלה של Web Workers, וזה שימושי גם לניפוי באגים. אפשר למצוא אותו בחלונית 'עובדים' בכלים למפתחים.
ביצועים
לפעמים, שלבים עם מספר רב של פוליגונים חורגים מ-100,000 פוליגונים, אבל הביצועים לא נפגעו באופן משמעותי גם כשהם נוצרו כולם כ-Physijs.ConcaveMesh
(btBvhTriangleMeshShape
ב-Bullet).
בהתחלה, קצב הפריימים ירד ככל שמספר האובייקטים שדרוש להם זיהוי התנגשויות גדל, אבל ביטול העיבוד הלא הכרחי ב-Physijs שיפר את הביצועים. השיפור הזה בוצע במזלג של Physijs המקורי.
אובייקטים רפאים
אובייקטים עם זיהוי התנגשויות אבל ללא השפעה על התנגשויות, ולכן ללא השפעה על אובייקטים אחרים, נקראים 'אובייקטים רפאים' ב-Bullet. למרות ש-Physijs לא תומכת רשמית באובייקטים רפאים, אפשר ליצור אותם שם על ידי שינוי דגלים אחרי יצירת Physijs.Mesh
. במשחק World Wide Maze נעשה שימוש באובייקטים רפאים לצורך זיהוי התנגשויות בין פריטים לנקודות יעד.
hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)
עבור collision_flags
, הערך של 1 הוא CF_STATIC_OBJECT
והערך של 4 הוא CF_NO_CONTACT_RESPONSE
. אפשר לנסות לחפש מידע נוסף בפורום של Bullet, ב-Stack Overflow או במסמכי התיעוד של Bullet. מכיוון ש-Physijs הוא מעטפת של Ammo.js, ו-Ammo.js זהה ביסודו ל-Bullet, רוב הדברים שאפשר לעשות ב-Bullet אפשר לעשות גם ב-Physijs.
הבעיה ב-Firefox 18
העדכון של Firefox מגרסה 17 לגרסה 18 שינה את האופן שבו Web Workers מחליפים נתונים, וכתוצאה מכך Physijs הפסיק לפעול. הבעיה דווחה ב-GitHub ונפתרה אחרי כמה ימים. היעילות של קוד פתוח הרימה לי את הרמה, אבל התקרית גם הזיכירה לי ש-World Wide Maze מורכב מכמה מסגרות שונות של קוד פתוח. כתבתי את המאמר הזה בתקווה לספק משוב כלשהו.
asm.js
למרות שהנושא הזה לא נוגע ישירות ל-World Wide Maze, כבר יש תמיכה ב-Ammo.js ב-asm.js של Mozilla, שפורסם לאחרונה (לא מפתיע, כי asm.js נוצר בעיקר כדי לזרז את JavaScript שנוצר על ידי Emscripten, והיוצר של Emscripten הוא גם היוצר של Ammo.js). אם Chrome תומך גם ב-asm.js, עומס המחשוב של מנוע הפיזיקה אמור לרדת באופן משמעותי. המהירות הייתה מהירה יותר באופן משמעותי כשבדקנו עם Firefox Nightly. אולי עדיף לכתוב קטעים שדורשים מהירות גבוהה יותר ב-C/C++ ואז להעביר אותם ל-JavaScript באמצעות Emscripten?
WebGL
להטמעת WebGL השתמשתי בספרייה three.js (r53) שמפותחת באופן פעיל ביותר. אמנם גרסה 57 כבר שוחררה בשלבים האחרונים של הפיתוח, אבל בוצעו שינויים משמעותיים ב-API, ולכן השתמשתי בגרסה המקורית להשקה.
אפקט זוהר
אפקט ההילה שנוסף ללב הכדור ולפריטים מיושם באמצעות גרסה פשוטה של מה שנקרא Kawase Method MGF. עם זאת, בעוד ששיטת Kawase מאפשרת להאיר את כל האזורים הבהירים, ב-World Wide Maze נוצרים יעדי רינדור נפרדים לאזורים שצריכים להאיר. הסיבה לכך היא שצריך להשתמש בצילום מסך של אתר לצורך טקסטורות של במה, וחילוץ פשוט של כל האזורים הבהירים יוביל לכך שכל האתר יהיה זוהר, אם למשל יש לו רקע לבן. שקלתי גם לעבד את כל התמונות ב-HDR, אבל החלטתי שלא לעשות זאת הפעם כי ההטמעה הייתה הופכת למסובכת למדי.
בפינה הימנית העליונה מוצגת ההעברה הראשונה, שבה אזורי ההילה עובדו בנפרד ולאחר מכן הוחל טשטוש. בפינה השמאלית התחתונה מוצגת ההעברה השנייה, שבה גודל התמונה הופחת ב-50% ולאחר מכן הוחל טשטוש. בפינה השמאלית העליונה מוצגת ההעברה השלישית, שבה התמונה שוב צומצמה ב-50% ולאחר מכן טשטשה. לאחר מכן, השכבות האלה הוחלו זו על גבי זו כדי ליצור את התמונה המשולבת הסופית שמוצגת בפינה הימנית התחתונה. לצורך הטשטוש השתמשתי ב-VerticalBlurShader
וב-HorizontalBlurShader
, שכלולים ב-three.js, כך שעדיין יש מקום לאופטימיזציה נוספת.
כדור מחזיר אור
ההשתקפות בכדור מבוססת על דוגמה מ-three.js. כל הכיוונים מוצגים ממיקוד הכדור, ומשמשים כמפות סביבה. צריך לעדכן את מפות הסביבה בכל פעם שהכדור זז, אבל מכיוון שהעדכון ב-60fps הוא אינטנסיבי, במקום זאת הוא מתבצע כל שלושה פריימים. התוצאה לא חלקה כמו עדכון כל פריים, אבל ההבדל כמעט בלתי מורגש אלא אם מציינים אותו.
תוכנת הצללה, תוכנת הצללה, תוכנת הצללה…
כדי לבצע רינדור, צריך להשתמש ב-WebGL עם שידורים (vertex shaders, fragment shaders). הצללים שכלולים ב-three.js כבר מאפשרים ליצור מגוון רחב של אפקטים, אבל כדי ליצור צללים ואופטימיזציה מורכבים יותר, אין מנוס מכתיבה של צללים משלכם. מכיוון ש-World Wide Maze שומר על המעבד עסוק במנוע הפיזיקה שלו, ניסיתי לנצל את ה-GPU במקום זאת על ידי כתיבת כמה שיותר ב-shading language (GLSL), גם כשעיבוד המעבד (דרך JavaScript) היה קל יותר. כמובן, אפקטים של גלי האוקיינוס מבוססים על צללים, כמו גם זיקוקים בנקודות של הבקעות והאפקט של רשת שמופיע כשהכדור מופיע.
התמונה שלמעלה היא מבדיקה של אפקט הרשת שמשמש כשהכדור מופיע. הדמות בצד ימין היא זו שמשמשת במשחק, והיא מורכבת מ-320 פוליגונים. בתמונה שבמרכז נעשה שימוש ב-5,000 מצולעים, ובתמונה שבצד שמאל נעשה שימוש ב-300,000 מצולעים. גם עם כל כך הרבה פוליגונים, העיבוד באמצעות שיבושים (shaders) יכול לשמור על קצב פריימים יציב של 30fps.
הפריטים הקטנים שמפוזרים ברחבי הבמה משולבים כולם במערך אחד, והתנועה של כל פריט בנפרד מתבססת על שינויי צבע (shaders) שמזיזים את כל אחד מהקצוות של הפוליגונים. הנתון הזה מגיע מבדיקת הביצועים של המערכת כשיש מספר גדול של אובייקטים. כאן מוצגים כ-5,000 אובייקטים, שמכילים כ-20,000 פוליגונים. הביצועים לא נפגעו בכלל.
poly2tri
השלבים נוצרים על סמך פרטי המתאר שהתקבלו מהשרת, ולאחר מכן הופכים לפוליגונים באמצעות JavaScript. תהליך יצירת המשולש (Triangulation) הוא חלק מרכזי בתהליך הזה, והוא מיושם בצורה גרועה על ידי three.js ובדרך כלל נכשל. לכן, החלטתי לשלב בעצמי ספריית טריאנגולציה אחרת שנקראת poly2tri. מסתבר ש-three.js ניסתה לעשות את אותו הדבר בעבר, אז הצלחתי להפעיל את זה פשוט על ידי הוספת הערה לחלק ממנו. כתוצאה מכך, מספר השגיאות ירד באופן משמעותי, וכך ניתן להוסיף עוד שלבים שניתן לשחק בהם. השגיאה הזו מופיעה מדי פעם, ומסיבה כלשהי, הספרייה poly2tri מטפלת בשגיאות על ידי שליחת התראות, אז שיניתי אותה כדי שהיא תשליך חריגות במקום זאת.
בתרשים שלמעלה מוצגת חלוקת הקו הכחול לטריאנגלים ויצירת הפוליגונים האדומים.
סינון אניסוטרופי
מאחר שמיפוי MIP איזוטרופי רגיל מצמצם את התמונות גם בציר האופקי וגם בציר האנכי, הצגת פוליגונים מזוויות משוכות גורמת לכך שהטקסטורות בקצה הרחוק של שלבי World Wide Maze ייראו כמו טקסטורות מוארכות אופקית ברזולוציה נמוכה. התמונה בפינה השמאלית העליונה של דף Wikipedia הזה היא דוגמה טובה לכך. בפועל, נדרשת רזולוציה אופקית גבוהה יותר, ו-WebGL (OpenGL) פותר את הבעיה באמצעות שיטה שנקראת סינון אנאיזוטרופי. ב-three.js, הגדרת ערך גדול מ-1 עבור THREE.Texture.anisotropy
מפעילה סינון אניסוטרופי. עם זאת, התכונה הזו היא תוסף, ויכול להיות שלא כל המעבדים הגרפיים יתמכו בה.
אופטימיזציה
כפי שצוין גם במאמר שיטות מומלצות ל-WebGL, הדרך החשובה ביותר לשפר את הביצועים של WebGL (OpenGL) היא לצמצם את מספר הקריאות לציור. במהלך הפיתוח הראשוני של World Wide Maze, כל האיים, הגשרים והמגדלים במשחק היו אובייקטים נפרדים. לפעמים התוצאה הייתה יותר מ-2,000 קריאות לציור, מה שהקשה על שימוש בשלבים מורכבים. עם זאת, אחרי שארזתי אובייקטים מאותו סוג במערך אחד, מספר הקריאות לציור ירד ל-50 בערך, והביצועים השתפרו באופן משמעותי.
השתמשתי בתכונה של מעקב ב-Chrome כדי לבצע אופטימיזציה נוספת. כלי הניתוח הכלולים בכלים למפתחים של Chrome יכולים לקבוע במידה מסוימת את זמני העיבוד הכוללים של שיטות, אבל באמצעות מעקב אפשר לדעת בדיוק כמה זמן נמשך כל חלק, עד אלפית השנייה. במאמר הזה מוסבר איך משתמשים במעקב.
למעלה מוצגות תוצאות המעקב של יצירת מפות סביבה לשתקוף הכדור. הוספת console.time
ו-console.timeEnd
למיקומים שנראים רלוונטיים ב-three.js מניבה תרשים שנראה כך. הזמן זורם משמאל לימין, וכל שכבה היא משהו כמו סטאק קריאות. עריכת עץ של console.time בתוך console.time
מאפשרת לבצע מדידות נוספות. התרשים העליון מייצג את התקופה שלפני האופטימיזציה והתרשים התחתון מייצג את התקופה שלאחר האופטימיזציה. כפי שמוצג בתרשים העליון, ה-updateMatrix
(למרות שהמילה קטועה) הופעל לכל אחד מהרנדרים 0 עד 5 במהלך האופטימיזציה המקדימות. שיניתי את הקוד כך שיופעל רק פעם אחת, כי התהליך הזה נדרש רק כשהאובייקטים משנים את המיקום או הכיוון.
כמובן שתהליך המעקב תופס משאבים, כך שהוספה מוגזמת של console.time
עלולה לגרום לסטייה משמעותית מהביצועים בפועל, וכך יהיה קשה לזהות אזורים לאופטימיזציה.
כלי לשיפור הביצועים
בגלל אופי האינטרנט, סביר להניח שהמשחק יפעל במערכות עם מפרטים שונים מאוד. בסרטון Find Your Way to Oz, שיצא בתחילת פברואר, נעשה שימוש בכיתה שנקראת IFLAutomaticPerformanceAdjust
כדי לצמצם את האפקטים בהתאם לתנודות בשיעור הפריימים, וכך להבטיח שההפעלה תהיה חלקה. המשחק World Wide Maze מבוסס על אותה כיתה IFLAutomaticPerformanceAdjust
, והוא מצמצם את האפקטים לפי הסדר הבא כדי שהמשחקיות תהיה חלקה ככל האפשר:
- אם קצב הפריימים יורד מתחת ל-45fps, מפות הסביבה יפסיקו להתעדכן.
- אם הוא עדיין נמוך מ-40fps, רזולוציית הרינדור תופחת ל-70% (50% מיחס השטח).
- אם עדיין לא מתקבלת קצב פריימים של 40fps, המערכת מסירה את FXAA (הפחתת aliasing).
- אם עדיין לא תגיעו ל-30fps, אפקטים של זוהר יוסרו.
דליפת זיכרון
הסרת אובייקטים בצורה מסודרת היא מטלה מסוימת ב-three.js. אבל אם לא נעשה זאת, ברור שיהיו דליפות זיכרון, ולכן פיתחתי את השיטה הבאה. @renderer
מתייחס ל-THREE.WebGLRenderer
. (בגרסה האחרונה של three.js נעשה שימוש בשיטה שונה במקצת של ביטול הקצאה, ולכן סביר להניח שהקוד הזה לא יפעל איתה כפי שהוא).
destructObjects: (object) =>
switch true
when object instanceof THREE.Object3D
@destructObjects(child) for child in object.children
object.parent?.remove(object)
object.deallocate()
object.geometry?.deallocate()
@renderer.deallocateObject(object)
object.destruct?(this)
when object instanceof THREE.Material
object.deallocate()
@renderer.deallocateMaterial(object)
when object instanceof THREE.Texture
object.deallocate()
@renderer.deallocateTexture(object)
when object instanceof THREE.EffectComposer
@destructObjects(object.copyPass.material)
object.passes.forEach (pass) =>
@destructObjects(pass.material) if pass.material
@renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
@renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
@renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2
HTML
לדעתי, הדבר הטוב ביותר באפליקציית WebGL הוא היכולת לעצב את פריסת הדף ב-HTML. פיתוח ממשקים דו-ממדיים כמו תצוגות של ניקוד או טקסט ב-Flash או ב-openFrameworks (OpenGL) הוא קצת קשה. ל-Flash יש לפחות סביבת פיתוח משולבת (IDE), אבל openFrameworks קשה אם לא רגילים אליו (שימוש במשהו כמו Cocos2D עשוי להקל על התהליך). לעומת זאת, HTML מאפשר שליטה מדויקת בכל היבטי העיצוב של הקצה הקדמי באמצעות CSS, בדיוק כמו בבניית אתרים. אי אפשר ליצור אפקטים מורכבים כמו חלקיקים שמתעבים ללוגו, אבל אפשר ליצור אפקטים תלת-ממדיים מסוימים במסגרת היכולות של טרנספורמציות CSS. אפקטים של טקסט ב-World Wide Maze עם הכיתוב 'GOAL' ו-'TIME IS UP' מונפשים באמצעות שינוי קנה מידה במעבר CSS (הוטמעו באמצעות Transit). (ברור שההדרגות ברקע מתבצעות באמצעות WebGL).
לכל דף במשחק (הכותרת, RESULT, RANKING וכו') יש קובץ HTML משלו, וכשהם נטענים כתבניות, מתבצעת קריאה ל-$(document.body).append()
עם הערכים המתאימים בזמן המתאים. אחת הבעיות הייתה שלא ניתן להגדיר אירועי עכבר ומקלדת לפני הוספה, ולכן הניסיון להפעיל את el.click (e) -> console.log(e)
לפני ההוספה לא עבד.
אינטרנציונליזציה (i18n)
עבודה ב-HTML הייתה נוחה גם ליצירת הגרסה באנגלית. כדי לענות על הצרכים שלי בנושאי בינלאומיות, בחרתי להשתמש ב-i18next, ספריית i18n לאינטרנט, שבה הצלחתי להשתמש כפי שהיא, ללא שינוי.
עריכת הטקסט במשחק והתרגום שלו בוצעו בגיליון אלקטרוני ב-Google Docs. מכיוון ש-i18next מחייב קובצי JSON, ייצאתי את הגיליונות האלקטרוניים לקובץ TSV ולאחר מכן המרתי אותם באמצעות ממיר מותאם אישית. ביצעתי הרבה עדכונים ממש לפני הפרסום, כך שתהליך הייצוא האוטומטי מגיליון אלקטרוני ב-Google Docs היה מקל עליי מאוד.
גם תכונה התרגום האוטומטי של Chrome פועלת באופן תקין, כי הדפים בנויים ב-HTML. עם זאת, לפעמים המערכת לא מצליחה לזהות את השפה בצורה נכונה, ובמקום זאת היא מזהה שפה שונה לגמרי (למשל, וייטנאמית), ולכן התכונה הזו מושבתת כרגע. (אפשר להשבית אותו באמצעות מטא תגים).
RequireJS
בחרתי ב-RequireJS כמערכת המודולים של JavaScript. 10,000 שורות קוד המקור של המשחק מחולקות לכ-60 כיתות (= קובצי coffee) ומקובצות לקובצי js נפרדים. RequireJS טוען את הקבצים הנפרדים האלה בסדר המתאים על סמך יחסי התלות ביניהם.
define ->
class Hoge
hogeMethod: ->
אפשר להשתמש בכיתה שהוגדרה למעלה (hoge.coffee) באופן הבא:
define ['hoge'], (Hoge) ->
class Moge
constructor: ->
@hoge = new Hoge()
@hoge.hogeMethod()
כדי שהקוד יפעל, צריך לטעון את הקובץ hoge.js לפני moge.js, ומכיוון ש-'hoge' מוגדר כארגומנטים הראשון של 'define', הקובץ hoge.js תמיד נטען קודם (הקריאה חוזרת מתבצעת אחרי שהטעינה של הקובץ hoge.js מסתיימת). המנגנון הזה נקרא AMD, וניתן להשתמש בכל ספרייה של צד שלישי לאותו סוג של קריאה חוזרת (callback), כל עוד היא תומכת ב-AMD. גם אלה שלא (למשל, three.js) יפעלו באופן דומה, כל עוד היחסי התלות יצוינו מראש.
התהליך דומה לייבוא של AS3, כך שלא אמורה להיות בעיה. אם בסופו של דבר יהיו לכם יותר קבצים תלויים, זה פתרון אפשרי.
r.js
RequireJS כולל אופטימיזטור שנקרא r.js. הפקודה הזו אוספת את קובץ ה-js הראשי עם כל קובצי ה-js התלויים בקובץ אחד, ולאחר מכן מבצעת לו אופטימיזציה באמצעות UglifyJS (או Closure Compiler). כך מצמצמים את מספר הקבצים ואת כמות הנתונים הכוללת שהדפדפן צריך לטעון. גודל הקובץ הכולל של JavaScript ב-World Wide Maze הוא כ-2MB, וניתן לצמצם אותו לכ-1MB באמצעות אופטימיזציה של r.js. אם אפשר יהיה להפיץ את המשחק באמצעות gzip, הגודל יצומצם ל-250KB. (ב-GAE יש בעיה שלא מאפשרת העברה של קובצי gzip בגודל 1MB או יותר, לכן המשחק מופץ כרגע ללא דחיסה כ-1MB של טקסט פשוט).
הכלי ליצירת שלבים
נתוני השלבים נוצרים באופן הבא, והפעולה מתבצעת כולה בשרת GCE בארה"ב:
- כתובת ה-URL של האתר שרוצים להפוך לשלב נשלחת באמצעות WebSocket.
- PhantomJS מצלמת צילום מסך, ומאחזרת את המיקומים של תגי ה-div ו-img ומפיקה אותם בפורמט JSON.
- על סמך צילום המסך משלב 2 ונתוני המיקום של רכיבי ה-HTML, תוכנית מותאמת אישית ב-C++ (OpenCV, Boost) מוחקת אזורים מיותרים, יוצרת איים, מחברת את האיים באמצעות גשרים, מחשבת את המיקומים של מחסומי ההגנה והפריטים, מגדירה את נקודת היעד וכו'. התוצאות מועברות בפורמט JSON וחוזרות לדפדפן.
PhantomJS
PhantomJS הוא דפדפן שלא נדרש לו מסך. הוא יכול לטעון דפי אינטרנט בלי לפתוח חלונות, כך שניתן להשתמש בו בבדיקות אוטומטיות או כדי לצלם צילומי מסך בצד השרת. מנוע הדפדפן שלו הוא WebKit, אותו מנוע שבו נעשה שימוש ב-Chrome וב-Safari, כך שהפריסה ותוצאות ההפעלה של JavaScript הן כמעט זהות לאלה של דפדפנים רגילים.
ב-PhantomJS, משתמשים ב-JavaScript או ב-CoffeeScript כדי לכתוב את התהליכים שרוצים לבצע. קל מאוד לצלם צילומי מסך, כפי שמתואר בדוגמה הזו. עבדתי בשרת Linux (CentOS), ולכן נדרשתי להתקין גופנים להצגת יפנית (M+ FONTS). גם במקרה כזה, העיבוד של הגופן שונה מזה של Windows או macOS, כך שאותו גופן יכול להיראות שונה במכונות אחרות (אבל ההבדל הוא מינימלי).
אחזור המיקומים של תגי img ו-div מתבצע בעיקרון באותו אופן כמו בדפים רגילים. אפשר גם להשתמש ב-jQuery ללא בעיות.
stage_builder
בהתחלה חשבתי להשתמש בגישה שמבוססת יותר על DOM כדי ליצור שלבים (בדומה ל-Firefox 3D Inspector) וניסיתי לבצע ניתוח DOM ב-PhantomJS. אבל בסופו של דבר, בחרתי בגישה של עיבוד תמונה. לשם כך כתבתי תוכנית C++ שמשתמשת ב-OpenCV וב-Boost שנקראת 'stage_builder'. הוא מבצע את הפעולות הבאות:
- טעינת צילום המסך וקובצי ה-JSON.
- המרת תמונות וטקסט ל'איים'.
- יוצרת גשרים לחיבור האיים.
- הסרת גשרים מיותרים כדי ליצור מבוך.
- פריטים גדולים.
- מקום לפריטים קטנים.
- מיקומי מחסומי הגנה.
- הפונקציה מפיקה נתוני מיקום בפורמט JSON.
כל אחד מהשלבים מפורט בהמשך.
טעינת צילום המסך וקבצי ה-JSON
כדי לטעון צילומי מסך, משתמשים ב-cv::imread
הרגיל. בדקתי כמה ספריות לקובצי JSON, אבל picojson נראתה לי הכי קלה לעבודה.
המרת תמונות וטקסט ל'איים'
התמונה שלמעלה היא צילום מסך של קטע החדשות באתר aid-dcc.com (לוחצים כדי להציג את התמונה בגודל המקורי). צריך להמיר את התמונות ואת רכיבי הטקסט לאיים. כדי לבודד את הקטעים האלה, צריך למחוק את צבע הרקע הלבן – כלומר את הצבע הנפוץ ביותר בצילום המסך. כך זה נראה בסיום:
החלקים הלבנים הם האיים הפוטנציאליים.
הטקסט דק וחדה מדי, לכן נעביד אותו בעזרת cv::dilate
, cv::GaussianBlur
ו-cv::threshold
. גם תוכן התמונה חסר, לכן נמלא את האזורים האלה בלבן, על סמך נתוני התג img שיוצאים מ-PhantomJS. התמונה שמתקבלת נראית כך:
הטקסט נראה עכשיו בצורה נוחה יותר, וכל תמונה היא אי בפני עצמו.
יצירת גשרים לחיבור האיים
כשהאיים מוכנים, הם מחוברים באמצעות גשרים. כל אי מחפש איים סמוכים שמשמאל, מימין, למעלה ולמטה, ואז מחבר גשר לנקודה הקרובה ביותר באי הקרוב ביותר. התוצאה נראית בערך כך:
הסרת גשרים מיותרים כדי ליצור מבוך
אם משאירים את כל הגשרים, קל מדי לנווט בזירה, ולכן צריך להסיר חלק מהם כדי ליצור מבוך. אי אחד (למשל, זה שבפינה הימנית העליונה) נבחר כנקודת התחלה, וכל הגשרים שמחברים לאי הזה נמחקים מלבד גשר אחד (שנבחר באקראי). לאחר מכן, מבצעים את אותה פעולה לגבי האי הבא שמחובר באמצעות הגשר שנותר. כשהנתיב מגיע לדרך ללא מוצא או מוביל חזרה לאי שבו כבר ביקרתם, הוא חוזר אחורה לנקודה שמאפשרת גישה לאי חדש. המבוך יושלם אחרי שכל האיים יעברו עיבוד באופן הזה.
הצבת פריטים גדולים
לכל אי מוסיפים פריט גדול אחד או יותר, בהתאם למימדיו, מהנקודות הכי רחוקות מקצוות האיים. הנקודות האלה מוצגות באדום בהמשך, אבל לא ברור מאוד מהן:
מכל הנקודות האפשריות האלה, הנקודה שבפינה הימנית העליונה מוגדרת כנקודת ההתחלה (מעגל אדום), הנקודה שבפינה הימנית התחתונה מוגדרת כיעד (מעגל ירוק) ומתוך שאר הנקודות נבחרות עד שש נקודות למיקום של פריטים גדולים (מעגל סגול).
הצבת פריטים קטנים
מספרים מתאימים של פריטים קטנים ממוקמים לאורך קווים במרחקים קבועים מקצוות האי. בתמונה שלמעלה (לא מ-aid-dcc.com) מוצגות קווים של מיקומי מודעות שהוגדרו מראש באפור, עם סטייה ומרווחים קבועים מקצוות האי. הנקודות האדומות מציינות את המיקום של הפריטים הקטנים. התמונה הזו היא מגרסה באמצע הפיתוח, ולכן הפריטים מסודרים בשורות ישרות, אבל בגרסה הסופית הפריטים מפוזרים בצורה קצת לא סדירה משני צידי הקווים האפורים.
הצבת אמצעי הגנה
בעיקרון, מחסומי ההגנה ממוקמים לאורך הגבולות החיצוניים של האיים, אבל צריך לחתוך אותם בגשרים כדי לאפשר גישה. ספריית הגיאומטריה של Boost הייתה שימושית לכך, והיא הפכה את החישובים הגיאומטריים לפשוטים יותר, למשל, קביעת המיקום שבו נתוני גבולות האי מצטלבים עם הקווים משני צידי הגשר.
הקווים הירוקים שמציינים את קווי המתאר של האיים הם מחסומי ההגנה. יכול להיות שיהיה קשה לראות בתמונה הזו, אבל אין קווים ירוקים במקומות שבהם נמצאים הגשרים. זהו קובץ האימג' הסופי שמשמש לניפוי באגים, שבו כל האובייקטים שצריך להפיק כפלט בפורמט JSON כלולים. הנקודות הכחולות הבהירות הן פריטים קטנים, והנקודות האפורות הן נקודות הצעה להפעלה מחדש. כשהכדור נופל לים, המשחק ממשיך מנקודת ההתחלה הקרובה ביותר. נקודות ההתחלה מחדש מסודרות בערך באותו אופן שבו מסודרים פריטים קטנים, במרווחי זמן קבועים ובמרחק קבוע מקצה האי.
הפקת נתוני מיקום בפורמט JSON
השתמשתי ב-picojson גם לפלט. הוא כותב את הנתונים לפלט הסטנדרטי, שמתקבל לאחר מכן על ידי מבצע הקריאה החוזרת (Node.js).
יצירת תוכנית C++ ב-Mac להרצה ב-Linux
המשחק פותח ב-Mac ופורס ב-Linux, אבל מאחר ש-OpenCV ו-Boost קיימים בשתי מערכות ההפעלה, הפיתוח עצמו לא היה קשה אחרי שהגדירו את סביבת ה-compile. השתמשתי בכלים של שורת הפקודה ב-Xcode כדי לנפות באגים ב-build ב-Mac, ואז יצרתי קובץ תצורה באמצעות automake/autoconf כדי שאפשר יהיה לקמפל את ה-build ב-Linux. לאחר מכן, פשוט השתמשתי ב-"configure && make" ב-Linux כדי ליצור את קובץ ההפעלה. נתקלתי בכמה באגים ספציפיים ל-Linux בגלל הבדלים בגרסאות של המהדר, אבל הצלחתי לפתור אותם בקלות יחסית באמצעות gdb.
סיכום
אפשר ליצור משחק כזה באמצעות Flash או Unity, ויש לכך יתרונות רבים. עם זאת, בגרסה הזו לא נדרשים יישומי פלאגין, ותכונות הפריסה של HTML5 + CSS3 הוכיחו את עצמן כחזקות מאוד. חשוב מאוד להשתמש בכלים המתאימים לכל משימה. באופן אישי, הופתעתי כמה טוב המשחק יצא, למרות שהוא נוצר כולו ב-HTML5. למרות שהוא עדיין חסר בתחומים רבים, אני מחכה לראות איך הוא יתפתח בעתיד.