оглавление

WebGLFundamentals.org

Fix, Fork, Contribute

WebGL 3D - Перспективная коррекция текстур

Эта статья продолжает серию, которая начинается с Основ WebGL. В этой статье мы рассмотрим, как правильно использовать текстуры с учётом перспективы. Для понимания вам, вероятно, понадобится прочитать о перспективе и текстурах. Также может понадобиться знание о varying-переменных, хотя отчасти эта тема будет затронута.

В статье о "работе WebGL" мы рассмотрели работу varying-переменных. Varying-переменная объявляется и получает своё значение в вершинном шейдере. После трёх вызовов вершинного шейдера WebGL отрисует треугольник. Во время отрисовки этого треугольника для каждого пикселя будет вызван фрагментный шейдер, который определит цвет текущего пикселя. Цвет при этом будет интерполирован между тремя вершинами треугольника.

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

Вернёмся к нашей первой статье, где мы отображали треугольник в пространстве отсечения, где не было никакой математики. Мы просто передавали координаты в пространстве отсечения в простой вершинный шейдер, который выглядел следующим образом:

  // атрибут, который будет получать данные из буфера
  attribute vec4 a_position;

  // все шейдеры имеют функцию main
  void main() {

    // gl_Position - специальная переменная вершинного шейдера,
    // которая отвечает за установку положения
    gl_Position = a_position;
  }

Был у нас и простой фрагментный шейдер, который отображал один и тот же цвет.

  // фрагментные шейдеры не имеют точности по умолчанию, поэтому нам необходимо её
  // указать. mediump подойдёт для большинства случаев. Он означает "средняя точность"
  precision mediump float;

  void main() {
    // gl_FragColor - специальная переменная фрагментного шейдера.
    // Она отвечает за установку цвета.
    gl_FragColor = vec4(1, 0, 0.5, 1); // вернёт красновато-фиолетовый
  }

Итак, отобразим 2 треугольника в пространстве отсечения. Мы передадим данные X, Y, Z и W для каждой вершины.

var positions = [
  -.8, -.8, 0, 1,  // первый треугольник первого прямоугольника
   .8, -.8, 0, 1,
  -.8, -.2, 0, 1,
  -.8, -.2, 0, 1,  // второй треугольник первого прямоугольника
   .8, -.8, 0, 1,
   .8, -.2, 0, 1,

  -.8,  .2, 0, 1,  // первый треугольник второго прямоугольника
   .8,  .2, 0, 1,
  -.8,  .8, 0, 1,
  -.8,  .8, 0, 1,  // второй треугольник второго прямоугольника
   .8,  .2, 0, 1,
   .8,  .8, 0, 1,
];

Результат получится следующий:

Теперь добавим некоторую varying-переменную с плавающей точкой. Передадим её напрямую из вершинного шейдера во фрагментный шейдер.

  attribute vec4 a_position;
+  attribute float a_brightness;

+  varying float v_brightness;

  void main() {
    gl_Position = a_position;

+    // передаём яркость во фрагментный шейдер
+    v_brightness = a_brightness;
  }

Во фрагментном шейдере используем varying-переменную для получения цвета.

  precision mediump float;

+  // интерполированная переменная из вершинного шейдера
+  varying float v_brightness;  

  void main() {
*    gl_FragColor = vec4(v_brightness, 0, 0, 1);  // красный
  }

Теперь нам нужно задать данные для varying-переменной, поэтому мы создадим буфер и поместим в него какие-нибудь значения - одно значение на одну вершину. Для вершин слева мы установим яркость в значение 0, а для правых вершин - в значение 1.

  // создаём буфер и помещаем в него 12 значений яркости
  var brightnessBuffer = gl.createBuffer();

  // привязываем буфер к ARRAY_BUFFER (можно сказать, что ARRAY_BUFFER = brightnessBuffer)
  gl.bindBuffer(gl.ARRAY_BUFFER, brightnessBuffer);

  var brightness = [
    0,  // первый треугольник первого прямоугольника
    1,
    0,
    0,  // второй треугольник первого прямоугольника
    1,
    1,

    0,  // первый треугольник второго прямоугольника
    1,
    0,
    0,  // второй треугольник второго прямоугольника
    1,
    1,
  ];

  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(brightness), gl.STATIC_DRAW);

Затем получаем ссылку на атрибут a_brightness во время инициализации

  // сюда пойдут данные для вершин
  var positionAttributeLocation = gl.getAttribLocation(program, "a_position");
+  var brightnessAttributeLocation = gl.getAttribLocation(program, "a_brightness");

и устанавливаем значение атрибута при отрисовке

  // включаем атрибут
  gl.enableVertexAttribArray(brightnessAttributeLocation);

  // привязываем буфер
  gl.bindBuffer(gl.ARRAY_BUFFER, brightnessBuffer);

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

И теперь при отрисовке мы получим два прямоугольника, которые имеют чёрный цвет слева, где brightness равна нулю, и красный цвет справа, где brightness равна 1. Внутри прямоугольников цвет будет плавно переходить от чёрного к красному (интерполяция varying-переменной).

Идём дальше. Из статьи о перспективе мы знаем, что WebGL принимает значение, которое мы поместили в gl_Position, и делит это значение на gl_Position.w.

