steps3D - Tutorials - Иерархический z-буфер: создание и использование для определения видимости

Иерархический z-буфер: создание и использование для определения видимости

При работе с обычными текстурами уже довольно давно используется пирамидальное фильтрование (mipmapping). Это позволяет эффективно и просто бороться с алиасингом. Основная идея в том, что вместо одной текстуры W*H текселов мы строим целую пирамиду текстур, на каждом шаге уменьшая вдвое ее размер по каждому измерению. Стандартный подход заключается в том, что на каждом уровне мы текстуру разбиваем на блоки 2х2 тексела и каждому такому блоку в следующем уровне будет соответствовать всего один тексел. Значение цвета в этом текселе будет равно среднему арифметическому цветов четырех исходный текселов.

Рис 1. Построение следующего шага пирамиды

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

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

Есть классическая работа по иерархической видимости (Hierarchical Z-buffer Visibility, Ned Greene, Michael Kass, Gavin Miller) рассматривается как можно по буферу глубины построить иерархию уровней (как и в обычном пирамидальном фильтровании). И эту иерархию можно крайне эффективно использовать для быстрого отсечения по видимости больших групп геометрии.

Современные GPU внутри себя часто используют такой иерархический буфер глубины для ускорения проверки видимости. Однако при этом GPU использует такой иерархический буфер глубины внутри себя и все уровни кроме нулевого не доступны программисту.

В этой статье будет рассмотрено как можно по уже имеющейся текстуре со значениями глубины (depth map) можно построить полную пирамиду и как можно использовать ее для быстрого отсечения невидимой геометрии на GPU.

Давайте рассмотрим как по i-му уровню пирамиды (размером wi*hi<) можно построить i+1-ый уровень. Мы как и ранее будем группировать текселы в блоки 2х2 на i-ом уровне и каждому такому блоку будет соответствовать один тексел на уровне i+1. Если мы хотим использовать получившийся иерархический буфер глубины для быстрой проверки на видимости, то нам нужно строить консервативную оценку видимости (т.е. когда мы можем ошибочно признать невидимый объект видимым, но никогда не признаем видимый объект невидимым). Для получения этого свойства мы должны к значениям глубины из блока 2х2 применять операцию max вместо традиционного усреднения.

Рис 2. Построение уровней при помощи max

Таким образом мы при построении i+1-го уровня задаем новую область рендеринга через glViewport соответствующий i+1-му уровню. Также мы задаем что будем читать только с i-го уровня, а писать в i+1-ый. На самом деле OpenGL позволяет читать и писать в одну и ту же текстуру, при условии что мы читаем с одного уровня, а пишем на другой.

Обратите внимание, что у нас размер i+1-го уровня ни по одному измерению не может быть меньше единицы. Далее мы выводим полноэкранный прямоугольник при помощи соответствующего шейдера. Потом эта же процедура повторяется снова и снова, пока мы не придем к текстуре размером 1х1.

void    buildDepthPyramid ()
{
    downsampleProgram.bind ();
        
    depthMap->bind    ();

            // copy depth buffer contents
    glCopyTexImage2D ( depthMap->getTarget (), 0, GL_DEPTH_COMPONENT, 0, 0, depthMap->getWidth (), depthMap->getHeight (), 0 );

        // prepare rendering to framebuffer
    fb.bind ();

             // build mipmap pyramid for 
            // start post processing, we don't need depth test
    glColorMask ( GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE );
    glDepthFunc ( GL_ALWAYS );        

            // calculate the number of mipmap levels for NPOT texture
    int w         = getWidth  ();
    int h         = getHeight ();
    int numLevels = 1 + (int)floorf ( log2f ( fmaxf ( w, h ) ) );

    for ( int i = 1; i < numLevels; i++ )
    {
        downsampleProgram.setUniformInt ( "lastMipWidth",  w );
        downsampleProgram.setUniformInt ( "lastMipHeight", h );
            
                // calculate next viewport size
        w = std::max ( 1, w / 2 );
        h = std::max ( 1, h / 2 );
            
                // set render area
        glViewport ( 0, 0, w, h );
            
                // bind next level for rendering but first restrict fetches only to previous level
        glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, i - 1 );
        glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL,  i - 1 );

        fb.attachDepthTexture ( depthMap, i );

            // draw full-screen quad
        quad.render ();
    }
        
    downsampleProgram.unbind ();
        
            // reset mipmap level range for the depth image
    glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0 );
    glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL,  numLevels - 1 );
        
    glColorMask ( GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE );
    glDepthFunc ( GL_LEQUAL );

            // restore viewport
    glViewport ( 0, 0, getWidth (), getHeight () );

           // restore depth mip 0 as framebuffer binding
    fb.attachDepthTexture ( depthMap, 0 );
    fb.unbind ();
}

