steps3D - Tutorials - Что такое гамма и почему это важно

Что такое гамма и почему это важно

Если мы хотим строить реалистически выглядящие изображения (или корректно обрабатывать изображения), то понимание что такое гамма и гамма-коррекция оказывается очень важным и не всегда разъясняемым. На следующем рисунке есть две области - одна из них заполнена цветом (128,128,128), а другая - шахматным шаблоном из точек цветов (0,0,0) и (255,255,255). В нормированных яркостях (в шейдерах OpenGL) это соответствует оттенкам серого с яркостями 0.5, 0 и 1.

Рис 1. Области со средней яркостью 0.5

Кажется совершенно очевидным что яркости в этих областях должны быть практически одинаковы. Однако легко видно что это не так - они очень заметно отличаются. И причиной этого отличия является гамма и гамма-коррекция.

Иногда рассказ о гамма начинают с рассказа о свойствах мониторов, но я считаю что правильным будет начать его с самого начала - зрительной системы человека. Она формировалась в ходе длительной эволюции и ее базовой задачей было выживание. Именно поэтому зрительная система человека обладает гораздо большей чувствительностью в темных цветах чем в ярких. Это объясняется тем что нужно было в условиях плохого освещения успешно охотиться самим и не стать жертвой других охотников. Просто естественный отбор наиболее приспособленных.

Исследования показали что восприятие яркости нашей зрительной системой существенно нелинейо - оно работает как функция возведения в степень и эта степень примерно равна 2.2. Именно эта нелинейность и позволяет хорошо видеть в условиях плохого освещения.

Рис 2. Тождественная функция y=x, степенная функция y=x2.2 и обратная к ней.

Эта степень равна 2.2 и называется гамма. Если мы теперь перейдем к хранению цифровых изображений, то сразу возникает естественный вопрос - хранить цвета (яркость) в линейном пространстве (т.е. как она есть на самом деле) или же учесть нелинейность человеческого восприятия и хранить уже в нелинейном гамма-пространстве - пространстве воспринимаемой яркости.

Если мы выбираем простейший вариант - хранить в линейном пространстве (например в виде 24-битовых значений), то из-за разной чувствительности глаза человека в темной и светлой областях мы придем к довольно неприятной ситуации. В темной области (где чувствительность высока) нам не будет хватать точности 8 бит на компоненту. А в светлых областях мы наоборот будем иметь избыточную точность.

А вот если мы будем хранить все цвета в гамма-пространстве то мы получим равномерное восприятие яркости и 8 бит нам будет хватать везде. Именно поэтому обычно мы храним изображения подвергнутые гамма-кодированию (gamma-encoding), когда каждая RGB компонента подвергается преобразованию при помощи функции y=x1/2.2.

За счет этого мы получаем равномерное распределение воспринимаемой человеком яркости. И, как правило, изображения хранятся именно в гамма-пространстве.

Обратите внимание, что это не относится ко всем изображениям (текстурам), используемым в компьютерной графике - карты нормалей, высоты, неровности и металличности обычно хранятся в линейном пространстве. А вот текстуры, содержащие цвет поверхности хранятся в гамма пространстве. При этом если у нас в RGBA текстуре в первых трех каналах хранится цвет, а в последнем - неровность, то в гамма пространстве хранятся именно первые три канала, а не четвертый. Ряд форматов хранения изображений позволяют добавить к изображению цветовой профиль, т.е. информацию о том, в каком именно пространстве хранится изображение.

Теперь, когда мы хотим отобразить это изображение на мониторе, то мы просто записываем RGB-байты, задающие цвета, прямо в видеопамять. И мы хотим, чтобы мы увидели цвета так, как они были изначально, т.е. мы ожидаем, что монитор автоматически выполнит перевод изображения из гамма пространства обратно в линейной. Поэтому мониторы обычно выполняют гамма-декодирование, т.е. применяют к компонентам функцию y=x2.2.

Теперь понятно, почему мы воспринимает значение цвета (128,128,128) не как яркость 0.5 - цвет, имеющий воспринимаемую яркость 0.5 вычисляется следующим фрагментом кода:

int ( 255 * pow ( 128/255.0, 1.0 / 2.2 ) )

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

