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

9 февраля 2016

В крайнем посте, посвященном изучению OpenGL, мы говорили об освещении. Сегодня же мы узнаем, как можно реализовать вывод текста, например, со значением FPS или текущими координатами камеры, «поверх» отрисованной сцены. Вообще, вывод текста — штука очень непростая. В алфавите может быть намного больше 256 символов, если речь идет, например, о китайском языке. Текст может выводиться не только слева направо, но и справа налево или сверху вниз. Не удивительно, что OpenGL, будучи довольно низкоуровневым API, ничего не знает о шрифтах и выводе текста. Думается также, что это одна из вещей, делающих работу локализаторов игр столь волнующей и увлекательной.

Изменения в проекте

Прежде, чем перейти к сути заметки, мне хотелось бы коротко осветить некоторые изменения, что были внесены мною в проект. Если в двух словах — я выкинул C++ и переписал все на чистый C. Тема выбора C вместо C++, пожалуй, заслуживают отдельного большого поста. Пока же хочется отметить лишь следующее.

Библиотеку deferxx, понятное дело, пришлось выкинуть. Я этому очень рад, так как обнаружил в ней проблемы с переносимостью. Какой-нибудь gcc 4.6 просто не в состоянии ее собрать.

Также пришлось выкинуть GLM, заменив его своей собственной библиотекой для работы с матрицами и векторами (см linearalg.c). Этот переход оказался намного проще, чем я думал.

Классы были заменены на обычные struct и процедуры для работы с ними. В плане инкапсуляции все стало значительно лучше, так как private поля больше не торчат наружу и не требуют перекомпиляции половины проекта при их изменении или же применения идиомы pImpl. Последняя становится очень нетривиальной, если хочется, чтобы код всегда работал быстро и правильно, в том числе при копировании объектов.

Для кроссплатформенного получения текущего времени в миллисекундах я поступил так же, как в свое время с отображением файлов в память. Просто написал отдельные реализации под *nix и Windows с ifdef’ами:

#ifdef _WIN32

#include <windows.h>

uint64_t
getCurrentTimeMs()
{
  FILETIME filetime;
  GetSystemTimeAsFileTime(&filetime);

  uint64_t nowWindows = (uint64_t)filetime.dwLowDateTime
    + ((uint64_t)(filetime.dwHighDateTime) << 32ULL);

  uint64_t nowUnix = nowWindows - 116444736000000000ULL;
  return nowUnix / 10000ULL;
}

#else // Linux, MacOS, etc

#include <sys/time.h>

uint64_t
getCurrentTimeMs()
{
  struct timeval tv;
  gettimeofday(&tv, NULL);

  return ((uint64_t)tv.tv_sec) * 1000 + ((uint64_t)tv.tv_usec) / 1000;
}

#endif

Для сравнения, до этого приходилось писать что-то вроде:

#include <chrono>
auto startTime = std::chrono::high_resolution_clock::now();
// ...
auto currentTime = std::chrono::high_resolution_clock::now();
float startDeltaTimeMs = std::chrono::duration_cast<
  std::chrono::milliseconds>(currentTime - startTime).count();

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

Были еще опасения, что от vector так просто отказаться не удастся. Однако они не подтвердились. В одном случае требуемый размер вектора был точно известен, потому он был очень просто заменен на массив. Во втором случае размер был переменным, но можно было без труда прикинуть его максимальное возможное значение. В итоге вектор также был заменен на массив и счетчик текущего числа элементов.

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

Суть идеи

Итак, вывод текста. Спрашивается, как его реализовать, если OpenGL ничего не знает ни о каком тексте или шрифтах? Как вы могли догадаться, решение заключается в использовании текстур. Например, берется квадратная текстура, на ней располагаются все допустимые буквы, скажем, в 16 столбцов и 16 строк, всего 256. При выводе текста вычисляются координаты букв на текстуре. Эти координаты затем используются при отрисовке треугольников, в результате пользователь видит на экране буквы! Если мы хотим больше 256 букв, можно использовать текстуру побольше, или разные текстуры для разных кодировок. Заметьте, что на текстуре мы можем изобразить не только обычные буквы, но и смайлики, буквы клингонского алфавита, словом, все, на что хватит фантазии.

