оглавление

WebGLFundamentals.org

Fix, Fork, Contribute

Как работает WebGL

Это продолжение основ WebGL. Перед нашим погружением я думаю, что нужно обсудить на базовом уровне, что делают на самом деле WebGL и видеокарта. По существу, графический процессор выполняет две задачи. Первая задача - обработка положений (массива данных) и вершин пространства отсечения. Вторая задача - отрисовка пикселей на основе результатов первой задачи.

Когда вы вызываете

var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 9;
gl.drawArrays(primitiveType, offset, count);

цифра 9 означает "обработай 9 вершин" и 9 вершин обрабатываются:

Слева отображены переданные вами данные. Вершинный шейдер - это функция, которую вы написали на GLSL. Она вызывается один раз на каждую вершину. Вы описываете математическую часть и устанавливаете специальную переменную gl_Position со значением координат пространства отсечения для текущей вершины. Видеокарта принимает это значение и хранит его во внутренней памяти.

Предположим, вы отрисовываете TRIANGLES, и каждый раз, когда первая задача создаёт 3 вершины, видеокарта использует их для создания треугольника. Она определяет, какие пиксели соответствуют 3 точкам треугольника и затем растеризует треугольник. Растеризация означает "отрисовка объекта пикселями". Для каждого пикселя будет вызван фрагментный шейдер, где вас спросят, каким цветом нужно закрасить этот пиксель. Чтобы ответить на этот вопрос, ваш фрагментный шейдер должен установить специльной переменной gl_FragColor значение цвета для данного пикселя.

Всё это очень интересно, но как вы могли видеть раньше в наших примерах до фрагментного шейдера доходило очень мало информации о пикселе. К счастью, мы можем передать больше информации. Мы определим “varying-переменные” для каждого значения, которое мы хотим передать из вершинного шейдера во фрагментный шейдер.

В качестве простого примера рассмотрим передачу координат пространства отсечения напрямую из вершинного шейдера во фрагментный шейдер.

Мы нарисуем простой треугольник. Возьмём наш предыдущий пример и заменим F на треугольник.

// заполнение буфера значениями, определяющими треугольник
function setGeometry(gl) {
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
             0, -100,
           150,  125,
          -175,  100]),
      gl.STATIC_DRAW);
}

И нам нужно отрисовать всего 3 вершины

// рисуем сцену
function drawScene() {
  ...
  // рисуем геометрию
  var primitiveType = gl.TRIANGLES;
  var offset = 0;
  var count = 3;
  gl.drawArrays(primitiveType, offset, count);
}

Затем в нашем вершинном шейдере мы определяем varying-переменную для передачи данных во фрагментный шейдер.

varying vec4 v_color;
...
void main() {
  // умножение положения на матрицу
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);

  // преобразование из пространства отсечения в пространство цвета
  // пространство отсечения находится в диапазоне от -1.0 до +1.0
  // пространство цвета находится в диапазоне от 0.0 до 1.0
*  v_color = gl_Position * 0.5 + 0.5;
}

И теперь мы объявляем ту же самую varying-переменную во фрагментном шейдере.

precision mediump float;

*varying vec4 v_color;

void main() {
*  gl_FragColor = v_color;
}

WebGL свяжет varying-переменную в вершинном шейдере с varying-переменной с тем же именем во фрагментном шейдере.

Вот рабочий пример.

Перемещайте, масштабируйте и поворачивайте треугольник. Обратите внимание, что из-за того, что цвета рассчитываются из координат, цвета не двигаются вместе с треугольником. Цвета зависят не от объекта, а от координат пикселя на canvas'е.

Теперь подумаем над этим. Мы задали только 3 вершины. Наш вершинный шейдер вызвался только 3 раза и рассчитал, соответственно, 3 цвета, однако у нашего треугольника гораздо больше цветов. Собственно, поэтому переменные и называются varying (англ. varying - изменяющийся).

WebGL принимает 3 рассчитанных нами значения для каждой вершины и затем при растеризации треугольника происходит интерполяция между значениями вершин. Для каждого пикселя WebGL вызывает фрагментный шейдер с интерполированным значением для этого пикселя.

В примере выше мы начали с 3 вершин

Вершины
0-100
150125
-175100

Наш вершинный шейдер применил матрицу для переноса, вращения, масштабирования и конвертирования координат в пространство отсечения. Значения по умолчанию для этих операций следующие: перенос = 200, 150, поворот = 0, масштаб = 1, 1 - то есть объект не поворачивается и не масштабируется, а только переносится. Исходя из того, что наш вторичный буфер имеет размер 400х300, вершинный шейдер применит матрицу и вычислит следующие 3 значения вершин пространства отсечения.

