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


Урок 39 - Обнаружение силуэта

Сегодня мы будем рассуждать о том, как найти силуэт объекта. Поясню, я говорю об силуэте 3D объекта, который получается при падении света в произвольном направлении. При перемещении источника света силуэт соответственно будет меняться. В этом принципиальное отличие от обнаружения силуэта в пространстве изображения, которое заключается в нахождении границы объекта на 2D изображении (и не зависит от позиции источника света). Хотя тема обнаружения силуэта интересна сама по себе, для меня это первый шаг в реализации техники Теневой объём (Stencil Shadow Volume). Это техника рендера теней, которая особенно полезна при работе с точечными источниками. Ее мы изучим в следующем уроке (можете сразу перейти в него).

Следующее изображение иллюстрирует силуэт, который мы хотим найти:

На изображении выше силуэтом является эллипс, окруженный лучами света.

Давайте обсудим реализацию. Модель обычно состоит из треульников, поэтому и силуэт должен состоять из сторон треугольника. Так как мы определим, какая грань часть силуэта, а какая нет? Секрет основывается на модели рассеянного света. Для этой модель сила света зависит от скалярного произведения между нормалью треугольника и вектором света. Если сторона треугольника повернута от источника света, то результат будет меньше либо равен 0. В этом случае свет целиком не попадает на треугольник. Для того, что бы определить является ли сторона треугольника частью силуэта или нет необходимо найти смежный треугольник с общим ребром и вычислить скалярное произведение между направлением света и нормалью и самого треугольника и соседнего к нему. Тогда ребро будет считаться принадлежащим силуэту, если один треугольник в области действия света, а другой - нет.

Следующее изображение показывает для простоты 2D объект:

Красная стрелка представляет луч света, который падает на 3 ребра (в 3D пространстве это будут треугольники), чьи нормали 1, 2, 3 (скалярное произведение этих нормалей и вектора, обратного к вектору света, очевидно больше 0). А грани, чьи нормали 4, 5 и 6 повернуты от треугольника (для них то же скалярное произведение будет меньше либо равно 0). 2 голубых круга отмечают силуэт объекта, причина в том, что грань 1 попадает под действие света, а 6 нет. А точка между ними и есть силлуэт. Аналогично и для других точек силлуэта. Стороны (или точки в нашем случае), у которых освещены обе стороны не являются частью силлуэта (между 1 и 2 и между 2 и 3).

Как вы видите, алгоритм для нахождения силлуэта крайне прост. Хотя, он требует от нас знание всех 3 соседей каждого треугольника. Это известно под названием Смежность (Adjacencies) треугольника. К сожалению, Assimp автоматически не находит смежности для нас, поэтому нам придется самим реализовать этот алгоритм. Во 2 части мы рассмотрим простой алгоритм, который реализует то, что нам требуется.

Где бы нам разместить разместить в коде этот алгоритм? помните, что нам требуется скалярное произведение между вектором света и нормалью к треугольнику, а так же нормали для 3 смежных треугольников. Для этого нам требуется все данные примитива. Нет, VS не достаточно. Похоже, что GS больше подходит, так как он позволяет получить доступ ко всем вершинам примитива. А что со смежностью? к счастью для нас разработчики OpenGL уже предоставляют топологию под названием "треугольник со смежностью". Если в буфере вершин будет информация о смежности, то она корректно загрузится, а GS будет получать по 6 вершин на треугольник вместо 3. 3 дополнительные вершины принадлежат смежным треугольникам и не связанны с текущим. Следующее изображение прояснит ситуацию:

Красные вершины на изображении выше принадлежат исходному треугольнику, а голубые смежным (пока что проигнорируйте грани e1-e6, они относятся к разделу с кодом). Когда в буфере вершин информация в формате выше, то VS будет вызван для каждой вершины (смежной и не смежной), а GS (если существует) вызывается для группы из 6 вершин, в числе которых вершины треугоника и смежные вершины. Когда мы используем GS, то выходная топология ложится на плечи разработчика, а если GS отсутствует, то растеризатор готов к такой схеме и попросту проигнорирует смежные вершины (а обработает вершины треугольника).

Заметим, что смежные вершины в вершинном буфере имеют тот же формат и аттрибуты, что и обычные вершины. Все, что делает их смежными, это их взаимное положение внутри группы из 6 вершин. В модели некоторые треугольники, имеющие общую вершину никак больше не связаны, а некоторые смежные. В таком случае индексная отрисовка становится еще привликательнее, так как экономит место в буфере вершин.

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

