Learnopengl. Урок 4.1 — Тест глубины

https://goo.gl/oUiGhF
  • Перевод
  • Tutorial
OGL3

Буфер глубины


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


Буфер глубины также, как и буфер цвета (хранящий цвета всех фрагментов – видимое изображение), хранит определенную информацию для каждого фрагмента и, обычно, имеет размеры совпадающие с размерами буфера цвета. Буфер глубины создается автоматически оконной системой ОС и хранит значения в виде 16, 24 или 32 битных чисел с плавающей точкой. В большинстве систем по умолчанию создается буфер с точностью 24 бита.

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

Тест глубины производится в экранном пространстве после выполнения фрагментного шейдера (и после теста трафарета, который будет рассмотрен в следующем уроке). Экранные координаты непосредственно связаны с параметрами окна просмотра, заданными функцией glViewport, и доступны через встроенную переменную GLSL gl_FragCoord в коде фрагментного шейдера. Компоненты x и y данной переменной представляют собой координаты фрагмента в окне просмотра (левый нижний угол окна имеет координаты (0, 0)). У gl_FragCoord также есть и третья компонента, которая собственно и содержит значение глубины фрагмента. Эта z-компонента используется для сравнения со значениями из буфера глубины.
Современные GPU практически все используют трюк, называемый ранним тестом глубины. Эта техника позволяет выполнить тест глубины до выполнения фрагментного шейдера. Если нам становится известно, что данный фрагмент никак не может быть виден (перекрыт другими объектами), то мы можем отбросить его до этапа шейдинга.
Фрагментные шейдеры довольно вычислительно тяжелы, потому стоит избегать их выполнения там, где это бессмысленно. У данной техники есть только одно ограничение: фрагментный шейдер не должен изменять значение глубины фрагмента. Это очевидно, ведь OpenGL в таком случае не сможет наперед определить значение глубины обрабатываемого фрагмента.

По умолчанию тест глубины отключен. Включим его:

glEnable(GL_DEPTH_TEST);  

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

Включение теста глубины также требует очистки буфера от старых значений в каждом кадре. В знакомую функцию glClear добавляется новый флаг GL_DEPTH_BUFFER_BIT

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);  

В определенных ситуациях вам может потребоваться выполнение теста глубины для обрабатываемых фрагментов с их отбрасыванием по результатам теста, но без обновления содержимого самого буфера. Т.е. назначение буферу режима «только чтение». Запись в буфер отключается установкой маски глубины в значение GL_FALSE:

glDepthMask(GL_FALSE);

Отмечу, что это имеет смысл только при включенном тесте глубины.

Функция теста глубины


OpenGL позволяет переопределить оператор сравнения, используемый в тесте глубины, что дает нам тонкий контроль над тем, какие фрагменты стоит обработать, какие отбросить и в каких случаях буфер глубины будет обновлен. Оператор устанавливается через вызов функции glDepthFunc:

glDepthFunc(GL_LESS); 

Функция принимает идентификатор оператора сравнения из данного списка:


По умолчанию используется GL_LESS, что подразумевает отбрасывание всех фрагментов, имеющих глубину большую или равную значению глубины, хранимому в буфере.

Давайте поэкспериментируем с тем, как разные операторы сравнения влияют на вывод нашего приложения. Мы используем новый проект, задающий сцену с двумя текстурированными кубами, стоящими на полу, и не использующий освещение. Исходный код находится здесь. Для начала поменяем оператор на GL_ALWAYS:

glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_ALWAYS);

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


Вернув обратно оператор GL_LESS мы получим корректную сцену:


Вопрос точности значений глубины


Значения в буфере глубины ограничены интервалом [0.0, 1.0] и относительно них производится проверка z-компоненты всех объектов сцены с точки зрения наблюдателя. При этом z-компонента объекта в видовом пространстве может принять любое значение в интервале [zNear, zFar], который определяет ближнюю и дальнюю границу пирамиды проецирования (projection frustum). Для устранения этого несоответствия нам понадобится способ преобразования значений z-компоненты в видовом пространстве к интервалу [0.0, 1.0]. Первый, наивный, способ – простое линейное преобразование:

${{F}_{depth}}=\frac{z-zNear}{zFar-zNear} $

где zNear и zFar – значения параметров near и far, которые мы использовали при конструировании матрицы проекции, задающей пирамиду видимости (см. системы координат). Данная зависимость принимает как параметр значение z, лежащее внутри пирамиды видимости и преобразует его к интервалу [0.0, 1.0]. Соотношение между значением z и результирующим значением глубины можно увидеть на графике:


