Deltadev-math.ruподписаться
// ТЕКСТУРЫ

Почему далёкая текстура мерцает: сэмплинг, мипмапы и анизотропия

Как GPU берёт цвет из текстуры: UV и сэмплинг, nearest против билинейной, мипмапы и выбор уровня, анизотропная фильтрация на скользящем угле, блочное сжатие BCn, ETC2 и ASTC, MSAA и сглаживание кромок. Шесть интерактивов в браузере.

31 июля 2026·55 мин чтения·сэмплингмипмапыанизотропияблочное сжатиеMSAA
Дейв закрашивает треугольник по пиксельной сетке, Delta подсвечивает сэмплы и буфер глубины

Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.

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

Собственно, всё это одна тема: как именно из картинки достаётся цвет для конкретного пикселя. Разберём:

  • почему тексель — не пиксель и что на самом деле делают nearest и билинейная фильтрация;
  • откуда берётся мерцание вдали — алиасинг при уменьшении — и как его лечат мипмапы;
  • почему при взгляде вдоль пола картинка мылится и что с этим делает анизотропная фильтрация;
  • сколько всё это весит в памяти и как устроено блочное сжатие — BCn на десктопе, ETC2 и ASTC на мобилках;
  • почему лесенка на краях геометрии лечится не мипами, а MSAA, и что он не умеет;
  • и как эти решения складываются в один запрос к сэмплеру.
// @easy_dev_math

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

Подписаться

Часть 1. Текстура — не картинка, а функция

Начнём с того, что в шейдере выглядит совсем невинно:

vec4 color = texture(albedo, uv);

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

UV — это адрес в долях, а не в пикселях

Текстурные координаты приходят из вершин: художник разложил модель на плоскость, и у каждой вершины появилась пара чисел u и v. Растеризатор интерполирует их по треугольнику — с делением на w, иначе текстура поплывёт, — и в пиксельный шейдер попадает конкретная пара чисел.

Важно, что это доли, а не пиксели. Ноль — левый край, единица — правый, независимо от того, лежит там картинка 64×64 или 4096×4096. Замените ассет на вдвое более подробный — код шейдера не изменится ни на символ. Текстура для шейдера — не массив, а функция: подали координату, получили цвет.

Вот только на диске у вас всё-таки массив. И вся эта часть — про то, как из конечного массива сделать функцию, определённую в любой точке.

Тексель — точка, а не квадратик

В прошлой статье мы договорились, что пиксель для растеризатора — точка-сэмпл в своём центре, а не маленький квадрат. С текселем ровно то же самое: значение текселя (i, j) живёт в точке

(i+0.5w, j+0.5h)\left(\frac{i + 0.5}{w},\ \frac{j + 0.5}{h}\right)

— в центре своей клетки. Клетка — это область, за которую тексель «отвечает» при простейшем фильтре, но само значение — точечная выборка сигнала. Классическое эссе Элви Рэя Смита «A Pixel Is Not a Little Square» (1995) — про это, и для текстур оно даже важнее, чем для экрана.

Из такой постановки прямо следуют оба фильтра увеличения.

Nearest (он же point): берём тексель, в чью клетку попала координата, — i = ⌊u·w⌋. Ровно одно чтение из памяти, ноль арифметики, идеально резкие границы. Именно поэтому его сознательно ставят в пиксель-арте: Minecraft со сглаженными текстурами перестал бы быть Minecraft.

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

x=uw0.5,i0=x,fx=xi0x = u\,w - 0.5,\quad i_0 = \lfloor x \rfloor,\quad f_x = x - i_0
c=(1fx)(1fy)c00+fx(1fy)c10+(1fx)fyc01+fxfyc11c = (1-f_x)(1-f_y)\,c_{00} + f_x(1-f_y)\,c_{10} + (1-f_x)f_y\,c_{01} + f_x f_y\,c_{11}

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

Пощупайте руками — тут это быстрее, чем читать формулы:

[ DEMO 01 ]//

Тексель, а не пиксель

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

Фильтр — свойство сэмплера, а не текстуры

В 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, «многое в малом».

Цепочка стоит дополнительной памяти, но немного: каждый следующий уровень вчетверо меньше предыдущего, а сумма ряда

1+14+116+164+=431 + \frac{1}{4} + \frac{1}{16} + \frac{1}{64} + \dots = \frac{4}{3}

то есть плюс примерно треть к размеру текстуры. За это вы получаете возможность в любой момент взять уже усреднённую версию нужной подробности.

Как выбирается уровень

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