Реализация

В Gimp мной была создана такая текстура:

Текстура шрифта

Это изображение 512x512, на котором изображены все ASCII символы начиная с пробела и заканчивая тильдой. Так как на текстуре оставалось еще место, я также заполнил ее некоторыми спецсимволами, а также некоторыми буквами греческого алфавита. Символы расположены в 16 столбцов и 8 (на самом деле только 7 — см далее) строк. Я использовал моноширинный шрифт Ubuntu Mono размером 64 попугая с антиалиасингом. Как я подсчитал позже, каждому символу соответствует прямоугольник шириной 32 пикселя и высотой 65 пикселей. Очевидно, что при такой высоте символа можно уместить только 7 полных строк символов, поэтому восьмая строка заполнена скорее для отладки, чем реального использования. Текстура была экспортирована в уже знакомый нам формат DXT1 с вычисленными заранее mipmaps.

Чтобы выводить что-то «поверх» всей остальной сцены, нам понадобится использовать отдельный набор шейдеров, а следовательно и отдельную программу (речь о программе, что является аргументом glUseProgram).

Так выглядит vertex shader:

#version 330 core

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

out vec2 fragmentUV;

void main() {
  fragmentUV = vertexUV;

    gl_Position.x = vertexPos.x*2 - 1;
    gl_Position.y = vertexPos.y*2 - 1;
    gl_Position.z = -1.0;
    gl_Position.w =  1.0;
}

А так — fragment shader:

#version 330 core

in vec2 fragmentUV;

out vec4 color;

uniform sampler2D textureSampler;

void main() {
    color = texture(textureSampler, fragmentUV);
    color = vec4(1, 1, 1, color.a);
}

Vertex shader делает незамысловатые преобразования координат, позволяющие нам работать в пространстве, где (0,0) соответствует левому нижнему углу экрана, а (1,1) — верхнему правому.

Fragment shader берет из текстуры альфа-канал и выводит текст белого цвета с соответствующей прозрачностью. На практике использовать всего лишь один канал в текстуре, пожалуй, слишком расточительно. Однако ничто не мешает, например, упаковать в одну текстуру сразу четыре шрифта. А то и больше, учитывая, что нам может быть вполне достаточно не сотен, а всего лишь десятка степеней прозрачности.

Чтобы прозрачность работала, нам нужно включить blending:

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

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

В остальном, по большому счету, ничего нового. Перед входом в основной цикл готовим VAO и VBO:

glBindBuffer(GL_ARRAY_BUFFER, fontVBO);
glBindVertexArray(fontVAO);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat),
                      NULL);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(GLfloat),
                      (const void*)(2*sizeof(GLfloat)));

Сам VBO заполняем в основном цикле при помощи не особо замысловатого кода:

inline static float
fontTextureCoordULeft(char c)
{
  int colNum = ((int)(c - ' ')) % FONT_TEXTURE_LETTER_NUM_IN_ROW;
  float coord = (float)colNum * FONT_TEXTURE_LETTER_WIDTH_PX
                  / FONT_TEXTURE_SIZE_PX;
  return coord + FONT_TEXTURE_COORD_DELTA;
}

// аналогично:
// * fontTextureCoordURight
// * fontTextureCoordVTop
// * fontTextureCoordVBottom

