Продолжаем изучение OpenGL: текстуры и skybox

30 сентября 2015

В последней заметке, посвященной OpenGL, мы нарисовали вращающийся разноцветный куб, а также научились управлять камерой при помощи мыши и клавиатуры. Сегодня же мы наконец-то научимся работать с текстурами. Теоретическая часть касательно текстур ранее уже объяснялась в заметке Учимся работать с текстурами в Haskell’евом OpenGL. Поэтому в рамках этой заметки предполагается, что с теорией читатель уже знаком, и основной акцент будет сделан на практике.

Мультисэмплинг

Хотелось бы отметить одно изменение в проекте, не связанное с текстурами. А именно, включенный мультисэмплинг:

glfwWindowHint(GLFW_SAMPLES, 4);

// ...

glEnable(GL_MULTISAMPLE);

Благодаря мультисэмплингу линии на границах примитивов выглядят намного более гладкими. Подробнее о том, как это работает, отлично рассказано здесь и здесь.

Текстуры в «обычных» форматах — JPG, PNG и прочих

Создание и удаление текстур в OpenGL происходит по аналогии с созданием и удалением других сущностей:

// создаем три текстуры
GLuint textureArray[3];
int texturesNum = sizeof(textureArray)/sizeof(textureArray[0]);
glGenTextures(texturesNum, textureArray);

// не забываем освободить их при выходе из скоупа
defer(glDeleteTextures(texturesNum, textureArray));

Тут для нас нет ничего нового, поэтому двигаемся дальше.

Сам по себе OpenGL ничего не знает ни о каких JPG, PNG, TGA или BMP. Все, с чем он умеет работать — это массивы цветов в определенном формате, скажем, RGBA. А значит, декодирование текстур из JPG или иного формата ложится на наши с вами плечи. К счастью, готовых библиотек для решения этой задачи имеется в избытке. Например, есть SOIL (давно не обновлялся и у меня лично он крэшился), SOIL2, DevIL, FreeImage, да и старых-добрый ImageMagick в конце концов. Я лично на данный момент сделал выбор в пользу библиотеки под названием STB. STB находится в public domain (является общественным достоянием, то есть, на использование кода нет вообще никаких ограничений), написана на Си, что позволяет использовать библиотеку в проектах как на Си, так и на C++, поддерживает все распространенные форматы, а также живет на GitHub, что позволяет очень удобно подключать либу к проекту при помощи сабмодулей Git. Наконец, STB имеет очень простой интерфейс и просто работает на всех популярных платформах. Чего еще можно желать?

Пример загрузки текстуры:

int width, height, n; // ширина, высота, байт на пиксель

// читаем файл, при необходимости конвертируем в 3 байта на пиксель
unsigned char *textureData = stbi_load(fname, &width, &height, &n, 3);
if(textureData == nullptr) {
  std::cout << "loadTexture failed, fname = " << fname << std::endl;
  return false;
}

// не забываем освободить память
defer(stbi_image_free(textureData));

Имея на руках декодированную текстуру, можно передать ее OpenGL следующим образом:

glBindTexture(GL_TEXTURE_2D, textureArray[0]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB,
             GL_UNSIGNED_BYTE, textureData);

С подробным описанием процедуры glTexImage2D можно ознакомиться здесь.

UV-координаты

По аналогии с тем, как раньше мы задавали цвет для каждой вершины, при работе с текстурами для каждой вершины мы должны задать соответствующие UV-кординаты на текстуре. В OpenGL координате с U = 0 и V = 0 соответствует нижний левый угол текстуры, а с U = 1 и V = 1 — верхний правый угол. Но есть нюанс.

В популярных графических форматах данные хранятся в «перевернутом» виде, где (0,0) соответствует верхнему левому углу, а (1,1) — нижнему правому. Поэтому нужно сделать одно из двух. Либо переворачивать картинку перед тем, как передавать ее процедуре glTexImage2D, что очень затратно по времени, либо использовать перевернутые координаты. На досуге можете попробовать оба варианта. В частности, исходный код к этой заметке содержит готовую процедуру flipTexture. Однако далее мы будем использовать более эффективный подход с перевернутыми координатами. К счастью, благодаря сишным макросам, этот подход можно использовать, не сломав себе мозг:

#define U(x) (x)
#define V(x) (1.0f - (x))

static const GLfloat globBoxVertexData[] = {
//   X     Y     Z       U        V
// front
     1.0f, 1.0f, 1.0f,   U(1.0f), V(1.0f),
    -1.0f, 1.0f, 1.0f,   U(0.0f), V(1.0f),
     1.0f,-1.0f, 1.0f,   U(1.0f), V(0.0f),

// ....

};

Интересно, что координаты U и V могут выходит за диапазон [0, 1].

OpenGL предлагает 4 способа обработки этой ситуации:

  • GL_CLAMP_TO_BORDER — при выходе за [0, 1] используется заданный цвет;
  • GL_CLAMP_TO_EDGE — при значении > 1 используется 1, а в случае < 0 используется 0;
  • GL_REPEAT — текстура многократно повторяется вдоль оси;
  • GL_MIRRORED_REPEAT — то же самое, только при повторении текстура зеркально отражается;