mesh.cpp:200

void Mesh::FindAdjacencies(const aiMesh* paiMesh, vector<unsigned int>& Indices)
{
    for (uint i = 0 ; i < paiMesh->mNumFaces ; i++) {
        const aiFace& face = paiMesh->mFaces[i];

        Face Unique;

        // Если вектор позиции продублирован в VB
        // мы будем использовать первый подходящий.
        for (uint j = 0 ; j < 3 ; j++) {
            uint Index = face.mIndices[j];
            aiVector3D& v = paiMesh->mVertices[Index];

            if (m_posMap.find(v) == m_posMap.end()) {
                m_posMap[v] = Index;
            }
            else {
                Index = m_posMap[v];
            }

            Unique.Indices[j] = Index;
        }

        m_uniqueFaces.push_back(Unique);

        Edge e1(Unique.Indices[0], Unique.Indices[1]);
        Edge e2(Unique.Indices[1], Unique.Indices[2]);
        Edge e3(Unique.Indices[2], Unique.Indices[0]);

        m_indexMap[e1].AddNeigbor(i);
        m_indexMap[e2].AddNeigbor(i);
        m_indexMap[e3].AddNeigbor(i);
    }

Большая часть алгоритма для смежностей находится в функции выше а так же в нескольких дополнительных структурах. Алгоритм состоит из 2 этапов. В первом мы задаем отображение между каждой гранью и 2 треугольниками, которые ее разделяют. Это происходит в цикле for выше. В первой половине этого цикла мы создаем связь между координатами позиции вершины и первым индексом вершины, которая ссылается на него. Причина, по которой различные индексы могут соответствовать вершинам с одинаковыми координатами в том, что какие-то другие аттрибуты заставили Assimp разделить одну вершину на 2, например, одна и таже вершина может иметь различные координаты текстуры для двух соседних треугольников. Это усложняет наш алгоритм смежности, нам лучше иметь одну вершину один раз. Хотя мы и создали связь между позицией и первым индексом, в дальнейшем мы будем исользовать только индексы.

mesh.cpp:216

    for (uint i = 0 ; i < paiMesh->mNumFaces ; i++) {
        const Face& face = m_uniqueFaces[i];

        for (uint j = 0 ; j < 3 ; j++) {
            Edge e(face.Indices[j], face.Indices[(j + 1) % 3]);
            assert(m_indexMap.find(e) != m_indexMap.end());
            Neighbors n = m_indexMap[e];
            uint OtherTri = n.GetOther(i);

            assert(OtherTri != -1)

            const Face& OtherFace = m_uniqueFaces[OtherTri];
            uint OppositeIndex = OtherFace.GetOppositeIndex(e);

            Indices.push_back(face.Indices[j]);
            Indices.push_back(OppositeIndex);
        }
    }
}

Во второй части мы заполняем вектор индексов набором из 6 вершин, что соответствует топологии треугольника со смежностью, как было указано ранее. Отображение, которое мы задали ранее, сейчас нам пригодится, поскольку для каждой стороны треугольника легко найти соседний треугольник, который разделяет теже вершины, а затем и вершину напротив этой стороны. 2 последние строки кода цикла чередуют содержимое буфера индексов для текущего треугольника и вершин для смежного треугольника, который расположен напротив текущего.

Так же добавлены мелкие изменения в классе Mesh. Я предлагаю вам сравнить их с версией из предыдущего урока и убедиться, что ничего не пропущено. Главное изменение - мы используем топологию GL_TRIANGLES_ADJACENCY вместо GL_TRIANGLES в вызове glDrawElementsBaseVertex(). Если забыть об этом, то GL передаст испорченные данные в GS.

silhouette.glsl

struct VSInput
{
    vec3  Position;
    vec2  TexCoord;
    vec3  Normal;
};

interface VSOutput
{
    vec3 WorldPos;
};

uniform mat4 gWVP;
uniform mat4 gWorld;

shader VSmain(in VSInput VSin:0, out VSOutput VSout)
{
    vec4 PosL      = vec4(VSin.Position, 1.0);
    gl_Position    = gWVP * PosL;
    VSout.WorldPos = (gWorld * PosL).xyz;
}

