Deltadev-math.ru
поддержать игру
// ПРОЗРАЧНОСТЬ

Стекло против z-буфера: alpha, порядок и блендинг

Почему прозрачность в реальном времени решается не так, как всё остальное: альфа как покрытие и оператор over, порядок и сортировка сзади-вперёд, тёмные каймы и premultiplied alpha, блендинг в линейном пространстве, alpha test, alpha-to-coverage и weighted blended OIT.

28 августа 2026·40 мин чтения·alpha и overпорядокpremultipliedOIT
Дейв смотрит сквозь стопку цветных стёкол, Дельта переставляет их местами

Прошлая статья закончилась обещанием неприятностей: пока всё в сцене непрозрачно, у вопроса «загорожено или нет» есть ответ «да» или «нет». Стоит поставить на пути света стекло и ответ уже не так прост. Разберём чего это стоит.

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

Стекло так не работает. Пиксель со стеклом — это не «стекло вместо стены», это «стекло и стена в одном пикселе, в известной пропорции». Это кстати любопытно с точки зрения вопроса, который мог у вас появится в статье про PBR про металлическую краску на автомобилях. Раз у нас металлик бинарный, а 1 отвечает за полное отражение, то как работает краска на машине? Она матово цветная, но при этом глянцевая - парадокс. Но нет. На машине блестит прозрачный лак, а под ним вполне себе матовая краска. Поэтому грамотная автомобильная краска делается в два слоя, один из которых прозрачный. Но вернёмся к сути.

То как цвета складываются определяет блендинг (blending): цвета умножаются на доли и складываются. И проблема в том, что в этой операции важен порядок сложения.

Разберём:

  • что на самом деле хранит четвёртый канал — и почему альфа описывает пиксель, а не материал;
  • оператор over: одна формула, из которой растёт всё остальное, — и почему она не переживает перестановку слагаемых;
  • куда прозрачность девает z-буфер: почему стекло читает глубину, но не пишет её, и как сортируют то, что осталось без теста;
  • откуда тёмная кайма по краю спрайтов и частиц — и как её убирает premultiplied alpha;
  • в каком пространстве складывать: блендинг байтов против блендинга света — линейность добирается и сюда;
  • alpha test и alpha-to-coverage: как листва возвращает себе z-буфер, чем за это платит и почему кусты лысеют вдали;
  • weighted blended OIT: как сложить стёкла вообще без порядка — и что эта сумма теряет по дороге.
// @easy_dev_math

Такие разборы — с рабочим кодом — выходят в канале каждую неделю.

Альфа: четвёртое число

У цвета в текстуре четыре канала, и три из них вопросов не вызывают. Четвёртый привычно называют прозрачностью. Для работы такого понимания хватает. Для разбора — нет, альфу понимают как свойство материала, а это свойство пикселя.

Сам канал придумали Эд Кэтмулл и Алви Рэй Смит на рубеже 1977–78 годов: вместо того чтобы перерендеривать один и тот же объект под каждый новый фон, они стали сохранять рядом с цветом пикселя его непрозрачность. Буква взялась из классической формулы интерполяции αA+(1α)B\alpha A + (1-\alpha)B — греческой альфой в ней обозначена доля смешивания. А алгебру вокруг канала построили Томас Портер и Том Дафф в 1984-м, и у них альфа определена аккуратнее, чем «прозрачность»: альфа — это покрытие (coverage), доля площади пикселя, накрытая веществом. Полупрозрачная плёнка, накрывшая пиксель целиком, и непрозрачная кромка, накрывшая его наполовину, дают одну и ту же арифметику. Пиксель на ровной середине стекла и пиксель на его кромке — один материал, разная альфа.

Из определения формула смешивания выводится устно. Пиксель накрыт стеклом на долю αs\alpha_s: на этой доле виден цвет стекла CsC_s, на остальной — то, что за ним, CdC_d. Итоговый цвет — средневзвешенное:

C=αsCs+(1αs)CdC = \alpha_s C_s + (1 - \alpha_s)\, C_d

Это оператор over. В алгебре Портера и Даффа таких операторов двенадцать, но конвейеру реального времени хватило одного — на over стоит вся прозрачность: стекло, частицы, интерфейс, декали, дым. Всё, что дальше в статье, — истории о том, при каких условиях эта формула работает.

Over целиком: когда снизу тоже прозрачноеОсторожно! Математика!

Формула выше — частный случай: снизу лежит непрозрачный фон. В общем виде приёмник тоже полупрозрачен, и сначала нужно суммарное покрытие. Считаем как вероятности: точка пикселя накрыта хотя бы одним из двух слоёв, если не «промахнулись оба»:

αo=αs+(1αs)αd\alpha_o = \alpha_s + (1 - \alpha_s)\,\alpha_d

Цвет собирается из того, что показывает верхний слой, и того, что нижний показывает сквозь просветы верхнего. Взвешенная сумма даёт сразу произведение цвета на покрытие:

