Deltadev-math.ru
поддержать игру
// биты

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

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

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

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

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

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

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

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

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

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

Часть 1. Бандинг

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

Дело в том, что цвет в кадре хранится побайтно: 8 бит на канал, то есть 2⁸ = 256 уровней яркости, и между соседними уровнями нет ничего. Непрерывная яркость округляется до ближайшего уровня — это квантование. Шаг между уровнями — 1/255: казалось бы, мелочь. Но на плавном градиенте один уровень растягивается на десятки пикселей: получается плоская полоса постоянного цвета, резкий прыжок на следующий уровень — и снова полоса. Это и есть бандинг (banding): видимые ступени на месте плавного перехода, когда уровней яркости не хватает.

Квантование — одной формулойОсторожно! Математика!

Непрерывная яркость Здесь и дальше яркость нормирована: 0 — чёрный, 1 — максимум канала. округляется до ближайшего из 2ⁿ уровней:

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 пикселей.

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

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

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

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

Гамма — одной формулойОсторожно! Математика!
V=L1/γ(хранение),L=Vγ(показ),γ2.2V = L^{1/\gamma} \quad\text{(хранение)}, \qquad L = V^{\gamma} \quad\text{(показ)}, \qquad \gamma \approx 2.2

V — то, что лежит в байтах, L — физическая яркость. Показатель γ ≈ 2.2 подобран под чувствительность зрения.

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

Точная формула sRGB (IEC 61966-2-1)Осторожно! Математика!
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}

Ровно эта формула реализована в src/lib/color/srgb.ts билда статьи.

Из этой кривой следует сюрприз: 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-битному, так что на большинстве платформ это почти бесплатно. Показательный разбор — Removing Dark Scene Color Banding in Unity: в VR, где глаза адаптируются к темноте шлема, 8-битного буфера не хватает ровно по описанным выше причинам.
  3. Dithering в пост-обработке. Финальный выход в 8-битный бэкбуфер дизерится — остаточные полосы разбиваются шумом, как в части 2.
// @easy_dev_math

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

Часть 4. Линейное пространство

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

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

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

Почему именно темнее — неравенство ЙенсенаОсторожно! Математика!

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

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

Слева — свет, который получился из смеси значений; справа — правильная середина по свету. Проверить можно числами из части 3: decode(0.5) = 0.214 — заметно меньше, чем (decode(0) + decode(1)) / 2 = 0.5.

Тот же эффект бьёт по формуле альфа-композитинга Портера–Даффа 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 на выводе (тоже аппаратно)

И сюда же стыкуется дизер из части 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

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

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