מבוא להצללות

מבוא

בעבר פרסמתי מבוא ל-Three.js. אם לא קראתם, אולי תרצו, כי זה הבסיס שעליו אבנה במהלך המאמר הזה.

אני רוצה לדבר על שיבוטים (shaders). WebGL הוא נהדר, וכמו שאמרתי קודם, Three.js (וספריות אחרות) עושות עבודה נהדרת בהסתרת הקשיים. אבל יהיו מקרים שבהם תרצו להשיג אפקט ספציפי, או שתצטרכו להבין לעומק איך הדברים המדהימים האלה מופיעים במסך, ויש סיכוי גבוה ששימוש בשיידרים יהיה חלק מהמשוואה הזו. בנוסף, אם אתם כמוני, יכול להיות שתרצו לעבור מהדברים הבסיסיים במדריך הקודם למשהו קצת יותר מסובך. אעבוד על בסיס ההנחה שאתה משתמש ב-Three.js, כי הוא עושה בשבילנו הרבה מהעבודה הקשה בנוגע להפעלת ה-shader. כמו כן, אני אומר שבהתחלה אסביר את ההקשר בנושא תוכנות הצללה, והחלק השני של המדריך הוא המקום שבו נתקדם לאזור קצת יותר מתקדם. הסיבה לכך היא שצללים נראים חריגים במבט ראשון, ויש צורך בהסבר קצת יותר מפורט.

1. שני האזורים הכהים שלנו

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

  1. תוכנות הצללה (shaders) של קודקודים
  2. תוכנות הצללה של שברי קוד (fragment)

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

2. Vertex Shaders

לוקחים צורה פרימית רגילה, כמו כדור. הוא מורכב מקודקודים, נכון? להצללה של קודקוד מקבלים בתורו כל אחד מהקודקודים, והוא עלול להסתובב איתם. ה-vertex shader קובע מה יקרה בפועל לכל אחד מהם, אבל יש לו אחריות אחת: בשלב מסוים הוא חייב להגדיר את gl_Position, וקטור של 4D float, שהוא המיקום הסופי של ה-vertex במסך. זהו תהליך מעניין בפני עצמו, כי אנחנו למעשה מדברים על העברת מיקום תלת-ממדי (קודקוד עם x,‏ y,‏ z) למסך דו-ממדי או הקרנה שלו למסך. למזלנו, אם נשתמש ב-Three.js, תהיה לנו דרך מקוצרת להגדיר את gl_Position בלי להעמיס על המערכת.

3. Shaders של שברי קוד

אז יש לנו את האובייקט עם הקודקודים שלו, והקרנו אותם למסך 2D, אבל מה לגבי הצבעים שבהם אנחנו משתמשים? מה לגבי טקסטורות ותאורה? זה בדיוק המטרה של תוכנת ההצללה למקטעים. בדומה לשדרוג של הנקודות, גם לשדרוג של הפירגמנטים יש משימה אחת בלבד: הוא צריך להגדיר או להשליך את המשתנה gl_FragColor, וקטור אחר של 4D float, שהוא הצבע הסופי של הפירגמנט. אבל מה זה מקטע? נסו לחשוב על שלושה קודקודים שמרכיבים משולש. יש להציג כל פיקסל בתוך המשולש. קטע קוד הוא הנתונים שסופקו על ידי שלושת הקודקודים האלה לצורך ציור כל פיקסל במשולש הזה. לכן, הפצחים מקבלים ערכים משוערכים מהקודקודים שמרכיבים אותם. אם קודקוד אחד צבוע אדום והקודקוד הסמוך לו צבוע כחול, נראה שהערכים של הצבע יתבצעו אינטרפולציה מאדום, דרך סגול, לכחול.

4. משתני Shader

כשמדברים על משתנים, יש שלוש הצהרות שאפשר להצהיר: Uniforms,‏ Attributes ו-Varyings. כששמעתי על שלושת הדברים האלה בפעם הראשונה, הייתי מבולבל מאוד כי הם לא תואמים לשום דבר אחר שבו עבדתי. אבל אפשר לחשוב עליהם כך:

  1. מאפייני Uniform נשלחים גם לשידורי vertex וגם לשידורי fragment, והם מכילים ערכים שלא משתנים בכל המסגרת שעוברת רינדור. דוגמה טובה לכך היא מיקום של תאורה.

  2. מאפיינים הם ערכים שחלים על קודקודים ספציפיים. המאפיינים זמינים רק ל-vertex shader. זה יכול להיות משהו כמו שלכל קודקוד יש צבע ייחודי. למאפיינים יש קשר אחד לאחד עם קודקודים.

  3. משתני Varying הם משתנים שמוצהרים ב-vertex shader שרוצים לשתף עם ה-fragment shader. לשם כך, אנחנו מוודאים שמצהירים על משתנה שונה מאותו סוג ואותו שם גם בתוכנת ההצללה (shader) של הקודקוד וגם בהצללה של המקטעים. שימוש קלאסי בייצוג הזה הוא תופעה רגילה של קודקוד, כי אפשר להשתמש בו לחישוב התאורה.

בהמשך נשתמש בכל שלושת הסוגים כדי שתוכלו להבין איך הם מיושמים בפועל.

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

5. Bonjourno World

זוהי דוגמה ל-Hello World של שגיאות קודקוד:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

וזו אותה פונקציה עבור שובר הפירורים:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

זה לא מסובך מדי, נכון?