αoCo=αsCs+(1αs)αdCd\alpha_o C_o = \alpha_s C_s + (1 - \alpha_s)\,\alpha_d C_d

Присмотритесь к форме: и в левой части, и в правой цвет ходит парой со своей альфой, всюду αC\alpha C. Портер и Дафф заметили это ещё в 1984-м и предложили хранить цвет уже умноженным на альфу — тогда обе формулы, для цвета и для покрытия, становятся одной. К этой идее мы вернёмся в части про каймы.

Это не шейдер

Важная деталь устройства: over не пишут в шейдере. Смешивание выполняет отдельный блок в самом конце конвейера — после фрагментного шейдера, после теста глубины. Шейдер отдаёт четвёрку чисел и не видит, что уже лежит в буфере кадра; блендер берёт эту четвёрку, читает буфер и смешивает по заранее настроенному правилу:

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // тот самый over

Приёмник CdC_d — это не «то, что за стеклом в сцене». Это то, что уже нарисовано. Блендер прикладывает новое поверх старого, и никак иначе: у него нет ни сцены, ни глубин остальных объектов — только текущее содержимое буфера. Значит «то, что за стеклом» обязано попасть в буфер раньше стекла. Порядок наложения слоёв равен порядку отрисовки.

Перестановка слагаемых

И тут резонный вопрос: а что случится, если очередь нарушить? Пусть перед светлым фоном висят два стекла, бирюзовое и оранжевое, оба с альфой 0.5. Бирюзовое поверх оранжевого — один цвет. Оранжевое поверх бирюзового — другой, заметно теплее. Одинаковые стёкла, одинаковые альфы, одинаковый фон. Разный кадр.

Так и должно быть: over не Коммутативность — независимость результата от порядка операндов: a + b = b + a. Сложение и умножение коммутативны, вычитание и деление — нет.. Верхний слой входит в сумму с весом αs\alpha_s целиком, нижний — только сквозь просветы, с весом (1αs)(1-\alpha_s).

[ DEMO 01 ]//

Оператор over: порядок слагаемых

Слева и справа — одна и та же пара стёкол в двух порядках наложения; байты итогового цвета выведены под плашками, разница — в счётчике. Полоса ниже — стопка одинаковых стёкол: каждое пропускает половину света, и сквозь четыре штуки до фона доходит 0.54=1/160.5^4 = 1/16 — шесть процентов с копейками. Стекло дешёвое, стопка дорогая.

Сумма, которой порядок безразличен

У over есть родственник, у которого с перестановками всё хорошо, — аддитивный блендинг:

glBlendFunc(GL_ONE, GL_ONE); // просто сложить свет

Никаких весов: цвета складываются как есть. Сложение коммутативно, значит порядок отрисовки не важен вообще. У Портера и Даффа этот оператор идёт тринадцатым, отдельно от основной таблицы, — plus, для светящихся объектов. Огонь, вспышки, лазеры, магия: системы частиц из сотен перекрывающихся спрайтов исторически живут на аддитиве в том числе поэтому — их не нужно сортировать.

Но есть нюанс. Аддитив умеет только добавлять свет: в формуле нет множителя (1αs)(1-\alpha_s), гасящего фон, поэтому загородить он ничего не может. Вспышку он нарисует, тонированное стекло или клуб дыма — нет: дым обязан затемнять то, что за ним, а это уже over со всеми его порядками. В главе GPU Gems про огонь в демо «Vulcan» команда NVIDIA начинала с аддитива именно ради независимости от порядка — и всё равно перешла на over ради тёмного дыма, заплатив сортировкой нескольких сотен частиц за кадр.

Порядок: очередь из стёкол

Чтож, формула есть, и известно, что порядок наложения равен порядку отрисовки. Посмотрим, что это делает с конвейером, который до сих пор порядком не интересовался.

Наивный план звучит так: включаем блендинг и рисуем сцену как рисовали. И начинаются проблемы. Есть два варианта.

Стекло пишет глубину. Z-буфер не знает про альфу: фрагмент прошёл тест — глубина записана. Если ближнее стекло нарисовалось раньше, всё, что стоит за ним и рисуется позже, отбрасывается тестом как загороженное. В кадре это выглядит так: сквозь стекло видно не сцену, а фон или skybox стекло «вырезало» по своему силуэту. Причём дыра мигает: чуть повернули камеру, порядок отправки совпал с порядком глубин — дыра исчезла, повернули дальше — появилась снова.

Стекло не пишет глубину. Дыр больше нет, тест никого не выбрасывает. Но теперь всё решает очередь отрисовки. Over кладёт по очереди отрисовки новое поверх старого. Геометрически стекло на три метра позади, а в порядке отрисовки стоит впереди. Камера показывает это не хуже: очередь зафиксирована, а порядок по глубине едет вместе с ракурсом. Обошли стойку с одной стороны — очередь случайно совпала с глубиной, стёкла легли правильно; обошли с другой — порядок по геометрии изменился, а очередь отрисовки в материале нет, и дальнее стекло встало поверх ближнего.

