Эта статья продолжает серию статей о 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);
В следующей статье я расскажу о том, как создать перспективу.