Ортографическая 3D-проекция WebGL
Это продолжение серии статей о WebGL. Первая была посвящена основам, а вторая – двумерным матрицам. Если вы ещё не читали их, рекомендуем сделать это. В предыдущей статье мы рассмотрели, как работают двумерные матрицы. Мы говорили о том, что перенос, поворот, масштабирование и даже проецирование из пикселей в пространство отсечения можно выполнить с помощью одной матрицы и некоторых математических операций с ней. От этого до 3D-моделирования всего один шаг. В предыдущих примерах с двумерными объектами мы умножали двумерные точки (x, y) на матрицу 3 x 3. Для 3D нам нужны 3D-точки (x, y, z) и матрица 4x4. Давайте возьмем наш последний пример и изменим его на 3D. Мы снова используем букву 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,
];
}
Обратите внимание, что теперь у нас есть три функции поворота. В 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 до width пикселей, высоту от 0 до height пикселей, но
для глубины оно будет от -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. Чтобы исправить это, давайте расширим геометрию до 3D. Наша текущая буква F состоит из 3 прямоугольников, по 2 треугольника в каждом. Чтобы сделать его трехмерным, потребуется 16 прямоугольников. Их слишком много, чтобы перечислять здесь. 16 прямоугольников x 2 треугольника на прямоугольник x 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-пространстве вы обычно хотите, чтобы треугольники, обращенные к вам, считались передними.
Эй, дружище! Куда делись все треугольники? Оказывается, многие из них смотрят не в ту сторону. Поверните его, и вы увидите, что они появляются, когда вы смотрите на другую сторону. К счастью, это легко исправить. Мы просто смотрим, какие из них направлены назад, и меняем местами две их вершины. Например, если один треугольник назад – это
1, 2, 3,
40, 50, 60,
700, 800, 900,
мы просто переворачиваем последние 2 вершины, чтобы сделать его вперед.
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 в каждом из них означает, что нужно извлечь только три значения для каждого атрибута. Это работает, потому что в вершинном шейдере WebGL предоставляет значения по умолчанию для тех, которые вы не предоставляете. Значения по умолчанию: 0, 0, 0, 1, где x = 0, y = 0, z = 0 и w = 1. Вот почему в нашем старом 2D-вершинном шейдере нам пришлось явно указать 1. Мы передавали x и y, и нам нужна была единица для z, но поскольку по умолчанию для z используется 0, нам пришлось явно указать единицу. Для 3D, хотя мы и не указываем "w", по умолчанию используется значение 1, которое нам нужно для работы матричной математики.