Как это обходят в движках?

Порядок, к которому индустрия пришла и который вы найдёте в любом движке:

  1. Сначала весь непрозрачный мир — желательно спереди-назад: ближние объекты записывают глубину первыми, и ранний тест отбрасывает загороженные фрагменты до запуска шейдера.
  2. Потом всё прозрачное, сзади-вперёд. Тест глубины включён — стекло за стеной обязано исчезать. Запись глубины выключена — стекло не должно вырезать дыры в том, что рисуется после него. Буфер глубины переводится в режим «только чтение».

Сзади-вперёд — потому что over собирает результат от дальнего к ближнему: сначала в буфер ложится то, что глубже всех, на него — следующий слой, и так до ближайшего. Мы уже встречали эту схему: в части про растеризацию она называлась алгоритмом художника. Тогда от всех этих бед нас спас z-буфер: он решает видимость по фрагментам и не требует никакого порядка. Ирония в том, что прозрачность выключает z-буферу запись — и сортировка, от которой он нас избавил, возвращается обратно. Z-буфер придумали, чтобы не сортировать. Стекло заставляет сортировать снова.

В Unity всё это зашито в очереди рендера, и по числам видно устройство: Background — 1000, Geometry — 2000, AlphaTest — 2450, Transparent — 3000, Overlay — 4000. Всё, что до 2500, считается непрозрачным и упорядочивается примерно спереди-назад ради раннего теста; всё, что выше, сортируется по расстоянию и рисуется от дальних к ближним. Помечая материал прозрачным, вы на самом деле переставляете объект в другую очередь с другими правилами. Отдельная очередь AlphaTest, о ней в пятой части.

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

[ DEMO 02 ]//

Луковица: пивот один на всех

Ничья тут, впрочем, — меньшая из бед. Пройдём вдоль луча, который бьёт в центр: он протыкает внешнюю оболочку снаружи, за ней внутренние, разворачивается, выходит через них же и последней пробивает внешнюю изнутри. Внешняя плёнка встречается на пути дважды — первой и последней. Значит, в очереди она обязана стоять и раньше внутренних, и позже них. Одним номером это не записывается: переберите оба осмысленных порядка, снаружи внутрь и изнутри наружу, — каждый собирает одну половину сферы и рассыпает другую, счётчик расхождения не обнуляется ни на одном.

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

Чем сортировка платит

Осталась мелочь: «сзади-вперёд» кто-то должен обеспечить. Расстояния до камеры меняются каждый кадр — значит, и сортировка каждый кадр. И сортируют не фрагменты, а объекты целиком: у объекта берут одну точку — центр или пивот, — считают расстояние до камеры и упорядочивают список. Одно число на объект.

Одно число на объект — это самая главная проблема:

  • пересекающиеся объекты: у двух скрещённых стёкол половина первого лежит перед вторым, половина — за ним. Какой порядок ни выбери, одна половина нарисована не в том порядке;
  • длинные объекты: мост тянется от переднего плана к горизонту, его пивот — где-то посередине. Любое стекло рядом сортируется относительно этой середины, а не того куска моста, который реально рядом;
  • циклы: три панели каруселью перекрывают друг друга по кругу — A перед B, B перед C, C перед A. Правильного порядка не существует ни для какого критерия — для треугольников мы это уже видели, у объектов то же самое.

У артефакта есть имя — popping.

[ DEMO 03 ]//

Стопка стёкол против z-буфера

Стопка из пяти стёкол и один непрозрачный столб; порядок в списке отрисовки намеренно перемешан. Счётчик сверяет кадр с эталоном: в режиме «как в сцене» доля неправильных пикселей гуляет вместе с углом камеры, запись глубины меняет один сорт брака на другой, сортировка сзади-вперёд обнуляет счётчик. Панели здесь параллельные и компактные — сортировке по центрам такой случай по зубам.

Кайма: почему край грязный

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

Пустые тексели не пустые

В текстуре с альфой каждый тексель хранит четыре числа, и у текселей с альфой ноль цветовые каналы тоже чем-то заняты. Чем — зависит от экспорта: остатками фона, чёрным, мусором после сжатия. Пока тексель читается один, это неважно: альфа ноль, цвет не попадёт в кадр никогда.

Но тексель почти никогда не читается один. Билинейная фильтрация возвращает смесь четырёх соседей, и смешивает она каждый канал независимо: отдельно красный, отдельно зелёный, отдельно альфу. На краю спрайта соседями оказываются живой тексель и «пустой». Классический пример из заметки Тома Форсайта про premultiplied alpha: между непрозрачным красным текселем и прозрачным синим фильтрация выдаёт пополам красно-синий с альфой 0.5 — фиолетовый фрагмент, которого в текстуре не было, нарисованный с 50-процентным покрытием.

