WebGL אורתוגרפיה בתלת-ממד

Gregg Tavares
Gregg Tavares

תלת-ממד אורתוגרפי ב-WebGL

הפוסט הזה הוא המשך של סדרת פוסטים על WebGL. הראשון התחיל עם היסודות והקודם היה על מטריצות דו-ממדיות. אם עדיין לא קראת אותם, כדאי לעיין בהם קודם. בפוסט הקודם הסברנו איך מטריצות דו-ממדיות פועלות. דיברנו על כך שניתן לבצע הזזה, סיבוב, שינוי קנה מידה ואפילו הקרנה מפיקסלים למרחב החיתוך באמצעות מטריצה אחת וקצת מתמטיקה קסומה של מטריצות. המעבר לתלת-ממד הוא רק צעד קטן משם. בדוגמאות הקודמות שלנו בדו-ממד היו לנו נקודות דו-ממדיות (x, y) שהכפלנו במטריצה של 3x3. כדי ליצור תלת-ממד, אנחנו צריכים נקודות תלת-ממדיות (x, y, z) ומטריצה של 4x4. ניקח את הדוגמה האחרונה ונשנה אותה לתלת-ממד. נשתמש שוב באות F, אבל הפעם באות F תלת-ממדית. הדבר הראשון שצריך לעשות הוא לשנות את ה-vertex shader כדי לטפל בתלת-ממד. הנה השיידר הישן.

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

והנה החדש

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;

uniform mat4 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;
}
</script>

זה נהיה אפילו יותר פשוט! אחר כך צריך לספק נתוני תלת-ממד.

...

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

...

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
        // left column
        0,   0,  0,
        30,   0,  0,
        0, 150,  0,
        0, 150,  0,
        30,   0,  0,
        30, 150,  0,

        // top rung
        30,   0,  0,
        100,   0,  0,
        30,  30,  0,
        30,  30,  0,
        100,   0,  0,
        100,  30,  0,

        // middle rung
        30,  60,  0,
        67,  60,  0,
        30,  90,  0,
        30,  90,  0,
        67,  60,  0,
        67,  90,  0]),
    gl.STATIC_DRAW);
}

עכשיו צריך לשנות את כל פונקציות המטריצה מדו-ממד לתלת-ממד. אלה הגרסאות הדו-ממדיות (לפני השינוי) של makeTranslation, ‏ makeRotation ו-makeScale.

function makeTranslation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}

function makeRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}

function makeScale(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}

והנה הגרסאות התלת-ממדיות המעודכנות.

function makeTranslation(tx, ty, tz) {
return [
    1,  0,  0,  0,
    0,  1,  0,  0,
    0,  0,  1,  0,
    tx, ty, tz, 1
];
}

function makeXRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
];
};

function makeYRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
];
};

function makeZRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
    c, s, 0, 0,
-s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
];
}

function makeScale(sx, sy, sz) {
return [
sx, 0,  0,  0,
0, sy,  0,  0,
0,  0, sz,  0,
0,  0,  0,  1,
];
}

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

סיבוב Z

newX = x * c + y * s;
newY = x * -s + y * c;

סיבוב בציר Y


newX = x * c + z * s;
newZ = x * -s + z * c;

סיבוב בציר ה-X

newY = y * c + z * s;
newZ = y * -s + z * c;

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

function make2DProjection(width, height) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
}

שמומר מפיקסלים למרחב חיתוך. בניסיון הראשון שלנו להרחיב את זה לתלת-ממד, ננסה

function make2DProjection(width, height, depth) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
    2 / width, 0, 0, 0,
    0, -2 / height, 0, 0,
    0, 0, 2 / depth, 0,
-1, 1, 0, 1,
];
}

בדיוק כמו שהיינו צריכים להמיר מפיקסלים למרחב חיתוך עבור x ו-y, עבור z אנחנו צריכים לעשות את אותו הדבר. במקרה הזה, אני מגדיר גם את יחידות הפיקסלים של מרחב Z. אני אעביר ערך דומה ל-width עבור העומק, כך שהמרחב שלנו יהיה ברוחב של 0 עד פיקסלים של רוחב, בגובה של 0 עד פיקסלים של גובה, אבל עבור העומק הוא יהיה עומק- חלקי 2 עד עומק+ חלקי 2. לבסוף, צריך לעדכן את הקוד שמחשב את המטריצה.

