Уроки по OpenGL с сайта OGLDev


Урок 23 - Карта теней - часть 1

Концепция теней не отделима от понятия света, именно он потребуется вам для отбрасывания тени. Существует множество методов создания теней, но в этом уроке из 2 частей мы рассмотрим основную и самую простую - карта теней.

Когда дело доходит до растеризации главный вопрос, который вы задаете - находится ли пиксель в тени? Давайте переформулируем вопрос - будет ли луч света, идущей из источника до пикселя, встречать на своем пути препятствия или нет? Если да - вероятно, что пиксель в тени (предполагая, что объект не прозрачный…), и если нет - пиксель не в тени. В каком-то смысле этот вопрос аналогичен вопросу из предыдущего урока - как определить, что 2 объекта перекрывают друг друга и понять, какой из них ближе? Если мы на мгновение поместим камеру в позицию света, то из 2 вопросов останется 1. Мы хотим, что бы пиксель, который не прошел тест глубины (т.е. тот, который дальше и имеет перекрывающие его пиксели) был в тени. Только пиксели, которые пройдут тест, должны оказаться на свету. Они будут единственными, кто получит свет, поскольку их ничто не закрывает. Вот идея карты теней в 2 словах.

Похоже на то, что тест глубины поможет нам определить, находится ли пиксель в тени или нет, но не все так просто. Камера и свет не всегда находятся в одной точке, а тест глубины обычно используется для решения проблем отображения в позиции камеры, поэтому, как мы можем использовать его для обнаружения теней, если источник света расположен где-то вдалеке? Решение - рендерить сцену 2 раза. Первый с позиции света, причем итог рендера не пойдет в буфер цвета. Вместо этого значение глубины ближайшего пикселя рендерится в отдельный буфер глубины (вместо того, который автоматически создается GLUT). Во втором проходе сцена рендерится как обычно, в позиции камеры. Буфер глубины, который мы сами создали, привязывается к фрагментному шейдеру. Для каждого пикселя мы получаем соответствующее значение глубины из буфера. Мы уже подсчитали глубину для этого пикселя с позиции света. Временами эти значения глубины равны. Это тот случай, когда пиксель был ближе к свету, поэтому значение его глубины попало в буфер. Тогда мы считаем, что пиксель на свету, а значит его цвет находится как обычно. Если значения глубины различны, то значит, что другой пиксель перекрыл наш во время первого рендера. Тогда мы учтем этот факт во время нахождения цвета добавив эффект затенения. Посмотрим на следующее изображение:

Наша сцена состоит из 2 объектов - плоскости и куба. Источник света расположен вверху слева и светит на куб. Во время первого прохода мы рендерим в буфер глубины из позиции источника света. Сосредоточимся на 3 точках A, B и C. Когда B рендерится, ее глубина попадет в буфер. Причина в том, что между ней и источником света ничего нет. Это ближайшая точка к свету на этой линии. А вот точки А и С "соревнуются", так как они попадают в один слот в буфере, они обе на одной линии с источником света, поэтому после проекции перспективы растеризатор обнаружит, что они обе хотят занять один и тот же пиксель на экране. Это и есть тест глубины, точка C выходит "победителем".

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

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

Исходный код к этому уроку включает простой квадратный меш, который может быть использован для отображения карты. Он составлен из 2 треугольников, а координаты текстур указаны так, что бы она покрывала всю поверхность. Когда квадрат рендерится, координаты текстуры интерполируются растеризатором, что позволяет отобразить текстуру на экран.

Прямиком к коду!

shadow_map_fbo.h:50

class ShadowMapFBO
{
    public:
        ShadowMapFBO();

        ~ShadowMapFBO();

        bool Init(unsigned int WindowWidth, unsigned int WindowHeight);

        void BindForWriting();

        void BindForReading(GLenum TextureUnit);

    private:
        GLuint m_fbo;
        GLuint m_shadowMap;
};

Результат 3D конвейера в конечном итоге попадает в нечто, называемое "объектом буфера кадра (framebuffer object (или FBO)). Это понятие раскладывается на буфер цвета (который отображается на экран), буфер глубины и еще несколько для дополнительных возможностей. Когда вызывается glutInitDisplayMode(), то создается стандартный буфер кадра с указанными параметрами. Он управляется оконной системой и не может быть удален OpenGL. Но приложение может создать отдельный, свой собственный буфер кадра. Он может быть использован для различных методов под управлением приложения. Класс ShadowMapFBO предоставляет простой интерфейс для FBO, который будет использован для наложения теней. Внутри у него 2 указателя к OpenGL. Первый - 'm_fbo' представляет текущий FBO. FBO инкапсулирует внутреннее состояние буфера кадра. Однажды создав и правильно настроив мы можем менять буфер просто привязав другой. Заметим, что только стандартный буфер может быть использован для отображения чего-либо на экран. Буфер кадра, созданный приложением может быть использован только для "рендера помимо экрана". Это может быть промежуточный рендер (как у нас для карты теней), результат которого будет использован для "настоящего" рендера, который попадет на экран.