Посчитаем аккуратнее на нашем случае. Тексель A — непрозрачный бирюзовый, тексель B — пустой, цвет чёрный, альфа ноль. Правда посередине между ними: половина пикселя накрыта бирюзовым, значит на белом фоне должно быть «полбирюзы плюс полфона». А что выдаёт straight-путь? Цвет — среднее, (CA+0)/2(C_A + 0)/2, то есть полубирюзовый-полутёмный; альфа — 0.5. Over на белом: четверть бирюзы, половина фона и четверть чёрного. Эта четверть черноты и есть кайма.

Ошибка фильтрации straight-альфы в общем видеОсторожно! Математика!

Композит использует цвет только в произведении αC\alpha C — вклад текселя в кадр. Фильтрация же усредняет CC и α\alpha порознь, а произведение средних не равно среднему произведений. Для середины между двумя текселями:

αA+αB2CA+CB2отфильтровали, потом умножили    αACA+αBCB2правильный вклад  =  (αAαB)(CACB)4\underbrace{\frac{\alpha_A + \alpha_B}{2}\cdot\frac{C_A + C_B}{2}}_{\text{отфильтровали, потом умножили}} \;-\; \underbrace{\frac{\alpha_A C_A + \alpha_B C_B}{2}}_{\text{правильный вклад}} \;=\; -\,\frac{(\alpha_A - \alpha_B)(C_A - C_B)}{4}

Ошибка пропорциональна сразу двум перепадам — альфы и цвета. Внутри спрайта, где альфа ровная, её нет; на краю, где альфа падает с единицы до нуля, а цвет — с живого до мусорного, она максимальна.

Хранить сразу произведение

Раз композиту нужно только αC\alpha C — можно хранить в текстуре сразу его. Это и есть premultiplied alpha: цветовые каналы заранее умножены на альфу, у пустого текселя цвет обязан быть нулевым. Не «желательно нулевым», а нулевым по построению: чем прозрачнее тексель, тем меньше света он несёт, и полностью прозрачный не несёт ничего.

Формула over при этом упрощается — умножение на альфу источника из неё исчезает, потому что уже сделано:

C=Cs+(1αs)CdC = C_s + (1 - \alpha_s)\, C_d
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); // over для premultiplied

Идея из той же статьи Портера и Даффа 1984 года: у них четвёрка (0.5, 0, 0, 0.5) прямо по определению означает «полностью красный объект, накрывший полпикселя», и вся алгебра композитинга записана в этих координатах — одной формулой для цвета и альфы.

Мипмапам premultiplied нужен по той же причине, что и билинейке: мип — это усреднение, просто посчитанное заранее. Пример из заметки NVIDIA «To Pre or Not To Pre»: текстура два на один, непрозрачный красный тексель и зелёный с альфой 0.1. Straight-мип усредняет каналы как есть — (0.5, 0.5, 0, 0.55): почти прозрачный зелёный внёс в цвет столько же, сколько непрозрачный красный, и мип перекрасился. Premultiplied-мип даёт (0.5, 0.05, 0, 0.55) — зелёного ровно столько, сколько его реального вклада. Чем дальше объект и мельче мип, тем большую площадь спрайта занимает эта разница — вот почему кайма растёт с расстоянием.

[ DEMO 04 ]//

Кайма по краю

Без фильтрации каймы нет вовсе — и края тоже, одна лесенка. Билинейка на straight-текстуре даёт тёмное кольцо по контуру, и с ростом мип-уровня оно расползается внутрь. Premultiplied при той же фильтрации кольца не даёт: счётчик меряет максимальное отклонение яркости края от эталона.

Два свойства в подарок

Premultiplied чинит фильтрацию — а заодно приносит два свойства.

Ассоциативность — независимость результата от расстановки скобок: (a + b) + c = a + (b + c). Сложение и умножение ассоциативны, вычитание и деление — нет.. В premultiplied-виде over ассоциативен: стопку слоёв можно свернуть заранее в один слой и потом класть его на любой фон — результат тот же, что при наложении по одному. На этом стоят оффскрин-композиция интерфейсов, атласы эффектов, импосторы: «сплющить» пачку частиц в текстуру и рисовать одним квадом можно только в premultiplied. Straight так не умеет: у промежуточного результата нет корректного представления, какие blend-режимы ни выбирай.

Аддитив бесплатно. Поставьте в premultiplied-текстуре альфу ноль, а цвет — не ноль. Формула выше превращается в C=Cs+CdC = C_s + C_d — чистый аддитив. Один blend state, а в одном атласе уживаются огонь (альфа ноль, цвет светит) и дым (альфа близка к единице, цвет гасит фон) — и частица может плавно перетекать из одного в другое просто анимацией каналов.