В демо мы собираемся обнаружить силуэт объекта и отметить его красной линией. Объект будет нарисован с помощью forward rendering, а для силуэта будет использоваться отдельный шейдер. Код выше соответствует VS. Ничего особенного. Мы только преобразовываем позицию в пространство клиппера с помощью матрицы WVP и даем GS вершины в мировом простарнстве (поскольку алгоритму силуэта именно такие и нужны).

void EmitLine(int StartIndex, int EndIndex)
{
    gl_Position = gl_in[StartIndex].gl_Position;
    EmitVertex();

    gl_Position = gl_in[EndIndex].gl_Position;
    EmitVertex();

    EndPrimitive();
}

uniform vec3 gLightPos;

shader GSmain(in VSOutput GSin[])
    {
    vec3 e1 = GSin[2].WorldPos - GSin[0].WorldPos;
    vec3 e2 = GSin[4].WorldPos - GSin[0].WorldPos;
    vec3 e3 = GSin[1].WorldPos - GSin[0].WorldPos;
    vec3 e4 = GSin[3].WorldPos - GSin[2].WorldPos;
    vec3 e5 = GSin[4].WorldPos - GSin[2].WorldPos;
    vec3 e6 = GSin[5].WorldPos - GSin[0].WorldPos;

    vec3 Normal = cross(e1,e2);
    vec3 LightDir = gLightPos - GSin[0].WorldPos;

    if (dot(Normal, LightDir) > 0.00001) {

        Normal = cross(e3,e1);

        if (dot(Normal, LightDir) <= 0) {
            EmitLine(0, 2);
        }

        Normal = cross(e4,e5);
        LightDir = gLightPos - GSin[2].WorldPos;

        if (dot(Normal, LightDir) <=0) {
            EmitLine(2, 4);
        }

        Normal = cross(e2,e6);
        LightDir = gLightPos - GSin[4].WorldPos;

        if (dot(Normal, LightDir) <= 0) {
            EmitLine(4, 0);
        }
    }
}

Вся логика силуэта внутри GS. Когда используется топология треугольника со смежностями GS получает 6 вершин. Мы начинаем с вычисления нескольких граней, которые помогут нам найти нормаль как текущего треугольника, так и 3 смежных. Посмотрите на изображение выше, что бы понять где находятся грани e1-e6. Затем мы проверяем, освещена ли грань треугольника с помощью скалярного произведения между ее нормалью и вектором света (который направлен вдоль света). Если результат скалярного произведения положительный, то ответ да (мы используем небольшое отклонение в связи с особенностями чисел с плавающей точкой). Если треугольник не освещен, то с ним закончили, но если на него падает свет, мы вычисляем аналогичное скалярное произведение между вектором света и каждым из смежных треугольников. Если смежный треугольник не освещен, то мы вызываем функцию EmitLine(), которая, очевидно, выпускает общую сторону между треугольником (на который падает свет) и его соседом (на который нет). FS просто задает цвет грани красным.

tutorial39.cpp:175

void RenderScene()
{
    // Рендерим объект как-есть
    m_LightingTech.Enable();

    Pipeline p;
    p.SetPerspectiveProj(m_persProjInfo);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    p.WorldPos(m_boxPos);
    m_LightingTech.SetWorldMatrix(p.GetWorldTrans());
    m_LightingTech.SetWVP(p.GetWVPTrans());

        m_mesh.Render();

    // Рендерим его силуэт
    m_silhouetteTech.Enable();

    m_silhouetteTech.SetWorldMatrix(p.GetWorldTrans());
    m_silhouetteTech.SetWVP(p.GetWVPTrans());
    m_silhouetteTech.SetLightPos(Vector3f(0.0f, 10.0f, 0.0f));

    glLineWidth(5.0f);

        m_mesh.Render();
}

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

Если вы будете использовать код выше как-есть, то вы можете заметить небольшие искажения вокруг линий силуэта. Причина в том, что второй рендер генерирует линии с приблизительно той же глубиной, как и исходное ребро меша. У этого феномена есть название Z fighting, так как пиксели из силуэта и исходного меша перекрывают друг друга непоследовательно (снова из-за величин с плавающей точкой). Что бы избежать этого мы используем функцию glDepthFunc(GL_LEQUAL), которая слегка расслабляет тест глубины. Это значит, что 2 пиксель перекроет предыдущий при равной глубине, у последнего всегда преимущество.

powered byDisqus