Если мы имеем дело с буфером глубины, размер которого по любому измерению является степенью двух, то мы получаем простейший шейдер - читаем четыре значения глубины, выбираем среди них максимальное и выводим его. Однако обычно размер буфера глубины не является степенью двух, то необходимо добавить в шейдер код для обработки граничных значений.

-- vertex

#version 460 core

layout(location = 0) in vec4 pos;

uniform vec2    scale;
uniform mat4    proj;

out vec2 tex;
out vec2 viewRay;

void main(void)
{
    tex         = 0.5 * ( pos.xy + vec2 ( 1.0 ) );
    gl_Position = vec4 ( pos.xy, 0.0, 1.0 );
}

-- fragment

#version 460 core

uniform sampler2D   lastMip;
uniform int         lastMipWidth;
uniform int         lastMipHeight;

in vec2 tex;

void main(void)
{
    vec4    texels;
    vec3    extra;

    texels.x = texture       ( lastMip, tex ).x;
    texels.y = textureOffset ( lastMip, tex, ivec2(-1, 0) ).x;
    texels.z = textureOffset ( lastMip, tex, ivec2(-1,-1) ).x;
    texels.w = textureOffset ( lastMip, tex, ivec2( 0,-1) ).x;
    
    float   maxZ = max ( max ( texels.x, texels.y ), max ( texels.z, texels.w ) );

        // if we are reducing an odd-width texture then the edge fragments have to fetch additional texels
    if ( ( (lastMipWidth & 1) != 0 ) && ( int(gl_FragCoord.x) == lastMipWidth - 3 ) ) 
    {
            // if both edges are odd, fetch the top-left corner texel
        if ( ( (lastMipHeight & 1) != 0 ) && ( int(gl_FragCoord.y) == lastMipHeight - 3 ) ) 
        {
            extra.z = textureOffset ( lastMip, tex, ivec2( 1, 1) ).x;
            maxZ    = max           ( maxZ, extra.z );
        }

        extra.x = textureOffset ( lastMip, tex, ivec2( 1, 0) ).x;
        extra.y = textureOffset ( lastMip, tex, ivec2( 1,-1) ).x;
        maxZ    = max           ( maxZ, max( extra.x, extra.y ) );
    } 
    else    // if we are reducing an odd-height texture then the edge fragments have to fetch additional texels
    if ( ( (lastMipHeight & 1) != 0 ) && ( int(gl_FragCoord.y) == lastMipHeight - 3 ) ) 
    {
        extra.x = textureOffset ( lastMip, tex, ivec2( 0, 1) ).x;
        extra.y = textureOffset ( lastMip, tex, ivec2(-1, 1) ).x;
        maxZ    = max           ( maxZ, max( extra.x, extra.y ) );
    }
    
    gl_FragDepth = maxZ;
}

Полученный таким образом иерархический буфер глубины можно использовать для самых разных целей, наиболее распространенными из них являются трассировка лучей по буферу глубины (например для screen-space reflection) и быстрое отсечение заведомо невидимых блоков геометрии.

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

Рис 3. Определение невидимости AABB

В результате мы приходим к следующей схеме определения видимости. На этапе подготовки геометрии мы разбиваем всю геометрию на группы близкорасположенных граней bi и строим ограничивающие тела Bi для каждой группы. Также мы выделяем (или строим) группу окклюдеров, т.е. больших граней, способных закрывать сообща много геометрии.

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

Далее вычислительный шейдер для каждой группы bi проверяет соответствующее ограничивающее тело Bi на видимость относительно построенного иерархического буфера глубины. Если не удалось доказать невидимость Bi то вся группа bi считается видимой и в выходной буфер добавляется запись, задающая рендеринг bi через indirect rendering.

-- compute
#version 460

layout( local_size_x = 64 ) in;

uniform mat4      mv;
uniform mat4      proj;
uniform float     width;
uniform float     height;
uniform vec3      extent;
uniform float     zNear;
uniform float     zFar;
uniform sampler2D hiZBuffer;

struct DrawElementsIndirectCommand
{
    uint    count;
    uint    instanceCount;
    uint    firstIndex;
    uint    baseVertex;
    uint    baseInstance;
};
 
layout(std430, binding = 0) buffer Pos 
{
    vec4 position [];
};

layout(std430, binding = 1) buffer Cmd
{
    DrawElementsIndirectCommand cmds [];
};

vec4 boundingBox [8];

