Хотите научиться добавлять в свою 3D-игру текстуры, освещение, тени, карты нормалей, светящиеся объекты, ambient occlusion и другие эффекты? Отлично! В этой статье представлен набор техник затенения, способных поднять уровень графики вашей игры на новые высоты. Я объясняю каждую технику таким образом, чтобы вы могли применить/портировать эту информацию в любом стеке инструментов, будь то Godot, Unity или что-то иное.
В качестве «клея» между шейдерами я решил использовать великолепный игровой движок Panda3D и OpenGL Shading Language (GLSL). Если вы пользуетесь таким же стеком, то получите дополнительное преимущество — узнаете, как использовать техники затенения конкретно в Panda3D и OpenGL.
Подготовка
Ниже представлена система, которую я использовал для разработки и тестирования кода примеров.
Среда
Код примеров был разработан и протестирован в следующей среде:
- Linux manjaro 4.9.135-1-MANJARO
- OpenGL renderer string: GeForce GTX 970/PCIe/SSE2
- OpenGL version string: 4.6.0 NVIDIA 410.73
- g++ (GCC) 8.2.1 20180831
- Panda3D 1.10.1-1
Материалы
Каждый из материалов Blender, использованных для создания
mill-scene.egg
, имеет две текстуры.Первая текстура — это карта нормалей (normal map), вторая — диффузная карта (diffuse map). Если объект использует нормали своих вершин, то применяется «однотонная синяя» карта нормалей. Благодаря тому, что у всех моделей одинаковые карты находятся в одинаковых позициях, шейдеры можно обобщить и применить к корневому ноду графа сцены.
Учтите, что граф сцены — это особенность реализации движка Panda3D.
Вот однотонная карта нормалей, содержащая только цвет
[red = 128, green = 128, blue = 255]
.Этот цвет обозначает единичную нормаль, указывающую в положительном направлении оси z
[0, 0, 1]
.[0, 0, 1] =
[ round((0 * 0.5 + 0.5) * 255)
, round((0 * 0.5 + 0.5) * 255)
, round((1 * 0.5 + 0.5) * 255)
] =
[128, 128, 255] =
[ round(128 / 255 * 2 - 1)
, round(128 / 255 * 2 - 1)
, round(255 / 255 * 2 - 1)
] =
[0, 0, 1]
Здесь мы видим единичную нормаль
[0, 0, 1]
, преобразованную в однотонный синий цвет [128, 128, 255]
, и однотонный синий, преобразованный в единичную нормаль.Подробнее об этом рассказано в разделе о технике наложения карт нормалей.
Panda3D
В этом примере кода в качестве «клея» между шейдерами используется Panda3D. Это не влияет на описываемые ниже техники, то есть вы сможете воспользоваться изученной здесь информацией в любом выбранном стеке или игровом движке. Panda3D обеспечивает определённые удобства. В статье я рассказал о них, поэтому вы можете или найти их аналог в своём стеке, или воссоздать их самостоятельно, если их нет в стеке.
Стоит учесть, что
gl-coordinate-system default
, textures-power-2 down
и textures-auto-power-2 1
были добавлены в config.prc
. Они не содержатся в стандартной конфигурации Panda3D.По умолчанию в Panda3D используется правосторонняя система координат с направленной вверх осью z, а в OpenGL применяется правосторонняя систем с направленной вверх осью y.
gl-coordinate-system default
позволяет избавиться от преобразований между двумя системами координат внутри шейдеров.textures-auto-power-2 1
позволяет нам использовать размеры текстур, не являющиеся степенями двойки, если система их поддерживает.Это бывает удобно при выполнении SSAO или реализации других техник в пределах экрана/окна, потому что размер экрана/окна обычно не является степенью двойки.
textures-power-2 down
уменьшает размер текстур до степени двойки, если система поддерживает только текстуры с размерами, равными степеням двойки.Сборка кода примера
Если вы хотите запустить код примера, то сначала его надо собрать.
Panda3D работает на Linux, Mac и Windows.
Linux
Начните с установки Panda3D SDK для своего дистрибутива.
Найдите, где находятся заголовки и библиотеки Panda3D. Скорее всего, они находятся соответственно в
/usr/include/panda3d/
и в /usr/lib/panda3d/
.Затем клонируйте этот репозиторий и перейдите в его каталог.
git clone https://github.com/lettier/3d-game-shaders-for-beginners.git
cd 3d-game-shaders-for-beginners
Теперь скомпилируйте исходный код в выходной файл.
g++ \
-c main.cxx \
-o 3d-game-shaders-for-beginners.o \
-std=gnu++11 \
-O2 \
-I/usr/include/python2.7/ \
-I/usr/include/panda3d/
Создав выходной файл, создайте исполняемый файл, связав выходной файл с его зависимостями.
g++ \
3d-game-shaders-for-beginners.o \
-o 3d-game-shaders-for-beginners \
-L/usr/lib/panda3d \
-lp3framework \
-lpanda \
-lpandafx \
-lpandaexpress \
-lp3dtoolconfig \
-lp3dtool \
-lp3pystub \
-lp3direct \
-lpthread
Дополнительную информацию см. в руководстве Panda3D.
Mac
Начните с установки Panda3D SDK для Mac.
Найдите где находятся заголовки и библиотеки Panda3D.
Затем клонируйте репозиторий и перейдите к его каталогу.
git clone https://github.com/lettier/3d-game-shaders-for-beginners.git
cd 3d-game-shaders-for-beginners
Теперь скомпилируйте исходный код в выходной файл. Вам нужно найти, где находятся каталоги include у Python 2.7 и Panda3D.
clang++ \
-c main.cxx \
-o 3d-game-shaders-for-beginners.o \
-std=gnu++11 \
-g \
-O2 \
-I/usr/include/python2.7/ \
-I/Developer/Panda3D/include/
Создав выходной файл, создайте исполняемый файл, связав выходной файл с его зависимостями.
Вам необходимо найти, где расположены библиотеки Panda3D.
clang++ \
3d-game-shaders-for-beginners.o \
-o 3d-game-shaders-for-beginners \
-L/Developer/Panda3D/lib \
-lp3framework \
-lpanda \
-lpandafx \
-lpandaexpress \
-lp3dtoolconfig \
-lp3dtool \
-lp3pystub \
-lp3direct \
-lpthread
Дополнительную информацию см. в руководстве Panda3D.
Windows
Начните с установки Panda3D SDK для Windows.
Найдите, где находятся заголовки и библиотеки Panda3D.
Склонируйте этот репозиторий и перейдите к его каталогу.
git clone https://github.com/lettier/3d-game-shaders-for-beginners.git
cd 3d-game-shaders-for-beginners
Дополнительную информацию см. в руководстве Panda3D.
Запуск демо
После сборки кода примера можно запустить исполняемый файл или демо. Вот так они запускаются в Linux или Mac.
./3d-game-shaders-for-beginners
А вот так они запускаются в Windows:
3d-game-shaders-for-beginners.exe
Управление с клавиатуры
В демо есть клавиатурное управление, позволяющее перемещать камеру и переключать состояние различных эффектов.
Движение
w
— переместиться вглубь сцены.a
— повернуть сцену по часовой стрелке.s
— отдалиться от сцены.d
— повернуть сцену против часовой стрелки.
Переключаемые эффекты
y
— включение SSAO.Shift
+y
— отключение SSAO.u
— включение контуров.Shift
+u
— отключение контуров.i
— включение bloom.Shift
+i
— отключение bloom.o
— включение карт нормалей.Shift
+o
— отключение карт нормалей.p
— включение тумана.Shift
+p
— отключение тумана.h
— включение глубины резкости.Shift
+h
— отключение глубины резкости.j
— включение постеризации.Shift
+j
— отключение постеризацииk
— включение пикселизации.Shift
+k
— отключение пикселизации.l
— включение резкости.Shift
+l
— отключение резкости.n
включение зернистости плёнки.Shift
+n
— отключение зернистости плёнки.
Система отсчёта
Прежде чем приступать к написанию шейдеров, нужно познакомиться со следующими системами отсчёта или системами координат. Все они сводятся к тому, относительно чего принимаются текущие координаты начала отсчёта
(0, 0, 0)
. Как только мы это узнаем, то сможем их преобразовывать с помощью какой-нибудь матрицы или другого векторного пространства. Обычно если выходные данные какого-шейдера выглядят неправильно, то причиной являются перепутанные системы координат.Модель
Система координат модели или объекта относительна к точке начала координат модели. В программах трёхмерного моделирования, например, в Blender, она обычно ставится в центр модели.
Мир
Мировое пространство относительно к точке начала координат созданной вами сцены/уровня/вселенной.
Обзор
Пространство координат обзора относительно к позиции активной камеры.
Отсечение
Пространство отсечения относительно к центру плёнки камеры. Все координаты в нём однородны и находятся в интервале
(-1, 1)
. X и y параллельны плёнке камеры, а координата z является глубиной.Все вершины, не находящиеся внутри границ пирамиды видимости или объёма видимости камеры отсекаются или отбрасываются. Мы видим, как это происходит с кубом, усечённым сзади дальней плоскостью камеры, и с кубом, расположенным сбоку.
Экран
Экранное пространство (обычно) относительно к левому нижнему углу экрана. X меняется от нуля до ширины экрана. Y меняется от нуля до высоты экрана.
GLSL
Вместо того, чтобы работать с конвейером фиксированных функций, мы будем использовать программируемый конвейер рендеринга GPU. Так как он программируемый, мы сами должны передавать ему программный код в виде шейдеров. Шейдер — это (обычно маленькая) программа, создаваемая с синтаксисом, напоминающим язык C. Программируемый конвейер рендеринга GPU состоит из различных этапов, которые можно программировать с помощью шейдеров. Различные виды шейдеров включают в себя вершинные шейдеры, шейдеры тесселяции, геометрические, фрагментные и вычислительные шейдеры. Для использования описанных в статье техник нам достаточно использовать вершинные и фрагментные
этапы.
#version 140
void main() {}
Вот минимальный шейдер GLSL, состоящий из номера версии GLSL и главной функции.
#version 140
uniform mat4 p3d_ModelViewProjectionMatrix;
in vec4 p3d_Vertex;
void main()
{
gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
}
Вот урезанный вершинный шейдер GLSL, преобразующий входную вершину в пространство отсечения и выводит эту новую позицию как однородную позицию вершины.
Процедура
main
ничего не возвращает, потому что является void
, а переменная gl_Position
— это встроенный вывод.Стоит упомянуть два ключевых слова:
uniform
и in
.Ключевое слово
uniform
означает, что эта глобальная переменная одинакова для всех вершин. Panda3D сам задаёт p3d_ModelViewProjectionMatrix
и для каждой вершины это одинаковая матрица.Ключевое слово
in
означает, что эта глобальная переменная передаётся шейдеру. Вершинный шейдер получает каждую вершину из которой состоит геометрия, к которой присоединён вершинный шейдер.#version 140
out vec4 fragColor;
void main() {
fragColor = vec4(0, 1, 0, 1);
}
Вот урезанный фрагментный шейдер GLSL, выводящий в качестве цвета фрагмента непрозрачный зелёный.
Не забывайте, что фрагмент влияет только на один экранный пиксель, но на один пиксель могут влиять несколько фрагментов.
Обратите внимание на ключевое слово
out
.Ключевое слово
out
означает, что эта глобальная переменная задаётся шейдером.Имя
fragColor
необязательно, поэтому можно выбрать любое другое.Вот результат вывода двух показанных выше шейдеров.
Рендеринг в текстуру
Вместо рендеринга/рисования непосредственно на экран, код примера использует технику под
названием «рендеринг в текстуру» (render to texture). Для рендеринга в текстуру необходимо настроить буфер кадров и привязать к нему текстуру. К одному буферу кадров можно привязать несколько текстур.
Привязанные к буферу кадров текстуры хранят векторы, возвращаемые фрагментным шейдером. Обычно эти векторы являются векторами цвета
(r, g, b, a)
, но они могут быть и позициями или векторами нормалей (x, y, z, w)
. Для каждой привязанной текстуры фрагментный шейдер может выводить отдельный вектор. Например, мы можем вывести за один проход позицию и нормаль вершины.Основная часть кода примера, работающего с Panda3D, связан с настройкой текстур буфера кадров. Чтобы не усложнять, у каждого фрагментного шейдера в коде примера есть только один вывод. Однако для обеспечения высокой частоты кадров (FPS) нам нужно выводить в каждом проходе рендеринга как можно больше информации.
Вот две структуры текстур буфера кадров из кода примера.
Первая структура рендерит сцену с водяной мельницей в текстуру буфера кадров с помощью множества вершинных и фрагментных шейдеров. Эта структура проходит по каждой из вершин сцены с мельницей и по соответствующим фрагментам.
В этой структуре код примера работает следующим образом.
- Сохраняет данные геометрии (например, позицию или нормаль вершины) для дальнейшего использования.
- Сохраняет данные материалов (например диффузный цвет) для дальнейшего использования.
- Создаёт UV-привязку разных текстур (диффузной, карты нормалей, карты теней и т.д.).
- Вычисляет окружающее, рассеянное, отражённое и испускаемое освещение.
- Рендерит туман.
Вторая структура — это ортогональная камера, направленная на прямоугольник в форме экрана.
Эта структура проходит всего по четырём вершинам и их соответствующим фрагментам.
Во второй структуре код примера выполняет следующие действия:
- Обрабатывает выходные данные другой текстуры буфера кадров.
- Комбинирует разные текстуры буфера кадров в одну.
В примере кода мы можем увидеть вывод одной текстуры буфера кадров, присвоив соответствующему кадру значение true, а всем остальным — false.
// ...
bool showPositionBuffer = false;
bool showNormalBuffer = false;
bool showSsaoBuffer = false;
bool showSsaoBlurBuffer = false;
bool showMaterialDiffuseBuffer = false;
bool showOutlineBuffer = false;
bool showBaseBuffer = false;
bool showSharpenBuffer = false;
bool showBloomBuffer = false;
bool showCombineBuffer = false;
bool showCombineBlurBuffer = false;
bool showDepthOfFieldBuffer = false;
bool showPosterizeBuffer = false;
bool showPixelizeBuffer = false;
bool showFilmGrainBuffer = true;
// ...
Текстурирование
Текстурирование — это привязка цвета или какого-то другого вектора к фрагменту с помощью UV-координат. Значения U и V изменяются в интервале от нуля до единицы. Каждая вершина получает UV-координату и она выводится в вершинный шейдер.
Фрагментный шейдер получает интерполированную UV-координату. Интерполяция обозначает, что UV-координата для фрагмента находится где-то между UV-координатами вершин, составляющих грань треугольника.
Вершинный шейдер
#version 140
uniform mat4 p3d_ModelViewProjectionMatrix;
in vec2 p3d_MultiTexCoord0;
in vec4 p3d_Vertex;
out vec2 texCoord;
void main()
{
texCoord = p3d_MultiTexCoord0;
gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
}
Здесь мы видим, что вершинный шейдер выводит координату текстуры во фрагментный шейдер. Заметьте, что это двухмерный вектор: одно значение для U и одно для V.
Фрагментный шейдер
#version 140
uniform sampler2D p3d_Texture0;
in vec2 texCoord;
out vec2 fragColor;
void main()
{
texColor = texture(p3d_Texture0, texCoord);
fragColor = texColor;
}
Здесь мы видим, что фрагментный шейдер ищет цвет в своей UV-координате и выводит его как цвет фрагмента.
Текстура, заполняемая экраном
#version 140
uniform sampler2D screenSizedTexture;
out vec2 fragColor;
void main()
{
vec2 texSize = textureSize(texture, 0).xy;
vec2 texCoord = gl_FragCoord.xy / texSize;
texColor = texture(screenSizedTexture, texCoord);
fragColor = texColor;
}
При выполнении рендеринга в текстуру мешем является плоский прямоугольник с таким же соотношением сторон, как у экрана. Поэтому мы можем вычислить UV-координаты, зная только
А) ширину и высоту текстуры с размером экрана, накладываемого на прямоугольник с использованием UV-координат, и
Б) координаты x и y фрагмента.
Чтобы привязать x к U, нужно разделить x на ширину входящей текстуры. Аналогично, чтобы привязать y к V, нужно разделить y на высоту входящей текстуры. Вы увидите, что эта техника используется в коде примера.
Освещение
Для определения освещения необходимо вычислить и скомбинировать аспекты окружающего, диффузного, отражённого и испускаемого освещения. В примере кода используется освещение по Фонгу.
Вершинный шейдер
// ...
uniform struct p3d_LightSourceParameters
{ vec4 color
; vec4 ambient
; vec4 diffuse
; vec4 specular
; vec4 position
; vec3 spotDirection
; float spotExponent
; float spotCutoff
; float spotCosCutoff
; float constantAttenuation
; float linearAttenuation
; float quadraticAttenuation
; vec3 attenuation
; sampler2DShadow shadowMap
; mat4 shadowViewMatrix
;
} p3d_LightSource[NUMBER_OF_LIGHTS];
// ...
Для каждого источника освещения, за исключением окружающего освещения, Panda3D предоставляет нам удобную структуру, доступную и для вершинного, и для фрагментного шейдеров. Самое удобное — это карта теней и матрица обзора теней для преобразования вершин в пространство теней или освещения.
// ...
vertexPosition = p3d_ModelViewMatrix * p3d_Vertex;
// ...
for (int i = 0; i < p3d_LightSource.length(); ++i) {
vertexInShadowSpaces[i] = p3d_LightSource[i].shadowViewMatrix * vertexPosition;
}
// ...
Начав с вершинного шейдера, мы должны преобразовать и вывести вершину из пространства обзора в пространство тени или освещения для каждого источника освещения в сцене. Это пригодится нам в будущем для фрагментного шейдера, чтобы рендерить тени. Пространство теней или освещения — это пространство, в котором каждая координата относительна позиции источника освещения (началом координат является источник освещения).
Фрагментный шейдер
Во фрагментном шейдере выполняется основная работа по вычислению освещения.
Материал
// ...
uniform struct
{ vec4 ambient
; vec4 diffuse
; vec4 emission
; vec3 specular
; float shininess
;
} p3d_Material;
// ...
Panda3D предоставляет нам материал (в виде struct) для меша или модели, которые мы рендерим в данный момент.
Несколько источников освещения
// ...
vec4 diffuseSpecular = vec4(0.0, 0.0, 0.0, 0.0);
// ...
Прежде чем обходить источники освещения сцены, создадим накопитель, который будет содержать и диффузные, и отражённые цвета.
// ...
for (int i = 0; i < p3d_LightSource.length(); ++i) {
// ...
}
// ...
Теперь мы можем обходить в цикле источники освещения, вычисляя диффузные и отражённые цвета для каждого.
Векторы, связанные с освещением
Здесь показаны четыре основных вектора, необходимых для вычисления диффузных и отражённых цветов, вносимых каждым источником освещения. Вектор направления освещения — это голубая стрелка, указывающая на источник освещения. Вектор нормали — это направленная вертикально вверх зелёная стрелка. Вектор отражения — это синяя стрелка, зеркально отражающая вектор направления освещения. Вектор глаза или обзора — это оранжевая стрелка, направленная в камеру.
// ...
vec3 lightDirection =
p3d_LightSource[i].position.xyz
- vertexPosition.xyz
* p3d_LightSource[i].position.w;
// ...
Направление освещения — это вектор из позиции вершины к позиции источника освещения.
Если это направленное освещение, то Panda3D присваивает
p3d_LightSource[i].position.w
нулевое значение. У направленного освещения нет позиции, только направление. Поэтому если это направленное освещение, то направление освещения будет отрицателным или противоположным направлением к источнику, потому что для направленного освещения Panda3D присваивает p3d_LightSource[i].position.xyz
значение -direction
. // ...
normal = normalize(vertexNormal);
// ...
Нормаль к вершине должна быть единичным вектором. Единичные векторы имеют величину, равную единице.
// ...
vec3 unitLightDirection = normalize(lightDirection);
vec3 eyeDirection = normalize(-vertexPosition.xyz);
vec3 reflectedDirection = normalize(-reflect(unitLightDirection, normal));
// ...
Далее нам понадобятся ещё три вектора.
Нам понадобится скалярное произведение с участием направления освещения, поэтому лучше нормализовать его. Это даёт нам расстояние или величину, равное единице (единичный вектор).
Направление взгляда противоположно позиции вершины/фрагмента, потому что позиция вершины/фрагмента относительна к позиции камеры. Не забывайте, что позиция вершины/фрагмента находится в пространстве обзора. Поэтому вместо движения от камеры (глаза) к вершине/фрагменту, мы двигаемся от вершины/фрагмента к камере (глазу).
Вектор отражения — это отражение направления освещения в нормали к поверхности. Когда «луч» света касается поверхности, он отражается под тем же углом, под которым падал. Угол между вектором направления освещения и нормалью называется «углом падения». Угол между вектором отражения и нормалью называется «углом отражения».
Нужно изменить знак вектора отражённого освещения, потому что он должен указывать в том же направлении, что и вектор глаза. Не забывайте, что направление глаза идёт из вершины/фрагмента к позиции камеры. Мы воспользуемся вектором отражения для вычисления яркости отражённого засвета.
Диффузное освещение
// ...
float diffuseIntensity = max(dot(normal, unitLightDirection), 0.0);
if (diffuseIntensity > 0) {
// ...
}
// ...
Яркость диффузного освещения — это скалярное произведение нормали к поверхности и направления освещения единичного вектора. Скалярное произведение может находиться в интервале от минус единицы до единицы. Если оба вектора указывают в одном направлении, то яркость равна единице. Во всех остальных случаях она будет меньше единицы.
Если вектор освещения приближается к тому же направлению, что нормаль, то яркость диффузного освещения стремится к единице.
Если яркость диффузного освещения меньше или равна нулю, то нужно переходить к следующему источнику освещения.
// ...
vec4 diffuse =
vec4
( clamp
( diffuseTex.rgb
* p3d_LightSource[i].diffuse.rgb
* diffuseIntensity
, 0
, 1
)
, 1
);
diffuse.r = clamp(diffuse.r, 0, diffuseTex.r);
diffuse.g = clamp(diffuse.g, 0, diffuseTex.g);
diffuse.b = clamp(diffuse.b, 0, diffuseTex.b);
// ...
Теперь мы можем вычислить диффузный цвет, вносимый этим источником. Если яркость диффузного освещения равна единице, то диффузный цвет будет смешением цвета диффузной текстуры и цветом освещения. При любой другой яркости диффузный цвет будет темнее.
Заметьте, что я ограничиваю диффузный цвет, чтобы он был не ярче, чем цвет диффузной текстуры. Это предотвратит переэкспонирование сцены.
Отражённое освещение
После диффузного освещения вычисляется отражённое.
// ...
vec4 specular =
clamp
( vec4(p3d_Material.specular, 1)
* p3d_LightSource[i].specular
* pow
( max(dot(reflectedDirection, eyeDirection), 0)
, p3d_Material.shininess
)
, 0
, 1
);
// ...
Яркость отражённого освещения — это скалярное произведение между вектором глаза и вектором отражения. Как и в случае с яркостью диффузного освещения, если два вектора указывают в одном направлении, то яркость отражённого освещения равна единице. Любая другая яркость будет уменьшать величину отражённого цвета, вносимого этим источником освещения.
Блеск материала определяет, насколько будет рассеиваться засвет отражённого освещения. Обычно он задаётся в программе моделирования, например в Blender. В Blender он называется specular hardness.
Spotlights
// ...
float unitLightDirectionDelta =
dot
( normalize(p3d_LightSource[i].spotDirection)
, -unitLightDirection
);
if (unitLightDirectionDelta >= p3d_LightSource[i].spotCosCutoff) {
// ...
}
// ...
}
Этот код не позволяет освещению воздействовать на фрагменты за пределами конуса прожекторного (spotlight) освещения или пирамиды. К счастью, Panda3D может задавать
spotDirection
и spotCosCutoff
для работы с направленным и точечным освещением. У прожекторов есть и позиция, и направление. Однако у направленного освещения есть только направление, а у точечных источников — только позиция. Тем не менее, этот код работает для всех трёх видов освещения без необходимости использования запутывающих операторов if.spotCosCutoff = cosine(0.5 * spotlightLensFovAngle);
Если в случае прожекторного освещения скалярное произведение вектора «фрагмент-источник освещения» и вектора направления прожектора меньше косинуса половины угла поля видимости
прожектора, то шейдер не принимает во внимание влияние этого источника.
Учтите, что необходимо изменить знак
unitLightDirection
. unitLightDirection
идёт от фрагмента к прожектору, а нам нужно двигаться от прожектора к фрагменту, потому что spotDirection
идёт непосредственно по центру пирамиды прожектора на некотором расстоянии от позиции прожектора.В случае направленного и точечного освещения Panda3D присваивает
spotCosCutoff
значение -1. Вспомним, что скалярное произведение изменяется в интервале от -1 до 1. Поэтому не важно, каким будет unitLightDirectionDelta
, потому что она всегда больше или равна -1. // ...
diffuse *= pow(unitLightDirectionDelta, p3d_LightSource[i].spotExponent);
// ...
Как и код
unitLightDirectionDelta
, этот код тоже работает для всех трёх типом источников освещения. В случае прожекторов он будет делать фрагменты ярче при приближении к центру пирамиды прожектора. Для направленных и точечных источников освещения spotExponent
равно нулю. Вспомним, что любое значение в степени ноль равно единице, поэтому диффузный цвет равен себе, умноженному на единицу, то есть не меняется.Тени
// ...
float shadow =
textureProj
( p3d_LightSource[i].shadowMap
, vertexInShadowSpaces[i]
);
diffuse.rgb *= shadow;
specular.rgb *= shadow;
// ...
Panda3D упрощает использование теней, потому что создаёт для каждого источника освещения в сцене карту теней и матрицу преобразования теней. Чтобы создать матрицу преобразования самостоятельно, нужно собрать матрицу, преобразующую координаты пространства обзора в пространство освещения (координаты относительны к позиции источника освещения). Чтобы самому создать карту теней, необходимо отрендерить сцену с точки зрения источника освещения в текстуру буфера кадров. Текстура буфера кадров должна содержать расстояния от источника освещения до фрагментов. Это называется «картой глубин». Наконец, нужно вручную передать шейдеру свою самодельную карту глубин как
uniform sampler2DShadow
, а матрицу преобразования теней как uniform mat4
. Так мы воссоздадим то, что Panda3D делает за нас автоматически.В показанном фрагменте кода используется
textureProj
, которая отличается от показанной выше функции texture
. textureProj
сначала делит vertexInShadowSpaces[i].xyz
на vertexInShadowSpaces[i].w
. Затем она использует vertexInShadowSpaces[i].xy
для нахождения глубины, хранящейся в карте теней. Потом она использует vertexInShadowSpaces[i].z
для сравнения глубины вершины с глубиной карты теней в vertexInShadowSpaces[i].xy
. Если сравнение проходит успешно, то textureProj
возвращает единицу. В противном случае она возвращает ноль. Ноль означает, что эта вершина/фрагмент находятся в тени, а единица — что вершина/фрагмент не в тени.Учтите, что
textureProj
также может возвращать значение от нуля до единицы в зависимости от того, как настроена карта теней. В данном примере textureProj
выполняет множественные тесты глубин на основе соседних значений глубин, и возвращает взвешенное среднее. Это взвешенное среднее может придавать теням плавность.Затухание
// ...
float lightDistance = length(lightDirection);
float attenuation =
1
/ ( p3d_LightSource[i].constantAttenuation
+ p3d_LightSource[i].linearAttenuation
* lightDistance
+ p3d_LightSource[i].quadraticAttenuation
* (lightDistance * lightDistance)
);
diffuse.rgb *= attenuation;
specular.rgb *= attenuation;
// ...
Расстояние до источника света — это просто величина или длина вектора направления освещения. Заметьте, что мы не используем нормализованное направление освещения, потому что такое расстояние равнялось бы единице.
Расстояние до источника освещения необходимо для вычисления затухания. Затухание означает, что воздействие света при отдалении от источника уменьшается.
Параметрам
constantAttenuation
, linearAttenuation
и quadraticAttenuation
можно задать любые значения. Стоит начать с constantAttenuation = 1
, linearAttenuation = 0
и quadraticAttenuation = 1
. При таких параметрах в позиции источника света равна единице и при отдалении от него стремится к нулю.Окончательный цвет освещения
// ...
diffuseSpecular += (diffuse + specular);
// ...
Для вычисления окончательного цвета освещения нужно сложить диффузный и отражённый цвет. Необходимо прибавлять это к накопителю в цикле обхода источников освещения в сцене.
Ambient
// ...
uniform sampler2D p3d_Texture1;
// ...
uniform struct
{ vec4 ambient
;
} p3d_LightModel;
// ...
in vec2 diffuseCoord;
// ...
vec4 diffuseTex = texture(p3d_Texture1, diffuseCoord);
// ...
vec4 ambient = p3d_Material.ambient * p3d_LightModel.ambient * diffuseTex;
// ...
Компонент окружающего освещения в модели освещения основывается на ambient-цвете материала, цвете окружающего освещения и цвете диффузной текстуры.
Никогда не должно быть больше одного источника окружающего освещения, поэтому это вычисление должно выполняться только один раз, в отличие от расчётов диффузного и отражённого цветов, накапливаемых для каждого источника освещения.
Учтите, что цвет окружающего освещения нам пригодится при выполнении SSAO.
Собираем всё вместе
// ...
vec4 outputColor = ambient + diffuseSpecular + p3d_Material.emission;
// ...
Окончательный цвет — это сумма ambient-цвета, диффузного цвета, отражённого цвета и испускаемого цвета.
Исходники
Карты нормалей
Использование карт нормалей позволяет без дополнительной геометрии добавлять к поверхности новые детали. Обычно при работе в программе 3D-моделирования создаются высоко- и низкополигональная версии меша. Затем берутся нормали вершин из высокополигонального меша, и запекаются в текстуру. Эта текстура является картой нормалей. Затем внутри фрагментного шейдера мы заменяем нормали вершин низкополигонального меша на нормали высокополигонального меша, запечённые в карту нормалей. Благодаря этому при освещении меша будет казаться, что у него больше полигонов, чем есть на самом деле. Это позволяет сохранить высокий FPS, в то же время передавая бОльшую часть деталей из высокополигональной версии.
Здесь мы видим переход от высокополигональной модели к низкополигональной, а затем к низкополигональной с наложенной картой нормалей.
Однако не стоит забывать, что наложение карты нормалей — это всего лишь иллюзия. Под определённым углом поверхность снова начинает выглядеть плоской.
Вершинный шейдер
// ...
uniform mat3 p3d_NormalMatrix;
// ...
in vec3 p3d_Normal;
// ...
in vec3 p3d_Binormal;
in vec3 p3d_Tangent;
// ...
vertexNormal = normalize(p3d_NormalMatrix * p3d_Normal);
binormal = normalize(p3d_NormalMatrix * p3d_Binormal);
tangent = normalize(p3d_NormalMatrix * p3d_Tangent);
// ...
Начав с вершинного шейдера, нам нужно вывести во фрагментный шейдер вектор нормали, вектор бинормали и касательный вектор. Эти векторы используются во фрагментном шейдере для преобразования нормали карты нормалей из касательного пространства в пространство обзора.
p3d_NormalMatrix
преобразует векторы нормали вершины, бинормали и касательный вектор в пространство обзора. Не забывайте, что в пространстве обзора все координаты относительны к позиции камеры.[p3d_NormalMatrix] — это верхние 3x3 элемента обратного транспонирования ModelViewMatrix. Эта структура используется для преобразования вектора нормали в координаты пространства обзора.
Источник
// ...
in vec2 p3d_MultiTexCoord0;
// ...
out vec2 normalCoord;
// ...
normalCoord = p3d_MultiTexCoord0;
// ...
Также нам нужно выводить во фрагментный шейдер UV-координаты карты нормалей.
Фрагментный шейдер
Вспомним, что нормаль вершины использовалась для вычисления освещения. Однако для вычисления освещения карта нормалей передаёт нам другие нормали. Во фрагментном шейдере нам нужно заменить нормали вершин на нормали, находящиеся в карте нормалей.
// ...
uniform sampler2D p3d_Texture0;
// ...
in vec2 normalCoord;
// ...
/* Find */
vec4 normalTex = texture(p3d_Texture0, normalCoord);
// ...
Воспользовавшись переданными вершинным шейдером координатами карты нормалей, вытащим из карты соответствующую нормаль.
// ...
vec3 normal;
// ...
/* Unpack */
normal =
normalize
( normalTex.rgb
* 2.0
- 1.0
);
// ...
Выше я показывал, как нормали преобразуются в цвета для создания карт нормалей. Теперь нам нужно обратить этот процесс, чтобы мы могли получить исходные нормали, запечённые в карту.
[ r, g, b] =
[ r * 2 - 1, g * 2 - 1, b * 2 - 1] =
[ x, y, z]
Вот как выглядит процесс распаковки нормалей из карты нормалей.
// ...
/* Transform */
normal =
normalize
( mat3
( tangent
, binormal
, vertexNormal
)
* normal
);
// ...
Получаемые из карты нормалей нормали обычно находятся в касательном пространстве. Однако они могут быть и в другом пространстве. Например, Blender позволяет запекать нормали в касательном пространстве, пространстве объекта, мировом пространстве и пространстве камеры.
Чтобы перенести нормаль карты нормалей из касательного пространства в пространство обзора, создадим матрицу 3x3 на основе касательного вектора, векторов бинормали и нормали вершины. Умножим нормаль на эту матрицу и нормализуем её. На этом мы закончили с нормалями. Все остальные вычисления освещения выполняются по-прежнему.
alexesDev
Только вчера кинул звезду этому репозиторию. Спасибо за перевод.
Но есть смысл переводить текст, где большая часть текста — код и картинки?
CoolMind
Есть. Без этого перевода многие даже не узнали бы об этой статье (и тем более не прочитали бы).
alexesDev
Ок. Смысл есть.
Упомяну только лайфхак. На github можно подписываться на людей и видеть в общей ленте то, что они старят (15-20 подписок на людей из ваших тем и будете в курсе всего). Так я и узнал про этот урок, кто-то из мира gamedev поставил star на эту репу.