Где это все может пригодиться ? Прежде всего при рендеринге 3D-графики, а точнее при расчете освещенности. Текстуры, задающие цвет материала, содержат значения в гамма-пространстве. Сам расчет освещенности должен производиться в линейном пространстве, но перед выводом его мы должны выполнить преобразование, компенсирующее преобразование монитора. Все преобразование в линейное пространство и из него мы либо выполняем сами, либо используем аппаратную sRGB-поддержку.

Поэтому после чтения цвета из цветовой текстуры (где оно хранится в преобразованном виде) мы должны перевести его обратно в линейное пространство через вызов pow(c.rgb, vec3(gamma)). А после расчета освещенности нам нужно перед записью в фреймбуфер перевести цвет из линейного пространства при помощи вызова pow(c.rgb, vec3(1.0/gamms)).

const float gamma = 2.2;

void main (void)
{
    const vec4  specColor = vec4 ( 1.0 );
    const float specPower = 50.0;

    vec4  decal = texture ( decalMap, tex );
    vec3  n2    = normalize ( n );
    vec3  l2    = normalize ( l );
    vec3  h2    = normalize ( h );
    float diff  = max ( dot ( n2, l2 ), 0.1 );
    float spec  = pow ( max ( dot ( n2, h2 ), 0.0 ), specPower );

    decal.rgb = pow ( decal.rgb, vec3 ( gamma ) );

    color = diff * decal + spec * specColor;

    color.rgb = pow ( color.rgb, vec3 ( 1.0 / gamma ) );    
}

На следующем рисунке приведены рядом два изображения. На изображении слева не был выполнен учет гамма-преобразования, а на изображении справа были выполнены все преобразования в линейное пространство и из него.

Еще одним интересным моментом, связанным с гамма, является учет ослабления света в зависимости от расстояния до источника света. С точки зрения физики эта зависимость имеет вид 1/d2. Однако вместо этого часто используют 1/d мотивируя это тем, что в этом случае получается "более правильное" изображение. Но на самом деле "правильным" она оказывается именно потому, что там не было выполнено гамма-преобразование. Когда мы добавляем правильную обработку гамма, то зависимость 1/d2 начинает выглядеть правильно и реалистично.

На самом деле не обязательно самому выполнять все эти преобразования - практически все производители мониторов, мобильных телефонов и т.п. уже давно поддерживают стандарт sRGB. По закону преобразования он использует кривую очень близкую к возведению в степень 2.2, но в окрестности нуля степенная функция заменяется на линейную. Это позволяет избежать сингулярности у обратной функции. Ниже приводятся формулы для используемого в sRGB прямого и обратного преобразования.

Большим плюсом sRGB является то, что он аппаратно поддерживается современными GPU. Поэтому вместо того, чтобы явно самому выполнять все преобразования между линейным и гамма-пространством при помощи функции pow можно получить это все практически бесплатно и автоматически. Следующая команда выключает автоматическое преобразование цвета при записи в фреймбуфер:

glEnable(GL_FRAMEBUFFER_SRGB);

Для автоматического преобразования при чтении из текстуры были введены два специальных формата - GL_SRGB и GL_SRGB_ALPHA. Обратите внимание, что при использовании четырехкомпонентной RGBA-текстуры преобразованию подвергаются только RGB-компоненты, а альфа-компонента остается неизменной. Ниже приводится функция, загружающая sRGB-текстуру при помощи библиотеки STB (входящей в SOIL).

GLuint  loadSRgbTexture ( const std::string& fileName )
{
    int     width, height, numChannels;
    GLuint  id;
        
    void  * img = stbi_load ( fileName.c_str (), &width, &height, &numChannels, 0 );
        
    if ( img == nullptr )       // error loading
        return 0;       	// cannot load texture

    GLenum fmt    = numChannels == 3 ? GL_RGB   : GL_RGBA;
    GLenum intFmt = numChannels == 3 ? GL_SRGB8 : GL_SRGB8_ALPHA8;
        
				// Use DSA approach to create texture
    glCreateTextures        ( GL_TEXTURE_2D, 1, &id );
    glTextureParameteri     ( id, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR );
    glTextureParameteri     ( id, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
    glTextureStorage2D      ( id, getLevels ( width, height ), intFmt, width, height );
    glTextureSubImage2D     ( id, 0, 0, 0, width, height, fmt, GL_UNSIGNED_BYTE, img );
    glGenerateTextureMipmap ( id );
        
    free ( img );

    return id;
}

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