C++
Working with 3D-graphics
24 June

WBOIT в OpenGL: прозрачность без сортировки

Речь пойдёт о “Weighted blended order-independent transparency” (далее WBOIT) — приёме, описанном в JCGT в 2013 г. (ссылка).

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

$\begin{matrix}C = C_{near} \alpha +C_{far}(1- \alpha )&&(1)\end{matrix}$


Для неё важен порядок расположения фрагментов: цвет ближнего фрагмента и его непрозрачность (opacity) обозначены как Cnear и α, а результирующий цвет всех фрагментов, что расположились за ним — как Cfar. Непрозрачность — это свойство, принимающее значения от 0 до 1, где 0 означает, что объект настолько прозрачен, что его не видно, а 1 — что он настолько непрозрачен, что за ним ничего не видно.

Чтобы использовать эту формулу, нужно сперва отсортировать фрагменты по глубине. Представьте, сколько головной боли с этим связано! В общем случае, сортировку нужно делать в каждом кадре. Если вы сортируете объекты, то некоторые объекты сложной формы придётся разрезать на куски и сортировать по глубине отрезанные части (в частности, для пересекающихся поверхностей это совершенно точно нужно будет делать). Если вы сортируете фрагменты, то сортировка будет происходить в шейдерах. Такой подход называется «Order-independent transparency» (OIT), и в нём используется связный список, хранящийся в памяти видеокарты. Предсказать, сколько памяти придётся выделить под этот список — практически нереально. А если памяти не хватит, на экране появятся артефакты.

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

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

$ \begin{matrix}C = {{ \sum_{i=1}^{n}C_i \alpha_i}\over{ \sum_{i=1}^{n}\alpha_i}}(1- \prod_{i=1}^{n}(1-\alpha_i))+C_0 \prod_{i=1}^{n}(1-\alpha_i) &&(2)\end{matrix} $




На скриншоте — группы полупрозрачных треугольников, расположенных в четыре слоя по глубине. Слева они отрендерены с использованием техники WBOIT. Справа — картинка, полученная с использованием формулы (1), классический блендинг цветов с учётом порядка расположения фрагментов. Далее буду называть это CODB (Classic order-dependent blending).

Перед тем, как начать рендеринг прозрачных объектов, мы должны отрендерить все непрозрачные. После этого прозрачные объекты рендерятся с тестом глубины, но без записи в буфер глубины (это делается так: glEnable(GL_DEPTH_TEST); glDepthMask(GL_FALSE);). То есть, вот что происходит в точке с некими экранными координатами (x, y): прозрачные фрагменты, оказавшиеся ближе непрозрачного, проходят тест глубины, независимо от того, как они располагаются по глубине относительно уже нарисованных прозрачных фрагментов, а прозрачные фрагменты, оказавшиеся дальше непрозрачного, не проходят тест глубины, и, соответственно, отбрасываются.

C0 в формуле (2) — это цвет непрозрачного фрагмента, поверх которого рисуются прозрачные фрагменты, которых у нас n штук, обозначенных индексами с 1 по n. Ci — это цвет i-го прозрачного фрагмента, αi — его непрозрачность.

Если присмотреться, то формула (2) немножко похожа на формулу (1). Если представить, что — это Cnear, C0 — это Cfar, а — это α, то это и будет 1-я формула, один в один. И правда, — это взвешенное среднее цветов прозрачных фрагментов (по такой же формуле в механике определяется центр масс), оно сойдёт за цвет ближнего фрагмента Cnear. C0 — это цвет непрозрачного фрагмента, расположенного за всеми фрагментами, для которых мы посчитали это взвешенное среднее, и он сойдёт за Cfar. То есть мы заменили все прозрачные фрагменты одним “усреднённым” фрагментом и применили стандартную формулу смешивания цветов — формулу (1). Что же это за хитрая формула для α, которую нам предлагают авторы оригинальной статьи?

$\alpha=1- \prod_{i=1}^{n}(1-\alpha_i)$


Это скалярная функция в n-мерном пространстве, так что вспомним дифференциальный анализ функций нескольких переменных. Учитывая что все αi принадлежат диапазону от 0 до 1, частная производная по любой из переменных всегда будет неотрицательной константой. Значит, opacity «усреднённого» фрагмента возрастает при увеличении opacity любого из прозрачных фрагментов, а это именно то, что нам надо. К тому же, она возрастает линейно.

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

Если непрозрачность хотя бы одного фрагмента равна 1, то α равно 1. То есть непрозрачный фрагмент становится невидим, что в общем-то хорошо. Только вот прозрачные фрагменты, расположенные за фрагментом с opacity=1, всё еще просвечивают через него и влияют на результирующий цвет:



Тут сверху лежит оранжевый треугольник, под ним зелёный, а под зелёным — серый и циановый, и всё это на чёрном фоне. У синего непрозрачность = 1, у всех остальных — 0,5. Картинка справа — это то, что должно быть. Как видите, WBOIT смотрится отвратительно. Единственное место, где проступает нормальный оранжевый цвет — это кромка зелёного треугольника, обведённая непрозрачной белой линией. Как я только что сказал, непрозрачный фрагмент невидим, если opacity прозрачного фрагмента равна 1.

Ещё лучше это видно вот здесь:



Оранжевый треугольник имеет непрозрачность 1, зелёный с отключённой прозрачностью просто рисуется вместе с непрозрачными объектами. Выглядит это так, как будто ЗЕЛЁНЫЙ треугольник просвечивает ОРАНЖЕВЫМ цветом через оранжевый треугольник.

Чтобы картинка выглядела достойно, проще всего не назначать объектам высокую непрозрачность. В своём рабочем проекте я не позволяю задавать opacity больше 0,5. Это 3D CAD, в котором объекты рисуются схематично, и особого реализма не требуется, так что там допустимо такое ограничение.

С низкими значениями непрозрачности картинки слева и справа смотрятся почти одинаково:



А с высокими они заметно различаются:



Вот так выглядит прозрачный многогранник:




У многогранника оранжевые боковые и зелёные горизонтальные грани. К сожалению, с первого взгляда это не поймёшь, т.е. картинка не выглядит убедительно. Там, где спереди оказывается оранжевая стенка, нужно больше оранжевого, и там, где зелёная — больше зелёного. Будет гораздо лучше рисовать грани одним цветом:



WBOIT с учётом глубины


Чтобы хоть как-то возместить отсутствие сортировки по глубине, авторы статьи придумали несколько вариантов добавления глубины в формулу (2). Это делает реализацию сложнее, а результат — менее предсказуемым и зависящим от особенностей конкретной трёхмерной сцены. Я не стал углубляться в эту тему, так что кому интересно — предлагаю ознакомиться со статьёй.

Утверждается, что WBOIT иногда способен на то, чего не может классическая прозрачность с сортировкой. Например, вы рисуете дым как систему частиц, используя только две частицы — с тёмным и светлым дымом. Когда одна частица проходит через другую, классическое смешивание цветов с сортировкой даёт некрасивый результат — цвет дыма из светлого резко становится тёмным. В статье говорится, что WBOIT с учётом глубины позволяет добиться плавного перехода и выглядит правдоподобнее. То же можно сказать про моделирования меха и волос в виде тонких трубок.

Код


Теперь о том, как реализовать формулу (2) на OpenGL. Код примера лежит на Гитхабе (ссылка), и большая часть картинок в статье — оттуда. Можете собрать и поиграться с моими треугольничками. Используется фреймворк Qt.

Тем, кто только приступает к изучению рендеринга прозрачных объектов, рекомендую вот эти две статьи:

Learn OpenGL. Урок 4.3 — Смешивание цветов
Алгоритм Order-Independent Transparency c использованием связных списков на Direct3D 11 и OpenGL 4

Вторая, правда, не так важна для понимания этого материала, но первая обязательна к прочтению.

Чтобы вычислить формулу (2), нам понадобятся 2 дополнительных фреймбуфера, 3 multisample текстуры и рендербуфер, в который мы будем записывать глубину. В первую текстуру — colorTextureNT (NT означает non-transparent) — мы будем рендерить непрозрачные объекты. Она имеет тип GL_RGB10_A2. Вторая текстура (colorTexture) будет иметь тип GL_RGBA16F; в первые 3 компонента этой текстуры мы будем записывать вот этот кусок формулы (2): , в четвёртый — . Ещё одна текстура типа GL_R16 (alphaTexture) будет содержать .

Сначала надо создать эти объекты получить от OpenGL их идентификаторы:

    f->glGenFramebuffers (1, &framebufferNT    );
    f->glGenTextures     (1, &colorTextureNT   );
    f->glGenRenderbuffers(1, &depthRenderbuffer);

    f->glGenFramebuffers(1, &framebuffer );
    f->glGenTextures    (1, &colorTexture);
    f->glGenTextures    (1, &alphaTexture); 

Как я уже сказал, тут используется фреймворк Qt, и все вызовы OpenGL проходят через объект типа QOpenGLFunctions_4_5_Core, который у меня везде обозначается как f.