bool    frustumCull ( in vec3 instPos, in vec3 ext )
{
    mat4 mvp = proj * mv;

        // create the bounding box of the object
    boundingBox [0] = mvp * vec4 ( instPos + vec3 (  ext.x,  ext.y,  ext.z ), 1.0 );
    boundingBox [1] = mvp * vec4 ( instPos + vec3 ( -ext.x,  ext.y,  ext.z ), 1.0 );
    boundingBox [2] = mvp * vec4 ( instPos + vec3 (  ext.x, -ext.y,  ext.z ), 1.0 );
    boundingBox [3] = mvp * vec4 ( instPos + vec3 ( -ext.x, -ext.y,  ext.z ), 1.0 );
    boundingBox [4] = mvp * vec4 ( instPos + vec3 (  ext.x,  ext.y, -ext.z ), 1.0 );
    boundingBox [5] = mvp * vec4 ( instPos + vec3 ( -ext.x,  ext.y, -ext.z ), 1.0 );
    boundingBox [6] = mvp * vec4 ( instPos + vec3 (  ext.x, -ext.y, -ext.z ), 1.0 );
    boundingBox [7] = mvp * vec4 ( instPos + vec3 ( -ext.x, -ext.y, -ext.z ), 1.0 );
   
        // check how the bounding box resides regarding to the view frustum
    int outOfBound [6] = int[6]( 0, 0, 0, 0, 0, 0 );

    // check very box vertex against each plane
    for ( int i = 0; i < 8; i++ )
    {
        if ( boundingBox[i].x >  boundingBox[i].w ) 
            outOfBound [0]++;

        if ( boundingBox [i].x < -boundingBox [i].w ) 
            outOfBound [1]++;

        if ( boundingBox [i].y >  boundingBox [i].w ) 
            outOfBound [2]++;

        if ( boundingBox [i].y < -boundingBox [i].w ) 
            outOfBound [3]++;

        if ( boundingBox [i].z >  boundingBox [i].w ) 
            outOfBound [4]++;

        if ( boundingBox [i].z < -boundingBox [i].w ) 
            outOfBound [5]++;
    }

    for ( int i = 0; i < 6; i++ )
        if ( outOfBound [i] == 8 ) 
            return false;

    return true;
}

float   linearDepth ( float d )
{
  return (2.0 * zNear) / (zFar + zNear - d * (zFar - zNear));
}

bool    hiZHidden ( in vec3 instPos, in vec3 ext )
{
        // first do frustum cull
    if ( !frustumCull ( instPos, ext ) ) 
        return true;
    
        // perform perspective division for the bounding box
        // npw boundingBox [i] in clip space [-1,1]^3
    for ( int i = 0; i < 8; i++ )
        boundingBox [i].xyz /= boundingBox [i].w;
    
    vec2 boundingRect [2];

        // calculate screen space bounding rectangle
        // convert xy from clip space to [0,1]^2
    boundingRect[0].x = min ( min ( min ( boundingBox [0].x, boundingBox [1].x ),
                                    min ( boundingBox [2].x, boundingBox [3].x ) ),
                              min ( min ( boundingBox [4].x, boundingBox [5].x ),
                                    min ( boundingBox [6].x, boundingBox [7].x ) ) ) / 2.0 + 0.5;
                                    
    boundingRect[0].y = min ( min ( min ( boundingBox [0].y, boundingBox [1].y ),
                                    min ( boundingBox [2].y, boundingBox [3].y ) ),
                              min ( min ( boundingBox [4].y, boundingBox [5].y ),
                                    min ( boundingBox [6].y, boundingBox [7].y ) ) ) / 2.0 + 0.5;
                                    
    boundingRect[1].x = max ( max ( max ( boundingBox [0].x, boundingBox [1].x ),
                                    max ( boundingBox [2].x, boundingBox [3].x ) ),
                              max ( max ( boundingBox [4].x, boundingBox [5].x ),
                                    max ( boundingBox [6].x, boundingBox [7].x ) ) ) / 2.0 + 0.5;
                                    
    boundingRect[1].y = max ( max ( max ( boundingBox [0].y, boundingBox [1].y ),
                                    max ( boundingBox [2].y, boundingBox [3].y ) ),
                              max ( max ( boundingBox [4].y, boundingBox [5].y ),
                                    max ( boundingBox [6].y, boundingBox [7].y ) ) ) / 2.0 + 0.5;

        // then depth value of the front-most (nearest) point
    float instanceDepth = min ( min ( min ( boundingBox [0].z, boundingBox  [1].z ),
                                      min ( boundingBox [2].z,  boundingBox [3].z ) ),
                                min ( min ( boundingBox [4].z,  boundingBox [5].z ),
                                      min ( boundingBox [6].z,  boundingBox [7].z ) ) );

        // convert min depth from [-1,1] to [0,1]
    instanceDepth = instanceDepth / 2.0 + 0.5;

        // calculate the bounding rectangle size in viewport coordinates
    float viewSizeX = (boundingRect [1].x - boundingRect [0].x) * width;
    float viewSizeY = (boundingRect [1].y - boundingRect [0].y) * height;
    
        // calculate texture LOD used for lookup in the depth buffer texture
    float LOD = ceil ( log2 ( max ( viewSizeX, viewSizeY ) / 2.0 ) );
    
        // finally fetch the depth texture using explicit LOD lookups
    vec4 samples;
    
    samples.x = textureLod ( hiZBuffer, vec2 ( boundingRect [0].x, boundingRect [0].y ), LOD ).x;
    samples.y = textureLod ( hiZBuffer, vec2 ( boundingRect [0].x, boundingRect [1].y ), LOD ).x;
    samples.z = textureLod ( hiZBuffer, vec2 ( boundingRect [1].x, boundingRect [1].y ), LOD ).x;
    samples.w = textureLod ( hiZBuffer, vec2 ( boundingRect [1].x, boundingRect [0].y ), LOD ).x;
    
        // get max value depth from buffer
    float maxDepth = max ( max ( samples.x, samples.y ), max ( samples.z, samples.w ) );
    
        // object is hidden if it is farther than buffer max depth
    bool    isHidden = instanceDepth >= maxDepth;

    return isHidden;
}

