оглавление

WebGLFundamentals.org

Fix, Fork, Contribute

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

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="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);

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

Почему атрибут 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, нас вполне устраивает для работы матриц.

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