ב-vertex shader, אנחנו מקבלים כמה uniforms מ-Three.js. שני המאפיינים האלה הם מטריצות 4D שנקראות 'מטריצה של מודל-תצוגה' ו'מטריצה של הקרנה'. אין צורך לדעת בדיוק איך הם פועלים, אבל תמיד כדאי להבין איך הדברים פועלים. הגרסה הקצרה היא האופן שבו המיקום התלת-ממדי של הקודקוד צפוי בפועל במיקום הדו-ממדי הסופי במסך.

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

6. שימוש ב-MeshShaderMaterial

בסדר, הגדרנו שידרוג, אבל איך משתמשים בו עם Three.js? מסתבר שזה קל מאוד. זה נראה בערך כך:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

לאחר מכן, Three.js יאסוף ויפעיל את תוכנות ההצללה (shader) שמחוברות לרשת שאליה נתתם את החומר. זה ממש פשוט. כנראה שכן, אבל אנחנו מדברים על תלת-ממד שפועל בדפדפן, אז סביר להניח שציפית למידה מסוימת של מורכבות.

למעשה אנחנו יכולים להוסיף עוד שני מאפיינים ל-MeshShaderMaterial: מדים ומאפיינים. אפשר להשתמש בהם גם עם וקטורים, גם עם מספרים שלמים וגם עם מספרים עשרוניים, אבל כפי שציינתי קודם, מאפייני uniform זהים לכל המסגרת, כלומר לכל הנקודות, ולכן הם בדרך כלל ערכים בודדים. עם זאת, מאפיינים הם משתנים לכל קודקוד, ולכן הם אמורים להיות מערך. צריכה להיות יחסיות אחד לאחד בין מספר הערכים במערך המאפיינים לבין מספר הנקודות (vertices) ברשת.

7. השלבים הבאים

עכשיו נקדיש קצת זמן להוספת לולאת אנימציה, מאפייני קודקודים ומאפיין אחיד. נוסיף גם משתנה משתנה כדי ש-vertex shader יוכל לשלוח נתונים מסוימים ל-fragment shader. התוצאה הסופית היא שהספירה שהייתה ורודה תופיע מוארת מלמעלה ומצדדים, ותבצע פעימות. זה אולי נשמע קצת מוזר, אבל אנחנו מקווים שהסבר הזה יעזור לכם להבין טוב יותר את שלושת סוגי המשתנים ואת היחסים ביניהם לבין הגיאומטריה הבסיסית.

8. אור מזויף

נעדכן את הצביעה כדי שהאובייקט לא יהיה שטוח בצבע. אפשר להסתכל איך Three.js מטפלת בתאורה, אבל כמובן שברור לך שהיא מורכבת יותר ממה שאנחנו צריכים כרגע, אז נשתמש בזיוף. מומלץ מאוד לעיין בהשידרוגים המצוינים שכלולים ב-Three.js, וגם בשידרוגים מהפרויקט המדהים של Chris Milk ו-Google ב-WebGL, Rome. חזרה לשיחה על ה-shaders. נעדכן את Vertex Shader כדי לספק לכל Vertex נורמלי ל-Fragment Shader. לשם כך אנחנו משתמשים בשיטות שונות:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

וב-Fragment Shader נגדיר את אותו שם משתנה, ולאחר מכן נשתמש במכפלת הנקודה של נורמלית הנקודה עם וקטור שמייצג אור שזורח מלמעלה ומשמאל לכדור. התוצאה הסופית היא אפקט שדומה לאור כיווני בחבילת 3D.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

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

מה השלב הבא? כדאי לנסות לשנות את המיקומים של כמה קודקודים.

9. מאפיינים

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

נתחיל בהוספת המאפיין ל-vertex shader:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

איך זה נראה?

לא הרבה שונה! הסיבה לכך היא שהמאפיין לא הוגדר ב-MeshShaderMaterial, ולכן במקום זאת ה-shader משתמש בערך אפס. זה קצת כמו placeholder כרגע. בקרוב נוסיף את המאפיין ל-MeshShaderMaterial ב-JavaScript, ו-Three.js יקשור אותם יחד באופן אוטומטי.

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

10. עדכון של MeshShader

נתחיל ישירות בעדכון של MeshShaderMaterial עם המאפיין הנדרש להפעלת ההזזה. תזכורת: מאפיינים הם ערכים לכל קודקוד, ולכן אנחנו צריכים ערך אחד לכל קודקוד בספירה שלנו. כך:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

עכשיו אנחנו רואים כדור מעוות, אבל הדבר המגניב הוא שכל ההזזה מתרחשת ב-GPU.

11. ליצור אנימציה של הפריימר הזה

כדאי להוסיף אנימציה. איך אנחנו עושים את זה? יש שני דברים שצריך לעשות:

  1. מאפיין אחיד ליצירת אנימציה של כמות ההזזה שצריך להחיל בכל פריים. אפשר להשתמש בסינוס או בקוסינוס לצורך כך, כי הערכים שלהם נעים בין -1 ל-1.
  2. לולאת אנימציה ב-JS

נוסיף את המאפיין היוניפורמי גם ל-MeshShaderMaterial וגם ל-Vertex Shader. קודם כול, Vertex Shader:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

בשלב הבא אנחנו מעדכנים את MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

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

עכשיו צריך להכין את הקריאה ל-render בפונקציה ב-JavaScript, ואז להשתמש ב-requestAnimationFrame כדי לקרוא לה. שם צריך גם לעדכן את הערך של התלבושת.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. סיכום

זה הכול! עכשיו אפשר לראות שהיא פועלת באנימציה קצבית מוזרה (ומעט פסיכודלית).

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