Продолжаем изучение OpenGL: освещение по Фонгу

25 ноября 2015

За последнее время мы довольно много узнали об OpenGL, в том числе научились управлять камерой, работать с текстурами, а также с моделями. Настало время поговорить о чем-то намного более интересном, а именно — об освещении. Интересна эта тема, потому что ничего готового для работы со светом в OpenGL нет, все нужно писать самостоятельно на шейдерах. В рамках этой заметки мы рассмотрим освещение по Фонгу. Это довольно большая тема, поэтому говорить мы будем исключительно об освещении. В том, как делаются тени, придется разобраться в другой раз.

Сохранение и использование нормалей

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

Мы уже знаем, что у моделей есть вершины и соответствующие этим вершинам UV-координаты. Для создания освещения нам понадобится еще кое-какая информация о моделях, а именно — нормали. Нормаль — это единичный вектор, соответствующей вершине (или, как вариант, полигону, но это не наш случай). Какую именно роль играют нормали при реализации освещения мы узнаем ниже. Пока достаточно сказать, что нормали действительно очень важны. Например, благодаря им поверхности выглядят более гладкими и можно отличить шар от правильного выпуклого многогранника вроде икосаэдра. А раз нормали так важны, нам нужно научиться их сохранять при преобразовании моделей Blender в наш собственный формат.

Соответствующие изменения довольно тривиальны. Мы получаем нормали точно так же, как получали координаты вершин и UV-координаты:

// часть тела процедуры importedModelCreate

for(unsigned int j = 0; j < face.mNumIndices; ++j) {
  unsigned int index = face.mIndices[j];
  aiVector3D pos = mesh->mVertices[index];
  aiVector3D uv = mesh->mTextureCoords[0][index];
  aiVector3D normal = mesh->mNormals[index];

  verticesBuffer[verticesBufferIndex++] = pos.x;
  verticesBuffer[verticesBufferIndex++] = pos.y;
  verticesBuffer[verticesBufferIndex++] = pos.z;
  verticesBuffer[verticesBufferIndex++] = normal.x;
  verticesBuffer[verticesBufferIndex++] = normal.y;
  verticesBuffer[verticesBufferIndex++] = normal.z;
  verticesBuffer[verticesBufferIndex++] = uv.x;
  verticesBuffer[verticesBufferIndex++] = 1.0f - uv.y;
}

Аналогично изменяется процедура оптимизации модели. А в процедуре modelLoad вместо двух массивов атрибутов нам теперь потребуется три:

// часть тела процедуры modelLoad

glBindVertexArray(modelVAO);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);

glBindBuffer(GL_ARRAY_BUFFER, modelVBO);
glBufferData(GL_ARRAY_BUFFER, header->verticesDataSize, verticesPtr,
             GL_STATIC_DRAW);

GLsizei stride = 8*sizeof(GLfloat);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, nullptr);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride,
                      (const void*)(3*sizeof(GLfloat)));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride,
                      (const void*)(6*sizeof(GLfloat)));

Также нам дополнительно понадобится uniform-переменная с матрицей M:

GLint uniformM = getUniformLocation(programId, "M");

// ...

glUniformMatrix4fv(uniformM, 1, GL_FALSE, &towerM[0][0]);

… чтобы в vertex shader правильно повернуть нормаль в пространстве:

#version 330 core

layout(location = 0) in vec3 vertexPos;
layout(location = 1) in vec3 vertexNorm;
layout(location = 2) in vec2 vertexUV;

uniform mat4 MVP;
uniform mat4 M;

out vec2 fragmentUV;
out vec3 fragmentNormal;
out vec3 fragmentPos;

void main() {
  fragmentUV = vertexUV;
  fragmentNormal = (M * vec4(vertexNorm, 0)).xyz;
  fragmentPos = (M * vec4(vertexPos, 1)).xyz;

  gl_Position = MVP * vec4(vertexPos, 1);
}

Наконец, fragment shader принимает интерполированную по трем вершинам нормаль:

// ...

void main() {
  // normal should be corrected after interpolation
  vec3 normal = normalize(fragmentNormal);

  // ...
}

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

Что такое освещение по Фонгу

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

Важно понимать разницу между следующими понятиями:

  • Затенение по Гуро (Gouraud shading) — это когда вы вычисляете освещенность каждой вершины, а освещенность фрагментов между ними интерполируется;
  • Затенение по Фонгу (Phong shading) — когда освещенность вычисляется отдельно для каждого фрагмента;
  • Освещение по Фонгу (Phong lighting или Phong reflection model) — конкретный способ освещения, о котором идет речь в этой заметке и который можно использовать как в затенении по Гуро, так и в затенении по Фонгу;