значения в gl_Position
0.0000.660
0.750-0.830
-0.875-0.660

Он также преобразует их в цвета и запишет значение в varying-переменную v_color, которую мы объявили.

значения в v_color
0.50000.8300.5
0.87500.0860.5
0.06250.1700.5

Эти 3 значения в v_color затем интерполируются и передадутся во фрагментный шейдер для каждого пикселя.

v_color интерполируется между v0, v1 и v2

Также в наших силах передать больше данных вершинному шейдеру, которые затем мы можем передать дальше во фрагментный шейдер. Нарисуем для примера прямоугольник, состоящий из 2 треугольников 2 разных цветов. Чтобы это сделать, добавим ещё один атрибут вершинному шейдеру, чтобы передать ему больше данных, которые дальше пойдут во фрагментный шейдер.

attribute vec2 a_position;
+attribute vec4 a_color;
...
varying vec4 v_color;

void main() {
   ...
  // копируем цвет из атрибута в varying-переменную
*  v_color = a_color;
}

Теперь нужно передать цвета для использования в WebGL.

  // получаем ссылки на атрибуты, куда запишутся данные
  var positionLocation = gl.getAttribLocation(program, "a_position");
+  var colorLocation = gl.getAttribLocation(program, "a_color");
  ...
+  // создаём буфер для цветов
+  var colorBuffer = gl.createBuffer();
+  gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
  // устанавливаем цвета
+  setColors(gl);
  ...

+// заполняем буфер цветами для двух треугольников,
+// образующих прямоугольник
+function setColors(gl) {
+  // выбираем 2 случайных цвета
+  var r1 = Math.random();
+  var b1 = Math.random();
+  var g1 = Math.random();
+
+  var r2 = Math.random();
+  var b2 = Math.random();
+  var g2 = Math.random();
+
+  gl.bufferData(
+      gl.ARRAY_BUFFER,
+      new Float32Array(
+        [ r1, b1, g1, 1,
+          r1, b1, g1, 1,
+          r1, b1, g1, 1,
+          r2, b2, g2, 1,
+          r2, b2, g2, 1,
+          r2, b2, g2, 1]),
+      gl.STATIC_DRAW);
+}

Во время отрисовки настраиваем атрибут цвета

+gl.enableVertexAttribArray(colorLocation);
+
+// Привязываем буфер цветов.
+gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
+
+// Указываем атрибуту, как получать данные от colorBuffer (ARRAY_BUFFER)
+var size = 4;          // 4 компоненты на итерацию
+var type = gl.FLOAT;   // наши данные - 32-битные числа с плавающей точкой
+var normalize = false; // не нормализовать данные
+var stride = 0;        // 0 = перемещаться на size * sizeof(type) каждую итерацию для получения следующего положения
+var offset = 0;        // начинать с начала буфера
+gl.vertexAttribPointer(
+    colorLocation, size, type, normalize, stride, offset)

И устанавливаем count для вычисления 6 вершин 2 треугольников

// Отрисовка геометрии.
var primitiveType = gl.TRIANGLES;
var offset = 0;
*var count = 6;
gl.drawArrays(primitiveType, offset, count);

И вот результат:

Заметьте, что у нас получилось 2 треугольника со сплошным цветом. Однако, мы передаём значения в varying-переменной и эти значения интерполируются при отрисовке треугольника. Но так как мы использовали одинаковый цвет для всех 3 вершин каждого треугольника, цвет не меняется. Если сделать цвет каждой вершины различным, мы увидим интерполяцию.

// заполняем буфер цветами для двух треугольников,
// образующих прямоугольник
function setColors(gl) {
  // делаем отдельный цвет для каждой вершины
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array(
*        [ Math.random(), Math.random(), Math.random(), 1,
*          Math.random(), Math.random(), Math.random(), 1,
*          Math.random(), Math.random(), Math.random(), 1,
*          Math.random(), Math.random(), Math.random(), 1,
*          Math.random(), Math.random(), Math.random(), 1,
*          Math.random(), Math.random(), Math.random(), 1]),
      gl.STATIC_DRAW);
}

И теперь мы видим интерполированную varying-переменную.

Полагаю, выглядит не особо захватывающе, но это отлично демонстрирует использование более одного атрибута и передачу данных из вершинного шейдера во фрагментный шейдер. Если вы посмотрите на пример обработки изображений, вы увидите, что там также используется дополнительный атрибут для передачи координат текстур.

Что делают эти команды, связанные с буферами и атрибутами?