На практике индустрия живёт на полдороге. Unity в спрайтовом шейдере умножает цвет на альфу прямо во фрагменте — блендинг premultiplied, но текстура остаётся straight, и каймы от фильтрации лечатся отдельной галочкой импорта Alpha Is Transparency, которая расталкивает цвет живых текселей в пустые. Unreal по умолчанию блендит полупрозрачность в straight-виде, а premultiplied вынесен в отдельный blend mode AlphaComposite.

В каком пространстве складывать

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

Вспомните сезон про дизеринг и гамму: байт в кадре — не количество света, а sRGB-код, перцептивная шкала, у которой тёмной части выделено больше уровней. Код 128 — это не «половина яркости»: раскодировать его в свет — получится 21.6% от максимума. А вся усредняющая математика корректна только над светом — мипы, MSAA-resolve и блендинг в том числе. Прозрачность — это буквально взвешенное усреднение.

Красное стекло с альфой 0.5 на зелёном фоне; и то и другое — чистые, яркие цвета. Складываем байты: (255, 0, 0) и (0, 255, 0) пополам — (128, 128, 0). Выглядит логично. Теперь посчитаем светом: половина от полного красного — это 50% красного света, а байтом 50% света кодируется как 188, не 128. Правильный ответ — (188, 188, 0), яркий жёлтый. Наивный — тёмный болотный, который излучает 43% положенного света: перепад между 128 и 188 на глаз огромен.

Откуда 188 и 43%Осторожно! Математика!

Декодирование sRGB-кода V[0,1]V \in [0,1] в свет и обратно (кусочная кривая стандарта, та же, что в статье про дизеринг):

L=(V+0.0551.055)2.4(V>0.04045),V=1.055L1/2.40.055(L0.0031308)L = \left(\frac{V + 0.055}{1.055}\right)^{2.4}\quad(V > 0.04045),\qquad V = 1.055\,L^{1/2.4} - 0.055\quad(L \ge 0.0031308)

Смешение пополам в линейном свете: L=0.51.0+0.50.0=0.5L = 0.5 \cdot 1.0 + 0.5 \cdot 0.0 = 0.5, кодируем: V=1.0550.51/2.40.055=0.7354V = 1.055 \cdot 0.5^{1/2.4} - 0.055 = 0.7354, байт 0.73542551880.7354 \cdot 255 \approx 188.

Смешение пополам в байтах: (255+0)/2=127.5128(255+0)/2 = 127.5 \to 128, а света в этом коде L(128/255)=0.216L(128/255) = 0.216. Отношение к правильному: 0.216/0.5=0.430.216 / 0.5 = 0.43 — недодали больше половины света.

Что умеет железо

GPU про эту проблему знает, и решение зашито в самое устройство буфера. Если render target объявлен sRGB-форматом, блендер обязан работать по схеме «декодировать — сложить — закодировать»: значение из буфера линеаризуется, смешивается с линейным выходом шейдера, результат кодируется обратно перед записью. Это требование спецификаций: расширение EXT_framebuffer_sRGB прямо начинается с положения, что блендинг фреймбуфера — линейная операция, и предписывает линеаризацию до смешивания; функциональная спека Direct3D 11 требует конверсии в линейное перед любой арифметикой фильтрации и блендинга. Кривая при этом касается только цвета: альфа во всех форматах хранится линейно и не перекодируется — покрытие есть покрытие.

Обратная сторона: если вы рисуете sRGB-байты в обычный UNORM-буфер без sRGB-флага, блендер никакой кривой не видит. Он складывает коды как числа — и получается ровно та арифметика из примера выше, с провалом яркости на каждом стыке. Глава «The Importance of Being Linear» из GPU Gems 3 перечисляет последствия работы в нелинейных данных списком, и «неправильные значения альфа-канала, артефакты композитинга» стоят там через запятую с кривыми мипами.

Почему тогда весь 2D-мир складывает байты

Потому что так исторически сложилось и так дешевле, а на глаз в интерфейсах это чаще терпимо, чем нет. Photoshop смешивает слои прямо в пространстве документа; переключатель «Blend RGB Colors Using Gamma» с гаммой 1.0 в его настройках Adobe сопровождает пометкой, что вариант колориметрически корректен, — и предупреждением, что документ с ним станет выглядеть иначе, чем в любом другом приложении. Это предупреждение — сама суть ситуации: смешивание в кодах давно стало «стандартным видом» полупрозрачности в 2D, и включение математически правильного блендинга меняет привычную картинку.

Отсюда практическое правило: пространство блендинга — это конвенция, но конвенция должна быть одна на весь пайплайн. Смешали часть эффектов в линейном, часть в sRGB — получили кадр, в котором одинаковые ассеты выглядят по-разному в зависимости от того, каким путём попали на экран.

[ DEMO 05 ]//

В каком пространстве складывать

