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

Gregg Tavares
Gregg Tavares

WebGL Orthographic 3D

הפוסט הזה הוא המשך לסדרה של פוסטים על WebGL. בפוסט הראשון התחלנו מהיסודות, ובפוסט הקודם דיברנו על מטריצות דו-ממדיות על מטריצות דו-ממדיות. אם עדיין לא קראת אותם, מומלץ לעשות זאת קודם. בפרסום הקודם הוסבר איך פועלות מטריצות דו-מימדיות. דיברנו על תרגום, סיבוב, שינוי קנה מידה ואפילו הקרנה מפיקסלים למרחב הקליפ, ואפשר לעשות את כל הפעולות האלה באמצעות מטריצה אחת וקצת מתמטיקה קסומה של מטריצות. משם, הדרך ליצירת מודלים תלת-ממדיים היא קצרה. בדוגמאות הקודמות שלנו במרחב דו-מימדי היו נקודות דו-מימדיות (x, y) שהכפלנו במטריצה 3x3. כדי להשתמש ב-3D, צריך נקודות 3D (x,‏ y,‏ z) ומטריצה 4x4. נשתמש בדוגמה האחרונה ונשנה אותה ל-3D. נשתמש שוב באות F, אבל הפעם באות F תלת-ממדית. הדבר הראשון שצריך לעשות הוא לשנות את עיבוד הקודקודים (vertex shader) כדי לטפל ב-3D. זהו ה-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);
}

בשלב הבא צריך לשנות את כל פונקציות המטריצות מ-2D ל-3D אלה הגרסאות 2D (לפני) של 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 פונקציות רוטציה. נזקקנו רק ל-2D כי בעצם סובבנו רק סביב ציר 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
];
}

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

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 עד גובה הפיקסלים, אבל לעומק הוא יהיה -depth / 2 עד +depth / 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 שטוח, ולכן קשה לראות תלת-ממד. כדי לפתור את הבעיה, נרחיב את הגיאומטריה ל-3D. ה-F הנוכחי שלנו מורכב מ-3 מלבנים, עם 2 משולשים בכל אחד. כדי ליצור אותה בתלת-ממד, נדרשים בסך הכול 16 מלבנים. יש הרבה מדי דברים כאלה כדי לרשום אותם כאן. 16 מלבנים x 2 משולשים לכל מלבן x 3 קודקודים לכל משולש = 96 קודקודים. כדי לראות את כולם, פותחים את המקור בדוגמה. צריך לצייר עוד קודקודים, כך

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

כשמזיזים את פס ההזזה, קשה מאוד לזהות שמדובר ב-3D. ננסה לצבוע כל מלבן בצבע שונה. כדי לעשות זאת, נוסיף מאפיין נוסף לשפת שגיאת הקודקוד (vertex shader) ו-varying כדי להעביר אותו משפת שגיאת הקודקוד לשפת שגיאת הפירור (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, ההגדרה של משולש כמשולש שמשוך בכיוון השעון או נגד כיוון השעון תלויה בקודקודים של המשולש במרחב החיתוך. במילים אחרות, WebGL קובע אם משולש נמצא בחזית או בעורף אחרי שמחילים פעולות מתמטיות על הנקודות (vertices) ב-vertex shader. כלומר, לדוגמה, משולש בכיוון השעון שמוגדל ב-X ב-1 הופך למשולש נגד כיוון השעון, או משולש בכיוון השעון שמסתובב ב-180 מעלות סביב ציר X או Y הופך למשולש נגד כיוון השעון. מכיוון שהשבתנו את CULL_FACE, אנחנו יכולים לראות משולשים גם בכיוון השעון(חזית) וגם נגד כיוון השעון(גב). עכשיו, אחרי שהפעלנו את ההגדרה הזו, בכל פעם שמשולש הפונה קדימה יתהפך, בין שבגלל שינוי קנה מידה או סיבוב או מסיבה כלשהי אחרת, WebGL לא ידפיס אותו. זה טוב, כי כשאתם מסובבים משהו בתלת-ממד, בדרך כלל אתם רוצים שהמשולשים שמוצגים לכם ייחשבו כמשולשים שפונים אליכם.

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

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

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

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

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

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 הישן שלנו ב-2D היינו צריכים לספק את הערך 1 באופן מפורש. העברנו את הערכים x ו-y, ורצינו להעביר את הערך 1 עבור z, אבל ערך ברירת המחדל של z הוא 0, ולכן נאלצנו לציין את הערך 1 באופן מפורש. עם זאת, בתלת-ממד, גם אם לא מציינים ערך ל-'w', הערך שמוגדר כברירת מחדל הוא 1, וזה הערך שנחוץ כדי שהמתמטיקה של המטריצות תפעל.