Определение видимости средствами GPU. Расширения ARB_occlusion_query и NV_conditional_render.

Одной из классических задач компьютерной графики является задача определения видимости - определить какие именно объекты (и какие их части) будут видны (или не видны) для данного положения наблюдателя - HSR (Hidden Surface Removal).

Сейчас эта задача благополучная переложена на аппаратно-реализованный z-буфер. Однако оказывается, что даже аппаратно реализованный z-буфер для сцен большой сложности (и с использованием сложный шейдеров) может дать явно недостаточное быстродействие. Причина этого - z-буфер требует вывода всех граней и их растеризации, давая тем самым затраты порядка O(N), где N - число выводимых объектов.

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

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

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

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

Тут можно воспользоваться целым рядом подходов, позволяющим находить и отбрасывать заведомо невидимые грани/объекты. За счет этого удается понизить вычислительную сложность алгоритма - в идеале хочется получить зависимость линейную по количеству действительно видимых граней.

Все подобные подходы называются culling и обычно активно используют различные пространственные индексы.

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

Рис 1. frusum culling - только объекты E, F, G и H могут быть видны.

Однако гораздо большую выгоду для многих сцен может дать учет загораживающей способности самих выводимых объектов - так расположенный прямо перед камерой объект закрывает от камеры довольно большую часть сцены. Так объект A полностью закрывает собой от камеры объекты B и C (см. рис 2).

Рис 2. Occlusion culling.

Если же учитывать закрывание сразу группой объект, то получается т.н. occluder fusion - несколько объектов, взятые вместе, закрывают гораздо большую часть сцены, чем взятые по отдельности (см. рис 3).

Рис 3. Occluder fusion - ни один из объектов A и B, взятый по отдельности, не может закрыть собой объект C. Однако взятые вместе они полностью закрывают его.

Закрывающую способность отдельных объектов еще можно моделировать на CPU - обычно вводятся так называемые закрыватели (occluders) - невидимые и более простые объекты - используемые для построения "закрытых" областей пространства. По каждому из таких закрывателей строится пирамида, представляющая собой часть пространства, закрытую данным закрывателем от наблюдателя. Однако подобные закрывающие объекты обычно создаются и расставляются вручную и не поддерживают occluder fusion.

Для того, чтобы эффективно использовать occluder fusion необходим быстрый доступ к содержимому z-буфера, т.е. необходима аппаратная поддержка occlusion test'ов самим GPU.

В OpenGL аппаратная поддержка occlusion test'ов осуществляется через два расширения - ARB_occlusion_query и NV_conditional_render.

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

Сам запрос на число проходящих тест глубины фрагментов осуществляется через специальный объект - query. Каждый query-объект идентифицируется при помощи беззнакового отличного от нуля целого числа (подобно текстурам). Создание и и уничтожение query-объектов осуществляются при помощи следующих функций :

void      glGenQueriesARB    ( GLsizei n, GLuint * ids );
void      glDeleteQueriesARB ( GLsizei n, const GLuint * ids );

При помощи функции glIsQueryARB можно проверить является ли заданное число идентификатором какого-либо query-объекта.

GLboolean glIsQueryARB ( GLuint id );

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

void      glBeginQueryARB ( GLenum target, GLuint queryId );
void      glEndQueryARB   ( GLenum target );

В качестве параметра target используется константа GL_SAMPLES_PASSED_ARB, а параметр queryId задает query-объект, используемый для получения результата. Обратите внимание, что запрос видимости (occlusion query) является асинхронным и их может быть много - т.е. можно создать целую серия подряд идущих запросов видимости, каждый такой запрос будет иметь свой query-объект и свою пару glBeginQueryARB и glBeginQueryARB (эти пары не могут пересекаться и быть вложенными).

Для получения информации о результате и состоянии запроса служат следующие функции:

void glGetQueryObjectivARB  ( GLuint id,     GLenum pname, int    * params );
void glGetQueryObjectuivARB ( GLuint id,     GLenum pname, GLuint * params );
void glGetQueryivARB        ( GLenum target, GLenum pname, int    * params );

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

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

GLuint resultReady;

glGetQueryObjectuivARB ( queryId, GL_QUERY_RESULT_AVAILABLE_ARB, &resultReady );

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

GLuint result;

glQueryObjectuiv ( queryId, GL_QUERY_RESULT_ARB, &result );

Для получения числа бит счетчика фрагментов служит приведенный ниже код.

int numBits;

glQueryObjectiv ( GL_SAMPLES_PASSED_ARB, GL_QUERY_COUNTER_BITS_ARB, &numBits );

Однако в силу устройства GPU запрос на видимость (occlusion query) обладает латентностью, т.е. его результат доступен не сразу же после запроса, а лишь спустя некоторое время. Однако существует возможность узнать готов ли уже результат, т.е. можно в ожидании готовности выполнять какие-либо другие операции.

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

Значительно сократить ожидание может использование расширения NV_conditional_render. Данное расширение позволяет связать выполнение заданного набора команд с переданным ранее запросом на видимость (occlusion query). Тем самым, не нужно дожидаться пока результат запроса будет готов - можно сразу же отправить данные, но с пометкой условного рендеринга. Если в результате переданного запроса будет установлена невидимость, то вывод соответствующих объектов будет просто пропущен.

Расширение NV_conditional_render вводит всего две новых команды, обозначающие начало и конец "условного рендеринга".

void glBeginConditionalRenderNV ( GLuint queryId, GLenum mode );
void glEndConditionalRenderNV   ();

Параметр queryId задает запрос на видимость (occlusion query), результат которого (GL_SAMPLES_PASSED_ARB) будет использован для решения, стоит ли выводить объекты из данного блока условного рендеринга.

Параметр mode задает, что надо делать для случая, если результаты запроса все-таки не готовы (такое может быть из-за параллельного характера обработки данных на GPU, поэтому желательно чтобы между заданием occlusion query и началом блока условного рендеринга были другие команды). Возможными значениями для данного параметра являются GL_QUERY_WAIT_NV, GL_QUERY_NO_WAIT_NV, GL_QUERY_BY_REGION_WAIT_NV и GL_QUERY_BY_REGION_NO_WAIT_NV.

Режим GL_QUERY_WAIT_NV задает, что в случае, когда к началу рендеринга результаты запроса видимости еще не готовы, необходимо их дождаться и по ним принять решение выводить объекты или нет.

Режим GL_QUERY_NO_WAIT_NV означает, что в случае неготовности результатов, следует сразу же вывести объекты, т.е. в этом случае происходит "нормальный" рендеринг.

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

Таким образом использование "условного рендеринга" позволяет не дожидаться, пока запрос на видимость вернется с GPU на CPU, а просто передать данные и позволить GPU самому решить нужно ли выводить эти данные.