Верхняя секция — то самое красное стекло на зелёном: в байтовом режиме шов между ними тёмный, в линейном перекрытие остаётся ярким. Ниже — растяжка между двумя цветами и белый дым на тёмном фоне: следите за серединой градиента и за тем, насколько «грязным» дым становится в байтовом режиме.

Вырезать вместо смешивать

Чтож, вернёмся к сортировке — точнее, к сценам, где сортировать нечего и надеяться не на что. Дерево. Крона из тысяч листьев, каждый лист — квад с текстурой, у которой мягкий альфа-край. Листья перекрывают друг друга во всех направлениях, спереди и сзади одновременно, внутри одного меша. Сортировать здесь нечего: правильного порядка квадов не существует, а фрагментного порядка блендер не умеет. Трава, кусты, сетки заборов, решётки радиаторов — весь этот класс геометрии в ту же копилку.

Индустрия отвечает ходом конём: раз полупрозрачность создаёт проблему порядка — убрать полупрозрачность.

Alpha test: либо есть, либо нет

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

vec4 texColor = texture(uAlbedo, uv);
if (texColor.a < 0.5) discard;  // ниже порога — фрагмента не существует

Полутонов больше нет, у каждого фрагмента снова ровно два состояния — и контракт из начала статьи восстановлен: у пикселя один хозяин. Выживший фрагмент непрозрачен, значит пишет глубину; z-буфер снова решает видимость по фрагментам; порядок отрисовки снова безразличен. Заодно чинятся тени: в карту глубины листва с alpha test попадает как полноценная геометрия — вырезанный фрагмент не существует и для света.

Никаких полутонов — значит, кромка листа жёсткая, в одну ступеньку. Это та самая лесенка, только теперь её не спрятать текстурной фильтрацией: фильтрация есть, а полутон запрещён самим методом.

Листва лысеет вдали

Менее очевидная беда вылезает на расстоянии. Мип — это усреднение. Возьмём четвёрку текселей на кромке листа: один непрозрачный, альфа 0.95, и три почти пустых по 0.05. Порог 0.5: на нулевом мипе жив один тексель из четырёх, покрытие 25%. Следующий мип усредняет четвёрку в один тексель с альфой 0.275 — и при том же пороге он мёртв. Было 25%, стало 0%.

Игнасио Кастаньо описал это в заметке «Computing Alpha Mipmaps», когда команда The Witness упёрлась в деревья, выцветающие с расстоянием: у каждого мип-уровня своя доля текселей, проходящих порог, и в большинстве случаев эта доля падает. Дерево вблизи — пышное, в ста метрах — скелет. Художники лечили это руками: подкручивали контраст мипов, масштабировали альфу по дистанции — пока не появилось решение по существу.

Нормировка покрытия по КастаньоОсторожно! Практика!

Определим покрытие мип-уровня как долю текселей выше порога ArA_r:

coverage=1Ni[ai>Ar]\mathrm{coverage} = \frac{1}{N}\sum_i \left[a_i > A_r\right]

Хочется найти для каждого мипа множитель альфы, возвращающий покрытие нулевого уровня. Напрямую множитель ищется плохо — задача дискретная. Кастаньо разворачивает её: бинарным поиском находится новый порог ara_r, при котором мип даёт нужное покрытие (порог зажат в [0, 1], бисекция сходится), а множитель после этого — просто отношение:

scale=Ar/ar\mathrm{scale} = A_r / a_r

Вся работа делается один раз при сборке текстуры, рантайм не меняется вовсе. Реализация лежит в открытом NVIDIA Texture Tools; альтернативный подход — «Alpha Distribution for Alpha Testing» Джема Юкселя (i3d, 2018), где предобрабатываются сами альфа-значения.

Alpha-to-coverage: полутон из сэмплов

Остался жёсткий край. Хочется вернуть кромке мягкость, не возвращая блендинг с его порядком, — и у железа для этого припасён механизм на стыке с MSAA.

Вспомните устройство MSAA: у пикселя несколько сэмплов покрытия, шейдер отрабатывает один раз, а результат раскладывается по сэмплам. Alpha-to-coverage превращает альфу фрагмента в маску этих сэмплов: альфа 0.5 при четырёх сэмплах зажигает два. Дальше всё по обычной непрозрачной механике — каждый сэмпл со своей глубиной, никакого блендинга, никакого порядка, а в момент resolve сэмплы усредняются, и край получает полутон.

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

Нюансы у механизма сугубо аппаратные. Градаций мало: четыре сэмпла — пять уровней покрытия, от нуля до четырёх, и плавный альфа-градиент превращается в заметный узор (часть железа тянет градации дизер-паттернами на соседние пиксели). Без MSAA механизм просто выключен — спецификация пропускает этот шаг целиком, если буфер не мультисэмпловый. И это не блендинг: сэмпл, занятый ближним фрагментом с большой альфой, перекрывает дальний целиком — усреднение происходит один раз на resolve, а не по слоям.