Сам по себе буфер кадра легко заполнить. Что бы его можно было использовать, мы должны прикрепить одну или несколько текстур. Текстура содержит пространство буфера кадра. OpenGL определяет следующие точки крепления:

  1. COLOR_ATTACHMENTi - текстура, которая будет прикреплена сюда, будет получать цвет, который выходит из фрагментного шейдера. Окончание 'i' означает, что может быть прикреплено сразу несколько текстур; у фрагментного шейдера есть механизм, который позволяет рендерить сразу в несколько буферов цвета одновременно.
  2. DEPTH_ATTACHMENT - эта текстура будет получать результат теста глубины.
  3. STENCIL_ATTACHMENT - текстура будет использована в качестве трафарета (стенсила). Он ограничивает область растеризации и может быть использован во многих ситуациях.
  4. DEPTH_STENCIL_ATTACHMENT - просто комбинация из двух предыдущих так как они довольно часто используются вместе.

Для карты теней потребуется только буфер глубины. Атрибут 'm_shadowMap' так же указатель на текстуру, которая будет использована для прикрепления к DEPTH_ATTACHMENT. ShadowMapFBO предоставляет набор методов, которые будут использованы в главной функции рендера. Мы будем вызывать BindForWriting() перед рендером в карту теней и BindForReading() перед вторым проходом.

shadow_map_fbo.cpp:42

glGenFramebuffers(1, &m_fbo);

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

shadow_map_fbo.cpp:45

glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);

Далее мы создаем текстуру, которая будет служить в качестве карты теней. В целом это обычная 2D текстура с некоторыми отличиями, что бы она подходила для нашей цели:

  1. Внутренний формат - GL_DEPTH_COMPONENT. Это отличается от предыдущих использовании этой функции, в которой формат всегда был одним из типов цвета (например GL_RGB). GL_DEPTH_COMPONENT представляет единственное вещественное число, обозначающее глубину.
  2. Последний параметр glTexImage2D - 0. Это значит, что мы не поставляем данных для инициализации буфера. Это имеет смысл, зная что буфер будет хранить значение глубины каждого кадра, и каждый кадр будет немного отличаться. Когда бы мы не начали новый кадр, мы будем использовать glClear() для очистки нашего буфера. Вот вся инициализация в данной ситуации.
  3. Мы сообщаем OpenGL, что в случае выхода координат текстуры за пределы, мы их сжимаем до отрезка [0,1]. Это может произойти в случае, когда окно проекции с позиции камеры вмещает больше, чем окно в позиции света. Для избежания артефактов, таких как влияния тени саму на себя, мы сжимаем координаты текстуры.

shadow_map_fbo.cpp:53

glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);

Мы создали FBO, текстуру и настроили ее так, что бы она подходила для карты теней. Теперь нам требуется прикрепить текстуру к FBO. Первое, что мы должны сделать, это привязать FBO. Это сделает его "текущим" и все последующие операции над FBO будут применены к нему. Эта функция принимает указатель на FBO и желаемую цель. Она может быть GL_DRAW_FRAMEBUFFER или GL_READ_FRAMEBUFFER. Мы используем последний когда хотим считать с буфера кадра через glReadPixels (не в этом уроке). Так как мы хотим рендерить в буфер, то мы выбираем GL_DRAW_FRAMEBUFFER.

shadow_map_fbo.cpp:54

glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);

Здесь мы прикрепляем текстуру для карты теней к FBO. Последний параметр указывает на то, какой слой мипмапа использовать. Мипмап - это характеристика отображения текстуры, показывающая различные разрешения, начиная с наивысшего с мипмап равным 0 и уменьшая до 1-N. Комбинация мипмапов текстур - трехлинейный фильтр, дающий наилучший результат через комбинацию текселей с соседних уровней мипмапа (когда не один не подходит полностью). Здесь мы используем только 1 мипмап, поэтому ставим 0. Мы даем указатель на карту теней как 4 параметр.

shadow_map_fbo.cpp:57

glDrawBuffer(GL_NONE);

Так как мы не собираемся рендерить в буфер цвета (только в глубину) мы прямо указываем это выше. По умолчанию цель буфера цвета GL_COLOR_ATTACHMENT0, но наш FBO и не собирался хранить буфер цвета. Поэтому лучше указать OpenGL наши намерения. Подходящими параметрами для этой функции будут GL_NONE и от GL_COLOR_ATTACHMENT0 до GL_COLOR_ATTACHMENTm, где 'm' равна GL_MAX_COLOR_ATTACHMENTS - 1. Эти параметры используются только для FBO. Если используется буфер по умолчанию, то можно указать лишь GL_NONE, GL_FRONT_LEFT, GL_FRONT_RIGHT, GL_BACK_LEFT и GL_BACK_RIGHT. Это позволит рендерить прямо в передний или задний буферы (каждый из которых имеет левый и правый буферы).