Не удивительно, что Phong shading и Phong lighting часто путают, и в некоторых туториалах можно прочитать ерунду вроде «Идея освещения Фонга (Phong shading) заключается в использовании трех компонентов …» что сразу заставляет сильно усомниться в авторитете написавшего этот туториал человека.

Насколько я смог понять, в современных приложениях затенение по Гуро почти не используется, вместо него предпочтение отдается затенению по Фонгу. В рамках данного поста мы тоже будем использовать затенение по Фонгу, то есть, освещение будет вычислять отдельно для каждого фрагмента. Конкретный способ освещения, которым мы воспользуемся — освещение по Фонгу. Этот способ заключается в следующем.

По разным формулам вычисляется три компонента освещения:

  • Фоновое освещение (ambient lighting) — имитация света, достигшего заданной точки после отражения от других объектов. При расчете фонового освещения не учитываются ни нормали, ни текущее положение камеры;
  • Рассеянное освещение (diffuse lighting) — свет от источника, рассеянный после попадания в заданную точку. В зависимости от угла, под которым падает свет, освещение становится сильнее или слабее. Здесь учитываются нормали, но не положение камеры;
  • Отраженное освещение (specular lighting) — свет от источника, отраженный после попадания в заданную точку. Отраженный свет виден, если он попадает в камеру. Поэтому здесь учитываются как нормали, так и положение камеры;

Затем результаты суммируются, в результате чего получается общее освещение.

Чтобы стало еще интереснее, источники света бывают разные. Очевидно, что солнце на улице и фонарик в темноте освещают сцену совсем по-разному. Для начала мы рассмотрим наиболее простой источник — направленный свет.

Направленный свет (directional light)

Направленный свет — это имитация бесконечно удаленного источника света. Возьмем, например, Солнце. Солнце находится очень далеко от Земли. Поэтому у поверхности Земли можно с большой точностью считать все лучи света от Солнца параллельным. Направленный свет характеризует его направление, цвет, а также кое-какие коэффициенты, которые понадобятся нам ниже:

struct DirectionalLight {
  vec3 direction;

  vec3 color;
  float ambientIntensity;
  float diffuseIntensity;
  float specularIntensity; // for debug purposes, should be set to 1.0
};

В коде fragment shader определим процедуру calcDirectionalLight, которая будет использоваться как-то так:

in vec3 fragmentPos;
uniform vec3 cameraPos;
uniform DirectionalLight directionalLight;

// ...

void main() {
  // normal should be corrected after interpolation
  vec3 normal = normalize(fragmentNormal);
  vec3 fragmentToCamera = normalize(cameraPos - fragmentPos);

  vec4 directColor = calcDirectionalLight(normal, fragmentToCamera,
                                          directionalLight);

  // ...
}

Рассмотрим реализацию процедуры.

vec4 calcDirectionalLight(vec3 normal, vec3 fragmentToCamera,
                          DirectionalLight light) {
  vec4 ambientColor = vec4(light.color, 1) * light.ambientIntensity;

  // ...
}

Сначала вычисляется первый компонент — фоновое освещение. Это просто цвет излучаемого света умноженный на интенсивность фонового освещения. Пока все просто.

// ...

  float diffuseFactor = max(0.0, dot(normal, -light.direction));
  vec4 diffuseColor = vec4(light.color, 1) * light.diffuseIntensity
                      * diffuseFactor;

// ...

Рассеянное освещение. Переменная diffuseFactor представляет собой косинус угла между нормалью к фрагменту и вектором, направленным от фрагмента к источнику света. Если свет падает перпендикулярно поверхности, угол равен нулю. Косинус этого угла равен единице и освещенность максимальна (см статью на Wikipedia о Законе Ламберта). С увеличением угла косинус уменьшается и становится равным нулю, если свет идет параллельно поверхности. Если косинус отрицательный, значит источник света находится где-то за поверхностью и она не освещена, поэтому отрицательные значения мы обращаем в ноль при помощи max(0.0, ...). Помимо угла, под которым падает свет, также учитывается интенсивность рассеянного освещения diffuseIntensity.

// ...
  vec3 lightReflect = normalize(reflect(light.direction, normal));
  float specularFactor = pow(
                         max(0.0, dot(fragmentToCamera, lightReflect)),
                         materialSpecularFactor
                       );
  vec4 specularColor = light.specularIntensity * vec4(light.color, 1)
                       * materialSpecularIntensity * specularFactor;
// ...

