שלום! שמי מייקל צ'אנג ואני עובד בצוות Data Arts ב-Google. לאחרונה השלמנו את 100,000 Stars, ניסוי ב-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) של המצלמה הוא דינמי. כשמזיזים את האצבעות החוצה, שדה הראייה מתרחב וכולל יותר ויותר חלקים מהגלקסיה. ההפך הוא הנכון כשמתקרבים לכוכב – שדה הראייה מצטמצם. כך המצלמה יכולה לראות דברים קטנים מאוד (בהשוואה לגלקסיה) על ידי צמצום שדה הראייה למשהו שדומה לזכוכית מגדלת, בלי להתמודד עם בעיות של חיתוך קרוב למישור.

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

היה קשה לעבד את השמש. הייתי צריך לרמות ולהשתמש בכל הטכניקות של גרפיקה בזמן אמת שהכרתי. פני השמש הם קצף חם של פלזמה, והם משתנים עם הזמן. הסימולציה הזו בוצעה באמצעות טקסטורת מפת סיביות של תמונה באינפרא-אדום של פני השמש. ה-shader של המשטח מבצע בדיקת צבע על סמך גווני האפור של הטקסטורה הזו, ומבצע בדיקה בשיפוע צבע נפרד. כשמזיזים את החיפוש הזה לאורך זמן, נוצר עיוות שנראה כמו לבה.
טכניקה דומה שימשה ליצירת ההילה של השמש, אבל במקרה הזה מדובר בכרטיס סпрайт שטוח שתמיד פונה למצלמה באמצעות https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

ההתפרצויות הסולאריות נוצרו באמצעות הצללות של קודקודים ושל מקטעים שהוחלו על טורוס, שמסתובב ממש סביב הקצה של פני השמש. ל-vertex shader יש פונקציית רעש שגורמת לו לנוע בצורה דמוית-כתם.
כאן התחלתי לחוות בעיות של Z-fighting בגלל הדיוק של GL. כל המשתנים של הדיוק הוגדרו מראש ב-THREE.js, ולכן לא יכולתי להגדיל את הדיוק בלי להשקיע כמות עצומה של עבודה. בעיות הדיוק לא היו חמורות ליד המקור. אבל אחרי שהתחלתי ליצור מודלים של מערכות כוכבים אחרות, זה הפך לבעיה.

השתמשתי בכמה פתרונות כדי לצמצם את בעיית ה-Z-fighting. Material.polygonoffset הוא מאפיין שמאפשר לעבד פוליגונים במיקום נתפס שונה (לפי ההבנה שלי). הפרמטר הזה שימש כדי לכפות על מישור הקורונה להופיע תמיד מעל פני השמש. מתחת לזה, נוצרת "הילה" של השמש כדי ליצור קרני אור חדות שמתרחקות מהכדור.
בעיה אחרת שקשורה לדיוק היא שהמודלים של הכוכבים מתחילים לרעוד כשהסצנה מוגדלת. כדי לפתור את הבעיה, הייתי צריך לאפס את סיבוב הסצנה ולסובב בנפרד את מודל הכוכב ואת מפת הסביבה כדי ליצור את האשליה של תנועה במסלול סביב הכוכב.
יצירת Lensflare

אני מרגיש שאני יכול להשתמש בטכניקת ה-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 שאפשר להחליף. הגדרת ההיסט שלו גורמת לטקסטורה להיות 'מוזזת' לאורך הציר הזה, והגדרת spamming needsUpdate = true תגרום להתנהגות הזו לחזור על עצמה.
שימוש במעברי צבע
לכל כוכב יש צבע שונה שמבוסס על 'אינדקס צבעים' שאסטרונומים הקצו לו. באופן כללי, כוכבים אדומים קרים יותר וכוכבים כחולים או סגולים חמים יותר. בטווח הצבעים הזה יש פס של צבעים לבנים וכתומים.
כשעיבדתי את הכוכבים, רציתי לתת לכל חלקיק צבע משלו על סמך הנתונים האלה. הדרך לעשות את זה הייתה באמצעות 'מאפיינים' שניתנו לחומר ההצללה שהוחל על החלקיקים.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
אם ממלאים את המערך colorIndex, כל חלקיק מקבל את הצבע הייחודי שלו ב-Shader. בדרך כלל מעבירים וקטור צבע vec3, אבל במקרה הזה אני מעביר ערך float לחיפוש בסופו של דבר במדרג הצבעים.