shadow_map_fbo.cpp:59

GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);

if (Status != GL_FRAMEBUFFER_COMPLETE) {
    printf("FB error, status: 0x%x\n", Status);
    return false;
}

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

shadow_map_fbo.cpp:70

void ShadowMapFBO::BindForWriting()
{
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}

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

shadow_map_fbo.cpp:76

void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
    glActiveTexture(TextureUnit);
    glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}

… а эта функция будет использована перед вторым проходом для привязывания карты теней для чтения. Заметим, что мы привязываем объект текстуры вместо FBO. Эта функция принимает модуль текстуры, к которому будет привязана карта теней. Индекс модуля текстуры должен быть синхронизирован с шейдером (так как шейдер имеет uniform-переменную сэмплера текстуры). Очень важно заметить, что glActiveTexture принимает индекс текстуры как перечисление (например GL_TEXTURE0, GL_TEXTURE1 и т.д.), а шейдеру требуется только индекс (0, 1 …). Это может стать источником многих ошибок (поверь мне, я знаю).

shadow_map_technique.cpp:22

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;

out vec2 TexCoordOut;

void main()
{
        gl_Position = gWVP * vec4(Position, 1.0);
        TexCoordOut = TexCoord;
}

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

shadow_map_technique.cpp:39

#version 330

in vec2 TexCoordOut;
uniform sampler2D gShadowMap;

out vec4 FragColor;

void main()
{
    float Depth = texture(gShadowMap, TexCoordOut).x;
    Depth = 1.0 - (1.0 - Depth) * 25.0;
    FragColor = vec4(Depth);
}

Это фрагментный шейдер, который используется для отображения карты теней в рендере. Координаты 2D текстуры используются для получения значения глубины из карты. Текстура карты теней была создана с внутренним форматом GL_DEPTH_COMPONENT. Это значит, что тексел будет вещественным числом, не цветом. Вот почему используется '.x'. Матрица перспективы проекции имеет поведение, которое изменяет значение Z, что бы оно входило в отрезок [0,1] и чем ближе вершина, тем меньше значение. Объясняется это тем, что требуется обеспечить наибольшую точность Z при приближении к камере, потому что ошибки в этом хорошо заметны. Когда мы отображаем содержание буфера глубины, мы можем попасть в ситуацию, когда изображение не достаточно ясное. Поэтому после нахождения сэмплера глубины из карты теней мы уточняем через масштабирование расстояния текущей точки к дальней стороне (в которой Z равен 1.0) и затем вычитаем результат из 1.0 опять. Это усиление улучшит итоговое изображение. Мы используем новое значение глубины для создания цвета передавая его через все каналы. Это значит, что мы получим некоторые оттенки серого (белый на ближней секущей плоскости и черный на дальней).

Давайте посмотрим на то, как объединить куски кода выше и создать приложение.

tutorial23.cpp:107

virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_scale += 0.05f;

    ShadowMapPass();
    RenderPass();

    glutSwapBuffers();
}

Главная функция рендера стала гораздо проще, так как остальной функционал перемещен в другие функции. Для начала мы позаботились об "глобальных" вещах на подобии обновления позиции камеры и других членах класса. Затем мы вызываем функцию для рендера в карту высот до функции отображения результата. Наконец, вызывается glutSwapBuffer() для обновления экрана.

tutorial23.cpp:120

virtual void ShadowMapPass()
{
    m_shadowMapFBO.BindForWriting();

    glClear(GL_DEPTH_BUFFER_BIT);

    Pipeline p;
    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 5.0f);
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());

    m_pMesh->Render();

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

Мы начинаем этап карты теней с привязки FBO. Начиная с этого момента все значения высот будут переходить в нашу текстуру карты теней, а цвет будет выброшен. Мы очищаем буфер глубины (только) прежде чем начать делать что-либо. Затем мы устанавливаем класс конвейера для того, что бы рендерить меш (модель из Quake2 поставляется вместе с исходным кодом). Стоит заметить, что камера обновляется согласно позиции и направлению прожектора. Мы рендерим меш и затем переключаемся обратно в стандартный буфер кадра передав в качестве FBO 0.

main.cpp:139

virtual void RenderPass()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pShadowMapTech->SetTextureUnit(0);
    m_shadowMapFBO.BindForReading(GL_TEXTURE0);

    Pipeline p;
    p.Scale(5.0f, 5.0f, 5.0f);
    p.WorldPos(0.0f, 0.0f, 10.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());
    m_pQuad->Render();
}

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

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

powered byDisqus