Обратите внимание, что все рассмотренные зависимости дают значение, стремящееся к 0.0 для близких объектов и стремящееся к 1.0 для объектов, лежащих вблизи дальней плоскости отсечения.

Однако, на практике линейный буфер глубины практически не используется. Для достижения качественного результата проецирования используется зависимость, пропорциональная величине 1/z. Результатом использования такой зависимости является высокая точность значений глубины для малых z и гораздо меньшая точность для больших z. Задумайтесь над смыслом такого поведения: действительно ли нам важна так же точность значений глубины для объектов, удаленных на тысячи условных единиц от наблюдателя, что и у детализированных объектов, прямо перед наблюдателем? Использование линейного преобразования не учитывает этот вопрос.

Поскольку нелинейное преобразование пропорционально величине 1/z, то для значений z в интервале [1.0, 2.0] мы получим значения глубины в интервале [1.0, 0.5], что уже покрывает половину точности типа float, обеспечивая огромную точность для малых z. Значения z из интервала [50.0, 100.0] будут обеспечены всего 2% от доступной точности типа float – но это как раз то, что нам нужно. Итак, новая зависимость, в том числе учитывающая и параметры zNear и zFar матрицы проекции:

${{F}_{depth}}=\frac{1/z-1/zNear}{1/zFar-1/zNear}\,\,\,\,\,\,\,\,(2)$

Не переживайте, если вам не ясно, что конкретно подразумевает это выражение. Главное – запомнить, что значения, хранимые в буфере глубины, нелинейны в экранном пространстве (в видовом пространстве, до применения матрицы проецирования, они линейны). Значение 0.5 в буфере вовсе не означает, что объект находится посредине пирамиды видимости. На самом деле точка, которой соответствует эта глубина, довольно близка к ближней плоскости отсечения. На графике ниже приведена рассматриваемая зависимость нелинейного значения глубины от исходного значения z-компоненты:


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

Эффект нелинейности легко заметить при попытке визуализировать буфер глубины.

Визуальное представление значений буфера глубины.

Итак, в вершинном шейдере нам доступно значение глубины фрагмента через z-компоненту встроенной переменной gl_FragCoord. Если мы выведем это значение как значение цвета, то мы сможем визуализировать содержимое текущего буфера глубины:

void main()
{             
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
} 

Если вы попробуете запустить приложение, то, верней всего, все будет залито белым цветом, создавая впечатление, что все объекты имеют глубину 1.0 – максимально возможное значение. Отчего же мы не видим более темных участков, где глубина приближается к нулю?

Из предыдущего раздела мы помним, что в экранном пространстве значения буфера глубины нелинейны, т.е. для малых z точность велика, а для больших – мала. Значение глубины очень быстро возрастает с расстоянием в сцене, потому практически все вершины быстро достигают глубины близкой к 1.0. Если мы аккуратно подойдем поближе к одному из объектов, то в конце концов сможем различить потемнение их ближних частей с уменьшением значения z:

Здесь явно видно нелинейный характер значений глубины. У ближних объектов значение глубины изменяется значительно быстрее, чем у удаленных. Малейшее передвижение камеры меняет цвета от практически черных до чисто белого.

У нас, однако, есть возможность преобразовать нелинейные значения глубины фрагмента обратно в линейно распределенные. Для этого нам потребуется буквально обратить процесс проецирования вспять, но только для значений глубины. Первым шагом нам необходимо преобразовать значения глубины обратно из интервала значений [0.0, 1.0] в интервал [-1.0, 1.0] соответствующий нормализованным координатам устройства (NDC, normalized device coordinates) пространства отсечения. Затем выведем выражение, обратное нелинейному выражению (2), и применим его к полученному значению глубины. Результатом будет линейное значение глубины. Звучит вполне нам по силам, как вы считаете?

Итак, для начала переведем значение глубины в NDC:

float z = depth * 2.0 - 1.0; 

Далее, полученное значение z преобразуем в линейное с помощью зависимости, обратной (2):

float linearDepth = (2.0 * zNear * zFar) / (zFar + zNear - z * (zFar - zNear));	

Напомню, что данное выражение получено для матриц проецирования, использующих выражение (2) для нелинейного преобразования значений глубины, также ограничивая их интервалом [zNear, zFar]. Повторно даю ссылку на статью, полную математических деталей внутреннего устройства матрицы проецирования. Также из статьи можно понять, откуда берется упомянутое выше выражение.

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

#version 330 core
out vec4 FragColor;

float zNear = 0.1; 
float zFar  = 100.0; 
  
float LinearizeDepth(float depth) 
{
    // преобразуем обратно в NDC
    float z = depth * 2.0 - 1.0; 
    return (2.0 * zNear * zFar) / (zFar + zNear - z * (zFar - zNear));	
}