Px=(ux)2+(vx)2,Py=(uy)2+(vy)2P_x = \sqrt{\left(\frac{\partial u}{\partial x}\right)^2 + \left(\frac{\partial v}{\partial x}\right)^2},\qquad P_y = \sqrt{\left(\frac{\partial u}{\partial y}\right)^2 + \left(\frac{\partial v}{\partial y}\right)^2}

(координаты u, v здесь — в текселях, а не в долях), берём больший из них и логарифмируем:

λ=log2(max(Px,Py))\lambda = \log_2 \big(\max(P_x, P_y)\big)

Читается это просто. Один тексель на пиксель — λ = 0, нулевой уровень. Четыре текселя на пиксель — λ = 2, второй уровень, который как раз в четыре раза мельче. Каждое удвоение следа пикселя — плюс один уровень. Дробную часть λ можно использовать для смешивания двух соседних уровней — это и есть трилинейная фильтрация: две билинейные выборки плюс интерполяция между ними. Без неё, на целых уровнях, по полу пойдут заметные кольца — места, где сэмплер переключился с одного уровня на другой.

Покрутите сами: слева кадр, справа вся цепочка уровней с подсветкой тех, что взял сэмплер.

[ DEMO 02 ]//

Уменьшение, муар и мип-цепочка

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

Ловушка: усреднять надо в линейном свете

Второй переключатель демки — про то, как именно считались уровни. Возьмите чёрно-белую шахматку и усредните четыре текселя «как есть», по байтам: получите 128. Но байты в текстуре — это sRGB-кодировка, а не количество света. Правильное среднее считается в линейном пространстве: полсвета в sRGB-кодировке — это примерно 188, а вовсе не 128.

Разница огромная, и видно её как раз вдали: наивно посчитанные мипы делают дальний план темнее ближнего. Движки для sRGB-текстур это учитывают, но как только вы генерируете уровни сами (в тулзе, на GPU, в рантайме) — про кодировку легко забыть. Мы это уже разбирали в статье про гамму, и здесь та же ошибка вылезает с другой стороны.

А что с картами, которые не цветОсторожно! Практика!

Мип-цепочка исправно усредняет числа — но не всякие числа усредняются осмысленно.

  • Нормали. Среднее четырёх единичных векторов короче единицы: на дальних уровнях нормаль «сдувается», поверхность выглядит более гладкой, чем задумано. Отсюда приёмы вроде Toksvig и подходов, где потеря длины нормали переносится в шероховатость.
  • Шероховатость (roughness). Усреднять её отдельно от нормалей неправильно: две зеркальные грани, смотрящие в разные стороны, на расстоянии ведут себя как одна шероховатая. К этому вернёмся, когда доберёмся до PBR.
  • Маски с alpha-test. Листва и сетки на дальних уровнях «тают»: среднее альфы падает ниже порога отсечения, и половина листьев исчезает. Лечится подгонкой альфы по уровням (alpha coverage) — в Unity это галочка в настройках импорта.

Возвращаясь к «резче без мипов»

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

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

Часть 3. Скользящий угол: откуда мыло

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

След пикселя — не квадрат

Вернёмся к тому, что накрывает пиксель на поверхности. Когда поверхность параллельна экрану, это аккуратный квадратик: Px и Py примерно равны. Но пол под ногами наклонён почти под ноль, и картина другая: поперёк экрана пиксель накрывает пару текселей, а вглубь — десятки. След вытянут вдоль взгляда.

А в формуле выбора уровня стоит одно число:

λ=log2(max(Px,Py))\lambda = \log_2 \big(\max(P_x, P_y)\big)

Максимум. То есть уровень выбирается по длинной оси следа. Если вглубь пиксель накрывает 32 текселя, а поперёк 2, сэмплер возьмёт уровень, где всё уменьшено в 32 раза — и поперечная деталь, которой хватало разрешения с запасом, усредняется вместе со всем остальным. Вот и мыло.

Заметьте, выбора у изотропного сэмплера особо нет. Взял бы он минимум — вдоль длинной оси вернулся бы недосэмпленный сигнал, то есть кипение из части 2. Одним числом на весь след можно выбрать либо мыло, либо рябь.

Анизотропная фильтрация: несколько выборок вдоль оси

Раз след вытянут, будем щупать его не одной точкой, а несколькими — вдоль длинной оси. Спека EXT_texture_filter_anisotropic описывает это буквально в три строчки:

N=min(PmaxPmin, maxAniso),λ=log2 ⁣(PmaxN)N = \min\left(\left\lceil \frac{P_{max}}{P_{min}} \right\rceil,\ \text{maxAniso}\right), \qquad \lambda' = \log_2\!\left(\frac{P_{max}}{N}\right)

Pmax / Pmin — вытянутость следа. Столько выборок и нужно, чтобы каждая отвечала за свой кусочек длинной оси; уровень для них берётся не по всей длине, а по её N-й части — то есть log2(N) уровней детализации возвращается обратно. Восемь выборок — это три уровня детализации, отыгранных у мыла.

