оглавление

WebGLFundamentals.org

WebGL 3D - Ортогональ

Эта статья продолжает серию статей о 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="2d-vertex-shader" 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="3d-vertex-shader" type="x-shader/x-vertex">
*attribute vec4 a_position;

*uniform mat4 u_matrix;

void main() {
  // Умножаем координату на матрицу
*  gl_Position = u_matrix * a_position;
}
</script>

Он стал даже проще!

Теперь нам нужно передать 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

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;

И мы получаем наши повороты.

Подобным образом мы сделаем упрощённые функции.

  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="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() {
  // Умножаем координату на матрицу
  gl_Position = u_matrix * a_position;

+  // Передаём цвет во фрагментный шейдер
+  v_color = a_color;
}
</script>

Во фрагментном шейдере нам нужно использовать переданный цвет.

<script id="3d-vertex-shader" 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);

В следующей статье я расскажу о том, как создать перспективу.

Почему атрибут vec4, а gl.vertexAttribPointer имеет размер 3

Внимательный к деталям читатель мог заметить, что мы определили 2 атрибута следующим образом:

attribute vec4 a_position;
attribute vec4 a_color;

оба из которых 'vec4', но при передаче данных из буфера мы использовали

// Указываем атрибуту, как получать данные от 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);

...
// Указываем атрибуту, как получать данные от colorBuffer (ARRAY_BUFFER)
var size = 3;          // 3 компоненты на итерацию
var type = gl.UNSIGNED_BYTE;   // данные - 8-битные беззнаковые целые
var normalize = true;  // конвертировать из 0-255 в 0.0-1.0
var stride = 0;        // 0 = перемещаться на size * sizeof(type) каждую
                       // итерацию для получения следующего положения
var offset = 0;        // начинать с начала буфера
gl.vertexAttribPointer(
    colorAttributeLocation, size, type, normalize, stride, offset);

Цифра '3' означает, что нужно выбрать 3 значения из буфера на атрибут за одну итерацию в вершинном шейдере. Это работает, потому что в вершинном шейдере WebGL заполняет пропущенные параметры значениями по умолчанию. Этими значениями по умолчанию являются 0, 0, 0, 1, где x = 0, y = 0, z = 0 и w = 1. Вот почему в нашем старом вершинном шейдере 2D нам приходилось явно указывать 1. Мы передавали x и y, а ещё нужно было установить 1 для z, так как значением по умолчанию для z является 0. Однако, в 3D, даже если мы не передадим 'w', его значение по умолчанию, которое равняется 1, нас вполне устраивает для работы матриц.

Вопросы? Спросите на stackoverflow.
Нашли ошибку? Создайте задачу на github.
comments powered by Disqus