Ранее в вершинах мы передавали 1 для W, но с нашим знанием о том, что WebGL разделит все значения на W, мы можем сделать нечто подобное и получить идентичный результат:

  var mult = 20;
  var positions = [
      -.8,  .8, 0, 1,  // первый треугольник первого прямоугольника
       .8,  .8, 0, 1,
      -.8,  .2, 0, 1,
      -.8,  .2, 0, 1,  // второй треугольник первого прямоугольника
       .8,  .8, 0, 1,
       .8,  .2, 0, 1,

      -.8       , -.2       , 0,    1,  // первый треугольник второго прямоугольника
       .8 * mult, -.2 * mult, 0, mult,
      -.8       , -.8       , 0,    1,
      -.8       , -.8       , 0,    1,  // второй треугольник второго прямоугольника
       .8 * mult, -.2 * mult, 0, mult,
       .8 * mult, -.8 * mult, 0, mult,
  ];

В отрывке кода выше мы умножаем X и Y всех правых точек второго прямоугольника на mult, а затем устанавливаем параметру W значение mult. WebGL разделит все значения на W, поэтому в итоге мы должны получить то же самое, так ведь?

Взглянем на результат.

Мы видим, что два прямоугольника отрисовались в том же месте, где были до этого. Это значит, что X * MULT / MULT(W) по-прежнему равно X, то же самое справедливо и для Y. Но цвет довольно сильно изменился. Что произошло?

Оказывается, что WebGL использует W для перспективной коррекции текстуры, а точнее для перспективной коррекции интерполяции значений varying-переменных.

Для большей наглядности давайте изменим шейдер

gl_FragColor = vec4(fract(v_brightness * 10.), 0, 0, 1);  // красный

Умножая v_brightness на 10, мы получим значение от 0 до 10. Функция fract оставит только дробную часть, которая будет переходить от 0 до 1, от 0 до 1, от 0 до 1 - и так 10 раз.

Теперь нам легко увидеть перспективу.

Линейная интерполяция от одного значения до другого описывается следующей формулой:

 result = (1 - t) * a + t * b

где t изменяется от 0 до 1 и представляет собой определённое положение между a и b. Значение 0 соответствует a, а 1 соответствует b.

Однако, для varying-переменных WebGL использует следующую формулу

 result = (1 - t) * a / aW + t * b / bW
          -----------------------------
             (1 - t) / aW + t / bW

Здесь aW - это W, установленное в значение gl_Position.w, когда varying-переменная равна a. А bW - это значение W, установленное в значение gl_Position.w, когда varying-переменная равна b.

Так ли всё это важно? Взглянем на простой куб с текстурой, на котором мы закончили статью о текстурах. UV-координаты принимают значения от 0 до 1 на каждой стороне. Используется текстура размером 4х4 пикселя.

Теперь изменим вершинный шейдер в этом примере таким образом, чтобы мы самостоятельно выполняли деление на W. Нам нужно добавить всего 1 строку:

attribute vec4 a_position;
attribute vec2 a_texcoord;

uniform mat4 u_matrix;

varying vec2 v_texcoord;

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

+  // делим на W
+  gl_Position /= gl_Position.w;

  // передаём текстурные координаты во фрагментный шейдер
  v_texcoord = a_texcoord;
}

Деление на W означает, что конечное значение gl_Position.w будет равно 1. Значения X, Y и Z будут такими же, как если бы WebGL выполнял деление за нас. Посмотрим на результат.

В итоге мы получили тот же 3D-куб, но текстуры заметно исказились. Дело в том, что если не передать W, WebGL не сможет выполнить перспективную коррекцию наложения текстуры. Точнее, WebGL не сможет корректно выполнить перспективную интерполяцию varying-переменных.

Как вы помните из статьи о матрицах перспективы, значение W было нашим значением Z. При W равном 1 мы в итоге получаем линейную интерполяцию. То есть если взять уравнение выше

 result = (1 - t) * a / aW + t * b / bW
          -----------------------------
             (1 - t) / aW + t / bW

и заменить все W на 1

 result = (1 - t) * a / 1 + t * b / 1
          ---------------------------
             (1 - t) / 1 + t / 1

учитывая, что деление на 1 можно отбросить

 result = (1 - t) * a + t * b
          -------------------
             (1 - t) + t

далее (1 - t) + t упрощается до 1 и весь знаменатель уходит, у нас остаётся лишь

 result = (1 - t) * a + t * b

что в точности повторяет уравнение линейной интерполяции, приведённое выше.

Надеюсь, теперь стало понятно, почему WebGL использует матрицы 4х4 и вектора из четырёх элементов X, Y, Z и W. Значения X и Y делятся на W и мы получаем координаты пространства отсечения. Значение Z делится на W, в результате чего также получается координата по оси Z в пространстве отсечения. А W используется при интерполяции значений varying-переменных и обеспечивает перспективную коррекцию при наложении текстур.

Игровые консоли середины 1990-х

В качестве небольшого дополнения отмечу, что Playstation 1 и некоторые другие игровые консоли того же времени не выполняли перспективную коррекцию текстур. Глядя на результаты, которые мы получали в данной статье, вы понимаете, почему игры выглядели именно так, как они выглядели.

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