void main() 
{
    uint idx = gl_GlobalInvocationID.x;
    
    if ( idx < position.length () )
    {
        vec3 p   = position [idx].xyz;
    
        cmds [idx].instanceCount = hiZHidden ( p, extent ) ? 0 : 1;
    }
}

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

void glMultiDrawElementsIndirect ( GLenum mode, GLenum type,
                                    const void * indirect, GLsizei drawCount, GLsizei stride );

Здесь параметр mode задает тип выводимых примитивов (обычно это GL_TRIANGLES), параметр type задает используемый тип значений в индексном буфере. Параметр indirect задает смещение на массив данных в буфере типа GL_DRAW_INDIRECT_BUFFER. Параметр drawCount задает число элементов в этом массиве, а stride - смещение между началами очередных элементов (если он равен нулю, то смещение равно размеру структуры DrawElementsIndirectCommand).

Буфер типа GL_DRAW_INDIRECT_BUFFER содержим массив структур DrawElementsIndirectCommand, где каждая такая структура задает отдельный блок выводимой геометрии.

struct DrawElementsIndirectCommand
{
    GLuint    count;
    GLuint    instanceCount;
    GLuint    firstIndex;
    GLint     baseVertex;
    GLuint    baseInstance;
};

void    buildVisibleList ()
 {
        auto   sz = knot -> getBox ().getSize () * 0.5f;

        if ( cull )
        {
            depthMap -> bind      ( 0 );
            depthMap -> setFilter ( GL_NEAREST_MIPMAP_NEAREST, GL_NEAREST );

            cullProgram.bind ();
            cullProgram.setUniformMatrix ( "proj",   camera.getProjection () );
            cullProgram.setUniformMatrix ( "mv",     camera.getModelView  () );
            cullProgram.setUniformFloat  ( "width",  getWidth  () );
            cullProgram.setUniformFloat  ( "height", getHeight () );
            cullProgram.setUniformVector ( "extent", sz );
            cullProgram.setUniformFloat  ( "zNear",  getCamera ().getZNear () );
            cullProgram.setUniformFloat  ( "zFar",   getCamera ().getZFar  () );

            drawBuf.bindBase ( GL_SHADER_STORAGE_BUFFER, 1 );
            posBuf.bindBase  ( GL_SHADER_STORAGE_BUFFER, 0 );
            
                // run one thread of cull shader per each model
            glDispatchCompute ( (numModels + 63) / 64, 1, 1 );
            
            depthMap -> setFilter    ( GL_NEAREST, GL_NEAREST );
            cullProgram.unbind ();
        }      
 }
        
 void    renderVisibles ()
 {       
        program2.bind ();
        program2.setUniformMatrix ( "proj",  camera.getProjection () );
        program2.setUniformMatrix ( "mv",    camera.getModelView  () );

        knot->getVao ().bind ();
        
        drawBuf.bind    ( GL_DRAW_INDIRECT_BUFFER );
        posBuf.bindBase ( GL_SHADER_STORAGE_BUFFER, 0 );
        
        glMultiDrawElementsIndirect ( GL_TRIANGLES, GL_UNSIGNED_INT, 0, numModels, 0 );
        
        knot->getVao ().unbind ();
        
        program2.unbind ();
 }

По этой ссылке можно скачать весь исходный код к этой статье.