// Compute the matrices
var projectionMatrix =
    make2DProjection(canvas.width, canvas.height, canvas.width);
var translationMatrix =
    makeTranslation(translation[0], translation[1], translation[2]);
var rotationXMatrix = makeXRotation(rotation[0]);
var rotationYMatrix = makeYRotation(rotation[1]);
var rotationZMatrix = makeZRotation(rotation[2]);
var scaleMatrix = makeScale(scale[0], scale[1], scale[2]);

// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationZMatrix);
matrix = matrixMultiply(matrix, rotationYMatrix);
matrix = matrixMultiply(matrix, rotationXMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
matrix = matrixMultiply(matrix, projectionMatrix);

// Set the matrix.
gl.uniformMatrix4fv(matrixLocation, false, matrix);

הבעיה הראשונה היא שהגיאומטריה שלנו היא F שטוח, מה שמקשה לראות תלת מימד. כדי לתקן את זה, נרחיב את הגאומטריה לתלת-ממד. האות F הנוכחית שלנו מורכבת מ-3 מלבנים, שכל אחד מהם מורכב מ-2 משולשים. כדי להפוך אותו לתלת-ממדי, צריך 16 מלבנים בסך הכול. יש הרבה מאוד אפשרויות. ‫16 מלבנים כפול 2 משולשים לכל מלבן כפול 3 קודקודים לכל משולש שווה ל-96 קודקודים. כדי לראות את כולם, צריך להציג את המקור בדוגמה. אנחנו צריכים לצייר עוד קודקודים, אז

// Draw the geometry.
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6);

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

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec4 a_color;

uniform mat4 u_matrix;

varying vec4 v_color;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;

// Pass the color to the fragment shader.
v_color = a_color;
}
</script>

ואנחנו צריכים להשתמש בצבע הזה ב-fragment shader

<script id="3d-vertex-shader" type="x-shader/x-fragment">
precision mediump float;

// Passed in from the vertex shader.
varying vec4 v_color;

void main() {
gl_FragColor = v_color;
}
</script>

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

...
var colorLocation = gl.getAttribLocation(program, "a_color");

...
// Create a buffer for colors.
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(colorLocation);

// We'll supply RGB as bytes.
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

// Set Colors.
setColors(gl);

...
// Fill the buffer with colors for the 'F'.