void main()
{             
    // деление на zFar для лучшей наглядности
    float depth = LinearizeDepth(gl_FragCoord.z) / zFar;
    FragColor = vec4(vec3(depth), 1.0);
}

Поскольку линеаризованные значения глубины находятся между границами zNear и zFar, то бОльшая часть значений окажется больше 1.0 и будет выведена как чисто белый цвет. Поделив линейное значение глубины на величину zFar в коде функции main мы приближенно приводим его к интервалу [0.0, 1.0]. Это позволит нам наблюдать плавное возрастание яркости объектов сцены с их приближением к дальней плоскости пирамиды проекции, что гораздо нагляднее.

Запустив приложение в этот раз можно убедиться в линейном характере изменения значений глубины с расстоянием. Попробуйте побродить по сцене, дабы понаблюдать за изменениями:
Сцена практически полностью залита черным цветом, поскольку значения глубины линейно меняются от zNear = 0.1 к zFar = 100.0, которая, в данном случае, находится довольно далеко. А поскольку мы находимся рядом с ближней плоскости пирамиды проекции, то значения глубины и, соответственно, яркости весьма малы.

Артефакты ограниченной точности буфера глубины


Довольно распространен визуальный артефакт, появляющийся в случаях, когда две плоскости или два треугольника наложены друг на друга так близко, что точности буфера глубины не хватает для однозначного разрешения порядка расположения этих объектов. В результате этой неоднозначности фрагменты этих объектов постоянно будто бы меняют порядок расположения, создавая визуальный шум и узоры. Явление называется z-fighting, поскольку выглядит так, будто выводимые фигуры дерутся за возможность быть перекрыть другую.

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

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

Методы преодоления


Первый и, пожалуй, самый важным советом будет никогда не располагать объекты слишком близко друг к другу с риском наложения составляющих их треугольников. Добавляя небольшое, незаметное для пользователя, смещение между объектами вы обеспечите себе свободу от z-fighting’а. В нашем случае с плоскостью и контейнерами достаточно было бы просто сместить контейнеры в направлении положительной полуоси Y. Достаточно малое смещение будет незаметным, но достаточным для избавления от артефакта. Однако, данный метод требует ручной модификации сцены и тщательного тестирования для гарантии отсутствия проявлений z-fighting’а в сцене.

Другой подход заключается в задании ближней плоскости отсечения как можно дальше. Как выше было отмечено, значительная точность обеспечивается вблизи плоскости zNear. Потому, если мы отодвинем ближнюю плоскость от наблюдателя мы обеспечим большей точностью весь объем пирамиды видимости. Однако, стоит помнить, что излишнее смещение ближней плоскости может привести к заметному усечению объектов, находящихся вблизи. Так что данный подход требует некоторой доли проб и подгонки, дабы успешно выбрать значение zNear.

Третий метод просто предлагает использовать формат буфера глубины с большей точностью, за что придется расплачиваться долей производительности. В большинстве случаев используются буферы точностью 24 бита, но современные видеокарты позволяют использовать и точность в 32 бита для буфера глубины. Дополнительная точность позволит уменьшить эффект z-fighting’а, но будет стоить вам скорости выполнения.

Данные три техники избавления от z-fighting’а наиболее распространены и при этом легко реализуемы. Существуют и другие способы, более трудоемкие, но все так же не гарантирующие полного избавления от проблемы. Действительно, z-fighting типичная проблема, но при аккуратном применении перечисленных техник вам, вероятно, и вовсе не придется разбираться с проявлениями этого артефакта.

P.S.: один из комментаторов оригинальной статьи дает подсказку о двух методах 100% избавляющих от z-fighting’а: использовании буфера трафарета при рендере в несколько подходов; и использовании расширения SGIX_reference_plain.
  • +18
  • 3,3k
  • 3
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 3
  • +2
    Reversed Depth Buffer достаточно хорошо решает проблему с Z-fighting. Если кратко, то он избавляет от сильных потерь точности float (а чем больше значение, тем меньше точность после точки)
    • +1
      Спасибо за наводку! Очень интересный и простой подход.
      Однако, все хорошо у него только в DirectX из-за разницы в используемом пространстве clip space'a: у OGL интервал [-1, 1], у DX [0, 1]. В итоге просто так метод не применишь. Нужно либо использовать расширение ARB_clip_control extension, либо, с версии 4.5, устанавливать
      glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE);

      В старых версиях и GLES, соответственно, пролет. Хотя, зачем на GLES такая точность по глубине…
    • +1
      Как всегда спасибо, что не пропадаете

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