מבוא
בעבר פרסמתי מבוא ל-Three.js. אם לא קראתם את המאמר הזה, מומלץ לעשות זאת כי הוא הבסיס שבו אשתמש במאמר הזה.
אני רוצה לדבר על שיבוטים (shaders). WebGL הוא נהדר, וכמו שאמרתי קודם, Three.js (וספריות אחרות) עושות עבודה נהדרת בהסתרת הקשיים. עם זאת, יהיו מקרים שבהם תרצו להשיג אפקט ספציפי, או שתצטרכו להבין לעומק איך הדברים המדהימים האלה מופיעים במסך, ויש סיכוי גבוה ששימוש בשיידרים יהיה חלק מהמשוואה הזו. כמו כן, אם אתם כמוני, יכול להיות שתרצו לעבור מהדברים הבסיסיים במדריך הקודם למשהו קצת יותר מסובך. אעבוד על בסיס ההנחה שאתם משתמשים ב-Three.js, כי הוא עושה בשבילנו הרבה מהעבודה הקשה בנוגע להפעלת ה-shader. רציתי גם לציין מראש שבהתחלה אסביר את ההקשר של שיבוטים, ובחלק האחרון של המדריך נכנס לנושאים מתקדמים יותר. הסיבה לכך היא שצללים נראים חריגים במבט ראשון, ויש צורך בהסבר קצת יותר מפורט.
1. שני ה-Shaders שלנו
ב-WebGL אין אפשרות להשתמש בצינור עיבוד נתונים קבוע (Fixed Pipeline), כלומר אין לכם אפשרות ליצור עיבוד גרפי של התוכן שלכם מבלי להשתמש בכלים נוספים. עם זאת, מה שכן זמין הוא צינור עיבוד נתונים שניתן לתכנת, שהוא חזק יותר אבל גם קשה יותר להבין ולהשתמש בו. בקצרה, צינור עיבוד נתונים שניתן לתכנת אומר שהמתכנת אחראי על היצירה של הקודקודים וכו' שמוצגים במסך. שגיאות Shader הן חלק צינור עיבוד הנתונים הזה, ויש שני סוגים שלהן:
- תוכנות הצללה (shaders) של קודקודים
- תוכנות הצללה של שברי קוד (fragment)
אין לי ספק ששניהם לא אומרים כלום בפני עצמם. חשוב לדעת ששניהם פועלים באופן מלא ב-GPU של כרטיס המסך. כלומר, אנחנו רוצים להעביר אליהם את כל מה שאפשר, כדי שה-CPU יוכל לבצע משימות אחרות. מעבד GPU מודרני מותאם במיוחד לפונקציות שנדרשות לשיידרים, ולכן נהדר שאפשר להשתמש בו.
2. Vertex Shaders
לוקחים צורה פרימית רגילה, כמו כדור. הוא מורכב מקודקודים, נכון? לכל אחד מהקודקודים האלה מוקצה פסאודוקוד של צומת (vertex shader), והוא יכול לשנות אותם. ה-vertex shader קובע מה יקרה בפועל לכל אחד מהם, אבל יש לו אחריות אחת: בשלב מסוים הוא צריך להגדיר את gl_Position, וקטור של 4D float, שהוא המיקום הסופי של ה-vertex במסך. זהו תהליך מעניין בפני עצמו, כי אנחנו למעשה מדברים על העברת מיקום תלת-ממדי (קודקוד עם x, y, z) למסך דו-ממדי או הקרנה שלו למסך. למזלנו, אם נשתמש ב-Three.js תהיה לנו דרך מקוצרת להגדיר את gl_Position בלי להעמיס על המערכת.
3. Shaders של פלחים
אז יש לנו את האובייקט עם הקודקודים שלו, והקרנו אותם למסך הדו-ממדי, אבל מה לגבי הצבעים שבהם אנחנו משתמשים? מה לגבי טקסטורות ותאורה? זה בדיוק למה משמש שובר הפירגמנטים. בדומה לשניידר הנקודות, גם לשניידר הפירורים יש משימה אחת בלבד שהוא חייב לבצע: הוא חייב להגדיר או להשליך את המשתנה gl_FragColor, וקטור אחר של 4D float, שהוא הצבע הסופי של הפירור. אבל מהו קטע קוד? נסו לחשוב על שלושה קודקודים שמרכיבים משולש. צריך לצייר כל פיקסל בתוך המשולש הזה. הפירור הוא הנתונים שסופקו על ידי שלושת הקודקודים האלה לצורך ציור כל פיקסל במשולש הזה. לכן, הפצצות מקבלות ערכים משוערכים מהקודקודים שמרכיבים אותן. אם קודקוד אחד צבוע אדום והקודקוד הסמוך לו צבוע כחול, נראה שהערכים של הצבע יתבצעו אינטרפולציה מאדום, דרך סגול, לכחול.
4. משתני Shader
כשמדברים על משתנים, יש שלוש הצהרות שאפשר להצהיר: Uniforms, Attributes ו-Varyings. כששמעתי על שלושת הדברים האלה בפעם הראשונה, הייתי מבולבל מאוד כי הם לא תואמים לשום דבר אחר שבו עבדתי. אבל אפשר לחשוב עליהם כך:
מאפייני Uniform נשלחים גם לשידורי vertex וגם לשידורי fragment, והם מכילים ערכים שלא משתנים בכל המסגרת שעוברת רינדור. דוגמה טובה לכך היא מיקום של תאורה.
מאפיינים הם ערכים שחלים על קודקודים ספציפיים. המאפיינים זמינים רק ל-vertex shader. למשל, כל קודקוד יכול להיות בצבע שונה. למאפיינים יש קשר של אחד לאחד עם קודקודים.
משתני Varying הם משתנים שמוצהרים ב-vertex shader שרוצים לשתף עם ה-fragment shader. כדי לעשות זאת, חשוב להצהיר על משתנה משתנה (varying) מאותו סוג ושם גם ב-vertex shader וגם ב-fragment shader. שימוש קלאסי בכך הוא נורמלית של קודקוד, כי אפשר להשתמש בה בחישובים של התאורה.
בהמשך נשתמש בכל שלושת הסוגים כדי שתוכלו להבין איך הם מיושמים בפועל.
אחרי שדיברנו על שגיאות vertex ו-fragment, ועל סוגי המשתנים שהן מטפלות בהם, כדאי לבחון את השגיאות הפשוטות ביותר שאפשר ליצור.
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 תבצע הידור ותפעיל את השיזרים שמצורפים למערך שאליו נותנים את החומר הזה. זה ממש פשוט. כנראה שכן, אבל אנחנו מדברים על תלת-ממד שפועל בדפדפן, אז סביר להניח שציפית למידה מסוימת של מורכבות.
אפשר להוסיף עוד שני מאפיינים ל-MeshShaderMaterial: מאפיינים ומדים. אפשר להשתמש בהם עם וקטורים, מספרים שלמים או מספרים עשרוניים, אבל כפי שציינתי קודם, מאפייני uniform זהים לכל המסגרת, כלומר לכל הנקודות, ולכן הם בדרך כלל ערכים בודדים. עם זאת, מאפיינים הם משתנים לכל קודקוד, ולכן הם אמורים להיות מערך. צריכה להיות יחסיות אחד לאחד בין מספר הערכים במערך המאפיינים לבין מספר הנקודות (vertices) ברשת.
7. השלבים הבאים
עכשיו נקדיש קצת זמן להוספת לולאת אנימציה, מאפייני קודקודים ו-uniform. נוסיף גם משתנה משתנה כדי ש-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. עדכון של MeshShaderMaterial
נתחיל ישירות בעדכון של 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.
- לולאת אנימציה ב-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. סיכום
זה הכול! עכשיו אפשר לראות שהיא פועלת באנימציה קצבית מוזרה (ומעט פסיכודלית).
יש עוד הרבה דברים שאפשר להסביר על נושא השיזוענים, אבל אני מקווה שהמבוא הזה עזר לכם. עכשיו אתם אמורים להבין את המשמעות של שיבוטים כשאתם רואים אותם, וגם להרגיש בטוחים בעצמכם ליצור שיבוטים מדהימים משלכם.