Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.
Сталкивались ли вы с такой ситуацией: художник сдал закатное небо — мягкий переход от оранжевого к синему, — а в билде по этому небу идут полосы, будто градиент срезали лесенкой? Или другой вариант: сцена в подземелье, свет выставлен аккуратно, а тени заплыли грязной мутью. Самое обидное, что картинку никто не трогал. Не менялись ни текстуры, ни свет, ни шейдеры — поменялось только то, как кадр упаковали в биты.
Чтож, эта статья — про слой, о котором обычно вспоминают в последнюю очередь: путь кадра от чисел в буфере до пикселей на экране. Разберём четыре вещи по порядку:
- квантование и бандинг — почему 256 уровней яркости не хватает на гладкое небо;
- дизеринг — как точно отмеренный шум возвращает градиенту гладкость и почему не всякий шум одинаково полезен;
- гамма-кодирование — куда на самом деле ложатся 8 бит и почему тени особенные;
- линейное пространство — где усреднение цветов врёт и откуда берутся тёмные каймы.
Все четыре части соберём в конце в один интерактив: одна сцена, тумблеры конвейера, и видно, как полосы и грязь появляются и уходят.
Зачем это геймдеву
Бандинг — не редкий баг, а повседневный артефакт 8-битного вывода. Он вылезает на любой большой гладкой площади: небо, туман, виньетка, затухание света от источника, медленный фейд в чёрное. И у него есть приятное свойство: он чинится почти бесплатно — буквально парой галочек в движке, — если понимать, какими именно и почему они работают. Собственно, этим пониманием и займёмся.
Сразу оговорюсь: ничего нового в этой математике нет — квантованию и гамма-кодированию не один десяток лет, это база обработки сигналов. Но в геймдев-разработке эти вещи регулярно всплывают как «мистика рендера», так что, по-моему, им не помешает разбор на пальцах и с интерактивами.
Если вам интересна эта тема — добро пожаловать под кат!
Часть 1. Бандинг: полосы, которых никто не рисовал
Итак, начнём с эксперимента, который можно повторить в любом редакторе. Залейте холст плавным градиентом от чёрного к серому и растяните на весь экран. Гладко? Кажется, что да. А теперь присмотритесь к тёмной части — и глаз начнёт цеплять вертикальные полосы. Художник рисовал непрерывный переход, а на экране — ступеньки.
Дело в том, что цвет в кадре хранится побайтно: 8 бит на канал, то есть 2⁸ = 256 уровней яркости на канал, и между соседними уровнями нет ничего. Непрерывная яркость Здесь и дальше яркость нормирована: 0 — чёрный, 1 — максимум канала. округляется до ближайшего из этих уровней:
Это квантование. При n = 8 шаг равен 1/255 — казалось бы, мелочь. Но посмотрим, что происходит на медленном градиенте. Если яркость меняется, скажем, на 0.1 на всю ширину экрана в 2000 пикселей, то один уровень квантования растягивается на 2000 · (1/255) / 0.1 ≈ 78 пикселей. Получается плоская полоса постоянного цвета шириной под сотню пикселей, потом резкий прыжок на следующий уровень — и снова полоса. Это и есть бандинг (banding): видимые ступени на месте плавного перехода, когда уровней яркости не хватает.
Почему швы видно, если разница — 1/255
Тут подключается зрение, и у него к швам квантования особые счёты. Во-первых, глаз не меряет яркость абсолютно — он меряет перепады. Минимально заметная разница яркости примерно пропорциональна фону, на котором её показывают, — это закон Вебера. Поэтому один и тот же шаг квантования в светах незаметен, а в тенях уже превышает порог различимости — и ступенька становится видимой.
Во-вторых, на границе двух почти одинаковых тонов зрительная система сама добавляет контраст. Это полосы Маха — эффект латерального торможения в сетчатке: рецепторы подавляют соседей, и на каждом шве глаз дорисовывает светлую и тёмную каёмки, которых в сигнале нет. Эффект изучен давно и подробно — классика тут Ratliff, Mach Bands (1965).
Соберём всё в интерактив. Слайдер меняет битовую глубину — от стандартных 8 бит до совсем грубых 2. График под градиентом показывает яркость вдоль перехода: лесенка — то, что лежит в пикселях, пунктир — то, что достраивает глаз.
Бандинг: где кончаются уровни
Обратите внимание: даже на 8 битах в тёмной части градиента полосы различимы, особенно если смотреть в полумраке. 256 уровней «обычно хватает» — но ломается это ровно там, где сходятся гладкий градиент и большая площадь. Небо, туман, затухание света — то есть добрая половина атмосферных сцен.
Напрашивается вопрос: раз уровней мало, давайте добавим бит? Можно — 10-битные дисплеи существуют. Но у подавляющего большинства игроков экран 8-битный, и с этим нужно что-то делать уже сейчас. Есть приём лучше — и о нём часть 2.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Часть 2. Дизеринг: шум, который чистит картинку
Полосы на небе хочется починить в лоб — размыть переход блюром. Полосы и правда поедут, но вместе с ними поедет и детализация: небо превратится в мыло, а на следующем кадре, после любой перерисовки, полосы вернутся. Лечим симптом, а не причину.
Идея дизеринга обратная интуиции: чтобы картинка стала чище, в неё нарочно подмешивают шум. Дизеринг (dithering) — это точно отмеренный шум, который добавляют к сигналу до округления:
где θ — шумовой порог из диапазона [0, 1), свой для каждого пикселя. Смысл такой: раньше длинный участок градиента целиком ложился на одну ступеньку — ошибка округления была детерминированной, одинаковой у тысяч соседних пикселей, и складывалась в видимую полосу. Шум развязывает ошибку с сигналом: теперь часть пикселей округляется вверх, часть вниз, вперемешку, и в среднем по площади получается ровно та яркость, которой в уровнях уже нет. Глаз усредняет крупу обратно в плавный переход.
Приём этот старше геймдева: в цифровом аудио тем же способом убирают искажения квантования при мастеринге, и там же доказаны его свойства — классический разбор дал ещё Lipshitz, Wannamaker и Vanderkooy в Quantization and Dither: A Theoretical Survey (1992), включая любимый звукорежиссёрами TPDF-дизер с треугольным распределением шума.
Не любой шум одинаково полезен
Чтож, раз шум — лекарство, давайте возьмём Random.Range и готово? Да? Конечно же нет. Белый шум действительно уберёт полосы, но картинка станет заметно грязной: у белого шума энергия размазана по всем частотам, включая низкие, — а к низкочастотным пятнам зрение как раз чувствительно. Зерно будет лезть в глаза.
Поэтому за полвека индустрия перебрала несколько семейств дизера:
- Ordered / матрица Байера (Bayer, 1973). Детерминированная пороговая матрица, тайлится по экрану. Матрица 4×4 выглядит как-то так:
0 8 2 10
12 4 14 6
3 11 1 9
15 7 13 5
Каждое число — порог: делим на 16 и сравниваем. Дёшево до неприличия — ни памяти, ни состояния, одна выборка по (x % 4, y % 4). Расплата — регулярная кросс-сетка, которую глаз замечает на гладких участках.
-
Распространение ошибки / Флойд–Стейнберг (Floyd & Steinberg, 1976). Ошибку округления пикселя не выбрасываем, а разносим на ещё не обработанных соседей с весами 7/16, 3/16, 5/16, 1/16. Структура получается апериодичная и приятная глазу, но алгоритм последовательный — пиксели зависят друг от друга, и на GPU он параллелится плохо.
-
Blue noise (Ulichney, Digital Halftoning, 1987; метод void-and-cluster — 1993). Шум, у которого энергия сдвинута в высокие пространственные частоты — ровно туда, где чувствительность зрения падает. Зерно есть, но глаз его почти не вычленяет. На практике это предрассчитанная текстура порогов, тайлящаяся по экрану, — одна выборка, как у Байера, только без сетки.
Почему именно высокие частоты прячут зерноОсторожно! Математика!
У зрительной системы есть функция контрастной чувствительности (CSF): максимум — на средних пространственных частотах, спад — на высоких. Белый шум кладёт энергию равномерно, в том числе в область максимума CSF — поэтому «грязь» видна. Blue noise концентрирует энергию за пиком чувствительности: физически зерно никуда не делось, но воспринимаемая амплитуда у него в разы меньше. По сути это перцептивная оптимизация: прячем ошибку туда, куда глаз не смотрит.
Сравните все режимы на одном градиенте. Битовая глубина специально занижена до 4 бит, чтобы разница читалась сразу:
Дизер: какой шум чинит полосы
Где дизер живёт в играх
Дизер на выводе — далеко не единственное применение. Тот же приём под именем dither-fade решает задачу плавного исчезновения объектов: вместо настоящей полупрозрачности (дорогой и требующей сортировки) объект рисуется «пунктиром» — часть пикселей отбрасывается по пороговой маске, и доля отброшенных плавно растёт. Так делают LOD-переходы, растворение травы и волос, мягкие края волюметрики. А blue noise к тому же стал стандартом для распределения ошибки в стохастических эффектах — вплоть до трассировки: Heitz и Belcour в 2019 показали, как раскладывать монте-карловский шум по кадру в blue-noise-паттерн, чтобы картинка выглядела чище при том же счётчике сэмплов.
Так что когда движок предлагает галочку Dithering — теперь вы знаете, что за ней: шум перед округлением, спрятанный в частоты, к которым зрение равнодушно.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Часть 3. Гамма: почему 256 уровней лежат криво
Вернёмся к наблюдению из части 1: полосы и муть сильнее всего лезут в тёмных сценах. Ночной Skyrim, подвалы, углы за пределами света факела — светлая часть кадра держится, тёмная разваливается. Почему тени особенные?
Потому что глаз устроен нелинейно, и мы об этом уже говорили: по Веберу заметность перепада зависит от фона. В тёмном глаз различает крошечные перепады яркости, в светлом такие же перепады не видит в упор — в психофизике это обобщается до закона Вебера–Фехнера: воспринимаемая яркость растёт примерно логарифмически по физической. И теперь смотрите, что получается, если раскидать 256 уровней равномерно по физической яркости: в тенях, где глаз придирчив, шаг окажется грубым — и полезут банды; в светах, где глаз снисходителен, уровни лягут с запасом — и пропадут зря. Биты потрачены, качества нет.
Перцептивное кодирование
Решение индустрии: хранить не яркость, а степенную функцию от неё. Кодируем при записи, декодируем при показе:
Это и есть гамма-коррекция. Степенная кривая отдаёт больше уровней тёмному и меньше светлому — шаги между соседними уровнями выравниваются по восприятию, а не по физике. Те же 8 бит растягиваются под глаз, и 256 градаций хватает на гладкую тень. Приём умный, и я специально подчеркну: гамма — это не наследие «кривых мониторов», а вполне осмысленное сжатие. Канон по теме — Poynton, Gamma FAQ и Digital Video and HD: он прямо называет цель гаммы перцептивным кодированием битов.
Стандартная кривая для дисплеев — sRGB (IEC 61966-2-1). Это не чистая степень: у нуля идёт линейный участок (для численной устойчивости в самых тёмных значениях), дальше — экспонента 2.4, а в среднем кривая близка к степени 2.2:
Из этой формулы следует сюрприз, который ломает интуицию у половины разработчиков: 0.5 в sRGB — не половина света. Считаем точно: sRGB-значение 0.5 — это 0.2140 линейной яркости, а настоящая половина света кодируется sRGB-значением 0.7354. Оба числа в статье не с потолка: они вычисляются формулой выше, и в репозитории статьи их проверяет юнит-тест.
Откуда взялась цифра 2.2 историческиОсторожно! Математика!
Красивое совпадение: у ЭЛТ-мониторов зависимость яркости от напряжения сама по себе была степенной, с показателем, близким к обратной sRGB-кривой. Кодирование сигнала обратной степенью решало сразу две задачи — компенсировало кинескоп и удачно ложилось под закон Вебера–Фехнера. ЭЛТ ушли, дисплеи стали линейными по управлению, а кодировка осталась — потому что перцептивная выгода никуда не делась. Подробности и точные показатели — у Poynton в Gamma FAQ.
Так что же такое «грязь в тенях»
Теперь можно разложить симптом из вступления на два механизма.
Первый — банды от нехватки уровней. Даже с sRGB-кодировкой глубоким теням достаётся горстка градаций, и на тёмных градиентах ступеньки различимы. Хуже того, в тёмной сцене зрачок раскрывается, зрение адаптируется — и придирчивость к перепадам в тенях вырастает ещё сильнее. А вот если кадр хранится или считается линейно в 8 битах — в тенях остаётся совсем мало уровней, и банды превращаются в лесенку во весь экран. Сравните на линейке уровней в демо ниже: одна и та же битность, но уровни легли совсем по-разному.
Второй механизм — муть от счёта в неправильном пространстве: если смешивать и усреднять цвета прямо в sRGB-значениях, тёмные тона систематически уезжают. Это отдельная история, и ей посвящена часть 4.
Гамма: куда легли 256 уровней
Unity-угол: чем лечится бандинг в тёмных сценах
В Unity оба рычага доступны из коробки, и вместе с дизером из части 2 они складываются в рабочий триплет:
- Color Space = Linear (Player → Other Settings). Свет и блендинг считаются математически корректно — про это часть 4. В Gamma-режиме смешивание идёт по sRGB-значениям, отсюда пересвеченные и «мутные» блэнды — Unity Manual описывает их как overly-bright blends.
- HDR у камеры. Рендер идёт в линейный float-буфер, и теням хватает точности: формат R11G11B10 по ширине равен обычному 8-битному, так что на большинстве платформ это почти бесплатно. Показательный разбор — технота Meta Removing Dark Scene Color Banding in Unity: в VR, где глаза адаптируются к темноте шлема, 8-битного буфера не хватает ровно по описанным выше причинам.
- Dithering в пост-обработке. Финальный выход в 8-битный бэкбуфер дизерится — остаточные полосы разбиваются шумом, как в части 2.
Одна оговорка: пункты из техноты Meta приведены как иллюстрация принципа, а не как актуальный рецепт под конкретную версию движка — API пост-обработки в Unity переезжает регулярно, сверяйтесь с манулом своего пайплайна.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Часть 4. Линейное пространство: где сложение цветов врёт
Обещанный второй механизм грязи. Симптом выглядит так: накладываете полупрозрачный туман на сцену — и по контрастным краям ползёт тёмная окантовка. Сжимаете текстуру в мип — на швах мутные тёмные ореолы. Включаете сглаживание — грязь по краям геометрии. Четыре разных бага? Нет, один, просто корень у него общий.
Причина в том, что почти вся эта математика — усреднение. Альфа-блендинг, даунсэмпл, мипмапы, resolve мультисэмплинга — всё это взвешенные суммы цветов. А свет в физическом мире аддитивен: освещённость от двух источников — это сумма потоков фотонов, и никакой гамма-кривой фотоны не знают. Значит, складывать и усреднять можно только линейные яркости. Если же движок, старый шейдер или графический редактор берёт два пикселя и усредняет их sRGB-значения — он усредняет точки на степенной кривой, а не количества света. Классический текст об этом — Gritz и d'Eon, The Importance of Being Linear (GPU Gems 3, глава 24).
Почему среднее значений всегда уезжает в тёмное, а не куда попало? Из-за выпуклости кривой декодирования. Для любой выпуклой функции среднее значений функции меньше функции от среднего — это неравенство Йенсена:
По сути: смешали два sRGB-значения — получили яркость ниже правильной середины. На стыке контрастных цветов эта недостача и проявляется тёмной каймой. Тот же эффект бьёт по формуле альфа-композитинга Портера–Даффа out = src·α + dst·(1−α) (Porter & Duff, 1984): она написана для линейных значений, и в gamma-пространстве полупрозрачные края травы, листвы, дыма и частиц получают классический dark fringe.
Правильный конвейер: decode → математика → encode
Линейный workflow — это дисциплина из трёх шагов: раскодировать sRGB в линейный свет, посчитать всю математику (свет, блендинг, фильтрацию), закодировать обратно в sRGB только на выводе. В шейдерной псевдозаписи выглядит как-то так:
float3 albedo = srgbToLinear(tex2D(_MainTex, uv).rgb); // decode (обычно аппаратно)
float3 lit = albedo * lightColor * ndotl; // математика — в линейном
float3 outCol = linearToSrgb(lit); // encode на выводе (тоже аппаратно)На практике руками это почти не пишется: текстуры-альбедо помечаются как sRGB и раскодируются самим сэмплером, кодирование на выводе делает sRGB-таргет. Важное исключение: нормали, roughness, маски — это данные, а не цвет; их помечать sRGB нельзя, иначе сэмплер «раскодирует» то, что кодировкой не было. Подробный разбор color encoding есть в PBR Book Фарра, Якоба и Хамфриса.
И сюда же стыкуется дизер из части 2: раз финальное кодирование в 8 бит — последний шаг, то и дизер живёт именно там. Порядок такой: линейная математика → encode в sRGB → дизер → квантование. Если подмешать шум до кодирования, его амплитуда разъедется по яркости — в тенях зерно окажется грубее, чем в светах.
Тест на 50% серого
Самый наглядный способ увидеть, как среднее врёт, — шахматка из чёрных и белых пикселей. Издали она усредняется самим глазом и даёт настоящие полсвета. Рядом — плашка «наивных 50%», sRGB-значение 0.5: она заметно темнее шахматки. И плашка правильной половины света — sRGB-значение 0.7354 из части 3: она с шахматкой совпадает.
Линейное пространство: где среднее врёт
Кстати, исторически на этом тесте валился даже Photoshop: смешивание слоёв по умолчанию шло в gamma-пространстве (сейчас есть переключатель Blend RGB Colors Using Gamma 1.0). Так что если ваш пайплайн где-то усредняет sRGB-значения — вы в большой, хоть и не образцовой компании.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Соберём конвейер целиком
Итак, четыре части — четыре решения, которые кадр проходит по дороге к экрану:
- Сколько уровней. 8 бит на канал — 256 градаций; на гладких больших площадях этого мало, и вылезает бандинг (часть 1).
- Какой шум. Дизер перед квантованием растворяет полосы; спектр шума выбирается под задачу, и blue noise прячет зерно лучше всего (часть 2).
- Куда положить биты. sRGB-кодировка сгущает уровни в тенях, где глаз придирчив; линейное 8-битное хранение отдаёт тени на растерзание бандам (часть 3).
- Где считать. Свет складывается в линейном пространстве; усреднение sRGB-значений темнит и грязнит стыки (часть 4).
Финальный интерактив собирает всё в одну сцену: небо, тёмный пол, лампа и клок тумана. Стартовая конфигурация — хак на хаке: математика в gamma-пространстве, линейный 8-битный буфер, без дизера. Доведите картинку до чистой — по опыту частей выше вы уже знаете, какой тумблер что чинит.
Конвейер кадра: соберите чистую картинку
Это ровно тот же триплет, что в Unity: Color Space = Linear, HDR-буфер у камеры, Dithering в пост-обработке. Теперь за каждой галочкой для вас — конкретная математика, а не магия рендера.
Что осталось за рамками
Разумеется, слой отображения на этом не заканчивается. Мы не тронули тонмаппинг и HDR-стандарты вывода (PQ/HLG), широкие цветовые охваты, 10-битные дисплеи, временной дизер, который меняет паттерн от кадра к кадру, — и ещё десяток нюансов, на которые в реальном продакшене уходит своя неделя разборов. Да и в разобранном материале хватает упрощений: скажем, модель восприятия у меня везде «по Веберу», хотя психофизика яркости заметно богаче. Так что моё изложение — не единственно верное, а рабочая карта местности.
Но главный тезис от этого не меняется: у всякого упрощения есть область применимости. «8 бит хватит всем» — упрощение отличное, дешёвое и почти всегда верное. Профессионализм не в том, чтобы от него отказаться, а в том, чтобы знать, где именно оно перестаёт работать — и что подкрутить, когда на небе проступили полосы.
Надеюсь, статья была полезна. Такие разборы — с математикой, кодом и интерактивами — я регулярно публикую в телеграм-канале, заходите:
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Ну и, конечно же, буду рад обсудить в комментариях: где вы ловили бандинг, чем добивали, и какие ещё артефакты слоя отображения стоит разобрать так же подробно.