הצבעים נראו כך, אבל הייתי צריך לגשת לנתוני הצבעים של מפת הביטים מ-JavaScript. כדי לעשות את זה, טענתי קודם את התמונה ל-DOM, ציירתי אותה ברכיב canvas ואז ניגשתי למפת הסיביות של ה-canvas.
// 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
במהלך הפרויקט הבנתי שאני צריך לכתוב עוד ועוד הצללות כדי להשיג את כל האפקטים החזותיים. כתבתי טוען shader מותאם אישית למטרה הזו כי נמאס לי ש-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() מקבלת רשימה של שמות קבצים של Shader (עם הסיומת .fsh ל-fragment ו- .vsh ל-vertex), מנסה לטעון את הנתונים שלהם ואז פשוט מחליפה את הרשימה באובייקטים. התוצאה הסופית נמצאת במשתני ה-uniform של THREE.js, ואפשר להעביר אליהם shaders באופן הבא:
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 במיקום הרצוי.
בגרסאות מוקדמות של הפרויקט הזה השתמשתי באותה טכניקה, אבל רציתי לנסות את השיטה האחרת הזו שתוארה על ידי לואיס קרוז.
הרעיון הבסיסי הוא להתאים את טרנספורמציית המטריצה של CSS3D למצלמה ולסצנה של THREE, וכך אפשר "למקם" רכיבי CSS בתלת-ממד כאילו הם נמצאים מעל הסצנה של 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 מתייחס לטקסט המעובד כאל מרובע עם מרקם. חשוב לזכור את זה כשמשתמשים בשיטה הזו. בדיעבד, יכולתי פשוט להשתמש בטקסט עם גודל גופן ענק, ואולי זה משהו שאחקור בעתיד. בפרויקט הזה השתמשתי גם בתוויות הטקסט של מיקום ה-CSS 'למעלה/ימינה', שתיארתי קודם, עבור אלמנטים קטנים מאוד שמלווים כוכבי לכת במערכת השמש.
הפעלה של מוזיקה בלולאה
הקטע המוזיקלי שהושמע במהלך'מפת הגלקסיה ' של Mass Effect היה של המלחינים סם הוליק וג'ק וול מ-Bioware, והוא העביר את סוג הרגש שרציתי שהמבקר יחווה. רצינו להוסיף מוזיקה לפרויקט כי הרגשנו שהיא חלק חשוב מהאווירה, ועוזרת ליצור את תחושת ההתפעלות והפליאה שרצינו להשיג.
המפיק שלנו, ואלדין קלאמפ, יצר קשר עם סאם, שהיו לו הרבה קטעי מוזיקה מ-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 Renderer החדש, יוכלו לשמש לחיבור טוב יותר בין שני העולמות.
זיכויים
תודה לארון קובלין שנתן לי חופש פעולה בפרויקט הזה. ג'ונו ברנדל על עיצוב ממשק המשתמש המצוין + הטמעה, טיפול בסוגים והטמעה של סיור. ול-Valdean Klump על כך שנתן לפרויקט שם ועל כל הטקסט. תודה לסאבאח אחמד על ניקוי טון המדדים של זכויות השימוש במקורות הנתונים והתמונות. תודה ל-Clem Wright על הפנייה לאנשים הנכונים לצורך פרסום. דאג פריץ על מצוינות טכנית. לג'ורג' בראוור על שלימד אותי JS ו-CSS. וכמובן, Mr. Doob על THREE.js.