Теперь следует выделить память:

    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, colorTextureNT);
    f->glTexImage2DMultisample( GL_TEXTURE_2D_MULTISAMPLE, numOfSamples,
                                GL_RGB16F, w, h, GL_TRUE                 );
    f->glBindRenderbuffer(GL_RENDERBUFFER, depthRenderbuffer);
    f->glRenderbufferStorageMultisample( GL_RENDERBUFFER, numOfSamples,
                                         GL_DEPTH_COMPONENT, w, h        );

    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, colorTexture);
    f->glTexImage2DMultisample( GL_TEXTURE_2D_MULTISAMPLE, numOfSamples,
                                GL_RGBA16F, w, h, GL_TRUE                );
    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, alphaTexture);
    f->glTexImage2DMultisample( GL_TEXTURE_2D_MULTISAMPLE, numOfSamples,
                                GL_R16F, w, h, GL_TRUE                   );

И настроить фреймбуфферы:

    f->glBindFramebuffer(GL_FRAMEBUFFER, framebufferNT);
    f->glFramebufferTexture2D( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                               GL_TEXTURE_2D_MULTISAMPLE, colorTextureNT, 0
                             );
    f->glFramebufferRenderbuffer( GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                                  GL_RENDERBUFFER, depthRenderbuffer
                                );

    f->glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
    f->glFramebufferTexture2D( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                               GL_TEXTURE_2D_MULTISAMPLE, colorTexture, 0
                             );
    f->glFramebufferTexture2D( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1,
                               GL_TEXTURE_2D_MULTISAMPLE, alphaTexture, 0
                             );
    GLenum attachments[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
    f->glDrawBuffers(2, attachments);
    f->glFramebufferRenderbuffer( GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                                  GL_RENDERBUFFER, depthRenderbuffer
                                );

На втором проходе рендеринга вывод из фрагментного шейдера пойдёт сразу в две текстуры, и это надо явно указать с помощью glDrawBuffers.

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

    f->glBindFramebuffer(GL_FRAMEBUFFER, framebufferNT);
    // ... код рендеринга ...

Только что мы нарисовали все непрозрачные объекты на текстуре colorTextureNT, а глубины записали в рендербуффер. Перед тем, как использовать тот же самый рендербуффер на следующем этапе рисования, надо убедиться, что туда уже записаны все глубины непрозрачных объектов. Для этого используется GL_FRAMEBUFFER_BARRIER_BIT. После рендеринга прозрачных объектов мы вызовем функцию ApplyTextures(), которая запустит финальную стадию рендеринга, на которой фрагментный шейдер будет считывать данные из текстур colorTextureNT, colorTexture и alphaTexture, чтобы применить формулу (2). Текстуры к тому моменту должны быть полностью записаны, поэтому перед вызовом ApplyTextures() мы применяем GL_TEXTURE_FETCH_BARRIER_BIT.

    static constexpr GLfloat clearColor[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
    static constexpr GLfloat clearAlpha = 1.0f;

    f->glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

    f->glClearBufferfv(GL_COLOR, 0,  clearColor);
    f->glClearBufferfv(GL_COLOR, 1, &clearAlpha);

    f->glMemoryBarrier(GL_FRAMEBUFFER_BARRIER_BIT);

    PrepareToTransparentRendering();
    {
        // ... код рендеринга ...
    }
    CleanupAfterTransparentRendering();

    f->glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT);

    f->glBindFramebuffer(GL_FRAMEBUFFER, defaultFBO);
    ApplyTextures();

defaultFBO — это фреймбуфер, через который мы выводим изображение на экран. В большинстве случаев это 0, но в Qt это QOpenGLWidget::defaultFramebufferObject().

При каждом вызове фрагментного шейдера у нас будет информация о цвете и непрозрачности текущего фрагмента. Но на выходе в текстуре colorTexture мы хотим получить сумму (а в текстуре alphaTexture — произведение) неких функций от этих величин. Для этого используется блендинг. Причём, коль скоро для первой текстуры мы вычисляем сумму, а для второй — произведение, настройки блендинга (glBlendFunc и glBlendEquation) для каждого аттачмента надо задавать отдельно.

Вот содержимое функции PrepareToTransparentRendering():

    f->glEnable(GL_DEPTH_TEST); f->glDepthMask(GL_FALSE);
    f->glDepthFunc(GL_LEQUAL);
    f->glDisable(GL_CULL_FACE); 
    f->glEnable(GL_MULTISAMPLE);

    f->glEnable(GL_BLEND);

    f->glBlendFunci(0, GL_ONE, GL_ONE);
    f->glBlendEquationi(0, GL_FUNC_ADD);

    f->glBlendFunci(1, GL_DST_COLOR, GL_ZERO);
    f->glBlendEquationi(1, GL_FUNC_ADD);

И содержимое функции CleanupAfterTransparentRendering():

    f->glDepthMask(GL_TRUE);
    f->glDisable(GL_BLEND);

В моём фрагментном шейдере непрозрачность обозначается буквой w. Произведение цвета на w и саму w мы выводим в один выходной параметр, а 1 – w — в другой. Для каждого выходного параметра задаётся layout qualifier в виде «location = X», где X — индекс элемента в массиве аттачментов, который мы в 3-м листинге передали функции glDrawBuffers (конкретно, выходной параметр с location = 0 отправляется в текстуру, привязанную к GL_COLOR_ATTACHMENT0, а параметр с location = 1 — в текстуру, привязанную к GL_COLOR_ATTACHMENT1). Те же самые числа используются в функциях glBlendFunci и glBlendEquationi, чтобы указать номер аттачмента, для которого мы устанавливаем параметры блендинга.

Фрагментный шейдер:

#version 450 core

in vec3 color;

layout (location = 0) out vec4 outData;
layout (location = 1) out float alpha;

layout (location = 2) uniform float w;
void main()
{
    outData = vec4(w * color, w);
    alpha = 1 - w;
}

В функции ApplyTextures() мы просто рисуем прямоугольник на всё окно. Фрагментный шейдер запрашивает данные всех сформированных нами текстур, используя текущие экранные координаты в качестве текстурных координат и текущий номер сэмпла (gl_SampleID) в качестве номера сэмпла в multisample текстуре. Использование переменной gl_SampleID в шейдере автоматически включает режим, когда фрагментный шейдер вызывается один раз для каждого сэмпла (в нормальных условиях он вызывается один раз для всего пикселя, а результат записывается во все сэмплы, что оказались внутри примитива).

В вершинном шейдере нет ничего примечательного:

#version 450 core
const vec2 p[4] = vec2[4](
     vec2(-1, -1), vec2( 1, -1), vec2( 1,  1), vec2(-1,  1)
                         );
void main() { gl_Position = vec4(p[gl_VertexID], 0, 1); }

Фрагментный шейдер:

#version 450 core
out vec4 outColor;

layout (location = 0) uniform  sampler2DMS colorTextureNT;
layout (location = 1) uniform  sampler2DMS colorTexture;
layout (location = 2) uniform  sampler2DMS alphaTexture;

void main() {
    ivec2 upos = ivec2(gl_FragCoord.xy);
    vec4 cc = texelFetch(colorTexture, upos, gl_SampleID);
    vec3 sumOfColors = cc.rgb;
    float sumOfWeights = cc.a;

    vec3  colorNT = texelFetch(colorTextureNT, upos, gl_SampleID).rgb;

    if (sumOfWeights == 0)
    { outColor = vec4(colorNT, 1.0); return; }

    float alpha = 1 - texelFetch(alphaTexture, upos, gl_SampleID).r;
    colorNT = sumOfColors / sumOfWeights * alpha +
              colorNT * (1 - alpha);
    outColor = vec4(colorNT, 1.0);
}

И наконец — содержимое функции ApplyTextures():

    f->glActiveTexture(GL_TEXTURE0);
    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, colorTextureNT);
    f->glUniform1i(0, 0);
    f->glActiveTexture(GL_TEXTURE1);
    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, colorTexture);
    f->glUniform1i(1, 1);
    f->glActiveTexture(GL_TEXTURE2);
    f->glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, alphaTexture);
    f->glUniform1i(2, 2);

    f->glEnable(GL_MULTISAMPLE); f->glDisable(GL_DEPTH_TEST);
    f->glDrawArrays(GL_TRIANGLE_FAN, 0, 4);

Ну и хорошо бы освободить OpenGL ресурсы после того как всё закончилось. У меня этот код вызывается в деструкторе моего OpenGL-виджета:

    f->glDeleteFramebuffers (1, &framebufferNT);
    f->glDeleteTextures     (1, &colorTextureNT);
    f->glDeleteRenderbuffers(1, &depthRenderbuffer);

    f->glDeleteFramebuffers (1, &framebuffer);
    f->glDeleteTextures     (1, &colorTexture);
    f->glDeleteTextures     (1, &alphaTexture);

+13
1.7k 45
Comments 8
Top of the day