Выбрать конкретный способ для заданной оси можно при помощи процедуры glTexParameteri:

// в константах вместо букв U и V используются S и T
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

Если не очень понятно, то в этом туториале есть наглядные картинки.

Filtering и mipmapping

Представьте, что в неком примитиве используется текстура размером, скажем, 128x128 текселей. Нам нужно каким-то образом определить цвета пикселей на экране по цвету текселей. Проблема в том, что если камера находится очень далеко от примитива, на один пиксель может приходится сразу очень много текселей. Камера может находится так далеко, что примитив вообще сожмется в один пиксель. И наоборот, при приближении камеры один и тот же тексель может попасть во множество пикселей. Для того, чтобы картинка лучше выглядела при таких вот сценариях, и придумали так называемый filtering.

Допустим, для некого пикселя на экране были определены соответствующие UV-координаты на текстуре. Мы можем сказать OpenGL тупо использовать цвет текселя, который находится по этим координатам:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

Но результат будет так себе. Куда более удачную картинку можно получить, используя взвешенное среднее четырех ближайших к полученным координатам текселей:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

Но, оказывается, этого недостаточно в случаях, когда примитив находится очень далеко от камеры. При движении камеры он будет переливаться разными цветами, так как мы будем постоянно попадать на разные тексели. Решение заключается в использовании так называемых mipmaps. Это последовательность из одной и той же текстуры, только разного размера. В нашем примере это будут текстуры размером 128x128, 64x64, 32x32 и так далее до 1x1. Чем дальше камера от примитива, тем менее детализированная текстура используется, за счет чего и ликвидируется описанный эффект переливания. Можно использовать как заранее подготовленные mipmaps, так и попросить OpenGL сгенерировать их автоматически:

glGenerateMipmap(GL_TEXTURE_2D);

Остается только указать правильный параметр GL_TEXTURE_MIN_FILTER:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
                GL_LINEAR_MIPMAP_LINEAR);

… и дело сделано!

Пробрасываем текстуры в шейдеры

Как вы можете помнить, за раскрашивание примитивов отвечает fragment shader. А сейчас у нас есть только созданная текстура с загруженными в нее данными и параметрами, а также VBO, содержащий координаты вершин в пространстве с UV-координатами на текстуре, притом вперемешку. Явно чего-то не хватает.

Во-первых, нужно разделить координаты вершин и UV-координаты на текстуре по разным массивам атрибутов:

glBindVertexArray(boxVAO);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);

glBindBuffer(GL_ARRAY_BUFFER, boxVBO);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5*sizeof(GLfloat),
                      nullptr);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5*sizeof(GLfloat),
                      (const void*)(3*sizeof(GLfloat)));

Теперь стало понятно, зачем у процедуры glVertexAttribPointer есть аргументы stride и pointer. Вы можете помнить, что в заметке, посвященной VBO, VAO и шейдерам, мы упоминали эти параметры, но не разобрались, для чего они нужны. Так вот, pointer определяет, начиная с какого смещения (в байтах) хранится первый атрибут, а stride — какой шаг (также в байтах) нужно делать для перехода к следующему атрибуту. Благодаря этим параметрам мы можем использовать один VBO для хранения разных типов данных. Пока не могу сказать, насколько это более эффективно, чем использование двух отдельных VBO. Но это точно намного удобнее и компактнее при вбивании координат вершин и UV-координат руками.

Во-вторых, нам понадобится еще одна uniform-переменная, через которую шейдер будет обращаться к текстуре. Определение переменной в коде шейдера будет показано чуть ниже. В коде на С++ мы находим эту переменную как обычно:

GLint samplerId = glGetUniformLocation(programId, "textureSampler");

Связывание uniform переменных с конкретными текстурами осуществляется через так называемые texture unit. Например, так мы говорим, что переменная textureSampler соответствует texture unit с заданным номером:

int boxTextureNum = 0;
// ...
glUniform1i(samplerId, boxTextureNum);

Связывание же текстуры с texture unit производится вызовом glActiveTexture и следующим за ним glBindTexture:

glActiveTexture(GL_TEXTURE0 + boxTextureNum);
glBindTexture(GL_TEXTURE_2D, boxTexture);

В OpenGL по умолчанию всегда активен texture unit с номером ноль. Поэтому, если в шейдере не используется сразу несколько текстур, код может выглядеть как-то так:

glUniform1i(samplerId, 0);

while(/* ... */) { // основной цикл
  // ...

  glBindTexture(GL_TEXTURE_2D, someObjectTexture);
  // рисуем someObject

  glBindTexture(GL_TEXTURE_2D, someOtherObjectTexture);
  // рисуем someOtherObject

  // ...
}

Наконец, код vertex shader:

#version 330 core

layout(location = 0) in vec3 vertexPos;
layout(location = 1) in vec2 vertexUV;

uniform mat4 MVP;

out vec2 UV;

