Эта статья продолжает серию статей о WebGL. В первой из них мы начали с основ WebGL, а в предыдущей узнали о 2D-матрицах. Если вы их ещё не читали, рекомендую ознакомиться сначала с ними.
В последней статье мы разобрали, как работают матрицы в 2D. Мы выяснили, как перенос, поворот, масштабирование и даже проецирование пикселей в пространство отсечения могут быть выполнены с помощью одной матрицы и небольшой доли магии математики. 3D - это лишь небольшой дополнительный шаг от 2D-матриц.
В наших предыдущих 2D-примерах у нас было две точки (x, y), которые мы умножали на матрицу 3х3. В 3D нам нужно 3 точки (x, y, z) и матрицы 4х4.
Возьмём наш последний пример и перенесём его в 3D-пространство. Мы по-прежнему будем использовать 'F', но на этот раз это будет 'F' в 3D.
Первым делом нам нужно поменять вершинный шейдер для работы в 3D. Вот старый вершинный шейдер:
<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform mat3 u_matrix;
void main() {
// Умножаем координату на матрицу
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>
А вот новый:
<script id="vertex-shader-3d" type="x-shader/x-vertex">
*attribute vec4 a_position;
*uniform mat4 u_matrix;
void main() {
// Умножаем координату на матрицу
* gl_Position = u_matrix * a_position;
}
</script>
Он стал даже проще! Если в 2D-пространстве мы передавали x
и y
,
а z
устанавливали в 1, в 3D мы мы передаём x
, y
и z
. Ещё нам
нужно, чтобы w
равнялось единице, но здесь мы воспользуемся тем, что
для атрибута w
значение по умолчанию как раз равно 1.
Теперь нам нужно передать 3D-данные.
...
// Указываем атрибуту, как получать данные от positionBuffer (ARRAY_BUFFER)
* var size = 3; // 3 компоненты на итерацию
var type = gl.FLOAT; // наши данные - 32-битные числа с плавающей точкой
var normalize = false; // не нормализовать данные
var stride = 0; // 0 = перемещаться на size * sizeof(type) каждую итерацию для получения следующего положения
var offset = 0; // начинать с начала буфера
gl.vertexAttribPointer(
positionAttributeLocation, size, type, normalize, stride, offset);
...
// Заполняем текущий буфер ARRAY_BUFFER.
// Передаём значения, описывающие букву 'F'.
function setGeometry(gl) {
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// вертикальный столб
0, 0, 0,
30, 0, 0,
0, 150, 0,
0, 150, 0,
30, 0, 0,
30, 150, 0,
// верхняя перекладина
30, 0, 0,
100, 0, 0,
30, 30, 0,
30, 30, 0,
100, 0, 0,
100, 30, 0,
// перекладина посередине
30, 60, 0,
67, 60, 0,
30, 90, 0,
30, 90, 0,
67, 60, 0,
67, 90, 0]),
gl.STATIC_DRAW);
}
Теперь нам нужно поменять все функции по работе с матрицами из 2D в 3D.
Вот предыдущие 2D-версии функций m3.translation, m3.rotation и m3.scaling.
var m3 = {
translation: function translation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
},
rotation: function rotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
},
scaling: function scaling(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
},
};
А вот обновлённые версии функций для 3D.
var m4 = {
translation: function(tx, ty, tz) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1,
];
},
xRotation: function(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,
];
},
yRotation: function(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,
];
},
zRotation: function(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,
];
},
scaling: function(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
Поворот вокруг Y
Поворот вокруг X
И мы получаем наши повороты.
Подобным образом мы сделаем упрощённые функции.
translate: function(m, tx, ty, tz) {
return m4.multiply(m, m4.translation(tx, ty, tz));
},
xRotate: function(m, angleInRadians) {
return m4.multiply(m, m4.xRotation(angleInRadians));
},
yRotate: function(m, angleInRadians) {
return m4.multiply(m, m4.yRotation(angleInRadians));
},
zRotate: function(m, angleInRadians) {
return m4.multiply(m, m4.zRotation(angleInRadians));
},
scale: function(m, sx, sy, sz) {
return m4.multiply(m, m4.scaling(sx, sy, sz));
},
Нам также нужно изменить проекционную функцию. Старая функция:
projection: function (width, height) {
// Эта матрица переворачивает Y, чтобы 0 был наверху
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
},
}
которая преобразует пиксели в пространство отсечения. Для нашей первой попытки перехода к 3D попробуем следующее:
projection: function(width, height, depth) {
// Эта матрица переворачивает Y, чтобы 0 был наверху
return [
2 / width, 0, 0, 0,
0, -2 / height, 0, 0,
0, 0, 2 / depth, 0,
-1, 1, 0, 1,
];
},
Не забываем конвертировать координату Z из пикселей в пространство отсечения,
как мы делали с координатами X и Y. Мы передаём значение depth
для Z по
аналогии с width
, и наше пространство будет 0 пикселей шириной по width
,
0 пикселей высотой по height
и от -depth / 2
до +depth / 2
пикселей
глубиной по depth
.
Наконец, нам нужно изменить код для вычисления матриц.
// Задаём матрицы
* var matrix = m4.projection(gl.canvas.clientWidth, gl.canvas.clientHeight, 400);
* matrix = m4.translate(matrix, translation[0], translation[1], translation[2]);
* matrix = m4.xRotate(matrix, rotation[0]);
* matrix = m4.yRotate(matrix, rotation[1]);
* matrix = m4.zRotate(matrix, rotation[2]);
* matrix = m4.scale(matrix, scale[0], scale[1], scale[2]);
// Передаём матрицы шейдеру
* gl.uniformMatrix4fv(matrixLocation, false, matrix);
И вот пример.
Первая проблема заключается в том, что наша геометрия плоская, а по плоской 'F' сложно увидеть 3D. Для устранения этой проблемы перенесём геометрию в 3D. Наша 'F' сейчас сделана из 3 прямоугольников, 2 треугольника в каждом. Для 3D-формы понадобится в сумме 16 прямоугольников - 3 прямоугольника впереди, 3 сзади, один слева, 4 справа, 2 сверху и 3 снизу.
Довольно много, чтобы перечислять здесь. 16 прямоугольников с двумя треугольниками на каждый и 3 вершины на каждый треугольник - в сумме 96 вершин. Если хотите увидеть их все - посмотрите на исходный код примера.
Итак, нам нужно нарисовать больше вершин.
// Отрисовка геометрии
var primitiveType = gl.TRIANGLES;
var offset = 0;
* var count = 16 * 6;
gl.drawArrays(primitiveType, offset, count);
И мы получим пример 3D-геометрии
Передвигая слайдеры, довольно сложно определить форму 3D-объекта. Попробуем раскрасить каждый прямоугольник в свой цвет. Для этого добавим ещё один атрибут в вершинный шейдер и заведём varying-переменную для передачи значения из вершинного во фрагментный шейдер.
Вот новый вершинный шейдер:
<script id="vertex-shader-3d" type="x-shader/x-vertex">
attribute vec4 a_position;
+attribute vec4 a_color;
uniform mat4 u_matrix;
+varying vec4 v_color;
void main() {
// Умножаем координату на матрицу
gl_Position = u_matrix * a_position;
+ // Передаём цвет во фрагментный шейдер
+ v_color = a_color;
}
</script>
Во фрагментном шейдере нам нужно использовать переданный цвет.
<script id="fragment-shader-3d" type="x-shader/x-fragment">
precision mediump float;
+// Передаётся из вершинного шейдера
+varying vec4 v_color;
void main() {
* gl_FragColor = v_color;
}
</script>
Нам нужно получить ссылку на атрибут для передачи цветов, установить буфер и заполнить этот буфер данными цвета.
...
var colorLocation = gl.getAttribLocation(program, "a_color");
...
// Создаём буфер для цветов
var colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
// Заполняем буфер цветами
setColors(gl);
...
// Заполняем буфер цветами для буквы 'F'
function setColors(gl) {
gl.bufferData(
gl.ARRAY_BUFFER,
new Uint8Array([
// вертикальный столб
200, 70, 120,
200, 70, 120,
200, 70, 120,
200, 70, 120,
200, 70, 120,
200, 70, 120,
// верхняя перекладина
200, 70, 120,
200, 70, 120,
...
...
gl.STATIC_DRAW);
}
Затем при рендеринге мы описываем, как атрибут цвета будет получать данные из буфера цветов.
// Включаем атрибут цвета
gl.enableVertexAttribArray(colorLocation);
// Привязываем буфер цветов
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
// Указываем атрибуту, как получать данные от colorBuffer (ARRAY_BUFFER)
var size = 3; // 3 компоненты на итерацию
var type = gl.UNSIGNED_BYTE; // данные - 8-битные беззнаковые целые
var normalize = true; // нормализовать данные (конвертировать из 0-255 в 0-1)
var stride = 0; // 0 = перемещаться на size * sizeof(type) каждую итерацию для получения следующего положения
var offset = 0; // начинать с начала буфера
gl.vertexAttribPointer(
colorLocation, size, type, normalize, stride, offset)
Вот, что мы получим.
О-хо-хо, ну и каша. Оказывается, что разные части 3D-буквы 'F' - передняя, задняя, боковые и т.д. - отрисовываются в порядке следования данных геометрии. Это не даёт нам ожидаемый результат, так как иногда части, которые должны быть сзади, отрисовываются впереди.
Красным цветом отмечена передняя часть буквы 'F', но из-за того, что она отрисовывается первой, другие треугольники перекрывают её, хотя и находятся дальше. Например, фиолетовые части на самом деле должны находиться позади. Фиолетовые части рисуются вторыми, так как они идут вторыми в массиве данных.
Треугольники в WebGL могут располагаться лицевой или тыльной стороной. По умолчанию у треугольника с лицевой стороной вершины идут в направлении против часовой стрелке. У треугольника с тыльной стороной вершины идут в направлении по часовой стрелке.
В WebGL есть возможность отрисовывать только треугольники лицевой стороны или только треугольники тыльной стороны. Мы можем включить эту опцию через
gl.enable(gl.CULL_FACE);
Мы поместим этот код в функцию drawScene
. С этой включённой опцией
WebGL "отбраковывает" треугольники с тыльной стороной. Под "отбраковкой"
здесь можно понимать "не отрисовывать".
Следует заметить, что WebGL решает, повёрнут ли треугольник тыльной или лицевой стороной, когда вершины находятся в пространстве отсечения. Другими словами, направление по часовой или против часовой стрелке определяется ПОСЛЕ применения всех трансформаций. Это означает, что треугольник, у которого вершины расположены по часовой стрелке, после поворота по X на -1 становится тыльным и вершины в нём расположены уже против часовой стрелки. Если CULL_FACE отключен, мы видим и лицевые (вершины по часовой стрелке), и тыльные (вершины против часовой стрелки) треугольники. А после включения CULL_FACE, как только треугольники с лицевой стороной переворачиваются из-за масштабирования, поворота или чего-то ещё, WebGL перестаёт их отрисовывать. Хорошей практикой будет рассматривать те треугольники, которые направлены к вам, как треугольники с лицевой стороной.
С включённым CULL_FACE мы получим следующую картину:
Эй! Куда делись все треугольники? Оказывается, многие из них имеют неправильную направленность вершин. Поверните их и вы увидите, как они появляются с другой стороны. К счастью, это легко исправить. Нам нужно найти треугольники с тыльной стороной и поменять им 2 вершины местами. Например, если треугольник с тыльной стороной объявлен так:
1, 2, 3,
40, 50, 60,
700, 800, 900,
нам просто нужно переставить 2 вершины, чтобы сделать его сторону лицевой:
1, 2, 3,
700, 800, 900,
40, 50, 60,
Исправив все треугольники с тыльной стороной, мы получим
Это ближе к 3D, но всё же остаётся одна проблема. Даже когда все треугольники с лицевой стороной повёрнуты в правильном направлении, и когда все треугольники с тыльной скрываются, у нас по-прежнему есть места, где треугольники, которые должны быть на заднем плане, выходят на передний план и перекрывают другие треугольники.
И вот где появляется БУФЕР ГЛУБИНЫ.
Буфер глубины, иногда называемый z-буфер, - это прямоугольник пикселей глубины, и для создания изображения каждому пикселю цвета задаётся пиксель глубины. WebGL может отрисовать пиксель глубины точно так же, как он может отрисовать цветовой пиксель. Отрисовка выполняется на основе значения Z в вершинном шейдере. Z также находится в пространстве отсечения (от -1 до +1), и его нужно конвертировать так же, как X и Y. Затем это значение преобразовывается в пространство глубины (от 0 до +1). Перед отрисовкой пикселя WebGL проверяет соответствующий пиксель глубины. Если значение глубины рисуемого пикселя больше, чем значение соответствующего пикселя глубины, то WebGL не отрисовывает новый цветовой пиксель. В противном случае WebGL отрисовывает И новый цветовой пиксель с цветом из фрагментного шейдера, И пиксель глубины с новым значением глубины. Это означает, что пиксели, которые находятся позади других пикселей, не будут отрисовываться.
Мы можем включить эту опцию почти так же просто, как мы включали CULL_FACE
gl.enable(gl.DEPTH_TEST);
А ещё нам нужно очистить буфер глубины обратно в 1.0 перед отрисовкой.
// Отрисовка сцены
function drawScene() {
...
// Очищаем canvas И буфер глубины
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...
И теперь мы получили
Вот это настоящее 3D!
Одна небольшая деталь. В большинстве библиотек с 3D-математикой отсутствует
функция projection
, которая преобразовывает пиксели в пространство отсечения.
Обычно есть функция ortho
или orthographic
, которая выглядит примерно так:
var m4 = {
orthographic: function(left, right, bottom, top, near, far) {
return [
2 / (right - left), 0, 0, 0,
0, 2 / (top - bottom), 0, 0,
0, 0, 2 / (near - far), 0,
(left + right) / (left - right),
(bottom + top) / (bottom - top),
(near + far) / (near - far),
1,
];
}
В отличие от нашей упрощённой функции projection
, в которой были лишь ширина, высота и
глубина, в более распространённой ортографической функции мы можем передать левую, правую,
нижнюю, верхнюю, а также переднюю и заднюю плоскость, что даёт нам большую гибкость. Чтобы
заменить нашу предыдущую функцию, напишем следующий код:
var left = 0;
var right = gl.canvas.clientWidth;
var bottom = gl.canvas.clientHeight;
var top = 0;
var near = 400;
var far = -400;
m4.orthographic(left, right, bottom, top, near, far);
В следующей статье я расскажу о том, как создать перспективу.