Отраженное освещение. Переменная lightReflect — это единичный вектор, задающий направление отраженного света. Переменная specularFactor вычисляется похожим на diffuseFactor способом, только на этот раз учитывается косинус угла между направлением, в котором отразился свет, и направлением от фрагмента до камеры. Если этот угол равен нулю, значит отраженный свет летит прямо в камеру и блики на поверхности максимальны. Если угол велик, значит никаких бликов не должно быть видно. Здесь materialSpecularFactor является uniform переменной. Чем она больше, тем меньше по площади блики на поверхности объекта. Также используется переменная materialSpecularIntensity, определяющая яркость бликов. Заметьте, что все это — свойства материала, а не света. Например, метал отражает свет, и потому имеет блики. А дерево свет не отражает, и потом вы никогда не видите бликов на деревьях (конечно, если поверхность сухая, и так далее).

В приведенном коде у света есть свойство specularIntensity. Но его следует использовать только в отладочных целях, чтобы подчеркнуть блики от определенного источника света. В релизной версии кода этот коэффициент должен либо равняться единице, либо вовсе быть выкинутым из кода.

Наконец, три компонента складываются и возвращается результат:

// ...

  return ambientColor + diffuseColor + specularColor;
}

Не так уж и сложно, правда?

Точечный источник света (point light)

Точечный источник света — это, к примеру, горящая лампочка. Свет от лампочки направлен во все стороны. Поэтому точечный источник света не характеризуется направлением света, но характеризуется положением источника в пространстве:

struct PointLight {
  vec3 position;

  vec3 color;
  float ambientIntensity;
  float diffuseIntensity;
  float specularIntensity; // for debug purposes, should be set to 1.0
};

Освещенность от точечного источника света легко вычисляется через уже имеющуюся процедуру calcDirectionalLight:

vec4 calcPointLight(vec3 normal, vec3 fragmentToCamera,
                    PointLight light) {
  vec3 lightDirection = normalize(fragmentPos - light.position);
  float distance = length(fragmentPos - light.position);
  float pointFactor = 1.0 / (1.0 + pow(distance, 2));

  DirectionalLight tempDirectionalLight = DirectionalLight(
                                            lightDirection,
                                            light.color,
                                            light.ambientIntensity,
                                            light.diffuseIntensity,
                                            light.specularIntensity
                                          );
  return pointFactor * calcDirectionalLight(normal, fragmentToCamera,
                                            tempDirectionalLight);
}

Имея координаты фрагмента и источника света, можно легко вычислить направление света к заданному фрагменту через разность векторов. Множитель pointFactor отражает факт затухания света с квадратом расстояния до его источника (в соответствии с формулой зависимости площади поверхности сферы от радиуса). При вычислении pointFactor в делителе дополнительно прибавляется единица, чтобы предотвратить возможность деления на ноль. После этого все вычисляется точно так же, как для направленного света.

Прожектор (spot light)

В качестве примера этого источника света можно привести фонарик. Он похож на точечный источник света, только дополнительно имеет направление и угол влияния (cutoff):

struct SpotLight {
  vec3 direction;
  vec3 position;
  float cutoff;

  vec3 color;
  float ambientIntensity;
  float diffuseIntensity;
  float specularIntensity; // for debug purposes, should be set to 1.0
};

Соответствующая процедура:

vec4 calcSpotLight(vec3 normal, vec3 fragmentToCamera,
                   SpotLight light) {
  vec3 spotLightDirection = normalize(fragmentPos - light.position);
  float spotAngleCos = dot(spotLightDirection, light.direction);
  float attenuation = (1.0 - 1.0*(1.0 - spotAngleCos) /
                        (1.0 - light.cutoff));
  float spotFactor = float(spotAngleCos > light.cutoff) * attenuation;

  PointLight tempPointLight = PointLight(
                                light.position,
                                light.color,
                                light.ambientIntensity,
                                light.diffuseIntensity,
                                light.ambientIntensity
                              );
  return spotFactor * calcPointLight(normal, fragmentToCamera,
                                     tempPointLight);
}

Направление света вычисляется точно так же, как и для точечного источника. Затем вычисляется косинус угла между этим направлением и направлением, указанным в свойствах самого источника света. При помощи выражения float(spotAngleCos > light.cutoff) свет жестко обрезается до указанного угла. Множитель attenuation добавляет плавное затухание света по мере отдаления фрагментов от направления света, указанного в свойствах источника. После этого все вычисления сводятся к вычислениям для точечного источника света.

Гамма-коррекция

Целиком процедура main во fragment shader выглядит так:

