Орфографическое 3D WebGL

Грегг Таварес
Gregg Tavares

WebGL Орфографическое 3D

Этот пост является продолжением серии постов о WebGL. Первый начался с основ , а предыдущий был посвящен 2D-матрицам . Если вы их не читали, сначала просмотрите их. В последнем посте мы рассмотрели, как работают 2d-матрицы. Мы говорили о перемещении, вращении, масштабировании и даже проецировании пикселей в пространство клипа — все это можно выполнить с помощью одной матрицы и некоторой магической матричной математики. До создания 3D осталось всего лишь небольшой шаг. В наших предыдущих 2D-примерах у нас были 2D-точки (x, y), которые мы умножили на матрицу 3x3. Для создания 3D нам нужны 3D точки (x, y, z) и матрица 4x4. Давайте возьмем наш последний пример и изменим его на 3D. Мы снова будем использовать букву F, но на этот раз трехмерную букву F. Первое, что нам нужно сделать, это изменить вершинный шейдер для обработки 3D. Вот старый шейдер.

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

Все стало еще проще! Затем нам нужно предоставить 3D-данные.

...

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
];
}

А вот обновленные 3D-версии.

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. Теперь, хотя для создания 3D мы также хотим иметь возможность вращаться вокруг осей 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 до высоты в пикселях, но для глубины это будет от -глубины / 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, из-за чего трудно увидеть 3D. Чтобы это исправить, давайте расширим геометрию до 3D. Наша текущая F состоит из 3 прямоугольников, по 2 треугольника в каждом. Чтобы сделать его 3D, потребуется всего 16 прямоугольников. Это довольно много, чтобы перечислить здесь. 16 прямоугольников х 2 треугольника на прямоугольник х 3 вершины на треугольник — 96 вершин. Если вы хотите увидеть их все, просмотрите исходный код в образце. Нам нужно нарисовать больше вершин, поэтому

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

Двигая ползунки, довольно сложно определить, что это 3D. Давайте попробуем раскрасить каждый прямоугольник в другой цвет. Для этого мы добавим еще один атрибут в наш вершинный шейдер и переменный для передачи его из вершинного шейдера во фрагментный шейдер. Вот новый вершинный шейдер

<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 не будет его рисовать. Это хорошо, поскольку, поворачивая что-то в 3D, вы обычно хотите, чтобы треугольники, обращенные к вам, считались обращенными вперед.

Привет! Куда делись все треугольники? Оказывается, многие из них смотрят не в ту сторону. Поверните его, и вы увидите, как они появляются, когда посмотрите на другую сторону. К счастью, это легко исправить. Мы просто смотрим, какие из них обратные, и меняем местами 2 их вершины. Например, если один перевернутый треугольник

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

мы просто переворачиваем последние две вершины, чтобы двигаться вперед.

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

Это ближе, но есть еще одна проблема. Даже несмотря на то, что все треугольники обращены в правильном направлении, а треугольники, обращенные назад, удалены, у нас все еще есть места, где треугольники, которые должны быть сзади, рисуются поверх треугольников, которые должны быть спереди. Введите БУФЕР ГЛУБИНЫ. Буфер глубины, иногда называемый 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. Вот почему в нашем старом 2D вершинном шейдере нам приходилось явно указывать 1. Мы передавали x и y, и нам нужна была 1 для z, но поскольку значение по умолчанию для z равно 0, нам пришлось явно указать 1. Однако для 3D, хотя мы и не указываем «w», по умолчанию оно равно 1, что нам и нужно для матричная математика в работе.