[ DEMO 06 ]//

Листва: вырезать вместо смешивать

Один и тот же куст на четырёх мип-уровнях — «удаляющийся». В режиме blend края мягкие, но это снова прозрачность со всеми долгами порядка. Alpha test возвращает z-буфер, и счётчик покрытия показывает, как крона тает от мипа к мипу; нормировка по Кастаньо выравнивает покрытие обратно. A2C добавляет краю ступенчатый полутон — присмотритесь к дизер-узору на кромке.

Сложить без порядка

Чтож, инвентаризация. Сортировка держит компактные непересекающиеся стёкла. Вырезание держит листву. Остались сцены, где бессильно и то и другое: пересекающиеся плоскости, плотный дым из перекрывающихся клубов, вода с частицами внутри. Для них хочется невозможного — формулы, в которой порядок не участвует вовсе. Такие методы существуют, у них общее имя — order-independent transparency, OIT.

Точно, но за N проходов

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

Depth peeling Кэсса Эверитта (NVIDIA, 2001) добывает список послойно. Первый проход обычный — z-буфер оставляет ближайший прозрачный слой. Второй проход использует глубины первого, чтобы «снять» уже учтённое, и достаёт второй по близости слой. И так далее: слой — полный проход по геометрии. Потом слои складываются обычным over в известном порядке. Метод точен, а цена написана прямо в устройстве: хотите пять слоёв стекла — рисуйте прозрачную геометрию пять раз. Эверитт, впрочем, замечает, что вклад дальних слоёв быстро тает и его тестовой сцене хватало трёх.

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

Оба метода точные — и оба дорогие. А есть путь в другую сторону: неточный и почти бесплатный.

Weighted blended: сменить операцию

Вернёмся к самому началу. Порядок важен потому, что over не Коммутативность — независимость результата от порядка операндов: a + b = b + a. Сложение и умножение коммутативны, вычитание и деление — нет.. Но у нас уже есть две операции, которым порядок безразличен: сложение — и умножение. Морган Макгвайр и Луи Бавуа в работе «Weighted Blended Order-Independent Transparency» (JCGT, 2013) собирают прозрачность целиком из них.

Наблюдение первое: сколько фона видно сквозь стопку — произведение (1αi)\prod (1-\alpha_i), и оно от порядка не зависит. Ту часть ответа, которая отвечает за фон, можно посчитать точно, вообще не зная порядка, — копим произведение в отдельном буфере, авторы зовут его revealage, «сколько фона раскрыто».

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

Что этот размен покупает и что теряет — авторы описывают сами. Покрытие фона остаётся точным при любых весах, итоговый цвет — всегда смесь цветов реальных поверхностей, за их пределы метод не выходит. Один слой сворачивается в точный over — веса сокращаются. Дым из похожих по цвету частиц почти неотличим от эталона — усреднению всё равно, что усреднять, если цвета близки. Платится за это различимостью слоёв: несколько поверхностей с сопоставимым покрытием дают средний цвет независимо от того, кто ближе, — бирюзовое стекло за оранжевым и оранжевое за бирюзовым сходятся к одной картинке. Чтобы ближние слои всё же побеждали, в среднее вводят вес, убывающий с глубиной, — отсюда «weighted» в названии. Подбор весовой функции — самая капризная часть метода: авторы прямо пишут, что тяжелее всего сценам с большим диапазоном глубин и плотными кластерами разноцветных поверхностей, а результат к тому же меняется от переноса всей сцены по глубине.

Зато требования к железу — только рендер в текстуру и обычный блендинг: метод изначально позиционировался как OIT для WebGL, мобильных чипов и консолей. Два маленьких буфера, один проход по прозрачной геометрии, один полноэкранный проход сборки.

Формулы weighted blended OITОсторожно! Математика!

Итоговый цвет пикселя по уравнению (6) статьи — premultiplied-цвета CiC_i, покрытия αi\alpha_i, фон C0C_0:

Cf=i=1nCiw(zi,αi)i=1nαiw(zi,αi)(1i=1n(1αi))+C0i=1n(1αi)C_f = \frac{\displaystyle\sum_{i=1}^{n} C_i\, w(z_i, \alpha_i)}{\displaystyle\sum_{i=1}^{n} \alpha_i\, w(z_i, \alpha_i)} \left(1 - \prod_{i=1}^{n}(1-\alpha_i)\right) + C_0 \prod_{i=1}^{n}(1-\alpha_i)

Все три суммы-произведения коммутативны, поэтому копятся блендером в любом порядке: числитель и знаменатель — аддитивно в RGBA-буфер (цвет в RGB, знаменатель в альфа-канале), revealage — множительно, блендом с фактором (1αs)(1-\alpha_s). При n=1n=1 вес сокращается и остаётся ровно premultiplied-over: Cf=C1+(1α1)C0C_f = C_1 + (1-\alpha_1)C_0.