static void
calculateStatusLineBufferData(const char* text, GLuint fontVBO)
{
  unsigned int pos = 0;
  char c;
  for(;;)
  {
    c = text[pos];
    if(c == '\0')
      break;

    float uLeft = fontTextureCoordULeft(c);
    float uRight = fontTextureCoordURight(c);
    float vTop = fontTextureCoordVTop(c);
    float vBottom = fontTextureCoordVBottom(c);
    float x1 = (FONT_RENDER_SIZE/2)*pos;
    float x2 = (FONT_RENDER_SIZE/2)*pos + (FONT_RENDER_SIZE/2);

    // Triangle 1: 3 * X, Y, U, V

    globStatusLineBufferData[pos*6*4 + 0*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 0*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 0*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 0*4 + 3] = vBottom;

    globStatusLineBufferData[pos*6*4 + 1*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 1*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 1*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 1*4 + 3] = vBottom;

    globStatusLineBufferData[pos*6*4 + 2*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 2*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 2*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 2*4 + 3] = vTop;

    // Triangle 2: 3 * X, Y, U, V

    globStatusLineBufferData[pos*6*4 + 3*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 3*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 3*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 3*4 + 3] = vTop;

    globStatusLineBufferData[pos*6*4 + 4*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 4*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 4*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 4*4 + 3] = vTop;

    globStatusLineBufferData[pos*6*4 + 5*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 5*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 5*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 5*4 + 3] = vBottom;

    pos++;
  }

  globStatusLineVerticesNumber = pos*6;

  glBindBuffer(GL_ARRAY_BUFFER, fontVBO);
  glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat)*6*4*pos,
    globStatusLineBufferData, GL_DYNAMIC_DRAW);
}

И, наконец, в основном цикле после рендеринга всей сцены выводим текст:

glUseProgram(resources->fontProgramId);
glBindTexture(GL_TEXTURE_2D, fontTexture);
glBindVertexArray(fontVAO);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glDrawArrays(GL_TRIANGLES, 0, globStatusLineVerticesNumber);

Заметьте, что при рендеринге текста важно не забыть про включенный face culling. Чтобы треугольники были видны, они должны быть «закручены» против часовой стрелки. Подробности про face culling рассказывалось в заметке Продолжаем изучение OpenGL: управление камерой при помощи мыши и клавиатуры.

Обратите также внимание, что внезапно оказалось очень удобно использовать перевернутые UV-координаты, где точка (0,0) соответствует верхнему левому углу текстуры, а не нижнему левому. Подробнее про UV-координаты и почему они перевернутые рассказывалось в заметке Продолжаем изучение OpenGL: текстуры и skybox.

Окончательный результат выглядит приблизительно так:

Вывод текста на OpenGL

Довольно много времени у меня ушло на борьбу с артефактами при отображении букв. Часто можно было рядом с буквой увидеть маааленький кусочек соседней по текстуре буквы. Решал я эту проблему так. Во-первых, при экспорте текстуры в DDS поставил Mipmap Filter в Nearest. чтобы ни из-за какого размытия не брались части соседних букв. Во-вторых, подобрал небольшую дельту, корректирующую границы каждой буквы не текстуре. Заметьте, что к top значение надо прибавлять, а из bottom вычитать, так как мы решили работать с текстурой шрифта в перевернутых UV-координатах.

Замечание по поводу координат

Не совсем очевидно, почему выводимый текст никогда не пересекает других объектов на сцене. Также возникает вопрос, почему при выводе текста в vertex shader используется координата Z, равная -1, хотя нам известно, что в OpenGL ось Z направлена по направлению к камере. Казалось бы, чем больше Z, тем ближе объект должен быть к камере, разве не так? Лично у меня были большие трудности с пониманием этого момента. Я спрашивал и в #OpenGL на FreeNode, и на StackExchange. В итоге быстрее всех на вопрос мне ответил @sum3rman.

