WebGLFundamentals.org

WebGL 3D - Направленное освещение

Эта статья продолжает статью о камерах. Если вы её ещё не читали, рекомендую ознакомиться сначала с ней.

Есть множество способов создать освещение. Пожалуй, самым простым из них является направленное освещение.

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

Вычисление направленного освещения выполняется довольно легко. При известном угле направленного освещения и при известном положении поверхности мы можем вычислить скалярное произведение 2 направлений, что даст нам косинус угла между двумя направлениями.

Вот пример.

потяните за точки

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

Чем нам это может быть полезно? А тем, что, зная положение плоскости и направление падающего света, мы можем получить их скалярное произведение, что даст нам 1, когда свет падает прямо на поверхность, и -1, если свет падает с другой стороны.

измените направление

Мы можем умножить наш цвет на значение скалярного произведения и - БУМ! У нас есть освещение!

Введение в нормали

Понятия не имею, почему они называются нормали, но, по крайней мере, в 3D-графике нормали - единичные векторы, которые указывают направление поверхности.

Вот как выглядят нормали для куба и сферы.

Линии, торчащие из объектов, представляют собой нормали каждой из вершин.

Обратите внимание, что каждый угол куба содержит 3 нормали. Всё из-за того, что в каждой вершине сходятся 3 разных грани куба.

Нормали окрашиваются на основании направления, где положительное значение x становится красным, верх является зелёным, а положительное значение z становится синим.

Добавим нормали к нашему предыдущему примеру для создания освещения. Так как буква F очень угловатая и все грани выровнены по осям x, y или z, это будет очень просто. Грани, смотрящие вперёд, будут иметь нормали 0, 0, 1. Грани, направленные назад, будут иметь нормали 0, 0, -1. Направленные влево грани получат нормали -1, 0, 0, направленные вправо - 1, 0, 0, вверх - 0, 1, 0, а вниз 0, -1, 0.

function setNormals(gl) {
  var normals = new Float32Array([
          // лицо левого столба
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // лицо верхней перекладины
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // лицо перекладины посередине
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // тыл левого столба
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // тыл верхней перекладины
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // тыл перекладины посередине
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // верх
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // право верхней перекладины
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // низ верхней перекладины
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // между верхней и средней перекладиной
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // верх средней перекладины
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // право средней перекладины
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // низ средней перекладины
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // правая часть низа
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // низ
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // левая сторона
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0]);
  gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW);
}

Для того, чтобы можно было лучше рассмотреть освещение, уберём цвета в вершинах.

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

...

-// Создаём буфер для цветов
-var colorBuffer = gl.createBuffer();
-// Привязываем его к ARRAY_BUFFER (условно говоря, ARRAY_BUFFER = colorBuffer)
-gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
-// Записываем данные в буфер
-setColors(gl);

+// Создаём буфер для нормалей
+var normalBuffer = gl.createBuffer();
+// Привязываем его к ARRAY_BUFFER (условно говоря, ARRAY_BUFFER = normalBuffer)
+gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
+// Записываем данные в буфер
+setNormals(gl);

И при рендеринге

-// Включаем атрибут цвета
-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)

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

Теперь нужно использовать эти данные в шейдерах.

Сначала в вершинном шейдере нам нужно передать нормали во фрагментный шейдер.

attribute vec4 a_position;
-attribute vec4 a_color;
+attribute vec3 a_normal;

uniform mat4 u_matrix;

-varying vec4 v_color;
+varying vec3 v_normal;

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

-  // Передаём цвет во фрагментный шейдер
-  v_color = a_color;

+  // Передаём нормаль во фрагментный шейдер
+  v_normal = a_normal;
}

А во фрагментном шейдере выполним скалярное умножение направления света и нормали.

precision mediump float;

// Передаются из вершинного шейдера
-varying vec4 v_color;
+varying vec3 v_normal;

+uniform vec3 u_reverseLightDirection;
+uniform vec4 u_color;

void main() {
+   // v_normal - varying-переменная и будет интерполинована,
+   // после этого она не будет единичным вектором. Нормализуем
+   // её, чтобы снова получить единичный вектор.
+   vec3 normal = normalize(v_normal);
+
+   float light = dot(normal, u_reverseLightDirection);

*   gl_FragColor = u_color;

+   // Умножим только цвет (без прозрачности) на значение
+   // освещения
+   gl_FragColor.rgb *= light;
}

Затем получаем ссылку на u_color и u_reverseLightDirection.

  // получаем ссылку на uniform-переменные
  var matrixLocation = gl.getUniformLocation(program, "u_matrix");
+  var colorLocation = gl.getUniformLocation(program, "u_color");
+  var reverseLightDirectionLocation =
+      gl.getUniformLocation(program, "u_reverseLightDirection");

и задаём им значения

  // Задаём значение матрицы
  gl.uniformMatrix4fv(matrixLocation, false, worldViewProjectionMatrix);

+  // Устанавливаем цвет
+  gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]); // зелёный
+
+  // Задаём направление света
+  gl.uniform3fv(reverseLightDirectionLocation, m4.normalize([0.5, 0.7, 1]));

Функция normalize, которую мы рассматривали ранее, преобразует любое переданное в неё значение в единичный вектор. В рассмотренном примере используются следующие значения: x = 0.5, положительное значение x означает, что свет находится справа и направлен влево; y = 0.7, положительное значение y означает, что свет находится сверху и направлен вниз; z = 1, положительное значение z означает, что свет находится впереди и указывает на сцену. Относительные значения задают направление света главным образом на сцену - сильнее вниз и немного меньше влево.

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

