رسومات بيانية ثلاثية الأبعاد لـ WebGL

رسومات ثلاثية الأبعاد متعامدة باستخدام WebGL

هذه المشاركة هي استمرار لسلسلة من المشاركات حول WebGL. تناول الفيديو الأول الأساسيات، أما الفيديو السابق فكان عن المصفوفات الثنائية الأبعاد. إذا لم يسبق لك قراءتها، يُرجى الاطّلاع عليها أولاً. في المنشور الأخير، تناولنا كيفية عمل المصفوفات ثنائية الأبعاد. تحدثنا عن الإزاحة والتدوير وتغيير الحجم وحتى الإسقاط من وحدات البكسل إلى مساحة الاقتصاص، وكل ذلك يمكن إجراؤه باستخدام مصفوفة واحدة وبعض العمليات الحسابية السحرية للمصفوفات. ولإنشاء صور ثلاثية الأبعاد، ما عليك سوى اتّباع خطوة بسيطة. في الأمثلة السابقة ثنائية الأبعاد، كانت لدينا نقاط ثنائية الأبعاد (س، ص) ضربناها في مصفوفة 3×3. لإنشاء تأثير ثلاثي الأبعاد، نحتاج إلى نقاط ثلاثية الأبعاد (س، ص، ع) ومصفوفة 4x4. لنأخذ مثالنا الأخير ونغيّره إلى ثلاثي الأبعاد. سنستخدم الحرف F مرة أخرى، ولكن هذه المرة سيكون ثلاثي الأبعاد. أول شيء يتعين علينا القيام به هو تغيير تظليل الرأس للتعامل مع الأبعاد الثلاثية. إليك المظلّل القديم.

<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. ولكن لإنشاء تأثير ثلاثي الأبعاد، يجب أن نتمكّن أيضًا من تدوير العناصر حول المحورين السيني والصادي. يمكنك أن ترى من خلال النظر إليها أنها جميعًا متشابهة جدًا. إذا أردنا حلّها، فستبسط تمامًا كما في السابق

التدوير حول المحور Z

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

التدوير حول المحور Y


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

التدوير حول المحور س

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 مستطيلات، كل منها يتألف من مثلثَين. لجعله ثلاثي الأبعاد، سيتطلب ذلك إجمالي 16 مستطيلاً. هناك الكثير من الميزات التي يمكننا ذكرها هنا. ‫16 مستطيلاً × مثلثَين لكل مستطيل × 3 رؤوس لكل مثلث = 96 رأسًا. إذا أردت الاطّلاع على جميع هذه السمات، يمكنك عرض المصدر في النموذج. علينا رسم المزيد من الرؤوس

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

عند تحريك أشرطة التمرير، من الصعب معرفة أنّ الصورة ثلاثية الأبعاد. لنجرّب تلوين كل مستطيل بلون مختلف. للقيام بذلك، سنضيف سمة أخرى إلى مظلل الرؤوس ومتغيرًا لتمريره من مظلل الرؤوس إلى مظلل الأجزاء. إليك برنامج تظليل الرؤوس الجديد

<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>

ونحتاج إلى استخدام هذا اللون في أداة تظليل الأجزاء

<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 ما إذا كان المثلث أماميًا أو خلفيًا بعد تطبيق العمليات الحسابية على الرؤوس في تظليل الرؤوس. هذا يعني على سبيل المثال أنّ المثلث الذي يدور في اتجاه عقارب الساعة والذي يتم تغيير حجمه في المحور 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,

هذا أقرب إلى الإجابة الصحيحة، ولكن لا تزال هناك مشكلة أخرى. حتى مع توجيه جميع المثلثات في الاتجاه الصحيح ومع استبعاد المثلثات المواجهة للخلف، لا تزال لدينا أماكن يتم فيها رسم المثلثات التي من المفترض أن تكون في الخلف فوق المثلثات التي من المفترض أن تكون في الأمام. أدخِل "مخزن العمق" (DEPTH BUFFER). "مخزن العمق المؤقت"، الذي يُعرف أحيانًا باسم "مخزن Z المؤقت"، هو مستطيل من وحدات البكسل depth، وهو وحدة بكسل عمق واحدة لكل وحدة بكسل لونية مستخدَمة لإنشاء الصورة. عندما ترسم WebGL كل بكسل لون، يمكنها أيضًا رسم بكسل عمق. ويتم ذلك استنادًا إلى القيم التي نحصل عليها من مظلّل الرؤوس للمحور Z. تمامًا كما كان علينا التحويل إلى مساحة القطع لـ X وY، فإن Z أيضًا في مساحة القطع أو (-1 إلى +1). بعد ذلك، يتم تحويل هذه القيمة إلى قيمة مساحة عمق (من 0 إلى +1). قبل أن ترسم WebGL بكسل لون، ستتحقق من بكسل العمق المقابل. إذا كانت قيمة العمق للبكسل الذي على وشك رسمه أكبر من قيمة بكسل العمق المقابل، فلن يرسم WebGL بكسل اللون الجديد. وإلا، فإنه يرسم كلاً من بكسل اللون الجديد باللون من تظليل الجزء ويرسم بكسل العمق بقيمة العمق الجديدة. وهذا يعني أنّ وحدات البكسل التي تقع خلف وحدات بكسل أخرى لن يتم رسمها. يمكننا تفعيل هذه الميزة بسهولة تماثل سهولة تفعيل ميزة الاستبعاد باستخدام

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 قيم فقط لكل سمة. يعمل هذا لأنّ WebGL يوفّر في "مُظلِّل الرؤوس" قيمًا تلقائية للبيانات التي لا تقدّمها. القيم التلقائية هي 0 و0 و0 و1 حيث x = 0 وy = 0 وz = 0 وw = 1. لهذا السبب، كان علينا في مظلّل الرؤوس الثنائي الأبعاد القديم توفير القيمة 1 بشكل صريح. كنا نمرر x وy وكنا بحاجة إلى 1 لـ z ولكن لأن الإعداد الافتراضي لـ z هو 0، كان علينا توفير 1 بشكل صريح. بالنسبة إلى الأبعاد الثلاثية، على الرغم من أنّنا لا نقدّم قيمة "w"، يتم ضبطها تلقائيًا على 1، وهو ما نحتاجه لكي تعمل العمليات الحسابية للمصفوفة.