В контексте OpenGL, говоря «координаты», мы можем иметь в виду координаты в одной из следующих систем координат (насколько я смог разобраться):

  • Model (или object, или local) space — это координаты относительно конкретной модели. В этих координатах мы работаем, например, редактируя модель в Blender;
  • World space — то, что получается, после умножения координат вершин модели на матрицу M. То есть, положение модели в мире;
  • View (или eye) space — координаты, получаемые после умножения мировых координат на матрицу V. Это координаты по отношению к камере;
  • Normalized Device Coordinate (NDC) space — координаты, получаемые после умножения видовых координат на матрицу P. Все, что мы можем разглядеть при текущей перспективе, сжимается в куб со значениями X, Y и Z между -1 и 1;
  • Clip space — это тот же NDC space, в котором обрезаны полигоны, не попавшие в куб;
  • Window (или screen) space — это отображение X и Y координат из clip space в координаты пикселей на экране. Ось Z используется для тестирования глубины;

А теперь самое главное. Model, world и view координаты являются right-handed, то есть, ось Z в них направлена к камере. Однако NDC, clip и window space является left-handed, то есть ось Z направлена уже от камеры. Это легко проверить, умножив произвольный вектор на матрицу projection и посмотрев, что станет с его координатой Z. Таким образом, на вход vertex shader получает right-handed координаты. Также обычно он получает через uniform-переменную матрицу MVP. После умножения координат вершины на матрицу MVP мы получаем координаты в NDC space, которые являются left-handed. OpenGL ожидает такого выхода от vertex shader, поэтому все работает просто замечательно.

Теперь мы понимаем, почему в vertex shader, используемом для вывода текста, используется координата Z, равная -1. От шейдера ожидается выход в NDC space, а в нем чем меньше Z, тем ближе объект к камере. По этой же причине ничто не может пересечь выводимый текст, так как вся сцена на выходе у вершинных шейдеров сжата в куб со значениями X, Y и Z между -1 и 1.

Как альтернативный вариант, перед выводом текста можно делать glDisable(GL_DEPTH_TEST), а после — glEnable(GL_DEPTH_TEST). Это тоже работает и значение координаты Z в этом случае не имеет значения.

Замечание по поводу VBO indexing

В заметке Продолжаем изучение OpenGL: работа с моделями мы познакомились с техникой VBO indexing. Несложные расчеты говорят нам, что использование ее при рендеринге теста приведет к экономии памяти даже если хранить индексы в int’ах, и уж тем более — если в short’ах. Соответственно, время на передачу данных от CPU к GPU также сократится.

Следует однако учиывать, при переписывании кода на использование VBO indexing код может стать как быстрее, так и быстрее ассимптотически, но более медленным на практике просто из-за усложнения алгоритма. По этой причине я лично решил оставить код как есть, по крайней мере, до тех пор, пока мы в него не упираемся. В прошлый раз мы использовали VBO indexing для сжатия моделей. Выгода была очевидна, так как экономилось место на диске и уменьшалось время чтения с него. Здесь же мы производим вычисления и пересылаем новые данные в GPU на лету, притом, всего лишь раз в 200 мс.

Если желаете, можете реализовать VBO indexing самостоятельно в качестве упражнения. В качестве упражнения со звездочкой можете реализовать вычисление новых данных для VBO в отдельном потоке.

Заключение

Здесь мы только выводили строку, возможные значения которой более-менее известны заранее. Когда у вас появляется пользовательский ввод с юникодом, все становится намного интереснее. Заметьте также, что здесь мы работали с моноширинным шрифтом. Работа с пропорциональными шрифтами несколько более интересна, так как для каждого символа потребуется определить его ширину, где-то как-то ее хранить, а также учитывать при выводе текста.

В целом решение этих проблем заключается в том, чтобы генерировать текстуры на лету из шрифтов, например, при помощи библиотеки FreeType, а в остальном все будет работать точно так же. В IRC предлагают даже рендерить FreeType’ом целые слова, а не буквы, мол при использовании пропорциональных шрифтов так удобнее. Но использование FreeType — это уже тема для отдельной заметки.

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

Метки: , .


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