Deltadev-math.ruподписаться
// биты

Дизеринг и гамма-коррекция: почему градиенты «бандятся» и откуда грязь в тёмных сценах

Почему градиенты идут полосами и откуда грязь в тёмных сценах: квантование 8 бит, дизеринг и blue noise, sRGB-гамма и линейное пространство. С интерактивами.

3 июля 2026·22 мин чтения·бандингдизерингsRGB
Дейв и Delta раскладывают свет призмой в спектр

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

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

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

  • квантование и бандинг — почему 256 уровней яркости не хватает на гладкое небо;
  • дизеринг — как точно отмеренный шум возвращает градиенту гладкость и почему не всякий шум одинаково полезен;
  • гамма-кодирование — куда на самом деле ложатся 8 бит и почему тени особенные;
  • линейное пространство — где усреднение цветов врёт и откуда берутся тёмные каймы.

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

Зачем это геймдеву

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

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

Если вам интересна эта тема — добро пожаловать под кат!

Часть 1. Бандинг: полосы, которых никто не рисовал

Итак, начнём с эксперимента, который можно повторить в любом редакторе. Залейте холст плавным градиентом от чёрного к серому и растяните на весь экран. Гладко? Кажется, что да. А теперь присмотритесь к тёмной части — и глаз начнёт цеплять вертикальные полосы. Художник рисовал непрерывный переход, а на экране — ступеньки.

Дело в том, что цвет в кадре хранится побайтно: 8 бит на канал, то есть 2⁸ = 256 уровней яркости на канал, и между соседними уровнями нет ничего. Непрерывная яркость Здесь и дальше яркость нормирована: 0 — чёрный, 1 — максимум канала. округляется до ближайшего из этих уровней:

q(L)=round ⁣(L(2n1))2n1,шаг=12n1q(L) = \frac{\operatorname{round}\!\big(L \cdot (2^n - 1)\big)}{2^n - 1}, \qquad \text{шаг} = \frac{1}{2^n - 1}

Это квантование. При n = 8 шаг равен 1/255 — казалось бы, мелочь. Но посмотрим, что происходит на медленном градиенте. Если яркость меняется, скажем, на 0.1 на всю ширину экрана в 2000 пикселей, то один уровень квантования растягивается на 2000 · (1/255) / 0.1 ≈ 78 пикселей. Получается плоская полоса постоянного цвета шириной под сотню пикселей, потом резкий прыжок на следующий уровень — и снова полоса. Это и есть бандинг (banding): видимые ступени на месте плавного перехода, когда уровней яркости не хватает.

Почему швы видно, если разница — 1/255

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

Во-вторых, на границе двух почти одинаковых тонов зрительная система сама добавляет контраст. Это полосы Маха — эффект латерального торможения в сетчатке: рецепторы подавляют соседей, и на каждом шве глаз дорисовывает светлую и тёмную каёмки, которых в сигнале нет. Эффект изучен давно и подробно — классика тут Ratliff, Mach Bands (1965).

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

[ DEMO 01 ]//

Бандинг: где кончаются уровни

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

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

// @easy_dev_math

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

Подписаться

Часть 2. Дизеринг: шум, который чистит картинку

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

Идея дизеринга обратная интуиции: чтобы картинка стала чище, в неё нарочно подмешивают шум. Дизеринг (dithering) — это точно отмеренный шум, который добавляют к сигналу до округления:

qdither(L)=q ⁣(L+(θ12)шаг)q_{\text{dither}}(L) = q\!\left(L + \big(\theta - \tfrac{1}{2}\big) \cdot \text{шаг}\right)