void main() {
  UV = vertexUV;
  gl_Position = MVP * vec4(vertexPos, 1);
}

Как видите, здесь UV-координаты просто пробрасываются далее.

Код fragment shader:

#version 330 core

in vec2 UV;

uniform sampler2D textureSampler;

out vec3 color;

void main() {
  color = texture(textureSampler, UV).rgb;
}

Здесь проброшенные UV-координаты принимаются и используются для получения цвета через переменную textureSampler при помощи функции языка GLSL texture. Как обычно в мире OpenGL, все несколько запутанно, но в целом можно разобраться.

Работа со сжатыми текстурами в формате DDS

Текстуры в форматах вроде JPG или PNG используются в современном OpenGL разве что в порядке исключения. Если вы расскажете специалисту по OpenGL, что все ваши текстуры хранятся в одном из этих форматов, вас, скорее всего, поднимут на смех и посоветуют использовать DDS.

Чтобы понять, что не так с обычными форматами, попробуем шаг за шагом воспроизвести, что делает программа при их использовании. Считывается файл. Затем на CPU долго и мучительно происходит его распаковка. В итоге для текстуры 512x512 при использовании RGBA вы получаете 1 Мб данных. Эти данные вы начинаете пересылать в GPU. Потом вы говорите glGenerateMipmap и GPU как-то генерирует из этих данных mipmaps. Притом делает он это, скорее всего, не очень качественно, так как много времени на генерацию mipmaps GPU потратить не может. Теперь представьте, что текстур у вас очень много. Более того, вся эта последовательность шагов выполняется при каждом запуске программы.

При использовании формата DDS вам не нужно ничего разжимать. Вы берете файл, читаете его заголовок, проставляете соответствующие флаги и потом просто шлете сжатые данные на GPU как есть. GPU в состоянии самостоятельно их распаковать. Притом, делает он это на лету, при обращении к текстуре, а сама текстура так и хранится в сжатом виде. Нам даже не требуется выделять память и считывать в нее файл, ведь можно воспользоваться отображением файла в память (MapViewOfFile в Windows, mmap в nix-системах). Стоит ли говорить, что этот подход куда более эффективен? Кроме того, формат DDS позволяет хранить mipmaps. На их генерацию мы можем потратить хоть два часа, что позволяет использовать сложные алгоритмы и получить более качественный результат. Ну и как небольшой бонус, DDS настолько просто парсить, что не нужно таскать за собой сторонние библиотеки.

В DDS используется сжатие с потерями. Что интересно, в отличие от JPG и прочих, степень сжатия фиксированная. Данные в файле с расширением DDS часто хранятся в одном из следующих форматов:

  • DXT1 — сжатие 8:1, позволяет хранить однобитовый альфа-канал;
  • DXT3 — сжатие 4:1, 4 бита на альфа-канал;
  • DXT5 — сжатие 4:1, альфа-канал кодируется по аналогии с цветом;

DXT5 кодирует альфа-канал намного лучше, чем DXT3, в этом главном образом и заключается различие. Более подробно об этих форматах можно прочитать на gamedev.ru, хабре или вики.

Как и в случае с другими форматами, в DDS используются «перевернутые» координаты. Что в какой-то степени даже удобно. По крайней мере, меньше макросов писать.

Для сохранения текстур в формате DDS я использовал плагин для Gimp. В Ubuntu Linux его можно установить так:

sudo apt-get install gimp-dds

Сначала я хотел найти готовую библиотеку для работы с DDS. Но в IRC сразу несколько человек заверило меня, что использование самописной процедуры для решения этой задачи в мире OpenGL считается нормальной практикой. Более того, мне не смогли порекомендовать кроссплатформенный способ сделать отображение файла в память и посоветовали просто написать несколько версий, да воспользоваться ifdef. Что я, собственно, и сделал. Код получившейся процедуры loadDDSTexture вы найдете в полной версии исходников к этой заметке. То, что касается отображение файлов в память, сильно выходит за рамки этой заметки, и может быть изучено заинтересованными читателям самостоятельно при помощи man и MSDN. Что же касается парсинга заголовка DDS, то там все до безобразия просто и было одолжено мной из реализаций, найденных через Google. Кому интересны детали, может ознакомиться с прилагаемыми исходниками самостоятельно.

Заключение

В ходе изучения работы с текстурами мной была написана такая демка:

Работа с текстурами и skybox на OpenGL

Ящик находится на квадратном острове, покрытым травой. Текстура травы повторяется благодаря GL_REPEAT. Остров вращается вокруг своей оси вместе с ящиком. Небо и солнце натянуты на так называемый skybox. Камера всегда находится в центре огромного куба, на который натянута специальным образом подготовленная текстура. Если бы вместо куба использовалась сфера, это называлось бы skydome.

Как всегда, код был проверен на работу на трех компьютерах с разными GPU, работающими под Linux, Windows и MacOS. Полную версих исходников вы найдете в этом репозитории.

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

Дополнение: Продолжаем изучение OpenGL: работа с моделями

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.