Прошлая статья закончилась на странном месте. Weighted blended OIT копил слагаемые не в кадре, а в двух отдельных буферах, и собирал картинку отдельным полноэкранным проходом: кадр перестал быть местом, куда рисуют, и стал текстурой, которую считают. В таком состоянии кадр проводит заметную часть своей жизни — после того как нарисован последний объект, и до того, как его увидит игрок.
Казалось бы, после последнего объекта картинка готова: растеризация отработала, z-буфер закрыт, блендинг сложил стёкла. Не совсем. Яркость солнца там в разы больше единицы, края треугольников идут ступеньками, а монитор ни то ни другое показать не умеет. Между «нарисовано» и «показано» стоит стек проходов, каждый из которых читает кадр как текстуру и пишет новый.
Симптомы того, что стек собран плохо, вы видели. Лесенки по краям крыш и проводов. Блик, который искрит на мокром асфальте, хотя сглаживание включено. Картинка плоская и тусклая, а свет в сцене настраивается на глаз, «чтобы не выбелилось».
Разберём:
- почему после последнего объекта кадр — это текстура, и что из этого следует для цены: полноэкранный проход стоит от числа пикселей, а не от сложности сцены;
- два сорта алиасинга — край силуэта и рябь внутри поверхности — и почему MSAA лечит только один из них; FXAA, TAA и откуда берутся призраки;
- зажим в единице: почему солнце, лампа и белая стена записываются в буфер одним числом, и что меняет HDR-буфер;
- bloom как пирамида уменьшений: порог, уровни и одиночные сверхъяркие пиксели;
- tone mapping: Reinhard, Hable и ACES — как сжать свет в экран и не потерять солнце;
- порядок проходов: почему одни и те же операции в другой очереди дают другой кадр.
Такие разборы — с рабочим кодом — выходят в канале каждую неделю.
Кадр как текстура
Всё, что мы разбирали до этого, писало в один буфер — тот, который показывается на экране. Но писать можно в любую текстуру. Достаточно привязать её как цель рендера (render target, во фреймбуфер в терминах OpenGL), нарисовать сцену как обычно — и вместо экрана она окажется в текстуре. Экран при этом останется пустым. Схема разобрана в LearnOpenGL, в главе про фреймбуферы: сцена рисуется в текстуру, затем на экран выводится один прямоугольник во весь кадр с этой текстурой, и с этого момента над всей сценой можно делать любые пост-эффекты, обрабатывая её как картинку.
Полноэкранный проход
Раз кадр стал текстурой, с ним можно делать всё, что делают с текстурами: сэмплировать, фильтровать, читать соседей. Рисуем прямоугольник во весь экран, и фрагментный шейдер отрабатывает по разу на каждый пиксель цели. Внутри он читает из текстуры кадра то, что ему нужно — этот пиксель, соседей, глубину, если её сохранили отдельно, — и пишет результат. Это и есть пост-эффект: полноэкранный проход. Ни одного треугольника сцены он не видит. Что было под пикселем — стена, куст или небо, — шейдеру неизвестно, у него есть только цвета.
Полноэкранный треугольник без буфера вершинОсторожно! Код!
Прямоугольник обычно рисуют не двумя треугольниками, а одним, накрывающим экран с запасом: у двух треугольников есть диагональ, и вдоль неё квады 2×2 достаются обеим половинкам. Вершины при этом не хранят вовсе — три позиции считаются из номера вершины. Приём в таком виде лежит в сэмпле FXAA от NVIDIA:
float4 FullscreenVS(uint id : SV_VertexID) : SV_POSITION {
float2 uv = float2((id << 1) & 2, id & 2); // (0,0) (2,0) (0,2)
return float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
}Ping-pong
Проходов в стеке много: bloom, tone mapping, сглаживание, размытие, грейдинг. Читать текстуру и писать в неё же в одном проходе нельзя: результат такого чтения не определён, соседний пиксель может быть уже переписан, а может и нет. Поэтому целей две. Первый проход читает из A и пишет в B, второй читает B и пишет в A, и так далее — ping-pong. Плюс промежуточные цели меньшего размера там, где полное разрешение не нужно.
И тут резонный вопрос: почему бы не свернуть всё в один проход? Один шейдер, одно чтение, одна запись. Ответ — соседи. Размытие радиусом в сотню пикселей требует прочитать сотню соседей на каждый пиксель кадра, а через цепочку уменьшенных копий тот же радиус стоит несколько чтений на каждом уровне. Каждый уровень — отдельная цель и отдельный проход. Часть проходов и вовсе пишут не картинку, а данные для следующих: среднюю яркость кадра для экспозиции, векторы движения для сглаживания.
Сколько это стоит
Выкиньте с уровня всё и оставьте пустую комнату. Размытие в движении, глубина резкости и tone mapping будут стоить столько же, сколько стоили в лесу. Проверяется в профайлере на пустой сцене: полноэкранный проход отрабатывает по разу на пиксель, и работы у него ровно столько, сколько пикселей.
Прикинем цифры. Хорхе Хименес в докладе про постобработку Call of Duty: Advanced Warfare (SIGGRAPH 2014) приводит замеры на Xbox One: боке — 1.27 мс. Бюджет кадра при 60 fps — 16.7 мс. Итого под восемь процентов кадра за один эффект.
Цена стека: пиксели, а не сцена
Тумблеры включают сами проходы — bloom, глубину резкости, размытие в движении, tone mapping, сглаживание — и каждый прибавляет свою цену к счётчику пиксель-проходов. Слайдер домов меняет счётчики сцены — фрагменты и draw call — и не двигает пиксель-проходы. Render scale двигает всё, но по площади. Единицы намеренно условные: не миллисекунды, а вызовы шейдера — миллисекунды зависят от железа и от того, что именно делает проход.
Сглаживание: сначала посмотрите, что дрожит
Прежде чем крутить галку сглаживания, стоит остановить кадр и посмотреть, что именно в нём дрожит. Есть два случая. Первый — край силуэта: ступеньки по контуру крыши, провода, перил. Второй — рябь внутри поверхности: блик скачет по мокрому асфальту, металл искрит, листва мерцает. Оба называются алиасингом, оба происходят от недостатка сэмплов. Но недосэмплированы разные вещи.
Край: покрытие пикселя
Пиксель — не точка, а площадка. Растеризатор решает, внутри ли пиксель треугольника, по одной точке — центру: да или нет. Край треугольника проходит через пиксель под углом, а решение бинарное — вот и ступенька. Стоит камере сдвинуться на долю пикселя, ступенька перескакивает на соседа: лесенка «ползёт». Правильный ответ для такого пикселя — доля его площади, накрытая треугольником, то самое покрытие, про которое мы говорили в статье про альфу.
MSAA считает именно его. Вместо одной точки на пиксель — несколько (четыре, восемь), и тест покрытия и глубины делается по каждой. А вот фрагментный шейдер вызывается по-прежнему один раз на пиксель; его цвет раздаётся всем накрытым сэмплам, и при сборке кадра усредняется. Так это описано в LearnOpenGL, в главе про сглаживание: мультисэмплинг ставит несколько точек для определения покрытия треугольника, глубина хранится по сэмплам, а шейдер выполняется однажды. На краю пиксель получает полутон, пропорциональный покрытию.
Рябь внутри: недосэмплированное затенение
Шейдер вычисляет функцию в одной точке на пиксель. Если функция меняется быстрее, чем идут пиксели, — блик от почти зеркального материала с крошечным Roughness, мелкие детали карты нормалей, тонкие линии текстуры, — одна точка её не описывает, и соседние пиксели попадают то на гребень, то в провал. В статике это рябь, в движении — искры и мерцание.
Лобовое решение — считать затенение в каждом сэмпле, а не один раз на пиксель: SSAA. Оно лечит и край, и рябь, и стоит в четыре раза дороже по затенению при четырёх сэмплах. Никто так не делает, но эталон для проверки остальных методов — именно он.
Так что рябь лечат не сглаживанием. Текстуры — мипмапами: это уже сделанное заранее усреднение. Блики — ограничением Roughness снизу: документация Filament рекомендует зажимать его в безопасный диапазон прямо в шейдере и отмечает, что это заодно чинит алиасинг бликов; Frostbite так подпирает Roughness у аналитических источников. Одиночные сверхъяркие пиксели HDR-кадра — «светлячки» — отдельная история, к ней вернёмся в части про bloom.
И третий случай, который путают с первым: кромка листвы через alpha test. Полутонов у неё нет по построению, MSAA их не придумает, пока не включён alpha-to-coverage.
Два алиасинга: край и рябь внутри
Ошибка края и ошибка внутри считаются отдельно, относительно эталона на 256 сэмплов. MSAA почти обнуляет первую и не двигает вторую: число затенений на пиксель у него то же, что без сглаживания. Сдвиг фигуры на доли пикселя — то, что происходит при движении камеры: в режиме одного сэмпла лесенка ползёт, а полосы внутри перестраиваются.
FXAA: сгладить готовую картинку
MSAA живёт в растеризации, и у него есть цена помимо затенений: цвет и глубина хранятся по сэмплам, буферы в четыре раза толще, а в отложенном освещении в четыре раза толще становится весь G-буфер. Тимоти Лоттес в описании FXAA (NVIDIA, 2009) прямо называет это преимуществом своего метода перед MSAA для deferred-рендера.
FXAA — фильтр по готовой картинке. Один проход по односэмпловому кадру: по яркости соседей находятся контрастные пиксели, вдоль края ищется его протяжённость, и пиксель подмешивается к соседям поперёк края. Ни геометрии, ни глубины — только цвета. По замеру из того же описания, кадр 1920×1200 на GTX 480 обходился меньше миллисекунды.
Но есть нюанс, и он в самом принципе. Фильтр не знает, что перед ним: край крыши, буква интерфейса или тонкая линия текстуры. Всё контрастное сгладится одинаково, и мелкие детали поплывут. Информации о покрытии у FXAA нет, он её не восстанавливает — он размывает так, чтобы лесенка перестала читаться. Развитие идеи — SMAA (Хименес и соавторы, 2012): формы краёв распознаются по образцам, и картинка остаётся резче.
И важная деталь про место в стеке, о ней — в пятой части: FXAA обязан стоять после tone mapping и sRGB-кодирования. В HDR он бесполезен: Лоттес приводит пример — пиксели с яркостью 0 и 16 усреднятся в 8, а после сжатия и 8, и 16 превратятся в одну и ту же единицу — сглаживания не случится.
TAA: сэмплы, разложенные по времени
Идея в один ход. Четыре сэмпла на пиксель за кадр — дорого. А один сэмпл на пиксель за кадр, но каждый кадр в другой точке пикселя? Сетку сэмплов сдвигают на долю пикселя (jitter), и за несколько кадров пиксель успевает побывать во всех положениях, которые SSAA брал бы за один. Осталось накопить: результат кадра смешивается с историей — тем, что накопили раньше — с весом . Вес 0.1 значит, что в истории живут примерно десять последних кадров. Для неподвижной сцены это SSAA почти бесплатно.
История как экспоненциальное среднееОсторожно! Математика!
Кадр за кадром история обновляется одной формулой:
Раскрутим рекурсию назад: каждый прошлый кадр входит в неё со своим весом,
Веса геометрической прогрессии в сумме дают единицу — история остаётся средним, а не усилением. Средний «возраст» кадра в истории:
При — девять кадров назад; условно говоря, история «весит» кадров. Меньше — глаже, но дольше отклик на любое изменение.
Сцены, впрочем, движутся. Пиксель, в котором сейчас край персонажа, кадр назад был фоном, и его история — про фон. Поэтому историю читают не на том же месте экрана, а там, где этот пиксель был в прошлом кадре: по вектору движения, который для каждого пикселя пишет сам рендер. Это Reprojection — перенос значения из прошлого кадра в текущий по известному движению пикселя: читаем историю со смещением на вектор движения..
Репроекция закрывает движущийся объект, но не то, что он открыл. Фон за уехавшим диском в прошлом кадре был диском; в истории там лежит его цвет, и он тянется шлейфом — призраком (ghosting). Лечение грубое и рабочее: зажать историю в диапазон соседей текущего кадра. Если в окрестности 3×3 нет ничего похожего на светлый диск, значения истории светлее максимума соседей быть не может. Такой стек — сдвиг сэмплов, репроекция, зажим истории по соседям — Брайан Карис разобрал в докладе про временное сглаживание Unreal Engine 4 (SIGGRAPH 2014), и в общих чертах так TAA устроен до сих пор.
Накопление по кадрам: TAA и призраки
Диск ходит по полосатому фону. Без репроекции история читается на месте, и за диском остаётся след. С репроекцией след остаётся только там, где диск открыл фон, — устаревшая история. Зажим по соседям срезает и его, а кромки диска и полос остаются сглаженными.
Зажим в единице
Знакомая последовательность действий. Солнце поярче — небо белеет целиком, вместе с облаками. Гасим — в тенях чернота. Находим компромисс, сцену принимают. Через месяц появляется ночная сцена с фонарями, и всё начинается по новой.
Механизм в одну фразу: значения в обычном буфере кадра при записи зажимаются в [0, 1]. Всё, что ярче единицы, единицей и записывается: солнце, лампа и белая стена превращаются в одно число, и разницы между ними в кадре уже нет. Авторы LearnOpenGL в главе про HDR замечают, что это на вид невинное ограничение заставляло задавать свет и цвета в диапазоне до единицы, подгоняя их под сцену, хотя в уравнениях освещения никакого потолка нет — он есть только у монитора.
Пока буфер зажат, свет крутят не как свет, а как способ его обрезать: настраивают не освещение, а то, как оно обрежется. Работает это ровно до сцены с другим набором яркостей.
Кадр с запасом
Лечится порядком. Кадр считают и хранят в числах с запасом: буфер в формате с плавающей точкой — по 16 бит на канал (RGBA16F) или упакованный R11G11B10F, где три канала делят 32 бита и альфы нет. Значение ярче единицы в таком буфере — нормальное значение. А сжатие в то, что покажет монитор, делают один раз, последним шагом — это tone mapping, и о нём следующая часть.
Цена — память и шина: восемь байт на пиксель против четырёх у обычного RGBA8, то есть вдвое больше на каждое чтение и запись каждого прохода. И одно уточнение, которое замыкает второй сезон: HDR-буфер линейный. Свет в нём складывается как свет, sRGB-кодирование появляется только на выходе, а с ним и дизеринг — квантование в 8 бит откладывается до последнего момента.
Bloom: пирамида уменьшений
Чтож, кадр в HDR. Солнце в нём в десятки раз ярче стены, но монитор всё равно покажет белый — и не более белый, чем у стены. Как показать, что солнце ярче? Документация Unreal объясняет bloom ровно этим: дисплеи не показывают широкий диапазон, очень яркие объекты отрисовать нельзя, поэтому имитируется то, что происходит с ярким светом в глазу и объективе — свечение вокруг него.
Как он собран
Типовой bloom — четыре шага. Хименес в том же докладе перечисляет их как есть, я только разверну.
- Из кадра оставляют одно яркое. Всё, что тусклее порога, обнуляется, и на выходе чёрная картинка, на которой остались лампы, солнце и блики.
- Эту картинку уменьшают вдвое. Потом ещё вдвое. И ещё несколько раз, пока не получится стопка копий: половина кадра, четверть, восьмая доля.
- Каждую копию растягивают обратно до размера кадра. Мелкая копия при растяжке превращается в размытое пятно, и чем меньше была копия, тем шире пятно: восьмая доля кадра, растянутая в восемь раз, — это и есть широкое мягкое свечение вокруг лампы.
- Все растянутые копии складывают с исходным кадром. Сами лампы остаются резкими, а вокруг них ложится сумма размытых копий.
Почему пирамида, а не одно большое размытие? Из-за цены. Размытие радиусом в сотню пикселей — сотни чтений на пиксель. А в пирамиде каждый уровень — четверть пикселей предыдущего, и пикселей во всех уровнях вместе меньше трети кадра; радиус же растёт вдвое с каждым уровнем сам собой. Уменьшение и увеличение с билинейной фильтрацией дают размытие даром — фильтрация и есть усреднение. Широкое свечение по цене меньше одного полноэкранного прохода.
Пиксели пирамиды и вес против светлячковОсторожно! Математика!
Уровни пирамиды — четверть, шестнадцатая, шестьдесят четвёртая доля кадра. Сумма на уровнях:
Спуск и подъём — по два прохода на уровень, так что вся пирамида укладывается в две трети кадра по пикселям, при любом числе уровней.
Усреднение четырёх сэмплов с весами Кариса:
где — яркость сэмпла. Пиксель в двести раз ярче соседей получает вес в двести раз меньше: в среднее он входит примерно как ещё один обычный сосед, а не как двести. Карис в заметке 2013 года показывает, что это равносильно усреднению после сжатия кривой и обратному преобразованию — сжали, усреднили, разжали.
Порог
Порог у bloom — не настройка силы, а решение, что вообще попадёт в размытие: только пятна ярче порога или весь кадр. Пороговый bloom читается как свечение, наклеенное поверх резкого кадра: вокруг ламп ореол, рядом нетронутая картинка, два склеенных слоя. Непорогованный ведёт в мягкость весь кадр.
Advanced Warfare пороговый вход убрал — Хименес выделяет это отдельным пунктом. Его довод, пересказанный в гостевой статье LearnOpenGL про физически обоснованный bloom: при PBR динамический диапазон и так широк, порог не нужен; слой свечения ставится слабым, поэтому заметно текут только по-настоящему яркие места, а вся картинка получает лёгкую мягкость — что хорошо или плохо в зависимости от художественной задачи, но в целом ближе к фотографии. Если порог всё же нужен, его делают мягким: резкий порог заставляет объекты с яркостью около порога включать и выключать свечение при малейшем изменении света.
Мягкий порогОсторожно! Код!
Квадратичное «колено» вокруг порога: ниже порога минус колено — ноль, выше — всё превышение, между ними плавная сшивка.
vec3 prefilter(vec3 c, float threshold, float knee) {
float brightness = max(c.r, max(c.g, c.b));
float soft = clamp(brightness - threshold + knee, 0.0, 2.0 * knee);
soft = soft * soft / (4.0 * knee + 1e-5);
float contribution = max(soft, brightness - threshold);
return c * (contribution / max(brightness, 1e-5));
}Светлячки
Обратная сторона отказа от порога — светлячки. Хименес пишет, что HDR-рендер с физически обоснованным освещением часто даёт мерцающие яркие пиксели из-за недосэмплирования картинки — та самая рябь бликов из второй части, только теперь каждый такой пиксель ярче соседей в сотни раз. Bloom исправно размазывает его в пятно, и пятно мерцает вместе с пикселем. Лечение — до усреднения: Карис предложил сжать динамический диапазон перед тем, как усреднять, и в Advanced Warfare так делают на первом уменьшении пирамиды. В простейшем виде это веса у сэмплов: сверхъяркий пиксель теряет право тянуть среднее за собой.
Bloom: порог, пирамида и светлячки
Переключите буфер на 8 бит: максимум яркости на входе становится единицей, и порогу нечего различать — солнце и стена текут одинаково или не текут вовсе. Порог в нуле — мягкость по всему кадру. Светлячок без веса Кариса даёт пятно на весь угол, с весом — исчезает, а остальное свечение не меняется.
Tone mapping: сжать свет в экран
Кадр лежит в float: солнце около двадцати, лампы около пяти, освещённая стена — доли единицы, тени — сотые. Монитор показывает от нуля до единицы. Нужна функция, которая всё это в единицу уложит. Самая плохая из них — обрезка: всё выше единицы становится белым, солнце, лампы и зарево вокруг солнца — одно пятно без формы. Остальные функции придуманы для того, чтобы этого не делать.
Reinhard: фотографическая кривая
Эрик Райнхард с соавторами в статье «Photographic Tone Reproduction for Digital Images» (SIGGRAPH 2002) взяли готовое решение у фотографов — зонную систему Анселя Адамса. Сначала — экспозиция: считается логарифмическое среднее яркости кадра, и оно приводится к «среднему серому» — по умолчанию 0.18 на шкале от нуля до единицы; авторы называют этот множитель ключом сцены. Это тот самый множитель экспозиции, который во всех движках стоит перед кривой, а его автоматический подбор по среднему кадра — та самая автоэкспозиция.
Затем — сжатие, и оно записывается в одну строку: . Авторы поясняют: большие яркости масштабируются примерно в раз, малые — примерно в единицу, а знаменатель плавно сшивает оба режима, и любая яркость гарантированно попадает в отображаемый диапазон.
Но есть нюанс: белого в этой кривой нет. Единица уходит в половину, десять — в 0.91, сто — в 0.99, и никакая яркость не дотягивает до чистого белого. Солнце получается серым, картинка — плоской. Поэтому в той же статье есть вариант с точкой белого — наименьшей яркостью, которая отобразится в чистый белый; по умолчанию авторы ставят туда максимальную яркость сцены. Ниже точки белого кривая почти линейна, к ней — сжимается.
Hable: плёночная S-кривая
Джон Хейбл на Uncharted 2 пошёл от плёнки. Кривая плёнки — S-образная: у неё есть носок, который прижимает тени к чёрному и даёт контраст, и плечо, которое плавно выводит яркое в белый вместо обрезки. В посте «Filmic Tonemapping Operators» (2010) он приводит свою кривую как рациональную функцию с шестью константами, множителем экспозиции 2 на входе и точкой белого : результат делится на значение кривой в , чтобы отобразилась ровно в единицу. В наших единицах, с учётом удвоения на входе, белым становится входное значение 5.6, а единица уходит примерно в 0.49.
ACES и его аппроксимация
ACES — Academy Color Encoding System, стандарт кинопроизводства; для вывода на экран в нём стоит пара преобразований, RRT и ODT, и их точная реализация громоздка. Кшиштоф Наркович в 2016-м подобрал к ним компактную аппроксимацию — рациональную функцию с пятью константами (2.51, 0.03, 2.43, 0.59, 0.14), с максимальной ошибкой подгонки 0.0138 и упором на точность в тенях. Вход у неё заранее проэкспонирован: единица отображается примерно в 0.8; чтобы получить исходную кривую ACES, вход надо умножить на 0.6. Чистого белого аппроксимация достигает при входе около 7.24 — это уже наш расчёт, корень квадратного уравнения из спойлера ниже. В настройках цвета Unity и Unreal режим с таким названием — это она или её родственники.
Три кривые формуламиОсторожно! Математика!
Райнхард, уравнения (1)–(4) статьи. Экспозиция по логарифмическому среднему и ключу :
Сжатие — простое и с точкой белого:
Кривая Хейбла с константами :
Аппроксимация ACES по Нарковичу:
Точка белого — где : , положительный корень .
По каналам или по яркости
Райнхард определял оператор на яркости: сжимается яркость, а три канала масштабируются одним числом, и оттенок сохраняется. В играх кривую чаще применяют к каждому каналу отдельно — код Хейбла берёт вектор из трёх компонент и пропускает его через кривую целиком. Разница видна на ярких насыщенных цветах. По каналам яркий оранжевый выцветает к белому: красный канал упёрся в плечо раньше остальных, и цвет теряет насыщенность — так ведёт себя плёнка, и на это похожа ACES. По яркости оттенок остаётся, но у насыщенного цвета один из каналов всё равно вылезает за единицу, и его обрезает уже экран — оттенок сдвигается, а не сохраняется.
Кривые tone mapping
Справа — кривая: вход по горизонтали, выход по вертикали, пунктиром — единица на входе и точка белого. Слева — кадр через неё. Обрезка и Reinhard — два края одной ошибки: у первой белого слишком много, у второго его нет вовсе. Экспозиция сдвигает весь кадр по кривой; точка белого работает только у Reinhard с белым — поставьте её ниже яркости солнца и посмотрите, как оно снова становится белым.
Порядок проходов
Чтож, у нас есть все детали: HDR-кадр, сглаживание, bloom, tone mapping, sRGB-кодирование. Осталось выстроить их в очередь, и очередь важна. Одни и те же операции в другом порядке дают другой кадр.
Правила простые, и каждое мы уже вывели.
Всё, что про свет, — до tone mapping. Bloom, глубина резкости, размытие в движении складывают и усредняют свет, а свет складывается только в линейных величинах с запасом по яркости. Bloom по уже сжатому кадру не отличит солнце от белой стены. Размытие в движении по сжатому кадру даст тусклые следы вместо ярких.
Tone mapping — один раз. Кривая нелинейна: сжать дважды — получить другую кривую.
sRGB-кодирование — после сжатия. Кодирование — не математика цвета, это подготовка чисел для монитора; Гритц и д'Эон в GPU Gems 3 называют гамма-коррекцию применением обратного преобразования монитора к пикселям перед показом. Скормить закодированные значения кривой tone mapping — значит подать ей байты вместо света: тени вытянутся, кадр выбелится. Именно это и делал хак из третьей части — вывод линейного HDR прямо в 8 бит sRGB, только с другой стороны.
FXAA — после кодирования. Лоттес прямо пишет, что фильтр рассчитан на конец стека, после сжатия в LDR и перевода в sRGB, и на HDR-кадре не сглаживает контрастные края. TAA чаще живёт до tone mapping, в HDR, но с оговорками.
Интерфейс — после всего. Ему не нужны ни bloom, ни tone mapping, ни размытие; он рисуется поверх готового кадра.
Порядок проходов
Слева — выбранный порядок, справа — эталонный. Bloom после tone mapping выравнивает свечение у солнца и стены. 8-битный буфер перед стеком обрезает всё яркое ещё до bloom — светиться нечему. Кодирование до tone mapping выбеливает кадр целиком: кривая получила байты.
Выводы
Если оставить от статьи одну мысль, пусть будет такая: кадр остаётся данными до тех пор, пока его не закодировали для экрана. Каждый пост-эффект — функция над этими данными: его цена задаётся числом пикселей, а его правильность — местом в очереди. Стек ничего не знает о сцене, и в этом одновременно его сила — цена не зависит от содержимого — и его слабость: FXAA не отличит край крыши от буквы, а TAA не знает, что открылось за уехавшим объектом, пока ему не подскажут.
Отсюда разбор проблем:
- кадр просел одинаково в пустой комнате и в лесу — цена стека живёт в пикселях; ручка — render scale, и работает она по площади;
- лесенка ползёт по краю при движении камеры — покрытие решается по центру пикселя; MSAA или TAA;
- блик искрит на мокром асфальте, MSAA не помог — недосэмплированное затенение: Roughness снизу, мипы, светлячки — в шейдере, не в сглаживании;
- кромка листвы зубчатая при включённом MSAA — alpha test без покрытия; alpha-to-coverage;
- за движущимся объектом тянется след — TAA без репроекции или без зажима истории по соседям;
- картинка мягкая, мелкие детали поплыли — TAA и FXAA размывают то, о чём не знают;
- солнце, лампа и стена одного цвета, свет настраивается «до белого» — буфер зажат в единице: нет HDR-буфера и tone mapping в конце;
- bloom выглядит наклеенным слоем — порог; без порога — мягкость по всему кадру, и это уже выбор;
- вокруг одного пикселя мерцающее пятно — светлячок; вес Кариса до усреднения;
- после перехода на HDR картинка плоская и серая — Reinhard без точки белого: кривая без плеча;
- яркие цвета уходят в белый — оператор по каналам; так ведёт себя плёнка, и обычно этого и хотят;
- свечение одинаковое у солнца и у стены — bloom после tone mapping или по 8-битному буферу;
- тени вытянуты, кадр выбелен — sRGB-кодирование раньше tone mapping.
Чего мы не разобрали
Глубину резкости и размытие в движении. У каждого своя физика — круг нерезкости объектива, векторы скорости пикселей — и свои артефакты на контурах объектов. В стеке они стоят там же, где bloom: до tone mapping, в линейном свете.
Грейдинг. Кривые и таблицы, которыми колорист доводит картинку после сжатия, — отдельное ремесло со своими инструментами.
Автоэкспозицию. Логарифмическое среднее Райнхарда, размазанное по времени, — так движки имитируют привыкание глаза к темноте и свету, и у этой имитации свои настройки и свои ловушки.
HDR-вывод. Мониторы с широким диапазоном — другой диапазон яркости, другое кодирование сигнала, другой ODT; часть того, что мы сжимали, туда можно отдать как есть.
Апскейлеры. DLSS, FSR, TSR по устройству — родственники TAA: тот же сдвиг сэмплов, та же история с репроекцией, только кадр рисуется в меньшем разрешении, а восстанавливается в большее.
И общая закономерность, сквозная для всей дуги. Стек пост-эффектов не рисует ничего нового — он переупаковывает то, что уже посчитано. Его цена в пикселях, а не в треугольниках, и его артефакты — след того, что он не знает о сцене. Разница между «включил галку, стало красивее» и «знаю, что чиню» — в том, чтобы понимать, какой из проходов о чём не знает.
Дальше по конвейеру — снова экранное пространство, но читать из кадра мы будем не цвета. Буфер глубины и буфер нормалей тоже текстуры, и у них можно спросить о свете, которого в кадре нет: об отскоках от стен, о тени в углу, куда ни одна лампа не смотрит напрямую, об отражениях. Следующий сезон — «Свет, которого не видно»: ambient константой, затенение окружением, отражения и глобальное освещение.
Надеюсь, статья была полезна. Заходите в телеграм-канал, такие разборы выходят там каждую неделю, и буду рад дополнениям в комментариях: у стека пост-эффектов нюансов заметно больше, чем влезло в пять частей.
Разборы графики с кодом — каждую неделю в канале.