function setColors(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Uint8Array([
        // left column front
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,

        // top rung front
    200,  70, 120,
    200,  70, 120,
    ...
    ...
    gl.STATIC_DRAW);
}

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

משולש מתפתל.

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

gl.enable(gl.CULL_FACE);

שאנחנו עושים רק פעם אחת, ממש בתחילת התוכנית. כשהתכונה הזו מופעלת, WebGL מסתיר כברירת מחדל את המשולשים שפונים אחורה. "הסרה" במקרה הזה היא מילה יפה ל "לא לצייר". שימו לב שב-WebGL, אם משולש נחשב כנע בכיוון השעון או נגד כיוון השעון, זה תלוי בקודקודים של המשולש הזה ב-clipspace. במילים אחרות, WebGL מחליט אם המשולש הוא קדמי או אחורי אחרי שהפעלתם את המתמטיקה על הקודקודים ב-vertex shader. כלומר, לדוגמה, משולש עם כיוון השעון שמשנים את קנה המידה שלו בציר ה-X ב-‎-1 הופך למשולש נגד כיוון השעון, או משולש עם כיוון השעון שמסובבים ב-180 מעלות סביב ציר ה-X או ה-Y הופך למשולש נגד כיוון השעון. מכיוון שהשבתנו את CULL_FACE, אנחנו יכולים לראות גם משולשים עם כיוון השעון(מלפנים) וגם משולשים נגד כיוון השעון(מאחור). עכשיו, כשהפעלנו את זה, בכל פעם שמשולש שפונה קדימה מתהפך בגלל שינוי קנה מידה או סיבוב או מכל סיבה שהיא, WebGL לא יצייר אותו. זה דבר טוב, כי כשמסובבים משהו בתלת-ממד, בדרך כלל רוצים שהמשולשים שפונים אליכם ייחשבו כפונים קדימה.

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

1,   2,   3,
40,  50,  60,
700, 800, 900,

אנחנו פשוט הופכים את שני הקודקודים האחרונים כדי שהכיוון יהיה קדימה.

1,   2,   3,
700, 800, 900,
40,  50,  60,

זה קרוב יותר, אבל עדיין יש בעיה אחת. גם כשכל המשולשים פונים לכיוון הנכון, וכשהמשולשים שפונים אחורה נחתכים, עדיין יש מקומות שבהם משולשים שאמורים להיות מאחור מצוירים מעל משולשים שאמורים להיות מלפנים. מזינים את מאגר הנתונים של העומק. מאגר עומק, שנקרא לפעמים מאגר Z, הוא מלבן של depth פיקסלים, פיקסל עומק אחד לכל פיקסל צבע שמשמש ליצירת התמונה. כאשר WebGL מצייר כל פיקסל צבע, הוא יכול גם לצייר פיקסל עומק. הוא עושה את זה על סמך הערכים שאנחנו מחזירים מה-vertex shader עבור Z. בדיוק כמו שהיינו צריכים להמיר למרחב החיתוך עבור X ו-Y, כך גם Z נמצא במרחב החיתוך או (‎-1 עד ‎+1). לאחר מכן הערך הזה מומר לערך של מרחב עומק (0 עד ‎+1). לפני ש-WebGL מצייר פיקסל צבע, הוא בודק את פיקסל העומק התואם. אם ערך העומק של הפיקסל שהוא עומד לצייר גדול מערך פיקסל העומק התואם, WebGL לא מצייר את פיקסל הצבע החדש. אחרת, הוא משרטט גם את הפיקסל של הצבע החדש עם הצבע מה-fragment shader וגם את הפיקסל של העומק עם ערך העומק החדש. כלומר, פיקסלים שנמצאים מאחורי פיקסלים אחרים לא ישורטטו. אנחנו יכולים להפעיל את התכונה הזו כמעט באותה קלות שבה הפעלנו את ה-culling עם

gl.enable(gl.DEPTH_TEST);

אנחנו צריכים גם לנקות את מאגר העומק בחזרה ל-1.0 לפני שנתחיל לצייר.

// Draw the scene.
function drawScene() {
// Clear the canvas AND the depth buffer.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...

בפוסט הבא אסביר איך להוסיף לו פרספקטיבה.

למה המאפיין הוא vec4 אבל הגודל של gl.vertexAttribPointer הוא 3

אם אתם שמים לב לפרטים, אולי הבחנתם שהגדרנו את שני המאפיינים שלנו בתור

attribute vec4 a_position;
attribute vec4 a_color;

שניהם מסוג vec4, אבל כשאנחנו אומרים ל-WebGL איך לקחת נתונים מהמאגרים שלנו, השתמשנו

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

המספר "3" בכל אחת מהפונקציות האלה מציין שצריך לשלוף רק 3 ערכים לכל מאפיין. זה עובד כי ב-vertex shader, ‏WebGL מספק ערכי ברירת מחדל לאלה שלא סיפקתם. ערכי ברירת המחדל הם 0, 0, 0, 1 כאשר x = 0, ‏ y = 0, ‏ z = 0 ו-w = 1. לכן ב-vertex shader הדו-ממדי הישן שלנו היינו צריכים לספק את הערך 1 באופן מפורש. העברנו את x ו-y והיינו צריכים 1 עבור z, אבל בגלל שברירת המחדל של z היא 0, היינו צריכים לספק 1 באופן מפורש. אבל בתלת-ממד, גם אם לא נספק ערך ל-w, ברירת המחדל שלו היא 1, וזה מה שאנחנו צריכים כדי שהחישוב של המטריצה יעבוד.