где θ — шумовой порог из диапазона [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 бит, чтобы разница читалась сразу:

[ DEMO 02 ]//

Дизер: какой шум чинит полосы

Где дизер живёт в играх

Дизер на выводе — далеко не единственное применение. Тот же приём под именем dither-fade решает задачу плавного исчезновения объектов: вместо настоящей полупрозрачности (дорогой и требующей сортировки) объект рисуется «пунктиром» — часть пикселей отбрасывается по пороговой маске, и доля отброшенных плавно растёт. Так делают LOD-переходы, растворение травы и волос, мягкие края волюметрики. А blue noise к тому же стал стандартом для распределения ошибки в стохастических эффектах — вплоть до трассировки: Heitz и Belcour в 2019 показали, как раскладывать монте-карловский шум по кадру в blue-noise-паттерн, чтобы картинка выглядела чище при том же счётчике сэмплов.

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

// @easy_dev_math

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

Подписаться

Часть 3. Гамма: почему 256 уровней лежат криво

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

Потому что глаз устроен нелинейно, и мы об этом уже говорили: по Веберу заметность перепада зависит от фона. В тёмном глаз различает крошечные перепады яркости, в светлом такие же перепады не видит в упор — в психофизике это обобщается до закона Вебера–Фехнера: воспринимаемая яркость растёт примерно логарифмически по физической. И теперь смотрите, что получается, если раскидать 256 уровней равномерно по физической яркости: в тенях, где глаз придирчив, шаг окажется грубым — и полезут банды; в светах, где глаз снисходителен, уровни лягут с запасом — и пропадут зря. Биты потрачены, качества нет.

Перцептивное кодирование

Решение индустрии: хранить не яркость, а степенную функцию от неё. Кодируем при записи, декодируем при показе:

V=L1/γ(хранение),L=Vγ(показ),γ2.2V = L^{1/\gamma} \quad\text{(хранение)}, \qquad L = V^{\gamma} \quad\text{(показ)}, \qquad \gamma \approx 2.2

Это и есть гамма-коррекция. Степенная кривая отдаёт больше уровней тёмному и меньше светлому — шаги между соседними уровнями выравниваются по восприятию, а не по физике. Те же 8 бит растягиваются под глаз, и 256 градаций хватает на гладкую тень. Приём умный, и я специально подчеркну: гамма — это не наследие «кривых мониторов», а вполне осмысленное сжатие. Канон по теме — Poynton, Gamma FAQ и Digital Video and HD: он прямо называет цель гаммы перцептивным кодированием битов.

Стандартная кривая для дисплеев — sRGB (IEC 61966-2-1). Это не чистая степень: у нуля идёт линейный участок (для численной устойчивости в самых тёмных значениях), дальше — экспонента 2.4, а в среднем кривая близка к степени 2.2:

linear(V)={V12.92,V0.04045(V+0.0551.055)2.4,V>0.04045\text{linear}(V) = \begin{cases} \dfrac{V}{12.92}, & V \le 0.04045 \\[8pt] \left(\dfrac{V + 0.055}{1.055}\right)^{2.4}, & V > 0.04045 \end{cases}

Из этой формулы следует сюрприз, который ломает интуицию у половины разработчиков: 0.5 в sRGB — не половина света. Считаем точно: sRGB-значение 0.5 — это 0.2140 линейной яркости, а настоящая половина света кодируется sRGB-значением 0.7354. Оба числа в статье не с потолка: они вычисляются формулой выше, и в репозитории статьи их проверяет юнит-тест.

Откуда взялась цифра 2.2 историческиОсторожно! Математика!

Красивое совпадение: у ЭЛТ-мониторов зависимость яркости от напряжения сама по себе была степенной, с показателем, близким к обратной sRGB-кривой. Кодирование сигнала обратной степенью решало сразу две задачи — компенсировало кинескоп и удачно ложилось под закон Вебера–Фехнера. ЭЛТ ушли, дисплеи стали линейными по управлению, а кодировка осталась — потому что перцептивная выгода никуда не делась. Подробности и точные показатели — у Poynton в Gamma FAQ.

Так что же такое «грязь в тенях»

Теперь можно разложить симптом из вступления на два механизма.

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

Второй механизм — муть от счёта в неправильном пространстве: если смешивать и усреднять цвета прямо в sRGB-значениях, тёмные тона систематически уезжают. Это отдельная история, и ей посвящена часть 4.

[ DEMO 03 ]//

Гамма: куда легли 256 уровней

Unity-угол: чем лечится бандинг в тёмных сценах

В Unity оба рычага доступны из коробки, и вместе с дизером из части 2 они складываются в рабочий триплет:

  1. Color Space = Linear (Player → Other Settings). Свет и блендинг считаются математически корректно — про это часть 4. В Gamma-режиме смешивание идёт по sRGB-значениям, отсюда пересвеченные и «мутные» блэнды — Unity Manual описывает их как overly-bright blends.
  2. HDR у камеры. Рендер идёт в линейный float-буфер, и теням хватает точности: формат R11G11B10 по ширине равен обычному 8-битному, так что на большинстве платформ это почти бесплатно. Показательный разбор — технота Meta Removing Dark Scene Color Banding in Unity: в VR, где глаза адаптируются к темноте шлема, 8-битного буфера не хватает ровно по описанным выше причинам.
  3. Dithering в пост-обработке. Финальный выход в 8-битный бэкбуфер дизерится — остаточные полосы разбиваются шумом, как в части 2.

Одна оговорка: пункты из техноты Meta приведены как иллюстрация принципа, а не как актуальный рецепт под конкретную версию движка — API пост-обработки в Unity переезжает регулярно, сверяйтесь с манулом своего пайплайна.

// @easy_dev_math

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

Подписаться

Часть 4. Линейное пространство: где сложение цветов врёт

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

Причина в том, что почти вся эта математика — усреднение. Альфа-блендинг, даунсэмпл, мипмапы, resolve мультисэмплинга — всё это взвешенные суммы цветов. А свет в физическом мире аддитивен: освещённость от двух источников — это сумма потоков фотонов, и никакой гамма-кривой фотоны не знают. Значит, складывать и усреднять можно только линейные яркости. Если же движок, старый шейдер или графический редактор берёт два пикселя и усредняет их sRGB-значения — он усредняет точки на степенной кривой, а не количества света. Классический текст об этом — Gritz и d'Eon, The Importance of Being Linear (GPU Gems 3, глава 24).

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

decode ⁣(a+b2)<decode(a)+decode(b)2\operatorname{decode}\!\left(\frac{a + b}{2}\right) < \frac{\operatorname{decode}(a) + \operatorname{decode}(b)}{2}

По сути: смешали два 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: она с шахматкой совпадает.

[ DEMO 04 ]//

Линейное пространство: где среднее врёт

Кстати, исторически на этом тесте валился даже Photoshop: смешивание слоёв по умолчанию шло в gamma-пространстве (сейчас есть переключатель Blend RGB Colors Using Gamma 1.0). Так что если ваш пайплайн где-то усредняет sRGB-значения — вы в большой, хоть и не образцовой компании.

// @easy_dev_math

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

Подписаться

Соберём конвейер целиком

Итак, четыре части — четыре решения, которые кадр проходит по дороге к экрану:

  1. Сколько уровней. 8 бит на канал — 256 градаций; на гладких больших площадях этого мало, и вылезает бандинг (часть 1).
  2. Какой шум. Дизер перед квантованием растворяет полосы; спектр шума выбирается под задачу, и blue noise прячет зерно лучше всего (часть 2).
  3. Куда положить биты. sRGB-кодировка сгущает уровни в тенях, где глаз придирчив; линейное 8-битное хранение отдаёт тени на растерзание бандам (часть 3).
  4. Где считать. Свет складывается в линейном пространстве; усреднение sRGB-значений темнит и грязнит стыки (часть 4).

Финальный интерактив собирает всё в одну сцену: небо, тёмный пол, лампа и клок тумана. Стартовая конфигурация — хак на хаке: математика в gamma-пространстве, линейный 8-битный буфер, без дизера. Доведите картинку до чистой — по опыту частей выше вы уже знаете, какой тумблер что чинит.

[ DEMO 05 ]//

Конвейер кадра: соберите чистую картинку

Это ровно тот же триплет, что в Unity: Color Space = Linear, HDR-буфер у камеры, Dithering в пост-обработке. Теперь за каждой галочкой для вас — конкретная математика, а не магия рендера.

Что осталось за рамками

Разумеется, слой отображения на этом не заканчивается. Мы не тронули тонмаппинг и HDR-стандарты вывода (PQ/HLG), широкие цветовые охваты, 10-битные дисплеи, временной дизер, который меняет паттерн от кадра к кадру, — и ещё десяток нюансов, на которые в реальном продакшене уходит своя неделя разборов. Да и в разобранном материале хватает упрощений: скажем, модель восприятия у меня везде «по Веберу», хотя психофизика яркости заметно богаче. Так что моё изложение — не единственно верное, а рабочая карта местности.

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

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

// @easy_dev_math

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

Подписаться

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