void main() {
  // normal should be corrected after interpolation
  vec3 normal = normalize(fragmentNormal);
  vec3 fragmentToCamera = normalize(cameraPos - fragmentPos);

  vec4 directColor = calcDirectionalLight(normal, fragmentToCamera,
                                          directionalLight);
  vec4 pointColor = calcPointLight(normal, fragmentToCamera,
                                   pointLight);
  vec4 spotColor = calcSpotLight(normal, fragmentToCamera, spotLight);
  vec4 linearColor = texture(textureSampler, fragmentUV) *
                       (vec4(materialEmission, 1) + directColor +
                        pointColor + spotColor);

  vec4 gamma = vec4(vec3(1.0/2.2), 1);
  color = pow(linearColor, gamma); // gamma-corrected color
}

На materialEmission не обращайте особого внимания. Это просто еще одно свойство материала, добавляющее ему самостоятельное свечение. Многие объекты светятся сами по себе. Взять те же лампочки, которые служат источником света для других объектов. Мы ведь должны видеть их в полной темноте, даже если лампочки не освещены никаким другим источником света, верно?

Что действительно заслуживает внимания — это гамма-коррекция, которая заключается в возведении всех компонентов света в степень 1/2.2. До сих пор мы работали в линейном пространстве цветов, исходя из предположения, что цвет с яркостью 1.0 в два раза ярче цвета с яркостью 0.5. Проблема в том, что человеческий глаз воспринимает яркость не линейно. Поэтому для получения реалистичного освещения необходимо после всех вычислений в линейном пространстве производить гамма-коррекцию.

Следует учитывать, что при сохранении изображения современные графические редакторы также выполняют гамма-коррекцию. Поэтому перед использованием текстур нужно эту гамма-коррекцию отменить. К счастью, это не сложно.

Достаточно заменить в коде загрузки текстур все константы:

GL_COMPRESSED_RGBA_S3TC_DXT1_EXT
GL_COMPRESSED_RGBA_S3TC_DXT3_EXT
GL_COMPRESSED_RGBA_S3TC_DXT5_EXT

… на:

GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT
GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT
GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT

… соответственно. Так мы сообщим, что к изображением была применена гамма-коррекция, которую нужно отменить. Об остальном OpenGL позаботится сам.

В реальных приложениях параметр gamma (у нас gamma = 2.2) лучше выносить в настройки программы, чтобы пользователь при желании мог немного подстроить его под свой монитор.

Заключение

Настало время разглядывать картинки!

Фоновое, рассеянное и отраженное освещение

Здесь мы видим различные компоненты освещения. Слева направо, сверху вниз: фоновое, рассеянное, отраженное, все три вместе. Как видите, на сцену была добавлена модель тора. Из-за сложного расположения нормалей эту модель рекомендуется использовать для тестирования освещения.

Различные источники света

Различные источники света. Слева направо, сверху вниз: белый направленный свет, красный точечный источник света, синий прожектор, все три вместе.

Отмечу еще раз, что один и тот же метод освещения может иметь разные реализации. Например, можно сделать свойства материала ambient, diffuse и specular color, что позволит рисовать красные объекты, рассеивающие зеленый цвет и имеющие синие блики. В некоторых реализациях освещения по Фонгу я видел вычисление фонового освещения один раз, а не для каждого источника света. Также я видел реализации, где свет от точечного источника затухал не просто пропорционально квадрату расстояния до него (d * d), а по более общей формуле (в стиле A + B*d + C*d*d). Кто-то делает ambient intensity и diffuse intensity свойством не только источника света, но и материала. Не уверен, правда, насколько все это имеет отношение к реалистичности освещения. Но в качестве домашнего задания можете поиграться со всем этим.

Ссылки по теме:

  • Полная версия исходников к этой заметке доступна на GitHub. Как обычно, код был проверен под Windows, Linux и MacOS, на трех разных графических картах;
  • В современных приложениях зачастую используются более продвинутые техники, чем освещение по Фонгу. См например статьи Blinn-Phong shading model и SSOA на Wikipedia, а также далее по ссылкам;
  • Этот туториал в разделе «One Last Thing» объясняет, как поправить нормали в случае, если мы масштабируем модели. В реальных программах, скорее всего, просто не стоит так делать, так как это приведет к лишнему умножению на матрицу (вычисляемую по формуле mat3(transpose(inverse(M)))) каждой нормали на сцене. Плюс никакой коррекции нормалей не требуется, если делать модели просто в N раз больше или меньше, без растягивания;
  • При запуске демки к этому посту вы можете заметить, что блики у направленного света покрывают большую площадь при отдалении камеры от объекта. Согласно этому обсуждению, это нормальное поведение, а не баг. Просто в реальной жизни мы не каждый день резко меняем высоту над большими блестящими объектами, поэтому эффект выглядит непривычно;
  • В этом туториале в секции «Further Lighting» много интересных ссылок на дополнительные источники информации по освещению;

В следующий раз мы поговорим о выводе текста.

Дополнение: Продолжаем изучение OpenGL: простой вывод текста

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.