Одна из весовых функций, рекомендованных авторами (уравнение 9; настроена под 16-битный float-буфер и глубины камеры от 0.1 до 500):

w(z,α)=αclamp ⁣(0.03105+(z/200)4,  102,  3103)w(z, \alpha) = \alpha \cdot \mathrm{clamp}\!\left(\frac{0.03}{10^{-5} + (|z|/200)^4},\; 10^{-2},\; 3\cdot 10^{3}\right)

Без клампов вес переполняет 16-битный буфер у ближней плоскости и уходит в ноль у дальней. И примечание для копирующих формулы из статей: в первой публикации в уравнениях 7–10 были опечатки, авторы поправили PDF в марте 2014-го — сверяйтесь с исправленной версией.

[ DEMO 07 ]//

Пересечения: сложить без порядка

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

Выводы

Если оставить от статьи одну мысль, пусть будет такая: прозрачность ломает главный контракт конвейера — «у пикселя один хозяин» — и не предлагает нового. Z-буфер, ранний тест, произвольный порядок отрисовки — всё это следствия старого контракта, и всё это выключается, как только фрагменты начали делить пиксель. Дальше есть только три стратегии: договориться о порядке (сортировка), силой вернуть старый контракт (вырезание) или сменить операцию на такую, которой порядок безразличен (аддитив, weighted blended). Каждая техника этой статьи — одна из трёх стратегий, других пока не придумали.

Отсюда разбор проблем:

  • тёмная кайма по краю спрайта, растёт с расстоянием — straight-текстура под билинейкой и мипами: цвет пустых текселей просочился в край. Premultiplied или расталкивание цвета на импорте;
  • сквозь стекло виден skybox вместо сцены, дыра мигает при повороте — прозрачное пишет глубину и отсекает то, что рисуется после;
  • дальнее стекло встаёт поверх ближнего — прозрачное рисуется без сортировки, порядок наложения равен порядку отправки;
  • картинка щёлкает при движении камеры — сортировка по центрам: два пивота поменялись местами в очереди;
  • пересекающиеся стёкла неправильны наполовину, при любом порядке — порядок выбирается по объектам, а нужен по фрагментам;
  • стык двух ярких полупрозрачных цветов проваливается в грязную темноту — блендинг складывает sRGB-байты вместо света;
  • спрайт потемнел по всему полупрозрачному полю — двойное умножение на альфу: premultiplied-текстура в straight-блендере;
  • края «жирные», слишком светлые — обратный случай: straight-текстура в premultiplied-блендере;
  • листва пышная вблизи и лысая вдали — мипы утащили альфу под порог alpha test: покрытие тает по уровням, лечится нормировкой;
  • на кромке листвы регулярный точечный узор — alpha-to-coverage с малым числом сэмплов: покрытие квантуется в несколько градаций;
  • дым ровный, но стопка стёкол потеряла глубину — weighted blended: слои с сопоставимым покрытием сходятся к среднему цвету.

Чего мы не разобрали

Толщину. Over — модель бесконечно тонкой плёнки, а у настоящего стекла и воды есть глубина: свет в них преломляется и поглощается по пути. Преломление — это уже чтение кадра как текстуры со смещением, поглощение — экспонента от пройденного расстояния; и то и другое — отдельная механика поверх блендинга.

Поверхность. Стекло не только пропускает, но и отражает — по Френелю, сильнее под скользящим углом. Прозрачный PBR-материал складывает отражённую часть и пропущенную, и блендинг отвечает только за вторую.

Тени от прозрачного. Карта глубины хранит одно число на тексель — стекло в ней либо есть, либо нет, полутени фильтром не сделать. Цветные тени от витража — отдельные техники со своими картами.

Точные OIT по-взрослому. Списки фрагментов на DX11-железе, moment-based OIT (Мюнстерманн и соавторы, 2018) — статистические приближения распределения глубин; волосы и меха с их тысячами полупрозрачных прядей — целая отдельная дисциплина сортировки.

И общая закономерность, сквозная для всей дуги. Блендинг не хуже z-буфера — он отвечает на другой вопрос. Z-буфер спрашивает «кто ближе», блендинг — «в какой пропорции смешать». Беда начинается, когда конвейер, собранный вокруг первого вопроса, молча получает задачу со вторым: каждый артефакт этой статьи — след от стыка двух вопросов.

Дальше по конвейеру — территория, на которую мы уже наступили одной ногой. Weighted blended копит слагаемые не в кадре, а в отдельных буферах, и собирает картинку отдельным полноэкранным проходом — кадр перестал быть местом, куда рисуют, и стал текстурой, которую считают. Следующий сезон — «После кадра»: рендер в текстуру, сглаживание, bloom, tone mapping — всё, что происходит с кадром после того, как последний объект нарисован, и до того, как его увидит игрок.

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

// @easy_dev_math

Разборы графики с кодом — каждую неделю в канале.