Потолок N задаёт сэмплер: в Direct3D 11 поле MaxAnisotropy принимает значения от 1 до 16. Знакомая строчка «Anisotropic Filtering: 16x» в настройках графики — ровно про это число.

[ DEMO 03 ]//

Скользящий угол и анизотропия

Начните с ×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 весит:

2048×2048×4=16777216 байт=16 МиБ2048 \times 2048 \times 4 = 16\,777\,216\ \text{байт} = 16\ \text{МиБ}

Плюс мип-цепочка, то есть ещё треть: 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-пространстве. Если легли — потерь почти нет. Если в блоке встретились цвета, которые на одну прямую не ложатся, — лишнее подтянется к ближайшей позиции палитры.

[ DEMO 04 ]//

Блок 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 режим есть, но в импортёре не выбирается — там для альфы DXT5RGB + 1-bit Alpha Compressed ETC2 4 bitsсвоего punchthrough нет4
цвет + полная альфаRGBA Compressed DXT5 (BC3)RGBA Compressed ETC2блок под ассет8
один канал: маски, heightBC4R Compressed EAC 4 bitрежим luminance4
два канала: нормалиBC5RG Compressed EAC 8 bitрежим -normal, блок 5×58
максимум качества, LDRRGB(A) Compressed BC7аналога нетблок 4×48
HDRRGB 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 никуда не денется, сколько сэмплов ни поставь.

[ DEMO 05 ]//

Сэмплы покрытия: откуда берётся полутон на кромке

Слева пиксели под лупой, сквозь них идёт кромка. Начните без 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 без них не покажешь.

[ DEMO 06 ]//

Пол целиком: все тумблеры сразу

Порядок, в котором это интереснее всего смотреть.

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

БИЛИНЕЙНО. Почти ничего не изменилось. Билинейка смешивает четыре текселя, а в дальний пиксель их попадает несколько десятков — четыре из сорока погоды не делают. Фильтр увеличения не лечит уменьшение.

БЛИЖ. МИП. Рябь ушла. Переключитесь на карту уровней: по полу видно ровные кольца — места, где λ пересёк половину и сэмплер прыгнул на следующий уровень.

ТРИЛИНЕЙНО. Кольца растворились: два уровня смешиваются по дробной части λ. Зато дальний пол заметно мылит.

АНИЗО ×8. Дальний пол снова читается: в первой строке HUD видно, как λ проваливается заметно ниже требуемого — это и есть вернувшиеся уровни детализации. Посмотрите при этом на счётчик выборок: среднее по кадру заметно ниже потолка в восемь, потому что N считается для каждого пикселя отдельно, и ближней половине кадра лишние выборки не нужны.

bias. Утяните смещение в минус — картинка станет резче, а мерцание вернётся. Это ровно тот заём, который потом отдают артефактами: λ уменьшили искусственно, а количество текселей в пикселе от этого не изменилось.

MSAA ×4. Оставьте анизотропию и включите сглаживание кромок. Столбы перестают ступенчатиться, а пол не меняется ни на пиксель — что и обещано в части 5: сэмплов покрытия стало четыре, сэмпл шейдинга остался один, а внутри примитива усреднять нечего. Обратный порядок тоже поучителен: включите MSAA на NEAREST — кромки станут гладкими на кипящем полу. Два алиасинга, два инструмента, и подменить один другим не выйдет.

Таблица симптомов

Собственно, ради этого всё и разбиралось. Слева — что вы видите, справа — что крутить.

СимптомЧто происходитЧто делать
Дальняя текстура кипит в движениимипов нет (или bias в минусе): в пиксель попали десятки текселей, спросили одинвключить мипмапы, вернуть bias к нулю
Пол уходит в мыло вдоль взглядауровень выбран по длинной оси вытянутого следаанизотропия ×4…×16
Кольца на полуMIP-фильтр — «ближайший уровень»трилинейка (MIP_LINEAR)
Мыло при подходе вплотнуютекселей меньше, чем пикселей: это увеличение, мипы ни при чёмплотность текселей: крупнее текстура, мельче тайл, detail-карта
Квадратики вблизиnearest на увеличениибилинейка — или так и задумано (пиксель-арт)
Дальний план темнее ближнегомипы усреднены в sRGB вместо линейногопересобрать цепочку в линейном пространстве
Блочные пятна 4×4 на резких стыках цветана блок всего пара опорных цветов: BC1, базовые режимы ETC2BC7 на десктопе, 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 — следующая неделя, «Свет по-простому».

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

// @easy_dev_math

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

Подписаться