нажмите здесь, чтобы открыть в отдельном окне

При повороте F вы можете заметить, что освещение на ней не меняется. Но самыми яркими должны быть те части, которые направлены на камеру.

Для того, чтобы исправить эту ситуацию, нам нужно переориентировать нормали при изменении положения объекта. Как и в случае с вершинами, мы можем умножить нормали на матрицу. Наиболее очевидным выбором будет мировая матрица. Сейчас мы передаём одну матрицу u_matrix. А будем передавать 2 матрицы. Одна, u_world, будет мировой матрицей. Другая, u_worldViewProjection, будет выполнять роль прежней u_matrix.

attribute vec4 a_position;
attribute vec3 a_normal;

*uniform mat4 u_worldViewProjection;
+uniform mat4 u_world;

varying vec3 v_normal;

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

*  // Направляем нормали и передаём во фрагментный шейдер
*  v_normal = mat3(u_world) * a_normal;
}

Заметьте, что мы умножаем a_normal на mat3(u_world). Нормали - это направления, поэтому нам не важен перенос. Направление занимает лишь верхнюю часть 3х3 матрицы.

Теперь получим ссылки на эти uniform-переменные

  // получаем ссылки на uniform-переменные
*  var worldViewProjetionLocation =
*      gl.getUniformLocation(program, "u_worldViewProjection");
+  var worldLocation = gl.getUniformLocation(program, "u_world");

Затем обновим код, который их устанавливает.

*// Задаём значения матрицам
*gl.uniformMatrix4fv(
*    worldViewProjectionLocation, false,
*    worldViewProjectionMatrix);
*gl.uniformMatrix4fv(worldLocation, false, worldMatrix);

И вот, что мы получаем:

нажмите здесь, чтобы открыть в отдельном окне

Теперь при повороте F подсвечиваются те грани, которые направлены на наблюдателя.

Существует одна проблема, которую я не могу показать на демо, поэтому покажу на картинке. Для переориентации нормалей мы умножаем normal на матрицу u_world. Но что произойдёт при масштабировании мировой матрицы? Ответ - мы получим неверные нормали.

кликните для переключения нормалей

Я никогда не пытался понять решение, но оказывается, что можно инвертировать мировую матрицу, транспонировать её (то есть поменять местами столбцы и строки), и использовать эту матрицу - так мы получим правильный результат.

Фиолетовая сфера на картинке выше не искажена. Красная сфера слева масштабирована, и нормали умножились на мировую матрицу. Можно заметить, что что-то не так с освещением. Синяя сфера справа использует обратную транспонированную от мировой матрицы.

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

В соответствии с вышеизложенным изменим код нашего примера. Для начала обновим код шейдера. Технически мы могли бы просто обновить значение u_world, но лучше нам переименовать переменные, чтобы они отражали своё предназначение и чтобы мы не запутывались.

attribute vec4 a_position;
attribute vec3 a_normal;

uniform mat4 u_worldViewProjection;
*uniform mat4 u_worldInverseTranspose;

varying vec3 v_normal;

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

  // Направляем нормали и передаём во фрагментный шейдер
*  v_normal = mat3(u_worldInverseTranspose) * a_normal;
}

Получаем ссылки на переменные

-  var worldLocation = gl.getUniformLocation(program, "u_world");
+  var worldInverseTransposeLocation =
+      gl.getUniformLocation(program, "u_worldInverseTranspose");

А затем вычисляем значения и устанавливаем их переменным

var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix, worldMatrix);
var worldInverseMatrix = m4.inverse(worldMatrix);
var worldInverseTransposeMatrix = m4.transpose(worldInverseMatrix);

// Установка значений матриц
gl.uniformMatrix4fv(
    worldViewProjectionLocation, false,
    worldViewProjectionMatrix);
-gl.uniformMatrix4fv(worldLocation, false, worldMatrix);
+gl.uniformMatrix4fv(
+    worldInverseTransposeLocation, false,
+    worldInverseTransposeMatrix);

Теперь код транспонирования матрицы

var m4 = {
  transpose: function(m) {
    return [
      m[0], m[4], m[8], m[12],
      m[1], m[5], m[9], m[13],
      m[2], m[6], m[10], m[14],
      m[3], m[7], m[11], m[15],
    ];
  },

  ...

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

нажмите здесь, чтобы открыть в отдельном окне

Надеюсь, что первая часть освещения понятна. Далее мы разберём точечное освещение.

Альтернативы для mat3(u_worldInverseTranspose) * a_normal

В нашем шейдере выше есть такая строка

v_normal = mat3(u_worldInverseTranspose) * a_normal;

Мы могли бы использовать

v_normal = (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;

Установка w в значение 0 пред умножением приведёт к умножению значения переноса из матрицы на 0, то есть уберёт его. Думаю, что это более распространённый подход. Просто мне кажется, что mat3 выглядит аккуратней, хотя я использовал и другой подход тоже.

Ещё можно использовать тип mat3 для u_worldInverseTranspose. Но есть 2 причины, почему так делать не следует. Во-первых, нам может понадобиться полная версия u_worldInverseTranspose, то есть в некоторых случаях нам нужна mat4. Во-вторых, все функции JavaScript работают с матрицами 4x4. Делать второй набор функций для работы с матрицами 3x3 или даже конвертировать матрицы 4x4 в 3x3 (и всё только для того, чтобы покрыть этот единственный случай) - это не целесообразно.


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