Буферы - средство передачи вершин и вершинных данных на видеокарту. gl.createBuffer создаёт буфер. gl.bindBuffer устанавливает данный буфер как активный буфер, с которым будет происходить работа. gl.bufferData копирует данные в активный буфер. Обычно выполняется на этапе инициализации.

Когда данные помещены в буфер, нам нужно подсказать WebGL, как извлечь эти данные и передать атрибуту вершинного шейдера.

Чтобы это сделать, сначала получим у WebGL ссылку на атрибут. Например, в коде выше у нас было

// получаем ссылки на атрибуты, куда запишутся данные
var positionLocation = gl.getAttribLocation(program, "a_position");
var colorLocation = gl.getAttribLocation(program, "a_color");

Этот код обычно выполняется на этапе инициализации. Когда нам известна ссылка на атрибут, мы можем выполнить 3 команды прямо перед отрисовкой.

gl.enableVertexAttribArray(location);

Эта команда говорит WebGL, что мы хотим получать данные из буфера.

gl.bindBuffer(gl.ARRAY_BUFFER, someBuffer);

Здесь буфер привязывается к точке связи ARRAY_BUFFER. Это глобальная переменная внутри WebGL.

gl.vertexAttribPointer(
    location,
    numComponents,
    typeOfData,
    normalizeFlag,
    strideToNextPieceOfData,
    offsetIntoBuffer);

А эта команда говорит WebGL получить данные из буфера, который привязан к точке связи ARRAY_BUFFER; сколько компонентов уходит на вершину (1 - 4); какого типа данные (BYTE, FLOAT, INT, UNSIGNED_SHORT, и т.д.); шаг, который указывает, сколько байтов нужно пропустить, чтобы перейти от одной порции данных к следующей; отступ, чтобы указать, как далеко от начала буфера находятся наши данные.

Количество компонентов всегда от 1 до 4.

Если вы используете 1 буфер на один тип данных, тогда шаг и отступ можно всегда устанавливать в значение 0. Шаг со значением 0 означает, что нужно использовать шаг который соответствует этому типу и размеру". Отступ со значением 0 означает, что нужно начинать с самого начала буфера. Установка этих значений отличными от 0 нужна в более сложных случаях, и хотя это даёт некоторые преимущества в плане производительности, оно не стоит той сложности, пока вам не нужно использовать WebGL на полную катушку.

Надеюсь, с буферами и атрибутами стало понятней.

Далее пройдёмся по шейдерам и GLSL.

Что такое normalizeFlag в vertexAttribPointer?

Флаг нормализации предназначен для всех типов без плавающей точки. Если вы передадите false, типы значений будут интерпретироваться как есть. BYTE занимает диапазон от -128 до 127, UNSIGNED_BYTE от 0 до 255, SHORT от -32768 до 32767 и т.д.

Если установить флаг нормализации в значение true, значения BYTE (-128 to 127) будут представлять значения от -1.0 до +1.0, UNSIGNED_BYTE (0 to 255) от 0.0 до +1.0. Нормализованный SHORT тоже станет от -1.0 до +1.0, просто большей дискретности по сравнению с BYTE.

Наиболее часто нормализованные данные используются для цветов. Чаще всего цвет варьируется от 0.0 до 1.0. Использование типа float для красного, зелёного, синего цвета и прозрачности заняло бы 16 байтов на цвет каждой вершины. Если у вас сложная геометрия, такое использование прибавит большое количество байтов. Вместо этого вы можете конвертировать ваши цвета в UNSIGNED_BYTE, где 0 представляет 0.0, а 255 представляет 1.0. Теперь ван нужно всего 4 байта на цвет каждой вершины, что даёт экономию по памяти в 75%.

Давайте изменим код соответствующим образом. Когда мы говорим WebGL, как извлекать наши цвета, мы бы использовали

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

И при заполнении буфера цветом мы бы написали

// заполняем буфер цветами для двух треугольников,
// образующих прямоугольник
function setColors(gl) {
  // выбираем 2 случайных цвета
  var r1 = Math.random() * 256; // от 0 до 255.99999
  var b1 = Math.random() * 256; // эти значения
  var g1 = Math.random() * 256; // будут усечены
  var r2 = Math.random() * 256; // чтобы поместиться
  var b2 = Math.random() * 256; // Uint8Array
  var g2 = Math.random() * 256;

  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Uint8Array(   // Uint8Array
        [ r1, b1, g1, 255,
          r1, b1, g1, 255,
          r1, b1, g1, 255,
          r2, b2, g2, 255,
          r2, b2, g2, 255,
          r2, b2, g2, 255]),
      gl.STATIC_DRAW);
}

Вот пример этого кода:

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