שלום! שמי Michael Chang ואני עובד בצוות Data Arts ב-Google. לאחרונה השלמנו את 100,000 כוכבים, ניסוי ב-Chrome שמציג גרפיות של כוכבים שנמצאים בקרבת מקום. הפרויקט נוצר באמצעות THREE.js ו-CSS3D. בניתוח המקרה הזה אציג את תהליך הגילוי, אשתף כמה שיטות תכנות ואסיים בכמה מחשבות לשיפורים עתידיים.
הנושאים שנדון בהם כאן יהיו רחבים למדי, וידרשו ידע מסוים ב-THREE.js, אבל אני מקווה שעדיין תוכלו ליהנות מהם כניתוח טכני שלאחר המוות. אפשר לדלג לאזור שמעניין אותך באמצעות לחצן תוכן העניינים שמשמאל. קודם אציג את החלק של העיבוד בפרויקט, אחר כך את ניהול השיזרים ולבסוף אראה איך משתמשים בתוויות טקסט של CSS בשילוב עם WebGL.
חקר החלל
זמן קצר אחרי שסיימנו את Small Arms Globe, התנסיתי בהדגמה של חלקיקים ב-THREE.js עם עומק שדה. שמתי לב שאפשר לשנות את 'המידה' של הסצינה המתורגמת על ידי שינוי עוצמת האפקט שהוחל. כשאפקט עומק השדה היה קיצוני במיוחד, אובייקטים רחוקים הפכו למטושטשים מאוד, בדומה לאופן שבו פועלת צילום טилт-שיפט, שמעניק את האשליה של צפייה בסצנה מיקרוסקופית. לעומת זאת, אם מפחיתים את עוצמת האפקט, נראה כאילו אתם מביטים אל תוך המרחב העמוק.
התחלתי לחפש נתונים שאוכל להשתמש בהם כדי להחדיר מיקומי חלקיקים. הדרך הזו הובילה אותי למסד הנתונים HYG של astronexus.com, אוסף של שלושת מקורות הנתונים (Hipparcos, Yale Bright Star Catalog ו-Gliese/Jahreiss Catalog) עם קואורדינטות קרטוזיות xyz מחושבות מראש. בואו נתחיל!
לקח לי בערך שעה למצוא פתרון שמציב את נתוני הכוכבים במרחב תלת-ממדי. יש בדיוק 119,617 כוכבים בקבוצת הנתונים, כך שאפשר לייצג כל כוכב בחלקיק בלי בעיה ב-GPU מודרני. יש גם 87 כוכבים שזוהו בנפרד, לכן יצרתי שכבת-על של סמנים ב-CSS באמצעות אותה טכניקה שתיארתי ב-Small Arms Globe.
באותו זמן סיימתי את הסדרה Mass Effect. במשחק, השחקן מוזמן לחקור את הגלקסיה ולסרוק כוכבים שונים ולקרוא על ההיסטוריה הבדיונית שלהם, שנשמעת כמו היסטוריה מוויקיפדיה: אילו מינים שגשגו בכוכב, ההיסטוריה הגיאולוגית שלו וכו'.
בהתחשב במגוון הנתונים הקיימים על כוכבים, אפשר להציג מידע אמיתי על הגלקסיה באותו אופן. המטרה הסופית של הפרויקט היא להחיות את הנתונים האלה, לאפשר לצופים לחקור את הגלקסיה בסגנון Mass Effect, ללמוד על כוכבים ועל ההפצה שלהם, ובתקווה לעורר תחושה של פליאה וסקרנות לגבי החלל. סוף סוף!
לפני שאמשיך את המחקר הזה, רצוי לציין שאני לא אסטרונום, וזו עבודת מחקר של חובב, עם כמה עצות ממומחים חיצוניים. הפרויקט הזה צריך להיחשב כפרשנות של האמן למרחב.
בניית גלקסיה
התכנון שלי היה ליצור באופן פרוצדורלי מודל של הגלקסיה שיוכל להציב את נתוני הכוכבים בהקשר – ובתקווה לתת תצוגה מדהימה של המיקום שלנו בדרך החלב.
כדי ליצור את שביל החלב, יצרתי 100,000 חלקיקים והנחתי אותם בספירלה, על ידי הדמיה של האופן שבו הזרוע הגלקטית נוצרת. לא דאגתי יותר מדי לגבי הפרטים של היווצרות הזרוע הספירלית, כי זה יהיה מודל ייצוג ולא מודל מתמטי. עם זאת, ניסיתי להגיע למספר הנכון של זרועות הספיראל, ולסובב אותן ב'כיוון הנכון'.
בגרסאות מאוחרות יותר של מודל שביל החלב, הפחתתי את הדגש על השימוש בחלקיקים ובמקום זאת השתמשתי בתמונה רגילה של גלקסיה כדי ללוות את החלקיקים, בתקווה שהיא תיתן לו מראה יותר צילום. התמונה בפועל היא של גלקסיית NGC 1232 בצורתה הספירלית, שנמצאת במרחק של כ-70 מיליון שנות אור מאיתנו. התמונה עוברת מניפולציה כדי להיראות כמו שביל החלב.
כבר בשלב מוקדם החלטתי לייצג יחידת GL אחת, בעצם פיקסל בתלת-ממד, כשנת אור אחת – נוהל שהאחד את המיקום של כל מה שמוצג באופן חזותי, ולצערי גרם לי לבעיות רציניות של דיוק בשלב מאוחר יותר.
החלטתי גם לסובב את כל הסצנה במקום להזיז את המצלמה, כמו שעשיתי בכמה פרויקטים אחרים. אחד היתרונות הוא שכל הפריטים ממוקמים על 'מערכות כוונון', כך שגרירה של העכבר שמאלה וימינה מסובבת את האובייקט הרלוונטי, אבל כדי להתקרב, צריך רק לשנות את הערך של camera.position.z.
גם שדה הראייה (FOV) של המצלמה הוא דינמי. ככל שמתרחקים, שדה הראייה מתרחב ומאפשר לראות יותר ויותר מהגלקסיה. ההפך קורה כשמתקרבים לכוכב, שדה הראייה מצומצם. כך המצלמה יכולה לצלם דברים זעירים (בהשוואה לגלקסיה) על ידי צמצום שדה הראייה למשהו כמו משקפת אלוהית, בלי שתצטרכו להתמודד עם בעיות של חיתוך בקרבת המטוס.
מכאן הצלחתי 'להציב' את השמש במספר יחידות ממרחק הליבה הגלקטית. הצלחתי גם להציג באופן חזותי את הגודל היחסי של מערכת השמש על ידי מיפוי הרדיוס של מדרון קויפֶר (בסופו של דבר בחרתי להציג באופן חזותי את ענן אורט). בתוך מודל מערכת השמש הזה, הצלחתי גם להציג באופן חזותי מסלול פשוט של כדור הארץ, ואת הרדיוס בפועל של השמש בהשוואה.
היה קשה ליצור רינדור של השמש. נאלצתי להשתמש בכל טכניקות הגרפיקה בזמן אמת שידעתי. פני השטח של השמש הם קצף חם של פלזמה, והם צריכים להפיק פולסים ולשנות את צורתם לאורך זמן. הדבר נעשה באמצעות טקסטורת בייטמאפ של תמונה אינפרה-אדומה של פני השמש. שדה הטקסטורה מבצע חיפוש צבע על סמך סולם האפורים של הטקסטורה הזו, ומבצע חיפוש ברמפת צבעים נפרדת. כשהחיפוש הזה מוסט לאורך זמן, נוצר העיוות הזה שנראה כמו לבה.
שיטה דומה שימשה ליצירת ההילה של השמש, אלא שהיא הייתה כרטיס ספריי שטוח שתמיד פונה למצלמה באמצעות https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.
התפרצויות השמש נוצרו באמצעות שגיאות קודקוד וחלקיקים שהוחלו על טוריוס, שסובב ממש ליד קצה פני השטח של השמש. ל-vertex shader יש פונקציית רעש שגורמת לו לנוע בצורה של כתמים.
בשלב הזה התחלתי להיתקל בבעיות של מאבקי z בגלל דיוק GL. כל המשתנים של הדיוק מוגדרים מראש ב-THREE.js, כך שלא יכולתי לשפר את הדיוק באופן ריאליסטי בלי להשקיע כמות עצומה של עבודה. בעיות הדיוק לא היו חמורות כל כך ליד המקור. עם זאת, כשהתחלתי ליצור מודלים של מערכות כוכבים אחרות, הבעיה הזו הפכה לבעייתית.
השתמשתי בכמה שיטות כדי לצמצם את הבעיה של מאבקי z. Material.polygonoffset של THREE הוא מאפיין שמאפשר לעיבוד פוליגונים במיקום נתפס אחר (עד כמה שאני מבין). השתמשו בכך כדי לאלץ את מישור ההילה להופיע תמיד מעל לפני השמש. מתחת לכך, עיבדנו 'הילה' של השמש כדי ליצור קרני אור חדות שזזות מהספירה.
בעיה אחרת שקשורה לדיוק היא שהמודלים של הכוכבים מתחילים לרעוד כשמתקרבים לזירה. כדי לפתור את הבעיה, נאלצתי "לאפס" את סיבוב הסצנה ולסובב בנפרד את מודל הכוכב ואת מפת הסביבה כדי ליצור את האשליה שאתם מקיפים את הכוכב.
יצירת Lens Flare
אני מרגיש שאני יכול להשתמש ב-lensflare בצורה מוגזמת בתמונות של מרחבים. THREE.LensFlare עושה את העבודה, כל מה שצריך לעשות הוא להוסיף כמה משושים אנמורפיים וטוויסט של JJ Abrams. בקטע הקוד הבא מוסבר איך ליצור אותם בסצנה.
// This function returns a lesnflare THREE object to be .add()ed to the scene graph
function addLensFlare(x,y,z, size, overrideImage){
var flareColor = new THREE.Color( 0xffffff );
lensFlare = new THREE.LensFlare( overrideImage, 700, 0.0, THREE.AdditiveBlending, flareColor );
// we're going to be using multiple sub-lens-flare artifacts, each with a different size
lensFlare.add( textureFlare1, 4096, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
// and run each through a function below
lensFlare.customUpdateCallback = lensFlareUpdateCallback;
lensFlare.position = new THREE.Vector3(x,y,z);
lensFlare.size = size ? size : 16000 ;
return lensFlare;
}
// this function will operate over each lensflare artifact, moving them around the screen
function lensFlareUpdateCallback( object ) {
var f, fl = this.lensFlares.length;
var flare;
var vecX = -this.positionScreen.x _ 2;
var vecY = -this.positionScreen.y _ 2;
var size = object.size ? object.size : 16000;
var camDistance = camera.position.length();
for( f = 0; f < fl; f ++ ) {
flare = this.lensFlares[ f ];
flare.x = this.positionScreen.x + vecX * flare.distance;
flare.y = this.positionScreen.y + vecY * flare.distance;
flare.scale = size / camDistance;
flare.rotation = 0;
}
}
דרך קלה לגלילה של טקסטורות
ל'מישור ההכוונה המרחבית', נוצרה עצם ענק מסוג THREE.CylinderGeometry() שמרכזו בשמש. כדי ליצור את 'גל האור' שמתפשט החוצה, שיניתי את ההיסט של המרקם לאורך זמן באופן הבא:
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
הוא הטקסטורה ששייכת לחומר, שמקבלת פונקציית onUpdate שאפשר לשכתב. הגדרת ההיסט גורמת ל'גלילה' של המרקם לאורך הציר הזה, ושליחת ספאם של needsUpdate = true תאלץ את ההתנהגות הזו לחזור על עצמה בלולאה.
שימוש ברמפות צבעים
לכל כוכב יש צבע שונה על סמך 'מדד צבע' שהאסטרונומים הקצינו לו. באופן כללי, כוכבים אדומים קרים יותר וכוכבים כחולים/סגולים חמים יותר. בטווח הצבעים הזה יש פס של לבן וצבעים כתומים בינוניים.
כשעיבדתי את התמונה של הכוכבים, רציתי לתת לכל חלקיק צבע משלו על סמך הנתונים האלה. כדי לעשות זאת, השתמשו ב'מאפיינים' שניתנים לחומר ה-shader שחלה על החלקיקים.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
מילוי המערך colorIndex ייתן לכל חלקיק צבע ייחודי בשיידר. בדרך כלל מעבירים vec3 של צבע, אבל במקרה הזה מעבירים float לחיפוש הסופי של הרמפה של הצבעים.
רמפת הצבעים נראתה כך, אבל הייתי צריך לגשת לנתוני הצבעים של הבימפס מ-JavaScript. כדי לעשות זאת, קודם כל העליתי את התמונה ל-DOM, ציירתי אותה ברכיב בד, ואז ניגשתי ל-bitmap של הבד.
// make a blank canvas, sized to the image, in this case gradientImage is a dom image element
gradientCanvas = document.createElement('canvas');
gradientCanvas.width = gradientImage.width;
gradientCanvas.height = gradientImage.height;
// draw the image
gradientCanvas.getContext('2d').drawImage( gradientImage, 0, 0, gradientImage.width, gradientImage.height );
// a function to grab the pixel color based on a normalized percentage value
gradientCanvas.getColor = function( percentage ){
return this.getContext('2d').getImageData(percentage \* gradientImage.width,0, 1, 1).data;
}
לאחר מכן, אותה שיטה משמשת לצביעת כוכבים ספציפיים בתצוגת מודל הכוכבים.
עבודה עם שגיאות ב-Shader
במהלך הפרויקט הבנתי שאצטרך לכתוב יותר ויותר שידרים כדי ליצור את כל האפקטים החזותיים. לצורך כך כתבתי מעבד Shaders בהתאמה אישית כי נמאס לי ש-Shaders יהיו ב-index.html.
// list of shaders we'll load
var shaderList = ['shaders/starsurface', 'shaders/starhalo', 'shaders/starflare', 'shaders/galacticstars', /*...etc...*/];
// a small util to pre-fetch all shaders and put them in a data structure (replacing the list above)
function loadShaders( list, callback ){
var shaders = {};
var expectedFiles = list.length \* 2;
var loadedFiles = 0;
function makeCallback( name, type ){
return function(data){
if( shaders[name] === undefined ){
shaders[name] = {};
}
shaders[name][type] = data;
// check if done
loadedFiles++;
if( loadedFiles == expectedFiles ){
callback( shaders );
}
};
}
for( var i=0; i<list.length; i++ ){
var vertexShaderFile = list[i] + '.vsh';
var fragmentShaderFile = list[i] + '.fsh';
// find the filename, use it as the identifier
var splitted = list[i].split('/');
var shaderName = splitted[splitted.length-1];
$(document).load( vertexShaderFile, makeCallback(shaderName, 'vertex') );
$(document).load( fragmentShaderFile, makeCallback(shaderName, 'fragment') );
}
}
הפונקציה loadShaders() מקבלת רשימה של שמות קבצים של שגיאות (צפוי .fsh לשגיאות של פלגים ו- .vsh לשגיאות של צמתים), מנסה לטעון את הנתונים שלהן ואז מחליפה את הרשימה באובייקטים. התוצאה הסופית היא שאפשר להעביר לה את השיזרים שלכם ב-uniforms של THREE.js כך:
var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});
יכול להיות שיכולתי להשתמש ב-require.js, אבל לשם כך הייתי צריך לבצע איחוד קוד מסוים. הפתרון הזה קל יותר, אבל לדעתי אפשר לשפר אותו, אולי אפילו כתוסף ל-THREE.js. אם יש לך הצעות או דרכים לשפר את התהליך, אשמח לשמוע ממך.
תוויות טקסט ב-CSS מעל THREE.js
בפרויקט האחרון שלנו, Small Arms Globe, התנסיתי בהצגת תוויות טקסט מעל סצנה של THREE.js. השיטה שבה השתמשתי מחשבת את המיקום המוחלט של המודל שבו אני רוצה שהטקסט יופיע, לאחר מכן פותרת את מיקום המסך באמצעות THREE.Projector(), ובסוף משתמשת ב-CSS "top" ו-"left" כדי למקם את רכיבי ה-CSS במיקום הרצוי.
בגרסאות המוקדמות של הפרויקט הזה השתמשתי באותה טכניקה, אבל כבר רציתי לנסות את השיטה האחרת הזו שמתוארת על ידי Luis Cruz.
הרעיון הבסיסי: מתאימים את טרנספורמציית המטריצה של CSS3D למצלמה ולסצנה של THREE, ואז אפשר "להציב" רכיבי CSS ב-3D כאילו הם מופיעים מעל הסצנה של THREE. עם זאת, יש מגבלות לכך. לדוגמה, לא תוכלו להציג טקסט מתחת לאובייקט של THREE.js. עדיין מדובר בתהליך מהיר יותר מאשר ניסיון לבצע פריסה באמצעות מאפייני CSS מסוג 'top' ו-'left'.
כאן אפשר למצוא את הדמו (והקוד בקוד המקור). עם זאת, גיליתי שסדר המטריצה השתנה מאז ב-THREE.js. הפונקציה שעדכנתי:
/_ Fixes the difference between WebGL coordinates to CSS coordinates _/
function toCSSMatrix(threeMat4, b) {
var a = threeMat4, f;
if (b) {
f = [
a.elements[0], -a.elements[1], a.elements[2], a.elements[3],
a.elements[4], -a.elements[5], a.elements[6], a.elements[7],
a.elements[8], -a.elements[9], a.elements[10], a.elements[11],
a.elements[12], -a.elements[13], a.elements[14], a.elements[15]
];
} else {
f = [
a.elements[0], a.elements[1], a.elements[2], a.elements[3],
a.elements[4], a.elements[5], a.elements[6], a.elements[7],
a.elements[8], a.elements[9], a.elements[10], a.elements[11],
a.elements[12], a.elements[13], a.elements[14], a.elements[15]
];
}
for (var e in f) {
f[e] = epsilon(f[e]);
}
return "matrix3d(" + f.join(",") + ")";
}
מכיוון שכל התמונה עוברת טרנספורמציה, הטקסט לא מופנה יותר למצלמה. הפתרון היה להשתמש ב-THREE.Gyroscope(), שמאלץ את Object3D "לאבד" את הכיוון שעובר בירושה מהסצנה. הטכניקה הזו נקראת 'פרסום על גבי מקרנים', והיא מתאימה במיוחד לשימוש ב-Gyroscope.
הדבר הנחמד הוא שכל ה-DOM וה-CSS הרגילים עדיין עבדו, למשל אפשר להעביר את העכבר מעל תווית טקסט תלת-ממדית והיא תאיר עם צלליות.
כשהגדלתי את התצוגה, גיליתי שהשינוי בגודל הגופן גרם לבעיות במיקום. אולי זה בגלל ריווח בין האותיות וריפוי הטקסט? בעיה נוספת הייתה שהטקסט הפך לפסאודו-פיקסלים כשהגדלתם את התצוגה, כי ה-DOM renderer מתייחס לטקסט שעבר עיבוד כאל ריבוע עם טקסטורה. חשוב לזכור את זה כשמשתמשים בשיטה הזו. בדיעבד, יכולתי פשוט להשתמש בטקסט בגודל גופן ענק, ואולי זה משהו שאפשר לבדוק בעתיד. בפרויקט הזה השתמשתי גם בתווית הטקסט 'top/left' למיקום CSS, כפי שמתואר למעלה, לאלמנטים קטנים מאוד שמלווים את כוכבי הלכת במערכת השמש.
הפעלת מוזיקה והפעלה בלולאה
קטע המוזיקה ששומעים במהלך'המפה הגלקטית ' של Mass Effect נכתב על ידי המלחינים של Bioware, סמ הולייק וג'ק וול, והוא ביטא את סוג הרגש שרציתי שהמבקרים יחוו. רצינו להוסיף מוזיקה לפרויקט כי הרגשנו שהיא חלק חשוב מהאווירה, ותעזור ליצור את תחושת ההתפעלות והפליאה שניסינו להשיג.
המפיק שלנו, Valdean Klump, יצר קשר עם Sam, שהיה לו אוסף של מוזיקה מ-Mass Effect שנשארה על רצפת העריכה, והוא העניק לנו רשות להשתמש בה. שם הטראק הוא 'In a Strange Land'.
השתמשתי בתג האודיו להפעלת מוזיקה, אבל גם ב-Chrome המאפיין 'loop' לא היה מהימן – לפעמים הוא פשוט לא הצליח להפעיל את הלולאה. בסופו של דבר, הטריק של תגי האודיו הכפולים שימש לבדיקה של סיום ההפעלה ולמעבר לתג השני להפעלה. מה שהיה מאכזב הוא שהתמונה סטטית הזו לא הייתה במצב לולאה מושלם כל הזמן, אבל לצערי זה הכי טוב שיכולתי לעשות.
var musicA = document.getElementById('bgmusicA');
var musicB = document.getElementById('bgmusicB');
musicA.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playB = function(){
musicB.play();
}
// make it wait 15 seconds before playing again
setTimeout( playB, 15000 );
}, false);
musicB.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playA = function(){
musicA.play();
}
// otherwise the music will drive you insane
setTimeout( playA, 15000 );
}, false);
// okay so there's a bit of code redundancy, I admit it
musicA.play();
יש מקום לשיפור
אחרי שעבדתי עם THREE.js במשך זמן מה, הגעתי למצב שבו הנתונים שלי התערבבו יותר מדי עם הקוד. לדוגמה, כשמגדירים חומרים, טקסטורות והוראות לגיאומטריה בתוך שורה, בעצם 'יוצרים מודלים תלת-ממדיים באמצעות קוד'. זה היה ממש לא נעים, וזה תחום שבו אפשר לשפר מאוד את העבודה עם THREE.js בעתיד. לדוגמה, אפשר להגדיר את נתוני החומר בקובץ נפרד, רצוי כזה שאפשר לראות ולשנות אותו בהקשר כלשהו, ואפשר להחזיר אותו לפרויקט הראשי.
גם הקולגה שלנו, ריי מק'לור, הקדיש זמן ליצירת 'רעשי חלל' גנרטיביים מדהימים, אבל נאלצנו לחתוך אותם כי ממשק ה-API של אודיו באינטרנט לא יציב, ומקרה לפעמים קריסה של Chrome. זה חבל… אבל זה בהחלט גרם לנו לחשוב יותר על נושא האודיו בעבודות עתידיות. נכון למועד כתיבת האימייל הזה, קיבלתי הודעה על תיקון של Web Audio API, כך שיכול להיות שהבעיה נפתרה. כדאי לבדוק את זה בעתיד.
עדיין יש בעיות עם שילוב של רכיבים טיפוגרפיים עם WebGL, ואני לא בטוח ב-100% שאנחנו עושים את זה בצורה הנכונה. עדיין נראה שמדובר בהאקינג. יכול להיות שגרסאות עתידיות של THREE, עם מעבד ה-CSS החדש והמתקדם, יוכלו לשלב טוב יותר בין שני העולמות.
זיכויים
תודה ל-Aaron Koblin על ההרשאה להשתולל בפרויקט הזה. Jono Brandel על העיצוב וההטמעה המעולים של ממשק המשתמש, עיבוד הטקסט וההטמעה של הסיור. Valdean Klump על שם הפרויקט ועל כל הטקסט. Sabah Ahmed על הסדרת זכויות השימוש של טונות של נתונים ומקורות תמונות. Clem Wright על פנייה לאנשים הנכונים לצורך פרסום. Doug Fritz על מצוינות טכנית. ג'ורג' ברואר (George Brower) על לימודי JS ו-CSS. וכמובן מר Doob על THREE.js.