Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.
В прошлой статье треугольник превратился в пиксели: растеризатор решил, какие сэмплы накрыты, z-буфер решил, кто ближе. Пиксель закрашен — но чем? Всю ту статью мы говорили «шейдер возьмёт цвет из текстуры», как будто это одна строчка. Строчка и правда одна. А приколы у неё свои, и вы их точно видели: далёкая плитка кипит и переливается при движении камеры, сетка-рабица вдали превращается в рябь, которой в текстуре нет, пол под ногами уходит в мыло, а если подойти к стене вплотную — вместо кирпича размытое пятно.
Собственно, всё это одна тема: как именно из картинки достаётся цвет для конкретного пикселя. Разберём:
- почему тексель — не пиксель и что на самом деле делают
nearestи билинейная фильтрация; - откуда берётся мерцание вдали — алиасинг при уменьшении — и как его лечат мипмапы;
- почему при взгляде вдоль пола картинка мылится и что с этим делает анизотропная фильтрация;
- сколько всё это весит в памяти и как устроено блочное сжатие — BCn на десктопе, ETC2 и ASTC на мобилках;
- почему лесенка на краях геометрии лечится не мипами, а MSAA, и что он не умеет;
- и как эти решения складываются в один запрос к сэмплеру.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Часть 1. Текстура — не картинка, а функция
Начнём с того, что в шейдере выглядит совсем невинно:
vec4 color = texture(albedo, uv);Одна строчка. Внутри неё — выбор фильтра, выбор уровня детализации, до шестнадцати чтений из памяти и целый узел железа, который всем этим занимается. Давайте разберём, что он делает, по шагам.
UV — это адрес в долях, а не в пикселях
Текстурные координаты приходят из вершин: художник разложил модель на плоскость, и у каждой вершины появилась пара чисел u и v. Растеризатор интерполирует их по треугольнику — с делением на w, иначе текстура поплывёт, — и в пиксельный шейдер попадает конкретная пара чисел.
Важно, что это доли, а не пиксели. Ноль — левый край, единица — правый, независимо от того, лежит там картинка 64×64 или 4096×4096. Замените ассет на вдвое более подробный — код шейдера не изменится ни на символ. Текстура для шейдера — не массив, а функция: подали координату, получили цвет.
Вот только на диске у вас всё-таки массив. И вся эта часть — про то, как из конечного массива сделать функцию, определённую в любой точке.
Тексель — точка, а не квадратик
В прошлой статье мы договорились, что пиксель для растеризатора — точка-сэмпл в своём центре, а не маленький квадрат. С текселем ровно то же самое: значение текселя (i, j) живёт в точке
— в центре своей клетки. Клетка — это область, за которую тексель «отвечает» при простейшем фильтре, но само значение — точечная выборка сигнала. Классическое эссе Элви Рэя Смита «A Pixel Is Not a Little Square» (1995) — про это, и для текстур оно даже важнее, чем для экрана.
Из такой постановки прямо следуют оба фильтра увеличения.
Nearest (он же point): берём тексель, в чью клетку попала координата, — i = ⌊u·w⌋. Ровно одно чтение из памяти, ноль арифметики, идеально резкие границы. Именно поэтому его сознательно ставят в пиксель-арте: Minecraft со сглаженными текстурами перестал бы быть Minecraft.
Билинейная фильтрация: берём четыре ближайших текселя и смешиваем по расстоянию до их центров. Сначала переводим координату в «текселе-пространство» со сдвигом на полтекселя, потом раскладываем на целую и дробную части:
Веса в сумме дают единицу, а в центре текселя один из них равен единице, остальные — нулю: билинейка проходит ровно через исходные значения и ничего не выдумывает.
Пощупайте руками — тут это быстрее, чем читать формулы:
Тексель, а не пиксель
Сведите масштаб к единице: разница между фильтрами почти пропадает — тексель снова приходится на пиксель, спрашивать особо нечего. Разъезжаются они как раз тогда, когда пиксель и тексель перестали совпадать. И это, по сути, вся тема статьи: у сэмплера два разных сценария — увеличение (текселей меньше, чем пикселей) и уменьшение (текселей в пиксель попадает много). Билинейка — ответ на первый. Со вторым она не справляется вообще, и почему — в следующей части.
Фильтр — свойство сэмплера, а не текстуры
В API фильтрация задаётся не текстурой, а отдельным объектом-сэмплером. В Direct3D 11 это D3D11_SAMPLER_DESC, и его поля — почти оглавление этой статьи:
D3D11_SAMPLER_DESC s = {};
s.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; // фильтры: уменьшение, увеличение, между мипами
s.AddressU = D3D11_TEXTURE_ADDRESS_WRAP; // что за краем — часть 1
s.MipLODBias = 0.0f; // сдвиг уровня — часть 2
s.MaxAnisotropy = 1; // 1..16 — часть 3Три фильтра в одном поле — не случайность. MIN работает при уменьшении, MAG — при увеличении, MIP — при переходе между уровнями детализации. Одну и ту же текстуру два разных сэмплера могут читать совершенно по-разному, и это правильно: фильтр — это про то, как вы спрашиваете, а не про то, что лежит в памяти.
Что за краем текстуры
Координата запросто уезжает за пределы [0, 1] — у тайлящегося пола она вообще растёт бесконечно. Режим адресации решает, что делать: repeat берёт дробную часть (текстура повторяется), clamp прижимает к краю, mirror отражает, border возвращает заданный цвет.
Почему в атласах делают отступыОсторожно! Практика!
Если сложить много мелких текстур в один атлас, билинейка на краю подтянет соседей — чужие пиксели «протекут» в вашу иконку. Лечится отступом: край каждого спрайта дублируется наружу на несколько текселей.
С мипмапами (о них — часть 2) отступ приходится увеличивать: на каждом следующем уровне тексели усредняются по четыре, и то, что на нулевом уровне отстояло на 4 текселя, на четвёртом окажется соседом. Отсюда практическое правило: либо отступ порядка размера самого мелкого используемого уровня, либо каждый элемент атласа — отдельный тайл со своим clamp.
Частая пара настроек импорта
Point-фильтр плюс выключенная генерация мипмап — сочетание, которое регулярно встречается в настройках импорта: текстура резче, памяти меньше. На статичном кадре так и есть, и до определённого момента вопросов не возникает. Ломается это при движении камеры — что именно там происходит, разбираем в части 2.
И заодно сместим сам вопрос. Если текстура — функция, которую вы сэмплируете, то «какой тексель взять» — постановка неправильная. Правильная: какое среднее по области вернуть, если в пиксель попал не один тексель, а сорок.
Часть 2. Почему далёкая текстура мерцает
Итак, камера отъехала. Плитка, которая вблизи занимала пол-экрана, теперь ушла к горизонту, и в один пиксель попадает не половина текселя, а сорок текселей сразу. Вопрос на месте: какой цвет вернуть?
nearest вернёт один из сорока — тот, в чью клетку попал центр пикселя. Билинейка вернёт смесь четырёх из сорока. Про остальные тридцать шесть никто не спросил. И вот тут начинается интересное: стоит камере сдвинуться на волосок, и «выигравший» тексель меняется на совсем другой — соседний по картинке, но не по цвету. Пиксель прыгает. Соседний пиксель прыгает не в такт. По изображению идёт кипение.
Это алиасинг, и болезнь ровно та же, что у тонкого треугольника из прошлой статьи: сигнал меняется чаще, чем расставлены точки, которыми мы его щупаем. Только там сигналом была геометрия, а здесь — рисунок текстуры. Мы уже встречались с этим и в статье про дизер и гамму: когда шаг сетки крупнее деталей сигнала, правильного ответа из одной выборки не получить в принципе.
Правильный ответ — среднее по площади
Что вообще должен вернуть сэмплер в такой ситуации? Не «какой-то из сорока текселей», а их среднее: пиксель накрывает кусок поверхности, и его цвет — это цвет всего куска. Тогда мелкая расчерковка плитки вдали превращается не в рябь, а в ровный тон — как и в жизни, когда деталь перестаёт быть различима.
Считать это в лоб — сорок чтений на пиксель, а у горизонта и тысяча. На каждый пиксель каждого кадра. Понятно, что так никто не делает.
Мипмапы: усреднить заранее
Решение придумал Лэнс Уильямс в 1983 году — статья «Pyramidal Parametrics». Идея простая до неприличия: раз усреднение по площади всё равно понадобится, посчитаем его заранее и сложим рядом с текстурой. Уровень 1 — та же картинка вдвое меньше (каждый тексель = среднее четырёх), уровень 2 — ещё вдвое, и так до 1×1.
Слово «MIP» в названии — сокращение от латинского multum in parvo, «многое в малом».
Цепочка стоит дополнительной памяти, но немного: каждый следующий уровень вчетверо меньше предыдущего, а сумма ряда
то есть плюс примерно треть к размеру текстуры. За это вы получаете возможность в любой момент взять уже усреднённую версию нужной подробности.
Как выбирается уровень
Осталось понять, какой именно уровень брать. Для этого нужно знать, насколько быстро текстурная координата меняется при переходе к соседнему пикселю. Спека OpenGL (и, в том же виде, расширение про анизотропию) определяет это так: считаем два масштабных множителя — по горизонтали и по вертикали экрана,
(координаты u, v здесь — в текселях, а не в долях), берём больший из них и логарифмируем:
Читается это просто. Один тексель на пиксель — λ = 0, нулевой уровень. Четыре текселя на пиксель — λ = 2, второй уровень, который как раз в четыре раза мельче. Каждое удвоение следа пикселя — плюс один уровень. Дробную часть λ можно использовать для смешивания двух соседних уровней — это и есть трилинейная фильтрация: две билинейные выборки плюс интерполяция между ними. Без неё, на целых уровнях, по полу пойдут заметные кольца — места, где сэмплер переключился с одного уровня на другой.
Покрутите сами: слева кадр, справа вся цепочка уровней с подсветкой тех, что взял сэмплер.
Уменьшение, муар и мип-цепочка
Первое, что стоит сделать, — оставить «без мипов» и потянуть ползунок текселей на пиксель. Узор, который поплывёт по кадру, в текстуре отсутствует: это интерференция сетки текселей с сеткой пикселей, чистый артефакт выборки. Потом включите «ближайший мип» — рябь исчезнет, но появятся ступеньки. Потом трилинейку — и ступеньки размажутся.
Ловушка: усреднять надо в линейном свете
Второй переключатель демки — про то, как именно считались уровни. Возьмите чёрно-белую шахматку и усредните четыре текселя «как есть», по байтам: получите 128. Но байты в текстуре — это sRGB-кодировка, а не количество света. Правильное среднее считается в линейном пространстве: полсвета в sRGB-кодировке — это примерно 188, а вовсе не 128.
Разница огромная, и видно её как раз вдали: наивно посчитанные мипы делают дальний план темнее ближнего. Движки для sRGB-текстур это учитывают, но как только вы генерируете уровни сами (в тулзе, на GPU, в рантайме) — про кодировку легко забыть. Мы это уже разбирали в статье про гамму, и здесь та же ошибка вылезает с другой стороны.
А что с картами, которые не цветОсторожно! Практика!
Мип-цепочка исправно усредняет числа — но не всякие числа усредняются осмысленно.
- Нормали. Среднее четырёх единичных векторов короче единицы: на дальних уровнях нормаль «сдувается», поверхность выглядит более гладкой, чем задумано. Отсюда приёмы вроде Toksvig и подходов, где потеря длины нормали переносится в шероховатость.
- Шероховатость (roughness). Усреднять её отдельно от нормалей неправильно: две зеркальные грани, смотрящие в разные стороны, на расстоянии ведут себя как одна шероховатая. К этому вернёмся, когда доберёмся до PBR.
- Маски с alpha-test. Листва и сетки на дальних уровнях «тают»: среднее альфы падает ниже порога отсечения, и половина листьев исчезает. Лечится подгонкой альфы по уровням (alpha coverage) — в Unity это галочка в настройках импорта.
Возвращаясь к «резче без мипов»
Выключенные мипы дают ровно то, что видно в первом режиме демки: статичный скриншот действительно резче, а в движении дальний план кипит. Мипы не «портят картинку размытием» — они возвращают то, что и должно быть в пикселе: среднее по накрытой площади.
Правда, с ними приходит вторая проблема. Поставьте камеру почти параллельно полу — и увидите, что дальний план не просто усреднён, а размазан заметно сильнее, чем хотелось бы. Это уже не алиасинг, это мыло. Откуда оно берётся — часть 3.
Часть 3. Скользящий угол: откуда мыло
Посмотрите под ноги в любом шутере. Пол уходит вдаль, и метрах в десяти рисунок плитки превращается в кашу — не в мерцающую, как в прошлой части, а в ровную и мутную. При этом вбок, поперёк взгляда, детали ещё вполне читаются. Почему размывает именно вдоль?
След пикселя — не квадрат
Вернёмся к тому, что накрывает пиксель на поверхности. Когда поверхность параллельна экрану, это аккуратный квадратик: Px и Py примерно равны. Но пол под ногами наклонён почти под ноль, и картина другая: поперёк экрана пиксель накрывает пару текселей, а вглубь — десятки. След вытянут вдоль взгляда.
А в формуле выбора уровня стоит одно число:
Максимум. То есть уровень выбирается по длинной оси следа. Если вглубь пиксель накрывает 32 текселя, а поперёк 2, сэмплер возьмёт уровень, где всё уменьшено в 32 раза — и поперечная деталь, которой хватало разрешения с запасом, усредняется вместе со всем остальным. Вот и мыло.
Заметьте, выбора у изотропного сэмплера особо нет. Взял бы он минимум — вдоль длинной оси вернулся бы недосэмпленный сигнал, то есть кипение из части 2. Одним числом на весь след можно выбрать либо мыло, либо рябь.
Анизотропная фильтрация: несколько выборок вдоль оси
Раз след вытянут, будем щупать его не одной точкой, а несколькими — вдоль длинной оси. Спека EXT_texture_filter_anisotropic описывает это буквально в три строчки:
Pmax / Pmin — вытянутость следа. Столько выборок и нужно, чтобы каждая отвечала за свой кусочек длинной оси; уровень для них берётся не по всей длине, а по её N-й части — то есть log2(N) уровней детализации возвращается обратно. Восемь выборок — это три уровня детализации, отыгранных у мыла.
Потолок N задаёт сэмплер: в Direct3D 11 поле MaxAnisotropy принимает значения от 1 до 16. Знакомая строчка «Anisotropic Filtering: 16x» в настройках графики — ровно про это число.
Скользящий угол и анизотропия
Начните с ×1 — это и есть изотропный случай — и кликните по дальней части пола. Во врезке видно след пикселя в пространстве текстуры: вытянутый четырёхугольник и одна точка выборки в его центре. HUD показывает вытянутость и λ. Дальше включайте ×4, ×8, ×16: точек становится больше, они выстраиваются вдоль длинной оси, а λ падает — сэмплер начинает читать более детальные уровни. Ползунком наклона можно довести камеру почти до горизонтали и посмотреть, как вытянутость уходит за десяток.
Сколько это стоит
Прямо: до N трилинейных выборок вместо одной. Трилинейная — это уже две билинейные, то есть восемь чтений; при ×16 в худшем случае набегает сто двадцать восемь. Правда, «в худшем случае» тут ключевое: N считается на пиксель, и там, где след круглый (стена перед носом, пол под ногами), N = 1 и никакой доплаты нет. Платят только скользящие углы — и в кадре их обычно меньшинство. В демке счётчик среднего числа выборок как раз показывает эту разницу между «включил ×16» и «реально прочитал ×16».
Дополнительный бонус: выборки идут подряд вдоль одной линии в одном уровне мипа, то есть попадают в соседние строки кэша. Это не бесплатно, но и не буквально в 16 раз дороже.
А почему нельзя предпосчитать и анизотропию тожеОсторожно! Математика!
Можно, и пробовали. RIP-map — пирамида, где стороны уменьшаются независимо: 256×128, 128×256, 64×256 и так далее. Тогда для вытянутого следа нашёлся бы готовый уровень. Плата — память: полный набор комбинаций весит вчетверо больше исходной текстуры против трети у обычной цепочки. Да и покрывает он только оси, а след может быть повёрнут под любым углом.
Более точный подход — EWA, elliptical weighted average: след аппроксимируется эллипсом, и по нему считается взвешенное ядро (Greene и Heckbert, 1986). Качество отличное, цена — переменное число выборок с весами на каждый пиксель, что в железе фиксированного конвейера неудобно.
Индустрия выбрала середину: несколько выборок по прямой вдоль длинной оси. Не идеально, зато предсказуемо по цене и укладывается в тот же блок железа, что и трилинейка.
Итак, с фильтрацией разобрались: мипы против ряби, анизотропия против мыла. Но вся эта конструкция пока висит в воздухе — мы ни разу не спросили, сколько текстуры вообще влезет в память. А это как раз то место, где решения принимаются не программистом, а бюджетом.
Часть 4. Где всё это лежит: память и блочное сжатие
Итак, как читать текстуру, мы разобрали. Теперь — где она лежит и сколько занимает. Тут-то и всплывает тот самый бюджет из конца прошлой части. Тема кажется скучной ровно до первого профайлинга на мобилке.
Посчитаем. В RGBA8 на тексель уходит четыре байта, так что текстура 2048×2048 весит:
Плюс мип-цепочка, то есть ещё треть: 21,3 МиБ. Одна текстура. У персонажа таких обычно несколько — базовый цвет, нормали, шероховатость с металличностью, маски, — и вот уже под сотню мегабайт на одного героя. А теперь пройдитесь так по всему списку ассетов уровня.
Почему нельзя просто заархивировать
Первая мысль — «положим PNG, он же сжимает». Не выйдет, и причина не в качестве.
Сэмплеру нужен произвольный доступ: дайте тексель (1337, 42) — прямо сейчас, за фиксированное число тактов, не читая соседей. В PNG или JPEG длина сжатых данных зависит от содержимого: чтобы добраться до нужного пикселя, надо распаковать всё, что до него. GPU так не работает. PNG на диске — да, отлично; в видеопамяти он развернётся в те же 32 бита на тексель.
Значит, нужен формат, где адрес считается арифметикой, а не поиском. Отсюда и вся конструкция: картинка режется на блоки фиксированного размера — у BCn и ETC2 это всегда 4×4 текселя, у ASTC размер выбирают при сжатии, — и каждый блок занимает фиксированное число байт. Адрес блока — простое умножение, внутри блока — сдвиги и маски. Распаковывает блок само железо: текстурный блок, между памятью и фильтром. Контракт тут одинаковый у всех — и у десктопных BCn, и у мобильных ETC2 и ASTC; различаются они только начинкой блока.
Начнём с BC1: 4×4 текселя в 8 байт
В мобильном проекте вы поставите в импортёре ASTC или ETC2 — до них дойдём сразу после демки. Но начинку удобнее показывать на BC1 (в Unity он подписан RGB Compressed DXT1): он старше всех, и полей внутри ровно два. Разберём на нём, а дальше посмотрим, что с этой схемой сделали остальные.
В восьми байтах BC1 помещается:
- два опорных цвета в RGB565 — по два байта каждый;
- шестнадцать индексов по два бита — ещё четыре байта.
Декодер строит из двух опорных цветов палитру на четыре позиции: сами опорные плюс две точки, которые делят отрезок между ними на трети. Каждый тексель хранит только номер позиции. Получается 4 бита на тексель — в восемь раз меньше, чем RGBA8.
Собственно, вся схема держится на одном допущении: внутри блока цвета должны лечь примерно на одну прямую в RGB-пространстве. Если легли — потерь почти нет. Если в блоке встретились цвета, которые на одну прямую не ложатся, — лишнее подтянется к ближайшей позиции палитры.
Блок 4×4: как текстура влезает в память
Пощёлкайте по блокам на градиенте: ошибка почти нулевая, все шестнадцать текселей ложатся на отрезок между опорными точками. Потом переключитесь на резкие границы и кликните по кольцу вокруг круга — по стыку бирюзового и оранжевого: вся ошибка собирается там. А на шуме не выручит уже ничего: в блоке шестнадцать разных цветов, а позиций в палитре четыре.
Про кольцо оговорюсь отдельно. Бирюзовый и оранжевый расходятся по каналам в разные стороны, а кодировщик в демке наивный: опорные точки он берёт по краям облака цветов блока — крайние значения по каждому каналу, — и ось проходит мимо обоих цветов. Настоящий компрессор тут выкрутится: поставит опорные точки прямо в бирюзовый и оранжевый, и от блока останется только ошибка квантования в 565. Ось он ищет аккуратнее и потом уточняет её итерациями, так что на сложных блоках выигрывает. Схему это не меняет, зато хорошо видно, из чего складывается ошибка.
ETC2: тот же блок, другая начинка
Спецификация Khronos описывает ETC совсем иначе. Блок 4×4 делится пополам — на два подблока 2×4 или 4×2, — и у каждого свой базовый цвет. На тексель тоже приходится два бита, но выбирают они не точку на отрезке, а поправку из таблицы яркости, и поправка прибавляется сразу ко всем трём каналам. То есть внутри подблока меняется только яркость, а цветность у всех текселей одна — та, что задал базовый цвет.
Отсюда и слабое место: с резким стыком двух разных цветов внутри блока такая схема не справляется. Собственно, это ETC2 и чинит: к режимам ETC1 добавились три новых. T и H хранят две базовые точки и смещение из таблицы расстояний — в H оно симметрично вокруг обеих точек, в T одна точка остаётся как есть, а вокруг второй строится триада. Оба режима — про резкий переход цвета. А в planar индексов нет вообще: лежат три базовых цвета, и блок между ними интерполируется плоскостью — это уже про гладкие участки. ETC1 при этом остался строгим подмножеством ETC2, так что старые текстуры новое железо читает без оговорок.
Важное для планирования: ETC2 и EAC входят в ядро OpenGL ES 3.0 — их обязана поддерживать любая реализация. Поэтому ETC2 и живёт как страховка: в ядре ES 3.0 он обязателен, а в Vulkan это опциональная возможность устройства textureCompressionETC2 — формальной гарантии нет, но на практике её отдают все мобильные GPU, которые вообще запускают Vulkan. На это же опираются рекомендации Unity.
ASTC: размер блока — параметр сжатия
У ASTC блок всегда занимает 128 бит, а сколько текселей он покрывает — решают при сжатии. Стороны берутся из набора 4, 5, 6, 8, 10 и 12 текселей, всего 14 комбинаций: 4×4 — это 8 бит на тексель, 6×6 — 3,56, 8×8 — ровно 2, 12×12 — 0,89. Один формат, а битрейт подбирается под ассет, и пайплайн от этого не меняется.
ARM, соавтор формата, советует не подбирать размер блока под каждую текстуру отдельно, а разложить ассеты по категориям: цветным текстурам — блок между 6×6 и 8×8, элементам интерфейса — 4×4 или 5×5, нормалям — блок 5×5 и режим -normal, который хранит две компоненты вместо трёх.
Статус по API у ASTC разный: LDR-профиль обязателен в ядре OpenGL ES 3.2, а в Vulkan это опциональная возможность устройства textureCompressionASTC_LDR. HDR-профиль не обязателен нигде: в OpenGL ES он доступен только расширением KHR_texture_compression_astc_hdr, в Vulkan 1.3 он въехал в ядро, но остался опциональной возможностью textureCompressionASTC_HDR. На iOS он появился лишь с A13.
Что во что кладут
| Что нужно | Десктоп (BCn) | Мобилки (ETC2/EAC) | ASTC | Бит на тексель |
|---|---|---|---|---|
| цвет без альфы | RGB Compressed DXT1 (BC1) | RGB Compressed ETC2 | блок под ассет | 4 |
| цвет + 1 бит альфы | у BC1 режим есть, но в импортёре не выбирается — там для альфы DXT5 | RGB + 1-bit Alpha Compressed ETC2 4 bits | своего punchthrough нет | 4 |
| цвет + полная альфа | RGBA Compressed DXT5 (BC3) | RGBA Compressed ETC2 | блок под ассет | 8 |
| один канал: маски, height | BC4 | R Compressed EAC 4 bit | режим luminance | 4 |
| два канала: нормали | BC5 | RG Compressed EAC 8 bit | режим -normal, блок 5×5 | 8 |
| максимум качества, LDR | RGB(A) Compressed BC7 | аналога нет | блок 4×4 | 8 |
| HDR | RGB Compressed BC6H | аналога нет вообще | ASTC HDR, расширение | 8 |
Названия — как они подписаны в импортёре Unity. Колонка битрейта относится к BCn и ETC2/EAC: там он фиксирован. У ASTC его задаёт размер блока — от 8 до 0,89 бита на тексель.
Первые пять строк переносятся один в один: BC1 ↔ ETC2 RGB, BC3 ↔ ETC2 RGBA (128 бит на блок, первые 64 — альфа, следующие 64 — RGB), BC4 ↔ EAC R11, BC5 ↔ EAC RG11. У EAC при этом даже точность выше: 11 бит на канал против восьми у BC4 и BC5.
А вот две последние строки аналога не имеют. BC7 кладёт в блок несколько пар опорных точек — восемь режимов кодирования на выбор, и альфа в них живёт по-разному: где-то интерполируется вместе с RGB общими индексами, где-то идёт отдельным скаляром со своим набором, а где-то её нет совсем и декодер возвращает единицу. BC6H хранит half-float, то есть HDR, там режимов четырнадцать и альфы нет вовсе. Семейство ETC2/EAC целочисленное и нормализованное, значений больше единицы в его схеме нет в принципе. На мобилках обе роли забирает ASTC: ARM заявляет, что на восьми битах на тексель он сопоставим с BC7 в LDR и с BC6H в HDR.
Что это значит в настройках проекта
По умолчанию Unity на Android ставит ASTC, а ETC2 остаётся в списке подстраховкой на старое железо. В Player Settings это именно список, «Texture compression formats», и первый формат в нём — основной; texture compression targeting кладёт в AAB несколько наборов текстур, а Google Play отдаёт устройству подходящий APK. Промах стоит дорого: формат, которого на устройстве нет, Unity распакует при загрузке в несжатый формат по умолчанию для платформы — для LDR на Android это RGBA 32 бита — и будет держать распакованную копию в памяти рядом с исходной сжатой. Вместо 2–8 бит на тексель выходит 32 — и сжатый оригинал при этом никуда не девается.
На iOS Unity рекомендует ASTC для устройств с чипом A8 (2014) и новее — у Apple он и поддерживается с семейства GPU Apple2, то есть с тех же A8. PVRTC можно вычёркивать: Apple пометила его устаревшим в iOS 18 и советует вместо него ASTC, ETC2 или BC, а Unity убрала его из справочника форматов в 6.3.
Практические грабли блочного сжатияОсторожно! Практика!
- Не пережимайте сжатое. Взять сжатую текстуру, распаковать, поправить яркость и запечь обратно — значит сложить артефакты дважды. Исходники держите в lossless, сжатие — последний шаг сборки.
- Нормали — в двухканальный формат. У BC1 опорные цвета квантуются в 565, у ETC2 базовый цвет — 4–5 бит на канал, а на тексель внутри подблока меняется только яркость: поправка прибавляется сразу ко всем трём каналам. X и Y нормали меняются независимо друг от друга, и схема с общей яркостью такую пару не вытягивает — нормаль идёт ступеньками, а блики по ней пятнами. Для этого и есть двухканальные форматы: BC5 на десктопе,
RG Compressed EAC 8 bitна мобилках, ASTC в режиме-normal. ARM отдельно предупреждает, что ошибку направления нормали сильно усиливает зеркальный блик, — поэтому битрейт нормалям дают выше, чем цвету. - Градиенты страдают первыми. Опорные цвета BC1 квантуются в 565 — красный и синий по 32 уровня, зелёный по 64. На плавном небе это видно полосами, и лечится обычно дизером ещё до сжатия — привет статье про бандинг.
- sRGB — отдельные варианты форматов. Переводить значения в линейное пространство при чтении умеет не сам формат, а его sRGB-вариант, и есть он не у всех: у ETC2 — у RGB, punchthrough и RGBA, а у EAC R11 и RG11 нет вовсе. Гамма при этом касается только RGB, альфа всегда линейная. В Unity этим управляет галочка
sRGB (Color Texture): цветным картам она нужна, маскам — нет. - Мипы тоже сжимаются. Уровень мельче блока всё равно занимает целый блок: 16 байт у ASTC, 8 у ETC2 RGB. При блоке 12×12 уровни 8×8, 4×4, 2×2 и 1×1 весят одинаково — на хвосте цепочки экономия перестаёт работать.
- Crunch — про диск, не про память. В Unity он работает поверх DXT или ETC: при загрузке Crunch снимается на CPU, и в память едет обычный DXT или ETC — видеопамяти это не экономит. К ASTC он неприменим.
«Сжатие портит качество, поставим RGBA8»
Аргумент понятный, и в редакторе он даже подтверждается: разницы не видно ни на превью, ни на скриншоте. Дальше начинается арифметика: видеопамяти уходит в четыре-девять раз больше — смотря какой формат вы не поставили, — стриминг захлёбывается, а на подгрузке новых ассетов кадр проседает. Причём качество, за которое заплатили, до экрана обычно не доезжает: текстура почти всегда читается не с нулевого уровня.
Формат — это не «качество против размера». Это выбор представления, у которого свои сильные и слабые места: BC1 и базовые режимы ETC2 отлично держат гладкое и хуже — резкие цветные стыки (ETC2 ради них и завёл режимы T и H); BC5 и EAC RG сделаны под нормали; ASTC даёт подобрать битрейт под конкретный ассет. По сути, вопрос не «сжимать ли», а «каким блоком».
На этом с текстурой мы закончили: есть фильтр, мип-цепочка, анизотропия и формат, в котором всё это лежит в памяти. Но в кадре мерцает не только поверхность — по краям геометрии идёт своя лесенка, и мипы к ней отношения не имеют. Разберёмся с ней в части 5, а потом соберём кадр целиком.
Часть 5. Кромки: лесенка, которую мипами не вылечить
Всё, чем мы занимались до сих пор, — это цвет внутри поверхности. Но в кадре мерцает не только пол. Посмотрите на силуэт любого столба, провода или перил в движении: край идёт ступеньками, и ступеньки ползут. Болезнь та же самая, а лекарство совсем другое, и в остальных частях серии оно нам не встретится — так что давайте разберём его здесь.
Тот же алиасинг, только сигнал другой
Вспомним, из-за чего мерцала текстура: в пиксель попадало сорок текселей, а спрашивали мы один. Сигнал менялся чаще, чем стояли точки, которыми мы его щупаем.
С кромкой всё то же самое, только сигнал — не рисунок, а функция покрытия: внутри треугольника единица, снаружи ноль, между ними обрыв. Растеризатор задаёт ей ровно один вопрос на пиксель, в его центре (мы это разбирали), и получает «да» или «нет». Промежуточных ответов нет, поэтому диагональ и превращается в лестницу.
Приём из части 2 тут не сработает. Мип-цепочку текстуры можно посчитать заранее, один раз при импорте: картинка известна. А геометрия каждый кадр другая — камера сдвинулась, и покрытие пикселя стало новым. Усреднять придётся на лету.
MSAA: сэмплов покрытия много, сэмпл шейдинга один
Идея мультисэмплинга ровно в этом: спросить про покрытие несколько раз в разных точках пикселя, а вот считать цвет — по-прежнему один раз.
Так это и работает в спецификации D3D: тест покрытия идёт для каждой позиции сэмпла, а не для центра пикселя; если покрыт хоть один сэмпл, пиксельный шейдер запускается один раз, с атрибутами, интерполированными в центре пикселя, и его результат размножается по всем покрытым сэмплам, которые прошли тест глубины. В конце кадра сэмплы усредняются в один цвет — этот шаг называется resolve, и именно из него берутся полутона на кромке.
Отсюда сразу два следствия, которые стоит держать в голове. Первое: платим мы за MSAA памятью — цветовой буфер и буфер глубины раздуваются во столько раз, сколько сэмплов. Второе, и более важное: внутри примитива MSAA не даёт ничего. Все сэмплы пикселя хранят один и тот же цвет, среднее от него равно ему же — так и записано в разборе Khronos. Мерцание пола из части 2 никуда не денется, сколько сэмплов ни поставь.
Сэмплы покрытия: откуда берётся полутон на кромке
Слева пиксели под лупой, сквозь них идёт кромка. Начните без MSAA: сэмпл один, он в центре, и пиксель либо целиком закрашен, либо целиком пуст — справа видно ту самую лесенку. Дальше включайте ×2, ×4, ×8 и смотрите на счётчик «уровней на кромке»: это сколько разных значений покрытия вообще может получить пиксель.
Почему сетка сэмплов повёрнутая
Позиции сэмплов не выдуманы каждым вендором заново: в D3D они стандартизованы, заданы в шестнадцатых долях пикселя от его центра, и те же числа лежат в спеке Vulkan, только отсчитанные от левого верхнего угла. Для четырёх сэмплов это (−2,−6), (6,−2), (−6,2) и (2,6).
Выглядит как случайный набор, но смысл в нём простой: сэмплам нельзя выстраиваться в линию по вертикали или горизонтали. Переключите в демке сетку на регулярную и поставьте наклон кромки в ноль. Уровней станет три вместо пяти: горизонтальная кромка пересекает регулярную сетку так, что верхний ряд срабатывает целиком, потом нижний — промежуточных состояний почти нет. А ведь горизонталь и вертикаль в кадре встречаются чаще всего.
Со стандартной сеткой такого провала нет ни на одном наклоне: четыре сэмпла дают пять уровней хоть на горизонтали, хоть на диагонали. Правда, идеальных паттернов не бывает — покрутите наклон на ×2 и найдите 45°: два сэмпла стандартной пары лежат ровно на этой диагонали и срабатывают одновременно, так что уровней остаётся два. С двумя сэмплами выбирать особо не из чего.
Сколько это стоит
На десктопной видеокарте цена прямо пропорциональна числу сэмплов: при 4× буферы вчетверо больше, плюс трафик на их запись и чтение.
А вот на мобильном тайловом железе всё интереснее. Тайл рендерится в быстрой памяти внутри GPU, и по описанию ARM дополнительные сэмплы так и живут там: наружу, в основную память, уезжает уже резолвнутый цвет одного пикселя. То есть трафика по доп. сэмплам не возникает вовсе — при условии, что резолв сделан правильно, в подпроходе (pResolveAttachments, storeOp = DONT_CARE). Если резолвить вручную отдельным проходом, преимущество испаряется: ARM приводит 3,9 ГБ/с против 500 МБ/с на 4× MSAA в 1080p при 60 кадрах, а разбор Khronos — плюс 5 ГБ/с и примерно 500 мВт, пятая часть энергобюджета телефона.
Дальше начинается вкусовщина вендоров, и это стоит знать перед тем, как ставить галочку. ARM советует 4× как недорогой, в их же Vulkan SDK названы 1–2% просадки. Qualcomm говорит «практически бесплатно» уже про 2×, а не про 4×. Imagination пишет прямо, что 2× на их ядрах почти бесплатен, а 4× и выше заметно бьёт по скорости: растёт он-чип футпринт, из-за этого мельчает тайл, а значит, вершинная работа делается больше раз. Так что число сэмплов — параметр, который меряют на своём железе, а не выбирают по статье.
Чего MSAA не делаетОсторожно! Практика!
- Не чинит шейдерный алиасинг. Мерцающий блик на острой кромке металла, шум нормалей, рябь текстуры — всё это считается внутри шейдера, а шейдер выполняется раз на пиксель. Доки Unity формулируют это без обиняков: MSAA лучше всех справляется с кромками треугольников и не решает проблем шейдерного алиасинга.
- Не видит кромок, нарисованных альфой. Лист дерева — это квад, покрытие у него полное, а форма листа задана альфа-тестом уже в шейдере. Растеризатор про неё не знает. Для этого есть alpha-to-coverage: альфа из шейдера превращается в маску покрытия, которая накладывается на обычную. В Unity это
AlphaToMask On, и работает оно только вместе с MSAA — без мультисэмплинга результат зависит от API и железа. - Плохо дружит с deferred. G-буфер в несколько сэмплов — это его размер во столько же раз, и шейдить приходится на сэмпл. Исторически именно из-за этого deferred-движки уходили в постпроцессные методы; на мобильных ARM в такой ситуации предлагает темпоральное сглаживание.
- Внутренние рёбра меша не сглаживает. Если два треугольника одного объекта стыкуются внутри пикселя, покрытие всё равно полное — сглаживать нечего.
- Шейдинг на сэмпл превращает MSAA в суперсэмплинг. Его можно включить явно (
SV_SampleIndexв D3D, sample shading в Vulkan), и тогда фрагментный шейдер вызывается по числу сэмплов — цена соответствующая. Внутренний алиасинг это уберёт, но для текстур ту же работу уже делают мипы, причём даром.
Собственно, вот и вся мысль этой части: у кадра два разных алиасинга. Один сидит внутри поверхности и снимается мип-цепочкой с анизотропией, другой живёт на краях геометрии и снимается сэмплами покрытия. Инструменты не заменяют друг друга — и в следующей части это будет видно на одном кадре, где включены оба.
Часть 6. Всё вместе: один пиксель пола
Соберём цепочку целиком. Вот что происходит между строчкой texture(albedo, uv) и цветом в буфере — для одного-единственного пикселя:
// 1. uv пришли из вершин, интерполированы по треугольнику с делением на w
// 2. производные — разность uv с соседями по квадру 2×2
// dudx, dvdx, dudy, dvdy
// 3. масштабные множители в текселях
// Px = |(dudx, dvdx)| * (w, h), Py = |(dudy, dvdy)| * (w, h)
// 4. анизотропия: сколько выборок и на каком уровне
// N = min(ceil(Pmax / Pmin), maxAniso)
// λ = log2(Pmax / N) + bias
// 5. два соседних уровня цепочки: L = floor(λ) и L+1, вес — дробная часть
// 6. N раз вдоль длинной оси следа: билинейка на L, билинейка на L+1, смешать
// 7. каждая билинейка — 4 текселя, каждый тексель — распаковка блока 4×4Семь шагов, из которых программист пишет ноль: всё это делает текстурный блок сам. Управляете вы им через сэмплер — три фильтра, режим адресации, MaxAnisotropy, MipLODBias — и через то, какие данные подсунули: есть ли мипы, в каком формате, какого разрешения.
А теперь то же самое, но со всеми тумблерами сразу — на бесконечном полу, который едет на камеру. По сторонам коридора расставлены столбы: у пола кромок нет, а разговор из части 5 без них не покажешь.
Пол целиком: все тумблеры сразу
Порядок, в котором это интереснее всего смотреть.
NEAREST, камера едет. Дальний план кипит — то самое мерцание из заголовка статьи. Нажмите «стоп»: кипение исчезает, картинка выглядит просто шумной. В этом и коварство — на скриншоте проблемы нет. В HUD первая строка показывает, какой уровень требует след пикселя и с какого сэмплер читает на самом деле: в этом режиме второе число всегда ноль, сколько бы первое ни просило.
БИЛИНЕЙНО. Почти ничего не изменилось. Билинейка смешивает четыре текселя, а в дальний пиксель их попадает несколько десятков — четыре из сорока погоды не делают. Фильтр увеличения не лечит уменьшение.
БЛИЖ. МИП. Рябь ушла. Переключитесь на карту уровней: по полу видно ровные кольца — места, где λ пересёк половину и сэмплер прыгнул на следующий уровень.
ТРИЛИНЕЙНО. Кольца растворились: два уровня смешиваются по дробной части λ. Зато дальний пол заметно мылит.
АНИЗО ×8. Дальний пол снова читается: в первой строке HUD видно, как λ проваливается заметно ниже требуемого — это и есть вернувшиеся уровни детализации. Посмотрите при этом на счётчик выборок: среднее по кадру заметно ниже потолка в восемь, потому что N считается для каждого пикселя отдельно, и ближней половине кадра лишние выборки не нужны.
bias. Утяните смещение в минус — картинка станет резче, а мерцание вернётся. Это ровно тот заём, который потом отдают артефактами: λ уменьшили искусственно, а количество текселей в пикселе от этого не изменилось.
MSAA ×4. Оставьте анизотропию и включите сглаживание кромок. Столбы перестают ступенчатиться, а пол не меняется ни на пиксель — что и обещано в части 5: сэмплов покрытия стало четыре, сэмпл шейдинга остался один, а внутри примитива усреднять нечего. Обратный порядок тоже поучителен: включите MSAA на NEAREST — кромки станут гладкими на кипящем полу. Два алиасинга, два инструмента, и подменить один другим не выйдет.
Таблица симптомов
Собственно, ради этого всё и разбиралось. Слева — что вы видите, справа — что крутить.
| Симптом | Что происходит | Что делать |
|---|---|---|
| Дальняя текстура кипит в движении | мипов нет (или bias в минусе): в пиксель попали десятки текселей, спросили один | включить мипмапы, вернуть bias к нулю |
| Пол уходит в мыло вдоль взгляда | уровень выбран по длинной оси вытянутого следа | анизотропия ×4…×16 |
| Кольца на полу | MIP-фильтр — «ближайший уровень» | трилинейка (MIP_LINEAR) |
| Мыло при подходе вплотную | текселей меньше, чем пикселей: это увеличение, мипы ни при чём | плотность текселей: крупнее текстура, мельче тайл, detail-карта |
| Квадратики вблизи | nearest на увеличении | билинейка — или так и задумано (пиксель-арт) |
| Дальний план темнее ближнего | мипы усреднены в sRGB вместо линейного | пересобрать цепочку в линейном пространстве |
| Блочные пятна 4×4 на резких стыках цвета | на блок всего пара опорных цветов: BC1, базовые режимы ETC2 | BC7 на десктопе, ASTC мельче блоком на мобилках, либо развести цвета по разным материалам |
| Полосы на градиенте после сжатия | опорные цвета BC1 квантованы в RGB565 | дизер до сжатия, BC7 на десктопе или ASTC мельче блоком |
| Листва «тает» вдали | alpha-test плюс усреднение альфы по уровням | подгонка alpha coverage при генерации мипов |
| Шов по краю спрайта в атласе | билинейка подтянула соседей за краем | отступы в атласе, clamp, отдельный тайл |
| Артефакты уровня на стыке материалов | uv считаются внутри разошедшегося ветвления — производные испорчены | считать uv до ветвления или брать уровень явно (SampleLevel / textureLod) |
| Лесенка по краям геометрии, ползущая в движении | покрытие пикселя решается одной точкой в его центре | MSAA: сэмплы покрытия. Мипы и анизотропия тут не помогут |
| Листва рвётся по краям при включённом MSAA | форма задана альфа-тестом, растеризатор её не видит | alpha-to-coverage (AlphaToMask On), работает только вместе с MSAA |
| Швы и протечки атласа по кромке | центр пикселя не покрыт, атрибуты экстраполированы | centroid-интерполяция — с оглядкой на производные |
Выводы
Если оставить от статьи одну мысль, пусть это будет вот какая: текстура — не картинка, а функция, у которой спрашивают среднее по площади. Пиксель накрывает кусок поверхности, и правильный ответ — цвет всего куска, а не одной точки внутри него. Всё остальное — способы посчитать это среднее дёшево: мипмапы усредняют заранее, трилинейка сглаживает переходы между заготовками, анизотропия чинит случай, когда след пикселя вытянут, а блочное сжатие делает так, чтобы всё это влезло в память и доехало по шине.
Обратите внимание на закономерность — она сквозная для всей серии про путь кадра. Каждый приём здесь работает: билинейка корректна при увеличении, изотропный выбор уровня корректен для круглого следа, BC1 точен для блока, где цвета лежат на одной прямой. Ломается не приём, а его применение там, где предпосылки уже не выполняются: билинейкой пытаются лечить уменьшение, одним числом описать вытянутый след, двумя опорными цветами — блок, где цвета не легли на одну прямую. Понимать, при каких условиях приём даёт правильный ответ, — и есть разница между «покрутил галочки, стало лучше» и «знаю, что именно я чиню».
Дальше по конвейеру — свет. Мы весь этот сезон доставали из текстуры цвет, как будто он и есть итоговый: но albedo — это не «как выглядит поверхность», а «какую долю света она отражает». Что с этой долей делать, откуда берутся нормали, почему N·L работает и где начинает врать Blinn-Phong — следующая неделя, «Свет по-простому».
Надеюсь, статья была полезна, и в следующий раз, увидев кипящий вдали забор, вы сразу скажете, какая именно галочка не стоит. Если разбор зашёл — заходите в телеграм-канал, такие штуки с интерактивами выходят там каждую неделю. И буду рад дополнениям в комментариях: в текстурировании нюансов сильно больше, чем влезло в пять частей, — одни только виртуальные текстуры и стриминг тянут на отдельную статью.
Разборы графики с кодом и интерактивами — каждую неделю в канале.
